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: report each session's window id, and let a background window be captured (capturable:true) #6
pup-bridge 2.0.734 source (this PR) ยท seen on pup 2.0.396 under Adom Bridge 1.9.224 ยท macOS (MacBook Air, Apple silicon) ยท Chrome for Testing 148
What happened
AI Flow's tour fields films the Adom Fields app in a pup window: it opens one, polls pup_list_windows for that session's hwnd, and starts desktop_record_window_start {hwnd}. On a Mac it failed every time with
ERROR: the app's window did not appear on <mac> (no window for this walkthrough session)
The window was there. With foreground:true it was on screen, and desktop_list_windows listed it (hwnd 94297, com.google.chrome.for.testing, title "Adom Fields"), but pup_list_windows reported hwnd: null for the session. In the default background mode the app is also hidden (Cmd+H), so even with a handle a desktop window recording or desktop_screenshot_window would have nothing to capture. The tour worked once the window was in front and its handle was filled in from desktop_list_windows (done from the calling side as a stopgap): the clip recorded normally.
Why (in 2.0.734)
enumChromeWidgetWindowsreturns[]off Windows (desktop_enum_windowswithChrome_WidgetWin_1is Win32-only).hwndOwnerPidandresolveSessionHwndByBoundsboth returnnullunlessprocess.platform === 'win32'.pup_list_windowsreports only a cachedsession._hwnd, and on macOS nothing ever sets one (no birth capture, no window-property tag, no park/overlay resolution).
So on a Mac every session's hwnd is null, and the macOS background contract (hidden app) leaves a desktop capture nothing to see.
The change (src/server.js, SKILL.md)
enumChromeWidgetWindowson macOS reads AD'sdesktop_list_windows {includeOffscreen:true}(window id ashwnd,ownerPid, title, bundle id) and keeps the Chromium-family rows. Callers already fence rows by pup's own pids, as on Windows.hwndOwnerPidandresolveSessionHwndByBoundsrun on macOS too. There the Win32 legs fall through (the property lookup already returnsnulloff Windows), and the existing read-only, pid-fenced natural-title match and sole-window-of-profile pick resolve the handle.pup_list_windowson macOS resolves missing handles first (bounded, 2.5 s, in parallel), so the rows reporthwnd.pup_open_window {capturable:true}on macOS un-hides the window where it sits (visible, not focused, behind the user's active app; the samemacSetAppHiddenByPid(pid, false)primitivepup_alert_windowalready uses for its mac nudge) and sayscapturable: true|falsein the reply. Without the flag nothing changes; Windows and Linux are untouched.SKILL.md's macOS section said hidden windows are "screenshottable/recordable". That holds forpup_screenshotand pup's own recording (CDP), not for a desktop capture; it now says so and points tocapturable:true.
Tested
node --check src/server.jspasses.- The resolution path is the existing natural-title / sole-window code; the new part is the macOS row source, whose shape I checked against a live Mac (
desktop_list_windowsreturnsdata.windows[] {hwnd, ownerPid, title, appBundleId, rect, isOnscreen}on AD 1.9.224). - Not yet run on a Mac with this build (that Mac runs pup 2.0.396). Please run
pup_open_window {capturable:true}thenpup_list_windowsanddesktop_record_window_start {hwnd}on a Mac before release.
Also seen on that Mac
pup 2.0.396 launched Chrome for Testing without --use-mock-keychain, so macOS asked the user for their login keychain password ("Chromium Safe Storage"). 2.0.720 already adds the flag; the Mac was still on 2.0.396 under Adom Bridge 1.9.224, so it may not be receiving pup updates.
๐ค Generated with Claude Code
Diff Skip to comments (2)
@@ -1,21999 +1,22035 @@⋯ 8478 unchanged lines ⋯ // v1.9.297 semantics preserved natively (v2.0.27): the owner pid ONLY for a REAL top-level visible // Chrome browser frame. desktop_enum_windows enumerates top-level windows filtered by class, so a // child/utility HWND simply is not in the result โ same rejection, zero PowerShell.- if (process.platform !== 'win32' || !hwnd) return null;- // v2.0.308: this ran a RAW ab enum on every validation (every verb that touches an hwnd). Use the- // shared cached snapshot; a handle missing from it (a window younger than the cache) gets ONE- // fresh look, which is also coalesced across concurrent callers.- try {- let row = (await enumChromeWidgetWindows()).find(r => Number(r.h) === Number(hwnd));- if (!row) row = (await enumChromeWidgetWindows({ fresh: true })).find(r => Number(r.h) === Number(hwnd));- if (!row) return null;- const pid = Number(row.pid);- return Number.isFinite(pid) && pid > 0 ? { pid, title: String(row.title || '') } : null;- } catch (e) { return null; }-}-// Confirm an hwnd is a LIVE window owned by a pup browser process. Used as the hard gate before any-// taskbar paint/flash, and to invalidate a stale cached hwnd (recycled numbers).-// v2.0.34 (John caught fusion + nb resolving to the SAME hwnd): a window handle can belong to-// exactly ONE session. This rejects a candidate handle already claimed by another live session's-// _hwnd or birth __pupHwnd, so concurrent births/resolves on the shared profile can't cross-wire-// (the bug where one thread stamped its identity onto another thread's taskbar button).-// v2.0.45 (mimic-ralph caught mk-* windows STEALING John's real windows' handles during the-// recovery storm): in-memory claims lag recovery โ a session that resolves early can take a handle-// whose true owner hasn't restored its claim yet. This map is loaded from EVERY session file at the-// START of recovery (before any resolve can fire), so a persisted handle is protected from t=0.-// persistSessionHwnd keeps it current; deleteSessionFile releases it.-const _persistedHwndClaims = new Map(); // hwnd -> sessionId-function hwndOwnedByOtherSession(hwnd, exceptSessionId) {- const h = Number(hwnd);- const pc = _persistedHwndClaims.get(h);- if (pc && pc !== exceptSessionId) return true;- for (const [k, s] of sessions) {- if (k === exceptSessionId || !s) continue;- if (Number(s._hwnd) === h) return true;- for (const t of (s.tabs || [])) { if (t && t.page && Number(t.page.__pupHwnd) === h) return true; }- }- return false;-}-async function hwndBelongsToPup(hwnd, session) {- // v1.9.287 (John caught two sessions on DIFFERENT profiles sharing one hwnd): validate that the- // hwnd belongs to THIS session's OWN profile process, not merely "some pup window". The old check- // let a wrong-but-pup cached hwnd (another session's window) pass forever, so a stale/mis-resolved- // hwnd was never corrected and overlays painted on the wrong button. When a session is given, the- // pid set is scoped to its profile; a cross-profile hwnd now fails and forces a re-resolve.- const prof = session && session.profileName;- // v2.0.31: STRONGEST PROOF FIRST โ a hwnd pup captured at BIRTH (it created that exact off-screen- // window) is definitively pup's, no pid tracking and no title tag required. This replaces the- // v1.9.350 title-based shared-profile fallback that 2.0.24 broke by removing the title tag.- // v2.0.309 (John: the enum spam): this proof now runs BEFORE the owner-pid enumeration, so a- // window pup itself created is never re-enumerated to be believed. The owner pid is learned from- // the shared snapshot when it happens to be there, never by forcing a fresh enum.- try {- const born = session && (session.tabs || []).some(t => t && t.page && Number(t.page.__pupHwnd) === Number(hwnd));- if (born) {- try { const row = (_enumCache.rows || []).find(r => Number(r.h) === Number(hwnd)); if (row && row.pid) learnConfirmedPid(prof, Number(row.pid)); } catch (e) {}- return true;- }- } catch (e) {}- const info = await hwndOwnerPid(hwnd);- const pid = info && info.pid;- if (!pid) return false;- // v2.0.43: RECOVERED-HANDLE PROOF. A respawned pup can't have birth-captured anything, but the- // session file persisted both the hwnd AND Chrome's pid at capture time, and Chrome outlives pup- // respawns โ so this exact pair (the handle still owned by the very process it was recorded- // under) is proof by continuity. Without this branch every recovered window failed all proofs,- // its perfectly good persisted handle was discarded, and the wiggle fallback couldn't- // disambiguate the shared profile (mimic-ralph: NOT-RESOLVED-x4 on every window, every respawn).- try {- if (session && session._recoveredHwndPid && Number(session._hwnd) === Number(hwnd)- && Number(pid) === Number(session._recoveredHwndPid)) {- learnConfirmedPid(prof, pid); return true;- }- } catch (e) {}- const own = pupBrowserPidSet(prof || undefined);- if (own.has(pid)) return true;- try {- // v2.0.27: parent pid natively via ab process_list (was a SYNC CIM PowerShell that blocked the loop)- const pd = abPayload(await chrome.abCommand('process_list', { pidFilter: pid }, { timeoutMs: 5000 }));- const row = ((pd && pd.processes) || []).find(p => Number(p.pid) === Number(pid));- const par = row && Number(row.parentPid);- if (par && own.has(par)) { learnConfirmedPid(prof, pid); return true; }- } catch (e) {}- // v1.9.350 (John: "why does the taskbar pup icon have no overlay?"). The shared adom-you profile- // runs ONE Chrome process with MANY windows, and that process's real pid is often NOT in the tracked- // set (puppeteer's browser.process() is the dead post-fork launcher and _detachedPid can be stale),- // so the pid + parent checks above BOTH miss and this guard REFUSED every favicon badge on that- // profile โ the exact symptom John saw (teal base tile, no overlay). A pup window's title carries- // this session's UNIQUE tag "(ai-thread: <sid>)", which Edge / any other app can NEVER have, so a- // title match is hard proof of pup ownership. Accept it AND LEARN the owner pid onto the browser- // entry, so every sibling window on that shared Chrome process passes instantly afterwards- // (self-heal). Edge stays protected: no pup title, nothing learned, still refused.- try {- const title = info && info.title;- if (title && prof) {- let sid = null;- for (const [k, s] of sessions) { if (s === session) { sid = k; break; } }- if (sid && title.indexOf(`(${TITLE_LABEL}: ${sid}`) >= 0) { learnConfirmedPid(prof, pid); return true; }- }- } catch (e) {}- return false;-}-// v1.9.304: enumerate every visible top-level Chrome browser frame with its owner pid + rect. Shared-// by birth-time resolution (createPageInNewWindow) and the resolver. Returns [{h,pid,L,T,W,H}].-// v2.0.148 (John: "why are you calling enum_windows a bazillion times via ab?"): COALESCE + a tiny-// TTL cache. The enforcer/park/flash/reconcile sweeps each independently enumerate every Chrome-// window, so a single tick fired many identical desktop_enum_windows calls at ab within the same-// few ms โ the flood John saw. (1) In-flight coalescing: concurrent callers share ONE ab call.-// (2) A result cache. v2.0.151 (John: "does enum_windows have to be called as often as you do โ it-// makes a lot of noise in ab's logs"): raised 120ms โ 1500ms. The steady-state callers (the 8s-// reconcile, the overlay enforcer, the placement/user-activity sweeps) only need to FIND existing-// windows, and a window's geometry does not meaningfully change within 1.5s โ so a shared snapshot-// per 1.5s is correct for them and cuts the ab call rate by ~12x. The ONLY callers that must observe-// a brand-new window the instant it appears are the birth-time set-diff and the off-screen-// re-capture move-detect loops; both pass {fresh:true} and bypass the cache entirely.-const ENUM_CACHE_MS = 1500;-// v2.0.172 (John, 3rd report: "are you still spamming ab with enum_windows?"). He was right, and-// the 2.0.151 cache was never the whole story: {fresh:true} bypassed BOTH the cache AND the-// in-flight coalescing, and the two fresh callers are POLL LOOPS โ birth capture (up to 20 rounds)-// and off-screen re-capture (up to 8) โ so every window open sprayed ~8 enum calls/second at ab,-// and opening 7 windows multiplied that. Fresh callers genuinely cannot use a 1.5s snapshot (they-// must see a brand-new window), but they can share ONE call with each other: a 250ms freshness-// window is far below the ~100ms a window takes to appear, yet collapses simultaneous pollers and-// halves each loop's rate. Net: same detection latency, a fraction of the ab log noise.-// v2.0.184 (John's 3rd spam report, screenshot: multiple enum calls in the SAME second): the-// bursts are BIRTH-CAPTURE polls during window opens โ and sibling threads on a busy box open-// windows constantly, so "per-open cost" IS the steady-state log noise. 250ms sharing still allowed-// ~4 real calls/second. At 1000ms, every fresh poller shares one snapshot per second, so the ab log-// can never show more than ~1 enum/second no matter how many opens run concurrently. A birth-// set-diff tolerates a <=1s-stale snapshot fine: it just sees the new window one poll later, and-// window creation takes 1-3s anyway.-const ENUM_FRESH_MS = 1000;-// v2.0.187 (John, 4th report, and he named the actual design flaw: "you find the hwnd and then-// never clean up after yourself โ stop polling once you've figured out what you needed"). He is-// right: the enum exists for BIRTH (find the new hwnd by set-diff). Steady-state sweeps were lazily-// reusing it every 1.5s forever. Now: when every live session already has a resolved hwnd, the-// snapshot is served for up to ENUM_CACHE_LONG_MS without touching ab at all, and the cache is-// INVALIDATED the moment anything could have changed โ a window-opened UI event, a pup open/close,-// a reap. Steady state = zero enum calls, not a slower drip of them.-const ENUM_CACHE_LONG_MS = 45000;-function invalidateEnumCache() { _enumCache = { at: 0, rows: null }; }-function _allHwndsResolved() {- if (!sessions.size) return false;- let any = false;- for (const [, s] of sessions) { if (!(s.tabs || []).length) continue; any = true; if (!s._hwnd) return false; }- return any;-}-let _enumInflight = null;-let _enumFreshInflight = null;-let _enumCache = { at: 0, rows: null };-let _enumCalls = 0; // lifetime count of REAL ab calls (exposed on /status for auditing)-let _enumWindow = { at: 0, n: 0 }; // rolling 60s rate, so "is it spamming?" is answerable with data-async function enumChromeWidgetWindows(opts) {- if (process.platform !== 'win32') return [];- const fresh = !!(opts && opts.fresh);- if (fresh) {- if (_enumCache.rows && Date.now() - _enumCache.at < ENUM_FRESH_MS) return _enumCache.rows;- if (_enumFreshInflight) return _enumFreshInflight;- }- if (!fresh && _enumCache.rows && Date.now() - _enumCache.at < (_allHwndsResolved() ? ENUM_CACHE_LONG_MS : ENUM_CACHE_MS)) return _enumCache.rows;- if (!fresh && _enumInflight) return _enumInflight;- const run = (async () => {- try {- _enumCalls++;- const _now = Date.now();- if (_now - _enumWindow.at > 60000) _enumWindow = { at: _now, n: 0 };- _enumWindow.n++;- const d = abPayload(await chrome.abCommand('desktop_enum_windows', { className: 'Chrome_WidgetWin_1', includeOffscreen: true }, { timeoutMs: 8000 }));- const rows = [];- for (const w of ((d && d.windows) || [])) {- if (w.visible === false) continue;- const r = w.rect || [];- const h = Number(w.hwnd);- if (h && Number.isFinite(Number(r[2]))) rows.push({ h, pid: Number(w.pid), L: Number(r[0]), T: Number(r[1]), W: Number(r[2]) - Number(r[0]), H: Number(r[3]) - Number(r[1]), title: String(w.title || '') });- }- _enumCache = { at: Date.now(), rows };- return rows;- } catch (e) { return []; }- })();- if (fresh) { _enumFreshInflight = run; run.finally(() => { if (_enumFreshInflight === run) _enumFreshInflight = null; }); }- else { _enumInflight = run; run.finally(() => { if (_enumInflight === run) _enumInflight = null; }); }- return run;-}-// v1.9.309 (John: "how many times did you say you couldn't look up a window by title... you keep-// duct-taping it instead of a permanent solution"). He counted right โ 23 call sites resolved a-// window by its "(ai-thread: X)" TITLE, and that same unreliable lookup independently broke the-// overlay, the park, the flash, and the AUMID stamp. THE PERMANENT FIX: one way to name a session's-// OS window for every AD window verb โ its HANDLE. windowTarget() resolves + validates the hwnd-// (read-only; it never resizes anything) and returns { hwnd }. Every operation spreads this, so hwnd-// is primary everywhere; titleContains remains ONLY as a last-ditch fallback for a window we somehow-// never got a handle for. As birth-time capture + persistence cover every window, the fallback goes-// cold and title stops mattering for targeting at all.-// v2.0.237: returns a REAL hwnd or null โ never a title guess. Callers that can act without the-// shell (CDP geometry, for instance) use this so they can skip the ab round-trip entirely instead of-// sending a titleContains that cannot match while titleTag is off (its default).-async function windowHwnd(session, sessionId) {- try {- if (session && !session._hwnd) { await bestSessionHwnd(session, sessionId); }- if (session && session._hwnd && await hwndBelongsToPup(session._hwnd, session)) return Number(session._hwnd);- } catch (e) {}- return null;-}-async function windowTarget(session, sessionId) {- try {- if (session && !session._hwnd) { await resolveSessionHwndByBounds(session, sessionId); }- if (session && session._hwnd && await hwndBelongsToPup(session._hwnd, session)) return { hwnd: Number(session._hwnd) };- // (No pid-tree guess here: pup windows SHARE one Chrome process tree, so "the largest window of- // this pid" is often a DIFFERENT session's window โ parking or branding the wrong window is a- // worse failure than reporting that we could not resolve this one.)- } catch (e) {}- // v2.0.228: titleTag defaults OFF (since the 2.0.20 hotfix), so this fallback CANNOT match any- // window โ it produced the mystifying 'No visible window with title containing "(session: X)"'- // that broke pup_lower_os_window during the icon audit. Say so out loud once per session instead- // of letting the caller stare at an impossible-looking lookup.- if (pupSettings().titleTag !== 'on') {- const _s = sessions.get(sessionId);- if (_s && !_s._titleFallbackWarned) {- _s._titleFallbackWarned = true;- console.log(`[windowTarget] "${sessionId}" has NO hwnd and titleTag is OFF โ falling back to a title lookup that cannot match; the window op will fail. Root cause is the missing handle, not the title.`);- }- }- return { titleContains: `(${TITLE_LABEL}: ${sessionId}` };-}-async function resolveSessionHwndByBounds(session, sessionId) {- if (process.platform !== 'win32' || !session) return null;- // v2.0.17 (#557 follow-through): NEGATIVE-RESULT BACKOFF. A shared-profile window that resolution- // CANNOT disambiguate (N windows, many wiggle-matches) was re-probed on every overlay/park/flash- // touch โ a hot loop of PowerShell EnumWindows passes, forever, per unresolvable session. That- // burned CPU (John's laptop-load report) AND blocked the event loop often enough that ab's 1500ms- // /health marker probe timed out, so ab judged a LIVE bridge unhealthy, reaped it (0 tracked pids),- // and spawn-looped โ the #557 flap's other half. Three consecutive failures now put the session on- // a 5-minute resolution cooldown; any success (or a fresh open with a birth-time handle) resets it.- // v2.0.33: the backoff suppresses the GEOMETRY hammering (its original purpose) but must NOT- // block the definitive off-screen RE-CAPTURE below โ that is the fix for the stuck-bare case.- const _geomBackoff = !!(session._hwndBackoffUntil && Date.now() < session._hwndBackoffUntil);- // v2.0.312 (John's ring showed "17 straight misses" in 5s while claiming a 5-minute backoff): the- // backoff used to skip only the geometry leg, which is deleted, so every sweep still ran the whole- // function and logged a miss ~4x/sec. In backoff: one in-process property lookup (microseconds),- // then return quietly. The tag is the only signal that can change the answer anyway.- if (_geomBackoff) {- try {- const _ph0 = await resolveHwndBySessionProp(sessionId);- if (_ph0) { session._hwnd = _ph0; session._hwndFailStreak = 0; session._hwndBackoffUntil = 0; console.log(`[hwnd] "${sessionId}" resolved ${_ph0} by property tag (during backoff)`); return _ph0; }- } catch (e) {}- return null;- }- try {- // v1.9.278: NEVER trust a cached hwnd blind - validate it still belongs to pup before reuse- // (a dead/recycled handle now owned by Edge is exactly how the Edge overlay happened).- if (session._hwnd) {- if (await hwndBelongsToPup(session._hwnd, session)) { session._hwndFailStreak = 0; session._hwndBackoffUntil = 0; _ensureWindowTagged(session, sessionId, Number(session._hwnd)); console.log(`[hwnd] "${sessionId}" cached/persisted handle ${session._hwnd} VALIDATED (continuity)`); return Number(session._hwnd); }- console.log(`[hwnd] "${sessionId}" cached hwnd ${session._hwnd} is stale/foreign โ discarding and re-resolving`);- session._hwnd = null;- }- // v1.9.304: BIRTH-TIME handle โ captured while this window was off-screen at creation, the only- // unambiguous moment for a same-profile window. Trust it (validated) ahead of every geometry- // heuristic; this is what finally resolves multi-window profiles the wiggle never could.- try {- const p0 = (session.tabs || [])[0] && (session.tabs || [])[0].page;- const bh = p0 && p0.__pupHwnd;- if (bh && hwndOwnedByOtherSession(Number(bh), sessionId)) {- // v2.0.34: another live session already owns this handle โ our birth capture cross-wired.- // Drop it and fall through so we resolve a DISTINCT window instead of stamping onto theirs.- console.log(`[hwnd] "${sessionId}" birth handle ${bh} is owned by ANOTHER session โ discarding`);- try { p0.__pupHwnd = null; } catch (e) {}- } else if (bh && await hwndBelongsToPup(Number(bh), session)) {- session._hwnd = Number(bh); persistSessionHwnd(sessionId, Number(bh));- // DETERMINISTIC IDENTITY: tag the window with its sessionId so it stays re-findable after- // any future handle loss (dev-skills/pup-no-wiggle). Guarded: tags once per handle.- _ensureWindowTagged(session, sessionId, Number(bh));- console.log(`[hwnd] "${sessionId}" resolved OS handle ${bh} โ birth-time capture (off-screen at creation)`);- return Number(bh);- }- } catch (e) {}- // ========================================================================- // DETERMINISTIC PROPERTY LOOKUP โ the sanctioned resolver (dev-skills/pup-no-wiggle).- // The birth handle is a fast path; when it is gone (crash / respawn / bridge restart) we re-find- // the window by the Win32 property tag pup wrote at birth. This is a LOOKUP, not a guess: only the- // window tagged "AdomPupSid=<this session>" can match, so it is exact even on the shared profile- // where every window has identical geometry. This REPLACES the banned geometry 'wiggle'.- try {- const _ph = await resolveHwndBySessionProp(sessionId);- if (_ph && !hwndOwnedByOtherSession(Number(_ph), sessionId) && await hwndBelongsToPup(Number(_ph), session)) {- session._hwnd = Number(_ph); persistSessionHwnd(sessionId, Number(_ph));- session._hwndFailStreak = 0; session._hwndBackoffUntil = 0;- session._taggedHwnd = Number(_ph); // resolved BY the tag, so it is already tagged- try { const p0b = (session.tabs || [])[0]; if (p0b && p0b.page) p0b.page.__pupHwnd = Number(_ph); } catch (e) {}- console.log(`[hwnd] "${sessionId}" resolved OS handle ${_ph} by WINDOW-PROPERTY tag (deterministic; no geometry, no wiggle)`);- return Number(_ph);- }- } catch (e) {}- // v1.9.274 (John's dashboard showed 3 'untracked' windows that WERE the real session windows):- // every parked window shares the IDENTICAL rect (work-area minus insets), so pure geometry- // matching cannot distinguish windows after a restart and misassigns hwnds. The title tag is- // exact where it exists - resolve by title FIRST; geometry stays as the fallback for windows- // that cannot carry the tag (PDF viewer).- // titleTag ON: the persistent "(ai-thread: X)" suffix is the exact, reliable resolver. When OFF- // we skip straight to the unique-width geometry wiggle below (observer-proof; the transient TITLE- // approach was fought by the page's own title observer, collapsing same-profile windows onto one- // hwnd โ John's dup-handle bug).- // v2.0.10 (John: a bridge-pages window with 4 same-profile windows + 14 ambiguous wiggle-matches- // got NO overlay). Try the unique title tag REGARDLESS of the titleTag setting, but only trust an- // UNAMBIGUOUS (count===1) match. The "(ai-thread: <sid>)" suffix is unique to THIS session and- // impossible on Edge/non-pup, so a single match is safe even when the geometry wiggle can't tell- // same-profile windows apart. A page title-observer that strips the tag just yields count 0 (or a- // recycle yields >1), and we fall through to the wiggle โ never guessing. This is what the earlier- // titleTag gate blocked: the resolver could see the tag but was told not to use it.- // v2.0.148 (John's enum flood): during the resolution COOLDOWN, skip these OS-probe passes- // (the title find_window + the natural-title enum) โ they hammer ab every sweep for a session- // that already failed to resolve 3x, and if they COULD resolve it the backoff would never have- // accumulated. The 30s-throttled off-screen re-capture below still runs (v2.0.33), so a stuck- // background window still self-heals; we just stop the per-sweep probe storm meanwhile.- if (!_geomBackoff) try {- const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000, caller: sessionCaller(session) }));- const cnt = fw && (fw.count != null ? fw.count : (fw.data && fw.data.count));- const b = fw && (fw.best || (fw.data && fw.data.best));- if (cnt === 1 && b && b.hwnd) { session._hwnd = Number(b.hwnd); persistSessionHwnd(sessionId, Number(b.hwnd)); console.log(`[hwnd] "${sessionId}" resolved by unique TITLE tag -> ${b.hwnd}`); return Number(b.hwnd); }- } catch (e) {}- // v2.0.44 NATURAL-TITLE RESOLUTION (read-only, no tag, no geometry): the OS window title IS the- // active tab's page title (Chrome mirrors it), and pup already knows each session's active tab.- // Match this session's active-tab title against the native rows โ exactly-one match on a- // pup-owned pid is proof. This restores the v1.9.274 title-first doctrine WITHOUT the title tag- // that 2.0.24 removed as user-facing mangling: nothing is written anywhere, we just read what- // Chrome already shows. PID FENCE: candidate rows must belong to this profile's tracked pids or- // the recovery-continuity pid โ John's own Chrome (same window class!) can never be matched even- // if he has the identical page open. Same-title siblings yield 2+ matches and are refused- // (bare > wrong). This is what finally re-resolves recovered windows the geometry never could.- if (!_geomBackoff) try {- // v2.0.45 (the handle theft): the OS title mirrors the tab CHROME shows, which is NOT always- // pup's activeTabId (tab adds leave Chrome on the last-opened tab while pup's bookkeeping says- // the first). Asking the wrong title made this session uniquely match SOMEONE ELSE'S window.- // Ask Chrome: the tab whose document is 'visible' is the one the window's title mirrors.- let _pg0 = null;- for (const _t of (session.tabs || [])) {- if (!_t || !_t.page) continue;- try { const vis = await Promise.race([_t.page.evaluate(() => document.visibilityState), new Promise(r => setTimeout(() => r(''), 1200))]); if (vis === 'visible') { _pg0 = _t.page; break; } } catch (e) {}- }- if (!_pg0) { const _at = session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId); _pg0 = (_at && _at.page) || ((session.tabs || [])[0] && (session.tabs || [])[0].page); }- let _ptitle = '';- try { _ptitle = String(await Promise.race([_pg0.title(), new Promise(r => setTimeout(() => r(''), 2500))]) || '').trim(); } catch (e) {}- if (_ptitle.length >= 5) {- const _rows0 = await enumChromeWidgetWindows();- const _profPids0 = pupBrowserPidSet(session.profileName);- const _okPid = (p) => _profPids0.has(p) || (session._recoveredHwndPid && Number(p) === Number(session._recoveredHwndPid));- const _titleHit = (r) => r.title && (r.title === _ptitle || r.title.startsWith(_ptitle + ' - ') || r.title.startsWith(_ptitle + ' \u2013 '));- // v2.0.45: count title matches over ALL pup rows FIRST โ if two pup windows share this- // title, identity is unknowable by title even when ownership exclusions leave one row- // (excluding the true owner's row is exactly how the theft looked "unique"). Refuse.- const _allHits = _rows0.filter(r => _okPid(r.pid) && _titleHit(r));- const _tm = _allHits.length === 1 ? _allHits.filter(r => !hwndOwnedByOtherSession(r.h, sessionId)) : [];- if (_allHits.length > 1) console.log(`[hwnd] "${sessionId}" natural-title "${_ptitle.slice(0, 40)}" matches ${_allHits.length} pup windows โ ambiguous, refusing`);- if (_tm.length === 1) {- const got = Number(_tm[0].h);- session._hwnd = got; persistSessionHwnd(sessionId, got);- session._hwndFailStreak = 0; session._hwndBackoffUntil = 0;- try { const p0b = (session.tabs || [])[0]; if (p0b && p0b.page) p0b.page.__pupHwnd = got; } catch (e) {}- console.log(`[hwnd] "${sessionId}" resolved OS handle ${got} โ NATURAL-title match ("${_ptitle.slice(0, 60)}")`);- return got;- }- }- } catch (e) {}- const page = (session.tabs || [])[0] && (session.tabs || [])[0].page;- if (!page) return null;- // v2.0.314 (audit after John: "analyze all the polling... delete old code we don't use"): the- // geometry/wiggle tail is GONE (dev-skills/pup-no-wiggle). It opened a CDP session to read bounds- // it never used, carried a lock never taken, and ended in a literal `if (false)` recapture block.- // What remains is the ONE read-only, wiggle-free signal: if this profile's browser process owns- // exactly one top-level window, that window is this session's. Otherwise leave it bare; the- // property tag (set at birth) is how it gets resolved.- const rows = await enumChromeWidgetWindows();- const _profPids = pupBrowserPidSet(session.profileName);- const _profWins = new Set();- for (const r of rows) { if (r.h && _profPids.has(r.pid)) _profWins.add(r.h); }- let resolved = (_profWins.size === 1) ? [..._profWins][0] : null;- if (resolved && hwndOwnedByOtherSession(resolved, sessionId)) { console.log(`[hwnd] "${sessionId}" sole-window pick ${resolved} is owned by another session โ refusing`); resolved = null; }- if (resolved) {- session._hwnd = resolved;- persistSessionHwnd(sessionId, resolved);- console.log(`[hwnd] "${sessionId}" resolved OS handle ${resolved} โ sole window of profile process`);- return resolved;- }- session._hwndFailStreak = (session._hwndFailStreak || 0) + 1;- if (session._hwndFailStreak >= 3) {- session._hwndBackoffUntil = Date.now() + 5 * 60000;- console.log(`[hwnd] "${sessionId}" NOT resolved โ ${_profWins.size} windows on this profile, no property tag; leaving bare + backing off for 5 min`);- } else {- console.log(`[hwnd] "${sessionId}" NOT resolved โ ${_profWins.size} windows on this profile, no property tag; leaving bare rather than guessing`);- }- return null;- } catch (e) {- // v2.0.17: exceptions count toward the same backoff โ the EBUSY spin was exactly this path looping.- session._hwndFailStreak = (session._hwndFailStreak || 0) + 1;- if (session._hwndFailStreak >= 3) session._hwndBackoffUntil = Date.now() + 5 * 60000;- console.log(`[hwnd] "${sessionId}" bounds resolve failed: ${e.message}${session._hwndFailStreak >= 3 ? ' โ backing off resolution for 5 min' : ''}`);- return null;- }-}--async function pageWindowId(page) {- let cdp = null;- try {- cdp = await page.target().createCDPSession();- const { windowId } = await cdp.send('Browser.getWindowForTarget');- return windowId;- } catch (e) { console.log(`[drag] pageWindowId FAILED: ${e.message}`); return null; }- finally { try { if (cdp) await cdp.detach(); } catch (e) {} }-}-// Split any tab that no longer shares its session's window into its own session.-async function adoptDraggedOutTabs(sessionId) {- const session = sessions.get(sessionId);- if (!session || (session.tabs || []).length < 2) return 0;- let adopted = 0;- try {- // v1.9.71: do NOT log per-tab on every check. A 12-tab window emitted 12 "(same)" lines per- // check, and checks fire on window-opened events โ by far the biggest source of log volume.- // Only a tab that actually moved windows is worth a line.- const active = (session.tabs || []).find(t => t.tabId === session.activeTabId) || session.tabs[0];- const homeWin = await pageWindowId(active.page);-- if (homeWin == null) return 0;- for (const t of [...session.tabs]) {- if (t === active) continue;- const wid = await pageWindowId(t.page);- if (wid != null && wid !== homeWin) console.log(`[drag] "${sessionId}" ${t.tabId} moved to window ${wid} (home ${homeWin}) -> adopting`);- if (wid == null || wid === homeWin) continue;- // This tab lives in a DIFFERENT OS window now โ it was dragged out. Give it its own session.- const newId = `${sessionId}-split${adopted + 1}-${Date.now().toString(36).slice(-4)}`;- const idx = session.tabs.indexOf(t);- if (idx !== -1) session.tabs.splice(idx, 1);- if (session.activeTabId === t.tabId) session.activeTabId = session.tabs.length ? session.tabs[0].tabId : null;- const ns = createSession(session.profileName);- ns.owner = session.owner || null;- ns._wikiView = session._wikiView;- ns._wikiAuthed = session._wikiAuthed;- ns._publicProfile = session._publicProfile;- ns._sessionId = newId;- attachTab(ns, newId, t.page);- sessions.set(newId, ns);- // Re-tag the title FIRST: every downstream operation (identity stamp, overlay, jump list)- // resolves this window by its "(session: <id>)" suffix, and until it says the NEW id the- // window is invisible to AD and collides with its old siblings.- await retagSessionTitle(t.page, newId, ns.profileName);- const be = browsers.get(session.profileName);- if (be) be.refCount++;- let u = null; try { u = t.page.url(); } catch (e) {}- console.log(`[drag] "${sessionId}" tab dragged out โ adopted as session "${newId}" (${(u || '').slice(0, 60)})`);- adopted++;- // Brand the new window: its own identity, CATEGORY icon, overlay and jump list. Retried,- // because a freshly created window is not findable by title for a beat.- refreshWindowChrome(ns, newId).catch(() => {});- try { saveSessionFile(newId, ns.profileName, u); } catch (e) {}- }- if (adopted) {- // The SOURCE window lost a tab: its category may have changed (mixed -> wiki) and its overlay- // favicon may belong to the tab that just left. Force a full, retried refresh.- session._lastCat = undefined; // defeat the same-category short-circuit- _appOverlayCache.delete(sessionId + ':explicit');- // Re-assert the source window's own tag (its tab set changed under it).- try {- const at = (session.tabs || []).find(x => x.tabId === session.activeTabId) || session.tabs[0];- if (at) await retagSessionTitle(at.page, sessionId, session.profileName);- } catch (e) {}- refreshWindowChrome(session, sessionId).catch(() => {});- }- } catch (e) { console.log(`[drag] adopt threw: ${e.message}`); }- return adopted;-}-// v1.9.47 โ RECLAIM UNMANAGED WINDOWS. The gap that kept John's taskbar wrong.-//-// adoptDraggedOutTabs only ever looked at tabs pup ALREADY TRACKS, so it could never see a window-// pup has no tab for. Two everyday cases produce exactly that, and both leave a permanently-// UNBRANDED taskbar button (raw "Google Chrome for Testing"), which is what a taskbar audit found:-// 1. An OPENERLESS new window. wirePopupTracking deliberately drops targets with no opener (it-// must not await target.page() on one โ that re-enters the CDP pipe and hangs newPage; see-// v1.8.18). Correct there, but nothing ever picked those windows up afterwards. Measured: one-// ti.com session had spawned TWO untracked windows (an stm32 search + a product page).-// 2. A window whose SESSION DIED but whose Chrome kept running โ e.g. the session file was lost,-// so pup is not even connected to that profile any more.-//-// So reclamation must work from the WINDOW side, not the tab side: enumerate every page in every-// pup profile, group by OS window, and adopt any window no session owns. Runs on a timer (out of-// band โ never inside a targetcreated handler, to keep the pipe safe).-// v1.9.279 THE RECONCILER (John: "your code must be 100% correct... i even just saw another ai-// thread close an aa.com pup window and then you left the aa.com icon in your overlay"). Every bug-// this session was the same class: pup's in-memory model drifting from OS reality (a closed window-// lingering, a stamp not updated when a setting changed, a recycled hwnd). This one pass, run-// UNGATED every few seconds and immediately after any setting change or tab close, makes the model-// match reality. It NEVER kills a process (that stays in the gated reclaim path); it only corrects-// pup's own bookkeeping and the taskbar identity, both cheap and idempotent.-let _reconcileBusy = false;-// v1.9.284: apply an ownerChip toggle to already-open pages immediately (off removes it; on adds it),-// so the user does not have to navigate for the change to show. New navigations are handled by the-// inject-time gate in attachTab.-async function applyOwnerChipSetting(on) {- if (process.platform !== 'win32') return;- // v1.9.344 (John: turning the chip off "Request timed out"): a single wedged/slow page (e.g. a heavy- // 3D-viewer tab whose renderer is busy) used to hang the whole sweep because each page.evaluate was- // awaited with no bound. Race every per-page evaluate against a short timeout so one stuck tab can- // never block toggling the chip across every other window.- const evalBounded = (page, fn, ...a) => Promise.race([- page.evaluate(fn, ...a).catch(() => {}),- new Promise(r => setTimeout(r, 1500)),- ]);- for (const [sid, s] of [...sessions]) {- for (const t of (s.tabs || [])) {- if (!t || !t.page) continue;- try {- if (!on) {- await evalBounded(t.page, () => { try { const el = document.documentElement.querySelector('div[data-pup-owner]'); if (el) el.remove(); } catch (e) {} });- } else {- const thread = String(sid).replace(/^ai-/, '');- const prof = s.profileName || 'adom-you';- const avatar = pupSettings().identityAvatarB64 || '';- const ident = pupSettings().identityName || '';- await evalBounded(t.page, (thread2, profile, avatar2, ident2) => {- try {- const root = document.documentElement; if (!root || root.querySelector('div[data-pup-owner]')) return;- const host = document.createElement('div'); host.setAttribute('data-pup-owner', '1');- host.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483647;pointer-events:none;margin:0;padding:0;';- const sh = host.attachShadow ? host.attachShadow({ mode: 'closed' }) : null;- const lead = avatar2 ? '<img class="av" alt="" src="data:image/png;base64,' + avatar2 + '">' : '<span class="hx">โฌข</span>';- const html = '<style>.c{display:flex;align-items:center;gap:6px;font:11px/1.35 "JetBrains Mono",ui-monospace,monospace;color:#7fe9e2;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);border-radius:13px;padding:2px 9px 2px 3px;white-space:nowrap;opacity:.92;user-select:none}.c b{color:#e6edf3;font-weight:600}.av{width:20px;height:20px;border-radius:50%;object-fit:cover;flex:0 0 auto;border:1px solid rgba(0,184,177,.55)}.hx{padding-left:5px}</style>'- + '<div class="c" title="' + (ident2 ? ident2 + ' โ ' : '') + 'Adom pup">' + lead + '<span><b>' + thread2 + '</b> ยท ' + profile + '</span></div>';- if (sh) sh.innerHTML = html; else host.innerHTML = html;- root.appendChild(host);- } catch (e) {}- }, thread, prof, avatar, ident);- }- } catch (e) {}- }- }-}-// v2.0.236: four per-session Maps were written but never cleaned (_unstickLast, _unstickClickAt,-// _fgToastAt, _extEnabledFor) โ small, but they grew for the process lifetime and every future-// per-session map would repeat the mistake. One reaper, called from every teardown path.-function forgetSessionState(sid) {- for (const m of [_unstickLast, _unstickClickAt, _fgToastAt, _extEnabledFor]) {- try { m.delete(sid); } catch (e) {}- }-}-// v2.0.236: this exact 6-line preamble ("resolve the session or 400") was pasted into six verb-// cases. One helper; identical semantics (no auto-recovery โ these are the verbs that must act on a-// window that is ALREADY live, and silently relaunching one would be a different verb).-function requireLiveSession(res, args) {- const sessionId = args.sessionId || activeSessionId;- if (!sessionId || !sessions.has(sessionId)) {- sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });- return null;- }- return { sessionId, session: sessions.get(sessionId) };-}-// v2.0.239: /health, /status and the pup_status verb each hand-built the same core status fields-// (version, enum accounting, identity settings, session/active counts). The identical-// `settings: (() => {...})()` IIFE was pasted three times and the enum expression character for-// character. One builder; each surface spreads it and adds only what is genuinely its own.-function statusCore() {- const st = pupSettings();- return {- version: BRIDGE_VERSION,- // enum_windows accounting, so "is pup spamming ab?" is answered with a number, not an argument.- // lastMin counts REAL ab calls in the last 60s (cache hits and coalesced callers are not counted).- enumWindows: { total: _enumCalls, lastMin: (Date.now() - _enumWindow.at < 60000 ? _enumWindow.n : 0) },- win32: { inProcess: win32.available(), why: win32.why() }, // v2.0.310: koffi loaded? (else PowerShell fallback)- log: _logRing.slice(-120),- loops: _loopRing.slice(-10), // #80: cadence kept separate so it cannot evict the events- loops: loopReport(),- win32Calls: win32.stats ? win32.stats() : {},- // the CURRENT identity mode, so a test harness asserts against what pup IS, not an assumption- settings: {- taskbarMode: st.aumidIcons !== 'on' ? 'plain' : (st.threadTiles === 'on' ? 'tiles' : 'aumid'),- aumidIcons: st.aumidIcons, threadTiles: st.threadTiles,- overlayBadges: st.overlayBadges, overlaySize: st.overlaySize, jumpLists: st.jumpLists,- },- iconAudit: _lastAudit ? { ok: _lastAudit.ok, at: _lastAudit.at, mode: _lastAudit.mode, findings: _lastAudit.findings.length, repaired: _lastAudit.repaired, reason: _lastAudit.reason } : null,- sessionCount: sessions.size,- activeSession: activeSessionId,- };-}-// v2.0.239: the same 10-line C# raise helper was pasted into two script builders in the raise-// verb (and a third, already-deleted copy). One source of truth; callers append the invocation.-const PS_RAISE_TYPEDEF = [- 'Add-Type @"',- 'using System; using System.Runtime.InteropServices;',- 'public class WinFocusH {',- ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',- ' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);',- ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',- ' [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',- ' [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr h, int i, int v);',- // background windows carry WS_EX_NOACTIVATE (they can never take focus). To actually SHOW one,- // CLEAR that style first, then restore + foreground it.- ' public static void Raise(IntPtr h){',- ' int ex = GetWindowLong(h, -20); SetWindowLong(h, -20, ex & ~0x08000000);',- ' if (IsIconic(h)) ShowWindow(h, 9); ShowWindow(h, 5); SetForegroundWindow(h);',- ' }',- '}',- '"@',-];-// v2.0.240: the Chrome-style generic icons (grey globe / PDF sheet) were written out as SVG string-// literals in TWO different no-favicon paths โ the second was added because the first was missed,-// and the comment above it says so. One builder now, so the glyph a user sees cannot depend on-// which path produced it.-function genericIconSvg(isPdf) {- const plate = '<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>';- return isPdf- ? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">' + plate- + '<path d="M13 8 h15 l8 8 v24 a1 1 0 0 1-1 1 H14 a1 1 0 0 1-1-1 V9 a1 1 0 0 1 1-1 Z" fill="#ffffff" stroke="#9aa0a6" stroke-width="1.6"/>'- + '<path d="M28 8 v8 h8 Z" fill="#dadce0"/>'- + '<rect x="13" y="26" width="23" height="12" rx="2" fill="#e34f4f"/>'- + '<text x="24.5" y="35.4" font-family="Segoe UI,Arial,sans-serif" font-size="9" font-weight="700" fill="#ffffff" text-anchor="middle">PDF</text></svg>'- : '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">' + plate- + '<circle cx="24" cy="24" r="13" fill="none" stroke="#5f6368" stroke-width="3"/>'- + '<ellipse cx="24" cy="24" rx="5.6" ry="13" fill="none" stroke="#5f6368" stroke-width="2.6"/>'- + '<path d="M11.4 24 H36.6" stroke="#5f6368" stroke-width="2.6"/>'- + '<path d="M13.6 16.4 H34.4 M13.6 31.6 H34.4" stroke="#5f6368" stroke-width="2"/></svg>';-}-// v2.0.240: escaping a string for use inside a PowerShell -match pattern was written out twice as-// the same dense character class. One helper, so a fix to the escaping cannot land in only one scan.-function psRegexEscape(v) {- return String(v).replace(/[\\.^$*+?()[\]{}|]/g, '\\$&');-}-// v2.0.242 (John, pup-bridge#58: "pup_alert_window fails after a tab switch changes the window-// title โ resolve by hwnd, not title"). The STRONGEST handle pup owns is the one it captured when it-// created the window, stored on the page as __pupHwnd. It survives title changes, tab switches and-// parking, and it needs no enumeration. Nothing consulted it outside the ownership proof, so a-// session whose cached _hwnd went missing fell straight through to a title lookup that cannot match-// while titleTag is off (its default). Order: cached handle, birth-captured handle, bounds probe.-function birthHwnd(session) {- try {- for (const t of (session && session.tabs) || []) {- const h = t && t.page && Number(t.page.__pupHwnd);- if (h) return h;- }- } catch (e) {}- return null;-}-// Tag a confirmed (session, hwnd) with its durable "AdomPupSid=<sid>" window property, exactly ONCE-// per handle (dev-skills/pup-no-wiggle). The guard avoids spawning a SetProp PowerShell on every-// resolve โ it fires only when a handle is newly confirmed for a session, which is exactly when the-// durable tag must be (re)written so the window stays re-findable after any future handle loss.-function _ensureWindowTagged(session, sessionId, hwnd) {- if (!session || !hwnd || !sessionId) return;- if (Number(session._taggedHwnd) === Number(hwnd)) return;- session._taggedHwnd = Number(hwnd);- tagWindowSession(Number(hwnd), sessionId).then(ok => {- if (ok) console.log(`[identity] "${sessionId}" tagged window ${hwnd} with ${PUP_WIN_PROP} (durable; survives crash/restart, re-found by property lookup)`);- else session._taggedHwnd = null; // let a later resolve retry the tag- }).catch(() => { session._taggedHwnd = null; });-}-async function bestSessionHwnd(session, sessionId) {- if (!session) return null;- if (session._hwnd) { _ensureWindowTagged(session, sessionId, Number(session._hwnd)); return Number(session._hwnd); }- const b = birthHwnd(session);- if (b) { session._hwnd = b; _ensureWindowTagged(session, sessionId, b); return b; }- try { const r = await resolveSessionHwndByBounds(session, sessionId); if (r) return Number(r); } catch (e) {}- return null;-}-async function reconcileModel(reason) {- if (process.platform !== 'win32' || _reconcileBusy) return;- _reconcileBusy = true;- try {- // (1) REAP any session whose windows are all gone. This is the aa.com fix: a window closed by- // ANOTHER thread (or the user) leaves a session with only closed page handles. Unlike the old- // reaper, we do NOT require the whole profile's Chrome to be dead โ one closed window on a live- // shared profile is still a dead session, and its lingering badge/identity is the "stale icon".- for (const [sid, s] of [...sessions]) {- if (_launchInFlight.has(s.profileName)) continue;- // v1.9.305: !_healGaveUp โ a session the healer is still trying to bring back (lost to sleep) is- // NEVER reaped, even at 0 tabs, until the healer explicitly gives up. This is the reaper-vs-- // recovery race fix (John lost his windows to sleep). rescanProfile keeps _disconnectedAt fresh.- if (s._lostBrowser && !s._healGaveUp && s._disconnectedAt && (Date.now() - s._disconnectedAt < 120000)) continue; // heal window- let anyOpen = false;- for (const t of (s.tabs || [])) { try { if (t && t.page && !t.page.isClosed()) { anyOpen = true; break; } } catch (e) {} }- if (anyOpen) continue;- // v2.0.130 (#46 P0): the "no open windows" verdict above is CDP-DERIVED and it lied once โ- // heavy churn (ghost adoption + takeover closes + respawns) left every page handle closed- // while John's windows were open and visible; this reap then fed the janitor, which killed- // the live Chrome mid-annotation. GROUND TRUTH before destruction: if the OS still shows- // this session's window alive + visible, the verdict is wrong โ spare the session and try to- // re-attach via rescan instead. Ground truth failing to find a window (or erroring) falls- // through to today's reap, so a genuinely closed session still gets cleaned.- let _osAlive = false;- if (_isWin) {- try {- let h = s._hwnd ? Number(s._hwnd) : null;- if (h && !(await osHwndAlive(h))) h = null;- if (!h) { const h2 = await sessionHwnd(sid); if (h2 && (await osHwndAlive(h2))) h = h2; }- if (h) _osAlive = true;- if (_osAlive) {- console.log(`[reconcile] SPARED session "${sid}" โ CDP shows no open pages but OS window ${h} is alive+visible (ground truth overrules, #46); attempting rescan re-attach`);- const be = browsers.get(s.profileName);- if (be && be.browser) { rescanProfile(be.browser, s.profileName, { adoptOrphans: false }).catch(() => {}); }- }- } catch (e) {}- }- if (_osAlive) continue;- try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {}- try { fs.unlinkSync(path.join(require('os').tmpdir(), 'pup-badge-debug', sid + '.png')); } catch (e) {}- try { _tabFavCache && s.tabs && s.tabs.forEach(() => {}); } catch (e) {}- sessions.delete(sid); forgetSessionState(sid);- try { deleteSessionFile(sid); } catch (e) {}- console.log(`[reconcile] reaped closed session "${sid}" (no open windows${reason ? ', ' + reason : ''}) โ removed its badge + taskbar identity`);- }- // (1b) ORPHAN BADGE SWEEP: a session removed via the close VERB (or the dashboard close button)- // is deleted from the map before this reaper sees it, so its badge PNG would leak and keep being- // served by /dashboard/badge (the stale aa.com icon). Sweep the debug dir and delete any- // <sid>.png whose session no longer exists โ the one cleanup path that covers EVERY way a window- // can close. base-<profile>.png (browser exe icons) are keyed differently and left alone.- try {- const dir = path.join(require('os').tmpdir(), 'pup-badge-debug');- for (const f of fs.readdirSync(dir)) {- if (!f.endsWith('.png') || f.startsWith('base-')) continue;- const sid = f.slice(0, -4);- if (!sessions.has(sid)) { try { fs.unlinkSync(path.join(dir, f)); console.log(`[reconcile] deleted orphaned badge for closed session "${sid}"`); } catch (e) {} }- }- } catch (e) {}- // (1c) OWNER CHIP CONSISTENCY (John: "i can't see it anywhere"): the inject-time chip only- // reaches pages navigated AFTER a deploy/toggle, so existing windows had none. Keep every live- // page in sync with the setting here: add the chip where missing (on), remove it (off). Cheap- // and idempotent (one querySelector per page), pointer-events:none so it never blocks anything.- // v2.0.314 (audit): this block ran up to TWO page.evaluate per tab every 8s, forever, waking- // throttled renderers to re-assert settings that change only when a human flips a toggle. It- // now runs on the first tick after a settings change and then once a minute as a safety net.- const _enfSig = [pupSettings().ownerChip, pupSettings().titleTag, pupSettings().titlePrefix].join('|');- const _enfDue = (_enfSig !== reconcileModel._enfSig) || (Date.now() - (reconcileModel._enfAt || 0) > 60000);- if (_enfDue) { reconcileModel._enfSig = _enfSig; reconcileModel._enfAt = Date.now(); }- if (_enfDue) try {- const chipOn = pupSettings().ownerChip === 'on';- const _tagOn = pupSettings().titleTag === 'on';- const _prefixOn = pupSettings().titlePrefix === 'on';- // v1.9.286: bring EXISTING windows to the current title settings (new navs are handled by the- // injector/setTitleDecor gates). Strips the (ai-thread: X) suffix when titleTag is off, and- // safely removes ONLY pup's own known decor prefix when titlePrefix is off (never a page's- // real " ยท " title), disconnecting the decor observer so it cannot re-add it.- if (!_tagOn || !_prefixOn) {- for (const [sid, s2] of [...sessions]) {- if (s2._resolvingHwnd) continue; // v1.9.288: don't strip a transient resolve tag- for (const t of (s2.tabs || [])) {- if (!t || !t.page) continue;- try {- const esc = sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');- await t.page.evaluate((tagOn, prefixOn, escSid) => {- try {- if (!tagOn) {- // disconnect the stripping observer on existing pages so it stops fighting the- // transient resolution tag (the dup-hwnd cause); strip any baked tag once.- try { if (window.__pupTagObs) { window.__pupTagObs.disconnect(); window.__pupTagObs = null; } } catch (e) {}- const re = new RegExp('\\s*\\((?:session|ai-thread): ' + escSid + '(?:[^)]*)\\)', 'g'); const c = String(document.title || '').replace(re, ''); if (c !== document.title) document.title = c;- }- if (!prefixOn) {- try { if (window.__pupDecorObs) { window.__pupDecorObs.disconnect(); window.__pupDecorObs = null; } } catch (e) {}- const tag = window.__pupThreadTag;- let out = String(document.title || '');- const pull = () => {- if (tag && out.indexOf(tag + ' \u00b7 ') === 0) { out = out.slice(tag.length + 3); return true; }- const m = out.match(/^(?:\u25CF Logged in|\u25CB Public|\uD83D\uDD13 Logged in|\uD83D\uDCD6 Public) \u00b7 /);- if (m) { out = out.slice(m[0].length); return true; }- return false;- };- for (let i = 0; i < 4 && pull(); i++) {}- window.__pupThreadTag = null; window.__pupWikiLabel = null;- if (out !== document.title) document.title = out;- }- } catch (e) {}- }, _tagOn, _prefixOn, esc);- } catch (e) {}- }- }- }- for (const [sid, s] of [...sessions]) {- for (const t of (s.tabs || [])) {- if (!t || !t.page) continue;- try {- if (!chipOn) { await t.page.evaluate(() => { try { const e = document.documentElement.querySelector('div[data-pup-owner]'); if (e) e.remove(); } catch (x) {} }); continue; }- const thread = String(sid).replace(/^ai-/, ''); const prof = s.profileName || 'adom-you';- await t.page.evaluate((thread2, profile) => {- try {- const root = document.documentElement; if (!root || root.querySelector('div[data-pup-owner]')) return;- const host = document.createElement('div'); host.setAttribute('data-pup-owner', '1');- host.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483647;pointer-events:none;margin:0;padding:0;';- const sh = host.attachShadow ? host.attachShadow({ mode: 'closed' }) : null;- const html = '<style>.c{font:11px/1.35 "JetBrains Mono",ui-monospace,monospace;color:#7fe9e2;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);border-radius:6px;padding:2px 8px;white-space:nowrap;opacity:.9;user-select:none}.c b{color:#e6edf3;font-weight:600}</style>'- + '<div class="c">โฌข <b>' + thread2 + '</b> ยท ' + profile + '</div>';- if (sh) sh.innerHTML = html; else host.innerHTML = html;- root.appendChild(host);- } catch (x) {}- }, thread, prof);- } catch (e) {}- }- }- } catch (e) {}- // (1d) DUPLICATE-HWND SELF-HEAL (John caught two windows sharing one handle): a stale cache can- // pin a session to another same-profile window's hwnd, and per-profile validation cannot tell- // two windows of one process apart. So detect any hwnd claimed by >1 live session and clear the- // cache on ALL of them; the next resolve is now reliable (transient tag, no fighting observer),- // so each re-binds to its own unique window. Cheap: one pass over the in-memory sessions.- try {- const byHwnd = new Map();- for (const [sid, s2] of sessions) { const h = s2 && s2._hwnd; if (!h) continue; if (!byHwnd.has(h)) byHwnd.set(h, []); byHwnd.get(h).push(sid); }- for (const [h, sids] of byHwnd) {- if (sids.length < 2) continue;- for (const sid of sids) { const s2 = sessions.get(sid); if (s2) { s2._hwnd = null; s2._appOverlay = false; s2._overlayEnforceAt = 0; } }- console.log(`[reconcile] hwnd ${h} was claimed by ${sids.length} sessions (${sids.join(', ')}) โ cleared all to force unique re-resolve`);- }- } catch (e) {}- // (2) STAMP CONSISTENCY: make every live window's taskbar identity match the CURRENT settings.- // stampPupIdentity is idempotent (no-op unless the desired appId differs), so this both applies a- // grouped<->split or style change to existing windows AND stamps any window that never got one.- if (pupSettings().aumidIcons === 'on' && _identitySupported !== false) {- for (const [sid, s] of [...sessions]) {- if (!(s.tabs || []).length) continue;- try { await stampPupIdentity(sid, {}); } catch (e) {}- }- }- } finally { _reconcileBusy = false; }-}-async function reclaimUnmanagedWindows() {- let reclaimed = 0;- const handledByRescan = new Set();- try {- // v1.9.51 โ Part 0: a session whose CDP DISCONNECTED but whose Chrome window is still alive.- // handleBrowserDisconnect deletes the browser entry and marks the session `_lostBrowser` with- // ZERO tabs; nothing re-evaluated it, so it lingered as a zombie stamped ".plain" and fell back- // to the OLD default pup icon (exactly the stale icon John flagged on the shotlog window). If its- // Chrome is truly gone, drop the session AND unregister its AUMID so no stale taskbar identity is- // left; if its Chrome is alive, Part B below reconnects and rescanProfile reattaches it by title.- for (const [sid, s] of [...sessions]) {- // v1.9.119: do NOT require _lostBrowser. That flag is only set when a DISCONNECT EVENT fires;- // a Chrome killed outright (the bridge-restart tree kill) never emits one, so the session sat- // with 0 tabs and no flag and was never dropped. Zero tabs plus a dead port IS a zombie,- // however it got that way. (A session mid-launch has no tabs yet, so respect _launchInFlight.)- // v1.9.120: "0 tabs" is NOT the test. Killing Chrome outright does not empty s.tabs โ the array- // still holds now-dead page handles โ so my 1.9.119 gate skipped exactly the zombie it was meant- // to catch (verified: killed a window's Chrome, the session stayed listed with tabCount 1).- // The real test is whether every tab page is CLOSED/detached; that plus a dead CDP port below is- // a definitive zombie. Conservative on purpose: if ANY tab still reports open we leave it alone,- // because wrongly dropping a live session is far worse than a lingering dead one.- if (_launchInFlight.has(s.profileName)) continue;- // v1.9.172 (crash-heal race fix): do NOT reap a session the crash self-heal is still trying to- // recover. handleBrowserDisconnect marks a crashed session `_lostBrowser` + stamps- // `_disconnectedAt`; the health/heal loop then relaunches it. If reclaim dropped it (and deleted- // its file) first, the heal failed "session not found" (seen live under stress). Give the heal a- // protected 2-minute window; after that, if it still hasn't come back, reclaim reaps it as before.- if (s._lostBrowser && s._disconnectedAt && (Date.now() - s._disconnectedAt < 120000)) continue;- let anyTabOpen = false;- for (const t of (s.tabs || [])) {- try { if (t && t.page && !t.page.isClosed()) { anyTabOpen = true; break; } } catch (e) { /* detached = closed */ }- }- if (anyTabOpen) continue;- let port = null;- try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, s.profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {}- const alive = port && await fetch(`http://127.0.0.1:${port}/json/version`).then(r => r && r.ok).catch(() => false);- if (alive) continue; // Part B will reconnect + rescanProfile reattaches this same session- recordCleanup(`dropped zombie session "${sid}" (0 tabs, its Chrome was gone) and unregistered its taskbar identity โ it had been showing up in pup_list_windows for every thread`);- try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {}- sessions.delete(sid); forgetSessionState(sid);- try { deleteSessionFile(sid); } catch (e) {}- }-- // Part B: pup profiles whose Chrome is alive but that pup is not connected to. Covers a fully- // orphaned profile AND a session whose CDP dropped (browser entry deleted on disconnect).- let profileDirs = [];- try { profileDirs = fs.readdirSync(PROFILES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name); } catch (e) {}- for (const profileName of profileDirs) {- if (browsers.has(profileName)) continue;- let port = null;- try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {}- if (!port) continue;- const resp = await fetch(`http://127.0.0.1:${port}/json/version`).catch(() => null);- if (!resp || !resp.ok) continue; // stale port file, Chrome is gone โ leave it to the reaper- try {- const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });- browser._cdpPort = port;- browsers.set(profileName, { browser, refCount: 0 });- wirePopupTracking(browser, profileName);- browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));- console.log(`[reclaim] re-connected to profile "${profileName}" on port ${port} (Chrome outlived its CDP link)`);- // v1.9.51: reattach by TITLE TAG. rescanProfile reuses the EXISTING "(session: <id>)" session- // instead of minting a new "<profile>-winXXXX" (which would leave the tagged session a zombie- // AND duplicate the window). This is what actually un-zombies shotlog and restores its icon.- try {- const st = await rescanProfile(browser, profileName, { adoptOrphans: true });- handledByRescan.add(profileName);- for (const [sid, s] of sessions) {- if (s.profileName === profileName && !s._lostBrowser && (s.tabs || []).length) {- s._lastCat = undefined; // force a real category re-evaluation- refreshWindowChrome(s, sid).catch(() => {});- }- }- if (st && (st.reattached || st.adopted)) reclaimed += (st.reattached + st.adopted);- } catch (e) { console.log(`[reclaim] rescan of "${profileName}" threw: ${e.message}`); }- } catch (e) { continue; }- }-- // Part A: any page in a connected profile that no session owns.- for (const [profileName, be] of [...browsers]) {- if (handledByRescan.has(profileName)) continue; // rescanProfile already reconciled this one- // v1.9.50: NEVER reclaim from a profile with a launch in flight. A launching window exists as- // a page for a moment BEFORE its session is registered, so a sweep landing in that gap sees a- // legitimate window as unowned and adopts it โ producing TWO sessions for ONE window (caught- // by the audit as 8 sessions / 7 taskbar buttons, with "r4d" and "r4d-wina4rd" both pointing- // at the same window). Reclaim is a janitor: when a launch is in progress the answer is simply- // "not yet", never "adopt it".- if (_launchInFlight.has(profileName)) continue;- let pages = [];- try { pages = await be.browser.pages(); } catch (e) { continue; }- const owned = new Set();- for (const s of sessions.values()) for (const t of (s.tabs || [])) owned.add(t.page);- const loose = [];- for (const p of pages) {- if (owned.has(p)) continue;- let u = ''; try { u = p.url(); } catch (e) { continue; }- // about:blank is how a window is born; devtools/chrome/extension pages are not user- // content. v2.0.138: the old regex (chrome:) did NOT match chrome-extension:, so pup's- // own toolbar popup bubble got adopted as a ghost window on every click โ the source of- // the find_window flood John caught in the Activity Log.- if (isNonAdoptableUrl(u)) continue;- loose.push(p);- }- if (!loose.length) continue;- // Group by OS window so tabs of the SAME reclaimed window land in ONE session (not one each).- const byWindow = new Map();- for (const p of loose) {- let wid = null;- try { wid = await pageWindowId(p); } catch (e) {}- const key = wid == null ? `nowin-${loose.indexOf(p)}` : `w${wid}`;- if (!byWindow.has(key)) byWindow.set(key, []);- byWindow.get(key).push(p);- }- for (const [key, group] of byWindow) {- // v1.9.50: two-strike confirmation. Even outside a tracked launch there are brief windows- // where a page is real but not yet owned (a tab being attached, a session being rebuilt).- // Adopting on first sight turns any such blink into a duplicate session that can never be- // reconciled. Require the SAME window to look unowned on two consecutive sweeps; anything- // transient resolves itself in between and is never touched.- // v1.9.212: refuse to adopt a window that a LIVE session already owns by hwnd. Without this- // the janitor minted a new session every 90s for the same unresolvable PDF window, splitting- // its tabs across sessions (which is why one painter saw only PDFs and reported no favicon).- try {- let _wh = null;- try { _wh = await pageWindowId(group[0]); } catch (e) {}- if (_wh != null) {- let owned = false;- for (const [, s2] of sessions) {- for (const t2 of (s2.tabs || [])) {- try { if ((await pageWindowId(t2.page)) === _wh) { owned = true; break; } } catch (e) {}- }- if (owned) break;- }- if (owned) { console.log(`[reclaim] skipping window ${key} โ a live session already owns this OS window (no duplicate session)`); continue; }- }- } catch (e) {}- const strikeKey = `${profileName}:${key}`;- const strikes = (_reclaimStrikes.get(strikeKey) || 0) + 1;- if (strikes < 2) {- _reclaimStrikes.set(strikeKey, strikes);- console.log(`[reclaim] "${profileName}" window ${key} looks unowned (strike ${strikes}/2) โ waiting one more sweep before adopting`);- continue;- }- _reclaimStrikes.delete(strikeKey);- const newId = `${profileName}-win${Math.random().toString(36).slice(2, 6)}`;- const ns = createSession(profileName);- ns._sessionId = newId;- // v2.0.355 (issue #79). NEVER write a sentinel into `owner`. `owner` is the AI-THREAD NAME- // and tiles mode RENDERS it on the taskbar, so this line put a button on John's taskbar- // labelled "reclaimed", wrapped as "recl"/"aime", after his laptop slept overnight. His- // words: "it looks like some kind of weird 'reclaimed' pup window that pup created, which- // is horrific." An internal placeholder was being displayed as if it were a person's thread.- //- // The absence of an owner is what the adopted window HAS, so record it as absence: null.- // tileOwnerName() already handles that properly, deriving a stable readable name from the- // sessionId, so the tile stays a name tile and never shows an internal word. How it was- // adopted is diagnostic, so it goes in its own field where nothing renders it.- ns.owner = null;- ns._adoptedBy = 'reclaim';- for (const p of group) attachTab(ns, newId, p);- sessions.set(newId, ns);- be.refCount++;- let u = null; try { u = group[0].url(); } catch (e) {}- // Title FIRST โ every downstream brand step resolves the window by its "(session: <id>)".- try { await retagSessionTitle(group[0], newId, profileName); } catch (e) {}- // v2.0.357 (issue #79, the half that outlives the sentinel): TAG THE OS WINDOW.- // pup writes its identity property at BIRTH, and re-writes it on every successful resolve.- // An ADOPTED window was never born under pup, so it carried no tag and nothing here added- // one, which made it permanently unresolvable: "[hwnd] NOT resolved โ N windows on this- // profile, no property tag". That is not cosmetic. hwnd resolution is what decides which- // OS window a taskbar tile gets stamped onto, so an untagged window makes the NEXT window's- // tile land on the wrong button. John saw a brand-new thread's window appear under the- // adopted window's button and asked why his window was being put inside it. This is why.- // The title we just wrote is unique at this instant, so it is the one moment the hwnd can- // be recovered without guessing, and the tag then survives every later handle loss.- try {- const _mark = `${TITLE_LABEL}: ${newId}`;- // v2.0.360: RETRY. 2.0.357 looked exactly once, immediately after writing the title, and- // missed every time ("could not find its OS window by title" on all three adoptions in- // one restart, found in the event log the moment #80's ring fix made events visible).- // The DOM title is set synchronously by page.evaluate; the OS WINDOW title is updated by- // Chrome a frame or two later, so a single in-process EnumWindows pass reliably runs- // before the string it is searching for exists. Poll briefly instead of guessing a sleep.- let _hit = null;- for (let _i = 0; _i < 12 && !_hit; _i++) {- _hit = win32.findChromeWindowByTitle(_mark) || null;- if (!_hit) await new Promise(r => setTimeout(r, 60));- }- if (_hit && _hit.hwnd) {- ns._hwnd = Number(_hit.hwnd);- await tagWindowSession(_hit.hwnd, newId);- console.log(`[reclaim] "${newId}": tagged OS window ${_hit.hwnd} so it stays resolvable (was untagged: adopted windows never went through birth)`);- // #80: an adopted window is one pup found unowned. Whichever thread it USED to belong to- // is not addressable any more, so the notice goes on the new session and the janitor- // queue carries the broadcast, because the original owner cannot be reached by name.- } else {- console.log(`[reclaim] "${newId}": could not find its OS window by title โ it will stay untagged and unresolvable until a later resolve succeeds`);- }- } catch (e) { console.log(`[reclaim] "${newId}": tagging failed: ${e.message}`); }- // #80: the notice is about the ADOPTION, not about whether tagging succeeded, so it is set- // unconditionally. Tying it to the tag meant a failed tag also swallowed the explanation.- noteSessionDisruption(ns, `pup adopted this window after a restart: it was running but no session owned it. Its original AI thread, if any, has lost its handle to it and must re-open. Nothing was closed.`);- console.log(`[reclaim] UNMANAGED window on "${profileName}" (${group.length} tab(s)) adopted as "${newId}" โ ${(u || '').slice(0, 60)}`);- refreshWindowChrome(ns, newId).catch(() => {});- // v1.9.202: an ADOPTED window also needs its badge โ refreshWindowChrome is gated off under- // minimalTouch, so without this a reclaimed window (John's "durable-win55fa", 4 PDF tabs)- // stays permanently unbadged.- setTimeout(() => { applyAppOverlay(ns, newId).catch(() => {}); }, 2500);- // v1.9.70: a RECLAIMED window was never parked either. These are windows pup did not launch- // (an openerless popup, or an orphan whose session died), so they arrive wherever the shell- // put them โ usually on top. Background them like any pup-launched window.- // v1.9.181: โฆUNLESS the user is LOOKING AT IT. Adoption often re-claims a window whose- // session a crash dropped โ a window the user may be actively reading. Parking it then- // yanks it to the bottom mid-use (John hit this repeatedly). If it is the OS-foreground- // window, respect the user: mark it foreground instead of parking.- (async () => {- try {- const fg = await osGetForegroundWindow();- const hw = fg ? await cachedSessionHwnd(newId) : null;- if (fg && hw && String(fg) === String(hw)) {- ns._foreground = true; ns._userActiveAt = Date.now();- try { saveSessionFile(newId, profileName, u); } catch (e) {}- console.log(`[park] "${newId}" (reclaimed): SKIPPED โ it is the user's foreground window; marked foreground instead`);- return;- }- const r = await parkSessionWindowDirect(group[0], newId);- console.log(`[park] "${newId}" (reclaimed): ${r}`);- } catch (e) {}- })();- try { saveSessionFile(newId, profileName, u); } catch (e) {}- reclaimed++;- }- }- } catch (e) { console.log(`[reclaim] threw: ${e.message}`); }- if (reclaimed) {- console.log(`[reclaim] adopted ${reclaimed} previously-unbranded window(s)`);- // #80: the broadcast half. A thread whose session vanished cannot be told directly (its id is- // gone), so the janitor queue is the only channel that reaches anyone at all.- recordCleanup(`adopted ${reclaimed} window(s) that were running with no owning session, usually after a bridge upgrade. If a thread is missing tabs it opened, they were regrouped rather than closed โ re-open and they will stick.`);- }- return reclaimed;-}-// Drain the window-opened ring; a new window means SOME session may have lost a tab. Cheap: one-// drain call, then the CDP check only for multi-tab sessions.-async function drainWindowOpenedEvents() {- if (process.platform !== 'win32' || _uiWatchSupported === false) return;- await ensureWindowOpenedWatch();- if (!_uiWatchId) { console.log('[drag] drain skipped: no watchId'); return; }- try {- const data = abPayload(await chrome.abCommand('desktop_ui_events', { sinceSeq: _uiWatchSeq, max: 200 }, { timeoutMs: 6000 }));- if (!data) { console.log('[drag] drain: unparseable response'); return; }- const evs = data.events || [];- if (typeof data.nextSeq === 'number') _uiWatchSeq = data.nextSeq;- // v2.0.187: any window-opened event means the desktop changed โ the long-TTL enum snapshot is- // no longer trustworthy. Invalidate so the next consumer re-reads reality.- if ((data.events || []).length) invalidateEnumCache();- // v1.9.32: do NOT filter these events by window NAME. A window fires window-opened at CREATION,- // before its title exists, so the dragged-out Chrome window arrived with an EMPTY name and the- // old `/Chrome|Chromium/` filter threw away the one event that mattered (verified live: the- // needed event was seq 32 with name ""). Any window-opened is enough of a hint; the follow-up- // check is cheap (one getWindowForTarget per tab, multi-tab sessions only) and idempotent.- if (!evs.length) return;- // v1.9.42: THROTTLE + PRE-FILTER. A busy desktop fires window-opened constantly (tooltips,- // menus, task switching): 1360 events across 10 drains in ~a minute, measured live. Running the- // per-tab CDP check on every drain turned an "event-driven" design into exactly the wasteful- // polling it was meant to avoid. Two cheap guards, in order:- // 1. Nothing multi-tab? A single-tab window cannot be split, so there is nothing to detect.- // 2. At most one check per THROTTLE_MS, no matter how many events arrive.- // v1.9.47: a window-opened event may also mean an UNMANAGED window appeared (openerless popup),- // which the splittable check below cannot see โ it only looks at tabs we already track.- // v2.0.172: pup's OWN thread-tile button nudge (hide + re-show) fires window-opened, which used- // to kick a reclaim sweep โ pup reacting to itself, and a burst of enum calls right when a- // window opens. Ignore the ring while a nudge is in flight; a real new window is still caught by- // the next drain or the 90s safety-net sweep.- // v2.0.210 (busy-box hardening): 4s reclaim throttle meant a machine with several active AI- // threads (constant window-opened events) ran an enum-backed reclaim every few seconds forever.- // 30s bounds it at 2/min worst case; unmanaged-popup adoption tolerates that delay fine.- const THROTTLE_RECLAIM = 30000;- if (_nudgeUntil && Date.now() < _nudgeUntil) return;- if (Date.now() - _lastReclaim > THROTTLE_RECLAIM) {- _lastReclaim = Date.now();- reclaimUnmanagedWindows().catch(() => {});- }- const splittable = [...sessions].filter(([, sess]) => (sess.tabs || []).length >= 2);- if (!splittable.length) return;- const THROTTLE_MS = 1500;- const now = Date.now();- if (now - _lastDragCheck < THROTTLE_MS) return;- _lastDragCheck = now;- console.log(`[drag] ${evs.length} event(s) -> checking ${splittable.length} multi-tab session(s)`);- for (const [sid] of splittable) { await adoptDraggedOutTabs(sid); }- } catch (e) {}-}-// v1.9.40: RE-TAG a page's title suffix to a NEW sessionId. An adopted (dragged-out) tab kept the-// title of the session it came FROM, so AD could never resolve the new window by-// "(session: <newId>)" โ its refresh silently failed (bare Chrome icon, stale/none overlay) and,-// worse, several windows all answered to the SAME "(session: hybrid)" string, so title-based-// operations landed on an arbitrary window (that is how a shotlog favicon ended up on a window that-// was not shotlog). Strips ANY existing pup suffix, installs the new one, and re-arms the observer.-async function retagSessionTitle(page, sessionId, profileName) {- const suffix = profileName && profileName !== sessionId- ? ` (${TITLE_LABEL}: ${sessionId} | profile: ${profileName})`- : ` (${TITLE_LABEL}: ${sessionId})`;- try {- await page.evaluate((sfx, sid) => {- // v1.9.151: window.name is the DURABLE session carrier โ it survives same-tab navigations- // and sites never rewrite it, unlike document.title (ESPN's ticker destroyed the title tag- // and the upgrade-restart recovery mis-bound docs-writer's session onto ship-watch's espn- // window โ two sessions, one page). Title stays for AD's resolver; window.name is truth.- try { window.name = 'pup:' + sid; } catch (e) {}- // v1.9.43: strip EVERY "(session: ...)" occurrence, not just a trailing one. The old session's- // observer had already re-appended its suffix, so a trailing-only strip missed and we stacked- // two: "BMV080 (session: thr) (session: thr-split1-mkh7)". The window then still answered to- // the PARENT's suffix, collided with it, and its own branding landed unreliably.- 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 (John: "why do i still see you mangling the title?"): the VISIBLE suffix is DEAD.- // window.name (above) is the durable session carrier; ownership is conveyed by the jump-list- // "Owned by" task + pup_window_info card; hwnd resolution uses birth-capture/persisted handles.- // Strip any legacy tag once, install NOTHING, observe NOTHING.- const cleaned = strip(document.title);- if (cleaned !== document.title) document.title = cleaned;- }, suffix, sessionId);- return true;- } catch (e) { return false; }-}-// v1.9.36: refresh EVERYTHING a window's taskbar presence shows (identity icon, favicon overlay,-// jump list) and RETRY until the shell can actually see the window. John: "you can't be lazy about-// those overlay icons, always make sure they are as up to date as you can get them." A brand-new-// window (a drag-out, a relaunch) is not findable by title for a beat, so a single fire-and-forget-// refresh silently no-ops and leaves a STALE overlay: after a drag-out the remaining window kept-// showing the dragged site's favicon even though that tab was gone.-async function refreshWindowChrome(session, sessionId, tries = 5) {- if (minimalTouch()) return false; // v1.9.185 minimal-touch: no identity/badge re-stamps- if (process.platform !== 'win32' || !session) return false;- for (let i = 0; i < tries; i++) {- let ok = false;- try { ok = await brandPupWindow(sessionId); } catch (e) {}- if (ok) {- try { await updateTabCountBadge(session, sessionId); } catch (e) {}- try { await updateWikiJumplist(session, sessionId); } catch (e) {}- try { await updateThumbnailTooltip(session, sessionId); } catch (e) {}- return true;- }- await new Promise(r => setTimeout(r, 600 * (i + 1)));- }- console.log(`[chrome] "${sessionId}" could not refresh taskbar presence (window never became findable)`);- return false;-}-// v1.9.301 (John: "all the verbs that change tabs/windows/favicons, you just don't correctly-// trigger your overlay icon calculations"). The fix for drift-over-time: ONE centralized, debounced-// re-sync fired for EVERY mutating verb on an existing session (wired in the dispatch hook), instead-// of trusting each handler to remember. It clears the cached favicon pick so the NEW page's favicon-// is re-fetched, then calls the UNGATED applyAppOverlay (refreshWindowChrome is a no-op under-// minimalTouch, which is exactly why badges went stale). It does NOT clear the cached hwnd, so a-// routine verb never triggers the expensive width-wiggle โ the handle is validated inside-// applyAppOverlay. Debounced per session (~2s) so a burst of verbs collapses to one repaint.-function scheduleOverlayRefresh(sessionId) {- try {- if (!sessionId) return;- const prev = _overlayRefreshTimers.get(sessionId);- if (prev) clearTimeout(prev);- const t = setTimeout(() => {- _overlayRefreshTimers.delete(sessionId);- const s = sessions.get(sessionId);- if (!s || !(s.tabs || []).length) return;- s._overlayFav = null; s._faviconRetried = false; s._noFavLogged = false;- applyAppOverlay(s, sessionId).catch(() => {});- }, 2000);- if (t.unref) t.unref();- _overlayRefreshTimers.set(sessionId, t);- } catch (e) {}-}-// Brand a pup window: full identity on capable AD, overlay badge fallback on older AD.-async function brandPupWindow(sessionId) {- if (minimalTouch()) return false; // v1.9.185: no taskbar identity/branding in minimal mode- const stamped = await stampPupIdentity(sessionId);- if (stamped) return true;- // v1.9.52: if identity IS supported, a false here is a transient "not yet" (window not findable,- // or category still pending) โ NOT a reason to paint the legacy badge, which would fight the- // identity path. Only fall back to the badge on AD builds without identity support.- if (_identitySupported === true) return false;- return badgePupTaskbar(sessionId);-}-async function badgePupTaskbar(sessionId) {- if (minimalTouch()) return false; // v1.9.190: minimal-touch โ no taskbar work at all- if (process.platform !== 'win32') return false;- const icon = pupBadgeB64();- if (!icon) { console.log('[badge] no icon asset โ skipping'); return false; }- // v1.8.58: CHECK the result and LOG failures. The old fire-and-forget swallowed everything โ- // when AD rejected/failed the call (e.g. window title not yet resolvable), the badge just- // silently never appeared and every later attempt was debounced away.- try {- const r = await chrome.abCommand('desktop_taskbar', {- ...(await windowTarget(sessions.get(sessionId), sessionId)),- overlay: { icon, tooltip: 'Adom pup โ driven from the cloud' },- }, { timeoutMs: 6000 });- const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || r));- const failed = (r && r.error) || (raw && /"error"\s*:\s*"[^"]/.test(raw) && !/Taskbar updated/i.test(raw));- if (failed) { console.log(`[badge] "${sessionId}" FAILED: ${(r && r.error) || raw.slice(0, 160)}`); return false; }- console.log(`[badge] "${sessionId}" applied`);- return true;- } catch (e) { console.log(`[badge] "${sessionId}" threw: ${e.message}`); return false; }-}--// NOTE: taskbar badge/overlay is an AD-CORE capability (desktop_taskbar, AD โฅ1.8.152) โ the-// nbrowser_* extension uses it and AD does the cross-process work natively. pup must NOT-// reimplement it (an in-bridge ITaskbarList3::SetOverlayIcon is in-process-only and fails on-// the browser's out-of-process window). A pup-distinct badge (white Adom leaves) is an AD-// feature request on desktop_taskbar; pup will call that verb via direct-API once it exists.---// v2.0.27: osBackgroundWatchdog DELETED โ zero callers (superseded by the park + re-assert-// doctrine in dev-skills/pup-user-foreground).---// v2.0.27: osMinimizeToBackground DELETED โ zero callers, and minimize-based backgrounding is-// banned by the pup-user-foreground doctrine (background means BEHIND, never hidden).---// v1.8.53: send ONE hwnd to the bottom of the z-order. This is the ONLY Win32 call left with-// no direct API (CDP has no z-order; AD has no send-to-back verb yet โ issue filed). Minimal-// PS: one P/Invoke signature, no window-finding, no pid guessing, hwnd comes from AD's native-// desktop_find_window. Everything else in the park is CDP/AD direct.-// v2.0.261: a window younger than this cannot have been raised by a human taskbar click, because-// its button did not exist yet. Inside the grace period, OS-foreground is attributed to CHROME-// (which self-activates a newly created on-screen window), never to the user. Generous enough to-// cover a slow cold start, short enough that a real click seconds later is still honored.-const BIRTH_GRACE_MS = 8000;-function inBirthGrace(sess) {- return !!(sess && sess._bornAt && !sess._raiseGranted && Date.now() - sess._bornAt < BIRTH_GRACE_MS);-}-async function osBottomUnlessForeground(hwnd, force, sess) {- // v2.0.27: NATIVE (was the last big PowerShell in the z-order path). Three ab calls at ~20ms each- // replace one ~300ms runtime-compiled PS spawn, and the user-protection doctrine rides along:- // - not force: an OS-foreground target is left alone ('foreground').- // - force (Chrome self-raise override): a target that is OS-foreground AND was EVER user-raised- // stays hands-off for AS LONG AS it remains foreground (John 2026-08-09: "why do you need so- // long?" โ the 10-min flag is only the memory horizon for after the user LEAVES the window;- // while they are on it, there is no clock). Fresh input (<5s) attributes a raise not yet- // flagged. A self-raised window with idle hands and no user history still gets demoted.- if (process.platform !== 'win32' || !hwnd) return 'error';- try {- const fg = abPayload(await chrome.abCommand('desktop_get_foreground', {}, { timeoutMs: 4000 }));- if (fg && Number(fg.hwnd) === Number(hwnd)) {- if (!force) return 'foreground';- // v2.0.261 (John opened ti.com and it landed on top of his work, then pup refused to move it):- // the checks below attribute an OS-foreground pup window to the USER โ `_userActiveAt`, or- // simply "there was input in the last 5s". For a window that was just born, both are wrong:- // Chrome self-activates a newly created ON-SCREEN window (the old off-screen birth hid this),- // and John typing while pup opens a window is not John clicking that window's taskbar button,- // which did not exist yet. The measured result was `[park] "ti-proof" (post-nav re-assert):- // user-foreground โ hands off` on a window he had never touched. Inside the birth grace we- // therefore bottom it, exactly as the launch park already does unconditionally.- if (inBirthGrace(sess)) {- console.log(`[park] "${sess._sessionId || '?'}" is foreground ${Math.round((Date.now() - sess._bornAt) / 1000)}s after birth โ CHROME self-activating a new window, not the user (its taskbar button did not exist yet). Bottoming it.`);- } else {- if (sess && sess._userActiveAt) { sess._userActiveAt = Date.now(); return 'user-foreground'; }- const idle = await osIdleMs();- if (idle != null && idle < 5000) { if (sess) sess._userActiveAt = Date.now(); return 'user-foreground'; }- }- }- const d = abPayload(await chrome.abCommand('desktop_set_window_state', { hwnd: Number(hwnd), state: 'bottom', force: true }, { timeoutMs: 5000 }));- return (d && (d.sentToBack || d.ok)) ? 'bottomed' : 'error';- } catch (e) { return 'error'; }-}--// v1.8.53: THE park โ direct APIs end to end (John: "why powershell? that's heavy. why aren't-// you just calling direct api's?"). No EnumWindows, no pid guessing, no focus handback (with-// --no-startup-window nothing can steal focus), no wait loop (called AFTER newPage resolved, so-// the window exists โ the await IS the event). Steps:-// 1. Escape hatch: CDP `document.hasFocus()` โ can ONLY be true if the USER clicked the-// taskbar during the launch gap (Chrome can't self-focus anymore) โ move on-screen, leave-// them on it, report 'user-foreground'.-// 2. Z-bottom while still OFF-SCREEN (invisible): hwnd via AD desktop_find_window (native,-// title-suffix match), then osBottomUnlessForeground (the one residual Win32 call,-// which ALSO reads GetForegroundWindow โ the only honest user-click signal).-// 3. Re-check focus (user may have clicked mid-park) โ if so, on-screen + AD bring_to_front.-// 4. CDP Browser.setWindowBounds โ on-screen full-size. CDP addresses the EXACT window by-// windowId, so the catch-2 failure mode (parked the wrong window) is impossible.-// 5. Verify POSITION via CDP getWindowBounds (catch-2's park "verified" by focus alone and-// lied) โ 'backgrounded' | 'park-failed'.-// โโ HIGH-FPS LEASES + WATCHDOG (v1.9.76) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ-//-// A highFps window has Chrome's four power-saving mechanisms DISABLED, so it renders at-// full rate forever even when occluded. That is exactly what a background recording needs-// and exactly what flattened a user's battery when it outlived its purpose (issue #14).-// So a highFps window is never open-ended: it holds a LEASE, and a watchdog downgrades it-// when the lease lapses.-//-// The three activity signals, in priority order:-// 1. AN ACTIVE RECORDING IS AUTHORITATIVE. If a recording we manage is running on the-// session, the lease CANNOT lapse. This is the "never kill someone mid-video"-// guarantee, and it is knowable (not guessed) because recording runs through our verbs.-// 2. VERB ACTIVITY extends the lease โ someone is demonstrably using the window.-// 3. An explicit pup_highfps_extend renews it, so an AI that knows it needs longer-// can negotiate instead of being surprised.-//-// On lapse we DOWNGRADE (relaunch the same session, same tabs, without the flags) rather-// than close: the window may hold the user's content, and the goal is to stop burning power,-// not to destroy work.-const HIGHFPS_DEFAULT_MINUTES = 20; // long enough for a real demo take, short enough that- // an abandoned window cannot cost a battery overnight-const HIGHFPS_MAX_MINUTES = 120; // hard ceiling; ask again rather than lease a whole day-const HIGHFPS_WARN_BEFORE_MS = 2 * 60 * 1000;-const _highFps = new Map(); // sessionId -> { expiresAt, minutes, warned, grantedAt }--function highFpsGrant(sessionId, minutes) {- const m = Math.max(1, Math.min(HIGHFPS_MAX_MINUTES, Number(minutes) || HIGHFPS_DEFAULT_MINUTES));- const rec = { minutes: m, grantedAt: Date.now(), expiresAt: Date.now() + m * 60000, warned: false };- _highFps.set(sessionId, rec);- console.log(`[highfps] "${sessionId}" lease granted: ${m} min (expires ${new Date(rec.expiresAt).toISOString()})`);- return rec;-}-function highFpsExtend(sessionId, minutes) {- const cur = _highFps.get(sessionId);- if (!cur) return null;- const m = Math.max(1, Math.min(HIGHFPS_MAX_MINUTES, Number(minutes) || HIGHFPS_DEFAULT_MINUTES));- cur.expiresAt = Date.now() + m * 60000; cur.minutes = m; cur.warned = false;- console.log(`[highfps] "${sessionId}" lease extended: +${m} min`);- return cur;-}-function highFpsInfo(sessionId) {- const r = _highFps.get(sessionId);- if (!r) return null;- return { active: true, minutes: r.minutes, expiresAt: new Date(r.expiresAt).toISOString(),- msRemaining: Math.max(0, r.expiresAt - Date.now()), recordingActive: isRecordingActive(sessionId) };-}-// Is a recording we manage currently running against this session? Authoritative, not a guess.-function isRecordingActive(sessionId) {- // TAB recording: tabRecordStatusImpl is the authoritative per-session view (it already- // filters out recordings that are mid-stop). Anything it returns means frames are being- // pulled from this window right now.- try {- const active = tabRecordStatusImpl({ sessionId });- if (Array.isArray(active) ? active.length : (active && active.length)) return true;- } catch (e) {}- // DESKTOP recording: one global recorder; .current is set only while a take is running.- try { if (desktopRecorder && desktopRecorder.current) return true; } catch (e) {}- return false;-}-// Drop a session back to Chrome's normal power behaviour by relaunching it WITHOUT the flags,-// carrying every tab across (same machinery the wiki view toggle uses).-async function highFpsDowngrade(sessionId, why) {- const session = sessions.get(sessionId);- _highFps.delete(sessionId);- if (!session) return false;- try {- const urls = []; let activeIdx = 0;- (session.tabs || []).forEach((t, i) => {- try { const u = t.page.url(); if (u && u !== 'about:blank') { if (t.tabId === session.activeTabId) activeIdx = urls.length; urls.push(u); } } catch (e) {}- });- const owner = session.owner || null, profile = session.profileName;- console.log(`[highfps] "${sessionId}" DOWNGRADING to normal power (${why}) โ relaunching ${urls.length} tab(s) without the high-fps flags`);- await launchSession(sessionId, urls[activeIdx] || 'about:blank', { profile, owner, takeover: true, highFps: false });- for (let i = 0; i < urls.length; i++) { if (i === activeIdx) continue; try { await addTabToSession(sessionId, urls[i]); } catch (e) {} }- return true;- } catch (e) { console.log(`[highfps] "${sessionId}" downgrade failed: ${e.message}`); return false; }-}-setInterval(trackedLoop('highfps-lease-janitor', 30000, () => {- const now = Date.now();- for (const [sid, rec] of [..._highFps]) {- if (!sessions.has(sid)) { _highFps.delete(sid); continue; }- if (isRecordingActive(sid)) {- // Recording in flight: hold the lease open and keep pushing it out, so a long take can- // never be cut off. Logged once per tick at most because the janitor collapses repeats.- rec.expiresAt = Math.max(rec.expiresAt, now + 5 * 60000);- continue;- }- const left = rec.expiresAt - now;- if (left <= 0) { highFpsDowngrade(sid, `lease of ${rec.minutes} min lapsed with no active recording`).catch(() => {}); }- else if (left <= HIGHFPS_WARN_BEFORE_MS && !rec.warned) {- rec.warned = true;- console.log(`[highfps] "${sid}" lease expires in ~${Math.ceil(left / 60000)} min โ call pup_highfps_extend {sessionId,minutes} to keep it, or start a recording (a running recording holds it open indefinitely)`);- }- }-}), 30000);--// v2.0.285 RENDER LEASE / focus-emulation blank-window fix: REVERTED. Emulation.setFocusEmulationEnabled-// (the lever it used to keep an occluded window's page painted) ACTIVATES the OS window โ it-// foregrounded a background pup window (John, 2026-08-22), reintroducing the exact focus-steal the-// whole off-screen-birth doctrine exists to prevent. And it did not reliably fix the blank thumbnail-// anyway. So it is gone. The blank-occluded-window problem is REAL and still open (dev-skills/-// pup-render-test), but the fix must NOT touch OS focus: candidates to investigate next are the-// browser-level launch flag --disable-backgrounding-occluded-windows (retains the compositor surface-// without emulating focus) and rendering ONLY on genuine reveal. Do NOT reintroduce focus-emulation.--async function parkSessionWindowDirect(page, sessionId) {- // โโ macOS park โโ the whole background contract is one primitive: (re-)hide the- // session's browser process. The window was born hidden (pre-window hide in- // tryDetachedLaunch); here we size it full-screen via CDP while STILL hidden โ so a- // later raise shows a properly-sized window โ then re-assert the hide and verify.- // No z-order machinery: a hidden app has no on-screen windows to cover the user.- if (process.platform === 'darwin') {- try {- // The session may not be registered in `sessions` yet at launch-park time โ resolve- // the pid from the page's OWN browser (detached pid, else puppeteer's child process).- let pid = null;- try { const b = page.browser(); pid = b._detachedPid || (b.process() && b.process().pid) || null; } catch (e) {}- if (!pid) { const session = sessions.get(sessionId); pid = session ? macSessionPid(session) : null; }- try {- const cdp2 = await page.target().createCDPSession();- const { windowId } = await cdp2.send('Browser.getWindowForTarget');- let s = null;- for (let i = 0; i < 4 && !s; i++) {- s = await page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight })).catch(() => null);- if (s && !(s.w > 400 && s.h > 300)) s = null;- if (!s) await new Promise(r2 => setTimeout(r2, 300));- }- await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });- if (s) await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(s.x + 10), top: Math.round(s.y + 10), width: Math.round(s.w - 20), height: Math.round(s.h - 20) } }); // v1.9.139: same 10px inset as Windows- try { await cdp2.detach(); } catch (e) {}- } catch (e) {}- if (!pid) { console.log(`[park] "${sessionId}": no pid to hide (mac) โ park-failed`); return 'park-failed'; }- const hidden = await macSetAppHiddenByPid(pid, true);- if (!hidden) {- console.log(`[park] "${sessionId}": mac hide failed (NSRunningApplication had no app for the pid; browser gone?) โ park-failed`);- return 'park-failed';- }- return 'backgrounded';- } catch (e) {- console.error(`[park] ${sessionId} (mac): ${e.message}`);- return 'error';- }- }- if (process.platform !== 'win32') return null;- let cdp = null;- try {- cdp = await page.target().createCDPSession();- const { windowId } = await cdp.send('Browser.getWindowForTarget');- // v1.8.66: PLACEMENT DOCTRINE (three shipped placements raised the window โ CDP maximize- // = SW_MAXIMIZE raises; AD set_window_bounds raises despite restore:false; the on-screen- // small stage was visible. ALL of it John caught live):- // 1. Geometry via CDP plain bounds ONLY (left/top/width/height, normal state; CSS px from- // the page itself). NEVER windowState:'maximized', NEVER AD set_window_bounds.- // 2. Size is measured, not guessed: screen.availWidth/Height with retries (the one probe- // failure shipped 1600ร900 once โ retry beats fallback).- // 3. TRUST NOTHING: after placement, RE-BOTTOM and VERIFY THE Z-ORDER via AD- // desktop_list_windows. If pup's window cannot be confirmed below the user's windows,- // move it back OFF-SCREEN and report park-failed. A window that might be covering the- // user NEVER stays on-screen.- const cssScreen = async () => {- for (let i = 0; i < 4; i++) {- try {- // v1.9.139 (John): availLeft/availTop too โ a taskbar docked LEFT/TOP shifts the work- // area's origin, and assuming 0,0 would tuck the window under it.- const s = await page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight }));- if (s && s.w > 400 && s.h > 300) return s;- } catch (e) {}- await new Promise(r2 => setTimeout(r2, 300));- }- return null;- };- const place = (d) => cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(d.x + 10), top: Math.round(d.y + 10), width: Math.round(d.w - 20), height: Math.round(d.h - 20) } });- const placeOnScreen = async () => {- const s = await cssScreen();- if (!s) return false;- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });- // v1.9.139 (John): 10px inset on ALL sides of the work area โ the visible signature that- // placement is computed, not accidental. THIS is the real park geometry; the WinBgP/WinClr- // PS sites I patched in 1.9.136-137 are flash/minimize paths, not this one.- await place(s);- // v1.9.342 (John: "the window was BIGGER than the screen, i couldn't scroll to the bottom / see- // my content"). ROOT CAUSE: screen.avail* reflects whatever monitor the window is CURRENTLY on.- // A freshly opened window is launched OFF-SCREEN and then parked, and during that sequence it can- // momentarily sit on a DIFFERENT (larger) monitor โ so it gets SIZED for that monitor's work area- // but LANDS on the smaller primary, overflowing off the bottom-right until the slow (120s)- // placement sweep happens to re-park it. Close the race HERE: once the window is actually placed,- // re-read screen.avail (now it reflects the DESTINATION monitor) and, if that monitor differs,- // re-place to ITS work area. A window is never left overflowing its own screen, even for a moment.- try {- await new Promise(r2 => setTimeout(r2, 250));- const s2 = await cssScreen();- if (s2 && s2.w > 400 && s2.h > 300 && (s2.w !== s.w || s2.h !== s.h || s2.x !== s.x || s2.y !== s.y)) {- await place(s2);- console.log(`[park] "${sessionId}": destination monitor differs from park-time monitor (${s.w}x${s.h}@${s.x},${s.y} โ ${s2.w}x${s2.h}@${s2.x},${s2.y}) โ re-clamped to the real screen so no content is off-screen`);- }- } catch (e) {}- return true;- };- // v2.0.258: the moveOffScreen() helper is GONE. Every caller has been converted to place the- // window on-screen instead, because "park it off-screen until we are sure" is what left John- // unable to open his own windows twice in one day. Do not reintroduce it: a helper whose whole- // job is to move the user's window where they cannot click it is a loaded gun in this file.- // Z-order truth: our window's z must be strictly below at least one non-pup window, and it- // must not be topmost. Returns true only on CONFIRMED not-on-top.- // v2.0.259: zConfirmedBelow is GONE. It asked ab's window enumeration to PROVE our window sat- // below a non-pup window, and placement was gated on that proof. Two fatal properties: it- // returned false when our hwnd was simply absent from the enumeration (which an off-screen- // window often is, so the failure was self-sustaining), and there is no correct answer at all- // when the user has no other windows open. Nothing may gate placement on a proof like that.- // hwnd via AD (native) โ the title suffix "(session: <id>" is set by the welcome page.- // v1.8.59 HARD RULES, learned the expensive way:- // 1. The window is NEVER moved on-screen until z-bottom is CONFIRMED ('bottomed'). A- // lagged hwnd lookup once fell through and placed an un-bottomed window ON TOP of the- // user's work (the nxp.com pop). Invisible off-screen ('park-failed' + self-heal)- // beats covering the user's screen.- // 2. The launch park bottoms UNCONDITIONALLY (force=true) โ there is NO user-click escape- // hatch here. "Pup window is foreground at park time" has too many non-user causes:- // Windows re-assigns the foreground to a brand-new window when the previous foreground- // window was just destroyed (e.g. a bridge-update restart killing pup windows โ that- // forced another thread's fresh window onto John's screen), plus creation blips. The- // false-positive cost is a forced window on the user's screen (the #1 forbidden- // behavior); the false-negative cost is a user who clicked during the ~2s launch gap- // clicking ONCE more after the park โ after which the window stays, since nothing- // ever re-parks. Cheap. Do NOT reintroduce a foreground check here.- // v1.8.61: z-bottom via AD-CORE `desktop_set_window_state {state:'bottom'}` (shipped in AD- // 1.9.114 per pup's feature request) โ native, background-drive, resolves the window by- // title itself, and returns {sentToBack, wasForeground}. This deletes pup's LAST PowerShell- // from the open path; window management is now 100% direct API. Legacy fallback (older AD- // that rejects state:'bottom'): the old find_window + osBottomUnlessForeground PS pair.- const adBottom = async () => {- try {- // v1.8.63: force:true (AD โฅ1.9.115) โ bottom even if the window is currently foreground.- // At launch, foreground is NOT a user signal (post-restart reassignment), so pup always- // forces. On AD 1.9.114 the arg is ignored (skip-on-foreground) and the wasForeground- // fallback below covers it; that fallback auto-obsoletes as desktops update.- // v2.0.237: only ask the shell to re-bottom when we hold a real handle. Without one this- // used to send a titleContains that cannot match (titleTag is off by default), and ab's- // "No visible window with title containing (session: X)" surfaced as a FAILED pup_lower โ- // even though the CDP placement above had already parked the window correctly.- const _hw = await windowHwnd(sessions.get(sessionId), sessionId);- if (!_hw) {- if (!_parkNoHwndLogged.has(sessionId)) {- _parkNoHwndLogged.add(sessionId);- console.log(`[park] "${sessionId}" no window handle resolved โ CDP placement stands as the park; skipping the shell re-bottom (a title lookup cannot match with titleTag off).`);- }- return 'backgrounded';- }- const r = await chrome.abCommand('desktop_set_window_state', { hwnd: _hw, state: 'bottom', force: true }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});- if (r && r.success === false) return { ok: false, err: r.error || 'error' };- const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));- const o = raw ? JSON.parse(raw) : {};- const data = o.data || o;- return { ok: !!data.sentToBack, wasForeground: !!data.wasForeground, hwnd: data.hwnd || null, err: data.sentToBack ? null : 'not sent to back' };- } catch (e) { return { ok: false, err: e.message }; }- };- // โโ v2.0.259: PARK IS NO LONGER A PLACEMENT GATE (John's rules R1-R3) โโโโโโโโโโโโโโโโโโโโโโ- // The window is BORN at its final on-screen rect, so by the time we get here it is already- // somewhere the user can click. Park's only remaining job is to ASSERT the background preference.- // What changed, and why:- // * Placement no longer waits on proving z-bottom. The old order (bottom -> prove -> only then- // move on-screen) left the window at -32000 for up to 42 SECONDS (8 attempts x a 5s ab- // timeout + sleeps) with a live taskbar button. John accepted a "~2s launch gap"; he never- // agreed to that. R3 makes a working taskbar click a requirement, not a trade.- // * Bottoming is best-effort and BOUNDED (3 quick attempts, not 8 slow ones). If it fails and- // the window ends up foreground, R2 says accept it: "just allow that flaw to exist." We log- // it and stop. Fighting it is what produced every re-park loop, and each one eventually- // yanked a real user click.- // * NOTHING here may move, hide, minimize or off-screen the window as a consequence of a- // failed assertion. That is the whole lesson.- let bottomed = false, lastErr = 'error', parkHwnd = null;- for (let attempt = 0; attempt < 3; attempt++) {- const z = await adBottom();- if (z.ok) { bottomed = true; parkHwnd = z.hwnd; break; }- lastErr = z.err || 'error';- if (z.wasForeground) {- // The legacy PS path still matters on older ab (state:'bottom' absent, or force ignored).- let hwnd = null;- try { hwnd = await windowHwnd(sessions.get(sessionId), sessionId); } catch (e) {}- if (hwnd) {- const zr = await osBottomUnlessForeground(hwnd, true, sessions.get(sessionId));- if (zr === 'bottomed') { bottomed = true; parkHwnd = hwnd; break; }- if (zr === 'user-foreground') {- const _us = sessions.get(sessionId); if (_us) _us._userActiveAt = Date.now();- console.log(`[park] "${sessionId}": user has this window FOREGROUND (recent input) โ leaving it exactly where they put it`);- return 'user-foreground';- }- }- }- await new Promise(r2 => setTimeout(r2, 300));- }- // Whatever happened above, make sure the geometry is right. This never gates on anything and it- // never moves the window somewhere unreachable โ worst case it is a no-op on an already-correct- // window (the normal case now, since birth placed it).- try { await placeOnScreen(); } catch (e) {}- if (!parkHwnd) { try { parkHwnd = await windowHwnd(sessions.get(sessionId), sessionId); } catch (e) {} }- if (parkHwnd) { try { const _s = sessions.get(sessionId); if (_s) { _s._hwnd = Number(parkHwnd); persistSessionHwnd(sessionId, Number(parkHwnd)); } } catch (e) {} }- if (!bottomed) {- // R2: background is the PREFERENCE, not a precondition. Say so plainly and move on.- console.log(`[park] "${sessionId}": could not confirm z-bottom (${lastErr}) โ the window is ON SCREEN and clickable, which is the requirement; background is only the preference. Not fighting it (John: "just allow that flaw to exist").`);- return 'park-unconfirmed';- }- return 'backgrounded';- } catch (e) {- console.error(`[park] ${sessionId}: ${e.message}`);- return 'error';- } finally {- if (cdp) { try { await cdp.detach(); } catch (e) {} }- }-}--// v1.8.67: re-assert z-bottom after a navigation settles. The OPEN's first cross-origin nav-// (file:// welcome โ https real site) swaps renderer processes and Chromium re-shows the window-// at first paint โ RAISING it โ and heavy sites paint AFTER the park's z-confirm passed (the w3-// batch sat on top with honest 'backgrounded' verdicts; a later same-window navigate does NOT-// raise โ tested). force:false so AD skips a window the user is actively holding foreground.-async function reassertBottomAfterNav(sessionId) {- // v1.9.255 (John): arm the NO-FAULT WATCH - see the fgWatch interval. Caption-only, never a knockback.- try { const _gs = sessions.get(sessionId); if (_gs && !_gs._foreground) _gs._fgWatchUntil = Date.now() + 20000; } catch (e) {}- if (process.platform !== 'win32' || !sessionId) return;- // v1.9.201 (John's PDF window came to the front with NO ai requesting it): this used ONLY AD's- // desktop_set_window_state โ a verb that is NOT present on every desktop (the Bills thread hit- // exactly that: "desktop_set_window_state is not in this desktop's verb set"). Where it is absent- // this whole re-assert silently no-opped, so anything CHROME foregrounded on its own stayed on the- // user's screen. Local PDFs are the proven trigger: Chrome's PDF viewer activates the window on- // load. Do it IN-BRIDGE first (SetWindowPos to z-bottom, which never depends on an AD verb and- // respects a user-foregrounded window), and keep the AD verb only as a secondary attempt.- const sess = sessions.get(sessionId);- if (sess && userIsUsingWindow(sess)) return; // never yank a window the user chose to have up- let bottomed = false;- try {- const hwnd = await cachedSessionHwnd(sessionId);- const zr = hwnd ? await osBottomUnlessForeground(hwnd, true, sessions.get(sessionId)) : 'error';- if (zr === 'bottomed') bottomed = true;- // v2.0.26 (John: "you took it back to the bg... that fights with the user"): a re-assert that- // finds the window user-foregrounded (recent input) STOPS โ no retry, no ab fallback โ and- // marks the session so every later demotion timer skips it too (userIsUsingWindow, 10 min).- if (zr === 'user-foreground') {- if (sess) sess._userActiveAt = Date.now();- console.log(`[park] "${sessionId}" (post-nav re-assert): user-foreground โ hands off`);- return;- }- } catch (e) {}- if (bottomed) { console.log(`[park] "${sessionId}" (post-nav re-assert): bottomed`); return; }- // v1.9.202 (John's log audit found `park-failed` on two windows and pup just moved on): a- // park-failed window is sitting WHEREVER CHROME LEFT IT โ i.e. possibly on the user's screen, which- // is the one outcome park exists to prevent. Retry once after a beat before falling back to AD.- try {- await new Promise(r => setTimeout(r, 1200));- const hwnd2 = await cachedSessionHwnd(sessionId);- const zr2 = hwnd2 ? await osBottomUnlessForeground(hwnd2, true, sessions.get(sessionId)) : 'error';- if (zr2 === 'bottomed') {- console.log(`[park] "${sessionId}" (post-nav re-assert, retry): bottomed`);- return;- }- if (zr2 === 'user-foreground') {- const _us2 = sessions.get(sessionId); if (_us2) _us2._userActiveAt = Date.now();- console.log(`[park] "${sessionId}" (post-nav re-assert, retry): user-foreground โ hands off`);- return;- }- } catch (e) {}- 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.57: SELF-HEAL a stranded window. A window created under a build whose park failed (or-// any future placement bug) sits at its off-screen launch coords forever: its taskbar button-// "activates" an invisible window and the badge is gone โ exactly what happened to the-// adom-shotlog app's long-lived shared window. pup now detects and repairs this at every-// natural touchpoint (bridge-restart reattach, open_tab, switch_tab): cheap CDP bounds check;-// only if actually off-screen, re-run the standard park (which respects a user-foreground-// window) and re-apply the badge. Healthy windows are never touched.-async function healOffscreenWindow(session, sessionId) {- if (process.platform !== 'win32') return null;- try {- const tab = session && session.tabs && session.tabs[0];- const page = tab && tab.page;- if (!page) return null;- const cdp = await page.target().createCDPSession();- let b = null;- try {- const { windowId } = await cdp.send('Browser.getWindowForTarget');- const gb = await cdp.send('Browser.getWindowBounds', { windowId });- b = gb && gb.bounds;- } finally { try { await cdp.detach(); } catch (e) {} }- if (!b || (b.left > -1000 && b.top > -1000)) return null; // on-screen โ healthy, hands off (v1.8.64: catches the -1310 staging spot too)- console.log(`[heal] "${sessionId}" window stranded off-screen (${b.left},${b.top}) โ re-parking + re-badging`);- const r = await parkSessionWindowDirect(page, sessionId);- brandPupWindow(sessionId).catch(() => {});- return r;- } catch (e) { return null; }-}--// v1.8.45: TRULY clear a window's taskbar attention (the orange/pink Win11 "flash" tint).-// FLASHW_STOP only stops the PULSE โ the tint persists until the window is ACTIVATED. So we-// clear it the only way Windows allows: activate the window. To avoid a visible pop, we move-// it OFF-SCREEN first, activate it there (invisible โ clears the tint), move it back on-screen-// at z-bottom, then hand focus straight back to whatever the user was in (AttachThreadInput).-// Net effect: the flash clears, nothing pops, focus is never stolen. Returns "cleared" /-// "cleared-still-foreground" / "already-foreground" (user already had it) / "no-window".-async function osClearFlashByActivate(pid, cdpPort, hwnd) {- // v2.0.27: native โ ab desktop_clear_window_attention does the whole tint-clear dance in- // precompiled Rust (FLASHW_STOP + AttachThreadInput activate + focus handback, net-zero focus;- // pixel-verified on ab#64 after pup's FLASHW_STOP-alone caveat). The ~60-line PS is gone.- if (process.platform !== 'win32' || (!pid && !cdpPort && !hwnd)) return 'error';- try {- let target = Number(hwnd) || null;- if (!target && pid) target = await largestChromeWindowOfPidTree(pid);- if (!target) return 'no-window';- const fg = abPayload(await chrome.abCommand('desktop_get_foreground', {}, { timeoutMs: 4000 }));- if (fg && Number(fg.hwnd) === Number(target)) return 'already-foreground';- const d = abPayload(await chrome.abCommand('desktop_clear_window_attention', { hwnd: Number(target) }, { timeoutMs: 8000 }));- return (d && (d.cleared || d.ok)) ? 'cleared' : 'error';- } catch (e) { return 'error'; }-}--// NOTE: localhost-URL reprimand (container-vs-desktop) is handled by AD CORE, which-// appends a container-topology hint to a failed response whose URL is localhost. pup-// doesn't duplicate it โ and because AD keys on the connection FAILURE, Hydrogen-// Desktop's working localhost URLs load fine and never trip it. (Enriching AD's hint-// with actionable alternatives is an adom/adom-bridge request.)--// v1.8.23: REAL taskbar flash (FlashWindowEx) for a session's window โ the orange-// nudge that tells the user "the agent touched this window" WITHOUT stealing focus.-// v1.8.24: a FINITE 4-blink pulse then stop (see Flash() below) โ NOT a blink-forever-// nag. Skips if the window is already foreground. Fire-and-forget (async exec) so it-// never adds latency to the driving verb.-// v1.8.43: pup's taskbar flash routes through AD-CORE desktop_flash_window (native +-// reliable, ONE mechanism for flash AND clear) instead of pup's own FlashWindowEx โ which-// duplicated an AD capability, and whose fire-and-forget FLASHW_STOP wouldn't actually-// clear the Win11 attention tint. Resolves the window by its "(session: <id>)" title suffix.-// {stop:true} clears it; otherwise a finite 4-count flash. Returns AD's real result so-// callers can report truthfully (no more cleared:true lies).-async function flashViaAD(sessionId, opts) {- // v1.9.233 (John: "the flash all button isn't working"): the minimalTouch gate silently swallowed- // USER-initiated flashes too. minimal-touch exists to stop pup's AUTOMATIC machinery from touching- // the taskbar; a human clicking "flash" in the dashboard is the opposite of automatic. Callers that- // carry explicit user intent pass {force:true} and go through; everything automatic stays gated.- if (minimalTouch() && !(opts && opts.force)) return { _skipped: 'minimal-touch' };- opts = opts || {};- if (process.platform !== 'win32' || !sessionId) return null;- // v2.0.289 (John): taskbarActivity governs the orange flash. When it is NOT 'flash' (default- // 'progress', or 'off'), suppress AUTOMATIC flashes โ the determinate progress bar replaces them. A- // STOP always runs (so a leftover flash can be cleared) and an explicit user-clicked flash- // (opts.force) still goes through; only pup's own auto-flashes are gated.- if (!opts.stop && !opts.force && taskbarActivityMode() !== 'flash') return { _skipped: 'taskbar-activity-not-flash' };- // v1.9.242 (John: "i just hit 'clear flashes' and it didn't clear all your flashes"). Same disease- // as the badge saga: targeting by TITLE tag silently no-ops on any window whose title can't carry- // it (PDF viewer, retitled pages) โ and FlashWindowEx leaves the button highlighted until a STOP- // reaches that exact HWND. So: resolve the hwnd (same resolver the badge paint uses), address by- // hwnd with title as fallback only, and REMEMBER every hwnd we ever flashed so clear-all can sweep- // them even if the session is gone or retitled by then.- const session = sessions.get(sessionId);- if (session && !session._hwnd) { try { await bestSessionHwnd(session, sessionId); } catch (e) {} }- // v1.9.278: flash only a proven pup hwnd; else title match (a title tag can only exist on a pup- // window). Never flash a bare recycled hwnd - same Edge-protection guarantee as the overlay paint.- let _flashHwndOk = false;- if (session && session._hwnd) { try { _flashHwndOk = await hwndBelongsToPup(session._hwnd, session); } catch (e) {} if (!_flashHwndOk) session._hwnd = null; }- const tgt = _flashHwndOk ? { hwnd: session._hwnd } : { titleContains: `(${TITLE_LABEL}: ${sessionId}` };- if (!opts.stop && session && session._hwnd) _flashedHwnds.add(session._hwnd);- const args = opts.stop ? { ...tgt, stop: true } : { ...tgt, mode: 'count', count: 4 };- try { return await chrome.abCommand('desktop_flash_window', args, { timeoutMs: 6000 }); }- catch (e) { return null; }-}-const _flashedHwnds = new Set(); // every hwnd pup ever flashed this boot โ the clear-all sweep list-// v1.9.243 (John: "i just hit clear flashes again and it still didn't clear"). Per-boot bookkeeping-// was the hole: a bridge restart (every ship) empties the set while the taskbar keeps its orange, so-// clear-all had nothing to sweep. Bookkeeping across restarts is inherently fragile โ so DON'T rely-// on it: enumerate every visible top-level window of every pup-managed Chrome process (same PS enum-// the badge resolver uses) and stop them ALL. Filtered to pup's own browser PIDs so the user's-// personal Chrome is never touched.-// v2.0.27: the "which window carries this process's taskbar button" resolver, native. Largest-// visible real (>=80x60) Chrome frame owned by the pid or its direct chrome children.-async function largestChromeWindowOfPidTree(pid) {- if (!pid) return null;- const kids = new Set([Number(pid)]);- try {- const pd = abPayload(await chrome.abCommand('process_list', { nameFilter: 'chrome' }, { timeoutMs: 5000 }));- for (const p of ((pd && pd.processes) || [])) if (Number(p.parentPid) === Number(pid)) kids.add(Number(p.pid));- } catch (e) {}- const rows = await enumChromeWidgetWindows();- let best = null;- for (const r of rows) {- if (!kids.has(r.pid) || r.W < 80 || r.H < 60) continue;- const area = r.W * r.H;- if (!best || area > best.area) best = { h: r.h, area };- }- return best ? best.h : null;-}--async function enumAllPupChromeHwnds() {- // v2.0.27: native. The >=80x60 size filter survives (v1.9.273: every Chrome process carries a- // hidden helper window that reports visible with no real size โ the phantom "UNTRACKED" criers).- if (process.platform !== 'win32') return [];- const pupPids = new Set();- for (const [, be] of browsers) {- try { const pid = be && be.browser && (be.browser._detachedPid || (be.browser.process() && be.browser.process().pid)); if (pid) pupPids.add(Number(pid)); } catch (e) {}- }- if (!pupPids.size) return [];- try {- const rows = await enumChromeWidgetWindows();- return rows.filter(r => pupPids.has(r.pid) && r.W >= 80 && r.H >= 60).map(r => r.h);- } catch (e) { return []; }-}--// v1.8.23: verbs that MUTATE a session's visible state โ auto-flash the window so the-// user knows the agent changed it. evaluate counts UNLESS {readOnly:true}.-// v2.0.28 (#41 + John: "we moved to ai-thread name and dropped sessionId"): every verb that TARGETS-// one session. With sessionId omitted these resolve to the CALLER THREAD's own window (the same-// ai-<thread-slug> keying pup_open_window uses) โ never to activeSessionId, which is just whichever-// window was touched last, usually another thread's.-const SESSION_TARGETING_VERBS = new Set(['pup_progress', 'pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_open_tab', 'pup_switch_tab', 'pup_close_tab', 'pup_close_window', 'pup_eval', 'pup_evaluate', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_screenshot', 'pup_screenshot_full_res', 'pup_errors', 'pup_list_tabs', 'pup_wait', 'pup_alert_window', 'pup_focus_window', 'pup_raise_os_window', 'pup_lower_os_window', 'pup_window_info', 'pup_record_start', 'pup_record_stop', 'pup_wiki_set_view', 'pup_switch_window', 'pup_annotate', 'pup_floaty_click', 'pup_move_to_workspace', 'pup_fill_card']);-function threadDefaultSessionId(callerThread) {- return 'ai-' + String(callerThread).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);-}-const MUTATING_VERBS = new Set(['pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_switch_tab', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_eval', 'pup_evaluate']);--// Debounced auto-flash: one flash per window per ~5s burst of verbs, re-armed after-// quiet. {silent:true} on the verb OR session._flashSilent (session default) suppresses.-// v1.8.71 (the Aditya/Google-auth incident): pup is the ANONYMOUS SANDBOXED browser โ a user-// signing into a real account inside a pup window either gets lost on the next fresh window or-// (on a branded-browser fallback) triggers Chrome's profile-creation/consolidation machinery.-// When a caller opens a known auth/login URL, steer them to the right surface in the hint.-const AUTH_URL_RE = /accounts\.google\.com|login\.microsoftonline\.com|login\.live\.com|\.okta\.com|\.auth0\.com|github\.com\/login|signin\.aws\.amazon\.com|appleid\.apple\.com|login\.salesforce\.com|id\.atlassian\.com|auth\.openai\.com|\/o\/oauth2\/|\/oauth2?\/(v2\/)?auth(orize)?/i;-function authSteerHint(url) {- if (!url || !AUTH_URL_RE.test(String(url))) return '';- return ' ๐ AUTH URL DETECTED: pup is the ANONYMOUS SANDBOXED browser โ its profiles are disposable, so a login done here is easily lost (next fresh window = signed out), and it must not become the home of the user\'s identity. For signing the USER into their accounts prefer: (a) abe / nbrowser_* โ their REAL browser where sessions + password manager already live, or (b) AD-core desktop_open_url โ a plain native OS launch of their default browser (zero automation machinery; ideal for OAuth flows that just need the user to click Approve). Continue in pup ONLY for throwaway/test accounts or when the flow explicitly needs an anonymous context.';-}-const _flashState = new Map(); // sessionId -> last flash ts-// v1.9.140 (John: 'did you orange flash them twice? ... i had to go click all of them again') โ-// ONE FLASH PER OPEN, operation-scoped. The launch path flashes instantly, and the open handler-// used to flash again after the real URL loaded; the 5s TIME debounce between them is outrun by-// any heavy site (YouTube/Maps load >5s), so every slow page double-flashed โ and a second orange-// AFTER the user already clicked forces them to click every window again. A consumed-once marker-// scopes suppression to the OPEN OPERATION regardless of load time.-const _openFlashDone = new Map(); // sessionId -> ts of the launch flash for the current open-function scheduleAutoFlash(session, opts) {- opts = opts || {};- if (taskbarActivityMode() !== 'flash') return; // v2.0.292: autoFlash folded into taskbarActivity='flash'- if (process.platform !== 'win32' || !session || !session._sessionId) return;- if (opts.silent || session._flashSilent) return;- const key = session._sessionId;- const now = Date.now();- if (now - (_flashState.get(key) || 0) < 5000) return; // coalesce the burst- _flashState.set(key, now);- session._flashCount = (session._flashCount || 0) + 1;- console.log(`[flash] "${key}" (auto)`); // v1.9.140: every flash logs, so a double-flash can never hide again- flashViaAD(session._sessionId, {}); // fire-and-forget (async โ AD desktop_flash_window)-}--// Record what the agent just did to a session, for lastAgentUpdate (pup_list_tabs /-// list_windows / status) and the "โ " title marker โ so the user + Session Monitor can-// see WHICH window/tab, WHAT verb, and the optional human note, WHEN.-function recordAgentUpdate(session, verb, args, tabId) {- if (!session) return;- const note = (args && typeof args.updateNote === 'string' && args.updateNote.trim()) ||- (args && typeof args.note === 'string' && args.note.trim()) || null;- const upd = { tabId: tabId || session.activeTabId || null, verb, note, ts: Date.now() };- session._lastAgentUpdate = upd;- if (upd.tabId) { const t = (session.tabs || []).find(x => x.tabId === upd.tabId); if (t) t.lastAgentUpdate = upd; }-}--// v1.8.37: CLEANUP NUDGE. pup never auto-closes windows (a "stale" one may belong to a-// paused thread), so windows an agent opened for a finished task otherwise pile up on the-// user's desktop forever. This returns a _hint (appended to open/status responses) reminding-// the caller to close what it's done with โ but only when it's actually worth nagging: some-// window is old (โฅ OLD_MIN) OR there are a lot of them (โฅ MANY). Lists the oldest offenders.-function staleWindowsHint() {- try {- const OLD_MIN = 30, MANY = 5;- const now = Date.now();- const all = [];- for (const [sid, s] of sessions) {- all.push({ sid, ageMin: s.createdAt ? Math.round((now - s.createdAt) / 60000) : 0, owner: s.owner || null });- }- const old = all.filter(w => w.ageMin >= OLD_MIN);- if (old.length === 0 && all.length < MANY) return '';- const show = (old.length ? old : all).sort((a, b) => b.ageMin - a.ageMin).slice(0, 6)- .map(w => `${w.sid}${w.owner ? `(owner:${w.owner})` : ''}=${w.ageMin}m`).join(', ');- return ` ๐งน CLEANUP: ${all.length} pup window(s) open${old.length ? `, ${old.length} โฅ${OLD_MIN}m old` : ''} โ close the ones you're DONE with via pup_close_window so they don't pile up on the user's desktop (pup never auto-closes; that's your job when a task ends). Oldest: [${show}].`;- } catch (e) { return ''; }-}--// Resize/position a session's window via CDP (works for detached+connected). Setting-// explicit bounds also un-maximizes the window (--start-maximized is otherwise the-// default). x/y/width/height in screen pixels; any subset is honored.-async function setSessionWindowBounds(session, { x, y, width, height } = {}) {- try {- const cdp = await session.page.target().createCDPSession();- const { windowId } = await cdp.send('Browser.getWindowForTarget');- // Must leave maximized/minimized state before setting a size, or Chrome ignores w/h.- await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });- const bounds = { windowState: 'normal' };- if (Number.isFinite(width)) bounds.width = Math.round(width);- if (Number.isFinite(height)) bounds.height = Math.round(height);- if (Number.isFinite(x)) bounds.left = Math.round(x);- if (Number.isFinite(y)) bounds.top = Math.round(y);- await cdp.send('Browser.setWindowBounds', { windowId, bounds });- try { await cdp.detach(); } catch {}- return true;- } catch (e) { console.error(`[bounds] setSessionWindowBounds failed: ${e.message}`); return false; }-}--async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false, foreground = false, highFps = false, insecureDev = false } = {}) {- // Check if browser already running for this profile.- // isConnected() can be stale after laptop sleep/wake โ the WebSocket hasn't- // noticed the disconnect yet. Validate with a real CDP call (pages()) with- // a short timeout to catch zombie browser handles.- const existing = browsers.get(profileName);- if (existing && existing.browser.isConnected()) {- try {- await Promise.race([- existing.browser.pages(),- new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2000)),- ]);- return existing.browser; // Really alive- } catch (e) {- console.log(`Cached browser for "${profileName}" failed health check (${e.message}) โ discarding`);- }- }- // Clean up stale entry- if (existing) {- try { await existing.browser.close(); } catch (e) {}- browsers.delete(profileName);- }-- // v1.9.4: CONCURRENT-LAUNCH DEDUP โ see `_launchInFlight` declaration. If a launch for this- // profile is already running, JOIN it (return its Promise) rather than spawning a competing- // Chrome onto the same userDataDir (which mutually lock-kills and wedges the bridge). The- // reuse-of-a-live-browser fast path above already returned; only genuine (re)launches reach here.- if (_launchInFlight.has(profileName)) {- console.log(`[launch] profile "${profileName}" is already launching โ joining the in-flight launch (no second Chrome)`);- return await _launchInFlight.get(profileName);- }- let _resolveLaunch, _rejectLaunch;- const _launchPromise = new Promise((res, rej) => { _resolveLaunch = res; _rejectLaunch = rej; });- _launchPromise.catch(() => {}); // mark handled โ joiners still observe rejection via their own await- _launchInFlight.set(profileName, _launchPromise);- try {-- // Prefer an ALREADY-INSTALLED browser (Chrome โ Edge; every Windows box has Edge)- // so the common case needs NO ~150 MB Chrome-for-Testing download. Falls back to a- // cached CfT. A fresh --user-data-dir (below) keeps it CLEAN โ it never touches the- // user's real tabs/logins (driving the real browser is the Adom extension's job).- //- // launchCandidates() returns the FULL ordered fallback list ([pinned default] โ- // Chrome โ Edge โ cached CfT). The launch loop below tries each in turn and- // SPAWN-VERIFIES it โ a broken/partial top pick (e.g. a corrupt Chrome install)- // falls through to the next real browser instead of hard-failing the open. The- // browser that actually launches is cached as the default (chrome.markVerified).- // v2.0.369: refresh the CfT-first policy from the LIVE setting on every launch, so flipping- // toolbarExtension applies without a bridge restart.- try { if (chrome.setPreferCft) chrome.setPreferCft(pupSettings().toolbarExtension !== 'off'); } catch (e) {}- const candidates = chrome.launchCandidates();- if (candidates.length) {- console.log(`[launch] ${candidates.length} browser candidate(s): ` + candidates.map(c => `${c.kind}(${c.source})`).join(' โ '));- } else {- console.log('[launch] no system browser or cached CfT detected โ falling back to puppeteer.executablePath()');- }-- // v2.0.293 โ MERGE duplicate --disable-features / --enable-features into ONE flag each.- // Chrome honors only the LAST occurrence of a switch, so when three code eras each appended- // their own --disable-features (v2.0.120 MediaRouter/mDNS, v1.9.198 CalculateNativeWinOcclusion,- // v1.8.95 SigninIntercept...), only the last list applied โ which SILENTLY UNDID the v2.0.120- // firewall fix: MediaRouter's mDNS re-bound 0.0.0.0:5353 (measured live on ConfRoomROG, CfT 148)- // and every new CfT exe re-popped the Windows Firewall "allow access?" dialog at John.- // pup's HARD RULE is zero firewall prompts (see dev-skills/pup-no-firewall): every listener- // loopback-only. Any future feature-flag addition MUST go through this merge (it runs at both- // launch sites), never as a hand-appended second --disable-features.- function mergeChromiumFeatureFlags(args) {- const out = [];- const merged = {}; // switchName -> { idx, values:[] }- for (const a of args) {- const m = typeof a === 'string' && a.match(/^--(disable-features|enable-features)=(.*)$/);- if (!m) { out.push(a); continue; }- const key = m[1];- if (!merged[key]) { merged[key] = { idx: out.length, values: [] }; out.push(null); }- for (const v of m[2].split(',')) { if (v && !merged[key].values.includes(v)) merged[key].values.push(v); }- }- for (const key of Object.keys(merged)) out[merged[key].idx] = `--${key}=${merged[key].values.join(',')}`;- return out.filter(a => a !== null);- }-- const launchOpts = {- headless: false,- defaultViewport: null,- // executablePath is set PER-CANDIDATE in the spawn-verified launch loop below.- args: [- // v1.9.129 โ SECURE BY DEFAULT, now that the default profile is durable and holds REAL- // logins (John's Gmail as of today). Four flags removed from every default launch:- // --no-sandbox / --disable-setuid-sandbox: docker-era boilerplate; on a desktop the- // sandbox works and is strictly better protecting a credentialed profile.- // --disable-web-security: killed the same-origin policy, which was tolerable when every- // window was a throwaway empty jar and is UNSAFE on a profile with live sessions โ- // any opened page could have read Gmail cross-origin.- // --test-type: existed only to hush the warning bars the flags above caused, and it- // also suppresses Chrome's promo/save bubbles โ it is why Chrome NEVER OFFERED to- // save John's password. Remove the cause, lose the suppressor, get the bubble back.- // Dev windows that truly need cross-origin eval opt back in per-window with- // {webSecurity:false}, which FORCES an isolated throwaway profile (below) so the- // insecure flags can never share a process with the credentialed jar.- ...(insecureDev ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security', '--test-type'] : []),- '--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',- // 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'));+ if ((process.platform !== 'win32' && process.platform !== 'darwin') || !hwnd) return null;+ // v2.0.308: this ran a RAW ab enum on every validation (every verb that touches an hwnd). Use the+ // shared cached snapshot; a handle missing from it (a window younger than the cache) gets ONE+ // fresh look, which is also coalesced across concurrent callers.+ try {+ let row = (await enumChromeWidgetWindows()).find(r => Number(r.h) === Number(hwnd));+ if (!row) row = (await enumChromeWidgetWindows({ fresh: true })).find(r => Number(r.h) === Number(hwnd));+ if (!row) return null;+ const pid = Number(row.pid);+ return Number.isFinite(pid) && pid > 0 ? { pid, title: String(row.title || '') } : null;+ } catch (e) { return null; }+}+// Confirm an hwnd is a LIVE window owned by a pup browser process. Used as the hard gate before any+// taskbar paint/flash, and to invalidate a stale cached hwnd (recycled numbers).+// v2.0.34 (John caught fusion + nb resolving to the SAME hwnd): a window handle can belong to+// exactly ONE session. This rejects a candidate handle already claimed by another live session's+// _hwnd or birth __pupHwnd, so concurrent births/resolves on the shared profile can't cross-wire+// (the bug where one thread stamped its identity onto another thread's taskbar button).+// v2.0.45 (mimic-ralph caught mk-* windows STEALING John's real windows' handles during the+// recovery storm): in-memory claims lag recovery โ a session that resolves early can take a handle+// whose true owner hasn't restored its claim yet. This map is loaded from EVERY session file at the+// START of recovery (before any resolve can fire), so a persisted handle is protected from t=0.+// persistSessionHwnd keeps it current; deleteSessionFile releases it.+const _persistedHwndClaims = new Map(); // hwnd -> sessionId+function hwndOwnedByOtherSession(hwnd, exceptSessionId) {+ const h = Number(hwnd);+ const pc = _persistedHwndClaims.get(h);+ if (pc && pc !== exceptSessionId) return true;+ for (const [k, s] of sessions) {+ if (k === exceptSessionId || !s) continue;+ if (Number(s._hwnd) === h) return true;+ for (const t of (s.tabs || [])) { if (t && t.page && Number(t.page.__pupHwnd) === h) return true; }+ }+ return false;+}+async function hwndBelongsToPup(hwnd, session) {+ // v1.9.287 (John caught two sessions on DIFFERENT profiles sharing one hwnd): validate that the+ // hwnd belongs to THIS session's OWN profile process, not merely "some pup window". The old check+ // let a wrong-but-pup cached hwnd (another session's window) pass forever, so a stale/mis-resolved+ // hwnd was never corrected and overlays painted on the wrong button. When a session is given, the+ // pid set is scoped to its profile; a cross-profile hwnd now fails and forces a re-resolve.+ const prof = session && session.profileName;+ // v2.0.31: STRONGEST PROOF FIRST โ a hwnd pup captured at BIRTH (it created that exact off-screen+ // window) is definitively pup's, no pid tracking and no title tag required. This replaces the+ // v1.9.350 title-based shared-profile fallback that 2.0.24 broke by removing the title tag.+ // v2.0.309 (John: the enum spam): this proof now runs BEFORE the owner-pid enumeration, so a+ // window pup itself created is never re-enumerated to be believed. The owner pid is learned from+ // the shared snapshot when it happens to be there, never by forcing a fresh enum.+ try {+ const born = session && (session.tabs || []).some(t => t && t.page && Number(t.page.__pupHwnd) === Number(hwnd));+ if (born) {+ try { const row = (_enumCache.rows || []).find(r => Number(r.h) === Number(hwnd)); if (row && row.pid) learnConfirmedPid(prof, Number(row.pid)); } catch (e) {}+ return true;+ }+ } catch (e) {}+ const info = await hwndOwnerPid(hwnd);+ const pid = info && info.pid;+ if (!pid) return false;+ // v2.0.43: RECOVERED-HANDLE PROOF. A respawned pup can't have birth-captured anything, but the+ // session file persisted both the hwnd AND Chrome's pid at capture time, and Chrome outlives pup+ // respawns โ so this exact pair (the handle still owned by the very process it was recorded+ // under) is proof by continuity. Without this branch every recovered window failed all proofs,+ // its perfectly good persisted handle was discarded, and the wiggle fallback couldn't+ // disambiguate the shared profile (mimic-ralph: NOT-RESOLVED-x4 on every window, every respawn).+ try {+ if (session && session._recoveredHwndPid && Number(session._hwnd) === Number(hwnd)+ && Number(pid) === Number(session._recoveredHwndPid)) {+ learnConfirmedPid(prof, pid); return true;+ }+ } catch (e) {}+ const own = pupBrowserPidSet(prof || undefined);+ if (own.has(pid)) return true;+ try {+ // v2.0.27: parent pid natively via ab process_list (was a SYNC CIM PowerShell that blocked the loop)+ const pd = abPayload(await chrome.abCommand('process_list', { pidFilter: pid }, { timeoutMs: 5000 }));+ const row = ((pd && pd.processes) || []).find(p => Number(p.pid) === Number(pid));+ const par = row && Number(row.parentPid);+ if (par && own.has(par)) { learnConfirmedPid(prof, pid); return true; }+ } catch (e) {}+ // v1.9.350 (John: "why does the taskbar pup icon have no overlay?"). The shared adom-you profile+ // runs ONE Chrome process with MANY windows, and that process's real pid is often NOT in the tracked+ // set (puppeteer's browser.process() is the dead post-fork launcher and _detachedPid can be stale),+ // so the pid + parent checks above BOTH miss and this guard REFUSED every favicon badge on that+ // profile โ the exact symptom John saw (teal base tile, no overlay). A pup window's title carries+ // this session's UNIQUE tag "(ai-thread: <sid>)", which Edge / any other app can NEVER have, so a+ // title match is hard proof of pup ownership. Accept it AND LEARN the owner pid onto the browser+ // entry, so every sibling window on that shared Chrome process passes instantly afterwards+ // (self-heal). Edge stays protected: no pup title, nothing learned, still refused.+ try {+ const title = info && info.title;+ if (title && prof) {+ let sid = null;+ for (const [k, s] of sessions) { if (s === session) { sid = k; break; } }+ if (sid && title.indexOf(`(${TITLE_LABEL}: ${sid}`) >= 0) { learnConfirmedPid(prof, pid); return true; }+ }+ } catch (e) {}+ return false;+}+// v1.9.304: enumerate every visible top-level Chrome browser frame with its owner pid + rect. Shared+// by birth-time resolution (createPageInNewWindow) and the resolver. Returns [{h,pid,L,T,W,H}].+// v2.0.148 (John: "why are you calling enum_windows a bazillion times via ab?"): COALESCE + a tiny+// TTL cache. The enforcer/park/flash/reconcile sweeps each independently enumerate every Chrome+// window, so a single tick fired many identical desktop_enum_windows calls at ab within the same+// few ms โ the flood John saw. (1) In-flight coalescing: concurrent callers share ONE ab call.+// (2) A result cache. v2.0.151 (John: "does enum_windows have to be called as often as you do โ it+// makes a lot of noise in ab's logs"): raised 120ms โ 1500ms. The steady-state callers (the 8s+// reconcile, the overlay enforcer, the placement/user-activity sweeps) only need to FIND existing+// windows, and a window's geometry does not meaningfully change within 1.5s โ so a shared snapshot+// per 1.5s is correct for them and cuts the ab call rate by ~12x. The ONLY callers that must observe+// a brand-new window the instant it appears are the birth-time set-diff and the off-screen+// re-capture move-detect loops; both pass {fresh:true} and bypass the cache entirely.+const ENUM_CACHE_MS = 1500;+// v2.0.172 (John, 3rd report: "are you still spamming ab with enum_windows?"). He was right, and+// the 2.0.151 cache was never the whole story: {fresh:true} bypassed BOTH the cache AND the+// in-flight coalescing, and the two fresh callers are POLL LOOPS โ birth capture (up to 20 rounds)+// and off-screen re-capture (up to 8) โ so every window open sprayed ~8 enum calls/second at ab,+// and opening 7 windows multiplied that. Fresh callers genuinely cannot use a 1.5s snapshot (they+// must see a brand-new window), but they can share ONE call with each other: a 250ms freshness+// window is far below the ~100ms a window takes to appear, yet collapses simultaneous pollers and+// halves each loop's rate. Net: same detection latency, a fraction of the ab log noise.+// v2.0.184 (John's 3rd spam report, screenshot: multiple enum calls in the SAME second): the+// bursts are BIRTH-CAPTURE polls during window opens โ and sibling threads on a busy box open+// windows constantly, so "per-open cost" IS the steady-state log noise. 250ms sharing still allowed+// ~4 real calls/second. At 1000ms, every fresh poller shares one snapshot per second, so the ab log+// can never show more than ~1 enum/second no matter how many opens run concurrently. A birth+// set-diff tolerates a <=1s-stale snapshot fine: it just sees the new window one poll later, and+// window creation takes 1-3s anyway.+const ENUM_FRESH_MS = 1000;+// v2.0.187 (John, 4th report, and he named the actual design flaw: "you find the hwnd and then+// never clean up after yourself โ stop polling once you've figured out what you needed"). He is+// right: the enum exists for BIRTH (find the new hwnd by set-diff). Steady-state sweeps were lazily+// reusing it every 1.5s forever. Now: when every live session already has a resolved hwnd, the+// snapshot is served for up to ENUM_CACHE_LONG_MS without touching ab at all, and the cache is+// INVALIDATED the moment anything could have changed โ a window-opened UI event, a pup open/close,+// a reap. Steady state = zero enum calls, not a slower drip of them.+const ENUM_CACHE_LONG_MS = 45000;+function invalidateEnumCache() { _enumCache = { at: 0, rows: null }; }+function _allHwndsResolved() {+ if (!sessions.size) return false;+ let any = false;+ for (const [, s] of sessions) { if (!(s.tabs || []).length) continue; any = true; if (!s._hwnd) return false; }+ return any;+}+let _enumInflight = null;+let _enumFreshInflight = null;+let _enumCache = { at: 0, rows: null };+let _enumCalls = 0; // lifetime count of REAL ab calls (exposed on /status for auditing)+let _enumWindow = { at: 0, n: 0 }; // rolling 60s rate, so "is it spamming?" is answerable with data+async function enumChromeWidgetWindows(opts) {+ if (process.platform !== 'win32' && process.platform !== 'darwin') return [];+ const fresh = !!(opts && opts.fresh);+ if (fresh) {+ if (_enumCache.rows && Date.now() - _enumCache.at < ENUM_FRESH_MS) return _enumCache.rows;+ if (_enumFreshInflight) return _enumFreshInflight;+ }+ if (!fresh && _enumCache.rows && Date.now() - _enumCache.at < (_allHwndsResolved() ? ENUM_CACHE_LONG_MS : ENUM_CACHE_MS)) return _enumCache.rows;+ if (!fresh && _enumInflight) return _enumInflight;+ const run = (async () => {+ try {+ _enumCalls++;+ const _now = Date.now();+ if (_now - _enumWindow.at > 60000) _enumWindow = { at: _now, n: 0 };+ _enumWindow.n++;+ if (process.platform === 'darwin') {+ // macOS: AD's desktop_list_windows gives each window's CGWindowID (as hwnd), owner pid, title and+ // bundle id. The Chromium-family rows are the candidates; callers fence them by pup's own pids, as+ // on Windows. Without this every resolver below returned nothing on a Mac, so pup_list_windows+ // reported hwnd:null for every session and AD's window recording / screenshots had no target.+ const dm = abPayload(await chrome.abCommand('desktop_list_windows', { includeOffscreen: true }, { timeoutMs: 8000 }));+ const list = (dm && (dm.windows || (dm.data && dm.data.windows))) || [];+ const rows = [];+ for (const w of list) {+ if (!/chrom|edge/i.test(String(w.appBundleId || ''))) continue;+ const h = Number(w.hwnd), r = w.rect || {};+ if (h) rows.push({ h, pid: Number(w.ownerPid), L: Number(r.left) || 0, T: Number(r.top) || 0, W: Number(r.width) || 0, H: Number(r.height) || 0, title: String(w.title || '') });+ }+ _enumCache = { at: Date.now(), rows };+ return rows;+ }+ const d = abPayload(await chrome.abCommand('desktop_enum_windows', { className: 'Chrome_WidgetWin_1', includeOffscreen: true }, { timeoutMs: 8000 }));+ const rows = [];+ for (const w of ((d && d.windows) || [])) {+ if (w.visible === false) continue;+ const r = w.rect || [];+ const h = Number(w.hwnd);+ if (h && Number.isFinite(Number(r[2]))) rows.push({ h, pid: Number(w.pid), L: Number(r[0]), T: Number(r[1]), W: Number(r[2]) - Number(r[0]), H: Number(r[3]) - Number(r[1]), title: String(w.title || '') });+ }+ _enumCache = { at: Date.now(), rows };+ return rows;+ } catch (e) { return []; }+ })();+ if (fresh) { _enumFreshInflight = run; run.finally(() => { if (_enumFreshInflight === run) _enumFreshInflight = null; }); }+ else { _enumInflight = run; run.finally(() => { if (_enumInflight === run) _enumInflight = null; }); }+ return run;+}+// v1.9.309 (John: "how many times did you say you couldn't look up a window by title... you keep+// duct-taping it instead of a permanent solution"). He counted right โ 23 call sites resolved a+// window by its "(ai-thread: X)" TITLE, and that same unreliable lookup independently broke the+// overlay, the park, the flash, and the AUMID stamp. THE PERMANENT FIX: one way to name a session's+// OS window for every AD window verb โ its HANDLE. windowTarget() resolves + validates the hwnd+// (read-only; it never resizes anything) and returns { hwnd }. Every operation spreads this, so hwnd+// is primary everywhere; titleContains remains ONLY as a last-ditch fallback for a window we somehow+// never got a handle for. As birth-time capture + persistence cover every window, the fallback goes+// cold and title stops mattering for targeting at all.+// v2.0.237: returns a REAL hwnd or null โ never a title guess. Callers that can act without the+// shell (CDP geometry, for instance) use this so they can skip the ab round-trip entirely instead of+// sending a titleContains that cannot match while titleTag is off (its default).+async function windowHwnd(session, sessionId) {+ try {+ if (session && !session._hwnd) { await bestSessionHwnd(session, sessionId); }+ if (session && session._hwnd && await hwndBelongsToPup(session._hwnd, session)) return Number(session._hwnd);+ } catch (e) {}+ return null;+}+async function windowTarget(session, sessionId) {+ try {+ if (session && !session._hwnd) { await resolveSessionHwndByBounds(session, sessionId); }+ if (session && session._hwnd && await hwndBelongsToPup(session._hwnd, session)) return { hwnd: Number(session._hwnd) };+ // (No pid-tree guess here: pup windows SHARE one Chrome process tree, so "the largest window of+ // this pid" is often a DIFFERENT session's window โ parking or branding the wrong window is a+ // worse failure than reporting that we could not resolve this one.)+ } catch (e) {}+ // v2.0.228: titleTag defaults OFF (since the 2.0.20 hotfix), so this fallback CANNOT match any+ // window โ it produced the mystifying 'No visible window with title containing "(session: X)"'+ // that broke pup_lower_os_window during the icon audit. Say so out loud once per session instead+ // of letting the caller stare at an impossible-looking lookup.+ if (pupSettings().titleTag !== 'on') {+ const _s = sessions.get(sessionId);+ if (_s && !_s._titleFallbackWarned) {+ _s._titleFallbackWarned = true;+ console.log(`[windowTarget] "${sessionId}" has NO hwnd and titleTag is OFF โ falling back to a title lookup that cannot match; the window op will fail. Root cause is the missing handle, not the title.`);+ }+ }+ return { titleContains: `(${TITLE_LABEL}: ${sessionId}` };+}+async function resolveSessionHwndByBounds(session, sessionId) {+ if ((process.platform !== 'win32' && process.platform !== 'darwin') || !session) return null;+ // macOS: no birth capture or window-property tag exists there, so the natural-title match and the+ // sole-window-of-profile pick below (both read-only, pid-fenced) are what resolve the handle.+ // v2.0.17 (#557 follow-through): NEGATIVE-RESULT BACKOFF. A shared-profile window that resolution+ // CANNOT disambiguate (N windows, many wiggle-matches) was re-probed on every overlay/park/flash+ // touch โ a hot loop of PowerShell EnumWindows passes, forever, per unresolvable session. That+ // burned CPU (John's laptop-load report) AND blocked the event loop often enough that ab's 1500ms+ // /health marker probe timed out, so ab judged a LIVE bridge unhealthy, reaped it (0 tracked pids),+ // and spawn-looped โ the #557 flap's other half. Three consecutive failures now put the session on+ // a 5-minute resolution cooldown; any success (or a fresh open with a birth-time handle) resets it.+ // v2.0.33: the backoff suppresses the GEOMETRY hammering (its original purpose) but must NOT+ // block the definitive off-screen RE-CAPTURE below โ that is the fix for the stuck-bare case.+ const _geomBackoff = !!(session._hwndBackoffUntil && Date.now() < session._hwndBackoffUntil);+ // v2.0.312 (John's ring showed "17 straight misses" in 5s while claiming a 5-minute backoff): the+ // backoff used to skip only the geometry leg, which is deleted, so every sweep still ran the whole+ // function and logged a miss ~4x/sec. In backoff: one in-process property lookup (microseconds),+ // then return quietly. The tag is the only signal that can change the answer anyway.+ if (_geomBackoff) {+ try {+ const _ph0 = await resolveHwndBySessionProp(sessionId);+ if (_ph0) { session._hwnd = _ph0; session._hwndFailStreak = 0; session._hwndBackoffUntil = 0; console.log(`[hwnd] "${sessionId}" resolved ${_ph0} by property tag (during backoff)`); return _ph0; }+ } catch (e) {}+ return null;+ }+ try {+ // v1.9.278: NEVER trust a cached hwnd blind - validate it still belongs to pup before reuse+ // (a dead/recycled handle now owned by Edge is exactly how the Edge overlay happened).+ if (session._hwnd) {+ if (await hwndBelongsToPup(session._hwnd, session)) { session._hwndFailStreak = 0; session._hwndBackoffUntil = 0; _ensureWindowTagged(session, sessionId, Number(session._hwnd)); console.log(`[hwnd] "${sessionId}" cached/persisted handle ${session._hwnd} VALIDATED (continuity)`); return Number(session._hwnd); }+ console.log(`[hwnd] "${sessionId}" cached hwnd ${session._hwnd} is stale/foreign โ discarding and re-resolving`);+ session._hwnd = null;+ }+ // v1.9.304: BIRTH-TIME handle โ captured while this window was off-screen at creation, the only+ // unambiguous moment for a same-profile window. Trust it (validated) ahead of every geometry+ // heuristic; this is what finally resolves multi-window profiles the wiggle never could.+ try {+ const p0 = (session.tabs || [])[0] && (session.tabs || [])[0].page;+ const bh = p0 && p0.__pupHwnd;+ if (bh && hwndOwnedByOtherSession(Number(bh), sessionId)) {+ // v2.0.34: another live session already owns this handle โ our birth capture cross-wired.+ // Drop it and fall through so we resolve a DISTINCT window instead of stamping onto theirs.+ console.log(`[hwnd] "${sessionId}" birth handle ${bh} is owned by ANOTHER session โ discarding`);+ try { p0.__pupHwnd = null; } catch (e) {}+ } else if (bh && await hwndBelongsToPup(Number(bh), session)) {+ session._hwnd = Number(bh); persistSessionHwnd(sessionId, Number(bh));+ // DETERMINISTIC IDENTITY: tag the window with its sessionId so it stays re-findable after+ // any future handle loss (dev-skills/pup-no-wiggle). Guarded: tags once per handle.+ _ensureWindowTagged(session, sessionId, Number(bh));+ console.log(`[hwnd] "${sessionId}" resolved OS handle ${bh} โ birth-time capture (off-screen at creation)`);+ return Number(bh);+ }+ } catch (e) {}+ // ========================================================================+ // DETERMINISTIC PROPERTY LOOKUP โ the sanctioned resolver (dev-skills/pup-no-wiggle).+ // The birth handle is a fast path; when it is gone (crash / respawn / bridge restart) we re-find+ // the window by the Win32 property tag pup wrote at birth. This is a LOOKUP, not a guess: only the+ // window tagged "AdomPupSid=<this session>" can match, so it is exact even on the shared profile+ // where every window has identical geometry. This REPLACES the banned geometry 'wiggle'.+ try {+ const _ph = await resolveHwndBySessionProp(sessionId);+ if (_ph && !hwndOwnedByOtherSession(Number(_ph), sessionId) && await hwndBelongsToPup(Number(_ph), session)) {+ session._hwnd = Number(_ph); persistSessionHwnd(sessionId, Number(_ph));+ session._hwndFailStreak = 0; session._hwndBackoffUntil = 0;+ session._taggedHwnd = Number(_ph); // resolved BY the tag, so it is already tagged+ try { const p0b = (session.tabs || [])[0]; if (p0b && p0b.page) p0b.page.__pupHwnd = Number(_ph); } catch (e) {}+ console.log(`[hwnd] "${sessionId}" resolved OS handle ${_ph} by WINDOW-PROPERTY tag (deterministic; no geometry, no wiggle)`);+ return Number(_ph);+ }+ } catch (e) {}+ // v1.9.274 (John's dashboard showed 3 'untracked' windows that WERE the real session windows):+ // every parked window shares the IDENTICAL rect (work-area minus insets), so pure geometry+ // matching cannot distinguish windows after a restart and misassigns hwnds. The title tag is+ // exact where it exists - resolve by title FIRST; geometry stays as the fallback for windows+ // that cannot carry the tag (PDF viewer).+ // titleTag ON: the persistent "(ai-thread: X)" suffix is the exact, reliable resolver. When OFF+ // we skip straight to the unique-width geometry wiggle below (observer-proof; the transient TITLE+ // approach was fought by the page's own title observer, collapsing same-profile windows onto one+ // hwnd โ John's dup-handle bug).+ // v2.0.10 (John: a bridge-pages window with 4 same-profile windows + 14 ambiguous wiggle-matches+ // got NO overlay). Try the unique title tag REGARDLESS of the titleTag setting, but only trust an+ // UNAMBIGUOUS (count===1) match. The "(ai-thread: <sid>)" suffix is unique to THIS session and+ // impossible on Edge/non-pup, so a single match is safe even when the geometry wiggle can't tell+ // same-profile windows apart. A page title-observer that strips the tag just yields count 0 (or a+ // recycle yields >1), and we fall through to the wiggle โ never guessing. This is what the earlier+ // titleTag gate blocked: the resolver could see the tag but was told not to use it.+ // v2.0.148 (John's enum flood): during the resolution COOLDOWN, skip these OS-probe passes+ // (the title find_window + the natural-title enum) โ they hammer ab every sweep for a session+ // that already failed to resolve 3x, and if they COULD resolve it the backoff would never have+ // accumulated. The 30s-throttled off-screen re-capture below still runs (v2.0.33), so a stuck+ // background window still self-heals; we just stop the per-sweep probe storm meanwhile.+ if (!_geomBackoff) try {+ const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000, caller: sessionCaller(session) }));+ const cnt = fw && (fw.count != null ? fw.count : (fw.data && fw.data.count));+ const b = fw && (fw.best || (fw.data && fw.data.best));+ if (cnt === 1 && b && b.hwnd) { session._hwnd = Number(b.hwnd); persistSessionHwnd(sessionId, Number(b.hwnd)); console.log(`[hwnd] "${sessionId}" resolved by unique TITLE tag -> ${b.hwnd}`); return Number(b.hwnd); }+ } catch (e) {}+ // v2.0.44 NATURAL-TITLE RESOLUTION (read-only, no tag, no geometry): the OS window title IS the+ // active tab's page title (Chrome mirrors it), and pup already knows each session's active tab.+ // Match this session's active-tab title against the native rows โ exactly-one match on a+ // pup-owned pid is proof. This restores the v1.9.274 title-first doctrine WITHOUT the title tag+ // that 2.0.24 removed as user-facing mangling: nothing is written anywhere, we just read what+ // Chrome already shows. PID FENCE: candidate rows must belong to this profile's tracked pids or+ // the recovery-continuity pid โ John's own Chrome (same window class!) can never be matched even+ // if he has the identical page open. Same-title siblings yield 2+ matches and are refused+ // (bare > wrong). This is what finally re-resolves recovered windows the geometry never could.+ if (!_geomBackoff) try {+ // v2.0.45 (the handle theft): the OS title mirrors the tab CHROME shows, which is NOT always+ // pup's activeTabId (tab adds leave Chrome on the last-opened tab while pup's bookkeeping says+ // the first). Asking the wrong title made this session uniquely match SOMEONE ELSE'S window.+ // Ask Chrome: the tab whose document is 'visible' is the one the window's title mirrors.+ let _pg0 = null;+ for (const _t of (session.tabs || [])) {+ if (!_t || !_t.page) continue;+ try { const vis = await Promise.race([_t.page.evaluate(() => document.visibilityState), new Promise(r => setTimeout(() => r(''), 1200))]); if (vis === 'visible') { _pg0 = _t.page; break; } } catch (e) {}+ }+ if (!_pg0) { const _at = session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId); _pg0 = (_at && _at.page) || ((session.tabs || [])[0] && (session.tabs || [])[0].page); }+ let _ptitle = '';+ try { _ptitle = String(await Promise.race([_pg0.title(), new Promise(r => setTimeout(() => r(''), 2500))]) || '').trim(); } catch (e) {}+ if (_ptitle.length >= 5) {+ const _rows0 = await enumChromeWidgetWindows();+ const _profPids0 = pupBrowserPidSet(session.profileName);+ const _okPid = (p) => _profPids0.has(p) || (session._recoveredHwndPid && Number(p) === Number(session._recoveredHwndPid));+ const _titleHit = (r) => r.title && (r.title === _ptitle || r.title.startsWith(_ptitle + ' - ') || r.title.startsWith(_ptitle + ' \u2013 '));+ // v2.0.45: count title matches over ALL pup rows FIRST โ if two pup windows share this+ // title, identity is unknowable by title even when ownership exclusions leave one row+ // (excluding the true owner's row is exactly how the theft looked "unique"). Refuse.+ const _allHits = _rows0.filter(r => _okPid(r.pid) && _titleHit(r));+ const _tm = _allHits.length === 1 ? _allHits.filter(r => !hwndOwnedByOtherSession(r.h, sessionId)) : [];+ if (_allHits.length > 1) console.log(`[hwnd] "${sessionId}" natural-title "${_ptitle.slice(0, 40)}" matches ${_allHits.length} pup windows โ ambiguous, refusing`);+ if (_tm.length === 1) {+ const got = Number(_tm[0].h);+ session._hwnd = got; persistSessionHwnd(sessionId, got);+ session._hwndFailStreak = 0; session._hwndBackoffUntil = 0;+ try { const p0b = (session.tabs || [])[0]; if (p0b && p0b.page) p0b.page.__pupHwnd = got; } catch (e) {}+ console.log(`[hwnd] "${sessionId}" resolved OS handle ${got} โ NATURAL-title match ("${_ptitle.slice(0, 60)}")`);+ return got;+ }+ }+ } catch (e) {}+ const page = (session.tabs || [])[0] && (session.tabs || [])[0].page;+ if (!page) return null;+ // v2.0.314 (audit after John: "analyze all the polling... delete old code we don't use"): the+ // geometry/wiggle tail is GONE (dev-skills/pup-no-wiggle). It opened a CDP session to read bounds+ // it never used, carried a lock never taken, and ended in a literal `if (false)` recapture block.+ // What remains is the ONE read-only, wiggle-free signal: if this profile's browser process owns+ // exactly one top-level window, that window is this session's. Otherwise leave it bare; the+ // property tag (set at birth) is how it gets resolved.+ const rows = await enumChromeWidgetWindows();+ const _profPids = pupBrowserPidSet(session.profileName);+ const _profWins = new Set();+ for (const r of rows) { if (r.h && _profPids.has(r.pid)) _profWins.add(r.h); }+ let resolved = (_profWins.size === 1) ? [..._profWins][0] : null;+ if (resolved && hwndOwnedByOtherSession(resolved, sessionId)) { console.log(`[hwnd] "${sessionId}" sole-window pick ${resolved} is owned by another session โ refusing`); resolved = null; }+ if (resolved) {+ session._hwnd = resolved;+ persistSessionHwnd(sessionId, resolved);+ console.log(`[hwnd] "${sessionId}" resolved OS handle ${resolved} โ sole window of profile process`);+ return resolved;+ }+ session._hwndFailStreak = (session._hwndFailStreak || 0) + 1;+ if (session._hwndFailStreak >= 3) {+ session._hwndBackoffUntil = Date.now() + 5 * 60000;+ console.log(`[hwnd] "${sessionId}" NOT resolved โ ${_profWins.size} windows on this profile, no property tag; leaving bare + backing off for 5 min`);+ } else {+ console.log(`[hwnd] "${sessionId}" NOT resolved โ ${_profWins.size} windows on this profile, no property tag; leaving bare rather than guessing`);+ }+ return null;+ } catch (e) {+ // v2.0.17: exceptions count toward the same backoff โ the EBUSY spin was exactly this path looping.+ session._hwndFailStreak = (session._hwndFailStreak || 0) + 1;+ if (session._hwndFailStreak >= 3) session._hwndBackoffUntil = Date.now() + 5 * 60000;+ console.log(`[hwnd] "${sessionId}" bounds resolve failed: ${e.message}${session._hwndFailStreak >= 3 ? ' โ backing off resolution for 5 min' : ''}`);+ return null;+ }+}++async function pageWindowId(page) {+ let cdp = null;+ try {+ cdp = await page.target().createCDPSession();+ const { windowId } = await cdp.send('Browser.getWindowForTarget');+ return windowId;+ } catch (e) { console.log(`[drag] pageWindowId FAILED: ${e.message}`); return null; }+ finally { try { if (cdp) await cdp.detach(); } catch (e) {} }+}+// Split any tab that no longer shares its session's window into its own session.+async function adoptDraggedOutTabs(sessionId) {+ const session = sessions.get(sessionId);+ if (!session || (session.tabs || []).length < 2) return 0;+ let adopted = 0;+ try {+ // v1.9.71: do NOT log per-tab on every check. A 12-tab window emitted 12 "(same)" lines per+ // check, and checks fire on window-opened events โ by far the biggest source of log volume.+ // Only a tab that actually moved windows is worth a line.+ const active = (session.tabs || []).find(t => t.tabId === session.activeTabId) || session.tabs[0];+ const homeWin = await pageWindowId(active.page);++ if (homeWin == null) return 0;+ for (const t of [...session.tabs]) {+ if (t === active) continue;+ const wid = await pageWindowId(t.page);+ if (wid != null && wid !== homeWin) console.log(`[drag] "${sessionId}" ${t.tabId} moved to window ${wid} (home ${homeWin}) -> adopting`);+ if (wid == null || wid === homeWin) continue;+ // This tab lives in a DIFFERENT OS window now โ it was dragged out. Give it its own session.+ const newId = `${sessionId}-split${adopted + 1}-${Date.now().toString(36).slice(-4)}`;+ const idx = session.tabs.indexOf(t);+ if (idx !== -1) session.tabs.splice(idx, 1);+ if (session.activeTabId === t.tabId) session.activeTabId = session.tabs.length ? session.tabs[0].tabId : null;+ const ns = createSession(session.profileName);+ ns.owner = session.owner || null;+ ns._wikiView = session._wikiView;+ ns._wikiAuthed = session._wikiAuthed;+ ns._publicProfile = session._publicProfile;+ ns._sessionId = newId;+ attachTab(ns, newId, t.page);+ sessions.set(newId, ns);+ // Re-tag the title FIRST: every downstream operation (identity stamp, overlay, jump list)+ // resolves this window by its "(session: <id>)" suffix, and until it says the NEW id the+ // window is invisible to AD and collides with its old siblings.+ await retagSessionTitle(t.page, newId, ns.profileName);+ const be = browsers.get(session.profileName);+ if (be) be.refCount++;+ let u = null; try { u = t.page.url(); } catch (e) {}+ console.log(`[drag] "${sessionId}" tab dragged out โ adopted as session "${newId}" (${(u || '').slice(0, 60)})`);+ adopted++;+ // Brand the new window: its own identity, CATEGORY icon, overlay and jump list. Retried,+ // because a freshly created window is not findable by title for a beat.+ refreshWindowChrome(ns, newId).catch(() => {});+ try { saveSessionFile(newId, ns.profileName, u); } catch (e) {}+ }+ if (adopted) {+ // The SOURCE window lost a tab: its category may have changed (mixed -> wiki) and its overlay+ // favicon may belong to the tab that just left. Force a full, retried refresh.+ session._lastCat = undefined; // defeat the same-category short-circuit+ _appOverlayCache.delete(sessionId + ':explicit');+ // Re-assert the source window's own tag (its tab set changed under it).+ try {+ const at = (session.tabs || []).find(x => x.tabId === session.activeTabId) || session.tabs[0];+ if (at) await retagSessionTitle(at.page, sessionId, session.profileName);+ } catch (e) {}+ refreshWindowChrome(session, sessionId).catch(() => {});+ }+ } catch (e) { console.log(`[drag] adopt threw: ${e.message}`); }+ return adopted;+}+// v1.9.47 โ RECLAIM UNMANAGED WINDOWS. The gap that kept John's taskbar wrong.+//+// adoptDraggedOutTabs only ever looked at tabs pup ALREADY TRACKS, so it could never see a window+// pup has no tab for. Two everyday cases produce exactly that, and both leave a permanently+// UNBRANDED taskbar button (raw "Google Chrome for Testing"), which is what a taskbar audit found:+// 1. An OPENERLESS new window. wirePopupTracking deliberately drops targets with no opener (it+// must not await target.page() on one โ that re-enters the CDP pipe and hangs newPage; see+// v1.8.18). Correct there, but nothing ever picked those windows up afterwards. Measured: one+// ti.com session had spawned TWO untracked windows (an stm32 search + a product page).+// 2. A window whose SESSION DIED but whose Chrome kept running โ e.g. the session file was lost,+// so pup is not even connected to that profile any more.+//+// So reclamation must work from the WINDOW side, not the tab side: enumerate every page in every+// pup profile, group by OS window, and adopt any window no session owns. Runs on a timer (out of+// band โ never inside a targetcreated handler, to keep the pipe safe).+// v1.9.279 THE RECONCILER (John: "your code must be 100% correct... i even just saw another ai+// thread close an aa.com pup window and then you left the aa.com icon in your overlay"). Every bug+// this session was the same class: pup's in-memory model drifting from OS reality (a closed window+// lingering, a stamp not updated when a setting changed, a recycled hwnd). This one pass, run+// UNGATED every few seconds and immediately after any setting change or tab close, makes the model+// match reality. It NEVER kills a process (that stays in the gated reclaim path); it only corrects+// pup's own bookkeeping and the taskbar identity, both cheap and idempotent.+let _reconcileBusy = false;+// v1.9.284: apply an ownerChip toggle to already-open pages immediately (off removes it; on adds it),+// so the user does not have to navigate for the change to show. New navigations are handled by the+// inject-time gate in attachTab.+async function applyOwnerChipSetting(on) {+ if (process.platform !== 'win32') return;+ // v1.9.344 (John: turning the chip off "Request timed out"): a single wedged/slow page (e.g. a heavy+ // 3D-viewer tab whose renderer is busy) used to hang the whole sweep because each page.evaluate was+ // awaited with no bound. Race every per-page evaluate against a short timeout so one stuck tab can+ // never block toggling the chip across every other window.+ const evalBounded = (page, fn, ...a) => Promise.race([+ page.evaluate(fn, ...a).catch(() => {}),+ new Promise(r => setTimeout(r, 1500)),+ ]);+ for (const [sid, s] of [...sessions]) {+ for (const t of (s.tabs || [])) {+ if (!t || !t.page) continue;+ try {+ if (!on) {+ await evalBounded(t.page, () => { try { const el = document.documentElement.querySelector('div[data-pup-owner]'); if (el) el.remove(); } catch (e) {} });+ } else {+ const thread = String(sid).replace(/^ai-/, '');+ const prof = s.profileName || 'adom-you';+ const avatar = pupSettings().identityAvatarB64 || '';+ const ident = pupSettings().identityName || '';+ await evalBounded(t.page, (thread2, profile, avatar2, ident2) => {+ try {+ const root = document.documentElement; if (!root || root.querySelector('div[data-pup-owner]')) return;+ const host = document.createElement('div'); host.setAttribute('data-pup-owner', '1');+ host.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483647;pointer-events:none;margin:0;padding:0;';+ const sh = host.attachShadow ? host.attachShadow({ mode: 'closed' }) : null;+ const lead = avatar2 ? '<img class="av" alt="" src="data:image/png;base64,' + avatar2 + '">' : '<span class="hx">โฌข</span>';+ const html = '<style>.c{display:flex;align-items:center;gap:6px;font:11px/1.35 "JetBrains Mono",ui-monospace,monospace;color:#7fe9e2;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);border-radius:13px;padding:2px 9px 2px 3px;white-space:nowrap;opacity:.92;user-select:none}.c b{color:#e6edf3;font-weight:600}.av{width:20px;height:20px;border-radius:50%;object-fit:cover;flex:0 0 auto;border:1px solid rgba(0,184,177,.55)}.hx{padding-left:5px}</style>'+ + '<div class="c" title="' + (ident2 ? ident2 + ' โ ' : '') + 'Adom pup">' + lead + '<span><b>' + thread2 + '</b> ยท ' + profile + '</span></div>';+ if (sh) sh.innerHTML = html; else host.innerHTML = html;+ root.appendChild(host);+ } catch (e) {}+ }, thread, prof, avatar, ident);+ }+ } catch (e) {}+ }+ }+}+// v2.0.236: four per-session Maps were written but never cleaned (_unstickLast, _unstickClickAt,+// _fgToastAt, _extEnabledFor) โ small, but they grew for the process lifetime and every future+// per-session map would repeat the mistake. One reaper, called from every teardown path.+function forgetSessionState(sid) {+ for (const m of [_unstickLast, _unstickClickAt, _fgToastAt, _extEnabledFor]) {+ try { m.delete(sid); } catch (e) {}+ }+}+// v2.0.236: this exact 6-line preamble ("resolve the session or 400") was pasted into six verb+// cases. One helper; identical semantics (no auto-recovery โ these are the verbs that must act on a+// window that is ALREADY live, and silently relaunching one would be a different verb).+function requireLiveSession(res, args) {+ const sessionId = args.sessionId || activeSessionId;+ if (!sessionId || !sessions.has(sessionId)) {+ sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+ return null;+ }+ return { sessionId, session: sessions.get(sessionId) };+}+// v2.0.239: /health, /status and the pup_status verb each hand-built the same core status fields+// (version, enum accounting, identity settings, session/active counts). The identical+// `settings: (() => {...})()` IIFE was pasted three times and the enum expression character for+// character. One builder; each surface spreads it and adds only what is genuinely its own.+function statusCore() {+ const st = pupSettings();+ return {+ version: BRIDGE_VERSION,+ // enum_windows accounting, so "is pup spamming ab?" is answered with a number, not an argument.+ // lastMin counts REAL ab calls in the last 60s (cache hits and coalesced callers are not counted).+ enumWindows: { total: _enumCalls, lastMin: (Date.now() - _enumWindow.at < 60000 ? _enumWindow.n : 0) },+ win32: { inProcess: win32.available(), why: win32.why() }, // v2.0.310: koffi loaded? (else PowerShell fallback)+ log: _logRing.slice(-120),+ loops: _loopRing.slice(-10), // #80: cadence kept separate so it cannot evict the events+ loops: loopReport(),+ win32Calls: win32.stats ? win32.stats() : {},+ // the CURRENT identity mode, so a test harness asserts against what pup IS, not an assumption+ settings: {+ taskbarMode: st.aumidIcons !== 'on' ? 'plain' : (st.threadTiles === 'on' ? 'tiles' : 'aumid'),+ aumidIcons: st.aumidIcons, threadTiles: st.threadTiles,+ overlayBadges: st.overlayBadges, overlaySize: st.overlaySize, jumpLists: st.jumpLists,+ },+ iconAudit: _lastAudit ? { ok: _lastAudit.ok, at: _lastAudit.at, mode: _lastAudit.mode, findings: _lastAudit.findings.length, repaired: _lastAudit.repaired, reason: _lastAudit.reason } : null,+ sessionCount: sessions.size,+ activeSession: activeSessionId,+ };+}+// v2.0.239: the same 10-line C# raise helper was pasted into two script builders in the raise+// verb (and a third, already-deleted copy). One source of truth; callers append the invocation.+const PS_RAISE_TYPEDEF = [+ 'Add-Type @"',+ 'using System; using System.Runtime.InteropServices;',+ 'public class WinFocusH {',+ ' [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',+ ' [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);',+ ' [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',+ ' [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);',+ ' [DllImport("user32.dll")] public static extern int SetWindowLong(IntPtr h, int i, int v);',+ // background windows carry WS_EX_NOACTIVATE (they can never take focus). To actually SHOW one,+ // CLEAR that style first, then restore + foreground it.+ ' public static void Raise(IntPtr h){',+ ' int ex = GetWindowLong(h, -20); SetWindowLong(h, -20, ex & ~0x08000000);',+ ' if (IsIconic(h)) ShowWindow(h, 9); ShowWindow(h, 5); SetForegroundWindow(h);',+ ' }',+ '}',+ '"@',+];+// v2.0.240: the Chrome-style generic icons (grey globe / PDF sheet) were written out as SVG string+// literals in TWO different no-favicon paths โ the second was added because the first was missed,+// and the comment above it says so. One builder now, so the glyph a user sees cannot depend on+// which path produced it.+function genericIconSvg(isPdf) {+ const plate = '<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>';+ return isPdf+ ? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">' + plate+ + '<path d="M13 8 h15 l8 8 v24 a1 1 0 0 1-1 1 H14 a1 1 0 0 1-1-1 V9 a1 1 0 0 1 1-1 Z" fill="#ffffff" stroke="#9aa0a6" stroke-width="1.6"/>'+ + '<path d="M28 8 v8 h8 Z" fill="#dadce0"/>'+ + '<rect x="13" y="26" width="23" height="12" rx="2" fill="#e34f4f"/>'+ + '<text x="24.5" y="35.4" font-family="Segoe UI,Arial,sans-serif" font-size="9" font-weight="700" fill="#ffffff" text-anchor="middle">PDF</text></svg>'+ : '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">' + plate+ + '<circle cx="24" cy="24" r="13" fill="none" stroke="#5f6368" stroke-width="3"/>'+ + '<ellipse cx="24" cy="24" rx="5.6" ry="13" fill="none" stroke="#5f6368" stroke-width="2.6"/>'+ + '<path d="M11.4 24 H36.6" stroke="#5f6368" stroke-width="2.6"/>'+ + '<path d="M13.6 16.4 H34.4 M13.6 31.6 H34.4" stroke="#5f6368" stroke-width="2"/></svg>';+}+// v2.0.240: escaping a string for use inside a PowerShell -match pattern was written out twice as+// the same dense character class. One helper, so a fix to the escaping cannot land in only one scan.+function psRegexEscape(v) {+ return String(v).replace(/[\\.^$*+?()[\]{}|]/g, '\\$&');+}+// v2.0.242 (John, pup-bridge#58: "pup_alert_window fails after a tab switch changes the window+// title โ resolve by hwnd, not title"). The STRONGEST handle pup owns is the one it captured when it+// created the window, stored on the page as __pupHwnd. It survives title changes, tab switches and+// parking, and it needs no enumeration. Nothing consulted it outside the ownership proof, so a+// session whose cached _hwnd went missing fell straight through to a title lookup that cannot match+// while titleTag is off (its default). Order: cached handle, birth-captured handle, bounds probe.+function birthHwnd(session) {+ try {+ for (const t of (session && session.tabs) || []) {+ const h = t && t.page && Number(t.page.__pupHwnd);+ if (h) return h;+ }+ } catch (e) {}+ return null;+}+// Tag a confirmed (session, hwnd) with its durable "AdomPupSid=<sid>" window property, exactly ONCE+// per handle (dev-skills/pup-no-wiggle). The guard avoids spawning a SetProp PowerShell on every+// resolve โ it fires only when a handle is newly confirmed for a session, which is exactly when the+// durable tag must be (re)written so the window stays re-findable after any future handle loss.+function _ensureWindowTagged(session, sessionId, hwnd) {+ if (!session || !hwnd || !sessionId) return;+ if (Number(session._taggedHwnd) === Number(hwnd)) return;+ session._taggedHwnd = Number(hwnd);+ tagWindowSession(Number(hwnd), sessionId).then(ok => {+ if (ok) console.log(`[identity] "${sessionId}" tagged window ${hwnd} with ${PUP_WIN_PROP} (durable; survives crash/restart, re-found by property lookup)`);+ else session._taggedHwnd = null; // let a later resolve retry the tag+ }).catch(() => { session._taggedHwnd = null; });+}+async function bestSessionHwnd(session, sessionId) {+ if (!session) return null;+ if (session._hwnd) { _ensureWindowTagged(session, sessionId, Number(session._hwnd)); return Number(session._hwnd); }+ const b = birthHwnd(session);+ if (b) { session._hwnd = b; _ensureWindowTagged(session, sessionId, b); return b; }+ try { const r = await resolveSessionHwndByBounds(session, sessionId); if (r) return Number(r); } catch (e) {}+ return null;+}+async function reconcileModel(reason) {+ if (process.platform !== 'win32' || _reconcileBusy) return;+ _reconcileBusy = true;+ try {+ // (1) REAP any session whose windows are all gone. This is the aa.com fix: a window closed by+ // ANOTHER thread (or the user) leaves a session with only closed page handles. Unlike the old+ // reaper, we do NOT require the whole profile's Chrome to be dead โ one closed window on a live+ // shared profile is still a dead session, and its lingering badge/identity is the "stale icon".+ for (const [sid, s] of [...sessions]) {+ if (_launchInFlight.has(s.profileName)) continue;+ // v1.9.305: !_healGaveUp โ a session the healer is still trying to bring back (lost to sleep) is+ // NEVER reaped, even at 0 tabs, until the healer explicitly gives up. This is the reaper-vs-+ // recovery race fix (John lost his windows to sleep). rescanProfile keeps _disconnectedAt fresh.+ if (s._lostBrowser && !s._healGaveUp && s._disconnectedAt && (Date.now() - s._disconnectedAt < 120000)) continue; // heal window+ let anyOpen = false;+ for (const t of (s.tabs || [])) { try { if (t && t.page && !t.page.isClosed()) { anyOpen = true; break; } } catch (e) {} }+ if (anyOpen) continue;+ // v2.0.130 (#46 P0): the "no open windows" verdict above is CDP-DERIVED and it lied once โ+ // heavy churn (ghost adoption + takeover closes + respawns) left every page handle closed+ // while John's windows were open and visible; this reap then fed the janitor, which killed+ // the live Chrome mid-annotation. GROUND TRUTH before destruction: if the OS still shows+ // this session's window alive + visible, the verdict is wrong โ spare the session and try to+ // re-attach via rescan instead. Ground truth failing to find a window (or erroring) falls+ // through to today's reap, so a genuinely closed session still gets cleaned.+ let _osAlive = false;+ if (_isWin) {+ try {+ let h = s._hwnd ? Number(s._hwnd) : null;+ if (h && !(await osHwndAlive(h))) h = null;+ if (!h) { const h2 = await sessionHwnd(sid); if (h2 && (await osHwndAlive(h2))) h = h2; }+ if (h) _osAlive = true;+ if (_osAlive) {+ console.log(`[reconcile] SPARED session "${sid}" โ CDP shows no open pages but OS window ${h} is alive+visible (ground truth overrules, #46); attempting rescan re-attach`);+ const be = browsers.get(s.profileName);+ if (be && be.browser) { rescanProfile(be.browser, s.profileName, { adoptOrphans: false }).catch(() => {}); }+ }+ } catch (e) {}+ }+ if (_osAlive) continue;+ try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {}+ try { fs.unlinkSync(path.join(require('os').tmpdir(), 'pup-badge-debug', sid + '.png')); } catch (e) {}+ try { _tabFavCache && s.tabs && s.tabs.forEach(() => {}); } catch (e) {}+ sessions.delete(sid); forgetSessionState(sid);+ try { deleteSessionFile(sid); } catch (e) {}+ console.log(`[reconcile] reaped closed session "${sid}" (no open windows${reason ? ', ' + reason : ''}) โ removed its badge + taskbar identity`);+ }+ // (1b) ORPHAN BADGE SWEEP: a session removed via the close VERB (or the dashboard close button)+ // is deleted from the map before this reaper sees it, so its badge PNG would leak and keep being+ // served by /dashboard/badge (the stale aa.com icon). Sweep the debug dir and delete any+ // <sid>.png whose session no longer exists โ the one cleanup path that covers EVERY way a window+ // can close. base-<profile>.png (browser exe icons) are keyed differently and left alone.+ try {+ const dir = path.join(require('os').tmpdir(), 'pup-badge-debug');+ for (const f of fs.readdirSync(dir)) {+ if (!f.endsWith('.png') || f.startsWith('base-')) continue;+ const sid = f.slice(0, -4);+ if (!sessions.has(sid)) { try { fs.unlinkSync(path.join(dir, f)); console.log(`[reconcile] deleted orphaned badge for closed session "${sid}"`); } catch (e) {} }+ }+ } catch (e) {}+ // (1c) OWNER CHIP CONSISTENCY (John: "i can't see it anywhere"): the inject-time chip only+ // reaches pages navigated AFTER a deploy/toggle, so existing windows had none. Keep every live+ // page in sync with the setting here: add the chip where missing (on), remove it (off). Cheap+ // and idempotent (one querySelector per page), pointer-events:none so it never blocks anything.+ // v2.0.314 (audit): this block ran up to TWO page.evaluate per tab every 8s, forever, waking+ // throttled renderers to re-assert settings that change only when a human flips a toggle. It+ // now runs on the first tick after a settings change and then once a minute as a safety net.+ const _enfSig = [pupSettings().ownerChip, pupSettings().titleTag, pupSettings().titlePrefix].join('|');+ const _enfDue = (_enfSig !== reconcileModel._enfSig) || (Date.now() - (reconcileModel._enfAt || 0) > 60000);+ if (_enfDue) { reconcileModel._enfSig = _enfSig; reconcileModel._enfAt = Date.now(); }+ if (_enfDue) try {+ const chipOn = pupSettings().ownerChip === 'on';+ const _tagOn = pupSettings().titleTag === 'on';+ const _prefixOn = pupSettings().titlePrefix === 'on';+ // v1.9.286: bring EXISTING windows to the current title settings (new navs are handled by the+ // injector/setTitleDecor gates). Strips the (ai-thread: X) suffix when titleTag is off, and+ // safely removes ONLY pup's own known decor prefix when titlePrefix is off (never a page's+ // real " ยท " title), disconnecting the decor observer so it cannot re-add it.+ if (!_tagOn || !_prefixOn) {+ for (const [sid, s2] of [...sessions]) {+ if (s2._resolvingHwnd) continue; // v1.9.288: don't strip a transient resolve tag+ for (const t of (s2.tabs || [])) {+ if (!t || !t.page) continue;+ try {+ const esc = sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');+ await t.page.evaluate((tagOn, prefixOn, escSid) => {+ try {+ if (!tagOn) {+ // disconnect the stripping observer on existing pages so it stops fighting the+ // transient resolution tag (the dup-hwnd cause); strip any baked tag once.+ try { if (window.__pupTagObs) { window.__pupTagObs.disconnect(); window.__pupTagObs = null; } } catch (e) {}+ const re = new RegExp('\\s*\\((?:session|ai-thread): ' + escSid + '(?:[^)]*)\\)', 'g'); const c = String(document.title || '').replace(re, ''); if (c !== document.title) document.title = c;+ }+ if (!prefixOn) {+ try { if (window.__pupDecorObs) { window.__pupDecorObs.disconnect(); window.__pupDecorObs = null; } } catch (e) {}+ const tag = window.__pupThreadTag;+ let out = String(document.title || '');+ const pull = () => {+ if (tag && out.indexOf(tag + ' \u00b7 ') === 0) { out = out.slice(tag.length + 3); return true; }+ const m = out.match(/^(?:\u25CF Logged in|\u25CB Public|\uD83D\uDD13 Logged in|\uD83D\uDCD6 Public) \u00b7 /);+ if (m) { out = out.slice(m[0].length); return true; }+ return false;+ };+ for (let i = 0; i < 4 && pull(); i++) {}+ window.__pupThreadTag = null; window.__pupWikiLabel = null;+ if (out !== document.title) document.title = out;+ }+ } catch (e) {}+ }, _tagOn, _prefixOn, esc);+ } catch (e) {}+ }+ }+ }+ for (const [sid, s] of [...sessions]) {+ for (const t of (s.tabs || [])) {+ if (!t || !t.page) continue;+ try {+ if (!chipOn) { await t.page.evaluate(() => { try { const e = document.documentElement.querySelector('div[data-pup-owner]'); if (e) e.remove(); } catch (x) {} }); continue; }+ const thread = String(sid).replace(/^ai-/, ''); const prof = s.profileName || 'adom-you';+ await t.page.evaluate((thread2, profile) => {+ try {+ const root = document.documentElement; if (!root || root.querySelector('div[data-pup-owner]')) return;+ const host = document.createElement('div'); host.setAttribute('data-pup-owner', '1');+ host.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483647;pointer-events:none;margin:0;padding:0;';+ const sh = host.attachShadow ? host.attachShadow({ mode: 'closed' }) : null;+ const html = '<style>.c{font:11px/1.35 "JetBrains Mono",ui-monospace,monospace;color:#7fe9e2;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);border-radius:6px;padding:2px 8px;white-space:nowrap;opacity:.9;user-select:none}.c b{color:#e6edf3;font-weight:600}</style>'+ + '<div class="c">โฌข <b>' + thread2 + '</b> ยท ' + profile + '</div>';+ if (sh) sh.innerHTML = html; else host.innerHTML = html;+ root.appendChild(host);+ } catch (x) {}+ }, thread, prof);+ } catch (e) {}+ }+ }+ } catch (e) {}+ // (1d) DUPLICATE-HWND SELF-HEAL (John caught two windows sharing one handle): a stale cache can+ // pin a session to another same-profile window's hwnd, and per-profile validation cannot tell+ // two windows of one process apart. So detect any hwnd claimed by >1 live session and clear the+ // cache on ALL of them; the next resolve is now reliable (transient tag, no fighting observer),+ // so each re-binds to its own unique window. Cheap: one pass over the in-memory sessions.+ try {+ const byHwnd = new Map();+ for (const [sid, s2] of sessions) { const h = s2 && s2._hwnd; if (!h) continue; if (!byHwnd.has(h)) byHwnd.set(h, []); byHwnd.get(h).push(sid); }+ for (const [h, sids] of byHwnd) {+ if (sids.length < 2) continue;+ for (const sid of sids) { const s2 = sessions.get(sid); if (s2) { s2._hwnd = null; s2._appOverlay = false; s2._overlayEnforceAt = 0; } }+ console.log(`[reconcile] hwnd ${h} was claimed by ${sids.length} sessions (${sids.join(', ')}) โ cleared all to force unique re-resolve`);+ }+ } catch (e) {}+ // (2) STAMP CONSISTENCY: make every live window's taskbar identity match the CURRENT settings.+ // stampPupIdentity is idempotent (no-op unless the desired appId differs), so this both applies a+ // grouped<->split or style change to existing windows AND stamps any window that never got one.+ if (pupSettings().aumidIcons === 'on' && _identitySupported !== false) {+ for (const [sid, s] of [...sessions]) {+ if (!(s.tabs || []).length) continue;+ try { await stampPupIdentity(sid, {}); } catch (e) {}+ }+ }+ } finally { _reconcileBusy = false; }+}+async function reclaimUnmanagedWindows() {+ let reclaimed = 0;+ const handledByRescan = new Set();+ try {+ // v1.9.51 โ Part 0: a session whose CDP DISCONNECTED but whose Chrome window is still alive.+ // handleBrowserDisconnect deletes the browser entry and marks the session `_lostBrowser` with+ // ZERO tabs; nothing re-evaluated it, so it lingered as a zombie stamped ".plain" and fell back+ // to the OLD default pup icon (exactly the stale icon John flagged on the shotlog window). If its+ // Chrome is truly gone, drop the session AND unregister its AUMID so no stale taskbar identity is+ // left; if its Chrome is alive, Part B below reconnects and rescanProfile reattaches it by title.+ for (const [sid, s] of [...sessions]) {+ // v1.9.119: do NOT require _lostBrowser. That flag is only set when a DISCONNECT EVENT fires;+ // a Chrome killed outright (the bridge-restart tree kill) never emits one, so the session sat+ // with 0 tabs and no flag and was never dropped. Zero tabs plus a dead port IS a zombie,+ // however it got that way. (A session mid-launch has no tabs yet, so respect _launchInFlight.)+ // v1.9.120: "0 tabs" is NOT the test. Killing Chrome outright does not empty s.tabs โ the array+ // still holds now-dead page handles โ so my 1.9.119 gate skipped exactly the zombie it was meant+ // to catch (verified: killed a window's Chrome, the session stayed listed with tabCount 1).+ // The real test is whether every tab page is CLOSED/detached; that plus a dead CDP port below is+ // a definitive zombie. Conservative on purpose: if ANY tab still reports open we leave it alone,+ // because wrongly dropping a live session is far worse than a lingering dead one.+ if (_launchInFlight.has(s.profileName)) continue;+ // v1.9.172 (crash-heal race fix): do NOT reap a session the crash self-heal is still trying to+ // recover. handleBrowserDisconnect marks a crashed session `_lostBrowser` + stamps+ // `_disconnectedAt`; the health/heal loop then relaunches it. If reclaim dropped it (and deleted+ // its file) first, the heal failed "session not found" (seen live under stress). Give the heal a+ // protected 2-minute window; after that, if it still hasn't come back, reclaim reaps it as before.+ if (s._lostBrowser && s._disconnectedAt && (Date.now() - s._disconnectedAt < 120000)) continue;+ let anyTabOpen = false;+ for (const t of (s.tabs || [])) {+ try { if (t && t.page && !t.page.isClosed()) { anyTabOpen = true; break; } } catch (e) { /* detached = closed */ }+ }+ if (anyTabOpen) continue;+ let port = null;+ try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, s.profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {}+ const alive = port && await fetch(`http://127.0.0.1:${port}/json/version`).then(r => r && r.ok).catch(() => false);+ if (alive) continue; // Part B will reconnect + rescanProfile reattaches this same session+ recordCleanup(`dropped zombie session "${sid}" (0 tabs, its Chrome was gone) and unregistered its taskbar identity โ it had been showing up in pup_list_windows for every thread`);+ try { if (s._curAppId && s._curAppId !== PUP_APP_ID) await unregisterPupAumid(s._curAppId); } catch (e) {}+ sessions.delete(sid); forgetSessionState(sid);+ try { deleteSessionFile(sid); } catch (e) {}+ }++ // Part B: pup profiles whose Chrome is alive but that pup is not connected to. Covers a fully+ // orphaned profile AND a session whose CDP dropped (browser entry deleted on disconnect).+ let profileDirs = [];+ try { profileDirs = fs.readdirSync(PROFILES_DIR, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name); } catch (e) {}+ for (const profileName of profileDirs) {+ if (browsers.has(profileName)) continue;+ let port = null;+ try { port = parseInt(fs.readFileSync(path.join(PROFILES_DIR, profileName, 'DevToolsActivePort'), 'utf8').trim().split('\n')[0], 10) || null; } catch (e) {}+ if (!port) continue;+ const resp = await fetch(`http://127.0.0.1:${port}/json/version`).catch(() => null);+ if (!resp || !resp.ok) continue; // stale port file, Chrome is gone โ leave it to the reaper+ try {+ const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });+ browser._cdpPort = port;+ browsers.set(profileName, { browser, refCount: 0 });+ wirePopupTracking(browser, profileName);+ browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));+ console.log(`[reclaim] re-connected to profile "${profileName}" on port ${port} (Chrome outlived its CDP link)`);+ // v1.9.51: reattach by TITLE TAG. rescanProfile reuses the EXISTING "(session: <id>)" session+ // instead of minting a new "<profile>-winXXXX" (which would leave the tagged session a zombie+ // AND duplicate the window). This is what actually un-zombies shotlog and restores its icon.+ try {+ const st = await rescanProfile(browser, profileName, { adoptOrphans: true });+ handledByRescan.add(profileName);+ for (const [sid, s] of sessions) {+ if (s.profileName === profileName && !s._lostBrowser && (s.tabs || []).length) {+ s._lastCat = undefined; // force a real category re-evaluation+ refreshWindowChrome(s, sid).catch(() => {});+ }+ }+ if (st && (st.reattached || st.adopted)) reclaimed += (st.reattached + st.adopted);+ } catch (e) { console.log(`[reclaim] rescan of "${profileName}" threw: ${e.message}`); }+ } catch (e) { continue; }+ }++ // Part A: any page in a connected profile that no session owns.+ for (const [profileName, be] of [...browsers]) {+ if (handledByRescan.has(profileName)) continue; // rescanProfile already reconciled this one+ // v1.9.50: NEVER reclaim from a profile with a launch in flight. A launching window exists as+ // a page for a moment BEFORE its session is registered, so a sweep landing in that gap sees a+ // legitimate window as unowned and adopts it โ producing TWO sessions for ONE window (caught+ // by the audit as 8 sessions / 7 taskbar buttons, with "r4d" and "r4d-wina4rd" both pointing+ // at the same window). Reclaim is a janitor: when a launch is in progress the answer is simply+ // "not yet", never "adopt it".+ if (_launchInFlight.has(profileName)) continue;+ let pages = [];+ try { pages = await be.browser.pages(); } catch (e) { continue; }+ const owned = new Set();+ for (const s of sessions.values()) for (const t of (s.tabs || [])) owned.add(t.page);+ const loose = [];+ for (const p of pages) {+ if (owned.has(p)) continue;+ let u = ''; try { u = p.url(); } catch (e) { continue; }+ // about:blank is how a window is born; devtools/chrome/extension pages are not user+ // content. v2.0.138: the old regex (chrome:) did NOT match chrome-extension:, so pup's+ // own toolbar popup bubble got adopted as a ghost window on every click โ the source of+ // the find_window flood John caught in the Activity Log.+ if (isNonAdoptableUrl(u)) continue;+ loose.push(p);+ }+ if (!loose.length) continue;+ // Group by OS window so tabs of the SAME reclaimed window land in ONE session (not one each).+ const byWindow = new Map();+ for (const p of loose) {+ let wid = null;+ try { wid = await pageWindowId(p); } catch (e) {}+ const key = wid == null ? `nowin-${loose.indexOf(p)}` : `w${wid}`;+ if (!byWindow.has(key)) byWindow.set(key, []);+ byWindow.get(key).push(p);+ }+ for (const [key, group] of byWindow) {+ // v1.9.50: two-strike confirmation. Even outside a tracked launch there are brief windows+ // where a page is real but not yet owned (a tab being attached, a session being rebuilt).+ // Adopting on first sight turns any such blink into a duplicate session that can never be+ // reconciled. Require the SAME window to look unowned on two consecutive sweeps; anything+ // transient resolves itself in between and is never touched.+ // v1.9.212: refuse to adopt a window that a LIVE session already owns by hwnd. Without this+ // the janitor minted a new session every 90s for the same unresolvable PDF window, splitting+ // its tabs across sessions (which is why one painter saw only PDFs and reported no favicon).+ try {+ let _wh = null;+ try { _wh = await pageWindowId(group[0]); } catch (e) {}+ if (_wh != null) {+ let owned = false;+ for (const [, s2] of sessions) {+ for (const t2 of (s2.tabs || [])) {+ try { if ((await pageWindowId(t2.page)) === _wh) { owned = true; break; } } catch (e) {}+ }+ if (owned) break;+ }+ if (owned) { console.log(`[reclaim] skipping window ${key} โ a live session already owns this OS window (no duplicate session)`); continue; }+ }+ } catch (e) {}+ const strikeKey = `${profileName}:${key}`;+ const strikes = (_reclaimStrikes.get(strikeKey) || 0) + 1;+ if (strikes < 2) {+ _reclaimStrikes.set(strikeKey, strikes);+ console.log(`[reclaim] "${profileName}" window ${key} looks unowned (strike ${strikes}/2) โ waiting one more sweep before adopting`);+ continue;+ }+ _reclaimStrikes.delete(strikeKey);+ const newId = `${profileName}-win${Math.random().toString(36).slice(2, 6)}`;+ const ns = createSession(profileName);+ ns._sessionId = newId;+ // v2.0.355 (issue #79). NEVER write a sentinel into `owner`. `owner` is the AI-THREAD NAME+ // and tiles mode RENDERS it on the taskbar, so this line put a button on John's taskbar+ // labelled "reclaimed", wrapped as "recl"/"aime", after his laptop slept overnight. His+ // words: "it looks like some kind of weird 'reclaimed' pup window that pup created, which+ // is horrific." An internal placeholder was being displayed as if it were a person's thread.+ //+ // The absence of an owner is what the adopted window HAS, so record it as absence: null.+ // tileOwnerName() already handles that properly, deriving a stable readable name from the+ // sessionId, so the tile stays a name tile and never shows an internal word. How it was+ // adopted is diagnostic, so it goes in its own field where nothing renders it.+ ns.owner = null;+ ns._adoptedBy = 'reclaim';+ for (const p of group) attachTab(ns, newId, p);+ sessions.set(newId, ns);+ be.refCount++;+ let u = null; try { u = group[0].url(); } catch (e) {}+ // Title FIRST โ every downstream brand step resolves the window by its "(session: <id>)".+ try { await retagSessionTitle(group[0], newId, profileName); } catch (e) {}+ // v2.0.357 (issue #79, the half that outlives the sentinel): TAG THE OS WINDOW.+ // pup writes its identity property at BIRTH, and re-writes it on every successful resolve.+ // An ADOPTED window was never born under pup, so it carried no tag and nothing here added+ // one, which made it permanently unresolvable: "[hwnd] NOT resolved โ N windows on this+ // profile, no property tag". That is not cosmetic. hwnd resolution is what decides which+ // OS window a taskbar tile gets stamped onto, so an untagged window makes the NEXT window's+ // tile land on the wrong button. John saw a brand-new thread's window appear under the+ // adopted window's button and asked why his window was being put inside it. This is why.+ // The title we just wrote is unique at this instant, so it is the one moment the hwnd can+ // be recovered without guessing, and the tag then survives every later handle loss.+ try {+ const _mark = `${TITLE_LABEL}: ${newId}`;+ // v2.0.360: RETRY. 2.0.357 looked exactly once, immediately after writing the title, and+ // missed every time ("could not find its OS window by title" on all three adoptions in+ // one restart, found in the event log the moment #80's ring fix made events visible).+ // The DOM title is set synchronously by page.evaluate; the OS WINDOW title is updated by+ // Chrome a frame or two later, so a single in-process EnumWindows pass reliably runs+ // before the string it is searching for exists. Poll briefly instead of guessing a sleep.+ let _hit = null;+ for (let _i = 0; _i < 12 && !_hit; _i++) {+ _hit = win32.findChromeWindowByTitle(_mark) || null;+ if (!_hit) await new Promise(r => setTimeout(r, 60));+ }+ if (_hit && _hit.hwnd) {+ ns._hwnd = Number(_hit.hwnd);+ await tagWindowSession(_hit.hwnd, newId);+ console.log(`[reclaim] "${newId}": tagged OS window ${_hit.hwnd} so it stays resolvable (was untagged: adopted windows never went through birth)`);+ // #80: an adopted window is one pup found unowned. Whichever thread it USED to belong to+ // is not addressable any more, so the notice goes on the new session and the janitor+ // queue carries the broadcast, because the original owner cannot be reached by name.+ } else {+ console.log(`[reclaim] "${newId}": could not find its OS window by title โ it will stay untagged and unresolvable until a later resolve succeeds`);+ }+ } catch (e) { console.log(`[reclaim] "${newId}": tagging failed: ${e.message}`); }+ // #80: the notice is about the ADOPTION, not about whether tagging succeeded, so it is set+ // unconditionally. Tying it to the tag meant a failed tag also swallowed the explanation.+ noteSessionDisruption(ns, `pup adopted this window after a restart: it was running but no session owned it. Its original AI thread, if any, has lost its handle to it and must re-open. Nothing was closed.`);+ console.log(`[reclaim] UNMANAGED window on "${profileName}" (${group.length} tab(s)) adopted as "${newId}" โ ${(u || '').slice(0, 60)}`);+ refreshWindowChrome(ns, newId).catch(() => {});+ // v1.9.202: an ADOPTED window also needs its badge โ refreshWindowChrome is gated off under+ // minimalTouch, so without this a reclaimed window (John's "durable-win55fa", 4 PDF tabs)+ // stays permanently unbadged.+ setTimeout(() => { applyAppOverlay(ns, newId).catch(() => {}); }, 2500);+ // v1.9.70: a RECLAIMED window was never parked either. These are windows pup did not launch+ // (an openerless popup, or an orphan whose session died), so they arrive wherever the shell+ // put them โ usually on top. Background them like any pup-launched window.+ // v1.9.181: โฆUNLESS the user is LOOKING AT IT. Adoption often re-claims a window whose+ // session a crash dropped โ a window the user may be actively reading. Parking it then+ // yanks it to the bottom mid-use (John hit this repeatedly). If it is the OS-foreground+ // window, respect the user: mark it foreground instead of parking.+ (async () => {+ try {+ const fg = await osGetForegroundWindow();+ const hw = fg ? await cachedSessionHwnd(newId) : null;+ if (fg && hw && String(fg) === String(hw)) {+ ns._foreground = true; ns._userActiveAt = Date.now();+ try { saveSessionFile(newId, profileName, u); } catch (e) {}+ console.log(`[park] "${newId}" (reclaimed): SKIPPED โ it is the user's foreground window; marked foreground instead`);+ return;+ }+ const r = await parkSessionWindowDirect(group[0], newId);+ console.log(`[park] "${newId}" (reclaimed): ${r}`);+ } catch (e) {}+ })();+ try { saveSessionFile(newId, profileName, u); } catch (e) {}+ reclaimed++;+ }+ }+ } catch (e) { console.log(`[reclaim] threw: ${e.message}`); }+ if (reclaimed) {+ console.log(`[reclaim] adopted ${reclaimed} previously-unbranded window(s)`);+ // #80: the broadcast half. A thread whose session vanished cannot be told directly (its id is+ // gone), so the janitor queue is the only channel that reaches anyone at all.+ recordCleanup(`adopted ${reclaimed} window(s) that were running with no owning session, usually after a bridge upgrade. If a thread is missing tabs it opened, they were regrouped rather than closed โ re-open and they will stick.`);+ }+ return reclaimed;+}+// Drain the window-opened ring; a new window means SOME session may have lost a tab. Cheap: one+// drain call, then the CDP check only for multi-tab sessions.+async function drainWindowOpenedEvents() {+ if (process.platform !== 'win32' || _uiWatchSupported === false) return;+ await ensureWindowOpenedWatch();+ if (!_uiWatchId) { console.log('[drag] drain skipped: no watchId'); return; }+ try {+ const data = abPayload(await chrome.abCommand('desktop_ui_events', { sinceSeq: _uiWatchSeq, max: 200 }, { timeoutMs: 6000 }));+ if (!data) { console.log('[drag] drain: unparseable response'); return; }+ const evs = data.events || [];+ if (typeof data.nextSeq === 'number') _uiWatchSeq = data.nextSeq;+ // v2.0.187: any window-opened event means the desktop changed โ the long-TTL enum snapshot is+ // no longer trustworthy. Invalidate so the next consumer re-reads reality.+ if ((data.events || []).length) invalidateEnumCache();+ // v1.9.32: do NOT filter these events by window NAME. A window fires window-opened at CREATION,+ // before its title exists, so the dragged-out Chrome window arrived with an EMPTY name and the+ // old `/Chrome|Chromium/` filter threw away the one event that mattered (verified live: the+ // needed event was seq 32 with name ""). Any window-opened is enough of a hint; the follow-up+ // check is cheap (one getWindowForTarget per tab, multi-tab sessions only) and idempotent.+ if (!evs.length) return;+ // v1.9.42: THROTTLE + PRE-FILTER. A busy desktop fires window-opened constantly (tooltips,+ // menus, task switching): 1360 events across 10 drains in ~a minute, measured live. Running the+ // per-tab CDP check on every drain turned an "event-driven" design into exactly the wasteful+ // polling it was meant to avoid. Two cheap guards, in order:+ // 1. Nothing multi-tab? A single-tab window cannot be split, so there is nothing to detect.+ // 2. At most one check per THROTTLE_MS, no matter how many events arrive.+ // v1.9.47: a window-opened event may also mean an UNMANAGED window appeared (openerless popup),+ // which the splittable check below cannot see โ it only looks at tabs we already track.+ // v2.0.172: pup's OWN thread-tile button nudge (hide + re-show) fires window-opened, which used+ // to kick a reclaim sweep โ pup reacting to itself, and a burst of enum calls right when a+ // window opens. Ignore the ring while a nudge is in flight; a real new window is still caught by+ // the next drain or the 90s safety-net sweep.+ // v2.0.210 (busy-box hardening): 4s reclaim throttle meant a machine with several active AI+ // threads (constant window-opened events) ran an enum-backed reclaim every few seconds forever.+ // 30s bounds it at 2/min worst case; unmanaged-popup adoption tolerates that delay fine.+ const THROTTLE_RECLAIM = 30000;+ if (_nudgeUntil && Date.now() < _nudgeUntil) return;+ if (Date.now() - _lastReclaim > THROTTLE_RECLAIM) {+ _lastReclaim = Date.now();+ reclaimUnmanagedWindows().catch(() => {});+ }+ const splittable = [...sessions].filter(([, sess]) => (sess.tabs || []).length >= 2);+ if (!splittable.length) return;+ const THROTTLE_MS = 1500;+ const now = Date.now();+ if (now - _lastDragCheck < THROTTLE_MS) return;+ _lastDragCheck = now;+ console.log(`[drag] ${evs.length} event(s) -> checking ${splittable.length} multi-tab session(s)`);+ for (const [sid] of splittable) { await adoptDraggedOutTabs(sid); }+ } catch (e) {}+}+// v1.9.40: RE-TAG a page's title suffix to a NEW sessionId. An adopted (dragged-out) tab kept the+// title of the session it came FROM, so AD could never resolve the new window by+// "(session: <newId>)" โ its refresh silently failed (bare Chrome icon, stale/none overlay) and,+// worse, several windows all answered to the SAME "(session: hybrid)" string, so title-based+// operations landed on an arbitrary window (that is how a shotlog favicon ended up on a window that+// was not shotlog). Strips ANY existing pup suffix, installs the new one, and re-arms the observer.+async function retagSessionTitle(page, sessionId, profileName) {+ const suffix = profileName && profileName !== sessionId+ ? ` (${TITLE_LABEL}: ${sessionId} | profile: ${profileName})`+ : ` (${TITLE_LABEL}: ${sessionId})`;+ try {+ await page.evaluate((sfx, sid) => {+ // v1.9.151: window.name is the DURABLE session carrier โ it survives same-tab navigations+ // and sites never rewrite it, unlike document.title (ESPN's ticker destroyed the title tag+ // and the upgrade-restart recovery mis-bound docs-writer's session onto ship-watch's espn+ // window โ two sessions, one page). Title stays for AD's resolver; window.name is truth.+ try { window.name = 'pup:' + sid; } catch (e) {}+ // v1.9.43: strip EVERY "(session: ...)" occurrence, not just a trailing one. The old session's+ // observer had already re-appended its suffix, so a trailing-only strip missed and we stacked+ // two: "BMV080 (session: thr) (session: thr-split1-mkh7)". The window then still answered to+ // the PARENT's suffix, collided with it, and its own branding landed unreliably.+ 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 (John: "why do i still see you mangling the title?"): the VISIBLE suffix is DEAD.+ // window.name (above) is the durable session carrier; ownership is conveyed by the jump-list+ // "Owned by" task + pup_window_info card; hwnd resolution uses birth-capture/persisted handles.+ // Strip any legacy tag once, install NOTHING, observe NOTHING.+ const cleaned = strip(document.title);+ if (cleaned !== document.title) document.title = cleaned;+ }, suffix, sessionId);+ return true;+ } catch (e) { return false; }+}+// v1.9.36: refresh EVERYTHING a window's taskbar presence shows (identity icon, favicon overlay,+// jump list) and RETRY until the shell can actually see the window. John: "you can't be lazy about+// those overlay icons, always make sure they are as up to date as you can get them." A brand-new+// window (a drag-out, a relaunch) is not findable by title for a beat, so a single fire-and-forget+// refresh silently no-ops and leaves a STALE overlay: after a drag-out the remaining window kept+// showing the dragged site's favicon even though that tab was gone.+async function refreshWindowChrome(session, sessionId, tries = 5) {+ if (minimalTouch()) return false; // v1.9.185 minimal-touch: no identity/badge re-stamps+ if (process.platform !== 'win32' || !session) return false;+ for (let i = 0; i < tries; i++) {+ let ok = false;+ try { ok = await brandPupWindow(sessionId); } catch (e) {}+ if (ok) {+ try { await updateTabCountBadge(session, sessionId); } catch (e) {}+ try { await updateWikiJumplist(session, sessionId); } catch (e) {}+ try { await updateThumbnailTooltip(session, sessionId); } catch (e) {}+ return true;+ }+ await new Promise(r => setTimeout(r, 600 * (i + 1)));+ }+ console.log(`[chrome] "${sessionId}" could not refresh taskbar presence (window never became findable)`);+ return false;+}+// v1.9.301 (John: "all the verbs that change tabs/windows/favicons, you just don't correctly+// trigger your overlay icon calculations"). The fix for drift-over-time: ONE centralized, debounced+// re-sync fired for EVERY mutating verb on an existing session (wired in the dispatch hook), instead+// of trusting each handler to remember. It clears the cached favicon pick so the NEW page's favicon+// is re-fetched, then calls the UNGATED applyAppOverlay (refreshWindowChrome is a no-op under+// minimalTouch, which is exactly why badges went stale). It does NOT clear the cached hwnd, so a+// routine verb never triggers the expensive width-wiggle โ the handle is validated inside+// applyAppOverlay. Debounced per session (~2s) so a burst of verbs collapses to one repaint.+function scheduleOverlayRefresh(sessionId) {+ try {+ if (!sessionId) return;+ const prev = _overlayRefreshTimers.get(sessionId);+ if (prev) clearTimeout(prev);+ const t = setTimeout(() => {+ _overlayRefreshTimers.delete(sessionId);+ const s = sessions.get(sessionId);+ if (!s || !(s.tabs || []).length) return;+ s._overlayFav = null; s._faviconRetried = false; s._noFavLogged = false;+ applyAppOverlay(s, sessionId).catch(() => {});+ }, 2000);+ if (t.unref) t.unref();+ _overlayRefreshTimers.set(sessionId, t);+ } catch (e) {}+}+// Brand a pup window: full identity on capable AD, overlay badge fallback on older AD.+async function brandPupWindow(sessionId) {+ if (minimalTouch()) return false; // v1.9.185: no taskbar identity/branding in minimal mode+ const stamped = await stampPupIdentity(sessionId);+ if (stamped) return true;+ // v1.9.52: if identity IS supported, a false here is a transient "not yet" (window not findable,+ // or category still pending) โ NOT a reason to paint the legacy badge, which would fight the+ // identity path. Only fall back to the badge on AD builds without identity support.+ if (_identitySupported === true) return false;+ return badgePupTaskbar(sessionId);+}+async function badgePupTaskbar(sessionId) {+ if (minimalTouch()) return false; // v1.9.190: minimal-touch โ no taskbar work at all+ if (process.platform !== 'win32') return false;+ const icon = pupBadgeB64();+ if (!icon) { console.log('[badge] no icon asset โ skipping'); return false; }+ // v1.8.58: CHECK the result and LOG failures. The old fire-and-forget swallowed everything โ+ // when AD rejected/failed the call (e.g. window title not yet resolvable), the badge just+ // silently never appeared and every later attempt was debounced away.+ try {+ const r = await chrome.abCommand('desktop_taskbar', {+ ...(await windowTarget(sessions.get(sessionId), sessionId)),+ overlay: { icon, tooltip: 'Adom pup โ driven from the cloud' },+ }, { timeoutMs: 6000 });+ const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || r));+ const failed = (r && r.error) || (raw && /"error"\s*:\s*"[^"]/.test(raw) && !/Taskbar updated/i.test(raw));+ if (failed) { console.log(`[badge] "${sessionId}" FAILED: ${(r && r.error) || raw.slice(0, 160)}`); return false; }+ console.log(`[badge] "${sessionId}" applied`);+ return true;+ } catch (e) { console.log(`[badge] "${sessionId}" threw: ${e.message}`); return false; }+}++// NOTE: taskbar badge/overlay is an AD-CORE capability (desktop_taskbar, AD โฅ1.8.152) โ the+// nbrowser_* extension uses it and AD does the cross-process work natively. pup must NOT+// reimplement it (an in-bridge ITaskbarList3::SetOverlayIcon is in-process-only and fails on+// the browser's out-of-process window). A pup-distinct badge (white Adom leaves) is an AD+// feature request on desktop_taskbar; pup will call that verb via direct-API once it exists.+++// v2.0.27: osBackgroundWatchdog DELETED โ zero callers (superseded by the park + re-assert+// doctrine in dev-skills/pup-user-foreground).+++// v2.0.27: osMinimizeToBackground DELETED โ zero callers, and minimize-based backgrounding is+// banned by the pup-user-foreground doctrine (background means BEHIND, never hidden).+++// v1.8.53: send ONE hwnd to the bottom of the z-order. This is the ONLY Win32 call left with+// no direct API (CDP has no z-order; AD has no send-to-back verb yet โ issue filed). Minimal+// PS: one P/Invoke signature, no window-finding, no pid guessing, hwnd comes from AD's native+// desktop_find_window. Everything else in the park is CDP/AD direct.+// v2.0.261: a window younger than this cannot have been raised by a human taskbar click, because+// its button did not exist yet. Inside the grace period, OS-foreground is attributed to CHROME+// (which self-activates a newly created on-screen window), never to the user. Generous enough to+// cover a slow cold start, short enough that a real click seconds later is still honored.+const BIRTH_GRACE_MS = 8000;+function inBirthGrace(sess) {+ return !!(sess && sess._bornAt && !sess._raiseGranted && Date.now() - sess._bornAt < BIRTH_GRACE_MS);+}+async function osBottomUnlessForeground(hwnd, force, sess) {+ // v2.0.27: NATIVE (was the last big PowerShell in the z-order path). Three ab calls at ~20ms each+ // replace one ~300ms runtime-compiled PS spawn, and the user-protection doctrine rides along:+ // - not force: an OS-foreground target is left alone ('foreground').+ // - force (Chrome self-raise override): a target that is OS-foreground AND was EVER user-raised+ // stays hands-off for AS LONG AS it remains foreground (John 2026-08-09: "why do you need so+ // long?" โ the 10-min flag is only the memory horizon for after the user LEAVES the window;+ // while they are on it, there is no clock). Fresh input (<5s) attributes a raise not yet+ // flagged. A self-raised window with idle hands and no user history still gets demoted.+ if (process.platform !== 'win32' || !hwnd) return 'error';+ try {+ const fg = abPayload(await chrome.abCommand('desktop_get_foreground', {}, { timeoutMs: 4000 }));+ if (fg && Number(fg.hwnd) === Number(hwnd)) {+ if (!force) return 'foreground';+ // v2.0.261 (John opened ti.com and it landed on top of his work, then pup refused to move it):+ // the checks below attribute an OS-foreground pup window to the USER โ `_userActiveAt`, or+ // simply "there was input in the last 5s". For a window that was just born, both are wrong:+ // Chrome self-activates a newly created ON-SCREEN window (the old off-screen birth hid this),+ // and John typing while pup opens a window is not John clicking that window's taskbar button,+ // which did not exist yet. The measured result was `[park] "ti-proof" (post-nav re-assert):+ // user-foreground โ hands off` on a window he had never touched. Inside the birth grace we+ // therefore bottom it, exactly as the launch park already does unconditionally.+ if (inBirthGrace(sess)) {+ console.log(`[park] "${sess._sessionId || '?'}" is foreground ${Math.round((Date.now() - sess._bornAt) / 1000)}s after birth โ CHROME self-activating a new window, not the user (its taskbar button did not exist yet). Bottoming it.`);+ } else {+ if (sess && sess._userActiveAt) { sess._userActiveAt = Date.now(); return 'user-foreground'; }+ const idle = await osIdleMs();+ if (idle != null && idle < 5000) { if (sess) sess._userActiveAt = Date.now(); return 'user-foreground'; }+ }+ }+ const d = abPayload(await chrome.abCommand('desktop_set_window_state', { hwnd: Number(hwnd), state: 'bottom', force: true }, { timeoutMs: 5000 }));+ return (d && (d.sentToBack || d.ok)) ? 'bottomed' : 'error';+ } catch (e) { return 'error'; }+}++// v1.8.53: THE park โ direct APIs end to end (John: "why powershell? that's heavy. why aren't+// you just calling direct api's?"). No EnumWindows, no pid guessing, no focus handback (with+// --no-startup-window nothing can steal focus), no wait loop (called AFTER newPage resolved, so+// the window exists โ the await IS the event). Steps:+// 1. Escape hatch: CDP `document.hasFocus()` โ can ONLY be true if the USER clicked the+// taskbar during the launch gap (Chrome can't self-focus anymore) โ move on-screen, leave+// them on it, report 'user-foreground'.+// 2. Z-bottom while still OFF-SCREEN (invisible): hwnd via AD desktop_find_window (native,+// title-suffix match), then osBottomUnlessForeground (the one residual Win32 call,+// which ALSO reads GetForegroundWindow โ the only honest user-click signal).+// 3. Re-check focus (user may have clicked mid-park) โ if so, on-screen + AD bring_to_front.+// 4. CDP Browser.setWindowBounds โ on-screen full-size. CDP addresses the EXACT window by+// windowId, so the catch-2 failure mode (parked the wrong window) is impossible.+// 5. Verify POSITION via CDP getWindowBounds (catch-2's park "verified" by focus alone and+// lied) โ 'backgrounded' | 'park-failed'.+// โโ HIGH-FPS LEASES + WATCHDOG (v1.9.76) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ+//+// A highFps window has Chrome's four power-saving mechanisms DISABLED, so it renders at+// full rate forever even when occluded. That is exactly what a background recording needs+// and exactly what flattened a user's battery when it outlived its purpose (issue #14).+// So a highFps window is never open-ended: it holds a LEASE, and a watchdog downgrades it+// when the lease lapses.+//+// The three activity signals, in priority order:+// 1. AN ACTIVE RECORDING IS AUTHORITATIVE. If a recording we manage is running on the+// session, the lease CANNOT lapse. This is the "never kill someone mid-video"+// guarantee, and it is knowable (not guessed) because recording runs through our verbs.+// 2. VERB ACTIVITY extends the lease โ someone is demonstrably using the window.+// 3. An explicit pup_highfps_extend renews it, so an AI that knows it needs longer+// can negotiate instead of being surprised.+//+// On lapse we DOWNGRADE (relaunch the same session, same tabs, without the flags) rather+// than close: the window may hold the user's content, and the goal is to stop burning power,+// not to destroy work.+const HIGHFPS_DEFAULT_MINUTES = 20; // long enough for a real demo take, short enough that+ // an abandoned window cannot cost a battery overnight+const HIGHFPS_MAX_MINUTES = 120; // hard ceiling; ask again rather than lease a whole day+const HIGHFPS_WARN_BEFORE_MS = 2 * 60 * 1000;+const _highFps = new Map(); // sessionId -> { expiresAt, minutes, warned, grantedAt }++function highFpsGrant(sessionId, minutes) {+ const m = Math.max(1, Math.min(HIGHFPS_MAX_MINUTES, Number(minutes) || HIGHFPS_DEFAULT_MINUTES));+ const rec = { minutes: m, grantedAt: Date.now(), expiresAt: Date.now() + m * 60000, warned: false };+ _highFps.set(sessionId, rec);+ console.log(`[highfps] "${sessionId}" lease granted: ${m} min (expires ${new Date(rec.expiresAt).toISOString()})`);+ return rec;+}+function highFpsExtend(sessionId, minutes) {+ const cur = _highFps.get(sessionId);+ if (!cur) return null;+ const m = Math.max(1, Math.min(HIGHFPS_MAX_MINUTES, Number(minutes) || HIGHFPS_DEFAULT_MINUTES));+ cur.expiresAt = Date.now() + m * 60000; cur.minutes = m; cur.warned = false;+ console.log(`[highfps] "${sessionId}" lease extended: +${m} min`);+ return cur;+}+function highFpsInfo(sessionId) {+ const r = _highFps.get(sessionId);+ if (!r) return null;+ return { active: true, minutes: r.minutes, expiresAt: new Date(r.expiresAt).toISOString(),+ msRemaining: Math.max(0, r.expiresAt - Date.now()), recordingActive: isRecordingActive(sessionId) };+}+// Is a recording we manage currently running against this session? Authoritative, not a guess.+function isRecordingActive(sessionId) {+ // TAB recording: tabRecordStatusImpl is the authoritative per-session view (it already+ // filters out recordings that are mid-stop). Anything it returns means frames are being+ // pulled from this window right now.+ try {+ const active = tabRecordStatusImpl({ sessionId });+ if (Array.isArray(active) ? active.length : (active && active.length)) return true;+ } catch (e) {}+ // DESKTOP recording: one global recorder; .current is set only while a take is running.+ try { if (desktopRecorder && desktopRecorder.current) return true; } catch (e) {}+ return false;+}+// Drop a session back to Chrome's normal power behaviour by relaunching it WITHOUT the flags,+// carrying every tab across (same machinery the wiki view toggle uses).+async function highFpsDowngrade(sessionId, why) {+ const session = sessions.get(sessionId);+ _highFps.delete(sessionId);+ if (!session) return false;+ try {+ const urls = []; let activeIdx = 0;+ (session.tabs || []).forEach((t, i) => {+ try { const u = t.page.url(); if (u && u !== 'about:blank') { if (t.tabId === session.activeTabId) activeIdx = urls.length; urls.push(u); } } catch (e) {}+ });+ const owner = session.owner || null, profile = session.profileName;+ console.log(`[highfps] "${sessionId}" DOWNGRADING to normal power (${why}) โ relaunching ${urls.length} tab(s) without the high-fps flags`);+ await launchSession(sessionId, urls[activeIdx] || 'about:blank', { profile, owner, takeover: true, highFps: false });+ for (let i = 0; i < urls.length; i++) { if (i === activeIdx) continue; try { await addTabToSession(sessionId, urls[i]); } catch (e) {} }+ return true;+ } catch (e) { console.log(`[highfps] "${sessionId}" downgrade failed: ${e.message}`); return false; }+}+setInterval(trackedLoop('highfps-lease-janitor', 30000, () => {+ const now = Date.now();+ for (const [sid, rec] of [..._highFps]) {+ if (!sessions.has(sid)) { _highFps.delete(sid); continue; }+ if (isRecordingActive(sid)) {+ // Recording in flight: hold the lease open and keep pushing it out, so a long take can+ // never be cut off. Logged once per tick at most because the janitor collapses repeats.+ rec.expiresAt = Math.max(rec.expiresAt, now + 5 * 60000);+ continue;+ }+ const left = rec.expiresAt - now;+ if (left <= 0) { highFpsDowngrade(sid, `lease of ${rec.minutes} min lapsed with no active recording`).catch(() => {}); }+ else if (left <= HIGHFPS_WARN_BEFORE_MS && !rec.warned) {+ rec.warned = true;+ console.log(`[highfps] "${sid}" lease expires in ~${Math.ceil(left / 60000)} min โ call pup_highfps_extend {sessionId,minutes} to keep it, or start a recording (a running recording holds it open indefinitely)`);+ }+ }+}), 30000);++// v2.0.285 RENDER LEASE / focus-emulation blank-window fix: REVERTED. Emulation.setFocusEmulationEnabled+// (the lever it used to keep an occluded window's page painted) ACTIVATES the OS window โ it+// foregrounded a background pup window (John, 2026-08-22), reintroducing the exact focus-steal the+// whole off-screen-birth doctrine exists to prevent. And it did not reliably fix the blank thumbnail+// anyway. So it is gone. The blank-occluded-window problem is REAL and still open (dev-skills/+// pup-render-test), but the fix must NOT touch OS focus: candidates to investigate next are the+// browser-level launch flag --disable-backgrounding-occluded-windows (retains the compositor surface+// without emulating focus) and rendering ONLY on genuine reveal. Do NOT reintroduce focus-emulation.++async function parkSessionWindowDirect(page, sessionId) {+ // โโ macOS park โโ the whole background contract is one primitive: (re-)hide the+ // session's browser process. The window was born hidden (pre-window hide in+ // tryDetachedLaunch); here we size it full-screen via CDP while STILL hidden โ so a+ // later raise shows a properly-sized window โ then re-assert the hide and verify.+ // No z-order machinery: a hidden app has no on-screen windows to cover the user.+ if (process.platform === 'darwin') {+ try {+ // The session may not be registered in `sessions` yet at launch-park time โ resolve+ // the pid from the page's OWN browser (detached pid, else puppeteer's child process).+ let pid = null;+ try { const b = page.browser(); pid = b._detachedPid || (b.process() && b.process().pid) || null; } catch (e) {}+ if (!pid) { const session = sessions.get(sessionId); pid = session ? macSessionPid(session) : null; }+ try {+ const cdp2 = await page.target().createCDPSession();+ const { windowId } = await cdp2.send('Browser.getWindowForTarget');+ let s = null;+ for (let i = 0; i < 4 && !s; i++) {+ s = await page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight })).catch(() => null);+ if (s && !(s.w > 400 && s.h > 300)) s = null;+ if (!s) await new Promise(r2 => setTimeout(r2, 300));+ }+ await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+ if (s) await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(s.x + 10), top: Math.round(s.y + 10), width: Math.round(s.w - 20), height: Math.round(s.h - 20) } }); // v1.9.139: same 10px inset as Windows+ try { await cdp2.detach(); } catch (e) {}+ } catch (e) {}+ if (!pid) { console.log(`[park] "${sessionId}": no pid to hide (mac) โ park-failed`); return 'park-failed'; }+ const hidden = await macSetAppHiddenByPid(pid, true);+ if (!hidden) {+ console.log(`[park] "${sessionId}": mac hide failed (NSRunningApplication had no app for the pid; browser gone?) โ park-failed`);+ return 'park-failed';+ }+ return 'backgrounded';+ } catch (e) {+ console.error(`[park] ${sessionId} (mac): ${e.message}`);+ return 'error';+ }+ }+ if (process.platform !== 'win32') return null;+ let cdp = null;+ try {+ cdp = await page.target().createCDPSession();+ const { windowId } = await cdp.send('Browser.getWindowForTarget');+ // v1.8.66: PLACEMENT DOCTRINE (three shipped placements raised the window โ CDP maximize+ // = SW_MAXIMIZE raises; AD set_window_bounds raises despite restore:false; the on-screen+ // small stage was visible. ALL of it John caught live):+ // 1. Geometry via CDP plain bounds ONLY (left/top/width/height, normal state; CSS px from+ // the page itself). NEVER windowState:'maximized', NEVER AD set_window_bounds.+ // 2. Size is measured, not guessed: screen.availWidth/Height with retries (the one probe+ // failure shipped 1600ร900 once โ retry beats fallback).+ // 3. TRUST NOTHING: after placement, RE-BOTTOM and VERIFY THE Z-ORDER via AD+ // desktop_list_windows. If pup's window cannot be confirmed below the user's windows,+ // move it back OFF-SCREEN and report park-failed. A window that might be covering the+ // user NEVER stays on-screen.+ const cssScreen = async () => {+ for (let i = 0; i < 4; i++) {+ try {+ // v1.9.139 (John): availLeft/availTop too โ a taskbar docked LEFT/TOP shifts the work+ // area's origin, and assuming 0,0 would tuck the window under it.+ const s = await page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight }));+ if (s && s.w > 400 && s.h > 300) return s;+ } catch (e) {}+ await new Promise(r2 => setTimeout(r2, 300));+ }+ return null;+ };+ const place = (d) => cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(d.x + 10), top: Math.round(d.y + 10), width: Math.round(d.w - 20), height: Math.round(d.h - 20) } });+ const placeOnScreen = async () => {+ const s = await cssScreen();+ if (!s) return false;+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+ // v1.9.139 (John): 10px inset on ALL sides of the work area โ the visible signature that+ // placement is computed, not accidental. THIS is the real park geometry; the WinBgP/WinClr+ // PS sites I patched in 1.9.136-137 are flash/minimize paths, not this one.+ await place(s);+ // v1.9.342 (John: "the window was BIGGER than the screen, i couldn't scroll to the bottom / see+ // my content"). ROOT CAUSE: screen.avail* reflects whatever monitor the window is CURRENTLY on.+ // A freshly opened window is launched OFF-SCREEN and then parked, and during that sequence it can+ // momentarily sit on a DIFFERENT (larger) monitor โ so it gets SIZED for that monitor's work area+ // but LANDS on the smaller primary, overflowing off the bottom-right until the slow (120s)+ // placement sweep happens to re-park it. Close the race HERE: once the window is actually placed,+ // re-read screen.avail (now it reflects the DESTINATION monitor) and, if that monitor differs,+ // re-place to ITS work area. A window is never left overflowing its own screen, even for a moment.+ try {+ await new Promise(r2 => setTimeout(r2, 250));+ const s2 = await cssScreen();+ if (s2 && s2.w > 400 && s2.h > 300 && (s2.w !== s.w || s2.h !== s.h || s2.x !== s.x || s2.y !== s.y)) {+ await place(s2);+ console.log(`[park] "${sessionId}": destination monitor differs from park-time monitor (${s.w}x${s.h}@${s.x},${s.y} โ ${s2.w}x${s2.h}@${s2.x},${s2.y}) โ re-clamped to the real screen so no content is off-screen`);+ }+ } catch (e) {}+ return true;+ };+ // v2.0.258: the moveOffScreen() helper is GONE. Every caller has been converted to place the+ // window on-screen instead, because "park it off-screen until we are sure" is what left John+ // unable to open his own windows twice in one day. Do not reintroduce it: a helper whose whole+ // job is to move the user's window where they cannot click it is a loaded gun in this file.+ // Z-order truth: our window's z must be strictly below at least one non-pup window, and it+ // must not be topmost. Returns true only on CONFIRMED not-on-top.+ // v2.0.259: zConfirmedBelow is GONE. It asked ab's window enumeration to PROVE our window sat+ // below a non-pup window, and placement was gated on that proof. Two fatal properties: it+ // returned false when our hwnd was simply absent from the enumeration (which an off-screen+ // window often is, so the failure was self-sustaining), and there is no correct answer at all+ // when the user has no other windows open. Nothing may gate placement on a proof like that.+ // hwnd via AD (native) โ the title suffix "(session: <id>" is set by the welcome page.+ // v1.8.59 HARD RULES, learned the expensive way:+ // 1. The window is NEVER moved on-screen until z-bottom is CONFIRMED ('bottomed'). A+ // lagged hwnd lookup once fell through and placed an un-bottomed window ON TOP of the+ // user's work (the nxp.com pop). Invisible off-screen ('park-failed' + self-heal)+ // beats covering the user's screen.+ // 2. The launch park bottoms UNCONDITIONALLY (force=true) โ there is NO user-click escape+ // hatch here. "Pup window is foreground at park time" has too many non-user causes:+ // Windows re-assigns the foreground to a brand-new window when the previous foreground+ // window was just destroyed (e.g. a bridge-update restart killing pup windows โ that+ // forced another thread's fresh window onto John's screen), plus creation blips. The+ // false-positive cost is a forced window on the user's screen (the #1 forbidden+ // behavior); the false-negative cost is a user who clicked during the ~2s launch gap+ // clicking ONCE more after the park โ after which the window stays, since nothing+ // ever re-parks. Cheap. Do NOT reintroduce a foreground check here.+ // v1.8.61: z-bottom via AD-CORE `desktop_set_window_state {state:'bottom'}` (shipped in AD+ // 1.9.114 per pup's feature request) โ native, background-drive, resolves the window by+ // title itself, and returns {sentToBack, wasForeground}. This deletes pup's LAST PowerShell+ // from the open path; window management is now 100% direct API. Legacy fallback (older AD+ // that rejects state:'bottom'): the old find_window + osBottomUnlessForeground PS pair.+ const adBottom = async () => {+ try {+ // v1.8.63: force:true (AD โฅ1.9.115) โ bottom even if the window is currently foreground.+ // At launch, foreground is NOT a user signal (post-restart reassignment), so pup always+ // forces. On AD 1.9.114 the arg is ignored (skip-on-foreground) and the wasForeground+ // fallback below covers it; that fallback auto-obsoletes as desktops update.+ // v2.0.237: only ask the shell to re-bottom when we hold a real handle. Without one this+ // used to send a titleContains that cannot match (titleTag is off by default), and ab's+ // "No visible window with title containing (session: X)" surfaced as a FAILED pup_lower โ+ // even though the CDP placement above had already parked the window correctly.+ const _hw = await windowHwnd(sessions.get(sessionId), sessionId);+ if (!_hw) {+ if (!_parkNoHwndLogged.has(sessionId)) {+ _parkNoHwndLogged.add(sessionId);+ console.log(`[park] "${sessionId}" no window handle resolved โ CDP placement stands as the park; skipping the shell re-bottom (a title lookup cannot match with titleTag off).`);+ }+ return 'backgrounded';+ }+ const r = await chrome.abCommand('desktop_set_window_state', { hwnd: _hw, state: 'bottom', force: true }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});+ if (r && r.success === false) return { ok: false, err: r.error || 'error' };+ const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+ const o = raw ? JSON.parse(raw) : {};+ const data = o.data || o;+ return { ok: !!data.sentToBack, wasForeground: !!data.wasForeground, hwnd: data.hwnd || null, err: data.sentToBack ? null : 'not sent to back' };+ } catch (e) { return { ok: false, err: e.message }; }+ };+ // โโ v2.0.259: PARK IS NO LONGER A PLACEMENT GATE (John's rules R1-R3) โโโโโโโโโโโโโโโโโโโโโโ+ // The window is BORN at its final on-screen rect, so by the time we get here it is already+ // somewhere the user can click. Park's only remaining job is to ASSERT the background preference.+ // What changed, and why:+ // * Placement no longer waits on proving z-bottom. The old order (bottom -> prove -> only then+ // move on-screen) left the window at -32000 for up to 42 SECONDS (8 attempts x a 5s ab+ // timeout + sleeps) with a live taskbar button. John accepted a "~2s launch gap"; he never+ // agreed to that. R3 makes a working taskbar click a requirement, not a trade.+ // * Bottoming is best-effort and BOUNDED (3 quick attempts, not 8 slow ones). If it fails and+ // the window ends up foreground, R2 says accept it: "just allow that flaw to exist." We log+ // it and stop. Fighting it is what produced every re-park loop, and each one eventually+ // yanked a real user click.+ // * NOTHING here may move, hide, minimize or off-screen the window as a consequence of a+ // failed assertion. That is the whole lesson.+ let bottomed = false, lastErr = 'error', parkHwnd = null;+ for (let attempt = 0; attempt < 3; attempt++) {+ const z = await adBottom();+ if (z.ok) { bottomed = true; parkHwnd = z.hwnd; break; }+ lastErr = z.err || 'error';+ if (z.wasForeground) {+ // The legacy PS path still matters on older ab (state:'bottom' absent, or force ignored).+ let hwnd = null;+ try { hwnd = await windowHwnd(sessions.get(sessionId), sessionId); } catch (e) {}+ if (hwnd) {+ const zr = await osBottomUnlessForeground(hwnd, true, sessions.get(sessionId));+ if (zr === 'bottomed') { bottomed = true; parkHwnd = hwnd; break; }+ if (zr === 'user-foreground') {+ const _us = sessions.get(sessionId); if (_us) _us._userActiveAt = Date.now();+ console.log(`[park] "${sessionId}": user has this window FOREGROUND (recent input) โ leaving it exactly where they put it`);+ return 'user-foreground';+ }+ }+ }+ await new Promise(r2 => setTimeout(r2, 300));+ }+ // Whatever happened above, make sure the geometry is right. This never gates on anything and it+ // never moves the window somewhere unreachable โ worst case it is a no-op on an already-correct+ // window (the normal case now, since birth placed it).+ try { await placeOnScreen(); } catch (e) {}+ if (!parkHwnd) { try { parkHwnd = await windowHwnd(sessions.get(sessionId), sessionId); } catch (e) {} }+ if (parkHwnd) { try { const _s = sessions.get(sessionId); if (_s) { _s._hwnd = Number(parkHwnd); persistSessionHwnd(sessionId, Number(parkHwnd)); } } catch (e) {} }+ if (!bottomed) {+ // R2: background is the PREFERENCE, not a precondition. Say so plainly and move on.+ console.log(`[park] "${sessionId}": could not confirm z-bottom (${lastErr}) โ the window is ON SCREEN and clickable, which is the requirement; background is only the preference. Not fighting it (John: "just allow that flaw to exist").`);+ return 'park-unconfirmed';+ }+ return 'backgrounded';+ } catch (e) {+ console.error(`[park] ${sessionId}: ${e.message}`);+ return 'error';+ } finally {+ if (cdp) { try { await cdp.detach(); } catch (e) {} }+ }+}++// v1.8.67: re-assert z-bottom after a navigation settles. The OPEN's first cross-origin nav+// (file:// welcome โ https real site) swaps renderer processes and Chromium re-shows the window+// at first paint โ RAISING it โ and heavy sites paint AFTER the park's z-confirm passed (the w3+// batch sat on top with honest 'backgrounded' verdicts; a later same-window navigate does NOT+// raise โ tested). force:false so AD skips a window the user is actively holding foreground.+async function reassertBottomAfterNav(sessionId) {+ // v1.9.255 (John): arm the NO-FAULT WATCH - see the fgWatch interval. Caption-only, never a knockback.+ try { const _gs = sessions.get(sessionId); if (_gs && !_gs._foreground) _gs._fgWatchUntil = Date.now() + 20000; } catch (e) {}+ if (process.platform !== 'win32' || !sessionId) return;+ // v1.9.201 (John's PDF window came to the front with NO ai requesting it): this used ONLY AD's+ // desktop_set_window_state โ a verb that is NOT present on every desktop (the Bills thread hit+ // exactly that: "desktop_set_window_state is not in this desktop's verb set"). Where it is absent+ // this whole re-assert silently no-opped, so anything CHROME foregrounded on its own stayed on the+ // user's screen. Local PDFs are the proven trigger: Chrome's PDF viewer activates the window on+ // load. Do it IN-BRIDGE first (SetWindowPos to z-bottom, which never depends on an AD verb and+ // respects a user-foregrounded window), and keep the AD verb only as a secondary attempt.+ const sess = sessions.get(sessionId);+ if (sess && userIsUsingWindow(sess)) return; // never yank a window the user chose to have up+ let bottomed = false;+ try {+ const hwnd = await cachedSessionHwnd(sessionId);+ const zr = hwnd ? await osBottomUnlessForeground(hwnd, true, sessions.get(sessionId)) : 'error';+ if (zr === 'bottomed') bottomed = true;+ // v2.0.26 (John: "you took it back to the bg... that fights with the user"): a re-assert that+ // finds the window user-foregrounded (recent input) STOPS โ no retry, no ab fallback โ and+ // marks the session so every later demotion timer skips it too (userIsUsingWindow, 10 min).+ if (zr === 'user-foreground') {+ if (sess) sess._userActiveAt = Date.now();+ console.log(`[park] "${sessionId}" (post-nav re-assert): user-foreground โ hands off`);+ return;+ }+ } catch (e) {}+ if (bottomed) { console.log(`[park] "${sessionId}" (post-nav re-assert): bottomed`); return; }+ // v1.9.202 (John's log audit found `park-failed` on two windows and pup just moved on): a+ // park-failed window is sitting WHEREVER CHROME LEFT IT โ i.e. possibly on the user's screen, which+ // is the one outcome park exists to prevent. Retry once after a beat before falling back to AD.+ try {+ await new Promise(r => setTimeout(r, 1200));+ const hwnd2 = await cachedSessionHwnd(sessionId);+ const zr2 = hwnd2 ? await osBottomUnlessForeground(hwnd2, true, sessions.get(sessionId)) : 'error';+ if (zr2 === 'bottomed') {+ console.log(`[park] "${sessionId}" (post-nav re-assert, retry): bottomed`);+ return;+ }+ if (zr2 === 'user-foreground') {+ const _us2 = sessions.get(sessionId); if (_us2) _us2._userActiveAt = Date.now();+ console.log(`[park] "${sessionId}" (post-nav re-assert, retry): user-foreground โ hands off`);+ return;+ }+ } catch (e) {}+ 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.57: SELF-HEAL a stranded window. A window created under a build whose park failed (or+// any future placement bug) sits at its off-screen launch coords forever: its taskbar button+// "activates" an invisible window and the badge is gone โ exactly what happened to the+// adom-shotlog app's long-lived shared window. pup now detects and repairs this at every+// natural touchpoint (bridge-restart reattach, open_tab, switch_tab): cheap CDP bounds check;+// only if actually off-screen, re-run the standard park (which respects a user-foreground+// window) and re-apply the badge. Healthy windows are never touched.+async function healOffscreenWindow(session, sessionId) {+ if (process.platform !== 'win32') return null;+ try {+ const tab = session && session.tabs && session.tabs[0];+ const page = tab && tab.page;+ if (!page) return null;+ const cdp = await page.target().createCDPSession();+ let b = null;+ try {+ const { windowId } = await cdp.send('Browser.getWindowForTarget');+ const gb = await cdp.send('Browser.getWindowBounds', { windowId });+ b = gb && gb.bounds;+ } finally { try { await cdp.detach(); } catch (e) {} }+ if (!b || (b.left > -1000 && b.top > -1000)) return null; // on-screen โ healthy, hands off (v1.8.64: catches the -1310 staging spot too)+ console.log(`[heal] "${sessionId}" window stranded off-screen (${b.left},${b.top}) โ re-parking + re-badging`);+ const r = await parkSessionWindowDirect(page, sessionId);+ brandPupWindow(sessionId).catch(() => {});+ return r;+ } catch (e) { return null; }+}++// v1.8.45: TRULY clear a window's taskbar attention (the orange/pink Win11 "flash" tint).+// FLASHW_STOP only stops the PULSE โ the tint persists until the window is ACTIVATED. So we+// clear it the only way Windows allows: activate the window. To avoid a visible pop, we move+// it OFF-SCREEN first, activate it there (invisible โ clears the tint), move it back on-screen+// at z-bottom, then hand focus straight back to whatever the user was in (AttachThreadInput).+// Net effect: the flash clears, nothing pops, focus is never stolen. Returns "cleared" /+// "cleared-still-foreground" / "already-foreground" (user already had it) / "no-window".+async function osClearFlashByActivate(pid, cdpPort, hwnd) {+ // v2.0.27: native โ ab desktop_clear_window_attention does the whole tint-clear dance in+ // precompiled Rust (FLASHW_STOP + AttachThreadInput activate + focus handback, net-zero focus;+ // pixel-verified on ab#64 after pup's FLASHW_STOP-alone caveat). The ~60-line PS is gone.+ if (process.platform !== 'win32' || (!pid && !cdpPort && !hwnd)) return 'error';+ try {+ let target = Number(hwnd) || null;+ if (!target && pid) target = await largestChromeWindowOfPidTree(pid);+ if (!target) return 'no-window';+ const fg = abPayload(await chrome.abCommand('desktop_get_foreground', {}, { timeoutMs: 4000 }));+ if (fg && Number(fg.hwnd) === Number(target)) return 'already-foreground';+ const d = abPayload(await chrome.abCommand('desktop_clear_window_attention', { hwnd: Number(target) }, { timeoutMs: 8000 }));+ return (d && (d.cleared || d.ok)) ? 'cleared' : 'error';+ } catch (e) { return 'error'; }+}++// NOTE: localhost-URL reprimand (container-vs-desktop) is handled by AD CORE, which+// appends a container-topology hint to a failed response whose URL is localhost. pup+// doesn't duplicate it โ and because AD keys on the connection FAILURE, Hydrogen+// Desktop's working localhost URLs load fine and never trip it. (Enriching AD's hint+// with actionable alternatives is an adom/adom-bridge request.)++// v1.8.23: REAL taskbar flash (FlashWindowEx) for a session's window โ the orange+// nudge that tells the user "the agent touched this window" WITHOUT stealing focus.+// v1.8.24: a FINITE 4-blink pulse then stop (see Flash() below) โ NOT a blink-forever+// nag. Skips if the window is already foreground. Fire-and-forget (async exec) so it+// never adds latency to the driving verb.+// v1.8.43: pup's taskbar flash routes through AD-CORE desktop_flash_window (native ++// reliable, ONE mechanism for flash AND clear) instead of pup's own FlashWindowEx โ which+// duplicated an AD capability, and whose fire-and-forget FLASHW_STOP wouldn't actually+// clear the Win11 attention tint. Resolves the window by its "(session: <id>)" title suffix.+// {stop:true} clears it; otherwise a finite 4-count flash. Returns AD's real result so+// callers can report truthfully (no more cleared:true lies).+async function flashViaAD(sessionId, opts) {+ // v1.9.233 (John: "the flash all button isn't working"): the minimalTouch gate silently swallowed+ // USER-initiated flashes too. minimal-touch exists to stop pup's AUTOMATIC machinery from touching+ // the taskbar; a human clicking "flash" in the dashboard is the opposite of automatic. Callers that+ // carry explicit user intent pass {force:true} and go through; everything automatic stays gated.+ if (minimalTouch() && !(opts && opts.force)) return { _skipped: 'minimal-touch' };+ opts = opts || {};+ if (process.platform !== 'win32' || !sessionId) return null;+ // v2.0.289 (John): taskbarActivity governs the orange flash. When it is NOT 'flash' (default+ // 'progress', or 'off'), suppress AUTOMATIC flashes โ the determinate progress bar replaces them. A+ // STOP always runs (so a leftover flash can be cleared) and an explicit user-clicked flash+ // (opts.force) still goes through; only pup's own auto-flashes are gated.+ if (!opts.stop && !opts.force && taskbarActivityMode() !== 'flash') return { _skipped: 'taskbar-activity-not-flash' };+ // v1.9.242 (John: "i just hit 'clear flashes' and it didn't clear all your flashes"). Same disease+ // as the badge saga: targeting by TITLE tag silently no-ops on any window whose title can't carry+ // it (PDF viewer, retitled pages) โ and FlashWindowEx leaves the button highlighted until a STOP+ // reaches that exact HWND. So: resolve the hwnd (same resolver the badge paint uses), address by+ // hwnd with title as fallback only, and REMEMBER every hwnd we ever flashed so clear-all can sweep+ // them even if the session is gone or retitled by then.+ const session = sessions.get(sessionId);+ if (session && !session._hwnd) { try { await bestSessionHwnd(session, sessionId); } catch (e) {} }+ // v1.9.278: flash only a proven pup hwnd; else title match (a title tag can only exist on a pup+ // window). Never flash a bare recycled hwnd - same Edge-protection guarantee as the overlay paint.+ let _flashHwndOk = false;+ if (session && session._hwnd) { try { _flashHwndOk = await hwndBelongsToPup(session._hwnd, session); } catch (e) {} if (!_flashHwndOk) session._hwnd = null; }+ const tgt = _flashHwndOk ? { hwnd: session._hwnd } : { titleContains: `(${TITLE_LABEL}: ${sessionId}` };+ if (!opts.stop && session && session._hwnd) _flashedHwnds.add(session._hwnd);+ const args = opts.stop ? { ...tgt, stop: true } : { ...tgt, mode: 'count', count: 4 };+ try { return await chrome.abCommand('desktop_flash_window', args, { timeoutMs: 6000 }); }+ catch (e) { return null; }+}+const _flashedHwnds = new Set(); // every hwnd pup ever flashed this boot โ the clear-all sweep list+// v1.9.243 (John: "i just hit clear flashes again and it still didn't clear"). Per-boot bookkeeping+// was the hole: a bridge restart (every ship) empties the set while the taskbar keeps its orange, so+// clear-all had nothing to sweep. Bookkeeping across restarts is inherently fragile โ so DON'T rely+// on it: enumerate every visible top-level window of every pup-managed Chrome process (same PS enum+// the badge resolver uses) and stop them ALL. Filtered to pup's own browser PIDs so the user's+// personal Chrome is never touched.+// v2.0.27: the "which window carries this process's taskbar button" resolver, native. Largest+// visible real (>=80x60) Chrome frame owned by the pid or its direct chrome children.+async function largestChromeWindowOfPidTree(pid) {+ if (!pid) return null;+ const kids = new Set([Number(pid)]);+ try {+ const pd = abPayload(await chrome.abCommand('process_list', { nameFilter: 'chrome' }, { timeoutMs: 5000 }));+ for (const p of ((pd && pd.processes) || [])) if (Number(p.parentPid) === Number(pid)) kids.add(Number(p.pid));+ } catch (e) {}+ const rows = await enumChromeWidgetWindows();+ let best = null;+ for (const r of rows) {+ if (!kids.has(r.pid) || r.W < 80 || r.H < 60) continue;+ const area = r.W * r.H;+ if (!best || area > best.area) best = { h: r.h, area };+ }+ return best ? best.h : null;+}++async function enumAllPupChromeHwnds() {+ // v2.0.27: native. The >=80x60 size filter survives (v1.9.273: every Chrome process carries a+ // hidden helper window that reports visible with no real size โ the phantom "UNTRACKED" criers).+ if (process.platform !== 'win32') return [];+ const pupPids = new Set();+ for (const [, be] of browsers) {+ try { const pid = be && be.browser && (be.browser._detachedPid || (be.browser.process() && be.browser.process().pid)); if (pid) pupPids.add(Number(pid)); } catch (e) {}+ }+ if (!pupPids.size) return [];+ try {+ const rows = await enumChromeWidgetWindows();+ return rows.filter(r => pupPids.has(r.pid) && r.W >= 80 && r.H >= 60).map(r => r.h);+ } catch (e) { return []; }+}++// v1.8.23: verbs that MUTATE a session's visible state โ auto-flash the window so the+// user knows the agent changed it. evaluate counts UNLESS {readOnly:true}.+// v2.0.28 (#41 + John: "we moved to ai-thread name and dropped sessionId"): every verb that TARGETS+// one session. With sessionId omitted these resolve to the CALLER THREAD's own window (the same+// ai-<thread-slug> keying pup_open_window uses) โ never to activeSessionId, which is just whichever+// window was touched last, usually another thread's.+const SESSION_TARGETING_VERBS = new Set(['pup_progress', 'pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_open_tab', 'pup_switch_tab', 'pup_close_tab', 'pup_close_window', 'pup_eval', 'pup_evaluate', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_screenshot', 'pup_screenshot_full_res', 'pup_errors', 'pup_list_tabs', 'pup_wait', 'pup_alert_window', 'pup_focus_window', 'pup_raise_os_window', 'pup_lower_os_window', 'pup_window_info', 'pup_record_start', 'pup_record_stop', 'pup_wiki_set_view', 'pup_switch_window', 'pup_annotate', 'pup_floaty_click', 'pup_move_to_workspace', 'pup_fill_card']);+function threadDefaultSessionId(callerThread) {+ return 'ai-' + String(callerThread).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);+}+const MUTATING_VERBS = new Set(['pup_open_window', 'pup_open_tab', 'pup_navigate', 'pup_reload', 'pup_back', 'pup_forward', 'pup_switch_tab', 'pup_click', 'pup_type', 'pup_press_key', 'pup_input_dispatch', 'pup_eval', 'pup_evaluate']);++// Debounced auto-flash: one flash per window per ~5s burst of verbs, re-armed after+// quiet. {silent:true} on the verb OR session._flashSilent (session default) suppresses.+// v1.8.71 (the Aditya/Google-auth incident): pup is the ANONYMOUS SANDBOXED browser โ a user+// signing into a real account inside a pup window either gets lost on the next fresh window or+// (on a branded-browser fallback) triggers Chrome's profile-creation/consolidation machinery.+// When a caller opens a known auth/login URL, steer them to the right surface in the hint.+const AUTH_URL_RE = /accounts\.google\.com|login\.microsoftonline\.com|login\.live\.com|\.okta\.com|\.auth0\.com|github\.com\/login|signin\.aws\.amazon\.com|appleid\.apple\.com|login\.salesforce\.com|id\.atlassian\.com|auth\.openai\.com|\/o\/oauth2\/|\/oauth2?\/(v2\/)?auth(orize)?/i;+function authSteerHint(url) {+ if (!url || !AUTH_URL_RE.test(String(url))) return '';+ return ' ๐ AUTH URL DETECTED: pup is the ANONYMOUS SANDBOXED browser โ its profiles are disposable, so a login done here is easily lost (next fresh window = signed out), and it must not become the home of the user\'s identity. For signing the USER into their accounts prefer: (a) abe / nbrowser_* โ their REAL browser where sessions + password manager already live, or (b) AD-core desktop_open_url โ a plain native OS launch of their default browser (zero automation machinery; ideal for OAuth flows that just need the user to click Approve). Continue in pup ONLY for throwaway/test accounts or when the flow explicitly needs an anonymous context.';+}+const _flashState = new Map(); // sessionId -> last flash ts+// v1.9.140 (John: 'did you orange flash them twice? ... i had to go click all of them again') โ+// ONE FLASH PER OPEN, operation-scoped. The launch path flashes instantly, and the open handler+// used to flash again after the real URL loaded; the 5s TIME debounce between them is outrun by+// any heavy site (YouTube/Maps load >5s), so every slow page double-flashed โ and a second orange+// AFTER the user already clicked forces them to click every window again. A consumed-once marker+// scopes suppression to the OPEN OPERATION regardless of load time.+const _openFlashDone = new Map(); // sessionId -> ts of the launch flash for the current open+function scheduleAutoFlash(session, opts) {+ opts = opts || {};+ if (taskbarActivityMode() !== 'flash') return; // v2.0.292: autoFlash folded into taskbarActivity='flash'+ if (process.platform !== 'win32' || !session || !session._sessionId) return;+ if (opts.silent || session._flashSilent) return;+ const key = session._sessionId;+ const now = Date.now();+ if (now - (_flashState.get(key) || 0) < 5000) return; // coalesce the burst+ _flashState.set(key, now);+ session._flashCount = (session._flashCount || 0) + 1;+ console.log(`[flash] "${key}" (auto)`); // v1.9.140: every flash logs, so a double-flash can never hide again+ flashViaAD(session._sessionId, {}); // fire-and-forget (async โ AD desktop_flash_window)+}++// Record what the agent just did to a session, for lastAgentUpdate (pup_list_tabs /+// list_windows / status) and the "โ " title marker โ so the user + Session Monitor can+// see WHICH window/tab, WHAT verb, and the optional human note, WHEN.+function recordAgentUpdate(session, verb, args, tabId) {+ if (!session) return;+ const note = (args && typeof args.updateNote === 'string' && args.updateNote.trim()) ||+ (args && typeof args.note === 'string' && args.note.trim()) || null;+ const upd = { tabId: tabId || session.activeTabId || null, verb, note, ts: Date.now() };+ session._lastAgentUpdate = upd;+ if (upd.tabId) { const t = (session.tabs || []).find(x => x.tabId === upd.tabId); if (t) t.lastAgentUpdate = upd; }+}++// v1.8.37: CLEANUP NUDGE. pup never auto-closes windows (a "stale" one may belong to a+// paused thread), so windows an agent opened for a finished task otherwise pile up on the+// user's desktop forever. This returns a _hint (appended to open/status responses) reminding+// the caller to close what it's done with โ but only when it's actually worth nagging: some+// window is old (โฅ OLD_MIN) OR there are a lot of them (โฅ MANY). Lists the oldest offenders.+function staleWindowsHint() {+ try {+ const OLD_MIN = 30, MANY = 5;+ const now = Date.now();+ const all = [];+ for (const [sid, s] of sessions) {+ all.push({ sid, ageMin: s.createdAt ? Math.round((now - s.createdAt) / 60000) : 0, owner: s.owner || null });+ }+ const old = all.filter(w => w.ageMin >= OLD_MIN);+ if (old.length === 0 && all.length < MANY) return '';+ const show = (old.length ? old : all).sort((a, b) => b.ageMin - a.ageMin).slice(0, 6)+ .map(w => `${w.sid}${w.owner ? `(owner:${w.owner})` : ''}=${w.ageMin}m`).join(', ');+ return ` ๐งน CLEANUP: ${all.length} pup window(s) open${old.length ? `, ${old.length} โฅ${OLD_MIN}m old` : ''} โ close the ones you're DONE with via pup_close_window so they don't pile up on the user's desktop (pup never auto-closes; that's your job when a task ends). Oldest: [${show}].`;+ } catch (e) { return ''; }+}++// Resize/position a session's window via CDP (works for detached+connected). Setting+// explicit bounds also un-maximizes the window (--start-maximized is otherwise the+// default). x/y/width/height in screen pixels; any subset is honored.+async function setSessionWindowBounds(session, { x, y, width, height } = {}) {+ try {+ const cdp = await session.page.target().createCDPSession();+ const { windowId } = await cdp.send('Browser.getWindowForTarget');+ // Must leave maximized/minimized state before setting a size, or Chrome ignores w/h.+ await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+ const bounds = { windowState: 'normal' };+ if (Number.isFinite(width)) bounds.width = Math.round(width);+ if (Number.isFinite(height)) bounds.height = Math.round(height);+ if (Number.isFinite(x)) bounds.left = Math.round(x);+ if (Number.isFinite(y)) bounds.top = Math.round(y);+ await cdp.send('Browser.setWindowBounds', { windowId, bounds });+ try { await cdp.detach(); } catch {}+ return true;+ } catch (e) { console.error(`[bounds] setSessionWindowBounds failed: ${e.message}`); return false; }+}++async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false, foreground = false, highFps = false, insecureDev = false } = {}) {+ // Check if browser already running for this profile.+ // isConnected() can be stale after laptop sleep/wake โ the WebSocket hasn't+ // noticed the disconnect yet. Validate with a real CDP call (pages()) with+ // a short timeout to catch zombie browser handles.+ const existing = browsers.get(profileName);+ if (existing && existing.browser.isConnected()) {+ try {+ await Promise.race([+ existing.browser.pages(),+ new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2000)),+ ]);+ return existing.browser; // Really alive+ } catch (e) {+ console.log(`Cached browser for "${profileName}" failed health check (${e.message}) โ discarding`);+ }+ }+ // Clean up stale entry+ if (existing) {+ try { await existing.browser.close(); } catch (e) {}+ browsers.delete(profileName);+ }++ // v1.9.4: CONCURRENT-LAUNCH DEDUP โ see `_launchInFlight` declaration. If a launch for this+ // profile is already running, JOIN it (return its Promise) rather than spawning a competing+ // Chrome onto the same userDataDir (which mutually lock-kills and wedges the bridge). The+ // reuse-of-a-live-browser fast path above already returned; only genuine (re)launches reach here.+ if (_launchInFlight.has(profileName)) {+ console.log(`[launch] profile "${profileName}" is already launching โ joining the in-flight launch (no second Chrome)`);+ return await _launchInFlight.get(profileName);+ }+ let _resolveLaunch, _rejectLaunch;+ const _launchPromise = new Promise((res, rej) => { _resolveLaunch = res; _rejectLaunch = rej; });+ _launchPromise.catch(() => {}); // mark handled โ joiners still observe rejection via their own await+ _launchInFlight.set(profileName, _launchPromise);+ try {++ // Prefer an ALREADY-INSTALLED browser (Chrome โ Edge; every Windows box has Edge)+ // so the common case needs NO ~150 MB Chrome-for-Testing download. Falls back to a+ // cached CfT. A fresh --user-data-dir (below) keeps it CLEAN โ it never touches the+ // user's real tabs/logins (driving the real browser is the Adom extension's job).+ //+ // launchCandidates() returns the FULL ordered fallback list ([pinned default] โ+ // Chrome โ Edge โ cached CfT). The launch loop below tries each in turn and+ // SPAWN-VERIFIES it โ a broken/partial top pick (e.g. a corrupt Chrome install)+ // falls through to the next real browser instead of hard-failing the open. The+ // browser that actually launches is cached as the default (chrome.markVerified).+ // v2.0.369: refresh the CfT-first policy from the LIVE setting on every launch, so flipping+ // toolbarExtension applies without a bridge restart.+ try { if (chrome.setPreferCft) chrome.setPreferCft(pupSettings().toolbarExtension !== 'off'); } catch (e) {}+ const candidates = chrome.launchCandidates();+ if (candidates.length) {+ console.log(`[launch] ${candidates.length} browser candidate(s): ` + candidates.map(c => `${c.kind}(${c.source})`).join(' โ '));+ } else {+ console.log('[launch] no system browser or cached CfT detected โ falling back to puppeteer.executablePath()');+ }++ // v2.0.293 โ MERGE duplicate --disable-features / --enable-features into ONE flag each.+ // Chrome honors only the LAST occurrence of a switch, so when three code eras each appended+ // their own --disable-features (v2.0.120 MediaRouter/mDNS, v1.9.198 CalculateNativeWinOcclusion,+ // v1.8.95 SigninIntercept...), only the last list applied โ which SILENTLY UNDID the v2.0.120+ // firewall fix: MediaRouter's mDNS re-bound 0.0.0.0:5353 (measured live on ConfRoomROG, CfT 148)+ // and every new CfT exe re-popped the Windows Firewall "allow access?" dialog at John.+ // pup's HARD RULE is zero firewall prompts (see dev-skills/pup-no-firewall): every listener+ // loopback-only. Any future feature-flag addition MUST go through this merge (it runs at both+ // launch sites), never as a hand-appended second --disable-features.+ function mergeChromiumFeatureFlags(args) {+ const out = [];+ const merged = {}; // switchName -> { idx, values:[] }+ for (const a of args) {+ const m = typeof a === 'string' && a.match(/^--(disable-features|enable-features)=(.*)$/);+ if (!m) { out.push(a); continue; }+ const key = m[1];+ if (!merged[key]) { merged[key] = { idx: out.length, values: [] }; out.push(null); }+ for (const v of m[2].split(',')) { if (v && !merged[key].values.includes(v)) merged[key].values.push(v); }+ }+ for (const key of Object.keys(merged)) out[merged[key].idx] = `--${key}=${merged[key].values.join(',')}`;+ return out.filter(a => a !== null);+ }++ const launchOpts = {+ headless: false,+ defaultViewport: null,+ // executablePath is set PER-CANDIDATE in the spawn-verified launch loop below.+ args: [+ // v1.9.129 โ SECURE BY DEFAULT, now that the default profile is durable and holds REAL+ // logins (John's Gmail as of today). Four flags removed from every default launch:+ // --no-sandbox / --disable-setuid-sandbox: docker-era boilerplate; on a desktop the+ // sandbox works and is strictly better protecting a credentialed profile.+ // --disable-web-security: killed the same-origin policy, which was tolerable when every+ // window was a throwaway empty jar and is UNSAFE on a profile with live sessions โ+ // any opened page could have read Gmail cross-origin.+ // --test-type: existed only to hush the warning bars the flags above caused, and it+ // also suppresses Chrome's promo/save bubbles โ it is why Chrome NEVER OFFERED to+ // save John's password. Remove the cause, lose the suppressor, get the bubble back.+ // Dev windows that truly need cross-origin eval opt back in per-window with+ // {webSecurity:false}, which FORCES an isolated throwaway profile (below) so the+ // insecure flags can never share a process with the credentialed jar.+ ...(insecureDev ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security', '--test-type'] : []),+ '--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',+ // 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).+ // macOS: a background window lives in a HIDDEN app (Cmd+H), which AD's window recording and desktop+ // screenshots cannot see. capturable:true un-hides it where it sits (visible, NOT focused, behind the+ // user's active app: the same primitive as pup_alert_window's mac nudge), so it can be filmed or+ // photographed without taking the user's screen. Windows and Linux are unchanged.+ let _capturable;+ if (process.platform === 'darwin' && !foreground && args.capturable) {+ try { const _cs = sessions.get(sessionId); _capturable = !!(_cs && await macSetAppHiddenByPid(macSessionPid(_cs), false)); } catch (e) { _capturable = false; }+ }+ 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',+ // macOS capturable:true: whether the window was un-hidden for capture (undefined when not asked)+ capturable: _capturable,+ // 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];+ // macOS: nothing else resolves a session's window handle there (no park/overlay/flash machinery),+ // so resolve the missing ones here, bounded, before the rows report hwnd.+ if (process.platform === 'darwin') {+ await Promise.all(entries.filter(([, s]) => s && !s._hwnd).map(([id, s]) =>+ withTimeout(resolveSessionHwndByBounds(s, id), 2500, 'resolveSessionHwnd').catch(() => null)));+ }+ 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'));++@@ -1,848 +1,854 @@⋯ 568 unchanged lines ⋯ Every `pup_*` verb works on macOS; only the OS-presentation layer differs: - **Background = hidden app.** A background open launches the session's browser process hidden- (the Cmd+H state), truly invisible, still fully drivable/screenshottable/recordable.+ (the Cmd+H state), truly invisible, still fully drivable, and `pup_screenshot` / pup's own+ recording still work (they read the page through CDP). A **desktop** capture of the window does+ not: AD's window recording (`desktop_record_window_start`) and `desktop_screenshot_window` cannot+ see a hidden app's window. Open with **`capturable: true`** to have the window un-hidden where it+ sits (visible, not focused, behind the user's active app) so it can be filmed or photographed+ without taking the screen; `pup_list_windows` reports its `hwnd` (the macOS window id) to target. `pup_lower_os_window` re-hides; `pup_raise_os_window` (with `foregroundReason`) unhides + activates. One session = one browser process, so a shared-profile window group hides/shows together.⋯ 270 unchanged lines ⋯ measured around 200 MB each, so twenty of them is 4 GB that Chrome will not hand back while you hold the tabs open. Close tabs with `pup_close_tab` the moment a task is done, rather than leaving a window to grow. (Written after a thread opened twenty of them on John's laptop and drove it into thrashing, 2026-09-10.)+
Comments
Log in to comment.