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.
macOS: --use-mock-keychain (no keychain prompt per CfT build), depth-counted capture guard, tools/rust-build-macos.sh, mac manifest 2.0.729 #5
Rebased on the page repo at 2.0.729 (your #![cfg(unix)] and process_group(0) fixes are untouched). Three small things, all macOS-only:
--use-mock-keychainon every Chrome launch (macOS). Chromium's OSCrypt otherwise creates a "Chromium Safe Storage" item in the user's login keychain to encrypt cookies, and the keychain ACL is per binary, so every new Chrome-for-Testing build pops "Google Chrome for Testing wants to use your confidential information stored in 'Chromium Safe Storage'" (Kyle's Mac, 2026-10-20, on apup_open_window). pup's profiles are isolated throwaways, so an in-memory key is right; the mock key is constant across launches, so cookies still persist between sessions.pup_browser::platform_switches()(empty on Windows) is appended at the four Rust launch sites and the recorder HUD;server.jsgets the same flag darwin-only at both launch sites. One-time cost on an existing Mac profile: cookies written under the real keychain key are unreadable once (one re-login).- Depth-counted capture guard (your note on #4):
capture_unhidden()is now(depth, apps); the outermostcapture_frames_beginunhides, inner calls only bump the count, and the lastcapture_frames_endre-hides. Two concurrent captures no longer overwrite each other's list. tools/rust-build-macos.sh, finally in the diff: gen-assets + borders + workspace tests, universal aarch64/x86_64 release build, Developer ID codesign (hardened runtime), the zip with the 0755 entrypoint +bridge.json+BRIDGE_VERSION, and the staged macOS manifest.tools/rust-build-macos.sh <ver> [--no-sign]. Yourrust-build.shis untouched.
Also included: adom-bridge-puppeteer-manifest-macos.json pointing at adom-bridge-puppeteer-macos-v2.0.729.zip (sha256 17f16843β¦, 8427128 bytes), built from this tree. Kyle uploads that zip as the macos asset of release 2.0.729; the Windows manifest is untouched.
Gates on the Mac: workspace tests green, check-borders.sh OK, the 2.0.729 mac binary runs under Kyle's Bridge 1.9.219 (readiness, open, eval, screenshot, raise, lower, close).
π€ Generated with Claude Code
Diff Skip to comments (1)
@@ -1,825 +1,842 @@⋯ 508 unchanged lines ⋯ static STOCK_FLAGS: AtomicBool = AtomicBool::new(false); pub fn set_stock_flags(on: bool) { STOCK_FLAGS.store(on, Ordering::SeqCst); } +/// 2.0.720: switches a platform needs so Chrome never raises an OS prompt of its own. macOS: Chromium's+/// OSCrypt otherwise creates/reads a "Chromium Safe Storage" item in the user's LOGIN KEYCHAIN to encrypt+/// cookies, and the keychain ACL is per BINARY, so EVERY new Chrome-for-Testing build pops "Google Chrome+/// for Testing wants to use your confidential informationβ¦" (Kyle, 2026-10-20, via H). pup's profiles are+/// isolated throwaways, so an in-memory key is right; the mock keychain's key is constant across launches,+/// so cookies written under it still persist. Cookies encrypted under a real keychain key before this flag+/// are unreadable once (one re-login). Windows/Linux: nothing (DPAPI / basic store prompt nothing).+pub fn platform_switches() -> Vec<String> {+ if cfg!(target_os = "macos") { vec!["--use-mock-keychain".into()] } else { vec![] }+}+ /// The switches every pup launch carries (server.js v2.0.293 merged form: ONE --disable-features). pub fn chrome_switches(profile_dir: &Path, high_fps: bool, insecure_dev: bool, extensions: &[PathBuf]) -> Vec<String> { if STOCK_FLAGS.load(Ordering::SeqCst) {⋯ 11 unchanged lines ⋯ // Preferences so pre-acknowledging is impossible. Keep ONLY pup's own extensions enabled. v.push(format!("--disable-extensions-except={}", extensions.iter().map(|p| p.to_string_lossy().to_string()).collect::<Vec<_>>().join(","))); } v.push("--no-startup-window".into());+ v.extend(platform_switches()); return v; } let mut v: Vec<String> = vec![⋯ 31 unchanged lines ⋯ // birthed an extra untracked about:blank window that pup never stamped: a stock Chrome for Testing // button with the attention tint on the taskbar (John, 2026-09-06). Never pass a URL here. v.push("--no-startup-window".into());+ v.extend(platform_switches()); v } ⋯ 125 unchanged lines ⋯ // (the CDP lane's note said "Never pass a URL here"), and the cold lane was launched with "about:blank": // that made an unowned about:blank window that sat ON TOP for 18 s until the orphan sweep closed it, // on every cold start (measured, 2.0.631).+ args.extend(platform_switches()); if !url.is_empty() { args.push(url.to_string()); } return host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind)); }⋯ 43 unchanged lines ⋯ // 2.0.633: same rule as the stock branch above (2.0.632 only patched that one; this is the DEFAULT branch). // An empty argument was handed to Chrome as "" and it opened its working directory as a file listing, // on top of the user, for the whole session (measured on ConfRoomROG: "Index of C:\Users\john\.cache\puppeteer\").+ args.extend(platform_switches()); if !url.is_empty() { args.push(url.to_string()); } host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| format!("{}: {e}", cand.kind)) }⋯ 74 unchanged lines ⋯ let _ = fs::remove_dir_all(&tmp); } }++@@ -1,351 +1,354 @@⋯ 274 unchanged lines ⋯ let url = format!("file:///{}?reason={}", html.to_string_lossy().replace('\\', "/"), reason.bytes().map(|b| if b.is_ascii_alphanumeric() { (b as char).to_string() } else { format!("%{b:02X}") }).collect::<String>()); let port_file = profile_dir.join("DevToolsActivePort"); let _ = std::fs::remove_file(&port_file); let args = vec![format!("--user-data-dir={}", profile_dir.display()), "--remote-debugging-port=0".into(), format!("--app={url}"), "--window-size=420,260".into(), "--use-fake-ui-for-media-stream".into(), "--auto-select-desktop-capture-source=Entire screen".into(), "--enable-features=GetDisplayMediaSet".into(), "--auto-accept-this-tab-capture".into(), "--no-first-run".into(), "--no-default-browser-check".into(), "--disable-features=CalculateNativeWinOcclusion,MediaRouter".into()];+ let args = [args, pup_browser::platform_switches()].concat(); let pid = ctx.host.spawn_detached(&cand.executable_path, &args, None).map_err(|e| err("recorder_launch_failed", e.to_string(), None))?; let t0 = Instant::now(); let mut port = None; while t0.elapsed() < Duration::from_secs(20) { if let Ok(s) = std::fs::read_to_string(&port_file) { if let Some(p) = s.lines().next().and_then(|l| l.trim().parse::<u16>().ok()) { port = Some(p); break; } } std::thread::sleep(Duration::from_millis(200)); }⋯ 68 unchanged lines ⋯ let summary = json!({ "clipCount": r.clips.len(), "totalSizeKB": r.clips.iter().map(|c| c["sizeKB"].as_u64().unwrap_or(0)).sum::<u64>(), "reason": r.reason, "clips": r.clips }); ok(json!({ "success": true, "_hint": SCREEN_HINT, "output": json!({ "ok": true, "sessionSummary": summary }).to_string() })) }++@@ -1,747 +1,757 @@⋯ 298 unchanged lines ⋯ } /// Apps capture_frames_begin unhid, for capture_frames_end to park again.-fn capture_unhidden() -> &'static Mutex<Vec<u32>> {- static U: std::sync::OnceLock<Mutex<Vec<u32>>> = std::sync::OnceLock::new();- U.get_or_init(|| Mutex::new(Vec::new()))+// Depth-counted so two concurrent captures share one unhide/re-hide (John's review on PR #4): the apps list+// is set by the OUTERMOST begin and re-hidden by the LAST end; inner calls only bump the count.+fn capture_unhidden() -> &'static Mutex<(usize, Vec<u32>)> {+ static G: std::sync::OnceLock<Mutex<(usize, Vec<u32>)>> = std::sync::OnceLock::new();+ G.get_or_init(|| Mutex::new((0, Vec::new()))) } /// Is the whole app that owns `pid` hidden (Cmd+H state)? None when the pid has no app.⋯ 93 unchanged lines ⋯ // again in capture_frames_end. The user's own Chrome is never touched (pup-profiles marker), and an app the // user has frontmost is left alone in both directions. fn capture_frames_begin(&self) -> bool {+ let mut g = capture_unhidden().lock().unwrap();+ if g.0 > 0 { g.0 += 1; return !g.1.is_empty(); } // a capture is already holding the apps unhidden let mut apps: Vec<u32> = Vec::new(); for (pid, _) in self.pup_browser_processes() { if app_hidden(pid) == Some(true) && !app_is_frontmost(pid) && !apps.contains(&pid) { apps.push(pid); } }+ g.0 = 1; if apps.is_empty() { return false; } for pid in &apps { imp::set_app_hidden(*pid, false); } // one compositor turn so the first capture after the unhide finds a frame std::thread::sleep(std::time::Duration::from_millis(150));- *capture_unhidden().lock().unwrap() = apps;+ g.1 = apps; true } fn capture_frames_end(&self) {- let apps = std::mem::take(&mut *capture_unhidden().lock().unwrap());+ let mut g = capture_unhidden().lock().unwrap();+ g.0 = g.0.saturating_sub(1);+ if g.0 > 0 { return; }+ let apps = std::mem::take(&mut g.1); for pid in apps { if !app_is_frontmost(pid) { imp::set_app_hidden(pid, true); } } } // Windows: FlashWindowEx(FLASHW_STOP). No taskbar flash on macOS; nothing to stop.⋯ 325 unchanged lines ⋯ assert!(h.secret_get(svc, acct).is_none()); } }++@@ -1,21996 +1,22000 @@⋯ 10477 unchanged lines ⋯ '--start-maximized', '--remote-debugging-port=0', // Suppress "Sign in to Chromium" popup and first-run dialogs '--no-first-run', '--no-default-browser-check', '--disable-default-apps',- '--disable-sync', '--disable-background-networking',- '--disable-infobars',- // v2.0.57 (John: the "Chromium now launches when Windows starts" promo infobar on fresh- // profiles is hideous): layered suppression. Unknown --disable-features names are ignored- // harmlessly, so we name every plausible id for the launch-on-startup promo; and- // --disable-field-trial-config turns off the experiment config that gates promos like it,- // which is also the determinism WHY-CFT promises (same browser on every machine).- '--disable-field-trial-config',- // v2.0.120 (John got a Windows Firewall/UAC prompt when his payroll thread opened a Gusto- // window): Chrome's Cast/MediaRouter feature binds an mDNS listener on 0.0.0.0:5353 β a- // network-exposed socket, which is exactly what makes Windows Firewall prompt (and it prompts- // per-new-exe, so every CfT version bump re-triggers it). pup is an automation browser that- // never casts, so disable MediaRouter + the DIAL/mDNS route providers: Chrome then binds only- // loopback and NO firewall prompt is ever shown. --disable-background-networking (above) alone- // does not cover this; MediaRouter's mDNS is separate.- '--disable-features=OptimizationGuideModelDownloading,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,TextSafetyClassifier,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider',- '--media-router=0',- // v2.0.393 (John saw the Windows Firewall prompt again on winvm, 2026-09-04, on a freshly- // downloaded CfT): MediaRouter was not the only non-loopback bind. A 40s socket sampler while- // DigiKey + TI loaded caught five transient UDP sockets on 0.0.0.0 (WebRTC ICE candidates and- // QUIC/HTTP-3 client sockets). Windows prompts on the FIRST such bind of a new exe, and every- // CfT download is a new exe. pup never needs inbound or peer-to-peer: no QUIC (TCP does not- // prompt), WebRTC only through a proxy (no raw UDP bind), and no WebRTC mDNS responder.- '--disable-quic',- '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',- '--webrtc-ip-handling-policy=disable_non_proxied_udp',- // v2.0.394: the QUIC/WebRTC flags above did NOT stop the binds (measured: six paired v4/v6- // UDP sockets on 0.0.0.0 / :: while pages loaded, on a Chrome that carried every flag). The- // pattern is Chrome's BUILT-IN async DNS client, which opens its own UDP sockets per query- // instead of using the OS resolver - and a bound UDP socket is what Windows Firewall prompts- // on. Hand DNS back to the OS resolver: Chrome then opens no UDP socket at all.- // v2.0.395: AsyncDns off was still not enough on DigiKey (three paired v4/v6 UDP sockets during- // the load, none on TI). Chrome issues HTTPS-type (SVCB) DNS queries per host for HTTP/3 + ECH- // discovery through its OWN DnsClient even when the A/AAAA path uses the OS resolver. Off too.- '--disable-features=WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello',- '--disable-async-dns',- '--ash-no-nudges',- // ADOM-YOU VISUAL IDENTITY (John, 2026-08-06). A custom logo image on the- // Chrome profile chip is impossible for a local non-signed-in profile- // (faking the gaia picture shows a gray generic + a permanent "Action- // required" nag β tested, see brandPupProfile / pup-profile-identity). The- // reliable, always-visible, no-nag signal is an autogenerated theme: this- // flag tints the frame + toolbar Adom-teal so EVERY adom-you window is- // unmistakably an Adom window at a glance. Only the durable shared jar gets- // it (John's pick); isolated throwaways stay plain Chrome. The flag installs- // the theme INTO the profile, so it persists across later launches too.- // v1.9.345 (John): frame color is the Adom near-black ground #0d1117 (RGB 13,17,23), NOT the old- // bright teal (20,184,184) β "the teal is a bit ugly; something closer to black but within the Adom- // brand". This dark-slate frame color-codes pup windows as the adom-you doppelganger's own windows,- // distinct from the user's real Chrome profiles, without a garish tint.- ...(profileName === DEFAULT_PROFILE ? ['--install-autogenerated-theme=13,17,23'] : []),- // #435 β CRASH-RESTORE SUPPRESSION. pup profiles are AI-driven and never want Chrome's "Restore- // pages? Chrome didn't shut down correctly" flow. After a shared-Chrome crash it reopens the DEAD- // session's tabs (URL dupes + about:blank) into whatever window relaunches, and pup then adopts- // them into the WRONG live session (John: "why are there about:blank tabs in my review window?").- // prepareCleanProfile() resets exit_type=Normal + wipes the session blobs before a FRESH launch,- // but can't run on the RECONNECT relaunch path (Chrome already up). These flags kill the crash- // bubble/restore on EVERY launch path (they flow into the detached chromeArgs via the launchOpts- // .args spread) so there are no stray restored tabs to misroute in the first place.- '--disable-session-crashed-bubble', '--hide-crash-restore-bubble',- // The "controlled by automated test software" infobar is keyed on the PRESENCE of- // --enable-automation (stripped via ignoreDefaultArgs; never pass it). With the- // unsupported flags gone from default launches (v1.9.129) there is no warning bar- // left to hush, so --test-type is only added on the insecureDev opt-in above.- // v1.9.159 (John saw the yellow bar): --disable-blink-features=AutomationControlled REMOVED.- // It was the LAST warning-triggering flag after 1.9.129 (whose --test-type removal un-hushed- // the 'unsupported command-line flag' infobar), so every window wore the banner. It is also- // redundant: the AutomationControlled blink feature only activates via --enable-automation,- // which pup strips (ignoreDefaultArgs) and never passes β navigator.webdriver is false- // without it (verified live before removal).- // Tab recording uses CDP Page.startScreencast piped to ffmpeg β see- // tabRecordStartImpl below. CDP screencast is scoped to a specific- // page target at the protocol level, so cross-tab/cross-Chrome- // leakage is impossible. The flags below keep the page rendering at- // full rate even when occluded, so screencast frames stream- // continuously instead of being paint-throttled to ~0 fps:- // --disable-renderer-backgrounding: keep rendering when in background tab- // --disable-backgrounding-occluded-windows: keep rendering when window is occluded- // --disable-background-timer-throttling: keep timers/RAF firing in background- // --disable-features=CalculateNativeWinOcclusion:- // skip the occlusion-detection probe- // Without these, recording a tab while the user is in another app- // produces a 1-2 fps video.- //- // v1.9.76 β THESE ARE NOW OPT-IN (highFps). They were applied to EVERY window,- // forever, which is why an idle pup window could never enter Chrome's low-power- // state: we had disabled all four of the mechanisms that let it. Three idle windows- // held a Modern Standby laptop out of deep idle overnight and flattened the battery- // (wiki issue #14). A NORMAL Chrome window does not do this because it throttles when- // occluded β ours structurally could not. Default is now Chrome's own power behaviour;- // callers that genuinely need continuous frames ask for highFps and get a LEASE.- // v1.9.198 β ALWAYS disable Chrome's NATIVE WINDOWS OCCLUSION CALCULATION. This is the single- // targeted cure for the bug behind John's "can't scroll", "frozen", and the Bills thread's- // silently-failing typing: Chrome's Win32 occlusion detector can latch a window as OCCLUDED and- // never re-evaluate it. A window in that state reports visibilityState:'hidden' even while it is- // FRONTMOST, and Chrome then withholds BOTH paint and INPUT β measured directly: pup_eval- // DOM writes still land (main thread runs) while CDP key events vanish (compositor wedged). Only- // a real OS click cleared it, which is why pup windows felt broken in a way no verb could fix.- // pup windows are parked at z-bottom BY DESIGN, so they hit this detector constantly β far more- // than a normal Chrome window ever would. This flag disables only the occlusion CALCULATION; the- // three anti-throttle flags below stay opt-in (highFps), so ordinary background throttling β and- // the battery saving from issue #14 β is untouched.- '--disable-features=CalculateNativeWinOcclusion',- ...(highFps ? [- '--disable-renderer-backgrounding',- '--disable-backgrounding-occluded-windows',- '--disable-background-timer-throttling',- ] : []),- ],- ignoreDefaultArgs: ['--enable-automation'],- // Let Chrome survive if the bridge process dies β enables reconnect on restart- handleSIGINT: false,- handleSIGTERM: false,- handleSIGHUP: false,- pipe: false,- };-- // nativeSite mode: strip flags that break complex Google sites (GCP Console,- // Google Workspace admin, etc.). These sites rely on background networking,- // CORS enforcement, and Chrome sync for org/billing context.- if (nativeSite) {- const stripFlags = new Set(['--disable-background-networking', '--disable-sync', '--disable-web-security']);- launchOpts.args = launchOpts.args.filter(a => !stripFlags.has(a));- console.log(`nativeSite mode: stripped ${stripFlags.size} flags for real-site compatibility`);- }- // v2.0.293: collapse duplicate feature flags for the puppeteer.launch path too (the detached- // Windows path merges chromeArgs at its own assembly). See mergeChromiumFeatureFlags above.- launchOpts.args = mergeChromiumFeatureFlags(launchOpts.args);-- let userDataDir = null;- if (!freshProfile) {- userDataDir = path.join(PROFILES_DIR, profileName);- fs.mkdirSync(userDataDir, { recursive: true });- launchOpts.userDataDir = userDataDir;- console.log(`Browser for profile "${profileName}": persistent at ${userDataDir}`);- } else {- console.log(`Browser for profile "${profileName}": fresh temporary profile`);- }-- // Try to reconnect to an existing Chrome first (e.g. after bridge restart).- // After laptop sleep/wake, fetch() can hang indefinitely on dead sockets,- // so we apply a 3s timeout via AbortController β failure falls through- // to the launch path (which has its own retry + orphan cleanup).- let browser;- if (userDataDir) {- const portFile = path.join(userDataDir, 'DevToolsActivePort');- try {- const portData = fs.readFileSync(portFile, 'utf8').trim();- const port = parseInt(portData.split('\n')[0], 10);- if (port && !isNaN(port)) {- const ac = new AbortController();- const tid = setTimeout(() => ac.abort(), 3000);- let resp = null;- try {- resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });- } catch (e) {- // timed out, refused, or TCP stack hung after sleep β ignore- console.log(`Reconnect probe to :${port} failed (${e.message}) β will relaunch`);- } finally {- clearTimeout(tid);- }- if (resp && resp.ok) {- // Also gate puppeteer.connect with a timeout β it can hang too- try {- browser = await Promise.race([- puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }),- new Promise((_, rej) => setTimeout(() => rej(new Error('puppeteer.connect timeout')), 5000)),- ]);- browser._cdpPort = port;- const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);- if (sFiles.length > 0) browser._detachedPid = sFiles[0].pid;- console.log(`Reconnected to existing Chrome on port ${port} for profile "${profileName}"`);- } catch (e) {- console.log(`puppeteer.connect to :${port} failed (${e.message}) β will relaunch`);- browser = null;- }- }- }- } catch { /* DevToolsActivePort doesn't exist or is stale β launch fresh */ }- }-- if (!browser) {- // Pre-launch cleanup: kill any orphaned Chrome holding this profile's lock- if (userDataDir) {- const lockFile = path.join(userDataDir, 'SingletonLock');- const lockExists = fs.existsSync(lockFile) || fs.existsSync(path.join(userDataDir, 'lockfile'));- if (lockExists) {- // v2.0.257 β THE BUG THAT COST JOHN THREE WINDOWS (2026-08-20). This kill is authorized by an- // INFERENCE: "a lock file exists and I could not reconnect just now, therefore the holder is a- // dead orphan from an unclean shutdown." That inference is wrong in the one case where it is- // catastrophic. The reconnect above gives up after a 3s probe + a 5s puppeteer.connect, and on- // a loaded machine a perfectly healthy Chrome misses those deadlines β so pup declared the LIVE- // shared adom-you Chrome an orphan and killed it, taking three windows with it (two other- // threads' dashboards and the ab web-demo window John was actively using). pup then relaunched- // them and reconciled them away as "closed", so from John's side they simply vanished.- // GROUND TRUTH beats inference: if any live session is bound to this profile, that process is- // not an orphan β it is the process serving the user's windows. Never kill it. Try one patient- // reconnect instead, and if that fails, fail THIS launch honestly rather than destroying work- // that is not ours to destroy. A failed launch costs a retry; a wrong kill costs the user's- // windows, and they have no way to get them back.- let _liveOnProfile = 0;- for (const _s of sessions.values()) { if (_s.profileName === profileName) _liveOnProfile++; }- if (_liveOnProfile > 0) {- console.log(`Profile "${profileName}" lock is held while ${_liveOnProfile} live pup session(s) are bound to it β that Chrome is NOT an orphan, it is serving the user's windows. Refusing to kill it; retrying the reconnect patiently.`);- let _reconnected = null;- try {- const _pf = path.join(userDataDir, 'DevToolsActivePort');- const _p = parseInt(String(fs.readFileSync(_pf, 'utf8')).trim().split('\n')[0], 10);- if (_p && !isNaN(_p)) {- _reconnected = await Promise.race([- puppeteer.connect({ browserURL: `http://127.0.0.1:${_p}`, defaultViewport: null }),- new Promise((_, rej) => setTimeout(() => rej(new Error('patient reconnect timeout')), 20000)),- ]);- _reconnected._cdpPort = _p;- }- } catch (e) { console.log(`Patient reconnect to profile "${profileName}" failed too (${e.message})`); }- if (_reconnected) {- console.log(`Patient reconnect to profile "${profileName}" SUCCEEDED β the browser was healthy, just slow. Nothing was killed.`);- browser = _reconnected;- } else {- throw new Error(`profile "${profileName}" is in use by ${_liveOnProfile} live pup session(s) and its Chrome could not be reconnected. Refusing to kill the browser hosting those windows just to free the profile lock β that would destroy windows other threads (or the user) are using. Retry in a moment, or close those sessions first with pup_close_window.`);- }- } else {- console.log(`Profile "${profileName}" has a lock file and NO live sessions are bound to it β checking for orphaned Chrome...`);- await killBrowserProcess(null, profileName);- // Give OS time to release the lock- await new Promise(r => setTimeout(r, 1000));- }- }-- // Pup is AI-driven, never human-driven, so Chrome's "restore last- // session" is pure cost: it reopens stale tabs from previous runs- // (8+ tab buildup after a few iter cycles) AND those tabs keep- // running heavy content (3D viewers, animations) under the- // anti-throttle flags we set, eating CPU. Wipe the session-restore- // state before launch β the AI knows what it wants open.- // v2.0.257: skip when the patient reconnect above adopted the live browser β that profile is- // IN USE and wiping its session-restore state (or relaunching over it) is exactly the damage- // this fix exists to prevent.- if (!browser) prepareCleanProfile(userDataDir);- // Give the profile a visible Adom identity in Chrome's profile chip. The- // shared durable jar is branded "Adom"; a named isolated jar carries its- // own name so the user can still tell windows apart. Both wear the- // Adom-logo avatar so ANY pup window is visually distinct from real Chrome.- if (!browser) brandPupProfile(userDataDir, profileName,- profileName === DEFAULT_PROFILE ? 'Adom'- : (/^iso-.*-pub$/.test(profileName) ? 'Adom Β· anonymous' : ('Adom Β· ' + profileName)));- }-- // βββ Helper: kill any Chrome processes locking this profile dir βββ- // v1.6.2+: route through killOrphanChromesForProfile + cleanProfileLocks- // (PowerShell-based) instead of wmic. wmic was deprecated in 2021 and- // removed from the default PATH in recent Windows 11 updates β when- // it's missing, the legacy code silently failed, leaving stale Chrome- // processes holding the profile and producing the canonical "Chrome- // process exited immediately" symptom on every retry. Caught the day- // v1.6.1 shipped because the recovery path exercised this helper more- // aggressively than before.- const killChromeHoldingProfile = async () => {- if (!userDataDir) return;- console.log(`Killing any Chrome processes holding profile "${profileName}"...`);- try {- await killOrphanChromesForProfile(profileName);- } catch (e) {- console.log(`killChromeHoldingProfile (orphan kill): ${e.message}`);- }- try {- await cleanProfileLocks(profileName);- } catch (e) {- console.log(`killChromeHoldingProfile (lock cleanup): ${e.message}`);- }- // Give OS time to release locks- await new Promise(r => setTimeout(r, 1500));- };-- // βββ Helper: one detached-launch attempt, returns browser or throws βββ- const tryDetachedLaunch = async (attemptNum, chromePathArg) => {- const { spawn } = require('child_process');- // Launch the candidate the loop is currently trying (installed Chrome/Edge- // preferred; cached CfT fallback). Falls back to puppeteer's own path only if- // no candidate path was resolved (the no-system-browser edge case).- const chromePath = chromePathArg || puppeteer.executablePath();- const debugPort = 9200 + Math.floor(Math.random() * 800);- // v1.8.26: BACKGROUND-BY-DEFAULT, done right. Chrome/Edge aggressively grab the- // foreground on launch (and re-grab it whenever shown), so minimizing/z-ordering- // AFTER the fact loses the race and the window flashes over the user's work. The- // reliable fix: launch it OFF-SCREEN (far past any monitor). CDP still renders it,- // so pup_screenshot/eval/record all work, but the user NEVER sees it pop up and- // it never covers their active window. foreground:true launches maximized on-screen- // as before. pup_raise_os_window moves it on-screen when the user asks to watch.- // v1.8.51: `--no-startup-window` is THE fix for the focus-steal war. Windows only grants- // a process the right to take the foreground at launch. With this flag the browser- // process starts with NO window at all β the window is created LATER via CDP (newPage),- // by which time Chrome is a background process and the OS itself DENIES it the- // foreground. So: no steal is possible, no watchdog/timer war, and a user's taskbar- // click can never be fought (nothing is running to fight it). The off-screen position- // args stay as belt-and-suspenders for the brief moment before the park.- // v1.8.95: SUPPRESS the browser sign-in / "set up a work profile" prompt. I was WRONG- // that CfT lacks this machinery β it shows the SAME "Sign in to Chromium? Set up a work- // profile" dialog that poisoned Aditya (John proved it live 2026-07-19). These flags kill- // it at launch so NO user ever sees it: browser account sign-in off, sync off, the sign-in- // promos/intercepts off. The wiki auth we DO want is a plain site cookie β unaffected.- // This PREVENTS the poisoning at the source; the auth-URL steering hint stays as backup.- const noBrowserIdentityFlags = [- '--allow-browser-signin=false',- '--disable-sync',- '--disable-features=SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker',- '--disable-signin-promo',- ];- const chromeArgs = mergeChromiumFeatureFlags([- ...launchOpts.args.filter(a => a !== '--remote-debugging-port=0' && (foreground || a !== '--start-maximized')),- `--remote-debugging-port=${debugPort}`,- `--user-data-dir=${userDataDir}`,- ...noBrowserIdentityFlags,- ...(foreground ? [] : ['--no-startup-window']), // v1.9.135 (John): with no-startup-window there IS no window at launch β the -32000 position arg was fighting nothing, and it poisoned every Chrome-created window (popups, restores) with an off-screen default. Park owns geometry now.- ]);- console.log(`Chrome launch attempt ${attemptNum} for profile "${profileName}" on CDP port ${debugPort}`);- // v1.8.34: snapshot the user's foreground window right before launch (background only)- // so we can hand keyboard focus back to it after the browser grabs it.- const priorForeground = foreground ? null : await osGetForegroundWindow();- // v1.9.122 β REPARENT Chrome out of the bridge's process TREE (Windows).- // Measured root cause of "every pup window dies on a bridge restart": Node's- // { detached:true } gives a new process GROUP but the child stays a TREE child of- // node (parentPid = the bridge), so AD's tree-kill on restart takes Chrome with it.- // Chrome killed dirty = never a clean shutdown, and (with the durable "you" profile- // coming next) a rotting profile. Fix: launch through a transient `cmd /c start`,- // which returns instantly and EXITS, orphaning Chrome from node within milliseconds.- // A later `taskkill /T` on the bridge no longer finds Chrome. CDP connects by PORT- // (below), so losing the direct PID handle is fine β we re-resolve it after connect.- // macOS/Linux keep the direct spawn (no tree-kill problem observed there).- const reparented = process.platform === 'win32' && !!userDataDir;- let child;- if (reparented) {- // v1.9.132 β REVERTED to the 1.9.122 cmd spawn. The 1.9.131 Start-Process variant fixed a- // real hazard (Chrome inheriting cmd's transient console handles via 'start /b'; a dead- // conhost can block Chrome's writes = the full-browser hang John hit) but it DID NOT- // LAUNCH from the bridge: every attempt died 'CDP never became ready', while the exact- // same Start-Process line run manually via shell_execute worked (HTTP 200 from CDP). So- // the defect is in composing the ~20-arg list through node spawn -> powershell -Command- // -> -ArgumentList, not in the approach. Reverted rather than blind-iterate on quoting;- // a broken launch is strictly worse than a rare hang. PARKED FIX, with both measurements- // in the 1.9.131/1.9.132 ship notes: diff the manual vs bridge-composed command lines- // (wmic/Get-CimInstance CommandLine of the child powershell) and re-land console-free.- // Full control of quoting (the taskbar-command lesson): build the command string- // ourselves and pass it verbatim, rather than letting Node re-quote through cmd.- const winQuote = (a) => /[\s"]/.test(a) ? '"' + String(a).replace(/"/g, '""') + '"' : a;- const startCmd = `start "" /b ${winQuote(chromePath)} ${chromeArgs.map(winQuote).join(' ')}`;- child = spawn('cmd.exe', ['/d', '/s', '/c', startCmd], {- windowsVerbatimArguments: true, windowsHide: true, detached: true, stdio: 'ignore',- });- } else {- child = spawn(chromePath, chromeArgs, { detached: true, stdio: 'ignore', windowsHide: false });- }- child.unref();-- // Wait for the browser to start and CDP to be ready (30s β cold start can be slow).- // IMPORTANT (Edge + some Chrome channels): the process we spawned is often just a- // LAUNCHER that relaunches the real browser onto a DIFFERENT PID and then exits. So- // we probe CDP FIRST every tick, and on launcher-death we give CDP one last chance- // before declaring failure β otherwise a perfectly-good Edge reads as "exited- // immediately". (The old order killed Edge launches dead.)- let connected = false;- let childDied = false;- for (let i = 0; i < 60; i++) {- await new Promise(r => setTimeout(r, 500));- // CDP up? Then we're connected regardless of which PID now hosts the browser.- try {- const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);- if (resp.ok) { connected = true; break; }- } catch {}- // v1.9.122: the reparent launcher (cmd) exits within ms BY DESIGN, so its death- // carries no information β poll CDP for the whole budget instead of bailing early.- if (reparented) continue;- // Is the process we spawned still alive?- let alive = true;- try {- if (process.platform === 'win32') {- const out = execSync(`tasklist /FI "PID eq ${child.pid}" /NH /FO CSV`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });- alive = out.includes(`"${child.pid}"`);- } else {- process.kill(child.pid, 0); // throws if dead- }- } catch { alive = false; }- if (!alive) {- // Launcher exited β the browser may be up on another PID. Final CDP grace probe.- await new Promise(r => setTimeout(r, 700));- try {- const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);- if (resp.ok) { connected = true; break; }- } catch {}- childDied = true; break;- }- }-- if (!connected) {- // Kill the zombie we spawned before throwing. v1.9.122: when reparented, child.pid is- // the already-exited launcher, so a stranded Chrome is only reachable via its profile.- try {- if (reparented) { await killChromeHoldingProfile(); }- else if (process.platform === 'win32') execSync(`taskkill /F /T /PID ${child.pid}`, { stdio: 'ignore' });- else process.kill(child.pid, 'SIGKILL');- } catch {}- const reason = childDied ? 'Chrome process exited immediately' : 'CDP never became ready';- throw new Error(`Chrome launch attempt ${attemptNum} failed: ${reason} (port ${debugPort}, PID ${child.pid})`);- }-- const b = await puppeteer.connect({ browserURL: `http://127.0.0.1:${debugPort}`, defaultViewport: null });- // v1.9.122: recover the real Chrome pid by its debug port (the reparent path's child.pid- // is the exited cmd). Retry briefly β the process is definitely up once CDP answered.- let resolvedPid = child.pid;- if (reparented) {- for (let t = 0; t < 4; t++) {- const procs = await _psProcs(`$_.CommandLine -like '*--remote-debugging-port=${debugPort}*'`);- const hit = procs.find(x => /--user-data-dir/.test(x.cmd));- if (hit) { resolvedPid = hit.pid; break; }- await new Promise(r => setTimeout(r, 300));- }- console.log(`[reparent] Chrome for "${profileName}" reparented off the bridge tree; real PID ${resolvedPid} (launcher was ${child.pid})`);- }- b._detachedPid = resolvedPid;- b._cdpPort = debugPort;- b._priorForeground = priorForeground; // v1.8.34: user's window to hand focus back to- console.log(`Launched detached Chrome (PID ${child.pid}) on CDP port ${debugPort} for profile "${profileName}"`);- // v1.8.24: ENFORCE background-by-default the instant the window exists β push it- // behind the user's windows AND hand focus back to whatever they were in, so the- // --start-maximized launch never steals focus. Retry a few times because Chrome/Edge- // can re-raise itself during startup (first paint, restore prompts). Resolve the- // window via the CDP-port owner (reliable across launcherβbrowser pid relaunches).- if (!foreground && process.platform === 'win32') {- // v1.8.53: with --no-startup-window there is NO window yet (it's created by newPage()- // in launchSession) β so the park happens THERE, via parkSessionWindowDirect (CDP +- // AD direct APIs, no PS window-hunting). Just mark that this fresh browser needs it.- b._bgResult = 'parking';- b._needsPark = true;- }- if (!foreground && process.platform === 'darwin') {- // macOS background-by-default: hide the app NOW, while --no-startup-window means it- // has no window yet β windows created by a hidden app are born hidden, so the launch- // is truly invisible (AppKit clamps the -32000 off-screen coords, so only the hide- // covers the gap here). The park then sizes the window + re-asserts the hide.- b._bgResult = 'parking';- b._needsPark = true;- b._macHidden = await macSetAppHiddenByPid(child.pid, true);- if (!b._macHidden) console.log(`[launch] mac pre-window hide failed for pid ${child.pid} (Automation grant missing?) β park will retry`);- }- return b;- };-- // v2.0.53/57: stage the extensions; CfT candidates carry them as --load-extension below.- // The identity extension rides ONLY adom-you (throwaway profiles must not claim the identity).- const _tbExtDir = stageToolbarExtension();- // v2.0.101 (John): a non-adom-you window still shows an identity tile β the ANONYMOUS one- // (pup 4-dot mark in the avatar inset) β so a different-profile window is visibly not-you.- const _idExtDir = (path.basename(userDataDir || '') === 'adom-you') ? await stageIdentityExtension()- : ((pupSettings().toolbarExtension !== 'off') ? _stageExtDir('extension-identity-anon', 'pup-identity-anon-extension') : null);- // v2.0.92 (John: pen button beside the extension icons β Chrome allows ONE action per- // extension, so the pen is its own tiny extension; three family tiles = the Adom toolbar)- const _penExtDir = (pupSettings().toolbarExtension !== 'off') ? _stageExtDir('extension-annotate', 'pup-annotate-extension') : null;- const _extDirs = [_penExtDir, _tbExtDir, _idExtDir].filter(Boolean);-- // βββ Spawn-verified launch: try each candidate browser in order, up to 3 βββ- // attempts each (with orphan/lock cleanup between attempts). If a browser can't- // actually spawn (corrupt install, wrong arch, locked), fall THROUGH to the next- // candidate rather than failing the open. First one that launches wins + is cached.- // v1.9.74: the detached launch runs on macOS too (same --no-startup-window contract;- // the mac park is the app-level hide instead of the off-screen/z-bottom dance).- const isDetachedLaunch = (process.platform === 'win32' || process.platform === 'darwin') && userDataDir;- const MAX_ATTEMPTS = 3;- let lastErr = null;- let chosen = null;- const triedList = [];- // No detected candidates (no system browser + no cached CfT) β let puppeteer pick- // its own executablePath. This is the rare cold-start edge the gate usually catches.- const launchList = candidates.length ? candidates : [{ kind: 'default', source: 'puppeteer', executablePath: null }];-- for (const cand of launchList) {- // v2.0.257: the patient reconnect above may have adopted the profile's LIVE browser rather than- // letting pup kill it. In that case there is nothing to launch β relaunching over a profile that- // is in use is the very damage being prevented.- if (browser) break;- if (cand.executablePath) launchOpts.executablePath = cand.executablePath;- else delete launchOpts.executablePath;- // v2.0.53: the toolbar rides ONLY on CfT (branded Chrome ignores/removed the flag).- launchOpts.args = launchOpts.args.filter(a => !a.startsWith('--load-extension='));- if (_extDirs.length && cand.kind === 'chrome-for-testing') launchOpts.args.push(`--load-extension=${_extDirs.join(',')}`);- lastErr = null;- for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {- try {- if (isDetachedLaunch) {- browser = await tryDetachedLaunch(attempt, cand.executablePath);- } else {- { const _lt0 = Date.now(); browser = await puppeteer.launch(launchOpts); _lastLaunchMs = Date.now() - _lt0; _lastLaunchAt = Date.now(); try { pupTimingRecord('browser_launch_' + (cand.kind || 'unknown'), _lastLaunchMs); } catch (e) {} } // v2.0.383 (#59): measure the cold spawn- }- chosen = cand;- lastErr = null;- break;- } catch (err) {- lastErr = err;- console.log(`Launch attempt ${attempt}/${MAX_ATTEMPTS} for ${cand.kind}(${cand.source}) failed: ${err.message}`);- if (attempt >= MAX_ATTEMPTS) break;- // Recovery before retry: kill any lingering Chrome holding the profile + clear locks- await killChromeHoldingProfile();- }- }- if (browser) break;- triedList.push({ kind: cand.kind, source: cand.source, executablePath: cand.executablePath || null, error: lastErr && lastErr.message });- if (launchList.length > 1) {- console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts β trying next browser`);- // A failed candidate (e.g. an Edge that aborted) can leave SingletonLock /- // DevToolsActivePort in the shared userDataDir that would poison the NEXT- // browser. Clear them before the next candidate so the fallback is clean.- await killChromeHoldingProfile();- }- }-- if (browser && chosen) {- // Record what actually launched so the open response + hints are accurate, and- // cache it as the auto-default so we don't re-probe next time.- browser._pupChosen = { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null };- // v1.9.94 (issue #22 Finding 4, AdityaAngajala) β REMOVED: the v1.8.71 "converge this box to- // CfT" background prewarm. It was the CfT-FIRST doctrine's convergence step, and native-first- // (v1.9.90) retired that doctrine β I removed the same kick from chrome.warmup() then and MISSED- // this one, so a native launch still started a ~150 MB CfT download nobody asked for. Worse, it- // was actively BLOCKING: while that download is in flight, chrome.readiness() reported the whole- // browser subsystem as not-ready (see the `ready` fix in chrome.js), and pup_open_window's- // gate refuses on `!rd.ready` β so a stalled prewarm made EVERY open fail with- // chrome_for_testing_installing on a machine that had a perfectly good system Chrome AND Edge.- // Measured live on H2O (AD 1.9.177): readiness simultaneously reported browserSource:"system"- // with a valid chrome.exe AND blocked opens at 0%. Native-first means we do not fetch CfT when a- // native browser works β CfT is fetched only as the last resort (no Chromium at all), which- // chrome.warmup() already handles at spawn. Do NOT reintroduce a convergence prewarm here.- _lastChosenBrowser = { ...browser._pupChosen, fallbackFrom: triedList.map(t => `${t.kind}(${t.source})`) };- if (chosen.executablePath) chrome.markVerified(chosen);- if (triedList.length) console.log(`[launch] launched ${chosen.kind}(${chosen.source}) after ${triedList.length} browser(s) failed to spawn`);- }-- if (!browser) {- // Every candidate failed to spawn. Branch on whether ANY browser was even- // detectable so the error is actionable, not a dev string telling the AI to run npx.- const triedTxt = triedList.length- ? triedList.map(t => `${t.kind}(${t.source}): ${t.error}`).join(' | ')- : '(no browsers were detected to try)';- const cdet = chrome.detectChrome();- if (!candidates.length && !cdet.installed) {- const e = new Error('No Chrome/Edge on this machine and Chrome for Testing is not installed β pup cannot launch a browser yet.');- e.errorCode = 'no_pup_available';- e._hint = 'This box has no installed Chrome or Edge and no cached Chrome for Testing. Fastest fix: have the user install Chrome or Edge (Edge ships with Windows) β pup uses it instantly, no download. Or call pup_prewarm / pup_use {browser:"cft"} to fetch Chrome for Testing (~150 MB, one time), poll pup_readiness until ready:true, then retry.';- throw e;- }- const e = new Error(- `Every detected browser failed to spawn for profile "${profileName}" (${MAX_ATTEMPTS} attempts each). Tried β ${triedTxt}.`- );- e.errorCode = 'pup_launch_failed';- e._hint = `pup tried every browser it found and none would launch (usually low memory, a locked/busy profile, or corrupt installs). Tried: ${triedList.map(t => `${t.kind}(${t.source})`).join(', ')}. Retry once; if it persists, pin a specific browser with pup_use {browser:"chrome"|"edge"|"cft"}, or surface the last error to the user.`;- throw e;- }- }-- browsers.set(profileName, { browser, refCount: 0 });-- // v2.0.114 (John: "get your pup icon into this toolbar"). ROOT CAUSE of the missing toolbar- // tiles: Chrome for Testing now DISABLES unpacked (--load-extension) extensions until the- // profile's Developer mode is on β so the Adom action icons had no action, could not be pinned,- // and hid in the puzzle menu. Developer mode lives in HMAC-signed Secure Preferences, so pup- // cannot seed it by writing files; it must call the developerPrivate API in a chrome://extensions- // page. This does that once per launch (fire-and-forget), then pins every Adom action icon.- if (pupSettings().toolbarExtension !== 'off') ensureExtensionsEnabled(browser, profileName).catch(() => {});-- // v1.4.12: pre-grant the dialog-suppressing permissions before any- // navigation happens. Done at browser-context level so every tab/origin- // inherits β caller doesn't need to re-grant per origin. See- // applyDefaultPermissions for rationale + opt-out via strictPermissions.- await applyDefaultPermissions(browser, profileName, { strictPermissions });-- // Auto-track popups: window.open() / target=_blank navigation on this Chrome- // gets attached to the session that owns the opener tab. See wirePopupTracking.- wirePopupTracking(browser, profileName);-- // Clean up when browser is closed externally (user closed Chrome, crashed, etc.)- // v1.5.1+: see handleBrowserDisconnect for rationale. Sessions are kept- // in the in-memory Map + on disk so pup_rescan / health-check can- // recover the still-alive Chrome window. Old behavior (delete everything)- // hijacked unrelated windows on subsequent calls.- browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));-- // v1.9.4: hand the launched browser to any concurrent joiners, then clear the in-flight slot.- _launchInFlight.delete(profileName);- _resolveLaunch(browser);- return browser;- } catch (_launchErr) {- // v1.9.4: propagate the SAME failure to every concurrent joiner and clear the slot so a- // later open can retry cleanly (a stuck slot would otherwise wedge all future opens).- _launchInFlight.delete(profileName);- _rejectLaunch(_launchErr);- throw _launchErr;- }-}--// v1.9.19: create a page in a BRAND NEW OS WINDOW of an existing browser. puppeteer's newPage()-// always makes a tab; only CDP Target.createTarget accepts newWindow. Page identity is resolved by-// diffing browser.pages() before/after rather than matching internal target ids, so this stays-// correct across puppeteer versions. Returns null if the window never materialises.-async function createPageInNewWindow(browser, profileName) {- let cdp = null;- // v1.9.304 BIRTH-TIME hwnd: give each new window a UNIQUE off-screen birth position so we can- // resolve its exact OS handle while it is still off-screen AND small (1280x800) β the ONLY moment a- // same-profile window is unambiguous. After park it is maximized + shares the parked rect with its- // siblings, which is why the width-wiggle could never tell them apart. Captured here, stashed on the- // page, trusted by resolveSessionHwndByBounds. This is the fix for multi-window profiles (adom-you).- // v2.0.259 (John's rule R1): the window is BORN where it will live β primary work area, 10px inset,- // on screen, clickable from its first millisecond. It used to be born at -32000 purely so the- // set-diff had a unique coordinate to match on, i.e. IDENTITY dictated PLACEMENT, and that is- // precisely how windows ended up stranded off-screen behind live taskbar buttons. The lock-based- // set-diff below never needed the coordinate (see the note on _birthLock: "the one handle that- // appeared while I held the lock is unambiguous regardless of position"), so nothing is lost.- _birthSeq++;- const _br = await birthRect();- try {- const before = new Set(await browser.pages());- cdp = await browser.target().createCDPSession();- // v1.9.144 (John: "you keep opening pup windows on top of the one i'm working in") β birth- // the window OFF-SCREEN so the moment between creation and the park is invisible. 1.9.135- // removed the process-wide -32000 launch default (it poisoned every window's restore rect);- // these are explicit PER-WINDOW coords on this one creation, and the unconditional park- // (1.9.138) immediately rewrites the normal rect on-screen at bottom-z, so nothing can stay- // stranded. Windows whose park somehow fails are caught by healOffscreenWindow.- // v1.9.253 (John: "i don't believe you that chrome is auto-foregrounding the windows" β he was- // right to push: MEASURE it). Capture the user's foreground before creating the window; check it- // after. If creation stole focus, hand it straight back while the new window is still OFF-SCREEN- // (invisible), so the user's typing is interrupted for at most ~1s and NOTHING ever appears on- // their screen. This is the at-the-source fix; parks and re-asserts then act on an unfocused- // window (osBottomUnlessForeground no longer mistakes the creation-grab for user intent).- const prevFg = await osGetForegroundWindow();- // v2.0.38: hold the birth lock across snapshot -> create -> capture so exactly one OS window- // appears in the diff. `capturedHwnd` is set inside; the page-find stays outside is fine because- // the page target is unique per createTarget regardless.- let fresh = null, capturedHwnd = null;- await withBirthLock(async () => {- invalidateEnumCache(); // v2.0.187: a birth changes the desktop β never diff against a stale snapshot- let beforeHwnds = new Set();- if (process.platform === 'win32') { try { beforeHwnds = new Set((await enumChromeWidgetWindows()).map(r => Number(r.h))); } catch (e) {} }- // v2.0.276 (John: "off-screen birth could work as long as you move it to the main screen- // within 50ms"). BORN OFF-SCREEN, because that is the ONLY lever Chrome/CDP leave us to stop- // Chrome activating its own window: Chrome does not bother giving the foreground to a window- // that is nowhere visible (measured). CDP createTarget has no no-activate option, and- // WS_EX_NOACTIVATE is a dead-end (Chrome re-activates anyway + breaks the taskbar click).- // So: create off-screen (zero focus steal), then IMMEDIATELY reposition on-screen with a single- // local CDP setWindowBounds β which moves WITHOUT changing z-order, so the window lands on- // screen, clickable, and still NOT foreground. This is ~20ms of local CDP, well inside the 50ms- // budget, so the off-screen state lasts one frame and the "stranded unclickable window" can- // never happen. No handback exists anymore because there is no steal to undo.- const _OFFX = -32000, _OFFY = -32000;- const _ct = await cdp.send('Target.createTarget', { url: 'about:blank', newWindow: true, left: _OFFX, top: _OFFY, width: _br.width, height: _br.height });- const _tid = _ct && _ct.targetId;- const _moveStart = Date.now();- try {- const { windowId: _wid } = await cdp.send('Browser.getWindowForTarget', _tid ? { targetId: _tid } : {});- await cdp.send('Browser.setWindowBounds', { windowId: _wid, bounds: { left: _br.left, top: _br.top, width: _br.width, height: _br.height } });- const _moveMs = Date.now() - _moveStart;- console.log(`[birth] window moved off-screen -> on-screen (${_br.left},${_br.top}) in ${_moveMs}ms (budget 50ms; born off-screen so Chrome never activated it -> no focus steal)`);- } catch (e) { console.log(`[birth] on-screen reposition failed (${e && e.message}) β the placement sweep will land it; the window is briefly off-screen`); }- for (let i = 0; i < 50 && !fresh; i++) {- fresh = (await browser.pages()).find(p => !before.has(p));- if (!fresh) await new Promise(r => setTimeout(r, 100));- }- if (process.platform === 'win32' && fresh) {- // The owner PID: native Chrome uses the SAME Chrome_WidgetWin_1 class, so only OUR process's- // windows are candidates (v2.0.185 β another thread's pup window once branded native Chrome).- const _bpid = Number(browser._detachedPid || (browser.process() && browser.process().pid) || 0) || null;- // v2.0.287 CONCURRENCY FIX (John: "why are you so insanely inconsistent?"). The old set-diff- // ("the one NEW window under the lock") could not disambiguate windows that materialise at the- // same instant when several open at once β it saw ">1 new window" and REFUSED, so that window- // got NO handle, could not be parked (it FOREGROUNDED) nor stamped (wrong/no icon), and WHICH- // window lost the race was random (the inconsistency). Fix: set a UNIQUE transient title on- // THIS exact page β it is on about:blank pre-navigation, so the title is ours to set and no- // other window, pup or native, can carry it β then capture the OS window whose title contains- // it. Unique per birth => deterministic even when 3 windows open in the same millisecond. The- // caller's navigation overwrites the title right after, by which point the hwnd is captured.- const _birthTitle = `PUPBIRTH-${_birthSeq}-${Math.floor(Date.now() % 1000000)}`;- try { await fresh.evaluate((t) => { document.title = t; }, _birthTitle); } catch (e) {}- // v2.0.308 (John: "why are you spamming desktop_enum_windows again? now that you do the- // SetProp technique you would never have to look at hwnd deltas"): he is right. The title- // capture used to POLL ab's enum up to 14 times per birth (the 1/s enum spam in his log).- // ONE server-side wait on the unique title now: ab blocks until a Chrome_WidgetWin_1 window- // whose title contains the token exists and hands back its hwnd. No enumeration, no deltas.- // v2.0.313: IN-PROCESS title poll (koffi EnumWindows + GetWindowText, microseconds per pass,- // every 50ms, 2.5s cap). Zero ab calls, zero process spawns, and no guessing at a verb's- // contract. Chrome takes a few hundred ms to push a document.title into the OS window title,- // which is what the poll absorbs.- if (win32.available()) {- const _t0 = Date.now();- while (!capturedHwnd && Date.now() - _t0 < 2500) {- const hit = win32.findChromeWindowByTitle(_birthTitle);- if (hit && hit.hwnd && (!_bpid || !hit.pid || Number(hit.pid) === _bpid) && !hwndOwnedByOtherSession(hit.hwnd, null)) { capturedHwnd = hit.hwnd; console.log(`[birth] in-process title poll matched "${_birthTitle}" -> ${hit.hwnd} (pid ${hit.pid}) after ${Date.now() - _t0}ms`); break; }- await new Promise(r => setTimeout(r, 50));- }- if (!capturedHwnd) console.log(`[birth] in-process title poll: no window carried "${_birthTitle}" within 2.5s`);- }- if (!capturedHwnd) {- // ONE enumeration (off-screen included), never a loop: the birth window is still at its- // off-screen birth position at this instant, which a visible-only wait can miss.- try {- const rows = await enumChromeWidgetWindows({ fresh: true });- const mine = rows.filter(r => (!_bpid || Number(r.pid) === _bpid) && !hwndOwnedByOtherSession(r.h, null) && String(r.title || '').includes(_birthTitle));- if (mine.length === 1) { capturedHwnd = mine[0].h; console.log(`[birth] single enum matched "${_birthTitle}" -> ${capturedHwnd}`); }- else console.log(`[birth] single enum: ${mine.length} windows carry "${_birthTitle}" (of ${rows.length})`);- } catch (e) {}- }- if (!capturedHwnd) console.log(`[hwnd] birth on "${profileName}": no window matched the unique birth title "${_birthTitle}" yet β resolver will settle it`);- }- });- // R4: the handle is resolved in the SAME LOCKED MOMENT the window was created β the one new- // top-level window owned by THIS profile's Chrome process. No coordinate involved, so it stays- // exact even though every window is now born at the identical on-screen rect. Stashed on the page- // for the resolver, which trusts it above everything else.- if (fresh && capturedHwnd) { fresh.__pupHwnd = capturedHwnd; console.log(`[hwnd] birth-resolved new window on "${profileName}" -> ${capturedHwnd} (unique-title capture, concurrency-safe; born on-screen at ${_br.left},${_br.top} ${_br.width}x${_br.height})`); }- else if (fresh) console.log(`[hwnd] birth capture on "${profileName}" produced no handle β will resolve later`);- try {- const nowFg = await osGetForegroundWindow();- // v2.0.276: THE HANDBACK IS DELETED. It existed only because 2.0.259's on-screen birth let- // Chrome steal the foreground on every open β and it was a bug: it called- // desktop_bring_to_front(prevFg), and on a machine full of pup windows prevFg is usually- // ANOTHER pup window, so it FOREGROUNDED pup windows (with no caption). Off-screen birth means- // there is no steal to undo, so the whole mechanism is gone. This is only a DIAGNOSTIC now: if- // it ever logs, off-screen birth failed to suppress the activation and that is a real bug to- // investigate β never something to paper over with a bring_to_front.- if (prevFg && nowFg && String(nowFg) !== String(prevFg)) {- console.log(`[focus] UNEXPECTED: a new window took the foreground (${prevFg} -> ${nowFg}) despite off-screen birth β off-screen birth is supposed to make this impossible; investigate, do NOT add a handback.`);- }- } catch (e) {}- // v2.0.285: the blank-occluded-window fix that lived here (Emulation.setFocusEmulationEnabled at- // birth) is REVERTED β it ACTIVATED the OS window and foregrounded a background pup window (John,- // 2026-08-22), the exact focus-steal off-screen birth exists to prevent. The blank problem is still- // real (dev-skills/pup-render-test) but must be solved WITHOUT touching OS focus. Do NOT reintroduce- // focus-emulation on the new window here.- return fresh;- } catch (e) {- console.log(`[window] createPageInNewWindow failed: ${e.message}`);- return null;- } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }-}--// Launch a session as a tab in a Chrome browser-// profile: which Chrome profile to use (defaults to sessionId)-// If another session already uses that profile, this creates a NEW TAB in the same Chrome-async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false, owner = null, takeover = false, foreground = false, silent = false, highFps = false, insecureDev = false } = {}) {- const profileName = profile || sessionId;-- // If session already exists and page is still alive, just navigate- const existing = sessions.get(sessionId);- if (existing) {- const browserEntry = browsers.get(existing.profileName);- if (browserEntry && browserEntry.browser.isConnected()) {- try {- // Check if the page is still valid- await existing.page.title();- // ββ v1.8.16 ownership gate ββββββββββββββββββββββββββββββββββββββββ- // Reusing an existing sessionId with a NEW url = navigating away a- // window some thread was using. If both sides declared owners and- // they differ, REFUSE (unless takeover:true) β the caller is about- // to steal another thread's window. Same-owner / no-url reuse is the- // legitimate "refresh my own window" pattern and passes untouched.- let prevUrl = null; try { prevUrl = existing.page.url() || null; } catch {}- const navigatingAway = !!url && !!prevUrl && url !== prevUrl;- const declaredMismatch = navigatingAway && existing.owner && owner && owner !== existing.owner;- if (declaredMismatch && !takeover) {- const age = Math.round((Date.now() - (existing.createdAt || Date.now())) / 60000);- const err = new Error(`Session "${sessionId}" belongs to another thread (owner "${existing.owner}", opened ${age} min ago, currently showing ${prevUrl}).`);- err.code = 'session_owned_by_another_thread';- err.details = { sessionId, owner: existing.owner, requestedBy: owner, currentUrl: prevUrl, ageMinutes: age };- throw err;- }- // One-shot reuse metadata for the open_window handler's hint:- // was this a benign refresh, a sanctioned takeover, or an anonymous grab?- existing._reuseMeta = {- reused: true,- previousUrl: prevUrl,- navigatedAway: navigatingAway,- previousOwner: existing.owner || null,- sameOwner: !!(existing.owner && owner && owner === existing.owner),- anonymousGrab: navigatingAway && !!existing.owner && !owner,- takeover: !!(declaredMismatch && takeover),- };- // Ownership follows a sanctioned takeover or fills in when unset.- if ((takeover || !existing.owner) && owner) existing.owner = owner;- if (url) {- await existing.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });- existing.errors.length = 0;- }- activeSessionId = sessionId;- return existing;- } catch (e) {- if (e && e.code === 'session_owned_by_another_thread') throw e;- // Page was closed β fall through to create new tab- console.log(`Session "${sessionId}" page was closed β recreating tab`);- }- }- // Stale session β clean up- sessions.delete(sessionId);- if (existing.profileName) {- const be = browsers.get(existing.profileName);- if (be) be.refCount = Math.max(0, be.refCount - 1);- // v1.6.1+: kill orphan Chromes + clean profile locks before relaunching.- // Without this, a stale `lockfile` (Windows) or `SingletonLock` (POSIX)- // left behind by an unclean shutdown causes the next puppeteer.launch- // to exit immediately with "Chrome process exited immediately." The- // sleep/wake recovery scenario hit exactly this: Chrome died during- // sleep, lockfile remained, every relaunch attempt failed silently.- try {- await killOrphanChromesForProfile(existing.profileName);- await cleanProfileLocks(existing.profileName);- } catch (e) {- console.log(`[launchSession] pre-relaunch cleanup failed (non-fatal): ${e.message}`);- }- }- }-- // Get or launch Chrome for this profile- const browser = await getOrLaunchBrowser(profileName, { freshProfile, strictPermissions, nativeSite, foreground, highFps, insecureDev });- // v1.9.76: a high-fps window is leased, never open-ended. See HIGHFPS_DEFAULT_MINUTES.- if (highFps) { try { highFpsGrant(sessionId, undefined); } catch (e) {} }-- // Prefer to REUSE the startup blank tab (every fresh Chrome opens one) instead- // of creating a new tab and then closing the blank. The startup tab's URL- // varies across Chrome versions: about:blank, chrome://newtab/,- // chrome://new-tab-page/, chrome-search://local-ntp/..., so match loosely.- const isBlank = (u) => {- if (!u) return true;- return u === 'about:blank'- || u.startsWith('chrome://newtab')- || u.startsWith('chrome://new-tab-page')- || u.startsWith('chrome-search://local-ntp')- || u === 'edge://newtab/';- };-- // Snapshot existing pages and find a reusable blank one NOT already claimed- // by another session.- const claimedPages = new Set();- for (const s of sessions.values()) { if (s.page) claimedPages.add(s.page); }- const existingPages = await browser.pages();- // v1.9.19: ONE SESSION = ONE OS WINDOW, even when sessions share a profile.- // browser.newPage() opens a TAB in whatever window the profile already has, so two sessions on- // the shared adom-wiki-authed jar collapsed into one window. A window's title reflects only its- // ACTIVE tab, so AD could not resolve "the window titled (session: <id>)" for the background- // one -- every per-window operation keyed on that title silently failed (icon stamp, taskbar- // overlay, flash, jump list), which is why the login icon would not repaint after a view toggle.- // Fix: when another live session already owns a window on this profile, create the page in its- // OWN window via CDP Target.createTarget {newWindow:true}. Same cookie jar, separate window.- const profileAlreadyInUse = [...sessions.values()].some(s => s.profileName === profileName && s.page);- let page = profileAlreadyInUse ? null : existingPages.find(p => {- if (claimedPages.has(p)) return false;- try { return isBlank(p.url()); } catch { return false; }- });-- if (page) {- console.log(`Reusing existing blank tab (${page.url()}) for session "${sessionId}"`);- } else if (profileAlreadyInUse) {- page = await createPageInNewWindow(browser, profileName);- if (page) page._pupCreated = true; // v1.9.142: never a 'restored leftover' β see closeRestoredLeftoverTabs- if (page) console.log(`Session "${sessionId}" opened in its OWN window on shared profile "${profileName}"`);- else { console.log(`[window] newWindow target failed for "${sessionId}" -- falling back to a tab`); page = await browser.newPage(); page._pupCreated = true; }- } else if (process.platform === 'win32') {- // v2.0.37 (John: "look at your pup windows bar icons"): the FIRST window on a profile used to- // take browser.newPage(), which captures NO OS hwnd β so after 2.0.24 removed the title-tag- // fallback that window was permanently unresolvable and showed a raw Chrome taskbar button. Route- // it through the SAME birth-capture path every other window uses (create off-screen at a unique- // position, set-diff the new handle), so every pup window on Windows gets a reliable hwnd.- page = await createPageInNewWindow(browser, profileName);- if (page) page._pupCreated = true;- else { console.log(`[window] first-window newWindow target failed for "${sessionId}" -- falling back to a tab`); page = await browser.newPage(); page._pupCreated = true; }- } else {- page = await browser.newPage();- page._pupCreated = true; // v1.9.142- }- // v2.0.310 (John: "immediately associate the chrome uniqueid to the hwnd and SetProp on that- // window so the association is in memory forever"): the birth capture stashed the hwnd on the- // page; THIS is the first moment the sessionId is known, so the property goes on right now,- // in-process (microseconds), not lazily on some later resolve.- if (page && page.__pupHwnd && process.platform === 'win32') {- const _tagged = win32.setProp(page.__pupHwnd, `${PUP_WIN_PROP}=${_sanSid(sessionId)}`);- if (_tagged) console.log(`[hwnd] "${sessionId}" tagged ${page.__pupHwnd} at birth (SetProp, in-process)`);- else if (_tagged === null) tagWindowSession(page.__pupHwnd, sessionId).catch(() => {}); // koffi unavailable: PowerShell fallback- }-- // Always open the welcome page first β it shows session identity (sessionId,- // profile, userDataDir) in the tab title and page body so users can tell- // which Chrome window belongs to which session.- const welcomeFile = path.join(__dirname, 'welcome.html');- const userDataDir = freshProfile ? null : path.join(PROFILES_DIR, profileName);- const welcomeParams = new URLSearchParams({ sessionId, profile: profileName });- if (userDataDir) welcomeParams.set('userDataDir', userDataDir);- const welcomeUrl = `file:///${welcomeFile.replace(/\\/g, '/')}?${welcomeParams}`;- try {- await page.goto(welcomeUrl, { waitUntil: 'domcontentloaded', timeout: 10000 });- } catch (e) {- console.log(`Welcome page failed to load (non-fatal): ${e.message}`);- }-- // v1.8.73: stamp the window IDENTITY at CREATE (per the AD contract: identity is per-HWND β- // stamping only at park would leave the window reading "Google Chrome for Testing" during its- // whole load). The welcome page just set the "(session: <id>)" title, so AD can resolve it.- // Fire-and-forget; the park re-brands (with badge fallback) as belt-and-suspenders.- // v1.9.185 minimal-touch: do NOT stamp a per-session AUMID. Changing a window's AppUserModelID- // makes Windows DESTROY and RECREATE its taskbar button β the single most invasive thing pup did- // to a window it does not own. In minimal mode the window keeps Chrome's own identity.- if (process.platform === 'win32' && (!minimalTouch() || pupSettings().aumidIcons === 'on')) { stampPupIdentity(sessionId, { once: true }).catch(() => {}); }-- // v1.8.68: the park is DEFINED here but KICKED OFF only after the real URL's navigation has- // SETTLED (below). Rationale: the first cross-origin paint RAISES the window; if the window- // is already placed on-screen (even at z-bottom), the user sees a blip on top of their work- // (the adom/wiki thread's window John caught). Parking after settle means the paint-raise- // hits a window still OFF-SCREEN β invisible, harmless. The window's first visible moment is- // full-size, z-bottomed, and z-confirmed. Only for a freshly launched browser (_needsPark) β- // a reused window is already placed, and re-parking it could hide a window the user has up.- const kickoffPark = () => {- if (foreground || (process.platform !== 'win32' && process.platform !== 'darwin')) return;- try {- const be = browsers.get(profileName);- // v1.9.138 (John's 'why did the park not happen on the gmail window') β park EVERY- // background window, not just the browser's first. _needsPark was a per-LAUNCH flag,- // cleared after the first park, so with the shared durable profile (one Chrome, many- // windows) every window after the first was NEVER parked: it sat at Chrome's default- // geometry (historically the -32000 process default = stranded off-screen). The gate- // is gone β parking is per-WINDOW and unconditional for background opens. And because- // Chrome's own late first-run sizing can land AFTER the park and clobber it (the- // inset-proof window read 0,0 full-size), the park RE-ASSERTS once at +2.5s.- if (be && be.browser) {- be.browser._needsPark = false;- be.browser._bgResult = 'parking';- const doPark = (tag) => parkSessionWindowDirect(page, sessionId)- .then(r => {- be.browser._bgResult = r;- console.log(`[park] "${sessionId}"${tag}: ${r}`);- brandPupWindow(sessionId).catch(() => {});- return r;- })- .catch(e => { be.browser._bgResult = 'error'; console.log(`[park] "${sessionId}"${tag} failed: ${e.message}`); });- be.browser._bgPromise = doPark('');- // v1.9.201 β RE-ASSERT ALWAYS. My 1.9.186 minimal-touch change dropped this second park pass- // as "a redundant window mutation". It is not redundant: it is the safety net that re-parks a- // window CHROME ITSELF brought forward after the first park. Local PDFs are the proven case β- // Chrome's PDF viewer activates the window on load, so John saw a 5-PDF window pop to the- // front even though NO ai requested foreground and pup's own log said "backgrounded".- // Keeping a window off the user's screen IS the point of minimal-touch, so this belongs in it.- setTimeout(() => { doPark(' (re-assert)'); }, 2500);- setTimeout(() => { brandPupWindow(sessionId).catch(() => {}); }, 1200);- }- } catch (e) {}- };- // v1.8.36: SNAPPY auto-flash. The window already exists + is backgrounded here (the- // welcome page loaded from a local file), so flash the taskbar NOW β do not wait for the- // real URL's domcontentloaded, which for heavy sites is another 1β3s. Set _flashState so- // the open handler's later scheduleAutoFlash debounces (no double flash). Fire-and-forget.- if (!foreground && !silent && process.platform === 'win32') {- // v1.9.185 minimal-touch: no taskbar flash at all (and no log noise pretending one fired).- if (taskbarActivityMode() === 'flash') { try { _flashState.set(sessionId, Date.now()); _openFlashDone.set(sessionId, Date.now()); console.log(`[flash] "${sessionId}" (launch)`); flashViaAD(sessionId, { force: true }); } catch (e) {} } // v2.0.292: openFlash folded into taskbarActivity='flash'- }-- if (url) {- // Apply stored basic-auth creds for this URL's host BEFORE navigating- // so the Chrome native auth dialog never appears. No-op if no match.- try {- const matchedHost = await credentialVault.applyCredentialsToPage(page, url);- if (matchedHost) console.log(`[creds] applied to session "${sessionId}" first nav: matched ${matchedHost}`);- } catch (e) {- console.log(`[creds] applyCredentialsToPage on launchSession failed: ${e.message}`);- }- // v1.9.105: keep the main-frame HTTP status β the strongest broken-render signal there is.- const _resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });- try { page.__pupHttpStatus = _resp && typeof _resp.status === 'function' ? _resp.status() : null; } catch (e) {}- // v1.8.68: wait for the page to SETTLE (load event, capped at 2.5s) while the window is- // still OFF-SCREEN, so the first paint's window-raise is invisible. Skip the wait if the- // page is already complete.- if (!foreground && process.platform === 'win32') {- try {- const ready = await page.evaluate(() => document.readyState).catch(() => 'unknown');- if (ready !== 'complete') {- await Promise.race([- new Promise(res => { try { page.once('load', res); } catch (e) { res(); } }),- new Promise(res => setTimeout(res, 2500)),- ]);- }- } catch (e) {}- // Late-paint belt-and-suspenders: one more bottom re-assert well after settle.- setTimeout(() => { reassertBottomAfterNav(sessionId).catch(() => {}); }, 3000);- }- }- // Park now β after settle (url path) or right away (welcome-only path). The paint-raise- // already happened off-screen; the window's first visible frame is full-size at z-bottom.- kickoffPark();-- // Belt and suspenders: close any tab in this Chrome that's NOT claimed by- // a registered session. This catches two cases:- // (a) leftover blank tabs from reconnect scenarios- // (b) tabs Chrome restored from session-restore (despite our- // prepareCleanProfile pre-launch pass) β pup is AI-driven, the AI- // knows what tabs it wants, so any unclaimed page is by definition- // a stale leftover from a prior run.- // Tabs registered by other live sessions on this same Chrome (multi-session- // shared profile) ARE in refreshedClaimed and stay open.- let restoredTabsClosed = 0;- try {- const after = await browser.pages();- const refreshedClaimed = new Set();- for (const s of sessions.values()) {- for (const t of s.tabs) refreshedClaimed.add(t.page);- }- refreshedClaimed.add(page);- for (const p of after) {- if (refreshedClaimed.has(p)) continue;- try {- let pUrl = '';- try { pUrl = p.url() || ''; } catch {}- await p.close({ runBeforeUnload: false });- restoredTabsClosed++;- if (!isBlank(pUrl)) {- console.log(`Closed restored leftover tab (${pUrl}) on session "${sessionId}" launch`);- }- } catch {}- }- } catch {}- if (restoredTabsClosed > 0) {- console.log(`Session "${sessionId}" launch: closed ${restoredTabsClosed} stale tab(s) β pup is AI-driven, no session-restore needed`);- }-- // Create the session with this page as its first tab.- const session = createSession(profileName);- // v1.9.26: sessions on the authed wiki profile are authed-INTENT β seed the flag NOW, before- // the first identity stamp, so the FIRST taskbar icon is already the signed-in one. Windows- // LATCHES a button's icon at creation and in-place re-stamps often do not repaint (proven live:- // ex-wikiin registered icon=wiki-in yet kept the plain book), so correcting the icon later is- // unreliable β the first stamp must be right. The real auth probe still runs after open and- // corrects the flag (and the tab glyph) if the ~30-day session actually expired.- if (profileName === WIKI_AUTH_PROFILE) session._wikiAuthed = true;- // v1.8.16: stamp the declared owner (advisory thread/task label) at create.- if (owner) session.owner = owner;- // v1.6.3+: stash the per-session downloadPath BEFORE attachTab β attachTab- // reads it to apply Page.setDownloadBehavior on the new page. Default- // resolves in attachTab itself if unset.- if (downloadPath) session._downloadPath = downloadPath;- attachTab(session, sessionId, page);- session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution- // v2.0.261 (John, live on ti.com): BIRTH TIMESTAMP. A user CANNOT have raised a window that did- // not exist a second ago β there was no taskbar button to click. Without this, pup's own newborn- // window self-activating while the user happens to be typing reads as "the user raised it", and- // every demotion path politely backs off and LEAVES IT ON TOP OF THEIR WORK. See BIRTH_GRACE_MS.- session._bornAt = Date.now();- // v1.9.158 (flash-counter fix): the launch flash fires BEFORE this registration, so counting- // via sessions.get() at flash time always missed β single-tab windows read flash:0 while the- // orange really fired (John's audit column caught it). Credit it here, where the session exists.- if (_flashState.has(sessionId) && Date.now() - _flashState.get(sessionId) < 60000) session._flashCount = (session._flashCount || 0) + 1;- session._iconApplied = false; // v2.0.289: reset so the icon-apply progress bar runs for this open- sessions.set(sessionId, session);- // v2.0.289 (John): show a determinate taskbar progress bar from window open until its taskbar icon- // actually lands (the async birth->hwnd->stamp->tile->nudge flow), sized to the running average.- try { startIconApplyProgress(sessionId); } catch (e) {}- // v2.0.25: adopt the create-time identity stamp so the post-park re-brand no-ops instead of- // recreating the taskbar button a second time with the identical appId.- try {- const _ea = _earlyStamped.get(sessionId);- if (_ea) { session._curAppId = _ea; session._frozenAppId = _ea; _earlyStamped.delete(sessionId); }- } catch (e) {}- activeSessionId = sessionId;-- // Track refCount- const be = browsers.get(profileName);- if (be) be.refCount++;-- // Append sessionId (and profile if different) to the page title so user can identify windows- try {- const titleSuffix = profileName !== sessionId- ? ` (${TITLE_LABEL}: ${sessionId} | profile: ${profileName})`- : ` (${TITLE_LABEL}: ${sessionId})`;- await page.evaluate((suffix, sid) => {- // v1.9.43: ONE named handle for the title observer. An adopted (dragged-out) tab must be able- // to disconnect the PREVIOUS session's observer, or that observer keeps re-appending the old- // suffix and fights every re-tag forever.- // v1.9.153: window.name = durable session carrier (the LAUNCH-path injector was the third,- // unstamped one β why docs-writer still mis-bound after 1.9.151).- try { window.name = 'pup:' + sid; } catch (e) {}- const strip = (t) => String(t || '').replace(/\s*\((?:session|ai-thread):[^)]*\)/g, '').trim();- try { if (window.__pupTitleObs) { window.__pupTitleObs.disconnect(); window.__pupTitleObs = null; } } catch (e) {}- // v2.0.24: no visible suffix, no observer (see retagSessionTitle) β window.name is the carrier.- const cleaned = strip(document.title);- if (cleaned !== document.title) document.title = cleaned;- }, titleSuffix, sessionId);- } catch (e) {- // Don't fail the session if title injection fails (e.g. about:blank)- }-- // Persist to disk for recovery after bridge restart- saveSessionFile(sessionId, profileName, url);-- // Stash one-shot launch metadata so the pup_open_window response can- // surface it. Cleared after first read in getSessionInfo.- session.lastLaunchMeta = { restoredTabsClosed };-- console.log(`Session "${sessionId}" launched as tab in profile "${profileName}" (${url || 'about:blank'}). Total sessions: ${sessions.size}`);- return session;-}---// Append a tab to an existing session. Returns the new tabId.-async function addTabToSession(sessionId, url) {- const session = sessions.get(sessionId);- if (!session) {- throw new Error(`Session "${sessionId}" not found. Use pup_open_window first.`);- }- const be = browsers.get(session.profileName);- if (!be || !be.browser.isConnected()) {- throw new Error(`Session "${sessionId}" browser is not connected.`);- }- // v1.9.157 (on-desk, John watching) β SERIALIZE tab creation per profile. Measured: newPage()- // lands the tab in the session's OWN window every time SINGLE-THREADED (2 tabs, 1 window). The- // split ONLY happens under concurrency: interleaved newPage() calls on the shared profile race- // on Chrome's ambiguous 'active window', so a tab is born in the wrong window and the drag-out- // handler splits it. Funnelling newPage through the same _profileOpenChain that serializes opens- // removes the interleave β each tab is created when its session's window is the settled context.- const _tabPrev = _profileOpenChain.get(session.profileName) || Promise.resolve();- // v1.9.245 (John's audit caught phantom "-split1-" sessions): newPage() has NO window affinity β- // Chrome puts the tab in the process's most-recently-active window, and with many windows on the- // shared profile that is the WRONG window as often as not. Serializing (1.9.157) narrowed the race- // but could not remove it: the API simply cannot target a window. So create the tab FROM INSIDE the- // session's own window: CDP Runtime.evaluate window.open() with userGesture:true (satisfies the- // popup blocker without a global --disable-popup-blocking). The drag-out detector then never sees a- // misplaced tab. newPage() stays as the fallback if the in-window open yields nothing in 5s.- const _tabCur = _tabPrev.then(async () => {- const anchor = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];- if (anchor && anchor.page && !anchor._crashed) {- let cdp = null;- try {- // v1.9.295 (John: "why do i keep getting so many about:blank tabs?"): adopt EXACTLY the tab- // window.open() creates, identified by a UNIQUE per-call marker written into window.name.- //- // History of why both simpler schemes failed:- // - Opener match (waitForTarget on tg.opener() === anchor): window.open() blanks expose NO- // observable opener here (confirmed in the bridge log: "new tab ... with no opener"), so- // the match ALWAYS timed out and fell through to newPage() β orphaning the blank- // window.open had already made. That stray "about:blank" beside the real tab is what John- // kept seeing.- // - Plain set-difference (take any new page target): correct for a single add, but under a- // BURST several sessions' blanks are new at once, so calls raced to grab the first new- // target and duplicated tabs (a burst of 3 adds tripled the middle one in testing).- //- // The marker is unique per call, so it can only ever match this call's own tab β burst-safe.- // window.name survives navigation (even cross-origin), and about:blank is same-origin to the- // opener so the write always lands. We adopt while still about:blank, before goto().- const tok = 'pupclaim_' + (_tabClaimSeq = (_tabClaimSeq + 1) % 1e9) + '_' + Date.now().toString(36);- const before = new Set(be.browser.targets());- cdp = await anchor.page.target().createCDPSession();- await cdp.send('Runtime.evaluate', {- expression: '(function(){var w=window.open("about:blank");if(w){try{w.name=' + JSON.stringify(tok) + ';}catch(e){}}return !!w;})()',- userGesture: true });- const start = Date.now();- let pg = null;- while (Date.now() - start < 5000 && !pg) {- // Only inspect NEW page targets (set-diff narrows the field), then CONFIRM ours by marker.- const fresh = be.browser.targets().filter(t => { try { return !before.has(t) && t.type() === 'page'; } catch (e) { return false; } });- for (const t of fresh) {- let p = null; try { p = await t.page(); } catch (e) {}- if (!p) continue;- let nm = null; try { nm = await p.evaluate(() => window.name); } catch (e) { /* mid-nav context torn down; retry next poll */ }- if (nm === tok) { pg = p; break; }- }- if (!pg) await new Promise(r => setTimeout(r, 60));- }- if (pg) return pg;- // No marked tab appeared β window.open was blocked/failed and created NOTHING, so the- // newPage() fallback below can't orphan a blank.- } catch (e) { console.log(`[tab] in-window open failed (${e.message}) β falling back to newPage`); }- finally { try { if (cdp) await cdp.detach(); } catch (e) {} }- }- return Promise.race([- be.browser.newPage(),- new Promise((_, rej) => setTimeout(() => rej(new Error('newPage timed out β the browser did not create the tab in 20s. Retry pup_open_tab; if it persists, pup_close_window + pup_open_window.')), 20000)),- ]);- });- _profileOpenChain.set(session.profileName, _tabCur.catch(() => {}));- const page = await _tabCur;- try { page._pupCreated = true; } catch (e) {}- const tabId = attachTab(session, sessionId, page, { makeActive: true });-- // v1.8.19: the " (session: <sid>)" title marker is now owned SOLELY by the- // persistent evaluateOnNewDocument injector in attachTab (already installed above).- // We deliberately do NOT install a second, per-tab MutationObserver here β two- // observers appending different suffixes duel forever and hang the tab (the bug this- // release fixes). tabIds are surfaced via pup_list_tabs, not the title.- try {- if (url) {- try {- const matchedHost = await credentialVault.applyCredentialsToPage(page, url);- if (matchedHost) console.log(`[creds] applied to new tab "${tabId}": matched ${matchedHost}`);- } catch (e) {- console.log(`[creds] applyCredentialsToPage on new tab failed: ${e.message}`);- }- // v1.9.110: keep the tab's own HTTP status so probeRender can report a 404 with certainty.- const _tResp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });- try { page.__pupHttpStatus = _tResp && typeof _tResp.status === 'function' ? _tResp.status() : null; } catch (e) {}- }- } catch {}-- console.log(`Session "${sessionId}": added ${tabId} (${url || 'about:blank'}). Total tabs: ${session.tabs.length}`);- return tabId;-}--// Check if a session's browser is still connected-// v1.9.92 (issue #16) β liveness asks the SESSION'S OWN handle, not just our bookkeeping.-// The old check consulted ONLY the `browsers` map, so the instant `handleBrowserDisconnect` did-// `browsers.delete(profileName)` (a CDP blip, sleep/wake, a renderer crash) every session on that-// profile read as dead β even when its page was still perfectly connected and drivable. That false-// "dead" is what fed the destructive purge below. Now: trust the browsers entry when present, else-// fall back to asking the session's own page whether its browser is still connected. A session that-// can still be driven must never report as not-alive.-function isSessionAlive(s) {- if (!s) return false;- const be = browsers.get(s.profileName);- if (be && be.browser && be.browser.isConnected()) return true;- // Fallback: the bookkeeping entry is gone, but is the actual page still live?- try {- const p = s.page;- if (p && !p.isClosed() && p.browser() && p.browser().isConnected()) return true;- } catch (e) { /* detached handle β fall through to not-alive */ }- return false;-}----// Resolve a session by explicit sessionId or fall back to active.-// Does NOT change the global activeSessionId β safe for concurrent use.-// Returns { session, resolvedId } or null when sessionId was omitted and-// no live session exists. Never silently retargets when an explicit-// sessionId was passed but doesn't match β see resolveSessionOrError.-function resolveSession(args) {- // Explicit sessionId path: don't fall through to a different session if- // the requested one is missing or disconnected. v1.5.0 and earlier did,- // and that's how chip-fetcher's "pup_navigate landed on the wrong- // window" bug fired (caller passed the right sessionId, the bridge- // silently retargeted to whatever it had).- if (args.sessionId) {- if (sessions.has(args.sessionId)) {- const s = sessions.get(args.sessionId);- if (isSessionAlive(s)) {- s._lostBrowser = false;- return { session: s, resolvedId: args.sessionId };- }- // Session entry exists but its browser is gone (CDP socket dropped,- // browser process died, profile crashed). Caller will see a- // structured error from resolveSessionOrError pointing at- // pup_rescan.- }- return null;- }- // No sessionId passed: legacy fallback to active or first alive.- if (activeSessionId && sessions.has(activeSessionId)) {- const s = sessions.get(activeSessionId);- if (isSessionAlive(s)) return { session: s, resolvedId: activeSessionId };- sessions.delete(activeSessionId);- }- for (const [id, s] of sessions) {- if (isSessionAlive(s)) return { session: s, resolvedId: id };- if (!s._lostBrowser) sessions.delete(id); // keep disconnected entries for rescan- }- return null;-}--// Like resolveSession but throws a STRUCTURED error (JSON-encoded message-// with errorCode + currentSessions + _hint) when nothing resolves.-// v1.5.1+: differentiates "session not found" from "session known but-// browser disconnected" so callers / Claude can give the right next--// step recipe (pup_rescan vs pup_open_window).-function resolveSessionOrError(args) {- const resolved = resolveSession(args);- if (resolved) return resolved;- const explicit = !!args.sessionId;- const known = explicit && sessions.has(args.sessionId);- const requested = args.sessionId || '(none)';- const currentSessions = [...sessions.keys()];- const liveSessions = currentSessions.filter(id => {- const s = sessions.get(id);- return s && isSessionAlive(s);- });- const disconnectedSessions = currentSessions.filter(id => {- const s = sessions.get(id);- return s && s._lostBrowser;- });- let payload;- if (known) {- // Session entry exists but Chrome socket is gone β recoverable.- payload = {- error: `Session "${requested}" is known to the bridge but its Chrome browser is currently disconnected. The Chrome window may still be alive on the user's desktop; the CDP socket dropped (network blip, sleep/wake, puppeteer hiccup) and the bridge hasn't reconnected yet.`,- errorCode: 'session_disconnected',- currentSessions,- liveSessions,- disconnectedSessions,- _hint:- `Run \`pup_rescan\` to attempt automatic recovery. The 30s ` +- `health check will also auto-attempt, but rescan is faster + ` +- `idempotent. If rescan reports the profile as unreachable, ` +- `Chrome itself probably crashed β relaunch with pup_open_window.`,- };- } else if (explicit) {- // Caller asked for a specific sessionId we have NO record of.- // v2.0.128 (consumer bug report #5): be HONEST about a bridge restart instead of leaving the- // caller to guess whether their window was ever opened. If the bridge came up recently, say so- // outright β "the bridge restarted Ns ago, your window did not survive" β and note that named- // sessions auto-restore ONLY when their on-disk session file survived (pup relaunches them to- // their last URL on the next verb); if this id isn't recovering, that file is gone.- const _upS = Math.round(process.uptime());- const _recentRestart = _upS < 180;- payload = {- error: _recentRestart- ? `The bridge restarted ${_upS}s ago and session "${requested}" did not survive β its on-disk session file was not found, so pup cannot auto-restore it. (Named sessions auto-restore across a restart only while their session file persists; this one's is gone, or the window was opened by a build that didn't persist it.)`- : `Session "${requested}" not found in bridge. Either it was never opened, it was closed via pup_close_window, or the bridge restarted and the on-disk session file was cleaned.`,- errorCode: 'session_not_found',- bridgeUptimeSec: _upS,- bridgeRecentlyRestarted: _recentRestart,- currentSessions,- liveSessions,- disconnectedSessions,- _hint:- (_recentRestart ? `The window is gone β reopen it: pup_open_window {"sessionId":"${requested}","url":"..."} (pup re-keys it to this id and remembers it). ` : '') +- `Live sessions right now: [${liveSessions.join(', ') || '(none)'}]. ` +- (disconnectedSessions.length > 0- ? `Disconnected sessions awaiting rescan: [${disconnectedSessions.join(', ')}] β try \`pup_rescan\` first. `- : '') +- `If the Chrome window IS still on the desktop but the bridge can't see ` +- `it, run pup_rescan (it walks every Chrome target and recovers ` +- `sessions by parsing the (session: X) tag from each page's title). ` +- `If you need to start fresh, pup_open_window with the right sessionId.`,- };- } else {- // No sessionId passed and nothing is alive.- payload = {- error: `No live browser sessions. Use pup_open_window first.`,- errorCode: 'no_session',- currentSessions,- liveSessions,- disconnectedSessions,- _hint:- disconnectedSessions.length > 0- ? `Disconnected sessions exist on disk: [${disconnectedSessions.join(', ')}]. Run \`pup_rescan\` to see if any are recoverable before opening a new window.`- : `Open a Chrome window with: pup_open_window {"sessionId":"...","profile":"...","url":"..."}`,- };- }- const err = new Error(JSON.stringify(payload));- err._isStructured = true;- throw err;-}--// Send a structured-or-plain session resolve error. Mirror of-// sendTabResolveError β used by every dispatch site that calls-// resolveSessionOrError, so the structured payload (errorCode +-// currentSessions + _hint) reaches the caller intact.-// v2.0.233: this was a byte-for-byte copy of sendTabResolveError (verified by AST compare) β the-// same structured-error unwrap written twice under two names. One implementation, two names kept so-// each call site still reads as "session" or "tab" at its site.-const sendSessionResolveError = sendTabResolveError;--// v1.6.1+: the AUTO-RECOVER path. Tries every layer of recovery before-// surfacing a session-not-found error to the caller. Used by every-// pup_* dispatch site that previously called resolveSessionOrError.-//-// Recovery ladder:-// (a) Session is alive β return immediately.-// (b) Session is in `_lostBrowser` state (CDP socket dropped, sleep/wake,-// transient blip): attemptReconnectProfile(profile) β uses session-// file β DevToolsActivePort β running-process-cmdline-scan as-// three tiers of fallback. If reconnect succeeds, rescanProfile-// walks pages and re-attaches. Return the recovered session.-// (c) Reconnect failed but we have a last-known URL (saved in the-// session file at launch / nav time): kill any orphan Chrome-// holding the profile, clean profile locks (lockfile / Singleton*-// / DevToolsActivePort), then call launchSession to start a fresh-// Chrome with the same profile + URL. Caller sees a transparent-// recovery β same sessionId, fresh page, last URL re-loaded.-// (d) Session entry doesn't exist BUT a session file does (e.g. bridge-// restarted): same logic β try reconnect via session file, fall-// back to relaunch with the file's recorded URL.-// (e) Nothing works β throw the structured session_not_found /-// session_disconnected error from resolveSessionOrError so the-// caller (or its higher-level retry logic) gets a clear signal.-//-// The Docker container's contract: `pup_navigate / eval / screenshot-// / input_dispatch / errors / reload / fetch_url` should NEVER surface-// "Session closed" / "detached Frame" / "Browser disconnected" β those-// become internal retry signals, not user-facing errors.-async function recoverOrRelaunchSession(args) {- // (a) Fast path: live session, no recovery needed.- const fast = resolveSession(args);- if (fast) return fast;-- const requestedId = args.sessionId;- // No explicit sessionId β fall through to the strict-error path; we- // can't auto-recover what we don't know. Caller handles this via- // sendSessionResolveError as before (omitted-sessionId is a real- // "no session" case, not a recovery case).- if (!requestedId) {- return resolveSessionOrError(args);- }-- const inMem = sessions.get(requestedId);- const sessionFile = readSessionFile(requestedId);- // We need at minimum the profile name to do anything. Either from the- // in-memory session entry or from the on-disk session file.- const profileName = (inMem && inMem.profileName) || (sessionFile && sessionFile.profile);- if (!profileName) {- // Session is genuinely unknown to the bridge. Surface the structured- // session_not_found error.- return resolveSessionOrError(args);- }- // v1.9.172: fall back to the URL cached on the in-memory session (stamped at disconnect) when the- // session file is missing or has no URL β so a crash-relaunch still lands on the right page.- const lastKnownUrl = (sessionFile && sessionFile.url) || (inMem && inMem._lastUrl) || null;-- console.log(- `[recover] session "${requestedId}" needs recovery β profile="${profileName}", ` +- `inMemory=${!!inMem}, sessionFile=${!!sessionFile}, lastKnownUrl=${lastKnownUrl || '(none)'}`- );-- // (b) Try reconnect via the existing 3-tier path (session file β- // DevToolsActivePort β running-process scan).- let browser = await attemptReconnectProfile(profileName);- if (browser) {- try {- await rescanProfile(browser, profileName);- } catch (e) {- console.log(`[recover] rescanProfile after reconnect failed: ${e.message}`);- }- // After rescan, the session may now be in the map.- const after = resolveSession(args);- if (after) {- console.log(`[recover] session "${requestedId}" reconnected without relaunch`);- return after;- }- }-- // (c)/(d) Reconnect failed (or got a browser but couldn't find the- // session inside it). Kill any orphan Chrome for this profile, clean- // profile locks, then relaunch fresh with the last-known URL.- console.log(`[recover] reconnect insufficient β killing orphans + cleaning profile + relaunching session "${requestedId}"`);- await killOrphanChromesForProfile(profileName);- await cleanProfileLocks(profileName);- // Drop the dead in-memory entry so launchSession creates a fresh one.- // (launchSession's existing path checks for an existing session and- // would try to navigate it β but its page handle is dead.)- if (inMem) {- sessions.delete(requestedId);- }- try {- const session = await launchSession(requestedId, lastKnownUrl, { profile: profileName });- if (session) {- session._recovered = true;- session._recoveryReason = browser ? 'reconnect-no-page' : 'relaunch';- console.log(`[recover] session "${requestedId}" relaunched at ${lastKnownUrl || '(no URL)'}`);- const final = resolveSession({ sessionId: requestedId });- if (final) return final;- }- } catch (e) {- console.log(`[recover] launchSession failed during recovery: ${e.message}`);- }-- // (e) Total failure β surface the structured error so the caller can- // tell the user (or escalate to pup_open_window with explicit URL).- return resolveSessionOrError(args);-}--// Wrap an async page operation with a timeout so the HTTP response always comes back-async function withTimeout(promise, ms, label) {- let timer;- const timeout = new Promise((_, reject) => {- timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);- });- try {- return await Promise.race([promise, timeout]);- } finally {- clearTimeout(timer);- }-}--// Periodic health check (v1.5.1+):-//-// - Detect disconnected browsers and ATTEMPT RECONNECT before removing-// them. Old (β€v1.5.0) behavior was to immediately delete the browser-// entry + every session for that profile + every session file. That-// erased recovery state for what was usually a transient network-// blip (sleep/wake, puppeteer hiccup), leaving an orphaned-but-alive-// Chrome window the bridge could no longer see. We now keep state-// and try to recover.-//-// - Sessions whose browser is gone get marked _lostBrowser; their entries-// stay in the Map and on disk so pup_rescan / next health check-// can recover them. Only delete a session file when its underlying-// Chrome PID is confirmed dead.-//-// - Stale-file cleanup still runs (PID dead β file deleted) so we don't-// accumulate junk for Chromes that genuinely crashed.-// v2.0.315 (audit): the standalone 30s health loop is folded into the 15s probe loop below-// (every other tick), so there is ONE browser-death detector instead of two.-async function healthLegacyTick() {- for (const [profileName, be] of browsers) {- if (!be.browser.isConnected()) {- console.log(`[health] browser for profile "${profileName}" disconnected β attempting reconnect`);- browsers.delete(profileName); // attemptReconnectProfile re-adds on success- const reconnected = await attemptReconnectProfile(profileName).catch(() => null);- if (reconnected) {- try {- const r = await rescanProfile(reconnected, profileName);- console.log(`[health] auto-reconnected + rescanned profile "${profileName}": ${r.reattached} reattached`);- } catch (e) {- console.log(`[health] rescan after reconnect failed for "${profileName}": ${e.message}`);- }- } else {- console.log(`[health] could not reconnect profile "${profileName}" β sessions remain marked _lostBrowser`);- }- }- }- // Don't hard-delete sessions whose browser is currently disconnected β- // they may recover on the next loop. Only purge sessions whose specific- // PAGE handle is dead (tab was closed individually) AND profile is alive.- for (const [id, s] of sessions) {- if (s._lostBrowser) continue; // skip β awaiting reconnect- if (!isSessionAlive(s)) {- console.log(`[health] session "${id}" dead (page closed individually) β removing`);- sessions.delete(id);- deleteSessionFile(id);- if (activeSessionId === id) {- activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;- }- }- }- // v1.9.31: drain the window-opened ring here (no dedicated timer) so an idle desktop still- // notices a dragged-out tab within one tick; active use catches it immediately via the tab verbs.- drainWindowOpenedEvents().catch(() => {});- // Clean stale session files whose PIDs are confirmed dead. Sessions that- // are _lostBrowser but whose PID is alive STAY on disk for recovery.- const files = listSessionFiles();- for (const sf of files) {- if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {- console.log(`[health] stale session file "${sf.sessionId}" (PID ${sf.pid} dead) β deleting`);- deleteSessionFile(sf.sessionId);- }- }-}--// v1.0.16: the single source of truth for per-verb HINTS β used BOTH by-// `pup_describe` (so AD's Verbs tab shows hint/related/pitfalls under each-// verb) AND by sendJSON's runtime injector below (so EVERY pup_* call-// self-documents inline, even for an AI that never read describe). This is the-// reference shape the cloud-owned bridges (kicad/fusion/native-browser) were-// asked to adopt: a calling AI should never have to guess and never have to-// read docs first. { verb: { hint, related[], pitfalls[] } }.-const VERB_META = {- pup_open_window: { hint: 'Opens a generic, fully-controllable pup window (a fresh isolated Chrome/Edge process β never your real signed-in browser) that you then drive by sessionId. To drive the user\'s REAL Chrome/Edge, use the Adom browser extension\'s nbrowser_* verbs instead.', related: ['pup_navigate', 'pup_list_windows', 'pup_close_window'], pitfalls: ['sessionId is OPTIONAL: omit it and your thread gets its OWN window (keyed on your --ai-thread name; later opens become tabs in it). Pass one only for a deliberate separate window β it is then your handle for every later call', 'profile defaults to the durable shared \'adom-you\' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway β omit it for a plain "open in pup"', 'sessionIds are SHARED across all AI threads on this desktop β a generic id ("wiki", "test") may collide with another thread\'s window and navigating it away steals their work. Use a task-prefixed sessionId + pass owner:"<your-task>" so pup can protect it', 'reusing an existing sessionId with a new url NAVIGATES that window β check pup_status (shows owner per window) first if unsure'] },- pup_open_tab: { hint: 'Adds a tab to an existing session; returns the new tabId.', related: ['pup_switch_tab', 'pup_list_tabs', 'pup_close_tab'], pitfalls: ['the session must already exist (pup_open_window first)'] },- pup_close_tab: { hint: 'Closes one tab; the session stays open.', related: ['pup_close_window', 'pup_list_tabs'], pitfalls: ['omit tabId to close the active tab β closing the last tab may close the window'] },- pup_close_window: { hint: 'Closes the whole session and all its tabs. Destructive.', related: ['pup_list_windows'], pitfalls: ['this ends the session β its sessionId is no longer valid afterward'] },- pup_navigate: { hint: 'Drives the active tab to url and waits for load. Verify with a screenshot before asserting success.', related: ['pup_screenshot', 'pup_wait', 'pup_reload'], pitfalls: ['navigation can race SPA routing β confirm with pup_screenshot', 'some sites need pup_wait for a selector after navigate'] },- pup_back: { hint: 'History back on the active tab.', related: ['pup_forward', 'pup_navigate'], pitfalls: [] },- pup_forward: { hint: 'History forward on the active tab.', related: ['pup_back', 'pup_navigate'], pitfalls: [] },- pup_reload: { hint: 'Reloads the active tab.', related: ['pup_navigate', 'pup_wait', 'pup_dialog'], pitfalls: ['A page with unsaved changes raises a native "Reload site?" dialog that BLOCKS the window: the reload appears to time out and so does everything else. Answer it with pup_dialog.'] },- pup_dialog: { hint: 'Answer a native JS dialog (alert/confirm/prompt/beforeunload) that is blocking the window: {action:"accept"|"dismiss", promptText?}.', related: ['pup_reload', 'pup_navigate'], pitfalls: ['pup NEVER answers these for you. A beforeunload is a question about the user\'s unsaved work, so accept and dismiss each destroy something different. Read the page with pup_eval first if the work matters.'] },- pup_click: { hint: 'Clicks by CSS selector (preferred) or by x,y coords. Selector is more robust across layouts.', related: ['pup_type', 'pup_input_dispatch', 'pup_screenshot'], pitfalls: ['selector must match exactly one visible element β verify with pup_evaluate first', 'coords are viewport-relative; a scrolled element may be off-screen'] },- pup_type: { hint: 'Types into the focused or selector-targeted field.', related: ['pup_click', 'pup_press_key'], pitfalls: ['focus the field first (pup_click the input) or pass selector', 'does not clear existing text β select-all + delete if you need a clean field'] },- pup_press_key: { hint: 'Presses a key or chord (Enter, Ctrl+A, Tab).', related: ['pup_type', 'pup_input_dispatch'], pitfalls: ['chord syntax is "Ctrl+A" β verify the page has focus on the intended element'] },- pup_screenshot: { hint: 'PNG of the page. Your primary way to SEE state β call it after navigate/click to confirm.', related: ['pup_screenshot_full_res', 'pup_navigate'], pitfalls: ['default is the viewport; pass fullPage:true for the whole scroll height'] },- pup_evaluate: { hint: 'Runs JS in the page and returns the result β use it to read DOM state or verify selectors before acting.', related: ['pup_click', 'pup_fetch_url'], pitfalls: ['the expression must be serializable JSON in its return value', 'runs in the page context, not Node'] },- pup_fetch_url: { hint: 'Fetches a URL using the tab\'s live cookies/session β for authed APIs the bare CLI can\'t reach. Returns bodyBase64.', related: ['pup_navigate', 'pup_evaluate'], pitfalls: ['for session-scoped URLs pass the tabId whose cookies you need', 'returns base64 β decode it caller-side'] },- pup_input_dispatch: { hint: 'Low-level CDP mouse/keyboard input at coords β beats some overlays/iframes that swallow synthetic clicks.', related: ['pup_click', 'pup_press_key'], pitfalls: ['coords are viewport pixels', 'last-resort when pup_click can\'t reach the element β still cannot pass Cloudflare interactive challenges'] },- pup_record_start: { hint: 'Starts video recording of the page for this session.', related: ['pup_record_stop', 'pup_record_status'], pitfalls: ['one recording per session β stop the current one before starting another'] },- pup_record_stop: { hint: 'Stops recording and returns the saved file path.', related: ['pup_record_start', 'pup_record_list'], pitfalls: ['no-op if nothing is recording for this session'] },- pup_record_status: { hint: 'Whether a recording is active for the session.', related: ['pup_record_start', 'pup_record_stop'], pitfalls: [] },- pup_record_list: { hint: 'Lists saved page recordings.', related: ['pup_record_start'], pitfalls: [] },- desktop_record_start: { hint: 'Starts a full desktop screen recording (served by pup for ab-core until adom-bridge#143 lands; needs ffmpeg on the box for the duration header).', related: ['desktop_record_stop', 'desktop_record_status'], pitfalls: ['only one desktop recording at a time'] },- pup_readiness: { hint: 'Cold-start readiness probe: {ready, chromeForTestingInstalled, installing, installProgressPct}. On a fresh PC, poll this until ready:true before pup_open_window β the bridge auto-installs Chrome for Testing on its own.', related: ['pup_prewarm', 'pup_open_window'], pitfalls: ['ready:false with installing:true just means Chrome for Testing is still downloading β wait + poll, do NOT treat it as an error', 'installPhase:"failed" β surface lastError to the user (usually network/proxy blocking the download, or low disk)'] },- pup_prewarm: { hint: 'Installs Chrome for Testing (the browser pup drives) WITHOUT opening a window β the warm-before-first-use path. Returns fast; poll pup_readiness for completion.', related: ['pup_readiness', 'pup_open_window'], pitfalls: ['the ~150 MB download needs network access to storage.googleapis.com', 'does not open a window β call pup_open_window once readiness reports ready:true'] },- pup_import_scan: { hint: 'READ-ONLY discovery for the comprehensive credential import (adom-you seed): enumerates every profile of every Chromium browser (Chrome/Edge/Brave) with its source account email(s), saved-password count, and ABE status. Decrypts NOTHING β actual decryption needs the Bridge-core elevated SYSTEM helper (adom/adom-bridge #51).', related: ['pup_open_window'], pitfalls: ['Windows-only', 'passwordCount is null if python is not on PATH', 'ABE (v20) passwords cannot be decrypted by this user process β that is the Bridge SYSTEM helperβs job'] },- pup_describe: { hint: 'This list β the bridge\'s verbs + schemas + hints, for AD\'s Verbs tab. Read it once to learn the surface.', related: ['pup_status'], pitfalls: [] },-};--// βββ Bug-report invitation (v1.9.95, John's ask) ββββββββββββββββββββββββββββββ-// We cannot reproduce every environment. The AI driving this bridge is standing on a real user's-// real machine, which is exactly where the bugs we can't see live β the Adom team's wiki-issue-// reports (#14 battery drain, #15 respawn, #16 tracking, #19 repo drift, #22 concurrency) have each-// found something no local test would have. So ASK for reports, in the response the AI actually reads.-//-// Throttled deliberately, because a line on EVERY response is noise the AI learns to skip:-// β’ ALWAYS on a failure β the moment a bug is most likely and the evidence is still in hand.-// β’ ONCE per bridge process on the first verb β orientation, so it's known before anything breaks.-// β’ Occasionally after that (every Nth call) β a quiet reminder on a long-running session.-// Opt out with pup settings {reportIssues:"off"} for a user who doesn't want it.-// v2.0.255 (John, adom/pup-bridge#65, second bug in the same report): this ref is pasted into-// EVERY pup response's _reportIssues hint, and the old slug has 404'd since the rename. The-// hint's whole job is to route bug reports back here, so a stale slug means reports land-// nowhere or get abandoned β the worst possible thing for the one string that asks for help.-const PUP_ISSUES_REF = 'adom/pup-bridge';-// βββ "You just put something on screen β LOOK at it" (v1.9.99, John's ask) ββββββββββββββββββββββ-// The common failure: an AI generates or edits an app, opens it in pup, sees ok:true, and reports-// success to the user WITHOUT ever looking at the render. ok:true means the NAVIGATION worked. It-// says nothing about whether the page painted, whether the CSS loaded, or whether a stack trace is-// filling the viewport. pup is the one component that knows a window was just pointed at content,-// so it is the right place to insist on verification β in the response the AI actually reads.-// v1.9.105 (John: "you didn't even attempt to test their content and as you can see its 404") β-// PROGRAMMATIC RENDER CHECK. Telling the AI "go screenshot it" proved too weak: the launcher-mockup-// thread opened a .adom.cloud app that served "404 page not found", pup answered ok:true and said-// NOTHING, and the AI reported success. pup HAS the page, so it can just LOOK. This probes the loaded-// document and returns a verdict, making a broken render a FACT in the response instead of a suggestion-// the caller may skip. One evaluate, no screenshot, and it never throws into the caller's path.-// v1.9.117 (John, after the HD thread wasted a round trip on this) β LINT A MALFORMED WIKI URL.-// The HD build thread opened https://wiki.adom.inc/adom-wiki-skillpack, got "Page not found", and-// concluded (and told John) that "org pages are invisible to logged-out readers". That was WRONG and-// cost real trust: the window was logged in as John the whole time, the page exists and is public, and-// the URL was simply missing its OWNER segment. wiki.adom.inc pages are owner-qualified:-// https://wiki.adom.inc/<owner>/<slug> e.g. /adom/adom-wiki-skillpack-// https://wiki.adom.inc/<owner>/<slug>/blob/<path> e.g. /adom/adom-wiki-skillpack/blob/SKILL.md-// pup KNOWS the URL 404'd and knows the host, so it can name the actual cause instead of leaving the-// caller to invent one. Guessing "adom" is deliberate: it owns the overwhelming majority of pages, and-// the hint says to verify rather than trust the guess.-// v1.9.118 (John: "you should have complete awareness of how wiki urls are constructed so you can-// reprimand the calling ai", and "you should linter on localhost:{port} urls too") β CALL-TIME URL LINT.-// The 1.9.117 version only fired on a render FAILURE, so the caller learned the URL was wrong only after-// the page 404'd, and the HD thread had already reported a wrong conclusion by then. pup knows the URL-// the instant it is handed one, so the reprimand belongs there: returned as `_urlLint` on the open/tab/-// navigate response whether or not the page happens to load.-// Returns { code, message } or null. `callerContainer` (from X-Adom-Caller-Container) sharpens the-// localhost advice when we know which container is asking.-function urlLint(u, callerContainer) {- try {- if (!u || typeof u !== 'string') return null;-- // ββ file:// β v1.9.195 (John, 2026-07-29: a thread opened- // file:///home/adom/project/hd-launcher-mockup/publish/orbital-lab-helium/helium-lab.html and pup- // said NOTHING; it just rendered Chrome's ERR_FILE_NOT_FOUND). pup drives Chrome on the USER'S- // DESKTOP, so a file:// URL resolves against THEIR filesystem, never the caller's container. A- // container-shaped path (/home/, /root/, /workspace/, /tmp/) can NEVER exist there β that is a- // guaranteed-broken open and must be reprimanded before it wastes a window. Any other file://- // path gets an EXISTENCE PRE-FLIGHT below (the bridge runs on the desktop, so it can just look).- if (/^file:/i.test(u)) {- let fpath = '';- try { fpath = decodeURIComponent(new URL(u).pathname || ''); } catch (e) { fpath = ''; }- const winPath = fpath.replace(/^\/([A-Za-z]:)/, '$1').replace(/\//g, path.sep);- const containerish = /^\/(home|root|workspace|tmp|usr|var|opt|mnt)\//i.test(fpath);- if (containerish) {- return {- code: 'url_file_container_path',- message: `CONTAINER PATH HANDED TO PUP. You passed "${u}", but pup drives Chrome on the USER'S DESKTOP `- + `(Windows/macOS) β a file:// URL resolves against THEIR filesystem, not your container's. "${fpath}" is a `- + `container path, so it can never exist there and this open is guaranteed to render Chrome's `- + `ERR_FILE_NOT_FOUND page. Do ONE of these instead: (1) SERVE it and open the public proxy URL β run a `- + `static server in your container (e.g. \`python3 -m http.server <port>\` in that directory) and open `- + `https://<your-container>.adom.cloud/proxy/<port>/<file> ; or (2) PUSH it to the user's machine first `- + `with adom-bridge-cli push_file, then open the file:// path it actually landed at on THEIR disk. Never `- + `assume your container's paths exist on the user's computer β they are different machines.`,- };- }- // Not container-shaped: it MIGHT be a real path on this desktop. The bridge runs on the desktop,- // so pre-flight it rather than letting Chrome render an error page the caller may misread.- try {- if (winPath && !fs.existsSync(winPath)) {- return {- code: 'url_file_not_found',- message: `FILE DOES NOT EXIST ON THE USER'S MACHINE. pup pre-flighted "${winPath}" on the desktop before `- + `opening it and there is no such file, so this would have rendered Chrome's ERR_FILE_NOT_FOUND page. `- + `Check the path, or push the file to the desktop first with adom-bridge-cli push_file and open the path it `- + `reports back. (Remember: pup's Chrome runs on the USER'S computer, so file:// is THEIR filesystem, `- + `not your container's.)`,- };- }- } catch (e) {}- return null;- }-- const url = new URL(u);- const host = url.hostname.toLowerCase();-- // ββ localhost / loopback / LAN: pup runs on the USER'S machine, not the caller's container ββ- if (/^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/.test(host)) {- const port = url.port || '<port>';- const path = (url.pathname === '/' ? '' : url.pathname) + (url.search || '');- const slug = callerContainer ? String(callerContainer).toLowerCase().replace(/[^a-z0-9-]/g, '-') : '<your-container-slug>';- return {- code: 'url_localhost',- message: `WRONG HOST FOR PUP. You passed "${host}${url.port ? ':' + url.port : ''}", but pup drives a browser on the `- + `USER'S DESKTOP, not inside your container β so "${host}" resolves on THEIR machine and will hit whatever `- + `(if anything) is listening there, not your dev server. To show a service running INSIDE your container, use `- + `its public proxy URL: https://${slug}-<suffix>.adom.cloud/proxy/${port}/${path.replace(/^\//, '')} `- + `(<suffix> is the 10-12 char id assigned to your container; \`adom-cli container list\` or your existing `- + `container URL has it). If you genuinely meant a server on the USER'S machine, say so explicitly, because `- + `that is almost never what an AI in a container intends.`,- };- }-- // ββ wiki.adom.inc pages are OWNER-QUALIFIED ββ- if (/(^|\.)wiki\.adom\.inc$/.test(host)) {- const segs = url.pathname.split('/').filter(Boolean);- if (!segs.length) return null;- const ROUTES = new Set(['apps', 'skills', 'components', 'bootstraps', 'discover', 'login', 'search', 'api', 'download', 'static', 'hero', 'settings', 'new']);- if (ROUTES.has(segs[0].toLowerCase())) return null;- const ownerMissing = segs.length === 1 || (segs.length >= 2 && segs[1].toLowerCase() === 'blob');- if (ownerMissing) {- const slug = segs[0];- const rest = segs.slice(1).join('/');- const fixed = `${url.origin}/adom/${slug}${rest ? '/' + rest : ''}${url.search || ''}`;- return {- code: 'url_wiki_missing_owner',- message: `MALFORMED WIKI URL. wiki.adom.inc pages are OWNER-QUALIFIED β https://wiki.adom.inc/<owner>/<slug> `- + `(and /<owner>/<slug>/blob/<path> for a file) β and yours has no owner segment, so it will 404 no matter `- + `who is logged in. Most pages are owned by "adom", so you probably want: ${fixed} . Confirm the owner with `- + `\`adom-wiki page get ${slug}\` (read its "owner" field) instead of assuming. Do NOT conclude the page is `- + `private, restricted, or hidden from logged-out users because of this β that is a wrong conclusion a `- + `previous thread reported to the user, and the page was public the whole time.`,- };- }- return null;- }- return null;- } catch (e) { return null; }-}-// Back-compat shim for the failure-path caller.-function wikiUrlLint(u) {- const r = urlLint(u, null);- return (r && r.code === 'url_wiki_missing_owner') ? r.message : null;-}-// v1.9.125 (follow-on) β AUTO-FILL a vault-matched login form the instant the page renders.-// Runs after every render verb (open/tab/navigate/reload). Conservative by design:-// - only when the vault has a credential for this exact origin (deliberate opt-in per host),-// - only on real http(s) pages,-// - FILL ONLY, never auto-submit (auto-submitting on load risks MFA/lockouts/wrong-form logins),-// - never throws β autofill must never block or fail a navigation,-// - reported to the caller as _autoLogin, and silenceable with pup_configure {autoLogin:"off"}.-async function autoLoginOnLoad(res, page) {- try {- if (!res || !page) return;- let mode = 'submit'; // v2.0.110: default now fill+submit (confidence-gated below); 'fill'/'off' downgrade- try { const av = pupSettings().autoLogin; mode = (av === 'off') ? 'off' : (av === 'fill' ? 'fill' : 'submit'); } catch (e) {}- if (mode === 'off') return;- let url = '';- try { url = page.url(); } catch (e) { return; }- if (!url || !/^https?:/i.test(url)) return;- const creds = await credentialVault.getCredentialForUrl(url);- if (!creds) return;- // v2.0.110 (John): pup FILLS in the background but NEVER auto-submits. Submitting a stored- // credential is reason-gated (like foregrounding): the AI must call pup_login {submit:true,- // submitReason} and, in doing so, confirm the LIVE domain it is handing the password to β the- // hint regurgitates that exact domain so a lookalike/phishing host is caught before the secret- // is sent. mode 'fill' is identical here (fill-only); 'off' skips entirely.- let liveHost = '';- try { liveHost = new URL(url).hostname; } catch (e) {}- const r = await credentialVault.fillLoginForm(page, { username: creds.username, password: creds.password, submit: false });- if (r && r.ok && r.filledPassword) {- res._autoLoginNote = `pup AUTO-FILLED this login for saved host "${creds.host}" (username ${creds.username}) in the BACKGROUND, NOT submitted β no Chrome dropdown, no foregrounding. β BEFORE SUBMITTING, CONFIRM THE DOMAIN: you are about to send this credential to "${liveHost}". Is that the domain you intended, and does it match where this login belongs? A lookalike/typo/redirected host here is how credentials get phished. If you are sure, submit with pup_login {submit:true, submitReason:"<why you are confident this is the right site to send these creds to>"} (the reason is required; it forces you to actually verify the domain). pup_configure {autoLogin:"off"} disables auto-fill.`;- }- } catch (e) { /* autofill is best-effort; never disturb the navigation */ }-}-async function probeRender(page, httpStatus) {- const out = { httpStatus: (httpStatus === undefined ? null : httpStatus), ok: true, signals: [] };- try {- const probe = await Promise.race([- page.evaluate(async () => {- // v2.0.248: wait BRIEFLY for the document to finish loading before judging. Declining to- // judge (2.0.247) never lies but almost never fires, because the probe runs while the page- // is still 'interactive'. A short bounded wait β capped, and only on pages that have not- // finished β makes the verdict both useful and trustworthy: 'complete' means every image- // has settled, so placeholders and real failures are distinguishable.- try {- if (document.readyState !== 'complete') {- await Promise.race([- new Promise((r) => window.addEventListener('load', r, { once: true })),- new Promise((r) => setTimeout(r, 1500)),- ]);- }- } catch (e) {}- // v2.0.246 (verifying pup-bridge#60): judge images ONLY when the document has actually- // finished loading. The probe runs right after navigation, so mid-load it saw placeholders- // as broken (Colby's false alarm) and missed a real 404 that had not failed yet (the mirror- // false negative I then created). The load event fires after every image has settled, so at- // readyState 'complete' the verdict is trustworthy β and before that pup simply declines to- // judge instead of guessing, which costs nothing and never lies.- const t = (document.body ? (document.body.innerText || '') : '').trim();- return {- title: document.title || '',- bodyChars: t.length,- head: t.slice(0, 400),- elements: document.body ? document.body.querySelectorAll('*').length : 0,- styleSheets: document.styleSheets ? document.styleSheets.length : 0,- // v2.0.245 (pup-bridge#60, Colby: "broken_images_6" on a page whose images were fine):- // an <img> with NO src β or src="" β reports naturalWidth 0 in Chrome and is what every- // placeholder slot looks like (lazy-load skeletons, hidden preview clones, "image goes- // here" boxes). Counting those as broken told the driving AI the page was BROKEN when- // nothing had failed to load. A broken image is a REAL src that failed: non-empty src,- // finished loading, zero natural width.- // null = "not judged yet" (page still loading); a number = a verdict pup stands behind.- brokenImgs: (document.readyState === 'complete' && document.images)- ? Array.from(document.images).filter((i) => {- const src = i.getAttribute('src');- // a placeholder slot (no src / src="") reports naturalWidth 0 and is NOT broken- return !!(src && src.trim()) && i.complete && i.naturalWidth === 0;- }).length : null,- };- }),- new Promise(r => setTimeout(() => r(null), 6000)), // covers the bounded load wait above- ]);- if (!probe) { out.signals.push('probe_timed_out'); return out; }- out.title = probe.title; out.bodyChars = probe.bodyChars; out.elements = probe.elements;- const head = String(probe.head || '');- const low = head.toLowerCase();- if (typeof out.httpStatus === 'number' && out.httpStatus >= 400) out.signals.push('http_' + out.httpStatus);- if (/\b404\b|page not found|not found\b/.test(low)) out.signals.push('page_says_not_found');- if (/cannot (get|post) \//i.test(head)) out.signals.push('dev_server_cannot_get');- if (/internal server error|bad gateway|service unavailable/.test(low)) out.signals.push('server_error_text');- if (/traceback \(most recent call last\)|unhandled (exception|rejection)|referenceerror|typeerror:|syntaxerror:/.test(low)) out.signals.push('stack_trace_visible');- if (probe.bodyChars < 12 && probe.elements < 8) out.signals.push('effectively_blank');- if (probe.bodyChars > 40 && probe.styleSheets === 0) out.signals.push('no_stylesheets_loaded');- if (typeof probe.brokenImgs === 'number' && probe.brokenImgs > 0) out.signals.push('broken_images_' + probe.brokenImgs);- out.ok = out.signals.length === 0;- if (!out.ok) out.excerpt = head.slice(0, 180);- } catch (e) { out.signals.push('probe_failed'); }- return out;-}-const RENDER_VERBS = new Set(['pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_reload']);-// A URL that is almost certainly the caller's OWN work-in-progress (a dev server, a local file, an-// Adom-hosted app) earns the strongest wording β that is exactly the case John described.-function looksLikeOwnContent(u) {- try {- if (!u) return false;- if (/^file:/i.test(u)) return true;- const h = new URL(u).hostname;- return /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$/i.test(h)- || /\.local$/i.test(h)- || /\.adom\.cloud$/i.test(h)- || /^(\d{1,3}\.){3}\d{1,3}$/.test(h);- } catch (e) { return false; }-}-function renderVerifyHint(sessionId, url, rc) {- const sid = sessionId || '<sessionId>';- const own = looksLikeOwnContent(url);- // A DETECTED failure outranks advice: say what is wrong in the first words the caller reads.- if (rc && rc.ok === false && (rc.signals || []).length) {- const sig = rc.signals.join(', ');- // v1.9.117: a malformed wiki URL is a KNOWN cause, so lead with it rather than generic advice.- const lint = wikiUrlLint(url);- if (lint) {- return 'THIS PAGE DID NOT RENDER CORRECTLY (' + sig + '). ' + lint- + ' See it yourself: `adom-bridge-cli --ai-thread "<your thread>" pup_screenshot {"sessionId":"'- + sid + '"}`. Do NOT report success, and do NOT report a conclusion about permissions or login'- + ' until the URL is owner-qualified and you have re-checked.';- }- const st = (typeof rc.httpStatus === 'number') ? ' (HTTP ' + rc.httpStatus + ')' : '';- const ex = rc.excerpt ? ' The page says: "' + String(rc.excerpt).replace(/\s+/g, ' ').slice(0, 140) + '".' : '';- return 'THIS PAGE DID NOT RENDER CORRECTLY β pup checked it, so do NOT report success. Detected: '- + sig + st + '.' + ex- + ' See it yourself: `adom-bridge-cli --ai-thread "<your thread>" pup_screenshot {"sessionId":"' + sid + '"}`.'- + ' Then FIX THE CAUSE: a 404 or "Cannot GET" usually means the dev server is not running, is on a'- + ' different port, or the path is wrong; unstyled HTML means the CSS 404d; a stack trace means the'- + ' app threw. After fixing, `pup_reload` and re-check until this response comes back clean.'- + ' Do not tell the user it works while this warning is present.';- }- const lead = own- ? 'THIS LOOKS LIKE CONTENT YOU JUST BUILT OR EDITED β SCREENSHOT IT AND LOOK BEFORE YOU CLAIM IT WORKS.'- : 'VERIFY WHAT YOU JUST PUT ON SCREEN β do not assume it rendered.';- return `${lead} ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. `- + `Take a screenshot and READ the image: \`adom-bridge-cli --ai-thread "<your thread>" pup_screenshot {"sessionId":"${sid}"}\` β it returns a real PNG path you can open. `- + `Look for: a blank or white page, an unstyled flash of plain HTML (CSS never loaded), a stack trace or framework error overlay, `- + `missing images, a broken or overlapping layout, content cut off past the fold, a dev-server "Cannot GET /" page, or the wrong build entirely. `- + `If it is wrong: fix the source, \`pup_reload\`, and screenshot AGAIN β ralph-test that loop until the render is genuinely correct, `- + `rather than reporting success after one open. If the viewport is too small to judge, pup_set_viewport first; `- + `for a long page use pup_screenshot {fullPage:true}. Showing the user a screenshot of their own working app is also the fastest way to prove it works.`;-}-let _bugInviteCalls = 0;-let _bugInviteFirstDone = false;-const BUG_INVITE_EVERY = 40;-function bugReportInvite(isError) {- const lead = isError- ? 'THIS MAY BE A PUP BUG β PLEASE REPORT IT.'- : 'pup is actively developed and bug reports are genuinely wanted.';- return `${lead} If a pup verb misbehaves (wrong result, a hang, a window that never appears, a hint that turned out to be false, a process left running), file it on pup's own wiki repo so the maintainers see it: \`adom-wiki issue list ${PUP_ISSUES_REF}\` to check for a duplicate first, then \`adom-wiki issue create ${PUP_ISSUES_REF} --title "<one-line symptom>" --category bug-report --body "<what you ran, what you expected, what happened, the VERBATIM response JSON, and the bridge version from bridge_list>"\`. Include the machine's OS and whether it is Windows or macOS. Reports from real machines are the only way this bridge gets correct β the maintainers cannot reproduce every environment, browser install, or concurrency pattern locally. (A Bridge-core problem β the relay, bridge lifecycle, caller identity β belongs on adom/adom-bridge instead.)`;-}-// Resolve the session a response is ABOUT, from the caller's own addressing. pup's documented forms-// are zero-id (resolve by ai-thread), {window:"..."} and the legacy {sessionId:"..."}, so all three-// have to be honoured or a notice fires only for whichever form the author happened to test.-// GROUND TRUTH FOR EMULATION, measured rather than remembered (project-a5, 2026-08-28).-// pup used to answer "is this window emulated?" from its own record, and a RECOVERED window resets-// that record to null while Chrome keeps the CDP device-metrics override, because the override-// lives in the browser and not in pup. Every consumer then agreed the window was fine: the listings-// reported no override, the warning never fired, and reset's guard compared against nothing and-// passed vacuously with emulated:false while the page was demonstrably still 2200px wide in a-// 1688px window.-// outerWidth is the one number an override cannot fake, so the divergence IS the answer and needs-// no bookkeeping to survive a recovery. A real window's inner is a little smaller than its outer-// (borders, scrollbar); an emulated one is either far smaller or, in the cropping case, LARGER.-const EMULATION_SLACK = 60;-function looksEmulated(innerWidth, outerWidth) {- if (!innerWidth || !outerWidth) return false;- if (innerWidth > outerWidth + 2) return true; // wider than the window: content cropped- return (outerWidth - innerWidth) > EMULATION_SLACK; // far narrower: dead space beside it-}-async function measureViewport(page, session) {- try {- const d = await page.evaluate(() => ({- innerWidth: window.innerWidth, innerHeight: window.innerHeight,- outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio,- }));- const out = { ...d, emulated: looksEmulated(d.innerWidth, d.outerWidth), at: Date.now() };- if (session) { session._measuredViewport = out; session._lastOuterWidth = d.outerWidth; }- return out;- } catch (e) { return null; }-}-function sessionForResponse(obj, argsIn) {- try {- const a = argsIn || {};- // The payload is frequently SERIALISED INTO obj.output as a JSON string rather than living on- // the object, so obj.sessionId is undefined for most verbs. Look inside it too. This is why the- // first three attempts at these notices resolved nothing: the id was there, one level down.- let inner = null;- if (obj && typeof obj.output === 'string' && obj.output.charAt(0) === '{') {- try { inner = JSON.parse(obj.output); } catch (e) {}- }- const sid = (obj && (obj.sessionId || obj.window))- || (inner && (inner.sessionId || inner.window))- || a.sessionId || a.window;- if (sid && sessions.get(sid)) return sessions.get(sid);- const thread = a['ai-thread'] || a.aiThread;- if (thread) { for (const [, s2] of sessions) if (s2 && (s2.owner === thread || s2._ownerThread === thread)) return s2; }- return null;- } catch (e) { return null; }-}-// Notices that describe THIS session's state, delivered on the verbs that would otherwise report a-// confident wrong answer. Deliberately outside the bug-invite path: these are not bug reports.-function attachSessionNotices(obj, verb, argsIn) {- try {- if (!obj || typeof obj !== 'object' || Array.isArray(obj) || !verb) return;- const sess = sessionForResponse(obj, argsIn);- if (!sess) return;- // AN EMULATED VIEWPORT IS INVISIBLE TO THE AI THAT SET IT (John, 2026-08-27). He found a window- // rendering ~290px narrower than the browser window while the driving thread reported the page- // was "taking up 100% width". Both were true: a CSS-level check measures the EMULATION, so a- // page filling 1400 of 1400 reports 100% while the user looks at 1400 of 1690. Every honest- // in-page measurement agrees with itself and disagrees with the screen, so the override has to- // say so on the verbs used to measure and to look.- if (obj._viewportOverride === undefined && VIEWPORT_SENSITIVE.has(verb)) {- // Disclose an UNRECORDED override too. Today's root cause pinned the viewport without ever- // writing the record, so a warning that trusts the record is silent exactly when it matters.- // sess._measuredViewport is refreshed by any verb that measures the page.- let ev = sess._emulatedViewport;- if (!ev && sess._measuredViewport && sess._measuredViewport.emulated) {- ev = { width: sess._measuredViewport.innerWidth, height: sess._measuredViewport.innerHeight,- deviceScaleFactor: sess._measuredViewport.devicePixelRatio, derived: true };- }- if (ev && ev.width) {- // Both failure directions are real and they look opposite on screen. NARROWER than the- // window leaves dead space down the side; WIDER crops content off the edge, which is the- // one that hides information from the user. Name which one this is.- const outer = Number(sess._lastOuterWidth) || null;- let shape = '';- if (outer && ev.width > outer + 4) {- shape = ` The emulation is WIDER than the OS window (${ev.width} vs ${outer}), so the user is seeing this page CROPPED: roughly ${ev.width - outer}px of it is off the right edge of their window.`;- } else if (outer && ev.width < outer - 4) {- shape = ` The emulation is NARROWER than the OS window (${ev.width} vs ${outer}), so the user is seeing dead space beside the page.`;- }- obj._viewportOverride = `This window has an EMULATED VIEWPORT of ${ev.width}x${ev.height}`- + (ev.deviceScaleFactor ? ` @${ev.deviceScaleFactor}x` : '')- + `, so it is NOT tracking the real OS window.` + shape- + ` Anything you measure in the page (innerWidth, clientWidth, a bounding rect) describes the emulation, not what the user sees. Clear it with pup_set_viewport {"reset":true} when you are done emulating.`;- }- }- // #80: the restart notice, delivered to the session it happened to rather than broadcast.- if (obj._sessionNotice === undefined) {- const n = drainSessionNotice(sess);- if (n) obj._sessionNotice = n;- }- // If the payload lives inside output, put the notices THERE as well: that is the object the- // caller actually reads, and a field on the envelope is easy to miss.- try {- if (typeof obj.output === 'string' && obj.output.charAt(0) === '{' && (obj._viewportOverride || obj._sessionNotice)) {- const j = JSON.parse(obj.output);- if (obj._viewportOverride && j._viewportOverride === undefined) j._viewportOverride = obj._viewportOverride;- if (obj._sessionNotice && j._sessionNotice === undefined) j._sessionNotice = obj._sessionNotice;- obj.output = JSON.stringify(j);- }- } catch (e) {}- } catch (e) {}-}-function maybeAttachBugInvite(obj, verb, argsIn) {- try {- if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;- if (obj._reportIssues) return; // handler already set one- // v1.9.96a β ONLY count AI-facing SUBSTANTIVE verbs. Two layers of machine traffic were eating- // the once-per-process invitation before any AI could see it, and each failed SILENTLY (the- // feature simply never appeared):- // 1. AD probes /health (+ /status for the bridge chip) every ~15s β those replies flow through- // this same sendJSON chokepoint. Excluded by requiring a `verb` at all.- // 2. AD also calls pup's DECLARED statusVerb (pup_readiness) through /command on spawn, so- // it arrives as a real verb and consumed the invite anyway (measured twice: the first verb- // after a restart still carried nothing).- // So the orientation invite now fires on the first SUBSTANTIVE verb β an open/drive, the moment- // John actually described ("when typical verbs are being invoked like open browser") β while- // read-only probes and enumerations neither consume nor carry it. Failures still always carry it,- // whatever the verb, because that is when the evidence exists.- if (!verb) return;- const PROBE_VERBS = new Set([- 'pup_readiness', 'pup_status', 'pup_describe', 'pup_prewarm',- 'pup_list_windows', 'pup_list_tabs', 'pup_rescan', 'pup_configure', 'pup_import_scan',- ]);- const isProbe = PROBE_VERBS.has(verb);- let mode = 'on';- try { mode = (pupSettings().reportIssues === 'off') ? 'off' : 'on'; } catch (e) {}- if (mode === 'off') return;- // v1.9.119: hand over any pending janitorial report. Verb-scoped for the same reason the invite is:- // AD's /health poll and its statusVerb probe would otherwise consume it before any AI saw it.- try {- if (!isProbe && obj._cleanupReport === undefined) {- const rep = drainCleanupReports();- if (rep) obj._cleanupReport = rep;- }- } catch (e) {}- const isError = obj.success === false || !!obj.errorCode || (!!obj.error && obj.error !== '');- // A FAILURE always earns the invitation, even on a probe β that is the highest-value moment.- if (isError) { obj._reportIssues = bugReportInvite(true); return; }- if (isProbe) return; // probes: never consume, never carry- _bugInviteCalls++;- const first = !_bugInviteFirstDone;- const periodic = _bugInviteCalls % BUG_INVITE_EVERY === 0;- if (!first && !periodic) return;- if (first) _bugInviteFirstDone = true;- obj._reportIssues = bugReportInvite(false);- } catch (e) { /* never let the invitation break a response */ }-}--function sendJSON(res, obj, status = 200) {- try {- // v1.0.16: runtime _hint injection β if this response is for a documented- // verb and the handler didn't already set its own _hint, attach the verb's- // canonical hint (+ related) from VERB_META. So EVERY pup_* call carries- // actionable guidance inline, matching the contract AD asks all bridges for.- // v2.0.363: session notices are attached HERE, not inside maybeAttachBugInvite. That function- // returns early on `if (obj._reportIssues) return`, and most substantive verbs already carry the- // bug-report invite, so anything I put after that guard was unreachable for exactly the verbs- // that matter. Two notices shipped into that dead zone before I read the control flow instead of- // assuming it. They are independent of the invite and belong at the chokepoint itself.- attachSessionNotices(obj, res && res._adomCommand, res && res._adomArgs);- maybeAttachBugInvite(obj, res && res._adomCommand, res && res._adomArgs);- if (obj && typeof obj === 'object' && !Array.isArray(obj)) {- const meta = res && res._adomCommand && VERB_META[res._adomCommand];- if (meta) {- if (obj._hint === undefined && meta.hint) obj._hint = meta.hint;- if (obj.related === undefined && Array.isArray(meta.related) && meta.related.length) obj.related = meta.related;- // SDK "THE ONE PRINCIPLE": every verb response carries _next (the obvious next verbs) too.- if (obj._next === undefined && (Array.isArray(meta.next) || Array.isArray(meta.related))) obj._next = meta.next || meta.related;- if (obj.pitfalls === undefined && Array.isArray(meta.pitfalls) && meta.pitfalls.length) obj.pitfalls = meta.pitfalls;- }- }- // v1.9.98 β CRITICAL DELIVERY FIX. Anything attached to the OUTER envelope is DROPPED on a- // SUCCESSFUL call: the conventional success shape is {success:true, output:"<json string>"}, and- // AD/the CLI unwrap `output` into the top level and discard the rest of the envelope. Measured:- // a pup_navigate success came back as {durationMs, ok, sessionId, status, tabId, url} β no- // _hint, no _next, no _reportIssues, even though sendJSON had set them. (Failures survive because- // their handlers set those fields on the outer object AND AD preserves the envelope on an error.)- // So the VERB_META hints and the bug-report invitation were only ever reaching callers on FAILURE- // β exactly backwards from the SDK's first principle that every response teaches the caller.- // Fix: when `output` is a JSON object string, merge these advisory fields INSIDE it too, without- // overwriting anything the handler already put there.- try {- if (obj && typeof obj === 'object' && typeof obj.output === 'string' && obj.output.charAt(0) === '{') {- const inner = JSON.parse(obj.output);- if (inner && typeof inner === 'object' && !Array.isArray(inner)) {- let touched = false;- // v2.0.111: credential hints surface on ANY verb, not just render verbs β the- // anti-phishing auto-fill note and the login-capture note must reach the caller on its- // very next call whatever that call is.- if (res && res._autoLoginNote && inner._autoLogin === undefined) { inner._autoLogin = res._autoLoginNote; touched = true; }- try {- const _cs = sessions.get(inner.sessionId || (obj && obj.sessionId));- if (_cs && _cs._pendingCredHint && inner._credentialCaptured === undefined) {- inner._credentialCaptured = _cs._pendingCredHint; _cs._pendingCredHint = null; touched = true;- }- } catch (e) {}- // v2.0.130 (#47, aiThread-first): every response naming a sessionId ALSO carries- // `window` (the new public name for the per-window label) and `aiThread` (the owning- // thread), so callers learn the new vocabulary with zero breakage; sessionId stays a- // back-compat alias. One teaching hint fires when an AI caller spelled out the id of- // its OWN thread's default window β zero-id addressing would have resolved it.- try {- if (inner.sessionId && inner.window === undefined) {- inner.window = inner.sessionId; touched = true;- const _os = sessions.get(inner.sessionId);- const _own = _os && (_os._ownerThread || _os.owner);- if (_own && inner.aiThread === undefined) inner.aiThread = _own;- const _ct = res && res._pupCallerThread;- if (res && res._pupExplicitSid && _ct && !USER_CALLERS.has(_ct) && inner._addressing === undefined- && _own === _ct && threadDefaultSessionId(_ct) === inner.sessionId) {- inner._addressing = 'FYI: this is YOUR thread\'s own window β omit the id entirely (zero-id addressing: pup resolves your --ai-thread name to your window). `window` is the new name for this label (sessionId remains a silent alias); pass either only when one thread runs several windows.';- }- }- } catch (e) {}- // v1.9.99: on a verb that just pointed a window at content, insist the caller LOOK at it.- // Only on success (a failure has its own errorCode + hint to act on first), and never- // overwriting a handler that already said something about verification.- try {- const _v = res && res._adomCommand;- const _ok = inner.ok !== false && obj.success !== false && !obj.errorCode;- if (_v && RENDER_VERBS.has(_v) && _ok && inner._verifyRender === undefined) {- const _rc2 = (res && res._renderCheck) || null;- if (_rc2 && inner.renderCheck === undefined) inner.renderCheck = _rc2;- // v1.9.118: CALL-TIME reprimand β fires on the URL itself, pass or fail.- try {- if (inner._urlLint === undefined) {- const _lu = inner.url || (obj && obj.url) || (res && res._lintUrl);- const _ml = urlLint(_lu, res && res._callerContainer);- if (_ml) { inner._urlLint = _ml.message; inner.urlLintCode = _ml.code; touched = true; }- }- } catch (e) {}- // v1.9.109: on ANY render verb, a detected failure must lead the hint the caller reads,- // not just pup_open_window's (which composes its own). reload/navigate get it here.- if (_rc2 && _rc2.ok === false && (_rc2.signals || []).length) {- const _st = (typeof _rc2.httpStatus === 'number') ? ' (HTTP ' + _rc2.httpStatus + ')' : '';- const _lead = '\u26D4 THE PAGE IS STILL BROKEN: ' + _rc2.signals.join(', ') + _st- + (_rc2.excerpt ? ' \u2014 it says "' + String(_rc2.excerpt).replace(/\s+/g, ' ').slice(0, 120) + '"' : '')- + '. Do NOT report success. See _verifyRender. ';- const _cur = (inner._hint !== undefined ? inner._hint : obj._hint) || '';- if (String(_cur).indexOf('STILL BROKEN') === -1 && String(_cur).indexOf('IS BROKEN') === -1) inner._hint = _lead + _cur;- }- inner._verifyRender = renderVerifyHint(inner.sessionId || (obj && obj.sessionId), inner.url || (obj && obj.url), _rc2);- touched = true;- }- } catch (e) {}- for (const k of ['_reportIssues', '_cleanupReport', '_autoLogin', '_hint', '_next', 'related', 'pitfalls']) {- if (obj[k] !== undefined && inner[k] === undefined) { inner[k] = obj[k]; touched = true; }- }- if (touched) obj.output = JSON.stringify(inner);- }- }- } catch (e) { /* output wasn't JSON β leave it exactly as the handler wrote it */ }- res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });- res.end(JSON.stringify(obj));- } catch (e) { /* response already sent */ }-}--function readBody(req) {- return new Promise(resolve => {- let d = '';- req.on('data', c => d += c);- req.on('end', () => resolve(d));- });-}--// v1.9.172 (jam fix): a pure IN-MEMORY snapshot of a session β NO CDP, so it can never hang.-// pup_list_windows falls back to this when a window is too busy (Chrome under load) to answer-// a live CDP read in time, so the list ALWAYS returns instead of stalling to null ("pup looks-// frozen"). Values come from the last successful read cached on the session.-function getSessionInfoFast(sessionId, session) {- return {- sessionId,- url: session._lastUrl || null,- title: session._lastTitle || null,- active: sessionId === activeSessionId,- errorCount: session.errors ? session.errors.length : 0,- profile: session.profileName,- tabCount: session.tabs ? session.tabs.length : 0,- activeTabId: session.activeTabId,- overlay: session._overlayDesc || (session._appOverlay ? 'favicon' : 'none'),- flashCount: session._flashCount || 0,- progressBarCount: session._progressCount || 0,- owner: session.owner || null,- // v1.9.223 (issue #22): say WHERE the owner came from β 'declared' (caller passed owner) vs- // 'caller-identity' (from AD's authenticated X-Adom-Caller-* headers, so not self-asserted).- ownerSource: session.owner ? (session._ownerFromCallerIdentity ? 'caller-identity' : 'declared') : null,- ageMinutes: session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null,- lastAgentUpdate: session._lastAgentUpdate || null,- lastForeground: session._lastForeground || null,- emulatedViewport: session._emulatedViewport || null,- degraded: true,- degradedReason: 'this window was busy (Chrome under heavy load) so pup returned its LAST-KNOWN state instead of stalling β the window is NOT crashed; retry shortly for live values',- };-}--async function getSessionInfo(sessionId, session) {- let url = null, title = null;- try {- url = session.page?.url() || null; // synchronous (puppeteer-cached frame URL) β never hangs- // page.title() is a CDP round-trip that HANGS on a jammed/busy page. Cap it hard and fall back- // to the last-known title so a single busy window can never stall pup_list_windows (v1.9.172).- if (session.page) title = await withTimeout(session.page.title(), 1200, 'title').catch(() => session._lastTitle || null);- } catch (e) {}- if (url) session._lastUrl = url; // cache for the degraded/fast path- if (title) session._lastTitle = title;- const info = {- sessionId,- url,- title,- active: sessionId === activeSessionId,- errorCount: session.errors.length,- profile: session.profileName,- tabCount: session.tabs.length,- activeTabId: session.activeTabId,- // v1.9.150: which taskbar overlay this window wears and why (favicon:<host> | count:N | none).- overlay: session._overlayDesc || (session._appOverlay ? 'favicon' : 'none (not yet applied)'),- flashCount: session._flashCount || 0,- progressBarCount: session._progressCount || 0,- progressBarMs: (session._progressMs || 0) + (session._progressSince ? Date.now() - session._progressSince : 0),- // v1.8.16: advisory ownership β which AI thread/task opened this window.- owner: session.owner || null,- // v1.9.223 (issue #22): say WHERE the owner came from β 'declared' (caller passed owner) vs- // 'caller-identity' (from AD's authenticated X-Adom-Caller-* headers, so not self-asserted).- ownerSource: session.owner ? (session._ownerFromCallerIdentity ? 'caller-identity' : 'declared') : null,- ageMinutes: session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null,- // v1.8.23: last thing the agent did here {tabId, verb, note, ts} β for the user +- // Session Monitor to see WHICH window, WHAT, and WHEN.- lastAgentUpdate: session._lastAgentUpdate || null,- // v1.8.26: did background-by-default actually take? 'backgrounded' (good),- // 'still-foreground' (focus steal β a bug), 'no-window', or null (foreground open- // or non-Windows). Self-verified by re-reading the OS foreground window post-open.- // v1.8.70: who put this window on the user's screen, when, and their stated reason β- // the audit answer to "why did this foreground?" in one status query.- lastForeground: session._lastForeground || null,- // v1.9.219 (issue #28): report a pinned viewport. A window that ignores OS resizing must be- // visibly in that state β otherwise it is indistinguishable from an app with broken layout code.- emulatedViewport: session._emulatedViewport || null,- background: (() => {- try {- const bg = browsers.get(session.profileName)?.browser?._bgResult || null;- // v1.8.59: a park-failed window is sitting invisible off-screen (deliberately β better- // than popping over the user's work). Any status/list query opportunistically retries- // the heal, so it converges to 'backgrounded' without waiting for a tab touchpoint.- if (bg === 'park-failed') healOffscreenWindow(session, sessionId).then(r => { try { if (r) browsers.get(session.profileName).browser._bgResult = r; } catch (e) {} }).catch(() => {});- return bg;- } catch (e) { return null; }- })(),- // v1.8.45: PROGRAMMATIC flash state. 'none' = not flashed; 'pending' = flashed and the- // user has NOT activated it since (taskbar still lit); 'cleared' = the user activated it- // (clicked its taskbar button) after the flash, so the tint is gone. Computed from the- // server-side flash timestamp vs the in-page focus latch (window.__pupFocusTs, set by the- // injected 'focus' listener). Lets a caller answer "which windows are still flashing?"- // without a screenshot.- flash: await (async () => {- try {- const key = session._sessionId || sessionId;- const flashTs = _flashState.get(key) || 0;- if (!flashTs) return 'none';- let focusTs = 0;- // v1.9.172: cap this CDP eval too β on a jammed page it would hang getSessionInfo just like- // title() did. On timeout we can't know if the flash was cleared, so report 'pending' (safe:- // it just means "may still be flashing", never a false 'cleared').- try { focusTs = await withTimeout(session.page.evaluate(() => window.__pupFocusTs || 0), 800, 'flash-focus').catch(() => 0); } catch (e) {}- return (focusTs && focusTs > flashTs) ? 'cleared' : 'pending';- } catch (e) { return null; }- })(),- };- // One-shot: surface launch-time metadata (restored-tab cleanup count)- // so callers can verify session-restore prevention worked. Drop after read.- if (session.lastLaunchMeta) {- info.restoredTabsClosed = session.lastLaunchMeta.restoredTabsClosed;- delete session.lastLaunchMeta;- }- return info;-}--// v1.8.16: shared ownership gate for mutating verbs (navigate / close). Returns a-// refusal payload when the caller DECLARED an owner that mismatches the session's-// (send it as-is), else null. Anonymous callers pass (advisory model β we can't-// authenticate threads), but open_window's hints teach them to declare.-function ownershipRefusal(session, sessionId, args, verb) {- const caller = (args && typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;- if (!session || !session.owner || !caller || caller === session.owner || (args && args.takeover)) return null;- let curUrl = null; try { curUrl = session.page?.url() || null; } catch {}- const age = session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null;- return {- success: false,- errorCode: 'session_owned_by_another_thread',- error: `Session "${sessionId}" belongs to another thread (owner "${session.owner}", opened ${age} min ago, currently showing ${curUrl}).`,- sessionId, owner: session.owner, requestedBy: caller, currentUrl: curUrl, ageMinutes: age,- _hint: `STOP β this window belongs to AI thread "${session.owner}", not you ("${caller}"). ${verb} on it would break that thread's work. Open your OWN window instead: pup_open_window with a UNIQUE task-prefixed sessionId (e.g. "${caller}-web") and owner:"${caller}". Only pass takeover:true if the user EXPLICITLY told you to repurpose this window. pup_status lists every window + its owner.`,- };-}--// v1.9.224 β dashboard state. Deliberately built from the SAME in-memory registries the verbs use,-// with NO CDP calls, so opening the dashboard can never wake a throttled page, disturb a window, or-// add load to the shared socket. It reports what pup BELIEVES, which is exactly what you want when-// debugging pup: a divergence between this and the screen IS the bug.-const _tabFavCache = new Map(); // tab URL -> {buf, mime} (v1.9.225)-async function dashboardSnapshot() {- const now = Date.now();- const wins = [];- for (const [sid, sess] of sessions) {- const tabs = (sess.tabs || []).map(t => {- let u = null, ttl = null;- try { u = t.page && t.page.url(); } catch (e) {}- try { ttl = t._lastTitle || null; } catch (e) {}- // v1.9.238: attach how THIS tab's favicon was obtained (format/mode/transparent/kind). Comes- // from the rich resolver's cache, keyed by the tab's URL; the active tab also has the live- // overlay state. Shown in each tab's dashboard row so the favicon logic is fully transparent.- let fav = null;- try { const fm = u && _tabFavMeta.get(u); if (fm) fav = fm.meta; } catch (e) {}- if (t.tabId === sess.activeTabId && sess._overlayFav) { const { b64, ...m } = sess._overlayFav; fav = m; }- return {- tabId: t.tabId, url: u, title: ttl,- active: t.tabId === sess.activeTabId,- crashed: !!t._crashed,- crashReason: t._crashReason || null,- opener: t.opener || null,- fav,- };- });- const hf = sess._highFpsUntil && sess._highFpsUntil > now;- wins.push({- sessionId: sid,- profile: sess.profileName,- owner: sess.owner || null,- ownerSource: sess.owner ? (sess._ownerFromCallerIdentity ? 'caller-identity' : 'declared') : null,- ownerThread: sess._ownerThread || null,- ownerContainer: sess._ownerContainer || null,- lastThread: sess._lastThread || null,- foreground: !!sess._foreground,- foregroundReason: (sess._lastForeground && sess._lastForeground.reason) || null,- foregroundAt: (sess._lastForeground && sess._lastForeground.ts) || null,- foregroundVerb: (sess._lastForeground && sess._lastForeground.verb) || null,- background: sess._bgResult || null,- highFps: !!hf,- highFpsMinutesLeft: hf ? Math.max(0, Math.round((sess._highFpsUntil - now) / 60000)) : null,- openedAt: sess.createdAt || null,- uptimeMs: sess.createdAt ? now - sess.createdAt : null,- tabCount: tabs.length,- tabs,- overlay: sess._overlayDesc || null,- overlayWhy: sess._overlayWhy || null,- // v1.9.237 (John: "put an led indicator indicating the check for overlay icons match came back- // green vs red"). Per-window overlay health, the source of truth the LED rolls up:- // green = pup painted a REAL page favicon and the exact bytes are on disk to serve the badge- // amber = painted, but the page had no favicon so it's the legit generic fallback- // red = not painted yet / paint failed β the taskbar button would be bare or wrong- _curAppId: sess._curAppId || null,- // v1.9.316 (John: "why doesn't the dashboard say if i'm logged in or not to the wiki for pup- // windows that are wiki-centric icons?"). pup already tracks the wiki login state in- // sess._wikiAuthed (drives the signed-in glyph); surface it on any window whose active page is an- // Adom wiki URL so the dashboard shows SIGNED IN vs PUBLIC per wiki window.- wiki: (() => {- try {- const at = (tabs || []).find(t => t && t.active) || (tabs || [])[0];- const u = (at && at.url) || sess._lastUrl || null;- if (u && isAdomUrl(u)) return { authed: !!sess._wikiAuthed, view: sess._wikiView || (sess._wikiAuthed ? 'authed' : 'public') };- } catch (e) {}- return null;- })(),- // v2.0.227: pup KNEW this window's button was never rebuilt (SKIP-FG) yet the chip still read- // green MATCH while the taskbar showed the stock Chrome name + stale art. Surface it.- identityPending: !!(sess._nudgePending),- identityPendingMs: sess._nudgePending ? Date.now() - sess._nudgePending.since : 0,- overlayLed: (() => {- const painted = !!sess._appOverlay;- // v2.0.230: this ran a SYNC stat per session on every 2s dashboard push. The overlay- // compositor already records whether it wrote bytes for this session, so trust the flag and- // only touch the disk when it has never painted (the rare, genuinely-unknown case).- const hasBytes = (sess._overlayBytesAt ? true : (() => { try { return fs.existsSync(path.join(os.tmpdir(), 'pup-badge-debug', sid + '.png')); } catch (e) { return false; } })());- if (!painted || !hasBytes) return 'red';- return sess._overlayIsGeneric ? 'amber' : 'green';- })(),- hwnd: sess._hwnd || null,- emulatedViewport: sess._emulatedViewport || null,- lostBrowser: !!sess._lostBrowser,- lastAgentUpdate: sess._lastAgentUpdate || null,- errorCount: (sess.errors || []).length,- // v1.9.257 (John: "if there's an err, let the tooltip show what the error is"): the last few- // actual error texts ride along so the err tag can explain itself instead of just counting.- recentErrors: (sess.errors || []).slice(-5).map(e => ({ type: e.type, text: String(e.text || '').slice(0, 160) })),- flashCount: sess._flashCount || 0,- url: (() => { try { return sess.page && sess.page.url(); } catch (e) { return sess._lastUrl || null; } })(),- });- }- // v1.9.225 (John): group into the HIERARCHY the user actually sees β TASKBAR BUTTON β window(s) β- // tabs. Which windows share a button depends on pup's taskbarGrouping setting: 'split' gives each- // window its own AUMID (its own button), 'grouped' stacks them all under one "Adom Pup" button, and- // under minimalTouch pup stamps no identity at all so they are plain Chrome buttons. Modelling this- // explicitly is also diagnostic: two sessions sharing one hwnd (the duplicate-session bug) show up- // as two rows under a single button instead of looking like two unrelated windows.- const grouping = (() => { try { return pupSettings().taskbarGrouping || 'split'; } catch (e) { return 'split'; } })();- const buttons = new Map();- for (const w of wins) {- let key, label;- // v1.9.233 (John: "why do i see 3 rows for main taskbar icons yet i only see 2 taskbar icons?"):- // under minimal-touch pup stamps NO per-window AUMID, and Windows groups unbranded windows by- // their PROCESS - so ALL windows of one Chrome profile share ONE taskbar button. Keying by- // hwnd/session invented a button per window and the dashboard showed more rows than the real- // taskbar had icons. Key by PROFILE (= one Chrome process = one button), which is why the header- // count "chromes N" equals the number of real taskbar icons.- // v1.9.248: AUMID stamping is REVERTED (settled no-AUMID decision; see- // dev-skills/pup-aumid-decisions). This branch remains ONLY to truthfully model windows that- // were stamped before the revert - the AUMID property lives on the window until it closes, so- // those really do keep their own taskbar buttons. New windows never enter it.- // v1.9.306 (John flashed his live windows so I could finally SEE the real taskbar clearly): my- // 1.9.298 change was WRONG. I claimed "every pup window wears the teal TILE" from a blurry crop,- // and forced the dashboard to draw teal for all. The flash proved the opposite: with aumidIcons- // OFF (the settled default), the LIVE pup windows wear the **Chrome-for-Testing exe icon** + the- // pup overlay β the teal tiles I saw were GHOST buttons from dead AUMID-stamped windows, not the- // live ones. So the dashboard must show what the window ACTUALLY wears: the teal stamp tile ONLY- // when the window is genuinely AUMID-stamped (_curAppId), otherwise the real Chrome-for-Testing- // exe icon. That makes the strip match the glass again.- let baseIcon;- if (w._curAppId) {- key = 'aumid:' + w._curAppId;- label = w.sessionId;- baseIcon = '/dashboard/stampicon?session=' + encodeURIComponent(w.sessionId);- } else {- key = 'chrome-process:' + w.profile;- label = 'Chrome (' + w.profile + ')';- baseIcon = '/dashboard/baseicon?profile=' + encodeURIComponent(w.profile);- }- if (!buttons.has(key)) buttons.set(key, { key, label, kind: 'process', baseIcon, grouping: minimalTouch() ? 'minimal-touch' : grouping, windows: [], badgeSession: null, badgeAt: 0, badgeVia: null });- const btn = buttons.get(key);- btn.windows.push(w.sessionId);- // Windows shows the overlay of whichever window is FOREMOST in the group. Mirror that: prefer the- // foreground window's badge; otherwise the newest-painted. Record which, so the LED can say whether- // the dashboard badge matches what the taskbar button is actually wearing.- const paintedAt = (w.overlay && !String(w.overlay).startsWith('none') && w.overlayWhy && w.overlayWhy.at) || 0;- if (w.foreground) { btn.badgeSession = w.sessionId; btn.badgeAt = Infinity; btn.badgeVia = 'foreground window'; btn.badgeLed = w.overlayLed; }- else if (paintedAt > btn.badgeAt) { btn.badgeAt = paintedAt; btn.badgeSession = w.sessionId; btn.badgeVia = 'newest-painted (no window foreground)'; btn.badgeLed = w.overlayLed; }- }- // v1.9.237: button LED = green only if EVERY window under it wears a real-favicon overlay (a single- // bare/wrong window makes the whole taskbar button suspect); red if any window is bare; amber if all- // painted but some are the generic fallback. This is the "do the dashboard overlays match the taskbar"- // check John asked to see as a green/red light.- for (const btn of buttons.values()) {- const leds = btn.windows.map(sid => { const w = wins.find(x => x.sessionId === sid); return w ? w.overlayLed : 'red'; });- // v2.0.227: an identity pup stamped but never got onto the SHELL (skipped nudge) means this- // button is displaying the WRONG name/art right now β that is a red, not a green.- const _pend = btn.windows.filter(sid => { const w = wins.find(x => x.sessionId === sid); return w && w.identityPending; });- btn.identityPending = _pend.length;- btn.led = _pend.length ? 'red' : leds.includes('red') ? 'red' : leds.includes('amber') ? 'amber' : 'green';- btn.ledWhy = _pend.length ? `identity stamped but the shell button was never rebuilt on ${_pend.length} window(s) (the nudge is skipped while a window is focused) β this button still shows its PREVIOUS name/art; a retry is queued`- : leds.includes('red') ? 'a window under this button has NO overlay painted (taskbar would be bare/stale)'- : leds.includes('amber') ? 'all painted, but a window fell back to the generic icon (page had no favicon)'- : 'every window wears its real page-favicon overlay β dashboard matches the taskbar';- }- // v1.9.248 (John: "your dashboard view of the windows taskbar icons is not accurate to what is- // showing in the taskbar and yet you have a green led indicating it is. so you are still lying to- // me"). He is right: the LED asserted "matches the taskbar" while only checking pup's own- // bookkeeping. Windows offers no API to read BACK a button's overlay, but pup CAN catch the worst- // lie: real taskbar buttons from pup-managed Chrome processes that pup has NO session for (zombie- // windows from lost sessions - his two mystery teal tiles). Enumerate (cached 12s) and surface- // them as untracked; the header can only claim MATCH when there are none.- let untracked = [];- try {- const nowT = Date.now();- if (!dashboardSnapshot._enumAt || nowT - dashboardSnapshot._enumAt > 12000) {- dashboardSnapshot._enumAt = nowT;- dashboardSnapshot._enumHwnds = await Promise.race([enumAllPupChromeHwnds(), new Promise(r => setTimeout(() => r(null), 9500))]) || dashboardSnapshot._enumHwnds || [];- }- const known = new Set(wins.map(w => Number(w.hwnd)).filter(Boolean));- untracked = (dashboardSnapshot._enumHwnds || []).filter(h => !known.has(Number(h)));- } catch (e) {}- const threads = [...new Set(wins.map(w => w.owner || w.ownerThread).filter(Boolean))].sort();- const profiles = {};- for (const w of wins) profiles[w.profile] = (profiles[w.profile] || 0) + 1;- let depsOk = false; try { require.resolve('puppeteer'); depsOk = true; } catch (e) {}- return {- ts: now,- bridge: { version: BRIDGE_VERSION, platform: process.platform, uptimeMs: Math.round(process.uptime() * 1000), depsOk, minimalTouch: minimalTouch(), flashedPending: _flashedHwnds.size, settings: pupSettings(), loops: loopReport(), win32Calls: (win32.stats ? win32.stats() : {}), win32InProcess: win32.available(), abEnumLastMin: (_enumWindow && Date.now() - _enumWindow.at < 60000 ? _enumWindow.n : 0) },- counts: { windows: wins.length, tabs: wins.reduce((a, w) => a + w.tabCount, 0), browsers: browsers.size, threads: threads.length },- threads, profiles,- taskbarButtons: [...buttons.values()],- untrackedWindows: untracked,- toasts: _dashToasts.filter(t => Date.now() - t.at < 15000),- regen: _regenBusy ? { running: true, inFlight: (_lastRegen && _lastRegen.inFlight) || 0 }- : (_lastRegen && _lastRegen.done ? { running: false, at: _lastRegen.at, swept: _lastRegen.swept, painted: _lastRegen.painted, changed: _lastRegen.changed } : null),- windows: wins.sort((a, b) => (b.openedAt || 0) - (a.openedAt || 0)),- };-}---// v1.9.224 β the dashboard page. Single self-contained document (no CDN, no build step) because it-// is served by the bridge itself and must work on a machine with no network. Adom dark theme.-// The six "say it" phrases the dock shows on its card and this dashboard lists at the top. They-// teach the user to ASK for pup by its noun instead of hunting for buttons (John, via the Dock Bar-// guide), so they are deliberately verbs a person would actually say out loud.-const SAY_PHRASES = [- 'open your dashboard in pup',- 'open my wiki page in pup',- 'open my app in pup',- 'screenshot this site in pup',- 'record a clip of this page in pup',- 'show me the logged-out view in pup',-];-// "Play the demo" hands the dock card's demo script to a fresh AI thread rather than running a-// scripted sequence from here. The scripted runner lived in the container-side helper, and the-// desktop cannot reach the container's loopback (measured: a pup window opened on a container port-// came up with zero tabs), so a button on this desktop-served page could never have called it.-// Driving it through the AI is also the honest demonstration: pup's whole point is that you ASK.-const DEMO_PROMPT = 'Run the pup demo and show me what pup can do on my machine.\n\n'- + '1. Open a pup window to https://wiki.adom.inc/adom/pup-bridge. Tell me it opened in the BACKGROUND, pup never steals my screen, and that I can ask you to open one in the foreground any time I want to watch, or just click its taskbar button myself.\n'- + '2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n'- + '3. Record a short clip of the window. Mention highFps mode, which records demo video at full frame rate while the window stays in the background.\n'- + '4. Annotate the page: draw on it and capture it, and show me the gliding cursor with its intent narration so I can see what you are doing and why.\n'- + '5. Read the pup dashboard LED status and tell me each light. If anything is not green, name the exact verb that fixes it and offer to run it.\n\n'- + 'Then close the demo window so nothing is left on my desktop.';--// The local Hydrogen control API. THE PORT IS DYNAMIC AND MUST BE DISCOVERED (v2.0.351).-// This used to hardcode 47084 with a fallback to ~/.adom/hydrogen-control-url. Both are-// CONTAINER-side facts that I inherited when the say-strip moved from the container helper into-// this bridge, and neither holds on the desktop: there is no hydrogen-control-url file there, and-// ah's control API was actually on 63549. So every call to Hydrogen from this bridge was hitting a-// closed port. The version gate failed OPEN (an unreachable probe is treated as "cannot read, do-// not block"), so it looked healthy while gating nothing, and the module's send relay answered 502.-// Found by the module's checkSend probe, which exists for exactly this: a missing gate reads as an-// allowed gate rather than as an error.-//-// The discovery responder answers on one of four fixed candidates and its body carries the real-// control_url. Probe, cache, and RE-PROBE whenever a call fails, because the port moves when ah-// restarts and a cached value is stale from that moment on.-const HYDROGEN_DISCOVERY_PORTS = [47080, 48080, 49180, 50280];-let HYDROGEN_CTL = null;-let _ctlAt = 0;-function _httpGetJson(url, timeoutMs) {- return new Promise((resolve) => {- const req = http.get(url, { timeout: timeoutMs || 1500 }, (r) => {- let b = ''; r.on('data', d => { b += d; });- r.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve(j); });- });- req.on('error', () => resolve(null));- req.on('timeout', () => { req.destroy(); resolve(null); });- });-}-async function hydrogenControlUrl(force) {- if (!force && HYDROGEN_CTL && Date.now() - _ctlAt < 300000) return HYDROGEN_CTL;- // In a container the workspace file is authoritative and costs nothing to read.- try {- const u = fs.readFileSync(path.join(os.homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim();- if (u) { HYDROGEN_CTL = u.replace(/\/$/, ''); _ctlAt = Date.now(); return HYDROGEN_CTL; }- } catch (e) {}- for (const port of HYDROGEN_DISCOVERY_PORTS) {- const j = await _httpGetJson(`http://127.0.0.1:${port}/`, 1200);- if (j && j.control_url) {- HYDROGEN_CTL = String(j.control_url).replace(/\/$/, '');- _ctlAt = Date.now();- console.log(`[hydrogen] control API discovered at ${HYDROGEN_CTL} (via responder on ${port})`);- return HYDROGEN_CTL;- }- }- HYDROGEN_CTL = null;- return null;-}-async function hydrogenGet(urlPath, timeoutMs) {- let r = await _hydrogenGetOnce(await hydrogenControlUrl(false), urlPath, timeoutMs);- // A dead port means the cached URL is stale (ah restarted), not that Hydrogen is gone.- if (!r.status) r = await _hydrogenGetOnce(await hydrogenControlUrl(true), urlPath, timeoutMs);- return r;-}-function _hydrogenGetOnce(base, urlPath, timeoutMs) {- if (!base) return Promise.resolve({ status: 0, error: 'hydrogen control API not discovered' });- return new Promise((resolve) => {- const req = http.get(base + urlPath, { timeout: timeoutMs || 2500 }, (res2) => {- let b = ''; res2.on('data', (d) => { b += d; });- res2.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res2.statusCode, json: j, raw: b, contentType: res2.headers['content-type'] || null }); });- });- req.on('error', (e) => resolve({ status: 0, error: e.message }));- req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });- });-}-async function hydrogenPost(urlPath, bodyObj, timeoutMs) {- let r = await _hydrogenPostOnce(await hydrogenControlUrl(false), urlPath, bodyObj, timeoutMs);- if (!r.status) r = await _hydrogenPostOnce(await hydrogenControlUrl(true), urlPath, bodyObj, timeoutMs);- return r;-}-function _hydrogenPostOnce(base, urlPath, bodyObj, timeoutMs) {- if (!base) return Promise.resolve({ status: 0, error: 'hydrogen control API not discovered' });- return new Promise((resolve) => {- const data = JSON.stringify(bodyObj);- const req = http.request(base + urlPath, { method: 'POST', timeout: timeoutMs || 4000,- headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, (res2) => {- let b = ''; res2.on('data', (d) => { b += d; });- res2.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res2.statusCode, json: j, raw: b, contentType: res2.headers['content-type'] || null }); });- });- req.on('error', (e) => resolve({ status: 0, error: e.message }));- req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });- req.end(data);- });-}-// Release-version gate. GET /updater/status -> updater.current_version is the ONE endpoint that-// carries a release version: /version returns an endpoint list, and /health and /buildinfo carry-// only build_sha / build_epoch, which are worse than useless as gates (build_sha lies on-// incremental builds; build_epoch pins the feature to one machine's build clock, so every user on-// a stock signed release is refused forever). A pre-1.0.142 shell still carries the crash family-// this feature was parked for. UNREADABLE FAILS OPEN: never block on a field we cannot read.-const SEND_MIN_RELEASE = [1, 0, 142];-let _shellGate = { at: 0, ok: true, why: '' };-async function shellSupportsSend() {- if (Date.now() - _shellGate.at < 60000) return _shellGate;- const r = await hydrogenGet('/updater/status');- const u = (r && r.json && (r.json.updater || r.json)) || null;- const raw = u && typeof u.current_version === 'string' ? u.current_version.trim() : '';- const m = raw.match(/^(\d+)\.(\d+)\.(\d+)/);- let out = { ok: true, why: '' };- if (m) {- const v = [+m[1], +m[2], +m[3]];- let cmp = 0;- for (let i = 0; i < 3; i++) { const d = (v[i] || 0) - (SEND_MIN_RELEASE[i] || 0); if (d) { cmp = d < 0 ? -1 : 1; break; } }- if (cmp < 0) out = { ok: false, why: 'Your Hydrogen (' + raw + ') predates the Send to AI fix. Update to 1.0.142 or newer to enable it; the phrase was copied instead.' };- }- _shellGate = { at: Date.now(), ...out };- return _shellGate;-}--const DASHBOARD_HTML = `<!doctype html><html lang="en"><head>-<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">-<title>Pup live windows</title>-<link rel="icon" type="image/svg+xml" href="/dashboard/icon.svg?v=${BRIDGE_VERSION}">-<style>-@import url('https://adom.inc/fonts/fonts.css');-*{box-sizing:border-box} html,body{margin:0;background:#0d1117;color:#c9d1d9;font:14px/1.5 Satoshi,"Segoe UI",system-ui,sans-serif}-/* v2.0.97 (John): brand scrollbars everywhere β the stock chrome bar looked bolted-on in the- settings dialog. Thin, rounded, track invisible, thumb on the border token. */-::-webkit-scrollbar{width:9px;height:9px}-::-webkit-scrollbar-track{background:transparent}-::-webkit-scrollbar-thumb{background:#30363d;border-radius:8px;border:2px solid #0d1117}-::-webkit-scrollbar-thumb:hover{background:#484f58}-::-webkit-scrollbar-corner{background:transparent}-a{color:#64abff}-header{position:sticky;top:0;z-index:10;background:#161b22;border-bottom:1px solid #30363d;height:44px;padding:0 16px;display:flex;gap:10px;align-items:center}-/* ββ DOCK CONTRACT regions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ- The newbie panel is NO LONGER hand-rolled here. adom-ui-design 2.0.0 ships it as ONE shared- module and pup mounts it (see /dashboard/newbie-panel.js). Its CSS, markup, icons, clipboard- path, send path, toasts, tooltips and open/closed memory all live in the module. Every rule the- old hand-rolled panel declared is DELETED rather than left beside the module: a stray .btn or- .ibtn declared after the module's rules is exactly how other apps silently lost their styling to- source order. What remains here is the status pill row, which is its own contract region. */-/* THE FLEET GUTTER (adom-ui-design 2.4.0). ONE number, declared once, used by the panel's mount- container AND by every region under it, so they share an edge because there is a single source- of truth rather than because two literals happen to match. That "by luck rather than by rule"- distinction was my own observation about this page and it is now a fleet rule.- pup was full-bleed at ZERO a side, not the ~4px it looked like: #newbie is a direct child of- body with no padding, so the panel measured exactly clientWidth and the 9px gap from the 744- pane was entirely the scrollbar. selfCheck could not see it while it only measured inside the- panel.- 2.5.0 no longer cares WHICH element carries the padding: it finds the nearest scrolling ancestor- and checks that the panel sits 16px inside it on both sides. The contract is where the edges- land, not the structure. (Earlier versions named an element and were wrong twice: measuring- documentElement failed correct app-shell pages by a scrollbar width, and measuring the mount div- would have red-lined every page that carried the gutter on an outer container.)- pup stays with the TOKEN rather than nesting, and that is now the fleet ruling for this shape:- the pill row and filter bar below carry FULL-BLEED bottom borders as separators, and a rule that- spans the pane reads as a divider between regions, while an inset one reads as a card edge. The- panel is the only card in that stack, so the separators stay full width and the gutter is shared- through one token instead. */-:root{--gutter:16px}-#newbie{padding:0 var(--gutter)}-.ledrow{display:flex;flex-wrap:wrap;gap:10px;padding:12px var(--gutter);border-bottom:1px solid #30363d}-.led-pill{display:inline-flex;align-items:center;gap:7px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:default}-.led-pill .d{width:8px;height:8px;border-radius:50%;flex:none}-.led-pill .d.ok{background:#3fb950}.led-pill .d.warn{background:#d29922}.led-pill .d.fail{background:#f85149}-.led-pill .lb{font-weight:600;color:#e6edf3}.led-pill .sh{color:#8b949e}-#recall[aria-pressed="true"]{color:#00b8b1;background:#21262d}-/* 620px, NOT 760px (adom-ui-design 1.25.0). Measured dock panes run about 744px to 853px, so a- 760px breakpoint sat INSIDE that band and the two-column panel collapsed in the very place it- was designed for: John's pane is 744px, so he never once saw the layout this panel specifies.- 620px clears the whole pane band while still collapsing on a phone (390px). */-@media(max-width:620px){.nb-cols{grid-template-columns:1fr}.nb-right{border-left:0;border-top:1px solid #30363d;padding-left:0;padding-top:16px}.play{width:100%;justify-content:center}}-.brand{position:relative;display:flex;align-items:center;gap:7px;color:#00b8b1;font-family:"Familjen Grotesk",Satoshi,sans-serif;font-weight:600;font-size:14px;cursor:default}-.brand .bicon{width:16px;height:16px;color:#e6edf3}-.brand .bsub{color:#8b949e;font-weight:500}-.vsep{width:1px;height:22px;background:#30363d;flex:0 0 1px}-.subject{display:flex;align-items:baseline;gap:10px;min-width:0;flex:1 1 auto;overflow:hidden}-.sname{font-weight:700;color:#e6edf3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}-.smeta{color:#8b949e;font-size:12px;font-family:"JetBrains Mono",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}-.smeta b{color:#c9d1d9;font-weight:500}-@media(max-width:720px){.smeta{display:none}}-.acts2{margin-left:auto;display:flex;gap:6px}-.ibtn{width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid #30363d;border-radius:8px;color:#e6edf3;cursor:pointer;transition:background .15s ease}-.ibtn:hover{background:#1c2128;border-color:#00b8b1}-.ibtn.danger:hover{border-color:#f85149;color:#f85149}-.ibtn svg{width:16px;height:16px}-.pop{position:fixed;z-index:99999;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:10px 12px;font-size:12px;color:#c9d1d9;font-weight:400;font-family:Satoshi,sans-serif;display:none;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:auto;line-height:1.7}-.pop.show{display:block}-#bar{display:flex;gap:10px;align-items:center;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d;flex-wrap:wrap}-.dot{width:8px;height:8px;border-radius:50%;background:#3fb950;display:inline-block;margin-right:6px}-.dot.stale{background:#d29922}-.pill{background:#111b23;border:1px solid #1d2833;border-radius:999px;padding:3px 10px;font-size:12px;color:#9fb0c0}-.pill b{color:#e6edf3;font-weight:600}-select,input{background:#111b23;color:#e6edf3;border:1px solid #23303c;border-radius:6px;padding:5px 9px;font-size:13px}-.btn{background:#16222c;border:1px solid #2a3a47;color:#cfe0ea;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer}-.btn:hover{background:#1d2b36;border-color:#00b8b1}-.btn.sm{padding:2px 8px;font-size:11px}-.btn.xs{padding:0 6px;font-size:11px;line-height:16px}-.btn.danger{border-color:#5c2b2b;color:#ff9d95}-.btn.danger:hover{background:#2a1717;border-color:#f85149}-.sub{margin-left:auto;color:#7d8f9f;font-size:12px}-.loops{margin:18px 0 6px;padding:10px 12px;border:1px solid #30363d;border-radius:8px;background:#161b22;font:11.5px ui-monospace,monospace;color:#8b949e}-.lpt{color:#7d8f9f;margin:2px 0 6px}-.lp{display:inline-block;margin:0 10px 6px 0;padding:2px 7px;border:1px solid #30363d;border-radius:6px;color:#c9d1d9}-.lp b{color:#00b8b1}-.lp.hot{border-color:#f85149;color:#f85149}-.lp.hot b{color:#f85149}--/* taskbar strip: mimic of the real Windows taskbar */-#toasts{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column-reverse;gap:8px;align-items:center}-.toast{background:#21262d;border:1px solid #30363d;border-left:3px solid #00b8b1;color:#c9d1d9;border-radius:8px;padding:8px 14px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.5);transition:opacity .3s ease}-.toast.success{border-left-color:#3fb950}-.toast.error{border-left-color:#f85149}-.toast.out{opacity:0}-/* ===== Settings modal (adom-ui-design / settings-layout, v2.0.296) =====- Tokens first: a scheme swap must change nothing but these. */-#dlgWrap{position:fixed;inset:0;z-index:99998;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--accent:#00b8b1;--on-accent:#05221f;--red:#f85149;--green:#3fb950;--brand-heading:"Familjen Grotesk",Satoshi,sans-serif;--brand-body:Satoshi,"Familjen Grotesk",sans-serif}-/* the backdrop law: DIM 25%, never blur (settings often change the very thing behind the modal) */-#dlgBg{position:absolute;inset:0;background:rgba(0,0,0,.25)}-/* the radius element never scrolls: #dlg clips, .settings-body is the scroller */-#dlg{position:absolute;top:48px;left:50%;transform:translateX(-50%);width:min(880px,94vw);max-height:calc(100vh - 96px);display:flex;flex-direction:column;overflow:hidden;padding:0;background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.6);outline:none}-.dlghead{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:var(--surface);border-bottom:1px solid var(--border);font-family:var(--brand-heading);font-weight:600;font-size:15px;color:var(--text)}-.settings-content{display:flex;flex:1 1 auto;min-height:0}-.settings-side{width:13rem;flex-shrink:0;border-right:1px solid var(--border);overflow:auto;padding:.75rem .5rem}-.settings-body{flex:1;min-width:0;overflow:auto;padding:1rem 1.25rem;scroll-behavior:smooth;max-height:70vh}-/* house scrollbar: thin, transparent track, translucent pill, teal on hover */-.settings-body::-webkit-scrollbar,.settings-side::-webkit-scrollbar{width:8px;height:8px}-.settings-body::-webkit-scrollbar-track,.settings-side::-webkit-scrollbar-track{background:transparent;margin:12px 0}-.settings-body::-webkit-scrollbar-thumb,.settings-side::-webkit-scrollbar-thumb{background:rgba(139,148,158,.35);border-radius:8px;border:2px solid transparent;background-clip:padding-box}-.settings-body::-webkit-scrollbar-thumb:hover,.settings-side::-webkit-scrollbar-thumb:hover{background:var(--accent);background-clip:padding-box}-/* sidebar: every entry a button; parents expanded; indentation is the hierarchy cue */-.side-sec{width:100%;padding:.3rem .5rem;border-radius:6px;display:flex;align-items:center;gap:.25rem;background:none;border:none;color:var(--text);font-family:var(--brand-body);font-size:.8125rem;text-align:left;cursor:pointer;user-select:none}-.side-sub{width:100%;padding:.25rem .5rem .25rem 1.5rem;border-radius:6px;display:flex;align-items:center;background:none;border:none;color:var(--text-body);font-family:var(--brand-body);font-size:.75rem;text-align:left;cursor:pointer;user-select:none}-.side-sec:hover,.side-sub:hover{color:var(--accent);background:rgba(255,255,255,.06)}-.side-sec.selected,.side-sub.selected{color:var(--accent);font-weight:700}-.side-sec:focus-visible,.side-sub:focus-visible{outline:2px solid var(--accent);outline-offset:1px}-/* content hierarchy: section (h2) > card (h3) > row */-.sec-title{height:2rem;display:flex;align-items:center;margin:.25rem 0 .5rem;font-family:var(--brand-heading);font-size:1.6825rem;font-weight:800;color:var(--accent);scroll-margin-top:.75rem}-.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem;scroll-margin-top:.75rem}-.card-title{height:2rem;display:flex;align-items:center;gap:10px;margin:0;font-family:var(--brand-heading);font-size:1.3125rem;font-weight:600;color:var(--accent)}-.card-status{font-family:var(--brand-body);font-size:11px;color:var(--text-2);font-weight:400}-.card-status.ok{color:var(--green)}-.card-desc{margin:.2rem 0 .5rem;font-family:var(--brand-body);font-size:.8125rem;line-height:1.55;color:var(--text-2);max-width:60ch}-.card-desc code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}-.divider{height:1px;background:var(--border);margin:.55rem 0}-.set-row{padding:.5rem 0;display:flex;flex-direction:column;gap:.25rem}-.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}-.row-name{margin:.125rem 0;font-family:var(--brand-heading);font-size:.9375rem;font-weight:600;color:var(--text)}-.row-name .title{color:#8fd3d4}-.row-desc{margin:0;font-family:var(--brand-body);font-size:.8125rem;line-height:1.55;color:var(--text-2);max-width:60ch}-.default-hint{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}-.row-why{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);font-style:italic}-/* dependent settings: indented under the parent with a spine, dimmed (never hidden) when the parent is off */-.setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}-.setting-children[data-enabled='false']{opacity:.55}-.setting-children[data-enabled='false'] .row-name{color:var(--text-2)}-.setting-children[data-enabled='false'] .row-name .title{color:var(--text-2)}-/* screenshots of the thing the setting changes: real captures only */-.setting-shot{margin:0 0 .5rem}-.setting-shot img{display:block;max-width:min(100%,520px);height:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface)}-.setting-shot figcaption{margin-top:.35rem;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}-.shot-pending{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);font-style:italic}-/* the house toggle (compact modal size 32x18), role=switch, white thumb in both states */-.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease;border-radius:10px}-.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}-.toggle:hover .toggle-track{border-color:var(--accent)}-.toggle:active{transform:translateY(1px) scale(.97)}-.toggle.is-on .toggle-track{background:var(--accent);border-color:var(--accent)}-.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}-.toggle.is-on .toggle-thumb{transform:translateX(14px)}-.toggle:active .toggle-thumb{width:16px}-.toggle.is-on:active .toggle-thumb{transform:translateX(10px)}-.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}-.toggle[disabled]{cursor:not-allowed}-/* enum controls */-.set-row select{background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-family:var(--brand-body);font-size:.875rem}-.set-row select:hover{border-color:var(--accent)}-.set-row select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}-/* alternatives: one choice card per option, each with its own REAL screenshot, teal ring = selected */-.choice-set{border:none;margin:.25rem 0 .5rem;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}-.choice{position:relative;flex:1 1 200px;max-width:270px;display:flex;flex-direction:column;gap:.35rem;padding:.6rem;cursor:pointer;background:var(--surface);border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease,background .15s ease,transform .06s ease}-.choice input{position:absolute;opacity:0;pointer-events:none}-.choice img{display:block;width:auto;max-width:100%;height:auto;margin:0 auto;border-radius:4px;background:#0d1117}-.choice-name{font-family:var(--brand-heading);font-size:.875rem;font-weight:600;color:var(--text)}-.choice-desc{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);line-height:1.5}-.choice:hover{border-color:var(--accent);background:var(--elevated)}-.choice:active{transform:translateY(1px) scale(.995)}-.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}-.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}-.choice-set[data-enabled='false']{opacity:.55;pointer-events:none}-/* sliders: custom track so the thumb reaches both ends */-.set-row .slider-line{display:flex;align-items:center;gap:10px}-.set-row input[type=range]{flex:1 1 auto;-webkit-appearance:none;appearance:none;height:18px;background:transparent;margin:0}-.set-row input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:var(--overlay);border:1px solid var(--border)}-.set-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);margin-top:-6px;box-shadow:0 0 0 1px #0d1117}-.set-row input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}-.sval{font-family:ui-monospace,monospace;font-size:.8125rem;color:var(--text-body);min-width:3.5em;text-align:right}-/* footer: note + two-click Restore defaults (auto-disarms after 4s) */-.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}-.footer-note{margin:0;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}-.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;cursor:pointer;transition:background .12s ease,transform .06s ease}-.restore-btn:hover{background:var(--elevated)}-.restore-btn:active{transform:translateY(1px)}-.restore-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}-.restore-btn.armed{color:var(--red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.08)}-#dlgBody{padding:0}-.ibadge{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;border:1px solid #8b949e;color:#8b949e;font-size:10px;font-style:italic;cursor:help;vertical-align:2px}-.ibadge:hover{border-color:#00b8b1;color:#00b8b1}-.sexample{display:block;max-width:100%;margin-top:8px;border:1px solid #30363d;border-radius:8px}-.texwrap{margin-top:10px}-.texstrip{display:flex;flex-wrap:wrap;gap:12px;padding:12px 10px;background:#0d1117;border:1px solid #30363d;border-radius:8px}-.tex{display:flex;flex-direction:column;align-items:center;gap:5px;width:64px}-.tex canvas{width:44px;height:44px;border-radius:7px;display:block}-.texn{font:400 9.5px ui-monospace,monospace;color:#8b949e;text-align:center;line-height:1.2;word-break:break-word}-.texcap{margin-top:6px;font-size:11px;color:#8b949e}-.texwide{width:auto}-.texbase{width:44px;height:44px;border-radius:7px;display:block}-.texmatrix{display:grid;grid-template-columns:repeat(3,minmax(70px,1fr));gap:10px;margin-bottom:8px}-.mcell{position:relative;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 4px;border:1px solid #30363d;border-radius:8px;cursor:pointer}-/* v2.0.266 (John: "why is there some random 'default' pill in the upper right corner?"): this was- position:absolute inside .stlbl, which is NOT positioned β so it escaped all the way out to #dlg- and parked itself on top of the dialog's X button. An absolutely-positioned badge that depends on- an ancestor nobody made relative is a trap; make it a plain INLINE chip in the label instead, where- it reads as "1. tiles + overlay (current) default" and has no positioning to get wrong. */-.mdef{display:inline-block;margin-left:6px;vertical-align:1px;font:600 8.5px 'Familjen Grotesk',sans-serif;color:#00b8b1;border:1px solid #00b8b1;border-radius:4px;padding:0 4px;letter-spacing:.04em;white-space:nowrap}-.mcell:hover{border-color:#8b949e}-.mcell.mcur{border-color:#00b8b1;box-shadow:0 0 0 1px #00b8b1 inset}-.mic{position:relative;display:inline-block}-.mic canvas.mbase,.mic canvas[data-mtile],.mic canvas[data-tname],.mic img.mbase{width:44px;height:44px;border-radius:7px;display:block}-.mic .mbadge{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:5px}-.texnames{display:flex;flex-wrap:wrap;gap:12px;padding-top:10px;border-top:1px solid #30363d}-.texdim{opacity:.45}-.st6{display:flex;flex-direction:column;gap:8px}-/* v2.0.266: flex-wrap + position:relative. Measured live: this row's content wanted 516px inside a- 439px strip, so the sample icons spilled past .texstrip's dark background and drew a hard vertical- seam down the panel ("why is the background behind the taskbar identity choices so whacked"). */-.strow6{position:relative;display:flex;align-items:center;flex-wrap:wrap;gap:12px;padding:7px 10px;border:1px solid #30363d;border-radius:8px;cursor:pointer}-.strow6:hover{border-color:#8b949e}-.strow6.mcur{border-color:#00b8b1;box-shadow:0 0 0 1px #00b8b1 inset}-.stlbl{flex:0 0 150px;font:600 12px 'Familjen Grotesk',sans-serif;color:#c9d1d9;line-height:1.35}-.sticons{flex:1 1 220px;min-width:0;display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:7px 7px 0 0}-.mbcard{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:5px;background:#202124;display:flex;align-items:center;justify-content:center;overflow:hidden}-.mbcard img{width:16px;height:16px;object-fit:contain}-.srow{display:flex;align-items:flex-start;gap:12px;padding:11px 0;border-bottom:1px solid #21262d}-.srow:last-child{border-bottom:none}-.srow .stxt{flex:1 1 auto;min-width:0}-.srow .slbl{color:#e6edf3;font-weight:600;font-size:13px}-.srow .sdesc{color:#8b949e;font-size:12px;line-height:1.5;margin-top:2px}-/* .tgl retired in v2.0.296: the settings dialog uses the house .toggle (settings-layout skill) */-.sub{margin-left:26px;padding:8px 0 2px;display:flex;align-items:center;gap:10px}-.sub.sdis{opacity:.4;pointer-events:none}-.sub input[type=range]{flex:1 1 auto;accent-color:#00b8b1;-webkit-appearance:none;appearance:none;height:18px;background:transparent;margin:0}.sub input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:#2a3038}.sub input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:#00b8b1;margin-top:-5px;box-shadow:0 0 0 1px #0d1117}-.sub .sval{color:#c9d1d9;font-family:"JetBrains Mono",monospace;font-size:12px;min-width:44px;text-align:right}-.sub select{background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:4px 8px}-#taskbar{position:sticky;top:44px;z-index:9;display:flex;gap:6px;align-items:center;padding:8px 18px;background:#0a0f14;border-bottom:1px solid #1d2833;overflow-x:auto}-#taskbar .lbl{color:#5c6c7a;font-size:11px;margin-right:6px;flex:0 0 auto}-.tbi{position:relative;width:48px;height:42px;border-radius:8px;background:#0f1720;border:1px solid #1d2833;display:flex;align-items:center;justify-content:center;cursor:pointer;flex:0 0 auto}-.tbi.ghost{color:#5c6c7c;font-size:18px;font-weight:700;border-style:dashed;border-color:#7c3a3a}-.tbi:hover{border-color:#00b8b1;background:#12202a}-.tbi.fg{border-color:#00b8b1}-.tbi img.base{width:30px;height:30px;object-fit:contain}-.tbi img.ov{position:absolute;top:1px;right:1px;width:17px;height:17px;object-fit:contain;border-radius:4px}-.tbi .n{position:absolute;bottom:-6px;left:-6px;background:#00b8b1;color:#05221f;font-size:10px;font-weight:700;border-radius:8px;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px}-.tbi .u{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);width:16px;height:3px;border-radius:2px;background:#00b8b1}--/* tree */-main{padding:14px 18px}-.node{margin:0}-.tw{margin-left:0}-.row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px}-.row:hover{background:#0f1720}-.tog{width:16px;flex:0 0 16px;color:#7d8f9f;cursor:pointer;user-select:none;text-align:center}-.tog.leaf{visibility:hidden}-.ico{width:22px;height:22px;flex:0 0 22px;object-fit:contain;border-radius:5px;background:#0f1720}-.ico.sm{width:16px;height:16px;flex:0 0 16px}-.fttray{display:inline-flex;gap:3px;align-items:center;flex-wrap:wrap}-.ft{font-size:9px;line-height:1;padding:2px 5px;border-radius:4px;background:#161f29;color:#9fb2c2;border:1px solid #24303c;white-space:nowrap}-.ft.dim{opacity:.55}-.ft.k-ok{background:#0f2a1c;color:#5fe0a0;border-color:#1c4a33}-.ft.k-gen{background:#2a230f;color:#e0c15f;border-color:#4a3d1c}-.ft.k-red{background:#2a0f14;color:#ff8a95;border-color:#4a1c24}-.ft.k-dark{background:#12202e;color:#7fc0ff;border-color:#1c3a52}-.ft.k-light{background:#26240f;color:#e8e07a;border-color:#48431c}-.ft.k-warn{background:#2a1a0f;color:#ffab6a;border-color:#4a2f1c}-.led{display:inline-block;width:9px;height:9px;border-radius:50%;flex:0 0 9px;vertical-align:middle;box-shadow:0 0 0 1px rgba(0,0,0,.4) inset}-.led.green{background:#22e07a;box-shadow:0 0 6px 1px rgba(34,224,122,.65)}-.led.amber{background:#f0b429;box-shadow:0 0 6px 1px rgba(240,180,41,.5)}-.led.red{background:#ff5b6a;box-shadow:0 0 6px 1px rgba(255,91,106,.6)}-.tbi .led{position:absolute;bottom:-5px;right:-5px;box-shadow:0 0 0 2px #0d1117}-.icostack{position:relative;flex:0 0 22px;width:22px;height:22px}-.icostack .ico{position:absolute;inset:0}-.icostack .icoov{position:absolute;top:-3px;right:-3px;width:13px;height:13px;object-fit:contain;border-radius:3px}-.nm{font-weight:600}-.nm.dim{font-weight:400;color:#c3d0da}-.meta{color:#7d8f9f;font-size:12px}-.tag{font-size:11px;border-radius:5px;padding:1px 7px;border:1px solid #23303c;color:#9fb0c0}-.tag.fg{background:#00b8b11a;border-color:#00b8b1;color:#00b8b1}-.tag.ok{background:#3fb9501a;border-color:#3fb950;color:#3fb950}-.tag.hf{background:#d299221a;border-color:#d29922;color:#e3b341}-.tag.warn{background:#f851491a;border-color:#f85149;color:#ff7b72}-.tag.vp{background:#a371f71a;border-color:#a371f7;color:#c297ff}-.acts{margin-left:auto;display:flex;gap:4px;opacity:0;transition:opacity .1s}-.row:hover .acts{opacity:1}-.tbnode{border:1px solid #1d2833;border-radius:10px;margin-bottom:12px;background:#0c131a;overflow:hidden}-.tbnode>.row{background:#111b23;border-bottom:1px solid #1d2833;border-radius:0}-.win{margin:2px 0 2px 20px;border-left:2px solid #1a2530}-.win>.row .nm{font-size:13px}-.tabs{margin:0 0 4px 44px}-.why{margin:4px 0 8px 44px;padding:6px 9px;background:#0d1117;border:1px solid #1d2833;border-radius:6px;color:#8fa3b3;font-size:11px;line-height:1.6}-.why b{display:block;color:#00b8b1;margin-bottom:2px}-.empty{color:#7d8f9f;padding:40px;text-align:center}-.hidden{display:none}-footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px}-</style></head><body>-<header>- <div class="brand" id="brand">- <svg class="bicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/><path d="M2.75 8.75h18.5"/><circle cx="12" cy="14.2" r="3.1"/></svg><!-- v2.0.339: the header mark is now the SAME drawing as the favicon and the dock card (docs/img/dock-pup.svg): window frame + aperture ring. It used to be the retired two-windows-plus-heartbeat concept, so this page showed one pup mark in its header and a different one in its tab. stroke stays currentColor here and ONLY here: an inline mark should inherit the header colour, while the favicon is an isolated document where currentColor resolves to black, which is why /dashboard/icon.svg hardcodes #e6edf3. -->- <span class="bname">Pup</span><span class="bsub">· live windows</span>- </div>- <div class="pop" id="pop">- <div>version <b id="cV">?</b></div>- <div id="popMeta">connecting</div>- <div><a href="https://wiki.adom.inc/adom/pup-bridge" target="_blank" rel="noopener">wiki: pup-bridge</a></div>- </div>- <div class="vsep"></div>- <div class="subject">- <span class="sname"><span class="dot" id="live"></span><span id="subjName">all windows</span></span>- <span class="smeta"><b id="cW">0</b> windows · <b id="cT">0</b> tabs · <b id="cB">0</b> icons · <span id="cAY" title="windows surfing on the shared adom-you profile / total work windows. More adom-you = a richer shared login store = pup auto-fills more sites for you over time.">?</span> adom-you · <span id="cLed" title="green: every taskbar button wears its real page-favicon overlay and no untracked pup windows exist. red: a window is bare or unmodeled."><span class="led red" id="cLedDot"></span> <b id="cLedTxt">?</b></span> · <span id="age"></span></span>- </div>- <div class="acts2">- <button class="ibtn" id="recall" aria-pressed="true" title="Hide the prompts panel" aria-label="Hide the prompts panel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.2 3.2c-.5.2-.8.7-.8 1.3v.3"/><path d="M12 17.2h.01"/></svg></button>- <button class="ibtn" id="gear" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>- <button class="ibtn" data-act="regen-overlays" data-tip="Regenerate ALL overlay icons: re-scan every open window and tab and re-run the from-scratch overlay algorithm (re-resolve each window handle, re-pick the favicon, recompose + repaint the taskbar badge) and refresh these dashboard icons to match. A clean sweep to verify correctness and surface any drift." title="regenerate all overlay icons (clean sweep)" aria-label="regenerate overlays"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>- <button class="ibtn" data-act="flash-all" title="flash every pup taskbar button (the Windows taskbar flash, accent-colored on your theme)" aria-label="flash all"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>- <button class="ibtn" data-act="unflash-all" title="clear every taskbar flash" aria-label="clear taskbar flashes"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>- <button class="ibtn danger" data-act="close-all" data-confirm="Close ALL pup windows?" data-tip="Close ALL pup windows on this desktop (every window in the list below, not this dashboard page). Asks before doing it." aria-label="close all pup windows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="13" height="11" rx="1.6"/><path d="M4 15V6a1.5 1.5 0 0 1 1.5-1.5H16"/><path d="M12.2 11.2l4.6 4.6M16.8 11.2l-4.6 4.6"/></svg></button>- </div>-</header>-<div id="newbie"></div>-<div class="ledrow" id="leds"></div>-<div id="bar">- <span class="pill" id="cM" style="display:none">minimal-touch</span>- <select id="f"><option value="">all AI threads</option></select>- <input id="q" placeholder="filter url / window / ai-thread..." size="20">-</div>-<div id="toasts"></div>-<div id="dlgWrap" class="hidden">- <div id="dlgBg"></div>- <div id="dlg" role="dialog" aria-modal="true" aria-label="pup Settings" tabindex="-1">- <div class="dlghead">pup Settings<button class="ibtn" id="dlgClose" title="Close settings" aria-label="Close settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>- <div class="settings-content">- <nav class="settings-side" id="dlgSide" aria-label="Settings sections"></nav>- <div class="settings-body" id="dlgScroller"><div id="dlgBody"></div></div>- </div>- </div>-</div>-<div id="taskbar"></div>-<main id="m"><div class="empty">connecting...</div></main>-<div id="loops" class="loops"></div>-<footer id="ft"></footer>-<script src="/dashboard/newbie-panel.js"></script>-<script>-const _q=s=>document.querySelector(s); let SNAP=null; const COLL=new Set();-// v1.9.258 (John hovered a tooltip that his stale page could not render): the page's code is baked-// at load, but the bridge updates underneath it on every ship. When the SSE reports a different-// bridge version than this page was served by, reload once - the dashboard always follows the bridge.-const PAGE_VERSION='${BRIDGE_VERSION}';-const ago=ms=>{if(ms==null)return'-';const s=Math.round(ms/1000);if(s<60)return s+'s';const m=Math.round(s/60);if(m<60)return m+'m';const h=Math.floor(m/60);return h+'h '+(m%60)+'m';};-const esc=t=>String(t==null?'':t).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));-// v1.9.311 (John: "why do i keep seeing these refresh blip non-stop?"): the badge URL used to-// cache-bust with SNAP.ts, which changes every SSE tick, so the no-store image refetched a couple-// times a SECOND β a constant flicker. Version it by the badge's actual PAINT TIME instead: the URL-// only changes when the overlay is genuinely repainted, so between repaints the browser serves the-// image from cache and there is zero blip even though render() rebuilds the DOM each tick.-const badge=sid=>{var v=0;try{var w=(SNAP&&SNAP.windows||[]).find(function(x){return x.sessionId===sid;});v=(w&&w.overlayWhy&&w.overlayWhy.at)||0;}catch(e){}return '/dashboard/badge?session='+encodeURIComponent(sid)+'&v='+v;};-const fav=(sid,tid)=>'/dashboard/favicon?session='+encodeURIComponent(sid)+'&tab='+encodeURIComponent(tid);-// v1.9.238: per-tab favicon acquisition state as compact chips + a full tooltip.-function favTags(f){- if(!f) return '<span class="ft dim" title="favicon not resolved yet">fav ?</span>';- const K={'page-favicon':['favicon','k-ok'],'sibling-tab-favicon':['sibling','k-ok'],'favicon.ico':['favicon.ico','k-ok'],'app-provided':['app icon','k-ok'],'generic-web':['generic web','k-gen'],'generic-pdf':['generic pdf','k-gen'],'none':['none','k-red']};- const kd=K[f.kind]||[f.kind||'?','k-gen'];- const bits=[];- bits.push('<span class="ft '+kd[1]+'">'+esc(kd[0])+'</span>');- if(f.format) bits.push('<span class="ft">'+esc(String(f.format).toUpperCase())+'</span>');- if(f.mode&&f.mode!=='default') bits.push('<span class="ft '+(f.mode==='dark'?'k-dark':f.mode==='light'?'k-light':'')+'">'+esc(f.mode)+'-mode</span>');- if(f.transparent===true) bits.push('<span class="ft'+(f.darkDominant?' k-warn':'')+'">transparent'+(f.darkDominant?' Β· dark':'')+'</span>');- else if(f.transparent===false) bits.push('<span class="ft">opaque</span>');- const tip=['kind: '+(f.kind||'?'),'format: '+(f.format||'?'),'color-scheme: '+(f.mode||'?'),'transparent: '+(f.transparent===null?'n/a':f.transparent),f.luminance!=null?'luminance: '+f.luminance+(f.darkDominant?' (dark)':''):'',f.via?'fetched via: '+f.via:'',f.candidates!=null?'candidates seen: '+f.candidates:'',f.href&&!/^generic|^app-provided/.test(f.href)?'src: '+f.href:'',f.note?'warning: '+f.note:''].filter(Boolean).join(String.fromCharCode(10));- return '<span class="fttray" title="'+esc(tip)+'">'+bits.join('')+'</span>';-}--function render(){- if(!SNAP)return; const d=SNAP;- if(d.bridge&&d.bridge.version&&d.bridge.version!==PAGE_VERSION){location.reload();return;}- _q('#cW').textContent=d.counts.windows; _q('#cT').textContent=d.counts.tabs;- _q('#cB').textContent=(d.taskbarButtons||[]).length; _q('#cV').textContent=d.bridge.version;- (function(){const ws=(d.windows||[]).filter(w=>w.sessionId!=='pup-dashboard');const ay=ws.filter(w=>w.profile==='adom-you').length;_q('#cAY').textContent=ay+'/'+ws.length;})();- _q('#popMeta').textContent=d.bridge.platform+' \u00b7 bridge up '+ago(d.bridge.uptimeMs)+' \u00b7 deps '+(d.bridge.depsOk?'ok':'missing');- _q('#subjName').textContent=(_q('#f').value||'all windows');- for(const t of (d.toasts||[])){ if(t.id>SEENTOAST){SEENTOAST=t.id;toast(t.msg,t.type);} }- _q('#cM').style.display=d.bridge.minimalTouch?'':'none';- // header overlay-match rollup: red if ANY button bare, amber if any generic, else green- const bl=(d.taskbarButtons||[]).map(b=>b.led||'red');- let roll=bl.includes('red')?'red':bl.includes('amber')?'amber':(bl.length?'green':'red');- if((d.untrackedWindows||[]).length) roll='red'; // an unmodeled real button = the mimic CANNOT claim MATCH- _q('#cLedDot').className='led '+roll;- _q('#cLedTxt').textContent=(d.untrackedWindows||[]).length?((d.untrackedWindows||[]).length+' UNTRACKED'):roll==='green'?'MATCH':roll==='amber'?'generic':'MISMATCH';- _q('#age').textContent='updated '+new Date(d.ts).toLocaleTimeString()+(d.regen?(d.regen.running?' Β· regenerating '+d.regen.inFlight+' windowsβ¦':(d.regen.changed!=null?' Β· last sweep: '+d.regen.changed+'/'+d.regen.swept+' corrected':'')):'');- const sel=_q('#f'),cur=sel.value;- if(sel.options.length-1!==d.threads.length){sel.innerHTML='<option value="">all AI threads</option>'+d.threads.map(t=>'<option>'+esc(t)+'</option>').join('');sel.value=cur;}- const q=_q('#q').value.toLowerCase(),th=sel.value;- const byId=Object.fromEntries(d.windows.map(w=>[w.sessionId,w]));- const match=w=>{if(th&&(w.owner||w.ownerThread)!==th)return false;if(q){const hay=(w.sessionId+' '+(w.url||'')+' '+(w.owner||'')+' '+w.tabs.map(t=>t.url||'').join(' ')).toLowerCase();if(!hay.includes(q))return false;}return true;};- const groups=(d.taskbarButtons||[]).map(b=>({...b,wins:b.windows.map(s=>byId[s]).filter(w=>w&&match(w))})).filter(g=>g.wins.length);-- // taskbar strip (real order, real badge)- // v1.9.235 (John's audit): each strip button mimics the REAL taskbar button - the browser/category- // BASE icon full-size, with the painted overlay badge in the TOP-RIGHT corner at ~55% (measured- // from his actual taskbar), and NO overlay at all when pup has not painted one (a bare Chrome- // button must render bare here too, otherwise the mimic lies).- const ghosts=(d.untrackedWindows||[]).map(h=>'<div class="tbi ghost" title="a window of the pup-managed Chrome is on the real taskbar but pup has NO window record for it (hwnd '+h+'). The mimic cannot know its icon - this is why the header LED is red.">?<span class="led red"></span></div>').join('');- _q('#taskbar').innerHTML='<span class="lbl" title="mimics ONLY the taskbar buttons of pup-managed Chrome windows. Other Adom apps (Adom Hydrogen, Adom Bridge) have their own teal-tile buttons that are out of pup scope.">TASKBAR (pup)</span>'+groups.map((g,i)=>{- const fg=g.wins.some(w=>w.foreground);- return '<div class="tbi'+(fg?' fg':'')+'" data-jump="g'+i+'" title="'+esc(g.label)+': '+esc(g.ledWhy||'')+'">'- +'<img class="base" src="'+esc(g.baseIcon||'')+'" data-fallback="hide">'- +(g.badgeSession?'<img class="ov" src="'+badge(g.badgeSession)+'" data-fallback="hide">':'')- +'<span class="led '+esc(g.led||'red')+'" title="'+esc(g.ledWhy||'')+'"></span>'- +(g.wins.length>1?'<span class="n">'+g.wins.length+'</span>':'')- +(fg?'<span class="u"></span>':'')+'</div>';- }).join('')+ghosts;-- // tree: taskbar icon -> windows -> tabs- _q('#m').innerHTML=groups.length?groups.map((g,gi)=>{- const gid='g'+gi, gcoll=COLL.has(gid);- return '<div class="tbnode" id="'+gid+'">'- +'<div class="row"><span class="tog" data-toggle="'+gid+'">'+(gcoll?'βΈ':'βΎ')+'</span>'- +'<span class="icostack"><img class="ico" src="'+esc(g.baseIcon||'')+'" data-fallback="hide">'- +(g.badgeSession?'<img class="icoov" src="'+badge(g.badgeSession)+'" data-fallback="hide">':'')+'</span>'- +'<span class="led '+esc(g.led||'red')+'" title="overlay match: '+esc(g.ledWhy||'')+'"></span>'- +'<span class="nm">'+esc(g.label)+'</span>'- +'<span class="meta">'+g.wins.length+' window'+(g.wins.length>1?'s':'')+' · '+esc(g.grouping)+' · badge from '+esc(g.badgeVia||'?')+'</span>'- +'<span class="acts"><button class="btn sm" data-act="flash-group" data-ids="'+esc(g.windows.join(','))+'">flash</button>'- +'<button class="btn sm" data-act="unflash-group" data-ids="'+esc(g.windows.join(','))+'">clear</button></span></div>'- +'<div class="tw'+(gcoll?' hidden':'')+'">'+g.wins.map(w=>{- const wid=gid+'-'+esc(w.sessionId), wcoll=COLL.has(wid);- const crash=w.tabs.some(t=>t.crashed)||w.lostBrowser;- return '<div class="win">'- +'<div class="row"><span class="tog" data-toggle="'+wid+'">'+(wcoll?'βΈ':'βΎ')+'</span>'- +'<span class="led '+esc(w.overlayLed||'red')+'" title="'+(w.overlayLed==='green'?'real page favicon painted β matches taskbar':w.overlayLed==='amber'?'painted, generic fallback (page has no favicon)':'NOT painted β taskbar would be bare/stale')+'"></span>'- +'<img class="ico" src="'+badge(w.sessionId)+'" data-fallback="hide">'- +'<span class="nm">'+esc(w.sessionId)+'</span>'- +'<span class="tag '+(w.foreground?'fg':'')+'">'+(w.foreground?'FG':'bg')+'</span>'- +(w.sessionId==='pup-dashboard'?'<span class="tag">dashboard</span>':(w.profile==='adom-you'?'<span class="tag ok" title="surfing on the shared adom-you profile: every login this window makes enriches the shared credential store, so pup can auto-fill it everywhere later">surfing as adom-you</span>':'<span class="tag warn" title="NOT on adom-you: logins made here do NOT build the shared credential store. Prefer adom-you unless isolation is genuinely needed.">NOT adom-you: '+esc(w.profile)+'</span>'))- +(w.owner?'<span class="tag">'+esc(w.owner)+(w.ownerSource==='caller-identity'?' β':'')+'</span>':'<span class="tag warn">UNOWNED</span>')- +(w.wiki?'<span class="tag '+(w.wiki.authed?'ok':'warn')+'" title="'+(w.wiki.authed?'This Adom wiki window is LOGGED IN β you see private source, drafts, owner-only cards, reply/edit':'This Adom wiki window is the PUBLIC / logged-OUT view β exactly what the world sees')+'">π wiki: '+(w.wiki.authed?'signed in':'public')+'</span>':'')- +(w.highFps?'<span class="tag hf">highFps '+w.highFpsMinutesLeft+'m</span>':'')- +(w.emulatedViewport?'<span class="tag vp">vp '+w.emulatedViewport.width+'x'+w.emulatedViewport.height+'</span>':'')- +(crash?'<span class="tag warn">'+(w.lostBrowser?'browser lost':'crashed tab')+'</span>':'')- +(w.errorCount?'<span class="tag warn" title="'+esc((w.recentErrors||[]).map(e=>'['+e.type+'] '+e.text).join(String.fromCharCode(10))||'no detail captured')+'">'+w.errorCount+' err</span>':'')- +'<span class="meta">'+esc(w.profile)+' · '+ago(w.uptimeMs)+'</span>'- +'<span class="acts">'- +'<button class="btn sm" data-act="fg" data-sid="'+esc(w.sessionId)+'">bring to fg</button>'- +'<button class="btn sm" data-act="bg" data-sid="'+esc(w.sessionId)+'">send to bg</button>'- +'<button class="btn sm" data-act="flash" data-sid="'+esc(w.sessionId)+'">flash</button>'- +'<button class="btn sm" data-act="unflash" data-sid="'+esc(w.sessionId)+'">clear</button>'- +'<button class="btn sm danger" data-act="close-window" data-sid="'+esc(w.sessionId)+'" data-confirm="Close '+esc(w.sessionId)+'?">close</button></span></div>'- +'<div class="'+(wcoll?'hidden':'')+'">'- +(w.foreground&&w.foregroundReason?'<div class="why"><b>why foreground</b>'+esc(w.foregroundReason)+'</div>':'')- +(w.overlayWhy?'<div class="why"><b>how this icon was chosen</b><span class="led '+esc(w.overlayLed||'red')+'"></span> '+(w.overlayLed==='green'?'real page favicon':w.overlayLed==='amber'?'generic fallback (no favicon on the page)':'not painted')+'<br>tab '+esc(w.overlayWhy.tabId||'?')+', '+esc(w.overlayWhy.tabPickedBy)+'<br>source: '+esc(w.overlayWhy.source||'none')+'<br>via: '+esc(w.overlayWhy.fetchedVia)+'<br>painted by: '+esc(w.overlayWhy.paintedBy)+'</div>':'')- +'<div class="tabs">'+w.tabs.map(t=>- '<div class="row"><span class="tog leaf">β’</span>'- +'<img class="ico sm" src="'+fav(w.sessionId,t.tabId)+'" data-fallback="hide">'- +'<span class="nm dim'+(t.active?'"':'"')+'">'+esc(t.title||t.url||'-')+'</span>'- +(t.active?'<span class="tag fg">active</span>':'')- +(t.crashed?'<span class="tag warn">crashed</span>':'')- +favTags(t.fav)- +'<span class="acts"><button class="btn xs" data-act="fg" data-sid="'+esc(w.sessionId)+'" data-tid="'+esc(t.tabId)+'">fg</button>'- +'<button class="btn xs" data-act="bg" data-sid="'+esc(w.sessionId)+'">bg</button>'- +'<button class="btn xs danger" data-act="close-tab" data-sid="'+esc(w.sessionId)+'" data-tid="'+esc(t.tabId)+'">close</button></span></div>'- ).join('')+'</div></div></div>';- }).join('')+'</div></div>';- }).join(''):'<div class="empty">no pup windows open'+(th||q?' matching this filter':'')+'</div>';- _q('#ft').textContent='bridge up '+ago(d.bridge.uptimeMs)+' \u00b7 '+d.bridge.platform+' \u00b7 deps '+(d.bridge.depsOk?'ok':'MISSING')+' \u00b7 win32 '+(d.bridge.win32InProcess?'in-process':'powershell fallback');- // v2.0.315 LOOP RADAR (John): the periodic loops and in-process Win32 calls no longer show in ab's- // activity log, so they are shown HERE, per minute, with the declared cadence next to each so a- // spamming loop stands out (ticks/min far above 60000/cadence).- try{- const lp=d.bridge.loops||{}; const w=d.bridge.win32Calls||{};- const rows=Object.entries(lp).map(([n,v])=>{const exp=Math.max(1,Math.ceil(60000/v.cadenceMs));const hot=v.lastMin>exp*1.5;return '<span class="lp'+(hot?' hot':'')+'" title="declared cadence '+v.cadenceMs+'ms = '+exp+'/min; avg '+v.avgMs+'ms, max '+v.maxMs+'ms, '+v.ticks+' ticks total, '+v.slowTicks+' slow">'+n+' <b>'+v.lastMin+'</b>/'+exp+'</span>';}).join('');- const wc=Object.entries(w).map(([n,v])=>n+'='+v).join(' ');- _q('#loops').innerHTML='<div class="lpt">loops, ticks in the last minute (actual/declared)</div>'+rows+'<div class="lpt">in-process win32 calls since boot: '+(wc||'none')+' \u00b7 ab enum_windows last minute: '+(d.bridge.abEnumLastMin||0)+'</div>';- }catch(e){}-}-function toast(msg,type){- const t=document.createElement('div');t.className='toast '+(type||'info');t.textContent=msg;- _q('#toasts').appendChild(t);- setTimeout(()=>{t.classList.add('out');setTimeout(()=>t.remove(),350)},type==='error'?8000:4000);-}-const ACTMSG={'flash':'flashed the taskbar button of','unflash':'taskbar flash cleared on','flash-all':'flashed every pup taskbar button','unflash-all':'cleared every taskbar flash','close-all':'closed all pup windows','close-window':'closed','close-tab':'closed tab in','fg':'bringing to front:','bg':'sent to background:','set-setting':'setting saved','test-caption':'sample caption shown on your screen','restore-defaults':'every setting restored to its default','regen-overlays':'regenerated all overlay icons','import-scan':'scanned your browsers for logins','import-run':'import'};-async function act(p){- try{- // settings-layout: every settings round trip is BOUNDED (5s), so a stalled bridge can never- // leave a control visually stuck; the click already reflected in the DOM optimistically.- const ac=new AbortController(); const tm=setTimeout(()=>ac.abort(),5000);- const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p),signal:ac.signal}).finally(()=>clearTimeout(tm));- const j=await r.json().catch(()=>({}));- const what=(ACTMSG[p.action]||p.action)+(p.sessionId?' '+p.sessionId:'');- if(p.action==='regen-overlays'){ if(j.ok&&j.started) toast('Regenerating overlays on '+j.swept+' windows: watch the icons update live','info'); else if(j.ok&&j.alreadyRunning) toast('A regenerate sweep is already running','info'); else toast('regenerate failed'+(j.error?': '+j.error:''),'error'); return j; }- if(j.ok) toast(what+(p.action==='unflash-all'&&j.swept!=null?' ('+j.swept+' windows swept)':'')+(p.action==='flash-all'&&j.delivered!=null?' ('+j.delivered+' of '+j.n+' delivered)':''), p.action.indexOf('close')===0?'success':'info');- else toast(what+' failed'+(j.error?': '+j.error:''),'error');- return j;- }catch(e){toast(p.action+' failed: '+e.message,'error');}-}-let SEENTOAST=0;-// proper tooltip (adom-ui-design tooltips skill): body-appended fixed div, 600ms reveal,-// clamped to the viewport on BOTH axes, never a title attr for rich content.-(function(){- let tt=null,show=null;- function hide(){clearTimeout(show);if(tt){tt.remove();tt=null;}}- document.addEventListener('mouseover',ev=>{- // v2.0.97 (John: double tooltips β a raw title attr AND this tooltip): any title attr is- // absorbed into data-tip the first time the cursor arrives, well inside the ~1s native- // delay, so the browser tooltip never fires and every hint renders through this one system.- const t=ev.target.closest('[title]');- if(t){const v=t.getAttribute('title');if(v&&!t.dataset.tip)t.dataset.tip=v;t.removeAttribute('title');}- const el=ev.target.closest('[data-tip]');- if(!el){hide();return;}- clearTimeout(show);- show=setTimeout(()=>{- hide();- tt=document.createElement('div');- tt.style.cssText='position:fixed;z-index:100000;max-width:380px;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:10px 12px;font-size:12px;line-height:1.6;color:#c9d1d9;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:none';- tt.textContent=el.dataset.tip;- document.body.appendChild(tt);- const r=el.getBoundingClientRect();- const M=8,w=tt.offsetWidth,h=tt.offsetHeight;- let x=r.left+14,y=r.bottom+10;- if(y+h>innerHeight-M)y=Math.max(M,r.top-h-10);- x=Math.max(M,Math.min(x,innerWidth-w-M));- y=Math.max(M,Math.min(y,innerHeight-h-M));- tt.style.left=x+'px';tt.style.top=y+'px';- },600);- });- document.addEventListener('scroll',hide,true);-})();-// v1.9.259: the settings dialog. Rows defined once; state comes from SNAP.bridge.settings; every-// change POSTs set-setting and answers back with a toast.-const SETTINGS_DEF=[- {k:'taskbarMode',mainsel:1,threadex:1,l:'Taskbar identity',info:'Three ALTERNATE modes for how pup brands your Windows taskbar, from most to least branded. TILES: every AI thread gets ONE button whose icon is the thread NAME drawn on the Adom teal tile - all of that thread\u2019s windows group under it, so the taskbar reads as a list of your threads. AUMID: every pup window gets its own button with a pup/category icon (per-window identity, no thread grouping) - the AUMID grouping and icon-style options below apply to this mode only. PLAIN: pup never touches identity; windows stack under the stock Chrome-for-Testing button like any unbranded app. The favicon overlay badge is separate - the Overlay badges toggle below works in tiles and aumid modes.',d:'Click a row in the matrix below to switch modes; the current one is ringed.',sel:['tiles','aumid','plain']},- // v2.0.269 (John): the badge rows live HERE, directly under Taskbar identity, because they are- // part of the same decision β the identity picker's own examples are 'tiles + overlay' vs 'tiles',- // so having the overlay toggle sit fifteen rows further down was a genuine discoverability bug.- {k:'overlayBadges',l:'Favicon overlay badges',d:'Paint each pup taskbar icon with the favicon of its active tab in the top-right corner.',pair:[{img:'obon',cap:'On: the wiki favicon badged on the tile'},{img:'oboff',cap:'Off: the bare thread tile'}]},- {k:'overlaySize',l:'Badge size',d:'How big the badge is drawn, as a percent of full size. Each card is the real tile at that size.',sel:['50','66','75','100'],selLabels:{'50':'50%, smallest, maximum text clearance','66':'66%','75':'75%, balanced','100':'100%, full size'},dep:'overlayBadges'},- {k:'taskbarGrouping',l:'AUMID grouping',d:'Only applies when AUMID icons are on. split = one taskbar button per window. grouped = windows grouped BY CATEGORY when icon-style is "category" (up to 5 buttons: signed-in wiki, public wiki, open web, Adom app, plain: for example all your signed-in-wiki windows collapse under the one padlock button); with icon-style "standard" grouped collapses everything under a single Adom Pup button.',sel:['split','grouped'],depMode:'aumid',imgBy:{split:'groupsplit',grouped:'groupgrouped'}},- {k:'aumidIconStyle',l:'AUMID icon style',d:'Only applies when AUMID icons are on: standard = one pup tile for every window; category = the custom set (wiki signed-out, wiki signed-in padlock, world icon for the open web, adom app icon, mixed).',sel:['standard','category'],depMode:'aumid',imgBy:{standard:'iconstylestd',category:'iconstylecat'}},- {k:'jumpLists',l:'Jump lists',d:'The menu Windows shows when you right-click a pup taskbar icon (real screenshot below, captured on this machine). Requires AUMID icons on. Quirk: the FIRST right-click can blip the menu closed; right-click again about a second later and it holds (a Windows shell timing race, not a pup bug).',img:'jumplist'},- {k:'jlViewTasks',l:'',d:'Wiki view tasks: Switch to logged-in view / Switch to public view on every wiki window.',dep:'jumpLists',subtgl:1,img:'jlviewtasks'},- {k:'jlCloseAll',l:'',d:'The Close all pup windows task on every pup icon.',dep:'jumpLists',subtgl:1,img:'jlcloseall'},- {k:'jlDashboard',l:'',d:'The Open pup dashboard task on every pup icon: one click opens the live window/tab/thread map.',dep:'jumpLists',subtgl:1,img:'jldashboard'},- {k:'jlAnnotate',l:'',d:'The Annotate this page task: draw on the page; Done captures it for the AI thread that owns the window.',dep:'jumpLists',subtgl:1,img:'jlannotate'},- {k:'annCaptionBar',l:'Caption bar on captures',d:'Bake a thin context strip under every annotation/snip capture (thread, page, time) so any AI you paste it into sees where it came from.',img:'anncapbar'},- {k:'toolbarExtension',l:'Adom toolbar in Chrome',d:'The Adom Pup extension button in the browser toolbar (annotate, snip, view toggle, dashboard, owner, close). Requires the one-time extension install.',img:'toolbarext'},- {k:'tbSnipHotkey',l:'',d:'Alt+Shift+A starts annotate (Auto: capture on mouse-up); Alt+Shift+M starts it in Multi (compose several shapes, capture once on Done).',dep:'toolbarExtension',subtgl:1},- {k:'tbFloatyPencil',l:'',d:'The tiny pencil in the upper-right corner of each page: one click starts annotate. Off by default: the corner hover zone can fight buttons in app headers, and the toolbar pen button covers discovery.',dep:'toolbarExtension',subtgl:1},- {k:'floatyHitPx',l:'',d:'',sliderLabel:'pencil corner hit area',unit:'px',slider:[6,120,2],dep:'tbFloatyPencil'},- // v2.0.293 (John: "why did you make progress bar vs orange flash a child of Favicon overlay- // badges? that hierarchy doesn't make sense"): taskbarActivity is its OWN top-level row here in- // the activity/foreground section, NOT inside the taskbar-identity cluster where its position- // between the badge/AUMID rows rendered it as a sub-setting of overlay badges.- {k:'taskbarActivity',l:'Taskbar activity signal',d:'Pick what the button shows. Each card is a real capture of the same pup button on a dark taskbar.',sel:['progress','flash','off'],selLabels:{'progress':'progress bar (default)','flash':'orange flash (old)','off':'off'},imgBy:{progress:'tbactprogress',flash:'tbactflash',off:'tbactoff'}},- {k:'fgCaption',l:'Caption on AI foreground',d:'When an AI brings a window to your screen, show its stated reason as an on-screen caption. The AI is always forced to give a reason; this shows it to you.',img:'fgcaption'},- {k:'fgCaptionMs',l:'',d:'',sliderLabel:'minimum caption time (longer text auto-extends at reading speed, 12s max)',unit:'s',slider:[500,8000,250],dep:'fgCaption',test:1},- {k:'sleepRecovery',l:'Sleep and crash recovery',d:'After the laptop wakes or Chrome dies, automatically bring every pup window back at its last page, signed in. Never restarts a live Chrome.'},- {k:'autoLogin',l:'Auto-fill saved logins',d:'Fill a matching saved login when a login page loads in a pup window (background, no Chrome dropdown). pup never submits on its own: submitting a stored password is reason-gated and confirms the live domain first, to guard against lookalike/phishing sites.'},- {k:'captureLogins',l:'Capture logins to the vault',d:'As you sign into sites in pup windows, store the username and password in the OS keychain (encrypted at rest, never logged) so return visits auto-fill. This is how the shared adom-you identity grows over time.'},- {k:'reportIssues',l:'Issue-report hints',d:'Let pup invite AIs to file a wiki issue when they hit something broken.'},- {k:'titleTag',l:'ai-thread tag in the window title',d:'Append "(ai-thread: <name>)" to each window title so YOU can tell windows apart from the title bar. Off by default because it can look like the page mangled its own title. Purely cosmetic now: pup identifies its own windows by a Win32 property tag it writes at birth, so it never needs the title.'},- {k:'titlePrefix',l:'Thread name prefixed to the title',d:'Prepend the owning thread (and wiki logged-in/out state) to the start of each window title. Off by default.'},- {k:'ownerChip',l:'Floating owner chip in each window',d:'Show a tiny non-interactive chip in the bottom-left of each pup window naming the AI thread that owns it and its profile. It cannot be clicked through to (pointer-events off) and lives in an isolated layer so it never affects the page. Turn off if you do not want any overlay in your windows.',img:'ownerchip'},- {k:'notMineCaption',l:'"Not foregrounded by Adom" caption',d:'When a window comes to the front on its own (a pageβs JavaScript, a focus change, Chrome itself, for example a PDF viewer self-activating), briefly caption that Adom did NOT do it. Stays SILENT when YOU raised the window (detected via recent taskbar/keyboard input). This is the single control for self-foreground captions, including the 20s right after a window opens. Off by default.'},- {k:'minimalTouch',l:'Minimal-touch mode',d:'The safety mode from the Chrome crash hunt. ON means pup keeps its hands off your windows: it only opens them and drives the pages inside, and skips the extra Windows machinery (identity stamps, window sweeps, shell churn) that was suspected of freezing Chrome. The toggles above are the real per-feature controls now and they win over this switch. Rule of thumb: leave this ON; if pup ever gets crashy, make sure it is ON and turn features back on one at a time to find the culprit.'},-];-// Import-logins picker (the adom-you seed). Discovery runs today via pup_import_scan; the-// actual decrypt is AD-core (adom/adom-bridge #51) so the Import button reports honest-pending.-let _importScan=null; let _importSel={};-function _idTag(a){return '<span style="display:inline-block;background:rgba(120,150,205,.22);border-radius:4px;padding:1px 6px;margin:0 4px 2px 0;font-size:11px;white-space:nowrap">'+esc(a)+'</span>';}-function _idBadges(accts){return (accts&&accts.length)?accts.map(_idTag).join(''):'<span style="opacity:.55;font-size:11px">no linked account</span>';}-function importSectionHtml(){- const s=_importScan; let rows='';- if(s&&s.profiles){- rows=s.profiles.length?s.profiles.map(p=>{- const key=p.browser+'/'+p.profileDir, on=_importSel[key]!==false;- const cnt=(p.passwordCount==null?'?':p.passwordCount)+' logins';- const lock=p.abe?'<span style="opacity:.6;font-size:11px" title="App-Bound Encrypted - needs admin approval to decrypt">π encrypted</span>':'';- return '<div class="srow" style="padding:8px 0"><label class="stxt" style="cursor:pointer;display:flex;gap:10px;align-items:flex-start;width:100%">'- +'<input type="checkbox" data-imchk="'+esc(key)+'"'+(on?' checked':'')+' style="margin-top:3px;flex:0 0 auto">'- +'<span style="flex:1 1 auto;min-width:0"><div class="slbl">'+esc(p.browserName||p.browser)+' — '+esc(p.profileName||p.profileDir)+'</div>'- +'<div class="sdesc">'+cnt+' '+_idBadges(p.accounts)+' '+lock+'</div></span></label></div>';- }).join(''):'<div class="sdesc">No Chrome / Edge / Brave profiles found on this machine.</div>';- }- const has=s&&s.profiles&&s.profiles.length;- const summary=s?'<div class="sdesc" style="margin:8px 0">Found <b>'+(s.profiles?s.profiles.length:0)+'</b> profile(s), ~<b>'+(s.totalPasswords||0)+'</b> saved logins across '+((s.accounts&&s.accounts.length)?s.accounts.map(esc).join(', '):'no linked accounts')+'.'+(s.pythonAvailable?'':' <span style="opacity:.6">(login counts unavailable - python not found)</span>')+'</div>':'';- return '<div class="srow" style="flex-direction:column;align-items:stretch;border-top:2px solid rgba(255,255,255,.16);margin-top:16px;padding-top:14px">'- +'<div class="slbl" style="font-size:15px">Import logins from Chrome / Edge <span class="ibadge" data-tip="Seeds the shared adom-you profile with the logins you already have, tagged by which account they came from ([email protected] vs [email protected] vs [email protected]) so the agent acts as the right you on each site. Discovery runs now; the actual decrypt needs one Windows admin approval, being built in Adom Bridge core (issue #51).">i</span></div>'- +'<div class="sdesc">Build a comprehensive adom-you profile from every browser profile on this machine. Each login stays tagged by its source account so the agent picks the right identity per site.</div>'- +'<div style="display:flex;gap:8px;margin:12px 0;flex-wrap:wrap"><button class="btn sm" id="importScanBtn">'+(s?'Rescan browsers':'Scan my browsers')+'</button>'- +(has?'<button class="btn sm" id="importRunBtn">Import selected logins</button>':'')+'</div>'- +summary+rows- +(has?'<div id="importStatus" class="sdesc" style="opacity:.6;margin-top:10px">Import pops one Adom Bridge consent dialog + one Windows admin (UAC) prompt, then decrypts your selected logins into the adom-you vault, tagged by account. Passwords are never shown or logged.</div>':'')- +'</div>';-}-async function runImportScan(){const b=_q('#importScanBtn');if(b){b.disabled=true;b.textContent='Scanningβ¦';}const j=await act({action:'import-scan'});_importScan=(j&&j.ok)?j:{profiles:[],accounts:[]};renderDlg();}-async function runImport(){- const sel=((_importScan&&_importScan.profiles)||[]).filter(p=>_importSel[p.browser+'/'+p.profileDir]!==false);- if(!sel.length){toast('Select at least one profile','error');return;}- const btn=_q('#importRunBtn');if(btn){btn.disabled=true;btn.textContent='Approve on your desktopβ¦';}- const el=_q('#importStatus');if(el)el.textContent='Waiting for the Adom Bridge consent dialog and Windows admin (UAC) prompt on your screenβ¦';- const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'import-run',profiles:sel.map(p=>({browser:p.browser,profileDir:p.profileDir}))})});- const j=await r.json().catch(()=>({}));- if(btn){btn.disabled=false;btn.textContent='Import selected logins';}- if(el)el.textContent=j.message||j.error||'Done.';- toast(j.ok?('Imported '+((j.added||0)+(j.updated||0))+' logins into the adom-you vault'):(j.message||j.error||'Import failed'),j.ok?'success':'error');-}-// ===== Settings renderer (adom-ui-design / settings-layout, v2.0.296) =====-// One continuously scrolling pane: sections (h2) > cards (h3) > rows, a sidebar that jumps within it-// and scroll-spies, every row a breadcrumb name + explanation + (real) screenshot + control + default.-const SET_NAMES={taskbarMode:'Identity mode',overlayBadges:'Favicon overlay badges',overlaySize:'Badge size',taskbarGrouping:'Grouping',aumidIconStyle:'Icon style',jumpLists:'Jump lists',jlViewTasks:'Wiki view tasks',jlCloseAll:'Close all windows task',jlDashboard:'Open dashboard task',jlAnnotate:'Annotate this page task',annCaptionBar:'Caption bar on captures',toolbarExtension:'Adom toolbar in Chrome',tbSnipHotkey:'Snip hotkey',tbFloatyPencil:'Corner pencil',floatyHitPx:'Pencil hit area',taskbarActivity:'Activity signal',fgCaption:'Caption on AI foreground',fgCaptionMs:'Minimum caption time',notMineCaption:'Not-by-Adom caption',sleepRecovery:'Sleep and crash recovery',autoLogin:'Auto-fill saved logins',captureLogins:'Capture logins to the vault',reportIssues:'Issue-report hints',titleTag:'ai-thread tag in the title',titlePrefix:'Thread name prefixed to the title',ownerChip:'Floating owner chip',minimalTouch:'Minimal-touch mode'};-const SET_DEFAULTS={taskbarMode:'Tiles',overlayBadges:'On',overlaySize:'75%, balanced',taskbarGrouping:'Split',aumidIconStyle:'Category',jumpLists:'On',jlViewTasks:'On',jlCloseAll:'On',jlDashboard:'On',jlAnnotate:'On',annCaptionBar:'On',toolbarExtension:'On',tbSnipHotkey:'On',tbFloatyPencil:'Off',floatyHitPx:'24px',taskbarActivity:'Progress bar',fgCaption:'On',fgCaptionMs:'3.5s',notMineCaption:'Off',sleepRecovery:'On',autoLogin:'On',captureLogins:'On',reportIssues:'On',titleTag:'Off',titlePrefix:'Off',ownerChip:'On',minimalTouch:'On'};-// choice cards: the option name + the one-line tradeoff + the REAL screenshot per option-const SET_CHOICES={- overlaySize:[{v:'50',n:'50%',d:'Smallest badge, maximum clearance for the tile text.',img:'obsize50',alt:'The pup tile with a small favicon badge in its corner'},- {v:'66',n:'66%',d:'Small, still clear of the text.',img:'obsize66',alt:'The pup tile with a medium-small favicon badge'},- {v:'75',n:'75%',d:'Balanced: readable badge, readable text.',img:'obsize75',alt:'The pup tile with a medium favicon badge'},- {v:'100',n:'100%',d:'Full size. Covers the corner of the tile text.',img:'obsize100',alt:'The pup tile with a large favicon badge overlapping the text corner'}],- taskbarActivity:[{v:'progress',n:'Progress bar',d:'Fills over the operationβs usual duration, so you can see how far along it is.',img:'tbactprogress',alt:'The pup taskbar button with a blue progress bar filling underneath it'},- {v:'flash',n:'Orange flash',d:'Highlights the button until you look at it. Tells you something happened, not how long is left.',img:'tbactflash',alt:'The same taskbar button with the attention highlight underneath it'},- {v:'off',n:'Off',d:'The taskbar button never changes.',img:'tbactoff',alt:'The same taskbar button with no highlight of any kind'}],- taskbarGrouping:[{v:'split',n:'Split',d:'One taskbar button per window.',img:'groupsplit',alt:'Five separate pup taskbar buttons, one per window'},- {v:'grouped',n:'Grouped',d:'Windows of the same category share one button.',img:'groupgrouped',alt:'The same five windows collapsed under category buttons'}],- aumidIconStyle:[{v:'standard',n:'Standard',d:'One pup tile for every window.',img:'iconstylestd',alt:'Pup taskbar buttons all wearing the same pup tile'},- {v:'category',n:'Category',d:'A tile per kind: signed-in wiki, public wiki, open web, Adom app, mixed.',img:'iconstylecat',alt:'Pup taskbar buttons each wearing the icon of its category'}],-};-const SET_SECTIONS=[- {id:'s-identity',t:'Taskbar identity',cards:[- {id:'c-mode',t:'Identity mode',status:st=>st.taskbarMode,desc:'How pup brands your Windows taskbar. Tiles gives every AI thread one button whose icon is the thread name on the Adom teal tile, so the taskbar reads as a list of your threads. AUMID gives every window its own button with a pup or category icon. Plain leaves identity alone and windows stack under the stock Chrome for Testing button. The example tiles are drawn live by the same code that draws the real icons, so they cannot drift from what Windows shows.',rows:['taskbarMode']},- {id:'c-badges',t:'Overlay badges',status:st=>st.overlayBadges==='on'?'on':'off',desc:'The favicon of the active tab, composited into the corner of the taskbar button so you can tell at a glance which site a window is on. Works in tiles and AUMID modes.',rows:['overlayBadges','overlaySize']},- {id:'c-aumid',t:'AUMID options',status:st=>st.taskbarMode==='aumid'?'active':'inert in '+st.taskbarMode+' mode',desc:'These two only apply in AUMID mode, where each window carries its own button.',rows:['taskbarGrouping','aumidIconStyle'],depMode:'aumid'},- ]},- {id:'s-jumplists',t:'Jump lists',cards:[- {id:'c-jl',t:'Right-click menu',status:st=>st.jumpLists==='on'?'on':'off',desc:'The menu Windows shows when you right-click a pup taskbar button. pup registers the tasks below through the Windows jump-list API, so they appear exactly where every other app puts them. Requires an identity mode other than plain.',rows:['jumpLists','jlViewTasks','jlCloseAll','jlDashboard','jlAnnotate']},- ]},- {id:'s-capture',t:'Annotate and capture',cards:[- {id:'c-capbar',t:'Captures',status:st=>st.annCaptionBar==='on'?'caption bar on':'caption bar off',desc:'When you draw on a page and finish, pup captures the marked-up region as a PNG and copies it to your clipboard.',rows:['annCaptionBar']},- {id:'c-toolbar',t:'Adom toolbar in Chrome',status:st=>st.toolbarExtension==='on'?'on':'off',desc:'A small family of extension buttons that ride the Chrome for Testing launch: the pup menu, the annotate pen, and your adom-you identity tile. They are staged on every launch, so they need no setup.',rows:['toolbarExtension','tbSnipHotkey','tbFloatyPencil','floatyHitPx']},- ]},- {id:'s-activity',t:'Activity and foreground',cards:[- {id:'c-activity',t:'Taskbar activity signal',status:st=>({progress:'progress bar',flash:'orange flash',off:'off'})[st.taskbarActivity]||st.taskbarActivity,desc:'What a windowβs taskbar button does while pup is working on it. pup keeps a running average of how long each operation takes (applying the window icon, taking a screenshot) and fills the bar over that estimate, capping just short of done until the work actually finishes. Windows only.',rows:['taskbarActivity']},- {id:'c-captions',t:'Foreground captions',status:st=>st.fgCaption==='on'?'on':'off',desc:'pup opens every window in the background and never brings one to your screen without a reason. When an AI does raise a window, it must state why, and that reason is shown as an on-screen caption BEFORE the window comes up, naming the thread that asked. Caption time scales with the amount of text: the slider sets the minimum, and longer reasons extend at reading speed up to 12 seconds.',rows:['fgCaption','fgCaptionMs','notMineCaption']},- ]},- {id:'s-logins',t:'Logins',cards:[- {id:'c-vault',t:'Credential vault',status:st=>st.captureLogins==='on'?'capturing':'not capturing',desc:'pup runs its own password manager backed by the OS keychain, so an AI can sign into sites in the background without Chromeβs foreground autofill dropdown. Passwords are encrypted at rest and never logged or echoed.',rows:['autoLogin','captureLogins']},- {id:'c-import',t:'Import logins',html:()=>importSectionHtml()},- ]},- {id:'s-inpage',t:'In-page identity',cards:[- {id:'c-labels',t:'Window labels',status:st=>st.ownerChip==='on'?'owner chip on':'owner chip off',desc:'Ways to tell which AI thread owns a window from inside it. pup identifies its own windows by a Win32 property it writes at birth, so none of these are needed for bookkeeping; they exist only for you.',rows:['ownerChip','titleTag','titlePrefix']},- ]},- {id:'s-safety',t:'Recovery and safety',cards:[- {id:'c-safety',t:'Recovery and safety',status:st=>st.sleepRecovery==='on'?'recovery on':'recovery off',desc:'What pup does when things go wrong, and how much it touches your windows beyond driving the pages inside them.',rows:['sleepRecovery','reportIssues','minimalTouch']},- ]},-];-const SET_PREFIX={taskbarMode:'Taskbar: ',overlayBadges:'Taskbar: ',overlaySize:'Overlay badges: ',taskbarGrouping:'AUMID: ',aumidIconStyle:'AUMID: ',jumpLists:'Taskbar: ',jlViewTasks:'Jump lists: ',jlCloseAll:'Jump lists: ',jlDashboard:'Jump lists: ',jlAnnotate:'Jump lists: ',annCaptionBar:'Captures: ',toolbarExtension:'Chrome: ',tbSnipHotkey:'Toolbar: ',tbFloatyPencil:'Toolbar: ',floatyHitPx:'Corner pencil: ',taskbarActivity:'Taskbar: ',fgCaption:'Foreground: ',fgCaptionMs:'Caption: ',notMineCaption:'Foreground: ',sleepRecovery:'Recovery: ',autoLogin:'Vault: ',captureLogins:'Vault: ',reportIssues:'Safety: ',titleTag:'Title: ',titlePrefix:'Title: ',ownerChip:'In page: ',minimalTouch:'Safety: '};-const SET_DEFS=Object.fromEntries(SETTINGS_DEF.map(d=>[d.k,d]));-const SET_SHOT_PENDING=new Set(['tbFloatyPencil','titleTag','titlePrefix','notMineCaption']);-let _dlgSelected=null;-function _rowName(k){return '<h4 class="row-name" title="'+esc(k)+'"><span class="prefix">'+esc(SET_PREFIX[k]||'')+'</span><span class="title">'+esc(SET_NAMES[k]||k)+'</span></h4>';}-function _toggle(k,on,dis){return '<button class="toggle'+(on?' is-on':'')+'" role="switch" aria-checked="'+on+'" aria-label="'+esc(SET_NAMES[k]||k)+'" data-tgl="'+k+'"'+(dis?' disabled':'')+'><span class="toggle-track"><span class="toggle-thumb"></span></span></button>';}-function _shot(name,alt,cap){return name?'<figure class="setting-shot"><img src="/dashboard/example?n='+name+'&v='+PAGE_VERSION+'" alt="'+esc(alt||'')+'" loading="lazy" data-fallback="hide">'+(cap?'<figcaption>'+esc(cap)+'</figcaption>':'')+'</figure>':'';}-function _dflt(k){return '<span class="default-hint">Default: '+esc(SET_DEFAULTS[k]||'')+'</span>';}-function renderRow(k,st,enabled){- const def=SET_DEFS[k]; if(!def) return '';- const dis=!enabled;- if(def.slider){- const v=Number(st[k])||(def.unit==='px'?24:3500); const unit=def.unit||'s';- const disp=unit==='px'?v+'px':(v/1000).toFixed(1)+'s';- return '<div class="set-row" id="row-'+k+'">'+_rowName(k)+'<p class="row-desc">'+esc(def.sliderLabel||'')+'</p>'- +'<div class="slider-line"><input type="range" min="'+def.slider[0]+'" max="'+def.slider[1]+'" step="'+def.slider[2]+'" value="'+v+'" data-slider="'+k+'" data-unit="'+unit+'" aria-label="'+esc(SET_NAMES[k]||k)+'"'+(dis?' disabled':'')+'>'- +'<span class="sval" id="val-'+k+'">'+disp+'</span>'- +(def.test?'<button class="btn sm" data-testcap="1" title="Show a sample caption on your screen now, at the configured duration"'+(dis?' disabled':'')+'>test</button>':'')+'</div>'+_dflt(k)+'</div>';- }- if(SET_CHOICES[k]){- const cur=st[k];- return '<div class="set-row" id="row-'+k+'">'+_rowName(k)+'<p class="row-desc">'+esc(def.d)+'</p>'- +'<fieldset class="choice-set" role="radiogroup" aria-label="'+esc(SET_NAMES[k]||k)+'" data-enabled="'+(!dis)+'">'- +SET_CHOICES[k].map(c=>'<label class="choice" data-selected="'+(cur===c.v)+'"><input type="radio" name="sc-'+k+'" value="'+c.v+'" data-choice="'+k+'"'+(cur===c.v?' checked':'')+(dis?' disabled':'')+'>'- +'<img src="/dashboard/example?n='+c.img+'&v='+PAGE_VERSION+'" alt="'+esc(c.alt)+'" loading="lazy" data-fallback="hide">'- +'<span class="choice-name">'+esc(c.n)+'</span><span class="choice-desc">'+esc(c.d)+'</span></label>').join('')- +'</fieldset>'+_dflt(k)+'</div>';- }- if(def.sel){- const selHtml='<select data-sel="'+k+'" aria-label="'+esc(SET_NAMES[k]||k)+'"'+(dis?' disabled':'')+'>'+def.sel.map(x=>'<option value="'+esc(x)+'"'+(st[k]===x?' selected':'')+'>'+esc((def.selLabels&&def.selLabels[x])||x)+'</option>').join('')+'</select>';- const threadEx=def.threadex?'<div class="texwrap"><div class="texstrip" id="texstrip"></div><div class="texcap">Your real thread names, drawn exactly as the taskbar draws them. The badge in the corner is the favicon of the active tab.</div></div>':'';- return '<div class="set-row" id="row-'+k+'"><div class="row-head">'+_rowName(k)+selHtml+'</div><p class="row-desc">'+esc(def.d)+'</p>'+threadEx+_dflt(k)+'</div>';- }- const on=st[k]==='on';- const shot=def.pair?'<div style="display:flex;gap:.75rem;flex-wrap:wrap">'+def.pair.map(q=>_shot(q.img,q.cap,q.cap)).join('')+'</div>'- :def.img?_shot(def.img,SET_NAMES[k]+' as it appears on this machine',null):(SET_SHOT_PENDING.has(k)?'<div class="shot-pending">Screenshot pending: a real capture of this will be added; nothing is mocked up in its place.</div>':'');- return '<div class="set-row" id="row-'+k+'"><div class="row-head">'+_rowName(k)+_toggle(k,on,dis)+'</div><p class="row-desc">'+esc(def.d)+'</p>'+shot+_dflt(k)+'</div>';-}-function renderCard(card,st){- const status=card.status?card.status(st):'';- const okish=/^(on|active|tiles|aumid|progress bar|orange flash|capturing|recovery on|owner chip on|caption bar on)$/.test(status);- let body='';- if(card.html){ body=card.html(); }- else{- const keys=card.rows;- const parentKey=keys[0];- const parentDef=SET_DEFS[parentKey];- const cardOn=card.depMode?(st.taskbarMode===card.depMode):true;- // the first row is the parent; rows that declare dep on it (or on a sibling) are its children- const children=keys.slice(1).filter(k=>SET_DEFS[k]&&(SET_DEFS[k].dep===parentKey||keys.includes(SET_DEFS[k].dep)));- const siblings=keys.slice(1).filter(k=>!children.includes(k));- body+=renderRow(parentKey,st,cardOn);- if(children.length){- const pOn=cardOn&&(parentDef&&parentDef.sel?true:st[parentKey]==='on');- body+='<div class="setting-children" data-enabled="'+pOn+'" title="'+(pOn?'':'Available when '+esc(SET_NAMES[parentKey])+' is on')+'">'- +(pOn?'':'<div class="row-why">Available when '+esc(SET_NAMES[parentKey])+' is on.</div>')- +children.map((k,i)=>{- const d=SET_DEFS[k]; const gp=d.dep&&d.dep!==parentKey?d.dep:null; // grandchild (e.g. pencil hit area under corner pencil)- const en=pOn&&(!gp||st[gp]==='on');- return (i?'<div class="divider"></div>':'')+renderRow(k,st,en);- }).join('')+'</div>';- }- siblings.forEach(k=>{ body+='<div class="divider"></div>'+renderRow(k,st,cardOn); });- }- return '<section class="settings-card" id="'+card.id+'"><h3 class="card-title">'+esc(card.t)+(status?' <span class="card-status'+(okish?' ok':'')+'">'+esc(status)+'</span>':'')+'</h3>'- +(card.desc?'<p class="card-desc">'+esc(card.desc)+'</p>':'')+body+'</section>';-}-function renderDlg(){- const st=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};- st.taskbarMode=st.aumidIcons!=='on'?'plain':(st.threadTiles==='on'?'tiles':'aumid');- _q('#dlgSide').innerHTML=SET_SECTIONS.map(sec=>'<button class="side-sec'+(_dlgSelected===sec.id?' selected':'')+'" data-jump="'+sec.id+'"><span aria-hidden="true" style="font-size:.7em;opacity:.7">\u25BE</span>'+esc(sec.t)+'</button>'- +sec.cards.map(c=>'<button class="side-sub'+(_dlgSelected===c.id?' selected':'')+'" data-jump="'+c.id+'">'+esc(c.t)+'</button>').join('')).join('');- _q('#dlgBody').innerHTML=SET_SECTIONS.map(sec=>'<h2 class="sec-title" id="'+sec.id+'">'+esc(sec.t)+'</h2>'+sec.cards.map(c=>renderCard(c,st)).join('')).join('')- +'<footer class="settings-footer"><p class="footer-note">Settings are stored on this machine (~/.adom/pup-settings.json) and apply to live windows immediately. Restoring returns every option above to its default; your identity avatar is kept.</p>'- +'<button class="restore-btn" id="restoreBtn">Restore defaults</button></footer>';- paintThreadExamples();-}-function openDlg(){- renderDlg(); _q('#dlgWrap').classList.remove('hidden');- const dlg=_q('#dlg'), sc=_q('#dlgScroller');- try{dlg.focus();}catch(e){}- sc.scrollTop=0; requestAnimationFrame(()=>{sc.scrollTop=0; spyDlg();});-}-function closeDlg(){_q('#dlgWrap').classList.add('hidden');}-let _spyRaf=0,_spyMuteUntil=0;-function spyDlg(){- const pane=_q('#dlgScroller'); if(!pane) return;- const line=pane.getBoundingClientRect().top+pane.clientHeight*0.33;- let cur=null;- for(const h of pane.querySelectorAll('.sec-title,.settings-card')){ if(h.getBoundingClientRect().top<=line) cur=h.id; else break; }- if(cur&&cur!==_dlgSelected){_dlgSelected=cur; document.querySelectorAll('#dlgSide [data-jump]').forEach(b=>b.classList.toggle('selected',b.dataset.jump===cur));}-}-let _restoreArmed=0;-// v2.0.172 THREAD TILE EXAMPLES β draw sample taskbar tiles on canvas using the SAME rules as-// renderThreadTileIco on the server (teal #00b8b1, ink #05221f, auto-fit, 2-line wrap on the last-// space or the first hyphen, text anchored BOTTOM-LEFT so the top-right favicon badge stays clear).-// Uses the threads that actually have windows open right now, then fills out with canonical examples-// so the strip always shows a range of name lengths.-function paintThreadExamples(){- const strip=_q('#texstrip'); if(!strip) return;- // v2.0.204 (John, the final spec): the examples are FIVE FIXED CANONICAL SAMPLES β wiki page,- // dashboard app, ti.com, mouser, digikey β rendered identically under every one of the six- // states, from BUNDLED ground-truth art (each vendor's own favicon, the real category tiles, the- // real Chrome-for-Testing icon). Always present, never dependent on which windows are open: the- // rows exist to teach what the modes MEAN.- const st2=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};- const mode=st2.aumidIcons!=='on'?'plain':(st2.threadTiles==='on'?'tiles':'aumid');- const ovOn=st2.overlayBadges!=='off';- const V='v='+(typeof PAGE_VERSION!=='undefined'?PAGE_VERSION:'0');- // v2.0.206 (John: the wiki has TWO icon styles β signed-in and public β both are canonical- // samples): SIX fixed samples per state row.- const SAMPLES=[- {tile:'wiki in', fav:'/dashboard/sample-favicon?n=wiki&'+V, cat:'wikiin'},- {tile:'wiki out', fav:'/dashboard/sample-favicon?n=wiki&'+V, cat:'wiki'},- {tile:'dash', fav:'/dashboard/icon.svg?'+V, cat:'app'},- {tile:'ti.com', fav:'/dashboard/sample-favicon?n=ti&'+V, cat:'web'},- {tile:'mouser', fav:'/dashboard/sample-favicon?n=mouser&'+V, cat:'web'},- {tile:'digikey', fav:'/dashboard/sample-favicon?n=digikey&'+V, cat:'web'},- ];- function fb(sm){ return '<span class="mbcard"><img src="'+sm.fav+'" alt=""></span>'; }- function tileIc(sm,ov){ return '<span class="mic"><canvas width="128" height="128" data-tname="'+esc(sm.tile)+'"></canvas>'+(ov?fb(sm):'')+'</span>'; }- function aumidIc(sm,ov){ return '<span class="mic"><img class="mbase" src="/dashboard/sample-cat?k='+sm.cat+'&'+V+'" data-catfb="'+sm.cat+'">'+(ov?fb(sm):'')+'</span>'; }- // v2.0.205 (John: "I want all 5 icons for each of the 6 states"): plain rows show all five- // samples too β each as the stock Chrome icon (that IS what plain looks like per window), with- // its OWN site badge in the overlay row. The stacking note stays as a caption, not a collapse.- function plainIc(sm,ov){ return '<span class="mic"><img class="mbase" src="/dashboard/baseicon?profile=adom-you&'+V+'">'+(ov?fb(sm):'')+'</span>'; }- function plainGroup(ov){- var cards=SAMPLES.map(function(sm){- return '<span style="display:inline-flex;flex-direction:column;width:44px;height:34px;border:1px solid #3a4a52;border-radius:4px;overflow:hidden;background:#e8eaed;flex:none">'- +'<span style="display:flex;align-items:center;gap:3px;height:12px;background:#202124;padding:0 3px">'- +'<img src="'+sm.fav+'" style="width:8px;height:8px;object-fit:contain">'- +'<span style="flex:1"></span><span style="color:#9aa0a6;font-size:7px;line-height:1">\u2715</span></span>'- +'<span style="flex:1;background:linear-gradient(#ffffff,#e8eaed)"></span></span>';- }).join('');- // v2.0.266: this strip of six 44px flex:none cards did NOT wrap, so it was a hard ~298px floor- // that no amount of shrinking elsewhere could beat β the last 49px of the panel's horizontal- // overflow (and the seam past .texstrip's background) came from exactly here. Let it wrap.- return '<div style="display:flex;flex-direction:column;align-items:center;gap:3px;max-width:100%;min-width:0">'- +'<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:4px;padding:5px 6px;border:1px solid #2f3f46;border-radius:6px;background:#1b2429;max-width:100%">'+cards+'</div>'- +'<span style="color:#4a5a58;font-size:10px;line-height:1.2">\u25B2 what hovering the ONE button shows</span>'- +'<span class="mic"><img class="mbase" src="/dashboard/baseicon?profile=adom-you&'+V+'">'+(ov?fb(SAMPLES[3]):'')+'</span>'- +'</div>';- }- function showApplyProgress(){- // v2.0.214b: a setting click re-renders the whole dialog, destroying anything inserted at- // click time β so the panel re-finds (or re-creates) itself on EVERY poll tick instead.- function ensureEl(){- var el=document.getElementById('applyprog');- if(el&&el.isConnected) return el;- var anchor=document.querySelector('.st6'); if(!anchor) return null;- el=document.createElement('div'); el.id='applyprog';- el.style.cssText='margin:10px 4px 2px;padding:10px 12px;border:1px solid #2a3f3e;border-radius:8px;background:#0f1817;font:12px/1.8 "JetBrains Mono",Consolas,monospace;color:#9fb3b1';- (anchor.parentElement||anchor).insertAdjacentElement('afterend',el);- return el;- }- var t0=Date.now();- function paint(a){- var el=ensureEl(); if(!el) return false;- el.style.display='block';- if(!a || (Date.now()-a.at>60000 && !a.done)){ el.innerHTML='<span style="color:#00b8b1">Applying\u2026</span>'; return false; }- var h='<style>@keyframes apr{to{transform:rotate(360deg)}}.apspin{display:inline-block;animation:apr 1s linear infinite}</style>';- a.steps.forEach(function(st){- var mark = st.state==='done' ? '<span style="color:#3fb950">\u2713</span>'- : st.state==='run' ? '<span class="apspin" style="color:#00b8b1">\u25CC</span>'- : '<span style="color:#4a5a58">\u25CB</span>';- h+='<div>'+mark+' '+st.label+'</div>';- });- if(a.done) h+='<div style="margin-top:6px;color:#3fb950;font-weight:700">\u2713 Done: your taskbar now matches this state</div>';- el.innerHTML=h;- return !!a.done;- }- (function tick(){- fetch('/dashboard/apply-progress').then(function(r){return r.json()}).then(function(j){- var fin=paint(j&&j.apply);- if(fin){ setTimeout(function(){ var e2=document.getElementById('applyprog'); if(e2) e2.style.display='none'; },7000); return; }- if(Date.now()-t0<45000) setTimeout(tick,600);- else { var e3=ensureEl(); if(e3) e3.innerHTML='<span style="color:#d29922">Still settling in the background\u2026 (check the taskbar in a few seconds)</span>'; }- }).catch(function(){ if(Date.now()-t0<45000) setTimeout(tick,900); });- })();- }- const states=[- ['tiles',true, '1. tiles + overlay'],- ['tiles',false,'2. tiles'],- ['aumid',true, '3. aumid + overlay'],- ['aumid',false,'4. aumid'],- ['plain',true, '5. plain + overlay'],- ['plain',false,'6. plain'],- ];- strip.innerHTML='<div class="st6">'+states.map(function(cfg,i){- const m=cfg[0],ov=cfg[1],lbl=cfg[2];- const cur=(m===mode&&ov===ovOn);- const isDefault=(m==='tiles'&&ov);- let icons='';- if(m==='tiles'){ icons=SAMPLES.map(function(sm){return tileIc(sm,ov);}).join(''); }- else if(m==='aumid'){ icons=SAMPLES.map(function(sm){return aumidIc(sm,ov);}).join(''); }- else {- var ptxt = ov- ? 'ALL pup windows share this ONE stock Chrome button. They stay separate windows: hover the button and Windows fans out a preview of each (drawn above). You just cannot ungroup them on the bar, because the per-app identity that separates buttons is exactly what plain mode removes. The single badge belongs to the most recently updated window.'- : 'ALL pup windows share this ONE stock Chrome button. They stay separate windows: hover the button and Windows fans out a preview of each (drawn above). You just cannot ungroup them on the bar, because the per-app identity that separates buttons is exactly what plain mode removes.';- icons=plainGroup(ov)+'<span class="texn" style="align-self:center;flex:1 1 190px;min-width:0;max-width:100%;text-align:left">'+ptxt+'</span>';- }- return '<div class="strow6'+(cur?' mcur':'')+'" data-si="'+i+'" title="click to switch to this state">'- +'<span class="stlbl">'+lbl+(cur?' (current)':'')+(isDefault?' <span class="mdef">default</span>':'')+'</span>'- +'<div class="sticons">'+icons+'</div></div>';- }).join('')+'</div>';- const cap=strip.parentElement?strip.parentElement.querySelector('.texcap'):null;- if(cap)cap.textContent='Six canonical sample windows (signed-in wiki, public wiki, the pup dashboard app, ti.com, mouser, digikey) rendered under every state so you can compare like for like. Tiles are drawn by the real tile renderer; category icons and the Chrome icon are the real registered art; badges are each site\u2019s own favicon. Click a row to switch to it; a step list shows the apply progress and a green check when your taskbar matches.';- strip.querySelectorAll('canvas[data-tname]').forEach(function(cv){ drawNameTile(cv.getContext('2d'),128,cv.getAttribute('data-tname')); });- strip.querySelectorAll('img[data-catfb]').forEach(function(im){- im.onerror=function(){- const cv=document.createElement('canvas'); cv.width=128; cv.height=128; cv.className='mbase';- const ctx=cv.getContext('2d'),box=128;- rr(ctx,0,0,box,box,box*0.14,'#00b8b1');- rr(ctx,box*0.18,box*0.18,box*0.64,box*0.64,box*0.1,'#053b38');- ctx.strokeStyle='#7fe7e2';ctx.lineWidth=box*0.045;- ctx.beginPath();ctx.arc(box/2,box/2,box*0.19,0,7);ctx.stroke();- im.replaceWith(cv);- };- });- strip.querySelectorAll('.strow6').forEach(function(row,i){- row.onclick=function(){- const c=states[i];- setSetting('taskbarMode',c[0]);- setSetting('overlayBadges',c[1]?'on':'off');- showApplyProgress();- };- });-}-function rr(ctx,x,y,w,h,r,fill){ctx.fillStyle=fill;ctx.beginPath();if(ctx.roundRect)ctx.roundRect(x,y,w,h,r);else ctx.rect(x,y,w,h);ctx.fill();}-function drawNameTile(ctx,box,nm){- rr(ctx,0,0,box,box,0,'#00b8b1');- const FLOOR=box*0.36;- const wFor=function(i,total){return (total===2&&i===0)?box*0.80:box*0.92;};- const setF=function(sz){ctx.font='700 '+sz+"px 'Familjen Grotesk','Segoe UI',sans-serif";};- const wid=function(t,sz){setF(sz);return ctx.measureText(t).width;};- const fitSize=function(lines){let sz=box*0.9;- for(let i=0;i<90;i++){let okf=true;- for(let j=0;j<lines.length;j++){if(wid(lines[j],sz)>wFor(j,lines.length)){okf=false;break;}}- if(okf&&lines.length*sz*0.95<=box*0.88)break;sz*=0.95;}- return sz;};- const cands=[[nm]];const sp=nm.lastIndexOf(' '),hy=nm.indexOf('-');- if(sp>0)cands.push([nm.slice(0,sp),nm.slice(sp+1)]);else if(hy>0)cands.push([nm.slice(0,hy+1),nm.slice(hy+1)]);- if(sp<0&&hy<0&&nm.length>=7){const mm=Math.ceil(nm.length/2);cands.push([nm.slice(0,mm),nm.slice(mm)]);}- let best=[nm],bf=0;for(const c of cands){const f=fitSize(c);if(f>bf){bf=f;best=c;}}- if(bf<FLOOR){bf=FLOOR;- const take=function(str,maxw){let n=0;for(let i=1;i<=str.length;i++){if(wid(str.slice(0,i),FLOOR)<=maxw)n=i;else break;}return n;};- let rest=String(nm).trim();const out=[];- for(let ln=0;ln<2&&rest;ln++){- let n=take(rest,wFor(ln,2));- if(n>=rest.length){out.push(rest);rest='';break;}- if(n<1)n=1;- const seg=rest.slice(0,n+1);- const bp=Math.max(seg.lastIndexOf(' '),seg.lastIndexOf('-'));- const cut=(bp>=Math.ceil(n*0.5))?bp+(seg.charAt(bp)==='-'?1:0):n;- out.push(rest.slice(0,cut).trim());- rest=rest.slice(cut).replace(/^[ -]+/,'');}- best=out.filter(Boolean);}- setF(bf);ctx.fillStyle='#05221f';ctx.textAlign='left';ctx.textBaseline='alphabetic';- const lh=bf*0.95,mx=box*0.055,baseY=box-box*0.05-bf*0.14;- best.forEach(function(ln,i){ctx.fillText(ln,mx,baseY-(best.length-1-i)*lh);});-}--async function setSetting(key,value){- // optimistic: the control already reflects the click; the re-render after the save must not- // jump the scroller (settings-layout: one continuous pane, position preserved across saves).- const sc=_q('#dlgScroller'); const keepTop=sc?sc.scrollTop:0;- const j=await act({action:'set-setting',key,value});- if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;renderDlg();if(sc){sc.scrollTop=keepTop;}}-}-document.addEventListener('click',ev=>{- if(ev.target.closest('#gear')){openDlg();return;}- if(ev.target.closest('#dlgClose')||ev.target.id==='dlgBg'){closeDlg();return;}- const j=ev.target.closest('[data-jump]');- if(j){_dlgSelected=j.dataset.jump;_spyMuteUntil=Date.now()+600;document.querySelectorAll('#dlgSide [data-jump]').forEach(b=>b.classList.toggle('selected',b===j));const el=document.getElementById(j.dataset.jump);if(el)el.scrollIntoView({block:'start'});return;}- if(ev.target.closest('#restoreBtn')){- const b=_q('#restoreBtn');- if(!_restoreArmed){_restoreArmed=Date.now();b.classList.add('armed');b.textContent='Click again to confirm';setTimeout(()=>{if(_restoreArmed&&Date.now()-_restoreArmed>=3900){_restoreArmed=0;b.classList.remove('armed');b.textContent='Restore defaults';}},4000);return;}- _restoreArmed=0;b.classList.remove('armed');b.textContent='Restoringβ¦';- act({action:'restore-defaults'}).then(j=>{if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;}renderDlg();});- return;- }- const t=ev.target.closest('[data-tgl]');- if(t){if(t.disabled)return;const next=t.classList.contains('is-on')?'off':'on';t.classList.toggle('is-on',next==='on');t.setAttribute('aria-checked',next==='on');setSetting(t.dataset.tgl,next);return;}- if(ev.target.closest('[data-testcap]')){act({action:'test-caption'});return;}- if(ev.target.closest('#importScanBtn')){runImportScan();return;}- if(ev.target.closest('#importRunBtn')){runImport();return;}-});-document.addEventListener('change',ev=>{- const c=ev.target.closest('[data-imchk]');- if(c){_importSel[c.dataset.imchk]=c.checked;}-});-document.addEventListener('input',ev=>{- const r=ev.target.closest('[data-slider]');- if(r){_q('#val-'+r.dataset.slider).textContent=r.dataset.unit==='px'?r.value+'px':(Number(r.value)/1000).toFixed(1)+'s';}-});-document.addEventListener('keydown',ev=>{ if(ev.key==='Escape'&&!_q('#dlgWrap').classList.contains('hidden')){closeDlg();} });-document.addEventListener('scroll',ev=>{ if(ev.target&&ev.target.id==='dlgScroller'){ if(_spyRaf)return; _spyRaf=requestAnimationFrame(()=>{_spyRaf=0; if(Date.now()<_spyMuteUntil)return; spyDlg();}); } },true);-document.addEventListener('change',ev=>{- const c=ev.target.closest('[data-choice]');- if(c){const set=c.closest('.choice-set');if(set)set.querySelectorAll('.choice').forEach(l=>l.dataset.selected=String(l.contains(c)));setSetting(c.dataset.choice,c.value);return;}- const r=ev.target.closest('[data-slider]');- if(r){setSetting(r.dataset.slider,Number(r.value));return;}- const sl=ev.target.closest('[data-sel]');- if(sl){- const def=SETTINGS_DEF.find(d=>d.k===sl.dataset.sel);- if(def&&def.imgBy){const im=sl.closest('.sub').querySelector('.sexample');if(im)im.src='/dashboard/example?n='+def.imgBy[sl.value]+'&v='+PAGE_VERSION;}- setSetting(sl.dataset.sel,sl.value);- }-});-(function(){- const b=_q('#brand'),pop=_q('#pop');let show=null,hide=null;- function place(){const r=b.getBoundingClientRect();pop.style.left=Math.max(8,r.left)+'px';pop.style.top=(r.bottom+8)+'px';}- b.addEventListener('mouseenter',()=>{clearTimeout(hide);show=setTimeout(()=>{place();pop.classList.add('show');},600);});- b.addEventListener('mouseleave',()=>{clearTimeout(show);hide=setTimeout(()=>pop.classList.remove('show'),220);});- pop.addEventListener('mouseenter',()=>clearTimeout(hide));- pop.addEventListener('mouseleave',()=>{hide=setTimeout(()=>pop.classList.remove('show'),220);});-})();-document.addEventListener('error',ev=>{const i=ev.target;if(i&&i.tagName==='IMG'&&i.dataset&&i.dataset.fallback==='hide')i.style.visibility='hidden';},true);-document.addEventListener('click',ev=>{- const tog=ev.target.closest('[data-toggle]');- if(tog){const id=tog.dataset.toggle;if(COLL.has(id))COLL.delete(id);else COLL.add(id);render();return;}- const jmp=ev.target.closest('[data-jump]');- if(jmp){const t=document.getElementById(jmp.dataset.jump);if(t){t.scrollIntoView({behavior:'smooth',block:'start'});t.style.transition='box-shadow .3s';t.style.boxShadow='0 0 0 2px #00b8b1';setTimeout(()=>t.style.boxShadow='',900);}return;}- const b=ev.target.closest('[data-act]');if(!b)return;- const a=b.dataset.act,c=b.dataset.confirm;if(c&&!confirm(c))return;- if(a==='flash-group'||a==='unflash-group'){(b.dataset.ids||'').split(',').filter(Boolean).forEach(sid=>act({action:a==='flash-group'?'flash':'unflash',sessionId:sid}));return;}- act({action:a,sessionId:b.dataset.sid,tabId:b.dataset.tid});-});-_q('#f').onchange=render; _q('#q').oninput=render;-const es=new EventSource('/dashboard/events');-es.onmessage=e=>{const d=JSON.parse(e.data);if(d.error){_q('#m').innerHTML='<div class="empty">snapshot error: '+esc(d.error)+'</div>';_q('#live').classList.add('stale');return;}SNAP=d;_q('#live').classList.remove('stale');render();};-es.onerror=()=>_q('#live').classList.add('stale');--/* ββ the SHARED newbie panel (adom-ui-design 2.0.0) βββββββββββββββββββββββββββββββββββββββββββ- pup owns exactly three things here: the phrases, the noun, and what the demo button does.- Everything else (card, grid, table, icons, clipboard, send with its version gate and- three-state verification, toasts, tooltips, the X and the header toggle) belongs to the module.- apiBase is left at its default so the two Hydrogen calls go SAME-ORIGIN and this bridge relays- them; see the /updater/status and /ai-threads/run-prompt routes in server.js. */-AdomNewbiePanel.mount({- mount: '#newbie',- app: 'pup',- appName: 'Pup',- slug: 'pup-bridge',- toggleButton: '#recall',- demo: {- label: 'Play the demo',- meta: '5 steps, about 2 minutes',- onPlay: function () {- AdomNewbiePanel.toast('Handing the demo script to a new AI thread. This can take up to two minutes.');- fetch('/api/demo-send', {method:'POST'})- .then(function(r){ return r.json(); })- .then(function(j){ AdomNewbiePanel.toast(j.ok ? 'The demo is running in a new AI thread.' : (j.why || 'Could not reach the AI panel.'), j.ok ? '' : 'err'); })- .catch(function(){ AdomNewbiePanel.toast('Could not reach the AI panel.', 'err'); });- }- },- phrases: ${JSON.stringify(SAY_PHRASES)}-});--/* Status pills, straight from /api/status so the pills and the LED feed cannot drift. */-async function paintLeds(){- var host=document.getElementById('leds'); if(!host) return;- try{- var j=await (await fetch('/api/status',{cache:'no-store'})).json();- host.innerHTML=(j.leds||[]).map(function(l){- var esc=function(t){ return String(t==null?'':t).replace(/&/g,'&').replace(/</g,'<'); };- return '<span class="led-pill" title="'+esc(l.detail)+'"><span class="d '+esc(l.state)+'"></span>'- +'<span class="lb">'+esc(l.label)+'</span><span class="sh">'+esc(l.short)+'</span></span>';- }).join('');- }catch(e){}-}-paintLeds(); setInterval(paintLeds,15000);-</script></body></html>`;--const server = http.createServer(async (req, res) => {- const url = new URL(req.url, `http://localhost:${PORT}`);- const pathname = url.pathname;-- // CORS preflight- if (req.method === 'OPTIONS') {- // v2.0.389 (control-api-proxy trap 1, caught by newbie-panel 2.22.0's checkSend): a BLANKET 204- // answers for paths that exist in no method, so a probe cannot tell a wired route from a typo.- // Preflight 204 only for paths pup actually serves; everything else is a 404.- const _optPath = (() => { try { return new URL(req.url, 'http://127.0.0.1').pathname; } catch (e) { return req.url || ''; } })();- const _served = /^\/(command|status|health|launch|navigate|reload|screenshot(\.png)?|errors|eval|wait|close|updater\/status|ai-threads\/run-prompt(\/status)?|api\/[A-Za-z0-9_-]+|dashboard(\/.*)?)$/.test(_optPath);- if (!_served) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{"error":"no such route"}'); return; }- res.writeHead(204, {- 'Access-Control-Allow-Origin': '*',- 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',- 'Access-Control-Allow-Headers': 'Content-Type',- });- res.end();- return;- }- // v1.9.334 (#468): everything except the lightweight /health probe needs the deferred native modules- // (puppeteer/sharp/keytar/chrome). Load them on first use; /health stays answerable during the ~8s- // one-time load so AD never mistakes a warming bridge for a wedged one.- // v2.0.13 (John, "everyone keeps saying pup is down"): ALSO exclude /status. pup declares /status as- // its healthEndpoint (for the rich cold-start chip), so ab's spawn-ready probe hits /status; when- // /status triggered the ~8s heavy init it blocked the event loop and ab timed out and reaped the- // bridge ("never bound its port 64230"), the exact cold-spawn flap. /status now answers instantly- // (it guards `chrome` for the pre-init window) and never gates on the native load.- if (pathname !== '/health' && pathname !== '/status') ensureHeavyInit();-- try {- // GET /health- // ββ v1.9.224 β LIVE MANAGEMENT DASHBOARD (John's ask) βββββββββββββββββββββββββββββββββββββ- // Point a browser at http://<pup-host>:<port>/dashboard to see every window pup is driving right- // now: its overlay badge (the ACTUAL composed PNG, inline), tab list, owning AI thread, fg/bg with- // the justification the caller had to give to foreground it, highFps lease, uptime β filterable by- // thread. Updates live over SSE, so it reflects reality rather than a snapshot you have to reload.- //- // This is also a debugging instrument: most of the hardest pup bugs (a stale badge from a dead- // session, duplicate sessions painting one window, a window pinned by an emulated viewport) are- // obvious the moment you can SEE all the state at once, and were invisible while state could only- // be read one verb at a time.- if (pathname === '/dashboard/events') {- res.writeHead(200, {- 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',- 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*',- });- try { res.flushHeaders && res.flushHeaders(); } catch (e) {}- let alive = true;- const push = async () => {- if (!alive) return;- // v1.9.229: REPORT the failure, do not swallow it. The previous version caught any error from- // dashboardSnapshot() and silently set alive=false, so a single throw left the page stuck on- // "connectingβ¦" forever with no way to see why. That is exactly the failure-suppression- // pattern the restructure plan calls out (525 catch blocks, many hiding real errors) and I- // reproduced it in brand-new code. Now the error is streamed to the client and logged.- let payload;- try {- payload = JSON.stringify(await dashboardSnapshot());- } catch (e) {- console.log(`[dashboard] snapshot FAILED: ${e && e.stack ? e.stack.split('\n')[0] : e}`);- payload = JSON.stringify({ error: String((e && e.message) || e), ts: Date.now() });- }- try { res.write(`data: ${payload}\n\n`); } catch (e) { alive = false; }- };- await push();- const iv = setInterval(push, 2000);- req.on('close', () => { alive = false; clearInterval(iv); });- return;- }- if ((pathname === '/ui/toast' || pathname === '/dashboard/toast') && req.method === 'POST') {- // adom-ui-design section 3: the toast hook, built in so the AI can narrate over the live app- // ("Running test 3 of 8") through the same surface the app uses for its own feedback.- try {- const body = JSON.parse(await readBody(req) || '{}');- const msg = String(body.msg || body.message || '').slice(0, 200);- if (!msg) { sendJSON(res, { ok: false, error: 'msg required' }); return; }- _dashToasts.push({ id: ++_dashToastId, msg, type: ['success', 'error', 'info'].includes(body.type) ? body.type : 'info', at: Date.now() });- while (_dashToasts.length > 20) _dashToasts.shift();- sendJSON(res, { ok: true }); return;- } catch (e) { sendJSON(res, { ok: false, error: e.message }); return; }- }- if (pathname === '/dashboard/action' && req.method === 'POST') {- // v1.9.226 (John): make the dashboard actionable, not just observational β close all / close one- // window / close one tab, and flash a taskbar button orange (or clear it), per-button and for- // everything at once. These reuse the SAME internals the verbs use, so the dashboard can never- // drift from verb behaviour. Flash is the "come look at this one" cue; unflash clears it without- // stealing focus, which matters because this user alt-tabs away from foregrounded windows.- let body = '';- req.on('data', c => { body += c; });- await new Promise(r => req.on('end', r));- let a = {}; try { a = JSON.parse(body || '{}'); } catch (e) {}- const act = a.action, sid = a.sessionId, tid = a.tabId;- const done = (o2) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o2)); };- try {- // Closing = close every tab in the session (the same effect pup_close_window has); the- // session is then reaped by the existing zero-tab cleanup, so there is no second code path.- const closeOne = async (id) => {- const sess = sessions.get(id);- if (!sess) return false;- for (const t of [...(sess.tabs || [])]) { try { await t.page.close(); } catch (e) {} }- try { sessions.delete(id); deleteSessionFile(id); } catch (e) {}- return true;- };- if (act === 'close-all') {- const ids = [...sessions.keys()];- for (const id of ids) { try { await closeOne(id); } catch (e) {} }- return done({ ok: true, closed: ids.length });- }- if (act === 'close-window' && sid) { const okc = await closeOne(sid); return done({ ok: okc, closed: sid }); }- if (act === 'close-tab' && sid && tid) {- const sess = sessions.get(sid);- const tab = sess && (sess.tabs || []).find(t => t.tabId === tid);- if (!tab) return done({ ok: false, error: 'tab not found' });- try { await tab.page.close(); } catch (e) {}- return done({ ok: true, closed: tid });- }- if (act === 'test-caption') {- // v1.9.262 (John: "give them a test button so they can see the caption").- // v2.0.294: same word-count scaling as real captions, so the test shows the truth.- const _msg = 'This is what an AI-foreground caption looks like. Reason: "you clicked Test in pup settings"';- const ms = captionMsFor(_msg);- pupCaption(`${_msg} (${(ms / 1000).toFixed(1)}s)`, ms, 'pup-fg-reason', ms).catch(() => {});- return done({ ok: true, ms });- }- if (act === 'import-scan') {- // Dashboard "Import logins" picker β self-call the read-only discovery verb.- try {- const r = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_import_scan', args: {} }) });- const j = await r.json().catch(() => ({}));- return done({ ok: !!j.success, profiles: j.profiles || [], totalPasswords: j.totalPasswords || 0, accounts: j.accounts || [], pythonAvailable: !!j.pythonAvailable });- } catch (e) { return done({ ok: false, error: e.message }); }- }- if (act === 'import-run') {- // AD >= 1.9.220 ships desktop_decrypt_pup_credentials (the SYSTEM-elevated ABE decrypt,- // adom/adom-bridge #51). It MUST be called LOCALLY: chrome.abCommand routes over AD's- // direct API (127.0.0.1), which is exactly the loopback path AD requires β a relayed call is- // refused with errorCode:relay_forbidden and pops no UAC. AD fires its OWN consent dialog +- // UAC prompt (that IS the user's permission for the decrypt). Plaintext returns ONCE over- // loopback; we merge it into the identity-tagged vault and DROP it. NEVER log a password or- // echo it into another verb/response β this handler returns COUNTS ONLY.- const sel = Array.isArray(a.profiles) ? a.profiles : [];- const browsers = [...new Set(sel.map(p => p && p.browser).filter(Boolean))];- if (!browsers.length) return done({ ok: false, error: 'no profiles selected' });- console.log(`[import] decrypt requested for ${sel.length} profile(s) across ${browsers.join(', ')} β calling AD helper locally (consent + UAC will prompt on the desktop)`);- let resp;- try {- resp = await decryptBrowserCreds({ browsers, include: ['passwords'] });- } catch (e) { return done({ ok: false, error: 'decrypt helper call failed: ' + (e && e.message || e) }); }- const payload = abPayload(resp) || {};- const ec = payload.errorCode || (resp && resp.errorCode);- if (ec) {- const emap = {- relay_forbidden: 'The decrypt helper refused a relayed call (pup must call it locally). This is a pup bug β please report it.',- consent_declined: 'You declined the Adom Bridge consent dialog. Nothing was read.',- elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',- helper_missing: 'The signed decrypt helper is not installed β update or repair Adom Bridge.',- };- return done({ ok: false, errorCode: ec, message: emap[ec] || ('Decrypt failed: ' + ec) });- }- const pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];- if (!payload.ok && !pwRows.length) return done({ ok: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') });- // Honor per-profile deselection: keep rows whose (browser, profile) was selected; if the- // profile keys don't line up with AD's naming, fall back to browser-level (never drop all).- const wanted = new Set(sel.filter(p => p && p.profileDir).map(p => `${p.browser} ${p.profileDir}`));- let rows = wanted.size ? pwRows.filter(r => r && wanted.has(`${r.sourceBrowser} ${r.sourceProfile}`)) : pwRows.slice();- if (!rows.length) rows = pwRows.filter(r => r && browsers.includes(r.sourceBrowser));- let merge;- try { merge = await credentialVault.mergeImportedCredentials(rows); }- catch (e) { return done({ ok: false, error: 'vault merge failed: ' + (e && e.message || e) }); }- // DROP plaintext immediately β zero every returned password so nothing lingers in memory/response.- try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; } catch (e) {}- const st = payload.stats || {};- console.log(`[import] vault merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped (AD stats profilesRead=${st.profilesRead} passwordsOk=${st.passwordsOk} passwordsFailed=${st.passwordsFailed})`);- return done({ ok: true, imported: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,- stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed, cookiesFailed: st.cookiesFailed },- message: `Imported ${merge.added + merge.updated} logins into your adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''}), tagged by source account. pup can now sign in as you on those sites.` });- }- if (act === 'restore-defaults') {- // v2.0.296 (settings-layout skill: the two-click Restore defaults footer). Wipe every- // stored option so the D block's defaults apply; the user's IDENTITY (avatar + name)- // is data, not an option, so it survives.- const keep = {};- for (const k of ['identityAvatarB64', 'identityName']) if (pupSettings()[k] !== undefined) keep[k] = pupSettings()[k];- try { fs.mkdirSync(path.dirname(PUP_SETTINGS_PATH), { recursive: true }); fs.writeFileSync(PUP_SETTINGS_PATH, JSON.stringify(keep, null, 2)); } catch (e) {}- _pupSettings = keep;- console.log('[settings] restored defaults (dashboard dialog)');- try { _startModeApply(pupSettings().aumidIcons !== 'on' ? 'plain' : (pupSettings().threadTiles === 'on' ? 'tiles' : 'aumid')); } catch (e) {}- return done({ ok: true, settings: pupSettings() });- }- if (act === 'set-setting') {- // v1.9.259: the dashboard settings dialog. Whitelisted keys only; values validated.- const ALLOWED = {- aumidIcons: ['on', 'off'], jumpLists: ['on', 'off'],- fgCaption: ['on', 'off'], overlayBadges: ['on', 'off'], overlaySize: ['50', '66', '75', '100'],- sleepRecovery: ['on', 'off'],- reportIssues: ['on', 'off'], autoLogin: ['on', 'off', 'submit', 'fill'], captureLogins: ['on', 'off'], minimalTouch: ['on', 'off'],- taskbarGrouping: ['split', 'grouped'],- taskbarActivity: ['progress', 'flash', 'off'],- aumidIconStyle: ['standard', 'category'],- threadTiles: ['on', 'off'],- jlViewTasks: ['on', 'off'], jlCloseAll: ['on', 'off'], jlDashboard: ['on', 'off'], jlAnnotate: ['on', 'off'], annCaptionBar: ['on', 'off'], toolbarExtension: ['on', 'off'], tbOwnerBadge: ['on', 'off'], tbSnipHotkey: ['on', 'off'], tbFloatyPencil: ['on', 'off'],- notMineCaption: ['on', 'off'], ownerChip: ['on', 'off'],- titleTag: ['on', 'off'], titlePrefix: ['on', 'off'],- };- const key = String(a.key || ''); let val = a.value;- // v2.0.179: taskbarMode β the dialog's 3-way identity ladder (tiles | aumid | plain).- // One key in the UI, mapped onto the two stored keys; full re-register + sweep so every- // live window moves to the chosen mode immediately.- if (key === 'taskbarMode') {- if (!['tiles', 'aumid', 'plain'].includes(val)) return done({ ok: false, error: 'taskbarMode must be tiles|aumid|plain' });- const pair = val === 'tiles' ? { aumidIcons: 'on', threadTiles: 'on' }- : val === 'aumid' ? { aumidIcons: 'on', threadTiles: 'off' }- : { aumidIcons: 'off', threadTiles: 'off' };- savePupSettings({ ...pair, _chosen: { ...(pupSettings()._chosen || {}), aumidIcons: true, threadTiles: true } });- console.log(`[settings] taskbarMode -> ${val} (dashboard dialog)`);- _startModeApply(val); // v2.0.214: the apply-progress tracker owns the strip/sweep now- return done({ ok: true, settings: pupSettings() });- }- if (key === 'floatyHitPx') {- val = Math.max(6, Math.min(200, Number(val) || 24));- } else if (key === 'fgCaptionMs') {- val = Math.max(500, Math.min(12000, Number(val) || 3500)); // v2.0.294 slider goes to 8s; reading-time cap is 12s- } else if (!ALLOWED[key] || !ALLOWED[key].includes(val)) {- return done({ ok: false, error: 'unknown setting or value' });- }- const _ch = { ...(pupSettings()._chosen || {}), [key]: true };- savePupSettings({ [key]: val, _chosen: _ch });- console.log(`[settings] "${key}" -> ${val} (dashboard dialog)`);- // v1.9.279: a taskbar-identity setting must take effect on EXISTING windows immediately,- // not just future ones. Reconcile now re-stamps every live window to match (idempotent).- if (['aumidIcons', 'taskbarGrouping', 'aumidIconStyle', 'ownerChip', 'titleTag', 'titlePrefix'].includes(key)) reconcileModel('setting ' + key).catch(() => {});- if (['jumpLists', 'jlViewTasks', 'jlCloseAll', 'jlDashboard', 'jlAnnotate'].includes(key)) refreshAllJumplists().catch(() => {});- // v2.0.197 (the 6-state proof exposed it): overlayBadges OFF only stopped FUTURE paints β- // badges already on glass stayed until some unrelated churn wiped them, so the toggle- // looked broken. OFF now actively clears every painted badge; ON repaints the fleet.- if (key === 'overlayBadges') {- // v2.0.214: a standalone overlay toggle gets its own one-step progress entry; when a- // mode switch is mid-flight its tracker already covers the badge pass, so leave it be.- let _obApply = null;- if (!_modeApply || _modeApply.done || Date.now() - _modeApply.at > 20000) {- _obApply = { at: Date.now(), token: ++_modeApplyTok, mode: 'overlay-' + val, steps: [{ label: val === 'on' ? 'Painting overlay badges' : 'Clearing overlay badges', state: 'run' }], done: false };- _modeApply = _obApply;- }- applyOverlayBadgeSetting(val, _obApply).catch(() => {});- }- // v2.0.262: badge SIZE live-applies exactly like the badge on/off toggle β a size change- // that only took effect on the next natural repaint would look broken (John flips it, the- // taskbar does not move). Same repaint path, with its own progress entry.- if (key === 'overlaySize') {- let _szApply = null;- if (!_modeApply || _modeApply.done || Date.now() - _modeApply.at > 20000) {- _szApply = { at: Date.now(), token: ++_modeApplyTok, mode: 'overlaysize-' + val, steps: [{ label: val === '100' ? 'Repainting badges at full size' : 'Repainting badges at half size', state: 'run' }], done: false };- _modeApply = _szApply;- }- applyOverlayBadgeSetting(pupSettings().overlayBadges === 'off' ? 'off' : 'on', _szApply).catch(() => {});- }- // v2.0.90: the pencil hit-area slider live-applies β re-inject the floaty on every open- // page (the versioned guard includes the px value, so a change tears down and rebuilds).- if (key === 'floatyHitPx' || key === 'tbFloatyPencil' || key === 'tbSnipHotkey') {- (async () => { try { const fj = floatyJs(); for (const [, s6] of sessions) for (const t6 of (s6.tabs || [])) { try { await Promise.race([t6.page.evaluate(fj), new Promise(r => setTimeout(r, 1200))]); } catch (e) {} } } catch (e) {} })();- }- // v1.9.320: OFF clears the menu, ON/sub re-commits- if (key === 'ownerChip') applyOwnerChipSetting(val === 'on').catch(() => {});- return done({ ok: true, settings: pupSettings() });- }- if (act === 'fg' && sid) {- // v1.9.243 (John: "add a 'bring to fg' and 'send to bg' button to each tab/window").- // Self-call the REAL raise verb so every safety behaviour applies unchanged (caption with- // the reason, standard reveal geometry, badge re-assert). A dashboard click IS the user- // acting, so the foreground gate's reason is exactly that.- try {- if (tid) { await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_switch_tab', args: { sessionId: sid, tabId: tid } }) }); }- const r = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_raise_os_window', args: { sessionId: sid, foregroundReason: 'you clicked "bring to fg" on this window in the pup dashboard' } }) });- const j = await r.json().catch(() => ({}));- return done({ ok: !!j.success });- } catch (e) { return done({ ok: false, error: e.message }); }- }- if (act === 'bg' && sid) {- try {- const r = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_lower_os_window', args: { sessionId: sid } }) });- const j = await r.json().catch(() => ({}));- return done({ ok: !!j.success });- } catch (e) { return done({ ok: false, error: e.message }); }- }- if (act === 'flash' && sid) { try { await flashViaAD(sid, { force: true }); } catch (e) {} return done({ ok: true }); }- if (act === 'unflash' && sid) { try { await flashViaAD(sid, { stop: true, force: true }); } catch (e) {} return done({ ok: true }); }- if (act === 'flash-all' || act === 'unflash-all') {- const stop = act === 'unflash-all';- let delivered = 0;- for (const id of [...sessions.keys()]) { try { const r = await flashViaAD(id, stop ? { stop: true, force: true } : { force: true }); if (r) delivered++; } catch (e) {} }- if (stop) {- // v1.9.243: sweep by ENUMERATION, not bookkeeping. The tracked set is per-boot, so any- // flash started before the last bridge restart was invisible to it (exactly what John- // hit). Enumerate every visible window of every pup-managed Chrome process and stop each- // one β no memory required, covers retitled/closed-session/pre-restart flashes alike.- const swept = new Set();- try {- for (const h of await enumAllPupChromeHwnds()) {- try { await chrome.abCommand('desktop_flash_window', { hwnd: h, stop: true }, { timeoutMs: 6000 }); swept.add(h); } catch (e) {}- }- } catch (e) {}- for (const h of [..._flashedHwnds]) {- if (!swept.has(h)) { try { await chrome.abCommand('desktop_flash_window', { hwnd: h, stop: true }, { timeoutMs: 6000 }); } catch (e) {} }- _flashedHwnds.delete(h);- }- console.log(`[flash] clear-all swept ${swept.size} enumerated pup Chrome windows + tracked set`);- return done({ ok: true, n: sessions.size, swept: swept.size });- }- return done({ ok: true, n: sessions.size, delivered });- }- if (act === 'regen-overlays') {- // v1.9.298 (John: "give me a button to regenerate all overlay icons β a clean sweep so I can- // verify you're doing it right, and it may help debug why they drift"). Runs the SAME- // from-scratch computation used on load, for EVERY window, rewriting the badge PNG the- // dashboard reads β so the taskbar and dashboard both refresh from one source. A full sweep- // re-resolves each window handle (serialized wiggle) + refetches each favicon, which takes- // longer than an HTTP round-trip, so RUN IT IN THE BACKGROUND and answer immediately. The- // dashboard icons update live over SSE as each window repaints; the per-window drift report- // (which windows were wrong until the sweep β i.e. which verb failed to re-trigger the calc,- // John's hypothesis) goes to the bridge log AND is stored for the header to show.- // v1.9.300 (John hit the button and "it didn't work"): a prior sweep left _regenBusy STUCK- // true (its await hung on the deadlocked wiggle), so every later click returned "already- // running" and did nothing. PREEMPT a wedged flag: only refuse if a sweep genuinely started- // in the last 90s. And below, per-window timeouts + a finally guarantee the flag always- // clears, so the button can never wedge again.- const _regenAgeMs = (_lastRegen && _lastRegen.startedAt) ? (Date.now() - _lastRegen.startedAt) : Infinity;- if (_regenBusy && _regenAgeMs < 90000) return done({ ok: true, started: false, alreadyRunning: true, swept: (_lastRegen && _lastRegen.inFlight) || 0 });- const ids = [...sessions.keys()].filter(id => { const s = sessions.get(id); return s && (s.tabs || []).length; });- _regenBusy = true;- _lastRegen = { startedAt: Date.now(), inFlight: ids.length, done: false, swept: 0, painted: 0, changed: 0 };- (async () => {- const results = [];- const favKind = (f) => (f && f.kind) || 'none';- for (const id of ids) {- const sess = sessions.get(id);- if (!sess || !(sess.tabs || []).length) continue;- const before = { hwnd: sess._hwnd || null, fav: favKind(sess._overlayFav) };- // Clear every cache so this is a TRUE recompute, not a replay of the last cached pick,- // and force handle re-resolution (a stale/wrong handle is the other half of the drift).- sess._overlayFav = null; sess._faviconRetried = false; sess._noFavLogged = false;- sess._hwnd = null; sess._appOverlay = false; sess._overlayEnforceAt = 0;- let painted = false;- // Per-window timeout: one wedged window can never stall the whole sweep (or the flag).- try { painted = await Promise.race([applyAppOverlay(sess, id), new Promise(r => setTimeout(() => r(false), 20000))]); } catch (e) {}- const after = { hwnd: sess._hwnd || null, fav: favKind(sess._overlayFav) };- const changed = String(before.hwnd) !== String(after.hwnd) || before.fav !== after.fav;- results.push({ sessionId: id, painted: !!painted, favKind: after.fav, hwndBefore: before.hwnd, hwndAfter: after.hwnd, favBefore: before.fav, changed });- }- try { await reconcileModel('regen-overlays (dashboard button)'); } catch (e) {}- const changed = results.filter(r => r.changed).length;- const painted = results.filter(r => r.painted).length;- console.log(`[regen] DONE β swept ${results.length} windows, ${painted} painted, ${changed} corrected (hwnd or favicon changed on the sweep)`);- for (const r of results.filter(x => x.changed)) console.log(`[regen] CORRECTED "${r.sessionId}": hwnd ${r.hwndBefore}->${r.hwndAfter}, fav ${r.favBefore}->${r.favKind}`);- _lastRegen = { at: Date.now(), done: true, swept: results.length, painted, changed, results };- })().catch((e) => { try { console.log('[regen] sweep error: ' + e.message); } catch (x) {} }).finally(() => { _regenBusy = false; });- return done({ ok: true, started: true, swept: ids.length });- }- return done({ ok: false, error: 'unknown action' });- } catch (e) { return done({ ok: false, error: e.message }); }- }- if (pathname === '/dashboard/icon.svg') {- // v1.9.231 (John): the dashboard is an Adom APP, so per adom-ui-design it serves its OWN icon- // rather than inheriting Chrome's. Clean 32x32 SVG, Adom teal, the two-leaf pup mark on a- // rounded tile so it reads at 16px in a taskbar overlay and in a browser tab. Served as a real- // asset (not a data: URI) so pup's favicon pipeline fetches it exactly like any other app icon,- // which also means the dashboard window's own taskbar badge is produced by the same code path- // it is built to display.- // v1.9.237 (John: "why have you not created your own pup favicon yet using our adom ui design- // skills?"). Designed against adom-ui-design: exact brand tokens β accent #00b8b1, on-accent- // #05221f (the token for marks on a teal fill), bg #0d1117, border #30363d. Concept = two- // overlapping browser-window cards (the live "cluster of windows" pup drives) with an SSE- // heartbeat pulse across the front one (live monitoring). Bold fills + negative-space marks so- // nothing hairlines at 16px, and it reads as its OWN app, distinct from the plain Adom leaf mark.- // v1.9.250 (adom-ui-design section 9, John: "what does it say about headers and icons?"):- // "Icons are monochrome white. No exceptions... and the favicon." The teal version violated the- // master rule (brand color belongs only in marketing art). Same two-windows+pulse concept,- // #e6edf3 marks on the dark ground tile.- res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });- // v2.0.339: the LINE DRAWING, and the reason it changed here is worth recording. This route- // served the filled teal tile, which was correct while the dock opened the container-side- // launch page (that page carried the line icon). 2.0.337 moved the dock to THIS page and the- // tab reverted to the tile, because THE FAVICON IS A PROPERTY OF THE PAGE, NOT OF THE APP:- // change which page the dock opens and you change the icon with it. John caught it on his- // screen ("why is this its icon?") within minutes of the merge.- // Same artifact as docs/img/dock-pup.svg (dockbar.json webview.favicon) so the tab favicon,- // the header mark and the dock card are ONE drawing, per the icons skill. tests/check_icon.js- // asserts the two stay byte-identical in geometry, because two copies of a drawing in two- // ship artifacts is exactly how they drift.- // stroke is HARDCODED #e6edf3, never currentColor: a favicon renders as an ISOLATED document- // where currentColor resolves to black and the mark vanishes on the dark tab strip.- res.end('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '- + 'stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'- + '<rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/>'- + '<path d="M2.75 8.75h18.5"/>'- + '<circle cx="12" cy="14.2" r="3.1"/>'- + '</svg>');- return;- }- if (pathname === '/dashboard/favicon') {- // v1.9.225 (John): every TAB shows its own favicon in the dashboard as a visual cue. Cached per- // tab URL so repeated SSE renders never re-fetch, and fetched through the page itself (cookies)- // so authed app icons resolve β the same path that fixed the dashboard-v2 badge.- const sid = url.searchParams.get('session') || '';- const tid = url.searchParams.get('tab') || '';- try {- const sess = sessions.get(sid);- const tab = sess && (sess.tabs || []).find(t => t.tabId === tid);- if (!tab || !tab.page) { res.writeHead(404); res.end(); return; }- let key = null; try { key = tab.page.url(); } catch (e) {}- if (key && _tabFavCache.has(key)) {- const c = _tabFavCache.get(key);- res.writeHead(200, { 'Content-Type': c.mime || 'image/png', 'Cache-Control': 'public, max-age=60' }); // v1.9.311: cacheable so tab favicons don't refetch every SSE tick (the refresh blip)- res.end(c.buf); return;- }- // v1.9.238: the rich resolver returns a rasterized PNG + the full acquisition STATE (format,- // color-scheme mode, transparency, how it was fetched). Cache both β the meta feeds each tab's- // dashboard row so John can see per tab exactly how its favicon was obtained.- const rich = await Promise.race([- resolveFaviconRich(tab.page),- new Promise(r => setTimeout(() => r(null), 3500)),- ]).catch(() => null);- if (!rich || !rich.b64) {- // v1.9.276 (John: "why 300 errors in pup-dashboard?"): a 404 here re-fired on EVERY 2s- // SSE re-render (fresh img elements each render, Chrome does not cache 404s) and each one- // logged a console error in the dashboard window - one unresolvable tab favicon filled the- // whole 300-entry error buffer. Same doctrine as taskbar badges: never bare, never 404 -- // serve the generic page glyph with a 200, and negative-cache so the expensive resolution- // is not re-run every tick either.- if (key) { _tabFavCache.set(key, { buf: Buffer.from(GENERIC_TAB_SVG), mime: 'image/svg+xml' }); _tabFavMeta.set(key, { meta: { kind: 'none', note: 'no favicon resolved; generic glyph shown' }, at: Date.now() }); }- res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=60' });- res.end(GENERIC_TAB_SVG); return;- }- const buf = Buffer.from(rich.b64, 'base64');- if (key) {- _tabFavCache.set(key, { buf, mime: 'image/png' }); if (_tabFavCache.size > 200) _tabFavCache.delete(_tabFavCache.keys().next().value);- const { b64, ...metaNoBytes } = rich; _tabFavMeta.set(key, { meta: metaNoBytes, at: Date.now() }); if (_tabFavMeta.size > 300) _tabFavMeta.delete(_tabFavMeta.keys().next().value);- }- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });- res.end(buf); return;- } catch (e) { res.writeHead(404); res.end(); return; }- }- if (pathname === '/dashboard/example') {- // v1.9.262 (John: "show screenshots... so user knows what this is cuz they won't know what- // aumid is"): illustrative images for the settings dialog, shipped with the bridge.- try {- const nm = (url.searchParams.get('n') || '').replace(/[^a-z0-9]/g, ''); // v2.0.301: names carry digits (obsize50)- const f = path.join(__dirname, 'icons', 'settings-ex', nm + '.png');- const buf = fs.readFileSync(f);- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });- res.end(buf); return;- } catch (e) { res.writeHead(404); res.end(); return; }- }- // v2.0.230: the dashboard re-renders every 2s and emits FRESH <img> elements, so these endpoints- // were doing a synchronous file read + full ICO decode per window per 2 seconds. Cache the- // decoded bytes keyed on path+mtime; the art only changes when a tile is re-rendered.- if (pathname === '/dashboard/stampicon') {- // v2.0.210 (John: "why aren't your dashboard icons consistent with the settings?"): the strip- // must show what the button ACTUALLY wears. In tiles mode that is the THREAD TILE, not the- // category icon this endpoint predates. Serve the tile when tiles mode owns the session.- try {- const _sid0 = url.searchParams.get('session') || '';- const _s0 = sessions.get(_sid0);- if (pupSettings().aumidIcons === 'on' && pupSettings().threadTiles === 'on' && _s0 && (_s0.owner || _s0._ownerThread)) {- const _tp0 = path.join(THREAD_TILE_DIR, `${threadSlug(_s0.owner || _s0._ownerThread)}.ico`);- const _inner0 = cachedIconPng(_tp0, icoLargestPng);- // mtime-keyed cache upstream, so the browser may cache too β a re-render of the same tile- // is byte-identical, and a NEW tile lands under a new mtime (and a fresh ?v= from the page).- if (_inner0) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=60' }); res.end(_inner0); return; }- }- } catch (e) {}- // v1.9.246 (John's audit): a STAMPED window's real taskbar button shows the icon the stamp- // registered (pupIconPath's category tile), NOT the Chrome exe icon - the strip must match.- try {- const sid = url.searchParams.get('session') || '';- const inner = cachedIconPng(pupIconPath(sid), icoLargestPng);- if (!inner) { res.writeHead(404); res.end(); return; }- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=600' });- res.end(inner); return;- } catch (e) { res.writeHead(404); res.end(); return; }- }- if (pathname === '/dashboard/baseicon') {- // v1.9.235 (John's audit): the taskbar strip must show the FULL button - the real browser icon- // with the overlay in its corner - not the overlay blown up as the whole button. Extract the- // actual icon from the browser exe that owns the profile's process (PowerShell, once, cached),- // so if the machine runs Edge we show Edge, not a hardcoded Chrome.- try {- const prof = url.searchParams.get('profile') || '';- const cache = path.join(require('os').tmpdir(), 'pup-badge-debug', `base-${prof.replace(/[^a-z0-9-]/gi,'_')}.png`);- if (!fs.existsSync(cache)) {- const be = browsers.get(prof);- const pid = be && be.browser && (be.browser._detachedPid || (() => { try { return be.browser.process()?.pid; } catch (e) { return null; } })());- if (!pid) { res.writeHead(404); res.end(); return; }- const ps1 = path.join(require('os').tmpdir(), `_adom_baseicon_${process.pid}.ps1`);- fs.writeFileSync(ps1, [- 'Add-Type -AssemblyName System.Drawing',- `$p = (Get-Process -Id ${Number(pid)} -ErrorAction Stop).Path`,- '$i = [System.Drawing.Icon]::ExtractAssociatedIcon($p)',- '$b = $i.ToBitmap()',- `$b.Save('${cache.split(String.fromCharCode(92)).join(String.fromCharCode(92,92))}', [System.Drawing.Imaging.ImageFormat]::Png)`,- ].join(String.fromCharCode(13,10)));- await runPsHiddenPromise(ps1, { timeout: 8000 });- }- const buf = fs.readFileSync(cache);- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=600' });- res.end(buf); return;- } catch (e) { res.writeHead(404); res.end(); return; }- }- if (pathname === '/dashboard/sample-favicon') {- // v2.0.204 (John: the Settings examples are CANONICAL β wiki page, dashboard app, ti.com,- // mouser, digikey β so users learn what the modes mean regardless of what is open): serve the- // bundled ground-truth favicons fetched from each vendor's own site (ti's is the red Texas- // logo pup's live pipeline cannot fetch yet, #55 β the example is still correct art).- try {- const n = (url.searchParams.get('n') || '').replace(/[^a-z]/g, '');- const map = { wiki: ['wiki.svg', 'image/svg+xml'], ti: ['ti.ico', 'image/x-icon'], mouser: ['mouser.ico', 'image/x-icon'], digikey: ['digikey.svg', 'image/svg+xml'] };- if (map[n]) {- const buf = fs.readFileSync(path.join(__dirname, 'icons', 'settings-samples', map[n][0]));- res.writeHead(200, { 'Content-Type': map[n][1], 'Cache-Control': 'public, max-age=3600' });- res.end(buf); return;- }- } catch (e) {}- res.writeHead(404); res.end(); return;- }- if (pathname === '/dashboard/apply-progress') { sendJSON(res, { ok: true, apply: _modeApply }); return; }- if (pathname === '/dashboard/sample-cat') {- // The real registered category tiles (wiki signed-in / app / web) as PNG, for the aumid rows.- try {- const k = (url.searchParams.get('k') || '').replace(/[^a-z]/g, '');- const name = { wikiin: 'pup-cat-wiki-in9.ico', wiki: 'pup-cat-wiki9.ico', app: 'pup-cat-app9.ico', web: 'pup-cat-web9.ico' }[k];- if (name) {- const inner = icoLargestPng(fs.readFileSync(path.join(_NOTCH_CACHE, name)));- if (inner) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' }); res.end(inner); return; }- }- } catch (e) {}- res.writeHead(404); res.end(); return;- }- if (pathname === '/dashboard/bestbadge') {- // v2.0.201 (John: the matrix overlay cells showed blank circles): serve the composed badge of- // a session whose favicon is REAL (kind page-favicon), so the Settings preview always has an- // ACCURATE overlay example when any live window has one. 404s only when no such session exists.- try {- let best = null;- for (const [sid0, s0] of sessions) {- const k = s0 && s0._overlayFav && s0._overlayFav.kind;- if (k === 'page-favicon' || k === 'app-provided') {- const f0 = path.join(require('os').tmpdir(), 'pup-badge-debug', `${sid0}.png`);- try { const st0 = fs.statSync(f0); if (!best || st0.mtimeMs > best.m) best = { f: f0, m: st0.mtimeMs }; } catch (e) {}- }- }- if (best) { const buf = fs.readFileSync(best.f); res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); res.end(buf); return; }- } catch (e) {}- res.writeHead(404); res.end(); return;- }- if (pathname === '/dashboard/badge') {- // Serve the exact overlay PNG pup last composed for a session, so the dashboard shows the REAL- // badge rather than a re-render that might differ from what Windows was handed.- const sid = url.searchParams.get('session') || '';- try {- const f = path.join(require('os').tmpdir(), 'pup-badge-debug', `${sid}.png`);- const buf = fs.readFileSync(f);- // v1.9.311: cacheable now that the URL is versioned by paint time (&v=). A repaint changes the- // version β new URL β one refetch; between repaints the browser serves from cache (no blip).- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });- res.end(buf); return;- } catch (e) {- // v2.0.94: a 1x1 transparent PNG instead of a 404. A freshly (re)opened window has no- // composed overlay yet, and every such 404 landed in the dashboard page's OWN error- // tracking β the red "N err" chip was counting its self-inflicted badge probes (John saw- // "10 err" on a healthy dashboard). No badge is an empty image, not an error.- res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });- res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')); return;- }- }- if (pathname === '/favicon.ico') {- // v2.0.94: Chrome probes /favicon.ico on every bridge-served page; the 404 polluted the- // dashboard's err chip. Serve the dashboard icon.- res.writeHead(302, { Location: '/dashboard/icon.svg', 'Cache-Control': 'public, max-age=86400' });- res.end(); return;- }- if (pathname === '/dashboard' || pathname === '/dashboard/') {- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });- res.end(DASHBOARD_HTML);- return;- }- if (pathname === '/identity') {- // v2.0.58 (identity-card skill): the Adom identity payload for the toolbar Identity- // extension's medium card. Reads ab's session (~/.adom/session.json), asks Carbon for- // /user + /user/orgs, caches 10 min. Fields are passed through as-is; absent stays absent- // (the card hides what it does not have β never invent fields).- try {- if (global.__pupIdCache && Date.now() - global.__pupIdCache.at < 600000) { sendJSON(res, global.__pupIdCache.v); return; }- let sess = {};- try { sess = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.adom', 'session.json'), 'utf8')); } catch (e) {}- const token = sess.token || sess.session_token || sess.access_token || (sess.session && sess.session.token) || null;- const su = sess.user || {};- let user = null, orgs = [];- if (token) {- const hdrs = { Authorization: 'Bearer ' + token };- try { const r1 = await fetch('https://carbon.adom.inc/user', { headers: hdrs }); if (r1.ok) user = await r1.json(); } catch (e) {}- try { const r2 = await fetch('https://carbon.adom.inc/user/orgs', { headers: hdrs }); if (r2.ok) { const j2 = await r2.json(); orgs = Array.isArray(j2) ? j2 : (j2.orgs || []); } } catch (e) {}- }- const u = user || su || {};- const v = {- ok: true,- displayName: u.display_name || u.displayName || u.name || null,- username: u.name || u.username || null,- email: u.email || null,- avatarUrl: u.avatar_url || u.avatarUrl || null,- avatarB64: pupSettings().identityAvatarB64 || null,- isAdmin: !!(u.is_admin || u.isAdmin),- orgs: (orgs || []).map(o => ({ name: o.name, displayName: o.display_name || o.name, avatarUrl: o.avatar_url || null })),- profile: 'adom-you',- };- global.__pupIdCache = { at: Date.now(), v };- sendJSON(res, v);- } catch (e) { sendJSON(res, { ok: false, error: e.message }); }- return;- }- if (pathname === '/health') {- const sessionList = [];- for (const [id, s] of sessions) {- let pageUrl = null;- try { pageUrl = s.page.url(); } catch (e) {}- // v2.0.51: owner + view ride /health so the toolbar extension can badge tabs per owning- // thread and label its view toggle without a heavier verb round-trip.- // v2.0.100: field renamed owner -> ownerThread ON PURPOSE. Stale extension workers on- // warm Chromes (which never reload their staged extensions) paint the retired initials- // badge from this row's owner field every 30s; renaming it starves them within one sweep- // without touching any thread's running windows. New extension readers use ownerThread.- sessionList.push({ sessionId: id, url: pageUrl, active: id === activeSessionId, ownerThread: s.owner || s._ownerThread || null, view: s._wikiView || null, authed: (s._wikiAuthed === true ? true : (s._wikiAuthed === false ? false : null)) });- }- sendJSON(res, {- status: 'ok',- bridge: 'puppeteer',- ...statusCore(),- port: PORT,- toolbar: { enabled: pupSettings().toolbarExtension !== 'off', ownerBadge: pupSettings().tbOwnerBadge !== 'off', snipHotkey: pupSettings().tbSnipHotkey !== 'off', floaty: pupSettings().tbFloatyPencil !== 'off' },- browserCount: browsers.size,- sessions: sessionList,- chrome: (chrome && chrome.readiness) ? chrome.readiness() : { ready: false, initializing: true },- });- return;- }-- // POST /command β generic command dispatch- if (pathname === '/command' && req.method === 'POST') {- const body = JSON.parse(await readBody(req));- const command = body.command;- const args = body.args || {};- res._adomCommand = command; // v1.0.16: lets sendJSON inject this verb's _hint/related- // v2.0.362: stash the ARGS too. sendJSON's decorator only ever received (obj, verb), so any- // notice that needs to know WHICH SESSION a call was about had no way to find out: the- // response body of a verb like pup_screenshot carries no sessionId, and `args` was not in- // scope. Two notices shipped against that blind spot and neither could ever fire (the #80- // restart notice and the #79-adjacent viewport warning). This is the missing wire.- res._adomArgs = args;- // v2.0.14 (John): GUIDANCE SHIM for the 2026-08 browser_* -> pup_* clean break. The break is- // deliberate β nothing executes under the old names, no aliases β but the bare ab-side error- // ("no installed bridge claims this prefix") MISLED broken callers into reinstalling pup- // instead of renaming their call, and the CLI's --help / `help <old-verb>` paths were dead ends- // too. So pup re-claims the browser_ prefix SOLELY to answer with the exact fix. The break- // still happens (callers must migrate), but now it teaches the new verb instead of stranding- // the AI. nbrowser_* (the extension) is unaffected β it does not start with "browser_".- // Delete this shim once the fleet has migrated.- if (typeof command === 'string' && command.startsWith('browser_')) {- const newVerb = command.replace(/^browser_/, 'pup_');- console.log(`[rename-shim] refused '${command}' -> told caller to use '${newVerb}' (thread: ${req.headers['x-adom-caller-thread'] || 'unknown'})`);- sendJSON(res, {- success: false,- error: `'${command}' was RENAMED to '${newVerb}' in the 2026-08 browser_* -> pup_* cutover (clean break, no aliases β this call did NOT execute). Re-issue the SAME call as '${newVerb}' with identical args.`,- errorCode: 'verb_renamed',- renamedTo: newVerb,- _hint: `The whole verb family moved one-for-one: browser_<x> is now pup_<x> (e.g. browser_open_window -> pup_open_window, browser_screenshot -> pup_screenshot). Update your caller/skill to the pup_* names permanently β the old names are gone for good. Full catalog: pup_describe. Docs: https://wiki.adom.inc/adom/pup-bridge`,- }, 410);- return;- }- // v1.9.101 (John's ask) β CAPTURE WHO IS ASKING. AD stamps these on every bridge request it- // relays (caller-identity contract, AD >=1.9.180); pup was throwing them away. A pup window- // effectively IS an AI thread, and John runs ~20 at once, so surfacing "which thread owns this- // window" is what lets him associate a window on his taskbar with the conversation that made it.- // Attribution only β self-reported, unverifiable, never gate anything on it.- const _callerThread = req.headers['x-adom-caller-thread'] || null;- const _callerContainer = req.headers['x-adom-caller-container'] || null;- const _callerReason = req.headers['x-adom-caller-reason'] || null;- // v1.9.118: make the URL + caller container available to the shared response path so the- // call-time URL lint works on every render verb, including ones that don't echo the url back.- try { res._lintUrl = args && args.url; res._callerContainer = _callerContainer; } catch (e) {}-- // v2.0.19 (John: "the kicad thread just took over a pup window owned by the nb thread...- // force it to only work with its own pup window"): HARD OWNERSHIP, keyed on the CALLER- // IDENTITY ab already forces on every verb. The old advisory gate compared only the OPTIONAL- // args.owner field ("anonymous callers pass"), and gated just navigate/close β so any thread- // that simply didn't declare an owner could drive, retab, or close another thread's window.- // Now: every window-mutating verb that names an explicit sessionId is REFUSED when that- // session belongs to a different AI thread, unless takeover:true β which is allowed only for- // when the user explicitly asked, is logged loudly with both thread names, and reassigns- // ownership so the trail stays honest. Read verbs (screenshot, list, status, errors) stay- // open so threads can observe each other's windows without disturbing them. Runs BEFORE the- // ownership-record block so a refused caller never pollutes lastDrivenBy attribution.- // v2.0.28 (John: "we moved to ai-thread name and dropped sessionId"): a session-targeting verb- // with sessionId OMITTED resolves to the CALLER THREAD's own window β first the derived- // ai-<thread-slug> window, else the single window the thread owns under an explicit name.- // No match is a teaching error. The old activeSessionId fallback (= whichever window ANY- // thread touched last) is gone from every session-targeting verb.- // v2.0.58 (John clicked Annotate in the toolbar popup and nothing happened): a USER caller- // (toolbar popup) can only know its tab's URL, and matching only the session's MAIN page- // failed on any other tab. Resolve by ANY tab of any session when _tabUrl rides the args.- if (args && !args.sessionId && args._tabUrl && USER_CALLERS.has(_callerThread) && SESSION_TARGETING_VERBS.has(command)) {- const tu = String(args._tabUrl).split('#')[0];- outer: for (const [k3, s3] of sessions) {- for (const t3 of (s3.tabs || [])) {- let u3 = null; try { u3 = t3.page.url(); } catch (e) {}- if (u3 && u3.split('#')[0] === tu) { args.sessionId = k3; args._resolvedTabId = t3.tabId; break outer; }- }- }- }- // v2.0.130 (#47, the rename campaign): `window` is the NEW public name for the per-window- // label formerly called sessionId β needed only when one thread runs several windows.- // Accepted everywhere sessionId is; sessionId stays as a silent back-compat alias forever.- if (args && !args.sessionId && args.window != null && String(args.window).trim()) args.sessionId = String(args.window).trim();- // Stash addressing context for the central response enrichment in sendJSON (#47): responses- // emit window + aiThread alongside sessionId, and a caller that spelled out an id its own- // thread-name would have resolved gets one teaching hint.- try { res._pupCallerThread = _callerThread; res._pupExplicitSid = !!(args && args.sessionId); } catch (e) {}- // v2.0.103 (John: "dispense with sessionId β the thread name is the handle"): explicit- // thread-name addressing. aiThreadName resolves to that thread's window on any targeting verb.- if (args && !args.sessionId && (args['ai-thread'] || args.aiThread || args.aiThreadName) && SESSION_TARGETING_VERBS.has(command)) {- const tn = String(args['ai-thread'] || args.aiThread || args.aiThreadName); // v2.0.106: "ai-thread" β the CLI flag's exact spelling (John); camelCase forms kept as silent aliases- const derivedT = threadDefaultSessionId(tn);- if (sessions.has(derivedT)) args.sessionId = derivedT;- else {- const theirs = [...sessions.entries()].filter(([, s2]) => (s2.owner || s2._ownerThread) === tn).map(([k]) => k);- if (theirs.length === 1) args.sessionId = theirs[0];- else if (theirs.length) { sendJSON(res, { success: false, errorCode: 'ambiguous_session', error: `Thread "${tn}" owns ${theirs.length} windows (${theirs.join(', ')}) β pass sessionId to pick one.` }, 404); return; }- else { sendJSON(res, { success: false, errorCode: 'no_session_for_thread', error: `No pup window for thread "${tn}".`, _hint: 'pup_my_windows {} lists a thread\'s windows; pup_open_window creates yours keyed to your thread name.' }, 404); return; }- }- }- if (args && !args.sessionId && _callerThread && !USER_CALLERS.has(_callerThread) && SESSION_TARGETING_VERBS.has(command)) {- const derived = threadDefaultSessionId(_callerThread);- if (sessions.has(derived)) args.sessionId = derived;- else {- const mine = [...sessions.entries()].filter(([, s2]) => (s2.owner || s2._ownerThread) === _callerThread).map(([k]) => k);- if (mine.length === 1) args.sessionId = mine[0];- else {- sendJSON(res, {- success: false,- errorCode: mine.length ? 'ambiguous_session' : 'no_session_for_thread',- error: mine.length- ? `Your thread owns ${mine.length} windows β name the sessionId you mean (${mine.join(', ')}).`- : `Your thread ("${_callerThread}") has no pup window yet.`,- liveSessions: [...sessions.keys()],- _hint: `pup no longer implies the "active" window (that was whichever window ANY thread touched last β issue #41). ${mine.length ? 'Pass the sessionId explicitly.' : 'Open your own window first: pup_open_window (omit sessionId and your thread gets its own).'}`,- }, 404);- return;- }- }- }-- // v2.0.28 (#41, the nb thread's catch): a verb naming an EXPLICIT sessionId that no longer- // exists must ERROR. It used to silently fall back to activeSessionId β ANOTHER thread's- // window β so after a bridge restart, a thread driving its dead id navigated and screenshotted- // the pup-wiki thread's window while every response LOOKED ok. That is also what masqueraded- // as the "kicad took over nb's window" incident. Nothing mutates, nothing screenshots, the- // caller learns exactly what happened and how to recover.- if (args && typeof args.sessionId === 'string' && args.sessionId- && command !== 'pup_open_window' && !USER_CALLERS.has(_callerThread) && !sessions.has(args.sessionId)) {- const live = [...sessions.keys()];- sendJSON(res, {- success: false,- errorCode: 'session_not_found',- error: `Session "${args.sessionId}" does not exist (it may have died in a bridge restart).`,- liveSessions: live,- _hint: `Nothing was done β pup no longer falls back to the active window (that silently drove ANOTHER thread's window; issue #41). Re-create your window with pup_open_window (reusing the same sessionId is fine)${live.length ? `. Live sessions right now: ${live.join(', ')}` : ''}.`,- }, 404);- return;- }-- {- // v2.0.30 (John: the "Close ALL" jump-list task stopped working): a click on a pup window's- // OWN taskbar jump-list is the USER operating their OWN window β always sanctioned. The- // per-window tasks fire with caller aiThread 'user-taskbar-menu'; that caller bypasses the- // ownership gate entirely (it is not another AI thread trying to steal a window). And- // pup_close with no sessionId is the GLOBAL close-all (close every window across every- // thread, by design) β never gate it, never resolve it to the active window.- const _isUserTaskbar = USER_CALLERS.has(_callerThread);- const _isGlobalClose = command === 'pup_close' && !(args && args.sessionId);- // v2.0.153 (found building Thread Tiles): pup_configure sets GLOBAL settings (threadTiles,- // taskbarGrouping, aumidIconStyle, β¦), not a per-window action β so it must NEVER be gated on- // the active window. It was in this list, so changing any setting while ANOTHER thread's- // window happened to be active was refused with session_owned_by_another_thread. Removed.- const OWNER_GATED = !_isUserTaskbar && !_isGlobalClose && (MUTATING_VERBS.has(command)- || ['pup_close_tab', 'pup_close_window', 'pup_close', 'pup_focus_window',- 'pup_raise_os_window', 'pup_lower_os_window', 'pup_alert_window', 'pup_wiki_set_view',- 'pup_record_start', 'pup_record_stop'].includes(command));- // With sessionId OMITTED, a mutating verb used to resolve to activeSessionId (another thread's- // window). Gate the RESOLVED target β but NEVER manufacture activeSessionId for a global close- // or a user-taskbar action.- const _gateSid = (args && args.sessionId) || (OWNER_GATED && command !== 'pup_open_window' ? activeSessionId : null);- if (OWNER_GATED && _gateSid && _callerThread) {- const _gs = sessions.get(_gateSid);- const _owner = _gs && (_gs.owner || _gs._ownerThread);- if (_gs && _owner && _owner !== _callerThread) {- if (args.takeover) {- // v2.0.20: ownership TRANSFERS only on an open_window claim. A one-shot sanctioned- // action (reload/navigate/close to repair or hand off) must not steal the window β- // v2.0.19 reassigned on every takeover, so a repair reload would have locked the- // original owner out of its own window. The action is still logged with both names.- console.log(`[takeover] "${_gateSid}": thread "${_callerThread}" acted on "${_owner}"'s window via ${command} (takeover:true β sanctioned only when the user asked${command === 'pup_open_window' ? '; ownership TRANSFERRED' : '; ownership unchanged'})`);- _gs._lastTakeoverBy = _callerThread; _gs._takenOverAt = Date.now();- if (command === 'pup_open_window') { _gs._takenOverFrom = _owner; _gs.owner = _callerThread; _gs._ownerThread = _callerThread; }- } else {- console.log(`[own] REFUSED ${command} on "${_gateSid}"${args.sessionId ? '' : ' (RESOLVED from activeSessionId β name your own sessionId)'} β owner "${_owner}", caller "${_callerThread}"`);- let _curUrl = null; try { _curUrl = _gs.page?.url() || null; } catch (e) {}- sendJSON(res, {- success: false,- errorCode: 'session_owned_by_another_thread',- error: `Session "${_gateSid}"${args.sessionId ? '' : ' (the ACTIVE window β you omitted sessionId)'} belongs to AI thread "${_owner}" β your thread ("${_callerThread}") cannot ${command} it.`,- sessionId: _gateSid, owner: _owner, requestedBy: _callerThread, currentUrl: _curUrl,- _hint: `STOP β this window is owned by "${_owner}"; driving it would break that thread's work in front of the user. Open your OWN window instead: pup_open_window WITHOUT a sessionId gives your thread its own window (keyed on your --ai-thread name), or pass a unique task-prefixed sessionId. Only pass takeover:true if the USER explicitly told you to take this window over β every takeover is logged with both thread names.`,- }, 409);- return;- }- }- }- }-- // Record ownership on the session this verb targets: the FIRST thread to touch a session owns- // it (that is the window's identity for the user); every later call refreshes lastDrivenBy so a- // handoff is still visible rather than silently rewriting the owner.- try {- const _sid = args && args.sessionId;- const _sess = _sid && sessions.get(_sid);- if (_sess && _callerThread) {- if (!_sess._ownerThread) { _sess._ownerThread = _callerThread; _sess._ownerContainer = _callerContainer; }- _sess._lastThread = _callerThread;- _sess._lastContainer = _callerContainer;- }- } catch (e) {}-- // v1.8.23: AUTO-FLASH + agent-update tracking for state-mutating verbs on an EXISTING- // session (pup_open_window flashes from inside its own handler, once the session- // exists). evaluate is read-only iff {readOnly:true}. {silent:true} per verb or the- // session default suppresses. Runs BEFORE the handler β signals "the agent is- // touching this window" regardless of the verb's outcome.- if (MUTATING_VERBS.has(command) && command !== 'pup_open_window' && command !== 'pup_open_tab') {- const isReadOnlyEval = (command === 'pup_eval' || command === 'pup_evaluate') && args.readOnly;- if (!isReadOnlyEval) {- const sid = args.sessionId || activeSessionId;- const s = sid && sessions.get(sid);- if (s) { recordAgentUpdate(s, command, args, args.tabId || s.activeTabId); scheduleAutoFlash(s, { silent: !!args.silent }); scheduleOverlayRefresh(sid); }- }- }- // v1.9.301: close_tab is NOT in MUTATING_VERBS but it changes the active tab (hence the favicon),- // so it must re-trigger the overlay calc too. Centralize it here so no tab/window/favicon verb is- // missed β the drift John called out.- if (command === 'pup_close_tab' || command === 'pup_close_window') { try { scheduleOverlayRefresh(args.sessionId || activeSessionId); } catch (e) {} }-- // v1.9.70 β AUDIT EVERY INCOMING VERB. John: "do you even log all the actions against you?"- // Fair β the log recorded what the bridge DID (park/identity/overlay) but never WHAT WAS ASKED- // of it, so a question like "what foregrounded this window?" was unanswerable after the fact.- // One compact line per verb, with the session and the few args that change behaviour.- if (command && command !== 'bridge_log_read') {- const _a = [];- if (args.sessionId) _a.push(`session=${args.sessionId}`);- if (args.url) _a.push(`url=${String(args.url).slice(0, 60)}`);- if (args.foreground) _a.push('foreground=true');- if (args.foregroundReason) _a.push('foregroundReason=YES');- if (args.view) _a.push(`view=${args.view}`);- if (args.tabId) _a.push(`tab=${args.tabId}`);- if (args.owner) _a.push(`owner=${args.owner}`);- console.log(`[verb] ${command}${_a.length ? ' ' + _a.join(' ') : ''}`);- }-- // v1.9.57: light the taskbar progress bar while a thread is driving THIS window. Broader than- // MUTATING_VERBS above on purpose β a screenshot or an eval is still an agent working in that- // window, and that is exactly what the user wants to see. Status/polling verbs are excluded- // (see PASSIVE_VERBS) or every window would read as permanently busy.- if (command && command.startsWith('pup_') && !PASSIVE_VERBS.has(command)) {- try { markSessionBusy(args.sessionId || activeSessionId); } catch (e) {}- }-- switch (command) {- // v1.0.15: self-describe β AD's "Verbs" tab POSTs {command:'pup_describe'}- // and renders this verbatim: one entry per documented verb with- // {name, summary, input, output, timeoutSeconds, example}. (pup_ is this- // bridge's primary prefix, so AD calls it pup_describe.)- case 'pup_describe': {- // v1.0.16: each entry merges its VERB_META (hint/related/pitfalls) so AD's- // Verbs tab renders the full self-doc β the reference shape for all bridges.- const V = (name, summary, input, timeoutSeconds, example) => {- const m = VERB_META[name] || {};- // v2.0.130 (#47, aiThread-first): the catalog is where callers learn addressing, so fix- // it in ONE place β any terse/stale sessionId doc ("required str" predates zero-id- // addressing and is simply wrong now) is rewritten to lead with omission + the new- // `window` name. Entries with rich bespoke wording (pup_open_window etc.) are left as- // written. sessionId remains a silent back-compat alias everywhere.- try {- if (input && typeof input.sessionId === 'string' && input.sessionId.length < 45) {- input.sessionId = 'optional str β OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)';- }- } catch (e) {}- return { name, summary, input, output: { success: 'bool' }, timeoutSeconds,- statusVerb: null, longRunning: false, example: example || {},- hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] };- };- sendJSON(res, { success: true, bridge: 'puppeteer',- // v2.0.383 (#59/#64): the measured-progress contract, per the Bridge SDK.- progressContract: { statusVerb: 'pup_readiness', activeKey: 'active', slowVerbs: ['pup_open_window', 'pup_prewarm', 'pup_use'], blockedByPhases: ['consent', 'dialog'], blockedOnUser: { errorCode: 'blocked_on_user', rule: 'ab BRIDGE_SDK 2.1.30: a verb parked on a HUMAN never bare-timeouts; it answers this shape', stillRunning: 'true = the wait continues in the background: re-call the SAME verb with the SAME args to re-attach (no second prompt) or watch pup_readiness.active[]; false = the human must act first, then re-issue the verb', inVerbWaitSec: Math.round(consent.inVerbMs / 1000), promptLifetimeSec: consent.totalSec }, timings: pupTimingsSummary() },- verbs: [- V('pup_my_windows', 'START HERE for window management: everything YOUR thread owns β windows, tabs, urls, titles, state β with ready-to-run next steps. Zero args. You NEVER need an hwnd, desktop_find_window, or desktop_screenshot_window for a pup window: pup keys every window to its owning --ai-thread name and resolves ids for you β omit sessionId in any pup verb to target your own window, or pass "ai-thread" to address a thread by name (the CLI\'s --ai-thread flag, same spelling). pup_screenshot {} screenshots it; pup_reload {} refreshes it.', { 'ai-thread': 'optional str β inspect another thread\'s windows (read-only listing); default = the calling thread. The CLI flag\'s exact spelling; aiThread also accepted' }, 10, {}),- V('pup_open_window', 'Open a generic pup window β a fresh isolated Chrome/Edge process (never your real signed-in browser), fully drivable via CDP. ALWAYS opens in the BACKGROUND (does not steal focus; never disturbs the user\'s main work; still fully screenshottable/drivable) UNLESS you pass foreground:true β do that ONLY when the user explicitly wants to watch. Pass width+height (+ optional x,y, screen px) to place it at a specific size/position for screenshot framing β this NO LONGER foregrounds it (sizing stays in the background too; default unsized is maximized). profile defaults to the durable shared \'adom-you\' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway. OWNERSHIP: sessionIds are shared across ALL AI threads β use a task-prefixed sessionId + owner:"<your-task>" so pup can refuse other threads stealing your window (reusing an existing id with a new url navigates it away; declared cross-owner reuse errors with session_owned_by_another_thread unless takeover:true). (For the user\'s REAL Chrome/Edge, use the Adom extension\'s nbrowser_* verbs.)', { sessionId: 'optional str β OMIT IT (recommended): your thread gets its OWN window keyed on your --ai-thread name; the first open creates it, later opens become TABS in it, and no other thread can touch it. Pass an explicit id only when you need a SEPARATE window (task-prefix it, e.g. "mytask-web")', url: 'optional str', profile: 'optional str β a named durable jar; DEFAULT is the shared "adom-you" profile (signed-in state shared across windows). isolated:true overrides to a throwaway', isolated: 'optional bool β private throwaway profile: fresh-login tests, acting as a different user. REASON-GATED (v2.0.101): requires isolationReason or the open is refused β every window defaults to the shared adom-you profile', isolationReason: 'required WITH isolated:true β why this window must NOT ride the shared adom-you profile (min 10 chars; logged, auditable). webSecurity:false and wikiView:public carry their own built-in reason', webSecurity: 'optional false β dev escape hatch for cross-origin eval; FORCES isolation so insecure flags never touch the credentialed profile', owner: 'optional str β your thread/task label; lets pup protect this window from other threads', takeover: 'optional bool β claim a window owned by another thread (only when the user explicitly asks)', foreground: 'optional bool β request showing the window on the user\'s screen. GATED (v1.8.70): requires foregroundReason too, else the open succeeds but stays BACKGROUND with foregroundDenied set. Only when the USER explicitly asked to watch.', foregroundReason: 'required WITH foreground:true β quote/paraphrase what the user said that justifies putting a window on their screen (min 10 chars; logged + recorded in lastAgentUpdate, auditable)', width: 'optional int px β sizes for screenshot framing, stays background', height: 'optional int px', x: 'optional int px', y: 'optional int px', wikiView: 'optional "authed"|"public" β for wiki.adom.inc URLs. THREE distinct cases: (1) OMIT = the shared adom-you profile, LOGGED IN as the user if they have signed into the wiki (the usual case); this is NOT anonymous. (2) "public" = the SERVER renders the page exactly as a signed-out visitor sees it via the ?view=public param (adom/wiki#126: request user nulled, byte-identical to logged-out, private pages 404) β the window STAYS on the shared profile, session untouched, no isolated jar; use this to verify a publish did not leak private source. (3) "authed" = the explicit logged-in view (30-day SSO; first use may need a one-time login β response says wikiLoginNeeded). To compare "what I see" vs "what the world sees", open one "authed" (or omit) AND one "public" tab β do NOT rely on omit for the public view.', appName: 'optional str β the APP this window is driving (e.g. "shotlog"); shown in the hover tooltip', appIconB64: 'optional base64 PNG β override the app icon worn in the taskbar overlay. Usually UNNECESSARY: pup auto-fetches the page\'s own <link rel="icon"> favicon (the adom-ui-design standard artifact every app must serve) and wears it automatically. An app-looking URL with NO favicon gets a reprimand hint.', updateNote: 'optional str β a short "what I did" note, surfaced back in list_tabs/list_windows as lastAgentUpdate so the user knows what changed', autoFlash: 'optional bool (default true) β mutating verbs auto-flash this window\'s taskbar (debounced, never steals focus) so the user notices the agent touched it; pass false to make this window silent by default', silent: 'optional bool β suppress the auto-flash for just this call' }, 60, { sessionId: 'mytask-web', owner: 'mytask', url: 'https://example.com', width: 960, height: 540 }),- V('pup_open_tab', 'Open a new tab in an EXISTING session/window (returns {tabId, tabCount, activeTabId} β no need to diff list_tabs). This is the right verb for multi-URL work: ONE window with many tabs (one taskbar button), not many windows. The window stays in the background; the tab auto-flashes the taskbar (debounced) so the user notices. The USER can click the window\'s taskbar button at any time to view it β pup never pulls it back down.', { sessionId: 'required str', url: 'optional str', updateNote: 'optional str β short "what I did" note surfaced in list_tabs as lastAgentUpdate', silent: 'optional bool β suppress the auto-flash for this call' }, 30, { sessionId: 'work', url: 'https://example.com' }),- V('pup_close_tab', 'Close a tab.', { sessionId: 'required str', tabId: 'optional str (default active)' }, 15, { sessionId: 'work' }),- V('pup_close_window', 'Close a window/session (its tabs go too).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),- V('pup_window_info', 'Everything about ONE pup window as JSON β owner AI thread, container, profile, tabs, age, last agent action, and the window\'s hwnd (for desktop_screenshot_window when you need the browser chrome in frame). For an AI caller this is a PURE DATA query: nothing is drawn on the user\'s screen. Visual surfaces are user-initiated only β a jump-list click shows the native taskbar caption, a toolbar-popup click shows the in-page floaty card. showCaption:true opts an AI caller into the caption ONLY when the user asked to have the window identified on screen. Read-only, never owner-gated.', { sessionId: 'optional str', showCaption: 'optional bool β draw the ~12s taskbar caption too; ONLY when the user asked to see which window this is' }, 15, {}),- V('pup_wiki_set_view', 'Flip a WIKI pup window between the logged-in and public view IN PLACE: every wiki tab reloads with the server-side ?view=public param appended (public) or stripped (authed) β adom/wiki#126 renders the request exactly as a signed-out visitor while the session stays untouched, so there is no relaunch, no cookie wipe, no profile switch (the legacy jar relaunch survives only as a fallback for servers that ignore the param). This is the callback for the per-window taskbar jump-list task (WINDOWS ONLY) β a USER-initiated action. AI threads should NOT call this; pass wikiView:"authed"|"public" on pup_open_window instead.', { sessionId: 'required str', view: 'required "authed"|"public"|"toggle" β toggle flips whatever the window currently shows (the Alt+Shift+V hotkey form)' }, 45, { sessionId: 'wiki-bmv080', view: 'authed' }),- V('pup_navigate', 'Navigate the active tab to a URL.', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://example.com' }),- V('pup_back', 'History back.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),- V('pup_forward', 'History forward.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),- V('pup_progress', 'Paint the Windows taskbar PROGRESS BAR on your window during a LONG multi-stage task (crawling a site, login->search->download loops, batch harvesting datasheets/STEP files) so the user sees work happening at a glance. mode:"indeterminate" (pulsing) to start, mode:"value" with value:0-100 as stages complete, mode:"off" when done or aborted (always clear!). Optional note:"12/40 datasheets" shows on the button hover tooltip. YOUR judgment: a single quick page open does NOT warrant a progress bar. See the pup-progress skill.', { mode: '"indeterminate" | "value" | "off"', value: 'optional 0-100 (mode:"value")', note: 'optional short stage label for the hover tooltip', sessionId: 'optional β defaults to your thread\'s own window' }, 8, {}),- V('pup_reload', 'Reload the active tab.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),- V('pup_click', 'Click an element (by selector or coords). Selector clicks LOCATE the element once then click by its center coordinates (fast on huge/animated DOMs, where page.click used to hit the 15s wall). Response includes method. If a React toggle/like/heart does NOT change state after a normal click, that control ignores trusted clicks β retry with domClick:true to fire a synthetic element.click().', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int', domClick: 'optional bool β dispatch a synthetic element.click() instead of a trusted CDP click; for React controls that ignore trusted clicks', clickCount: 'optional int 1-3', button: 'optional "left"|"right"|"middle"' }, 20, { sessionId: 'work', selector: 'button#go' }),- V('pup_assisted_click', 'RESPECTFUL real-cursor click for a surface pup CDP cannot drive (a NATIVE OS dialog, or a cross-origin FedCM "Sign in with Google" account chooser that rejects every in-page click). Do NOT hand-roll raise+desktop_click for these β use THIS. It: captions the reason + toasts BEFORE touching the screen; foregrounds; then WAITS, detecting if the user alt-tabbed away and holding (captioning "Adom is waiting β click back when ready") until the user is actually on the window; then runs the TIGHT real-cursor hover->click that FedCM accepts (interleaving any screenshot/eval between raise and click makes the click silently no-op); captions each step. Times out as user_not_ready clicking NOTHING if they never return. First take a desktop_screenshot_window of the session to read the target\'s WINDOW-pixel x,y.', { sessionId: 'required str', x: 'required int β WINDOW-image pixel of the target (from desktop_screenshot_window)', y: 'required int', reason: 'required str β WHY you must click (shown to the user as a caption)', label: 'required str β WHAT you are clicking (e.g. "your Google account")', maxWaitMs: 'optional int β how long to wait for the user (default 120000)' }, 130, { sessionId: 'adsk', x: 900, y: 379, reason: 'clicking your Google account to finish signing into Autodesk', label: 'your Google account' }),- V('pup_type', 'Type text into a field via real CDP keystrokes (isTrusted). Reads the value back and returns valueLanded β real keystrokes only register in a FOREGROUNDED window, so if valueLanded:false the field stayed empty (the silent no-op): use pup_fill (background-safe) for a non-React input, or pup_login {realKeystrokes:true} for a login form. Pass clear:true to REPLACE instead of appending.', { sessionId: 'required str', text: 'required str', selector: 'optional str', clear: 'optional bool β empty the field first (replace, not append)' }, 20, { sessionId: 'work', selector: '#user', text: 'adominc1', clear: true }),- V('pup_fill', 'Set a field\'s value in the BACKGROUND (no foreground, no keystrokes): writes .value via the native setter and fires input+change so React/Vue/Angular controlled inputs pick it up. The fix for pup_type\'s silent no-op on a backgrounded window. Reads the value back and returns valueLanded. For React-MASKED password fields that reject programmatic writes, use pup_login {realKeystrokes:true} instead.', { sessionId: 'required str', selector: 'required str', text: 'required str' }, 20, { sessionId: 'work', selector: '#email', text: '[email protected]' }),- V('pup_press_key', 'Press a key, optionally with modifier chords held (Ctrl+A, Ctrl+V, Shift+Tab).', { sessionId: 'required str', key: 'required str', modifiers: 'optional array β any of ["Control","Shift","Alt","Meta"] (aliases ctrl/cmd/command/option accepted); held down around the key press' }, 15, { sessionId: 'work', key: 'a', modifiers: ['Control'] }),- V('pup_NATIVE_INPUT_WARNING', 'β READ BEFORE USING AD\'s desktop_click / desktop_type / desktop_press_key ON A PUP WINDOW. Native OS input does NOT target a window β it goes to WHATEVER WINDOW HAS OS FOCUS at that instant. This user runs many AI threads and alt-tabs away from a foregrounded window almost immediately. If you fire native input without warning them, your keystrokes land in THEIR document (this happened: a username was typed into the user\'s own window). REQUIRED: notify_user toast + desktop_caption FIRST, tell them explicitly "don\'t type or click until I say done", wait until the window is genuinely foreground, re-verify immediately before EACH event, and caption when done. pup_assisted_click already does all of this β use it. And ALWAYS prefer CDP input (pup_click/pup_type/pup_hover): it needs no foreground and disturbs nobody. A native verb returning ok:true means the event was dispatched to the OS, NOT that it reached your window.', {}, 0, {}),- V('pup_set_viewport', 'Pin the page viewport (CDP device-metrics) to an exact size for deterministic screenshots β OR clear that pin. β A pin PERSISTS: it survives navigation and hard reload, and while set the window stops tracking OS resizing (a human resizing it sees no reflow, which looks exactly like an app layout bug). ALWAYS clear it when done. Reported as emulatedViewport in pup_status / pup_list_windows.', { sessionId: 'required str', width: 'int β required unless reset', height: 'int β required unless reset', deviceScaleFactor: 'optional number (default 1)', reset: 'optional bool β CLEAR the override and track the OS window again (width/height not needed)' }, 15, { sessionId: 'work', reset: true }),- V('pup_install_toolbar', 'Status/trigger for the toolbar extension, which AUTO-INSTALLS invisibly at every profile launch (transient pipe-launch + CDP Extensions.loadUnpacked; persists in the profile). Running profiles pick it up on their next launch; cold profiles install immediately.', { profile: 'optional, default adom-you' }, 30, {}),- V('pup_floaty_click', 'TEST SEAM: simulate a user click on the floaty pencil (fires the same host click handler a mouse does; the sweep then starts annotate within ~4s). Built for the recurring floaty ralph β assert the annotate overlay appears after calling this.', { sessionId: 'required window', tabId: 'optional tab' }, 10, {}),- V('pup_annotate', 'Start the HUMAN annotation overlay on a window\'s active tab (pen/arrow/box/text toolbar injected in-page; also on every window\'s taskbar jump list as "Annotate this page"). Two capture modes the user can also flip in the toolbar: "auto" (default) captures the instant the mouse lifts β the one-red-circle case; "multi" accumulates shapes (color swatches: red/blue/yellow one click, more in a flyout) and captures ONCE on Done. Done captures a PNG AND leaves the drawing in the DOM, so the owning thread\'s next pup_screenshot shows exactly what the user marked β check session.lastAnnotation via pup_list_windows. action:"cancel" removes the overlay.', { sessionId: 'required window', mode: 'optional "auto"|"multi"', action: 'optional "cancel"' }, 15, {}),- V('pup_dashboard', 'Open the LIVE pup management dashboard in its OWN window (its own profile, so its own taskbar icon). Shows every window pup is driving as taskbar button -> window(s) -> tabs with each tab favicon, owning AI thread, foreground/background + the reason given to foreground it, highFps lease, uptime, and WHY each overlay icon was chosen. Live over SSE. Reuses the one window on repeat calls; backgrounded by default like any pup window.', {}, 30, {}),- V('pup_deps', 'Report pup dependency health (wiki issue #15): depsPresent, selfInstalledThisBoot, lastError. Use this to answer "is pup broken?" without reading spawn logs.', { }, 10, {}),- V('pup_hover', 'Move the pointer over a target WITHOUT clicking (reveals tooltips / hover menus). Glides the visible Adom cursor there too.', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int', intent: 'optional str β narrated on the cursor label', steps: 'optional int (default 8)' }, 15, { sessionId: 'work', selector: '#menu' }),- V('pup_maximize', 'Maximize (or restore/fullscreen/minimize) a pup window via CDP β no AD desktop verb required. Does NOT foreground it.', { sessionId: 'required str', state: 'optional "maximized"|"normal"|"minimized"|"fullscreen" (default maximized)' }, 15, { sessionId: 'work' }),- V('pup_set_identity', 'Set the Adom identity shown in the floating owner chip on every pup window (avatar + name). Chrome\'s own profile chip cannot take a custom local image (Google sign-in only), so the user\'s Adom avatar lives here instead. Pass a pre-circle-cropped PNG as base64. Applies to live windows immediately + persists.', { avatarB64: 'optional str β base64 PNG (ideally ~48px, circle-cropped); the leading data: prefix is stripped if present', name: 'optional str β display name shown in the chip tooltip', enabled: 'optional bool β turn the whole owner chip on/off', clear: 'optional bool β remove the stored avatar + name' }, 15, { name: 'John Lauer', avatarB64: '<base64 png>' }),- V('pup_screenshot', 'Screenshot the page (PNG; fullPage optional). NOTE: fullPage only covers DOCUMENT-level scroll β on SPAs that scroll inside a nested container it returns just the viewport + a nestedScroll hint; use pup_screenshot_element or pup_scroll for those.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),- V('pup_screenshot_element', 'Screenshot ONE element, resolved by CSS selector OR visible text (pierces shadow DOM), scrolled into view first. The clean way to grab a single card/widget on a component-heavy page. Text matches the TIGHTEST text node β to capture the enclosing CARD, add closest (e.g. {text:"Downloadβ¦", closest:".download-card"}); with closest, an ambiguous phrase still lands on the real widget (skips prose that has no such ancestor). Returns base64 + filePath like pup_screenshot.', { sessionId: 'required str', selector: 'CSS selector (or use text)', text: 'visible text to match (or use selector)', closest: 'optional selector β climb to the nearest ancestor matching it (the card container)', padding: 'optional int px around the element', deep: 'optional bool β pierce shadow DOM (default true)' }, 30, { sessionId: 'work', selector: '.download-card' }),- V('pup_scroll', 'Scroll the ACTUAL scrolling element (nearest ancestor whose scrollHeight>clientHeight, NOT just window β window.scrollTo no-ops on nested-scroll SPAs). Scroll by delta {dy,dx} OR to an element {toSelector|toText} (pierces shadow DOM). Returns {scrollElement, scrollTop, scrollHeight, clientHeight} so you can confirm it landed.', { sessionId: 'required str', dy: 'optional int px (down+/up-)', dx: 'optional int px', toSelector: 'optional CSS selector to scroll to', toText: 'optional visible text to scroll to', block: 'optional start|center|end (for scroll-to; default center)', behavior: 'optional auto|smooth' }, 30, { sessionId: 'work', toText: 'Download' }),- V('pup_set_viewport', 'Set the page viewport (CDP device-metrics) deterministically, independent of the OS window size β fixes a too-small innerWidth so fullPage/element shots render at a known width. width+height required.', { sessionId: 'required str', width: 'required int px', height: 'required int px', deviceScaleFactor: 'optional number (default 1)' }, 15, { sessionId: 'work', width: 1280, height: 900 }),- V('pup_evaluate', 'Evaluate JS in the page and return the result β THE verb for reading DOM state / asserting on a page (same handler as pup_eval). Add frame:"<name|url-substring|index>" to run inside an iframe.', { sessionId: 'required str', expression: 'required str β aliases accepted: expr, script, js, code', frame: 'optional str β run inside this iframe instead of the top document' }, 30, { sessionId: 'work', expression: 'document.title' }),- V('pup_fetch_url', 'Fetch a URL\'s content via the browser (cookies/session intact).', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://api.example.com' }),- V('pup_download', 'Download a file THROUGH THE BROWSER, exactly as the user clicking it would β real session, real cookies, real anti-bot cookies β and Chrome saves it to the Downloads folder. This is the right verb for vendor files (datasheets, KiCad/Altium libraries, STEP 3D models) that a server-side fetch/curl can never get past the site\'s bot wall. Pass selector to click the actual download button (authentic), or url to force-download via an in-page a[download]. Confirms completion via Chrome\'s download events and returns the ACTUAL saved path + byte count + file magic. For a gated file, log in on the window first (pup_login).', { sessionId: 'optional str', selector: 'optional str β the real download control to click', url: 'optional str β download this URL via an in-page a[download] click', saveAs: 'optional str β filename override', saveDir: 'optional str β default the user\'s Downloads folder' }, 100, { sessionId: 'work', selector: 'button.download' }),- V('pup_input_dispatch', 'Low-level input (mouse/keyboard) at coords β beats some overlays.', { sessionId: 'required str', type: 'required str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', type: 'mousePressed', x: 100, y: 200 }),- V('pup_record_start', 'Start recording the page (video).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),- V('pup_record_stop', 'Stop recording; returns the saved file.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),- V('pup_record_status', 'Recording status for a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),- V('pup_record_list', 'List recordings.', {}, 10, {}),- V('pup_recorder_open', 'Open pup\'s Chrome screen-share recorder HUD (WebM; consent = the visible HUD). Canonical name (pup-bridge#94).', { reason: 'str (required)' }, 20, {}),- V('pup_screen_record_start', 'Start a whole-screen recording through Chrome screen-share (WebM VP9, system audio + mic). Canonical name; for a background no-browser MP4 of one window use ab-core desktop_record_window_start.', { reason: 'str (required)', confirmDesktopNotTabRecording: 'true', fps: 'int', audio: 'bool', maxDurationMs: 'int' }, 20, {}),- V('pup_screen_record_stop', 'Stop the Chrome screen recording; returns the .webm path.', {}, 30, {}),- V('pup_screen_record_status', 'Status of the Chrome screen recorder + any active clip.', {}, 10, {}),- V('pup_screen_record_list', 'List completed Chrome screen recordings on disk.', {}, 10, {}),- V('pup_recorder_close', 'Close the Chrome screen-recorder HUD (auto-stops a clip).', {}, 15, {}),- V('desktop_record_start', 'ALIAS of pup_screen_record_start (routes to ab-core once its native recorder lands, adom-bridge#143).', { outPath: 'optional str' }, 20, {}),- V('pup_readiness', 'Readiness + which browser pup will drive (installed Chrome/Edge, or cached Chrome for Testing) + the full candidate list and current default. READ-ONLY; never downloads.', {}, 10, {}),- V('pup_prewarm', 'Install Chrome for Testing WITHOUT opening a window, and pin it as the default. Use when you specifically want CfT rather than the machine\'s installed Chrome/Edge.', { wait: 'optional bool β block until installed', setDefault: 'optional bool (default true) β pin CfT as the default' }, 240, {}),- V('pup_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. With {browser:"chrome", install:true} it DOWNLOADS & INSTALLS real Google Chrome on an Edge-only box, then pins it β SILENT on a normal PC; on a locked-down box (VM/managed device) it needs a Windows approval, so the bridge pops a native "click YES on the UAC" notification and, if it expires, re-notifies with a one-tap "Approve now" (all autonomous β no AI turn). Poll pup_readiness.chromeStablePhase: installing β awaiting_uac β ready. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', install: 'optional bool β with browser:"chrome", install real Chrome if missing', elevate: 'optional "auto"(default)|true|false β auto: quiet install, prompt only if it needs admin; false: never prompt; true: go straight to the prompt', executablePath: 'optional β pin an exact browser binary' }, 240, {}),- V('pup_lower_os_window', 'Send a pup window to the BACKGROUND (behind the user\'s windows) without stealing focus β still fully drivable. The default for open; use this to re-hide a window you foregrounded.', { sessionId: 'optional str' }, 15, {}),- V('pup_raise_os_window', 'Bring a pup window to the FOREGROUND (user\'s screen). GATED (v1.8.70): REFUSED with foreground_reason_required unless you pass foregroundReason β a quote/paraphrase of what the user said that justifies it (min 10 chars; logged, recorded in lastAgentUpdate, auditable). Allowed ONLY when the user EXPLICITLY must SEE it (a demo, recording, "show me"). It STEALS focus β to just signal "I updated this window", use pup_alert_window (or the auto-flash), NOT raise. After the show-me: pup_lower_os_window to return it to the background. Alias: pup_focus_window. IMPORTANT: if you are foregrounding in order to CLICK a native dialog or a cross-origin FedCM/Google account chooser, do NOT raise-then-click yourself β use pup_assisted_click, which waits for the user to actually be on the window (alt-tab aware) and narrates via captions, so you never act while they are not ready.', { sessionId: 'optional str', foregroundReason: 'REQUIRED β why the user asked to see this window' }, 15, { foregroundReason: 'user said "show me the wiki page"' }),- V('pup_login', 'Fill (and optionally submit) an HTML login form in the CURRENT tab, in the BACKGROUND β no Chrome autofill dropdown, no foregrounding, React/Vue-safe. Creds from the keychain vault (matched on the tab URL; captured automatically as you log in, or stored via credential_set / save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched pages AUTO-FILL on load (_autoLogin on the response). SUBMIT IS REASON-GATED: submit:true requires submitReason AND makes you confirm the live domain (the refusal regurgitates the exact host so a lookalike/phishing site is caught before the secret is sent). pup_configure {autoLogin:"off"} disables auto-fill; {captureLogins:"off"} stops auto-capture.', { sessionId: 'optional str β omit to target the window your own thread owns', username: 'optional str', password: 'optional str β with username, fills these instead of the vault', submit: 'optional bool β submit after filling; REQUIRES submitReason', submitReason: 'required WITH submit:true β why you are confident the CURRENT domain is the correct, intended site to send these credentials to (min 10 chars; the refusal shows you the live host to confirm; logged, auditable)', save: 'optional bool β with explicit creds, store them (OS keychain) so next time is automatic', host: 'optional str β override the vault host key when saving', realKeystrokes: 'optional bool β type with REAL key events instead of a background .value-set. Needed for React-controlled MASKED fields (Gusto/Keycloak SSO) that ignore .value and would submit an empty password. It briefly FOREGROUNDS the window (real keystrokes only register on-screen). Use when a normal fill returned filledPassword:false / reactMaskedFieldSuspected, or when the login-recipe for the site says so.' }, 20, { sessionId: 'work', submit: true, submitReason: 'confirmed the tab is on the real vendor domain I intended to sign into' }),- V('pup_credentials', 'List saved logins (host + username only; passwords are in the OS keychain and NEVER returned). pup captures these automatically as you sign into sites and auto-fills them in the background on return visits.', {}, 10, {}),- V('pup_credential_reveal', 'Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden: from a cloud thread use pup_login instead, which signs the site in inside a pup window without the secret ever leaving the vault. The secret is returned ONCE and must never be logged or echoed.', { host: 'required str β the site, e.g. "github.com"', reason: 'required str (min 10 chars) β shown to the user on the consent toast; say what you will do with it' }, 130, { host: 'github.com', reason: 'signing into GitHub in my own browser to open a PR for the user' }),- V('pup_card_set', 'Store a payment card in the OS-keychain card vault under a nickname. Ask the USER for the details β never harvest them from a page. Validates Luhn + expiry + cvv. No verb ever returns the number or CVV afterward.', { nickname: 'required str, e.g. "adom-amex"', number: 'required str', expiry: 'required MM/YY', cvv: 'required 3-4 digits', name: 'optional cardholder name' }, 15, {}),- V('pup_cards', 'List saved payment cards: nickname, brand, last4, expiry, cardholder only. The number and CVV never leave the keychain.', {}, 10, {}),- V('pup_card_delete', 'Remove a saved card from the keychain + index by nickname.', { nickname: 'required str' }, 10, {}),- V('pup_fill_card', 'Fill a saved card into the checkout page of a pup window WITHOUT the digits ever reaching you: pup types them into the card fields (walking payment-processor iframes like Stripe) and returns booleans. EVERY fill is gated by a desktop approval toast naming merchant, amount and last4 β the user authorizes the specific charge in the moment. Pass merchant+amount so the toast is honest. Allowed from cloud threads (the human click is the gate). After ok:true, screenshot to verify, then submit.', { nickname: 'required str', merchant: 'recommended str β shown on the approval toast', amount: 'recommended str, e.g. "$350"', window: 'optional (defaults to your thread\'s window)' }, 130, { nickname: 'adom-amex', merchant: 'venturedallas.com', amount: '$350' }),- V('pup_import_browser_logins', 'Seed pup\'s vault from the user\'s OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY β the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. ["snapeda","digikey"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).', { browsers: 'optional str[] subset of ["chrome","edge","brave"] (default: all found)', hostFilter: 'optional str or str[] β only import logins whose host contains one of these (surgical import)' }, 190, { hostFilter: ['snapeda', 'ultralibrarian', 'digikey'] }),- V('pup_reap_profiles', 'Delete LEAKED isolated profile directories β the iso-* throwaway jars minted by isolated:true opens (and by wikiView:public before 2.0.132, when the public view moved to the server-side ?view=public param) whose window is long gone (they used to pile up, 100s per machine). Only removes an iso-* dir that no live session or session file owns, that no Chrome is holding, and that is >15 min old; durable jars (adom-you, named profiles) are NEVER touched. Runs automatically every 15 min too. dryRun:true previews the list without deleting.', { dryRun: 'optional bool β preview which iso-* profiles would be removed, delete nothing' }, 20, { dryRun: true }),- V('pup_move_to_workspace', 'MOVE this pup window\'s page into the user\'s Hydrogen workspace: the active tab\'s URL is handed to Hydrogen (ah) on this machine via its Browser Picker (webview by default, honoring the user\'s per-domain choice), then the pup window closes β its owner initiated the move. keepWindow:true keeps the pup window open too. Needs Hydrogen running locally (hydrogen_unreachable otherwise; the window is left untouched on failure). Cloud Hydrogen (hc) is not yet supported.', { sessionId: 'optional str', keepWindow: 'optional bool β hand the URL over but keep the pup window open' }, 25, {}),- V('pup_uninstall', 'DESTRUCTIVE β cleanly remove pup\'s WHOLE footprint from this machine: the HKCU AppUserModelId Adom.Pup registry keys, all staged extensions, ALL pup profiles + sessions, the OS-keychain credential vault, pup-settings.json, the badge-debug temp dir, and the jump-list launcher. ALWAYS preview first with dryRun:true (removes nothing, returns the plan). To actually remove, pass confirm:"uninstall". keepCredentials:true preserves the saved-login vault; keepProfiles:true preserves profiles/sessions. Chrome-for-Testing + firewall rules are SHARED and left alone unless removeChrome:true / removeFirewallRules:true. Closes every window + quits every browser first to release profile locks. (The bridge itself is managed by Adom Bridge β remove pup from Bridge to stop it re-seeding.)', { dryRun: 'optional bool β preview the removal plan without removing anything (do this first)', confirm: 'required str "uninstall" to actually remove', keepCredentials: 'optional bool β keep the saved-login vault', keepProfiles: 'optional bool β keep pup profiles + sessions', removeChrome: 'optional bool β also delete the Chrome-for-Testing install (shared; default keep)', removeFirewallRules: 'optional bool β also remove the CfT firewall rules (needs admin)' }, 60, { dryRun: true }),- V('pup_alert_window', 'Nudge the user about a pup window WITHOUT stealing focus β on Windows it flashes the taskbar button; on macOS (no taskbar, and no API to bounce another app\'s Dock tile) it unhides the window at its z-position and posts an AD toast whose click focuses it. Same contract on both, so call it identically; just never tell a Mac user to look for a flashing icon. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window β use this for an explicit end-of-task "it\'s ready" nudge on top of that. STRONGLY prefer this (or the auto-flash) over pup_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.', { sessionId: 'optional str', stop: 'optional bool β CLEAR the highlight instead of flashing' }, 15, {}),- V('pup_render_html', 'Render a local HTML string in the tab via page.setContent β UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.', { sessionId: 'required str', html: 'required HTML string', waitUntil: 'optional (domcontentloaded default)', updateNote: 'optional note for lastAgentUpdate', silent: 'optional bool β suppress the auto-flash' }, 30, { sessionId: 'work', html: '<h1>CafΓ©</h1>' }),- // Session/tab/window management + probes (were routed but undocumented).- V('pup_status', 'List all open pup sessions + their tabs (NOT a readiness check β use pup_readiness for that).', {}, 10, {}),- V('pup_list_windows', 'List every open session/window with its tabs.', {}, 10, {}),- V('pup_eval_frame_note', 'pup_eval accepts frame:"<name|url-substring|index>" to run inside an IFRAME (login forms usually live in one). Cross-origin frames work. Omit for the top document; a bad value returns frame_not_found WITH the list of frames in the tab.', { sessionId: 'required str', expr: 'required str', frame: 'optional str' }, 20, { sessionId: 'work', expr: 'document.querySelector("#userId").value', frame: 'logonbox' }),- V('pup_switch_window', 'Make a session the active one (for verbs that default to active).', { sessionId: 'required str' }, 10, { sessionId: 'work' }),- V('pup_list_tabs', 'List the tabs in a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),- V('pup_switch_tab', 'Activate a tab WITHIN its session/window (idempotent β safe to call when the tab is already active). Does NOT raise a background window to the user\'s screen: the tab becomes the window\'s visible tab wherever the window is, and the taskbar auto-flash tells the user something changed. Also self-heals the window (re-park if a bug ever stranded it off-screen) and refreshes the Adom taskbar badge β good citizenship for long-lived shared windows.', { sessionId: 'required str', tabId: 'required str', updateNote: 'optional str', silent: 'optional bool' }, 10, { sessionId: 'work', tabId: 'tab-2' }),- V('pup_screenshot_full_res', 'FULL-resolution page screenshot (NOT downscaled β for saving to disk, NOT for Read: it can exceed Claude\'s image limit). Use pup_screenshot for AI-readable shots.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),- V('pup_errors', 'Console errors + failed network requests for a session/tab (clears by default).', { sessionId: 'required str', tabId: 'optional str', clear: 'optional bool (default true)' }, 10, { sessionId: 'work' }),- V('pup_wait', 'Wait N ms (or for a selector) before the next step.', { sessionId: 'required str', ms: 'optional int', selector: 'optional str' }, 60, { sessionId: 'work', ms: 500 }),- V('pup_rescan', 'Recover orphaned sessions: reconnect dropped CDP sockets + rebuild sessions from window titles. Pass adoptOrphans:true to also adopt untagged tabs.', { adoptOrphans: 'optional bool' }, 30, {}),- V('pup_close', 'Close ALL pup sessions + windows at once (destructive). NOTE: `{}` here does NOT mean "the active session" the way it does on other session-scoped verbs β with no args this closes EVERY window, across every AI thread on the machine, with no confirmation. To close ONE window use pup_close_window {sessionId}. Pass {sessionId} here only if you genuinely mean that one session.', {}, 20, {}),- V('pup_configure', 'Get/set user-facing pup preferences (persisted across bridge updates). taskbarGrouping: "split" (default β one taskbar button PER WINDOW, each badged with its own tab count; best for telling AI threads apart) | "grouped" (all pup windows stack under ONE "Adom Pup" button, badge shows window count; tidy taskbar). Applies to live windows immediately. Ask the USER which they prefer before changing it β this is their taskbar. WINDOWS ONLY: macOS has no taskbar, so the setting is stored but INERT there; the reply carries platform + taskbarSupported so branch on those, not on this text.', { taskbarGrouping: 'optional "split"|"grouped"', taskbarActivity: 'optional "progress"|"flash"|"off" β how pup signals it is working on a window, on that window\'s taskbar button: a determinate progress bar sized to the operation\'s running-average time (default), the old orange flash, or nothing. Replaces the former openFlash/autoFlash/agentActivity toggles.', reportIssues: 'optional "on"|"off" β pup\'s bug-report invitation (_reportIssues), on by default', autoLogin: 'optional "on"|"off" β auto-fill a vault-matched login form on page load (fill only, never submits), on by default', overlaySize: 'optional "50"|"100" β how big the favicon badge is drawn on the taskbar button. "50" (the DEFAULT) draws it at half size so it stays clear of the thread tile\'s bottom line of text; "100" is the older, larger badge' }, 15, { taskbarGrouping: 'grouped' }),- V('pup_describe', 'This list β the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}),- ] });- return;- }- // --- Session management commands ---- case 'pup_open_window': {- const _openT0 = Date.now(); const _openId = 'open-' + _openT0.toString(36);- _inflightOpens.set(_openId, { t0: _openT0, sessionId: args && (args.window || args.sessionId) || null, thread: _callerThread || null }); // v2.0.383 (#59/#64)- // v1.9.195 (John: "you're supposed to pre-emptively catch stupid urls the ai tells you to- // open"). REFUSE a URL that CANNOT work, before spending a window on it. A container path- // handed to pup (file:///home/...) or a file:// path that does not exist on the desktop is- // a guaranteed ERR_FILE_NOT_FOUND β previously pup opened a window, rendered Chrome's error- // page, and said NOTHING, so the calling AI had to guess (and often misdiagnosed it).- {- const _pre = urlLint(args.url, res && res._callerContainer);- if (_pre && (_pre.code === 'url_file_container_path' || _pre.code === 'url_file_not_found')) {- console.log(`[urlLint] REFUSED open of "${args.url}" β ${_pre.code}`);- sendJSON(res, { success: false, errorCode: _pre.code, error: _pre.message, _urlLint: _pre.message, urlLintCode: _pre.code });- return;- }- }- // v1.9.147: sessionId is OPTIONAL when the caller identifies its thread β the thread gets- // its own keyed window (see the thread-default block below). Only the fully anonymous- // no-id no-thread case is refused, down there, with a teaching error.- if (!args.sessionId && !_callerThread) {- sendJSON(res, { success: false, errorCode: 'caller_identity_required',- error: 'No sessionId AND no caller identity. Either pass a sessionId, or (better) identify your thread β adom-bridge-cli --ai-thread "<your thread>" ... β and pup gives your thread its OWN window automatically: first open creates it, later opens become tabs in it, and no other thread can touch it.' });- return;- }- // ββ Cold-start gate (v1.9.90: NATIVE-FIRST) β READ-ONLY detect; never blocks when drivable ββ- // pup drives the user's INSTALLED browser by default β Chrome, else Edge β in a FRESH isolated- // profile with the noBrowserIdentityFlags that kill Chrome's sign-in / "set up a work profile"- // merge dialog at launch. That combination is exactly as clean as CfT (the Aditya incident was- // fixed by those flags, not by the binary β CfT shows the SAME dialog without them). So this- // gate only blocks when there is NOTHING drivable at all (no Chrome, no Edge, no cached CfT):- // then it fetches Chrome-for-Testing in the BACKGROUND as the last resort and returns the- // pollable {installing:true}. That case is rare (Edge ships on every Windows PC). Chrome for- // Testing is still available by explicit pin β pup_use {browser:"cft"} β for a- // managed/enterprise box whose policies interfere with automation.- // Existing/live sessions are exempt (their browser is already running).- {- const rd = chrome.readiness(); // detect-only; never downloads- const _def = (() => { try { return chrome.readDefault(); } catch (e) { return null; } })();- const _pinnedBranded = !!(_def && _def.kind && !/chrome-for-testing|cft/i.test(_def.kind));- const _cftCached = (() => { try { return chrome.detectChrome().installed; } catch (e) { return false; } })();- const _sessionAlive = sessions.has(args.sessionId) || !!(browsers.get(args.profile || args.sessionId) && browsers.get(args.profile || args.sessionId).browser);- // v1.9.77 β NEVER BLOCK ON A CfT DOWNLOAD WHEN A SYSTEM BROWSER EXISTS (wiki issue #15,- // AdityaAngajala's second report). This gate forced a ~191 MB Chrome-for-Testing download even- // though pup_readiness reported a healthy system Edge AND Chrome, so a machine with TWO- // perfectly good browsers could not open a window at all β and the download never finalised- // because its cache dir was half-populated. It also contradicted our own README: 'Chrome for- // Testing is auto-fetched only as a last resort when the box has no Chromium browser at all.'- // CfT is still PREFERRED (launchCandidates orders it first, and prewarm still fetches it in the- // background) β preference is fine, blocking is not. Only gate when there is genuinely nothing- // drivable on the machine.- // v1.9.90 NATIVE-FIRST: launchCandidates orders the installed browser FIRST, so this gate- // only fires when there is genuinely NOTHING drivable (no Chrome, no Edge, no cached CfT).- // When a system browser exists, pup drives it directly (fresh profile + identity flags).- const _hasSystemBrowser = (rd.candidates || []).some(c => c && c.source === 'system');- const _needsCft = !_pinnedBranded && !_cftCached && !_sessionAlive && !_hasSystemBrowser;- if (!rd.ready || _needsCft) {- if (rd.installPhase === 'failed') {- sendJSON(res, {- success: false,- errorCode: rd.lastErrorCode || 'chrome_install_failed',- error: `No Chrome/Edge installed and the Chrome-for-Testing fallback failed to install: ${rd.lastError}`,- readiness: rd,- _hint: 'Chrome for Testing (the last-resort browser for a box with no Chrome/Edge) failed to install β usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with pup_prewarm once the cause is fixed. If this box actually HAS Chrome or Edge, pin it: pup_use {browser:"chrome"|"edge"} β pup drives it cleanly (fresh isolated profile + identity-suppression flags, no merge dialog), no download needed.',- });- return;- }- // No Chrome/Edge AND no cached CfT. If the disk is too low to hold CfT,- // don't kick off a doomed 150 MB download β fail fast with a disk hint.- if (rd.lowDisk) {- sendJSON(res, {- success: false,- errorCode: 'chrome_install_no_disk',- readiness: rd,- error: `No Chrome/Edge on this machine and only ${rd.diskFreeMb} MB free β not enough to install Chrome for Testing (~800 MB needed).`,- _hint: `This box has no Chrome or Edge, and only ${rd.diskFreeMb} MB free disk β too low to download Chrome for Testing (~150 MB download, ~600 MB unpacked). Ask the user to free up disk space, then retry; OR install Edge/Chrome (Edge ships with Windows and needs almost nothing). Do NOT keep retrying the download β it will fail until disk is freed.`,- });- return;- }- // No native Chrome/Edge on this box, so fetch CfT in the background as the LAST-RESORT- // browser and tell the caller to poll+retry. (When a native browser exists we never reach- // here β native-first drives it directly with a fresh profile + identity-suppression flags.)- if (!rd.installing) chrome.ensureChromeReady({ background: true });- sendJSON(res, {- success: false,- errorCode: 'chrome_for_testing_installing',- installing: true,- statusVerb: 'pup_readiness',- readiness: rd,- error: 'This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).',- _hint: 'LAST-RESORT PROVISIONING (not an error): pup drives your INSTALLED Chrome or Edge by default, cleanly β a fresh isolated profile PLUS launch flags that suppress Chrome\'s sign-in / "set up a work profile" merge dialog, so there is no identity bleed. THIS box has neither Chrome nor Edge, so pup is fetching Chrome for Testing in the background as the only drivable option (~a minute or two, one time). Poll `pup_readiness` until {ready:true}, then re-issue pup_open_window. If the box actually DOES have Chrome/Edge, pin it with pup_use {browser:"chrome"|"edge"} to skip the download.',- });- return;- }- }- // pup is a generic, non-profile browser: each session is a fresh isolated- // profile (its own Chrome/Edge process), never the user's real signed-in- // browser. `profile` names that isolated user-data-dir; default it to the- // sessionId so a bare "open in pup" (just sessionId + url) works cleanly.- // (To drive the user's REAL Chrome/Edge, that's the Adom browser EXTENSION's- // nbrowser_* verbs β a separate bridge, not pup.)- // v1.8.96: wikiView:"authed" β ALWAYS the reserved vault profile. The 30-day SSO login- // lives ONLY in adom-wiki-authed, so authed access MUST use that jar regardless of any- // profile the thread passed (that's what makes ONE login serve EVERY thread). Each- // session still gets its OWN window; they share only the cookie jar (proven: two- // different threads, both authed, separate windows).- // v1.9.147 (John: 'the ai has to send its ai-thread name and therefore you can make 1- // window for that and tabs in that window, and one ai-thread won't pollute another's').- // sessionId is now OPTIONAL. With no sessionId:- // - the session keys on the CALLER THREAD ('ai-<thread-slug>') β the same thread always- // lands in ITS one window, a different thread in its own; pollution is impossible by- // construction (plus the 1.9.146 cross-owner guards for explicit ids).- // - if that thread-window already EXISTS, the url opens as a NEW TAB in it instead of- // a new window β one window per thread, tabs accumulate, exactly the model.- // - anonymous callers (no thread header AND no sessionId) are refused with a teaching- // error: identity is the price of the default window.- if (!args.sessionId) {- if (!_callerThread) {- sendJSON(res, { success: false, errorCode: 'caller_identity_required',- error: 'No sessionId AND no caller identity. Either pass a sessionId, or (better) identify your thread β adom-bridge-cli --ai-thread "<your thread>" ... β and pup gives your thread its OWN window automatically: first open creates it, later opens become tabs in it, and no other thread can touch it.' });- return;- }- args.sessionId = threadDefaultSessionId(_callerThread); // v2.0.28: shared keying helper- const _own = sessions.get(args.sessionId);- if (_own && isSessionAlive(_own) && args.url) {- try {- const tabId = await addTabToSession(args.sessionId, args.url);- recordAgentUpdate(_own, 'pup_open_window', args, tabId);- scheduleAutoFlash(_own, { silent: !!args.silent });- updateTabCountBadge(_own, args.sessionId).catch(() => {});- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, sessionId: args.sessionId, tabId, openedAsTab: true,- tabCount: (_own.tabs || []).length, activeTabId: _own.activeTabId,- }), _hint: `Opened as a NEW TAB in your thread's own window ("${args.sessionId}") β one window per thread, tabs accumulate there (pass an explicit sessionId if you truly need a separate window). Drive this tab via tabId ${tabId}.`, _next: threadNextBlock() });- return;- } catch (e) { /* fall through to a fresh window if the tab add failed */ }- }- }- // v1.9.162 (John: 'i want ALL pup windows to use the same profile now so you have all of- // the user's saved logins over time β the database builds up and this is pleasurable to- // use over time'). ONE durable jar (adom-you) for EVERYTHING, wiki AUTHED included, so- // every login the user does anywhere accumulates into one growing credential store. The- // ONLY windows that get a separate jar are the three that DEFINITIONALLY require it:- // {webSecurity:false} β insecure per-process flags must never touch the credentialed jar- // {isolated:true} β an explicit fresh/throwaway jar (login tests, act-as-another-user)- // wiki PUBLIC view β logged-OUT by definition, needs a clean jar to show the world's view- // Everything else, explicit profile aside, is DEFAULT_PROFILE. (wiki AUTHED no longer uses- // the old separate adom-wiki-authed jar β its login now lives in the one shared profile.)- if (args.webSecurity === false) args.isolated = true;- // v1.9.315 (John: "if the ai asks to open a wiki page, just always open it logged in by- // default, and hint that it can pass a param to open anonymously"). For an Adom wiki URL,- // DEFAULT to the logged-in (authed) view β it auto-signs from the AD desktop token with no- // user prompt β UNLESS the caller explicitly asked for the anonymous view (noAutoLogin:true,- // or wikiView:"public"). noAutoLogin is the plain-English opt-out John described.- if (isAdomUrl(args.url) && args.noAutoLogin) args.wikiView = 'public';- if (isAdomUrl(args.url) && !args.wikiView) { args.wikiView = 'authed'; args._wikiDefaultedAuthed = true; }- // v2.0.132 (adom/wiki#126 adopted β John: "implement that instead"): the public view is- // now a SERVER-RENDERED URL param, not an isolated anonymous jar. ?view=public makes the- // server render exactly what a signed-out visitor sees (session untouched, byte-identical,- // private pages 404), so the window stays on the shared adom-you profile β no throwaway- // iso-* profile is minted (which was also the main source of the leaked-profile pile).- // An explicit isolated:true still isolates (with its own reason) if a caller insists.- if (args.wikiView === 'public' && isAdomUrl(args.url) && args.isolated !== true) {- args.url = wikiPublicUrl(args.url);- }- if (args.isolated === true) {- // v2.0.101 (John: "ALWAYS open in adom-you for consistency; an alternate profile only- // when the AI specifically asks AND gives a reason why"). Isolation is REASON-GATED- // like foregrounding: the two definitional cases carry their own reason, an explicit- // isolated:true must state one or the open is refused with a teaching hint.- if (!args.isolationReason) {- if (args.webSecurity === false) args.isolationReason = 'webSecurity:false β insecure per-process flags must never touch the credentialed adom-you jar';- else if (args.wikiView === 'public') args.isolationReason = 'wiki public view β a genuinely logged-out jar showing exactly what the world sees';- }- if (!args.isolationReason || String(args.isolationReason).trim().length < 10) {- sendJSON(res, { success: false, error: 'isolation_reason_required', errorCode: 'isolation_reason_required', _hint: 'Every pup window rides the ONE shared adom-you profile by default (consistency + every login enriches the shared credential jar β the user wants this). isolated:true is the exception and needs a stated reason: re-call with isolationReason:"<why this window must NOT be adom-you>" (min 10 chars, e.g. fresh-login test, acting as a different user, untrusted site). If you do not truly need isolation, drop isolated:true.', _autoMode: 'Claude Code AUTO MODE note: this is pup\'s gate, not the harness classifier β re-calling with an isolationReason quoted from the user\'s own words is a structured verb call and passes Auto review cleanly. For the wiki public view you do not need isolation at all: wikiView:"public" renders anonymously in the shared window.' });- return;- }- console.log(`[profile] "${args.sessionId}" ISOLATED β reason: ${args.isolationReason}`);- args.profile = 'iso-' + args.sessionId; // isolation beats any explicit profile- } else {- // v1.9.346 (John: "steer everything to adom-you"). adom-you IS the AI doppelganger's identity- // β ONE shared credential jar. A thread passing its own named profile ("nameplates", "np-verify")- // fragments that: a separate login jar, none of the shared adom-you logins, and it wouldn't wear- // the adom-you frame color. So OUTSIDE genuine throwaway isolation (isolated:true / webSecurity:false- // / wikiView:"public" β all of which set isolated above), EVERY window goes on the shared adom-you,- // overriding any custom profile name. The override is surfaced (_profileSteered) so the caller sees- // pup redirected them; a thread that truly needs a private jar uses isolated:true.- if (args.profile && args.profile !== DEFAULT_PROFILE) {- args._profileSteered = { from: args.profile, to: DEFAULT_PROFILE };- console.log(`[profile] steered "${args.sessionId}" from custom profile "${args.profile}" β shared ${DEFAULT_PROFILE} (John: everything on adom-you unless isolated:true)`);- }- args.profile = DEFAULT_PROFILE;- }- const sessionId = args.sessionId;- // v1.9.110 (issue #22): NEVER LEAVE A SESSION UNOWNED, and settle ownership at ADMISSION.- // The owner is the explicit `owner` arg when given, else the calling AI thread's own name- // (AD hands us that via X-Adom-Caller-Thread), so "unowned" stops being reachable for any- // caller that identifies itself. The claim below is taken synchronously β no await between- // the check and the set β which is what closes the cold-start race where two agents were- // both admitted for the same sessionId.- {- // #338 + John (2026-08-05): THE AI-THREAD NAME IS THE OWNER β the absolute standard. A pup- // session is owned by the calling AI thread's NAME; the sessionId is NEVER an identity or a- // fallback (that stale stopgap is gone β John: "i'm sick of seeing sessionid, force ai-thread- // name now"). Resolve the thread from every form it can arrive in: AD's X-Adom-Caller-Thread- // header, args.caller.aiThread, or a top-level args.aiThread. An explicit `owner` arg is- // accepted only as a legacy alias. If a caller supplies NEITHER a thread NOR an owner, REFUSE- // with caller_identity_required β no synthesized, no unowned sessions, ever. (Internal pup- // opens call launchSession directly with their own owner, so they bypass this verb gate.)- const _aiThread = (_callerThread && String(_callerThread).trim())- || (args.caller && typeof args.caller === 'object' && args.caller.aiThread && String(args.caller.aiThread).trim())- || (typeof args.aiThread === 'string' && args.aiThread.trim() ? args.aiThread.trim() : null)- || null;- const _explicitOwner = (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;- // v2.0.115 (John: reopening other threads' windows stamped them as MINE). An EXPLICIT- // owner arg now WINS over the caller's ai-thread β it is the documented "your thread/task- // label" and the only way one thread can faithfully restore a window it is reopening ON- // BEHALF of another thread (e.g. a reload cycle). With no owner arg, the caller's ai-thread- // is the owner, exactly as before. Ownership here is attribution, not a security boundary,- // so honoring the caller's stated owner is correct.- const _wantOwner = _explicitOwner || _aiThread;- if (!_wantOwner) {- sendJSON(res, {- success: false,- errorCode: 'caller_identity_required',- error: `'pup_open_window' needs to know WHICH AI THREAD is opening this window. pup owns every session by ai-thread NAME now β the sessionId is not an identity.`,- supplyIdentity: {- bestWay: `adom-bridge-cli --ai-thread "<your conversation name>" pup_open_window '<json>'`,- orInArgs: { caller: { aiThread: '<your conversation name>', containerName: '<container>' } },- },- _hint: `Re-run with your ai-thread name so pup can own this window as YOU β e.g. adom-bridge-cli --ai-thread "chip-fetcher tab 3" pup_open_window '{"sessionId":"chip-fetcher-web","url":"..."}'. That NAME (not the sessionId) is the window's owner and what protects it from other threads; sessionId is just the window's key, never who owns it.`,- });- return;- }- const _held = _sessionOwnerClaims.get(sessionId) || null;- if (_held && _held !== _wantOwner && !args.takeover && !sessions.has(sessionId)) {- sendJSON(res, {- success: false,- errorCode: 'session_owned_by_another_thread',- error: `Session "${sessionId}" was claimed by thread "${_held}" moments ago and is still being provisioned.`,- sessionId, owner: _held, requestedBy: _wantOwner,- _hint: `STOP β thread "${_held}" claimed sessionId "${sessionId}" first and its window is still opening: this is a COLD-START RACE, not an available id. Do NOT retry. Open your OWN window under a task-prefixed sessionId (e.g. "${_wantOwner}-web"); it will be owned by "${_wantOwner}". Pass takeover:true only if the user EXPLICITLY told you to repurpose that thread's window.`,- });- return;- }- if (!_held) _sessionOwnerClaims.set(sessionId, _wantOwner);- // Stamp the resolved ai-thread as the owner for the rest of the pipeline (launchSession +- // pup_status). _wantOwner is always set here (we refused above if not).- args.owner = _wantOwner;- if (_aiThread && _wantOwner === _aiThread) args._ownerFromCallerIdentity = true;- }- // v1.6.3+: optional downloadPath override. Defaults to the user's- // home Downloads dir, which is what desktop_watch_files polls by- // default β the two ends meet without configuration. Caller can- // override per-session via this arg if they want downloads to- // land elsewhere (e.g. straight into a chip-fetcher staging dir).- const downloadPath = (args.downloadPath && typeof args.downloadPath === 'string')- ? args.downloadPath- : path.join(os.homedir(), 'Downloads');- // v1.8.70: THE FOREGROUND GATE (open-time half; mirrors abe's dual key). The flag- // alone is NOT enough β without foregroundReason the open still SUCCEEDS but the- // window stays BACKGROUND (abe semantics: withhold the foregrounding, not the work),- // and the response says exactly what was missing. A granted foreground is logged +- // recorded in lastAgentUpdate so the user can always audit who put a window on their- // screen and why. Also accepts abe's arg name userRequestedForeground.- // v1.8.92: app identity args β the driving app's icon/name for the window overlay.- const _appIconB64 = (typeof args.appIconB64 === 'string' && args.appIconB64.length > 50) ? args.appIconB64 : null;- const _appName = (typeof args.appName === 'string' && args.appName.trim()) ? args.appName.trim() : null;- const fgRequested = !!(args.foreground || args.raise || args.show || args.userRequestedForeground);- const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;- const fgGranted = fgRequested && !!fgReason;- const fgDenied = fgRequested && !fgReason;- if (fgGranted) {- console.log(`[foreground] "${sessionId}" open GRANTED β reason: ${fgReason}`);- // v2.0.261: exempt from the birth grace. The caller asked for a foreground window on- // the user's behalf, so this one is SUPPOSED to be in front β the grace exists only to- // stop Chrome's self-activation of an unasked-for window being read as user intent.- try { const _s = sessions.get(sessionId); if (_s) _s._raiseGranted = true; } catch (e) {}- }- // v2.0.165 THREAD TILES ROOT CAUSE: default the owner to the CALLING ai-thread when not- // explicitly declared, BEFORE launchSession β session.owner must exist at CREATE time so the- // FIRST identity stamp (the one that births the taskbar button) already computes the thread- // AUMID + name tile. Previously owner came only from args.owner, so the button was BORN under- // a per-session category appId, and the later thread re-stamp silently failed forever whenever- // the hwnd could not be re-resolved (title fallback is dead with titleTag off) β the taskbar- // kept the category icon and never grouped. User-surface callers (taskbar/toolbar) stay- // ownerless: a human click is not an AI thread.- if (!(typeof args.owner === 'string' && args.owner.trim()) && _callerThread && !USER_CALLERS.has(_callerThread)) {- args.owner = _callerThread; args._ownerFromCallerIdentity = true;- }- let session;- try {- // v1.9.143: same-profile opens are serialized (see _profileOpenChain).- const _chainKey = args.profile;- const _prev = _profileOpenChain.get(_chainKey) || Promise.resolve();- const _cur = _prev.then(() => launchSession(sessionId, args.url, {- freshProfile: !!args.freshProfile,- insecureDev: args.webSecurity === false,- profile: args.profile,- strictPermissions: !!args.strictPermissions,- downloadPath,- nativeSite: !!args.nativeSite,- highFps: !!args.highFps,- owner: (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null,- takeover: !!args.takeover,- // v1.8.24: background-by-default is enforced DURING launch (focus handed- // back to the user's window the instant pup's window appears) unless the- // caller explicitly wants it foreground (v1.8.70: AND supplied a reason).- foreground: fgGranted,- silent: !!args.silent || args.autoFlash === false,- }));- // Chain tail: never rejects (errors surface via _cur below), so one failed open- // can never wedge the queue for the profile.- _profileOpenChain.set(_chainKey, _cur.catch(() => {}));- session = await _cur;- } catch (e) {- if (e && e.code === 'session_owned_by_another_thread') {- const d = e.details || {};- sendJSON(res, {- success: false,- errorCode: 'session_owned_by_another_thread',- error: e.message,- ...d,- _hint: `STOP β do NOT take this window. Session "${d.sessionId}" was opened by another AI thread (owner "${d.owner}") and is currently showing ${d.currentUrl}. Navigating it away would destroy that thread's work. Do ONE of: (1) open your OWN window with a UNIQUE sessionId β prefix it with your task, e.g. "${d.requestedBy}-web" (RECOMMENDED); (2) if the user EXPLICITLY told you to repurpose this window, re-send with takeover:true to claim it; (3) pup_status first to see all windows + owners before picking a sessionId. Generic ids like "wiki"/"test"/"main" collide across threads β always task-prefix yours.`,- });- return;- }- throw e;- }- // One-shot reuse/steal report from launchSession (existing-session path).- const reuse = session && session._reuseMeta ? session._reuseMeta : null;- if (session) delete session._reuseMeta;- // Stash on the session so attachTab uses it for any subsequent- // tab open or popup auto-attach in this session.- if (session) session._downloadPath = downloadPath;- // #338: the owner came from the calling ai-thread (caller identity), not a self-asserted arg β- // mark it so pup_status/list_windows report ownerSource:"caller-identity" instead of "declared".- if (session && args._ownerFromCallerIdentity) session._ownerFromCallerIdentity = true;- // v1.9.102 β STAMP THE OWNING THREAD *HERE*, not at dispatch. The pre-dispatch stamp can- // only find a session that already exists, and on an OPEN the session is created INSIDE this- // handler β so the single most important window (the one this thread just created) got no- // identity and the hover tooltip read "(no AI-thread identity yet)". _callerThread is in- // scope for the whole dispatch, so stamp as soon as the session is real.- if (session && _callerThread) {- // v2.0.115: on an OPEN with an explicit owner (args.owner is the resolved _wantOwner- // there), the OWNER thread β not the caller β is who owns the window, so zero-id- // targeting (callerOwnWindow keys on _ownerThread) resolves for the right thread after- // a faithful reopen. Every other path still stamps the calling thread.- if (!session._ownerThread) { session._ownerThread = (command === 'pup_open_window' && args.owner) ? args.owner : _callerThread; session._ownerContainer = _callerContainer; }- session._lastThread = _callerThread;- session._lastContainer = _callerContainer;- if (_callerReason) session._openReason = String(_callerReason).slice(0, 300);- try { if (_callerContainer) session._callerContainer = _callerContainer; } catch (e) {}- try { if (args && args.url && !session._intendedUrl) session._intendedUrl = String(args.url); } catch (e) {}- console.log(`[caller] "${sessionId}" owned by AI thread "${_callerThread}"${_callerContainer ? ` (${_callerContainer})` : ''}${_callerReason ? ` β reason: ${String(_callerReason).slice(0, 90)}` : ''}`);- // v1.9.103: put the thread FIRST in the window title, which is the text Windows draws above- // the taskbar hover-preview. Kept SHORT (18 chars) on purpose: that header truncates around- // 25-30 chars, so a long tag would push out the page title AND get cut itself. This is the- // one surface that answers "which of my 20 threads made this window" at a glance.- try {- const tag = String(_callerThread).trim().slice(0, 18);- if (tag) { session._threadTag = tag; await setTitleDecor(session.page, { threadTag: tag }); }- } catch (e) {}- }- if (session && _appIconB64) { session._appIconB64 = _appIconB64; session._appIconMime = 'image/png'; }- if (session && _appName) session._appName = _appName;- // v1.8.50: the background watchdog runs concurrently with navigation. Await its- // SELF-VERIFIED verdict (bounded) so the response's `background` field is the real- // outcome ('backgrounded'/'still-foreground'), never a guess mid-park.- try {- const bp = session && browsers.get(session.profileName)?.browser?._bgPromise;- if (bp) await Promise.race([bp, new Promise(r => setTimeout(r, 7000))]);- } catch (e) {}- // v1.8.92: wear the driving app's favicon in the overlay (page-served or explicit).- let _appIconWorn = false;- if (session && process.platform === 'win32') {- try { _appIconWorn = await Promise.race([applyAppOverlay(session, sessionId), new Promise(r => setTimeout(() => r(false), 4000))]); } catch (e) {}- // v1.8.99: run the favicon-vs-count decider (a window may open with multiple tabs).- try { updateTabCountBadge(session, sessionId).catch(() => {}); } catch (e) {}- }- const appIconHint = appIconReprimand(args.url, _appIconWorn);- // v1.8.93: wiki dual-view hint + auth-state check.- // v2.0.29 (#42, John saw one thread's fresh window render signed-OUT while its siblings on- // the SAME shared profile rendered signed-in): the shared adom-you profile is SUPPOSED to be- // signed in as the user. Check auth on EVERY adom URL that is not the deliberately-anonymous- // public/isolated view β not just explicit wikiView:"authed" β so the omitted-view case- // (the common one) is covered too.- let _wikiAuth = null;- const _wantsSignedIn = session && isAdomUrl(args.url) && args.wikiView !== 'public' && !args.isolated;- if (_wantsSignedIn) {- try { _wikiAuth = await Promise.race([checkWikiAuth(session.page), new Promise(r => setTimeout(() => r({ authed: false, unknown: true }), 3500))]); } catch (e) {}- }- // v1.9.88/v2.0.29 β DEFINITELY signed out (a real 401, not a probe timeout) but the desktop- // holds a wiki token: AUTO-SIGN from it BEFORE the first visible paint settles, so the window- // never shows a signed-out frame (which is also what the taskbar hover thumbnail would cache).- if (_wantsSignedIn && _wikiAuth && _wikiAuth.authed === false && !_wikiAuth.unknown && readDesktopWikiToken()) {- try {- const _np = (() => { try { const u2 = new URL(args.url); return u2.pathname + u2.search; } catch (e) { return '/adom'; } })();- const _who = await autoSignWikiFromDesktop(session.page, _np);- if (_who) {- _wikiAuth = { authed: true, user: _who !== 'authed' ? _who : undefined, autoSigned: true };- session._wikiAuthed = true;- console.log(`[wikiview] "${sessionId}" ${args.wikiView || 'shared'} AUTO-SIGNED from AD desktop token (${_who}) β was signed out at first paint (#42)`);- // repaint so the taskbar thumbnail drops any cached signed-out frame- if (process.platform === 'win32') { try { scheduleOverlayRefresh(sessionId); } catch (e) {} }- }- } catch (e) {}- }- // v1.9.175: pass args.wikiView VERBATIM so the hint distinguishes omit (shared, logged in)- // from explicit wikiView:"public" (isolated, logged out) β collapsing them misreported the- // shared logged-in view as "the logged-out public view".- const wikiHint = wikiViewHint(args.url, args.wikiView, _wikiAuth);- // v1.8.97: gentle in-tab glyph β π authed (logged in), π public.- if (session && isAdomUrl(args.url)) {- const label = (args.wikiView === 'authed' && _wikiAuth && _wikiAuth.authed) ? '\u25CF Logged in' : '\u25CB Public';- try { await setWikiModeGlyph(session.page, label); } catch (e) {}- }- // v1.9.106: LOOK at what we just opened rather than only advising the caller to look. A- // 404 / blank / unstyled / crashed page becomes a reported FACT (renderCheck + a failure-first- // _verifyRender) instead of a cheerful ok:true the caller can misread as success.- if (session && args.url) {- try {- let _hs = null;- try { _hs = session.page && session.page.__pupHttpStatus; } catch (e) {}- res._renderCheck = await probeRender(session.page, _hs);- try { await autoLoginOnLoad(res, session.page); } catch (e) {}- const _rc = res._renderCheck;- if (_rc && _rc.ok === false) console.log(`[render] "${sessionId}" LOOKS BROKEN: ${_rc.signals.join(', ')}`);- } catch (e) {}- }- const info = await getSessionInfo(sessionId, session);- // v1.8.0: BACKGROUND BY DEFAULT. pup is the AI's workspace, not the user's β- // a window that pops to the foreground and covers what the user is doing is- // disruptive. v1.8.17: background is now the HARD default β a window is- // foregrounded ONLY when the caller EXPLICITLY asks (foreground/raise/show:true,- // e.g. the user said "let me watch" / a recording). Sizing/positioning NO LONGER- // foregrounds: a sized window is placed via CDP (works fine while occluded) and- // STILL pushed to the back, so screenshot-framing never disturbs the user. The- // window stays fully drivable + screenshottable in the background (occluded, not- // minimized). Order matters: size FIRST, then send to back, so the final z-order- // is background even after the bounds call.- const w = Number(args.width), h = Number(args.height), wx = Number(args.x), wy = Number(args.y);- const sized = Number.isFinite(w) && Number.isFinite(h);- const foreground = fgGranted; // v1.8.70: the gate's verdict, not the raw flag- if (sized) {- try { await setSessionWindowBounds(session, { x: wx, y: wy, width: w, height: h }); } catch (e) {}- // v2.0.367 (John: "it keeps happening to all ai threads... you're doing something to- // fuck up the browser client area"). THIS LINE WAS THE CAUSE, and it is why the problem- // followed every thread rather than one.- //- // 1.8.21 pinned the page viewport to the requested width/height on EVERY sized open, to- // fix a real bug (DPI/window-state quirks left innerWidth at ~170px on some laptops).- // But a CDP device-metrics override is PERMANENT for the life of the window, so from- // that moment the page stopped tracking its own window. Maximise it, or let the user- // resize it, and the page keeps rendering at the original width inside a much larger- // frame: content stops short and the rest is dead space. Three different threads' windows- // showed exactly that today.- //- // It was also INVISIBLE, because this path never recorded _emulatedViewport. So pup's- // listings said "no override", every warning I added keyed off that record and stayed- // silent, and the owning AI measured innerWidth, saw the page filling it, and honestly- // reported "100% width" while the human looked at a gap.- //- // The fix keeps the original repair and drops the permanent cost: size the OS window,- // MEASURE what the page actually got, and only pin if the page failed to follow (the- // ~170px case). A window that tracks its own bounds is left alone to keep tracking them.- try {- const _got = await session.page.evaluate(() => ({ iw: window.innerWidth, ih: window.innerHeight }));- const _wantW = Math.round(w);- const _followed = _got && _got.iw > 200 && Math.abs(_got.iw - _wantW) <= Math.max(40, _wantW * 0.15);- if (!_followed) {- await session.page.setViewport({ width: _wantW, height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1 });- session._emulatedViewport = { width: _wantW, height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1, reason: 'window did not follow its bounds' };- session._lastOuterWidth = _got && _got.iw ? null : null;- console.log(`[viewport] "${sessionId}": window did not follow its bounds (page reported ${_got ? _got.iw : '?'}px for a ${_wantW}px window) β PINNED the viewport as a repair, and recorded it so it is disclosed`);- }- } catch (e) {}- }- // v1.8.69: re-assert background after an explicit sizing call β Z-ORDER ONLY.- // The old call here was the legacy osBackgroundWindowByPid, whose PS park MOVES the- // window to 0,0 sized-to-monitor β silently clobbering the exact x/y/w/h the caller- // just asked for (every sized open snapped back to the primary monitor's origin,- // which broke placing windows on a second monitor). AD state:'bottom' with- // force:false touches nothing but z, and respects a user-held window.- if (!foreground && sized) {- try {- await chrome.abCommand('desktop_set_window_state', { ...(await windowTarget(sessions.get(sessionId), sessionId)), state: 'bottom', force: false }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});- } catch (e) {}- }- // v1.8.26: record whether this window is meant to be on screen β gates every- // later bringToFront (navigate/reload/switch_tab must not raise a bg window).- if (session) session._foreground = foreground;- // v1.9.3: remember this window's wiki view so the taskbar jump-list toggle (and any- // later relaunch) knows which cookie jar it's on. Record the "public" profile too so- // toggling back from authed restores the original jar, not just the sessionId default.- if (session && isAdomUrl(args.url)) {- session._wikiView = (args.wikiView === 'authed') ? 'authed' : 'public';- if (session._wikiView === 'public') session._publicProfile = session.profileName;- // v1.9.12: seed the icon state so the FIRST brand already carries the right glyph.- session._wikiAuthed = !!(_wikiAuth && _wikiAuth.authed);- }- if (session && fgGranted) {- session._lastForeground = { ts: Date.now(), verb: 'pup_open_window', reason: fgReason };- toastForeground(sessionId, fgReason).catch(() => {}); // v1.9.166- try { recordAgentUpdate(session, 'pup_open_window', { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}- }- // v1.8.23: session-level auto-flash default (autoFlash:false β this window never- // auto-flashes, e.g. a chatty automation loop the user isn't watching).- if (session && args.autoFlash === false) session._flashSilent = true;- // Flash on open (a NEW window appearing IS the update) β unless foregrounded- // (already on screen) or suppressed. Also stamp the first agent update.- if (session) {- recordAgentUpdate(session, 'pup_open_window', args, session.activeTabId);- // v1.9.140: the launch path already flashed this open β consume the marker and skip.- if (!foreground && !_openFlashDone.delete(sessionId)) scheduleAutoFlash(session, { silent: !!args.silent });- }- // v1.8.53: badge timing moved into the park (launchSession) β it fires the moment the- // park verdict lands instead of blind 1.5s/3.5s timers. This is only a fallback for- // the REUSED-window path (no park runs there, but the badge may have been cleared).- if (process.platform === 'win32') {- setTimeout(() => { brandPupWindow(sessionId).catch(() => {}); }, 300);- // v1.9.3: attach/clear the per-window wiki view-toggle jump list once the window's- // identity is stamped (registers the per-session AUMID if needed).- setTimeout(() => { updateWikiJumplist(session, sessionId).catch(() => {}); }, 900);- // v1.9.18: REPAINT the overlay after session._wikiAuthed is set. The first- // applyAppOverlay above runs BEFORE the wiki auth result is recorded a few lines- // down, so it always composited the favicon on the logged-out (dark) plate even for- // an authed window. This second pass paints the correct plate colour.- setTimeout(() => { updateTabCountBadge(session, sessionId).catch(() => {}); }, 1200);- }- // Verbose browser report: which browser we actually drove, what else is available.- const brep = browserPickReport();- const fgDenyHint = fgDenied ? ' β FOREGROUND DENIED: you passed foreground:true WITHOUT a foregroundReason, so the window opened in the BACKGROUND (the open itself succeeded). Foregrounding steals the user\'s screen+keyboard and is allowed ONLY when the USER explicitly asked to SEE this window. If they did: re-call with foreground:true + foregroundReason:"<quote/paraphrase what the user said>" (min 10 chars β it is recorded and auditable). If they did not: leave it background; pup_alert_window is the correct "it\'s ready" signal.' : '';- const bgHint = foreground- ? 'Opened in the FOREGROUND as EXPLICITLY requested (foreground/raise/show:true). Return it to the background with pup_lower_os_window once the user is done watching.'- : `Opened in the BACKGROUND (behind the user's windows, did NOT steal focus)${sized ? ' β and placed at the requested size/position WITHOUT foregrounding, so framing it for a screenshot never disturbs the user' : ''}. This is the HARD default so pup never disrupts the user's main work. It is FULLY drivable here: pup_screenshot / pup_eval / pup_navigate all work while it stays hidden. ONLY bring it to the user's screen if they EXPLICITLY want to WATCH (a demo, a recording, a "show me") β pass foreground:true on open (or call pup_raise_os_window). Sizing alone does NOT foreground anymore. LONG MULTI-STAGE TASK in this window (crawling a site, login->search->download loops, batch harvesting)? Paint the taskbar progress bar so the user sees the work: pup_progress {mode:\"indeterminate\", note:\"crawling ti.com\"} to start, {mode:\"value\", value:40, note:\"12/40 datasheets\"} as stages advance, {mode:\"off\"} when done. A single quick page open does NOT need it β your call. Full guidance: the pup-progress skill.`;- // v1.8.16: ownership/reuse verdict β reprimand grabs, bless refreshes.- let ownHint = '';- if (reuse && reuse.navigatedAway) {- if (reuse.takeover) {- ownHint = ` TAKEOVER: you claimed session "${sessionId}" from thread "${reuse.previousOwner}" (was showing ${reuse.previousUrl}) via takeover:true β you now own it. Only do this when the user explicitly asked.`;- } else if (reuse.anonymousGrab) {- ownHint = ` β YOU JUST TOOK OVER AN EXISTING WINDOW: session "${sessionId}" belonged to thread "${reuse.previousOwner}" and was showing ${reuse.previousUrl} β you navigated it away WITHOUT declaring an owner. If that window wasn't yours, you broke another AI thread's work: re-open ITS url (${reuse.previousUrl}) on this sessionId, then use your OWN unique task-prefixed sessionId. ALWAYS pass owner:"<your-task-name>" on pup_open_window so pup can protect windows across threads.`;- } else if (reuse.sameOwner) {- ownHint = ` Reused your own window (owner match) β navigated ${reuse.previousUrl} β new url.`;- } else {- ownHint = ` NOTE: reused EXISTING session "${sessionId}" (was showing ${reuse.previousUrl}, no owner declared by either side). If that window belonged to another AI thread, you just stole it β prefer a unique task-prefixed sessionId for new work, and pass owner:"<your-task-name>" so pup can enforce ownership.`;- }- } else if (reuse && reuse.reused) {- ownHint = ' Reused the existing window (same/no url β benign refresh).';- } else if (!args.owner) {- // v1.8.57: teach anonymous CREATORS too (a long-lived app like adom-shotlog that- // never declares owner gets zero cross-thread protection β its window can be- // navigated away or closed by any other AI thread without pup refusing).- ownHint = ` TIP: you created this window WITHOUT owner β pass owner:"<your-app-or-task>" on pup_open_window so pup REFUSES other threads' attempts to navigate/close it (session_owned_by_another_thread) instead of letting them take it. Especially important for a long-lived shared window.`;- }- // v1.9.76 β HIGH-FPS / POWER hint on EVERY open. An AI cannot discover this- // capability otherwise, and the wrong choice stays invisible until a video comes- // out at 1-2 fps or a user's battery is flat overnight (issue #14).- const hf = highFpsInfo(sessionId);- const fpsHint = hf- ? ` HIGH-FPS WINDOW, LEASED ${hf.minutes} MIN (expires ${hf.expiresAt}). Chrome's four power-saving mechanisms are DISABLED here, so it renders at FULL RATE even while occluded β that is what makes a background video recording usable. It also means it never idles down, so it is LEASED: when the lease lapses pup DOWNGRADES it to normal power by relaunching the same session with the same tabs (it is NOT closed β your content survives). YOU CANNOT BE CUT OFF MID-RECORDING: while a recording is actually running the lease cannot lapse. If you know you need longer WITHOUT recording the whole time, negotiate now: pup_highfps_extend {sessionId:"${sessionId}", minutes:N} (max ${HIGHFPS_MAX_MINUTES}). pup warns ~2 min before it downgrades.`- : ` POWER: NORMAL window on Chrome's own power behaviour β it throttles when occluded, so it costs nothing sitting in the background. Right default for driving, scraping and screenshots. If you need to RECORD VIDEO of this window while it is occluded, a normal window only yields ~1-2 fps: reopen with pup_open_window {highFps:true} for full-rate background frames. highFps is leased (default ${HIGHFPS_DEFAULT_MINUTES} min) and auto-downgrades when idle, so it can never flatten the user's battery.`;- sendJSON(res, {- success: true,- output: JSON.stringify({- ok: true,- highFps: hf || false,- defaultPermissionsApplied: !args.strictPermissions,- // v1.9.109 (John: "you can trim the bloat if you think that just causes the ai to ignore- // stuff") β this emitted all ~32 permission strings on EVERY open. The caller never acts on- // them, and they were padding the response that a 404 verdict had to compete with. Ordering- // by consequence means boilerplate collapses to a count + a pointer.- defaultPermissionsCount: args.strictPermissions ? 0 : PERMISSIVE_DEFAULT_PERMISSIONS.length,- defaultPermissionsNote: args.strictPermissions- ? 'strictPermissions: nothing pre-granted'- : 'full browser permissions pre-granted (clipboard, camera, geolocation, window-management, ...) β pup_describe has the exact list',- downloadsEnabled: !args.strictPermissions,- downloadPath,- // v1.4.0: which browser pup drove + the full fallback picture.- browser: brep.browser,- // v2.0.378 (#81 ask 2): say it in the open response when a window CANNOT carry the toolbar.- _toolbarNotice: (brep.browser && brep.browser.kind && brep.browser.kind !== 'chrome-for-testing' && pupSettings().toolbarExtension !== 'off')- ? `This window launched on ${brep.browser.kind}: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing, so it has NO toolbar. pup_readiness shows why that browser was picked; pup_use {browser:"auto"} plus a browser relaunch converges to CfT.`- : undefined,- // Same treatment: the full browser inventory is a one-time discovery fact, not something a- // caller re-reads on every open. The chosen browser is already reported in `browser`.- availableBrowsers: (() => {- try { return (brep.availableBrowsers || []).map(b => `${b.kind}(${b.source})`); } catch (e) { return brep.availableBrowsers; }- })(),- defaultBrowser: brep.defaultBrowser,- // v1.8.0: window placement β background unless explicitly foregrounded.- placement: foreground ? 'foreground' : 'background',- // v2.0.383 (#63): the canonical SDK key a caller branches on; `placement` stays as the legacy alias.- windowEtiquette: foreground ? 'foreground' : 'background',- // v2.0.383 (#59): the terminal progress frame (actuals; startKind cold|warm; estimate from this machine's history).- progress: (() => { _inflightOpens.delete(_openId); try { return openProgressFrame(_openT0); } catch (e) { return undefined; } })(),- // v1.8.2: window size (if width/height were given). null = default maximized.- size: sized ? { width: Math.round(w), height: Math.round(h), x: Number.isFinite(wx) ? Math.round(wx) : null, y: Number.isFinite(wy) ? Math.round(wy) : null } : null,- // v1.8.16: reuse/ownership verdict (null = fresh window).- reused: reuse ? { navigatedAway: reuse.navigatedAway, previousUrl: reuse.previousUrl, previousOwner: reuse.previousOwner, takeover: reuse.takeover } : null,- owner: session.owner || null,- appIconWorn: _appIconWorn || undefined,- wikiView: isAdomUrl(args.url) ? (args.wikiView === 'authed' ? 'authed' : 'public') : undefined,- wikiLoginNeeded: (args.wikiView === 'authed' && _wikiAuth && !_wikiAuth.authed) || undefined,- foregroundDenied: fgDenied ? 'foreground_reason_required' : undefined,- _next: threadNextBlock(),- // v1.9.108 (John, looking at a real launcher-mockup response) β A DETECTED RENDER- // FAILURE MUST LEAD. The verdict was correct and the calling AI did act on it, but _hint- // opened with a FAVICON nag, then a power lecture, then a browser inventory, so the single- // most important fact ("your page is a 404") sat three screens down behind 32 permission- // strings. Rich hints are good; burying the critical one behind cosmetic ones is not.- // When the page is broken, say that FIRST and defer the cosmetics to the same response's- // other fields (they are still returned, just not in front of a failure).- _hint: (() => {- const rc = (res && res._renderCheck) || null;- const broken = rc && rc.ok === false && (rc.signals || []).length;- const steerNote = args._profileSteered- ? ` NOTE: you asked for profile "${args._profileSteered.from}", but pup put this window on the shared "${DEFAULT_PROFILE}" instead β every pup window shares ONE identity (the adom-you doppelganger) so logins accumulate in one jar and every window wears the adom-you look. If you genuinely need a private, throwaway jar (a fresh-login test, or acting as a different user), pass {isolated:true} β a named {profile:"..."} no longer makes a separate durable jar.`- : '';- const profileNote = args.profile === DEFAULT_PROFILE- ? `${steerNote} PROFILE: this window uses pup's durable shared "${DEFAULT_PROFILE}" profile β logins, cookies and localStorage are SAVED and SHARED across ALL pup windows, so a site you sign into once stays signed in everywhere and app state (localStorage) persists between windows. This is deliberate. For a PRIVATE throwaway window instead, reopen with {isolated:true}.`- : ((args.wikiView === 'authed') ? '' : ` PROFILE: "${args.profile}" β an isolated throwaway jar (isolated:true).`);- const rest = `${wikiHint}${authSteerHint(args.url)}${fgDenyHint}${appIconHint}${bgHint}${profileNote}${ownHint}${fpsHint} ${brep._hint}${staleWindowsHint()}${adUpgradeHint()}`;- if (!broken) return rest;- const st = (typeof rc.httpStatus === 'number') ? ` (HTTP ${rc.httpStatus})` : '';- return `β THE PAGE YOU JUST OPENED IS BROKEN: ${rc.signals.join(', ')}${st}`- + `${rc.excerpt ? ` β it says "${String(rc.excerpt).replace(/\s+/g, ' ').slice(0, 120)}"` : ''}. `- + `Do NOT report success. Screenshot it (pup_screenshot {"sessionId":"${sessionId}"}), fix the cause, pup_reload, re-check. `- + `See _verifyRender for the full checklist. Everything below is secondary: ${rest}`;- })(),- ...info,- }),- });- return;- }- case 'pup_switch_window': {- const sessionId = args.sessionId;- if (!sessionId || !sessions.has(sessionId)) {- const available = [...sessions.keys()];- sendJSON(res, { success: false, error: `Session "${sessionId}" not found. Available: ${available.join(', ')}` });- return;- }- activeSessionId = sessionId;- const session = sessions.get(sessionId);- const info = await getSessionInfo(sessionId, session);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...info }) });- return;- }- case 'pup_list_windows': {- // v1.9.172 (jam fix): gather sessions IN PARALLEL, each with a hard per-session timeout that- // falls back to a pure in-memory snapshot. Previously this awaited getSessionInfo() SERIALLY,- // and getSessionInfo did uncapped CDP reads (title() + a focus eval) per window β so ONE busy- // window (Chrome under load on a heavy SPA) hung the whole handler and the CLI returned null,- // i.e. "pup looks frozen". Now the list ALWAYS returns within ~2.5s with every window present;- // a window too busy to answer live is marked degraded with its last-known state, never dropped.- const entries = [...sessions];- const list = await Promise.all(entries.map(([id, s]) =>- withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s))- ));- const degradedCount = list.filter(x => x && x.degraded).length;- const out = { sessions: list, count: list.length, activeSession: activeSessionId };- if (degradedCount) {- out._degraded = `${degradedCount} of ${list.length} window(s) were busy (Chrome under heavy load) so pup returned their last-known state rather than stalling. pup is NOT frozen and the windows are NOT crashed β they will report live values again shortly.`;- }- sendJSON(res, { success: true, output: JSON.stringify(out) });- return;- }- case 'pup_configure': {- // v2.0.179 (John: "Tile Icons are an ALTERNATE to the AUMID icons which are an alternate- // to plain CfT icons"): taskbarMode is the one-key ladder β 'tiles' (thread name tiles,- // the default), 'aumid' (per-window/category icons), 'plain' (no branding, stock Chrome- // button). It maps onto the two underlying keys so old callers keep working; overlays- // stay their own orthogonal overlayBadges toggle.- const tm = args.taskbarMode;- if (tm !== undefined && !['tiles', 'aumid', 'plain'].includes(tm)) {- sendJSON(res, { success: false, error: `taskbarMode must be one of tiles|aumid|plain` });- return;- }- if (tm) {- const pair = tm === 'tiles' ? { aumidIcons: 'on', threadTiles: 'on' }- : tm === 'aumid' ? { aumidIcons: 'on', threadTiles: 'off' }- : { aumidIcons: 'off', threadTiles: 'off' };- savePupSettings({ ...pair, _chosen: { ...(pupSettings()._chosen || {}), aumidIcons: true, threadTiles: true } });- console.log(`[configure] taskbarMode -> ${tm} (${JSON.stringify(pair)})`);- if (tm === 'plain') { stripAllPupIdentities('configure-taskbarmode-plain').catch(() => {}); }- else {- for (const [, s] of [...sessions]) { try { s._curAppId = null; s._frozenAppId = null; } catch (e) {} }- try { _registeredPupAumids.clear(); } catch (e) {}- brandSweep('configure-taskbarmode').catch(() => {});- reconcileModel('setting taskbarMode').catch(() => {});- }- }- // v1.8.82: user-facing pup preferences. Currently: taskbarGrouping 'split'|'grouped'.- const valid = ['split', 'grouped'];- const g = args.taskbarGrouping;- if (g !== undefined && !valid.includes(g)) {- sendJSON(res, { success: false, error: `taskbarGrouping must be one of ${valid.join('|')}`, });- return;- }- // agentActivity was collapsed into taskbarActivity in 2.0.292; callers still passing it get a- // pointer instead of a silent write to a key nothing reads (audit, 2.0.314).- if (args.agentActivity !== undefined) { sendJSON(res, { success: false, error: 'agentActivity was removed in 2.0.292; use taskbarActivity: "progress"|"flash"|"off"' }); return; }- // v1.9.95: reportIssues on|off β the bug-report invitation pup attaches as _reportIssues- // (on failures, on the first verb of a bridge run, and occasionally after). On by default:- // reports from real machines are the only way this bridge gets correct. A user who finds it- // noisy can silence it without losing any functionality.- const ri = args.reportIssues;- if (ri !== undefined && !['on', 'off'].includes(ri)) {- sendJSON(res, { success: false, error: `reportIssues must be one of on|off` });- return;- }- // v1.9.125 / v2.0.110: autoLogin β pup auto-filling a vault-matched login form on page- // load. 'on'|'submit' = fill (submit stays reason-gated via pup_login); 'fill' = same- // fill-only; 'off' = do not auto-fill.- const al = args.autoLogin;- if (al !== undefined && !['on', 'off', 'submit', 'fill'].includes(al)) {- sendJSON(res, { success: false, error: `autoLogin must be one of on|off|submit|fill` });- return;- }- // v2.0.110: captureLogins on|off β record form logins into the keychain vault as they submit.- const cl = args.captureLogins;- if (cl !== undefined && !['on', 'off'].includes(cl)) {- sendJSON(res, { success: false, error: `captureLogins must be one of on|off` });- return;- }- if (cl) { savePupSettings({ captureLogins: cl }); console.log(`[configure] captureLogins -> ${cl}`); }- // v1.9.185: minimalTouch on|off β see the minimalTouch() note. 'on' strips pup down to- // open-a-window + drive-it-over-CDP, disabling every cosmetic/janitor action pup performs- // ON Chrome (taskbar identity/badges/jump lists/progress/flash/toasts, the in-page title- // observer, and the periodic reclaim/placement/reconcile/unstick sweeps).- const mt = args.minimalTouch;- if (mt !== undefined && !['on', 'off'].includes(mt)) {- sendJSON(res, { success: false, error: `minimalTouch must be one of on|off` });- return;- }- if (mt) { savePupSettings({ minimalTouch: mt }); console.log(`[configure] minimalTouch -> ${mt}`); }- if (ri) { savePupSettings({ reportIssues: ri }); console.log(`[configure] reportIssues -> ${ri}`); }- if (al) { savePupSettings({ autoLogin: al }); console.log(`[configure] autoLogin -> ${al}`); }- if (g) {- savePupSettings({ taskbarGrouping: g });- console.log(`[configure] taskbarGrouping -> ${g}`);- // re-stamp every live window under the new identity scheme + refresh badges- brandSweep('configure').then(async () => {- try { const first = sessions.values().next().value; if (first) await updateTabCountBadge(first, sessions.keys().next().value); } catch (e) {}- // split mode: refresh each window's own tab badge- if (g === 'split') { for (const [sid, sess] of sessions) { updateTabCountBadge(sess, sid).catch(() => {}); } }- }).catch(() => {});- }- // v2.0.154 THREAD TILES on/off β re-stamp every live window so their taskbar buttons move- // to (or off) the per-thread named tile immediately.- const tt = args.threadTiles;- if (tt !== undefined && !['on', 'off'].includes(tt)) { sendJSON(res, { success: false, error: `threadTiles must be one of on|off` }); return; }- if (tt) {- savePupSettings({ threadTiles: tt });- console.log(`[configure] threadTiles -> ${tt}`);- // Force a full re-register: clear frozen ids AND the registered-AUMID set so every window- // re-runs registerPupAumid (which is where the tile renders) instead of short-circuiting.- for (const [sid, sess] of [...sessions]) { try { sess._curAppId = null; sess._frozenAppId = null; } catch (e) {} }- try { _registeredPupAumids.clear(); } catch (e) {}- brandSweep('configure-threadtiles').catch(() => {});- }- const ob = args.overlayBadges;- if (ob !== undefined && !['on', 'off'].includes(ob)) { sendJSON(res, { success: false, error: `overlayBadges must be one of on|off` }); return; }- if (ob) {- savePupSettings({ overlayBadges: ob });- console.log(`[configure] overlayBadges -> ${ob}`);- applyOverlayBadgeSetting(ob, null).catch(() => {});- }- // v2.0.262: badge SIZE over the verb too. pup_configure has a habit of missing new keys- // (selftest T6 caught exactly that for jumpLists), so it is wired in the same pass as the- // dashboard rather than left for later.- const osz = args.overlaySize;- if (osz !== undefined && !['50', '66', '75', '100'].includes(String(osz))) { sendJSON(res, { success: false, error: `overlaySize must be one of 50|66|75|100 β the PERCENT of full size to draw the favicon badge at. 75 is the default; smaller keeps more of the thread tile's bottom line of text readable.` }); return; }- if (osz !== undefined) {- savePupSettings({ overlaySize: String(osz) });- console.log(`[configure] overlaySize -> ${osz}`);- applyOverlayBadgeSetting(pupSettings().overlayBadges === 'off' ? 'off' : 'on', null).catch(() => {});- }- // v2.0.213 (selftest T6 caught it): pup_configure NEVER handled jumpLists β the arg was- // silently ignored (only the dashboard dialog was wired to refreshAllJumplists), so a- // toggle through the verb was a no-op and post-respawn re-commits could only come from- // natural stamp churn. Master + the four task sub-toggles, live-applied.- for (const _jk of ['jumpLists', 'jlViewTasks', 'jlCloseAll', 'jlDashboard', 'jlAnnotate']) {- const _jv = args[_jk];- if (_jv === undefined) continue;- if (!['on', 'off'].includes(_jv)) { sendJSON(res, { success: false, error: `${_jk} must be one of on|off` }); return; }- savePupSettings({ [_jk]: _jv, _chosen: { ...(pupSettings()._chosen || {}), [_jk]: true } });- console.log(`[configure] ${_jk} -> ${_jv}`);- refreshAllJumplists().catch(() => {});- }- const ais = args.aumidIconStyle;- if (ais !== undefined && !['standard', 'category'].includes(ais)) { sendJSON(res, { success: false, error: `aumidIconStyle must be one of standard|category` }); return; }- if (ais) { savePupSettings({ aumidIconStyle: ais }); console.log(`[configure] aumidIconStyle -> ${ais}`); for (const [, s] of sessions) { s._curAppId = null; s._frozenAppId = null; } brandSweep('configure-iconstyle').catch(() => {}); }- // v1.9.86 (Kyle): BOTH of these settings drive Windows-only machinery β taskbar buttons,- // badges and the progress bar are stamped by desktop_set_window_identity /- // desktop_taskbar, and stampPupIdentity/brandSweep early-return on non-win32. The- // setting is still stored (it's a user preference, and the profile may later be- // read on Windows), but off-Windows it has NO visible effect. The old hint said- // "Applied to live windows immediately" unconditionally, so on macOS the calling AI- // was handed a confident description of something that did not happen β and per the- // SDK's first principle the AI reads this output, never the on-disk skill. Describe- // the platform we're actually on. (v1.9.91 merge: reuses the module-level _isWin from- // the v1.9.89 reap work rather than shadowing it with a local const.)- const _os = process.platform === 'darwin' ? 'macOS' : process.platform;- const _effect = _isWin- ? 'Applied to live windows immediately; persists across bridge updates (~/.adom/pup-settings.json).'- : `NOTE: stored but INERT on ${_os} β pup's taskbar buttons, badges and progress bar are Windows-only (there is no equivalent Dock affordance), so nothing changed on screen. Persists across bridge updates (~/.adom/pup-settings.json) and takes effect if this profile is used on Windows.`;- const _gHint = g- ? `taskbarGrouping is now "${g}" β on Windows, ${g === 'grouped' ? 'all pup windows stack under ONE "Adom Pup" taskbar button and its badge shows the WINDOW count' : 'each pup window gets its OWN taskbar button wearing its tab count'}. ${_effect}`- : `Current pup settings. Set with e.g. {"taskbarGrouping":"grouped"}.${_isWin ? '' : ' taskbarGrouping and taskbarActivity are Windows-only in effect; they are stored but inert here.'}`;- const _sOut = pupSettings();- const _tmOut = _sOut.aumidIcons !== 'on' ? 'plain' : (_sOut.threadTiles === 'on' ? 'tiles' : 'aumid');- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, settings: { ..._sOut, taskbarMode: _tmOut }, platform: process.platform, taskbarSupported: _isWin, _hint: _gHint }) });- return;- }- case 'pup_my_windows': {- // v2.0.103 (John: "shouldn't you give the thread a unique way to talk to you recalling- // the windows you opened for it?"): one verb, zero args β everything the calling thread- // owns, with ready-to-run next steps. aiThreadName inspects another thread read-only.- const who = (args && (args['ai-thread'] || args.aiThread || args.aiThreadName)) || _callerThread;- if (!who) { sendJSON(res, { success: false, errorCode: 'caller_identity_required', error: 'Identify your thread (--ai-thread "<name>") or pass aiThreadName.' }); return; }- const wins = [];- for (const [sid, sM] of sessions) {- if ((sM.owner || sM._ownerThread) !== who) continue;- const tabsM = [];- for (const tM of (sM.tabs || [])) {- let uM = null, tiM = null;- try { uM = tM.page.url(); } catch (e) {}- try { tiM = await Promise.race([tM.page.title(), new Promise(r => setTimeout(() => r(null), 800))]); } catch (e) {}- tabsM.push({ tabId: tM.tabId, url: uM, title: tiM, active: tM.tabId === sM.activeTabId });- }- // emulatedViewport belongs HERE too (project-a5): pup_my_windows advertises itself as- // "full state of everything your thread owns" and is the verb an AI reaches for first,- // but it was the one listing that omitted the override entirely, so the first place you- // look was the one place it could not tell you.- wins.push({ window: sid, profile: sM.profileName, notYou: /^iso-/.test(sM.profileName || ''), lostBrowser: !!sM._lostBrowser, createdMinAgo: sM.createdAt ? Math.round((Date.now() - sM.createdAt) / 60000) : null, lastAgentUpdate: sM._lastAgentUpdate || null, emulatedViewport: sM._emulatedViewport || null, tabs: tabsM });- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, 'ai-thread': who, windows: wins,- _hint: wins.length ? `These are ${who === _callerThread ? 'your' : `${who}'s`} windows. Target them with NO ids: any pup verb without sessionId resolves to your own thread's window (or address any thread via "ai-thread"). pup_screenshot {} to see it; pup_reload {} to refresh it; tab verbs take tabId.`- // v2.0.107: an empty answer during post-restart re-adoption is a LIE OF OMISSION β- // say so instead of letting the caller conclude its window is gone.- : (process.uptime() < 90 ? `NO WINDOWS LISTED YET, BUT DO NOT TRUST THIS: the bridge restarted ${Math.round(process.uptime())}s ago and is still re-adopting surviving windows. Retry in ~30s before concluding "${who}" has nothing.` : `No windows for "${who}" yet. pup_open_window {"url":"..."} creates one β pup keys it to the thread name, remembers it, and every later verb finds it with no ids.`),- _next: threadNextBlock() }) });- return;- }- case 'pup_quit_idle_browsers': {- // v2.0.98 (maintenance): gracefully Browser.close() every profile browser that has ZERO- // live sessions. pup deliberately keeps Chrome warm across window closes and bridge- // restarts, but a warm browser never reloads its --load-extension dirs, so extension- // updates need the process to actually exit. This is the safe, pup-native way: CDP- // Browser.close on pup's OWN browser objects, only when no session (hence no window)- // rides them β never an OS process kill.- const liveProfiles = new Set();- for (const s9 of sessions.values()) { if (!s9._lostBrowser && (s9.tabs || []).length) liveProfiles.add(s9.profileName); }- const closed = [], skipped = [], protectedByPages = [];- for (const [prof, be9] of [...browsers]) {- if (liveProfiles.has(prof)) { skipped.push(prof); continue; }- // v2.0.117 (John β this verb once closed a browser holding 8 windows): "no adopted- // session" does NOT mean idle. Right after a bridge restart, re-adoption may not have- // run yet, so a warm browser still holding real windows looks session-less. Before- // closing, confirm via the browser's ACTUAL open pages that it holds no real window;- // any non-blank page means it is mid-re-adoption (or user-held), never close it. If the- // browser can't be inspected, be conservative and skip β a false "idle" here is data loss.- let realPages = 0;- try {- const pages = await Promise.race([be9.browser.pages(), new Promise((_, rej) => setTimeout(() => rej(new Error('pages timeout')), 3000))]);- for (const pg of (pages || [])) { let u = ''; try { u = pg.url(); } catch (e) {} if (u && u !== 'about:blank' && !/^chrome:\/\/(newtab|new-tab-page)/.test(u)) realPages++; }- } catch (e) { realPages = -1; }- if (realPages !== 0) { protectedByPages.push(`${prof} (${realPages < 0 ? 'uninspectable' : realPages + ' open page(s)'})`); skipped.push(prof); continue; }- try { await be9.browser.close(); } catch (e) {}- browsers.delete(prof);- closed.push(prof);- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, closed, skipped, protectedByPages, _hint: closed.length ? `Closed the idle browser process for: ${closed.join(', ')}. The next pup_open_window on these profiles launches a FRESH Chrome, which re-reads staged extensions.` : `No idle browsers to close.${protectedByPages.length ? ` PROTECTED from closing (still hold open windows, likely mid-re-adoption after a restart): ${protectedByPages.join(', ')}.` : ' Every known browser still carries live sessions (listed in skipped).'}` }) });- return;- }- case 'pup_reap_profiles': {- // v2.0.129: manual trigger for the leaked-isolated-profile reaper (also runs periodically).- // dryRun:true previews which iso-* throwaway dirs WOULD be removed without deleting anything.- const dryRun = args.dryRun === true;- const r = await reapLeakedIsolatedProfiles({ dryRun });- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, dryRun, scannedIsoProfiles: r.scanned, wouldRemoveOrRemoved: r.removed, removed: dryRun ? 0 : r.removed,- spared: r.spared, inUse: r.held, tooFresh: r.tooFresh, skipped: r.skipped || null, sampleNames: r.removedNames,- _hint: r.skipped === 'recovery_pending'- ? 'Skipped this cycle: session recovery is pending (no live sessions but session files exist on disk) β an isolated session could still be re-adopted. Retry after recovery settles.'- : (dryRun- ? `${r.removed} leaked iso-* profile(s) would be removed (${r.spared} spared by a live session/file, ${r.held} in-use, ${r.tooFresh} too fresh to touch). Re-run without dryRun to delete them.`- : `Removed ${r.removed} leaked iso-* profile(s) (${r.spared} spared, ${r.held} in-use, ${r.tooFresh} too fresh). Durable jars (adom-you, named profiles) are never touched β only iso-* throwaways.`)- }) });- return;- }- case 'pup_uninstall': {- // v2.0.126 (John): cleanly remove pup's WHOLE machine footprint. Destructive β REASON-- // GATED behind confirm:"uninstall". dryRun:true reports what WOULD be removed. Keep-flags- // preserve user data (keepCredentials, keepProfiles). Chrome-for-Testing + firewall rules- // are SHARED/system, left alone unless removeChrome / removeFirewallRules. Everything runs- // in-process on the desktop (this bridge IS on the machine) via fs + reg.exe.- const dry = args && args.dryRun === true;- const rmChrome = args && args.removeChrome === true;- // v2.0.130 (#48, Bridge SDK uninstall cascade): ab's uninstaller enumerates bridges and- // calls each declared <prefix>uninstall NON-INTERACTIVELY during a silent /S uninstall β- // it must never be refused. cascade:true is the machine path (declared in bridge.json's- // uninstall field, per the reply on issue #48); confirm:"uninstall" remains the explicit- // human/AI path. Both idempotent, best-effort per item, never prompting.- if (args && args.cascade === true && !args.confirm) args.confirm = 'uninstall';- if (!dry && (!args || args.confirm !== 'uninstall')) {- sendJSON(res, { success: false, errorCode: 'confirm_required', _hint: 'pup_uninstall REMOVES pup\'s footprint from this machine (registry AUMID keys, staged extensions, all pup profiles, the login vault, settings, temp + launcher files). Preview first: pup_uninstall {dryRun:true}. To actually remove: pup_uninstall {confirm:"uninstall"}. Keep your saved logins: add keepCredentials:true. Keep profiles/sessions: keepProfiles:true. Chrome-for-Testing and firewall rules are left unless removeChrome:true / removeFirewallRules:true.' });- return;- }- const home = os.homedir();- const P = (...a) => path.join(home, '.adom', ...a);- const dirTargets = [- { path: PROFILES_DIR, label: 'pup-profiles (all windows/logins)', skip: args && args.keepProfiles === true },- { path: P('pup-sessions'), label: 'pup-sessions', skip: args && args.keepProfiles === true },- { path: P('pup-sessions-bak'), label: 'pup-sessions-bak', skip: args && args.keepProfiles === true },- { path: P('pup-extension'), label: 'toolbar extension' },- { path: P('pup-identity-extension'), label: 'identity extension' },- { path: P('pup-identity-anon-extension'), label: 'anon identity extension' },- { path: P('pup-annotate-extension'), label: 'annotate extension' },- { path: P('pup-avatars'), label: 'pup-avatars' },- { path: path.join(require('os').tmpdir(), 'pup-badge-debug'), label: 'badge-debug temp' },- // v2.0.161: the AUMID icon caches (ALL generations) β thread-name tiles + notched category- // tiles the taskbar keys pointed at. The registry keys are wiped below; wipe their .ico- // files too so uninstall leaves zero pup footprint in %TEMP%.- { path: path.join(require('os').tmpdir(), 'pup-thread-tiles'), label: 'thread-tile icons (AUMID)' },- { path: path.join(require('os').tmpdir(), 'pup-notched-icons'), label: 'category tile icons (AUMID)' },- ];- const fileTargets = [- { path: PUP_SETTINGS_PATH, label: 'pup-settings.json' },- { path: pupJumplistLauncherPath(), label: 'jump-list launcher' },- { path: path.join(home, '.adom', 'pup-jumplist-port'), label: 'jump-list port file' },- ];- if (rmChrome) dirTargets.push({ path: path.join(home, '.cache', 'puppeteer', 'chrome'), label: 'Chrome-for-Testing install' });- // count/measure- const exists = (p) => { try { return fs.existsSync(p); } catch (e) { return false; } };- const willRemoveDirs = dirTargets.filter(t => !t.skip && exists(t.path));- const willRemoveFiles = fileTargets.filter(t => exists(t.path));- // AUMID registry keys- let aumidKeys = [];- if (_isWin) { try { aumidKeys = String(execFileSync('reg', ['query', 'HKCU\\Software\\Classes\\AppUserModelId'], { encoding: 'utf8', timeout: 5000, windowsHide: true })).split(/\r?\n/).filter(l => /\\Adom\.Pup/i.test(l)).map(l => l.trim()); } catch (e) {} }- const vaultCount = (args && args.keepCredentials === true) ? 0 : (() => { try { return credentialVault.listCredentials().length; } catch (e) { return 0; } })();-- const plan = {- registryKeys: aumidKeys.length,- profilesAndSessions: (args && args.keepProfiles === true) ? 'KEPT' : willRemoveDirs.filter(t => /profile|session/.test(t.label)).map(t => t.label),- directories: willRemoveDirs.map(t => t.label),- files: willRemoveFiles.map(t => t.label),- vaultCredentials: (args && args.keepCredentials === true) ? 'KEPT' : vaultCount,- chromeForTesting: rmChrome ? 'WILL REMOVE' : 'kept (shared)',- firewallRules: (args && args.removeFirewallRules === true) ? 'WILL REMOVE' : 'kept',- };- if (dry) { sendJSON(res, { success: true, output: JSON.stringify({ ok: true, dryRun: true, plan, _hint: `Preview only β nothing removed. To do it: pup_uninstall {confirm:"uninstall"}${args && args.keepCredentials ? ', keepCredentials:true' : ''}${args && args.keepProfiles ? ', keepProfiles:true' : ''}.` }) }); return; }-- // EXECUTE. First close every window + quit every browser so profile dirs unlock.- const removed = { registryKeys: 0, dirs: [], files: [], vaultCredentials: 0, errors: [] };- try { for (const [pName, be] of [...browsers]) { try { await killBrowserProcess(be.browser, pName); } catch (e2) {} } browsers.clear(); sessions.clear(); } catch (e) { removed.errors.push('close-all: ' + e.message); }- await new Promise(r => setTimeout(r, 1500));- // registry- if (_isWin) { for (const line of aumidKeys) { try { execFileSync('reg', ['delete', line, '/f'], { timeout: 5000, windowsHide: true, stdio: 'ignore' }); removed.registryKeys++; } catch (e) { removed.errors.push('reg ' + line + ': ' + e.message); } } }- // dirs- for (const t of willRemoveDirs) { try { fs.rmSync(t.path, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 }); removed.dirs.push(t.label); } catch (e) { removed.errors.push(t.label + ': ' + e.message); } }- // files- for (const t of willRemoveFiles) { try { fs.rmSync(t.path, { force: true }); removed.files.push(t.label); } catch (e) { removed.errors.push(t.label + ': ' + e.message); } }- // vault- if (!(args && args.keepCredentials === true)) { try { const w = await credentialVault.wipeAll(); removed.vaultCredentials = w.removed; } catch (e) { removed.errors.push('vault: ' + e.message); } }- // firewall (needs admin; best-effort)- if (_isWin && args && args.removeFirewallRules === true) { try { execFileSync('powershell', ['-NoProfile', '-Command', "Get-NetFirewallApplicationFilter | Where-Object { $_.Program -match 'puppeteer.*chrome' } | Remove-NetFirewallRule -ErrorAction SilentlyContinue"], { timeout: 15000, windowsHide: true, stdio: 'ignore' }); removed.firewallRules = 'attempted'; } catch (e) { removed.errors.push('firewall (needs admin): ' + e.message); } }-- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, removed, _hint: `pup uninstalled from this machine. Removed ${removed.registryKeys} registry key(s), ${removed.dirs.length} dir(s), ${removed.files.length} file(s), ${removed.vaultCredentials} vault credential(s).${removed.errors.length ? ' Some items errored (may need Chrome fully closed or admin): ' + removed.errors.slice(0, 5).join('; ') : ''} Chrome-for-Testing ${rmChrome ? 'removed' : 'left in place (shared)'}. The bridge itself is managed by Adom Bridge β remove pup from Bridge to stop it re-seeding.` }) });- return;- }- case 'pup_rescan': {- try { await auditStampedIdentities(); } catch (e) {} // v2.0.386 (#83): rescan also re-checks every window's stamped identity- // v1.5.1+: orphan-recovery primitive. Walk every known profile- // (in-memory + on-disk session files), attempt reconnect for any- // whose CDP socket dropped, then walk pages of every connected- // browser and rebuild session entries by parsing the (session: X)- // tag from each page's title. Idempotent β pages already attached- // to a live session are left alone.- //- // Args:- // adoptOrphans (optional bool, default false) β when true,- // pages with no (session: X) tag are attached under a- // generated sessionId so the caller can drive them. Useful- // when Chrome has tabs the bridge has never seen (e.g. user- // opened them manually). Default false to avoid surprising- // callers with random tabs in their sessions list.- try {- const adoptOrphans = !!args.adoptOrphans;- // v2.0.321 (#72 ask): rescan also resets each tab's input CDP session, so a wedged input- // channel is recovered by pup_rescan (in addition to the automatic discard-on-timeout).- for (const [, ss] of sessions) { for (const t of (ss.tabs || [])) { try { if (t && t._inputCdp) killInputCdp(t); } catch (e) {} } }- const stats = await rescanAllProfiles({ adoptOrphans });- brandSweep('rescan').catch(() => {}); // v1.8.79: unify icons on re-adopted windows- // Build the post-rescan session list so callers can verify- // their sessionId is back. liveSessions are usable; disconnected- // are still awaiting Chrome to reappear (PID gone or port dead).- const liveSessions = [];- const disconnectedSessions = [];- for (const [id, s] of sessions) {- if (isSessionAlive(s)) {- liveSessions.push(id);- } else if (s._lostBrowser) {- disconnectedSessions.push(id);- }- }- sendJSON(res, {- success: true,- output: JSON.stringify({- ok: true,- rescanned: stats.profilesScanned,- profilesReconnected: stats.profilesReconnected,- profilesUnreachable: stats.profilesUnreachable,- reattached: stats.totalReattached,- orphansAdopted: stats.totalAdopted,- liveSessions,- disconnectedSessions,- _hint:- disconnectedSessions.length > 0- ? `Some sessions still disconnected: [${disconnectedSessions.join(', ')}]. Their Chrome PID may be dead β check with pup_list_windows. pup_open_window (with the original sessionId + profile) will start fresh; the on-disk session file is overwritten.`- : (liveSessions.length > 0- ? `All known sessions are live: [${liveSessions.join(', ')}].`- : `No sessions known to the bridge. pup_open_window to start one.`),- }),- });- } catch (e) {- sendJSON(res, { success: false, error: `rescan failed: ${e.message}` });- }- return;- }- case 'pup_close_window': {- invalidateEnumCache(); // v2.0.187: the desktop is about to change- const sessionId = args.sessionId;- if (!requireLiveSession(res, { ...args, sessionId })) return;- const session = sessions.get(sessionId);- // v1.8.16: refuse a declared cross-owner close (destroying another thread's window).- { const refusal = ownershipRefusal(session, sessionId, args, 'pup_close_window'); if (refusal) { sendJSON(res, refusal); return; } }- // v1.9.189: prune this window's AUMID key(s) AFTER the close settles. Previously keys were- // only pruned at boot, so every closed window left registry state behind until the next- // restart (audit found Adom.Pup.mt3.web.i3 alive long after mt3 was gone).- setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);- const profileName = session.profileName;-- // Close ALL tabs in this session (a window can now hold many tabs)- const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;- // v2.0.46 (mimic-ralph: closing a freshly-RECOVERED multi-tab window left the window ALIVE- // as an orphan, later adopted as a weird adom-you-win**** session β the ghost windows John- // saw): recovery binds ONE tab per session and the sibling-tab rescan runs later, so- // closing only the BOUND tabs closes 1 of N and the OS window survives with the rest.- // Close by WINDOW instead: collect the CDP windowId of every bound tab, then close EVERY- // page in the browser that lives in one of those windows β bound to this session or not.- // Any window this session owns dies whole; other sessions' windows have different- // windowIds and are untouched.- const _winIds = new Set();- for (const t of [...session.tabs]) {- try {- const c = await t.page.target().createCDPSession();- try { const { windowId } = await c.send('Browser.getWindowForTarget'); if (windowId != null) _winIds.add(windowId); } finally { try { await c.detach(); } catch (e) {} }- } catch (e) {}- }- if (_winIds.size) {- try {- const _b = browsers.get(session.profileName) && browsers.get(session.profileName).browser;- const _boundPages = new Set([...session.tabs].map(t => t.page));- for (const p of (_b ? await _b.pages() : [])) {- if (_boundPages.has(p)) continue; // bound tabs close below (keeps their bookkeeping)- try {- const c = await p.target().createCDPSession();- let wid = null;- try { ({ windowId: wid } = await c.send('Browser.getWindowForTarget')); } finally { try { await c.detach(); } catch (e) {} }- if (wid != null && _winIds.has(wid)) {- // never close a page another session tracks (shared-profile safety)- let _foreign = false;- for (const [ok2, os2] of sessions) { if (ok2 !== sessionId && os2 && (os2.tabs || []).some(t2 => t2 && t2.page === p)) { _foreign = true; break; } }- if (!_foreign) { console.log(`[close] "${sessionId}" closing UNBOUND sibling tab in its window (${(p.url() || '').slice(0, 60)})`); await p.close().catch(() => {}); }- }- } catch (e) {}- }- } catch (e) {}- }- for (const t of [...session.tabs]) {- try { await t.page.close(); } catch (e) {}- }- session.tabs.length = 0;- session.activeTabId = null;- sessions.delete(sessionId);- _sessionOwnerClaims.delete(sessionId); // v1.9.110: the id is free again- deleteSessionFile(sessionId);- clearSessionBusy(sessionId); // v1.9.57: drop any pending progress timer- unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete)-- // #434 / v2.0.256: the ground-truth decision now lives in ONE primitive- // (killProfileBrowserIfUnused) so no call site can reintroduce the refCount kill.- let _reap = { killed: 0, remaining: 0 };- _reap = await killProfileBrowserIfUnused(profileName, 'session closed (all tabs)');-- if (activeSessionId === sessionId) {- activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;- }- console.log(`Session "${sessionId}" closed (all tabs). Remaining sessions: ${sessions.size}`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, remaining: sessions.size, activeSession: activeSessionId,- chromeProcsKilled: _reap.killed, chromeProcsRemaining: _reap.remaining,- ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) for this profile SURVIVED teardown (should be 0) and may peg a CPU core. The box may be under heavy load β retry pup_close_window; if it persists it is a reap bug worth reporting.` } : {}),- }) });- return;- }-- // v2.0.21 (John): the "Owned by: <thread>" jump-list callback β show everything about this- // window as a NATIVE always-on-top caption card just above the taskbar (ab desktop_caption,- // click-through, auto-expires). Replaces the title-tag approach to conveying ownership.- // Read-only + not owner-gated: any thread (and the jump-list click itself) may ask.- case 'pup_window_info': {- const sid = args.sessionId || activeSessionId;- const session = sid && sessions.get(sid);- if (!session) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }, 404); return; }- const ownT = session.owner || session._ownerThread || null;- const ageMin = session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null;- const tabs = [];- try {- for (const t of (session.tabs || [])) {- let u = null, ti = null;- try { u = t.page.url(); } catch (e) {}- try { ti = await Promise.race([t.page.title(), new Promise(r => setTimeout(() => r(null), 1500))]); } catch (e) {}- tabs.push({ tabId: t.tabId, url: u, title: ti, active: t.tabId === session.activeTabId });- }- } catch (e) {}- const lu = session._lastAgentUpdate || null;- const _fmtAge = (m) => m == null ? 'unknown' : (m < 60 ? `${m} min` : `${Math.round(m / 6) / 10} h`);- const lines = [];- lines.push(`Adom Pup window "${sid}" Β· owned by AI thread: ${ownT || 'unclaimed'}`);- const bits = [];- if (session._ownerContainer) bits.push(`container ${session._ownerContainer}`);- bits.push(`profile ${session.profileName || '?'}`);- bits.push(`${tabs.length} tab${tabs.length === 1 ? '' : 's'}`);- bits.push(`open ${_fmtAge(ageMin)}`);- lines.push(bits.join(' Β· '));- const at = tabs.find(t => t.active) || tabs[0];- if (at) lines.push(`Showing: ${(at.title || at.url || '?').slice(0, 90)}`);- if (lu) lines.push(`Last agent action: ${lu.verb}${lu.note ? ` Β· ${lu.note}` : ''} (${_fmtAge(Math.round((Date.now() - lu.ts) / 60000))} ago)`);- if (session._lastTakeoverBy) lines.push(`β last takeover: by "${session._lastTakeoverBy}" ${_fmtAge(Math.round((Date.now() - (session._takenOverAt || Date.now())) / 60000))} ago`);- // v2.0.130 (#44, John's design): the TOOLBAR POPUP's Window-info click gets a proper- // IN-PAGE floaty card (brand tokens, draggable grip, 600ms viewport-clamped tooltips- // teaching the ai-thread-owns-window model) instead of the always-a-bit-wonky ab desktop- // caption. Jump-list clicks KEEP the caption (no reliable in-page surface exists from the- // taskbar); AI/JSON callers get the data either way. Card falls back to the caption if- // injection fails, so the user never gets nothing.- const _fromToolbar = _callerThread === 'user-toolbar' || (args.caller && args.caller.aiThread === 'user-toolbar');- let cardShown = false, captionShown = false;- if (_fromToolbar) {- try {- const _cardTab = (session.tabs || []).find(t => t.tabId === (args._resolvedTabId || session.activeTabId)) || (session.tabs || [])[0];- const _cardInfo = {- sid, owner: ownT || 'unclaimed', container: session._ownerContainer || null,- profile: session.profileName || '?', age: _fmtAge(ageMin),- tabs: tabs.map(t => ({ label: (t.title || t.url || '?').slice(0, 64), active: !!t.active })),- last: lu ? `${lu.verb}${lu.note ? ': ' + lu.note : ''} (${_fmtAge(Math.round((Date.now() - lu.ts) / 60000))} ago)` : null,- takeover: session._lastTakeoverBy ? `by "${session._lastTakeoverBy}" ${_fmtAge(Math.round((Date.now() - (session._takenOverAt || Date.now())) / 60000))} ago` : null,- };- await _cardTab.page.evaluate((d) => {- try {- const old = document.querySelector('div[data-pup-infocard]');- if (old) old.remove();- const host = document.createElement('div');- host.setAttribute('data-pup-infocard', '1');- host.style.cssText = 'position:fixed;top:24px;right:24px;z-index:2147483647;margin:0;padding:0;';- const sh = host.attachShadow ? host.attachShadow({ mode: 'open' }) : null;- const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));- const TIPS = {- thread: 'The ai-thread is the identifier for pup windows: you get one pup window per ai-thread, and pup default-creates tabs in that window for everything the thread manages or needs a browser surface for.',- container: 'The cloud container the owning AI thread runs in. That is where the AI driving this window lives.',- profile: 'The Chrome profile (cookie jar) this window rides. adom-you is the shared signed-in jar every window defaults to; iso-* jars are throwaway anonymous profiles.',- tabs: 'Every tab in this window. The owning thread adds tabs here instead of opening new windows, so one taskbar button holds all its pages.',- age: 'How long this window has been open. pup windows persist until their thread (or you) closes them.',- last: 'The most recent pup action the owning AI ran against this window, with the note it left.',- takeover: 'Another thread claimed this window from its previous owner. Takeovers are logged and only allowed when the user asked.',- };- let rows = '';- const row = (k, label, val, accent) => { rows += `<div class="r" data-tip="${esc(TIPS[k] || '')}"><span class="k">${esc(label)}</span><span class="v${accent ? ' a' : ''}">${esc(val)}</span></div>`; };- row('thread', 'ai-thread', d.owner, true);- if (d.container) row('container', 'container', d.container);- row('profile', 'profile', d.profile);- row('age', 'open', d.age);- if (d.last) row('last', 'last action', d.last);- if (d.takeover) row('takeover', 'takeover', d.takeover);- let tabsHtml = '';- for (const t of (d.tabs || [])) tabsHtml += `<div class="t${t.active ? ' on' : ''}">${t.active ? 'βΈ ' : ''}${esc(t.label)}</div>`;- const html = `<style>- .card{width:340px;max-width:calc(100vw - 48px);background:#0d1117;border:1px solid #30363d;border-radius:10px;box-shadow:0 12px 34px rgba(0,0,0,.55);font:12px/1.45 "Segoe UI",system-ui,sans-serif;color:#c9d1d9;overflow:hidden;user-select:none}- .hd{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#161b22;border-bottom:1px solid #30363d;cursor:grab}- .hd:active{cursor:grabbing}- .grip{color:#8b949e;font-size:13px;letter-spacing:1px}- .ttl{font-weight:600;color:#e6edf3;flex:1}- .x{cursor:pointer;color:#8b949e;padding:2px 6px;border-radius:5px;font-size:13px}- .x:hover{color:#e6edf3;background:#21262d}- .bd{padding:8px 10px}- .r{display:flex;gap:8px;padding:3px 2px;border-radius:5px}- .r:hover{background:#161b22}- .k{color:#8b949e;min-width:78px}- .v{color:#c9d1d9;flex:1;word-break:break-word}- .v.a{color:#00b8b1;font-weight:600}- .tabs{margin-top:6px;border-top:1px solid #21262d;padding-top:6px}- .t{color:#8b949e;padding:2px 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}- .t.on{color:#e6edf3}- .teach{margin-top:8px;padding:7px 9px;background:#0b2b29;border:1px solid rgba(0,184,177,.35);border-radius:7px;color:#7fe9e2;font-size:11px}- .tip{position:fixed;z-index:2147483647;max-width:260px;background:#161b22;border:1px solid #30363d;border-radius:7px;padding:7px 9px;color:#c9d1d9;font-size:11px;line-height:1.45;box-shadow:0 8px 22px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .18s ease;text-transform:none}- .tip.show{opacity:1}- </style>- <div class="card">- <div class="hd" data-drag><span class="grip">β Ώ</span><span class="ttl">Adom Pup: window "${esc(d.sid)}"</span><span class="x" data-x title="Close">β</span></div>- <div class="bd">${rows}<div class="tabs" data-tip="${esc(TIPS.tabs)}">${tabsHtml}</div>- <div class="teach">One pup window per ai-thread: the thread named above owns this window, and everything it needs a browser for opens as a tab here.</div></div>- </div><div class="tip" data-tip-el></div>`;- if (sh) sh.innerHTML = html; else host.innerHTML = html;- const root = sh || host;- document.documentElement.appendChild(host);- // close- root.querySelector('[data-x]').addEventListener('click', () => host.remove());- // drag by the grip header, clamped to the viewport- const hd = root.querySelector('[data-drag]');- let dx = 0, dy = 0, dragging = false;- hd.addEventListener('pointerdown', (e) => {- if (e.target.closest('[data-x]')) return;- dragging = true; const r = host.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top;- try { hd.setPointerCapture(e.pointerId); } catch (e2) {}- });- hd.addEventListener('pointermove', (e) => {- if (!dragging) return;- const w = host.offsetWidth, h = host.offsetHeight;- const L = Math.max(8, Math.min(innerWidth - w - 8, e.clientX - dx));- const T = Math.max(8, Math.min(innerHeight - h - 8, e.clientY - dy));- host.style.left = L + 'px'; host.style.top = T + 'px'; host.style.right = 'auto';- });- hd.addEventListener('pointerup', () => { dragging = false; });- // tooltip singleton: 600ms delay, clamped to the viewport on BOTH axes- const tip = root.querySelector('[data-tip-el]');- let tmr = null;- root.querySelectorAll('[data-tip]').forEach((el) => {- el.addEventListener('mouseenter', (e) => {- const txt = el.getAttribute('data-tip');- if (!txt) return;- clearTimeout(tmr);- tmr = setTimeout(() => {- tip.textContent = txt;- tip.classList.add('show');- // measure AFTER content set- const tw = tip.offsetWidth, th = tip.offsetHeight, M = 8;- let x = e.clientX + 14, y = e.clientY + 14;- if (x + tw + M > innerWidth) x = e.clientX - tw - 14;- if (y + th + M > innerHeight) y = e.clientY - th - 14;- x = Math.max(M, Math.min(innerWidth - tw - M, x));- y = Math.max(M, Math.min(innerHeight - th - M, y));- tip.style.left = x + 'px'; tip.style.top = y + 'px';- }, 600);- });- el.addEventListener('mouseleave', () => { clearTimeout(tmr); tip.classList.remove('show'); });- });- } catch (e) {}- }, _cardInfo);- cardShown = true;- } catch (e) { console.log(`[window-info] in-page card injection failed (${e.message}) β falling back to the ab caption`); }- }- // v2.0.131 (John: "why did you show this caption?"): an AI CALLER querying window info is- // a DATA request β it must never paint anything on the user's screen. The caption is a- // USER surface, shown only when the USER clicked the jump-list task (user-taskbar-menu);- // toolbar clicks get the in-page card above. AI callers get JSON only, opt-in- // showCaption:true for the rare "identify this window to the user" case. (The incident:- // an AI called this just to get the hwnd and the user got a 12s caption over their work.)- const _userInitiated = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);- if (!cardShown && (_userInitiated || args.showCaption === true)) {- try {- const r = await chrome.abCommand('desktop_caption', {- id: `pup-info-${sid}`, text: lines.join('\n'),- position: 'bottom', size: 'medium', duration: 12000,- }, { timeoutMs: 8000, caller: sessionCaller(session) });- captionShown = !(r && r.success === false);- } catch (e) {}- }- // v2.0.131: include the hwnd β the pup_open_window _next text promises "pup_window_info- // hands you this window's hwnd for desktop_screenshot_window", but the response never- // carried it, so callers hwnd-hunted anyway (and tripped the caption doing it).- let _hwnd = null;- try { _hwnd = session._hwnd ? Number(session._hwnd) : (await sessionHwnd(sid)); } catch (e) {}- sendJSON(res, {- success: true, sessionId: sid, owner: ownT, ownerContainer: session._ownerContainer || null,- profile: session.profileName || null, ageMinutes: ageMin, tabs, lastAgentUpdate: lu,- hwnd: _hwnd || null,- foreground: !!session._foreground, lastTakeoverBy: session._lastTakeoverBy || null,- cardShown, captionShown,- _hint: cardShown- ? 'An in-page floaty card with this info is showing in the window (draggable by its grip, hover any row for its explanation, β to close).'- : (captionShown- ? 'A native card with this info is showing just above the taskbar for ~12s (click-through, auto-dismisses).'- : 'Info returned as JSON only β nothing was drawn on the user\'s screen (hwnd included for desktop_screenshot_window when you need the browser chrome in frame). A USER clicking the jump-list/toolbar gets the visual card; an AI caller can opt in with showCaption:true when the user asked to have the window identified.'),- });- return;- }- // v2.0.130 (#43, ah leg revived on John's "do them all"): MOVE this pup window's page into- // the user's Hydrogen workspace. Design: docs/PLAN-interactive-surface.md Phase 3. The- // plan's direct workspace-tabs endpoint (port 47083) is not exposed by today's Hydrogen, so- // this rides the REAL current API: ab's hd_api -> Hydrogen control API POST /open-url,- // which routes through the Browser Picker (per-domain memory; webview is the default- // surface, exactly the "move to my workspace" semantic). On success the pup window closes- // (its owner initiated the move) unless keepWindow:true. hc (cloud) stays blocked on the- // workspace-mutation auth ask recorded in the plan.- case 'pup_move_to_workspace': {- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_move_to_workspace'); if (refusal) { sendJSON(res, refusal); return; } }- let moveUrl = null;- try { const at = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0]; moveUrl = at ? at.page.url() : session.page.url(); } catch (e) {}- if (!moveUrl || /^(about:|chrome:|edge:|devtools:)/i.test(moveUrl)) {- sendJSON(res, { success: false, errorCode: 'no_movable_url', error: `This window's active tab has no movable URL (${moveUrl || 'none'}).` });- return;- }- let hd = null;- try { hd = await chrome.abCommand('hd_api', { method: 'POST', path: '/open-url', body: { url: moveUrl } }, { timeoutMs: 12000, caller: sessionCaller(session) });- } catch (e) { hd = { success: false, error: e.message }; }- const hdOk = hd && hd.success !== false && !(hd.body && hd.body.error);- if (!hdOk) {- sendJSON(res, { success: false, errorCode: 'hydrogen_unreachable', error: `Hydrogen did not accept the URL: ${(hd && (hd.error || (hd.body && hd.body.error))) || 'unknown error'}`, _hint: 'Moving a pup page to the workspace needs Hydrogen (ah) running on this machine β check hd_status. The pup window was left untouched.' });- return;- }- let windowClosed = false;- if (args.keepWindow !== true) {- try {- const profileName = session.profileName;- const _closingAppId = session._curAppId || `${PUP_APP_ID}.${resolvedId}`;- for (const t of [...(session.tabs || [])]) { try { await t.page.close(); } catch (e) {} }- session.tabs.length = 0; session.activeTabId = null;- sessions.delete(resolvedId);- deleteSessionFile(resolvedId);- unregisterPupAumid(_closingAppId).catch(() => {});- await killProfileBrowserIfUnused(profileName, 'window closed');- windowClosed = true;- setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);- } catch (e) {}- }- console.log(`[move-to-workspace] "${resolvedId}" -> Hydrogen (${moveUrl.slice(0, 80)}) β windowClosed=${windowClosed}`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, movedUrl: moveUrl, windowClosed, sessionId: resolvedId,- _hint: windowClosed- ? 'The page was handed to Hydrogen (its Browser Picker routes it β webview by default, honoring the user\'s per-domain choice) and this pup window closed. The user picks the surface if the picker prompts.'- : 'The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true).',- }) });- return;- }- // v1.9.3 (AD #208): the taskbar jump-list callback β flip THIS wiki window between the- // logged-in and public view IN PLACE. A view switch = a cookie-jar (userDataDir) switch,- // fixed at browser launch, so it's a close-and-relaunch of the same sessionId under the- // other profile at the same URL. Invoked when the user clicks the per-window taskbar- // right-click task (runs AD's bundled CLI β this verb); it's a USER action, so the window- // is brought on-screen. Not meant for AI threads (they pass wikiView on pup_open_window).- case 'pup_wiki_set_view': {- const sessionId = args.sessionId;- const _lv = requireLiveSession(res, { ...args, sessionId });- if (!_lv) return;- const session = _lv.session;- let url = null; try { url = session.page.url() || null; } catch (e) {}- if (!url || !isAdomUrl(url)) { sendJSON(res, { success: false, error: 'wiki view toggle applies only to Adom (adom.inc) URLs', output: JSON.stringify({ ok: false, url }) }); return; }- const curView = session._wikiView === 'authed' ? 'authed' : 'public';- // v2.0.139 (John: "add a shortcut key like annotate has"): view:"toggle" flips whatever- // the window currently shows β the Alt+Shift+V hotkey's form, since the extension can't- // know the current view. Explicit "authed"/"public" behave as before.- // v2.0.241 (caught while verifying the guard rewrite): an UNRECOGNISED view was silently- // coerced to 'public'. A caller asking for "signed-in" got the public view plus a response- // claiming view:"public", unchanged:true β technically true, completely misleading. Reject- // it and name the accepted values instead of guessing what the caller meant.- if (args.view !== undefined && !['authed', 'public', 'toggle'].includes(args.view)) {- sendJSON(res, { success: false, error: `view must be one of authed|public|toggle (got "${args.view}")`,- _hint: 'authed = the signed-in view, public = what a logged-out visitor sees, toggle = flip whichever it is now. There is no "signed-in"/"private" alias: this used to fall through to public silently.' });- return;- }- const view = args.view === 'toggle'- ? (curView === 'authed' ? 'public' : 'authed')- : (args.view === 'authed' ? 'authed' : 'public');- const owner = session.owner || null;- const publicProfile = session._publicProfile || sessionId;- if (curView === view) {- // v1.9.332 (John clicked "Switch to logged-in view" and "it did nothing"). pup BELIEVED the- // window was already in this view, so it only brought it to front β no reload. But the task- // PROMISES a "harmless reload", and pup's view belief is unreliable (a cookie expired, another- // window flipped auth, or the initial default-authed never actually signed the page in). So a- // real USER click ALWAYS re-asserts the requested view: it falls through to the mint/navigate- // path below, which is a genuine reload that GUARANTEES the page is in that view (for authed it- // re-mints + navigates, i.e. actually signs it in). Only a PROGRAMMATIC same-view call short-- // circuits with bringToFront.- const _userClick = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);- if (!_userClick) {- session._foreground = true;- try { await session.page.bringToFront(); } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, unchanged: true, url }) });- return;- }- console.log(`[wikiview] "${sessionId}" user clicked "${view}" while pup thought it was already ${curView} β re-asserting the view (harmless reload), NOT skipping (this is why the click "did nothing")`);- // fall through to the switch logic below, which navigates/asserts the requested view- }- // v2.0.132 (adom/wiki#126 β Colby shipped pup's own ask; John: "implement that instead"):- // THE PRIMARY PATH. ?view=public makes the server render any page GET exactly as a- // signed-out visitor (request's user nulled; the real session untouched; byte-identical;- // private pages 404). So the toggle is now a plain IN-PLACE reload of every wiki tab with- // the param appended (public) or stripped (authed): no relaunch, no cookie wipe, no- // profile switch, works on the shared jar, and the user just sees tabs reload in their- // window β exactly what John expected all along. The jar-relaunch machinery below- // survives only as the fallback for old servers that ignore the param, and for windows- // still sitting on a legacy iso-* jar (their authed direction falls through to the- // relaunch home to adom-you).- if (!/^iso-/.test(session.profileName || '') || view === 'public') {- try {- const _wTabs = (session.tabs || []).filter(t => { try { return isAdomUrl(t.page.url()); } catch (e) { return false; } });- if (_wTabs.length) {- for (const t of _wTabs) {- let tu = null; try { tu = t.page.url(); } catch (e) {}- if (!tu) continue;- const target = view === 'public' ? wikiPublicUrl(tu) : wikiStripPublicParam(tu);- try { await t.page.goto(target, { waitUntil: 'domcontentloaded', timeout: 25000 }); } catch (e) {}- }- const _at = _wTabs.find(t => t.tabId === session.activeTabId) || _wTabs[0];- // v2.0.133 (caught live in verification): checkWikiAuth is a same-origin API fetch- // that carries the REAL session cookie β which ?view=public deliberately leaves- // untouched β so it always reports "authed" and made this path conclude the server- // ignored the param, falling back to the jar relaunch it was built to replace.- // The param changes the RENDER, so probe the RENDER: a logged-out page shows the- // Login button; a signed-in one shows the profile menu instead.- let _renderLoggedOut = null;- try {- _renderLoggedOut = await Promise.race([- _at.page.evaluate(() => { try { return [...document.querySelectorAll('a,button')].some(e => /^\s*Login\s*$/i.test(e.textContent || '')); } catch (e) { return null; } }),- new Promise(r => setTimeout(() => r(null), 6000)), // covers the bounded load wait above- ]);- } catch (e) {}- let _a = null;- if (view === 'authed') { try { _a = await checkWikiAuth(_at.page); } catch (e) {} }- const _renderedAuthed = view === 'authed' ? !!(_a && _a.authed) : false;- // v2.0.134 (John: "you're STILL resizing the window on me"): the public direction- // NEVER falls back to the jar relaunch anymore. The old guard probed the header for- // the Login button right after domcontentloaded β when the header rendered a beat- // late the probe missed, wrongly concluded "server ignored the param", and- // relaunched the window (the resize John kept seeing). The param is live on prod- // and verified; a possibly-late header paint is never worth destroying the user's- // window over. The probe survives for LOGGING only.- if (view === 'public' && _renderLoggedOut === false) {- console.log(`[wikiview] "${sessionId}" header probe did not see the logged-out marker yet (late paint or old server) β TRUSTING ?view=public, staying in place (no relaunch, by design)`);- }- if (view === 'authed' && !_renderedAuthed) {- // Param stripped but the jar holds no live wiki session β continue into the- // existing mint / auto-sign / manual-login machinery below with a clean URL.- console.log(`[wikiview] "${sessionId}" param strip left the window logged out (no live session) β continuing to the sign-in path`);- url = wikiStripPublicParam(url);- } else {- session._wikiView = view;- try { await setWikiAuthedState(session, sessionId, view === 'authed', {}); } catch (e) {}- try { await setWikiModeGlyph(_at.page, view === 'authed' ? 'β Logged in' : 'β Public'); } catch (e) {}- session._wikiJumplistKey = undefined;- updateWikiJumplist(session, sessionId).catch(() => {});- updateThumbnailTooltip(session, sessionId).catch(() => {});- const _userClickP = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);- if (_userClickP) {- session._foreground = true;- try { await session.page.bringToFront(); } catch (e) {}- try { await osRaiseSessionWindow(session, sessionId, 'you clicked the taskbar view switch'); } catch (e) {}- }- wikiCaption(view === 'authed'- ? `Adom wiki: now the logged-in view${_a && _a.user ? ` (${_a.user})` : ''}`- : '\u{1F4D6} Adom wiki: now the PUBLIC view β the server rendered it exactly as a signed-out visitor sees it', 6000).catch(() => {});- console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE via ?view=public (adom/wiki#126) β ${_wTabs.length} tab(s) reloaded, no relaunch, no profile switch`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, view, inPlace: true, serverParam: true, tabsReloaded: _wTabs.length,- url: (() => { try { return _at.page.url(); } catch (e) { return null; } })(),- _hint: view === 'public'- ? 'PUBLIC view via the server-side ?view=public param (adom/wiki#126): the server renders exactly what a signed-out visitor sees while the window stays on the shared profile β session untouched, no relaunch, no isolated jar. Verify a publish leaked nothing, then flip back any time.'- : 'Logged-in view restored by stripping ?view=public β the shared-profile session was untouched throughout, so no re-login was needed.',- }) });- return;- }- }- } catch (e) { console.log(`[wikiview] "${sessionId}" param path failed (${e.message}) β falling back to the legacy machinery`); }- }- // v1.9.63 β IN-PLACE view switch, no close-and-relaunch.- //- // A wiki session cookie is per-PROFILE and a profile's userDataDir is fixed at Chrome- // launch, which is why this used to tear the window down and relaunch it under the other- // profile (the thing John watched vanish and never come back). Colby's browser-session- // endpoint removes that: an ALREADY-LOGGED-IN page can mint its own single-use magic link- // with nothing but its session cookie β- // POST /api/v1/auth/browser-session {next:"/adom/page"} -> {url, expires_in:60}- // so the BRIDGE can mint on the desktop (no container token, no adom-wiki CLI) and just- // navigate this window. The other direction is a cookie wipe plus a plain navigate. Either- // way it is one hop and the window/tabs are never destroyed.- //- // Guard: only wipe cookies when this window owns its profile. The shared authed profile- // backs OTHER windows too and clearing it would silently log them all out β fall through- // to the legacy relaunch in that case.- // v2.0.48 (John: "what should happen when i click this if there are multiple tabs in that- // window?"): the unit is the WINDOW β but cookies are per PROFILE and CDP's- // Network.clearBrowserCookies is BROWSER-wide, so on a SHARED profile the in-place public- // switch wiped EVERY thread's cookies (silent global logout) and reloaded only one tab.- // The old guard tested the LEGACY profile name (adom-wiki-authed) and never matched- // today's shared adom-you. Ground truth instead: the profile is shared if ANY other live- // session rides it (plus the known shared names). A shared-profile public switch now takes- // the tab-preserving RELAUNCH path: the whole window (all tabs) moves to a logged-out- // profile; every other window keeps its login.- let _profileShared = /adom-wiki-authed|adom-you/i.test(session.profileName || '');- try { for (const [k5, s5] of sessions) { if (k5 !== sessionId && s5 && s5.profileName === session.profileName) { _profileShared = true; break; } } } catch (e) {}- // v2.0.57 (John round-tripped the toggle: authed direction signed in the THROWAWAY- // profile instead of coming home): in-place authed is only correct when the window is- // NOT on a toggle-created iso-* jar. From a throwaway, the authed direction takes the- // tab-preserving relaunch back to the shared adom-you jar, so the round trip is- // symmetric and logins land where they enrich the shared credential store.- const _canInPlace = (view === 'authed') ? !/^iso-/.test(session.profileName || '') : !_profileShared;- if (_canInPlace) {- try {- let targetUrl = null;- if (view === 'authed') {- // Mint from ANY currently-authenticated pup page. Single-use + 60s TTL, so mint- // immediately before navigating and never cache it (a live link IS a credential).- let minter = null;- for (const [, s2] of sessions) {- for (const t2 of (s2.tabs || [])) {- try {- if (!isAdomUrl(t2.page.url())) continue;- const a2 = await checkWikiAuth(t2.page);- if (a2 && a2.authed) { minter = t2.page; break; }- } catch (e) {}- }- if (minter) break;- }- if (minter) {- const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return '/'; } })();- targetUrl = await minter.evaluate(async (np) => {- try {- const r = await fetch('/api/v1/auth/browser-session', {- method: 'POST', credentials: 'same-origin',- headers: { 'content-type': 'application/json' },- body: JSON.stringify({ next: np }),- });- if (!r.ok) return null;- const j = await r.json();- if (!j || !j.url) return null;- // The endpoint does NOT honour a `next` in the body or query β it only ever- // returns `?ticket=...`. The CLI bakes the deep-link by APPENDING `&next=<path>`- // client-side, so do the same or the redeem lands on the wiki ROOT and the user- // loses the page they were on (measured: toggled to authed and landed on "/").- return j.url + '&next=' + encodeURIComponent(np);- } catch (e) { return null; }- }, nextPath);- }- if (!targetUrl) {- // v1.9.88 β AUTO-SIGN from AD's stored identity BEFORE ever asking the human. No- // already-authed pup page exists to mint from, but AD wrote the user's wiki session- // token to ~/.adom/session.json on THIS desktop, so pup mints the magic link from- // that token server-side and navigates this window straight to it. The user is asked- // for nothing; the vault profile then persists it ~30 days. Manual /login is only the- // fallback when AD itself is signed out (no token) or the ~30-day session expired.- const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return "/adom"; } })();- const _autoWho = await autoSignWikiFromDesktop(session.page, nextPath);- if (_autoWho) {- session._wikiView = "authed";- try { await setWikiModeGlyph(session.page, "β Logged in"); } catch (e) {}- try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}- session._wikiJumplistKey = undefined;- updateWikiJumplist(session, sessionId).catch(() => {});- updateThumbnailTooltip(session, sessionId).catch(() => {});- session._foreground = true;- try { await session.page.bringToFront(); } catch (e) {}- try { await osRaiseSessionWindow(session, sessionId, 'you asked for the signed-in wiki view; you were signed in automatically'); } catch (e) {}- wikiCaption(`Adom wiki: signed you in automatically${_autoWho && _autoWho !== 'authed' ? ` as ${_autoWho}` : ''}, no login needed`, 6000).catch(() => {});- console.log(`[wikiview] "${sessionId}" AUTO-SIGNED from AD desktop token (${_autoWho}) β no manual login`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, view: "authed", autoSigned: true, url: session.page.url(),- _hint: "pup signed this window into the Adom wiki AUTOMATICALLY using the identity Bridge already holds on this desktop (~/.adom/session.json) β no manual login. The shared vault profile now carries the session, so every thread's authed view works and it persists ~30 days.",- }) });- return;- }- // Fall back to the manual sign-in page (AD is signed out, or the session expired).- const loginUrl = "https://wiki.adom.inc/login?redirect=" + encodeURIComponent(nextPath);- console.log(`[wikiview] "${sessionId}" no authed session yet β navigating THIS window to the sign-in page: ${loginUrl}`);- await session.page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 25000 });- session._wikiView = "authed";- session._foreground = true;- try { await session.page.bringToFront(); } catch (e) {}- try { await osRaiseSessionWindow(session, sessionId, 'you clicked Sign in: finish the one-time wiki sign-in here'); } catch (e) {} // user clicked the task β SHOW them the sign-in page- try { await setWikiModeGlyph(session.page, "β Public"); } catch (e) {}- wikiCaption("\u{1F513} Adom wiki: finish the one-time sign-in HERE β you'll come back to your page signed in", 9000).catch(() => {});- // The hydrogen SSO often AUTO-COMPLETES: if the user's SSO session is already alive- // (John's was), /login bounces straight back to the wiki page already signed in, with- // no form to fill. In that case we must correct the glyph/tab-label/category-icon to- // the logged-in state β the caption above said "finish the sign-in" but there was- // nothing to finish. Re-check in the background (the redirect chain needs a beat) and- // flip everything if we landed authed; if the form is still showing, leave it be.- (async () => {- // Poll for up to ~90s: covers BOTH the instant SSO auto-complete AND a real- // manual sign-in (typing credentials + 2FA takes far longer than a few seconds).- // Stops the moment we detect authed; a checkWikiAuth is a cheap same-origin fetch,- // and it only returns authed once the window has redirected back to the wiki as the- // signed-in user, so this is effectively "wait until sign-in completes, then flip".- for (let i = 0; i < 45; i++) {- await new Promise(r => setTimeout(r, 2000));- if (!sessions.has(sessionId)) return; // window closed mid-sign-in β stop- let a = null; try { a = await checkWikiAuth(session.page); } catch (e) {}- if (a && a.authed) {- try { await setWikiModeGlyph(session.page, "β Logged in"); } catch (e) {}- try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}- session._wikiJumplistKey = undefined;- updateWikiJumplist(session, sessionId).catch(() => {});- updateThumbnailTooltip(session, sessionId).catch(() => {});- wikiCaption(`Adom wiki: signed in${a.user ? ` as ${a.user}` : ""} β this is now the logged-in view`, 6000).catch(() => {});- console.log(`[wikiview] "${sessionId}" SSO auto-completed on the sign-in nav β flipped to logged-in (${a.user || "authed"})`);- return;- }- }- })().catch(() => {});- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, view: "authed", signInStarted: true, loginUrl, url: session.page.url(),- _hint: "No wiki session existed yet, so pup navigated THIS window to the Adom wiki sign-in page (hydrogen SSO) and raised it. The user completes the one-time sign-in and is returned to their wiki page signed in; after that every thread's logged-in view works, because a signed-in window can mint the magic link. This is a USER action (they clicked the taskbar task), so foregrounding is expected.",- }) });- return;- }- } else {- try {- const cdp = await session.page.target().createCDPSession();- await cdp.send('Network.clearBrowserCookies');- await cdp.detach();- } catch (e) {}- targetUrl = url;- }- if (targetUrl) {- await session.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 25000 });- session._wikiView = view;- const a3 = await checkWikiAuth(session.page).catch(() => ({ authed: false }));- await setWikiAuthedState(session, sessionId, !!(a3 && a3.authed), { unknown: !!(a3 && a3.unknown) });- try { await setWikiModeGlyph(session.page, (a3 && a3.authed) ? 'β Logged in' : 'β Public'); } catch (e) {}- session._wikiJumplistKey = undefined; // the toggle's own label flips- updateWikiJumplist(session, sessionId).catch(() => {});- updateThumbnailTooltip(session, sessionId).catch(() => {});- session._foreground = true;- try { await session.page.bringToFront(); } catch (e) {}- console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE (authed=${!!(a3 && a3.authed)}) β no relaunch`);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, inPlace: true, authed: !!(a3 && a3.authed), url: session.page.url() }) });- return;- }- } catch (e) { console.log(`[wikiview] "${sessionId}" in-place switch failed (${e.message}) β falling back to relaunch`); }- }-- // v1.9.29 BUG FIX: carry EVERY tab across the switch. The old code relaunched only the- // active tab's url and closed the rest, silently destroying the user's other tabs (John- // lost a 5-tab research window to one jump-list click). Snapshot all urls + which was- // active BEFORE the teardown, then restore them into the relaunched window.- const _tabUrls = [];- let _activeIdx = 0;- try {- (session.tabs || []).forEach((t, i) => {- let u = null; try { u = t.page.url(); } catch (e) {}- if (u && !/^(about:|chrome:|edge:|devtools:)/i.test(u)) {- if (t.tabId === session.activeTabId) _activeIdx = _tabUrls.length;- _tabUrls.push(u);- }- });- } catch (e) {}- if (!_tabUrls.length) _tabUrls.push(url);- console.log(`[wiki] view toggle "${sessionId}": ${curView} β ${view} (${_tabUrls.length} tab(s), active #${_activeIdx + 1})`);- // v1.9.9: tell the user IMMEDIATELY that their click registered β the relaunch takes a- // few seconds and silence reads as "it did nothing". Replaced by the result caption below.- const _switchMsg = view === 'authed'- ? '\u{1F513} Switching to the LOGGED-IN view of the Adom wikiβ¦'- : '\u{1F4D6} Switching to the PUBLIC (logged-out) view of the Adom wikiβ¦';- // v1.9.11: OS caption (AD >=1.9.144). Fire-and-forget: the old window is torn down- // moments later, so an OS-level surface is the ONLY thing that survives the switch.- wikiCaption(_switchMsg, 30000).catch(() => {});- const oldProfile = session.profileName;- const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;- const newProfile = view === 'authed' ? DEFAULT_PROFILE : ('iso-' + sessionId + '-pub'); // v1.9.162: authed = the ONE shared jar (accumulate); public = a clean logged-out jar- // v2.0.130 (#45, traced root cause): the close-first relaunch DROPPED THE FOREGROUND LOCK.- // The moment the old window died, Windows handed foreground to the next app (the editor),- // and by raise time SetForegroundWindow was denied β so a toggle clicked on a FOREGROUND- // window came back in the background no matter how many raises we retried (2.0.57's- // delayed second raise lost the same way). CREATE-THEN-SWAP instead: launch the- // replacement under a temp id, restore all tabs, raise it WHILE the old window still- // holds foreground (same-app focus handoff is permitted), THEN close the old window and- // rename the temp session to the original id (owner, hwnd claim, files, jump list,- // AUMID). Only when the original window was actually foreground AND this is a- // USER_CALLERS click β AI-driven toggles keep background semantics. Bonus: the- // zero-window dead-air gap during the switch is gone.- let _wasForeground = false;- if (process.platform === 'win32') {- try {- const fgH = await osGetForegroundWindow();- const myH = await sessionHwnd(sessionId);- _wasForeground = !!(fgH && myH && Number(fgH) === Number(myH));- } catch (e) {}- }- const _userClickSwap = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);- if (_wasForeground && _userClickSwap) {- const tempId = sessionId + '.swap';- try {- const sNew = await launchSession(tempId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true });- for (let i = 0; i < _tabUrls.length; i++) {- if (i === _activeIdx) continue;- try { await addTabToSession(tempId, _tabUrls[i]); } catch (e) {}- }- try { const sT = sessions.get(tempId); const back = sT && sT.tabs && sT.tabs[0]; if (back) { sT.activeTabId = back.tabId; await back.page.bringToFront(); } } catch (e) {}- // Raise the replacement WHILE we still own foreground via the old window.- sNew._foreground = true;- try { await osRaiseSessionWindow(sNew, tempId, 'you clicked the taskbar view switch β handing focus to the replacement window'); } catch (e) {}- // Retire the old window (foreground stays with our replacement β same-app handoff).- for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }- session.tabs.length = 0; session.activeTabId = null;- sessions.delete(sessionId);- deleteSessionFile(sessionId);- unregisterPupAumid(_closingAppId).catch(() => {});- await killProfileBrowserIfUnused(oldProfile, 'profile steer: old window retired');- // RENAME temp -> original id: map, files, hwnd claim. The periodic brand sweep +- // title injector key off the map id, so titles/badges restamp to the original id.- sessions.delete(tempId);- sessions.set(sessionId, sNew);- try { deleteSessionFile(tempId); } catch (e) {}- try { saveSessionFile(sessionId, newProfile, (() => { try { return sNew.page.url(); } catch (e) { return url; } })()); } catch (e) {}- try { if (sNew._hwnd) persistSessionHwnd(sessionId, sNew._hwnd); } catch (e) {}- sNew._wikiView = view;- if (view === 'public') sNew._publicProfile = newProfile;- sNew._wikiJumplistView = undefined;- sNew._wikiJumplistKey = undefined;- try { await osRaiseSessionWindow(sNew, sessionId, 'finishing the view switch you clicked (create-then-swap)'); } catch (e) {}- sNew._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked switch to ${view} on a foreground window (create-then-swap)` };- try { recordAgentUpdate(sNew, 'pup_wiki_set_view', { updateNote: `wiki view β ${view} (create-then-swap, foreground preserved)` }, sNew.activeTabId); } catch (e) {}- try { flashViaAD(sessionId, {}); } catch (e) {}- try { await setWikiModeGlyph(sNew.page, view === 'authed' ? 'β Logged in' : 'β Public'); } catch (e) {}- console.log(`[wikiview] "${sessionId}" -> ${view} via CREATE-THEN-SWAP (window was foreground; replacement raised before the old one closed)`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, view, url, foregrounded: true, swap: true,- _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view. It was FOREGROUND when the user clicked, so pup used create-then-swap: the replacement was raised while the old window still held focus, then swapped in under the same id β the user never lost the window.`,- }) });- const s2 = sNew; // background cosmetics below expect s2- (async () => {- if (process.platform === 'win32') {- await new Promise(r => setTimeout(r, 1500));- try { await brandPupWindow(sessionId); } catch (e) {}- try { await applyAppOverlay(s2, sessionId); } catch (e) {}- try { await updateWikiJumplist(s2, sessionId); } catch (e) {}- }- if (view === 'authed') {- await new Promise(r => setTimeout(r, 300));- let a = null;- try { a = await checkWikiAuth(s2.page); } catch (e) {}- try { await setWikiModeGlyph(s2.page, (a && a.authed) ? 'β Logged in' : 'β Public'); } catch (e) {}- try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}- const _doneMsg = a && a.authed- ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`- : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in β sign in in this window';- wikiCaption(_doneMsg, 6000).catch(() => {});- } else {- try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}- wikiCaption('\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view β what the world sees', 6000).catch(() => {});- }- })().catch(() => {});- return;- } catch (e) {- console.log(`[wikiview] "${sessionId}" create-then-swap failed (${e.message}) β falling back to the legacy close-first relaunch`);- // Clean up a half-launched temp session so the legacy path starts clean.- try {- const sT = sessions.get(tempId);- if (sT) { for (const t of [...(sT.tabs || [])]) { try { await t.page.close(); } catch (e2) {} } sessions.delete(tempId); deleteSessionFile(tempId); }- } catch (e2) {}- // If the old window survived (launch failed before teardown), the legacy path below- // re-does the close correctly; if it did not, the close loop no-ops.- }- }- // Close the current window (mirror pup_close_window) so we can relaunch under the- // other cookie jar.- if (sessions.has(sessionId)) {- for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }- session.tabs.length = 0; session.activeTabId = null;- sessions.delete(sessionId);- deleteSessionFile(sessionId);- unregisterPupAumid(_closingAppId).catch(() => {});- await killProfileBrowserIfUnused(oldProfile, 'profile switch');- }- // Relaunch the SAME sessionId under the new profile at the same URL.- let s2 = null;- try { s2 = await launchSession(sessionId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true }); }- catch (e) { sendJSON(res, { success: false, error: `wiki view relaunch failed: ${e.message}` }); return; }- // v1.9.29: restore the OTHER tabs (in their original order) into the relaunched window.- for (let i = 0; i < _tabUrls.length; i++) {- if (i === _activeIdx) continue;- try { await addTabToSession(sessionId, _tabUrls[i]); } catch (e) {}- }- // Re-activate the tab the user was actually on.- try {- const s3 = sessions.get(sessionId);- const back = (s3 && s3.tabs && s3.tabs[0]) || null;- if (back) { s3.activeTabId = back.tabId; await back.page.bringToFront(); }- } catch (e) {}- s2._wikiView = view;- if (view === 'public') s2._publicProfile = newProfile;- s2._foreground = true;- s2._wikiJumplistView = undefined;- s2._wikiJumplistKey = undefined; // v1.9.62: the key is what gates the commit now- // v1.9.7: the user clicked a per-window taskbar task β REALLY pop it to the foreground- // (full OS SetForegroundWindow raise, not the old CDP-only maximize that left it behind- // the user's active window β the reason John "never saw the switch work"). Plus a taskbar- // flash as a belt-and-suspenders notice in case Windows' foreground lock denies the raise.- await osRaiseSessionWindow(s2, sessionId, 'you clicked the taskbar menu to switch this wiki view');- // v2.0.57 (John: "it re-opened in the bg which didn't make sense, I was in the fg"):- // one delayed second raise β the first can lose the foreground race while the new- // window is still materializing.- setTimeout(() => { try { const s4 = sessions.get(sessionId); if (s4) osRaiseSessionWindow(s4, sessionId, 'finishing the view switch you clicked').catch(() => {}); } catch (e) {} }, 2500);- // v1.9.29 BUG FIX: VERIFY the window actually came back. John clicked the toggle, the old- // window closed, captions fired, and NO window returned: the relaunch had happened but the- // window stayed unfindable/off-screen (the identity stamp logged "No visible window"),- // leaving him with nothing. A switch that cannot show its window is a FAILED switch, so- // confirm the shell can see it and self-heal before reporting success.- let _visible = false;- for (let i = 0; i < 8 && !_visible; i++) {- try {- const fr = await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});- const fraw = fr && (typeof fr.output === 'string' ? fr.output : JSON.stringify(fr.output || ''));- const fo = fraw ? JSON.parse(fraw) : {};- _visible = !!((fo.data || fo).best);- } catch (e) {}- if (!_visible) {- try { await healOffscreenWindow(s2, sessionId); } catch (e) {}- try { await osRaiseSessionWindow(s2, sessionId, 'retrying: bringing the wiki window you clicked back on-screen'); } catch (e) {}- await new Promise(r => setTimeout(r, 700 * (i + 1)));- }- }- if (!_visible) {- console.log(`[wiki] "${sessionId}" switched but the window never became visible β telling the user`);- wikiCaption('Adom wiki: the switch finished but the window did not come back on screen. Click the Adom Pup taskbar button to raise it.', 15000).catch(() => {});- }- try { flashViaAD(sessionId, {}); } catch (e) {}- s2._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked the taskbar "switch to ${view} view" task` };- try { recordAgentUpdate(s2, 'pup_wiki_set_view', { updateNote: `wiki view β ${view} (user clicked taskbar task)` }, s2.activeTabId); } catch (e) {}- // v1.9.7: set an OPTIMISTIC glyph immediately (the window is on the authed profile, and the- // common case IS logged in) so the tab reflects the switch the instant it pops, then- // RESPOND β the window is already raised. Everything below runs in the BACKGROUND so a- // click never waits on cosmetics: branding, favicon overlay, jump list, AND the auth- // RE-check that corrects the glyph once the authed cookies/SPA settle (the first check- // races the page load, so a logged-in window was briefly mislabeling "π Public" β John- // saw exactly this).- try { await setWikiModeGlyph(s2.page, view === 'authed' ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, view, url, foregrounded: true,- _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view (auth is re-confirmed a moment later; the tab label self-corrects if the ~30-day login has expired).`,- }) });- (async () => {- if (process.platform === 'win32') {- // v1.9.10: WAIT for the title suffix "(session: <id>)" to exist before branding β- // branding immediately raced the title injector and AD found "No visible window- // with title containing (session: wiki-demo)" ([badge] FAILED in the log).- await new Promise(r => setTimeout(r, 1500));- try { await brandPupWindow(sessionId); } catch (e) {}- try { await applyAppOverlay(s2, sessionId); } catch (e) {}- try { await updateWikiJumplist(s2, sessionId); } catch (e) {}- }- if (view === 'authed') {- await new Promise(r => setTimeout(r, 300));- let a = null;- try { a = await checkWikiAuth(s2.page); } catch (e) {}- try { await setWikiModeGlyph(s2.page, (a && a.authed) ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}- try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}- // v1.9.9: result caption β names WHO you're signed in as (John's ask), or says the- // one-time sign-in is still needed. Replaces the "Switchingβ¦" caption (same id).- const _doneMsg = a && a.authed- ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`- : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in β sign in in this window';- wikiCaption(_doneMsg, 6000).catch(() => {});- } else {- try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}- const _doneMsg = '\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view β what the world sees';- wikiCaption(_doneMsg, 6000).catch(() => {});- }- })().catch(() => {});- return;- }-- // --- Tab-level commands ---- case 'pup_open_tab': {- // v1.9.146: explicit sessionId wins; otherwise the CALLER THREAD's own most-recent window β- // NEVER the global activeSessionId (that is another thread's window as often as not).- const sessionId = args.sessionId || callerOwnWindow(_callerThread);- if (!sessionId || !sessions.has(sessionId)) {- const mine = _callerThread ? [...sessions].filter(([,s]) => s._ownerThread === _callerThread).map(([id]) => id) : [];- sendJSON(res, { success: false, errorCode: 'no_target_window',- error: `No window to add a tab to. ${args.sessionId ? `Session "${args.sessionId}" not found.` : (_callerThread ? `Your thread ("${_callerThread}") owns no open pup window yet.` : `No sessionId given and your thread sent no caller identity.`)} Open one with pup_open_window first (pass a sessionId you'll reuse), then pup_open_tab into it.`,- yourWindows: mine });- return;- }- // Guard: refuse to add a tab to a window owned by a DIFFERENT thread unless it was named- // explicitly (a deliberate cross-thread action), so a stray call can't contaminate.- {- const _tgt = sessions.get(sessionId);- if (_tgt && _tgt._ownerThread && _callerThread && _tgt._ownerThread !== _callerThread && !args.takeover) {- sendJSON(res, { success: false, errorCode: 'session_owned_by_another_thread',- error: `Window "${sessionId}" is owned by thread "${_tgt._ownerThread}", not "${_callerThread}". Opening a tab here would land in another agent's window. Open your OWN window (pup_open_window) or pass takeover:true if you truly mean to use theirs.` });- return;- }- }- try {- const tabId = await addTabToSession(sessionId, args.url);- const session = sessions.get(sessionId);- const tab = getTabById(session, tabId);- recordAgentUpdate(session, 'pup_open_tab', args, tabId); // v1.8.23- scheduleAutoFlash(session, { silent: !!args.silent });- updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80- // v1.8.57: long-lived-app touchpoint (see switch_tab) β heal a stranded window,- // re-badge debounced (v1.8.58: only debounce on SUCCESS so failures retry).- if (process.platform === 'win32') {- // v1.8.67: a new tab's first paint can raise its window β re-assert (force:false- // skips a user-held window).- if (!session._foreground) reassertBottomAfterNav(sessionId).catch(() => {});- healOffscreenWindow(session, sessionId).catch(() => {});- const now = Date.now();- if (!session._badgedAt || now - session._badgedAt > 600000) {- brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});- }- }- // v1.9.110 (closing the set-enumeration gap): RENDER_VERBS has four members and I had wired- // three. A tab is content on the user's screen exactly like a window, so it gets the same- // verdict β otherwise "open it in a tab" silently skips the check that "open it in a window"- // performs. When a behavior belongs to a declared SET, every member gets it.- try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}- try { await autoLoginOnLoad(res, tab.page); } catch (e) {}- try {- const _rc = res._renderCheck;- if (_rc && _rc.ok === false) console.log(`[render] "${sessionId}" tab ${tabId} BROKEN: ${_rc.signals.join(', ')}`);- } catch (e) {}- const info = await describeTab(tab, session.activeTabId);- // v1.8.23: return tabId + tabCount + activeTabId at the TOP level (was only inside `info`).- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, tabId, tabCount: session.tabs.length, activeTabId: session.activeTabId, ...info }) });- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_render_html': {- // v1.8.23: render a local HTML string in the tab via page.setContent β which- // handles encoding correctly (UTF-8), unlike a data: URL that mojibakes accented- // chars without an explicit charset. Pass {html}. Great for previewing generated- // markup without writing a temp file or fighting data:-URL escaping.- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- if (typeof args.html !== 'string') {- sendJSON(res, { success: false, error: 'pup_render_html requires an html string.', _hint: 'e.g. {"sessionId":"...","html":"<h1>CafΓ©</h1>"} β setContent renders it as UTF-8 (a data: URL would mojibake the Γ©).' });- return;- }- try {- await withTimeout(tab.page.setContent(args.html, { waitUntil: args.waitUntil || 'domcontentloaded', timeout: 20000 }), 25_000, 'page.setContent');- recordAgentUpdate(session, 'pup_render_html', args, tabId);- scheduleAutoFlash(session, { silent: !!args.silent });- let title = null; try { title = await tab.page.title(); } catch {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, title, bytes: args.html.length, _hint: 'Rendered your HTML in the tab (UTF-8, no data:-URL mojibake). Screenshot it or drive it like any page.' }) });- } catch (e) { sendJSON(res, { success: false, error: `pup_render_html failed: ${e.message}` }); }- return;- }- case 'pup_switch_tab': {- const _live = requireLiveSession(res, args);- if (!_live) return;- const { sessionId, session } = _live;- const tab = getTabById(session, args.tabId);- if (!tab) {- const available = session.tabs.map(t => t.tabId).join(', ');- sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });- return;- }- session.activeTabId = tab.tabId;- // v2.0.272 (#69, reported against 2.0.269): THIS VERB USED TO LIE ON EVERY BACKGROUND- // WINDOW. `page.bringToFront()` is the only thing that actually switches Chrome's visible- // tab, and it was gated on `session._foreground`. pup windows are background BY DEFAULT,- // so the normal case updated pup's own `activeTabId` bookkeeping, never told Chrome, and- // still returned success:true β while pup_list_tabs (which reads Chrome's real state)- // kept reporting the old tab. Two verbs disagreeing, with the honest one looking broken.- // The v1.8.26 rationale was "only RAISE THE OS WINDOW for a session the user is watching",- // which conflated two different things: activating a TAB inside a window is not the same- // as raising that window. The legitimate worry underneath is real though β Target.activate- // can pull the window forward β so do the activation ALWAYS and hand the user's focus- // straight back, exactly the one-shot handback used at window birth (v2.0.259). Never a- // timer, never a loop; the window itself is never moved.- let _fgBefore = null;- try { if (!session._foreground && process.platform === 'win32') _fgBefore = await osGetForegroundWindow(); } catch (e) {}- try { await tab.page.bringToFront(); } catch (e) {}- if (_fgBefore) {- try {- const _after = await osGetForegroundWindow();- if (_after && String(_after) !== String(_fgBefore)) {- console.log(`[switch-tab] "${sessionId}" activating ${tab.tabId} pulled the window forward β handing focus back to ${_fgBefore}`);- await chrome.abCommand('desktop_bring_to_front', { hwnd: Number(_fgBefore) }, { timeoutMs: 4000 });- }- } catch (e) {}- }- // v1.8.57: switch_tab is a long-lived app's hot path (adom-shotlog switches its- // shared window's tabs for hours) β self-heal a stranded window + re-badge here- // (debounced) so the window stays clickable/badged across bridge restarts.- if (process.platform === 'win32') {- healOffscreenWindow(session, sessionId).catch(() => {});- updateTabCountBadge(session, sessionId).catch(() => {}); // v1.9.0: overlay follows the active tab's favicon- const now = Date.now();- if (!session._badgedAt || now - session._badgedAt > 600000) {- // v1.8.58: only debounce on SUCCESS β a failed badge retries on the next call.- brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});- }- }- // v2.0.272: VERIFY, do not assert. #69's whole complaint is a verb that reports ok while- // nothing happened, so read the activation back from Chrome and say so when it did not take.- let _isActive = null;- try { _isActive = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'switch-verify') === 'visible'; } catch (e) {}- const info = await describeTab(tab, session.activeTabId);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info,- activated: _isActive,- ...(_isActive === false ? { _hint: `pup switched its OWN pointer to ${tab.tabId}, but Chrome still does not report that tab as visible. Confirm with pup_list_tabs (it reads Chrome, not pup's bookkeeping) before trusting the switch, and please report it on adom/pup-bridge β a verb that says ok while nothing moved is the exact class issue #69 closed.` } : {}) }) });- return;- }- case 'pup_close_tab': {- const _live = requireLiveSession(res, args);- if (!_live) return;- const { sessionId, session } = _live;- if (!args.tabId) {- sendJSON(res, { success: false, error: 'tabId is required for pup_close_tab' });- return;- }- const idx = session.tabs.findIndex(t => t.tabId === args.tabId);- if (idx === -1) {- const available = session.tabs.map(t => t.tabId).join(', ');- sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });- return;- }- const tab = session.tabs[idx];- try { await tab.page.close(); } catch {}- // The page.on('close', ...) handler in attachTab removes the tab from- // session.tabs, but race-proof: remove here too if still present.- const stillIdx = session.tabs.findIndex(t => t.tabId === args.tabId);- if (stillIdx !== -1) session.tabs.splice(stillIdx, 1);- if (session.activeTabId === args.tabId) {- session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;- }- updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, closedTab: args.tabId, remaining: session.tabs.length, activeTabId: session.activeTabId }) });- return;- }- case 'pup_list_tabs': {- drainWindowOpenedEvents().catch(() => {}); // v1.9.32: cheap event-driven drag-out check- const _live = requireLiveSession(res, args);- if (!_live) return;- const { sessionId, session } = _live;- const tabs = [];- for (const t of session.tabs) {- const info = await describeTab(t, session.activeTabId);- if (t.lastAgentUpdate) info.lastAgentUpdate = t.lastAgentUpdate; // v1.8.23: per-tab- tabs.push(info);- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, count: tabs.length, activeTabId: session.activeTabId, lastAgentUpdate: session._lastAgentUpdate || null, tabs }) });- return;- }-- case 'pup_alert_window': {- // Flash the taskbar button for a session's window WITHOUT foregrounding it. v1.8.35:- // {stop:true} (alias {clear:true}) CLEARS a lingering orange highlight instead.- // (Pre-1.8.35 this wrongly called bringToFront β it raised the window instead of- // flashing. Now it uses the real FlashWindowEx path, same as the auto-flash.)- const _live = requireLiveSession(res, args);- if (!_live) return;- const { sessionId, session } = _live;- try {- const stop = !!(args.stop || args.clear);- // ββ macOS ββ there is no taskbar to flash and macOS has no API to bounce ANOTHER- // app's Dock tile. The mac analog: unhide the window at its z-position (visible but- // NOT focused β the non-intrusive nudge) + a native AD toast whose click focuses it.- // {stop} dismisses the toast.- if (process.platform === 'darwin') {- const sid = session._sessionId || sessionId;- const toastId = `pup-alert-${sid}`;- let applied = false;- if (stop) {- try { const r2 = await chrome.abCommand('notify_dismiss', { id: toastId }, { timeoutMs: 5000 }); applied = !!r2; } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: false, cleared: applied, applied, clearMethod: applied ? 'toast-dismissed' : null, _hint: applied ? 'Alert toast dismissed.' : 'No toast to dismiss (or Bridge unreachable).' }) });- return;- }- try { await macSetAppHiddenByPid(macSessionPid(session), false); } catch (e) {}- try {- const r2 = await chrome.abCommand('notify_user', {- id: toastId, level: 'info', scenario: 'reminder',- title: 'A pup window wants your attention',- body: `Session "${sid}" has something to show β click to bring it up.`,- focus: { ...(await windowTarget(sessions.get(sid), sid)) },- }, { timeoutMs: 6000 });- applied = !!(r2 && r2.success !== false);- } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied, clearMethod: null, _hint: applied ? 'macOS nudge: window un-hidden (not focused) + a native toast posted β its click focuses the window. Pass {stop:true} to dismiss the toast.' : 'Window un-hidden, but the AD toast could not be posted (Bridge unreachable or notify unsupported).' }) });- return;- }- // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native).- // v2.0.243 (pup-bridge#58, real root cause): pup_alert_window is an EXPLICIT request from- // the caller β never pup's automatic machinery β so it must not be swallowed by the- // minimal-touch gate. That gate is what made the flash silently no-op on John's box- // while the response blamed the window title, which sent the investigation at the- // title/hwnd resolver instead of at the gate.- // v2.0.304 (John: "i just saw a pup window orange flash, but i thought our new setting was- // for you to not do the orange flash anymore"): in `progress` mode pup never paints orange,- // not even for an explicit alert. The nudge becomes a short full progress pulse on the- // button (fill to 100%, hold ~2.5s, clear), the same signal family the user chose.- if (!stop && taskbarActivityMode() === 'progress' && process.platform === 'win32') {- const _sid = session._sessionId || sessionId;- const _t = await windowTarget(session, _sid).catch(() => null);- if (_t) { await _pushProgress(_t, 100); setTimeout(() => { _pushProgress(_t, null).catch(() => {}); }, 2500); }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied: !!_t, method: 'progress-pulse', _hint: 'taskbarActivity is "progress", so the nudge was a 2.5s full progress pulse on the taskbar button instead of the orange flash (pup never paints orange in progress mode). Set taskbarActivity:"flash" to get the old highlight.' }) });- return;- }- const r = await flashViaAD(session._sessionId || sessionId, { stop, force: true });- let applied = false;- try { const o = r && abPayload(r); applied = !!(o && (o.ok || o.count)); } catch (e) {}- // v1.8.45: FLASHW_STOP only stops the PULSE β the Win11 attention tint persists- // until the window is ACTIVATED. So on {stop} we ALSO clear the tint for real via an- // off-screen activate-and-restore (no pop, focus handed straight back). This is the- // fix for "you said you cleared it but the button is still lit."- let clearMethod = null;- if (stop && process.platform === 'win32') {- try {- const br = browsers.get(session.profileName) && browsers.get(session.profileName).browser;- if (br && (br._detachedPid || br._cdpPort || session._hwnd)) clearMethod = await osClearFlashByActivate(br._detachedPid, br._cdpPort, session._hwnd);- } catch (e) {}- try { _flashState.delete(session._sessionId || sessionId); } catch (e) {}- }- const trulyCleared = stop && ['cleared', 'cleared-still-foreground', 'already-foreground'].includes(clearMethod);- console.log(`${stop ? 'Cleared' : 'Flashed'} taskbar for session "${sessionId}" (AD applied=${applied}, clear=${clearMethod})`);- const hint = stop- ? (trulyCleared- ? `Flash truly cleared β activated the window off-screen to drop the Win11 attention tint (${clearMethod}), then handed focus back. No pop, no focus steal.`- : `Could not fully clear (${clearMethod || 'window not found'}). FLASHW_STOP ran (applied=${applied}) but the persistent tint needs activation and that step failed.`)- : (applied ? 'Flashed the taskbar (finite 4-count) via AD, no focus steal. Pass {stop:true} to clear.'- : (r && r._skipped === 'minimal-touch'- ? 'Skipped: this desktop runs pup in MINIMAL-TOUCH mode, which suppresses pup-initiated taskbar effects. Turn it off in the dashboard Settings (or pup_configure {minimalTouch:"off"}) if you want alerts to flash.'- : 'AD desktop_flash_window could not act on this window. pup targets it by HANDLE (title tags are off by default), so this usually means the window closed or the handle went stale β pup_window_info shows the handle pup is using.'));- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: !stop, cleared: stop ? trulyCleared : false, applied, clearMethod, _hint: hint }) });- } catch (e) {- sendJSON(res, { success: false, error: `Alert failed: ${e.message}` });- }- return;- }- case 'pup_lower_os_window': {- // Send a pup window to the BACKGROUND (behind the user's windows) without- // stealing focus. It stays fully drivable + screenshottable. This is the- // "get it off the user's screen" verb β the counterpart to raise/focus.- const _live = requireLiveSession(res, args);- if (!_live) return;- const { sessionId, session } = _live;- // v1.8.47b: park on-screen at z-bottom (screenshots keep working). An explicit lower is- // a deliberate re-park, so clear the user-foreground latch too.- const be = browsers.get(session.profileName);- if (be && be.browser) be.browser._userForeground = false;- let ok;- if (process.platform === 'darwin') {- // macOS lower = hide the session's browser process (still fully drivable β CDP- // keeps rendering hidden windows). Per-process, so shared-profile siblings hide too.- ok = await macSetAppHiddenByPid(macSessionPid(session), true);- } else {- if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, sessionId); } catch (e) {} }- // v2.0.236: route through the SAME park every internal caller uses instead of the two- // legacy one-off implementations (osBackgroundWindowByPid took parameters its body no- // longer read, and osSendWindowToBack re-derived the window by profile). One park now.- const _pg = (session.tabs && session.tabs[0] && session.tabs[0].page) || session.page || null;- ok = _pg ? !!(await parkSessionWindowDirect(_pg, sessionId)) : false;- }- session._foreground = false;- session._fgWatchUntil = 0; // v1.9.260: a deliberate lower is not a "surfaced without pup" event - z-bottom keeps OS focus and the no-fault watch was mislabeling it- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', windowEtiquette: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) });- return;- }- case 'pup_raise_os_window':- case 'pup_focus_window': {- // Actually brings the Chrome window to the foreground using Win32 SetForegroundWindow- const _live = requireLiveSession(res, args);- if (!_live) return;- const { sessionId, session } = _live;- // v1.8.70: THE FOREGROUND GATE (mirrors abe's userRequestedForeground+foregroundReason- // dual key β same contract across Adom's browser surfaces). Raising steals the user's- // screen and keyboard; it is allowed ONLY as a user-requested show-me, and the caller- // must SAY SO in a recorded, auditable reason. Real incident: a thread raised a window- // 2 minutes after a background open and the only forensics were a focus-latch- // timestamp β never again.- const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;- if (!fgReason) {- sendJSON(res, {- success: false,- errorCode: 'foreground_reason_required',- error: 'Raise refused: foregroundReason is required.',- _hint: 'Foregrounding steals the user\'s screen and keyboard focus. It is allowed ONLY when the USER explicitly asked to SEE this window (a "show me", a demo, a live recording). Call again with foregroundReason:"<quote or closely paraphrase what the user said that justifies this>" (min 10 chars) β the reason is logged, recorded in lastAgentUpdate, and auditable by the user. If the user did NOT ask to see it, do NOT raise: use pup_alert_window (taskbar nudge) or rely on the auto-flash. After a granted raise: do the one thing the user asked for, then pup_lower_os_window to return it to the background.',- });- return;- }- console.log(`[foreground] "${sessionId}" raise GRANTED β reason: ${fgReason}`);- try { const _rs = sessions.get(sessionId); if (_rs) _rs._raiseGranted = true; } catch (e) {}- session._lastForeground = { ts: Date.now(), verb: command, reason: fgReason };- toastForeground(sessionId, fgReason).catch(() => {}); // v1.9.166: ping the human before/as we reveal- // v1.9.150: the reveal redraws the taskbar button; nothing re-wore the favicon, so all- // three of John's foregrounded windows lost their overlay. Re-assert it after the raise.- setTimeout(() => { updateTabCountBadge(session, args.sessionId || session._sessionId).catch(() => {}); }, 1200);- try { recordAgentUpdate(session, command, { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}- // v1.9.201 / v2.0.290: show the caller's justification (and WHICH ai-thread) on screen- // BEFORE the window comes forward, so the caption always precedes the reveal β never trails- // it. AWAITED (fire-and-forget let the raise win the race, John saw the caption ~5s late).- try { await captionForegroundReason(sessionId, fgReason, command, _callerThread); } catch (e) {}- try {- // v1.9.149 (John's audit: two of three raised windows came up 0,0 full-screen, not the- // standard frame): the reveal must present the SAME work-area-minus-10px geometry the- // park set β the old block here CDP-MAXIMIZED over it, which is exactly the sibling-path- // legacy the placement doctrine forbids. Reveal geometry == park geometry, one standard.- try {- const cdp = await session.page.target().createCDPSession();- const { windowId } = await cdp.send('Browser.getWindowForTarget');- let sc = null;- try { sc = await session.page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight })); } catch (e) {}- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });- if (sc && sc.w > 400) {- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(sc.x + 10), top: Math.round(sc.y + 10), width: Math.round(sc.w - 20), height: Math.round(sc.h - 20) } });- }- try { await cdp.detach(); } catch (e) {}- } catch (e) {}- // v1.8.23: BLUR the page's active element BEFORE raising. Raising steals the OS- // focus, and a keystroke the user was mid-typing can land in whatever input the- // pup page had focused (a form field became "John Lauer a"). With no focused- // element, a stray key has nowhere to land. (The residual SetForegroundWindow- // input-queue timing is AD-owned when AD intercepts this verb β flagged upstream.)- try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}- // First activate the tab within Chrome- await session.page.bringToFront();- activeSessionId = sessionId;- session._foreground = true; // v1.8.26: user asked to watch β allow later raises-- // v1.9.67 β RAISE BY HWND FIRST (fixes "I click and no window comes up" on Edge boxes).- //- // The PID path below is skipped whenever `process()` is null, which is EVERY detached launch on- // Windows β so on a box with no Chrome (pup falls back to Edge and caches it as the verified- // default) the only thing that ran was the CDP bounds + bringToFront above. bringToFront activates- // the TAB inside the window, not the OS window, so a window pup parked at the bottom of the- // z-order stayed buried. Reproduced on ConfRoomROG: the verb returned ok:true, logged- // "raise GRANTED", and the screen never changed. The PID fallback was Chrome-only too β it scanned- // for child processes named "chrome.exe" and would never match msedge.exe.- //- // Resolving by the "(session: <id>)" title is browser-agnostic and names the EXACT window (the old- // _detachedPid attempt raised the LAUNCHER's window). The raise stays in PowerShell rather than- // handing the hwnd to AD, because backgrounded pup windows carry WS_EX_NOACTIVATE β structurally- // unable to take focus until that style is CLEARED, which is exactly what Raise() does.- let _raisedByHwnd = false;- if (process.platform === 'darwin') {- // macOS raise = unhide the session's process + make it frontmost. The CDP- // bounds/maximize above already sized it; NSRunningApplication does the activation.- const pid = macSessionPid(session);- _raisedByHwnd = await macActivateByPid(pid);- console.log(`[foreground] "${sessionId}" mac activate pid=${pid} β ${_raisedByHwnd}`);- }- if (process.platform === 'win32') {- try {- // v1.9.220 (wiki issue #5, John, 2026-07-06): TRACK THE HANDLE, don't match the title.- // Title-matching is fragile exactly as he described β right after a navigation the new- // page sets its own title before the bridge re-appends the "(session: <id>)" suffix, so- // the lookup fails; and Chrome's PDF viewer never carries the suffix at all, which is- // what made PDF windows unraisable/unpaintable. sessionHwnd() now prefers the handle- // pup stored at park (falling back to bounds-matching, then a title lookup), so the- // raise resolves the SAME window regardless of what its title currently says.- let hwnd = await sessionHwnd(sessionId);- if (!hwnd) {- const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))}));- hwnd = fw && fw.best && fw.best.hwnd;- }- if (hwnd) {- const psPath = path.join(require('os').tmpdir(), '_adom_focus_hwnd.ps1');- fs.writeFileSync(psPath, [- ...PS_RAISE_TYPEDEF,- `[WinFocusH]::Raise([IntPtr]${hwnd})`,- "Write-Output 'focused'",- ].join('\n'), 'utf8');- const out = await runPsHiddenPromise(psPath, { timeout: 8000 });- // v1.9.68: PS reports 'focused' even when Windows IGNORED the call. SetForegroundWindow is- // subject to the FOREGROUND LOCK: a process that does not already own the foreground cannot- // steal it, so it silently no-ops (measured: log said FOCUSED, screen unchanged). Use PS only- // for what it is uniquely good at β clearing WS_EX_NOACTIVATE and un-minimizing β then let AD,- // which owns a foreground-capable path, do the real raise on the SAME hwnd.- const _styleCleared = /focused/.test(out || '');- try {- const br = abPayload(await chrome.abCommand('desktop_bring_to_front', { hwnd, state: 'restore' }, { timeoutMs: 6000 }));- _raisedByHwnd = !!(br && (br.ok === undefined || br.ok));- } catch (e) { _raisedByHwnd = false; }- console.log(`[foreground] "${sessionId}" hwnd=${hwnd} styleCleared=${_styleCleared} adRaise=${_raisedByHwnd}`);- } else { console.log(`[foreground] "${sessionId}" could not resolve an hwnd by title`); }- } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }- }-- // Get Chrome PID from the browser process- const browserProcess = browsers.get(session.profileName)?.browser?.process();- const pid = browserProcess?.pid;- if (!_raisedByHwnd && pid && process.platform === 'win32') {- // Write PowerShell script to temp file and execute (avoids quoting hell)- const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');- const psContent = [- ...PS_RAISE_TYPEDEF,- `$targetPid = ${pid}`,- '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',- 'if ($p -and $p.MainWindowHandle -ne 0) {',- ' [WinFocusH]::Raise($p.MainWindowHandle)',- " Write-Output 'focused'",- '} else {',- ' Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',- ' $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',- ' if ($cp -and $cp.MainWindowHandle -ne 0) {',- ' [WinFocusH]::Raise($cp.MainWindowHandle)',- " Write-Output 'focused'",- ' return',- ' }',- ' }',- '}',- ].join('\r\n');- fs.writeFileSync(psScript, psContent);- try {- const result = (await runPsHiddenPromise(psScript, { timeout: 8000 })).toString().trim();- console.log(`Focused session "${sessionId}" via SetForegroundWindow: ${result}`);- } catch (e) {- console.error(`SetForegroundWindow failed for session "${sessionId}":`, e.message);- }- }- // v1.9.180: a raise can land on a stuck-occlusion window (frontmost yet page-hidden β- // frame-frozen in front of the user). Check shortly after the reveal and unstick at once- // rather than waiting for the 15s sweep.- setTimeout(async () => {- try {- const s = sessions.get(sessionId);- const fg = await osGetForegroundWindow();- const hw = s ? await cachedSessionHwnd(sessionId) : null;- if (s && fg && hw) await unstickIfHiddenWhileFront(sessionId, s, fg, hw);- } catch (e) {}- }, 2000);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true, placement: 'foreground', windowEtiquette: 'foreground' }) }); // v2.0.383 (#63): canonical etiquette key- } catch (e) {- sendJSON(res, { success: false, error: `Focus failed: ${e.message}` });- }- return;- }-- // --- Existing commands (operate on active session) ---- case 'pup_launch': {- sendJSON(res, { success: false, error: 'pup_launch is removed. Use pup_open_window with required sessionId and profile params.' });- return;- }- case 'pup_screenshot': {- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- const fullPage = args.fullPage || false;- const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM;- const ts = Date.now();- // v2.0.286 (John): a capture can sometimes bring the window to the FOREGROUND (pup falling- // back to a window-level grab when the CDP path can't serve it). When that happens the user- // must be told WHY β the missing caption is exactly why uncaptioned foregrounds slipped past.- // Snapshot the OS foreground before the capture so we can detect+caption it after.- const _fgBefore = await osGetForegroundWindow().catch(() => null);- // v2.0.289: show a determinate taskbar progress bar for the length of the capture, sized to- // the running-average screenshot time (John: progress bars instead of the flash).- let screenshotBuf = null, _capturedVia = 'cdp';- try {- screenshotBuf = await withTaskbarProgress(resolvedId, 'screenshot', () => withTimeout(- // v2.0.186 (#50): captureBeyondViewport defaults TRUE in current puppeteer, which renders- // from the document ORIGIN β so a viewport screenshot ignored the page's live scroll- // position (scroll via pup_eval, screenshot still showed the top). Viewport shots now- // capture the compositor's real viewport; fullPage keeps beyond-viewport ON (it needs it).- raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })),- 20_000, 'page.screenshot'- ));- } catch (e1) {- // v2.0.386 (#80): a CDP capture on a fully OCCLUDED background window can hang until the- // compositor produces a frame (pup deliberately lets background windows throttle, #14), and- // the old path surfaced that as a bare timeout with an empty output. Two fallbacks that- // never touch OS focus, then an honest error: (1) CDP lifecycle 'active' + one retry;- // (2) an OS-level PrintWindow capture of the window (works while occluded); (3) errorCode.- if (!/timed out/i.test(String(e1 && e1.message))) throw e1;- console.log(`[screenshot] "${resolvedId}" CDP capture timed out β trying lifecycle-active retry, then OS window capture`);- try {- await pageSetLifecycleActive(tab.page);- screenshotBuf = await withTimeout(raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })), 8000, 'page.screenshot(retry)');- _capturedVia = 'cdp-after-lifecycle-active';- } catch (e2) {- let _osBuf = null;- try {- if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {} }- if (session._hwnd && !fullPage) {- const r = await chrome.abCommand('desktop_screenshot_window', { hwnd: Number(session._hwnd) }, { timeoutMs: 25000, callerThread: _callerThread || undefined, callerReason: 'pup screenshot fallback (CDP capture timed out on an occluded window)' });- const p = abPayload(r) || {};- const fp = p.fullPath || p.safePath || (r && (r.fullPath || r.safePath));- if (fp && fs.existsSync(fp)) _osBuf = fs.readFileSync(fp);- }- } catch (e) {}- if (!_osBuf) {- sendJSON(res, { success: false, errorCode: 'screenshot_timeout', error: `page.screenshot timed out (CDP), a lifecycle-active retry timed out, and the OS window capture was unavailable${fullPage ? ' (fullPage has no OS fallback)' : ''}`,- _hint: 'The window is probably fully occluded and its compositor is idle (background windows throttle on purpose, #14). pup_eval still works, so the page is fine. Options: retry in a moment; call pup_raise_os_window with a foregroundReason if the user asked to see it; or open the window with highFps:true when you need continuous frames. Read errorCode, not output: this failure used to arrive as an empty output.' });- return;- }- screenshotBuf = _osBuf; _capturedVia = 'os-window';- console.log(`[screenshot] "${resolvedId}" served by OS window capture after CDP timeouts`);- }- }- // If the capture foregrounded THIS window AND there was no recent user input (so it was pup,- // not the user clicking it), caption "<thread> Β· screenshotting" so the user knows what put it- // on their screen. No-op on the common CDP path, which never foregrounds (measured 0/101).- try {- const _hw = session && session._hwnd;- if (_hw) {- const _fgAfter = await osGetForegroundWindow().catch(() => null);- if (_fgAfter && String(_fgAfter) === String(_hw) && String(_fgAfter) !== String(_fgBefore)) {- // v2.0.293 (John: "you keep foregrounding windows without ever giving me ab captions"):- // the old idle>3000 gate assumed an ACTIVE user must have clicked the window up β but the- // active-user case is precisely when a silent foreground is most disruptive, and the fg- // flipping to OUR hwnd mid-capture is pup's doing regardless of user activity. Caption- // ALWAYS. (The idle reading is kept only for the log.)- let _idle = -1; try { _idle = await osIdleMs(); } catch (e) {}- const _thread = session.owner || session._ownerThread || 'an AI thread';- const _scapMs = captionMsFor(`${_thread} Β· screenshotting`);- pupCaption(`${_thread} Β· screenshotting`, _scapMs, 'pup-screenshot', _scapMs).catch(() => {});- console.log(`[screenshot] "${resolvedId}" came to the FOREGROUND during capture (user idle ${_idle}ms) β captioned "${_thread} Β· screenshotting"`);- }- }- } catch (e) {}- // v1.8.21: fullPage only covers DOCUMENT-level scroll. On SPAs that scroll inside- // a nested container (the document itself doesn't grow), fullPage silently returns- // just the visible viewport. Detect that and hand the caller the right tool.- let nestedScrollHint = null;- if (fullPage) {- try {- await ensureDeepHelpers(tab.page);- const ns = await tab.page.evaluate(() => {- const doc = document.scrollingElement || document.documentElement;- const docScrolls = doc.scrollHeight > doc.clientHeight + 1;- const main = window.__pupMainScroller();- const nested = !docScrolls && main !== doc && main.scrollHeight > main.clientHeight + 1;- return { docScrolls, nested, scroller: window.__pupDesc(main), scrollHeight: main.scrollHeight, clientHeight: main.clientHeight };- });- if (ns.nested) nestedScrollHint = `fullPage captured only the viewport: this page scrolls inside a NESTED container (${ns.scroller}, ${ns.scrollHeight}px of content vs ${ns.clientHeight}px visible), not the document β fullPage can't stitch that. To capture the whole thing, either pup_scroll {dy:...} through it and stitch, or (for one region) pup_screenshot_element {selector|text}. To capture a specific card, pup_screenshot_element is the one-shot fix.`;- } catch (e) {}- }-- // Downscale to keep the image under Claude's Read limit when sharp is- // available; otherwise serve at capture size (sharp is optional β see the- // processScreenshot helper). A β€1568px image is the ideal path.- const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);- const shot = await processScreenshot(screenshotBuf, maxDim);- screenshotBuf = shot.buf;-- const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);- fs.writeFileSync(filePath, screenshotBuf);- const sizeKB = Math.round(screenshotBuf.length / 1024);- session.latestShot = filePath;- const base64 = screenshotBuf.toString('base64');-- sendJSON(res, { success: true, output: JSON.stringify({- ok: true,- sizeKB,- filePath,- base64,- capturedVia: _capturedVia, // v2.0.386 (#80): cdp | cdp-after-lifecycle-active | os-window- width: shot.width,- height: shot.height,- origWidth: shot.origWidth,- origHeight: shot.origHeight,- resized: shot.resized,- ...(shot.sharpUsed ? {} : { sharpUnavailable: true, _hint: 'Screenshot served at CAPTURE size β the sharp resize library is not installed on this box, so the image was NOT downscaled and may exceed Claude\'s ~2000px Read limit. If a Read fails on size, capture a smaller viewport, or (rare) this box\'s native-dep install failed β see pup_readiness.' }),- ...(nestedScrollHint ? { nestedScroll: true, _hint: nestedScrollHint } : {}),- sessionId: resolvedId,- tabId,- }) });- return;- }- case 'pup_set_viewport': {- // v1.8.21: set the page viewport (CDP device-metrics) deterministically β- // independent of the OS window size. Fixes "innerWidth came up ~170px" and lets a- // fullPage/element shot render at a known width. width+height required.- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- // v1.9.219 (wiki issue #28, Drew Owens): the override used to PIN a window for life β it- // survived a hard reload, could not be cleared, and nothing reported it, so a window that- // silently stopped tracking OS resizing was indistinguishable from an app with broken layout- // code. That cost him a real debugging session hunting his own resize handlers. Fix 1 of 4:- // a RESET form. Pass {reset:true} (or omit width/height) to call setViewport(null), which- // clears Emulation device-metrics and hands the page back to the real OS window.- const wantsReset = args.reset === true || (args.width === undefined && args.height === undefined);- if (wantsReset) {- try {- // v2.0.365 (project-a5's report). VERIFY THE CLEAR, do not assume it. They measured a- // reset that returned ok:true with "innerWidth now 2200" β the OVERRIDE width, not the- // real one β while the override was still in force through a reload. A reset that- // reports the number it was supposed to remove is worse than a failure, because the- // caller then trusts a window that is still cropping content off the user's screen.- const _prev = session._emulatedViewport ? Number(session._emulatedViewport.width) : null;- await tab.page.setViewport(null);- // Belt and braces: puppeteer's setViewport(null) does not always reach the metrics- // override on a page that was emulated earlier in its life, so clear it at the- // protocol level too. Harmless when there is nothing to clear.- try { const _cdp = await tab.page.createCDPSession(); await _cdp.send('Emulation.clearDeviceMetricsOverride'); await _cdp.detach().catch(() => {}); } catch (e) {}- session._emulatedViewport = null;- // Measure AFTER a forced reflow, and measure the OUTER window too: outerWidth is the- // one number an emulated viewport cannot fake, so innerWidth-vs-outerWidth is how an- // AI can finally tell an emulated window from a real one (their ask (b)).- await tab.page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));- const dims = await tab.page.evaluate(() => ({- innerWidth: window.innerWidth, innerHeight: window.innerHeight,- outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio,- }));- // Verify by MEASUREMENT. The old check compared against the remembered override- // width, so on a recovered window (record wiped, override live) there was nothing to- // compare against and it passed vacuously. innerWidth vs outerWidth cannot be fooled- // that way, and it also catches the case where the record was never there at all.- const _stillEmulated = looksEmulated(dims.innerWidth, dims.outerWidth)- || (_prev && Math.abs(dims.innerWidth - _prev) <= 2);- if (_stillEmulated) {- sendJSON(res, { success: false, error: `the viewport override did not clear: the page still reports innerWidth ${dims.innerWidth}, which is the override width`,- errorCode: 'viewport_reset_failed',- output: JSON.stringify({ ok: false, sessionId: resolvedId, tabId, emulated: true, ...dims }),- _hint: `pup could not remove the emulation from this window. Close and reopen it (pup_close_window then pup_open_window) β a fresh window reports the real size. Do NOT trust in-page measurements from this window meanwhile: the page believes it is ${dims.innerWidth}px wide while the OS window is ${dims.outerWidth}px, so the user is seeing it cropped.` });- return;- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, reset: true, emulated: looksEmulated(dims.innerWidth, dims.outerWidth), ...dims,- _hint: `Viewport override CLEARED β this window tracks the real OS window again (innerWidth now ${dims.innerWidth}, dpr ${dims.devicePixelRatio}). Resizing the window will reflow the page normally.` }) });- } catch (e) { sendJSON(res, { success: false, error: `clearing the viewport override failed: ${e.message}` }); }- return;- }- const vw = Number(args.width), vh = Number(args.height);- if (!Number.isFinite(vw) || !Number.isFinite(vh)) {- sendJSON(res, { success: false, error: 'pup_set_viewport requires numeric width + height (or {reset:true} to clear an existing override).', _hint: 'Set: {"sessionId":"...","width":1280,"height":900}. Clear: {"sessionId":"...","reset":true}. Optional deviceScaleFactor (default 1).' });- return;- }- const dsf = Number(args.deviceScaleFactor) || 1;- try {- // Capture the REAL window width BEFORE emulating. outerWidth is the one number the- // override cannot fake, and it is what lets every later warning say whether the user is- // seeing dead space or seeing content cropped off their screen.- let _outerBefore = null;- try { _outerBefore = await tab.page.evaluate(() => window.outerWidth); } catch (e) {}- await tab.page.setViewport({ width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf });- if (_outerBefore) session._lastOuterWidth = _outerBefore;- // Fix 2 of 4: RECORD the override so pup_status / pup_list_windows can report it.- session._emulatedViewport = { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf };- const dims = await tab.page.evaluate(() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio }));- // Fix 3 of 4: the hint must state the LIFECYCLE, not just the benefit. The old text sold- // the feature and never said the effect is permanent for the life of the window.- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, emulated: true, realOuterWidth: _outerBefore, applied: { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf }, ...dims,- _hint: `Viewport PINNED to ${Math.round(vw)}Γ${Math.round(vh)} (innerWidth now ${dims.innerWidth}). β THIS PERSISTS: it survives navigation AND hard reload, and while it is set the window NO LONGER TRACKS OS RESIZING β a human resizing this window will see the page not reflow, which reads exactly like an app layout bug. CLEAR IT when you are done: pup_set_viewport {"sessionId":"${resolvedId}","reset":true}. It is reported as emulatedViewport in pup_status / pup_list_windows.` }) });- } catch (e) { sendJSON(res, { success: false, error: `setViewport failed: ${e.message}` }); }- return;- }- case 'pup_scroll': {- // v1.8.21: first-class scroll. Resolves the ACTUAL scrolling element (nearest- // ancestor whose scrollHeight>clientHeight, NOT just window β window.scrollTo- // no-ops on nested-scroll SPAs). Supports scroll-by-delta {dy,dx} AND scroll-to- // {toSelector|toText, deep} piercing shadow roots; returns the scroller's metrics- // so the caller can confirm it landed.- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- await ensureDeepHelpers(tab.page);- try {- const r = await tab.page.evaluate((o) => {- let target = null;- if (o.toSelector) target = window.$deep(o.toSelector);- else if (o.toText) target = window.deepText(o.toText);- let scroller;- if (o.toSelector || o.toText) {- if (!target) return { found: false };- scroller = window.__pupScrollableAncestor(target);- target.scrollIntoView({ block: o.block || 'center', inline: 'nearest', behavior: o.behavior || 'auto' });- } else {- scroller = window.__pupMainScroller();- scroller.scrollBy({ top: o.dy || 0, left: o.dx || 0, behavior: o.behavior || 'auto' });- }- return { found: true, scrollElement: window.__pupDesc(scroller), scrollTop: Math.round(scroller.scrollTop), scrollLeft: Math.round(scroller.scrollLeft), scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, atBottom: scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 };- }, { dy: Number(args.dy) || 0, dx: Number(args.dx) || 0, toSelector: args.toSelector || null, toText: args.toText || null, block: args.block || null, behavior: args.behavior || 'auto' });- if (!r.found) {- sendJSON(res, { success: false, errorCode: 'scroll_target_not_found', error: `pup_scroll: could not resolve ${args.toSelector ? 'selector "'+args.toSelector+'"' : 'text "'+args.toText+'"'} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId });- return;- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, ...r, _hint: `Scrolled ${args.toSelector||args.toText ? 'to the target' : 'by delta'} inside ${r.scrollElement} β scrollTop ${r.scrollTop}/${r.scrollHeight - r.clientHeight}${r.atBottom ? ' (at bottom)' : ''}. Screenshot to confirm, or pup_screenshot_element to grab just a card.` }) });- } catch (e) { sendJSON(res, { success: false, error: `pup_scroll failed: ${e.message}` }); }- return;- }- case 'pup_screenshot_element': {- // v1.8.21: screenshot ONE element, resolved by CSS selector OR visible text- // (piercing shadow DOM), scrolled into view first. The clean fix for "capture- // just this card" on component-heavy pages. Returns the same shape as- // pup_screenshot (base64 + filePath, downscaled for Read).- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- if (!args.selector && !args.text) {- sendJSON(res, { success: false, error: 'pup_screenshot_element requires a selector OR text.', _hint: 'e.g. {"selector":".download-card"} or {"text":"Download for your machine"}. Pierces shadow DOM by default.' });- return;- }- await ensureDeepHelpers(tab.page);- let handle;- try {- // Resolve by selector or text (shadow-piercing), then optionally climb to the- // nearest matching ancestor with `closest` β e.g. {text:"Downloadβ¦", closest:".card"}- // grabs the whole CARD, not just the text node. Returns the FIRST text match- // whose closest(sel) exists, so an ambiguous phrase (also in README prose) still- // lands on the real widget.- handle = await tab.page.evaluateHandle((sel, txt, closest) => {- let el = sel ? window.$deep(sel) : null;- if (!el && txt) {- if (closest) {- // scan all text matches, pick the first whose closest(sel) resolves- const marks = [];- const walk = (root) => { const a = root.querySelectorAll('*'); for (const e of a) {- let own = ''; for (const c of e.childNodes) if (c.nodeType === 3) own += c.textContent;- if (own.toLowerCase().indexOf(txt.toLowerCase()) !== -1) { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0) marks.push(e); }- if (e.shadowRoot) walk(e.shadowRoot); } };- walk(document);- for (const m of marks) { const c = m.closest(closest); if (c) { el = c; break; } }- } else { el = window.deepText(txt); }- } else if (el && closest) { el = el.closest(closest) || el; }- return el;- }, args.selector || null, args.text || null, args.closest || null);- const el = handle.asElement();- if (!el) {- try { await handle.dispose(); } catch {}- sendJSON(res, { success: false, errorCode: 'element_not_found', error: `pup_screenshot_element: no element matched ${args.selector ? 'selector "'+args.selector+'"' : 'text "'+args.text+'"'}${args.closest ? ' with a closest("'+args.closest+'") ancestor' : ''} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId, _hint: 'If matching by text is ambiguous (the phrase also appears in prose), pass a `selector` for the widget, or a `closest` selector for its card container.' });- return;- }- await el.evaluate((e) => e.scrollIntoView({ block: 'center', inline: 'center' }));- await new Promise(r => setTimeout(r, 150)); // let layout settle after scroll- const pad = Number(args.padding) || 0;- // Document-relative clip (getBoundingClientRect + scroll) + captureBeyondViewport β- // this is coordinate-correct even when the element is below the fold (puppeteer's- // boundingBox()/clip spaces mismatched and captured the wrong region).- const rect = await el.evaluate((e, p) => { const r = e.getBoundingClientRect();- return { x: r.left + (window.scrollX || 0) - p, y: r.top + (window.scrollY || 0) - p, width: r.width + 2 * p, height: r.height + 2 * p, vw: r.width, vh: r.height };- }, pad);- if (rect.vw < 1 || rect.vh < 1) throw new Error('element has no rendered box (hidden/zero-size)');- const rawBuf = await withTimeout(tab.page.screenshot({ type: 'png', captureBeyondViewport: true, clip: { x: Math.max(0, Math.round(rect.x)), y: Math.max(0, Math.round(rect.y)), width: Math.round(rect.width), height: Math.round(rect.height) } }), 20_000, 'element.screenshot(clip)');- try { await handle.dispose(); } catch {}- const shot = await processScreenshot(rawBuf, MAX_SCREENSHOT_DIM);- const ts = Date.now();- const filePath = path.join(SHOTS_DIR, `pup-el-${ts}.png`);- fs.writeFileSync(filePath, shot.buf);- session.latestShot = filePath;- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, matchedBy: args.selector ? 'selector' : 'text', sizeKB: Math.round(shot.buf.length / 1024), filePath, base64: shot.buf.toString('base64'), width: shot.width, height: shot.height, origWidth: shot.origWidth, origHeight: shot.origHeight, resized: shot.resized, _hint: 'Captured just that element (scrolled into view first). Read the filePath to see it.' }) });- } catch (e) {- try { if (handle) await handle.dispose(); } catch {}- sendJSON(res, { success: false, error: `pup_screenshot_element failed: ${e.message}` });- }- return;- }- case 'pup_screenshot_full_res': {- // v1.8.40+: full-resolution variant for the rare "save to disk for- // external use" case (export to wiki, hand to a designer, etc.).- // NEVER pass the resulting image to Claude via Read β full-res- // browser viewports are typically 2559x1398 (or larger on hi-DPI- // monitors), which exceeds Claude's 2000px many-image limit and- // crashes the session. For AI-readable screenshots, use- // pup_screenshot which forces a β€1568px resize.- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- const fullPage = args.fullPage || false;- const ts = Date.now();- const screenshotBuf = await withTimeout(- tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage }),- 20_000, 'page.screenshot'- );- // Probe dimensions for the warning payload β cheap. No resize, no- // recompression β the bytes that hit disk are exactly what Chrome captured.- const meta = sharp ? await sharp(screenshotBuf).metadata() : pngDimensions(screenshotBuf);- const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`);- fs.writeFileSync(filePath, screenshotBuf);- const sizeKB = Math.round(screenshotBuf.length / 1024);- session.latestShot = filePath;- const base64 = screenshotBuf.toString('base64');- sendJSON(res, {- success: true,- output: JSON.stringify({- ok: true,- sizeKB,- filePath,- base64,- width: meta.width,- height: meta.height,- sessionId: resolvedId,- tabId,- _warning: `FULL RESOLUTION (${meta.width}x${meta.height}px) β do NOT pass this image to Claude via the Read tool. Images >2000px crash many-image sessions at the Claude API limit. Use pup_screenshot instead for AI-readable screenshots (auto-resized to β€1568px). This command is for saving to disk / wiki / export only.`,- }),- });- return;- }- case 'pup_hover': {- // v1.9.198 (Bills thread): pup_hover did not exist β the only way to move the pointer- // without clicking was pup_input_dispatch {type:'move'}, which is unobvious next to- // pup_click. Hovering also reveals tooltips/menus that only appear on pointer-over.- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- try {- let px = Number(args.x), py = Number(args.y);- if (args.selector) {- const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });- px = box.x; py = box.y;- }- if (!Number.isFinite(px) || !Number.isFinite(py)) {- sendJSON(res, { success: false, errorCode: 'hover_target_missing', error: 'pup_hover needs selector, or x + y.' });- return;- }- const _hIntent = (typeof args.intent === 'string' && args.intent.trim()) ? args.intent.trim() : `Hovering ${args.selector ? `"${args.selector}"` : 'target'}`;- if (args.showCursor !== false) { try { await showPupCursor(tab.page, px, py, { click: false, label: _hIntent }); } catch (e) {} }- await withTimeout(tab.page.mouse.move(px, py, { steps: Number(args.steps) || 8 }), 10_000, 'mouse.move');- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, hovered: args.selector || { x: px, y: py },- _hint: 'Pointer moved (isTrusted) and the visible Adom cursor glided there. Tooltips/hover menus that need a real pointer-over should now be present β read them with pup_eval.' }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'hover_failed', error: `pup_hover failed: ${e.message}` });- }- return;- }- case 'pup_set_identity': {- // v1.9.343 (John: "put my Adom profile picture in there β show my adom identity"). Chrome's- // own profile chip refuses a custom local image (only a real Google sign-in gets one), so we- // show the user's Adom identity in the floating owner chip instead. This verb stores the- // avatar (a pre-circle-cropped PNG as base64, so the bridge stays image-lib-free here) + a- // display name into pup-settings, and repaints every live window's chip so it shows at once.- try {- const patch = {};- if (args.clear) { patch.identityAvatarB64 = ''; patch.identityName = ''; }- if (typeof args.avatarB64 === 'string') patch.identityAvatarB64 = args.avatarB64.replace(/^data:image\/\w+;base64,/, '');- if (typeof args.name === 'string') patch.identityName = args.name;- if (args.enabled === true || args.enabled === false) patch.ownerChip = args.enabled ? 'on' : 'off';- savePupSettings(patch);- // Repaint live windows NON-BLOCKING (each per-page evaluate is timeout-bounded inside- // applyOwnerChipSetting): remove the current chip, then re-add with the new identity if the- // chip is on. Fire-and-forget so a slow page can never make THIS verb time out.- applyOwnerChipSetting(false)- .then(() => { if (pupSettings().ownerChip === 'on') return applyOwnerChipSetting(true); })- .catch(() => {});- const s = pupSettings();- sendJSON(res, { success: true, output: JSON.stringify({ ok: true,- identityName: s.identityName || null,- hasAvatar: !!s.identityAvatarB64,- avatarBytes: s.identityAvatarB64 ? Buffer.from(s.identityAvatarB64, 'base64').length : 0,- ownerChip: s.ownerChip,- _hint: 'Adom identity set. It shows as the avatar leading the floating owner chip (bottom-left) on every pup window, applied live. Chrome\'s own profile chip cannot take a custom local image (Google sign-in only), so this is where your Adom identity lives. Turn the chip off with ownerChip:off or {enabled:false}; clear the identity with {clear:true}.' }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'set_identity_failed', error: `pup_set_identity failed: ${e.message}` });- }- return;- }- case 'pup_maximize': {- // v1.9.197 GAP 6 (Bills thread): honouring "pup windows must be maximized" required- // pup_eval + window.resizeTo(...) because desktop_set_window_state is not in every- // desktop's verb set. This does it properly via CDP Browser.setWindowBounds (the same- // channel park uses), so it works on any host and never depends on an AD verb being present.- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- try {- const cdp = await session.page.target().createCDPSession();- const { windowId } = await cdp.send('Browser.getWindowForTarget');- const want = (args.state === 'normal' || args.state === 'minimized' || args.state === 'fullscreen') ? args.state : 'maximized';- // A window must be 'normal' before another state can be applied.- try { await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } }); } catch (e) {}- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: want } });- let bounds = null;- try { ({ bounds } = await cdp.send('Browser.getWindowBounds', { windowId })); } catch (e) {}- try { await cdp.detach(); } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, state: want, bounds,- _hint: 'Window state set via CDP. This does NOT foreground the window β it stays background-parked unless you explicitly raise it (pup_raise_os_window with a foregroundReason).' }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'maximize_failed', error: `pup_maximize failed: ${e.message}` });- }- return;- }- // pup_evaluate is the catalog/manifest name; pup_eval the historical one. Accept both.- // v2.0.255 (John, adom/pup-bridge#65): this alias was written as a fallthrough label above- // `case 'pup_hover'` instead of here, so EVERY pup_evaluate call ran the HOVER handler and- // came back "pup_hover needs selector, or x + y" / hover_target_missing β an error naming a- // verb the caller never called, while the _hint stayed pup_evaluate's own (that split is the- // tell for a misdispatch, and it is worth remembering: right docs + wrong error = routing).- // It made the only verb that can READ the DOM unusable, so a page could be driven but never- // asserted on, which is exactly where it left John mid-ralph.- case 'pup_evaluate':- case 'pup_eval': {- // He probed expr/expression/script/js/code, all four failing identically because none of- // them ever reached this handler. Since the catalog did not state the arg name either,- // accept the obvious synonyms rather than making anyone guess again.- for (const _a of ['expression', 'script', 'js', 'code']) { if (!args.expr && args[_a]) args.expr = args[_a]; }- if (!args.expr) {- sendJSON(res, { success: false, errorCode: 'eval_expr_missing', error: 'Missing required parameter "expr" (aliases: "expression", "script", "js", "code"). Usage: pup_eval { "sessionId": "...", "expr": "document.title" }' });- return;- }- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- // v1.8.21: ensure the shadow-DOM helpers are present so an expr can use- // $deep(sel) / $$deep(sel) / deepText(txt) without hand-rolling a shadow walk.- await ensureDeepHelpers(tab.page);- // v1.9.197 GAP 5 (Bills thread): pup_eval only ever ran in the TOP frame, so every call- // against a login form inside an iframe had to be hand-wrapped in- // document.getElementById('logonbox').contentDocument β which only works because that frame- // happened to be SAME-ORIGIN. A cross-origin login iframe (very common) was simply- // unreachable. `frame` selects the execution context by name/id, url substring, or index;- // puppeteer talks to each frame over its own CDP session, so cross-origin works too.- let _evalCtx = tab.page;- if (args.frame !== undefined && args.frame !== null && args.frame !== '') {- const _frames = tab.page.frames();- const _fq = String(args.frame);- let _f = null;- if (/^\d+$/.test(_fq)) _f = _frames[Number(_fq)] || null;- if (!_f) _f = _frames.find(fr => { try { return fr.name() === _fq; } catch (e) { return false; } }) || null;- if (!_f) _f = _frames.find(fr => { try { return (fr.url() || '').includes(_fq); } catch (e) { return false; } }) || null;- if (!_f) {- const _avail = _frames.map((fr, i) => { let nm = '', u = ''; try { nm = fr.name(); } catch (e) {} try { u = fr.url(); } catch (e) {} return `[${i}] name=${nm || '(none)'} url=${(u || '').slice(0, 70)}`; });- sendJSON(res, { success: false, errorCode: 'frame_not_found',- error: `No frame matched "${_fq}" in session "${resolvedId}".`,- frames: _avail,- _hint: `frame matches by frame NAME, then by URL substring, then by numeric index. Frames currently in this tab:\n${_avail.join('\n')}\nNote an iframe's id attribute is NOT its frame name β use its name attribute, a distinctive piece of its src URL, or the index. Omit frame to run in the top document.` });- return;- }- _evalCtx = _f;- }- let result;- try {- result = await withTimeout(- raceCrash(tab, resolvedId, _evalCtx.evaluate(new Function('return (' + args.expr + ')'))),- 20_000, 'page.evaluate'- );- } catch (e) {- // v1.9.158: a DETACHED page handle is a recovery signal, not a result. Re-resolve the- // session's live page by window.name (the durable carrier) and retry ONCE β this is the- // armor for the detached-frame-under-load failure (two sessions went permanently- // undrivable in the 2026-07-27 heavy run because this error was returned as __error).- if (/detached Frame|Session closed|Target closed/i.test(e.message || '')) {- try {- const be2 = browsers.get(session.profileName);- let fresh = null;- if (be2 && be2.browser.isConnected()) {- for (const pg of await be2.browser.pages()) {- const wn = await pg.evaluate(() => window.name).catch(() => '');- if (wn === 'pup:' + resolvedId) { fresh = pg; break; }- }- }- if (fresh) {- tab.page = fresh;- try { fresh._pupCreated = true; } catch (e3) {}- console.log(`[recover] "${resolvedId}" page handle was detached β re-resolved live page by window.name, retrying eval`);- result = await withTimeout(fresh.evaluate(new Function('return (' + args.expr + ')')), 20_000, 'page.evaluate(retry)');- } else { result = { __error: e.message + ' (re-resolve by window.name found no live page)' }; }- } catch (e2) { result = { __error: e.message + ' (recovery retry failed: ' + e2.message + ')' }; }- } else {- result = { __error: e.message };- }- }- // v2.0.274 (#69, second half): STATEMENTS, and stop swallowing the error.- // The eval above is `new Function('return (' + expr + ')')` β EXPRESSION-only. The reporter- // sent `location.assign('...'); 'navigating'`, which is two statements, so it was a- // SyntaxError: nothing ran, the page never navigated, and the handler still answered- // ok:true with the failure buried in result.__error. Measured verbatim:- // result: {"__error": "Unexpected token ';'"} returned as a SUCCESS- // Fix 1: on a SYNTAX error, retry through CDP Runtime.evaluate, which has devtools-console- // semantics β it accepts a statement list and returns the completion value, so the same- // source works and 'navigating' comes back.- if (result && result.__error && /Unexpected token|SyntaxError|Unexpected end of input/i.test(String(result.__error))) {- let cdpSession = null;- try {- cdpSession = await _evalCtx.target().createCDPSession();- const r = await withTimeout(cdpSession.send('Runtime.evaluate', {- expression: String(args.expr), returnByValue: true, awaitPromise: true, userGesture: true,- }), 20_000, 'Runtime.evaluate');- if (r && r.exceptionDetails) {- result = { __error: (r.exceptionDetails.exception && r.exceptionDetails.exception.description) || r.exceptionDetails.text || 'evaluation threw' };- } else {- result = r && r.result ? r.result.value : null;- console.log(`[eval] "${resolvedId}" expression form was a syntax error β ran it as STATEMENTS via Runtime.evaluate (devtools-console semantics)`);- }- } catch (e) {- // A navigation started by the source tears down the context; that is the code WORKING.- if (/context was destroyed|Execution context|Target closed/i.test(e.message || '')) {- console.log(`[eval] "${resolvedId}" execution context was destroyed mid-eval β that means the page NAVIGATED, which is success, not failure`);- result = { navigated: true };- }- } finally { try { if (cdpSession) await cdpSession.detach(); } catch (e) {} }- }- // Fix 2: a failed eval is a FAILED verb. #69's whole complaint is verbs that answer ok- // while nothing happened; returning {ok:true, result:{__error}} is that exact lie.- if (result && typeof result === 'object' && result.__error) {- sendJSON(res, { success: false, errorCode: 'eval_failed', error: `pup_eval could not run your code: ${result.__error}`,- _hint: 'The expression form is tried first, then a statement list via Runtime.evaluate. If this is a syntax error, check the source; if it names a detached frame or closed target, the tab moved and pup already retried once.' });- return;- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, result, sessionId: resolvedId, tabId }) });- return;- }- // ββ Element-level input + history nav (v1.8.8) βββββββββββββββββββββββββ- // These five were advertised in bridge.json + pup_describe but had no- // router cases (found by the full verb self-test). Selector-based where it- // helps; same session/tab auto-recovery as pup_eval.- case 'pup_click': {- if (!args.selector && !(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {- sendJSON(res, { success: false, error: 'pup_click needs "selector" OR "x"+"y". Usage: { "sessionId": "...", "selector": "#submit" } or { "sessionId": "...", "x": 100, "y": 200 }' });- return;- }- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- try {- // v1.9.164: show the animated cursor gliding to the target BEFORE the real click, so the- // user can see pup is driving. Default on; {showCursor:false} skips it.- const _cursor = args.showCursor !== false;- // v1.9.197 BUG 2: build real click options so clickCount/button/delay actually apply.- const _cc = Number(args.clickCount);- const _clickOpts = {};- if (Number.isFinite(_cc) && _cc >= 1) _clickOpts.clickCount = Math.min(3, Math.round(_cc));- if (typeof args.button === 'string') _clickOpts.button = args.button;- if (Number.isFinite(Number(args.delay))) _clickOpts.delay = Number(args.delay);- // v1.9.196: the cursor LABEL must narrate WHAT is being clicked and WHY β that is the whole- // point of showing a cursor (John: "i can't even read the indicator text you're supposed to- // put in there explaining your reasoning"). Caller may pass `intent`; otherwise pup composes- // an HD-style narration from the target itself, so the label is never a bare "Adom".- const _tgt = args.selector || (args.text ? `"${args.text}"` : null);- const _intent = (typeof args.intent === 'string' && args.intent.trim())- ? args.intent.trim()- : (args.reason && String(args.reason).trim() ? String(args.reason).trim() : (_tgt ? `Clicking ${_tgt}` : 'Clicking'));- let _method = 'cdp';- if (args.selector && args.domClick === true) {- // v2.0.128 (consumer bug report #2): some React controls (heart/like/vote toggles)- // ignore a CDP "trusted" mouse click but DO fire on a synthetic element.click(). This- // is the opposite of pup's usual advice, so it's opt-in β the miss-hint below points- // callers here when a trusted click produced no state change.- _method = 'domClick';- const dr = await withTimeout(tab.page.evaluate((sel) => {- const el = document.querySelector(sel);- if (!el) return { found: false };- el.scrollIntoView({ block: 'center', inline: 'center' });- el.click();- return { found: true };- }, args.selector), 8_000, 'domClick.evaluate');- if (!dr.found) { sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_click(domClick): selector "${args.selector}" not found.`, _hint: `Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` }); return; }- } else if (args.selector) {- // v2.0.128 (consumer bug report #3): page.click(selector) re-resolves the element and- // waits on Puppeteer's visibility machinery, which crawls on huge/animated DOMs and hit- // the 15s wall β while a plain coordinate click on the SAME element returned instantly.- // So: locate the element ONCE (scroll into view, read its center), then click by those- // coordinates β the fast path the caller proved works. Only fall back to page.click when- // the box can't be resolved (0-size / not laid out yet).- let _box = null;- try {- _box = await withTimeout(tab.page.evaluate((sel) => {- const el = document.querySelector(sel);- if (!el) return null;- el.scrollIntoView({ block: 'center', inline: 'center' });- const r = el.getBoundingClientRect();- if (r.width < 1 || r.height < 1) return null;- return { x: r.x + r.width / 2, y: r.y + r.height / 2 };- }, args.selector), 6_000, 'locate.box');- } catch (e) {}- if (_box) {- if (_cursor) { await showPupCursor(tab.page, _box.x, _box.y, { label: _intent }); await new Promise(r => setTimeout(r, 440)); }- _method = 'coordinate';- await withTimeout(tab.page.mouse.click(_box.x, _box.y, _clickOpts), 10_000, 'mouse.click(box)');- } else {- // element not found/visible yet β bounded wait, then Puppeteer's own click (honours clickCount).- _method = 'page.click';- await withTimeout(tab.page.click(args.selector, _clickOpts), 12_000, 'page.click');- }- } else {- if (_cursor) { await showPupCursor(tab.page, Number(args.x), Number(args.y), { label: _intent }); await new Promise(r => setTimeout(r, 440)); }- _method = 'coordinate';- await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y), _clickOpts), 10_000, 'mouse.click');- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: args.selector || { x: Number(args.x), y: Number(args.y) }, method: _method, sessionId: resolvedId, tabId, _hint: (args.selector && _method !== 'domClick') ? 'Click dispatched (isTrusted). Verify the effect with pup_eval (a state probe) β a click that hit nothing still returns ok. If a React toggle/like/heart did NOT change state, that control ignores trusted clicks: retry pup_click {selector, domClick:true} to fire the synthetic handler.' : 'Click dispatched. Verify the effect with pup_eval (e.g. document.title or a state probe) β a click that hit nothing still returns ok.' }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'click_failed', error: `pup_click failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist/be visible. Probe it first: pup_eval {"expr":"!!document.querySelector('${args.selector}')"} β or click by x/y from a pup_screenshot.` : 'x/y click failed β coordinates are page-viewport pixels; take a pup_screenshot to aim.' });- }- return;- }- // v1.9.167 β ASSISTED FOREGROUND CLICK (John's full protocol) for surfaces pup's CDP click- // can't drive (native dialog, cross-origin FedCM chooser). Caption the reasoning, toast,- // foreground, WAIT until the user is actually on the window (alt-tab aware), then a TIGHT- // real-cursor hover->click, captioning each step. x,y = WINDOW-image pixels from a- // desktop_screenshot_window you took to find the target. reason=why, label=what.- case 'pup_assisted_click': {- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- if (!(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {- sendJSON(res, { success: false, error: 'pup_assisted_click needs window-image x+y (from a desktop_screenshot_window of this session) plus reason+label.' }); return;- }- if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'assisted click is Windows-only (uses the OS cursor + foreground detection).' }); return; }- const _reason = String(args.reason || 'Adom needs to click something on your screen.').slice(0, 180);- const _label = String(args.label || 'the highlighted control').slice(0, 80);- try {- await pupCaption(`Adom: ${_reason} I will bring this window up and WAIT until you are ready before clicking ${_label}.`, 9000);- try { await chrome.abCommand('notify_user', { title: 'Adom needs one click', body: `${_label} β ${_reason} I will wait until you are on the window.`, focus: false }, { timeoutMs: 4000 }); } catch (e) {}- session._foreground = true; session._lastForeground = { ts: Date.now(), verb: command, reason: _reason };- try { await osRaiseSessionWindow(session, resolvedId, null); } catch (e) {} // assisted_click narrates its own captions- const hwnd = await sessionHwnd(resolvedId);- // v2.0.397 (#92): the in-verb wait is capped under every ab budget; a longer human- // absence answers blocked_on_user (the caller re-issues the click when the user is back).- const _maxWait = Math.min(Number(args.maxWaitMs) || 120000, 45000);- const ready = await waitForUserReady(resolvedId, hwnd, { maxMs: _maxWait });- if (!ready) {- await pupCaption('Adom is still waiting for you to come back to this window; your AI will ask again when you are ready.', 7000);- sendJSON(res, blockedOnUser({ stillRunning: false, reason: 'user_not_ready', blockedBy: 'The user must bring the pup window to the foreground (click it or Alt-Tab to it) so the real-cursor click lands in the right window', waitedSec: Math.round(_maxWait / 1000), error: 'Waited for the user to focus the window; they did not. Nothing was clicked.', _hint: 'Not a failure: the human was not at the window. Tell them what you need clicked and where, then re-issue pup_assisted_click with the same args when they say they are ready; it waits again (up to 45s per call).' }));- return;- }- await pupCaption(`Adom is clicking ${_label} now.`, 5000);- const shot = abPayload(await chrome.abCommand('desktop_screenshot_window', { ...(await windowTarget(sessions.get(resolvedId), resolvedId)) }, { timeoutMs: 8000, caller: sessionCaller(session) }));- const shotId = shot && (shot.shotId || (shot.screenshots && shot.screenshots[0] && shot.screenshots[0].shotId));- if (!shotId) { sendJSON(res, { success: false, error: 'could not capture the window to aim the click' }); return; }- const X = Math.round(Number(args.x)), Y = Math.round(Number(args.y));- try { await chrome.abCommand('desktop_hover', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}- await new Promise(r => setTimeout(r, 900));- try { await chrome.abCommand('desktop_click', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}- await pupCaption(`Adom clicked ${_label}. Continuing.`, 5000);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: _label, x: X, y: Y, sessionId: resolvedId }), _hint: `Assisted click done after the user was confirmed on the window. Verify the effect with a screenshot before assuming it advanced.` });- } catch (e) {- sendJSON(res, { success: false, error: `assisted click failed: ${e.message}` });- }- return;- }- case 'pup_type': {- if (typeof args.text !== 'string') {- sendJSON(res, { success: false, error: 'pup_type needs "text" (string). Optional "selector" focuses the element first. Usage: { "sessionId": "...", "selector": "#q", "text": "hello" }' });- return;- }- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- try {- // v1.9.197 BUG 3 (Bills thread, top priority): pup_type ALWAYS APPENDED, and with- // modifiers broken (bug 1) and clickCount ignored (bug 2) there was NO way to replace a- // field's value at all β the caller got "adominc1adominc1" and fell back to a bash loop of- // 20 Backspaces. `clear:true` now empties the field first, using REAL key events- // (select-all then Delete) rather than setting .value, because React-controlled inputs- // ignore direct value writes β the Chase login form is exactly that case.- if (args.clear) {- if (args.selector) { await withTimeout(tab.page.focus(args.selector), 8_000, 'page.focus'); }- const _selMod = process.platform === 'darwin' ? 'Meta' : 'Control';- await tab.page.keyboard.down(_selMod);- try { await tab.page.keyboard.press('a'); } finally { try { await tab.page.keyboard.up(_selMod); } catch (e) {} }- await tab.page.keyboard.press('Delete');- }- if (args.selector) await withTimeout(tab.page.type(args.selector, args.text), 15_000, 'page.type');- else await withTimeout(tab.page.keyboard.type(args.text), 15_000, 'keyboard.type');- // v2.0.128 (consumer bug report #1 β highest priority): pup_type reported ok:true on a- // field that stayed EMPTY, so a sign-in silently no-opped while pup claimed success. Read- // the value back when we have a selector, and if what we typed did not land, still return- // ok:true (the keystrokes WERE dispatched) but flag valueLanded:false with the real reason:- // CDP/page.keyboard keystrokes only register in a FOREGROUNDED window, and this window is- // very likely in the background. Point at the two working paths.- let _valueLanded = null, _readLen = null;- if (args.selector) {- try {- _readLen = await withTimeout(tab.page.$eval(args.selector, el => {- if (el == null) return -1;- if (typeof el.value === 'string') return el.value.length;- if (el.isContentEditable) return (el.textContent || '').length;- return -2; // element exists but has no value/textContent we can read- }), 5_000, 'verify.$eval');- if (_readLen >= 0) _valueLanded = _readLen >= Math.min(args.text.length, 1);- } catch (e) { _readLen = null; }- }- const _emptyMiss = _valueLanded === false && args.text.length > 0;- const _out = { ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId };- if (args.selector) { _out.valueLanded = _valueLanded; _out.fieldValueLength = _readLen; }- _out._hint = _emptyMiss- ? 'β KEYSTROKES DISPATCHED BUT THE FIELD IS STILL EMPTY β this is the silent no-op. CDP/page.keyboard keystrokes ONLY register when the window is FOREGROUNDED, and this window is almost certainly in the background. Two fixes: (1) for a NON-React input, set the value directly β pup_eval will not (expression-only), so use pup_fill {selector,text} which sets .value via the native setter + dispatches input/change and works in the background; (2) for a login form (React-masked fields, real keystrokes required) use pup_login {username,password,realKeystrokes:true}, which foregrounds the window automatically. Do NOT trust a bare ok:true from pup_type on a login field β check valueLanded.'- : (_valueLanded === true ? 'Typed via CDP (isTrusted); value verified present in the field.' : 'Typed via CDP (isTrusted). Could not read the field back to verify (no selector, or non-standard element) β confirm with a screenshot before assuming it landed.');- sendJSON(res, { success: true, output: JSON.stringify(_out) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'type_failed', error: `pup_type failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist. Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` : 'No selector given β ensure an element has focus (pup_click it first).' });- }- return;- }- // v2.0.128 (consumer bug report #1): the BACKGROUND-safe fallback that pup_type's miss-hint- // points at. Sets the field's value via the native property setter and dispatches input +- // change so React/Vue/Angular controlled inputs pick it up β no foreground, no keystrokes.- // Works for the common "logged-out form filled while the window is in the background" case.- // For React-MASKED password fields that ignore programmatic value writes, pup_login- // {realKeystrokes:true} is still the answer (it foregrounds + sends real keys). pup_fill- // ALWAYS reads the value back and reports valueLanded so a caller never gets a false ok.- case 'pup_fill': {- if (!args.selector || typeof args.text !== 'string') {- sendJSON(res, { success: false, error: 'pup_fill needs "selector" and "text" (string). It sets .value via the native setter + fires input/change (React-safe, works in the background). Usage: { "sessionId": "...", "selector": "#email", "text": "[email protected]" }' });- return;- }- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- try {- const r = await withTimeout(tab.page.evaluate((sel, val) => {- const el = document.querySelector(sel);- if (!el) return { found: false };- // Use the prototype's native value setter so React's descriptor override doesn't swallow it.- try {- const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;- const desc = Object.getOwnPropertyDescriptor(proto, 'value');- if (desc && desc.set) desc.set.call(el, val); else el.value = val;- } catch (e) { try { el.value = val; } catch (e2) {} }- el.dispatchEvent(new Event('input', { bubbles: true }));- el.dispatchEvent(new Event('change', { bubbles: true }));- const got = (typeof el.value === 'string') ? el.value : (el.textContent || '');- return { found: true, landedLen: got.length, matched: got === val };- }, args.selector, args.text), 8_000, 'pup_fill.evaluate');- if (!r.found) {- sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_fill: selector "${args.selector}" not found.`, _hint: `Probe it: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` });- return;- }- const _landed = r.matched === true || r.landedLen >= Math.min(args.text.length, 1);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, filled: args.selector, valueLanded: _landed, fieldValueLength: r.landedLen, sessionId: resolvedId, tabId,- _hint: _landed- ? 'Set via native setter + input/change (background-safe). If a React SUBMIT still rejects it as empty, the field is masked/guarded β use pup_login {username,password,realKeystrokes:true} which foregrounds + sends real keystrokes.'- : 'β Value did not stick β this field guards against programmatic writes (React-masked password fields do this). Use pup_login {realKeystrokes:true} (foregrounds + real keys) instead.'- }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'fill_failed', error: `pup_fill failed: ${e.message}`, _hint: 'The page may have navigated. Re-check the field with pup_eval, or retry.' });- }- return;- }- case 'pup_press_key': {- if (!args.key) {- sendJSON(res, { success: false, error: 'pup_press_key needs "key" β a puppeteer KeyInput name like "Enter", "Tab", "Escape", "ArrowDown", "a". Usage: { "sessionId": "...", "key": "Enter" }' });- return;- }- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- try {- // v1.9.197 BUG 1 (Bills thread): modifiers were parsed by NOBODY β keyboard.press(key)- // ignores them entirely, so Ctrl+A typed a literal "a" into the field ("adominc1" ->- // "adominc1a") and every chord (Ctrl+A/C/V) was unusable. Hold each modifier down, press,- // then release in reverse order β the CDP-correct way to send a real chord.- const _mods = Array.isArray(args.modifiers) ? args.modifiers.filter(m => typeof m === 'string' && m.trim()) : [];- const _MODMAP = { control: 'Control', ctrl: 'Control', cmd: 'Meta', command: 'Meta', meta: 'Meta', alt: 'Alt', option: 'Alt', shift: 'Shift' };- const _norm = _mods.map(m => _MODMAP[String(m).toLowerCase()] || m);- const _bad = _norm.filter(m => !['Control', 'Meta', 'Alt', 'Shift'].includes(m));- if (_bad.length) {- sendJSON(res, { success: false, errorCode: 'bad_modifier', error: `Unknown modifier(s): ${_bad.join(', ')}.`, _hint: 'modifiers must be any of ["Control","Shift","Alt","Meta"] (aliases: ctrl, cmd, command, option accepted).' });- return;- }- for (const m of _norm) await tab.page.keyboard.down(m);- try {- await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');- } finally {- for (const m of [..._norm].reverse()) { try { await tab.page.keyboard.up(m); } catch (e) {} }- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, pressed: args.key, sessionId: resolvedId, tabId, _hint: 'Key pressed in the PAGE (isTrusted). For OS-level chords outside the page (alt+f4, ctrl+shift+p in browser chrome), use AD\'s desktop_press_key instead.' }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'press_key_failed', error: `pup_press_key failed: ${e.message}`, _hint: `"${args.key}" must be a valid puppeteer KeyInput name (case-sensitive: "Enter", "ArrowLeft", "F5", single chars like "a").` });- }- return;- }- case 'pup_back':- case 'pup_forward': {- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- const dir = command === 'pup_back' ? 'back' : 'forward';- try {- let before = null; try { before = tab.page.url(); } catch {}- // goBack/goForward resolve null on a CACHED history nav even though the URL- // DID change, so we detect "moved" by the URL actually changing, not the- // (unreliable) return value.- await withTimeout(dir === 'back' ? tab.page.goBack({ waitUntil: 'domcontentloaded' }) : tab.page.goForward({ waitUntil: 'domcontentloaded' }), 20_000, 'history.' + dir).catch(() => {});- let url = null; try { url = tab.page.url(); } catch {}- const moved = !!(url && before && url !== before);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, direction: dir, moved, url, sessionId: resolvedId, tabId, _hint: moved ? `Now at ${url}.` : `No ${dir === 'back' ? 'earlier' : 'later'} history entry β the page did not move (moved:false, not an error).` }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: dir + '_failed', error: `pup_${dir} failed: ${e.message}` });- }- return;- }- case 'pup_input_dispatch': {- // Trusted input via puppeteer's CDP-backed page.mouse / page.keyboard.- // Events fired here have isTrusted=true (Chromium dispatches them at the- // browser level via Input.dispatchMouseEvent / dispatchKeyEvent), unlike- // page-side dispatchEvent(new MouseEvent(...)) which is isTrusted=false- // and silently no-ops on framework click handlers / anti-automation gates.- //- // Types:- // click β { x, y } coords, OR { selector } resolves bounding rect- // move β { x, y } mouse move (useful for human-like trajectories)- // type β { text } types into the focused element- // key β { key } presses a single key (Enter, Escape, Tab, ArrowUp, etc.)- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }-- const t = (args.type || '').toLowerCase();- if (!t) {- sendJSON(res, { success: false, error: 'pup_input_dispatch: "type" required (click|move|type|key)' });- return;- }- try {- if (t === 'click') {- if (args.selector) {- // SMART SELECTOR PICK (v1.4.8+):- //- // Puppeteer's page.click(selector) blindly takes the first- // document-order match. That bit users on WAGO when- // `button.wg-button--primary` matched 4 elements (a 54x54- // cookie-banner X with no text, two visible Add-to-cart- // buttons, the actual Generate-datasheet button) β and the- // cookie X got clicked. The relay returned ok:true because- // a trusted click DID dispatch β just on the wrong element.- //- // We now run all matches through page.evaluate() to:- // 1. Filter out hidden elements (display:none, opacity:0,- // offscreen, zero-area, behind the topmost stacking- // context modal)- // 2. Filter out elements with empty text content (modal- // close buttons / icon-only utility buttons usually- // aren't what the caller wanted when they passed a- // class selector)- // 3. Tie-break by visual area (largest wins β the actual- // submit button beats a small icon button)- //- // The chosen element's index, rect, and visible text are- // returned in the response so the caller can sanity-check.- // Pass `firstMatch:true` to skip filtering and use legacy- // first-document-order behavior.- const matches = await tab.page.$$(args.selector);- if (matches.length === 0) {- sendJSON(res, { success: false, error: `selector not found: ${args.selector}`, _hint: 'Selector matches no element. Verify the page is loaded (pup_wait) and the selector is correct (try with pup_eval first).' });- return;- }-- let chosenHandle = matches[0];- let chosenIndex = 0;- let chosenMeta = null;- // v1.4.10: surface which DOM element won the modal-root- // detection in the response, so callers can debug false-- // positives. Stays null in the single-match / firstMatch- // branches since modal scoping doesn't apply there.- let modalRootInfo = null;-- if (matches.length === 1 || args.firstMatch === true) {- // Single match OR explicit opt-out β use document-order first.- chosenMeta = await tab.page.evaluate((el) => {- if (!el) return null;- const r = el.getBoundingClientRect();- return {- x: Math.round(r.x), y: Math.round(r.y),- w: Math.round(r.width), h: Math.round(r.height),- text: (el.textContent || '').trim().slice(0, 80),- };- }, chosenHandle);- } else {- // Multiple matches β score them and pick the best.- //- // v1.4.9+: when an open modal/dialog is on screen, restrict- // the candidate set to elements INSIDE it. The page behind- // a modal is visually inert; clicking elements behind a- // modal is almost never what the user intended. WAGO bit- // on this: their "Add to shopping cart" button (behind the- // modal) was 9648 pxΒ², the modal's "Generate data sheet"- // was 9264 pxΒ² β area-only tiebreak picked add-to-cart.- // Now we detect the modal root first and only look inside.- const evalResult = await tab.page.evaluate((selector) => {- // v1.4.10: A modal-root candidate is only "plausible" if it- // contains at least one visible interactive element with- // non-empty text. Without this filter, empty overlay- // containers (Vue-Toastification's 4 toast wrappers,- // notification mount points, full-screen ad scrims, etc.)- // win the heuristic β they're fixed/absolute, z β₯ 100, cover- // >25% of viewport β but contain no interactive content.- // Result before the fix: smart-pick scoped to the wrong- // root and missed the actual modal entirely (WAGO bug:- // pickStrategy ended up "visible-text-largest-no-modal-match"- // and clicked "Add to shopping cart" in the page behind- // the real Generate-Datasheet modal).- function isPlausibleModal(el) {- if (!el) return false;- const interactives = el.querySelectorAll(- 'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex], [onclick]'- );- for (const child of interactives) {- const cs = getComputedStyle(child);- if (cs.display === 'none' || cs.visibility === 'hidden') continue;- if (parseFloat(cs.opacity || '1') === 0) continue;- const r = child.getBoundingClientRect();- if (r.width === 0 || r.height === 0) continue;- const text = (child.textContent || '').trim();- if (text.length > 0) return true;- }- return false;- }-- // Find the topmost modal/dialog root if one is open.- // Order matches what a human visually sees:- // 1. <dialog open> (HTML5 native)- // 2. [aria-modal="true"] (ARIA)- // 3. role="dialog" + visible (ARIA fallback)- // 4. fixed/absolute element with z-index β₯ 100- // AND covering β₯25% of viewport (custom modals)- //- // v1.4.10: every tier is now post-filtered through- // isPlausibleModal so we don't latch onto empty overlays.- // Each tier returns the most-recently-added (last in- // document order) plausible candidate, walking backward- // through matches if the topmost is implausible.- function topmostModalRoot() {- const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));- for (let i = openDialogs.length - 1; i >= 0; i--) {- if (isPlausibleModal(openDialogs[i])) return openDialogs[i];- }- const ariaModals = Array.from(document.querySelectorAll('[aria-modal="true"]'))- .filter(el => {- const cs = getComputedStyle(el);- return cs.display !== 'none' && cs.visibility !== 'hidden';- });- for (let i = ariaModals.length - 1; i >= 0; i--) {- if (isPlausibleModal(ariaModals[i])) return ariaModals[i];- }- const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))- .filter(el => {- const cs = getComputedStyle(el);- if (cs.display === 'none' || cs.visibility === 'hidden') return false;- const r = el.getBoundingClientRect();- return r.width > 0 && r.height > 0;- });- for (let i = dialogs.length - 1; i >= 0; i--) {- if (isPlausibleModal(dialogs[i])) return dialogs[i];- }- // Custom modals (no ARIA, just a styled overlay)- const candidates = Array.from(document.querySelectorAll('*'))- .filter(el => {- const cs = getComputedStyle(el);- if (!['fixed', 'absolute'].includes(cs.position)) return false;- const z = parseInt(cs.zIndex || '0', 10);- if (isNaN(z) || z < 100) return false;- const r = el.getBoundingClientRect();- const viewportArea = window.innerWidth * window.innerHeight;- if (viewportArea === 0) return false;- return (r.width * r.height) / viewportArea > 0.25;- })- .sort((a, b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));- for (const c of candidates) {- if (isPlausibleModal(c)) return c;- }- return null;- }- const modalRoot = topmostModalRoot();- const modalRootInfo = modalRoot ? {- tag: modalRoot.tagName.toLowerCase(),- id: modalRoot.id || null,- cls: ((modalRoot.className && typeof modalRoot.className === 'string')- ? modalRoot.className- : ((modalRoot.className && modalRoot.className.baseVal) || '')).slice(0, 80),- z: parseInt(getComputedStyle(modalRoot).zIndex || '0', 10),- role: modalRoot.getAttribute('role') || null,- ariaModal: modalRoot.getAttribute('aria-modal') || null,- } : null;-- const els = Array.from(document.querySelectorAll(selector));- const scored = els.map((el, idx) => {- const r = el.getBoundingClientRect();- const cs = getComputedStyle(el);- const visible =- cs.display !== 'none' &&- cs.visibility !== 'hidden' &&- parseFloat(cs.opacity || '1') > 0 &&- r.width > 0 && r.height > 0 &&- r.bottom > 0 && r.right > 0 &&- r.top < (window.innerHeight || 99999) &&- r.left < (window.innerWidth || 99999);- const text = (el.textContent || '').trim();- const area = r.width * r.height;- const insideModal = modalRoot != null && modalRoot.contains(el);- return {- idx,- x: Math.round(r.x), y: Math.round(r.y),- w: Math.round(r.width), h: Math.round(r.height),- text: text.slice(0, 80),- hasText: text.length > 0,- visible,- area,- insideModal,- modalDetected: modalRoot != null,- };- });- return { scored, modalRoot: modalRootInfo };- }, args.selector);- const scored = evalResult.scored;- modalRootInfo = evalResult.modalRoot;-- // Pick priority:- // 1. inside-modal AND visible AND has-text β largest area- // 2. visible AND has-text β largest area- // 3. visible (any) β largest area- // 4. document-order first match (last resort)- const modalDetected = scored.length > 0 && scored[0].modalDetected;- const inModal = scored.filter(s => s.insideModal && s.visible && s.hasText);- const visibleTexted = scored.filter(s => s.visible && s.hasText);- const visibleOnly = scored.filter(s => s.visible);- let pick = null;- let strategy = 'visible-text-largest';- if (inModal.length > 0) {- pick = inModal.reduce((best, cur) => cur.area > best.area ? cur : best, inModal[0]);- strategy = 'modal-scoped-largest';- } else if (visibleTexted.length > 0) {- pick = visibleTexted.reduce((best, cur) => cur.area > best.area ? cur : best, visibleTexted[0]);- strategy = modalDetected ? 'visible-text-largest-no-modal-match' : 'visible-text-largest';- } else if (visibleOnly.length > 0) {- pick = visibleOnly.reduce((best, cur) => cur.area > best.area ? cur : best, visibleOnly[0]);- strategy = 'visible-largest';- }- if (pick) {- chosenIndex = pick.idx;- chosenHandle = matches[chosenIndex];- chosenMeta = pick;- chosenMeta._strategy = strategy;- } else {- // Nothing visible at all β keep document-order first as last resort- // and surface the count so caller knows ambiguity exists.- chosenMeta = scored[0];- if (chosenMeta) chosenMeta._strategy = 'fallback-first-document-order';- }- }-- // v2.0.321 (#72): scroll into view, resolve the rect, then click via the ISOLATED- // timeout-bounded CDP path (rawTrustedClick) instead of chosenHandle.click(), which- // routes through puppeteer's wedge-prone mouse lock. isTrusted=true either way.- try { await chosenHandle.scrollIntoView(); } catch (e) {}- let _box = null; try { _box = await chosenHandle.boundingBox(); } catch (e) {}- if (!_box || _box.width < 1 || _box.height < 1) { sendJSON(res, { success: false, error: 'chosen element has no visible box to click', selector: args.selector }); return; }- await rawTrustedClick(tab, Math.round(_box.x + _box.width / 2), Math.round(_box.y + _box.height / 2), { button: args.button || 'left', clickCount: args.clickCount || 1 });- sendJSON(res, { success: true, output: JSON.stringify({- ok: true,- sessionId: resolvedId,- tabId,- dispatched: 'click',- via: 'selector',- selector: args.selector,- matchedCount: matches.length,- chosenIndex,- clickedRect: chosenMeta ? { x: chosenMeta.x, y: chosenMeta.y, w: chosenMeta.w, h: chosenMeta.h } : null,- clickedText: chosenMeta ? chosenMeta.text : null,- insideModal: chosenMeta && typeof chosenMeta.insideModal === 'boolean' ? chosenMeta.insideModal : null,- modalDetected: chosenMeta && typeof chosenMeta.modalDetected === 'boolean' ? chosenMeta.modalDetected : null,- // v1.4.10: surface which DOM element won the modal-root- // detection so callers can debug false-positives. When the- // smart pick lands on the wrong element, this tells you- // immediately whether modal detection latched onto the- // intended overlay or got fooled by a toast-container etc.- modalRoot: modalRootInfo,- pickStrategy: matches.length === 1- ? 'only-match'- : (args.firstMatch === true- ? 'first-match'- : (chosenMeta && chosenMeta._strategy ? chosenMeta._strategy : 'visible-text-largest')),- })});- return;- }- if (typeof args.x !== 'number' || typeof args.y !== 'number') {- sendJSON(res, { success: false, error: 'pup_input_dispatch click: provide either {selector} OR {x, y}' });- return;- }- await rawTrustedClick(tab, args.x, args.y, { button: args.button || 'left', clickCount: args.clickCount || 1 });- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });- return;- }- if (t === 'move') {- if (typeof args.x !== 'number' || typeof args.y !== 'number') {- sendJSON(res, { success: false, error: 'pup_input_dispatch move requires {x, y}' });- return;- }- // v1.9.198 (Bills thread): a CDP move returned ok but the DRAWN cursor never budged β- // only clicks moved it, which undercuts the whole "gliding cursor" idea. Move the- // visible cursor with the real pointer (no click ripple).- if (args.showCursor !== false) { try { await showPupCursor(tab.page, Number(args.x), Number(args.y), { click: false, label: args.intent || 'Moving' }); } catch (e) {} }- await rawTrustedMove(tab, args.x, args.y);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });- return;- }- if (t === 'type') {- if (typeof args.text !== 'string') {- sendJSON(res, { success: false, error: 'pup_input_dispatch type requires {text}' });- return;- }- // Optionally focus a selector first so keystrokes land in the right input.- if (args.selector) {- try { await tab.page.focus(args.selector); }- catch (e) {- sendJSON(res, { success: false, error: `Failed to focus ${args.selector}: ${e.message}` });- return;- }- }- await withTimeout(tab.page.keyboard.type(args.text, { delay: args.delay || 0 }), 15000, 'input type');- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });- return;- }- if (t === 'key') {- if (typeof args.key !== 'string') {- sendJSON(res, { success: false, error: 'pup_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });- return;- }- await withTimeout(tab.page.keyboard.press(args.key, { delay: args.delay || 0 }), 8000, 'input key');- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });- return;- }- sendJSON(res, { success: false, error: `pup_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });- } catch (e) {- sendJSON(res, { success: false, errorCode: e && e.wedge ? 'input_channel_reset' : undefined, error: `pup_input_dispatch ${t} failed: ${e.message}`, _hint: e && e.wedge ? 'The input channel was reset after a hung page handler; the session is NOT wedged β retry the dispatch. If it keeps hanging, the page handler itself (often an unfocused-window clipboard write) is the cause.' : undefined });- }- return;- }- case 'pup_fetch_url': {- // Fetch an arbitrary URL with the session's cookies and return the- // raw bytes (base64 in JSON; CLI optionally writes to disk).- //- // Why this exists: when a vendor click spawns a popup tab whose- // Content-Type is application/pdf, Chrome wraps it in the built-in- // PDF viewer. In-page `fetch(location.href)` then returns the- // viewer's HTML wrapper (~200 KB stub), NOT the PDF binary.- // Bypass: re-issue the request via puppeteer's- // BrowserContext.request β same cookies, no rendering layer, raw- // response body. Works for any content type; particularly useful- // for PDFs, ZIPs, CAD bundles, anything the page would otherwise- // open in a viewer plugin.- //- // saveTo semantics (clarified in v1.4.9 β v1.4.8 was ambiguous):- // - This is the BRIDGE side (Windows desktop). The bridge ALWAYS- // returns bodyBase64. If the user wants the file on the Docker- // side (the common case), the CLI handles the write β see- // cli/src/commands.rs::pup_fetch_url. The arg name there- // is `saveTo` and it's interpreted as a Docker-side path.- // - For the rare case of writing directly on the DESKTOP- // filesystem (e.g. dropping a CAD bundle into the user's- // Downloads folder so they can open it locally), the CLI- // forwards `desktopSaveTo` here, which the bridge writes via- // fs.writeFileSync. Returns `desktopSavedTo` field so it's- // unambiguous which filesystem the path refers to.- //- // Args (bridge-facing):- // sessionId (required)- // url (required)- // method (optional, default GET)- // headers (optional, object β Cookie auto-included from session)- // body (optional, raw string body for POST etc.)- // desktopSaveTo (optional Windows path β write on desktop and- // return desktopSavedTo. Always also returns- // bodyBase64 so caller can verify or save- // container-side too.)- // tabId (optional β picks the cookie context; defaults- // to active tab. Cookies are per-browser-context- // so any tab in the session works.)- if (!args.url) {- sendJSON(res, { success: false, error: 'pup_fetch_url requires {url}' });- return;- }- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }-- // Helper: optionally write to the desktop filesystem; ALWAYS return- // bodyBase64 so the CLI can either pass it through to the caller OR- // base64-decode it and write to a Docker-side path.- const respondWithBytes = (buf, ct, status) => {- const out = {- ok: true,- bytes: buf.length,- contentType: ct,- status,- sessionId: resolvedId,- tabId,- bodyBase64: buf.toString('base64'),- };- if (args.desktopSaveTo) {- try {- // Resolve the path explicitly so callers can see what was used.- // Don't silently land at a quirky CWD-relative path like the- // v1.4.8 saveTo bug did.- const abs = path.resolve(args.desktopSaveTo);- const dir = path.dirname(abs);- fs.mkdirSync(dir, { recursive: true });- fs.writeFileSync(abs, buf);- out.desktopSavedTo = abs;- } catch (e) {- // Don't lose the bytes β return them in bodyBase64 plus an- // explicit error so the CLI knows the desktop write failed- // but the bytes are still recoverable container-side.- out.desktopSaveError = `desktopSaveTo write to ${args.desktopSaveTo} failed: ${e.message}`;- out.desktopSavedTo = null;- }- }- sendJSON(res, { success: true, output: JSON.stringify(out) });- };-- try {- const browserCtx = tab.page.browserContext();- const reqClient = browserCtx.request;- if (!reqClient || typeof reqClient.fetch !== 'function') {- // Fallback for older puppeteer: do an in-page fetch in a- // separate clean tab (avoids PDF viewer interception by- // navigating to about:blank first, then fetching).- const cleanPage = await browserCtx.newPage();- try {- await cleanPage.goto('about:blank');- const result = await cleanPage.evaluate(async (u, m, h, b) => {- const init = { method: m, credentials: 'include', headers: h || {} };- if (b != null) init.body = b;- const r = await fetch(u, init);- const ab = await r.arrayBuffer();- const u8 = new Uint8Array(ab);- // Convert in chunks β avoids stack overflow on large blobs- let bin = '';- const CHUNK = 32768;- for (let i = 0; i < u8.length; i += CHUNK) {- bin += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));- }- return {- base64: btoa(bin),- size: u8.length,- contentType: r.headers.get('content-type') || '',- status: r.status,- };- }, args.url, args.method || 'GET', args.headers || null, args.body || null);- await cleanPage.close({ runBeforeUnload: false });- respondWithBytes(Buffer.from(result.base64, 'base64'), result.contentType, result.status);- return;- } catch (e) {- try { await cleanPage.close({ runBeforeUnload: false }); } catch {}- throw e;- }- }-- // Modern puppeteer: BrowserContext.request.fetch β bypasses page- // rendering entirely. Cookies from the context are auto-included.- const init = {- method: args.method || 'GET',- headers: args.headers || {},- };- if (args.body != null) init.data = args.body;- const resp = await reqClient.fetch(args.url, init);- const buf = await resp.body();- const ct = (resp.headers && resp.headers()['content-type']) || '';- respondWithBytes(buf, ct, resp.status());- } catch (e) {- sendJSON(res, { success: false, error: `pup_fetch_url failed: ${e.message}`, _hint: 'If this URL requires the popup tab\'s session cookies, make sure that tab is in the same session and pass its tabId here. The bridge always returns bodyBase64; the CLI handles container-side writes via the saveTo arg.' });- }- return;- }- // v2.0.142 (John: "you need to download via the browser just like a user does β pup is about- // the browser"). A BROWSER-NATIVE download: pup drives Chrome to fetch the file exactly as a- // user clicking the button would β real session, real cookies, real anti-bot cookies, real- // Content-Disposition β and Chrome's own download manager writes it to the Downloads folder.- // NO bridge-side fetch/curl (that trips every vendor's anti-bot wall; if a caller wants curl- // it does that outside pup). Completion is confirmed via CDP Browser.downloadProgress, so the- // response carries the ACTUAL saved path + byte count, not a guess.- // selector : click THIS real download control (the authentic user action). OR- // url : synthesize an <a href=url download> in the page and real-click it (forces the- // download path even for a URL Chrome would otherwise render inline, e.g. a PDF).- case 'pup_download': {- if (!args.selector && !args.url) { sendJSON(res, { success: false, error: 'pup_download needs {selector} (a real download button to click) OR {url} (downloaded via an in-page <a download> click). Optional saveAs.' }); return; }- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- const dir = args.saveDir || (session._downloadPath) || path.join(os.homedir(), 'Downloads');- let cdp = null;- try {- cdp = await tab.page.target().createCDPSession();- // allow downloads to `dir` AND emit Browser.download* events so we can await completion.- try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}- await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir, eventsEnabled: true });- let willBegin = null;- const done = new Promise((resolve, reject) => {- const to = setTimeout(() => reject(new Error('download did not complete within 90s')), 90000);- cdp.on('Browser.downloadWillBegin', (e) => { willBegin = e; });- cdp.on('Browser.downloadProgress', (e) => {- if (e.state === 'completed') { clearTimeout(to); resolve(e); }- else if (e.state === 'canceled') { clearTimeout(to); reject(new Error('download was canceled by the page/Chrome')); }- });- });- // Trigger the download the way a user does.- if (args.selector) {- await withTimeout(tab.page.click(args.selector, {}), 12000, 'download.click').catch(async () => {- // fall back to a scroll-into-view + coordinate click if the plain click missed- const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }).catch(() => null);- if (box) await tab.page.mouse.click(box.x, box.y);- });- } else {- await tab.page.evaluate((u, nm) => {- const a = document.createElement('a'); a.href = u; if (nm) a.download = nm; else a.setAttribute('download', '');- a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);- }, args.url, args.saveAs || '');- }- const ev = await done;- // Resolve the real file: allow-mode keeps the suggested name; the guid file is renamed on complete.- let fname = (willBegin && willBegin.suggestedFilename) || (args.saveAs || '') || ('download-' + (ev.guid || 'file'));- let abs = path.join(dir, fname);- // Verify on disk; if the suggested name isn't there yet, find the newest non-.crdownload file.- let sizeOnDisk = null;- try { sizeOnDisk = fs.statSync(abs).size; } catch (e) {- try {- const recent = fs.readdirSync(dir).filter(f => !f.endsWith('.crdownload'))- .map(f => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs }))- .sort((a, b) => b.m - a.m)[0];- if (recent) { abs = path.join(dir, recent.f); fname = recent.f; sizeOnDisk = fs.statSync(abs).size; }- } catch (e2) {}- }- const magic = (() => { try { return fs.readFileSync(abs).slice(0, 5).toString('latin1').replace(/[^\x20-\x7e]/g, '.'); } catch (e) { return null; } })();- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, savedTo: abs, filename: fname, bytes: (ev.totalReceivedBytes || ev.receivedBytes || sizeOnDisk), sizeOnDisk, magic,- triggeredBy: args.selector ? ('click ' + args.selector) : ('a[download] ' + args.url),- sessionId: resolvedId, tabId,- _hint: `Chrome downloaded this to ${abs} (${sizeOnDisk != null ? sizeOnDisk + ' bytes' : 'size unread'}${magic ? ', starts "' + magic + '"' : ''}) β the real browser download, session cookies and all. To bring it into the container, pull it with Bridge file verbs (desktop_pull_file).`,- }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'download_failed', error: `pup_download failed: ${e.message}`, _hint: args.selector ? `The click may not have started a download. Verify the selector is the real download control (pup_screenshot to see it), and that this window is logged in if the file is gated.` : 'The URL did not download β for a gated file, sign in on this window first (pup_login).' });- } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }- return;- }- case 'pup_errors': {- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- const errs = [...tab.errors];- if (args.clear !== false) tab.errors.length = 0;- sendJSON(res, { success: true, output: JSON.stringify({ errors: errs, count: errs.length, sessionId: resolvedId, tabId }) });- return;- }- case 'pup_progress': {- // v2.0.193 (John: "if a thread is crawling a site through login -> component pages ->- // datasheets -> step files, it should paint the taskbar progress bar; a simple page open- // shouldn't β the AI decides, but pup has to TEACH it"). Caller-facing progress control.- // pup_progress {mode:"indeterminate", note:"crawling ti.com"} β pulsing bar- // pup_progress {mode:"value", value:35, note:"12/40 datasheets"} β determinate- // pup_progress {mode:"off"} β clear when done- const _pm = String(args.mode || (typeof args.value === 'number' ? 'value' : 'indeterminate'));- if (!['indeterminate', 'value', 'off'].includes(_pm)) { sendJSON(res, { success: false, error: 'mode must be indeterminate|value|off' }); return; }- const _pv = Math.max(0, Math.min(100, Number(args.value) || 0));- const _note = (typeof args.note === 'string' && args.note.trim()) ? args.note.trim().slice(0, 120) : null;- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }- if (!session) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }- session._explicitProgress = _pm !== 'off';- if (_pm !== 'off') { if (!session._progressSince) { session._progressSince = Date.now(); session._progressCount = (session._progressCount || 0) + 1; } }- else if (session._progressSince) { session._progressMs = (session._progressMs || 0) + (Date.now() - session._progressSince); session._progressSince = null; }- // v2.0.244 (pup-bridge#54, same root cause as #58): a caller asking for a progress bar is- // an EXPLICIT request, not pup's automatic machinery, so minimal-touch must not silently- // eat it. That gate is why "pup_progress paints nothing while a direct desktop_taskbar by- // hwnd paints" β the verb never made the call at all, and still answered ok:true. It now- // paints regardless, and the response says plainly when the desktop cannot show it.- let _progressPainted = false, _progressSkip = null;- if (process.platform !== 'win32') _progressSkip = 'this desktop is not Windows β taskbar progress bars are a Windows shell feature';- if (process.platform === 'win32') {- try {- // v2.0.195: resolve the OS window handle FIRST (same as the identity stamp) β after a- // bridge respawn the reloaded session has no hwnd yet, and windowTarget's title- // fallback is dead with titleTag off, so the progress paint silently hit nothing- // (measured: direct-by-hwnd painted the Win11 underline pill; the verb path did not).- try { if (!session._hwnd) await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {}- // v2.0.194: progress + tooltip in ONE desktop_taskbar call β a follow-up call that- // omitted the progress field was clearing the bar the first call had just set.- await chrome.abCommand('desktop_taskbar', {- ...(await windowTarget(session, resolvedId)),- progress: _pm === 'off' ? { state: 'none' } : _pm === 'value' ? { state: 'normal', value: _pv } : { state: 'indeterminate' },- ...(_note ? { thumbnailTooltip: `${session.owner || resolvedId} β ${_note}` } : {}),- }, { timeoutMs: 4000 });- try { session._lastPaintSig = null; session._appOverlay = false; session._overlayEnforceAt = 0; } catch (e) {}- _progressPainted = true;- } catch (e) { _progressSkip = `the shell call failed: ${e && e.message}`; }- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, mode: _pm, painted: _progressPainted, ...(_progressSkip ? { notPainted: _progressSkip } : {}), ...(_pm === 'value' ? { value: _pv } : {}), ...(_note ? { note: _note } : {}),- _hint: _progressSkip ? `Progress state recorded, but nothing was drawn: ${_progressSkip}.`- : _pm === 'off'- ? 'Progress bar cleared. Good hygiene: always clear when the multi-stage task finishes or aborts.'- : `Taskbar progress ${_pm === 'value' ? `at ${_pv}%` : 'pulsing (indeterminate)'} on "${resolvedId}"${_note ? ` β hover tooltip says "${_note}"` : ''}. Update value/note as stages advance; pup_progress {mode:"off"} when done. The automatic activity strip stays out of your way while this is set.` }) });- return;- }- case 'pup_reload': {- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }- catch (e) { sendTabResolveError(res, e); return; }- tab.errors.length = 0;- // v1.9.176: a CRASHED renderer can't be page.reload()'d (detached frame) β respawn it via- // raw CDP instead. This is the recovery the crash hint promised but page.reload() couldn't do.- if (tab._crashed) {- const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (e) { return null; } })() || args.url;- const ok = await respawnCrashedRenderer(tab.page, rurl);- if (ok) {- tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;- console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via respawn`);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });- } else {- console.log(`[crash] session "${resolvedId}" tab ${tabId} respawn did NOT take β advising close+reopen`);- sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn did not recover it (Chrome may be too memory-pressured to spawn a fresh renderer). Close and reopen the window: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${rurl || ''}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });- }- return;- }- // v1.9.109: keep the reload's own HTTP status β page.reload() returns the response, and this- // is the verb an AI uses right after "I fixed it", so a still-broken page must be caught here.- let _rlResp;- try {- _rlResp = await withTimeout(- tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }),- 30_000, 'page.reload'- );- } catch (e) {- // The renderer may have crashed between resolve and reload (detached frame) β fall back to respawn.- if (_isCrashedFrameError(e)) {- const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (x) { return null; } })() || args.url;- const ok = await respawnCrashedRenderer(tab.page, rurl);- tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;- console.log(`[crash] session "${resolvedId}" tab ${tabId} reload hit a crashed frame β recovered via respawn (${ok ? 'ok' : 'sent'})`);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });- return;- }- throw e;- }- try { tab.page.__pupHttpStatus = _rlResp && typeof _rlResp.status === 'function' ? _rlResp.status() : null; } catch (e) {}- // v1.8.26: the user is signalled by the auto-flash, NOT by raising the window.- // Only raise if this session is one the user is actively watching.- if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }- // v1.9.109 β PROBE ON RELOAD TOO. This was the hole: only pup_open_window probed, so a- // thread that hit a 404, fixed its dev server, called pup_reload and got ok:true with the- // GENERIC "do not assume it rendered" hint concluded "Mockup is now live again" β reporting- // success on a page nobody had checked. The verb used to confirm a fix is exactly the verb- // that must verify it.- try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}- try { await autoLoginOnLoad(res, tab.page); } catch (e) {}- try {- const _rc = res._renderCheck;- if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" STILL BROKEN after reload: ${_rc.signals.join(', ')}`);- } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) });- return;- }- case 'pup_navigate': {- {- // v1.9.195: same pre-flight as open β never navigate a window to a guaranteed-broken- // file:// URL (container path, or missing on the desktop) and leave the caller guessing.- const _pre = urlLint(args.url, res && res._callerContainer);- if (_pre && (_pre.code === 'url_file_container_path' || _pre.code === 'url_file_not_found')) {- console.log(`[urlLint] REFUSED navigate to "${args.url}" β ${_pre.code}`);- sendJSON(res, { success: false, errorCode: _pre.code, error: _pre.message, _urlLint: _pre.message, urlLintCode: _pre.code });- return;- }- }- let session, resolvedId;- // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing- // any error. The Docker container should never see "Session- // closed" / "detached Frame" β those become internal retry- // signals. See recoverOrRelaunchSession.- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- // v1.8.16: refuse a declared cross-owner navigate (window steal).- { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_navigate'); if (refusal) { sendJSON(res, refusal); return; } }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }- catch (e) { sendTabResolveError(res, e); return; }- // Apply stored basic-auth creds for the navigation target's host- // BEFORE goto, so the Chrome native auth dialog never appears- // for sites in the credential vault.- try {- const matchedHost = await credentialVault.applyCredentialsToPage(tab.page, args.url);- if (matchedHost) console.log(`[creds] applied to navigate ${resolvedId}/${tabId}: matched ${matchedHost}`);- } catch (e) {- console.log(`[creds] applyCredentialsToPage on navigate failed: ${e.message}`);- }- // v1.9.176: navigating a CRASHED renderer via page.goto throws 'detached Frame'. When the tab- // is flagged crashed (or goto hits a crashed frame), respawn via raw CDP Page.navigate to the- // requested URL β that spawns a fresh renderer in place and lands on the target page.- if (tab._crashed) {- const ok = await respawnCrashedRenderer(tab.page, args.url);- if (!ok) {- console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate respawn did NOT take β advising close+reopen`);- sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn to ${args.url} did not recover it (Chrome may be too memory-pressured). Close and reopen: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${args.url}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });- return;- }- tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;- console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate recovered a crashed renderer via respawn`);- } else {- try {- await withTimeout(- tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 25000 }),- 30_000, 'page.goto'- );- } catch (e) {- if (_isCrashedFrameError(e)) {- const ok = await respawnCrashedRenderer(tab.page, args.url);- tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;- console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate hit a crashed frame β recovered via respawn (${ok ? 'ok' : 'sent'})`);- } else {- throw e;- }- }- }- tab.errors.length = 0;- // v1.9.201: Chrome can foreground a window on its own during a navigation (local PDFs do- // this reliably). Re-assert the park unless the user has this window up on purpose.- if (!userIsUsingWindow(session)) setTimeout(() => { reassertBottomAfterNav(resolvedId).catch(() => {}); }, 1800);- // Re-apply title suffix after navigation- try {- const pName = session.profileName;- // When there's more than one tab, include tabId in the title suffix- const titleSuffix = session.tabs.length > 1- ? ` (${TITLE_LABEL}: ${resolvedId} | ${tabId})`- : (pName !== resolvedId- ? ` (${TITLE_LABEL}: ${resolvedId} | profile: ${pName})`- : ` (${TITLE_LABEL}: ${resolvedId})`);- await tab.page.evaluate((suffix, sid) => {- try { window.name = 'pup:' + sid; } catch (e) {} // v1.9.153: durable carrier on tab retitles too- // v2.0.24: no visible suffix, no observer β window.name is the carrier.- const cleaned = String(document.title || '').replace(/\s*\((?:session|ai-thread):[^)]*\)/g, '').trim();- if (cleaned !== document.title) document.title = cleaned;- }, titleSuffix, resolvedId);- } catch {}- // v1.8.26: signal via auto-flash, NOT by raising the window (that stole focus- // on every navigate). Only raise for a session the user is actively watching.- if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }- // Only persist URL for single-tab sessions β the session file is a- // per-session snapshot and multi-tab URLs can't fit.- if (session.tabs.length === 1) {- saveSessionFile(resolvedId, session.profileName, args.url);- }- if (session && process.platform === 'win32') {- setTimeout(() => { applyAppOverlay(session, resolvedId).catch(() => {}); }, 1500); // v1.8.92: new site, new favicon- // v1.9.43: second pass β many sites inject their <link rel=icon> after first paint.- setTimeout(() => { if (session) session._faviconRetried = false; applyAppOverlay(session, resolvedId).catch(() => {}); }, 4500);- // v1.9.1: on an Adom nav, re-check auth (login may have completed since open) and- // refresh the tab label so it becomes "π Logged in" once actually authenticated.- // v1.9.103: a navigation replaces the document, so re-assert the thread tag (cheap, and- // evaluateOnNewDocument already re-runs it at document-start; this covers the live page).- try { if (session && session._threadTag) setTitleDecor(session.page, { threadTag: session._threadTag }).catch(() => {}); } catch (e) {}- if (isAdomUrl(args.url)) {- setTimeout(async () => {- try {- const a = await checkWikiAuth(session.page);- // v1.9.93: an indeterminate probe must not repaint the glyph OR flip the identity.- if (!(a && a.unknown)) {- await setWikiModeGlyph(session.page, a && a.authed ? '\u25CF Logged in' : '\u25CB Public');- await setWikiAuthedState(session, resolvedId, a && a.authed, { unknown: false });- }- } catch (e) {}- }, 1800);- }- // v1.9.3: refresh the per-window wiki jump list (adds the toggle on a wiki page,- // clears it when the window navigated off Adom).- setTimeout(() => { updateWikiJumplist(session, resolvedId).catch(() => {}); }, 1000);- }- if (tab._crashed) { tab._crashed = false; delete tab._crashedAt; delete tab._crashReason; console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via navigate`); }- // v1.9.109: probe on navigate as well β same reasoning as reload.- try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}- try { await autoLoginOnLoad(res, tab.page); } catch (e) {}- try {- const _rc = res._renderCheck;- if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" BROKEN after navigate to ${args.url}: ${_rc.signals.join(', ')}`);- } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: args.url, sessionId: resolvedId, tabId, _next: threadNextBlock(), ...(authSteerHint(args.url) ? { _hint: authSteerHint(args.url).trim() } : {}) }) });- return;- }- case 'pup_install_toolbar': {- // v2.0.53: nothing to install anymore β on CfT the toolbar is a launch arg. This verb- // reports the state so agents can explain it.- const cft = (chrome && chrome.readiness) ? chrome.readiness() : {};- const staged = stageToolbarExtension();- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, mode: 'launch-flag', stagedAt: staged,- driving: cft.browserKind || null,- active: !!staged && cft.browserKind === 'chrome-for-testing',- _hint: staged ? (cft.browserKind === 'chrome-for-testing' ? 'Toolbar auto-loads on every profile launch (CfT --load-extension).' : `Driving browser is ${cft.browserKind}; the toolbar flag only works on Chrome for Testing β pup_use {browser:"cft"} to enable.`) : 'toolbarExtension is off in settings (or non-Windows).',- }) });- return;- }- case 'pup_floaty_click': {- // v2.0.69 (John: "tool the clicking so we can ralph this weekly forever"): first-class- // simulated click on the floaty pencil. Fires the SAME host click handler a mouse does- // (host.click() -> onclick -> flag -> sweep harvest -> annotate), so the ralph exercises- // the full production chain without CDP input-simulation flakiness. Honest errors: floaty- // absent means the setting is off or the page never got the injection.- const sessionId = args.sessionId;- const _lv = requireLiveSession(res, { ...args, sessionId });- if (!_lv) return;- const session = _lv.session;- const tab = (args.tabId && (session.tabs || []).find(t => t.tabId === args.tabId)) || (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];- if (!tab || !tab.page) { sendJSON(res, { success: false, error: 'no tab' }); return; }- try {- const r = await tab.page.evaluate(() => {- const h = document.getElementById('__pup_floaty');- if (!h) return { clicked: false, reason: 'floaty not present (tbFloatyPencil off, or page not injected)' };- h.click();- window.__pupFloatyClick = null; // consumed here: the verb arms the watcher itself- return { clicked: true };- });- if (r.clicked) { session._annotateArmed = Date.now(); startAnnotation(session, sessionId, 'user-floaty', { tool: 'pen', tabId: tab.tabId }).catch(() => {}); }- sendJSON(res, { success: !!r.clicked, output: JSON.stringify(r), ...(r.clicked ? {} : { error: r.reason }) });- } catch (e) { sendJSON(res, { success: false, error: e.message }); }- return;- }- case 'pup_annotate': {- // start/cancel the human annotation overlay on a window's active tab. Jump-list clicks- // arrive as user-taskbar-menu (ownership-gate exempt); threads annotate their OWN windows.- const sessionId = args.sessionId;- const _lv = requireLiveSession(res, { ...args, sessionId });- if (!_lv) return;- const session = _lv.session;- try {- if (args.action === 'cancel') {- const tab0 = (session.tabs || [])[0];- if (tab0 && tab0.page) await tab0.page.evaluate(() => { const w = document.getElementById('__pup_annotate'); if (w) w.remove(); window.__pupAnnotate = null; });- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cancelled: true }) });- return;- }- await startAnnotation(session, sessionId, _callerThread, { tool: args.tool === 'snip' ? 'snip' : 'pen', mode: args.mode, tabId: args._resolvedTabId || null });- // v2.0.293 (John: "you just foregrounded a pup window and never showed an ab caption"):- // the old unconditional raise claimed a jump-list click is user-sanctioned by- // construction β TRUE only for USER callers (jump list / toolbar). An AI THREAD calling- // pup_annotate got the same screen-steal, and a scripted annotate workflow re-raised- // the window on every arm. Now: USER callers get the raise (they clicked, they are- // waiting for the window); AI callers arm the overlay in the BACKGROUND and must- // foreground explicitly via pup_raise_os_window + foregroundReason (the reason-gated,- // captioned path) if the user should draw now.- const _userCaller = _callerThread === 'user-taskbar-menu' || _callerThread === 'user-toolbar';- if (_userCaller) {- session._foreground = true; session._userActiveAt = Date.now();- try { const t0 = (session.tabs || [])[0]; if (t0 && t0.page) await t0.page.bringToFront(); } catch (e) {}- try { await osRaiseSessionWindow(session, sessionId, 'you clicked Annotate: draw on the page, then hit Done'); } catch (e) {}- }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, started: true, raised: _userCaller, _hint: _userCaller ? 'Overlay is live: pen/arrow/box/text, color dot cycles, Done captures (drawing stays visible so the owning AI thread\'s next screenshot shows it), \u2716 cancels.' : 'Overlay is armed but the window stays in the BACKGROUND (AI-called annotate never steals the screen). If the user should draw NOW, call pup_raise_os_window with a foregroundReason: that captions the reason first, then foregrounds.' }) });- } catch (e) { sendJSON(res, { success: false, error: `annotate failed: ${e.message}` }); }- return;- }- case 'pup_dashboard': {- // v1.9.231 (John): the dashboard ALWAYS gets its OWN pup window, never a tab in someone- // else's, so it carries its own taskbar icon (its served icon.svg) and can be found and- // closed independently. Fixed sessionId + its own durable profile, so repeat calls REUSE- // the one window instead of spawning duplicates. Opens in the background like any pup- // window: it is a monitor, and monitors must not steal the screen.- const dashUrl = `http://127.0.0.1:${PORT}/dashboard`;- // v2.0.178 (John: "when I click the jump list to open the dashboard, open it FOREGROUND β- // it was a human action, not an AI action"). Exactly right: the background-always rule- // exists so AIs never steal the screen, but a jump-list / toolbar click IS the user asking- // to see it β backgrounding it just makes the click feel broken. USER_CALLERS is the- // human-surface marker (taskbar menu, toolbar, pill), so those callers get the window- // raised; AI callers keep the background default + raise-with-reason path.- const _humanClick = USER_CALLERS.has(_callerThread);- const _humanReason = 'you clicked "Open pup dashboard" in the taskbar menu';- try {- const existing = sessions.get('pup-dashboard');- if (existing) {- try { await existing.page.goto(dashUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch (e) {}- if (_humanClick) { try { await osRaiseSessionWindow(existing, 'pup-dashboard', _humanReason); } catch (e) {} }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: true, foregrounded: _humanClick,- _hint: _humanClick- ? `The pup dashboard was already open β refreshed and RAISED to the foreground (a human clicked the jump-list task, so no background etiquette applies).`- : `The pup dashboard is already open in its own window (session "pup-dashboard") and was refreshed. It is BACKGROUNDED like any pup window; raise it with pup_raise_os_window + a foregroundReason if the user asked to see it.` }) });- return;- }- const _dashSess = await launchSession('pup-dashboard', dashUrl, { profile: 'pup-dashboard', owner: 'pup-dashboard', foreground: _humanClick });- if (_humanClick) { try { await osRaiseSessionWindow(_dashSess || sessions.get('pup-dashboard'), 'pup-dashboard', _humanReason); } catch (e) {} }- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: false, foregrounded: _humanClick,- _hint: `Opened the live pup dashboard in its OWN window (session "pup-dashboard", its own profile so it has its own taskbar icon). It shows every window pup is driving, grouped taskbar button -> window -> tabs, with each tab's favicon, the owning AI thread, foreground/background plus the reason given to foreground, highFps lease, uptime, and how each overlay icon was chosen. Updates live over SSE. ${_humanClick ? 'FOREGROUNDED (human click on a user surface).' : 'Backgrounded by default.'}` }) });- } catch (e) {- sendJSON(res, { success: false, errorCode: 'dashboard_open_failed', error: `could not open the dashboard: ${e.message}` });- }- return;- }- case 'pup_deps': {- // v1.9.218 (issue #15): report dependency health so "pup is broken" can be answered without- // reading the spawn log. selfInstalled:true means the bridge repaired itself this boot.- let resolved = false; try { require.resolve('puppeteer'); resolved = true; } catch (e) {}- sendJSON(res, { success: true, output: JSON.stringify({- ok: resolved, depsPresent: resolved,- selfInstalledThisBoot: !!(_depState && _depState.installed),- lastError: (_depState && _depState.error) || null,- dir: __dirname,- _hint: resolved- ? (_depState && _depState.installed- ? 'Dependencies were MISSING at boot and pup self-installed them (wiki issue #15: the source-only zip ships no node_modules and AD\'s install did not run, or a respawn wiped it). No human npm install is needed.'- : 'Dependencies present. Normal state.')- : 'Dependencies MISSING and self-install failed β pup cannot drive a browser. Ensure node + npm are on PATH for the AD process, then bridge_kill + bridge_resume to retry the self-install.',- }) });- return;- }- case 'pup_readiness': {- // READ-ONLY readiness probe. NEVER triggers a download. `ready` reflects both- // server-up (this verb answered) AND Chrome-for-Testing cached. For the general- // "what do I have / is it ready" question prefer AD's `bridge_readiness` (it also- // reports AD-core's Tier-1 prewarm progress); this verb is pup's own detailed view.- const rd = chrome.readiness();- const availTxt = (rd.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';- const defTxt = rd.defaultBrowser- ? ` Default: ${rd.defaultBrowser.kind}${rd.defaultBrowser.forced ? ' (pinned)' : ' (auto)'}${rd.defaultBrowser.pendingInstall ? ', downloading' : ''}.`- : ' Default: auto (first browser that launches).';- const switchTxt = ' Switch/install with pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.';- // v2.0.383 (#59/#64): the fast progress verb. active[] = one row per in-flight phase (this is- // what animates a bar); progress = the download's own frame while it runs.- const _act = activeProgress();- const _dl = rd.installing ? { phase: 'download', stepLabel: 'Downloading Chrome for Testing', percent: rd.installProgressPct || 0, elapsedSec: rd.downloadElapsedSec, estimatedSec: (rd.downloadElapsedSec != null && rd.downloadEtaSec != null) ? rd.downloadElapsedSec + rd.downloadEtaSec : null, etaSec: rd.downloadEtaSec, confidence: rd.downloadEtaSec != null ? 'measured' : 'estimated', startKind: 'cold' } : undefined;- sendJSON(res, {- success: true,- output: JSON.stringify({ ...rd, active: _act, progress: _dl }),- ...rd,- active: _act,- progress: _dl,- _hint: rd.ready- ? `Ready β pup will drive ${rd.browserKind} (${rd.browserSource}) at ${rd.browserExecutablePath}. Available: ${availTxt}.${defTxt} No download needed. Proceed with pup_open_window.${switchTxt} RECOVERY NOTE: if you were polling this because pup's verbs were TIMING OUT (dim LED in AD / browser.bridgeRunning:false), that means the bridge PROCESS was down β a status probe like this does NOT respawn it; only a window/tab verb (pup_open_window) auto-spawns the bridge. And never SPAM pup_open_window while it says "bridge_starting" β concurrent opens on one profile used to collide (fixed v1.9.4, but still: fire ONE open, then poll pup_list_windows with backoff).`- : rd.installing- ? `Fetching Chrome for Testing (${rd.installProgressPct}%) β this box had no Chrome/Edge. Poll this verb (or bridge_readiness) until ready:true, then pup_open_window. Faster: have the user install Chrome or Edge (Edge ships with Windows) and pup uses it instantly.`- : rd.installPhase === 'failed'- ? `No Chrome/Edge on this box and the Chrome-for-Testing fallback FAILED: ${rd.lastError}. Have the user install Chrome or Edge (fastest, no download), or retry pup_prewarm (usually network/proxy blocking the download, or low disk).`- : `No Chrome/Edge detected and no cached Chrome for Testing. Available: ${availTxt}. Have the user install Chrome or Edge (Edge ships with Windows β instant, no download), or call pup_use {browser:"cft"} / pup_prewarm to fetch Chrome for Testing.`,- });- return;- }- case 'pup_import_scan': {- // READ-ONLY discovery pass for the comprehensive credential import (adom-you seed).- // Enumerates EVERY profile of EVERY Chromium browser (Chrome/Edge/Brave) on this box- // with: source account email(s) (the identity tag β Preferences.account_info[].email),- // saved-password count (parsed from the Login Data SQLite via the desktop's python,- // NEVER a byte-scan β see dev-skills/pup-browser-import), Login Data size, and ABE- // status. It DECRYPTS NOTHING β decryption needs the Bridge-core elevated SYSTEM helper- // (adom/adom-bridge #51, desktop_decrypt_pup_credentials). This verb is the- // foundation the first-run prompt + Settings "Import logins" picker read.- if (process.platform !== 'win32') {- sendJSON(res, { success: true, output: JSON.stringify({ profiles: [] }), profiles: [], _hint: 'Credential import discovery is Windows-only (Chromium ABE / DPAPI). No profiles enumerated on this platform.' });- return;- }- const scanPs = [- "$ErrorActionPreference='SilentlyContinue'",- "$local = $env:LOCALAPPDATA",- "$browsers = @(",- " @{ id='chrome'; name='Google Chrome'; root=(Join-Path $local 'Google\\Chrome\\User Data') },",- " @{ id='edge'; name='Microsoft Edge'; root=(Join-Path $local 'Microsoft\\Edge\\User Data') },",- " @{ id='brave'; name='Brave'; root=(Join-Path $local 'BraveSoftware\\Brave-Browser\\User Data') }",- ")",- "$py = $null",- "foreach($c in @('python','python3','py')){ $g = (Get-Command $c -ErrorAction SilentlyContinue); if($g){ $py=$g.Source; break } }",- "$pyLines = @(",- " 'import sqlite3,sys',",- " 'c=sqlite3.connect(sys.argv[1])',",- " 'try:',",- " ' n=c.execute(\"select count(*) from logins where blank_password=0\").fetchone()[0]',",- " 'except Exception:',",- " ' n=c.execute(\"select count(*) from logins\").fetchone()[0]',",- " 'print(n)'",- ") -join [char]10",- "$codeFile = $null",- "if($py){ $codeFile = Join-Path $env:TEMP ('pup_ldcount_' + [guid]::NewGuid().ToString('N') + '.py'); Set-Content -LiteralPath $codeFile -Value $pyLines -Encoding ASCII }",- "$out = @()",- "foreach($b in $browsers){",- " if(-not (Test-Path $b.root)){ continue }",- " $abe=$false; $legacy=$false",- " $lsPath = Join-Path $b.root 'Local State'",- " if(Test-Path $lsPath){ try { $ls = Get-Content -Raw -LiteralPath $lsPath | ConvertFrom-Json; if($ls.os_crypt.app_bound_encrypted_key){ $abe=$true }; if($ls.os_crypt.encrypted_key){ $legacy=$true } } catch {} }",- " $profs = @()",- " $d = Get-Item -LiteralPath (Join-Path $b.root 'Default') -ErrorAction SilentlyContinue; if($d){ $profs += $d }",- " $profs += (Get-ChildItem -LiteralPath $b.root -Directory -Filter 'Profile *' -ErrorAction SilentlyContinue)",- " foreach($pf in $profs){",- " if(-not $pf){ continue }",- " $prefPath = Join-Path $pf.FullName 'Preferences'",- " if(-not (Test-Path $prefPath)){ continue }",- " $emails=@(); $pname=$pf.Name",- " try { $pref = Get-Content -Raw -LiteralPath $prefPath | ConvertFrom-Json; if($pref.profile.name){ $pname = $pref.profile.name }; if($pref.account_info){ foreach($a in $pref.account_info){ if($a.email){ $emails += $a.email } } } } catch {}",- " $ldPath = Join-Path $pf.FullName 'Login Data'",- " $pwCount=$null; $ldBytes=0",- " if(Test-Path $ldPath){",- " $ldBytes = (Get-Item -LiteralPath $ldPath).Length",- " if($py -and $codeFile){",- " $tmp = Join-Path $env:TEMP ('pup_ld_' + [guid]::NewGuid().ToString('N') + '.db')",- " try { Copy-Item -LiteralPath $ldPath -Destination $tmp -Force; $r = & $py $codeFile $tmp 2>$null; if($r){ $pwCount = [int]($r | Select-Object -First 1) } } catch {}",- " Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue",- " }",- " }",- " $out += [pscustomobject]@{ browser=$b.id; browserName=$b.name; profileDir=$pf.Name; profileName=$pname; accounts=@($emails); passwordCount=$pwCount; loginDataBytes=$ldBytes; abe=$abe; legacyKey=$legacy }",- " }",- "}",- "if($codeFile){ Remove-Item -LiteralPath $codeFile -Force -ErrorAction SilentlyContinue }",- "[pscustomobject]@{ pythonAvailable=[bool]$py; profiles=@($out) } | ConvertTo-Json -Depth 6 -Compress"- ].join('\n');- const scanPath = path.join(require('os').tmpdir(), '_pup_import_scan.ps1');- let scan = { pythonAvailable: false, profiles: [] };- try {- fs.writeFileSync(scanPath, scanPs);- const raw = (await runPsHiddenPromise(scanPath, { timeout: 25000 }) || '').trim();- if (raw) scan = JSON.parse(raw);- } catch (e) { console.log('[import-scan] failed:', e && e.message); }- const profiles = Array.isArray(scan.profiles) ? scan.profiles : (scan.profiles ? [scan.profiles] : []);- const totalPw = profiles.reduce((s, p) => s + (p.passwordCount || 0), 0);- const accts = [...new Set(profiles.flatMap(p => p.accounts || []))];- const anyAbe = profiles.some(p => p.abe);- sendJSON(res, {- success: true,- output: JSON.stringify({ profiles, totalPasswords: totalPw, accounts: accts }),- profiles,- totalPasswords: totalPw,- accounts: accts,- pythonAvailable: !!scan.pythonAvailable,- _hint: profiles.length- ? `Found ${profiles.length} browser profile(s) across ${new Set(profiles.map(p=>p.browser)).size} browser(s), ~${totalPw} saved password(s), identities: ${accts.join(', ') || 'none tagged'}.${anyAbe ? ' These are App-Bound-Encrypted (v20) β a USER process (this bridge) canNOT decrypt them.' : ''} This verb is DISCOVERY ONLY. The actual decrypt is AD's desktop_decrypt_pup_credentials (AD >= 1.9.220): the dashboard "Import logins" button calls it LOCALLY (bridge β AD direct API), the user approves one consent dialog + one UAC prompt, and pup merges the identity-tagged rows into the adom-you vault (credential_vault.js) and drops the plaintext. ${scan.pythonAvailable ? '' : 'NOTE: python not found on PATH, so passwordCount is null β only file presence/size known. '}Next: open the Settings "Import logins from Chrome/Edge" picker, which reads this list.`- : `No Chromium browser profiles with a Preferences file found under %LOCALAPPDATA% (Chrome/Edge/Brave). Nothing to import.`,- });- return;- }- // v2.0.143 (John: "import from my native browsers via ab's credential verb β that i don't- // think ever worked β and get it into your stored credential system"). It DID work, but only- // from the dashboard's "Import logins" button; there was no AI-callable verb, so a thread- // couldn't seed pup's vault from the user's real Chrome/Edge. This verb runs the whole flow:- // scan β call AD's SYSTEM-elevated decrypt LOCALLY (the user approves one consent + one UAC- // on the desktop; a relayed call is refused, which is why pup β a LOCAL bridge β must be the- // one to ask) β merge the identity-tagged rows into the keychain vault β DROP the plaintext.- // Returns COUNTS ONLY, never a password. hostFilter narrows the merge to matching hosts (a- // surgical import, e.g. just the vendor sites); omit to import everything the user has saved.- case 'pup_import_browser_logins': {- if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'Browser-login import is Windows-only (Chromium ABE/DPAPI).' }); return; }- // 1) scan for profiles (reuse the discovery verb over loopback).- let scan = {};- try {- const sr = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_import_scan', args: {} }) });- const sj = await sr.json().catch(() => ({}));- try { scan = JSON.parse(sj.output); } catch (e) { scan = sj; }- } catch (e) {}- const allProfiles = Array.isArray(scan.profiles) ? scan.profiles : [];- const wantBrowsers = Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [...new Set(allProfiles.map(p => p.browser))];- const browsers = wantBrowsers.filter(Boolean);- if (!browsers.length) { sendJSON(res, { success: false, error: 'No Chromium browser profiles with saved passwords were found to import from.' }); return; }- // 2) the SYSTEM-elevated decrypt, called LOCALLY (consent + UAC prompt on the desktop).- console.log(`[import-verb] decrypt requested for browsers=[${browsers.join(', ')}] β AD helper, consent + UAC will prompt on the desktop`);- let resp;- try { resp = await decryptBrowserCreds({ browsers, include: ['passwords'] }); }- catch (e) { sendJSON(res, { success: false, errorCode: 'decrypt_call_failed', error: 'decrypt helper call failed: ' + (e && e.message || e) }); return; }- const payload = abPayload(resp) || {};- const ec = payload.errorCode || (resp && resp.errorCode);- if (ec) {- const emap = {- relay_forbidden: 'AD refused the decrypt as a relayed call β this should not happen from pup (a local bridge). Please report it.',- consent_declined: 'You declined the Adom Bridge consent dialog on the desktop. Nothing was read.',- elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',- helper_missing: 'The signed decrypt helper is not installed β update/repair Adom Bridge.',- };- sendJSON(res, { success: false, errorCode: ec, error: emap[ec] || ('Decrypt failed: ' + ec) });- return;- }- let pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];- if (!payload.ok && !pwRows.length) { sendJSON(res, { success: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') }); return; }- // 3) optional surgical filter by host substring(s), then merge into the vault.- const filters = (typeof args.hostFilter === 'string' ? [args.hostFilter] : (Array.isArray(args.hostFilter) ? args.hostFilter : [])).map(s => String(s).toLowerCase()).filter(Boolean);- let rows = pwRows;- if (filters.length) rows = pwRows.filter(r => { const h = String((r && (r.host || r.url || r.origin)) || '').toLowerCase(); return filters.some(f => h.includes(f)); });- let merge = { added: 0, updated: 0, skipped: 0 };- try { merge = await credentialVault.mergeImportedCredentials(rows); }- catch (e) { sendJSON(res, { success: false, error: 'vault merge failed: ' + (e && e.message || e) }); return; }- // 4) DROP all plaintext immediately.- try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; pwRows = []; } catch (e) {}- const st = payload.stats || {};- console.log(`[import-verb] merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped${filters.length ? ` (filtered to: ${filters.join(', ')})` : ''}`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,- hostFilter: filters.length ? filters : null,- stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed },- _hint: `Imported ${merge.added + merge.updated} login(s) into the adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''})${filters.length ? `, filtered to host(s) matching [${filters.join(', ')}]` : ''}. pup can now auto-sign-in as you on those sites: navigate a pup window to one and it fills + (with submitReason) submits. Passwords were dropped from memory; pup_credentials lists host+username only.`,- }) });- return;- }- case 'pup_prewarm': {- // Install Chrome for Testing WITHOUT opening a window β the warm-before-- // first-use path. Default non-blocking so AD/HD can fire-and-forget on- // embedded first-run; pass {wait:true} to block until installed.- const block = !!(args && (args.wait === true || args.block === true));- await chrome.ensureChromeReady({ background: !block });- // Prewarm = the AI explicitly wants Chrome for Testing β pin it as the default- // (unless a different browser was already explicitly pinned). Once cached, every- // open drives CfT. Pass {setDefault:false} to only download without pinning.- const pinCft = args && args.setDefault === false ? false : true;- const curDef = chrome.readDefault();- if (pinCft && !(curDef && curDef.forced && curDef.kind !== 'chrome-for-testing')) {- chrome.setDefaultBrowser({ browser: 'cft' });- }- const rd = chrome.readiness();- sendJSON(res, {- success: rd.installPhase !== 'failed',- output: JSON.stringify(rd),- ...rd,- statusVerb: 'pup_readiness',- _hint: rd.ready- ? `Chrome for Testing is ready${pinCft ? ' and pinned as the default' : ''} β pup will drive it on the next pup_open_window. (pup_use {browser:"auto"} reverts to installed Chrome/Edge.)`- : rd.installing- ? `Downloading Chrome for Testing (${rd.installProgressPct}%)${pinCft ? '; it is pinned as the default so every open uses it once ready' : ''}. Poll pup_readiness until ready:true. No window is opened by prewarm.`- : rd.installPhase === 'failed'- ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk. pup still works with installed Chrome/Edge β pup_use {browser:"auto"}.`- : 'Prewarm started; poll pup_readiness for completion.',- });- return;- }- // ANSWER a blocking native dialog (v2.0.344). This is the verb the dialog_open error- // points at. It is deliberately a SEPARATE, explicit call rather than an auto-answer:- // a beforeunload is a question about the user's unsaved work, and pup answering it on the- // AI's behalf would silently destroy data in one direction or the other.- case 'pup_dialog': {- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- // Fall back to whatever window actually holds it: a modal blocks the whole browser, so the- // caller commonly targets the window they were driving rather than the one that raised it.- let pending = findPendingDialog(session);- let holder = resolvedId;- if (!pending) { const any = findAnyPendingDialog(); if (any) { pending = any; holder = any.sessionId; } }- if (!pending) {- sendJSON(res, {- status: 'ok', handled: false, dialog: null,- _hint: 'No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else.',- });- return;- }- const action = (args && args.action) || '';- if (action !== 'accept' && action !== 'dismiss') {- sendJSON(res, {- status: 'error', errorCode: 'dialog_action_required',- error: `a ${pending.type} dialog is open; pass action:"accept" or action:"dismiss"`,- dialog: { type: pending.type, message: pending.message, defaultValue: pending.defaultValue },- _hint: 'accept = press the confirming button (Reload / OK). dismiss = press Cancel. pup will not choose for you.',- });- return;- }- const holdSession = sessions.get(holder) || session;- const tab = holdSession.tabs.find(t => t && t.pendingDialog);- try {- if (action === 'accept') await pending.dialog.accept(args && args.promptText !== undefined ? String(args.promptText) : undefined);- else await pending.dialog.dismiss();- } catch (e) {- if (tab) tab.pendingDialog = null; // a stale handle cannot be answered twice- sendJSON(res, { status: 'error', error: `could not answer the dialog: ${e.message}`, errorCode: 'dialog_answer_failed' });- return;- }- if (tab) tab.pendingDialog = null;- sendJSON(res, {- status: 'ok', handled: true, action, window: holder,- dialog: { type: pending.type, message: pending.message },- _hint: `Answered "${action}" on window "${holder}". EVERY pup window is unblocked now, not just that one, and the verb that timed out can be retried.`,- });- return;- }-- case 'pup_use': {- // Pin (or clear) the browser pup drives by default. browser: 'chrome' | 'edge'- // | 'cft' | 'auto' (or pass an explicit executablePath). 'cft' fetches Chrome- // for Testing in the background if not cached, then pins it. Persists across- // bridge restarts. The launch path still spawn-verifies + falls back, so a- // pinned browser that later breaks won't hard-fail an open.- const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install), elevate: args ? args.elevate : undefined, temporary: !!(args && args.temporary) });- const rd = chrome.readiness();- const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';- let hint;- if (!result.ok) {- hint = `${result.error} Available on this machine: ${availTxt}. Use pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.`;- } else if (result.cleared) {- hint = `Default cleared β AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next pup_open_window applies it.`;- } else if (result.installingChrome) {- // v1.9.86 (Kyle): this hint was entirely Windows-shaped (UAC, awaiting_uac, "click YES"). On- // macOS the install is a dmg -> ditto -> /Applications (falling back to ~/Applications)- // with NO elevation step and NO awaiting_uac phase, so a Mac AI reading the old text- // would poll for a phase that never arrives and tell the user to approve a prompt that- // never appears. Per the SDK's first principle the AI reads THIS, not the skill file.- hint = process.platform === 'darwin'- ? `Downloading & installing REAL Google Chrome (~90 MB) from Google's .dmg and pinning it as the default. On macOS there is NO elevation step and NO approval prompt: pup mounts the dmg with hdiutil and copies Google Chrome.app to /Applications, falling back to ~/Applications if that isn't writable. Poll pup_readiness: chromeStablePhase installing β ready (there is no awaiting_uac phase on macOS β do not wait for one, and do not tell the user to approve anything). If it fails at both destinations the install genuinely failed; read the error. Until then pup drives ${availTxt}, so nothing is blocked.`- : `Downloading & installing REAL Google Chrome (~90 MB, user-scoped) and pinning it as the default. On a normal PC this is SILENT (no prompt). On a locked-down box (VM / managed device) it needs a Windows approval β the bridge pops a native "click YES on the UAC" notification on the user's desktop, and if it expires it re-notifies with a one-tap "Approve now" (no AI turn needed). Poll pup_readiness: chromeStablePhase installing β awaiting_uac (waiting on the user's click; chromeStableAwaitingApproval:true) β ready. Until then pup drives ${availTxt}, so nothing is blocked. Pass {browser:"chrome", install:true, elevate:false} to skip the prompt path, or elevate:true to go straight to it.`;- } else if (result.installing) {- hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll pup_readiness until ready:true β until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`;- } else {- hint = `Default pinned β ${result.defaultBrowser ? result.defaultBrowser.kind : args.browser}. The next pup_open_window drives it. Available: ${availTxt}. Revert anytime with pup_use {browser:"auto"}.`;- // v2.0.369 (pup-bridge #81): pinning off CfT silently strips the toolbar family from- // every later launch (--load-extension rides ONLY CfT). Say so at pin time.- const _pk = result.defaultBrowser ? result.defaultBrowser.kind : args.browser;- if (_pk && _pk !== 'chrome-for-testing' && _pk !== 'cft' && pupSettings().toolbarExtension !== 'off') {- hint += ` β WARNING: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing β windows launched on ${_pk} will have NO toolbar. If this pin is temporary (a comparison/spike), pass {temporary:true} so it expires at the next bridge boot, or revert to auto when done.`;- }- if (args && args.temporary) hint += ' This pin is TEMPORARY: it expires automatically at the next bridge boot (back to auto).';- }- sendJSON(res, {- success: !!result.ok,- output: JSON.stringify({ ...result, readiness: rd }),- ...result,- errorCode: result.ok ? undefined : (result.errorCode || 'pup_use_failed'),- statusVerb: 'pup_readiness',- _hint: hint,- });- return;- }- case 'pup_audit_icons': {- // v2.0.228: detect drift between pup's model and the SHELL's own buttons, repair it, and- // report what was wrong. fix:false makes it a dry run.- const _r = await auditTaskbarIcons(args.reason || 'pup_audit_icons verb', { fix: args.fix !== false, force: !!args.force });- sendJSON(res, { success: true, audit: _r,- _hint: _r.ok ? 'Every live window matches the shell IN PUP\'S MODEL for this mode. That is not the same as matching the GLASS: the audit cannot read pixels, it infers art from timestamps. If a button looks wrong to a human, believe the human and re-run with {force:true}, which re-asserts every icon with a fresh cache key.'- : `${_r.findings.length} drift finding(s), ${_r.repaired} repaired β re-run in ~30s to confirm the glass settled.` });- return;- }- case 'pup_status': {- // v1.9.172 (jam fix): same resilience as pup_list_windows β parallel + per-session- // timeout + in-memory fallback, so a busy window can never hang pup_status into null.- const _entries = [...sessions];- const list = await Promise.all(_entries.map(([id, s]) =>- withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s))- ));- const _degraded = list.filter(x => x && x.degraded).length;- const statusResult = {- ok: true,- // v2.0.190: version + the enum_windows counter belong on the VERB payload (2.0.188 put- // them on the HTTP /status chip only β the wrong surface for remote no-shell boxes).- ...statusCore(),- sessions: list,- pupshotUrl,- };- if (_degraded) statusResult._degraded = `${_degraded} of ${list.length} window(s) were busy (Chrome under load) and show last-known state β pup is NOT frozen; retry shortly for live values.`;- // Add guidance when no sessions exist so the AI knows what to do next- if (sessions.size === 0) {- statusResult.hint = 'No browser sessions are open. Use pup_open_window with sessionId, profile, and url to launch a Chrome window on the desktop before using screenshot/eval/navigate.';- } else {- // v1.8.37: nudge to close stale/piled-up windows (each row already has ageMinutes+owner).- const cln = staleWindowsHint();- if (cln) statusResult.hint = cln.trim();- }- sendJSON(res, { success: true, output: JSON.stringify(statusResult) });- return;- }- case 'pup_close': {- // With sessionId: close just that session. Without: close everything.- // Per-session close mirrors pup_close_window's logic β kills the- // Chrome process for that profile if no other sessions share it.- if (args.sessionId) {- const target = sessions.get(args.sessionId);- if (!target) {- sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` });- return;- }- // v1.8.16: refuse a declared cross-owner close.- { const refusal = ownershipRefusal(target, args.sessionId, args, 'pup_close'); if (refusal) { sendJSON(res, refusal); return; } }- const profileName = target.profileName;- // Are there other sessions on this profile? If yes, only close- // this session's tab (Chrome stays alive for the others). If no,- // kill the Chrome process so the profile is fully released and- // the next pup_open_window starts from a clean slate.- // (Session map key IS the sessionId; iterate entries to compare.)- const otherSessionsOnProfile = [...sessions.entries()].filter(- ([id, s]) => id !== args.sessionId && s.profileName === profileName- );- let _reap = { killed: 0, remaining: 0 };- try {- if (otherSessionsOnProfile.length === 0) {- // Only this session uses this Chrome β kill the whole browser.- const be = browsers.get(profileName);- if (be) {- _reap = await killBrowserProcess(be.browser, profileName);- browsers.delete(profileName);- }- } else {- // Other sessions still using this Chrome β just close this tab.- if (target.page) {- try { await target.page.close({ runBeforeUnload: false }); } catch {}- }- }- } catch (e) {- console.log(`pup_close (per-session) error: ${e.message}`);- }- sessions.delete(args.sessionId);- deleteSessionFile(args.sessionId);- if (activeSessionId === args.sessionId) {- activeSessionId = sessions.size > 0 ? [...sessions.keys()][0] : null;- }- sendJSON(res, { success: true, output: JSON.stringify({- ok: true,- sessionId: args.sessionId,- message: `Session "${args.sessionId}" closed`,- chromeKilled: otherSessionsOnProfile.length === 0,- chromeProcsKilled: _reap.killed,- chromeProcsRemaining: _reap.remaining,- remainingSessions: sessions.size,- ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) SURVIVED teardown (should be 0) and may peg a CPU core β retry if it persists.` } : {}),- })});- return;- }-- // No sessionId β close everything (also the taskbar jump-list "Close ALL" task).- for (const [pName, be] of browsers) {- await killBrowserProcess(be.browser, pName);- }- browsers.clear();- // v1.9.51: unregister every per-session AUMID as we drop the sessions, so closing all- // windows leaves NO stale taskbar identity behind (the exact "old icon" class John flagged).- for (const [id, s] of sessions) {- deleteSessionFile(id);- clearSessionBusy(id); // v1.9.57- try { if (s && s._curAppId && s._curAppId !== PUP_APP_ID) unregisterPupAumid(s._curAppId).catch(() => {}); } catch (e) {}- }- sessions.clear();- _sessionOwnerClaims.clear(); // v1.9.110- activeSessionId = null;- // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live.- try { pruneStaleAumids(); } catch (e) {}- // Final VERIFIED sweep β with no live session left, reap EVERY pup chrome/renderer still- // alive (parent or reparented orphan). This is what makes "close all" actually leave zero- // core-pegging processes behind, and reports the count so the caller knows it's clean.- const _sweep = await reapOrphanPupChromes();- sendJSON(res, { success: true, output: JSON.stringify({- ok: true,- message: 'All sessions and browsers closed',- remainingSessions: 0,- chromeProcsKilled: _sweep.killed,- chromeProcsRemaining: _sweep.remaining,- ...(_sweep.remaining ? { _hint: `WARNING: ${_sweep.remaining} pup chrome process(es) survived the close-all sweep (should be 0) and may peg CPU β the box may be heavily loaded; retry pup_close.` } : {}),- })});- return;- }- case 'pup_wait': {- const ms = args.ms || 3000;- await new Promise(r => setTimeout(r, ms));- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, waited: ms }) });- return;- }-- // βββ Credential vault (HTTP Basic Auth for pup) ββββββββββββββββ- // Stored in the OS keychain (DPAPI on Windows, Keychain on macOS,- // libsecret on Linux). Applied via puppeteer's page.authenticate()- // before navigation so Chrome's native auth dialog never appears.- case 'credential_set': {- try {- const r = await credentialVault.setCredential({- host: args.host,- username: args.username,- password: args.password,- });- sendJSON(res, { success: true, output: JSON.stringify(r) });- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_credentials': // v2.0.110: friendlier alias- case 'credential_list': {- try {- const credentials = credentialVault.listCredentials();- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials, _hint: credentials.length ? `${credentials.length} saved login(s) in the OS keychain (passwords never listed). pup auto-fills these in the background; submit is reason-gated via pup_login. Remove one: credential_delete {host}.` : 'No saved logins yet. pup captures them automatically as you sign into sites (captureLogins:"on"), or store one explicitly with credential_set {host, username, password}.' }) });- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- // v2.0.368 (John: "all of us humans are now using ai as our personal assistant so it has to- // be able to do everything we can do as a human. but yes, security needs to be taken into- // account."). REVEAL one stored secret to a LOCAL caller, with the user's click as the gate.- //- // Why this exists: Grok Bot runs ON the desktop with a shell and calls adom-bridge natively.- // The adom-you vault is the list that grows with every login pup makes on John's behalf, and- // nothing could read a secret back out of it: pup_credentials lists host+username only, and- // pup_login types the secret into a pup window itself. Right for an AI driving pup; wrong for- // an assistant that needs the credential in ITS OWN tool. ab's own precedent for handing- // plaintext to a caller is desktop_decrypt_browser_credentials: LOCAL callers only, a consent- // dialog naming the requester, plaintext returned ONCE over loopback, never logged. This copies- // that shape and narrows it: ONE host per request, never bulk.- //- // THE GATE IS THE CLICK, NOT THE TRANSPORT CHECK. pup cannot verify where a call came from:- // caller headers are self-reported attribution (ab knows the verified relay connection; pup- // does not). So the container-header refusal below is a best-effort tripwire for the common- // cloud-relay case, and the thing that actually protects the vault is the sticky toast on the- // user's own screen, naming the caller and the host, that THEY have to click. No click, no secret.- case 'pup_credential_reveal': {- const host = String((args && args.host) || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');- const reason = String((args && args.reason) || '').trim();- if (!host) { sendJSON(res, { success: false, errorCode: 'host_required', error: 'pup_credential_reveal needs {host}: the site whose login you need, one per call', _hint: 'Example: {"host":"github.com","reason":"signing into GitHub in my own browser to open a PR for the user"}. Bulk export does not exist on purpose.' }); return; }- if (reason.length < 10) { sendJSON(res, { success: false, errorCode: 'reason_required', error: 'pup_credential_reveal needs a reason (min 10 chars) β it is shown to the user on the consent toast', _hint: 'Say what you will do with it, in words the user will read on their screen before deciding.' }); return; }- // v2.0.374 (#82): ab 2.0.96+ stamps X-Adom-Verified-Origin from the transport it- // OBSERVED (relay ws vs loopback vs bridge callback) β the one origin signal a caller- // cannot forge. This verb hands out plaintext, so the gate is strict allow-list: only a- // VERIFIED local caller passes. 'relay' and 'bridge' are refused, and a MISSING header- // (pre-2.0.96 ab, or an unidentified process POSTing at pup's port directly) is refused- // rather than assumed safe. The self-reported container header stays as a tripwire.- const _vo = req.headers['x-adom-verified-origin'] || null;- if (_vo !== 'local' || _callerContainer) {- console.log(`[credential] reveal REFUSED for ${host}: verified-origin=${_vo || 'MISSING'} caller "${_callerThread || '?'}"${_callerContainer ? ` (container ${_callerContainer})` : ''}`);- const isRelay = _vo === 'relay' || !!_callerContainer;- sendJSON(res, {- success: false,- errorCode: isRelay ? 'relay_forbidden' : 'origin_unverified',- error: isRelay- ? 'pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused, matching ab\'s rule for desktop_decrypt_browser_credentials'- : `pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin "${_vo || 'MISSING'}"`,- _hint: isRelay- ? 'From a cloud thread, do not ask for the secret: call pup_login {submit:true, submitReason} and pup signs the site in inside a pup window without the credential ever leaving the vault.'- : 'Call through the adom-bridge CLI on this desktop (ab 2.0.96+). A bridge callback or a direct loopback POST is not accepted for plaintext reveals.',- });- return;- }- let cred = null;- try { cred = await credentialVault.getCredentialForUrl('https://' + host); } catch (e) { cred = null; }- if (!cred) { sendJSON(res, { success: false, errorCode: 'no_credential', error: `no saved login for ${host}`, _hint: 'pup_credentials {} lists the hosts the vault knows (host + username only).' }); return; }- const who = _callerThread || 'an unnamed local caller';- const _ask = await consent.ask({- key: `pup_credential_reveal|${host}|${_callerThread || 'local'}`, verb: 'pup_credential_reveal',- what: `Waiting for the user to allow revealing the ${host} login to ${who}`,- toast: {- idPrefix: 'credreveal', level: 'warning', scenario: 'reminder',- title: `Reveal your ${host} login to ${who}?`,- body: `${who} is asking pup for the saved login for ${host} (username ${cred.username}). Reason given: "${reason}". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.`,- buttons: [{ label: 'Reveal' }, { label: 'Deny' }],- },- callerThread: _callerThread || undefined, callerReason: reason,- });- if (_ask.pending) {- console.log(`[credential] reveal for ${host} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) β answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);- sendJSON(res, blockedOnUser({- stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Reveal or Deny on the desktop toast "Reveal your ${host} login to ${who}?"`, remainingSec: _ask.remainingSec,- error: 'waiting on the user, not failed: the consent toast is still on their screen',- _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,- }));- return;- }- const decision = _ask.decision;- if (decision !== 'Reveal') {- const code = decision ? 'consent_declined' : 'consent_timeout';- console.log(`[credential] reveal ${code.toUpperCase()} for ${host} by "${who}"`);- sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined on the desktop; nothing was revealed' : `the user did not answer the consent toast within ${consent.totalSec}s; nothing was revealed`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to reveal it, then call once more if they say yes.' });- return;- }- // Plaintext ONCE. The audit line below is the only record and it carries no secret.- console.log(`[credential] reveal GRANTED for ${host} (user ${cred.username}) to "${who}" β user clicked Reveal`);- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, host: cred.host, username: cred.username, password: cred.password, sourceAccount: cred.sourceAccount || null, revealedTo: who,- _hint: 'This secret was released ONCE because the user clicked Reveal on their desktop. Use it, then drop it: never log it, never echo it into another verb\'s args or a response, never write it to disk.',- }) });- return;- }- // ββ Payment-card vault (issue #84: pup could drive a whole checkout, then stall at the- // card step). Storage mirrors the credential vault; the CONSENT model is stricter: a card- // is not a password, so EVERY fill prompts the human once, in the moment, with merchant +- // amount + last4 on the toast. No verb ever returns a PAN or CVV β pup_fill_card types- // the digits into the page itself and returns booleans, so the AI never handles them.- case 'pup_card_set': {- try {- const r = await cardVault.setCard({ nickname: args.nickname, number: args.number, expiry: args.expiry, cvv: args.cvv, name: args.name });- console.log(`[card] SET ${r.nickname} (${r.brand} β¦${r.last4}) by "${_callerThread || '?'}"`);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r, _hint: 'Stored in the OS keychain. Fill it into a checkout with pup_fill_card {nickname, merchant, amount} β the user approves each fill on a desktop toast. Never echo the number/cvv anywhere again; drop them from your context.' }) });- } catch (e) { sendJSON(res, { success: false, errorCode: 'card_invalid', error: e.message }); }- return;- }- case 'pup_cards': {- const cards = cardVault.listCards();- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cards, _hint: cards.length ? 'Nickname/brand/last4/expiry only β the number and CVV never leave the keychain. Fill with pup_fill_card.' : 'No cards saved. Store one with pup_card_set {nickname, number, expiry, cvv, name} β ask the USER to provide it, never harvest it from a page.' }) });- return;- }- case 'pup_card_delete': {- const r = await cardVault.deleteCard(args.nickname);- console.log(`[card] DELETE ${args.nickname} (removed=${r.removed}) by "${_callerThread || '?'}"`);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r }) });- return;- }- case 'pup_fill_card': {- const nickname = String((args && args.nickname) || '').trim().toLowerCase();- if (!nickname) { sendJSON(res, { success: false, errorCode: 'nickname_required', error: 'pup_fill_card needs {nickname} β pup_cards {} lists what the vault holds', _hint: 'Also pass {merchant, amount} so the user\'s approval toast names the specific charge.' }); return; }- const _lv = requireLiveSession(res, args);- if (!_lv) return;- const session = _lv.session;- const sessionId = _lv.sessionId || args.sessionId;- const meta = (cardVault.listCards() || []).find(c => c.nickname === nickname);- if (!meta) { sendJSON(res, { success: false, errorCode: 'no_card', error: `no saved card named "${nickname}"`, _hint: 'pup_cards {} lists saved cards; pup_card_set stores one.' }); return; }- const tab0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];- const pg = tab0 && tab0.page;- if (!pg) { sendJSON(res, { success: false, errorCode: 'no_page', error: 'the target window has no live page' }); return; }- let merchant = String((args && args.merchant) || '').trim();- if (!merchant) { try { merchant = new URL(pg.url()).hostname; } catch (e) { merchant = 'this page'; } }- const amount = String((args && args.amount) || '').trim();- const who = _callerThread || 'an unnamed caller';- // THE GATE: per-fill human approval, in the moment, naming the specific charge. This is- // deliberately allowed for RELAYED callers (unlike pup_credential_reveal): the digits- // never reach the caller, and the human click is the authorization for this one fill.- console.log(`[card] fill REQUESTED ${nickname} (β¦${meta.last4}) on ${merchant}${amount ? ' for ' + amount : ''} by "${who}" β asking the user`);- const _ask = await consent.ask({- key: `pup_fill_card|${nickname}|${merchant}|${_callerThread || 'anon'}`, verb: 'pup_fill_card',- what: `Waiting for the user to approve the ${meta.brand} β¦${meta.last4} fill on ${merchant}`,- toast: {- idPrefix: 'cardfill', level: 'warning', scenario: 'reminder',- title: `Pay with ${meta.brand} β¦${meta.last4} on ${merchant}?`,- body: `${who} wants to fill your card "${nickname}" (${meta.brand} ending ${meta.last4}) into the checkout on ${merchant}${amount ? ` for ${amount}` : ''}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.`,- buttons: [{ label: 'Approve' }, { label: 'Deny' }],- },- callerThread: _callerThread || undefined, callerReason: `fill card ${nickname} on ${merchant}`,- });- if (_ask.pending) {- console.log(`[card] fill ${nickname} on ${merchant} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) β answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);- sendJSON(res, blockedOnUser({- stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Approve or Deny on the desktop toast "Pay with ${meta.brand} β¦${meta.last4} on ${merchant}?"`, remainingSec: _ask.remainingSec,- error: 'waiting on the user, not failed: the approval toast is still on their screen',- _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,- }));- return;- }- const decision = _ask.decision;- if (decision !== 'Approve') {- const code = decision ? 'consent_declined' : 'consent_timeout';- console.log(`[card] fill ${code.toUpperCase()} ${nickname} on ${merchant} by "${who}"`);- sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined the card fill on the desktop; nothing was filled' : `the user did not answer the approval toast within ${consent.totalSec}s; nothing was filled`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to pay with this card, then call once more if they say yes.' });- return;- }- let card = null;- try { card = await cardVault.getCard(nickname); } catch (e) { card = null; }- if (!card) { sendJSON(res, { success: false, errorCode: 'no_card', error: 'the card vanished from the keychain between listing and filling' }); return; }- try {- const r = await cardVault.fillCardIntoPage(pg, card);- console.log(`[card] fill GRANTED+DONE ${nickname} (β¦${meta.last4}) on ${merchant} by "${who}": ${JSON.stringify(r.filled)} (${r.framesScanned} frames)`);- const missing = Object.entries(r.filled).filter(([k, v]) => !v && (k !== 'name' || card.name)).map(([k]) => k);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, filled: r.filled, framesScanned: r.framesScanned, last4: meta.last4, brand: meta.brand, _hint: missing.length ? `Fields not found on the page: ${missing.join(', ')} β screenshot the checkout and check whether they use an unusual widget; fill those by driving the page directly WITHOUT ever asking for the digits.` : 'All card fields filled. Screenshot to verify, then submit the checkout. The digits never left the desktop; do not try to read them back.' }) });- } catch (e) { sendJSON(res, { success: false, errorCode: 'fill_failed', error: `card fill failed: ${e.message}` }); }- return;- }- case 'credential_delete': {- try {- const r = await credentialVault.deleteCredential(args.host);- sendJSON(res, { success: true, output: JSON.stringify(r) });- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- // v1.9.124 β fill (and optionally submit) an HTML login FORM in the BACKGROUND, from the- // vault or explicit creds, with NO Chrome autofill dropdown and NO foregrounding. This is- // the answer to Q4: pup owns the creds, so it fills the fields over CDP directly instead of- // waiting on Chrome's native floaty menu (which pup can't click without stealing focus).- case 'pup_login': {- let session, resolvedId;- try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }- catch (e) { sendSessionResolveError(res, e); return; }- let tab, tabId;- try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }- catch (e) { sendTabResolveError(res, e); return; }- try {- const curUrl = tab.page.url();- let username = args.username, password = args.password, source = 'explicit';- // Optionally persist an explicit cred so NEXT time it's automatic.- if (username && password && args.save) {- let host = args.host;- try { if (!host) host = new URL(curUrl).hostname; } catch (e) {}- if (host) { try { await credentialVault.setCredential({ host, username, password }); } catch (e) {} }- }- // No explicit creds β pull from the vault by the page's current URL.- if (!password) {- const creds = await credentialVault.getCredentialForUrl(curUrl);- if (creds) { username = creds.username; password = creds.password; source = 'vault'; }- }- if (!password) {- sendJSON(res, { success: false, errorCode: 'no_credential', output: JSON.stringify({ ok: false, url: curUrl }),- _hint: `No credential to fill. Either pass {username, password} (add {save:true} to remember it for this host), or store one first with credential_set {host:"${(() => { try { return new URL(curUrl).hostname; } catch (e) { return '<host>'; } })()}", username, password} and call pup_login again. pup then fills the form in the BACKGROUND β no Chrome dropdown, no foregrounding.` });- return;- }- // v2.0.110 (John): SUBMIT is reason-gated + domain-confirmed. Sending a stored secret- // is the irreversible act β refuse submit:true without a submitReason, and regurgitate- // the LIVE domain so the AI must confirm it is not a lookalike/phishing host first.- if (args.submit && (!args.submitReason || String(args.submitReason).trim().length < 10)) {- let liveHost = '<unknown>'; try { liveHost = new URL(curUrl).hostname; } catch (e) {}- sendJSON(res, { success: false, errorCode: 'submit_reason_required', output: JSON.stringify({ ok: false, url: curUrl, host: liveHost }),- _hint: `β CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS. You asked to submit a stored login, but this is the point of no return: the password goes to "${liveHost}" the instant you submit. Is "${liveHost}" truly the site these credentials belong to β not a lookalike, typo-squat, or a host you were redirected to? If you are sure, re-call pup_login {submit:true, submitReason:"<why you are confident ${liveHost} is the correct, intended domain for this login>"} (min 10 chars). Not sure? Do NOT submit β pup already filled the fields, so a human can finish, or you can navigate away.`,- _autoMode: 'Claude Code AUTO MODE note: this refusal is pup\'s own gate, not the harness safety check β re-calling pup_login with submitReason is a structured verb call and passes Auto review. Do NOT route around it with pup_eval/raw scripting to poke the form; hand-rolled credential scripting is exactly what Auto mode flags. If the user pasted a password into chat, use it once via pup_login {password}, then tell them pup auto-captures logins so they never need to paste it again.' });- return;- }- if (args.submit) { let lh = ''; try { lh = new URL(curUrl).hostname; } catch (e) {} console.log(`[cred] pup_login submit to ${lh} β reason: ${String(args.submitReason).slice(0, 200)}`); }- // v2.0.125: realKeystrokes forces genuine key events for React-masked fields (Gusto).- // Those only register when the window is FOREGROUNDED, so raise it first. Default path- // (.value-set) stays fully background. autoFill-on-load never uses this.- const useKeystrokes = args.realKeystrokes === true || args.keystrokes === true;- if (useKeystrokes) { try { await osRaiseSessionWindow(session, resolvedId, 'realKeystrokes login: this site uses a masked field that only accepts real keyboard input, which requires the window on screen for a moment'); await new Promise(r2 => setTimeout(r2, 500)); } catch (e) {} }- const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit, keystrokes: useKeystrokes });- if (!r || !r.ok) {- sendJSON(res, { success: false, errorCode: r && r.reason === 'no_password_field' ? 'no_login_form' : 'login_fill_failed',- output: JSON.stringify({ ok: false, reason: r && r.reason, url: curUrl }),- _hint: (r && r.reason === 'no_password_field')- ? 'No password field found on this page β it may not be the login page yet, or the form is in an iframe / shadow DOM pup did not reach. Navigate to the actual login page first, then retry.'- : 'Could not fill the login form. Screenshot the page to see its state.' });- return;- }- // v2.0.125: a false filledPassword after the default .value-set almost always means a- // React-controlled MASKED field (Gusto etc.) silently ignored the set β the classic- // "submits empty / Password is required" trap. Coach the caller to the fix instead of- // letting it submit an empty password.- // v2.0.397 (#92): after a SUBMIT, look for the walls only a human can pass (one-time- // code, authenticator, captcha). That is blocked_on_user, not success.- if (args.submit && r.submitted) {- let _wall = null;- try { await new Promise(rs => setTimeout(rs, 1800)); _wall = await detectHumanChallenge(tab.page); } catch (e) { _wall = null; }- if (_wall) {- console.log(`[login] ${liveHost || curUrl}: submitted, now on a ${_wall.kind} wall (${_wall.evidence}) β blocked_on_user`);- sendJSON(res, blockedOnUser({ stillRunning: false, reason: _wall.kind, blockedBy: _wall.kind === 'captcha' ? 'The user must solve the captcha in the pup window' : 'The user must enter the one-time / two-factor code in the pup window', evidence: _wall.evidence,- output: JSON.stringify({ ok: false, url: (() => { try { return tab.page.url(); } catch (e) { return curUrl; } })(), submitted: true, filledUsername: r.filledUsername, filledPassword: r.filledPassword, wall: _wall.kind }),- _hint: `The login was filled and submitted; the site now needs the HUMAN (${_wall.kind}). Raise the window with pup_raise_os_window {foregroundReason:"finish the ${_wall.kind} for ${liveHost || 'this site'}"} and tell them what to do, then continue (screenshot to confirm you are past it). Do not re-submit the password.` }));- return;- }- }- const reactMaskedMiss = !useKeystrokes && r.filledPassword === false;- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, url: curUrl, credentialSource: source,- filledUsername: r.filledUsername, filledPassword: r.filledPassword,- submitted: r.submitted, reactMaskedFieldSuspected: reactMaskedMiss || undefined,- }),- _hint: reactMaskedMiss- ? `β The password did NOT land β this field looks React-controlled/masked (common on SSO/Keycloak logins like Gusto): a background .value-set is ignored and the form would submit EMPTY. Re-call pup_login {realKeystrokes:true, submit:true, submitReason:"β¦"}. That types with real key events, which such fields honour, but it briefly foregrounds the window (the only way real keystrokes register). Check login-recipes/<domain>.md on the pup wiki page for the site's flow.`- : `Login form filled in the BACKGROUND from ${source} creds${r.submitted ? ' and SUBMITTED' : ' (not submitted β pass {submit:true} to submit, or drive the submit button yourself)'}. No Chrome dropdown, window never foregrounded. Verify with pup_screenshot before assuming you are signed in.` });- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }-- // βββ Desktop recording (HUD) βββββββββββββββββββββββββββββββββββ- case 'pup_recorder_open': {- try {- await ensureRecorderWindowOpen(args.reason);- sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({- ok: true, opened: true, reason: desktopRecorder.reason,- })});- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_screen_record_start': {- try {- const r = await desktopRecordStartImpl(args);- sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_screen_record_stop': {- try {- const r = await desktopRecordStopImpl(args);- sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_screen_record_status': {- const cur = desktopRecorder.current;- const status = {- ok: true,- hudOpen: !!desktopRecorder.browser,- reason: desktopRecorder.reason,- currentRecording: cur ? {- recordingId: cur.recordingId,- durationMs: Date.now() - cur.startedAt,- filePath: cur.filePath,- fps: cur.fps,- audio: cur.audio,- } : null,- clipsThisSession: desktopRecorder.clipsThisSession,- };- sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify(status) });- return;- }- case 'pup_screen_record_list': {- sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({- ok: true, recordings: desktopRecordListImpl(),- })});- return;- }- case 'pup_recorder_close': {- try {- const summary = await closeRecorderWindow();- sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({- ok: true, sessionSummary: summary,- })});- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'desktop_list_monitors': {- // Minimal v1 β return primary monitor only, with best-effort dimensions- // (full multi-monitor enumeration is v1.1 via Tauri-side EnumDisplayMonitors)- const monitors = [{- index: 0, name: 'Primary',- x: 0, y: 0,- width: 1920, height: 1080, // best-effort fallback- primary: true,- }];- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, monitors })});- return;- }-- // βββ Tab recording (CDP screencast, no HUD) βββββββββββββββββββββ- // v1.9.76 β renew a high-fps lease. The watchdog WARNS before it downgrades, and a- // running recording holds the lease open on its own, so an AI only needs this when it- // knows up front it wants longer than the default without recording the whole time.- case 'pup_highfps_extend': {- const sid = args.sessionId || activeSessionId;- if (!sid || !sessions.has(sid)) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }); return; }- const info = highFpsInfo(sid);- if (!info) {- sendJSON(res, { success: false, error: `Session "${sid}" is not a high-fps window β nothing to extend.`,- _hint: 'This window runs on Chrome\'s normal power behaviour (the default, and the right choice for almost everything). Only a window opened with pup_open_window {highFps:true} holds a lease. If you need continuous full-rate frames while the window is occluded (background video recording), reopen it with highFps:true.' });- return;- }- const rec = highFpsExtend(sid, args.minutes);- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: sid, highFps: highFpsInfo(sid),- _hint: `High-fps lease extended to ${rec.minutes} min. Reminder: while a recording is actually running the lease CANNOT lapse, so you do not need to keep extending during a take.` }) });- return;- }- case 'pup_record_start': {- try {- const sessionId = args.sessionId || activeSessionId;- if (!sessionId) {- sendJSON(res, { success: false, error: 'sessionId is required' });- return;- }- // v1.9.76: recording a window that is NOT high-fps yields ~1-2 fps whenever the- // window is occluded, because Chrome throttles background paints. That is now the- // DEFAULT power behaviour (issue #14), so warn loudly instead of shipping a bad take.- const _hf = highFpsInfo(sessionId);- if (!_hf) console.log(`[highfps] "${sessionId}" recording started on a NORMAL-power window β frames will drop to ~1-2 fps while it is occluded. Reopen with pup_open_window {highFps:true} for a full-rate background take.`);- const r = await tabRecordStartImpl({ ...args, sessionId });- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_record_stop': {- try {- if (!args.recordingId) {- sendJSON(res, { success: false, error: 'recordingId is required' });- return;- }- const r = await tabRecordStopImpl({ sessionId: args.sessionId, recordingId: args.recordingId });- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});- } catch (e) {- sendJSON(res, { success: false, error: e.message });- }- return;- }- case 'pup_record_status': {- const active = tabRecordStatusImpl({ sessionId: args.sessionId });- sendJSON(res, { success: true, output: JSON.stringify({ ok: true, active })});- return;- }- case 'pup_record_list': {- sendJSON(res, { success: true, output: JSON.stringify({- ok: true, recordings: tabRecordListImpl(),- })});- return;- }-- default:- sendJSON(res, { success: false, errorCode: 'unknown_verb', error: `Unknown command: ${command}.`,- _hint: `Run pup_describe {} for the FULL current verb catalog (names, args, examples) β it is always up to date, unlike any memorized list. Common intents: window/tab work (pup_open_window, pup_open_tab, pup_my_windows), driving (pup_click, pup_type, pup_fill, pup_press_key, pup_navigate), looking (pup_screenshot, pup_eval), logins (pup_login, pup_credentials), capture (pup_annotate, pup_record_start).`,- _autoMode: 'Claude Code AUTO MODE note: prefer a structured pup_* verb from pup_describe over improvising with desktop_* clicks + eval chains β structured verbs state their intent and pass Auto review; hand-rolled input scripting is what gets flagged.' }, 404);- return;- }- }-- // v2.0.236: the legacy curl endpoints (/launch /navigate /reload /screenshot /screenshot.png- // /errors /eval /wait) are GONE. Each duplicated a pup_* verb on the "active session" with none- // of the ownership gate, session recovery, or foreground etiquette the verbs have β so any- // caller reaching them got subtly different (worse) behavior, and they were the last consumer of- // getActiveSession(). Nothing in this repo, the skills, or the recorder referenced them. They- // answer 410 with the verb to use instead, so a forgotten script fails loudly and fixably.- {- const LEGACY = {- '/launch': 'pup_open_window', '/navigate': 'pup_navigate', '/reload': 'pup_reload',- '/screenshot': 'pup_screenshot', '/screenshot.png': 'pup_screenshot', '/errors': 'pup_errors',- '/eval': 'pup_eval', '/wait': 'pup_wait',- };- if (LEGACY[pathname]) {- res.writeHead(410, { 'Content-Type': 'application/json' });- res.end(JSON.stringify({- ok: false, gone: pathname, use: LEGACY[pathname],- _hint: `${pathname} was a legacy curl shim that acted on whatever window happened to be "active" β no owner check, no recovery. Call the verb ${LEGACY[pathname]} through adom-bridge-cli instead (it targets YOUR thread's window and recovers a lost one).`,- }));- return;- }- }-- // DEMO ONLY (v2.0.342). The say-strip's phrase sending moved into the shared newbie-panel- // module, which does it client-side against the same-origin relays above, so the old- // /api/say-send is gone rather than left as a second, drifting implementation of the same- // contract. What stays is the demo, because the demo script is pup's own and the module only- // knows how to call back into onPlay.- if (pathname === '/api/demo-send' && req.method === 'POST') {- const guard = await shellSupportsSend();- if (!guard.ok) { sendJSON(res, { ok: false, why: guard.why }); return; }- const r = await hydrogenPost('/ai-threads/run-prompt', {- new: true, prompt: DEMO_PROMPT, submit: true, name: 'Pup - demo', tags: ['dock:pup-bridge'],- }, 120000);- if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + ').' }); return; }- const j = r.json || {};- // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI contract, dock-bar broadcast): run-prompt now returns- // in under a second with {ok, job, state:'opening'} and delivered_to is never filled on that- // path; progress comes from polling /ai-threads/run-prompt/status?job=<job>. working or later- // counts as sent; submit_failed means the prompt sits in the tab unsent (tell the user to press- // Enter there). Older builds still answer the delivered_to shape, handled below unchanged.- if (j && j.job && j.state) {- const t0 = Date.now(); let st = j.state, last = j;- const SENT = new Set(['working', 'needs_you', 'done']);- const FAIL = new Set(['submit_failed', 'submitted_unidentified', 'submit_unconfirmed', 'still_working']);- while (Date.now() - t0 < 30000 && !SENT.has(st) && !FAIL.has(st)) {- await new Promise(r2 => setTimeout(r2, 1000));- const pr = await hydrogenGet('/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job), 5000);- if (pr.status === 404) { sendJSON(res, { ok: false, why: 'This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab.' }); return; }- if (pr.json && pr.json.state) { last = pr.json; st = pr.json.state; }- }- if (SENT.has(st)) { sendJSON(res, { ok: true, state: st, job: j.job }); return; }- if (st === 'submit_failed') { sendJSON(res, { ok: false, state: st, why: 'The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it.' }); return; }- if (FAIL.has(st)) { sendJSON(res, { ok: false, state: st, why: 'Hydrogen reported ' + st + ' for the new thread.' }); return; }- sendJSON(res, { ok: true, state: st, job: j.job, why: 'The new AI tab opened; still ' + st + ' after 30s.' }); return;- }- // Three states, matching the module: delivered_to is an ARRAY, and a thread that opened- // without confirming delivery is NOT a failure. Reporting it as one invites a retry, and a- // retry double-sends the demo into a second thread.- const dt = j.delivered_to;- const arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);- if (arr.length || (j.inject && j.inject.delivered_in_conversation)) { sendJSON(res, { ok: true, state: 'delivered' }); return; }- if (r.status >= 200 && r.status < 300) { sendJSON(res, { ok: true, state: 'opened', why: (j._hints && j._hints.note) || null }); return; }- sendJSON(res, { ok: false, why: (j._hints && j._hints.note) || j.error || ('AI threads returned ' + r.status) });- return;- }- // The DOCK CONTRACT status payload (v2.0.337). John, via the Dock Bar guide: "we can't have- // pup having 2 different dashboards." pup used to serve a second, near-empty "launch page" from- // a container-side helper on its own port, and the dock pointed at THAT while this dashboard,- // the one with the taskbar strip, the window tree and icon provenance, went undiscovered. The- // dock now opens THIS page, so the contract's status feed has to come from here.- //- // Building it in the bridge is not just relocation, it removes a class of bug: the helper- // reached pup over ab and had to GUESS which desktop to ask when several were connected, which- // is why John's laptop dashboard rendered a "target: ConfRoomROG" chip. The bridge IS the- // desktop, so there is nothing to resolve and nothing to get wrong.- if (pathname === '/dashboard/newbie-panel.js') {- try {- const js = fs.readFileSync(path.join(__dirname, 'newbie-panel.js'));- res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });- res.end(js);- } catch (e) { res.writeHead(500); res.end('newbie-panel.js missing'); }- return;- }- // Same-origin proxies for the two Hydrogen endpoints the module calls. The module defaults- // apiBase to '' ON PURPOSE so the dashboard's own server relays them: a browser page on- // 127.0.0.1:8851 fetching 127.0.0.1:47084 is cross-origin, and a missing CORS header makes the- // browser discard a perfectly good 200. The bridge already talks to Hydrogen server-side, so- // relaying costs nothing and removes CORS from the picture entirely.- // RELAY THE BYTES, NOT A RE-SERIALISATION (v2.0.349). This used to JSON.parse the upstream- // response and JSON.stringify it back, which is a lossy round trip pretending to be a proxy:- // an integer past 2^53 loses precision, 1.0 becomes 1, and a non-JSON body upstream turns into- // an invented {"error":...} object. The module reads updater.current_version by splitting on- // dots and treats delivered_to as an ARRAY, so a retype changes BEHAVIOUR while every- // status probe stays green. Forward the status, the content-type and the body untouched.- if (pathname === '/updater/status' && req.method === 'GET') {- const r = await hydrogenGet('/updater/status', 8000);- if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }- res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });- res.end(r.raw);- return;- }- if (pathname === '/ai-threads/run-prompt' && req.method === 'POST') {- let body = '';- req.on('data', (d) => { body += d; if (body.length > 65536) req.destroy(); });- req.on('end', async () => {- let payload = null;- try { payload = JSON.parse(body); } catch (e) { res.writeHead(400); res.end('{"error":"bad json"}'); return; }- // 120s, matching the module's own AbortController: the editor can take 60s just to create- // a tab on a loaded workbench, and a shorter relay timeout would manufacture a failure the- // module would then report as "opened" when it actually delivered.- const r = await hydrogenPost('/ai-threads/run-prompt', payload, 120000);- if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }- res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });- res.end(r.raw);- });- return;- }- // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI): the THIRD proxied route. run-prompt now returns a job- // id and the page polls this once a second for {state}. The query string (?job=<uuid>) MUST be- // forwarded intact: a path-only proxy answers about no job at all. Same byte-faithful relay as- // the two routes above; a missing ah answers 502-with-a-hint, never a 404 the module reads as- // "route not wired".- if (pathname === '/ai-threads/run-prompt/status' && req.method === 'GET') {- const qs = (() => { try { const u = new URL(req.url, 'http://127.0.0.1'); return u.search || ''; } catch (e) { return ''; } })();- const r = await hydrogenGet('/ai-threads/run-prompt/status' + qs, 8000);- if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable', _hint: 'Hydrogen (ah) is not reachable from pup; the AI-threads progress route needs the local Hydrogen control API.' })); return; }- res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });- res.end(r.raw);- return;- }- if (pathname === '/api/status') {- const warming = !chrome || !chrome.readiness;- const rd = warming ? null : chrome.readiness();- const leds = [];- const cftOk = !!(rd && (rd.ready === true || rd.installed === true) && rd.installPhase !== 'failed');- leds.push({- key: 'chrome', label: 'Chrome for Testing',- state: warming ? 'warn' : (rd.installPhase === 'failed' ? 'fail' : (cftOk ? 'ok' : 'warn')),- detail: warming ? 'the bridge is still warming up'- : (rd.installPhase === 'failed' ? `install FAILED: ${rd.lastError || 'unknown error'}`- : (rd.installing ? `downloading (${rd.installProgressPct || 0}%)`- : (cftOk ? (rd.version ? `installed, pinned ${rd.version}` : 'installed and pinned') : (rd.reason || 'not installed yet')))),- short: warming ? 'warming' : (cftOk ? 'installed' : (rd.installing ? 'installing' : 'not installed')),- _hint: cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.',- });- // Answering by construction: this response IS the bridge answering, so claiming anything- // else here would be theatre. The honest variable is the version, not the state.- leds.push({ key: 'bridge', label: 'Bridge', state: 'ok', detail: `pup ${BRIDGE_VERSION} answering on ${os.hostname()}`, short: 'answering', _hint: null });- let tabCount = 0, notYou = 0;- for (const [, s2] of sessions) {- tabCount += Array.isArray(s2.tabs) ? s2.tabs.length : 0;- if ((s2.profileName || DEFAULT_PROFILE) !== DEFAULT_PROFILE) notYou++;- }- leds.push({- key: 'windows', label: 'Windows', state: 'ok',- detail: sessions.size ? `${sessions.size} window(s), ${tabCount} tab(s) open` : 'no pup windows open right now',- short: `${sessions.size} open`, _hint: null,- });- leds.push({- key: 'profile', label: 'Profile',- state: notYou ? 'warn' : 'ok',- detail: notYou ? `${notYou} window(s) are NOT on the shared ${DEFAULT_PROFILE} profile, so their logins build nothing`- : `every window is surfing as ${DEFAULT_PROFILE}, so logins accumulate`,- short: notYou ? `${notYou} not ${DEFAULT_PROFILE}` : DEFAULT_PROFILE, _hint: null,- });- const worst = leds.some(l => l.state === 'fail') ? 'fail' : (leds.some(l => l.state === 'warn') ? 'warn' : 'ok');- sendJSON(res, {- state: worst, label: 'pup',- detail: worst === 'ok' ? 'ready for action' : (leds.find(l => l.state !== 'ok') || {}).detail || 'needs attention',- version: BRIDGE_VERSION, dashboardVersion: BRIDGE_VERSION,- desktop: os.hostname(), desktops: [os.hostname()], leds,- });- return;- }- // GET /status- if (pathname === '/status') {- // v2.0.13 (John): /status no longer triggers ensureHeavyInit, so on a cold spawn `chrome` may- // not be loaded yet. Answer the chip probe INSTANTLY with a warming state rather than throwing- // on chrome.readiness() β that is what let ab's spawn-ready probe succeed instead of reaping us.- if (!chrome || !chrome.readiness) {- sendJSON(res, {- ok: true, bridge: 'puppeteer', version: BRIDGE_VERSION,- led: 'yellow', summary: 'starting',- tooltip: `Puppeteer bridge v${BRIDGE_VERSION} β warming up (loading runtime, recovering sessions)`,- warming: true, ready: false,- sessionCount: sessions.size, activeSession: activeSessionId,- });- return;- }- const list = [];- for (const [id, s] of sessions) {- let pageUrl = null;- try { pageUrl = s.page.url(); } catch (e) {}- list.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });- }- // Status-chip contract (AD renders led/summary/tooltip verbatim). The chip- // now TELLS THE TRUTH about cold-start: yellow while Chrome for Testing is- // missing/installing, red if its install failed, green when pup is ready.- const rd = chrome.readiness();- let led = 'green', summary, tooltip;- if (rd.installPhase === 'failed') {- led = 'red'; summary = 'Chrome install failed';- tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing install FAILED: ${rd.lastError}\nRetry pup_prewarm (usually a network/proxy or disk issue).`;- } else if (rd.installing) {- led = 'yellow'; summary = `Installing Chrome ${rd.installProgressPct}%`;- tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nDownloading Chrome for Testing (${rd.installProgressPct}%) β pup not ready yet.\nPoll pup_readiness until ready.`;- } else if (!rd.ready) {- // No drivable browser AT ALL (no Chrome, no Edge, no cached CfT) β the rare case.- led = 'yellow'; summary = 'No browser yet';- tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nNo Chrome/Edge detected and Chrome for Testing not cached β pup will fetch CfT on first use (or call pup_prewarm). Installing Chrome or Edge is instant, no download.`;- } else {- // READY: a drivable browser exists (installed Chrome/Edge, or cached CfT).- // Installed-browser-first model β CfT being absent is NOT a warning state.- led = 'green'; summary = `${sessions.size} window${sessions.size === 1 ? '' : 's'} Β· ${rd.browserKind}`;- tooltip = `Puppeteer bridge v${BRIDGE_VERSION} β ready\nDrives ${rd.browserKind} (${rd.browserSource}) Β· ${sessions.size} session(s)${rd.chromeForTestingInstalled ? `\nCfT ${rd.chromeBuildId || ''} cached as fallback` : ''}`;- }- // v1.8.78: the ONE non-cosmetic degradation on old AD gets a visible chip warning.- if (_adRaiseGateBypassed) {- if (led === 'green') led = 'yellow';- tooltip += `\nβ AD ${_adVersion} < 1.9.124: raise intercepted β the foreground gate does not apply on this box. Update Adom Bridge.`;- }- sendJSON(res, {- ok: true,- bridge: 'puppeteer',- ...statusCore(),- adVersion: _adVersion,- raiseGateBypassed: _adRaiseGateBypassed || undefined,- led, summary, tooltip,- sessions: list,- chrome: rd,- pupshotUrl,- });- return;- }-- // POST /close- if (pathname === '/close' && req.method === 'POST') {- for (const [, be] of browsers) {- try { await be.browser.close(); } catch (e) {}- }- browsers.clear();- sessions.clear();- activeSessionId = null;- sendJSON(res, { ok: true, message: 'All sessions closed' });- return;- }-- sendJSON(res, { error: 'Unknown endpoint: ' + pathname }, 404);- } catch (e) {- // v1.9.79 (issue #16): if a verb blew up (timeout / detached / target closed) on a tab whose- // renderer CRASHED, do not return a useless generic error. The crash event often lands right as- // the op times out, so re-check here and return the structured crash error with the- // reload-to-recover path β turning a 20s silent hang into an actionable answer.- // A BLOCKING DIALOG LOOKS EXACTLY LIKE A HANG, so say which it is (v2.0.344). Every verb that- // touches a page with a modal up returns some flavour of "timed out", and three different- // timeout messages taught the AI nothing about the real cause. If a dialog is pending on this- // session, the timeout is EXPLAINED rather than reported bare.- // v2.0.353: scan EVERY session, not args.sessionId. The first cut looked the dialog up on- // `args.sessionId`, which is the LEGACY id: pup's documented addressing is zero-id ({} resolves- // to your thread's window) or {window:"..."}, so args.sessionId is normally undefined and this- // whole branch never ran. The feature worked only for the one calling convention almost nobody- // uses, which is why it looked shipped and behaved exactly as if it had never been written.- try {- const _d = findAnyPendingDialog();- if (_d && /timed out|timeout|detached|Target closed|Session closed/i.test(e.message || '')) {- sendJSON(res, {- status: 'error',- error: `a ${_d.type} dialog in window "${_d.sessionId}" is BLOCKING EVERY pup window: ${JSON.stringify(_d.message)}`,- errorCode: 'dialog_open',- dialog: { type: _d.type, message: _d.message, defaultValue: _d.defaultValue, tabId: _d.tabId, window: _d.sessionId },- _hint: `EVERY pup window is stuck until this is answered, including windows that have nothing to do with it, because pup's windows share one browser and a modal blocks the browser. The dialog is in window "${_d.sessionId}".` + (_d.type === 'beforeunload'- ? ` It is asking to confirm leaving a page with UNSAVED CHANGES. pup will not answer it for you: "Cancel" keeps the page and blocks your navigation, "Reload" throws the changes away. Decide, then call pup_dialog {"window":"${_d.sessionId}","action":"accept"} to proceed, or "dismiss" to stay. If the unsaved work might matter, read it out with pup_eval FIRST.`- : ` Answer it with pup_dialog {"window":"${_d.sessionId}","action":"accept"} or "dismiss"${_d.type === 'prompt' ? ', optionally with promptText' : ''}.`),- }, 200);- return;- }- } catch (de) {}- try {- const _a = (typeof args !== 'undefined' && args) || {};- const _sess = _a.sessionId && sessions.get(_a.sessionId);- if (_sess && /timed out|detached|Target closed|Session closed|crashed/i.test(e.message || '')) {- const _t = _a.tabId ? getTabById(_sess, _a.tabId) : getActiveTab(_sess);- if (_t && _t._crashed) { _throwIfCrashed(_t, _a.sessionId); }- }- } catch (ce) {- if (ce && ce._isStructured) {- try { const pl = JSON.parse(ce.message); console.error('Handler error (tab crashed):', pl.error); sendJSON(res, pl, 200); return; } catch (pe) {}- }- }- console.error('Handler error:', e);- sendJSON(res, { error: e.message }, 500);- }-});--// Global request timeout β no HTTP request should hang longer than 45s-// v2.0.391 (release sweep on winvm, 2026-09-04): this was 45_000, which silently CLOSED the socket-// under any verb slower than 45s (a heavy vendor site on a small VM, an isolated-profile launch)-// while the handler kept running - the caller saw a transport error and pup still created the-// window, the worst possible split. bridge.json declares up to 240s (prewarm/use) and 150s-// (open_window); the socket budget must exceed the longest declared verb, never undercut it.-server.timeout = 260_000;-server.headersTimeout = 65_000;-server.keepAliveTimeout = 60_000;-server.requestTimeout = 260_000;-server.keepAliveTimeout = 30_000;--// Bind to the host AD tells us to (AD 1.9.63+ passes ADOM_BIND_HOST, always 127.0.0.1),-// falling back to loopback. AD's hard guarantee is NO firewall prompts by default β-// nothing it spawns may bind a public interface (0.0.0.0) unasked. A hostless listen()-// binds every interface and pops the Windows Firewall "allow access?" dialog; honoring-// ADOM_BIND_HOST keeps a loopback-only socket, which is firewall-exempt.-const BIND_HOST = process.env.ADOM_BIND_HOST || '127.0.0.1';-let _pupBoundOnce = false;-const _onPupBound = async () => {- if (_pupBoundOnce) return; _pupBoundOnce = true;- console.log(`Puppeteer Bridge running on http://${BIND_HOST}:${PORT} (bind=${BIND_HOST}; no firewall prompt)`);- // v2.0.41: publish pup's own port so jump-list tasks can talk straight back to us (pupβpup), and- // (re)write the callback launcher so the .ps1 tracks any format change on upgrade.- try { writePupPortFile(); writeExtensionPortFile(); ensurePupJumplistLauncher(); } catch (e) {}- console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');- console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');- // v2.0.13 (John, "everyone keeps saying pup is down"): DEFER the heavy native load + warm-up +- // session recovery OFF the bind tick with setImmediate, so the event loop is free to answer /health- // and /status (ab's spawn-ready probe) the instant the port binds. Previously ensureHeavyInit() ran- // synchronously here and blocked the loop for the whole ~8s native load; a probe landing in that- // window stalled and ab reaped the bridge ("never bound its port 64230") β the cold-spawn flap.- // Verbs still force ensureHeavyInit() on first use (idempotent), so nothing races ahead of its deps.- setImmediate(async () => {- try { ensureHeavyInit(); } catch (e) { console.error('heavy init failed:', e.message); }- // Self-warm: detect browsers + disk so the FIRST open is instant (cheap no-op if a browser exists).- // v2.0.369: tell chrome.js whether the toolbar family is wanted BEFORE warmup, so a fresh- // Edge-only box background-fetches CfT at install time and converges onto the toolbar-capable- // browser with zero pinning (the Azure-VM Edge regression).- // v2.0.378 (#81): a pup_use {temporary:true} pin lives only until this boot.- try { const _exp = chrome && chrome.expireTemporaryDefault && chrome.expireTemporaryDefault(); if (_exp) console.log(`[launch] temporary browser pin (${_exp}) expired at boot β auto`); } catch (e) {}- try { if (chrome && chrome.setPreferCft) chrome.setPreferCft(pupSettings().toolbarExtension !== 'off'); } catch (e) {}- try { if (chrome && chrome.warmup) chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }- // Recover sessions from disk (reconnect to Chrome windows that survived a bridge restart).- // Fire-and-forget: recovery must NEVER gate readiness or block the health probe.- try { await recoverSessions(); } catch (e) { console.error('Session recovery failed:', e.message); }- });- // v1.8.73 / v2.0.118: register the Adom.Pup anchor app identity once per bridge run (HKCU AUMID,- // registry-only β NO Start Menu shortcut, users disliked it; also self-heals a stale one). Loopback- // call from the bridge itself β trusted transport, no relay gating. Self-gates on AD <1.9.134.- setTimeout(() => { ensurePupIdentityRegistered().catch(() => {}); }, 2500);- setTimeout(() => { probeAdVersion().catch(() => {}); }, 1500);- // v1.9.116 β ON EVERY BRIDGE START, FORCE A JUMP-LIST RE-COMMIT FOR EVERY RECOVERED SESSION.- // This is the structural gap that let a fixed bug reach John anyway. A jump-list task embeds a full- // CLI command line, so when the command FORMAT changes (v1.9.115 had to add --ai-thread or AD refuses- // the click), every window that already exists keeps its OLD, now-broken command. stampPupIdentity- // only re-commits when the appId CHANGES, so a window whose category is unchanged across an upgrade- // never picks up the new format: John clicked "Switch to public view" on a pre-upgrade window and it- // silently did nothing, while the same verb worked perfectly from the CLI. Clearing the dedupe key- // before the startup sweep makes the re-attach real rather than a no-op, so a bridge upgrade now- // repairs task commands on every surviving window. Cheap: one AD call per live window, once per boot.- setTimeout(() => {- try {- let n = 0;- for (const [, s] of sessions) { if (s) { s._wikiJumplistKey = undefined; s._jlFrozen = false; n++; } }- if (n) console.log(`[jumplist] bridge start: forcing a re-commit on ${n} recovered session(s) so task commands match this version`);- } catch (e) {}- brandSweep('startup').catch(() => {});- }, 8000);- setTimeout(() => { ensureWindowOpenedWatch().catch(() => {}); }, 9000);-- // Session recovery now runs in the setImmediate above (fire-and-forget), so it never gates the- // health probe or blocks the bind tick.-- // v1.9.45: sweep orphaned AUMID keys, but LATE. Running it straight after recoverSessions() looked- // right and was wrong: when recovery legitimately ends with zero sessions (or is still settling),- // "not a live session" matches EVERY key and the sweep deletes the whole set, unbranding windows- // that were about to be re-stamped (observed: "pruned 12 stale keys" then keys: 0). Wait for the- // session map to settle, and never sweep from an empty map β with no live sessions there is- // nothing to distinguish stale from pending, so doing nothing is the correct, safe answer.- setTimeout(() => {- try {- if (!sessions.size) { console.log('[identity] AUMID prune skipped β no live sessions to compare against'); return; }- pruneStaleAumids();- } catch (e) {}- }, 60000);-- // v1.9.47: reclaim on boot (catches windows orphaned by a crash/restart) and then as a slow safety- // net, so an unbranded window can never persist even if its window-opened event was missed.- setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000);- setInterval(trackedLoop('reclaim-unmanaged', 90000, () => { if (minimalTouch()) return; return reclaimUnmanagedWindows().catch(() => {}); }), 90000);- // v1.9.279: the reconciler runs UNGATED (bookkeeping + identity only, never a process kill), so- // closed windows are reaped and stamp/settings drift is corrected within seconds, in every mode.- setInterval(trackedLoop('reconcile-model', 8000, () => reconcileModel('periodic').catch(() => {})), 8000);- // v2.0.161 (John: AUMID icons need a JANITOR, not just close-time cleanup): sweep orphaned- // Adom.Pup.* registry keys + prior-generation .ico caches on a slow cadence, so leaks from- // generation bumps, renamed/closed threads, and crashed windows never accumulate over a long- // uptime. The 60s startup prune skips an empty session map (boot-recovery guard); this steady-state- // sweep runs every 5 min and reaps even when nothing is live (an empty map long after boot means- // the keys are genuinely orphaned, and a re-stamp self-heals the rare reclaim race). reg.exe is- // only spawned every 5 min, so it adds no ab-log noise.- setInterval(trackedLoop('prune-stale-aumids', 300000, () => { try { pruneStaleAumids(); } catch (e) {} }), 300000);- // Build the notched category tiles once at boot (John's clean-backdrop-for-the-overlay idea).- // Fire-and-forget: pupIconPath serves the un-notched tile until this finishes, then it re-stamps.- ensureNotchedIcons().catch(() => {});- // v1.9.179 β TAB RECONCILER. Prune dead/detached and duplicate tab entries so the taskbar count- // badge reflects the REAL live tabs (rapid reconnects had inflated 1-tab windows to "4") and the- // favicon overlay reads from a LIVE active tab instead of a detached phantom. Runs on a gentle- // cadence; only sessions whose tab list actually changed re-stamp their overlay.- // v1.9.182: boot pass + a SLOW patrol (5 min, was 45s). The dup/phantom state only arises from- // reconnects, and every rescan already reconciles inline β the patrol is just a backstop, and a- // hot one multiplies any probe-judgement bug across every window (today's lesson).- setTimeout(() => { for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }, 8000);- setInterval(trackedLoop('identity-audit', 300000, () => { if (minimalTouch()) return; auditStampedIdentities().catch(() => {}); }), 300000); // v2.0.386 (#83 ask 3)- setInterval(trackedLoop('reconcile-session-tabs', 300000, () => { if (minimalTouch()) return; for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }), 300000);- // v1.9.171 β CRASH SELF-HEAL. The shared Chrome dies two ways on heavy/hostile pages (measured- // repeatedly on the Autodesk sign-in flow), and pup must recover from BOTH without a human:- //- // (1) CRASHED β Chrome's process exits. puppeteer fires 'disconnected', handleBrowserDisconnect- // DELETES the browsers entry and marks each affected session `_lostBrowser`. So by the time- // any watchdog runs the browsers map is already EMPTY β probing it finds nothing (this is why- // the v1.9.170 browsers-probe never fired). The real signal is the orphaned `_lostBrowser`- // sessions, and the real fix is to RELAUNCH them from disk. On the one durable profile they- // come back SIGNED IN at their last URL, in the background frame (no foreground steal).- // (2) HUNG β Chrome is alive but its CDP socket stops answering (no 'disconnected' fires, so the- // entry LINGERS as a dead handle; the next verb returns 'Connection closed' and passive verbs- // return null β pup LOOKS frozen, John: 'pup seems frozen again'). A real CDP probe with a- // timeout catches this; two consecutive misses (~30s, not a transient slow beat) = discard +- // reap + mark sessions lost, which then feed into the same (1) relaunch path.- //- // Backoff is mandatory: a page that crashes-Chrome-on-load (Kepler) would otherwise spin an endless- // relaunch loop and cook the laptop. Cap auto-relaunches per session; after the cap, hands off and- // tells the user via the cleanup queue that the page itself looks unstable.- const _healthStrikes = new Map(); // profileName -> consecutive hung-probe misses- const _healRelaunch = new Map(); // sessionId -> { count, last, gaveUp }- const _healingIds = new Set(); // sessionIds with a relaunch in flight (guards the 15s re-tick)- const MAX_HEAL_ATTEMPTS = 2;- const HEAL_COOLDOWN_MS = 5 * 60 * 1000;- // v1.9.256 SLEEP/WAKE + LOST-BROWSER RECOVERY (John, after a day away with the lid closed: "a- // laptop going to sleep is quite a common occurrence so you need to make sure after a wake that- // you are able to get back to the previous state you were in, i.e. all your browsers should not- // be lost anymore"). His dashboard sat on 'browser lost' for a day because the crash-recovery- // loop lived inside the minimalTouch-gated health interval - the THIRD critical feature silently- // killed by that one gate (overlay enforcer, user-activity watcher, now recovery). Recovery runs- // UNGATED here: minimal-touch forbids KILLING or RESTARTING a live Chrome behind the user's back;- // it does not forbid resurrecting DEAD windows to restore the user's state - that restoration is- // exactly what the user wants after a sleep. (Hung-Chrome teardown, which DOES kill, stays gated.)- let _lastRecoveryTick = Date.now();- setInterval(trackedLoop('sleep-wake-heal', 20000, async () => {- if (process.platform !== 'win32' || pupSettings().sleepRecovery !== 'on') return;- const now = Date.now();- const gap = now - _lastRecoveryTick;- _lastRecoveryTick = now;- if (gap > 90000) console.log(`[wake] system appears to have SLEPT ~${Math.round(gap / 60000)}min (timer gap) β checking every session for lost browsers`);- for (const [sid, sess] of [...sessions]) {- if (!sess._lostBrowser || _healingIds.has(sid)) continue;- const rec = _healRelaunch.get(sid) || { count: 0, last: 0, gaveUp: false };- if (rec.count >= MAX_HEAL_ATTEMPTS) {- if (now - rec.last < HEAL_COOLDOWN_MS) {- if (!rec.gaveUp) {- rec.gaveUp = true; _healRelaunch.set(sid, rec);- try { sess._healGaveUp = true; } catch (e) {} // v1.9.305: now the reaper may finally clean it up- console.log(`[heal] "${sid}" exceeded ${MAX_HEAL_ATTEMPTS} relaunch attempts β backing off ${Math.round(HEAL_COOLDOWN_MS / 60000)}m`);- recordCleanup(`pup tried to bring your "${sid}" window back but it kept failing β pup has stopped retrying for now. Reopen it manually when ready; your logins are safe on disk.`);- }- continue;- }- rec.count = 0; rec.gaveUp = false;- }- _healingIds.add(sid);- rec.count += 1; rec.last = now; _healRelaunch.set(sid, rec);- console.log(`[heal] "${sid}" browser lost (crash or sleep) β recovering (attempt ${rec.count}/${MAX_HEAL_ATTEMPTS}) at its last URL`);- try {- const r = await recoverOrRelaunchSession({ sessionId: sid });- if (r && r.session) {- console.log(`[heal] "${sid}" recovered β window is back, signed in, where it left off`);- recordCleanup(`pup auto-recovered your "${sid}" window (lost to a crash or the machine sleeping) β relaunched in the background, signed in, at where it left off.`);- } else {- console.log(`[heal] "${sid}" recovery did not resolve a live session yet β will retry next tick`);- }- } catch (e) {- console.log(`[heal] "${sid}" recovery failed: ${e.message} β will retry next tick`);- } finally {- _healingIds.delete(sid);- }- }- }), 20000);-- // v1.9.255 NO-FAULT WATCH (John: "if the window does get foreground by chrome itself or a focus- // or some other reason, you do watch it for 20 seconds and put up a caption saying something like- // 'it wasn't my fault, the javascript, or focus, or chrome caused the foreground. not me!'").- // For 20s after each background open/nav, poll the OS foreground ONLY while a watch is armed- // (zero steady-state cost). If the window surfaces without a granted foregroundReason: show the- // disclaimer caption (2s), mark it foreground (the IRON LAW: whatever raised it, it stays up),- // disarm. NEVER a knockback - that variant shipped as 1.9.252 and was rightly killed same day.- setInterval(trackedLoop('notmine-caption-watch', 2500, async () => {- // v2.0.292: fgWatch removed (its foreground-STEAL purpose died with off-screen birth; it was- // caption-only, a 20s-scoped duplicate of notMineCaption). The 20s self-foreground caption now- // honors notMineCaption β the single control for "a window fronted itself, Adom did not do it".- if (process.platform !== 'win32' || pupSettings().notMineCaption !== 'on') return;- try {- const now = Date.now();- const watched = [...sessions].filter(([, s2]) => s2._fgWatchUntil && s2._fgWatchUntil > now && !s2._foreground && !s2._lostBrowser);- if (!watched.length) return;- const fg = await osGetForegroundWindow();- if (!fg) return;- for (const [sid2, s2] of watched) {- try {- if (!s2._hwnd) { try { await resolveSessionHwndByBounds(s2, sid2); } catch (e) {} }- if (!s2._hwnd || String(fg) !== String(s2._hwnd)) continue;- s2._fgWatchUntil = 0;- s2._foreground = true; // iron law: it is up, it stays up- // v1.9.282 (John, furious the caption lingered): (a) the whole caption is a user-toggle- // (default on) - if off, mark foreground and move on, silently; (b) DEDUPE hard - the- // watch fires per-session, and rapid fires all reused the id 'pup-nofault', each resetting- // AD's expiry so the caption never got its 2s and stayed forever. At most one no-fault- // caption every 10s, so it can NEVER be held open by a re-issue storm.- if (pupSettings().notMineCaption !== 'on') break;- if (Date.now() - (globalThis._lastNoFaultCaptionAt || 0) < 10000) break;- // v1.9.292 (John: "i tend to click the taskbar icon to bring the window to the foreground...- // i only want to see this message if it was some other reason other than me raising it").- // A taskbar click is RECENT user input; a JS/Chrome raise has none. If input happened within- // the last 1.5s, assume the USER raised it and stay SILENT. Errs toward silence (a JS raise- // that coincides with the user typing elsewhere is also suppressed β the polite direction).- const _idle = await osIdleMs();- if (_idle != null && _idle < 1500) { console.log(`[fg-watch] "${sid2}" surfaced with recent user input (${_idle}ms idle) β assuming USER raised it, no caption`); break; }- globalThis._lastNoFaultCaptionAt = Date.now();- console.log(`[fg-watch] "${sid2}" surfaced with no pup raise and no recent input (idle ${_idle}ms) β no-fault caption (2s, deduped)`);- const _nmMsg = `This window was NOT foregrounded by Adom: JavaScript, a focus change, Chrome itself, or your own click raised "${sid2}". Not me!`;- const _nmMs = captionMsFor(_nmMsg);- pupCaption(_nmMsg, _nmMs, 'pup-nofault', _nmMs).catch(() => {});- break;- } catch (e) {}- }- } catch (e) {}- }), 2500);-- // v1.9.243 (John: "you have red led's galore in your dashboard. why?"). The overlay ENFORCER was- // dead on arrival: it sat inside the health interval ABOVE, whose first line is `if (minimalTouch())- // return;` β and minimal-touch is the normal mode, so it never ran once. Overlays are explicitly- // ALLOWED in minimal-touch (v1.9.191, gentle branding), so enforcement gets its OWN interval with no- // minimal-touch gate. 10s cadence: a window is never knowingly bare for more than ~10s.-setInterval(trackedLoop('overlay-enforcer', 4000, async () => {- if (process.platform !== 'win32') return;- // v2.0.67: floaty-click harvest runs BEFORE the identity gate (the pencil must work even when- // identity stamping is unsupported) and heartbeats so a dead sweep is visible in the log.- global.__floatyHb = (global.__floatyHb || 0) + 1;- if (global.__floatyHb % 15 === 1) console.log(`[floaty] sweep heartbeat #${global.__floatyHb} (${sessions.size} session(s), identitySupported=${_identitySupported})`);- // v2.0.230 (John: "find code that loops way too much and chews up wasteful cpu"). This sweep used- // to make THREE CDP round-trips per tab every 4s: harvest the pencil click, harvest a captured- // login, and run a title-cleanup blob that was a measured NO-OP (both call sites pass an empty- // owner, so the in-page code returns immediately). At ~10 tabs that was 7.5 renderer wakeups per- // second, forever, on an idle machine. Now: ONE evaluate per tab returning both harvests, the- // dead title sweep deleted, and tabs are skipped entirely when neither feature can fire.- // v2.0.315: both harvests now arrive by BINDING (push) the instant they happen; this poll is only- // the fallback for a page that somehow lacks the bindings, so it runs every 8th tick (~32s).- global.__floatyPollTick = (global.__floatyPollTick || 0) + 1;- if (global.__floatyPollTick % 8 === 0) try {- const _capOn = pupSettings().captureLogins !== 'off';- for (const [sidF, sF] of [...sessions]) {- if (sF._lostBrowser || !(sF.tabs || []).length) continue;- if (sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) continue;- for (const tF of (sF.tabs || [])) {- if (!tF || !tF.page) continue;- let harvest = null;- try {- harvest = await Promise.race([- tF.page.evaluate((wantCap) => {- const out = {};- const c = window.__pupFloatyClick; if (c) { window.__pupFloatyClick = null; out.click = c; }- if (wantCap) { const l = window.__pupCapturedLogin; if (l) { window.__pupCapturedLogin = null; out.login = l; } }- return (out.click || out.login) ? out : null;- }, _capOn),- new Promise(r => setTimeout(() => r(null), 2500)),- ]);- } catch (e) {}- if (!harvest) continue;- if (harvest.login && harvest.login.host && harvest.login.p) {- const cap = harvest.login;- try {- await credentialVault.setCredential({ host: cap.host, username: cap.u || '', password: cap.p });- console.log(`[cred] captured login for ${cap.host}${cap.u ? ` (${cap.u})` : ''} on "${sidF}" β keychain vault`);- sF._pendingCredHint = `pup CAPTURED this login for "${cap.host}"${cap.u ? ` (username ${cap.u})` : ''} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit auto-fills and signs in with no Chrome dropdown and no foregrounding. This is how the shared adom-you identity grows β every login you do makes future ones automatic. Manage: pup_credentials {} to list, credential_delete {host} to remove, pup_configure {captureLogins:"off"} to stop capturing.`;- } catch (e) { console.log(`[cred] capture store failed for ${cap.host}: ${e.message}`); }- }- if (harvest.click) {- sF._annotateArmed = Date.now();- console.log(`[floaty] "${sidF}" pencil clicked β arming capture watcher on ${tF.tabId}`);- startAnnotation(sF, sidF, 'user-floaty', { tool: 'pen', tabId: tF.tabId }).catch(() => {});- break;- }- }- }- } catch (e) {}- if (_identitySupported !== true) return;- try {- for (const [sid2, s2] of [...sessions]) {- if (s2._lostBrowser || !(s2.tabs || []).length) continue;- // v1.9.329: re-stamp an unstamped base tile if needed, but DO NOT `continue` β the overlay- // paint below is independent and MUST still run this cycle. The old `continue` meant an- // unstamped window never got its overlay either, so it stayed a bare (now notched β hole)- // button until it happened to stamp. Both are attempted every sweep now.- if (pupSettings().aumidIcons === 'on' && !s2._curAppId && Date.now() - (s2._stampTriedAt || 0) > 20000) {- s2._stampTriedAt = Date.now(); stampPupIdentity(sid2, { once: true }).catch(() => {});- }- if (pupSettings().overlayBadges !== 'on') continue;- // v2.0.47: GROUND-TRUTH RE-ASSERT. Overlay state is write-only (Windows can't be asked- // whether the badge is still there), and shell-side events beyond the progress bar can- // silently clear it. Every 60s per window, void the paint memory so the block below- // repaints unconditionally β one cheap ab call per window per minute buys "the badge you- // see is the badge we believe", instead of memory drifting from the screen forever.- // v2.0.230: was every 60s per window β one ab/PowerShell taskbar round-trip per window per- // minute, forever, purely as a safety net against shell-side clears. Stretched to 5 minutes:- // the real clears (button rebuild, progress bar, TaskbarCreated) all trigger a repaint by- // EVENT already, so this only has to catch the exotic case, and it can be lazy about it.- if (s2._appOverlay && Date.now() - (s2._overlayReassertAt || 0) > 300000) {- s2._overlayReassertAt = Date.now();- s2._lastPaintSig = null; s2._appOverlay = false;- s2._overlayReassertQuiet = true; // v2.0.48: periodic re-assert, not a real bare-heal β log quietly- }- const bare = !s2._appOverlay;- // A window whose page genuinely has NO favicon anywhere sits on the generic glyph forever, and- // the enforcer would re-fetch + re-compose it every 10s for the life of the window (John saw the- // churn in AD's log). Once we've re-attempted the upgrade a few times and the page STILL has no- // favicon, accept the generic glyph as final and stop re-enforcing β until the tab NAVIGATES,- // which clears the flag (framenavigated) so a new URL gets fresh upgrade attempts.- const stuckGeneric = s2._appOverlay && s2._overlayIsGeneric && !s2._overlayGenericFinal;- if (!bare && !stuckGeneric) continue;- const last = s2._overlayEnforceAt || 0;- // v1.9.329: a bare window is now a VISIBLE hole (the notch), so recover it fast β 4s, not 20s.- // v1.9.337: a GENERIC overlay (favicon fetch missed, e.g. right after an icon-gen re-stamp- // recreated the button) used to wait 90s before the enforcer re-fetched the real favicon β- // long enough that a re-stamp left every window wearing the ugly generic globe for over a- // minute (John: "what the fuck happened to the icons?"). Upgrade generic β real fast now.- const cooldown = bare ? 4000 : 10000;- if (Date.now() - last < cooldown) continue;- s2._overlayEnforceAt = Date.now();- const wasGeneric = stuckGeneric;- applyAppOverlay(s2, sid2).then(okp => {- if (!okp) return;- if (wasGeneric && !s2._overlayIsGeneric) { s2._overlayGenericTries = 0; console.log(`[overlay] "${sid2}" UPGRADED generic β real favicon (enforcer re-fetched it)`); }- else if (wasGeneric && s2._overlayIsGeneric) {- s2._overlayGenericTries = (s2._overlayGenericTries || 0) + 1;- if (s2._overlayGenericTries >= 3) { s2._overlayGenericFinal = true; console.log(`[overlay] "${sid2}" still has no favicon after 3 upgrade attempts β accepting generic glyph as final; will retry on next navigation`); }- }- else if (bare) {- // v2.0.48 (John saw "ENFORCED (was bare)" spam every minute): the 60s ground-truth- // re-assert is EXPECTED repainting, not a heal β don't announce it as one. Log a real- // bare-heal loudly, and the periodic re-assert only once per ~10 min per window.- if (s2._overlayReassertQuiet) {- s2._overlayReassertQuiet = false;- if (!s2._reassertLogAt || Date.now() - s2._reassertLogAt > 600000) { s2._reassertLogAt = Date.now(); console.log(`[overlay] "${sid2}" periodic ground-truth re-assert (5 min cycle; logged at most every 10 min)`); }- } else console.log(`[overlay] "${sid2}" ENFORCED (was bare) β every pup window must wear its overlay`);- }- }).catch(() => {});- }- } catch (e) {}- }), 4000); // v1.9.329: 4s sweep (was 10s) β a bare window is now a visible notch-hole; heal it fast-- let _healthTick = 0;- setInterval(trackedLoop('health+user-activity', 15000, async () => {- _healthTick++;- if (_healthTick % 2 === 0) { try { await healthLegacyTick(); } catch (e) {} } // the old 30s loop's duties- if (process.platform !== 'win32') return;- // v1.9.185 minimal-touch: NEVER kill or relaunch the user's Chrome behind their back. John:- // "you're just slapping monitoring on top of it so you can fucking restart it on me which is- // infuriating." In minimal mode pup reports problems and lets the user decide.- if (minimalTouch()) return;- // (2) HUNG detection β probe each LIVE browser entry; on 2 misses, tear it down so its sessions- // become `_lostBrowser` and fall into the relaunch path below.- for (const [profileName, entry] of [...browsers]) {- let alive = false;- try {- await Promise.race([- entry.browser.pages(),- new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 3500)),- ]);- alive = true;- } catch (e) {}- if (alive) { _healthStrikes.delete(profileName); continue; }- const strikes = (_healthStrikes.get(profileName) || 0) + 1;- _healthStrikes.set(profileName, strikes);- if (strikes < 2) { console.log(`[health] "${profileName}" failed a liveness probe (strike ${strikes}/2) β watching`); continue; }- _healthStrikes.delete(profileName);- console.log(`[health] "${profileName}" HUNG (2 strikes) β discarding the dead handle so its windows can relaunch`);- try { await entry.browser.close(); } catch (e) {}- browsers.delete(profileName);- try { await killOrphanChromesForProfile(profileName); } catch (e) {}- for (const [, sess] of sessions) { if (sess.profileName === profileName) sess._lostBrowser = true; }- }- // (3) USER-ACTIVITY WATCHER + STUCK-OCCLUSION check (v1.9.180/181). One OS foreground read per- // tick. When the foreground IS a pup window: (a) stamp it user-active + foreground (PERSISTED),- // so no park/recovery/adoption path ever demotes a window the user chose to bring up β the- // missing primitive behind "pup keeps yanking my windows" (PLACEMENT-INVARIANT item 1, John's- // design, finally implemented); (b) run the stuck-occlusion unstick on that ONE window only.- try {- // v1.9.185 minimal-touch: no foreground polling, no unstick, no autonomous clicks.- if (minimalTouch()) throw new Error('minimal-touch');- if (sessions.size > 0) {- const fg = await osGetForegroundWindow();- if (fg) {- for (const [sid, sess] of [...sessions]) {- try {- const hwnd = await cachedSessionHwnd(sid);- if (!hwnd || String(fg) !== String(hwnd)) continue;- // v2.0.261: do NOT credit the user for a window that was just born. Chrome- // self-activates a newly created on-screen window, and this observer running inside- // that moment would stamp _userActiveAt/_foreground β permanently marking an- // unasked-for window as "the user's", so nothing would ever move it off their work.- if (inBirthGrace(sess)) continue;- sess._userActiveAt = Date.now();- if (!sess._foreground) {- sess._foreground = true;- console.log(`[useractive] "${sid}" observed as the USER'S foreground window β marking foreground (persisted); pup will not re-park it`);- try { let u = null; try { u = sess.page && sess.page.url(); } catch (e) {} saveSessionFile(sid, sess.profileName, u); } catch (e) {}- }- await unstickIfHiddenWhileFront(sid, sess, fg, hwnd);- break; // only one window can be foreground- } catch (e) {}- }- }- }- } catch (e) {}- }), 15000);- // v1.9.166: taskbar-progress reconciler β clear any bar that is lit but shouldn't be.- // v1.9.181: only for sessions whose bar is ACTUALLY lit (_progressSince set). The old loop fired an- // AD desktop_taskbar call (a PowerShell round-trip on the desktop) for EVERY session EVERY 20s- // forever β with 10 windows that was 30 pointless PS invocations a minute pounding the machine pup- // is supposed to stay invisible on. Steady-state is now zero calls.- setInterval(trackedLoop('progress-reconciler', 20000, async () => {- if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress') return; // v2.0.292- for (const [sid, sess] of [...sessions]) {- const st = _busyState.get(sid);- if ((!st || !st.busy) && sess && sess._progressSince) { try { await setSessionProgress(sid, false); } catch (e) {} }- }- }), 20000);- // v1.9.145 (John found v21-review stranded at -32000 all day) β PLACEMENT SWEEP. The healer- // (healOffscreenWindow) existed but only ran from a few verb paths, so a long-lived window that- // predated the on-screen park kept its off-screen geometry FOREVER β made worse by the reparent:- // windows now survive bridge restarts, so stale geometry survives too. Assert placement for the- // whole standing population at boot and every 2 minutes; every repair is reported via the- // cleanup queue so a rescue is never silent. Healthy windows are never touched (the healer- // no-ops when bounds are on-screen).- // v2.0.314 (audit): the per-session CDP placement sweep (120s, a CDP session + getWindowBounds per- // window per tick) is DELETED β sweepVisibility below asserts the same off-screen invariant for- // the whole population from the OS side, every 30s, in-process.-- // v2.0.251 (John: "why can't i open this pup window? its like you did that off-screen thing again- // and now when i go to click on it as a human you fuck me over"). The placement sweep above asserts- // GEOMETRY β it catches a window stranded off-screen, and it explicitly does NOT touch a healthy or- // minimized one. But a window can be invisible without being mis-placed: pup's taskbar nudge HIDES- // the window for 80ms to force the shell to rebuild its button, and any interruption in that gap- // (the 20s execFile kill, a crash, a sleep) left it hidden with nothing anywhere in this codebase- // watching for it. Reproduced live on AdomLapper: after the hide half alone, IsWindowVisible=false- // and the taskbar button reveals nothing to a human click β exactly John's report.- // So placement is no longer the only invariant: VISIBILITY is asserted too, for the whole standing- // population. Safe by construction β a human can minimize a window but can never hide one, so a- // hidden pup window is always pup's own mess to clean up (see ensureWindowShown).- // Deliberately NOT minimalTouch-gated, unlike every other sweep here: minimal-touch exists to keep- // pup's automatic machinery off the user's taskbar, and a window the user cannot open is not- // "minimal touch", it is a broken window. Repairing it is the one automatic act that only ever- // gives the user back something pup took.- const sweepVisibility = async (cause) => {- // Never race a nudge's own 80ms hide gap β un-hiding mid-nudge would silently defeat the button- // rebuild. _nudgeUntil covers the in-flight window; a nudge that STRANDED a window has long since- // passed it, so the repair still lands (at worst one sweep later).- if (Date.now() < _nudgeUntil) return;- // Assert the invariant over the POPULATION, by owning process β deliberately NOT per session.- // Two earlier cuts of this sweep repaired nothing because they went looking for a session's- // handle first: windowHwnd -> bestSessionHwnd falls back to enumerate-and-wiggle, which only ever- // sees VISIBLE windows (circular β 28 straight "NOT resolved" misses on a hidden window sitting- // right there), and the cached/birth handles are exactly what a window in trouble tends to lack- // (a park-failed window never persisted one, and a pup restart clears the birth capture).- // The property that actually matters needs no session mapping at all: NO titled top-level window- // of a pup-owned Chrome process may be hidden. One enumeration repairs every stranded window,- // including ones pup has lost track of, and costs one PowerShell per sweep instead of one per- // session. Titled + WS_CAPTION only, so Chrome's own untitled helper windows are never touched.- const pids = [...pupBrowserPidSet()].filter(Boolean);- if (!pids.length) return;- // v2.0.314: in-process (koffi) β microseconds, no spawn. The PowerShell below is the fallback.- if (win32.available()) {- const fixes = win32.fixHiddenOrOffscreen(pids) || [];- for (const f of fixes) {- const t = String(f.title || '').slice(0, 60);- if (f.kind === 'MOVED-ONSCREEN') { console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`moved a pup window back ON-SCREEN ("${t}") β it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {} }- else if (f.kind === 'MOVE-FAILED') console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is off-screen and would NOT move back β reporting rather than pretending it is fine`);- else if (f.kind === 'RESHOWN') { console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${t}") β its taskbar button would have revealed nothing when clicked`); } catch (e) {} }- else console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is hidden and would NOT re-show β reporting rather than pretending it is fine`);- }- return;- }- const ps = `$s=@'-using System; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;-public static class VS {- public delegate bool EnumProc(IntPtr h, IntPtr l);- [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);- [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);- [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);- [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);- [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);- [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);- [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);- [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);- [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);- [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);- [DllImport("user32.dll")] public static extern bool GetWindowPlacement(IntPtr h, ref WINDOWPLACEMENT p);- [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }- [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }- [StructLayout(LayoutKind.Sequential)] public struct WINDOWPLACEMENT {- public int length; public int flags; public int showCmd;- public POINT ptMinPosition; public POINT ptMaxPosition; public RECT rcNormalPosition; }- public static List<string> Fix(int[] pids) {- var outp = new List<string>();- EnumWindows((h, l) => {- int pid; GetWindowThreadProcessId(h, out pid);- if (Array.IndexOf(pids, pid) < 0) return true;- if ((GetWindowLong(h, -16) & 0x00C00000) == 0) return true; // WS_CAPTION β real windows only- var sb = new StringBuilder(300); GetWindowText(h, sb, 300);- if (sb.Length == 0) return true;- if (!IsWindowVisible(h)) {- ShowWindow(h, 4); // SW_SHOWNOACTIVATE- outp.Add((IsWindowVisible(h) ? "RESHOWN|" : "RESHOW-FAILED|") + h + "|" + sb.ToString());- return true;- }- // v2.0.258: HIDDEN was only half of "the user cannot open this window". OFF-SCREEN is the- // other half, and it is the one that stranded John's "ab ralph" window at -21845,-21845 behind- // a live taskbar button. Same invariant, same repair: a minimized window reports the -32000- // placeholder rect legitimately, so judge position from the RESTORE rect, never GetWindowRect.- if (IsIconic(h)) return true; // minimized is the user's choice- WINDOWPLACEMENT wp = new WINDOWPLACEMENT();- wp.length = Marshal.SizeOf(wp);- if (!GetWindowPlacement(h, ref wp)) return true;- int L = wp.rcNormalPosition.L, T = wp.rcNormalPosition.T;- int W = wp.rcNormalPosition.R - L, H = wp.rcNormalPosition.B - T;- int vx = GetSystemMetrics(76), vy = GetSystemMetrics(77), vw = GetSystemMetrics(78), vh = GetSystemMetrics(79);- // Off-screen = the window does not overlap the virtual desktop at all.- if (L < vx + vw && L + W > vx && T < vy + vh && T + H > vy) return true;- int nw = W > 200 && W <= vw ? W : (vw - 20), nh = H > 200 && H <= vh ? H : (vh - 20);- SetWindowPos(h, IntPtr.Zero, vx + 10, vy + 10, nw, nh, 0x14); // NOACTIVATE|NOZORDER- RECT chk; GetWindowRect(h, out chk);- bool fixedNow = chk.L < vx + vw && chk.R > vx && chk.T < vy + vh && chk.B > vy;- outp.Add((fixedNow ? "MOVED-ONSCREEN|" : "MOVE-FAILED|") + h + "|" + sb.ToString());- return true;- }, IntPtr.Zero);- return outp;- }-}-'@; Add-Type -TypeDefinition $s; [VS]::Fix(@(${pids.join(',')})) | ForEach-Object { $_ }`;- await new Promise((resolve) => {- try {- execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 25000 }, (err, stdout) => {- for (const line of String(stdout || '').trim().split(/\r?\n/)) {- const m = /^(RESHOWN|RESHOW-FAILED|MOVED-ONSCREEN|MOVE-FAILED)\|(\d+)\|(.*)$/.exec(line.trim());- if (!m) continue;- if (m[1] === 'MOVED-ONSCREEN') {- console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} sweep β its taskbar button was live while the window sat where no click could reach it`);- try { recordCleanup(`moved a pup window back ON-SCREEN ("${m[3].slice(0, 60)}") β it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {}- } else if (m[1] === 'MOVE-FAILED') {- console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is off-screen and would NOT move back β reporting rather than pretending it is fine`);- } else if (m[1] === 'RESHOWN') {- console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} visibility sweep β a hidden window's taskbar button reveals nothing to a human click`);- try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${m[3].slice(0, 60)}") β its taskbar button would have revealed nothing when clicked`); } catch (e) {}- } else {- console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is hidden and would NOT re-show β reporting rather than pretending it is fine`);- }- }- resolve();- });- } catch (e) { resolve(); }- });- };- // v2.0.258: every 30s, not 120s. This sweep is the last line of defence for "the user clicked the- // taskbar button and nothing came up", and John hit that twice in one day β two minutes of an- // unopenable window is not a safety net, it is the bug with a longer fuse. One PowerShell per- // 30s across the whole population is cheap next to that.- setTimeout(() => { sweepVisibility('startup').catch(() => {}); }, 8000);- setInterval(trackedLoop('visibility-sweep', 30000, () => sweepVisibility('periodic').catch(() => {})), 30000);-- // v1.9.89: reap ORPHANED pup chrome/renderers β any process under a pup-profiles dir whose profile- // has no live session (a previous bridge crash left them, or renderers that outlived a close and are- // pegging a core). Boot sweep runs AFTER recovery has settled so it never kills a window recovery- // just reconnected; then a slow periodic net. Spares every live session BY PROFILE, so it can only- // ever hit true orphans β never a session's own renderer children.- setTimeout(() => { reapOrphanPupChromes().catch(() => {}); }, 15000);- // v2.0.230: skip the PowerShell process enumeration entirely when there is nothing that could- // have leaked (no live sessions AND no session files on disk) β it was ~20 PowerShell spawns an- // hour on a totally idle machine.- setInterval(trackedLoop('reap-orphan-chromes', 180000, () => {- if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }- reapOrphanPupChromes().catch(() => {});- }), 180000);-- // v2.0.129: reap LEAKED isolated profile DIRECTORIES (iso-* throwaways whose window is long gone).- // Boot pass waits 60s so session recovery has fully settled first (never delete a profile an iso- // session is mid-recovery on); then a slow 15-min net drains the backlog a few at a time.- setTimeout(() => { reapLeakedIsolatedProfiles().catch(() => {}); }, 60000);- setInterval(trackedLoop('reap-leaked-profiles', 900000, () => {- if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }- reapLeakedIsolatedProfiles().catch(() => {});- }), 15 * 60 * 1000);-- // (audit 2.0.314) the 5-minute stale-session-file cleanup was a byte-for-byte duplicate of the- // tail of the 30s health loop; deleted.-};--// v2.0.16 (#557 ask 3, pup-side defense): EADDRINUSE SELF-HEAL. The #557 permanent outage was ab's-// reaper losing track of a live bridge process (bridge_kill returned killedPids:[]): the orphan kept-// port 64230, so every replacement ab spawned died on the bind conflict, forever, and recovery took a-// manual taskkill. ab's real fix (reap by port owner / child tree) is filed against adom/adom-bridge;-// until it lands, the REPLACEMENT heals it: on EADDRINUSE, probe the holder's /health β if (and ONLY-// if) it identifies as a pup puppeteer bridge, it is by definition an orphan (ab only spawns a-// replacement when the holder is off its books), so kill that ONE pid and retry the bind. A holder-// that is NOT a pup bridge is never touched: log and exit. Convergence: if ab tracks us after the-// heal, routing resumes; if ab already gave up on us, the NEXT spawn heals against us and gets-// tracked β one extra cycle, never a loop (3-retry cap either way).-let _bindRetries = 0;-let _eaccesTries = 0;-server.on('error', (err) => {- const code = err && err.code;- // v2.0.231 (John's machine, 2026-08-18): EACCES on a loopback port is almost never a permission- // problem β on Windows it means the port sits inside a RESERVED range (Hyper-V/WinNAT grabs- // blocks like 64160-64259 at boot). ab assigns the port, so pup cannot fix routing by itself, and- // exiting produced an invisible crash-loop: the bridge died on every spawn until the machine was- // rebooted, with nothing but "fatal listen error (EACCES)" to go on. Now pup NAMES the cause,- // prints the exact remedy, and binds a fallback port anyway so its own HTTP surface (dashboard,- // /health) is reachable and the port file tells the truth about where it landed.- // v2.0.234 (John's AdomLapper, 2026-08-18): EACCES on a loopback port means the port sits inside a- // Windows RESERVED range β Hyper-V/WinNAT grabs blocks (here 64160-64259, which swallowed ab's- // assigned 64230) at every boot. Exiting made this a silent permanent outage: ab respawned on the- // same port, hit the same wall, and after 3 tries went into backoff, so pup stayed dead until- // someone ran an elevated `net stop winnat`. Now pup STAYS ALIVE and re-attempts the SAME port on- // a backoff, so the instant the range is released (winnat restart, Docker/WSL shutdown, reboot)- // it binds and is healthy on the port ab is already routing to β no respawn, no human, no gap.- if (code === 'EACCES' && process.platform === 'win32') {- _eaccesTries++;- if (_eaccesTries === 1) {- let ranges = '';- try { ranges = execFileSync('netsh', ['int', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'], { timeout: 8000, windowsHide: true }).toString(); } catch (e) {}- let inRange = null;- for (const m of ranges.matchAll(/^\s*(\d+)\s+(\d+)/gm)) {- const a = Number(m[1]), b = Number(m[2]);- if (PORT >= a && PORT <= b) { inRange = `${a}-${b}`; break; }- }- console.error(`[bind] EACCES on 127.0.0.1:${PORT}${inRange ? ` β that port is inside the Windows RESERVED range ${inRange}` : ''}.`);- console.error('[bind] Cause: Windows (Hyper-V/WinNAT) reserves port blocks at boot. Not a pup bug, not a permissions problem, and NOT fixable from this process.');- console.error('[bind] Release it (elevated): net stop winnat && net start winnat β or reboot. Filed as adom/adom-bridge#83 so ab stops assigning ports inside these ranges.');- console.error(`[bind] pup will STAY UP and retry ${PORT} every 15s; it binds automatically the moment the range is released.`);- } else if (_eaccesTries % 20 === 0) {- console.error(`[bind] still cannot bind ${PORT} (Windows reserved range) β ${_eaccesTries} attempts; retrying every 15s.`);- }- setTimeout(() => {- try {- server.listen(PORT, BIND_HOST, () => {- console.error(`[bind] RECOVERED β the Windows reservation on ${PORT} was released after ${_eaccesTries} attempt(s); pup is live on its assigned port with no respawn needed.`);- _eaccesTries = 0;- try { _onPupBound(); } catch (e) {}- });- } catch (e) {}- }, 15000);- return;- }- if (code !== 'EADDRINUSE' || process.platform !== 'win32' || _bindRetries >= 3) {- console.error(`[bind] fatal listen error (${code || (err && err.message)}) β exiting so the supervisor respawns cleanly`);- process.exit(1);- }- _bindRetries++;- (async () => {- try {- const health = await Promise.race([- fetch(`http://127.0.0.1:${PORT}/health`).then(r => r.json()).catch(() => null),- new Promise(r => setTimeout(() => r(null), 2500)),- ]);- if (!health || health.bridge !== 'puppeteer') {- console.error(`[bind] port ${PORT} is held by a NON-pup process β refusing to touch it; exiting`);- process.exit(1);- }- const { execFileSync } = require('child_process');- const pidOut = execFileSync('powershell.exe', ['-NoProfile', '-Command',- `Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess`],- { encoding: 'utf8', timeout: 8000, windowsHide: true }).trim();- const holderPid = Number(pidOut) || 0;- if (!holderPid || holderPid === process.pid) {- console.error(`[bind] EADDRINUSE but no resolvable holder pid (got "${pidOut}") β retrying bind blind`);- } else {- console.error(`[bind] port ${PORT} held by ORPHANED sibling pup bridge v${health.version || '?'} (pid ${holderPid}) β ab spawned this process as its replacement, so the holder is off ab's books. Killing that ONE pid and retrying (attempt ${_bindRetries}/3).`);- execFileSync('taskkill', ['/PID', String(holderPid), '/F'], { timeout: 8000, windowsHide: true });- }- await new Promise(r => setTimeout(r, 1200));- server.listen(PORT, BIND_HOST, _onPupBound);- } catch (e) {- console.error(`[bind] self-heal attempt ${_bindRetries} failed: ${e.message} β exiting for a clean respawn`);- process.exit(1);- }- })();-});--server.listen(PORT, BIND_HOST, _onPupBound);--// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ-// RECORDING β desktop (HUD) and tab (CDP screencast)-// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ--const RECORDINGS_DIR = path.join(__dirname, 'recordings');-fs.mkdirSync(RECORDINGS_DIR, { recursive: true });-const RECORDER_PROFILE_DIR = path.join(PROFILES_DIR, '_recorder');--// βββ ffmpeg detection (used to add Duration tag to MediaRecorder WebMs) ββ-// Probe at module-load so we know upfront whether the remux path works.-// Cached for the life of the process. Try common Windows install dirs in-// addition to PATH so a winget/scoop/choco install is found even if PATH-// hasn't been refreshed in the bridge's environment.-let FFMPEG_PATH = null;-// v1.8.20: re-callable (was a boot-only IIFE). Recording verbs call it LAZILY when-// FFMPEG_PATH is still null, so a user who installs ffmpeg AFTER the bridge started can-// record immediately β no bridge restart needed. Re-scans cleanly each call.-const SCREEN_RECORDER_HINT = 'This is pup\'s CHROME screen-share recorder (getDisplayMedia -> WebM VP9, consent = the visible recorder HUD, system audio + mic mixing, cross-platform). Canonical names: pup_screen_record_start/stop/status/list + pup_recorder_open/close (pup-bridge#94). ab-core serves desktop_record_* NATIVELY since ab 2.1.36 (WGC + system audio to MP4, no browser, no picker): use that for a whole-monitor or single-window recording; use this Chrome recorder when you need the OS share picker, tab audio, WebM, or a non-Windows box.';-function detectFFmpeg() {- FFMPEG_PATH = null;- const { spawnSync } = require('child_process');- // 1. PATH- try {- const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });- if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }- } catch {}- // 2. Common macOS install paths (Homebrew arm64/x64, MacPorts) β AD spawns the bridge- // outside a login shell, so /opt/homebrew/bin is usually NOT on PATH here.- if (process.platform === 'darwin') {- for (const p of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/local/bin/ffmpeg']) {- try {- if (fs.existsSync(p)) {- const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });- if (r.status === 0) { FFMPEG_PATH = p; return; }- }- } catch {}- }- }- // 3. Common Windows install paths- if (process.platform === 'win32') {- const candidates = [- 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',- 'C:\\ffmpeg\\bin\\ffmpeg.exe',- 'C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe',- `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe`,- `${process.env.USERPROFILE}\\scoop\\shims\\ffmpeg.exe`,- ];- for (const p of candidates) {- try {- if (fs.existsSync(p)) {- const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });- if (r.status === 0) { FFMPEG_PATH = p; return; }- }- } catch {}- }- // 3. Glob the WinGet directory (version number changes between releases)- try {- const winget = `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages`;- if (fs.existsSync(winget)) {- for (const e of fs.readdirSync(winget)) {- if (e.startsWith('Gyan.FFmpeg')) {- const sub = path.join(winget, e);- const walk = (dir) => {- for (const f of fs.readdirSync(dir, { withFileTypes: true })) {- if (f.isDirectory()) {- const r = walk(path.join(dir, f.name));- if (r) return r;- } else if (f.name === 'ffmpeg.exe') {- return path.join(dir, f.name);- }- }- return null;- };- const found = walk(sub);- if (found) { FFMPEG_PATH = found; return; }- }- }- }- } catch {}- }- return FFMPEG_PATH;-}-detectFFmpeg();-if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);-else console.warn(`[recorder] ffmpeg NOT FOUND β desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via '${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}' to enable Duration metadata.`);--/**- * Remux a WebM file in place to add a SegmentDuration tag.- * Uses `ffmpeg -i in.webm -c copy out.webm` β copies the stream packets- * unchanged (no re-encode), and the matroska muxer writes a complete- * SegmentInfo with Duration. Fast (~100-300ms for typical clips).- * Atomic: writes to a temp file then renames so a partial run can't- * corrupt the original.- */-async function remuxWebmAddDuration(filePath) {- if (!FFMPEG_PATH) detectFFmpeg(); // lazy re-scan: ffmpeg may have been installed since boot- if (!FFMPEG_PATH) throw new Error('ffmpeg not found on PATH or in common Windows install dirs');- const { spawn } = require('child_process');- const tmpPath = filePath + '.remux.tmp';- await new Promise((resolve, reject) => {- const proc = spawn(FFMPEG_PATH, [- '-y', // overwrite tmp if it exists- '-loglevel', 'error', // quiet but still surface errors- '-i', filePath,- '-c', 'copy', // no re-encode β just rewrite container- '-f', 'webm',- tmpPath,- ], { stdio: ['ignore', 'ignore', 'pipe'] });- let stderr = '';- proc.stderr.on('data', b => { stderr += b.toString(); });- proc.on('error', reject);- proc.on('exit', code => {- if (code === 0) resolve();- else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));- });- });- // Atomic replace: rename tmp β original. fs.renameSync replaces on Win10+.- fs.renameSync(tmpPath, filePath);-}--// Desktop recorder (singleton; one HUD across the whole bridge)-const desktopRecorder = {- browser: null,- page: null,- windowId: null, // Chrome windowId for Browser.setWindowBounds- cdp: null, // CDP session to recorder page- reason: null,- hudOpenedAt: null,- current: null, // active recording state, see below- clipsThisSession: [], // [{recordingId, filePath, sizeKB, durationMs, reason}]- recCounter: 0,- idleTimer: null,-};--// Tab recordings (concurrent; many at once)-// Map<recordingId, {sessionId, tabId, dirPath, framesDir, concatStream, manifestFrames,-// cdp, started_at, frameCount, fps, quality, stopTimer, stopping}>-const tabRecordings = new Map();-let tabRecCounter = 0;--// βββ Desktop recorder helpers βββββββββββββββββββββββββββββββββββββββββββββββ--function recorderHtmlUrl(reason) {- const file = path.join(__dirname, 'recorder.html').replace(/\\/g, '/');- const params = new URLSearchParams({ reason: reason || '' }).toString();- return `file:///${file}?${params}`;-}--async function ensureRecorderWindowOpen(reason) {- if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {- throw new Error('reason is required β explain to the user why you\'re recording');- }- // If browser+page exist and are alive, just update the reason- if (desktopRecorder.browser && desktopRecorder.browser.isConnected() && desktopRecorder.page) {- try {- await desktopRecorder.page.evaluate((r) => window.setSessionReason && window.setSessionReason(r), reason);- desktopRecorder.reason = reason;- return;- } catch {- // Page died; tear down and respawn- try { await desktopRecorder.browser.close(); } catch {}- desktopRecorder.browser = null;- desktopRecorder.page = null;- desktopRecorder.cdp = null;- }- }-- fs.mkdirSync(RECORDER_PROFILE_DIR, { recursive: true });-- // Single fixed size for the HUD β large enough that every state's content- // fits without dynamic resizing. Position is set AFTER launch via CDP using- // screen.availWidth/availHeight (taskbar-aware, DPI-aware) β Chrome's- // --window-position arg uses physical pixels and doesn't account for the- // taskbar, which made the HUD half off-screen on non-1920Γ1080 displays.- const winW = 360, winH = 420;-- console.log(`[recorder] Spawning HUD window (reason="${reason}")`);-- const launchArgs = [- `--app=${recorderHtmlUrl(reason)}`,- `--window-size=${winW},${winH}`,- // No --window-position; we anchor bottom-right after the page loads- '--use-fake-ui-for-media-stream',- '--auto-select-desktop-capture-source=Entire screen',- '--enable-features=GetDisplayMediaSet',- '--auto-accept-this-tab-capture',- '--no-sandbox',- '--no-first-run',- '--no-default-browser-check',- // v2.0.293: MediaRouter's mDNS binds 0.0.0.0:5353 β a non-loopback listener that pops the- // Windows Firewall dialog (per-exe). pup's hard rule is ZERO firewall prompts, so the- // recorder Chrome disables it exactly like the main launch path (one flag, comma-merged β- // never add a second --disable-features here, Chrome only honors the last).- '--disable-features=Translate,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider',- '--media-router=0',- `--user-data-dir=${RECORDER_PROFILE_DIR}`,- ];-- desktopRecorder.browser = await puppeteer.launch({- headless: false,- defaultViewport: null,- ignoreDefaultArgs: ['--enable-automation'],- args: launchArgs,- });-- // Find the recorder page (the only page in this Chrome)- const deadline = Date.now() + 5000;- while (Date.now() < deadline) {- const pages = await desktopRecorder.browser.pages();- if (pages.length > 0) {- desktopRecorder.page = pages[0];- break;- }- await new Promise(r => setTimeout(r, 50));- }- if (!desktopRecorder.page) throw new Error('Recorder window did not produce a page');-- await desktopRecorder.page.waitForFunction(() => typeof window.startRecording === 'function', { timeout: 10000 });-- // ββ Bug 1 fix: anchor bottom-right via screen.availWidth/availHeight ββ- // Chrome's --window-position uses physical pixels and ignores the taskbar,- // so a hardcoded fallback can land the HUD off-screen on any monitor that- // isn't 1920Γ1080. Query the page's `screen.avail*` (DIPs, taskbar-aware)- // and use CDP Browser.setWindowBounds to position it precisely.- try {- const cdp = await desktopRecorder.page.target().createCDPSession();- desktopRecorder.cdp = cdp;- const wInfo = await cdp.send('Browser.getWindowForTarget', {});- desktopRecorder.windowId = wInfo.windowId;-- const dims = await desktopRecorder.page.evaluate(() => ({- availLeft: screen.availLeft || 0,- availTop: screen.availTop || 0,- availWidth: screen.availWidth,- availHeight: screen.availHeight,- outerWidth: window.outerWidth,- outerHeight: window.outerHeight,- }));- const margin = 16;- const winW = Math.min(dims.outerWidth, dims.availWidth);- const winH = Math.min(dims.outerHeight, dims.availHeight);- const left = Math.max(dims.availLeft, dims.availLeft + dims.availWidth - winW - margin);- const top = Math.max(dims.availTop, dims.availTop + dims.availHeight - winH - margin);- await cdp.send('Browser.setWindowBounds', {- windowId: wInfo.windowId,- bounds: { left, top, width: winW, height: winH, windowState: 'normal' },- });- console.log(`[recorder] anchored bottom-right: (${left},${top}) ${winW}x${winH} (work area ${dims.availWidth}x${dims.availHeight})`);- } catch (e) {- console.log(`[recorder] anchor failed (non-fatal): ${e.message}`);- }-- // Wire exposed functions- await desktopRecorder.page.exposeFunction('_saveChunk', (b64) => {- if (!desktopRecorder.current || !desktopRecorder.current.fd) return;- try {- fs.writeSync(desktopRecorder.current.fd, Buffer.from(b64, 'base64'));- } catch (e) {- console.error(`[recorder] saveChunk failed: ${e.message}`);- }- });-- await desktopRecorder.page.exposeFunction('_recordingStopped', async ({ sentBytes, durationMs }) => {- const cur = desktopRecorder.current;- if (!cur) return;- try { fs.closeSync(cur.fd); } catch {}- cur.fd = null;-- // ββ Bug 3a fix: ffmpeg remux to write SegmentDuration ββ- // MediaRecorder writes a "live" WebM with no Duration element, so- // ffprobe reports duration=N/A. Earlier attempts at byte-level EBML- // patching corrupted the Tracks element / SeekHead offsets and made- // the resulting files unplayable (nb_streams=0). Ditched.- //- // The reliable fix is `ffmpeg -i in.webm -c copy out.webm` β copies- // streams (no re-encode, fast: ~50-200 MB/s), and the muxer writes- // a proper SegmentInfo with Duration. We do this asynchronously so- // the UI's "Saved" state appears immediately; the patched file is- // ready by the time the user pulls it.- //- // If ffmpeg isn't on PATH we leave the file untouched. It's still a- // valid (playable) WebM β just no Duration tag in the container.- // Telemetry: log clearly so the user knows whether it ran.- try {- await remuxWebmAddDuration(cur.filePath);- console.log(`[recorder] ffmpeg remux added Duration tag to ${cur.filePath}`);- } catch (e) {- console.error(`[recorder] ffmpeg remux failed (file still plays, ffprobe will report N/A): ${e.message}`);- }-- let sizeKB = 0;- try { sizeKB = Math.round(fs.statSync(cur.filePath).size / 1024); } catch {}- cur.durationMs = durationMs;- cur.sizeKB = sizeKB;- cur.stoppedAt = Date.now();- // Write sidecar metadata- try {- const sidecar = {- recordingId: cur.recordingId,- filePath: cur.filePath,- startedAt: cur.startedAt,- stoppedAt: cur.stoppedAt,- durationMs,- sizeKB,- sizeBytes: sentBytes,- fps: cur.fps,- audio: cur.audio,- monitor: cur.monitor,- reason: cur.reason,- stopReason: cur.stopReason || 'requested',- };- fs.writeFileSync(cur.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));- } catch (e) {- console.error(`[recorder] sidecar write failed: ${e.message}`);- }- // Add to session clip list- desktopRecorder.clipsThisSession.push({- recordingId: cur.recordingId, filePath: cur.filePath,- sizeKB, durationMs, reason: cur.reason,- });- desktopRecorder.current = null;- console.log(`[recorder] Clip ${cur.recordingId} stopped: ${sizeKB}KB, ${durationMs}ms`);- // Tell page to show Saved state- try {- await desktopRecorder.page.evaluate((clip) => window.showSaved && window.showSaved(clip), {- filename: path.basename(cur.filePath),- sizeBytes: sentBytes,- durationMs,- });- } catch {}- // Resolve any waiter- if (cur.stopResolver) cur.stopResolver();- });-- await desktopRecorder.page.exposeFunction('_resizeWindow', async ({ width, height }) => {- try {- const cdp = desktopRecorder.cdp || (desktopRecorder.cdp = await desktopRecorder.page.target().createCDPSession());- const target = desktopRecorder.page.target();- const targetId = target._targetId || target.target?.()?._targetId || (await target.createCDPSession()).then(()=>{}); // best effort- // Use Puppeteer's underlying CDP: simpler to use Browser.getWindowForTarget by sending raw CDP- const { windowId } = await cdp.send('Browser.getWindowForTarget', {});- desktopRecorder.windowId = windowId;- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width, height } });- } catch (e) {- console.log(`[recorder] resize failed (non-fatal): ${e.message}`);- }- });-- await desktopRecorder.page.exposeFunction('_recorderClosed', () => {- console.log(`[recorder] Page reports closing`);- });-- await desktopRecorder.page.exposeFunction('_openRecordingsFolder', () => {- try {- execSync(`explorer "${RECORDINGS_DIR}"`, { stdio: 'ignore' });- } catch {}- });-- // Detect window-close as a stop signal- desktopRecorder.browser.on('disconnected', () => {- console.log(`[recorder] Browser disconnected`);- if (desktopRecorder.current) {- // Best-effort: mark stopped, write whatever we have- try { if (desktopRecorder.current.fd) fs.closeSync(desktopRecorder.current.fd); } catch {}- if (desktopRecorder.current.stopResolver) desktopRecorder.current.stopResolver();- desktopRecorder.current = null;- }- desktopRecorder.browser = null;- desktopRecorder.page = null;- desktopRecorder.cdp = null;- desktopRecorder.reason = null;- desktopRecorder.clipsThisSession = [];- });-- desktopRecorder.reason = reason;- desktopRecorder.hudOpenedAt = Date.now();- desktopRecorder.clipsThisSession = [];-}--async function closeRecorderWindow() {- if (desktopRecorder.current) {- // Stop active recording first- try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}- // Wait briefly for stop to flush- const deadline = Date.now() + 3000;- while (desktopRecorder.current && Date.now() < deadline) {- await new Promise(r => setTimeout(r, 50));- }- }- const summary = {- clipCount: desktopRecorder.clipsThisSession.length,- totalSizeKB: desktopRecorder.clipsThisSession.reduce((a, c) => a + (c.sizeKB || 0), 0),- reason: desktopRecorder.reason,- clips: desktopRecorder.clipsThisSession,- };- if (desktopRecorder.browser) {- try { await desktopRecorder.browser.close(); } catch {}- }- desktopRecorder.browser = null;- desktopRecorder.page = null;- desktopRecorder.cdp = null;- desktopRecorder.reason = null;- desktopRecorder.current = null;- desktopRecorder.clipsThisSession = [];- return summary;-}--async function desktopRecordStartImpl(opts) {- const { reason, monitor = 'primary', fps = 30, audio = false,- videoBitsPerSecond = 4_000_000, maxDurationMs = 600_000,- confirmDesktopNotTabRecording } = opts;-- if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {- throw new Error('reason is required β explain to the user why you\'re recording');- }-- // Guardrail: Claude frequently mistakes "record this pup window/tab" for a- // desktop-recording request. Desktop recording captures the WHOLE screen- // (including unrelated apps in the foreground); if the pup window is- // occluded the resulting clip won't show what the user actually wanted.- // For 95% of "record a pup" asks the right verb is pup_record_start- // (tab-scoped via CDP, captures regardless of foreground state, ~50fps).- // Adom-desktop's full-desktop recording is mostly redundant with what- // Hydrogen ships natively, and only really useful when Hydrogen is- // already busy doing tab-recording for ralph-loop screenshot tests AND- // the user wants a parallel desktop video at the same time.- // Force the caller to explicitly opt in so they re-read this and confirm.- if (confirmDesktopNotTabRecording !== true) {- const err = new Error(- 'desktop_record_start records the WHOLE DESKTOP, not a pup tab. ' +- 'If the user said "record a pup" / "record this Chrome window" / "record the page", ' +- 'they almost certainly want pup_record_start instead β that is tab-scoped via ' +- 'CDP Page.startScreencast, captures the pup tab regardless of foreground state, ' +- 'returns a single .webm at ~50fps, and does not need a HUD. ' +- 'Hydrogen also has built-in desktop recording (recording start --share screen) which ' +- 'is usually preferable for general "record my screen" asks. ' +- 'adom-bridge\'s desktop_record_* is mainly for the case where Hydrogen is already ' +- 'doing tab recording (ralph-loop screenshot tests) and you want a parallel desktop ' +- 'video at the same time. ' +- 'If you have read this and you really do want full-desktop recording (not pup tab), ' +- 'pass confirmDesktopNotTabRecording: true and resend the call.'- );- err.code = 'desktop_record_needs_confirmation';- err.hint = 'For pup tab recording: adom-bridge-cli --ai-thread "<your thread>" pup_record_start \'{"sessionId":"<id>","fps":30}\'. ' +- 'For Hydrogen-driven desktop recording: hydrogen recording start --share screen. ' +- 'For a parallel desktop clip alongside Hydrogen tab capture: re-call with confirmDesktopNotTabRecording: true.';- throw err;- }-- if (desktopRecorder.current && !desktopRecorder.current.stopping) {- throw new Error(`A recording is already in progress (${desktopRecorder.current.recordingId}). Stop it first.`);- }-- await ensureRecorderWindowOpen(reason);-- const recordingId = `rec-${++desktopRecorder.recCounter}`;- const ts = Date.now();- const filePath = path.join(RECORDINGS_DIR, `rec-desktop-${ts}.webm`);- const fd = fs.openSync(filePath, 'w');-- desktopRecorder.current = {- recordingId, filePath, fd,- startedAt: ts, stoppedAt: null,- durationMs: 0, sizeKB: 0,- fps, audio, monitor, reason,- stopping: false, stopResolver: null,- stopReason: null,- };-- // Safety cap- desktopRecorder.current.maxDurationTimer = setTimeout(async () => {- if (desktopRecorder.current && desktopRecorder.current.recordingId === recordingId) {- desktopRecorder.current.stopReason = 'maxDuration';- try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}- }- }, maxDurationMs);-- // Tell the page to start recording- await desktopRecorder.page.evaluate((startOpts) => window.startRecording(startOpts), {- fps, audio, videoBitsPerSecond, reason,- });-- return {- recordingId, filePath, startedAt: ts,- monitor: { name: monitor }, reason,- };-}--async function desktopRecordStopImpl({ recordingId } = {}) {- if (!desktopRecorder.current) throw new Error('No active desktop recording');- const cur = desktopRecorder.current;- if (recordingId && cur.recordingId !== recordingId) {- throw new Error(`Recording ${recordingId} not active (current is ${cur.recordingId})`);- }- if (cur.stopping) throw new Error('Already stopping');- cur.stopping = true;- if (cur.maxDurationTimer) clearTimeout(cur.maxDurationTimer);-- const stopWait = new Promise(r => { cur.stopResolver = r; });- try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch (e) {- // If evaluate fails (e.g. page died), we still resolve below via disconnected handler- console.log(`[recorder] page.stopRecording threw: ${e.message}`);- }- // Wait for _recordingStopped exposed function to fire (with timeout)- await Promise.race([stopWait, new Promise(r => setTimeout(r, 10000))]);-- const stopReason = cur.stopReason || 'requested';- return {- recordingId: cur.recordingId,- filePath: cur.filePath,- sizeKB: cur.sizeKB,- durationMs: cur.durationMs,- stopReason,- };-}--// βββ Tab recording (CDP screencast) ββββββββββββββββββββββββββββββββββββββββ--// ββ Tab recording via CDP Page.startScreencast β ffmpeg pipe ββββββββββββββ-//-// The previous incarnation of this code (v1.3.32-attempt) tried Chrome's-// in-tab MediaRecorder + getDisplayMedia({preferCurrentTab:true}). That-// path turned out to be unreliable for tab scoping: Chromium's-// --use-fake-ui-for-media-stream defaults to "Entire Screen" capture, and-// --auto-select-tab-capture-source-by-title scans across ALL Chrome-// processes (it picked the user's other Chrome windows instead of pup's).-// No flag combination reliably scoped the capture to the calling tab.-//-// CDP Page.startScreencast IS reliably tab-scoped at the protocol level β-// the CDP session is bound to a specific Page target, and screencastFrame-// only emits frames for that Page. We pipe those JPEG frames directly into-// ffmpeg's stdin (no per-frame disk write, no tar bundle). ffmpeg encodes-// VP9 in real time and produces a single .webm file. ffmpeg is already-// installed (FFMPEG_PATH probed at module load; same dep as the desktop-// recorder's duration-remux step).-//-// Caller note: Chrome throttles paint on occluded windows, so call-// pup_raise_os_window first if you want frames at the requested fps.--// Brief pause to let any in-flight Page.captureScreenshot settle before-// detaching the CDP session. Twice the frame interval is a safe upper bound-// for the longest a single capture can take.-function intervalSafeWait(fps) {- return Math.min(200, Math.max(50, Math.round(2 * 1000 / fps)));-}--async function tabRecordStartImpl({- sessionId, tabId,- fps = 30,- quality = 85,- maxDurationMs = 600_000,-}) {- if (!FFMPEG_PATH) detectFFmpeg(); // lazy re-scan: ffmpeg may have been installed since the bridge started- if (!FFMPEG_PATH) {- throw new Error(`ffmpeg not found. Install it (${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}), then just retry β the bridge re-detects it, no restart needed.`);- }-- const session = sessions.get(sessionId);- if (!session) throw new Error(`Session "${sessionId}" not found`);- const { tab } = resolveTabOrError(session, sessionId, { tabId });-- // One recording per tab.- for (const r of tabRecordings.values()) {- if (r.sessionId === sessionId && r.tabId === tab.tabId && !r.stopping) {- throw new Error(`Tab ${tab.tabId} in session ${sessionId} is already being recorded (${r.recordingId})`);- }- }-- const recordingId = `rec-tab-${++tabRecCounter}`;- const ts = Date.now();- const filePath = path.join(- RECORDINGS_DIR,- `rec-tab-${sessionId}-${tab.tabId}-${ts}.webm`,- );-- // Frames go to a temp dir during capture; ffmpeg muxes them to webm at- // stop time via image2 demuxer with the f%06d.jpg pattern. This avoids- // ffmpeg-stdin-streaming pitfalls (image2pipe and mjpeg both exit early- // when input briefly stalls β observed in testing).- const framesDir = path.join(RECORDINGS_DIR, `${path.basename(filePath, '.webm')}.frames`);- fs.mkdirSync(framesDir, { recursive: true });-- const cdp = await tab.page.target().createCDPSession();-- const state = {- recordingId, sessionId, tabId: tab.tabId,- cdp, filePath, framesDir,- fps, quality,- startedAt: ts, stoppedAt: null,- frameCount: 0, sizeBytes: 0,- frameTimestamps: [], // wall-clock ms for each captured frame, used to mux with real timing- stopping: false, stopTimer: null, stopReason: null,- captureLoop: null,- };- tabRecordings.set(recordingId, state);-- // Bring tab to front so Chrome's compositor stays warm- try { await tab.page.bringToFront(); } catch {}-- // ββ CDP Page.startScreencast with frame ACK βββββββββββββββββββββββββββββ- // Chrome's compositor pushes JPEG frames as the page paints. Each frame- // arrives with a sessionId that MUST be acknowledged via- // Page.screencastFrameAck or Chrome stops sending after the first frame- // (backpressure). Earlier impl (1.3.32) missed the ack β that's why we- // saw "1 frame and stops" behavior, not paint-throttling. Tab-scoped at- // the protocol level β the CDP session is bound to a specific Page target.- //- // everyNthFrame=1 means "every paint frame"; combined with the page's- // own paint rate (60fps when foregrounded), this gives 60 fps capture- // ceiling. Quality 60-85 keeps per-frame size manageable.- state.frameHandler = async (event) => {- if (state.stopping) return;- const { data, sessionId: ackId } = event;- const buf = Buffer.from(data, 'base64');- const capturedAt = Date.now();- state.frameCount += 1;- state.sizeBytes += buf.length;- state.frameTimestamps.push(capturedAt - state.startedAt);- if (state.frameCount === 1) {- console.log(`[${recordingId}] FIRST screencast frame (${buf.length} bytes)`);- }- const fname = `f${String(state.frameCount).padStart(6, '0')}.jpg`;- try { // v2.0.230: was writeFileSync at up to 60fps β every frame blocked the event loop and stalled- // every other session's CDP traffic for the length of a recording. The ack below is the- // backpressure, so the write can be async.- fs.promises.writeFile(path.join(state.framesDir, fname), buf).catch(() => {}); }- catch (e) { console.error(`[${recordingId}] frame write failed: ${e.message}`); }- // ACK so Chrome continues sending. Without this, only frame 1 arrives.- try { await cdp.send('Page.screencastFrameAck', { sessionId: ackId }); }- catch (e) {- if (state.frameCount < 3) console.log(`[${recordingId}] ack error: ${e.message}`);- }- };- cdp.on('Page.screencastFrame', state.frameHandler);-- await cdp.send('Page.startScreencast', {- format: 'jpeg',- quality,- everyNthFrame: Math.max(1, Math.round(60 / fps)), // 1 -> 60fps, 2 -> 30fps, etc- });-- console.log(`[${recordingId}] CDP screencast started @ target ${fps}fps (everyNthFrame=${Math.max(1, Math.round(60 / fps))}) β ${framesDir}`);-- state.stopTimer = setTimeout(() => {- state.stopReason = 'maxDuration';- tabRecordStopImpl({ sessionId, recordingId }).catch(() => {});- }, maxDurationMs);-- return { recordingId, sessionId, tabId: tab.tabId, filePath, startedAt: ts };-}--async function tabRecordStopImpl({ sessionId, recordingId }) {- const state = tabRecordings.get(recordingId);- if (!state) throw new Error(`Recording ${recordingId} not found`);- if (sessionId && state.sessionId !== sessionId) throw new Error(`Recording ${recordingId} belongs to a different session`);- if (state.stopping) {- const deadline = Date.now() + 5000;- while (tabRecordings.has(recordingId) && Date.now() < deadline) await new Promise(r => setTimeout(r, 50));- throw new Error(`Recording ${recordingId} already stopping`);- }- state.stopping = true;- if (state.stopTimer) clearTimeout(state.stopTimer);-- // Stop screencast β Chrome stops emitting Page.screencastFrame events- try { await state.cdp.send('Page.stopScreencast'); } catch {}- // Detach the CDP frame handler so any late frames are dropped- if (state.frameHandler) {- try { state.cdp.off('Page.screencastFrame', state.frameHandler); } catch {}- state.frameHandler = null;- }- // Brief settle window for in-flight frames already in the JS event loop- await new Promise(r => setTimeout(r, intervalSafeWait(state.fps)));- try { await state.cdp.detach(); } catch {}-- state.stoppedAt = Date.now();- const durationMs = state.stoppedAt - state.startedAt;-- // ββ Mux the frames dir to webm via ffmpeg concat demuxer ββββββββββββββ- // Each captured frame has a wall-clock timestamp (state.frameTimestamps).- // We emit a concat list with explicit per-frame durations so playback- // matches real wall time even when actual capture rate fell below the- // requested fps (Page.captureScreenshot is GPU-bound; on a busy machine- // 30 fps requested often delivers ~10-15 fps actual). Using the concat- // demuxer with per-frame durations produces a video that plays in- // real-time regardless of capture-rate variance.- if (state.frameCount > 0) {- const { spawn } = require('child_process');- const totalDurMs = (state.frameTimestamps[state.frameTimestamps.length - 1] || 0) + 1; // +1 to avoid zero- const actualFps = state.frameCount / (totalDurMs / 1000);-- // Build concat list: each frame followed by `duration <seconds>`. The- // last file entry must be repeated (concat demuxer quirk) so the final- // frame is held for its proper duration instead of clipped.- const concatLines = [];- for (let i = 0; i < state.frameCount; i++) {- const fname = `f${String(i + 1).padStart(6, '0')}.jpg`;- concatLines.push(`file '${fname}'`);- const next = state.frameTimestamps[i + 1];- const cur = state.frameTimestamps[i];- const durSec = next != null- ? Math.max(0.001, (next - cur) / 1000)- : Math.max(0.001, 1 / Math.max(1, actualFps));- concatLines.push(`duration ${durSec.toFixed(4)}`);- }- // Repeat the last file entry (no duration) β concat demuxer requirement- concatLines.push(`file 'f${String(state.frameCount).padStart(6, '0')}.jpg'`);- const concatPath = path.join(state.framesDir, 'concat.txt');- fs.writeFileSync(concatPath, concatLines.join('\n'));-- const muxArgs = [- '-y',- '-loglevel', 'error',- '-f', 'concat',- '-safe', '0',- '-i', concatPath,- '-vsync', 'vfr',- '-c:v', 'libvpx-vp9',- '-b:v', '0',- '-crf', '32',- '-pix_fmt', 'yuv420p',- '-row-mt', '1',- '-deadline', 'realtime',- '-cpu-used', '8',- '-an',- state.filePath,- ];- console.log(`[${state.recordingId}] muxing ${state.frameCount} frames over ${(totalDurMs/1000).toFixed(2)}s (avg ${actualFps.toFixed(1)} fps actual, ${state.fps} target)`);- const muxStart = Date.now();- await new Promise((resolve, reject) => {- const proc = spawn(FFMPEG_PATH, muxArgs, { stdio: ['ignore', 'ignore', 'pipe'] });- let stderr = '';- proc.stderr.on('data', b => { stderr += b.toString(); });- proc.on('error', reject);- proc.on('exit', code => {- if (code === 0) return resolve();- reject(new Error(`ffmpeg mux exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));- });- }).catch(e => {- console.error(`[${state.recordingId}] mux failed: ${e.message}`);- });- console.log(`[${state.recordingId}] muxed ${state.frameCount} frames β ${state.filePath} in ${Date.now() - muxStart}ms`);-- // Clean up frames dir (keep on mux failure for debugging)- if (fs.existsSync(state.filePath)) {- try { fs.rmSync(state.framesDir, { recursive: true, force: true }); }- catch (e) { console.log(`[${state.recordingId}] frames cleanup failed: ${e.message}`); }- }- } else {- console.log(`[${state.recordingId}] zero frames captured β no webm produced`);- }-- // Sidecar metadata (used by pup_record_list)- try {- const sidecar = {- recordingId, sessionId: state.sessionId, tabId: state.tabId,- filePath: state.filePath, fps: state.fps, quality: state.quality,- startedAt: state.startedAt, stoppedAt: state.stoppedAt,- durationMs, frameCount: state.frameCount, sizeBytes: state.sizeBytes,- stopReason: state.stopReason || 'requested',- };- fs.writeFileSync(state.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));- } catch (e) {- console.error(`[${state.recordingId}] sidecar write failed: ${e.message}`);- }-- let sizeKB = 0;- try { sizeKB = Math.round(fs.statSync(state.filePath).size / 1024); } catch {}-- const actualFps = durationMs > 0 ? +(state.frameCount / (durationMs / 1000)).toFixed(2) : 0;-- tabRecordings.delete(recordingId);- return {- recordingId,- sessionId: state.sessionId,- tabId: state.tabId,- filePath: state.filePath,- sizeKB,- durationMs,- frameCount: state.frameCount,- fpsTarget: state.fps,- fpsActual: actualFps,- stopReason: state.stopReason || 'requested',- };-}--function tabRecordStatusImpl({ sessionId } = {}) {- const active = [];- for (const s of tabRecordings.values()) {- if (sessionId && s.sessionId !== sessionId) continue;- if (s.stopping) continue;- const elapsedMs = Date.now() - s.startedAt;- const actualFps = elapsedMs > 100 ? +(s.frameCount / (elapsedMs / 1000)).toFixed(2) : null;- active.push({- recordingId: s.recordingId, sessionId: s.sessionId, tabId: s.tabId,- filePath: s.filePath,- fpsTarget: s.fps,- fpsActual: actualFps,- frameCount: s.frameCount,- durationMs: elapsedMs,- sizeBytesApprox: s.sizeBytes,- startedAt: s.startedAt,- });- }- return active;-}--// v2.0.236: these were two copies of the same directory scan differing only in the filename prefix-// and two sidecar fields. One implementation, two thin wrappers so the call sites read the same.-function listRecordings(prefix, extra) {- try {- return fs.readdirSync(RECORDINGS_DIR)- .filter(f => f.startsWith(prefix) && f.endsWith('.webm'))- .map(f => {- const fp = path.join(RECORDINGS_DIR, f);- const stat = fs.statSync(fp);- let sidecar = {};- try { sidecar = JSON.parse(fs.readFileSync(fp.replace(/\.webm$/, '.json'), 'utf8')); } catch {}- const base = {- recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),- filePath: fp,- sizeKB: Math.round(stat.size / 1024),- durationMs: sidecar.durationMs || null,- mtime: stat.mtime.toISOString(),- };- for (const k of extra) base[k] = sidecar[k] !== undefined ? sidecar[k] : null;- return base;- });- } catch { return []; }-}-const tabRecordListImpl = () => listRecordings('rec-tab-', ['fps', 'sessionId', 'tabId']);-const desktopRecordListImpl = () => listRecordings('rec-desktop-', ['reason']);--// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ--// Graceful shutdown: disconnect from Chrome instead of killing it-// Chrome stays alive for reconnect on next bridge startup-async function gracefulShutdown(signal) {- console.log(`\n[${signal}] Draining recordings + disconnecting from Chrome (Chrome stays alive for reconnect)...`);-- // Drain active desktop recording- if (desktopRecorder.current) {- try {- desktopRecorder.current.stopReason = 'bridgeShutdown';- await desktopRecordStopImpl({ recordingId: desktopRecorder.current.recordingId });- } catch (e) {- console.error(` desktop recording stop failed: ${e.message}`);- }- }- // Close recorder window so its WebM has a valid trailing cluster- if (desktopRecorder.browser) {- try { await desktopRecorder.browser.close(); } catch {}- }-- // Drain active tab recordings- for (const [recId, _state] of [...tabRecordings.entries()]) {- try {- _state.stopReason = 'bridgeShutdown';- await tabRecordStopImpl({ sessionId: _state.sessionId, recordingId: recId });- } catch (e) {- console.error(` tab recording ${recId} stop failed: ${e.message}`);- }- }-- for (const [name, entry] of browsers) {- try {- entry.browser.disconnect();- console.log(` Disconnected from browser "${name}"`);- } catch (e) {- console.error(` Error disconnecting "${name}": ${e.message}`);- }- }- browsers.clear();- sessions.clear();- process.exit(0);-}-process.on('SIGINT', () => gracefulShutdown('SIGINT'));-process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));+ // 2.0.720 macOS: in-memory cookie key; otherwise OSCrypt pops a login-keychain prompt per CfT binary.+ ...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []),+ '--disable-sync', '--disable-background-networking',+ '--disable-infobars',+ // v2.0.57 (John: the "Chromium now launches when Windows starts" promo infobar on fresh+ // profiles is hideous): layered suppression. Unknown --disable-features names are ignored+ // harmlessly, so we name every plausible id for the launch-on-startup promo; and+ // --disable-field-trial-config turns off the experiment config that gates promos like it,+ // which is also the determinism WHY-CFT promises (same browser on every machine).+ '--disable-field-trial-config',+ // v2.0.120 (John got a Windows Firewall/UAC prompt when his payroll thread opened a Gusto+ // window): Chrome's Cast/MediaRouter feature binds an mDNS listener on 0.0.0.0:5353 β a+ // network-exposed socket, which is exactly what makes Windows Firewall prompt (and it prompts+ // per-new-exe, so every CfT version bump re-triggers it). pup is an automation browser that+ // never casts, so disable MediaRouter + the DIAL/mDNS route providers: Chrome then binds only+ // loopback and NO firewall prompt is ever shown. --disable-background-networking (above) alone+ // does not cover this; MediaRouter's mDNS is separate.+ '--disable-features=OptimizationGuideModelDownloading,OptimizationHints,OptimizationHintsFetching,OptimizationTargetPrediction,TextSafetyClassifier,WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider',+ '--media-router=0',+ // v2.0.393 (John saw the Windows Firewall prompt again on winvm, 2026-09-04, on a freshly+ // downloaded CfT): MediaRouter was not the only non-loopback bind. A 40s socket sampler while+ // DigiKey + TI loaded caught five transient UDP sockets on 0.0.0.0 (WebRTC ICE candidates and+ // QUIC/HTTP-3 client sockets). Windows prompts on the FIRST such bind of a new exe, and every+ // CfT download is a new exe. pup never needs inbound or peer-to-peer: no QUIC (TCP does not+ // prompt), WebRTC only through a proxy (no raw UDP bind), and no WebRTC mDNS responder.+ '--disable-quic',+ '--force-webrtc-ip-handling-policy=disable_non_proxied_udp',+ '--webrtc-ip-handling-policy=disable_non_proxied_udp',+ // v2.0.394: the QUIC/WebRTC flags above did NOT stop the binds (measured: six paired v4/v6+ // UDP sockets on 0.0.0.0 / :: while pages loaded, on a Chrome that carried every flag). The+ // pattern is Chrome's BUILT-IN async DNS client, which opens its own UDP sockets per query+ // instead of using the OS resolver - and a bound UDP socket is what Windows Firewall prompts+ // on. Hand DNS back to the OS resolver: Chrome then opens no UDP socket at all.+ // v2.0.395: AsyncDns off was still not enough on DigiKey (three paired v4/v6 UDP sockets during+ // the load, none on TI). Chrome issues HTTPS-type (SVCB) DNS queries per host for HTTP/3 + ECH+ // discovery through its OWN DnsClient even when the A/AAAA path uses the OS resolver. Off too.+ '--disable-features=WebRtcHideLocalIpsWithMdns,WebRtcAllowInputVolumeAdjustment,AsyncDns,DnsOverHttps,DnsOverHttpsUpgrade,UseDnsHttpsSvcb,UseDnsHttpsSvcbAlpn,EncryptedClientHello',+ '--disable-async-dns',+ '--ash-no-nudges',+ // ADOM-YOU VISUAL IDENTITY (John, 2026-08-06). A custom logo image on the+ // Chrome profile chip is impossible for a local non-signed-in profile+ // (faking the gaia picture shows a gray generic + a permanent "Action+ // required" nag β tested, see brandPupProfile / pup-profile-identity). The+ // reliable, always-visible, no-nag signal is an autogenerated theme: this+ // flag tints the frame + toolbar Adom-teal so EVERY adom-you window is+ // unmistakably an Adom window at a glance. Only the durable shared jar gets+ // it (John's pick); isolated throwaways stay plain Chrome. The flag installs+ // the theme INTO the profile, so it persists across later launches too.+ // v1.9.345 (John): frame color is the Adom near-black ground #0d1117 (RGB 13,17,23), NOT the old+ // bright teal (20,184,184) β "the teal is a bit ugly; something closer to black but within the Adom+ // brand". This dark-slate frame color-codes pup windows as the adom-you doppelganger's own windows,+ // distinct from the user's real Chrome profiles, without a garish tint.+ ...(profileName === DEFAULT_PROFILE ? ['--install-autogenerated-theme=13,17,23'] : []),+ // #435 β CRASH-RESTORE SUPPRESSION. pup profiles are AI-driven and never want Chrome's "Restore+ // pages? Chrome didn't shut down correctly" flow. After a shared-Chrome crash it reopens the DEAD+ // session's tabs (URL dupes + about:blank) into whatever window relaunches, and pup then adopts+ // them into the WRONG live session (John: "why are there about:blank tabs in my review window?").+ // prepareCleanProfile() resets exit_type=Normal + wipes the session blobs before a FRESH launch,+ // but can't run on the RECONNECT relaunch path (Chrome already up). These flags kill the crash+ // bubble/restore on EVERY launch path (they flow into the detached chromeArgs via the launchOpts+ // .args spread) so there are no stray restored tabs to misroute in the first place.+ '--disable-session-crashed-bubble', '--hide-crash-restore-bubble',+ // The "controlled by automated test software" infobar is keyed on the PRESENCE of+ // --enable-automation (stripped via ignoreDefaultArgs; never pass it). With the+ // unsupported flags gone from default launches (v1.9.129) there is no warning bar+ // left to hush, so --test-type is only added on the insecureDev opt-in above.+ // v1.9.159 (John saw the yellow bar): --disable-blink-features=AutomationControlled REMOVED.+ // It was the LAST warning-triggering flag after 1.9.129 (whose --test-type removal un-hushed+ // the 'unsupported command-line flag' infobar), so every window wore the banner. It is also+ // redundant: the AutomationControlled blink feature only activates via --enable-automation,+ // which pup strips (ignoreDefaultArgs) and never passes β navigator.webdriver is false+ // without it (verified live before removal).+ // Tab recording uses CDP Page.startScreencast piped to ffmpeg β see+ // tabRecordStartImpl below. CDP screencast is scoped to a specific+ // page target at the protocol level, so cross-tab/cross-Chrome+ // leakage is impossible. The flags below keep the page rendering at+ // full rate even when occluded, so screencast frames stream+ // continuously instead of being paint-throttled to ~0 fps:+ // --disable-renderer-backgrounding: keep rendering when in background tab+ // --disable-backgrounding-occluded-windows: keep rendering when window is occluded+ // --disable-background-timer-throttling: keep timers/RAF firing in background+ // --disable-features=CalculateNativeWinOcclusion:+ // skip the occlusion-detection probe+ // Without these, recording a tab while the user is in another app+ // produces a 1-2 fps video.+ //+ // v1.9.76 β THESE ARE NOW OPT-IN (highFps). They were applied to EVERY window,+ // forever, which is why an idle pup window could never enter Chrome's low-power+ // state: we had disabled all four of the mechanisms that let it. Three idle windows+ // held a Modern Standby laptop out of deep idle overnight and flattened the battery+ // (wiki issue #14). A NORMAL Chrome window does not do this because it throttles when+ // occluded β ours structurally could not. Default is now Chrome's own power behaviour;+ // callers that genuinely need continuous frames ask for highFps and get a LEASE.+ // v1.9.198 β ALWAYS disable Chrome's NATIVE WINDOWS OCCLUSION CALCULATION. This is the single+ // targeted cure for the bug behind John's "can't scroll", "frozen", and the Bills thread's+ // silently-failing typing: Chrome's Win32 occlusion detector can latch a window as OCCLUDED and+ // never re-evaluate it. A window in that state reports visibilityState:'hidden' even while it is+ // FRONTMOST, and Chrome then withholds BOTH paint and INPUT β measured directly: pup_eval+ // DOM writes still land (main thread runs) while CDP key events vanish (compositor wedged). Only+ // a real OS click cleared it, which is why pup windows felt broken in a way no verb could fix.+ // pup windows are parked at z-bottom BY DESIGN, so they hit this detector constantly β far more+ // than a normal Chrome window ever would. This flag disables only the occlusion CALCULATION; the+ // three anti-throttle flags below stay opt-in (highFps), so ordinary background throttling β and+ // the battery saving from issue #14 β is untouched.+ '--disable-features=CalculateNativeWinOcclusion',+ ...(highFps ? [+ '--disable-renderer-backgrounding',+ '--disable-backgrounding-occluded-windows',+ '--disable-background-timer-throttling',+ ] : []),+ ],+ ignoreDefaultArgs: ['--enable-automation'],+ // Let Chrome survive if the bridge process dies β enables reconnect on restart+ handleSIGINT: false,+ handleSIGTERM: false,+ handleSIGHUP: false,+ pipe: false,+ };++ // nativeSite mode: strip flags that break complex Google sites (GCP Console,+ // Google Workspace admin, etc.). These sites rely on background networking,+ // CORS enforcement, and Chrome sync for org/billing context.+ if (nativeSite) {+ const stripFlags = new Set(['--disable-background-networking', '--disable-sync', '--disable-web-security']);+ launchOpts.args = launchOpts.args.filter(a => !stripFlags.has(a));+ console.log(`nativeSite mode: stripped ${stripFlags.size} flags for real-site compatibility`);+ }+ // v2.0.293: collapse duplicate feature flags for the puppeteer.launch path too (the detached+ // Windows path merges chromeArgs at its own assembly). See mergeChromiumFeatureFlags above.+ launchOpts.args = mergeChromiumFeatureFlags(launchOpts.args);++ let userDataDir = null;+ if (!freshProfile) {+ userDataDir = path.join(PROFILES_DIR, profileName);+ fs.mkdirSync(userDataDir, { recursive: true });+ launchOpts.userDataDir = userDataDir;+ console.log(`Browser for profile "${profileName}": persistent at ${userDataDir}`);+ } else {+ console.log(`Browser for profile "${profileName}": fresh temporary profile`);+ }++ // Try to reconnect to an existing Chrome first (e.g. after bridge restart).+ // After laptop sleep/wake, fetch() can hang indefinitely on dead sockets,+ // so we apply a 3s timeout via AbortController β failure falls through+ // to the launch path (which has its own retry + orphan cleanup).+ let browser;+ if (userDataDir) {+ const portFile = path.join(userDataDir, 'DevToolsActivePort');+ try {+ const portData = fs.readFileSync(portFile, 'utf8').trim();+ const port = parseInt(portData.split('\n')[0], 10);+ if (port && !isNaN(port)) {+ const ac = new AbortController();+ const tid = setTimeout(() => ac.abort(), 3000);+ let resp = null;+ try {+ resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });+ } catch (e) {+ // timed out, refused, or TCP stack hung after sleep β ignore+ console.log(`Reconnect probe to :${port} failed (${e.message}) β will relaunch`);+ } finally {+ clearTimeout(tid);+ }+ if (resp && resp.ok) {+ // Also gate puppeteer.connect with a timeout β it can hang too+ try {+ browser = await Promise.race([+ puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }),+ new Promise((_, rej) => setTimeout(() => rej(new Error('puppeteer.connect timeout')), 5000)),+ ]);+ browser._cdpPort = port;+ const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);+ if (sFiles.length > 0) browser._detachedPid = sFiles[0].pid;+ console.log(`Reconnected to existing Chrome on port ${port} for profile "${profileName}"`);+ } catch (e) {+ console.log(`puppeteer.connect to :${port} failed (${e.message}) β will relaunch`);+ browser = null;+ }+ }+ }+ } catch { /* DevToolsActivePort doesn't exist or is stale β launch fresh */ }+ }++ if (!browser) {+ // Pre-launch cleanup: kill any orphaned Chrome holding this profile's lock+ if (userDataDir) {+ const lockFile = path.join(userDataDir, 'SingletonLock');+ const lockExists = fs.existsSync(lockFile) || fs.existsSync(path.join(userDataDir, 'lockfile'));+ if (lockExists) {+ // v2.0.257 β THE BUG THAT COST JOHN THREE WINDOWS (2026-08-20). This kill is authorized by an+ // INFERENCE: "a lock file exists and I could not reconnect just now, therefore the holder is a+ // dead orphan from an unclean shutdown." That inference is wrong in the one case where it is+ // catastrophic. The reconnect above gives up after a 3s probe + a 5s puppeteer.connect, and on+ // a loaded machine a perfectly healthy Chrome misses those deadlines β so pup declared the LIVE+ // shared adom-you Chrome an orphan and killed it, taking three windows with it (two other+ // threads' dashboards and the ab web-demo window John was actively using). pup then relaunched+ // them and reconciled them away as "closed", so from John's side they simply vanished.+ // GROUND TRUTH beats inference: if any live session is bound to this profile, that process is+ // not an orphan β it is the process serving the user's windows. Never kill it. Try one patient+ // reconnect instead, and if that fails, fail THIS launch honestly rather than destroying work+ // that is not ours to destroy. A failed launch costs a retry; a wrong kill costs the user's+ // windows, and they have no way to get them back.+ let _liveOnProfile = 0;+ for (const _s of sessions.values()) { if (_s.profileName === profileName) _liveOnProfile++; }+ if (_liveOnProfile > 0) {+ console.log(`Profile "${profileName}" lock is held while ${_liveOnProfile} live pup session(s) are bound to it β that Chrome is NOT an orphan, it is serving the user's windows. Refusing to kill it; retrying the reconnect patiently.`);+ let _reconnected = null;+ try {+ const _pf = path.join(userDataDir, 'DevToolsActivePort');+ const _p = parseInt(String(fs.readFileSync(_pf, 'utf8')).trim().split('\n')[0], 10);+ if (_p && !isNaN(_p)) {+ _reconnected = await Promise.race([+ puppeteer.connect({ browserURL: `http://127.0.0.1:${_p}`, defaultViewport: null }),+ new Promise((_, rej) => setTimeout(() => rej(new Error('patient reconnect timeout')), 20000)),+ ]);+ _reconnected._cdpPort = _p;+ }+ } catch (e) { console.log(`Patient reconnect to profile "${profileName}" failed too (${e.message})`); }+ if (_reconnected) {+ console.log(`Patient reconnect to profile "${profileName}" SUCCEEDED β the browser was healthy, just slow. Nothing was killed.`);+ browser = _reconnected;+ } else {+ throw new Error(`profile "${profileName}" is in use by ${_liveOnProfile} live pup session(s) and its Chrome could not be reconnected. Refusing to kill the browser hosting those windows just to free the profile lock β that would destroy windows other threads (or the user) are using. Retry in a moment, or close those sessions first with pup_close_window.`);+ }+ } else {+ console.log(`Profile "${profileName}" has a lock file and NO live sessions are bound to it β checking for orphaned Chrome...`);+ await killBrowserProcess(null, profileName);+ // Give OS time to release the lock+ await new Promise(r => setTimeout(r, 1000));+ }+ }++ // Pup is AI-driven, never human-driven, so Chrome's "restore last+ // session" is pure cost: it reopens stale tabs from previous runs+ // (8+ tab buildup after a few iter cycles) AND those tabs keep+ // running heavy content (3D viewers, animations) under the+ // anti-throttle flags we set, eating CPU. Wipe the session-restore+ // state before launch β the AI knows what it wants open.+ // v2.0.257: skip when the patient reconnect above adopted the live browser β that profile is+ // IN USE and wiping its session-restore state (or relaunching over it) is exactly the damage+ // this fix exists to prevent.+ if (!browser) prepareCleanProfile(userDataDir);+ // Give the profile a visible Adom identity in Chrome's profile chip. The+ // shared durable jar is branded "Adom"; a named isolated jar carries its+ // own name so the user can still tell windows apart. Both wear the+ // Adom-logo avatar so ANY pup window is visually distinct from real Chrome.+ if (!browser) brandPupProfile(userDataDir, profileName,+ profileName === DEFAULT_PROFILE ? 'Adom'+ : (/^iso-.*-pub$/.test(profileName) ? 'Adom Β· anonymous' : ('Adom Β· ' + profileName)));+ }++ // βββ Helper: kill any Chrome processes locking this profile dir βββ+ // v1.6.2+: route through killOrphanChromesForProfile + cleanProfileLocks+ // (PowerShell-based) instead of wmic. wmic was deprecated in 2021 and+ // removed from the default PATH in recent Windows 11 updates β when+ // it's missing, the legacy code silently failed, leaving stale Chrome+ // processes holding the profile and producing the canonical "Chrome+ // process exited immediately" symptom on every retry. Caught the day+ // v1.6.1 shipped because the recovery path exercised this helper more+ // aggressively than before.+ const killChromeHoldingProfile = async () => {+ if (!userDataDir) return;+ console.log(`Killing any Chrome processes holding profile "${profileName}"...`);+ try {+ await killOrphanChromesForProfile(profileName);+ } catch (e) {+ console.log(`killChromeHoldingProfile (orphan kill): ${e.message}`);+ }+ try {+ await cleanProfileLocks(profileName);+ } catch (e) {+ console.log(`killChromeHoldingProfile (lock cleanup): ${e.message}`);+ }+ // Give OS time to release locks+ await new Promise(r => setTimeout(r, 1500));+ };++ // βββ Helper: one detached-launch attempt, returns browser or throws βββ+ const tryDetachedLaunch = async (attemptNum, chromePathArg) => {+ const { spawn } = require('child_process');+ // Launch the candidate the loop is currently trying (installed Chrome/Edge+ // preferred; cached CfT fallback). Falls back to puppeteer's own path only if+ // no candidate path was resolved (the no-system-browser edge case).+ const chromePath = chromePathArg || puppeteer.executablePath();+ const debugPort = 9200 + Math.floor(Math.random() * 800);+ // v1.8.26: BACKGROUND-BY-DEFAULT, done right. Chrome/Edge aggressively grab the+ // foreground on launch (and re-grab it whenever shown), so minimizing/z-ordering+ // AFTER the fact loses the race and the window flashes over the user's work. The+ // reliable fix: launch it OFF-SCREEN (far past any monitor). CDP still renders it,+ // so pup_screenshot/eval/record all work, but the user NEVER sees it pop up and+ // it never covers their active window. foreground:true launches maximized on-screen+ // as before. pup_raise_os_window moves it on-screen when the user asks to watch.+ // v1.8.51: `--no-startup-window` is THE fix for the focus-steal war. Windows only grants+ // a process the right to take the foreground at launch. With this flag the browser+ // process starts with NO window at all β the window is created LATER via CDP (newPage),+ // by which time Chrome is a background process and the OS itself DENIES it the+ // foreground. So: no steal is possible, no watchdog/timer war, and a user's taskbar+ // click can never be fought (nothing is running to fight it). The off-screen position+ // args stay as belt-and-suspenders for the brief moment before the park.+ // v1.8.95: SUPPRESS the browser sign-in / "set up a work profile" prompt. I was WRONG+ // that CfT lacks this machinery β it shows the SAME "Sign in to Chromium? Set up a work+ // profile" dialog that poisoned Aditya (John proved it live 2026-07-19). These flags kill+ // it at launch so NO user ever sees it: browser account sign-in off, sync off, the sign-in+ // promos/intercepts off. The wiki auth we DO want is a plain site cookie β unaffected.+ // This PREVENTS the poisoning at the source; the auth-URL steering hint stays as backup.+ const noBrowserIdentityFlags = [+ '--allow-browser-signin=false',+ '--disable-sync',+ '--disable-features=SigninIntercept,ForceSignInReauth,SyncPromoAfterSignin,DiceWebSigninInterception,ProfilePicker',+ '--disable-signin-promo',+ ];+ const chromeArgs = mergeChromiumFeatureFlags([+ ...launchOpts.args.filter(a => a !== '--remote-debugging-port=0' && (foreground || a !== '--start-maximized')),+ `--remote-debugging-port=${debugPort}`,+ `--user-data-dir=${userDataDir}`,+ ...noBrowserIdentityFlags,+ ...(foreground ? [] : ['--no-startup-window']), // v1.9.135 (John): with no-startup-window there IS no window at launch β the -32000 position arg was fighting nothing, and it poisoned every Chrome-created window (popups, restores) with an off-screen default. Park owns geometry now.+ ]);+ console.log(`Chrome launch attempt ${attemptNum} for profile "${profileName}" on CDP port ${debugPort}`);+ // v1.8.34: snapshot the user's foreground window right before launch (background only)+ // so we can hand keyboard focus back to it after the browser grabs it.+ const priorForeground = foreground ? null : await osGetForegroundWindow();+ // v1.9.122 β REPARENT Chrome out of the bridge's process TREE (Windows).+ // Measured root cause of "every pup window dies on a bridge restart": Node's+ // { detached:true } gives a new process GROUP but the child stays a TREE child of+ // node (parentPid = the bridge), so AD's tree-kill on restart takes Chrome with it.+ // Chrome killed dirty = never a clean shutdown, and (with the durable "you" profile+ // coming next) a rotting profile. Fix: launch through a transient `cmd /c start`,+ // which returns instantly and EXITS, orphaning Chrome from node within milliseconds.+ // A later `taskkill /T` on the bridge no longer finds Chrome. CDP connects by PORT+ // (below), so losing the direct PID handle is fine β we re-resolve it after connect.+ // macOS/Linux keep the direct spawn (no tree-kill problem observed there).+ const reparented = process.platform === 'win32' && !!userDataDir;+ let child;+ if (reparented) {+ // v1.9.132 β REVERTED to the 1.9.122 cmd spawn. The 1.9.131 Start-Process variant fixed a+ // real hazard (Chrome inheriting cmd's transient console handles via 'start /b'; a dead+ // conhost can block Chrome's writes = the full-browser hang John hit) but it DID NOT+ // LAUNCH from the bridge: every attempt died 'CDP never became ready', while the exact+ // same Start-Process line run manually via shell_execute worked (HTTP 200 from CDP). So+ // the defect is in composing the ~20-arg list through node spawn -> powershell -Command+ // -> -ArgumentList, not in the approach. Reverted rather than blind-iterate on quoting;+ // a broken launch is strictly worse than a rare hang. PARKED FIX, with both measurements+ // in the 1.9.131/1.9.132 ship notes: diff the manual vs bridge-composed command lines+ // (wmic/Get-CimInstance CommandLine of the child powershell) and re-land console-free.+ // Full control of quoting (the taskbar-command lesson): build the command string+ // ourselves and pass it verbatim, rather than letting Node re-quote through cmd.+ const winQuote = (a) => /[\s"]/.test(a) ? '"' + String(a).replace(/"/g, '""') + '"' : a;+ const startCmd = `start "" /b ${winQuote(chromePath)} ${chromeArgs.map(winQuote).join(' ')}`;+ child = spawn('cmd.exe', ['/d', '/s', '/c', startCmd], {+ windowsVerbatimArguments: true, windowsHide: true, detached: true, stdio: 'ignore',+ });+ } else {+ child = spawn(chromePath, chromeArgs, { detached: true, stdio: 'ignore', windowsHide: false });+ }+ child.unref();++ // Wait for the browser to start and CDP to be ready (30s β cold start can be slow).+ // IMPORTANT (Edge + some Chrome channels): the process we spawned is often just a+ // LAUNCHER that relaunches the real browser onto a DIFFERENT PID and then exits. So+ // we probe CDP FIRST every tick, and on launcher-death we give CDP one last chance+ // before declaring failure β otherwise a perfectly-good Edge reads as "exited+ // immediately". (The old order killed Edge launches dead.)+ let connected = false;+ let childDied = false;+ for (let i = 0; i < 60; i++) {+ await new Promise(r => setTimeout(r, 500));+ // CDP up? Then we're connected regardless of which PID now hosts the browser.+ try {+ const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+ if (resp.ok) { connected = true; break; }+ } catch {}+ // v1.9.122: the reparent launcher (cmd) exits within ms BY DESIGN, so its death+ // carries no information β poll CDP for the whole budget instead of bailing early.+ if (reparented) continue;+ // Is the process we spawned still alive?+ let alive = true;+ try {+ if (process.platform === 'win32') {+ const out = execSync(`tasklist /FI "PID eq ${child.pid}" /NH /FO CSV`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });+ alive = out.includes(`"${child.pid}"`);+ } else {+ process.kill(child.pid, 0); // throws if dead+ }+ } catch { alive = false; }+ if (!alive) {+ // Launcher exited β the browser may be up on another PID. Final CDP grace probe.+ await new Promise(r => setTimeout(r, 700));+ try {+ const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+ if (resp.ok) { connected = true; break; }+ } catch {}+ childDied = true; break;+ }+ }++ if (!connected) {+ // Kill the zombie we spawned before throwing. v1.9.122: when reparented, child.pid is+ // the already-exited launcher, so a stranded Chrome is only reachable via its profile.+ try {+ if (reparented) { await killChromeHoldingProfile(); }+ else if (process.platform === 'win32') execSync(`taskkill /F /T /PID ${child.pid}`, { stdio: 'ignore' });+ else process.kill(child.pid, 'SIGKILL');+ } catch {}+ const reason = childDied ? 'Chrome process exited immediately' : 'CDP never became ready';+ throw new Error(`Chrome launch attempt ${attemptNum} failed: ${reason} (port ${debugPort}, PID ${child.pid})`);+ }++ const b = await puppeteer.connect({ browserURL: `http://127.0.0.1:${debugPort}`, defaultViewport: null });+ // v1.9.122: recover the real Chrome pid by its debug port (the reparent path's child.pid+ // is the exited cmd). Retry briefly β the process is definitely up once CDP answered.+ let resolvedPid = child.pid;+ if (reparented) {+ for (let t = 0; t < 4; t++) {+ const procs = await _psProcs(`$_.CommandLine -like '*--remote-debugging-port=${debugPort}*'`);+ const hit = procs.find(x => /--user-data-dir/.test(x.cmd));+ if (hit) { resolvedPid = hit.pid; break; }+ await new Promise(r => setTimeout(r, 300));+ }+ console.log(`[reparent] Chrome for "${profileName}" reparented off the bridge tree; real PID ${resolvedPid} (launcher was ${child.pid})`);+ }+ b._detachedPid = resolvedPid;+ b._cdpPort = debugPort;+ b._priorForeground = priorForeground; // v1.8.34: user's window to hand focus back to+ console.log(`Launched detached Chrome (PID ${child.pid}) on CDP port ${debugPort} for profile "${profileName}"`);+ // v1.8.24: ENFORCE background-by-default the instant the window exists β push it+ // behind the user's windows AND hand focus back to whatever they were in, so the+ // --start-maximized launch never steals focus. Retry a few times because Chrome/Edge+ // can re-raise itself during startup (first paint, restore prompts). Resolve the+ // window via the CDP-port owner (reliable across launcherβbrowser pid relaunches).+ if (!foreground && process.platform === 'win32') {+ // v1.8.53: with --no-startup-window there is NO window yet (it's created by newPage()+ // in launchSession) β so the park happens THERE, via parkSessionWindowDirect (CDP ++ // AD direct APIs, no PS window-hunting). Just mark that this fresh browser needs it.+ b._bgResult = 'parking';+ b._needsPark = true;+ }+ if (!foreground && process.platform === 'darwin') {+ // macOS background-by-default: hide the app NOW, while --no-startup-window means it+ // has no window yet β windows created by a hidden app are born hidden, so the launch+ // is truly invisible (AppKit clamps the -32000 off-screen coords, so only the hide+ // covers the gap here). The park then sizes the window + re-asserts the hide.+ b._bgResult = 'parking';+ b._needsPark = true;+ b._macHidden = await macSetAppHiddenByPid(child.pid, true);+ if (!b._macHidden) console.log(`[launch] mac pre-window hide failed for pid ${child.pid} (Automation grant missing?) β park will retry`);+ }+ return b;+ };++ // v2.0.53/57: stage the extensions; CfT candidates carry them as --load-extension below.+ // The identity extension rides ONLY adom-you (throwaway profiles must not claim the identity).+ const _tbExtDir = stageToolbarExtension();+ // v2.0.101 (John): a non-adom-you window still shows an identity tile β the ANONYMOUS one+ // (pup 4-dot mark in the avatar inset) β so a different-profile window is visibly not-you.+ const _idExtDir = (path.basename(userDataDir || '') === 'adom-you') ? await stageIdentityExtension()+ : ((pupSettings().toolbarExtension !== 'off') ? _stageExtDir('extension-identity-anon', 'pup-identity-anon-extension') : null);+ // v2.0.92 (John: pen button beside the extension icons β Chrome allows ONE action per+ // extension, so the pen is its own tiny extension; three family tiles = the Adom toolbar)+ const _penExtDir = (pupSettings().toolbarExtension !== 'off') ? _stageExtDir('extension-annotate', 'pup-annotate-extension') : null;+ const _extDirs = [_penExtDir, _tbExtDir, _idExtDir].filter(Boolean);++ // βββ Spawn-verified launch: try each candidate browser in order, up to 3 βββ+ // attempts each (with orphan/lock cleanup between attempts). If a browser can't+ // actually spawn (corrupt install, wrong arch, locked), fall THROUGH to the next+ // candidate rather than failing the open. First one that launches wins + is cached.+ // v1.9.74: the detached launch runs on macOS too (same --no-startup-window contract;+ // the mac park is the app-level hide instead of the off-screen/z-bottom dance).+ const isDetachedLaunch = (process.platform === 'win32' || process.platform === 'darwin') && userDataDir;+ const MAX_ATTEMPTS = 3;+ let lastErr = null;+ let chosen = null;+ const triedList = [];+ // No detected candidates (no system browser + no cached CfT) β let puppeteer pick+ // its own executablePath. This is the rare cold-start edge the gate usually catches.+ const launchList = candidates.length ? candidates : [{ kind: 'default', source: 'puppeteer', executablePath: null }];++ for (const cand of launchList) {+ // v2.0.257: the patient reconnect above may have adopted the profile's LIVE browser rather than+ // letting pup kill it. In that case there is nothing to launch β relaunching over a profile that+ // is in use is the very damage being prevented.+ if (browser) break;+ if (cand.executablePath) launchOpts.executablePath = cand.executablePath;+ else delete launchOpts.executablePath;+ // v2.0.53: the toolbar rides ONLY on CfT (branded Chrome ignores/removed the flag).+ launchOpts.args = launchOpts.args.filter(a => !a.startsWith('--load-extension='));+ if (_extDirs.length && cand.kind === 'chrome-for-testing') launchOpts.args.push(`--load-extension=${_extDirs.join(',')}`);+ lastErr = null;+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {+ try {+ if (isDetachedLaunch) {+ browser = await tryDetachedLaunch(attempt, cand.executablePath);+ } else {+ { const _lt0 = Date.now(); browser = await puppeteer.launch(launchOpts); _lastLaunchMs = Date.now() - _lt0; _lastLaunchAt = Date.now(); try { pupTimingRecord('browser_launch_' + (cand.kind || 'unknown'), _lastLaunchMs); } catch (e) {} } // v2.0.383 (#59): measure the cold spawn+ }+ chosen = cand;+ lastErr = null;+ break;+ } catch (err) {+ lastErr = err;+ console.log(`Launch attempt ${attempt}/${MAX_ATTEMPTS} for ${cand.kind}(${cand.source}) failed: ${err.message}`);+ if (attempt >= MAX_ATTEMPTS) break;+ // Recovery before retry: kill any lingering Chrome holding the profile + clear locks+ await killChromeHoldingProfile();+ }+ }+ if (browser) break;+ triedList.push({ kind: cand.kind, source: cand.source, executablePath: cand.executablePath || null, error: lastErr && lastErr.message });+ if (launchList.length > 1) {+ console.log(`[launch] ${cand.kind}(${cand.source}) exhausted ${MAX_ATTEMPTS} attempts β trying next browser`);+ // A failed candidate (e.g. an Edge that aborted) can leave SingletonLock /+ // DevToolsActivePort in the shared userDataDir that would poison the NEXT+ // browser. Clear them before the next candidate so the fallback is clean.+ await killChromeHoldingProfile();+ }+ }++ if (browser && chosen) {+ // Record what actually launched so the open response + hints are accurate, and+ // cache it as the auto-default so we don't re-probe next time.+ browser._pupChosen = { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null };+ // v1.9.94 (issue #22 Finding 4, AdityaAngajala) β REMOVED: the v1.8.71 "converge this box to+ // CfT" background prewarm. It was the CfT-FIRST doctrine's convergence step, and native-first+ // (v1.9.90) retired that doctrine β I removed the same kick from chrome.warmup() then and MISSED+ // this one, so a native launch still started a ~150 MB CfT download nobody asked for. Worse, it+ // was actively BLOCKING: while that download is in flight, chrome.readiness() reported the whole+ // browser subsystem as not-ready (see the `ready` fix in chrome.js), and pup_open_window's+ // gate refuses on `!rd.ready` β so a stalled prewarm made EVERY open fail with+ // chrome_for_testing_installing on a machine that had a perfectly good system Chrome AND Edge.+ // Measured live on H2O (AD 1.9.177): readiness simultaneously reported browserSource:"system"+ // with a valid chrome.exe AND blocked opens at 0%. Native-first means we do not fetch CfT when a+ // native browser works β CfT is fetched only as the last resort (no Chromium at all), which+ // chrome.warmup() already handles at spawn. Do NOT reintroduce a convergence prewarm here.+ _lastChosenBrowser = { ...browser._pupChosen, fallbackFrom: triedList.map(t => `${t.kind}(${t.source})`) };+ if (chosen.executablePath) chrome.markVerified(chosen);+ if (triedList.length) console.log(`[launch] launched ${chosen.kind}(${chosen.source}) after ${triedList.length} browser(s) failed to spawn`);+ }++ if (!browser) {+ // Every candidate failed to spawn. Branch on whether ANY browser was even+ // detectable so the error is actionable, not a dev string telling the AI to run npx.+ const triedTxt = triedList.length+ ? triedList.map(t => `${t.kind}(${t.source}): ${t.error}`).join(' | ')+ : '(no browsers were detected to try)';+ const cdet = chrome.detectChrome();+ if (!candidates.length && !cdet.installed) {+ const e = new Error('No Chrome/Edge on this machine and Chrome for Testing is not installed β pup cannot launch a browser yet.');+ e.errorCode = 'no_pup_available';+ e._hint = 'This box has no installed Chrome or Edge and no cached Chrome for Testing. Fastest fix: have the user install Chrome or Edge (Edge ships with Windows) β pup uses it instantly, no download. Or call pup_prewarm / pup_use {browser:"cft"} to fetch Chrome for Testing (~150 MB, one time), poll pup_readiness until ready:true, then retry.';+ throw e;+ }+ const e = new Error(+ `Every detected browser failed to spawn for profile "${profileName}" (${MAX_ATTEMPTS} attempts each). Tried β ${triedTxt}.`+ );+ e.errorCode = 'pup_launch_failed';+ e._hint = `pup tried every browser it found and none would launch (usually low memory, a locked/busy profile, or corrupt installs). Tried: ${triedList.map(t => `${t.kind}(${t.source})`).join(', ')}. Retry once; if it persists, pin a specific browser with pup_use {browser:"chrome"|"edge"|"cft"}, or surface the last error to the user.`;+ throw e;+ }+ }++ browsers.set(profileName, { browser, refCount: 0 });++ // v2.0.114 (John: "get your pup icon into this toolbar"). ROOT CAUSE of the missing toolbar+ // tiles: Chrome for Testing now DISABLES unpacked (--load-extension) extensions until the+ // profile's Developer mode is on β so the Adom action icons had no action, could not be pinned,+ // and hid in the puzzle menu. Developer mode lives in HMAC-signed Secure Preferences, so pup+ // cannot seed it by writing files; it must call the developerPrivate API in a chrome://extensions+ // page. This does that once per launch (fire-and-forget), then pins every Adom action icon.+ if (pupSettings().toolbarExtension !== 'off') ensureExtensionsEnabled(browser, profileName).catch(() => {});++ // v1.4.12: pre-grant the dialog-suppressing permissions before any+ // navigation happens. Done at browser-context level so every tab/origin+ // inherits β caller doesn't need to re-grant per origin. See+ // applyDefaultPermissions for rationale + opt-out via strictPermissions.+ await applyDefaultPermissions(browser, profileName, { strictPermissions });++ // Auto-track popups: window.open() / target=_blank navigation on this Chrome+ // gets attached to the session that owns the opener tab. See wirePopupTracking.+ wirePopupTracking(browser, profileName);++ // Clean up when browser is closed externally (user closed Chrome, crashed, etc.)+ // v1.5.1+: see handleBrowserDisconnect for rationale. Sessions are kept+ // in the in-memory Map + on disk so pup_rescan / health-check can+ // recover the still-alive Chrome window. Old behavior (delete everything)+ // hijacked unrelated windows on subsequent calls.+ browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));++ // v1.9.4: hand the launched browser to any concurrent joiners, then clear the in-flight slot.+ _launchInFlight.delete(profileName);+ _resolveLaunch(browser);+ return browser;+ } catch (_launchErr) {+ // v1.9.4: propagate the SAME failure to every concurrent joiner and clear the slot so a+ // later open can retry cleanly (a stuck slot would otherwise wedge all future opens).+ _launchInFlight.delete(profileName);+ _rejectLaunch(_launchErr);+ throw _launchErr;+ }+}++// v1.9.19: create a page in a BRAND NEW OS WINDOW of an existing browser. puppeteer's newPage()+// always makes a tab; only CDP Target.createTarget accepts newWindow. Page identity is resolved by+// diffing browser.pages() before/after rather than matching internal target ids, so this stays+// correct across puppeteer versions. Returns null if the window never materialises.+async function createPageInNewWindow(browser, profileName) {+ let cdp = null;+ // v1.9.304 BIRTH-TIME hwnd: give each new window a UNIQUE off-screen birth position so we can+ // resolve its exact OS handle while it is still off-screen AND small (1280x800) β the ONLY moment a+ // same-profile window is unambiguous. After park it is maximized + shares the parked rect with its+ // siblings, which is why the width-wiggle could never tell them apart. Captured here, stashed on the+ // page, trusted by resolveSessionHwndByBounds. This is the fix for multi-window profiles (adom-you).+ // v2.0.259 (John's rule R1): the window is BORN where it will live β primary work area, 10px inset,+ // on screen, clickable from its first millisecond. It used to be born at -32000 purely so the+ // set-diff had a unique coordinate to match on, i.e. IDENTITY dictated PLACEMENT, and that is+ // precisely how windows ended up stranded off-screen behind live taskbar buttons. The lock-based+ // set-diff below never needed the coordinate (see the note on _birthLock: "the one handle that+ // appeared while I held the lock is unambiguous regardless of position"), so nothing is lost.+ _birthSeq++;+ const _br = await birthRect();+ try {+ const before = new Set(await browser.pages());+ cdp = await browser.target().createCDPSession();+ // v1.9.144 (John: "you keep opening pup windows on top of the one i'm working in") β birth+ // the window OFF-SCREEN so the moment between creation and the park is invisible. 1.9.135+ // removed the process-wide -32000 launch default (it poisoned every window's restore rect);+ // these are explicit PER-WINDOW coords on this one creation, and the unconditional park+ // (1.9.138) immediately rewrites the normal rect on-screen at bottom-z, so nothing can stay+ // stranded. Windows whose park somehow fails are caught by healOffscreenWindow.+ // v1.9.253 (John: "i don't believe you that chrome is auto-foregrounding the windows" β he was+ // right to push: MEASURE it). Capture the user's foreground before creating the window; check it+ // after. If creation stole focus, hand it straight back while the new window is still OFF-SCREEN+ // (invisible), so the user's typing is interrupted for at most ~1s and NOTHING ever appears on+ // their screen. This is the at-the-source fix; parks and re-asserts then act on an unfocused+ // window (osBottomUnlessForeground no longer mistakes the creation-grab for user intent).+ const prevFg = await osGetForegroundWindow();+ // v2.0.38: hold the birth lock across snapshot -> create -> capture so exactly one OS window+ // appears in the diff. `capturedHwnd` is set inside; the page-find stays outside is fine because+ // the page target is unique per createTarget regardless.+ let fresh = null, capturedHwnd = null;+ await withBirthLock(async () => {+ invalidateEnumCache(); // v2.0.187: a birth changes the desktop β never diff against a stale snapshot+ let beforeHwnds = new Set();+ if (process.platform === 'win32') { try { beforeHwnds = new Set((await enumChromeWidgetWindows()).map(r => Number(r.h))); } catch (e) {} }+ // v2.0.276 (John: "off-screen birth could work as long as you move it to the main screen+ // within 50ms"). BORN OFF-SCREEN, because that is the ONLY lever Chrome/CDP leave us to stop+ // Chrome activating its own window: Chrome does not bother giving the foreground to a window+ // that is nowhere visible (measured). CDP createTarget has no no-activate option, and+ // WS_EX_NOACTIVATE is a dead-end (Chrome re-activates anyway + breaks the taskbar click).+ // So: create off-screen (zero focus steal), then IMMEDIATELY reposition on-screen with a single+ // local CDP setWindowBounds β which moves WITHOUT changing z-order, so the window lands on+ // screen, clickable, and still NOT foreground. This is ~20ms of local CDP, well inside the 50ms+ // budget, so the off-screen state lasts one frame and the "stranded unclickable window" can+ // never happen. No handback exists anymore because there is no steal to undo.+ const _OFFX = -32000, _OFFY = -32000;+ const _ct = await cdp.send('Target.createTarget', { url: 'about:blank', newWindow: true, left: _OFFX, top: _OFFY, width: _br.width, height: _br.height });+ const _tid = _ct && _ct.targetId;+ const _moveStart = Date.now();+ try {+ const { windowId: _wid } = await cdp.send('Browser.getWindowForTarget', _tid ? { targetId: _tid } : {});+ await cdp.send('Browser.setWindowBounds', { windowId: _wid, bounds: { left: _br.left, top: _br.top, width: _br.width, height: _br.height } });+ const _moveMs = Date.now() - _moveStart;+ console.log(`[birth] window moved off-screen -> on-screen (${_br.left},${_br.top}) in ${_moveMs}ms (budget 50ms; born off-screen so Chrome never activated it -> no focus steal)`);+ } catch (e) { console.log(`[birth] on-screen reposition failed (${e && e.message}) β the placement sweep will land it; the window is briefly off-screen`); }+ for (let i = 0; i < 50 && !fresh; i++) {+ fresh = (await browser.pages()).find(p => !before.has(p));+ if (!fresh) await new Promise(r => setTimeout(r, 100));+ }+ if (process.platform === 'win32' && fresh) {+ // The owner PID: native Chrome uses the SAME Chrome_WidgetWin_1 class, so only OUR process's+ // windows are candidates (v2.0.185 β another thread's pup window once branded native Chrome).+ const _bpid = Number(browser._detachedPid || (browser.process() && browser.process().pid) || 0) || null;+ // v2.0.287 CONCURRENCY FIX (John: "why are you so insanely inconsistent?"). The old set-diff+ // ("the one NEW window under the lock") could not disambiguate windows that materialise at the+ // same instant when several open at once β it saw ">1 new window" and REFUSED, so that window+ // got NO handle, could not be parked (it FOREGROUNDED) nor stamped (wrong/no icon), and WHICH+ // window lost the race was random (the inconsistency). Fix: set a UNIQUE transient title on+ // THIS exact page β it is on about:blank pre-navigation, so the title is ours to set and no+ // other window, pup or native, can carry it β then capture the OS window whose title contains+ // it. Unique per birth => deterministic even when 3 windows open in the same millisecond. The+ // caller's navigation overwrites the title right after, by which point the hwnd is captured.+ const _birthTitle = `PUPBIRTH-${_birthSeq}-${Math.floor(Date.now() % 1000000)}`;+ try { await fresh.evaluate((t) => { document.title = t; }, _birthTitle); } catch (e) {}+ // v2.0.308 (John: "why are you spamming desktop_enum_windows again? now that you do the+ // SetProp technique you would never have to look at hwnd deltas"): he is right. The title+ // capture used to POLL ab's enum up to 14 times per birth (the 1/s enum spam in his log).+ // ONE server-side wait on the unique title now: ab blocks until a Chrome_WidgetWin_1 window+ // whose title contains the token exists and hands back its hwnd. No enumeration, no deltas.+ // v2.0.313: IN-PROCESS title poll (koffi EnumWindows + GetWindowText, microseconds per pass,+ // every 50ms, 2.5s cap). Zero ab calls, zero process spawns, and no guessing at a verb's+ // contract. Chrome takes a few hundred ms to push a document.title into the OS window title,+ // which is what the poll absorbs.+ if (win32.available()) {+ const _t0 = Date.now();+ while (!capturedHwnd && Date.now() - _t0 < 2500) {+ const hit = win32.findChromeWindowByTitle(_birthTitle);+ if (hit && hit.hwnd && (!_bpid || !hit.pid || Number(hit.pid) === _bpid) && !hwndOwnedByOtherSession(hit.hwnd, null)) { capturedHwnd = hit.hwnd; console.log(`[birth] in-process title poll matched "${_birthTitle}" -> ${hit.hwnd} (pid ${hit.pid}) after ${Date.now() - _t0}ms`); break; }+ await new Promise(r => setTimeout(r, 50));+ }+ if (!capturedHwnd) console.log(`[birth] in-process title poll: no window carried "${_birthTitle}" within 2.5s`);+ }+ if (!capturedHwnd) {+ // ONE enumeration (off-screen included), never a loop: the birth window is still at its+ // off-screen birth position at this instant, which a visible-only wait can miss.+ try {+ const rows = await enumChromeWidgetWindows({ fresh: true });+ const mine = rows.filter(r => (!_bpid || Number(r.pid) === _bpid) && !hwndOwnedByOtherSession(r.h, null) && String(r.title || '').includes(_birthTitle));+ if (mine.length === 1) { capturedHwnd = mine[0].h; console.log(`[birth] single enum matched "${_birthTitle}" -> ${capturedHwnd}`); }+ else console.log(`[birth] single enum: ${mine.length} windows carry "${_birthTitle}" (of ${rows.length})`);+ } catch (e) {}+ }+ if (!capturedHwnd) console.log(`[hwnd] birth on "${profileName}": no window matched the unique birth title "${_birthTitle}" yet β resolver will settle it`);+ }+ });+ // R4: the handle is resolved in the SAME LOCKED MOMENT the window was created β the one new+ // top-level window owned by THIS profile's Chrome process. No coordinate involved, so it stays+ // exact even though every window is now born at the identical on-screen rect. Stashed on the page+ // for the resolver, which trusts it above everything else.+ if (fresh && capturedHwnd) { fresh.__pupHwnd = capturedHwnd; console.log(`[hwnd] birth-resolved new window on "${profileName}" -> ${capturedHwnd} (unique-title capture, concurrency-safe; born on-screen at ${_br.left},${_br.top} ${_br.width}x${_br.height})`); }+ else if (fresh) console.log(`[hwnd] birth capture on "${profileName}" produced no handle β will resolve later`);+ try {+ const nowFg = await osGetForegroundWindow();+ // v2.0.276: THE HANDBACK IS DELETED. It existed only because 2.0.259's on-screen birth let+ // Chrome steal the foreground on every open β and it was a bug: it called+ // desktop_bring_to_front(prevFg), and on a machine full of pup windows prevFg is usually+ // ANOTHER pup window, so it FOREGROUNDED pup windows (with no caption). Off-screen birth means+ // there is no steal to undo, so the whole mechanism is gone. This is only a DIAGNOSTIC now: if+ // it ever logs, off-screen birth failed to suppress the activation and that is a real bug to+ // investigate β never something to paper over with a bring_to_front.+ if (prevFg && nowFg && String(nowFg) !== String(prevFg)) {+ console.log(`[focus] UNEXPECTED: a new window took the foreground (${prevFg} -> ${nowFg}) despite off-screen birth β off-screen birth is supposed to make this impossible; investigate, do NOT add a handback.`);+ }+ } catch (e) {}+ // v2.0.285: the blank-occluded-window fix that lived here (Emulation.setFocusEmulationEnabled at+ // birth) is REVERTED β it ACTIVATED the OS window and foregrounded a background pup window (John,+ // 2026-08-22), the exact focus-steal off-screen birth exists to prevent. The blank problem is still+ // real (dev-skills/pup-render-test) but must be solved WITHOUT touching OS focus. Do NOT reintroduce+ // focus-emulation on the new window here.+ return fresh;+ } catch (e) {+ console.log(`[window] createPageInNewWindow failed: ${e.message}`);+ return null;+ } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }+}++// Launch a session as a tab in a Chrome browser+// profile: which Chrome profile to use (defaults to sessionId)+// If another session already uses that profile, this creates a NEW TAB in the same Chrome+async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false, owner = null, takeover = false, foreground = false, silent = false, highFps = false, insecureDev = false } = {}) {+ const profileName = profile || sessionId;++ // If session already exists and page is still alive, just navigate+ const existing = sessions.get(sessionId);+ if (existing) {+ const browserEntry = browsers.get(existing.profileName);+ if (browserEntry && browserEntry.browser.isConnected()) {+ try {+ // Check if the page is still valid+ await existing.page.title();+ // ββ v1.8.16 ownership gate ββββββββββββββββββββββββββββββββββββββββ+ // Reusing an existing sessionId with a NEW url = navigating away a+ // window some thread was using. If both sides declared owners and+ // they differ, REFUSE (unless takeover:true) β the caller is about+ // to steal another thread's window. Same-owner / no-url reuse is the+ // legitimate "refresh my own window" pattern and passes untouched.+ let prevUrl = null; try { prevUrl = existing.page.url() || null; } catch {}+ const navigatingAway = !!url && !!prevUrl && url !== prevUrl;+ const declaredMismatch = navigatingAway && existing.owner && owner && owner !== existing.owner;+ if (declaredMismatch && !takeover) {+ const age = Math.round((Date.now() - (existing.createdAt || Date.now())) / 60000);+ const err = new Error(`Session "${sessionId}" belongs to another thread (owner "${existing.owner}", opened ${age} min ago, currently showing ${prevUrl}).`);+ err.code = 'session_owned_by_another_thread';+ err.details = { sessionId, owner: existing.owner, requestedBy: owner, currentUrl: prevUrl, ageMinutes: age };+ throw err;+ }+ // One-shot reuse metadata for the open_window handler's hint:+ // was this a benign refresh, a sanctioned takeover, or an anonymous grab?+ existing._reuseMeta = {+ reused: true,+ previousUrl: prevUrl,+ navigatedAway: navigatingAway,+ previousOwner: existing.owner || null,+ sameOwner: !!(existing.owner && owner && owner === existing.owner),+ anonymousGrab: navigatingAway && !!existing.owner && !owner,+ takeover: !!(declaredMismatch && takeover),+ };+ // Ownership follows a sanctioned takeover or fills in when unset.+ if ((takeover || !existing.owner) && owner) existing.owner = owner;+ if (url) {+ await existing.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });+ existing.errors.length = 0;+ }+ activeSessionId = sessionId;+ return existing;+ } catch (e) {+ if (e && e.code === 'session_owned_by_another_thread') throw e;+ // Page was closed β fall through to create new tab+ console.log(`Session "${sessionId}" page was closed β recreating tab`);+ }+ }+ // Stale session β clean up+ sessions.delete(sessionId);+ if (existing.profileName) {+ const be = browsers.get(existing.profileName);+ if (be) be.refCount = Math.max(0, be.refCount - 1);+ // v1.6.1+: kill orphan Chromes + clean profile locks before relaunching.+ // Without this, a stale `lockfile` (Windows) or `SingletonLock` (POSIX)+ // left behind by an unclean shutdown causes the next puppeteer.launch+ // to exit immediately with "Chrome process exited immediately." The+ // sleep/wake recovery scenario hit exactly this: Chrome died during+ // sleep, lockfile remained, every relaunch attempt failed silently.+ try {+ await killOrphanChromesForProfile(existing.profileName);+ await cleanProfileLocks(existing.profileName);+ } catch (e) {+ console.log(`[launchSession] pre-relaunch cleanup failed (non-fatal): ${e.message}`);+ }+ }+ }++ // Get or launch Chrome for this profile+ const browser = await getOrLaunchBrowser(profileName, { freshProfile, strictPermissions, nativeSite, foreground, highFps, insecureDev });+ // v1.9.76: a high-fps window is leased, never open-ended. See HIGHFPS_DEFAULT_MINUTES.+ if (highFps) { try { highFpsGrant(sessionId, undefined); } catch (e) {} }++ // Prefer to REUSE the startup blank tab (every fresh Chrome opens one) instead+ // of creating a new tab and then closing the blank. The startup tab's URL+ // varies across Chrome versions: about:blank, chrome://newtab/,+ // chrome://new-tab-page/, chrome-search://local-ntp/..., so match loosely.+ const isBlank = (u) => {+ if (!u) return true;+ return u === 'about:blank'+ || u.startsWith('chrome://newtab')+ || u.startsWith('chrome://new-tab-page')+ || u.startsWith('chrome-search://local-ntp')+ || u === 'edge://newtab/';+ };++ // Snapshot existing pages and find a reusable blank one NOT already claimed+ // by another session.+ const claimedPages = new Set();+ for (const s of sessions.values()) { if (s.page) claimedPages.add(s.page); }+ const existingPages = await browser.pages();+ // v1.9.19: ONE SESSION = ONE OS WINDOW, even when sessions share a profile.+ // browser.newPage() opens a TAB in whatever window the profile already has, so two sessions on+ // the shared adom-wiki-authed jar collapsed into one window. A window's title reflects only its+ // ACTIVE tab, so AD could not resolve "the window titled (session: <id>)" for the background+ // one -- every per-window operation keyed on that title silently failed (icon stamp, taskbar+ // overlay, flash, jump list), which is why the login icon would not repaint after a view toggle.+ // Fix: when another live session already owns a window on this profile, create the page in its+ // OWN window via CDP Target.createTarget {newWindow:true}. Same cookie jar, separate window.+ const profileAlreadyInUse = [...sessions.values()].some(s => s.profileName === profileName && s.page);+ let page = profileAlreadyInUse ? null : existingPages.find(p => {+ if (claimedPages.has(p)) return false;+ try { return isBlank(p.url()); } catch { return false; }+ });++ if (page) {+ console.log(`Reusing existing blank tab (${page.url()}) for session "${sessionId}"`);+ } else if (profileAlreadyInUse) {+ page = await createPageInNewWindow(browser, profileName);+ if (page) page._pupCreated = true; // v1.9.142: never a 'restored leftover' β see closeRestoredLeftoverTabs+ if (page) console.log(`Session "${sessionId}" opened in its OWN window on shared profile "${profileName}"`);+ else { console.log(`[window] newWindow target failed for "${sessionId}" -- falling back to a tab`); page = await browser.newPage(); page._pupCreated = true; }+ } else if (process.platform === 'win32') {+ // v2.0.37 (John: "look at your pup windows bar icons"): the FIRST window on a profile used to+ // take browser.newPage(), which captures NO OS hwnd β so after 2.0.24 removed the title-tag+ // fallback that window was permanently unresolvable and showed a raw Chrome taskbar button. Route+ // it through the SAME birth-capture path every other window uses (create off-screen at a unique+ // position, set-diff the new handle), so every pup window on Windows gets a reliable hwnd.+ page = await createPageInNewWindow(browser, profileName);+ if (page) page._pupCreated = true;+ else { console.log(`[window] first-window newWindow target failed for "${sessionId}" -- falling back to a tab`); page = await browser.newPage(); page._pupCreated = true; }+ } else {+ page = await browser.newPage();+ page._pupCreated = true; // v1.9.142+ }+ // v2.0.310 (John: "immediately associate the chrome uniqueid to the hwnd and SetProp on that+ // window so the association is in memory forever"): the birth capture stashed the hwnd on the+ // page; THIS is the first moment the sessionId is known, so the property goes on right now,+ // in-process (microseconds), not lazily on some later resolve.+ if (page && page.__pupHwnd && process.platform === 'win32') {+ const _tagged = win32.setProp(page.__pupHwnd, `${PUP_WIN_PROP}=${_sanSid(sessionId)}`);+ if (_tagged) console.log(`[hwnd] "${sessionId}" tagged ${page.__pupHwnd} at birth (SetProp, in-process)`);+ else if (_tagged === null) tagWindowSession(page.__pupHwnd, sessionId).catch(() => {}); // koffi unavailable: PowerShell fallback+ }++ // Always open the welcome page first β it shows session identity (sessionId,+ // profile, userDataDir) in the tab title and page body so users can tell+ // which Chrome window belongs to which session.+ const welcomeFile = path.join(__dirname, 'welcome.html');+ const userDataDir = freshProfile ? null : path.join(PROFILES_DIR, profileName);+ const welcomeParams = new URLSearchParams({ sessionId, profile: profileName });+ if (userDataDir) welcomeParams.set('userDataDir', userDataDir);+ const welcomeUrl = `file:///${welcomeFile.replace(/\\/g, '/')}?${welcomeParams}`;+ try {+ await page.goto(welcomeUrl, { waitUntil: 'domcontentloaded', timeout: 10000 });+ } catch (e) {+ console.log(`Welcome page failed to load (non-fatal): ${e.message}`);+ }++ // v1.8.73: stamp the window IDENTITY at CREATE (per the AD contract: identity is per-HWND β+ // stamping only at park would leave the window reading "Google Chrome for Testing" during its+ // whole load). The welcome page just set the "(session: <id>)" title, so AD can resolve it.+ // Fire-and-forget; the park re-brands (with badge fallback) as belt-and-suspenders.+ // v1.9.185 minimal-touch: do NOT stamp a per-session AUMID. Changing a window's AppUserModelID+ // makes Windows DESTROY and RECREATE its taskbar button β the single most invasive thing pup did+ // to a window it does not own. In minimal mode the window keeps Chrome's own identity.+ if (process.platform === 'win32' && (!minimalTouch() || pupSettings().aumidIcons === 'on')) { stampPupIdentity(sessionId, { once: true }).catch(() => {}); }++ // v1.8.68: the park is DEFINED here but KICKED OFF only after the real URL's navigation has+ // SETTLED (below). Rationale: the first cross-origin paint RAISES the window; if the window+ // is already placed on-screen (even at z-bottom), the user sees a blip on top of their work+ // (the adom/wiki thread's window John caught). Parking after settle means the paint-raise+ // hits a window still OFF-SCREEN β invisible, harmless. The window's first visible moment is+ // full-size, z-bottomed, and z-confirmed. Only for a freshly launched browser (_needsPark) β+ // a reused window is already placed, and re-parking it could hide a window the user has up.+ const kickoffPark = () => {+ if (foreground || (process.platform !== 'win32' && process.platform !== 'darwin')) return;+ try {+ const be = browsers.get(profileName);+ // v1.9.138 (John's 'why did the park not happen on the gmail window') β park EVERY+ // background window, not just the browser's first. _needsPark was a per-LAUNCH flag,+ // cleared after the first park, so with the shared durable profile (one Chrome, many+ // windows) every window after the first was NEVER parked: it sat at Chrome's default+ // geometry (historically the -32000 process default = stranded off-screen). The gate+ // is gone β parking is per-WINDOW and unconditional for background opens. And because+ // Chrome's own late first-run sizing can land AFTER the park and clobber it (the+ // inset-proof window read 0,0 full-size), the park RE-ASSERTS once at +2.5s.+ if (be && be.browser) {+ be.browser._needsPark = false;+ be.browser._bgResult = 'parking';+ const doPark = (tag) => parkSessionWindowDirect(page, sessionId)+ .then(r => {+ be.browser._bgResult = r;+ console.log(`[park] "${sessionId}"${tag}: ${r}`);+ brandPupWindow(sessionId).catch(() => {});+ return r;+ })+ .catch(e => { be.browser._bgResult = 'error'; console.log(`[park] "${sessionId}"${tag} failed: ${e.message}`); });+ be.browser._bgPromise = doPark('');+ // v1.9.201 β RE-ASSERT ALWAYS. My 1.9.186 minimal-touch change dropped this second park pass+ // as "a redundant window mutation". It is not redundant: it is the safety net that re-parks a+ // window CHROME ITSELF brought forward after the first park. Local PDFs are the proven case β+ // Chrome's PDF viewer activates the window on load, so John saw a 5-PDF window pop to the+ // front even though NO ai requested foreground and pup's own log said "backgrounded".+ // Keeping a window off the user's screen IS the point of minimal-touch, so this belongs in it.+ setTimeout(() => { doPark(' (re-assert)'); }, 2500);+ setTimeout(() => { brandPupWindow(sessionId).catch(() => {}); }, 1200);+ }+ } catch (e) {}+ };+ // v1.8.36: SNAPPY auto-flash. The window already exists + is backgrounded here (the+ // welcome page loaded from a local file), so flash the taskbar NOW β do not wait for the+ // real URL's domcontentloaded, which for heavy sites is another 1β3s. Set _flashState so+ // the open handler's later scheduleAutoFlash debounces (no double flash). Fire-and-forget.+ if (!foreground && !silent && process.platform === 'win32') {+ // v1.9.185 minimal-touch: no taskbar flash at all (and no log noise pretending one fired).+ if (taskbarActivityMode() === 'flash') { try { _flashState.set(sessionId, Date.now()); _openFlashDone.set(sessionId, Date.now()); console.log(`[flash] "${sessionId}" (launch)`); flashViaAD(sessionId, { force: true }); } catch (e) {} } // v2.0.292: openFlash folded into taskbarActivity='flash'+ }++ if (url) {+ // Apply stored basic-auth creds for this URL's host BEFORE navigating+ // so the Chrome native auth dialog never appears. No-op if no match.+ try {+ const matchedHost = await credentialVault.applyCredentialsToPage(page, url);+ if (matchedHost) console.log(`[creds] applied to session "${sessionId}" first nav: matched ${matchedHost}`);+ } catch (e) {+ console.log(`[creds] applyCredentialsToPage on launchSession failed: ${e.message}`);+ }+ // v1.9.105: keep the main-frame HTTP status β the strongest broken-render signal there is.+ const _resp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });+ try { page.__pupHttpStatus = _resp && typeof _resp.status === 'function' ? _resp.status() : null; } catch (e) {}+ // v1.8.68: wait for the page to SETTLE (load event, capped at 2.5s) while the window is+ // still OFF-SCREEN, so the first paint's window-raise is invisible. Skip the wait if the+ // page is already complete.+ if (!foreground && process.platform === 'win32') {+ try {+ const ready = await page.evaluate(() => document.readyState).catch(() => 'unknown');+ if (ready !== 'complete') {+ await Promise.race([+ new Promise(res => { try { page.once('load', res); } catch (e) { res(); } }),+ new Promise(res => setTimeout(res, 2500)),+ ]);+ }+ } catch (e) {}+ // Late-paint belt-and-suspenders: one more bottom re-assert well after settle.+ setTimeout(() => { reassertBottomAfterNav(sessionId).catch(() => {}); }, 3000);+ }+ }+ // Park now β after settle (url path) or right away (welcome-only path). The paint-raise+ // already happened off-screen; the window's first visible frame is full-size at z-bottom.+ kickoffPark();++ // Belt and suspenders: close any tab in this Chrome that's NOT claimed by+ // a registered session. This catches two cases:+ // (a) leftover blank tabs from reconnect scenarios+ // (b) tabs Chrome restored from session-restore (despite our+ // prepareCleanProfile pre-launch pass) β pup is AI-driven, the AI+ // knows what tabs it wants, so any unclaimed page is by definition+ // a stale leftover from a prior run.+ // Tabs registered by other live sessions on this same Chrome (multi-session+ // shared profile) ARE in refreshedClaimed and stay open.+ let restoredTabsClosed = 0;+ try {+ const after = await browser.pages();+ const refreshedClaimed = new Set();+ for (const s of sessions.values()) {+ for (const t of s.tabs) refreshedClaimed.add(t.page);+ }+ refreshedClaimed.add(page);+ for (const p of after) {+ if (refreshedClaimed.has(p)) continue;+ try {+ let pUrl = '';+ try { pUrl = p.url() || ''; } catch {}+ await p.close({ runBeforeUnload: false });+ restoredTabsClosed++;+ if (!isBlank(pUrl)) {+ console.log(`Closed restored leftover tab (${pUrl}) on session "${sessionId}" launch`);+ }+ } catch {}+ }+ } catch {}+ if (restoredTabsClosed > 0) {+ console.log(`Session "${sessionId}" launch: closed ${restoredTabsClosed} stale tab(s) β pup is AI-driven, no session-restore needed`);+ }++ // Create the session with this page as its first tab.+ const session = createSession(profileName);+ // v1.9.26: sessions on the authed wiki profile are authed-INTENT β seed the flag NOW, before+ // the first identity stamp, so the FIRST taskbar icon is already the signed-in one. Windows+ // LATCHES a button's icon at creation and in-place re-stamps often do not repaint (proven live:+ // ex-wikiin registered icon=wiki-in yet kept the plain book), so correcting the icon later is+ // unreliable β the first stamp must be right. The real auth probe still runs after open and+ // corrects the flag (and the tab glyph) if the ~30-day session actually expired.+ if (profileName === WIKI_AUTH_PROFILE) session._wikiAuthed = true;+ // v1.8.16: stamp the declared owner (advisory thread/task label) at create.+ if (owner) session.owner = owner;+ // v1.6.3+: stash the per-session downloadPath BEFORE attachTab β attachTab+ // reads it to apply Page.setDownloadBehavior on the new page. Default+ // resolves in attachTab itself if unset.+ if (downloadPath) session._downloadPath = downloadPath;+ attachTab(session, sessionId, page);+ session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution+ // v2.0.261 (John, live on ti.com): BIRTH TIMESTAMP. A user CANNOT have raised a window that did+ // not exist a second ago β there was no taskbar button to click. Without this, pup's own newborn+ // window self-activating while the user happens to be typing reads as "the user raised it", and+ // every demotion path politely backs off and LEAVES IT ON TOP OF THEIR WORK. See BIRTH_GRACE_MS.+ session._bornAt = Date.now();+ // v1.9.158 (flash-counter fix): the launch flash fires BEFORE this registration, so counting+ // via sessions.get() at flash time always missed β single-tab windows read flash:0 while the+ // orange really fired (John's audit column caught it). Credit it here, where the session exists.+ if (_flashState.has(sessionId) && Date.now() - _flashState.get(sessionId) < 60000) session._flashCount = (session._flashCount || 0) + 1;+ session._iconApplied = false; // v2.0.289: reset so the icon-apply progress bar runs for this open+ sessions.set(sessionId, session);+ // v2.0.289 (John): show a determinate taskbar progress bar from window open until its taskbar icon+ // actually lands (the async birth->hwnd->stamp->tile->nudge flow), sized to the running average.+ try { startIconApplyProgress(sessionId); } catch (e) {}+ // v2.0.25: adopt the create-time identity stamp so the post-park re-brand no-ops instead of+ // recreating the taskbar button a second time with the identical appId.+ try {+ const _ea = _earlyStamped.get(sessionId);+ if (_ea) { session._curAppId = _ea; session._frozenAppId = _ea; _earlyStamped.delete(sessionId); }+ } catch (e) {}+ activeSessionId = sessionId;++ // Track refCount+ const be = browsers.get(profileName);+ if (be) be.refCount++;++ // Append sessionId (and profile if different) to the page title so user can identify windows+ try {+ const titleSuffix = profileName !== sessionId+ ? ` (${TITLE_LABEL}: ${sessionId} | profile: ${profileName})`+ : ` (${TITLE_LABEL}: ${sessionId})`;+ await page.evaluate((suffix, sid) => {+ // v1.9.43: ONE named handle for the title observer. An adopted (dragged-out) tab must be able+ // to disconnect the PREVIOUS session's observer, or that observer keeps re-appending the old+ // suffix and fights every re-tag forever.+ // v1.9.153: window.name = durable session carrier (the LAUNCH-path injector was the third,+ // unstamped one β why docs-writer still mis-bound after 1.9.151).+ try { window.name = 'pup:' + sid; } catch (e) {}+ const strip = (t) => String(t || '').replace(/\s*\((?:session|ai-thread):[^)]*\)/g, '').trim();+ try { if (window.__pupTitleObs) { window.__pupTitleObs.disconnect(); window.__pupTitleObs = null; } } catch (e) {}+ // v2.0.24: no visible suffix, no observer (see retagSessionTitle) β window.name is the carrier.+ const cleaned = strip(document.title);+ if (cleaned !== document.title) document.title = cleaned;+ }, titleSuffix, sessionId);+ } catch (e) {+ // Don't fail the session if title injection fails (e.g. about:blank)+ }++ // Persist to disk for recovery after bridge restart+ saveSessionFile(sessionId, profileName, url);++ // Stash one-shot launch metadata so the pup_open_window response can+ // surface it. Cleared after first read in getSessionInfo.+ session.lastLaunchMeta = { restoredTabsClosed };++ console.log(`Session "${sessionId}" launched as tab in profile "${profileName}" (${url || 'about:blank'}). Total sessions: ${sessions.size}`);+ return session;+}+++// Append a tab to an existing session. Returns the new tabId.+async function addTabToSession(sessionId, url) {+ const session = sessions.get(sessionId);+ if (!session) {+ throw new Error(`Session "${sessionId}" not found. Use pup_open_window first.`);+ }+ const be = browsers.get(session.profileName);+ if (!be || !be.browser.isConnected()) {+ throw new Error(`Session "${sessionId}" browser is not connected.`);+ }+ // v1.9.157 (on-desk, John watching) β SERIALIZE tab creation per profile. Measured: newPage()+ // lands the tab in the session's OWN window every time SINGLE-THREADED (2 tabs, 1 window). The+ // split ONLY happens under concurrency: interleaved newPage() calls on the shared profile race+ // on Chrome's ambiguous 'active window', so a tab is born in the wrong window and the drag-out+ // handler splits it. Funnelling newPage through the same _profileOpenChain that serializes opens+ // removes the interleave β each tab is created when its session's window is the settled context.+ const _tabPrev = _profileOpenChain.get(session.profileName) || Promise.resolve();+ // v1.9.245 (John's audit caught phantom "-split1-" sessions): newPage() has NO window affinity β+ // Chrome puts the tab in the process's most-recently-active window, and with many windows on the+ // shared profile that is the WRONG window as often as not. Serializing (1.9.157) narrowed the race+ // but could not remove it: the API simply cannot target a window. So create the tab FROM INSIDE the+ // session's own window: CDP Runtime.evaluate window.open() with userGesture:true (satisfies the+ // popup blocker without a global --disable-popup-blocking). The drag-out detector then never sees a+ // misplaced tab. newPage() stays as the fallback if the in-window open yields nothing in 5s.+ const _tabCur = _tabPrev.then(async () => {+ const anchor = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];+ if (anchor && anchor.page && !anchor._crashed) {+ let cdp = null;+ try {+ // v1.9.295 (John: "why do i keep getting so many about:blank tabs?"): adopt EXACTLY the tab+ // window.open() creates, identified by a UNIQUE per-call marker written into window.name.+ //+ // History of why both simpler schemes failed:+ // - Opener match (waitForTarget on tg.opener() === anchor): window.open() blanks expose NO+ // observable opener here (confirmed in the bridge log: "new tab ... with no opener"), so+ // the match ALWAYS timed out and fell through to newPage() β orphaning the blank+ // window.open had already made. That stray "about:blank" beside the real tab is what John+ // kept seeing.+ // - Plain set-difference (take any new page target): correct for a single add, but under a+ // BURST several sessions' blanks are new at once, so calls raced to grab the first new+ // target and duplicated tabs (a burst of 3 adds tripled the middle one in testing).+ //+ // The marker is unique per call, so it can only ever match this call's own tab β burst-safe.+ // window.name survives navigation (even cross-origin), and about:blank is same-origin to the+ // opener so the write always lands. We adopt while still about:blank, before goto().+ const tok = 'pupclaim_' + (_tabClaimSeq = (_tabClaimSeq + 1) % 1e9) + '_' + Date.now().toString(36);+ const before = new Set(be.browser.targets());+ cdp = await anchor.page.target().createCDPSession();+ await cdp.send('Runtime.evaluate', {+ expression: '(function(){var w=window.open("about:blank");if(w){try{w.name=' + JSON.stringify(tok) + ';}catch(e){}}return !!w;})()',+ userGesture: true });+ const start = Date.now();+ let pg = null;+ while (Date.now() - start < 5000 && !pg) {+ // Only inspect NEW page targets (set-diff narrows the field), then CONFIRM ours by marker.+ const fresh = be.browser.targets().filter(t => { try { return !before.has(t) && t.type() === 'page'; } catch (e) { return false; } });+ for (const t of fresh) {+ let p = null; try { p = await t.page(); } catch (e) {}+ if (!p) continue;+ let nm = null; try { nm = await p.evaluate(() => window.name); } catch (e) { /* mid-nav context torn down; retry next poll */ }+ if (nm === tok) { pg = p; break; }+ }+ if (!pg) await new Promise(r => setTimeout(r, 60));+ }+ if (pg) return pg;+ // No marked tab appeared β window.open was blocked/failed and created NOTHING, so the+ // newPage() fallback below can't orphan a blank.+ } catch (e) { console.log(`[tab] in-window open failed (${e.message}) β falling back to newPage`); }+ finally { try { if (cdp) await cdp.detach(); } catch (e) {} }+ }+ return Promise.race([+ be.browser.newPage(),+ new Promise((_, rej) => setTimeout(() => rej(new Error('newPage timed out β the browser did not create the tab in 20s. Retry pup_open_tab; if it persists, pup_close_window + pup_open_window.')), 20000)),+ ]);+ });+ _profileOpenChain.set(session.profileName, _tabCur.catch(() => {}));+ const page = await _tabCur;+ try { page._pupCreated = true; } catch (e) {}+ const tabId = attachTab(session, sessionId, page, { makeActive: true });++ // v1.8.19: the " (session: <sid>)" title marker is now owned SOLELY by the+ // persistent evaluateOnNewDocument injector in attachTab (already installed above).+ // We deliberately do NOT install a second, per-tab MutationObserver here β two+ // observers appending different suffixes duel forever and hang the tab (the bug this+ // release fixes). tabIds are surfaced via pup_list_tabs, not the title.+ try {+ if (url) {+ try {+ const matchedHost = await credentialVault.applyCredentialsToPage(page, url);+ if (matchedHost) console.log(`[creds] applied to new tab "${tabId}": matched ${matchedHost}`);+ } catch (e) {+ console.log(`[creds] applyCredentialsToPage on new tab failed: ${e.message}`);+ }+ // v1.9.110: keep the tab's own HTTP status so probeRender can report a 404 with certainty.+ const _tResp = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });+ try { page.__pupHttpStatus = _tResp && typeof _tResp.status === 'function' ? _tResp.status() : null; } catch (e) {}+ }+ } catch {}++ console.log(`Session "${sessionId}": added ${tabId} (${url || 'about:blank'}). Total tabs: ${session.tabs.length}`);+ return tabId;+}++// Check if a session's browser is still connected+// v1.9.92 (issue #16) β liveness asks the SESSION'S OWN handle, not just our bookkeeping.+// The old check consulted ONLY the `browsers` map, so the instant `handleBrowserDisconnect` did+// `browsers.delete(profileName)` (a CDP blip, sleep/wake, a renderer crash) every session on that+// profile read as dead β even when its page was still perfectly connected and drivable. That false+// "dead" is what fed the destructive purge below. Now: trust the browsers entry when present, else+// fall back to asking the session's own page whether its browser is still connected. A session that+// can still be driven must never report as not-alive.+function isSessionAlive(s) {+ if (!s) return false;+ const be = browsers.get(s.profileName);+ if (be && be.browser && be.browser.isConnected()) return true;+ // Fallback: the bookkeeping entry is gone, but is the actual page still live?+ try {+ const p = s.page;+ if (p && !p.isClosed() && p.browser() && p.browser().isConnected()) return true;+ } catch (e) { /* detached handle β fall through to not-alive */ }+ return false;+}++++// Resolve a session by explicit sessionId or fall back to active.+// Does NOT change the global activeSessionId β safe for concurrent use.+// Returns { session, resolvedId } or null when sessionId was omitted and+// no live session exists. Never silently retargets when an explicit+// sessionId was passed but doesn't match β see resolveSessionOrError.+function resolveSession(args) {+ // Explicit sessionId path: don't fall through to a different session if+ // the requested one is missing or disconnected. v1.5.0 and earlier did,+ // and that's how chip-fetcher's "pup_navigate landed on the wrong+ // window" bug fired (caller passed the right sessionId, the bridge+ // silently retargeted to whatever it had).+ if (args.sessionId) {+ if (sessions.has(args.sessionId)) {+ const s = sessions.get(args.sessionId);+ if (isSessionAlive(s)) {+ s._lostBrowser = false;+ return { session: s, resolvedId: args.sessionId };+ }+ // Session entry exists but its browser is gone (CDP socket dropped,+ // browser process died, profile crashed). Caller will see a+ // structured error from resolveSessionOrError pointing at+ // pup_rescan.+ }+ return null;+ }+ // No sessionId passed: legacy fallback to active or first alive.+ if (activeSessionId && sessions.has(activeSessionId)) {+ const s = sessions.get(activeSessionId);+ if (isSessionAlive(s)) return { session: s, resolvedId: activeSessionId };+ sessions.delete(activeSessionId);+ }+ for (const [id, s] of sessions) {+ if (isSessionAlive(s)) return { session: s, resolvedId: id };+ if (!s._lostBrowser) sessions.delete(id); // keep disconnected entries for rescan+ }+ return null;+}++// Like resolveSession but throws a STRUCTURED error (JSON-encoded message+// with errorCode + currentSessions + _hint) when nothing resolves.+// v1.5.1+: differentiates "session not found" from "session known but+// browser disconnected" so callers / Claude can give the right next-+// step recipe (pup_rescan vs pup_open_window).+function resolveSessionOrError(args) {+ const resolved = resolveSession(args);+ if (resolved) return resolved;+ const explicit = !!args.sessionId;+ const known = explicit && sessions.has(args.sessionId);+ const requested = args.sessionId || '(none)';+ const currentSessions = [...sessions.keys()];+ const liveSessions = currentSessions.filter(id => {+ const s = sessions.get(id);+ return s && isSessionAlive(s);+ });+ const disconnectedSessions = currentSessions.filter(id => {+ const s = sessions.get(id);+ return s && s._lostBrowser;+ });+ let payload;+ if (known) {+ // Session entry exists but Chrome socket is gone β recoverable.+ payload = {+ error: `Session "${requested}" is known to the bridge but its Chrome browser is currently disconnected. The Chrome window may still be alive on the user's desktop; the CDP socket dropped (network blip, sleep/wake, puppeteer hiccup) and the bridge hasn't reconnected yet.`,+ errorCode: 'session_disconnected',+ currentSessions,+ liveSessions,+ disconnectedSessions,+ _hint:+ `Run \`pup_rescan\` to attempt automatic recovery. The 30s ` ++ `health check will also auto-attempt, but rescan is faster + ` ++ `idempotent. If rescan reports the profile as unreachable, ` ++ `Chrome itself probably crashed β relaunch with pup_open_window.`,+ };+ } else if (explicit) {+ // Caller asked for a specific sessionId we have NO record of.+ // v2.0.128 (consumer bug report #5): be HONEST about a bridge restart instead of leaving the+ // caller to guess whether their window was ever opened. If the bridge came up recently, say so+ // outright β "the bridge restarted Ns ago, your window did not survive" β and note that named+ // sessions auto-restore ONLY when their on-disk session file survived (pup relaunches them to+ // their last URL on the next verb); if this id isn't recovering, that file is gone.+ const _upS = Math.round(process.uptime());+ const _recentRestart = _upS < 180;+ payload = {+ error: _recentRestart+ ? `The bridge restarted ${_upS}s ago and session "${requested}" did not survive β its on-disk session file was not found, so pup cannot auto-restore it. (Named sessions auto-restore across a restart only while their session file persists; this one's is gone, or the window was opened by a build that didn't persist it.)`+ : `Session "${requested}" not found in bridge. Either it was never opened, it was closed via pup_close_window, or the bridge restarted and the on-disk session file was cleaned.`,+ errorCode: 'session_not_found',+ bridgeUptimeSec: _upS,+ bridgeRecentlyRestarted: _recentRestart,+ currentSessions,+ liveSessions,+ disconnectedSessions,+ _hint:+ (_recentRestart ? `The window is gone β reopen it: pup_open_window {"sessionId":"${requested}","url":"..."} (pup re-keys it to this id and remembers it). ` : '') ++ `Live sessions right now: [${liveSessions.join(', ') || '(none)'}]. ` ++ (disconnectedSessions.length > 0+ ? `Disconnected sessions awaiting rescan: [${disconnectedSessions.join(', ')}] β try \`pup_rescan\` first. `+ : '') ++ `If the Chrome window IS still on the desktop but the bridge can't see ` ++ `it, run pup_rescan (it walks every Chrome target and recovers ` ++ `sessions by parsing the (session: X) tag from each page's title). ` ++ `If you need to start fresh, pup_open_window with the right sessionId.`,+ };+ } else {+ // No sessionId passed and nothing is alive.+ payload = {+ error: `No live browser sessions. Use pup_open_window first.`,+ errorCode: 'no_session',+ currentSessions,+ liveSessions,+ disconnectedSessions,+ _hint:+ disconnectedSessions.length > 0+ ? `Disconnected sessions exist on disk: [${disconnectedSessions.join(', ')}]. Run \`pup_rescan\` to see if any are recoverable before opening a new window.`+ : `Open a Chrome window with: pup_open_window {"sessionId":"...","profile":"...","url":"..."}`,+ };+ }+ const err = new Error(JSON.stringify(payload));+ err._isStructured = true;+ throw err;+}++// Send a structured-or-plain session resolve error. Mirror of+// sendTabResolveError β used by every dispatch site that calls+// resolveSessionOrError, so the structured payload (errorCode ++// currentSessions + _hint) reaches the caller intact.+// v2.0.233: this was a byte-for-byte copy of sendTabResolveError (verified by AST compare) β the+// same structured-error unwrap written twice under two names. One implementation, two names kept so+// each call site still reads as "session" or "tab" at its site.+const sendSessionResolveError = sendTabResolveError;++// v1.6.1+: the AUTO-RECOVER path. Tries every layer of recovery before+// surfacing a session-not-found error to the caller. Used by every+// pup_* dispatch site that previously called resolveSessionOrError.+//+// Recovery ladder:+// (a) Session is alive β return immediately.+// (b) Session is in `_lostBrowser` state (CDP socket dropped, sleep/wake,+// transient blip): attemptReconnectProfile(profile) β uses session+// file β DevToolsActivePort β running-process-cmdline-scan as+// three tiers of fallback. If reconnect succeeds, rescanProfile+// walks pages and re-attaches. Return the recovered session.+// (c) Reconnect failed but we have a last-known URL (saved in the+// session file at launch / nav time): kill any orphan Chrome+// holding the profile, clean profile locks (lockfile / Singleton*+// / DevToolsActivePort), then call launchSession to start a fresh+// Chrome with the same profile + URL. Caller sees a transparent+// recovery β same sessionId, fresh page, last URL re-loaded.+// (d) Session entry doesn't exist BUT a session file does (e.g. bridge+// restarted): same logic β try reconnect via session file, fall+// back to relaunch with the file's recorded URL.+// (e) Nothing works β throw the structured session_not_found /+// session_disconnected error from resolveSessionOrError so the+// caller (or its higher-level retry logic) gets a clear signal.+//+// The Docker container's contract: `pup_navigate / eval / screenshot+// / input_dispatch / errors / reload / fetch_url` should NEVER surface+// "Session closed" / "detached Frame" / "Browser disconnected" β those+// become internal retry signals, not user-facing errors.+async function recoverOrRelaunchSession(args) {+ // (a) Fast path: live session, no recovery needed.+ const fast = resolveSession(args);+ if (fast) return fast;++ const requestedId = args.sessionId;+ // No explicit sessionId β fall through to the strict-error path; we+ // can't auto-recover what we don't know. Caller handles this via+ // sendSessionResolveError as before (omitted-sessionId is a real+ // "no session" case, not a recovery case).+ if (!requestedId) {+ return resolveSessionOrError(args);+ }++ const inMem = sessions.get(requestedId);+ const sessionFile = readSessionFile(requestedId);+ // We need at minimum the profile name to do anything. Either from the+ // in-memory session entry or from the on-disk session file.+ const profileName = (inMem && inMem.profileName) || (sessionFile && sessionFile.profile);+ if (!profileName) {+ // Session is genuinely unknown to the bridge. Surface the structured+ // session_not_found error.+ return resolveSessionOrError(args);+ }+ // v1.9.172: fall back to the URL cached on the in-memory session (stamped at disconnect) when the+ // session file is missing or has no URL β so a crash-relaunch still lands on the right page.+ const lastKnownUrl = (sessionFile && sessionFile.url) || (inMem && inMem._lastUrl) || null;++ console.log(+ `[recover] session "${requestedId}" needs recovery β profile="${profileName}", ` ++ `inMemory=${!!inMem}, sessionFile=${!!sessionFile}, lastKnownUrl=${lastKnownUrl || '(none)'}`+ );++ // (b) Try reconnect via the existing 3-tier path (session file β+ // DevToolsActivePort β running-process scan).+ let browser = await attemptReconnectProfile(profileName);+ if (browser) {+ try {+ await rescanProfile(browser, profileName);+ } catch (e) {+ console.log(`[recover] rescanProfile after reconnect failed: ${e.message}`);+ }+ // After rescan, the session may now be in the map.+ const after = resolveSession(args);+ if (after) {+ console.log(`[recover] session "${requestedId}" reconnected without relaunch`);+ return after;+ }+ }++ // (c)/(d) Reconnect failed (or got a browser but couldn't find the+ // session inside it). Kill any orphan Chrome for this profile, clean+ // profile locks, then relaunch fresh with the last-known URL.+ console.log(`[recover] reconnect insufficient β killing orphans + cleaning profile + relaunching session "${requestedId}"`);+ await killOrphanChromesForProfile(profileName);+ await cleanProfileLocks(profileName);+ // Drop the dead in-memory entry so launchSession creates a fresh one.+ // (launchSession's existing path checks for an existing session and+ // would try to navigate it β but its page handle is dead.)+ if (inMem) {+ sessions.delete(requestedId);+ }+ try {+ const session = await launchSession(requestedId, lastKnownUrl, { profile: profileName });+ if (session) {+ session._recovered = true;+ session._recoveryReason = browser ? 'reconnect-no-page' : 'relaunch';+ console.log(`[recover] session "${requestedId}" relaunched at ${lastKnownUrl || '(no URL)'}`);+ const final = resolveSession({ sessionId: requestedId });+ if (final) return final;+ }+ } catch (e) {+ console.log(`[recover] launchSession failed during recovery: ${e.message}`);+ }++ // (e) Total failure β surface the structured error so the caller can+ // tell the user (or escalate to pup_open_window with explicit URL).+ return resolveSessionOrError(args);+}++// Wrap an async page operation with a timeout so the HTTP response always comes back+async function withTimeout(promise, ms, label) {+ let timer;+ const timeout = new Promise((_, reject) => {+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);+ });+ try {+ return await Promise.race([promise, timeout]);+ } finally {+ clearTimeout(timer);+ }+}++// Periodic health check (v1.5.1+):+//+// - Detect disconnected browsers and ATTEMPT RECONNECT before removing+// them. Old (β€v1.5.0) behavior was to immediately delete the browser+// entry + every session for that profile + every session file. That+// erased recovery state for what was usually a transient network+// blip (sleep/wake, puppeteer hiccup), leaving an orphaned-but-alive+// Chrome window the bridge could no longer see. We now keep state+// and try to recover.+//+// - Sessions whose browser is gone get marked _lostBrowser; their entries+// stay in the Map and on disk so pup_rescan / next health check+// can recover them. Only delete a session file when its underlying+// Chrome PID is confirmed dead.+//+// - Stale-file cleanup still runs (PID dead β file deleted) so we don't+// accumulate junk for Chromes that genuinely crashed.+// v2.0.315 (audit): the standalone 30s health loop is folded into the 15s probe loop below+// (every other tick), so there is ONE browser-death detector instead of two.+async function healthLegacyTick() {+ for (const [profileName, be] of browsers) {+ if (!be.browser.isConnected()) {+ console.log(`[health] browser for profile "${profileName}" disconnected β attempting reconnect`);+ browsers.delete(profileName); // attemptReconnectProfile re-adds on success+ const reconnected = await attemptReconnectProfile(profileName).catch(() => null);+ if (reconnected) {+ try {+ const r = await rescanProfile(reconnected, profileName);+ console.log(`[health] auto-reconnected + rescanned profile "${profileName}": ${r.reattached} reattached`);+ } catch (e) {+ console.log(`[health] rescan after reconnect failed for "${profileName}": ${e.message}`);+ }+ } else {+ console.log(`[health] could not reconnect profile "${profileName}" β sessions remain marked _lostBrowser`);+ }+ }+ }+ // Don't hard-delete sessions whose browser is currently disconnected β+ // they may recover on the next loop. Only purge sessions whose specific+ // PAGE handle is dead (tab was closed individually) AND profile is alive.+ for (const [id, s] of sessions) {+ if (s._lostBrowser) continue; // skip β awaiting reconnect+ if (!isSessionAlive(s)) {+ console.log(`[health] session "${id}" dead (page closed individually) β removing`);+ sessions.delete(id);+ deleteSessionFile(id);+ if (activeSessionId === id) {+ activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;+ }+ }+ }+ // v1.9.31: drain the window-opened ring here (no dedicated timer) so an idle desktop still+ // notices a dragged-out tab within one tick; active use catches it immediately via the tab verbs.+ drainWindowOpenedEvents().catch(() => {});+ // Clean stale session files whose PIDs are confirmed dead. Sessions that+ // are _lostBrowser but whose PID is alive STAY on disk for recovery.+ const files = listSessionFiles();+ for (const sf of files) {+ if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {+ console.log(`[health] stale session file "${sf.sessionId}" (PID ${sf.pid} dead) β deleting`);+ deleteSessionFile(sf.sessionId);+ }+ }+}++// v1.0.16: the single source of truth for per-verb HINTS β used BOTH by+// `pup_describe` (so AD's Verbs tab shows hint/related/pitfalls under each+// verb) AND by sendJSON's runtime injector below (so EVERY pup_* call+// self-documents inline, even for an AI that never read describe). This is the+// reference shape the cloud-owned bridges (kicad/fusion/native-browser) were+// asked to adopt: a calling AI should never have to guess and never have to+// read docs first. { verb: { hint, related[], pitfalls[] } }.+const VERB_META = {+ pup_open_window: { hint: 'Opens a generic, fully-controllable pup window (a fresh isolated Chrome/Edge process β never your real signed-in browser) that you then drive by sessionId. To drive the user\'s REAL Chrome/Edge, use the Adom browser extension\'s nbrowser_* verbs instead.', related: ['pup_navigate', 'pup_list_windows', 'pup_close_window'], pitfalls: ['sessionId is OPTIONAL: omit it and your thread gets its OWN window (keyed on your --ai-thread name; later opens become tabs in it). Pass one only for a deliberate separate window β it is then your handle for every later call', 'profile defaults to the durable shared \'adom-you\' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway β omit it for a plain "open in pup"', 'sessionIds are SHARED across all AI threads on this desktop β a generic id ("wiki", "test") may collide with another thread\'s window and navigating it away steals their work. Use a task-prefixed sessionId + pass owner:"<your-task>" so pup can protect it', 'reusing an existing sessionId with a new url NAVIGATES that window β check pup_status (shows owner per window) first if unsure'] },+ pup_open_tab: { hint: 'Adds a tab to an existing session; returns the new tabId.', related: ['pup_switch_tab', 'pup_list_tabs', 'pup_close_tab'], pitfalls: ['the session must already exist (pup_open_window first)'] },+ pup_close_tab: { hint: 'Closes one tab; the session stays open.', related: ['pup_close_window', 'pup_list_tabs'], pitfalls: ['omit tabId to close the active tab β closing the last tab may close the window'] },+ pup_close_window: { hint: 'Closes the whole session and all its tabs. Destructive.', related: ['pup_list_windows'], pitfalls: ['this ends the session β its sessionId is no longer valid afterward'] },+ pup_navigate: { hint: 'Drives the active tab to url and waits for load. Verify with a screenshot before asserting success.', related: ['pup_screenshot', 'pup_wait', 'pup_reload'], pitfalls: ['navigation can race SPA routing β confirm with pup_screenshot', 'some sites need pup_wait for a selector after navigate'] },+ pup_back: { hint: 'History back on the active tab.', related: ['pup_forward', 'pup_navigate'], pitfalls: [] },+ pup_forward: { hint: 'History forward on the active tab.', related: ['pup_back', 'pup_navigate'], pitfalls: [] },+ pup_reload: { hint: 'Reloads the active tab.', related: ['pup_navigate', 'pup_wait', 'pup_dialog'], pitfalls: ['A page with unsaved changes raises a native "Reload site?" dialog that BLOCKS the window: the reload appears to time out and so does everything else. Answer it with pup_dialog.'] },+ pup_dialog: { hint: 'Answer a native JS dialog (alert/confirm/prompt/beforeunload) that is blocking the window: {action:"accept"|"dismiss", promptText?}.', related: ['pup_reload', 'pup_navigate'], pitfalls: ['pup NEVER answers these for you. A beforeunload is a question about the user\'s unsaved work, so accept and dismiss each destroy something different. Read the page with pup_eval first if the work matters.'] },+ pup_click: { hint: 'Clicks by CSS selector (preferred) or by x,y coords. Selector is more robust across layouts.', related: ['pup_type', 'pup_input_dispatch', 'pup_screenshot'], pitfalls: ['selector must match exactly one visible element β verify with pup_evaluate first', 'coords are viewport-relative; a scrolled element may be off-screen'] },+ pup_type: { hint: 'Types into the focused or selector-targeted field.', related: ['pup_click', 'pup_press_key'], pitfalls: ['focus the field first (pup_click the input) or pass selector', 'does not clear existing text β select-all + delete if you need a clean field'] },+ pup_press_key: { hint: 'Presses a key or chord (Enter, Ctrl+A, Tab).', related: ['pup_type', 'pup_input_dispatch'], pitfalls: ['chord syntax is "Ctrl+A" β verify the page has focus on the intended element'] },+ pup_screenshot: { hint: 'PNG of the page. Your primary way to SEE state β call it after navigate/click to confirm.', related: ['pup_screenshot_full_res', 'pup_navigate'], pitfalls: ['default is the viewport; pass fullPage:true for the whole scroll height'] },+ pup_evaluate: { hint: 'Runs JS in the page and returns the result β use it to read DOM state or verify selectors before acting.', related: ['pup_click', 'pup_fetch_url'], pitfalls: ['the expression must be serializable JSON in its return value', 'runs in the page context, not Node'] },+ pup_fetch_url: { hint: 'Fetches a URL using the tab\'s live cookies/session β for authed APIs the bare CLI can\'t reach. Returns bodyBase64.', related: ['pup_navigate', 'pup_evaluate'], pitfalls: ['for session-scoped URLs pass the tabId whose cookies you need', 'returns base64 β decode it caller-side'] },+ pup_input_dispatch: { hint: 'Low-level CDP mouse/keyboard input at coords β beats some overlays/iframes that swallow synthetic clicks.', related: ['pup_click', 'pup_press_key'], pitfalls: ['coords are viewport pixels', 'last-resort when pup_click can\'t reach the element β still cannot pass Cloudflare interactive challenges'] },+ pup_record_start: { hint: 'Starts video recording of the page for this session.', related: ['pup_record_stop', 'pup_record_status'], pitfalls: ['one recording per session β stop the current one before starting another'] },+ pup_record_stop: { hint: 'Stops recording and returns the saved file path.', related: ['pup_record_start', 'pup_record_list'], pitfalls: ['no-op if nothing is recording for this session'] },+ pup_record_status: { hint: 'Whether a recording is active for the session.', related: ['pup_record_start', 'pup_record_stop'], pitfalls: [] },+ pup_record_list: { hint: 'Lists saved page recordings.', related: ['pup_record_start'], pitfalls: [] },+ desktop_record_start: { hint: 'Starts a full desktop screen recording (served by pup for ab-core until adom-bridge#143 lands; needs ffmpeg on the box for the duration header).', related: ['desktop_record_stop', 'desktop_record_status'], pitfalls: ['only one desktop recording at a time'] },+ pup_readiness: { hint: 'Cold-start readiness probe: {ready, chromeForTestingInstalled, installing, installProgressPct}. On a fresh PC, poll this until ready:true before pup_open_window β the bridge auto-installs Chrome for Testing on its own.', related: ['pup_prewarm', 'pup_open_window'], pitfalls: ['ready:false with installing:true just means Chrome for Testing is still downloading β wait + poll, do NOT treat it as an error', 'installPhase:"failed" β surface lastError to the user (usually network/proxy blocking the download, or low disk)'] },+ pup_prewarm: { hint: 'Installs Chrome for Testing (the browser pup drives) WITHOUT opening a window β the warm-before-first-use path. Returns fast; poll pup_readiness for completion.', related: ['pup_readiness', 'pup_open_window'], pitfalls: ['the ~150 MB download needs network access to storage.googleapis.com', 'does not open a window β call pup_open_window once readiness reports ready:true'] },+ pup_import_scan: { hint: 'READ-ONLY discovery for the comprehensive credential import (adom-you seed): enumerates every profile of every Chromium browser (Chrome/Edge/Brave) with its source account email(s), saved-password count, and ABE status. Decrypts NOTHING β actual decryption needs the Bridge-core elevated SYSTEM helper (adom/adom-bridge #51).', related: ['pup_open_window'], pitfalls: ['Windows-only', 'passwordCount is null if python is not on PATH', 'ABE (v20) passwords cannot be decrypted by this user process β that is the Bridge SYSTEM helperβs job'] },+ pup_describe: { hint: 'This list β the bridge\'s verbs + schemas + hints, for AD\'s Verbs tab. Read it once to learn the surface.', related: ['pup_status'], pitfalls: [] },+};++// βββ Bug-report invitation (v1.9.95, John's ask) ββββββββββββββββββββββββββββββ+// We cannot reproduce every environment. The AI driving this bridge is standing on a real user's+// real machine, which is exactly where the bugs we can't see live β the Adom team's wiki-issue+// reports (#14 battery drain, #15 respawn, #16 tracking, #19 repo drift, #22 concurrency) have each+// found something no local test would have. So ASK for reports, in the response the AI actually reads.+//+// Throttled deliberately, because a line on EVERY response is noise the AI learns to skip:+// β’ ALWAYS on a failure β the moment a bug is most likely and the evidence is still in hand.+// β’ ONCE per bridge process on the first verb β orientation, so it's known before anything breaks.+// β’ Occasionally after that (every Nth call) β a quiet reminder on a long-running session.+// Opt out with pup settings {reportIssues:"off"} for a user who doesn't want it.+// v2.0.255 (John, adom/pup-bridge#65, second bug in the same report): this ref is pasted into+// EVERY pup response's _reportIssues hint, and the old slug has 404'd since the rename. The+// hint's whole job is to route bug reports back here, so a stale slug means reports land+// nowhere or get abandoned β the worst possible thing for the one string that asks for help.+const PUP_ISSUES_REF = 'adom/pup-bridge';+// βββ "You just put something on screen β LOOK at it" (v1.9.99, John's ask) ββββββββββββββββββββββ+// The common failure: an AI generates or edits an app, opens it in pup, sees ok:true, and reports+// success to the user WITHOUT ever looking at the render. ok:true means the NAVIGATION worked. It+// says nothing about whether the page painted, whether the CSS loaded, or whether a stack trace is+// filling the viewport. pup is the one component that knows a window was just pointed at content,+// so it is the right place to insist on verification β in the response the AI actually reads.+// v1.9.105 (John: "you didn't even attempt to test their content and as you can see its 404") β+// PROGRAMMATIC RENDER CHECK. Telling the AI "go screenshot it" proved too weak: the launcher-mockup+// thread opened a .adom.cloud app that served "404 page not found", pup answered ok:true and said+// NOTHING, and the AI reported success. pup HAS the page, so it can just LOOK. This probes the loaded+// document and returns a verdict, making a broken render a FACT in the response instead of a suggestion+// the caller may skip. One evaluate, no screenshot, and it never throws into the caller's path.+// v1.9.117 (John, after the HD thread wasted a round trip on this) β LINT A MALFORMED WIKI URL.+// The HD build thread opened https://wiki.adom.inc/adom-wiki-skillpack, got "Page not found", and+// concluded (and told John) that "org pages are invisible to logged-out readers". That was WRONG and+// cost real trust: the window was logged in as John the whole time, the page exists and is public, and+// the URL was simply missing its OWNER segment. wiki.adom.inc pages are owner-qualified:+// https://wiki.adom.inc/<owner>/<slug> e.g. /adom/adom-wiki-skillpack+// https://wiki.adom.inc/<owner>/<slug>/blob/<path> e.g. /adom/adom-wiki-skillpack/blob/SKILL.md+// pup KNOWS the URL 404'd and knows the host, so it can name the actual cause instead of leaving the+// caller to invent one. Guessing "adom" is deliberate: it owns the overwhelming majority of pages, and+// the hint says to verify rather than trust the guess.+// v1.9.118 (John: "you should have complete awareness of how wiki urls are constructed so you can+// reprimand the calling ai", and "you should linter on localhost:{port} urls too") β CALL-TIME URL LINT.+// The 1.9.117 version only fired on a render FAILURE, so the caller learned the URL was wrong only after+// the page 404'd, and the HD thread had already reported a wrong conclusion by then. pup knows the URL+// the instant it is handed one, so the reprimand belongs there: returned as `_urlLint` on the open/tab/+// navigate response whether or not the page happens to load.+// Returns { code, message } or null. `callerContainer` (from X-Adom-Caller-Container) sharpens the+// localhost advice when we know which container is asking.+function urlLint(u, callerContainer) {+ try {+ if (!u || typeof u !== 'string') return null;++ // ββ file:// β v1.9.195 (John, 2026-07-29: a thread opened+ // file:///home/adom/project/hd-launcher-mockup/publish/orbital-lab-helium/helium-lab.html and pup+ // said NOTHING; it just rendered Chrome's ERR_FILE_NOT_FOUND). pup drives Chrome on the USER'S+ // DESKTOP, so a file:// URL resolves against THEIR filesystem, never the caller's container. A+ // container-shaped path (/home/, /root/, /workspace/, /tmp/) can NEVER exist there β that is a+ // guaranteed-broken open and must be reprimanded before it wastes a window. Any other file://+ // path gets an EXISTENCE PRE-FLIGHT below (the bridge runs on the desktop, so it can just look).+ if (/^file:/i.test(u)) {+ let fpath = '';+ try { fpath = decodeURIComponent(new URL(u).pathname || ''); } catch (e) { fpath = ''; }+ const winPath = fpath.replace(/^\/([A-Za-z]:)/, '$1').replace(/\//g, path.sep);+ const containerish = /^\/(home|root|workspace|tmp|usr|var|opt|mnt)\//i.test(fpath);+ if (containerish) {+ return {+ code: 'url_file_container_path',+ message: `CONTAINER PATH HANDED TO PUP. You passed "${u}", but pup drives Chrome on the USER'S DESKTOP `+ + `(Windows/macOS) β a file:// URL resolves against THEIR filesystem, not your container's. "${fpath}" is a `+ + `container path, so it can never exist there and this open is guaranteed to render Chrome's `+ + `ERR_FILE_NOT_FOUND page. Do ONE of these instead: (1) SERVE it and open the public proxy URL β run a `+ + `static server in your container (e.g. \`python3 -m http.server <port>\` in that directory) and open `+ + `https://<your-container>.adom.cloud/proxy/<port>/<file> ; or (2) PUSH it to the user's machine first `+ + `with adom-bridge-cli push_file, then open the file:// path it actually landed at on THEIR disk. Never `+ + `assume your container's paths exist on the user's computer β they are different machines.`,+ };+ }+ // Not container-shaped: it MIGHT be a real path on this desktop. The bridge runs on the desktop,+ // so pre-flight it rather than letting Chrome render an error page the caller may misread.+ try {+ if (winPath && !fs.existsSync(winPath)) {+ return {+ code: 'url_file_not_found',+ message: `FILE DOES NOT EXIST ON THE USER'S MACHINE. pup pre-flighted "${winPath}" on the desktop before `+ + `opening it and there is no such file, so this would have rendered Chrome's ERR_FILE_NOT_FOUND page. `+ + `Check the path, or push the file to the desktop first with adom-bridge-cli push_file and open the path it `+ + `reports back. (Remember: pup's Chrome runs on the USER'S computer, so file:// is THEIR filesystem, `+ + `not your container's.)`,+ };+ }+ } catch (e) {}+ return null;+ }++ const url = new URL(u);+ const host = url.hostname.toLowerCase();++ // ββ localhost / loopback / LAN: pup runs on the USER'S machine, not the caller's container ββ+ if (/^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/.test(host)) {+ const port = url.port || '<port>';+ const path = (url.pathname === '/' ? '' : url.pathname) + (url.search || '');+ const slug = callerContainer ? String(callerContainer).toLowerCase().replace(/[^a-z0-9-]/g, '-') : '<your-container-slug>';+ return {+ code: 'url_localhost',+ message: `WRONG HOST FOR PUP. You passed "${host}${url.port ? ':' + url.port : ''}", but pup drives a browser on the `+ + `USER'S DESKTOP, not inside your container β so "${host}" resolves on THEIR machine and will hit whatever `+ + `(if anything) is listening there, not your dev server. To show a service running INSIDE your container, use `+ + `its public proxy URL: https://${slug}-<suffix>.adom.cloud/proxy/${port}/${path.replace(/^\//, '')} `+ + `(<suffix> is the 10-12 char id assigned to your container; \`adom-cli container list\` or your existing `+ + `container URL has it). If you genuinely meant a server on the USER'S machine, say so explicitly, because `+ + `that is almost never what an AI in a container intends.`,+ };+ }++ // ββ wiki.adom.inc pages are OWNER-QUALIFIED ββ+ if (/(^|\.)wiki\.adom\.inc$/.test(host)) {+ const segs = url.pathname.split('/').filter(Boolean);+ if (!segs.length) return null;+ const ROUTES = new Set(['apps', 'skills', 'components', 'bootstraps', 'discover', 'login', 'search', 'api', 'download', 'static', 'hero', 'settings', 'new']);+ if (ROUTES.has(segs[0].toLowerCase())) return null;+ const ownerMissing = segs.length === 1 || (segs.length >= 2 && segs[1].toLowerCase() === 'blob');+ if (ownerMissing) {+ const slug = segs[0];+ const rest = segs.slice(1).join('/');+ const fixed = `${url.origin}/adom/${slug}${rest ? '/' + rest : ''}${url.search || ''}`;+ return {+ code: 'url_wiki_missing_owner',+ message: `MALFORMED WIKI URL. wiki.adom.inc pages are OWNER-QUALIFIED β https://wiki.adom.inc/<owner>/<slug> `+ + `(and /<owner>/<slug>/blob/<path> for a file) β and yours has no owner segment, so it will 404 no matter `+ + `who is logged in. Most pages are owned by "adom", so you probably want: ${fixed} . Confirm the owner with `+ + `\`adom-wiki page get ${slug}\` (read its "owner" field) instead of assuming. Do NOT conclude the page is `+ + `private, restricted, or hidden from logged-out users because of this β that is a wrong conclusion a `+ + `previous thread reported to the user, and the page was public the whole time.`,+ };+ }+ return null;+ }+ return null;+ } catch (e) { return null; }+}+// Back-compat shim for the failure-path caller.+function wikiUrlLint(u) {+ const r = urlLint(u, null);+ return (r && r.code === 'url_wiki_missing_owner') ? r.message : null;+}+// v1.9.125 (follow-on) β AUTO-FILL a vault-matched login form the instant the page renders.+// Runs after every render verb (open/tab/navigate/reload). Conservative by design:+// - only when the vault has a credential for this exact origin (deliberate opt-in per host),+// - only on real http(s) pages,+// - FILL ONLY, never auto-submit (auto-submitting on load risks MFA/lockouts/wrong-form logins),+// - never throws β autofill must never block or fail a navigation,+// - reported to the caller as _autoLogin, and silenceable with pup_configure {autoLogin:"off"}.+async function autoLoginOnLoad(res, page) {+ try {+ if (!res || !page) return;+ let mode = 'submit'; // v2.0.110: default now fill+submit (confidence-gated below); 'fill'/'off' downgrade+ try { const av = pupSettings().autoLogin; mode = (av === 'off') ? 'off' : (av === 'fill' ? 'fill' : 'submit'); } catch (e) {}+ if (mode === 'off') return;+ let url = '';+ try { url = page.url(); } catch (e) { return; }+ if (!url || !/^https?:/i.test(url)) return;+ const creds = await credentialVault.getCredentialForUrl(url);+ if (!creds) return;+ // v2.0.110 (John): pup FILLS in the background but NEVER auto-submits. Submitting a stored+ // credential is reason-gated (like foregrounding): the AI must call pup_login {submit:true,+ // submitReason} and, in doing so, confirm the LIVE domain it is handing the password to β the+ // hint regurgitates that exact domain so a lookalike/phishing host is caught before the secret+ // is sent. mode 'fill' is identical here (fill-only); 'off' skips entirely.+ let liveHost = '';+ try { liveHost = new URL(url).hostname; } catch (e) {}+ const r = await credentialVault.fillLoginForm(page, { username: creds.username, password: creds.password, submit: false });+ if (r && r.ok && r.filledPassword) {+ res._autoLoginNote = `pup AUTO-FILLED this login for saved host "${creds.host}" (username ${creds.username}) in the BACKGROUND, NOT submitted β no Chrome dropdown, no foregrounding. β BEFORE SUBMITTING, CONFIRM THE DOMAIN: you are about to send this credential to "${liveHost}". Is that the domain you intended, and does it match where this login belongs? A lookalike/typo/redirected host here is how credentials get phished. If you are sure, submit with pup_login {submit:true, submitReason:"<why you are confident this is the right site to send these creds to>"} (the reason is required; it forces you to actually verify the domain). pup_configure {autoLogin:"off"} disables auto-fill.`;+ }+ } catch (e) { /* autofill is best-effort; never disturb the navigation */ }+}+async function probeRender(page, httpStatus) {+ const out = { httpStatus: (httpStatus === undefined ? null : httpStatus), ok: true, signals: [] };+ try {+ const probe = await Promise.race([+ page.evaluate(async () => {+ // v2.0.248: wait BRIEFLY for the document to finish loading before judging. Declining to+ // judge (2.0.247) never lies but almost never fires, because the probe runs while the page+ // is still 'interactive'. A short bounded wait β capped, and only on pages that have not+ // finished β makes the verdict both useful and trustworthy: 'complete' means every image+ // has settled, so placeholders and real failures are distinguishable.+ try {+ if (document.readyState !== 'complete') {+ await Promise.race([+ new Promise((r) => window.addEventListener('load', r, { once: true })),+ new Promise((r) => setTimeout(r, 1500)),+ ]);+ }+ } catch (e) {}+ // v2.0.246 (verifying pup-bridge#60): judge images ONLY when the document has actually+ // finished loading. The probe runs right after navigation, so mid-load it saw placeholders+ // as broken (Colby's false alarm) and missed a real 404 that had not failed yet (the mirror+ // false negative I then created). The load event fires after every image has settled, so at+ // readyState 'complete' the verdict is trustworthy β and before that pup simply declines to+ // judge instead of guessing, which costs nothing and never lies.+ const t = (document.body ? (document.body.innerText || '') : '').trim();+ return {+ title: document.title || '',+ bodyChars: t.length,+ head: t.slice(0, 400),+ elements: document.body ? document.body.querySelectorAll('*').length : 0,+ styleSheets: document.styleSheets ? document.styleSheets.length : 0,+ // v2.0.245 (pup-bridge#60, Colby: "broken_images_6" on a page whose images were fine):+ // an <img> with NO src β or src="" β reports naturalWidth 0 in Chrome and is what every+ // placeholder slot looks like (lazy-load skeletons, hidden preview clones, "image goes+ // here" boxes). Counting those as broken told the driving AI the page was BROKEN when+ // nothing had failed to load. A broken image is a REAL src that failed: non-empty src,+ // finished loading, zero natural width.+ // null = "not judged yet" (page still loading); a number = a verdict pup stands behind.+ brokenImgs: (document.readyState === 'complete' && document.images)+ ? Array.from(document.images).filter((i) => {+ const src = i.getAttribute('src');+ // a placeholder slot (no src / src="") reports naturalWidth 0 and is NOT broken+ return !!(src && src.trim()) && i.complete && i.naturalWidth === 0;+ }).length : null,+ };+ }),+ new Promise(r => setTimeout(() => r(null), 6000)), // covers the bounded load wait above+ ]);+ if (!probe) { out.signals.push('probe_timed_out'); return out; }+ out.title = probe.title; out.bodyChars = probe.bodyChars; out.elements = probe.elements;+ const head = String(probe.head || '');+ const low = head.toLowerCase();+ if (typeof out.httpStatus === 'number' && out.httpStatus >= 400) out.signals.push('http_' + out.httpStatus);+ if (/\b404\b|page not found|not found\b/.test(low)) out.signals.push('page_says_not_found');+ if (/cannot (get|post) \//i.test(head)) out.signals.push('dev_server_cannot_get');+ if (/internal server error|bad gateway|service unavailable/.test(low)) out.signals.push('server_error_text');+ if (/traceback \(most recent call last\)|unhandled (exception|rejection)|referenceerror|typeerror:|syntaxerror:/.test(low)) out.signals.push('stack_trace_visible');+ if (probe.bodyChars < 12 && probe.elements < 8) out.signals.push('effectively_blank');+ if (probe.bodyChars > 40 && probe.styleSheets === 0) out.signals.push('no_stylesheets_loaded');+ if (typeof probe.brokenImgs === 'number' && probe.brokenImgs > 0) out.signals.push('broken_images_' + probe.brokenImgs);+ out.ok = out.signals.length === 0;+ if (!out.ok) out.excerpt = head.slice(0, 180);+ } catch (e) { out.signals.push('probe_failed'); }+ return out;+}+const RENDER_VERBS = new Set(['pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_reload']);+// A URL that is almost certainly the caller's OWN work-in-progress (a dev server, a local file, an+// Adom-hosted app) earns the strongest wording β that is exactly the case John described.+function looksLikeOwnContent(u) {+ try {+ if (!u) return false;+ if (/^file:/i.test(u)) return true;+ const h = new URL(u).hostname;+ return /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$/i.test(h)+ || /\.local$/i.test(h)+ || /\.adom\.cloud$/i.test(h)+ || /^(\d{1,3}\.){3}\d{1,3}$/.test(h);+ } catch (e) { return false; }+}+function renderVerifyHint(sessionId, url, rc) {+ const sid = sessionId || '<sessionId>';+ const own = looksLikeOwnContent(url);+ // A DETECTED failure outranks advice: say what is wrong in the first words the caller reads.+ if (rc && rc.ok === false && (rc.signals || []).length) {+ const sig = rc.signals.join(', ');+ // v1.9.117: a malformed wiki URL is a KNOWN cause, so lead with it rather than generic advice.+ const lint = wikiUrlLint(url);+ if (lint) {+ return 'THIS PAGE DID NOT RENDER CORRECTLY (' + sig + '). ' + lint+ + ' See it yourself: `adom-bridge-cli --ai-thread "<your thread>" pup_screenshot {"sessionId":"'+ + sid + '"}`. Do NOT report success, and do NOT report a conclusion about permissions or login'+ + ' until the URL is owner-qualified and you have re-checked.';+ }+ const st = (typeof rc.httpStatus === 'number') ? ' (HTTP ' + rc.httpStatus + ')' : '';+ const ex = rc.excerpt ? ' The page says: "' + String(rc.excerpt).replace(/\s+/g, ' ').slice(0, 140) + '".' : '';+ return 'THIS PAGE DID NOT RENDER CORRECTLY β pup checked it, so do NOT report success. Detected: '+ + sig + st + '.' + ex+ + ' See it yourself: `adom-bridge-cli --ai-thread "<your thread>" pup_screenshot {"sessionId":"' + sid + '"}`.'+ + ' Then FIX THE CAUSE: a 404 or "Cannot GET" usually means the dev server is not running, is on a'+ + ' different port, or the path is wrong; unstyled HTML means the CSS 404d; a stack trace means the'+ + ' app threw. After fixing, `pup_reload` and re-check until this response comes back clean.'+ + ' Do not tell the user it works while this warning is present.';+ }+ const lead = own+ ? 'THIS LOOKS LIKE CONTENT YOU JUST BUILT OR EDITED β SCREENSHOT IT AND LOOK BEFORE YOU CLAIM IT WORKS.'+ : 'VERIFY WHAT YOU JUST PUT ON SCREEN β do not assume it rendered.';+ return `${lead} ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. `+ + `Take a screenshot and READ the image: \`adom-bridge-cli --ai-thread "<your thread>" pup_screenshot {"sessionId":"${sid}"}\` β it returns a real PNG path you can open. `+ + `Look for: a blank or white page, an unstyled flash of plain HTML (CSS never loaded), a stack trace or framework error overlay, `+ + `missing images, a broken or overlapping layout, content cut off past the fold, a dev-server "Cannot GET /" page, or the wrong build entirely. `+ + `If it is wrong: fix the source, \`pup_reload\`, and screenshot AGAIN β ralph-test that loop until the render is genuinely correct, `+ + `rather than reporting success after one open. If the viewport is too small to judge, pup_set_viewport first; `+ + `for a long page use pup_screenshot {fullPage:true}. Showing the user a screenshot of their own working app is also the fastest way to prove it works.`;+}+let _bugInviteCalls = 0;+let _bugInviteFirstDone = false;+const BUG_INVITE_EVERY = 40;+function bugReportInvite(isError) {+ const lead = isError+ ? 'THIS MAY BE A PUP BUG β PLEASE REPORT IT.'+ : 'pup is actively developed and bug reports are genuinely wanted.';+ return `${lead} If a pup verb misbehaves (wrong result, a hang, a window that never appears, a hint that turned out to be false, a process left running), file it on pup's own wiki repo so the maintainers see it: \`adom-wiki issue list ${PUP_ISSUES_REF}\` to check for a duplicate first, then \`adom-wiki issue create ${PUP_ISSUES_REF} --title "<one-line symptom>" --category bug-report --body "<what you ran, what you expected, what happened, the VERBATIM response JSON, and the bridge version from bridge_list>"\`. Include the machine's OS and whether it is Windows or macOS. Reports from real machines are the only way this bridge gets correct β the maintainers cannot reproduce every environment, browser install, or concurrency pattern locally. (A Bridge-core problem β the relay, bridge lifecycle, caller identity β belongs on adom/adom-bridge instead.)`;+}+// Resolve the session a response is ABOUT, from the caller's own addressing. pup's documented forms+// are zero-id (resolve by ai-thread), {window:"..."} and the legacy {sessionId:"..."}, so all three+// have to be honoured or a notice fires only for whichever form the author happened to test.+// GROUND TRUTH FOR EMULATION, measured rather than remembered (project-a5, 2026-08-28).+// pup used to answer "is this window emulated?" from its own record, and a RECOVERED window resets+// that record to null while Chrome keeps the CDP device-metrics override, because the override+// lives in the browser and not in pup. Every consumer then agreed the window was fine: the listings+// reported no override, the warning never fired, and reset's guard compared against nothing and+// passed vacuously with emulated:false while the page was demonstrably still 2200px wide in a+// 1688px window.+// outerWidth is the one number an override cannot fake, so the divergence IS the answer and needs+// no bookkeeping to survive a recovery. A real window's inner is a little smaller than its outer+// (borders, scrollbar); an emulated one is either far smaller or, in the cropping case, LARGER.+const EMULATION_SLACK = 60;+function looksEmulated(innerWidth, outerWidth) {+ if (!innerWidth || !outerWidth) return false;+ if (innerWidth > outerWidth + 2) return true; // wider than the window: content cropped+ return (outerWidth - innerWidth) > EMULATION_SLACK; // far narrower: dead space beside it+}+async function measureViewport(page, session) {+ try {+ const d = await page.evaluate(() => ({+ innerWidth: window.innerWidth, innerHeight: window.innerHeight,+ outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio,+ }));+ const out = { ...d, emulated: looksEmulated(d.innerWidth, d.outerWidth), at: Date.now() };+ if (session) { session._measuredViewport = out; session._lastOuterWidth = d.outerWidth; }+ return out;+ } catch (e) { return null; }+}+function sessionForResponse(obj, argsIn) {+ try {+ const a = argsIn || {};+ // The payload is frequently SERIALISED INTO obj.output as a JSON string rather than living on+ // the object, so obj.sessionId is undefined for most verbs. Look inside it too. This is why the+ // first three attempts at these notices resolved nothing: the id was there, one level down.+ let inner = null;+ if (obj && typeof obj.output === 'string' && obj.output.charAt(0) === '{') {+ try { inner = JSON.parse(obj.output); } catch (e) {}+ }+ const sid = (obj && (obj.sessionId || obj.window))+ || (inner && (inner.sessionId || inner.window))+ || a.sessionId || a.window;+ if (sid && sessions.get(sid)) return sessions.get(sid);+ const thread = a['ai-thread'] || a.aiThread;+ if (thread) { for (const [, s2] of sessions) if (s2 && (s2.owner === thread || s2._ownerThread === thread)) return s2; }+ return null;+ } catch (e) { return null; }+}+// Notices that describe THIS session's state, delivered on the verbs that would otherwise report a+// confident wrong answer. Deliberately outside the bug-invite path: these are not bug reports.+function attachSessionNotices(obj, verb, argsIn) {+ try {+ if (!obj || typeof obj !== 'object' || Array.isArray(obj) || !verb) return;+ const sess = sessionForResponse(obj, argsIn);+ if (!sess) return;+ // AN EMULATED VIEWPORT IS INVISIBLE TO THE AI THAT SET IT (John, 2026-08-27). He found a window+ // rendering ~290px narrower than the browser window while the driving thread reported the page+ // was "taking up 100% width". Both were true: a CSS-level check measures the EMULATION, so a+ // page filling 1400 of 1400 reports 100% while the user looks at 1400 of 1690. Every honest+ // in-page measurement agrees with itself and disagrees with the screen, so the override has to+ // say so on the verbs used to measure and to look.+ if (obj._viewportOverride === undefined && VIEWPORT_SENSITIVE.has(verb)) {+ // Disclose an UNRECORDED override too. Today's root cause pinned the viewport without ever+ // writing the record, so a warning that trusts the record is silent exactly when it matters.+ // sess._measuredViewport is refreshed by any verb that measures the page.+ let ev = sess._emulatedViewport;+ if (!ev && sess._measuredViewport && sess._measuredViewport.emulated) {+ ev = { width: sess._measuredViewport.innerWidth, height: sess._measuredViewport.innerHeight,+ deviceScaleFactor: sess._measuredViewport.devicePixelRatio, derived: true };+ }+ if (ev && ev.width) {+ // Both failure directions are real and they look opposite on screen. NARROWER than the+ // window leaves dead space down the side; WIDER crops content off the edge, which is the+ // one that hides information from the user. Name which one this is.+ const outer = Number(sess._lastOuterWidth) || null;+ let shape = '';+ if (outer && ev.width > outer + 4) {+ shape = ` The emulation is WIDER than the OS window (${ev.width} vs ${outer}), so the user is seeing this page CROPPED: roughly ${ev.width - outer}px of it is off the right edge of their window.`;+ } else if (outer && ev.width < outer - 4) {+ shape = ` The emulation is NARROWER than the OS window (${ev.width} vs ${outer}), so the user is seeing dead space beside the page.`;+ }+ obj._viewportOverride = `This window has an EMULATED VIEWPORT of ${ev.width}x${ev.height}`+ + (ev.deviceScaleFactor ? ` @${ev.deviceScaleFactor}x` : '')+ + `, so it is NOT tracking the real OS window.` + shape+ + ` Anything you measure in the page (innerWidth, clientWidth, a bounding rect) describes the emulation, not what the user sees. Clear it with pup_set_viewport {"reset":true} when you are done emulating.`;+ }+ }+ // #80: the restart notice, delivered to the session it happened to rather than broadcast.+ if (obj._sessionNotice === undefined) {+ const n = drainSessionNotice(sess);+ if (n) obj._sessionNotice = n;+ }+ // If the payload lives inside output, put the notices THERE as well: that is the object the+ // caller actually reads, and a field on the envelope is easy to miss.+ try {+ if (typeof obj.output === 'string' && obj.output.charAt(0) === '{' && (obj._viewportOverride || obj._sessionNotice)) {+ const j = JSON.parse(obj.output);+ if (obj._viewportOverride && j._viewportOverride === undefined) j._viewportOverride = obj._viewportOverride;+ if (obj._sessionNotice && j._sessionNotice === undefined) j._sessionNotice = obj._sessionNotice;+ obj.output = JSON.stringify(j);+ }+ } catch (e) {}+ } catch (e) {}+}+function maybeAttachBugInvite(obj, verb, argsIn) {+ try {+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return;+ if (obj._reportIssues) return; // handler already set one+ // v1.9.96a β ONLY count AI-facing SUBSTANTIVE verbs. Two layers of machine traffic were eating+ // the once-per-process invitation before any AI could see it, and each failed SILENTLY (the+ // feature simply never appeared):+ // 1. AD probes /health (+ /status for the bridge chip) every ~15s β those replies flow through+ // this same sendJSON chokepoint. Excluded by requiring a `verb` at all.+ // 2. AD also calls pup's DECLARED statusVerb (pup_readiness) through /command on spawn, so+ // it arrives as a real verb and consumed the invite anyway (measured twice: the first verb+ // after a restart still carried nothing).+ // So the orientation invite now fires on the first SUBSTANTIVE verb β an open/drive, the moment+ // John actually described ("when typical verbs are being invoked like open browser") β while+ // read-only probes and enumerations neither consume nor carry it. Failures still always carry it,+ // whatever the verb, because that is when the evidence exists.+ if (!verb) return;+ const PROBE_VERBS = new Set([+ 'pup_readiness', 'pup_status', 'pup_describe', 'pup_prewarm',+ 'pup_list_windows', 'pup_list_tabs', 'pup_rescan', 'pup_configure', 'pup_import_scan',+ ]);+ const isProbe = PROBE_VERBS.has(verb);+ let mode = 'on';+ try { mode = (pupSettings().reportIssues === 'off') ? 'off' : 'on'; } catch (e) {}+ if (mode === 'off') return;+ // v1.9.119: hand over any pending janitorial report. Verb-scoped for the same reason the invite is:+ // AD's /health poll and its statusVerb probe would otherwise consume it before any AI saw it.+ try {+ if (!isProbe && obj._cleanupReport === undefined) {+ const rep = drainCleanupReports();+ if (rep) obj._cleanupReport = rep;+ }+ } catch (e) {}+ const isError = obj.success === false || !!obj.errorCode || (!!obj.error && obj.error !== '');+ // A FAILURE always earns the invitation, even on a probe β that is the highest-value moment.+ if (isError) { obj._reportIssues = bugReportInvite(true); return; }+ if (isProbe) return; // probes: never consume, never carry+ _bugInviteCalls++;+ const first = !_bugInviteFirstDone;+ const periodic = _bugInviteCalls % BUG_INVITE_EVERY === 0;+ if (!first && !periodic) return;+ if (first) _bugInviteFirstDone = true;+ obj._reportIssues = bugReportInvite(false);+ } catch (e) { /* never let the invitation break a response */ }+}++function sendJSON(res, obj, status = 200) {+ try {+ // v1.0.16: runtime _hint injection β if this response is for a documented+ // verb and the handler didn't already set its own _hint, attach the verb's+ // canonical hint (+ related) from VERB_META. So EVERY pup_* call carries+ // actionable guidance inline, matching the contract AD asks all bridges for.+ // v2.0.363: session notices are attached HERE, not inside maybeAttachBugInvite. That function+ // returns early on `if (obj._reportIssues) return`, and most substantive verbs already carry the+ // bug-report invite, so anything I put after that guard was unreachable for exactly the verbs+ // that matter. Two notices shipped into that dead zone before I read the control flow instead of+ // assuming it. They are independent of the invite and belong at the chokepoint itself.+ attachSessionNotices(obj, res && res._adomCommand, res && res._adomArgs);+ maybeAttachBugInvite(obj, res && res._adomCommand, res && res._adomArgs);+ if (obj && typeof obj === 'object' && !Array.isArray(obj)) {+ const meta = res && res._adomCommand && VERB_META[res._adomCommand];+ if (meta) {+ if (obj._hint === undefined && meta.hint) obj._hint = meta.hint;+ if (obj.related === undefined && Array.isArray(meta.related) && meta.related.length) obj.related = meta.related;+ // SDK "THE ONE PRINCIPLE": every verb response carries _next (the obvious next verbs) too.+ if (obj._next === undefined && (Array.isArray(meta.next) || Array.isArray(meta.related))) obj._next = meta.next || meta.related;+ if (obj.pitfalls === undefined && Array.isArray(meta.pitfalls) && meta.pitfalls.length) obj.pitfalls = meta.pitfalls;+ }+ }+ // v1.9.98 β CRITICAL DELIVERY FIX. Anything attached to the OUTER envelope is DROPPED on a+ // SUCCESSFUL call: the conventional success shape is {success:true, output:"<json string>"}, and+ // AD/the CLI unwrap `output` into the top level and discard the rest of the envelope. Measured:+ // a pup_navigate success came back as {durationMs, ok, sessionId, status, tabId, url} β no+ // _hint, no _next, no _reportIssues, even though sendJSON had set them. (Failures survive because+ // their handlers set those fields on the outer object AND AD preserves the envelope on an error.)+ // So the VERB_META hints and the bug-report invitation were only ever reaching callers on FAILURE+ // β exactly backwards from the SDK's first principle that every response teaches the caller.+ // Fix: when `output` is a JSON object string, merge these advisory fields INSIDE it too, without+ // overwriting anything the handler already put there.+ try {+ if (obj && typeof obj === 'object' && typeof obj.output === 'string' && obj.output.charAt(0) === '{') {+ const inner = JSON.parse(obj.output);+ if (inner && typeof inner === 'object' && !Array.isArray(inner)) {+ let touched = false;+ // v2.0.111: credential hints surface on ANY verb, not just render verbs β the+ // anti-phishing auto-fill note and the login-capture note must reach the caller on its+ // very next call whatever that call is.+ if (res && res._autoLoginNote && inner._autoLogin === undefined) { inner._autoLogin = res._autoLoginNote; touched = true; }+ try {+ const _cs = sessions.get(inner.sessionId || (obj && obj.sessionId));+ if (_cs && _cs._pendingCredHint && inner._credentialCaptured === undefined) {+ inner._credentialCaptured = _cs._pendingCredHint; _cs._pendingCredHint = null; touched = true;+ }+ } catch (e) {}+ // v2.0.130 (#47, aiThread-first): every response naming a sessionId ALSO carries+ // `window` (the new public name for the per-window label) and `aiThread` (the owning+ // thread), so callers learn the new vocabulary with zero breakage; sessionId stays a+ // back-compat alias. One teaching hint fires when an AI caller spelled out the id of+ // its OWN thread's default window β zero-id addressing would have resolved it.+ try {+ if (inner.sessionId && inner.window === undefined) {+ inner.window = inner.sessionId; touched = true;+ const _os = sessions.get(inner.sessionId);+ const _own = _os && (_os._ownerThread || _os.owner);+ if (_own && inner.aiThread === undefined) inner.aiThread = _own;+ const _ct = res && res._pupCallerThread;+ if (res && res._pupExplicitSid && _ct && !USER_CALLERS.has(_ct) && inner._addressing === undefined+ && _own === _ct && threadDefaultSessionId(_ct) === inner.sessionId) {+ inner._addressing = 'FYI: this is YOUR thread\'s own window β omit the id entirely (zero-id addressing: pup resolves your --ai-thread name to your window). `window` is the new name for this label (sessionId remains a silent alias); pass either only when one thread runs several windows.';+ }+ }+ } catch (e) {}+ // v1.9.99: on a verb that just pointed a window at content, insist the caller LOOK at it.+ // Only on success (a failure has its own errorCode + hint to act on first), and never+ // overwriting a handler that already said something about verification.+ try {+ const _v = res && res._adomCommand;+ const _ok = inner.ok !== false && obj.success !== false && !obj.errorCode;+ if (_v && RENDER_VERBS.has(_v) && _ok && inner._verifyRender === undefined) {+ const _rc2 = (res && res._renderCheck) || null;+ if (_rc2 && inner.renderCheck === undefined) inner.renderCheck = _rc2;+ // v1.9.118: CALL-TIME reprimand β fires on the URL itself, pass or fail.+ try {+ if (inner._urlLint === undefined) {+ const _lu = inner.url || (obj && obj.url) || (res && res._lintUrl);+ const _ml = urlLint(_lu, res && res._callerContainer);+ if (_ml) { inner._urlLint = _ml.message; inner.urlLintCode = _ml.code; touched = true; }+ }+ } catch (e) {}+ // v1.9.109: on ANY render verb, a detected failure must lead the hint the caller reads,+ // not just pup_open_window's (which composes its own). reload/navigate get it here.+ if (_rc2 && _rc2.ok === false && (_rc2.signals || []).length) {+ const _st = (typeof _rc2.httpStatus === 'number') ? ' (HTTP ' + _rc2.httpStatus + ')' : '';+ const _lead = '\u26D4 THE PAGE IS STILL BROKEN: ' + _rc2.signals.join(', ') + _st+ + (_rc2.excerpt ? ' \u2014 it says "' + String(_rc2.excerpt).replace(/\s+/g, ' ').slice(0, 120) + '"' : '')+ + '. Do NOT report success. See _verifyRender. ';+ const _cur = (inner._hint !== undefined ? inner._hint : obj._hint) || '';+ if (String(_cur).indexOf('STILL BROKEN') === -1 && String(_cur).indexOf('IS BROKEN') === -1) inner._hint = _lead + _cur;+ }+ inner._verifyRender = renderVerifyHint(inner.sessionId || (obj && obj.sessionId), inner.url || (obj && obj.url), _rc2);+ touched = true;+ }+ } catch (e) {}+ for (const k of ['_reportIssues', '_cleanupReport', '_autoLogin', '_hint', '_next', 'related', 'pitfalls']) {+ if (obj[k] !== undefined && inner[k] === undefined) { inner[k] = obj[k]; touched = true; }+ }+ if (touched) obj.output = JSON.stringify(inner);+ }+ }+ } catch (e) { /* output wasn't JSON β leave it exactly as the handler wrote it */ }+ res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });+ res.end(JSON.stringify(obj));+ } catch (e) { /* response already sent */ }+}++function readBody(req) {+ return new Promise(resolve => {+ let d = '';+ req.on('data', c => d += c);+ req.on('end', () => resolve(d));+ });+}++// v1.9.172 (jam fix): a pure IN-MEMORY snapshot of a session β NO CDP, so it can never hang.+// pup_list_windows falls back to this when a window is too busy (Chrome under load) to answer+// a live CDP read in time, so the list ALWAYS returns instead of stalling to null ("pup looks+// frozen"). Values come from the last successful read cached on the session.+function getSessionInfoFast(sessionId, session) {+ return {+ sessionId,+ url: session._lastUrl || null,+ title: session._lastTitle || null,+ active: sessionId === activeSessionId,+ errorCount: session.errors ? session.errors.length : 0,+ profile: session.profileName,+ tabCount: session.tabs ? session.tabs.length : 0,+ activeTabId: session.activeTabId,+ overlay: session._overlayDesc || (session._appOverlay ? 'favicon' : 'none'),+ flashCount: session._flashCount || 0,+ progressBarCount: session._progressCount || 0,+ owner: session.owner || null,+ // v1.9.223 (issue #22): say WHERE the owner came from β 'declared' (caller passed owner) vs+ // 'caller-identity' (from AD's authenticated X-Adom-Caller-* headers, so not self-asserted).+ ownerSource: session.owner ? (session._ownerFromCallerIdentity ? 'caller-identity' : 'declared') : null,+ ageMinutes: session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null,+ lastAgentUpdate: session._lastAgentUpdate || null,+ lastForeground: session._lastForeground || null,+ emulatedViewport: session._emulatedViewport || null,+ degraded: true,+ degradedReason: 'this window was busy (Chrome under heavy load) so pup returned its LAST-KNOWN state instead of stalling β the window is NOT crashed; retry shortly for live values',+ };+}++async function getSessionInfo(sessionId, session) {+ let url = null, title = null;+ try {+ url = session.page?.url() || null; // synchronous (puppeteer-cached frame URL) β never hangs+ // page.title() is a CDP round-trip that HANGS on a jammed/busy page. Cap it hard and fall back+ // to the last-known title so a single busy window can never stall pup_list_windows (v1.9.172).+ if (session.page) title = await withTimeout(session.page.title(), 1200, 'title').catch(() => session._lastTitle || null);+ } catch (e) {}+ if (url) session._lastUrl = url; // cache for the degraded/fast path+ if (title) session._lastTitle = title;+ const info = {+ sessionId,+ url,+ title,+ active: sessionId === activeSessionId,+ errorCount: session.errors.length,+ profile: session.profileName,+ tabCount: session.tabs.length,+ activeTabId: session.activeTabId,+ // v1.9.150: which taskbar overlay this window wears and why (favicon:<host> | count:N | none).+ overlay: session._overlayDesc || (session._appOverlay ? 'favicon' : 'none (not yet applied)'),+ flashCount: session._flashCount || 0,+ progressBarCount: session._progressCount || 0,+ progressBarMs: (session._progressMs || 0) + (session._progressSince ? Date.now() - session._progressSince : 0),+ // v1.8.16: advisory ownership β which AI thread/task opened this window.+ owner: session.owner || null,+ // v1.9.223 (issue #22): say WHERE the owner came from β 'declared' (caller passed owner) vs+ // 'caller-identity' (from AD's authenticated X-Adom-Caller-* headers, so not self-asserted).+ ownerSource: session.owner ? (session._ownerFromCallerIdentity ? 'caller-identity' : 'declared') : null,+ ageMinutes: session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null,+ // v1.8.23: last thing the agent did here {tabId, verb, note, ts} β for the user ++ // Session Monitor to see WHICH window, WHAT, and WHEN.+ lastAgentUpdate: session._lastAgentUpdate || null,+ // v1.8.26: did background-by-default actually take? 'backgrounded' (good),+ // 'still-foreground' (focus steal β a bug), 'no-window', or null (foreground open+ // or non-Windows). Self-verified by re-reading the OS foreground window post-open.+ // v1.8.70: who put this window on the user's screen, when, and their stated reason β+ // the audit answer to "why did this foreground?" in one status query.+ lastForeground: session._lastForeground || null,+ // v1.9.219 (issue #28): report a pinned viewport. A window that ignores OS resizing must be+ // visibly in that state β otherwise it is indistinguishable from an app with broken layout code.+ emulatedViewport: session._emulatedViewport || null,+ background: (() => {+ try {+ const bg = browsers.get(session.profileName)?.browser?._bgResult || null;+ // v1.8.59: a park-failed window is sitting invisible off-screen (deliberately β better+ // than popping over the user's work). Any status/list query opportunistically retries+ // the heal, so it converges to 'backgrounded' without waiting for a tab touchpoint.+ if (bg === 'park-failed') healOffscreenWindow(session, sessionId).then(r => { try { if (r) browsers.get(session.profileName).browser._bgResult = r; } catch (e) {} }).catch(() => {});+ return bg;+ } catch (e) { return null; }+ })(),+ // v1.8.45: PROGRAMMATIC flash state. 'none' = not flashed; 'pending' = flashed and the+ // user has NOT activated it since (taskbar still lit); 'cleared' = the user activated it+ // (clicked its taskbar button) after the flash, so the tint is gone. Computed from the+ // server-side flash timestamp vs the in-page focus latch (window.__pupFocusTs, set by the+ // injected 'focus' listener). Lets a caller answer "which windows are still flashing?"+ // without a screenshot.+ flash: await (async () => {+ try {+ const key = session._sessionId || sessionId;+ const flashTs = _flashState.get(key) || 0;+ if (!flashTs) return 'none';+ let focusTs = 0;+ // v1.9.172: cap this CDP eval too β on a jammed page it would hang getSessionInfo just like+ // title() did. On timeout we can't know if the flash was cleared, so report 'pending' (safe:+ // it just means "may still be flashing", never a false 'cleared').+ try { focusTs = await withTimeout(session.page.evaluate(() => window.__pupFocusTs || 0), 800, 'flash-focus').catch(() => 0); } catch (e) {}+ return (focusTs && focusTs > flashTs) ? 'cleared' : 'pending';+ } catch (e) { return null; }+ })(),+ };+ // One-shot: surface launch-time metadata (restored-tab cleanup count)+ // so callers can verify session-restore prevention worked. Drop after read.+ if (session.lastLaunchMeta) {+ info.restoredTabsClosed = session.lastLaunchMeta.restoredTabsClosed;+ delete session.lastLaunchMeta;+ }+ return info;+}++// v1.8.16: shared ownership gate for mutating verbs (navigate / close). Returns a+// refusal payload when the caller DECLARED an owner that mismatches the session's+// (send it as-is), else null. Anonymous callers pass (advisory model β we can't+// authenticate threads), but open_window's hints teach them to declare.+function ownershipRefusal(session, sessionId, args, verb) {+ const caller = (args && typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;+ if (!session || !session.owner || !caller || caller === session.owner || (args && args.takeover)) return null;+ let curUrl = null; try { curUrl = session.page?.url() || null; } catch {}+ const age = session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null;+ return {+ success: false,+ errorCode: 'session_owned_by_another_thread',+ error: `Session "${sessionId}" belongs to another thread (owner "${session.owner}", opened ${age} min ago, currently showing ${curUrl}).`,+ sessionId, owner: session.owner, requestedBy: caller, currentUrl: curUrl, ageMinutes: age,+ _hint: `STOP β this window belongs to AI thread "${session.owner}", not you ("${caller}"). ${verb} on it would break that thread's work. Open your OWN window instead: pup_open_window with a UNIQUE task-prefixed sessionId (e.g. "${caller}-web") and owner:"${caller}". Only pass takeover:true if the user EXPLICITLY told you to repurpose this window. pup_status lists every window + its owner.`,+ };+}++// v1.9.224 β dashboard state. Deliberately built from the SAME in-memory registries the verbs use,+// with NO CDP calls, so opening the dashboard can never wake a throttled page, disturb a window, or+// add load to the shared socket. It reports what pup BELIEVES, which is exactly what you want when+// debugging pup: a divergence between this and the screen IS the bug.+const _tabFavCache = new Map(); // tab URL -> {buf, mime} (v1.9.225)+async function dashboardSnapshot() {+ const now = Date.now();+ const wins = [];+ for (const [sid, sess] of sessions) {+ const tabs = (sess.tabs || []).map(t => {+ let u = null, ttl = null;+ try { u = t.page && t.page.url(); } catch (e) {}+ try { ttl = t._lastTitle || null; } catch (e) {}+ // v1.9.238: attach how THIS tab's favicon was obtained (format/mode/transparent/kind). Comes+ // from the rich resolver's cache, keyed by the tab's URL; the active tab also has the live+ // overlay state. Shown in each tab's dashboard row so the favicon logic is fully transparent.+ let fav = null;+ try { const fm = u && _tabFavMeta.get(u); if (fm) fav = fm.meta; } catch (e) {}+ if (t.tabId === sess.activeTabId && sess._overlayFav) { const { b64, ...m } = sess._overlayFav; fav = m; }+ return {+ tabId: t.tabId, url: u, title: ttl,+ active: t.tabId === sess.activeTabId,+ crashed: !!t._crashed,+ crashReason: t._crashReason || null,+ opener: t.opener || null,+ fav,+ };+ });+ const hf = sess._highFpsUntil && sess._highFpsUntil > now;+ wins.push({+ sessionId: sid,+ profile: sess.profileName,+ owner: sess.owner || null,+ ownerSource: sess.owner ? (sess._ownerFromCallerIdentity ? 'caller-identity' : 'declared') : null,+ ownerThread: sess._ownerThread || null,+ ownerContainer: sess._ownerContainer || null,+ lastThread: sess._lastThread || null,+ foreground: !!sess._foreground,+ foregroundReason: (sess._lastForeground && sess._lastForeground.reason) || null,+ foregroundAt: (sess._lastForeground && sess._lastForeground.ts) || null,+ foregroundVerb: (sess._lastForeground && sess._lastForeground.verb) || null,+ background: sess._bgResult || null,+ highFps: !!hf,+ highFpsMinutesLeft: hf ? Math.max(0, Math.round((sess._highFpsUntil - now) / 60000)) : null,+ openedAt: sess.createdAt || null,+ uptimeMs: sess.createdAt ? now - sess.createdAt : null,+ tabCount: tabs.length,+ tabs,+ overlay: sess._overlayDesc || null,+ overlayWhy: sess._overlayWhy || null,+ // v1.9.237 (John: "put an led indicator indicating the check for overlay icons match came back+ // green vs red"). Per-window overlay health, the source of truth the LED rolls up:+ // green = pup painted a REAL page favicon and the exact bytes are on disk to serve the badge+ // amber = painted, but the page had no favicon so it's the legit generic fallback+ // red = not painted yet / paint failed β the taskbar button would be bare or wrong+ _curAppId: sess._curAppId || null,+ // v1.9.316 (John: "why doesn't the dashboard say if i'm logged in or not to the wiki for pup+ // windows that are wiki-centric icons?"). pup already tracks the wiki login state in+ // sess._wikiAuthed (drives the signed-in glyph); surface it on any window whose active page is an+ // Adom wiki URL so the dashboard shows SIGNED IN vs PUBLIC per wiki window.+ wiki: (() => {+ try {+ const at = (tabs || []).find(t => t && t.active) || (tabs || [])[0];+ const u = (at && at.url) || sess._lastUrl || null;+ if (u && isAdomUrl(u)) return { authed: !!sess._wikiAuthed, view: sess._wikiView || (sess._wikiAuthed ? 'authed' : 'public') };+ } catch (e) {}+ return null;+ })(),+ // v2.0.227: pup KNEW this window's button was never rebuilt (SKIP-FG) yet the chip still read+ // green MATCH while the taskbar showed the stock Chrome name + stale art. Surface it.+ identityPending: !!(sess._nudgePending),+ identityPendingMs: sess._nudgePending ? Date.now() - sess._nudgePending.since : 0,+ overlayLed: (() => {+ const painted = !!sess._appOverlay;+ // v2.0.230: this ran a SYNC stat per session on every 2s dashboard push. The overlay+ // compositor already records whether it wrote bytes for this session, so trust the flag and+ // only touch the disk when it has never painted (the rare, genuinely-unknown case).+ const hasBytes = (sess._overlayBytesAt ? true : (() => { try { return fs.existsSync(path.join(os.tmpdir(), 'pup-badge-debug', sid + '.png')); } catch (e) { return false; } })());+ if (!painted || !hasBytes) return 'red';+ return sess._overlayIsGeneric ? 'amber' : 'green';+ })(),+ hwnd: sess._hwnd || null,+ emulatedViewport: sess._emulatedViewport || null,+ lostBrowser: !!sess._lostBrowser,+ lastAgentUpdate: sess._lastAgentUpdate || null,+ errorCount: (sess.errors || []).length,+ // v1.9.257 (John: "if there's an err, let the tooltip show what the error is"): the last few+ // actual error texts ride along so the err tag can explain itself instead of just counting.+ recentErrors: (sess.errors || []).slice(-5).map(e => ({ type: e.type, text: String(e.text || '').slice(0, 160) })),+ flashCount: sess._flashCount || 0,+ url: (() => { try { return sess.page && sess.page.url(); } catch (e) { return sess._lastUrl || null; } })(),+ });+ }+ // v1.9.225 (John): group into the HIERARCHY the user actually sees β TASKBAR BUTTON β window(s) β+ // tabs. Which windows share a button depends on pup's taskbarGrouping setting: 'split' gives each+ // window its own AUMID (its own button), 'grouped' stacks them all under one "Adom Pup" button, and+ // under minimalTouch pup stamps no identity at all so they are plain Chrome buttons. Modelling this+ // explicitly is also diagnostic: two sessions sharing one hwnd (the duplicate-session bug) show up+ // as two rows under a single button instead of looking like two unrelated windows.+ const grouping = (() => { try { return pupSettings().taskbarGrouping || 'split'; } catch (e) { return 'split'; } })();+ const buttons = new Map();+ for (const w of wins) {+ let key, label;+ // v1.9.233 (John: "why do i see 3 rows for main taskbar icons yet i only see 2 taskbar icons?"):+ // under minimal-touch pup stamps NO per-window AUMID, and Windows groups unbranded windows by+ // their PROCESS - so ALL windows of one Chrome profile share ONE taskbar button. Keying by+ // hwnd/session invented a button per window and the dashboard showed more rows than the real+ // taskbar had icons. Key by PROFILE (= one Chrome process = one button), which is why the header+ // count "chromes N" equals the number of real taskbar icons.+ // v1.9.248: AUMID stamping is REVERTED (settled no-AUMID decision; see+ // dev-skills/pup-aumid-decisions). This branch remains ONLY to truthfully model windows that+ // were stamped before the revert - the AUMID property lives on the window until it closes, so+ // those really do keep their own taskbar buttons. New windows never enter it.+ // v1.9.306 (John flashed his live windows so I could finally SEE the real taskbar clearly): my+ // 1.9.298 change was WRONG. I claimed "every pup window wears the teal TILE" from a blurry crop,+ // and forced the dashboard to draw teal for all. The flash proved the opposite: with aumidIcons+ // OFF (the settled default), the LIVE pup windows wear the **Chrome-for-Testing exe icon** + the+ // pup overlay β the teal tiles I saw were GHOST buttons from dead AUMID-stamped windows, not the+ // live ones. So the dashboard must show what the window ACTUALLY wears: the teal stamp tile ONLY+ // when the window is genuinely AUMID-stamped (_curAppId), otherwise the real Chrome-for-Testing+ // exe icon. That makes the strip match the glass again.+ let baseIcon;+ if (w._curAppId) {+ key = 'aumid:' + w._curAppId;+ label = w.sessionId;+ baseIcon = '/dashboard/stampicon?session=' + encodeURIComponent(w.sessionId);+ } else {+ key = 'chrome-process:' + w.profile;+ label = 'Chrome (' + w.profile + ')';+ baseIcon = '/dashboard/baseicon?profile=' + encodeURIComponent(w.profile);+ }+ if (!buttons.has(key)) buttons.set(key, { key, label, kind: 'process', baseIcon, grouping: minimalTouch() ? 'minimal-touch' : grouping, windows: [], badgeSession: null, badgeAt: 0, badgeVia: null });+ const btn = buttons.get(key);+ btn.windows.push(w.sessionId);+ // Windows shows the overlay of whichever window is FOREMOST in the group. Mirror that: prefer the+ // foreground window's badge; otherwise the newest-painted. Record which, so the LED can say whether+ // the dashboard badge matches what the taskbar button is actually wearing.+ const paintedAt = (w.overlay && !String(w.overlay).startsWith('none') && w.overlayWhy && w.overlayWhy.at) || 0;+ if (w.foreground) { btn.badgeSession = w.sessionId; btn.badgeAt = Infinity; btn.badgeVia = 'foreground window'; btn.badgeLed = w.overlayLed; }+ else if (paintedAt > btn.badgeAt) { btn.badgeAt = paintedAt; btn.badgeSession = w.sessionId; btn.badgeVia = 'newest-painted (no window foreground)'; btn.badgeLed = w.overlayLed; }+ }+ // v1.9.237: button LED = green only if EVERY window under it wears a real-favicon overlay (a single+ // bare/wrong window makes the whole taskbar button suspect); red if any window is bare; amber if all+ // painted but some are the generic fallback. This is the "do the dashboard overlays match the taskbar"+ // check John asked to see as a green/red light.+ for (const btn of buttons.values()) {+ const leds = btn.windows.map(sid => { const w = wins.find(x => x.sessionId === sid); return w ? w.overlayLed : 'red'; });+ // v2.0.227: an identity pup stamped but never got onto the SHELL (skipped nudge) means this+ // button is displaying the WRONG name/art right now β that is a red, not a green.+ const _pend = btn.windows.filter(sid => { const w = wins.find(x => x.sessionId === sid); return w && w.identityPending; });+ btn.identityPending = _pend.length;+ btn.led = _pend.length ? 'red' : leds.includes('red') ? 'red' : leds.includes('amber') ? 'amber' : 'green';+ btn.ledWhy = _pend.length ? `identity stamped but the shell button was never rebuilt on ${_pend.length} window(s) (the nudge is skipped while a window is focused) β this button still shows its PREVIOUS name/art; a retry is queued`+ : leds.includes('red') ? 'a window under this button has NO overlay painted (taskbar would be bare/stale)'+ : leds.includes('amber') ? 'all painted, but a window fell back to the generic icon (page had no favicon)'+ : 'every window wears its real page-favicon overlay β dashboard matches the taskbar';+ }+ // v1.9.248 (John: "your dashboard view of the windows taskbar icons is not accurate to what is+ // showing in the taskbar and yet you have a green led indicating it is. so you are still lying to+ // me"). He is right: the LED asserted "matches the taskbar" while only checking pup's own+ // bookkeeping. Windows offers no API to read BACK a button's overlay, but pup CAN catch the worst+ // lie: real taskbar buttons from pup-managed Chrome processes that pup has NO session for (zombie+ // windows from lost sessions - his two mystery teal tiles). Enumerate (cached 12s) and surface+ // them as untracked; the header can only claim MATCH when there are none.+ let untracked = [];+ try {+ const nowT = Date.now();+ if (!dashboardSnapshot._enumAt || nowT - dashboardSnapshot._enumAt > 12000) {+ dashboardSnapshot._enumAt = nowT;+ dashboardSnapshot._enumHwnds = await Promise.race([enumAllPupChromeHwnds(), new Promise(r => setTimeout(() => r(null), 9500))]) || dashboardSnapshot._enumHwnds || [];+ }+ const known = new Set(wins.map(w => Number(w.hwnd)).filter(Boolean));+ untracked = (dashboardSnapshot._enumHwnds || []).filter(h => !known.has(Number(h)));+ } catch (e) {}+ const threads = [...new Set(wins.map(w => w.owner || w.ownerThread).filter(Boolean))].sort();+ const profiles = {};+ for (const w of wins) profiles[w.profile] = (profiles[w.profile] || 0) + 1;+ let depsOk = false; try { require.resolve('puppeteer'); depsOk = true; } catch (e) {}+ return {+ ts: now,+ bridge: { version: BRIDGE_VERSION, platform: process.platform, uptimeMs: Math.round(process.uptime() * 1000), depsOk, minimalTouch: minimalTouch(), flashedPending: _flashedHwnds.size, settings: pupSettings(), loops: loopReport(), win32Calls: (win32.stats ? win32.stats() : {}), win32InProcess: win32.available(), abEnumLastMin: (_enumWindow && Date.now() - _enumWindow.at < 60000 ? _enumWindow.n : 0) },+ counts: { windows: wins.length, tabs: wins.reduce((a, w) => a + w.tabCount, 0), browsers: browsers.size, threads: threads.length },+ threads, profiles,+ taskbarButtons: [...buttons.values()],+ untrackedWindows: untracked,+ toasts: _dashToasts.filter(t => Date.now() - t.at < 15000),+ regen: _regenBusy ? { running: true, inFlight: (_lastRegen && _lastRegen.inFlight) || 0 }+ : (_lastRegen && _lastRegen.done ? { running: false, at: _lastRegen.at, swept: _lastRegen.swept, painted: _lastRegen.painted, changed: _lastRegen.changed } : null),+ windows: wins.sort((a, b) => (b.openedAt || 0) - (a.openedAt || 0)),+ };+}+++// v1.9.224 β the dashboard page. Single self-contained document (no CDN, no build step) because it+// is served by the bridge itself and must work on a machine with no network. Adom dark theme.+// The six "say it" phrases the dock shows on its card and this dashboard lists at the top. They+// teach the user to ASK for pup by its noun instead of hunting for buttons (John, via the Dock Bar+// guide), so they are deliberately verbs a person would actually say out loud.+const SAY_PHRASES = [+ 'open your dashboard in pup',+ 'open my wiki page in pup',+ 'open my app in pup',+ 'screenshot this site in pup',+ 'record a clip of this page in pup',+ 'show me the logged-out view in pup',+];+// "Play the demo" hands the dock card's demo script to a fresh AI thread rather than running a+// scripted sequence from here. The scripted runner lived in the container-side helper, and the+// desktop cannot reach the container's loopback (measured: a pup window opened on a container port+// came up with zero tabs), so a button on this desktop-served page could never have called it.+// Driving it through the AI is also the honest demonstration: pup's whole point is that you ASK.+const DEMO_PROMPT = 'Run the pup demo and show me what pup can do on my machine.\n\n'+ + '1. Open a pup window to https://wiki.adom.inc/adom/pup-bridge. Tell me it opened in the BACKGROUND, pup never steals my screen, and that I can ask you to open one in the foreground any time I want to watch, or just click its taskbar button myself.\n'+ + '2. Screenshot it and show me the result, so I can see you can read the page without touching my browser.\n'+ + '3. Record a short clip of the window. Mention highFps mode, which records demo video at full frame rate while the window stays in the background.\n'+ + '4. Annotate the page: draw on it and capture it, and show me the gliding cursor with its intent narration so I can see what you are doing and why.\n'+ + '5. Read the pup dashboard LED status and tell me each light. If anything is not green, name the exact verb that fixes it and offer to run it.\n\n'+ + 'Then close the demo window so nothing is left on my desktop.';++// The local Hydrogen control API. THE PORT IS DYNAMIC AND MUST BE DISCOVERED (v2.0.351).+// This used to hardcode 47084 with a fallback to ~/.adom/hydrogen-control-url. Both are+// CONTAINER-side facts that I inherited when the say-strip moved from the container helper into+// this bridge, and neither holds on the desktop: there is no hydrogen-control-url file there, and+// ah's control API was actually on 63549. So every call to Hydrogen from this bridge was hitting a+// closed port. The version gate failed OPEN (an unreachable probe is treated as "cannot read, do+// not block"), so it looked healthy while gating nothing, and the module's send relay answered 502.+// Found by the module's checkSend probe, which exists for exactly this: a missing gate reads as an+// allowed gate rather than as an error.+//+// The discovery responder answers on one of four fixed candidates and its body carries the real+// control_url. Probe, cache, and RE-PROBE whenever a call fails, because the port moves when ah+// restarts and a cached value is stale from that moment on.+const HYDROGEN_DISCOVERY_PORTS = [47080, 48080, 49180, 50280];+let HYDROGEN_CTL = null;+let _ctlAt = 0;+function _httpGetJson(url, timeoutMs) {+ return new Promise((resolve) => {+ const req = http.get(url, { timeout: timeoutMs || 1500 }, (r) => {+ let b = ''; r.on('data', d => { b += d; });+ r.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve(j); });+ });+ req.on('error', () => resolve(null));+ req.on('timeout', () => { req.destroy(); resolve(null); });+ });+}+async function hydrogenControlUrl(force) {+ if (!force && HYDROGEN_CTL && Date.now() - _ctlAt < 300000) return HYDROGEN_CTL;+ // In a container the workspace file is authoritative and costs nothing to read.+ try {+ const u = fs.readFileSync(path.join(os.homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim();+ if (u) { HYDROGEN_CTL = u.replace(/\/$/, ''); _ctlAt = Date.now(); return HYDROGEN_CTL; }+ } catch (e) {}+ for (const port of HYDROGEN_DISCOVERY_PORTS) {+ const j = await _httpGetJson(`http://127.0.0.1:${port}/`, 1200);+ if (j && j.control_url) {+ HYDROGEN_CTL = String(j.control_url).replace(/\/$/, '');+ _ctlAt = Date.now();+ console.log(`[hydrogen] control API discovered at ${HYDROGEN_CTL} (via responder on ${port})`);+ return HYDROGEN_CTL;+ }+ }+ HYDROGEN_CTL = null;+ return null;+}+async function hydrogenGet(urlPath, timeoutMs) {+ let r = await _hydrogenGetOnce(await hydrogenControlUrl(false), urlPath, timeoutMs);+ // A dead port means the cached URL is stale (ah restarted), not that Hydrogen is gone.+ if (!r.status) r = await _hydrogenGetOnce(await hydrogenControlUrl(true), urlPath, timeoutMs);+ return r;+}+function _hydrogenGetOnce(base, urlPath, timeoutMs) {+ if (!base) return Promise.resolve({ status: 0, error: 'hydrogen control API not discovered' });+ return new Promise((resolve) => {+ const req = http.get(base + urlPath, { timeout: timeoutMs || 2500 }, (res2) => {+ let b = ''; res2.on('data', (d) => { b += d; });+ res2.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res2.statusCode, json: j, raw: b, contentType: res2.headers['content-type'] || null }); });+ });+ req.on('error', (e) => resolve({ status: 0, error: e.message }));+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+ });+}+async function hydrogenPost(urlPath, bodyObj, timeoutMs) {+ let r = await _hydrogenPostOnce(await hydrogenControlUrl(false), urlPath, bodyObj, timeoutMs);+ if (!r.status) r = await _hydrogenPostOnce(await hydrogenControlUrl(true), urlPath, bodyObj, timeoutMs);+ return r;+}+function _hydrogenPostOnce(base, urlPath, bodyObj, timeoutMs) {+ if (!base) return Promise.resolve({ status: 0, error: 'hydrogen control API not discovered' });+ return new Promise((resolve) => {+ const data = JSON.stringify(bodyObj);+ const req = http.request(base + urlPath, { method: 'POST', timeout: timeoutMs || 4000,+ headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, (res2) => {+ let b = ''; res2.on('data', (d) => { b += d; });+ res2.on('end', () => { let j = null; try { j = JSON.parse(b); } catch (e) {} resolve({ status: res2.statusCode, json: j, raw: b, contentType: res2.headers['content-type'] || null }); });+ });+ req.on('error', (e) => resolve({ status: 0, error: e.message }));+ req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });+ req.end(data);+ });+}+// Release-version gate. GET /updater/status -> updater.current_version is the ONE endpoint that+// carries a release version: /version returns an endpoint list, and /health and /buildinfo carry+// only build_sha / build_epoch, which are worse than useless as gates (build_sha lies on+// incremental builds; build_epoch pins the feature to one machine's build clock, so every user on+// a stock signed release is refused forever). A pre-1.0.142 shell still carries the crash family+// this feature was parked for. UNREADABLE FAILS OPEN: never block on a field we cannot read.+const SEND_MIN_RELEASE = [1, 0, 142];+let _shellGate = { at: 0, ok: true, why: '' };+async function shellSupportsSend() {+ if (Date.now() - _shellGate.at < 60000) return _shellGate;+ const r = await hydrogenGet('/updater/status');+ const u = (r && r.json && (r.json.updater || r.json)) || null;+ const raw = u && typeof u.current_version === 'string' ? u.current_version.trim() : '';+ const m = raw.match(/^(\d+)\.(\d+)\.(\d+)/);+ let out = { ok: true, why: '' };+ if (m) {+ const v = [+m[1], +m[2], +m[3]];+ let cmp = 0;+ for (let i = 0; i < 3; i++) { const d = (v[i] || 0) - (SEND_MIN_RELEASE[i] || 0); if (d) { cmp = d < 0 ? -1 : 1; break; } }+ if (cmp < 0) out = { ok: false, why: 'Your Hydrogen (' + raw + ') predates the Send to AI fix. Update to 1.0.142 or newer to enable it; the phrase was copied instead.' };+ }+ _shellGate = { at: Date.now(), ...out };+ return _shellGate;+}++const DASHBOARD_HTML = `<!doctype html><html lang="en"><head>+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">+<title>Pup live windows</title>+<link rel="icon" type="image/svg+xml" href="/dashboard/icon.svg?v=${BRIDGE_VERSION}">+<style>+@import url('https://adom.inc/fonts/fonts.css');+*{box-sizing:border-box} html,body{margin:0;background:#0d1117;color:#c9d1d9;font:14px/1.5 Satoshi,"Segoe UI",system-ui,sans-serif}+/* v2.0.97 (John): brand scrollbars everywhere β the stock chrome bar looked bolted-on in the+ settings dialog. Thin, rounded, track invisible, thumb on the border token. */+::-webkit-scrollbar{width:9px;height:9px}+::-webkit-scrollbar-track{background:transparent}+::-webkit-scrollbar-thumb{background:#30363d;border-radius:8px;border:2px solid #0d1117}+::-webkit-scrollbar-thumb:hover{background:#484f58}+::-webkit-scrollbar-corner{background:transparent}+a{color:#64abff}+header{position:sticky;top:0;z-index:10;background:#161b22;border-bottom:1px solid #30363d;height:44px;padding:0 16px;display:flex;gap:10px;align-items:center}+/* ββ DOCK CONTRACT regions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ+ The newbie panel is NO LONGER hand-rolled here. adom-ui-design 2.0.0 ships it as ONE shared+ module and pup mounts it (see /dashboard/newbie-panel.js). Its CSS, markup, icons, clipboard+ path, send path, toasts, tooltips and open/closed memory all live in the module. Every rule the+ old hand-rolled panel declared is DELETED rather than left beside the module: a stray .btn or+ .ibtn declared after the module's rules is exactly how other apps silently lost their styling to+ source order. What remains here is the status pill row, which is its own contract region. */+/* THE FLEET GUTTER (adom-ui-design 2.4.0). ONE number, declared once, used by the panel's mount+ container AND by every region under it, so they share an edge because there is a single source+ of truth rather than because two literals happen to match. That "by luck rather than by rule"+ distinction was my own observation about this page and it is now a fleet rule.+ pup was full-bleed at ZERO a side, not the ~4px it looked like: #newbie is a direct child of+ body with no padding, so the panel measured exactly clientWidth and the 9px gap from the 744+ pane was entirely the scrollbar. selfCheck could not see it while it only measured inside the+ panel.+ 2.5.0 no longer cares WHICH element carries the padding: it finds the nearest scrolling ancestor+ and checks that the panel sits 16px inside it on both sides. The contract is where the edges+ land, not the structure. (Earlier versions named an element and were wrong twice: measuring+ documentElement failed correct app-shell pages by a scrollbar width, and measuring the mount div+ would have red-lined every page that carried the gutter on an outer container.)+ pup stays with the TOKEN rather than nesting, and that is now the fleet ruling for this shape:+ the pill row and filter bar below carry FULL-BLEED bottom borders as separators, and a rule that+ spans the pane reads as a divider between regions, while an inset one reads as a card edge. The+ panel is the only card in that stack, so the separators stay full width and the gutter is shared+ through one token instead. */+:root{--gutter:16px}+#newbie{padding:0 var(--gutter)}+.ledrow{display:flex;flex-wrap:wrap;gap:10px;padding:12px var(--gutter);border-bottom:1px solid #30363d}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:default}+.led-pill .d{width:8px;height:8px;border-radius:50%;flex:none}+.led-pill .d.ok{background:#3fb950}.led-pill .d.warn{background:#d29922}.led-pill .d.fail{background:#f85149}+.led-pill .lb{font-weight:600;color:#e6edf3}.led-pill .sh{color:#8b949e}+#recall[aria-pressed="true"]{color:#00b8b1;background:#21262d}+/* 620px, NOT 760px (adom-ui-design 1.25.0). Measured dock panes run about 744px to 853px, so a+ 760px breakpoint sat INSIDE that band and the two-column panel collapsed in the very place it+ was designed for: John's pane is 744px, so he never once saw the layout this panel specifies.+ 620px clears the whole pane band while still collapsing on a phone (390px). */+@media(max-width:620px){.nb-cols{grid-template-columns:1fr}.nb-right{border-left:0;border-top:1px solid #30363d;padding-left:0;padding-top:16px}.play{width:100%;justify-content:center}}+.brand{position:relative;display:flex;align-items:center;gap:7px;color:#00b8b1;font-family:"Familjen Grotesk",Satoshi,sans-serif;font-weight:600;font-size:14px;cursor:default}+.brand .bicon{width:16px;height:16px;color:#e6edf3}+.brand .bsub{color:#8b949e;font-weight:500}+.vsep{width:1px;height:22px;background:#30363d;flex:0 0 1px}+.subject{display:flex;align-items:baseline;gap:10px;min-width:0;flex:1 1 auto;overflow:hidden}+.sname{font-weight:700;color:#e6edf3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.smeta{color:#8b949e;font-size:12px;font-family:"JetBrains Mono",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.smeta b{color:#c9d1d9;font-weight:500}+@media(max-width:720px){.smeta{display:none}}+.acts2{margin-left:auto;display:flex;gap:6px}+.ibtn{width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid #30363d;border-radius:8px;color:#e6edf3;cursor:pointer;transition:background .15s ease}+.ibtn:hover{background:#1c2128;border-color:#00b8b1}+.ibtn.danger:hover{border-color:#f85149;color:#f85149}+.ibtn svg{width:16px;height:16px}+.pop{position:fixed;z-index:99999;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:10px 12px;font-size:12px;color:#c9d1d9;font-weight:400;font-family:Satoshi,sans-serif;display:none;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:auto;line-height:1.7}+.pop.show{display:block}+#bar{display:flex;gap:10px;align-items:center;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d;flex-wrap:wrap}+.dot{width:8px;height:8px;border-radius:50%;background:#3fb950;display:inline-block;margin-right:6px}+.dot.stale{background:#d29922}+.pill{background:#111b23;border:1px solid #1d2833;border-radius:999px;padding:3px 10px;font-size:12px;color:#9fb0c0}+.pill b{color:#e6edf3;font-weight:600}+select,input{background:#111b23;color:#e6edf3;border:1px solid #23303c;border-radius:6px;padding:5px 9px;font-size:13px}+.btn{background:#16222c;border:1px solid #2a3a47;color:#cfe0ea;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer}+.btn:hover{background:#1d2b36;border-color:#00b8b1}+.btn.sm{padding:2px 8px;font-size:11px}+.btn.xs{padding:0 6px;font-size:11px;line-height:16px}+.btn.danger{border-color:#5c2b2b;color:#ff9d95}+.btn.danger:hover{background:#2a1717;border-color:#f85149}+.sub{margin-left:auto;color:#7d8f9f;font-size:12px}+.loops{margin:18px 0 6px;padding:10px 12px;border:1px solid #30363d;border-radius:8px;background:#161b22;font:11.5px ui-monospace,monospace;color:#8b949e}+.lpt{color:#7d8f9f;margin:2px 0 6px}+.lp{display:inline-block;margin:0 10px 6px 0;padding:2px 7px;border:1px solid #30363d;border-radius:6px;color:#c9d1d9}+.lp b{color:#00b8b1}+.lp.hot{border-color:#f85149;color:#f85149}+.lp.hot b{color:#f85149}++/* taskbar strip: mimic of the real Windows taskbar */+#toasts{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column-reverse;gap:8px;align-items:center}+.toast{background:#21262d;border:1px solid #30363d;border-left:3px solid #00b8b1;color:#c9d1d9;border-radius:8px;padding:8px 14px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.5);transition:opacity .3s ease}+.toast.success{border-left-color:#3fb950}+.toast.error{border-left-color:#f85149}+.toast.out{opacity:0}+/* ===== Settings modal (adom-ui-design / settings-layout, v2.0.296) =====+ Tokens first: a scheme swap must change nothing but these. */+#dlgWrap{position:fixed;inset:0;z-index:99998;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--accent:#00b8b1;--on-accent:#05221f;--red:#f85149;--green:#3fb950;--brand-heading:"Familjen Grotesk",Satoshi,sans-serif;--brand-body:Satoshi,"Familjen Grotesk",sans-serif}+/* the backdrop law: DIM 25%, never blur (settings often change the very thing behind the modal) */+#dlgBg{position:absolute;inset:0;background:rgba(0,0,0,.25)}+/* the radius element never scrolls: #dlg clips, .settings-body is the scroller */+#dlg{position:absolute;top:48px;left:50%;transform:translateX(-50%);width:min(880px,94vw);max-height:calc(100vh - 96px);display:flex;flex-direction:column;overflow:hidden;padding:0;background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.6);outline:none}+.dlghead{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:var(--surface);border-bottom:1px solid var(--border);font-family:var(--brand-heading);font-weight:600;font-size:15px;color:var(--text)}+.settings-content{display:flex;flex:1 1 auto;min-height:0}+.settings-side{width:13rem;flex-shrink:0;border-right:1px solid var(--border);overflow:auto;padding:.75rem .5rem}+.settings-body{flex:1;min-width:0;overflow:auto;padding:1rem 1.25rem;scroll-behavior:smooth;max-height:70vh}+/* house scrollbar: thin, transparent track, translucent pill, teal on hover */+.settings-body::-webkit-scrollbar,.settings-side::-webkit-scrollbar{width:8px;height:8px}+.settings-body::-webkit-scrollbar-track,.settings-side::-webkit-scrollbar-track{background:transparent;margin:12px 0}+.settings-body::-webkit-scrollbar-thumb,.settings-side::-webkit-scrollbar-thumb{background:rgba(139,148,158,.35);border-radius:8px;border:2px solid transparent;background-clip:padding-box}+.settings-body::-webkit-scrollbar-thumb:hover,.settings-side::-webkit-scrollbar-thumb:hover{background:var(--accent);background-clip:padding-box}+/* sidebar: every entry a button; parents expanded; indentation is the hierarchy cue */+.side-sec{width:100%;padding:.3rem .5rem;border-radius:6px;display:flex;align-items:center;gap:.25rem;background:none;border:none;color:var(--text);font-family:var(--brand-body);font-size:.8125rem;text-align:left;cursor:pointer;user-select:none}+.side-sub{width:100%;padding:.25rem .5rem .25rem 1.5rem;border-radius:6px;display:flex;align-items:center;background:none;border:none;color:var(--text-body);font-family:var(--brand-body);font-size:.75rem;text-align:left;cursor:pointer;user-select:none}+.side-sec:hover,.side-sub:hover{color:var(--accent);background:rgba(255,255,255,.06)}+.side-sec.selected,.side-sub.selected{color:var(--accent);font-weight:700}+.side-sec:focus-visible,.side-sub:focus-visible{outline:2px solid var(--accent);outline-offset:1px}+/* content hierarchy: section (h2) > card (h3) > row */+.sec-title{height:2rem;display:flex;align-items:center;margin:.25rem 0 .5rem;font-family:var(--brand-heading);font-size:1.6825rem;font-weight:800;color:var(--accent);scroll-margin-top:.75rem}+.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem;scroll-margin-top:.75rem}+.card-title{height:2rem;display:flex;align-items:center;gap:10px;margin:0;font-family:var(--brand-heading);font-size:1.3125rem;font-weight:600;color:var(--accent)}+.card-status{font-family:var(--brand-body);font-size:11px;color:var(--text-2);font-weight:400}+.card-status.ok{color:var(--green)}+.card-desc{margin:.2rem 0 .5rem;font-family:var(--brand-body);font-size:.8125rem;line-height:1.55;color:var(--text-2);max-width:60ch}+.card-desc code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}+.divider{height:1px;background:var(--border);margin:.55rem 0}+.set-row{padding:.5rem 0;display:flex;flex-direction:column;gap:.25rem}+.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}+.row-name{margin:.125rem 0;font-family:var(--brand-heading);font-size:.9375rem;font-weight:600;color:var(--text)}+.row-name .title{color:#8fd3d4}+.row-desc{margin:0;font-family:var(--brand-body);font-size:.8125rem;line-height:1.55;color:var(--text-2);max-width:60ch}+.default-hint{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+.row-why{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);font-style:italic}+/* dependent settings: indented under the parent with a spine, dimmed (never hidden) when the parent is off */+.setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}+.setting-children[data-enabled='false']{opacity:.55}+.setting-children[data-enabled='false'] .row-name{color:var(--text-2)}+.setting-children[data-enabled='false'] .row-name .title{color:var(--text-2)}+/* screenshots of the thing the setting changes: real captures only */+.setting-shot{margin:0 0 .5rem}+.setting-shot img{display:block;max-width:min(100%,520px);height:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface)}+.setting-shot figcaption{margin-top:.35rem;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+.shot-pending{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);font-style:italic}+/* the house toggle (compact modal size 32x18), role=switch, white thumb in both states */+.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease;border-radius:10px}+.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}+.toggle:hover .toggle-track{border-color:var(--accent)}+.toggle:active{transform:translateY(1px) scale(.97)}+.toggle.is-on .toggle-track{background:var(--accent);border-color:var(--accent)}+.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}+.toggle.is-on .toggle-thumb{transform:translateX(14px)}+.toggle:active .toggle-thumb{width:16px}+.toggle.is-on:active .toggle-thumb{transform:translateX(10px)}+.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.toggle[disabled]{cursor:not-allowed}+/* enum controls */+.set-row select{background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-family:var(--brand-body);font-size:.875rem}+.set-row select:hover{border-color:var(--accent)}+.set-row select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}+/* alternatives: one choice card per option, each with its own REAL screenshot, teal ring = selected */+.choice-set{border:none;margin:.25rem 0 .5rem;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}+.choice{position:relative;flex:1 1 200px;max-width:270px;display:flex;flex-direction:column;gap:.35rem;padding:.6rem;cursor:pointer;background:var(--surface);border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease,background .15s ease,transform .06s ease}+.choice input{position:absolute;opacity:0;pointer-events:none}+.choice img{display:block;width:auto;max-width:100%;height:auto;margin:0 auto;border-radius:4px;background:#0d1117}+.choice-name{font-family:var(--brand-heading);font-size:.875rem;font-weight:600;color:var(--text)}+.choice-desc{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);line-height:1.5}+.choice:hover{border-color:var(--accent);background:var(--elevated)}+.choice:active{transform:translateY(1px) scale(.995)}+.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}+.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}+.choice-set[data-enabled='false']{opacity:.55;pointer-events:none}+/* sliders: custom track so the thumb reaches both ends */+.set-row .slider-line{display:flex;align-items:center;gap:10px}+.set-row input[type=range]{flex:1 1 auto;-webkit-appearance:none;appearance:none;height:18px;background:transparent;margin:0}+.set-row input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:var(--overlay);border:1px solid var(--border)}+.set-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);margin-top:-6px;box-shadow:0 0 0 1px #0d1117}+.set-row input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}+.sval{font-family:ui-monospace,monospace;font-size:.8125rem;color:var(--text-body);min-width:3.5em;text-align:right}+/* footer: note + two-click Restore defaults (auto-disarms after 4s) */+.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}+.footer-note{margin:0;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;cursor:pointer;transition:background .12s ease,transform .06s ease}+.restore-btn:hover{background:var(--elevated)}+.restore-btn:active{transform:translateY(1px)}+.restore-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.restore-btn.armed{color:var(--red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.08)}+#dlgBody{padding:0}+.ibadge{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;border:1px solid #8b949e;color:#8b949e;font-size:10px;font-style:italic;cursor:help;vertical-align:2px}+.ibadge:hover{border-color:#00b8b1;color:#00b8b1}+.sexample{display:block;max-width:100%;margin-top:8px;border:1px solid #30363d;border-radius:8px}+.texwrap{margin-top:10px}+.texstrip{display:flex;flex-wrap:wrap;gap:12px;padding:12px 10px;background:#0d1117;border:1px solid #30363d;border-radius:8px}+.tex{display:flex;flex-direction:column;align-items:center;gap:5px;width:64px}+.tex canvas{width:44px;height:44px;border-radius:7px;display:block}+.texn{font:400 9.5px ui-monospace,monospace;color:#8b949e;text-align:center;line-height:1.2;word-break:break-word}+.texcap{margin-top:6px;font-size:11px;color:#8b949e}+.texwide{width:auto}+.texbase{width:44px;height:44px;border-radius:7px;display:block}+.texmatrix{display:grid;grid-template-columns:repeat(3,minmax(70px,1fr));gap:10px;margin-bottom:8px}+.mcell{position:relative;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 4px;border:1px solid #30363d;border-radius:8px;cursor:pointer}+/* v2.0.266 (John: "why is there some random 'default' pill in the upper right corner?"): this was+ position:absolute inside .stlbl, which is NOT positioned β so it escaped all the way out to #dlg+ and parked itself on top of the dialog's X button. An absolutely-positioned badge that depends on+ an ancestor nobody made relative is a trap; make it a plain INLINE chip in the label instead, where+ it reads as "1. tiles + overlay (current) default" and has no positioning to get wrong. */+.mdef{display:inline-block;margin-left:6px;vertical-align:1px;font:600 8.5px 'Familjen Grotesk',sans-serif;color:#00b8b1;border:1px solid #00b8b1;border-radius:4px;padding:0 4px;letter-spacing:.04em;white-space:nowrap}+.mcell:hover{border-color:#8b949e}+.mcell.mcur{border-color:#00b8b1;box-shadow:0 0 0 1px #00b8b1 inset}+.mic{position:relative;display:inline-block}+.mic canvas.mbase,.mic canvas[data-mtile],.mic canvas[data-tname],.mic img.mbase{width:44px;height:44px;border-radius:7px;display:block}+.mic .mbadge{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:5px}+.texnames{display:flex;flex-wrap:wrap;gap:12px;padding-top:10px;border-top:1px solid #30363d}+.texdim{opacity:.45}+.st6{display:flex;flex-direction:column;gap:8px}+/* v2.0.266: flex-wrap + position:relative. Measured live: this row's content wanted 516px inside a+ 439px strip, so the sample icons spilled past .texstrip's dark background and drew a hard vertical+ seam down the panel ("why is the background behind the taskbar identity choices so whacked"). */+.strow6{position:relative;display:flex;align-items:center;flex-wrap:wrap;gap:12px;padding:7px 10px;border:1px solid #30363d;border-radius:8px;cursor:pointer}+.strow6:hover{border-color:#8b949e}+.strow6.mcur{border-color:#00b8b1;box-shadow:0 0 0 1px #00b8b1 inset}+.stlbl{flex:0 0 150px;font:600 12px 'Familjen Grotesk',sans-serif;color:#c9d1d9;line-height:1.35}+.sticons{flex:1 1 220px;min-width:0;display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:7px 7px 0 0}+.mbcard{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:5px;background:#202124;display:flex;align-items:center;justify-content:center;overflow:hidden}+.mbcard img{width:16px;height:16px;object-fit:contain}+.srow{display:flex;align-items:flex-start;gap:12px;padding:11px 0;border-bottom:1px solid #21262d}+.srow:last-child{border-bottom:none}+.srow .stxt{flex:1 1 auto;min-width:0}+.srow .slbl{color:#e6edf3;font-weight:600;font-size:13px}+.srow .sdesc{color:#8b949e;font-size:12px;line-height:1.5;margin-top:2px}+/* .tgl retired in v2.0.296: the settings dialog uses the house .toggle (settings-layout skill) */+.sub{margin-left:26px;padding:8px 0 2px;display:flex;align-items:center;gap:10px}+.sub.sdis{opacity:.4;pointer-events:none}+.sub input[type=range]{flex:1 1 auto;accent-color:#00b8b1;-webkit-appearance:none;appearance:none;height:18px;background:transparent;margin:0}.sub input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:#2a3038}.sub input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:#00b8b1;margin-top:-5px;box-shadow:0 0 0 1px #0d1117}+.sub .sval{color:#c9d1d9;font-family:"JetBrains Mono",monospace;font-size:12px;min-width:44px;text-align:right}+.sub select{background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:4px 8px}+#taskbar{position:sticky;top:44px;z-index:9;display:flex;gap:6px;align-items:center;padding:8px 18px;background:#0a0f14;border-bottom:1px solid #1d2833;overflow-x:auto}+#taskbar .lbl{color:#5c6c7a;font-size:11px;margin-right:6px;flex:0 0 auto}+.tbi{position:relative;width:48px;height:42px;border-radius:8px;background:#0f1720;border:1px solid #1d2833;display:flex;align-items:center;justify-content:center;cursor:pointer;flex:0 0 auto}+.tbi.ghost{color:#5c6c7c;font-size:18px;font-weight:700;border-style:dashed;border-color:#7c3a3a}+.tbi:hover{border-color:#00b8b1;background:#12202a}+.tbi.fg{border-color:#00b8b1}+.tbi img.base{width:30px;height:30px;object-fit:contain}+.tbi img.ov{position:absolute;top:1px;right:1px;width:17px;height:17px;object-fit:contain;border-radius:4px}+.tbi .n{position:absolute;bottom:-6px;left:-6px;background:#00b8b1;color:#05221f;font-size:10px;font-weight:700;border-radius:8px;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px}+.tbi .u{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);width:16px;height:3px;border-radius:2px;background:#00b8b1}++/* tree */+main{padding:14px 18px}+.node{margin:0}+.tw{margin-left:0}+.row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px}+.row:hover{background:#0f1720}+.tog{width:16px;flex:0 0 16px;color:#7d8f9f;cursor:pointer;user-select:none;text-align:center}+.tog.leaf{visibility:hidden}+.ico{width:22px;height:22px;flex:0 0 22px;object-fit:contain;border-radius:5px;background:#0f1720}+.ico.sm{width:16px;height:16px;flex:0 0 16px}+.fttray{display:inline-flex;gap:3px;align-items:center;flex-wrap:wrap}+.ft{font-size:9px;line-height:1;padding:2px 5px;border-radius:4px;background:#161f29;color:#9fb2c2;border:1px solid #24303c;white-space:nowrap}+.ft.dim{opacity:.55}+.ft.k-ok{background:#0f2a1c;color:#5fe0a0;border-color:#1c4a33}+.ft.k-gen{background:#2a230f;color:#e0c15f;border-color:#4a3d1c}+.ft.k-red{background:#2a0f14;color:#ff8a95;border-color:#4a1c24}+.ft.k-dark{background:#12202e;color:#7fc0ff;border-color:#1c3a52}+.ft.k-light{background:#26240f;color:#e8e07a;border-color:#48431c}+.ft.k-warn{background:#2a1a0f;color:#ffab6a;border-color:#4a2f1c}+.led{display:inline-block;width:9px;height:9px;border-radius:50%;flex:0 0 9px;vertical-align:middle;box-shadow:0 0 0 1px rgba(0,0,0,.4) inset}+.led.green{background:#22e07a;box-shadow:0 0 6px 1px rgba(34,224,122,.65)}+.led.amber{background:#f0b429;box-shadow:0 0 6px 1px rgba(240,180,41,.5)}+.led.red{background:#ff5b6a;box-shadow:0 0 6px 1px rgba(255,91,106,.6)}+.tbi .led{position:absolute;bottom:-5px;right:-5px;box-shadow:0 0 0 2px #0d1117}+.icostack{position:relative;flex:0 0 22px;width:22px;height:22px}+.icostack .ico{position:absolute;inset:0}+.icostack .icoov{position:absolute;top:-3px;right:-3px;width:13px;height:13px;object-fit:contain;border-radius:3px}+.nm{font-weight:600}+.nm.dim{font-weight:400;color:#c3d0da}+.meta{color:#7d8f9f;font-size:12px}+.tag{font-size:11px;border-radius:5px;padding:1px 7px;border:1px solid #23303c;color:#9fb0c0}+.tag.fg{background:#00b8b11a;border-color:#00b8b1;color:#00b8b1}+.tag.ok{background:#3fb9501a;border-color:#3fb950;color:#3fb950}+.tag.hf{background:#d299221a;border-color:#d29922;color:#e3b341}+.tag.warn{background:#f851491a;border-color:#f85149;color:#ff7b72}+.tag.vp{background:#a371f71a;border-color:#a371f7;color:#c297ff}+.acts{margin-left:auto;display:flex;gap:4px;opacity:0;transition:opacity .1s}+.row:hover .acts{opacity:1}+.tbnode{border:1px solid #1d2833;border-radius:10px;margin-bottom:12px;background:#0c131a;overflow:hidden}+.tbnode>.row{background:#111b23;border-bottom:1px solid #1d2833;border-radius:0}+.win{margin:2px 0 2px 20px;border-left:2px solid #1a2530}+.win>.row .nm{font-size:13px}+.tabs{margin:0 0 4px 44px}+.why{margin:4px 0 8px 44px;padding:6px 9px;background:#0d1117;border:1px solid #1d2833;border-radius:6px;color:#8fa3b3;font-size:11px;line-height:1.6}+.why b{display:block;color:#00b8b1;margin-bottom:2px}+.empty{color:#7d8f9f;padding:40px;text-align:center}+.hidden{display:none}+footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px}+</style></head><body>+<header>+ <div class="brand" id="brand">+ <svg class="bicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/><path d="M2.75 8.75h18.5"/><circle cx="12" cy="14.2" r="3.1"/></svg><!-- v2.0.339: the header mark is now the SAME drawing as the favicon and the dock card (docs/img/dock-pup.svg): window frame + aperture ring. It used to be the retired two-windows-plus-heartbeat concept, so this page showed one pup mark in its header and a different one in its tab. stroke stays currentColor here and ONLY here: an inline mark should inherit the header colour, while the favicon is an isolated document where currentColor resolves to black, which is why /dashboard/icon.svg hardcodes #e6edf3. -->+ <span class="bname">Pup</span><span class="bsub">· live windows</span>+ </div>+ <div class="pop" id="pop">+ <div>version <b id="cV">?</b></div>+ <div id="popMeta">connecting</div>+ <div><a href="https://wiki.adom.inc/adom/pup-bridge" target="_blank" rel="noopener">wiki: pup-bridge</a></div>+ </div>+ <div class="vsep"></div>+ <div class="subject">+ <span class="sname"><span class="dot" id="live"></span><span id="subjName">all windows</span></span>+ <span class="smeta"><b id="cW">0</b> windows · <b id="cT">0</b> tabs · <b id="cB">0</b> icons · <span id="cAY" title="windows surfing on the shared adom-you profile / total work windows. More adom-you = a richer shared login store = pup auto-fills more sites for you over time.">?</span> adom-you · <span id="cLed" title="green: every taskbar button wears its real page-favicon overlay and no untracked pup windows exist. red: a window is bare or unmodeled."><span class="led red" id="cLedDot"></span> <b id="cLedTxt">?</b></span> · <span id="age"></span></span>+ </div>+ <div class="acts2">+ <button class="ibtn" id="recall" aria-pressed="true" title="Hide the prompts panel" aria-label="Hide the prompts panel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.2 3.2c-.5.2-.8.7-.8 1.3v.3"/><path d="M12 17.2h.01"/></svg></button>+ <button class="ibtn" id="gear" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>+ <button class="ibtn" data-act="regen-overlays" data-tip="Regenerate ALL overlay icons: re-scan every open window and tab and re-run the from-scratch overlay algorithm (re-resolve each window handle, re-pick the favicon, recompose + repaint the taskbar badge) and refresh these dashboard icons to match. A clean sweep to verify correctness and surface any drift." title="regenerate all overlay icons (clean sweep)" aria-label="regenerate overlays"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>+ <button class="ibtn" data-act="flash-all" title="flash every pup taskbar button (the Windows taskbar flash, accent-colored on your theme)" aria-label="flash all"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>+ <button class="ibtn" data-act="unflash-all" title="clear every taskbar flash" aria-label="clear taskbar flashes"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>+ <button class="ibtn danger" data-act="close-all" data-confirm="Close ALL pup windows?" data-tip="Close ALL pup windows on this desktop (every window in the list below, not this dashboard page). Asks before doing it." aria-label="close all pup windows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="13" height="11" rx="1.6"/><path d="M4 15V6a1.5 1.5 0 0 1 1.5-1.5H16"/><path d="M12.2 11.2l4.6 4.6M16.8 11.2l-4.6 4.6"/></svg></button>+ </div>+</header>+<div id="newbie"></div>+<div class="ledrow" id="leds"></div>+<div id="bar">+ <span class="pill" id="cM" style="display:none">minimal-touch</span>+ <select id="f"><option value="">all AI threads</option></select>+ <input id="q" placeholder="filter url / window / ai-thread..." size="20">+</div>+<div id="toasts"></div>+<div id="dlgWrap" class="hidden">+ <div id="dlgBg"></div>+ <div id="dlg" role="dialog" aria-modal="true" aria-label="pup Settings" tabindex="-1">+ <div class="dlghead">pup Settings<button class="ibtn" id="dlgClose" title="Close settings" aria-label="Close settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>+ <div class="settings-content">+ <nav class="settings-side" id="dlgSide" aria-label="Settings sections"></nav>+ <div class="settings-body" id="dlgScroller"><div id="dlgBody"></div></div>+ </div>+ </div>+</div>+<div id="taskbar"></div>+<main id="m"><div class="empty">connecting...</div></main>+<div id="loops" class="loops"></div>+<footer id="ft"></footer>+<script src="/dashboard/newbie-panel.js"></script>+<script>+const _q=s=>document.querySelector(s); let SNAP=null; const COLL=new Set();+// v1.9.258 (John hovered a tooltip that his stale page could not render): the page's code is baked+// at load, but the bridge updates underneath it on every ship. When the SSE reports a different+// bridge version than this page was served by, reload once - the dashboard always follows the bridge.+const PAGE_VERSION='${BRIDGE_VERSION}';+const ago=ms=>{if(ms==null)return'-';const s=Math.round(ms/1000);if(s<60)return s+'s';const m=Math.round(s/60);if(m<60)return m+'m';const h=Math.floor(m/60);return h+'h '+(m%60)+'m';};+const esc=t=>String(t==null?'':t).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));+// v1.9.311 (John: "why do i keep seeing these refresh blip non-stop?"): the badge URL used to+// cache-bust with SNAP.ts, which changes every SSE tick, so the no-store image refetched a couple+// times a SECOND β a constant flicker. Version it by the badge's actual PAINT TIME instead: the URL+// only changes when the overlay is genuinely repainted, so between repaints the browser serves the+// image from cache and there is zero blip even though render() rebuilds the DOM each tick.+const badge=sid=>{var v=0;try{var w=(SNAP&&SNAP.windows||[]).find(function(x){return x.sessionId===sid;});v=(w&&w.overlayWhy&&w.overlayWhy.at)||0;}catch(e){}return '/dashboard/badge?session='+encodeURIComponent(sid)+'&v='+v;};+const fav=(sid,tid)=>'/dashboard/favicon?session='+encodeURIComponent(sid)+'&tab='+encodeURIComponent(tid);+// v1.9.238: per-tab favicon acquisition state as compact chips + a full tooltip.+function favTags(f){+ if(!f) return '<span class="ft dim" title="favicon not resolved yet">fav ?</span>';+ const K={'page-favicon':['favicon','k-ok'],'sibling-tab-favicon':['sibling','k-ok'],'favicon.ico':['favicon.ico','k-ok'],'app-provided':['app icon','k-ok'],'generic-web':['generic web','k-gen'],'generic-pdf':['generic pdf','k-gen'],'none':['none','k-red']};+ const kd=K[f.kind]||[f.kind||'?','k-gen'];+ const bits=[];+ bits.push('<span class="ft '+kd[1]+'">'+esc(kd[0])+'</span>');+ if(f.format) bits.push('<span class="ft">'+esc(String(f.format).toUpperCase())+'</span>');+ if(f.mode&&f.mode!=='default') bits.push('<span class="ft '+(f.mode==='dark'?'k-dark':f.mode==='light'?'k-light':'')+'">'+esc(f.mode)+'-mode</span>');+ if(f.transparent===true) bits.push('<span class="ft'+(f.darkDominant?' k-warn':'')+'">transparent'+(f.darkDominant?' Β· dark':'')+'</span>');+ else if(f.transparent===false) bits.push('<span class="ft">opaque</span>');+ const tip=['kind: '+(f.kind||'?'),'format: '+(f.format||'?'),'color-scheme: '+(f.mode||'?'),'transparent: '+(f.transparent===null?'n/a':f.transparent),f.luminance!=null?'luminance: '+f.luminance+(f.darkDominant?' (dark)':''):'',f.via?'fetched via: '+f.via:'',f.candidates!=null?'candidates seen: '+f.candidates:'',f.href&&!/^generic|^app-provided/.test(f.href)?'src: '+f.href:'',f.note?'warning: '+f.note:''].filter(Boolean).join(String.fromCharCode(10));+ return '<span class="fttray" title="'+esc(tip)+'">'+bits.join('')+'</span>';+}++function render(){+ if(!SNAP)return; const d=SNAP;+ if(d.bridge&&d.bridge.version&&d.bridge.version!==PAGE_VERSION){location.reload();return;}+ _q('#cW').textContent=d.counts.windows; _q('#cT').textContent=d.counts.tabs;+ _q('#cB').textContent=(d.taskbarButtons||[]).length; _q('#cV').textContent=d.bridge.version;+ (function(){const ws=(d.windows||[]).filter(w=>w.sessionId!=='pup-dashboard');const ay=ws.filter(w=>w.profile==='adom-you').length;_q('#cAY').textContent=ay+'/'+ws.length;})();+ _q('#popMeta').textContent=d.bridge.platform+' \u00b7 bridge up '+ago(d.bridge.uptimeMs)+' \u00b7 deps '+(d.bridge.depsOk?'ok':'missing');+ _q('#subjName').textContent=(_q('#f').value||'all windows');+ for(const t of (d.toasts||[])){ if(t.id>SEENTOAST){SEENTOAST=t.id;toast(t.msg,t.type);} }+ _q('#cM').style.display=d.bridge.minimalTouch?'':'none';+ // header overlay-match rollup: red if ANY button bare, amber if any generic, else green+ const bl=(d.taskbarButtons||[]).map(b=>b.led||'red');+ let roll=bl.includes('red')?'red':bl.includes('amber')?'amber':(bl.length?'green':'red');+ if((d.untrackedWindows||[]).length) roll='red'; // an unmodeled real button = the mimic CANNOT claim MATCH+ _q('#cLedDot').className='led '+roll;+ _q('#cLedTxt').textContent=(d.untrackedWindows||[]).length?((d.untrackedWindows||[]).length+' UNTRACKED'):roll==='green'?'MATCH':roll==='amber'?'generic':'MISMATCH';+ _q('#age').textContent='updated '+new Date(d.ts).toLocaleTimeString()+(d.regen?(d.regen.running?' Β· regenerating '+d.regen.inFlight+' windowsβ¦':(d.regen.changed!=null?' Β· last sweep: '+d.regen.changed+'/'+d.regen.swept+' corrected':'')):'');+ const sel=_q('#f'),cur=sel.value;+ if(sel.options.length-1!==d.threads.length){sel.innerHTML='<option value="">all AI threads</option>'+d.threads.map(t=>'<option>'+esc(t)+'</option>').join('');sel.value=cur;}+ const q=_q('#q').value.toLowerCase(),th=sel.value;+ const byId=Object.fromEntries(d.windows.map(w=>[w.sessionId,w]));+ const match=w=>{if(th&&(w.owner||w.ownerThread)!==th)return false;if(q){const hay=(w.sessionId+' '+(w.url||'')+' '+(w.owner||'')+' '+w.tabs.map(t=>t.url||'').join(' ')).toLowerCase();if(!hay.includes(q))return false;}return true;};+ const groups=(d.taskbarButtons||[]).map(b=>({...b,wins:b.windows.map(s=>byId[s]).filter(w=>w&&match(w))})).filter(g=>g.wins.length);++ // taskbar strip (real order, real badge)+ // v1.9.235 (John's audit): each strip button mimics the REAL taskbar button - the browser/category+ // BASE icon full-size, with the painted overlay badge in the TOP-RIGHT corner at ~55% (measured+ // from his actual taskbar), and NO overlay at all when pup has not painted one (a bare Chrome+ // button must render bare here too, otherwise the mimic lies).+ const ghosts=(d.untrackedWindows||[]).map(h=>'<div class="tbi ghost" title="a window of the pup-managed Chrome is on the real taskbar but pup has NO window record for it (hwnd '+h+'). The mimic cannot know its icon - this is why the header LED is red.">?<span class="led red"></span></div>').join('');+ _q('#taskbar').innerHTML='<span class="lbl" title="mimics ONLY the taskbar buttons of pup-managed Chrome windows. Other Adom apps (Adom Hydrogen, Adom Bridge) have their own teal-tile buttons that are out of pup scope.">TASKBAR (pup)</span>'+groups.map((g,i)=>{+ const fg=g.wins.some(w=>w.foreground);+ return '<div class="tbi'+(fg?' fg':'')+'" data-jump="g'+i+'" title="'+esc(g.label)+': '+esc(g.ledWhy||'')+'">'+ +'<img class="base" src="'+esc(g.baseIcon||'')+'" data-fallback="hide">'+ +(g.badgeSession?'<img class="ov" src="'+badge(g.badgeSession)+'" data-fallback="hide">':'')+ +'<span class="led '+esc(g.led||'red')+'" title="'+esc(g.ledWhy||'')+'"></span>'+ +(g.wins.length>1?'<span class="n">'+g.wins.length+'</span>':'')+ +(fg?'<span class="u"></span>':'')+'</div>';+ }).join('')+ghosts;++ // tree: taskbar icon -> windows -> tabs+ _q('#m').innerHTML=groups.length?groups.map((g,gi)=>{+ const gid='g'+gi, gcoll=COLL.has(gid);+ return '<div class="tbnode" id="'+gid+'">'+ +'<div class="row"><span class="tog" data-toggle="'+gid+'">'+(gcoll?'βΈ':'βΎ')+'</span>'+ +'<span class="icostack"><img class="ico" src="'+esc(g.baseIcon||'')+'" data-fallback="hide">'+ +(g.badgeSession?'<img class="icoov" src="'+badge(g.badgeSession)+'" data-fallback="hide">':'')+'</span>'+ +'<span class="led '+esc(g.led||'red')+'" title="overlay match: '+esc(g.ledWhy||'')+'"></span>'+ +'<span class="nm">'+esc(g.label)+'</span>'+ +'<span class="meta">'+g.wins.length+' window'+(g.wins.length>1?'s':'')+' · '+esc(g.grouping)+' · badge from '+esc(g.badgeVia||'?')+'</span>'+ +'<span class="acts"><button class="btn sm" data-act="flash-group" data-ids="'+esc(g.windows.join(','))+'">flash</button>'+ +'<button class="btn sm" data-act="unflash-group" data-ids="'+esc(g.windows.join(','))+'">clear</button></span></div>'+ +'<div class="tw'+(gcoll?' hidden':'')+'">'+g.wins.map(w=>{+ const wid=gid+'-'+esc(w.sessionId), wcoll=COLL.has(wid);+ const crash=w.tabs.some(t=>t.crashed)||w.lostBrowser;+ return '<div class="win">'+ +'<div class="row"><span class="tog" data-toggle="'+wid+'">'+(wcoll?'βΈ':'βΎ')+'</span>'+ +'<span class="led '+esc(w.overlayLed||'red')+'" title="'+(w.overlayLed==='green'?'real page favicon painted β matches taskbar':w.overlayLed==='amber'?'painted, generic fallback (page has no favicon)':'NOT painted β taskbar would be bare/stale')+'"></span>'+ +'<img class="ico" src="'+badge(w.sessionId)+'" data-fallback="hide">'+ +'<span class="nm">'+esc(w.sessionId)+'</span>'+ +'<span class="tag '+(w.foreground?'fg':'')+'">'+(w.foreground?'FG':'bg')+'</span>'+ +(w.sessionId==='pup-dashboard'?'<span class="tag">dashboard</span>':(w.profile==='adom-you'?'<span class="tag ok" title="surfing on the shared adom-you profile: every login this window makes enriches the shared credential store, so pup can auto-fill it everywhere later">surfing as adom-you</span>':'<span class="tag warn" title="NOT on adom-you: logins made here do NOT build the shared credential store. Prefer adom-you unless isolation is genuinely needed.">NOT adom-you: '+esc(w.profile)+'</span>'))+ +(w.owner?'<span class="tag">'+esc(w.owner)+(w.ownerSource==='caller-identity'?' β':'')+'</span>':'<span class="tag warn">UNOWNED</span>')+ +(w.wiki?'<span class="tag '+(w.wiki.authed?'ok':'warn')+'" title="'+(w.wiki.authed?'This Adom wiki window is LOGGED IN β you see private source, drafts, owner-only cards, reply/edit':'This Adom wiki window is the PUBLIC / logged-OUT view β exactly what the world sees')+'">π wiki: '+(w.wiki.authed?'signed in':'public')+'</span>':'')+ +(w.highFps?'<span class="tag hf">highFps '+w.highFpsMinutesLeft+'m</span>':'')+ +(w.emulatedViewport?'<span class="tag vp">vp '+w.emulatedViewport.width+'x'+w.emulatedViewport.height+'</span>':'')+ +(crash?'<span class="tag warn">'+(w.lostBrowser?'browser lost':'crashed tab')+'</span>':'')+ +(w.errorCount?'<span class="tag warn" title="'+esc((w.recentErrors||[]).map(e=>'['+e.type+'] '+e.text).join(String.fromCharCode(10))||'no detail captured')+'">'+w.errorCount+' err</span>':'')+ +'<span class="meta">'+esc(w.profile)+' · '+ago(w.uptimeMs)+'</span>'+ +'<span class="acts">'+ +'<button class="btn sm" data-act="fg" data-sid="'+esc(w.sessionId)+'">bring to fg</button>'+ +'<button class="btn sm" data-act="bg" data-sid="'+esc(w.sessionId)+'">send to bg</button>'+ +'<button class="btn sm" data-act="flash" data-sid="'+esc(w.sessionId)+'">flash</button>'+ +'<button class="btn sm" data-act="unflash" data-sid="'+esc(w.sessionId)+'">clear</button>'+ +'<button class="btn sm danger" data-act="close-window" data-sid="'+esc(w.sessionId)+'" data-confirm="Close '+esc(w.sessionId)+'?">close</button></span></div>'+ +'<div class="'+(wcoll?'hidden':'')+'">'+ +(w.foreground&&w.foregroundReason?'<div class="why"><b>why foreground</b>'+esc(w.foregroundReason)+'</div>':'')+ +(w.overlayWhy?'<div class="why"><b>how this icon was chosen</b><span class="led '+esc(w.overlayLed||'red')+'"></span> '+(w.overlayLed==='green'?'real page favicon':w.overlayLed==='amber'?'generic fallback (no favicon on the page)':'not painted')+'<br>tab '+esc(w.overlayWhy.tabId||'?')+', '+esc(w.overlayWhy.tabPickedBy)+'<br>source: '+esc(w.overlayWhy.source||'none')+'<br>via: '+esc(w.overlayWhy.fetchedVia)+'<br>painted by: '+esc(w.overlayWhy.paintedBy)+'</div>':'')+ +'<div class="tabs">'+w.tabs.map(t=>+ '<div class="row"><span class="tog leaf">β’</span>'+ +'<img class="ico sm" src="'+fav(w.sessionId,t.tabId)+'" data-fallback="hide">'+ +'<span class="nm dim'+(t.active?'"':'"')+'">'+esc(t.title||t.url||'-')+'</span>'+ +(t.active?'<span class="tag fg">active</span>':'')+ +(t.crashed?'<span class="tag warn">crashed</span>':'')+ +favTags(t.fav)+ +'<span class="acts"><button class="btn xs" data-act="fg" data-sid="'+esc(w.sessionId)+'" data-tid="'+esc(t.tabId)+'">fg</button>'+ +'<button class="btn xs" data-act="bg" data-sid="'+esc(w.sessionId)+'">bg</button>'+ +'<button class="btn xs danger" data-act="close-tab" data-sid="'+esc(w.sessionId)+'" data-tid="'+esc(t.tabId)+'">close</button></span></div>'+ ).join('')+'</div></div></div>';+ }).join('')+'</div></div>';+ }).join(''):'<div class="empty">no pup windows open'+(th||q?' matching this filter':'')+'</div>';+ _q('#ft').textContent='bridge up '+ago(d.bridge.uptimeMs)+' \u00b7 '+d.bridge.platform+' \u00b7 deps '+(d.bridge.depsOk?'ok':'MISSING')+' \u00b7 win32 '+(d.bridge.win32InProcess?'in-process':'powershell fallback');+ // v2.0.315 LOOP RADAR (John): the periodic loops and in-process Win32 calls no longer show in ab's+ // activity log, so they are shown HERE, per minute, with the declared cadence next to each so a+ // spamming loop stands out (ticks/min far above 60000/cadence).+ try{+ const lp=d.bridge.loops||{}; const w=d.bridge.win32Calls||{};+ const rows=Object.entries(lp).map(([n,v])=>{const exp=Math.max(1,Math.ceil(60000/v.cadenceMs));const hot=v.lastMin>exp*1.5;return '<span class="lp'+(hot?' hot':'')+'" title="declared cadence '+v.cadenceMs+'ms = '+exp+'/min; avg '+v.avgMs+'ms, max '+v.maxMs+'ms, '+v.ticks+' ticks total, '+v.slowTicks+' slow">'+n+' <b>'+v.lastMin+'</b>/'+exp+'</span>';}).join('');+ const wc=Object.entries(w).map(([n,v])=>n+'='+v).join(' ');+ _q('#loops').innerHTML='<div class="lpt">loops, ticks in the last minute (actual/declared)</div>'+rows+'<div class="lpt">in-process win32 calls since boot: '+(wc||'none')+' \u00b7 ab enum_windows last minute: '+(d.bridge.abEnumLastMin||0)+'</div>';+ }catch(e){}+}+function toast(msg,type){+ const t=document.createElement('div');t.className='toast '+(type||'info');t.textContent=msg;+ _q('#toasts').appendChild(t);+ setTimeout(()=>{t.classList.add('out');setTimeout(()=>t.remove(),350)},type==='error'?8000:4000);+}+const ACTMSG={'flash':'flashed the taskbar button of','unflash':'taskbar flash cleared on','flash-all':'flashed every pup taskbar button','unflash-all':'cleared every taskbar flash','close-all':'closed all pup windows','close-window':'closed','close-tab':'closed tab in','fg':'bringing to front:','bg':'sent to background:','set-setting':'setting saved','test-caption':'sample caption shown on your screen','restore-defaults':'every setting restored to its default','regen-overlays':'regenerated all overlay icons','import-scan':'scanned your browsers for logins','import-run':'import'};+async function act(p){+ try{+ // settings-layout: every settings round trip is BOUNDED (5s), so a stalled bridge can never+ // leave a control visually stuck; the click already reflected in the DOM optimistically.+ const ac=new AbortController(); const tm=setTimeout(()=>ac.abort(),5000);+ const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p),signal:ac.signal}).finally(()=>clearTimeout(tm));+ const j=await r.json().catch(()=>({}));+ const what=(ACTMSG[p.action]||p.action)+(p.sessionId?' '+p.sessionId:'');+ if(p.action==='regen-overlays'){ if(j.ok&&j.started) toast('Regenerating overlays on '+j.swept+' windows: watch the icons update live','info'); else if(j.ok&&j.alreadyRunning) toast('A regenerate sweep is already running','info'); else toast('regenerate failed'+(j.error?': '+j.error:''),'error'); return j; }+ if(j.ok) toast(what+(p.action==='unflash-all'&&j.swept!=null?' ('+j.swept+' windows swept)':'')+(p.action==='flash-all'&&j.delivered!=null?' ('+j.delivered+' of '+j.n+' delivered)':''), p.action.indexOf('close')===0?'success':'info');+ else toast(what+' failed'+(j.error?': '+j.error:''),'error');+ return j;+ }catch(e){toast(p.action+' failed: '+e.message,'error');}+}+let SEENTOAST=0;+// proper tooltip (adom-ui-design tooltips skill): body-appended fixed div, 600ms reveal,+// clamped to the viewport on BOTH axes, never a title attr for rich content.+(function(){+ let tt=null,show=null;+ function hide(){clearTimeout(show);if(tt){tt.remove();tt=null;}}+ document.addEventListener('mouseover',ev=>{+ // v2.0.97 (John: double tooltips β a raw title attr AND this tooltip): any title attr is+ // absorbed into data-tip the first time the cursor arrives, well inside the ~1s native+ // delay, so the browser tooltip never fires and every hint renders through this one system.+ const t=ev.target.closest('[title]');+ if(t){const v=t.getAttribute('title');if(v&&!t.dataset.tip)t.dataset.tip=v;t.removeAttribute('title');}+ const el=ev.target.closest('[data-tip]');+ if(!el){hide();return;}+ clearTimeout(show);+ show=setTimeout(()=>{+ hide();+ tt=document.createElement('div');+ tt.style.cssText='position:fixed;z-index:100000;max-width:380px;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:10px 12px;font-size:12px;line-height:1.6;color:#c9d1d9;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:none';+ tt.textContent=el.dataset.tip;+ document.body.appendChild(tt);+ const r=el.getBoundingClientRect();+ const M=8,w=tt.offsetWidth,h=tt.offsetHeight;+ let x=r.left+14,y=r.bottom+10;+ if(y+h>innerHeight-M)y=Math.max(M,r.top-h-10);+ x=Math.max(M,Math.min(x,innerWidth-w-M));+ y=Math.max(M,Math.min(y,innerHeight-h-M));+ tt.style.left=x+'px';tt.style.top=y+'px';+ },600);+ });+ document.addEventListener('scroll',hide,true);+})();+// v1.9.259: the settings dialog. Rows defined once; state comes from SNAP.bridge.settings; every+// change POSTs set-setting and answers back with a toast.+const SETTINGS_DEF=[+ {k:'taskbarMode',mainsel:1,threadex:1,l:'Taskbar identity',info:'Three ALTERNATE modes for how pup brands your Windows taskbar, from most to least branded. TILES: every AI thread gets ONE button whose icon is the thread NAME drawn on the Adom teal tile - all of that thread\u2019s windows group under it, so the taskbar reads as a list of your threads. AUMID: every pup window gets its own button with a pup/category icon (per-window identity, no thread grouping) - the AUMID grouping and icon-style options below apply to this mode only. PLAIN: pup never touches identity; windows stack under the stock Chrome-for-Testing button like any unbranded app. The favicon overlay badge is separate - the Overlay badges toggle below works in tiles and aumid modes.',d:'Click a row in the matrix below to switch modes; the current one is ringed.',sel:['tiles','aumid','plain']},+ // v2.0.269 (John): the badge rows live HERE, directly under Taskbar identity, because they are+ // part of the same decision β the identity picker's own examples are 'tiles + overlay' vs 'tiles',+ // so having the overlay toggle sit fifteen rows further down was a genuine discoverability bug.+ {k:'overlayBadges',l:'Favicon overlay badges',d:'Paint each pup taskbar icon with the favicon of its active tab in the top-right corner.',pair:[{img:'obon',cap:'On: the wiki favicon badged on the tile'},{img:'oboff',cap:'Off: the bare thread tile'}]},+ {k:'overlaySize',l:'Badge size',d:'How big the badge is drawn, as a percent of full size. Each card is the real tile at that size.',sel:['50','66','75','100'],selLabels:{'50':'50%, smallest, maximum text clearance','66':'66%','75':'75%, balanced','100':'100%, full size'},dep:'overlayBadges'},+ {k:'taskbarGrouping',l:'AUMID grouping',d:'Only applies when AUMID icons are on. split = one taskbar button per window. grouped = windows grouped BY CATEGORY when icon-style is "category" (up to 5 buttons: signed-in wiki, public wiki, open web, Adom app, plain: for example all your signed-in-wiki windows collapse under the one padlock button); with icon-style "standard" grouped collapses everything under a single Adom Pup button.',sel:['split','grouped'],depMode:'aumid',imgBy:{split:'groupsplit',grouped:'groupgrouped'}},+ {k:'aumidIconStyle',l:'AUMID icon style',d:'Only applies when AUMID icons are on: standard = one pup tile for every window; category = the custom set (wiki signed-out, wiki signed-in padlock, world icon for the open web, adom app icon, mixed).',sel:['standard','category'],depMode:'aumid',imgBy:{standard:'iconstylestd',category:'iconstylecat'}},+ {k:'jumpLists',l:'Jump lists',d:'The menu Windows shows when you right-click a pup taskbar icon (real screenshot below, captured on this machine). Requires AUMID icons on. Quirk: the FIRST right-click can blip the menu closed; right-click again about a second later and it holds (a Windows shell timing race, not a pup bug).',img:'jumplist'},+ {k:'jlViewTasks',l:'',d:'Wiki view tasks: Switch to logged-in view / Switch to public view on every wiki window.',dep:'jumpLists',subtgl:1,img:'jlviewtasks'},+ {k:'jlCloseAll',l:'',d:'The Close all pup windows task on every pup icon.',dep:'jumpLists',subtgl:1,img:'jlcloseall'},+ {k:'jlDashboard',l:'',d:'The Open pup dashboard task on every pup icon: one click opens the live window/tab/thread map.',dep:'jumpLists',subtgl:1,img:'jldashboard'},+ {k:'jlAnnotate',l:'',d:'The Annotate this page task: draw on the page; Done captures it for the AI thread that owns the window.',dep:'jumpLists',subtgl:1,img:'jlannotate'},+ {k:'annCaptionBar',l:'Caption bar on captures',d:'Bake a thin context strip under every annotation/snip capture (thread, page, time) so any AI you paste it into sees where it came from.',img:'anncapbar'},+ {k:'toolbarExtension',l:'Adom toolbar in Chrome',d:'The Adom Pup extension button in the browser toolbar (annotate, snip, view toggle, dashboard, owner, close). Requires the one-time extension install.',img:'toolbarext'},+ {k:'tbSnipHotkey',l:'',d:'Alt+Shift+A starts annotate (Auto: capture on mouse-up); Alt+Shift+M starts it in Multi (compose several shapes, capture once on Done).',dep:'toolbarExtension',subtgl:1},+ {k:'tbFloatyPencil',l:'',d:'The tiny pencil in the upper-right corner of each page: one click starts annotate. Off by default: the corner hover zone can fight buttons in app headers, and the toolbar pen button covers discovery.',dep:'toolbarExtension',subtgl:1},+ {k:'floatyHitPx',l:'',d:'',sliderLabel:'pencil corner hit area',unit:'px',slider:[6,120,2],dep:'tbFloatyPencil'},+ // v2.0.293 (John: "why did you make progress bar vs orange flash a child of Favicon overlay+ // badges? that hierarchy doesn't make sense"): taskbarActivity is its OWN top-level row here in+ // the activity/foreground section, NOT inside the taskbar-identity cluster where its position+ // between the badge/AUMID rows rendered it as a sub-setting of overlay badges.+ {k:'taskbarActivity',l:'Taskbar activity signal',d:'Pick what the button shows. Each card is a real capture of the same pup button on a dark taskbar.',sel:['progress','flash','off'],selLabels:{'progress':'progress bar (default)','flash':'orange flash (old)','off':'off'},imgBy:{progress:'tbactprogress',flash:'tbactflash',off:'tbactoff'}},+ {k:'fgCaption',l:'Caption on AI foreground',d:'When an AI brings a window to your screen, show its stated reason as an on-screen caption. The AI is always forced to give a reason; this shows it to you.',img:'fgcaption'},+ {k:'fgCaptionMs',l:'',d:'',sliderLabel:'minimum caption time (longer text auto-extends at reading speed, 12s max)',unit:'s',slider:[500,8000,250],dep:'fgCaption',test:1},+ {k:'sleepRecovery',l:'Sleep and crash recovery',d:'After the laptop wakes or Chrome dies, automatically bring every pup window back at its last page, signed in. Never restarts a live Chrome.'},+ {k:'autoLogin',l:'Auto-fill saved logins',d:'Fill a matching saved login when a login page loads in a pup window (background, no Chrome dropdown). pup never submits on its own: submitting a stored password is reason-gated and confirms the live domain first, to guard against lookalike/phishing sites.'},+ {k:'captureLogins',l:'Capture logins to the vault',d:'As you sign into sites in pup windows, store the username and password in the OS keychain (encrypted at rest, never logged) so return visits auto-fill. This is how the shared adom-you identity grows over time.'},+ {k:'reportIssues',l:'Issue-report hints',d:'Let pup invite AIs to file a wiki issue when they hit something broken.'},+ {k:'titleTag',l:'ai-thread tag in the window title',d:'Append "(ai-thread: <name>)" to each window title so YOU can tell windows apart from the title bar. Off by default because it can look like the page mangled its own title. Purely cosmetic now: pup identifies its own windows by a Win32 property tag it writes at birth, so it never needs the title.'},+ {k:'titlePrefix',l:'Thread name prefixed to the title',d:'Prepend the owning thread (and wiki logged-in/out state) to the start of each window title. Off by default.'},+ {k:'ownerChip',l:'Floating owner chip in each window',d:'Show a tiny non-interactive chip in the bottom-left of each pup window naming the AI thread that owns it and its profile. It cannot be clicked through to (pointer-events off) and lives in an isolated layer so it never affects the page. Turn off if you do not want any overlay in your windows.',img:'ownerchip'},+ {k:'notMineCaption',l:'"Not foregrounded by Adom" caption',d:'When a window comes to the front on its own (a pageβs JavaScript, a focus change, Chrome itself, for example a PDF viewer self-activating), briefly caption that Adom did NOT do it. Stays SILENT when YOU raised the window (detected via recent taskbar/keyboard input). This is the single control for self-foreground captions, including the 20s right after a window opens. Off by default.'},+ {k:'minimalTouch',l:'Minimal-touch mode',d:'The safety mode from the Chrome crash hunt. ON means pup keeps its hands off your windows: it only opens them and drives the pages inside, and skips the extra Windows machinery (identity stamps, window sweeps, shell churn) that was suspected of freezing Chrome. The toggles above are the real per-feature controls now and they win over this switch. Rule of thumb: leave this ON; if pup ever gets crashy, make sure it is ON and turn features back on one at a time to find the culprit.'},+];+// Import-logins picker (the adom-you seed). Discovery runs today via pup_import_scan; the+// actual decrypt is AD-core (adom/adom-bridge #51) so the Import button reports honest-pending.+let _importScan=null; let _importSel={};+function _idTag(a){return '<span style="display:inline-block;background:rgba(120,150,205,.22);border-radius:4px;padding:1px 6px;margin:0 4px 2px 0;font-size:11px;white-space:nowrap">'+esc(a)+'</span>';}+function _idBadges(accts){return (accts&&accts.length)?accts.map(_idTag).join(''):'<span style="opacity:.55;font-size:11px">no linked account</span>';}+function importSectionHtml(){+ const s=_importScan; let rows='';+ if(s&&s.profiles){+ rows=s.profiles.length?s.profiles.map(p=>{+ const key=p.browser+'/'+p.profileDir, on=_importSel[key]!==false;+ const cnt=(p.passwordCount==null?'?':p.passwordCount)+' logins';+ const lock=p.abe?'<span style="opacity:.6;font-size:11px" title="App-Bound Encrypted - needs admin approval to decrypt">π encrypted</span>':'';+ return '<div class="srow" style="padding:8px 0"><label class="stxt" style="cursor:pointer;display:flex;gap:10px;align-items:flex-start;width:100%">'+ +'<input type="checkbox" data-imchk="'+esc(key)+'"'+(on?' checked':'')+' style="margin-top:3px;flex:0 0 auto">'+ +'<span style="flex:1 1 auto;min-width:0"><div class="slbl">'+esc(p.browserName||p.browser)+' — '+esc(p.profileName||p.profileDir)+'</div>'+ +'<div class="sdesc">'+cnt+' '+_idBadges(p.accounts)+' '+lock+'</div></span></label></div>';+ }).join(''):'<div class="sdesc">No Chrome / Edge / Brave profiles found on this machine.</div>';+ }+ const has=s&&s.profiles&&s.profiles.length;+ const summary=s?'<div class="sdesc" style="margin:8px 0">Found <b>'+(s.profiles?s.profiles.length:0)+'</b> profile(s), ~<b>'+(s.totalPasswords||0)+'</b> saved logins across '+((s.accounts&&s.accounts.length)?s.accounts.map(esc).join(', '):'no linked accounts')+'.'+(s.pythonAvailable?'':' <span style="opacity:.6">(login counts unavailable - python not found)</span>')+'</div>':'';+ return '<div class="srow" style="flex-direction:column;align-items:stretch;border-top:2px solid rgba(255,255,255,.16);margin-top:16px;padding-top:14px">'+ +'<div class="slbl" style="font-size:15px">Import logins from Chrome / Edge <span class="ibadge" data-tip="Seeds the shared adom-you profile with the logins you already have, tagged by which account they came from ([email protected] vs [email protected] vs [email protected]) so the agent acts as the right you on each site. Discovery runs now; the actual decrypt needs one Windows admin approval, being built in Adom Bridge core (issue #51).">i</span></div>'+ +'<div class="sdesc">Build a comprehensive adom-you profile from every browser profile on this machine. Each login stays tagged by its source account so the agent picks the right identity per site.</div>'+ +'<div style="display:flex;gap:8px;margin:12px 0;flex-wrap:wrap"><button class="btn sm" id="importScanBtn">'+(s?'Rescan browsers':'Scan my browsers')+'</button>'+ +(has?'<button class="btn sm" id="importRunBtn">Import selected logins</button>':'')+'</div>'+ +summary+rows+ +(has?'<div id="importStatus" class="sdesc" style="opacity:.6;margin-top:10px">Import pops one Adom Bridge consent dialog + one Windows admin (UAC) prompt, then decrypts your selected logins into the adom-you vault, tagged by account. Passwords are never shown or logged.</div>':'')+ +'</div>';+}+async function runImportScan(){const b=_q('#importScanBtn');if(b){b.disabled=true;b.textContent='Scanningβ¦';}const j=await act({action:'import-scan'});_importScan=(j&&j.ok)?j:{profiles:[],accounts:[]};renderDlg();}+async function runImport(){+ const sel=((_importScan&&_importScan.profiles)||[]).filter(p=>_importSel[p.browser+'/'+p.profileDir]!==false);+ if(!sel.length){toast('Select at least one profile','error');return;}+ const btn=_q('#importRunBtn');if(btn){btn.disabled=true;btn.textContent='Approve on your desktopβ¦';}+ const el=_q('#importStatus');if(el)el.textContent='Waiting for the Adom Bridge consent dialog and Windows admin (UAC) prompt on your screenβ¦';+ const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'import-run',profiles:sel.map(p=>({browser:p.browser,profileDir:p.profileDir}))})});+ const j=await r.json().catch(()=>({}));+ if(btn){btn.disabled=false;btn.textContent='Import selected logins';}+ if(el)el.textContent=j.message||j.error||'Done.';+ toast(j.ok?('Imported '+((j.added||0)+(j.updated||0))+' logins into the adom-you vault'):(j.message||j.error||'Import failed'),j.ok?'success':'error');+}+// ===== Settings renderer (adom-ui-design / settings-layout, v2.0.296) =====+// One continuously scrolling pane: sections (h2) > cards (h3) > rows, a sidebar that jumps within it+// and scroll-spies, every row a breadcrumb name + explanation + (real) screenshot + control + default.+const SET_NAMES={taskbarMode:'Identity mode',overlayBadges:'Favicon overlay badges',overlaySize:'Badge size',taskbarGrouping:'Grouping',aumidIconStyle:'Icon style',jumpLists:'Jump lists',jlViewTasks:'Wiki view tasks',jlCloseAll:'Close all windows task',jlDashboard:'Open dashboard task',jlAnnotate:'Annotate this page task',annCaptionBar:'Caption bar on captures',toolbarExtension:'Adom toolbar in Chrome',tbSnipHotkey:'Snip hotkey',tbFloatyPencil:'Corner pencil',floatyHitPx:'Pencil hit area',taskbarActivity:'Activity signal',fgCaption:'Caption on AI foreground',fgCaptionMs:'Minimum caption time',notMineCaption:'Not-by-Adom caption',sleepRecovery:'Sleep and crash recovery',autoLogin:'Auto-fill saved logins',captureLogins:'Capture logins to the vault',reportIssues:'Issue-report hints',titleTag:'ai-thread tag in the title',titlePrefix:'Thread name prefixed to the title',ownerChip:'Floating owner chip',minimalTouch:'Minimal-touch mode'};+const SET_DEFAULTS={taskbarMode:'Tiles',overlayBadges:'On',overlaySize:'75%, balanced',taskbarGrouping:'Split',aumidIconStyle:'Category',jumpLists:'On',jlViewTasks:'On',jlCloseAll:'On',jlDashboard:'On',jlAnnotate:'On',annCaptionBar:'On',toolbarExtension:'On',tbSnipHotkey:'On',tbFloatyPencil:'Off',floatyHitPx:'24px',taskbarActivity:'Progress bar',fgCaption:'On',fgCaptionMs:'3.5s',notMineCaption:'Off',sleepRecovery:'On',autoLogin:'On',captureLogins:'On',reportIssues:'On',titleTag:'Off',titlePrefix:'Off',ownerChip:'On',minimalTouch:'On'};+// choice cards: the option name + the one-line tradeoff + the REAL screenshot per option+const SET_CHOICES={+ overlaySize:[{v:'50',n:'50%',d:'Smallest badge, maximum clearance for the tile text.',img:'obsize50',alt:'The pup tile with a small favicon badge in its corner'},+ {v:'66',n:'66%',d:'Small, still clear of the text.',img:'obsize66',alt:'The pup tile with a medium-small favicon badge'},+ {v:'75',n:'75%',d:'Balanced: readable badge, readable text.',img:'obsize75',alt:'The pup tile with a medium favicon badge'},+ {v:'100',n:'100%',d:'Full size. Covers the corner of the tile text.',img:'obsize100',alt:'The pup tile with a large favicon badge overlapping the text corner'}],+ taskbarActivity:[{v:'progress',n:'Progress bar',d:'Fills over the operationβs usual duration, so you can see how far along it is.',img:'tbactprogress',alt:'The pup taskbar button with a blue progress bar filling underneath it'},+ {v:'flash',n:'Orange flash',d:'Highlights the button until you look at it. Tells you something happened, not how long is left.',img:'tbactflash',alt:'The same taskbar button with the attention highlight underneath it'},+ {v:'off',n:'Off',d:'The taskbar button never changes.',img:'tbactoff',alt:'The same taskbar button with no highlight of any kind'}],+ taskbarGrouping:[{v:'split',n:'Split',d:'One taskbar button per window.',img:'groupsplit',alt:'Five separate pup taskbar buttons, one per window'},+ {v:'grouped',n:'Grouped',d:'Windows of the same category share one button.',img:'groupgrouped',alt:'The same five windows collapsed under category buttons'}],+ aumidIconStyle:[{v:'standard',n:'Standard',d:'One pup tile for every window.',img:'iconstylestd',alt:'Pup taskbar buttons all wearing the same pup tile'},+ {v:'category',n:'Category',d:'A tile per kind: signed-in wiki, public wiki, open web, Adom app, mixed.',img:'iconstylecat',alt:'Pup taskbar buttons each wearing the icon of its category'}],+};+const SET_SECTIONS=[+ {id:'s-identity',t:'Taskbar identity',cards:[+ {id:'c-mode',t:'Identity mode',status:st=>st.taskbarMode,desc:'How pup brands your Windows taskbar. Tiles gives every AI thread one button whose icon is the thread name on the Adom teal tile, so the taskbar reads as a list of your threads. AUMID gives every window its own button with a pup or category icon. Plain leaves identity alone and windows stack under the stock Chrome for Testing button. The example tiles are drawn live by the same code that draws the real icons, so they cannot drift from what Windows shows.',rows:['taskbarMode']},+ {id:'c-badges',t:'Overlay badges',status:st=>st.overlayBadges==='on'?'on':'off',desc:'The favicon of the active tab, composited into the corner of the taskbar button so you can tell at a glance which site a window is on. Works in tiles and AUMID modes.',rows:['overlayBadges','overlaySize']},+ {id:'c-aumid',t:'AUMID options',status:st=>st.taskbarMode==='aumid'?'active':'inert in '+st.taskbarMode+' mode',desc:'These two only apply in AUMID mode, where each window carries its own button.',rows:['taskbarGrouping','aumidIconStyle'],depMode:'aumid'},+ ]},+ {id:'s-jumplists',t:'Jump lists',cards:[+ {id:'c-jl',t:'Right-click menu',status:st=>st.jumpLists==='on'?'on':'off',desc:'The menu Windows shows when you right-click a pup taskbar button. pup registers the tasks below through the Windows jump-list API, so they appear exactly where every other app puts them. Requires an identity mode other than plain.',rows:['jumpLists','jlViewTasks','jlCloseAll','jlDashboard','jlAnnotate']},+ ]},+ {id:'s-capture',t:'Annotate and capture',cards:[+ {id:'c-capbar',t:'Captures',status:st=>st.annCaptionBar==='on'?'caption bar on':'caption bar off',desc:'When you draw on a page and finish, pup captures the marked-up region as a PNG and copies it to your clipboard.',rows:['annCaptionBar']},+ {id:'c-toolbar',t:'Adom toolbar in Chrome',status:st=>st.toolbarExtension==='on'?'on':'off',desc:'A small family of extension buttons that ride the Chrome for Testing launch: the pup menu, the annotate pen, and your adom-you identity tile. They are staged on every launch, so they need no setup.',rows:['toolbarExtension','tbSnipHotkey','tbFloatyPencil','floatyHitPx']},+ ]},+ {id:'s-activity',t:'Activity and foreground',cards:[+ {id:'c-activity',t:'Taskbar activity signal',status:st=>({progress:'progress bar',flash:'orange flash',off:'off'})[st.taskbarActivity]||st.taskbarActivity,desc:'What a windowβs taskbar button does while pup is working on it. pup keeps a running average of how long each operation takes (applying the window icon, taking a screenshot) and fills the bar over that estimate, capping just short of done until the work actually finishes. Windows only.',rows:['taskbarActivity']},+ {id:'c-captions',t:'Foreground captions',status:st=>st.fgCaption==='on'?'on':'off',desc:'pup opens every window in the background and never brings one to your screen without a reason. When an AI does raise a window, it must state why, and that reason is shown as an on-screen caption BEFORE the window comes up, naming the thread that asked. Caption time scales with the amount of text: the slider sets the minimum, and longer reasons extend at reading speed up to 12 seconds.',rows:['fgCaption','fgCaptionMs','notMineCaption']},+ ]},+ {id:'s-logins',t:'Logins',cards:[+ {id:'c-vault',t:'Credential vault',status:st=>st.captureLogins==='on'?'capturing':'not capturing',desc:'pup runs its own password manager backed by the OS keychain, so an AI can sign into sites in the background without Chromeβs foreground autofill dropdown. Passwords are encrypted at rest and never logged or echoed.',rows:['autoLogin','captureLogins']},+ {id:'c-import',t:'Import logins',html:()=>importSectionHtml()},+ ]},+ {id:'s-inpage',t:'In-page identity',cards:[+ {id:'c-labels',t:'Window labels',status:st=>st.ownerChip==='on'?'owner chip on':'owner chip off',desc:'Ways to tell which AI thread owns a window from inside it. pup identifies its own windows by a Win32 property it writes at birth, so none of these are needed for bookkeeping; they exist only for you.',rows:['ownerChip','titleTag','titlePrefix']},+ ]},+ {id:'s-safety',t:'Recovery and safety',cards:[+ {id:'c-safety',t:'Recovery and safety',status:st=>st.sleepRecovery==='on'?'recovery on':'recovery off',desc:'What pup does when things go wrong, and how much it touches your windows beyond driving the pages inside them.',rows:['sleepRecovery','reportIssues','minimalTouch']},+ ]},+];+const SET_PREFIX={taskbarMode:'Taskbar: ',overlayBadges:'Taskbar: ',overlaySize:'Overlay badges: ',taskbarGrouping:'AUMID: ',aumidIconStyle:'AUMID: ',jumpLists:'Taskbar: ',jlViewTasks:'Jump lists: ',jlCloseAll:'Jump lists: ',jlDashboard:'Jump lists: ',jlAnnotate:'Jump lists: ',annCaptionBar:'Captures: ',toolbarExtension:'Chrome: ',tbSnipHotkey:'Toolbar: ',tbFloatyPencil:'Toolbar: ',floatyHitPx:'Corner pencil: ',taskbarActivity:'Taskbar: ',fgCaption:'Foreground: ',fgCaptionMs:'Caption: ',notMineCaption:'Foreground: ',sleepRecovery:'Recovery: ',autoLogin:'Vault: ',captureLogins:'Vault: ',reportIssues:'Safety: ',titleTag:'Title: ',titlePrefix:'Title: ',ownerChip:'In page: ',minimalTouch:'Safety: '};+const SET_DEFS=Object.fromEntries(SETTINGS_DEF.map(d=>[d.k,d]));+const SET_SHOT_PENDING=new Set(['tbFloatyPencil','titleTag','titlePrefix','notMineCaption']);+let _dlgSelected=null;+function _rowName(k){return '<h4 class="row-name" title="'+esc(k)+'"><span class="prefix">'+esc(SET_PREFIX[k]||'')+'</span><span class="title">'+esc(SET_NAMES[k]||k)+'</span></h4>';}+function _toggle(k,on,dis){return '<button class="toggle'+(on?' is-on':'')+'" role="switch" aria-checked="'+on+'" aria-label="'+esc(SET_NAMES[k]||k)+'" data-tgl="'+k+'"'+(dis?' disabled':'')+'><span class="toggle-track"><span class="toggle-thumb"></span></span></button>';}+function _shot(name,alt,cap){return name?'<figure class="setting-shot"><img src="/dashboard/example?n='+name+'&v='+PAGE_VERSION+'" alt="'+esc(alt||'')+'" loading="lazy" data-fallback="hide">'+(cap?'<figcaption>'+esc(cap)+'</figcaption>':'')+'</figure>':'';}+function _dflt(k){return '<span class="default-hint">Default: '+esc(SET_DEFAULTS[k]||'')+'</span>';}+function renderRow(k,st,enabled){+ const def=SET_DEFS[k]; if(!def) return '';+ const dis=!enabled;+ if(def.slider){+ const v=Number(st[k])||(def.unit==='px'?24:3500); const unit=def.unit||'s';+ const disp=unit==='px'?v+'px':(v/1000).toFixed(1)+'s';+ return '<div class="set-row" id="row-'+k+'">'+_rowName(k)+'<p class="row-desc">'+esc(def.sliderLabel||'')+'</p>'+ +'<div class="slider-line"><input type="range" min="'+def.slider[0]+'" max="'+def.slider[1]+'" step="'+def.slider[2]+'" value="'+v+'" data-slider="'+k+'" data-unit="'+unit+'" aria-label="'+esc(SET_NAMES[k]||k)+'"'+(dis?' disabled':'')+'>'+ +'<span class="sval" id="val-'+k+'">'+disp+'</span>'+ +(def.test?'<button class="btn sm" data-testcap="1" title="Show a sample caption on your screen now, at the configured duration"'+(dis?' disabled':'')+'>test</button>':'')+'</div>'+_dflt(k)+'</div>';+ }+ if(SET_CHOICES[k]){+ const cur=st[k];+ return '<div class="set-row" id="row-'+k+'">'+_rowName(k)+'<p class="row-desc">'+esc(def.d)+'</p>'+ +'<fieldset class="choice-set" role="radiogroup" aria-label="'+esc(SET_NAMES[k]||k)+'" data-enabled="'+(!dis)+'">'+ +SET_CHOICES[k].map(c=>'<label class="choice" data-selected="'+(cur===c.v)+'"><input type="radio" name="sc-'+k+'" value="'+c.v+'" data-choice="'+k+'"'+(cur===c.v?' checked':'')+(dis?' disabled':'')+'>'+ +'<img src="/dashboard/example?n='+c.img+'&v='+PAGE_VERSION+'" alt="'+esc(c.alt)+'" loading="lazy" data-fallback="hide">'+ +'<span class="choice-name">'+esc(c.n)+'</span><span class="choice-desc">'+esc(c.d)+'</span></label>').join('')+ +'</fieldset>'+_dflt(k)+'</div>';+ }+ if(def.sel){+ const selHtml='<select data-sel="'+k+'" aria-label="'+esc(SET_NAMES[k]||k)+'"'+(dis?' disabled':'')+'>'+def.sel.map(x=>'<option value="'+esc(x)+'"'+(st[k]===x?' selected':'')+'>'+esc((def.selLabels&&def.selLabels[x])||x)+'</option>').join('')+'</select>';+ const threadEx=def.threadex?'<div class="texwrap"><div class="texstrip" id="texstrip"></div><div class="texcap">Your real thread names, drawn exactly as the taskbar draws them. The badge in the corner is the favicon of the active tab.</div></div>':'';+ return '<div class="set-row" id="row-'+k+'"><div class="row-head">'+_rowName(k)+selHtml+'</div><p class="row-desc">'+esc(def.d)+'</p>'+threadEx+_dflt(k)+'</div>';+ }+ const on=st[k]==='on';+ const shot=def.pair?'<div style="display:flex;gap:.75rem;flex-wrap:wrap">'+def.pair.map(q=>_shot(q.img,q.cap,q.cap)).join('')+'</div>'+ :def.img?_shot(def.img,SET_NAMES[k]+' as it appears on this machine',null):(SET_SHOT_PENDING.has(k)?'<div class="shot-pending">Screenshot pending: a real capture of this will be added; nothing is mocked up in its place.</div>':'');+ return '<div class="set-row" id="row-'+k+'"><div class="row-head">'+_rowName(k)+_toggle(k,on,dis)+'</div><p class="row-desc">'+esc(def.d)+'</p>'+shot+_dflt(k)+'</div>';+}+function renderCard(card,st){+ const status=card.status?card.status(st):'';+ const okish=/^(on|active|tiles|aumid|progress bar|orange flash|capturing|recovery on|owner chip on|caption bar on)$/.test(status);+ let body='';+ if(card.html){ body=card.html(); }+ else{+ const keys=card.rows;+ const parentKey=keys[0];+ const parentDef=SET_DEFS[parentKey];+ const cardOn=card.depMode?(st.taskbarMode===card.depMode):true;+ // the first row is the parent; rows that declare dep on it (or on a sibling) are its children+ const children=keys.slice(1).filter(k=>SET_DEFS[k]&&(SET_DEFS[k].dep===parentKey||keys.includes(SET_DEFS[k].dep)));+ const siblings=keys.slice(1).filter(k=>!children.includes(k));+ body+=renderRow(parentKey,st,cardOn);+ if(children.length){+ const pOn=cardOn&&(parentDef&&parentDef.sel?true:st[parentKey]==='on');+ body+='<div class="setting-children" data-enabled="'+pOn+'" title="'+(pOn?'':'Available when '+esc(SET_NAMES[parentKey])+' is on')+'">'+ +(pOn?'':'<div class="row-why">Available when '+esc(SET_NAMES[parentKey])+' is on.</div>')+ +children.map((k,i)=>{+ const d=SET_DEFS[k]; const gp=d.dep&&d.dep!==parentKey?d.dep:null; // grandchild (e.g. pencil hit area under corner pencil)+ const en=pOn&&(!gp||st[gp]==='on');+ return (i?'<div class="divider"></div>':'')+renderRow(k,st,en);+ }).join('')+'</div>';+ }+ siblings.forEach(k=>{ body+='<div class="divider"></div>'+renderRow(k,st,cardOn); });+ }+ return '<section class="settings-card" id="'+card.id+'"><h3 class="card-title">'+esc(card.t)+(status?' <span class="card-status'+(okish?' ok':'')+'">'+esc(status)+'</span>':'')+'</h3>'+ +(card.desc?'<p class="card-desc">'+esc(card.desc)+'</p>':'')+body+'</section>';+}+function renderDlg(){+ const st=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};+ st.taskbarMode=st.aumidIcons!=='on'?'plain':(st.threadTiles==='on'?'tiles':'aumid');+ _q('#dlgSide').innerHTML=SET_SECTIONS.map(sec=>'<button class="side-sec'+(_dlgSelected===sec.id?' selected':'')+'" data-jump="'+sec.id+'"><span aria-hidden="true" style="font-size:.7em;opacity:.7">\u25BE</span>'+esc(sec.t)+'</button>'+ +sec.cards.map(c=>'<button class="side-sub'+(_dlgSelected===c.id?' selected':'')+'" data-jump="'+c.id+'">'+esc(c.t)+'</button>').join('')).join('');+ _q('#dlgBody').innerHTML=SET_SECTIONS.map(sec=>'<h2 class="sec-title" id="'+sec.id+'">'+esc(sec.t)+'</h2>'+sec.cards.map(c=>renderCard(c,st)).join('')).join('')+ +'<footer class="settings-footer"><p class="footer-note">Settings are stored on this machine (~/.adom/pup-settings.json) and apply to live windows immediately. Restoring returns every option above to its default; your identity avatar is kept.</p>'+ +'<button class="restore-btn" id="restoreBtn">Restore defaults</button></footer>';+ paintThreadExamples();+}+function openDlg(){+ renderDlg(); _q('#dlgWrap').classList.remove('hidden');+ const dlg=_q('#dlg'), sc=_q('#dlgScroller');+ try{dlg.focus();}catch(e){}+ sc.scrollTop=0; requestAnimationFrame(()=>{sc.scrollTop=0; spyDlg();});+}+function closeDlg(){_q('#dlgWrap').classList.add('hidden');}+let _spyRaf=0,_spyMuteUntil=0;+function spyDlg(){+ const pane=_q('#dlgScroller'); if(!pane) return;+ const line=pane.getBoundingClientRect().top+pane.clientHeight*0.33;+ let cur=null;+ for(const h of pane.querySelectorAll('.sec-title,.settings-card')){ if(h.getBoundingClientRect().top<=line) cur=h.id; else break; }+ if(cur&&cur!==_dlgSelected){_dlgSelected=cur; document.querySelectorAll('#dlgSide [data-jump]').forEach(b=>b.classList.toggle('selected',b.dataset.jump===cur));}+}+let _restoreArmed=0;+// v2.0.172 THREAD TILE EXAMPLES β draw sample taskbar tiles on canvas using the SAME rules as+// renderThreadTileIco on the server (teal #00b8b1, ink #05221f, auto-fit, 2-line wrap on the last+// space or the first hyphen, text anchored BOTTOM-LEFT so the top-right favicon badge stays clear).+// Uses the threads that actually have windows open right now, then fills out with canonical examples+// so the strip always shows a range of name lengths.+function paintThreadExamples(){+ const strip=_q('#texstrip'); if(!strip) return;+ // v2.0.204 (John, the final spec): the examples are FIVE FIXED CANONICAL SAMPLES β wiki page,+ // dashboard app, ti.com, mouser, digikey β rendered identically under every one of the six+ // states, from BUNDLED ground-truth art (each vendor's own favicon, the real category tiles, the+ // real Chrome-for-Testing icon). Always present, never dependent on which windows are open: the+ // rows exist to teach what the modes MEAN.+ const st2=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};+ const mode=st2.aumidIcons!=='on'?'plain':(st2.threadTiles==='on'?'tiles':'aumid');+ const ovOn=st2.overlayBadges!=='off';+ const V='v='+(typeof PAGE_VERSION!=='undefined'?PAGE_VERSION:'0');+ // v2.0.206 (John: the wiki has TWO icon styles β signed-in and public β both are canonical+ // samples): SIX fixed samples per state row.+ const SAMPLES=[+ {tile:'wiki in', fav:'/dashboard/sample-favicon?n=wiki&'+V, cat:'wikiin'},+ {tile:'wiki out', fav:'/dashboard/sample-favicon?n=wiki&'+V, cat:'wiki'},+ {tile:'dash', fav:'/dashboard/icon.svg?'+V, cat:'app'},+ {tile:'ti.com', fav:'/dashboard/sample-favicon?n=ti&'+V, cat:'web'},+ {tile:'mouser', fav:'/dashboard/sample-favicon?n=mouser&'+V, cat:'web'},+ {tile:'digikey', fav:'/dashboard/sample-favicon?n=digikey&'+V, cat:'web'},+ ];+ function fb(sm){ return '<span class="mbcard"><img src="'+sm.fav+'" alt=""></span>'; }+ function tileIc(sm,ov){ return '<span class="mic"><canvas width="128" height="128" data-tname="'+esc(sm.tile)+'"></canvas>'+(ov?fb(sm):'')+'</span>'; }+ function aumidIc(sm,ov){ return '<span class="mic"><img class="mbase" src="/dashboard/sample-cat?k='+sm.cat+'&'+V+'" data-catfb="'+sm.cat+'">'+(ov?fb(sm):'')+'</span>'; }+ // v2.0.205 (John: "I want all 5 icons for each of the 6 states"): plain rows show all five+ // samples too β each as the stock Chrome icon (that IS what plain looks like per window), with+ // its OWN site badge in the overlay row. The stacking note stays as a caption, not a collapse.+ function plainIc(sm,ov){ return '<span class="mic"><img class="mbase" src="/dashboard/baseicon?profile=adom-you&'+V+'">'+(ov?fb(sm):'')+'</span>'; }+ function plainGroup(ov){+ var cards=SAMPLES.map(function(sm){+ return '<span style="display:inline-flex;flex-direction:column;width:44px;height:34px;border:1px solid #3a4a52;border-radius:4px;overflow:hidden;background:#e8eaed;flex:none">'+ +'<span style="display:flex;align-items:center;gap:3px;height:12px;background:#202124;padding:0 3px">'+ +'<img src="'+sm.fav+'" style="width:8px;height:8px;object-fit:contain">'+ +'<span style="flex:1"></span><span style="color:#9aa0a6;font-size:7px;line-height:1">\u2715</span></span>'+ +'<span style="flex:1;background:linear-gradient(#ffffff,#e8eaed)"></span></span>';+ }).join('');+ // v2.0.266: this strip of six 44px flex:none cards did NOT wrap, so it was a hard ~298px floor+ // that no amount of shrinking elsewhere could beat β the last 49px of the panel's horizontal+ // overflow (and the seam past .texstrip's background) came from exactly here. Let it wrap.+ return '<div style="display:flex;flex-direction:column;align-items:center;gap:3px;max-width:100%;min-width:0">'+ +'<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:4px;padding:5px 6px;border:1px solid #2f3f46;border-radius:6px;background:#1b2429;max-width:100%">'+cards+'</div>'+ +'<span style="color:#4a5a58;font-size:10px;line-height:1.2">\u25B2 what hovering the ONE button shows</span>'+ +'<span class="mic"><img class="mbase" src="/dashboard/baseicon?profile=adom-you&'+V+'">'+(ov?fb(SAMPLES[3]):'')+'</span>'+ +'</div>';+ }+ function showApplyProgress(){+ // v2.0.214b: a setting click re-renders the whole dialog, destroying anything inserted at+ // click time β so the panel re-finds (or re-creates) itself on EVERY poll tick instead.+ function ensureEl(){+ var el=document.getElementById('applyprog');+ if(el&&el.isConnected) return el;+ var anchor=document.querySelector('.st6'); if(!anchor) return null;+ el=document.createElement('div'); el.id='applyprog';+ el.style.cssText='margin:10px 4px 2px;padding:10px 12px;border:1px solid #2a3f3e;border-radius:8px;background:#0f1817;font:12px/1.8 "JetBrains Mono",Consolas,monospace;color:#9fb3b1';+ (anchor.parentElement||anchor).insertAdjacentElement('afterend',el);+ return el;+ }+ var t0=Date.now();+ function paint(a){+ var el=ensureEl(); if(!el) return false;+ el.style.display='block';+ if(!a || (Date.now()-a.at>60000 && !a.done)){ el.innerHTML='<span style="color:#00b8b1">Applying\u2026</span>'; return false; }+ var h='<style>@keyframes apr{to{transform:rotate(360deg)}}.apspin{display:inline-block;animation:apr 1s linear infinite}</style>';+ a.steps.forEach(function(st){+ var mark = st.state==='done' ? '<span style="color:#3fb950">\u2713</span>'+ : st.state==='run' ? '<span class="apspin" style="color:#00b8b1">\u25CC</span>'+ : '<span style="color:#4a5a58">\u25CB</span>';+ h+='<div>'+mark+' '+st.label+'</div>';+ });+ if(a.done) h+='<div style="margin-top:6px;color:#3fb950;font-weight:700">\u2713 Done: your taskbar now matches this state</div>';+ el.innerHTML=h;+ return !!a.done;+ }+ (function tick(){+ fetch('/dashboard/apply-progress').then(function(r){return r.json()}).then(function(j){+ var fin=paint(j&&j.apply);+ if(fin){ setTimeout(function(){ var e2=document.getElementById('applyprog'); if(e2) e2.style.display='none'; },7000); return; }+ if(Date.now()-t0<45000) setTimeout(tick,600);+ else { var e3=ensureEl(); if(e3) e3.innerHTML='<span style="color:#d29922">Still settling in the background\u2026 (check the taskbar in a few seconds)</span>'; }+ }).catch(function(){ if(Date.now()-t0<45000) setTimeout(tick,900); });+ })();+ }+ const states=[+ ['tiles',true, '1. tiles + overlay'],+ ['tiles',false,'2. tiles'],+ ['aumid',true, '3. aumid + overlay'],+ ['aumid',false,'4. aumid'],+ ['plain',true, '5. plain + overlay'],+ ['plain',false,'6. plain'],+ ];+ strip.innerHTML='<div class="st6">'+states.map(function(cfg,i){+ const m=cfg[0],ov=cfg[1],lbl=cfg[2];+ const cur=(m===mode&&ov===ovOn);+ const isDefault=(m==='tiles'&&ov);+ let icons='';+ if(m==='tiles'){ icons=SAMPLES.map(function(sm){return tileIc(sm,ov);}).join(''); }+ else if(m==='aumid'){ icons=SAMPLES.map(function(sm){return aumidIc(sm,ov);}).join(''); }+ else {+ var ptxt = ov+ ? 'ALL pup windows share this ONE stock Chrome button. They stay separate windows: hover the button and Windows fans out a preview of each (drawn above). You just cannot ungroup them on the bar, because the per-app identity that separates buttons is exactly what plain mode removes. The single badge belongs to the most recently updated window.'+ : 'ALL pup windows share this ONE stock Chrome button. They stay separate windows: hover the button and Windows fans out a preview of each (drawn above). You just cannot ungroup them on the bar, because the per-app identity that separates buttons is exactly what plain mode removes.';+ icons=plainGroup(ov)+'<span class="texn" style="align-self:center;flex:1 1 190px;min-width:0;max-width:100%;text-align:left">'+ptxt+'</span>';+ }+ return '<div class="strow6'+(cur?' mcur':'')+'" data-si="'+i+'" title="click to switch to this state">'+ +'<span class="stlbl">'+lbl+(cur?' (current)':'')+(isDefault?' <span class="mdef">default</span>':'')+'</span>'+ +'<div class="sticons">'+icons+'</div></div>';+ }).join('')+'</div>';+ const cap=strip.parentElement?strip.parentElement.querySelector('.texcap'):null;+ if(cap)cap.textContent='Six canonical sample windows (signed-in wiki, public wiki, the pup dashboard app, ti.com, mouser, digikey) rendered under every state so you can compare like for like. Tiles are drawn by the real tile renderer; category icons and the Chrome icon are the real registered art; badges are each site\u2019s own favicon. Click a row to switch to it; a step list shows the apply progress and a green check when your taskbar matches.';+ strip.querySelectorAll('canvas[data-tname]').forEach(function(cv){ drawNameTile(cv.getContext('2d'),128,cv.getAttribute('data-tname')); });+ strip.querySelectorAll('img[data-catfb]').forEach(function(im){+ im.onerror=function(){+ const cv=document.createElement('canvas'); cv.width=128; cv.height=128; cv.className='mbase';+ const ctx=cv.getContext('2d'),box=128;+ rr(ctx,0,0,box,box,box*0.14,'#00b8b1');+ rr(ctx,box*0.18,box*0.18,box*0.64,box*0.64,box*0.1,'#053b38');+ ctx.strokeStyle='#7fe7e2';ctx.lineWidth=box*0.045;+ ctx.beginPath();ctx.arc(box/2,box/2,box*0.19,0,7);ctx.stroke();+ im.replaceWith(cv);+ };+ });+ strip.querySelectorAll('.strow6').forEach(function(row,i){+ row.onclick=function(){+ const c=states[i];+ setSetting('taskbarMode',c[0]);+ setSetting('overlayBadges',c[1]?'on':'off');+ showApplyProgress();+ };+ });+}+function rr(ctx,x,y,w,h,r,fill){ctx.fillStyle=fill;ctx.beginPath();if(ctx.roundRect)ctx.roundRect(x,y,w,h,r);else ctx.rect(x,y,w,h);ctx.fill();}+function drawNameTile(ctx,box,nm){+ rr(ctx,0,0,box,box,0,'#00b8b1');+ const FLOOR=box*0.36;+ const wFor=function(i,total){return (total===2&&i===0)?box*0.80:box*0.92;};+ const setF=function(sz){ctx.font='700 '+sz+"px 'Familjen Grotesk','Segoe UI',sans-serif";};+ const wid=function(t,sz){setF(sz);return ctx.measureText(t).width;};+ const fitSize=function(lines){let sz=box*0.9;+ for(let i=0;i<90;i++){let okf=true;+ for(let j=0;j<lines.length;j++){if(wid(lines[j],sz)>wFor(j,lines.length)){okf=false;break;}}+ if(okf&&lines.length*sz*0.95<=box*0.88)break;sz*=0.95;}+ return sz;};+ const cands=[[nm]];const sp=nm.lastIndexOf(' '),hy=nm.indexOf('-');+ if(sp>0)cands.push([nm.slice(0,sp),nm.slice(sp+1)]);else if(hy>0)cands.push([nm.slice(0,hy+1),nm.slice(hy+1)]);+ if(sp<0&&hy<0&&nm.length>=7){const mm=Math.ceil(nm.length/2);cands.push([nm.slice(0,mm),nm.slice(mm)]);}+ let best=[nm],bf=0;for(const c of cands){const f=fitSize(c);if(f>bf){bf=f;best=c;}}+ if(bf<FLOOR){bf=FLOOR;+ const take=function(str,maxw){let n=0;for(let i=1;i<=str.length;i++){if(wid(str.slice(0,i),FLOOR)<=maxw)n=i;else break;}return n;};+ let rest=String(nm).trim();const out=[];+ for(let ln=0;ln<2&&rest;ln++){+ let n=take(rest,wFor(ln,2));+ if(n>=rest.length){out.push(rest);rest='';break;}+ if(n<1)n=1;+ const seg=rest.slice(0,n+1);+ const bp=Math.max(seg.lastIndexOf(' '),seg.lastIndexOf('-'));+ const cut=(bp>=Math.ceil(n*0.5))?bp+(seg.charAt(bp)==='-'?1:0):n;+ out.push(rest.slice(0,cut).trim());+ rest=rest.slice(cut).replace(/^[ -]+/,'');}+ best=out.filter(Boolean);}+ setF(bf);ctx.fillStyle='#05221f';ctx.textAlign='left';ctx.textBaseline='alphabetic';+ const lh=bf*0.95,mx=box*0.055,baseY=box-box*0.05-bf*0.14;+ best.forEach(function(ln,i){ctx.fillText(ln,mx,baseY-(best.length-1-i)*lh);});+}++async function setSetting(key,value){+ // optimistic: the control already reflects the click; the re-render after the save must not+ // jump the scroller (settings-layout: one continuous pane, position preserved across saves).+ const sc=_q('#dlgScroller'); const keepTop=sc?sc.scrollTop:0;+ const j=await act({action:'set-setting',key,value});+ if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;renderDlg();if(sc){sc.scrollTop=keepTop;}}+}+document.addEventListener('click',ev=>{+ if(ev.target.closest('#gear')){openDlg();return;}+ if(ev.target.closest('#dlgClose')||ev.target.id==='dlgBg'){closeDlg();return;}+ const j=ev.target.closest('[data-jump]');+ if(j){_dlgSelected=j.dataset.jump;_spyMuteUntil=Date.now()+600;document.querySelectorAll('#dlgSide [data-jump]').forEach(b=>b.classList.toggle('selected',b===j));const el=document.getElementById(j.dataset.jump);if(el)el.scrollIntoView({block:'start'});return;}+ if(ev.target.closest('#restoreBtn')){+ const b=_q('#restoreBtn');+ if(!_restoreArmed){_restoreArmed=Date.now();b.classList.add('armed');b.textContent='Click again to confirm';setTimeout(()=>{if(_restoreArmed&&Date.now()-_restoreArmed>=3900){_restoreArmed=0;b.classList.remove('armed');b.textContent='Restore defaults';}},4000);return;}+ _restoreArmed=0;b.classList.remove('armed');b.textContent='Restoringβ¦';+ act({action:'restore-defaults'}).then(j=>{if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;}renderDlg();});+ return;+ }+ const t=ev.target.closest('[data-tgl]');+ if(t){if(t.disabled)return;const next=t.classList.contains('is-on')?'off':'on';t.classList.toggle('is-on',next==='on');t.setAttribute('aria-checked',next==='on');setSetting(t.dataset.tgl,next);return;}+ if(ev.target.closest('[data-testcap]')){act({action:'test-caption'});return;}+ if(ev.target.closest('#importScanBtn')){runImportScan();return;}+ if(ev.target.closest('#importRunBtn')){runImport();return;}+});+document.addEventListener('change',ev=>{+ const c=ev.target.closest('[data-imchk]');+ if(c){_importSel[c.dataset.imchk]=c.checked;}+});+document.addEventListener('input',ev=>{+ const r=ev.target.closest('[data-slider]');+ if(r){_q('#val-'+r.dataset.slider).textContent=r.dataset.unit==='px'?r.value+'px':(Number(r.value)/1000).toFixed(1)+'s';}+});+document.addEventListener('keydown',ev=>{ if(ev.key==='Escape'&&!_q('#dlgWrap').classList.contains('hidden')){closeDlg();} });+document.addEventListener('scroll',ev=>{ if(ev.target&&ev.target.id==='dlgScroller'){ if(_spyRaf)return; _spyRaf=requestAnimationFrame(()=>{_spyRaf=0; if(Date.now()<_spyMuteUntil)return; spyDlg();}); } },true);+document.addEventListener('change',ev=>{+ const c=ev.target.closest('[data-choice]');+ if(c){const set=c.closest('.choice-set');if(set)set.querySelectorAll('.choice').forEach(l=>l.dataset.selected=String(l.contains(c)));setSetting(c.dataset.choice,c.value);return;}+ const r=ev.target.closest('[data-slider]');+ if(r){setSetting(r.dataset.slider,Number(r.value));return;}+ const sl=ev.target.closest('[data-sel]');+ if(sl){+ const def=SETTINGS_DEF.find(d=>d.k===sl.dataset.sel);+ if(def&&def.imgBy){const im=sl.closest('.sub').querySelector('.sexample');if(im)im.src='/dashboard/example?n='+def.imgBy[sl.value]+'&v='+PAGE_VERSION;}+ setSetting(sl.dataset.sel,sl.value);+ }+});+(function(){+ const b=_q('#brand'),pop=_q('#pop');let show=null,hide=null;+ function place(){const r=b.getBoundingClientRect();pop.style.left=Math.max(8,r.left)+'px';pop.style.top=(r.bottom+8)+'px';}+ b.addEventListener('mouseenter',()=>{clearTimeout(hide);show=setTimeout(()=>{place();pop.classList.add('show');},600);});+ b.addEventListener('mouseleave',()=>{clearTimeout(show);hide=setTimeout(()=>pop.classList.remove('show'),220);});+ pop.addEventListener('mouseenter',()=>clearTimeout(hide));+ pop.addEventListener('mouseleave',()=>{hide=setTimeout(()=>pop.classList.remove('show'),220);});+})();+document.addEventListener('error',ev=>{const i=ev.target;if(i&&i.tagName==='IMG'&&i.dataset&&i.dataset.fallback==='hide')i.style.visibility='hidden';},true);+document.addEventListener('click',ev=>{+ const tog=ev.target.closest('[data-toggle]');+ if(tog){const id=tog.dataset.toggle;if(COLL.has(id))COLL.delete(id);else COLL.add(id);render();return;}+ const jmp=ev.target.closest('[data-jump]');+ if(jmp){const t=document.getElementById(jmp.dataset.jump);if(t){t.scrollIntoView({behavior:'smooth',block:'start'});t.style.transition='box-shadow .3s';t.style.boxShadow='0 0 0 2px #00b8b1';setTimeout(()=>t.style.boxShadow='',900);}return;}+ const b=ev.target.closest('[data-act]');if(!b)return;+ const a=b.dataset.act,c=b.dataset.confirm;if(c&&!confirm(c))return;+ if(a==='flash-group'||a==='unflash-group'){(b.dataset.ids||'').split(',').filter(Boolean).forEach(sid=>act({action:a==='flash-group'?'flash':'unflash',sessionId:sid}));return;}+ act({action:a,sessionId:b.dataset.sid,tabId:b.dataset.tid});+});+_q('#f').onchange=render; _q('#q').oninput=render;+const es=new EventSource('/dashboard/events');+es.onmessage=e=>{const d=JSON.parse(e.data);if(d.error){_q('#m').innerHTML='<div class="empty">snapshot error: '+esc(d.error)+'</div>';_q('#live').classList.add('stale');return;}SNAP=d;_q('#live').classList.remove('stale');render();};+es.onerror=()=>_q('#live').classList.add('stale');++/* ββ the SHARED newbie panel (adom-ui-design 2.0.0) βββββββββββββββββββββββββββββββββββββββββββ+ pup owns exactly three things here: the phrases, the noun, and what the demo button does.+ Everything else (card, grid, table, icons, clipboard, send with its version gate and+ three-state verification, toasts, tooltips, the X and the header toggle) belongs to the module.+ apiBase is left at its default so the two Hydrogen calls go SAME-ORIGIN and this bridge relays+ them; see the /updater/status and /ai-threads/run-prompt routes in server.js. */+AdomNewbiePanel.mount({+ mount: '#newbie',+ app: 'pup',+ appName: 'Pup',+ slug: 'pup-bridge',+ toggleButton: '#recall',+ demo: {+ label: 'Play the demo',+ meta: '5 steps, about 2 minutes',+ onPlay: function () {+ AdomNewbiePanel.toast('Handing the demo script to a new AI thread. This can take up to two minutes.');+ fetch('/api/demo-send', {method:'POST'})+ .then(function(r){ return r.json(); })+ .then(function(j){ AdomNewbiePanel.toast(j.ok ? 'The demo is running in a new AI thread.' : (j.why || 'Could not reach the AI panel.'), j.ok ? '' : 'err'); })+ .catch(function(){ AdomNewbiePanel.toast('Could not reach the AI panel.', 'err'); });+ }+ },+ phrases: ${JSON.stringify(SAY_PHRASES)}+});++/* Status pills, straight from /api/status so the pills and the LED feed cannot drift. */+async function paintLeds(){+ var host=document.getElementById('leds'); if(!host) return;+ try{+ var j=await (await fetch('/api/status',{cache:'no-store'})).json();+ host.innerHTML=(j.leds||[]).map(function(l){+ var esc=function(t){ return String(t==null?'':t).replace(/&/g,'&').replace(/</g,'<'); };+ return '<span class="led-pill" title="'+esc(l.detail)+'"><span class="d '+esc(l.state)+'"></span>'+ +'<span class="lb">'+esc(l.label)+'</span><span class="sh">'+esc(l.short)+'</span></span>';+ }).join('');+ }catch(e){}+}+paintLeds(); setInterval(paintLeds,15000);+</script></body></html>`;++const server = http.createServer(async (req, res) => {+ const url = new URL(req.url, `http://localhost:${PORT}`);+ const pathname = url.pathname;++ // CORS preflight+ if (req.method === 'OPTIONS') {+ // v2.0.389 (control-api-proxy trap 1, caught by newbie-panel 2.22.0's checkSend): a BLANKET 204+ // answers for paths that exist in no method, so a probe cannot tell a wired route from a typo.+ // Preflight 204 only for paths pup actually serves; everything else is a 404.+ const _optPath = (() => { try { return new URL(req.url, 'http://127.0.0.1').pathname; } catch (e) { return req.url || ''; } })();+ const _served = /^\/(command|status|health|launch|navigate|reload|screenshot(\.png)?|errors|eval|wait|close|updater\/status|ai-threads\/run-prompt(\/status)?|api\/[A-Za-z0-9_-]+|dashboard(\/.*)?)$/.test(_optPath);+ if (!_served) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end('{"error":"no such route"}'); return; }+ res.writeHead(204, {+ 'Access-Control-Allow-Origin': '*',+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',+ 'Access-Control-Allow-Headers': 'Content-Type',+ });+ res.end();+ return;+ }+ // v1.9.334 (#468): everything except the lightweight /health probe needs the deferred native modules+ // (puppeteer/sharp/keytar/chrome). Load them on first use; /health stays answerable during the ~8s+ // one-time load so AD never mistakes a warming bridge for a wedged one.+ // v2.0.13 (John, "everyone keeps saying pup is down"): ALSO exclude /status. pup declares /status as+ // its healthEndpoint (for the rich cold-start chip), so ab's spawn-ready probe hits /status; when+ // /status triggered the ~8s heavy init it blocked the event loop and ab timed out and reaped the+ // bridge ("never bound its port 64230"), the exact cold-spawn flap. /status now answers instantly+ // (it guards `chrome` for the pre-init window) and never gates on the native load.+ if (pathname !== '/health' && pathname !== '/status') ensureHeavyInit();++ try {+ // GET /health+ // ββ v1.9.224 β LIVE MANAGEMENT DASHBOARD (John's ask) βββββββββββββββββββββββββββββββββββββ+ // Point a browser at http://<pup-host>:<port>/dashboard to see every window pup is driving right+ // now: its overlay badge (the ACTUAL composed PNG, inline), tab list, owning AI thread, fg/bg with+ // the justification the caller had to give to foreground it, highFps lease, uptime β filterable by+ // thread. Updates live over SSE, so it reflects reality rather than a snapshot you have to reload.+ //+ // This is also a debugging instrument: most of the hardest pup bugs (a stale badge from a dead+ // session, duplicate sessions painting one window, a window pinned by an emulated viewport) are+ // obvious the moment you can SEE all the state at once, and were invisible while state could only+ // be read one verb at a time.+ if (pathname === '/dashboard/events') {+ res.writeHead(200, {+ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache',+ 'Connection': 'keep-alive', 'Access-Control-Allow-Origin': '*',+ });+ try { res.flushHeaders && res.flushHeaders(); } catch (e) {}+ let alive = true;+ const push = async () => {+ if (!alive) return;+ // v1.9.229: REPORT the failure, do not swallow it. The previous version caught any error from+ // dashboardSnapshot() and silently set alive=false, so a single throw left the page stuck on+ // "connectingβ¦" forever with no way to see why. That is exactly the failure-suppression+ // pattern the restructure plan calls out (525 catch blocks, many hiding real errors) and I+ // reproduced it in brand-new code. Now the error is streamed to the client and logged.+ let payload;+ try {+ payload = JSON.stringify(await dashboardSnapshot());+ } catch (e) {+ console.log(`[dashboard] snapshot FAILED: ${e && e.stack ? e.stack.split('\n')[0] : e}`);+ payload = JSON.stringify({ error: String((e && e.message) || e), ts: Date.now() });+ }+ try { res.write(`data: ${payload}\n\n`); } catch (e) { alive = false; }+ };+ await push();+ const iv = setInterval(push, 2000);+ req.on('close', () => { alive = false; clearInterval(iv); });+ return;+ }+ if ((pathname === '/ui/toast' || pathname === '/dashboard/toast') && req.method === 'POST') {+ // adom-ui-design section 3: the toast hook, built in so the AI can narrate over the live app+ // ("Running test 3 of 8") through the same surface the app uses for its own feedback.+ try {+ const body = JSON.parse(await readBody(req) || '{}');+ const msg = String(body.msg || body.message || '').slice(0, 200);+ if (!msg) { sendJSON(res, { ok: false, error: 'msg required' }); return; }+ _dashToasts.push({ id: ++_dashToastId, msg, type: ['success', 'error', 'info'].includes(body.type) ? body.type : 'info', at: Date.now() });+ while (_dashToasts.length > 20) _dashToasts.shift();+ sendJSON(res, { ok: true }); return;+ } catch (e) { sendJSON(res, { ok: false, error: e.message }); return; }+ }+ if (pathname === '/dashboard/action' && req.method === 'POST') {+ // v1.9.226 (John): make the dashboard actionable, not just observational β close all / close one+ // window / close one tab, and flash a taskbar button orange (or clear it), per-button and for+ // everything at once. These reuse the SAME internals the verbs use, so the dashboard can never+ // drift from verb behaviour. Flash is the "come look at this one" cue; unflash clears it without+ // stealing focus, which matters because this user alt-tabs away from foregrounded windows.+ let body = '';+ req.on('data', c => { body += c; });+ await new Promise(r => req.on('end', r));+ let a = {}; try { a = JSON.parse(body || '{}'); } catch (e) {}+ const act = a.action, sid = a.sessionId, tid = a.tabId;+ const done = (o2) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(o2)); };+ try {+ // Closing = close every tab in the session (the same effect pup_close_window has); the+ // session is then reaped by the existing zero-tab cleanup, so there is no second code path.+ const closeOne = async (id) => {+ const sess = sessions.get(id);+ if (!sess) return false;+ for (const t of [...(sess.tabs || [])]) { try { await t.page.close(); } catch (e) {} }+ try { sessions.delete(id); deleteSessionFile(id); } catch (e) {}+ return true;+ };+ if (act === 'close-all') {+ const ids = [...sessions.keys()];+ for (const id of ids) { try { await closeOne(id); } catch (e) {} }+ return done({ ok: true, closed: ids.length });+ }+ if (act === 'close-window' && sid) { const okc = await closeOne(sid); return done({ ok: okc, closed: sid }); }+ if (act === 'close-tab' && sid && tid) {+ const sess = sessions.get(sid);+ const tab = sess && (sess.tabs || []).find(t => t.tabId === tid);+ if (!tab) return done({ ok: false, error: 'tab not found' });+ try { await tab.page.close(); } catch (e) {}+ return done({ ok: true, closed: tid });+ }+ if (act === 'test-caption') {+ // v1.9.262 (John: "give them a test button so they can see the caption").+ // v2.0.294: same word-count scaling as real captions, so the test shows the truth.+ const _msg = 'This is what an AI-foreground caption looks like. Reason: "you clicked Test in pup settings"';+ const ms = captionMsFor(_msg);+ pupCaption(`${_msg} (${(ms / 1000).toFixed(1)}s)`, ms, 'pup-fg-reason', ms).catch(() => {});+ return done({ ok: true, ms });+ }+ if (act === 'import-scan') {+ // Dashboard "Import logins" picker β self-call the read-only discovery verb.+ try {+ const r = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_import_scan', args: {} }) });+ const j = await r.json().catch(() => ({}));+ return done({ ok: !!j.success, profiles: j.profiles || [], totalPasswords: j.totalPasswords || 0, accounts: j.accounts || [], pythonAvailable: !!j.pythonAvailable });+ } catch (e) { return done({ ok: false, error: e.message }); }+ }+ if (act === 'import-run') {+ // AD >= 1.9.220 ships desktop_decrypt_pup_credentials (the SYSTEM-elevated ABE decrypt,+ // adom/adom-bridge #51). It MUST be called LOCALLY: chrome.abCommand routes over AD's+ // direct API (127.0.0.1), which is exactly the loopback path AD requires β a relayed call is+ // refused with errorCode:relay_forbidden and pops no UAC. AD fires its OWN consent dialog ++ // UAC prompt (that IS the user's permission for the decrypt). Plaintext returns ONCE over+ // loopback; we merge it into the identity-tagged vault and DROP it. NEVER log a password or+ // echo it into another verb/response β this handler returns COUNTS ONLY.+ const sel = Array.isArray(a.profiles) ? a.profiles : [];+ const browsers = [...new Set(sel.map(p => p && p.browser).filter(Boolean))];+ if (!browsers.length) return done({ ok: false, error: 'no profiles selected' });+ console.log(`[import] decrypt requested for ${sel.length} profile(s) across ${browsers.join(', ')} β calling AD helper locally (consent + UAC will prompt on the desktop)`);+ let resp;+ try {+ resp = await decryptBrowserCreds({ browsers, include: ['passwords'] });+ } catch (e) { return done({ ok: false, error: 'decrypt helper call failed: ' + (e && e.message || e) }); }+ const payload = abPayload(resp) || {};+ const ec = payload.errorCode || (resp && resp.errorCode);+ if (ec) {+ const emap = {+ relay_forbidden: 'The decrypt helper refused a relayed call (pup must call it locally). This is a pup bug β please report it.',+ consent_declined: 'You declined the Adom Bridge consent dialog. Nothing was read.',+ elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',+ helper_missing: 'The signed decrypt helper is not installed β update or repair Adom Bridge.',+ };+ return done({ ok: false, errorCode: ec, message: emap[ec] || ('Decrypt failed: ' + ec) });+ }+ const pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];+ if (!payload.ok && !pwRows.length) return done({ ok: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') });+ // Honor per-profile deselection: keep rows whose (browser, profile) was selected; if the+ // profile keys don't line up with AD's naming, fall back to browser-level (never drop all).+ const wanted = new Set(sel.filter(p => p && p.profileDir).map(p => `${p.browser} ${p.profileDir}`));+ let rows = wanted.size ? pwRows.filter(r => r && wanted.has(`${r.sourceBrowser} ${r.sourceProfile}`)) : pwRows.slice();+ if (!rows.length) rows = pwRows.filter(r => r && browsers.includes(r.sourceBrowser));+ let merge;+ try { merge = await credentialVault.mergeImportedCredentials(rows); }+ catch (e) { return done({ ok: false, error: 'vault merge failed: ' + (e && e.message || e) }); }+ // DROP plaintext immediately β zero every returned password so nothing lingers in memory/response.+ try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; } catch (e) {}+ const st = payload.stats || {};+ console.log(`[import] vault merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped (AD stats profilesRead=${st.profilesRead} passwordsOk=${st.passwordsOk} passwordsFailed=${st.passwordsFailed})`);+ return done({ ok: true, imported: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,+ stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed, cookiesFailed: st.cookiesFailed },+ message: `Imported ${merge.added + merge.updated} logins into your adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''}), tagged by source account. pup can now sign in as you on those sites.` });+ }+ if (act === 'restore-defaults') {+ // v2.0.296 (settings-layout skill: the two-click Restore defaults footer). Wipe every+ // stored option so the D block's defaults apply; the user's IDENTITY (avatar + name)+ // is data, not an option, so it survives.+ const keep = {};+ for (const k of ['identityAvatarB64', 'identityName']) if (pupSettings()[k] !== undefined) keep[k] = pupSettings()[k];+ try { fs.mkdirSync(path.dirname(PUP_SETTINGS_PATH), { recursive: true }); fs.writeFileSync(PUP_SETTINGS_PATH, JSON.stringify(keep, null, 2)); } catch (e) {}+ _pupSettings = keep;+ console.log('[settings] restored defaults (dashboard dialog)');+ try { _startModeApply(pupSettings().aumidIcons !== 'on' ? 'plain' : (pupSettings().threadTiles === 'on' ? 'tiles' : 'aumid')); } catch (e) {}+ return done({ ok: true, settings: pupSettings() });+ }+ if (act === 'set-setting') {+ // v1.9.259: the dashboard settings dialog. Whitelisted keys only; values validated.+ const ALLOWED = {+ aumidIcons: ['on', 'off'], jumpLists: ['on', 'off'],+ fgCaption: ['on', 'off'], overlayBadges: ['on', 'off'], overlaySize: ['50', '66', '75', '100'],+ sleepRecovery: ['on', 'off'],+ reportIssues: ['on', 'off'], autoLogin: ['on', 'off', 'submit', 'fill'], captureLogins: ['on', 'off'], minimalTouch: ['on', 'off'],+ taskbarGrouping: ['split', 'grouped'],+ taskbarActivity: ['progress', 'flash', 'off'],+ aumidIconStyle: ['standard', 'category'],+ threadTiles: ['on', 'off'],+ jlViewTasks: ['on', 'off'], jlCloseAll: ['on', 'off'], jlDashboard: ['on', 'off'], jlAnnotate: ['on', 'off'], annCaptionBar: ['on', 'off'], toolbarExtension: ['on', 'off'], tbOwnerBadge: ['on', 'off'], tbSnipHotkey: ['on', 'off'], tbFloatyPencil: ['on', 'off'],+ notMineCaption: ['on', 'off'], ownerChip: ['on', 'off'],+ titleTag: ['on', 'off'], titlePrefix: ['on', 'off'],+ };+ const key = String(a.key || ''); let val = a.value;+ // v2.0.179: taskbarMode β the dialog's 3-way identity ladder (tiles | aumid | plain).+ // One key in the UI, mapped onto the two stored keys; full re-register + sweep so every+ // live window moves to the chosen mode immediately.+ if (key === 'taskbarMode') {+ if (!['tiles', 'aumid', 'plain'].includes(val)) return done({ ok: false, error: 'taskbarMode must be tiles|aumid|plain' });+ const pair = val === 'tiles' ? { aumidIcons: 'on', threadTiles: 'on' }+ : val === 'aumid' ? { aumidIcons: 'on', threadTiles: 'off' }+ : { aumidIcons: 'off', threadTiles: 'off' };+ savePupSettings({ ...pair, _chosen: { ...(pupSettings()._chosen || {}), aumidIcons: true, threadTiles: true } });+ console.log(`[settings] taskbarMode -> ${val} (dashboard dialog)`);+ _startModeApply(val); // v2.0.214: the apply-progress tracker owns the strip/sweep now+ return done({ ok: true, settings: pupSettings() });+ }+ if (key === 'floatyHitPx') {+ val = Math.max(6, Math.min(200, Number(val) || 24));+ } else if (key === 'fgCaptionMs') {+ val = Math.max(500, Math.min(12000, Number(val) || 3500)); // v2.0.294 slider goes to 8s; reading-time cap is 12s+ } else if (!ALLOWED[key] || !ALLOWED[key].includes(val)) {+ return done({ ok: false, error: 'unknown setting or value' });+ }+ const _ch = { ...(pupSettings()._chosen || {}), [key]: true };+ savePupSettings({ [key]: val, _chosen: _ch });+ console.log(`[settings] "${key}" -> ${val} (dashboard dialog)`);+ // v1.9.279: a taskbar-identity setting must take effect on EXISTING windows immediately,+ // not just future ones. Reconcile now re-stamps every live window to match (idempotent).+ if (['aumidIcons', 'taskbarGrouping', 'aumidIconStyle', 'ownerChip', 'titleTag', 'titlePrefix'].includes(key)) reconcileModel('setting ' + key).catch(() => {});+ if (['jumpLists', 'jlViewTasks', 'jlCloseAll', 'jlDashboard', 'jlAnnotate'].includes(key)) refreshAllJumplists().catch(() => {});+ // v2.0.197 (the 6-state proof exposed it): overlayBadges OFF only stopped FUTURE paints β+ // badges already on glass stayed until some unrelated churn wiped them, so the toggle+ // looked broken. OFF now actively clears every painted badge; ON repaints the fleet.+ if (key === 'overlayBadges') {+ // v2.0.214: a standalone overlay toggle gets its own one-step progress entry; when a+ // mode switch is mid-flight its tracker already covers the badge pass, so leave it be.+ let _obApply = null;+ if (!_modeApply || _modeApply.done || Date.now() - _modeApply.at > 20000) {+ _obApply = { at: Date.now(), token: ++_modeApplyTok, mode: 'overlay-' + val, steps: [{ label: val === 'on' ? 'Painting overlay badges' : 'Clearing overlay badges', state: 'run' }], done: false };+ _modeApply = _obApply;+ }+ applyOverlayBadgeSetting(val, _obApply).catch(() => {});+ }+ // v2.0.262: badge SIZE live-applies exactly like the badge on/off toggle β a size change+ // that only took effect on the next natural repaint would look broken (John flips it, the+ // taskbar does not move). Same repaint path, with its own progress entry.+ if (key === 'overlaySize') {+ let _szApply = null;+ if (!_modeApply || _modeApply.done || Date.now() - _modeApply.at > 20000) {+ _szApply = { at: Date.now(), token: ++_modeApplyTok, mode: 'overlaysize-' + val, steps: [{ label: val === '100' ? 'Repainting badges at full size' : 'Repainting badges at half size', state: 'run' }], done: false };+ _modeApply = _szApply;+ }+ applyOverlayBadgeSetting(pupSettings().overlayBadges === 'off' ? 'off' : 'on', _szApply).catch(() => {});+ }+ // v2.0.90: the pencil hit-area slider live-applies β re-inject the floaty on every open+ // page (the versioned guard includes the px value, so a change tears down and rebuilds).+ if (key === 'floatyHitPx' || key === 'tbFloatyPencil' || key === 'tbSnipHotkey') {+ (async () => { try { const fj = floatyJs(); for (const [, s6] of sessions) for (const t6 of (s6.tabs || [])) { try { await Promise.race([t6.page.evaluate(fj), new Promise(r => setTimeout(r, 1200))]); } catch (e) {} } } catch (e) {} })();+ }+ // v1.9.320: OFF clears the menu, ON/sub re-commits+ if (key === 'ownerChip') applyOwnerChipSetting(val === 'on').catch(() => {});+ return done({ ok: true, settings: pupSettings() });+ }+ if (act === 'fg' && sid) {+ // v1.9.243 (John: "add a 'bring to fg' and 'send to bg' button to each tab/window").+ // Self-call the REAL raise verb so every safety behaviour applies unchanged (caption with+ // the reason, standard reveal geometry, badge re-assert). A dashboard click IS the user+ // acting, so the foreground gate's reason is exactly that.+ try {+ if (tid) { await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_switch_tab', args: { sessionId: sid, tabId: tid } }) }); }+ const r = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_raise_os_window', args: { sessionId: sid, foregroundReason: 'you clicked "bring to fg" on this window in the pup dashboard' } }) });+ const j = await r.json().catch(() => ({}));+ return done({ ok: !!j.success });+ } catch (e) { return done({ ok: false, error: e.message }); }+ }+ if (act === 'bg' && sid) {+ try {+ const r = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_lower_os_window', args: { sessionId: sid } }) });+ const j = await r.json().catch(() => ({}));+ return done({ ok: !!j.success });+ } catch (e) { return done({ ok: false, error: e.message }); }+ }+ if (act === 'flash' && sid) { try { await flashViaAD(sid, { force: true }); } catch (e) {} return done({ ok: true }); }+ if (act === 'unflash' && sid) { try { await flashViaAD(sid, { stop: true, force: true }); } catch (e) {} return done({ ok: true }); }+ if (act === 'flash-all' || act === 'unflash-all') {+ const stop = act === 'unflash-all';+ let delivered = 0;+ for (const id of [...sessions.keys()]) { try { const r = await flashViaAD(id, stop ? { stop: true, force: true } : { force: true }); if (r) delivered++; } catch (e) {} }+ if (stop) {+ // v1.9.243: sweep by ENUMERATION, not bookkeeping. The tracked set is per-boot, so any+ // flash started before the last bridge restart was invisible to it (exactly what John+ // hit). Enumerate every visible window of every pup-managed Chrome process and stop each+ // one β no memory required, covers retitled/closed-session/pre-restart flashes alike.+ const swept = new Set();+ try {+ for (const h of await enumAllPupChromeHwnds()) {+ try { await chrome.abCommand('desktop_flash_window', { hwnd: h, stop: true }, { timeoutMs: 6000 }); swept.add(h); } catch (e) {}+ }+ } catch (e) {}+ for (const h of [..._flashedHwnds]) {+ if (!swept.has(h)) { try { await chrome.abCommand('desktop_flash_window', { hwnd: h, stop: true }, { timeoutMs: 6000 }); } catch (e) {} }+ _flashedHwnds.delete(h);+ }+ console.log(`[flash] clear-all swept ${swept.size} enumerated pup Chrome windows + tracked set`);+ return done({ ok: true, n: sessions.size, swept: swept.size });+ }+ return done({ ok: true, n: sessions.size, delivered });+ }+ if (act === 'regen-overlays') {+ // v1.9.298 (John: "give me a button to regenerate all overlay icons β a clean sweep so I can+ // verify you're doing it right, and it may help debug why they drift"). Runs the SAME+ // from-scratch computation used on load, for EVERY window, rewriting the badge PNG the+ // dashboard reads β so the taskbar and dashboard both refresh from one source. A full sweep+ // re-resolves each window handle (serialized wiggle) + refetches each favicon, which takes+ // longer than an HTTP round-trip, so RUN IT IN THE BACKGROUND and answer immediately. The+ // dashboard icons update live over SSE as each window repaints; the per-window drift report+ // (which windows were wrong until the sweep β i.e. which verb failed to re-trigger the calc,+ // John's hypothesis) goes to the bridge log AND is stored for the header to show.+ // v1.9.300 (John hit the button and "it didn't work"): a prior sweep left _regenBusy STUCK+ // true (its await hung on the deadlocked wiggle), so every later click returned "already+ // running" and did nothing. PREEMPT a wedged flag: only refuse if a sweep genuinely started+ // in the last 90s. And below, per-window timeouts + a finally guarantee the flag always+ // clears, so the button can never wedge again.+ const _regenAgeMs = (_lastRegen && _lastRegen.startedAt) ? (Date.now() - _lastRegen.startedAt) : Infinity;+ if (_regenBusy && _regenAgeMs < 90000) return done({ ok: true, started: false, alreadyRunning: true, swept: (_lastRegen && _lastRegen.inFlight) || 0 });+ const ids = [...sessions.keys()].filter(id => { const s = sessions.get(id); return s && (s.tabs || []).length; });+ _regenBusy = true;+ _lastRegen = { startedAt: Date.now(), inFlight: ids.length, done: false, swept: 0, painted: 0, changed: 0 };+ (async () => {+ const results = [];+ const favKind = (f) => (f && f.kind) || 'none';+ for (const id of ids) {+ const sess = sessions.get(id);+ if (!sess || !(sess.tabs || []).length) continue;+ const before = { hwnd: sess._hwnd || null, fav: favKind(sess._overlayFav) };+ // Clear every cache so this is a TRUE recompute, not a replay of the last cached pick,+ // and force handle re-resolution (a stale/wrong handle is the other half of the drift).+ sess._overlayFav = null; sess._faviconRetried = false; sess._noFavLogged = false;+ sess._hwnd = null; sess._appOverlay = false; sess._overlayEnforceAt = 0;+ let painted = false;+ // Per-window timeout: one wedged window can never stall the whole sweep (or the flag).+ try { painted = await Promise.race([applyAppOverlay(sess, id), new Promise(r => setTimeout(() => r(false), 20000))]); } catch (e) {}+ const after = { hwnd: sess._hwnd || null, fav: favKind(sess._overlayFav) };+ const changed = String(before.hwnd) !== String(after.hwnd) || before.fav !== after.fav;+ results.push({ sessionId: id, painted: !!painted, favKind: after.fav, hwndBefore: before.hwnd, hwndAfter: after.hwnd, favBefore: before.fav, changed });+ }+ try { await reconcileModel('regen-overlays (dashboard button)'); } catch (e) {}+ const changed = results.filter(r => r.changed).length;+ const painted = results.filter(r => r.painted).length;+ console.log(`[regen] DONE β swept ${results.length} windows, ${painted} painted, ${changed} corrected (hwnd or favicon changed on the sweep)`);+ for (const r of results.filter(x => x.changed)) console.log(`[regen] CORRECTED "${r.sessionId}": hwnd ${r.hwndBefore}->${r.hwndAfter}, fav ${r.favBefore}->${r.favKind}`);+ _lastRegen = { at: Date.now(), done: true, swept: results.length, painted, changed, results };+ })().catch((e) => { try { console.log('[regen] sweep error: ' + e.message); } catch (x) {} }).finally(() => { _regenBusy = false; });+ return done({ ok: true, started: true, swept: ids.length });+ }+ return done({ ok: false, error: 'unknown action' });+ } catch (e) { return done({ ok: false, error: e.message }); }+ }+ if (pathname === '/dashboard/icon.svg') {+ // v1.9.231 (John): the dashboard is an Adom APP, so per adom-ui-design it serves its OWN icon+ // rather than inheriting Chrome's. Clean 32x32 SVG, Adom teal, the two-leaf pup mark on a+ // rounded tile so it reads at 16px in a taskbar overlay and in a browser tab. Served as a real+ // asset (not a data: URI) so pup's favicon pipeline fetches it exactly like any other app icon,+ // which also means the dashboard window's own taskbar badge is produced by the same code path+ // it is built to display.+ // v1.9.237 (John: "why have you not created your own pup favicon yet using our adom ui design+ // skills?"). Designed against adom-ui-design: exact brand tokens β accent #00b8b1, on-accent+ // #05221f (the token for marks on a teal fill), bg #0d1117, border #30363d. Concept = two+ // overlapping browser-window cards (the live "cluster of windows" pup drives) with an SSE+ // heartbeat pulse across the front one (live monitoring). Bold fills + negative-space marks so+ // nothing hairlines at 16px, and it reads as its OWN app, distinct from the plain Adom leaf mark.+ // v1.9.250 (adom-ui-design section 9, John: "what does it say about headers and icons?"):+ // "Icons are monochrome white. No exceptions... and the favicon." The teal version violated the+ // master rule (brand color belongs only in marketing art). Same two-windows+pulse concept,+ // #e6edf3 marks on the dark ground tile.+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'public, max-age=3600' });+ // v2.0.339: the LINE DRAWING, and the reason it changed here is worth recording. This route+ // served the filled teal tile, which was correct while the dock opened the container-side+ // launch page (that page carried the line icon). 2.0.337 moved the dock to THIS page and the+ // tab reverted to the tile, because THE FAVICON IS A PROPERTY OF THE PAGE, NOT OF THE APP:+ // change which page the dock opens and you change the icon with it. John caught it on his+ // screen ("why is this its icon?") within minutes of the merge.+ // Same artifact as docs/img/dock-pup.svg (dockbar.json webview.favicon) so the tab favicon,+ // the header mark and the dock card are ONE drawing, per the icons skill. tests/check_icon.js+ // asserts the two stay byte-identical in geometry, because two copies of a drawing in two+ // ship artifacts is exactly how they drift.+ // stroke is HARDCODED #e6edf3, never currentColor: a favicon renders as an ISOLATED document+ // where currentColor resolves to black and the mark vanishes on the dark tab strip.+ res.end('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '+ + 'stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'+ + '<rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/>'+ + '<path d="M2.75 8.75h18.5"/>'+ + '<circle cx="12" cy="14.2" r="3.1"/>'+ + '</svg>');+ return;+ }+ if (pathname === '/dashboard/favicon') {+ // v1.9.225 (John): every TAB shows its own favicon in the dashboard as a visual cue. Cached per+ // tab URL so repeated SSE renders never re-fetch, and fetched through the page itself (cookies)+ // so authed app icons resolve β the same path that fixed the dashboard-v2 badge.+ const sid = url.searchParams.get('session') || '';+ const tid = url.searchParams.get('tab') || '';+ try {+ const sess = sessions.get(sid);+ const tab = sess && (sess.tabs || []).find(t => t.tabId === tid);+ if (!tab || !tab.page) { res.writeHead(404); res.end(); return; }+ let key = null; try { key = tab.page.url(); } catch (e) {}+ if (key && _tabFavCache.has(key)) {+ const c = _tabFavCache.get(key);+ res.writeHead(200, { 'Content-Type': c.mime || 'image/png', 'Cache-Control': 'public, max-age=60' }); // v1.9.311: cacheable so tab favicons don't refetch every SSE tick (the refresh blip)+ res.end(c.buf); return;+ }+ // v1.9.238: the rich resolver returns a rasterized PNG + the full acquisition STATE (format,+ // color-scheme mode, transparency, how it was fetched). Cache both β the meta feeds each tab's+ // dashboard row so John can see per tab exactly how its favicon was obtained.+ const rich = await Promise.race([+ resolveFaviconRich(tab.page),+ new Promise(r => setTimeout(() => r(null), 3500)),+ ]).catch(() => null);+ if (!rich || !rich.b64) {+ // v1.9.276 (John: "why 300 errors in pup-dashboard?"): a 404 here re-fired on EVERY 2s+ // SSE re-render (fresh img elements each render, Chrome does not cache 404s) and each one+ // logged a console error in the dashboard window - one unresolvable tab favicon filled the+ // whole 300-entry error buffer. Same doctrine as taskbar badges: never bare, never 404 -+ // serve the generic page glyph with a 200, and negative-cache so the expensive resolution+ // is not re-run every tick either.+ if (key) { _tabFavCache.set(key, { buf: Buffer.from(GENERIC_TAB_SVG), mime: 'image/svg+xml' }); _tabFavMeta.set(key, { meta: { kind: 'none', note: 'no favicon resolved; generic glyph shown' }, at: Date.now() }); }+ res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=60' });+ res.end(GENERIC_TAB_SVG); return;+ }+ const buf = Buffer.from(rich.b64, 'base64');+ if (key) {+ _tabFavCache.set(key, { buf, mime: 'image/png' }); if (_tabFavCache.size > 200) _tabFavCache.delete(_tabFavCache.keys().next().value);+ const { b64, ...metaNoBytes } = rich; _tabFavMeta.set(key, { meta: metaNoBytes, at: Date.now() }); if (_tabFavMeta.size > 300) _tabFavMeta.delete(_tabFavMeta.keys().next().value);+ }+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });+ res.end(buf); return;+ } catch (e) { res.writeHead(404); res.end(); return; }+ }+ if (pathname === '/dashboard/example') {+ // v1.9.262 (John: "show screenshots... so user knows what this is cuz they won't know what+ // aumid is"): illustrative images for the settings dialog, shipped with the bridge.+ try {+ const nm = (url.searchParams.get('n') || '').replace(/[^a-z0-9]/g, ''); // v2.0.301: names carry digits (obsize50)+ const f = path.join(__dirname, 'icons', 'settings-ex', nm + '.png');+ const buf = fs.readFileSync(f);+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });+ res.end(buf); return;+ } catch (e) { res.writeHead(404); res.end(); return; }+ }+ // v2.0.230: the dashboard re-renders every 2s and emits FRESH <img> elements, so these endpoints+ // were doing a synchronous file read + full ICO decode per window per 2 seconds. Cache the+ // decoded bytes keyed on path+mtime; the art only changes when a tile is re-rendered.+ if (pathname === '/dashboard/stampicon') {+ // v2.0.210 (John: "why aren't your dashboard icons consistent with the settings?"): the strip+ // must show what the button ACTUALLY wears. In tiles mode that is the THREAD TILE, not the+ // category icon this endpoint predates. Serve the tile when tiles mode owns the session.+ try {+ const _sid0 = url.searchParams.get('session') || '';+ const _s0 = sessions.get(_sid0);+ if (pupSettings().aumidIcons === 'on' && pupSettings().threadTiles === 'on' && _s0 && (_s0.owner || _s0._ownerThread)) {+ const _tp0 = path.join(THREAD_TILE_DIR, `${threadSlug(_s0.owner || _s0._ownerThread)}.ico`);+ const _inner0 = cachedIconPng(_tp0, icoLargestPng);+ // mtime-keyed cache upstream, so the browser may cache too β a re-render of the same tile+ // is byte-identical, and a NEW tile lands under a new mtime (and a fresh ?v= from the page).+ if (_inner0) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=60' }); res.end(_inner0); return; }+ }+ } catch (e) {}+ // v1.9.246 (John's audit): a STAMPED window's real taskbar button shows the icon the stamp+ // registered (pupIconPath's category tile), NOT the Chrome exe icon - the strip must match.+ try {+ const sid = url.searchParams.get('session') || '';+ const inner = cachedIconPng(pupIconPath(sid), icoLargestPng);+ if (!inner) { res.writeHead(404); res.end(); return; }+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=600' });+ res.end(inner); return;+ } catch (e) { res.writeHead(404); res.end(); return; }+ }+ if (pathname === '/dashboard/baseicon') {+ // v1.9.235 (John's audit): the taskbar strip must show the FULL button - the real browser icon+ // with the overlay in its corner - not the overlay blown up as the whole button. Extract the+ // actual icon from the browser exe that owns the profile's process (PowerShell, once, cached),+ // so if the machine runs Edge we show Edge, not a hardcoded Chrome.+ try {+ const prof = url.searchParams.get('profile') || '';+ const cache = path.join(require('os').tmpdir(), 'pup-badge-debug', `base-${prof.replace(/[^a-z0-9-]/gi,'_')}.png`);+ if (!fs.existsSync(cache)) {+ const be = browsers.get(prof);+ const pid = be && be.browser && (be.browser._detachedPid || (() => { try { return be.browser.process()?.pid; } catch (e) { return null; } })());+ if (!pid) { res.writeHead(404); res.end(); return; }+ const ps1 = path.join(require('os').tmpdir(), `_adom_baseicon_${process.pid}.ps1`);+ fs.writeFileSync(ps1, [+ 'Add-Type -AssemblyName System.Drawing',+ `$p = (Get-Process -Id ${Number(pid)} -ErrorAction Stop).Path`,+ '$i = [System.Drawing.Icon]::ExtractAssociatedIcon($p)',+ '$b = $i.ToBitmap()',+ `$b.Save('${cache.split(String.fromCharCode(92)).join(String.fromCharCode(92,92))}', [System.Drawing.Imaging.ImageFormat]::Png)`,+ ].join(String.fromCharCode(13,10)));+ await runPsHiddenPromise(ps1, { timeout: 8000 });+ }+ const buf = fs.readFileSync(cache);+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=600' });+ res.end(buf); return;+ } catch (e) { res.writeHead(404); res.end(); return; }+ }+ if (pathname === '/dashboard/sample-favicon') {+ // v2.0.204 (John: the Settings examples are CANONICAL β wiki page, dashboard app, ti.com,+ // mouser, digikey β so users learn what the modes mean regardless of what is open): serve the+ // bundled ground-truth favicons fetched from each vendor's own site (ti's is the red Texas+ // logo pup's live pipeline cannot fetch yet, #55 β the example is still correct art).+ try {+ const n = (url.searchParams.get('n') || '').replace(/[^a-z]/g, '');+ const map = { wiki: ['wiki.svg', 'image/svg+xml'], ti: ['ti.ico', 'image/x-icon'], mouser: ['mouser.ico', 'image/x-icon'], digikey: ['digikey.svg', 'image/svg+xml'] };+ if (map[n]) {+ const buf = fs.readFileSync(path.join(__dirname, 'icons', 'settings-samples', map[n][0]));+ res.writeHead(200, { 'Content-Type': map[n][1], 'Cache-Control': 'public, max-age=3600' });+ res.end(buf); return;+ }+ } catch (e) {}+ res.writeHead(404); res.end(); return;+ }+ if (pathname === '/dashboard/apply-progress') { sendJSON(res, { ok: true, apply: _modeApply }); return; }+ if (pathname === '/dashboard/sample-cat') {+ // The real registered category tiles (wiki signed-in / app / web) as PNG, for the aumid rows.+ try {+ const k = (url.searchParams.get('k') || '').replace(/[^a-z]/g, '');+ const name = { wikiin: 'pup-cat-wiki-in9.ico', wiki: 'pup-cat-wiki9.ico', app: 'pup-cat-app9.ico', web: 'pup-cat-web9.ico' }[k];+ if (name) {+ const inner = icoLargestPng(fs.readFileSync(path.join(_NOTCH_CACHE, name)));+ if (inner) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' }); res.end(inner); return; }+ }+ } catch (e) {}+ res.writeHead(404); res.end(); return;+ }+ if (pathname === '/dashboard/bestbadge') {+ // v2.0.201 (John: the matrix overlay cells showed blank circles): serve the composed badge of+ // a session whose favicon is REAL (kind page-favicon), so the Settings preview always has an+ // ACCURATE overlay example when any live window has one. 404s only when no such session exists.+ try {+ let best = null;+ for (const [sid0, s0] of sessions) {+ const k = s0 && s0._overlayFav && s0._overlayFav.kind;+ if (k === 'page-favicon' || k === 'app-provided') {+ const f0 = path.join(require('os').tmpdir(), 'pup-badge-debug', `${sid0}.png`);+ try { const st0 = fs.statSync(f0); if (!best || st0.mtimeMs > best.m) best = { f: f0, m: st0.mtimeMs }; } catch (e) {}+ }+ }+ if (best) { const buf = fs.readFileSync(best.f); res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' }); res.end(buf); return; }+ } catch (e) {}+ res.writeHead(404); res.end(); return;+ }+ if (pathname === '/dashboard/badge') {+ // Serve the exact overlay PNG pup last composed for a session, so the dashboard shows the REAL+ // badge rather than a re-render that might differ from what Windows was handed.+ const sid = url.searchParams.get('session') || '';+ try {+ const f = path.join(require('os').tmpdir(), 'pup-badge-debug', `${sid}.png`);+ const buf = fs.readFileSync(f);+ // v1.9.311: cacheable now that the URL is versioned by paint time (&v=). A repaint changes the+ // version β new URL β one refetch; between repaints the browser serves from cache (no blip).+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=3600' });+ res.end(buf); return;+ } catch (e) {+ // v2.0.94: a 1x1 transparent PNG instead of a 404. A freshly (re)opened window has no+ // composed overlay yet, and every such 404 landed in the dashboard page's OWN error+ // tracking β the red "N err" chip was counting its self-inflicted badge probes (John saw+ // "10 err" on a healthy dashboard). No badge is an empty image, not an error.+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });+ res.end(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64')); return;+ }+ }+ if (pathname === '/favicon.ico') {+ // v2.0.94: Chrome probes /favicon.ico on every bridge-served page; the 404 polluted the+ // dashboard's err chip. Serve the dashboard icon.+ res.writeHead(302, { Location: '/dashboard/icon.svg', 'Cache-Control': 'public, max-age=86400' });+ res.end(); return;+ }+ if (pathname === '/dashboard' || pathname === '/dashboard/') {+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });+ res.end(DASHBOARD_HTML);+ return;+ }+ if (pathname === '/identity') {+ // v2.0.58 (identity-card skill): the Adom identity payload for the toolbar Identity+ // extension's medium card. Reads ab's session (~/.adom/session.json), asks Carbon for+ // /user + /user/orgs, caches 10 min. Fields are passed through as-is; absent stays absent+ // (the card hides what it does not have β never invent fields).+ try {+ if (global.__pupIdCache && Date.now() - global.__pupIdCache.at < 600000) { sendJSON(res, global.__pupIdCache.v); return; }+ let sess = {};+ try { sess = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.adom', 'session.json'), 'utf8')); } catch (e) {}+ const token = sess.token || sess.session_token || sess.access_token || (sess.session && sess.session.token) || null;+ const su = sess.user || {};+ let user = null, orgs = [];+ if (token) {+ const hdrs = { Authorization: 'Bearer ' + token };+ try { const r1 = await fetch('https://carbon.adom.inc/user', { headers: hdrs }); if (r1.ok) user = await r1.json(); } catch (e) {}+ try { const r2 = await fetch('https://carbon.adom.inc/user/orgs', { headers: hdrs }); if (r2.ok) { const j2 = await r2.json(); orgs = Array.isArray(j2) ? j2 : (j2.orgs || []); } } catch (e) {}+ }+ const u = user || su || {};+ const v = {+ ok: true,+ displayName: u.display_name || u.displayName || u.name || null,+ username: u.name || u.username || null,+ email: u.email || null,+ avatarUrl: u.avatar_url || u.avatarUrl || null,+ avatarB64: pupSettings().identityAvatarB64 || null,+ isAdmin: !!(u.is_admin || u.isAdmin),+ orgs: (orgs || []).map(o => ({ name: o.name, displayName: o.display_name || o.name, avatarUrl: o.avatar_url || null })),+ profile: 'adom-you',+ };+ global.__pupIdCache = { at: Date.now(), v };+ sendJSON(res, v);+ } catch (e) { sendJSON(res, { ok: false, error: e.message }); }+ return;+ }+ if (pathname === '/health') {+ const sessionList = [];+ for (const [id, s] of sessions) {+ let pageUrl = null;+ try { pageUrl = s.page.url(); } catch (e) {}+ // v2.0.51: owner + view ride /health so the toolbar extension can badge tabs per owning+ // thread and label its view toggle without a heavier verb round-trip.+ // v2.0.100: field renamed owner -> ownerThread ON PURPOSE. Stale extension workers on+ // warm Chromes (which never reload their staged extensions) paint the retired initials+ // badge from this row's owner field every 30s; renaming it starves them within one sweep+ // without touching any thread's running windows. New extension readers use ownerThread.+ sessionList.push({ sessionId: id, url: pageUrl, active: id === activeSessionId, ownerThread: s.owner || s._ownerThread || null, view: s._wikiView || null, authed: (s._wikiAuthed === true ? true : (s._wikiAuthed === false ? false : null)) });+ }+ sendJSON(res, {+ status: 'ok',+ bridge: 'puppeteer',+ ...statusCore(),+ port: PORT,+ toolbar: { enabled: pupSettings().toolbarExtension !== 'off', ownerBadge: pupSettings().tbOwnerBadge !== 'off', snipHotkey: pupSettings().tbSnipHotkey !== 'off', floaty: pupSettings().tbFloatyPencil !== 'off' },+ browserCount: browsers.size,+ sessions: sessionList,+ chrome: (chrome && chrome.readiness) ? chrome.readiness() : { ready: false, initializing: true },+ });+ return;+ }++ // POST /command β generic command dispatch+ if (pathname === '/command' && req.method === 'POST') {+ const body = JSON.parse(await readBody(req));+ const command = body.command;+ const args = body.args || {};+ res._adomCommand = command; // v1.0.16: lets sendJSON inject this verb's _hint/related+ // v2.0.362: stash the ARGS too. sendJSON's decorator only ever received (obj, verb), so any+ // notice that needs to know WHICH SESSION a call was about had no way to find out: the+ // response body of a verb like pup_screenshot carries no sessionId, and `args` was not in+ // scope. Two notices shipped against that blind spot and neither could ever fire (the #80+ // restart notice and the #79-adjacent viewport warning). This is the missing wire.+ res._adomArgs = args;+ // v2.0.14 (John): GUIDANCE SHIM for the 2026-08 browser_* -> pup_* clean break. The break is+ // deliberate β nothing executes under the old names, no aliases β but the bare ab-side error+ // ("no installed bridge claims this prefix") MISLED broken callers into reinstalling pup+ // instead of renaming their call, and the CLI's --help / `help <old-verb>` paths were dead ends+ // too. So pup re-claims the browser_ prefix SOLELY to answer with the exact fix. The break+ // still happens (callers must migrate), but now it teaches the new verb instead of stranding+ // the AI. nbrowser_* (the extension) is unaffected β it does not start with "browser_".+ // Delete this shim once the fleet has migrated.+ if (typeof command === 'string' && command.startsWith('browser_')) {+ const newVerb = command.replace(/^browser_/, 'pup_');+ console.log(`[rename-shim] refused '${command}' -> told caller to use '${newVerb}' (thread: ${req.headers['x-adom-caller-thread'] || 'unknown'})`);+ sendJSON(res, {+ success: false,+ error: `'${command}' was RENAMED to '${newVerb}' in the 2026-08 browser_* -> pup_* cutover (clean break, no aliases β this call did NOT execute). Re-issue the SAME call as '${newVerb}' with identical args.`,+ errorCode: 'verb_renamed',+ renamedTo: newVerb,+ _hint: `The whole verb family moved one-for-one: browser_<x> is now pup_<x> (e.g. browser_open_window -> pup_open_window, browser_screenshot -> pup_screenshot). Update your caller/skill to the pup_* names permanently β the old names are gone for good. Full catalog: pup_describe. Docs: https://wiki.adom.inc/adom/pup-bridge`,+ }, 410);+ return;+ }+ // v1.9.101 (John's ask) β CAPTURE WHO IS ASKING. AD stamps these on every bridge request it+ // relays (caller-identity contract, AD >=1.9.180); pup was throwing them away. A pup window+ // effectively IS an AI thread, and John runs ~20 at once, so surfacing "which thread owns this+ // window" is what lets him associate a window on his taskbar with the conversation that made it.+ // Attribution only β self-reported, unverifiable, never gate anything on it.+ const _callerThread = req.headers['x-adom-caller-thread'] || null;+ const _callerContainer = req.headers['x-adom-caller-container'] || null;+ const _callerReason = req.headers['x-adom-caller-reason'] || null;+ // v1.9.118: make the URL + caller container available to the shared response path so the+ // call-time URL lint works on every render verb, including ones that don't echo the url back.+ try { res._lintUrl = args && args.url; res._callerContainer = _callerContainer; } catch (e) {}++ // v2.0.19 (John: "the kicad thread just took over a pup window owned by the nb thread...+ // force it to only work with its own pup window"): HARD OWNERSHIP, keyed on the CALLER+ // IDENTITY ab already forces on every verb. The old advisory gate compared only the OPTIONAL+ // args.owner field ("anonymous callers pass"), and gated just navigate/close β so any thread+ // that simply didn't declare an owner could drive, retab, or close another thread's window.+ // Now: every window-mutating verb that names an explicit sessionId is REFUSED when that+ // session belongs to a different AI thread, unless takeover:true β which is allowed only for+ // when the user explicitly asked, is logged loudly with both thread names, and reassigns+ // ownership so the trail stays honest. Read verbs (screenshot, list, status, errors) stay+ // open so threads can observe each other's windows without disturbing them. Runs BEFORE the+ // ownership-record block so a refused caller never pollutes lastDrivenBy attribution.+ // v2.0.28 (John: "we moved to ai-thread name and dropped sessionId"): a session-targeting verb+ // with sessionId OMITTED resolves to the CALLER THREAD's own window β first the derived+ // ai-<thread-slug> window, else the single window the thread owns under an explicit name.+ // No match is a teaching error. The old activeSessionId fallback (= whichever window ANY+ // thread touched last) is gone from every session-targeting verb.+ // v2.0.58 (John clicked Annotate in the toolbar popup and nothing happened): a USER caller+ // (toolbar popup) can only know its tab's URL, and matching only the session's MAIN page+ // failed on any other tab. Resolve by ANY tab of any session when _tabUrl rides the args.+ if (args && !args.sessionId && args._tabUrl && USER_CALLERS.has(_callerThread) && SESSION_TARGETING_VERBS.has(command)) {+ const tu = String(args._tabUrl).split('#')[0];+ outer: for (const [k3, s3] of sessions) {+ for (const t3 of (s3.tabs || [])) {+ let u3 = null; try { u3 = t3.page.url(); } catch (e) {}+ if (u3 && u3.split('#')[0] === tu) { args.sessionId = k3; args._resolvedTabId = t3.tabId; break outer; }+ }+ }+ }+ // v2.0.130 (#47, the rename campaign): `window` is the NEW public name for the per-window+ // label formerly called sessionId β needed only when one thread runs several windows.+ // Accepted everywhere sessionId is; sessionId stays as a silent back-compat alias forever.+ if (args && !args.sessionId && args.window != null && String(args.window).trim()) args.sessionId = String(args.window).trim();+ // Stash addressing context for the central response enrichment in sendJSON (#47): responses+ // emit window + aiThread alongside sessionId, and a caller that spelled out an id its own+ // thread-name would have resolved gets one teaching hint.+ try { res._pupCallerThread = _callerThread; res._pupExplicitSid = !!(args && args.sessionId); } catch (e) {}+ // v2.0.103 (John: "dispense with sessionId β the thread name is the handle"): explicit+ // thread-name addressing. aiThreadName resolves to that thread's window on any targeting verb.+ if (args && !args.sessionId && (args['ai-thread'] || args.aiThread || args.aiThreadName) && SESSION_TARGETING_VERBS.has(command)) {+ const tn = String(args['ai-thread'] || args.aiThread || args.aiThreadName); // v2.0.106: "ai-thread" β the CLI flag's exact spelling (John); camelCase forms kept as silent aliases+ const derivedT = threadDefaultSessionId(tn);+ if (sessions.has(derivedT)) args.sessionId = derivedT;+ else {+ const theirs = [...sessions.entries()].filter(([, s2]) => (s2.owner || s2._ownerThread) === tn).map(([k]) => k);+ if (theirs.length === 1) args.sessionId = theirs[0];+ else if (theirs.length) { sendJSON(res, { success: false, errorCode: 'ambiguous_session', error: `Thread "${tn}" owns ${theirs.length} windows (${theirs.join(', ')}) β pass sessionId to pick one.` }, 404); return; }+ else { sendJSON(res, { success: false, errorCode: 'no_session_for_thread', error: `No pup window for thread "${tn}".`, _hint: 'pup_my_windows {} lists a thread\'s windows; pup_open_window creates yours keyed to your thread name.' }, 404); return; }+ }+ }+ if (args && !args.sessionId && _callerThread && !USER_CALLERS.has(_callerThread) && SESSION_TARGETING_VERBS.has(command)) {+ const derived = threadDefaultSessionId(_callerThread);+ if (sessions.has(derived)) args.sessionId = derived;+ else {+ const mine = [...sessions.entries()].filter(([, s2]) => (s2.owner || s2._ownerThread) === _callerThread).map(([k]) => k);+ if (mine.length === 1) args.sessionId = mine[0];+ else {+ sendJSON(res, {+ success: false,+ errorCode: mine.length ? 'ambiguous_session' : 'no_session_for_thread',+ error: mine.length+ ? `Your thread owns ${mine.length} windows β name the sessionId you mean (${mine.join(', ')}).`+ : `Your thread ("${_callerThread}") has no pup window yet.`,+ liveSessions: [...sessions.keys()],+ _hint: `pup no longer implies the "active" window (that was whichever window ANY thread touched last β issue #41). ${mine.length ? 'Pass the sessionId explicitly.' : 'Open your own window first: pup_open_window (omit sessionId and your thread gets its own).'}`,+ }, 404);+ return;+ }+ }+ }++ // v2.0.28 (#41, the nb thread's catch): a verb naming an EXPLICIT sessionId that no longer+ // exists must ERROR. It used to silently fall back to activeSessionId β ANOTHER thread's+ // window β so after a bridge restart, a thread driving its dead id navigated and screenshotted+ // the pup-wiki thread's window while every response LOOKED ok. That is also what masqueraded+ // as the "kicad took over nb's window" incident. Nothing mutates, nothing screenshots, the+ // caller learns exactly what happened and how to recover.+ if (args && typeof args.sessionId === 'string' && args.sessionId+ && command !== 'pup_open_window' && !USER_CALLERS.has(_callerThread) && !sessions.has(args.sessionId)) {+ const live = [...sessions.keys()];+ sendJSON(res, {+ success: false,+ errorCode: 'session_not_found',+ error: `Session "${args.sessionId}" does not exist (it may have died in a bridge restart).`,+ liveSessions: live,+ _hint: `Nothing was done β pup no longer falls back to the active window (that silently drove ANOTHER thread's window; issue #41). Re-create your window with pup_open_window (reusing the same sessionId is fine)${live.length ? `. Live sessions right now: ${live.join(', ')}` : ''}.`,+ }, 404);+ return;+ }++ {+ // v2.0.30 (John: the "Close ALL" jump-list task stopped working): a click on a pup window's+ // OWN taskbar jump-list is the USER operating their OWN window β always sanctioned. The+ // per-window tasks fire with caller aiThread 'user-taskbar-menu'; that caller bypasses the+ // ownership gate entirely (it is not another AI thread trying to steal a window). And+ // pup_close with no sessionId is the GLOBAL close-all (close every window across every+ // thread, by design) β never gate it, never resolve it to the active window.+ const _isUserTaskbar = USER_CALLERS.has(_callerThread);+ const _isGlobalClose = command === 'pup_close' && !(args && args.sessionId);+ // v2.0.153 (found building Thread Tiles): pup_configure sets GLOBAL settings (threadTiles,+ // taskbarGrouping, aumidIconStyle, β¦), not a per-window action β so it must NEVER be gated on+ // the active window. It was in this list, so changing any setting while ANOTHER thread's+ // window happened to be active was refused with session_owned_by_another_thread. Removed.+ const OWNER_GATED = !_isUserTaskbar && !_isGlobalClose && (MUTATING_VERBS.has(command)+ || ['pup_close_tab', 'pup_close_window', 'pup_close', 'pup_focus_window',+ 'pup_raise_os_window', 'pup_lower_os_window', 'pup_alert_window', 'pup_wiki_set_view',+ 'pup_record_start', 'pup_record_stop'].includes(command));+ // With sessionId OMITTED, a mutating verb used to resolve to activeSessionId (another thread's+ // window). Gate the RESOLVED target β but NEVER manufacture activeSessionId for a global close+ // or a user-taskbar action.+ const _gateSid = (args && args.sessionId) || (OWNER_GATED && command !== 'pup_open_window' ? activeSessionId : null);+ if (OWNER_GATED && _gateSid && _callerThread) {+ const _gs = sessions.get(_gateSid);+ const _owner = _gs && (_gs.owner || _gs._ownerThread);+ if (_gs && _owner && _owner !== _callerThread) {+ if (args.takeover) {+ // v2.0.20: ownership TRANSFERS only on an open_window claim. A one-shot sanctioned+ // action (reload/navigate/close to repair or hand off) must not steal the window β+ // v2.0.19 reassigned on every takeover, so a repair reload would have locked the+ // original owner out of its own window. The action is still logged with both names.+ console.log(`[takeover] "${_gateSid}": thread "${_callerThread}" acted on "${_owner}"'s window via ${command} (takeover:true β sanctioned only when the user asked${command === 'pup_open_window' ? '; ownership TRANSFERRED' : '; ownership unchanged'})`);+ _gs._lastTakeoverBy = _callerThread; _gs._takenOverAt = Date.now();+ if (command === 'pup_open_window') { _gs._takenOverFrom = _owner; _gs.owner = _callerThread; _gs._ownerThread = _callerThread; }+ } else {+ console.log(`[own] REFUSED ${command} on "${_gateSid}"${args.sessionId ? '' : ' (RESOLVED from activeSessionId β name your own sessionId)'} β owner "${_owner}", caller "${_callerThread}"`);+ let _curUrl = null; try { _curUrl = _gs.page?.url() || null; } catch (e) {}+ sendJSON(res, {+ success: false,+ errorCode: 'session_owned_by_another_thread',+ error: `Session "${_gateSid}"${args.sessionId ? '' : ' (the ACTIVE window β you omitted sessionId)'} belongs to AI thread "${_owner}" β your thread ("${_callerThread}") cannot ${command} it.`,+ sessionId: _gateSid, owner: _owner, requestedBy: _callerThread, currentUrl: _curUrl,+ _hint: `STOP β this window is owned by "${_owner}"; driving it would break that thread's work in front of the user. Open your OWN window instead: pup_open_window WITHOUT a sessionId gives your thread its own window (keyed on your --ai-thread name), or pass a unique task-prefixed sessionId. Only pass takeover:true if the USER explicitly told you to take this window over β every takeover is logged with both thread names.`,+ }, 409);+ return;+ }+ }+ }+ }++ // Record ownership on the session this verb targets: the FIRST thread to touch a session owns+ // it (that is the window's identity for the user); every later call refreshes lastDrivenBy so a+ // handoff is still visible rather than silently rewriting the owner.+ try {+ const _sid = args && args.sessionId;+ const _sess = _sid && sessions.get(_sid);+ if (_sess && _callerThread) {+ if (!_sess._ownerThread) { _sess._ownerThread = _callerThread; _sess._ownerContainer = _callerContainer; }+ _sess._lastThread = _callerThread;+ _sess._lastContainer = _callerContainer;+ }+ } catch (e) {}++ // v1.8.23: AUTO-FLASH + agent-update tracking for state-mutating verbs on an EXISTING+ // session (pup_open_window flashes from inside its own handler, once the session+ // exists). evaluate is read-only iff {readOnly:true}. {silent:true} per verb or the+ // session default suppresses. Runs BEFORE the handler β signals "the agent is+ // touching this window" regardless of the verb's outcome.+ if (MUTATING_VERBS.has(command) && command !== 'pup_open_window' && command !== 'pup_open_tab') {+ const isReadOnlyEval = (command === 'pup_eval' || command === 'pup_evaluate') && args.readOnly;+ if (!isReadOnlyEval) {+ const sid = args.sessionId || activeSessionId;+ const s = sid && sessions.get(sid);+ if (s) { recordAgentUpdate(s, command, args, args.tabId || s.activeTabId); scheduleAutoFlash(s, { silent: !!args.silent }); scheduleOverlayRefresh(sid); }+ }+ }+ // v1.9.301: close_tab is NOT in MUTATING_VERBS but it changes the active tab (hence the favicon),+ // so it must re-trigger the overlay calc too. Centralize it here so no tab/window/favicon verb is+ // missed β the drift John called out.+ if (command === 'pup_close_tab' || command === 'pup_close_window') { try { scheduleOverlayRefresh(args.sessionId || activeSessionId); } catch (e) {} }++ // v1.9.70 β AUDIT EVERY INCOMING VERB. John: "do you even log all the actions against you?"+ // Fair β the log recorded what the bridge DID (park/identity/overlay) but never WHAT WAS ASKED+ // of it, so a question like "what foregrounded this window?" was unanswerable after the fact.+ // One compact line per verb, with the session and the few args that change behaviour.+ if (command && command !== 'bridge_log_read') {+ const _a = [];+ if (args.sessionId) _a.push(`session=${args.sessionId}`);+ if (args.url) _a.push(`url=${String(args.url).slice(0, 60)}`);+ if (args.foreground) _a.push('foreground=true');+ if (args.foregroundReason) _a.push('foregroundReason=YES');+ if (args.view) _a.push(`view=${args.view}`);+ if (args.tabId) _a.push(`tab=${args.tabId}`);+ if (args.owner) _a.push(`owner=${args.owner}`);+ console.log(`[verb] ${command}${_a.length ? ' ' + _a.join(' ') : ''}`);+ }++ // v1.9.57: light the taskbar progress bar while a thread is driving THIS window. Broader than+ // MUTATING_VERBS above on purpose β a screenshot or an eval is still an agent working in that+ // window, and that is exactly what the user wants to see. Status/polling verbs are excluded+ // (see PASSIVE_VERBS) or every window would read as permanently busy.+ if (command && command.startsWith('pup_') && !PASSIVE_VERBS.has(command)) {+ try { markSessionBusy(args.sessionId || activeSessionId); } catch (e) {}+ }++ switch (command) {+ // v1.0.15: self-describe β AD's "Verbs" tab POSTs {command:'pup_describe'}+ // and renders this verbatim: one entry per documented verb with+ // {name, summary, input, output, timeoutSeconds, example}. (pup_ is this+ // bridge's primary prefix, so AD calls it pup_describe.)+ case 'pup_describe': {+ // v1.0.16: each entry merges its VERB_META (hint/related/pitfalls) so AD's+ // Verbs tab renders the full self-doc β the reference shape for all bridges.+ const V = (name, summary, input, timeoutSeconds, example) => {+ const m = VERB_META[name] || {};+ // v2.0.130 (#47, aiThread-first): the catalog is where callers learn addressing, so fix+ // it in ONE place β any terse/stale sessionId doc ("required str" predates zero-id+ // addressing and is simply wrong now) is rewritten to lead with omission + the new+ // `window` name. Entries with rich bespoke wording (pup_open_window etc.) are left as+ // written. sessionId remains a silent back-compat alias everywhere.+ try {+ if (input && typeof input.sessionId === 'string' && input.sessionId.length < 45) {+ input.sessionId = 'optional str β OMIT IT: zero-id addressing resolves your --ai-thread name to your own window. Pass a window label only when one thread runs several windows (`window` is the new name for this param; sessionId is the legacy alias)';+ }+ } catch (e) {}+ return { name, summary, input, output: { success: 'bool' }, timeoutSeconds,+ statusVerb: null, longRunning: false, example: example || {},+ hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] };+ };+ sendJSON(res, { success: true, bridge: 'puppeteer',+ // v2.0.383 (#59/#64): the measured-progress contract, per the Bridge SDK.+ progressContract: { statusVerb: 'pup_readiness', activeKey: 'active', slowVerbs: ['pup_open_window', 'pup_prewarm', 'pup_use'], blockedByPhases: ['consent', 'dialog'], blockedOnUser: { errorCode: 'blocked_on_user', rule: 'ab BRIDGE_SDK 2.1.30: a verb parked on a HUMAN never bare-timeouts; it answers this shape', stillRunning: 'true = the wait continues in the background: re-call the SAME verb with the SAME args to re-attach (no second prompt) or watch pup_readiness.active[]; false = the human must act first, then re-issue the verb', inVerbWaitSec: Math.round(consent.inVerbMs / 1000), promptLifetimeSec: consent.totalSec }, timings: pupTimingsSummary() },+ verbs: [+ V('pup_my_windows', 'START HERE for window management: everything YOUR thread owns β windows, tabs, urls, titles, state β with ready-to-run next steps. Zero args. You NEVER need an hwnd, desktop_find_window, or desktop_screenshot_window for a pup window: pup keys every window to its owning --ai-thread name and resolves ids for you β omit sessionId in any pup verb to target your own window, or pass "ai-thread" to address a thread by name (the CLI\'s --ai-thread flag, same spelling). pup_screenshot {} screenshots it; pup_reload {} refreshes it.', { 'ai-thread': 'optional str β inspect another thread\'s windows (read-only listing); default = the calling thread. The CLI flag\'s exact spelling; aiThread also accepted' }, 10, {}),+ V('pup_open_window', 'Open a generic pup window β a fresh isolated Chrome/Edge process (never your real signed-in browser), fully drivable via CDP. ALWAYS opens in the BACKGROUND (does not steal focus; never disturbs the user\'s main work; still fully screenshottable/drivable) UNLESS you pass foreground:true β do that ONLY when the user explicitly wants to watch. Pass width+height (+ optional x,y, screen px) to place it at a specific size/position for screenshot framing β this NO LONGER foregrounds it (sizing stays in the background too; default unsized is maximized). profile defaults to the durable shared \'adom-you\' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway. OWNERSHIP: sessionIds are shared across ALL AI threads β use a task-prefixed sessionId + owner:"<your-task>" so pup can refuse other threads stealing your window (reusing an existing id with a new url navigates it away; declared cross-owner reuse errors with session_owned_by_another_thread unless takeover:true). (For the user\'s REAL Chrome/Edge, use the Adom extension\'s nbrowser_* verbs.)', { sessionId: 'optional str β OMIT IT (recommended): your thread gets its OWN window keyed on your --ai-thread name; the first open creates it, later opens become TABS in it, and no other thread can touch it. Pass an explicit id only when you need a SEPARATE window (task-prefix it, e.g. "mytask-web")', url: 'optional str', profile: 'optional str β a named durable jar; DEFAULT is the shared "adom-you" profile (signed-in state shared across windows). isolated:true overrides to a throwaway', isolated: 'optional bool β private throwaway profile: fresh-login tests, acting as a different user. REASON-GATED (v2.0.101): requires isolationReason or the open is refused β every window defaults to the shared adom-you profile', isolationReason: 'required WITH isolated:true β why this window must NOT ride the shared adom-you profile (min 10 chars; logged, auditable). webSecurity:false and wikiView:public carry their own built-in reason', webSecurity: 'optional false β dev escape hatch for cross-origin eval; FORCES isolation so insecure flags never touch the credentialed profile', owner: 'optional str β your thread/task label; lets pup protect this window from other threads', takeover: 'optional bool β claim a window owned by another thread (only when the user explicitly asks)', foreground: 'optional bool β request showing the window on the user\'s screen. GATED (v1.8.70): requires foregroundReason too, else the open succeeds but stays BACKGROUND with foregroundDenied set. Only when the USER explicitly asked to watch.', foregroundReason: 'required WITH foreground:true β quote/paraphrase what the user said that justifies putting a window on their screen (min 10 chars; logged + recorded in lastAgentUpdate, auditable)', width: 'optional int px β sizes for screenshot framing, stays background', height: 'optional int px', x: 'optional int px', y: 'optional int px', wikiView: 'optional "authed"|"public" β for wiki.adom.inc URLs. THREE distinct cases: (1) OMIT = the shared adom-you profile, LOGGED IN as the user if they have signed into the wiki (the usual case); this is NOT anonymous. (2) "public" = the SERVER renders the page exactly as a signed-out visitor sees it via the ?view=public param (adom/wiki#126: request user nulled, byte-identical to logged-out, private pages 404) β the window STAYS on the shared profile, session untouched, no isolated jar; use this to verify a publish did not leak private source. (3) "authed" = the explicit logged-in view (30-day SSO; first use may need a one-time login β response says wikiLoginNeeded). To compare "what I see" vs "what the world sees", open one "authed" (or omit) AND one "public" tab β do NOT rely on omit for the public view.', appName: 'optional str β the APP this window is driving (e.g. "shotlog"); shown in the hover tooltip', appIconB64: 'optional base64 PNG β override the app icon worn in the taskbar overlay. Usually UNNECESSARY: pup auto-fetches the page\'s own <link rel="icon"> favicon (the adom-ui-design standard artifact every app must serve) and wears it automatically. An app-looking URL with NO favicon gets a reprimand hint.', updateNote: 'optional str β a short "what I did" note, surfaced back in list_tabs/list_windows as lastAgentUpdate so the user knows what changed', autoFlash: 'optional bool (default true) β mutating verbs auto-flash this window\'s taskbar (debounced, never steals focus) so the user notices the agent touched it; pass false to make this window silent by default', silent: 'optional bool β suppress the auto-flash for just this call' }, 60, { sessionId: 'mytask-web', owner: 'mytask', url: 'https://example.com', width: 960, height: 540 }),+ V('pup_open_tab', 'Open a new tab in an EXISTING session/window (returns {tabId, tabCount, activeTabId} β no need to diff list_tabs). This is the right verb for multi-URL work: ONE window with many tabs (one taskbar button), not many windows. The window stays in the background; the tab auto-flashes the taskbar (debounced) so the user notices. The USER can click the window\'s taskbar button at any time to view it β pup never pulls it back down.', { sessionId: 'required str', url: 'optional str', updateNote: 'optional str β short "what I did" note surfaced in list_tabs as lastAgentUpdate', silent: 'optional bool β suppress the auto-flash for this call' }, 30, { sessionId: 'work', url: 'https://example.com' }),+ V('pup_close_tab', 'Close a tab.', { sessionId: 'required str', tabId: 'optional str (default active)' }, 15, { sessionId: 'work' }),+ V('pup_close_window', 'Close a window/session (its tabs go too).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+ V('pup_window_info', 'Everything about ONE pup window as JSON β owner AI thread, container, profile, tabs, age, last agent action, and the window\'s hwnd (for desktop_screenshot_window when you need the browser chrome in frame). For an AI caller this is a PURE DATA query: nothing is drawn on the user\'s screen. Visual surfaces are user-initiated only β a jump-list click shows the native taskbar caption, a toolbar-popup click shows the in-page floaty card. showCaption:true opts an AI caller into the caption ONLY when the user asked to have the window identified on screen. Read-only, never owner-gated.', { sessionId: 'optional str', showCaption: 'optional bool β draw the ~12s taskbar caption too; ONLY when the user asked to see which window this is' }, 15, {}),+ V('pup_wiki_set_view', 'Flip a WIKI pup window between the logged-in and public view IN PLACE: every wiki tab reloads with the server-side ?view=public param appended (public) or stripped (authed) β adom/wiki#126 renders the request exactly as a signed-out visitor while the session stays untouched, so there is no relaunch, no cookie wipe, no profile switch (the legacy jar relaunch survives only as a fallback for servers that ignore the param). This is the callback for the per-window taskbar jump-list task (WINDOWS ONLY) β a USER-initiated action. AI threads should NOT call this; pass wikiView:"authed"|"public" on pup_open_window instead.', { sessionId: 'required str', view: 'required "authed"|"public"|"toggle" β toggle flips whatever the window currently shows (the Alt+Shift+V hotkey form)' }, 45, { sessionId: 'wiki-bmv080', view: 'authed' }),+ V('pup_navigate', 'Navigate the active tab to a URL.', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://example.com' }),+ V('pup_back', 'History back.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+ V('pup_forward', 'History forward.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+ V('pup_progress', 'Paint the Windows taskbar PROGRESS BAR on your window during a LONG multi-stage task (crawling a site, login->search->download loops, batch harvesting datasheets/STEP files) so the user sees work happening at a glance. mode:"indeterminate" (pulsing) to start, mode:"value" with value:0-100 as stages complete, mode:"off" when done or aborted (always clear!). Optional note:"12/40 datasheets" shows on the button hover tooltip. YOUR judgment: a single quick page open does NOT warrant a progress bar. See the pup-progress skill.', { mode: '"indeterminate" | "value" | "off"', value: 'optional 0-100 (mode:"value")', note: 'optional short stage label for the hover tooltip', sessionId: 'optional β defaults to your thread\'s own window' }, 8, {}),+ V('pup_reload', 'Reload the active tab.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),+ V('pup_click', 'Click an element (by selector or coords). Selector clicks LOCATE the element once then click by its center coordinates (fast on huge/animated DOMs, where page.click used to hit the 15s wall). Response includes method. If a React toggle/like/heart does NOT change state after a normal click, that control ignores trusted clicks β retry with domClick:true to fire a synthetic element.click().', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int', domClick: 'optional bool β dispatch a synthetic element.click() instead of a trusted CDP click; for React controls that ignore trusted clicks', clickCount: 'optional int 1-3', button: 'optional "left"|"right"|"middle"' }, 20, { sessionId: 'work', selector: 'button#go' }),+ V('pup_assisted_click', 'RESPECTFUL real-cursor click for a surface pup CDP cannot drive (a NATIVE OS dialog, or a cross-origin FedCM "Sign in with Google" account chooser that rejects every in-page click). Do NOT hand-roll raise+desktop_click for these β use THIS. It: captions the reason + toasts BEFORE touching the screen; foregrounds; then WAITS, detecting if the user alt-tabbed away and holding (captioning "Adom is waiting β click back when ready") until the user is actually on the window; then runs the TIGHT real-cursor hover->click that FedCM accepts (interleaving any screenshot/eval between raise and click makes the click silently no-op); captions each step. Times out as user_not_ready clicking NOTHING if they never return. First take a desktop_screenshot_window of the session to read the target\'s WINDOW-pixel x,y.', { sessionId: 'required str', x: 'required int β WINDOW-image pixel of the target (from desktop_screenshot_window)', y: 'required int', reason: 'required str β WHY you must click (shown to the user as a caption)', label: 'required str β WHAT you are clicking (e.g. "your Google account")', maxWaitMs: 'optional int β how long to wait for the user (default 120000)' }, 130, { sessionId: 'adsk', x: 900, y: 379, reason: 'clicking your Google account to finish signing into Autodesk', label: 'your Google account' }),+ V('pup_type', 'Type text into a field via real CDP keystrokes (isTrusted). Reads the value back and returns valueLanded β real keystrokes only register in a FOREGROUNDED window, so if valueLanded:false the field stayed empty (the silent no-op): use pup_fill (background-safe) for a non-React input, or pup_login {realKeystrokes:true} for a login form. Pass clear:true to REPLACE instead of appending.', { sessionId: 'required str', text: 'required str', selector: 'optional str', clear: 'optional bool β empty the field first (replace, not append)' }, 20, { sessionId: 'work', selector: '#user', text: 'adominc1', clear: true }),+ V('pup_fill', 'Set a field\'s value in the BACKGROUND (no foreground, no keystrokes): writes .value via the native setter and fires input+change so React/Vue/Angular controlled inputs pick it up. The fix for pup_type\'s silent no-op on a backgrounded window. Reads the value back and returns valueLanded. For React-MASKED password fields that reject programmatic writes, use pup_login {realKeystrokes:true} instead.', { sessionId: 'required str', selector: 'required str', text: 'required str' }, 20, { sessionId: 'work', selector: '#email', text: '[email protected]' }),+ V('pup_press_key', 'Press a key, optionally with modifier chords held (Ctrl+A, Ctrl+V, Shift+Tab).', { sessionId: 'required str', key: 'required str', modifiers: 'optional array β any of ["Control","Shift","Alt","Meta"] (aliases ctrl/cmd/command/option accepted); held down around the key press' }, 15, { sessionId: 'work', key: 'a', modifiers: ['Control'] }),+ V('pup_NATIVE_INPUT_WARNING', 'β READ BEFORE USING AD\'s desktop_click / desktop_type / desktop_press_key ON A PUP WINDOW. Native OS input does NOT target a window β it goes to WHATEVER WINDOW HAS OS FOCUS at that instant. This user runs many AI threads and alt-tabs away from a foregrounded window almost immediately. If you fire native input without warning them, your keystrokes land in THEIR document (this happened: a username was typed into the user\'s own window). REQUIRED: notify_user toast + desktop_caption FIRST, tell them explicitly "don\'t type or click until I say done", wait until the window is genuinely foreground, re-verify immediately before EACH event, and caption when done. pup_assisted_click already does all of this β use it. And ALWAYS prefer CDP input (pup_click/pup_type/pup_hover): it needs no foreground and disturbs nobody. A native verb returning ok:true means the event was dispatched to the OS, NOT that it reached your window.', {}, 0, {}),+ V('pup_set_viewport', 'Pin the page viewport (CDP device-metrics) to an exact size for deterministic screenshots β OR clear that pin. β A pin PERSISTS: it survives navigation and hard reload, and while set the window stops tracking OS resizing (a human resizing it sees no reflow, which looks exactly like an app layout bug). ALWAYS clear it when done. Reported as emulatedViewport in pup_status / pup_list_windows.', { sessionId: 'required str', width: 'int β required unless reset', height: 'int β required unless reset', deviceScaleFactor: 'optional number (default 1)', reset: 'optional bool β CLEAR the override and track the OS window again (width/height not needed)' }, 15, { sessionId: 'work', reset: true }),+ V('pup_install_toolbar', 'Status/trigger for the toolbar extension, which AUTO-INSTALLS invisibly at every profile launch (transient pipe-launch + CDP Extensions.loadUnpacked; persists in the profile). Running profiles pick it up on their next launch; cold profiles install immediately.', { profile: 'optional, default adom-you' }, 30, {}),+ V('pup_floaty_click', 'TEST SEAM: simulate a user click on the floaty pencil (fires the same host click handler a mouse does; the sweep then starts annotate within ~4s). Built for the recurring floaty ralph β assert the annotate overlay appears after calling this.', { sessionId: 'required window', tabId: 'optional tab' }, 10, {}),+ V('pup_annotate', 'Start the HUMAN annotation overlay on a window\'s active tab (pen/arrow/box/text toolbar injected in-page; also on every window\'s taskbar jump list as "Annotate this page"). Two capture modes the user can also flip in the toolbar: "auto" (default) captures the instant the mouse lifts β the one-red-circle case; "multi" accumulates shapes (color swatches: red/blue/yellow one click, more in a flyout) and captures ONCE on Done. Done captures a PNG AND leaves the drawing in the DOM, so the owning thread\'s next pup_screenshot shows exactly what the user marked β check session.lastAnnotation via pup_list_windows. action:"cancel" removes the overlay.', { sessionId: 'required window', mode: 'optional "auto"|"multi"', action: 'optional "cancel"' }, 15, {}),+ V('pup_dashboard', 'Open the LIVE pup management dashboard in its OWN window (its own profile, so its own taskbar icon). Shows every window pup is driving as taskbar button -> window(s) -> tabs with each tab favicon, owning AI thread, foreground/background + the reason given to foreground it, highFps lease, uptime, and WHY each overlay icon was chosen. Live over SSE. Reuses the one window on repeat calls; backgrounded by default like any pup window.', {}, 30, {}),+ V('pup_deps', 'Report pup dependency health (wiki issue #15): depsPresent, selfInstalledThisBoot, lastError. Use this to answer "is pup broken?" without reading spawn logs.', { }, 10, {}),+ V('pup_hover', 'Move the pointer over a target WITHOUT clicking (reveals tooltips / hover menus). Glides the visible Adom cursor there too.', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int', intent: 'optional str β narrated on the cursor label', steps: 'optional int (default 8)' }, 15, { sessionId: 'work', selector: '#menu' }),+ V('pup_maximize', 'Maximize (or restore/fullscreen/minimize) a pup window via CDP β no AD desktop verb required. Does NOT foreground it.', { sessionId: 'required str', state: 'optional "maximized"|"normal"|"minimized"|"fullscreen" (default maximized)' }, 15, { sessionId: 'work' }),+ V('pup_set_identity', 'Set the Adom identity shown in the floating owner chip on every pup window (avatar + name). Chrome\'s own profile chip cannot take a custom local image (Google sign-in only), so the user\'s Adom avatar lives here instead. Pass a pre-circle-cropped PNG as base64. Applies to live windows immediately + persists.', { avatarB64: 'optional str β base64 PNG (ideally ~48px, circle-cropped); the leading data: prefix is stripped if present', name: 'optional str β display name shown in the chip tooltip', enabled: 'optional bool β turn the whole owner chip on/off', clear: 'optional bool β remove the stored avatar + name' }, 15, { name: 'John Lauer', avatarB64: '<base64 png>' }),+ V('pup_screenshot', 'Screenshot the page (PNG; fullPage optional). NOTE: fullPage only covers DOCUMENT-level scroll β on SPAs that scroll inside a nested container it returns just the viewport + a nestedScroll hint; use pup_screenshot_element or pup_scroll for those.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),+ V('pup_screenshot_element', 'Screenshot ONE element, resolved by CSS selector OR visible text (pierces shadow DOM), scrolled into view first. The clean way to grab a single card/widget on a component-heavy page. Text matches the TIGHTEST text node β to capture the enclosing CARD, add closest (e.g. {text:"Downloadβ¦", closest:".download-card"}); with closest, an ambiguous phrase still lands on the real widget (skips prose that has no such ancestor). Returns base64 + filePath like pup_screenshot.', { sessionId: 'required str', selector: 'CSS selector (or use text)', text: 'visible text to match (or use selector)', closest: 'optional selector β climb to the nearest ancestor matching it (the card container)', padding: 'optional int px around the element', deep: 'optional bool β pierce shadow DOM (default true)' }, 30, { sessionId: 'work', selector: '.download-card' }),+ V('pup_scroll', 'Scroll the ACTUAL scrolling element (nearest ancestor whose scrollHeight>clientHeight, NOT just window β window.scrollTo no-ops on nested-scroll SPAs). Scroll by delta {dy,dx} OR to an element {toSelector|toText} (pierces shadow DOM). Returns {scrollElement, scrollTop, scrollHeight, clientHeight} so you can confirm it landed.', { sessionId: 'required str', dy: 'optional int px (down+/up-)', dx: 'optional int px', toSelector: 'optional CSS selector to scroll to', toText: 'optional visible text to scroll to', block: 'optional start|center|end (for scroll-to; default center)', behavior: 'optional auto|smooth' }, 30, { sessionId: 'work', toText: 'Download' }),+ V('pup_set_viewport', 'Set the page viewport (CDP device-metrics) deterministically, independent of the OS window size β fixes a too-small innerWidth so fullPage/element shots render at a known width. width+height required.', { sessionId: 'required str', width: 'required int px', height: 'required int px', deviceScaleFactor: 'optional number (default 1)' }, 15, { sessionId: 'work', width: 1280, height: 900 }),+ V('pup_evaluate', 'Evaluate JS in the page and return the result β THE verb for reading DOM state / asserting on a page (same handler as pup_eval). Add frame:"<name|url-substring|index>" to run inside an iframe.', { sessionId: 'required str', expression: 'required str β aliases accepted: expr, script, js, code', frame: 'optional str β run inside this iframe instead of the top document' }, 30, { sessionId: 'work', expression: 'document.title' }),+ V('pup_fetch_url', 'Fetch a URL\'s content via the browser (cookies/session intact).', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://api.example.com' }),+ V('pup_download', 'Download a file THROUGH THE BROWSER, exactly as the user clicking it would β real session, real cookies, real anti-bot cookies β and Chrome saves it to the Downloads folder. This is the right verb for vendor files (datasheets, KiCad/Altium libraries, STEP 3D models) that a server-side fetch/curl can never get past the site\'s bot wall. Pass selector to click the actual download button (authentic), or url to force-download via an in-page a[download]. Confirms completion via Chrome\'s download events and returns the ACTUAL saved path + byte count + file magic. For a gated file, log in on the window first (pup_login).', { sessionId: 'optional str', selector: 'optional str β the real download control to click', url: 'optional str β download this URL via an in-page a[download] click', saveAs: 'optional str β filename override', saveDir: 'optional str β default the user\'s Downloads folder' }, 100, { sessionId: 'work', selector: 'button.download' }),+ V('pup_input_dispatch', 'Low-level input (mouse/keyboard) at coords β beats some overlays.', { sessionId: 'required str', type: 'required str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', type: 'mousePressed', x: 100, y: 200 }),+ V('pup_record_start', 'Start recording the page (video).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+ V('pup_record_stop', 'Stop recording; returns the saved file.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),+ V('pup_record_status', 'Recording status for a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),+ V('pup_record_list', 'List recordings.', {}, 10, {}),+ V('pup_recorder_open', 'Open pup\'s Chrome screen-share recorder HUD (WebM; consent = the visible HUD). Canonical name (pup-bridge#94).', { reason: 'str (required)' }, 20, {}),+ V('pup_screen_record_start', 'Start a whole-screen recording through Chrome screen-share (WebM VP9, system audio + mic). Canonical name; for a background no-browser MP4 of one window use ab-core desktop_record_window_start.', { reason: 'str (required)', confirmDesktopNotTabRecording: 'true', fps: 'int', audio: 'bool', maxDurationMs: 'int' }, 20, {}),+ V('pup_screen_record_stop', 'Stop the Chrome screen recording; returns the .webm path.', {}, 30, {}),+ V('pup_screen_record_status', 'Status of the Chrome screen recorder + any active clip.', {}, 10, {}),+ V('pup_screen_record_list', 'List completed Chrome screen recordings on disk.', {}, 10, {}),+ V('pup_recorder_close', 'Close the Chrome screen-recorder HUD (auto-stops a clip).', {}, 15, {}),+ V('desktop_record_start', 'ALIAS of pup_screen_record_start (routes to ab-core once its native recorder lands, adom-bridge#143).', { outPath: 'optional str' }, 20, {}),+ V('pup_readiness', 'Readiness + which browser pup will drive (installed Chrome/Edge, or cached Chrome for Testing) + the full candidate list and current default. READ-ONLY; never downloads.', {}, 10, {}),+ V('pup_prewarm', 'Install Chrome for Testing WITHOUT opening a window, and pin it as the default. Use when you specifically want CfT rather than the machine\'s installed Chrome/Edge.', { wait: 'optional bool β block until installed', setDefault: 'optional bool (default true) β pin CfT as the default' }, 240, {}),+ V('pup_use', 'Pin (or clear) the browser pup drives by default: chrome | edge | cft | auto (or an explicit executablePath). "cft" fetches Chrome for Testing then pins it. With {browser:"chrome", install:true} it DOWNLOADS & INSTALLS real Google Chrome on an Edge-only box, then pins it β SILENT on a normal PC; on a locked-down box (VM/managed device) it needs a Windows approval, so the bridge pops a native "click YES on the UAC" notification and, if it expires, re-notifies with a one-tap "Approve now" (all autonomous β no AI turn). Poll pup_readiness.chromeStablePhase: installing β awaiting_uac β ready. Persists across restarts; launch still spawn-verifies + falls back.', { browser: '"chrome" | "edge" | "cft" | "auto"', install: 'optional bool β with browser:"chrome", install real Chrome if missing', elevate: 'optional "auto"(default)|true|false β auto: quiet install, prompt only if it needs admin; false: never prompt; true: go straight to the prompt', executablePath: 'optional β pin an exact browser binary' }, 240, {}),+ V('pup_lower_os_window', 'Send a pup window to the BACKGROUND (behind the user\'s windows) without stealing focus β still fully drivable. The default for open; use this to re-hide a window you foregrounded.', { sessionId: 'optional str' }, 15, {}),+ V('pup_raise_os_window', 'Bring a pup window to the FOREGROUND (user\'s screen). GATED (v1.8.70): REFUSED with foreground_reason_required unless you pass foregroundReason β a quote/paraphrase of what the user said that justifies it (min 10 chars; logged, recorded in lastAgentUpdate, auditable). Allowed ONLY when the user EXPLICITLY must SEE it (a demo, recording, "show me"). It STEALS focus β to just signal "I updated this window", use pup_alert_window (or the auto-flash), NOT raise. After the show-me: pup_lower_os_window to return it to the background. Alias: pup_focus_window. IMPORTANT: if you are foregrounding in order to CLICK a native dialog or a cross-origin FedCM/Google account chooser, do NOT raise-then-click yourself β use pup_assisted_click, which waits for the user to actually be on the window (alt-tab aware) and narrates via captions, so you never act while they are not ready.', { sessionId: 'optional str', foregroundReason: 'REQUIRED β why the user asked to see this window' }, 15, { foregroundReason: 'user said "show me the wiki page"' }),+ V('pup_login', 'Fill (and optionally submit) an HTML login form in the CURRENT tab, in the BACKGROUND β no Chrome autofill dropdown, no foregrounding, React/Vue-safe. Creds from the keychain vault (matched on the tab URL; captured automatically as you log in, or stored via credential_set / save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched pages AUTO-FILL on load (_autoLogin on the response). SUBMIT IS REASON-GATED: submit:true requires submitReason AND makes you confirm the live domain (the refusal regurgitates the exact host so a lookalike/phishing site is caught before the secret is sent). pup_configure {autoLogin:"off"} disables auto-fill; {captureLogins:"off"} stops auto-capture.', { sessionId: 'optional str β omit to target the window your own thread owns', username: 'optional str', password: 'optional str β with username, fills these instead of the vault', submit: 'optional bool β submit after filling; REQUIRES submitReason', submitReason: 'required WITH submit:true β why you are confident the CURRENT domain is the correct, intended site to send these credentials to (min 10 chars; the refusal shows you the live host to confirm; logged, auditable)', save: 'optional bool β with explicit creds, store them (OS keychain) so next time is automatic', host: 'optional str β override the vault host key when saving', realKeystrokes: 'optional bool β type with REAL key events instead of a background .value-set. Needed for React-controlled MASKED fields (Gusto/Keycloak SSO) that ignore .value and would submit an empty password. It briefly FOREGROUNDS the window (real keystrokes only register on-screen). Use when a normal fill returned filledPassword:false / reactMaskedFieldSuspected, or when the login-recipe for the site says so.' }, 20, { sessionId: 'work', submit: true, submitReason: 'confirmed the tab is on the real vendor domain I intended to sign into' }),+ V('pup_credentials', 'List saved logins (host + username only; passwords are in the OS keychain and NEVER returned). pup captures these automatically as you sign into sites and auto-fills them in the background on return visits.', {}, 10, {}),+ V('pup_credential_reveal', 'Reveal ONE saved login (username + password) to a LOCAL caller on the desktop, gated by a sticky consent toast the user must click. One host per call, never bulk. A relayed (cloud container) call is refused with relay_forbidden: from a cloud thread use pup_login instead, which signs the site in inside a pup window without the secret ever leaving the vault. The secret is returned ONCE and must never be logged or echoed.', { host: 'required str β the site, e.g. "github.com"', reason: 'required str (min 10 chars) β shown to the user on the consent toast; say what you will do with it' }, 130, { host: 'github.com', reason: 'signing into GitHub in my own browser to open a PR for the user' }),+ V('pup_card_set', 'Store a payment card in the OS-keychain card vault under a nickname. Ask the USER for the details β never harvest them from a page. Validates Luhn + expiry + cvv. No verb ever returns the number or CVV afterward.', { nickname: 'required str, e.g. "adom-amex"', number: 'required str', expiry: 'required MM/YY', cvv: 'required 3-4 digits', name: 'optional cardholder name' }, 15, {}),+ V('pup_cards', 'List saved payment cards: nickname, brand, last4, expiry, cardholder only. The number and CVV never leave the keychain.', {}, 10, {}),+ V('pup_card_delete', 'Remove a saved card from the keychain + index by nickname.', { nickname: 'required str' }, 10, {}),+ V('pup_fill_card', 'Fill a saved card into the checkout page of a pup window WITHOUT the digits ever reaching you: pup types them into the card fields (walking payment-processor iframes like Stripe) and returns booleans. EVERY fill is gated by a desktop approval toast naming merchant, amount and last4 β the user authorizes the specific charge in the moment. Pass merchant+amount so the toast is honest. Allowed from cloud threads (the human click is the gate). After ok:true, screenshot to verify, then submit.', { nickname: 'required str', merchant: 'recommended str β shown on the approval toast', amount: 'recommended str, e.g. "$350"', window: 'optional (defaults to your thread\'s window)' }, 130, { nickname: 'adom-amex', merchant: 'venturedallas.com', amount: '$350' }),+ V('pup_import_browser_logins', 'Seed pup\'s vault from the user\'s OWN saved Chrome/Edge/Brave passwords, so pup can sign in as them on sites they already use (vendor CAD portals, etc). Runs the SYSTEM-elevated decrypt LOCALLY β the user approves ONE consent dialog + ONE Windows UAC prompt on the desktop (a cloud/relay caller cannot do this; only pup, a local bridge, can ask). Merges the identity-tagged rows into the keychain vault and DROPS the plaintext. Returns COUNTS ONLY. hostFilter narrows the import to matching hosts (surgical, e.g. ["snapeda","digikey"]); omit to import everything saved. Pair with pup_import_scan (read-only preview of how many logins exist).', { browsers: 'optional str[] subset of ["chrome","edge","brave"] (default: all found)', hostFilter: 'optional str or str[] β only import logins whose host contains one of these (surgical import)' }, 190, { hostFilter: ['snapeda', 'ultralibrarian', 'digikey'] }),+ V('pup_reap_profiles', 'Delete LEAKED isolated profile directories β the iso-* throwaway jars minted by isolated:true opens (and by wikiView:public before 2.0.132, when the public view moved to the server-side ?view=public param) whose window is long gone (they used to pile up, 100s per machine). Only removes an iso-* dir that no live session or session file owns, that no Chrome is holding, and that is >15 min old; durable jars (adom-you, named profiles) are NEVER touched. Runs automatically every 15 min too. dryRun:true previews the list without deleting.', { dryRun: 'optional bool β preview which iso-* profiles would be removed, delete nothing' }, 20, { dryRun: true }),+ V('pup_move_to_workspace', 'MOVE this pup window\'s page into the user\'s Hydrogen workspace: the active tab\'s URL is handed to Hydrogen (ah) on this machine via its Browser Picker (webview by default, honoring the user\'s per-domain choice), then the pup window closes β its owner initiated the move. keepWindow:true keeps the pup window open too. Needs Hydrogen running locally (hydrogen_unreachable otherwise; the window is left untouched on failure). Cloud Hydrogen (hc) is not yet supported.', { sessionId: 'optional str', keepWindow: 'optional bool β hand the URL over but keep the pup window open' }, 25, {}),+ V('pup_uninstall', 'DESTRUCTIVE β cleanly remove pup\'s WHOLE footprint from this machine: the HKCU AppUserModelId Adom.Pup registry keys, all staged extensions, ALL pup profiles + sessions, the OS-keychain credential vault, pup-settings.json, the badge-debug temp dir, and the jump-list launcher. ALWAYS preview first with dryRun:true (removes nothing, returns the plan). To actually remove, pass confirm:"uninstall". keepCredentials:true preserves the saved-login vault; keepProfiles:true preserves profiles/sessions. Chrome-for-Testing + firewall rules are SHARED and left alone unless removeChrome:true / removeFirewallRules:true. Closes every window + quits every browser first to release profile locks. (The bridge itself is managed by Adom Bridge β remove pup from Bridge to stop it re-seeding.)', { dryRun: 'optional bool β preview the removal plan without removing anything (do this first)', confirm: 'required str "uninstall" to actually remove', keepCredentials: 'optional bool β keep the saved-login vault', keepProfiles: 'optional bool β keep pup profiles + sessions', removeChrome: 'optional bool β also delete the Chrome-for-Testing install (shared; default keep)', removeFirewallRules: 'optional bool β also remove the CfT firewall rules (needs admin)' }, 60, { dryRun: true }),+ V('pup_alert_window', 'Nudge the user about a pup window WITHOUT stealing focus β on Windows it flashes the taskbar button; on macOS (no taskbar, and no API to bounce another app\'s Dock tile) it unhides the window at its z-position and posts an AD toast whose click focuses it. Same contract on both, so call it identically; just never tell a Mac user to look for a flashing icon. NOTE (v1.8.23): mutating verbs already AUTO-FLASH (debounced, one per ~5s burst per window) so the user always knows the agent touched a window β use this for an explicit end-of-task "it\'s ready" nudge on top of that. STRONGLY prefer this (or the auto-flash) over pup_raise_os_window, which steals focus and can leak a mid-typed keystroke into the page.', { sessionId: 'optional str', stop: 'optional bool β CLEAR the highlight instead of flashing' }, 15, {}),+ V('pup_render_html', 'Render a local HTML string in the tab via page.setContent β UTF-8-correct (a data: URL mojibakes accented chars without an explicit charset). Great for previewing generated markup without a temp file. Auto-flashes the window.', { sessionId: 'required str', html: 'required HTML string', waitUntil: 'optional (domcontentloaded default)', updateNote: 'optional note for lastAgentUpdate', silent: 'optional bool β suppress the auto-flash' }, 30, { sessionId: 'work', html: '<h1>CafΓ©</h1>' }),+ // Session/tab/window management + probes (were routed but undocumented).+ V('pup_status', 'List all open pup sessions + their tabs (NOT a readiness check β use pup_readiness for that).', {}, 10, {}),+ V('pup_list_windows', 'List every open session/window with its tabs.', {}, 10, {}),+ V('pup_eval_frame_note', 'pup_eval accepts frame:"<name|url-substring|index>" to run inside an IFRAME (login forms usually live in one). Cross-origin frames work. Omit for the top document; a bad value returns frame_not_found WITH the list of frames in the tab.', { sessionId: 'required str', expr: 'required str', frame: 'optional str' }, 20, { sessionId: 'work', expr: 'document.querySelector("#userId").value', frame: 'logonbox' }),+ V('pup_switch_window', 'Make a session the active one (for verbs that default to active).', { sessionId: 'required str' }, 10, { sessionId: 'work' }),+ V('pup_list_tabs', 'List the tabs in a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),+ V('pup_switch_tab', 'Activate a tab WITHIN its session/window (idempotent β safe to call when the tab is already active). Does NOT raise a background window to the user\'s screen: the tab becomes the window\'s visible tab wherever the window is, and the taskbar auto-flash tells the user something changed. Also self-heals the window (re-park if a bug ever stranded it off-screen) and refreshes the Adom taskbar badge β good citizenship for long-lived shared windows.', { sessionId: 'required str', tabId: 'required str', updateNote: 'optional str', silent: 'optional bool' }, 10, { sessionId: 'work', tabId: 'tab-2' }),+ V('pup_screenshot_full_res', 'FULL-resolution page screenshot (NOT downscaled β for saving to disk, NOT for Read: it can exceed Claude\'s image limit). Use pup_screenshot for AI-readable shots.', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),+ V('pup_errors', 'Console errors + failed network requests for a session/tab (clears by default).', { sessionId: 'required str', tabId: 'optional str', clear: 'optional bool (default true)' }, 10, { sessionId: 'work' }),+ V('pup_wait', 'Wait N ms (or for a selector) before the next step.', { sessionId: 'required str', ms: 'optional int', selector: 'optional str' }, 60, { sessionId: 'work', ms: 500 }),+ V('pup_rescan', 'Recover orphaned sessions: reconnect dropped CDP sockets + rebuild sessions from window titles. Pass adoptOrphans:true to also adopt untagged tabs.', { adoptOrphans: 'optional bool' }, 30, {}),+ V('pup_close', 'Close ALL pup sessions + windows at once (destructive). NOTE: `{}` here does NOT mean "the active session" the way it does on other session-scoped verbs β with no args this closes EVERY window, across every AI thread on the machine, with no confirmation. To close ONE window use pup_close_window {sessionId}. Pass {sessionId} here only if you genuinely mean that one session.', {}, 20, {}),+ V('pup_configure', 'Get/set user-facing pup preferences (persisted across bridge updates). taskbarGrouping: "split" (default β one taskbar button PER WINDOW, each badged with its own tab count; best for telling AI threads apart) | "grouped" (all pup windows stack under ONE "Adom Pup" button, badge shows window count; tidy taskbar). Applies to live windows immediately. Ask the USER which they prefer before changing it β this is their taskbar. WINDOWS ONLY: macOS has no taskbar, so the setting is stored but INERT there; the reply carries platform + taskbarSupported so branch on those, not on this text.', { taskbarGrouping: 'optional "split"|"grouped"', taskbarActivity: 'optional "progress"|"flash"|"off" β how pup signals it is working on a window, on that window\'s taskbar button: a determinate progress bar sized to the operation\'s running-average time (default), the old orange flash, or nothing. Replaces the former openFlash/autoFlash/agentActivity toggles.', reportIssues: 'optional "on"|"off" β pup\'s bug-report invitation (_reportIssues), on by default', autoLogin: 'optional "on"|"off" β auto-fill a vault-matched login form on page load (fill only, never submits), on by default', overlaySize: 'optional "50"|"100" β how big the favicon badge is drawn on the taskbar button. "50" (the DEFAULT) draws it at half size so it stays clear of the thread tile\'s bottom line of text; "100" is the older, larger badge' }, 15, { taskbarGrouping: 'grouped' }),+ V('pup_describe', 'This list β the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}),+ ] });+ return;+ }+ // --- Session management commands ---+ case 'pup_open_window': {+ const _openT0 = Date.now(); const _openId = 'open-' + _openT0.toString(36);+ _inflightOpens.set(_openId, { t0: _openT0, sessionId: args && (args.window || args.sessionId) || null, thread: _callerThread || null }); // v2.0.383 (#59/#64)+ // v1.9.195 (John: "you're supposed to pre-emptively catch stupid urls the ai tells you to+ // open"). REFUSE a URL that CANNOT work, before spending a window on it. A container path+ // handed to pup (file:///home/...) or a file:// path that does not exist on the desktop is+ // a guaranteed ERR_FILE_NOT_FOUND β previously pup opened a window, rendered Chrome's error+ // page, and said NOTHING, so the calling AI had to guess (and often misdiagnosed it).+ {+ const _pre = urlLint(args.url, res && res._callerContainer);+ if (_pre && (_pre.code === 'url_file_container_path' || _pre.code === 'url_file_not_found')) {+ console.log(`[urlLint] REFUSED open of "${args.url}" β ${_pre.code}`);+ sendJSON(res, { success: false, errorCode: _pre.code, error: _pre.message, _urlLint: _pre.message, urlLintCode: _pre.code });+ return;+ }+ }+ // v1.9.147: sessionId is OPTIONAL when the caller identifies its thread β the thread gets+ // its own keyed window (see the thread-default block below). Only the fully anonymous+ // no-id no-thread case is refused, down there, with a teaching error.+ if (!args.sessionId && !_callerThread) {+ sendJSON(res, { success: false, errorCode: 'caller_identity_required',+ error: 'No sessionId AND no caller identity. Either pass a sessionId, or (better) identify your thread β adom-bridge-cli --ai-thread "<your thread>" ... β and pup gives your thread its OWN window automatically: first open creates it, later opens become tabs in it, and no other thread can touch it.' });+ return;+ }+ // ββ Cold-start gate (v1.9.90: NATIVE-FIRST) β READ-ONLY detect; never blocks when drivable ββ+ // pup drives the user's INSTALLED browser by default β Chrome, else Edge β in a FRESH isolated+ // profile with the noBrowserIdentityFlags that kill Chrome's sign-in / "set up a work profile"+ // merge dialog at launch. That combination is exactly as clean as CfT (the Aditya incident was+ // fixed by those flags, not by the binary β CfT shows the SAME dialog without them). So this+ // gate only blocks when there is NOTHING drivable at all (no Chrome, no Edge, no cached CfT):+ // then it fetches Chrome-for-Testing in the BACKGROUND as the last resort and returns the+ // pollable {installing:true}. That case is rare (Edge ships on every Windows PC). Chrome for+ // Testing is still available by explicit pin β pup_use {browser:"cft"} β for a+ // managed/enterprise box whose policies interfere with automation.+ // Existing/live sessions are exempt (their browser is already running).+ {+ const rd = chrome.readiness(); // detect-only; never downloads+ const _def = (() => { try { return chrome.readDefault(); } catch (e) { return null; } })();+ const _pinnedBranded = !!(_def && _def.kind && !/chrome-for-testing|cft/i.test(_def.kind));+ const _cftCached = (() => { try { return chrome.detectChrome().installed; } catch (e) { return false; } })();+ const _sessionAlive = sessions.has(args.sessionId) || !!(browsers.get(args.profile || args.sessionId) && browsers.get(args.profile || args.sessionId).browser);+ // v1.9.77 β NEVER BLOCK ON A CfT DOWNLOAD WHEN A SYSTEM BROWSER EXISTS (wiki issue #15,+ // AdityaAngajala's second report). This gate forced a ~191 MB Chrome-for-Testing download even+ // though pup_readiness reported a healthy system Edge AND Chrome, so a machine with TWO+ // perfectly good browsers could not open a window at all β and the download never finalised+ // because its cache dir was half-populated. It also contradicted our own README: 'Chrome for+ // Testing is auto-fetched only as a last resort when the box has no Chromium browser at all.'+ // CfT is still PREFERRED (launchCandidates orders it first, and prewarm still fetches it in the+ // background) β preference is fine, blocking is not. Only gate when there is genuinely nothing+ // drivable on the machine.+ // v1.9.90 NATIVE-FIRST: launchCandidates orders the installed browser FIRST, so this gate+ // only fires when there is genuinely NOTHING drivable (no Chrome, no Edge, no cached CfT).+ // When a system browser exists, pup drives it directly (fresh profile + identity flags).+ const _hasSystemBrowser = (rd.candidates || []).some(c => c && c.source === 'system');+ const _needsCft = !_pinnedBranded && !_cftCached && !_sessionAlive && !_hasSystemBrowser;+ if (!rd.ready || _needsCft) {+ if (rd.installPhase === 'failed') {+ sendJSON(res, {+ success: false,+ errorCode: rd.lastErrorCode || 'chrome_install_failed',+ error: `No Chrome/Edge installed and the Chrome-for-Testing fallback failed to install: ${rd.lastError}`,+ readiness: rd,+ _hint: 'Chrome for Testing (the last-resort browser for a box with no Chrome/Edge) failed to install β usually no network / a proxy blocking storage.googleapis.com, or low disk. Retry with pup_prewarm once the cause is fixed. If this box actually HAS Chrome or Edge, pin it: pup_use {browser:"chrome"|"edge"} β pup drives it cleanly (fresh isolated profile + identity-suppression flags, no merge dialog), no download needed.',+ });+ return;+ }+ // No Chrome/Edge AND no cached CfT. If the disk is too low to hold CfT,+ // don't kick off a doomed 150 MB download β fail fast with a disk hint.+ if (rd.lowDisk) {+ sendJSON(res, {+ success: false,+ errorCode: 'chrome_install_no_disk',+ readiness: rd,+ error: `No Chrome/Edge on this machine and only ${rd.diskFreeMb} MB free β not enough to install Chrome for Testing (~800 MB needed).`,+ _hint: `This box has no Chrome or Edge, and only ${rd.diskFreeMb} MB free disk β too low to download Chrome for Testing (~150 MB download, ~600 MB unpacked). Ask the user to free up disk space, then retry; OR install Edge/Chrome (Edge ships with Windows and needs almost nothing). Do NOT keep retrying the download β it will fail until disk is freed.`,+ });+ return;+ }+ // No native Chrome/Edge on this box, so fetch CfT in the background as the LAST-RESORT+ // browser and tell the caller to poll+retry. (When a native browser exists we never reach+ // here β native-first drives it directly with a fresh profile + identity-suppression flags.)+ if (!rd.installing) chrome.ensureChromeReady({ background: true });+ sendJSON(res, {+ success: false,+ errorCode: 'chrome_for_testing_installing',+ installing: true,+ statusVerb: 'pup_readiness',+ readiness: rd,+ error: 'This box has no installed Chrome or Edge, so pup is fetching Chrome for Testing as the last-resort browser (~150 MB, one time).',+ _hint: 'LAST-RESORT PROVISIONING (not an error): pup drives your INSTALLED Chrome or Edge by default, cleanly β a fresh isolated profile PLUS launch flags that suppress Chrome\'s sign-in / "set up a work profile" merge dialog, so there is no identity bleed. THIS box has neither Chrome nor Edge, so pup is fetching Chrome for Testing in the background as the only drivable option (~a minute or two, one time). Poll `pup_readiness` until {ready:true}, then re-issue pup_open_window. If the box actually DOES have Chrome/Edge, pin it with pup_use {browser:"chrome"|"edge"} to skip the download.',+ });+ return;+ }+ }+ // pup is a generic, non-profile browser: each session is a fresh isolated+ // profile (its own Chrome/Edge process), never the user's real signed-in+ // browser. `profile` names that isolated user-data-dir; default it to the+ // sessionId so a bare "open in pup" (just sessionId + url) works cleanly.+ // (To drive the user's REAL Chrome/Edge, that's the Adom browser EXTENSION's+ // nbrowser_* verbs β a separate bridge, not pup.)+ // v1.8.96: wikiView:"authed" β ALWAYS the reserved vault profile. The 30-day SSO login+ // lives ONLY in adom-wiki-authed, so authed access MUST use that jar regardless of any+ // profile the thread passed (that's what makes ONE login serve EVERY thread). Each+ // session still gets its OWN window; they share only the cookie jar (proven: two+ // different threads, both authed, separate windows).+ // v1.9.147 (John: 'the ai has to send its ai-thread name and therefore you can make 1+ // window for that and tabs in that window, and one ai-thread won't pollute another's').+ // sessionId is now OPTIONAL. With no sessionId:+ // - the session keys on the CALLER THREAD ('ai-<thread-slug>') β the same thread always+ // lands in ITS one window, a different thread in its own; pollution is impossible by+ // construction (plus the 1.9.146 cross-owner guards for explicit ids).+ // - if that thread-window already EXISTS, the url opens as a NEW TAB in it instead of+ // a new window β one window per thread, tabs accumulate, exactly the model.+ // - anonymous callers (no thread header AND no sessionId) are refused with a teaching+ // error: identity is the price of the default window.+ if (!args.sessionId) {+ if (!_callerThread) {+ sendJSON(res, { success: false, errorCode: 'caller_identity_required',+ error: 'No sessionId AND no caller identity. Either pass a sessionId, or (better) identify your thread β adom-bridge-cli --ai-thread "<your thread>" ... β and pup gives your thread its OWN window automatically: first open creates it, later opens become tabs in it, and no other thread can touch it.' });+ return;+ }+ args.sessionId = threadDefaultSessionId(_callerThread); // v2.0.28: shared keying helper+ const _own = sessions.get(args.sessionId);+ if (_own && isSessionAlive(_own) && args.url) {+ try {+ const tabId = await addTabToSession(args.sessionId, args.url);+ recordAgentUpdate(_own, 'pup_open_window', args, tabId);+ scheduleAutoFlash(_own, { silent: !!args.silent });+ updateTabCountBadge(_own, args.sessionId).catch(() => {});+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, sessionId: args.sessionId, tabId, openedAsTab: true,+ tabCount: (_own.tabs || []).length, activeTabId: _own.activeTabId,+ }), _hint: `Opened as a NEW TAB in your thread's own window ("${args.sessionId}") β one window per thread, tabs accumulate there (pass an explicit sessionId if you truly need a separate window). Drive this tab via tabId ${tabId}.`, _next: threadNextBlock() });+ return;+ } catch (e) { /* fall through to a fresh window if the tab add failed */ }+ }+ }+ // v1.9.162 (John: 'i want ALL pup windows to use the same profile now so you have all of+ // the user's saved logins over time β the database builds up and this is pleasurable to+ // use over time'). ONE durable jar (adom-you) for EVERYTHING, wiki AUTHED included, so+ // every login the user does anywhere accumulates into one growing credential store. The+ // ONLY windows that get a separate jar are the three that DEFINITIONALLY require it:+ // {webSecurity:false} β insecure per-process flags must never touch the credentialed jar+ // {isolated:true} β an explicit fresh/throwaway jar (login tests, act-as-another-user)+ // wiki PUBLIC view β logged-OUT by definition, needs a clean jar to show the world's view+ // Everything else, explicit profile aside, is DEFAULT_PROFILE. (wiki AUTHED no longer uses+ // the old separate adom-wiki-authed jar β its login now lives in the one shared profile.)+ if (args.webSecurity === false) args.isolated = true;+ // v1.9.315 (John: "if the ai asks to open a wiki page, just always open it logged in by+ // default, and hint that it can pass a param to open anonymously"). For an Adom wiki URL,+ // DEFAULT to the logged-in (authed) view β it auto-signs from the AD desktop token with no+ // user prompt β UNLESS the caller explicitly asked for the anonymous view (noAutoLogin:true,+ // or wikiView:"public"). noAutoLogin is the plain-English opt-out John described.+ if (isAdomUrl(args.url) && args.noAutoLogin) args.wikiView = 'public';+ if (isAdomUrl(args.url) && !args.wikiView) { args.wikiView = 'authed'; args._wikiDefaultedAuthed = true; }+ // v2.0.132 (adom/wiki#126 adopted β John: "implement that instead"): the public view is+ // now a SERVER-RENDERED URL param, not an isolated anonymous jar. ?view=public makes the+ // server render exactly what a signed-out visitor sees (session untouched, byte-identical,+ // private pages 404), so the window stays on the shared adom-you profile β no throwaway+ // iso-* profile is minted (which was also the main source of the leaked-profile pile).+ // An explicit isolated:true still isolates (with its own reason) if a caller insists.+ if (args.wikiView === 'public' && isAdomUrl(args.url) && args.isolated !== true) {+ args.url = wikiPublicUrl(args.url);+ }+ if (args.isolated === true) {+ // v2.0.101 (John: "ALWAYS open in adom-you for consistency; an alternate profile only+ // when the AI specifically asks AND gives a reason why"). Isolation is REASON-GATED+ // like foregrounding: the two definitional cases carry their own reason, an explicit+ // isolated:true must state one or the open is refused with a teaching hint.+ if (!args.isolationReason) {+ if (args.webSecurity === false) args.isolationReason = 'webSecurity:false β insecure per-process flags must never touch the credentialed adom-you jar';+ else if (args.wikiView === 'public') args.isolationReason = 'wiki public view β a genuinely logged-out jar showing exactly what the world sees';+ }+ if (!args.isolationReason || String(args.isolationReason).trim().length < 10) {+ sendJSON(res, { success: false, error: 'isolation_reason_required', errorCode: 'isolation_reason_required', _hint: 'Every pup window rides the ONE shared adom-you profile by default (consistency + every login enriches the shared credential jar β the user wants this). isolated:true is the exception and needs a stated reason: re-call with isolationReason:"<why this window must NOT be adom-you>" (min 10 chars, e.g. fresh-login test, acting as a different user, untrusted site). If you do not truly need isolation, drop isolated:true.', _autoMode: 'Claude Code AUTO MODE note: this is pup\'s gate, not the harness classifier β re-calling with an isolationReason quoted from the user\'s own words is a structured verb call and passes Auto review cleanly. For the wiki public view you do not need isolation at all: wikiView:"public" renders anonymously in the shared window.' });+ return;+ }+ console.log(`[profile] "${args.sessionId}" ISOLATED β reason: ${args.isolationReason}`);+ args.profile = 'iso-' + args.sessionId; // isolation beats any explicit profile+ } else {+ // v1.9.346 (John: "steer everything to adom-you"). adom-you IS the AI doppelganger's identity+ // β ONE shared credential jar. A thread passing its own named profile ("nameplates", "np-verify")+ // fragments that: a separate login jar, none of the shared adom-you logins, and it wouldn't wear+ // the adom-you frame color. So OUTSIDE genuine throwaway isolation (isolated:true / webSecurity:false+ // / wikiView:"public" β all of which set isolated above), EVERY window goes on the shared adom-you,+ // overriding any custom profile name. The override is surfaced (_profileSteered) so the caller sees+ // pup redirected them; a thread that truly needs a private jar uses isolated:true.+ if (args.profile && args.profile !== DEFAULT_PROFILE) {+ args._profileSteered = { from: args.profile, to: DEFAULT_PROFILE };+ console.log(`[profile] steered "${args.sessionId}" from custom profile "${args.profile}" β shared ${DEFAULT_PROFILE} (John: everything on adom-you unless isolated:true)`);+ }+ args.profile = DEFAULT_PROFILE;+ }+ const sessionId = args.sessionId;+ // v1.9.110 (issue #22): NEVER LEAVE A SESSION UNOWNED, and settle ownership at ADMISSION.+ // The owner is the explicit `owner` arg when given, else the calling AI thread's own name+ // (AD hands us that via X-Adom-Caller-Thread), so "unowned" stops being reachable for any+ // caller that identifies itself. The claim below is taken synchronously β no await between+ // the check and the set β which is what closes the cold-start race where two agents were+ // both admitted for the same sessionId.+ {+ // #338 + John (2026-08-05): THE AI-THREAD NAME IS THE OWNER β the absolute standard. A pup+ // session is owned by the calling AI thread's NAME; the sessionId is NEVER an identity or a+ // fallback (that stale stopgap is gone β John: "i'm sick of seeing sessionid, force ai-thread+ // name now"). Resolve the thread from every form it can arrive in: AD's X-Adom-Caller-Thread+ // header, args.caller.aiThread, or a top-level args.aiThread. An explicit `owner` arg is+ // accepted only as a legacy alias. If a caller supplies NEITHER a thread NOR an owner, REFUSE+ // with caller_identity_required β no synthesized, no unowned sessions, ever. (Internal pup+ // opens call launchSession directly with their own owner, so they bypass this verb gate.)+ const _aiThread = (_callerThread && String(_callerThread).trim())+ || (args.caller && typeof args.caller === 'object' && args.caller.aiThread && String(args.caller.aiThread).trim())+ || (typeof args.aiThread === 'string' && args.aiThread.trim() ? args.aiThread.trim() : null)+ || null;+ const _explicitOwner = (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null;+ // v2.0.115 (John: reopening other threads' windows stamped them as MINE). An EXPLICIT+ // owner arg now WINS over the caller's ai-thread β it is the documented "your thread/task+ // label" and the only way one thread can faithfully restore a window it is reopening ON+ // BEHALF of another thread (e.g. a reload cycle). With no owner arg, the caller's ai-thread+ // is the owner, exactly as before. Ownership here is attribution, not a security boundary,+ // so honoring the caller's stated owner is correct.+ const _wantOwner = _explicitOwner || _aiThread;+ if (!_wantOwner) {+ sendJSON(res, {+ success: false,+ errorCode: 'caller_identity_required',+ error: `'pup_open_window' needs to know WHICH AI THREAD is opening this window. pup owns every session by ai-thread NAME now β the sessionId is not an identity.`,+ supplyIdentity: {+ bestWay: `adom-bridge-cli --ai-thread "<your conversation name>" pup_open_window '<json>'`,+ orInArgs: { caller: { aiThread: '<your conversation name>', containerName: '<container>' } },+ },+ _hint: `Re-run with your ai-thread name so pup can own this window as YOU β e.g. adom-bridge-cli --ai-thread "chip-fetcher tab 3" pup_open_window '{"sessionId":"chip-fetcher-web","url":"..."}'. That NAME (not the sessionId) is the window's owner and what protects it from other threads; sessionId is just the window's key, never who owns it.`,+ });+ return;+ }+ const _held = _sessionOwnerClaims.get(sessionId) || null;+ if (_held && _held !== _wantOwner && !args.takeover && !sessions.has(sessionId)) {+ sendJSON(res, {+ success: false,+ errorCode: 'session_owned_by_another_thread',+ error: `Session "${sessionId}" was claimed by thread "${_held}" moments ago and is still being provisioned.`,+ sessionId, owner: _held, requestedBy: _wantOwner,+ _hint: `STOP β thread "${_held}" claimed sessionId "${sessionId}" first and its window is still opening: this is a COLD-START RACE, not an available id. Do NOT retry. Open your OWN window under a task-prefixed sessionId (e.g. "${_wantOwner}-web"); it will be owned by "${_wantOwner}". Pass takeover:true only if the user EXPLICITLY told you to repurpose that thread's window.`,+ });+ return;+ }+ if (!_held) _sessionOwnerClaims.set(sessionId, _wantOwner);+ // Stamp the resolved ai-thread as the owner for the rest of the pipeline (launchSession ++ // pup_status). _wantOwner is always set here (we refused above if not).+ args.owner = _wantOwner;+ if (_aiThread && _wantOwner === _aiThread) args._ownerFromCallerIdentity = true;+ }+ // v1.6.3+: optional downloadPath override. Defaults to the user's+ // home Downloads dir, which is what desktop_watch_files polls by+ // default β the two ends meet without configuration. Caller can+ // override per-session via this arg if they want downloads to+ // land elsewhere (e.g. straight into a chip-fetcher staging dir).+ const downloadPath = (args.downloadPath && typeof args.downloadPath === 'string')+ ? args.downloadPath+ : path.join(os.homedir(), 'Downloads');+ // v1.8.70: THE FOREGROUND GATE (open-time half; mirrors abe's dual key). The flag+ // alone is NOT enough β without foregroundReason the open still SUCCEEDS but the+ // window stays BACKGROUND (abe semantics: withhold the foregrounding, not the work),+ // and the response says exactly what was missing. A granted foreground is logged ++ // recorded in lastAgentUpdate so the user can always audit who put a window on their+ // screen and why. Also accepts abe's arg name userRequestedForeground.+ // v1.8.92: app identity args β the driving app's icon/name for the window overlay.+ const _appIconB64 = (typeof args.appIconB64 === 'string' && args.appIconB64.length > 50) ? args.appIconB64 : null;+ const _appName = (typeof args.appName === 'string' && args.appName.trim()) ? args.appName.trim() : null;+ const fgRequested = !!(args.foreground || args.raise || args.show || args.userRequestedForeground);+ const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;+ const fgGranted = fgRequested && !!fgReason;+ const fgDenied = fgRequested && !fgReason;+ if (fgGranted) {+ console.log(`[foreground] "${sessionId}" open GRANTED β reason: ${fgReason}`);+ // v2.0.261: exempt from the birth grace. The caller asked for a foreground window on+ // the user's behalf, so this one is SUPPOSED to be in front β the grace exists only to+ // stop Chrome's self-activation of an unasked-for window being read as user intent.+ try { const _s = sessions.get(sessionId); if (_s) _s._raiseGranted = true; } catch (e) {}+ }+ // v2.0.165 THREAD TILES ROOT CAUSE: default the owner to the CALLING ai-thread when not+ // explicitly declared, BEFORE launchSession β session.owner must exist at CREATE time so the+ // FIRST identity stamp (the one that births the taskbar button) already computes the thread+ // AUMID + name tile. Previously owner came only from args.owner, so the button was BORN under+ // a per-session category appId, and the later thread re-stamp silently failed forever whenever+ // the hwnd could not be re-resolved (title fallback is dead with titleTag off) β the taskbar+ // kept the category icon and never grouped. User-surface callers (taskbar/toolbar) stay+ // ownerless: a human click is not an AI thread.+ if (!(typeof args.owner === 'string' && args.owner.trim()) && _callerThread && !USER_CALLERS.has(_callerThread)) {+ args.owner = _callerThread; args._ownerFromCallerIdentity = true;+ }+ let session;+ try {+ // v1.9.143: same-profile opens are serialized (see _profileOpenChain).+ const _chainKey = args.profile;+ const _prev = _profileOpenChain.get(_chainKey) || Promise.resolve();+ const _cur = _prev.then(() => launchSession(sessionId, args.url, {+ freshProfile: !!args.freshProfile,+ insecureDev: args.webSecurity === false,+ profile: args.profile,+ strictPermissions: !!args.strictPermissions,+ downloadPath,+ nativeSite: !!args.nativeSite,+ highFps: !!args.highFps,+ owner: (typeof args.owner === 'string' && args.owner.trim()) ? args.owner.trim() : null,+ takeover: !!args.takeover,+ // v1.8.24: background-by-default is enforced DURING launch (focus handed+ // back to the user's window the instant pup's window appears) unless the+ // caller explicitly wants it foreground (v1.8.70: AND supplied a reason).+ foreground: fgGranted,+ silent: !!args.silent || args.autoFlash === false,+ }));+ // Chain tail: never rejects (errors surface via _cur below), so one failed open+ // can never wedge the queue for the profile.+ _profileOpenChain.set(_chainKey, _cur.catch(() => {}));+ session = await _cur;+ } catch (e) {+ if (e && e.code === 'session_owned_by_another_thread') {+ const d = e.details || {};+ sendJSON(res, {+ success: false,+ errorCode: 'session_owned_by_another_thread',+ error: e.message,+ ...d,+ _hint: `STOP β do NOT take this window. Session "${d.sessionId}" was opened by another AI thread (owner "${d.owner}") and is currently showing ${d.currentUrl}. Navigating it away would destroy that thread's work. Do ONE of: (1) open your OWN window with a UNIQUE sessionId β prefix it with your task, e.g. "${d.requestedBy}-web" (RECOMMENDED); (2) if the user EXPLICITLY told you to repurpose this window, re-send with takeover:true to claim it; (3) pup_status first to see all windows + owners before picking a sessionId. Generic ids like "wiki"/"test"/"main" collide across threads β always task-prefix yours.`,+ });+ return;+ }+ throw e;+ }+ // One-shot reuse/steal report from launchSession (existing-session path).+ const reuse = session && session._reuseMeta ? session._reuseMeta : null;+ if (session) delete session._reuseMeta;+ // Stash on the session so attachTab uses it for any subsequent+ // tab open or popup auto-attach in this session.+ if (session) session._downloadPath = downloadPath;+ // #338: the owner came from the calling ai-thread (caller identity), not a self-asserted arg β+ // mark it so pup_status/list_windows report ownerSource:"caller-identity" instead of "declared".+ if (session && args._ownerFromCallerIdentity) session._ownerFromCallerIdentity = true;+ // v1.9.102 β STAMP THE OWNING THREAD *HERE*, not at dispatch. The pre-dispatch stamp can+ // only find a session that already exists, and on an OPEN the session is created INSIDE this+ // handler β so the single most important window (the one this thread just created) got no+ // identity and the hover tooltip read "(no AI-thread identity yet)". _callerThread is in+ // scope for the whole dispatch, so stamp as soon as the session is real.+ if (session && _callerThread) {+ // v2.0.115: on an OPEN with an explicit owner (args.owner is the resolved _wantOwner+ // there), the OWNER thread β not the caller β is who owns the window, so zero-id+ // targeting (callerOwnWindow keys on _ownerThread) resolves for the right thread after+ // a faithful reopen. Every other path still stamps the calling thread.+ if (!session._ownerThread) { session._ownerThread = (command === 'pup_open_window' && args.owner) ? args.owner : _callerThread; session._ownerContainer = _callerContainer; }+ session._lastThread = _callerThread;+ session._lastContainer = _callerContainer;+ if (_callerReason) session._openReason = String(_callerReason).slice(0, 300);+ try { if (_callerContainer) session._callerContainer = _callerContainer; } catch (e) {}+ try { if (args && args.url && !session._intendedUrl) session._intendedUrl = String(args.url); } catch (e) {}+ console.log(`[caller] "${sessionId}" owned by AI thread "${_callerThread}"${_callerContainer ? ` (${_callerContainer})` : ''}${_callerReason ? ` β reason: ${String(_callerReason).slice(0, 90)}` : ''}`);+ // v1.9.103: put the thread FIRST in the window title, which is the text Windows draws above+ // the taskbar hover-preview. Kept SHORT (18 chars) on purpose: that header truncates around+ // 25-30 chars, so a long tag would push out the page title AND get cut itself. This is the+ // one surface that answers "which of my 20 threads made this window" at a glance.+ try {+ const tag = String(_callerThread).trim().slice(0, 18);+ if (tag) { session._threadTag = tag; await setTitleDecor(session.page, { threadTag: tag }); }+ } catch (e) {}+ }+ if (session && _appIconB64) { session._appIconB64 = _appIconB64; session._appIconMime = 'image/png'; }+ if (session && _appName) session._appName = _appName;+ // v1.8.50: the background watchdog runs concurrently with navigation. Await its+ // SELF-VERIFIED verdict (bounded) so the response's `background` field is the real+ // outcome ('backgrounded'/'still-foreground'), never a guess mid-park.+ try {+ const bp = session && browsers.get(session.profileName)?.browser?._bgPromise;+ if (bp) await Promise.race([bp, new Promise(r => setTimeout(r, 7000))]);+ } catch (e) {}+ // v1.8.92: wear the driving app's favicon in the overlay (page-served or explicit).+ let _appIconWorn = false;+ if (session && process.platform === 'win32') {+ try { _appIconWorn = await Promise.race([applyAppOverlay(session, sessionId), new Promise(r => setTimeout(() => r(false), 4000))]); } catch (e) {}+ // v1.8.99: run the favicon-vs-count decider (a window may open with multiple tabs).+ try { updateTabCountBadge(session, sessionId).catch(() => {}); } catch (e) {}+ }+ const appIconHint = appIconReprimand(args.url, _appIconWorn);+ // v1.8.93: wiki dual-view hint + auth-state check.+ // v2.0.29 (#42, John saw one thread's fresh window render signed-OUT while its siblings on+ // the SAME shared profile rendered signed-in): the shared adom-you profile is SUPPOSED to be+ // signed in as the user. Check auth on EVERY adom URL that is not the deliberately-anonymous+ // public/isolated view β not just explicit wikiView:"authed" β so the omitted-view case+ // (the common one) is covered too.+ let _wikiAuth = null;+ const _wantsSignedIn = session && isAdomUrl(args.url) && args.wikiView !== 'public' && !args.isolated;+ if (_wantsSignedIn) {+ try { _wikiAuth = await Promise.race([checkWikiAuth(session.page), new Promise(r => setTimeout(() => r({ authed: false, unknown: true }), 3500))]); } catch (e) {}+ }+ // v1.9.88/v2.0.29 β DEFINITELY signed out (a real 401, not a probe timeout) but the desktop+ // holds a wiki token: AUTO-SIGN from it BEFORE the first visible paint settles, so the window+ // never shows a signed-out frame (which is also what the taskbar hover thumbnail would cache).+ if (_wantsSignedIn && _wikiAuth && _wikiAuth.authed === false && !_wikiAuth.unknown && readDesktopWikiToken()) {+ try {+ const _np = (() => { try { const u2 = new URL(args.url); return u2.pathname + u2.search; } catch (e) { return '/adom'; } })();+ const _who = await autoSignWikiFromDesktop(session.page, _np);+ if (_who) {+ _wikiAuth = { authed: true, user: _who !== 'authed' ? _who : undefined, autoSigned: true };+ session._wikiAuthed = true;+ console.log(`[wikiview] "${sessionId}" ${args.wikiView || 'shared'} AUTO-SIGNED from AD desktop token (${_who}) β was signed out at first paint (#42)`);+ // repaint so the taskbar thumbnail drops any cached signed-out frame+ if (process.platform === 'win32') { try { scheduleOverlayRefresh(sessionId); } catch (e) {} }+ }+ } catch (e) {}+ }+ // v1.9.175: pass args.wikiView VERBATIM so the hint distinguishes omit (shared, logged in)+ // from explicit wikiView:"public" (isolated, logged out) β collapsing them misreported the+ // shared logged-in view as "the logged-out public view".+ const wikiHint = wikiViewHint(args.url, args.wikiView, _wikiAuth);+ // v1.8.97: gentle in-tab glyph β π authed (logged in), π public.+ if (session && isAdomUrl(args.url)) {+ const label = (args.wikiView === 'authed' && _wikiAuth && _wikiAuth.authed) ? '\u25CF Logged in' : '\u25CB Public';+ try { await setWikiModeGlyph(session.page, label); } catch (e) {}+ }+ // v1.9.106: LOOK at what we just opened rather than only advising the caller to look. A+ // 404 / blank / unstyled / crashed page becomes a reported FACT (renderCheck + a failure-first+ // _verifyRender) instead of a cheerful ok:true the caller can misread as success.+ if (session && args.url) {+ try {+ let _hs = null;+ try { _hs = session.page && session.page.__pupHttpStatus; } catch (e) {}+ res._renderCheck = await probeRender(session.page, _hs);+ try { await autoLoginOnLoad(res, session.page); } catch (e) {}+ const _rc = res._renderCheck;+ if (_rc && _rc.ok === false) console.log(`[render] "${sessionId}" LOOKS BROKEN: ${_rc.signals.join(', ')}`);+ } catch (e) {}+ }+ const info = await getSessionInfo(sessionId, session);+ // v1.8.0: BACKGROUND BY DEFAULT. pup is the AI's workspace, not the user's β+ // a window that pops to the foreground and covers what the user is doing is+ // disruptive. v1.8.17: background is now the HARD default β a window is+ // foregrounded ONLY when the caller EXPLICITLY asks (foreground/raise/show:true,+ // e.g. the user said "let me watch" / a recording). Sizing/positioning NO LONGER+ // foregrounds: a sized window is placed via CDP (works fine while occluded) and+ // STILL pushed to the back, so screenshot-framing never disturbs the user. The+ // window stays fully drivable + screenshottable in the background (occluded, not+ // minimized). Order matters: size FIRST, then send to back, so the final z-order+ // is background even after the bounds call.+ const w = Number(args.width), h = Number(args.height), wx = Number(args.x), wy = Number(args.y);+ const sized = Number.isFinite(w) && Number.isFinite(h);+ const foreground = fgGranted; // v1.8.70: the gate's verdict, not the raw flag+ if (sized) {+ try { await setSessionWindowBounds(session, { x: wx, y: wy, width: w, height: h }); } catch (e) {}+ // v2.0.367 (John: "it keeps happening to all ai threads... you're doing something to+ // fuck up the browser client area"). THIS LINE WAS THE CAUSE, and it is why the problem+ // followed every thread rather than one.+ //+ // 1.8.21 pinned the page viewport to the requested width/height on EVERY sized open, to+ // fix a real bug (DPI/window-state quirks left innerWidth at ~170px on some laptops).+ // But a CDP device-metrics override is PERMANENT for the life of the window, so from+ // that moment the page stopped tracking its own window. Maximise it, or let the user+ // resize it, and the page keeps rendering at the original width inside a much larger+ // frame: content stops short and the rest is dead space. Three different threads' windows+ // showed exactly that today.+ //+ // It was also INVISIBLE, because this path never recorded _emulatedViewport. So pup's+ // listings said "no override", every warning I added keyed off that record and stayed+ // silent, and the owning AI measured innerWidth, saw the page filling it, and honestly+ // reported "100% width" while the human looked at a gap.+ //+ // The fix keeps the original repair and drops the permanent cost: size the OS window,+ // MEASURE what the page actually got, and only pin if the page failed to follow (the+ // ~170px case). A window that tracks its own bounds is left alone to keep tracking them.+ try {+ const _got = await session.page.evaluate(() => ({ iw: window.innerWidth, ih: window.innerHeight }));+ const _wantW = Math.round(w);+ const _followed = _got && _got.iw > 200 && Math.abs(_got.iw - _wantW) <= Math.max(40, _wantW * 0.15);+ if (!_followed) {+ await session.page.setViewport({ width: _wantW, height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1 });+ session._emulatedViewport = { width: _wantW, height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1, reason: 'window did not follow its bounds' };+ session._lastOuterWidth = _got && _got.iw ? null : null;+ console.log(`[viewport] "${sessionId}": window did not follow its bounds (page reported ${_got ? _got.iw : '?'}px for a ${_wantW}px window) β PINNED the viewport as a repair, and recorded it so it is disclosed`);+ }+ } catch (e) {}+ }+ // v1.8.69: re-assert background after an explicit sizing call β Z-ORDER ONLY.+ // The old call here was the legacy osBackgroundWindowByPid, whose PS park MOVES the+ // window to 0,0 sized-to-monitor β silently clobbering the exact x/y/w/h the caller+ // just asked for (every sized open snapped back to the primary monitor's origin,+ // which broke placing windows on a second monitor). AD state:'bottom' with+ // force:false touches nothing but z, and respects a user-held window.+ if (!foreground && sized) {+ try {+ await chrome.abCommand('desktop_set_window_state', { ...(await windowTarget(sessions.get(sessionId), sessionId)), state: 'bottom', force: false }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});+ } catch (e) {}+ }+ // v1.8.26: record whether this window is meant to be on screen β gates every+ // later bringToFront (navigate/reload/switch_tab must not raise a bg window).+ if (session) session._foreground = foreground;+ // v1.9.3: remember this window's wiki view so the taskbar jump-list toggle (and any+ // later relaunch) knows which cookie jar it's on. Record the "public" profile too so+ // toggling back from authed restores the original jar, not just the sessionId default.+ if (session && isAdomUrl(args.url)) {+ session._wikiView = (args.wikiView === 'authed') ? 'authed' : 'public';+ if (session._wikiView === 'public') session._publicProfile = session.profileName;+ // v1.9.12: seed the icon state so the FIRST brand already carries the right glyph.+ session._wikiAuthed = !!(_wikiAuth && _wikiAuth.authed);+ }+ if (session && fgGranted) {+ session._lastForeground = { ts: Date.now(), verb: 'pup_open_window', reason: fgReason };+ toastForeground(sessionId, fgReason).catch(() => {}); // v1.9.166+ try { recordAgentUpdate(session, 'pup_open_window', { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}+ }+ // v1.8.23: session-level auto-flash default (autoFlash:false β this window never+ // auto-flashes, e.g. a chatty automation loop the user isn't watching).+ if (session && args.autoFlash === false) session._flashSilent = true;+ // Flash on open (a NEW window appearing IS the update) β unless foregrounded+ // (already on screen) or suppressed. Also stamp the first agent update.+ if (session) {+ recordAgentUpdate(session, 'pup_open_window', args, session.activeTabId);+ // v1.9.140: the launch path already flashed this open β consume the marker and skip.+ if (!foreground && !_openFlashDone.delete(sessionId)) scheduleAutoFlash(session, { silent: !!args.silent });+ }+ // v1.8.53: badge timing moved into the park (launchSession) β it fires the moment the+ // park verdict lands instead of blind 1.5s/3.5s timers. This is only a fallback for+ // the REUSED-window path (no park runs there, but the badge may have been cleared).+ if (process.platform === 'win32') {+ setTimeout(() => { brandPupWindow(sessionId).catch(() => {}); }, 300);+ // v1.9.3: attach/clear the per-window wiki view-toggle jump list once the window's+ // identity is stamped (registers the per-session AUMID if needed).+ setTimeout(() => { updateWikiJumplist(session, sessionId).catch(() => {}); }, 900);+ // v1.9.18: REPAINT the overlay after session._wikiAuthed is set. The first+ // applyAppOverlay above runs BEFORE the wiki auth result is recorded a few lines+ // down, so it always composited the favicon on the logged-out (dark) plate even for+ // an authed window. This second pass paints the correct plate colour.+ setTimeout(() => { updateTabCountBadge(session, sessionId).catch(() => {}); }, 1200);+ }+ // Verbose browser report: which browser we actually drove, what else is available.+ const brep = browserPickReport();+ const fgDenyHint = fgDenied ? ' β FOREGROUND DENIED: you passed foreground:true WITHOUT a foregroundReason, so the window opened in the BACKGROUND (the open itself succeeded). Foregrounding steals the user\'s screen+keyboard and is allowed ONLY when the USER explicitly asked to SEE this window. If they did: re-call with foreground:true + foregroundReason:"<quote/paraphrase what the user said>" (min 10 chars β it is recorded and auditable). If they did not: leave it background; pup_alert_window is the correct "it\'s ready" signal.' : '';+ const bgHint = foreground+ ? 'Opened in the FOREGROUND as EXPLICITLY requested (foreground/raise/show:true). Return it to the background with pup_lower_os_window once the user is done watching.'+ : `Opened in the BACKGROUND (behind the user's windows, did NOT steal focus)${sized ? ' β and placed at the requested size/position WITHOUT foregrounding, so framing it for a screenshot never disturbs the user' : ''}. This is the HARD default so pup never disrupts the user's main work. It is FULLY drivable here: pup_screenshot / pup_eval / pup_navigate all work while it stays hidden. ONLY bring it to the user's screen if they EXPLICITLY want to WATCH (a demo, a recording, a "show me") β pass foreground:true on open (or call pup_raise_os_window). Sizing alone does NOT foreground anymore. LONG MULTI-STAGE TASK in this window (crawling a site, login->search->download loops, batch harvesting)? Paint the taskbar progress bar so the user sees the work: pup_progress {mode:\"indeterminate\", note:\"crawling ti.com\"} to start, {mode:\"value\", value:40, note:\"12/40 datasheets\"} as stages advance, {mode:\"off\"} when done. A single quick page open does NOT need it β your call. Full guidance: the pup-progress skill.`;+ // v1.8.16: ownership/reuse verdict β reprimand grabs, bless refreshes.+ let ownHint = '';+ if (reuse && reuse.navigatedAway) {+ if (reuse.takeover) {+ ownHint = ` TAKEOVER: you claimed session "${sessionId}" from thread "${reuse.previousOwner}" (was showing ${reuse.previousUrl}) via takeover:true β you now own it. Only do this when the user explicitly asked.`;+ } else if (reuse.anonymousGrab) {+ ownHint = ` β YOU JUST TOOK OVER AN EXISTING WINDOW: session "${sessionId}" belonged to thread "${reuse.previousOwner}" and was showing ${reuse.previousUrl} β you navigated it away WITHOUT declaring an owner. If that window wasn't yours, you broke another AI thread's work: re-open ITS url (${reuse.previousUrl}) on this sessionId, then use your OWN unique task-prefixed sessionId. ALWAYS pass owner:"<your-task-name>" on pup_open_window so pup can protect windows across threads.`;+ } else if (reuse.sameOwner) {+ ownHint = ` Reused your own window (owner match) β navigated ${reuse.previousUrl} β new url.`;+ } else {+ ownHint = ` NOTE: reused EXISTING session "${sessionId}" (was showing ${reuse.previousUrl}, no owner declared by either side). If that window belonged to another AI thread, you just stole it β prefer a unique task-prefixed sessionId for new work, and pass owner:"<your-task-name>" so pup can enforce ownership.`;+ }+ } else if (reuse && reuse.reused) {+ ownHint = ' Reused the existing window (same/no url β benign refresh).';+ } else if (!args.owner) {+ // v1.8.57: teach anonymous CREATORS too (a long-lived app like adom-shotlog that+ // never declares owner gets zero cross-thread protection β its window can be+ // navigated away or closed by any other AI thread without pup refusing).+ ownHint = ` TIP: you created this window WITHOUT owner β pass owner:"<your-app-or-task>" on pup_open_window so pup REFUSES other threads' attempts to navigate/close it (session_owned_by_another_thread) instead of letting them take it. Especially important for a long-lived shared window.`;+ }+ // v1.9.76 β HIGH-FPS / POWER hint on EVERY open. An AI cannot discover this+ // capability otherwise, and the wrong choice stays invisible until a video comes+ // out at 1-2 fps or a user's battery is flat overnight (issue #14).+ const hf = highFpsInfo(sessionId);+ const fpsHint = hf+ ? ` HIGH-FPS WINDOW, LEASED ${hf.minutes} MIN (expires ${hf.expiresAt}). Chrome's four power-saving mechanisms are DISABLED here, so it renders at FULL RATE even while occluded β that is what makes a background video recording usable. It also means it never idles down, so it is LEASED: when the lease lapses pup DOWNGRADES it to normal power by relaunching the same session with the same tabs (it is NOT closed β your content survives). YOU CANNOT BE CUT OFF MID-RECORDING: while a recording is actually running the lease cannot lapse. If you know you need longer WITHOUT recording the whole time, negotiate now: pup_highfps_extend {sessionId:"${sessionId}", minutes:N} (max ${HIGHFPS_MAX_MINUTES}). pup warns ~2 min before it downgrades.`+ : ` POWER: NORMAL window on Chrome's own power behaviour β it throttles when occluded, so it costs nothing sitting in the background. Right default for driving, scraping and screenshots. If you need to RECORD VIDEO of this window while it is occluded, a normal window only yields ~1-2 fps: reopen with pup_open_window {highFps:true} for full-rate background frames. highFps is leased (default ${HIGHFPS_DEFAULT_MINUTES} min) and auto-downgrades when idle, so it can never flatten the user's battery.`;+ sendJSON(res, {+ success: true,+ output: JSON.stringify({+ ok: true,+ highFps: hf || false,+ defaultPermissionsApplied: !args.strictPermissions,+ // v1.9.109 (John: "you can trim the bloat if you think that just causes the ai to ignore+ // stuff") β this emitted all ~32 permission strings on EVERY open. The caller never acts on+ // them, and they were padding the response that a 404 verdict had to compete with. Ordering+ // by consequence means boilerplate collapses to a count + a pointer.+ defaultPermissionsCount: args.strictPermissions ? 0 : PERMISSIVE_DEFAULT_PERMISSIONS.length,+ defaultPermissionsNote: args.strictPermissions+ ? 'strictPermissions: nothing pre-granted'+ : 'full browser permissions pre-granted (clipboard, camera, geolocation, window-management, ...) β pup_describe has the exact list',+ downloadsEnabled: !args.strictPermissions,+ downloadPath,+ // v1.4.0: which browser pup drove + the full fallback picture.+ browser: brep.browser,+ // v2.0.378 (#81 ask 2): say it in the open response when a window CANNOT carry the toolbar.+ _toolbarNotice: (brep.browser && brep.browser.kind && brep.browser.kind !== 'chrome-for-testing' && pupSettings().toolbarExtension !== 'off')+ ? `This window launched on ${brep.browser.kind}: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing, so it has NO toolbar. pup_readiness shows why that browser was picked; pup_use {browser:"auto"} plus a browser relaunch converges to CfT.`+ : undefined,+ // Same treatment: the full browser inventory is a one-time discovery fact, not something a+ // caller re-reads on every open. The chosen browser is already reported in `browser`.+ availableBrowsers: (() => {+ try { return (brep.availableBrowsers || []).map(b => `${b.kind}(${b.source})`); } catch (e) { return brep.availableBrowsers; }+ })(),+ defaultBrowser: brep.defaultBrowser,+ // v1.8.0: window placement β background unless explicitly foregrounded.+ placement: foreground ? 'foreground' : 'background',+ // v2.0.383 (#63): the canonical SDK key a caller branches on; `placement` stays as the legacy alias.+ windowEtiquette: foreground ? 'foreground' : 'background',+ // v2.0.383 (#59): the terminal progress frame (actuals; startKind cold|warm; estimate from this machine's history).+ progress: (() => { _inflightOpens.delete(_openId); try { return openProgressFrame(_openT0); } catch (e) { return undefined; } })(),+ // v1.8.2: window size (if width/height were given). null = default maximized.+ size: sized ? { width: Math.round(w), height: Math.round(h), x: Number.isFinite(wx) ? Math.round(wx) : null, y: Number.isFinite(wy) ? Math.round(wy) : null } : null,+ // v1.8.16: reuse/ownership verdict (null = fresh window).+ reused: reuse ? { navigatedAway: reuse.navigatedAway, previousUrl: reuse.previousUrl, previousOwner: reuse.previousOwner, takeover: reuse.takeover } : null,+ owner: session.owner || null,+ appIconWorn: _appIconWorn || undefined,+ wikiView: isAdomUrl(args.url) ? (args.wikiView === 'authed' ? 'authed' : 'public') : undefined,+ wikiLoginNeeded: (args.wikiView === 'authed' && _wikiAuth && !_wikiAuth.authed) || undefined,+ foregroundDenied: fgDenied ? 'foreground_reason_required' : undefined,+ _next: threadNextBlock(),+ // v1.9.108 (John, looking at a real launcher-mockup response) β A DETECTED RENDER+ // FAILURE MUST LEAD. The verdict was correct and the calling AI did act on it, but _hint+ // opened with a FAVICON nag, then a power lecture, then a browser inventory, so the single+ // most important fact ("your page is a 404") sat three screens down behind 32 permission+ // strings. Rich hints are good; burying the critical one behind cosmetic ones is not.+ // When the page is broken, say that FIRST and defer the cosmetics to the same response's+ // other fields (they are still returned, just not in front of a failure).+ _hint: (() => {+ const rc = (res && res._renderCheck) || null;+ const broken = rc && rc.ok === false && (rc.signals || []).length;+ const steerNote = args._profileSteered+ ? ` NOTE: you asked for profile "${args._profileSteered.from}", but pup put this window on the shared "${DEFAULT_PROFILE}" instead β every pup window shares ONE identity (the adom-you doppelganger) so logins accumulate in one jar and every window wears the adom-you look. If you genuinely need a private, throwaway jar (a fresh-login test, or acting as a different user), pass {isolated:true} β a named {profile:"..."} no longer makes a separate durable jar.`+ : '';+ const profileNote = args.profile === DEFAULT_PROFILE+ ? `${steerNote} PROFILE: this window uses pup's durable shared "${DEFAULT_PROFILE}" profile β logins, cookies and localStorage are SAVED and SHARED across ALL pup windows, so a site you sign into once stays signed in everywhere and app state (localStorage) persists between windows. This is deliberate. For a PRIVATE throwaway window instead, reopen with {isolated:true}.`+ : ((args.wikiView === 'authed') ? '' : ` PROFILE: "${args.profile}" β an isolated throwaway jar (isolated:true).`);+ const rest = `${wikiHint}${authSteerHint(args.url)}${fgDenyHint}${appIconHint}${bgHint}${profileNote}${ownHint}${fpsHint} ${brep._hint}${staleWindowsHint()}${adUpgradeHint()}`;+ if (!broken) return rest;+ const st = (typeof rc.httpStatus === 'number') ? ` (HTTP ${rc.httpStatus})` : '';+ return `β THE PAGE YOU JUST OPENED IS BROKEN: ${rc.signals.join(', ')}${st}`+ + `${rc.excerpt ? ` β it says "${String(rc.excerpt).replace(/\s+/g, ' ').slice(0, 120)}"` : ''}. `+ + `Do NOT report success. Screenshot it (pup_screenshot {"sessionId":"${sessionId}"}), fix the cause, pup_reload, re-check. `+ + `See _verifyRender for the full checklist. Everything below is secondary: ${rest}`;+ })(),+ ...info,+ }),+ });+ return;+ }+ case 'pup_switch_window': {+ const sessionId = args.sessionId;+ if (!sessionId || !sessions.has(sessionId)) {+ const available = [...sessions.keys()];+ sendJSON(res, { success: false, error: `Session "${sessionId}" not found. Available: ${available.join(', ')}` });+ return;+ }+ activeSessionId = sessionId;+ const session = sessions.get(sessionId);+ const info = await getSessionInfo(sessionId, session);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...info }) });+ return;+ }+ case 'pup_list_windows': {+ // v1.9.172 (jam fix): gather sessions IN PARALLEL, each with a hard per-session timeout that+ // falls back to a pure in-memory snapshot. Previously this awaited getSessionInfo() SERIALLY,+ // and getSessionInfo did uncapped CDP reads (title() + a focus eval) per window β so ONE busy+ // window (Chrome under load on a heavy SPA) hung the whole handler and the CLI returned null,+ // i.e. "pup looks frozen". Now the list ALWAYS returns within ~2.5s with every window present;+ // a window too busy to answer live is marked degraded with its last-known state, never dropped.+ const entries = [...sessions];+ const list = await Promise.all(entries.map(([id, s]) =>+ withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s))+ ));+ const degradedCount = list.filter(x => x && x.degraded).length;+ const out = { sessions: list, count: list.length, activeSession: activeSessionId };+ if (degradedCount) {+ out._degraded = `${degradedCount} of ${list.length} window(s) were busy (Chrome under heavy load) so pup returned their last-known state rather than stalling. pup is NOT frozen and the windows are NOT crashed β they will report live values again shortly.`;+ }+ sendJSON(res, { success: true, output: JSON.stringify(out) });+ return;+ }+ case 'pup_configure': {+ // v2.0.179 (John: "Tile Icons are an ALTERNATE to the AUMID icons which are an alternate+ // to plain CfT icons"): taskbarMode is the one-key ladder β 'tiles' (thread name tiles,+ // the default), 'aumid' (per-window/category icons), 'plain' (no branding, stock Chrome+ // button). It maps onto the two underlying keys so old callers keep working; overlays+ // stay their own orthogonal overlayBadges toggle.+ const tm = args.taskbarMode;+ if (tm !== undefined && !['tiles', 'aumid', 'plain'].includes(tm)) {+ sendJSON(res, { success: false, error: `taskbarMode must be one of tiles|aumid|plain` });+ return;+ }+ if (tm) {+ const pair = tm === 'tiles' ? { aumidIcons: 'on', threadTiles: 'on' }+ : tm === 'aumid' ? { aumidIcons: 'on', threadTiles: 'off' }+ : { aumidIcons: 'off', threadTiles: 'off' };+ savePupSettings({ ...pair, _chosen: { ...(pupSettings()._chosen || {}), aumidIcons: true, threadTiles: true } });+ console.log(`[configure] taskbarMode -> ${tm} (${JSON.stringify(pair)})`);+ if (tm === 'plain') { stripAllPupIdentities('configure-taskbarmode-plain').catch(() => {}); }+ else {+ for (const [, s] of [...sessions]) { try { s._curAppId = null; s._frozenAppId = null; } catch (e) {} }+ try { _registeredPupAumids.clear(); } catch (e) {}+ brandSweep('configure-taskbarmode').catch(() => {});+ reconcileModel('setting taskbarMode').catch(() => {});+ }+ }+ // v1.8.82: user-facing pup preferences. Currently: taskbarGrouping 'split'|'grouped'.+ const valid = ['split', 'grouped'];+ const g = args.taskbarGrouping;+ if (g !== undefined && !valid.includes(g)) {+ sendJSON(res, { success: false, error: `taskbarGrouping must be one of ${valid.join('|')}`, });+ return;+ }+ // agentActivity was collapsed into taskbarActivity in 2.0.292; callers still passing it get a+ // pointer instead of a silent write to a key nothing reads (audit, 2.0.314).+ if (args.agentActivity !== undefined) { sendJSON(res, { success: false, error: 'agentActivity was removed in 2.0.292; use taskbarActivity: "progress"|"flash"|"off"' }); return; }+ // v1.9.95: reportIssues on|off β the bug-report invitation pup attaches as _reportIssues+ // (on failures, on the first verb of a bridge run, and occasionally after). On by default:+ // reports from real machines are the only way this bridge gets correct. A user who finds it+ // noisy can silence it without losing any functionality.+ const ri = args.reportIssues;+ if (ri !== undefined && !['on', 'off'].includes(ri)) {+ sendJSON(res, { success: false, error: `reportIssues must be one of on|off` });+ return;+ }+ // v1.9.125 / v2.0.110: autoLogin β pup auto-filling a vault-matched login form on page+ // load. 'on'|'submit' = fill (submit stays reason-gated via pup_login); 'fill' = same+ // fill-only; 'off' = do not auto-fill.+ const al = args.autoLogin;+ if (al !== undefined && !['on', 'off', 'submit', 'fill'].includes(al)) {+ sendJSON(res, { success: false, error: `autoLogin must be one of on|off|submit|fill` });+ return;+ }+ // v2.0.110: captureLogins on|off β record form logins into the keychain vault as they submit.+ const cl = args.captureLogins;+ if (cl !== undefined && !['on', 'off'].includes(cl)) {+ sendJSON(res, { success: false, error: `captureLogins must be one of on|off` });+ return;+ }+ if (cl) { savePupSettings({ captureLogins: cl }); console.log(`[configure] captureLogins -> ${cl}`); }+ // v1.9.185: minimalTouch on|off β see the minimalTouch() note. 'on' strips pup down to+ // open-a-window + drive-it-over-CDP, disabling every cosmetic/janitor action pup performs+ // ON Chrome (taskbar identity/badges/jump lists/progress/flash/toasts, the in-page title+ // observer, and the periodic reclaim/placement/reconcile/unstick sweeps).+ const mt = args.minimalTouch;+ if (mt !== undefined && !['on', 'off'].includes(mt)) {+ sendJSON(res, { success: false, error: `minimalTouch must be one of on|off` });+ return;+ }+ if (mt) { savePupSettings({ minimalTouch: mt }); console.log(`[configure] minimalTouch -> ${mt}`); }+ if (ri) { savePupSettings({ reportIssues: ri }); console.log(`[configure] reportIssues -> ${ri}`); }+ if (al) { savePupSettings({ autoLogin: al }); console.log(`[configure] autoLogin -> ${al}`); }+ if (g) {+ savePupSettings({ taskbarGrouping: g });+ console.log(`[configure] taskbarGrouping -> ${g}`);+ // re-stamp every live window under the new identity scheme + refresh badges+ brandSweep('configure').then(async () => {+ try { const first = sessions.values().next().value; if (first) await updateTabCountBadge(first, sessions.keys().next().value); } catch (e) {}+ // split mode: refresh each window's own tab badge+ if (g === 'split') { for (const [sid, sess] of sessions) { updateTabCountBadge(sess, sid).catch(() => {}); } }+ }).catch(() => {});+ }+ // v2.0.154 THREAD TILES on/off β re-stamp every live window so their taskbar buttons move+ // to (or off) the per-thread named tile immediately.+ const tt = args.threadTiles;+ if (tt !== undefined && !['on', 'off'].includes(tt)) { sendJSON(res, { success: false, error: `threadTiles must be one of on|off` }); return; }+ if (tt) {+ savePupSettings({ threadTiles: tt });+ console.log(`[configure] threadTiles -> ${tt}`);+ // Force a full re-register: clear frozen ids AND the registered-AUMID set so every window+ // re-runs registerPupAumid (which is where the tile renders) instead of short-circuiting.+ for (const [sid, sess] of [...sessions]) { try { sess._curAppId = null; sess._frozenAppId = null; } catch (e) {} }+ try { _registeredPupAumids.clear(); } catch (e) {}+ brandSweep('configure-threadtiles').catch(() => {});+ }+ const ob = args.overlayBadges;+ if (ob !== undefined && !['on', 'off'].includes(ob)) { sendJSON(res, { success: false, error: `overlayBadges must be one of on|off` }); return; }+ if (ob) {+ savePupSettings({ overlayBadges: ob });+ console.log(`[configure] overlayBadges -> ${ob}`);+ applyOverlayBadgeSetting(ob, null).catch(() => {});+ }+ // v2.0.262: badge SIZE over the verb too. pup_configure has a habit of missing new keys+ // (selftest T6 caught exactly that for jumpLists), so it is wired in the same pass as the+ // dashboard rather than left for later.+ const osz = args.overlaySize;+ if (osz !== undefined && !['50', '66', '75', '100'].includes(String(osz))) { sendJSON(res, { success: false, error: `overlaySize must be one of 50|66|75|100 β the PERCENT of full size to draw the favicon badge at. 75 is the default; smaller keeps more of the thread tile's bottom line of text readable.` }); return; }+ if (osz !== undefined) {+ savePupSettings({ overlaySize: String(osz) });+ console.log(`[configure] overlaySize -> ${osz}`);+ applyOverlayBadgeSetting(pupSettings().overlayBadges === 'off' ? 'off' : 'on', null).catch(() => {});+ }+ // v2.0.213 (selftest T6 caught it): pup_configure NEVER handled jumpLists β the arg was+ // silently ignored (only the dashboard dialog was wired to refreshAllJumplists), so a+ // toggle through the verb was a no-op and post-respawn re-commits could only come from+ // natural stamp churn. Master + the four task sub-toggles, live-applied.+ for (const _jk of ['jumpLists', 'jlViewTasks', 'jlCloseAll', 'jlDashboard', 'jlAnnotate']) {+ const _jv = args[_jk];+ if (_jv === undefined) continue;+ if (!['on', 'off'].includes(_jv)) { sendJSON(res, { success: false, error: `${_jk} must be one of on|off` }); return; }+ savePupSettings({ [_jk]: _jv, _chosen: { ...(pupSettings()._chosen || {}), [_jk]: true } });+ console.log(`[configure] ${_jk} -> ${_jv}`);+ refreshAllJumplists().catch(() => {});+ }+ const ais = args.aumidIconStyle;+ if (ais !== undefined && !['standard', 'category'].includes(ais)) { sendJSON(res, { success: false, error: `aumidIconStyle must be one of standard|category` }); return; }+ if (ais) { savePupSettings({ aumidIconStyle: ais }); console.log(`[configure] aumidIconStyle -> ${ais}`); for (const [, s] of sessions) { s._curAppId = null; s._frozenAppId = null; } brandSweep('configure-iconstyle').catch(() => {}); }+ // v1.9.86 (Kyle): BOTH of these settings drive Windows-only machinery β taskbar buttons,+ // badges and the progress bar are stamped by desktop_set_window_identity /+ // desktop_taskbar, and stampPupIdentity/brandSweep early-return on non-win32. The+ // setting is still stored (it's a user preference, and the profile may later be+ // read on Windows), but off-Windows it has NO visible effect. The old hint said+ // "Applied to live windows immediately" unconditionally, so on macOS the calling AI+ // was handed a confident description of something that did not happen β and per the+ // SDK's first principle the AI reads this output, never the on-disk skill. Describe+ // the platform we're actually on. (v1.9.91 merge: reuses the module-level _isWin from+ // the v1.9.89 reap work rather than shadowing it with a local const.)+ const _os = process.platform === 'darwin' ? 'macOS' : process.platform;+ const _effect = _isWin+ ? 'Applied to live windows immediately; persists across bridge updates (~/.adom/pup-settings.json).'+ : `NOTE: stored but INERT on ${_os} β pup's taskbar buttons, badges and progress bar are Windows-only (there is no equivalent Dock affordance), so nothing changed on screen. Persists across bridge updates (~/.adom/pup-settings.json) and takes effect if this profile is used on Windows.`;+ const _gHint = g+ ? `taskbarGrouping is now "${g}" β on Windows, ${g === 'grouped' ? 'all pup windows stack under ONE "Adom Pup" taskbar button and its badge shows the WINDOW count' : 'each pup window gets its OWN taskbar button wearing its tab count'}. ${_effect}`+ : `Current pup settings. Set with e.g. {"taskbarGrouping":"grouped"}.${_isWin ? '' : ' taskbarGrouping and taskbarActivity are Windows-only in effect; they are stored but inert here.'}`;+ const _sOut = pupSettings();+ const _tmOut = _sOut.aumidIcons !== 'on' ? 'plain' : (_sOut.threadTiles === 'on' ? 'tiles' : 'aumid');+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, settings: { ..._sOut, taskbarMode: _tmOut }, platform: process.platform, taskbarSupported: _isWin, _hint: _gHint }) });+ return;+ }+ case 'pup_my_windows': {+ // v2.0.103 (John: "shouldn't you give the thread a unique way to talk to you recalling+ // the windows you opened for it?"): one verb, zero args β everything the calling thread+ // owns, with ready-to-run next steps. aiThreadName inspects another thread read-only.+ const who = (args && (args['ai-thread'] || args.aiThread || args.aiThreadName)) || _callerThread;+ if (!who) { sendJSON(res, { success: false, errorCode: 'caller_identity_required', error: 'Identify your thread (--ai-thread "<name>") or pass aiThreadName.' }); return; }+ const wins = [];+ for (const [sid, sM] of sessions) {+ if ((sM.owner || sM._ownerThread) !== who) continue;+ const tabsM = [];+ for (const tM of (sM.tabs || [])) {+ let uM = null, tiM = null;+ try { uM = tM.page.url(); } catch (e) {}+ try { tiM = await Promise.race([tM.page.title(), new Promise(r => setTimeout(() => r(null), 800))]); } catch (e) {}+ tabsM.push({ tabId: tM.tabId, url: uM, title: tiM, active: tM.tabId === sM.activeTabId });+ }+ // emulatedViewport belongs HERE too (project-a5): pup_my_windows advertises itself as+ // "full state of everything your thread owns" and is the verb an AI reaches for first,+ // but it was the one listing that omitted the override entirely, so the first place you+ // look was the one place it could not tell you.+ wins.push({ window: sid, profile: sM.profileName, notYou: /^iso-/.test(sM.profileName || ''), lostBrowser: !!sM._lostBrowser, createdMinAgo: sM.createdAt ? Math.round((Date.now() - sM.createdAt) / 60000) : null, lastAgentUpdate: sM._lastAgentUpdate || null, emulatedViewport: sM._emulatedViewport || null, tabs: tabsM });+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, 'ai-thread': who, windows: wins,+ _hint: wins.length ? `These are ${who === _callerThread ? 'your' : `${who}'s`} windows. Target them with NO ids: any pup verb without sessionId resolves to your own thread's window (or address any thread via "ai-thread"). pup_screenshot {} to see it; pup_reload {} to refresh it; tab verbs take tabId.`+ // v2.0.107: an empty answer during post-restart re-adoption is a LIE OF OMISSION β+ // say so instead of letting the caller conclude its window is gone.+ : (process.uptime() < 90 ? `NO WINDOWS LISTED YET, BUT DO NOT TRUST THIS: the bridge restarted ${Math.round(process.uptime())}s ago and is still re-adopting surviving windows. Retry in ~30s before concluding "${who}" has nothing.` : `No windows for "${who}" yet. pup_open_window {"url":"..."} creates one β pup keys it to the thread name, remembers it, and every later verb finds it with no ids.`),+ _next: threadNextBlock() }) });+ return;+ }+ case 'pup_quit_idle_browsers': {+ // v2.0.98 (maintenance): gracefully Browser.close() every profile browser that has ZERO+ // live sessions. pup deliberately keeps Chrome warm across window closes and bridge+ // restarts, but a warm browser never reloads its --load-extension dirs, so extension+ // updates need the process to actually exit. This is the safe, pup-native way: CDP+ // Browser.close on pup's OWN browser objects, only when no session (hence no window)+ // rides them β never an OS process kill.+ const liveProfiles = new Set();+ for (const s9 of sessions.values()) { if (!s9._lostBrowser && (s9.tabs || []).length) liveProfiles.add(s9.profileName); }+ const closed = [], skipped = [], protectedByPages = [];+ for (const [prof, be9] of [...browsers]) {+ if (liveProfiles.has(prof)) { skipped.push(prof); continue; }+ // v2.0.117 (John β this verb once closed a browser holding 8 windows): "no adopted+ // session" does NOT mean idle. Right after a bridge restart, re-adoption may not have+ // run yet, so a warm browser still holding real windows looks session-less. Before+ // closing, confirm via the browser's ACTUAL open pages that it holds no real window;+ // any non-blank page means it is mid-re-adoption (or user-held), never close it. If the+ // browser can't be inspected, be conservative and skip β a false "idle" here is data loss.+ let realPages = 0;+ try {+ const pages = await Promise.race([be9.browser.pages(), new Promise((_, rej) => setTimeout(() => rej(new Error('pages timeout')), 3000))]);+ for (const pg of (pages || [])) { let u = ''; try { u = pg.url(); } catch (e) {} if (u && u !== 'about:blank' && !/^chrome:\/\/(newtab|new-tab-page)/.test(u)) realPages++; }+ } catch (e) { realPages = -1; }+ if (realPages !== 0) { protectedByPages.push(`${prof} (${realPages < 0 ? 'uninspectable' : realPages + ' open page(s)'})`); skipped.push(prof); continue; }+ try { await be9.browser.close(); } catch (e) {}+ browsers.delete(prof);+ closed.push(prof);+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, closed, skipped, protectedByPages, _hint: closed.length ? `Closed the idle browser process for: ${closed.join(', ')}. The next pup_open_window on these profiles launches a FRESH Chrome, which re-reads staged extensions.` : `No idle browsers to close.${protectedByPages.length ? ` PROTECTED from closing (still hold open windows, likely mid-re-adoption after a restart): ${protectedByPages.join(', ')}.` : ' Every known browser still carries live sessions (listed in skipped).'}` }) });+ return;+ }+ case 'pup_reap_profiles': {+ // v2.0.129: manual trigger for the leaked-isolated-profile reaper (also runs periodically).+ // dryRun:true previews which iso-* throwaway dirs WOULD be removed without deleting anything.+ const dryRun = args.dryRun === true;+ const r = await reapLeakedIsolatedProfiles({ dryRun });+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, dryRun, scannedIsoProfiles: r.scanned, wouldRemoveOrRemoved: r.removed, removed: dryRun ? 0 : r.removed,+ spared: r.spared, inUse: r.held, tooFresh: r.tooFresh, skipped: r.skipped || null, sampleNames: r.removedNames,+ _hint: r.skipped === 'recovery_pending'+ ? 'Skipped this cycle: session recovery is pending (no live sessions but session files exist on disk) β an isolated session could still be re-adopted. Retry after recovery settles.'+ : (dryRun+ ? `${r.removed} leaked iso-* profile(s) would be removed (${r.spared} spared by a live session/file, ${r.held} in-use, ${r.tooFresh} too fresh to touch). Re-run without dryRun to delete them.`+ : `Removed ${r.removed} leaked iso-* profile(s) (${r.spared} spared, ${r.held} in-use, ${r.tooFresh} too fresh). Durable jars (adom-you, named profiles) are never touched β only iso-* throwaways.`)+ }) });+ return;+ }+ case 'pup_uninstall': {+ // v2.0.126 (John): cleanly remove pup's WHOLE machine footprint. Destructive β REASON-+ // GATED behind confirm:"uninstall". dryRun:true reports what WOULD be removed. Keep-flags+ // preserve user data (keepCredentials, keepProfiles). Chrome-for-Testing + firewall rules+ // are SHARED/system, left alone unless removeChrome / removeFirewallRules. Everything runs+ // in-process on the desktop (this bridge IS on the machine) via fs + reg.exe.+ const dry = args && args.dryRun === true;+ const rmChrome = args && args.removeChrome === true;+ // v2.0.130 (#48, Bridge SDK uninstall cascade): ab's uninstaller enumerates bridges and+ // calls each declared <prefix>uninstall NON-INTERACTIVELY during a silent /S uninstall β+ // it must never be refused. cascade:true is the machine path (declared in bridge.json's+ // uninstall field, per the reply on issue #48); confirm:"uninstall" remains the explicit+ // human/AI path. Both idempotent, best-effort per item, never prompting.+ if (args && args.cascade === true && !args.confirm) args.confirm = 'uninstall';+ if (!dry && (!args || args.confirm !== 'uninstall')) {+ sendJSON(res, { success: false, errorCode: 'confirm_required', _hint: 'pup_uninstall REMOVES pup\'s footprint from this machine (registry AUMID keys, staged extensions, all pup profiles, the login vault, settings, temp + launcher files). Preview first: pup_uninstall {dryRun:true}. To actually remove: pup_uninstall {confirm:"uninstall"}. Keep your saved logins: add keepCredentials:true. Keep profiles/sessions: keepProfiles:true. Chrome-for-Testing and firewall rules are left unless removeChrome:true / removeFirewallRules:true.' });+ return;+ }+ const home = os.homedir();+ const P = (...a) => path.join(home, '.adom', ...a);+ const dirTargets = [+ { path: PROFILES_DIR, label: 'pup-profiles (all windows/logins)', skip: args && args.keepProfiles === true },+ { path: P('pup-sessions'), label: 'pup-sessions', skip: args && args.keepProfiles === true },+ { path: P('pup-sessions-bak'), label: 'pup-sessions-bak', skip: args && args.keepProfiles === true },+ { path: P('pup-extension'), label: 'toolbar extension' },+ { path: P('pup-identity-extension'), label: 'identity extension' },+ { path: P('pup-identity-anon-extension'), label: 'anon identity extension' },+ { path: P('pup-annotate-extension'), label: 'annotate extension' },+ { path: P('pup-avatars'), label: 'pup-avatars' },+ { path: path.join(require('os').tmpdir(), 'pup-badge-debug'), label: 'badge-debug temp' },+ // v2.0.161: the AUMID icon caches (ALL generations) β thread-name tiles + notched category+ // tiles the taskbar keys pointed at. The registry keys are wiped below; wipe their .ico+ // files too so uninstall leaves zero pup footprint in %TEMP%.+ { path: path.join(require('os').tmpdir(), 'pup-thread-tiles'), label: 'thread-tile icons (AUMID)' },+ { path: path.join(require('os').tmpdir(), 'pup-notched-icons'), label: 'category tile icons (AUMID)' },+ ];+ const fileTargets = [+ { path: PUP_SETTINGS_PATH, label: 'pup-settings.json' },+ { path: pupJumplistLauncherPath(), label: 'jump-list launcher' },+ { path: path.join(home, '.adom', 'pup-jumplist-port'), label: 'jump-list port file' },+ ];+ if (rmChrome) dirTargets.push({ path: path.join(home, '.cache', 'puppeteer', 'chrome'), label: 'Chrome-for-Testing install' });+ // count/measure+ const exists = (p) => { try { return fs.existsSync(p); } catch (e) { return false; } };+ const willRemoveDirs = dirTargets.filter(t => !t.skip && exists(t.path));+ const willRemoveFiles = fileTargets.filter(t => exists(t.path));+ // AUMID registry keys+ let aumidKeys = [];+ if (_isWin) { try { aumidKeys = String(execFileSync('reg', ['query', 'HKCU\\Software\\Classes\\AppUserModelId'], { encoding: 'utf8', timeout: 5000, windowsHide: true })).split(/\r?\n/).filter(l => /\\Adom\.Pup/i.test(l)).map(l => l.trim()); } catch (e) {} }+ const vaultCount = (args && args.keepCredentials === true) ? 0 : (() => { try { return credentialVault.listCredentials().length; } catch (e) { return 0; } })();++ const plan = {+ registryKeys: aumidKeys.length,+ profilesAndSessions: (args && args.keepProfiles === true) ? 'KEPT' : willRemoveDirs.filter(t => /profile|session/.test(t.label)).map(t => t.label),+ directories: willRemoveDirs.map(t => t.label),+ files: willRemoveFiles.map(t => t.label),+ vaultCredentials: (args && args.keepCredentials === true) ? 'KEPT' : vaultCount,+ chromeForTesting: rmChrome ? 'WILL REMOVE' : 'kept (shared)',+ firewallRules: (args && args.removeFirewallRules === true) ? 'WILL REMOVE' : 'kept',+ };+ if (dry) { sendJSON(res, { success: true, output: JSON.stringify({ ok: true, dryRun: true, plan, _hint: `Preview only β nothing removed. To do it: pup_uninstall {confirm:"uninstall"}${args && args.keepCredentials ? ', keepCredentials:true' : ''}${args && args.keepProfiles ? ', keepProfiles:true' : ''}.` }) }); return; }++ // EXECUTE. First close every window + quit every browser so profile dirs unlock.+ const removed = { registryKeys: 0, dirs: [], files: [], vaultCredentials: 0, errors: [] };+ try { for (const [pName, be] of [...browsers]) { try { await killBrowserProcess(be.browser, pName); } catch (e2) {} } browsers.clear(); sessions.clear(); } catch (e) { removed.errors.push('close-all: ' + e.message); }+ await new Promise(r => setTimeout(r, 1500));+ // registry+ if (_isWin) { for (const line of aumidKeys) { try { execFileSync('reg', ['delete', line, '/f'], { timeout: 5000, windowsHide: true, stdio: 'ignore' }); removed.registryKeys++; } catch (e) { removed.errors.push('reg ' + line + ': ' + e.message); } } }+ // dirs+ for (const t of willRemoveDirs) { try { fs.rmSync(t.path, { recursive: true, force: true, maxRetries: 3, retryDelay: 300 }); removed.dirs.push(t.label); } catch (e) { removed.errors.push(t.label + ': ' + e.message); } }+ // files+ for (const t of willRemoveFiles) { try { fs.rmSync(t.path, { force: true }); removed.files.push(t.label); } catch (e) { removed.errors.push(t.label + ': ' + e.message); } }+ // vault+ if (!(args && args.keepCredentials === true)) { try { const w = await credentialVault.wipeAll(); removed.vaultCredentials = w.removed; } catch (e) { removed.errors.push('vault: ' + e.message); } }+ // firewall (needs admin; best-effort)+ if (_isWin && args && args.removeFirewallRules === true) { try { execFileSync('powershell', ['-NoProfile', '-Command', "Get-NetFirewallApplicationFilter | Where-Object { $_.Program -match 'puppeteer.*chrome' } | Remove-NetFirewallRule -ErrorAction SilentlyContinue"], { timeout: 15000, windowsHide: true, stdio: 'ignore' }); removed.firewallRules = 'attempted'; } catch (e) { removed.errors.push('firewall (needs admin): ' + e.message); } }++ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, removed, _hint: `pup uninstalled from this machine. Removed ${removed.registryKeys} registry key(s), ${removed.dirs.length} dir(s), ${removed.files.length} file(s), ${removed.vaultCredentials} vault credential(s).${removed.errors.length ? ' Some items errored (may need Chrome fully closed or admin): ' + removed.errors.slice(0, 5).join('; ') : ''} Chrome-for-Testing ${rmChrome ? 'removed' : 'left in place (shared)'}. The bridge itself is managed by Adom Bridge β remove pup from Bridge to stop it re-seeding.` }) });+ return;+ }+ case 'pup_rescan': {+ try { await auditStampedIdentities(); } catch (e) {} // v2.0.386 (#83): rescan also re-checks every window's stamped identity+ // v1.5.1+: orphan-recovery primitive. Walk every known profile+ // (in-memory + on-disk session files), attempt reconnect for any+ // whose CDP socket dropped, then walk pages of every connected+ // browser and rebuild session entries by parsing the (session: X)+ // tag from each page's title. Idempotent β pages already attached+ // to a live session are left alone.+ //+ // Args:+ // adoptOrphans (optional bool, default false) β when true,+ // pages with no (session: X) tag are attached under a+ // generated sessionId so the caller can drive them. Useful+ // when Chrome has tabs the bridge has never seen (e.g. user+ // opened them manually). Default false to avoid surprising+ // callers with random tabs in their sessions list.+ try {+ const adoptOrphans = !!args.adoptOrphans;+ // v2.0.321 (#72 ask): rescan also resets each tab's input CDP session, so a wedged input+ // channel is recovered by pup_rescan (in addition to the automatic discard-on-timeout).+ for (const [, ss] of sessions) { for (const t of (ss.tabs || [])) { try { if (t && t._inputCdp) killInputCdp(t); } catch (e) {} } }+ const stats = await rescanAllProfiles({ adoptOrphans });+ brandSweep('rescan').catch(() => {}); // v1.8.79: unify icons on re-adopted windows+ // Build the post-rescan session list so callers can verify+ // their sessionId is back. liveSessions are usable; disconnected+ // are still awaiting Chrome to reappear (PID gone or port dead).+ const liveSessions = [];+ const disconnectedSessions = [];+ for (const [id, s] of sessions) {+ if (isSessionAlive(s)) {+ liveSessions.push(id);+ } else if (s._lostBrowser) {+ disconnectedSessions.push(id);+ }+ }+ sendJSON(res, {+ success: true,+ output: JSON.stringify({+ ok: true,+ rescanned: stats.profilesScanned,+ profilesReconnected: stats.profilesReconnected,+ profilesUnreachable: stats.profilesUnreachable,+ reattached: stats.totalReattached,+ orphansAdopted: stats.totalAdopted,+ liveSessions,+ disconnectedSessions,+ _hint:+ disconnectedSessions.length > 0+ ? `Some sessions still disconnected: [${disconnectedSessions.join(', ')}]. Their Chrome PID may be dead β check with pup_list_windows. pup_open_window (with the original sessionId + profile) will start fresh; the on-disk session file is overwritten.`+ : (liveSessions.length > 0+ ? `All known sessions are live: [${liveSessions.join(', ')}].`+ : `No sessions known to the bridge. pup_open_window to start one.`),+ }),+ });+ } catch (e) {+ sendJSON(res, { success: false, error: `rescan failed: ${e.message}` });+ }+ return;+ }+ case 'pup_close_window': {+ invalidateEnumCache(); // v2.0.187: the desktop is about to change+ const sessionId = args.sessionId;+ if (!requireLiveSession(res, { ...args, sessionId })) return;+ const session = sessions.get(sessionId);+ // v1.8.16: refuse a declared cross-owner close (destroying another thread's window).+ { const refusal = ownershipRefusal(session, sessionId, args, 'pup_close_window'); if (refusal) { sendJSON(res, refusal); return; } }+ // v1.9.189: prune this window's AUMID key(s) AFTER the close settles. Previously keys were+ // only pruned at boot, so every closed window left registry state behind until the next+ // restart (audit found Adom.Pup.mt3.web.i3 alive long after mt3 was gone).+ setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);+ const profileName = session.profileName;++ // Close ALL tabs in this session (a window can now hold many tabs)+ const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;+ // v2.0.46 (mimic-ralph: closing a freshly-RECOVERED multi-tab window left the window ALIVE+ // as an orphan, later adopted as a weird adom-you-win**** session β the ghost windows John+ // saw): recovery binds ONE tab per session and the sibling-tab rescan runs later, so+ // closing only the BOUND tabs closes 1 of N and the OS window survives with the rest.+ // Close by WINDOW instead: collect the CDP windowId of every bound tab, then close EVERY+ // page in the browser that lives in one of those windows β bound to this session or not.+ // Any window this session owns dies whole; other sessions' windows have different+ // windowIds and are untouched.+ const _winIds = new Set();+ for (const t of [...session.tabs]) {+ try {+ const c = await t.page.target().createCDPSession();+ try { const { windowId } = await c.send('Browser.getWindowForTarget'); if (windowId != null) _winIds.add(windowId); } finally { try { await c.detach(); } catch (e) {} }+ } catch (e) {}+ }+ if (_winIds.size) {+ try {+ const _b = browsers.get(session.profileName) && browsers.get(session.profileName).browser;+ const _boundPages = new Set([...session.tabs].map(t => t.page));+ for (const p of (_b ? await _b.pages() : [])) {+ if (_boundPages.has(p)) continue; // bound tabs close below (keeps their bookkeeping)+ try {+ const c = await p.target().createCDPSession();+ let wid = null;+ try { ({ windowId: wid } = await c.send('Browser.getWindowForTarget')); } finally { try { await c.detach(); } catch (e) {} }+ if (wid != null && _winIds.has(wid)) {+ // never close a page another session tracks (shared-profile safety)+ let _foreign = false;+ for (const [ok2, os2] of sessions) { if (ok2 !== sessionId && os2 && (os2.tabs || []).some(t2 => t2 && t2.page === p)) { _foreign = true; break; } }+ if (!_foreign) { console.log(`[close] "${sessionId}" closing UNBOUND sibling tab in its window (${(p.url() || '').slice(0, 60)})`); await p.close().catch(() => {}); }+ }+ } catch (e) {}+ }+ } catch (e) {}+ }+ for (const t of [...session.tabs]) {+ try { await t.page.close(); } catch (e) {}+ }+ session.tabs.length = 0;+ session.activeTabId = null;+ sessions.delete(sessionId);+ _sessionOwnerClaims.delete(sessionId); // v1.9.110: the id is free again+ deleteSessionFile(sessionId);+ clearSessionBusy(sessionId); // v1.9.57: drop any pending progress timer+ unregisterPupAumid(_closingAppId).catch(() => {}); // v1.9.2/#207 cleanup (appId captured pre-delete)++ // #434 / v2.0.256: the ground-truth decision now lives in ONE primitive+ // (killProfileBrowserIfUnused) so no call site can reintroduce the refCount kill.+ let _reap = { killed: 0, remaining: 0 };+ _reap = await killProfileBrowserIfUnused(profileName, 'session closed (all tabs)');++ if (activeSessionId === sessionId) {+ activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;+ }+ console.log(`Session "${sessionId}" closed (all tabs). Remaining sessions: ${sessions.size}`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, remaining: sessions.size, activeSession: activeSessionId,+ chromeProcsKilled: _reap.killed, chromeProcsRemaining: _reap.remaining,+ ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) for this profile SURVIVED teardown (should be 0) and may peg a CPU core. The box may be under heavy load β retry pup_close_window; if it persists it is a reap bug worth reporting.` } : {}),+ }) });+ return;+ }++ // v2.0.21 (John): the "Owned by: <thread>" jump-list callback β show everything about this+ // window as a NATIVE always-on-top caption card just above the taskbar (ab desktop_caption,+ // click-through, auto-expires). Replaces the title-tag approach to conveying ownership.+ // Read-only + not owner-gated: any thread (and the jump-list click itself) may ask.+ case 'pup_window_info': {+ const sid = args.sessionId || activeSessionId;+ const session = sid && sessions.get(sid);+ if (!session) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }, 404); return; }+ const ownT = session.owner || session._ownerThread || null;+ const ageMin = session.createdAt ? Math.round((Date.now() - session.createdAt) / 60000) : null;+ const tabs = [];+ try {+ for (const t of (session.tabs || [])) {+ let u = null, ti = null;+ try { u = t.page.url(); } catch (e) {}+ try { ti = await Promise.race([t.page.title(), new Promise(r => setTimeout(() => r(null), 1500))]); } catch (e) {}+ tabs.push({ tabId: t.tabId, url: u, title: ti, active: t.tabId === session.activeTabId });+ }+ } catch (e) {}+ const lu = session._lastAgentUpdate || null;+ const _fmtAge = (m) => m == null ? 'unknown' : (m < 60 ? `${m} min` : `${Math.round(m / 6) / 10} h`);+ const lines = [];+ lines.push(`Adom Pup window "${sid}" Β· owned by AI thread: ${ownT || 'unclaimed'}`);+ const bits = [];+ if (session._ownerContainer) bits.push(`container ${session._ownerContainer}`);+ bits.push(`profile ${session.profileName || '?'}`);+ bits.push(`${tabs.length} tab${tabs.length === 1 ? '' : 's'}`);+ bits.push(`open ${_fmtAge(ageMin)}`);+ lines.push(bits.join(' Β· '));+ const at = tabs.find(t => t.active) || tabs[0];+ if (at) lines.push(`Showing: ${(at.title || at.url || '?').slice(0, 90)}`);+ if (lu) lines.push(`Last agent action: ${lu.verb}${lu.note ? ` Β· ${lu.note}` : ''} (${_fmtAge(Math.round((Date.now() - lu.ts) / 60000))} ago)`);+ if (session._lastTakeoverBy) lines.push(`β last takeover: by "${session._lastTakeoverBy}" ${_fmtAge(Math.round((Date.now() - (session._takenOverAt || Date.now())) / 60000))} ago`);+ // v2.0.130 (#44, John's design): the TOOLBAR POPUP's Window-info click gets a proper+ // IN-PAGE floaty card (brand tokens, draggable grip, 600ms viewport-clamped tooltips+ // teaching the ai-thread-owns-window model) instead of the always-a-bit-wonky ab desktop+ // caption. Jump-list clicks KEEP the caption (no reliable in-page surface exists from the+ // taskbar); AI/JSON callers get the data either way. Card falls back to the caption if+ // injection fails, so the user never gets nothing.+ const _fromToolbar = _callerThread === 'user-toolbar' || (args.caller && args.caller.aiThread === 'user-toolbar');+ let cardShown = false, captionShown = false;+ if (_fromToolbar) {+ try {+ const _cardTab = (session.tabs || []).find(t => t.tabId === (args._resolvedTabId || session.activeTabId)) || (session.tabs || [])[0];+ const _cardInfo = {+ sid, owner: ownT || 'unclaimed', container: session._ownerContainer || null,+ profile: session.profileName || '?', age: _fmtAge(ageMin),+ tabs: tabs.map(t => ({ label: (t.title || t.url || '?').slice(0, 64), active: !!t.active })),+ last: lu ? `${lu.verb}${lu.note ? ': ' + lu.note : ''} (${_fmtAge(Math.round((Date.now() - lu.ts) / 60000))} ago)` : null,+ takeover: session._lastTakeoverBy ? `by "${session._lastTakeoverBy}" ${_fmtAge(Math.round((Date.now() - (session._takenOverAt || Date.now())) / 60000))} ago` : null,+ };+ await _cardTab.page.evaluate((d) => {+ try {+ const old = document.querySelector('div[data-pup-infocard]');+ if (old) old.remove();+ const host = document.createElement('div');+ host.setAttribute('data-pup-infocard', '1');+ host.style.cssText = 'position:fixed;top:24px;right:24px;z-index:2147483647;margin:0;padding:0;';+ const sh = host.attachShadow ? host.attachShadow({ mode: 'open' }) : null;+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));+ const TIPS = {+ thread: 'The ai-thread is the identifier for pup windows: you get one pup window per ai-thread, and pup default-creates tabs in that window for everything the thread manages or needs a browser surface for.',+ container: 'The cloud container the owning AI thread runs in. That is where the AI driving this window lives.',+ profile: 'The Chrome profile (cookie jar) this window rides. adom-you is the shared signed-in jar every window defaults to; iso-* jars are throwaway anonymous profiles.',+ tabs: 'Every tab in this window. The owning thread adds tabs here instead of opening new windows, so one taskbar button holds all its pages.',+ age: 'How long this window has been open. pup windows persist until their thread (or you) closes them.',+ last: 'The most recent pup action the owning AI ran against this window, with the note it left.',+ takeover: 'Another thread claimed this window from its previous owner. Takeovers are logged and only allowed when the user asked.',+ };+ let rows = '';+ const row = (k, label, val, accent) => { rows += `<div class="r" data-tip="${esc(TIPS[k] || '')}"><span class="k">${esc(label)}</span><span class="v${accent ? ' a' : ''}">${esc(val)}</span></div>`; };+ row('thread', 'ai-thread', d.owner, true);+ if (d.container) row('container', 'container', d.container);+ row('profile', 'profile', d.profile);+ row('age', 'open', d.age);+ if (d.last) row('last', 'last action', d.last);+ if (d.takeover) row('takeover', 'takeover', d.takeover);+ let tabsHtml = '';+ for (const t of (d.tabs || [])) tabsHtml += `<div class="t${t.active ? ' on' : ''}">${t.active ? 'βΈ ' : ''}${esc(t.label)}</div>`;+ const html = `<style>+ .card{width:340px;max-width:calc(100vw - 48px);background:#0d1117;border:1px solid #30363d;border-radius:10px;box-shadow:0 12px 34px rgba(0,0,0,.55);font:12px/1.45 "Segoe UI",system-ui,sans-serif;color:#c9d1d9;overflow:hidden;user-select:none}+ .hd{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#161b22;border-bottom:1px solid #30363d;cursor:grab}+ .hd:active{cursor:grabbing}+ .grip{color:#8b949e;font-size:13px;letter-spacing:1px}+ .ttl{font-weight:600;color:#e6edf3;flex:1}+ .x{cursor:pointer;color:#8b949e;padding:2px 6px;border-radius:5px;font-size:13px}+ .x:hover{color:#e6edf3;background:#21262d}+ .bd{padding:8px 10px}+ .r{display:flex;gap:8px;padding:3px 2px;border-radius:5px}+ .r:hover{background:#161b22}+ .k{color:#8b949e;min-width:78px}+ .v{color:#c9d1d9;flex:1;word-break:break-word}+ .v.a{color:#00b8b1;font-weight:600}+ .tabs{margin-top:6px;border-top:1px solid #21262d;padding-top:6px}+ .t{color:#8b949e;padding:2px 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+ .t.on{color:#e6edf3}+ .teach{margin-top:8px;padding:7px 9px;background:#0b2b29;border:1px solid rgba(0,184,177,.35);border-radius:7px;color:#7fe9e2;font-size:11px}+ .tip{position:fixed;z-index:2147483647;max-width:260px;background:#161b22;border:1px solid #30363d;border-radius:7px;padding:7px 9px;color:#c9d1d9;font-size:11px;line-height:1.45;box-shadow:0 8px 22px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .18s ease;text-transform:none}+ .tip.show{opacity:1}+ </style>+ <div class="card">+ <div class="hd" data-drag><span class="grip">β Ώ</span><span class="ttl">Adom Pup: window "${esc(d.sid)}"</span><span class="x" data-x title="Close">β</span></div>+ <div class="bd">${rows}<div class="tabs" data-tip="${esc(TIPS.tabs)}">${tabsHtml}</div>+ <div class="teach">One pup window per ai-thread: the thread named above owns this window, and everything it needs a browser for opens as a tab here.</div></div>+ </div><div class="tip" data-tip-el></div>`;+ if (sh) sh.innerHTML = html; else host.innerHTML = html;+ const root = sh || host;+ document.documentElement.appendChild(host);+ // close+ root.querySelector('[data-x]').addEventListener('click', () => host.remove());+ // drag by the grip header, clamped to the viewport+ const hd = root.querySelector('[data-drag]');+ let dx = 0, dy = 0, dragging = false;+ hd.addEventListener('pointerdown', (e) => {+ if (e.target.closest('[data-x]')) return;+ dragging = true; const r = host.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top;+ try { hd.setPointerCapture(e.pointerId); } catch (e2) {}+ });+ hd.addEventListener('pointermove', (e) => {+ if (!dragging) return;+ const w = host.offsetWidth, h = host.offsetHeight;+ const L = Math.max(8, Math.min(innerWidth - w - 8, e.clientX - dx));+ const T = Math.max(8, Math.min(innerHeight - h - 8, e.clientY - dy));+ host.style.left = L + 'px'; host.style.top = T + 'px'; host.style.right = 'auto';+ });+ hd.addEventListener('pointerup', () => { dragging = false; });+ // tooltip singleton: 600ms delay, clamped to the viewport on BOTH axes+ const tip = root.querySelector('[data-tip-el]');+ let tmr = null;+ root.querySelectorAll('[data-tip]').forEach((el) => {+ el.addEventListener('mouseenter', (e) => {+ const txt = el.getAttribute('data-tip');+ if (!txt) return;+ clearTimeout(tmr);+ tmr = setTimeout(() => {+ tip.textContent = txt;+ tip.classList.add('show');+ // measure AFTER content set+ const tw = tip.offsetWidth, th = tip.offsetHeight, M = 8;+ let x = e.clientX + 14, y = e.clientY + 14;+ if (x + tw + M > innerWidth) x = e.clientX - tw - 14;+ if (y + th + M > innerHeight) y = e.clientY - th - 14;+ x = Math.max(M, Math.min(innerWidth - tw - M, x));+ y = Math.max(M, Math.min(innerHeight - th - M, y));+ tip.style.left = x + 'px'; tip.style.top = y + 'px';+ }, 600);+ });+ el.addEventListener('mouseleave', () => { clearTimeout(tmr); tip.classList.remove('show'); });+ });+ } catch (e) {}+ }, _cardInfo);+ cardShown = true;+ } catch (e) { console.log(`[window-info] in-page card injection failed (${e.message}) β falling back to the ab caption`); }+ }+ // v2.0.131 (John: "why did you show this caption?"): an AI CALLER querying window info is+ // a DATA request β it must never paint anything on the user's screen. The caption is a+ // USER surface, shown only when the USER clicked the jump-list task (user-taskbar-menu);+ // toolbar clicks get the in-page card above. AI callers get JSON only, opt-in+ // showCaption:true for the rare "identify this window to the user" case. (The incident:+ // an AI called this just to get the hwnd and the user got a 12s caption over their work.)+ const _userInitiated = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+ if (!cardShown && (_userInitiated || args.showCaption === true)) {+ try {+ const r = await chrome.abCommand('desktop_caption', {+ id: `pup-info-${sid}`, text: lines.join('\n'),+ position: 'bottom', size: 'medium', duration: 12000,+ }, { timeoutMs: 8000, caller: sessionCaller(session) });+ captionShown = !(r && r.success === false);+ } catch (e) {}+ }+ // v2.0.131: include the hwnd β the pup_open_window _next text promises "pup_window_info+ // hands you this window's hwnd for desktop_screenshot_window", but the response never+ // carried it, so callers hwnd-hunted anyway (and tripped the caption doing it).+ let _hwnd = null;+ try { _hwnd = session._hwnd ? Number(session._hwnd) : (await sessionHwnd(sid)); } catch (e) {}+ sendJSON(res, {+ success: true, sessionId: sid, owner: ownT, ownerContainer: session._ownerContainer || null,+ profile: session.profileName || null, ageMinutes: ageMin, tabs, lastAgentUpdate: lu,+ hwnd: _hwnd || null,+ foreground: !!session._foreground, lastTakeoverBy: session._lastTakeoverBy || null,+ cardShown, captionShown,+ _hint: cardShown+ ? 'An in-page floaty card with this info is showing in the window (draggable by its grip, hover any row for its explanation, β to close).'+ : (captionShown+ ? 'A native card with this info is showing just above the taskbar for ~12s (click-through, auto-dismisses).'+ : 'Info returned as JSON only β nothing was drawn on the user\'s screen (hwnd included for desktop_screenshot_window when you need the browser chrome in frame). A USER clicking the jump-list/toolbar gets the visual card; an AI caller can opt in with showCaption:true when the user asked to have the window identified.'),+ });+ return;+ }+ // v2.0.130 (#43, ah leg revived on John's "do them all"): MOVE this pup window's page into+ // the user's Hydrogen workspace. Design: docs/PLAN-interactive-surface.md Phase 3. The+ // plan's direct workspace-tabs endpoint (port 47083) is not exposed by today's Hydrogen, so+ // this rides the REAL current API: ab's hd_api -> Hydrogen control API POST /open-url,+ // which routes through the Browser Picker (per-domain memory; webview is the default+ // surface, exactly the "move to my workspace" semantic). On success the pup window closes+ // (its owner initiated the move) unless keepWindow:true. hc (cloud) stays blocked on the+ // workspace-mutation auth ask recorded in the plan.+ case 'pup_move_to_workspace': {+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_move_to_workspace'); if (refusal) { sendJSON(res, refusal); return; } }+ let moveUrl = null;+ try { const at = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0]; moveUrl = at ? at.page.url() : session.page.url(); } catch (e) {}+ if (!moveUrl || /^(about:|chrome:|edge:|devtools:)/i.test(moveUrl)) {+ sendJSON(res, { success: false, errorCode: 'no_movable_url', error: `This window's active tab has no movable URL (${moveUrl || 'none'}).` });+ return;+ }+ let hd = null;+ try { hd = await chrome.abCommand('hd_api', { method: 'POST', path: '/open-url', body: { url: moveUrl } }, { timeoutMs: 12000, caller: sessionCaller(session) });+ } catch (e) { hd = { success: false, error: e.message }; }+ const hdOk = hd && hd.success !== false && !(hd.body && hd.body.error);+ if (!hdOk) {+ sendJSON(res, { success: false, errorCode: 'hydrogen_unreachable', error: `Hydrogen did not accept the URL: ${(hd && (hd.error || (hd.body && hd.body.error))) || 'unknown error'}`, _hint: 'Moving a pup page to the workspace needs Hydrogen (ah) running on this machine β check hd_status. The pup window was left untouched.' });+ return;+ }+ let windowClosed = false;+ if (args.keepWindow !== true) {+ try {+ const profileName = session.profileName;+ const _closingAppId = session._curAppId || `${PUP_APP_ID}.${resolvedId}`;+ for (const t of [...(session.tabs || [])]) { try { await t.page.close(); } catch (e) {} }+ session.tabs.length = 0; session.activeTabId = null;+ sessions.delete(resolvedId);+ deleteSessionFile(resolvedId);+ unregisterPupAumid(_closingAppId).catch(() => {});+ await killProfileBrowserIfUnused(profileName, 'window closed');+ windowClosed = true;+ setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);+ } catch (e) {}+ }+ console.log(`[move-to-workspace] "${resolvedId}" -> Hydrogen (${moveUrl.slice(0, 80)}) β windowClosed=${windowClosed}`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, movedUrl: moveUrl, windowClosed, sessionId: resolvedId,+ _hint: windowClosed+ ? 'The page was handed to Hydrogen (its Browser Picker routes it β webview by default, honoring the user\'s per-domain choice) and this pup window closed. The user picks the surface if the picker prompts.'+ : 'The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true).',+ }) });+ return;+ }+ // v1.9.3 (AD #208): the taskbar jump-list callback β flip THIS wiki window between the+ // logged-in and public view IN PLACE. A view switch = a cookie-jar (userDataDir) switch,+ // fixed at browser launch, so it's a close-and-relaunch of the same sessionId under the+ // other profile at the same URL. Invoked when the user clicks the per-window taskbar+ // right-click task (runs AD's bundled CLI β this verb); it's a USER action, so the window+ // is brought on-screen. Not meant for AI threads (they pass wikiView on pup_open_window).+ case 'pup_wiki_set_view': {+ const sessionId = args.sessionId;+ const _lv = requireLiveSession(res, { ...args, sessionId });+ if (!_lv) return;+ const session = _lv.session;+ let url = null; try { url = session.page.url() || null; } catch (e) {}+ if (!url || !isAdomUrl(url)) { sendJSON(res, { success: false, error: 'wiki view toggle applies only to Adom (adom.inc) URLs', output: JSON.stringify({ ok: false, url }) }); return; }+ const curView = session._wikiView === 'authed' ? 'authed' : 'public';+ // v2.0.139 (John: "add a shortcut key like annotate has"): view:"toggle" flips whatever+ // the window currently shows β the Alt+Shift+V hotkey's form, since the extension can't+ // know the current view. Explicit "authed"/"public" behave as before.+ // v2.0.241 (caught while verifying the guard rewrite): an UNRECOGNISED view was silently+ // coerced to 'public'. A caller asking for "signed-in" got the public view plus a response+ // claiming view:"public", unchanged:true β technically true, completely misleading. Reject+ // it and name the accepted values instead of guessing what the caller meant.+ if (args.view !== undefined && !['authed', 'public', 'toggle'].includes(args.view)) {+ sendJSON(res, { success: false, error: `view must be one of authed|public|toggle (got "${args.view}")`,+ _hint: 'authed = the signed-in view, public = what a logged-out visitor sees, toggle = flip whichever it is now. There is no "signed-in"/"private" alias: this used to fall through to public silently.' });+ return;+ }+ const view = args.view === 'toggle'+ ? (curView === 'authed' ? 'public' : 'authed')+ : (args.view === 'authed' ? 'authed' : 'public');+ const owner = session.owner || null;+ const publicProfile = session._publicProfile || sessionId;+ if (curView === view) {+ // v1.9.332 (John clicked "Switch to logged-in view" and "it did nothing"). pup BELIEVED the+ // window was already in this view, so it only brought it to front β no reload. But the task+ // PROMISES a "harmless reload", and pup's view belief is unreliable (a cookie expired, another+ // window flipped auth, or the initial default-authed never actually signed the page in). So a+ // real USER click ALWAYS re-asserts the requested view: it falls through to the mint/navigate+ // path below, which is a genuine reload that GUARANTEES the page is in that view (for authed it+ // re-mints + navigates, i.e. actually signs it in). Only a PROGRAMMATIC same-view call short-+ // circuits with bringToFront.+ const _userClick = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+ if (!_userClick) {+ session._foreground = true;+ try { await session.page.bringToFront(); } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, unchanged: true, url }) });+ return;+ }+ console.log(`[wikiview] "${sessionId}" user clicked "${view}" while pup thought it was already ${curView} β re-asserting the view (harmless reload), NOT skipping (this is why the click "did nothing")`);+ // fall through to the switch logic below, which navigates/asserts the requested view+ }+ // v2.0.132 (adom/wiki#126 β Colby shipped pup's own ask; John: "implement that instead"):+ // THE PRIMARY PATH. ?view=public makes the server render any page GET exactly as a+ // signed-out visitor (request's user nulled; the real session untouched; byte-identical;+ // private pages 404). So the toggle is now a plain IN-PLACE reload of every wiki tab with+ // the param appended (public) or stripped (authed): no relaunch, no cookie wipe, no+ // profile switch, works on the shared jar, and the user just sees tabs reload in their+ // window β exactly what John expected all along. The jar-relaunch machinery below+ // survives only as the fallback for old servers that ignore the param, and for windows+ // still sitting on a legacy iso-* jar (their authed direction falls through to the+ // relaunch home to adom-you).+ if (!/^iso-/.test(session.profileName || '') || view === 'public') {+ try {+ const _wTabs = (session.tabs || []).filter(t => { try { return isAdomUrl(t.page.url()); } catch (e) { return false; } });+ if (_wTabs.length) {+ for (const t of _wTabs) {+ let tu = null; try { tu = t.page.url(); } catch (e) {}+ if (!tu) continue;+ const target = view === 'public' ? wikiPublicUrl(tu) : wikiStripPublicParam(tu);+ try { await t.page.goto(target, { waitUntil: 'domcontentloaded', timeout: 25000 }); } catch (e) {}+ }+ const _at = _wTabs.find(t => t.tabId === session.activeTabId) || _wTabs[0];+ // v2.0.133 (caught live in verification): checkWikiAuth is a same-origin API fetch+ // that carries the REAL session cookie β which ?view=public deliberately leaves+ // untouched β so it always reports "authed" and made this path conclude the server+ // ignored the param, falling back to the jar relaunch it was built to replace.+ // The param changes the RENDER, so probe the RENDER: a logged-out page shows the+ // Login button; a signed-in one shows the profile menu instead.+ let _renderLoggedOut = null;+ try {+ _renderLoggedOut = await Promise.race([+ _at.page.evaluate(() => { try { return [...document.querySelectorAll('a,button')].some(e => /^\s*Login\s*$/i.test(e.textContent || '')); } catch (e) { return null; } }),+ new Promise(r => setTimeout(() => r(null), 6000)), // covers the bounded load wait above+ ]);+ } catch (e) {}+ let _a = null;+ if (view === 'authed') { try { _a = await checkWikiAuth(_at.page); } catch (e) {} }+ const _renderedAuthed = view === 'authed' ? !!(_a && _a.authed) : false;+ // v2.0.134 (John: "you're STILL resizing the window on me"): the public direction+ // NEVER falls back to the jar relaunch anymore. The old guard probed the header for+ // the Login button right after domcontentloaded β when the header rendered a beat+ // late the probe missed, wrongly concluded "server ignored the param", and+ // relaunched the window (the resize John kept seeing). The param is live on prod+ // and verified; a possibly-late header paint is never worth destroying the user's+ // window over. The probe survives for LOGGING only.+ if (view === 'public' && _renderLoggedOut === false) {+ console.log(`[wikiview] "${sessionId}" header probe did not see the logged-out marker yet (late paint or old server) β TRUSTING ?view=public, staying in place (no relaunch, by design)`);+ }+ if (view === 'authed' && !_renderedAuthed) {+ // Param stripped but the jar holds no live wiki session β continue into the+ // existing mint / auto-sign / manual-login machinery below with a clean URL.+ console.log(`[wikiview] "${sessionId}" param strip left the window logged out (no live session) β continuing to the sign-in path`);+ url = wikiStripPublicParam(url);+ } else {+ session._wikiView = view;+ try { await setWikiAuthedState(session, sessionId, view === 'authed', {}); } catch (e) {}+ try { await setWikiModeGlyph(_at.page, view === 'authed' ? 'β Logged in' : 'β Public'); } catch (e) {}+ session._wikiJumplistKey = undefined;+ updateWikiJumplist(session, sessionId).catch(() => {});+ updateThumbnailTooltip(session, sessionId).catch(() => {});+ const _userClickP = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+ if (_userClickP) {+ session._foreground = true;+ try { await session.page.bringToFront(); } catch (e) {}+ try { await osRaiseSessionWindow(session, sessionId, 'you clicked the taskbar view switch'); } catch (e) {}+ }+ wikiCaption(view === 'authed'+ ? `Adom wiki: now the logged-in view${_a && _a.user ? ` (${_a.user})` : ''}`+ : '\u{1F4D6} Adom wiki: now the PUBLIC view β the server rendered it exactly as a signed-out visitor sees it', 6000).catch(() => {});+ console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE via ?view=public (adom/wiki#126) β ${_wTabs.length} tab(s) reloaded, no relaunch, no profile switch`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, view, inPlace: true, serverParam: true, tabsReloaded: _wTabs.length,+ url: (() => { try { return _at.page.url(); } catch (e) { return null; } })(),+ _hint: view === 'public'+ ? 'PUBLIC view via the server-side ?view=public param (adom/wiki#126): the server renders exactly what a signed-out visitor sees while the window stays on the shared profile β session untouched, no relaunch, no isolated jar. Verify a publish leaked nothing, then flip back any time.'+ : 'Logged-in view restored by stripping ?view=public β the shared-profile session was untouched throughout, so no re-login was needed.',+ }) });+ return;+ }+ }+ } catch (e) { console.log(`[wikiview] "${sessionId}" param path failed (${e.message}) β falling back to the legacy machinery`); }+ }+ // v1.9.63 β IN-PLACE view switch, no close-and-relaunch.+ //+ // A wiki session cookie is per-PROFILE and a profile's userDataDir is fixed at Chrome+ // launch, which is why this used to tear the window down and relaunch it under the other+ // profile (the thing John watched vanish and never come back). Colby's browser-session+ // endpoint removes that: an ALREADY-LOGGED-IN page can mint its own single-use magic link+ // with nothing but its session cookie β+ // POST /api/v1/auth/browser-session {next:"/adom/page"} -> {url, expires_in:60}+ // so the BRIDGE can mint on the desktop (no container token, no adom-wiki CLI) and just+ // navigate this window. The other direction is a cookie wipe plus a plain navigate. Either+ // way it is one hop and the window/tabs are never destroyed.+ //+ // Guard: only wipe cookies when this window owns its profile. The shared authed profile+ // backs OTHER windows too and clearing it would silently log them all out β fall through+ // to the legacy relaunch in that case.+ // v2.0.48 (John: "what should happen when i click this if there are multiple tabs in that+ // window?"): the unit is the WINDOW β but cookies are per PROFILE and CDP's+ // Network.clearBrowserCookies is BROWSER-wide, so on a SHARED profile the in-place public+ // switch wiped EVERY thread's cookies (silent global logout) and reloaded only one tab.+ // The old guard tested the LEGACY profile name (adom-wiki-authed) and never matched+ // today's shared adom-you. Ground truth instead: the profile is shared if ANY other live+ // session rides it (plus the known shared names). A shared-profile public switch now takes+ // the tab-preserving RELAUNCH path: the whole window (all tabs) moves to a logged-out+ // profile; every other window keeps its login.+ let _profileShared = /adom-wiki-authed|adom-you/i.test(session.profileName || '');+ try { for (const [k5, s5] of sessions) { if (k5 !== sessionId && s5 && s5.profileName === session.profileName) { _profileShared = true; break; } } } catch (e) {}+ // v2.0.57 (John round-tripped the toggle: authed direction signed in the THROWAWAY+ // profile instead of coming home): in-place authed is only correct when the window is+ // NOT on a toggle-created iso-* jar. From a throwaway, the authed direction takes the+ // tab-preserving relaunch back to the shared adom-you jar, so the round trip is+ // symmetric and logins land where they enrich the shared credential store.+ const _canInPlace = (view === 'authed') ? !/^iso-/.test(session.profileName || '') : !_profileShared;+ if (_canInPlace) {+ try {+ let targetUrl = null;+ if (view === 'authed') {+ // Mint from ANY currently-authenticated pup page. Single-use + 60s TTL, so mint+ // immediately before navigating and never cache it (a live link IS a credential).+ let minter = null;+ for (const [, s2] of sessions) {+ for (const t2 of (s2.tabs || [])) {+ try {+ if (!isAdomUrl(t2.page.url())) continue;+ const a2 = await checkWikiAuth(t2.page);+ if (a2 && a2.authed) { minter = t2.page; break; }+ } catch (e) {}+ }+ if (minter) break;+ }+ if (minter) {+ const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return '/'; } })();+ targetUrl = await minter.evaluate(async (np) => {+ try {+ const r = await fetch('/api/v1/auth/browser-session', {+ method: 'POST', credentials: 'same-origin',+ headers: { 'content-type': 'application/json' },+ body: JSON.stringify({ next: np }),+ });+ if (!r.ok) return null;+ const j = await r.json();+ if (!j || !j.url) return null;+ // The endpoint does NOT honour a `next` in the body or query β it only ever+ // returns `?ticket=...`. The CLI bakes the deep-link by APPENDING `&next=<path>`+ // client-side, so do the same or the redeem lands on the wiki ROOT and the user+ // loses the page they were on (measured: toggled to authed and landed on "/").+ return j.url + '&next=' + encodeURIComponent(np);+ } catch (e) { return null; }+ }, nextPath);+ }+ if (!targetUrl) {+ // v1.9.88 β AUTO-SIGN from AD's stored identity BEFORE ever asking the human. No+ // already-authed pup page exists to mint from, but AD wrote the user's wiki session+ // token to ~/.adom/session.json on THIS desktop, so pup mints the magic link from+ // that token server-side and navigates this window straight to it. The user is asked+ // for nothing; the vault profile then persists it ~30 days. Manual /login is only the+ // fallback when AD itself is signed out (no token) or the ~30-day session expired.+ const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return "/adom"; } })();+ const _autoWho = await autoSignWikiFromDesktop(session.page, nextPath);+ if (_autoWho) {+ session._wikiView = "authed";+ try { await setWikiModeGlyph(session.page, "β Logged in"); } catch (e) {}+ try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}+ session._wikiJumplistKey = undefined;+ updateWikiJumplist(session, sessionId).catch(() => {});+ updateThumbnailTooltip(session, sessionId).catch(() => {});+ session._foreground = true;+ try { await session.page.bringToFront(); } catch (e) {}+ try { await osRaiseSessionWindow(session, sessionId, 'you asked for the signed-in wiki view; you were signed in automatically'); } catch (e) {}+ wikiCaption(`Adom wiki: signed you in automatically${_autoWho && _autoWho !== 'authed' ? ` as ${_autoWho}` : ''}, no login needed`, 6000).catch(() => {});+ console.log(`[wikiview] "${sessionId}" AUTO-SIGNED from AD desktop token (${_autoWho}) β no manual login`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, view: "authed", autoSigned: true, url: session.page.url(),+ _hint: "pup signed this window into the Adom wiki AUTOMATICALLY using the identity Bridge already holds on this desktop (~/.adom/session.json) β no manual login. The shared vault profile now carries the session, so every thread's authed view works and it persists ~30 days.",+ }) });+ return;+ }+ // Fall back to the manual sign-in page (AD is signed out, or the session expired).+ const loginUrl = "https://wiki.adom.inc/login?redirect=" + encodeURIComponent(nextPath);+ console.log(`[wikiview] "${sessionId}" no authed session yet β navigating THIS window to the sign-in page: ${loginUrl}`);+ await session.page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 25000 });+ session._wikiView = "authed";+ session._foreground = true;+ try { await session.page.bringToFront(); } catch (e) {}+ try { await osRaiseSessionWindow(session, sessionId, 'you clicked Sign in: finish the one-time wiki sign-in here'); } catch (e) {} // user clicked the task β SHOW them the sign-in page+ try { await setWikiModeGlyph(session.page, "β Public"); } catch (e) {}+ wikiCaption("\u{1F513} Adom wiki: finish the one-time sign-in HERE β you'll come back to your page signed in", 9000).catch(() => {});+ // The hydrogen SSO often AUTO-COMPLETES: if the user's SSO session is already alive+ // (John's was), /login bounces straight back to the wiki page already signed in, with+ // no form to fill. In that case we must correct the glyph/tab-label/category-icon to+ // the logged-in state β the caption above said "finish the sign-in" but there was+ // nothing to finish. Re-check in the background (the redirect chain needs a beat) and+ // flip everything if we landed authed; if the form is still showing, leave it be.+ (async () => {+ // Poll for up to ~90s: covers BOTH the instant SSO auto-complete AND a real+ // manual sign-in (typing credentials + 2FA takes far longer than a few seconds).+ // Stops the moment we detect authed; a checkWikiAuth is a cheap same-origin fetch,+ // and it only returns authed once the window has redirected back to the wiki as the+ // signed-in user, so this is effectively "wait until sign-in completes, then flip".+ for (let i = 0; i < 45; i++) {+ await new Promise(r => setTimeout(r, 2000));+ if (!sessions.has(sessionId)) return; // window closed mid-sign-in β stop+ let a = null; try { a = await checkWikiAuth(session.page); } catch (e) {}+ if (a && a.authed) {+ try { await setWikiModeGlyph(session.page, "β Logged in"); } catch (e) {}+ try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}+ session._wikiJumplistKey = undefined;+ updateWikiJumplist(session, sessionId).catch(() => {});+ updateThumbnailTooltip(session, sessionId).catch(() => {});+ wikiCaption(`Adom wiki: signed in${a.user ? ` as ${a.user}` : ""} β this is now the logged-in view`, 6000).catch(() => {});+ console.log(`[wikiview] "${sessionId}" SSO auto-completed on the sign-in nav β flipped to logged-in (${a.user || "authed"})`);+ return;+ }+ }+ })().catch(() => {});+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, view: "authed", signInStarted: true, loginUrl, url: session.page.url(),+ _hint: "No wiki session existed yet, so pup navigated THIS window to the Adom wiki sign-in page (hydrogen SSO) and raised it. The user completes the one-time sign-in and is returned to their wiki page signed in; after that every thread's logged-in view works, because a signed-in window can mint the magic link. This is a USER action (they clicked the taskbar task), so foregrounding is expected.",+ }) });+ return;+ }+ } else {+ try {+ const cdp = await session.page.target().createCDPSession();+ await cdp.send('Network.clearBrowserCookies');+ await cdp.detach();+ } catch (e) {}+ targetUrl = url;+ }+ if (targetUrl) {+ await session.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 25000 });+ session._wikiView = view;+ const a3 = await checkWikiAuth(session.page).catch(() => ({ authed: false }));+ await setWikiAuthedState(session, sessionId, !!(a3 && a3.authed), { unknown: !!(a3 && a3.unknown) });+ try { await setWikiModeGlyph(session.page, (a3 && a3.authed) ? 'β Logged in' : 'β Public'); } catch (e) {}+ session._wikiJumplistKey = undefined; // the toggle's own label flips+ updateWikiJumplist(session, sessionId).catch(() => {});+ updateThumbnailTooltip(session, sessionId).catch(() => {});+ session._foreground = true;+ try { await session.page.bringToFront(); } catch (e) {}+ console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE (authed=${!!(a3 && a3.authed)}) β no relaunch`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, inPlace: true, authed: !!(a3 && a3.authed), url: session.page.url() }) });+ return;+ }+ } catch (e) { console.log(`[wikiview] "${sessionId}" in-place switch failed (${e.message}) β falling back to relaunch`); }+ }++ // v1.9.29 BUG FIX: carry EVERY tab across the switch. The old code relaunched only the+ // active tab's url and closed the rest, silently destroying the user's other tabs (John+ // lost a 5-tab research window to one jump-list click). Snapshot all urls + which was+ // active BEFORE the teardown, then restore them into the relaunched window.+ const _tabUrls = [];+ let _activeIdx = 0;+ try {+ (session.tabs || []).forEach((t, i) => {+ let u = null; try { u = t.page.url(); } catch (e) {}+ if (u && !/^(about:|chrome:|edge:|devtools:)/i.test(u)) {+ if (t.tabId === session.activeTabId) _activeIdx = _tabUrls.length;+ _tabUrls.push(u);+ }+ });+ } catch (e) {}+ if (!_tabUrls.length) _tabUrls.push(url);+ console.log(`[wiki] view toggle "${sessionId}": ${curView} β ${view} (${_tabUrls.length} tab(s), active #${_activeIdx + 1})`);+ // v1.9.9: tell the user IMMEDIATELY that their click registered β the relaunch takes a+ // few seconds and silence reads as "it did nothing". Replaced by the result caption below.+ const _switchMsg = view === 'authed'+ ? '\u{1F513} Switching to the LOGGED-IN view of the Adom wikiβ¦'+ : '\u{1F4D6} Switching to the PUBLIC (logged-out) view of the Adom wikiβ¦';+ // v1.9.11: OS caption (AD >=1.9.144). Fire-and-forget: the old window is torn down+ // moments later, so an OS-level surface is the ONLY thing that survives the switch.+ wikiCaption(_switchMsg, 30000).catch(() => {});+ const oldProfile = session.profileName;+ const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;+ const newProfile = view === 'authed' ? DEFAULT_PROFILE : ('iso-' + sessionId + '-pub'); // v1.9.162: authed = the ONE shared jar (accumulate); public = a clean logged-out jar+ // v2.0.130 (#45, traced root cause): the close-first relaunch DROPPED THE FOREGROUND LOCK.+ // The moment the old window died, Windows handed foreground to the next app (the editor),+ // and by raise time SetForegroundWindow was denied β so a toggle clicked on a FOREGROUND+ // window came back in the background no matter how many raises we retried (2.0.57's+ // delayed second raise lost the same way). CREATE-THEN-SWAP instead: launch the+ // replacement under a temp id, restore all tabs, raise it WHILE the old window still+ // holds foreground (same-app focus handoff is permitted), THEN close the old window and+ // rename the temp session to the original id (owner, hwnd claim, files, jump list,+ // AUMID). Only when the original window was actually foreground AND this is a+ // USER_CALLERS click β AI-driven toggles keep background semantics. Bonus: the+ // zero-window dead-air gap during the switch is gone.+ let _wasForeground = false;+ if (process.platform === 'win32') {+ try {+ const fgH = await osGetForegroundWindow();+ const myH = await sessionHwnd(sessionId);+ _wasForeground = !!(fgH && myH && Number(fgH) === Number(myH));+ } catch (e) {}+ }+ const _userClickSwap = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+ if (_wasForeground && _userClickSwap) {+ const tempId = sessionId + '.swap';+ try {+ const sNew = await launchSession(tempId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true });+ for (let i = 0; i < _tabUrls.length; i++) {+ if (i === _activeIdx) continue;+ try { await addTabToSession(tempId, _tabUrls[i]); } catch (e) {}+ }+ try { const sT = sessions.get(tempId); const back = sT && sT.tabs && sT.tabs[0]; if (back) { sT.activeTabId = back.tabId; await back.page.bringToFront(); } } catch (e) {}+ // Raise the replacement WHILE we still own foreground via the old window.+ sNew._foreground = true;+ try { await osRaiseSessionWindow(sNew, tempId, 'you clicked the taskbar view switch β handing focus to the replacement window'); } catch (e) {}+ // Retire the old window (foreground stays with our replacement β same-app handoff).+ for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }+ session.tabs.length = 0; session.activeTabId = null;+ sessions.delete(sessionId);+ deleteSessionFile(sessionId);+ unregisterPupAumid(_closingAppId).catch(() => {});+ await killProfileBrowserIfUnused(oldProfile, 'profile steer: old window retired');+ // RENAME temp -> original id: map, files, hwnd claim. The periodic brand sweep ++ // title injector key off the map id, so titles/badges restamp to the original id.+ sessions.delete(tempId);+ sessions.set(sessionId, sNew);+ try { deleteSessionFile(tempId); } catch (e) {}+ try { saveSessionFile(sessionId, newProfile, (() => { try { return sNew.page.url(); } catch (e) { return url; } })()); } catch (e) {}+ try { if (sNew._hwnd) persistSessionHwnd(sessionId, sNew._hwnd); } catch (e) {}+ sNew._wikiView = view;+ if (view === 'public') sNew._publicProfile = newProfile;+ sNew._wikiJumplistView = undefined;+ sNew._wikiJumplistKey = undefined;+ try { await osRaiseSessionWindow(sNew, sessionId, 'finishing the view switch you clicked (create-then-swap)'); } catch (e) {}+ sNew._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked switch to ${view} on a foreground window (create-then-swap)` };+ try { recordAgentUpdate(sNew, 'pup_wiki_set_view', { updateNote: `wiki view β ${view} (create-then-swap, foreground preserved)` }, sNew.activeTabId); } catch (e) {}+ try { flashViaAD(sessionId, {}); } catch (e) {}+ try { await setWikiModeGlyph(sNew.page, view === 'authed' ? 'β Logged in' : 'β Public'); } catch (e) {}+ console.log(`[wikiview] "${sessionId}" -> ${view} via CREATE-THEN-SWAP (window was foreground; replacement raised before the old one closed)`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, view, url, foregrounded: true, swap: true,+ _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view. It was FOREGROUND when the user clicked, so pup used create-then-swap: the replacement was raised while the old window still held focus, then swapped in under the same id β the user never lost the window.`,+ }) });+ const s2 = sNew; // background cosmetics below expect s2+ (async () => {+ if (process.platform === 'win32') {+ await new Promise(r => setTimeout(r, 1500));+ try { await brandPupWindow(sessionId); } catch (e) {}+ try { await applyAppOverlay(s2, sessionId); } catch (e) {}+ try { await updateWikiJumplist(s2, sessionId); } catch (e) {}+ }+ if (view === 'authed') {+ await new Promise(r => setTimeout(r, 300));+ let a = null;+ try { a = await checkWikiAuth(s2.page); } catch (e) {}+ try { await setWikiModeGlyph(s2.page, (a && a.authed) ? 'β Logged in' : 'β Public'); } catch (e) {}+ try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}+ const _doneMsg = a && a.authed+ ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`+ : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in β sign in in this window';+ wikiCaption(_doneMsg, 6000).catch(() => {});+ } else {+ try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}+ wikiCaption('\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view β what the world sees', 6000).catch(() => {});+ }+ })().catch(() => {});+ return;+ } catch (e) {+ console.log(`[wikiview] "${sessionId}" create-then-swap failed (${e.message}) β falling back to the legacy close-first relaunch`);+ // Clean up a half-launched temp session so the legacy path starts clean.+ try {+ const sT = sessions.get(tempId);+ if (sT) { for (const t of [...(sT.tabs || [])]) { try { await t.page.close(); } catch (e2) {} } sessions.delete(tempId); deleteSessionFile(tempId); }+ } catch (e2) {}+ // If the old window survived (launch failed before teardown), the legacy path below+ // re-does the close correctly; if it did not, the close loop no-ops.+ }+ }+ // Close the current window (mirror pup_close_window) so we can relaunch under the+ // other cookie jar.+ if (sessions.has(sessionId)) {+ for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }+ session.tabs.length = 0; session.activeTabId = null;+ sessions.delete(sessionId);+ deleteSessionFile(sessionId);+ unregisterPupAumid(_closingAppId).catch(() => {});+ await killProfileBrowserIfUnused(oldProfile, 'profile switch');+ }+ // Relaunch the SAME sessionId under the new profile at the same URL.+ let s2 = null;+ try { s2 = await launchSession(sessionId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true }); }+ catch (e) { sendJSON(res, { success: false, error: `wiki view relaunch failed: ${e.message}` }); return; }+ // v1.9.29: restore the OTHER tabs (in their original order) into the relaunched window.+ for (let i = 0; i < _tabUrls.length; i++) {+ if (i === _activeIdx) continue;+ try { await addTabToSession(sessionId, _tabUrls[i]); } catch (e) {}+ }+ // Re-activate the tab the user was actually on.+ try {+ const s3 = sessions.get(sessionId);+ const back = (s3 && s3.tabs && s3.tabs[0]) || null;+ if (back) { s3.activeTabId = back.tabId; await back.page.bringToFront(); }+ } catch (e) {}+ s2._wikiView = view;+ if (view === 'public') s2._publicProfile = newProfile;+ s2._foreground = true;+ s2._wikiJumplistView = undefined;+ s2._wikiJumplistKey = undefined; // v1.9.62: the key is what gates the commit now+ // v1.9.7: the user clicked a per-window taskbar task β REALLY pop it to the foreground+ // (full OS SetForegroundWindow raise, not the old CDP-only maximize that left it behind+ // the user's active window β the reason John "never saw the switch work"). Plus a taskbar+ // flash as a belt-and-suspenders notice in case Windows' foreground lock denies the raise.+ await osRaiseSessionWindow(s2, sessionId, 'you clicked the taskbar menu to switch this wiki view');+ // v2.0.57 (John: "it re-opened in the bg which didn't make sense, I was in the fg"):+ // one delayed second raise β the first can lose the foreground race while the new+ // window is still materializing.+ setTimeout(() => { try { const s4 = sessions.get(sessionId); if (s4) osRaiseSessionWindow(s4, sessionId, 'finishing the view switch you clicked').catch(() => {}); } catch (e) {} }, 2500);+ // v1.9.29 BUG FIX: VERIFY the window actually came back. John clicked the toggle, the old+ // window closed, captions fired, and NO window returned: the relaunch had happened but the+ // window stayed unfindable/off-screen (the identity stamp logged "No visible window"),+ // leaving him with nothing. A switch that cannot show its window is a FAILED switch, so+ // confirm the shell can see it and self-heal before reporting success.+ let _visible = false;+ for (let i = 0; i < 8 && !_visible; i++) {+ try {+ const fr = await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});+ const fraw = fr && (typeof fr.output === 'string' ? fr.output : JSON.stringify(fr.output || ''));+ const fo = fraw ? JSON.parse(fraw) : {};+ _visible = !!((fo.data || fo).best);+ } catch (e) {}+ if (!_visible) {+ try { await healOffscreenWindow(s2, sessionId); } catch (e) {}+ try { await osRaiseSessionWindow(s2, sessionId, 'retrying: bringing the wiki window you clicked back on-screen'); } catch (e) {}+ await new Promise(r => setTimeout(r, 700 * (i + 1)));+ }+ }+ if (!_visible) {+ console.log(`[wiki] "${sessionId}" switched but the window never became visible β telling the user`);+ wikiCaption('Adom wiki: the switch finished but the window did not come back on screen. Click the Adom Pup taskbar button to raise it.', 15000).catch(() => {});+ }+ try { flashViaAD(sessionId, {}); } catch (e) {}+ s2._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked the taskbar "switch to ${view} view" task` };+ try { recordAgentUpdate(s2, 'pup_wiki_set_view', { updateNote: `wiki view β ${view} (user clicked taskbar task)` }, s2.activeTabId); } catch (e) {}+ // v1.9.7: set an OPTIMISTIC glyph immediately (the window is on the authed profile, and the+ // common case IS logged in) so the tab reflects the switch the instant it pops, then+ // RESPOND β the window is already raised. Everything below runs in the BACKGROUND so a+ // click never waits on cosmetics: branding, favicon overlay, jump list, AND the auth+ // RE-check that corrects the glyph once the authed cookies/SPA settle (the first check+ // races the page load, so a logged-in window was briefly mislabeling "π Public" β John+ // saw exactly this).+ try { await setWikiModeGlyph(s2.page, view === 'authed' ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, view, url, foregrounded: true,+ _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view (auth is re-confirmed a moment later; the tab label self-corrects if the ~30-day login has expired).`,+ }) });+ (async () => {+ if (process.platform === 'win32') {+ // v1.9.10: WAIT for the title suffix "(session: <id>)" to exist before branding β+ // branding immediately raced the title injector and AD found "No visible window+ // with title containing (session: wiki-demo)" ([badge] FAILED in the log).+ await new Promise(r => setTimeout(r, 1500));+ try { await brandPupWindow(sessionId); } catch (e) {}+ try { await applyAppOverlay(s2, sessionId); } catch (e) {}+ try { await updateWikiJumplist(s2, sessionId); } catch (e) {}+ }+ if (view === 'authed') {+ await new Promise(r => setTimeout(r, 300));+ let a = null;+ try { a = await checkWikiAuth(s2.page); } catch (e) {}+ try { await setWikiModeGlyph(s2.page, (a && a.authed) ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}+ try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}+ // v1.9.9: result caption β names WHO you're signed in as (John's ask), or says the+ // one-time sign-in is still needed. Replaces the "Switchingβ¦" caption (same id).+ const _doneMsg = a && a.authed+ ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`+ : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in β sign in in this window';+ wikiCaption(_doneMsg, 6000).catch(() => {});+ } else {+ try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}+ const _doneMsg = '\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view β what the world sees';+ wikiCaption(_doneMsg, 6000).catch(() => {});+ }+ })().catch(() => {});+ return;+ }++ // --- Tab-level commands ---+ case 'pup_open_tab': {+ // v1.9.146: explicit sessionId wins; otherwise the CALLER THREAD's own most-recent window β+ // NEVER the global activeSessionId (that is another thread's window as often as not).+ const sessionId = args.sessionId || callerOwnWindow(_callerThread);+ if (!sessionId || !sessions.has(sessionId)) {+ const mine = _callerThread ? [...sessions].filter(([,s]) => s._ownerThread === _callerThread).map(([id]) => id) : [];+ sendJSON(res, { success: false, errorCode: 'no_target_window',+ error: `No window to add a tab to. ${args.sessionId ? `Session "${args.sessionId}" not found.` : (_callerThread ? `Your thread ("${_callerThread}") owns no open pup window yet.` : `No sessionId given and your thread sent no caller identity.`)} Open one with pup_open_window first (pass a sessionId you'll reuse), then pup_open_tab into it.`,+ yourWindows: mine });+ return;+ }+ // Guard: refuse to add a tab to a window owned by a DIFFERENT thread unless it was named+ // explicitly (a deliberate cross-thread action), so a stray call can't contaminate.+ {+ const _tgt = sessions.get(sessionId);+ if (_tgt && _tgt._ownerThread && _callerThread && _tgt._ownerThread !== _callerThread && !args.takeover) {+ sendJSON(res, { success: false, errorCode: 'session_owned_by_another_thread',+ error: `Window "${sessionId}" is owned by thread "${_tgt._ownerThread}", not "${_callerThread}". Opening a tab here would land in another agent's window. Open your OWN window (pup_open_window) or pass takeover:true if you truly mean to use theirs.` });+ return;+ }+ }+ try {+ const tabId = await addTabToSession(sessionId, args.url);+ const session = sessions.get(sessionId);+ const tab = getTabById(session, tabId);+ recordAgentUpdate(session, 'pup_open_tab', args, tabId); // v1.8.23+ scheduleAutoFlash(session, { silent: !!args.silent });+ updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80+ // v1.8.57: long-lived-app touchpoint (see switch_tab) β heal a stranded window,+ // re-badge debounced (v1.8.58: only debounce on SUCCESS so failures retry).+ if (process.platform === 'win32') {+ // v1.8.67: a new tab's first paint can raise its window β re-assert (force:false+ // skips a user-held window).+ if (!session._foreground) reassertBottomAfterNav(sessionId).catch(() => {});+ healOffscreenWindow(session, sessionId).catch(() => {});+ const now = Date.now();+ if (!session._badgedAt || now - session._badgedAt > 600000) {+ brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});+ }+ }+ // v1.9.110 (closing the set-enumeration gap): RENDER_VERBS has four members and I had wired+ // three. A tab is content on the user's screen exactly like a window, so it gets the same+ // verdict β otherwise "open it in a tab" silently skips the check that "open it in a window"+ // performs. When a behavior belongs to a declared SET, every member gets it.+ try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}+ try { await autoLoginOnLoad(res, tab.page); } catch (e) {}+ try {+ const _rc = res._renderCheck;+ if (_rc && _rc.ok === false) console.log(`[render] "${sessionId}" tab ${tabId} BROKEN: ${_rc.signals.join(', ')}`);+ } catch (e) {}+ const info = await describeTab(tab, session.activeTabId);+ // v1.8.23: return tabId + tabCount + activeTabId at the TOP level (was only inside `info`).+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, tabId, tabCount: session.tabs.length, activeTabId: session.activeTabId, ...info }) });+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_render_html': {+ // v1.8.23: render a local HTML string in the tab via page.setContent β which+ // handles encoding correctly (UTF-8), unlike a data: URL that mojibakes accented+ // chars without an explicit charset. Pass {html}. Great for previewing generated+ // markup without writing a temp file or fighting data:-URL escaping.+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ if (typeof args.html !== 'string') {+ sendJSON(res, { success: false, error: 'pup_render_html requires an html string.', _hint: 'e.g. {"sessionId":"...","html":"<h1>CafΓ©</h1>"} β setContent renders it as UTF-8 (a data: URL would mojibake the Γ©).' });+ return;+ }+ try {+ await withTimeout(tab.page.setContent(args.html, { waitUntil: args.waitUntil || 'domcontentloaded', timeout: 20000 }), 25_000, 'page.setContent');+ recordAgentUpdate(session, 'pup_render_html', args, tabId);+ scheduleAutoFlash(session, { silent: !!args.silent });+ let title = null; try { title = await tab.page.title(); } catch {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, title, bytes: args.html.length, _hint: 'Rendered your HTML in the tab (UTF-8, no data:-URL mojibake). Screenshot it or drive it like any page.' }) });+ } catch (e) { sendJSON(res, { success: false, error: `pup_render_html failed: ${e.message}` }); }+ return;+ }+ case 'pup_switch_tab': {+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live;+ const tab = getTabById(session, args.tabId);+ if (!tab) {+ const available = session.tabs.map(t => t.tabId).join(', ');+ sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });+ return;+ }+ session.activeTabId = tab.tabId;+ // v2.0.272 (#69, reported against 2.0.269): THIS VERB USED TO LIE ON EVERY BACKGROUND+ // WINDOW. `page.bringToFront()` is the only thing that actually switches Chrome's visible+ // tab, and it was gated on `session._foreground`. pup windows are background BY DEFAULT,+ // so the normal case updated pup's own `activeTabId` bookkeeping, never told Chrome, and+ // still returned success:true β while pup_list_tabs (which reads Chrome's real state)+ // kept reporting the old tab. Two verbs disagreeing, with the honest one looking broken.+ // The v1.8.26 rationale was "only RAISE THE OS WINDOW for a session the user is watching",+ // which conflated two different things: activating a TAB inside a window is not the same+ // as raising that window. The legitimate worry underneath is real though β Target.activate+ // can pull the window forward β so do the activation ALWAYS and hand the user's focus+ // straight back, exactly the one-shot handback used at window birth (v2.0.259). Never a+ // timer, never a loop; the window itself is never moved.+ let _fgBefore = null;+ try { if (!session._foreground && process.platform === 'win32') _fgBefore = await osGetForegroundWindow(); } catch (e) {}+ try { await tab.page.bringToFront(); } catch (e) {}+ if (_fgBefore) {+ try {+ const _after = await osGetForegroundWindow();+ if (_after && String(_after) !== String(_fgBefore)) {+ console.log(`[switch-tab] "${sessionId}" activating ${tab.tabId} pulled the window forward β handing focus back to ${_fgBefore}`);+ await chrome.abCommand('desktop_bring_to_front', { hwnd: Number(_fgBefore) }, { timeoutMs: 4000 });+ }+ } catch (e) {}+ }+ // v1.8.57: switch_tab is a long-lived app's hot path (adom-shotlog switches its+ // shared window's tabs for hours) β self-heal a stranded window + re-badge here+ // (debounced) so the window stays clickable/badged across bridge restarts.+ if (process.platform === 'win32') {+ healOffscreenWindow(session, sessionId).catch(() => {});+ updateTabCountBadge(session, sessionId).catch(() => {}); // v1.9.0: overlay follows the active tab's favicon+ const now = Date.now();+ if (!session._badgedAt || now - session._badgedAt > 600000) {+ // v1.8.58: only debounce on SUCCESS β a failed badge retries on the next call.+ brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});+ }+ }+ // v2.0.272: VERIFY, do not assert. #69's whole complaint is a verb that reports ok while+ // nothing happened, so read the activation back from Chrome and say so when it did not take.+ let _isActive = null;+ try { _isActive = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'switch-verify') === 'visible'; } catch (e) {}+ const info = await describeTab(tab, session.activeTabId);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info,+ activated: _isActive,+ ...(_isActive === false ? { _hint: `pup switched its OWN pointer to ${tab.tabId}, but Chrome still does not report that tab as visible. Confirm with pup_list_tabs (it reads Chrome, not pup's bookkeeping) before trusting the switch, and please report it on adom/pup-bridge β a verb that says ok while nothing moved is the exact class issue #69 closed.` } : {}) }) });+ return;+ }+ case 'pup_close_tab': {+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live;+ if (!args.tabId) {+ sendJSON(res, { success: false, error: 'tabId is required for pup_close_tab' });+ return;+ }+ const idx = session.tabs.findIndex(t => t.tabId === args.tabId);+ if (idx === -1) {+ const available = session.tabs.map(t => t.tabId).join(', ');+ sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });+ return;+ }+ const tab = session.tabs[idx];+ try { await tab.page.close(); } catch {}+ // The page.on('close', ...) handler in attachTab removes the tab from+ // session.tabs, but race-proof: remove here too if still present.+ const stillIdx = session.tabs.findIndex(t => t.tabId === args.tabId);+ if (stillIdx !== -1) session.tabs.splice(stillIdx, 1);+ if (session.activeTabId === args.tabId) {+ session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;+ }+ updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, closedTab: args.tabId, remaining: session.tabs.length, activeTabId: session.activeTabId }) });+ return;+ }+ case 'pup_list_tabs': {+ drainWindowOpenedEvents().catch(() => {}); // v1.9.32: cheap event-driven drag-out check+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live;+ const tabs = [];+ for (const t of session.tabs) {+ const info = await describeTab(t, session.activeTabId);+ if (t.lastAgentUpdate) info.lastAgentUpdate = t.lastAgentUpdate; // v1.8.23: per-tab+ tabs.push(info);+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, count: tabs.length, activeTabId: session.activeTabId, lastAgentUpdate: session._lastAgentUpdate || null, tabs }) });+ return;+ }++ case 'pup_alert_window': {+ // Flash the taskbar button for a session's window WITHOUT foregrounding it. v1.8.35:+ // {stop:true} (alias {clear:true}) CLEARS a lingering orange highlight instead.+ // (Pre-1.8.35 this wrongly called bringToFront β it raised the window instead of+ // flashing. Now it uses the real FlashWindowEx path, same as the auto-flash.)+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live;+ try {+ const stop = !!(args.stop || args.clear);+ // ββ macOS ββ there is no taskbar to flash and macOS has no API to bounce ANOTHER+ // app's Dock tile. The mac analog: unhide the window at its z-position (visible but+ // NOT focused β the non-intrusive nudge) + a native AD toast whose click focuses it.+ // {stop} dismisses the toast.+ if (process.platform === 'darwin') {+ const sid = session._sessionId || sessionId;+ const toastId = `pup-alert-${sid}`;+ let applied = false;+ if (stop) {+ try { const r2 = await chrome.abCommand('notify_dismiss', { id: toastId }, { timeoutMs: 5000 }); applied = !!r2; } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: false, cleared: applied, applied, clearMethod: applied ? 'toast-dismissed' : null, _hint: applied ? 'Alert toast dismissed.' : 'No toast to dismiss (or Bridge unreachable).' }) });+ return;+ }+ try { await macSetAppHiddenByPid(macSessionPid(session), false); } catch (e) {}+ try {+ const r2 = await chrome.abCommand('notify_user', {+ id: toastId, level: 'info', scenario: 'reminder',+ title: 'A pup window wants your attention',+ body: `Session "${sid}" has something to show β click to bring it up.`,+ focus: { ...(await windowTarget(sessions.get(sid), sid)) },+ }, { timeoutMs: 6000 });+ applied = !!(r2 && r2.success !== false);+ } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied, clearMethod: null, _hint: applied ? 'macOS nudge: window un-hidden (not focused) + a native toast posted β its click focuses the window. Pass {stop:true} to dismiss the toast.' : 'Window un-hidden, but the AD toast could not be posted (Bridge unreachable or notify unsupported).' }) });+ return;+ }+ // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native).+ // v2.0.243 (pup-bridge#58, real root cause): pup_alert_window is an EXPLICIT request from+ // the caller β never pup's automatic machinery β so it must not be swallowed by the+ // minimal-touch gate. That gate is what made the flash silently no-op on John's box+ // while the response blamed the window title, which sent the investigation at the+ // title/hwnd resolver instead of at the gate.+ // v2.0.304 (John: "i just saw a pup window orange flash, but i thought our new setting was+ // for you to not do the orange flash anymore"): in `progress` mode pup never paints orange,+ // not even for an explicit alert. The nudge becomes a short full progress pulse on the+ // button (fill to 100%, hold ~2.5s, clear), the same signal family the user chose.+ if (!stop && taskbarActivityMode() === 'progress' && process.platform === 'win32') {+ const _sid = session._sessionId || sessionId;+ const _t = await windowTarget(session, _sid).catch(() => null);+ if (_t) { await _pushProgress(_t, 100); setTimeout(() => { _pushProgress(_t, null).catch(() => {}); }, 2500); }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied: !!_t, method: 'progress-pulse', _hint: 'taskbarActivity is "progress", so the nudge was a 2.5s full progress pulse on the taskbar button instead of the orange flash (pup never paints orange in progress mode). Set taskbarActivity:"flash" to get the old highlight.' }) });+ return;+ }+ const r = await flashViaAD(session._sessionId || sessionId, { stop, force: true });+ let applied = false;+ try { const o = r && abPayload(r); applied = !!(o && (o.ok || o.count)); } catch (e) {}+ // v1.8.45: FLASHW_STOP only stops the PULSE β the Win11 attention tint persists+ // until the window is ACTIVATED. So on {stop} we ALSO clear the tint for real via an+ // off-screen activate-and-restore (no pop, focus handed straight back). This is the+ // fix for "you said you cleared it but the button is still lit."+ let clearMethod = null;+ if (stop && process.platform === 'win32') {+ try {+ const br = browsers.get(session.profileName) && browsers.get(session.profileName).browser;+ if (br && (br._detachedPid || br._cdpPort || session._hwnd)) clearMethod = await osClearFlashByActivate(br._detachedPid, br._cdpPort, session._hwnd);+ } catch (e) {}+ try { _flashState.delete(session._sessionId || sessionId); } catch (e) {}+ }+ const trulyCleared = stop && ['cleared', 'cleared-still-foreground', 'already-foreground'].includes(clearMethod);+ console.log(`${stop ? 'Cleared' : 'Flashed'} taskbar for session "${sessionId}" (AD applied=${applied}, clear=${clearMethod})`);+ const hint = stop+ ? (trulyCleared+ ? `Flash truly cleared β activated the window off-screen to drop the Win11 attention tint (${clearMethod}), then handed focus back. No pop, no focus steal.`+ : `Could not fully clear (${clearMethod || 'window not found'}). FLASHW_STOP ran (applied=${applied}) but the persistent tint needs activation and that step failed.`)+ : (applied ? 'Flashed the taskbar (finite 4-count) via AD, no focus steal. Pass {stop:true} to clear.'+ : (r && r._skipped === 'minimal-touch'+ ? 'Skipped: this desktop runs pup in MINIMAL-TOUCH mode, which suppresses pup-initiated taskbar effects. Turn it off in the dashboard Settings (or pup_configure {minimalTouch:"off"}) if you want alerts to flash.'+ : 'AD desktop_flash_window could not act on this window. pup targets it by HANDLE (title tags are off by default), so this usually means the window closed or the handle went stale β pup_window_info shows the handle pup is using.'));+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: !stop, cleared: stop ? trulyCleared : false, applied, clearMethod, _hint: hint }) });+ } catch (e) {+ sendJSON(res, { success: false, error: `Alert failed: ${e.message}` });+ }+ return;+ }+ case 'pup_lower_os_window': {+ // Send a pup window to the BACKGROUND (behind the user's windows) without+ // stealing focus. It stays fully drivable + screenshottable. This is the+ // "get it off the user's screen" verb β the counterpart to raise/focus.+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live;+ // v1.8.47b: park on-screen at z-bottom (screenshots keep working). An explicit lower is+ // a deliberate re-park, so clear the user-foreground latch too.+ const be = browsers.get(session.profileName);+ if (be && be.browser) be.browser._userForeground = false;+ let ok;+ if (process.platform === 'darwin') {+ // macOS lower = hide the session's browser process (still fully drivable β CDP+ // keeps rendering hidden windows). Per-process, so shared-profile siblings hide too.+ ok = await macSetAppHiddenByPid(macSessionPid(session), true);+ } else {+ if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, sessionId); } catch (e) {} }+ // v2.0.236: route through the SAME park every internal caller uses instead of the two+ // legacy one-off implementations (osBackgroundWindowByPid took parameters its body no+ // longer read, and osSendWindowToBack re-derived the window by profile). One park now.+ const _pg = (session.tabs && session.tabs[0] && session.tabs[0].page) || session.page || null;+ ok = _pg ? !!(await parkSessionWindowDirect(_pg, sessionId)) : false;+ }+ session._foreground = false;+ session._fgWatchUntil = 0; // v1.9.260: a deliberate lower is not a "surfaced without pup" event - z-bottom keeps OS focus and the no-fault watch was mislabeling it+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', windowEtiquette: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) });+ return;+ }+ case 'pup_raise_os_window':+ case 'pup_focus_window': {+ // Actually brings the Chrome window to the foreground using Win32 SetForegroundWindow+ const _live = requireLiveSession(res, args);+ if (!_live) return;+ const { sessionId, session } = _live;+ // v1.8.70: THE FOREGROUND GATE (mirrors abe's userRequestedForeground+foregroundReason+ // dual key β same contract across Adom's browser surfaces). Raising steals the user's+ // screen and keyboard; it is allowed ONLY as a user-requested show-me, and the caller+ // must SAY SO in a recorded, auditable reason. Real incident: a thread raised a window+ // 2 minutes after a background open and the only forensics were a focus-latch+ // timestamp β never again.+ const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;+ if (!fgReason) {+ sendJSON(res, {+ success: false,+ errorCode: 'foreground_reason_required',+ error: 'Raise refused: foregroundReason is required.',+ _hint: 'Foregrounding steals the user\'s screen and keyboard focus. It is allowed ONLY when the USER explicitly asked to SEE this window (a "show me", a demo, a live recording). Call again with foregroundReason:"<quote or closely paraphrase what the user said that justifies this>" (min 10 chars) β the reason is logged, recorded in lastAgentUpdate, and auditable by the user. If the user did NOT ask to see it, do NOT raise: use pup_alert_window (taskbar nudge) or rely on the auto-flash. After a granted raise: do the one thing the user asked for, then pup_lower_os_window to return it to the background.',+ });+ return;+ }+ console.log(`[foreground] "${sessionId}" raise GRANTED β reason: ${fgReason}`);+ try { const _rs = sessions.get(sessionId); if (_rs) _rs._raiseGranted = true; } catch (e) {}+ session._lastForeground = { ts: Date.now(), verb: command, reason: fgReason };+ toastForeground(sessionId, fgReason).catch(() => {}); // v1.9.166: ping the human before/as we reveal+ // v1.9.150: the reveal redraws the taskbar button; nothing re-wore the favicon, so all+ // three of John's foregrounded windows lost their overlay. Re-assert it after the raise.+ setTimeout(() => { updateTabCountBadge(session, args.sessionId || session._sessionId).catch(() => {}); }, 1200);+ try { recordAgentUpdate(session, command, { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}+ // v1.9.201 / v2.0.290: show the caller's justification (and WHICH ai-thread) on screen+ // BEFORE the window comes forward, so the caption always precedes the reveal β never trails+ // it. AWAITED (fire-and-forget let the raise win the race, John saw the caption ~5s late).+ try { await captionForegroundReason(sessionId, fgReason, command, _callerThread); } catch (e) {}+ try {+ // v1.9.149 (John's audit: two of three raised windows came up 0,0 full-screen, not the+ // standard frame): the reveal must present the SAME work-area-minus-10px geometry the+ // park set β the old block here CDP-MAXIMIZED over it, which is exactly the sibling-path+ // legacy the placement doctrine forbids. Reveal geometry == park geometry, one standard.+ try {+ const cdp = await session.page.target().createCDPSession();+ const { windowId } = await cdp.send('Browser.getWindowForTarget');+ let sc = null;+ try { sc = await session.page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight })); } catch (e) {}+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+ if (sc && sc.w > 400) {+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(sc.x + 10), top: Math.round(sc.y + 10), width: Math.round(sc.w - 20), height: Math.round(sc.h - 20) } });+ }+ try { await cdp.detach(); } catch (e) {}+ } catch (e) {}+ // v1.8.23: BLUR the page's active element BEFORE raising. Raising steals the OS+ // focus, and a keystroke the user was mid-typing can land in whatever input the+ // pup page had focused (a form field became "John Lauer a"). With no focused+ // element, a stray key has nowhere to land. (The residual SetForegroundWindow+ // input-queue timing is AD-owned when AD intercepts this verb β flagged upstream.)+ try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}+ // First activate the tab within Chrome+ await session.page.bringToFront();+ activeSessionId = sessionId;+ session._foreground = true; // v1.8.26: user asked to watch β allow later raises++ // v1.9.67 β RAISE BY HWND FIRST (fixes "I click and no window comes up" on Edge boxes).+ //+ // The PID path below is skipped whenever `process()` is null, which is EVERY detached launch on+ // Windows β so on a box with no Chrome (pup falls back to Edge and caches it as the verified+ // default) the only thing that ran was the CDP bounds + bringToFront above. bringToFront activates+ // the TAB inside the window, not the OS window, so a window pup parked at the bottom of the+ // z-order stayed buried. Reproduced on ConfRoomROG: the verb returned ok:true, logged+ // "raise GRANTED", and the screen never changed. The PID fallback was Chrome-only too β it scanned+ // for child processes named "chrome.exe" and would never match msedge.exe.+ //+ // Resolving by the "(session: <id>)" title is browser-agnostic and names the EXACT window (the old+ // _detachedPid attempt raised the LAUNCHER's window). The raise stays in PowerShell rather than+ // handing the hwnd to AD, because backgrounded pup windows carry WS_EX_NOACTIVATE β structurally+ // unable to take focus until that style is CLEARED, which is exactly what Raise() does.+ let _raisedByHwnd = false;+ if (process.platform === 'darwin') {+ // macOS raise = unhide the session's process + make it frontmost. The CDP+ // bounds/maximize above already sized it; NSRunningApplication does the activation.+ const pid = macSessionPid(session);+ _raisedByHwnd = await macActivateByPid(pid);+ console.log(`[foreground] "${sessionId}" mac activate pid=${pid} β ${_raisedByHwnd}`);+ }+ if (process.platform === 'win32') {+ try {+ // v1.9.220 (wiki issue #5, John, 2026-07-06): TRACK THE HANDLE, don't match the title.+ // Title-matching is fragile exactly as he described β right after a navigation the new+ // page sets its own title before the bridge re-appends the "(session: <id>)" suffix, so+ // the lookup fails; and Chrome's PDF viewer never carries the suffix at all, which is+ // what made PDF windows unraisable/unpaintable. sessionHwnd() now prefers the handle+ // pup stored at park (falling back to bounds-matching, then a title lookup), so the+ // raise resolves the SAME window regardless of what its title currently says.+ let hwnd = await sessionHwnd(sessionId);+ if (!hwnd) {+ const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))}));+ hwnd = fw && fw.best && fw.best.hwnd;+ }+ if (hwnd) {+ const psPath = path.join(require('os').tmpdir(), '_adom_focus_hwnd.ps1');+ fs.writeFileSync(psPath, [+ ...PS_RAISE_TYPEDEF,+ `[WinFocusH]::Raise([IntPtr]${hwnd})`,+ "Write-Output 'focused'",+ ].join('\n'), 'utf8');+ const out = await runPsHiddenPromise(psPath, { timeout: 8000 });+ // v1.9.68: PS reports 'focused' even when Windows IGNORED the call. SetForegroundWindow is+ // subject to the FOREGROUND LOCK: a process that does not already own the foreground cannot+ // steal it, so it silently no-ops (measured: log said FOCUSED, screen unchanged). Use PS only+ // for what it is uniquely good at β clearing WS_EX_NOACTIVATE and un-minimizing β then let AD,+ // which owns a foreground-capable path, do the real raise on the SAME hwnd.+ const _styleCleared = /focused/.test(out || '');+ try {+ const br = abPayload(await chrome.abCommand('desktop_bring_to_front', { hwnd, state: 'restore' }, { timeoutMs: 6000 }));+ _raisedByHwnd = !!(br && (br.ok === undefined || br.ok));+ } catch (e) { _raisedByHwnd = false; }+ console.log(`[foreground] "${sessionId}" hwnd=${hwnd} styleCleared=${_styleCleared} adRaise=${_raisedByHwnd}`);+ } else { console.log(`[foreground] "${sessionId}" could not resolve an hwnd by title`); }+ } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }+ }++ // Get Chrome PID from the browser process+ const browserProcess = browsers.get(session.profileName)?.browser?.process();+ const pid = browserProcess?.pid;+ if (!_raisedByHwnd && pid && process.platform === 'win32') {+ // Write PowerShell script to temp file and execute (avoids quoting hell)+ const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');+ const psContent = [+ ...PS_RAISE_TYPEDEF,+ `$targetPid = ${pid}`,+ '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',+ 'if ($p -and $p.MainWindowHandle -ne 0) {',+ ' [WinFocusH]::Raise($p.MainWindowHandle)',+ " Write-Output 'focused'",+ '} else {',+ ' Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',+ ' $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',+ ' if ($cp -and $cp.MainWindowHandle -ne 0) {',+ ' [WinFocusH]::Raise($cp.MainWindowHandle)',+ " Write-Output 'focused'",+ ' return',+ ' }',+ ' }',+ '}',+ ].join('\r\n');+ fs.writeFileSync(psScript, psContent);+ try {+ const result = (await runPsHiddenPromise(psScript, { timeout: 8000 })).toString().trim();+ console.log(`Focused session "${sessionId}" via SetForegroundWindow: ${result}`);+ } catch (e) {+ console.error(`SetForegroundWindow failed for session "${sessionId}":`, e.message);+ }+ }+ // v1.9.180: a raise can land on a stuck-occlusion window (frontmost yet page-hidden β+ // frame-frozen in front of the user). Check shortly after the reveal and unstick at once+ // rather than waiting for the 15s sweep.+ setTimeout(async () => {+ try {+ const s = sessions.get(sessionId);+ const fg = await osGetForegroundWindow();+ const hw = s ? await cachedSessionHwnd(sessionId) : null;+ if (s && fg && hw) await unstickIfHiddenWhileFront(sessionId, s, fg, hw);+ } catch (e) {}+ }, 2000);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true, placement: 'foreground', windowEtiquette: 'foreground' }) }); // v2.0.383 (#63): canonical etiquette key+ } catch (e) {+ sendJSON(res, { success: false, error: `Focus failed: ${e.message}` });+ }+ return;+ }++ // --- Existing commands (operate on active session) ---+ case 'pup_launch': {+ sendJSON(res, { success: false, error: 'pup_launch is removed. Use pup_open_window with required sessionId and profile params.' });+ return;+ }+ case 'pup_screenshot': {+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ const fullPage = args.fullPage || false;+ const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM;+ const ts = Date.now();+ // v2.0.286 (John): a capture can sometimes bring the window to the FOREGROUND (pup falling+ // back to a window-level grab when the CDP path can't serve it). When that happens the user+ // must be told WHY β the missing caption is exactly why uncaptioned foregrounds slipped past.+ // Snapshot the OS foreground before the capture so we can detect+caption it after.+ const _fgBefore = await osGetForegroundWindow().catch(() => null);+ // v2.0.289: show a determinate taskbar progress bar for the length of the capture, sized to+ // the running-average screenshot time (John: progress bars instead of the flash).+ let screenshotBuf = null, _capturedVia = 'cdp';+ try {+ screenshotBuf = await withTaskbarProgress(resolvedId, 'screenshot', () => withTimeout(+ // v2.0.186 (#50): captureBeyondViewport defaults TRUE in current puppeteer, which renders+ // from the document ORIGIN β so a viewport screenshot ignored the page's live scroll+ // position (scroll via pup_eval, screenshot still showed the top). Viewport shots now+ // capture the compositor's real viewport; fullPage keeps beyond-viewport ON (it needs it).+ raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })),+ 20_000, 'page.screenshot'+ ));+ } catch (e1) {+ // v2.0.386 (#80): a CDP capture on a fully OCCLUDED background window can hang until the+ // compositor produces a frame (pup deliberately lets background windows throttle, #14), and+ // the old path surfaced that as a bare timeout with an empty output. Two fallbacks that+ // never touch OS focus, then an honest error: (1) CDP lifecycle 'active' + one retry;+ // (2) an OS-level PrintWindow capture of the window (works while occluded); (3) errorCode.+ if (!/timed out/i.test(String(e1 && e1.message))) throw e1;+ console.log(`[screenshot] "${resolvedId}" CDP capture timed out β trying lifecycle-active retry, then OS window capture`);+ try {+ await pageSetLifecycleActive(tab.page);+ screenshotBuf = await withTimeout(raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })), 8000, 'page.screenshot(retry)');+ _capturedVia = 'cdp-after-lifecycle-active';+ } catch (e2) {+ let _osBuf = null;+ try {+ if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {} }+ if (session._hwnd && !fullPage) {+ const r = await chrome.abCommand('desktop_screenshot_window', { hwnd: Number(session._hwnd) }, { timeoutMs: 25000, callerThread: _callerThread || undefined, callerReason: 'pup screenshot fallback (CDP capture timed out on an occluded window)' });+ const p = abPayload(r) || {};+ const fp = p.fullPath || p.safePath || (r && (r.fullPath || r.safePath));+ if (fp && fs.existsSync(fp)) _osBuf = fs.readFileSync(fp);+ }+ } catch (e) {}+ if (!_osBuf) {+ sendJSON(res, { success: false, errorCode: 'screenshot_timeout', error: `page.screenshot timed out (CDP), a lifecycle-active retry timed out, and the OS window capture was unavailable${fullPage ? ' (fullPage has no OS fallback)' : ''}`,+ _hint: 'The window is probably fully occluded and its compositor is idle (background windows throttle on purpose, #14). pup_eval still works, so the page is fine. Options: retry in a moment; call pup_raise_os_window with a foregroundReason if the user asked to see it; or open the window with highFps:true when you need continuous frames. Read errorCode, not output: this failure used to arrive as an empty output.' });+ return;+ }+ screenshotBuf = _osBuf; _capturedVia = 'os-window';+ console.log(`[screenshot] "${resolvedId}" served by OS window capture after CDP timeouts`);+ }+ }+ // If the capture foregrounded THIS window AND there was no recent user input (so it was pup,+ // not the user clicking it), caption "<thread> Β· screenshotting" so the user knows what put it+ // on their screen. No-op on the common CDP path, which never foregrounds (measured 0/101).+ try {+ const _hw = session && session._hwnd;+ if (_hw) {+ const _fgAfter = await osGetForegroundWindow().catch(() => null);+ if (_fgAfter && String(_fgAfter) === String(_hw) && String(_fgAfter) !== String(_fgBefore)) {+ // v2.0.293 (John: "you keep foregrounding windows without ever giving me ab captions"):+ // the old idle>3000 gate assumed an ACTIVE user must have clicked the window up β but the+ // active-user case is precisely when a silent foreground is most disruptive, and the fg+ // flipping to OUR hwnd mid-capture is pup's doing regardless of user activity. Caption+ // ALWAYS. (The idle reading is kept only for the log.)+ let _idle = -1; try { _idle = await osIdleMs(); } catch (e) {}+ const _thread = session.owner || session._ownerThread || 'an AI thread';+ const _scapMs = captionMsFor(`${_thread} Β· screenshotting`);+ pupCaption(`${_thread} Β· screenshotting`, _scapMs, 'pup-screenshot', _scapMs).catch(() => {});+ console.log(`[screenshot] "${resolvedId}" came to the FOREGROUND during capture (user idle ${_idle}ms) β captioned "${_thread} Β· screenshotting"`);+ }+ }+ } catch (e) {}+ // v1.8.21: fullPage only covers DOCUMENT-level scroll. On SPAs that scroll inside+ // a nested container (the document itself doesn't grow), fullPage silently returns+ // just the visible viewport. Detect that and hand the caller the right tool.+ let nestedScrollHint = null;+ if (fullPage) {+ try {+ await ensureDeepHelpers(tab.page);+ const ns = await tab.page.evaluate(() => {+ const doc = document.scrollingElement || document.documentElement;+ const docScrolls = doc.scrollHeight > doc.clientHeight + 1;+ const main = window.__pupMainScroller();+ const nested = !docScrolls && main !== doc && main.scrollHeight > main.clientHeight + 1;+ return { docScrolls, nested, scroller: window.__pupDesc(main), scrollHeight: main.scrollHeight, clientHeight: main.clientHeight };+ });+ if (ns.nested) nestedScrollHint = `fullPage captured only the viewport: this page scrolls inside a NESTED container (${ns.scroller}, ${ns.scrollHeight}px of content vs ${ns.clientHeight}px visible), not the document β fullPage can't stitch that. To capture the whole thing, either pup_scroll {dy:...} through it and stitch, or (for one region) pup_screenshot_element {selector|text}. To capture a specific card, pup_screenshot_element is the one-shot fix.`;+ } catch (e) {}+ }++ // Downscale to keep the image under Claude's Read limit when sharp is+ // available; otherwise serve at capture size (sharp is optional β see the+ // processScreenshot helper). A β€1568px image is the ideal path.+ const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);+ const shot = await processScreenshot(screenshotBuf, maxDim);+ screenshotBuf = shot.buf;++ const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);+ fs.writeFileSync(filePath, screenshotBuf);+ const sizeKB = Math.round(screenshotBuf.length / 1024);+ session.latestShot = filePath;+ const base64 = screenshotBuf.toString('base64');++ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true,+ sizeKB,+ filePath,+ base64,+ capturedVia: _capturedVia, // v2.0.386 (#80): cdp | cdp-after-lifecycle-active | os-window+ width: shot.width,+ height: shot.height,+ origWidth: shot.origWidth,+ origHeight: shot.origHeight,+ resized: shot.resized,+ ...(shot.sharpUsed ? {} : { sharpUnavailable: true, _hint: 'Screenshot served at CAPTURE size β the sharp resize library is not installed on this box, so the image was NOT downscaled and may exceed Claude\'s ~2000px Read limit. If a Read fails on size, capture a smaller viewport, or (rare) this box\'s native-dep install failed β see pup_readiness.' }),+ ...(nestedScrollHint ? { nestedScroll: true, _hint: nestedScrollHint } : {}),+ sessionId: resolvedId,+ tabId,+ }) });+ return;+ }+ case 'pup_set_viewport': {+ // v1.8.21: set the page viewport (CDP device-metrics) deterministically β+ // independent of the OS window size. Fixes "innerWidth came up ~170px" and lets a+ // fullPage/element shot render at a known width. width+height required.+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ // v1.9.219 (wiki issue #28, Drew Owens): the override used to PIN a window for life β it+ // survived a hard reload, could not be cleared, and nothing reported it, so a window that+ // silently stopped tracking OS resizing was indistinguishable from an app with broken layout+ // code. That cost him a real debugging session hunting his own resize handlers. Fix 1 of 4:+ // a RESET form. Pass {reset:true} (or omit width/height) to call setViewport(null), which+ // clears Emulation device-metrics and hands the page back to the real OS window.+ const wantsReset = args.reset === true || (args.width === undefined && args.height === undefined);+ if (wantsReset) {+ try {+ // v2.0.365 (project-a5's report). VERIFY THE CLEAR, do not assume it. They measured a+ // reset that returned ok:true with "innerWidth now 2200" β the OVERRIDE width, not the+ // real one β while the override was still in force through a reload. A reset that+ // reports the number it was supposed to remove is worse than a failure, because the+ // caller then trusts a window that is still cropping content off the user's screen.+ const _prev = session._emulatedViewport ? Number(session._emulatedViewport.width) : null;+ await tab.page.setViewport(null);+ // Belt and braces: puppeteer's setViewport(null) does not always reach the metrics+ // override on a page that was emulated earlier in its life, so clear it at the+ // protocol level too. Harmless when there is nothing to clear.+ try { const _cdp = await tab.page.createCDPSession(); await _cdp.send('Emulation.clearDeviceMetricsOverride'); await _cdp.detach().catch(() => {}); } catch (e) {}+ session._emulatedViewport = null;+ // Measure AFTER a forced reflow, and measure the OUTER window too: outerWidth is the+ // one number an emulated viewport cannot fake, so innerWidth-vs-outerWidth is how an+ // AI can finally tell an emulated window from a real one (their ask (b)).+ await tab.page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));+ const dims = await tab.page.evaluate(() => ({+ innerWidth: window.innerWidth, innerHeight: window.innerHeight,+ outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio,+ }));+ // Verify by MEASUREMENT. The old check compared against the remembered override+ // width, so on a recovered window (record wiped, override live) there was nothing to+ // compare against and it passed vacuously. innerWidth vs outerWidth cannot be fooled+ // that way, and it also catches the case where the record was never there at all.+ const _stillEmulated = looksEmulated(dims.innerWidth, dims.outerWidth)+ || (_prev && Math.abs(dims.innerWidth - _prev) <= 2);+ if (_stillEmulated) {+ sendJSON(res, { success: false, error: `the viewport override did not clear: the page still reports innerWidth ${dims.innerWidth}, which is the override width`,+ errorCode: 'viewport_reset_failed',+ output: JSON.stringify({ ok: false, sessionId: resolvedId, tabId, emulated: true, ...dims }),+ _hint: `pup could not remove the emulation from this window. Close and reopen it (pup_close_window then pup_open_window) β a fresh window reports the real size. Do NOT trust in-page measurements from this window meanwhile: the page believes it is ${dims.innerWidth}px wide while the OS window is ${dims.outerWidth}px, so the user is seeing it cropped.` });+ return;+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, reset: true, emulated: looksEmulated(dims.innerWidth, dims.outerWidth), ...dims,+ _hint: `Viewport override CLEARED β this window tracks the real OS window again (innerWidth now ${dims.innerWidth}, dpr ${dims.devicePixelRatio}). Resizing the window will reflow the page normally.` }) });+ } catch (e) { sendJSON(res, { success: false, error: `clearing the viewport override failed: ${e.message}` }); }+ return;+ }+ const vw = Number(args.width), vh = Number(args.height);+ if (!Number.isFinite(vw) || !Number.isFinite(vh)) {+ sendJSON(res, { success: false, error: 'pup_set_viewport requires numeric width + height (or {reset:true} to clear an existing override).', _hint: 'Set: {"sessionId":"...","width":1280,"height":900}. Clear: {"sessionId":"...","reset":true}. Optional deviceScaleFactor (default 1).' });+ return;+ }+ const dsf = Number(args.deviceScaleFactor) || 1;+ try {+ // Capture the REAL window width BEFORE emulating. outerWidth is the one number the+ // override cannot fake, and it is what lets every later warning say whether the user is+ // seeing dead space or seeing content cropped off their screen.+ let _outerBefore = null;+ try { _outerBefore = await tab.page.evaluate(() => window.outerWidth); } catch (e) {}+ await tab.page.setViewport({ width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf });+ if (_outerBefore) session._lastOuterWidth = _outerBefore;+ // Fix 2 of 4: RECORD the override so pup_status / pup_list_windows can report it.+ session._emulatedViewport = { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf };+ const dims = await tab.page.evaluate(() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio }));+ // Fix 3 of 4: the hint must state the LIFECYCLE, not just the benefit. The old text sold+ // the feature and never said the effect is permanent for the life of the window.+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, emulated: true, realOuterWidth: _outerBefore, applied: { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf }, ...dims,+ _hint: `Viewport PINNED to ${Math.round(vw)}Γ${Math.round(vh)} (innerWidth now ${dims.innerWidth}). β THIS PERSISTS: it survives navigation AND hard reload, and while it is set the window NO LONGER TRACKS OS RESIZING β a human resizing this window will see the page not reflow, which reads exactly like an app layout bug. CLEAR IT when you are done: pup_set_viewport {"sessionId":"${resolvedId}","reset":true}. It is reported as emulatedViewport in pup_status / pup_list_windows.` }) });+ } catch (e) { sendJSON(res, { success: false, error: `setViewport failed: ${e.message}` }); }+ return;+ }+ case 'pup_scroll': {+ // v1.8.21: first-class scroll. Resolves the ACTUAL scrolling element (nearest+ // ancestor whose scrollHeight>clientHeight, NOT just window β window.scrollTo+ // no-ops on nested-scroll SPAs). Supports scroll-by-delta {dy,dx} AND scroll-to+ // {toSelector|toText, deep} piercing shadow roots; returns the scroller's metrics+ // so the caller can confirm it landed.+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ await ensureDeepHelpers(tab.page);+ try {+ const r = await tab.page.evaluate((o) => {+ let target = null;+ if (o.toSelector) target = window.$deep(o.toSelector);+ else if (o.toText) target = window.deepText(o.toText);+ let scroller;+ if (o.toSelector || o.toText) {+ if (!target) return { found: false };+ scroller = window.__pupScrollableAncestor(target);+ target.scrollIntoView({ block: o.block || 'center', inline: 'nearest', behavior: o.behavior || 'auto' });+ } else {+ scroller = window.__pupMainScroller();+ scroller.scrollBy({ top: o.dy || 0, left: o.dx || 0, behavior: o.behavior || 'auto' });+ }+ return { found: true, scrollElement: window.__pupDesc(scroller), scrollTop: Math.round(scroller.scrollTop), scrollLeft: Math.round(scroller.scrollLeft), scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, atBottom: scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 };+ }, { dy: Number(args.dy) || 0, dx: Number(args.dx) || 0, toSelector: args.toSelector || null, toText: args.toText || null, block: args.block || null, behavior: args.behavior || 'auto' });+ if (!r.found) {+ sendJSON(res, { success: false, errorCode: 'scroll_target_not_found', error: `pup_scroll: could not resolve ${args.toSelector ? 'selector "'+args.toSelector+'"' : 'text "'+args.toText+'"'} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId });+ return;+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, ...r, _hint: `Scrolled ${args.toSelector||args.toText ? 'to the target' : 'by delta'} inside ${r.scrollElement} β scrollTop ${r.scrollTop}/${r.scrollHeight - r.clientHeight}${r.atBottom ? ' (at bottom)' : ''}. Screenshot to confirm, or pup_screenshot_element to grab just a card.` }) });+ } catch (e) { sendJSON(res, { success: false, error: `pup_scroll failed: ${e.message}` }); }+ return;+ }+ case 'pup_screenshot_element': {+ // v1.8.21: screenshot ONE element, resolved by CSS selector OR visible text+ // (piercing shadow DOM), scrolled into view first. The clean fix for "capture+ // just this card" on component-heavy pages. Returns the same shape as+ // pup_screenshot (base64 + filePath, downscaled for Read).+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ if (!args.selector && !args.text) {+ sendJSON(res, { success: false, error: 'pup_screenshot_element requires a selector OR text.', _hint: 'e.g. {"selector":".download-card"} or {"text":"Download for your machine"}. Pierces shadow DOM by default.' });+ return;+ }+ await ensureDeepHelpers(tab.page);+ let handle;+ try {+ // Resolve by selector or text (shadow-piercing), then optionally climb to the+ // nearest matching ancestor with `closest` β e.g. {text:"Downloadβ¦", closest:".card"}+ // grabs the whole CARD, not just the text node. Returns the FIRST text match+ // whose closest(sel) exists, so an ambiguous phrase (also in README prose) still+ // lands on the real widget.+ handle = await tab.page.evaluateHandle((sel, txt, closest) => {+ let el = sel ? window.$deep(sel) : null;+ if (!el && txt) {+ if (closest) {+ // scan all text matches, pick the first whose closest(sel) resolves+ const marks = [];+ const walk = (root) => { const a = root.querySelectorAll('*'); for (const e of a) {+ let own = ''; for (const c of e.childNodes) if (c.nodeType === 3) own += c.textContent;+ if (own.toLowerCase().indexOf(txt.toLowerCase()) !== -1) { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0) marks.push(e); }+ if (e.shadowRoot) walk(e.shadowRoot); } };+ walk(document);+ for (const m of marks) { const c = m.closest(closest); if (c) { el = c; break; } }+ } else { el = window.deepText(txt); }+ } else if (el && closest) { el = el.closest(closest) || el; }+ return el;+ }, args.selector || null, args.text || null, args.closest || null);+ const el = handle.asElement();+ if (!el) {+ try { await handle.dispose(); } catch {}+ sendJSON(res, { success: false, errorCode: 'element_not_found', error: `pup_screenshot_element: no element matched ${args.selector ? 'selector "'+args.selector+'"' : 'text "'+args.text+'"'}${args.closest ? ' with a closest("'+args.closest+'") ancestor' : ''} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId, _hint: 'If matching by text is ambiguous (the phrase also appears in prose), pass a `selector` for the widget, or a `closest` selector for its card container.' });+ return;+ }+ await el.evaluate((e) => e.scrollIntoView({ block: 'center', inline: 'center' }));+ await new Promise(r => setTimeout(r, 150)); // let layout settle after scroll+ const pad = Number(args.padding) || 0;+ // Document-relative clip (getBoundingClientRect + scroll) + captureBeyondViewport β+ // this is coordinate-correct even when the element is below the fold (puppeteer's+ // boundingBox()/clip spaces mismatched and captured the wrong region).+ const rect = await el.evaluate((e, p) => { const r = e.getBoundingClientRect();+ return { x: r.left + (window.scrollX || 0) - p, y: r.top + (window.scrollY || 0) - p, width: r.width + 2 * p, height: r.height + 2 * p, vw: r.width, vh: r.height };+ }, pad);+ if (rect.vw < 1 || rect.vh < 1) throw new Error('element has no rendered box (hidden/zero-size)');+ const rawBuf = await withTimeout(tab.page.screenshot({ type: 'png', captureBeyondViewport: true, clip: { x: Math.max(0, Math.round(rect.x)), y: Math.max(0, Math.round(rect.y)), width: Math.round(rect.width), height: Math.round(rect.height) } }), 20_000, 'element.screenshot(clip)');+ try { await handle.dispose(); } catch {}+ const shot = await processScreenshot(rawBuf, MAX_SCREENSHOT_DIM);+ const ts = Date.now();+ const filePath = path.join(SHOTS_DIR, `pup-el-${ts}.png`);+ fs.writeFileSync(filePath, shot.buf);+ session.latestShot = filePath;+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, matchedBy: args.selector ? 'selector' : 'text', sizeKB: Math.round(shot.buf.length / 1024), filePath, base64: shot.buf.toString('base64'), width: shot.width, height: shot.height, origWidth: shot.origWidth, origHeight: shot.origHeight, resized: shot.resized, _hint: 'Captured just that element (scrolled into view first). Read the filePath to see it.' }) });+ } catch (e) {+ try { if (handle) await handle.dispose(); } catch {}+ sendJSON(res, { success: false, error: `pup_screenshot_element failed: ${e.message}` });+ }+ return;+ }+ case 'pup_screenshot_full_res': {+ // v1.8.40+: full-resolution variant for the rare "save to disk for+ // external use" case (export to wiki, hand to a designer, etc.).+ // NEVER pass the resulting image to Claude via Read β full-res+ // browser viewports are typically 2559x1398 (or larger on hi-DPI+ // monitors), which exceeds Claude's 2000px many-image limit and+ // crashes the session. For AI-readable screenshots, use+ // pup_screenshot which forces a β€1568px resize.+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ const fullPage = args.fullPage || false;+ const ts = Date.now();+ const screenshotBuf = await withTimeout(+ tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage }),+ 20_000, 'page.screenshot'+ );+ // Probe dimensions for the warning payload β cheap. No resize, no+ // recompression β the bytes that hit disk are exactly what Chrome captured.+ const meta = sharp ? await sharp(screenshotBuf).metadata() : pngDimensions(screenshotBuf);+ const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`);+ fs.writeFileSync(filePath, screenshotBuf);+ const sizeKB = Math.round(screenshotBuf.length / 1024);+ session.latestShot = filePath;+ const base64 = screenshotBuf.toString('base64');+ sendJSON(res, {+ success: true,+ output: JSON.stringify({+ ok: true,+ sizeKB,+ filePath,+ base64,+ width: meta.width,+ height: meta.height,+ sessionId: resolvedId,+ tabId,+ _warning: `FULL RESOLUTION (${meta.width}x${meta.height}px) β do NOT pass this image to Claude via the Read tool. Images >2000px crash many-image sessions at the Claude API limit. Use pup_screenshot instead for AI-readable screenshots (auto-resized to β€1568px). This command is for saving to disk / wiki / export only.`,+ }),+ });+ return;+ }+ case 'pup_hover': {+ // v1.9.198 (Bills thread): pup_hover did not exist β the only way to move the pointer+ // without clicking was pup_input_dispatch {type:'move'}, which is unobvious next to+ // pup_click. Hovering also reveals tooltips/menus that only appear on pointer-over.+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ try {+ let px = Number(args.x), py = Number(args.y);+ if (args.selector) {+ const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });+ px = box.x; py = box.y;+ }+ if (!Number.isFinite(px) || !Number.isFinite(py)) {+ sendJSON(res, { success: false, errorCode: 'hover_target_missing', error: 'pup_hover needs selector, or x + y.' });+ return;+ }+ const _hIntent = (typeof args.intent === 'string' && args.intent.trim()) ? args.intent.trim() : `Hovering ${args.selector ? `"${args.selector}"` : 'target'}`;+ if (args.showCursor !== false) { try { await showPupCursor(tab.page, px, py, { click: false, label: _hIntent }); } catch (e) {} }+ await withTimeout(tab.page.mouse.move(px, py, { steps: Number(args.steps) || 8 }), 10_000, 'mouse.move');+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, hovered: args.selector || { x: px, y: py },+ _hint: 'Pointer moved (isTrusted) and the visible Adom cursor glided there. Tooltips/hover menus that need a real pointer-over should now be present β read them with pup_eval.' }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'hover_failed', error: `pup_hover failed: ${e.message}` });+ }+ return;+ }+ case 'pup_set_identity': {+ // v1.9.343 (John: "put my Adom profile picture in there β show my adom identity"). Chrome's+ // own profile chip refuses a custom local image (only a real Google sign-in gets one), so we+ // show the user's Adom identity in the floating owner chip instead. This verb stores the+ // avatar (a pre-circle-cropped PNG as base64, so the bridge stays image-lib-free here) + a+ // display name into pup-settings, and repaints every live window's chip so it shows at once.+ try {+ const patch = {};+ if (args.clear) { patch.identityAvatarB64 = ''; patch.identityName = ''; }+ if (typeof args.avatarB64 === 'string') patch.identityAvatarB64 = args.avatarB64.replace(/^data:image\/\w+;base64,/, '');+ if (typeof args.name === 'string') patch.identityName = args.name;+ if (args.enabled === true || args.enabled === false) patch.ownerChip = args.enabled ? 'on' : 'off';+ savePupSettings(patch);+ // Repaint live windows NON-BLOCKING (each per-page evaluate is timeout-bounded inside+ // applyOwnerChipSetting): remove the current chip, then re-add with the new identity if the+ // chip is on. Fire-and-forget so a slow page can never make THIS verb time out.+ applyOwnerChipSetting(false)+ .then(() => { if (pupSettings().ownerChip === 'on') return applyOwnerChipSetting(true); })+ .catch(() => {});+ const s = pupSettings();+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true,+ identityName: s.identityName || null,+ hasAvatar: !!s.identityAvatarB64,+ avatarBytes: s.identityAvatarB64 ? Buffer.from(s.identityAvatarB64, 'base64').length : 0,+ ownerChip: s.ownerChip,+ _hint: 'Adom identity set. It shows as the avatar leading the floating owner chip (bottom-left) on every pup window, applied live. Chrome\'s own profile chip cannot take a custom local image (Google sign-in only), so this is where your Adom identity lives. Turn the chip off with ownerChip:off or {enabled:false}; clear the identity with {clear:true}.' }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'set_identity_failed', error: `pup_set_identity failed: ${e.message}` });+ }+ return;+ }+ case 'pup_maximize': {+ // v1.9.197 GAP 6 (Bills thread): honouring "pup windows must be maximized" required+ // pup_eval + window.resizeTo(...) because desktop_set_window_state is not in every+ // desktop's verb set. This does it properly via CDP Browser.setWindowBounds (the same+ // channel park uses), so it works on any host and never depends on an AD verb being present.+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ try {+ const cdp = await session.page.target().createCDPSession();+ const { windowId } = await cdp.send('Browser.getWindowForTarget');+ const want = (args.state === 'normal' || args.state === 'minimized' || args.state === 'fullscreen') ? args.state : 'maximized';+ // A window must be 'normal' before another state can be applied.+ try { await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } }); } catch (e) {}+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: want } });+ let bounds = null;+ try { ({ bounds } = await cdp.send('Browser.getWindowBounds', { windowId })); } catch (e) {}+ try { await cdp.detach(); } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, state: want, bounds,+ _hint: 'Window state set via CDP. This does NOT foreground the window β it stays background-parked unless you explicitly raise it (pup_raise_os_window with a foregroundReason).' }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'maximize_failed', error: `pup_maximize failed: ${e.message}` });+ }+ return;+ }+ // pup_evaluate is the catalog/manifest name; pup_eval the historical one. Accept both.+ // v2.0.255 (John, adom/pup-bridge#65): this alias was written as a fallthrough label above+ // `case 'pup_hover'` instead of here, so EVERY pup_evaluate call ran the HOVER handler and+ // came back "pup_hover needs selector, or x + y" / hover_target_missing β an error naming a+ // verb the caller never called, while the _hint stayed pup_evaluate's own (that split is the+ // tell for a misdispatch, and it is worth remembering: right docs + wrong error = routing).+ // It made the only verb that can READ the DOM unusable, so a page could be driven but never+ // asserted on, which is exactly where it left John mid-ralph.+ case 'pup_evaluate':+ case 'pup_eval': {+ // He probed expr/expression/script/js/code, all four failing identically because none of+ // them ever reached this handler. Since the catalog did not state the arg name either,+ // accept the obvious synonyms rather than making anyone guess again.+ for (const _a of ['expression', 'script', 'js', 'code']) { if (!args.expr && args[_a]) args.expr = args[_a]; }+ if (!args.expr) {+ sendJSON(res, { success: false, errorCode: 'eval_expr_missing', error: 'Missing required parameter "expr" (aliases: "expression", "script", "js", "code"). Usage: pup_eval { "sessionId": "...", "expr": "document.title" }' });+ return;+ }+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ // v1.8.21: ensure the shadow-DOM helpers are present so an expr can use+ // $deep(sel) / $$deep(sel) / deepText(txt) without hand-rolling a shadow walk.+ await ensureDeepHelpers(tab.page);+ // v1.9.197 GAP 5 (Bills thread): pup_eval only ever ran in the TOP frame, so every call+ // against a login form inside an iframe had to be hand-wrapped in+ // document.getElementById('logonbox').contentDocument β which only works because that frame+ // happened to be SAME-ORIGIN. A cross-origin login iframe (very common) was simply+ // unreachable. `frame` selects the execution context by name/id, url substring, or index;+ // puppeteer talks to each frame over its own CDP session, so cross-origin works too.+ let _evalCtx = tab.page;+ if (args.frame !== undefined && args.frame !== null && args.frame !== '') {+ const _frames = tab.page.frames();+ const _fq = String(args.frame);+ let _f = null;+ if (/^\d+$/.test(_fq)) _f = _frames[Number(_fq)] || null;+ if (!_f) _f = _frames.find(fr => { try { return fr.name() === _fq; } catch (e) { return false; } }) || null;+ if (!_f) _f = _frames.find(fr => { try { return (fr.url() || '').includes(_fq); } catch (e) { return false; } }) || null;+ if (!_f) {+ const _avail = _frames.map((fr, i) => { let nm = '', u = ''; try { nm = fr.name(); } catch (e) {} try { u = fr.url(); } catch (e) {} return `[${i}] name=${nm || '(none)'} url=${(u || '').slice(0, 70)}`; });+ sendJSON(res, { success: false, errorCode: 'frame_not_found',+ error: `No frame matched "${_fq}" in session "${resolvedId}".`,+ frames: _avail,+ _hint: `frame matches by frame NAME, then by URL substring, then by numeric index. Frames currently in this tab:\n${_avail.join('\n')}\nNote an iframe's id attribute is NOT its frame name β use its name attribute, a distinctive piece of its src URL, or the index. Omit frame to run in the top document.` });+ return;+ }+ _evalCtx = _f;+ }+ let result;+ try {+ result = await withTimeout(+ raceCrash(tab, resolvedId, _evalCtx.evaluate(new Function('return (' + args.expr + ')'))),+ 20_000, 'page.evaluate'+ );+ } catch (e) {+ // v1.9.158: a DETACHED page handle is a recovery signal, not a result. Re-resolve the+ // session's live page by window.name (the durable carrier) and retry ONCE β this is the+ // armor for the detached-frame-under-load failure (two sessions went permanently+ // undrivable in the 2026-07-27 heavy run because this error was returned as __error).+ if (/detached Frame|Session closed|Target closed/i.test(e.message || '')) {+ try {+ const be2 = browsers.get(session.profileName);+ let fresh = null;+ if (be2 && be2.browser.isConnected()) {+ for (const pg of await be2.browser.pages()) {+ const wn = await pg.evaluate(() => window.name).catch(() => '');+ if (wn === 'pup:' + resolvedId) { fresh = pg; break; }+ }+ }+ if (fresh) {+ tab.page = fresh;+ try { fresh._pupCreated = true; } catch (e3) {}+ console.log(`[recover] "${resolvedId}" page handle was detached β re-resolved live page by window.name, retrying eval`);+ result = await withTimeout(fresh.evaluate(new Function('return (' + args.expr + ')')), 20_000, 'page.evaluate(retry)');+ } else { result = { __error: e.message + ' (re-resolve by window.name found no live page)' }; }+ } catch (e2) { result = { __error: e.message + ' (recovery retry failed: ' + e2.message + ')' }; }+ } else {+ result = { __error: e.message };+ }+ }+ // v2.0.274 (#69, second half): STATEMENTS, and stop swallowing the error.+ // The eval above is `new Function('return (' + expr + ')')` β EXPRESSION-only. The reporter+ // sent `location.assign('...'); 'navigating'`, which is two statements, so it was a+ // SyntaxError: nothing ran, the page never navigated, and the handler still answered+ // ok:true with the failure buried in result.__error. Measured verbatim:+ // result: {"__error": "Unexpected token ';'"} returned as a SUCCESS+ // Fix 1: on a SYNTAX error, retry through CDP Runtime.evaluate, which has devtools-console+ // semantics β it accepts a statement list and returns the completion value, so the same+ // source works and 'navigating' comes back.+ if (result && result.__error && /Unexpected token|SyntaxError|Unexpected end of input/i.test(String(result.__error))) {+ let cdpSession = null;+ try {+ cdpSession = await _evalCtx.target().createCDPSession();+ const r = await withTimeout(cdpSession.send('Runtime.evaluate', {+ expression: String(args.expr), returnByValue: true, awaitPromise: true, userGesture: true,+ }), 20_000, 'Runtime.evaluate');+ if (r && r.exceptionDetails) {+ result = { __error: (r.exceptionDetails.exception && r.exceptionDetails.exception.description) || r.exceptionDetails.text || 'evaluation threw' };+ } else {+ result = r && r.result ? r.result.value : null;+ console.log(`[eval] "${resolvedId}" expression form was a syntax error β ran it as STATEMENTS via Runtime.evaluate (devtools-console semantics)`);+ }+ } catch (e) {+ // A navigation started by the source tears down the context; that is the code WORKING.+ if (/context was destroyed|Execution context|Target closed/i.test(e.message || '')) {+ console.log(`[eval] "${resolvedId}" execution context was destroyed mid-eval β that means the page NAVIGATED, which is success, not failure`);+ result = { navigated: true };+ }+ } finally { try { if (cdpSession) await cdpSession.detach(); } catch (e) {} }+ }+ // Fix 2: a failed eval is a FAILED verb. #69's whole complaint is verbs that answer ok+ // while nothing happened; returning {ok:true, result:{__error}} is that exact lie.+ if (result && typeof result === 'object' && result.__error) {+ sendJSON(res, { success: false, errorCode: 'eval_failed', error: `pup_eval could not run your code: ${result.__error}`,+ _hint: 'The expression form is tried first, then a statement list via Runtime.evaluate. If this is a syntax error, check the source; if it names a detached frame or closed target, the tab moved and pup already retried once.' });+ return;+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, result, sessionId: resolvedId, tabId }) });+ return;+ }+ // ββ Element-level input + history nav (v1.8.8) βββββββββββββββββββββββββ+ // These five were advertised in bridge.json + pup_describe but had no+ // router cases (found by the full verb self-test). Selector-based where it+ // helps; same session/tab auto-recovery as pup_eval.+ case 'pup_click': {+ if (!args.selector && !(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {+ sendJSON(res, { success: false, error: 'pup_click needs "selector" OR "x"+"y". Usage: { "sessionId": "...", "selector": "#submit" } or { "sessionId": "...", "x": 100, "y": 200 }' });+ return;+ }+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ try {+ // v1.9.164: show the animated cursor gliding to the target BEFORE the real click, so the+ // user can see pup is driving. Default on; {showCursor:false} skips it.+ const _cursor = args.showCursor !== false;+ // v1.9.197 BUG 2: build real click options so clickCount/button/delay actually apply.+ const _cc = Number(args.clickCount);+ const _clickOpts = {};+ if (Number.isFinite(_cc) && _cc >= 1) _clickOpts.clickCount = Math.min(3, Math.round(_cc));+ if (typeof args.button === 'string') _clickOpts.button = args.button;+ if (Number.isFinite(Number(args.delay))) _clickOpts.delay = Number(args.delay);+ // v1.9.196: the cursor LABEL must narrate WHAT is being clicked and WHY β that is the whole+ // point of showing a cursor (John: "i can't even read the indicator text you're supposed to+ // put in there explaining your reasoning"). Caller may pass `intent`; otherwise pup composes+ // an HD-style narration from the target itself, so the label is never a bare "Adom".+ const _tgt = args.selector || (args.text ? `"${args.text}"` : null);+ const _intent = (typeof args.intent === 'string' && args.intent.trim())+ ? args.intent.trim()+ : (args.reason && String(args.reason).trim() ? String(args.reason).trim() : (_tgt ? `Clicking ${_tgt}` : 'Clicking'));+ let _method = 'cdp';+ if (args.selector && args.domClick === true) {+ // v2.0.128 (consumer bug report #2): some React controls (heart/like/vote toggles)+ // ignore a CDP "trusted" mouse click but DO fire on a synthetic element.click(). This+ // is the opposite of pup's usual advice, so it's opt-in β the miss-hint below points+ // callers here when a trusted click produced no state change.+ _method = 'domClick';+ const dr = await withTimeout(tab.page.evaluate((sel) => {+ const el = document.querySelector(sel);+ if (!el) return { found: false };+ el.scrollIntoView({ block: 'center', inline: 'center' });+ el.click();+ return { found: true };+ }, args.selector), 8_000, 'domClick.evaluate');+ if (!dr.found) { sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_click(domClick): selector "${args.selector}" not found.`, _hint: `Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` }); return; }+ } else if (args.selector) {+ // v2.0.128 (consumer bug report #3): page.click(selector) re-resolves the element and+ // waits on Puppeteer's visibility machinery, which crawls on huge/animated DOMs and hit+ // the 15s wall β while a plain coordinate click on the SAME element returned instantly.+ // So: locate the element ONCE (scroll into view, read its center), then click by those+ // coordinates β the fast path the caller proved works. Only fall back to page.click when+ // the box can't be resolved (0-size / not laid out yet).+ let _box = null;+ try {+ _box = await withTimeout(tab.page.evaluate((sel) => {+ const el = document.querySelector(sel);+ if (!el) return null;+ el.scrollIntoView({ block: 'center', inline: 'center' });+ const r = el.getBoundingClientRect();+ if (r.width < 1 || r.height < 1) return null;+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 };+ }, args.selector), 6_000, 'locate.box');+ } catch (e) {}+ if (_box) {+ if (_cursor) { await showPupCursor(tab.page, _box.x, _box.y, { label: _intent }); await new Promise(r => setTimeout(r, 440)); }+ _method = 'coordinate';+ await withTimeout(tab.page.mouse.click(_box.x, _box.y, _clickOpts), 10_000, 'mouse.click(box)');+ } else {+ // element not found/visible yet β bounded wait, then Puppeteer's own click (honours clickCount).+ _method = 'page.click';+ await withTimeout(tab.page.click(args.selector, _clickOpts), 12_000, 'page.click');+ }+ } else {+ if (_cursor) { await showPupCursor(tab.page, Number(args.x), Number(args.y), { label: _intent }); await new Promise(r => setTimeout(r, 440)); }+ _method = 'coordinate';+ await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y), _clickOpts), 10_000, 'mouse.click');+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: args.selector || { x: Number(args.x), y: Number(args.y) }, method: _method, sessionId: resolvedId, tabId, _hint: (args.selector && _method !== 'domClick') ? 'Click dispatched (isTrusted). Verify the effect with pup_eval (a state probe) β a click that hit nothing still returns ok. If a React toggle/like/heart did NOT change state, that control ignores trusted clicks: retry pup_click {selector, domClick:true} to fire the synthetic handler.' : 'Click dispatched. Verify the effect with pup_eval (e.g. document.title or a state probe) β a click that hit nothing still returns ok.' }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'click_failed', error: `pup_click failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist/be visible. Probe it first: pup_eval {"expr":"!!document.querySelector('${args.selector}')"} β or click by x/y from a pup_screenshot.` : 'x/y click failed β coordinates are page-viewport pixels; take a pup_screenshot to aim.' });+ }+ return;+ }+ // v1.9.167 β ASSISTED FOREGROUND CLICK (John's full protocol) for surfaces pup's CDP click+ // can't drive (native dialog, cross-origin FedCM chooser). Caption the reasoning, toast,+ // foreground, WAIT until the user is actually on the window (alt-tab aware), then a TIGHT+ // real-cursor hover->click, captioning each step. x,y = WINDOW-image pixels from a+ // desktop_screenshot_window you took to find the target. reason=why, label=what.+ case 'pup_assisted_click': {+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ if (!(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {+ sendJSON(res, { success: false, error: 'pup_assisted_click needs window-image x+y (from a desktop_screenshot_window of this session) plus reason+label.' }); return;+ }+ if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'assisted click is Windows-only (uses the OS cursor + foreground detection).' }); return; }+ const _reason = String(args.reason || 'Adom needs to click something on your screen.').slice(0, 180);+ const _label = String(args.label || 'the highlighted control').slice(0, 80);+ try {+ await pupCaption(`Adom: ${_reason} I will bring this window up and WAIT until you are ready before clicking ${_label}.`, 9000);+ try { await chrome.abCommand('notify_user', { title: 'Adom needs one click', body: `${_label} β ${_reason} I will wait until you are on the window.`, focus: false }, { timeoutMs: 4000 }); } catch (e) {}+ session._foreground = true; session._lastForeground = { ts: Date.now(), verb: command, reason: _reason };+ try { await osRaiseSessionWindow(session, resolvedId, null); } catch (e) {} // assisted_click narrates its own captions+ const hwnd = await sessionHwnd(resolvedId);+ // v2.0.397 (#92): the in-verb wait is capped under every ab budget; a longer human+ // absence answers blocked_on_user (the caller re-issues the click when the user is back).+ const _maxWait = Math.min(Number(args.maxWaitMs) || 120000, 45000);+ const ready = await waitForUserReady(resolvedId, hwnd, { maxMs: _maxWait });+ if (!ready) {+ await pupCaption('Adom is still waiting for you to come back to this window; your AI will ask again when you are ready.', 7000);+ sendJSON(res, blockedOnUser({ stillRunning: false, reason: 'user_not_ready', blockedBy: 'The user must bring the pup window to the foreground (click it or Alt-Tab to it) so the real-cursor click lands in the right window', waitedSec: Math.round(_maxWait / 1000), error: 'Waited for the user to focus the window; they did not. Nothing was clicked.', _hint: 'Not a failure: the human was not at the window. Tell them what you need clicked and where, then re-issue pup_assisted_click with the same args when they say they are ready; it waits again (up to 45s per call).' }));+ return;+ }+ await pupCaption(`Adom is clicking ${_label} now.`, 5000);+ const shot = abPayload(await chrome.abCommand('desktop_screenshot_window', { ...(await windowTarget(sessions.get(resolvedId), resolvedId)) }, { timeoutMs: 8000, caller: sessionCaller(session) }));+ const shotId = shot && (shot.shotId || (shot.screenshots && shot.screenshots[0] && shot.screenshots[0].shotId));+ if (!shotId) { sendJSON(res, { success: false, error: 'could not capture the window to aim the click' }); return; }+ const X = Math.round(Number(args.x)), Y = Math.round(Number(args.y));+ try { await chrome.abCommand('desktop_hover', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}+ await new Promise(r => setTimeout(r, 900));+ try { await chrome.abCommand('desktop_click', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}+ await pupCaption(`Adom clicked ${_label}. Continuing.`, 5000);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: _label, x: X, y: Y, sessionId: resolvedId }), _hint: `Assisted click done after the user was confirmed on the window. Verify the effect with a screenshot before assuming it advanced.` });+ } catch (e) {+ sendJSON(res, { success: false, error: `assisted click failed: ${e.message}` });+ }+ return;+ }+ case 'pup_type': {+ if (typeof args.text !== 'string') {+ sendJSON(res, { success: false, error: 'pup_type needs "text" (string). Optional "selector" focuses the element first. Usage: { "sessionId": "...", "selector": "#q", "text": "hello" }' });+ return;+ }+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ try {+ // v1.9.197 BUG 3 (Bills thread, top priority): pup_type ALWAYS APPENDED, and with+ // modifiers broken (bug 1) and clickCount ignored (bug 2) there was NO way to replace a+ // field's value at all β the caller got "adominc1adominc1" and fell back to a bash loop of+ // 20 Backspaces. `clear:true` now empties the field first, using REAL key events+ // (select-all then Delete) rather than setting .value, because React-controlled inputs+ // ignore direct value writes β the Chase login form is exactly that case.+ if (args.clear) {+ if (args.selector) { await withTimeout(tab.page.focus(args.selector), 8_000, 'page.focus'); }+ const _selMod = process.platform === 'darwin' ? 'Meta' : 'Control';+ await tab.page.keyboard.down(_selMod);+ try { await tab.page.keyboard.press('a'); } finally { try { await tab.page.keyboard.up(_selMod); } catch (e) {} }+ await tab.page.keyboard.press('Delete');+ }+ if (args.selector) await withTimeout(tab.page.type(args.selector, args.text), 15_000, 'page.type');+ else await withTimeout(tab.page.keyboard.type(args.text), 15_000, 'keyboard.type');+ // v2.0.128 (consumer bug report #1 β highest priority): pup_type reported ok:true on a+ // field that stayed EMPTY, so a sign-in silently no-opped while pup claimed success. Read+ // the value back when we have a selector, and if what we typed did not land, still return+ // ok:true (the keystrokes WERE dispatched) but flag valueLanded:false with the real reason:+ // CDP/page.keyboard keystrokes only register in a FOREGROUNDED window, and this window is+ // very likely in the background. Point at the two working paths.+ let _valueLanded = null, _readLen = null;+ if (args.selector) {+ try {+ _readLen = await withTimeout(tab.page.$eval(args.selector, el => {+ if (el == null) return -1;+ if (typeof el.value === 'string') return el.value.length;+ if (el.isContentEditable) return (el.textContent || '').length;+ return -2; // element exists but has no value/textContent we can read+ }), 5_000, 'verify.$eval');+ if (_readLen >= 0) _valueLanded = _readLen >= Math.min(args.text.length, 1);+ } catch (e) { _readLen = null; }+ }+ const _emptyMiss = _valueLanded === false && args.text.length > 0;+ const _out = { ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId };+ if (args.selector) { _out.valueLanded = _valueLanded; _out.fieldValueLength = _readLen; }+ _out._hint = _emptyMiss+ ? 'β KEYSTROKES DISPATCHED BUT THE FIELD IS STILL EMPTY β this is the silent no-op. CDP/page.keyboard keystrokes ONLY register when the window is FOREGROUNDED, and this window is almost certainly in the background. Two fixes: (1) for a NON-React input, set the value directly β pup_eval will not (expression-only), so use pup_fill {selector,text} which sets .value via the native setter + dispatches input/change and works in the background; (2) for a login form (React-masked fields, real keystrokes required) use pup_login {username,password,realKeystrokes:true}, which foregrounds the window automatically. Do NOT trust a bare ok:true from pup_type on a login field β check valueLanded.'+ : (_valueLanded === true ? 'Typed via CDP (isTrusted); value verified present in the field.' : 'Typed via CDP (isTrusted). Could not read the field back to verify (no selector, or non-standard element) β confirm with a screenshot before assuming it landed.');+ sendJSON(res, { success: true, output: JSON.stringify(_out) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'type_failed', error: `pup_type failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist. Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` : 'No selector given β ensure an element has focus (pup_click it first).' });+ }+ return;+ }+ // v2.0.128 (consumer bug report #1): the BACKGROUND-safe fallback that pup_type's miss-hint+ // points at. Sets the field's value via the native property setter and dispatches input ++ // change so React/Vue/Angular controlled inputs pick it up β no foreground, no keystrokes.+ // Works for the common "logged-out form filled while the window is in the background" case.+ // For React-MASKED password fields that ignore programmatic value writes, pup_login+ // {realKeystrokes:true} is still the answer (it foregrounds + sends real keys). pup_fill+ // ALWAYS reads the value back and reports valueLanded so a caller never gets a false ok.+ case 'pup_fill': {+ if (!args.selector || typeof args.text !== 'string') {+ sendJSON(res, { success: false, error: 'pup_fill needs "selector" and "text" (string). It sets .value via the native setter + fires input/change (React-safe, works in the background). Usage: { "sessionId": "...", "selector": "#email", "text": "[email protected]" }' });+ return;+ }+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ try {+ const r = await withTimeout(tab.page.evaluate((sel, val) => {+ const el = document.querySelector(sel);+ if (!el) return { found: false };+ // Use the prototype's native value setter so React's descriptor override doesn't swallow it.+ try {+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;+ const desc = Object.getOwnPropertyDescriptor(proto, 'value');+ if (desc && desc.set) desc.set.call(el, val); else el.value = val;+ } catch (e) { try { el.value = val; } catch (e2) {} }+ el.dispatchEvent(new Event('input', { bubbles: true }));+ el.dispatchEvent(new Event('change', { bubbles: true }));+ const got = (typeof el.value === 'string') ? el.value : (el.textContent || '');+ return { found: true, landedLen: got.length, matched: got === val };+ }, args.selector, args.text), 8_000, 'pup_fill.evaluate');+ if (!r.found) {+ sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_fill: selector "${args.selector}" not found.`, _hint: `Probe it: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` });+ return;+ }+ const _landed = r.matched === true || r.landedLen >= Math.min(args.text.length, 1);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, filled: args.selector, valueLanded: _landed, fieldValueLength: r.landedLen, sessionId: resolvedId, tabId,+ _hint: _landed+ ? 'Set via native setter + input/change (background-safe). If a React SUBMIT still rejects it as empty, the field is masked/guarded β use pup_login {username,password,realKeystrokes:true} which foregrounds + sends real keystrokes.'+ : 'β Value did not stick β this field guards against programmatic writes (React-masked password fields do this). Use pup_login {realKeystrokes:true} (foregrounds + real keys) instead.'+ }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'fill_failed', error: `pup_fill failed: ${e.message}`, _hint: 'The page may have navigated. Re-check the field with pup_eval, or retry.' });+ }+ return;+ }+ case 'pup_press_key': {+ if (!args.key) {+ sendJSON(res, { success: false, error: 'pup_press_key needs "key" β a puppeteer KeyInput name like "Enter", "Tab", "Escape", "ArrowDown", "a". Usage: { "sessionId": "...", "key": "Enter" }' });+ return;+ }+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ try {+ // v1.9.197 BUG 1 (Bills thread): modifiers were parsed by NOBODY β keyboard.press(key)+ // ignores them entirely, so Ctrl+A typed a literal "a" into the field ("adominc1" ->+ // "adominc1a") and every chord (Ctrl+A/C/V) was unusable. Hold each modifier down, press,+ // then release in reverse order β the CDP-correct way to send a real chord.+ const _mods = Array.isArray(args.modifiers) ? args.modifiers.filter(m => typeof m === 'string' && m.trim()) : [];+ const _MODMAP = { control: 'Control', ctrl: 'Control', cmd: 'Meta', command: 'Meta', meta: 'Meta', alt: 'Alt', option: 'Alt', shift: 'Shift' };+ const _norm = _mods.map(m => _MODMAP[String(m).toLowerCase()] || m);+ const _bad = _norm.filter(m => !['Control', 'Meta', 'Alt', 'Shift'].includes(m));+ if (_bad.length) {+ sendJSON(res, { success: false, errorCode: 'bad_modifier', error: `Unknown modifier(s): ${_bad.join(', ')}.`, _hint: 'modifiers must be any of ["Control","Shift","Alt","Meta"] (aliases: ctrl, cmd, command, option accepted).' });+ return;+ }+ for (const m of _norm) await tab.page.keyboard.down(m);+ try {+ await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');+ } finally {+ for (const m of [..._norm].reverse()) { try { await tab.page.keyboard.up(m); } catch (e) {} }+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, pressed: args.key, sessionId: resolvedId, tabId, _hint: 'Key pressed in the PAGE (isTrusted). For OS-level chords outside the page (alt+f4, ctrl+shift+p in browser chrome), use AD\'s desktop_press_key instead.' }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'press_key_failed', error: `pup_press_key failed: ${e.message}`, _hint: `"${args.key}" must be a valid puppeteer KeyInput name (case-sensitive: "Enter", "ArrowLeft", "F5", single chars like "a").` });+ }+ return;+ }+ case 'pup_back':+ case 'pup_forward': {+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ const dir = command === 'pup_back' ? 'back' : 'forward';+ try {+ let before = null; try { before = tab.page.url(); } catch {}+ // goBack/goForward resolve null on a CACHED history nav even though the URL+ // DID change, so we detect "moved" by the URL actually changing, not the+ // (unreliable) return value.+ await withTimeout(dir === 'back' ? tab.page.goBack({ waitUntil: 'domcontentloaded' }) : tab.page.goForward({ waitUntil: 'domcontentloaded' }), 20_000, 'history.' + dir).catch(() => {});+ let url = null; try { url = tab.page.url(); } catch {}+ const moved = !!(url && before && url !== before);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, direction: dir, moved, url, sessionId: resolvedId, tabId, _hint: moved ? `Now at ${url}.` : `No ${dir === 'back' ? 'earlier' : 'later'} history entry β the page did not move (moved:false, not an error).` }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: dir + '_failed', error: `pup_${dir} failed: ${e.message}` });+ }+ return;+ }+ case 'pup_input_dispatch': {+ // Trusted input via puppeteer's CDP-backed page.mouse / page.keyboard.+ // Events fired here have isTrusted=true (Chromium dispatches them at the+ // browser level via Input.dispatchMouseEvent / dispatchKeyEvent), unlike+ // page-side dispatchEvent(new MouseEvent(...)) which is isTrusted=false+ // and silently no-ops on framework click handlers / anti-automation gates.+ //+ // Types:+ // click β { x, y } coords, OR { selector } resolves bounding rect+ // move β { x, y } mouse move (useful for human-like trajectories)+ // type β { text } types into the focused element+ // key β { key } presses a single key (Enter, Escape, Tab, ArrowUp, etc.)+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }++ const t = (args.type || '').toLowerCase();+ if (!t) {+ sendJSON(res, { success: false, error: 'pup_input_dispatch: "type" required (click|move|type|key)' });+ return;+ }+ try {+ if (t === 'click') {+ if (args.selector) {+ // SMART SELECTOR PICK (v1.4.8+):+ //+ // Puppeteer's page.click(selector) blindly takes the first+ // document-order match. That bit users on WAGO when+ // `button.wg-button--primary` matched 4 elements (a 54x54+ // cookie-banner X with no text, two visible Add-to-cart+ // buttons, the actual Generate-datasheet button) β and the+ // cookie X got clicked. The relay returned ok:true because+ // a trusted click DID dispatch β just on the wrong element.+ //+ // We now run all matches through page.evaluate() to:+ // 1. Filter out hidden elements (display:none, opacity:0,+ // offscreen, zero-area, behind the topmost stacking+ // context modal)+ // 2. Filter out elements with empty text content (modal+ // close buttons / icon-only utility buttons usually+ // aren't what the caller wanted when they passed a+ // class selector)+ // 3. Tie-break by visual area (largest wins β the actual+ // submit button beats a small icon button)+ //+ // The chosen element's index, rect, and visible text are+ // returned in the response so the caller can sanity-check.+ // Pass `firstMatch:true` to skip filtering and use legacy+ // first-document-order behavior.+ const matches = await tab.page.$$(args.selector);+ if (matches.length === 0) {+ sendJSON(res, { success: false, error: `selector not found: ${args.selector}`, _hint: 'Selector matches no element. Verify the page is loaded (pup_wait) and the selector is correct (try with pup_eval first).' });+ return;+ }++ let chosenHandle = matches[0];+ let chosenIndex = 0;+ let chosenMeta = null;+ // v1.4.10: surface which DOM element won the modal-root+ // detection in the response, so callers can debug false-+ // positives. Stays null in the single-match / firstMatch+ // branches since modal scoping doesn't apply there.+ let modalRootInfo = null;++ if (matches.length === 1 || args.firstMatch === true) {+ // Single match OR explicit opt-out β use document-order first.+ chosenMeta = await tab.page.evaluate((el) => {+ if (!el) return null;+ const r = el.getBoundingClientRect();+ return {+ x: Math.round(r.x), y: Math.round(r.y),+ w: Math.round(r.width), h: Math.round(r.height),+ text: (el.textContent || '').trim().slice(0, 80),+ };+ }, chosenHandle);+ } else {+ // Multiple matches β score them and pick the best.+ //+ // v1.4.9+: when an open modal/dialog is on screen, restrict+ // the candidate set to elements INSIDE it. The page behind+ // a modal is visually inert; clicking elements behind a+ // modal is almost never what the user intended. WAGO bit+ // on this: their "Add to shopping cart" button (behind the+ // modal) was 9648 pxΒ², the modal's "Generate data sheet"+ // was 9264 pxΒ² β area-only tiebreak picked add-to-cart.+ // Now we detect the modal root first and only look inside.+ const evalResult = await tab.page.evaluate((selector) => {+ // v1.4.10: A modal-root candidate is only "plausible" if it+ // contains at least one visible interactive element with+ // non-empty text. Without this filter, empty overlay+ // containers (Vue-Toastification's 4 toast wrappers,+ // notification mount points, full-screen ad scrims, etc.)+ // win the heuristic β they're fixed/absolute, z β₯ 100, cover+ // >25% of viewport β but contain no interactive content.+ // Result before the fix: smart-pick scoped to the wrong+ // root and missed the actual modal entirely (WAGO bug:+ // pickStrategy ended up "visible-text-largest-no-modal-match"+ // and clicked "Add to shopping cart" in the page behind+ // the real Generate-Datasheet modal).+ function isPlausibleModal(el) {+ if (!el) return false;+ const interactives = el.querySelectorAll(+ 'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex], [onclick]'+ );+ for (const child of interactives) {+ const cs = getComputedStyle(child);+ if (cs.display === 'none' || cs.visibility === 'hidden') continue;+ if (parseFloat(cs.opacity || '1') === 0) continue;+ const r = child.getBoundingClientRect();+ if (r.width === 0 || r.height === 0) continue;+ const text = (child.textContent || '').trim();+ if (text.length > 0) return true;+ }+ return false;+ }++ // Find the topmost modal/dialog root if one is open.+ // Order matches what a human visually sees:+ // 1. <dialog open> (HTML5 native)+ // 2. [aria-modal="true"] (ARIA)+ // 3. role="dialog" + visible (ARIA fallback)+ // 4. fixed/absolute element with z-index β₯ 100+ // AND covering β₯25% of viewport (custom modals)+ //+ // v1.4.10: every tier is now post-filtered through+ // isPlausibleModal so we don't latch onto empty overlays.+ // Each tier returns the most-recently-added (last in+ // document order) plausible candidate, walking backward+ // through matches if the topmost is implausible.+ function topmostModalRoot() {+ const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));+ for (let i = openDialogs.length - 1; i >= 0; i--) {+ if (isPlausibleModal(openDialogs[i])) return openDialogs[i];+ }+ const ariaModals = Array.from(document.querySelectorAll('[aria-modal="true"]'))+ .filter(el => {+ const cs = getComputedStyle(el);+ return cs.display !== 'none' && cs.visibility !== 'hidden';+ });+ for (let i = ariaModals.length - 1; i >= 0; i--) {+ if (isPlausibleModal(ariaModals[i])) return ariaModals[i];+ }+ const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))+ .filter(el => {+ const cs = getComputedStyle(el);+ if (cs.display === 'none' || cs.visibility === 'hidden') return false;+ const r = el.getBoundingClientRect();+ return r.width > 0 && r.height > 0;+ });+ for (let i = dialogs.length - 1; i >= 0; i--) {+ if (isPlausibleModal(dialogs[i])) return dialogs[i];+ }+ // Custom modals (no ARIA, just a styled overlay)+ const candidates = Array.from(document.querySelectorAll('*'))+ .filter(el => {+ const cs = getComputedStyle(el);+ if (!['fixed', 'absolute'].includes(cs.position)) return false;+ const z = parseInt(cs.zIndex || '0', 10);+ if (isNaN(z) || z < 100) return false;+ const r = el.getBoundingClientRect();+ const viewportArea = window.innerWidth * window.innerHeight;+ if (viewportArea === 0) return false;+ return (r.width * r.height) / viewportArea > 0.25;+ })+ .sort((a, b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));+ for (const c of candidates) {+ if (isPlausibleModal(c)) return c;+ }+ return null;+ }+ const modalRoot = topmostModalRoot();+ const modalRootInfo = modalRoot ? {+ tag: modalRoot.tagName.toLowerCase(),+ id: modalRoot.id || null,+ cls: ((modalRoot.className && typeof modalRoot.className === 'string')+ ? modalRoot.className+ : ((modalRoot.className && modalRoot.className.baseVal) || '')).slice(0, 80),+ z: parseInt(getComputedStyle(modalRoot).zIndex || '0', 10),+ role: modalRoot.getAttribute('role') || null,+ ariaModal: modalRoot.getAttribute('aria-modal') || null,+ } : null;++ const els = Array.from(document.querySelectorAll(selector));+ const scored = els.map((el, idx) => {+ const r = el.getBoundingClientRect();+ const cs = getComputedStyle(el);+ const visible =+ cs.display !== 'none' &&+ cs.visibility !== 'hidden' &&+ parseFloat(cs.opacity || '1') > 0 &&+ r.width > 0 && r.height > 0 &&+ r.bottom > 0 && r.right > 0 &&+ r.top < (window.innerHeight || 99999) &&+ r.left < (window.innerWidth || 99999);+ const text = (el.textContent || '').trim();+ const area = r.width * r.height;+ const insideModal = modalRoot != null && modalRoot.contains(el);+ return {+ idx,+ x: Math.round(r.x), y: Math.round(r.y),+ w: Math.round(r.width), h: Math.round(r.height),+ text: text.slice(0, 80),+ hasText: text.length > 0,+ visible,+ area,+ insideModal,+ modalDetected: modalRoot != null,+ };+ });+ return { scored, modalRoot: modalRootInfo };+ }, args.selector);+ const scored = evalResult.scored;+ modalRootInfo = evalResult.modalRoot;++ // Pick priority:+ // 1. inside-modal AND visible AND has-text β largest area+ // 2. visible AND has-text β largest area+ // 3. visible (any) β largest area+ // 4. document-order first match (last resort)+ const modalDetected = scored.length > 0 && scored[0].modalDetected;+ const inModal = scored.filter(s => s.insideModal && s.visible && s.hasText);+ const visibleTexted = scored.filter(s => s.visible && s.hasText);+ const visibleOnly = scored.filter(s => s.visible);+ let pick = null;+ let strategy = 'visible-text-largest';+ if (inModal.length > 0) {+ pick = inModal.reduce((best, cur) => cur.area > best.area ? cur : best, inModal[0]);+ strategy = 'modal-scoped-largest';+ } else if (visibleTexted.length > 0) {+ pick = visibleTexted.reduce((best, cur) => cur.area > best.area ? cur : best, visibleTexted[0]);+ strategy = modalDetected ? 'visible-text-largest-no-modal-match' : 'visible-text-largest';+ } else if (visibleOnly.length > 0) {+ pick = visibleOnly.reduce((best, cur) => cur.area > best.area ? cur : best, visibleOnly[0]);+ strategy = 'visible-largest';+ }+ if (pick) {+ chosenIndex = pick.idx;+ chosenHandle = matches[chosenIndex];+ chosenMeta = pick;+ chosenMeta._strategy = strategy;+ } else {+ // Nothing visible at all β keep document-order first as last resort+ // and surface the count so caller knows ambiguity exists.+ chosenMeta = scored[0];+ if (chosenMeta) chosenMeta._strategy = 'fallback-first-document-order';+ }+ }++ // v2.0.321 (#72): scroll into view, resolve the rect, then click via the ISOLATED+ // timeout-bounded CDP path (rawTrustedClick) instead of chosenHandle.click(), which+ // routes through puppeteer's wedge-prone mouse lock. isTrusted=true either way.+ try { await chosenHandle.scrollIntoView(); } catch (e) {}+ let _box = null; try { _box = await chosenHandle.boundingBox(); } catch (e) {}+ if (!_box || _box.width < 1 || _box.height < 1) { sendJSON(res, { success: false, error: 'chosen element has no visible box to click', selector: args.selector }); return; }+ await rawTrustedClick(tab, Math.round(_box.x + _box.width / 2), Math.round(_box.y + _box.height / 2), { button: args.button || 'left', clickCount: args.clickCount || 1 });+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true,+ sessionId: resolvedId,+ tabId,+ dispatched: 'click',+ via: 'selector',+ selector: args.selector,+ matchedCount: matches.length,+ chosenIndex,+ clickedRect: chosenMeta ? { x: chosenMeta.x, y: chosenMeta.y, w: chosenMeta.w, h: chosenMeta.h } : null,+ clickedText: chosenMeta ? chosenMeta.text : null,+ insideModal: chosenMeta && typeof chosenMeta.insideModal === 'boolean' ? chosenMeta.insideModal : null,+ modalDetected: chosenMeta && typeof chosenMeta.modalDetected === 'boolean' ? chosenMeta.modalDetected : null,+ // v1.4.10: surface which DOM element won the modal-root+ // detection so callers can debug false-positives. When the+ // smart pick lands on the wrong element, this tells you+ // immediately whether modal detection latched onto the+ // intended overlay or got fooled by a toast-container etc.+ modalRoot: modalRootInfo,+ pickStrategy: matches.length === 1+ ? 'only-match'+ : (args.firstMatch === true+ ? 'first-match'+ : (chosenMeta && chosenMeta._strategy ? chosenMeta._strategy : 'visible-text-largest')),+ })});+ return;+ }+ if (typeof args.x !== 'number' || typeof args.y !== 'number') {+ sendJSON(res, { success: false, error: 'pup_input_dispatch click: provide either {selector} OR {x, y}' });+ return;+ }+ await rawTrustedClick(tab, args.x, args.y, { button: args.button || 'left', clickCount: args.clickCount || 1 });+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });+ return;+ }+ if (t === 'move') {+ if (typeof args.x !== 'number' || typeof args.y !== 'number') {+ sendJSON(res, { success: false, error: 'pup_input_dispatch move requires {x, y}' });+ return;+ }+ // v1.9.198 (Bills thread): a CDP move returned ok but the DRAWN cursor never budged β+ // only clicks moved it, which undercuts the whole "gliding cursor" idea. Move the+ // visible cursor with the real pointer (no click ripple).+ if (args.showCursor !== false) { try { await showPupCursor(tab.page, Number(args.x), Number(args.y), { click: false, label: args.intent || 'Moving' }); } catch (e) {} }+ await rawTrustedMove(tab, args.x, args.y);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });+ return;+ }+ if (t === 'type') {+ if (typeof args.text !== 'string') {+ sendJSON(res, { success: false, error: 'pup_input_dispatch type requires {text}' });+ return;+ }+ // Optionally focus a selector first so keystrokes land in the right input.+ if (args.selector) {+ try { await tab.page.focus(args.selector); }+ catch (e) {+ sendJSON(res, { success: false, error: `Failed to focus ${args.selector}: ${e.message}` });+ return;+ }+ }+ await withTimeout(tab.page.keyboard.type(args.text, { delay: args.delay || 0 }), 15000, 'input type');+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });+ return;+ }+ if (t === 'key') {+ if (typeof args.key !== 'string') {+ sendJSON(res, { success: false, error: 'pup_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });+ return;+ }+ await withTimeout(tab.page.keyboard.press(args.key, { delay: args.delay || 0 }), 8000, 'input key');+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });+ return;+ }+ sendJSON(res, { success: false, error: `pup_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: e && e.wedge ? 'input_channel_reset' : undefined, error: `pup_input_dispatch ${t} failed: ${e.message}`, _hint: e && e.wedge ? 'The input channel was reset after a hung page handler; the session is NOT wedged β retry the dispatch. If it keeps hanging, the page handler itself (often an unfocused-window clipboard write) is the cause.' : undefined });+ }+ return;+ }+ case 'pup_fetch_url': {+ // Fetch an arbitrary URL with the session's cookies and return the+ // raw bytes (base64 in JSON; CLI optionally writes to disk).+ //+ // Why this exists: when a vendor click spawns a popup tab whose+ // Content-Type is application/pdf, Chrome wraps it in the built-in+ // PDF viewer. In-page `fetch(location.href)` then returns the+ // viewer's HTML wrapper (~200 KB stub), NOT the PDF binary.+ // Bypass: re-issue the request via puppeteer's+ // BrowserContext.request β same cookies, no rendering layer, raw+ // response body. Works for any content type; particularly useful+ // for PDFs, ZIPs, CAD bundles, anything the page would otherwise+ // open in a viewer plugin.+ //+ // saveTo semantics (clarified in v1.4.9 β v1.4.8 was ambiguous):+ // - This is the BRIDGE side (Windows desktop). The bridge ALWAYS+ // returns bodyBase64. If the user wants the file on the Docker+ // side (the common case), the CLI handles the write β see+ // cli/src/commands.rs::pup_fetch_url. The arg name there+ // is `saveTo` and it's interpreted as a Docker-side path.+ // - For the rare case of writing directly on the DESKTOP+ // filesystem (e.g. dropping a CAD bundle into the user's+ // Downloads folder so they can open it locally), the CLI+ // forwards `desktopSaveTo` here, which the bridge writes via+ // fs.writeFileSync. Returns `desktopSavedTo` field so it's+ // unambiguous which filesystem the path refers to.+ //+ // Args (bridge-facing):+ // sessionId (required)+ // url (required)+ // method (optional, default GET)+ // headers (optional, object β Cookie auto-included from session)+ // body (optional, raw string body for POST etc.)+ // desktopSaveTo (optional Windows path β write on desktop and+ // return desktopSavedTo. Always also returns+ // bodyBase64 so caller can verify or save+ // container-side too.)+ // tabId (optional β picks the cookie context; defaults+ // to active tab. Cookies are per-browser-context+ // so any tab in the session works.)+ if (!args.url) {+ sendJSON(res, { success: false, error: 'pup_fetch_url requires {url}' });+ return;+ }+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }++ // Helper: optionally write to the desktop filesystem; ALWAYS return+ // bodyBase64 so the CLI can either pass it through to the caller OR+ // base64-decode it and write to a Docker-side path.+ const respondWithBytes = (buf, ct, status) => {+ const out = {+ ok: true,+ bytes: buf.length,+ contentType: ct,+ status,+ sessionId: resolvedId,+ tabId,+ bodyBase64: buf.toString('base64'),+ };+ if (args.desktopSaveTo) {+ try {+ // Resolve the path explicitly so callers can see what was used.+ // Don't silently land at a quirky CWD-relative path like the+ // v1.4.8 saveTo bug did.+ const abs = path.resolve(args.desktopSaveTo);+ const dir = path.dirname(abs);+ fs.mkdirSync(dir, { recursive: true });+ fs.writeFileSync(abs, buf);+ out.desktopSavedTo = abs;+ } catch (e) {+ // Don't lose the bytes β return them in bodyBase64 plus an+ // explicit error so the CLI knows the desktop write failed+ // but the bytes are still recoverable container-side.+ out.desktopSaveError = `desktopSaveTo write to ${args.desktopSaveTo} failed: ${e.message}`;+ out.desktopSavedTo = null;+ }+ }+ sendJSON(res, { success: true, output: JSON.stringify(out) });+ };++ try {+ const browserCtx = tab.page.browserContext();+ const reqClient = browserCtx.request;+ if (!reqClient || typeof reqClient.fetch !== 'function') {+ // Fallback for older puppeteer: do an in-page fetch in a+ // separate clean tab (avoids PDF viewer interception by+ // navigating to about:blank first, then fetching).+ const cleanPage = await browserCtx.newPage();+ try {+ await cleanPage.goto('about:blank');+ const result = await cleanPage.evaluate(async (u, m, h, b) => {+ const init = { method: m, credentials: 'include', headers: h || {} };+ if (b != null) init.body = b;+ const r = await fetch(u, init);+ const ab = await r.arrayBuffer();+ const u8 = new Uint8Array(ab);+ // Convert in chunks β avoids stack overflow on large blobs+ let bin = '';+ const CHUNK = 32768;+ for (let i = 0; i < u8.length; i += CHUNK) {+ bin += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));+ }+ return {+ base64: btoa(bin),+ size: u8.length,+ contentType: r.headers.get('content-type') || '',+ status: r.status,+ };+ }, args.url, args.method || 'GET', args.headers || null, args.body || null);+ await cleanPage.close({ runBeforeUnload: false });+ respondWithBytes(Buffer.from(result.base64, 'base64'), result.contentType, result.status);+ return;+ } catch (e) {+ try { await cleanPage.close({ runBeforeUnload: false }); } catch {}+ throw e;+ }+ }++ // Modern puppeteer: BrowserContext.request.fetch β bypasses page+ // rendering entirely. Cookies from the context are auto-included.+ const init = {+ method: args.method || 'GET',+ headers: args.headers || {},+ };+ if (args.body != null) init.data = args.body;+ const resp = await reqClient.fetch(args.url, init);+ const buf = await resp.body();+ const ct = (resp.headers && resp.headers()['content-type']) || '';+ respondWithBytes(buf, ct, resp.status());+ } catch (e) {+ sendJSON(res, { success: false, error: `pup_fetch_url failed: ${e.message}`, _hint: 'If this URL requires the popup tab\'s session cookies, make sure that tab is in the same session and pass its tabId here. The bridge always returns bodyBase64; the CLI handles container-side writes via the saveTo arg.' });+ }+ return;+ }+ // v2.0.142 (John: "you need to download via the browser just like a user does β pup is about+ // the browser"). A BROWSER-NATIVE download: pup drives Chrome to fetch the file exactly as a+ // user clicking the button would β real session, real cookies, real anti-bot cookies, real+ // Content-Disposition β and Chrome's own download manager writes it to the Downloads folder.+ // NO bridge-side fetch/curl (that trips every vendor's anti-bot wall; if a caller wants curl+ // it does that outside pup). Completion is confirmed via CDP Browser.downloadProgress, so the+ // response carries the ACTUAL saved path + byte count, not a guess.+ // selector : click THIS real download control (the authentic user action). OR+ // url : synthesize an <a href=url download> in the page and real-click it (forces the+ // download path even for a URL Chrome would otherwise render inline, e.g. a PDF).+ case 'pup_download': {+ if (!args.selector && !args.url) { sendJSON(res, { success: false, error: 'pup_download needs {selector} (a real download button to click) OR {url} (downloaded via an in-page <a download> click). Optional saveAs.' }); return; }+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ const dir = args.saveDir || (session._downloadPath) || path.join(os.homedir(), 'Downloads');+ let cdp = null;+ try {+ cdp = await tab.page.target().createCDPSession();+ // allow downloads to `dir` AND emit Browser.download* events so we can await completion.+ try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir, eventsEnabled: true });+ let willBegin = null;+ const done = new Promise((resolve, reject) => {+ const to = setTimeout(() => reject(new Error('download did not complete within 90s')), 90000);+ cdp.on('Browser.downloadWillBegin', (e) => { willBegin = e; });+ cdp.on('Browser.downloadProgress', (e) => {+ if (e.state === 'completed') { clearTimeout(to); resolve(e); }+ else if (e.state === 'canceled') { clearTimeout(to); reject(new Error('download was canceled by the page/Chrome')); }+ });+ });+ // Trigger the download the way a user does.+ if (args.selector) {+ await withTimeout(tab.page.click(args.selector, {}), 12000, 'download.click').catch(async () => {+ // fall back to a scroll-into-view + coordinate click if the plain click missed+ const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }).catch(() => null);+ if (box) await tab.page.mouse.click(box.x, box.y);+ });+ } else {+ await tab.page.evaluate((u, nm) => {+ const a = document.createElement('a'); a.href = u; if (nm) a.download = nm; else a.setAttribute('download', '');+ a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);+ }, args.url, args.saveAs || '');+ }+ const ev = await done;+ // Resolve the real file: allow-mode keeps the suggested name; the guid file is renamed on complete.+ let fname = (willBegin && willBegin.suggestedFilename) || (args.saveAs || '') || ('download-' + (ev.guid || 'file'));+ let abs = path.join(dir, fname);+ // Verify on disk; if the suggested name isn't there yet, find the newest non-.crdownload file.+ let sizeOnDisk = null;+ try { sizeOnDisk = fs.statSync(abs).size; } catch (e) {+ try {+ const recent = fs.readdirSync(dir).filter(f => !f.endsWith('.crdownload'))+ .map(f => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs }))+ .sort((a, b) => b.m - a.m)[0];+ if (recent) { abs = path.join(dir, recent.f); fname = recent.f; sizeOnDisk = fs.statSync(abs).size; }+ } catch (e2) {}+ }+ const magic = (() => { try { return fs.readFileSync(abs).slice(0, 5).toString('latin1').replace(/[^\x20-\x7e]/g, '.'); } catch (e) { return null; } })();+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, savedTo: abs, filename: fname, bytes: (ev.totalReceivedBytes || ev.receivedBytes || sizeOnDisk), sizeOnDisk, magic,+ triggeredBy: args.selector ? ('click ' + args.selector) : ('a[download] ' + args.url),+ sessionId: resolvedId, tabId,+ _hint: `Chrome downloaded this to ${abs} (${sizeOnDisk != null ? sizeOnDisk + ' bytes' : 'size unread'}${magic ? ', starts "' + magic + '"' : ''}) β the real browser download, session cookies and all. To bring it into the container, pull it with Bridge file verbs (desktop_pull_file).`,+ }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'download_failed', error: `pup_download failed: ${e.message}`, _hint: args.selector ? `The click may not have started a download. Verify the selector is the real download control (pup_screenshot to see it), and that this window is logged in if the file is gated.` : 'The URL did not download β for a gated file, sign in on this window first (pup_login).' });+ } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }+ return;+ }+ case 'pup_errors': {+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ const errs = [...tab.errors];+ if (args.clear !== false) tab.errors.length = 0;+ sendJSON(res, { success: true, output: JSON.stringify({ errors: errs, count: errs.length, sessionId: resolvedId, tabId }) });+ return;+ }+ case 'pup_progress': {+ // v2.0.193 (John: "if a thread is crawling a site through login -> component pages ->+ // datasheets -> step files, it should paint the taskbar progress bar; a simple page open+ // shouldn't β the AI decides, but pup has to TEACH it"). Caller-facing progress control.+ // pup_progress {mode:"indeterminate", note:"crawling ti.com"} β pulsing bar+ // pup_progress {mode:"value", value:35, note:"12/40 datasheets"} β determinate+ // pup_progress {mode:"off"} β clear when done+ const _pm = String(args.mode || (typeof args.value === 'number' ? 'value' : 'indeterminate'));+ if (!['indeterminate', 'value', 'off'].includes(_pm)) { sendJSON(res, { success: false, error: 'mode must be indeterminate|value|off' }); return; }+ const _pv = Math.max(0, Math.min(100, Number(args.value) || 0));+ const _note = (typeof args.note === 'string' && args.note.trim()) ? args.note.trim().slice(0, 120) : null;+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }+ if (!session) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }+ session._explicitProgress = _pm !== 'off';+ if (_pm !== 'off') { if (!session._progressSince) { session._progressSince = Date.now(); session._progressCount = (session._progressCount || 0) + 1; } }+ else if (session._progressSince) { session._progressMs = (session._progressMs || 0) + (Date.now() - session._progressSince); session._progressSince = null; }+ // v2.0.244 (pup-bridge#54, same root cause as #58): a caller asking for a progress bar is+ // an EXPLICIT request, not pup's automatic machinery, so minimal-touch must not silently+ // eat it. That gate is why "pup_progress paints nothing while a direct desktop_taskbar by+ // hwnd paints" β the verb never made the call at all, and still answered ok:true. It now+ // paints regardless, and the response says plainly when the desktop cannot show it.+ let _progressPainted = false, _progressSkip = null;+ if (process.platform !== 'win32') _progressSkip = 'this desktop is not Windows β taskbar progress bars are a Windows shell feature';+ if (process.platform === 'win32') {+ try {+ // v2.0.195: resolve the OS window handle FIRST (same as the identity stamp) β after a+ // bridge respawn the reloaded session has no hwnd yet, and windowTarget's title+ // fallback is dead with titleTag off, so the progress paint silently hit nothing+ // (measured: direct-by-hwnd painted the Win11 underline pill; the verb path did not).+ try { if (!session._hwnd) await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {}+ // v2.0.194: progress + tooltip in ONE desktop_taskbar call β a follow-up call that+ // omitted the progress field was clearing the bar the first call had just set.+ await chrome.abCommand('desktop_taskbar', {+ ...(await windowTarget(session, resolvedId)),+ progress: _pm === 'off' ? { state: 'none' } : _pm === 'value' ? { state: 'normal', value: _pv } : { state: 'indeterminate' },+ ...(_note ? { thumbnailTooltip: `${session.owner || resolvedId} β ${_note}` } : {}),+ }, { timeoutMs: 4000 });+ try { session._lastPaintSig = null; session._appOverlay = false; session._overlayEnforceAt = 0; } catch (e) {}+ _progressPainted = true;+ } catch (e) { _progressSkip = `the shell call failed: ${e && e.message}`; }+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, mode: _pm, painted: _progressPainted, ...(_progressSkip ? { notPainted: _progressSkip } : {}), ...(_pm === 'value' ? { value: _pv } : {}), ...(_note ? { note: _note } : {}),+ _hint: _progressSkip ? `Progress state recorded, but nothing was drawn: ${_progressSkip}.`+ : _pm === 'off'+ ? 'Progress bar cleared. Good hygiene: always clear when the multi-stage task finishes or aborts.'+ : `Taskbar progress ${_pm === 'value' ? `at ${_pv}%` : 'pulsing (indeterminate)'} on "${resolvedId}"${_note ? ` β hover tooltip says "${_note}"` : ''}. Update value/note as stages advance; pup_progress {mode:"off"} when done. The automatic activity strip stays out of your way while this is set.` }) });+ return;+ }+ case 'pup_reload': {+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }+ catch (e) { sendTabResolveError(res, e); return; }+ tab.errors.length = 0;+ // v1.9.176: a CRASHED renderer can't be page.reload()'d (detached frame) β respawn it via+ // raw CDP instead. This is the recovery the crash hint promised but page.reload() couldn't do.+ if (tab._crashed) {+ const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (e) { return null; } })() || args.url;+ const ok = await respawnCrashedRenderer(tab.page, rurl);+ if (ok) {+ tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+ console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via respawn`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });+ } else {+ console.log(`[crash] session "${resolvedId}" tab ${tabId} respawn did NOT take β advising close+reopen`);+ sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn did not recover it (Chrome may be too memory-pressured to spawn a fresh renderer). Close and reopen the window: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${rurl || ''}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });+ }+ return;+ }+ // v1.9.109: keep the reload's own HTTP status β page.reload() returns the response, and this+ // is the verb an AI uses right after "I fixed it", so a still-broken page must be caught here.+ let _rlResp;+ try {+ _rlResp = await withTimeout(+ tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }),+ 30_000, 'page.reload'+ );+ } catch (e) {+ // The renderer may have crashed between resolve and reload (detached frame) β fall back to respawn.+ if (_isCrashedFrameError(e)) {+ const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (x) { return null; } })() || args.url;+ const ok = await respawnCrashedRenderer(tab.page, rurl);+ tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+ console.log(`[crash] session "${resolvedId}" tab ${tabId} reload hit a crashed frame β recovered via respawn (${ok ? 'ok' : 'sent'})`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });+ return;+ }+ throw e;+ }+ try { tab.page.__pupHttpStatus = _rlResp && typeof _rlResp.status === 'function' ? _rlResp.status() : null; } catch (e) {}+ // v1.8.26: the user is signalled by the auto-flash, NOT by raising the window.+ // Only raise if this session is one the user is actively watching.+ if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }+ // v1.9.109 β PROBE ON RELOAD TOO. This was the hole: only pup_open_window probed, so a+ // thread that hit a 404, fixed its dev server, called pup_reload and got ok:true with the+ // GENERIC "do not assume it rendered" hint concluded "Mockup is now live again" β reporting+ // success on a page nobody had checked. The verb used to confirm a fix is exactly the verb+ // that must verify it.+ try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}+ try { await autoLoginOnLoad(res, tab.page); } catch (e) {}+ try {+ const _rc = res._renderCheck;+ if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" STILL BROKEN after reload: ${_rc.signals.join(', ')}`);+ } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) });+ return;+ }+ case 'pup_navigate': {+ {+ // v1.9.195: same pre-flight as open β never navigate a window to a guaranteed-broken+ // file:// URL (container path, or missing on the desktop) and leave the caller guessing.+ const _pre = urlLint(args.url, res && res._callerContainer);+ if (_pre && (_pre.code === 'url_file_container_path' || _pre.code === 'url_file_not_found')) {+ console.log(`[urlLint] REFUSED navigate to "${args.url}" β ${_pre.code}`);+ sendJSON(res, { success: false, errorCode: _pre.code, error: _pre.message, _urlLint: _pre.message, urlLintCode: _pre.code });+ return;+ }+ }+ let session, resolvedId;+ // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+ // any error. The Docker container should never see "Session+ // closed" / "detached Frame" β those become internal retry+ // signals. See recoverOrRelaunchSession.+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ // v1.8.16: refuse a declared cross-owner navigate (window steal).+ { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_navigate'); if (refusal) { sendJSON(res, refusal); return; } }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }+ catch (e) { sendTabResolveError(res, e); return; }+ // Apply stored basic-auth creds for the navigation target's host+ // BEFORE goto, so the Chrome native auth dialog never appears+ // for sites in the credential vault.+ try {+ const matchedHost = await credentialVault.applyCredentialsToPage(tab.page, args.url);+ if (matchedHost) console.log(`[creds] applied to navigate ${resolvedId}/${tabId}: matched ${matchedHost}`);+ } catch (e) {+ console.log(`[creds] applyCredentialsToPage on navigate failed: ${e.message}`);+ }+ // v1.9.176: navigating a CRASHED renderer via page.goto throws 'detached Frame'. When the tab+ // is flagged crashed (or goto hits a crashed frame), respawn via raw CDP Page.navigate to the+ // requested URL β that spawns a fresh renderer in place and lands on the target page.+ if (tab._crashed) {+ const ok = await respawnCrashedRenderer(tab.page, args.url);+ if (!ok) {+ console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate respawn did NOT take β advising close+reopen`);+ sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn to ${args.url} did not recover it (Chrome may be too memory-pressured). Close and reopen: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${args.url}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });+ return;+ }+ tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+ console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate recovered a crashed renderer via respawn`);+ } else {+ try {+ await withTimeout(+ tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 25000 }),+ 30_000, 'page.goto'+ );+ } catch (e) {+ if (_isCrashedFrameError(e)) {+ const ok = await respawnCrashedRenderer(tab.page, args.url);+ tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+ console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate hit a crashed frame β recovered via respawn (${ok ? 'ok' : 'sent'})`);+ } else {+ throw e;+ }+ }+ }+ tab.errors.length = 0;+ // v1.9.201: Chrome can foreground a window on its own during a navigation (local PDFs do+ // this reliably). Re-assert the park unless the user has this window up on purpose.+ if (!userIsUsingWindow(session)) setTimeout(() => { reassertBottomAfterNav(resolvedId).catch(() => {}); }, 1800);+ // Re-apply title suffix after navigation+ try {+ const pName = session.profileName;+ // When there's more than one tab, include tabId in the title suffix+ const titleSuffix = session.tabs.length > 1+ ? ` (${TITLE_LABEL}: ${resolvedId} | ${tabId})`+ : (pName !== resolvedId+ ? ` (${TITLE_LABEL}: ${resolvedId} | profile: ${pName})`+ : ` (${TITLE_LABEL}: ${resolvedId})`);+ await tab.page.evaluate((suffix, sid) => {+ try { window.name = 'pup:' + sid; } catch (e) {} // v1.9.153: durable carrier on tab retitles too+ // v2.0.24: no visible suffix, no observer β window.name is the carrier.+ const cleaned = String(document.title || '').replace(/\s*\((?:session|ai-thread):[^)]*\)/g, '').trim();+ if (cleaned !== document.title) document.title = cleaned;+ }, titleSuffix, resolvedId);+ } catch {}+ // v1.8.26: signal via auto-flash, NOT by raising the window (that stole focus+ // on every navigate). Only raise for a session the user is actively watching.+ if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }+ // Only persist URL for single-tab sessions β the session file is a+ // per-session snapshot and multi-tab URLs can't fit.+ if (session.tabs.length === 1) {+ saveSessionFile(resolvedId, session.profileName, args.url);+ }+ if (session && process.platform === 'win32') {+ setTimeout(() => { applyAppOverlay(session, resolvedId).catch(() => {}); }, 1500); // v1.8.92: new site, new favicon+ // v1.9.43: second pass β many sites inject their <link rel=icon> after first paint.+ setTimeout(() => { if (session) session._faviconRetried = false; applyAppOverlay(session, resolvedId).catch(() => {}); }, 4500);+ // v1.9.1: on an Adom nav, re-check auth (login may have completed since open) and+ // refresh the tab label so it becomes "π Logged in" once actually authenticated.+ // v1.9.103: a navigation replaces the document, so re-assert the thread tag (cheap, and+ // evaluateOnNewDocument already re-runs it at document-start; this covers the live page).+ try { if (session && session._threadTag) setTitleDecor(session.page, { threadTag: session._threadTag }).catch(() => {}); } catch (e) {}+ if (isAdomUrl(args.url)) {+ setTimeout(async () => {+ try {+ const a = await checkWikiAuth(session.page);+ // v1.9.93: an indeterminate probe must not repaint the glyph OR flip the identity.+ if (!(a && a.unknown)) {+ await setWikiModeGlyph(session.page, a && a.authed ? '\u25CF Logged in' : '\u25CB Public');+ await setWikiAuthedState(session, resolvedId, a && a.authed, { unknown: false });+ }+ } catch (e) {}+ }, 1800);+ }+ // v1.9.3: refresh the per-window wiki jump list (adds the toggle on a wiki page,+ // clears it when the window navigated off Adom).+ setTimeout(() => { updateWikiJumplist(session, resolvedId).catch(() => {}); }, 1000);+ }+ if (tab._crashed) { tab._crashed = false; delete tab._crashedAt; delete tab._crashReason; console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via navigate`); }+ // v1.9.109: probe on navigate as well β same reasoning as reload.+ try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}+ try { await autoLoginOnLoad(res, tab.page); } catch (e) {}+ try {+ const _rc = res._renderCheck;+ if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" BROKEN after navigate to ${args.url}: ${_rc.signals.join(', ')}`);+ } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: args.url, sessionId: resolvedId, tabId, _next: threadNextBlock(), ...(authSteerHint(args.url) ? { _hint: authSteerHint(args.url).trim() } : {}) }) });+ return;+ }+ case 'pup_install_toolbar': {+ // v2.0.53: nothing to install anymore β on CfT the toolbar is a launch arg. This verb+ // reports the state so agents can explain it.+ const cft = (chrome && chrome.readiness) ? chrome.readiness() : {};+ const staged = stageToolbarExtension();+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, mode: 'launch-flag', stagedAt: staged,+ driving: cft.browserKind || null,+ active: !!staged && cft.browserKind === 'chrome-for-testing',+ _hint: staged ? (cft.browserKind === 'chrome-for-testing' ? 'Toolbar auto-loads on every profile launch (CfT --load-extension).' : `Driving browser is ${cft.browserKind}; the toolbar flag only works on Chrome for Testing β pup_use {browser:"cft"} to enable.`) : 'toolbarExtension is off in settings (or non-Windows).',+ }) });+ return;+ }+ case 'pup_floaty_click': {+ // v2.0.69 (John: "tool the clicking so we can ralph this weekly forever"): first-class+ // simulated click on the floaty pencil. Fires the SAME host click handler a mouse does+ // (host.click() -> onclick -> flag -> sweep harvest -> annotate), so the ralph exercises+ // the full production chain without CDP input-simulation flakiness. Honest errors: floaty+ // absent means the setting is off or the page never got the injection.+ const sessionId = args.sessionId;+ const _lv = requireLiveSession(res, { ...args, sessionId });+ if (!_lv) return;+ const session = _lv.session;+ const tab = (args.tabId && (session.tabs || []).find(t => t.tabId === args.tabId)) || (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];+ if (!tab || !tab.page) { sendJSON(res, { success: false, error: 'no tab' }); return; }+ try {+ const r = await tab.page.evaluate(() => {+ const h = document.getElementById('__pup_floaty');+ if (!h) return { clicked: false, reason: 'floaty not present (tbFloatyPencil off, or page not injected)' };+ h.click();+ window.__pupFloatyClick = null; // consumed here: the verb arms the watcher itself+ return { clicked: true };+ });+ if (r.clicked) { session._annotateArmed = Date.now(); startAnnotation(session, sessionId, 'user-floaty', { tool: 'pen', tabId: tab.tabId }).catch(() => {}); }+ sendJSON(res, { success: !!r.clicked, output: JSON.stringify(r), ...(r.clicked ? {} : { error: r.reason }) });+ } catch (e) { sendJSON(res, { success: false, error: e.message }); }+ return;+ }+ case 'pup_annotate': {+ // start/cancel the human annotation overlay on a window's active tab. Jump-list clicks+ // arrive as user-taskbar-menu (ownership-gate exempt); threads annotate their OWN windows.+ const sessionId = args.sessionId;+ const _lv = requireLiveSession(res, { ...args, sessionId });+ if (!_lv) return;+ const session = _lv.session;+ try {+ if (args.action === 'cancel') {+ const tab0 = (session.tabs || [])[0];+ if (tab0 && tab0.page) await tab0.page.evaluate(() => { const w = document.getElementById('__pup_annotate'); if (w) w.remove(); window.__pupAnnotate = null; });+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cancelled: true }) });+ return;+ }+ await startAnnotation(session, sessionId, _callerThread, { tool: args.tool === 'snip' ? 'snip' : 'pen', mode: args.mode, tabId: args._resolvedTabId || null });+ // v2.0.293 (John: "you just foregrounded a pup window and never showed an ab caption"):+ // the old unconditional raise claimed a jump-list click is user-sanctioned by+ // construction β TRUE only for USER callers (jump list / toolbar). An AI THREAD calling+ // pup_annotate got the same screen-steal, and a scripted annotate workflow re-raised+ // the window on every arm. Now: USER callers get the raise (they clicked, they are+ // waiting for the window); AI callers arm the overlay in the BACKGROUND and must+ // foreground explicitly via pup_raise_os_window + foregroundReason (the reason-gated,+ // captioned path) if the user should draw now.+ const _userCaller = _callerThread === 'user-taskbar-menu' || _callerThread === 'user-toolbar';+ if (_userCaller) {+ session._foreground = true; session._userActiveAt = Date.now();+ try { const t0 = (session.tabs || [])[0]; if (t0 && t0.page) await t0.page.bringToFront(); } catch (e) {}+ try { await osRaiseSessionWindow(session, sessionId, 'you clicked Annotate: draw on the page, then hit Done'); } catch (e) {}+ }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, started: true, raised: _userCaller, _hint: _userCaller ? 'Overlay is live: pen/arrow/box/text, color dot cycles, Done captures (drawing stays visible so the owning AI thread\'s next screenshot shows it), \u2716 cancels.' : 'Overlay is armed but the window stays in the BACKGROUND (AI-called annotate never steals the screen). If the user should draw NOW, call pup_raise_os_window with a foregroundReason: that captions the reason first, then foregrounds.' }) });+ } catch (e) { sendJSON(res, { success: false, error: `annotate failed: ${e.message}` }); }+ return;+ }+ case 'pup_dashboard': {+ // v1.9.231 (John): the dashboard ALWAYS gets its OWN pup window, never a tab in someone+ // else's, so it carries its own taskbar icon (its served icon.svg) and can be found and+ // closed independently. Fixed sessionId + its own durable profile, so repeat calls REUSE+ // the one window instead of spawning duplicates. Opens in the background like any pup+ // window: it is a monitor, and monitors must not steal the screen.+ const dashUrl = `http://127.0.0.1:${PORT}/dashboard`;+ // v2.0.178 (John: "when I click the jump list to open the dashboard, open it FOREGROUND β+ // it was a human action, not an AI action"). Exactly right: the background-always rule+ // exists so AIs never steal the screen, but a jump-list / toolbar click IS the user asking+ // to see it β backgrounding it just makes the click feel broken. USER_CALLERS is the+ // human-surface marker (taskbar menu, toolbar, pill), so those callers get the window+ // raised; AI callers keep the background default + raise-with-reason path.+ const _humanClick = USER_CALLERS.has(_callerThread);+ const _humanReason = 'you clicked "Open pup dashboard" in the taskbar menu';+ try {+ const existing = sessions.get('pup-dashboard');+ if (existing) {+ try { await existing.page.goto(dashUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch (e) {}+ if (_humanClick) { try { await osRaiseSessionWindow(existing, 'pup-dashboard', _humanReason); } catch (e) {} }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: true, foregrounded: _humanClick,+ _hint: _humanClick+ ? `The pup dashboard was already open β refreshed and RAISED to the foreground (a human clicked the jump-list task, so no background etiquette applies).`+ : `The pup dashboard is already open in its own window (session "pup-dashboard") and was refreshed. It is BACKGROUNDED like any pup window; raise it with pup_raise_os_window + a foregroundReason if the user asked to see it.` }) });+ return;+ }+ const _dashSess = await launchSession('pup-dashboard', dashUrl, { profile: 'pup-dashboard', owner: 'pup-dashboard', foreground: _humanClick });+ if (_humanClick) { try { await osRaiseSessionWindow(_dashSess || sessions.get('pup-dashboard'), 'pup-dashboard', _humanReason); } catch (e) {} }+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: false, foregrounded: _humanClick,+ _hint: `Opened the live pup dashboard in its OWN window (session "pup-dashboard", its own profile so it has its own taskbar icon). It shows every window pup is driving, grouped taskbar button -> window -> tabs, with each tab's favicon, the owning AI thread, foreground/background plus the reason given to foreground, highFps lease, uptime, and how each overlay icon was chosen. Updates live over SSE. ${_humanClick ? 'FOREGROUNDED (human click on a user surface).' : 'Backgrounded by default.'}` }) });+ } catch (e) {+ sendJSON(res, { success: false, errorCode: 'dashboard_open_failed', error: `could not open the dashboard: ${e.message}` });+ }+ return;+ }+ case 'pup_deps': {+ // v1.9.218 (issue #15): report dependency health so "pup is broken" can be answered without+ // reading the spawn log. selfInstalled:true means the bridge repaired itself this boot.+ let resolved = false; try { require.resolve('puppeteer'); resolved = true; } catch (e) {}+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: resolved, depsPresent: resolved,+ selfInstalledThisBoot: !!(_depState && _depState.installed),+ lastError: (_depState && _depState.error) || null,+ dir: __dirname,+ _hint: resolved+ ? (_depState && _depState.installed+ ? 'Dependencies were MISSING at boot and pup self-installed them (wiki issue #15: the source-only zip ships no node_modules and AD\'s install did not run, or a respawn wiped it). No human npm install is needed.'+ : 'Dependencies present. Normal state.')+ : 'Dependencies MISSING and self-install failed β pup cannot drive a browser. Ensure node + npm are on PATH for the AD process, then bridge_kill + bridge_resume to retry the self-install.',+ }) });+ return;+ }+ case 'pup_readiness': {+ // READ-ONLY readiness probe. NEVER triggers a download. `ready` reflects both+ // server-up (this verb answered) AND Chrome-for-Testing cached. For the general+ // "what do I have / is it ready" question prefer AD's `bridge_readiness` (it also+ // reports AD-core's Tier-1 prewarm progress); this verb is pup's own detailed view.+ const rd = chrome.readiness();+ const availTxt = (rd.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+ const defTxt = rd.defaultBrowser+ ? ` Default: ${rd.defaultBrowser.kind}${rd.defaultBrowser.forced ? ' (pinned)' : ' (auto)'}${rd.defaultBrowser.pendingInstall ? ', downloading' : ''}.`+ : ' Default: auto (first browser that launches).';+ const switchTxt = ' Switch/install with pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.';+ // v2.0.383 (#59/#64): the fast progress verb. active[] = one row per in-flight phase (this is+ // what animates a bar); progress = the download's own frame while it runs.+ const _act = activeProgress();+ const _dl = rd.installing ? { phase: 'download', stepLabel: 'Downloading Chrome for Testing', percent: rd.installProgressPct || 0, elapsedSec: rd.downloadElapsedSec, estimatedSec: (rd.downloadElapsedSec != null && rd.downloadEtaSec != null) ? rd.downloadElapsedSec + rd.downloadEtaSec : null, etaSec: rd.downloadEtaSec, confidence: rd.downloadEtaSec != null ? 'measured' : 'estimated', startKind: 'cold' } : undefined;+ sendJSON(res, {+ success: true,+ output: JSON.stringify({ ...rd, active: _act, progress: _dl }),+ ...rd,+ active: _act,+ progress: _dl,+ _hint: rd.ready+ ? `Ready β pup will drive ${rd.browserKind} (${rd.browserSource}) at ${rd.browserExecutablePath}. Available: ${availTxt}.${defTxt} No download needed. Proceed with pup_open_window.${switchTxt} RECOVERY NOTE: if you were polling this because pup's verbs were TIMING OUT (dim LED in AD / browser.bridgeRunning:false), that means the bridge PROCESS was down β a status probe like this does NOT respawn it; only a window/tab verb (pup_open_window) auto-spawns the bridge. And never SPAM pup_open_window while it says "bridge_starting" β concurrent opens on one profile used to collide (fixed v1.9.4, but still: fire ONE open, then poll pup_list_windows with backoff).`+ : rd.installing+ ? `Fetching Chrome for Testing (${rd.installProgressPct}%) β this box had no Chrome/Edge. Poll this verb (or bridge_readiness) until ready:true, then pup_open_window. Faster: have the user install Chrome or Edge (Edge ships with Windows) and pup uses it instantly.`+ : rd.installPhase === 'failed'+ ? `No Chrome/Edge on this box and the Chrome-for-Testing fallback FAILED: ${rd.lastError}. Have the user install Chrome or Edge (fastest, no download), or retry pup_prewarm (usually network/proxy blocking the download, or low disk).`+ : `No Chrome/Edge detected and no cached Chrome for Testing. Available: ${availTxt}. Have the user install Chrome or Edge (Edge ships with Windows β instant, no download), or call pup_use {browser:"cft"} / pup_prewarm to fetch Chrome for Testing.`,+ });+ return;+ }+ case 'pup_import_scan': {+ // READ-ONLY discovery pass for the comprehensive credential import (adom-you seed).+ // Enumerates EVERY profile of EVERY Chromium browser (Chrome/Edge/Brave) on this box+ // with: source account email(s) (the identity tag β Preferences.account_info[].email),+ // saved-password count (parsed from the Login Data SQLite via the desktop's python,+ // NEVER a byte-scan β see dev-skills/pup-browser-import), Login Data size, and ABE+ // status. It DECRYPTS NOTHING β decryption needs the Bridge-core elevated SYSTEM helper+ // (adom/adom-bridge #51, desktop_decrypt_pup_credentials). This verb is the+ // foundation the first-run prompt + Settings "Import logins" picker read.+ if (process.platform !== 'win32') {+ sendJSON(res, { success: true, output: JSON.stringify({ profiles: [] }), profiles: [], _hint: 'Credential import discovery is Windows-only (Chromium ABE / DPAPI). No profiles enumerated on this platform.' });+ return;+ }+ const scanPs = [+ "$ErrorActionPreference='SilentlyContinue'",+ "$local = $env:LOCALAPPDATA",+ "$browsers = @(",+ " @{ id='chrome'; name='Google Chrome'; root=(Join-Path $local 'Google\\Chrome\\User Data') },",+ " @{ id='edge'; name='Microsoft Edge'; root=(Join-Path $local 'Microsoft\\Edge\\User Data') },",+ " @{ id='brave'; name='Brave'; root=(Join-Path $local 'BraveSoftware\\Brave-Browser\\User Data') }",+ ")",+ "$py = $null",+ "foreach($c in @('python','python3','py')){ $g = (Get-Command $c -ErrorAction SilentlyContinue); if($g){ $py=$g.Source; break } }",+ "$pyLines = @(",+ " 'import sqlite3,sys',",+ " 'c=sqlite3.connect(sys.argv[1])',",+ " 'try:',",+ " ' n=c.execute(\"select count(*) from logins where blank_password=0\").fetchone()[0]',",+ " 'except Exception:',",+ " ' n=c.execute(\"select count(*) from logins\").fetchone()[0]',",+ " 'print(n)'",+ ") -join [char]10",+ "$codeFile = $null",+ "if($py){ $codeFile = Join-Path $env:TEMP ('pup_ldcount_' + [guid]::NewGuid().ToString('N') + '.py'); Set-Content -LiteralPath $codeFile -Value $pyLines -Encoding ASCII }",+ "$out = @()",+ "foreach($b in $browsers){",+ " if(-not (Test-Path $b.root)){ continue }",+ " $abe=$false; $legacy=$false",+ " $lsPath = Join-Path $b.root 'Local State'",+ " if(Test-Path $lsPath){ try { $ls = Get-Content -Raw -LiteralPath $lsPath | ConvertFrom-Json; if($ls.os_crypt.app_bound_encrypted_key){ $abe=$true }; if($ls.os_crypt.encrypted_key){ $legacy=$true } } catch {} }",+ " $profs = @()",+ " $d = Get-Item -LiteralPath (Join-Path $b.root 'Default') -ErrorAction SilentlyContinue; if($d){ $profs += $d }",+ " $profs += (Get-ChildItem -LiteralPath $b.root -Directory -Filter 'Profile *' -ErrorAction SilentlyContinue)",+ " foreach($pf in $profs){",+ " if(-not $pf){ continue }",+ " $prefPath = Join-Path $pf.FullName 'Preferences'",+ " if(-not (Test-Path $prefPath)){ continue }",+ " $emails=@(); $pname=$pf.Name",+ " try { $pref = Get-Content -Raw -LiteralPath $prefPath | ConvertFrom-Json; if($pref.profile.name){ $pname = $pref.profile.name }; if($pref.account_info){ foreach($a in $pref.account_info){ if($a.email){ $emails += $a.email } } } } catch {}",+ " $ldPath = Join-Path $pf.FullName 'Login Data'",+ " $pwCount=$null; $ldBytes=0",+ " if(Test-Path $ldPath){",+ " $ldBytes = (Get-Item -LiteralPath $ldPath).Length",+ " if($py -and $codeFile){",+ " $tmp = Join-Path $env:TEMP ('pup_ld_' + [guid]::NewGuid().ToString('N') + '.db')",+ " try { Copy-Item -LiteralPath $ldPath -Destination $tmp -Force; $r = & $py $codeFile $tmp 2>$null; if($r){ $pwCount = [int]($r | Select-Object -First 1) } } catch {}",+ " Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue",+ " }",+ " }",+ " $out += [pscustomobject]@{ browser=$b.id; browserName=$b.name; profileDir=$pf.Name; profileName=$pname; accounts=@($emails); passwordCount=$pwCount; loginDataBytes=$ldBytes; abe=$abe; legacyKey=$legacy }",+ " }",+ "}",+ "if($codeFile){ Remove-Item -LiteralPath $codeFile -Force -ErrorAction SilentlyContinue }",+ "[pscustomobject]@{ pythonAvailable=[bool]$py; profiles=@($out) } | ConvertTo-Json -Depth 6 -Compress"+ ].join('\n');+ const scanPath = path.join(require('os').tmpdir(), '_pup_import_scan.ps1');+ let scan = { pythonAvailable: false, profiles: [] };+ try {+ fs.writeFileSync(scanPath, scanPs);+ const raw = (await runPsHiddenPromise(scanPath, { timeout: 25000 }) || '').trim();+ if (raw) scan = JSON.parse(raw);+ } catch (e) { console.log('[import-scan] failed:', e && e.message); }+ const profiles = Array.isArray(scan.profiles) ? scan.profiles : (scan.profiles ? [scan.profiles] : []);+ const totalPw = profiles.reduce((s, p) => s + (p.passwordCount || 0), 0);+ const accts = [...new Set(profiles.flatMap(p => p.accounts || []))];+ const anyAbe = profiles.some(p => p.abe);+ sendJSON(res, {+ success: true,+ output: JSON.stringify({ profiles, totalPasswords: totalPw, accounts: accts }),+ profiles,+ totalPasswords: totalPw,+ accounts: accts,+ pythonAvailable: !!scan.pythonAvailable,+ _hint: profiles.length+ ? `Found ${profiles.length} browser profile(s) across ${new Set(profiles.map(p=>p.browser)).size} browser(s), ~${totalPw} saved password(s), identities: ${accts.join(', ') || 'none tagged'}.${anyAbe ? ' These are App-Bound-Encrypted (v20) β a USER process (this bridge) canNOT decrypt them.' : ''} This verb is DISCOVERY ONLY. The actual decrypt is AD's desktop_decrypt_pup_credentials (AD >= 1.9.220): the dashboard "Import logins" button calls it LOCALLY (bridge β AD direct API), the user approves one consent dialog + one UAC prompt, and pup merges the identity-tagged rows into the adom-you vault (credential_vault.js) and drops the plaintext. ${scan.pythonAvailable ? '' : 'NOTE: python not found on PATH, so passwordCount is null β only file presence/size known. '}Next: open the Settings "Import logins from Chrome/Edge" picker, which reads this list.`+ : `No Chromium browser profiles with a Preferences file found under %LOCALAPPDATA% (Chrome/Edge/Brave). Nothing to import.`,+ });+ return;+ }+ // v2.0.143 (John: "import from my native browsers via ab's credential verb β that i don't+ // think ever worked β and get it into your stored credential system"). It DID work, but only+ // from the dashboard's "Import logins" button; there was no AI-callable verb, so a thread+ // couldn't seed pup's vault from the user's real Chrome/Edge. This verb runs the whole flow:+ // scan β call AD's SYSTEM-elevated decrypt LOCALLY (the user approves one consent + one UAC+ // on the desktop; a relayed call is refused, which is why pup β a LOCAL bridge β must be the+ // one to ask) β merge the identity-tagged rows into the keychain vault β DROP the plaintext.+ // Returns COUNTS ONLY, never a password. hostFilter narrows the merge to matching hosts (a+ // surgical import, e.g. just the vendor sites); omit to import everything the user has saved.+ case 'pup_import_browser_logins': {+ if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'Browser-login import is Windows-only (Chromium ABE/DPAPI).' }); return; }+ // 1) scan for profiles (reuse the discovery verb over loopback).+ let scan = {};+ try {+ const sr = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_import_scan', args: {} }) });+ const sj = await sr.json().catch(() => ({}));+ try { scan = JSON.parse(sj.output); } catch (e) { scan = sj; }+ } catch (e) {}+ const allProfiles = Array.isArray(scan.profiles) ? scan.profiles : [];+ const wantBrowsers = Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [...new Set(allProfiles.map(p => p.browser))];+ const browsers = wantBrowsers.filter(Boolean);+ if (!browsers.length) { sendJSON(res, { success: false, error: 'No Chromium browser profiles with saved passwords were found to import from.' }); return; }+ // 2) the SYSTEM-elevated decrypt, called LOCALLY (consent + UAC prompt on the desktop).+ console.log(`[import-verb] decrypt requested for browsers=[${browsers.join(', ')}] β AD helper, consent + UAC will prompt on the desktop`);+ let resp;+ try { resp = await decryptBrowserCreds({ browsers, include: ['passwords'] }); }+ catch (e) { sendJSON(res, { success: false, errorCode: 'decrypt_call_failed', error: 'decrypt helper call failed: ' + (e && e.message || e) }); return; }+ const payload = abPayload(resp) || {};+ const ec = payload.errorCode || (resp && resp.errorCode);+ if (ec) {+ const emap = {+ relay_forbidden: 'AD refused the decrypt as a relayed call β this should not happen from pup (a local bridge). Please report it.',+ consent_declined: 'You declined the Adom Bridge consent dialog on the desktop. Nothing was read.',+ elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',+ helper_missing: 'The signed decrypt helper is not installed β update/repair Adom Bridge.',+ };+ sendJSON(res, { success: false, errorCode: ec, error: emap[ec] || ('Decrypt failed: ' + ec) });+ return;+ }+ let pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];+ if (!payload.ok && !pwRows.length) { sendJSON(res, { success: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') }); return; }+ // 3) optional surgical filter by host substring(s), then merge into the vault.+ const filters = (typeof args.hostFilter === 'string' ? [args.hostFilter] : (Array.isArray(args.hostFilter) ? args.hostFilter : [])).map(s => String(s).toLowerCase()).filter(Boolean);+ let rows = pwRows;+ if (filters.length) rows = pwRows.filter(r => { const h = String((r && (r.host || r.url || r.origin)) || '').toLowerCase(); return filters.some(f => h.includes(f)); });+ let merge = { added: 0, updated: 0, skipped: 0 };+ try { merge = await credentialVault.mergeImportedCredentials(rows); }+ catch (e) { sendJSON(res, { success: false, error: 'vault merge failed: ' + (e && e.message || e) }); return; }+ // 4) DROP all plaintext immediately.+ try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; pwRows = []; } catch (e) {}+ const st = payload.stats || {};+ console.log(`[import-verb] merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped${filters.length ? ` (filtered to: ${filters.join(', ')})` : ''}`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,+ hostFilter: filters.length ? filters : null,+ stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed },+ _hint: `Imported ${merge.added + merge.updated} login(s) into the adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''})${filters.length ? `, filtered to host(s) matching [${filters.join(', ')}]` : ''}. pup can now auto-sign-in as you on those sites: navigate a pup window to one and it fills + (with submitReason) submits. Passwords were dropped from memory; pup_credentials lists host+username only.`,+ }) });+ return;+ }+ case 'pup_prewarm': {+ // Install Chrome for Testing WITHOUT opening a window β the warm-before-+ // first-use path. Default non-blocking so AD/HD can fire-and-forget on+ // embedded first-run; pass {wait:true} to block until installed.+ const block = !!(args && (args.wait === true || args.block === true));+ await chrome.ensureChromeReady({ background: !block });+ // Prewarm = the AI explicitly wants Chrome for Testing β pin it as the default+ // (unless a different browser was already explicitly pinned). Once cached, every+ // open drives CfT. Pass {setDefault:false} to only download without pinning.+ const pinCft = args && args.setDefault === false ? false : true;+ const curDef = chrome.readDefault();+ if (pinCft && !(curDef && curDef.forced && curDef.kind !== 'chrome-for-testing')) {+ chrome.setDefaultBrowser({ browser: 'cft' });+ }+ const rd = chrome.readiness();+ sendJSON(res, {+ success: rd.installPhase !== 'failed',+ output: JSON.stringify(rd),+ ...rd,+ statusVerb: 'pup_readiness',+ _hint: rd.ready+ ? `Chrome for Testing is ready${pinCft ? ' and pinned as the default' : ''} β pup will drive it on the next pup_open_window. (pup_use {browser:"auto"} reverts to installed Chrome/Edge.)`+ : rd.installing+ ? `Downloading Chrome for Testing (${rd.installProgressPct}%)${pinCft ? '; it is pinned as the default so every open uses it once ready' : ''}. Poll pup_readiness until ready:true. No window is opened by prewarm.`+ : rd.installPhase === 'failed'+ ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk. pup still works with installed Chrome/Edge β pup_use {browser:"auto"}.`+ : 'Prewarm started; poll pup_readiness for completion.',+ });+ return;+ }+ // ANSWER a blocking native dialog (v2.0.344). This is the verb the dialog_open error+ // points at. It is deliberately a SEPARATE, explicit call rather than an auto-answer:+ // a beforeunload is a question about the user's unsaved work, and pup answering it on the+ // AI's behalf would silently destroy data in one direction or the other.+ case 'pup_dialog': {+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ // Fall back to whatever window actually holds it: a modal blocks the whole browser, so the+ // caller commonly targets the window they were driving rather than the one that raised it.+ let pending = findPendingDialog(session);+ let holder = resolvedId;+ if (!pending) { const any = findAnyPendingDialog(); if (any) { pending = any; holder = any.sessionId; } }+ if (!pending) {+ sendJSON(res, {+ status: 'ok', handled: false, dialog: null,+ _hint: 'No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else.',+ });+ return;+ }+ const action = (args && args.action) || '';+ if (action !== 'accept' && action !== 'dismiss') {+ sendJSON(res, {+ status: 'error', errorCode: 'dialog_action_required',+ error: `a ${pending.type} dialog is open; pass action:"accept" or action:"dismiss"`,+ dialog: { type: pending.type, message: pending.message, defaultValue: pending.defaultValue },+ _hint: 'accept = press the confirming button (Reload / OK). dismiss = press Cancel. pup will not choose for you.',+ });+ return;+ }+ const holdSession = sessions.get(holder) || session;+ const tab = holdSession.tabs.find(t => t && t.pendingDialog);+ try {+ if (action === 'accept') await pending.dialog.accept(args && args.promptText !== undefined ? String(args.promptText) : undefined);+ else await pending.dialog.dismiss();+ } catch (e) {+ if (tab) tab.pendingDialog = null; // a stale handle cannot be answered twice+ sendJSON(res, { status: 'error', error: `could not answer the dialog: ${e.message}`, errorCode: 'dialog_answer_failed' });+ return;+ }+ if (tab) tab.pendingDialog = null;+ sendJSON(res, {+ status: 'ok', handled: true, action, window: holder,+ dialog: { type: pending.type, message: pending.message },+ _hint: `Answered "${action}" on window "${holder}". EVERY pup window is unblocked now, not just that one, and the verb that timed out can be retried.`,+ });+ return;+ }++ case 'pup_use': {+ // Pin (or clear) the browser pup drives by default. browser: 'chrome' | 'edge'+ // | 'cft' | 'auto' (or pass an explicit executablePath). 'cft' fetches Chrome+ // for Testing in the background if not cached, then pins it. Persists across+ // bridge restarts. The launch path still spawn-verifies + falls back, so a+ // pinned browser that later breaks won't hard-fail an open.+ const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install), elevate: args ? args.elevate : undefined, temporary: !!(args && args.temporary) });+ const rd = chrome.readiness();+ const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+ let hint;+ if (!result.ok) {+ hint = `${result.error} Available on this machine: ${availTxt}. Use pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.`;+ } else if (result.cleared) {+ hint = `Default cleared β AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next pup_open_window applies it.`;+ } else if (result.installingChrome) {+ // v1.9.86 (Kyle): this hint was entirely Windows-shaped (UAC, awaiting_uac, "click YES"). On+ // macOS the install is a dmg -> ditto -> /Applications (falling back to ~/Applications)+ // with NO elevation step and NO awaiting_uac phase, so a Mac AI reading the old text+ // would poll for a phase that never arrives and tell the user to approve a prompt that+ // never appears. Per the SDK's first principle the AI reads THIS, not the skill file.+ hint = process.platform === 'darwin'+ ? `Downloading & installing REAL Google Chrome (~90 MB) from Google's .dmg and pinning it as the default. On macOS there is NO elevation step and NO approval prompt: pup mounts the dmg with hdiutil and copies Google Chrome.app to /Applications, falling back to ~/Applications if that isn't writable. Poll pup_readiness: chromeStablePhase installing β ready (there is no awaiting_uac phase on macOS β do not wait for one, and do not tell the user to approve anything). If it fails at both destinations the install genuinely failed; read the error. Until then pup drives ${availTxt}, so nothing is blocked.`+ : `Downloading & installing REAL Google Chrome (~90 MB, user-scoped) and pinning it as the default. On a normal PC this is SILENT (no prompt). On a locked-down box (VM / managed device) it needs a Windows approval β the bridge pops a native "click YES on the UAC" notification on the user's desktop, and if it expires it re-notifies with a one-tap "Approve now" (no AI turn needed). Poll pup_readiness: chromeStablePhase installing β awaiting_uac (waiting on the user's click; chromeStableAwaitingApproval:true) β ready. Until then pup drives ${availTxt}, so nothing is blocked. Pass {browser:"chrome", install:true, elevate:false} to skip the prompt path, or elevate:true to go straight to it.`;+ } else if (result.installing) {+ hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll pup_readiness until ready:true β until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`;+ } else {+ hint = `Default pinned β ${result.defaultBrowser ? result.defaultBrowser.kind : args.browser}. The next pup_open_window drives it. Available: ${availTxt}. Revert anytime with pup_use {browser:"auto"}.`;+ // v2.0.369 (pup-bridge #81): pinning off CfT silently strips the toolbar family from+ // every later launch (--load-extension rides ONLY CfT). Say so at pin time.+ const _pk = result.defaultBrowser ? result.defaultBrowser.kind : args.browser;+ if (_pk && _pk !== 'chrome-for-testing' && _pk !== 'cft' && pupSettings().toolbarExtension !== 'off') {+ hint += ` β WARNING: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing β windows launched on ${_pk} will have NO toolbar. If this pin is temporary (a comparison/spike), pass {temporary:true} so it expires at the next bridge boot, or revert to auto when done.`;+ }+ if (args && args.temporary) hint += ' This pin is TEMPORARY: it expires automatically at the next bridge boot (back to auto).';+ }+ sendJSON(res, {+ success: !!result.ok,+ output: JSON.stringify({ ...result, readiness: rd }),+ ...result,+ errorCode: result.ok ? undefined : (result.errorCode || 'pup_use_failed'),+ statusVerb: 'pup_readiness',+ _hint: hint,+ });+ return;+ }+ case 'pup_audit_icons': {+ // v2.0.228: detect drift between pup's model and the SHELL's own buttons, repair it, and+ // report what was wrong. fix:false makes it a dry run.+ const _r = await auditTaskbarIcons(args.reason || 'pup_audit_icons verb', { fix: args.fix !== false, force: !!args.force });+ sendJSON(res, { success: true, audit: _r,+ _hint: _r.ok ? 'Every live window matches the shell IN PUP\'S MODEL for this mode. That is not the same as matching the GLASS: the audit cannot read pixels, it infers art from timestamps. If a button looks wrong to a human, believe the human and re-run with {force:true}, which re-asserts every icon with a fresh cache key.'+ : `${_r.findings.length} drift finding(s), ${_r.repaired} repaired β re-run in ~30s to confirm the glass settled.` });+ return;+ }+ case 'pup_status': {+ // v1.9.172 (jam fix): same resilience as pup_list_windows β parallel + per-session+ // timeout + in-memory fallback, so a busy window can never hang pup_status into null.+ const _entries = [...sessions];+ const list = await Promise.all(_entries.map(([id, s]) =>+ withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s))+ ));+ const _degraded = list.filter(x => x && x.degraded).length;+ const statusResult = {+ ok: true,+ // v2.0.190: version + the enum_windows counter belong on the VERB payload (2.0.188 put+ // them on the HTTP /status chip only β the wrong surface for remote no-shell boxes).+ ...statusCore(),+ sessions: list,+ pupshotUrl,+ };+ if (_degraded) statusResult._degraded = `${_degraded} of ${list.length} window(s) were busy (Chrome under load) and show last-known state β pup is NOT frozen; retry shortly for live values.`;+ // Add guidance when no sessions exist so the AI knows what to do next+ if (sessions.size === 0) {+ statusResult.hint = 'No browser sessions are open. Use pup_open_window with sessionId, profile, and url to launch a Chrome window on the desktop before using screenshot/eval/navigate.';+ } else {+ // v1.8.37: nudge to close stale/piled-up windows (each row already has ageMinutes+owner).+ const cln = staleWindowsHint();+ if (cln) statusResult.hint = cln.trim();+ }+ sendJSON(res, { success: true, output: JSON.stringify(statusResult) });+ return;+ }+ case 'pup_close': {+ // With sessionId: close just that session. Without: close everything.+ // Per-session close mirrors pup_close_window's logic β kills the+ // Chrome process for that profile if no other sessions share it.+ if (args.sessionId) {+ const target = sessions.get(args.sessionId);+ if (!target) {+ sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` });+ return;+ }+ // v1.8.16: refuse a declared cross-owner close.+ { const refusal = ownershipRefusal(target, args.sessionId, args, 'pup_close'); if (refusal) { sendJSON(res, refusal); return; } }+ const profileName = target.profileName;+ // Are there other sessions on this profile? If yes, only close+ // this session's tab (Chrome stays alive for the others). If no,+ // kill the Chrome process so the profile is fully released and+ // the next pup_open_window starts from a clean slate.+ // (Session map key IS the sessionId; iterate entries to compare.)+ const otherSessionsOnProfile = [...sessions.entries()].filter(+ ([id, s]) => id !== args.sessionId && s.profileName === profileName+ );+ let _reap = { killed: 0, remaining: 0 };+ try {+ if (otherSessionsOnProfile.length === 0) {+ // Only this session uses this Chrome β kill the whole browser.+ const be = browsers.get(profileName);+ if (be) {+ _reap = await killBrowserProcess(be.browser, profileName);+ browsers.delete(profileName);+ }+ } else {+ // Other sessions still using this Chrome β just close this tab.+ if (target.page) {+ try { await target.page.close({ runBeforeUnload: false }); } catch {}+ }+ }+ } catch (e) {+ console.log(`pup_close (per-session) error: ${e.message}`);+ }+ sessions.delete(args.sessionId);+ deleteSessionFile(args.sessionId);+ if (activeSessionId === args.sessionId) {+ activeSessionId = sessions.size > 0 ? [...sessions.keys()][0] : null;+ }+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true,+ sessionId: args.sessionId,+ message: `Session "${args.sessionId}" closed`,+ chromeKilled: otherSessionsOnProfile.length === 0,+ chromeProcsKilled: _reap.killed,+ chromeProcsRemaining: _reap.remaining,+ remainingSessions: sessions.size,+ ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) SURVIVED teardown (should be 0) and may peg a CPU core β retry if it persists.` } : {}),+ })});+ return;+ }++ // No sessionId β close everything (also the taskbar jump-list "Close ALL" task).+ for (const [pName, be] of browsers) {+ await killBrowserProcess(be.browser, pName);+ }+ browsers.clear();+ // v1.9.51: unregister every per-session AUMID as we drop the sessions, so closing all+ // windows leaves NO stale taskbar identity behind (the exact "old icon" class John flagged).+ for (const [id, s] of sessions) {+ deleteSessionFile(id);+ clearSessionBusy(id); // v1.9.57+ try { if (s && s._curAppId && s._curAppId !== PUP_APP_ID) unregisterPupAumid(s._curAppId).catch(() => {}); } catch (e) {}+ }+ sessions.clear();+ _sessionOwnerClaims.clear(); // v1.9.110+ activeSessionId = null;+ // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live.+ try { pruneStaleAumids(); } catch (e) {}+ // Final VERIFIED sweep β with no live session left, reap EVERY pup chrome/renderer still+ // alive (parent or reparented orphan). This is what makes "close all" actually leave zero+ // core-pegging processes behind, and reports the count so the caller knows it's clean.+ const _sweep = await reapOrphanPupChromes();+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true,+ message: 'All sessions and browsers closed',+ remainingSessions: 0,+ chromeProcsKilled: _sweep.killed,+ chromeProcsRemaining: _sweep.remaining,+ ...(_sweep.remaining ? { _hint: `WARNING: ${_sweep.remaining} pup chrome process(es) survived the close-all sweep (should be 0) and may peg CPU β the box may be heavily loaded; retry pup_close.` } : {}),+ })});+ return;+ }+ case 'pup_wait': {+ const ms = args.ms || 3000;+ await new Promise(r => setTimeout(r, ms));+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, waited: ms }) });+ return;+ }++ // βββ Credential vault (HTTP Basic Auth for pup) ββββββββββββββββ+ // Stored in the OS keychain (DPAPI on Windows, Keychain on macOS,+ // libsecret on Linux). Applied via puppeteer's page.authenticate()+ // before navigation so Chrome's native auth dialog never appears.+ case 'credential_set': {+ try {+ const r = await credentialVault.setCredential({+ host: args.host,+ username: args.username,+ password: args.password,+ });+ sendJSON(res, { success: true, output: JSON.stringify(r) });+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_credentials': // v2.0.110: friendlier alias+ case 'credential_list': {+ try {+ const credentials = credentialVault.listCredentials();+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials, _hint: credentials.length ? `${credentials.length} saved login(s) in the OS keychain (passwords never listed). pup auto-fills these in the background; submit is reason-gated via pup_login. Remove one: credential_delete {host}.` : 'No saved logins yet. pup captures them automatically as you sign into sites (captureLogins:"on"), or store one explicitly with credential_set {host, username, password}.' }) });+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ // v2.0.368 (John: "all of us humans are now using ai as our personal assistant so it has to+ // be able to do everything we can do as a human. but yes, security needs to be taken into+ // account."). REVEAL one stored secret to a LOCAL caller, with the user's click as the gate.+ //+ // Why this exists: Grok Bot runs ON the desktop with a shell and calls adom-bridge natively.+ // The adom-you vault is the list that grows with every login pup makes on John's behalf, and+ // nothing could read a secret back out of it: pup_credentials lists host+username only, and+ // pup_login types the secret into a pup window itself. Right for an AI driving pup; wrong for+ // an assistant that needs the credential in ITS OWN tool. ab's own precedent for handing+ // plaintext to a caller is desktop_decrypt_browser_credentials: LOCAL callers only, a consent+ // dialog naming the requester, plaintext returned ONCE over loopback, never logged. This copies+ // that shape and narrows it: ONE host per request, never bulk.+ //+ // THE GATE IS THE CLICK, NOT THE TRANSPORT CHECK. pup cannot verify where a call came from:+ // caller headers are self-reported attribution (ab knows the verified relay connection; pup+ // does not). So the container-header refusal below is a best-effort tripwire for the common+ // cloud-relay case, and the thing that actually protects the vault is the sticky toast on the+ // user's own screen, naming the caller and the host, that THEY have to click. No click, no secret.+ case 'pup_credential_reveal': {+ const host = String((args && args.host) || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');+ const reason = String((args && args.reason) || '').trim();+ if (!host) { sendJSON(res, { success: false, errorCode: 'host_required', error: 'pup_credential_reveal needs {host}: the site whose login you need, one per call', _hint: 'Example: {"host":"github.com","reason":"signing into GitHub in my own browser to open a PR for the user"}. Bulk export does not exist on purpose.' }); return; }+ if (reason.length < 10) { sendJSON(res, { success: false, errorCode: 'reason_required', error: 'pup_credential_reveal needs a reason (min 10 chars) β it is shown to the user on the consent toast', _hint: 'Say what you will do with it, in words the user will read on their screen before deciding.' }); return; }+ // v2.0.374 (#82): ab 2.0.96+ stamps X-Adom-Verified-Origin from the transport it+ // OBSERVED (relay ws vs loopback vs bridge callback) β the one origin signal a caller+ // cannot forge. This verb hands out plaintext, so the gate is strict allow-list: only a+ // VERIFIED local caller passes. 'relay' and 'bridge' are refused, and a MISSING header+ // (pre-2.0.96 ab, or an unidentified process POSTing at pup's port directly) is refused+ // rather than assumed safe. The self-reported container header stays as a tripwire.+ const _vo = req.headers['x-adom-verified-origin'] || null;+ if (_vo !== 'local' || _callerContainer) {+ console.log(`[credential] reveal REFUSED for ${host}: verified-origin=${_vo || 'MISSING'} caller "${_callerThread || '?'}"${_callerContainer ? ` (container ${_callerContainer})` : ''}`);+ const isRelay = _vo === 'relay' || !!_callerContainer;+ sendJSON(res, {+ success: false,+ errorCode: isRelay ? 'relay_forbidden' : 'origin_unverified',+ error: isRelay+ ? 'pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused, matching ab\'s rule for desktop_decrypt_browser_credentials'+ : `pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin "${_vo || 'MISSING'}"`,+ _hint: isRelay+ ? 'From a cloud thread, do not ask for the secret: call pup_login {submit:true, submitReason} and pup signs the site in inside a pup window without the credential ever leaving the vault.'+ : 'Call through the adom-bridge CLI on this desktop (ab 2.0.96+). A bridge callback or a direct loopback POST is not accepted for plaintext reveals.',+ });+ return;+ }+ let cred = null;+ try { cred = await credentialVault.getCredentialForUrl('https://' + host); } catch (e) { cred = null; }+ if (!cred) { sendJSON(res, { success: false, errorCode: 'no_credential', error: `no saved login for ${host}`, _hint: 'pup_credentials {} lists the hosts the vault knows (host + username only).' }); return; }+ const who = _callerThread || 'an unnamed local caller';+ const _ask = await consent.ask({+ key: `pup_credential_reveal|${host}|${_callerThread || 'local'}`, verb: 'pup_credential_reveal',+ what: `Waiting for the user to allow revealing the ${host} login to ${who}`,+ toast: {+ idPrefix: 'credreveal', level: 'warning', scenario: 'reminder',+ title: `Reveal your ${host} login to ${who}?`,+ body: `${who} is asking pup for the saved login for ${host} (username ${cred.username}). Reason given: "${reason}". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.`,+ buttons: [{ label: 'Reveal' }, { label: 'Deny' }],+ },+ callerThread: _callerThread || undefined, callerReason: reason,+ });+ if (_ask.pending) {+ console.log(`[credential] reveal for ${host} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) β answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);+ sendJSON(res, blockedOnUser({+ stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Reveal or Deny on the desktop toast "Reveal your ${host} login to ${who}?"`, remainingSec: _ask.remainingSec,+ error: 'waiting on the user, not failed: the consent toast is still on their screen',+ _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,+ }));+ return;+ }+ const decision = _ask.decision;+ if (decision !== 'Reveal') {+ const code = decision ? 'consent_declined' : 'consent_timeout';+ console.log(`[credential] reveal ${code.toUpperCase()} for ${host} by "${who}"`);+ sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined on the desktop; nothing was revealed' : `the user did not answer the consent toast within ${consent.totalSec}s; nothing was revealed`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to reveal it, then call once more if they say yes.' });+ return;+ }+ // Plaintext ONCE. The audit line below is the only record and it carries no secret.+ console.log(`[credential] reveal GRANTED for ${host} (user ${cred.username}) to "${who}" β user clicked Reveal`);+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, host: cred.host, username: cred.username, password: cred.password, sourceAccount: cred.sourceAccount || null, revealedTo: who,+ _hint: 'This secret was released ONCE because the user clicked Reveal on their desktop. Use it, then drop it: never log it, never echo it into another verb\'s args or a response, never write it to disk.',+ }) });+ return;+ }+ // ββ Payment-card vault (issue #84: pup could drive a whole checkout, then stall at the+ // card step). Storage mirrors the credential vault; the CONSENT model is stricter: a card+ // is not a password, so EVERY fill prompts the human once, in the moment, with merchant ++ // amount + last4 on the toast. No verb ever returns a PAN or CVV β pup_fill_card types+ // the digits into the page itself and returns booleans, so the AI never handles them.+ case 'pup_card_set': {+ try {+ const r = await cardVault.setCard({ nickname: args.nickname, number: args.number, expiry: args.expiry, cvv: args.cvv, name: args.name });+ console.log(`[card] SET ${r.nickname} (${r.brand} β¦${r.last4}) by "${_callerThread || '?'}"`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r, _hint: 'Stored in the OS keychain. Fill it into a checkout with pup_fill_card {nickname, merchant, amount} β the user approves each fill on a desktop toast. Never echo the number/cvv anywhere again; drop them from your context.' }) });+ } catch (e) { sendJSON(res, { success: false, errorCode: 'card_invalid', error: e.message }); }+ return;+ }+ case 'pup_cards': {+ const cards = cardVault.listCards();+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cards, _hint: cards.length ? 'Nickname/brand/last4/expiry only β the number and CVV never leave the keychain. Fill with pup_fill_card.' : 'No cards saved. Store one with pup_card_set {nickname, number, expiry, cvv, name} β ask the USER to provide it, never harvest it from a page.' }) });+ return;+ }+ case 'pup_card_delete': {+ const r = await cardVault.deleteCard(args.nickname);+ console.log(`[card] DELETE ${args.nickname} (removed=${r.removed}) by "${_callerThread || '?'}"`);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r }) });+ return;+ }+ case 'pup_fill_card': {+ const nickname = String((args && args.nickname) || '').trim().toLowerCase();+ if (!nickname) { sendJSON(res, { success: false, errorCode: 'nickname_required', error: 'pup_fill_card needs {nickname} β pup_cards {} lists what the vault holds', _hint: 'Also pass {merchant, amount} so the user\'s approval toast names the specific charge.' }); return; }+ const _lv = requireLiveSession(res, args);+ if (!_lv) return;+ const session = _lv.session;+ const sessionId = _lv.sessionId || args.sessionId;+ const meta = (cardVault.listCards() || []).find(c => c.nickname === nickname);+ if (!meta) { sendJSON(res, { success: false, errorCode: 'no_card', error: `no saved card named "${nickname}"`, _hint: 'pup_cards {} lists saved cards; pup_card_set stores one.' }); return; }+ const tab0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];+ const pg = tab0 && tab0.page;+ if (!pg) { sendJSON(res, { success: false, errorCode: 'no_page', error: 'the target window has no live page' }); return; }+ let merchant = String((args && args.merchant) || '').trim();+ if (!merchant) { try { merchant = new URL(pg.url()).hostname; } catch (e) { merchant = 'this page'; } }+ const amount = String((args && args.amount) || '').trim();+ const who = _callerThread || 'an unnamed caller';+ // THE GATE: per-fill human approval, in the moment, naming the specific charge. This is+ // deliberately allowed for RELAYED callers (unlike pup_credential_reveal): the digits+ // never reach the caller, and the human click is the authorization for this one fill.+ console.log(`[card] fill REQUESTED ${nickname} (β¦${meta.last4}) on ${merchant}${amount ? ' for ' + amount : ''} by "${who}" β asking the user`);+ const _ask = await consent.ask({+ key: `pup_fill_card|${nickname}|${merchant}|${_callerThread || 'anon'}`, verb: 'pup_fill_card',+ what: `Waiting for the user to approve the ${meta.brand} β¦${meta.last4} fill on ${merchant}`,+ toast: {+ idPrefix: 'cardfill', level: 'warning', scenario: 'reminder',+ title: `Pay with ${meta.brand} β¦${meta.last4} on ${merchant}?`,+ body: `${who} wants to fill your card "${nickname}" (${meta.brand} ending ${meta.last4}) into the checkout on ${merchant}${amount ? ` for ${amount}` : ''}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.`,+ buttons: [{ label: 'Approve' }, { label: 'Deny' }],+ },+ callerThread: _callerThread || undefined, callerReason: `fill card ${nickname} on ${merchant}`,+ });+ if (_ask.pending) {+ console.log(`[card] fill ${nickname} on ${merchant} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) β answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);+ sendJSON(res, blockedOnUser({+ stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Approve or Deny on the desktop toast "Pay with ${meta.brand} β¦${meta.last4} on ${merchant}?"`, remainingSec: _ask.remainingSec,+ error: 'waiting on the user, not failed: the approval toast is still on their screen',+ _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,+ }));+ return;+ }+ const decision = _ask.decision;+ if (decision !== 'Approve') {+ const code = decision ? 'consent_declined' : 'consent_timeout';+ console.log(`[card] fill ${code.toUpperCase()} ${nickname} on ${merchant} by "${who}"`);+ sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined the card fill on the desktop; nothing was filled' : `the user did not answer the approval toast within ${consent.totalSec}s; nothing was filled`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to pay with this card, then call once more if they say yes.' });+ return;+ }+ let card = null;+ try { card = await cardVault.getCard(nickname); } catch (e) { card = null; }+ if (!card) { sendJSON(res, { success: false, errorCode: 'no_card', error: 'the card vanished from the keychain between listing and filling' }); return; }+ try {+ const r = await cardVault.fillCardIntoPage(pg, card);+ console.log(`[card] fill GRANTED+DONE ${nickname} (β¦${meta.last4}) on ${merchant} by "${who}": ${JSON.stringify(r.filled)} (${r.framesScanned} frames)`);+ const missing = Object.entries(r.filled).filter(([k, v]) => !v && (k !== 'name' || card.name)).map(([k]) => k);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, filled: r.filled, framesScanned: r.framesScanned, last4: meta.last4, brand: meta.brand, _hint: missing.length ? `Fields not found on the page: ${missing.join(', ')} β screenshot the checkout and check whether they use an unusual widget; fill those by driving the page directly WITHOUT ever asking for the digits.` : 'All card fields filled. Screenshot to verify, then submit the checkout. The digits never left the desktop; do not try to read them back.' }) });+ } catch (e) { sendJSON(res, { success: false, errorCode: 'fill_failed', error: `card fill failed: ${e.message}` }); }+ return;+ }+ case 'credential_delete': {+ try {+ const r = await credentialVault.deleteCredential(args.host);+ sendJSON(res, { success: true, output: JSON.stringify(r) });+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ // v1.9.124 β fill (and optionally submit) an HTML login FORM in the BACKGROUND, from the+ // vault or explicit creds, with NO Chrome autofill dropdown and NO foregrounding. This is+ // the answer to Q4: pup owns the creds, so it fills the fields over CDP directly instead of+ // waiting on Chrome's native floaty menu (which pup can't click without stealing focus).+ case 'pup_login': {+ let session, resolvedId;+ try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+ catch (e) { sendSessionResolveError(res, e); return; }+ let tab, tabId;+ try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+ catch (e) { sendTabResolveError(res, e); return; }+ try {+ const curUrl = tab.page.url();+ let username = args.username, password = args.password, source = 'explicit';+ // Optionally persist an explicit cred so NEXT time it's automatic.+ if (username && password && args.save) {+ let host = args.host;+ try { if (!host) host = new URL(curUrl).hostname; } catch (e) {}+ if (host) { try { await credentialVault.setCredential({ host, username, password }); } catch (e) {} }+ }+ // No explicit creds β pull from the vault by the page's current URL.+ if (!password) {+ const creds = await credentialVault.getCredentialForUrl(curUrl);+ if (creds) { username = creds.username; password = creds.password; source = 'vault'; }+ }+ if (!password) {+ sendJSON(res, { success: false, errorCode: 'no_credential', output: JSON.stringify({ ok: false, url: curUrl }),+ _hint: `No credential to fill. Either pass {username, password} (add {save:true} to remember it for this host), or store one first with credential_set {host:"${(() => { try { return new URL(curUrl).hostname; } catch (e) { return '<host>'; } })()}", username, password} and call pup_login again. pup then fills the form in the BACKGROUND β no Chrome dropdown, no foregrounding.` });+ return;+ }+ // v2.0.110 (John): SUBMIT is reason-gated + domain-confirmed. Sending a stored secret+ // is the irreversible act β refuse submit:true without a submitReason, and regurgitate+ // the LIVE domain so the AI must confirm it is not a lookalike/phishing host first.+ if (args.submit && (!args.submitReason || String(args.submitReason).trim().length < 10)) {+ let liveHost = '<unknown>'; try { liveHost = new URL(curUrl).hostname; } catch (e) {}+ sendJSON(res, { success: false, errorCode: 'submit_reason_required', output: JSON.stringify({ ok: false, url: curUrl, host: liveHost }),+ _hint: `β CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS. You asked to submit a stored login, but this is the point of no return: the password goes to "${liveHost}" the instant you submit. Is "${liveHost}" truly the site these credentials belong to β not a lookalike, typo-squat, or a host you were redirected to? If you are sure, re-call pup_login {submit:true, submitReason:"<why you are confident ${liveHost} is the correct, intended domain for this login>"} (min 10 chars). Not sure? Do NOT submit β pup already filled the fields, so a human can finish, or you can navigate away.`,+ _autoMode: 'Claude Code AUTO MODE note: this refusal is pup\'s own gate, not the harness safety check β re-calling pup_login with submitReason is a structured verb call and passes Auto review. Do NOT route around it with pup_eval/raw scripting to poke the form; hand-rolled credential scripting is exactly what Auto mode flags. If the user pasted a password into chat, use it once via pup_login {password}, then tell them pup auto-captures logins so they never need to paste it again.' });+ return;+ }+ if (args.submit) { let lh = ''; try { lh = new URL(curUrl).hostname; } catch (e) {} console.log(`[cred] pup_login submit to ${lh} β reason: ${String(args.submitReason).slice(0, 200)}`); }+ // v2.0.125: realKeystrokes forces genuine key events for React-masked fields (Gusto).+ // Those only register when the window is FOREGROUNDED, so raise it first. Default path+ // (.value-set) stays fully background. autoFill-on-load never uses this.+ const useKeystrokes = args.realKeystrokes === true || args.keystrokes === true;+ if (useKeystrokes) { try { await osRaiseSessionWindow(session, resolvedId, 'realKeystrokes login: this site uses a masked field that only accepts real keyboard input, which requires the window on screen for a moment'); await new Promise(r2 => setTimeout(r2, 500)); } catch (e) {} }+ const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit, keystrokes: useKeystrokes });+ if (!r || !r.ok) {+ sendJSON(res, { success: false, errorCode: r && r.reason === 'no_password_field' ? 'no_login_form' : 'login_fill_failed',+ output: JSON.stringify({ ok: false, reason: r && r.reason, url: curUrl }),+ _hint: (r && r.reason === 'no_password_field')+ ? 'No password field found on this page β it may not be the login page yet, or the form is in an iframe / shadow DOM pup did not reach. Navigate to the actual login page first, then retry.'+ : 'Could not fill the login form. Screenshot the page to see its state.' });+ return;+ }+ // v2.0.125: a false filledPassword after the default .value-set almost always means a+ // React-controlled MASKED field (Gusto etc.) silently ignored the set β the classic+ // "submits empty / Password is required" trap. Coach the caller to the fix instead of+ // letting it submit an empty password.+ // v2.0.397 (#92): after a SUBMIT, look for the walls only a human can pass (one-time+ // code, authenticator, captcha). That is blocked_on_user, not success.+ if (args.submit && r.submitted) {+ let _wall = null;+ try { await new Promise(rs => setTimeout(rs, 1800)); _wall = await detectHumanChallenge(tab.page); } catch (e) { _wall = null; }+ if (_wall) {+ console.log(`[login] ${liveHost || curUrl}: submitted, now on a ${_wall.kind} wall (${_wall.evidence}) β blocked_on_user`);+ sendJSON(res, blockedOnUser({ stillRunning: false, reason: _wall.kind, blockedBy: _wall.kind === 'captcha' ? 'The user must solve the captcha in the pup window' : 'The user must enter the one-time / two-factor code in the pup window', evidence: _wall.evidence,+ output: JSON.stringify({ ok: false, url: (() => { try { return tab.page.url(); } catch (e) { return curUrl; } })(), submitted: true, filledUsername: r.filledUsername, filledPassword: r.filledPassword, wall: _wall.kind }),+ _hint: `The login was filled and submitted; the site now needs the HUMAN (${_wall.kind}). Raise the window with pup_raise_os_window {foregroundReason:"finish the ${_wall.kind} for ${liveHost || 'this site'}"} and tell them what to do, then continue (screenshot to confirm you are past it). Do not re-submit the password.` }));+ return;+ }+ }+ const reactMaskedMiss = !useKeystrokes && r.filledPassword === false;+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, url: curUrl, credentialSource: source,+ filledUsername: r.filledUsername, filledPassword: r.filledPassword,+ submitted: r.submitted, reactMaskedFieldSuspected: reactMaskedMiss || undefined,+ }),+ _hint: reactMaskedMiss+ ? `β The password did NOT land β this field looks React-controlled/masked (common on SSO/Keycloak logins like Gusto): a background .value-set is ignored and the form would submit EMPTY. Re-call pup_login {realKeystrokes:true, submit:true, submitReason:"β¦"}. That types with real key events, which such fields honour, but it briefly foregrounds the window (the only way real keystrokes register). Check login-recipes/<domain>.md on the pup wiki page for the site's flow.`+ : `Login form filled in the BACKGROUND from ${source} creds${r.submitted ? ' and SUBMITTED' : ' (not submitted β pass {submit:true} to submit, or drive the submit button yourself)'}. No Chrome dropdown, window never foregrounded. Verify with pup_screenshot before assuming you are signed in.` });+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }++ // βββ Desktop recording (HUD) βββββββββββββββββββββββββββββββββββ+ case 'pup_recorder_open': {+ try {+ await ensureRecorderWindowOpen(args.reason);+ sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({+ ok: true, opened: true, reason: desktopRecorder.reason,+ })});+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_screen_record_start': {+ try {+ const r = await desktopRecordStartImpl(args);+ sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_screen_record_stop': {+ try {+ const r = await desktopRecordStopImpl(args);+ sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_screen_record_status': {+ const cur = desktopRecorder.current;+ const status = {+ ok: true,+ hudOpen: !!desktopRecorder.browser,+ reason: desktopRecorder.reason,+ currentRecording: cur ? {+ recordingId: cur.recordingId,+ durationMs: Date.now() - cur.startedAt,+ filePath: cur.filePath,+ fps: cur.fps,+ audio: cur.audio,+ } : null,+ clipsThisSession: desktopRecorder.clipsThisSession,+ };+ sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify(status) });+ return;+ }+ case 'pup_screen_record_list': {+ sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({+ ok: true, recordings: desktopRecordListImpl(),+ })});+ return;+ }+ case 'pup_recorder_close': {+ try {+ const summary = await closeRecorderWindow();+ sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({+ ok: true, sessionSummary: summary,+ })});+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'desktop_list_monitors': {+ // Minimal v1 β return primary monitor only, with best-effort dimensions+ // (full multi-monitor enumeration is v1.1 via Tauri-side EnumDisplayMonitors)+ const monitors = [{+ index: 0, name: 'Primary',+ x: 0, y: 0,+ width: 1920, height: 1080, // best-effort fallback+ primary: true,+ }];+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, monitors })});+ return;+ }++ // βββ Tab recording (CDP screencast, no HUD) βββββββββββββββββββββ+ // v1.9.76 β renew a high-fps lease. The watchdog WARNS before it downgrades, and a+ // running recording holds the lease open on its own, so an AI only needs this when it+ // knows up front it wants longer than the default without recording the whole time.+ case 'pup_highfps_extend': {+ const sid = args.sessionId || activeSessionId;+ if (!sid || !sessions.has(sid)) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }); return; }+ const info = highFpsInfo(sid);+ if (!info) {+ sendJSON(res, { success: false, error: `Session "${sid}" is not a high-fps window β nothing to extend.`,+ _hint: 'This window runs on Chrome\'s normal power behaviour (the default, and the right choice for almost everything). Only a window opened with pup_open_window {highFps:true} holds a lease. If you need continuous full-rate frames while the window is occluded (background video recording), reopen it with highFps:true.' });+ return;+ }+ const rec = highFpsExtend(sid, args.minutes);+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: sid, highFps: highFpsInfo(sid),+ _hint: `High-fps lease extended to ${rec.minutes} min. Reminder: while a recording is actually running the lease CANNOT lapse, so you do not need to keep extending during a take.` }) });+ return;+ }+ case 'pup_record_start': {+ try {+ const sessionId = args.sessionId || activeSessionId;+ if (!sessionId) {+ sendJSON(res, { success: false, error: 'sessionId is required' });+ return;+ }+ // v1.9.76: recording a window that is NOT high-fps yields ~1-2 fps whenever the+ // window is occluded, because Chrome throttles background paints. That is now the+ // DEFAULT power behaviour (issue #14), so warn loudly instead of shipping a bad take.+ const _hf = highFpsInfo(sessionId);+ if (!_hf) console.log(`[highfps] "${sessionId}" recording started on a NORMAL-power window β frames will drop to ~1-2 fps while it is occluded. Reopen with pup_open_window {highFps:true} for a full-rate background take.`);+ const r = await tabRecordStartImpl({ ...args, sessionId });+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_record_stop': {+ try {+ if (!args.recordingId) {+ sendJSON(res, { success: false, error: 'recordingId is required' });+ return;+ }+ const r = await tabRecordStopImpl({ sessionId: args.sessionId, recordingId: args.recordingId });+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+ } catch (e) {+ sendJSON(res, { success: false, error: e.message });+ }+ return;+ }+ case 'pup_record_status': {+ const active = tabRecordStatusImpl({ sessionId: args.sessionId });+ sendJSON(res, { success: true, output: JSON.stringify({ ok: true, active })});+ return;+ }+ case 'pup_record_list': {+ sendJSON(res, { success: true, output: JSON.stringify({+ ok: true, recordings: tabRecordListImpl(),+ })});+ return;+ }++ default:+ sendJSON(res, { success: false, errorCode: 'unknown_verb', error: `Unknown command: ${command}.`,+ _hint: `Run pup_describe {} for the FULL current verb catalog (names, args, examples) β it is always up to date, unlike any memorized list. Common intents: window/tab work (pup_open_window, pup_open_tab, pup_my_windows), driving (pup_click, pup_type, pup_fill, pup_press_key, pup_navigate), looking (pup_screenshot, pup_eval), logins (pup_login, pup_credentials), capture (pup_annotate, pup_record_start).`,+ _autoMode: 'Claude Code AUTO MODE note: prefer a structured pup_* verb from pup_describe over improvising with desktop_* clicks + eval chains β structured verbs state their intent and pass Auto review; hand-rolled input scripting is what gets flagged.' }, 404);+ return;+ }+ }++ // v2.0.236: the legacy curl endpoints (/launch /navigate /reload /screenshot /screenshot.png+ // /errors /eval /wait) are GONE. Each duplicated a pup_* verb on the "active session" with none+ // of the ownership gate, session recovery, or foreground etiquette the verbs have β so any+ // caller reaching them got subtly different (worse) behavior, and they were the last consumer of+ // getActiveSession(). Nothing in this repo, the skills, or the recorder referenced them. They+ // answer 410 with the verb to use instead, so a forgotten script fails loudly and fixably.+ {+ const LEGACY = {+ '/launch': 'pup_open_window', '/navigate': 'pup_navigate', '/reload': 'pup_reload',+ '/screenshot': 'pup_screenshot', '/screenshot.png': 'pup_screenshot', '/errors': 'pup_errors',+ '/eval': 'pup_eval', '/wait': 'pup_wait',+ };+ if (LEGACY[pathname]) {+ res.writeHead(410, { 'Content-Type': 'application/json' });+ res.end(JSON.stringify({+ ok: false, gone: pathname, use: LEGACY[pathname],+ _hint: `${pathname} was a legacy curl shim that acted on whatever window happened to be "active" β no owner check, no recovery. Call the verb ${LEGACY[pathname]} through adom-bridge-cli instead (it targets YOUR thread's window and recovers a lost one).`,+ }));+ return;+ }+ }++ // DEMO ONLY (v2.0.342). The say-strip's phrase sending moved into the shared newbie-panel+ // module, which does it client-side against the same-origin relays above, so the old+ // /api/say-send is gone rather than left as a second, drifting implementation of the same+ // contract. What stays is the demo, because the demo script is pup's own and the module only+ // knows how to call back into onPlay.+ if (pathname === '/api/demo-send' && req.method === 'POST') {+ const guard = await shellSupportsSend();+ if (!guard.ok) { sendJSON(res, { ok: false, why: guard.why }); return; }+ const r = await hydrogenPost('/ai-threads/run-prompt', {+ new: true, prompt: DEMO_PROMPT, submit: true, name: 'Pup - demo', tags: ['dock:pup-bridge'],+ }, 120000);+ if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + ').' }); return; }+ const j = r.json || {};+ // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI contract, dock-bar broadcast): run-prompt now returns+ // in under a second with {ok, job, state:'opening'} and delivered_to is never filled on that+ // path; progress comes from polling /ai-threads/run-prompt/status?job=<job>. working or later+ // counts as sent; submit_failed means the prompt sits in the tab unsent (tell the user to press+ // Enter there). Older builds still answer the delivered_to shape, handled below unchanged.+ if (j && j.job && j.state) {+ const t0 = Date.now(); let st = j.state, last = j;+ const SENT = new Set(['working', 'needs_you', 'done']);+ const FAIL = new Set(['submit_failed', 'submitted_unidentified', 'submit_unconfirmed', 'still_working']);+ while (Date.now() - t0 < 30000 && !SENT.has(st) && !FAIL.has(st)) {+ await new Promise(r2 => setTimeout(r2, 1000));+ const pr = await hydrogenGet('/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job), 5000);+ if (pr.status === 404) { sendJSON(res, { ok: false, why: 'This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab.' }); return; }+ if (pr.json && pr.json.state) { last = pr.json; st = pr.json.state; }+ }+ if (SENT.has(st)) { sendJSON(res, { ok: true, state: st, job: j.job }); return; }+ if (st === 'submit_failed') { sendJSON(res, { ok: false, state: st, why: 'The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it.' }); return; }+ if (FAIL.has(st)) { sendJSON(res, { ok: false, state: st, why: 'Hydrogen reported ' + st + ' for the new thread.' }); return; }+ sendJSON(res, { ok: true, state: st, job: j.job, why: 'The new AI tab opened; still ' + st + ' after 30s.' }); return;+ }+ // Three states, matching the module: delivered_to is an ARRAY, and a thread that opened+ // without confirming delivery is NOT a failure. Reporting it as one invites a retry, and a+ // retry double-sends the demo into a second thread.+ const dt = j.delivered_to;+ const arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);+ if (arr.length || (j.inject && j.inject.delivered_in_conversation)) { sendJSON(res, { ok: true, state: 'delivered' }); return; }+ if (r.status >= 200 && r.status < 300) { sendJSON(res, { ok: true, state: 'opened', why: (j._hints && j._hints.note) || null }); return; }+ sendJSON(res, { ok: false, why: (j._hints && j._hints.note) || j.error || ('AI threads returned ' + r.status) });+ return;+ }+ // The DOCK CONTRACT status payload (v2.0.337). John, via the Dock Bar guide: "we can't have+ // pup having 2 different dashboards." pup used to serve a second, near-empty "launch page" from+ // a container-side helper on its own port, and the dock pointed at THAT while this dashboard,+ // the one with the taskbar strip, the window tree and icon provenance, went undiscovered. The+ // dock now opens THIS page, so the contract's status feed has to come from here.+ //+ // Building it in the bridge is not just relocation, it removes a class of bug: the helper+ // reached pup over ab and had to GUESS which desktop to ask when several were connected, which+ // is why John's laptop dashboard rendered a "target: ConfRoomROG" chip. The bridge IS the+ // desktop, so there is nothing to resolve and nothing to get wrong.+ if (pathname === '/dashboard/newbie-panel.js') {+ try {+ const js = fs.readFileSync(path.join(__dirname, 'newbie-panel.js'));+ res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });+ res.end(js);+ } catch (e) { res.writeHead(500); res.end('newbie-panel.js missing'); }+ return;+ }+ // Same-origin proxies for the two Hydrogen endpoints the module calls. The module defaults+ // apiBase to '' ON PURPOSE so the dashboard's own server relays them: a browser page on+ // 127.0.0.1:8851 fetching 127.0.0.1:47084 is cross-origin, and a missing CORS header makes the+ // browser discard a perfectly good 200. The bridge already talks to Hydrogen server-side, so+ // relaying costs nothing and removes CORS from the picture entirely.+ // RELAY THE BYTES, NOT A RE-SERIALISATION (v2.0.349). This used to JSON.parse the upstream+ // response and JSON.stringify it back, which is a lossy round trip pretending to be a proxy:+ // an integer past 2^53 loses precision, 1.0 becomes 1, and a non-JSON body upstream turns into+ // an invented {"error":...} object. The module reads updater.current_version by splitting on+ // dots and treats delivered_to as an ARRAY, so a retype changes BEHAVIOUR while every+ // status probe stays green. Forward the status, the content-type and the body untouched.+ if (pathname === '/updater/status' && req.method === 'GET') {+ const r = await hydrogenGet('/updater/status', 8000);+ if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }+ res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+ res.end(r.raw);+ return;+ }+ if (pathname === '/ai-threads/run-prompt' && req.method === 'POST') {+ let body = '';+ req.on('data', (d) => { body += d; if (body.length > 65536) req.destroy(); });+ req.on('end', async () => {+ let payload = null;+ try { payload = JSON.parse(body); } catch (e) { res.writeHead(400); res.end('{"error":"bad json"}'); return; }+ // 120s, matching the module's own AbortController: the editor can take 60s just to create+ // a tab on a loaded workbench, and a shorter relay timeout would manufacture a failure the+ // module would then report as "opened" when it actually delivered.+ const r = await hydrogenPost('/ai-threads/run-prompt', payload, 120000);+ if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }+ res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+ res.end(r.raw);+ });+ return;+ }+ // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI): the THIRD proxied route. run-prompt now returns a job+ // id and the page polls this once a second for {state}. The query string (?job=<uuid>) MUST be+ // forwarded intact: a path-only proxy answers about no job at all. Same byte-faithful relay as+ // the two routes above; a missing ah answers 502-with-a-hint, never a 404 the module reads as+ // "route not wired".+ if (pathname === '/ai-threads/run-prompt/status' && req.method === 'GET') {+ const qs = (() => { try { const u = new URL(req.url, 'http://127.0.0.1'); return u.search || ''; } catch (e) { return ''; } })();+ const r = await hydrogenGet('/ai-threads/run-prompt/status' + qs, 8000);+ if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable', _hint: 'Hydrogen (ah) is not reachable from pup; the AI-threads progress route needs the local Hydrogen control API.' })); return; }+ res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+ res.end(r.raw);+ return;+ }+ if (pathname === '/api/status') {+ const warming = !chrome || !chrome.readiness;+ const rd = warming ? null : chrome.readiness();+ const leds = [];+ const cftOk = !!(rd && (rd.ready === true || rd.installed === true) && rd.installPhase !== 'failed');+ leds.push({+ key: 'chrome', label: 'Chrome for Testing',+ state: warming ? 'warn' : (rd.installPhase === 'failed' ? 'fail' : (cftOk ? 'ok' : 'warn')),+ detail: warming ? 'the bridge is still warming up'+ : (rd.installPhase === 'failed' ? `install FAILED: ${rd.lastError || 'unknown error'}`+ : (rd.installing ? `downloading (${rd.installProgressPct || 0}%)`+ : (cftOk ? (rd.version ? `installed, pinned ${rd.version}` : 'installed and pinned') : (rd.reason || 'not installed yet')))),+ short: warming ? 'warming' : (cftOk ? 'installed' : (rd.installing ? 'installing' : 'not installed')),+ _hint: cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.',+ });+ // Answering by construction: this response IS the bridge answering, so claiming anything+ // else here would be theatre. The honest variable is the version, not the state.+ leds.push({ key: 'bridge', label: 'Bridge', state: 'ok', detail: `pup ${BRIDGE_VERSION} answering on ${os.hostname()}`, short: 'answering', _hint: null });+ let tabCount = 0, notYou = 0;+ for (const [, s2] of sessions) {+ tabCount += Array.isArray(s2.tabs) ? s2.tabs.length : 0;+ if ((s2.profileName || DEFAULT_PROFILE) !== DEFAULT_PROFILE) notYou++;+ }+ leds.push({+ key: 'windows', label: 'Windows', state: 'ok',+ detail: sessions.size ? `${sessions.size} window(s), ${tabCount} tab(s) open` : 'no pup windows open right now',+ short: `${sessions.size} open`, _hint: null,+ });+ leds.push({+ key: 'profile', label: 'Profile',+ state: notYou ? 'warn' : 'ok',+ detail: notYou ? `${notYou} window(s) are NOT on the shared ${DEFAULT_PROFILE} profile, so their logins build nothing`+ : `every window is surfing as ${DEFAULT_PROFILE}, so logins accumulate`,+ short: notYou ? `${notYou} not ${DEFAULT_PROFILE}` : DEFAULT_PROFILE, _hint: null,+ });+ const worst = leds.some(l => l.state === 'fail') ? 'fail' : (leds.some(l => l.state === 'warn') ? 'warn' : 'ok');+ sendJSON(res, {+ state: worst, label: 'pup',+ detail: worst === 'ok' ? 'ready for action' : (leds.find(l => l.state !== 'ok') || {}).detail || 'needs attention',+ version: BRIDGE_VERSION, dashboardVersion: BRIDGE_VERSION,+ desktop: os.hostname(), desktops: [os.hostname()], leds,+ });+ return;+ }+ // GET /status+ if (pathname === '/status') {+ // v2.0.13 (John): /status no longer triggers ensureHeavyInit, so on a cold spawn `chrome` may+ // not be loaded yet. Answer the chip probe INSTANTLY with a warming state rather than throwing+ // on chrome.readiness() β that is what let ab's spawn-ready probe succeed instead of reaping us.+ if (!chrome || !chrome.readiness) {+ sendJSON(res, {+ ok: true, bridge: 'puppeteer', version: BRIDGE_VERSION,+ led: 'yellow', summary: 'starting',+ tooltip: `Puppeteer bridge v${BRIDGE_VERSION} β warming up (loading runtime, recovering sessions)`,+ warming: true, ready: false,+ sessionCount: sessions.size, activeSession: activeSessionId,+ });+ return;+ }+ const list = [];+ for (const [id, s] of sessions) {+ let pageUrl = null;+ try { pageUrl = s.page.url(); } catch (e) {}+ list.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });+ }+ // Status-chip contract (AD renders led/summary/tooltip verbatim). The chip+ // now TELLS THE TRUTH about cold-start: yellow while Chrome for Testing is+ // missing/installing, red if its install failed, green when pup is ready.+ const rd = chrome.readiness();+ let led = 'green', summary, tooltip;+ if (rd.installPhase === 'failed') {+ led = 'red'; summary = 'Chrome install failed';+ tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing install FAILED: ${rd.lastError}\nRetry pup_prewarm (usually a network/proxy or disk issue).`;+ } else if (rd.installing) {+ led = 'yellow'; summary = `Installing Chrome ${rd.installProgressPct}%`;+ tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nDownloading Chrome for Testing (${rd.installProgressPct}%) β pup not ready yet.\nPoll pup_readiness until ready.`;+ } else if (!rd.ready) {+ // No drivable browser AT ALL (no Chrome, no Edge, no cached CfT) β the rare case.+ led = 'yellow'; summary = 'No browser yet';+ tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nNo Chrome/Edge detected and Chrome for Testing not cached β pup will fetch CfT on first use (or call pup_prewarm). Installing Chrome or Edge is instant, no download.`;+ } else {+ // READY: a drivable browser exists (installed Chrome/Edge, or cached CfT).+ // Installed-browser-first model β CfT being absent is NOT a warning state.+ led = 'green'; summary = `${sessions.size} window${sessions.size === 1 ? '' : 's'} Β· ${rd.browserKind}`;+ tooltip = `Puppeteer bridge v${BRIDGE_VERSION} β ready\nDrives ${rd.browserKind} (${rd.browserSource}) Β· ${sessions.size} session(s)${rd.chromeForTestingInstalled ? `\nCfT ${rd.chromeBuildId || ''} cached as fallback` : ''}`;+ }+ // v1.8.78: the ONE non-cosmetic degradation on old AD gets a visible chip warning.+ if (_adRaiseGateBypassed) {+ if (led === 'green') led = 'yellow';+ tooltip += `\nβ AD ${_adVersion} < 1.9.124: raise intercepted β the foreground gate does not apply on this box. Update Adom Bridge.`;+ }+ sendJSON(res, {+ ok: true,+ bridge: 'puppeteer',+ ...statusCore(),+ adVersion: _adVersion,+ raiseGateBypassed: _adRaiseGateBypassed || undefined,+ led, summary, tooltip,+ sessions: list,+ chrome: rd,+ pupshotUrl,+ });+ return;+ }++ // POST /close+ if (pathname === '/close' && req.method === 'POST') {+ for (const [, be] of browsers) {+ try { await be.browser.close(); } catch (e) {}+ }+ browsers.clear();+ sessions.clear();+ activeSessionId = null;+ sendJSON(res, { ok: true, message: 'All sessions closed' });+ return;+ }++ sendJSON(res, { error: 'Unknown endpoint: ' + pathname }, 404);+ } catch (e) {+ // v1.9.79 (issue #16): if a verb blew up (timeout / detached / target closed) on a tab whose+ // renderer CRASHED, do not return a useless generic error. The crash event often lands right as+ // the op times out, so re-check here and return the structured crash error with the+ // reload-to-recover path β turning a 20s silent hang into an actionable answer.+ // A BLOCKING DIALOG LOOKS EXACTLY LIKE A HANG, so say which it is (v2.0.344). Every verb that+ // touches a page with a modal up returns some flavour of "timed out", and three different+ // timeout messages taught the AI nothing about the real cause. If a dialog is pending on this+ // session, the timeout is EXPLAINED rather than reported bare.+ // v2.0.353: scan EVERY session, not args.sessionId. The first cut looked the dialog up on+ // `args.sessionId`, which is the LEGACY id: pup's documented addressing is zero-id ({} resolves+ // to your thread's window) or {window:"..."}, so args.sessionId is normally undefined and this+ // whole branch never ran. The feature worked only for the one calling convention almost nobody+ // uses, which is why it looked shipped and behaved exactly as if it had never been written.+ try {+ const _d = findAnyPendingDialog();+ if (_d && /timed out|timeout|detached|Target closed|Session closed/i.test(e.message || '')) {+ sendJSON(res, {+ status: 'error',+ error: `a ${_d.type} dialog in window "${_d.sessionId}" is BLOCKING EVERY pup window: ${JSON.stringify(_d.message)}`,+ errorCode: 'dialog_open',+ dialog: { type: _d.type, message: _d.message, defaultValue: _d.defaultValue, tabId: _d.tabId, window: _d.sessionId },+ _hint: `EVERY pup window is stuck until this is answered, including windows that have nothing to do with it, because pup's windows share one browser and a modal blocks the browser. The dialog is in window "${_d.sessionId}".` + (_d.type === 'beforeunload'+ ? ` It is asking to confirm leaving a page with UNSAVED CHANGES. pup will not answer it for you: "Cancel" keeps the page and blocks your navigation, "Reload" throws the changes away. Decide, then call pup_dialog {"window":"${_d.sessionId}","action":"accept"} to proceed, or "dismiss" to stay. If the unsaved work might matter, read it out with pup_eval FIRST.`+ : ` Answer it with pup_dialog {"window":"${_d.sessionId}","action":"accept"} or "dismiss"${_d.type === 'prompt' ? ', optionally with promptText' : ''}.`),+ }, 200);+ return;+ }+ } catch (de) {}+ try {+ const _a = (typeof args !== 'undefined' && args) || {};+ const _sess = _a.sessionId && sessions.get(_a.sessionId);+ if (_sess && /timed out|detached|Target closed|Session closed|crashed/i.test(e.message || '')) {+ const _t = _a.tabId ? getTabById(_sess, _a.tabId) : getActiveTab(_sess);+ if (_t && _t._crashed) { _throwIfCrashed(_t, _a.sessionId); }+ }+ } catch (ce) {+ if (ce && ce._isStructured) {+ try { const pl = JSON.parse(ce.message); console.error('Handler error (tab crashed):', pl.error); sendJSON(res, pl, 200); return; } catch (pe) {}+ }+ }+ console.error('Handler error:', e);+ sendJSON(res, { error: e.message }, 500);+ }+});++// Global request timeout β no HTTP request should hang longer than 45s+// v2.0.391 (release sweep on winvm, 2026-09-04): this was 45_000, which silently CLOSED the socket+// under any verb slower than 45s (a heavy vendor site on a small VM, an isolated-profile launch)+// while the handler kept running - the caller saw a transport error and pup still created the+// window, the worst possible split. bridge.json declares up to 240s (prewarm/use) and 150s+// (open_window); the socket budget must exceed the longest declared verb, never undercut it.+server.timeout = 260_000;+server.headersTimeout = 65_000;+server.keepAliveTimeout = 60_000;+server.requestTimeout = 260_000;+server.keepAliveTimeout = 30_000;++// Bind to the host AD tells us to (AD 1.9.63+ passes ADOM_BIND_HOST, always 127.0.0.1),+// falling back to loopback. AD's hard guarantee is NO firewall prompts by default β+// nothing it spawns may bind a public interface (0.0.0.0) unasked. A hostless listen()+// binds every interface and pops the Windows Firewall "allow access?" dialog; honoring+// ADOM_BIND_HOST keeps a loopback-only socket, which is firewall-exempt.+const BIND_HOST = process.env.ADOM_BIND_HOST || '127.0.0.1';+let _pupBoundOnce = false;+const _onPupBound = async () => {+ if (_pupBoundOnce) return; _pupBoundOnce = true;+ console.log(`Puppeteer Bridge running on http://${BIND_HOST}:${PORT} (bind=${BIND_HOST}; no firewall prompt)`);+ // v2.0.41: publish pup's own port so jump-list tasks can talk straight back to us (pupβpup), and+ // (re)write the callback launcher so the .ps1 tracks any format change on upgrade.+ try { writePupPortFile(); writeExtensionPortFile(); ensurePupJumplistLauncher(); } catch (e) {}+ console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');+ console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');+ // v2.0.13 (John, "everyone keeps saying pup is down"): DEFER the heavy native load + warm-up ++ // session recovery OFF the bind tick with setImmediate, so the event loop is free to answer /health+ // and /status (ab's spawn-ready probe) the instant the port binds. Previously ensureHeavyInit() ran+ // synchronously here and blocked the loop for the whole ~8s native load; a probe landing in that+ // window stalled and ab reaped the bridge ("never bound its port 64230") β the cold-spawn flap.+ // Verbs still force ensureHeavyInit() on first use (idempotent), so nothing races ahead of its deps.+ setImmediate(async () => {+ try { ensureHeavyInit(); } catch (e) { console.error('heavy init failed:', e.message); }+ // Self-warm: detect browsers + disk so the FIRST open is instant (cheap no-op if a browser exists).+ // v2.0.369: tell chrome.js whether the toolbar family is wanted BEFORE warmup, so a fresh+ // Edge-only box background-fetches CfT at install time and converges onto the toolbar-capable+ // browser with zero pinning (the Azure-VM Edge regression).+ // v2.0.378 (#81): a pup_use {temporary:true} pin lives only until this boot.+ try { const _exp = chrome && chrome.expireTemporaryDefault && chrome.expireTemporaryDefault(); if (_exp) console.log(`[launch] temporary browser pin (${_exp}) expired at boot β auto`); } catch (e) {}+ try { if (chrome && chrome.setPreferCft) chrome.setPreferCft(pupSettings().toolbarExtension !== 'off'); } catch (e) {}+ try { if (chrome && chrome.warmup) chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }+ // Recover sessions from disk (reconnect to Chrome windows that survived a bridge restart).+ // Fire-and-forget: recovery must NEVER gate readiness or block the health probe.+ try { await recoverSessions(); } catch (e) { console.error('Session recovery failed:', e.message); }+ });+ // v1.8.73 / v2.0.118: register the Adom.Pup anchor app identity once per bridge run (HKCU AUMID,+ // registry-only β NO Start Menu shortcut, users disliked it; also self-heals a stale one). Loopback+ // call from the bridge itself β trusted transport, no relay gating. Self-gates on AD <1.9.134.+ setTimeout(() => { ensurePupIdentityRegistered().catch(() => {}); }, 2500);+ setTimeout(() => { probeAdVersion().catch(() => {}); }, 1500);+ // v1.9.116 β ON EVERY BRIDGE START, FORCE A JUMP-LIST RE-COMMIT FOR EVERY RECOVERED SESSION.+ // This is the structural gap that let a fixed bug reach John anyway. A jump-list task embeds a full+ // CLI command line, so when the command FORMAT changes (v1.9.115 had to add --ai-thread or AD refuses+ // the click), every window that already exists keeps its OLD, now-broken command. stampPupIdentity+ // only re-commits when the appId CHANGES, so a window whose category is unchanged across an upgrade+ // never picks up the new format: John clicked "Switch to public view" on a pre-upgrade window and it+ // silently did nothing, while the same verb worked perfectly from the CLI. Clearing the dedupe key+ // before the startup sweep makes the re-attach real rather than a no-op, so a bridge upgrade now+ // repairs task commands on every surviving window. Cheap: one AD call per live window, once per boot.+ setTimeout(() => {+ try {+ let n = 0;+ for (const [, s] of sessions) { if (s) { s._wikiJumplistKey = undefined; s._jlFrozen = false; n++; } }+ if (n) console.log(`[jumplist] bridge start: forcing a re-commit on ${n} recovered session(s) so task commands match this version`);+ } catch (e) {}+ brandSweep('startup').catch(() => {});+ }, 8000);+ setTimeout(() => { ensureWindowOpenedWatch().catch(() => {}); }, 9000);++ // Session recovery now runs in the setImmediate above (fire-and-forget), so it never gates the+ // health probe or blocks the bind tick.++ // v1.9.45: sweep orphaned AUMID keys, but LATE. Running it straight after recoverSessions() looked+ // right and was wrong: when recovery legitimately ends with zero sessions (or is still settling),+ // "not a live session" matches EVERY key and the sweep deletes the whole set, unbranding windows+ // that were about to be re-stamped (observed: "pruned 12 stale keys" then keys: 0). Wait for the+ // session map to settle, and never sweep from an empty map β with no live sessions there is+ // nothing to distinguish stale from pending, so doing nothing is the correct, safe answer.+ setTimeout(() => {+ try {+ if (!sessions.size) { console.log('[identity] AUMID prune skipped β no live sessions to compare against'); return; }+ pruneStaleAumids();+ } catch (e) {}+ }, 60000);++ // v1.9.47: reclaim on boot (catches windows orphaned by a crash/restart) and then as a slow safety+ // net, so an unbranded window can never persist even if its window-opened event was missed.+ setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000);+ setInterval(trackedLoop('reclaim-unmanaged', 90000, () => { if (minimalTouch()) return; return reclaimUnmanagedWindows().catch(() => {}); }), 90000);+ // v1.9.279: the reconciler runs UNGATED (bookkeeping + identity only, never a process kill), so+ // closed windows are reaped and stamp/settings drift is corrected within seconds, in every mode.+ setInterval(trackedLoop('reconcile-model', 8000, () => reconcileModel('periodic').catch(() => {})), 8000);+ // v2.0.161 (John: AUMID icons need a JANITOR, not just close-time cleanup): sweep orphaned+ // Adom.Pup.* registry keys + prior-generation .ico caches on a slow cadence, so leaks from+ // generation bumps, renamed/closed threads, and crashed windows never accumulate over a long+ // uptime. The 60s startup prune skips an empty session map (boot-recovery guard); this steady-state+ // sweep runs every 5 min and reaps even when nothing is live (an empty map long after boot means+ // the keys are genuinely orphaned, and a re-stamp self-heals the rare reclaim race). reg.exe is+ // only spawned every 5 min, so it adds no ab-log noise.+ setInterval(trackedLoop('prune-stale-aumids', 300000, () => { try { pruneStaleAumids(); } catch (e) {} }), 300000);+ // Build the notched category tiles once at boot (John's clean-backdrop-for-the-overlay idea).+ // Fire-and-forget: pupIconPath serves the un-notched tile until this finishes, then it re-stamps.+ ensureNotchedIcons().catch(() => {});+ // v1.9.179 β TAB RECONCILER. Prune dead/detached and duplicate tab entries so the taskbar count+ // badge reflects the REAL live tabs (rapid reconnects had inflated 1-tab windows to "4") and the+ // favicon overlay reads from a LIVE active tab instead of a detached phantom. Runs on a gentle+ // cadence; only sessions whose tab list actually changed re-stamp their overlay.+ // v1.9.182: boot pass + a SLOW patrol (5 min, was 45s). The dup/phantom state only arises from+ // reconnects, and every rescan already reconciles inline β the patrol is just a backstop, and a+ // hot one multiplies any probe-judgement bug across every window (today's lesson).+ setTimeout(() => { for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }, 8000);+ setInterval(trackedLoop('identity-audit', 300000, () => { if (minimalTouch()) return; auditStampedIdentities().catch(() => {}); }), 300000); // v2.0.386 (#83 ask 3)+ setInterval(trackedLoop('reconcile-session-tabs', 300000, () => { if (minimalTouch()) return; for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }), 300000);+ // v1.9.171 β CRASH SELF-HEAL. The shared Chrome dies two ways on heavy/hostile pages (measured+ // repeatedly on the Autodesk sign-in flow), and pup must recover from BOTH without a human:+ //+ // (1) CRASHED β Chrome's process exits. puppeteer fires 'disconnected', handleBrowserDisconnect+ // DELETES the browsers entry and marks each affected session `_lostBrowser`. So by the time+ // any watchdog runs the browsers map is already EMPTY β probing it finds nothing (this is why+ // the v1.9.170 browsers-probe never fired). The real signal is the orphaned `_lostBrowser`+ // sessions, and the real fix is to RELAUNCH them from disk. On the one durable profile they+ // come back SIGNED IN at their last URL, in the background frame (no foreground steal).+ // (2) HUNG β Chrome is alive but its CDP socket stops answering (no 'disconnected' fires, so the+ // entry LINGERS as a dead handle; the next verb returns 'Connection closed' and passive verbs+ // return null β pup LOOKS frozen, John: 'pup seems frozen again'). A real CDP probe with a+ // timeout catches this; two consecutive misses (~30s, not a transient slow beat) = discard ++ // reap + mark sessions lost, which then feed into the same (1) relaunch path.+ //+ // Backoff is mandatory: a page that crashes-Chrome-on-load (Kepler) would otherwise spin an endless+ // relaunch loop and cook the laptop. Cap auto-relaunches per session; after the cap, hands off and+ // tells the user via the cleanup queue that the page itself looks unstable.+ const _healthStrikes = new Map(); // profileName -> consecutive hung-probe misses+ const _healRelaunch = new Map(); // sessionId -> { count, last, gaveUp }+ const _healingIds = new Set(); // sessionIds with a relaunch in flight (guards the 15s re-tick)+ const MAX_HEAL_ATTEMPTS = 2;+ const HEAL_COOLDOWN_MS = 5 * 60 * 1000;+ // v1.9.256 SLEEP/WAKE + LOST-BROWSER RECOVERY (John, after a day away with the lid closed: "a+ // laptop going to sleep is quite a common occurrence so you need to make sure after a wake that+ // you are able to get back to the previous state you were in, i.e. all your browsers should not+ // be lost anymore"). His dashboard sat on 'browser lost' for a day because the crash-recovery+ // loop lived inside the minimalTouch-gated health interval - the THIRD critical feature silently+ // killed by that one gate (overlay enforcer, user-activity watcher, now recovery). Recovery runs+ // UNGATED here: minimal-touch forbids KILLING or RESTARTING a live Chrome behind the user's back;+ // it does not forbid resurrecting DEAD windows to restore the user's state - that restoration is+ // exactly what the user wants after a sleep. (Hung-Chrome teardown, which DOES kill, stays gated.)+ let _lastRecoveryTick = Date.now();+ setInterval(trackedLoop('sleep-wake-heal', 20000, async () => {+ if (process.platform !== 'win32' || pupSettings().sleepRecovery !== 'on') return;+ const now = Date.now();+ const gap = now - _lastRecoveryTick;+ _lastRecoveryTick = now;+ if (gap > 90000) console.log(`[wake] system appears to have SLEPT ~${Math.round(gap / 60000)}min (timer gap) β checking every session for lost browsers`);+ for (const [sid, sess] of [...sessions]) {+ if (!sess._lostBrowser || _healingIds.has(sid)) continue;+ const rec = _healRelaunch.get(sid) || { count: 0, last: 0, gaveUp: false };+ if (rec.count >= MAX_HEAL_ATTEMPTS) {+ if (now - rec.last < HEAL_COOLDOWN_MS) {+ if (!rec.gaveUp) {+ rec.gaveUp = true; _healRelaunch.set(sid, rec);+ try { sess._healGaveUp = true; } catch (e) {} // v1.9.305: now the reaper may finally clean it up+ console.log(`[heal] "${sid}" exceeded ${MAX_HEAL_ATTEMPTS} relaunch attempts β backing off ${Math.round(HEAL_COOLDOWN_MS / 60000)}m`);+ recordCleanup(`pup tried to bring your "${sid}" window back but it kept failing β pup has stopped retrying for now. Reopen it manually when ready; your logins are safe on disk.`);+ }+ continue;+ }+ rec.count = 0; rec.gaveUp = false;+ }+ _healingIds.add(sid);+ rec.count += 1; rec.last = now; _healRelaunch.set(sid, rec);+ console.log(`[heal] "${sid}" browser lost (crash or sleep) β recovering (attempt ${rec.count}/${MAX_HEAL_ATTEMPTS}) at its last URL`);+ try {+ const r = await recoverOrRelaunchSession({ sessionId: sid });+ if (r && r.session) {+ console.log(`[heal] "${sid}" recovered β window is back, signed in, where it left off`);+ recordCleanup(`pup auto-recovered your "${sid}" window (lost to a crash or the machine sleeping) β relaunched in the background, signed in, at where it left off.`);+ } else {+ console.log(`[heal] "${sid}" recovery did not resolve a live session yet β will retry next tick`);+ }+ } catch (e) {+ console.log(`[heal] "${sid}" recovery failed: ${e.message} β will retry next tick`);+ } finally {+ _healingIds.delete(sid);+ }+ }+ }), 20000);++ // v1.9.255 NO-FAULT WATCH (John: "if the window does get foreground by chrome itself or a focus+ // or some other reason, you do watch it for 20 seconds and put up a caption saying something like+ // 'it wasn't my fault, the javascript, or focus, or chrome caused the foreground. not me!'").+ // For 20s after each background open/nav, poll the OS foreground ONLY while a watch is armed+ // (zero steady-state cost). If the window surfaces without a granted foregroundReason: show the+ // disclaimer caption (2s), mark it foreground (the IRON LAW: whatever raised it, it stays up),+ // disarm. NEVER a knockback - that variant shipped as 1.9.252 and was rightly killed same day.+ setInterval(trackedLoop('notmine-caption-watch', 2500, async () => {+ // v2.0.292: fgWatch removed (its foreground-STEAL purpose died with off-screen birth; it was+ // caption-only, a 20s-scoped duplicate of notMineCaption). The 20s self-foreground caption now+ // honors notMineCaption β the single control for "a window fronted itself, Adom did not do it".+ if (process.platform !== 'win32' || pupSettings().notMineCaption !== 'on') return;+ try {+ const now = Date.now();+ const watched = [...sessions].filter(([, s2]) => s2._fgWatchUntil && s2._fgWatchUntil > now && !s2._foreground && !s2._lostBrowser);+ if (!watched.length) return;+ const fg = await osGetForegroundWindow();+ if (!fg) return;+ for (const [sid2, s2] of watched) {+ try {+ if (!s2._hwnd) { try { await resolveSessionHwndByBounds(s2, sid2); } catch (e) {} }+ if (!s2._hwnd || String(fg) !== String(s2._hwnd)) continue;+ s2._fgWatchUntil = 0;+ s2._foreground = true; // iron law: it is up, it stays up+ // v1.9.282 (John, furious the caption lingered): (a) the whole caption is a user-toggle+ // (default on) - if off, mark foreground and move on, silently; (b) DEDUPE hard - the+ // watch fires per-session, and rapid fires all reused the id 'pup-nofault', each resetting+ // AD's expiry so the caption never got its 2s and stayed forever. At most one no-fault+ // caption every 10s, so it can NEVER be held open by a re-issue storm.+ if (pupSettings().notMineCaption !== 'on') break;+ if (Date.now() - (globalThis._lastNoFaultCaptionAt || 0) < 10000) break;+ // v1.9.292 (John: "i tend to click the taskbar icon to bring the window to the foreground...+ // i only want to see this message if it was some other reason other than me raising it").+ // A taskbar click is RECENT user input; a JS/Chrome raise has none. If input happened within+ // the last 1.5s, assume the USER raised it and stay SILENT. Errs toward silence (a JS raise+ // that coincides with the user typing elsewhere is also suppressed β the polite direction).+ const _idle = await osIdleMs();+ if (_idle != null && _idle < 1500) { console.log(`[fg-watch] "${sid2}" surfaced with recent user input (${_idle}ms idle) β assuming USER raised it, no caption`); break; }+ globalThis._lastNoFaultCaptionAt = Date.now();+ console.log(`[fg-watch] "${sid2}" surfaced with no pup raise and no recent input (idle ${_idle}ms) β no-fault caption (2s, deduped)`);+ const _nmMsg = `This window was NOT foregrounded by Adom: JavaScript, a focus change, Chrome itself, or your own click raised "${sid2}". Not me!`;+ const _nmMs = captionMsFor(_nmMsg);+ pupCaption(_nmMsg, _nmMs, 'pup-nofault', _nmMs).catch(() => {});+ break;+ } catch (e) {}+ }+ } catch (e) {}+ }), 2500);++ // v1.9.243 (John: "you have red led's galore in your dashboard. why?"). The overlay ENFORCER was+ // dead on arrival: it sat inside the health interval ABOVE, whose first line is `if (minimalTouch())+ // return;` β and minimal-touch is the normal mode, so it never ran once. Overlays are explicitly+ // ALLOWED in minimal-touch (v1.9.191, gentle branding), so enforcement gets its OWN interval with no+ // minimal-touch gate. 10s cadence: a window is never knowingly bare for more than ~10s.+setInterval(trackedLoop('overlay-enforcer', 4000, async () => {+ if (process.platform !== 'win32') return;+ // v2.0.67: floaty-click harvest runs BEFORE the identity gate (the pencil must work even when+ // identity stamping is unsupported) and heartbeats so a dead sweep is visible in the log.+ global.__floatyHb = (global.__floatyHb || 0) + 1;+ if (global.__floatyHb % 15 === 1) console.log(`[floaty] sweep heartbeat #${global.__floatyHb} (${sessions.size} session(s), identitySupported=${_identitySupported})`);+ // v2.0.230 (John: "find code that loops way too much and chews up wasteful cpu"). This sweep used+ // to make THREE CDP round-trips per tab every 4s: harvest the pencil click, harvest a captured+ // login, and run a title-cleanup blob that was a measured NO-OP (both call sites pass an empty+ // owner, so the in-page code returns immediately). At ~10 tabs that was 7.5 renderer wakeups per+ // second, forever, on an idle machine. Now: ONE evaluate per tab returning both harvests, the+ // dead title sweep deleted, and tabs are skipped entirely when neither feature can fire.+ // v2.0.315: both harvests now arrive by BINDING (push) the instant they happen; this poll is only+ // the fallback for a page that somehow lacks the bindings, so it runs every 8th tick (~32s).+ global.__floatyPollTick = (global.__floatyPollTick || 0) + 1;+ if (global.__floatyPollTick % 8 === 0) try {+ const _capOn = pupSettings().captureLogins !== 'off';+ for (const [sidF, sF] of [...sessions]) {+ if (sF._lostBrowser || !(sF.tabs || []).length) continue;+ if (sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) continue;+ for (const tF of (sF.tabs || [])) {+ if (!tF || !tF.page) continue;+ let harvest = null;+ try {+ harvest = await Promise.race([+ tF.page.evaluate((wantCap) => {+ const out = {};+ const c = window.__pupFloatyClick; if (c) { window.__pupFloatyClick = null; out.click = c; }+ if (wantCap) { const l = window.__pupCapturedLogin; if (l) { window.__pupCapturedLogin = null; out.login = l; } }+ return (out.click || out.login) ? out : null;+ }, _capOn),+ new Promise(r => setTimeout(() => r(null), 2500)),+ ]);+ } catch (e) {}+ if (!harvest) continue;+ if (harvest.login && harvest.login.host && harvest.login.p) {+ const cap = harvest.login;+ try {+ await credentialVault.setCredential({ host: cap.host, username: cap.u || '', password: cap.p });+ console.log(`[cred] captured login for ${cap.host}${cap.u ? ` (${cap.u})` : ''} on "${sidF}" β keychain vault`);+ sF._pendingCredHint = `pup CAPTURED this login for "${cap.host}"${cap.u ? ` (username ${cap.u})` : ''} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit auto-fills and signs in with no Chrome dropdown and no foregrounding. This is how the shared adom-you identity grows β every login you do makes future ones automatic. Manage: pup_credentials {} to list, credential_delete {host} to remove, pup_configure {captureLogins:"off"} to stop capturing.`;+ } catch (e) { console.log(`[cred] capture store failed for ${cap.host}: ${e.message}`); }+ }+ if (harvest.click) {+ sF._annotateArmed = Date.now();+ console.log(`[floaty] "${sidF}" pencil clicked β arming capture watcher on ${tF.tabId}`);+ startAnnotation(sF, sidF, 'user-floaty', { tool: 'pen', tabId: tF.tabId }).catch(() => {});+ break;+ }+ }+ }+ } catch (e) {}+ if (_identitySupported !== true) return;+ try {+ for (const [sid2, s2] of [...sessions]) {+ if (s2._lostBrowser || !(s2.tabs || []).length) continue;+ // v1.9.329: re-stamp an unstamped base tile if needed, but DO NOT `continue` β the overlay+ // paint below is independent and MUST still run this cycle. The old `continue` meant an+ // unstamped window never got its overlay either, so it stayed a bare (now notched β hole)+ // button until it happened to stamp. Both are attempted every sweep now.+ if (pupSettings().aumidIcons === 'on' && !s2._curAppId && Date.now() - (s2._stampTriedAt || 0) > 20000) {+ s2._stampTriedAt = Date.now(); stampPupIdentity(sid2, { once: true }).catch(() => {});+ }+ if (pupSettings().overlayBadges !== 'on') continue;+ // v2.0.47: GROUND-TRUTH RE-ASSERT. Overlay state is write-only (Windows can't be asked+ // whether the badge is still there), and shell-side events beyond the progress bar can+ // silently clear it. Every 60s per window, void the paint memory so the block below+ // repaints unconditionally β one cheap ab call per window per minute buys "the badge you+ // see is the badge we believe", instead of memory drifting from the screen forever.+ // v2.0.230: was every 60s per window β one ab/PowerShell taskbar round-trip per window per+ // minute, forever, purely as a safety net against shell-side clears. Stretched to 5 minutes:+ // the real clears (button rebuild, progress bar, TaskbarCreated) all trigger a repaint by+ // EVENT already, so this only has to catch the exotic case, and it can be lazy about it.+ if (s2._appOverlay && Date.now() - (s2._overlayReassertAt || 0) > 300000) {+ s2._overlayReassertAt = Date.now();+ s2._lastPaintSig = null; s2._appOverlay = false;+ s2._overlayReassertQuiet = true; // v2.0.48: periodic re-assert, not a real bare-heal β log quietly+ }+ const bare = !s2._appOverlay;+ // A window whose page genuinely has NO favicon anywhere sits on the generic glyph forever, and+ // the enforcer would re-fetch + re-compose it every 10s for the life of the window (John saw the+ // churn in AD's log). Once we've re-attempted the upgrade a few times and the page STILL has no+ // favicon, accept the generic glyph as final and stop re-enforcing β until the tab NAVIGATES,+ // which clears the flag (framenavigated) so a new URL gets fresh upgrade attempts.+ const stuckGeneric = s2._appOverlay && s2._overlayIsGeneric && !s2._overlayGenericFinal;+ if (!bare && !stuckGeneric) continue;+ const last = s2._overlayEnforceAt || 0;+ // v1.9.329: a bare window is now a VISIBLE hole (the notch), so recover it fast β 4s, not 20s.+ // v1.9.337: a GENERIC overlay (favicon fetch missed, e.g. right after an icon-gen re-stamp+ // recreated the button) used to wait 90s before the enforcer re-fetched the real favicon β+ // long enough that a re-stamp left every window wearing the ugly generic globe for over a+ // minute (John: "what the fuck happened to the icons?"). Upgrade generic β real fast now.+ const cooldown = bare ? 4000 : 10000;+ if (Date.now() - last < cooldown) continue;+ s2._overlayEnforceAt = Date.now();+ const wasGeneric = stuckGeneric;+ applyAppOverlay(s2, sid2).then(okp => {+ if (!okp) return;+ if (wasGeneric && !s2._overlayIsGeneric) { s2._overlayGenericTries = 0; console.log(`[overlay] "${sid2}" UPGRADED generic β real favicon (enforcer re-fetched it)`); }+ else if (wasGeneric && s2._overlayIsGeneric) {+ s2._overlayGenericTries = (s2._overlayGenericTries || 0) + 1;+ if (s2._overlayGenericTries >= 3) { s2._overlayGenericFinal = true; console.log(`[overlay] "${sid2}" still has no favicon after 3 upgrade attempts β accepting generic glyph as final; will retry on next navigation`); }+ }+ else if (bare) {+ // v2.0.48 (John saw "ENFORCED (was bare)" spam every minute): the 60s ground-truth+ // re-assert is EXPECTED repainting, not a heal β don't announce it as one. Log a real+ // bare-heal loudly, and the periodic re-assert only once per ~10 min per window.+ if (s2._overlayReassertQuiet) {+ s2._overlayReassertQuiet = false;+ if (!s2._reassertLogAt || Date.now() - s2._reassertLogAt > 600000) { s2._reassertLogAt = Date.now(); console.log(`[overlay] "${sid2}" periodic ground-truth re-assert (5 min cycle; logged at most every 10 min)`); }+ } else console.log(`[overlay] "${sid2}" ENFORCED (was bare) β every pup window must wear its overlay`);+ }+ }).catch(() => {});+ }+ } catch (e) {}+ }), 4000); // v1.9.329: 4s sweep (was 10s) β a bare window is now a visible notch-hole; heal it fast++ let _healthTick = 0;+ setInterval(trackedLoop('health+user-activity', 15000, async () => {+ _healthTick++;+ if (_healthTick % 2 === 0) { try { await healthLegacyTick(); } catch (e) {} } // the old 30s loop's duties+ if (process.platform !== 'win32') return;+ // v1.9.185 minimal-touch: NEVER kill or relaunch the user's Chrome behind their back. John:+ // "you're just slapping monitoring on top of it so you can fucking restart it on me which is+ // infuriating." In minimal mode pup reports problems and lets the user decide.+ if (minimalTouch()) return;+ // (2) HUNG detection β probe each LIVE browser entry; on 2 misses, tear it down so its sessions+ // become `_lostBrowser` and fall into the relaunch path below.+ for (const [profileName, entry] of [...browsers]) {+ let alive = false;+ try {+ await Promise.race([+ entry.browser.pages(),+ new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 3500)),+ ]);+ alive = true;+ } catch (e) {}+ if (alive) { _healthStrikes.delete(profileName); continue; }+ const strikes = (_healthStrikes.get(profileName) || 0) + 1;+ _healthStrikes.set(profileName, strikes);+ if (strikes < 2) { console.log(`[health] "${profileName}" failed a liveness probe (strike ${strikes}/2) β watching`); continue; }+ _healthStrikes.delete(profileName);+ console.log(`[health] "${profileName}" HUNG (2 strikes) β discarding the dead handle so its windows can relaunch`);+ try { await entry.browser.close(); } catch (e) {}+ browsers.delete(profileName);+ try { await killOrphanChromesForProfile(profileName); } catch (e) {}+ for (const [, sess] of sessions) { if (sess.profileName === profileName) sess._lostBrowser = true; }+ }+ // (3) USER-ACTIVITY WATCHER + STUCK-OCCLUSION check (v1.9.180/181). One OS foreground read per+ // tick. When the foreground IS a pup window: (a) stamp it user-active + foreground (PERSISTED),+ // so no park/recovery/adoption path ever demotes a window the user chose to bring up β the+ // missing primitive behind "pup keeps yanking my windows" (PLACEMENT-INVARIANT item 1, John's+ // design, finally implemented); (b) run the stuck-occlusion unstick on that ONE window only.+ try {+ // v1.9.185 minimal-touch: no foreground polling, no unstick, no autonomous clicks.+ if (minimalTouch()) throw new Error('minimal-touch');+ if (sessions.size > 0) {+ const fg = await osGetForegroundWindow();+ if (fg) {+ for (const [sid, sess] of [...sessions]) {+ try {+ const hwnd = await cachedSessionHwnd(sid);+ if (!hwnd || String(fg) !== String(hwnd)) continue;+ // v2.0.261: do NOT credit the user for a window that was just born. Chrome+ // self-activates a newly created on-screen window, and this observer running inside+ // that moment would stamp _userActiveAt/_foreground β permanently marking an+ // unasked-for window as "the user's", so nothing would ever move it off their work.+ if (inBirthGrace(sess)) continue;+ sess._userActiveAt = Date.now();+ if (!sess._foreground) {+ sess._foreground = true;+ console.log(`[useractive] "${sid}" observed as the USER'S foreground window β marking foreground (persisted); pup will not re-park it`);+ try { let u = null; try { u = sess.page && sess.page.url(); } catch (e) {} saveSessionFile(sid, sess.profileName, u); } catch (e) {}+ }+ await unstickIfHiddenWhileFront(sid, sess, fg, hwnd);+ break; // only one window can be foreground+ } catch (e) {}+ }+ }+ }+ } catch (e) {}+ }), 15000);+ // v1.9.166: taskbar-progress reconciler β clear any bar that is lit but shouldn't be.+ // v1.9.181: only for sessions whose bar is ACTUALLY lit (_progressSince set). The old loop fired an+ // AD desktop_taskbar call (a PowerShell round-trip on the desktop) for EVERY session EVERY 20s+ // forever β with 10 windows that was 30 pointless PS invocations a minute pounding the machine pup+ // is supposed to stay invisible on. Steady-state is now zero calls.+ setInterval(trackedLoop('progress-reconciler', 20000, async () => {+ if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress') return; // v2.0.292+ for (const [sid, sess] of [...sessions]) {+ const st = _busyState.get(sid);+ if ((!st || !st.busy) && sess && sess._progressSince) { try { await setSessionProgress(sid, false); } catch (e) {} }+ }+ }), 20000);+ // v1.9.145 (John found v21-review stranded at -32000 all day) β PLACEMENT SWEEP. The healer+ // (healOffscreenWindow) existed but only ran from a few verb paths, so a long-lived window that+ // predated the on-screen park kept its off-screen geometry FOREVER β made worse by the reparent:+ // windows now survive bridge restarts, so stale geometry survives too. Assert placement for the+ // whole standing population at boot and every 2 minutes; every repair is reported via the+ // cleanup queue so a rescue is never silent. Healthy windows are never touched (the healer+ // no-ops when bounds are on-screen).+ // v2.0.314 (audit): the per-session CDP placement sweep (120s, a CDP session + getWindowBounds per+ // window per tick) is DELETED β sweepVisibility below asserts the same off-screen invariant for+ // the whole population from the OS side, every 30s, in-process.++ // v2.0.251 (John: "why can't i open this pup window? its like you did that off-screen thing again+ // and now when i go to click on it as a human you fuck me over"). The placement sweep above asserts+ // GEOMETRY β it catches a window stranded off-screen, and it explicitly does NOT touch a healthy or+ // minimized one. But a window can be invisible without being mis-placed: pup's taskbar nudge HIDES+ // the window for 80ms to force the shell to rebuild its button, and any interruption in that gap+ // (the 20s execFile kill, a crash, a sleep) left it hidden with nothing anywhere in this codebase+ // watching for it. Reproduced live on AdomLapper: after the hide half alone, IsWindowVisible=false+ // and the taskbar button reveals nothing to a human click β exactly John's report.+ // So placement is no longer the only invariant: VISIBILITY is asserted too, for the whole standing+ // population. Safe by construction β a human can minimize a window but can never hide one, so a+ // hidden pup window is always pup's own mess to clean up (see ensureWindowShown).+ // Deliberately NOT minimalTouch-gated, unlike every other sweep here: minimal-touch exists to keep+ // pup's automatic machinery off the user's taskbar, and a window the user cannot open is not+ // "minimal touch", it is a broken window. Repairing it is the one automatic act that only ever+ // gives the user back something pup took.+ const sweepVisibility = async (cause) => {+ // Never race a nudge's own 80ms hide gap β un-hiding mid-nudge would silently defeat the button+ // rebuild. _nudgeUntil covers the in-flight window; a nudge that STRANDED a window has long since+ // passed it, so the repair still lands (at worst one sweep later).+ if (Date.now() < _nudgeUntil) return;+ // Assert the invariant over the POPULATION, by owning process β deliberately NOT per session.+ // Two earlier cuts of this sweep repaired nothing because they went looking for a session's+ // handle first: windowHwnd -> bestSessionHwnd falls back to enumerate-and-wiggle, which only ever+ // sees VISIBLE windows (circular β 28 straight "NOT resolved" misses on a hidden window sitting+ // right there), and the cached/birth handles are exactly what a window in trouble tends to lack+ // (a park-failed window never persisted one, and a pup restart clears the birth capture).+ // The property that actually matters needs no session mapping at all: NO titled top-level window+ // of a pup-owned Chrome process may be hidden. One enumeration repairs every stranded window,+ // including ones pup has lost track of, and costs one PowerShell per sweep instead of one per+ // session. Titled + WS_CAPTION only, so Chrome's own untitled helper windows are never touched.+ const pids = [...pupBrowserPidSet()].filter(Boolean);+ if (!pids.length) return;+ // v2.0.314: in-process (koffi) β microseconds, no spawn. The PowerShell below is the fallback.+ if (win32.available()) {+ const fixes = win32.fixHiddenOrOffscreen(pids) || [];+ for (const f of fixes) {+ const t = String(f.title || '').slice(0, 60);+ if (f.kind === 'MOVED-ONSCREEN') { console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`moved a pup window back ON-SCREEN ("${t}") β it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {} }+ else if (f.kind === 'MOVE-FAILED') console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is off-screen and would NOT move back β reporting rather than pretending it is fine`);+ else if (f.kind === 'RESHOWN') { console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${t}") β its taskbar button would have revealed nothing when clicked`); } catch (e) {} }+ else console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is hidden and would NOT re-show β reporting rather than pretending it is fine`);+ }+ return;+ }+ const ps = `$s=@'+using System; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;+public static class VS {+ public delegate bool EnumProc(IntPtr h, IntPtr l);+ [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);+ [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);+ [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);+ [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);+ [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);+ [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);+ [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);+ [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);+ [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);+ [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);+ [DllImport("user32.dll")] public static extern bool GetWindowPlacement(IntPtr h, ref WINDOWPLACEMENT p);+ [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }+ [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }+ [StructLayout(LayoutKind.Sequential)] public struct WINDOWPLACEMENT {+ public int length; public int flags; public int showCmd;+ public POINT ptMinPosition; public POINT ptMaxPosition; public RECT rcNormalPosition; }+ public static List<string> Fix(int[] pids) {+ var outp = new List<string>();+ EnumWindows((h, l) => {+ int pid; GetWindowThreadProcessId(h, out pid);+ if (Array.IndexOf(pids, pid) < 0) return true;+ if ((GetWindowLong(h, -16) & 0x00C00000) == 0) return true; // WS_CAPTION β real windows only+ var sb = new StringBuilder(300); GetWindowText(h, sb, 300);+ if (sb.Length == 0) return true;+ if (!IsWindowVisible(h)) {+ ShowWindow(h, 4); // SW_SHOWNOACTIVATE+ outp.Add((IsWindowVisible(h) ? "RESHOWN|" : "RESHOW-FAILED|") + h + "|" + sb.ToString());+ return true;+ }+ // v2.0.258: HIDDEN was only half of "the user cannot open this window". OFF-SCREEN is the+ // other half, and it is the one that stranded John's "ab ralph" window at -21845,-21845 behind+ // a live taskbar button. Same invariant, same repair: a minimized window reports the -32000+ // placeholder rect legitimately, so judge position from the RESTORE rect, never GetWindowRect.+ if (IsIconic(h)) return true; // minimized is the user's choice+ WINDOWPLACEMENT wp = new WINDOWPLACEMENT();+ wp.length = Marshal.SizeOf(wp);+ if (!GetWindowPlacement(h, ref wp)) return true;+ int L = wp.rcNormalPosition.L, T = wp.rcNormalPosition.T;+ int W = wp.rcNormalPosition.R - L, H = wp.rcNormalPosition.B - T;+ int vx = GetSystemMetrics(76), vy = GetSystemMetrics(77), vw = GetSystemMetrics(78), vh = GetSystemMetrics(79);+ // Off-screen = the window does not overlap the virtual desktop at all.+ if (L < vx + vw && L + W > vx && T < vy + vh && T + H > vy) return true;+ int nw = W > 200 && W <= vw ? W : (vw - 20), nh = H > 200 && H <= vh ? H : (vh - 20);+ SetWindowPos(h, IntPtr.Zero, vx + 10, vy + 10, nw, nh, 0x14); // NOACTIVATE|NOZORDER+ RECT chk; GetWindowRect(h, out chk);+ bool fixedNow = chk.L < vx + vw && chk.R > vx && chk.T < vy + vh && chk.B > vy;+ outp.Add((fixedNow ? "MOVED-ONSCREEN|" : "MOVE-FAILED|") + h + "|" + sb.ToString());+ return true;+ }, IntPtr.Zero);+ return outp;+ }+}+'@; Add-Type -TypeDefinition $s; [VS]::Fix(@(${pids.join(',')})) | ForEach-Object { $_ }`;+ await new Promise((resolve) => {+ try {+ execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 25000 }, (err, stdout) => {+ for (const line of String(stdout || '').trim().split(/\r?\n/)) {+ const m = /^(RESHOWN|RESHOW-FAILED|MOVED-ONSCREEN|MOVE-FAILED)\|(\d+)\|(.*)$/.exec(line.trim());+ if (!m) continue;+ if (m[1] === 'MOVED-ONSCREEN') {+ console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} sweep β its taskbar button was live while the window sat where no click could reach it`);+ try { recordCleanup(`moved a pup window back ON-SCREEN ("${m[3].slice(0, 60)}") β it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {}+ } else if (m[1] === 'MOVE-FAILED') {+ console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is off-screen and would NOT move back β reporting rather than pretending it is fine`);+ } else if (m[1] === 'RESHOWN') {+ console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} visibility sweep β a hidden window's taskbar button reveals nothing to a human click`);+ try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${m[3].slice(0, 60)}") β its taskbar button would have revealed nothing when clicked`); } catch (e) {}+ } else {+ console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is hidden and would NOT re-show β reporting rather than pretending it is fine`);+ }+ }+ resolve();+ });+ } catch (e) { resolve(); }+ });+ };+ // v2.0.258: every 30s, not 120s. This sweep is the last line of defence for "the user clicked the+ // taskbar button and nothing came up", and John hit that twice in one day β two minutes of an+ // unopenable window is not a safety net, it is the bug with a longer fuse. One PowerShell per+ // 30s across the whole population is cheap next to that.+ setTimeout(() => { sweepVisibility('startup').catch(() => {}); }, 8000);+ setInterval(trackedLoop('visibility-sweep', 30000, () => sweepVisibility('periodic').catch(() => {})), 30000);++ // v1.9.89: reap ORPHANED pup chrome/renderers β any process under a pup-profiles dir whose profile+ // has no live session (a previous bridge crash left them, or renderers that outlived a close and are+ // pegging a core). Boot sweep runs AFTER recovery has settled so it never kills a window recovery+ // just reconnected; then a slow periodic net. Spares every live session BY PROFILE, so it can only+ // ever hit true orphans β never a session's own renderer children.+ setTimeout(() => { reapOrphanPupChromes().catch(() => {}); }, 15000);+ // v2.0.230: skip the PowerShell process enumeration entirely when there is nothing that could+ // have leaked (no live sessions AND no session files on disk) β it was ~20 PowerShell spawns an+ // hour on a totally idle machine.+ setInterval(trackedLoop('reap-orphan-chromes', 180000, () => {+ if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }+ reapOrphanPupChromes().catch(() => {});+ }), 180000);++ // v2.0.129: reap LEAKED isolated profile DIRECTORIES (iso-* throwaways whose window is long gone).+ // Boot pass waits 60s so session recovery has fully settled first (never delete a profile an iso+ // session is mid-recovery on); then a slow 15-min net drains the backlog a few at a time.+ setTimeout(() => { reapLeakedIsolatedProfiles().catch(() => {}); }, 60000);+ setInterval(trackedLoop('reap-leaked-profiles', 900000, () => {+ if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }+ reapLeakedIsolatedProfiles().catch(() => {});+ }), 15 * 60 * 1000);++ // (audit 2.0.314) the 5-minute stale-session-file cleanup was a byte-for-byte duplicate of the+ // tail of the 30s health loop; deleted.+};++// v2.0.16 (#557 ask 3, pup-side defense): EADDRINUSE SELF-HEAL. The #557 permanent outage was ab's+// reaper losing track of a live bridge process (bridge_kill returned killedPids:[]): the orphan kept+// port 64230, so every replacement ab spawned died on the bind conflict, forever, and recovery took a+// manual taskkill. ab's real fix (reap by port owner / child tree) is filed against adom/adom-bridge;+// until it lands, the REPLACEMENT heals it: on EADDRINUSE, probe the holder's /health β if (and ONLY+// if) it identifies as a pup puppeteer bridge, it is by definition an orphan (ab only spawns a+// replacement when the holder is off its books), so kill that ONE pid and retry the bind. A holder+// that is NOT a pup bridge is never touched: log and exit. Convergence: if ab tracks us after the+// heal, routing resumes; if ab already gave up on us, the NEXT spawn heals against us and gets+// tracked β one extra cycle, never a loop (3-retry cap either way).+let _bindRetries = 0;+let _eaccesTries = 0;+server.on('error', (err) => {+ const code = err && err.code;+ // v2.0.231 (John's machine, 2026-08-18): EACCES on a loopback port is almost never a permission+ // problem β on Windows it means the port sits inside a RESERVED range (Hyper-V/WinNAT grabs+ // blocks like 64160-64259 at boot). ab assigns the port, so pup cannot fix routing by itself, and+ // exiting produced an invisible crash-loop: the bridge died on every spawn until the machine was+ // rebooted, with nothing but "fatal listen error (EACCES)" to go on. Now pup NAMES the cause,+ // prints the exact remedy, and binds a fallback port anyway so its own HTTP surface (dashboard,+ // /health) is reachable and the port file tells the truth about where it landed.+ // v2.0.234 (John's AdomLapper, 2026-08-18): EACCES on a loopback port means the port sits inside a+ // Windows RESERVED range β Hyper-V/WinNAT grabs blocks (here 64160-64259, which swallowed ab's+ // assigned 64230) at every boot. Exiting made this a silent permanent outage: ab respawned on the+ // same port, hit the same wall, and after 3 tries went into backoff, so pup stayed dead until+ // someone ran an elevated `net stop winnat`. Now pup STAYS ALIVE and re-attempts the SAME port on+ // a backoff, so the instant the range is released (winnat restart, Docker/WSL shutdown, reboot)+ // it binds and is healthy on the port ab is already routing to β no respawn, no human, no gap.+ if (code === 'EACCES' && process.platform === 'win32') {+ _eaccesTries++;+ if (_eaccesTries === 1) {+ let ranges = '';+ try { ranges = execFileSync('netsh', ['int', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'], { timeout: 8000, windowsHide: true }).toString(); } catch (e) {}+ let inRange = null;+ for (const m of ranges.matchAll(/^\s*(\d+)\s+(\d+)/gm)) {+ const a = Number(m[1]), b = Number(m[2]);+ if (PORT >= a && PORT <= b) { inRange = `${a}-${b}`; break; }+ }+ console.error(`[bind] EACCES on 127.0.0.1:${PORT}${inRange ? ` β that port is inside the Windows RESERVED range ${inRange}` : ''}.`);+ console.error('[bind] Cause: Windows (Hyper-V/WinNAT) reserves port blocks at boot. Not a pup bug, not a permissions problem, and NOT fixable from this process.');+ console.error('[bind] Release it (elevated): net stop winnat && net start winnat β or reboot. Filed as adom/adom-bridge#83 so ab stops assigning ports inside these ranges.');+ console.error(`[bind] pup will STAY UP and retry ${PORT} every 15s; it binds automatically the moment the range is released.`);+ } else if (_eaccesTries % 20 === 0) {+ console.error(`[bind] still cannot bind ${PORT} (Windows reserved range) β ${_eaccesTries} attempts; retrying every 15s.`);+ }+ setTimeout(() => {+ try {+ server.listen(PORT, BIND_HOST, () => {+ console.error(`[bind] RECOVERED β the Windows reservation on ${PORT} was released after ${_eaccesTries} attempt(s); pup is live on its assigned port with no respawn needed.`);+ _eaccesTries = 0;+ try { _onPupBound(); } catch (e) {}+ });+ } catch (e) {}+ }, 15000);+ return;+ }+ if (code !== 'EADDRINUSE' || process.platform !== 'win32' || _bindRetries >= 3) {+ console.error(`[bind] fatal listen error (${code || (err && err.message)}) β exiting so the supervisor respawns cleanly`);+ process.exit(1);+ }+ _bindRetries++;+ (async () => {+ try {+ const health = await Promise.race([+ fetch(`http://127.0.0.1:${PORT}/health`).then(r => r.json()).catch(() => null),+ new Promise(r => setTimeout(() => r(null), 2500)),+ ]);+ if (!health || health.bridge !== 'puppeteer') {+ console.error(`[bind] port ${PORT} is held by a NON-pup process β refusing to touch it; exiting`);+ process.exit(1);+ }+ const { execFileSync } = require('child_process');+ const pidOut = execFileSync('powershell.exe', ['-NoProfile', '-Command',+ `Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess`],+ { encoding: 'utf8', timeout: 8000, windowsHide: true }).trim();+ const holderPid = Number(pidOut) || 0;+ if (!holderPid || holderPid === process.pid) {+ console.error(`[bind] EADDRINUSE but no resolvable holder pid (got "${pidOut}") β retrying bind blind`);+ } else {+ console.error(`[bind] port ${PORT} held by ORPHANED sibling pup bridge v${health.version || '?'} (pid ${holderPid}) β ab spawned this process as its replacement, so the holder is off ab's books. Killing that ONE pid and retrying (attempt ${_bindRetries}/3).`);+ execFileSync('taskkill', ['/PID', String(holderPid), '/F'], { timeout: 8000, windowsHide: true });+ }+ await new Promise(r => setTimeout(r, 1200));+ server.listen(PORT, BIND_HOST, _onPupBound);+ } catch (e) {+ console.error(`[bind] self-heal attempt ${_bindRetries} failed: ${e.message} β exiting for a clean respawn`);+ process.exit(1);+ }+ })();+});++server.listen(PORT, BIND_HOST, _onPupBound);++// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ+// RECORDING β desktop (HUD) and tab (CDP screencast)+// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ++const RECORDINGS_DIR = path.join(__dirname, 'recordings');+fs.mkdirSync(RECORDINGS_DIR, { recursive: true });+const RECORDER_PROFILE_DIR = path.join(PROFILES_DIR, '_recorder');++// βββ ffmpeg detection (used to add Duration tag to MediaRecorder WebMs) ββ+// Probe at module-load so we know upfront whether the remux path works.+// Cached for the life of the process. Try common Windows install dirs in+// addition to PATH so a winget/scoop/choco install is found even if PATH+// hasn't been refreshed in the bridge's environment.+let FFMPEG_PATH = null;+// v1.8.20: re-callable (was a boot-only IIFE). Recording verbs call it LAZILY when+// FFMPEG_PATH is still null, so a user who installs ffmpeg AFTER the bridge started can+// record immediately β no bridge restart needed. Re-scans cleanly each call.+const SCREEN_RECORDER_HINT = 'This is pup\'s CHROME screen-share recorder (getDisplayMedia -> WebM VP9, consent = the visible recorder HUD, system audio + mic mixing, cross-platform). Canonical names: pup_screen_record_start/stop/status/list + pup_recorder_open/close (pup-bridge#94). ab-core serves desktop_record_* NATIVELY since ab 2.1.36 (WGC + system audio to MP4, no browser, no picker): use that for a whole-monitor or single-window recording; use this Chrome recorder when you need the OS share picker, tab audio, WebM, or a non-Windows box.';+function detectFFmpeg() {+ FFMPEG_PATH = null;+ const { spawnSync } = require('child_process');+ // 1. PATH+ try {+ const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+ if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }+ } catch {}+ // 2. Common macOS install paths (Homebrew arm64/x64, MacPorts) β AD spawns the bridge+ // outside a login shell, so /opt/homebrew/bin is usually NOT on PATH here.+ if (process.platform === 'darwin') {+ for (const p of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/local/bin/ffmpeg']) {+ try {+ if (fs.existsSync(p)) {+ const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+ if (r.status === 0) { FFMPEG_PATH = p; return; }+ }+ } catch {}+ }+ }+ // 3. Common Windows install paths+ if (process.platform === 'win32') {+ const candidates = [+ 'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',+ 'C:\\ffmpeg\\bin\\ffmpeg.exe',+ 'C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe',+ `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe`,+ `${process.env.USERPROFILE}\\scoop\\shims\\ffmpeg.exe`,+ ];+ for (const p of candidates) {+ try {+ if (fs.existsSync(p)) {+ const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+ if (r.status === 0) { FFMPEG_PATH = p; return; }+ }+ } catch {}+ }+ // 3. Glob the WinGet directory (version number changes between releases)+ try {+ const winget = `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages`;+ if (fs.existsSync(winget)) {+ for (const e of fs.readdirSync(winget)) {+ if (e.startsWith('Gyan.FFmpeg')) {+ const sub = path.join(winget, e);+ const walk = (dir) => {+ for (const f of fs.readdirSync(dir, { withFileTypes: true })) {+ if (f.isDirectory()) {+ const r = walk(path.join(dir, f.name));+ if (r) return r;+ } else if (f.name === 'ffmpeg.exe') {+ return path.join(dir, f.name);+ }+ }+ return null;+ };+ const found = walk(sub);+ if (found) { FFMPEG_PATH = found; return; }+ }+ }+ }+ } catch {}+ }+ return FFMPEG_PATH;+}+detectFFmpeg();+if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);+else console.warn(`[recorder] ffmpeg NOT FOUND β desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via '${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}' to enable Duration metadata.`);++/**+ * Remux a WebM file in place to add a SegmentDuration tag.+ * Uses `ffmpeg -i in.webm -c copy out.webm` β copies the stream packets+ * unchanged (no re-encode), and the matroska muxer writes a complete+ * SegmentInfo with Duration. Fast (~100-300ms for typical clips).+ * Atomic: writes to a temp file then renames so a partial run can't+ * corrupt the original.+ */+async function remuxWebmAddDuration(filePath) {+ if (!FFMPEG_PATH) detectFFmpeg(); // lazy re-scan: ffmpeg may have been installed since boot+ if (!FFMPEG_PATH) throw new Error('ffmpeg not found on PATH or in common Windows install dirs');+ const { spawn } = require('child_process');+ const tmpPath = filePath + '.remux.tmp';+ await new Promise((resolve, reject) => {+ const proc = spawn(FFMPEG_PATH, [+ '-y', // overwrite tmp if it exists+ '-loglevel', 'error', // quiet but still surface errors+ '-i', filePath,+ '-c', 'copy', // no re-encode β just rewrite container+ '-f', 'webm',+ tmpPath,+ ], { stdio: ['ignore', 'ignore', 'pipe'] });+ let stderr = '';+ proc.stderr.on('data', b => { stderr += b.toString(); });+ proc.on('error', reject);+ proc.on('exit', code => {+ if (code === 0) resolve();+ else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));+ });+ });+ // Atomic replace: rename tmp β original. fs.renameSync replaces on Win10+.+ fs.renameSync(tmpPath, filePath);+}++// Desktop recorder (singleton; one HUD across the whole bridge)+const desktopRecorder = {+ browser: null,+ page: null,+ windowId: null, // Chrome windowId for Browser.setWindowBounds+ cdp: null, // CDP session to recorder page+ reason: null,+ hudOpenedAt: null,+ current: null, // active recording state, see below+ clipsThisSession: [], // [{recordingId, filePath, sizeKB, durationMs, reason}]+ recCounter: 0,+ idleTimer: null,+};++// Tab recordings (concurrent; many at once)+// Map<recordingId, {sessionId, tabId, dirPath, framesDir, concatStream, manifestFrames,+// cdp, started_at, frameCount, fps, quality, stopTimer, stopping}>+const tabRecordings = new Map();+let tabRecCounter = 0;++// βββ Desktop recorder helpers βββββββββββββββββββββββββββββββββββββββββββββββ++function recorderHtmlUrl(reason) {+ const file = path.join(__dirname, 'recorder.html').replace(/\\/g, '/');+ const params = new URLSearchParams({ reason: reason || '' }).toString();+ return `file:///${file}?${params}`;+}++async function ensureRecorderWindowOpen(reason) {+ if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {+ throw new Error('reason is required β explain to the user why you\'re recording');+ }+ // If browser+page exist and are alive, just update the reason+ if (desktopRecorder.browser && desktopRecorder.browser.isConnected() && desktopRecorder.page) {+ try {+ await desktopRecorder.page.evaluate((r) => window.setSessionReason && window.setSessionReason(r), reason);+ desktopRecorder.reason = reason;+ return;+ } catch {+ // Page died; tear down and respawn+ try { await desktopRecorder.browser.close(); } catch {}+ desktopRecorder.browser = null;+ desktopRecorder.page = null;+ desktopRecorder.cdp = null;+ }+ }++ fs.mkdirSync(RECORDER_PROFILE_DIR, { recursive: true });++ // Single fixed size for the HUD β large enough that every state's content+ // fits without dynamic resizing. Position is set AFTER launch via CDP using+ // screen.availWidth/availHeight (taskbar-aware, DPI-aware) β Chrome's+ // --window-position arg uses physical pixels and doesn't account for the+ // taskbar, which made the HUD half off-screen on non-1920Γ1080 displays.+ const winW = 360, winH = 420;++ console.log(`[recorder] Spawning HUD window (reason="${reason}")`);++ const launchArgs = [+ `--app=${recorderHtmlUrl(reason)}`,+ `--window-size=${winW},${winH}`,+ // No --window-position; we anchor bottom-right after the page loads+ '--use-fake-ui-for-media-stream',+ '--auto-select-desktop-capture-source=Entire screen',+ '--enable-features=GetDisplayMediaSet',+ '--auto-accept-this-tab-capture',+ '--no-sandbox',+ '--no-first-run',+ '--no-default-browser-check',+ ...(process.platform === 'darwin' ? ['--use-mock-keychain'] : []), // 2.0.720: see main launch+ // v2.0.293: MediaRouter's mDNS binds 0.0.0.0:5353 β a non-loopback listener that pops the+ // Windows Firewall dialog (per-exe). pup's hard rule is ZERO firewall prompts, so the+ // recorder Chrome disables it exactly like the main launch path (one flag, comma-merged β+ // never add a second --disable-features here, Chrome only honors the last).+ '--disable-features=Translate,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider',+ '--media-router=0',+ `--user-data-dir=${RECORDER_PROFILE_DIR}`,+ ];++ desktopRecorder.browser = await puppeteer.launch({+ headless: false,+ defaultViewport: null,+ ignoreDefaultArgs: ['--enable-automation'],+ args: launchArgs,+ });++ // Find the recorder page (the only page in this Chrome)+ const deadline = Date.now() + 5000;+ while (Date.now() < deadline) {+ const pages = await desktopRecorder.browser.pages();+ if (pages.length > 0) {+ desktopRecorder.page = pages[0];+ break;+ }+ await new Promise(r => setTimeout(r, 50));+ }+ if (!desktopRecorder.page) throw new Error('Recorder window did not produce a page');++ await desktopRecorder.page.waitForFunction(() => typeof window.startRecording === 'function', { timeout: 10000 });++ // ββ Bug 1 fix: anchor bottom-right via screen.availWidth/availHeight ββ+ // Chrome's --window-position uses physical pixels and ignores the taskbar,+ // so a hardcoded fallback can land the HUD off-screen on any monitor that+ // isn't 1920Γ1080. Query the page's `screen.avail*` (DIPs, taskbar-aware)+ // and use CDP Browser.setWindowBounds to position it precisely.+ try {+ const cdp = await desktopRecorder.page.target().createCDPSession();+ desktopRecorder.cdp = cdp;+ const wInfo = await cdp.send('Browser.getWindowForTarget', {});+ desktopRecorder.windowId = wInfo.windowId;++ const dims = await desktopRecorder.page.evaluate(() => ({+ availLeft: screen.availLeft || 0,+ availTop: screen.availTop || 0,+ availWidth: screen.availWidth,+ availHeight: screen.availHeight,+ outerWidth: window.outerWidth,+ outerHeight: window.outerHeight,+ }));+ const margin = 16;+ const winW = Math.min(dims.outerWidth, dims.availWidth);+ const winH = Math.min(dims.outerHeight, dims.availHeight);+ const left = Math.max(dims.availLeft, dims.availLeft + dims.availWidth - winW - margin);+ const top = Math.max(dims.availTop, dims.availTop + dims.availHeight - winH - margin);+ await cdp.send('Browser.setWindowBounds', {+ windowId: wInfo.windowId,+ bounds: { left, top, width: winW, height: winH, windowState: 'normal' },+ });+ console.log(`[recorder] anchored bottom-right: (${left},${top}) ${winW}x${winH} (work area ${dims.availWidth}x${dims.availHeight})`);+ } catch (e) {+ console.log(`[recorder] anchor failed (non-fatal): ${e.message}`);+ }++ // Wire exposed functions+ await desktopRecorder.page.exposeFunction('_saveChunk', (b64) => {+ if (!desktopRecorder.current || !desktopRecorder.current.fd) return;+ try {+ fs.writeSync(desktopRecorder.current.fd, Buffer.from(b64, 'base64'));+ } catch (e) {+ console.error(`[recorder] saveChunk failed: ${e.message}`);+ }+ });++ await desktopRecorder.page.exposeFunction('_recordingStopped', async ({ sentBytes, durationMs }) => {+ const cur = desktopRecorder.current;+ if (!cur) return;+ try { fs.closeSync(cur.fd); } catch {}+ cur.fd = null;++ // ββ Bug 3a fix: ffmpeg remux to write SegmentDuration ββ+ // MediaRecorder writes a "live" WebM with no Duration element, so+ // ffprobe reports duration=N/A. Earlier attempts at byte-level EBML+ // patching corrupted the Tracks element / SeekHead offsets and made+ // the resulting files unplayable (nb_streams=0). Ditched.+ //+ // The reliable fix is `ffmpeg -i in.webm -c copy out.webm` β copies+ // streams (no re-encode, fast: ~50-200 MB/s), and the muxer writes+ // a proper SegmentInfo with Duration. We do this asynchronously so+ // the UI's "Saved" state appears immediately; the patched file is+ // ready by the time the user pulls it.+ //+ // If ffmpeg isn't on PATH we leave the file untouched. It's still a+ // valid (playable) WebM β just no Duration tag in the container.+ // Telemetry: log clearly so the user knows whether it ran.+ try {+ await remuxWebmAddDuration(cur.filePath);+ console.log(`[recorder] ffmpeg remux added Duration tag to ${cur.filePath}`);+ } catch (e) {+ console.error(`[recorder] ffmpeg remux failed (file still plays, ffprobe will report N/A): ${e.message}`);+ }++ let sizeKB = 0;+ try { sizeKB = Math.round(fs.statSync(cur.filePath).size / 1024); } catch {}+ cur.durationMs = durationMs;+ cur.sizeKB = sizeKB;+ cur.stoppedAt = Date.now();+ // Write sidecar metadata+ try {+ const sidecar = {+ recordingId: cur.recordingId,+ filePath: cur.filePath,+ startedAt: cur.startedAt,+ stoppedAt: cur.stoppedAt,+ durationMs,+ sizeKB,+ sizeBytes: sentBytes,+ fps: cur.fps,+ audio: cur.audio,+ monitor: cur.monitor,+ reason: cur.reason,+ stopReason: cur.stopReason || 'requested',+ };+ fs.writeFileSync(cur.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));+ } catch (e) {+ console.error(`[recorder] sidecar write failed: ${e.message}`);+ }+ // Add to session clip list+ desktopRecorder.clipsThisSession.push({+ recordingId: cur.recordingId, filePath: cur.filePath,+ sizeKB, durationMs, reason: cur.reason,+ });+ desktopRecorder.current = null;+ console.log(`[recorder] Clip ${cur.recordingId} stopped: ${sizeKB}KB, ${durationMs}ms`);+ // Tell page to show Saved state+ try {+ await desktopRecorder.page.evaluate((clip) => window.showSaved && window.showSaved(clip), {+ filename: path.basename(cur.filePath),+ sizeBytes: sentBytes,+ durationMs,+ });+ } catch {}+ // Resolve any waiter+ if (cur.stopResolver) cur.stopResolver();+ });++ await desktopRecorder.page.exposeFunction('_resizeWindow', async ({ width, height }) => {+ try {+ const cdp = desktopRecorder.cdp || (desktopRecorder.cdp = await desktopRecorder.page.target().createCDPSession());+ const target = desktopRecorder.page.target();+ const targetId = target._targetId || target.target?.()?._targetId || (await target.createCDPSession()).then(()=>{}); // best effort+ // Use Puppeteer's underlying CDP: simpler to use Browser.getWindowForTarget by sending raw CDP+ const { windowId } = await cdp.send('Browser.getWindowForTarget', {});+ desktopRecorder.windowId = windowId;+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width, height } });+ } catch (e) {+ console.log(`[recorder] resize failed (non-fatal): ${e.message}`);+ }+ });++ await desktopRecorder.page.exposeFunction('_recorderClosed', () => {+ console.log(`[recorder] Page reports closing`);+ });++ await desktopRecorder.page.exposeFunction('_openRecordingsFolder', () => {+ try {+ execSync(`explorer "${RECORDINGS_DIR}"`, { stdio: 'ignore' });+ } catch {}+ });++ // Detect window-close as a stop signal+ desktopRecorder.browser.on('disconnected', () => {+ console.log(`[recorder] Browser disconnected`);+ if (desktopRecorder.current) {+ // Best-effort: mark stopped, write whatever we have+ try { if (desktopRecorder.current.fd) fs.closeSync(desktopRecorder.current.fd); } catch {}+ if (desktopRecorder.current.stopResolver) desktopRecorder.current.stopResolver();+ desktopRecorder.current = null;+ }+ desktopRecorder.browser = null;+ desktopRecorder.page = null;+ desktopRecorder.cdp = null;+ desktopRecorder.reason = null;+ desktopRecorder.clipsThisSession = [];+ });++ desktopRecorder.reason = reason;+ desktopRecorder.hudOpenedAt = Date.now();+ desktopRecorder.clipsThisSession = [];+}++async function closeRecorderWindow() {+ if (desktopRecorder.current) {+ // Stop active recording first+ try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}+ // Wait briefly for stop to flush+ const deadline = Date.now() + 3000;+ while (desktopRecorder.current && Date.now() < deadline) {+ await new Promise(r => setTimeout(r, 50));+ }+ }+ const summary = {+ clipCount: desktopRecorder.clipsThisSession.length,+ totalSizeKB: desktopRecorder.clipsThisSession.reduce((a, c) => a + (c.sizeKB || 0), 0),+ reason: desktopRecorder.reason,+ clips: desktopRecorder.clipsThisSession,+ };+ if (desktopRecorder.browser) {+ try { await desktopRecorder.browser.close(); } catch {}+ }+ desktopRecorder.browser = null;+ desktopRecorder.page = null;+ desktopRecorder.cdp = null;+ desktopRecorder.reason = null;+ desktopRecorder.current = null;+ desktopRecorder.clipsThisSession = [];+ return summary;+}++async function desktopRecordStartImpl(opts) {+ const { reason, monitor = 'primary', fps = 30, audio = false,+ videoBitsPerSecond = 4_000_000, maxDurationMs = 600_000,+ confirmDesktopNotTabRecording } = opts;++ if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {+ throw new Error('reason is required β explain to the user why you\'re recording');+ }++ // Guardrail: Claude frequently mistakes "record this pup window/tab" for a+ // desktop-recording request. Desktop recording captures the WHOLE screen+ // (including unrelated apps in the foreground); if the pup window is+ // occluded the resulting clip won't show what the user actually wanted.+ // For 95% of "record a pup" asks the right verb is pup_record_start+ // (tab-scoped via CDP, captures regardless of foreground state, ~50fps).+ // Adom-desktop's full-desktop recording is mostly redundant with what+ // Hydrogen ships natively, and only really useful when Hydrogen is+ // already busy doing tab-recording for ralph-loop screenshot tests AND+ // the user wants a parallel desktop video at the same time.+ // Force the caller to explicitly opt in so they re-read this and confirm.+ if (confirmDesktopNotTabRecording !== true) {+ const err = new Error(+ 'desktop_record_start records the WHOLE DESKTOP, not a pup tab. ' ++ 'If the user said "record a pup" / "record this Chrome window" / "record the page", ' ++ 'they almost certainly want pup_record_start instead β that is tab-scoped via ' ++ 'CDP Page.startScreencast, captures the pup tab regardless of foreground state, ' ++ 'returns a single .webm at ~50fps, and does not need a HUD. ' ++ 'Hydrogen also has built-in desktop recording (recording start --share screen) which ' ++ 'is usually preferable for general "record my screen" asks. ' ++ 'adom-bridge\'s desktop_record_* is mainly for the case where Hydrogen is already ' ++ 'doing tab recording (ralph-loop screenshot tests) and you want a parallel desktop ' ++ 'video at the same time. ' ++ 'If you have read this and you really do want full-desktop recording (not pup tab), ' ++ 'pass confirmDesktopNotTabRecording: true and resend the call.'+ );+ err.code = 'desktop_record_needs_confirmation';+ err.hint = 'For pup tab recording: adom-bridge-cli --ai-thread "<your thread>" pup_record_start \'{"sessionId":"<id>","fps":30}\'. ' ++ 'For Hydrogen-driven desktop recording: hydrogen recording start --share screen. ' ++ 'For a parallel desktop clip alongside Hydrogen tab capture: re-call with confirmDesktopNotTabRecording: true.';+ throw err;+ }++ if (desktopRecorder.current && !desktopRecorder.current.stopping) {+ throw new Error(`A recording is already in progress (${desktopRecorder.current.recordingId}). Stop it first.`);+ }++ await ensureRecorderWindowOpen(reason);++ const recordingId = `rec-${++desktopRecorder.recCounter}`;+ const ts = Date.now();+ const filePath = path.join(RECORDINGS_DIR, `rec-desktop-${ts}.webm`);+ const fd = fs.openSync(filePath, 'w');++ desktopRecorder.current = {+ recordingId, filePath, fd,+ startedAt: ts, stoppedAt: null,+ durationMs: 0, sizeKB: 0,+ fps, audio, monitor, reason,+ stopping: false, stopResolver: null,+ stopReason: null,+ };++ // Safety cap+ desktopRecorder.current.maxDurationTimer = setTimeout(async () => {+ if (desktopRecorder.current && desktopRecorder.current.recordingId === recordingId) {+ desktopRecorder.current.stopReason = 'maxDuration';+ try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}+ }+ }, maxDurationMs);++ // Tell the page to start recording+ await desktopRecorder.page.evaluate((startOpts) => window.startRecording(startOpts), {+ fps, audio, videoBitsPerSecond, reason,+ });++ return {+ recordingId, filePath, startedAt: ts,+ monitor: { name: monitor }, reason,+ };+}++async function desktopRecordStopImpl({ recordingId } = {}) {+ if (!desktopRecorder.current) throw new Error('No active desktop recording');+ const cur = desktopRecorder.current;+ if (recordingId && cur.recordingId !== recordingId) {+ throw new Error(`Recording ${recordingId} not active (current is ${cur.recordingId})`);+ }+ if (cur.stopping) throw new Error('Already stopping');+ cur.stopping = true;+ if (cur.maxDurationTimer) clearTimeout(cur.maxDurationTimer);++ const stopWait = new Promise(r => { cur.stopResolver = r; });+ try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch (e) {+ // If evaluate fails (e.g. page died), we still resolve below via disconnected handler+ console.log(`[recorder] page.stopRecording threw: ${e.message}`);+ }+ // Wait for _recordingStopped exposed function to fire (with timeout)+ await Promise.race([stopWait, new Promise(r => setTimeout(r, 10000))]);++ const stopReason = cur.stopReason || 'requested';+ return {+ recordingId: cur.recordingId,+ filePath: cur.filePath,+ sizeKB: cur.sizeKB,+ durationMs: cur.durationMs,+ stopReason,+ };+}++// βββ Tab recording (CDP screencast) ββββββββββββββββββββββββββββββββββββββββ++// ββ Tab recording via CDP Page.startScreencast β ffmpeg pipe ββββββββββββββ+//+// The previous incarnation of this code (v1.3.32-attempt) tried Chrome's+// in-tab MediaRecorder + getDisplayMedia({preferCurrentTab:true}). That+// path turned out to be unreliable for tab scoping: Chromium's+// --use-fake-ui-for-media-stream defaults to "Entire Screen" capture, and+// --auto-select-tab-capture-source-by-title scans across ALL Chrome+// processes (it picked the user's other Chrome windows instead of pup's).+// No flag combination reliably scoped the capture to the calling tab.+//+// CDP Page.startScreencast IS reliably tab-scoped at the protocol level β+// the CDP session is bound to a specific Page target, and screencastFrame+// only emits frames for that Page. We pipe those JPEG frames directly into+// ffmpeg's stdin (no per-frame disk write, no tar bundle). ffmpeg encodes+// VP9 in real time and produces a single .webm file. ffmpeg is already+// installed (FFMPEG_PATH probed at module load; same dep as the desktop+// recorder's duration-remux step).+//+// Caller note: Chrome throttles paint on occluded windows, so call+// pup_raise_os_window first if you want frames at the requested fps.++// Brief pause to let any in-flight Page.captureScreenshot settle before+// detaching the CDP session. Twice the frame interval is a safe upper bound+// for the longest a single capture can take.+function intervalSafeWait(fps) {+ return Math.min(200, Math.max(50, Math.round(2 * 1000 / fps)));+}++async function tabRecordStartImpl({+ sessionId, tabId,+ fps = 30,+ quality = 85,+ maxDurationMs = 600_000,+}) {+ if (!FFMPEG_PATH) detectFFmpeg(); // lazy re-scan: ffmpeg may have been installed since the bridge started+ if (!FFMPEG_PATH) {+ throw new Error(`ffmpeg not found. Install it (${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}), then just retry β the bridge re-detects it, no restart needed.`);+ }++ const session = sessions.get(sessionId);+ if (!session) throw new Error(`Session "${sessionId}" not found`);+ const { tab } = resolveTabOrError(session, sessionId, { tabId });++ // One recording per tab.+ for (const r of tabRecordings.values()) {+ if (r.sessionId === sessionId && r.tabId === tab.tabId && !r.stopping) {+ throw new Error(`Tab ${tab.tabId} in session ${sessionId} is already being recorded (${r.recordingId})`);+ }+ }++ const recordingId = `rec-tab-${++tabRecCounter}`;+ const ts = Date.now();+ const filePath = path.join(+ RECORDINGS_DIR,+ `rec-tab-${sessionId}-${tab.tabId}-${ts}.webm`,+ );++ // Frames go to a temp dir during capture; ffmpeg muxes them to webm at+ // stop time via image2 demuxer with the f%06d.jpg pattern. This avoids+ // ffmpeg-stdin-streaming pitfalls (image2pipe and mjpeg both exit early+ // when input briefly stalls β observed in testing).+ const framesDir = path.join(RECORDINGS_DIR, `${path.basename(filePath, '.webm')}.frames`);+ fs.mkdirSync(framesDir, { recursive: true });++ const cdp = await tab.page.target().createCDPSession();++ const state = {+ recordingId, sessionId, tabId: tab.tabId,+ cdp, filePath, framesDir,+ fps, quality,+ startedAt: ts, stoppedAt: null,+ frameCount: 0, sizeBytes: 0,+ frameTimestamps: [], // wall-clock ms for each captured frame, used to mux with real timing+ stopping: false, stopTimer: null, stopReason: null,+ captureLoop: null,+ };+ tabRecordings.set(recordingId, state);++ // Bring tab to front so Chrome's compositor stays warm+ try { await tab.page.bringToFront(); } catch {}++ // ββ CDP Page.startScreencast with frame ACK βββββββββββββββββββββββββββββ+ // Chrome's compositor pushes JPEG frames as the page paints. Each frame+ // arrives with a sessionId that MUST be acknowledged via+ // Page.screencastFrameAck or Chrome stops sending after the first frame+ // (backpressure). Earlier impl (1.3.32) missed the ack β that's why we+ // saw "1 frame and stops" behavior, not paint-throttling. Tab-scoped at+ // the protocol level β the CDP session is bound to a specific Page target.+ //+ // everyNthFrame=1 means "every paint frame"; combined with the page's+ // own paint rate (60fps when foregrounded), this gives 60 fps capture+ // ceiling. Quality 60-85 keeps per-frame size manageable.+ state.frameHandler = async (event) => {+ if (state.stopping) return;+ const { data, sessionId: ackId } = event;+ const buf = Buffer.from(data, 'base64');+ const capturedAt = Date.now();+ state.frameCount += 1;+ state.sizeBytes += buf.length;+ state.frameTimestamps.push(capturedAt - state.startedAt);+ if (state.frameCount === 1) {+ console.log(`[${recordingId}] FIRST screencast frame (${buf.length} bytes)`);+ }+ const fname = `f${String(state.frameCount).padStart(6, '0')}.jpg`;+ try { // v2.0.230: was writeFileSync at up to 60fps β every frame blocked the event loop and stalled+ // every other session's CDP traffic for the length of a recording. The ack below is the+ // backpressure, so the write can be async.+ fs.promises.writeFile(path.join(state.framesDir, fname), buf).catch(() => {}); }+ catch (e) { console.error(`[${recordingId}] frame write failed: ${e.message}`); }+ // ACK so Chrome continues sending. Without this, only frame 1 arrives.+ try { await cdp.send('Page.screencastFrameAck', { sessionId: ackId }); }+ catch (e) {+ if (state.frameCount < 3) console.log(`[${recordingId}] ack error: ${e.message}`);+ }+ };+ cdp.on('Page.screencastFrame', state.frameHandler);++ await cdp.send('Page.startScreencast', {+ format: 'jpeg',+ quality,+ everyNthFrame: Math.max(1, Math.round(60 / fps)), // 1 -> 60fps, 2 -> 30fps, etc+ });++ console.log(`[${recordingId}] CDP screencast started @ target ${fps}fps (everyNthFrame=${Math.max(1, Math.round(60 / fps))}) β ${framesDir}`);++ state.stopTimer = setTimeout(() => {+ state.stopReason = 'maxDuration';+ tabRecordStopImpl({ sessionId, recordingId }).catch(() => {});+ }, maxDurationMs);++ return { recordingId, sessionId, tabId: tab.tabId, filePath, startedAt: ts };+}++async function tabRecordStopImpl({ sessionId, recordingId }) {+ const state = tabRecordings.get(recordingId);+ if (!state) throw new Error(`Recording ${recordingId} not found`);+ if (sessionId && state.sessionId !== sessionId) throw new Error(`Recording ${recordingId} belongs to a different session`);+ if (state.stopping) {+ const deadline = Date.now() + 5000;+ while (tabRecordings.has(recordingId) && Date.now() < deadline) await new Promise(r => setTimeout(r, 50));+ throw new Error(`Recording ${recordingId} already stopping`);+ }+ state.stopping = true;+ if (state.stopTimer) clearTimeout(state.stopTimer);++ // Stop screencast β Chrome stops emitting Page.screencastFrame events+ try { await state.cdp.send('Page.stopScreencast'); } catch {}+ // Detach the CDP frame handler so any late frames are dropped+ if (state.frameHandler) {+ try { state.cdp.off('Page.screencastFrame', state.frameHandler); } catch {}+ state.frameHandler = null;+ }+ // Brief settle window for in-flight frames already in the JS event loop+ await new Promise(r => setTimeout(r, intervalSafeWait(state.fps)));+ try { await state.cdp.detach(); } catch {}++ state.stoppedAt = Date.now();+ const durationMs = state.stoppedAt - state.startedAt;++ // ββ Mux the frames dir to webm via ffmpeg concat demuxer ββββββββββββββ+ // Each captured frame has a wall-clock timestamp (state.frameTimestamps).+ // We emit a concat list with explicit per-frame durations so playback+ // matches real wall time even when actual capture rate fell below the+ // requested fps (Page.captureScreenshot is GPU-bound; on a busy machine+ // 30 fps requested often delivers ~10-15 fps actual). Using the concat+ // demuxer with per-frame durations produces a video that plays in+ // real-time regardless of capture-rate variance.+ if (state.frameCount > 0) {+ const { spawn } = require('child_process');+ const totalDurMs = (state.frameTimestamps[state.frameTimestamps.length - 1] || 0) + 1; // +1 to avoid zero+ const actualFps = state.frameCount / (totalDurMs / 1000);++ // Build concat list: each frame followed by `duration <seconds>`. The+ // last file entry must be repeated (concat demuxer quirk) so the final+ // frame is held for its proper duration instead of clipped.+ const concatLines = [];+ for (let i = 0; i < state.frameCount; i++) {+ const fname = `f${String(i + 1).padStart(6, '0')}.jpg`;+ concatLines.push(`file '${fname}'`);+ const next = state.frameTimestamps[i + 1];+ const cur = state.frameTimestamps[i];+ const durSec = next != null+ ? Math.max(0.001, (next - cur) / 1000)+ : Math.max(0.001, 1 / Math.max(1, actualFps));+ concatLines.push(`duration ${durSec.toFixed(4)}`);+ }+ // Repeat the last file entry (no duration) β concat demuxer requirement+ concatLines.push(`file 'f${String(state.frameCount).padStart(6, '0')}.jpg'`);+ const concatPath = path.join(state.framesDir, 'concat.txt');+ fs.writeFileSync(concatPath, concatLines.join('\n'));++ const muxArgs = [+ '-y',+ '-loglevel', 'error',+ '-f', 'concat',+ '-safe', '0',+ '-i', concatPath,+ '-vsync', 'vfr',+ '-c:v', 'libvpx-vp9',+ '-b:v', '0',+ '-crf', '32',+ '-pix_fmt', 'yuv420p',+ '-row-mt', '1',+ '-deadline', 'realtime',+ '-cpu-used', '8',+ '-an',+ state.filePath,+ ];+ console.log(`[${state.recordingId}] muxing ${state.frameCount} frames over ${(totalDurMs/1000).toFixed(2)}s (avg ${actualFps.toFixed(1)} fps actual, ${state.fps} target)`);+ const muxStart = Date.now();+ await new Promise((resolve, reject) => {+ const proc = spawn(FFMPEG_PATH, muxArgs, { stdio: ['ignore', 'ignore', 'pipe'] });+ let stderr = '';+ proc.stderr.on('data', b => { stderr += b.toString(); });+ proc.on('error', reject);+ proc.on('exit', code => {+ if (code === 0) return resolve();+ reject(new Error(`ffmpeg mux exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));+ });+ }).catch(e => {+ console.error(`[${state.recordingId}] mux failed: ${e.message}`);+ });+ console.log(`[${state.recordingId}] muxed ${state.frameCount} frames β ${state.filePath} in ${Date.now() - muxStart}ms`);++ // Clean up frames dir (keep on mux failure for debugging)+ if (fs.existsSync(state.filePath)) {+ try { fs.rmSync(state.framesDir, { recursive: true, force: true }); }+ catch (e) { console.log(`[${state.recordingId}] frames cleanup failed: ${e.message}`); }+ }+ } else {+ console.log(`[${state.recordingId}] zero frames captured β no webm produced`);+ }++ // Sidecar metadata (used by pup_record_list)+ try {+ const sidecar = {+ recordingId, sessionId: state.sessionId, tabId: state.tabId,+ filePath: state.filePath, fps: state.fps, quality: state.quality,+ startedAt: state.startedAt, stoppedAt: state.stoppedAt,+ durationMs, frameCount: state.frameCount, sizeBytes: state.sizeBytes,+ stopReason: state.stopReason || 'requested',+ };+ fs.writeFileSync(state.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));+ } catch (e) {+ console.error(`[${state.recordingId}] sidecar write failed: ${e.message}`);+ }++ let sizeKB = 0;+ try { sizeKB = Math.round(fs.statSync(state.filePath).size / 1024); } catch {}++ const actualFps = durationMs > 0 ? +(state.frameCount / (durationMs / 1000)).toFixed(2) : 0;++ tabRecordings.delete(recordingId);+ return {+ recordingId,+ sessionId: state.sessionId,+ tabId: state.tabId,+ filePath: state.filePath,+ sizeKB,+ durationMs,+ frameCount: state.frameCount,+ fpsTarget: state.fps,+ fpsActual: actualFps,+ stopReason: state.stopReason || 'requested',+ };+}++function tabRecordStatusImpl({ sessionId } = {}) {+ const active = [];+ for (const s of tabRecordings.values()) {+ if (sessionId && s.sessionId !== sessionId) continue;+ if (s.stopping) continue;+ const elapsedMs = Date.now() - s.startedAt;+ const actualFps = elapsedMs > 100 ? +(s.frameCount / (elapsedMs / 1000)).toFixed(2) : null;+ active.push({+ recordingId: s.recordingId, sessionId: s.sessionId, tabId: s.tabId,+ filePath: s.filePath,+ fpsTarget: s.fps,+ fpsActual: actualFps,+ frameCount: s.frameCount,+ durationMs: elapsedMs,+ sizeBytesApprox: s.sizeBytes,+ startedAt: s.startedAt,+ });+ }+ return active;+}++// v2.0.236: these were two copies of the same directory scan differing only in the filename prefix+// and two sidecar fields. One implementation, two thin wrappers so the call sites read the same.+function listRecordings(prefix, extra) {+ try {+ return fs.readdirSync(RECORDINGS_DIR)+ .filter(f => f.startsWith(prefix) && f.endsWith('.webm'))+ .map(f => {+ const fp = path.join(RECORDINGS_DIR, f);+ const stat = fs.statSync(fp);+ let sidecar = {};+ try { sidecar = JSON.parse(fs.readFileSync(fp.replace(/\.webm$/, '.json'), 'utf8')); } catch {}+ const base = {+ recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),+ filePath: fp,+ sizeKB: Math.round(stat.size / 1024),+ durationMs: sidecar.durationMs || null,+ mtime: stat.mtime.toISOString(),+ };+ for (const k of extra) base[k] = sidecar[k] !== undefined ? sidecar[k] : null;+ return base;+ });+ } catch { return []; }+}+const tabRecordListImpl = () => listRecordings('rec-tab-', ['fps', 'sessionId', 'tabId']);+const desktopRecordListImpl = () => listRecordings('rec-desktop-', ['reason']);++// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ++// Graceful shutdown: disconnect from Chrome instead of killing it+// Chrome stays alive for reconnect on next bridge startup+async function gracefulShutdown(signal) {+ console.log(`\n[${signal}] Draining recordings + disconnecting from Chrome (Chrome stays alive for reconnect)...`);++ // Drain active desktop recording+ if (desktopRecorder.current) {+ try {+ desktopRecorder.current.stopReason = 'bridgeShutdown';+ await desktopRecordStopImpl({ recordingId: desktopRecorder.current.recordingId });+ } catch (e) {+ console.error(` desktop recording stop failed: ${e.message}`);+ }+ }+ // Close recorder window so its WebM has a valid trailing cluster+ if (desktopRecorder.browser) {+ try { await desktopRecorder.browser.close(); } catch {}+ }++ // Drain active tab recordings+ for (const [recId, _state] of [...tabRecordings.entries()]) {+ try {+ _state.stopReason = 'bridgeShutdown';+ await tabRecordStopImpl({ sessionId: _state.sessionId, recordingId: recId });+ } catch (e) {+ console.error(` tab recording ${recId} stop failed: ${e.message}`);+ }+ }++ for (const [name, entry] of browsers) {+ try {+ entry.browser.disconnect();+ console.log(` Disconnected from browser "${name}"`);+ } catch (e) {+ console.error(` Error disconnecting "${name}": ${e.message}`);+ }+ }+ browsers.clear();+ sessions.clear();+ process.exit(0);+}+process.on('SIGINT', () => gracefulShutdown('SIGINT'));+process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));+@@ -0,0 +1,91 @@+#!/usr/bin/env bash+# rust-build-macos.sh β build the Rust pup bridge for macOS and its release zip.+#+# tools/rust-build-macos.sh <version> [--no-sign] # -> dist/<version>-macos/adom-bridge-puppeteer-macos-v<version>.zip+#+# The macOS sibling of tools/rust-build.sh (which is the Windows exe). Same package shape: ONE binary+# (`adom-pup-bridge`, a Mach-O, no extension) + bridge.json (spawn.kind exe) + BRIDGE_VERSION at the zip root,+# every browser asset embedded. Differences, all platform facts:+# - target aarch64-apple-darwin (x86_64-apple-darwin is added as a universal binary when that target is installed);+# - the entrypoint carries mode 0755 INSIDE the zip (Bridge >= 1.9.216 also restores it on extraction);+# - bridge.json declares macos supported / windows unsupported, and `updateManifestUrls.macos` points at the+# macOS manifest (adom-bridge-puppeteer-manifest-macos.json) so a Mac never follows the Windows line+# (Bridge 1.9.212+ reads the per-OS map; the plain updateManifestUrl stays John's public manifest);+# - signed with the Adom Developer ID (hardened runtime, timestamp) unless --no-sign; notarization is a+# separate step (tools/notarize-macos.sh is not written yet: a bare binary Bridge extracts and spawns is not+# Gatekeeper-assessed, but sign anyway so the provenance is on the file).+# Gates: border check, embedded assets regenerated, workspace tests green on this Mac.+set -euo pipefail+VER="${1:?usage: tools/rust-build-macos.sh <version> [--no-sign]}"; SIGN=1; [ "${2:-}" = "--no-sign" ] && SIGN=0+REPO="$(cd "$(dirname "$0")/.." && pwd)"; cd "$REPO"+ARCH="dist/${VER}-macos"; ZIP="adom-bridge-puppeteer-macos-v${VER}.zip"+[ "$(cat src/BRIDGE_VERSION)" = "$VER" ] || { echo "src/BRIDGE_VERSION is $(cat src/BRIDGE_VERSION), not $VER"; exit 1; }+IDENTITY="${ADOM_SIGNING_IDENTITY:-Developer ID Application: Adom Industries Inc. (B22GYTS2NN)}"++echo "== mac 0. gates =="+python3 tools/gen-assets.py+sh tools/check-borders.sh+( cd rust && grep -q "^version = \"${VER}\"" Cargo.toml ) || { echo " rust/Cargo.toml version is not ${VER}"; exit 1; }+TEST_OUT=$(cd rust && cargo test --workspace -q 2>&1) || { echo "$TEST_OUT" | grep -E "error|FAILED|panicked" | head -20; echo " TESTS FAILED"; exit 1; }+echo " workspace tests green"++echo "== mac 1. release build =="+TARGETS="aarch64-apple-darwin"+rustup target list --installed | grep -q '^x86_64-apple-darwin$' && TARGETS="$TARGETS x86_64-apple-darwin"+BINS=""+for T in $TARGETS; do+ ( cd rust && cargo build --release --target "$T" 2>&1 | grep -E "^(error|warning: unused)|Finished" )+ B="rust/target/$T/release/adom-pup-bridge"; [ -f "$B" ] || { echo " $B missing"; exit 1; }+ grep -q "Puppeteer Bridge v" "$B" || { echo " $B lacks the boot banner string"; exit 1; }+ BINS="$BINS $B"+done+mkdir -p "$ARCH"+OUT="$ARCH/adom-pup-bridge"+set -- $BINS+if [ $# -gt 1 ]; then lipo -create -output "$OUT" "$@"; BUILT="universal ($TARGETS)"; else cp "$1" "$OUT"; BUILT="$TARGETS"; fi+chmod 755 "$OUT"++if [ "$SIGN" = 1 ]; then+ echo "== mac 1b. sign ($IDENTITY) =="+ codesign --force --options runtime --timestamp --sign "$IDENTITY" --identifier inc.adom.pup-bridge "$OUT" || { echo " SIGNING FAILED: nothing packaged"; exit 1; }+ codesign --verify --strict "$OUT" || { echo " signature does not verify"; exit 1; }+else+ echo "== mac 1b. --no-sign: ad-hoc signature only (local testing) =="+ codesign --force --sign - "$OUT"+fi++echo "== mac 2. package =="+python3 - "$VER" "$OUT" "$ARCH/$ZIP" "$BUILT" <<'PY'+import json, sys, zipfile, hashlib, os, time+ver, binary, out, built = sys.argv[1:5]+d = json.load(open('src/bridge.json'))+assert d['version'] == ver, f"src/bridge.json is {d['version']}, not {ver}"+d['spawn'] = { 'kind': 'exe', 'entrypoint': 'adom-pup-bridge', 'port': d['spawn'].get('port', 8851), 'healthEndpoint': '/status', 'stopMethod': 'kill', 'killImageName': 'adom-pup-bridge', 'persistent': True,+ '_hint': 'spawn.kind exe: one sha256-verified Mach-O binary, no Node, no npm, no postinstall. The macOS build of the same Rust workspace as the Windows exe (rust/host-mac is the only platform crate that differs).' }+d.pop('runtime', None)+d['dependencies'] = { 'browser': 'Chromium-family: installed Google Chrome or Microsoft Edge (/Applications), else Chrome for Testing (mac-arm64 / mac-x64) is fetched as a last resort.' }+d['platforms'] = { 'macos': {'supported': True, 'since': ver, 'notes': 'The Rust binary on macOS. Background etiquette is the app-level hide (Cmd+H state) of the pup browser: windows stay drivable and screenshottable while hidden; a granted raise unhides and activates. Taskbar identity (AUMID, jump lists, overlay badges, flash) is Windows-only and inert here; verbs report platform + taskbarSupported.'},+ 'windows': {'supported': False, 'reason': 'This is the macOS package. Windows follows adom-bridge-puppeteer-manifest.json (the exe build).'},+ 'linux': {'supported': False, 'reason': 'not yet'} }+public = 'https://wiki.adom.inc/api/v1/pages/pup-bridge/files/adom-bridge-puppeteer-manifest.json'+mac = 'https://wiki.adom.inc/api/v1/pages/pup-bridge/files/adom-bridge-puppeteer-manifest-macos.json'+d['updateManifestUrls'] = { 'macos': mac, 'windows': public }+d['shell'] = 'rust'+d['builtFor'] = built+with zipfile.ZipFile(out, 'w', zipfile.ZIP_DEFLATED) as z:+ zi = zipfile.ZipInfo('adom-pup-bridge', date_time=time.localtime()[:6]); zi.external_attr = (0o100755 << 16); zi.compress_type = zipfile.ZIP_DEFLATED+ z.writestr(zi, open(binary, 'rb').read())+ z.writestr('bridge.json', json.dumps(d, indent=2) + '\n')+ z.writestr('BRIDGE_VERSION', ver)+json.dump(d, open(os.path.join(os.path.dirname(out), 'bridge.json'), 'w'), indent=2)+sha = hashlib.sha256(open(out,'rb').read()).hexdigest(); size = os.path.getsize(out)+# the macOS manifest, same shape as the public one, platform-tagged like the KiCad mac line+man = { 'manifest_version': 1, 'name': 'puppeteer', 'version': ver,+ 'url': f'https://wiki.adom.inc/download/adom/pup-bridge/{ver}/{os.path.basename(out)}', 'sha256': sha, 'size': size,+ 'platform': 'macos', 'shell': 'rust', 'verbPrefixes': d.get('verbPrefixes', ['pup_', 'browser_']), 'healthEndpoint': '/status', 'statusVerb': d.get('statusVerb', 'pup_readiness'),+ 'released_at': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) }+json.dump(man, open(os.path.join(os.path.dirname(out), 'adom-bridge-puppeteer-manifest-macos.json'), 'w'), indent=2)+print(f" {out} sha256={sha} size={size} binary={os.path.getsize(binary)} ({built})")+print(f" manifest staged: {os.path.dirname(out)}/adom-bridge-puppeteer-manifest-macos.json (NOT pushed; publishing is a human's call)")+PY+@@ -0,0 +1,17 @@+{+ "manifest_version": 1,+ "name": "puppeteer",+ "version": "2.0.729",+ "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.729/adom-bridge-puppeteer-macos-v2.0.729.zip",+ "sha256": "17f1684309242cde136531f8c456a54a0e0b33a71384a902cbd67fd010477978",+ "size": 8427128,+ "platform": "macos",+ "shell": "rust",+ "verbPrefixes": [+ "pup_",+ "browser_"+ ],+ "healthEndpoint": "/status",+ "statusVerb": "pup_readiness",+ "released_at": "2026-10-20T17:22:58Z"+}
Comments
Log in to comment.