← All Pull Requests

mac: hide/raise via NSRunningApplication, no System Events (Automation TCC prompt) #2

Merged Released in 2.0.598 on 16 Sep 2026 opened by Kyle Bergstedt

Kyle, 2026-09-16, on his Mac: "i just got a popup for adom bridge trying to control system events. this is the point of the accessibility permission, system events shouldn't be required" / "everything should be done through bridge".

Cause: macSetAppHiddenByPid / macActivateByPid (src/server.js ~135-146) run osascript -e 'tell application "System Events" to set visible/frontmost of (first process whose unix id is N)'. An Apple Event to System Events needs the Automation TCC grant, which nobody is granted, and since Hydrogen forwards every plugin verb through Bridge the prompt reads "Adom Bridge wants to control System Events". Bridge core dropped its own System Events sites in bridge-macos 1.9.202 for the same reason.

Change (Node line, mac only, one region): the two helpers now call NSRunningApplication.runningApplicationWithProcessIdentifier(pid) → hide / unhide / activateWithOptions through osascript's JavaScript-for-Automation ObjC bridge (osascript -l JavaScript). That runs AppKit in-process; no Apple Event leaves osascript, so no TCC prompt of any kind. runOsascriptPromise is renamed runJxaPromise (it had no other callers). hide/unhide are asynchronous, so the hidden helper judges by a.hidden after 300 ms instead of by the call returning. The park-failed log line no longer suggests granting Automation. No Windows path touched.

Verified on macOS 26.6 against Chrome for Testing 146 launched with a scratch profile: hide → true, unhide → true, activate → true, re-hide → true; node --check src/server.js clean; zero tell application "System Events" left in src/ (the three remaining mentions are the comment explaining this change). Same mechanism the Rust host-mac crate uses (objc2-app-kit NSRunningApplication), so the two lines agree.

Base: the page's src/server.js at 2.0.597 (byte-identical to the file this patch was made against). Ask: merge, ship a Node build (tools/ship.sh <ver> "<log>" --shell node) and, on Kyle's words, promote it so the mac public tier (2.0.396 today, and the bridge-macos seed) stops prompting. Hydrogen/Bridge side (BRIDGE) reseeds plugins/puppeteer from that build.

Diff Skip to comments (2)

--- a/src/server.js+++ b/src/server.js@@ -1,21989 +1,21997 @@⋯ 125 unchanged lines ⋯     });   }); }-function runOsascriptPromise(script, { timeout = 8000 } = {}) {-  return new Promise(resolve => {-    execFile('/usr/bin/osascript', ['-e', script], { encoding: 'utf8', timeout }, (err, stdout) => resolve(err ? null : (stdout || '').trim()));-  });-}-// Hide (true) / unhide (false) every window of a process. Resolves true if the script ran.-async function macSetAppHiddenByPid(pid, hidden) {-  if (!pid) return false;-  const r = await runOsascriptPromise(`tell application "System Events" to set visible of (first process whose unix id is ${Number(pid)}) to ${hidden ? 'false' : 'true'}`);-  return r !== null;-}-// Unhide + bring the process's windows to the foreground (the user-asked-to-watch path).-async function macActivateByPid(pid) {-  if (!pid) return false;-  await runOsascriptPromise(`tell application "System Events" to set visible of (first process whose unix id is ${Number(pid)}) to true`);-  const r = await runOsascriptPromise(`tell application "System Events" to set frontmost of (first process whose unix id is ${Number(pid)}) to true`);-  return r !== null;-}-// The pid that owns a session's browser (detached launches record it; puppeteer.launch-// exposes process()). Null when unresolvable — callers report applied:false.-function macSessionPid(session) {-  try {-    const be = browsers.get(session.profileName);-    const b = be && be.browser;-    if (!b) return null;-    return b._detachedPid || (b.process() && b.process().pid) || null;-  } catch (e) { return null; }-}--// ── v1.9.218 — SELF-HEALING DEPENDENCIES (wiki issue #15) ───────────────────────────────────────-// pup ships as a SOURCE-ONLY zip (node_modules is 88MB of native builds — it cannot go in the wiki-// release), and the design assumes AD runs `npm install` in the bridge dir on spawn. In practice that-// does not reliably happen: AD re-provisions the cache dir on respawn, wiping any manual install, and-// the bridge then dies at this very line with MODULE_NOT_FOUND: Cannot find module 'puppeteer'. The-// user's lived experience, in the reporter's words, is "pup keeps breaking and I always have to-// reinstall it" — and a human had to npm install by hand again TODAY to bring it back.-//-// A bridge that cannot start without a step someone else may not perform is not robust. So: detect-// missing deps HERE, before the require that would crash, and install them ourselves. This is-// idempotent, runs only when deps are actually absent, and turns a hard crash-loop into a slow first-// start. It does not replace AD's install — it makes pup survive when that install did not happen.-function ensureDepsInstalled() {-  try {-    require.resolve('puppeteer');-    return { ok: true, installed: false };-  } catch (e) {-    // Deps are missing. Install them in OUR directory, synchronously, before anything requires them.-    const dir = __dirname;-    console.error(`[deps] node_modules missing (${e.code || 'MODULE_NOT_FOUND'}) — self-installing in ${dir}. This is wiki issue #15: the source-only zip ships no node_modules and AD's install did not run or was wiped by a respawn.`);-    const started = Date.now();-    try {-      const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';-      // #280: do NOT let puppeteer's postinstall block/fail on a Chrome-for-Testing download here. It-      // reliably fails behind npm's allow-scripts gate and on half-populated CfT cache dirs, and pup-      // drives system Chrome/Edge without it. Skip it so the self-install finishes fast and pup is-      // immediately usable via pup_use; CfT is fetched lazily (last resort) only when genuinely no-      // system browser exists.-      execFileSync(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {-        cwd: dir, stdio: 'inherit', timeout: 300000, windowsHide: true,-        env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: '1', PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: '1' },-      });-      require.resolve('puppeteer');-      console.error(`[deps] self-install SUCCEEDED in ${Math.round((Date.now() - started) / 1000)}s — pup is starting normally. No human npm install needed.`);-      return { ok: true, installed: true };-    } catch (e2) {-      console.error(`[deps] self-install FAILED: ${e2.message}. pup cannot start. Check that node + npm are on PATH for the AD process, then retry, or run: cd "${dir}" && npm install`);-      return { ok: false, installed: false, error: e2.message };-    }-  }-}-// v1.9.334 (wiki issue #468 — Drew: pup "never bound its port", 100% unusable). The heavy NATIVE-// requires below (puppeteer, keytar via credential_vault, sharp, and chrome.js — which itself requires-// puppeteer) block the event loop for ~8s at module load, so the HTTP port only bound at ~8s, long-// after AD's recover_not_listening reaps a not-yet-bound bridge (~2-3.6s). FIX: DEFER them out of the-// SYNCHRONOUS module-load path. Only light modules load synchronously now → the port binds in <1s, AD-// sees it listening and never reaps, and the ~8s of native loading happens in ensureHeavyInit() AFTER-// the bind (first line of the listen callback, plus lazily on the first non-/health request). /health-// needs no heavy module (its chrome.readiness() is guarded for the pre-init window).-let _depState = null, puppeteer = null, credentialVault = null, cardVault = null, chrome = null;-let _heavyInitDone = false;-function ensureHeavyInit() {-  if (_heavyInitDone) return;-  _heavyInitDone = true;-  try { _depState = ensureDepsInstalled(); } catch (e) { _depState = { ok: false, error: e.message }; }-  puppeteer = require('puppeteer');-  credentialVault = require('./credential_vault');-  cardVault = require('./card_vault');-  chrome = require('./chrome'); // Chrome-for-Testing self-heal (also pulls puppeteer internally)-  try { sharp = require('sharp'); }-  catch (e) { console.warn('[pup] sharp unavailable — screenshots served at capture size, not downscaled:', e.message); sharp = null; }-}--// sharp is REQUIRED for resizing screenshots — never send full-res to container.-// v1.8.40+: previously this was wrapped in try/catch with a `let sharp = null`-// fallback. The fallback path silently sent 2559px PNGs to /tmp/adom-desktop-screenshots/,-// which CRASHES Claude many-image sessions at the 2000px limit. Hard-require-// sharp is OPTIONAL — a heavy native dep whose install can fail on a fresh box (no-// prebuild / no build tools / a managed-runtime PATH gap). pup must still spawn and-// work without it: we fall back to serving the screenshot at capture size and reading-// dimensions from the PNG header. When sharp IS present, we downscale to keep images-// under Claude's Read limit (the ideal path). Never let a missing sharp crash the bridge.-let sharp = null;   // v1.9.334: loaded lazily in ensureHeavyInit() (see the #468 deferral above) — never in the sync module-load path-const MAX_SCREENSHOT_DIM = 1568; // Claude's optimal max dimension--// PNG width/height straight from the IHDR header (bytes 16-23, big-endian). Pure JS,-// no sharp needed. puppeteer screenshots are always PNG.-function pngDimensions(buf) {-  try { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; }-  catch { return { width: 0, height: 0 }; }-}-// Downscale a PNG to <= maxDim (long edge) when sharp is available; otherwise pass it-// through at capture size. Returns the (possibly-resized) buffer + dimensions + flags.-async function processScreenshot(buf, maxDim) {-  if (sharp) {-    const orig = await sharp(buf).metadata();-    const needsResize = orig.width > maxDim || orig.height > maxDim;-    const out = await (needsResize-      ? sharp(buf).resize({ width: maxDim, height: maxDim, fit: 'inside' })-      : sharp(buf)).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();-    const fin = await sharp(out).metadata();-    return { buf: out, width: fin.width, height: fin.height, origWidth: orig.width, origHeight: orig.height, resized: needsResize, sharpUsed: true };-  }-  const d = pngDimensions(buf);-  return { buf, width: d.width, height: d.height, origWidth: d.width, origHeight: d.height, resized: false, sharpUsed: false };-}--const PORT = (() => {-  const idx = process.argv.indexOf('--port');-  return idx !== -1 && process.argv[idx + 1] ? parseInt(process.argv[idx + 1]) : 8851;-})();--// v2.0.50: HUMAN-INITIATED caller identities (taskbar jump-list clicks, the toolbar-extension-// popup). Exempt from the ownership gate (a human operating their own window is always-// sanctioned) and never recorded as a window's owner. Was 4 exact-string compares on-// 'user-taskbar-menu'; the toolbar extension adds 'user-toolbar'.-const USER_CALLERS = new Set(['user-taskbar-menu', 'user-toolbar', 'user-pill']);-const BRIDGE_VERSION = (() => { try { return fs.readFileSync(path.join(__dirname, 'BRIDGE_VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();--const SHOTS_DIR = path.join(__dirname, 'screenshots');-fs.mkdirSync(SHOTS_DIR, { recursive: true });--// v1.8.21: shadow-DOM-piercing + nested-scroll helpers, installed into every page's-// window so pup_eval can use them ($deep / $$deep / deepText) AND so pup_scroll-// / pup_screenshot_element resolve elements + the ACTUAL scroll container the same-// way. Idempotent (guarded by __pupDeepInstalled). Injected via evaluateOnNewDocument-// (persists across navigations) AND ensured on-demand before each verb (covers the-// already-loaded document).-const PUP_DEEP_HELPERS = `(function(){-  if (window.__pupDeepInstalled) return; window.__pupDeepInstalled = true;-  // querySelectorAll piercing OPEN shadow roots-  window.$$deep = function(sel, root){ root = root||document; var out=[];-    var walk=function(n){ var f; try{f=n.querySelectorAll(sel);}catch(e){f=[];}-      for(var i=0;i<f.length;i++)out.push(f[i]);-      var a=n.querySelectorAll('*'); for(var j=0;j<a.length;j++){ if(a[j].shadowRoot) walk(a[j].shadowRoot); } };-    walk(root); return out; };-  window.$deep = function(sel, root){ var a=window.$$deep(sel,root); return a[0]||null; };-  // first VISIBLE element whose own text contains txt (case-insensitive), piercing shadow-  window.deepText = function(txt, root){ root=root||document; txt=(txt||'').toLowerCase(); var best=null;-    var walk=function(n){ var a=n.querySelectorAll('*'); for(var i=0;i<a.length;i++){ var el=a[i], own='';-      for(var k=0;k<el.childNodes.length;k++){ if(el.childNodes[k].nodeType===3) own+=el.childNodes[k].textContent; }-      if(own.toLowerCase().indexOf(txt)!==-1){ var r=el.getBoundingClientRect(); if(r.width>0&&r.height>0){ best=el; return true; } }-      if(el.shadowRoot&&walk(el.shadowRoot)) return true; } return false; };-    walk(root); return best; };-  // nearest scrollable ancestor of el (crosses shadow boundaries via host)-  window.__pupScrollableAncestor = function(el){ var n=el;-    while(n && n!==document.documentElement){ try{ var s=getComputedStyle(n), oy=s.overflowY;-      if((oy==='auto'||oy==='scroll'||oy==='overlay') && n.scrollHeight>n.clientHeight+1) return n; }catch(e){}-      n = n.parentElement || (n.getRootNode&&n.getRootNode().host) || null; }-    return document.scrollingElement||document.documentElement; };-  // the page's MAIN scroller: the document if it scrolls, else the biggest scrollable container-  window.__pupMainScroller = function(){ var doc=document.scrollingElement||document.documentElement;-    if(doc.scrollHeight>doc.clientHeight+1) return doc;-    var best=doc, area=0, all=window.$$deep('*');-    for(var i=0;i<all.length;i++){ var el=all[i]; try{ var s=getComputedStyle(el);-      if((s.overflowY==='auto'||s.overflowY==='scroll'||s.overflowY==='overlay') && el.scrollHeight>el.clientHeight+1){-        var a=(el.scrollHeight-el.clientHeight)*el.clientWidth; if(a>area){ area=a; best=el; } } }catch(e){} }-    return best; };-  window.__pupDesc = function(el){ if(!el) return null;-    var id=el.id?('#'+el.id):''; var cn=(el.className&&el.className.toString)?el.className.toString().trim():'';-    var cls=cn?('.'+cn.split(/\\s+/).slice(0,2).join('.')):''; return (el.tagName||'').toLowerCase()+id+cls; };-})();`;--async function ensureDeepHelpers(page) { try { await page.evaluate(PUP_DEEP_HELPERS); } catch (e) {} }--// Persistent Chrome profiles — each session gets its own userDataDir so IndexedDB, cookies,-// localStorage survive across restarts. v1.8.98: these live OUTSIDE the bridge dir, in the-// stable ~/.adom zone. __dirname is bridges-cache\puppeteer, which AD CLOBBERS on every-// bridge_install — storing profiles there wiped every persistent login (incl. the wiki-auth-// session) on every pup update. A stable home makes "log in once" actually last (across updates,-// restarts, reboots). One-time migration: pull an existing in-cache profiles/ dir over if present.-const ADOM_STATE_DIR = path.join(os.homedir(), '.adom');-const PROFILES_DIR = path.join(ADOM_STATE_DIR, 'pup-profiles');-// v2.0.150: DETERMINISTIC Adom extension IDs. Each manifest carries a fixed `key`, so its ID is-// stable + known (not the fragile path-hash of an unpacked load). This lets us write the toolbar-// pin ORDER directly into Preferences (extensions.pinned_extensions), which is what actually-// controls the left→right icon order. Order the array [pup, annotate, identity] to place the-// profile/identity tile rightmost, next to Chrome's own profile avatar. KEEP IN LOCKSTEP with the-// `key` fields in src/extension*/manifest.json — regenerating a key changes the id here too.-const ADOM_EXT_IDS = {-  pup: 'liomcaladmooagjiebkfgegciadapnpi',-  annotate: 'ghadnmahlldiigemdbekmhcbfcpkmclg',-  identity: 'mngfopllhdfgomjphhejmcdimcohcgnf',-  identityAnon: 'mhoenaodccbppcinfbaifamkgfhbakbe',-};-const SESSIONS_DIR = path.join(ADOM_STATE_DIR, 'pup-sessions');-fs.mkdirSync(PROFILES_DIR, { recursive: true });-fs.mkdirSync(SESSIONS_DIR, { recursive: true });-// One-time migration from the old clobber-prone in-bridge locations (best-effort).-try {-  for (const [oldRel, dest] of [['profiles', PROFILES_DIR], ['sessions', SESSIONS_DIR]]) {-    const oldDir = path.join(__dirname, oldRel);-    if (fs.existsSync(oldDir) && fs.readdirSync(dest).length === 0) {-      for (const entry of fs.readdirSync(oldDir)) {-        try { fs.renameSync(path.join(oldDir, entry), path.join(dest, entry)); } catch (e) {}-      }-      console.log(`[migrate] moved ${oldRel}/ from the bridge cache to ${dest}`);-    }-  }-} catch (e) {}--// Multi-session state: sessions are tabs within shared Chrome browsers-// One Chrome process per profile (userDataDir), multiple tabs per Chrome.-// v2.0.256 (John lost THREE windows to this): the ONE primitive allowed to kill a profile's Chrome.-// #434 established the rule — decide from GROUND TRUTH, never from the cached refCount — but only-// converted ONE of the four call sites. The other three still ran `if (--refCount <= 0) kill`, and-// that counter is provably wrong right after a RESTART: recovery re-creates the browser entry with-// `refCount: 0` and does not count the sessions it just restored. So on AdomLapper, three recovered-// windows (fusion-dashboard, kicad-maint, and the ab web-demo window John was using) sat on the-// shared adom-you Chrome with refCount 0, and the next path to run decremented 0 -> 0 and-// force-killed the process hosting all three. pup then relaunched them and reconciled them away as-// "closed", so all three of the user's windows vanished.-// A refcount is telemetry. The session map is truth. Every kill goes through here.-async function killProfileBrowserIfUnused(profileName, why) {-  const be = browsers.get(profileName);-  if (!be) return { killed: 0, remaining: 0 };-  be.refCount = Math.max(0, be.refCount - 1);   // kept honest for telemetry only — NEVER authorizes a kill-  // Any OTHER session still bound to this profile? _lostBrowser entries count on purpose: rescan may-  // re-adopt that still-open window, so a disconnected record must still veto the kill.-  let others = 0;-  for (const s of sessions.values()) { if (s.profileName === profileName) others++; }-  let busy = 0;-  for (const [sid, st] of _busyState) {-    if (st && st.busy) { const bs = sessions.get(sid); if (bs && bs.profileName === profileName) busy++; }-  }-  if (others || busy) {-    console.log(`Keeping browser for profile "${profileName}" — ${others} other session(s)${busy ? ` and ${busy} verb(s) in flight` : ''} still using it (refCount=${be.refCount}); NOT killing (${why})`);-    return { killed: 0, remaining: others };-  }-  console.log(`No more sessions using profile "${profileName}" — killing browser (${why})`);-  let r = { killed: 0, remaining: 0 };-  try { r = await killBrowserProcess(be.browser, profileName); } catch (e) {}-  browsers.delete(profileName);-  return r;-}-// browsers:  profileName → { browser, refCount }-// sessions:  sessionId → Session (see createSession below)-//-// Session shape (new — supports multiple tabs per session):-//   {-//     tabs:        [{ tabId, page, errors }]  // all open tabs for this session-//     activeTabId: string                      // currently "foregrounded" tab-//     latestShot:  string | null               // last screenshot path-//     profileName: string                      // Chrome profile-//     tabCounter:  number                      // monotonic per-session id counter-//     // Backward-compat virtual properties (getters):-//     page:   active tab's page   (for legacy callers that read session.page)-//     errors: active tab's errors-//   }-let browsers = new Map();-let sessions = new Map();-let activeSessionId = null;-// v1.9.146 (John: 'different ai threads opening pup window tabs into each other's windows ... make-// sure their windows only open in tabs by default in the pup window they own'). activeSessionId is-// a SINGLE GLOBAL overwritten on every open by ANY thread, so pup_open_tab with no sessionId-// dumped the tab into whatever window was last touched — cross-thread contamination. This resolves-// the CALLER THREAD'S OWN window instead: the most recently created live session this thread owns.-function callerOwnWindow(callerThread) {-  if (!callerThread) return null;-  let best = null;-  for (const [sid, s] of sessions) {-    if (s && s._ownerThread === callerThread && isSessionAlive(s)) {-      if (!best || (s.createdAt || 0) >= (sessions.get(best).createdAt || 0)) best = sid;-    }-  }-  return best;-}-// v2.0.103 (John, watching the ah-build thread hwnd-hunt its own window): every open/navigate-// response teaches the caller how to KEEP driving its window with zero ids — the follow-ups an-// AI actually needs next, stated as ready-to-run calls.-function threadNextBlock() {-  return [-    'pup_screenshot {} — screenshot YOUR window (no ids: pup resolves your --ai-thread name to your window)',-    'pup_reload {} — reload it (e.g. after you publish or rebuild something it shows)',-    'pup_my_windows {} — full state of everything your thread owns: windows, tabs, urls, titles',-    'pup_open_window {"url":"..."} — another page = another TAB in your same window',-    'pup_raise_os_window {"foregroundReason":"..."} — bring it onto the user\'s screen (gated)',-    'Windows open MAXIMIZED already. NEVER hunt hwnds (desktop_find_window / desktop_set_window_state) for a pup window — pup verbs already know your window.',-    'pup_screenshot {} captures the PAGE (CDP, pixel-perfect, works in background — the shot you want 99% of the time). Only when you need the browser CHROME in frame (tab strip, toolbar icons): pup_window_info {} hands you this window\'s hwnd for desktop_screenshot_window — no searching, ever.',-  ];-}-// v2.0.110 (John authorized: pup runs its OWN login store so the AI manages credentials without-// Chrome's foreground picker). This rail records a login as it is submitted: on submit of a form-// holding a filled secret field, it stashes {host, username, secret} on window.__pupCapturedLogin.-// The 10s sweep harvests it into the OS-keychain vault (never the index, never a log) and hints-// the caller. Read-and-cleared atomically by the sweep to minimize how long it sits in the page.-const CAPTURE_LOGIN_JS = `(() => {-  if (window !== window.top) return;-  if (window.__pupCapV === '${BRIDGE_VERSION}') return;-  window.__pupCapV = '${BRIDGE_VERSION}';-  function vis(el){ return !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0; }-  // v2.0.123: a password field is not always type=password — Gusto and many SSO/Keycloak themes use-  // type=text with autocomplete="current-password" (or name/id "password") and mask it themselves.-  function isPwd(el){ return el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || '')); }-  function grab(form){-    try {-      var pwd = [].slice.call((form || document).querySelectorAll('input')).filter(vis).filter(isPwd)[0];-      if (!pwd || !pwd.value) return;-      var all = [].slice.call((form || document).querySelectorAll('input'));-      var pi = all.indexOf(pwd);-      var user = [].slice.call((form || document).querySelectorAll('input[autocomplete=username], input[type=email]')).filter(vis).filter(function(e){ return !isPwd(e); })[0];-      if (!user) {-        var cands = [].slice.call((form || document).querySelectorAll('input[type=text], input[type=tel], input:not([type])')).filter(vis).filter(function(e){ return !isPwd(e); });-        user = cands.slice().reverse().filter(function(c){ return all.indexOf(c) < pi; })[0] || cands[0] || null;-      }-      var rec = { host: location.hostname, u: user ? user.value : '', p: pwd.value, ts: Date.now() };-      // Prefer the SYNCHRONOUS binding — it reaches the bridge BEFORE the submit navigates the-      // page away (the global stash below would be wiped by that navigation, missing the capture).-      if (typeof window.__pupCredCapture === 'function') { try { window.__pupCredCapture(JSON.stringify(rec)); } catch (e) {} }-      window.__pupCapturedLogin = rec;   // fallback for SPA logins that never navigate (sweep harvests it)-    } catch (e) {}-  }-  function formHasPwd(f){ try { return !!f && [].slice.call(f.querySelectorAll('input')).some(isPwd); } catch (e) { return false; } }-  addEventListener('submit', function(e){ grab(e.target && e.target.tagName === 'FORM' ? e.target : null); }, true);-  addEventListener('click', function(e){-    try {-      var t = e.target && e.target.closest && e.target.closest('button, input[type=submit], [role=button], a');-      if (!t) return;-      var f = t.form || (t.closest && t.closest('form'));-      // v2.0.128 (consumer bug report #4): was a literal input[type=password] check, so a masked-      // type=text password field (Rails/Keycloak) never matched and capture missed on click. Use the-      // isPwd matcher; if there is no enclosing form, sweep the document (SPA login with no <form>).-      if (formHasPwd(f)) grab(f); else if (!f) grab(null);-    } catch (e2) {}-  }, true);-  // v2.0.121 (John, Gusto miss): ALSO capture the instant the password field loses focus or-  // changes — this fires BEFORE the form submits and, crucially, before a cross-origin SSO redirect-  // (login.gusto.com -> app.gusto.com) tears the page down. Submit-only capture raced that redirect-  // and lost; grabbing on blur/change delivers the creds through the synchronous binding first.-  addEventListener('change', function(e){ try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);-  addEventListener('blur', function(e){ try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);-  // v2.0.128 (consumer bug report #4): a STANDARD Rails login often submits PROGRAMMATICALLY —-  // form.submit() (and requestSubmit's older cousins) does NOT dispatch the 'submit' event, so-  // submit/click capture silently missed it. Hook the prototype so a programmatic submit still grabs.-  try {-    var _origSubmit = HTMLFormElement.prototype.submit;-    HTMLFormElement.prototype.submit = function(){ try { if (formHasPwd(this)) grab(this); } catch (e) {} return _origSubmit.apply(this, arguments); };-  } catch (e) {}-  // Last-resort net: on Enter inside a login form, and on pagehide before ANY navigation tears the-  // page down, grab whatever password field is currently filled. Cheap, synchronous, dedup is-  // downstream (same host+user+pwd within the session is coalesced).-  addEventListener('keydown', function(e){ try { if (e.key === 'Enter' && isPwd(document.activeElement) && document.activeElement.value) grab(document.activeElement.form || null); } catch (e2) {} }, true);-  addEventListener('pagehide', function(){ try { grab(null); } catch (e2) {} }, true);-})()`;-let pupshotUrl = null;-// v1.9.4: profileName -> in-flight launch Promise. A fresh profile isn't in `browsers` until its-// Chrome launch FINISHES (~10-15s), so two near-simultaneous opens for the same profile used to-// each spawn their OWN detached Chrome on the SAME userDataDir; they then collided on Chrome's-// SingletonLock and mutually taskkilled each other's process, so NONE came up and the bridge-// looked wedged for minutes. This map lets concurrent callers JOIN the first launch instead of-// racing a second Chrome onto the same dir. (Root cause of the 2026-07-19 cold-start wedge.)-const _launchInFlight = new Map();-// v1.9.143 (John's 3-phase test, attempt 3) — SERIALIZE OPENS PER PROFILE. Ten PARALLEL opens on-// the shared profile interleaved page-creation with sibling cleanup: 3 died 'Navigating frame was-// detached' even with the _pupCreated guard (the marker lands after creation returns; targets are-// visible mid-creation), and worse, the sibling closes drove windows to zero tabs — Chrome EXITS-// when its last window closes, which took ALL TEN sessions down ('Session not found' across the-// board by phase 3). Opens on DIFFERENT profiles stay fully parallel; opens on the SAME profile-// run in arrival order, each completing create+cleanup before the next starts. Correctness first:-// a fleet of AI threads opening default windows simultaneously is the 10,000-user case.-const _profileOpenChain = new Map(); // profileName -> tail promise-let _tabClaimSeq = 0; // monotonic per-call marker for in-window tab adoption (see addTabToSession)-let _birthSeq = 0;-// v2.0.259 (John's placement rules, R1): a window is CREATED at its final on-screen rect, so we need-// the primary monitor's work area BEFORE any page exists to ask (screen.availWidth comes from a page,-// which is the chicken-and-egg that pushed the old design into staging windows off-screen and moving-// them later). SystemParametersInfo(SPI_GETWORKAREA) answers without a window. Cached — monitors do-// not change often, and a stale value only costs a slightly-off rect that the park corrects.-let _workAreaCache = null;   // { x, y, w, h, at }-async function primaryWorkArea() {-  if (_workAreaCache && Date.now() - _workAreaCache.at < 5 * 60000) return _workAreaCache;-  if (process.platform !== 'win32') return null;-  const ps = `$s=@'-using System; using System.Runtime.InteropServices;-public static class WA {-  [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }-  [DllImport("user32.dll")] public static extern bool SystemParametersInfo(uint a, uint b, ref RECT r, uint c);-}-'@; Add-Type -TypeDefinition $s-$r = New-Object WA+RECT-if([WA]::SystemParametersInfo(0x0030, 0, [ref]$r, 0)) { "{0},{1},{2},{3}" -f $r.L,$r.T,($r.R-$r.L),($r.B-$r.T) } else { "ERR" }`;-  return await new Promise((resolve) => {-    try {-      execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 10000 }, (err, stdout) => {-        const m = /^(-?\d+),(-?\d+),(\d+),(\d+)$/.exec(String(stdout || '').trim());-        if (!m) { console.log(`[workarea] could not read the primary work area (${err ? err.message.slice(0, 60) : String(stdout || '').trim().slice(0, 40)}) — birth will fall back to a conservative on-screen box`); return resolve(null); }-        const w = Number(m[3]), h = Number(m[4]);-        if (!(w > 400 && h > 300)) return resolve(null);-        _workAreaCache = { x: Number(m[1]), y: Number(m[2]), w, h, at: Date.now() };-        console.log(`[workarea] primary work area ${w}x${h} at ${m[1]},${m[2]}`);-        resolve(_workAreaCache);-      });-    } catch (e) { resolve(null); }-  });-}-// R1: the birth rect — the primary work area inset 10px on ALL FOUR sides. The inset is deliberate-// and visible: it is how you can tell at a glance that a window was PLACED by pup rather than landing-// somewhere by accident. Falls back to a conservative on-screen box, never off-screen.-async function birthRect() {-  const wa = await primaryWorkArea();-  if (wa) return { left: wa.x + 10, top: wa.y + 10, width: Math.max(400, wa.w - 20), height: Math.max(300, wa.h - 20) };-  return { left: 10, top: 10, width: 1280, height: 800 };-}-// v2.0.38: SERIALIZE window creation so the set-diff birth capture always sees EXACTLY ONE new-// window handle (position is unreliable — Chrome clamps our -32000 birth coord on-screen, so we-// cannot identify the window by where it is; but "the one handle that appeared while I held the-// lock" is unambiguous regardless of position or concurrency across threads).-let _birthLock = Promise.resolve();-function withBirthLock(fn) {-  const prev = _birthLock;-  let release;-  _birthLock = new Promise(r => { release = r; });-  return prev.then(fn).finally(() => release());-}    // monotonic counter for unique off-screen birth positions (birth-time hwnd resolution)--// ============================================================================-// DETERMINISTIC WINDOW IDENTITY — the Win32 window-property tag (dev-skills/pup-no-wiggle)-// -----------------------------------------------------------------------------// A pup window is identified by a Win32 WINDOW PROPERTY carrying its sessionId, NOT by geometry.-// SetProp attaches "AdomPupSid=<sessionId>" to the OS window; the property is stored WITH the window-// (owned by Chrome), so it survives a page changing its title, a tab crash, and pup itself-// restarting. VERIFIED 2026-08-22: process A set a prop then exited; process B (separate process)-// read it back intact. To re-find a window we READ this property (a lookup), we NEVER guess by-// size/position and we NEVER nudge a window to see which one moves (the banned "wiggle").-const PUP_WIN_PROP = 'AdomPupSid';-function _runPsEncoded(ps, timeout = 8000) {-  return new Promise((resolve) => {-    if (process.platform !== 'win32') return resolve(null);-    try {-      const enc = Buffer.from(ps, 'utf16le').toString('base64');-      execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', enc],-        { windowsHide: true, timeout }, (err, stdout) => resolve(err ? null : String(stdout || '').trim()));-    } catch (e) { resolve(null); }-  });-}-function _sanSid(sessionId) { return String(sessionId == null ? '' : sessionId).replace(/[\r\n"|]/g, ''); }-// Tag an OS window with its owning sessionId. Idempotent. Called whenever pup confirms a (sessionId,-// hwnd) pair (at birth and on every successful resolve), so every window pup knows carries its tag-// and stays re-findable after any handle loss.-async function tagWindowSession(hwnd, sessionId) {-  if (process.platform !== 'win32' || !hwnd || !sessionId) return false;-  const sid = _sanSid(sessionId);-  // v2.0.310: direct user32 SetProp through koffi; PowerShell below is only the no-koffi fallback.-  const _fast = win32.setProp(hwnd, `${PUP_WIN_PROP}=${sid}`);-  if (_fast !== null) return !!_fast;-  const ps = `Add-Type @"-using System;using System.Runtime.InteropServices;-public class PupTag { [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern bool SetProp(IntPtr h,string s,IntPtr v); }-"@-[void][PupTag]::SetProp([IntPtr]${Number(hwnd)}, "${PUP_WIN_PROP}=${sid}", [IntPtr]1)-Write-Output ok`;-  const r = await _runPsEncoded(ps, 6000);-  return r === 'ok';-}-// Re-find a session's OS window by its property tag — the deterministic recovery path (crash /-// respawn / bridge restart). Uses the reliable ab enumeration to list this profile's Chrome windows,-// then a plain GetProp probe per handle (NO EnumWindows delegate, NO geometry, NO nudge). Returns-// the hwnd whose "AdomPupSid=<sessionId>" property is set, or null. Exactly-one by construction: the-// property name encodes THIS sessionId, so only the window tagged for this session can match.-async function resolveHwndBySessionProp(sessionId) {-  if (process.platform !== 'win32' || !sessionId) return null;-  const sid = _sanSid(sessionId);-  // v2.0.310: one in-process EnumWindows + GetProp sweep (microseconds, no spawn, no ab call).-  const _fastH = win32.findWindowByProp(`${PUP_WIN_PROP}=${sid}`);-  if (_fastH !== null) return _fastH > 0 ? _fastH : null;-  // v2.0.308: the candidate list used to come from a FRESH ab enum (more enum spam). The property is-  // the identity, so enumerate locally and read it in the same script: one PowerShell, zero ab calls.-  const ps = `Add-Type @"-using System;using System.Runtime.InteropServices;using System.Text;-public class PupGP {-  public delegate bool EnumProc(IntPtr h, IntPtr l);-  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);-  [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr GetProp(IntPtr h,string s);-  [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n);-  public static long Find(string prop) {-    long found = 0;-    EnumWindows((h, l) => {-      var sb = new StringBuilder(64); GetClassName(h, sb, 64);-      if (sb.ToString() == "Chrome_WidgetWin_1" && GetProp(h, prop) != IntPtr.Zero) { found = h.ToInt64(); return false; }-      return true;-    }, IntPtr.Zero);-    return found;-  }-}-"@-Write-Output ([PupGP]::Find("${PUP_WIN_PROP}=${sid}"))`;-  const out = await _runPsEncoded(ps, 6000);-  const h = Number(String(out || '').trim().split(/\r?\n/)[0] || '0');-  return h && Number.isFinite(h) && h !== 0 ? h : null;-}-let _regenBusy = false;   // a dashboard "regenerate all overlays" sweep is in flight-let _lastRegen = null;    // { at, done, swept, painted, changed, results[] } from the last sweep-const _overlayRefreshTimers = new Map(); // per-session debounce for the centralized post-verb overlay re-sync-// v1.9.110 (issue #22, AdityaAngajala) — ADMISSION-TIME OWNERSHIP CLAIM.-// Two defects he measured: (a) `owner` is optional, so in practice sessions end up unowned and the guard-// that protects a busy window is opt-out by omission; (b) ownership was established when the BROWSER was-// acquired, not when the request was admitted, so two agents cold-starting the same sessionId were BOTH-// told to proceed and whichever launch landed first silently became the owner while the other believed it-// owned that window too. This map is claimed SYNCHRONOUSLY on the open path before any await, so the-// second differently-owned caller is refused immediately instead of receiving a provisioning hint that-// implies "retry and it's yours". Cleared on close. Ownership is still self-reported attribution, never-// authorization — it exists to stop threads trampling each other, not to secure anything.-const _sessionOwnerClaims = new Map();   // sessionId -> owner string-// v1.9.119 (John's design) — CLEANUP REPORT QUEUE. pup should tidy up on its own AND tell somebody it-// did. Silent janitorial work is invisible: John found four zombie sessions cluttering every thread's-// pup_list_windows and had to ask about them. So each cleanup is recorded here and attached to the-// NEXT verb response as `_cleanupReport`, then DROPPED (report-once). The thread that receives it is-// whichever one happens to call next, which is usually NOT the thread whose window was cleaned — that is-// accepted by design: it may be irrelevant to that caller and it can ignore it, but it reaches the user-// eventually, which beats never surfacing at all. Capped so a storm cannot bloat a response.-// v2.0.359 (issue #80). A PER-SESSION notice, because the janitor queue above is a BROADCAST and-// this case needs the opposite. When the bridge is upgraded or restarted, pup re-adopts the windows-// underneath every live session, and tabs can end up regrouped into a different session than the-// one that opened them. The thread that opened them sees tabs vanish with no close call and no-// error: it looks like pup lost their work. That is what happened to the "fort worth" thread while-// I was installing three builds in a row, and their report (#80) correctly said the fix is not to-// stop restarting but to TELL the affected session.-//-// Broadcast is wrong here: drainCleanupReports hands the message to whichever thread calls NEXT,-// which is usually not the thread that was affected. This attaches the notice to the session it-// happened to, and clears it once that session's own next verb carries it.-function noteSessionDisruption(session, msg) {-  try { if (session && msg) session._disruptionNotice = String(msg).slice(0, 300); } catch (e) {}-}-function drainSessionNotice(session) {-  try {-    if (!session || !session._disruptionNotice) return null;-    const m = session._disruptionNotice;-    session._disruptionNotice = null;-    return m;-  } catch (e) { return null; }-}-// Verbs whose ANSWER is a measurement or a picture, and is therefore silently wrong when a viewport-// override is in force. These carry the override warning; the rest do not need the noise.-const VIEWPORT_SENSITIVE = new Set([-  'pup_screenshot', 'pup_screenshot_full_res', 'pup_eval', 'pup_get_text',-  'pup_list_windows', 'pup_my_windows', 'pup_status', 'pup_open_window', 'pup_navigate', 'pup_reload',-]);-const _cleanupReports = [];-const CLEANUP_REPORT_MAX = 12;-function recordCleanup(msg) {-  try {-    if (!msg) return;-    _cleanupReports.push(String(msg).slice(0, 240));-    while (_cleanupReports.length > CLEANUP_REPORT_MAX) _cleanupReports.shift();-    console.log(`[janitor] ${msg}`);-  } catch (e) {}-}-function drainCleanupReports() {-  if (!_cleanupReports.length) return null;-  const items = _cleanupReports.splice(0, _cleanupReports.length);-  return `pup CLEANED UP after itself since your last call (reported once, then forgotten — it may not `-    + `concern YOUR windows, in which case ignore it, but please surface it to the user if it looks `-    + `relevant): ${items.map((m, i) => `(${i + 1}) ${m}`).join('  ')}`;-}---// ── Tab helpers ────────────────────────────────────────────────────-function createSession(profileName) {-  const session = {-    tabs: [],-    activeTabId: null,-    latestShot: null,-    profileName,-    tabCounter: 0,-    // v1.8.16: advisory ownership. Multiple AI threads share this bridge and-    // sessions are keyed only by sessionId — so one thread could silently-    // navigate away ("steal") a window another thread was using. `owner` is a-    // caller-declared label (pup_open_window {owner:"my-task"}); it can't-    // be authenticated, but it lets us REFUSE declared cross-owner takeovers-    // and reprimand anonymous ones with a loud hint.-    owner: null,-    createdAt: Date.now(),-    // v1.8.26: is this window CURRENTLY meant to be on the user's screen? false =-    // background (the hard default) — pup must never raise it (no bringToFront on-    // navigate/reload/switch_tab). Set true only when opened foreground:true or after-    // pup_raise_os_window, false again after pup_lower_os_window.-    _foreground: false,-    // Ring buffer of recently-closed tabs (capped at 20). Used by-    // resolveTabOrError to enrich tab_not_found errors with `lastKnownUrl`-    // when the bogus tabId matches a tab that closed in the last few seconds.-    // The common case: a popup PDF tab auto-closes after the viewer renders,-    // and the caller tries to eval/fetch against the stale tabId. We can now-    // surface the URL the popup was at so they can pup_fetch_url it-    // directly without having to re-trigger the click.-    recentlyClosedTabs: [],-  };-  // Legacy-compat getters: existing code that reads session.page / session.errors-  // transparently sees the active tab's page / errors.-  Object.defineProperty(session, 'page', {-    enumerable: false,-    get() { const t = getActiveTab(session); return t ? t.page : null; },-  });-  Object.defineProperty(session, 'errors', {-    enumerable: false,-    get() { const t = getActiveTab(session); return t ? t.errors : []; },-  });-  return session;-}--function nextTabId(session) {-  session.tabCounter = (session.tabCounter || 0) + 1;-  return `tab-${session.tabCounter}`;-}--function getActiveTab(session) {-  if (!session || !session.tabs || session.tabs.length === 0) return null;-  if (session.activeTabId) {-    const t = session.tabs.find(t => t.tabId === session.activeTabId);-    if (t) return t;-  }-  return session.tabs[0];-}--function getTabById(session, tabId) {-  if (!session || !tabId) return null;-  return session.tabs.find(t => t.tabId === tabId) || null;-}--// Attach page → tab to a session, wire error collection + console forwarding.-// Called by both launchSession (opening new window) and pup_open_tab.-//-// `opener` documents how the tab came to exist:-//   - "user" (default): Claude requested it via pup_open_window/pup_open_tab-//   - "popup":          Chrome opened it via window.open() / target=_blank — auto-tracked-//                       by the targetcreated listener wired in wirePopupTracking().-//                       openerTabId names the tab that triggered the popup.-// v1.9.48: debounced category re-check, fired by a tab's framenavigated. Debounced because a single-// user navigation emits several frame events (redirects, client-side routes) and each one would-// otherwise cost an AUMID re-register + re-stamp. Re-stamping is only done when the category ACTUALLY-// changed, so a settled window costs one cheap comparison.-const _catRecheckTimers = new Map();-function scheduleCategoryRecheck(sessionId) {-  if (process.platform !== 'win32') return;-  // v1.9.185 minimal-touch: no AUMID re-register / taskbar re-stamp on navigation. Every category-  // change re-registered an AppUserModelID and re-stamped the window — i.e. Windows destroyed and-  // rebuilt that taskbar button — on a SPA that route-changes constantly.-  if (minimalTouch()) return;-  clearTimeout(_catRecheckTimers.get(sessionId));-  _catRecheckTimers.set(sessionId, setTimeout(async () => {-    _catRecheckTimers.delete(sessionId);-    try {-      const s = sessions.get(sessionId);-      if (!s) return;-      const cat = pupCategory(sessionId);-      if (!cat || cat === s._lastCat) return;-      console.log(`[identity] "${sessionId}" category changed ${s._lastCat || 'none'} -> ${cat} (after navigation) — re-stamping`);-      s._lastCat = cat;-      updateThumbnailTooltip(s, sessionId).catch(() => {});-      for (let i = 0; i < 4; i++) {-        let ok = false;-        try { ok = await brandPupWindow(sessionId); } catch (e) {}-        if (ok) { s._badgedAt = Date.now(); return; }-        await new Promise(r => setTimeout(r, 900 * (i + 1)));-      }-      // Do NOT leave _lastCat claiming success: the stamp never landed, so the cached value would-      // suppress every future attempt and freeze the wrong icon permanently.-      s._lastCat = undefined;-      console.log(`[identity] "${sessionId}" post-navigation re-stamp exhausted retries (wanted ${cat}) — category cache cleared so a later trigger retries`);-    } catch (e) {}-  }, 1500));-}--// Make a pup-driven tab IMMUNE to debugger pauses. A page's `debugger;` statement (or a stray-// breakpoint) only pauses when the Debugger domain is enabled — but when it IS enabled (by a stale-// CDP client, a previously-opened DevTools, whatever), a single `debugger;` FREEZES the renderer,-// and because sibling tabs share the process the freeze locks the WHOLE window. John hit exactly-// this: the "Debugger paused in another tab" banner appeared and the wiki 3D viewer stopped-// responding. pup drives via CDP for AUTOMATION, never to debug page code, so it must never honor a-// pause. We enable Debugger, tell V8 to skip ALL pauses, and resume anything already frozen — then-// keep the session attached so the skip stays in force for this tab's whole life.-async function neutralizeDebugger(page) {-  try {-    const cdp = await page.target().createCDPSession();-    await cdp.send('Debugger.enable').catch(() => {});-    await cdp.send('Debugger.setSkipAllPauses', { skip: true }).catch(() => {});-    await cdp.send('Debugger.resume').catch(() => {}); // clears any pause inherited at attach time-    // v1.9.347 (John: "a heavy page should NEVER crash your bridge; you're mishandling the CDP interface").-    // The 1.9.341 version KEPT the Debugger domain enabled for the tab's whole life. That makes Chrome-    // stream a Debugger.scriptParsed event for EVERY script the page ever parses — on a heavy bundle (a-    // Babylon.js 3D viewer is thousands of scripts) that is a flood of CDP messages processed on the-    // bridge's single event loop, plus V8 runs de-optimized while Debugger is on. Under concurrent-    // heavy-page opens it starved the loop so AD could not reach the bridge ("error sending request").-    // FIX: DISABLE Debugger right after clearing any inherited pause. With no client holding the domain,-    // a page's `debugger;` is a no-op anyway — so we keep the freeze immunity WITHOUT the scriptParsed-    // flood or the de-opt, and we detach the session instead of leaking one per tab.-    await cdp.send('Debugger.disable').catch(() => {});-    await cdp.detach().catch(() => {});-  } catch (e) {}-}--// v2.0.141 (John: "surf as normal consumer chrome — that's our right, this really is me surfing-// from a normal chrome, we just use CfT so we can automate it"). Chrome for Testing's UA STRING is-// already indistinguishable from consumer Chrome, but its CLIENT-HINTS brands are not: CfT reports-// only [Not/A)Brand, Chromium] while consumer Google Chrome includes a "Google Chrome" brand.-// Cloudflare/Akamai read exactly that Sec-CH-UA brand list and wall the missing-Google-Chrome case-// as a non-consumer browser (measured live: TI/ST/ams load, but DigiKey/Mouser/Arrow/SnapEDA/CSE/-// Octopart all served "Just a moment…"). This override makes every page present as Google Chrome —-// UA header AND the userAgentData brands, kept CONSISTENT (a UA/brand mismatch is itself a bot-// tell) — via CDP Network.setUserAgentOverride (what page.setUserAgent(ua, meta) drives). The major-// version is read from the live UA so a CfT bump never drifts it. This is not spoofing a different-// device; it is the same Windows Chrome the user runs, correctly declaring itself as Google Chrome.-let _uaOverride = null;   // { ua, metadata } — computed once from the running browser-async function computeConsumerUA(page) {-  if (_uaOverride) return _uaOverride;-  let ua = '';-  try { ua = await page.browser().userAgent(); } catch (e) {}-  if (!ua) { try { ua = await page.evaluate(() => navigator.userAgent); } catch (e) {} }-  if (!ua) return null;-  const m = ua.match(/Chrome\/(\d+)(?:\.\d+)*/);-  const major = m ? m[1] : '148';-  const full = (ua.match(/Chrome\/([\d.]+)/) || [])[1] || (major + '.0.0.0');-  // Consumer Chrome's brand triplet (GREASE brand + Chromium + Google Chrome). Order is not-  // load-bearing; the PRESENCE of "Google Chrome" is what CF checks.-  const brands = [-    { brand: 'Not/A)Brand', version: '99' },-    { brand: 'Google Chrome', version: major },-    { brand: 'Chromium', version: major },-  ];-  _uaOverride = {-    ua,-    metadata: {-      brands,-      fullVersionList: [-        { brand: 'Not/A)Brand', version: '99.0.0.0' },-        { brand: 'Google Chrome', version: full },-        { brand: 'Chromium', version: full },-      ],-      fullVersion: full,-      platform: 'Windows', platformVersion: '15.0.0',-      architecture: 'x86', bitness: '64', model: '', mobile: false,-      wow64: false,-    },-  };-  return _uaOverride;-}-async function applyConsumerUA(page) {-  try {-    const o = await computeConsumerUA(page);-    if (!o) return;-    // page.setUserAgent(ua, metadata) issues Network.setUserAgentOverride with userAgentMetadata,-    // which drives BOTH the UA request header and navigator.userAgentData / Sec-CH-UA consistently.-    await page.setUserAgent(o.ua, o.metadata);-  } catch (e) { /* best-effort — a page that refuses the override just keeps CfT's brands */ }-}-// v2.0.143 (John's "the import never worked" — traced): AD RENAMED the SYSTEM-elevated decrypt-// verb from desktop_decrypt_pup_credentials to desktop_decrypt_browser_credentials, but pup still-// called the OLD name, so every import died with unknown_verb (which the dashboard swallowed —-// exactly why it looked like it never worked). Call the CURRENT name first; fall back to the old-// one on unknown_verb so pup keeps working on an older AD. Both are LOCAL-only (relay_forbidden);-// pup calls over the loopback direct API so it is allowed, and AD fires its own consent + UAC.-async function decryptBrowserCreds(argsObj) {-  const names = ['desktop_decrypt_browser_credentials', 'desktop_decrypt_pup_credentials'];-  let last = null;-  for (const n of names) {-    const resp = await chrome.abCommand(n, argsObj, { timeoutMs: 180000 });-    const p = abPayload(resp) || {};-    const ec = p.errorCode || (resp && resp.errorCode);-    if (ec === 'unknown_verb') { last = resp; continue; }   // wrong name on this AD — try the next-    return resp;-  }-  return last;-}-// ANY pending dialog, anywhere (v2.0.353). John, watching it happen: "it stops my other pup windows-// from even foregrounding so i get stuck on all pup windows cuz of one lame javascript alert dialog-// box." He is right, and it changes the shape of this: pup's windows share ONE Chrome for the-// adom-you profile, so a modal does not block its own window, it blocks the BROWSER. The window-// holding the dialog is therefore usually NOT the window whose verb just failed, and scanning only-// the caller's session finds nothing while every window is stuck.-// ── v2.0.383 (#59/#64): MEASURED progress for slow verbs ─────────────────────────────────-// Rolling per-machine timing history (cold = a browser process was spawned; warm = a window was-// added to a live browser), so estimatedSec is a real median that narrows as samples arrive, and-// confidence is 'measured' only once there are enough of them. Exposed in pup_describe.-const TIMINGS_PATH = path.join(os.homedir(), '.adom', 'pup-timings.json');-function _readTimings() { try { return JSON.parse(fs.readFileSync(TIMINGS_PATH, 'utf8')); } catch (e) { return {}; } }-function pupTimingRecord(kind, ms) {-  try {-    const t = _readTimings(); const arr = Array.isArray(t[kind]) ? t[kind] : [];-    arr.push(Math.round(ms)); while (arr.length > 20) arr.shift(); t[kind] = arr;-    fs.mkdirSync(path.dirname(TIMINGS_PATH), { recursive: true }); fs.writeFileSync(TIMINGS_PATH, JSON.stringify(t));-    console.log(`[launch] timing recorded ${kind}=${Math.round(ms)}ms (${arr.length} samples)`);-  } catch (e) { console.log(`[launch] timing record FAILED for ${kind}: ${e.message}`); }-}-function pupTimingEstimate(kind) {-  const arr = (_readTimings()[kind] || []).slice().sort((a, b) => a - b);-  if (!arr.length) return { estimatedSec: null, samples: 0, confidence: 'unknown' };-  const med = arr[Math.floor(arr.length / 2)];-  return { estimatedSec: Math.max(1, Math.round(med / 1000)), samples: arr.length, confidence: arr.length >= 3 ? 'measured' : 'estimated' };-}-function pupTimingsSummary() { const t = _readTimings(); const o = {}; for (const k of Object.keys(t)) o[k] = pupTimingEstimate(k); return o; }-let _lastLaunchAt = 0, _lastLaunchMs = 0;          // set where the browser process is actually spawned-const _inflightOpens = new Map();                  // id -> { t0, sessionId, thread }-// v2.0.397 (#92): consent toasts live in a registry (src/consent.js) so a verb parked on the-// human's click answers blocked_on_user (stillRunning:true) inside every ab budget instead of-// timing out, and a re-call re-attaches to the same toast. _pendingConsents stays the readiness map.-const _consentMod = require('./consent.js');-const consent = _consentMod.createRegistry({-  abCommand: (v, a, o) => chrome.abCommand(v, a, o), abPayload: (r) => abPayload(r),-  log: (rec, decision, retract) => console.log(`[consent] ${rec.verb} ${decision ? 'ANSWERED "' + decision + '"' : 'TIMED OUT'} (${rec.id}, attached ${rec.attached}x)${decision ? '' : ` — toast retract dismissed=${retract}`}`),-});-const blockedOnUser = _consentMod.blockedOnUser;-const _pendingConsents = consent.map;                // consentId -> { since, verb, what, decided, ... }-// Terminal frame for a finished open (percent is 100 by definition; actuals + startKind ride along).-function openProgressFrame(t0) {-  const launched = _lastLaunchAt >= t0;-  const kind = launched ? 'cold' : 'warm';-  const el = Date.now() - t0; pupTimingRecord('open_window_' + kind, el);-  const est = pupTimingEstimate('open_window_' + kind);-  return { phase: launched ? 'cold_start' : 'open_window', stepLabel: launched ? 'Launched the browser and opened the window' : 'Opened the window in the running browser',-    percent: 100, elapsedSec: Math.round(el / 1000 * 10) / 10, estimatedSec: est.estimatedSec, etaSec: 0, confidence: est.confidence, startKind: kind,-    launchMs: launched ? _lastLaunchMs : undefined };-}-// Live rows for the fast status verb (pup_readiness): one per in-flight phase. Empty = idle.-function activeProgress() {-  const rows = []; const now = Date.now();-  for (const [id, o] of _inflightOpens) {-    if (now - o.t0 > 180000) { _inflightOpens.delete(id); continue; }-    const kind = (_lastLaunchAt >= o.t0) ? 'cold' : 'warm';-    const est = pupTimingEstimate('open_window_' + kind); const el = (now - o.t0) / 1000;-    const pct = est.estimatedSec ? Math.min(95, Math.round(el / est.estimatedSec * 100)) : null;-    rows.push({ id, verb: 'pup_open_window', phase: kind === 'cold' ? 'cold_start' : 'open_window', stepLabel: (kind === 'cold' ? 'Launching the browser' : 'Opening the window') + (o.sessionId ? ` (${o.sessionId})` : ''),-      percent: pct, elapsedSec: Math.round(el), estimatedSec: est.estimatedSec, etaSec: est.estimatedSec ? Math.max(0, Math.round(est.estimatedSec - el)) : null, confidence: est.confidence, startKind: kind });-  }-  try {-    const rd = chrome && chrome.readiness ? chrome.readiness() : null;-    if (rd && rd.installing && rd.installPhase === 'unpacking') {-      // v2.0.398: the unpack is its own phase (it used to sit at 'download 100%' for minutes).-      const est = rd.bytesTotal ? Math.round(rd.bytesTotal * 2.3) : 0;-      const pct = est ? Math.min(99, Math.round((rd.unpackBytes || 0) / est * 100)) : null;-      rows.push({ id: 'cft-unpack', verb: 'pup_prewarm', phase: 'unpack', stepLabel: `Unpacking Chrome for Testing${rd.unpackMethod ? ' (' + rd.unpackMethod + ')' : ''}`, percent: pct,-        bytes: rd.unpackBytes || 0, elapsedSec: rd.unpackElapsedSec, estimatedSec: null, etaSec: null, confidence: 'estimated', startKind: 'cold' });-    } else if (rd && rd.installing) rows.push({ id: 'cft-download', verb: 'pup_prewarm', 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' });-  } catch (e) {}-  for (const row of consent.rows(now)) rows.push(row);-  try {-    const dlg = findAnyPendingDialog();-    if (dlg) rows.push({ id: 'dialog-' + (dlg.sessionId || 'any'), verb: 'pup_dialog', phase: 'dialog', blockedOnUser: true, stepLabel: 'A browser dialog is up' + (dlg.sessionId ? ` in ${dlg.sessionId}` : ''), percent: null, confidence: 'measured', blockedBy: 'a browser dialog (pup_dialog to answer it)' });-  } catch (e) {}-  return rows;-}-// ── v2.0.386 (#83 ask 3): IDENTITY AUDIT — read back what the OS window is ACTUALLY stamped with-// and compare it to what pup believes (_curAppId). A mismatch (a stale generation, a stamp that-// never landed, a window Chrome recreated) is re-stamped through the normal path, which also-// re-registers and nudges the button. One PowerShell per sweep for every live hwnd; every 5 min.-function readStampedAumids(hwnds) {-  return new Promise((resolve) => {-    if (process.platform !== 'win32' || !hwnds.length) return resolve({});-    const ps = `$code=@'-using System; using System.Runtime.InteropServices;-public static class AumidRead {-  [StructLayout(LayoutKind.Sequential)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }-  [StructLayout(LayoutKind.Sequential)] public struct PROPVARIANT { public ushort vt; public ushort r1; public ushort r2; public ushort r3; public IntPtr p; public IntPtr p2; }-  [ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]-  public interface IPropertyStore { int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY pk); int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv); int SetValue(ref PROPERTYKEY key, ref PROPVARIANT pv); int Commit(); }-  [DllImport("shell32.dll")] public static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out IPropertyStore pps);-  static Guid AUM = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");-  static Guid IID = new Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99");-  public static string Read(IntPtr hwnd) {-    IPropertyStore ps; if (SHGetPropertyStoreForWindow(hwnd, ref IID, out ps) != 0) return null;-    PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = AUM; k.pid = 5;-    PROPVARIANT pv; if (ps.GetValue(ref k, out pv) != 0) return null;-    return (pv.vt == 31 && pv.p != IntPtr.Zero) ? Marshal.PtrToStringUni(pv.p) : null;-  }-}-'@-Add-Type -TypeDefinition $code-foreach($h in @(${hwnds.join(',')})){ $a=[AumidRead]::Read([IntPtr]$h); Write-Output ("$h" + [char]9 + $a) }`;-    execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 20000 }, (err, stdout) => {-      const out = {};-      if (!err) for (const line of String(stdout || '').split(/\r?\n/)) { const [h, a] = line.split('\t'); if (h) out[String(h).trim()] = (a || '').trim() || null; }-      resolve(out);-    });-  });-}-async function auditStampedIdentities() {-  if (process.platform !== 'win32' || pupSettings().aumidIcons !== 'on') return;-  const targets = [];-  for (const [sid, s] of sessions) { if (s && s._hwnd && s._curAppId) targets.push({ sid, hwnd: String(s._hwnd), expect: s._curAppId }); }-  if (!targets.length) return;-  const actual = await readStampedAumids(targets.map(t => t.hwnd));-  let mismatches = 0;-  for (const t of targets) {-    const got = actual[t.hwnd];-    if (got === undefined) continue;                       // window not readable this pass (gone, or PS failed)-    if (got === t.expect) continue;-    mismatches++;-    console.log(`[identity] AUDIT MISMATCH "${t.sid}": stamped=${got || '(none)'} expected=${t.expect} — re-stamping (#83)`);-    const s = sessions.get(t.sid); if (!s) continue;-    s._curAppId = null; s._recoveryIdentityVerified = true;-    stampPupIdentity(t.sid, { _commit: true }).catch(() => {});-  }-  if (!mismatches) console.log(`[identity] audit ok: ${targets.length} window(s) stamped as expected`);-}-function findAnyPendingDialog() {-  for (const [sid, session] of sessions) {-    const d = findPendingDialog(session);-    if (d) return { ...d, sessionId: sid };-  }-  return null;-}-// The one pending native dialog on a session, if any. A modal blocks the WHOLE window, so it does-// not matter which tab owns it: the first one found is the one the caller is stuck behind.-// v2.0.397 (#92): the walls a page puts up that only a human can pass. Cheap DOM heuristics,-// read in the background; used by pup_login after a submit. Returns { kind, evidence } | null.-async function detectHumanChallenge(page) {-  if (!page) return null;-  const r = await Promise.race([-    page.evaluate(() => {-      const q = (sel) => { try { return document.querySelector(sel); } catch (e) { return null; } };-      const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };-      const otp = q('input[autocomplete="one-time-code"]') || q('input[name*="otp" i]') || q('input[id*="otp" i]') || q('input[name*="verification" i][inputmode="numeric"]') || q('input[name*="mfa" i]') || q('input[name*="totp" i]') || q('input[name*="2fa" i]');-      if (vis(otp)) return { kind: 'two_factor', evidence: 'one-time-code input: ' + (otp.getAttribute('name') || otp.getAttribute('id') || otp.getAttribute('autocomplete')) };-      const capFrame = Array.from(document.querySelectorAll('iframe')).find(f => /recaptcha|hcaptcha|turnstile|arkose|funcaptcha/i.test(f.src || ''));-      if (capFrame && vis(capFrame)) return { kind: 'captcha', evidence: 'captcha iframe: ' + new URL(capFrame.src).hostname };-      const t = (document.body && document.body.innerText || '').slice(0, 20000);-      if (/verification code|two[- ]factor|2-step verification|authenticator app|enter the code we sent|security code|one-time (pass)?code/i.test(t) && !q('input[type="password"]')) return { kind: 'two_factor', evidence: 'page text mentions a verification code and no password field is present' };-      if (/i'?m not a robot|prove you are human|verify you are human|security check/i.test(t)) return { kind: 'captcha', evidence: 'page text asks for a human check' };-      return null;-    }),-    new Promise((rs) => setTimeout(() => rs(null), 4000)),-  ]);-  return r || null;-}-function findPendingDialog(session) {-  if (!session || !Array.isArray(session.tabs)) return null;-  for (const t of session.tabs) {-    if (t && t.pendingDialog) return { ...t.pendingDialog, tabId: t.tabId };-  }-  return null;-}--function attachTab(session, sessionId, page, { makeActive = true, opener = 'user', openerTabId = null } = {}) {-  const tabId = nextTabId(session);-  const errors = [];-  setupErrorCollection(page, errors);-  // v2.0.141: present as consumer Google Chrome on EVERY tab (see computeConsumerUA).-  applyConsumerUA(page);-  // Immunize this tab against debugger-statement / breakpoint freezes (see neutralizeDebugger).-  neutralizeDebugger(page);-  // v1.9.78 — SURVIVE A RENDERER CRASH (wiki issue #16). Under many heavy-SPA tabs plus rapid-  // navigate/eval/screenshot churn, one tab's renderer can crash (Chrome's per-tab 'Aw, Snap').-  // puppeteer surfaces that as the page 'error' event; we mark the tab so every verb FAST-FAILS-  // with a clear 'reload this tab' instead of hanging for 20s and wedging the whole window. The-  // crash is isolated to the tab — the window and its other tabs stay drivable.-  const _markCrashed = (why) => {-    try {-      const t = (session.tabs || []).find(x => x.page === page);-      if (t && !t._crashed) {-        t._crashed = true; t._crashedAt = Date.now(); t._crashReason = why;-        // v2.0.236: wake anything racing this tab's in-flight verb (replaces a 250ms poll)-        try { const w = t._crashWaiters || []; t._crashWaiters = []; w.forEach(fn => { try { fn(); } catch (e) {} }); } catch (e) {}-        try { const u = page.url(); if (u && u !== 'about:blank') t._crashUrl = u; } catch (e) {}-        console.log(`[crash] session "${sessionId}" tab ${t.tabId} renderer CRASHED (${why}) — auto-recovering; verbs fast-fail until it's back`);-        // v1.9.176 — AUTO-HEAL the crashed renderer ONCE. An 'Aw, Snap' tab is already dead (it shows-        // Chrome's error page), so respawning it loses nothing and is pure recovery — exactly the-        // resilience John asked for. Cap at one auto-attempt: if it re-crashes (memory still pressured),-        // stop and leave it flagged so the caller decides, rather than spinning a reload-crash loop.-        if ((t._crashAutoAttempts || 0) < 1) {-          t._crashAutoAttempts = (t._crashAutoAttempts || 0) + 1;-          const rurl = t._crashUrl;-          setTimeout(async () => {-            try {-              if (!t._crashed) return;   // a verb already recovered it-              const ok = await respawnCrashedRenderer(page, rurl);-              if (ok) {-                t._crashed = false; delete t._crashedAt; delete t._crashReason;-                console.log(`[crash] session "${sessionId}" tab ${t.tabId} AUTO-recovered (renderer respawned)`);-                recordCleanup(`pup auto-recovered a crashed ("Aw, Snap! Out of Memory") tab in session "${sessionId}" by respawning its renderer${rurl ? ` at ${rurl}` : ''} — the tab had run out of memory (usually many heavy tabs sharing one Chrome); the window is back on its own.`);-              } else {-                console.log(`[crash] session "${sessionId}" tab ${t.tabId} auto-recover did not take — left flagged for manual pup_reload`);-              }-            } catch (e) {}-          }, 2500);-        }-      }-    } catch (e) {}-  };-  try { page.on('error', (err) => _markCrashed((err && err.message) || 'Page crashed!')); } catch (e) {}-  // v1.9.185 (John: "what are you doing that's so different controlling Chrome that you cause-  // crashes?" — this was one real answer): the v1.9.78 crash detector opened a SECOND CDP session-  // per tab and NEVER detached it. Every tab therefore held an extra attached debugger session for-  // its whole life, accumulating in the browser process — cross-process state your normal Chrome-  // never carries. puppeteer's page 'error' event already fires on renderer crash (it is driven by-  // the same Inspector.targetCrashed on the page's OWN session), so the extra session bought-  // nothing. Removed: one less permanent CDP attachment per tab, and one less thing pup does TO-  // Chrome that Chrome was not designed to have done to it continuously.-  // v1.8.17: PERSISTENT title-suffix injector. AD's pup_raise/lower_os_window-  // (AD-core verbs, not this bridge — they shadow our fallback handlers) locate the-  // OS window by its " (session: <id>)" title suffix. Post-goto page.evaluate re-adds-  // the suffix, but a NEW page sets its own title BEFORE that runs → a brief window-  // where the suffix is absent and AD's lookup fails ("No visible window with title-  // containing …") right after a navigation. evaluateOnNewDocument installs the-  // enforcer at document-start on EVERY navigation (before page scripts), so the-  // suffix reappears the instant the page sets its title — closing that gap.-  try {-    page.evaluateOnNewDocument((sid, minimal, label, tagOn) => {-      // v1.9.185: carry minimal-touch into the page so the title observer below can skip installing-      // itself (see the note at `observe`). Read at document-start, before any page script runs.-      try { if (minimal) window.__pupMinimalTouch = true; } catch (e) {}-      // TOLERANT marker check (v1.8.19): a tab's own suffix may be-      // " (session: <sid>)" OR " (session: <sid> | <tabId>)". This injector must-      // only ADD a marker when NONE is present — if it enforced an exact-      // " (session: <sid>)" it would fight addTabToSession's " | <tabId>" suffix:-      // two MutationObservers appending different strings ping-pong forever, peg the-      // renderer, and flood the CDP pipe (which HUNG pup_open_tab on Edge and-      // poisoned the whole bridge). Matching on the marker PREFIX makes it cooperate.-      const LBL = label || 'ai-thread';-      const MARKER = ' (' + LBL + ': ' + sid;   // no closing paren — matches both forms-      const BASE = ' (' + LBL + ': ' + sid + ')';-      // v1.9.49: do NOT require a pre-existing title. This guard used to be-      //     if (document.title && ...)-      // so a page that never sets a <title> (plenty of app routes and SPA shells serve one) kept an-      // EMPTY title, the session marker was never injected, and the OS window title stayed a bare-      // "Google Chrome for Testing". AD resolves pup windows by their "(session: <id>)" suffix, so-      // that window became INVISIBLE to every branding call: identity stamp, category flip, overlay-      // and jump list all failed with "No visible window with title containing (session: ...)".-      // That is the failure behind icons that look stale or never update — the window was findable-      // right up until it navigated to a title-less page. An empty title must still get the marker.-      // v1.9.56: SELF-HEALING against DUPLICATES. The old check only asked "is my marker absent?",-      // so once a second copy appeared it was never collapsed — measured live as-      //   "○ Public · Hydrogen Desktop - Adom Wiki (session: hdr-wiki) (session: hdr-wiki)"-      // (107-char title). Two independent MutationObservers touch this title (this session-suffix-      // injector and the wiki state-glyph injector); a page that rewrites its own title can let both-      // fire before either sees the other's write, and each appends. Duplicated tags bloat the-      // taskbar-thumbnail CAPTION (the surface we want for status) and, worse, AD resolves pup windows-      // by "(session: <id>" — the same duplicate-tag class that previously made branding land on the-      // WRONG window. So: count OUR OWN tag and normalise to exactly one. Only our own sid is touched,-      // so an adopted tab's re-tag (retagSessionTitle) is never fought.-      const RE_MINE = new RegExp('\\s*\\((?:session|ai-thread): ' + sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[^)]*)\\)', 'g');-      const ensure = () => {-        try {-          const cur = String(document.title || '');-          if (!tagOn) { const clean = cur.replace(RE_MINE, ''); if (clean !== cur) document.title = clean; return; }  // OFF: keep title clean-          const n = (cur.match(RE_MINE) || []).length;-          if (n === 1) return;                                   // already exactly one — leave it alone-          if (n === 0) { document.title = cur + BASE; return; }  // none — add ours-          document.title = cur.replace(RE_MINE, '') + BASE;      // duplicates — collapse to one-        } catch (e) {}-      };-      const observe = () => {-        // v1.9.288: when the tag is OFF we strip once (above) but must NOT run a stripping observer --        // it would instantly remove the TRANSIENT tag pup adds to resolve the window's hwnd, forcing-        // ambiguous geometry (two same-profile windows share a rect => same hwnd, John's dup-hwnd bug).-        if (!tagOn) return true;-        // v2.0.20: ONLY a real <title> node. The old head/documentElement fallback observed the-        // ENTIRE document subtree on titleless pages (raw JSON/API views), firing ensure() on every-        // DOM mutation — a renderer-pegging cost on exactly the pages least able to afford it. A-        // titleless page keeps its once-stamped tag; continuous enforcement is not worth a freeze.-        const t = document.querySelector('title');-        if (!t) return true;-        // v1.9.185 minimal-touch: in MINIMAL mode we stamp the title ONCE and do NOT install a-        // MutationObserver. On a heavy SPA (Google Slides/Sheets rewrite document.title constantly)-        // this observer re-fired on every one of those writes, each time writing the title BACK —-        // an in-page write loop pup itself created, on the exact windows John saw freeze. The-        // window still carries the tag from the initial stamp; only the continuous re-enforcement-        // is dropped. (window.name, set below, is the durable identity anyway.)-        if (window.__pupMinimalTouch) return true;-        try { window.__pupTagObs = new MutationObserver(ensure); window.__pupTagObs.observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {}-        return true;-      };-      ensure();-      if (!observe()) document.addEventListener('DOMContentLoaded', () => { observe(); ensure(); });-    }, sessionId, minimalTouch(), TITLE_LABEL, pupSettings().titleTag === 'on').catch(() => {});-  } catch (e) {}-  // v1.9.284 (John: "a small floating in-window chip that indicates what ai-thread owns this tab,-  // what profile ... it can't get in the way though"). A tiny corner pill showing owner-thread and-  // profile. Design so it can NEVER get in the way or disturb his vibe-coded pages:-  //   - attached to <html> (survives SPA <body> swaps) inside a CLOSED shadow root, so page CSS/JS-  //     can neither style it nor see it, and it cannot affect the page's layout or selectors;-  //   - pointer-events:none, so every click passes straight through it;-  //   - one-shot (no observers/intervals fighting the page), and it self-dims after a few seconds.-  // Gated by the ownerChip setting (default on). Value strips a leading "ai-" so it reads as the-  // plain thread name, matching the new "ai-thread" title label.-  try {-    if (pupSettings().ownerChip === 'on') {-      const _threadDisp = String(sessionId).replace(/^ai-/, '');-      const _prof = (session && session.profileName) || 'adom-you';-      // v1.9.343 (John: "put my Adom profile picture in there — show my adom identity"). Chrome's own-      // profile chip cannot take a custom local image (only a real Google sign-in can — tested), so we-      // show the user's Adom identity HERE instead: the owner chip leads with the user's Adom avatar-      // (set via pup_set_identity → fetched, circle-cropped, embedded as a data URI in settings).-      // Falls back to the ⬢ glyph when no identity is configured.-      const _avatar = pupSettings().identityAvatarB64 || '';-      const _ident = pupSettings().identityName || '';-      page.evaluateOnNewDocument((thread, profile, avatar, ident) => {-        try {-          const add = () => {-            try {-              const root = document.documentElement; if (!root) return;-              if (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 = avatar-                ? '<img class="av" alt="" src="data:image/png;base64,' + avatar + '">'-                : '<span class="hx">⬢</span>';-              const html = '<style>.c{display:flex;align-items:center;gap:6px;font:11px/1.35 "JetBrains Mono",ui-monospace,SFMono-Regular,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;transition:opacity .5s ease;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}.dim{opacity:.3}</style>'-                + '<div class="c" id="pupc" title="' + (ident ? String(ident) + ' — ' : '') + 'Adom pup">' + lead + '<span><b>' + String(thread) + '</b> · ' + String(profile) + '</span></div>';-              if (sh) { sh.innerHTML = html; } else { host.innerHTML = html; }-              root.appendChild(host);-              const el = sh ? sh.getElementById('pupc') : host.querySelector('#pupc');-              setTimeout(() => { try { if (el) el.classList.add('dim'); } catch (e) {} }, 4500);-            } catch (e) {}-          };-          if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true });-          else add();-        } catch (e) {}-      }, _threadDisp, _prof, _avatar, _ident).catch(() => {});-    }-  } catch (e) {}-  // v1.8.21: install the shadow-DOM + scroll helpers on every document so pup_eval-  // can use $deep / $$deep / deepText without hand-rolling a shadow-root walk.-  if (process.platform === 'win32') {-    // v2.0.72: PRECACHE the annotate module in every page so the pencil and hotkey open the-    // toolbar instantly, client-side, with zero bridge round-trip (John: it was laggy).-    try { page.evaluateOnNewDocument(ANNOTATE_OVERLAY_JS).catch(() => {}); page.evaluate(ANNOTATE_OVERLAY_JS).catch(() => {}); } catch (e) {}-    // v2.0.95: always injected — the script self-configures from the baked flags (floaty on/off,-    // hotkey on/off), and injecting the OFF build is what tears an elder pencil out of the page.-    const fj = floatyJs();-    try { page.evaluateOnNewDocument(fj).catch(() => {}); page.evaluate(fj).catch(() => {}); } catch (e) {}-    // v2.0.102: cleanup-only — strip any title prefix an elder bridge's injected keeper left-    // (their evaluateOnNewDocument scripts persist on long-lived pages and run first; this-    // registers after them, so the strip wins).-    const tjClean = titleJs('');-    try { page.evaluateOnNewDocument(tjClean).catch(() => {}); page.evaluate(tjClean).catch(() => {}); } catch (e) {}-    // v2.0.110: login-capture rail — records credentials as they are submitted so the keychain-    // vault grows the adom-you identity automatically (John authorized). Self-guarded, page-local.-    // A synchronous Puppeteer binding stores the cred BEFORE the submit navigates away (a-    // navigation would wipe the page global); the sweep-harvested global is the SPA fallback.-    // v2.0.315: the corner-pencil click PUSHES (binding) instead of being polled every 4s per tab.-    page.exposeFunction('__pupFloatyPush', (tabHint) => {-      try {-        const sF = sessions.get(sessionId); if (!sF) return;-        if (sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) return;-        sF._annotateArmed = Date.now();-        const tF = (sF.tabs || []).find(t => t && t.page === page);-        console.log(`[floaty] "${sessionId}" pencil clicked (push) — arming capture watcher on ${tF ? tF.tabId : '?'}`);-        startAnnotation(sF, sessionId, 'user-floaty', { tool: 'pen', tabId: tF ? tF.tabId : null }).catch(() => {});-      } catch (e) {}-    }).catch(() => {});-    page.exposeFunction('__pupCredCapture', (json) => {-      (async () => {-        try {-          if (pupSettings().captureLogins === 'off') return;-          const rec = JSON.parse(json || '{}');-          if (!rec.host || !rec.p) return;-          await credentialVault.setCredential({ host: rec.host, username: rec.u || '', password: rec.p });-          console.log(`[cred] captured login for ${rec.host}${rec.u ? ` (${rec.u})` : ''} on "${sessionId}" → keychain vault`);-          const sNow = sessions.get(sessionId);-          if (sNow) sNow._pendingCredHint = `pup CAPTURED this login for "${rec.host}"${rec.u ? ` (username ${rec.u})` : ''} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit auto-fills in the background with no Chrome dropdown and no foregrounding; submitting stays reason-gated. 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.`;-        } catch (e) { console.log(`[cred] binding store failed: ${e.message}`); }-      })();-    }).catch(() => { /* already registered on this page (re-attach) — fine */ });-    try { page.evaluateOnNewDocument(CAPTURE_LOGIN_JS).catch(() => {}); page.evaluate(CAPTURE_LOGIN_JS).catch(() => {}); } catch (e) {}-  }-  try { page.evaluateOnNewDocument(PUP_DEEP_HELPERS).catch(() => {}); } catch (e) {}-  // v1.9.48: re-evaluate the window CATEGORY when this tab finishes navigating.-  //-  // Category used to be recomputed ONLY inside updateTabCountBadge, i.e. only when a tab VERB ran.-  // A tab's URL is not known at that instant — the page is still about:blank while the navigation-  // is in flight — so the category was computed from the tabs that happened to have loaded, cached-  // in _lastCat, and then never revisited, because nothing re-checks after a page settles. Opening-  // two tabs back-to-back reliably froze a wiki+web+app window on the plain "wiki" icon instead of-  // "mixed" (caught by the taskbar audit as r1: 3 tabs, registered wiki).-  // The navigation event is the correct trigger: it is exactly when a tab's category becomes known.-  try {-    page.on('framenavigated', (frame) => {-      try { if (frame !== page.mainFrame()) return; } catch (e) { return; }-      scheduleCategoryRecheck(sessionId);-      // New URL → give favicon detection a fresh start: a page that had none may now have one.-      try { session._overlayGenericFinal = false; session._overlayGenericTries = 0; } catch (e) {}-      // v1.9.324 (John: "EVERY verb OR EVENT that could change an icon MUST trigger a recalc").-      // The favicon OVERLAY badge was only recomputed by explicit MUTATING_VERBS. Any navigation-      // pup did not itself initiate — a JS redirect, a <meta> refresh, a form submit, an SPA route-      // change, an OAuth bounce, a link click INSIDE the tab, pup_login's submit — changes the-      // page favicon but fired no verb, so the taskbar kept a STALE badge. THAT is the drift John-      // has caught a dozen times. The navigation event is the true trigger: recompute here, for the-      // active/visible tab (applyAppOverlay re-resolves the visible tab), debounced ~2s so a redirect-      // chain or route-spammy SPA collapses to one repaint, and gated on the overlayBadges pref.-      scheduleOverlayRefresh(sessionId);-    });-  } catch (e) {}-  // v1.8.45: track when the USER activates this window (e.g. clicks its taskbar button to-  // watch it) so pup can report per-window flash state PROGRAMMATICALLY. The window 'focus'-  // event fires when the OS window is activated; we stamp the time. A focus AFTER the last-  // flash means the user cleared that flash. Timestamps use the desktop's own clock — the-  // same machine the bridge runs on — so they're directly comparable to _flashState.-  const PUP_FOCUS_HOOK = () => {-    try {-      if (window.__pupFocusHook) return; window.__pupFocusHook = true;-      if (typeof window.__pupFocusTs !== 'number') window.__pupFocusTs = 0;-      window.addEventListener('focus', () => { try { window.__pupFocusTs = Date.now(); } catch (e) {} }, true);-    } catch (e) {}-  };-  try {-    // Future documents (navigations) get it at document-start…-    page.evaluateOnNewDocument(PUP_FOCUS_HOOK).catch(() => {});-    // …AND the CURRENT document gets it right now. evaluateOnNewDocument alone only fires on-    // the NEXT navigation — a page that had already navigated when attachTab ran never got the-    // hook at all (v1.8.45-55 bug: every clicked window still reported flash 'pending', so the-    // "which windows has the user seen" query silently returned garbage).-    page.evaluate(PUP_FOCUS_HOOK).catch(() => {});-  } catch (e) {}-  // Tag console logs with tabId so the caller can tell which tab emitted them-  // v1.9.163 (crash fix): RATE-LIMIT the console mirror. This synchronously wrote EVERY page-  // console message to the bridge's stdout; a spammy SPA (measured: Autodesk's Kepler portal)-  // floods the Node event loop with blocking writes and stalls CDP for EVERY session (HUNG-alive,-  // CDP-DEAD). Cap to ~8/sec per tab; drop the rest with a one-line "(suppressed N)" summary.-  let _cmWin = 0, _cmCount = 0, _cmDropped = 0;-  page.on('console', msg => {-    try {-      const now = Date.now();-      if (now - _cmWin > 1000) {-        if (_cmDropped > 0) console.log(`[pup console ${sessionId}/${tabId}] (suppressed ${_cmDropped} flood messages in the last second)`);-        _cmWin = now; _cmCount = 0; _cmDropped = 0;-      }-      if (_cmCount >= 8) { _cmDropped++; return; }-      _cmCount++;-      console.log(`[pup console ${sessionId}/${tabId} ${msg.type()}] ${msg.text()}`);-    } catch {}-  });-  // When the user closes a tab via Chrome UI, keep our state in sync-  page.on('close', () => {-    const idx = session.tabs.findIndex(t => t.tabId === tabId);-    if (idx !== -1) {-      // Capture the URL BEFORE the page handle is GC'd, so resolveTabOrError-      // can include it in tab_not_found errors. Critical for popup workflows-      // (PDF popups auto-close after the viewer renders; without lastKnownUrl-      // the caller has to re-trigger the click that spawned the popup).-      let lastKnownUrl = null;-      try { lastKnownUrl = page.url(); } catch {}-      const closedEntry = {-        tabId,-        url: lastKnownUrl,-        opener: opener || 'user',-        openerTabId: openerTabId || null,-        closedAt: Date.now(),-      };-      if (!session.recentlyClosedTabs) session.recentlyClosedTabs = [];-      session.recentlyClosedTabs.push(closedEntry);-      // Cap the ring buffer at 20 so a long-lived session with many popups-      // doesn't slowly leak memory through this list.-      if (session.recentlyClosedTabs.length > 20) {-        session.recentlyClosedTabs.shift();-      }-      session.tabs.splice(idx, 1);-      if (session.activeTabId === tabId) {-        session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;-      }-      console.log(`Tab "${tabId}" of session "${sessionId}" closed by user. Remaining tabs: ${session.tabs.length}. Last URL: ${lastKnownUrl || '(unknown)'}`);-      // v1.9.279: if that was the LAST window of the session, reap it NOW (badge + identity + entry)-      // instead of leaving a stale icon until the next sweep — the aa.com-closed-by-another-thread case.-      if (session.tabs.length === 0) { try { reconcileModel('tab-close').catch(() => {}); } catch (e) {} }-    }-    // Auto-stop any tab recording for this tab-    try {-      if (typeof tabRecordings !== 'undefined') {-        for (const [recId, state] of [...tabRecordings.entries()]) {-          if (state.sessionId === sessionId && state.tabId === tabId && !state.stopping) {-            state.stopReason = 'tabClosed';-            tabRecordStopImpl({ sessionId, recordingId: recId }).catch(e =>-              console.error(`[${recId}] auto-stop on tab close failed: ${e.message}`));-          }-        }-      }-    } catch (e) { console.error(`tab-close recording cleanup failed: ${e.message}`); }-  });-  // NATIVE JS DIALOGS (v2.0.344, John: "do you detect these dialogs and tell the ai and let them-  // agree to the reload?"). Until now pup registered NO dialog handler at all, and the consequences-  // were worse than cosmetic: an alert/confirm/prompt, or a beforeunload "Reload site? Changes you-  // made may not be saved", BLOCKS the whole window. Measured before this fix, on a page with an-  // edited textarea: pup_reload sat for 25s and returned "Navigation timeout of 25000 ms exceeded",-  // pup_screenshot returned "page.screenshot timed out after 20000ms", and even pup_close_window-  // timed out. Three different verbs, three timeout messages, and not one of them said the true-  // cause: a modal is up, waiting for a human who is never coming.-  //-  // We deliberately do NOT auto-dismiss. Puppeteer's default (dismiss when nobody listens) would-  // silently answer "Cancel" to a question about the USER'S UNSAVED WORK, and silently discarding-  // someone's data is worse than blocking. So the dialog is RECORDED and the AI is told, which is-  // exactly what John asked for: detect it, surface it, let the driver decide.-  const tabRec = { tabId, page, errors, opener, openerTabId, pendingDialog: null };-  page.on('dialog', (dialog) => {-    tabRec.pendingDialog = {-      dialog,-      type: dialog.type(),                 // alert | confirm | prompt | beforeunload-      message: dialog.message(),-      defaultValue: dialog.defaultValue(),-      at: Date.now(),-    };-    console.error(`[${sessionId}] tab ${tabId}: ${dialog.type()} dialog opened and is BLOCKING: ${JSON.stringify(dialog.message()).slice(0, 160)}`);-  });-  session.tabs.push(tabRec);-  if (makeActive) session.activeTabId = tabId;--  // v1.6.3+: enable scripted downloads on this page by default. Pup is a-  // tool-driving surface; chip-fetcher and similar tools need JS-triggered-  // downloads (a, fetch+blob, window.location=url) to actually save instead-  // of being silently dropped on the floor by Chrome. See-  // applyDownloadBehavior for rationale.-  //-  // Runs async (don't block attachTab on it) and idempotent — re-applying-  // is harmless. Re-fires on every same-tab main-frame navigation as a-  // safety belt: empirically modern Chrome doesn't reset the download-  // policy across same-context navs, but cross-context (target detach,-  // browser-restored tab) can lose it. Re-applying costs nothing.-  const downloadPath = (session._downloadPath) || path.join(os.homedir(), 'Downloads');-  applyDownloadBehavior(page, downloadPath).catch(() => {});-  page.on('framenavigated', frame => {-    try {-      if (frame === page.mainFrame()) {-        applyDownloadBehavior(page, downloadPath).catch(() => {});-      }-    } catch {}-  });--  // v1.8.80: any tab addition (incl. popup auto-attach) refreshes the tab-count badge-  setTimeout(() => { try { updateTabCountBadge(session, sessionId).catch(() => {}); } catch (e) {} }, 500);-  return tabId;-}--// Find the (sessionId, tabId) tuple whose page matches a Puppeteer Target's page.-// Used by the popup tracker to figure out which existing tab triggered window.open().-async function findTabForTarget(target) {-  let openerPage = null;-  try { openerPage = await target.page(); } catch {}-  if (openerPage) {-    for (const [sid, s] of sessions) {-      for (const t of s.tabs) {-        if (t.page === openerPage) return { sessionId: sid, tabId: t.tabId };-      }-    }-    return null;-  }-  // #425: iframe-originated popup. When a page opens target=_blank from INSIDE a cross-origin/sandboxed-  // iframe (e.g. the wiki README iframe with allow-popups), target.opener() points at the SUBFRAME-  // (OOPIF) target, whose .page() is null — so the identity match above can't see it and the popup gets-  // dropped ("opener target unknown to any session"). Resolve the owning tracked page by matching the-  // opener frame's URL against each page's frames. frames()/url() are synchronous cached data (no new-  // await target.page(), so the CDP-pipe-hang guard in wirePopupTracking is unaffected). Require a-  // UNIQUE match so ambiguous about:blank/about:srcdoc frames never mis-route the popup.-  let openerUrl = '';-  try { openerUrl = target.url() || ''; } catch {}-  if (!openerUrl || openerUrl === 'about:blank' || openerUrl === 'about:srcdoc') return null;-  let hit = null;-  for (const [sid, s] of sessions) {-    for (const t of s.tabs) {-      if (!t.page) continue;-      let frames = [];-      try { frames = t.page.frames(); } catch { continue; }-      for (const f of frames) {-        let fu = ''; try { fu = f.url() || ''; } catch {}-        if (fu && fu === openerUrl) {-          if (hit && (hit.sessionId !== sid || hit.tabId !== t.tabId)) return null; // ambiguous → drop-          hit = { sessionId: sid, tabId: t.tabId };-        }-      }-    }-  }-  return hit;-}--// Wire popup auto-tracking on a freshly launched (or reconnected) browser.-// Whenever Chrome opens a new top-level page that we DIDN'T explicitly create-// (window.open(), target="_blank" form post, <a target="_blank"> click, etc.),-// `targetcreated` fires. We resolve the target's page, find the session that-// owns its opener (via target.opener()), and attach the popup to that session-// as a tracked tab. pup_list_tabs then includes it like any other tab —-// callers can switch to it, eval against it, screenshot it, close it.-//-// Edge cases:-//   - Target has no opener (top-level Chrome window opened by external means)-//     → drop; we don't know which session it belongs to.-//   - Target is already attached (we created it via launchSession or open_tab-//     and the targetcreated event raced our explicit attach) → drop.-//   - Browser is the desktop recorder window (separate profile) → drop.-function wirePopupTracking(browser, profileName) {-  browser.on('targetcreated', async (target) => {-    try {-      if (target.type() !== 'page') return;--      // Check opener() FIRST — it's a synchronous target-info property, no page init.-      // A popup we care about (window.open / target=_blank) HAS an opener; a target-      // with NO opener is either the tab WE just made via browser.newPage() in-      // addTabToSession, or a truly external window — both of which we drop.-      // CRITICAL (v1.8.18): we must NOT `await target.page()` on an openerless target.-      // On Edge, awaiting the page of a target that our own newPage() is still-      // initializing re-enters the single-threaded CDP pipe and HANGS newPage() —-      // which then poisons the whole connection (every later screenshot / list / nav-      // times out). Bailing on opener()===null before target.page() avoids it entirely.-      const openerTarget = target.opener();-      if (!openerTarget) {-        console.log(`[popup-track] new tab on profile "${profileName}" with no opener — not tracking (our own newPage or external)`);-        return;-      }--      const popupPage = await target.page();-      if (!popupPage) return;--      // v1.9.296: skip pup's OWN in-window tab creations. addTabToSession opens a-      // window.open("about:blank") tagged with a unique window.name ("pupclaim_…") and adopts it-      // explicitly. When that blank's opener happened to be observable here, the tracker ALSO-      // attached the very same page — double-attaching it (two tab rows for one tab: the duplicate-      // John saw on a burst of opens). The marker is authoritative: these belong to addTabToSession,-      // so the tracker must not touch them. (Real page-initiated popups have no such marker.)-      try {-        const nm = await popupPage.evaluate(() => window.name);-        if (typeof nm === 'string' && nm.indexOf('pupclaim_') === 0) return;-      } catch (e) { /* can't read it (mid-nav) — fall through to the registered-page dedup below */ }--      // Skip if some session already has this page registered (we attached it-      // explicitly; this is the targetcreated event firing for our own creation).-      for (const s of sessions.values()) {-        for (const t of s.tabs) {-          if (t.page === popupPage) return;-        }-      }-      const ownerInfo = await findTabForTarget(openerTarget);-      if (!ownerInfo) {-        console.log(`[popup-track] new tab on profile "${profileName}", opener target unknown to any session — not tracking`);-        return;-      }-      const session = sessions.get(ownerInfo.sessionId);-      if (!session) return;--      // Attach as a popup-origin tab. attachTab handles the standard error +-      // console + page.on('close') wiring same as user-opened tabs.-      const tabId = attachTab(session, ownerInfo.sessionId, popupPage, {-        makeActive: false, // don't yank focus; let caller decide via pup_switch_tab-        opener: 'popup',-        openerTabId: ownerInfo.tabId,-      });-      let popupUrl = '';-      try { popupUrl = popupPage.url(); } catch {}-      console.log(`[popup-track] new popup attached: session="${ownerInfo.sessionId}" tabId="${tabId}" openedBy="${ownerInfo.tabId}" url="${popupUrl}"`);-    } catch (e) {-      console.log(`[popup-track] error: ${e.message}`);-    }-  });-}--// Resolve which page+errors to operate on. If args.tabId is given, use that-// specific tab; else fall back to the session's active tab.-// Returns { tab, tabId } or throws a structured Error whose .message is JSON-// (so the dispatch layer can surface currentTabs + a hint without re-parsing).-//-// The hint matters most for popup-tab workflows: PDF popups and downloads-// auto-close fast (Chrome PDF viewer with Content-Disposition: attachment-// closes after the download). Callers who try to eval-against-the-popup-// after it's gone used to get the active tab's response silently-// (v1.4.8 and earlier had a quirk where some paths did this). We now error-// loudly with the exact recipe to use instead.-function resolveTabOrError(session, sessionId, args, { allowCrashed = false } = {}) {-  if (args.tabId) {-    const t = getTabById(session, args.tabId);-    if (!t) {-      const available = session.tabs.map(x => x.tabId);-      // v1.4.10: If the requested tabId is one we recently saw close (most-      // commonly a popup PDF that auto-closed after rendering), surface its-      // last known URL + closure metadata so the caller can immediately-      // pup_fetch_url it without having to re-trigger the spawning click.-      let recentlyClosed = null;-      if (Array.isArray(session.recentlyClosedTabs)) {-        const hit = session.recentlyClosedTabs.find(c => c.tabId === args.tabId);-        if (hit) {-          recentlyClosed = {-            lastKnownUrl: hit.url,-            opener: hit.opener,-            openerTabId: hit.openerTabId,-            closedMsAgo: Date.now() - hit.closedAt,-          };-        }-      }-      const payload = {-        error: `Tab "${args.tabId}" not found in session "${sessionId}".`,-        currentTabs: available,-        _hint:-          `Most likely the popup auto-closed (PDF popups close after the ` +-          `viewer renders if Content-Disposition is attachment, or if a ` +-          `download fired). The right pattern with popup tabs is: ` +-          `(1) pup_list_tabs IMMEDIATELY after the click that spawned ` +-          `the popup, (2) read the popup's url field from that response ` +-          `(don't eval against the popup's tabId), (3) pup_fetch_url ` +-          `with that URL — never eval-against-popup-then-fetch.`,-        errorCode: 'tab_not_found',-      };-      if (recentlyClosed) {-        Object.assign(payload, recentlyClosed);-        // Sharpen the hint when we have a URL: the caller can act on it now.-        if (recentlyClosed.lastKnownUrl) {-          payload._hint =-            `This tab closed ${Math.round(recentlyClosed.closedMsAgo / 1000)}s ago at URL ${recentlyClosed.lastKnownUrl}. ` +-            `Call pup_fetch_url with that URL (and the opener tabId "${recentlyClosed.openerTabId || 'tab-1'}" for cookies) to get its bytes — ` +-            `do NOT try to eval against the closed tabId.`;-        }-      }-      const err = new Error(JSON.stringify(payload));-      err._isStructured = true;-      throw err;-    }-    if (!allowCrashed) _throwIfCrashed(t, sessionId);-    return { tab: t, tabId: t.tabId };-  }-  const t = getActiveTab(session);-  if (!t) {-    throw new Error(`Session "${sessionId}" has no open tabs.`);-  }-  if (!allowCrashed) _throwIfCrashed(t, sessionId);-  return { tab: t, tabId: t.tabId };-}-// v1.9.78: a crashed-renderer tab cannot be screenshotted or evaluated — those operations hang-// until the verb times out (20s) and, on a busy window, that read-back wedge is what the user-// perceives as "pup crashed". Fail FAST and tell the caller exactly how to recover.-// v1.9.176: recover a CRASHED renderer IN PLACE. A crashed renderer ('Aw, Snap!') detaches the-// page's main frame, so page.reload()/page.goto() throw 'Attempted to use detached Frame' — which is-// exactly why the crash hint's promised recovery ("pup_reload to recover") did NOT work. Driving-// the RAW target with Page.navigate spawns a fresh renderer in the SAME tab/window/session, which-// puppeteer then re-attaches. Returns true once the fresh page answers a probe, false on failure.-async function respawnCrashedRenderer(page, url) {-  // Anti-race: the auto-heal (from the crash detector) and a manual pup_reload/navigate can both-  // reach here for the SAME crashed page. Two concurrent createCDPSession + Page.navigate on one-  // crashed target hang. So only ONE respawn runs per page; a second caller waits for it and returns-  // its result. Every CDP call is hard-timeout-bounded so a crashed target can never wedge the verb.-  if (page.__pupRespawning) {-    const dl = Date.now() + 10000;-    while (page.__pupRespawning && Date.now() < dl) await new Promise(r => setTimeout(r, 300));-    try { await withTimeout(page.evaluate('1'), 1500, 'respawn-probe'); return true; } catch (e) { return false; }-  }-  page.__pupRespawning = true;-  let cdp = null;-  // STABLY alive, not just a transient hit: a fresh renderer flickers an execution context DURING-  // its navigation, so a single successful probe is a false positive (the page is still mid-crash--  // recovery and 'Execution context was destroyed' fires a beat later). Require readyState to read-  // as a real value on TWO probes ~700ms apart before declaring recovery.-  const stablyAlive = async () => {-    try {-      const rs = await withTimeout(page.evaluate('document.readyState'), 1500, 'probe');-      return rs === 'loading' || rs === 'interactive' || rs === 'complete';-    } catch (e) { return false; }-  };-  try {-    cdp = await withTimeout(page.target().createCDPSession(), 4000, 'cdp-session');-    try { await withTimeout(cdp.send('Page.navigate', { url: url || 'about:blank' }), 8000, 'page-navigate'); } catch (e) {}-    const deadline = Date.now() + 10000;-    while (Date.now() < deadline) {-      await new Promise(r => setTimeout(r, 500));-      if (await stablyAlive()) {-        await new Promise(r => setTimeout(r, 700));-        if (await stablyAlive()) return true;   // two consecutive good reads = genuinely back-      }-    }-    return false;-  } catch (e) {-    console.log(`[crash] respawnCrashedRenderer failed: ${e.message}`);-    return false;-  } finally {-    page.__pupRespawning = false;-    try { if (cdp) await cdp.detach(); } catch (e) {}-  }-}--// Detached-frame / crashed-renderer error signatures — the raw puppeteer errors that mean the tab's-// renderer is dead and normal page.* calls can't run; the caller should fall back to a respawn.-function _isCrashedFrameError(e) {-  const m = (e && e.message) || String(e || '');-  return /detached Frame|Target crashed|Session closed|Execution context was destroyed|no live page/i.test(m);-}--// The CDP target id behind a page — the stable identity of a Chrome tab across bridge reconnects-// (the puppeteer Page OBJECT is recreated on every reconnect; the target id is not).-function _pageTargetId(page) {-  try { const t = page.target(); return (t && (t._targetId || (t._targetInfo && t._targetInfo.targetId))) || null; } catch (e) { return null; }-}--// v1.9.179: prune DEAD (closed / detached-frame) and DUPLICATE tab entries from a session. Rapid-// bridge restarts + crash reconnects bound the same Chrome tab multiple times (rescan keyed on the-// page OBJECT, which changes on reconnect), so a single-page window accumulated 4 identical entries —-// inflating the taskbar count badge to "4" and, because the active entry was a detached phantom, the-// favicon read (page.evaluate) threw and the overlay fell back to that wrong count. This makes the tab-// list reflect the REAL live tabs again. A probe TIMEOUT means "busy", not dead — those are kept, so a-// merely-slow tab is never wrongly dropped.-async function reconcileSessionTabs(session, sessionId) {-  if (!session || !Array.isArray(session.tabs) || session.tabs.length === 0) return 0;-  // v1.9.182 — EMERGENCY FIX for the v1.9.181 reconciler, which DESTROYED live windows (John:-  // "why did my pup windows just crash?" — smu-deck-pup2 and wiki-review were stripped to 0 tabs-  // while he was USING them). The 1.9.181 version judged liveness from browser.targets() via-  // puppeteer-internal target-id fields; the id sets from browser.targets() and page.target() did-  // not match, so EVERY live tab looked "absent" and was dropped. Lesson enforced here: the-  // reconciler must be able to prove a tab dead with DIRECT evidence before dropping it.-  //   - isClosed() → dead (puppeteer's own state, no internals).-  //   - Otherwise a tab is dropped ONLY if a 1.5s probe fails with a crashed/detached-frame error-  //     (the phantom signature). A timeout means busy → KEEP. No target-set inference, ever.-  //   - Duplicate detection (same target id twice) keeps the FIRST binding and needs no liveness-  //     judgement; unresolvable ids are never treated as duplicates.-  // Cost honesty: this restores a per-tab probe, but ONLY for tabs not already provably closed, and-  // the probe reuses the page's existing session (no renderer paint wake for a throttled page-  // answering a trivial eval). Correctness beats the load micro-win — proven by today's failure.-  const kept = []; const seenTargets = new Set(); let dropped = 0;-  for (const t of session.tabs) {-    let dead = false;-    try {-      if (!t.page || t.page.isClosed()) dead = true;-      else {-        try { await withTimeout(t.page.evaluate('1'), 1500, 'tab-reconcile'); }-        catch (e) { if (_isCrashedFrameError(e)) dead = true; /* timeout = busy → keep */ }-      }-    } catch (e) { dead = true; }-    const tid = dead ? null : _pageTargetId(t.page);-    if (dead || (tid && seenTargets.has(tid))) { dropped++; continue; }-    if (tid) seenTargets.add(tid);-    kept.push(t);-  }-  // Safety rail: NEVER let a reconcile empty a session that had live-looking tabs going in. If every-  // tab judged dead, distrust the judgement (a systemic probe failure looks exactly like this) —-  // keep the tabs and let real evidence (isClosed, disconnects) catch genuinely dead ones later.-  if (kept.length === 0 && session.tabs.length > 0 && !session._lostBrowser) {-    console.log(`[tabs] session "${sessionId}" reconcile judged ALL ${session.tabs.length} tab(s) dead — refusing the wholesale drop (probe failure is more likely than every tab dying at once)`);-    return 0;-  }-  if (dropped > 0) {-    session.tabs = kept;-    if (session.activeTabId && !kept.find(t => t.tabId === session.activeTabId)) {-      session.activeTabId = kept.length ? kept[kept.length - 1].tabId : null;-    }-    console.log(`[tabs] session "${sessionId}" reconciled: dropped ${dropped} dead/duplicate tab(s) → ${kept.length} live`);-    try { refreshWindowChrome(session, sessionId).catch(() => {}); } catch (e) {}  // re-stamp count/favicon overlay-  }-  return dropped;-}--function _throwIfCrashed(t, sessionId) {-  if (!t || !t._crashed) return;-  const secs = Math.round((Date.now() - (t._crashedAt || Date.now())) / 1000);-  const payload = {-    error: `Tab "${t.tabId}" in session "${sessionId}" has a CRASHED renderer (${t._crashReason || 'crash'}, ${secs}s ago).`,-    errorCode: 'tab_renderer_crashed',-    tabId: t.tabId,-    _hint: `The tab's Chrome renderer process died (usually memory pressure from many heavy tabs, or a navigate racing an eval/screenshot on the same tab). The window and its OTHER tabs are fine. To recover THIS tab: pup_reload {sessionId:"${sessionId}", tabId:"${t.tabId}"} (or pup_navigate it to a URL) — that respawns the renderer and clears the crash flag. Then avoid driving a single tab with overlapping navigate/eval/screenshot calls; wait for one to return before the next.`,-  };-  const err = new Error(JSON.stringify(payload));-  err._isStructured = true;-  throw err;-}--// #293 — close the ONE residual 20s hang: when a screenshot/eval op is already IN FLIGHT and the-// renderer crashes mid-op, page.on('error') flips t._crashed a beat later. Race the op against that-// flag so we abandon the doomed op the instant the crash lands and return the structured crash error,-// instead of waiting out the full withTimeout budget. The losing op promise is swallowed so a late-// rejection can't surface as an unhandledRejection. (Crash detection + pre-op fast-fail via-// resolveTabOrError already exist; this only trims the racing-op latency.)-// v2.0.236: this polled every 250ms for the LIFETIME of every screenshot/eval/click — a 4Hz timer-// per in-flight op, watching for a condition the tab already announces. _markCrashed now resolves a-// per-tab waiter, so the race is event-driven: zero timers, and it reacts instantly instead of up-// to 250ms late. (A tab already crashed when the op starts still fast-fails, as before.)-function crashSignal(t) {-  if (!t) return new Promise(() => {});           // nothing to watch-  if (!t._crashWaiters) t._crashWaiters = [];-  return new Promise((resolve) => { t._crashWaiters.push(resolve); });-}-function raceCrash(t, sessionId, opPromise) {-  opPromise.catch(() => {});-  if (t && t._crashed) { try { _throwIfCrashed(t, sessionId); } catch (e) { return Promise.reject(e); } }-  const crashWatch = crashSignal(t).then(() => { _throwIfCrashed(t, sessionId); });-  return Promise.race([opPromise, crashWatch]);-}--// Send a structured-or-plain error from resolveTabOrError. If the error-// message is the structured JSON we throw above, parse it back out and-// surface the fields directly in the response. Otherwise plain string.-function sendTabResolveError(res, e) {-  if (e && e._isStructured) {-    try {-      const parsed = JSON.parse(e.message);-      sendJSON(res, { success: false, ...parsed });-      return;-    } catch {}-  }-  sendJSON(res, { success: false, error: e.message });-}--// Build a plain-object tab description for API responses.-async function describeTab(tab, activeTabId) {-  let url = null, title = null;-  try { url = tab.page.url(); } catch {}-  try { title = await tab.page.title(); } catch {}-  return {-    tabId: tab.tabId,-    url,-    title,-    active: tab.tabId === activeTabId,-    errorCount: tab.errors.length,-    // Provenance: "user" if Claude opened this tab via pup_open_window /-    // pup_open_tab, "popup" if the page itself spawned it via window.open()-    // / target=_blank. openerTabId names the tab that triggered the popup-    // (so callers can correlate "this datasheet popup came from product page X").-    opener: tab.opener || 'user',-    openerTabId: tab.openerTabId || null,-  };-}--// ── Persistent session file helpers ────────────────────────────────-function sessionFilePath(sessionId) {-  return path.join(SESSIONS_DIR, `${sessionId}.json`);-}--function saveSessionFile(sessionId, profileName, url) {-  const entry = browsers.get(profileName);-  let pid = null, cdpPort = null;-  if (entry) {-    // Prefer detached PID (Windows), fall back to Puppeteer's process()-    pid = entry.browser._detachedPid || null;-    try { if (!pid) pid = entry.browser.process()?.pid || null; } catch {}-    // Prefer stored CDP port, fall back to DevToolsActivePort file-    cdpPort = entry.browser._cdpPort || null;-    if (!cdpPort) {-      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');-      try {-        cdpPort = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;-      } catch {}-    }-  }-  const data = {-    sessionId, profile: profileName, url: url || null,-    pid, cdpPort,-    // v1.9.181: persist the user's foreground choice. Without this, EVERY bridge restart forgot that-    // the user had this window up and recovery re-parked it to the bottom mid-use — the single-    // biggest "pup windows don't behave / keep vanishing" driver (John, 2026-07-29). The flag is set-    // by a granted raise OR by pup OBSERVING the user foreground the window themselves (taskbar click).-    foreground: !!((sessions.get(sessionId) || {})._foreground),-    // v1.9.197 BUG 4 (Bills thread): owner was NEVER written here, so every bridge restart erased it-    // and pup_list_windows reported owner:null for every session — silently disabling the-    // session_owned_by_another_thread protection that owner is supposed to back.-    owner: ((sessions.get(sessionId) || {}).owner) || null,-    // v2.0.250 (#51): persist the identity this window is WEARING. After a respawn a reloaded-    // session has _curAppId=null until its first stamp lands, so the boot prune could not tell a-    // live thread key from an orphan and deleted it, un-branding the button until a full re-stamp.-    appId: ((sessions.get(sessionId) || {})._curAppId) || null,-    // v1.9.300: persist the resolved OS window handle. Chrome windows survive a bridge respawn with-    // the SAME hwnd, so restoring + validating this on recovery lets a window keep its handle WITHOUT-    // re-running the fragile width-wiggle — which is what stormed/deadlocked after every ship and left-    // every window hwnd=None. Validated via hwndBelongsToPup before reuse, so a stale/recycled handle-    // is discarded, not trusted.-    _hwnd: ((sessions.get(sessionId) || {})._hwnd) || null,-    createdAt: new Date().toISOString(),-    updatedAt: new Date().toISOString(),-  };-  try {-    fs.writeFileSync(sessionFilePath(sessionId), JSON.stringify(data, null, 2));-  } catch (e) {-    console.error(`Failed to save session file for "${sessionId}": ${e.message}`);-  }-}--function releasePersistedClaim(sessionId) {-  try { for (const [h, sid] of _persistedHwndClaims) { if (sid === sessionId) _persistedHwndClaims.delete(h); } } catch (e) {}-}-function deleteSessionFile(sessionId) {-  releasePersistedClaim(sessionId);-  try { fs.unlinkSync(sessionFilePath(sessionId)); } catch {}-}--// v1.9.300: patch JUST the resolved hwnd into a session's file without rewriting the rest (so a-// full saveSessionFile with url/pid isn't required at every resolution). Called whenever the handle-// is resolved, so a bridge respawn can restore it and skip the wiggle.-function persistSessionHwnd(sessionId, hwnd) {-  // v2.0.17: a real handle landing (birth, park, title-tag, wiggle — every success funnels here)-  // clears the resolution backoff so a session that finally resolved is probed normally again.-  try { const s = sessions.get(sessionId); if (s && hwnd) { s._hwndFailStreak = 0; s._hwndBackoffUntil = 0; } } catch (e) {}-  try {-    const f = sessionFilePath(sessionId);-    if (!fs.existsSync(f)) return;-    const d = JSON.parse(fs.readFileSync(f, 'utf8'));-    if (d._hwnd === (hwnd || null)) return;-    // v2.0.45: keep the persisted-claims map in lockstep (release the old handle, claim the new)-    try { if (d._hwnd && _persistedHwndClaims.get(Number(d._hwnd)) === sessionId) _persistedHwndClaims.delete(Number(d._hwnd)); } catch (e) {}-    try { if (hwnd) _persistedHwndClaims.set(Number(hwnd), sessionId); } catch (e) {}-    d._hwnd = hwnd || null; d.updatedAt = new Date().toISOString();-    fs.writeFileSync(f, JSON.stringify(d, null, 2));-  } catch (e) {}-}--function readSessionFile(sessionId) {-  try {-    return JSON.parse(fs.readFileSync(sessionFilePath(sessionId), 'utf8'));-  } catch { return null; }-}--function listSessionFiles() {-  try {-    return fs.readdirSync(SESSIONS_DIR)-      .filter(f => f.endsWith('.json'))-      .map(f => {-        try { return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); } catch { return null; }-      })-      .filter(Boolean);-  } catch { return []; }-}--function isProcessAlive(pid) {-  if (!pid) return false;-  try {-    process.kill(pid, 0); // signal 0 = check existence, doesn't kill-    return true;-  } catch { return false; }-}--// ── Session-tag extraction (v1.5.1+) ──────────────────────────────────-// launchSession() injects ` (session: <sessionId> | profile: <profileName>)`-// or ` (session: <sessionId>)` into every page's title via a MutationObserver.-// On reconnect / rescan we recover the sessionId from this tag — it's our-// durable mapping from "puppeteer Page handle" → "bridge sessionId" that-// survives across CDP socket drops, bridge restarts, and Chrome stay-alive-// scenarios. Without this tag (e.g. about:blank pages), the page is-// considered an orphan and will be adopted under a generated sessionId-// if rescan is asked to.-// v1.9.284 (John: "call it ai-thread ... and just do the visual parts i see"): the visible title-// tag label. WRITE side uses this; READ side (parse/strip/match) is TOLERANT of the legacy "session"-// label so windows tagged by an older build still resolve during the switchover.-const TITLE_LABEL = 'ai-thread';-const SESSION_TAG_RE = /\((?:session|ai-thread):\s*([^|)]+?)(?:\s*\|.*)?\)\s*$/;-function parseSessionIdFromTitle(title) {-  if (!title) return null;-  const m = String(title).match(SESSION_TAG_RE);-  return m ? m[1].trim() : null;-}--// ── Disconnect-handling shared between every browser.on('disconnected')-// site (v1.5.1+) ──────────────────────────────────────────────────────-//-// Old (≤v1.5.0) behavior: when a browser disconnected (CDP socket dropped,-// network blip, puppeteer-side hiccup), the bridge eagerly purged every-// session for that profile from `sessions` AND deleted the on-disk session-// file. The Chrome window was still alive on the user's desktop, fully-// usable, but every reference to it had been wiped — so subsequent-// pup_navigate/eval/screenshot calls hit "Session not found" and-// (worse) pup_navigate without a sessionId silently retargeted-// to whatever session was still in memory, hijacking unrelated windows.-// Confirmed twice in chip-fetcher's session log on 2026-05-04.-//-// New behavior: keep the session entries in memory + on disk so they-// remain "known but currently disconnected." The next pup_rescan-// (manual) or 30-second health check (auto) will try to reconnect via-// the persisted DevToolsActivePort + verify Chrome's PID is still alive,-// then walk pages and re-attach by parsing the (session: X) tag from-// each page's title.-function handleBrowserDisconnect(browser, profileName) {-  const entry = browsers.get(profileName);-  if (!entry || entry.browser !== browser) return;-  browsers.delete(profileName);-  let touched = 0;-  for (const s of sessions.values()) {-    if (s.profileName === profileName) {-      s._lostBrowser = true;-      s._disconnectedAt = Date.now();-      // v1.9.172 (crash-heal race fix): capture the last-known URL NOW, while the (dead) page handle-      // still reports its cached url. The crash self-heal relaunches from this even if the session-      // file is later reaped, so a crash under load recovers instead of failing "session not found".-      try { const u = s.page && s.page.url(); if (u && u !== 'about:blank') s._lastUrl = u; } catch (e) {}-      touched++;-    }-  }-  console.log(-    `[disconnect] profile "${profileName}" disconnected — ${touched} session(s) marked as awaiting reconnect. ` +-    `Run pup_rescan to recover; the 30s health check will also auto-attempt.`-  );-}--// v1.6.1+: scan the running process list for a Chrome process whose-// command line includes `--user-data-dir=...<profileName>`. Returns-// `[{pid, cdpPort}, ...]` for every match. Used as a third-tier fallback-// when neither the on-disk session file nor the profile's-// DevToolsActivePort file knows about an alive Chrome — which is the-// canonical sleep/wake symptom (DevToolsActivePort is rewritten-// transiently and can be missing after wake).-async function findChromesByProfile(profileName) {-  // Use Windows tasklist + wmic equivalent OR PowerShell on Windows;-  // ps + grep elsewhere. Both return PID + full command line so we can-  // parse out --remote-debugging-port=<NNNN>.-  const matches = [];-  try {-    if (process.platform === 'win32') {-      // PowerShell's CIM is the most reliable way to get full command-      // lines on Windows. Fall back to wmic if PowerShell isn't-      // available (very rare on modern Windows). We use a single-      // -Command invocation so child-process startup cost is paid once.-      const { spawnSync } = require('child_process');-      const result = spawnSync('powershell', [-        '-NoProfile',-        '-Command',-        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match '${psRegexEscape(profileName)}' } | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`,-      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });-      if (result.stdout) {-        let parsed;-        try {-          parsed = JSON.parse(result.stdout);-        } catch {-          parsed = null;-        }-        const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);-        for (const item of items) {-          const cmd = item.CommandLine || '';-          // Only consider the parent Chrome process — skip --type=renderer-          // / --type=gpu-process / --type=crashpad-handler etc. They share-          // the user-data-dir but aren't the listening parent.-          if (/--type=/.test(cmd)) continue;-          // Must look like a pup-managed Chrome — has --remote-debugging-port.-          const portMatch = cmd.match(/--remote-debugging-port=(\d+)/);-          if (!portMatch) continue;-          // Must reference our profile dir specifically (not just the name-          // appearing somewhere in args).-          const profileNeedle = path.join(PROFILES_DIR, profileName).toLowerCase();-          if (!cmd.toLowerCase().includes(profileNeedle.toLowerCase())) continue;-          matches.push({ pid: item.ProcessId, cdpPort: parseInt(portMatch[1], 10) });-        }-      }-    } else {-      // POSIX: ps + grep. Same logic, simpler shape.-      const { spawnSync } = require('child_process');-      const result = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf8', timeout: 5_000 });-      if (result.stdout) {-        for (const line of result.stdout.split('\n')) {-          // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).-          if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue;-          if (/--type=/.test(line)) continue;-          const portMatch = line.match(/--remote-debugging-port=(\d+)/);-          if (!portMatch) continue;-          const profileNeedle = path.join(PROFILES_DIR, profileName);-          if (!line.includes(profileNeedle)) continue;-          const pidMatch = line.trim().match(/^(\d+)/);-          if (!pidMatch) continue;-          matches.push({ pid: parseInt(pidMatch[1], 10), cdpPort: parseInt(portMatch[1], 10) });-        }-      }-    }-  } catch (e) {-    console.log(`[findChromesByProfile] scan failed for "${profileName}": ${e.message}`);-  }-  return matches;-}--// v1.6.1+: kill every Chrome process associated with a profile, then-// remove the lock files Chrome leaves behind. Used before relaunching-// when a previous Chrome died unexpectedly (sleep/wake / crash) and-// left the profile in a state where a fresh `puppeteer.launch()` would-// exit immediately with "Chrome process exited immediately."-//-// Lock files we clean (cross-platform — Chrome creates a different set-// per OS):-//   - lockfile          (Windows — Chrome's own profile lock)-//   - SingletonLock     (Linux/macOS — Chrome's instance singleton)-//   - SingletonSocket   (Linux/macOS — same)-//   - SingletonCookie   (Linux/macOS — same)-//   - DevToolsActivePort (Chrome rotates this — stale value points at a-//     dead Chrome; we delete to force a re-write on next launch)-async function cleanProfileLocks(profileName) {-  const profileDir = path.join(PROFILES_DIR, profileName);-  if (!fs.existsSync(profileDir)) {-    return { cleaned: [], note: 'profile directory does not exist' };-  }-  const lockNames = [-    'lockfile',-    'SingletonLock',-    'SingletonSocket',-    'SingletonCookie',-    'DevToolsActivePort',-  ];-  const cleaned = [];-  for (const name of lockNames) {-    const p = path.join(profileDir, name);-    try {-      // unlinkSync errors if missing — that's fine, ignore.-      fs.unlinkSync(p);-      cleaned.push(name);-    } catch {}-  }-  console.log(`[cleanProfileLocks] profile "${profileName}": cleaned ${JSON.stringify(cleaned)}`);-  return { cleaned };-}--// v1.6.1+: kill every Chrome process matching this profile. Called-// before clean+relaunch when a stale Chrome is holding the profile.-// Idempotent — does nothing if no chrome processes match.-async function killOrphanChromesForProfile(profileName) {-  const found = await findChromesByProfile(profileName);-  let killed = 0;-  for (const { pid } of found) {-    try {-      if (process.platform === 'win32') {-        const { spawnSync } = require('child_process');-        spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { timeout: 5_000, windowsHide: true });-      } else {-        process.kill(pid, 'SIGKILL');-      }-      killed++;-    } catch (e) {-      console.log(`[killOrphan] failed to kill PID ${pid}: ${e.message}`);-    }-  }-  if (killed > 0) {-    console.log(`[killOrphan] killed ${killed} Chrome process tree(s) for profile "${profileName}"`);-    // Give the OS a beat to release file handles before the caller-    // tries to delete profile locks / relaunch.-    await new Promise(r => setTimeout(r, 1500));-  }-  return { killed };-}--// Try to puppeteer.connect to an already-alive Chrome for `profileName`,-// using the on-disk session file's recorded cdpPort + DevToolsActivePort-// fallback. Returns the Browser on success, null on failure. Caller is-// responsible for setting up listeners (handleBrowserDisconnect, popup-// tracking, default permissions). Idempotent — if a live entry already-// exists in `browsers`, returns that one.-async function attemptReconnectProfile(profileName) {-  const existing = browsers.get(profileName);-  if (existing && existing.browser.isConnected()) {-    return existing.browser;-  }-  // Pick any session file for this profile to discover cdpPort / pid.-  const files = listSessionFiles().filter(sf => sf.profile === profileName);-  let port = null, pid = null;-  for (const sf of files) {-    if (sf.cdpPort) port = port || sf.cdpPort;-    if (sf.pid) pid = pid || sf.pid;-  }-  // Fall back to DevToolsActivePort file in the profile dir.-  if (!port) {-    try {-      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');-      port = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;-    } catch {}-  }-  // v1.6.1+: third-tier fallback — scan running Chrome processes for one-  // whose --user-data-dir matches this profile. This catches the canonical-  // sleep/wake symptom: Chrome is alive, has its CDP port open and serving-  // /json/version, but the bridge has no on-disk record of it (e.g. session-  // file was deleted by an old aggressive disconnect handler before v1.5.1,-  // OR the bridge restarted after launch). See findChromesByProfile.-  if (!port) {-    const found = await findChromesByProfile(profileName);-    if (found.length > 0) {-      port = found[0].cdpPort;-      pid = pid || found[0].pid;-      console.log(`[reconnect] discovered orphan chrome via cmdline scan: profile="${profileName}" pid=${pid} port=${port}`);-    }-  }-  if (!port) {-    console.log(`[reconnect] no cdpPort known for profile "${profileName}" — cannot reconnect`);-    return null;-  }-  // Quick liveness probe with a 3s timeout (sleep/wake can leave half-open-  // sockets that hang fetch indefinitely; we'd rather fail fast and let-  // the next probe try again).-  const ac = new AbortController();-  const tid = setTimeout(() => ac.abort(), 3000);-  let ok = false;-  try {-    const resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });-    ok = !!(resp && resp.ok);-  } catch {} finally {-    clearTimeout(tid);-  }-  if (!ok) {-    console.log(`[reconnect] CDP port ${port} for profile "${profileName}" not responding`);-    return null;-  }-  try {-    const 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)),-    ]);-    if (pid) browser._detachedPid = pid;-    browser._cdpPort = port;-    browsers.set(profileName, { browser, refCount: 0 });-    wirePopupTracking(browser, profileName);-    browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));-    console.log(`[reconnect] reconnected to Chrome on port ${port} for profile "${profileName}"`);-    return browser;-  } catch (e) {-    console.log(`[reconnect] puppeteer.connect failed for profile "${profileName}" port ${port}: ${e.message}`);-    return null;-  }-}--// Walk every page in `browser` and rebuild `sessions` entries for any-// page whose title carries a `(session: X)` tag. Pages without a tag-// are left alone unless `adoptOrphans:true` is passed, in which case-// they're attached under a generated sessionId so the caller can drive-// them. Idempotent — pages already attached to a live session are left-// in place.-// v2.0.138 (root cause of John's find_window flood): pages that are NEVER user content and must-// never be adopted as windows. chrome-extension:// covers pup's OWN toolbar/annotate/identity-// popup bubbles — every toolbar click spawns popup.html as a page target, the reclaim sweep saw-// it "unowned" twice and minted a ghost adom-you-win**** session for it, whose hwnd (a popup-// bubble with no title tag) could never resolve — the thing every sweep then hunted forever.-function isNonAdoptableUrl(u) {-  // v2.0.158: data: URLs are internal (the thread-tile render page uses one) — never a user window.-  return !u || u === 'about:blank' || /^(devtools|chrome|edge|chrome-extension|chrome-untrusted|extension|view-source|data):/i.test(u);-}-async function rescanProfile(browser, profileName, { adoptOrphans = false } = {}) {-  const pages = await browser.pages();-  // v1.9.179: key the "already attached" map on the CDP TARGET ID, not the page OBJECT. On a-  // reconnect (bridge restart / crash recovery) puppeteer hands back a BRAND-NEW Page object for the-  // SAME Chrome tab, so an object-keyed map never matched and re-attached the tab as a duplicate —-  // which is how a 1-tab window grew to 4 identical entries. Target ids survive reconnects, so this-  // matches the existing tab and skips it instead of duplicating. (Falls back to the object when a-  // target id can't be read, preserving the old behaviour for that edge.)-  const existingPageTab = new Map();-  const keyFor = (pg) => _pageTargetId(pg) || pg;-  for (const s of sessions.values()) {-    if (s.profileName !== profileName) continue;-    for (const t of s.tabs) existingPageTab.set(keyFor(t.page), { sid: null, t });-  }-  for (const [sid, s] of sessions.entries()) {-    if (s.profileName !== profileName) continue;-    for (const t of s.tabs) {-      const cur = existingPageTab.get(keyFor(t.page));-      if (cur) cur.sid = sid;-    }-  }-  let reattached = 0, adopted = 0, alreadyLive = 0;-  const seenSessions = new Set();-  for (const page of pages) {-    if (existingPageTab.has(keyFor(page))) {-      alreadyLive++;-      const cur = existingPageTab.get(keyFor(page));-      if (cur && cur.sid) seenSessions.add(cur.sid);-      continue;-    }-    let title = '';-    try { title = await page.title(); } catch { /* page closed during rescan */ continue; }-    // v1.9.151: match by window.name FIRST — the durable carrier. Title tags die under SPA-    // title churn (the espn mis-bind); window.name survives every same-tab navigation.-    let sessionId = null;-    try {-      const wn = await page.evaluate(() => window.name);-      if (wn && typeof wn === 'string' && wn.startsWith('pup:')) sessionId = wn.slice(4).trim() || null;-    } catch (e) {}-    if (!sessionId) sessionId = parseSessionIdFromTitle(title);-    if (!sessionId) {-      // No tag — could be a freshly-opened tab the user navigated to via-      // Chrome's New Tab Page, or a page the bridge already managed but-      // whose title got replaced by a script before the MutationObserver-      // could re-inject. Adopt only when explicitly asked — and NEVER a-      // non-user page (extension popups, devtools, chrome:// UI): those are-      // transient browser chrome, not windows (v2.0.138, the ghost source).-      let _au = ''; try { _au = page.url() || ''; } catch (e) {}-      if (isNonAdoptableUrl(_au)) continue;-      if (!adoptOrphans) continue;-      sessionId = `orphan-${profileName}-${Date.now()}-${adopted}`;-      adopted++;-    }-    let session = sessions.get(sessionId);-    if (!session) {-      session = createSession(profileName);-      session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution-      // v1.9.310: restore the PERSISTED OS handle on this adoption path too. Without the width-wiggle,-      // a re-adopted window on a MULTI-window profile has no way to re-derive its handle — but the-      // window kept the SAME hwnd across the respawn, so the persisted value is still correct (and-      // hwndBelongsToPup validates it before any use). This is what keeps recovered windows GREEN-      // instead of red after the wiggle removal. recoverSessions already did this; rescan now matches.-      try { const _sf = readSessionFile(sessionId); if (_sf && _sf._hwnd) session._hwnd = Number(_sf._hwnd); } catch (e) {}-      sessions.set(sessionId, session);-    } else if (session.profileName !== profileName) {-      // SessionId collision across profiles — shouldn't happen in practice-      // since profile defaults to the durable shared \'adom-you\' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway, but guard anyway. Skip rather-      // than clobber.-      console.log(`[rescan] sessionId "${sessionId}" found in profile "${session.profileName}" but page is in profile "${profileName}" — skipping`);-      continue;-    }-    session._lostBrowser = false;-    delete session._disconnectedAt;-    delete session._healGaveUp;   // v1.9.305: a live tab is back — reset the give-up latch-    attachTab(session, sessionId, page, { makeActive: false });-    seenSessions.add(sessionId);-    reattached++;-  }-  // Sessions for this profile that have no live tab any more (Chrome may-  // have closed the tab while disconnected) keep their entries marked-  // _lostBrowser so the strict resolveSession path surfaces them clearly.-  for (const [sid, s] of sessions.entries()) {-    if (s.profileName !== profileName) continue;-    if (!seenSessions.has(sid) && s.tabs.length === 0) {-      s._lostBrowser = true;-    }-  }-  console.log(-    `[rescan] profile "${profileName}": ${reattached} reattached, ${adopted} orphans adopted, ` +-    `${alreadyLive} pages already live`-  );-  // v1.9.179: after re-binding, prune any dead/duplicate tab entries this or a prior reconnect left-  // behind, so the tab count + overlay are correct immediately (pup_rescan triggers this).-  for (const [sid, s] of sessions.entries()) {-    if (s.profileName === profileName) { try { await reconcileSessionTabs(s, sid); } catch (e) {} }-  }-  // v1.9.305 (John: "we came back from sleep and lost all pup windows"): the fix for the reaper-vs--  // recovery RACE. On wake, rescan reattached a page and cleared _lostBrowser=false, but the prune-  // above then dropped that page as the DEAD pre-sleep tab — leaving the session at 0 live tabs with-  // _lostBrowser already false. The 8s reaper's guard is `_lostBrowser && _disconnectedAt<120s`, so it-  // REAPED the recoverable session before the 20s healer could relaunch it (killed pup-dashboard +-  // noreply-bounce-admin; only hd-wiki-pages won the race). Re-mark any now-empty session as lost and-  // refresh its heal window, so the reaper leaves it alone and the healer brings it back at its URL.-  for (const [sid, s] of sessions.entries()) {-    if (s.profileName !== profileName) continue;-    const liveTabs = (s.tabs || []).filter(t => { try { return t && t.page && !t.page.isClosed(); } catch (e) { return false; } }).length;-    if (liveTabs === 0 && !s._healGaveUp) { s._lostBrowser = true; s._disconnectedAt = Date.now(); }-  }-  // v1.9.243: repaint overlays IMMEDIATELY on reattach. A bridge restart wipes in-memory painted-  // state while the REAL taskbar keeps whatever was on it — the dashboard and taskbar diverge until-  // something repaints (John: "the taskbar icon you show for icon 1 is not consistent with what you-  // show in the windows taskbar"). Converge at the source: every reattached session repaints now, so-  // model == glass within seconds of a restart, not minutes.-  for (const sid of seenSessions) {-    const s2 = sessions.get(sid);-    if (s2 && (s2.tabs || []).length) setTimeout(() => { applyAppOverlay(s2, sid).catch(() => {}); }, 1500);-  }-  // v1.8.57: after a reattach, self-heal a window stranded off-screen by an older build's-  // failed park (one heal per profile — all this profile's sessions share the window).-  if (reattached > 0) {-    for (const sid of seenSessions) {-      const s = sessions.get(sid);-      if (s && s.tabs.length > 0) { healOffscreenWindow(s, sid).catch(() => {}); break; }-    }-  }-  return { reattached, adopted, alreadyLive };-}--// Walk every known profile (in-memory + on-disk) and try to rebuild a-// complete session map. Used by pup_rescan and by the 30s health-// check when it detects a disconnected browser whose Chrome PID is-// still alive. Returns aggregated stats for the response payload.-//-// v1.6.1+: also discovers profiles via a running-process scan. Catches-// the case where the bridge restarted (or was launched fresh after a-// crash) and a Chrome window is still alive on the desktop with no-// in-memory or on-disk record. The chipsmith-after-sleep scenario hit-// exactly this: Chrome alive, bridge had nothing for it.-async function rescanAllProfiles({ adoptOrphans = false } = {}) {-  const profileNames = new Set();-  for (const p of browsers.keys()) profileNames.add(p);-  for (const sf of listSessionFiles()) profileNames.add(sf.profile);-  // v1.6.1: scan for orphan Chrome processes running with a profile dir-  // under our PROFILES_DIR — they're pup-managed Chromes that the bridge-  // has lost track of. Each match is a profile we should try to recover.-  try {-    if (process.platform === 'win32') {-      const { spawnSync } = require('child_process');-      const profilesDir = psRegexEscape(PROFILES_DIR);-      const result = spawnSync('powershell', [-        '-NoProfile', '-Command',-        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match 'remote-debugging-port' -and $_.CommandLine -match '${profilesDir}' -and $_.CommandLine -notmatch '--type=' } | ForEach-Object { if ($_.CommandLine -match 'user-data-dir=([^"]*?)(?=\\s+--|"$|$)') { Split-Path -Leaf $matches[1] } } | Sort-Object -Unique`,-      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });-      if (result.stdout) {-        for (const line of result.stdout.split('\n')) {-          const name = line.trim().replace(/['"]/g, '');-          if (name) profileNames.add(name);-        }-      }-    } else {-      // POSIX equivalent — quick ps + grep + extract.-      const { spawnSync } = require('child_process');-      const result = spawnSync('ps', ['-eo', 'command'], { encoding: 'utf8', timeout: 5_000 });-      if (result.stdout) {-        const seen = new Set();-        for (const line of result.stdout.split('\n')) {-          // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).-          if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue;-          if (line.includes('--type=')) continue;-          if (!line.includes('remote-debugging-port')) continue;-          if (!line.includes(PROFILES_DIR)) continue;-          const m = line.match(new RegExp(`user-data-dir=([^\\s]+/${path.basename(PROFILES_DIR)}/[^\\s]+)`));-          if (m) {-            const name = path.basename(m[1]);-            if (!seen.has(name)) { seen.add(name); profileNames.add(name); }-          }-        }-      }-    }-  } catch (e) {-    console.log(`[rescanAll] orphan process scan failed: ${e.message}`);-  }-  let totalReattached = 0, totalAdopted = 0, profilesScanned = 0, profilesReconnected = 0, profilesUnreachable = 0;-  for (const profileName of profileNames) {-    let browser = browsers.get(profileName)?.browser;-    if (!browser || !browser.isConnected()) {-      browser = await attemptReconnectProfile(profileName);-      if (browser) profilesReconnected++;-    }-    if (!browser) {-      profilesUnreachable++;-      // Mark any in-memory sessions for this profile as still disconnected-      for (const s of sessions.values()) {-        if (s.profileName === profileName) s._lostBrowser = true;-      }-      continue;-    }-    try {-      const r = await rescanProfile(browser, profileName, { adoptOrphans });-      totalReattached += r.reattached;-      totalAdopted += r.adopted;-      profilesScanned++;-    } catch (e) {-      console.log(`[rescan] profile "${profileName}" rescan threw: ${e.message}`);-    }-  }-  return { totalReattached, totalAdopted, profilesScanned, profilesReconnected, profilesUnreachable };-}--// Recover sessions from disk on startup — reconnect to existing Chrome processes-// v1.9.45: crash sentinel. Written before the risky recovery ops for ONE session and removed-// after that session settles, so a leftover file at boot names exactly the session that killed us.-const RECOVERY_SENTINEL = path.join(SESSIONS_DIR, '.recovery-in-flight');-let _poisonSessionId = null;--// v2.0.15 (#557 ask 2): TIMEOUT every await in recovery that can hang on a wedged/orphaned Chrome-// (fetch /json/version, puppeteer.connect, browser.pages(), page.evaluate). Since v2.0.13 recovery-// runs AFTER the port binds (fire-and-forget), so a hang can no longer block startup or the health-// probe — but it could still wedge the recovery pass itself forever: sessions half-recovered, the-// RECOVERY_SENTINEL left naming an innocent session, and the next boot quarantining it. Every risky-// await now races a deadline and falls back, so one wedged window costs seconds, never the pass.-// Rejections resolve to the fallback too (callers treated them as fallbacks already).-const _recoverTimeout = (p, ms, fallback) => Promise.race([-  Promise.resolve(p).catch(() => fallback),-  new Promise(r => setTimeout(() => r(fallback), ms)),-]);--const _recoveryProbeMemo = new Map();   // v2.0.392: pid -> probe verdict, one per recovery pass-async function recoverSessions() {-  _recoveryProbeMemo.clear();-  const files = listSessionFiles();-  try {-    if (fs.existsSync(RECOVERY_SENTINEL)) {-      _poisonSessionId = fs.readFileSync(RECOVERY_SENTINEL, 'utf8').trim() || null;-      if (_poisonSessionId) console.error(`[recovery] last boot died while recovering "${_poisonSessionId}" — that ONE session will be quarantined`);-      fs.unlinkSync(RECOVERY_SENTINEL);-    }-  } catch (e) {}-  if (files.length === 0) return;-  // v2.0.45: claim every persisted handle UP FRONT, before any recovery/resolve can race — this is-  // what stops an early-resolving session from stealing a handle whose owner recovers later.-  for (const sf0 of files) { try { if (sf0._hwnd && sf0.sessionId) _persistedHwndClaims.set(Number(sf0._hwnd), sf0.sessionId); } catch (e) {} }-  console.log(`Recovering ${files.length} session(s) from disk... (${_persistedHwndClaims.size} persisted handle claims pre-loaded)`);-  // v1.9.154: pages already bound in THIS recovery pass — two sessions must never share a page-  // (the espn double-bind: a poisoned session file URL-matched another session's page).-  const _recoveryClaimed = new Set();--  for (const sf of files) {-    const { sessionId, profile: profileName, pid, cdpPort, url } = sf;--    // Skip if already in memory-    if (sessions.has(sessionId)) continue;--    // v1.9.6: PERSISTENT CRASH CIRCUIT BREAKER. A NATIVE crash during recovery (inside-    // puppeteer.connect / a CDP call / a native module) kills the process HARD — before the-    // per-session try/catch below can delete the file — so the SAME poison session file-    // re-crashes pup on EVERY boot: the ~60s respawn loop John hit 2026-07-19 (AD's lifecycle log-    // showed spawn-with-no-reap every minute; in-process try/catch can't stop a native crash).-    // Fix: persist a recover-attempt marker BEFORE the risky ops. A file that already has an-    // INCOMPLETE recovery attempt (crashed us last boot) is QUARANTINED — deleted, not retried.-    // A recovery that FAILS gracefully deletes its own file (catch below), and a recovery that-    // SUCCEEDS clears the marker (saveSessionFile rewrites without it) — so a leftover marker-    // means "this file crashed recovery," and one strike quarantines it. Bounds any poison file-    // to a single crash instead of an unbounded loop.-    // v1.9.45: the per-session `recoverAttempts` strike above was WRONG and cost John all 6 of his-    // windows. It quarantined any file carrying an unfinished-attempt marker, but "unfinished" is-    // not the same as "crashed": any restart that races the marker-clearing write leaves the mark-    // behind on a perfectly healthy session, so a NORMAL restart could quarantine EVERYTHING (log:-    // "Recovering 6 session(s) from disk... Recovery complete. 0 session(s) active"). Crash-    // protection must not be able to delete healthy sessions — that is a worse failure than the-    // respawn loop it guards against.-    // The evidence we actually need is WHICH session was in flight when the process died. That is-    // one global sentinel, written before the risky ops and removed after, naming exactly one-    // session. A crash leaves it pointing at the poison file; everything else recovers untouched.-    if (_poisonSessionId === sessionId) {-      console.error(`Session "${sessionId}" was mid-recovery when pup died last boot (native crash) — QUARANTINING just this one; all other sessions still recover`);-      deleteSessionFile(sessionId);-      continue;-    }-    try { fs.writeFileSync(RECOVERY_SENTINEL, sessionId); } catch (e) {}--    // Check if Chrome is still alive-    if (!isProcessAlive(pid)) {-      console.log(`Session "${sessionId}" (PID ${pid}) is dead — removing stale file`);-      deleteSessionFile(sessionId);-      continue;-    }--    // Try to reconnect via CDP-    if (!cdpPort) {-      // Try reading DevToolsActivePort from profile dir-      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');-      try {-        const portData = fs.readFileSync(portFile, 'utf8').trim();-        sf.cdpPort = parseInt(portData.split('\n')[0], 10) || null;-      } catch {}-    }--    const port = sf.cdpPort || cdpPort;-    if (!port) {-      console.log(`Session "${sessionId}" has no CDP port — removing stale file`);-      deleteSessionFile(sessionId);-      continue;-    }--    try {-      // v2.0.392 (release sweep on winvm, 2026-09-04): this used to be ONE 5s probe, and a miss-      // meant SIGKILL of the Chrome PID plus deleting the session file — per session, so seven-      // sessions each tried to kill the same shared Chrome. On a 4-vCPU VM with ten heavy tabs-      // the devtools endpoint simply answered slowly, and pup destroyed nine of the user's windows-      // during a routine bridge update (both laptops answered fast and survived the same restart).-      // A SLOW Chrome is not an orphaned Chrome. Recovery now (a) probes with escalation, 5s -> 15s-      // -> 30s, (b) memoizes ONE verdict per PID for the whole pass, and (c) on a persistent miss-      // NEVER kills and NEVER deletes: the session file stays, the session is left for the 30s-      // health loop / pup_rescan to reconnect. A truly dead process is already handled above by-      // isProcessAlive; a live process that will not answer keeps its windows, which the user can-      // see and close themselves. Destroying visible windows is the worse failure, every time.-      if (!_recoveryProbeMemo.has(pid)) {-        let ok = false;-        for (const cap of [5000, 15000, 30000]) {-          const resp = await _recoverTimeout(fetch(`http://127.0.0.1:${port}/json/version`), cap, null);-          if (resp && resp.ok) { ok = true; break; }-          console.log(`Session "${sessionId}" CDP port ${port} did not answer within ${cap / 1000}s — retrying with a longer cap (Chrome PID ${pid} is alive; a slow Chrome is not an orphan)`);-        }-        _recoveryProbeMemo.set(pid, ok);-      }-      if (!_recoveryProbeMemo.get(pid)) {-        console.log(`Session "${sessionId}" CDP port ${port} still not answering after 50s of probes — LEAVING Chrome PID ${pid} and this session file alone (awaiting reconnect; the health loop and pup_rescan keep trying)`);-        continue;-      }--      // Reconnect browser if needed-      if (!browsers.has(profileName) || !browsers.get(profileName).browser.isConnected()) {-        // 15s cap — puppeteer.connect against a wedged Chrome otherwise hangs the whole pass (#557)-        const browser = await _recoverTimeout(puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }), 15000, null);-        if (!browser) throw new Error(`CDP connect to port ${port} hung or failed (15s cap) — wedged or orphaned Chrome`);-        browser._detachedPid = pid; // Preserve PID so killBrowserProcess can find it-        browser._cdpPort = port;-        browsers.set(profileName, { browser, refCount: 0 });-        wirePopupTracking(browser, profileName);-        // v1.5.1+: shared disconnect handler — keeps sessions intact for-        // recovery instead of erasing them. See handleBrowserDisconnect.-        browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));-        console.log(`Reconnected to Chrome on port ${port} for profile "${profileName}"`);-      }--      // Find the page that matches this session's URL (10s cap on pages() — same wedge risk)-      const browser = browsers.get(profileName).browser;-      let pages = await _recoverTimeout(browser.pages(), 10000, null);-      if (!pages) { console.log(`Session "${sessionId}": browser.pages() did not answer in 10s — one more try with a 30s cap before treating it as wedged`); pages = await _recoverTimeout(browser.pages(), 30000, null); }-      if (!pages) throw new Error('browser.pages() hung (10s + 30s caps) — wedged Chrome; session left for reconnect');-      let page = null;--      // v1.9.154 — THE fourth matcher, fixed (two failed binding proofs cornered it here):-      // 1) window.name FIRST: the durable identity every injector stamps since 1.9.153. A saved-      //    URL can be POISONED by a prior mis-bind (docs-writer's file said espn.com), so URL-      //    matching alone re-creates the bug forever.-      for (const p of pages) {-        if (_recoveryClaimed.has(p)) continue;-        try {-          const wn = await _recoverTimeout(p.evaluate(() => window.name), 3000, '');-          if (wn === 'pup:' + sessionId) { page = p; break; }-        } catch (e) {}-      }-      // 2) URL match — but NEVER a page another session already claimed, and never one that-      //    carries a DIFFERENT session's pup:<id> name.-      if (!page && url) {-        for (const p of pages) {-          if (_recoveryClaimed.has(p)) continue;-          try {-            if (!p.url().startsWith(url.split('?')[0])) continue;-            const wn = await _recoverTimeout(p.evaluate(() => window.name), 3000, '');-            if (wn && wn.startsWith('pup:') && wn !== 'pup:' + sessionId) continue;-            page = p; break;-          } catch (e) {}-        }-      }-      // 3) last unclaimed non-blank page that doesn't belong to someone else.-      if (!page) {-        const cands = [];-        for (const p of pages) {-          if (_recoveryClaimed.has(p)) continue;-          try {-            if (p.url() === 'about:blank') continue;-            const wn = await _recoverTimeout(p.evaluate(() => window.name), 3000, '');-            if (wn && wn.startsWith('pup:') && wn !== 'pup:' + sessionId) continue;-            cands.push(p);-          } catch (e) {}-        }-        page = cands.pop() || null;-      }-      if (page) _recoveryClaimed.add(page);-      // Last resort: any page-      if (!page && pages.length > 0) {-        page = pages[pages.length - 1];-      }--      if (!page) {-        console.log(`Session "${sessionId}" — no pages found in Chrome, removing`);-        deleteSessionFile(sessionId);-        continue;-      }--      const session = createSession(profileName);-      attachTab(session, sessionId, page);-      session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution-      // v1.9.181: restore the persisted foreground choice BEFORE the saveSessionFile rewrite below-      // (which reads it back out of the session) — otherwise every restart wiped the flag and the-      // park below yanked a window the user was actively using to the bottom (John, 2026-07-29).-      if (sf.foreground) { session._foreground = true; session._userActiveAt = Date.now(); }-      // v2.0.356 (issue #79): MIGRATE AWAY the 'reclaimed' sentinel on restore. Removing the line-      // that WROTE it (2.0.355) was not enough, because sessions persist and the poisoned owner is-      // restored verbatim on every start. John's laptop still showed the tile after the fix landed:-      // the bug was already on disk. Treat the sentinel as the absence it always meant, so an-      // affected machine heals on its next restart instead of carrying the tile forever.-      if (sf.owner === 'reclaimed') {-        console.log(`[migrate] session "${sf.sessionId || ''}": dropping the legacy 'reclaimed' owner sentinel (issue #79) — it is an absence, not a thread name`);-      } else if (sf.owner) session.owner = sf.owner;   // v1.9.197 BUG 4: restore ownership across restarts-      // v1.9.300: restore the persisted OS handle. The Chrome window kept the SAME hwnd across the-      // respawn, so this lets the window skip the width-wiggle entirely; resolveSessionHwndByBounds-      // still validates it via hwndBelongsToPup before any paint, so a stale/recycled handle is-      // discarded. This is the fix for "every window came back hwnd=None after a ship."-      if (sf._hwnd) {-        session._hwnd = Number(sf._hwnd);-        // v2.0.43 (mimic-ralph: EVERY recovered window came back NO-HANDLE/NO-OVERLAY after a-        // respawn): restoring the hwnd is useless if hwndBelongsToPup then REJECTS it — and it did,-        // every time, because all four ownership proofs are dead for a recovered window (no birth-        // capture in this process, stale pid tracking, dead parent, title tag removed in 2.0.24).-        // The honest proof we DO have: this session file also persisted Chrome's pid, and Chrome-        // never restarted across a pup respawn — so "persisted hwnd whose CURRENT owner pid equals-        // the persisted Chrome pid" ties the handle to the exact process it was born under. Record-        // the pid; hwndBelongsToPup accepts that pair as proof (and learns the pid for siblings).-        if (pid) session._recoveredHwndPid = Number(pid) || null;-        console.log(`[recovery] "${sessionId}" restored persisted hwnd ${sf._hwnd} (continuity proof pid ${pid || 'none'})`);-        // v2.0.357: RE-TAG on recovery. The identity property lives in Chrome's process, so it-        // normally survives a pup respawn, but it does NOT survive the case that actually matters:-        // a window that was adopted before 2.0.357 (never tagged) or one whose Chrome restarted.-        // Re-writing it here is idempotent and costs one in-process SetProp, and it means a machine-        // carrying untagged windows heals on its next restart rather than staying unresolvable.-        tagWindowSession(Number(sf._hwnd), sessionId).catch(() => {});-        // v2.0.366 (project-a5): RE-DERIVE THE EMULATION. Recovery restored the window but not its-        // emulation record, and Chrome keeps the CDP override because it lives in the browser. The-        // record defaulted to null, so every consumer confidently reported "not emulated" about a-        // window that was. Measure it back rather than guessing: a page whose innerWidth diverges-        // from its outerWidth is emulated, whatever pup happens to remember.-        (async () => {-          try {-            const t0 = (session.tabs || [])[0];-            if (!t0 || !t0.page) return;-            const m = await measureViewport(t0.page, session);-            if (m && m.emulated) {-              session._emulatedViewport = { width: m.innerWidth, height: m.innerHeight, deviceScaleFactor: m.devicePixelRatio, derived: true };-              session._lastOuterWidth = m.outerWidth;-              console.log(`[recovery] "${sessionId}" is still EMULATED at ${m.innerWidth}x${m.innerHeight} in a ${m.outerWidth}px window — record re-derived (it does not survive recovery on its own)`);-            }-          } catch (e) {}-        })();-        // #80: tell this session it went through a restart, and how many tabs came back, so a-        // thread that opened three and finds one knows WHY rather than concluding pup ate them.-        const _tabsBack = (session.tabs || []).length;-        noteSessionDisruption(session, `pup's bridge restarted (upgrade or respawn) and this window was recovered. ${_tabsBack} tab(s) came back. If you opened more than that, the missing ones were regrouped rather than closed: re-open them and they will stick. pup did not close anything.`);-      }-      // v1.9.202 (John's icon audit): a recovered window came back with NO taskbar badge. Under-      // minimalTouch the overlay is applied only on OPEN and NAVIGATE, and the re-stamp sweeps that-      // used to cover recovery are gated off — so every bridge restart left surviving windows bare-      // (drew-nsf-pcl had favicon+adom:wiki.adom.inc, then lost it). Re-wear it after recovery.-      setTimeout(() => { applyAppOverlay(session, sessionId).catch(() => {}); }, 2500);-      sessions.set(sessionId, session);-      const be = browsers.get(profileName);-      if (be) be.refCount++;-      if (!activeSessionId) activeSessionId = sessionId;--      // v1.9.6: recovered CLEANLY — rewrite the file WITHOUT the recover-attempt marker so the-      // circuit breaker doesn't quarantine a healthy session on the next boot.-      try { let u = null; try { u = page.url(); } catch (e) {} saveSessionFile(sessionId, profileName, u); } catch (e) {}-      console.log(`Recovered session "${sessionId}" (profile: ${profileName}, URL: ${page.url()})`);-      // v1.9.70 — RE-PARK ON RECOVERY. The background-by-default rule was only ever enforced on the-      // LAUNCH path. Recovery re-attaches to a window that already exists, and after a REBOOT the OS-      // restores those Chrome/Edge windows in the FOREGROUND — so every recovered pup window sat on-      // top of the user's screen and nothing ever pushed it back down. That is John's "after my reboot-      // almost all pup windows are opening in the foreground". Park unless the session was explicitly-      // left in the foreground by a user-granted raise (that state is recorded on the session file).-      if (!userIsUsingWindow(session)) {-        parkSessionWindowDirect(page, sessionId)-          .then(r => console.log(`[park] "${sessionId}" (recovered): ${r}`))-          .catch(e => console.log(`[park] "${sessionId}" (recovered) failed: ${e.message}`));-      } else {-        console.log(`[park] "${sessionId}" (recovered): SKIPPED — user had this window foregrounded (persisted flag); leaving it exactly where they had it`);-      }-    } catch (e) {-      console.error(`Failed to recover session "${sessionId}": ${e.message}`);-      deleteSessionFile(sessionId);-    }-  }-  // Every session got through its risky section, so nothing is in flight — drop the sentinel.-  // (Each iteration rewrites it, so a crash always leaves it naming the session that died.)-  try { if (fs.existsSync(RECOVERY_SENTINEL)) fs.unlinkSync(RECOVERY_SENTINEL); } catch (e) {}-  _poisonSessionId = null;-  console.log(`Recovery complete. ${sessions.size} session(s) active.`);-}--// Catch unhandled errors so server never crashes-process.on('uncaughtException', (e) => {-  console.error('Uncaught:', e.message);-});-process.on('unhandledRejection', (e) => {-  console.error('Unhandled rejection:', e);-});-// On bridge shutdown, disconnect from Chrome without killing it.-// Chrome has handleSIGINT/SIGTERM/SIGHUP: false and --remote-debugging-port=0,-// so it'll keep running. The session files on disk let the next bridge reconnect.-process.on('exit', () => {-  for (const [, be] of browsers) {-    try { be.browser.disconnect(); } catch {}-  }-});-process.on('SIGINT', () => { process.exit(0); });-process.on('SIGTERM', () => { process.exit(0); });--// v1.9.164 (John: 'you need the simulated mouse like what nb has so the user knows you're-// controlling the mouse'). An in-page animated cursor for pup's clicks: a teal Adom pointer that-// GLIDES from its last spot to the click target and PULSES a ring on click. Injected via CDP so it-// rides on top of whatever page is loaded (pointer-events:none, max z-index). Only visible when the-// window is on-screen — which is exactly when the user is watching pup drive. Best-effort; a page-// that blocks injection (rare) just doesn't show it, the click still fires.-// v1.9.196 (John: "why didn't you make it look like abe's or hd's? we should have a consistent-// cursor in all of our apps. hd's looks amazing" + "i can't even read the indicator text ... and i-// don't see the adom logo in there either").-//-// This is now HD's AI cursor, ported BYTE-FAITHFUL from the canonical source-// (adom-browser-extension/extension/src/cdp.js `cursorScript`, itself an exact match of HD's-// hd-control AI_CURSOR_JS) so the cursor is IDENTICAL across HD, the extension, and pup. My previous-// hand-drawn version was a fourth variant nobody asked for: it had NO intent label at all (the-// reasoning went only to a bottom-of-screen caption) and NO Adom mark, just a 7px letter "A".-//-// What HD's cursor gives us: the teal arrow (#00b8b0) with dark-teal outline + glow, a PULSING teal-// ring around the tip, and a readable monospace LABEL that narrates what the AI is doing right now,-// which flips to the left of the cursor near the screen edge so it never clips.-//-// ADDITION for pup (John's ask): a small ADOM MARK inside the label chip, so the user can tell it is-// ADOM driving the cursor and not some other AI process. It is the real brand asset (icons/-// pup-overlay-32.png) inlined as a data URI, not a drawn letter.-let _pupCursorMarkUri = null;-function pupCursorMarkUri() {-  if (_pupCursorMarkUri !== null) return _pupCursorMarkUri;-  try { _pupCursorMarkUri = 'data:image/png;base64,' + fs.readFileSync(path.join(__dirname, 'icons', 'pup-overlay-32.png')).toString('base64'); }-  catch (e) { _pupCursorMarkUri = ''; }-  return _pupCursorMarkUri;-}--async function hidePupCursorEverywhere(page) {-  // v1.9.198 (Bills thread): an ORPHANED cursor sat on the username field forever — old-style art,-  // never moving, while the live cursor moved around it. Two causes, both fixed here:-  //   (1) LEGACY ID. Pre-1.9.196 pup drew '#__pupCursor'; the HD port draws '#__adom_cursor__'. The-  //       new code never removed the old element, so any page that had ever hosted the old cursor-  //       kept it on screen forever.-  //   (2) PER-FRAME LEAK. The cursor is injected into whatever DOCUMENT is driven. Interacting with a-  //       field inside an iframe (Chase's iframe#logonbox) draws a cursor in THAT frame's document;-  //       when focus returns to the top frame nothing ever disposed the iframe's instance.-  // So: sweep EVERY frame and remove BOTH ids before drawing, and on explicit hide.-  const frames = (() => { try { return page.frames(); } catch (e) { return [page]; } })();-  await Promise.all(frames.map(fr => fr.evaluate(() => {-    try {-      ['__adom_cursor__', '__pupCursor', '__adom_cursor_ring__', '__adom_cursor_label__'].forEach(id => {-        const el = document.getElementById(id);-        if (el && el.parentNode) el.parentNode.removeChild(el);-      });-    } catch (e) {}-  }).catch(() => {})));-}--// v2.0.321 (#72): trusted input via a DEDICATED, TIMEOUT-BOUNDED CDP session, NOT puppeteer's-// page.mouse/keyboard. puppeteer serializes every mouse op behind an internal lock; when one-// Input.dispatchMouseEvent hangs (a click handler that awaits navigator.clipboard.writeText in an-// UNFOCUSED window neither resolves nor rejects), that lock is held forever and EVERY later dispatch-// on the page wedges — the session's input is dead until the window is recreated, because the lock-// lives in puppeteer's memory where pup_reload/pup_rescan cannot reach it. A per-tab CDP session we-// OWN, with a per-call deadline, isolates the failure: a hang times out, we DISCARD that session,-// and the next call makes a fresh one. One bad page handler can no longer poison the whole session.-async function _inputCdp(tab) {-  if (tab._inputCdp && !tab._inputCdpDead) return tab._inputCdp;-  const sess = await tab.page.target().createCDPSession();-  tab._inputCdp = sess; tab._inputCdpDead = false;-  return sess;-}-function killInputCdp(tab) {-  const sess = tab && tab._inputCdp;-  if (tab) { tab._inputCdp = null; tab._inputCdpDead = false; }-  if (sess) { try { const r = sess.detach(); if (r && r.catch) r.catch(() => {}); } catch (e) {} }-}-async function _rawInput(tab, method, params, ms = 6000) {-  const sess = await _inputCdp(tab);-  try {-    return await Promise.race([-      sess.send(method, params),-      new Promise((_, rej) => setTimeout(() => rej(new Error('input_dispatch_timeout')), ms)),-    ]);-  } catch (e) {-    // a hung/failed command leaves this CDP session with a stuck in-flight send; discard it so the-    // NEXT dispatch is clean. THIS is the #72 fix: the wedge can never persist past one call.-    killInputCdp(tab);-    if (String(e && e.message) === 'input_dispatch_timeout') { const err = new Error('input timed out after ' + ms + 'ms — a page handler hung (e.g. clipboard write in an unfocused window). The input channel was reset; retry, and if it persists the page handler is the cause.'); err.wedge = true; throw err; }-    throw e;-  }-}-const _btnMask = (b) => (b === 'right' ? 2 : b === 'middle' ? 4 : 1);-async function rawTrustedClick(tab, x, y, { button = 'left', clickCount = 1, ms = 6000 } = {}) {-  const b = button === 'right' ? 'right' : button === 'middle' ? 'middle' : 'left';-  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, ms);-  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: b, buttons: _btnMask(b), clickCount }, ms);-  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: b, buttons: 0, clickCount }, ms);-}-async function rawTrustedMove(tab, x, y, { ms = 6000 } = {}) {-  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, ms);-}--async function showPupCursor(page, x, y, { click = true, label = null } = {}) {-  try {-    // Remove any stale instance ANYWHERE (legacy id, or one left in an iframe) before drawing.-    await hidePupCursorEverywhere(page);-    await page.evaluate((tx, ty, doClick, lbl, markUri) => {-      var id = '__adom_cursor__';-      var c = document.getElementById(id);-      if (!c) {-        c = document.createElement('div');-        c.id = id;-        c.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;transition:left 0.15s,top 0.15s;display:none;';-        var g = document.createElement('div'); // pulsing teal glow ring-        g.id = '__adom_cursor_ring__';-        g.style.cssText = 'position:absolute;top:-16px;left:-16px;width:40px;height:40px;border-radius:50%;background:rgba(0,184,176,0.15);border:2px solid rgba(0,184,176,0.4);animation:adomCurP 1.5s ease-in-out infinite;';-        c.appendChild(g);-        var a = document.createElement('div'); // the teal Adom arrow (HD's exact SVG)-        a.innerHTML = '<svg width="20" height="28" viewBox="0 0 20 28" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L1 22L6.5 16.5L10.5 26L14 24.5L10 15L17 15Z" fill="#00b8b0" stroke="#004d49" stroke-width="1.2" stroke-linejoin="round"/></svg>';-        a.style.cssText = 'position:absolute;top:0;left:0;filter:drop-shadow(0 0 6px rgba(0,184,176,0.7));';-        c.appendChild(a);-        var l = document.createElement('div'); // the intent label (+ Adom mark)-        l.id = '__adom_cursor_label__';-        l.style.cssText = 'position:absolute;top:32px;left:8px;white-space:nowrap;font-size:11px;color:#00b8b0;background:rgba(13,17,23,0.92);padding:2px 10px;border-radius:4px;border:1px solid rgba(0,184,176,0.6);font-family:monospace;display:flex;align-items:center;gap:6px;';-        c.appendChild(l);-        (document.body || document.documentElement).appendChild(c);-        if (!document.getElementById('__adom_cur_kf__')) {-          var s = document.createElement('style'); s.id = '__adom_cur_kf__';-          s.textContent = '@keyframes adomCurP{0%{transform:scale(1);opacity:0.7}50%{transform:scale(1.5);opacity:0.2}100%{transform:scale(1);opacity:0.7}}@keyframes adomCurClick{0%{transform:scale(1);opacity:0.85}45%{transform:scale(2.2);opacity:0.05}100%{transform:scale(1);opacity:0.7}}';-          document.head.appendChild(s);-        }-      }-      var lab = c.querySelector('#__adom_cursor_label__');-      if (lab) {-        var txt = (lbl != null ? lbl : 'Adom');-        // Adom mark FIRST (so "who is driving" reads before the narration), then the intent text.-        lab.innerHTML = '';-        if (markUri) {-          var im = document.createElement('img');-          im.src = markUri;-          im.style.cssText = 'width:13px;height:13px;flex:0 0 13px;display:block;';-          lab.appendChild(im);-        }-        var sp = document.createElement('span');-        sp.textContent = txt;-        lab.appendChild(sp);-        var nearRight = (tx != null) && (tx > (window.innerWidth - 250));-        if (nearRight) { lab.style.left = 'auto'; lab.style.right = '0px'; lab.style.textAlign = 'right'; }-        else { lab.style.left = '8px'; lab.style.right = 'auto'; lab.style.textAlign = 'left'; }-      }-      if (tx != null && ty != null) { c.style.left = tx + 'px'; c.style.top = ty + 'px'; }-      c.style.display = 'block';-      if (doClick) {-        var ring = c.querySelector('#__adom_cursor_ring__');-        if (ring) { ring.style.animation = 'none'; void ring.offsetWidth; ring.style.animation = 'adomCurClick 0.45s ease-out, adomCurP 1.5s ease-in-out infinite 0.45s'; }-      }-    }, x, y, click, label, pupCursorMarkUri());-  } catch (e) {}-}--// v1.9.221 (John, standing rule): A CAPTION IS NEVER SHOWN LONGER THAN 2 SECONDS. Callers had been-// passing 5s, 6s, 8s and even 15s, which parks text over the user's screen long after it has been-// read — he caught a 15s one live. The cap is enforced HERE, at the single choke point, so no caller-// can violate it regardless of what it requests. A caption is a glance, not a dialog: if something-// genuinely needs the user's attention for longer, use a toast (notify_user), which they dismiss.-// v2.0.294 (John: "you put them up so fast, i don't think you're following your own timer"): he was-// right — this cap was 2000 and silently OVERRODE every caller's stated duration (assisted-click's-// 9s/7s/5s narration, the 4s screenshot caption: all actually showed 2s). The anti-rudeness cap now-// matches the reading-time model's ceiling; callers state honest durations (word-scaled via-// captionMsFor) and this only stops a runaway from squatting past 12s.-const PUP_CAPTION_MAX_MS = 12000;-async function pupCaption(text, expiresInMs = PUP_CAPTION_MAX_MS, id = 'pup-assist', maxMs) {-  // maxMs: a USER-CHOSEN duration (settings slider) may exceed the 2s anti-rudeness cap — the cap-  // exists to stop the AI outstaying its welcome, not to override the user's own preference.-  const _cap = Number(maxMs) > 0 ? Number(maxMs) : PUP_CAPTION_MAX_MS;-  expiresInMs = Math.min(Number(expiresInMs) || _cap, _cap);-  // v1.9.244 (John: "you left the caption up forever. i've told you 100 times: 2 seconds"). The 2s-  // clamp NEVER REACHED AD: current AD's desktop_caption takes `duration`, pup sent `expiresInMs`-  // (an older AD spelling), so AD ignored it and used its 30s default. Send BOTH names so every AD-  // build honors its own; the clamp finally bites.-  // v2.0.293 (John: "you just foregrounded a pup window and never showed an ab caption"): a failed-  // caption used to vanish silently — the raise proceeded and nobody could tell WHY no caption-  // showed. Failures now log loudly; the raise still proceeds (the caption is a courtesy, but the-  // silence made every miss undebuggable).-  try {-    const _r = await chrome.abCommand('desktop_caption', { text, duration: expiresInMs, expiresInMs, id, position: 'bottom', size: 'medium' }, { timeoutMs: 4000 });-    if (!_r || _r.ok === false || _r.success === false) console.log(`[caption] desktop_caption did NOT confirm (id=${id}): ${JSON.stringify(_r || null).slice(0, 200)} — text was: ${String(text).slice(0, 80)}`);-  } catch (e) { console.log(`[caption] desktop_caption FAILED (id=${id}): ${e.message} — text was: ${String(text).slice(0, 80)}`); }-}-// v2.0.137 (John's Activity Log flooded with find_window: "why do you loop like crazy?"):-// NEGATIVE-RESULT BACKOFF for the title hunt. A session whose window cannot be resolved by title-// (a ghost adoption whose page rewrote the title tag, or a window that is simply gone) used to be-// re-hunted by EVERY periodic sweep forever — the user-activity watcher walks every session each-// tick, cachedSessionHwnd only caches SUCCESSES, and each miss fired a fresh desktop_find_window-// into ab's Activity Log. Failures now back off exponentially (1 min doubling to a 30-min cap),-// any success clears the slate, and the 5th consecutive miss surfaces the ghost once via the-// cleanup report instead of grooming it silently until the end of time.-const _hwndHuntFails = new Map();   // sessionId -> { fails, ts }-async function sessionHwnd(sessionId) {-  // v1.9.212: prefer the STORED handle — a title lookup cannot find a PDF-viewer window at all.-  try {-    const _s = sessions.get(sessionId);-    if (_s && _s._hwnd) { _hwndHuntFails.delete(sessionId); return Number(_s._hwnd); }-    if (_s) { const h = await bestSessionHwnd(_s, sessionId); if (h) { _hwndHuntFails.delete(sessionId); return h; } }-  } catch (e) {}-  const _nf = _hwndHuntFails.get(sessionId);-  if (_nf && Date.now() - _nf.ts < Math.min(60000 * Math.pow(2, _nf.fails - 1), 30 * 60000)) return null;-  const _recordMiss = () => {-    const fails = (_nf ? _nf.fails : 0) + 1;-    _hwndHuntFails.set(sessionId, { fails, ts: Date.now() });-    if (fails === 5) {-      console.log(`[hwnd-hunt] "${sessionId}" unresolvable by title 5x in a row — likely a ghost adoption or a rewritten title tag; backing off to 30-min retries`);-      recordCleanup(`window "${sessionId}" cannot be found on the desktop by its title (5 straight misses) — pup stopped hunting for it every sweep (30-min retries now). If this window is real, pup_rescan re-binds it; if it is a ghost, pup_close_window "${sessionId}" clears it.`);-    }-  };-  try {-    const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000, caller: sessionCaller(sessions.get(sessionId)) }));-    const b = fw && (fw.best || (fw.data && fw.data.best));-    const h = b && (b.hwnd != null ? Number(b.hwnd) : null);-    if (h) { _hwndHuntFails.delete(sessionId); return h; }-    _recordMiss();-    return null;-  } catch (e) { _recordMiss(); return null; }-}-// The RESPECT LOOP (John: 'detect whether they alt+tabbed away ... wait until i bring the window-// back'). Blocks until the user's foreground IS this pup window and stays so briefly, captioning-// that we are waiting so we never act while they look elsewhere. true when ready, false on timeout.-async function waitForUserReady(sessionId, hwnd, { maxMs = 120000 } = {}) {-  if (process.platform !== 'win32' || !hwnd) return true;-  const start = Date.now();-  let stableSince = 0, lastCap = 0, capCount = 0;-  // #437 (John: "an ad caption is disturbing my work"): BACKOFF, not a keep-alive loop. Show the-  // wait-for-focus assist a couple of times then go SILENT — an unattended wait can be hours, and a-  // permanent on-screen "I am waiting" nag is exactly the disturbance the caption TTL model exists to-  // prevent. Gated on the user's fgCaption pref and honoring their fgCaptionMs duration (the call site-  // already showed an initial notify_user toast + caption, so 'off' never leaves the user unaware).-  const _capOn = pupSettings().fgCaption === 'on';-  const _capMs = Math.max(500, Math.min(12000, Number(pupSettings().fgCaptionMs) || 2000));-  const MAX_CAPS = 3;   // initial reminder + at most 2 more, then stop-  while (Date.now() - start < maxMs) {-    const fg = await osGetForegroundWindow();-    if (fg === hwnd) {-      if (!stableSince) stableSince = Date.now();-      if (Date.now() - stableSince >= 1200) return true;-    } else {-      stableSince = 0;-      if (_capOn && capCount < MAX_CAPS && Date.now() - lastCap > 3500) {-        lastCap = Date.now(); capCount++;-        pupCaption('Adom is waiting: click back into THIS window when you are ready, and I will continue. (Nothing happens until you do.)', _capMs, 'pup-assist', _capMs).catch(() => {});-      }-    }-    await new Promise(r => setTimeout(r, 400));-  }-  return false;-}-function setupErrorCollection(pg, errorsArr) {-  // v1.9.163 (crash fix): bounded buffer + NO requestfailed listener. The requestfailed listener-  // enabled the CDP Network domain, whose per-request event stream is a firehose on request-heavy-  // SPAs and saturated pup's single shared CDP WebSocket, jamming every session at once. Network-  // failures are rarely the actionable error anyway; console.error + pageerror are. And the buffer-  // is now capped — a spammy page used to grow it unbounded.-  const CAP = 300;-  const push = (e) => { errorsArr.push(e); if (errorsArr.length > CAP) errorsArr.splice(0, errorsArr.length - CAP); };-  pg.on('console', msg => { if (msg.type() === 'error') push({ type: 'console.error', text: msg.text(), ts: Date.now() }); });-  pg.on('pageerror', err => push({ type: 'exception', text: err.message, ts: Date.now() }));-}--// Kill a Chrome browser process forcefully by PID-// ─── Reliable renderer/orphan reaping (close leaving core-pegging renderers → froze the desktop) ──-//-// v1.9.89 — the teardown reap is now AWAITED + VERIFIED + load-tolerant. The old sweep was-// FIRE-AND-FORGET with an 8s timeout on a FULL Win32_Process enumeration, which TIMES OUT precisely-// when the box is pegged by the runaway renderers it exists to kill (measured on AdomLapper: overall-// CPU 88-98% on 16 cores, dwm starved to 38-63%, mouse frozen, forced reboot; 6 CfT procs survived a-// pup_close, 4 --type=renderer children near 100%). Three fixes: (1) push the image-name filter to-// WMI server-side (-Filter "Name='chrome.exe' OR Name='msedge.exe'") so ONLY browser rows are-// materialized — fast even at 98% CPU; (2) AWAIT the reap so close() VERIFIES nothing survives before-// returning ok; (3) return chromeProcsRemaining so the caller KNOWS the teardown completed. Still-// non-blocking: async execFile inside an awaited Promise suspends only THIS request's async chain,-// never the event loop (the v1.9.81 rule was against execFileSync/execSync on the close path).-const _isWin = process.platform === 'win32';-// Enumerate chrome/msedge {pid, cmd} matching a PowerShell -Where clause on CommandLine. The -Filter-// makes WMI return only browser processes server-side, so this stays cheap under heavy CPU load.-function _psProcs(whereClause) {-  return new Promise((resolve) => {-    if (!_isWin) { resolve([]); return; }-    const ps = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='msedge.exe'" | Where-Object { ${whereClause} } | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`;-    try {-      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],-        { encoding: 'utf8', timeout: 7000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {-          if (err || !out) { resolve([]); return; }-          let parsed; try { parsed = JSON.parse(out); } catch { parsed = null; }-          const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);-          resolve(items.map(i => ({ pid: i.ProcessId, cmd: i.CommandLine || '' })).filter(x => x.pid));-        });-    } catch (e) { resolve([]); }-  });-}-function _taskkillPids(pids) {-  return Promise.all([...new Set(pids || [])].map(pid => new Promise((resolve) => {-    try { execFile('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 5000 }, () => resolve()); }-    catch (e) { resolve(); }-  })));-}-// v2.0.130 (#46 P0 — janitor killed a live browser mid-annotation): GROUND-TRUTH helpers. The-// doctrine from that incident: NEVER let a derived model (CDP page handles, session-map state)-// authorize destruction — ask the OS. These two answer "does this pid own a visible top-level-// window?" and "is this hwnd a live, visible window?" straight from user32/the process table.-// Both FAIL SAFE for their callers: on any error the pid-check returns an empty set and the-// hwnd-check returns false, which the call sites treat as "no proof of life" — they then fall-// back to today's behavior, never to a kill they would not otherwise have made.-function _pidsWithVisibleWindow(pids) {-  return new Promise((resolve) => {-    const ids = [...new Set(pids || [])].filter(Number.isFinite);-    if (!_isWin || !ids.length) { resolve(new Set()); return; }-    const ps = `Get-Process -Id ${ids.join(',')} -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -ExpandProperty Id`;-    try {-      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],-        { encoding: 'utf8', timeout: 7000, windowsHide: true }, (err, out) => {-          if (err || !out) { resolve(new Set()); return; }-          resolve(new Set(out.split(/\r?\n/).map(l => Number(l.trim())).filter(Number.isFinite)));-        });-    } catch (e) { resolve(new Set()); }-  });-}-// v2.0.230: this is called from the 8s reconciler, and each call was a PowerShell spawn PLUS a-// Roslyn C# compile (~0.5s CPU). A session the ground-truth check SPARES is not deleted, so it-// re-entered the same branch on every tick — a self-sustaining burner that one stuck window could-// pin forever. Verdicts are now cached per hwnd for 60s (a window does not blink in and out of-// existence faster than that, and the reconciler is a safety net, not a realtime probe).-const _hwndAliveCache = new Map();   // hwnd -> { alive, at }-const HWND_ALIVE_TTL = 60000;-function osHwndAlive(hwnd) {-  return new Promise((resolve) => {-    const h = Number(hwnd);-    if (!_isWin || !Number.isFinite(h) || h <= 0) { resolve(false); return; }-    const c = _hwndAliveCache.get(h);-    if (c && Date.now() - c.at < HWND_ALIVE_TTL) { resolve(c.alive); return; }-    if (_hwndAliveCache.size > 200) { for (const [k, v] of _hwndAliveCache) { if (Date.now() - v.at > HWND_ALIVE_TTL) _hwndAliveCache.delete(k); } }-    const ps = `Add-Type -Namespace PW -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);'; if ([PW.U]::IsWindow([IntPtr]${h}) -and [PW.U]::IsWindowVisible([IntPtr]${h})) { 'ALIVE' }`;-    try {-      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],-        { encoding: 'utf8', timeout: 7000, windowsHide: true }, (err, out) => {-          const alive = !err && /ALIVE/.test(out || '');-          _hwndAliveCache.set(h, { alive, at: Date.now() });-          resolve(alive);-        });-    } catch (e) { resolve(false); }-  });-}-const _psLike = (p) => String(p).replace(/'/g, "''");-// Reap EVERY chrome/msedge (the parent AND any reparented --type=renderer orphan the tree-kill missed)-// still bound to this profile's userDataDir. Awaited + verified, one retry for a straggler mid-spawn.-// Profile-scoped so it can never touch the user's real browser or another session's window.-async function reapProfileChromes(profileName) {-  if (!_isWin || !profileName) return { killed: 0, remaining: 0 };-  const where = `$_.CommandLine -like '*${_psLike(path.join(PROFILES_DIR, profileName))}*'`;-  let procs = await _psProcs(where);-  const killed = procs.length;-  if (procs.length) { await _taskkillPids(procs.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }-  let remaining = await _psProcs(where);-  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = await _psProcs(where); }-  if (killed || remaining.length) console.log(`[reap] profile "${profileName}": killed ${killed}, remaining ${remaining.length}`);-  return { killed, remaining: remaining.length };-}-// Reap ORPHANED pup chrome/msedge — any process under a pup-profiles dir whose profile has NO live-// session (parent AND renderer children). Live sessions are spared by PROFILE (not just parent PID),-// so a session's own renderers are never touched. Used by pup_close (all) + boot/periodic sweeps.-async function reapOrphanPupChromes() {-  if (!_isWin) return { killed: 0, remaining: 0 };-  const liveDirs = new Set();-  for (const [, s] of sessions) { if (s && s.profileName) liveDirs.add(path.join(PROFILES_DIR, s.profileName).toLowerCase()); }-  // v1.9.92 (learned from issue #16) — NEVER SWEEP FROM AN EMPTY SESSION MAP while session FILES-  // still exist on disk. This sweep decides "orphan" as "no live session owns this profile", so an-  // empty map makes EVERY pup window an orphan and we would kill the user's live windows. The map can-  // legitimately be empty-but-pending: at boot before recoverSessions() finishes, or (pre-#16-fix)-  // after a resolver purged it. On-disk session files are the tell that windows are expected. Same-  // doctrine as the AUMID prune below: with nothing to distinguish stale from pending, doing nothing-  // is the correct answer. A genuine no-windows box has no session files, so real orphans still get-  // reaped there.-  if (!liveDirs.size) {-    let pending = 0;-    try { pending = listSessionFiles().length; } catch (e) {}-    if (pending) {-      console.log(`[reap-orphan] SKIPPED — session map is empty but ${pending} session file(s) exist (recovery pending); refusing to treat live windows as orphans`);-      return { killed: 0, remaining: 0, skipped: 'recovery_pending' };-    }-  }-  const where = `$_.CommandLine -like '*${_psLike(PROFILES_DIR)}*'`;-  const isOrphan = (p) => { const c = (p.cmd || '').toLowerCase(); for (const d of liveDirs) { if (c.includes(d)) return false; } return true; };-  let orphans = (await _psProcs(where)).filter(isOrphan);-  // v2.0.130 (#46 P0 — John lost live windows mid-annotation): "no live session owns this profile"-  // is a DERIVED verdict and it was FALSE once (session churn + respawns reaped the sessions while-  // their windows were open; the janitor then taskkilled the live Chrome under John's cursor).-  // GROUND TRUTH GATE: ask the OS which candidate pids own a visible top-level window. Any pid-  // that does is by definition NOT an orphan — someone can SEE that window — so it is spared, and-  // so is every sibling process sharing its user-data-dir (killing a spared parent's renderers-  // would crash the very window we just refused to kill). Spared windows are left for pup_rescan /-  // the health check to re-adopt. If the PS probe fails it returns an empty set, which only makes-  // this pass as permissive as the old code — never more destructive.-  const _visPids = await _pidsWithVisibleWindow(orphans.map(p => p.pid));-  if (_visPids.size) {-    const sparedDirs = [];-    for (const p of orphans) {-      if (!_visPids.has(p.pid)) continue;-      const m = (p.cmd || '').match(/user-data-dir=(?:"([^"]+)"|(\S+))/i);-      const dir = ((m && (m[1] || m[2])) || '').toLowerCase();-      if (dir) sparedDirs.push(dir);-    }-    const _spared = (p) => {-      if (_visPids.has(p.pid)) return true;-      const c = (p.cmd || '').toLowerCase();-      return sparedDirs.some(d => d && c.includes(d));-    };-    const before = orphans.length;-    orphans = orphans.filter(p => !_spared(p));-    if (before !== orphans.length) {-      console.log(`[reap-orphan] GROUND TRUTH spared ${before - orphans.length} process(es): the OS shows visible top-level window(s) on ${_visPids.size} pid(s) — "orphan" verdict overruled, leaving them for rescan/adoption`);-      recordCleanup(`janitor spared ${before - orphans.length} browser process(es) the session model called orphaned but the OS shows with VISIBLE windows — left alive for re-adoption (the #46 ground-truth gate)`);-    }-    // The retry pass below re-enumerates; make it honor the same sparing.-    var _notSpared = (p) => !_spared(p);-  } else { var _notSpared = () => true; }-  const killed = orphans.length;-  if (orphans.length) { await _taskkillPids(orphans.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }-  let remaining = (await _psProcs(where)).filter(isOrphan).filter(_notSpared);-  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan).filter(_notSpared); }-  if (killed || remaining.length) {-    recordCleanup(`reaped ${killed} orphaned pup browser process(es) that no live session owned${remaining.length ? ` (${remaining.length} survived and may still be using CPU)` : ''} — ${liveDirs.size} live profile(s) were spared`);-  }-  return { killed, remaining: remaining.length };-}--// v2.0.129: reap LEAKED isolated profile DIRECTORIES on disk. Every isolated:true / wikiView:public-// open mints a throwaway `iso-*` profile under PROFILES_DIR; they are never reused and never meant to-// persist, but nothing deleted the directory when the window closed — so they piled up (387 found on-// one machine, each a full Chrome profile). This removes an iso-* dir only when ALL hold true:-//   (a) no LIVE session owns it, and no on-disk session FILE references it (recovery-pending safety);-//   (b) no live Chrome process is holding it (never delete an in-use dir);-//   (c) it hasn't been modified in the last 15 min (never race an in-flight isolation launch).-// Durable jars (adom-you, named profiles, _recorder) never carry the iso- prefix, so they can NEVER be-// hit. Same empty-map guard as reapOrphanPupChromes. Pass {dryRun:true} to preview without deleting.-async function reapLeakedIsolatedProfiles(opts) {-  const dryRun = !!(opts && opts.dryRun);-  const MIN_AGE_MS = 15 * 60 * 1000;-  const out = { removed: 0, scanned: 0, spared: 0, held: 0, tooFresh: 0, removedNames: [], dryRun };-  let entries = [];-  try { entries = fs.readdirSync(PROFILES_DIR, { withFileTypes: true }).filter(d => d.isDirectory() && /^iso-/.test(d.name)).map(d => d.name); }-  catch (e) { return out; }-  out.scanned = entries.length;-  if (!entries.length) return out;-  // Spare anything a live session OR a session file references.-  const spared = new Set();-  for (const [, s] of sessions) { if (s && s.profileName) spared.add(s.profileName); }-  let sessFiles = [];-  try { sessFiles = listSessionFiles(); } catch (e) {}-  for (const sf of sessFiles) { if (sf && sf.profile) spared.add(sf.profile); }-  // Empty-map + pending-files guard: no live sessions but session files exist → recovery is pending;-  // an iso session could still be re-adopted, so do NOT reap this cycle (mirrors the process reaper).-  if (!sessions.size && sessFiles.length) { out.skipped = 'recovery_pending'; return out; }-  // Which iso profiles have a live Chrome process holding them (never delete an in-use dir).-  const heldDirs = new Set();-  if (_isWin) {-    try {-      const procs = await _psProcs(`$_.CommandLine -like '*${_psLike(PROFILES_DIR)}*'`);-      for (const p of procs) { const c = (p.cmd || '').toLowerCase(); for (const n of entries) { if (c.includes(path.join(PROFILES_DIR, n).toLowerCase())) heldDirs.add(n); } }-    } catch (e) {}-  }-  const now = Date.now();-  for (const name of entries) {-    if (spared.has(name)) { out.spared++; continue; }-    if (heldDirs.has(name)) { out.held++; continue; }-    const dir = path.join(PROFILES_DIR, name);-    let st; try { st = fs.statSync(dir); } catch (e) { continue; }-    if (now - st.mtimeMs < MIN_AGE_MS) { out.tooFresh++; continue; }-    if (dryRun) { out.removed++; if (out.removedNames.length < 50) out.removedNames.push(name); continue; }-    try { fs.rmSync(dir, { recursive: true, force: true }); out.removed++; if (out.removedNames.length < 50) out.removedNames.push(name); }-    catch (e) { /* locked / in-use — leave it for a later cycle */ }-  }-  if (out.removed && !dryRun) recordCleanup(`reaped ${out.removed} leaked isolated profile(s) with no live session (${out.spared} spared, ${out.held} in-use, ${out.tooFresh} too fresh) — durable jars untouched`);-  return out;-}--// Tear down the browser for a profile and VERIFY it. Returns {killed, remaining}; remaining MUST be 0.-// Falls back to browser.close() if PID isn't available. `browser` may be null when we're just cleaning-// up an orphaned lock file and have no live puppeteer handle.-async function killBrowserProcess(browser, profileName) {-  if (!profileName) {-    console.log('killBrowserProcess called without profileName — skipping');-    return { killed: 0, remaining: 0 };-  }-  let pid = null;-  if (browser) {-    pid = browser._detachedPid || null;-    if (!pid) {-      try { pid = browser.process()?.pid; } catch (e) { /* connected browser has no process() */ }-    }-    // Graceful close, but never let a wedged CDP make close() hang the whole teardown (the reap-    // below is what actually guarantees the processes die, so a slow/hung close must not block it).-    // v2.0.116 (John): give it a longer budget than before — a clean exit is what FLUSHES the-    // profile's prefs to disk (toolbar pins, developer_mode, saved logins), and the old 3s race-    // could force-kill mid-flush, which is why the pinned toolbar tiles were lost every relaunch.-    try { await Promise.race([browser.close(), new Promise(r => setTimeout(r, 8000))]); }-    catch (e) { console.log(`Graceful close failed for profile "${profileName}": ${e.message}`); }-  }--  // If no browser handle, try to recover PID from session files on disk-  if (!pid) {-    try {-      const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);-      if (sFiles.length > 0) pid = sFiles[0].pid;-    } catch {}-  }--  // v2.0.116: DO NOT force-kill a process that already exited on its own — that clean exit is-  // exactly what let Chrome finish writing its prefs. Poll the PID for a short grace window after-  // close() resolves; only tree-kill a genuinely WEDGED process. The profile-scoped reap below-  // still guarantees no reparented renderer lingers (those never write the profile prefs).-  const _pidAlive = (p) => { try { process.kill(p, 0); return true; } catch (e) { return e.code === 'EPERM'; } };-  if (pid) {-    let alive = _pidAlive(pid);-    for (let i = 0; i < 12 && alive; i++) { await new Promise(r => setTimeout(r, 250)); alive = _pidAlive(pid); }-    if (!alive) {-      console.log(`Chrome PID ${pid} for profile "${profileName}" exited cleanly — prefs (pins/dev-mode/logins) flushed`);-    } else {-      if (_isWin) { await _taskkillPids([pid]); }-      else { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }-      console.log(`Force-killed WEDGED Chrome PID ${pid} for profile "${profileName}" (graceful close did not exit it in time)`);-    }-  }--  // Then the RELIABLE, AWAITED, VERIFIED profile-scoped reap. taskkill /T kills the parent's tree, but-  // a crashed/backgrounded renderer can be reparented and survive it, lingering — or pegging a full-  // core. This awaited sweep catches those and confirms none remain, which is what makes close() honest.-  const rep = await reapProfileChromes(profileName);--  // Remove any stale SingletonLock so the next open on this profile starts clean.-  try { fs.unlinkSync(path.join(PROFILES_DIR, profileName, 'SingletonLock')); } catch { /* absent/locked */ }-  return rep;-}--// ─── Profile session-restore prevention ──────────────────────────────────-//-// Persistent profiles (--user-data-dir) make Chrome remember the previous-// session. Without intervention, Chrome's "restore previous session" path-// re-opens every tab the user had on last close, AND if Chrome thinks the-// last exit was unclean (which happens whenever puppeteer's child process-// is force-killed) it pops the "Restore tabs?" bubble too. End result is-// stale tab buildup across close/reopen cycles — 8+ tabs after a few iters.-//-// The reliable fix is two-pronged at profile level:-//-// 1. Patch <userDataDir>/Default/Preferences before launch:-//    - profile.exit_type = "Normal"-//    - profile.exited_cleanly = true-//      → Chrome thinks the last exit was clean, skips the restore bubble.-//    - session.restore_on_startup = 5 (open new tab page)-//    - session.startup_urls = []-//      → Even if user had set "On startup → continue where you left off",-//        we override it to "Open the New Tab page".-//-// 2. Delete the session-state blobs that Chrome reads to rebuild tabs:-//    - Default/Last Session, Default/Last Tabs-//    - Default/Current Session, Default/Current Tabs-//    - Default/Sessions/    (newer Chromium session-store dir)-//    - Default/Tabs/         (newer Chromium tab-store dir)-//-// We only run this when LAUNCHING (not when reconnecting to a running-// Chrome via DevToolsActivePort). Reconnect path means Chrome is already-// alive and we shouldn't touch its profile files.-function prepareCleanProfile(userDataDir) {-  if (!userDataDir) return;-  try {-    const defaultDir = path.join(userDataDir, 'Default');-    fs.mkdirSync(defaultDir, { recursive: true });--    // 1. Patch Preferences JSON-    const prefsPath = path.join(defaultDir, 'Preferences');-    let prefs = {};-    try {-      const raw = fs.readFileSync(prefsPath, 'utf8');-      prefs = JSON.parse(raw);-    } catch (e) {-      // Missing or unreadable Preferences file = first launch; that's fine-      prefs = {};-    }-    prefs.profile = prefs.profile || {};-    // v2.0.396 (zero-firewall-prompt, WebRTC leg): the launch switch --force-webrtc-ip-handling-policy-    // is not honored reliably by CfT, and Cloudflare-style challenge pages (DigiKey) gather ICE-    // candidates, which binds paired v4/v6 UDP sockets in the network service - the bind Windows-    // prompts on. These are the exact profile prefs the enterprise WebRtcIPHandling policy writes:-    // WebRTC keeps working through TCP/proxied transport, never a raw UDP bind. pup owns this-    // profile, so writing them here (Chrome closed, pre-launch) is the sanctioned lever.-    prefs.webrtc = Object.assign({}, prefs.webrtc || {}, { ip_handling_policy: 'disable_non_proxied_udp', nonproxied_udp_enabled: false, multiple_routes_enabled: false, allow_legacy_tls_protocols: false });-    // v2.0.53: PIN the Adom Pup toolbar icons by default (MV3 action icons hide in the puzzle menu-    // unless pinned). v2.0.150 (John: "be consistent on icon order... profile at the RIGHT end...-    // might just be the order you load them"): the toolbar order IS the `pinned_extensions` ARRAY-    // order in Preferences — NOT the --load-extension order and NOT the sequence of pin API calls-    // (both proven not to control it). So we WRITE that array here, pre-launch (Chrome closed), in-    // the fixed canonical order. Each extension now carries a stable `key` in its manifest, so its-    // ID is deterministic (ADOM_EXT_IDS) regardless of staging path — the old path-hash id was-    // fragile and is gone. Canonical left→right: Pup (dots), Annotate (pencil), Identity (profile)-    // LAST so the profile tile lands rightmost, next to Chrome's own profile avatar. Any foreign-    // pinned id is preserved BEFORE our block so our Adom trio stays the rightmost group.-    try {-      if (pupSettings().toolbarExtension !== 'off') {-        prefs.extensions = prefs.extensions || {};-        const ours = [ADOM_EXT_IDS.pup, ADOM_EXT_IDS.annotate, ADOM_EXT_IDS.identity, ADOM_EXT_IDS.identityAnon];-        const prev = Array.isArray(prefs.extensions.pinned_extensions) ? prefs.extensions.pinned_extensions : [];-        const foreign = prev.filter(id => !ours.includes(id));-        prefs.extensions.pinned_extensions = [...foreign, ...ours];-      }-    } catch (e) {}-    try { prefs.browser = prefs.browser || {}; prefs.browser.promotional_tabs_enabled = false; } catch (e) {}-    prefs.profile.exit_type = 'Normal';-    prefs.profile.exited_cleanly = true;-    // v2.0.122 (John: "the save dialog is still showing... i'd like you to just always-    // automatically handle this"). Chrome's native "Save password?" bubble is OFF now — pup owns-    // credential capture (the login-capture rail, hardened in 2.0.121 to grab on password-    // blur/change so it beats cross-origin SSO redirects) and fills from its own keychain vault-    // over CDP (no dropdown, no foreground). Chrome's bubble was redundant and the exact dialog-    // John did not want. (Was on since v1.9.129, before pup had its own vault.) Autofill via-    // fillLoginForm is unaffected — it sets values directly, independent of Chrome's manager.-    prefs.credentials_enable_service = false;-    prefs.profile.password_manager_enabled = false;-    prefs.profile.password_manager_leak_detection = false;-    prefs.session = prefs.session || {};-    prefs.session.restore_on_startup = 5; // 5 = new tab page; 1 = restore last-    prefs.session.startup_urls = [];-    fs.writeFileSync(prefsPath, JSON.stringify(prefs));--    // 2. Delete session-state blobs (best effort — Chrome may not have written some)-    const blobs = ['Last Session', 'Last Tabs', 'Current Session', 'Current Tabs'];-    for (const name of blobs) {-      const p = path.join(defaultDir, name);-      try { fs.rmSync(p, { force: true }); } catch {}-    }-    const dirs = ['Sessions', 'Tabs'];-    for (const name of dirs) {-      const p = path.join(defaultDir, name);-      try { fs.rmSync(p, { recursive: true, force: true }); } catch {}-    }-  } catch (e) {-    // Profile cleanup is defense-in-depth — don't fail the launch if it errors-    console.log(`prepareCleanProfile("${userDataDir}") warning: ${e.message}`);-  }-}--// Give a pup profile a visible identity so the user can tell at a glance that a-// window is an Adom pup window (specifically the shared "adom-you" jar) rather-// than their real signed-in Chrome. John asked for this after a Ctrl+N window-// left him unsure which profile he was in.-//-// IMPORTANT — what does NOT work (tested live 2026-08-06, do not re-try):-// Chrome has NO supported way to give a LOCAL (non-Google-signed-in) profile a-// custom image avatar. The only file hook is the GAIA account picture, and-// faking it (writing "Google Profile Picture.png" + spoofing gaia_id/-// use_gaia_picture in info_cache) does NOT render the image AND makes Chrome-// think there is a broken signed-in account → a permanent "Action required"-// nag pill on the toolbar. So we never touch gaia fields. See pup-profile-identity.-//-// What we DO here: set the profile NAME (shows in the profile menu on click, no-// nag). The always-visible signal is the Adom-teal window theme, applied as a-// LAUNCH FLAG at the call site (--install-autogenerated-theme), not here.-//-// Runs pre-launch (Chrome not yet holding this dir), same as prepareCleanProfile —-// Local State can only be safely rewritten while the profile's Chrome is closed.-// v2.0.114: turn on the profile's Developer mode (so CfT enables unpacked extensions) and pin-// every Adom action icon, via the developerPrivate API on a short-lived chrome://extensions page.-// Idempotent + guarded per browser instance; entirely background (a hidden about:blank→extensions-// page opened and closed, never surfaced). Once dev mode is set, Chrome persists it in Secure-// Preferences with a valid HMAC, so later launches of this profile keep it — but running every-// launch is cheap insurance and the only path that fixes a FRESH profile (dev mode starts off).-const _extEnabledFor = new Set();-async function ensureExtensionsEnabled(browser, profileName) {-  if (_extEnabledFor.has(profileName)) return;-  let page = null;-  try {-    page = await browser.newPage();-    await page.goto('chrome://extensions', { waitUntil: 'domcontentloaded', timeout: 8000 }).catch(() => {});-    const r = await page.evaluate(() => new Promise((resolve) => {-      try {-        if (!(chrome && chrome.developerPrivate)) return resolve({ ok: false, why: 'no developerPrivate' });-        chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }, () => {-          chrome.developerPrivate.getExtensionsInfo((infos) => {-            const adom = (infos || []).filter((i) => /^Adom /.test(i.name || ''));-            if (!adom.length) return resolve({ ok: true, devMode: true, pinned: 0 });-            // v2.0.147 (John: "be consistent on your icon order, users memorize these; profile-            // always at the RIGHT end, next to Chrome's own profile icon"). Chrome lays pinned-            // extension icons out in the ORDER THEY ARE PINNED; the old code pinned all three in-            // PARALLEL, so the completion order (and thus the toolbar order) was random every launch-            // — the exact inconsistency John saw. Fix: a FIXED canonical order — Pup (dots), then-            // Annotate (pencil), then Identity (profile) LAST so it lands rightmost — pinned strictly-            // SEQUENTIALLY (await each before the next) so the layout is deterministic every time.-            // v2.0.150: ORDER is now owned by the pinned_extensions array we write in-            // prepareCleanProfile (pre-launch) — that is the real control. Here we only ENSURE each-            // Adom icon is pinned (a no-op if the pref already pinned it); re-pinning never reorders,-            // so this cannot fight the canonical order. (The old unpin-then-repin dance is gone — it-            // could not control order and risked leaving an icon unpinned mid-sequence.)-            let pinned = 0;-            const pinNext = (idx) => {-              if (idx >= adom.length) return resolve({ ok: true, devMode: true, pinned, total: adom.length });-              chrome.developerPrivate.updateExtensionConfiguration({ extensionId: adom[idx].id, pinnedToToolbar: true }, () => {-                if (!chrome.runtime.lastError) pinned++;-                pinNext(idx + 1);-              });-            };-            pinNext(0);-          });-        });-      } catch (e) { resolve({ ok: false, why: e.message }); }-    }));-    if (r && r.ok) { _extEnabledFor.add(profileName); console.log(`[ext] "${profileName}": developer mode on, pinned ${r.pinned}/${r.total || 0} Adom toolbar icons`); }-    else { console.log(`[ext] "${profileName}": could not enable/pin (${r && r.why})`); }-  } catch (e) { console.log(`[ext] ensureExtensionsEnabled("${profileName}") error: ${e.message}`); }-  finally { try { if (page) await page.close(); } catch (e) {} }-}-function brandPupProfile(userDataDir, profileName, displayName) {-  if (!userDataDir) return;-  try {-    const defaultDir = path.join(userDataDir, 'Default');-    fs.mkdirSync(defaultDir, { recursive: true });--    // Local State → profile.info_cache["Default"] : name only, NO gaia fields.-    const lsPath = path.join(userDataDir, 'Local State');-    let ls = {};-    try { ls = JSON.parse(fs.readFileSync(lsPath, 'utf8')); } catch { ls = {}; }-    ls.profile = ls.profile || {};-    ls.profile.info_cache = ls.profile.info_cache || {};-    const info = ls.profile.info_cache['Default'] || {};-    info.name = displayName;-    info.shortcut_name = displayName;-    info.is_using_default_name = false;-    ls.profile.info_cache['Default'] = info;-    fs.writeFileSync(lsPath, JSON.stringify(ls));--    // Mirror the name into the profile's own Preferences (some surfaces read it-    // from here rather than Local State).-    const prefsPath = path.join(defaultDir, 'Preferences');-    let prefs = {};-    try { prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8')); } catch { prefs = {}; }-    prefs.profile = prefs.profile || {};-    prefs.profile.name = displayName;-    prefs.profile.using_default_name = false;-    fs.writeFileSync(prefsPath, JSON.stringify(prefs));-  } catch (e) {-    console.log(`brandPupProfile("${profileName}") warning: ${e.message}`);-  }-}--// After launch, sweep any pages Chrome opened that weren't created by us.-// This is the second line of defense in case Preferences-patching missed-// some restore path. session.tabs[] tracks the pages WE registered; any-// browser.pages() entries beyond that are restored leftovers — close them.-// Keeps the page we just opened (matched by URL or by being the only one-// the bridge has registered).-async function closeRestoredLeftoverTabs(browser, keepPage) {-  let closed = 0;-  try {-    const pages = await browser.pages();-    for (const p of pages) {-      if (p === keepPage) continue;-      // v1.9.142 (John's 3-phase test found it): NEVER close a page pup itself created. Two-      // concurrent opens on the same profile each ran this cleanup and shot the SIBLING's-      // just-created page mid-goto ('Navigating frame was detached') — 9 of 10 parallel opens-      // died. Restored leftovers are pages pup did NOT create; the marker is the difference.-      if (p._pupCreated) continue;-      // Skip blank/about:blank (Chrome's default first tab) — handled separately-      let url = '';-      try { url = p.url() || ''; } catch {}-      if (url.startsWith('chrome://newtab') || url === 'about:blank' || url === '') {-        // Don't kill the blank tab here; new-tab reuse logic handles it-        continue;-      }-      try { await p.close({ runBeforeUnload: false }); closed++; } catch {}-    }-  } catch (e) {-    console.log(`closeRestoredLeftoverTabs warning: ${e.message}`);-  }-  return closed;-}--// Get or launch a Chrome browser for a given profile name-// Multiple sessions can share the same browser (as tabs) if they use the same profile-//-// v1.6.3+: full-capability default. Pup is a tool-driving surface, NOT a-// user-facing browser — every chip-fetcher-style flow needs Chrome in-// "everything allowed" mode so JS-triggered downloads don't get silently-// blocked, clipboard access works for pasting into vendor forms, and-// permission prompts don't pop up under automation.-//-// Two CDP mechanisms together give us this:-//-//   1. Browser.setPermission (per-origin permissions) — FULL DEBUG: every-//      permission Chrome accepts is GRANTED (notifications, geolocation,-//      camera, mic, midi, sensors, clipboard, wake-lock, …) so no permission-//      prompt EVER blocks a debug flow. Per-permission so an unknown/rejected-//      descriptor (e.g. clipboard-sanitized-write on some builds) only drops-//      itself, never the rest (the v1.4.12 Browser.grantPermissions array form-//      failed wholesale on Chrome 146 when one name was unrecognized).-//      Opt-out for the rare untrusted-site case: strictPermissions:true.-//-//   2. Page.setDownloadBehavior — see applyDownloadBehavior below. This-//      is the actual mechanism for unblocking scripted downloads. The-//      v1.4.12 attempt at "automaticDownloads" via grantPermissions-//      didn't help with scripted downloads — it controlled the multi--//      file infobar, which is a different layer. setDownloadBehavior-//      tells Chrome to actually save the file when JS triggers a nav-//      to a binary content-type.-//-// Opt-out: pass { strictPermissions: true } to pup_open_window for-// sessions that genuinely need the user to gate every download (rare).-//-// History: v1.4.12 used Browser.grantPermissions(['automaticDownloads',-// 'notifications', 'clipboardReadWrite']). On Chrome 146 it failed with-// "Unknown permission type: automaticDownloads" — taking the whole-// grant down. v1.6.3 fixes by switching to Browser.setPermission per--// permission and dropping the unhelpful automaticDownloads (which we-// replace with the actually-effective Page.setDownloadBehavior).-// FULL-DEBUG default: grant EVERYTHING Chrome will accept (John: always launch-// pup with full permissions for full debug). All 'granted', none denied. Applied-// per-permission below so any descriptor a given Chrome build rejects (e.g.-// clipboard-sanitized-write) only drops itself — the rest still land. Opt-out via-// strictPermissions:true on pup_open_window for an untrusted site you're auditing.-const PERMISSIVE_DEFAULT_PERMISSIONS = [-  // v1.8.69: window-management → getScreenDetails() works (multi-monitor enumeration from-  // inside the page; without the grant it hangs on a CDP-suppressed permission prompt).-  { name: 'window-management', setting: 'granted' },-  { name: 'clipboard-read', setting: 'granted' },-  { name: 'clipboard-write', setting: 'granted' },-  { name: 'clipboard-sanitized-write', setting: 'granted' },-  { name: 'notifications', setting: 'granted' },-  { name: 'geolocation', setting: 'granted' },-  { name: 'camera', setting: 'granted' },-  { name: 'microphone', setting: 'granted' },-  { name: 'midi', setting: 'granted' },-  { name: 'midiSysex', setting: 'granted' },-  { name: 'background-sync', setting: 'granted' },-  { name: 'background-fetch', setting: 'granted' },-  { name: 'periodic-background-sync', setting: 'granted' },-  { name: 'payment-handler', setting: 'granted' },-  { name: 'persistent-storage', setting: 'granted' },-  { name: 'durable-storage', setting: 'granted' },-  { name: 'storage-access', setting: 'granted' },-  { name: 'top-level-storage-access', setting: 'granted' },-  { name: 'idle-detection', setting: 'granted' },-  { name: 'display-capture', setting: 'granted' },-  { name: 'window-management', setting: 'granted' },-  { name: 'local-fonts', setting: 'granted' },-  { name: 'nfc', setting: 'granted' },-  { name: 'screen-wake-lock', setting: 'granted' },-  { name: 'system-wake-lock', setting: 'granted' },-  { name: 'accelerometer', setting: 'granted' },-  { name: 'gyroscope', setting: 'granted' },-  { name: 'magnetometer', setting: 'granted' },-  { name: 'ambient-light-sensor', setting: 'granted' },-  { name: 'sensors', setting: 'granted' },-  { name: 'protected-media-identifier', setting: 'granted' },-  { name: 'captured-surface-control', setting: 'granted' },-  { name: 'speaker-selection', setting: 'granted' },-];-async function applyDefaultPermissions(browser, profileName, { strictPermissions = false } = {}) {-  if (strictPermissions) {-    console.log(`[permissions] strictPermissions=true — leaving Chromium defaults in place for profile "${profileName}"`);-    return { applied: false, reason: 'strictPermissions' };-  }-  const session = await browser.target().createCDPSession().catch(() => null);-  if (!session) {-    console.log(`[permissions] could not open CDP session for "${profileName}" — skipping`);-    return { applied: false, reason: 'cdp-session-open-failed' };-  }-  const applied = [];-  const failed = [];-  try {-    for (const p of PERMISSIVE_DEFAULT_PERMISSIONS) {-      try {-        await session.send('Browser.setPermission', {-          permission: { name: p.name },-          setting: p.setting,-          // origin omitted → all origins on default browser context-        });-        applied.push(`${p.name}=${p.setting}`);-      } catch (e) {-        failed.push({ name: p.name, error: e.message });-      }-    }-  } finally {-    try { await session.detach(); } catch {}-  }-  if (applied.length > 0) {-    console.log(`[permissions] profile "${profileName}": applied [${applied.join(', ')}]${failed.length > 0 ? `; failed [${failed.map(f => f.name).join(', ')}]` : ''}`);-  } else if (failed.length > 0) {-    console.log(`[permissions] profile "${profileName}": ALL grants failed: ${JSON.stringify(failed)}`);-  }-  return { applied, failed };-}--// v1.6.3+: enable scripted downloads on a page. This is the SECOND half-// of pup's full-capability default (the first half is applyDefaultPermissions-// above). Without setDownloadBehavior=allow, JS-triggered downloads (a-// click on `<a download href="...">`, a `fetch().then(blob → save)`, a-// vendor "Download Symbol" button that does `window.location = url`) get-// SILENTLY BLOCKED by Chrome — no error, no UI, no file in ~/Downloads.-// chip-fetcher debugged this for hours before figuring out the cause.-//-// Why per-page and not per-browser: setDownloadBehavior is a Page-domain-// command. Each tab needs its own application. We also re-apply on every-// main-frame navigation as a safety belt — empirically Chrome doesn't-// reset across same-context navs, but cross-context (browser-restored-// tabs, target-domain disconnect/reconnect) can lose it.-//-// downloadPath defaults to %USERPROFILE%\Downloads on Windows / ~/Downloads-// on POSIX — chip-fetcher's `desktop_watch_files` polls this exact dir,-// so the two ends meet by default. Caller can override via the-// `downloadPath` arg on pup_open_window if they need somewhere else.-async function applyDownloadBehavior(page, downloadPath) {-  if (!downloadPath) {-    downloadPath = path.join(os.homedir(), 'Downloads');-  }-  // Ensure the dir exists — Chrome silently no-ops if the path doesn't-  // exist on Windows; the file ends up nowhere and the AI debug-loops.-  try { fs.mkdirSync(downloadPath, { recursive: true }); } catch {}-  let cdp;-  try {-    cdp = await page.target().createCDPSession();-    await cdp.send('Page.setDownloadBehavior', {-      behavior: 'allow',-      downloadPath,-    });-  } catch (e) {-    // Older Chromes used the now-deprecated Browser.setDownloadBehavior-    // (with browserContextId). Try that as a fallback. Modern Chrome-    // (≥83) supports both forms; this branch only fires on truly old-    // Chrome for Testing builds.-    try {-      if (cdp) await cdp.send('Browser.setDownloadBehavior', {-        behavior: 'allow',-        downloadPath,-      });-    } catch (e2) {-      console.log(`[downloads] setDownloadBehavior failed for page (non-fatal): ${e.message} / fallback: ${e2.message}`);-    }-  } finally {-    try { if (cdp) await cdp.detach(); } catch {}-  }-}--// The browser that most recently launched (kind/source/executablePath + any fallback-// chain), so pup_open_window's response + hints report what pup ACTUALLY drove —-// which can differ from resolveBrowser()'s top pick if the top candidate had to fall-// through. Read right after getOrLaunchBrowser() returns.-let _lastChosenBrowser = null;--// Build the verbose browser report the AI gets on every open: what we drove, what-// else is available, the current default, and how to switch or install full CfT.-function browserPickReport() {-  const sum = chrome.pickSummary();-  const chosen = _lastChosenBrowser || sum.picked;-  const availTxt = (sum.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';-  const def = sum.defaultBrowser;-  const defTxt = def-    ? `Default is ${def.kind}${def.forced ? ' (pinned via pup_use)' : ' (auto — cached from a prior successful launch)'}${def.pendingInstall ? ', still downloading' : ''}.`-    : 'Default is auto (pup drives the first browser that launches, then caches it).';-  const fellBack = chosen && chosen.fallbackFrom && chosen.fallbackFrom.length-    ? ` NOTE: fell back past ${chosen.fallbackFrom.join(', ')} which would not spawn.` : '';-  const hint = chosen-    ? `Driving ${chosen.kind}${chosen.source ? ` (${chosen.source})` : ''}${chosen.executablePath ? ` — ${chosen.executablePath}` : ''}, in a FRESH isolated profile (the user's real tabs/logins are untouched).${fellBack} Available on this machine: ${availTxt}. ${defTxt} To switch the browser pup drives: pup_use {browser:"chrome"|"edge"|"cft"|"auto"}. To install & pin full Chrome for Testing: pup_use {browser:"cft"} (fetches ~150 MB once, then every open uses it) or pup_prewarm.`-    : `No browser resolved. Available on this machine: ${availTxt}.`;-  return {-    browser: chosen ? { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null } : null,-    availableBrowsers: sum.candidates,-    defaultBrowser: def,-    _hint: hint,-  };-}---// v1.8.34: capture the user's CURRENT foreground window (decimal HWND string) right-// before we launch a pup browser, so the backgrounder can hand keyboard focus back to it.-async function osIdleMs() {-  // v1.9.292 (John: differentiate HIS taskbar click from a JS/Chrome raise). v2.0.27: native ab verb-  // (GetLastInputInfo in precompiled Rust, ~20ms) — no more runtime-compiled PowerShell.-  if (process.platform !== 'win32') return null;-  try {-    const d = abPayload(await chrome.abCommand('desktop_user_idle_ms', {}, { timeoutMs: 4000 }));-    return (d && d.ok && Number.isFinite(Number(d.idleMs))) ? Number(d.idleMs) : null;-  } catch (e) { return null; }-}-async function osGetForegroundWindow() {-  // v2.0.27: native (desktop_get_foreground). Returns the decimal hwnd as a STRING (legacy shape) or null.-  if (process.platform !== 'win32') return null;-  try {-    const d = abPayload(await chrome.abCommand('desktop_get_foreground', {}, { timeoutMs: 4000 }));-    const h = d && Number(d.hwnd);-    return h ? String(h) : null;-  } catch (e) { return null; }-}--// v1.9.180 — STUCK-OCCLUSION UNSTICK. Root-caused live on John's Slides window (2026-07-29): after a-// browser-process hang, Chrome's native occlusion state for a window can come back CORRUPTED — the-// window is frontmost on screen, yet the page reports document.visibilityState 'hidden', so Chrome-// withholds every frame. The user sees DWM's last composited frame (looks perfect in screenshots) but-// scrolling/typing repaints NOTHING — "pup is frozen" while the page is healthy. Programmatic raise-// (SWP_NOACTIVATE) and even minimize/restore do NOT recompute it; a REAL activation does. So: when a-// pup window IS the OS-foreground window but its page claims hidden, that state is impossible-if--// healthy — unstick it (CDP lifecycle 'active' first, then a real forced activation) and tell the user.-async function pageSetLifecycleActive(page) {-  let cdp = null;-  try {-    cdp = await withTimeout(page.target().createCDPSession(), 3000, 'cdp-lifecycle');-    await withTimeout(cdp.send('Page.setWebLifecycleState', { state: 'active' }), 3000, 'lifecycle');-    return true;-  } catch (e) { return false; }-  finally { try { if (cdp) await cdp.detach(); } catch (e) {} }-}--// Force a REAL activation of a window (AttachThreadInput + ALT-nudge + SetForegroundWindow) — the-// user-input-equivalent activation that actually recomputes Chrome's occlusion, unlike a plain raise.-async function forceActivateHwnd(hwnd) {-  // v2.0.27: native — desktop_bring_to_front does the AttachThreadInput + SetForegroundWindow dance-  // (foreground-lock bypass) in precompiled Rust; confirmed equivalent by the ab thread on ab#64.-  if (process.platform !== 'win32' || !hwnd) return false;-  try {-    const d = abPayload(await chrome.abCommand('desktop_bring_to_front', { hwnd: Number(hwnd) }, { timeoutMs: 5000 }));-    return !!(d && (d.ok || d.success || d.broughtToFront));-  } catch (e) { return false; }-}--// v1.9.181: cached session hwnds so periodic sweeps don't call AD's find_window per session per tick.-// TTL 10 min; a stale hwnd just fails a foreground match harmlessly and refreshes on the next miss.-// v2.0.254: origins whose /favicon.ico failed on BOTH rails -> ts of that failure. Suppresses the-// re-fetch (and its log lines) for 30 min. Capped so a long-lived pup can't accumulate origins.-const _faviconNegCache = new Map();-function _faviconNegRemember(origin) {-  _faviconNegCache.set(origin, Date.now());-  if (_faviconNegCache.size > 500) { for (const k of [..._faviconNegCache.keys()].slice(0, 200)) _faviconNegCache.delete(k); }-}-const _hwndCache = new Map();     // sessionId -> { hwnd, ts }-async function cachedSessionHwnd(sessionId) {-  const c = _hwndCache.get(sessionId);-  if (c && Date.now() - c.ts < 10 * 60000) return c.hwnd;-  const hwnd = await sessionHwnd(sessionId);-  if (hwnd) _hwndCache.set(sessionId, { hwnd, ts: Date.now() });-  else _hwndCache.delete(sessionId);-  return hwnd;-}--// v1.9.181: is the user actively using this window? True when a granted raise OR an OBSERVED user-// foreground (taskbar click, alt-tab) marked it, with a 10-minute recency window. Every park/demote-// path must check this — pup must NEVER pull a window out from under the user (John, 2026-07-29:-// "pup windows still don't behave well"; root cause was exactly these unguarded demotions).-function userIsUsingWindow(session) {-  if (!session) return false;-  // v2.0.261: a newborn window cannot be one "the user is using" — its taskbar button did not exist-  // until a moment ago. Without this the flags set by Chrome's own self-activation make every-  // demotion path stand down and leave an unasked-for window on top of the user's work.-  if (inBirthGrace(session)) return false;-  if (session._foreground) return true;-  return !!(session._userActiveAt && Date.now() - session._userActiveAt < 10 * 60000);-}--const _unstickLast = new Map();   // sessionId -> ts of last unstick attempt (don't nag every tick)-// v1.9.181: hwnd arrives precomputed from the sweep (which already matched it against the OS-// foreground), so this probes the ONE matched window — the old version evaluated visibility in EVERY-// session every tick, waking throttled renderers bridge-wide (the exact battery/load sin of issue #14).-const _unstickStrikes = new Map();  // sessionId -> consecutive stuck detections-const _unstickClickAt = new Map();  // sessionId -> ts of last REAL-click cure (hard 10-min cap)-async function unstickIfHiddenWhileFront(sessionId, session, fgHwnd, hwnd) {-  if (process.platform !== 'win32' || !session) return false;-  if (!hwnd || String(fgHwnd) !== String(hwnd)) { _unstickStrikes.delete(sessionId); return false; }-  // v1.9.184 (John: "why does the mouse keep jumping to the upper right corner") — the v1.9.183-  // check probed only pup's ACTIVE tab. When the user is viewing a DIFFERENT tab of that window,-  // the probed background tab reports 'hidden' — which is NORMAL — so pup misdiagnosed a healthy-  // window as frozen every sweep and fired its cure ladder, ending in a REAL CLICK on the user's-  // screen every 30s. A window is stuck ONLY if NO tab reports visible: a healthy frontmost window-  // always has exactly one visible tab. Probe across tabs; a visible tab also corrects pup's stale-  // activeTabId to what the user is actually looking at.-  let sawAnswer = false, visibleTab = null;-  for (const t of (session.tabs || [])) {-    if (!t || !t.page || t._crashed) continue;-    try {-      const v = await withTimeout(t.page.evaluate('document.visibilityState'), 1500, 'vis-probe');-      sawAnswer = true;-      if (v === 'visible') { visibleTab = t; break; }-    } catch (e) { /* busy/unreachable tab — not evidence either way */ }-  }-  if (visibleTab) {-    if (session.activeTabId !== visibleTab.tabId) {-      console.log(`[unstick] "${sessionId}" user is viewing ${visibleTab.tabId} (pup thought ${session.activeTabId}) — syncing activeTabId, window is healthy`);-      session.activeTabId = visibleTab.tabId;-    }-    _unstickStrikes.delete(sessionId);-    return false;-  }-  if (!sawAnswer) return false;   // no tab answered — cannot judge; never act on no evidence-  // ALL answering tabs hidden while frontmost = the stuck state. Require it TWICE in a row-  // (~30s apart) so a transient (mid-tab-switch, mid-navigation) can never trigger the cure.-  const strikes = (_unstickStrikes.get(sessionId) || 0) + 1;-  _unstickStrikes.set(sessionId, strikes);-  if (strikes < 2) { console.log(`[unstick] "${sessionId}" all tabs hidden while frontmost (strike 1/2) — watching, not acting`); return false; }-  _unstickStrikes.delete(sessionId);-  const tab = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];-  if (!tab || !tab.page) return false;-  let vis = 'hidden';-  const last = _unstickLast.get(sessionId) || 0;-  if (Date.now() - last < 30000) return false;-  _unstickLast.set(sessionId, Date.now());-  console.log(`[unstick] "${sessionId}" is the FOREGROUND window but its page reports hidden — stuck occlusion; unsticking`);-  await pageSetLifecycleActive(tab.page);-  await new Promise(r => setTimeout(r, 700));-  try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) {}-  if (vis === 'hidden') {-    await forceActivateHwnd(hwnd);-    await new Promise(r => setTimeout(r, 900));-    try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) {}-  }-  if (vis === 'hidden') {-    // v1.9.183: LAST RESORT that actually works — a REAL input click. Proven live twice on John's-    // Slides window: CDP lifecycle 'active' and the ALT-nudge SetForegroundWindow both left the page-    // hidden; a genuine mouse click on the window cleared it instantly both times (Chrome's stuck-    // occlusion only recomputes on real input). Click the EMPTY tab-strip area (right side, clear of-    // the window buttons): pure activation, cannot hit page content. Acceptable cursor blip: this-    // branch only runs when the user is ALREADY looking at this frozen window.-    // v1.9.184: HARD CAP — a real click at most once per session per 10 minutes. A misdiagnosis-    // repeating a real click is far worse than a frozen window staying frozen one sweep longer.-    const lastClick = _unstickClickAt.get(sessionId) || 0;-    if (Date.now() - lastClick < 10 * 60000) {-      console.log(`[unstick] "${sessionId}" real-click cure SKIPPED (10-min cap) — soft nudges failed but a repeat click risks disrupting the user`);-      return false;-    }-    _unstickClickAt.set(sessionId, Date.now());-    try {-      const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 }));-      const r = fw && (fw.best || (fw.data && fw.data.best)); const rect = r && r.rect;-      if (rect && rect.width > 600) {-        const cx = Math.round(rect.left + rect.width - 300);-        const cy = Math.round(rect.top + 10);-        await chrome.abCommand('desktop_click', { x: cx, y: cy, reason: `unstick frame-frozen pup window "${sessionId}" (real activation click on empty tab strip)` }, { timeoutMs: 8000 });-        await new Promise(r2 => setTimeout(r2, 1200));-        try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) {}-      }-    } catch (e) {}-  }-  const fixed = vis !== 'hidden';-  if (fixed) {-    recordCleanup(`pup detected your "${sessionId}" window was front-and-center but Chrome had stopped painting it (stuck occlusion after a hang) — pup unstuck it automatically; scrolling and rendering work again.`);-  } else {-    recordCleanup(`pup detected your "${sessionId}" window is frontmost but Chrome is refusing to paint it (stuck occlusion). One real click anywhere on that window fixes it — pup's automatic nudges did not clear it this time.`);-  }-  console.log(`[unstick] "${sessionId}" ${fixed ? 'UNSTUCK — page visible again' : 'still hidden after nudges (needs one real user click)'}`);-  return fixed;-}--// v1.8.40: badge each pup window's TASKBAR BUTTON with the white Adom leaves, so users can-// tell a pup-driven window from their own browser. Done via AD-core desktop_taskbar (custom-// PNG overlay, AD ≥1.9.106) — AD paints it cross-process natively; pup just hands over the-// art (an in-bridge SetOverlayIcon is in-process-only and can't badge the browser's window).-// We resolve the window by its "(session: <id>)" title suffix so AD finds the hwnd itself.-let _pupBadgeB64 = null;-function pupBadgeB64() {-  if (_pupBadgeB64 !== null) return _pupBadgeB64;-  try { _pupBadgeB64 = fs.readFileSync(path.join(__dirname, 'icons', 'pup-overlay-32.png')).toString('base64'); }-  catch (e) { _pupBadgeB64 = ''; }-  return _pupBadgeB64;-}-// v1.8.73: FULL WINDOW-IDENTITY TAKEOVER (AD >=1.9.134, built to pup's spec). Two halves:-// registration (once per bridge run: HKCU AUMID + Start Menu shortcut whose pin launches a REAL-// pup window via adom-desktop-cli) and per-window stamping (AUMID + WM_SETICON, done by AD-core-// because icon HICONs are process-owned USER objects that must outlive the caller — AD holds-// them in a process-lifetime cache; see dev skill "Full icon/identity takeover"). The bridge-// registers ITSELF over the loopback direct-API (trusted transport, Tier-2 verb is ungated-// there) and owns its icon in its install dir — change branding = ship a new pup release with a-// NEW icon FILENAME (Windows caches decoded icons by path). Self-gating: on AD <1.9.134 the-// verbs are unknown -> _identitySupported=false -> the overlay badge fallback keeps working.-// v1.8.78: AD-version awareness. Most capability gaps self-gate silently (identity -> badge,-// bottom/force -> PS fallback) and need no nagging. ONE gap is a POLICY hole, not cosmetics:-// AD <1.9.124 intercepts pup_raise_os_window natively, so pup's foreground gate (reason-// required + audit trail) silently does not exist there. pup can't enforce through an-// interceptor, but it can KNOW and SAY SO — probed once at startup, surfaced in /status and-// open-response hints so users/AIs are told to update AD rather than trusting a gate that-// isn't there.-let _adVersion = null;-let _adRaiseGateBypassed = false;-function _verLt(a, b) {-  const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);-  for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return true; if ((pa[i] || 0) > (pb[i] || 0)) return false; }-  return false;-}-async function probeAdVersion() {-  try {-    const r = await chrome.abCommand('update_status', {}, { timeoutMs: 6000 });-    const raw = r && (typeof r.output === 'string' && r.output ? JSON.parse(r.output) : r.output || r);-    const v = (raw && (raw.currentVersion || (raw.data && raw.data.currentVersion))) || (r && r.currentVersion) || null;-    if (v) {-      _adVersion = v;-      _adRaiseGateBypassed = _verLt(v, '1.9.124');-      console.log(`[abver] AD ${v}${_adRaiseGateBypassed ? ' — WARNING: <1.9.124 intercepts raise, the foreground gate is BYPASSED on this box; recommend updating Adom Bridge' : ''}`);-    }-  } catch (e) {}-}-function adUpgradeHint() {-  return _adRaiseGateBypassed-    ? ` ⚠ Bridge ${_adVersion} on this desktop is older than 1.9.124: it intercepts pup_raise_os_window natively, so pup's foreground gate (foregroundReason + audit trail) does NOT apply to raises here. Recommend the user update Adom Bridge (update_check / the footer banner) — everything else degrades gracefully, this is the one policy gap.`-    : '';-}-// v1.8.82: PUP SETTINGS — user-configurable behavior, persisted OUTSIDE the bridge dir (the-// bridges-cache is clobbered on every update) in ~/.adom/pup-settings.json.-// taskbarGrouping (John's discovery while debugging the AUMID grouping):-//   'split'   (default) — per-session AUMID: ONE TASKBAR BUTTON PER WINDOW, each wearing its-//              own tab-count badge. Best for telling AI threads apart at a glance.-//   'grouped' — shared AUMID: ALL pup windows stack under ONE "Adom Pup" button (tidy taskbar);-//              the badge shows the WINDOW count (>=2) since per-window badges are meaningless-//              on a grouped button.-const PUP_SETTINGS_PATH = path.join(os.homedir(), '.adom', 'pup-settings.json');-let _pupSettings = null;-function pupSettings() {-  if (_pupSettings) return _pupSettings;-  try { _pupSettings = JSON.parse(fs.readFileSync(PUP_SETTINGS_PATH, 'utf8')); } catch (e) { _pupSettings = {}; }-  if (!_pupSettings.taskbarGrouping) _pupSettings.taskbarGrouping = 'split';-  // v1.9.259 (John's settings dialog): every user-facing behavior gets an explicit pref.-  // v2.0.113 (John: "your aumid's seem to be working great now, so the settings i'm running with-  // are the new defaults"). The per-window taskbar identity + jump lists + category icons were-  // defaulted OFF only because AUMID stamping was the prime suspect in the old Chrome crashes;-  // that is resolved, so a FRESH user now gets the good experience John runs with, not the old-  // all-windows-stacked-under-one-button mode. (Existing users' explicit choices are untouched —-  // the _chosen tracking means flipping a default only moves users who never set the key.)-  const D = { aumidIcons: 'on', jumpLists: 'on', fgCaption: 'on', fgCaptionMs: 2000,-              overlayBadges: 'on', sleepRecovery: 'on',-              // v2.0.262 (John): the badge at full size covers the BOTTOM LINE of the thread tile's-              // name, and that text is the whole point of thread tiles ("i'm finding that the text is-              // super valuable so i want to be able to see it"). '50' composites the same badge at half-              // scale inside the same canvas, so Windows draws it half-size in the corner and the tile-              // text stays readable. DEFAULT is '50' — the readable one — with '100' available for-              // anyone who wants the big badge back. v2.0.263: John tried 50 and asked for 66 — the-              // value is a straight PERCENT now, so adding another stop is a one-word change. v2.0.264: 75 added-              // and made the default after he compared 100/66/50 on real tiles.-              overlaySize: '75',-              aumidIconStyle: 'category', taskbarGrouping: 'split',-              // v2.0.152 THREAD TILES (John): group every pup window/tab of one ai-thread under ONE-              // taskbar button whose icon is that thread's NAME rendered on the teal tile (Familjen-              // Grotesk, auto-fit + truncate). It overrides the grouping/style modes so a thread's-              // whole pup estate lives under a single named tile ("oh, that's my shotlog thread").-              // v2.0.174 (John: "put thread tiles at top of settings since its the DEFAULT and my-              // favorite"): now ON by default, and the first row in the Settings dialog. _chosen-              // tracking means this only moves users who never set the key themselves.-              threadTiles: 'on' };-  // v2.0.292: openFlash / autoFlash / agentActivity / fgWatch REMOVED — collapsed into taskbarActivity-  // (progress|flash|off) and notMineCaption. Their gates now read taskbarActivity/notMineCaption.-  // v1.9.268 (John on autoLogin: "this should be on by default" - and it always WAS behavior-default-  // on via the !== 'off' checks, but the dialog rendered the unset key as an OFF toggle: a display-  // lie). Legacy keys get explicit defaults so the dialog reflects actual behavior.-  D.autoLogin = 'on'; D.reportIssues = 'on';-  // v2.0.289 (John): how pup signals activity on a window's taskbar button. 'progress' = a DETERMINATE-  // progress bar sized to the running-average time of the operation (icon-apply, screenshot, ...) —-  // the new default, replacing the orange flash. 'flash' = the old orange taskbar highlight-  // (openFlash/autoFlash). 'off' = neither. Windows-only; stored-but-inert elsewhere.-  D.taskbarActivity = 'progress';-  // v1.9.275 (John: 'shouldn't there be a setting for whether to show the full jump list or not?')-  D.jlViewTasks = 'on'; D.jlCloseAll = 'on';-  // v2.0.42 (John: "can you add a jump list to open the pup dashboard?")-  D.jlDashboard = 'on';-  D.jlAnnotate = 'on';   // v2.0.49-  // v2.0.50 (plan: interactive surface): caption bar baked under captures; toolbar extension family-  D.annCaptionBar = 'on'; D.toolbarExtension = 'on'; D.tbOwnerBadge = 'on'; D.tbSnipHotkey = 'on'; D.tbFloatyPencil = 'off'; D.floatyHitPx = 24;-  D.captureLogins = 'on';   // v2.0.110 (John authorized): auto-capture form logins into the keychain vault-  // v1.9.293 (John): the 'NOT foregrounded by Adom' caption is now OFF by default. He is right that-  // input-timing cannot reliably tell his own raises (click / alt+tab / window click) from a rare-  // JS raise - and Windows blocks most background foreground-steals anyway, so the case it exists-  // for seldom happens. Kept as an OFF-by-default opt-in; the idle-gate stays for anyone who turns it on.-  D.notMineCaption = 'off';-  if (_pupSettings.notMineCaption === 'on' && !(_pupSettings._chosen || {}).notMineCaption) _pupSettings.notMineCaption = 'off';-  D.ownerChip = 'on';        // v1.9.284 (John): the floating owner-thread/profile chip, default on-  // v1.9.286 (John: "i dont like seeing the title mangled anymore"): BOTH title decorations OFF by-  // default. titleTag = the "(ai-thread: X)" resolution suffix (pup resolves transiently while the-  // window is off-screen when this is off, so titles stay clean and window-finding still works);-  // titlePrefix = the "<thread> · " cosmetic prefix at the start.-  // v2.0.20 HOTFIX: titleTag default BACK OFF. The v2.0.19 flip re-armed the title-observer-  // ping-pong the code has always warned about (SPA rewrites title -> observer re-tags -> loop-  // pegs the renderer + floods CDP) and froze a live window within the hour (hd-publish-1.0.0,-  // captureScreenshot Internal error). Ownership visibility stays via the jump-list header name-  // and the in-window owner chip; the title tag remains an OPT-IN.-  D.titleTag = 'off'; D.titlePrefix = 'off';-  if (_pupSettings.autoLogin === 'off' && !chosen.autoLogin) _pupSettings.autoLogin = 'on';-  // v1.9.260 (John: "2s feels too fast cuz i can't read"): readable default. A stored 2000 was the-  // old default, not a choice made in the dialog (it shipped hours ago) - migrate it up once.-  if (D.fgCaptionMs) D.fgCaptionMs = 3500;-  if (_pupSettings.fgCaptionMs === 2000) _pupSettings.fgCaptionMs = 3500;-  for (const k of Object.keys(D)) if (_pupSettings[k] === undefined) _pupSettings[k] = D[k];-  return _pupSettings;-}-function savePupSettings(patch) {-  const cur = { ...pupSettings(), ...patch };-  try { fs.mkdirSync(path.dirname(PUP_SETTINGS_PATH), { recursive: true }); fs.writeFileSync(PUP_SETTINGS_PATH, JSON.stringify(cur, null, 2)); } catch (e) {}-  _pupSettings = cur;-  return cur;-}--// v1.9.185 — MINIMAL-TOUCH MODE (John, 2026-07-29: "you crash chrome with the way you control it …-// what are you doing that's so different trying to control chrome that you cause crashes and freezes-// in the first place? that's where you should be looking").-//-// He is right that the honest experiment is to STOP DOING THE EXTRA THINGS, not to monitor harder.-// Everything pup does to a window BEYOND opening it and driving CDP is cosmetic/管理 machinery that a-// normal Chrome window never experiences, and all of it is cross-process traffic that synchronizes-// with Chrome's UI thread or forces throttled renderers awake:-//   • taskbar identity re-stamps (AUMID changes DESTROY + RECREATE the taskbar button)-//   • tab-count badges + favicon overlays (per-window art pushed through AD → shell)-//   • jump-list rebuilds (every CommitList re-creates the menu)-//   • progress bars, auto-flash, foreground toasts-//   • title re-stamping via an in-page MutationObserver that re-fires on EVERY SPA title change-//   • periodic sweeps that eval into parked/throttled pages (forcing wakes Chrome wanted to avoid)-//   • the unstick real-click, watchdog kills, adoption re-parks-// minimalTouch:'on' keeps ONLY what the user actually asked pup for — windows open in the background,-// drivable over CDP, logins accumulating in the shared profile — and disables every item above.-// If freezes/crashes persist with it on, the cause is NOT pup's window machinery and I must look-// elsewhere; if they stop, this machinery WAS the cause. Either answer is worth more than more-// monitoring. Default OFF (existing behaviour) until measured.-function minimalTouch() {-  try { return pupSettings().minimalTouch === 'on'; } catch (e) { return false; }-}--const PUP_APP_ID = 'Adom.Pup';-const PUP_DISPLAY_NAME = 'Adom Pup';-// v1.9.111 (John's ask) — SAY WHICH MODE the taskbar icon is showing, in the jump-list header row.-// pup already paints five different category icons (public wiki, signed-in wiki, Adom app, plain web,-// mixed) and already names them in the hover tooltip via CAT_LABEL, but the header row just said-// "Adom Pup", so the icon's meaning was guesswork. The header is width-constrained (the flyout is-// ~440px and "Close ALL Adom Pup windows" is about as long as it comfortably gets), so these are-// SHORTER than CAT_LABEL, which stays as-is for the uncapped tooltip. The per-session AUMID already-// encodes the category, so a category flip mints a new appId and the new name takes effect with it.-const CAT_MODE_SHORT = {-  wiki: 'wiki · logged out',-  'wiki-in': 'wiki · LOGGED IN',-  app: 'Adom app',-  web: 'web page',-  mixed: 'mixed content',-};-function pupDisplayName(sessionId) {-  try {-    const cat = sessionId ? pupCategory(sessionId) : null;-    const mode = cat && CAT_MODE_SHORT[cat];-    let name = mode ? `${PUP_DISPLAY_NAME} · ${mode}` : PUP_DISPLAY_NAME;-    // v2.0.19 (John): the jump-list header + pin name carry the OWNING AI THREAD, so a right-click-    // on any pup taskbar button answers "whose window is this" even with the title tag off.-    const s = sessionId && sessions.get(sessionId);-    const own = s && (s.owner || s._ownerThread);-    if (own) name += ` · ${String(own).replace(/^ai[- ]/i, '').slice(0, 28)}`;-    return name;-  } catch (e) { return PUP_DISPLAY_NAME; }-}-let _identitySupported = null;   // null = unprobed-let _identityRegistered = false;-let _jumplistSupported = null;   // null = unprobed (desktop_set_window_jumplist, AD ≥1.9.139)-// v1.8.74: adom-pup-BROWSER.ico — favicon + browser-window inlay (lower-right), so the icon-// says "this is an Adom BROWSER window" at a glance. NEW FILENAME on art change, always —-// Windows' shell + AD's HICON cache key by path (stale-tile risk otherwise). Future home for-// the master art: the Adom brand-icons wiki repo; for now the pup repo owns it.-// v1.9.12: the taskbar icon now CARRIES THE ADOM-WIKI LOGIN STATE (John: "show an indicator in the-// pup icon whether the adom wiki is logged in... maybe that browser window icon gets filled in-// solid"). Two variants of the SAME art, differing only in the lower-right corner glyph:-//   adom-pup-browser3.ico    — browser-window OUTLINE  = logged OUT / not a wiki window (default)-//   adom-pup-wiki-authed1.ico — SOLID PADLOCK          = signed in to the Adom wiki-// Solid-vs-outline is the most legible difference at 16-32px (a mass/contrast change survives-// downscaling; fine shape detail does not), and the padlock adds the semantic meaning on top.-// The OVERLAY slot is NOT used for this — it belongs to the active tab's favicon (v1.9.0).-// NEW FILENAME on any art change, always: the shell + AD cache HICONs by PATH (stale-tile trap).-// v1.9.19: CATEGORY ICONS. A pup window's taskbar icon says WHAT KIND of thing it is showing.-// Every icon is the teal Adom tile (the pup identity, the container) carrying ONE monochrome-// #e6edf3 glyph drawn on the 24x24 house grid, per the brand icon law: custom hand-drawn, single-// colour, no gradients, no shadows, NO EMOJI. Glyphs match the shipped family style (see the brand-// page icons: symbol, step, shotlog).-//   wiki     open book, outline   Adom wiki, public / logged out-//   wiki-in  open book, SOLID     Adom wiki, signed in   (solid vs hollow is the one mass change-//                                 that survives Windows painting the button at ~24px; a padlock-//                                 turned to mush at that size)-//   app      window + prompt      an Adom app: localhost or a cloud slug proxy URL-//   web      globe                anything else on the internet-//   mixed    stacked windows      tabs spanning more than one category-// v1.9.54: the RETIRED default was adom-pup-browser3.ico (the old "Adom mark fills the tile + tiny-// corner window" brand). John retired it in favour of the teal-tile category family. The new default-// (pup-cat-default9.ico) matches that family — teal tile + dark-teal window body + a neutral-// browser-chrome glyph — so a category-less window and the base Adom Pup identity no longer wear the-// old art anywhere. The 5 categories still override it via PUP_CAT_ICONS.-const PUP_ICON_DEFAULT = 'pup-cat-default9.ico';-// v1.9.45 — ICON GENERATION. BUMP THIS whenever the icon FILES or the AUMID branding mechanism-// change, and every live window gets a freshly-painted taskbar button.-//-// WHY it must exist: Win11 bakes a taskbar button's tile when the button is CREATED and never-// re-reads it (AD's measurement, WIN11_TASKBAR_ICON.md Part 7). A window keeps one appId for its-// whole life, so fixing what an appId RESOLVES TO cannot repair a button that already exists. That-// is exactly what happened in 1.9.44: writing IconResource fixed every FUTURE button but left the-// already-created ones white, because their tiles were baked while the AUMID was still unbranded.-// Folding a generation token into the appId turns an icon change into a NEW appId, which forces the-// shell to create a NEW button, which is the ONLY thing that repaints. Cost is one button rebuild.-const PUP_ICON_GEN = 'i8';   // i8: virgin AUMIDs after the 2.0.217-222 registry sweeps orphaned ab's icon-handle cache (registered-by-appId, keys deleted on disk -> stamps skipped the re-write, buttons lost their art). i7 (John): NO NOTCH — base tile is a FULL solid icon; the overlay carries its own background now. BUMPED because the-// icon FILES changed — the AUMID appId carries this token, so a new gen forces Windows to CREATE A NEW-// BUTTON that reads the new (notched) icon. Same-appId re-stamps do NOT repaint a baked Win11 tile, which-// is why the notch never appeared on the live button until this bump. (i3 = pre-notch PNG-frame icons.)-const PUP_CAT_ICONS = {-  wiki: 'pup-cat-wiki9.ico',-  'wiki-in': 'pup-cat-wiki-in9.ico',-  app: 'pup-cat-app9.ico',-  web: 'pup-cat-web9.ico',-  mixed: 'pup-cat-mixed9.ico',-};-function classifyUrl(u) {-  try {-    if (!u) return null;-    if (isAdomUrl(u)) return 'wiki';-    const h = new URL(u).hostname;-    if (/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(h)) return 'app';-    // v1.9.41 (audit): ANY *.adom.cloud slug host is an Adom app, with or without a /proxy/ path.-    // The old rule demanded /proxy/, so an app served at a bare slug (project-manager-xxxx.adom.cloud)-    // was labelled "web" and wore the globe. Caught by auditing my own taskbar.-    if (/\.adom\.cloud$/i.test(h)) return 'app';-    if (/^(about|chrome|edge|devtools):/i.test(u) || u.startsWith('file:')) return null; // ignore chrome pages-    return 'web';-  } catch (e) { return null; }-}-// v2.0.277 — TILES MODE: EVERY window wears a NAME TILE, never a category glyph. Root of John's-// "inconsistent taskbar icons" (2026-08-22): a window that lost its owning ai-thread (recovered /-// reclaimed popup, issue #70) had `owner`/`_ownerThread` both empty, so the tile branches fell-// through to a category AUMID (`Adom.Pup.<sid>.<cat>`) and the button rendered the pup category-// icon sitting next to real thread tiles. This helper removes the downgrade: when the real owner is-// gone, derive a STABLE readable name from the sessionId (cleaned) and FREEZE it on the session, so-// the window still wears a name tile and never churns. Real owner always wins when present; the-// derived name is only a floor so tiles mode is visually consistent no matter what. (Persisting the-// real owner across recovery — the deeper #70 root — is separate; this makes the SYMPTOM-// unreachable regardless.)-function tileOwnerName(sess, sessionId) {-  if (!sess) return null;-  const real = sess.owner || sess._ownerThread;-  if (real) return real;-  if (sess._derivedTileName) return sess._derivedTileName;-  const sid = String(sessionId || sess.sessionId || '').trim();-  let name = sid-    .replace(/^adom-you-?/i, '')          // strip the reclaimed-popup profile prefix-    .replace(/\bwin\d+\b/ig, '')           // strip the random win<rand> suffix-    .replace(/[-_]+/g, ' ')                // dashes/underscores -> spaces so the tile reads as words-    .replace(/\s+/g, ' ')-    .trim();-  if (!name) name = sid || 'pup';-  sess._derivedTileName = name;-  return name;-}-// The window's category across ALL its tabs. One kind -> that kind. Several -> 'mixed'.-function pupCategory(sessionId) {-  try {-    const s = sessionId && sessions.get(sessionId);-    if (!s) return null;-    const kinds = new Set();-    for (const t of (s.tabs || [])) {-      let k = null;-      try { k = classifyUrl(t.page && t.page.url()); } catch (e) {}-      if (k) kinds.add(k);-    }-    // v2.0.61 (John: predict from the URL at open — wiki is the 60% case): while pages are-    // still loading (about:blank), classify the INTENDED url so the FIRST stamp is already the-    // categorized id; and an unchecked wiki page predicts signed-in (auto-login lands there in-    // the common case). A wrong guess re-stamps once when the check resolves — the rare case.-    if (!kinds.size) { const kp = classifyUrl(s._intendedUrl); if (kp) kinds.add(kp); }-    if (!kinds.size) return null;-    if (kinds.size > 1) return 'mixed';-    const only = [...kinds][0];-    if (only === 'wiki' && (s._wikiAuthed || s._wikiAuthed === undefined)) return 'wiki-in';-    return only;-  } catch (e) { return null; }-}-function pupIconPath(sessionId) {-  if (sessionId === 'pup-dashboard') return notchedIconPath(path.join(__dirname, 'icons', PUP_CAT_ICONS.app || PUP_ICON_DEFAULT));-  // v1.9.261 (John): aumidIconStyle 'standard' = ONE icon for every AUMID window (the default pup-  // tile); 'category' = the custom per-category set (wiki signed-out, wiki signed-IN padlock, world-  // icon for the open web, adom app icon, mixed).-  if (pupSettings().aumidIconStyle !== 'category') return notchedIconPath(path.join(__dirname, 'icons', PUP_ICON_DEFAULT));-  const cat = pupCategory(sessionId);-  const file = (cat && PUP_CAT_ICONS[cat]) || PUP_ICON_DEFAULT;-  return notchedIconPath(path.join(__dirname, 'icons', file));-}-// ── Notched category tiles (John, 2026-08-04): "cut a notch out of the main icons so the overlay-// favicon has a clean background to sit on." AD paints the favicon overlay OVER the base tile's-// UPPER-RIGHT (it overhangs, and already shows the taskbar through the tile's transparent rounded-// corners). We punch a bigger transparent rounded notch into the upper-right of each STATIC category-// .ico so the whole favicon floats on the taskbar instead of on the busy teal art. Baked into the-// static tile ⇒ NO per-window / per-favicon churn (only the one-time re-stamp any icon-gen bump-// causes). Generated at runtime on the DESKTOP (sharp; the container has no sharp) into a cache dir.-// v1.9.328 (John: "why cut CIRCLES when the overlay favicon is a SQUARE?"). Right — the overlay is a-// SQUARE (favicon fills 48x48 + Adom mark in the corner), so the notch must be a SQUARE, flush to the-// tile's TOP and RIGHT edges (opening onto the taskbar), with ONLY the inner corner rounded. We get-// that by extending the mask rect PAST the top/right edges (`over`) so those corners fall off-canvas-// and only the inner (bottom-left) corner shows the radius — instead of a rounded-rect that rounds all-// four corners into a blob.-// v1.9.333 (John: "2px cutout on the LEFT of the overlay but only 1px BELOW it — unprofessional").-// The inner (bottom-left) corner radius curved the notch inward exactly where the favicon's bottom-left-// sits, eating the BOTTOM margin while the straight LEFT edge kept its full margin → the asymmetry. A-// near-sharp inner corner makes both the left and bottom cutouts uniform straight edges, so the margin-// around the overlay is even. (rx 0.02 ≈ a hair of anti-alias softening, effectively square at 16px.)-const NOTCH = { frac: 0.50, over: 0.16, rx: 0.02 };   // square side = 50% of tile; inner corner near-sharp for an EVEN margin-const _NOTCH_CACHE = path.join(os.tmpdir(), 'pup-notched-icons', PUP_ICON_GEN + '-sq2');   // -sq2: square notch + baked Adom-mark fallback-let _notchDone = false;-function notchedIconPath(origPath) {-  // Serve the notched copy as soon as it EXISTS on disk (from this run's build OR a prior run's cache),-  // not only after _notchDone — so a bridge respawn immediately re-stamps with the notched tile instead-  // of flashing the un-notched one. The cache path is keyed by gen + shape token, so it can only ever be-  // the CURRENT geometry. _notchDone still gates the one-time force-restamp in ensureNotchedIcons.-  try {-    const dest = path.join(_NOTCH_CACHE, path.basename(origPath));-    if (fs.existsSync(dest) && fs.statSync(dest).size > 0) return dest;-  } catch (e) {}-  return origPath;   // not built yet → original tile (harmless; the enforcer/reconcile swaps it in)-}-// Minimal ICO writer: embed PNG frames (valid on Win10/11 for every size).-function _icoEncode(frames) {-  const count = frames.length;-  const dir = Buffer.alloc(6 + 16 * count);-  dir.writeUInt16LE(0, 0); dir.writeUInt16LE(1, 2); dir.writeUInt16LE(count, 4);-  let offset = 6 + 16 * count; const bodies = [];-  frames.forEach((f, i) => {-    const e = 6 + 16 * i;-    dir[e] = f.size >= 256 ? 0 : f.size;-    dir[e + 1] = f.size >= 256 ? 0 : f.size;-    dir.writeUInt16LE(1, e + 4); dir.writeUInt16LE(32, e + 6);-    dir.writeUInt32LE(f.png.length, e + 8); dir.writeUInt32LE(offset, e + 12);-    offset += f.png.length; bodies.push(f.png);-  });-  return Buffer.concat([dir, ...bodies]);-}-async function _buildNotchedIco(sharp, srcPath, destPath) {-  const largest = icoLargestPng(fs.readFileSync(srcPath));-  if (!largest) throw new Error('no PNG frame in ' + path.basename(srcPath));-  const frames = [];-  for (const N of [256, 128, 64, 48, 32, 16]) {-    // v1.9.348 (John): NO NOTCH. The base taskbar tile is a FULL, solid icon again. The notch was the-    // wrong place to put the overlay's background — it carved a hole in the base tile (so a transparent-    // overlay favicon had *something* behind it), but that made the base icon look chewed-up AND the-    // hole bled through the favicon painted on top. The background now lives ON THE OVERLAY badge itself-    // (see applyAppOverlay: a dark, teal-edged card behind the favicon), so the base stays a clean full-    // tile and every overlay favicon reads well regardless of its own transparency.-    const png = await sharp(largest).resize(N, N, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();-    frames.push({ size: N, png });-  }-  fs.mkdirSync(path.dirname(destPath), { recursive: true });-  fs.writeFileSync(destPath, _icoEncode(frames));-}-let _notchReady = null;-function ensureNotchedIcons() {-  if (process.platform !== 'win32') return Promise.resolve();-  if (_notchReady) return _notchReady;-  _notchReady = (async () => {-    let sharp; try { sharp = require('sharp'); } catch (e) { console.log('[notch] sharp unavailable — using un-notched tiles'); return; }-    const files = [...new Set([PUP_ICON_DEFAULT, ...Object.values(PUP_CAT_ICONS)].filter(Boolean))];-    for (const f of files) {-      try {-        const src = path.join(__dirname, 'icons', f), dest = path.join(_NOTCH_CACHE, f);-        if (!fs.existsSync(src)) continue;-        if (!(fs.existsSync(dest) && fs.statSync(dest).size > 0)) await _buildNotchedIco(sharp, src, dest);-      } catch (e) { console.log(`[notch] FAILED ${f}: ${e.message}`); }-    }-    _notchDone = true;-    console.log(`[notch] ready (${files.length} tiles, notch=${Math.round(NOTCH.frac * 100)}% upper-right) — forcing a re-stamp so buttons pick it up`);-    // Force ONE re-stamp so live buttons swap to the notched icon (the appId is unchanged, but-    // desktop_set_window_identity recreates the button, which is what repaints the tile).-    try { for (const [, s] of sessions) { s._curAppId = null; s._lastCat = undefined; s._frozenAppId = null; s._jlFrozen = false; } await reconcileModel('notched-icons-ready'); } catch (e) {}-  })();-  return _notchReady;-}-// v2.0.40 (John: jump-list items do nothing): the CLI RENAMED from adom-desktop-cli.exe (in-// "Adom Desktop\\") to adom-bridge-cli.exe (in "Adom Bridge\\"). pup hardcoded the OLD path, so-// every jump-list task + header click + pinned launch invoked a MISSING exe and silently no-opped-// (verified live: the old path is gone, the new one exists). The bridge runs ON the desktop, so it-// resolves the real exe from disk (Bridge name first, legacy fallback) and caches it.-let _cliExeCache = null;-function pupCliExe() {-  if (_cliExeCache) { try { if (fs.existsSync(_cliExeCache)) return _cliExeCache; } catch (e) {} _cliExeCache = null; }-  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');-  const candidates = [-    path.join(la, 'Adom Bridge', 'adom-bridge-cli.exe'),-    path.join(la, 'Adom Bridge', 'adom-desktop-cli.exe'),-    path.join(la, 'Adom Desktop', 'adom-desktop-cli.exe'),-  ];-  for (const c of candidates) { try { if (fs.existsSync(c)) { _cliExeCache = c; return c; } } catch (e) {} }-  return candidates[0];   // current product name is the best guess if none found on disk-}-function pupRelaunchCommand(sessionId) {-  const cli = pupCliExe();-  const payload = JSON.stringify({ sessionId, foregroundReason: 'user clicked the Adom Pup taskbar header or pinned launcher', caller: { aiThread: 'user-taskbar-launcher' } }).replace(/"/g, '\\"');-  // v1.9.115: same as the task commands — this runs when the user clicks the jump-list HEADER row or a-  // pinned pup launcher, so it needs identity or AD refuses it and the click does nothing.-  return `"${cli}" pup_focus_window "${payload}"`;   // v1.9.121: identity rides in the payload JSON (see pupRelaunchCommand above)-}-// ─── v2.0.41: jump-list tasks call pup DIRECTLY (John: "be way smarter about how to call back into-// ab for the jump lists ... should pup be able to talk back to itself when it's its own bridge?").-// Answer: YES, for pup_* verbs. Old design set NO task `target`, so ab supplied the program (its-// bundled CLI) — an exe path pup neither controls nor can verify, and the very thing the rename broke.-// Now every task carries target=powershell.exe + a pup-OWNED launcher script that:-//   1) reads pup's OWN loopback port from a discovery file pup rewrites on every bind, then-//   2) POSTs the verb straight to http://127.0.0.1:<port>/command — pup talking to itself, no ab-//      relay hop, no CLI exe, no stale path (the two things that broke it), and-//   3) ONLY if that direct hit fails (pup idled/died — the Chrome windows outlive the node process,-//      and just pup→pup can't wake a dead pup) falls back to the resolved adom-bridge-cli.exe, which-//      goes through ab and RESPAWNS pup. Best of both: fast+accurate when up, ab-wake when asleep.-// JSON args ride as BASE64 (argv-safe: no spaces/quotes) so nothing mangles through the ShellLink →-// Explorer → powershell argv re-parse that historically ate raw escaped JSON (v1.9.9).-function pupPortFile() {-  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');-  return path.join(la, 'Adom Bridge', 'pup-bridge-port.json');-}-function writePupPortFile(overridePort) {-  if (process.platform !== 'win32') return;-  try {-    const f = pupPortFile();-    fs.mkdirSync(path.dirname(f), { recursive: true });-    // v2.0.231: overridePort lets the EACCES fallback record the port pup ACTUALLY bound, so the-    // file never advertises a port nothing is listening on.-    fs.writeFileSync(f, JSON.stringify({ port: Number(overridePort) || PORT, pid: process.pid, version: BRIDGE_VERSION, host: '127.0.0.1', assignedPort: PORT, fallback: !!overridePort }));-  } catch (e) { console.error('[jumplist] port-discovery write failed:', e.message); }-}-// v2.0.369 (CRR "pup not reachable", John 2026-09-02): the toolbar popup used to GUESS the port-// from a 2-entry hardcoded list, which broke the moment ab assigned anything else (CRR got 50950).-// The bridge now writes its LIVE port into the staged extension dir; the popup fetches-// chrome.runtime.getURL('port.json') — an unpacked extension reads that from disk on every popup-// open, so a bridge respawn on a new port is picked up with NO Chrome relaunch.-function writeExtensionPortFile(overridePort) {-  try {-    const d = path.join(os.homedir(), '.adom', 'pup-extension');-    fs.mkdirSync(d, { recursive: true });-    fs.writeFileSync(path.join(d, 'port.json'), JSON.stringify({ port: Number(overridePort) || PORT, version: BRIDGE_VERSION, pid: process.pid }));-  } catch (e) { console.error('[toolbar] port.json write failed:', e.message); }-}-const PUP_JL_LAUNCHER_VERSION = 3;   // bump to force-rewrite the .ps1 on upgrade-function pupJumplistLauncherPath() {-  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');-  return path.join(la, 'Adom Bridge', 'pup-jumplist-call.ps1');-}-function ensurePupJumplistLauncher() {-  if (process.platform !== 'win32') return pupJumplistLauncherPath();-  const p = pupJumplistLauncherPath();-  // The script is pup's OWN callback shim. $Verb + $B64 (base64 of the verb's JSON args). It hits pup-  // directly, and only falls back to the CLI (which wakes pup) if the direct POST throws.-  const script = `# Adom Pup jump-list callback (v${PUP_JL_LAUNCHER_VERSION}) — pup talks to itself; CLI wakes it if asleep.-param([Parameter(Mandatory=$true)][string]$Verb, [string]$B64='')-$ErrorActionPreference='Stop'-$json = if ($B64) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($B64)) } else { '{}' }-try { $a = $json | ConvertFrom-Json } catch { $a = @{} }-# stamp caller identity into args too (some verbs read args.caller; the header covers the rest)-try { $a | Add-Member -NotePropertyName caller -NotePropertyValue (@{aiThread='user-taskbar-menu'}) -Force } catch {}-$body = (@{ command=$Verb; args=$a } | ConvertTo-Json -Depth 12 -Compress)-$portFile = Join-Path $env:LOCALAPPDATA 'Adom Bridge\\pup-bridge-port.json'-$ok=$false-try {-  $port = (Get-Content $portFile -Raw -ErrorAction Stop | ConvertFrom-Json).port-  Invoke-RestMethod -Uri ("http://127.0.0.1:{0}/command" -f $port) -Method Post -ContentType 'application/json' -Headers @{'x-adom-caller-thread'='user-taskbar-menu';'x-adom-caller-reason'='jump-list click'} -Body $body -TimeoutSec 6 | Out-Null-  $ok=$true-} catch { $ok=$false }-if (-not $ok) {-  # pup is not answering (idled/died). Go through the CLI so ab respawns it.-  $cli = Join-Path $env:LOCALAPPDATA 'Adom Bridge\\adom-bridge-cli.exe'-  if (-not (Test-Path $cli)) { $cli = Join-Path $env:LOCALAPPDATA 'Adom Desktop\\adom-desktop-cli.exe' }-  if (Test-Path $cli) { & $cli $Verb $json | Out-Null }-}-`;-  try {-    let cur = null; try { cur = fs.readFileSync(p, 'utf8'); } catch (e) {}-    if (cur !== script) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, script); }-  } catch (e) { console.error('[jumplist] launcher write failed:', e.message); }-  return p;-}-// Build a jump-list task that routes through the pup-owned launcher (direct-to-pup, CLI fallback).-function pupTask(verb, obj, meta) {-  const b64 = Buffer.from(JSON.stringify(obj || {}), 'utf8').toString('base64');-  const launcher = pupJumplistLauncherPath();-  return {-    title: meta.title,-    description: meta.description,-    iconPath: meta.iconPath,-    target: 'powershell.exe',-    args: `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}" ${verb} ${b64}`,-  };-}-function _isUnknownVerb(r, raw) { return /unknown (desktop )?command|unknown verb/i.test(((r && r.error) || '') + ' ' + (raw || '')); }-// v2.0.118: delete any "Adom Pup" Start Menu shortcut a prior pup version left (per-user and,-// best-effort, all-users). Pup's own shortcut, safe to remove; HKCU/per-user needs no admin.-function removePupStartMenuShortcut() {-  if (process.platform !== 'win32') return;-  const dirs = [];-  if (process.env.APPDATA) dirs.push(process.env.APPDATA);-  if (process.env.ProgramData) dirs.push(process.env.ProgramData);-  for (const base of dirs) {-    const lnk = path.join(base, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Adom Pup.lnk');-    try { if (fs.existsSync(lnk)) { fs.unlinkSync(lnk); console.log(`[identity] removed stale "Adom Pup" Start Menu shortcut: ${lnk}`); } } catch (e) { /* all-users needs admin; ignore */ }-  }-}-async function ensurePupIdentityRegistered() {-  if (process.platform !== 'win32' || _identitySupported === false) return false;-  if (_identityRegistered) return true;-  try {-    // v2.0.118 (John: Adom users don't want an "Adom Pup" entry in their Start Menu). Register the-    // anchor AUMID registry-ONLY (shortcut:false) and brand it by writing IconResource straight into-    // HKCU — the SAME shortcut-free technique the per-window AUMIDs already use — so grouped taskbar-    // mode still has its grouping anchor but NO Start Menu shortcut is ever created. The old-    // pinnable-launcher shortcut is dropped deliberately: nobody asked for it and users dislike it.-    removePupStartMenuShortcut();   // self-heal any shortcut a prior pup version created on this machine-    writeAumidIcon(PUP_APP_ID, pupIconPath());-    const r = await chrome.abCommand('desktop_register_app_identity', {-      appId: PUP_APP_ID, displayName: PUP_DISPLAY_NAME, iconPath: pupIconPath(), shortcut: false,-    }, { timeoutMs: 8000 });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));-    if ((r && r.success === false) || _isUnknownVerb(r, raw)) {-      if (_isUnknownVerb(r, raw)) { _identitySupported = false; console.log('[identity] AD lacks register_app_identity (needs >=1.9.134) — overlay badge stays'); }-      else console.log(`[identity] register failed: ${((r && r.error) || raw || '').slice(0, 140)}`);-      return false;-    }-    _identitySupported = true; _identityRegistered = true;-    console.log('[identity] Adom.Pup anchor AUMID registered (HKCU registry-only, NO Start Menu shortcut — v2.0.118)');-    return true;-  } catch (e) { console.log(`[identity] register threw: ${e.message}`); return false; }-}-// v2.0.25 (John watched the button mutate 3x on open: overlay-on-chrome-icon, then AUMID recreate,-// then overlay again): the create-time stamp fires BEFORE the session registers, so its result had-// nowhere to be recorded — the later re-brand then re-committed the SAME appId (visible button-// recreate #2). Remember pre-registration stamps here; launchSession copies them onto the session-// at registration so the idempotence gate makes the second stamp a true no-op.-const _earlyStamped = new Map();   // sessionId -> appId stamped before session registration-let _stampLandedThisBoot = false;   // v2.0.186 (#51): gates the AUMID prune until re-stamps land after a respawn-async function stampPupIdentity(sessionId, opts) {-  // v1.9.271: the dashboard must never gang onto the shared button. v1.9.272 (John: "if we're in-  // aumid mode then the pup dashboard should be in that mode too. right now you're being-  // inconsistent"): so in AUMID mode it gets its OWN DEDICATED identity (Adom.Pup.Dashboard) - own-  // button always, but wearing the same tile system as every other stamped window. The flag rides-  // to the appId computation below; grouped mode never absorbs it.-  // v1.9.185 minimal-touch: the AUMID stamp is the single most invasive thing pup does to a window-  // it does not own (Windows DESTROYS and RECREATES the taskbar button on an AppUserModelID change).-  // Gated at the choke point so no caller can leak it.-  // v1.9.248 reverted the silent 1.9.245 re-enable (see dev-skills/pup-aumid-decisions).-  // v1.9.259: John EXPLICITLY re-decided, per that skill's process rule, as a USER-CONTROLLED-  // EXPERIMENT toggle (2026-08-01: "a key setting is letting me turn on AUMID taskbar icons or-  // leaving them off... then i can play around with having it on or off and see if aumid is what's-  // crashing the app or not"). Default stays OFF. When the user turns it on: ONE gentle stamp per-  // window at creation ({once}), never the category re-stamp churn.-  // v1.9.279: the stamp is now IDEMPOTENT and SELF-CORRECTING. aumidIcons is the single master-  // switch (works the same in minimal-touch or not). The old {once} guard blocked legitimate-  // identity CHANGES (grouped->split never re-stamped, so John's split setting never took effect);-  // idempotency now comes from the desired-vs-current appId compare below (a real change re-stamps-  // exactly once; no change is a no-op), so `opts` is ignored and reconcileModel can call this every-  // sweep with zero churn.-  if (pupSettings().aumidIcons !== 'on') return false;-  if (process.platform !== 'win32' || !sessionId || _identitySupported === false) return false;-  const _stampGen = _identityGen;   // v2.0.216: abort if a mode change lands while this stamp is in flight-  if (!_identityRegistered) { await ensurePupIdentityRegistered(); if (_identitySupported === false) return false; }-  try {-    // v1.8.81: PER-SESSION AUMID (Adom.Pup.<sessionId>). A single shared AUMID made Windows-    // GROUP every pup window into ONE taskbar button — killing per-window differentiation and-    // making the tab-count overlay show whichever window last updated (John caught 4 windows-    // masquerading as one button with a wrong badge). Per-session ids restore one button per-    // window; the per-window RelaunchIconResource/RelaunchDisplayNameResource AD sets are what-    // brand an UNREGISTERED AUMID's button, and the registered plain "Adom.Pup" stays as the-    // pinnable Start Menu launcher.-    // v1.9.27 (AD's Win11 measurement, WIN11_TASKBAR_ICON.md Part 7): the taskbar TILE is baked-    // when the BUTTON is created and NEVER re-read — WM_SETICON, DeleteTab/AddTab, SHChangeNotify,-    // FRAMECHANGED, RedrawWindow, TaskbarCreated all leave it unchanged. The ONLY live repaint is-    // making the shell create a NEW button, which happens when the window is stamped with a-    // DIFFERENT appId. So the per-session appId now CARRIES THE CATEGORY:-    //     Adom.Pup.<sid>.<category>-    // A category flip = register new appId -> stamp it -> unregister the old one (~1s, no flicker,-    // AD-verified). TRADEOFF (Windows constraint, by design): a taskbar PIN binds to one AUMID, so-    // a pin made under an old category shows the running window as a separate button. Per-session-    // windows are transient and their pins launch via relaunchCommand anyway, so tile-tracks-state-    // wins here; GROUPED mode keeps the stable pinnable Adom.Pup.-    const grouped = pupSettings().taskbarGrouping === 'grouped';-    const sess = sessions.get(sessionId);-    // v2.0.168: pre-registration stamps (no session yet => owner unknowable) are SKIPPED in Thread-    // Tiles mode — they could only mint an intermediate per-session id the button would transition-    // through, violating the direct CfT -> thread-tile rule. The registered-session stamp follows-    // within a beat and carries the real thread identity.-    if (!sess && pupSettings().threadTiles === 'on') return false;-    // v1.9.52 — DO NOT stamp a transient ".plain" identity for a window that is merely still LOADING.-    // A freshly-opened window sits on about:blank for a beat (category null -> "plain"), then lands on-    // its real URL (category web/wiki/app). The old code stamped ".plain" during that beat, which-    // REGISTERED an Adom.Pup.<sid>.plain.<gen> AUMID — a full taskbar identity with NO jump list — and-    // created a button under it, then created ANOTHER button under ".category" a moment later. Windows-    // caches the button->AUMID->jumplist association per button; that plain->category churn is what-    // made the jump list "blip out and need a second right-click" (Explorer first resolved the stale-    // ".plain" association, which had no list). Skipping the plain stamp while a real URL is loading-    // means the button is created ONCE, under its final category AUMID, with its jump list already-    // attached — so the menu resolves on the first click. A genuinely category-less window (a page-    // that never leaves about:blank) still gets "plain" after a few attempts so it is never unbranded.-    const rawCat = pupCategory(sessionId);-    // v2.0.168 THREAD TILES DIRECT TRANSITION (John: "you should be going direct from the standard-    // CfT taskbar icon to your ai-thread name icon — no intermediate AUMID"). A thread-eligible-    // window's identity does not depend on the page category at all, so do NOT wait for the category-    // to settle (and never mint a pre-registration intermediate id, below) — stamp the thread AUMID-    // as the FIRST and ONLY identity the button ever transitions to.-    const _threadEligible = pupSettings().threadTiles === 'on' && sess && tileOwnerName(sess, sessionId);-    if (!grouped && !rawCat && !_threadEligible) {-      const hasRealUrlLoading = (sess && (sess.tabs || []).some(t => {-        try { const u = t.page && t.page.url(); return u && !/^(about:blank|chrome|edge|devtools):/i.test(u); } catch (e) { return false; }-      }));-      const attempts = (sess && sess._plainStampAttempts) || 0;-      if (hasRealUrlLoading || attempts < 6) {-        if (sess) sess._plainStampAttempts = attempts + 1;-        return false; // pending — the retry loop / framenavigated recheck re-stamps once the category is known-      }-    }-    if (sess) sess._plainStampAttempts = 0;-    const cat = rawCat || 'plain';-    const _dashInfra = sessionId === 'pup-dashboard';-    // v1.9.321 (John: "i like that you just group to each category icon"): make GROUPED mode actually-    // group BY CATEGORY when the category icon-style is on — windows of the same category share one-    // AUMID (Adom.Pup.<cat>), so you get up to 5 category buttons (all wiki-signed-in windows under the-    // padlock button, all open-web windows under the globe, etc.), each wearing its category icon.-    // Previously grouped lumped EVERY window under one Adom.Pup button regardless of style. Plain-    // "standard" style still collapses to the single Adom.Pup button (one icon, so per-category buttons-    // would be pointless). Split is unchanged (one button per window).-    const _catGroup = grouped && pupSettings().aumidIconStyle === 'category';-    // v2.0.152 THREAD TILES: when on, every window of an ai-thread shares ONE AUMID keyed on the-    // thread name, so the whole thread collapses to a single named taskbar button. Takes precedence-    // over category/grouped/split. The dashboard keeps its own identity; a window with no owner-    // thread falls through to the normal modes.-    const _thread = (!_dashInfra && pupSettings().threadTiles === 'on' && sess && tileOwnerName(sess, sessionId)) || null;-    let appId = _dashInfra ? `${PUP_APP_ID}.Dashboard.${PUP_ICON_GEN}`-      : _thread ? threadAumid(_thread)-      : _catGroup ? `${PUP_APP_ID}.${cat}.${PUP_ICON_GEN}`-      : grouped ? PUP_APP_ID-      : `${PUP_APP_ID}.${sessionId}.${cat}.${PUP_ICON_GEN}`;-    // v2.0.7 (John chose "stamp once, then freeze"): once a per-session window has been branded with a-    // real category, DO NOT re-stamp it when its category later changes (e.g. adding a web tab to a wiki-    // window flips the category to "mixed"). Every category change recreates the taskbar button plus its-    // jump-list and overlay — the multi-tab churn John kept catching. Freezing the appId after the first-    // real stamp trades a possibly-stale tile (a wiki window that later goes mixed still reads "wiki")-    // for ZERO taskbar churn for the window's life. Grouped/dashboard keep their own identities; the-    // active-tab favicon overlay still updates (that is the point, not churn), and the jump-list still-    // follows a view (public<->authed) flip since that changes the menu, not the button.-    if (sess && sess._frozenAppId && !_dashInfra && !grouped && !_thread) appId = sess._frozenAppId;-    const prevAppId = sess && sess._curAppId;-    // v1.9.279 THE IDEMPOTENCY GATE: only touch the window when its identity actually changes.-    // desktop_set_window_identity RECREATES the taskbar button every call, so firing it when nothing-    // changed is exactly the churn/flicker (and suspected crash pressure) we must never cause. Equal-    // desired-vs-current appId => no-op. This is what lets reconcileModel run every sweep safely.-    if (prevAppId === appId) {-      // v2.0.378 (#83, the blank-button ghost): a RECOVERED window arrives with _curAppId restored,-      // so this gate made its whole identity a no-op — but the shell state behind that id was built-      // by a Chrome relaunch that could precede the registration being readable (or the key could be-      // gone), and nothing ever rebuilt the button: a badge floating on an empty tile. Once per-      // recovered session: re-assert the registration and rebuild the button, then trust the gate.-      if (sess && sess._recovered && !sess._recoveryIdentityVerified) {-        sess._recoveryIdentityVerified = true;-        try { await registerPupAumid(appId, sessionId); } catch (e) {}-        try {-          if (!sess._hwnd) await resolveSessionHwndByBounds(sess, sessionId);-          const _rh = (sess._hwnd && await hwndBelongsToPup(sess._hwnd, sess)) ? sess._hwnd : null;-          if (_rh) { console.log(`[identity] "${sessionId}" recovered window: re-asserted ${appId} + one button rebuild (#83)`); nudgeTaskbarButton(_rh, sessionId); }-        } catch (e) {}-      }-      return true;-    }-    // v2.0.2 COALESCE THE CATEGORY-SETTLE CHURN (John: "why did i see the AUMID applied 3x when i-    // opened that window?"). A freshly-opened window's category refines as it loads and authenticates-    // (loading -> web -> wiki-signed-in), and every change re-stamps, which RECREATES the taskbar-    // button, so a single open flickered the button 2-3 times. Debounce the commit: record the-    // desired appId and stamp ONCE after a short quiet window; a further category change within the-    // window just updates the target and is picked up by the single scheduled commit, so the-    // intermediate tiles never get stamped. Same one-commit-per-real-change rule as the overlay paint.-    // The idempotency gate above already makes a stable window a no-op, so this only ever debounces a-    // genuine category CHANGE, never steady state. The commit re-enters with {_commit:true} to skip-    // the debounce and stamp the (by-then settled) category.-    const AUMID_SETTLE_MS = 1800;-    if (sess && !(opts && opts._commit)) {-      sess._desiredAppId = appId;-      if (!sess._aumidCommitTimer) {-        sess._aumidCommitTimer = setTimeout(() => {-          sess._aumidCommitTimer = null;-          if (sessions.has(sessionId)) stampPupIdentity(sessionId, { _commit: true }).catch(() => {});-        }, AUMID_SETTLE_MS);-      }-      return true; // deferred; the scheduled commit stamps the settled category exactly once-    }-    // Register the NEW appId BEFORE stamping (AD's verified order) so the fresh button resolves-    // its icon/name from the registry the moment it is created.-    if (true) {-      try { await registerPupAumid(appId, sessionId); } catch (e) {}-      // v1.9.61 — ORDER MATTERS (the live-tile trap, from AD's window-identity skill): Win11 bakes a-      // taskbar button's art when the button is CREATED and never re-reads it. Stamping the new appId-      // below is what creates that button. We used to brand the jump-list header AFTER the stamp, so-      // the header baked GENERIC and the later headerBranded:true had no visible effect — exactly what-      // John kept seeing. Brand the header (writes the window's Relaunch trio) FIRST, so the button is-      // born with it.-      if (pupSettings().jumpLists === 'on') { try { await updateWikiJumplist(sess, sessionId, { appId }); } catch (e) {} }-    }-    // v1.9.307 (John turned AUMID on and only 1 of 2 windows stamped — "why can't you get this right?"):-    // the stamp resolved the window by the TITLE TAG, which is OFF, so a window whose title lacks-    // "(ai-thread: X)" (the dashboard, whose page sets its own title) could NEVER be found — the log-    // said "No visible window with title containing (ai-thread: pup-dashboard...". Target by the-    // resolved HWND instead, the exact same handle the overlay paint + flash already use successfully.-    // Title stays as a fallback only for a window we somehow have no hwnd for.-    if (sess && !sess._hwnd) { try { await resolveSessionHwndByBounds(sess, sessionId); } catch (e) {} }-    const _stampHwnd = (sess && sess._hwnd && await hwndBelongsToPup(sess._hwnd, sess)) ? sess._hwnd : null;-    // v2.0.152 THREAD TILES: the per-window Relaunch icon must match the thread tile too, or Windows-    // would paint the default pup icon on the born button (it prefers the window's Relaunch icon over-    // the registry one). registerPupAumid ran just above and cached the .ico, so read it synchronously.-    // v2.0.222: -e<epoch> suffix — the plain-mode registry sweep deletes IconResource keys, and-    // ab's icon-handle cache (keyed by cacheKey) would otherwise skip re-writing them on the next-    // stamp, leaving buttons with the right NAME and no ART (the r1-tiles 0.017-teal ralph fail).-    const _regEpoch = Number(pupSettings()._stripEpoch || 0);-    let _stampIcon = pupIconPath(sessionId), _stampName = pupDisplayName(sessionId), _cacheKey = `pup-${sessionId}-${cat}-e${_regEpoch}`;-    if (appId.startsWith(`${PUP_APP_ID}.thread.`)) {-      const _tn = tileOwnerName(sess, sessionId);-      if (_tn) {-        // v2.0.157 THREAD TILES — render the name tile RIGHT HERE (stampPupIdentity always runs; the-        // registerPupAumid path was short-circuiting on already-registered appIds). Cached + deduped,-        // so a second caller is cheap. Any live browser can draw the offscreen 256px tile.-        let _tp = path.join(THREAD_TILE_DIR, `${threadSlug(_tn)}.ico`);-        let _ok = false; try { _ok = fs.existsSync(_tp) && fs.statSync(_tp).size > 100; } catch (e) {}-        if (!_ok) {-          // Render on an EXISTING page (no new window). Prefer this session's own tab; else any live tab.-          let _rp = sess && (sess.tabs || []).map(t => t && t.page).find(Boolean);-          if (!_rp) { outer: for (const [, s2] of sessions) { for (const t2 of (s2.tabs || [])) { if (t2 && t2.page) { _rp = t2.page; break outer; } } } }-          if (_rp) { try { const r = await renderThreadTileIco(_rp, _tn); if (r) _tp = r; } catch (e) { console.log(`[thread-tile] stamp render err: ${e.message}`); } }-        }-        // v2.0.176 (John: "why do you apply the aumid TWICE? the generic icon, then the ai-thread-        // name icon — that seems overkill"). He was right: _stampIcon defaults to the generic/category-        // pup tile, so if the name tile had not finished rendering yet, the thread AUMID got stamped-        // with the GENERIC icon and the real tile arrived on a LATER stamp — two visible identity-        // changes for one window. There is no reason to ever stamp a thread identity without its-        // tile: bail and let the retry stamp once, when the art is ready. Until then the window-        // simply keeps Chrome's own icon, which is honest (pup has not claimed it yet) and is the-        // single transition John asked for: default Chrome -> thread name tile, nothing in between.-        let _tileReady = false; try { _tileReady = fs.existsSync(_tp) && fs.statSync(_tp).size > 100; } catch (e) {}-        if (!_tileReady) {-          console.log(`[thread-tile] "${sessionId}" stamp DEFERRED — tile for "${_tn}" not rendered yet (no generic-icon placeholder stamp)`);-          return false;-        }-        _stampIcon = _tp;-        _stampName = String(_tn);-        // v2.0.166 THE STALE-HANDLE TRAP: ab caches icon HANDLES by cacheKey for its process-        // lifetime ("ab holds the icon handles for its process lifetime"). An unversioned key meant-        // a thread whose tile was first loaded in the broken PNG-ico era kept serving that dead-        // handle forever — the fixed BMP tile on disk was never re-read, and the button fell back to-        // the default Chrome-for-Testing identity (measured: fusion-verb-tests/reclaimed, first seen-        // AFTER the BMP fix, branded fine; shotlog/sxsw, first seen before it, never did). Version-        // the key with the tile generation so new art always gets a fresh handle.-        // v2.0.270 (John: "why is this pup icon so wrong? its not showing the ai-thread name"):-        // versioning by GENERATION is not enough. ab caches icon HANDLES by cacheKey for its whole-        // process lifetime, and the generation only changes when WE bump it — so a button whose-        // handle ab cached from an earlier stamp (category art, before this thread's tile finished-        // rendering) keeps serving that dead handle forever. Measured on John's "Bills" window: the-        // AUMID was registered, its IconResource pointed at the right .ico, and the .ico itself-        // rendered "Bills" correctly at all four sizes — yet the shell drew the category tile, and a-        // full threadTiles off/on re-register did NOT shift it, because the cacheKey never changed.-        // Key on the tile's CONTENT instead: mtime+size means any re-render is automatically a new-        // handle, and a stale one is structurally impossible rather than something an audit has to-        // notice after the fact.-        let _tsig = '0';-        try { const _stt = fs.statSync(_tp); _tsig = `${Math.round(_stt.mtimeMs)}-${_stt.size}`; } catch (e) {}-        _cacheKey = `pup-thread-${threadSlug(_tn)}-${THREAD_TILE_GEN}-t${_tsig}-e${_regEpoch}`;-      }-    }-    if (_stampGen !== _identityGen) { console.log(`[identity] "${sessionId}" stamp ABORTED (identity generation moved: a mode switch superseded this in-flight stamp)`); return false; }-    const r = await chrome.abCommand('desktop_set_window_identity', {-      ...(_stampHwnd ? { hwnd: _stampHwnd } : {}),-      titleContains: `(${TITLE_LABEL}: ${sessionId}`, cacheKey: _cacheKey,-      appId, displayName: _stampName, iconPath: _stampIcon,-      // v1.9.11 (AD ≥1.9.141): brand the taskbar RIGHT-CLICK MENU HEADER row (it showed the-      // chrome.exe icon for months). There is NO "ApplicationIcon" registry value on AUMID keys-      // and the registry cannot brand the header at all — the header obeys the window's-      // Relaunch* property TRIO, and Windows IGNORES RelaunchIconResource unless RelaunchCommand-      // is ALSO set. So we must pass a real relaunch command. It doubles as what a header click-      // or a taskbar PIN launches, so it has to genuinely refocus THIS session: pup_focus_window-      // carries a foregroundReason (the raise gate refuses without one, and a header click IS a-      // real user request to see the window). Quotes are backslash-escaped for Windows argv —-      // same trap that made jump-list clicks no-op in 1.9.9.-      relaunchCommand: pupRelaunchCommand(sessionId),-    }, { timeoutMs: 6000 });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));-    // v2.0.164 THREAD-TILES DIAGNOSTIC (kept, low-noise): the cross-process AUMID read from the-    // container is blind, and the bridge does not surface its console here, so record the ACTUAL-    // stamp decision + AD's reply to a rolling temp file (last ~200 lines) we can cat via ab. This-    // is how we confirm the thread appId + tile icon actually reach desktop_set_window_identity.-    try {-      const _dbgP = path.join(os.tmpdir(), 'pup-stamp-debug.log');-      const dbg = { t: Date.now(), sid: sessionId, owner: (sess && (sess.owner || sess._ownerThread)) || null,-        tt: pupSettings().threadTiles, isThreadAppId: appId.startsWith(`${PUP_APP_ID}.thread.`), appId,-        stampIconLeaf: String(_stampIcon).split(/[\\/]/).pop(), success: r && r.success, reply: (raw || '').slice(0, 160) };-      fs.appendFileSync(_dbgP, JSON.stringify(dbg) + '\n');-    } catch (e) {}-    if ((r && r.success === false) || _isUnknownVerb(r, raw)) {-      if (_isUnknownVerb(r, raw)) _identitySupported = false;-      else console.log(`[identity] "${sessionId}" stamp FAILED (appId=${appId}): ${((r && r.error) || raw || 'no response').slice(0, 140)}`);-      return false;-    }-    const o = raw ? JSON.parse(raw) : {};-    const data = o.data || o;-    const ok = !!(data.ok || (data.applied && data.applied.length));-    if (!ok) console.log(`[identity] "${sessionId}" stamp NOT applied (appId=${appId}): ${(raw || '').slice(0, 140)}`);-    if (ok) {-      console.log(`[identity] "${sessionId}" stamped appId=${appId}: ${(data.applied || []).join(',') || 'ok'}`);-      try { markIconApplied(sessionId); } catch (e) {}   // v2.0.289: end the icon-apply taskbar progress bar-      if (!sess) _earlyStamped.set(sessionId, appId);   // v2.0.25: pre-registration stamp — see above-      if (sess && prevAppId !== appId) {-        _stampLandedThisBoot = true;-        sess._curAppId = appId;-        // v2.0.168: timestamp a landed THREAD stamp — the overlay paint waits a settle beat after-        // this so the shell finishes moving the window onto the thread button before any-        // SetOverlayIcon call can latch the button association (the 2.0.167 regression).-        if (appId.startsWith(`${PUP_APP_ID}.thread.`)) {-          sess._threadStampAt = Date.now();-        }-        // v2.0.180 (the "prove it" run): the button nudge now fires on EVERY landed identity-        // CHANGE, not just thread stamps — this Win11 build never rebuilds a button on an in-place-        // AUMID change, so tiles<->aumid mode switches (and any future identity move) need the same-        // hide/re-show the thread transition needed. Once per (session, appId), foreground-skipped.-        if (true) {-          // v2.0.169 THE BUTTON NUDGE (measured live, the missing last mile): on this Win11 build the-          // shell does NOT recreate a taskbar button when a live window's AUMID changes — the button-          // stays latched to the birth identity (default CfT) no matter how cleanly the stamp applied-          // (AD replied applied:[appId,relaunchIcon,displayName,icon] and the button never moved).-          // A hide + re-show (SW_HIDE -> SW_SHOWNOACTIVATE, or SW_SHOWMINNOACTIVE when the user had-          // it minimized; never activates, never changes what the user chose to see) forces the shell to-          // destroy and recreate the button, which reads the CURRENT thread AUMID -> the name tile-          // appears (verified: the stuck "Google Chrome for Testing" button became "kicad" on nudge).-          // Once per (session, appId); skipped when the window is foreground (a visible 600ms blink-          // of the user's active window would be disruptive — it heals on the next natural recreate).-          if (sess._threadNudged !== appId && _stampHwnd) {-            sess._threadNudged = appId;-            setTimeout(() => { nudgeWhenIconReady(_stampHwnd, sessionId, appId).catch(() => {}); }, 700);-          }-        }-        // v2.0.7: freeze the identity at this first real stamp — no re-stamp on later category changes.-        if (!_dashInfra && !grouped) sess._frozenAppId = appId;-        // v1.9.245: the stamp DESTROYED and RECREATED the taskbar button, so whatever overlay was-        // painted is gone from glass. Mark bare so the 10s enforcer repaints the new button; also-        // drop the cached hwnd — the new button may live on a new handle.-        // v2.0.183: keep sess._hwnd. The v1.9.245 comment feared "the new button may live on a new-        // handle", but a taskbar BUTTON is not the window HWND — an AUMID re-stamp never changes the-        // OS window handle, and nulling it here made every sweep re-resolve every window by bounds-        // (an enum_windows storm, John's 2nd spam report). hwndBelongsToPup still validates on use.-        sess._appOverlay = false; sess._overlayEnforceAt = 0;-        // The jump list attaches to the AUMID — a new appId starts with NO jump list, so force a-        // re-attach on the next updateWikiJumplist pass.-        if (pupSettings().jumpLists === 'on') updateWikiJumplist(sess, sessionId).catch(() => {});   // deduped by _wikiJumplistKey-        // v1.9.112 (John: "still shows for 200ms then disappears; second right-click works") —-        // RE-COMMIT THE JUMP LIST ONCE THE BUTTON IS REAL. Measured elimination got us here: nothing-        // fires while idle (0 log lines in 40s), and windows with exactly ONE AUMID registration blip-        // too, so it is neither a race nor category churn. What every fresh window DOES share is that-        // the stamp above RECREATES the taskbar button (Win11 bakes a button's identity at creation),-        // and the destination list was committed BEFORE that — deliberately, so the header art bakes.-        // So the button is born pointing at a list committed against a button that did not exist yet,-        // and the first right-click is the shell reconciling that, which is why the SECOND click works.-        // Re-committing ~1.6s after the stamp lands the list on the button that actually exists.-        // ⚠ OUTCOME (verified with John, 1.9.112): this did NOT fix the blip. Three fresh windows each-        // got exactly 1 AUMID registration + 1 post-stamp re-commit and ALL still showed the flyout for-        // ~200-300ms before it vanished. Keeping the re-commit because attaching the list to the live-        // button is correct hygiene, but do NOT read this as the cure. The blip is UNRESOLVED and is-        // tracked on AD (it owns desktop_set_window_identity and the shell interaction) — see the-        // pup-bridge-debug skill for the four hypotheses already eliminated, so nobody re-derives them.-        // v1.9.317 (John, ~10th time reporting the blip: "the FIRST right-click shows for a blip then-        // goes away, the 2nd ~1s later works"): this forced re-commit (clearing the dedup key so it-        // RE-attaches) fired a REDUNDANT CommitList ~1.6s after every stamp. Jump lists live on the-        // AUMID and were ALREADY committed during the stamp above, so the re-attach was never needed —-        // it only gave the shell one more chance to rebuild-and-dismiss a flyout the user just opened.-        // DO NOT clear the dedup key: let updateWikiJumplist no-op when the tasks are unchanged (same-        // appId+tasks), and only re-commit if the appId genuinely changed (a new button). This removes-        // pup's contribution to the blip. NOTE (honest): prior testing showed even ONE commit blips for-        // ~200-300ms, so a residual race remains that is the Windows shell / AD's (it rebuilds the menu-        // on ANY CommitList that lands while it is shown) — see dev-skills/pup-jumplist. Until that is-        // solved AD-side, a mimic'd right-click must click TWICE (first blips, second holds).-        setTimeout(() => {-          try {-            if (!sessions.has(sessionId)) return;-            const s2 = sessions.get(sessionId);-            if (!s2 || s2._curAppId !== appId) return;   // superseded by a later stamp — leave it alone-            updateWikiJumplist(s2, sessionId).catch(() => {});   // dedup-respecting: no-op unless tasks changed-          } catch (e) {}-        }, 1600);-        // Unregister the superseded appId (cleanup, AD step 3). Never the pinnable base id.-        if (prevAppId && prevAppId !== PUP_APP_ID) { unregisterPupAumid(prevAppId).catch(() => {}); }-      }-    }-    return ok;-  } catch (e) { return false; }-}-// v1.9.27: registrations are keyed by the FULL category-carrying appId. The category is baked-// into the id itself (Adom.Pup.<sid>.<cat>), so a Set suffices — a state change is a NEW id.-const _registeredPupAumids = new Set();-async function registerPupAumid(appId, sessionId) {-  if (_identitySupported === false || appId === PUP_APP_ID || _registeredPupAumids.has(appId)) return;-  // v2.0.152 THREAD TILES: a thread AUMID (Adom.Pup.thread.<slug>.<gen>) wears the thread's NAME tile-  // instead of the standard/category icon. Render it (once per thread, cached) from a live browser,-  // and fall back to the normal pup icon if the render fails so the button is never left unbranded.-  let iconPath = pupIconPath(sessionId);-  let displayName = pupDisplayName(sessionId);-  const _threadAumid = appId.startsWith(`${PUP_APP_ID}.thread.`);-  if (_threadAumid) {-    try {-      const s = sessions.get(sessionId);-      const threadName = tileOwnerName(s, sessionId);-      // Render on an existing page (no new window) — this session's tab, else any live tab.-      let _rp = s && (s.tabs || []).map(t => t && t.page).find(Boolean);-      if (!_rp) { outer2: for (const [, s2] of sessions) { for (const t2 of (s2.tabs || [])) { if (t2 && t2.page) { _rp = t2.page; break outer2; } } } }-      if (threadName && _rp) {-        const tile = await renderThreadTileIco(_rp, threadName);-        // v2.0.176: never brand a thread AUMID with the GENERIC pup icon (see stampPupIdentity) —-        // that placeholder is exactly the first of the two icon applies John flagged. No tile yet =-        // register nothing; the stamp defers too, and both land once when the art is ready.-        if (!tile) {-          // v2.0.250 (#51 second half): a defer used to be terminal — the window stayed unbranded-          // until unrelated churn or a manual taskbarMode flip forced a fresh sweep (John measured-          // "many minutes"). Re-attempt on a bounded backoff so the tile lands on its own.-          const _k = `tile:${threadName}`;-          const _n = (_tileDeferRetries.get(_k) || 0) + 1;-          if (_n <= 5) {-            _tileDeferRetries.set(_k, _n);-            const _wait = Math.min(60000, 4000 * _n);-            console.log(`[thread-tile] register DEFERRED for "${threadName}" — tile not ready; retry ${_n}/5 in ${Math.round(_wait / 1000)}s`);-            setTimeout(() => { brandSweep(`tile-defer retry ${_n} for "${threadName}"`).catch(() => {}); }, _wait);-          } else {-            console.log(`[thread-tile] register DEFERRED for "${threadName}" — tile STILL not ready after 5 retries; leaving it to the next sweep`);-          }-          return;-        }-        iconPath = tile;-        displayName = String(threadName);   // the taskbar hover/label reads the thread name too-      } else if (threadName) { return; }   // thread window, no page to draw with yet — wait, don't placeholder-    } catch (e) { console.log(`[thread-tile] register error: ${e.message}`); }-  }-  // v1.9.44: write IconResource FIRST, before the button can possibly exist. AD's registration does-  // not write it (see writeAumidIcon), and an AUMID with no icon is what paints white.-  // v2.0.161 (Thread-Tiles taskbar bug): writeAumidIcon is a ~10ms HKCU reg.exe write — NO button-  // destruction, NO shell/AD roundtrip. It is the NON-RACY "belt" that the per-window Relaunch prop-  // ("braces") only RACES for. So it MUST run even under minimal-touch: with minimal-touch on,-  // registerPupAumid used to bail at the very top, leaving a thread/category AUMID branded ONLY by the-  // racy Relaunch prop — which loses the race against Win11 baking the button art and paints the-  // DEFAULT pup icon (measured: 0 Adom.Pup.thread.* registry keys existed). Write the icon here; only-  // the INVASIVE Start-Menu registration below stays gated under minimal-touch.-  writeAumidIcon(appId, iconPath);-  if (minimalTouch()) return;   // registry branded (the belt); skip the AD register_app_identity roundtrip-  try {-    const r = await chrome.abCommand('desktop_register_app_identity', {-      appId, displayName, iconPath, shortcut: false,-    }, { timeoutMs: 6000 });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));-    if (_isUnknownVerb(r, raw)) return; // older AD without #207 — the per-window stamp still brands the taskbar button-    _registeredPupAumids.add(appId);-    console.log(`[identity] registered AUMID ${appId} (registry-only, icon=${pupCategory(sessionId) || 'plain'})`);-  } catch (e) {}-}-// v1.9.44 — WHY THE TASKBAR ICON GOES WHITE, and the fix. READ BEFORE TOUCHING ICON CODE.-//-// MEASURED on John's box: 129 of 129 HKCU AppUserModelId\Adom.Pup.* keys had IconResource = NULL.-// AD's desktop_register_app_identity ACCEPTS an iconPath but only materialises it when it also-// builds a Start Menu shortcut; with shortcut:false (our per-session path) the AUMID key is created-// with a DisplayName and NO IconResource at all. So the registry branded nothing, ever.-//-// Everything still LOOKED right because the per-window Relaunch* properties that-// desktop_set_window_identity sets do brand the button. But those are a RACE against Win11 baking-// the tile at button-creation time. Win the race -> correct icon. Lose it -> Windows has nothing to-// resolve for that AUMID and paints the GENERIC WHITE DOCUMENT. That is why the icon was white on-// ONE window (a long-lived session whose button predated its stamp) and correct on its siblings —-// it was never a category or icon-file problem, it was an unbranded AUMID plus a lost race.-//-// FIX: write IconResource into HKCU ourselves. HKCU needs no UAC, this bridge already runs ON the-// desktop (no AD roundtrip, no shell-approval prompt), and a registry value is not a race — it is-// there before and after the button exists. Keep the Relaunch* props too; they are what repaint a-// LIVE category flip. Belt and braces.-//-// If icons go white again, check IconResource FIRST (see pup-bridge-debug -> "white taskbar icon").-function writeAumidIcon(appId, iconPath) {-  if (process.platform !== 'win32' || !appId || !iconPath) return false;-  try {-    if (!fs.existsSync(iconPath)) { console.log(`[identity] icon MISSING on disk: ${iconPath}`); return false; }-    const key = `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`;-    // ",0" selects the first frame. reg.exe is used directly (not PowerShell) — no window, no profile-    // load, ~10ms, and it creates the key if the AD registration has not landed yet.-    execFileSync('reg', ['add', key, '/v', 'IconResource', '/t', 'REG_SZ', '/d', `${iconPath},0`, '/f'],-      { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });-    return true;-  } catch (e) { console.log(`[identity] IconResource write failed for ${appId}: ${e.message}`); return false; }-}--// ── THREAD TILES (v2.0.152, John) ───────────────────────────────────────────────────────────────-// Group every pup window/tab of one ai-thread under ONE taskbar button whose icon is that thread's-// NAME rendered on the teal tile. The name is rendered in the real Adom font (Familjen Grotesk) by-// CHROME — the ONE renderer guaranteed to have the woff2 — into a 256px PNG, which sharp downscales-// to a multi-size .ico. Rendered once per thread name and cached on disk.-const THREAD_TILE_GEN = 't7';   // t7: virgin AUMIDs after the registry-sweep era orphaned ab's cache (same story as i8). t6: font-size FLOOR + truncate-not-shrink (2.0.172, John: small text unreadable); bottom-left anchored. Gen bump = virgin AUMIDs + fresh ab icon-handle cache keys; the janitor reaps prior gens-const THREAD_TILE_DIR = path.join(os.tmpdir(), 'pup-thread-tiles', THREAD_TILE_GEN);-let _fgFontB64 = null;-function fgFontB64() {-  if (_fgFontB64 == null) { try { _fgFontB64 = fs.readFileSync(path.join(__dirname, 'extension', 'fg-700.woff2')).toString('base64'); } catch (e) { _fgFontB64 = ''; } }-  return _fgFontB64;-}-// AUMID- and filename-safe slug of an ai-thread name. Keep it stable + collision-resistant: a short-// hash suffix so "ah build" and "ah-build" never share a tile/button.-function threadSlug(name) {-  const s = String(name || 'thread').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'thread';-  const h = require('crypto').createHash('sha1').update(String(name || '')).digest('hex').slice(0, 6);-  return `${s}-${h}`;-}-// v2.0.271 — THE AUMID IS THE ICON CACHE KEY. Proven on John's "Bills" window (2026-08-21):-// Windows caches a taskbar button's icon PER AppUserModelID. Once the shell has drawn an AUMID with-// one icon, repointing that AUMID at new art is IGNORED. Measured, in order, all on the same window:-//   * the AUMID was registered and its IconResource pointed at the correct .ico-//   * the .ico itself rendered the "Bills" name tile correctly at 16/32/48/256-//   * a threadTiles off/on full re-register            -> art unchanged-//   * bumping the registration epoch (fresh ab cacheKey) -> art unchanged-//   * WM_SETICON with the tile on the window itself     -> art unchanged-//   * stamping a NOVEL AUMID string                     -> art CORRECT, instantly-// So the only thing that can move a wrong icon is a VIRGIN AUMID. THREAD_TILE_GEN already encodes-// that ("Gen bump = virgin AUMIDs"), but it is a hand-edited constant, so it cannot react when a-// thread's tile is re-rendered — which is exactly when the art changes and the shell must be told.-// Fold the tile's CONTENT into the AUMID: same tile bytes -> same AUMID (stable across restarts,-// no button churn); re-rendered tile -> virgin AUMID -> the shell is structurally forced to re-read-// the art. This is what makes wrong art unreachable instead of something an audit must notice.-function threadTileToken(name) {-  try {-    const st = fs.statSync(path.join(THREAD_TILE_DIR, `${threadSlug(name)}.ico`));-    const sig = `${Math.round(st.mtimeMs)}-${st.size}`;-    return 'i' + require('crypto').createHash('sha1').update(sig).digest('hex').slice(0, 6);-  } catch (e) { return ''; }   // no tile yet: plain gen AUMID; the stamp bails until the art exists-}-function threadAumid(name) {-  return `${PUP_APP_ID}.thread.${threadSlug(name)}.${THREAD_TILE_GEN}${threadTileToken(name)}`;-}-// Wrap a 256x256 PNG buffer into a multi-size .ico (256/48/32/16, each a PNG-compressed entry — the-// modern ICO form Windows Vista+ reads). sharp does the downscales (available on the desktop).-async function pngToIco(png256) {-  let sharp; try { sharp = require('sharp'); } catch (e) { return null; }-  // v2.0.160: the small sizes MUST be BMP/DIB (32bpp), not PNG — Windows' TASKBAR does not render-  // PNG-compressed ICO entries for small icons, so a PNG-only .ico fell back to the AUMID's default-  // registry icon (why the button showed the plain pup mark, not the name). BMP for 16/32/48; PNG is-  // fine (and smaller) only for the 256 entry.-  const sizes = [16, 32, 48, 256];-  const entries = [];-  for (const s of sizes) {-    if (s === 256) {-      entries.push({ s, data: await sharp(png256).resize(256, 256, { fit: 'fill' }).png().toBuffer() });-      continue;-    }-    const { data } = await sharp(png256).resize(s, s, { fit: 'fill', kernel: 'lanczos3' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });-    // BITMAPINFOHEADER: height is DOUBLED (XOR color bitmap + AND mask), 32bpp, uncompressed.-    const hdr = Buffer.alloc(40);-    hdr.writeUInt32LE(40, 0); hdr.writeInt32LE(s, 4); hdr.writeInt32LE(s * 2, 8);-    hdr.writeUInt16LE(1, 12); hdr.writeUInt16LE(32, 14);-    const xor = Buffer.alloc(s * s * 4);         // bottom-up BGRA-    for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) {-      const si = ((s - 1 - y) * s + x) * 4, di = (y * s + x) * 4;-      xor[di] = data[si + 2]; xor[di + 1] = data[si + 1]; xor[di + 2] = data[si]; xor[di + 3] = data[si + 3];-    }-    const andRow = Math.ceil(s / 32) * 4;        // 1bpp mask, 4-byte aligned rows; all-zero = opaque-    const andMask = Buffer.alloc(andRow * s, 0);-    entries.push({ s, data: Buffer.concat([hdr, xor, andMask]) });-  }-  const N = entries.length;-  const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(N, 4);-  const dir = Buffer.alloc(16 * N);-  let offset = 6 + 16 * N;-  entries.forEach((e, i) => {-    const d = dir.subarray(i * 16, i * 16 + 16);-    d.writeUInt8(e.s >= 256 ? 0 : e.s, 0); d.writeUInt8(e.s >= 256 ? 0 : e.s, 1);-    d.writeUInt8(0, 2); d.writeUInt8(0, 3); d.writeUInt16LE(1, 4); d.writeUInt16LE(32, 6);-    d.writeUInt32LE(e.data.length, 8); d.writeUInt32LE(offset, 12); offset += e.data.length;-  });-  return Buffer.concat([header, dir, ...entries.map(e => e.data)]);-}-// v2.0.230: decoded-icon cache for the dashboard's per-render icon endpoints (path+mtime keyed).-const _iconBufCache = new Map();   // "path|mtimeMs" -> Buffer-function cachedIconPng(file, decode) {-  try {-    const mt = fs.statSync(file).mtimeMs;-    const key = `${file}|${mt}`;-    const hit = _iconBufCache.get(key);-    if (hit) return hit;-    const out = decode(fs.readFileSync(file));-    if (out) {-      if (_iconBufCache.size > 60) _iconBufCache.clear();-      _iconBufCache.set(key, out);-    }-    return out;-  } catch (e) { return null; }-}-const _tileDeferRetries = new Map();   // v2.0.250 (#51): bounded retries per thread whose tile render deferred-const _parkNoHwndLogged = new Set();   // v2.0.237: log the no-handle park once per session-const _threadTileInflight = new Map();   // name -> Promise (dedupe concurrent renders)-// Render (or return cached) the .ico path for a thread name. v2.0.158: draw the tile on an OFFSCREEN-// CANVAS inside an EXISTING session page (`page`) — the FG font is injected via the FontFace API — so-// NO new window/tab is created and pup's window machinery has nothing to enumerate/adopt (the old-// browser.newPage() render spawned an OS window that triggered a birth/reclaim enum_windows storm).-// The canvas is never appended to the DOM and the page's own content/title are untouched (invisible,-// non-destructive). Returns a data-URL PNG that the bridge converts to a multi-size .ico.-async function renderThreadTileIco(page, name) {-  const icoPath = path.join(THREAD_TILE_DIR, `${threadSlug(name)}.ico`);-  try {-    if (fs.existsSync(icoPath) && fs.statSync(icoPath).size > 100) {-      try { _tileDeferRetries.delete(`tile:${name}`); } catch (e) {}   // v2.0.250: art exists, stop retrying-      return icoPath;-    }-  } catch (e) {}-  // v2.0.228: a FIRST render means every button of this thread was built before its art existed-  // (Windows bakes a button's icon at creation) — audit shortly after so those get rebuilt. This is-  // the exact defect John's audit found on "ah build thread".-  scheduleIconAudit(`tile rendered for "${name}"`, 12000);-  if (_threadTileInflight.has(name)) return _threadTileInflight.get(name);-  const job = (async () => {-    if (!page) { console.log(`[thread-tile] "${name}" NO page passed`); return null; }-    try {-      fs.mkdirSync(THREAD_TILE_DIR, { recursive: true });-      const dataUrl = await withTimeout(page.evaluate(async (nm, fontB64) => {-        try {-          if (fontB64) { try { const f = new FontFace('FGtile', 'url(data:font/woff2;base64,' + fontB64 + ')'); await f.load(); document.fonts.add(f); } catch (e) {} }-          const box = 256, cv = document.createElement('canvas'); cv.width = box; cv.height = box;-          const ctx = cv.getContext('2d');-          ctx.fillStyle = '#00b8b1'; ctx.fillRect(0, 0, box, box);-          // v2.0.172 (John: "some text is too small to read — go bigger and only show as many chars-          // as you can get in"). Shrink-to-fit alone made long names microscopic ("rock climbing-          // berlin" wrapped to 3 unreadable lines). Now there is a hard FLOOR on the font size: a-          // name that cannot fit at the floor is TRUNCATED instead of shrunk, so every tile stays-          // legible at 32px and shows the leading characters that identify the thread. The floor is-          // calibrated to "reclaimed" (John's readable reference: 2 lines, ~4-5 chars each).-          // Line 1 of a 2-line tile also stays narrower than line 2 to duck the top-right badge.-          const FLOOR = box * 0.36;-          const wFor = (i, total) => (total === 2 && i === 0) ? box * 0.80 : box * 0.92;-          const setF = (s) => { ctx.font = '700 ' + s + "px FGtile,'Segoe UI',sans-serif"; };-          const wid = (t, s) => { setF(s); return ctx.measureText(t).width; };-          const fitSize = (lines) => {-            let s = box * 0.9;-            for (let i = 0; i < 90; i++) {-              let ok = true;-              for (let j = 0; j < lines.length; j++) { if (wid(lines[j], s) > wFor(j, lines.length)) { ok = false; break; } }-              if (ok && lines.length * s * 0.95 <= box * 0.88) break;-              s *= 0.95;-            }-            return s;-          };-          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 m = Math.ceil(nm.length / 2); cands.push([nm.slice(0, m), nm.slice(m)]); }-          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 = (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);-          }-          // v2.0.170 (John): anchor the name BOTTOM-LEFT — the overlay badge owns the top-right-          // corner, so bottom-left keeps the most text visible under it.-          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((ln, i) => ctx.fillText(ln, mx, baseY - (best.length - 1 - i) * lh));-          return cv.toDataURL('image/png');-        } catch (e) { return null; }-      }, name, fgFontB64()), 8000, 'tile.evaluate');-      if (!dataUrl || dataUrl.indexOf('base64,') < 0) { console.log(`[thread-tile] "${name}" canvas render returned nothing`); return null; }-      const png = Buffer.from(dataUrl.split('base64,')[1], 'base64');-      const ico = await pngToIco(png);-      if (!ico) { console.log(`[thread-tile] "${name}" pngToIco returned null (sharp missing?)`); return null; }-      fs.writeFileSync(icoPath, ico);-      console.log(`[thread-tile] rendered tile for "${name}" -> ${icoPath} (${ico.length}B, no window)`);-      return icoPath;-    } catch (e) { console.log(`[thread-tile] render failed for "${name}": ${e.message}`); return null; }-    finally { _threadTileInflight.delete(name); }-  })();-  _threadTileInflight.set(name, job);-  return job;-}-// v1.9.44: the AUMID keys LEAKED. A new key is minted per session per category, but only the-// immediately-superseded one is unregistered, and only in-process — so every bridge restart orphaned-// its whole set (129 keys had accumulated). Sweep on startup: drop any Adom.Pup.<sid>.* key whose-// <sid> is not a live session. Never touch the pinnable base id.-function pruneStaleAumids() {-  if (process.platform !== 'win32') return;-  // v2.0.186 (#51): after a respawn, RELOADED sessions carry _curAppId=null until their first stamp-  // lands, so every live thread key looked orphaned and the boot prune wiped them (measured during-  // the 2.0.182 ladder proof). Until at least one stamp has landed this boot, keys on disk are-  // presumed live; pruning waits. An empty session map (nothing reloaded) still prunes as before.-  if (sessions.size && !_stampLandedThisBoot) { console.log('[identity] AUMID prune skipped — no stamp has landed since boot (reloaded sessions not yet re-branded)'); return; }-  // v2.0.161 — rewritten to key off the LIVE appIds directly, across EVERY AUMID shape-  // (per-session Adom.Pup.<sid>.<cat>.<gen>, category-group Adom.Pup.<cat>.<gen>, dashboard-  // Adom.Pup.Dashboard.<gen>, and THREAD TILES Adom.Pup.thread.<slug>.<gen>). The old code derived-  // a <sid> by stripping known suffixes and kept a key iff that sid was a live session — which-  // BROKE thread tiles: a thread key strips to "thread.<slug>.<gen>" (never a session id), so a-  // perfectly live thread's key looked orphaned and got deleted on every sweep, un-branding the-  // taskbar button. Instead: gather every appId any live session is (or is about to be) stamped-  // with — _curAppId (live), _frozenAppId (frozen), _desiredAppId (mid-open, set before the stamp-  // lands), the pre-registration _earlyStamped map, and _registeredPupAumids — and delete only the-  // Adom.Pup.* keys NOT in that set. The pinnable base launcher (Adom.Pup) always stays.-  const liveAppIds = new Set([PUP_APP_ID]);-  for (const [, s] of sessions) {-    for (const id of [s && s._curAppId, s && s._frozenAppId, s && s._desiredAppId]) { if (id) liveAppIds.add(id); }-  }-  for (const id of _earlyStamped.values()) { if (id) liveAppIds.add(id); }-  for (const id of _registeredPupAumids) { if (id) liveAppIds.add(id); }-  // v2.0.250 (#51): seed from what is ON DISK too. A window that existed before this boot is still-  // on the user's taskbar wearing its identity, so its key is live even though no in-memory session-  // has stamped yet. Two sources: the appId persisted at save time, and the thread AUMID the-  // session's owner implies (same formula the stamp uses), which covers files written before this-  // field existed.-  try {-    for (const sf of listSessionFiles()) {-      if (sf && sf.appId) liveAppIds.add(sf.appId);-      const _own = sf && sf.owner === 'reclaimed' ? null : (sf && sf.owner);   // #79: the sentinel is an absence, never a name-      if (_own) liveAppIds.add(threadAumid(_own));-    }-  } catch (e) {}-  try {-    const out = execFileSync('reg', ['query', 'HKCU\\Software\\Classes\\AppUserModelId'],-      { encoding: 'utf8', timeout: 8000, windowsHide: true });-    let dropped = 0;-    for (const line of out.split(/\r?\n/)) {-      const m = line.match(/\\(Adom\.Pup(?:\..+)?)\s*$/);-      if (!m) continue;-      const appId = m[1];-      if (appId === PUP_APP_ID || liveAppIds.has(appId)) continue;   // launcher + in-use keys stay-      try {-        execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/f'],-          { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });-        dropped++;-      } catch (e) {}-    }-    if (dropped) console.log(`[identity] pruned ${dropped} stale Adom.Pup.* AUMID key(s) from HKCU (kept ${liveAppIds.size - 1} live)`);-  } catch (e) {}-  // The .ico tiles those keys pointed at also accumulate in %TEMP% across generation bumps-  // (t1→t2→t3, i6→i7). Keep ONLY the current-generation dir under each cache root; every sibling is-  // a prior gen no live key references. Cheap readdir, so it rides the same janitor cadence.-  pruneStaleIconGenDirs();-}-// v2.0.169 — force the shell to recreate a window's taskbar button so it re-reads the CURRENT-// AUMID. Win11 (this build, measured) does NOT move a live window to a new button on an AUMID-// change; hide + re-show is the only reliable trigger. SW_SHOWNOACTIVATE never steals focus, and-// the same script skips the nudge when the window is FOREGROUND (blinking the user's active window-// would be disruptive). Fire-and-forget; ~1-2s of powershell incl. Add-Type, once per window per-// thread appId.-// v2.0.175: the hide window is 80ms, NOT 600ms. Measured (churn-ralph round-3 RED + a 200ms-// visibility poll): a 600ms hide made a freshly opened window genuinely invisible for ~600ms, which-// both failed the #46 live-window invariant and was a real flicker on the user's screen. 80ms still-// makes the shell destroy + recreate the button (verified live: a restamped window's button renamed-// to the new thread), so we keep the only effect that matters and drop 87% of the blind spot.-// v2.0.180 (plain mode must apply LIVE, for the "prove it" ladder walk): stamped identity used to-// persist on existing windows until they closed ("already-stamped windows keep their icon"). On a-// switch to plain, strip everything now: drop every session's identity bookkeeping, unregister the-// Adom.Pup.* registry keys, and nudge each window — an UNREGISTERED appId falls back to the owning-// exe's icon (AD's own doc), i.e. the stock Chrome-for-Testing look John calls plain jane.-async function stripAllPupIdentities(reason) {-  _identityGen++;   // v2.0.216: invalidate every in-flight stamp before wiping-  if (process.platform !== 'win32') return;-  const ids = new Set(); const targets = [];-  for (const [sid, s] of [...sessions]) {-    if (s._curAppId && s._curAppId !== PUP_APP_ID) ids.add(s._curAppId);-    s._curAppId = null; s._frozenAppId = null; s._desiredAppId = null; s._threadNudged = null;-    // v2.0.198: the shell-side lists are cleared below — reset the dedup keys too, or the later-    // re-commit believes the list is still attached and skips (John's taskless right-click menu).-    s._wikiJumplistKey = undefined; s._jlFrozen = false;-    targets.push([sid, s]);-  }-  for (const id of ids) {-    try { await chrome.abCommand('desktop_set_window_jumplist', { appId: id, tasks: [] }, { timeoutMs: 5000 }); } catch (e) {}-    try { await chrome.abCommand('desktop_unregister_app_identity', { appId: id }, { timeoutMs: 5000 }); } catch (e) {}-    try { execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${id}`, '/f'], { timeout: 5000, windowsHide: true, stdio: 'ignore' }); } catch (e) {}-  }-  try { _registeredPupAumids.clear(); } catch (e) {}-  // v2.0.223: the 2.0.217 registry SWEEP that lived here was a mistake and is gone. Registry keys-  // with no window property pointing at them are inert — they never split taskbar groups (Chrome's-  // native-vs-empty AUMID drift did, fixed by the convergence below) — and deleting them broke-  // re-entry into tiles/aumid: ab caches icon registration BY APPID, so re-stamps skipped the-  // registry re-write and buttons came back with the right NAME and no ART (ralph r1/r5 fails).-  // v2.0.182 (measured during the ladder proof): deleting the REGISTRY keys is not enough — the-  // per-WINDOW AppUserModel property trio (+ the explicit ID) survives on the window and keeps-  // branding the rebuilt button. Truly plain = wipe those properties with VT_EMPTY via the shell-  // property store, then nudge. One PowerShell run covers every window (verified live: a branded-  // pup window's button reverted to stock "Google Chrome for Testing" the moment this ran).-  const hwnds = [];-  for (const [sid, s] of targets) {-    try { if (!s._hwnd) await resolveSessionHwndByBounds(s, sid); } catch (e) {}-    if (s._hwnd) hwnds.push(Number(s._hwnd));-  }-  {-    // v2.0.221 (John's two-CfT-buttons find): wiping AUMIDs to VT_EMPTY was WRONG. Chrome sets its-    // own per-profile AUMID on every window (ChromeForTestin<hash>.<profile>.Default) and lazily-    // RE-ASSERTS it — so wiped windows grouped under the exe-fallback identity while Chrome-retagged-    // ones grouped under the native id: TWO taskbar buttons, both labeled "Google Chrome for-    // Testing", drifting further apart over time (an Explorer restart rebuilds the same split).-    // Truly plain = CONVERGE every CfT window onto Chrome's OWN native AUMID (idempotent against-    // Chrome's re-assertions), clear pup's Relaunch trio, null pup's WM_SETICON icons, nudge.-    // Covers ALL windows of pup's browser processes — orphans included — not just live sessions.-    // NOTE the correct 24-byte PROPVARIANT: the old 16-byte explicit-layout struct made GetValue-    // silently return zeros, which is why the wipe path never saw Chrome's ids in the first place.-    _nudgeUntil = Date.now() + 12000;-    const _pids = new Set();-    for (const [, s9] of [...sessions]) { try { const pid = Number(macSessionPid(s9) || 0); if (pid) _pids.add(pid); } catch (e) {} }-    try { for (const [, be] of browsers) { const b = be && be.browser; const pid = Number((b && (b._detachedPid || (b.process() && b.process().pid))) || 0); if (pid) _pids.add(pid); } } catch (e) {}-    const _fallbackNative = String(pupSettings()._cftNativeAumid || '');-    const psBody = `$code=@'-using System; using System.Text; using System.Runtime.InteropServices;-public static class Conv {-  [StructLayout(LayoutKind.Sequential)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }-  [StructLayout(LayoutKind.Sequential)] public struct PROPVARIANT { public ushort vt; public ushort r1; public ushort r2; public ushort r3; public IntPtr p; public IntPtr p2; }-  [ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]-  public interface IPropertyStore { int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY pk); int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv); int SetValue(ref PROPERTYKEY key, ref PROPVARIANT pv); int Commit(); }-  [DllImport("shell32.dll")] public static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out IPropertyStore pps);-  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp);-  public delegate bool EnumProc(IntPtr h, IntPtr lp);-  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);-  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);-  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);-  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();-  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);-  [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);-  static Guid AUM = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");-  static Guid IID = new Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99");-  public static string Read(IntPtr hwnd) {-    IPropertyStore ps; if (SHGetPropertyStoreForWindow(hwnd, ref IID, out ps) != 0) return null;-    PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = AUM; k.pid = 5;-    PROPVARIANT pv; if (ps.GetValue(ref k, out pv) != 0) return null;-    return (pv.vt == 31 && pv.p != IntPtr.Zero) ? Marshal.PtrToStringUni(pv.p) : null;-  }-  public static void Apply(IntPtr hwnd, string id) {-    IPropertyStore ps; if (SHGetPropertyStoreForWindow(hwnd, ref IID, out ps) != 0) return;-    foreach (uint pid in new uint[]{2,3,4}) {-      PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = AUM; k.pid = pid;-      PROPVARIANT em = new PROPVARIANT(); em.vt = 0; ps.SetValue(ref k, ref em);-    }-    PROPERTYKEY k5 = new PROPERTYKEY(); k5.fmtid = AUM; k5.pid = 5;-    if (id != null && id.Length > 0) {-      PROPVARIANT pv = new PROPVARIANT(); pv.vt = 31; pv.p = Marshal.StringToCoTaskMemUni(id);-      ps.SetValue(ref k5, ref pv); ps.Commit(); Marshal.FreeCoTaskMem(pv.p);-    } else {-      PROPVARIANT em2 = new PROPVARIANT(); em2.vt = 0; ps.SetValue(ref k5, ref em2); ps.Commit();-    }-    SendMessage(hwnd, 0x0080, (IntPtr)0, IntPtr.Zero);-    SendMessage(hwnd, 0x0080, (IntPtr)1, IntPtr.Zero);-  }-}-'@-Add-Type -TypeDefinition $code-$targets = @(PIDS_CSV)-$script:wins = @()-[Conv]::EnumWindows({ param($h,$lp)-  if([Conv]::IsWindowVisible($h)){ $p2=0; [void][Conv]::GetWindowThreadProcessId($h,[ref]$p2); if($targets -contains [int]$p2){ $script:wins += $h } }; $true }, [IntPtr]::Zero) | Out-Null-$native = $null-foreach($h in $script:wins){ $a=[Conv]::Read($h); if($a -and $a.StartsWith("ChromeForTestin")){ $native=$a; break } }-if(-not $native){ $native = "FALLBACK_NATIVE" }-if($native -eq ""){ $native = $null }-$fg=[Conv]::GetForegroundWindow(); $n=0-foreach($h in $script:wins){-  $cur=[Conv]::Read($h)-  if($cur -ne $native){ [Conv]::Apply($h,$native); $n++ }-}-if($n -gt 0){-  # BATCH nudge (v2.0.225/226): Win11 bakes a GROUP button's icon at creation, and a button only-  # rebuilds when EVERY member hides at once. The foreground window must be included: exempting it-  # left ITS button alive, Explorer re-associated that surviving button (old art baked) to the-  # native group, and every reshown window JOINED it — the whole group wore stale pup art. This is-  # a user-initiated mode switch, so one sub-second blink of the active window is the right trade;-  # focus is restored immediately after.-  $fgInSet = $script:wins -contains $fg-  foreach($h in $script:wins){ [void][Conv]::ShowWindow($h,0) }-  Start-Sleep -Milliseconds 250-  foreach($h in $script:wins){ [void][Conv]::ShowWindow($h,4) }-  if($fgInSet){ [void][Conv]::SetForegroundWindow($fg) }-}-Write-Output ("NATIVE: {0}" -f $(if($native){$native}else{"(none - emptied)"}))-Write-Output ("CONVERGED {0} of {1}" -f $n, $script:wins.Count)`;-    const psFinal = psBody.replace('PIDS_CSV', [..._pids].join(',') || '0').replace('FALLBACK_NATIVE', _fallbackNative.replace(/"/g, ''));-    try {-      const _tmpPs = path.join(os.tmpdir(), 'pup-plain-converge.ps1');-      fs.writeFileSync(_tmpPs, psFinal);-      execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _tmpPs], { windowsHide: true, timeout: 45000 }, (err, stdout) => {-        const out = String(stdout || '').trim();-        console.log(`[identity] plain converge: ${err ? 'ERR ' + err.message.slice(0, 80) : out.replace(/\n/g, ' | ')}`);-        const m = out.match(/NATIVE: (ChromeForTestin\S+)/);-        if (m) { try { savePupSettings({ _cftNativeAumid: m[1] }); } catch (e) {} }-      });-    } catch (e) { console.log(`[identity] plain converge spawn failed: ${e.message}`); }-  }-  console.log(`[identity] stripped ${ids.size} registered identit(ies), wiping window props on ${hwnds.length} window(s) (${reason})`);-  // v2.0.200 (#53, reproduced twice in six-state proofs): sessions whose hwnd was unresolved at-  // strip time kept their branded button. Schedule ONE deferred pass: re-resolve and wipe the-  // stragglers ~12s later, when bounds/title resolution has usually caught up.-  const _missed = targets.filter(([, s9]) => !s9._hwnd).map(([sid9]) => sid9);-  if (_missed.length && !reason.endsWith('(deferred)')) {-    setTimeout(() => {-      (async () => {-        try {-          if (pupSettings().aumidIcons === 'on') return;   // mode changed again — stale pass, skip-          let n = 0;-          for (const sid9 of _missed) {-            const s9 = sessions.get(sid9); if (!s9) continue;-            try { if (!s9._hwnd) await resolveSessionHwndByBounds(s9, sid9); } catch (e) {}-            if (s9._hwnd) n++;-          }-          if (n) { console.log(`[identity] deferred plain strip: ${n} straggler(s) now resolvable — re-stripping`); await stripAllPupIdentities(reason + ' (deferred)'); }-        } catch (e) {}-      })();-    }, 12000);-  }-}-let _nudgeUntil = 0;   // v2.0.172: while set, ignore window-opened events caused by our own nudge-// v2.0.227: backoff for a nudge skipped because the window was foreground. Short first (the user-// usually clicks away within seconds), then patient — a window focused for 10 straight minutes is-// being actively used, and blinking it the moment they look away is the whole point of the skip.-const NUDGE_RETRY_DELAYS = [6000, 15000, 45000, 120000, 300000];-// v2.0.251: THE VISIBILITY INVARIANT — "a live pup window is never invisible".-// A human can MINIMIZE a window; a human can never HIDE one. So IsWindowVisible=false on a window-// pup owns is ALWAYS pup's own doing, and re-showing it can never fight the user. That makes this-// repair unconditionally safe, which is why it can run from the periodic sweep as well as from the-// nudge callback. Re-shows with SW_SHOWNOACTIVATE (no focus steal, no z-order pop).-async function ensureWindowShown(hwnd, sessionId, why) {-  if (process.platform !== 'win32' || !hwnd) return null;-  const ps = `$s=@'-using System; using System.Runtime.InteropServices;-public static class EV {-  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);-  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);-  [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);-}-'@; Add-Type -TypeDefinition $s; $h=[IntPtr]${hwnd}-if(-not [EV]::IsWindow($h)){ 'GONE' }-elseif([EV]::IsWindowVisible($h)){ 'OK' }-else { [EV]::ShowWindow($h,4)|Out-Null; if([EV]::IsWindowVisible($h)){ 'RESHOWN' } else { 'RESHOW-FAILED' } }`;-  return await new Promise((resolve) => {-    try {-      execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 20000 }, (err, stdout) => {-        const r = String(stdout || '').trim();-        if (r === 'RESHOWN') {-          console.log(`[visibility] "${sessionId}" window was HIDDEN (${why}) — re-shown. A hidden window's taskbar button reveals nothing to a human click; pup must never leave one that way.`);-          try { recordCleanup(`re-showed session "${sessionId}"'s window (it had been left HIDDEN by ${why} — its taskbar button would have revealed nothing when clicked)`); } catch (e) {}-        } else if (r === 'RESHOW-FAILED') {-          console.log(`[visibility] "${sessionId}" window is hidden and would NOT re-show (${why}) — reporting rather than pretending it is fine`);-        }-        resolve(r || null);-      });-    } catch (e) { resolve(null); }-  });-}--// v2.0.275: has the shell got an icon to READ for this AUMID yet? Rebuilding a button before the-// IconResource write is visible is how a virgin AUMID gets "no icon" cached against it FOREVER —-// measured on John's "oven" button, which sat as a bare overlay badge on empty space while its-// .ico, its registry entry and its AUMID were all provably correct. Windows caches per AUMID, so-// there is no second chance: the button must not be built until the art is registered.-function aumidIconRegistered(appId) {-  if (process.platform !== 'win32' || !appId) return Promise.resolve(true);-  return new Promise((resolve) => {-    try {-      execFile('reg', ['query', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/v', 'IconResource'],-        { windowsHide: true, timeout: 4000 }, (err, stdout) => {-          const m = /IconResource\s+REG_SZ\s+(.+)/i.exec(String(stdout || ''));-          if (!m) return resolve(false);-          const p2 = m[1].trim().replace(/,\d+\s*$/, '');-          try { resolve(fs.existsSync(p2) && fs.statSync(p2).size > 100); } catch (e) { resolve(false); }-        });-    } catch (e) { resolve(true); }   // cannot check: do not block the nudge on our own failure-  });-}-async function nudgeWhenIconReady(hwnd, sessionId, appId) {-  for (let i = 0; i < 10; i++) {                 // up to ~2.5s, then proceed regardless-    if (await aumidIconRegistered(appId)) break;-    if (i === 0) console.log(`[thread-tile] "${sessionId}" holding the button rebuild until ${appId}'s icon is registered — building it first is what caches "no icon" against a virgin AUMID`);-    await new Promise(r => setTimeout(r, 250));-  }-  nudgeTaskbarButton(hwnd, sessionId);-}-function nudgeTaskbarButton(hwnd, sessionId) {-  if (process.platform !== 'win32' || !hwnd) return;-  _nudgeUntil = Date.now() + 6000;-  const ps = `$s=@'-using System; using System.Runtime.InteropServices;-public static class SW {-  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);-  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();-  [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);-  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);-  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);-}-'@; Add-Type -TypeDefinition $s; $h=[IntPtr]${hwnd}-if([SW]::GetForegroundWindow() -eq $h){ 'SKIP-FG' } else {-  # v2.0.251 (John: "why can't i open this pup window? ... when i go to click on it as a human you-  # fuck me over"). ROOT CAUSE: this nudge HIDES the user's window, and the re-show was a separate-  # statement in a process node kills at 20s. Reproduced live: after the HIDE half alone the window-  # is IsWindowVisible=false — its taskbar button reveals NOTHING to a human click, for minutes,-  # until some later audit cycle happened to nudge again. So the re-show is now in a FINALLY: the-  # window comes back even if this script throws, and (belt and braces) the caller verifies too.-  # SECOND BUG, same line: the old re-show was always SW_SHOWNOACTIVATE (4), which RESTORES a-  # minimized window — so pup silently un-minimized windows the user had put away (John's window-  # went minimized -> normal on its own while I was probing it). Re-show in the state we found it:-  # 7 = SW_SHOWMINNOACTIVE for a window the user had minimized, 4 otherwise. pup's taskbar-  # bookkeeping must never change what the user chose to see.-  $wasMin = [SW]::IsIconic($h)-  try { [SW]::ShowWindow($h,0)|Out-Null; Start-Sleep -Milliseconds 80 }-  finally { [SW]::ShowWindow($h, $(if($wasMin){7}else{4}))|Out-Null }-  if([SW]::IsWindowVisible($h)){ 'NUDGED' } else { 'NUDGED-STILL-HIDDEN' }-}`;-  try {-    execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 20000 }, (err, stdout) => {-      const r = String(stdout || '').trim();-      console.log(`[thread-tile] "${sessionId}" button nudge: ${err ? 'ERR ' + err.message.slice(0, 80) : r}`);-      // v2.0.251: the nudge hid the user's window; PROVE it came back. The PS finally clause covers a-      // throw, but node KILLS this process at 20s (an Add-Type compile on a loaded box can reach that)-      // and a killed process runs no finally — that is the path that stranded John's window invisible-      // behind a live taskbar button. Verify from here, where the kill cannot reach us.-      if (err || r === 'NUDGED-STILL-HIDDEN') {-        ensureWindowShown(hwnd, sessionId, err ? 'a nudge that was killed or errored mid-sequence' : 'a nudge whose re-show did not take').catch(() => {});-      }-      // The button WAS rebuilt in the still-hidden case (the hide/show ran) — only the re-show-      // failed, and we just repaired that. So downstream bookkeeping treats it as a normal NUDGED.-      const rN = (r === 'NUDGED-STILL-HIDDEN') ? 'NUDGED' : r;-      // v2.0.227 (John's icon audit found fusion-dashboard grouped under the stock "Google Chrome for-      // Testing" button while wearing stale teal art): a SKIP-FG was a DEAD END. Windows never rebuilds-      // a taskbar button on an in-place AUMID change without this hide/show, so a window that merely-      // happened to be FOCUSED at stamp time kept its previous button identity FOREVER. The skill-      // called this a self-healing tradeoff ("heals on the next focus change") but nothing watched for-      // that change. Now the skip is a PENDING state with a bounded backoff retry; the PS itself-      // re-checks foreground, so a retry while still focused simply skips again and re-queues.-      const _sk = sessions.get(sessionId);-      if (!err && rN === 'SKIP-FG' && _sk) {-        const at = (_sk._nudgePending && _sk._nudgePending.attempts || 0) + 1;-        if (at <= NUDGE_RETRY_DELAYS.length) {-          _sk._nudgePending = { since: (_sk._nudgePending && _sk._nudgePending.since) || Date.now(), attempts: at, hwnd };-          const delay = NUDGE_RETRY_DELAYS[at - 1];-          console.log(`[thread-tile] "${sessionId}" nudge SKIPPED (window was focused) — button still wears its OLD identity; retry ${at}/${NUDGE_RETRY_DELAYS.length} in ${Math.round(delay / 1000)}s`);-          if (_sk._nudgeTimer) clearTimeout(_sk._nudgeTimer);-          _sk._nudgeTimer = setTimeout(() => {-            const s2 = sessions.get(sessionId);-            if (!s2 || !s2._nudgePending) return;-            nudgeTaskbarButton(s2._nudgePending.hwnd || hwnd, sessionId);-          }, delay);-        } else {-          console.log(`[thread-tile] "${sessionId}" nudge still skipped after ${NUDGE_RETRY_DELAYS.length} retries — the window has been focused throughout; leaving it pending for the next stamp`);-        }-      }-      if (!err && rN === 'NUDGED' && _sk) { _sk._buttonBuiltAt = Date.now(); }   // v2.0.228: stale-art anchor-      if (!err && rN === 'NUDGED' && _sk && _sk._nudgePending) {-        console.log(`[thread-tile] "${sessionId}" deferred nudge LANDED after ${_sk._nudgePending.attempts} retr(ies) — button rebuilt with its real identity`);-        _sk._nudgePending = null;-        if (_sk._nudgeTimer) { clearTimeout(_sk._nudgeTimer); _sk._nudgeTimer = null; }-      }-      // v2.0.171: the nudge DESTROYS and recreates the button, so any overlay badge already painted-      // is gone from glass while pup's model still says painted — mark bare so the enforcer repaints-      // the favicon badge onto the recreated thread-tile button (John wants tile + overlay together).-      if (!err && rN === 'NUDGED') {-        const s = sessions.get(sessionId);-        if (s) {-          s._appOverlay = false; s._overlayEnforceAt = 0; s._lastPaintSig = null;-          // v2.0.208 (dynamic-switching ralph): marking bare is NOT a repaint — freshly nudged-          // buttons sat badge-less until unrelated churn kicked the enforcer (measured 40s+ on six-          // fresh windows, twice). Repaint DIRECTLY once the shell settles from the rebuild.-          setTimeout(() => { try { if (sessions.has(sessionId)) applyAppOverlay(s, sessionId).catch(() => {}); } catch (e) {} }, 1800);-        }-      }-    });-  } catch (e) { console.log(`[thread-tile] "${sessionId}" nudge spawn failed: ${e.message}`); }-}-// v2.0.161 (John: "you need a janitor AND uninstall cleanup for the aumid icons you register"):-// reap prior-generation icon-cache directories so tile .ico files never pile up in %TEMP%.-function pruneStaleIconGenDirs() {-  const roots = [-    { dir: path.dirname(THREAD_TILE_DIR), keep: path.basename(THREAD_TILE_DIR) },   // pup-thread-tiles/<gen>-    { dir: path.dirname(_NOTCH_CACHE),    keep: path.basename(_NOTCH_CACHE) },       // pup-notched-icons/<gen>-  ];-  for (const { dir, keep } of roots) {-    try {-      if (!fs.existsSync(dir)) continue;-      for (const name of fs.readdirSync(dir)) {-        if (name === keep) continue;-        try { fs.rmSync(path.join(dir, name), { recursive: true, force: true, maxRetries: 2, retryDelay: 200 }); } catch (e) {}-      }-    } catch (e) {}-  }-}-async function unregisterPupAumid(appId) {-  if (process.platform !== 'win32' || !appId || appId === PUP_APP_ID) return;-  // v2.0.161 THREAD TILES: a thread AUMID (Adom.Pup.thread.<slug>.<gen>) is SHARED by every window-  // of that thread, so closing ONE window must NOT unregister the key the siblings still wear —-  // that would un-brand their taskbar button and drop their jump list. Bail if any OTHER live-  // session is still stamped with this appId. (Harmless for unique per-session ids: none will match.)-  for (const [, s] of sessions) {-    if (s && (s._curAppId === appId || s._frozenAppId === appId || s._desiredAppId === appId)) return;-  }-  _registeredPupAumids.delete(appId);-  // v1.9.52: CLEAR this AUMID's jump list before unregistering it. Otherwise its CustomDestinations-  // file lingers in the shell (measured: 6 stale jump-list files for 3 live windows, because every-  // category flip mints a new AUMID and orphans the old one's list). Explorer resolving a button-  // against a pile of stale-but-present jump lists is part of the first-right-click "blip".-  if (_jumplistSupported !== false) {-    try { await chrome.abCommand('desktop_set_window_jumplist', { appId, tasks: [] }, { timeoutMs: 5000 }); } catch (e) {}-  }-  try { await chrome.abCommand('desktop_unregister_app_identity', { appId }, { timeoutMs: 5000 }); } catch (e) {}-  // v2.0.161: also drop the HKCU IconResource key DIRECTLY. Under minimal-touch we write that key via-  // reg.exe (AD's register roundtrip is skipped), so AD's unregister above may leave it behind — delete-  // it here so a closed window's key never lingers until the 5-min janitor. reg.exe is idempotent-  // (a missing key just errors, swallowed). The sibling-guard above already protects shared thread keys.-  try {-    execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/f'],-      { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });-  } catch (e) {}-}-// v1.9.320 (John: "i turned OFF jump lists in settings but i still see them in the taskbar. did you-// ralph test this at all?"). No — I only tested ON. Toggling OFF never CLEARED the already-committed-// tasks, because updateWikiJumplist returns early when jumpLists is off (so it can't clear), and the-// toggle wasn't wired to anything. This runs on every jumpLists/jlViewTasks/jlCloseAll change: OFF →-// commit an EMPTY list to each window's AUMID (removes the menu tasks); ON/sub-change → force a-// re-commit so the new task set shows immediately.-// v2.0.214 (John: "when i switch taskbar identity states in the settings panel, can you show a-// progress bar with indicator of what step you're doing... then a green check mark you're done.-// cuz i sit there waiting and am not sure if you're done or not"): a mode switch takes several-// seconds (strip/stamp, button-rebuild nudges, overlay repaints) and the dialog gave zero feedback.-// This tracker is driven by the REAL apply promises, not timers alone, so the check mark is honest.-let _modeApply = null;-let _modeApplyTok = 0;-// v2.0.216 (John's plain-mode ralph: "you aren't removing the aumid when i switch to chrome plain-// choice?"): rapid state clicks RACED. A brandSweep fires 9 parallel per-window stamp chains that-// pass the aumidIcons gate instantly; a plain click two seconds later strips all 9 windows, and-// then the in-flight stamps land ON TOP of the wipe. Every identity-affecting change now bumps-// this generation; any stamp that captured an older generation aborts before touching the window.-let _identityGen = 0;-function _startModeApply(val) {-  const steps = [-    { label: val === 'plain' ? 'Stripping pup identities (back to the stock Chrome button)' : 'Stamping taskbar identities', state: 'run' },-    { label: 'Rebuilding taskbar buttons', state: 'pend' },-    { label: 'Painting overlay badges', state: 'pend' },-  ];-  _identityGen++;   // v2.0.216: supersede ALL in-flight identity work before this apply starts-  _modeApply = { at: Date.now(), token: ++_modeApplyTok, mode: val, steps, done: false };-  const my = _modeApply;-  (async () => {-    try {-      if (val === 'plain') {-        await stripAllPupIdentities('settings-taskbarmode-plain');-      } else {-        for (const [, s7] of [...sessions]) { try { s7._curAppId = null; s7._frozenAppId = null; s7._wikiJumplistKey = undefined; s7._jlFrozen = false; } catch (e) {} }-        try { _registeredPupAumids.clear(); } catch (e) {}-        await brandSweep('settings-taskbarmode');-        reconcileModel('setting taskbarMode').catch(() => {});-      }-    } catch (e) {}-    if (_modeApply !== my) return;-    my.steps[0].state = 'done'; my.steps[1].state = 'run';-    // shell settle: the hide/show nudges plus (in tiles mode) the 5s thread-stamp overlay gate-    await new Promise(r => setTimeout(r, val === 'tiles' ? 6000 : 2500));-    if (_modeApply !== my) return;-    if (val === 'plain') {-      // verify pass: any stamp already past its generation check when the click landed has landed-      // by now (the settle above outlasts a desktop_set_window_identity round-trip) — wipe again so-      // the glass converges even in the worst race. Idempotent when nothing slipped through.-      try { await stripAllPupIdentities('settings-taskbarmode-plain (verify pass)'); } catch (e) {}-    }-    my.steps[1].state = 'done';-    if (pupSettings().overlayBadges === 'on') {-      my.steps[2].state = 'run';-      for (const [sid9, s9] of [...sessions]) { try { s9._lastPaintSig = null; await applyAppOverlay(s9, sid9); } catch (e) {} }-    } else {-      my.steps[2].label = 'Overlay badges are off (nothing to paint)';-    }-    if (_modeApply !== my) return;-    my.steps[2].state = 'done'; my.done = true;-    scheduleIconAudit('after mode switch');   // v2.0.228: self-heal whatever the switch left crooked-  })();-}-// v2.0.228 — THE TASKBAR ICON AUDIT (John, 2026-08-18: "whenever i ask you to audit your taskbar-// icons ... i mean detect whether you're off on your aumid tracking, find out why, fix your code,-// then reapply all aumid icons. the goal is that i never have to ask you to audit again").-// So the audit is not a report — it is a SELF-HEAL, and it runs on its own. It compares pup's model-// against the SHELL'S OWN button names (UIA ground truth, the only thing that cannot lie), finds-// every drift class we have ever shipped, repairs each, and re-verifies. Companion dev skill:-// dev-skills/pup-icon-audit.-let _lastAudit = null;-let _auditBusy = false;-async function taskbarButtonNames() {-  if (process.platform !== 'win32') return [];-  const ps = `Add-Type -AssemblyName UIAutomationClient-$bars = [System.Windows.Automation.AutomationElement]::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, (New-Object System.Windows.Automation.OrCondition((New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_TrayWnd')), (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_SecondaryTrayWnd')))))-foreach($bar in $bars){ $btns=$bar.FindAll([System.Windows.Automation.TreeScope]::Descendants,(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))); foreach($b in $btns){ Write-Output $b.Current.Name } }`;-  return await new Promise((resolve) => {-    try {-      execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 25000, maxBuffer: 1 << 20 }, (err, stdout) => {-        resolve(err ? [] : String(stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean));-      });-    } catch (e) { resolve([]); }-  });-}-async function auditTaskbarIcons(reason = 'manual', opts = {}) {-  const fix = opts.fix !== false;-  // v2.0.270: `force` re-asserts every button's ART even when nothing was detected. This exists-  // because the detector CANNOT see the glass — it infers art correctness from _buttonBuiltAt vs the-  // tile's mtime, and that inference was clean while John was staring at a wrong icon. When the-  // model and the pixels disagree, the model loses: force bumps the registration epoch (new cacheKey-  // -> ab must re-read the icon) and re-stamps every session.-  const force = !!opts.force;-  if (process.platform !== 'win32') return { ok: true, skipped: 'not win32' };-  if (_auditBusy) return _lastAudit || { ok: true, skipped: 'audit already running' };-  _auditBusy = true;-  try {-    const st = pupSettings();-    const mode = st.aumidIcons !== 'on' ? 'plain' : (st.threadTiles === 'on' ? 'tiles' : 'aumid');-    const names = await taskbarButtonNames();-    const findings = [];-    const live = [...sessions].filter(([sid, s]) => s && sid !== 'pup-dashboard');-    if (!names.length) { const r = { ok: true, mode, reason, skipped: 'UIA returned no buttons (locked screen / no session)', at: Date.now() }; _lastAudit = r; return r; }-    for (const [sid, sess] of live) {-      const thread = sess.owner || sess._ownerThread || '';-      if (mode === 'plain') {-        // plain: NOTHING of pup may appear as a button, and every window rides the stock group-        if (thread && names.some(n => n.startsWith(thread + ' ')) || names.some(n => n.startsWith('Adom Pup'))) {-          findings.push({ sid, thread, kind: 'leftover_identity', why: 'plain mode but a pup-branded button is still on the bar' });-        }-        continue;-      }-      const expect = mode === 'tiles' && thread ? thread : 'Adom Pup';-      const hasButton = names.some(n => n.startsWith(expect));-      if (!hasButton) {-        findings.push({ sid, thread, kind: 'no_button', why: `no taskbar button starts with "${expect}" — the identity never reached the shell (window grouped under the stock Chrome button)` });-        continue;-      }-      if (sess._nudgePending) {-        findings.push({ sid, thread, kind: 'nudge_pending', why: 'identity stamped but the button rebuild is still queued (window keeps being focused)' });-        continue;-      }-      // STALE ART: the button exists with the right NAME, but Windows baked its icon when the-      // button was created — if the icon file was written AFTER that, the glass shows old art.-      if (mode === 'tiles' && thread) {-        try {-          const ico = path.join(THREAD_TILE_DIR, `${threadSlug(thread)}.ico`);-          const mt = fs.existsSync(ico) ? fs.statSync(ico).mtimeMs : 0;-          const built = sess._buttonBuiltAt || 0;-          if (mt && built && mt > built + 1500) {-            findings.push({ sid, thread, kind: 'stale_art', why: `the tile icon was written ${Math.round((mt - built) / 1000)}s AFTER this button was last rebuilt — the shell is showing the icon it baked at creation` });-          } else if (mt && !built) {-            findings.push({ sid, thread, kind: 'unverified_art', why: 'this button has never been rebuilt by pup since the tile was rendered — cannot vouch for the art on glass' });-          }-        } catch (e) {}-      }-    }-    let repaired = 0;-    if (force) {-      console.log(`[icon-audit] (${reason}) FORCE: re-asserting art on ${live.length} button(s) regardless of findings — the detector cannot read the glass, so a clean verdict is not proof.`);-      try { savePupSettings({ _stripEpoch: Number(pupSettings()._stripEpoch || 0) + 1 }); } catch (e) {}-      for (const [sid, sess] of live) {-        try {-          const appId = sess._curAppId;-          if (appId) { try { _registeredPupAumids.delete(appId); } catch (e) {} }-          sess._curAppId = null; sess._frozenAppId = null; sess._threadNudged = null;-          await stampPupIdentity(sid, { reason: 'icon-audit force' });-          if (sess._hwnd) nudgeTaskbarButton(sess._hwnd, sid);-          repaired++;-        } catch (e) {}-      }-    }-    if (fix && findings.length) {-      console.log(`[icon-audit] (${reason}) ${findings.length} finding(s): ${findings.map(f => f.sid + ':' + f.kind).join(', ')} — REPAIRING`);-      // force ab to re-read every icon: bump the registration epoch so cacheKeys change-      try { savePupSettings({ _stripEpoch: Number(pupSettings()._stripEpoch || 0) + 1 }); } catch (e) {}-      for (const f of findings) {-        const sess = sessions.get(f.sid);-        if (!sess) continue;-        try {-          if (f.kind === 'leftover_identity') { await stripAllPupIdentities('icon-audit repair (plain)'); repaired++; break; }-          // re-register (fresh icon handle) + re-stamp + rebuild the button-          const appId = sess._curAppId;-          if (appId) { try { _registeredPupAumids.delete(appId); } catch (e) {} }-          sess._curAppId = null; sess._frozenAppId = null;-          await stampPupIdentity(f.sid, { _commit: true });-          if (!sess._hwnd) { try { await resolveSessionHwndByBounds(sess, f.sid); } catch (e) {} }-          if (sess._hwnd) nudgeTaskbarButton(sess._hwnd, f.sid);-          sess._appOverlay = false; sess._lastPaintSig = null;-          repaired++;-        } catch (e) { console.log(`[icon-audit] repair of "${f.sid}" failed: ${e && e.message}`); }-      }-      // let the shell settle, then repaint badges onto the rebuilt buttons-      setTimeout(() => { for (const [sid2, s2] of [...sessions]) { try { applyAppOverlay(s2, sid2).catch(() => {}); } catch (e) {} } }, 2500);-    }-    const result = { ok: findings.length === 0, mode, reason, at: Date.now(), buttons: names.length,-                     sessions: live.length, findings, repaired, fixed: fix };-    if (!findings.length) console.log(`[icon-audit] (${reason}) CLEAN — ${live.length} session(s) all match the shell in ${mode} mode`);-    _lastAudit = result;-    return result;-  } finally { _auditBusy = false; }-}-// Self-healing cadence: a quiet steady-state check every 4 minutes, plus a pass ~25s after any-// identity churn settles (that is when the classic "button baked before the icon existed" lands).-function scheduleIconAudit(reason, delayMs = 25000) {-  try { if (global.__auditTimer) clearTimeout(global.__auditTimer); } catch (e) {}-  global.__auditTimer = setTimeout(() => { auditTaskbarIcons(reason).catch(() => {}); }, delayMs);-}-if (process.platform === 'win32') {-  // v2.0.315 (audit): the 4-minute steady-state audit (one UIAutomation PowerShell crawl of the whole-  // taskbar, forever) is retired; the audit still runs 25s after every identity churn via scheduleIconAudit.-}-// v2.0.235: the overlayBadges apply loop existed TWICE (dashboard set-setting + pup_configure).-// A progress-tracker edit landed in both copies but its variable was declared in only one, so every-// pup_configure toggle threw "ReferenceError: _obApply is not defined" as an unhandled rejection.-// One implementation now; `track` carries the progress entry when the caller made one.-// v2.0.263: the badge scale as a PERCENT of full size. Stored as a string so it rides the same-// settings/whitelist plumbing as every other key; clamped here so a bad value can never produce a-// zero-size (invisible) badge.-function overlayScalePct() {-  const n = parseInt(String(pupSettings().overlaySize || '75'), 10);-  return (Number.isFinite(n) && n >= 25 && n <= 100) ? n : 75;-}-async function applyOverlayBadgeSetting(val, track) {-  try {-    for (const [sid, sess] of [...sessions]) {-      if (val === 'off') {-        try { await chrome.abCommand('desktop_taskbar', { ...(await windowTarget(sess, sid)), overlay: { badge: 'none' } }, { timeoutMs: 5000 }); } catch (e) {}-        sess._appOverlay = false; sess._lastPaintSig = null;-      } else {-        sess._appOverlay = false; sess._lastPaintSig = null; sess._overlayEnforceAt = 0;-        try { await applyAppOverlay(sess, sid); } catch (e) {}-      }-    }-  } catch (e) { console.log(`[settings] overlayBadges apply failed: ${e && e.message}`); }-  if (track && _modeApply === track) { track.steps[0].state = 'done'; track.done = true; }-}-async function refreshAllJumplists() {-  if (process.platform !== 'win32' || _jumplistSupported === false) return;-  const off = pupSettings().jumpLists !== 'on';-  for (const [sid, s] of [...sessions]) {-    try {-      let appId = s._curAppId;-      if (!appId && pupSettings().threadTiles === 'on' && (s.owner || s._ownerThread)) {-        appId = threadAumid(s.owner || s._ownerThread);   // v2.0.198: the button exists even when the stamp never landed-      }-      if (!appId) continue;-      if (off) {-        await chrome.abCommand('desktop_set_window_jumplist', { appId, tasks: [] }, { timeoutMs: 5000 });-        s._wikiJumplistKey = undefined; s._jlFrozen = false;   // so a later re-enable re-commits from scratch-        console.log(`[jumplist] "${sid}" CLEARED (jumpLists turned off)`);-      } else {-        s._wikiJumplistKey = undefined; s._jlFrozen = false;   // force a real re-commit with the new task set-        await updateWikiJumplist(s, sid);-      }-    } catch (e) {}-  }-}--// v1.9.12: record the wiki auth state and RE-BRAND if it changed, so the taskbar icon flips-// between the browser-window glyph and the solid padlock the moment login state is known.-// Returns true if the state changed (i.e. a re-stamp was issued).-async function setWikiAuthedState(session, sessionId, authed, opts = {}) {-  if (!session) return false;-  // v1.9.93 — an INDETERMINATE probe is not a state change. Callers pass the whole checkWikiAuth-  // result; if it could not determine the answer, keep the last known state and re-stamp NOTHING.-  if (opts.unknown) return false;-  const next = !!authed;-  if (session._wikiAuthed === next) return false;-  // v1.9.93 — FLIP COOLDOWN. Every flip re-stamps the identity, which mints a new AUMID and DESTROYS-  // + recreates the taskbar button; doing that repeatedly is what made the right-click jump list-  // "blip out" (the flyout dies with the button under it). A genuine login/logout happens once and is-  // worth one flip; a rapid re-flip is churn, so absorb it. The state is still recorded — only the-  // expensive, user-visible re-stamp is suppressed — so the icon settles on the next legitimate brand.-  const FLIP_COOLDOWN_MS = 15000;-  const sinceFlip = Date.now() - (session._wikiAuthFlipAt || 0);-  session._wikiAuthed = next;-  if (process.platform !== 'win32') return true;-  if (session._wikiAuthFlipAt && sinceFlip < FLIP_COOLDOWN_MS) {-    console.log(`[identity] "${sessionId}" wiki login state -> ${next ? 'LOGGED IN' : 'logged out'} but SUPPRESSING the re-stamp (${Math.round(sinceFlip / 1000)}s since the last flip; an AUMID flip destroys the taskbar button and would kill an open jump list)`);-    return true;-  }-  session._wikiAuthFlipAt = Date.now();-  console.log(`[identity] "${sessionId}" wiki login state -> ${next ? 'LOGGED IN (solid green glyph)' : 'logged out (hollow glyph)'}`);-  // v1.9.15: RETRY the re-stamp. After a view toggle the window is BRAND NEW and AD resolves it by-  // its "(session: <id>)" title suffix, which the title injector adds only once the relaunched page-  // has loaded — so a single immediate stamp loses the race and AD answers "No visible window with-  // title containing (session: ...)". That is exactly why the icon did not flip on the first try.-  // Retry with backoff (~0.8s,1.6s,2.4s,3.2s,4s) until the title exists; the AUMID re-register runs-  // after, so Alt-Tab/flyout get the matching icon too.-  let stamped = false;-  for (let i = 0; i < 5 && !stamped; i++) {-    try { stamped = await stampPupIdentity(sessionId); } catch (e) {}-    if (!stamped) await new Promise(r => setTimeout(r, 800 * (i + 1)));-  }-  if (!stamped) console.log(`[identity] "${sessionId}" icon re-stamp FAILED after retries — window title never matched`);-  return true;-}-// v1.9.3 (AD #208 — desktop_set_window_jumplist, AD ≥1.9.139): give each WIKI pup window a-// taskbar RIGHT-CLICK task to flip between the logged-in and public view. The jump list attaches-// to the per-session AUMID (registered registry-only in v1.9.2), so it is a PER-WINDOW menu.-// Clicking a task runs AD's bundled CLI → pup_wiki_set_view, which relaunches THIS window-// under the other cookie jar (profile = userDataDir is fixed at browser launch, so a view switch-// = a relaunch). Only meaningful in SPLIT mode (grouped mode shares one taskbar button, so a-// per-window menu would be ambiguous) and only for Adom (adom.inc) URLs; any other URL clears the-// menu. Self-gates off if AD lacks the verb. Idempotent per (session, view) via _wikiJumplistView.-async function updateWikiJumplist(session, sessionId, opts) {-  // v1.9.269 (John toggled jump lists ON and the wiki tasks never appeared): this function carried-  // TWO legacy gates that silently vetoed the user's explicit pref - the minimal-touch return (the-  // pref now IS the user's choice and wins) and the grouped exclusion (under grouped, commit with-  // last-writer-wins semantics, exactly how overlay badges already behave on a shared button).-  if (pupSettings().jumpLists !== 'on') return;-  // v2.0.220 (T5's honest fail in plain mode): a jump list can only attach to a pup AUMID, and in-  // plain mode no window wears one — yet this path still registered a bare Adom.Pup.<sid> registry-  // key per window birth (the fallback id below) purely so it had something to commit to. Plain-  // means plain: no pup AUMIDs, no pup jump lists (the stock Chrome button keeps Chrome's own menu).-  if (pupSettings().aumidIcons !== 'on') return;-  if (process.platform !== 'win32' || _identitySupported !== true || _jumplistSupported === false) return;-  if (!session) return;-  // v1.9.61: callers may pass the appId EXPLICITLY. stampPupIdentity brands the header BEFORE it-  // stamps the new appId onto the window, so at that moment session._curAppId is still the OLD id.-  // v2.0.177: never mint a generic Adom.Pup.<sid> intermediate for a thread-owned window.-  // v2.0.198 (John's screenshot: syr-rental-car button with NO tasks, only Pin/Close): the 2.0.177-  // DEFER starved the list forever when the stamp could not land (unresolvable hwnd, #54) — but the-  // BUTTON lives on the thread AUMID regardless, and that id is COMPUTABLE from the owner alone.-  // So derive it and attach the tasks to it directly: the menu works even when stamping cannot.-  let _jlAppId = (opts && opts.appId) || (session && session._curAppId) || null;-  if (!_jlAppId && session && pupSettings().threadTiles === 'on' && (session.owner || session._ownerThread)) {-    _jlAppId = threadAumid(session.owner || session._ownerThread);-  }-  const appId = _jlAppId || `${PUP_APP_ID}.${sessionId}`;-  let url = null; try { url = (session.page && session.page.url()) || null; } catch (e) {}-  const view = isAdomUrl(url) ? (session._wikiView === 'authed' ? 'authed' : 'public') : null;-  // v1.9.51: the jump-list STATE is now (view + a constant close-all task). The close-all task is-  // on EVERY pup window, so the idempotency key is just the view — a menu that already reflects this-  // view already has the close-all row too.-  // v1.9.61: key on (appId|view). Keying on view alone re-committed the list on every refresh once-  // the appId changed, and EVERY CommitList rebuilds the menu — if one lands while the user is-  // opening it, the shell dismisses it. That is the "first right-click does nothing, second works"-  // wonkiness. One commit per (button, view), never a redundant one.-  // v2.0.7 (B, "stamp once then freeze"): once this window's jump-list has been committed for its-  // frozen identity, never rebuild it again. Category/active-tab/view changes (public->authed, or-  // switching to a web tab) otherwise re-key and re-CommitList — the multi-tab menu churn. The tasks-  // are the same for a wiki window regardless of view (both switch-view tasks always show), so freezing-  // costs at most a slightly stale menu, and buys zero churn — matching the appId freeze.-  if (session._jlFrozen) return;-  const jlKey = `${appId}|${view || 'none'}|${pupSettings().jlViewTasks}|${pupSettings().jlCloseAll}|${pupSettings().jlDashboard}|${pupSettings().jlAnnotate}`;-  if (session._wikiJumplistKey === jlKey) return;-  // v2.0.3: coalesce the jump-list settle churn. On open, the bare per-session appId and the-  // public->authed view each produce a distinct (appId|view) commit, so a single open committed the-  // menu ~3x. Debounce the callers that DON'T pass an explicit appId (the settle-driven refreshes):-  // schedule ONE commit after a short quiet window, by which point appId + view have settled and the-  // (appId|view) dedup above collapses it to a single commit. The stamp path passes opts.appId and-  // must stay synchronous (it brands the header right before creating the button, v1.9.61), so it is-  // exempt; the commit re-entry (opts._commit) is exempt so it can actually fire.-  if (!(opts && opts._commit) && !(opts && opts.appId)) {-    // v2.0.6: RESET the timer on every change (not "schedule once"). A multi-tab open fires jump-list-    // requests spread over seconds (public->authed auth flip, then a second tab flips the category to-    // mixed); a fixed-window timer let an early state (wiki-in) commit before the window settled, so the-    // menu still rebuilt 2-3x. Resetting on each request means we only commit once the window has been-    // QUIET for the settle window, collapsing the whole settle to a single commit of the final state.-    if (session._jlCommitTimer) clearTimeout(session._jlCommitTimer);-    session._jlCommitTimer = setTimeout(() => {-      session._jlCommitTimer = null;-      const s2 = sessions.get(sessionId);-      if (s2) updateWikiJumplist(s2, sessionId, { _commit: true }).catch(() => {});-    }, 1200);-    return;-  }-  // v1.9.62: CLAIM the key before the await. Two callers (the pre-stamp brand and a timer-driven-  // refresh) can both compute the same key and both find it unset, so both commit — a redundant-  // CommitList that rebuilds the menu and can dismiss one the user is opening. Claiming up front-  // makes the second caller a no-op; we roll it back below if the call actually fails.-  const _prevJlKey = session._wikiJumplistKey;-  session._wikiJumplistKey = jlKey;-  // v1.9.6: give each task an icon (iconPath) — without it Windows shows a generic blank-document-  // glyph (John: "why is your icon generic for that menu item?").-  // v1.9.54: use THIS WINDOW'S category icon, not pupIconPath() with no arg — that argless call fell-  // back to PUP_ICON_DEFAULT (adom-pup-browser3.ico), the RETIRED "P + card" pup icon, so the menu-  // rows still wore the old brand. pupIconPath(sessionId) returns the new teal-tile category art-  // (pup-cat-<cat>9.ico), so the task rows match the window's own taskbar icon.-  const _taskIcon = pupIconPath(sessionId);-  // v1.9.9: WINDOWS ARGV QUOTING — THE reason clicking a task did NOTHING. The jump-list task line is-  // parsed by the CLI's argv parser, which STRIPS unescaped double quotes, so raw JSON arrived-  // mangled and the CLI died with `Invalid JSON args`. Wrap JSON in quotes with the inner quotes-  // BACKSLASH-escaped so argv delivers it intact as ONE argument. Any JSON-carrying task MUST use this.-  // v1.9.115 — TASK COMMANDS MUST CARRY IDENTITY. AD (>=1.9.183) refuses ANY command with no caller-  // identity, and a jump-list task is a real CLI command that pup composes. Without --ai-thread John's-  // click on "Switch to logged-in view" was refused with caller_identity_required and silently did-  // NOTHING. Same root cause as the un-branded windows, in the one call path I failed to enumerate:-  // I fixed the bridge's OUTBOUND calls (abCommand) and forgot the commands pup hands to WINDOWS.-  // The thread name is honest here — a human clicked a taskbar menu, so that is who is asking, and it-  // is what the user will recognise in their own Activity Log.-  // v1.9.121 — identity goes in the JSON ARGS, never as a CLI flag. The taskbar tasks invoke the-  // BUNDLED local adom-desktop-cli.exe, which is an OLDER build than the container CLI and rejects-  // --ai-thread outright ("error: unexpected argument '--ai-thread' found"), so my 1.9.115 flag made-  // every task command INVALID and John's clicks stopped working entirely. AD's documented precedence-  // is args > flag > env, and a `caller` key inside the verb's own JSON is just another arg, so it-  // works on every CLI version, old or new. Also space-free by construction, which dodges the-  // Explorer/ad-taskrun argv re-parsing hazard.-  // v2.0.41: every task now routes through pupTask() → the pup-owned launcher (direct-to-pup POST,-  // CLI fallback). We ensure the launcher .ps1 + the port-discovery file exist before committing.-  try { ensurePupJumplistLauncher(); writePupPortFile(); writeExtensionPortFile(); } catch (e) {}-  // v1.9.51: a CLOSE-ALL task on every pup window (John: "i have like 8 open right now"). It calls-  // pup_close with no sessionId, which kills every pup Chrome + clears every session.-  const closeAllTask = pupTask('pup_close', {}, {-    title: 'Close ALL Adom Pup windows',-    description: 'Close every Adom Pup browser window at once (all sessions, every tab)',-    iconPath: _taskIcon,-  });-  // v1.9.53: SHORT titles. John: the "Adom wiki:" prefix pushed the important word ("logged-in")-  // off the end where Windows truncates the task label ("...switch to logged-in vi…"). The whole-  // point of the toggle is the login state, so lead with that and let the DESCRIPTION carry the-  // "Adom wiki page" context (it renders on its own line, untruncated). Keep both under ~24 chars so-  // neither clips at the default jump-list width.-  // v1.9.128 (John): show BOTH view tasks on every wiki window, always. The old code offered only-  // the direction pup BELIEVED was the toggle — but pup's belief goes stale (auth flips in another-  // window, a cookie expires, a recovery misses state), and when it's wrong the ONE task shown is-  // the useless one. John: "you're never really always going to be in sync to know whether the user-  // is logged in or not so might as well always put both." Stable order (logged-in first) so muscle-  // memory works; clicking the view you're already in is just a harmless reload of that view.-  let tasks = [];-  // v1.9.275: each entry family has its own pref (dep of the jumpLists master).-  if (pupSettings().jlViewTasks === 'on' && (view === 'authed' || view === 'public')) {-    tasks.push(pupTask('pup_wiki_set_view', { sessionId, view: 'authed' }, { title: 'Switch to logged-in view', description: 'Reload this Adom wiki page SIGNED IN — private source, drafts, owner-only cards (already signed in? harmless reload)', iconPath: _taskIcon }));-    tasks.push(pupTask('pup_wiki_set_view', { sessionId, view: 'public' }, { title: 'Switch to public view', description: 'Reload this Adom wiki page logged OUT — exactly what the public/world sees (already public? harmless reload)', iconPath: _taskIcon }));-  }-  // v2.0.42 (John: "can you add a jump list to open the pup dashboard?"): one click on any pup-  // taskbar icon opens the live dashboard — the map of every pup window/tab and its owning AI-  // thread. The verb reuses the one dashboard window on repeat clicks, so this is idempotent.-  // v2.0.49 (John: "claude desktop can annotate — add it to pup"): draw on this page from the-  // taskbar; Done captures it for the AI thread that owns the window.-  if (pupSettings().jlAnnotate !== 'off') tasks.push(pupTask('pup_annotate', { sessionId }, {-    title: 'Annotate this page',-    description: 'Draw on this page (pen, arrow, box, text) — Done captures it so the AI thread owning this window sees exactly what you marked',-    iconPath: _taskIcon,-  }));-  if (pupSettings().jlDashboard === 'on') tasks.push(pupTask('pup_dashboard', {}, {-    title: 'Open pup dashboard',-    description: 'Live dashboard of every pup window: owning AI thread, tabs with favicons, foreground state and why, uptime (reuses the dashboard window if already open)',-    iconPath: _taskIcon,-  }));-  if (pupSettings().jlCloseAll === 'on') tasks.push(closeAllTask);-  // v2.0.21 (John: "stop trying to mangle the titles... instead give a jump list item that explains-  // the ai-thread that owns that window, and clicking it shows a native window with all the info"):-  // the FIRST task on every pup window names the owning AI thread; clicking it runs pup_window_info,-  // which paints a native always-on-top caption card just above the taskbar (ab desktop_caption)-  // with everything about this window. This replaces the title-tag approach for good.-  if (pupSettings().jlOwnerTask !== 'off') {-    const _ownT = (session && (session.owner || session._ownerThread)) || null;-    if (_ownT) tasks.unshift(pupTask('pup_window_info', { sessionId }, {-      title: `Owned by: ${String(_ownT).slice(0, 40)}`,-      description: `Show everything about this window as a native card above the taskbar — the AI thread that owns it ("${_ownT}"), its container, profile, tabs, age, and the last agent action`,-      iconPath: _taskIcon,-    }));-  }-  // v2.0.130 (#43): move this window's page into the user's Hydrogen workspace (webview via the-  // Browser Picker), then close the pup window. Only meaningful when Hydrogen runs locally; the-  // verb reports hydrogen_unreachable honestly and leaves the window alone if not.-  tasks.push(pupTask('pup_move_to_workspace', { sessionId }, {-    title: 'Move to my workspace',-    description: 'Hand this page to Hydrogen on this machine (opens in your workspace via the Browser Picker) and close this pup window',-    iconPath: _taskIcon,-  }));-  // The shell ignores a jump list on an unregistered AUMID — ensure it's registered first.-  // v1.9.314 (John: "i turned on jump lists and none of them work"): this guard REQUIRED the appId to-  // be in _registeredPupAumids, but that set is ONLY filled by desktop_register_app_identity, which-  // this AD build silently no-ops (measured: appIdReg=false for every window). So the guard RETURNED-  // before every commit and, because the dedup key is claimed just above, then dedup-skipped forever —-  // the real reason NONE of the jump lists worked. The per-window STAMP already associates the window-  // with this AUMID, and a direct desktop_set_window_jumplist (passing hwnd) commits fine without the-  // registry entry (proven). Register best-effort; NEVER block the commit on it.-  if (!_registeredPupAumids.has(appId)) { try { await registerPupAumid(appId, sessionId); } catch (e) {} }-  // v1.9.60 — BRAND THE JUMP-LIST HEADER ROW (AD >= 1.9.153).-  //-  // The header row (the "Adom Pup" app tile above Pin/Close) is branded by passing `headerIcon` to-  // desktop_set_window_jumplist. TWO non-obvious requirements, both measured:-  //   1. You MUST pass `hwnd`. With only appId, AD cannot locate the window to write the per-window-  //      Relaunch* props on and silently returns headerBranded:false with an EMPTY headerFields and-  //      NO error. `titleContains` does NOT work here either (also headerBranded:false) even though-  //      it works on desktop_set_window_identity. hwnd is the only locator that brands the header.-  //   2. The .ico must pass AD's iconCheck — in particular its 256px entry must be PNG-compressed,-  //      or the shell paints the generic white document regardless (the Vista+ rule that had all of-  //      our icons failing until 1.9.58).-  // The response carries headerBranded / headerFields / headerIconCheck, so this is verifiable in-  // code — no more "right-click it and tell me what you see".-  // v1.9.312 (John: "i turned on jump lists and none of them work"): this resolved the hwnd by TITLE-  // (desktop_find_window titleContains), which returns null when the title tag is off — so payload.hwnd-  // was never set and, per the note above, the jump list could not be committed to the window. The-  // "known Windows 11 bug" comment on the setting was a MISDIAGNOSIS; it was this title lookup. Use the-  // reliable birth-time/persisted handle instead, the same one every other window operation now uses.-  let hwnd = null;-  try {-    if (session && !session._hwnd) await resolveSessionHwndByBounds(session, sessionId);-    if (session && session._hwnd && await hwndBelongsToPup(session._hwnd, session)) hwnd = Number(session._hwnd);-  } catch (e) {}-  try {-    const payload = { appId, tasks };-    if (hwnd) { payload.hwnd = hwnd; payload.headerIcon = pupIconPath(sessionId); }-    const r = await chrome.abCommand('desktop_set_window_jumplist', payload, { timeoutMs: 6000 });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));-    if (_isUnknownVerb(r, raw)) { _jumplistSupported = false; session._wikiJumplistKey = _prevJlKey; return; }-    _jumplistSupported = true;-    session._wikiJumplistView = view;-    session._wikiJumplistKey = jlKey;-    // v2.0.8 (B): freeze as soon as we've committed the menu onto a real per-session CATEGORY appId-    // (Adom.Pup.<sid>.<cat>.<gen>), not the bare fallback. That is the button the window keeps, so one-    // commit to it is all we ever need; later view/active-tab flips are no-ops. Checking the appId-    // shape (rather than session._frozenAppId, which is only set after the stamp returns) freezes on the-    // very first real commit instead of a beat later, killing the settle rebuilds too.-    // v2.0.43 (mimic-ralph: windows randomly frozen at a 3-task menu, no view toggles): a wiki-    // window's categorized appId can commit BEFORE its login view resolves, and freezing on that-    // commit locks the incomplete menu forever. Freeze a wiki-category window only once the menu-    // actually carries the view tasks; non-wiki windows still freeze on first categorized commit.-    const _menuComplete = (view === 'authed' || view === 'public') || appId.indexOf('.wiki') < 0;-    if (appId && appId.indexOf('.' + sessionId + '.') >= 0 && _menuComplete) session._jlFrozen = true;-    // Surface the header-branding outcome: it is the ONLY programmatic signal that the app-tile row-    // actually took our icon (the rendered menu itself cannot be captured from the cloud).-    let hb = null, hic = null;-    try { const o2 = raw ? JSON.parse(raw) : {}; const d2 = o2.data || o2; hb = d2.headerBranded; hic = d2.headerIconCheck; } catch (e) {}-    console.log(`[jumplist] "${sessionId}" set ${tasks.length} task(s) on ${appId} (view=${view || 'none'}${view ? '+' : ''}close-all) headerBranded=${hb}${hic && hic.ok === false ? ` ICONCHECK_FAILED:${(hic.issues || []).join('; ')}` : ''}`);-  } catch (e) { session._wikiJumplistKey = _prevJlKey; }-}-// v1.8.80: TAB-COUNT overlay badge (John: "if the ai opens 8 tabs that makes that pup window-// unique"). The overlay slot is FREE on identity-capable AD (the base icon is the Adom Pup-// identity), so it now shows the window's tab count for >=2 tabs — dark circle, white digit,-// 9+ cap. NO badge = 1 tab, so a many-tab window is instantly distinguishable from its-// single-tab neighbors. On AD <1.9.134 (no identity) the overlay stays the pup brand badge —-// branding beats counting there. Updated on open_tab/close_tab/popup-attach; cleared at 1.-const _tabBadgeB64 = new Map(); // count-key -> base64 png (lazy)-function tabBadgeIcon(count) {-  const key = count > 9 ? '9plus' : String(count);-  if (_tabBadgeB64.has(key)) return _tabBadgeB64.get(key);-  try {-    const b = fs.readFileSync(path.join(__dirname, 'icons', 'tab-badges', `badge-${key}.png`)).toString('base64');-    _tabBadgeB64.set(key, b);-    return b;-  } catch (e) { return null; }-}-// v1.8.84: DUAL counter for grouped mode (John: "1 for # of windows and then a total for the-// tabs"). Windows = dark circle upper-right (same language as the split badge); total tabs =-// light teal circle lower-left with dark text. Combos are dynamic -> rendered at runtime from-// an SVG template via sharp (already a dep), cached in memory per (wins,tabs) pair. If sharp-// is unavailable (optional dep), grouped mode falls back to the static windows-count badge.-const _dualBadgeCache = new Map();-async function dualBadgeB64(wins, tabs) {-  const key = `${wins}/${tabs}`;-  if (_dualBadgeCache.has(key)) return _dualBadgeCache.get(key);-  let sharpMod = null;-  try { sharpMod = require('sharp'); } catch (e) { return null; }-  const w = wins > 9 ? '9+' : String(wins);-  const t = tabs > 99 ? '99+' : String(tabs);-  const wSize = w.length > 1 ? 11 : 14;-  const tSize = t.length > 2 ? 7.8 : (t.length > 1 ? 9.8 : 13);-  // v1.8.90: ROUNDED-RECT plates behind the digits (per John — bare digits were hard to see-  // without a background; rects fit digits tighter than circles so the type stays big).-  // Left dark rect = windows (white digit); right teal rect = total tabs (dark digit).-  const wS = w.length > 1 ? 11 : 14;-  const tS = t.length > 2 ? 8 : (t.length > 1 ? 10.5 : 14);-  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">-  <rect x="0.5" y="1" width="14.5" height="15" rx="4.5" fill="#003d40"/>-  <text x="7.75" y="${8.5 + wS * 0.36}" font-family="Segoe UI, Arial, sans-serif" font-size="${wS}" font-weight="700" fill="#ffffff" text-anchor="middle">${w}</text>-  <rect x="17" y="1" width="14.5" height="15" rx="4.5" fill="#7de8e2"/>-  <text x="24.25" y="${8.5 + tS * 0.36}" font-family="Segoe UI, Arial, sans-serif" font-size="${tS}" font-weight="700" fill="#003d40" text-anchor="middle">${t}</text>-</svg>`;-  try {-    const buf = await sharpMod(Buffer.from(svg)).resize(32, 32).png().toBuffer();-    const b64 = buf.toString('base64');-    _dualBadgeCache.set(key, b64);-    return b64;-  } catch (e) { return null; }-}--// v1.9.57 — AGENT-ACTIVITY INDICATOR (taskbar progress bar).-//-// John runs many AI threads at once and asked which pup window an agent is actually driving right-// now. The taskbar PROGRESS bar answers it with zero AD work: ITaskbarList3 progress is already-// exposed by desktop_taskbar, it paints ON the button (so it reads at a glance across the whole-// taskbar), and it is a SEPARATE channel from the overlay icon — the favicon overlay is untouched.-//-// Semantics: 'indeterminate' (marquee) while a thread is issuing verbs at this window, cleared-// ~2.5s after the last one. Deliberately NOT a percentage: an agent's work has no known length, and-// a fake percentage would be a lie. Only transitions are sent to AD (idle->busy once, busy->idle-// once), never one call per verb, so a chatty thread costs 2 AD calls, not 200.-const _busyState = new Map();      // sessionId -> { busy, timer }-const BUSY_IDLE_MS = 2500;-// v1.9.86 (Kyle) — WHY THIS IS GATED, recorded precisely because the obvious reading is WRONG and-// would invite a future un-gate. desktop_taskbar is NOT unsupported on macOS: AD really calls-// NSApplication.requestUserAttention (Dock bounce) for flash and NSDockTile for badge text, and-// every arg shape returns success here. The blocker is the ADDRESSING MODEL, not the capability.-// macOS has no per-window taskbar button, so AD's mac path ignores titleContains/hwnd (it reports-// hwnd:0) and paints AD's OWN Dock tile. pup's use is inherently PER-SESSION-WINDOW — "which of-// these five pup windows is this thread driving" — so un-gating would badge Adom Desktop rather-// than the pup window, and every session would fight over one tile. AD also skips overlay.icon on-// mac outright (no Dock equivalent), which is what this call passes.-// So: capability real, semantics incompatible. Do not un-gate on learning "desktop_taskbar works-// on mac" — it does; it just cannot address what pup needs to address. The mac equivalent of this-// cue is pup_alert_window's toast-and-unhide path, which IS per-window.-async function setSessionProgress(sessionId, busy) {-  if (minimalTouch()) return;   // v1.9.185 minimal-touch: no taskbar progress bars-  // v1.9.152 (John's audit columns): count progress-bar triggers + total ms it ran, per session.-  try {-    const _ps = sessions.get(sessionId);-    if (_ps) {-      if (busy && !_ps._progressSince) { _ps._progressSince = Date.now(); _ps._progressCount = (_ps._progressCount || 0) + 1; }-      if (!busy && _ps._progressSince) { _ps._progressMs = (_ps._progressMs || 0) + (Date.now() - _ps._progressSince); _ps._progressSince = null; }-    }-  } catch (e) {}-  if (process.platform !== 'win32') return;-  try {-    await chrome.abCommand('desktop_taskbar', {-      ...(await windowTarget(sessions.get(sessionId), sessionId)),-      progress: busy ? { state: 'indeterminate' } : { state: 'none' },-    }, { timeoutMs: 4000 });-  } catch (e) {}-  // v2.0.47 (John's zoom: a BARE tile my audit swore was painted): the taskbar progress cycle can-  // clear the button's overlay, but pup's paint memory (_appOverlay/_lastPaintSig) survives it, so-  // the enforcer "sees" a painted window and never repaints — bare on screen, green in the logs.-  // Windows offers no way to QUERY overlay state, so after every progress change assume the overlay-  // may be gone: void the paint memory and let the 4s enforcer sweep repaint (dedup makes it cheap).-  try { const _s3 = sessions.get(sessionId); if (_s3) { _s3._lastPaintSig = null; _s3._appOverlay = false; _s3._overlayEnforceAt = 0; } } catch (e) {}-}-// ============================================================================-// v2.0.289 VERB-TIMING METRICS + DETERMINATE TASKBAR PROGRESS (John's request: replace the orange-// flash with a taskbar PROGRESS BAR sized to how long the operation usually takes). Keep a running-// average per operation, fill the bar over that estimate, start at the EARLIEST point and end at the-// LATEST. Wired into icon-application (until the taskbar icon actually lands) and screenshots; any-// call site can wrap work with withTaskbarProgress(). Setting: taskbarActivity = progress|flash|off.-// -----------------------------------------------------------------------------const VERB_TIMING_FILE = path.join(require('os').homedir(), '.adom', 'pup-verb-timing.json');-let _verbTiming = {};   // opName -> { n, avgMs } — persisted so the averages survive restarts-try { _verbTiming = JSON.parse(fs.readFileSync(VERB_TIMING_FILE, 'utf8')) || {}; } catch (e) {}-let _verbTimingDirty = false;-function recordTiming(opName, ms) {-  if (!opName || !(ms >= 0) || ms > 120000) return;-  const cur = _verbTiming[opName] || { n: 0, avgMs: ms };-  cur.avgMs = cur.n === 0 ? Math.round(ms) : Math.round(cur.avgMs * 0.75 + ms * 0.25);  // weight recent runs-  cur.n = Math.min((cur.n || 0) + 1, 100000);-  _verbTiming[opName] = cur; _verbTimingDirty = true;-}-setInterval(() => {-  if (!_verbTimingDirty) return; _verbTimingDirty = false;-  try { fs.mkdirSync(path.dirname(VERB_TIMING_FILE), { recursive: true }); fs.writeFileSync(VERB_TIMING_FILE, JSON.stringify(_verbTiming)); } catch (e) {}-}, 30000);-const _DEFAULT_EST_MS = { 'icon-apply': 2200, 'screenshot': 900, 'navigate': 2500, 'open': 3200 };-function estimateMs(opName) { const t = _verbTiming[opName]; return (t && t.avgMs) || _DEFAULT_EST_MS[opName] || 1200; }-function taskbarActivityMode() { const m = pupSettings().taskbarActivity; return (m === 'flash' || m === 'off') ? m : 'progress'; }-async function _pushProgress(target, value) { try { await chrome.abCommand('desktop_taskbar', { ...target, progress: value == null ? { state: 'none' } : { state: 'normal', value } }, { timeoutMs: 3000 }); } catch (e) {} }-// A determinate bar that fills over the running-average estimate for `opName`, held ON for the life of-// `work`, then completed and cleared. Records the real duration to sharpen the estimate. No-op unless-// the setting is 'progress'. The bar caps at 92% until the work actually finishes so it never lies.-async function withTaskbarProgress(sessionId, opName, work) {-  const runOne = () => (typeof work === 'function' ? work() : work);-  if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress' || minimalTouch()) return runOne();-  const sess = sessions.get(sessionId);-  const target = sess && await windowTarget(sess, sessionId).catch(() => null);-  if (!target || !target.hwnd) return runOne();   // no handle yet — nothing to paint on-  const est = estimateMs(opName), started = Date.now(); let done = false;-  await _pushProgress(target, 2);-  const timer = setInterval(() => { if (done) return; const f = Math.min(0.92, (Date.now() - started) / Math.max(200, est)); _pushProgress(target, Math.max(2, Math.round(f * 100))).catch(() => {}); }, 180);-  try { return await runOne(); }-  finally {-    done = true; clearInterval(timer); recordTiming(opName, Date.now() - started);-    await _pushProgress(target, 100);-    setTimeout(() => { _pushProgress(target, null).catch(() => {}); try { if (sess) { sess._lastPaintSig = null; sess._appOverlay = false; sess._overlayEnforceAt = 0; } } catch (e) {} }, 220);-  }-}-// ICON-APPLY progress: a window's taskbar icon is applied ASYNC (birth -> hwnd -> stamp -> tile render-// -> nudge). Show a determinate bar the whole time — start at open (EARLIEST), end when the icon-// actually lands (LATEST) — timed to the running average of past icon-applies. Self-contained: a poll-// resolves the hwnd once it exists, then fills the bar until markIconApplied() fires or it times out.-const _iconApplyRunning = new Set();-function startIconApplyProgress(sessionId) {-  if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress' || minimalTouch()) return;-  if (_iconApplyRunning.has(sessionId)) return;-  _iconApplyRunning.add(sessionId);-  const est = estimateMs('icon-apply'), started = Date.now();-  (async () => {-    let target = null;-    try {-      for (let i = 0; i < 40; i++) {   // up to ~8s: wait for the handle, then fill the bar-        const sess = sessions.get(sessionId);-        if (!sess) break;-        if (sess._iconApplied) break;-        if (!target) target = await windowTarget(sess, sessionId).catch(() => null);-        if (target && target.hwnd) {-          const f = Math.min(0.92, (Date.now() - started) / Math.max(200, est));-          await _pushProgress(target, Math.max(2, Math.round(f * 100)));-        }-        await new Promise(r => setTimeout(r, 200));-      }-    } catch (e) {}-    if (target && target.hwnd) { await _pushProgress(target, 100); setTimeout(() => _pushProgress(target, null).catch(() => {}), 220); }-    recordTiming('icon-apply', Date.now() - started);-    _iconApplyRunning.delete(sessionId);-  })().catch(() => { _iconApplyRunning.delete(sessionId); });-}-function markIconApplied(sessionId) { const s = sessions.get(sessionId); if (s) s._iconApplied = true; }--// v1.9.166 (John, repeatedly: 'use an AD toast to get a human's attention'). Whenever pup FOREGROUNDS-// a window (a reveal / demo / 'show me'), fire an AD toast FIRST so the user's eyes are drawn to what-// just appeared — never silently push a window onto their screen. Best-effort; debounced per session.-const _fgToastAt = new Map();-// v1.9.201 (John): "every time you open a window in the foreground and you force the AI to give you-// a reason you should flash that on screen as an AD caption for 1 second". The foreground GATE already-// makes callers justify themselves and records the reason in lastForeground — but that audit trail was-// only visible if you went looking for it. Now the reason appears ON SCREEN the moment the window-// arrives, so the justification is accountable in real time. Deliberately NOT gated by minimalTouch:-// this is a user-protection signal, not cosmetic machinery.-// v2.0.294 (John: "time your ab captions... the timing is just too fast. we need a longer amount of-// time here, or you need to count the words and adjust"): caption duration scales with how much-// there is to READ. The fgCaptionMs slider is the FLOOR (short captions hold at least that long);-// longer text extends at reading speed (~350ms/word + 900ms to notice it), capped at 12s so a-// runaway reason can never squat on the screen.-function captionMsFor(text) {-  const base = Math.max(500, Math.min(12000, Number(pupSettings().fgCaptionMs) || 3500));-  const words = String(text || '').trim().split(/\s+/).filter(Boolean).length;-  return Math.max(base, Math.min(12000, 900 + words * 350));-}-async function captionForegroundReason(sessionId, reason, verb, thread) {-  if (pupSettings().fgCaption !== 'on') return;-  try {-    const why = String(reason || '').trim();-    if (!why) return;-    // v2.0.290 (John: "if you foreground, the caption must say WHICH ai-thread is doing it, and the-    // reason"). Name the initiating thread (the caller), falling back to the window's owner thread.-    const _s = sessions.get(sessionId);-    const _th = (thread && String(thread).trim()) || (_s && (_s.owner || _s._ownerThread)) || null;-    const who = _th ? `AI thread "${_th}"` : 'Adom';-    const msg = `${who} brought "${sessionId}" to the front. Reason: "${why}"`;-    const _capMs = captionMsFor(msg);-    await pupCaption(msg, _capMs, 'pup-fg-reason', _capMs);-  } catch (e) {}-}--async function toastForeground(sessionId, reason) {-  if (minimalTouch()) return;   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome-  try {-    if (process.platform !== 'win32') return;-    const now = Date.now();-    if (now - (_fgToastAt.get(sessionId) || 0) < 4000) return;   // debounce a burst-    _fgToastAt.set(sessionId, now);-    const body = reason ? String(reason).slice(0, 140) : 'Adom opened a window on your screen.';-    await chrome.abCommand('notify_user', { title: 'Adom is showing you a window', body, focus: false }, { timeoutMs: 4000 });-  } catch (e) {}-}-function markSessionBusy(sessionId) {-  if (process.platform !== 'win32' || !sessionId) return;-  if (taskbarActivityMode() !== 'progress') return;      // v2.0.292: driving-strip folded into taskbarActivity='progress'-  if (!sessions.has(sessionId)) return;-  let st = _busyState.get(sessionId);-  if (!st) { st = { busy: false, timer: null }; _busyState.set(sessionId, st); }-  // v2.0.193: while a caller holds EXPLICIT progress (pup_progress), the automatic busy strip stays-  // hands-off — otherwise its on/off toggling would fight the caller's crawl-stage bar.-  { const _es = sessions.get(sessionId); if (_es && _es._explicitProgress) return; }-  if (!st.busy) {-    st.busy = true;-    console.log(`[activity] "${sessionId}" agent driving — taskbar progress ON`);-    setSessionProgress(sessionId, true).catch(() => {});-  }-  clearTimeout(st.timer);-  st.timer = setTimeout(() => {-    st.busy = false;-    console.log(`[activity] "${sessionId}" idle — taskbar progress OFF`);-    setSessionProgress(sessionId, false).catch(() => {});-  }, BUSY_IDLE_MS);-}-function clearSessionBusy(sessionId) {-  const st = _busyState.get(sessionId);-  if (st) {-    clearTimeout(st.timer);-    // v1.9.166 (John: 'i see you leave those stale quite often'): explicitly turn the OS taskbar-    // progress OFF on teardown. Deleting the internal state alone left the taskbar bar lit whenever-    // the window churned before its idle-timer fired (the stale-bar bug).-    if (st.busy) setSessionProgress(sessionId, false).catch(() => {});-    _busyState.delete(sessionId);-  }-}-// Verbs that are pure status/polling: they do NOT mean an agent is driving the window, and lighting-// the bar for them would make every window look permanently busy (the audit loops alone poll these).-const PASSIVE_VERBS = new Set([-  'pup_annotate', 'pup_floaty_click', 'pup_list_windows', 'pup_list_tabs', 'pup_readiness', 'pup_status', 'pup_maximize', 'pup_hover', 'pup_deps', 'pup_dashboard',-  'pup_describe', 'pup_configure', 'pup_prewarm', 'pup_rescan', 'pup_wait', 'pup_import_scan', 'pup_set_identity',-]);--// v1.9.58 — THUMBNAIL TOOLTIP (AD >= 1.9.152: desktop_taskbar { thumbnailTooltip }).-//-// The hover-preview CAPTION is just the window title and Windows truncates it at ~25-30 chars, so it-// can only ever carry the state glyph plus a clipped page name. The thumbnail tooltip is the only-// UNCAPPED text surface on that popup and it accepts NEWLINES, so this is where the full answer to-// "what is this window?" belongs: that it is Adom Pup, what KIND of content it holds, whether a wiki-// page is the public or the signed-in view, that it is Chrome-driven, and the tab count.-const CAT_LABEL = {-  wiki: 'Adom wiki (PUBLIC view)',-  'wiki-in': 'Adom wiki (SIGNED IN)',-  app: 'Adom app',-  web: 'Web page',-  mixed: 'Mixed content',-};-function buildThumbnailTooltip(session, sessionId) {-  const cat = pupCategory(sessionId);-  const tabs = (session.tabs || []).length;-  // v1.9.101 (John's ask) — LEAD WITH THE OWNING AI THREAD. The hover tooltip is the only UNCAPPED-  // text surface on the taskbar preview (the header above the thumbnail is the window TITLE and-  // truncates around 25-30 chars), so it is the right home for full attribution. With ~20 threads-  // running at once, "which conversation made this window" is the single most useful thing the user-  // can read here — it turns an anonymous Chrome preview into "oh, that's my adom-tsci thread".-  const lines = [];-  const who = session._ownerThread || session._lastThread || null;-  const where = session._ownerContainer || session._lastContainer || null;-  if (who) {-    lines.push(where ? `${who}  ·  ${where}` : `${who}`);-    // v1.9.107 (John: "show the ai-thread name and reason for showing the pup window") — the REASON-    // the thread gave for this window. It only fits here: the preview HEADER is the window title-    // (truncates ~25-30 chars) and a thumbnail-toolbar button carries an icon, not text. This tooltip-    // is uncapped and takes newlines, so prose belongs on it.-    if (session._openReason) lines.push(`why: ${session._openReason}`);-    // A handoff is worth showing: the window belongs to one thread but another just drove it.-    if (session._lastThread && session._lastThread !== who) lines.push(`last driven by: ${session._lastThread}`);-  }-  lines.push(`Adom Pup · ${CAT_LABEL[cat] || 'Browser window'}${who ? '' : ' · (no AI-thread identity yet)'}`);-  try {-    if (cat === 'mixed') {-      // Name the distinct hosts so the user knows WHY it is mixed without opening it.-      const hosts = [];-      for (const t of (session.tabs || [])) {-        try { const h = new URL(t.page.url()).hostname.replace(/^www\./, ''); if (h && !hosts.includes(h)) hosts.push(h); } catch (e) {}-      }-      if (hosts.length) lines.push(hosts.slice(0, 4).join(', '));-    } else {-      const active = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];-      if (active) {-        let u = ''; try { u = active.page.url(); } catch (e) {}-        if (u) { try { const p = new URL(u); lines.push((p.hostname + p.pathname).replace(/^www\./, '').slice(0, 70)); } catch (e) {} }-      }-    }-  } catch (e) {}-  // v1.9.101: report the browser ACTUALLY driving this window. This said "Chrome for Testing"-  // unconditionally, which has been wrong for every native-first window since v1.9.90.-  let browserLabel = 'Browser';-  try {-    const be = browsers.get(session.profileName);-    const chosen = be && be.browser && be.browser._pupChosen;-    if (chosen && chosen.kind) {-      browserLabel = chosen.kind === 'chrome-for-testing' ? 'Chrome for Testing'-        : chosen.kind === 'edge' ? 'Microsoft Edge'-        : chosen.kind === 'chrome' ? 'Google Chrome' : chosen.kind;-    }-  } catch (e) {}-  lines.push(`${browserLabel} · ${tabs} tab${tabs === 1 ? '' : 's'}  ·  session: ${sessionId}`);-  return lines.join('\n');-}-// v1.9.104 — the caller to attribute a session-scoped AD callback to: the thread that OWNS this-// window (falling back to whoever last drove it). Returned as an abCommand opt so AD can render-// "<thread> (via pup)" on the Activity Log + any approval prompt. Returns null for work that is-// genuinely pup's own (timers, sweeps) — per the contract we must NOT forward a stale identity there.-function sessionCaller(session) {-  try {-    if (!session) return null;-    const thread = session._ownerThread || session._lastThread || null;-    if (!thread) return null;-    return { thread, container: session._ownerContainer || session._lastContainer || null, reason: session._openReason || null };  // #350: forward reason too-  } catch (e) { return null; }-}-async function updateThumbnailTooltip(session, sessionId) {-  if (minimalTouch()) return false;   // v1.9.190: minimal-touch — no taskbar work at all-  if (process.platform !== 'win32' || !session || _thumbTooltipSupported === false) return;-  const tip = buildThumbnailTooltip(session, sessionId);-  if (session._thumbTip === tip) return;   // idempotent: only push real changes-  try {-    const r = await chrome.abCommand('desktop_taskbar', {-      ...(await windowTarget(sessions.get(sessionId), sessionId)), thumbnailTooltip: tip,-    }, { timeoutMs: 5000, caller: sessionCaller(session) });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));-    if (_isUnknownVerb(r, raw) || /unknown (arg|field)|unexpected (arg|field)/i.test(raw || '')) {-      _thumbTooltipSupported = false;-      console.log('[thumbtip] AD lacks thumbnailTooltip (needs >=1.9.152) — skipping');-      return;-    }-    _thumbTooltipSupported = true;-    session._thumbTip = tip;-    console.log(`[thumbtip] "${sessionId}" -> ${tip.replace(/\n/g, ' | ')}`);-  } catch (e) {}-}-let _thumbTooltipSupported = null;--async function updateTabCountBadge(session, sessionId) {-  if (minimalTouch()) return;   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome-  if (process.platform !== 'win32' || _identitySupported !== true) return; // overlay busy with brand badge on old AD-  // v1.9.31: a tab verb just ran — cheap moment to notice a drag-out (event-driven, not a scan).-  drainWindowOpenedEvents().catch(() => {});-  // v1.9.59: refresh the hover-preview TOOLTIP here. It was only wired into refreshWindowChrome-  // (drag-out / reclaim / relaunch) and the category-change hook, so the NORMAL OPEN PATH — which-  // calls brandPupWindow directly and never touches either — never set a tooltip at all, and no-  // window that just opened had one. This function is the right home: it already runs on open and on-  // every tab open/close, which is exactly when the tooltip's content (category + tab count) changes.-  // It is idempotent (session._thumbTip) so repeat calls cost nothing.-  updateThumbnailTooltip(session, sessionId).catch(() => {});-  // v1.9.22: if the window's CATEGORY changed (a tab made it mixed, a navigate left the wiki...),-  // re-stamp the base icon NOW — the 10-minute brand debounce is for redundant re-stamps of the-  // SAME icon, not for a category flip the user should see immediately.-  try {-    const cat = pupCategory(sessionId);-    if (cat !== session._lastCat) {-      session._lastCat = cat;-      // v1.9.27: the stamp now swaps to a category-carrying appId, which makes the shell create a-      // FRESH button with the new tile (the only repaint Win11 allows) and handles the AUMID-      // register/unregister + jump-list re-attach itself. v1.9.28: RETRY with backoff — a single-      // silent failure here left the tile latched on the old category (seen live: .mixed AUMID-      // registered but the stamp never applied).-      (async () => {-        for (let i = 0; i < 4; i++) {-          let ok = false;-          try { ok = await brandPupWindow(sessionId); } catch (e) {}-          if (ok) { session._badgedAt = Date.now(); return; }-          await new Promise(r => setTimeout(r, 900 * (i + 1)));-        }-        session._lastCat = undefined;   // same reason as above: never cache an unlanded stamp-        console.log(`[identity] "${sessionId}" category re-stamp exhausted retries (wanted ${cat}) — cache cleared`);-      })().catch(() => {});-    }-  } catch (e) {}-  try {-    // grouped mode: the single stacked button's badge = WINDOW count (per-window tab counts-    // are meaningless on a grouped button). Applied to every window; last write paints the group.-    if (pupSettings().taskbarGrouping === 'grouped') {-      const wins = sessions.size;-      let totalTabs = 0;-      for (const [, s2] of sessions) totalTabs += (s2.tabs || []).length;-      let icon = null;-      if (wins >= 2) icon = (await dualBadgeB64(wins, totalTabs)) || tabBadgeIcon(wins);-      else if (totalTabs >= 2) icon = tabBadgeIcon(totalTabs); // single window: plain tab count-      for (const [sid2] of sessions) {-        const args2 = icon-          ? { ...(await windowTarget(sessions.get(sid2), sid2)), overlay: { icon, tooltip: `Adom Pup — ${wins} window${wins === 1 ? '' : 's'}, ${totalTabs} tab${totalTabs === 1 ? '' : 's'}` } }-          : { ...(await windowTarget(sessions.get(sid2), sid2)), overlay: { badge: 'none' } };-        try { await chrome.abCommand('desktop_taskbar', args2, { timeoutMs: 5000 }); } catch (e) {}-      }-      return;-    }-    // v1.9.0 split-mode overlay RULE (John: favicons > counts): the OVERLAY is the ACTIVE tab's-    // site favicon, whatever the tab count — you always see what you're looking at. The tab count-    // rides in the hover tooltip. Only if the active page has NO favicon do we fall back to a-    // plain count badge (so a many-tab faviconless window isn't a blank Adom tile).-    const count = (session.tabs || []).length;-    const worn = await applyAppOverlay(session, sessionId); // active tab's favicon + count in tooltip-    if (worn) return;-    session._appOverlay = false;-    if (count >= 2) {-      const icon = tabBadgeIcon(count);-      if (!icon) return;-      session._overlayDesc = `count:${count} (no favicon on the active tab, so the tab-count badge)`;-      await chrome.abCommand('desktop_taskbar', {-        ...(await windowTarget(sessions.get(sessionId), sessionId)),-        overlay: { icon, tooltip: `Adom Pup — ${count} tabs` },-      }, { timeoutMs: 5000 });-    } else {-      await chrome.abCommand('desktop_taskbar', {-        ...(await windowTarget(sessions.get(sessionId), sessionId)),-        overlay: { badge: 'none' },-      }, { timeoutMs: 5000 });-    }-  } catch (e) {}-}--// v1.8.79: BRAND SWEEP — unify every live window to the CURRENT identity art. Windows keep-// the identity they were stamped with at birth (per-HWND, AD-cached HICON), so after an icon-// rebrand or a bridge update the taskbar shows MIXED GENERATIONS (John caught a museum of-// today's six releases). The sweep re-asserts each session's title suffix (title churn can-// strand a window un-resolvable) then re-stamps it. Runs at startup (post-reattach) and after-// every rescan.-async function brandSweep(reason) {-  if (minimalTouch()) return;-  const _sweepGen = ++_identityGen;   // every new sweep supersedes ALL previous in-flight identity work   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome-  // v2.0.183 (John, 2nd enum report: "you're spamming enum_windows again"): a sweep re-stamps and-  // NUDGES every window, and each nudge fires a window-opened event whose drain kicked-  // reclaimUnmanagedWindows (an enum) — pup reacting to its own sweep, per window. The per-nudge-  // guard only covered ~6s; a 7-window sweep spans longer. Blanket the whole sweep.-  _nudgeUntil = Date.now() + 45000;-  // v1.9.41 (audit): re-stamp CATEGORY icons for every live session. Windows that predate the-  // category work, or that were recovered across a bridge restart, kept the plain default tile-  // forever because nothing re-evaluated them. Found by auditing my own taskbar: a long-lived-  // window sat on the default icon while its neighbours showed proper categories.-  try {-    for (const [sid, sess] of [...sessions]) {-      sess._lastCat = undefined;               // force a real re-evaluation-      refreshWindowChrome(sess, sid).catch(() => {});-    }-  } catch (e) {}--  if (process.platform !== 'win32') return;-  scheduleIconAudit('after brandSweep: ' + reason);-  let n = 0;-  for (const [sid, sess] of sessions) {-    if (_sweepGen !== _identityGen) { console.log(`[identity] brandSweep(${reason}) ABORTED mid-loop (superseded by a newer mode switch)`); break; }-    try {-      const tab = sess.tabs && sess.tabs[0];-      if (!tab || !tab.page) continue;-      // re-assert the title suffix so AD's resolver can find the window even after title churn-      if (pupSettings().titleTag === 'on') try {-        await tab.page.evaluate((sfx, sid2) => {-          try {-            const esc = sid2.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');-            const re = new RegExp('\\s*\\((?:session|ai-thread): ' + esc + '(?:[^)]*)\\)', 'g');-            const base = String(document.title || '').replace(re, '');   // drop any existing tag (either label)-            const want = base + sfx + ')';-            if (document.title !== want) document.title = want;           // exactly one, new label-          } catch (e) {}-          try { if (window.name !== 'pup:' + sid2) window.name = 'pup:' + sid2; } catch (e) {}-        }, ` (${TITLE_LABEL}: ${sid}`, sid);-      } catch (e) {}-      const ok = await brandPupWindow(sid);-      if (ok) n++;-    } catch (e) {}-  }-  if (n) console.log(`[brand-sweep] (${reason}) re-branded ${n} window(s) to current identity`);-}--// v1.8.92: APP-FAVICON overlay (John's standard: a pup window driven by an app WEARS that-// app's icon). Source of truth = the page's own <link rel="icon"> — the exact artifact the-// adom-ui-design standard mandates every app serve (monochrome white mark; see hd-tab-icons:-// the served favicon is the canonical webview identity, and pup now honors the same contract).-// Explicit overrides: pup_open_window {appIconB64|appIconUrl, appName}. Composited onto a-// dark rounded plate via sharp (white marks need dark backing on the taskbar). An app-looking-// URL (localhost / *.adom.cloud proxy) serving NO favicon gets a loud reprimand hint teaching-// the standard. When an app icon is worn, it takes the overlay slot; the tab count moves to-// the hover tooltip (grouped mode's dual counter is unaffected).-// v1.8.93: WIKI DUAL-VIEW + AUTH. pup drives a fresh anonymous profile by default → the wiki-// renders LOGGED OUT (public view). For the LOGGED-IN view, route to a reserved PERSISTENT-// profile whose on-disk cookie jar holds the SSO session (30-day) — log in once via SSO, reused-// for a month, re-auth only on expiry. No token→cookie exchange exists (wiki auth is cookie-based-// SSO to hydrogen.adom.inc; the container's CLI bearer is a different credential). The two views-// together let a user VERIFY publish visibility — e.g. "did source leak to the public?" (a-// hide_source page shows source to authed, binaries-only to public).-const WIKI_AUTH_PROFILE = 'adom-wiki-authed';-// v1.9.123 (step 2 of John's durable-profile plan) — THE shared, persistent "you" profile.-// Before this, an open with no explicit profile defaulted to the sessionId, i.e. a throwaway-// data dir per window: every new window was signed out of everything and app localStorage-// (e.g. Orbital Lab's saved 3D prefs) vanished. Now the default is ONE durable profile that-// every window shares, so logins/cookies/localStorage ACCUMULATE and are shared across all-// pup windows (the wiki already proved this jar-sharing model with adom-wiki-authed). Each-// window still gets its OWN OS window + taskbar identity — sharing a profile does NOT collapse-// windows into tabs (measured: two sessions on one profile = two windows, two taskbar buttons,-// one Chrome process). Step 1 (reparent so Chrome survives a bridge restart) is what makes a-// durable profile safe: without it, repeated dirty kills would rot the shared jar.-const DEFAULT_PROFILE = 'adom-you';-// v1.8.99: the SSO session covers the whole Adom ecosystem, not just the wiki. Gate the authed-// treatment on ANY adom.inc host (wiki + other Adom properties). NON-Adom sites (ti.com,-// digikey.com, even adom.cloud proxies) get NOTHING — no auth routing, hints, or glyph.-function isAdomUrl(u) { try { const h = new URL(u).hostname; return h === 'adom.inc' || /\.adom\.inc$/i.test(h); } catch (e) { return false; } }-// v2.0.132 (adom/wiki#126, Colby's server-side anonymous render — pup's own ask, now adopted):-// `?view=public` on any wiki page GET makes the SERVER render that one request exactly as a-// signed-out visitor sees it (the request's user is nulled; the real session cookie is untouched;-// verified byte-identical to a no-cookie request, and an owner's private page 404s under it). So-// the public view no longer needs an isolated anonymous profile jar at all — it is a URL rewrite.-function wikiPublicUrl(u) { try { const x = new URL(u); x.searchParams.set('view', 'public'); return x.toString(); } catch (e) { return u; } }-function wikiStripPublicParam(u) { try { const x = new URL(u); if (x.searchParams.get('view') === 'public') { x.searchParams.delete('view'); } return x.toString(); } catch (e) { return u; } }-// Ask the page (using ITS cookies) whether the wiki session is authenticated.-// v1.9.93 (John: "the jump list still blips out") — an INDETERMINATE auth probe must never be-// reported as "logged out". The old version collapsed three different outcomes into {authed:false}:-// a real logged-out answer, a non-ok HTTP response, and a THROWN probe (page mid-navigation, frame-// detached, offline). That made the auth state FLIP-FLOP (authed -> transient failure reads as-// logged-out -> authed again), and each flip re-stamps the window identity, which mints a NEW AUMID,-// which DESTROYS AND RECREATES the taskbar button (documented Win11 behavior). A button destroyed-// under an open right-click flyout is exactly the "menu shows ~300ms then disappears, second click-// works" bug. So: report `unknown:true` when we could not determine, and let callers ignore it.-async function checkWikiAuth(page) {-  try {-    return await page.evaluate(async () => {-      try {-        const r = await fetch('/auth/me', { credentials: 'same-origin' });-        if (!r.ok) return (r.status === 401 || r.status === 403) ? { authed: false } : { authed: false, unknown: true };-        const j = await r.json();-        return { authed: !!(j && j.user), user: j && j.user && (j.user.displayName || j.user.username) };-      }-      catch (e) { return { authed: false, unknown: true }; }   // probe failed — NOT evidence of logout-    });-  } catch (e) { return { authed: false, unknown: true }; }     // evaluate threw — NOT evidence of logout-}-// v1.9.88 — AUTO-SIGN the wiki from AD's stored identity, so pup NEVER makes the user hand-type a-// login into its no-memory browser. John, verbatim: "the ai calling you knows exactly who i am and-// ad knows who i am. so that's 2 potential places to get the info from and then you're asking me to-// login from scratch." Right. AD writes the user's wiki session token to ~/.adom/session.json on-// THIS desktop (the same box the bridge runs on), so pup mints a single-use browser-session magic-// link SERVER-SIDE from that token — the same endpoint an already-authed page uses, but authorized-// by the bearer token instead of a page cookie (verified: POST returns {url, expires_in} for a-// bearer with no browser) — and navigates the vault window straight to it. The human is asked for-// nothing; the shared vault profile then persists the session ~30 days. Manual /login is the-// fallback ONLY when AD itself is signed out (no token) or the session expired.-function readDesktopWikiToken() {-  try {-    const j = JSON.parse(fs.readFileSync(path.join(ADOM_STATE_DIR, 'session.json'), 'utf8'));-    if (!j || !j.session_token) return null;-    const exp = j.expires_at ? Date.parse(j.expires_at) : NaN;-    if (Number.isFinite(exp) && exp < Date.now()) return null;   // expired — force the manual path-    return { token: j.session_token, user: j.user_name || j.user_email || null };-  } catch (e) { return null; }-}-async function mintWikiMagicUrl(nextPath) {-  const tok = readDesktopWikiToken();-  if (!tok) return null;-  try {-    const r = await fetch('https://wiki.adom.inc/api/v1/auth/browser-session', {-      method: 'POST',-      headers: { 'content-type': 'application/json', 'authorization': 'Bearer ' + tok.token, 'user-agent': 'adom-pup-bridge' },-      body: JSON.stringify({ next: nextPath || '/' }),-    });-    if (!r.ok) return null;-    const j = await r.json();-    if (!j || !j.url) return null;-    // The endpoint returns ?ticket=...; the deep-link is appended client-side (same as the in-page-    // mint above + the adom-wiki CLI), else the redeem lands on the wiki ROOT and the user loses the-    // page they were on.-    const sep = j.url.includes('?') ? '&' : '?';-    return j.url + sep + 'next=' + encodeURIComponent(nextPath || '/');-  } catch (e) { return null; }-}-// Sign the given pup page into the wiki vault IN PLACE from the desktop token. Returns the user-// label on success (mint + navigate succeeded), or null to fall back to a manual login. A successful-// mint means the token was accepted (a bad token yields no url), so navigation is the real signal;-// the auth re-check is only to surface the live display name and never gates the result.-async function autoSignWikiFromDesktop(page, nextPath) {-  const magic = await mintWikiMagicUrl(nextPath);-  if (!magic) return null;-  try {-    await page.goto(magic, { waitUntil: 'domcontentloaded', timeout: 25000 });-  } catch (e) { return null; }-  let who = null;-  for (let i = 0; i < 6; i++) {-    let a = null; try { a = await checkWikiAuth(page); } catch (e) {}-    if (a && a.authed) { who = a.user; break; }-    await new Promise(r => setTimeout(r, 1000));-  }-  const tk = readDesktopWikiToken();-  return who || (tk && tk.user) || 'authed';-}-// v1.8.97: gentle IN-TAB mode indicator (John: "indicate which mode that pup window is in ...-// in the chrome ui in a gentle way"). Can't touch the native tab-strip empty space (browser UI,-// not reachable), but the TAB TITLE is: a single leading monochrome glyph shows on the tab.-// U+25CF solid = logged-in wiki view, U+25CB hollow = public/anonymous (geometric text glyphs,-// NOT emoji, per the brand icon law). Idempotent + MutationObserver so the SPA can't wipe-// it, and it doesn't fight the session-suffix injector (that touches the END, this the START).-// v1.9.1: WORD label, not a cryptic emoji (John saw a bare 📖 and had no idea what it meant).-// The tab title leads with "🔓 Logged in · " or "📖 Public · " so the mode is unmistakable.-// v1.9.103 (John: "show that info above the preview") — UNIFIED TITLE DECORATION, one observer.-// The text Windows draws above the taskbar hover-preview IS the window title, and Chrome derives that-// from document.title, so the ONLY way to get the owning AI thread up there is to decorate the title.-// There were already TWO title mutators (this wiki glyph prefixing the START, and the session-suffix-// injector appending the END) and the code comments record why a THIRD is dangerous: two observers-// writing the same region ping-pong forever, peg the renderer and flood CDP (that HUNG pup_open_tab-// on Edge). So instead of adding one, the prefix is now a SINGLE composed slot: an AI-thread tag and the-// wiki label are both written by one applier under one observer, in a fixed order, and stripping only-// ever removes decorations we know we wrote (never a page title that merely contains " · ").-// Result: "adom-tsci · Example Domain (session: x)" — the thread is the FIRST thing visible, so it-// survives the ~25-30 char truncation in the preview header, which is the whole point.-function _titleDecorFn(state) {-  try {-    window.__pupThreadTag = state.threadTag === null ? window.__pupThreadTag : state.threadTag;-    window.__pupWikiLabel = state.wikiLabel === null ? window.__pupWikiLabel : state.wikiLabel;-    const strip = (t) => {-      let out = t;-      const tag = window.__pupThreadTag;-      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++) {}-      return out;-    };-    const apply = () => {-      try {-        const parts = [];-        if (window.__pupThreadTag) parts.push(window.__pupThreadTag);-        if (window.__pupWikiLabel) parts.push(window.__pupWikiLabel);-        const decor = parts.length ? parts.join(' \u00b7 ') + ' \u00b7 ' : '';-        const base = strip(document.title || '');-        const want = decor + base;-        if ((document.title || '') !== want) document.title = want;-      } catch (e) {}-    };-    window.__pupApplyTitleDecor = apply;-    apply();-    const t = document.querySelector('title') || document.head || document.documentElement;-    if (t && !window.__pupDecorObs) {-      try { window.__pupDecorObs = new MutationObserver(apply); window.__pupDecorObs.observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {}-    }-  } catch (e) {}-}-// Set/refresh the decoration. Pass null for a slot you are not changing.-async function setTitleDecor(page, { threadTag = null, wikiLabel = null } = {}) {-  if (pupSettings().titlePrefix !== 'on') return;   // v1.9.286 (John): the "<thread> · " prefix is opt-in-  const st = { threadTag, wikiLabel };-  try { await page.evaluateOnNewDocument(_titleDecorFn, st); } catch (e) {}-  try { await page.evaluate(_titleDecorFn, st); } catch (e) {}-}-// Back-compat shim: every existing caller passes a wiki label and expects the old behaviour.-async function setWikiModeGlyph(page, label) {-  return setTitleDecor(page, { wikiLabel: label });-}--// v1.9.175: `rawView` is args.wikiView VERBATIM ('authed' | 'public' | undefined) so this can tell-// the three genuinely-different cases apart. The old signature collapsed omit and explicit-public-// into one 'public' and told the caller "you're logged out, this is what the world sees" for BOTH —-// but OMITTING wikiView uses the shared adom-you profile, which is LOGGED IN (the one-shared-profile-// model). Only an explicit wikiView:"public" forces an isolated anonymous jar. An agent verifying-// "did private source leak to the public?" that trusted the old hint would inspect the user's-// LOGGED-IN view and wrongly report it world-visible.-function wikiViewHint(url, rawView, auth) {-  if (!isAdomUrl(url)) return '';-  if (rawView === 'authed') {-    if (auth && auth.authed) return ` 🔓 Wiki opened LOGGED IN BY DEFAULT (as ${auth.user || 'the user'}) — pup auto-signed you in from the desktop token, no prompt. You see private source, drafts, owner-only cards, reply/edit. To instead see the ANONYMOUS/PUBLIC view (exactly what the world sees — e.g. verify a publish didn't leak private source), reopen with noAutoLogin:true (or wikiView:"public") — the server then renders it exactly as a signed-out visitor (?view=public, adom/wiki#126), same window, session untouched.`;-    return ` 🔑 Wiki authed view requested, but NO ONE has done the one-time login yet (or the ~30-day session expired) — so this window is currently logged-out. A thread can't self-login (it's the user's SSO). Offer it: pup_raise_os_window {foregroundReason:"sign into the Adom wiki"} → user does the hydrogen.adom.inc SSO ONCE → then EVERY thread's wikiView:"authed" is silently logged in for ~30 days (shared vault profile). Tell the user it's a one-time login that unlocks the logged-in view everywhere.`;-  }-  if (rawView === 'public') {-    // EXPLICIT public — the server renders the request anonymously via ?view=public-    // (adom/wiki#126): the one true "what the world sees" view, no isolated jar needed.-    return ` (📖 Wiki PUBLIC view — the SERVER rendered this page exactly as a signed-out visitor sees it (?view=public, adom/wiki#126: request user nulled, byte-identical to logged-out, private pages 404). The window stays on the shared profile with the session untouched. Use this to verify a publish didn't leak private source. For the user's logged-in view, use wikiView:"authed".)`;-  }-  // OMITTED wikiView — the shared adom-you profile. LOGGED IN if the user has signed into the wiki-  // (the default, since the wiki login lives on the shared profile now), logged out otherwise. This-  // is NOT guaranteed anonymous — do not treat it as "the public view".-  return ` (📖 Wiki on the SHARED profile — LOGGED IN as the user if they've signed into the wiki (the usual case, one shared login serves every window), logged out only if they never have. This is NOT the guaranteed-anonymous view: to see exactly what the world sees (e.g. confirm a publish didn't leak private source), pass wikiView:"public" (server-rendered anonymous view via ?view=public — same window, session untouched). For the explicit logged-in view, pass wikiView:"authed".)`;-}--const _appOverlayCache = new Map(); // sessionId -> composited b64-// v1.9.204 (John: "the active tab in that first taskbar icon is this you fucktard" — a tab that-// plainly HAS a favicon, while pup reported "no favicon on the active tab"). ROOT CAUSE: pup picked-// the tab from its OWN bookkeeping (session.activeTabId), which drifts out of sync the moment the user-// switches tabs themselves — the same drift that made the unstick misfire. He asked for "the favicon-// from the chrome tab that's in the foreground", so ASK CHROME: in a given window exactly one tab-// reports document.visibilityState === 'visible', and that IS the active tab.-async function resolveVisibleTab(session) {-  const tabs = (session.tabs || []).filter(t => t && t.page && !t._crashed);-  if (!tabs.length) return null;-  if (tabs.length === 1) return tabs[0];-  for (const t of tabs) {-    try {-      const v = await withTimeout(t.page.evaluate('document.visibilityState'), 1200, 'vis');-      if (v === 'visible') return t;-    } catch (e) {}-  }-  return (session.tabs || []).find(t => t.tabId === session.activeTabId) || tabs[0];-}--// v1.9.203 (John: "in the 3rd taskbar icon you only have the little teal adom favicon and not the-// main favicon from the active tab"). ROOT CAUSE: sharp CANNOT decode .ico, and the compositor only-// built the favicon layer when the source was NOT an .ico — so every site serving favicon.ico silently-// lost its favicon and the badge rendered as the Adom mark alone. Most modern .ico files embed a PNG-// per size, so parse the container, take the LARGEST entry, and hand its PNG bytes to sharp.-// ICO layout: 6-byte header (count at offset 4), then 16-byte directory entries-// (w,h,colors,rsv,planes,bpp,bytesLE@8,offsetLE@12).-function icoLargestPng(buf) {-  try {-    if (!buf || buf.length < 22) return null;-    if (buf.readUInt16LE(0) !== 0 || buf.readUInt16LE(2) !== 1) return null;   // not an ICO-    const n = buf.readUInt16LE(4);-    if (!n) return null;-    let best = null;-    for (let i = 0; i < n; i++) {-      const e = 6 + i * 16;-      if (e + 16 > buf.length) break;-      const w = buf[e] === 0 ? 256 : buf[e];-      const h = buf[e + 1] === 0 ? 256 : buf[e + 1];-      const size = buf.readUInt32LE(e + 8);-      const off = buf.readUInt32LE(e + 12);-      if (off + size > buf.length || size <= 0) continue;-      const area = w * h;-      if (!best || area > best.area) best = { area, off, size };-    }-    if (!best) return null;-    const data = buf.subarray(best.off, best.off + best.size);-    // PNG-in-ICO (the common modern case) — usable directly.-    if (data.length > 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) return data;-    return null;   // BMP/DIB-encoded entry — sharp can't read it either; caller falls back-  } catch (e) { return null; }-}--function icoLargestDibRgba(buf) {-  // v2.0.212 (#55 root cause, at last): ti.com and mouser.com ship CLASSIC BMP-in-ICO favicons-  // (32bpp uncompressed DIB entries, zero PNG entries). icoLargestPng only handles PNG-in-ICO, so-  // analyzeIconBytes judged these real, correctly-fetched icons "unreadable" and the badge went-  // generic — the network rails were never the problem. Decode the largest 32bpp DIB to raw RGBA-  // for sharp. Rows are bottom-up BGRA; height in the info header is doubled (XOR + AND mask);-  // an all-zero alpha channel means "use the AND mask", so apply it in that case.-  try {-    if (!buf || buf.length < 22) return null;-    if (buf.readUInt16LE(0) !== 0 || buf.readUInt16LE(2) !== 1) return null;-    const n = buf.readUInt16LE(4);-    let best = null;-    for (let i = 0; i < n; i++) {-      const e = 6 + i * 16;-      if (e + 16 > buf.length) break;-      const w = buf[e] === 0 ? 256 : buf[e];-      const h = buf[e + 1] === 0 ? 256 : buf[e + 1];-      const size = buf.readUInt32LE(e + 8);-      const off = buf.readUInt32LE(e + 12);-      if (off + size > buf.length || size <= 40) continue;-      const d = buf.subarray(off, off + size);-      if (d[0] === 0x89 && d[1] === 0x50) continue;                       // PNG entry — icoLargestPng's job-      if (d.readUInt32LE(0) !== 40) continue;                             // only plain BITMAPINFOHEADER-      if (d.readUInt16LE(14) !== 32 || d.readUInt32LE(16) !== 0) continue; // only 32bpp uncompressed-      if (!best || w * h > best.w * best.h) best = { w, h, d };-    }-    if (!best) return null;-    const { w, h, d } = best;-    const stride = w * 4;-    if (40 + stride * h > d.length) return null;-    const out = Buffer.alloc(w * h * 4);-    let maxA = 0;-    for (let y = 0; y < h; y++) {-      const srow = 40 + (h - 1 - y) * stride, drow = y * stride;-      for (let x = 0; x < w; x++) {-        const si = srow + x * 4, di = drow + x * 4;-        out[di] = d[si + 2]; out[di + 1] = d[si + 1]; out[di + 2] = d[si]; out[di + 3] = d[si + 3];-        if (d[si + 3] > maxA) maxA = d[si + 3];-      }-    }-    if (maxA === 0) {                                                     // pre-alpha ICO: AND mask rules-      const maskStride = Math.ceil(w / 32) * 4, maskOff = 40 + stride * h;-      for (let y = 0; y < h; y++) {-        const mrow = maskOff + (h - 1 - y) * maskStride;-        for (let x = 0; x < w; x++) {-          const bit = mrow + (x >> 3) < d.length ? (d[mrow + (x >> 3)] >> (7 - (x & 7))) & 1 : 0;-          out[y * stride + x * 4 + 3] = bit ? 0 : 255;-        }-      }-    }-    return { rgba: out, w, h };-  } catch (e) { return null; }-}--// v1.9.238 (John: "favicons these days can be svg or png, dark mode or light mode, transparent... you-// seem to not be doing much logic there... determine which favicon to retrieve to have it show the-// best/nicest in the windows taskbar"). A proper resolver that (1) enumerates EVERY declared icon with-// its type/sizes/media, (2) picks the best for a DARK taskbar — a site's dark-mode variant is the art-// designed to sit on dark, so it wins; a light-mode variant is usually dark ink that vanishes on the-// taskbar, so it loses — then SVG > sized PNG > apple-touch > ICO for crispness, (3) fetches with the-// page's cookies (authed icons), (4) rasterizes and MEASURES transparency + luminance so a dark-on-dark-// icon can be detected and skipped for a lighter sibling. Returns the full acquisition STATE so the-// dashboard can show, per tab, exactly how the icon was obtained.-const _tabFavMeta = new Map();   // page-url -> { meta, at }-// the generic tab glyph: a neutral page outline, monochrome per the icon rules-const GENERIC_TAB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="1.5" width="10" height="13" rx="1.5" fill="none" stroke="#8b949e" stroke-width="1.3"/><path d="M5.5 5h5M5.5 7.5h5M5.5 10h3.5" stroke="#8b949e" stroke-width="1.1" stroke-linecap="round"/></svg>';-const _dashToasts = []; let _dashToastId = 0;   // /ui/toast narration queue, delivered over the SSE-async function enumerateFaviconCandidates(page) {-  try {-    return await withTimeout(page.evaluate(() => {-      const q = 'link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"], link[rel="mask-icon"]';-      const links = Array.from(document.querySelectorAll(q));-      const out = links.map(l => ({-        href: l.href, type: (l.type || '').toLowerCase(), sizes: (l.getAttribute('sizes') || '').toLowerCase(),-        media: (l.media || l.getAttribute('media') || '').toLowerCase(), rel: (l.getAttribute('rel') || '').toLowerCase(),-      })).filter(c => c.href && c.href !== 'data:,' && !/^data:,?$/.test(c.href));-      try { out.push({ href: new URL('/favicon.ico', location.origin).href, type: '', sizes: '', media: '', rel: 'icon', synthetic: true }); } catch (e) {}-      return out;-    }), 2500, 'fav-enum').catch(() => []);-  } catch (e) { return []; }-}-function scoreFaviconCandidate(c) {-  let s = 0;-  const m = c.media || '';-  if (/prefers-color-scheme:\s*dark/.test(m)) s += 100;        // taskbar is dark → its dark-mode art-  else if (/prefers-color-scheme:\s*light/.test(m)) s -= 40;   // light-mode art = dark ink, dies on dark-  const base = (c.href || '').split('?')[0];-  const ext = (base.split('.').pop() || '').toLowerCase();-  const isSvg = c.type.includes('svg') || ext === 'svg';-  const isPng = c.type.includes('png') || ext === 'png';-  if (isSvg) s += 50;                                          // crispest at every size-  else if (c.rel.includes('apple-touch')) s += 35;            // usually a large clean PNG-  else if (isPng) { s += 30; const big = parseInt((c.sizes.match(/(\d+)x/) || [])[1] || '0', 10); s += Math.min(big, 256) / 16; }-  else if (ext === 'ico' || c.type.includes('icon')) s += 10;-  if (c.rel.includes('mask-icon')) s -= 60;                    // monochrome Safari pin, not a real favicon-  if (c.synthetic) s -= 5;-  return s;-}-function classifyMode(media) {-  if (/prefers-color-scheme:\s*dark/.test(media || '')) return 'dark';-  if (/prefers-color-scheme:\s*light/.test(media || '')) return 'light';-  return media ? 'media' : 'default';-}-async function fetchIconBytes(page, href) {-  // in-page (cookies) → bridge fetch → local file, mirroring fetchPageFavicon's ladder.-  if (/^data:/i.test(href)) {-    const m = href.match(/^data:([^;,]*)(;base64)?,(.*)$/);-    if (m && m[3]) { const b64 = m[2] ? m[3] : Buffer.from(decodeURIComponent(m[3])).toString('base64'); if (b64) return { b64, mime: m[1] || 'image/png', via: 'inline' }; }-    return null;-  }-  if (/^file:/i.test(href)) {-    try { const fp = decodeURIComponent(new URL(href).pathname).replace(/^\/([A-Za-z]:)/, '$1'); const buf = fs.readFileSync(fp); if (buf && buf.length) return { b64: buf.toString('base64'), mime: '', via: 'disk' }; } catch (e) {}-    return null;-  }-  try {-    const inPage = await withTimeout(page.evaluate(async (u) => {-      try { const r = await fetch(u, { credentials: 'include', cache: 'force-cache' }); if (!r.ok) return null; const b = await r.blob(); if (!b.size) return null;-        const b64 = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(String(fr.result).split(',')[1]); fr.onerror = rej; fr.readAsDataURL(b); });-        return b64 ? { b64, mime: b.type || '' } : null; } catch (e) { return null; }-    }, href), 4000, 'fav-inpage').catch(() => null);-    if (inPage && inPage.b64) return { b64: inPage.b64, mime: inPage.mime || '', via: 'in-page (cookies)' };-  } catch (e) {}-  try {-    const r = await fetch(href, { redirect: 'follow', signal: AbortSignal.timeout(4000) });-    if (r && r.ok) { const buf = Buffer.from(await r.arrayBuffer()); if (buf.length) return { b64: buf.toString('base64'), mime: r.headers.get('content-type') || '', via: 'bridge fetch' }; }-  } catch (e) {}-  return null;-}-async function analyzeIconBytes(b64, mime, href) {-  // Rasterize to PNG and measure transparency + luminance. Returns null if truly undecodable.-  const buf = Buffer.from(b64, 'base64');-  const ext = ((href || '').split('?')[0].split('.').pop() || '').toLowerCase();-  let format = 'png', input = buf, _rawOpts = null;-  if (mime.includes('svg') || ext === 'svg') format = 'svg';-  else if (mime.includes('png') || ext === 'png') format = 'png';-  else if (mime.includes('jpeg') || ext === 'jpg' || ext === 'jpeg') format = 'jpeg';-  else if (mime.includes('icon') || mime.includes('ico') || ext === 'ico') { format = 'ico'; const inner = icoLargestPng(buf); if (inner) input = inner; else { const dib = icoLargestDibRgba(buf); if (dib) { input = dib.rgba; _rawOpts = { raw: { width: dib.w, height: dib.h, channels: 4 } }; } } }-  try {-    // v1.9.240: RASTERIZE at a fixed size FIRST, then measure. An SVG with only a viewBox (no-    // width/height — like our own icon.svg) has no intrinsic size, so sharp.stats() on the raw bytes-    // throws "unsupported image" and the icon was wrongly judged unreadable. A high render density-    // keeps the raster crisp; measuring the PNG (always decodable) makes transparency/luminance robust.-    const S = require('sharp');   // module-scope var is named `sharp`; other fns re-require locally — do the same-    const opts = _rawOpts ? _rawOpts : (format === 'svg' ? { density: 384 } : {});-    const pngBuf = await S(input, opts).resize(48, 48, { fit: 'inside', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();-    const stats = await S(pngBuf).stats();-    const ch = stats.channels || [];-    const lum = ch.length >= 3 ? (0.299 * ch[0].mean + 0.587 * ch[1].mean + 0.114 * ch[2].mean) : (ch[0] ? ch[0].mean : 128);-    return { pngBuf, b64: pngBuf.toString('base64'), format, transparent: !stats.isOpaque, darkDominant: lum < 80, lum: Math.round(lum) };-  } catch (e) { return { pngBuf: null, b64: null, format, transparent: false, darkDominant: false, lum: null, unreadable: true }; }-}-async function resolveFaviconRich(page) {-  const cands = await enumerateFaviconCandidates(page);-  if (!cands.length) return null;-  const ranked = cands.map(c => ({ ...c, score: scoreFaviconCandidate(c) })).sort((a, b) => b.score - a.score);-  let firstReadable = null;-  for (const c of ranked) {-    const bytes = await fetchIconBytes(page, c.href).catch(() => null);-    if (!bytes || !bytes.b64) continue;-    const an = await analyzeIconBytes(bytes.b64, bytes.mime || c.type, c.href);-    if (!an || an.unreadable || !an.b64) continue;-    const meta = {-      b64: an.b64, mime: 'image/png', href: c.href, format: an.format, mode: classifyMode(c.media),-      transparent: an.transparent, darkDominant: an.darkDominant, luminance: an.lum, sizes: c.sizes || null,-      via: bytes.via, kind: c.synthetic ? 'favicon.ico' : 'page-favicon', candidates: cands.length,-      note: an.darkDominant && an.transparent ? 'dark + transparent (risk of vanishing on the dark taskbar)' : null,-    };-    if (!firstReadable) firstReadable = meta;-    // A dark-on-dark transparent icon vanishes on the dark taskbar — keep scanning for a lighter sibling-    // (typically the dark-mode media variant). If none is better, we still return the first readable one.-    if (an.darkDominant && an.transparent) continue;-    return meta;-  }-  return firstReadable;   // best-effort: readable but possibly dark-on-dark-}-// v2.0.210 (#55 final): fetch a favicon through CHROME's own network stack via CDP-// Network.loadNetworkResource — hostile CDNs fingerprint TLS, so node/curl get blocked where the-// real browser passes. Returns a Buffer or null.-async function fetchFaviconViaChrome(page, url) {-  try {-    const client = typeof page._client === 'function' ? page._client() : page._client;-    if (!client) return null;-    const frameId = page.mainFrame() && page.mainFrame()._id;-    const r = await client.send('Network.loadNetworkResource', { frameId, url, options: { disableCache: false, includeCredentials: true } });-    if (!r || !r.resource || !r.resource.success || !r.resource.stream) return null;-    const parts = [];-    for (;;) {-      const chunk = await client.send('IO.read', { handle: r.resource.stream, size: 65536 });-      parts.push(chunk.base64Encoded ? Buffer.from(chunk.data, 'base64') : Buffer.from(chunk.data, 'utf8'));-      if (chunk.eof) break;-    }-    client.send('IO.close', { handle: r.resource.stream }).catch(() => {});-    const buf = Buffer.concat(parts);-    return buf.length > 100 ? buf : null;-  } catch (e) { console.log(`[overlay] CDP favicon fetch threw: ${e && e.message}`); return null; }-}-async function applyAppOverlay(session, sessionId) {-  // v1.9.191 (John): overlays are the GENTLE branding path and are ALLOWED in minimal-touch.-  // ITaskbarList3::SetOverlayIcon paints a badge onto the EXISTING taskbar button; it never-  // destroys and recreates that button the way an AppUserModelID change does (which is what made-  // the old branding so invasive on SPAs that re-registered constantly). So in minimal mode pup-  // stamps NO identity and rebuilds NO jump list, but it DOES wear one composed badge per window.-  if (process.platform !== 'win32' || _identitySupported !== true) return false;-  if (pupSettings().overlayBadges !== 'on') return false;   // v1.9.259 user pref-  // v2.0.167 (John: "i don't see an overlay icon though, so that's sad"): thread tiles DO wear the-  // favicon overlay badge after all. The 2.0.159 suppression was a misdiagnosis — the "globe covering-  // the tile" was the stale category BUTTON ART (the ab icon-handle cache bug, fixed in 2.0.166), not-  // the overlay. The overlay is a small corner badge; the name stays readable. On a grouped thread-  // button Windows shows the overlay of the most recently updated window in the group — acceptable.-  // v2.0.168 THE LATCH GUARD (the 2.0.167 regression): SetOverlayIcon on a window still wearing the-  // DEFAULT CfT identity latches the shell's button association, and the thread stamp that follows-  // never moves the window to its thread button. So for a thread-eligible window the overlay paints-  // ONLY once the thread appId has landed, plus a settle beat for the shell to finish the move.-  // No 30s-grace fallback here — painting the pre-thread button is exactly the latch that breaks the-  // direct CfT -> thread-tile transition (John's rule).-  // v2.0.178 (John: dashboard button had NO overlay, "shame on you"): the latch guard below must-  // only gate sessions whose DESIRED identity is a thread tile. The dashboard has an owner-  // ("pup-dashboard") but deliberately wears its own Adom.Pup.Dashboard identity, never a thread-  // one — so the old guard blocked its overlay FOREVER. Same _dashInfra carve-out as the stamp.-  if (pupSettings().threadTiles === 'on' && sessionId !== 'pup-dashboard' && session && (session.owner || session._ownerThread)) {-    if (!(session._curAppId || '').startsWith(`${PUP_APP_ID}.thread.`)) return false;-    if (!session._threadStampAt || (Date.now() - session._threadStampAt) < 5000) return false;-  }-  // v1.9.281 THE RESURRECTION GUARD (found by ralph: a reaped session's badge kept reappearing).-  // applyAppOverlay is reached from several deferred setTimeout retries (favicon retry, post-nav,-  // heal) that CAPTURE the session object. If the window closed in the meantime, those late calls-  // recomposed and rewrote the badge PNG for a DEAD session — so the reconciler deleted it and the-  // stale paint immediately recreated it, forever. Refuse to paint a session that is no longer live-  // (dropped from the map, or all its windows closed). This is the choke point that kills the ghost.-  if (!sessions.has(sessionId)) return false;-  // v2.0.25 ORDERING (John watched it live: overlay landed on the still-Chrome button, then the-  // identity stamp RECREATED the button — wiping the badge — then the overlay painted again; three-  // visible mutations for one open). When AUMID identity is on, a YOUNG window's overlay defers-  // until the identity stamp has landed (the stamp marks the fresh button bare, which re-triggers-  // this paint), so the sequence is stamp-once -> overlay-once. After a 30s grace the overlay paints-  // regardless, so a window whose stamp cannot land (unresolvable hwnd) is never left bare forever.-  if (pupSettings().aumidIcons === 'on' && session && !session._curAppId && !_earlyStamped.has(sessionId)-      && session.createdAt && (Date.now() - session.createdAt) < 30000) return false;-  { const _live = sessions.get(sessionId); let _open = false; for (const t of ((_live && _live.tabs) || [])) { try { if (t && t.page && !t.page.isClosed()) { _open = true; break; } } catch (e) {} } if (!_open) return false; }-  try {-    // v1.9.0: reflect the ACTIVE tab (John: favicons are more useful than counts — show what-    // the user is looking at). Falls back to session.page.-    // v1.9.204: the ACTUALLY-visible tab, per Chrome — not pup's possibly-stale activeTabId.-    const activeTab = (await resolveVisibleTab(session)) || (session.tabs || [])[0];-    const activePage = (activeTab && activeTab.page) || session.page;-    if (activeTab && activeTab.tabId && session.activeTabId !== activeTab.tabId) {-      session.activeTabId = activeTab.tabId;   // keep bookkeeping honest for everything downstream-    }-    let src = session._appIconB64 ? { b64: session._appIconB64, mime: session._appIconMime || 'image/png', href: 'app-provided' } : null;-    if (src) session._overlayFav = { kind: 'app-provided', format: 'png', mode: 'default', transparent: null, via: 'pup_open_window appIconB64', href: 'app-provided' };-    if (!src) {-      // v1.9.238: rich, dark-taskbar-aware resolution + full state capture.-      const rich = await resolveFaviconRich(activePage).catch(() => null);-      if (rich && rich.b64) { src = { b64: rich.b64, mime: rich.mime, href: rich.href }; session._overlayFav = rich; try { session._lastRealSrc = { b64: rich.b64, mime: rich.mime, href: rich.href, forUrl: (activePage && activePage.url()) || null }; } catch (e) {} }-    }-    if (!src || !src.b64) {-      // v1.9.43: RETRY once shortly after. The old code gave up silently on the first miss, so a-      // favicon that simply had not loaded yet meant NO overlay for the life of the window. That-      // is why some pup icons had overlays and others did not: it was a race, not a rule.-      if (!session._faviconRetried) {-        session._faviconRetried = true;-        setTimeout(() => { applyAppOverlay(session, sessionId).catch(() => {}); }, 2500);-      }-      // v1.9.71: log the missing-favicon case ONCE per session. It was emitted on every tab-badge-      // update, so a faviconless page produced a line per verb — non-consecutive, so the dedupe-      // collapser could not catch it. The fact is static per page; repeating it says nothing new.-      if (!session._noFavLogged) { session._noFavLogged = true; console.log(`[overlay] "${sessionId}" no favicon on the active tab — trying sibling tabs, then the generic glyph`); }-      // v1.9.205 (John: "why does the first taskbar icon still not have a main overlay icon?"): a-      // badge must ALWAYS have a main image. Bailing out here left the button showing the bare Adom-      // mark (or nothing), which is exactly what he kept seeing. Chrome itself never shows "nothing" —-      // a faviconless page gets its generic globe. So: (a) try SIBLING tabs in this window, since a-      // multi-tab window usually has at least one real icon and that is far more informative than a-      // placeholder; (b) if none has one, draw a generic page/globe glyph. Either way the badge keeps-      // its structure — main image + Adom mark inset in the corner — so every pup button looks alike.-      try {-        for (const t of (session.tabs || [])) {-          if (!t || !t.page || t === activeTab || t._crashed) continue;-          const sib = await resolveFaviconRich(t.page).catch(() => null);-          if (sib && sib.b64) {-            const _sk = `${t.tabId}|${sib.href || ''}`;-            if (session._sibFavLogKey !== _sk) { session._sibFavLogKey = _sk; console.log(`[overlay] "${sessionId}" using sibling tab ${t.tabId}'s favicon (active tab has none)`); }-            src = { b64: sib.b64, mime: sib.mime, href: sib.href };-            session._overlayFav = { ...sib, kind: 'sibling-tab-favicon' };-            break;-          }-        }-      } catch (e) {}-      // #globe-flash (John: "skip the generic-globe intermediate after a re-stamp"): NEVER downgrade a-      // window's REAL favicon to the generic globe. If we couldn't re-resolve it THIS cycle but the page-      // is the SAME one we last painted a real favicon for (the classic case: an icon-gen re-stamp-      // recreated the button and repainted before the favicon was re-fetchable), reuse that last real-      // favicon instead of flashing the globe. Gated on the URL being unchanged, so a genuine navigation-      // to a truly faviconless page still correctly falls through to the generic glyph below.-      if ((!src || !src.b64) && session._lastRealSrc && session._lastRealSrc.b64) {-        let _curUrl = ''; try { _curUrl = (activePage && activePage.url()) || ''; } catch (e) {}-        // v2.0.199 (John's syr-renta/fusio-bri generic globes): key the reuse to the ORIGIN, not the-        // exact URL — same-site navigation kept the same favicon but invalidated the cache, so churny-        // sessions downgraded to the globe and STAYED there. Unreadable current URL (busy page) also-        // reuses: a stale real favicon beats a wrong generic every time.-        const _org = (u) => { try { return new URL(u).origin; } catch (e) { return null; } };-        if (session._lastRealSrc.forUrl && (!_curUrl || _org(session._lastRealSrc.forUrl) === _org(_curUrl))) {-          src = { b64: session._lastRealSrc.b64, mime: session._lastRealSrc.mime, href: session._lastRealSrc.href };-          session._overlayFav = { kind: 'page-favicon', format: 'png', mode: 'default', transparent: null, via: 'reused last real favicon (re-fetch missed this cycle — no downgrade to generic)', href: src.href };-          console.log(`[overlay] "${sessionId}" reused last real favicon (${src.href}) instead of the generic globe — same page, favicon just not re-fetchable this cycle`);-        }-      }-      // v2.0.211 (#55): when the in-page favicon path yields nothing, fetch <origin>/favicon.ico-      // out-of-band. TWO rails, each honestly logged: (1) bridge-side node fetch with a browser UA;-      // (2) Chrome's OWN network stack via CDP Network.loadNetworkResource (survives TLS-      // fingerprinting that kills node). The 2.0.209/210 versions failed SILENTLY here: Akamai's bot-      // wall answers 200 with an HTML challenge page, so the node rail "succeeded" with unreadable-      // bytes, skipped the CDP rail, and fell to the generic glyph without a single log line.-      if (!src || !src.b64) {-        try {-          let _u9 = ''; try { _u9 = (activePage && activePage.url()) || ''; } catch (e) {}-          const _o9 = (() => { try { const u = new URL(_u9); return /^https?:$/.test(u.protocol) && !/^(localhost|127\.)/.test(u.hostname) ? u.origin : null; } catch (e) { return null; } })();-          // v2.0.254: NEGATIVE CACHE per origin. 2.0.229 bounded the SUMMARY line (_bothRailsLogKey)-          // but not the work or the two rail-failure lines beneath it, so every overlay re-assert on a-          // faviconless page re-ran BOTH network fetches (a 5s-timeout node fetch + a CDP fetch) and-          // logged two more lines, forever. Measured live on ConfRoomROG 2026-08-20: three lines per-          // pass filled the entire log tail — the exact "the diagnosis died with it" trap the icon-audit-          // skill warns about, and it degraded a real investigation. One attempt per origin per 30 min.-          if (_o9 && _faviconNegCache.get(_o9) > Date.now() - 30 * 60000) {-            // known faviconless — fall straight through to the generic glyph, silently-          } else if (_o9) {-            const _icoUrl = _o9 + '/favicon.ico';-            const _tryIcon = async (buf, mime, rail) => {-              if (!buf || buf.length <= 100) return false;-              const _an = await analyzeIconBytes(buf.toString('base64'), mime || 'image/x-icon', _icoUrl).catch(() => null);-              if (_an && _an.b64 && !_an.unreadable) {-                src = { b64: _an.b64, mime: 'image/png', href: _icoUrl, darkDominant: _an.darkDominant, transparent: _an.transparent };-                session._overlayFav = { kind: 'page-favicon', format: _an.format, mode: 'default', transparent: _an.transparent, darkDominant: _an.darkDominant, via: rail + ' fetch (in-page favicon path came up empty)', href: _icoUrl };-                try { session._lastRealSrc = { b64: src.b64, mime: src.mime, href: _icoUrl, forUrl: _u9 }; } catch (e) {}-                console.log(`[overlay] "${sessionId}" favicon recovered ${rail} from ${_icoUrl}`);-                return true;-              }-              console.log(`[overlay] "${sessionId}" ${rail} fetch got ${buf.length}B from ${_icoUrl} but it is NOT a readable icon (bot-wall HTML?)`);-              return false;-            };-            let _got = false;-            const _ctl = new AbortController(); const _tm = setTimeout(() => _ctl.abort(), 5000);-            const _resp = await fetch(_icoUrl, { redirect: 'follow', signal: _ctl.signal,-              headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 'Accept': 'image/*,*/*;q=0.8' } }).catch(() => null);-            clearTimeout(_tm);-            if (_resp && _resp.ok) {-              _got = await _tryIcon(Buffer.from(await _resp.arrayBuffer()), _resp.headers.get('content-type'), 'bridge-side');-            } else {-              console.log(`[overlay] "${sessionId}" bridge-side favicon fetch: ${_resp ? 'HTTP ' + _resp.status : 'network error/timeout'} for ${_icoUrl}`);-            }-            if (!_got) {-              const _cb = await fetchFaviconViaChrome(activePage, _icoUrl);-              if (_cb) _got = await _tryIcon(_cb, null, 'via-Chrome-CDP');-              else console.log(`[overlay] "${sessionId}" via-Chrome-CDP favicon fetch returned nothing for ${_icoUrl}`);-            }-            if (_got) _faviconNegCache.delete(_o9);-            else {-              _faviconNegRemember(_o9);-              if (session._bothRailsLogKey !== _u9) { session._bothRailsLogKey = _u9; console.log(`[overlay] "${sessionId}" favicon fetch failed on BOTH rails for ${_o9} — generic it is; not retrying this origin for 30 min`); }-            }-          }-        } catch (e) { console.log(`[overlay] "${sessionId}" favicon fallback threw: ${e && e.message}`); }-      }-      if (!src || !src.b64) {-        // Generic glyph: a teal-on-dark globe, same silhouette Chrome falls back to.-        // v1.9.206: the glyph must be BRIGHT and BOLD. My first version used a #123033 plate with-        // 2.2px strokes — nearly the taskbar's own colour, and far too fine to survive the ~16px slot,-        // so the whole glyph vanished and only the teal corner mark read (the exact dark-on-dark-        // mistake made earlier today with the badge plate). Solid bright teal fill, dark heavy strokes.-        try {-          // v1.9.208: raw pixels, NOT SVG (sharp throws on SVG without librsvg — see note below).-          const sharpG = require('sharp');-          // v1.9.213: my own 1.9.210 revert left a deliberate throw HERE, and this path runs BEFORE the-          // 1.9.211 generic-icon code — so every faviconless window died on this line and reported-          // "glyph render failed" instead of getting the Chrome-style globe / PDF icon. Two competing-          // no-favicon paths existed and I only converted the second one. Build the generic icon here-          // too, choosing the PDF sheet for a .pdf tab and the grey globe otherwise.-          let _u2 = '';-          try { _u2 = (activePage && activePage.url()) || ''; } catch (e) {}-          const _isPdf2 = /\.pdf(\?|#|$)/i.test(_u2);-          const _svg2 = genericIconSvg(_isPdf2);-          src = { b64: Buffer.from(_svg2).toString('base64'), mime: 'image/svg+xml', href: _isPdf2 ? 'generic:pdf' : 'generic:page' };-          session._overlayFav = { kind: _isPdf2 ? 'generic-pdf' : 'generic-web', format: 'svg', mode: 'default', transparent: false, via: 'generated (no favicon existed anywhere in this window)', href: src.href, candidates: 0 };-          if (session._noFavLogKey !== _u2) { session._noFavLogKey = _u2; console.log(`[overlay] "${sessionId}" no favicon in this session — generic ${_isPdf2 ? 'PDF' : 'webpage'} icon + Adom mark`); }-          // v2.0.199: generic must not be a terminal state — the page often becomes readable moments-          // later (post-respawn churn). One re-resolve per generic paint, 30s out.-          // v2.0.229 (found while auditing icons: a single example.com window produced an endless-          // 30s loop of two failed fetch rails, and the noise pushed every other line out of the-          // log ring — it destroyed the audit trail I needed). A page that genuinely has no favicon-          // never grows one, so retry a FEW times, then stop until the page actually NAVIGATES.-          const _genUrl = (() => { try { return (activePage && activePage.url()) || ''; } catch (e) { return ''; } })();-          if (session._genericRetryUrl !== _genUrl) { session._genericRetryUrl = _genUrl; session._genericRetries = 0; }-          const GENERIC_MAX_RETRIES = 3;-          if (!session._genericRetryTimer && (session._genericRetries || 0) < GENERIC_MAX_RETRIES) {-            session._genericRetries = (session._genericRetries || 0) + 1;-            const _n = session._genericRetries;-            session._genericRetryTimer = setTimeout(() => {-              session._genericRetryTimer = null;-              try { if (sessions.has(sessionId)) { session._appOverlay = false; session._lastPaintSig = null; applyAppOverlay(session, sessionId).catch(() => {}); } } catch (e) {}-            }, 30000 * _n);   // 30s, 60s, 90s — then quiet until the next navigation-          } else if ((session._genericRetries || 0) >= GENERIC_MAX_RETRIES && !session._genericGaveUpLogged) {-            session._genericGaveUpLogged = true;-            console.log(`[overlay] "${sessionId}" this page has no favicon after ${GENERIC_MAX_RETRIES} tries — keeping the generic glyph and going QUIET until it navigates`);-          }--        } catch (e) {-          session._appOverlay = false;-          session._overlayDesc = 'none (no favicon and glyph render failed)';-          return false;-        }-      }-    }-    session._faviconRetried = false;-    // v1.9.16 (John's idea, and the right one): the favicon KEEPS the overlay slot — we only-    // recolour the PLATE it sits on. Signed in to the Adom wiki -> green plate; everything else-    // -> the normal dark plate. You still see which site you're on AND the wiki login state, and-    // at 16px the plate is a far bigger colour target than any glyph tucked in the base icon.-    // v1.9.19: plate is ALWAYS the neutral dark one again. Login state moved to the CATEGORY ICON-    // (monochrome, per the brand icon law), so tinting this plate would be a second, redundant and-    // off-law colour signal. The favicon keeps this slot untouched, which is the whole point of it.-    const plateAuthed = false;-    const plateFill = '#0b2325';-    // v1.9.214 — THE REASON TEN REVISIONS NEVER APPEARED. This composite is memo-cached, and the key-    // did NOT include the bridge build. So the FIRST image built for a given (session, favicon-href)-    // was cached forever: every later run hit the cache, logged success, returned the OLD bytes, and-    // the taskbar never changed no matter what the compositor code now did. That is exactly the-    // symptom John kept reporting — "the code runs, no errors, and the icon is still wrong" — and it-    // silently invalidated every badge fix from 1.9.203 onward, including the generic globe / PDF-    // icons. Keying on BRIDGE_VERSION makes any new build rebuild its badges.-    // v2.0.262: overlaySize is part of the key — without it, flipping the setting would serve the-    // previously-composited badge from cache and nothing would visibly change.-    const cacheKey = BRIDGE_VERSION + ':' + sessionId + ':' + (src.href || 'explicit') + ':' + (plateAuthed ? 'auth' : 'plain') + ':' + (pupSettings().aumidIcons === 'on' ? 'nomark' : 'mark') + ':sz' + overlayScalePct();-    let overlay = _appOverlayCache.get(cacheKey);-    if (!overlay) {-      let sharpMod = null;-      try { sharpMod = require('sharp'); } catch (e) { sharpMod = null; }-      const isIco = /x-icon|vnd\.microsoft\.icon/.test(src.mime) || (src.href || '').endsWith('.ico');-      if (isIco && !sharpMod) {-        overlay = src.b64; // sharp unavailable AND .ico — pass through (no compositing possible)-      } else if (!sharpMod) {-        overlay = src.b64;-      } else {-        try {-          // .ico can't be decoded by sharp — when we NEED the green plate, fall back to drawing-          // the plate alone rather than losing the signal entirely.-          let iconPng = null;-          // v1.9.326 (John: "your icons are STILL wrong in the taskbar"). ROOT CAUSE of the bare Adom-          // mark on Google Docs (and any site whose favicon URL ends in .ico): decide the container by-          // the actual BYTES, not by the href. analyzeIconBytes has ALREADY re-encoded a resolved-          // favicon to PNG, so a source whose URL merely ends in ".ico" (docs-favicon-2026-v2.ico)-          // arrives here as PNG — the old href-based isIco ran icoLargestPng on PNG bytes, got null,-          // dropped the favicon, and left the mark alone. Sniff the real magic bytes instead.-          {-            const _buf = Buffer.from(src.b64, 'base64');-            const _isIcoBytes = _buf.length >= 4 && _buf[0] === 0 && _buf[1] === 0 && _buf[2] === 1 && _buf[3] === 0;-            try {-              if (_isIcoBytes) {-                const inner = icoLargestPng(_buf);-                if (inner) iconPng = await sharpMod(inner).resize(48, 48, { fit: 'cover' }).png().toBuffer();-              } else {-                iconPng = await sharpMod(_buf).resize(48, 48, { fit: 'cover' }).png().toBuffer();-              }-            } catch (e) { iconPng = null; }-          }-          // v1.9.207 — THE ACTUAL HOLE behind John's bare first button, found by tracing the decode-          // path instead of guessing at pixels again. The glyph fallback added in 1.9.205 only covers-          // "no favicon FOUND at all". But when a favicon IS found and sharp cannot DECODE it (a-          // BMP-encoded .ico, which icoLargestPng deliberately returns null for; a malformed or-          // exotic SVG), iconPng stays null while src is truthy — so that fallback never ran, no-          // favicon layer was pushed, and the composite ended up as the Adom mark ALONE. That is-          // precisely the "no main overlay icon" he kept pointing at, through five revisions.-          // Any undecodable favicon now falls back to the glyph, so a badge can NEVER be mark-only.-          if (!iconPng) {-            // v1.9.208 — NEVER DEPEND ON SVG. John kept seeing "only the Adom mark", and the reason my-            // fallbacks never appeared is that they were all SVG: sharp needs librsvg to rasterise SVG-            // and silently THROWS without it. The Adom mark renders because it is a PNG. That also-            // explains the earlier "invisible plate". So the fallback is now built from a raw pixel-            // buffer (sharp `create`, no SVG) plus the mark PNG — both guaranteed to decode. A window-            // with no usable favicon gets a solid Adom-teal tile as its MAIN image, so the badge always-            // has one and never renders as a lone corner mark.-            // v1.9.211 (John): mirror what CHROME does for a faviconless page — a GENERIC GLOBE — and-            // use a distinct GENERIC PDF icon for PDFs, "since that's common in our world cuz of pdf-            // datasheets". Both still take the mini Adom mark inset, so the badge keeps one structure.-            // (Correcting my own earlier claim: sharp DOES rasterise SVG in this build — the wiki's-            // purple badge IS an SVG favicon — so these are drawn as SVG rather than raw pixels.)-            try {-              let u = '';-              try { u = (activePage && activePage.url()) || ''; } catch (e) {}-              const isPdf = /\.pdf(\?|#|$)/i.test(u) || /application\/pdf/i.test(String(src && src.mime || ''));-              const svg = genericIconSvg(isPdf);-              // v1.9.326: render the glyph straight INTO iconPng. The composite below only draws-              // iconPng, so the old code (which set `src` and left iconPng null) fell through to a-              // mark-only badge — exactly the "no main overlay icon" John kept reporting. sharp-              // rasterises SVG in this build (the wiki's purple badge is an SVG favicon).-              iconPng = await sharpMod(Buffer.from(svg)).resize(48, 48, { fit: 'cover' }).png().toBuffer();-              // Mark the source generic so _overlayIsGeneric (derived from src.href) is honest and the-              // dashboard LED shows AMBER, not a lying green, when we fell back to the glyph.-              src.href = isPdf ? 'generic:pdf' : 'generic:page';-              session._overlayFav = { kind: isPdf ? 'generic-pdf' : 'generic-web', format: 'svg', mode: 'default', transparent: false, via: 'generated (favicon undecodable / none declared)', href: isPdf ? 'generic:pdf' : 'generic:page', candidates: 0 };-              console.log(`[overlay] "${sessionId}" favicon undecodable/absent — generic ${isPdf ? 'PDF' : 'webpage'} icon (Chrome-style) + Adom mark`);-            } catch (e) {-              console.log(`[overlay] "${sessionId}" generic icon build FAILED: ${e.message}`);-              session._appOverlay = false;-              session._overlayDesc = 'none (no favicon; generic icon failed)';-              return false;-            }-          }-          // v1.9.191 (John's design): a TRULY BRANDED badge = the page's own favicon as the main-          // image, with the Adom mark tucked into the LOWER-RIGHT corner, so one glance says both-          // "this is that site" and "this is Adom driving it". Layout on the 32x32 badge (it renders-          // ~16px on the taskbar, so the corner mark gets a dark disc behind it to stay legible):-          //   plate  32x32 rounded dark-          //   favicon 23x23 at (1,1)      — dominant, upper-left biased-          //   disc    15x15 at (16,16)    — dark backing so the mark separates from busy favicons-          //   mark    12x12 at (17,17)    — the Adom leaves-          // v1.9.193 (John: "don't do a white background, that's ugly — you are just insetting the-          // adom favicon into the other favicon"). NO PLATE. The badge IS the page's own favicon at-          // full bleed, with the Adom mark inset into its lower-right corner. The favicon keeps the-          // exact look it was designed with (no tinted card behind it), and the corner mark carries-          // the branding. The mark sits on a small dark disc purely so it stays readable over busy-          // or light favicon artwork at ~16px.-          // v1.9.203 (John: "these overlay icons are a disaster"): composite at 48px, not 32px — the-          // same size the WORKING adom-browser-extension badge builder uses. The badge renders in a-          // ~16px taskbar slot, so compositing small and letting Windows shrink it turned both images-          // to mush. Build big, downscale once, and size the Adom mark as a fixed PROPORTION (3/8 of-          // the badge) so it is identical on every window instead of varying with each favicon's-          // internal padding.-          const BSZ = 48, MSZ = 18;   // 18/48 = 3/8-          let img = sharpMod({ create: { width: BSZ, height: BSZ, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } });-          const layers = [];-          // v1.9.348 (John): the overlay carries its OWN background now that the base tile has no notch.-          // Site favicons are frequently transparent, and painted straight onto the taskbar tile they-          // looked terrible (floating fragments over whatever was behind). Put a solid, dark, teal-edged-          // rounded CARD behind the favicon so every badge reads as one clean unit and transparent-          // favicons get a proper, consistent backdrop. This is where the "notch background" belongs.-          // v1.9.349 (John): NO teal border — the 1px border ate space and forced the favicon too small.-          // Just a full-bleed dark card the tone of the Windows taskbar behind the favicon. The favicon-          // is inset only 2px so it stays as LARGE as possible (44/48) while leaving a thin dark margin-          // the eye can see, so the badge still reads as a distinct unit on the tile.-          // v2.0.191 (John: "be smart about dark favicons — the GitHub badge comes out invisible").-          // analyzeIconBytes already measured the chosen favicon: a DARK-dominant glyph on-          // TRANSPARENCY vanishes on the dark card, so give exactly that class a LIGHT card-          // (Chrome's own generic-plate grey). Opaque favicons bring their own background and-          // light/colorful glyphs read fine on dark, so everything else keeps the dark card.-          const _lightCard = !!(src.darkDominant && src.transparent);-          const _cardFill = _lightCard ? '#f1f3f4' : '#202124';-          if (_lightCard) console.log(`[overlay] "${sessionId}" dark transparent favicon (${(src.href || '').slice(0, 60)}) — using LIGHT card for contrast`);-          const _bg = `<svg xmlns="http://www.w3.org/2000/svg" width="${BSZ}" height="${BSZ}"><rect x="0" y="0" width="${BSZ}" height="${BSZ}" rx="6" ry="6" fill="${_cardFill}"/></svg>`;-          layers.push({ input: await sharpMod(Buffer.from(_bg)).png().toBuffer(), gravity: 'northwest' });-          const FIN = 2;   // 2px dark margin around the favicon-          // Use the ALREADY-DECODED bytes — re-decoding src.b64 here is what broke .ico (sharp throws-          // on the raw container), silently dropping the favicon and leaving the Adom mark alone.-          if (iconPng) layers.push({ input: await sharpMod(iconPng).resize(BSZ - FIN * 2, BSZ - FIN * 2, { fit: 'cover' }).png().toBuffer(), top: FIN, left: FIN });-          // v1.9.331 (John: "why put an EXTRA adom mark on the overlay when AUMID is ON? the teal-          // category tile is already the 'this is Adom pup' indicator"). Right. The Adom mark on the-          // favicon exists ONLY to brand the badge when the BASE icon is plain Chrome (AUMID off). When-          // AUMID is on, the base IS the teal Adom category tile, so a second mark is redundant — the-          // overlay is then the CLEAN site favicon, nothing else.-          // v2.0.192 (John: "why put ANOTHER Adom logo on the overlay? the pup taskbar icon already-          // says this is Adom control — an insanely small extra mark makes no sense"). The corner-          // Adom mark is GONE from the badge in every mode. The badge is the site favicon on its-          // contrast card, nothing else; Adom identity lives in the base tile alone.-          if (layers.length) img = img.composite(layers);-          let _built = await img.png().toBuffer();-          // v2.0.262 (John): HALF-SIZE BADGE, and it is the default. At full size the badge covers the-          // thread tile's BOTTOM LINE of text — which is the whole point of thread tiles, since that-          // text is how you tell one thread's windows from another's ("the text is super valuable so i-          // want to be able to see it"). Windows gives us no way to ask the shell for a smaller overlay-          // slot, so we shrink the CONTENT inside the same canvas: composite the finished badge at half-          // scale into a transparent 48x48 and let the shell scale the whole thing as before. Net effect-          // on glass is a badge half the size, tucked in the corner, with the tile text clear.-          // Anchored NORTHEAST: the tile draws its name from the BOTTOM-LEFT, so the far corner from-          // the text is where the badge does the least damage.-          const _pct = overlayScalePct();-          if (_pct < 100) {-            const _h = Math.max(8, Math.round(BSZ * _pct / 100));-            const _small = await sharpMod(_built).resize(_h, _h, { fit: 'cover' }).png().toBuffer();-            _built = await sharpMod({ create: { width: BSZ, height: BSZ, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })-              .composite([{ input: _small, gravity: 'northeast' }]).png().toBuffer();-          }-          overlay = _built.toString('base64');-        } catch (e) { return false; }-      }-      _appOverlayCache.set(cacheKey, overlay);-    }-    const tabs = (session.tabs || []).length;-    const nm = session._appName ? `${session._appName} — ` : '';-    // v1.9.212: address the button BY HWND when known — the title tag is unavailable on PDF windows,-    // which is why those buttons never got painted no matter what the compositor produced.-    if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, sessionId); } catch (e) {} }-    // v1.9.278 THE EDGE GUARD: paint ONLY to an hwnd proven to be a pup browser window. If we cannot-    // prove ownership (recycled/dead handle), REFUSE - never fall back to a bare title match that-    // could hit another app. A missed overlay this cycle self-heals next sweep; painting on Edge does not.-    if (!session._hwnd || !(await hwndBelongsToPup(session._hwnd, session))) {-      session._hwnd = null; session._appOverlay = false;-      console.log(`[overlay] "${sessionId}" REFUSED to paint — no pup-owned hwnd confirmed (protecting non-pup taskbar buttons like Edge)`);-      return false;-    }-    const _tooltip = `Adom Pup — ${nm}${plateAuthed ? 'Adom wiki: SIGNED IN · ' : ''}${tabs} tab${tabs === 1 ? '' : 's'}${tabs > 1 ? ' (icon = active tab)' : ''}`;-    // DEDUP THE PAINT (John: "why are you sending overlay icons non-stop? every second? that's insane").-    // desktop_taskbar is a PowerShell round-trip on the user's desktop; re-sending an IDENTICAL overlay-    // is pure churn that spams AD's activity log and needlessly re-hits the taskbar. applyAppOverlay is-    // RE-RUN constantly and legitimately — the 4s enforcer sweep re-checks bare/generic windows, and a-    // framenavigated fires on every SPA route/redirect — but if the composed icon + tooltip + target are-    // unchanged since the last SUCCESSFUL paint, there is nothing to send. The signature includes the-    // appId so a base-tile RE-STAMP (which recreates the button and drops its overlay) still forces a-    // repaint, and the hwnd so a re-resolved handle repaints. Same rule as jump-list dedup: one commit-    // per real change, never a heartbeat.-    const _paintSig = `${session._hwnd}|${session._curAppId || ''}|${_tooltip}|${overlay}`;-    if (session._lastPaintSig === _paintSig) {-      session._appOverlay = true; // identical overlay already on the button — skip the AD round-trip-      return true;-    }-    // v1.9.215 DIAGNOSTIC (now AFTER the dedup so it only fires on a REAL paint, not every re-run):-    // dump the EXACT bytes we are about to hand AD so the composite can be inspected directly. If the-    // PNG contains the generic icon, the compositor is exonerated and the fault is delivery.-    try {-      const _dbgDir = path.join(require('os').tmpdir(), 'pup-badge-debug');-      fs.mkdirSync(_dbgDir, { recursive: true });-      const _dbgPath = path.join(_dbgDir, `${sessionId}.png`);-      const _dbgBuf = Buffer.from(overlay, 'base64');-      fs.writeFileSync(_dbgPath, _dbgBuf);-      session._overlayBytesAt = Date.now();   // v2.0.230: lets the dashboard stop stat-ing per push-      // v2.0.230: this fired on EVERY paint, including the periodic re-asserts, and was a top-3-      // source of log volume. The interesting event is a CHANGE of source, so collapse repeats.-      const _dbgSig = `${(src && src.href) || '?'}|${_dbgBuf.length}`;-      if (session._dbgLogSig !== _dbgSig) {-        session._dbgLogSig = _dbgSig;-        console.log(`[overlay-debug] "${sessionId}" PAINT ${_dbgBuf.length}B -> ${_dbgPath} | src=${(src && src.href) || '?'} | hwnd=${session._hwnd || '(none, using title)'}`);-      }-    } catch (e) { console.log(`[overlay-debug] dump failed: ${e.message}`); }-    await chrome.abCommand('desktop_taskbar', {-      hwnd: session._hwnd,-      overlay: { icon: overlay, tooltip: _tooltip },-    }, { timeoutMs: 5000 });-    session._lastPaintSig = _paintSig;-    session._appOverlay = true;-    session._overlayReassertAt = Date.now();   // v2.0.47: fresh paint restarts the re-assert clock-    // v1.9.150 (John: 'prove what overlay icon you're showing and why'): record the WHAT and WHY-    // so list_windows can report it and test tables can prove it.-    session._overlayWhy = {-      tabId: (activeTab && activeTab.tabId) || null,-      tabPickedBy: 'visibilityState==="visible" (asked Chrome, not pup bookkeeping)',-      source: (src && src.href) || null,-      fetchedVia: (src && /^generic:/.test(src.href || '')) ? 'generic fallback (no usable favicon)'-                : (src && src.href === 'data:inline') ? 'inline data: URI in the page'-                : 'page fetch with cookies, else bridge fetch',-      composed: 'favicon 48x48 cover + Adom mark 18x18 flush lower-right',-      paintedBy: session._hwnd ? ('hwnd ' + session._hwnd) : 'title match "(session: ' + sessionId + '" (NO hwnd — fragile)',-      at: Date.now(),-    };-    try { session._overlayDesc = 'favicon+adom:' + (src && src.href && src.href !== 'data:inline' ? new URL(src.href).hostname : 'inline-icon'); } catch (e) { session._overlayDesc = 'favicon+adom'; }-    // v1.9.237 (John: "why are the overlay icons the standard adom favicon when you have an entire set-    // of rules on picking the favicon from your first or foreground tab and you seem to not be doing-    // that"). A GENERIC fallback (favicon fetch failed/timed out, e.g. during a post-restart paint) is-    // NOT a finished state - the page usually HAS a favicon that becomes fetchable a moment later. Flag-    // it so the health-sweep enforcement re-attempts and UPGRADES the badge to the real favicon. Only a-    // page that genuinely has no favicon anywhere stays generic (correct, e.g. a bare checkout page).-    session._overlayIsGeneric = !!(src && /^generic:/.test(src.href || ''));-    session._overlayGenericAt = session._overlayIsGeneric ? Date.now() : 0;-    return true;-  } catch (e) { return false; }-}-function appIconReprimand(url, hadFavicon) {-  if (hadFavicon) return '';-  const appish = /localhost|127\.0\.0\.1|\.adom\.cloud\/proxy\//i.test(String(url || ''));-  if (!appish) return '';-  return ' 🎨 APP ICON MISSING: this looks like an Adom app, but the page serves NO favicon, so its pup window cannot wear the app\'s icon (and its webview tab shows a blank icon too). The adom-ui-design standard requires every app to serve a favicon (<link rel="icon">, monochrome WHITE mark, clean 24x24 SVG — see the adom-ui-design + app-creator skills). Add one and reopen, or pass appIconB64/appIconUrl on pup_open_window meanwhile.';-}--// v1.9.9: on-screen CAPTION for the taskbar wiki toggle (John's ask: "why don't you show an AD-// screen caption saying 'switching to logged in view of wiki for [email protected]'"). The switch-// relaunches the browser under the other cookie jar and takes several seconds, so WITHOUT this a-// click felt like nothing happened at all. Same `id` on every call so the start caption is-// REPLACED by the result caption instead of stacking. Best-effort; never throws.--// ─── v2.0.50 ANNOTATION (plan: docs/PLAN-interactive-surface.md). Human draws on the page;-// captures are TIGHT-CROPPED to the drawn strokes (+margin — "if the user wants the whole view,-// they'll draw a circle around it"), QUICK MODE captures on every mouse-up, and each capture is-// copied to the Windows clipboard as image + text/plain JSON sidecar so one paste into any AI-// chat carries both. A preview toast shows the exact crop so the user can verify the grab.-// Injected via CDP (CSP-immune), zero external resources; no backticks/interpolation inside the-// overlay string (it lives in a template literal).-const ANNOTATE_OVERLAY_JS = `window.__pupStartAnnotate = function (initTool, initMode) {-  if (window.__pupAnnotate) {-    // v2.0.372: a re-arm with an explicit mode SWITCHES the live overlay's mode (Alt+Shift+M on-    // an already-open toolbar). v2.0.81 rule preserved: a re-arm after emit-hide stays INVISIBLE.-    if (initMode && window.__pupAnnotate.setMode) { try { window.__pupAnnotate.setMode(initMode); } catch (e) {} }-    if (!window.__pupAnnotateHidden) window.__pupAnnotate.show();-    return 'shown';-  }-  initTool = initTool || 'pen';-  // v2.0.370 (John): two explicit capture modes. quick=true (Auto, the default) captures the-  // moment the mouse lifts — the one-red-circle case. quick=false (Multi) accumulates shapes —-  // a red circle here, a blue circle there — and captures ONCE on Done.-  var S = { tool: 'pen', color: '#f85149', strokes: [], cur: null, quick: initMode !== 'multi', seq: 0 };-  var wrap = document.createElement('div');-  wrap.id = '__pup_annotate';-  wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;';-  var cv = document.createElement('canvas');-  cv.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:auto;touch-action:none;';-  wrap.appendChild(cv);-  var bar = document.createElement('div');-  bar.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);display:flex;gap:6px;align-items:center;background:#161b22f2;border:1px solid #30363d;border-radius:999px;padding:7px 12px;pointer-events:auto;font:13px Satoshi,system-ui,sans-serif;color:#c9d1d9;box-shadow:0 6px 24px #000a;';-  wrap.appendChild(bar);-  // drag grip (floating-windows: draggable by a visible grip, clamped inside the viewport)-  var grip = document.createElement('div');-  grip.title = '';-  grip.style.cssText = 'width:14px;height:22px;cursor:grab;display:grid;grid-template-columns:1fr 1fr;gap:2px;align-content:center;justify-items:center;margin-right:2px;';-  for (var gi = 0; gi < 6; gi++) { var gd = document.createElement('div'); gd.style.cssText = 'width:3px;height:3px;border-radius:50%;background:#8b949e;'; grip.appendChild(gd); }-  bar.appendChild(grip);-  (function () {-    var drag = null;-    grip.addEventListener('pointerdown', function (e) {-      var r = bar.getBoundingClientRect();-      if (bar.style.transform) { bar.style.transform = ''; bar.style.left = r.left + 'px'; bar.style.top = r.top + 'px'; }-      drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };-      grip.setPointerCapture(e.pointerId); grip.style.cursor = 'grabbing'; e.preventDefault();-    });-    grip.addEventListener('pointermove', function (e) {-      if (!drag) return;-      var w = bar.offsetWidth, h = bar.offsetHeight, M = 8;-      var x = Math.min(Math.max(M, e.clientX - drag.dx), innerWidth - w - M);-      var y = Math.min(Math.max(M, e.clientY - drag.dy), innerHeight - h - M);-      bar.style.left = x + 'px'; bar.style.top = y + 'px';-    });-    grip.addEventListener('pointerup', function () { drag = null; grip.style.cursor = 'grab'; });-  })();-  // tooltip singleton (tooltips skill: fixed, body-level z-max, 600ms reveal, 0.18s ease-  // fade+translate, measured AFTER content set, clamped to the viewport on BOTH axes, flips-  // above when it would clip the bottom)-  var tipEl = document.createElement('div');-  tipEl.style.cssText = 'position:fixed;z-index:2147483647;max-width:280px;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:7px 10px;font:12px Satoshi,system-ui,sans-serif;color:#c9d1d9;box-shadow:0 6px 20px #000b;pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity 0.18s ease,transform 0.18s ease;text-transform:none;';-  wrap.appendChild(tipEl);-  var tipTimer = null;-  function tipShow(anchor, text) {-    clearTimeout(tipTimer);-    tipTimer = setTimeout(function () {-      tipEl.textContent = text;-      tipEl.style.opacity = '0';-      // measure only after content is set and laid out-      requestAnimationFrame(function () {-        var a = anchor.getBoundingClientRect(), M = 8;-        var w = tipEl.offsetWidth, h = tipEl.offsetHeight;-        var x = a.left + a.width / 2 - w / 2;-        var y = a.bottom + 10;-        if (y + h > innerHeight - M) y = a.top - h - 10;   // flip above instead of clipping-        x = Math.min(Math.max(M, x), innerWidth - w - M);-        y = Math.min(Math.max(M, y), innerHeight - h - M);-        tipEl.style.left = x + 'px'; tipEl.style.top = y + 'px';-        tipEl.style.opacity = '1'; tipEl.style.transform = 'translateY(0)';-      });-    }, 600);-  }-  function tipHide() { clearTimeout(tipTimer); tipEl.style.opacity = '0'; tipEl.style.transform = 'translateY(4px)'; }-  function tipFor(el, text) { el.addEventListener('mouseenter', function () { tipShow(el, text); }); el.addEventListener('mouseleave', tipHide); el.addEventListener('pointerdown', tipHide); el.removeAttribute('title'); }-  tipFor(grip, 'Drag to move this toolbar anywhere it is not in your way.');-  var ctx = cv.getContext('2d');-  // ── Photoshop-style tool cursors (v2.0.134, John's HN report: "pen mode shows the crosshair,-  // must be old code"): it was NOT old code. The pen/arrow cursors were CSS-  // url(data:image/svg+xml) images, and CSS cursor images load under the PAGE'S CSP img-src —-  // sites that don't allow data: (Hacker News: img-src 'self' ...) silently block the image and-  // CSS falls back to the declared crosshair, so one build looked new on the wiki and old on HN.-  // Fix: never FETCH a cursor at all. Hide the native cursor over the canvas and follow the-  // pointer with an inline-SVG sprite — DOM markup, which no CSP gates — hotspot preserved-  // (pen tip at 5,19; arrow tip at 2,2). Pen still carries the live color swatch.-  var spr = document.createElement('div');-  spr.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;display:none;will-change:transform;';-  wrap.appendChild(spr);-  var sprHot = [0, 0];-  function penSvg() {-    return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'-      + '<path d="M5 19 L6.5 14.5 L16 5 a2 2 0 0 1 2.8 2.8 L9.5 17.5 Z" fill="#e5e7eb" stroke="#111" stroke-width="1.2"/>'-      + '<path d="M5 19 L6.5 14.5 L9.5 17.5 Z" fill="' + S.color + '" stroke="#111" stroke-width="0.8"/>'-      + '<circle cx="19.5" cy="19.5" r="3.5" fill="' + S.color + '" stroke="#fff" stroke-width="1"/></svg>';-  }-  function arrowSvg() {-    return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'-      + '<path d="M2 2 L11 20 L13.5 13.5 L20 11 Z" fill="' + S.color + '" stroke="#fff" stroke-width="1.4"/></svg>';-  }-  function setCursor() {-    if (S.tool === 'pen' || S.tool === 'arrow') {-      cv.style.cursor = 'none';-      spr.innerHTML = S.tool === 'pen' ? penSvg() : arrowSvg();-      sprHot = S.tool === 'pen' ? [5, 19] : [2, 2];-      // shown on the next pointer move, so it never appears at a stale position-    } else {-      cv.style.cursor = S.tool === 'text' ? 'text' : 'crosshair';-      spr.style.display = 'none';-    }-  }-  function sprTrack(e) {-    if (S.tool !== 'pen' && S.tool !== 'arrow') return;-    spr.style.display = 'block';-    spr.style.transform = 'translate(' + (e.clientX - sprHot[0]) + 'px,' + (e.clientY - sprHot[1]) + 'px)';-  }-  cv.addEventListener('pointermove', sprTrack);-  cv.addEventListener('pointerenter', sprTrack);-  cv.addEventListener('pointerleave', function () { spr.style.display = 'none'; });-  function fit() { var d = window.devicePixelRatio || 1; cv.width = innerWidth * d; cv.height = innerHeight * d; ctx.setTransform(d, 0, 0, d, 0, 0); redraw(); }-  function drawStroke(st) {-    ctx.strokeStyle = st.color; ctx.fillStyle = st.color; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';-    if (st.tool === 'pen') { ctx.beginPath(); for (var i = 0; i < st.pts.length; i++) { var p = st.pts[i]; if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); } ctx.stroke(); }-    else if (st.tool === 'box') { var a = st.pts[0], b = st.pts[st.pts.length - 1]; ctx.strokeRect(Math.min(a[0],b[0]), Math.min(a[1],b[1]), Math.abs(b[0]-a[0]), Math.abs(b[1]-a[1])); }-    else if (st.tool === 'arrow') { var a2 = st.pts[0], b2 = st.pts[st.pts.length - 1]; ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();-      var ang = Math.atan2(b2[1]-a2[1], b2[0]-a2[0]);-      ctx.beginPath(); ctx.moveTo(b2[0], b2[1]);-      ctx.lineTo(b2[0]-14*Math.cos(ang-0.42), b2[1]-14*Math.sin(ang-0.42));-      ctx.lineTo(b2[0]-14*Math.cos(ang+0.42), b2[1]-14*Math.sin(ang+0.42));-      ctx.closePath(); ctx.fill(); }-    else if (st.tool === 'text') { ctx.font = '600 17px system-ui,sans-serif'; ctx.fillText(st.text || '', st.pts[0][0], st.pts[0][1]); }-    else if (st.tool === 'snip') { var s1 = st.pts[0], s2 = st.pts[st.pts.length - 1]; ctx.save(); ctx.setLineDash([7, 5]); ctx.lineWidth = 2; ctx.strokeStyle = '#00d4cb'; ctx.strokeRect(Math.min(s1[0],s2[0]), Math.min(s1[1],s2[1]), Math.abs(s2[0]-s1[0]), Math.abs(s2[1]-s1[1])); ctx.restore(); }-  }-  function redraw() { ctx.clearRect(0, 0, innerWidth, innerHeight); S.strokes.forEach(drawStroke); if (S.cur) drawStroke(S.cur); }-  // Tight-crop bbox over ALL strokes, padded and clamped to the viewport (1a2).-  function bbox() {-    if (!S.strokes.length) return null;-    var x1 = 1e9, y1 = 1e9, x2 = -1e9, y2 = -1e9;-    S.strokes.forEach(function (st) {-      if (st.tool === 'text') {-        var w = (st.text || '').length * 9.5, p0 = st.pts[0];-        x1 = Math.min(x1, p0[0]); y1 = Math.min(y1, p0[1] - 18); x2 = Math.max(x2, p0[0] + w); y2 = Math.max(y2, p0[1] + 6);-      } else st.pts.forEach(function (p) { x1 = Math.min(x1, p[0]); y1 = Math.min(y1, p[1]); x2 = Math.max(x2, p[0]); y2 = Math.max(y2, p[1]); });-    });-    var M = 48;-    x1 = Math.max(0, x1 - M); y1 = Math.max(0, y1 - M);-    x2 = Math.min(innerWidth, x2 + M); y2 = Math.min(innerHeight, y2 + M);-    if (x2 - x1 < 24 || y2 - y1 < 24) return null;-    return { x: Math.round(x1), y: Math.round(y1), w: Math.round(x2 - x1), h: Math.round(y2 - y1) };-  }-  function emit(extra, hide) {-    S.seq++;-    var r = { done: true, shapes: S.strokes.length, ts: Date.now(), seq: S.seq, bbox: bbox() };-    if (extra) for (var k in extra) r[k] = extra[k];-    if (hide) { bar.style.display = 'none'; cv.style.pointerEvents = 'none'; window.__pupAnnotateHidden = true; }-    window.__pupAnnotateResult = r;-  }-  cv.addEventListener('pointerdown', function (e) {-    if (S.tool === 'text') { var t = prompt('Annotation text:'); if (t) { S.strokes.push({ tool: 'text', color: S.color, pts: [[e.clientX, e.clientY]], text: t }); redraw(); if (S.quick) { emit(null, true); removeEventListener('keydown', onKey, true); } else advanceColor(); } return; }-    S.cur = { tool: S.tool, color: S.color, pts: [[e.clientX, e.clientY]] }; cv.setPointerCapture(e.pointerId);-  });-  cv.addEventListener('pointermove', function (e) { if (S.cur) { S.cur.pts.push([e.clientX, e.clientY]); redraw(); } });-  cv.addEventListener('pointerup', function () {-    if (!S.cur) return;-    if (S.cur.tool === 'snip') {-      var p1 = S.cur.pts[0], p2 = S.cur.pts[S.cur.pts.length - 1];-      var r = { x: Math.min(p1[0], p2[0]), y: Math.min(p1[1], p2[1]), w: Math.abs(p2[0]-p1[0]), h: Math.abs(p2[1]-p1[1]) };-      S.cur = null; redraw();-      if (r.w > 8 && r.h > 8) emit({ snip: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h) } }, true);-      return;-    }-    S.strokes.push(S.cur); S.cur = null; redraw();-    // QUICK MODE (1a3): the moment the mouse lifts, capture fires — keep drawing and each lift-    // supersedes the last capture with the union bbox.-    if (S.quick) { emit(null, true); removeEventListener('keydown', onKey, true); }-    // v2.0.371 (John): in MULTI mode each finished shape auto-advances to the next color, so a-    // red circle then a blue circle needs zero clicks. White is excluded (invisible on light-    // pages); a manual swatch pick overrides and the cycle continues from there.-    else advanceColor();-  });-  function icon(paths, dash) {-    var n = document.createElementNS('http://www.w3.org/2000/svg', 'svg');-    n.setAttribute('viewBox', '0 0 24 24'); n.setAttribute('width', '14'); n.setAttribute('height', '14');-    n.setAttribute('fill', 'none'); n.setAttribute('stroke', 'currentColor');-    n.setAttribute('stroke-width', '1.9'); n.setAttribute('stroke-linecap', 'round'); n.setAttribute('stroke-linejoin', 'round');-    paths.forEach(function (dd, i) { var p2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p2.setAttribute('d', dd); if (dash && i === dash - 1) p2.setAttribute('stroke-dasharray', '2.5 2'); n.appendChild(p2); });-    return n;-  }-  var ICONS = {-    pen: [['M4 20l1.5-4.5L16 5a2.1 2.1 0 0 1 3 3L8.5 18.5 4 20z', 'M13.5 7.5l3 3']],-    arrow: [['M5 19L17 7', 'M9 7h8v8']],-    box: [['M4.5 5.5h15v13h-15z']],-    text: [['M6 6h12', 'M12 6v12']],-    snip: [['M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3', 'M8.5 8.5h7v7h-7z'], 2],-    auto: [['M13 3L5 13h5l-1 8 8-10h-5l1-8']],-    multi: [['M4 4.5h10v10H4z', 'M10 9.5h10v10H10z']],-    undo: [['M8 5L4 9l4 4', 'M4 9h9a6 6 0 0 1 0 12h-2']],-    clear: [['M5 7h14', 'M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2', 'M7 7l1 13h8l1-13']],-    done: [['M4.5 12.5l5 5 10-11']],-    cancel: [['M6 6l12 12', 'M18 6L6 18']],-  };-  function btn(label, title, fn, id, iconKey) {-    var b = document.createElement('button');-    if (iconKey && ICONS[iconKey]) b.appendChild(icon(ICONS[iconKey][0], ICONS[iconKey][1]));-    b.appendChild(document.createTextNode(label)); b.title = title;-    b.style.cssText = 'background:none;border:1px solid transparent;border-radius:8px;color:#c9d1d9;padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;';-    b.onmouseenter = function () { b.style.background = '#1c2128'; };-    b.onmouseleave = function () { b.style.background = S.tool === b.dataset.tool ? 'rgba(0,184,177,.13)' : 'none'; };-    b.onclick = fn; if (id) b.dataset.tool = id; bar.appendChild(b);-    if (title) { tipFor(b, title); }-    return b;-  }-  function selTool(id) { S.tool = id; setCursor(); [].forEach.call(bar.querySelectorAll('button[data-tool]'), function (x) { x.style.background = x.dataset.tool === id ? 'rgba(0,184,177,.13)' : 'none'; x.style.borderColor = x.dataset.tool === id ? 'rgba(0,184,177,.45)' : 'transparent'; }); }-  btn('Pen', 'Draw freehand. With Auto on, the capture fires the moment you release the mouse: cropped tight to your drawing, copied to your clipboard.', function () { selTool('pen'); }, 'pen', 'pen');-  btn('Arrow', 'Point at the thing you mean. Drag from tail to tip; the arrowhead lands where you release.', function () { selTool('arrow'); }, 'arrow', 'arrow');-  btn('Box', 'Frame a region with a rectangle. Drag corner to corner.', function () { selTool('box'); }, 'box', 'box');-  btn('Text', 'Drop a short text note onto the page. Click where the note should start.', function () { selTool('text'); }, 'text', 'text');-  btn('Snip', 'Capture exactly a region, like Win+Shift+S: drag a marquee and it is copied to your clipboard the moment you release.', function () { selTool('snip'); }, 'snip', 'snip');-  // v2.0.370 (John): the color control is now a one-click TRIO — red (default), blue, yellow —-  // with the rarer colors one click deeper in a flyout. The old single cycling dot made-  // red→blue three clicks; picking between about 3 colors must be instant.-  var SW_MAIN = ['#f85149', '#64abff', '#d29922'];-  var SW_MORE = ['#3fb950', '#8c6bf7', '#e6edf3'];-  var flyout = document.createElement('div');-  flyout.style.cssText = 'position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);display:none;gap:7px;background:#161b22f2;border:1px solid #30363d;border-radius:999px;padding:7px 10px;box-shadow:0 6px 24px #000a;pointer-events:auto;';-  var more = document.createElement('button');-  function setColor(c) {-    S.color = c; setCursor(); flyout.style.display = 'none';-    [].forEach.call(wrap.querySelectorAll('button[data-sw]'), function (x) { x.style.borderColor = x.dataset.sw === c ? '#fff' : 'rgba(255,255,255,.25)'; });-    more.style.outline = SW_MORE.indexOf(c) >= 0 ? '2px solid #fff' : 'none';-  }-  // v2.0.371: the multi-mode auto-advance cycle — the quick trio then green/purple, never white.-  function advanceColor() {-    var CYCLE = ['#f85149', '#64abff', '#d29922', '#3fb950', '#8c6bf7'];-    setColor(CYCLE[(CYCLE.indexOf(S.color) + 1) % CYCLE.length]);-  }-  function swBtn(c, parent) {-    var s = document.createElement('button');-    s.dataset.sw = c;-    s.style.cssText = 'width:18px;height:18px;border-radius:50%;cursor:pointer;background:' + c + ';border:2px solid ' + (S.color === c ? '#fff' : 'rgba(255,255,255,.25)') + ';padding:0;flex:none;';-    s.onclick = function () { setColor(c); };-    parent.appendChild(s);-    return s;-  }-  SW_MAIN.forEach(function (c) { swBtn(c, bar); });-  more.style.cssText = 'width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid rgba(255,255,255,.25);padding:0;flex:none;background:conic-gradient(#f85149,#d29922,#3fb950,#64abff,#8c6bf7,#f85149);';-  more.onclick = function () { flyout.style.display = flyout.style.display === 'none' ? 'flex' : 'none'; };-  bar.appendChild(more); tipFor(more, 'More colors: green, purple, white.');-  SW_MORE.forEach(function (c) { swBtn(c, flyout); });-  bar.appendChild(flyout);-  // v2.0.370 (John): Auto vs Multi as an explicit two-state control, not a lone toggle.-  var modeWrap = document.createElement('div');-  modeWrap.style.cssText = 'display:flex;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-left:2px;';-  function modeBtn(label, iconKey, val, tip) {-    var b = document.createElement('button');-    if (ICONS[iconKey]) b.appendChild(icon(ICONS[iconKey][0], ICONS[iconKey][1]));-    b.appendChild(document.createTextNode(label));-    b.style.cssText = 'background:none;border:none;color:#c9d1d9;padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;';-    b.onclick = function () { S.quick = val; paintMode(); };-    modeWrap.appendChild(b); tipFor(b, tip);-    return b;-  }-  var mAuto = modeBtn('Auto', 'auto', true, 'One-shot (the default): draw one thing, lift the mouse, and it is captured and copied to your clipboard instantly.');-  var mMulti = modeBtn('Multi', 'multi', false, 'Compose: draw several shapes and NOTHING is captured until you click Done. The color auto-advances after each shape (red, blue, yellow, green, purple) so every shape is distinct — click a swatch any time to override.');-  function paintMode() {-    mAuto.style.background = S.quick ? 'rgba(0,184,177,.13)' : 'none'; mAuto.style.color = S.quick ? '#00d4cb' : '#c9d1d9';-    mMulti.style.background = !S.quick ? 'rgba(0,184,177,.13)' : 'none'; mMulti.style.color = !S.quick ? '#00d4cb' : '#c9d1d9';-  }-  paintMode(); bar.appendChild(modeWrap);-  btn('Undo', 'Remove the last thing you drew. Click again to keep unwinding.', function () { S.strokes.pop(); redraw(); }, null, 'undo');-  btn('Clear', 'Erase every drawing on this page. The page itself is untouched.', function () { S.strokes = []; redraw(); }, null, 'clear');-  var done = btn('Done', 'Capture the annotated area (tight-cropped around your drawing), copy it to your clipboard, and leave the drawing visible so the AI thread that owns this window sees it too.', function () { emit(null, true); }, null, 'done');-  done.style.cssText += 'background:rgba(0,184,177,.13);border:1px solid rgba(0,184,177,.55);color:#00d4cb;font-weight:600;';-  btn('', 'Close the annotation toolbar and remove all drawings from the page (Esc does this too).', function () { exitAll(); }, null, 'cancel');-  function exitAll() { removeEventListener('keydown', onKey, true); wrap.remove(); window.__pupAnnotate = null; window.__pupAnnotateResult = { done: false, ts: Date.now() }; }-  function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); exitAll(); } }-  addEventListener('keydown', onKey, true);-  // v2.0.72: called by the bridge the moment the screenshot is TAKEN (never before, so the-  // flash is not in the shot): camera-flash feedback over the captured region, instant toast-  // with the preview, then the drawings fade out so the user is back to normal surfing.-  window.__pupCaptureDone = function (region, previewUrl, line1) {-    try {-      var fl = document.createElement('div');-      if (!region || !region.w) throw 0; // skip the flash only (caught below): John's rule, flash the captured region or nothing — toast and fade still run-      var rg = region;-      fl.style.cssText = 'position:fixed;left:' + rg.x + 'px;top:' + rg.y + 'px;width:' + rg.w + 'px;height:' + rg.h + 'px;z-index:2147483647;background:none;border:2px dashed #00d4cb;border-radius:6px;opacity:1;pointer-events:none;transition:opacity .45s ease;box-sizing:border-box;';-      document.documentElement.appendChild(fl);-      setTimeout(function () { fl.style.opacity = '0'; }, 550);-      setTimeout(function () { try { fl.remove(); } catch (e) {} }, 1100);-    } catch (e) {}-    try {-      var old = document.getElementById('__pup_ann_toast'); if (old) old.remove();-      var t = document.createElement('div'); t.id = '__pup_ann_toast';-      t.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:18px;z-index:2147483647;background:#21262df2;border:1px solid #30363d;border-radius:12px;padding:10px;box-shadow:0 8px 30px #000c;font:12px Satoshi,system-ui,sans-serif;color:#c9d1d9;max-width:340px;cursor:pointer;';-      var cap = document.createElement('div'); cap.textContent = line1 || 'Captured'; cap.style.cssText = 'margin-bottom:7px;color:#00d4cb;font-weight:500;';-      t.appendChild(cap);-      // v2.0.135: the preview arrives as RAW RGBA pixels and is PAINTED (putImageData), never-      // LOADED — an <img src="data:..."> is a resource load the page's CSP img-src can veto-      // (Hacker News does), which showed the user a broken-image icon. Pixel writes to a canvas-      // are not loads; no site's CSP can break this. (String form kept for mixed-version overlap.)-      if (previewUrl && previewUrl.b64 && previewUrl.w) {-        try {-          var pc = document.createElement('canvas'); pc.width = previewUrl.w; pc.height = previewUrl.h;-          pc.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #30363d;';-          var bin = atob(previewUrl.b64), n = bin.length, arr = new Uint8ClampedArray(n);-          for (var i2 = 0; i2 < n; i2++) arr[i2] = bin.charCodeAt(i2);-          pc.getContext('2d').putImageData(new ImageData(arr, previewUrl.w, previewUrl.h), 0, 0);-          t.appendChild(pc);-        } catch (e2) {}-      } else if (previewUrl && typeof previewUrl === 'string') { var im = document.createElement('img'); im.src = previewUrl; im.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #30363d;'; t.appendChild(im); }-      t.onclick = function () { t.remove(); };-      document.documentElement.appendChild(t);-      setTimeout(function () { try { t.remove(); } catch (e) {} }, 4500);-    } catch (e) {}-    // fade the drawings out (John: after the snapshot, back to normal surfing)-    setTimeout(function () {-      try {-        cv.style.transition = 'opacity .8s ease'; cv.style.opacity = '0';-        setTimeout(function () { try { wrap.remove(); window.__pupAnnotate = null; window.__pupCaptureDone = null; removeEventListener('keydown', onKey, true); } catch (e) {} }, 900);-      } catch (e) {}-    }, 2200);-  };-  window.__pupAnnotate = {-    show: function () { window.__pupAnnotateHidden = false; wrap.style.display = ''; bar.style.display = 'flex'; cv.style.pointerEvents = 'auto'; addEventListener('keydown', onKey, true); },-    setMode: function (m) { S.quick = m !== 'multi'; paintMode(); },-  };-  addEventListener('resize', fit);-  document.documentElement.appendChild(wrap);-  fit(); selTool(initTool);-  return 'started';-};`;--// crc32 (fallback when zlib.crc32 is unavailable, node <20.15)-let _crcTable = null;-function _crc32(buf) {-  const zlib = require('zlib');-  if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;-  if (!_crcTable) {-    _crcTable = new Uint32Array(256);-    for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); _crcTable[n] = c >>> 0; }-  }-  let c = 0xFFFFFFFF;-  for (let i = 0; i < buf.length; i++) c = _crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);-  return (c ^ 0xFFFFFFFF) >>> 0;-}-// Insert an iTXt chunk (keyword + UTF-8 JSON) before IEND — the machine-readable sidecar that-// rides INSIDE the PNG, for tooling that reads metadata (sharp strips unknown chunks on-// re-encode, so this runs LAST, after any caption-bar composite).-function pngAddITXt(buf, keyword, text) {-  try {-    const iend = buf.lastIndexOf(Buffer.from([0x49, 0x45, 0x4E, 0x44]));-    if (iend < 8) return buf;-    const kw = Buffer.from(keyword, 'latin1');-    const data = Buffer.concat([kw, Buffer.from([0, 0, 0, 0, 0]), Buffer.from(text, 'utf8')]);-    const type = Buffer.from('iTXt', 'latin1');-    const len = Buffer.alloc(4); len.writeUInt32BE(data.length);-    const crc = Buffer.alloc(4); crc.writeUInt32BE(_crc32(Buffer.concat([type, data])));-    const chunk = Buffer.concat([len, type, data, crc]);-    const chunkStart = iend - 4;-    return Buffer.concat([buf.slice(0, chunkStart), chunk, buf.slice(chunkStart)]);-  } catch (e) { return buf; }-}-// Caption bar (phase 2): a thin strip baked UNDER the crop so any AI the image is pasted into-// sees the context — visible sidecar, immune to clipboard format games. Toggle: annCaptionBar.-async function addCaptionBar(buf, caption) {-  try {-    const sharp = require('sharp');-    const meta = await sharp(buf).metadata();-    const W = meta.width, BAR = 26;-    const esc = String(caption).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');-    const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + BAR + '">'-      + '<rect width="100%" height="100%" fill="#0d1117"/>'-      + '<text x="8" y="17" font-family="Segoe UI, system-ui, sans-serif" font-size="12" fill="#00d4cb">' + esc + '</text></svg>');-    return await sharp(buf)-      .extend({ bottom: BAR, background: '#0d1117' })-      .composite([{ input: svg, top: meta.height, left: 0 }])-      .png().toBuffer();-  } catch (e) { return buf; }-}-// Multi-format clipboard: image + text/plain TOGETHER via one .NET DataObject (plain-// Set-Clipboard cannot do multi-format). One paste into a chat input attaches the image AND-// inserts the sidecar text; custom mime formats would NOT survive into web inputs.-function pupClipHelperPath() {-  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');-  return path.join(la, 'Adom Bridge', 'pup-clip.ps1');-}-function ensurePupClipHelper() {-  if (process.platform !== 'win32') return null;-  const p2 = pupClipHelperPath();-  const script = `param([Parameter(Mandatory=$true)][string]$Png, [string]$Txt='')-Add-Type -AssemblyName System.Windows.Forms-Add-Type -AssemblyName System.Drawing-$img = [System.Drawing.Image]::FromFile($Png)-$d = New-Object System.Windows.Forms.DataObject-$d.SetImage($img)-if ($Txt -and (Test-Path $Txt)) { $d.SetText([IO.File]::ReadAllText($Txt)) }-[System.Windows.Forms.Clipboard]::SetDataObject($d, $true)-$img.Dispose()-`;-  try { let cur = null; try { cur = fs.readFileSync(p2, 'utf8'); } catch (e) {}-    if (cur !== script) { fs.mkdirSync(path.dirname(p2), { recursive: true }); fs.writeFileSync(p2, script); } } catch (e) { return null; }-  return p2;-}-function copyCaptureToClipboard(pngPath, sidecarText) {-  return new Promise((resolve) => {-    try {-      const helper = ensurePupClipHelper();-      if (!helper) return resolve(false);-      const txtPath = pngPath.replace(/\.png$/i, '.txt');-      fs.writeFileSync(txtPath, sidecarText);-      const { execFile } = require('child_process');-      execFile('powershell.exe', ['-STA', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helper, pngPath, txtPath],-        { timeout: 15000, windowsHide: true }, (err) => resolve(!err));-    } catch (e) { resolve(false); }-  });-}--// v2.0.53 (John repinned pup to CfT): the toolbar extension is now a LAUNCH ARG. Chrome for-// Testing still honors --load-extension (branded Chrome >=137 removed it), so when the driving-// browser is CfT and the toolbarExtension setting is on, every profile launch carries the flag-// and every window is BORN with the toolbar: no install step exists at all. This helper just-// stages the extension files at a stable path for the flag to point at.-function _stageExtDir(srcName, dstName) {-  const srcDir = path.join(__dirname, srcName);-  if (!fs.existsSync(path.join(srcDir, 'manifest.json'))) return null;-  const dstDir = path.join(os.homedir(), '.adom', dstName);-  try {-    fs.mkdirSync(dstDir, { recursive: true });-    for (const f of fs.readdirSync(srcDir)) {-      const sp = path.join(srcDir, f), dp = path.join(dstDir, f);-      try { const a = fs.statSync(sp), b = fs.existsSync(dp) && fs.statSync(dp); if (!b || b.size !== a.size || b.mtimeMs < a.mtimeMs) fs.copyFileSync(sp, dp); } catch (e) { fs.copyFileSync(sp, dp); }   // v2.0.96: mtime too — a same-length edit (e.g. a version bump) was silently never staged-    }-    return dstDir;-  } catch (e) { return null; }-}-// v2.0.66 (floaty pencil, server-injected): the extension's content-script rails never fire-// under this --load-extension path (declarative AND chrome.scripting verified dead while the-// popup works), so the floaty rides pup's own guaranteed injection rail instead (the owner-chip-// pattern). Clicking sets window.__pupFloatyClick; the 4s enforcer sweep picks it up and starts-// annotate on THAT tab. Zero extension dependency for discoverability.-function floatyJs() {-  const px = Math.max(6, Math.min(200, Number(pupSettings().floatyHitPx) || 24));-  const floatyOn = pupSettings().tbFloatyPencil !== 'off';-  const hotkeyOn = pupSettings().tbSnipHotkey !== 'off';-  return FLOATY_JS.replace(/__HITPX__/g, String(px)).replace(/__FLOATY_ON__/g, floatyOn ? '1' : '').replace(/__HOTKEY_ON__/g, hotkeyOn ? '1' : '');-}-const FLOATY_JS = `(() => {-  // v2.0.89: VERSIONED guard. evaluateOnNewDocument scripts from every prior bridge process stay-  // registered on long-lived pages and run oldest-first, so a plain boolean guard let the OLDEST-  // floaty win forever (John kept seeing the big hover zone after reloads). The newest module now-  // tears down any elder and rebuilds.-  if (window !== window.top) return;-  if (window.__pupFloatyV === '${BRIDGE_VERSION}:__HITPX__:__FLOATY_ON__:__HOTKEY_ON__') return;-  window.__pupFloatyV = '${BRIDGE_VERSION}:__HITPX__:__FLOATY_ON__:__HOTKEY_ON__'; window.__pupFloaty = true;-  var _oldHost = document.getElementById('__pup_floaty'); if (_oldHost) _oldHost.remove();-  function add() {-    // v2.0.95 (John: the corner hover zone fights buttons in app headers — off by default now-    // that the toolbar pen button covers discovery; the elder-host removal above still runs so-    // flipping the setting OFF tears the pencil out of every open page).-    if (!'__FLOATY_ON__') return;-    if (!document.body) { setTimeout(add, 400); return; }-    var host = document.createElement('div');-    host.id = '__pup_floaty';-    // v2.0.84 (John): TUCKED AWAY by default so it never interferes with page UI — a corner-    // hover zone reveals it (the taskbar-tuck pattern), tracked via mousemove (zero hit-testing-    // interference). Once dragged (grip, floating-windows skill), it pins visible where placed.-    host.style.cssText = 'position:fixed;top:14px;right:14px;z-index:2147483645;pointer-events:auto;display:flex;align-items:center;gap:4px;opacity:0;transform:translateY(-6px);transition:opacity .2s ease,transform .2s ease;';-    var root = host.attachShadow({ mode: 'closed' });-    var row = document.createElement('div');-    row.style.cssText = 'display:flex;align-items:center;gap:4px;';-    var grip = document.createElement('div');-    grip.setAttribute('aria-label', 'Move the annotate button');-    grip.style.cssText = 'width:12px;height:22px;cursor:grab;display:none;grid-template-columns:1fr 1fr;gap:2px;align-content:center;justify-items:center;';-    for (var gi = 0; gi < 6; gi++) { var gd = document.createElement('div'); gd.style.cssText = 'width:2.5px;height:2.5px;border-radius:50%;background:#8b949e;'; grip.appendChild(gd); }-    var btn = document.createElement('button');-    btn.setAttribute('aria-label', 'Annotate this page');-    btn.style.cssText = 'width:30px;height:30px;border-radius:8px;display:grid;place-items:center;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);cursor:pointer;padding:0;';-    btn.innerHTML = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.5-4.5L16 5a2.1 2.1 0 0 1 3 3L8.5 18.5 4 20z"/><path d="M13.5 7.5l3 3"/></svg>';-    var tip = document.createElement('div');-    tip.innerHTML = '<div style="font-weight:700;color:#e6edf3;margin-bottom:4px;font-family:Familjen Grotesk,Satoshi,sans-serif;">Annotate this page</div>'-      + '<div style="color:#c9d1d9;">Draw on the page to show your AI exactly what you mean; a red circle says it best. The moment you lift the mouse, the marked-up area is copied to your clipboard, ready to paste into any AI chat.</div>'-      + '<div style="margin-top:7px;color:#8b949e;font-size:11.5px;">Shortcut&nbsp;&nbsp;<span style="font-family:JetBrains Mono,monospace;font-size:10.5px;border:1px solid #30363d;border-radius:4px;padding:1px 6px;color:#c9d1d9;">Alt&nbsp;Shift&nbsp;A</span></div>';-    tip.style.cssText = 'position:fixed;z-index:2147483647;max-width:min(320px,calc(100vw - 40px));background:rgba(22,27,34,.96);color:#e6edf3;border:1px solid rgba(0,184,177,.28);border-radius:10px;padding:9px 13px;font:400 13px/1.4 Satoshi,system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.55);backdrop-filter:blur(16px);pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease;';-    row.appendChild(grip); row.appendChild(btn); root.appendChild(row); root.appendChild(tip);-    var timer = null, pinned = false, shown = false, hideTimer = null, idleTimer = null, moves = 0;-    // v2.0.93 (John: "why is the pen showing in the window you just opened?"): a window that-    // opens maximized UNDER a parked cursor gets ONE synthetic hover mousemove from Chromium at-    // load; if that lands in the corner zone the pencil revealed and stayed stuck (tuck only-    // runs on real movement). A reveal that saw almost no movement is that synthetic case:-    // auto-tuck it. Real hover-in produces dozens of mousemoves and is untouched.-    function reveal() {-      clearTimeout(hideTimer); clearTimeout(idleTimer); moves = 0;-      idleTimer = setTimeout(function () { if (!pinned && moves < 3) { shown = false; host.style.opacity = '0'; host.style.transform = 'translateY(-6px)'; grip.style.display = 'none'; tipHide(); } }, 3500);-      if (shown) return; shown = true; host.style.opacity = '1'; host.style.transform = 'translateY(0)'; grip.style.display = 'grid';-    }-    function tuck() { if (pinned) return; hideTimer = setTimeout(function () { shown = false; host.style.opacity = '0'; host.style.transform = 'translateY(-6px)'; grip.style.display = 'none'; tipHide(); }, 1200); }-    // corner hover zone via mousemove: NOTHING intercepts page clicks-    addEventListener('mousemove', function (e) {-      moves++;-      if (pinned) return;-      var hr = host.getBoundingClientRect();-      var inCorner = e.clientX > innerWidth - __HITPX__ && e.clientY < __HITPX__;   // v2.0.90: user-tunable corner target (floatyHitPx slider in Settings)-      var onHost = shown && e.clientX >= hr.left - 8 && e.clientX <= hr.right + 8 && e.clientY >= hr.top - 8 && e.clientY <= hr.bottom + 8;-      if (inCorner || onHost) reveal(); else if (shown) tuck();-    }, { passive: true });-    function tipShow() { clearTimeout(timer); timer = setTimeout(function () {-      var a = btn.getBoundingClientRect(), M = 8, w = tip.offsetWidth, h = tip.offsetHeight;-      var x = Math.min(Math.max(M, a.right - w), innerWidth - w - M);-      var y = a.bottom + 10; if (y + h > innerHeight - M) y = a.top - h - 10;-      y = Math.min(Math.max(M, y), innerHeight - h - M);-      tip.style.left = x + 'px'; tip.style.top = y + 'px'; tip.style.opacity = '1'; tip.style.transform = 'translateY(0)';-    }, 600); }-    function tipHide() { clearTimeout(timer); tip.style.opacity = '0'; tip.style.transform = 'translateY(4px)'; }-    btn.onmouseenter = tipShow; btn.onmouseleave = tipHide;-    btn.onclick = function () { tipHide(); btn.style.borderColor = '#00d4cb'; if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen'); } window.__pupFloatyClick = Date.now(); if (typeof window.__pupFloatyPush === 'function') { try { window.__pupFloatyPush('btn'); window.__pupFloatyClick = null; } catch (e) {} } setTimeout(function () { btn.style.borderColor = 'rgba(0,184,177,.45)'; }, 1500); };-    // drag by grip (floating-windows skill: visible grip, viewport-clamped); a drag PINS it-    (function () {-      var drag = null;-      grip.addEventListener('pointerdown', function (e) {-        var r = host.getBoundingClientRect();-        host.style.right = ''; host.style.left = r.left + 'px'; host.style.top = r.top + 'px';-        drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };-        grip.setPointerCapture(e.pointerId); grip.style.cursor = 'grabbing'; e.preventDefault();-      });-      grip.addEventListener('pointermove', function (e) {-        if (!drag) return;-        var w = host.offsetWidth, h = host.offsetHeight, M = 8;-        host.style.left = Math.min(Math.max(M, e.clientX - drag.dx), innerWidth - w - M) + 'px';-        host.style.top = Math.min(Math.max(M, e.clientY - drag.dy), innerHeight - h - M) + 'px';-      });-      grip.addEventListener('pointerup', function () { drag = null; pinned = true; grip.style.cursor = 'grab'; });-    })();-    document.body.appendChild(host);-  }-  add();-  if ('__HOTKEY_ON__') addEventListener('keydown', function (e) {-    // v2.0.372 (John): Alt+Shift+A = annotate in Auto; Alt+Shift+M = annotate straight into-    // MULTI (compose shapes, auto-advancing colors, capture once on Done). If the overlay is-    // already up, the hotkey switches its mode instead of being ignored.-    if (e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'A' || e.key === 'a' || e.key === 'M' || e.key === 'm')) {-      e.preventDefault(); e.stopPropagation();-      var mm = (e.key === 'M' || e.key === 'm') ? 'multi' : 'auto';-      if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen', mm); }-      window.__pupFloatyClick = Date.now(); if (typeof window.__pupFloatyPush === 'function') { try { window.__pupFloatyPush('hotkey'); window.__pupFloatyClick = null; } catch (e) {} }-    }-  }, true);-})()`;-// v2.0.102: the owner-in-tab-title experiment (2.0.96-2.0.101) is DEAD — John: "stop mangling-// the title of the page. i can't fucking stand when you do that." The page's title belongs to-// the page. See dev-skills/pup-page-integrity. TITLE_JS survives ONLY as the cleanup rail: the-// empty-owner build strips any prefix a prior version left and kills its keeper interval; the-// owner build must never be injected again.-const TITLE_JS = `(function () {-  if (window !== window.top) return;-  if (window.__pupTitleV === '${BRIDGE_VERSION}:__OWNER__') return;-  window.__pupTitleV = '${BRIDGE_VERSION}:__OWNER__';-  if (window.__pupTitleTimer) { clearInterval(window.__pupTitleTimer); window.__pupTitleTimer = null; }-  try {-    var prev = window.__pupTitlePre, t0 = document.title || '';-    if (prev && t0.slice(0, prev.length) === prev) document.title = t0.slice(prev.length);-  } catch (e) {}-  var OWNER = '__OWNER__';-  if (!OWNER) { window.__pupTitlePre = null; return; }-  // v2.0.96: PREFIX, not suffix — Chrome ellipsizes the tab at a fixed width, so a suffix-  // truncated out of sight and the whole point is that the thread name is DIRECTLY visible.-  var PRE = OWNER + ' \u00b7 ';-  window.__pupTitlePre = PRE;-  function apply() {-    try {-      var t = document.title || '';-      if (t.slice(0, PRE.length) === PRE) return;-      document.title = PRE + t;-    } catch (e) {}-  }-  apply();-  window.__pupTitleTimer = setInterval(apply, 1500);-})()`;-function titleJs(owner) {-  const o = String(owner || '').replace(/[\\'"\r\n]/g, ' ').trim();-  return TITLE_JS.replace(/__OWNER__/g, o);-}-function stageToolbarExtension() {-  if (process.platform !== 'win32') return null;-  if (pupSettings().toolbarExtension === 'off') return null;-  return _stageExtDir('extension', 'pup-extension');-}-// v2.0.57 (John: "we could never show the Adom profile avatar in the chrome toolbar — now we-// can"): a second staged extension whose toolbar icon IS the user's avatar, click → the identity-// card ("surfing as adom-you"). Rides ONLY the adom-you profile (an isolated throwaway carrying-// the identity would lie). The avatar (settings.identityAvatarB64, same source as the owner chip)-// overrides the icon files at stage time; the Adom favicon stays as the fallback.-async function stageIdentityExtension() {-  if (process.platform !== 'win32') return null;-  if (pupSettings().toolbarExtension === 'off') return null;-  const dstDir = _stageExtDir('extension-identity', 'pup-identity-extension');-  if (!dstDir) return null;-  try {-    // v2.0.85 (John: the two toolbar icons feel like brothers from another mother): ONE icon-    // family across every pup surface. The identity icon is the SAME teal rounded tile as the-    // pup icon and the taskbar tiles, with the avatar circle-cropped and inset in the frame —-    // same silhouette, same radius, same teal (#00b8b1), unmistakably siblings.-    const b64 = pupSettings().identityAvatarB64;-    if (b64) {-      const sharp = require('sharp');-      const buf = Buffer.from(String(b64).replace(/^data:image\/[a-z+]+;base64,/, ''), 'base64');-      for (const size of [16, 48, 128]) {-        const rx = Math.max(2, Math.round(size / 5.5));-        const inset = Math.max(1, Math.round(size * 0.11));-        const d = size - inset * 2;-        const tile = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" rx="${rx}" fill="#00b8b1"/></svg>`);-        const mask = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${d}" height="${d}"><circle cx="${d / 2}" cy="${d / 2}" r="${d / 2}" fill="#fff"/></svg>`);-        const avatar = await sharp(buf).resize(d, d, { fit: 'cover' }).composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer();-        const out = await sharp(tile).composite([{ input: avatar, left: inset, top: inset }]).png().toBuffer();-        fs.writeFileSync(path.join(dstDir, `icon${size}.png`), out);-      }-    }-  } catch (e) {}-  return dstDir;-}-async function startAnnotation(session, sessionId, callerThread, opts) {-  const tab = ((opts && opts.tabId) && (session.tabs || []).find(t => t.tabId === opts.tabId)) || null;-  let pg = tab && tab.page;-  if (!pg) {-    for (const t5 of (session.tabs || [])) { try { const vis = await Promise.race([t5.page.evaluate(() => document.visibilityState), new Promise(r => setTimeout(() => r(''), 900))]); if (vis === 'visible') { pg = t5.page; break; } } catch (e) {} }-  }-  if (!pg) { const t0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0]; pg = t0 && t0.page; }-  if (!pg) throw new Error('no page to annotate');-  const tool = (opts && opts.tool) || 'pen';-  // v2.0.370: multi = compose shapes, capture once on Done. v2.0.373: pass a mode ONLY when the-  // caller explicitly asked for one — the floaty/hotkey sweep re-arms the watcher moments after-  // the page-side hotkey already started the overlay, and a defaulted 'auto' here was flipping a-  // fresh Alt+Shift+M overlay straight back to Auto via the re-arm mode switch.-  const mode = (opts && (opts.mode === 'multi' || opts.mode === 'auto')) ? opts.mode : null;-  await pg.evaluate((code) => { if (!window.__pupStartAnnotate) eval(code); }, ANNOTATE_OVERLAY_JS);-  await pg.evaluate((t2, m2) => { window.__pupStartAnnotate(t2, m2); }, tool, mode);   // v2.0.77: NEVER clear __pupAnnotateResult here — arming must not destroy a capture emitted moments earlier-  console.log(`[annotate] "${sessionId}" overlay started (by ${callerThread || 'unknown'}, tool=${tool}, mode=${mode})`);-  // v2.0.76: the PROVEN watcher (2.0.49 pattern) — the fast-loop pickup never dispatched in-  // production and quick capture broke; this waits on the page itself and feeds the new fast-  // pipeline (flash + instant toast + async slow legs). Quick mode re-arms for the next stroke.-  (async () => {-    const t0 = Date.now();-    while (Date.now() - t0 < 15 * 60000) {-      try { await pg.waitForFunction('window.__pupAnnotateResult', { timeout: 15 * 60000 - (Date.now() - t0), polling: 500 }); }-      catch (e) {-        // v2.0.78: navigations (wiki auto-login among them) destroy the execution context and-        // reject the wait — the watcher must survive them, not die silently (the no-toast bug's-        // final layer). Page gone = done; otherwise re-arm and keep watching.-        if (pg.isClosed && pg.isClosed()) return;-        if (/destroyed|detached|Target closed|Session closed/i.test(e.message || '')) { await new Promise(r => setTimeout(r, 600)); continue; }-        return;-      }-      let res = null;-      try { res = await pg.evaluate(() => { const r = window.__pupAnnotateResult; window.__pupAnnotateResult = null; return r; }); } catch (e) { return; }-      if (!res) continue;-      if (!res.done) { console.log(`[annotate] "${sessionId}" cancelled`); return; }-      try { await processAnnotateCapture(session, sessionId, pg, res); } catch (e) { console.log(`[annotate] "${sessionId}" capture failed: ${e.message}`); }-      if (!res.quick) return;-    }-  })();-}--// v2.0.72 CAPTURE PIPELINE, reordered for perceived speed (John: the blip, the slow toast):-// screenshot first, then IMMEDIATELY the client-side camera flash + toast with the preview, and-// only afterwards the slow legs (caption bar, iTXt, clipboard, disk) run in the background.-// v2.0.83: FLICKER-FREE capture. CDP captureScreenshot compositor-swaps the surface, which-// visibly blips on a FOREGROUND hardware-accelerated window (pup's usual background shots never-// showed it; annotate shoots the window the user is watching). A single screencast frame (the-// recorder's rail) has no visual artifact; crop server-side with sharp, DPR-scaled.-async function flickerFreeShot(pg, clipRect) {-  const cdp = await pg.target().createCDPSession();-  try {-    const frameP = new Promise((resolve, reject) => {-      const to = setTimeout(() => reject(new Error('screencast frame timeout (6s)')), 6000);-      cdp.once('Page.screencastFrame', ev => { clearTimeout(to); resolve(ev); });-    });-    await cdp.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });-    const ev = await frameP;-    cdp.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});-    await cdp.send('Page.stopScreencast').catch(() => {});-    let buf = Buffer.from(ev.data, 'base64');-    if (clipRect) {-      const sharp = require('sharp');-      const meta = await sharp(buf).metadata();-      const cssW = (ev.metadata && ev.metadata.deviceWidth) || null;-      const scale = cssW ? meta.width / cssW : 1;-      const L = Math.max(0, Math.round(clipRect.x * scale)), T = Math.max(0, Math.round(clipRect.y * scale));-      const W = Math.min(meta.width - L, Math.round(clipRect.w * scale)), H = Math.min(meta.height - T, Math.round(clipRect.h * scale));-      if (W > 8 && H > 8) buf = await sharp(buf).extract({ left: L, top: T, width: W, height: H }).png().toBuffer();-    }-    return buf;-  } finally { try { await cdp.detach(); } catch (e) {} }-}-async function processAnnotateCapture(session, sessionId, pg, res) {-  console.log(`[annotate] "${sessionId}" capture: screenshotting`);-  const ts = new Date().toISOString().replace(/[:.]/g, '-');-  const isSnip = res.snip && res.snip.w > 8 && res.snip.h > 8;-  const clipRect = isSnip ? res.snip : res.bbox;-  const fp = path.join(SHOTS_DIR, `annotate-${isSnip ? 'snip-' : ''}${sessionId}-${ts}.png`);-  let buf;-  try { buf = await flickerFreeShot(pg, clipRect); }-  catch (e) {-    console.log(`[annotate] "${sessionId}" screencast shot failed (${e.message}) — falling back to captureScreenshot`);-    buf = await Promise.race([-      pg.screenshot(clipRect ? { clip: { x: clipRect.x, y: clipRect.y, width: clipRect.w, height: clipRect.h }, captureBeyondViewport: false } : { captureBeyondViewport: false }),-      new Promise((_, rej) => setTimeout(() => rej(new Error('screenshot timed out (10s)')), 10000)),-    ]);-  }-  console.log(`[annotate] "${sessionId}" capture: screenshot ok (${buf.length}B)`);-  // instant feedback: flash + toast (preview resize is fast, everything else waits)-  // v2.0.135 (John: "the image you grabbed is broken in this page?"): the preview was a-  // data:image/png URL in an <img>, and image loads — data: included — are governed by the-  // PAGE'S CSP img-src. CSP-strict sites (Hacker News: img-src 'self' ...) blocked it, so the-  // toast showed a broken-image icon. Same disease as the annotate cursors. Fix: ship RAW RGBA-  // pixels and let the toast PAINT them via canvas putImageData — pure pixel writes, no image-  // load, so no CSP anywhere can break the preview.-  let preview = null;-  try {-    const sharp = require('sharp');-    const { data, info } = await sharp(buf).resize({ width: 320, withoutEnlargement: true }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });-    preview = { b64: data.toString('base64'), w: info.width, h: info.height };-  } catch (e) {}-  const line1 = `${isSnip ? 'Snippet' : 'Annotation'} copied to your clipboard`;-  pg.evaluate((rg, pv, l1) => { if (window.__pupCaptureDone) window.__pupCaptureDone(rg, pv, l1); }, clipRect || null, preview, line1).catch(() => {});-  // slow legs, off the perceived path-  (async () => {-    try {-      const owner = session.owner || session._ownerThread || 'unknown-thread';-      let url = ''; try { url = pg.url() || ''; } catch (e) {}-      const shortUrl = url.replace(/^https?:\/\//, '').slice(0, 80);-      const when = new Date().toTimeString().slice(0, 5);-      if (pupSettings().annCaptionBar !== 'off') buf = await addCaptionBar(buf, `pup · ${owner}'s window · ${shortUrl} · ${when}`);-      const sidecar = { kind: 'pup-annotation', thread: owner, container: session._callerContainer || session._ownerContainer || null, sessionId, url, ts: new Date().toISOString(), shapes: res.shapes || 0, crop: clipRect || 'full', viewport: null };-      buf = pngAddITXt(buf, 'pup-annotation', JSON.stringify(sidecar));-      fs.writeFileSync(fp, buf);-      session._lastAnnotation = { path: fp, ts: Date.now(), shapes: res.shapes, snip: isSnip ? res.snip : null };-      const sidecarText = `pup snippet — ${owner}'s window — ${url} — ${sidecar.ts}\n` + JSON.stringify(sidecar, null, 1);-      const clipped = await copyCaptureToClipboard(fp, sidecarText);-      console.log(`[annotate] "${sessionId}" CAPTURED ${isSnip ? `SNIP ${res.snip.w}x${res.snip.h}` : (clipRect ? `tight-crop ${clipRect.w}x${clipRect.h}` : 'full-view')} -> ${fp} | clipboard=${clipped} | quick=${!!res.quick}`);-    } catch (e) { console.log(`[annotate] "${sessionId}" capture post-processing failed: ${e.message}`); }-  })();-}--async function wikiCaption(text, expiresInMs) {-  try {-    // v1.9.11: AD ≥1.9.144 exposes desktop_caption on the LOOPBACK direct API (the path bridges-    // use). Before that it was relay-only — ~90 inline verbs matched only their BARE names, and-    // the CLI strips the `desktop_` prefix client-side, which is exactly why a CLI test "worked"-    // while pup's identical call silently no-op'd. Both spellings are accepted now.-    // NOTE the arg is `expiresInMs` (NOT `duration`) — default 30000, max 600000.-    // This replaced the in-page DOM toast: with a real OS surface available, pup should not be-    // injecting elements into the user's page.-    const r = await chrome.abCommand('desktop_caption', {-      text, duration: Math.min(Number(expiresInMs) || 2000, 2000), expiresInMs: Math.min(Number(expiresInMs) || 2000, 2000), id: 'pup-wiki-view', position: 'bottom', size: 'medium',-    }, { timeoutMs: 4000 });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || r || ''));-    if (!r || (r.success === false) || /not a built-in desktop verb|unknown/i.test(raw || '')) {-      console.log(`[wiki] desktop_caption FAILED (AD <1.9.144?) — ${(raw || 'no response').slice(0, 120)}`);-    }-  } catch (e) {}-}-// v1.9.7: reusable OS FOREGROUND raise, extracted verbatim from the proven pup_raise_os_window-// path, so the wiki view-toggle callback can actually POP the relaunched window to the user's-// screen. A jump-list click is a genuine USER action, so a real SetForegroundWindow raise is-// warranted — the earlier CDP-only maximize left the window BEHIND whatever the user was looking-// at, which is why John "never saw the switch work." CDP place+maximize → blur → bringToFront →-// clear WS_EX_NOACTIVATE + SetForegroundWindow (via the detached/browser PID, with a WMI child-// fallback for launcher→browser PID relaunches). Best-effort throughout; never throws.-async function osRaiseSessionWindow(session, sessionId, reason, thread) {-  // v2.0.136 (John: "why are you showing this caption when i click annotate in the chrome bar?"):-  // if this window is ALREADY the user's foreground window, there is nothing to bring to the-  // front — the user is literally looking at it (they just clicked a control inside it, e.g. the-  // toolbar Annotate button). The raise was a no-op, but the choke-point caption explaining the-  // "raise" fired anyway — pure noise over their work. Early-exit: an already-foreground window-  // gets a CDP tab bringToFront only (so the right tab shows), no OS raise, NO caption. Raises-  // of genuinely background/minimized windows behave exactly as before, caption included.-  if (process.platform === 'win32') {-    try {-      const _fgNow = await osGetForegroundWindow();-      const _myH = session._hwnd ? Number(session._hwnd) : (await sessionHwnd(sessionId));-      if (_fgNow && _myH && Number(_fgNow) === Number(_myH)) {-        session._foreground = true;-        try { await session.page.bringToFront(); } catch (e) {}-        console.log(`[foreground] "${sessionId}" is already the user's foreground window — raise + caption skipped (no-op)`);-        return;-      }-    } catch (e) {}-  }-  // v1.9.255 (John: "if a user asks to fg, you show a caption for 2 seconds explaining the reason.-  // do you do that? cuz i asked you 100 times"): the pup_raise verb captioned, but the five-  // OTHER raise paths routed here and captioned NOTHING. Enforce at the choke point: every raise-  // through this function captions its reason for 2s. Callers that already narrate (assisted_click)-  // pass reason=null to avoid a double caption.-  // v2.0.290 (John: "you showed the caption ~5s LATER, which defeats the point — always show the-  // caption FIRST, then foreground"). AWAIT the caption so it is on screen BEFORE the raise runs;-  // fire-and-forget let the fast raise win the race and the caption trailed the window.-  try { if (reason) await captionForegroundReason(sessionId, reason, 'osRaiseSessionWindow', thread); } catch (e) {}-  try {-    try {-      const cdp = await session.page.target().createCDPSession();-      const { windowId } = await cdp.send('Browser.getWindowForTarget');-      // v2.0.134 (John: "you're STILL resizing the browser window on me" — traced HERE, not to-      // the relaunch): this used to UNCONDITIONALLY rewrite the normal rect to 1280x800@0,0 and-      // re-maximize on EVERY raise, so even an already-maximized, on-screen window visibly-      // snapped small-then-big each time a view toggle (or any user-click raise) went through.-      // The rewrite exists only to rescue a window stranded at the -32000 birth park, where the-      // normal rect must land on-screen before maximize means anything. So: read the CURRENT-      // bounds first — already-maximized windows are NOT touched at all, on-screen non-maximized-      // windows get maximize only, and only a genuinely off-screen window gets the full-      // rect-rewrite + maximize rescue.-      const _cur = await cdp.send('Browser.getWindowBounds', { windowId }).catch(() => null);-      const _b = _cur && _cur.bounds;-      const _off = _b && ((_b.left != null && _b.left <= -10000) || (_b.top != null && _b.top <= -10000));-      if (_off) {-        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: 1280, height: 800, windowState: 'normal' } });-        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });-      } else if (!_b || (_b.windowState !== 'maximized' && _b.windowState !== 'fullscreen')) {-        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });-      }-      // already maximized (or fullscreen): leave the geometry completely alone — raise only.-    } catch (e) {}-    try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}-    try { await session.page.bringToFront(); } catch (e) {}-    session._foreground = true;-    if (process.platform === 'darwin') {-      // macOS: unhide + activate the session's browser process (the CDP part above sized it).-      const pid = macSessionPid(session);-      const ok = await macActivateByPid(pid);-      console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${ok}`);-      return;-    }-    if (process.platform !== 'win32') return;-    // v1.9.66 — RAISE BY HWND. The CDP-only path above (setWindowBounds + bringToFront) happens to-    // surface a CHROME window, but it does NOT raise an EDGE one: bringToFront activates the TAB-    // inside the window, and setWindowBounds repositions without changing z-order, so a window pup-    // deliberately parked at the BOTTOM of the z-order stays buried. Measured on ConfRoomROG (which-    // is pinned to Edge because Edge was cached as its verified default): raise returned ok:true,-    // the log said "raise GRANTED", and the screen did not change — the user's "I click and no-    // window comes up".-    // The old PID fallback was removed for a good reason (a detached launch's process() is null, and-    // _detachedPid raised the LAUNCHER's window, i.e. the WRONG one). The fix is to name the window-    // exactly: resolve THIS session's hwnd by its "(session: <id>)" title, then have AD foreground-    // that specific hwnd. Precise, browser-agnostic, and no PID guessing.-    try {-      const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))}));-      const hwnd = fw && fw.best && fw.best.hwnd;-      if (hwnd) {-        await chrome.abCommand('desktop_bring_to_front', { hwnd }, { timeoutMs: 6000 });-        console.log(`[foreground] "${sessionId}" raised by hwnd=${hwnd}`);-      } else {-        console.log(`[foreground] "${sessionId}" could not resolve an hwnd to raise`);-      }-    } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }-    return;-    // v2.0.230 cleanup: ~50 lines of dead PowerShell WinFocus raise used to sit here, after an-    // unconditional return — superseded by the hwnd + desktop_bring_to_front path above. Deleted.-  } catch (e) {}-}-// ── v1.9.31: TAB DRAGGED OUT of a pup window → adopt it as its OWN session ──────────────-// John dragged a tab out of a pup window: Chrome created a NEW OS window, which (being a new HWND)-// carries none of pup's identity, so it wore the bare Chrome-for-Testing icon. Worse, the dragged-// page keeps its "(session: <id>)" title suffix, so ONE session suddenly spanned TWO windows and-// AD's title-based window resolution became ambiguous.-//-// Detection is EVENT-DRIVEN, never a scan (John: "scanning every second is wasteful"). AD exposes a-// global UIA subscription — desktop_ui_watch {event:"window-opened"} — whose events land in a-// 500-deep ring we drain with desktop_ui_events {sinceSeq}. A new window announces itself; we then-// do ONE cheap CDP check (Browser.getWindowForTarget per tab) only for sessions that could be-// affected. Draining is piggybacked on existing activity (the 30s health tick + tab verbs), so-// there is no dedicated timer.-//-// Policy (John's call, option A): the dragged-out tab becomes its OWN session — its own sessionId,-// identity, CATEGORY icon and jump list. That keeps "one session = one window" true, keeps titles-// unique, and lets a dragged-out digikey tab correctly show the globe instead of inheriting the-// wiki book.-// v1.9.35: AD's /command answers in TWO shapes depending on path/version: the relay wraps the-// payload in {output:"<json string>"}, while the loopback direct API can return the payload BARE.-// pup assumed the wrapped shape, so a bare payload silently became "" and every field read as-// undefined — that is why the drag-out drain saw ZERO events while a manual call saw dozens. This-// has now bitten twice (desktop_caption, desktop_ui_events), so parse defensively, once, here.-function abPayload(r) {-  if (!r || typeof r !== 'object') return null;-  let raw = r.output;-  if (raw === undefined || raw === null) {-    // BARE payload: the response IS the payload (no output wrapper).-    return (r.data && typeof r.data === 'object') ? r.data : r;-  }-  if (typeof raw !== 'string') return (raw.data && typeof raw.data === 'object') ? raw.data : raw;-  try { const o = JSON.parse(raw); return (o && o.data && typeof o.data === 'object') ? o.data : o; }-  catch (e) { return null; }-}-let _uiWatchId = null;-let _uiWatchSeq = 0;-let _lastDragCheck = 0;-let _lastReclaim = 0;-// Windows seen unowned but not yet adopted (see the two-strike rule in reclaimUnmanagedWindows).-const _reclaimStrikes = new Map();   // v1.9.42 throttle cursor for the drag-out check-let _uiWatchSupported = null;   // null = unprobed, false = AD too old-async function ensureWindowOpenedWatch() {-  if (process.platform !== 'win32' || _uiWatchSupported === false || _uiWatchId) return;-  try {-    // v1.9.34: REUSE an existing global window-opened watch instead of adding another. Every bridge-    // restart used to subscribe again and never unsubscribe, leaking watches (seen live: w1..w4).-    // Events land in ONE shared ring drained by seq, so any existing watch serves us fine.-    try {-      const ep = abPayload(await chrome.abCommand('desktop_ui_events', { sinceSeq: 0, max: 1 }, { timeoutMs: 5000 }));-      if (ep) {-        const existing = ((ep.activeWatches) || []).find(w => w.event === 'window-opened' && w.scope === 'global');-        if (existing && existing.watchId) {-          _uiWatchId = existing.watchId; _uiWatchSupported = true;-          console.log(`[drag] reusing existing window-opened watch (${_uiWatchId}) — no new subscription`);-          return;-        }-      }-    } catch (e) {}-    const r = await chrome.abCommand('desktop_ui_watch', { event: 'window-opened' }, { timeoutMs: 6000 });-    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));-    if (_isUnknownVerb(r, raw)) { _uiWatchSupported = false; return; }-    const o = raw ? JSON.parse(raw) : {};-    _uiWatchId = ((o.data || o).watchId) || (o.watchId) || 'w1';-    _uiWatchSupported = true;-    console.log(`[drag] subscribed to window-opened (watchId=${_uiWatchId}) — dragged-out tabs adopt themselves`);-  } catch (e) {}-}-// Which OS window (CDP windowId) is this page in?-// v1.9.216 — RESOLVE A WINDOW'S OS HANDLE WITHOUT ITS TITLE. This is the fix for the taskbar button-// John has asked about repeatedly. Every paint/park/lookup addressed windows by their-// "(session: <id>)" TITLE TAG, and Chrome's PDF viewer cannot carry that tag, so on a PDF window the-// paint had no target and vanished — the composed badge was CORRECT the whole time (dumped and-// verified: PDF sheet + Adom mark), it was simply never delivered. v1.9.212 tried to store the handle-// but only did so when park SUCCEEDED, and park fails on exactly these windows: circular.-//-// Break the circle with facts we already have: pup knows the profile's Chrome PID, and CDP tells us-// the window's exact bounds. Enumerate that PID's top-level windows and match on geometry. Works for-// any document type, needs no title, and the handle is then cached on the session.-// v1.9.278 (John: "why do i see a pup icon overlay over the msedge taskbar icon? you should never-// be touching edge?"). Root cause: pup cached a session's hwnd, the real window changed, the cached-// handle DIED, and Windows RECYCLES hwnd numbers - so that number was reassigned to an Edge window,-// and pup's overlay paint (which trusted the stale cache) landed on Edge. THE GUARANTEE, enforced-// below: pup only ever paints/flashes an hwnd it can PROVE belongs to one of its own browser-// processes. Any other hwnd is refused. pup can never touch Edge (or any app it did not launch).-function pupBrowserPidSet(profileName) {-  const pids = new Set();-  for (const [prof, be] of browsers) {-    if (profileName && prof !== profileName) continue;   // v1.9.287: scope to ONE profile when asked-    try {-      const b = be && be.browser; if (!b) continue;-      if (b._detachedPid) pids.add(Number(b._detachedPid));-      const pr = b.process && b.process(); if (pr && pr.pid) pids.add(Number(pr.pid));-      // v1.9.350: pids confirmed to back a pup window via its unique title tag (see hwndBelongsToPup).-      // Stored on the browser entry so they are discarded with it on browsers.delete — a recycled pid-      // can never keep passing after the process it named is gone.-      if (b._confirmedPids) for (const p of b._confirmedPids) pids.add(Number(p));-    } catch (e) {}-  }-  return pids;-}-// v1.9.350: record a pid proven (by unique pup title) to own a pup window, onto its profile's browser-// entry. Folded into pupBrowserPidSet so a shared-process profile (adom-you) self-heals after the first-// title-confirmed window, and cleared automatically when that browser entry is torn down.-function learnConfirmedPid(profileName, pid) {-  if (!profileName || !pid) return;-  try {-    const be = browsers.get(profileName);-    const b = be && be.browser; if (!b) return;-    if (!b._confirmedPids) b._confirmedPids = new Set();-    b._confirmedPids.add(Number(pid));-  } catch (e) {}-}-async function hwndOwnerPid(hwnd) {-  // 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 (System Events denied? grant Automation to Adom Bridge) — 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-desktop 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',-      '--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">&middot; 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 &middot; <b id="cT">0</b> tabs &middot; <b id="cB">0</b> icons &middot; <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 &middot; <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> &middot; <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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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':'')+' &middot; '+esc(g.grouping)+' &middot; 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)+' &middot; '+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)+' &mdash; '+esc(p.profileName||p.profileDir)+'</div>'-        +'<div class="sdesc">'+cnt+' &nbsp; '+_idBadges(p.accounts)+' &nbsp; '+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,'&amp;').replace(/</g,'&lt;'); };-      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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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; System Events does the activation.-              const pid = macSessionPid(session);-              _raisedByHwnd = await macActivateByPid(pid);-              console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${_raisedByHwnd}`);-            }-            if (process.platform === 'win32') {-              try {-                // v1.9.220 (wiki issue #5, John, 2026-07-06): TRACK THE HANDLE, don't match the title.-                // Title-matching is fragile exactly as he described — right after a navigation the new-                // page sets its own title before the bridge re-appends the "(session: <id>)" suffix, so-                // the lookup fails; and Chrome's PDF viewer never carries the suffix at all, which is-                // what made PDF windows unraisable/unpaintable. sessionHwnd() now prefers the handle-                // pup stored at park (falling back to bounds-matching, then a title lookup), so the-                // raise resolves the SAME window regardless of what its title currently says.-                let hwnd = await sessionHwnd(sessionId);-                if (!hwnd) {-                  const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))}));-                  hwnd = fw && fw.best && fw.best.hwnd;-                }-                if (hwnd) {-                  const psPath = path.join(require('os').tmpdir(), '_adom_focus_hwnd.ps1');-                  fs.writeFileSync(psPath, [-                    ...PS_RAISE_TYPEDEF,-                    `[WinFocusH]::Raise([IntPtr]${hwnd})`,-                    "Write-Output 'focused'",-                  ].join('\n'), 'utf8');-                  const out = await runPsHiddenPromise(psPath, { timeout: 8000 });-                  // v1.9.68: PS reports 'focused' even when Windows IGNORED the call. SetForegroundWindow is-                  // subject to the FOREGROUND LOCK: a process that does not already own the foreground cannot-                  // steal it, so it silently no-ops (measured: log said FOCUSED, screen unchanged). Use PS only-                  // for what it is uniquely good at — clearing WS_EX_NOACTIVATE and un-minimizing — then let AD,-                  // which owns a foreground-capable path, do the real raise on the SAME hwnd.-                  const _styleCleared = /focused/.test(out || '');-                  try {-                    const br = abPayload(await chrome.abCommand('desktop_bring_to_front', { hwnd, state: 'restore' }, { timeoutMs: 6000 }));-                    _raisedByHwnd = !!(br && (br.ok === undefined || br.ok));-                  } catch (e) { _raisedByHwnd = false; }-                  console.log(`[foreground] "${sessionId}" hwnd=${hwnd} styleCleared=${_styleCleared} adRaise=${_raisedByHwnd}`);-                } else { console.log(`[foreground] "${sessionId}" could not resolve an hwnd by title`); }-              } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }-            }--            // Get Chrome PID from the browser process-            const browserProcess = browsers.get(session.profileName)?.browser?.process();-            const pid = browserProcess?.pid;-            if (!_raisedByHwnd && pid && process.platform === 'win32') {-              // Write PowerShell script to temp file and execute (avoids quoting hell)-              const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');-              const psContent = [-                ...PS_RAISE_TYPEDEF,-                `$targetPid = ${pid}`,-                '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',-                'if ($p -and $p.MainWindowHandle -ne 0) {',-                '  [WinFocusH]::Raise($p.MainWindowHandle)',-                "  Write-Output 'focused'",-                '} else {',-                '  Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',-                '    $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',-                '    if ($cp -and $cp.MainWindowHandle -ne 0) {',-                '      [WinFocusH]::Raise($cp.MainWindowHandle)',-                "      Write-Output 'focused'",-                '      return',-                '    }',-                '  }',-                '}',-              ].join('\r\n');-              fs.writeFileSync(psScript, psContent);-              try {-                const result = (await runPsHiddenPromise(psScript, { timeout: 8000 })).toString().trim();-                console.log(`Focused session "${sessionId}" via SetForegroundWindow: ${result}`);-              } catch (e) {-                console.error(`SetForegroundWindow failed for session "${sessionId}":`, e.message);-              }-            }-            // v1.9.180: a raise can land on a stuck-occlusion window (frontmost yet page-hidden →-            // frame-frozen in front of the user). Check shortly after the reveal and unstick at once-            // rather than waiting for the 15s sweep.-            setTimeout(async () => {-              try {-                const s = sessions.get(sessionId);-                const fg = await osGetForegroundWindow();-                const hw = s ? await cachedSessionHwnd(sessionId) : null;-                if (s && fg && hw) await unstickIfHiddenWhileFront(sessionId, s, fg, hw);-              } catch (e) {}-            }, 2000);-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true, placement: 'foreground', windowEtiquette: 'foreground' }) });   // v2.0.383 (#63): canonical etiquette key-          } catch (e) {-            sendJSON(res, { success: false, error: `Focus failed: ${e.message}` });-          }-          return;-        }--        // --- Existing commands (operate on active session) ----        case 'pup_launch': {-          sendJSON(res, { success: false, error: 'pup_launch is removed. Use pup_open_window with required sessionId and profile params.' });-          return;-        }-        case 'pup_screenshot': {-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          const fullPage = args.fullPage || false;-          const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM;-          const ts = Date.now();-          // v2.0.286 (John): a capture can sometimes bring the window to the FOREGROUND (pup falling-          // back to a window-level grab when the CDP path can't serve it). When that happens the user-          // must be told WHY — the missing caption is exactly why uncaptioned foregrounds slipped past.-          // Snapshot the OS foreground before the capture so we can detect+caption it after.-          const _fgBefore = await osGetForegroundWindow().catch(() => null);-          // v2.0.289: show a determinate taskbar progress bar for the length of the capture, sized to-          // the running-average screenshot time (John: progress bars instead of the flash).-          let screenshotBuf = null, _capturedVia = 'cdp';-          try {-            screenshotBuf = await withTaskbarProgress(resolvedId, 'screenshot', () => withTimeout(-              // v2.0.186 (#50): captureBeyondViewport defaults TRUE in current puppeteer, which renders-              // from the document ORIGIN — so a viewport screenshot ignored the page's live scroll-              // position (scroll via pup_eval, screenshot still showed the top). Viewport shots now-              // capture the compositor's real viewport; fullPage keeps beyond-viewport ON (it needs it).-              raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })),-              20_000, 'page.screenshot'-            ));-          } catch (e1) {-            // v2.0.386 (#80): a CDP capture on a fully OCCLUDED background window can hang until the-            // compositor produces a frame (pup deliberately lets background windows throttle, #14), and-            // the old path surfaced that as a bare timeout with an empty output. Two fallbacks that-            // never touch OS focus, then an honest error: (1) CDP lifecycle 'active' + one retry;-            // (2) an OS-level PrintWindow capture of the window (works while occluded); (3) errorCode.-            if (!/timed out/i.test(String(e1 && e1.message))) throw e1;-            console.log(`[screenshot] "${resolvedId}" CDP capture timed out — trying lifecycle-active retry, then OS window capture`);-            try {-              await pageSetLifecycleActive(tab.page);-              screenshotBuf = await withTimeout(raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })), 8000, 'page.screenshot(retry)');-              _capturedVia = 'cdp-after-lifecycle-active';-            } catch (e2) {-              let _osBuf = null;-              try {-                if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {} }-                if (session._hwnd && !fullPage) {-                  const r = await chrome.abCommand('desktop_screenshot_window', { hwnd: Number(session._hwnd) }, { timeoutMs: 25000, callerThread: _callerThread || undefined, callerReason: 'pup screenshot fallback (CDP capture timed out on an occluded window)' });-                  const p = abPayload(r) || {};-                  const fp = p.fullPath || p.safePath || (r && (r.fullPath || r.safePath));-                  if (fp && fs.existsSync(fp)) _osBuf = fs.readFileSync(fp);-                }-              } catch (e) {}-              if (!_osBuf) {-                sendJSON(res, { success: false, errorCode: 'screenshot_timeout', error: `page.screenshot timed out (CDP), a lifecycle-active retry timed out, and the OS window capture was unavailable${fullPage ? ' (fullPage has no OS fallback)' : ''}`,-                  _hint: 'The window is probably fully occluded and its compositor is idle (background windows throttle on purpose, #14). pup_eval still works, so the page is fine. Options: retry in a moment; call pup_raise_os_window with a foregroundReason if the user asked to see it; or open the window with highFps:true when you need continuous frames. Read errorCode, not output: this failure used to arrive as an empty output.' });-                return;-              }-              screenshotBuf = _osBuf; _capturedVia = 'os-window';-              console.log(`[screenshot] "${resolvedId}" served by OS window capture after CDP timeouts`);-            }-          }-          // If the capture foregrounded THIS window AND there was no recent user input (so it was pup,-          // not the user clicking it), caption "<thread> · screenshotting" so the user knows what put it-          // on their screen. No-op on the common CDP path, which never foregrounds (measured 0/101).-          try {-            const _hw = session && session._hwnd;-            if (_hw) {-              const _fgAfter = await osGetForegroundWindow().catch(() => null);-              if (_fgAfter && String(_fgAfter) === String(_hw) && String(_fgAfter) !== String(_fgBefore)) {-                // v2.0.293 (John: "you keep foregrounding windows without ever giving me ab captions"):-                // the old idle>3000 gate assumed an ACTIVE user must have clicked the window up — but the-                // active-user case is precisely when a silent foreground is most disruptive, and the fg-                // flipping to OUR hwnd mid-capture is pup's doing regardless of user activity. Caption-                // ALWAYS. (The idle reading is kept only for the log.)-                let _idle = -1; try { _idle = await osIdleMs(); } catch (e) {}-                const _thread = session.owner || session._ownerThread || 'an AI thread';-                const _scapMs = captionMsFor(`${_thread} · screenshotting`);-                pupCaption(`${_thread} · screenshotting`, _scapMs, 'pup-screenshot', _scapMs).catch(() => {});-                console.log(`[screenshot] "${resolvedId}" came to the FOREGROUND during capture (user idle ${_idle}ms) — captioned "${_thread} · screenshotting"`);-              }-            }-          } catch (e) {}-          // v1.8.21: fullPage only covers DOCUMENT-level scroll. On SPAs that scroll inside-          // a nested container (the document itself doesn't grow), fullPage silently returns-          // just the visible viewport. Detect that and hand the caller the right tool.-          let nestedScrollHint = null;-          if (fullPage) {-            try {-              await ensureDeepHelpers(tab.page);-              const ns = await tab.page.evaluate(() => {-                const doc = document.scrollingElement || document.documentElement;-                const docScrolls = doc.scrollHeight > doc.clientHeight + 1;-                const main = window.__pupMainScroller();-                const nested = !docScrolls && main !== doc && main.scrollHeight > main.clientHeight + 1;-                return { docScrolls, nested, scroller: window.__pupDesc(main), scrollHeight: main.scrollHeight, clientHeight: main.clientHeight };-              });-              if (ns.nested) nestedScrollHint = `fullPage captured only the viewport: this page scrolls inside a NESTED container (${ns.scroller}, ${ns.scrollHeight}px of content vs ${ns.clientHeight}px visible), not the document — fullPage can't stitch that. To capture the whole thing, either pup_scroll {dy:...} through it and stitch, or (for one region) pup_screenshot_element {selector|text}. To capture a specific card, pup_screenshot_element is the one-shot fix.`;-            } catch (e) {}-          }--          // Downscale to keep the image under Claude's Read limit when sharp is-          // available; otherwise serve at capture size (sharp is optional — see the-          // processScreenshot helper). A ≤1568px image is the ideal path.-          const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);-          const shot = await processScreenshot(screenshotBuf, maxDim);-          screenshotBuf = shot.buf;--          const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);-          fs.writeFileSync(filePath, screenshotBuf);-          const sizeKB = Math.round(screenshotBuf.length / 1024);-          session.latestShot = filePath;-          const base64 = screenshotBuf.toString('base64');--          sendJSON(res, { success: true, output: JSON.stringify({-            ok: true,-            sizeKB,-            filePath,-            base64,-            capturedVia: _capturedVia,   // v2.0.386 (#80): cdp | cdp-after-lifecycle-active | os-window-            width: shot.width,-            height: shot.height,-            origWidth: shot.origWidth,-            origHeight: shot.origHeight,-            resized: shot.resized,-            ...(shot.sharpUsed ? {} : { sharpUnavailable: true, _hint: 'Screenshot served at CAPTURE size — the sharp resize library is not installed on this box, so the image was NOT downscaled and may exceed Claude\'s ~2000px Read limit. If a Read fails on size, capture a smaller viewport, or (rare) this box\'s native-dep install failed — see pup_readiness.' }),-            ...(nestedScrollHint ? { nestedScroll: true, _hint: nestedScrollHint } : {}),-            sessionId: resolvedId,-            tabId,-          }) });-          return;-        }-        case 'pup_set_viewport': {-          // v1.8.21: set the page viewport (CDP device-metrics) deterministically —-          // independent of the OS window size. Fixes "innerWidth came up ~170px" and lets a-          // fullPage/element shot render at a known width. width+height required.-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          // v1.9.219 (wiki issue #28, Drew Owens): the override used to PIN a window for life — it-          // survived a hard reload, could not be cleared, and nothing reported it, so a window that-          // silently stopped tracking OS resizing was indistinguishable from an app with broken layout-          // code. That cost him a real debugging session hunting his own resize handlers. Fix 1 of 4:-          // a RESET form. Pass {reset:true} (or omit width/height) to call setViewport(null), which-          // clears Emulation device-metrics and hands the page back to the real OS window.-          const wantsReset = args.reset === true || (args.width === undefined && args.height === undefined);-          if (wantsReset) {-            try {-              // v2.0.365 (project-a5's report). VERIFY THE CLEAR, do not assume it. They measured a-              // reset that returned ok:true with "innerWidth now 2200" — the OVERRIDE width, not the-              // real one — while the override was still in force through a reload. A reset that-              // reports the number it was supposed to remove is worse than a failure, because the-              // caller then trusts a window that is still cropping content off the user's screen.-              const _prev = session._emulatedViewport ? Number(session._emulatedViewport.width) : null;-              await tab.page.setViewport(null);-              // Belt and braces: puppeteer's setViewport(null) does not always reach the metrics-              // override on a page that was emulated earlier in its life, so clear it at the-              // protocol level too. Harmless when there is nothing to clear.-              try { const _cdp = await tab.page.createCDPSession(); await _cdp.send('Emulation.clearDeviceMetricsOverride'); await _cdp.detach().catch(() => {}); } catch (e) {}-              session._emulatedViewport = null;-              // Measure AFTER a forced reflow, and measure the OUTER window too: outerWidth is the-              // one number an emulated viewport cannot fake, so innerWidth-vs-outerWidth is how an-              // AI can finally tell an emulated window from a real one (their ask (b)).-              await tab.page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));-              const dims = await tab.page.evaluate(() => ({-                innerWidth: window.innerWidth, innerHeight: window.innerHeight,-                outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio,-              }));-              // Verify by MEASUREMENT. The old check compared against the remembered override-              // width, so on a recovered window (record wiped, override live) there was nothing to-              // compare against and it passed vacuously. innerWidth vs outerWidth cannot be fooled-              // that way, and it also catches the case where the record was never there at all.-              const _stillEmulated = looksEmulated(dims.innerWidth, dims.outerWidth)-                || (_prev && Math.abs(dims.innerWidth - _prev) <= 2);-              if (_stillEmulated) {-                sendJSON(res, { success: false, error: `the viewport override did not clear: the page still reports innerWidth ${dims.innerWidth}, which is the override width`,-                  errorCode: 'viewport_reset_failed',-                  output: JSON.stringify({ ok: false, sessionId: resolvedId, tabId, emulated: true, ...dims }),-                  _hint: `pup could not remove the emulation from this window. Close and reopen it (pup_close_window then pup_open_window) — a fresh window reports the real size. Do NOT trust in-page measurements from this window meanwhile: the page believes it is ${dims.innerWidth}px wide while the OS window is ${dims.outerWidth}px, so the user is seeing it cropped.` });-                return;-              }-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, reset: true, emulated: looksEmulated(dims.innerWidth, dims.outerWidth), ...dims,-                _hint: `Viewport override CLEARED — this window tracks the real OS window again (innerWidth now ${dims.innerWidth}, dpr ${dims.devicePixelRatio}). Resizing the window will reflow the page normally.` }) });-            } catch (e) { sendJSON(res, { success: false, error: `clearing the viewport override failed: ${e.message}` }); }-            return;-          }-          const vw = Number(args.width), vh = Number(args.height);-          if (!Number.isFinite(vw) || !Number.isFinite(vh)) {-            sendJSON(res, { success: false, error: 'pup_set_viewport requires numeric width + height (or {reset:true} to clear an existing override).', _hint: 'Set: {"sessionId":"...","width":1280,"height":900}. Clear: {"sessionId":"...","reset":true}. Optional deviceScaleFactor (default 1).' });-            return;-          }-          const dsf = Number(args.deviceScaleFactor) || 1;-          try {-            // Capture the REAL window width BEFORE emulating. outerWidth is the one number the-            // override cannot fake, and it is what lets every later warning say whether the user is-            // seeing dead space or seeing content cropped off their screen.-            let _outerBefore = null;-            try { _outerBefore = await tab.page.evaluate(() => window.outerWidth); } catch (e) {}-            await tab.page.setViewport({ width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf });-            if (_outerBefore) session._lastOuterWidth = _outerBefore;-            // Fix 2 of 4: RECORD the override so pup_status / pup_list_windows can report it.-            session._emulatedViewport = { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf };-            const dims = await tab.page.evaluate(() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio }));-            // Fix 3 of 4: the hint must state the LIFECYCLE, not just the benefit. The old text sold-            // the feature and never said the effect is permanent for the life of the window.-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, emulated: true, realOuterWidth: _outerBefore, applied: { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf }, ...dims,-              _hint: `Viewport PINNED to ${Math.round(vw)}×${Math.round(vh)} (innerWidth now ${dims.innerWidth}). ⚠ THIS PERSISTS: it survives navigation AND hard reload, and while it is set the window NO LONGER TRACKS OS RESIZING — a human resizing this window will see the page not reflow, which reads exactly like an app layout bug. CLEAR IT when you are done: pup_set_viewport {"sessionId":"${resolvedId}","reset":true}. It is reported as emulatedViewport in pup_status / pup_list_windows.` }) });-          } catch (e) { sendJSON(res, { success: false, error: `setViewport failed: ${e.message}` }); }-          return;-        }-        case 'pup_scroll': {-          // v1.8.21: first-class scroll. Resolves the ACTUAL scrolling element (nearest-          // ancestor whose scrollHeight>clientHeight, NOT just window — window.scrollTo-          // no-ops on nested-scroll SPAs). Supports scroll-by-delta {dy,dx} AND scroll-to-          // {toSelector|toText, deep} piercing shadow roots; returns the scroller's metrics-          // so the caller can confirm it landed.-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          await ensureDeepHelpers(tab.page);-          try {-            const r = await tab.page.evaluate((o) => {-              let target = null;-              if (o.toSelector) target = window.$deep(o.toSelector);-              else if (o.toText) target = window.deepText(o.toText);-              let scroller;-              if (o.toSelector || o.toText) {-                if (!target) return { found: false };-                scroller = window.__pupScrollableAncestor(target);-                target.scrollIntoView({ block: o.block || 'center', inline: 'nearest', behavior: o.behavior || 'auto' });-              } else {-                scroller = window.__pupMainScroller();-                scroller.scrollBy({ top: o.dy || 0, left: o.dx || 0, behavior: o.behavior || 'auto' });-              }-              return { found: true, scrollElement: window.__pupDesc(scroller), scrollTop: Math.round(scroller.scrollTop), scrollLeft: Math.round(scroller.scrollLeft), scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, atBottom: scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 };-            }, { dy: Number(args.dy) || 0, dx: Number(args.dx) || 0, toSelector: args.toSelector || null, toText: args.toText || null, block: args.block || null, behavior: args.behavior || 'auto' });-            if (!r.found) {-              sendJSON(res, { success: false, errorCode: 'scroll_target_not_found', error: `pup_scroll: could not resolve ${args.toSelector ? 'selector "'+args.toSelector+'"' : 'text "'+args.toText+'"'} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId });-              return;-            }-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, ...r, _hint: `Scrolled ${args.toSelector||args.toText ? 'to the target' : 'by delta'} inside ${r.scrollElement} — scrollTop ${r.scrollTop}/${r.scrollHeight - r.clientHeight}${r.atBottom ? ' (at bottom)' : ''}. Screenshot to confirm, or pup_screenshot_element to grab just a card.` }) });-          } catch (e) { sendJSON(res, { success: false, error: `pup_scroll failed: ${e.message}` }); }-          return;-        }-        case 'pup_screenshot_element': {-          // v1.8.21: screenshot ONE element, resolved by CSS selector OR visible text-          // (piercing shadow DOM), scrolled into view first. The clean fix for "capture-          // just this card" on component-heavy pages. Returns the same shape as-          // pup_screenshot (base64 + filePath, downscaled for Read).-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          if (!args.selector && !args.text) {-            sendJSON(res, { success: false, error: 'pup_screenshot_element requires a selector OR text.', _hint: 'e.g. {"selector":".download-card"} or {"text":"Download for your machine"}. Pierces shadow DOM by default.' });-            return;-          }-          await ensureDeepHelpers(tab.page);-          let handle;-          try {-            // Resolve by selector or text (shadow-piercing), then optionally climb to the-            // nearest matching ancestor with `closest` — e.g. {text:"Download…", closest:".card"}-            // grabs the whole CARD, not just the text node. Returns the FIRST text match-            // whose closest(sel) exists, so an ambiguous phrase (also in README prose) still-            // lands on the real widget.-            handle = await tab.page.evaluateHandle((sel, txt, closest) => {-              let el = sel ? window.$deep(sel) : null;-              if (!el && txt) {-                if (closest) {-                  // scan all text matches, pick the first whose closest(sel) resolves-                  const marks = [];-                  const walk = (root) => { const a = root.querySelectorAll('*'); for (const e of a) {-                    let own = ''; for (const c of e.childNodes) if (c.nodeType === 3) own += c.textContent;-                    if (own.toLowerCase().indexOf(txt.toLowerCase()) !== -1) { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0) marks.push(e); }-                    if (e.shadowRoot) walk(e.shadowRoot); } };-                  walk(document);-                  for (const m of marks) { const c = m.closest(closest); if (c) { el = c; break; } }-                } else { el = window.deepText(txt); }-              } else if (el && closest) { el = el.closest(closest) || el; }-              return el;-            }, args.selector || null, args.text || null, args.closest || null);-            const el = handle.asElement();-            if (!el) {-              try { await handle.dispose(); } catch {}-              sendJSON(res, { success: false, errorCode: 'element_not_found', error: `pup_screenshot_element: no element matched ${args.selector ? 'selector "'+args.selector+'"' : 'text "'+args.text+'"'}${args.closest ? ' with a closest("'+args.closest+'") ancestor' : ''} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId, _hint: 'If matching by text is ambiguous (the phrase also appears in prose), pass a `selector` for the widget, or a `closest` selector for its card container.' });-              return;-            }-            await el.evaluate((e) => e.scrollIntoView({ block: 'center', inline: 'center' }));-            await new Promise(r => setTimeout(r, 150)); // let layout settle after scroll-            const pad = Number(args.padding) || 0;-            // Document-relative clip (getBoundingClientRect + scroll) + captureBeyondViewport —-            // this is coordinate-correct even when the element is below the fold (puppeteer's-            // boundingBox()/clip spaces mismatched and captured the wrong region).-            const rect = await el.evaluate((e, p) => { const r = e.getBoundingClientRect();-              return { x: r.left + (window.scrollX || 0) - p, y: r.top + (window.scrollY || 0) - p, width: r.width + 2 * p, height: r.height + 2 * p, vw: r.width, vh: r.height };-            }, pad);-            if (rect.vw < 1 || rect.vh < 1) throw new Error('element has no rendered box (hidden/zero-size)');-            const rawBuf = await withTimeout(tab.page.screenshot({ type: 'png', captureBeyondViewport: true, clip: { x: Math.max(0, Math.round(rect.x)), y: Math.max(0, Math.round(rect.y)), width: Math.round(rect.width), height: Math.round(rect.height) } }), 20_000, 'element.screenshot(clip)');-            try { await handle.dispose(); } catch {}-            const shot = await processScreenshot(rawBuf, MAX_SCREENSHOT_DIM);-            const ts = Date.now();-            const filePath = path.join(SHOTS_DIR, `pup-el-${ts}.png`);-            fs.writeFileSync(filePath, shot.buf);-            session.latestShot = filePath;-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, matchedBy: args.selector ? 'selector' : 'text', sizeKB: Math.round(shot.buf.length / 1024), filePath, base64: shot.buf.toString('base64'), width: shot.width, height: shot.height, origWidth: shot.origWidth, origHeight: shot.origHeight, resized: shot.resized, _hint: 'Captured just that element (scrolled into view first). Read the filePath to see it.' }) });-          } catch (e) {-            try { if (handle) await handle.dispose(); } catch {}-            sendJSON(res, { success: false, error: `pup_screenshot_element failed: ${e.message}` });-          }-          return;-        }-        case 'pup_screenshot_full_res': {-          // v1.8.40+: full-resolution variant for the rare "save to disk for-          // external use" case (export to wiki, hand to a designer, etc.).-          // NEVER pass the resulting image to Claude via Read — full-res-          // browser viewports are typically 2559x1398 (or larger on hi-DPI-          // monitors), which exceeds Claude's 2000px many-image limit and-          // crashes the session. For AI-readable screenshots, use-          // pup_screenshot which forces a ≤1568px resize.-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          const fullPage = args.fullPage || false;-          const ts = Date.now();-          const screenshotBuf = await withTimeout(-            tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage }),-            20_000, 'page.screenshot'-          );-          // Probe dimensions for the warning payload — cheap. No resize, no-          // recompression — the bytes that hit disk are exactly what Chrome captured.-          const meta = sharp ? await sharp(screenshotBuf).metadata() : pngDimensions(screenshotBuf);-          const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`);-          fs.writeFileSync(filePath, screenshotBuf);-          const sizeKB = Math.round(screenshotBuf.length / 1024);-          session.latestShot = filePath;-          const base64 = screenshotBuf.toString('base64');-          sendJSON(res, {-            success: true,-            output: JSON.stringify({-              ok: true,-              sizeKB,-              filePath,-              base64,-              width: meta.width,-              height: meta.height,-              sessionId: resolvedId,-              tabId,-              _warning: `FULL RESOLUTION (${meta.width}x${meta.height}px) — do NOT pass this image to Claude via the Read tool. Images >2000px crash many-image sessions at the Claude API limit. Use pup_screenshot instead for AI-readable screenshots (auto-resized to ≤1568px). This command is for saving to disk / wiki / export only.`,-            }),-          });-          return;-        }-        case 'pup_hover': {-          // v1.9.198 (Bills thread): pup_hover did not exist — the only way to move the pointer-          // without clicking was pup_input_dispatch {type:'move'}, which is unobvious next to-          // pup_click. Hovering also reveals tooltips/menus that only appear on pointer-over.-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          try {-            let px = Number(args.x), py = Number(args.y);-            if (args.selector) {-              const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });-              px = box.x; py = box.y;-            }-            if (!Number.isFinite(px) || !Number.isFinite(py)) {-              sendJSON(res, { success: false, errorCode: 'hover_target_missing', error: 'pup_hover needs selector, or x + y.' });-              return;-            }-            const _hIntent = (typeof args.intent === 'string' && args.intent.trim()) ? args.intent.trim() : `Hovering ${args.selector ? `"${args.selector}"` : 'target'}`;-            if (args.showCursor !== false) { try { await showPupCursor(tab.page, px, py, { click: false, label: _hIntent }); } catch (e) {} }-            await withTimeout(tab.page.mouse.move(px, py, { steps: Number(args.steps) || 8 }), 10_000, 'mouse.move');-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, hovered: args.selector || { x: px, y: py },-              _hint: 'Pointer moved (isTrusted) and the visible Adom cursor glided there. Tooltips/hover menus that need a real pointer-over should now be present — read them with pup_eval.' }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'hover_failed', error: `pup_hover failed: ${e.message}` });-          }-          return;-        }-        case 'pup_set_identity': {-          // v1.9.343 (John: "put my Adom profile picture in there — show my adom identity"). Chrome's-          // own profile chip refuses a custom local image (only a real Google sign-in gets one), so we-          // show the user's Adom identity in the floating owner chip instead. This verb stores the-          // avatar (a pre-circle-cropped PNG as base64, so the bridge stays image-lib-free here) + a-          // display name into pup-settings, and repaints every live window's chip so it shows at once.-          try {-            const patch = {};-            if (args.clear) { patch.identityAvatarB64 = ''; patch.identityName = ''; }-            if (typeof args.avatarB64 === 'string') patch.identityAvatarB64 = args.avatarB64.replace(/^data:image\/\w+;base64,/, '');-            if (typeof args.name === 'string') patch.identityName = args.name;-            if (args.enabled === true || args.enabled === false) patch.ownerChip = args.enabled ? 'on' : 'off';-            savePupSettings(patch);-            // Repaint live windows NON-BLOCKING (each per-page evaluate is timeout-bounded inside-            // applyOwnerChipSetting): remove the current chip, then re-add with the new identity if the-            // chip is on. Fire-and-forget so a slow page can never make THIS verb time out.-            applyOwnerChipSetting(false)-              .then(() => { if (pupSettings().ownerChip === 'on') return applyOwnerChipSetting(true); })-              .catch(() => {});-            const s = pupSettings();-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true,-              identityName: s.identityName || null,-              hasAvatar: !!s.identityAvatarB64,-              avatarBytes: s.identityAvatarB64 ? Buffer.from(s.identityAvatarB64, 'base64').length : 0,-              ownerChip: s.ownerChip,-              _hint: 'Adom identity set. It shows as the avatar leading the floating owner chip (bottom-left) on every pup window, applied live. Chrome\'s own profile chip cannot take a custom local image (Google sign-in only), so this is where your Adom identity lives. Turn the chip off with ownerChip:off or {enabled:false}; clear the identity with {clear:true}.' }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'set_identity_failed', error: `pup_set_identity failed: ${e.message}` });-          }-          return;-        }-        case 'pup_maximize': {-          // v1.9.197 GAP 6 (Bills thread): honouring "pup windows must be maximized" required-          // pup_eval + window.resizeTo(...) because desktop_set_window_state is not in every-          // desktop's verb set. This does it properly via CDP Browser.setWindowBounds (the same-          // channel park uses), so it works on any host and never depends on an AD verb being present.-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          try {-            const cdp = await session.page.target().createCDPSession();-            const { windowId } = await cdp.send('Browser.getWindowForTarget');-            const want = (args.state === 'normal' || args.state === 'minimized' || args.state === 'fullscreen') ? args.state : 'maximized';-            // A window must be 'normal' before another state can be applied.-            try { await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } }); } catch (e) {}-            await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: want } });-            let bounds = null;-            try { ({ bounds } = await cdp.send('Browser.getWindowBounds', { windowId })); } catch (e) {}-            try { await cdp.detach(); } catch (e) {}-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, state: want, bounds,-              _hint: 'Window state set via CDP. This does NOT foreground the window — it stays background-parked unless you explicitly raise it (pup_raise_os_window with a foregroundReason).' }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'maximize_failed', error: `pup_maximize failed: ${e.message}` });-          }-          return;-        }-        // pup_evaluate is the catalog/manifest name; pup_eval the historical one. Accept both.-        // v2.0.255 (John, adom/pup-bridge#65): this alias was written as a fallthrough label above-        // `case 'pup_hover'` instead of here, so EVERY pup_evaluate call ran the HOVER handler and-        // came back "pup_hover needs selector, or x + y" / hover_target_missing — an error naming a-        // verb the caller never called, while the _hint stayed pup_evaluate's own (that split is the-        // tell for a misdispatch, and it is worth remembering: right docs + wrong error = routing).-        // It made the only verb that can READ the DOM unusable, so a page could be driven but never-        // asserted on, which is exactly where it left John mid-ralph.-        case 'pup_evaluate':-        case 'pup_eval': {-          // He probed expr/expression/script/js/code, all four failing identically because none of-          // them ever reached this handler. Since the catalog did not state the arg name either,-          // accept the obvious synonyms rather than making anyone guess again.-          for (const _a of ['expression', 'script', 'js', 'code']) { if (!args.expr && args[_a]) args.expr = args[_a]; }-          if (!args.expr) {-            sendJSON(res, { success: false, errorCode: 'eval_expr_missing', error: 'Missing required parameter "expr" (aliases: "expression", "script", "js", "code"). Usage: pup_eval { "sessionId": "...", "expr": "document.title" }' });-            return;-          }-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          // v1.8.21: ensure the shadow-DOM helpers are present so an expr can use-          // $deep(sel) / $$deep(sel) / deepText(txt) without hand-rolling a shadow walk.-          await ensureDeepHelpers(tab.page);-          // v1.9.197 GAP 5 (Bills thread): pup_eval only ever ran in the TOP frame, so every call-          // against a login form inside an iframe had to be hand-wrapped in-          // document.getElementById('logonbox').contentDocument — which only works because that frame-          // happened to be SAME-ORIGIN. A cross-origin login iframe (very common) was simply-          // unreachable. `frame` selects the execution context by name/id, url substring, or index;-          // puppeteer talks to each frame over its own CDP session, so cross-origin works too.-          let _evalCtx = tab.page;-          if (args.frame !== undefined && args.frame !== null && args.frame !== '') {-            const _frames = tab.page.frames();-            const _fq = String(args.frame);-            let _f = null;-            if (/^\d+$/.test(_fq)) _f = _frames[Number(_fq)] || null;-            if (!_f) _f = _frames.find(fr => { try { return fr.name() === _fq; } catch (e) { return false; } }) || null;-            if (!_f) _f = _frames.find(fr => { try { return (fr.url() || '').includes(_fq); } catch (e) { return false; } }) || null;-            if (!_f) {-              const _avail = _frames.map((fr, i) => { let nm = '', u = ''; try { nm = fr.name(); } catch (e) {} try { u = fr.url(); } catch (e) {} return `[${i}] name=${nm || '(none)'} url=${(u || '').slice(0, 70)}`; });-              sendJSON(res, { success: false, errorCode: 'frame_not_found',-                error: `No frame matched "${_fq}" in session "${resolvedId}".`,-                frames: _avail,-                _hint: `frame matches by frame NAME, then by URL substring, then by numeric index. Frames currently in this tab:\n${_avail.join('\n')}\nNote an iframe's id attribute is NOT its frame name — use its name attribute, a distinctive piece of its src URL, or the index. Omit frame to run in the top document.` });-              return;-            }-            _evalCtx = _f;-          }-          let result;-          try {-            result = await withTimeout(-              raceCrash(tab, resolvedId, _evalCtx.evaluate(new Function('return (' + args.expr + ')'))),-              20_000, 'page.evaluate'-            );-          } catch (e) {-            // v1.9.158: a DETACHED page handle is a recovery signal, not a result. Re-resolve the-            // session's live page by window.name (the durable carrier) and retry ONCE — this is the-            // armor for the detached-frame-under-load failure (two sessions went permanently-            // undrivable in the 2026-07-27 heavy run because this error was returned as __error).-            if (/detached Frame|Session closed|Target closed/i.test(e.message || '')) {-              try {-                const be2 = browsers.get(session.profileName);-                let fresh = null;-                if (be2 && be2.browser.isConnected()) {-                  for (const pg of await be2.browser.pages()) {-                    const wn = await pg.evaluate(() => window.name).catch(() => '');-                    if (wn === 'pup:' + resolvedId) { fresh = pg; break; }-                  }-                }-                if (fresh) {-                  tab.page = fresh;-                  try { fresh._pupCreated = true; } catch (e3) {}-                  console.log(`[recover] "${resolvedId}" page handle was detached — re-resolved live page by window.name, retrying eval`);-                  result = await withTimeout(fresh.evaluate(new Function('return (' + args.expr + ')')), 20_000, 'page.evaluate(retry)');-                } else { result = { __error: e.message + ' (re-resolve by window.name found no live page)' }; }-              } catch (e2) { result = { __error: e.message + ' (recovery retry failed: ' + e2.message + ')' }; }-            } else {-              result = { __error: e.message };-            }-          }-          // v2.0.274 (#69, second half): STATEMENTS, and stop swallowing the error.-          // The eval above is `new Function('return (' + expr + ')')` — EXPRESSION-only. The reporter-          // sent `location.assign('...'); 'navigating'`, which is two statements, so it was a-          // SyntaxError: nothing ran, the page never navigated, and the handler still answered-          // ok:true with the failure buried in result.__error. Measured verbatim:-          //     result: {"__error": "Unexpected token ';'"}   returned as a SUCCESS-          // Fix 1: on a SYNTAX error, retry through CDP Runtime.evaluate, which has devtools-console-          // semantics — it accepts a statement list and returns the completion value, so the same-          // source works and 'navigating' comes back.-          if (result && result.__error && /Unexpected token|SyntaxError|Unexpected end of input/i.test(String(result.__error))) {-            let cdpSession = null;-            try {-              cdpSession = await _evalCtx.target().createCDPSession();-              const r = await withTimeout(cdpSession.send('Runtime.evaluate', {-                expression: String(args.expr), returnByValue: true, awaitPromise: true, userGesture: true,-              }), 20_000, 'Runtime.evaluate');-              if (r && r.exceptionDetails) {-                result = { __error: (r.exceptionDetails.exception && r.exceptionDetails.exception.description) || r.exceptionDetails.text || 'evaluation threw' };-              } else {-                result = r && r.result ? r.result.value : null;-                console.log(`[eval] "${resolvedId}" expression form was a syntax error — ran it as STATEMENTS via Runtime.evaluate (devtools-console semantics)`);-              }-            } catch (e) {-              // A navigation started by the source tears down the context; that is the code WORKING.-              if (/context was destroyed|Execution context|Target closed/i.test(e.message || '')) {-                console.log(`[eval] "${resolvedId}" execution context was destroyed mid-eval — that means the page NAVIGATED, which is success, not failure`);-                result = { navigated: true };-              }-            } finally { try { if (cdpSession) await cdpSession.detach(); } catch (e) {} }-          }-          // Fix 2: a failed eval is a FAILED verb. #69's whole complaint is verbs that answer ok-          // while nothing happened; returning {ok:true, result:{__error}} is that exact lie.-          if (result && typeof result === 'object' && result.__error) {-            sendJSON(res, { success: false, errorCode: 'eval_failed', error: `pup_eval could not run your code: ${result.__error}`,-              _hint: 'The expression form is tried first, then a statement list via Runtime.evaluate. If this is a syntax error, check the source; if it names a detached frame or closed target, the tab moved and pup already retried once.' });-            return;-          }-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, result, sessionId: resolvedId, tabId }) });-          return;-        }-        // ── Element-level input + history nav (v1.8.8) ─────────────────────────-        // These five were advertised in bridge.json + pup_describe but had no-        // router cases (found by the full verb self-test). Selector-based where it-        // helps; same session/tab auto-recovery as pup_eval.-        case 'pup_click': {-          if (!args.selector && !(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {-            sendJSON(res, { success: false, error: 'pup_click needs "selector" OR "x"+"y". Usage: { "sessionId": "...", "selector": "#submit" } or { "sessionId": "...", "x": 100, "y": 200 }' });-            return;-          }-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          try {-            // v1.9.164: show the animated cursor gliding to the target BEFORE the real click, so the-            // user can see pup is driving. Default on; {showCursor:false} skips it.-            const _cursor = args.showCursor !== false;-            // v1.9.197 BUG 2: build real click options so clickCount/button/delay actually apply.-            const _cc = Number(args.clickCount);-            const _clickOpts = {};-            if (Number.isFinite(_cc) && _cc >= 1) _clickOpts.clickCount = Math.min(3, Math.round(_cc));-            if (typeof args.button === 'string') _clickOpts.button = args.button;-            if (Number.isFinite(Number(args.delay))) _clickOpts.delay = Number(args.delay);-            // v1.9.196: the cursor LABEL must narrate WHAT is being clicked and WHY — that is the whole-            // point of showing a cursor (John: "i can't even read the indicator text you're supposed to-            // put in there explaining your reasoning"). Caller may pass `intent`; otherwise pup composes-            // an HD-style narration from the target itself, so the label is never a bare "Adom".-            const _tgt = args.selector || (args.text ? `"${args.text}"` : null);-            const _intent = (typeof args.intent === 'string' && args.intent.trim())-              ? args.intent.trim()-              : (args.reason && String(args.reason).trim() ? String(args.reason).trim() : (_tgt ? `Clicking ${_tgt}` : 'Clicking'));-            let _method = 'cdp';-            if (args.selector && args.domClick === true) {-              // v2.0.128 (consumer bug report #2): some React controls (heart/like/vote toggles)-              // ignore a CDP "trusted" mouse click but DO fire on a synthetic element.click(). This-              // is the opposite of pup's usual advice, so it's opt-in — the miss-hint below points-              // callers here when a trusted click produced no state change.-              _method = 'domClick';-              const dr = await withTimeout(tab.page.evaluate((sel) => {-                const el = document.querySelector(sel);-                if (!el) return { found: false };-                el.scrollIntoView({ block: 'center', inline: 'center' });-                el.click();-                return { found: true };-              }, args.selector), 8_000, 'domClick.evaluate');-              if (!dr.found) { sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_click(domClick): selector "${args.selector}" not found.`, _hint: `Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` }); return; }-            } else if (args.selector) {-              // v2.0.128 (consumer bug report #3): page.click(selector) re-resolves the element and-              // waits on Puppeteer's visibility machinery, which crawls on huge/animated DOMs and hit-              // the 15s wall — while a plain coordinate click on the SAME element returned instantly.-              // So: locate the element ONCE (scroll into view, read its center), then click by those-              // coordinates — the fast path the caller proved works. Only fall back to page.click when-              // the box can't be resolved (0-size / not laid out yet).-              let _box = null;-              try {-                _box = await withTimeout(tab.page.evaluate((sel) => {-                  const el = document.querySelector(sel);-                  if (!el) return null;-                  el.scrollIntoView({ block: 'center', inline: 'center' });-                  const r = el.getBoundingClientRect();-                  if (r.width < 1 || r.height < 1) return null;-                  return { x: r.x + r.width / 2, y: r.y + r.height / 2 };-                }, args.selector), 6_000, 'locate.box');-              } catch (e) {}-              if (_box) {-                if (_cursor) { await showPupCursor(tab.page, _box.x, _box.y, { label: _intent }); await new Promise(r => setTimeout(r, 440)); }-                _method = 'coordinate';-                await withTimeout(tab.page.mouse.click(_box.x, _box.y, _clickOpts), 10_000, 'mouse.click(box)');-              } else {-                // element not found/visible yet — bounded wait, then Puppeteer's own click (honours clickCount).-                _method = 'page.click';-                await withTimeout(tab.page.click(args.selector, _clickOpts), 12_000, 'page.click');-              }-            } else {-              if (_cursor) { await showPupCursor(tab.page, Number(args.x), Number(args.y), { label: _intent }); await new Promise(r => setTimeout(r, 440)); }-              _method = 'coordinate';-              await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y), _clickOpts), 10_000, 'mouse.click');-            }-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: args.selector || { x: Number(args.x), y: Number(args.y) }, method: _method, sessionId: resolvedId, tabId, _hint: (args.selector && _method !== 'domClick') ? 'Click dispatched (isTrusted). Verify the effect with pup_eval (a state probe) — a click that hit nothing still returns ok. If a React toggle/like/heart did NOT change state, that control ignores trusted clicks: retry pup_click {selector, domClick:true} to fire the synthetic handler.' : 'Click dispatched. Verify the effect with pup_eval (e.g. document.title or a state probe) — a click that hit nothing still returns ok.' }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'click_failed', error: `pup_click failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist/be visible. Probe it first: pup_eval {"expr":"!!document.querySelector('${args.selector}')"} — or click by x/y from a pup_screenshot.` : 'x/y click failed — coordinates are page-viewport pixels; take a pup_screenshot to aim.' });-          }-          return;-        }-        // v1.9.167 — ASSISTED FOREGROUND CLICK (John's full protocol) for surfaces pup's CDP click-        // can't drive (native dialog, cross-origin FedCM chooser). Caption the reasoning, toast,-        // foreground, WAIT until the user is actually on the window (alt-tab aware), then a TIGHT-        // real-cursor hover->click, captioning each step. x,y = WINDOW-image pixels from a-        // desktop_screenshot_window you took to find the target. reason=why, label=what.-        case 'pup_assisted_click': {-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          if (!(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {-            sendJSON(res, { success: false, error: 'pup_assisted_click needs window-image x+y (from a desktop_screenshot_window of this session) plus reason+label.' }); return;-          }-          if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'assisted click is Windows-only (uses the OS cursor + foreground detection).' }); return; }-          const _reason = String(args.reason || 'Adom needs to click something on your screen.').slice(0, 180);-          const _label = String(args.label || 'the highlighted control').slice(0, 80);-          try {-            await pupCaption(`Adom: ${_reason} I will bring this window up and WAIT until you are ready before clicking ${_label}.`, 9000);-            try { await chrome.abCommand('notify_user', { title: 'Adom needs one click', body: `${_label} — ${_reason} I will wait until you are on the window.`, focus: false }, { timeoutMs: 4000 }); } catch (e) {}-            session._foreground = true; session._lastForeground = { ts: Date.now(), verb: command, reason: _reason };-            try { await osRaiseSessionWindow(session, resolvedId, null); } catch (e) {}   // assisted_click narrates its own captions-            const hwnd = await sessionHwnd(resolvedId);-            // v2.0.397 (#92): the in-verb wait is capped under every ab budget; a longer human-            // absence answers blocked_on_user (the caller re-issues the click when the user is back).-            const _maxWait = Math.min(Number(args.maxWaitMs) || 120000, 45000);-            const ready = await waitForUserReady(resolvedId, hwnd, { maxMs: _maxWait });-            if (!ready) {-              await pupCaption('Adom is still waiting for you to come back to this window; your AI will ask again when you are ready.', 7000);-              sendJSON(res, blockedOnUser({ stillRunning: false, reason: 'user_not_ready', blockedBy: 'The user must bring the pup window to the foreground (click it or Alt-Tab to it) so the real-cursor click lands in the right window', waitedSec: Math.round(_maxWait / 1000), error: 'Waited for the user to focus the window; they did not. Nothing was clicked.', _hint: 'Not a failure: the human was not at the window. Tell them what you need clicked and where, then re-issue pup_assisted_click with the same args when they say they are ready; it waits again (up to 45s per call).' }));-              return;-            }-            await pupCaption(`Adom is clicking ${_label} now.`, 5000);-            const shot = abPayload(await chrome.abCommand('desktop_screenshot_window', { ...(await windowTarget(sessions.get(resolvedId), resolvedId)) }, { timeoutMs: 8000, caller: sessionCaller(session) }));-            const shotId = shot && (shot.shotId || (shot.screenshots && shot.screenshots[0] && shot.screenshots[0].shotId));-            if (!shotId) { sendJSON(res, { success: false, error: 'could not capture the window to aim the click' }); return; }-            const X = Math.round(Number(args.x)), Y = Math.round(Number(args.y));-            try { await chrome.abCommand('desktop_hover', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}-            await new Promise(r => setTimeout(r, 900));-            try { await chrome.abCommand('desktop_click', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}-            await pupCaption(`Adom clicked ${_label}. Continuing.`, 5000);-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: _label, x: X, y: Y, sessionId: resolvedId }), _hint: `Assisted click done after the user was confirmed on the window. Verify the effect with a screenshot before assuming it advanced.` });-          } catch (e) {-            sendJSON(res, { success: false, error: `assisted click failed: ${e.message}` });-          }-          return;-        }-        case 'pup_type': {-          if (typeof args.text !== 'string') {-            sendJSON(res, { success: false, error: 'pup_type needs "text" (string). Optional "selector" focuses the element first. Usage: { "sessionId": "...", "selector": "#q", "text": "hello" }' });-            return;-          }-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          try {-            // v1.9.197 BUG 3 (Bills thread, top priority): pup_type ALWAYS APPENDED, and with-            // modifiers broken (bug 1) and clickCount ignored (bug 2) there was NO way to replace a-            // field's value at all — the caller got "adominc1adominc1" and fell back to a bash loop of-            // 20 Backspaces. `clear:true` now empties the field first, using REAL key events-            // (select-all then Delete) rather than setting .value, because React-controlled inputs-            // ignore direct value writes — the Chase login form is exactly that case.-            if (args.clear) {-              if (args.selector) { await withTimeout(tab.page.focus(args.selector), 8_000, 'page.focus'); }-              const _selMod = process.platform === 'darwin' ? 'Meta' : 'Control';-              await tab.page.keyboard.down(_selMod);-              try { await tab.page.keyboard.press('a'); } finally { try { await tab.page.keyboard.up(_selMod); } catch (e) {} }-              await tab.page.keyboard.press('Delete');-            }-            if (args.selector) await withTimeout(tab.page.type(args.selector, args.text), 15_000, 'page.type');-            else await withTimeout(tab.page.keyboard.type(args.text), 15_000, 'keyboard.type');-            // v2.0.128 (consumer bug report #1 — highest priority): pup_type reported ok:true on a-            // field that stayed EMPTY, so a sign-in silently no-opped while pup claimed success. Read-            // the value back when we have a selector, and if what we typed did not land, still return-            // ok:true (the keystrokes WERE dispatched) but flag valueLanded:false with the real reason:-            // CDP/page.keyboard keystrokes only register in a FOREGROUNDED window, and this window is-            // very likely in the background. Point at the two working paths.-            let _valueLanded = null, _readLen = null;-            if (args.selector) {-              try {-                _readLen = await withTimeout(tab.page.$eval(args.selector, el => {-                  if (el == null) return -1;-                  if (typeof el.value === 'string') return el.value.length;-                  if (el.isContentEditable) return (el.textContent || '').length;-                  return -2; // element exists but has no value/textContent we can read-                }), 5_000, 'verify.$eval');-                if (_readLen >= 0) _valueLanded = _readLen >= Math.min(args.text.length, 1);-              } catch (e) { _readLen = null; }-            }-            const _emptyMiss = _valueLanded === false && args.text.length > 0;-            const _out = { ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId };-            if (args.selector) { _out.valueLanded = _valueLanded; _out.fieldValueLength = _readLen; }-            _out._hint = _emptyMiss-              ? '⚠ KEYSTROKES DISPATCHED BUT THE FIELD IS STILL EMPTY — this is the silent no-op. CDP/page.keyboard keystrokes ONLY register when the window is FOREGROUNDED, and this window is almost certainly in the background. Two fixes: (1) for a NON-React input, set the value directly — pup_eval will not (expression-only), so use pup_fill {selector,text} which sets .value via the native setter + dispatches input/change and works in the background; (2) for a login form (React-masked fields, real keystrokes required) use pup_login {username,password,realKeystrokes:true}, which foregrounds the window automatically. Do NOT trust a bare ok:true from pup_type on a login field — check valueLanded.'-              : (_valueLanded === true ? 'Typed via CDP (isTrusted); value verified present in the field.' : 'Typed via CDP (isTrusted). Could not read the field back to verify (no selector, or non-standard element) — confirm with a screenshot before assuming it landed.');-            sendJSON(res, { success: true, output: JSON.stringify(_out) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'type_failed', error: `pup_type failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist. Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` : 'No selector given — ensure an element has focus (pup_click it first).' });-          }-          return;-        }-        // v2.0.128 (consumer bug report #1): the BACKGROUND-safe fallback that pup_type's miss-hint-        // points at. Sets the field's value via the native property setter and dispatches input +-        // change so React/Vue/Angular controlled inputs pick it up — no foreground, no keystrokes.-        // Works for the common "logged-out form filled while the window is in the background" case.-        // For React-MASKED password fields that ignore programmatic value writes, pup_login-        // {realKeystrokes:true} is still the answer (it foregrounds + sends real keys). pup_fill-        // ALWAYS reads the value back and reports valueLanded so a caller never gets a false ok.-        case 'pup_fill': {-          if (!args.selector || typeof args.text !== 'string') {-            sendJSON(res, { success: false, error: 'pup_fill needs "selector" and "text" (string). It sets .value via the native setter + fires input/change (React-safe, works in the background). Usage: { "sessionId": "...", "selector": "#email", "text": "[email protected]" }' });-            return;-          }-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          try {-            const r = await withTimeout(tab.page.evaluate((sel, val) => {-              const el = document.querySelector(sel);-              if (!el) return { found: false };-              // Use the prototype's native value setter so React's descriptor override doesn't swallow it.-              try {-                const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;-                const desc = Object.getOwnPropertyDescriptor(proto, 'value');-                if (desc && desc.set) desc.set.call(el, val); else el.value = val;-              } catch (e) { try { el.value = val; } catch (e2) {} }-              el.dispatchEvent(new Event('input', { bubbles: true }));-              el.dispatchEvent(new Event('change', { bubbles: true }));-              const got = (typeof el.value === 'string') ? el.value : (el.textContent || '');-              return { found: true, landedLen: got.length, matched: got === val };-            }, args.selector, args.text), 8_000, 'pup_fill.evaluate');-            if (!r.found) {-              sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_fill: selector "${args.selector}" not found.`, _hint: `Probe it: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` });-              return;-            }-            const _landed = r.matched === true || r.landedLen >= Math.min(args.text.length, 1);-            sendJSON(res, { success: true, output: JSON.stringify({-              ok: true, filled: args.selector, valueLanded: _landed, fieldValueLength: r.landedLen, sessionId: resolvedId, tabId,-              _hint: _landed-                ? 'Set via native setter + input/change (background-safe). If a React SUBMIT still rejects it as empty, the field is masked/guarded — use pup_login {username,password,realKeystrokes:true} which foregrounds + sends real keystrokes.'-                : '⚠ Value did not stick — this field guards against programmatic writes (React-masked password fields do this). Use pup_login {realKeystrokes:true} (foregrounds + real keys) instead.'-            }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'fill_failed', error: `pup_fill failed: ${e.message}`, _hint: 'The page may have navigated. Re-check the field with pup_eval, or retry.' });-          }-          return;-        }-        case 'pup_press_key': {-          if (!args.key) {-            sendJSON(res, { success: false, error: 'pup_press_key needs "key" — a puppeteer KeyInput name like "Enter", "Tab", "Escape", "ArrowDown", "a". Usage: { "sessionId": "...", "key": "Enter" }' });-            return;-          }-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          try {-            // v1.9.197 BUG 1 (Bills thread): modifiers were parsed by NOBODY — keyboard.press(key)-            // ignores them entirely, so Ctrl+A typed a literal "a" into the field ("adominc1" ->-            // "adominc1a") and every chord (Ctrl+A/C/V) was unusable. Hold each modifier down, press,-            // then release in reverse order — the CDP-correct way to send a real chord.-            const _mods = Array.isArray(args.modifiers) ? args.modifiers.filter(m => typeof m === 'string' && m.trim()) : [];-            const _MODMAP = { control: 'Control', ctrl: 'Control', cmd: 'Meta', command: 'Meta', meta: 'Meta', alt: 'Alt', option: 'Alt', shift: 'Shift' };-            const _norm = _mods.map(m => _MODMAP[String(m).toLowerCase()] || m);-            const _bad = _norm.filter(m => !['Control', 'Meta', 'Alt', 'Shift'].includes(m));-            if (_bad.length) {-              sendJSON(res, { success: false, errorCode: 'bad_modifier', error: `Unknown modifier(s): ${_bad.join(', ')}.`, _hint: 'modifiers must be any of ["Control","Shift","Alt","Meta"] (aliases: ctrl, cmd, command, option accepted).' });-              return;-            }-            for (const m of _norm) await tab.page.keyboard.down(m);-            try {-              await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');-            } finally {-              for (const m of [..._norm].reverse()) { try { await tab.page.keyboard.up(m); } catch (e) {} }-            }-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, pressed: args.key, sessionId: resolvedId, tabId, _hint: 'Key pressed in the PAGE (isTrusted). For OS-level chords outside the page (alt+f4, ctrl+shift+p in browser chrome), use AD\'s desktop_press_key instead.' }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'press_key_failed', error: `pup_press_key failed: ${e.message}`, _hint: `"${args.key}" must be a valid puppeteer KeyInput name (case-sensitive: "Enter", "ArrowLeft", "F5", single chars like "a").` });-          }-          return;-        }-        case 'pup_back':-        case 'pup_forward': {-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          const dir = command === 'pup_back' ? 'back' : 'forward';-          try {-            let before = null; try { before = tab.page.url(); } catch {}-            // goBack/goForward resolve null on a CACHED history nav even though the URL-            // DID change, so we detect "moved" by the URL actually changing, not the-            // (unreliable) return value.-            await withTimeout(dir === 'back' ? tab.page.goBack({ waitUntil: 'domcontentloaded' }) : tab.page.goForward({ waitUntil: 'domcontentloaded' }), 20_000, 'history.' + dir).catch(() => {});-            let url = null; try { url = tab.page.url(); } catch {}-            const moved = !!(url && before && url !== before);-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, direction: dir, moved, url, sessionId: resolvedId, tabId, _hint: moved ? `Now at ${url}.` : `No ${dir === 'back' ? 'earlier' : 'later'} history entry — the page did not move (moved:false, not an error).` }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: dir + '_failed', error: `pup_${dir} failed: ${e.message}` });-          }-          return;-        }-        case 'pup_input_dispatch': {-          // Trusted input via puppeteer's CDP-backed page.mouse / page.keyboard.-          // Events fired here have isTrusted=true (Chromium dispatches them at the-          // browser level via Input.dispatchMouseEvent / dispatchKeyEvent), unlike-          // page-side dispatchEvent(new MouseEvent(...)) which is isTrusted=false-          // and silently no-ops on framework click handlers / anti-automation gates.-          //-          // Types:-          //   click  — { x, y } coords, OR { selector } resolves bounding rect-          //   move   — { x, y } mouse move (useful for human-like trajectories)-          //   type   — { text } types into the focused element-          //   key    — { key } presses a single key (Enter, Escape, Tab, ArrowUp, etc.)-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }--          const t = (args.type || '').toLowerCase();-          if (!t) {-            sendJSON(res, { success: false, error: 'pup_input_dispatch: "type" required (click|move|type|key)' });-            return;-          }-          try {-            if (t === 'click') {-              if (args.selector) {-                // SMART SELECTOR PICK (v1.4.8+):-                //-                // Puppeteer's page.click(selector) blindly takes the first-                // document-order match. That bit users on WAGO when-                // `button.wg-button--primary` matched 4 elements (a 54x54-                // cookie-banner X with no text, two visible Add-to-cart-                // buttons, the actual Generate-datasheet button) — and the-                // cookie X got clicked. The relay returned ok:true because-                // a trusted click DID dispatch — just on the wrong element.-                //-                // We now run all matches through page.evaluate() to:-                //   1. Filter out hidden elements (display:none, opacity:0,-                //      offscreen, zero-area, behind the topmost stacking-                //      context modal)-                //   2. Filter out elements with empty text content (modal-                //      close buttons / icon-only utility buttons usually-                //      aren't what the caller wanted when they passed a-                //      class selector)-                //   3. Tie-break by visual area (largest wins — the actual-                //      submit button beats a small icon button)-                //-                // The chosen element's index, rect, and visible text are-                // returned in the response so the caller can sanity-check.-                // Pass `firstMatch:true` to skip filtering and use legacy-                // first-document-order behavior.-                const matches = await tab.page.$$(args.selector);-                if (matches.length === 0) {-                  sendJSON(res, { success: false, error: `selector not found: ${args.selector}`, _hint: 'Selector matches no element. Verify the page is loaded (pup_wait) and the selector is correct (try with pup_eval first).' });-                  return;-                }--                let chosenHandle = matches[0];-                let chosenIndex = 0;-                let chosenMeta = null;-                // v1.4.10: surface which DOM element won the modal-root-                // detection in the response, so callers can debug false--                // positives. Stays null in the single-match / firstMatch-                // branches since modal scoping doesn't apply there.-                let modalRootInfo = null;--                if (matches.length === 1 || args.firstMatch === true) {-                  // Single match OR explicit opt-out — use document-order first.-                  chosenMeta = await tab.page.evaluate((el) => {-                    if (!el) return null;-                    const r = el.getBoundingClientRect();-                    return {-                      x: Math.round(r.x), y: Math.round(r.y),-                      w: Math.round(r.width), h: Math.round(r.height),-                      text: (el.textContent || '').trim().slice(0, 80),-                    };-                  }, chosenHandle);-                } else {-                  // Multiple matches — score them and pick the best.-                  //-                  // v1.4.9+: when an open modal/dialog is on screen, restrict-                  // the candidate set to elements INSIDE it. The page behind-                  // a modal is visually inert; clicking elements behind a-                  // modal is almost never what the user intended. WAGO bit-                  // on this: their "Add to shopping cart" button (behind the-                  // modal) was 9648 px², the modal's "Generate data sheet"-                  // was 9264 px² — area-only tiebreak picked add-to-cart.-                  // Now we detect the modal root first and only look inside.-                  const evalResult = await tab.page.evaluate((selector) => {-                    // v1.4.10: A modal-root candidate is only "plausible" if it-                    // contains at least one visible interactive element with-                    // non-empty text. Without this filter, empty overlay-                    // containers (Vue-Toastification's 4 toast wrappers,-                    // notification mount points, full-screen ad scrims, etc.)-                    // win the heuristic — they're fixed/absolute, z ≥ 100, cover-                    // >25% of viewport — but contain no interactive content.-                    // Result before the fix: smart-pick scoped to the wrong-                    // root and missed the actual modal entirely (WAGO bug:-                    // pickStrategy ended up "visible-text-largest-no-modal-match"-                    // and clicked "Add to shopping cart" in the page behind-                    // the real Generate-Datasheet modal).-                    function isPlausibleModal(el) {-                      if (!el) return false;-                      const interactives = el.querySelectorAll(-                        'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex], [onclick]'-                      );-                      for (const child of interactives) {-                        const cs = getComputedStyle(child);-                        if (cs.display === 'none' || cs.visibility === 'hidden') continue;-                        if (parseFloat(cs.opacity || '1') === 0) continue;-                        const r = child.getBoundingClientRect();-                        if (r.width === 0 || r.height === 0) continue;-                        const text = (child.textContent || '').trim();-                        if (text.length > 0) return true;-                      }-                      return false;-                    }--                    // Find the topmost modal/dialog root if one is open.-                    // Order matches what a human visually sees:-                    //   1. <dialog open>                    (HTML5 native)-                    //   2. [aria-modal="true"]              (ARIA)-                    //   3. role="dialog" + visible          (ARIA fallback)-                    //   4. fixed/absolute element with z-index ≥ 100-                    //      AND covering ≥25% of viewport    (custom modals)-                    //-                    // v1.4.10: every tier is now post-filtered through-                    // isPlausibleModal so we don't latch onto empty overlays.-                    // Each tier returns the most-recently-added (last in-                    // document order) plausible candidate, walking backward-                    // through matches if the topmost is implausible.-                    function topmostModalRoot() {-                      const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));-                      for (let i = openDialogs.length - 1; i >= 0; i--) {-                        if (isPlausibleModal(openDialogs[i])) return openDialogs[i];-                      }-                      const ariaModals = Array.from(document.querySelectorAll('[aria-modal="true"]'))-                        .filter(el => {-                          const cs = getComputedStyle(el);-                          return cs.display !== 'none' && cs.visibility !== 'hidden';-                        });-                      for (let i = ariaModals.length - 1; i >= 0; i--) {-                        if (isPlausibleModal(ariaModals[i])) return ariaModals[i];-                      }-                      const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))-                        .filter(el => {-                          const cs = getComputedStyle(el);-                          if (cs.display === 'none' || cs.visibility === 'hidden') return false;-                          const r = el.getBoundingClientRect();-                          return r.width > 0 && r.height > 0;-                        });-                      for (let i = dialogs.length - 1; i >= 0; i--) {-                        if (isPlausibleModal(dialogs[i])) return dialogs[i];-                      }-                      // Custom modals (no ARIA, just a styled overlay)-                      const candidates = Array.from(document.querySelectorAll('*'))-                        .filter(el => {-                          const cs = getComputedStyle(el);-                          if (!['fixed', 'absolute'].includes(cs.position)) return false;-                          const z = parseInt(cs.zIndex || '0', 10);-                          if (isNaN(z) || z < 100) return false;-                          const r = el.getBoundingClientRect();-                          const viewportArea = window.innerWidth * window.innerHeight;-                          if (viewportArea === 0) return false;-                          return (r.width * r.height) / viewportArea > 0.25;-                        })-                        .sort((a, b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));-                      for (const c of candidates) {-                        if (isPlausibleModal(c)) return c;-                      }-                      return null;-                    }-                    const modalRoot = topmostModalRoot();-                    const modalRootInfo = modalRoot ? {-                      tag: modalRoot.tagName.toLowerCase(),-                      id: modalRoot.id || null,-                      cls: ((modalRoot.className && typeof modalRoot.className === 'string')-                        ? modalRoot.className-                        : ((modalRoot.className && modalRoot.className.baseVal) || '')).slice(0, 80),-                      z: parseInt(getComputedStyle(modalRoot).zIndex || '0', 10),-                      role: modalRoot.getAttribute('role') || null,-                      ariaModal: modalRoot.getAttribute('aria-modal') || null,-                    } : null;--                    const els = Array.from(document.querySelectorAll(selector));-                    const scored = els.map((el, idx) => {-                      const r = el.getBoundingClientRect();-                      const cs = getComputedStyle(el);-                      const visible =-                        cs.display !== 'none' &&-                        cs.visibility !== 'hidden' &&-                        parseFloat(cs.opacity || '1') > 0 &&-                        r.width > 0 && r.height > 0 &&-                        r.bottom > 0 && r.right > 0 &&-                        r.top < (window.innerHeight || 99999) &&-                        r.left < (window.innerWidth || 99999);-                      const text = (el.textContent || '').trim();-                      const area = r.width * r.height;-                      const insideModal = modalRoot != null && modalRoot.contains(el);-                      return {-                        idx,-                        x: Math.round(r.x), y: Math.round(r.y),-                        w: Math.round(r.width), h: Math.round(r.height),-                        text: text.slice(0, 80),-                        hasText: text.length > 0,-                        visible,-                        area,-                        insideModal,-                        modalDetected: modalRoot != null,-                      };-                    });-                    return { scored, modalRoot: modalRootInfo };-                  }, args.selector);-                  const scored = evalResult.scored;-                  modalRootInfo = evalResult.modalRoot;--                  // Pick priority:-                  //   1. inside-modal AND visible AND has-text → largest area-                  //   2. visible AND has-text                  → largest area-                  //   3. visible (any)                         → largest area-                  //   4. document-order first match (last resort)-                  const modalDetected = scored.length > 0 && scored[0].modalDetected;-                  const inModal = scored.filter(s => s.insideModal && s.visible && s.hasText);-                  const visibleTexted = scored.filter(s => s.visible && s.hasText);-                  const visibleOnly = scored.filter(s => s.visible);-                  let pick = null;-                  let strategy = 'visible-text-largest';-                  if (inModal.length > 0) {-                    pick = inModal.reduce((best, cur) => cur.area > best.area ? cur : best, inModal[0]);-                    strategy = 'modal-scoped-largest';-                  } else if (visibleTexted.length > 0) {-                    pick = visibleTexted.reduce((best, cur) => cur.area > best.area ? cur : best, visibleTexted[0]);-                    strategy = modalDetected ? 'visible-text-largest-no-modal-match' : 'visible-text-largest';-                  } else if (visibleOnly.length > 0) {-                    pick = visibleOnly.reduce((best, cur) => cur.area > best.area ? cur : best, visibleOnly[0]);-                    strategy = 'visible-largest';-                  }-                  if (pick) {-                    chosenIndex = pick.idx;-                    chosenHandle = matches[chosenIndex];-                    chosenMeta = pick;-                    chosenMeta._strategy = strategy;-                  } else {-                    // Nothing visible at all — keep document-order first as last resort-                    // and surface the count so caller knows ambiguity exists.-                    chosenMeta = scored[0];-                    if (chosenMeta) chosenMeta._strategy = 'fallback-first-document-order';-                  }-                }--                // v2.0.321 (#72): scroll into view, resolve the rect, then click via the ISOLATED-                // timeout-bounded CDP path (rawTrustedClick) instead of chosenHandle.click(), which-                // routes through puppeteer's wedge-prone mouse lock. isTrusted=true either way.-                try { await chosenHandle.scrollIntoView(); } catch (e) {}-                let _box = null; try { _box = await chosenHandle.boundingBox(); } catch (e) {}-                if (!_box || _box.width < 1 || _box.height < 1) { sendJSON(res, { success: false, error: 'chosen element has no visible box to click', selector: args.selector }); return; }-                await rawTrustedClick(tab, Math.round(_box.x + _box.width / 2), Math.round(_box.y + _box.height / 2), { button: args.button || 'left', clickCount: args.clickCount || 1 });-                sendJSON(res, { success: true, output: JSON.stringify({-                  ok: true,-                  sessionId: resolvedId,-                  tabId,-                  dispatched: 'click',-                  via: 'selector',-                  selector: args.selector,-                  matchedCount: matches.length,-                  chosenIndex,-                  clickedRect: chosenMeta ? { x: chosenMeta.x, y: chosenMeta.y, w: chosenMeta.w, h: chosenMeta.h } : null,-                  clickedText: chosenMeta ? chosenMeta.text : null,-                  insideModal: chosenMeta && typeof chosenMeta.insideModal === 'boolean' ? chosenMeta.insideModal : null,-                  modalDetected: chosenMeta && typeof chosenMeta.modalDetected === 'boolean' ? chosenMeta.modalDetected : null,-                  // v1.4.10: surface which DOM element won the modal-root-                  // detection so callers can debug false-positives. When the-                  // smart pick lands on the wrong element, this tells you-                  // immediately whether modal detection latched onto the-                  // intended overlay or got fooled by a toast-container etc.-                  modalRoot: modalRootInfo,-                  pickStrategy: matches.length === 1-                    ? 'only-match'-                    : (args.firstMatch === true-                      ? 'first-match'-                      : (chosenMeta && chosenMeta._strategy ? chosenMeta._strategy : 'visible-text-largest')),-                })});-                return;-              }-              if (typeof args.x !== 'number' || typeof args.y !== 'number') {-                sendJSON(res, { success: false, error: 'pup_input_dispatch click: provide either {selector} OR {x, y}' });-                return;-              }-              await rawTrustedClick(tab, args.x, args.y, { button: args.button || 'left', clickCount: args.clickCount || 1 });-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });-              return;-            }-            if (t === 'move') {-              if (typeof args.x !== 'number' || typeof args.y !== 'number') {-                sendJSON(res, { success: false, error: 'pup_input_dispatch move requires {x, y}' });-                return;-              }-              // v1.9.198 (Bills thread): a CDP move returned ok but the DRAWN cursor never budged —-              // only clicks moved it, which undercuts the whole "gliding cursor" idea. Move the-              // visible cursor with the real pointer (no click ripple).-              if (args.showCursor !== false) { try { await showPupCursor(tab.page, Number(args.x), Number(args.y), { click: false, label: args.intent || 'Moving' }); } catch (e) {} }-              await rawTrustedMove(tab, args.x, args.y);-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });-              return;-            }-            if (t === 'type') {-              if (typeof args.text !== 'string') {-                sendJSON(res, { success: false, error: 'pup_input_dispatch type requires {text}' });-                return;-              }-              // Optionally focus a selector first so keystrokes land in the right input.-              if (args.selector) {-                try { await tab.page.focus(args.selector); }-                catch (e) {-                  sendJSON(res, { success: false, error: `Failed to focus ${args.selector}: ${e.message}` });-                  return;-                }-              }-              await withTimeout(tab.page.keyboard.type(args.text, { delay: args.delay || 0 }), 15000, 'input type');-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });-              return;-            }-            if (t === 'key') {-              if (typeof args.key !== 'string') {-                sendJSON(res, { success: false, error: 'pup_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });-                return;-              }-              await withTimeout(tab.page.keyboard.press(args.key, { delay: args.delay || 0 }), 8000, 'input key');-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });-              return;-            }-            sendJSON(res, { success: false, error: `pup_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: e && e.wedge ? 'input_channel_reset' : undefined, error: `pup_input_dispatch ${t} failed: ${e.message}`, _hint: e && e.wedge ? 'The input channel was reset after a hung page handler; the session is NOT wedged — retry the dispatch. If it keeps hanging, the page handler itself (often an unfocused-window clipboard write) is the cause.' : undefined });-          }-          return;-        }-        case 'pup_fetch_url': {-          // Fetch an arbitrary URL with the session's cookies and return the-          // raw bytes (base64 in JSON; CLI optionally writes to disk).-          //-          // Why this exists: when a vendor click spawns a popup tab whose-          // Content-Type is application/pdf, Chrome wraps it in the built-in-          // PDF viewer. In-page `fetch(location.href)` then returns the-          // viewer's HTML wrapper (~200 KB stub), NOT the PDF binary.-          // Bypass: re-issue the request via puppeteer's-          // BrowserContext.request — same cookies, no rendering layer, raw-          // response body. Works for any content type; particularly useful-          // for PDFs, ZIPs, CAD bundles, anything the page would otherwise-          // open in a viewer plugin.-          //-          // saveTo semantics (clarified in v1.4.9 — v1.4.8 was ambiguous):-          //   - This is the BRIDGE side (Windows desktop). The bridge ALWAYS-          //     returns bodyBase64. If the user wants the file on the Docker-          //     side (the common case), the CLI handles the write — see-          //     cli/src/commands.rs::pup_fetch_url. The arg name there-          //     is `saveTo` and it's interpreted as a Docker-side path.-          //   - For the rare case of writing directly on the DESKTOP-          //     filesystem (e.g. dropping a CAD bundle into the user's-          //     Downloads folder so they can open it locally), the CLI-          //     forwards `desktopSaveTo` here, which the bridge writes via-          //     fs.writeFileSync. Returns `desktopSavedTo` field so it's-          //     unambiguous which filesystem the path refers to.-          //-          // Args (bridge-facing):-          //   sessionId       (required)-          //   url             (required)-          //   method          (optional, default GET)-          //   headers         (optional, object — Cookie auto-included from session)-          //   body            (optional, raw string body for POST etc.)-          //   desktopSaveTo   (optional Windows path — write on desktop and-          //                   return desktopSavedTo. Always also returns-          //                   bodyBase64 so caller can verify or save-          //                   container-side too.)-          //   tabId           (optional — picks the cookie context; defaults-          //                   to active tab. Cookies are per-browser-context-          //                   so any tab in the session works.)-          if (!args.url) {-            sendJSON(res, { success: false, error: 'pup_fetch_url requires {url}' });-            return;-          }-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }--          // Helper: optionally write to the desktop filesystem; ALWAYS return-          // bodyBase64 so the CLI can either pass it through to the caller OR-          // base64-decode it and write to a Docker-side path.-          const respondWithBytes = (buf, ct, status) => {-            const out = {-              ok: true,-              bytes: buf.length,-              contentType: ct,-              status,-              sessionId: resolvedId,-              tabId,-              bodyBase64: buf.toString('base64'),-            };-            if (args.desktopSaveTo) {-              try {-                // Resolve the path explicitly so callers can see what was used.-                // Don't silently land at a quirky CWD-relative path like the-                // v1.4.8 saveTo bug did.-                const abs = path.resolve(args.desktopSaveTo);-                const dir = path.dirname(abs);-                fs.mkdirSync(dir, { recursive: true });-                fs.writeFileSync(abs, buf);-                out.desktopSavedTo = abs;-              } catch (e) {-                // Don't lose the bytes — return them in bodyBase64 plus an-                // explicit error so the CLI knows the desktop write failed-                // but the bytes are still recoverable container-side.-                out.desktopSaveError = `desktopSaveTo write to ${args.desktopSaveTo} failed: ${e.message}`;-                out.desktopSavedTo = null;-              }-            }-            sendJSON(res, { success: true, output: JSON.stringify(out) });-          };--          try {-            const browserCtx = tab.page.browserContext();-            const reqClient = browserCtx.request;-            if (!reqClient || typeof reqClient.fetch !== 'function') {-              // Fallback for older puppeteer: do an in-page fetch in a-              // separate clean tab (avoids PDF viewer interception by-              // navigating to about:blank first, then fetching).-              const cleanPage = await browserCtx.newPage();-              try {-                await cleanPage.goto('about:blank');-                const result = await cleanPage.evaluate(async (u, m, h, b) => {-                  const init = { method: m, credentials: 'include', headers: h || {} };-                  if (b != null) init.body = b;-                  const r = await fetch(u, init);-                  const ab = await r.arrayBuffer();-                  const u8 = new Uint8Array(ab);-                  // Convert in chunks — avoids stack overflow on large blobs-                  let bin = '';-                  const CHUNK = 32768;-                  for (let i = 0; i < u8.length; i += CHUNK) {-                    bin += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));-                  }-                  return {-                    base64: btoa(bin),-                    size: u8.length,-                    contentType: r.headers.get('content-type') || '',-                    status: r.status,-                  };-                }, args.url, args.method || 'GET', args.headers || null, args.body || null);-                await cleanPage.close({ runBeforeUnload: false });-                respondWithBytes(Buffer.from(result.base64, 'base64'), result.contentType, result.status);-                return;-              } catch (e) {-                try { await cleanPage.close({ runBeforeUnload: false }); } catch {}-                throw e;-              }-            }--            // Modern puppeteer: BrowserContext.request.fetch — bypasses page-            // rendering entirely. Cookies from the context are auto-included.-            const init = {-              method: args.method || 'GET',-              headers: args.headers || {},-            };-            if (args.body != null) init.data = args.body;-            const resp = await reqClient.fetch(args.url, init);-            const buf = await resp.body();-            const ct = (resp.headers && resp.headers()['content-type']) || '';-            respondWithBytes(buf, ct, resp.status());-          } catch (e) {-            sendJSON(res, { success: false, error: `pup_fetch_url failed: ${e.message}`, _hint: 'If this URL requires the popup tab\'s session cookies, make sure that tab is in the same session and pass its tabId here. The bridge always returns bodyBase64; the CLI handles container-side writes via the saveTo arg.' });-          }-          return;-        }-        // v2.0.142 (John: "you need to download via the browser just like a user does — pup is about-        // the browser"). A BROWSER-NATIVE download: pup drives Chrome to fetch the file exactly as a-        // user clicking the button would — real session, real cookies, real anti-bot cookies, real-        // Content-Disposition — and Chrome's own download manager writes it to the Downloads folder.-        // NO bridge-side fetch/curl (that trips every vendor's anti-bot wall; if a caller wants curl-        // it does that outside pup). Completion is confirmed via CDP Browser.downloadProgress, so the-        // response carries the ACTUAL saved path + byte count, not a guess.-        //   selector : click THIS real download control (the authentic user action). OR-        //   url      : synthesize an <a href=url download> in the page and real-click it (forces the-        //              download path even for a URL Chrome would otherwise render inline, e.g. a PDF).-        case 'pup_download': {-          if (!args.selector && !args.url) { sendJSON(res, { success: false, error: 'pup_download needs {selector} (a real download button to click) OR {url} (downloaded via an in-page <a download> click). Optional saveAs.' }); return; }-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          const dir = args.saveDir || (session._downloadPath) || path.join(os.homedir(), 'Downloads');-          let cdp = null;-          try {-            cdp = await tab.page.target().createCDPSession();-            // allow downloads to `dir` AND emit Browser.download* events so we can await completion.-            try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}-            await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir, eventsEnabled: true });-            let willBegin = null;-            const done = new Promise((resolve, reject) => {-              const to = setTimeout(() => reject(new Error('download did not complete within 90s')), 90000);-              cdp.on('Browser.downloadWillBegin', (e) => { willBegin = e; });-              cdp.on('Browser.downloadProgress', (e) => {-                if (e.state === 'completed') { clearTimeout(to); resolve(e); }-                else if (e.state === 'canceled') { clearTimeout(to); reject(new Error('download was canceled by the page/Chrome')); }-              });-            });-            // Trigger the download the way a user does.-            if (args.selector) {-              await withTimeout(tab.page.click(args.selector, {}), 12000, 'download.click').catch(async () => {-                // fall back to a scroll-into-view + coordinate click if the plain click missed-                const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }).catch(() => null);-                if (box) await tab.page.mouse.click(box.x, box.y);-              });-            } else {-              await tab.page.evaluate((u, nm) => {-                const a = document.createElement('a'); a.href = u; if (nm) a.download = nm; else a.setAttribute('download', '');-                a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);-              }, args.url, args.saveAs || '');-            }-            const ev = await done;-            // Resolve the real file: allow-mode keeps the suggested name; the guid file is renamed on complete.-            let fname = (willBegin && willBegin.suggestedFilename) || (args.saveAs || '') || ('download-' + (ev.guid || 'file'));-            let abs = path.join(dir, fname);-            // Verify on disk; if the suggested name isn't there yet, find the newest non-.crdownload file.-            let sizeOnDisk = null;-            try { sizeOnDisk = fs.statSync(abs).size; } catch (e) {-              try {-                const recent = fs.readdirSync(dir).filter(f => !f.endsWith('.crdownload'))-                  .map(f => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs }))-                  .sort((a, b) => b.m - a.m)[0];-                if (recent) { abs = path.join(dir, recent.f); fname = recent.f; sizeOnDisk = fs.statSync(abs).size; }-              } catch (e2) {}-            }-            const magic = (() => { try { return fs.readFileSync(abs).slice(0, 5).toString('latin1').replace(/[^\x20-\x7e]/g, '.'); } catch (e) { return null; } })();-            sendJSON(res, { success: true, output: JSON.stringify({-              ok: true, savedTo: abs, filename: fname, bytes: (ev.totalReceivedBytes || ev.receivedBytes || sizeOnDisk), sizeOnDisk, magic,-              triggeredBy: args.selector ? ('click ' + args.selector) : ('a[download] ' + args.url),-              sessionId: resolvedId, tabId,-              _hint: `Chrome downloaded this to ${abs} (${sizeOnDisk != null ? sizeOnDisk + ' bytes' : 'size unread'}${magic ? ', starts "' + magic + '"' : ''}) — the real browser download, session cookies and all. To bring it into the container, pull it with Bridge file verbs (desktop_pull_file).`,-            }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'download_failed', error: `pup_download failed: ${e.message}`, _hint: args.selector ? `The click may not have started a download. Verify the selector is the real download control (pup_screenshot to see it), and that this window is logged in if the file is gated.` : 'The URL did not download — for a gated file, sign in on this window first (pup_login).' });-          } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }-          return;-        }-        case 'pup_errors': {-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          const errs = [...tab.errors];-          if (args.clear !== false) tab.errors.length = 0;-          sendJSON(res, { success: true, output: JSON.stringify({ errors: errs, count: errs.length, sessionId: resolvedId, tabId }) });-          return;-        }-        case 'pup_progress': {-          // v2.0.193 (John: "if a thread is crawling a site through login -> component pages ->-          // datasheets -> step files, it should paint the taskbar progress bar; a simple page open-          // shouldn't — the AI decides, but pup has to TEACH it"). Caller-facing progress control.-          //   pup_progress {mode:"indeterminate", note:"crawling ti.com"}          — pulsing bar-          //   pup_progress {mode:"value", value:35, note:"12/40 datasheets"}      — determinate-          //   pup_progress {mode:"off"}                                            — clear when done-          const _pm = String(args.mode || (typeof args.value === 'number' ? 'value' : 'indeterminate'));-          if (!['indeterminate', 'value', 'off'].includes(_pm)) { sendJSON(res, { success: false, error: 'mode must be indeterminate|value|off' }); return; }-          const _pv = Math.max(0, Math.min(100, Number(args.value) || 0));-          const _note = (typeof args.note === 'string' && args.note.trim()) ? args.note.trim().slice(0, 120) : null;-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }-          if (!session) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }-          session._explicitProgress = _pm !== 'off';-          if (_pm !== 'off') { if (!session._progressSince) { session._progressSince = Date.now(); session._progressCount = (session._progressCount || 0) + 1; } }-          else if (session._progressSince) { session._progressMs = (session._progressMs || 0) + (Date.now() - session._progressSince); session._progressSince = null; }-          // v2.0.244 (pup-bridge#54, same root cause as #58): a caller asking for a progress bar is-          // an EXPLICIT request, not pup's automatic machinery, so minimal-touch must not silently-          // eat it. That gate is why "pup_progress paints nothing while a direct desktop_taskbar by-          // hwnd paints" — the verb never made the call at all, and still answered ok:true. It now-          // paints regardless, and the response says plainly when the desktop cannot show it.-          let _progressPainted = false, _progressSkip = null;-          if (process.platform !== 'win32') _progressSkip = 'this desktop is not Windows — taskbar progress bars are a Windows shell feature';-          if (process.platform === 'win32') {-            try {-              // v2.0.195: resolve the OS window handle FIRST (same as the identity stamp) — after a-              // bridge respawn the reloaded session has no hwnd yet, and windowTarget's title-              // fallback is dead with titleTag off, so the progress paint silently hit nothing-              // (measured: direct-by-hwnd painted the Win11 underline pill; the verb path did not).-              try { if (!session._hwnd) await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {}-              // v2.0.194: progress + tooltip in ONE desktop_taskbar call — a follow-up call that-              // omitted the progress field was clearing the bar the first call had just set.-              await chrome.abCommand('desktop_taskbar', {-                ...(await windowTarget(session, resolvedId)),-                progress: _pm === 'off' ? { state: 'none' } : _pm === 'value' ? { state: 'normal', value: _pv } : { state: 'indeterminate' },-                ...(_note ? { thumbnailTooltip: `${session.owner || resolvedId} — ${_note}` } : {}),-              }, { timeoutMs: 4000 });-              try { session._lastPaintSig = null; session._appOverlay = false; session._overlayEnforceAt = 0; } catch (e) {}-              _progressPainted = true;-            } catch (e) { _progressSkip = `the shell call failed: ${e && e.message}`; }-          }-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, mode: _pm, painted: _progressPainted, ...(_progressSkip ? { notPainted: _progressSkip } : {}), ...(_pm === 'value' ? { value: _pv } : {}), ...(_note ? { note: _note } : {}),-            _hint: _progressSkip ? `Progress state recorded, but nothing was drawn: ${_progressSkip}.`-              : _pm === 'off'-              ? 'Progress bar cleared. Good hygiene: always clear when the multi-stage task finishes or aborts.'-              : `Taskbar progress ${_pm === 'value' ? `at ${_pv}%` : 'pulsing (indeterminate)'} on "${resolvedId}"${_note ? ` — hover tooltip says "${_note}"` : ''}. Update value/note as stages advance; pup_progress {mode:"off"} when done. The automatic activity strip stays out of your way while this is set.` }) });-          return;-        }-        case 'pup_reload': {-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }-          catch (e) { sendTabResolveError(res, e); return; }-          tab.errors.length = 0;-          // v1.9.176: a CRASHED renderer can't be page.reload()'d (detached frame) — respawn it via-          // raw CDP instead. This is the recovery the crash hint promised but page.reload() couldn't do.-          if (tab._crashed) {-            const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (e) { return null; } })() || args.url;-            const ok = await respawnCrashedRenderer(tab.page, rurl);-            if (ok) {-              tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;-              console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via respawn`);-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });-            } else {-              console.log(`[crash] session "${resolvedId}" tab ${tabId} respawn did NOT take — advising close+reopen`);-              sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn did not recover it (Chrome may be too memory-pressured to spawn a fresh renderer). Close and reopen the window: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${rurl || ''}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });-            }-            return;-          }-          // v1.9.109: keep the reload's own HTTP status — page.reload() returns the response, and this-          // is the verb an AI uses right after "I fixed it", so a still-broken page must be caught here.-          let _rlResp;-          try {-            _rlResp = await withTimeout(-              tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }),-              30_000, 'page.reload'-            );-          } catch (e) {-            // The renderer may have crashed between resolve and reload (detached frame) — fall back to respawn.-            if (_isCrashedFrameError(e)) {-              const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (x) { return null; } })() || args.url;-              const ok = await respawnCrashedRenderer(tab.page, rurl);-              tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;-              console.log(`[crash] session "${resolvedId}" tab ${tabId} reload hit a crashed frame — recovered via respawn (${ok ? 'ok' : 'sent'})`);-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });-              return;-            }-            throw e;-          }-          try { tab.page.__pupHttpStatus = _rlResp && typeof _rlResp.status === 'function' ? _rlResp.status() : null; } catch (e) {}-          // v1.8.26: the user is signalled by the auto-flash, NOT by raising the window.-          // Only raise if this session is one the user is actively watching.-          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }-          // v1.9.109 — PROBE ON RELOAD TOO. This was the hole: only pup_open_window probed, so a-          // thread that hit a 404, fixed its dev server, called pup_reload and got ok:true with the-          // GENERIC "do not assume it rendered" hint concluded "Mockup is now live again" — reporting-          // success on a page nobody had checked. The verb used to confirm a fix is exactly the verb-          // that must verify it.-          try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}-          try { await autoLoginOnLoad(res, tab.page); } catch (e) {}-          try {-            const _rc = res._renderCheck;-            if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" STILL BROKEN after reload: ${_rc.signals.join(', ')}`);-          } catch (e) {}-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) });-          return;-        }-        case 'pup_navigate': {-          {-            // v1.9.195: same pre-flight as open — never navigate a window to a guaranteed-broken-            // file:// URL (container path, or missing on the desktop) and leave the caller guessing.-            const _pre = urlLint(args.url, res && res._callerContainer);-            if (_pre && (_pre.code === 'url_file_container_path' || _pre.code === 'url_file_not_found')) {-              console.log(`[urlLint] REFUSED navigate to "${args.url}" — ${_pre.code}`);-              sendJSON(res, { success: false, errorCode: _pre.code, error: _pre.message, _urlLint: _pre.message, urlLintCode: _pre.code });-              return;-            }-          }-          let session, resolvedId;-          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing-          // any error. The Docker container should never see "Session-          // closed" / "detached Frame" — those become internal retry-          // signals. See recoverOrRelaunchSession.-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          // v1.8.16: refuse a declared cross-owner navigate (window steal).-          { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_navigate'); if (refusal) { sendJSON(res, refusal); return; } }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }-          catch (e) { sendTabResolveError(res, e); return; }-          // Apply stored basic-auth creds for the navigation target's host-          // BEFORE goto, so the Chrome native auth dialog never appears-          // for sites in the credential vault.-          try {-            const matchedHost = await credentialVault.applyCredentialsToPage(tab.page, args.url);-            if (matchedHost) console.log(`[creds] applied to navigate ${resolvedId}/${tabId}: matched ${matchedHost}`);-          } catch (e) {-            console.log(`[creds] applyCredentialsToPage on navigate failed: ${e.message}`);-          }-          // v1.9.176: navigating a CRASHED renderer via page.goto throws 'detached Frame'. When the tab-          // is flagged crashed (or goto hits a crashed frame), respawn via raw CDP Page.navigate to the-          // requested URL — that spawns a fresh renderer in place and lands on the target page.-          if (tab._crashed) {-            const ok = await respawnCrashedRenderer(tab.page, args.url);-            if (!ok) {-              console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate respawn did NOT take — advising close+reopen`);-              sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn to ${args.url} did not recover it (Chrome may be too memory-pressured). Close and reopen: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${args.url}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });-              return;-            }-            tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;-            console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate recovered a crashed renderer via respawn`);-          } else {-            try {-              await withTimeout(-                tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 25000 }),-                30_000, 'page.goto'-              );-            } catch (e) {-              if (_isCrashedFrameError(e)) {-                const ok = await respawnCrashedRenderer(tab.page, args.url);-                tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;-                console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate hit a crashed frame — recovered via respawn (${ok ? 'ok' : 'sent'})`);-              } else {-                throw e;-              }-            }-          }-          tab.errors.length = 0;-          // v1.9.201: Chrome can foreground a window on its own during a navigation (local PDFs do-          // this reliably). Re-assert the park unless the user has this window up on purpose.-          if (!userIsUsingWindow(session)) setTimeout(() => { reassertBottomAfterNav(resolvedId).catch(() => {}); }, 1800);-          // Re-apply title suffix after navigation-          try {-            const pName = session.profileName;-            // When there's more than one tab, include tabId in the title suffix-            const titleSuffix = session.tabs.length > 1-              ? ` (${TITLE_LABEL}: ${resolvedId} | ${tabId})`-              : (pName !== resolvedId-                  ? ` (${TITLE_LABEL}: ${resolvedId} | profile: ${pName})`-                  : ` (${TITLE_LABEL}: ${resolvedId})`);-            await tab.page.evaluate((suffix, sid) => {-              try { window.name = 'pup:' + sid; } catch (e) {}   // v1.9.153: durable carrier on tab retitles too-              // v2.0.24: no visible suffix, no observer — window.name is the carrier.-              const cleaned = String(document.title || '').replace(/\s*\((?:session|ai-thread):[^)]*\)/g, '').trim();-              if (cleaned !== document.title) document.title = cleaned;-            }, titleSuffix, resolvedId);-          } catch {}-          // v1.8.26: signal via auto-flash, NOT by raising the window (that stole focus-          // on every navigate). Only raise for a session the user is actively watching.-          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }-          // Only persist URL for single-tab sessions — the session file is a-          // per-session snapshot and multi-tab URLs can't fit.-          if (session.tabs.length === 1) {-            saveSessionFile(resolvedId, session.profileName, args.url);-          }-          if (session && process.platform === 'win32') {-            setTimeout(() => { applyAppOverlay(session, resolvedId).catch(() => {}); }, 1500); // v1.8.92: new site, new favicon-            // v1.9.43: second pass — many sites inject their <link rel=icon> after first paint.-            setTimeout(() => { if (session) session._faviconRetried = false; applyAppOverlay(session, resolvedId).catch(() => {}); }, 4500);-            // v1.9.1: on an Adom nav, re-check auth (login may have completed since open) and-            // refresh the tab label so it becomes "🔓 Logged in" once actually authenticated.-            // v1.9.103: a navigation replaces the document, so re-assert the thread tag (cheap, and-            // evaluateOnNewDocument already re-runs it at document-start; this covers the live page).-            try { if (session && session._threadTag) setTitleDecor(session.page, { threadTag: session._threadTag }).catch(() => {}); } catch (e) {}-            if (isAdomUrl(args.url)) {-              setTimeout(async () => {-                try {-                  const a = await checkWikiAuth(session.page);-                  // v1.9.93: an indeterminate probe must not repaint the glyph OR flip the identity.-                  if (!(a && a.unknown)) {-                    await setWikiModeGlyph(session.page, a && a.authed ? '\u25CF Logged in' : '\u25CB Public');-                    await setWikiAuthedState(session, resolvedId, a && a.authed, { unknown: false });-                  }-                } catch (e) {}-              }, 1800);-            }-            // v1.9.3: refresh the per-window wiki jump list (adds the toggle on a wiki page,-            // clears it when the window navigated off Adom).-            setTimeout(() => { updateWikiJumplist(session, resolvedId).catch(() => {}); }, 1000);-          }-          if (tab._crashed) { tab._crashed = false; delete tab._crashedAt; delete tab._crashReason; console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via navigate`); }-          // v1.9.109: probe on navigate as well — same reasoning as reload.-          try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}-          try { await autoLoginOnLoad(res, tab.page); } catch (e) {}-          try {-            const _rc = res._renderCheck;-            if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" BROKEN after navigate to ${args.url}: ${_rc.signals.join(', ')}`);-          } catch (e) {}-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: args.url, sessionId: resolvedId, tabId, _next: threadNextBlock(), ...(authSteerHint(args.url) ? { _hint: authSteerHint(args.url).trim() } : {}) }) });-          return;-        }-        case 'pup_install_toolbar': {-          // v2.0.53: nothing to install anymore — on CfT the toolbar is a launch arg. This verb-          // reports the state so agents can explain it.-          const cft = (chrome && chrome.readiness) ? chrome.readiness() : {};-          const staged = stageToolbarExtension();-          sendJSON(res, { success: true, output: JSON.stringify({-            ok: true, mode: 'launch-flag', stagedAt: staged,-            driving: cft.browserKind || null,-            active: !!staged && cft.browserKind === 'chrome-for-testing',-            _hint: staged ? (cft.browserKind === 'chrome-for-testing' ? 'Toolbar auto-loads on every profile launch (CfT --load-extension).' : `Driving browser is ${cft.browserKind}; the toolbar flag only works on Chrome for Testing — pup_use {browser:"cft"} to enable.`) : 'toolbarExtension is off in settings (or non-Windows).',-          }) });-          return;-        }-        case 'pup_floaty_click': {-          // v2.0.69 (John: "tool the clicking so we can ralph this weekly forever"): first-class-          // simulated click on the floaty pencil. Fires the SAME host click handler a mouse does-          // (host.click() -> onclick -> flag -> sweep harvest -> annotate), so the ralph exercises-          // the full production chain without CDP input-simulation flakiness. Honest errors: floaty-          // absent means the setting is off or the page never got the injection.-          const sessionId = args.sessionId;-          const _lv = requireLiveSession(res, { ...args, sessionId });-          if (!_lv) return;-          const session = _lv.session;-          const tab = (args.tabId && (session.tabs || []).find(t => t.tabId === args.tabId)) || (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];-          if (!tab || !tab.page) { sendJSON(res, { success: false, error: 'no tab' }); return; }-          try {-            const r = await tab.page.evaluate(() => {-              const h = document.getElementById('__pup_floaty');-              if (!h) return { clicked: false, reason: 'floaty not present (tbFloatyPencil off, or page not injected)' };-              h.click();-              window.__pupFloatyClick = null;   // consumed here: the verb arms the watcher itself-              return { clicked: true };-            });-            if (r.clicked) { session._annotateArmed = Date.now(); startAnnotation(session, sessionId, 'user-floaty', { tool: 'pen', tabId: tab.tabId }).catch(() => {}); }-            sendJSON(res, { success: !!r.clicked, output: JSON.stringify(r), ...(r.clicked ? {} : { error: r.reason }) });-          } catch (e) { sendJSON(res, { success: false, error: e.message }); }-          return;-        }-        case 'pup_annotate': {-          // start/cancel the human annotation overlay on a window's active tab. Jump-list clicks-          // arrive as user-taskbar-menu (ownership-gate exempt); threads annotate their OWN windows.-          const sessionId = args.sessionId;-          const _lv = requireLiveSession(res, { ...args, sessionId });-          if (!_lv) return;-          const session = _lv.session;-          try {-            if (args.action === 'cancel') {-              const tab0 = (session.tabs || [])[0];-              if (tab0 && tab0.page) await tab0.page.evaluate(() => { const w = document.getElementById('__pup_annotate'); if (w) w.remove(); window.__pupAnnotate = null; });-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cancelled: true }) });-              return;-            }-            await startAnnotation(session, sessionId, _callerThread, { tool: args.tool === 'snip' ? 'snip' : 'pen', mode: args.mode, tabId: args._resolvedTabId || null });-            // v2.0.293 (John: "you just foregrounded a pup window and never showed an ab caption"):-            // the old unconditional raise claimed a jump-list click is user-sanctioned by-            // construction — TRUE only for USER callers (jump list / toolbar). An AI THREAD calling-            // pup_annotate got the same screen-steal, and a scripted annotate workflow re-raised-            // the window on every arm. Now: USER callers get the raise (they clicked, they are-            // waiting for the window); AI callers arm the overlay in the BACKGROUND and must-            // foreground explicitly via pup_raise_os_window + foregroundReason (the reason-gated,-            // captioned path) if the user should draw now.-            const _userCaller = _callerThread === 'user-taskbar-menu' || _callerThread === 'user-toolbar';-            if (_userCaller) {-              session._foreground = true; session._userActiveAt = Date.now();-              try { const t0 = (session.tabs || [])[0]; if (t0 && t0.page) await t0.page.bringToFront(); } catch (e) {}-              try { await osRaiseSessionWindow(session, sessionId, 'you clicked Annotate: draw on the page, then hit Done'); } catch (e) {}-            }-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, started: true, raised: _userCaller, _hint: _userCaller ? 'Overlay is live: pen/arrow/box/text, color dot cycles, Done captures (drawing stays visible so the owning AI thread\'s next screenshot shows it), \u2716 cancels.' : 'Overlay is armed but the window stays in the BACKGROUND (AI-called annotate never steals the screen). If the user should draw NOW, call pup_raise_os_window with a foregroundReason: that captions the reason first, then foregrounds.' }) });-          } catch (e) { sendJSON(res, { success: false, error: `annotate failed: ${e.message}` }); }-          return;-        }-        case 'pup_dashboard': {-          // v1.9.231 (John): the dashboard ALWAYS gets its OWN pup window, never a tab in someone-          // else's, so it carries its own taskbar icon (its served icon.svg) and can be found and-          // closed independently. Fixed sessionId + its own durable profile, so repeat calls REUSE-          // the one window instead of spawning duplicates. Opens in the background like any pup-          // window: it is a monitor, and monitors must not steal the screen.-          const dashUrl = `http://127.0.0.1:${PORT}/dashboard`;-          // v2.0.178 (John: "when I click the jump list to open the dashboard, open it FOREGROUND —-          // it was a human action, not an AI action"). Exactly right: the background-always rule-          // exists so AIs never steal the screen, but a jump-list / toolbar click IS the user asking-          // to see it — backgrounding it just makes the click feel broken. USER_CALLERS is the-          // human-surface marker (taskbar menu, toolbar, pill), so those callers get the window-          // raised; AI callers keep the background default + raise-with-reason path.-          const _humanClick = USER_CALLERS.has(_callerThread);-          const _humanReason = 'you clicked "Open pup dashboard" in the taskbar menu';-          try {-            const existing = sessions.get('pup-dashboard');-            if (existing) {-              try { await existing.page.goto(dashUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch (e) {}-              if (_humanClick) { try { await osRaiseSessionWindow(existing, 'pup-dashboard', _humanReason); } catch (e) {} }-              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: true, foregrounded: _humanClick,-                _hint: _humanClick-                  ? `The pup dashboard was already open — refreshed and RAISED to the foreground (a human clicked the jump-list task, so no background etiquette applies).`-                  : `The pup dashboard is already open in its own window (session "pup-dashboard") and was refreshed. It is BACKGROUNDED like any pup window; raise it with pup_raise_os_window + a foregroundReason if the user asked to see it.` }) });-              return;-            }-            const _dashSess = await launchSession('pup-dashboard', dashUrl, { profile: 'pup-dashboard', owner: 'pup-dashboard', foreground: _humanClick });-            if (_humanClick) { try { await osRaiseSessionWindow(_dashSess || sessions.get('pup-dashboard'), 'pup-dashboard', _humanReason); } catch (e) {} }-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: false, foregrounded: _humanClick,-              _hint: `Opened the live pup dashboard in its OWN window (session "pup-dashboard", its own profile so it has its own taskbar icon). It shows every window pup is driving, grouped taskbar button -> window -> tabs, with each tab's favicon, the owning AI thread, foreground/background plus the reason given to foreground, highFps lease, uptime, and how each overlay icon was chosen. Updates live over SSE. ${_humanClick ? 'FOREGROUNDED (human click on a user surface).' : 'Backgrounded by default.'}` }) });-          } catch (e) {-            sendJSON(res, { success: false, errorCode: 'dashboard_open_failed', error: `could not open the dashboard: ${e.message}` });-          }-          return;-        }-        case 'pup_deps': {-          // v1.9.218 (issue #15): report dependency health so "pup is broken" can be answered without-          // reading the spawn log. selfInstalled:true means the bridge repaired itself this boot.-          let resolved = false; try { require.resolve('puppeteer'); resolved = true; } catch (e) {}-          sendJSON(res, { success: true, output: JSON.stringify({-            ok: resolved, depsPresent: resolved,-            selfInstalledThisBoot: !!(_depState && _depState.installed),-            lastError: (_depState && _depState.error) || null,-            dir: __dirname,-            _hint: resolved-              ? (_depState && _depState.installed-                 ? 'Dependencies were MISSING at boot and pup self-installed them (wiki issue #15: the source-only zip ships no node_modules and AD\'s install did not run, or a respawn wiped it). No human npm install is needed.'-                 : 'Dependencies present. Normal state.')-              : 'Dependencies MISSING and self-install failed — pup cannot drive a browser. Ensure node + npm are on PATH for the AD process, then bridge_kill + bridge_resume to retry the self-install.',-          }) });-          return;-        }-        case 'pup_readiness': {-          // READ-ONLY readiness probe. NEVER triggers a download. `ready` reflects both-          // server-up (this verb answered) AND Chrome-for-Testing cached. For the general-          // "what do I have / is it ready" question prefer AD's `bridge_readiness` (it also-          // reports AD-core's Tier-1 prewarm progress); this verb is pup's own detailed view.-          const rd = chrome.readiness();-          const availTxt = (rd.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';-          const defTxt = rd.defaultBrowser-            ? ` Default: ${rd.defaultBrowser.kind}${rd.defaultBrowser.forced ? ' (pinned)' : ' (auto)'}${rd.defaultBrowser.pendingInstall ? ', downloading' : ''}.`-            : ' Default: auto (first browser that launches).';-          const switchTxt = ' Switch/install with pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.';-          // v2.0.383 (#59/#64): the fast progress verb. active[] = one row per in-flight phase (this is-          // what animates a bar); progress = the download's own frame while it runs.-          const _act = activeProgress();-          const _dl = rd.installing ? { phase: 'download', stepLabel: 'Downloading Chrome for Testing', percent: rd.installProgressPct || 0, elapsedSec: rd.downloadElapsedSec, estimatedSec: (rd.downloadElapsedSec != null && rd.downloadEtaSec != null) ? rd.downloadElapsedSec + rd.downloadEtaSec : null, etaSec: rd.downloadEtaSec, confidence: rd.downloadEtaSec != null ? 'measured' : 'estimated', startKind: 'cold' } : undefined;-          sendJSON(res, {-            success: true,-            output: JSON.stringify({ ...rd, active: _act, progress: _dl }),-            ...rd,-            active: _act,-            progress: _dl,-            _hint: rd.ready-              ? `Ready — pup will drive ${rd.browserKind} (${rd.browserSource}) at ${rd.browserExecutablePath}. Available: ${availTxt}.${defTxt} No download needed. Proceed with pup_open_window.${switchTxt} RECOVERY NOTE: if you were polling this because pup's verbs were TIMING OUT (dim LED in AD / browser.bridgeRunning:false), that means the bridge PROCESS was down — a status probe like this does NOT respawn it; only a window/tab verb (pup_open_window) auto-spawns the bridge. And never SPAM pup_open_window while it says "bridge_starting" — concurrent opens on one profile used to collide (fixed v1.9.4, but still: fire ONE open, then poll pup_list_windows with backoff).`-              : rd.installing-                ? `Fetching Chrome for Testing (${rd.installProgressPct}%) — this box had no Chrome/Edge. Poll this verb (or bridge_readiness) until ready:true, then pup_open_window. Faster: have the user install Chrome or Edge (Edge ships with Windows) and pup uses it instantly.`-                : rd.installPhase === 'failed'-                  ? `No Chrome/Edge on this box and the Chrome-for-Testing fallback FAILED: ${rd.lastError}. Have the user install Chrome or Edge (fastest, no download), or retry pup_prewarm (usually network/proxy blocking the download, or low disk).`-                  : `No Chrome/Edge detected and no cached Chrome for Testing. Available: ${availTxt}. Have the user install Chrome or Edge (Edge ships with Windows — instant, no download), or call pup_use {browser:"cft"} / pup_prewarm to fetch Chrome for Testing.`,-          });-          return;-        }-        case 'pup_import_scan': {-          // READ-ONLY discovery pass for the comprehensive credential import (adom-you seed).-          // Enumerates EVERY profile of EVERY Chromium browser (Chrome/Edge/Brave) on this box-          // with: source account email(s) (the identity tag — Preferences.account_info[].email),-          // saved-password count (parsed from the Login Data SQLite via the desktop's python,-          // NEVER a byte-scan — see dev-skills/pup-browser-import), Login Data size, and ABE-          // status. It DECRYPTS NOTHING — decryption needs the Bridge-core elevated SYSTEM helper-          // (adom/adom-bridge #51, desktop_decrypt_pup_credentials). This verb is the-          // foundation the first-run prompt + Settings "Import logins" picker read.-          if (process.platform !== 'win32') {-            sendJSON(res, { success: true, output: JSON.stringify({ profiles: [] }), profiles: [], _hint: 'Credential import discovery is Windows-only (Chromium ABE / DPAPI). No profiles enumerated on this platform.' });-            return;-          }-          const scanPs = [-            "$ErrorActionPreference='SilentlyContinue'",-            "$local = $env:LOCALAPPDATA",-            "$browsers = @(",-            "  @{ id='chrome'; name='Google Chrome'; root=(Join-Path $local 'Google\\Chrome\\User Data') },",-            "  @{ id='edge';   name='Microsoft Edge'; root=(Join-Path $local 'Microsoft\\Edge\\User Data') },",-            "  @{ id='brave';  name='Brave';          root=(Join-Path $local 'BraveSoftware\\Brave-Browser\\User Data') }",-            ")",-            "$py = $null",-            "foreach($c in @('python','python3','py')){ $g = (Get-Command $c -ErrorAction SilentlyContinue); if($g){ $py=$g.Source; break } }",-            "$pyLines = @(",-            "  'import sqlite3,sys',",-            "  'c=sqlite3.connect(sys.argv[1])',",-            "  'try:',",-            "  '    n=c.execute(\"select count(*) from logins where blank_password=0\").fetchone()[0]',",-            "  'except Exception:',",-            "  '    n=c.execute(\"select count(*) from logins\").fetchone()[0]',",-            "  'print(n)'",-            ") -join [char]10",-            "$codeFile = $null",-            "if($py){ $codeFile = Join-Path $env:TEMP ('pup_ldcount_' + [guid]::NewGuid().ToString('N') + '.py'); Set-Content -LiteralPath $codeFile -Value $pyLines -Encoding ASCII }",-            "$out = @()",-            "foreach($b in $browsers){",-            "  if(-not (Test-Path $b.root)){ continue }",-            "  $abe=$false; $legacy=$false",-            "  $lsPath = Join-Path $b.root 'Local State'",-            "  if(Test-Path $lsPath){ try { $ls = Get-Content -Raw -LiteralPath $lsPath | ConvertFrom-Json; if($ls.os_crypt.app_bound_encrypted_key){ $abe=$true }; if($ls.os_crypt.encrypted_key){ $legacy=$true } } catch {} }",-            "  $profs = @()",-            "  $d = Get-Item -LiteralPath (Join-Path $b.root 'Default') -ErrorAction SilentlyContinue; if($d){ $profs += $d }",-            "  $profs += (Get-ChildItem -LiteralPath $b.root -Directory -Filter 'Profile *' -ErrorAction SilentlyContinue)",-            "  foreach($pf in $profs){",-            "    if(-not $pf){ continue }",-            "    $prefPath = Join-Path $pf.FullName 'Preferences'",-            "    if(-not (Test-Path $prefPath)){ continue }",-            "    $emails=@(); $pname=$pf.Name",-            "    try { $pref = Get-Content -Raw -LiteralPath $prefPath | ConvertFrom-Json; if($pref.profile.name){ $pname = $pref.profile.name }; if($pref.account_info){ foreach($a in $pref.account_info){ if($a.email){ $emails += $a.email } } } } catch {}",-            "    $ldPath = Join-Path $pf.FullName 'Login Data'",-            "    $pwCount=$null; $ldBytes=0",-            "    if(Test-Path $ldPath){",-            "      $ldBytes = (Get-Item -LiteralPath $ldPath).Length",-            "      if($py -and $codeFile){",-            "        $tmp = Join-Path $env:TEMP ('pup_ld_' + [guid]::NewGuid().ToString('N') + '.db')",-            "        try { Copy-Item -LiteralPath $ldPath -Destination $tmp -Force; $r = & $py $codeFile $tmp 2>$null; if($r){ $pwCount = [int]($r | Select-Object -First 1) } } catch {}",-            "        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue",-            "      }",-            "    }",-            "    $out += [pscustomobject]@{ browser=$b.id; browserName=$b.name; profileDir=$pf.Name; profileName=$pname; accounts=@($emails); passwordCount=$pwCount; loginDataBytes=$ldBytes; abe=$abe; legacyKey=$legacy }",-            "  }",-            "}",-            "if($codeFile){ Remove-Item -LiteralPath $codeFile -Force -ErrorAction SilentlyContinue }",-            "[pscustomobject]@{ pythonAvailable=[bool]$py; profiles=@($out) } | ConvertTo-Json -Depth 6 -Compress"-          ].join('\n');-          const scanPath = path.join(require('os').tmpdir(), '_pup_import_scan.ps1');-          let scan = { pythonAvailable: false, profiles: [] };-          try {-            fs.writeFileSync(scanPath, scanPs);-            const raw = (await runPsHiddenPromise(scanPath, { timeout: 25000 }) || '').trim();-            if (raw) scan = JSON.parse(raw);-          } catch (e) { console.log('[import-scan] failed:', e && e.message); }-          const profiles = Array.isArray(scan.profiles) ? scan.profiles : (scan.profiles ? [scan.profiles] : []);-          const totalPw = profiles.reduce((s, p) => s + (p.passwordCount || 0), 0);-          const accts = [...new Set(profiles.flatMap(p => p.accounts || []))];-          const anyAbe = profiles.some(p => p.abe);-          sendJSON(res, {-            success: true,-            output: JSON.stringify({ profiles, totalPasswords: totalPw, accounts: accts }),-            profiles,-            totalPasswords: totalPw,-            accounts: accts,-            pythonAvailable: !!scan.pythonAvailable,-            _hint: profiles.length-              ? `Found ${profiles.length} browser profile(s) across ${new Set(profiles.map(p=>p.browser)).size} browser(s), ~${totalPw} saved password(s), identities: ${accts.join(', ') || 'none tagged'}.${anyAbe ? ' These are App-Bound-Encrypted (v20) — a USER process (this bridge) canNOT decrypt them.' : ''} This verb is DISCOVERY ONLY. The actual decrypt is AD's desktop_decrypt_pup_credentials (AD >= 1.9.220): the dashboard "Import logins" button calls it LOCALLY (bridge → AD direct API), the user approves one consent dialog + one UAC prompt, and pup merges the identity-tagged rows into the adom-you vault (credential_vault.js) and drops the plaintext. ${scan.pythonAvailable ? '' : 'NOTE: python not found on PATH, so passwordCount is null — only file presence/size known. '}Next: open the Settings "Import logins from Chrome/Edge" picker, which reads this list.`-              : `No Chromium browser profiles with a Preferences file found under %LOCALAPPDATA% (Chrome/Edge/Brave). Nothing to import.`,-          });-          return;-        }-        // v2.0.143 (John: "import from my native browsers via ab's credential verb — that i don't-        // think ever worked — and get it into your stored credential system"). It DID work, but only-        // from the dashboard's "Import logins" button; there was no AI-callable verb, so a thread-        // couldn't seed pup's vault from the user's real Chrome/Edge. This verb runs the whole flow:-        // scan → call AD's SYSTEM-elevated decrypt LOCALLY (the user approves one consent + one UAC-        // on the desktop; a relayed call is refused, which is why pup — a LOCAL bridge — must be the-        // one to ask) → merge the identity-tagged rows into the keychain vault → DROP the plaintext.-        // Returns COUNTS ONLY, never a password. hostFilter narrows the merge to matching hosts (a-        // surgical import, e.g. just the vendor sites); omit to import everything the user has saved.-        case 'pup_import_browser_logins': {-          if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'Browser-login import is Windows-only (Chromium ABE/DPAPI).' }); return; }-          // 1) scan for profiles (reuse the discovery verb over loopback).-          let scan = {};-          try {-            const sr = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_import_scan', args: {} }) });-            const sj = await sr.json().catch(() => ({}));-            try { scan = JSON.parse(sj.output); } catch (e) { scan = sj; }-          } catch (e) {}-          const allProfiles = Array.isArray(scan.profiles) ? scan.profiles : [];-          const wantBrowsers = Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [...new Set(allProfiles.map(p => p.browser))];-          const browsers = wantBrowsers.filter(Boolean);-          if (!browsers.length) { sendJSON(res, { success: false, error: 'No Chromium browser profiles with saved passwords were found to import from.' }); return; }-          // 2) the SYSTEM-elevated decrypt, called LOCALLY (consent + UAC prompt on the desktop).-          console.log(`[import-verb] decrypt requested for browsers=[${browsers.join(', ')}] — AD helper, consent + UAC will prompt on the desktop`);-          let resp;-          try { resp = await decryptBrowserCreds({ browsers, include: ['passwords'] }); }-          catch (e) { sendJSON(res, { success: false, errorCode: 'decrypt_call_failed', error: 'decrypt helper call failed: ' + (e && e.message || e) }); return; }-          const payload = abPayload(resp) || {};-          const ec = payload.errorCode || (resp && resp.errorCode);-          if (ec) {-            const emap = {-              relay_forbidden: 'AD refused the decrypt as a relayed call — this should not happen from pup (a local bridge). Please report it.',-              consent_declined: 'You declined the Adom Bridge consent dialog on the desktop. Nothing was read.',-              elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',-              helper_missing: 'The signed decrypt helper is not installed — update/repair Adom Bridge.',-            };-            sendJSON(res, { success: false, errorCode: ec, error: emap[ec] || ('Decrypt failed: ' + ec) });-            return;-          }-          let pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];-          if (!payload.ok && !pwRows.length) { sendJSON(res, { success: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') }); return; }-          // 3) optional surgical filter by host substring(s), then merge into the vault.-          const filters = (typeof args.hostFilter === 'string' ? [args.hostFilter] : (Array.isArray(args.hostFilter) ? args.hostFilter : [])).map(s => String(s).toLowerCase()).filter(Boolean);-          let rows = pwRows;-          if (filters.length) rows = pwRows.filter(r => { const h = String((r && (r.host || r.url || r.origin)) || '').toLowerCase(); return filters.some(f => h.includes(f)); });-          let merge = { added: 0, updated: 0, skipped: 0 };-          try { merge = await credentialVault.mergeImportedCredentials(rows); }-          catch (e) { sendJSON(res, { success: false, error: 'vault merge failed: ' + (e && e.message || e) }); return; }-          // 4) DROP all plaintext immediately.-          try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; pwRows = []; } catch (e) {}-          const st = payload.stats || {};-          console.log(`[import-verb] merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped${filters.length ? ` (filtered to: ${filters.join(', ')})` : ''}`);-          sendJSON(res, { success: true, output: JSON.stringify({-            ok: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,-            hostFilter: filters.length ? filters : null,-            stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed },-            _hint: `Imported ${merge.added + merge.updated} login(s) into the adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''})${filters.length ? `, filtered to host(s) matching [${filters.join(', ')}]` : ''}. pup can now auto-sign-in as you on those sites: navigate a pup window to one and it fills + (with submitReason) submits. Passwords were dropped from memory; pup_credentials lists host+username only.`,-          }) });-          return;-        }-        case 'pup_prewarm': {-          // Install Chrome for Testing WITHOUT opening a window — the warm-before--          // first-use path. Default non-blocking so AD/HD can fire-and-forget on-          // embedded first-run; pass {wait:true} to block until installed.-          const block = !!(args && (args.wait === true || args.block === true));-          await chrome.ensureChromeReady({ background: !block });-          // Prewarm = the AI explicitly wants Chrome for Testing → pin it as the default-          // (unless a different browser was already explicitly pinned). Once cached, every-          // open drives CfT. Pass {setDefault:false} to only download without pinning.-          const pinCft = args && args.setDefault === false ? false : true;-          const curDef = chrome.readDefault();-          if (pinCft && !(curDef && curDef.forced && curDef.kind !== 'chrome-for-testing')) {-            chrome.setDefaultBrowser({ browser: 'cft' });-          }-          const rd = chrome.readiness();-          sendJSON(res, {-            success: rd.installPhase !== 'failed',-            output: JSON.stringify(rd),-            ...rd,-            statusVerb: 'pup_readiness',-            _hint: rd.ready-              ? `Chrome for Testing is ready${pinCft ? ' and pinned as the default' : ''} — pup will drive it on the next pup_open_window. (pup_use {browser:"auto"} reverts to installed Chrome/Edge.)`-              : rd.installing-                ? `Downloading Chrome for Testing (${rd.installProgressPct}%)${pinCft ? '; it is pinned as the default so every open uses it once ready' : ''}. Poll pup_readiness until ready:true. No window is opened by prewarm.`-                : rd.installPhase === 'failed'-                  ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk. pup still works with installed Chrome/Edge — pup_use {browser:"auto"}.`-                  : 'Prewarm started; poll pup_readiness for completion.',-          });-          return;-        }-        // ANSWER a blocking native dialog (v2.0.344). This is the verb the dialog_open error-        // points at. It is deliberately a SEPARATE, explicit call rather than an auto-answer:-        // a beforeunload is a question about the user's unsaved work, and pup answering it on the-        // AI's behalf would silently destroy data in one direction or the other.-        case 'pup_dialog': {-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          // Fall back to whatever window actually holds it: a modal blocks the whole browser, so the-          // caller commonly targets the window they were driving rather than the one that raised it.-          let pending = findPendingDialog(session);-          let holder = resolvedId;-          if (!pending) { const any = findAnyPendingDialog(); if (any) { pending = any; holder = any.sessionId; } }-          if (!pending) {-            sendJSON(res, {-              status: 'ok', handled: false, dialog: null,-              _hint: 'No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else.',-            });-            return;-          }-          const action = (args && args.action) || '';-          if (action !== 'accept' && action !== 'dismiss') {-            sendJSON(res, {-              status: 'error', errorCode: 'dialog_action_required',-              error: `a ${pending.type} dialog is open; pass action:"accept" or action:"dismiss"`,-              dialog: { type: pending.type, message: pending.message, defaultValue: pending.defaultValue },-              _hint: 'accept = press the confirming button (Reload / OK). dismiss = press Cancel. pup will not choose for you.',-            });-            return;-          }-          const holdSession = sessions.get(holder) || session;-          const tab = holdSession.tabs.find(t => t && t.pendingDialog);-          try {-            if (action === 'accept') await pending.dialog.accept(args && args.promptText !== undefined ? String(args.promptText) : undefined);-            else await pending.dialog.dismiss();-          } catch (e) {-            if (tab) tab.pendingDialog = null;   // a stale handle cannot be answered twice-            sendJSON(res, { status: 'error', error: `could not answer the dialog: ${e.message}`, errorCode: 'dialog_answer_failed' });-            return;-          }-          if (tab) tab.pendingDialog = null;-          sendJSON(res, {-            status: 'ok', handled: true, action, window: holder,-            dialog: { type: pending.type, message: pending.message },-            _hint: `Answered "${action}" on window "${holder}". EVERY pup window is unblocked now, not just that one, and the verb that timed out can be retried.`,-          });-          return;-        }--        case 'pup_use': {-          // Pin (or clear) the browser pup drives by default. browser: 'chrome' | 'edge'-          // | 'cft' | 'auto' (or pass an explicit executablePath). 'cft' fetches Chrome-          // for Testing in the background if not cached, then pins it. Persists across-          // bridge restarts. The launch path still spawn-verifies + falls back, so a-          // pinned browser that later breaks won't hard-fail an open.-          const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install), elevate: args ? args.elevate : undefined, temporary: !!(args && args.temporary) });-          const rd = chrome.readiness();-          const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';-          let hint;-          if (!result.ok) {-            hint = `${result.error} Available on this machine: ${availTxt}. Use pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.`;-          } else if (result.cleared) {-            hint = `Default cleared → AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next pup_open_window applies it.`;-          } else if (result.installingChrome) {-            // v1.9.86 (Kyle): this hint was entirely Windows-shaped (UAC, awaiting_uac, "click YES"). On-            // macOS the install is a dmg -> ditto -> /Applications (falling back to ~/Applications)-            // with NO elevation step and NO awaiting_uac phase, so a Mac AI reading the old text-            // would poll for a phase that never arrives and tell the user to approve a prompt that-            // never appears. Per the SDK's first principle the AI reads THIS, not the skill file.-            hint = process.platform === 'darwin'-              ? `Downloading & installing REAL Google Chrome (~90 MB) from Google's .dmg and pinning it as the default. On macOS there is NO elevation step and NO approval prompt: pup mounts the dmg with hdiutil and copies Google Chrome.app to /Applications, falling back to ~/Applications if that isn't writable. Poll pup_readiness: chromeStablePhase installing → ready (there is no awaiting_uac phase on macOS — do not wait for one, and do not tell the user to approve anything). If it fails at both destinations the install genuinely failed; read the error. Until then pup drives ${availTxt}, so nothing is blocked.`-              : `Downloading & installing REAL Google Chrome (~90 MB, user-scoped) and pinning it as the default. On a normal PC this is SILENT (no prompt). On a locked-down box (VM / managed device) it needs a Windows approval — the bridge pops a native "click YES on the UAC" notification on the user's desktop, and if it expires it re-notifies with a one-tap "Approve now" (no AI turn needed). Poll pup_readiness: chromeStablePhase installing → awaiting_uac (waiting on the user's click; chromeStableAwaitingApproval:true) → ready. Until then pup drives ${availTxt}, so nothing is blocked. Pass {browser:"chrome", install:true, elevate:false} to skip the prompt path, or elevate:true to go straight to it.`;-          } else if (result.installing) {-            hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll pup_readiness until ready:true — until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`;-          } else {-            hint = `Default pinned → ${result.defaultBrowser ? result.defaultBrowser.kind : args.browser}. The next pup_open_window drives it. Available: ${availTxt}. Revert anytime with pup_use {browser:"auto"}.`;-            // v2.0.369 (pup-bridge #81): pinning off CfT silently strips the toolbar family from-            // every later launch (--load-extension rides ONLY CfT). Say so at pin time.-            const _pk = result.defaultBrowser ? result.defaultBrowser.kind : args.browser;-            if (_pk && _pk !== 'chrome-for-testing' && _pk !== 'cft' && pupSettings().toolbarExtension !== 'off') {-              hint += ` ⚠ WARNING: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing — windows launched on ${_pk} will have NO toolbar. If this pin is temporary (a comparison/spike), pass {temporary:true} so it expires at the next bridge boot, or revert to auto when done.`;-            }-            if (args && args.temporary) hint += ' This pin is TEMPORARY: it expires automatically at the next bridge boot (back to auto).';-          }-          sendJSON(res, {-            success: !!result.ok,-            output: JSON.stringify({ ...result, readiness: rd }),-            ...result,-            errorCode: result.ok ? undefined : (result.errorCode || 'pup_use_failed'),-            statusVerb: 'pup_readiness',-            _hint: hint,-          });-          return;-        }-        case 'pup_audit_icons': {-          // v2.0.228: detect drift between pup's model and the SHELL's own buttons, repair it, and-          // report what was wrong. fix:false makes it a dry run.-          const _r = await auditTaskbarIcons(args.reason || 'pup_audit_icons verb', { fix: args.fix !== false, force: !!args.force });-          sendJSON(res, { success: true, audit: _r,-            _hint: _r.ok ? 'Every live window matches the shell IN PUP\'S MODEL for this mode. That is not the same as matching the GLASS: the audit cannot read pixels, it infers art from timestamps. If a button looks wrong to a human, believe the human and re-run with {force:true}, which re-asserts every icon with a fresh cache key.'-                         : `${_r.findings.length} drift finding(s), ${_r.repaired} repaired — re-run in ~30s to confirm the glass settled.` });-          return;-        }-        case 'pup_status': {-          // v1.9.172 (jam fix): same resilience as pup_list_windows — parallel + per-session-          // timeout + in-memory fallback, so a busy window can never hang pup_status into null.-          const _entries = [...sessions];-          const list = await Promise.all(_entries.map(([id, s]) =>-            withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s))-          ));-          const _degraded = list.filter(x => x && x.degraded).length;-          const statusResult = {-            ok: true,-            // v2.0.190: version + the enum_windows counter belong on the VERB payload (2.0.188 put-            // them on the HTTP /status chip only — the wrong surface for remote no-shell boxes).-            ...statusCore(),-            sessions: list,-            pupshotUrl,-          };-          if (_degraded) statusResult._degraded = `${_degraded} of ${list.length} window(s) were busy (Chrome under load) and show last-known state — pup is NOT frozen; retry shortly for live values.`;-          // Add guidance when no sessions exist so the AI knows what to do next-          if (sessions.size === 0) {-            statusResult.hint = 'No browser sessions are open. Use pup_open_window with sessionId, profile, and url to launch a Chrome window on the desktop before using screenshot/eval/navigate.';-          } else {-            // v1.8.37: nudge to close stale/piled-up windows (each row already has ageMinutes+owner).-            const cln = staleWindowsHint();-            if (cln) statusResult.hint = cln.trim();-          }-          sendJSON(res, { success: true, output: JSON.stringify(statusResult) });-          return;-        }-        case 'pup_close': {-          // With sessionId: close just that session. Without: close everything.-          // Per-session close mirrors pup_close_window's logic — kills the-          // Chrome process for that profile if no other sessions share it.-          if (args.sessionId) {-            const target = sessions.get(args.sessionId);-            if (!target) {-              sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` });-              return;-            }-            // v1.8.16: refuse a declared cross-owner close.-            { const refusal = ownershipRefusal(target, args.sessionId, args, 'pup_close'); if (refusal) { sendJSON(res, refusal); return; } }-            const profileName = target.profileName;-            // Are there other sessions on this profile? If yes, only close-            // this session's tab (Chrome stays alive for the others). If no,-            // kill the Chrome process so the profile is fully released and-            // the next pup_open_window starts from a clean slate.-            // (Session map key IS the sessionId; iterate entries to compare.)-            const otherSessionsOnProfile = [...sessions.entries()].filter(-              ([id, s]) => id !== args.sessionId && s.profileName === profileName-            );-            let _reap = { killed: 0, remaining: 0 };-            try {-              if (otherSessionsOnProfile.length === 0) {-                // Only this session uses this Chrome — kill the whole browser.-                const be = browsers.get(profileName);-                if (be) {-                  _reap = await killBrowserProcess(be.browser, profileName);-                  browsers.delete(profileName);-                }-              } else {-                // Other sessions still using this Chrome — just close this tab.-                if (target.page) {-                  try { await target.page.close({ runBeforeUnload: false }); } catch {}-                }-              }-            } catch (e) {-              console.log(`pup_close (per-session) error: ${e.message}`);-            }-            sessions.delete(args.sessionId);-            deleteSessionFile(args.sessionId);-            if (activeSessionId === args.sessionId) {-              activeSessionId = sessions.size > 0 ? [...sessions.keys()][0] : null;-            }-            sendJSON(res, { success: true, output: JSON.stringify({-              ok: true,-              sessionId: args.sessionId,-              message: `Session "${args.sessionId}" closed`,-              chromeKilled: otherSessionsOnProfile.length === 0,-              chromeProcsKilled: _reap.killed,-              chromeProcsRemaining: _reap.remaining,-              remainingSessions: sessions.size,-              ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) SURVIVED teardown (should be 0) and may peg a CPU core — retry if it persists.` } : {}),-            })});-            return;-          }--          // No sessionId — close everything (also the taskbar jump-list "Close ALL" task).-          for (const [pName, be] of browsers) {-            await killBrowserProcess(be.browser, pName);-          }-          browsers.clear();-          // v1.9.51: unregister every per-session AUMID as we drop the sessions, so closing all-          // windows leaves NO stale taskbar identity behind (the exact "old icon" class John flagged).-          for (const [id, s] of sessions) {-            deleteSessionFile(id);-            clearSessionBusy(id);                            // v1.9.57-            try { if (s && s._curAppId && s._curAppId !== PUP_APP_ID) unregisterPupAumid(s._curAppId).catch(() => {}); } catch (e) {}-          }-          sessions.clear();-          _sessionOwnerClaims.clear();                       // v1.9.110-          activeSessionId = null;-          // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live.-          try { pruneStaleAumids(); } catch (e) {}-          // Final VERIFIED sweep — with no live session left, reap EVERY pup chrome/renderer still-          // alive (parent or reparented orphan). This is what makes "close all" actually leave zero-          // core-pegging processes behind, and reports the count so the caller knows it's clean.-          const _sweep = await reapOrphanPupChromes();-          sendJSON(res, { success: true, output: JSON.stringify({-            ok: true,-            message: 'All sessions and browsers closed',-            remainingSessions: 0,-            chromeProcsKilled: _sweep.killed,-            chromeProcsRemaining: _sweep.remaining,-            ...(_sweep.remaining ? { _hint: `WARNING: ${_sweep.remaining} pup chrome process(es) survived the close-all sweep (should be 0) and may peg CPU — the box may be heavily loaded; retry pup_close.` } : {}),-          })});-          return;-        }-        case 'pup_wait': {-          const ms = args.ms || 3000;-          await new Promise(r => setTimeout(r, ms));-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, waited: ms }) });-          return;-        }--        // ─── Credential vault (HTTP Basic Auth for pup) ────────────────-        // Stored in the OS keychain (DPAPI on Windows, Keychain on macOS,-        // libsecret on Linux). Applied via puppeteer's page.authenticate()-        // before navigation so Chrome's native auth dialog never appears.-        case 'credential_set': {-          try {-            const r = await credentialVault.setCredential({-              host: args.host,-              username: args.username,-              password: args.password,-            });-            sendJSON(res, { success: true, output: JSON.stringify(r) });-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'pup_credentials':   // v2.0.110: friendlier alias-        case 'credential_list': {-          try {-            const credentials = credentialVault.listCredentials();-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials, _hint: credentials.length ? `${credentials.length} saved login(s) in the OS keychain (passwords never listed). pup auto-fills these in the background; submit is reason-gated via pup_login. Remove one: credential_delete {host}.` : 'No saved logins yet. pup captures them automatically as you sign into sites (captureLogins:"on"), or store one explicitly with credential_set {host, username, password}.' }) });-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        // v2.0.368 (John: "all of us humans are now using ai as our personal assistant so it has to-        // be able to do everything we can do as a human. but yes, security needs to be taken into-        // account."). REVEAL one stored secret to a LOCAL caller, with the user's click as the gate.-        //-        // Why this exists: Grok Bot runs ON the desktop with a shell and calls adom-bridge natively.-        // The adom-you vault is the list that grows with every login pup makes on John's behalf, and-        // nothing could read a secret back out of it: pup_credentials lists host+username only, and-        // pup_login types the secret into a pup window itself. Right for an AI driving pup; wrong for-        // an assistant that needs the credential in ITS OWN tool. ab's own precedent for handing-        // plaintext to a caller is desktop_decrypt_browser_credentials: LOCAL callers only, a consent-        // dialog naming the requester, plaintext returned ONCE over loopback, never logged. This copies-        // that shape and narrows it: ONE host per request, never bulk.-        //-        // THE GATE IS THE CLICK, NOT THE TRANSPORT CHECK. pup cannot verify where a call came from:-        // caller headers are self-reported attribution (ab knows the verified relay connection; pup-        // does not). So the container-header refusal below is a best-effort tripwire for the common-        // cloud-relay case, and the thing that actually protects the vault is the sticky toast on the-        // user's own screen, naming the caller and the host, that THEY have to click. No click, no secret.-        case 'pup_credential_reveal': {-          const host = String((args && args.host) || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');-          const reason = String((args && args.reason) || '').trim();-          if (!host) { sendJSON(res, { success: false, errorCode: 'host_required', error: 'pup_credential_reveal needs {host}: the site whose login you need, one per call', _hint: 'Example: {"host":"github.com","reason":"signing into GitHub in my own browser to open a PR for the user"}. Bulk export does not exist on purpose.' }); return; }-          if (reason.length < 10) { sendJSON(res, { success: false, errorCode: 'reason_required', error: 'pup_credential_reveal needs a reason (min 10 chars) — it is shown to the user on the consent toast', _hint: 'Say what you will do with it, in words the user will read on their screen before deciding.' }); return; }-          // v2.0.374 (#82): ab 2.0.96+ stamps X-Adom-Verified-Origin from the transport it-          // OBSERVED (relay ws vs loopback vs bridge callback) — the one origin signal a caller-          // cannot forge. This verb hands out plaintext, so the gate is strict allow-list: only a-          // VERIFIED local caller passes. 'relay' and 'bridge' are refused, and a MISSING header-          // (pre-2.0.96 ab, or an unidentified process POSTing at pup's port directly) is refused-          // rather than assumed safe. The self-reported container header stays as a tripwire.-          const _vo = req.headers['x-adom-verified-origin'] || null;-          if (_vo !== 'local' || _callerContainer) {-            console.log(`[credential] reveal REFUSED for ${host}: verified-origin=${_vo || 'MISSING'} caller "${_callerThread || '?'}"${_callerContainer ? ` (container ${_callerContainer})` : ''}`);-            const isRelay = _vo === 'relay' || !!_callerContainer;-            sendJSON(res, {-              success: false,-              errorCode: isRelay ? 'relay_forbidden' : 'origin_unverified',-              error: isRelay-                ? 'pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused, matching ab\'s rule for desktop_decrypt_browser_credentials'-                : `pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin "${_vo || 'MISSING'}"`,-              _hint: isRelay-                ? 'From a cloud thread, do not ask for the secret: call pup_login {submit:true, submitReason} and pup signs the site in inside a pup window without the credential ever leaving the vault.'-                : 'Call through the adom-bridge CLI on this desktop (ab 2.0.96+). A bridge callback or a direct loopback POST is not accepted for plaintext reveals.',-            });-            return;-          }-          let cred = null;-          try { cred = await credentialVault.getCredentialForUrl('https://' + host); } catch (e) { cred = null; }-          if (!cred) { sendJSON(res, { success: false, errorCode: 'no_credential', error: `no saved login for ${host}`, _hint: 'pup_credentials {} lists the hosts the vault knows (host + username only).' }); return; }-          const who = _callerThread || 'an unnamed local caller';-          const _ask = await consent.ask({-            key: `pup_credential_reveal|${host}|${_callerThread || 'local'}`, verb: 'pup_credential_reveal',-            what: `Waiting for the user to allow revealing the ${host} login to ${who}`,-            toast: {-              idPrefix: 'credreveal', level: 'warning', scenario: 'reminder',-              title: `Reveal your ${host} login to ${who}?`,-              body: `${who} is asking pup for the saved login for ${host} (username ${cred.username}). Reason given: "${reason}". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.`,-              buttons: [{ label: 'Reveal' }, { label: 'Deny' }],-            },-            callerThread: _callerThread || undefined, callerReason: reason,-          });-          if (_ask.pending) {-            console.log(`[credential] reveal for ${host} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) — answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);-            sendJSON(res, blockedOnUser({-              stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Reveal or Deny on the desktop toast "Reveal your ${host} login to ${who}?"`, remainingSec: _ask.remainingSec,-              error: 'waiting on the user, not failed: the consent toast is still on their screen',-              _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,-            }));-            return;-          }-          const decision = _ask.decision;-          if (decision !== 'Reveal') {-            const code = decision ? 'consent_declined' : 'consent_timeout';-            console.log(`[credential] reveal ${code.toUpperCase()} for ${host} by "${who}"`);-            sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined on the desktop; nothing was revealed' : `the user did not answer the consent toast within ${consent.totalSec}s; nothing was revealed`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to reveal it, then call once more if they say yes.' });-            return;-          }-          // Plaintext ONCE. The audit line below is the only record and it carries no secret.-          console.log(`[credential] reveal GRANTED for ${host} (user ${cred.username}) to "${who}" — user clicked Reveal`);-          sendJSON(res, { success: true, output: JSON.stringify({-            ok: true, host: cred.host, username: cred.username, password: cred.password, sourceAccount: cred.sourceAccount || null, revealedTo: who,-            _hint: 'This secret was released ONCE because the user clicked Reveal on their desktop. Use it, then drop it: never log it, never echo it into another verb\'s args or a response, never write it to disk.',-          }) });-          return;-        }-        // ── Payment-card vault (issue #84: pup could drive a whole checkout, then stall at the-        // card step). Storage mirrors the credential vault; the CONSENT model is stricter: a card-        // is not a password, so EVERY fill prompts the human once, in the moment, with merchant +-        // amount + last4 on the toast. No verb ever returns a PAN or CVV — pup_fill_card types-        // the digits into the page itself and returns booleans, so the AI never handles them.-        case 'pup_card_set': {-          try {-            const r = await cardVault.setCard({ nickname: args.nickname, number: args.number, expiry: args.expiry, cvv: args.cvv, name: args.name });-            console.log(`[card] SET ${r.nickname} (${r.brand} …${r.last4}) by "${_callerThread || '?'}"`);-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r, _hint: 'Stored in the OS keychain. Fill it into a checkout with pup_fill_card {nickname, merchant, amount} — the user approves each fill on a desktop toast. Never echo the number/cvv anywhere again; drop them from your context.' }) });-          } catch (e) { sendJSON(res, { success: false, errorCode: 'card_invalid', error: e.message }); }-          return;-        }-        case 'pup_cards': {-          const cards = cardVault.listCards();-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cards, _hint: cards.length ? 'Nickname/brand/last4/expiry only — the number and CVV never leave the keychain. Fill with pup_fill_card.' : 'No cards saved. Store one with pup_card_set {nickname, number, expiry, cvv, name} — ask the USER to provide it, never harvest it from a page.' }) });-          return;-        }-        case 'pup_card_delete': {-          const r = await cardVault.deleteCard(args.nickname);-          console.log(`[card] DELETE ${args.nickname} (removed=${r.removed}) by "${_callerThread || '?'}"`);-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r }) });-          return;-        }-        case 'pup_fill_card': {-          const nickname = String((args && args.nickname) || '').trim().toLowerCase();-          if (!nickname) { sendJSON(res, { success: false, errorCode: 'nickname_required', error: 'pup_fill_card needs {nickname} — pup_cards {} lists what the vault holds', _hint: 'Also pass {merchant, amount} so the user\'s approval toast names the specific charge.' }); return; }-          const _lv = requireLiveSession(res, args);-          if (!_lv) return;-          const session = _lv.session;-          const sessionId = _lv.sessionId || args.sessionId;-          const meta = (cardVault.listCards() || []).find(c => c.nickname === nickname);-          if (!meta) { sendJSON(res, { success: false, errorCode: 'no_card', error: `no saved card named "${nickname}"`, _hint: 'pup_cards {} lists saved cards; pup_card_set stores one.' }); return; }-          const tab0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];-          const pg = tab0 && tab0.page;-          if (!pg) { sendJSON(res, { success: false, errorCode: 'no_page', error: 'the target window has no live page' }); return; }-          let merchant = String((args && args.merchant) || '').trim();-          if (!merchant) { try { merchant = new URL(pg.url()).hostname; } catch (e) { merchant = 'this page'; } }-          const amount = String((args && args.amount) || '').trim();-          const who = _callerThread || 'an unnamed caller';-          // THE GATE: per-fill human approval, in the moment, naming the specific charge. This is-          // deliberately allowed for RELAYED callers (unlike pup_credential_reveal): the digits-          // never reach the caller, and the human click is the authorization for this one fill.-          console.log(`[card] fill REQUESTED ${nickname} (…${meta.last4}) on ${merchant}${amount ? ' for ' + amount : ''} by "${who}" — asking the user`);-          const _ask = await consent.ask({-            key: `pup_fill_card|${nickname}|${merchant}|${_callerThread || 'anon'}`, verb: 'pup_fill_card',-            what: `Waiting for the user to approve the ${meta.brand} …${meta.last4} fill on ${merchant}`,-            toast: {-              idPrefix: 'cardfill', level: 'warning', scenario: 'reminder',-              title: `Pay with ${meta.brand} …${meta.last4} on ${merchant}?`,-              body: `${who} wants to fill your card "${nickname}" (${meta.brand} ending ${meta.last4}) into the checkout on ${merchant}${amount ? ` for ${amount}` : ''}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.`,-              buttons: [{ label: 'Approve' }, { label: 'Deny' }],-            },-            callerThread: _callerThread || undefined, callerReason: `fill card ${nickname} on ${merchant}`,-          });-          if (_ask.pending) {-            console.log(`[card] fill ${nickname} on ${merchant} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) — answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);-            sendJSON(res, blockedOnUser({-              stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Approve or Deny on the desktop toast "Pay with ${meta.brand} …${meta.last4} on ${merchant}?"`, remainingSec: _ask.remainingSec,-              error: 'waiting on the user, not failed: the approval toast is still on their screen',-              _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,-            }));-            return;-          }-          const decision = _ask.decision;-          if (decision !== 'Approve') {-            const code = decision ? 'consent_declined' : 'consent_timeout';-            console.log(`[card] fill ${code.toUpperCase()} ${nickname} on ${merchant} by "${who}"`);-            sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined the card fill on the desktop; nothing was filled' : `the user did not answer the approval toast within ${consent.totalSec}s; nothing was filled`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to pay with this card, then call once more if they say yes.' });-            return;-          }-          let card = null;-          try { card = await cardVault.getCard(nickname); } catch (e) { card = null; }-          if (!card) { sendJSON(res, { success: false, errorCode: 'no_card', error: 'the card vanished from the keychain between listing and filling' }); return; }-          try {-            const r = await cardVault.fillCardIntoPage(pg, card);-            console.log(`[card] fill GRANTED+DONE ${nickname} (…${meta.last4}) on ${merchant} by "${who}": ${JSON.stringify(r.filled)} (${r.framesScanned} frames)`);-            const missing = Object.entries(r.filled).filter(([k, v]) => !v && (k !== 'name' || card.name)).map(([k]) => k);-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, filled: r.filled, framesScanned: r.framesScanned, last4: meta.last4, brand: meta.brand, _hint: missing.length ? `Fields not found on the page: ${missing.join(', ')} — screenshot the checkout and check whether they use an unusual widget; fill those by driving the page directly WITHOUT ever asking for the digits.` : 'All card fields filled. Screenshot to verify, then submit the checkout. The digits never left the desktop; do not try to read them back.' }) });-          } catch (e) { sendJSON(res, { success: false, errorCode: 'fill_failed', error: `card fill failed: ${e.message}` }); }-          return;-        }-        case 'credential_delete': {-          try {-            const r = await credentialVault.deleteCredential(args.host);-            sendJSON(res, { success: true, output: JSON.stringify(r) });-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        // v1.9.124 — fill (and optionally submit) an HTML login FORM in the BACKGROUND, from the-        // vault or explicit creds, with NO Chrome autofill dropdown and NO foregrounding. This is-        // the answer to Q4: pup owns the creds, so it fills the fields over CDP directly instead of-        // waiting on Chrome's native floaty menu (which pup can't click without stealing focus).-        case 'pup_login': {-          let session, resolvedId;-          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }-          catch (e) { sendSessionResolveError(res, e); return; }-          let tab, tabId;-          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }-          catch (e) { sendTabResolveError(res, e); return; }-          try {-            const curUrl = tab.page.url();-            let username = args.username, password = args.password, source = 'explicit';-            // Optionally persist an explicit cred so NEXT time it's automatic.-            if (username && password && args.save) {-              let host = args.host;-              try { if (!host) host = new URL(curUrl).hostname; } catch (e) {}-              if (host) { try { await credentialVault.setCredential({ host, username, password }); } catch (e) {} }-            }-            // No explicit creds → pull from the vault by the page's current URL.-            if (!password) {-              const creds = await credentialVault.getCredentialForUrl(curUrl);-              if (creds) { username = creds.username; password = creds.password; source = 'vault'; }-            }-            if (!password) {-              sendJSON(res, { success: false, errorCode: 'no_credential', output: JSON.stringify({ ok: false, url: curUrl }),-                _hint: `No credential to fill. Either pass {username, password} (add {save:true} to remember it for this host), or store one first with credential_set {host:"${(() => { try { return new URL(curUrl).hostname; } catch (e) { return '<host>'; } })()}", username, password} and call pup_login again. pup then fills the form in the BACKGROUND — no Chrome dropdown, no foregrounding.` });-              return;-            }-            // v2.0.110 (John): SUBMIT is reason-gated + domain-confirmed. Sending a stored secret-            // is the irreversible act — refuse submit:true without a submitReason, and regurgitate-            // the LIVE domain so the AI must confirm it is not a lookalike/phishing host first.-            if (args.submit && (!args.submitReason || String(args.submitReason).trim().length < 10)) {-              let liveHost = '<unknown>'; try { liveHost = new URL(curUrl).hostname; } catch (e) {}-              sendJSON(res, { success: false, errorCode: 'submit_reason_required', output: JSON.stringify({ ok: false, url: curUrl, host: liveHost }),-                _hint: `⛔ CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS. You asked to submit a stored login, but this is the point of no return: the password goes to "${liveHost}" the instant you submit. Is "${liveHost}" truly the site these credentials belong to — not a lookalike, typo-squat, or a host you were redirected to? If you are sure, re-call pup_login {submit:true, submitReason:"<why you are confident ${liveHost} is the correct, intended domain for this login>"} (min 10 chars). Not sure? Do NOT submit — pup already filled the fields, so a human can finish, or you can navigate away.`,-                _autoMode: 'Claude Code AUTO MODE note: this refusal is pup\'s own gate, not the harness safety check — re-calling pup_login with submitReason is a structured verb call and passes Auto review. Do NOT route around it with pup_eval/raw scripting to poke the form; hand-rolled credential scripting is exactly what Auto mode flags. If the user pasted a password into chat, use it once via pup_login {password}, then tell them pup auto-captures logins so they never need to paste it again.' });-              return;-            }-            if (args.submit) { let lh = ''; try { lh = new URL(curUrl).hostname; } catch (e) {} console.log(`[cred] pup_login submit to ${lh} — reason: ${String(args.submitReason).slice(0, 200)}`); }-            // v2.0.125: realKeystrokes forces genuine key events for React-masked fields (Gusto).-            // Those only register when the window is FOREGROUNDED, so raise it first. Default path-            // (.value-set) stays fully background. autoFill-on-load never uses this.-            const useKeystrokes = args.realKeystrokes === true || args.keystrokes === true;-            if (useKeystrokes) { try { await osRaiseSessionWindow(session, resolvedId, 'realKeystrokes login: this site uses a masked field that only accepts real keyboard input, which requires the window on screen for a moment'); await new Promise(r2 => setTimeout(r2, 500)); } catch (e) {} }-            const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit, keystrokes: useKeystrokes });-            if (!r || !r.ok) {-              sendJSON(res, { success: false, errorCode: r && r.reason === 'no_password_field' ? 'no_login_form' : 'login_fill_failed',-                output: JSON.stringify({ ok: false, reason: r && r.reason, url: curUrl }),-                _hint: (r && r.reason === 'no_password_field')-                  ? 'No password field found on this page — it may not be the login page yet, or the form is in an iframe / shadow DOM pup did not reach. Navigate to the actual login page first, then retry.'-                  : 'Could not fill the login form. Screenshot the page to see its state.' });-              return;-            }-            // v2.0.125: a false filledPassword after the default .value-set almost always means a-            // React-controlled MASKED field (Gusto etc.) silently ignored the set — the classic-            // "submits empty / Password is required" trap. Coach the caller to the fix instead of-            // letting it submit an empty password.-            // v2.0.397 (#92): after a SUBMIT, look for the walls only a human can pass (one-time-            // code, authenticator, captcha). That is blocked_on_user, not success.-            if (args.submit && r.submitted) {-              let _wall = null;-              try { await new Promise(rs => setTimeout(rs, 1800)); _wall = await detectHumanChallenge(tab.page); } catch (e) { _wall = null; }-              if (_wall) {-                console.log(`[login] ${liveHost || curUrl}: submitted, now on a ${_wall.kind} wall (${_wall.evidence}) — blocked_on_user`);-                sendJSON(res, blockedOnUser({ stillRunning: false, reason: _wall.kind, blockedBy: _wall.kind === 'captcha' ? 'The user must solve the captcha in the pup window' : 'The user must enter the one-time / two-factor code in the pup window', evidence: _wall.evidence,-                  output: JSON.stringify({ ok: false, url: (() => { try { return tab.page.url(); } catch (e) { return curUrl; } })(), submitted: true, filledUsername: r.filledUsername, filledPassword: r.filledPassword, wall: _wall.kind }),-                  _hint: `The login was filled and submitted; the site now needs the HUMAN (${_wall.kind}). Raise the window with pup_raise_os_window {foregroundReason:"finish the ${_wall.kind} for ${liveHost || 'this site'}"} and tell them what to do, then continue (screenshot to confirm you are past it). Do not re-submit the password.` }));-                return;-              }-            }-            const reactMaskedMiss = !useKeystrokes && r.filledPassword === false;-            sendJSON(res, { success: true, output: JSON.stringify({-              ok: true, url: curUrl, credentialSource: source,-              filledUsername: r.filledUsername, filledPassword: r.filledPassword,-              submitted: r.submitted, reactMaskedFieldSuspected: reactMaskedMiss || undefined,-            }),-              _hint: reactMaskedMiss-                ? `⚠ The password did NOT land — this field looks React-controlled/masked (common on SSO/Keycloak logins like Gusto): a background .value-set is ignored and the form would submit EMPTY. Re-call pup_login {realKeystrokes:true, submit:true, submitReason:"…"}. That types with real key events, which such fields honour, but it briefly foregrounds the window (the only way real keystrokes register). Check login-recipes/<domain>.md on the pup wiki page for the site's flow.`-                : `Login form filled in the BACKGROUND from ${source} creds${r.submitted ? ' and SUBMITTED' : ' (not submitted — pass {submit:true} to submit, or drive the submit button yourself)'}. No Chrome dropdown, window never foregrounded. Verify with pup_screenshot before assuming you are signed in.` });-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }--        // ─── Desktop recording (HUD) ───────────────────────────────────-        case 'pup_recorder_open': {-          try {-            await ensureRecorderWindowOpen(args.reason);-            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({-              ok: true, opened: true, reason: desktopRecorder.reason,-            })});-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'pup_screen_record_start': {-          try {-            const r = await desktopRecordStartImpl(args);-            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'pup_screen_record_stop': {-          try {-            const r = await desktopRecordStopImpl(args);-            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'pup_screen_record_status': {-          const cur = desktopRecorder.current;-          const status = {-            ok: true,-            hudOpen: !!desktopRecorder.browser,-            reason: desktopRecorder.reason,-            currentRecording: cur ? {-              recordingId: cur.recordingId,-              durationMs: Date.now() - cur.startedAt,-              filePath: cur.filePath,-              fps: cur.fps,-              audio: cur.audio,-            } : null,-            clipsThisSession: desktopRecorder.clipsThisSession,-          };-          sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify(status) });-          return;-        }-        case 'pup_screen_record_list': {-          sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({-            ok: true, recordings: desktopRecordListImpl(),-          })});-          return;-        }-        case 'pup_recorder_close': {-          try {-            const summary = await closeRecorderWindow();-            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({-              ok: true, sessionSummary: summary,-            })});-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'desktop_list_monitors': {-          // Minimal v1 — return primary monitor only, with best-effort dimensions-          // (full multi-monitor enumeration is v1.1 via Tauri-side EnumDisplayMonitors)-          const monitors = [{-            index: 0, name: 'Primary',-            x: 0, y: 0,-            width: 1920, height: 1080, // best-effort fallback-            primary: true,-          }];-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, monitors })});-          return;-        }--        // ─── Tab recording (CDP screencast, no HUD) ─────────────────────-        // v1.9.76 — renew a high-fps lease. The watchdog WARNS before it downgrades, and a-        // running recording holds the lease open on its own, so an AI only needs this when it-        // knows up front it wants longer than the default without recording the whole time.-        case 'pup_highfps_extend': {-          const sid = args.sessionId || activeSessionId;-          if (!sid || !sessions.has(sid)) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }); return; }-          const info = highFpsInfo(sid);-          if (!info) {-            sendJSON(res, { success: false, error: `Session "${sid}" is not a high-fps window — nothing to extend.`,-              _hint: 'This window runs on Chrome\'s normal power behaviour (the default, and the right choice for almost everything). Only a window opened with pup_open_window {highFps:true} holds a lease. If you need continuous full-rate frames while the window is occluded (background video recording), reopen it with highFps:true.' });-            return;-          }-          const rec = highFpsExtend(sid, args.minutes);-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: sid, highFps: highFpsInfo(sid),-            _hint: `High-fps lease extended to ${rec.minutes} min. Reminder: while a recording is actually running the lease CANNOT lapse, so you do not need to keep extending during a take.` }) });-          return;-        }-        case 'pup_record_start': {-          try {-            const sessionId = args.sessionId || activeSessionId;-            if (!sessionId) {-              sendJSON(res, { success: false, error: 'sessionId is required' });-              return;-            }-            // v1.9.76: recording a window that is NOT high-fps yields ~1-2 fps whenever the-            // window is occluded, because Chrome throttles background paints. That is now the-            // DEFAULT power behaviour (issue #14), so warn loudly instead of shipping a bad take.-            const _hf = highFpsInfo(sessionId);-            if (!_hf) console.log(`[highfps] "${sessionId}" recording started on a NORMAL-power window — frames will drop to ~1-2 fps while it is occluded. Reopen with pup_open_window {highFps:true} for a full-rate background take.`);-            const r = await tabRecordStartImpl({ ...args, sessionId });-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'pup_record_stop': {-          try {-            if (!args.recordingId) {-              sendJSON(res, { success: false, error: 'recordingId is required' });-              return;-            }-            const r = await tabRecordStopImpl({ sessionId: args.sessionId, recordingId: args.recordingId });-            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});-          } catch (e) {-            sendJSON(res, { success: false, error: e.message });-          }-          return;-        }-        case 'pup_record_status': {-          const active = tabRecordStatusImpl({ sessionId: args.sessionId });-          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, active })});-          return;-        }-        case 'pup_record_list': {-          sendJSON(res, { success: true, output: JSON.stringify({-            ok: true, recordings: tabRecordListImpl(),-          })});-          return;-        }--        default:-          sendJSON(res, { success: false, errorCode: 'unknown_verb', error: `Unknown command: ${command}.`,-            _hint: `Run pup_describe {} for the FULL current verb catalog (names, args, examples) — it is always up to date, unlike any memorized list. Common intents: window/tab work (pup_open_window, pup_open_tab, pup_my_windows), driving (pup_click, pup_type, pup_fill, pup_press_key, pup_navigate), looking (pup_screenshot, pup_eval), logins (pup_login, pup_credentials), capture (pup_annotate, pup_record_start).`,-            _autoMode: 'Claude Code AUTO MODE note: prefer a structured pup_* verb from pup_describe over improvising with desktop_* clicks + eval chains — structured verbs state their intent and pass Auto review; hand-rolled input scripting is what gets flagged.' }, 404);-          return;-      }-    }--    // v2.0.236: the legacy curl endpoints (/launch /navigate /reload /screenshot /screenshot.png-    // /errors /eval /wait) are GONE. Each duplicated a pup_* verb on the "active session" with none-    // of the ownership gate, session recovery, or foreground etiquette the verbs have — so any-    // caller reaching them got subtly different (worse) behavior, and they were the last consumer of-    // getActiveSession(). Nothing in this repo, the skills, or the recorder referenced them. They-    // answer 410 with the verb to use instead, so a forgotten script fails loudly and fixably.-    {-      const LEGACY = {-        '/launch': 'pup_open_window', '/navigate': 'pup_navigate', '/reload': 'pup_reload',-        '/screenshot': 'pup_screenshot', '/screenshot.png': 'pup_screenshot', '/errors': 'pup_errors',-        '/eval': 'pup_eval', '/wait': 'pup_wait',-      };-      if (LEGACY[pathname]) {-        res.writeHead(410, { 'Content-Type': 'application/json' });-        res.end(JSON.stringify({-          ok: false, gone: pathname, use: LEGACY[pathname],-          _hint: `${pathname} was a legacy curl shim that acted on whatever window happened to be "active" — no owner check, no recovery. Call the verb ${LEGACY[pathname]} through adom-bridge-cli instead (it targets YOUR thread's window and recovers a lost one).`,-        }));-        return;-      }-    }--    // DEMO ONLY (v2.0.342). The say-strip's phrase sending moved into the shared newbie-panel-    // module, which does it client-side against the same-origin relays above, so the old-    // /api/say-send is gone rather than left as a second, drifting implementation of the same-    // contract. What stays is the demo, because the demo script is pup's own and the module only-    // knows how to call back into onPlay.-    if (pathname === '/api/demo-send' && req.method === 'POST') {-      const guard = await shellSupportsSend();-      if (!guard.ok) { sendJSON(res, { ok: false, why: guard.why }); return; }-      const r = await hydrogenPost('/ai-threads/run-prompt', {-        new: true, prompt: DEMO_PROMPT, submit: true, name: 'Pup - demo', tags: ['dock:pup-bridge'],-      }, 120000);-      if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + ').' }); return; }-      const j = r.json || {};-      // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI contract, dock-bar broadcast): run-prompt now returns-      // in under a second with {ok, job, state:'opening'} and delivered_to is never filled on that-      // path; progress comes from polling /ai-threads/run-prompt/status?job=<job>. working or later-      // counts as sent; submit_failed means the prompt sits in the tab unsent (tell the user to press-      // Enter there). Older builds still answer the delivered_to shape, handled below unchanged.-      if (j && j.job && j.state) {-        const t0 = Date.now(); let st = j.state, last = j;-        const SENT = new Set(['working', 'needs_you', 'done']);-        const FAIL = new Set(['submit_failed', 'submitted_unidentified', 'submit_unconfirmed', 'still_working']);-        while (Date.now() - t0 < 30000 && !SENT.has(st) && !FAIL.has(st)) {-          await new Promise(r2 => setTimeout(r2, 1000));-          const pr = await hydrogenGet('/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job), 5000);-          if (pr.status === 404) { sendJSON(res, { ok: false, why: 'This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab.' }); return; }-          if (pr.json && pr.json.state) { last = pr.json; st = pr.json.state; }-        }-        if (SENT.has(st)) { sendJSON(res, { ok: true, state: st, job: j.job }); return; }-        if (st === 'submit_failed') { sendJSON(res, { ok: false, state: st, why: 'The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it.' }); return; }-        if (FAIL.has(st)) { sendJSON(res, { ok: false, state: st, why: 'Hydrogen reported ' + st + ' for the new thread.' }); return; }-        sendJSON(res, { ok: true, state: st, job: j.job, why: 'The new AI tab opened; still ' + st + ' after 30s.' }); return;-      }-      // Three states, matching the module: delivered_to is an ARRAY, and a thread that opened-      // without confirming delivery is NOT a failure. Reporting it as one invites a retry, and a-      // retry double-sends the demo into a second thread.-      const dt = j.delivered_to;-      const arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);-      if (arr.length || (j.inject && j.inject.delivered_in_conversation)) { sendJSON(res, { ok: true, state: 'delivered' }); return; }-      if (r.status >= 200 && r.status < 300) { sendJSON(res, { ok: true, state: 'opened', why: (j._hints && j._hints.note) || null }); return; }-      sendJSON(res, { ok: false, why: (j._hints && j._hints.note) || j.error || ('AI threads returned ' + r.status) });-      return;-    }-    // The DOCK CONTRACT status payload (v2.0.337). John, via the Dock Bar guide: "we can't have-    // pup having 2 different dashboards." pup used to serve a second, near-empty "launch page" from-    // a container-side helper on its own port, and the dock pointed at THAT while this dashboard,-    // the one with the taskbar strip, the window tree and icon provenance, went undiscovered. The-    // dock now opens THIS page, so the contract's status feed has to come from here.-    //-    // Building it in the bridge is not just relocation, it removes a class of bug: the helper-    // reached pup over ab and had to GUESS which desktop to ask when several were connected, which-    // is why John's laptop dashboard rendered a "target: ConfRoomROG" chip. The bridge IS the-    // desktop, so there is nothing to resolve and nothing to get wrong.-    if (pathname === '/dashboard/newbie-panel.js') {-      try {-        const js = fs.readFileSync(path.join(__dirname, 'newbie-panel.js'));-        res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });-        res.end(js);-      } catch (e) { res.writeHead(500); res.end('newbie-panel.js missing'); }-      return;-    }-    // Same-origin proxies for the two Hydrogen endpoints the module calls. The module defaults-    // apiBase to '' ON PURPOSE so the dashboard's own server relays them: a browser page on-    // 127.0.0.1:8851 fetching 127.0.0.1:47084 is cross-origin, and a missing CORS header makes the-    // browser discard a perfectly good 200. The bridge already talks to Hydrogen server-side, so-    // relaying costs nothing and removes CORS from the picture entirely.-    // RELAY THE BYTES, NOT A RE-SERIALISATION (v2.0.349). This used to JSON.parse the upstream-    // response and JSON.stringify it back, which is a lossy round trip pretending to be a proxy:-    // an integer past 2^53 loses precision, 1.0 becomes 1, and a non-JSON body upstream turns into-    // an invented {"error":...} object. The module reads updater.current_version by splitting on-    // dots and treats delivered_to as an ARRAY, so a retype changes BEHAVIOUR while every-    // status probe stays green. Forward the status, the content-type and the body untouched.-    if (pathname === '/updater/status' && req.method === 'GET') {-      const r = await hydrogenGet('/updater/status', 8000);-      if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }-      res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });-      res.end(r.raw);-      return;-    }-    if (pathname === '/ai-threads/run-prompt' && req.method === 'POST') {-      let body = '';-      req.on('data', (d) => { body += d; if (body.length > 65536) req.destroy(); });-      req.on('end', async () => {-        let payload = null;-        try { payload = JSON.parse(body); } catch (e) { res.writeHead(400); res.end('{"error":"bad json"}'); return; }-        // 120s, matching the module's own AbortController: the editor can take 60s just to create-        // a tab on a loaded workbench, and a shorter relay timeout would manufacture a failure the-        // module would then report as "opened" when it actually delivered.-        const r = await hydrogenPost('/ai-threads/run-prompt', payload, 120000);-        if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }-        res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });-        res.end(r.raw);-      });-      return;-    }-    // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI): the THIRD proxied route. run-prompt now returns a job-    // id and the page polls this once a second for {state}. The query string (?job=<uuid>) MUST be-    // forwarded intact: a path-only proxy answers about no job at all. Same byte-faithful relay as-    // the two routes above; a missing ah answers 502-with-a-hint, never a 404 the module reads as-    // "route not wired".-    if (pathname === '/ai-threads/run-prompt/status' && req.method === 'GET') {-      const qs = (() => { try { const u = new URL(req.url, 'http://127.0.0.1'); return u.search || ''; } catch (e) { return ''; } })();-      const r = await hydrogenGet('/ai-threads/run-prompt/status' + qs, 8000);-      if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable', _hint: 'Hydrogen (ah) is not reachable from pup; the AI-threads progress route needs the local Hydrogen control API.' })); return; }-      res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });-      res.end(r.raw);-      return;-    }-    if (pathname === '/api/status') {-      const warming = !chrome || !chrome.readiness;-      const rd = warming ? null : chrome.readiness();-      const leds = [];-      const cftOk = !!(rd && (rd.ready === true || rd.installed === true) && rd.installPhase !== 'failed');-      leds.push({-        key: 'chrome', label: 'Chrome for Testing',-        state: warming ? 'warn' : (rd.installPhase === 'failed' ? 'fail' : (cftOk ? 'ok' : 'warn')),-        detail: warming ? 'the bridge is still warming up'-          : (rd.installPhase === 'failed' ? `install FAILED: ${rd.lastError || 'unknown error'}`-          : (rd.installing ? `downloading (${rd.installProgressPct || 0}%)`-          : (cftOk ? (rd.version ? `installed, pinned ${rd.version}` : 'installed and pinned') : (rd.reason || 'not installed yet')))),-        short: warming ? 'warming' : (cftOk ? 'installed' : (rd.installing ? 'installing' : 'not installed')),-        _hint: cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.',-      });-      // Answering by construction: this response IS the bridge answering, so claiming anything-      // else here would be theatre. The honest variable is the version, not the state.-      leds.push({ key: 'bridge', label: 'Bridge', state: 'ok', detail: `pup ${BRIDGE_VERSION} answering on ${os.hostname()}`, short: 'answering', _hint: null });-      let tabCount = 0, notYou = 0;-      for (const [, s2] of sessions) {-        tabCount += Array.isArray(s2.tabs) ? s2.tabs.length : 0;-        if ((s2.profileName || DEFAULT_PROFILE) !== DEFAULT_PROFILE) notYou++;-      }-      leds.push({-        key: 'windows', label: 'Windows', state: 'ok',-        detail: sessions.size ? `${sessions.size} window(s), ${tabCount} tab(s) open` : 'no pup windows open right now',-        short: `${sessions.size} open`, _hint: null,-      });-      leds.push({-        key: 'profile', label: 'Profile',-        state: notYou ? 'warn' : 'ok',-        detail: notYou ? `${notYou} window(s) are NOT on the shared ${DEFAULT_PROFILE} profile, so their logins build nothing`-                       : `every window is surfing as ${DEFAULT_PROFILE}, so logins accumulate`,-        short: notYou ? `${notYou} not ${DEFAULT_PROFILE}` : DEFAULT_PROFILE, _hint: null,-      });-      const worst = leds.some(l => l.state === 'fail') ? 'fail' : (leds.some(l => l.state === 'warn') ? 'warn' : 'ok');-      sendJSON(res, {-        state: worst, label: 'pup',-        detail: worst === 'ok' ? 'ready for action' : (leds.find(l => l.state !== 'ok') || {}).detail || 'needs attention',-        version: BRIDGE_VERSION, dashboardVersion: BRIDGE_VERSION,-        desktop: os.hostname(), desktops: [os.hostname()], leds,-      });-      return;-    }-    // GET /status-    if (pathname === '/status') {-      // v2.0.13 (John): /status no longer triggers ensureHeavyInit, so on a cold spawn `chrome` may-      // not be loaded yet. Answer the chip probe INSTANTLY with a warming state rather than throwing-      // on chrome.readiness() — that is what let ab's spawn-ready probe succeed instead of reaping us.-      if (!chrome || !chrome.readiness) {-        sendJSON(res, {-          ok: true, bridge: 'puppeteer', version: BRIDGE_VERSION,-          led: 'yellow', summary: 'starting',-          tooltip: `Puppeteer bridge v${BRIDGE_VERSION} — warming up (loading runtime, recovering sessions)`,-          warming: true, ready: false,-          sessionCount: sessions.size, activeSession: activeSessionId,-        });-        return;-      }-      const list = [];-      for (const [id, s] of sessions) {-        let pageUrl = null;-        try { pageUrl = s.page.url(); } catch (e) {}-        list.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });-      }-      // Status-chip contract (AD renders led/summary/tooltip verbatim). The chip-      // now TELLS THE TRUTH about cold-start: yellow while Chrome for Testing is-      // missing/installing, red if its install failed, green when pup is ready.-      const rd = chrome.readiness();-      let led = 'green', summary, tooltip;-      if (rd.installPhase === 'failed') {-        led = 'red'; summary = 'Chrome install failed';-        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing install FAILED: ${rd.lastError}\nRetry pup_prewarm (usually a network/proxy or disk issue).`;-      } else if (rd.installing) {-        led = 'yellow'; summary = `Installing Chrome ${rd.installProgressPct}%`;-        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nDownloading Chrome for Testing (${rd.installProgressPct}%) — pup not ready yet.\nPoll pup_readiness until ready.`;-      } else if (!rd.ready) {-        // No drivable browser AT ALL (no Chrome, no Edge, no cached CfT) — the rare case.-        led = 'yellow'; summary = 'No browser yet';-        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nNo Chrome/Edge detected and Chrome for Testing not cached — pup will fetch CfT on first use (or call pup_prewarm). Installing Chrome or Edge is instant, no download.`;-      } else {-        // READY: a drivable browser exists (installed Chrome/Edge, or cached CfT).-        // Installed-browser-first model — CfT being absent is NOT a warning state.-        led = 'green'; summary = `${sessions.size} window${sessions.size === 1 ? '' : 's'} · ${rd.browserKind}`;-        tooltip = `Puppeteer bridge v${BRIDGE_VERSION} — ready\nDrives ${rd.browserKind} (${rd.browserSource}) · ${sessions.size} session(s)${rd.chromeForTestingInstalled ? `\nCfT ${rd.chromeBuildId || ''} cached as fallback` : ''}`;-      }-      // v1.8.78: the ONE non-cosmetic degradation on old AD gets a visible chip warning.-      if (_adRaiseGateBypassed) {-        if (led === 'green') led = 'yellow';-        tooltip += `\n⚠ AD ${_adVersion} < 1.9.124: raise intercepted — the foreground gate does not apply on this box. Update Adom Bridge.`;-      }-      sendJSON(res, {-        ok: true,-        bridge: 'puppeteer',-        ...statusCore(),-        adVersion: _adVersion,-        raiseGateBypassed: _adRaiseGateBypassed || undefined,-        led, summary, tooltip,-        sessions: list,-        chrome: rd,-        pupshotUrl,-      });-      return;-    }--    // POST /close-    if (pathname === '/close' && req.method === 'POST') {-      for (const [, be] of browsers) {-        try { await be.browser.close(); } catch (e) {}-      }-      browsers.clear();-      sessions.clear();-      activeSessionId = null;-      sendJSON(res, { ok: true, message: 'All sessions closed' });-      return;-    }--    sendJSON(res, { error: 'Unknown endpoint: ' + pathname }, 404);-  } catch (e) {-    // v1.9.79 (issue #16): if a verb blew up (timeout / detached / target closed) on a tab whose-    // renderer CRASHED, do not return a useless generic error. The crash event often lands right as-    // the op times out, so re-check here and return the structured crash error with the-    // reload-to-recover path — turning a 20s silent hang into an actionable answer.-    // A BLOCKING DIALOG LOOKS EXACTLY LIKE A HANG, so say which it is (v2.0.344). Every verb that-    // touches a page with a modal up returns some flavour of "timed out", and three different-    // timeout messages taught the AI nothing about the real cause. If a dialog is pending on this-    // session, the timeout is EXPLAINED rather than reported bare.-    // v2.0.353: scan EVERY session, not args.sessionId. The first cut looked the dialog up on-    // `args.sessionId`, which is the LEGACY id: pup's documented addressing is zero-id ({} resolves-    // to your thread's window) or {window:"..."}, so args.sessionId is normally undefined and this-    // whole branch never ran. The feature worked only for the one calling convention almost nobody-    // uses, which is why it looked shipped and behaved exactly as if it had never been written.-    try {-      const _d = findAnyPendingDialog();-      if (_d && /timed out|timeout|detached|Target closed|Session closed/i.test(e.message || '')) {-        sendJSON(res, {-          status: 'error',-          error: `a ${_d.type} dialog in window "${_d.sessionId}" is BLOCKING EVERY pup window: ${JSON.stringify(_d.message)}`,-          errorCode: 'dialog_open',-          dialog: { type: _d.type, message: _d.message, defaultValue: _d.defaultValue, tabId: _d.tabId, window: _d.sessionId },-          _hint: `EVERY pup window is stuck until this is answered, including windows that have nothing to do with it, because pup's windows share one browser and a modal blocks the browser. The dialog is in window "${_d.sessionId}".` + (_d.type === 'beforeunload'-            ? ` It is asking to confirm leaving a page with UNSAVED CHANGES. pup will not answer it for you: "Cancel" keeps the page and blocks your navigation, "Reload" throws the changes away. Decide, then call pup_dialog {"window":"${_d.sessionId}","action":"accept"} to proceed, or "dismiss" to stay. If the unsaved work might matter, read it out with pup_eval FIRST.`-            : ` Answer it with pup_dialog {"window":"${_d.sessionId}","action":"accept"} or "dismiss"${_d.type === 'prompt' ? ', optionally with promptText' : ''}.`),-        }, 200);-        return;-      }-    } catch (de) {}-    try {-      const _a = (typeof args !== 'undefined' && args) || {};-      const _sess = _a.sessionId && sessions.get(_a.sessionId);-      if (_sess && /timed out|detached|Target closed|Session closed|crashed/i.test(e.message || '')) {-        const _t = _a.tabId ? getTabById(_sess, _a.tabId) : getActiveTab(_sess);-        if (_t && _t._crashed) { _throwIfCrashed(_t, _a.sessionId); }-      }-    } catch (ce) {-      if (ce && ce._isStructured) {-        try { const pl = JSON.parse(ce.message); console.error('Handler error (tab crashed):', pl.error); sendJSON(res, pl, 200); return; } catch (pe) {}-      }-    }-    console.error('Handler error:', e);-    sendJSON(res, { error: e.message }, 500);-  }-});--// Global request timeout — no HTTP request should hang longer than 45s-// v2.0.391 (release sweep on winvm, 2026-09-04): this was 45_000, which silently CLOSED the socket-// under any verb slower than 45s (a heavy vendor site on a small VM, an isolated-profile launch)-// while the handler kept running - the caller saw a transport error and pup still created the-// window, the worst possible split. bridge.json declares up to 240s (prewarm/use) and 150s-// (open_window); the socket budget must exceed the longest declared verb, never undercut it.-server.timeout = 260_000;-server.headersTimeout = 65_000;-server.keepAliveTimeout = 60_000;-server.requestTimeout = 260_000;-server.keepAliveTimeout = 30_000;--// Bind to the host AD tells us to (AD 1.9.63+ passes ADOM_BIND_HOST, always 127.0.0.1),-// falling back to loopback. AD's hard guarantee is NO firewall prompts by default —-// nothing it spawns may bind a public interface (0.0.0.0) unasked. A hostless listen()-// binds every interface and pops the Windows Firewall "allow access?" dialog; honoring-// ADOM_BIND_HOST keeps a loopback-only socket, which is firewall-exempt.-const BIND_HOST = process.env.ADOM_BIND_HOST || '127.0.0.1';-let _pupBoundOnce = false;-const _onPupBound = async () => {-  if (_pupBoundOnce) return; _pupBoundOnce = true;-  console.log(`Puppeteer Bridge running on http://${BIND_HOST}:${PORT} (bind=${BIND_HOST}; no firewall prompt)`);-  // v2.0.41: publish pup's own port so jump-list tasks can talk straight back to us (pup→pup), and-  // (re)write the callback launcher so the .ps1 tracks any format change on upgrade.-  try { writePupPortFile(); writeExtensionPortFile(); ensurePupJumplistLauncher(); } catch (e) {}-  console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');-  console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');-  // v2.0.13 (John, "everyone keeps saying pup is down"): DEFER the heavy native load + warm-up +-  // session recovery OFF the bind tick with setImmediate, so the event loop is free to answer /health-  // and /status (ab's spawn-ready probe) the instant the port binds. Previously ensureHeavyInit() ran-  // synchronously here and blocked the loop for the whole ~8s native load; a probe landing in that-  // window stalled and ab reaped the bridge ("never bound its port 64230") — the cold-spawn flap.-  // Verbs still force ensureHeavyInit() on first use (idempotent), so nothing races ahead of its deps.-  setImmediate(async () => {-    try { ensureHeavyInit(); } catch (e) { console.error('heavy init failed:', e.message); }-    // Self-warm: detect browsers + disk so the FIRST open is instant (cheap no-op if a browser exists).-    // v2.0.369: tell chrome.js whether the toolbar family is wanted BEFORE warmup, so a fresh-    // Edge-only box background-fetches CfT at install time and converges onto the toolbar-capable-    // browser with zero pinning (the Azure-VM Edge regression).-    // v2.0.378 (#81): a pup_use {temporary:true} pin lives only until this boot.-    try { const _exp = chrome && chrome.expireTemporaryDefault && chrome.expireTemporaryDefault(); if (_exp) console.log(`[launch] temporary browser pin (${_exp}) expired at boot → auto`); } catch (e) {}-    try { if (chrome && chrome.setPreferCft) chrome.setPreferCft(pupSettings().toolbarExtension !== 'off'); } catch (e) {}-    try { if (chrome && chrome.warmup) chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }-    // Recover sessions from disk (reconnect to Chrome windows that survived a bridge restart).-    // Fire-and-forget: recovery must NEVER gate readiness or block the health probe.-    try { await recoverSessions(); } catch (e) { console.error('Session recovery failed:', e.message); }-  });-  // v1.8.73 / v2.0.118: register the Adom.Pup anchor app identity once per bridge run (HKCU AUMID,-  // registry-only — NO Start Menu shortcut, users disliked it; also self-heals a stale one). Loopback-  // call from the bridge itself — trusted transport, no relay gating. Self-gates on AD <1.9.134.-  setTimeout(() => { ensurePupIdentityRegistered().catch(() => {}); }, 2500);-  setTimeout(() => { probeAdVersion().catch(() => {}); }, 1500);-  // v1.9.116 — ON EVERY BRIDGE START, FORCE A JUMP-LIST RE-COMMIT FOR EVERY RECOVERED SESSION.-  // This is the structural gap that let a fixed bug reach John anyway. A jump-list task embeds a full-  // CLI command line, so when the command FORMAT changes (v1.9.115 had to add --ai-thread or AD refuses-  // the click), every window that already exists keeps its OLD, now-broken command. stampPupIdentity-  // only re-commits when the appId CHANGES, so a window whose category is unchanged across an upgrade-  // never picks up the new format: John clicked "Switch to public view" on a pre-upgrade window and it-  // silently did nothing, while the same verb worked perfectly from the CLI. Clearing the dedupe key-  // before the startup sweep makes the re-attach real rather than a no-op, so a bridge upgrade now-  // repairs task commands on every surviving window. Cheap: one AD call per live window, once per boot.-  setTimeout(() => {-    try {-      let n = 0;-      for (const [, s] of sessions) { if (s) { s._wikiJumplistKey = undefined; s._jlFrozen = false; n++; } }-      if (n) console.log(`[jumplist] bridge start: forcing a re-commit on ${n} recovered session(s) so task commands match this version`);-    } catch (e) {}-    brandSweep('startup').catch(() => {});-  }, 8000);-  setTimeout(() => { ensureWindowOpenedWatch().catch(() => {}); }, 9000);--  // Session recovery now runs in the setImmediate above (fire-and-forget), so it never gates the-  // health probe or blocks the bind tick.--  // v1.9.45: sweep orphaned AUMID keys, but LATE. Running it straight after recoverSessions() looked-  // right and was wrong: when recovery legitimately ends with zero sessions (or is still settling),-  // "not a live session" matches EVERY key and the sweep deletes the whole set, unbranding windows-  // that were about to be re-stamped (observed: "pruned 12 stale keys" then keys: 0). Wait for the-  // session map to settle, and never sweep from an empty map — with no live sessions there is-  // nothing to distinguish stale from pending, so doing nothing is the correct, safe answer.-  setTimeout(() => {-    try {-      if (!sessions.size) { console.log('[identity] AUMID prune skipped — no live sessions to compare against'); return; }-      pruneStaleAumids();-    } catch (e) {}-  }, 60000);--  // v1.9.47: reclaim on boot (catches windows orphaned by a crash/restart) and then as a slow safety-  // net, so an unbranded window can never persist even if its window-opened event was missed.-  setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000);-  setInterval(trackedLoop('reclaim-unmanaged', 90000, () => { if (minimalTouch()) return; return reclaimUnmanagedWindows().catch(() => {}); }), 90000);-  // v1.9.279: the reconciler runs UNGATED (bookkeeping + identity only, never a process kill), so-  // closed windows are reaped and stamp/settings drift is corrected within seconds, in every mode.-  setInterval(trackedLoop('reconcile-model', 8000, () => reconcileModel('periodic').catch(() => {})), 8000);-  // v2.0.161 (John: AUMID icons need a JANITOR, not just close-time cleanup): sweep orphaned-  // Adom.Pup.* registry keys + prior-generation .ico caches on a slow cadence, so leaks from-  // generation bumps, renamed/closed threads, and crashed windows never accumulate over a long-  // uptime. The 60s startup prune skips an empty session map (boot-recovery guard); this steady-state-  // sweep runs every 5 min and reaps even when nothing is live (an empty map long after boot means-  // the keys are genuinely orphaned, and a re-stamp self-heals the rare reclaim race). reg.exe is-  // only spawned every 5 min, so it adds no ab-log noise.-  setInterval(trackedLoop('prune-stale-aumids', 300000, () => { try { pruneStaleAumids(); } catch (e) {} }), 300000);-  // Build the notched category tiles once at boot (John's clean-backdrop-for-the-overlay idea).-  // Fire-and-forget: pupIconPath serves the un-notched tile until this finishes, then it re-stamps.-  ensureNotchedIcons().catch(() => {});-  // v1.9.179 — TAB RECONCILER. Prune dead/detached and duplicate tab entries so the taskbar count-  // badge reflects the REAL live tabs (rapid reconnects had inflated 1-tab windows to "4") and the-  // favicon overlay reads from a LIVE active tab instead of a detached phantom. Runs on a gentle-  // cadence; only sessions whose tab list actually changed re-stamp their overlay.-  // v1.9.182: boot pass + a SLOW patrol (5 min, was 45s). The dup/phantom state only arises from-  // reconnects, and every rescan already reconciles inline — the patrol is just a backstop, and a-  // hot one multiplies any probe-judgement bug across every window (today's lesson).-  setTimeout(() => { for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }, 8000);-  setInterval(trackedLoop('identity-audit', 300000, () => { if (minimalTouch()) return; auditStampedIdentities().catch(() => {}); }), 300000);   // v2.0.386 (#83 ask 3)-  setInterval(trackedLoop('reconcile-session-tabs', 300000, () => { if (minimalTouch()) return; for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }), 300000);-  // v1.9.171 — CRASH SELF-HEAL. The shared Chrome dies two ways on heavy/hostile pages (measured-  // repeatedly on the Autodesk sign-in flow), and pup must recover from BOTH without a human:-  //-  //   (1) CRASHED — Chrome's process exits. puppeteer fires 'disconnected', handleBrowserDisconnect-  //       DELETES the browsers entry and marks each affected session `_lostBrowser`. So by the time-  //       any watchdog runs the browsers map is already EMPTY — probing it finds nothing (this is why-  //       the v1.9.170 browsers-probe never fired). The real signal is the orphaned `_lostBrowser`-  //       sessions, and the real fix is to RELAUNCH them from disk. On the one durable profile they-  //       come back SIGNED IN at their last URL, in the background frame (no foreground steal).-  //   (2) HUNG — Chrome is alive but its CDP socket stops answering (no 'disconnected' fires, so the-  //       entry LINGERS as a dead handle; the next verb returns 'Connection closed' and passive verbs-  //       return null → pup LOOKS frozen, John: 'pup seems frozen again'). A real CDP probe with a-  //       timeout catches this; two consecutive misses (~30s, not a transient slow beat) = discard +-  //       reap + mark sessions lost, which then feed into the same (1) relaunch path.-  //-  // Backoff is mandatory: a page that crashes-Chrome-on-load (Kepler) would otherwise spin an endless-  // relaunch loop and cook the laptop. Cap auto-relaunches per session; after the cap, hands off and-  // tells the user via the cleanup queue that the page itself looks unstable.-  const _healthStrikes = new Map();       // profileName -> consecutive hung-probe misses-  const _healRelaunch = new Map();        // sessionId -> { count, last, gaveUp }-  const _healingIds = new Set();          // sessionIds with a relaunch in flight (guards the 15s re-tick)-  const MAX_HEAL_ATTEMPTS = 2;-  const HEAL_COOLDOWN_MS = 5 * 60 * 1000;-  // v1.9.256 SLEEP/WAKE + LOST-BROWSER RECOVERY (John, after a day away with the lid closed: "a-  // laptop going to sleep is quite a common occurrence so you need to make sure after a wake that-  // you are able to get back to the previous state you were in, i.e. all your browsers should not-  // be lost anymore"). His dashboard sat on 'browser lost' for a day because the crash-recovery-  // loop lived inside the minimalTouch-gated health interval - the THIRD critical feature silently-  // killed by that one gate (overlay enforcer, user-activity watcher, now recovery). Recovery runs-  // UNGATED here: minimal-touch forbids KILLING or RESTARTING a live Chrome behind the user's back;-  // it does not forbid resurrecting DEAD windows to restore the user's state - that restoration is-  // exactly what the user wants after a sleep. (Hung-Chrome teardown, which DOES kill, stays gated.)-  let _lastRecoveryTick = Date.now();-  setInterval(trackedLoop('sleep-wake-heal', 20000, async () => {-    if (process.platform !== 'win32' || pupSettings().sleepRecovery !== 'on') return;-    const now = Date.now();-    const gap = now - _lastRecoveryTick;-    _lastRecoveryTick = now;-    if (gap > 90000) console.log(`[wake] system appears to have SLEPT ~${Math.round(gap / 60000)}min (timer gap) — checking every session for lost browsers`);-    for (const [sid, sess] of [...sessions]) {-      if (!sess._lostBrowser || _healingIds.has(sid)) continue;-      const rec = _healRelaunch.get(sid) || { count: 0, last: 0, gaveUp: false };-      if (rec.count >= MAX_HEAL_ATTEMPTS) {-        if (now - rec.last < HEAL_COOLDOWN_MS) {-          if (!rec.gaveUp) {-            rec.gaveUp = true; _healRelaunch.set(sid, rec);-            try { sess._healGaveUp = true; } catch (e) {}   // v1.9.305: now the reaper may finally clean it up-            console.log(`[heal] "${sid}" exceeded ${MAX_HEAL_ATTEMPTS} relaunch attempts — backing off ${Math.round(HEAL_COOLDOWN_MS / 60000)}m`);-            recordCleanup(`pup tried to bring your "${sid}" window back but it kept failing — pup has stopped retrying for now. Reopen it manually when ready; your logins are safe on disk.`);-          }-          continue;-        }-        rec.count = 0; rec.gaveUp = false;-      }-      _healingIds.add(sid);-      rec.count += 1; rec.last = now; _healRelaunch.set(sid, rec);-      console.log(`[heal] "${sid}" browser lost (crash or sleep) — recovering (attempt ${rec.count}/${MAX_HEAL_ATTEMPTS}) at its last URL`);-      try {-        const r = await recoverOrRelaunchSession({ sessionId: sid });-        if (r && r.session) {-          console.log(`[heal] "${sid}" recovered — window is back, signed in, where it left off`);-          recordCleanup(`pup auto-recovered your "${sid}" window (lost to a crash or the machine sleeping) — relaunched in the background, signed in, at where it left off.`);-        } else {-          console.log(`[heal] "${sid}" recovery did not resolve a live session yet — will retry next tick`);-        }-      } catch (e) {-        console.log(`[heal] "${sid}" recovery failed: ${e.message} — will retry next tick`);-      } finally {-        _healingIds.delete(sid);-      }-    }-  }), 20000);--  // v1.9.255 NO-FAULT WATCH (John: "if the window does get foreground by chrome itself or a focus-  // or some other reason, you do watch it for 20 seconds and put up a caption saying something like-  // 'it wasn't my fault, the javascript, or focus, or chrome caused the foreground. not me!'").-  // For 20s after each background open/nav, poll the OS foreground ONLY while a watch is armed-  // (zero steady-state cost). If the window surfaces without a granted foregroundReason: show the-  // disclaimer caption (2s), mark it foreground (the IRON LAW: whatever raised it, it stays up),-  // disarm. NEVER a knockback - that variant shipped as 1.9.252 and was rightly killed same day.-  setInterval(trackedLoop('notmine-caption-watch', 2500, async () => {-    // v2.0.292: fgWatch removed (its foreground-STEAL purpose died with off-screen birth; it was-    // caption-only, a 20s-scoped duplicate of notMineCaption). The 20s self-foreground caption now-    // honors notMineCaption — the single control for "a window fronted itself, Adom did not do it".-    if (process.platform !== 'win32' || pupSettings().notMineCaption !== 'on') return;-    try {-      const now = Date.now();-      const watched = [...sessions].filter(([, s2]) => s2._fgWatchUntil && s2._fgWatchUntil > now && !s2._foreground && !s2._lostBrowser);-      if (!watched.length) return;-      const fg = await osGetForegroundWindow();-      if (!fg) return;-      for (const [sid2, s2] of watched) {-        try {-          if (!s2._hwnd) { try { await resolveSessionHwndByBounds(s2, sid2); } catch (e) {} }-          if (!s2._hwnd || String(fg) !== String(s2._hwnd)) continue;-          s2._fgWatchUntil = 0;-          s2._foreground = true;   // iron law: it is up, it stays up-          // v1.9.282 (John, furious the caption lingered): (a) the whole caption is a user-toggle-          // (default on) - if off, mark foreground and move on, silently; (b) DEDUPE hard - the-          // watch fires per-session, and rapid fires all reused the id 'pup-nofault', each resetting-          // AD's expiry so the caption never got its 2s and stayed forever. At most one no-fault-          // caption every 10s, so it can NEVER be held open by a re-issue storm.-          if (pupSettings().notMineCaption !== 'on') break;-          if (Date.now() - (globalThis._lastNoFaultCaptionAt || 0) < 10000) break;-          // v1.9.292 (John: "i tend to click the taskbar icon to bring the window to the foreground...-          // i only want to see this message if it was some other reason other than me raising it").-          // A taskbar click is RECENT user input; a JS/Chrome raise has none. If input happened within-          // the last 1.5s, assume the USER raised it and stay SILENT. Errs toward silence (a JS raise-          // that coincides with the user typing elsewhere is also suppressed — the polite direction).-          const _idle = await osIdleMs();-          if (_idle != null && _idle < 1500) { console.log(`[fg-watch] "${sid2}" surfaced with recent user input (${_idle}ms idle) — assuming USER raised it, no caption`); break; }-          globalThis._lastNoFaultCaptionAt = Date.now();-          console.log(`[fg-watch] "${sid2}" surfaced with no pup raise and no recent input (idle ${_idle}ms) — no-fault caption (2s, deduped)`);-          const _nmMsg = `This window was NOT foregrounded by Adom: JavaScript, a focus change, Chrome itself, or your own click raised "${sid2}". Not me!`;-          const _nmMs = captionMsFor(_nmMsg);-          pupCaption(_nmMsg, _nmMs, 'pup-nofault', _nmMs).catch(() => {});-          break;-        } catch (e) {}-      }-    } catch (e) {}-  }), 2500);--  // v1.9.243 (John: "you have red led's galore in your dashboard. why?"). The overlay ENFORCER was-  // dead on arrival: it sat inside the health interval ABOVE, whose first line is `if (minimalTouch())-  // return;` — and minimal-touch is the normal mode, so it never ran once. Overlays are explicitly-  // ALLOWED in minimal-touch (v1.9.191, gentle branding), so enforcement gets its OWN interval with no-  // minimal-touch gate. 10s cadence: a window is never knowingly bare for more than ~10s.-setInterval(trackedLoop('overlay-enforcer', 4000, async () => {-    if (process.platform !== 'win32') return;-    // v2.0.67: floaty-click harvest runs BEFORE the identity gate (the pencil must work even when-    // identity stamping is unsupported) and heartbeats so a dead sweep is visible in the log.-    global.__floatyHb = (global.__floatyHb || 0) + 1;-    if (global.__floatyHb % 15 === 1) console.log(`[floaty] sweep heartbeat #${global.__floatyHb} (${sessions.size} session(s), identitySupported=${_identitySupported})`);-    // v2.0.230 (John: "find code that loops way too much and chews up wasteful cpu"). This sweep used-    // to make THREE CDP round-trips per tab every 4s: harvest the pencil click, harvest a captured-    // login, and run a title-cleanup blob that was a measured NO-OP (both call sites pass an empty-    // owner, so the in-page code returns immediately). At ~10 tabs that was 7.5 renderer wakeups per-    // second, forever, on an idle machine. Now: ONE evaluate per tab returning both harvests, the-    // dead title sweep deleted, and tabs are skipped entirely when neither feature can fire.-    // v2.0.315: both harvests now arrive by BINDING (push) the instant they happen; this poll is only-    // the fallback for a page that somehow lacks the bindings, so it runs every 8th tick (~32s).-    global.__floatyPollTick = (global.__floatyPollTick || 0) + 1;-    if (global.__floatyPollTick % 8 === 0) try {-      const _capOn = pupSettings().captureLogins !== 'off';-      for (const [sidF, sF] of [...sessions]) {-        if (sF._lostBrowser || !(sF.tabs || []).length) continue;-        if (sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) continue;-        for (const tF of (sF.tabs || [])) {-          if (!tF || !tF.page) continue;-          let harvest = null;-          try {-            harvest = await Promise.race([-              tF.page.evaluate((wantCap) => {-                const out = {};-                const c = window.__pupFloatyClick; if (c) { window.__pupFloatyClick = null; out.click = c; }-                if (wantCap) { const l = window.__pupCapturedLogin; if (l) { window.__pupCapturedLogin = null; out.login = l; } }-                return (out.click || out.login) ? out : null;-              }, _capOn),-              new Promise(r => setTimeout(() => r(null), 2500)),-            ]);-          } catch (e) {}-          if (!harvest) continue;-          if (harvest.login && harvest.login.host && harvest.login.p) {-            const cap = harvest.login;-            try {-              await credentialVault.setCredential({ host: cap.host, username: cap.u || '', password: cap.p });-              console.log(`[cred] captured login for ${cap.host}${cap.u ? ` (${cap.u})` : ''} on "${sidF}" → keychain vault`);-              sF._pendingCredHint = `pup CAPTURED this login for "${cap.host}"${cap.u ? ` (username ${cap.u})` : ''} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit auto-fills and signs in with no Chrome dropdown and no foregrounding. This is how the shared adom-you identity grows — every login you do makes future ones automatic. Manage: pup_credentials {} to list, credential_delete {host} to remove, pup_configure {captureLogins:"off"} to stop capturing.`;-            } catch (e) { console.log(`[cred] capture store failed for ${cap.host}: ${e.message}`); }-          }-          if (harvest.click) {-            sF._annotateArmed = Date.now();-            console.log(`[floaty] "${sidF}" pencil clicked — arming capture watcher on ${tF.tabId}`);-            startAnnotation(sF, sidF, 'user-floaty', { tool: 'pen', tabId: tF.tabId }).catch(() => {});-            break;-          }-        }-      }-    } catch (e) {}-    if (_identitySupported !== true) return;-    try {-      for (const [sid2, s2] of [...sessions]) {-        if (s2._lostBrowser || !(s2.tabs || []).length) continue;-        // v1.9.329: re-stamp an unstamped base tile if needed, but DO NOT `continue` — the overlay-        // paint below is independent and MUST still run this cycle. The old `continue` meant an-        // unstamped window never got its overlay either, so it stayed a bare (now notched → hole)-        // button until it happened to stamp. Both are attempted every sweep now.-        if (pupSettings().aumidIcons === 'on' && !s2._curAppId && Date.now() - (s2._stampTriedAt || 0) > 20000) {-          s2._stampTriedAt = Date.now(); stampPupIdentity(sid2, { once: true }).catch(() => {});-        }-        if (pupSettings().overlayBadges !== 'on') continue;-        // v2.0.47: GROUND-TRUTH RE-ASSERT. Overlay state is write-only (Windows can't be asked-        // whether the badge is still there), and shell-side events beyond the progress bar can-        // silently clear it. Every 60s per window, void the paint memory so the block below-        // repaints unconditionally — one cheap ab call per window per minute buys "the badge you-        // see is the badge we believe", instead of memory drifting from the screen forever.-        // v2.0.230: was every 60s per window — one ab/PowerShell taskbar round-trip per window per-        // minute, forever, purely as a safety net against shell-side clears. Stretched to 5 minutes:-        // the real clears (button rebuild, progress bar, TaskbarCreated) all trigger a repaint by-        // EVENT already, so this only has to catch the exotic case, and it can be lazy about it.-        if (s2._appOverlay && Date.now() - (s2._overlayReassertAt || 0) > 300000) {-          s2._overlayReassertAt = Date.now();-          s2._lastPaintSig = null; s2._appOverlay = false;-          s2._overlayReassertQuiet = true;   // v2.0.48: periodic re-assert, not a real bare-heal — log quietly-        }-        const bare = !s2._appOverlay;-        // A window whose page genuinely has NO favicon anywhere sits on the generic glyph forever, and-        // the enforcer would re-fetch + re-compose it every 10s for the life of the window (John saw the-        // churn in AD's log). Once we've re-attempted the upgrade a few times and the page STILL has no-        // favicon, accept the generic glyph as final and stop re-enforcing — until the tab NAVIGATES,-        // which clears the flag (framenavigated) so a new URL gets fresh upgrade attempts.-        const stuckGeneric = s2._appOverlay && s2._overlayIsGeneric && !s2._overlayGenericFinal;-        if (!bare && !stuckGeneric) continue;-        const last = s2._overlayEnforceAt || 0;-        // v1.9.329: a bare window is now a VISIBLE hole (the notch), so recover it fast — 4s, not 20s.-        // v1.9.337: a GENERIC overlay (favicon fetch missed, e.g. right after an icon-gen re-stamp-        // recreated the button) used to wait 90s before the enforcer re-fetched the real favicon —-        // long enough that a re-stamp left every window wearing the ugly generic globe for over a-        // minute (John: "what the fuck happened to the icons?"). Upgrade generic → real fast now.-        const cooldown = bare ? 4000 : 10000;-        if (Date.now() - last < cooldown) continue;-        s2._overlayEnforceAt = Date.now();-        const wasGeneric = stuckGeneric;-        applyAppOverlay(s2, sid2).then(okp => {-          if (!okp) return;-          if (wasGeneric && !s2._overlayIsGeneric) { s2._overlayGenericTries = 0; console.log(`[overlay] "${sid2}" UPGRADED generic → real favicon (enforcer re-fetched it)`); }-          else if (wasGeneric && s2._overlayIsGeneric) {-            s2._overlayGenericTries = (s2._overlayGenericTries || 0) + 1;-            if (s2._overlayGenericTries >= 3) { s2._overlayGenericFinal = true; console.log(`[overlay] "${sid2}" still has no favicon after 3 upgrade attempts — accepting generic glyph as final; will retry on next navigation`); }-          }-          else if (bare) {-            // v2.0.48 (John saw "ENFORCED (was bare)" spam every minute): the 60s ground-truth-            // re-assert is EXPECTED repainting, not a heal — don't announce it as one. Log a real-            // bare-heal loudly, and the periodic re-assert only once per ~10 min per window.-            if (s2._overlayReassertQuiet) {-              s2._overlayReassertQuiet = false;-              if (!s2._reassertLogAt || Date.now() - s2._reassertLogAt > 600000) { s2._reassertLogAt = Date.now(); console.log(`[overlay] "${sid2}" periodic ground-truth re-assert (5 min cycle; logged at most every 10 min)`); }-            } else console.log(`[overlay] "${sid2}" ENFORCED (was bare) — every pup window must wear its overlay`);-          }-        }).catch(() => {});-      }-    } catch (e) {}-  }), 4000);   // v1.9.329: 4s sweep (was 10s) — a bare window is now a visible notch-hole; heal it fast--  let _healthTick = 0;-  setInterval(trackedLoop('health+user-activity', 15000, async () => {-    _healthTick++;-    if (_healthTick % 2 === 0) { try { await healthLegacyTick(); } catch (e) {} }   // the old 30s loop's duties-    if (process.platform !== 'win32') return;-    // v1.9.185 minimal-touch: NEVER kill or relaunch the user's Chrome behind their back. John:-    // "you're just slapping monitoring on top of it so you can fucking restart it on me which is-    // infuriating." In minimal mode pup reports problems and lets the user decide.-    if (minimalTouch()) return;-    // (2) HUNG detection — probe each LIVE browser entry; on 2 misses, tear it down so its sessions-    // become `_lostBrowser` and fall into the relaunch path below.-    for (const [profileName, entry] of [...browsers]) {-      let alive = false;-      try {-        await Promise.race([-          entry.browser.pages(),-          new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 3500)),-        ]);-        alive = true;-      } catch (e) {}-      if (alive) { _healthStrikes.delete(profileName); continue; }-      const strikes = (_healthStrikes.get(profileName) || 0) + 1;-      _healthStrikes.set(profileName, strikes);-      if (strikes < 2) { console.log(`[health] "${profileName}" failed a liveness probe (strike ${strikes}/2) — watching`); continue; }-      _healthStrikes.delete(profileName);-      console.log(`[health] "${profileName}" HUNG (2 strikes) — discarding the dead handle so its windows can relaunch`);-      try { await entry.browser.close(); } catch (e) {}-      browsers.delete(profileName);-      try { await killOrphanChromesForProfile(profileName); } catch (e) {}-      for (const [, sess] of sessions) { if (sess.profileName === profileName) sess._lostBrowser = true; }-    }-    // (3) USER-ACTIVITY WATCHER + STUCK-OCCLUSION check (v1.9.180/181). One OS foreground read per-    // tick. When the foreground IS a pup window: (a) stamp it user-active + foreground (PERSISTED),-    // so no park/recovery/adoption path ever demotes a window the user chose to bring up — the-    // missing primitive behind "pup keeps yanking my windows" (PLACEMENT-INVARIANT item 1, John's-    // design, finally implemented); (b) run the stuck-occlusion unstick on that ONE window only.-    try {-      // v1.9.185 minimal-touch: no foreground polling, no unstick, no autonomous clicks.-      if (minimalTouch()) throw new Error('minimal-touch');-      if (sessions.size > 0) {-        const fg = await osGetForegroundWindow();-        if (fg) {-          for (const [sid, sess] of [...sessions]) {-            try {-              const hwnd = await cachedSessionHwnd(sid);-              if (!hwnd || String(fg) !== String(hwnd)) continue;-              // v2.0.261: do NOT credit the user for a window that was just born. Chrome-              // self-activates a newly created on-screen window, and this observer running inside-              // that moment would stamp _userActiveAt/_foreground — permanently marking an-              // unasked-for window as "the user's", so nothing would ever move it off their work.-              if (inBirthGrace(sess)) continue;-              sess._userActiveAt = Date.now();-              if (!sess._foreground) {-                sess._foreground = true;-                console.log(`[useractive] "${sid}" observed as the USER'S foreground window — marking foreground (persisted); pup will not re-park it`);-                try { let u = null; try { u = sess.page && sess.page.url(); } catch (e) {} saveSessionFile(sid, sess.profileName, u); } catch (e) {}-              }-              await unstickIfHiddenWhileFront(sid, sess, fg, hwnd);-              break;   // only one window can be foreground-            } catch (e) {}-          }-        }-      }-    } catch (e) {}-  }), 15000);-  // v1.9.166: taskbar-progress reconciler — clear any bar that is lit but shouldn't be.-  // v1.9.181: only for sessions whose bar is ACTUALLY lit (_progressSince set). The old loop fired an-  // AD desktop_taskbar call (a PowerShell round-trip on the desktop) for EVERY session EVERY 20s-  // forever — with 10 windows that was 30 pointless PS invocations a minute pounding the machine pup-  // is supposed to stay invisible on. Steady-state is now zero calls.-  setInterval(trackedLoop('progress-reconciler', 20000, async () => {-    if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress') return;   // v2.0.292-    for (const [sid, sess] of [...sessions]) {-      const st = _busyState.get(sid);-      if ((!st || !st.busy) && sess && sess._progressSince) { try { await setSessionProgress(sid, false); } catch (e) {} }-    }-  }), 20000);-  // v1.9.145 (John found v21-review stranded at -32000 all day) — PLACEMENT SWEEP. The healer-  // (healOffscreenWindow) existed but only ran from a few verb paths, so a long-lived window that-  // predated the on-screen park kept its off-screen geometry FOREVER — made worse by the reparent:-  // windows now survive bridge restarts, so stale geometry survives too. Assert placement for the-  // whole standing population at boot and every 2 minutes; every repair is reported via the-  // cleanup queue so a rescue is never silent. Healthy windows are never touched (the healer-  // no-ops when bounds are on-screen).-  // v2.0.314 (audit): the per-session CDP placement sweep (120s, a CDP session + getWindowBounds per-  // window per tick) is DELETED — sweepVisibility below asserts the same off-screen invariant for-  // the whole population from the OS side, every 30s, in-process.--  // v2.0.251 (John: "why can't i open this pup window? its like you did that off-screen thing again-  // and now when i go to click on it as a human you fuck me over"). The placement sweep above asserts-  // GEOMETRY — it catches a window stranded off-screen, and it explicitly does NOT touch a healthy or-  // minimized one. But a window can be invisible without being mis-placed: pup's taskbar nudge HIDES-  // the window for 80ms to force the shell to rebuild its button, and any interruption in that gap-  // (the 20s execFile kill, a crash, a sleep) left it hidden with nothing anywhere in this codebase-  // watching for it. Reproduced live on AdomLapper: after the hide half alone, IsWindowVisible=false-  // and the taskbar button reveals nothing to a human click — exactly John's report.-  // So placement is no longer the only invariant: VISIBILITY is asserted too, for the whole standing-  // population. Safe by construction — a human can minimize a window but can never hide one, so a-  // hidden pup window is always pup's own mess to clean up (see ensureWindowShown).-  // Deliberately NOT minimalTouch-gated, unlike every other sweep here: minimal-touch exists to keep-  // pup's automatic machinery off the user's taskbar, and a window the user cannot open is not-  // "minimal touch", it is a broken window. Repairing it is the one automatic act that only ever-  // gives the user back something pup took.-  const sweepVisibility = async (cause) => {-    // Never race a nudge's own 80ms hide gap — un-hiding mid-nudge would silently defeat the button-    // rebuild. _nudgeUntil covers the in-flight window; a nudge that STRANDED a window has long since-    // passed it, so the repair still lands (at worst one sweep later).-    if (Date.now() < _nudgeUntil) return;-    // Assert the invariant over the POPULATION, by owning process — deliberately NOT per session.-    // Two earlier cuts of this sweep repaired nothing because they went looking for a session's-    // handle first: windowHwnd -> bestSessionHwnd falls back to enumerate-and-wiggle, which only ever-    // sees VISIBLE windows (circular — 28 straight "NOT resolved" misses on a hidden window sitting-    // right there), and the cached/birth handles are exactly what a window in trouble tends to lack-    // (a park-failed window never persisted one, and a pup restart clears the birth capture).-    // The property that actually matters needs no session mapping at all: NO titled top-level window-    // of a pup-owned Chrome process may be hidden. One enumeration repairs every stranded window,-    // including ones pup has lost track of, and costs one PowerShell per sweep instead of one per-    // session. Titled + WS_CAPTION only, so Chrome's own untitled helper windows are never touched.-    const pids = [...pupBrowserPidSet()].filter(Boolean);-    if (!pids.length) return;-    // v2.0.314: in-process (koffi) — microseconds, no spawn. The PowerShell below is the fallback.-    if (win32.available()) {-      const fixes = win32.fixHiddenOrOffscreen(pids) || [];-      for (const f of fixes) {-        const t = String(f.title || '').slice(0, 60);-        if (f.kind === 'MOVED-ONSCREEN') { console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`moved a pup window back ON-SCREEN ("${t}") — it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {} }-        else if (f.kind === 'MOVE-FAILED') console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is off-screen and would NOT move back — reporting rather than pretending it is fine`);-        else if (f.kind === 'RESHOWN') { console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${t}") — its taskbar button would have revealed nothing when clicked`); } catch (e) {} }-        else console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is hidden and would NOT re-show — reporting rather than pretending it is fine`);-      }-      return;-    }-    const ps = `$s=@'-using System; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;-public static class VS {-  public delegate bool EnumProc(IntPtr h, IntPtr l);-  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);-  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);-  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);-  [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);-  [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);-  [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);-  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);-  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);-  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);-  [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);-  [DllImport("user32.dll")] public static extern bool GetWindowPlacement(IntPtr h, ref WINDOWPLACEMENT p);-  [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }-  [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }-  [StructLayout(LayoutKind.Sequential)] public struct WINDOWPLACEMENT {-    public int length; public int flags; public int showCmd;-    public POINT ptMinPosition; public POINT ptMaxPosition; public RECT rcNormalPosition; }-  public static List<string> Fix(int[] pids) {-    var outp = new List<string>();-    EnumWindows((h, l) => {-      int pid; GetWindowThreadProcessId(h, out pid);-      if (Array.IndexOf(pids, pid) < 0) return true;-      if ((GetWindowLong(h, -16) & 0x00C00000) == 0) return true;   // WS_CAPTION — real windows only-      var sb = new StringBuilder(300); GetWindowText(h, sb, 300);-      if (sb.Length == 0) return true;-      if (!IsWindowVisible(h)) {-        ShowWindow(h, 4);                                            // SW_SHOWNOACTIVATE-        outp.Add((IsWindowVisible(h) ? "RESHOWN|" : "RESHOW-FAILED|") + h + "|" + sb.ToString());-        return true;-      }-      // v2.0.258: HIDDEN was only half of "the user cannot open this window". OFF-SCREEN is the-      // other half, and it is the one that stranded John's "ab ralph" window at -21845,-21845 behind-      // a live taskbar button. Same invariant, same repair: a minimized window reports the -32000-      // placeholder rect legitimately, so judge position from the RESTORE rect, never GetWindowRect.-      if (IsIconic(h)) return true;                                  // minimized is the user's choice-      WINDOWPLACEMENT wp = new WINDOWPLACEMENT();-      wp.length = Marshal.SizeOf(wp);-      if (!GetWindowPlacement(h, ref wp)) return true;-      int L = wp.rcNormalPosition.L, T = wp.rcNormalPosition.T;-      int W = wp.rcNormalPosition.R - L, H = wp.rcNormalPosition.B - T;-      int vx = GetSystemMetrics(76), vy = GetSystemMetrics(77), vw = GetSystemMetrics(78), vh = GetSystemMetrics(79);-      // Off-screen = the window does not overlap the virtual desktop at all.-      if (L < vx + vw && L + W > vx && T < vy + vh && T + H > vy) return true;-      int nw = W > 200 && W <= vw ? W : (vw - 20), nh = H > 200 && H <= vh ? H : (vh - 20);-      SetWindowPos(h, IntPtr.Zero, vx + 10, vy + 10, nw, nh, 0x14);  // NOACTIVATE|NOZORDER-      RECT chk; GetWindowRect(h, out chk);-      bool fixedNow = chk.L < vx + vw && chk.R > vx && chk.T < vy + vh && chk.B > vy;-      outp.Add((fixedNow ? "MOVED-ONSCREEN|" : "MOVE-FAILED|") + h + "|" + sb.ToString());-      return true;-    }, IntPtr.Zero);-    return outp;-  }-}-'@; Add-Type -TypeDefinition $s; [VS]::Fix(@(${pids.join(',')})) | ForEach-Object { $_ }`;-    await new Promise((resolve) => {-      try {-        execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 25000 }, (err, stdout) => {-          for (const line of String(stdout || '').trim().split(/\r?\n/)) {-            const m = /^(RESHOWN|RESHOW-FAILED|MOVED-ONSCREEN|MOVE-FAILED)\|(\d+)\|(.*)$/.exec(line.trim());-            if (!m) continue;-            if (m[1] === 'MOVED-ONSCREEN') {-              console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} sweep — its taskbar button was live while the window sat where no click could reach it`);-              try { recordCleanup(`moved a pup window back ON-SCREEN ("${m[3].slice(0, 60)}") — it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {}-            } else if (m[1] === 'MOVE-FAILED') {-              console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is off-screen and would NOT move back — reporting rather than pretending it is fine`);-            } else if (m[1] === 'RESHOWN') {-              console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} visibility sweep — a hidden window's taskbar button reveals nothing to a human click`);-              try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${m[3].slice(0, 60)}") — its taskbar button would have revealed nothing when clicked`); } catch (e) {}-            } else {-              console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is hidden and would NOT re-show — reporting rather than pretending it is fine`);-            }-          }-          resolve();-        });-      } catch (e) { resolve(); }-    });-  };-  // v2.0.258: every 30s, not 120s. This sweep is the last line of defence for "the user clicked the-  // taskbar button and nothing came up", and John hit that twice in one day — two minutes of an-  // unopenable window is not a safety net, it is the bug with a longer fuse. One PowerShell per-  // 30s across the whole population is cheap next to that.-  setTimeout(() => { sweepVisibility('startup').catch(() => {}); }, 8000);-  setInterval(trackedLoop('visibility-sweep', 30000, () => sweepVisibility('periodic').catch(() => {})), 30000);--  // v1.9.89: reap ORPHANED pup chrome/renderers — any process under a pup-profiles dir whose profile-  // has no live session (a previous bridge crash left them, or renderers that outlived a close and are-  // pegging a core). Boot sweep runs AFTER recovery has settled so it never kills a window recovery-  // just reconnected; then a slow periodic net. Spares every live session BY PROFILE, so it can only-  // ever hit true orphans — never a session's own renderer children.-  setTimeout(() => { reapOrphanPupChromes().catch(() => {}); }, 15000);-  // v2.0.230: skip the PowerShell process enumeration entirely when there is nothing that could-  // have leaked (no live sessions AND no session files on disk) — it was ~20 PowerShell spawns an-  // hour on a totally idle machine.-  setInterval(trackedLoop('reap-orphan-chromes', 180000, () => {-    if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }-    reapOrphanPupChromes().catch(() => {});-  }), 180000);--  // v2.0.129: reap LEAKED isolated profile DIRECTORIES (iso-* throwaways whose window is long gone).-  // Boot pass waits 60s so session recovery has fully settled first (never delete a profile an iso-  // session is mid-recovery on); then a slow 15-min net drains the backlog a few at a time.-  setTimeout(() => { reapLeakedIsolatedProfiles().catch(() => {}); }, 60000);-  setInterval(trackedLoop('reap-leaked-profiles', 900000, () => {-    if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }-    reapLeakedIsolatedProfiles().catch(() => {});-  }), 15 * 60 * 1000);--  // (audit 2.0.314) the 5-minute stale-session-file cleanup was a byte-for-byte duplicate of the-  // tail of the 30s health loop; deleted.-};--// v2.0.16 (#557 ask 3, pup-side defense): EADDRINUSE SELF-HEAL. The #557 permanent outage was ab's-// reaper losing track of a live bridge process (bridge_kill returned killedPids:[]): the orphan kept-// port 64230, so every replacement ab spawned died on the bind conflict, forever, and recovery took a-// manual taskkill. ab's real fix (reap by port owner / child tree) is filed against adom/adom-bridge;-// until it lands, the REPLACEMENT heals it: on EADDRINUSE, probe the holder's /health — if (and ONLY-// if) it identifies as a pup puppeteer bridge, it is by definition an orphan (ab only spawns a-// replacement when the holder is off its books), so kill that ONE pid and retry the bind. A holder-// that is NOT a pup bridge is never touched: log and exit. Convergence: if ab tracks us after the-// heal, routing resumes; if ab already gave up on us, the NEXT spawn heals against us and gets-// tracked — one extra cycle, never a loop (3-retry cap either way).-let _bindRetries = 0;-let _eaccesTries = 0;-server.on('error', (err) => {-  const code = err && err.code;-  // v2.0.231 (John's machine, 2026-08-18): EACCES on a loopback port is almost never a permission-  // problem — on Windows it means the port sits inside a RESERVED range (Hyper-V/WinNAT grabs-  // blocks like 64160-64259 at boot). ab assigns the port, so pup cannot fix routing by itself, and-  // exiting produced an invisible crash-loop: the bridge died on every spawn until the machine was-  // rebooted, with nothing but "fatal listen error (EACCES)" to go on. Now pup NAMES the cause,-  // prints the exact remedy, and binds a fallback port anyway so its own HTTP surface (dashboard,-  // /health) is reachable and the port file tells the truth about where it landed.-  // v2.0.234 (John's AdomLapper, 2026-08-18): EACCES on a loopback port means the port sits inside a-  // Windows RESERVED range — Hyper-V/WinNAT grabs blocks (here 64160-64259, which swallowed ab's-  // assigned 64230) at every boot. Exiting made this a silent permanent outage: ab respawned on the-  // same port, hit the same wall, and after 3 tries went into backoff, so pup stayed dead until-  // someone ran an elevated `net stop winnat`. Now pup STAYS ALIVE and re-attempts the SAME port on-  // a backoff, so the instant the range is released (winnat restart, Docker/WSL shutdown, reboot)-  // it binds and is healthy on the port ab is already routing to — no respawn, no human, no gap.-  if (code === 'EACCES' && process.platform === 'win32') {-    _eaccesTries++;-    if (_eaccesTries === 1) {-      let ranges = '';-      try { ranges = execFileSync('netsh', ['int', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'], { timeout: 8000, windowsHide: true }).toString(); } catch (e) {}-      let inRange = null;-      for (const m of ranges.matchAll(/^\s*(\d+)\s+(\d+)/gm)) {-        const a = Number(m[1]), b = Number(m[2]);-        if (PORT >= a && PORT <= b) { inRange = `${a}-${b}`; break; }-      }-      console.error(`[bind] EACCES on 127.0.0.1:${PORT}${inRange ? ` — that port is inside the Windows RESERVED range ${inRange}` : ''}.`);-      console.error('[bind] Cause: Windows (Hyper-V/WinNAT) reserves port blocks at boot. Not a pup bug, not a permissions problem, and NOT fixable from this process.');-      console.error('[bind] Release it (elevated): net stop winnat && net start winnat   — or reboot. Filed as adom/adom-bridge#83 so ab stops assigning ports inside these ranges.');-      console.error(`[bind] pup will STAY UP and retry ${PORT} every 15s; it binds automatically the moment the range is released.`);-    } else if (_eaccesTries % 20 === 0) {-      console.error(`[bind] still cannot bind ${PORT} (Windows reserved range) — ${_eaccesTries} attempts; retrying every 15s.`);-    }-    setTimeout(() => {-      try {-        server.listen(PORT, BIND_HOST, () => {-          console.error(`[bind] RECOVERED — the Windows reservation on ${PORT} was released after ${_eaccesTries} attempt(s); pup is live on its assigned port with no respawn needed.`);-          _eaccesTries = 0;-          try { _onPupBound(); } catch (e) {}-        });-      } catch (e) {}-    }, 15000);-    return;-  }-  if (code !== 'EADDRINUSE' || process.platform !== 'win32' || _bindRetries >= 3) {-    console.error(`[bind] fatal listen error (${code || (err && err.message)}) — exiting so the supervisor respawns cleanly`);-    process.exit(1);-  }-  _bindRetries++;-  (async () => {-    try {-      const health = await Promise.race([-        fetch(`http://127.0.0.1:${PORT}/health`).then(r => r.json()).catch(() => null),-        new Promise(r => setTimeout(() => r(null), 2500)),-      ]);-      if (!health || health.bridge !== 'puppeteer') {-        console.error(`[bind] port ${PORT} is held by a NON-pup process — refusing to touch it; exiting`);-        process.exit(1);-      }-      const { execFileSync } = require('child_process');-      const pidOut = execFileSync('powershell.exe', ['-NoProfile', '-Command',-        `Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess`],-        { encoding: 'utf8', timeout: 8000, windowsHide: true }).trim();-      const holderPid = Number(pidOut) || 0;-      if (!holderPid || holderPid === process.pid) {-        console.error(`[bind] EADDRINUSE but no resolvable holder pid (got "${pidOut}") — retrying bind blind`);-      } else {-        console.error(`[bind] port ${PORT} held by ORPHANED sibling pup bridge v${health.version || '?'} (pid ${holderPid}) — ab spawned this process as its replacement, so the holder is off ab's books. Killing that ONE pid and retrying (attempt ${_bindRetries}/3).`);-        execFileSync('taskkill', ['/PID', String(holderPid), '/F'], { timeout: 8000, windowsHide: true });-      }-      await new Promise(r => setTimeout(r, 1200));-      server.listen(PORT, BIND_HOST, _onPupBound);-    } catch (e) {-      console.error(`[bind] self-heal attempt ${_bindRetries} failed: ${e.message} — exiting for a clean respawn`);-      process.exit(1);-    }-  })();-});--server.listen(PORT, BIND_HOST, _onPupBound);--// ════════════════════════════════════════════════════════════════════════════-//  RECORDING — desktop (HUD) and tab (CDP screencast)-// ════════════════════════════════════════════════════════════════════════════--const RECORDINGS_DIR = path.join(__dirname, 'recordings');-fs.mkdirSync(RECORDINGS_DIR, { recursive: true });-const RECORDER_PROFILE_DIR = path.join(PROFILES_DIR, '_recorder');--// ─── ffmpeg detection (used to add Duration tag to MediaRecorder WebMs) ──-// Probe at module-load so we know upfront whether the remux path works.-// Cached for the life of the process. Try common Windows install dirs in-// addition to PATH so a winget/scoop/choco install is found even if PATH-// hasn't been refreshed in the bridge's environment.-let FFMPEG_PATH = null;-// v1.8.20: re-callable (was a boot-only IIFE). Recording verbs call it LAZILY when-// FFMPEG_PATH is still null, so a user who installs ffmpeg AFTER the bridge started can-// record immediately — no bridge restart needed. Re-scans cleanly each call.-const SCREEN_RECORDER_HINT = 'This is pup\'s CHROME screen-share recorder (getDisplayMedia -> WebM VP9, consent = the visible recorder HUD, system audio + mic mixing, cross-platform). Canonical names: pup_screen_record_start/stop/status/list + pup_recorder_open/close (pup-bridge#94). ab-core serves desktop_record_* NATIVELY since ab 2.1.36 (WGC + system audio to MP4, no browser, no picker): use that for a whole-monitor or single-window recording; use this Chrome recorder when you need the OS share picker, tab audio, WebM, or a non-Windows box.';-function detectFFmpeg() {-  FFMPEG_PATH = null;-  const { spawnSync } = require('child_process');-  // 1. PATH-  try {-    const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });-    if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }-  } catch {}-  // 2. Common macOS install paths (Homebrew arm64/x64, MacPorts) — AD spawns the bridge-  // outside a login shell, so /opt/homebrew/bin is usually NOT on PATH here.-  if (process.platform === 'darwin') {-    for (const p of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/local/bin/ffmpeg']) {-      try {-        if (fs.existsSync(p)) {-          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });-          if (r.status === 0) { FFMPEG_PATH = p; return; }-        }-      } catch {}-    }-  }-  // 3. Common Windows install paths-  if (process.platform === 'win32') {-    const candidates = [-      'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',-      'C:\\ffmpeg\\bin\\ffmpeg.exe',-      'C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe',-      `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe`,-      `${process.env.USERPROFILE}\\scoop\\shims\\ffmpeg.exe`,-    ];-    for (const p of candidates) {-      try {-        if (fs.existsSync(p)) {-          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });-          if (r.status === 0) { FFMPEG_PATH = p; return; }-        }-      } catch {}-    }-    // 3. Glob the WinGet directory (version number changes between releases)-    try {-      const winget = `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages`;-      if (fs.existsSync(winget)) {-        for (const e of fs.readdirSync(winget)) {-          if (e.startsWith('Gyan.FFmpeg')) {-            const sub = path.join(winget, e);-            const walk = (dir) => {-              for (const f of fs.readdirSync(dir, { withFileTypes: true })) {-                if (f.isDirectory()) {-                  const r = walk(path.join(dir, f.name));-                  if (r) return r;-                } else if (f.name === 'ffmpeg.exe') {-                  return path.join(dir, f.name);-                }-              }-              return null;-            };-            const found = walk(sub);-            if (found) { FFMPEG_PATH = found; return; }-          }-        }-      }-    } catch {}-  }-  return FFMPEG_PATH;-}-detectFFmpeg();-if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);-else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via '${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}' to enable Duration metadata.`);--/**- * Remux a WebM file in place to add a SegmentDuration tag.- * Uses `ffmpeg -i in.webm -c copy out.webm` — copies the stream packets- * unchanged (no re-encode), and the matroska muxer writes a complete- * SegmentInfo with Duration. Fast (~100-300ms for typical clips).- * Atomic: writes to a temp file then renames so a partial run can't- * corrupt the original.- */-async function remuxWebmAddDuration(filePath) {-  if (!FFMPEG_PATH) detectFFmpeg();   // lazy re-scan: ffmpeg may have been installed since boot-  if (!FFMPEG_PATH) throw new Error('ffmpeg not found on PATH or in common Windows install dirs');-  const { spawn } = require('child_process');-  const tmpPath = filePath + '.remux.tmp';-  await new Promise((resolve, reject) => {-    const proc = spawn(FFMPEG_PATH, [-      '-y',                       // overwrite tmp if it exists-      '-loglevel', 'error',       // quiet but still surface errors-      '-i', filePath,-      '-c', 'copy',               // no re-encode — just rewrite container-      '-f', 'webm',-      tmpPath,-    ], { stdio: ['ignore', 'ignore', 'pipe'] });-    let stderr = '';-    proc.stderr.on('data', b => { stderr += b.toString(); });-    proc.on('error', reject);-    proc.on('exit', code => {-      if (code === 0) resolve();-      else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));-    });-  });-  // Atomic replace: rename tmp → original. fs.renameSync replaces on Win10+.-  fs.renameSync(tmpPath, filePath);-}--// Desktop recorder (singleton; one HUD across the whole bridge)-const desktopRecorder = {-  browser: null,-  page: null,-  windowId: null,           // Chrome windowId for Browser.setWindowBounds-  cdp: null,                // CDP session to recorder page-  reason: null,-  hudOpenedAt: null,-  current: null,            // active recording state, see below-  clipsThisSession: [],     // [{recordingId, filePath, sizeKB, durationMs, reason}]-  recCounter: 0,-  idleTimer: null,-};--// Tab recordings (concurrent; many at once)-// Map<recordingId, {sessionId, tabId, dirPath, framesDir, concatStream, manifestFrames,-//                  cdp, started_at, frameCount, fps, quality, stopTimer, stopping}>-const tabRecordings = new Map();-let tabRecCounter = 0;--// ─── Desktop recorder helpers ───────────────────────────────────────────────--function recorderHtmlUrl(reason) {-  const file = path.join(__dirname, 'recorder.html').replace(/\\/g, '/');-  const params = new URLSearchParams({ reason: reason || '' }).toString();-  return `file:///${file}?${params}`;-}--async function ensureRecorderWindowOpen(reason) {-  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {-    throw new Error('reason is required — explain to the user why you\'re recording');-  }-  // If browser+page exist and are alive, just update the reason-  if (desktopRecorder.browser && desktopRecorder.browser.isConnected() && desktopRecorder.page) {-    try {-      await desktopRecorder.page.evaluate((r) => window.setSessionReason && window.setSessionReason(r), reason);-      desktopRecorder.reason = reason;-      return;-    } catch {-      // Page died; tear down and respawn-      try { await desktopRecorder.browser.close(); } catch {}-      desktopRecorder.browser = null;-      desktopRecorder.page = null;-      desktopRecorder.cdp = null;-    }-  }--  fs.mkdirSync(RECORDER_PROFILE_DIR, { recursive: true });--  // Single fixed size for the HUD — large enough that every state's content-  // fits without dynamic resizing. Position is set AFTER launch via CDP using-  // screen.availWidth/availHeight (taskbar-aware, DPI-aware) — Chrome's-  // --window-position arg uses physical pixels and doesn't account for the-  // taskbar, which made the HUD half off-screen on non-1920×1080 displays.-  const winW = 360, winH = 420;--  console.log(`[recorder] Spawning HUD window (reason="${reason}")`);--  const launchArgs = [-    `--app=${recorderHtmlUrl(reason)}`,-    `--window-size=${winW},${winH}`,-    // No --window-position; we anchor bottom-right after the page loads-    '--use-fake-ui-for-media-stream',-    '--auto-select-desktop-capture-source=Entire screen',-    '--enable-features=GetDisplayMediaSet',-    '--auto-accept-this-tab-capture',-    '--no-sandbox',-    '--no-first-run',-    '--no-default-browser-check',-    // v2.0.293: MediaRouter's mDNS binds 0.0.0.0:5353 — a non-loopback listener that pops the-    // Windows Firewall dialog (per-exe). pup's hard rule is ZERO firewall prompts, so the-    // recorder Chrome disables it exactly like the main launch path (one flag, comma-merged —-    // never add a second --disable-features here, Chrome only honors the last).-    '--disable-features=Translate,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider',-    '--media-router=0',-    `--user-data-dir=${RECORDER_PROFILE_DIR}`,-  ];--  desktopRecorder.browser = await puppeteer.launch({-    headless: false,-    defaultViewport: null,-    ignoreDefaultArgs: ['--enable-automation'],-    args: launchArgs,-  });--  // Find the recorder page (the only page in this Chrome)-  const deadline = Date.now() + 5000;-  while (Date.now() < deadline) {-    const pages = await desktopRecorder.browser.pages();-    if (pages.length > 0) {-      desktopRecorder.page = pages[0];-      break;-    }-    await new Promise(r => setTimeout(r, 50));-  }-  if (!desktopRecorder.page) throw new Error('Recorder window did not produce a page');--  await desktopRecorder.page.waitForFunction(() => typeof window.startRecording === 'function', { timeout: 10000 });--  // ── Bug 1 fix: anchor bottom-right via screen.availWidth/availHeight ──-  // Chrome's --window-position uses physical pixels and ignores the taskbar,-  // so a hardcoded fallback can land the HUD off-screen on any monitor that-  // isn't 1920×1080. Query the page's `screen.avail*` (DIPs, taskbar-aware)-  // and use CDP Browser.setWindowBounds to position it precisely.-  try {-    const cdp = await desktopRecorder.page.target().createCDPSession();-    desktopRecorder.cdp = cdp;-    const wInfo = await cdp.send('Browser.getWindowForTarget', {});-    desktopRecorder.windowId = wInfo.windowId;--    const dims = await desktopRecorder.page.evaluate(() => ({-      availLeft: screen.availLeft || 0,-      availTop: screen.availTop || 0,-      availWidth: screen.availWidth,-      availHeight: screen.availHeight,-      outerWidth: window.outerWidth,-      outerHeight: window.outerHeight,-    }));-    const margin = 16;-    const winW = Math.min(dims.outerWidth, dims.availWidth);-    const winH = Math.min(dims.outerHeight, dims.availHeight);-    const left = Math.max(dims.availLeft, dims.availLeft + dims.availWidth - winW - margin);-    const top = Math.max(dims.availTop, dims.availTop + dims.availHeight - winH - margin);-    await cdp.send('Browser.setWindowBounds', {-      windowId: wInfo.windowId,-      bounds: { left, top, width: winW, height: winH, windowState: 'normal' },-    });-    console.log(`[recorder] anchored bottom-right: (${left},${top}) ${winW}x${winH} (work area ${dims.availWidth}x${dims.availHeight})`);-  } catch (e) {-    console.log(`[recorder] anchor failed (non-fatal): ${e.message}`);-  }--  // Wire exposed functions-  await desktopRecorder.page.exposeFunction('_saveChunk', (b64) => {-    if (!desktopRecorder.current || !desktopRecorder.current.fd) return;-    try {-      fs.writeSync(desktopRecorder.current.fd, Buffer.from(b64, 'base64'));-    } catch (e) {-      console.error(`[recorder] saveChunk failed: ${e.message}`);-    }-  });--  await desktopRecorder.page.exposeFunction('_recordingStopped', async ({ sentBytes, durationMs }) => {-    const cur = desktopRecorder.current;-    if (!cur) return;-    try { fs.closeSync(cur.fd); } catch {}-    cur.fd = null;--    // ── Bug 3a fix: ffmpeg remux to write SegmentDuration ──-    // MediaRecorder writes a "live" WebM with no Duration element, so-    // ffprobe reports duration=N/A. Earlier attempts at byte-level EBML-    // patching corrupted the Tracks element / SeekHead offsets and made-    // the resulting files unplayable (nb_streams=0). Ditched.-    //-    // The reliable fix is `ffmpeg -i in.webm -c copy out.webm` — copies-    // streams (no re-encode, fast: ~50-200 MB/s), and the muxer writes-    // a proper SegmentInfo with Duration. We do this asynchronously so-    // the UI's "Saved" state appears immediately; the patched file is-    // ready by the time the user pulls it.-    //-    // If ffmpeg isn't on PATH we leave the file untouched. It's still a-    // valid (playable) WebM — just no Duration tag in the container.-    // Telemetry: log clearly so the user knows whether it ran.-    try {-      await remuxWebmAddDuration(cur.filePath);-      console.log(`[recorder] ffmpeg remux added Duration tag to ${cur.filePath}`);-    } catch (e) {-      console.error(`[recorder] ffmpeg remux failed (file still plays, ffprobe will report N/A): ${e.message}`);-    }--    let sizeKB = 0;-    try { sizeKB = Math.round(fs.statSync(cur.filePath).size / 1024); } catch {}-    cur.durationMs = durationMs;-    cur.sizeKB = sizeKB;-    cur.stoppedAt = Date.now();-    // Write sidecar metadata-    try {-      const sidecar = {-        recordingId: cur.recordingId,-        filePath: cur.filePath,-        startedAt: cur.startedAt,-        stoppedAt: cur.stoppedAt,-        durationMs,-        sizeKB,-        sizeBytes: sentBytes,-        fps: cur.fps,-        audio: cur.audio,-        monitor: cur.monitor,-        reason: cur.reason,-        stopReason: cur.stopReason || 'requested',-      };-      fs.writeFileSync(cur.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));-    } catch (e) {-      console.error(`[recorder] sidecar write failed: ${e.message}`);-    }-    // Add to session clip list-    desktopRecorder.clipsThisSession.push({-      recordingId: cur.recordingId, filePath: cur.filePath,-      sizeKB, durationMs, reason: cur.reason,-    });-    desktopRecorder.current = null;-    console.log(`[recorder] Clip ${cur.recordingId} stopped: ${sizeKB}KB, ${durationMs}ms`);-    // Tell page to show Saved state-    try {-      await desktopRecorder.page.evaluate((clip) => window.showSaved && window.showSaved(clip), {-        filename: path.basename(cur.filePath),-        sizeBytes: sentBytes,-        durationMs,-      });-    } catch {}-    // Resolve any waiter-    if (cur.stopResolver) cur.stopResolver();-  });--  await desktopRecorder.page.exposeFunction('_resizeWindow', async ({ width, height }) => {-    try {-      const cdp = desktopRecorder.cdp || (desktopRecorder.cdp = await desktopRecorder.page.target().createCDPSession());-      const target = desktopRecorder.page.target();-      const targetId = target._targetId || target.target?.()?._targetId || (await target.createCDPSession()).then(()=>{}); // best effort-      // Use Puppeteer's underlying CDP: simpler to use Browser.getWindowForTarget by sending raw CDP-      const { windowId } = await cdp.send('Browser.getWindowForTarget', {});-      desktopRecorder.windowId = windowId;-      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width, height } });-    } catch (e) {-      console.log(`[recorder] resize failed (non-fatal): ${e.message}`);-    }-  });--  await desktopRecorder.page.exposeFunction('_recorderClosed', () => {-    console.log(`[recorder] Page reports closing`);-  });--  await desktopRecorder.page.exposeFunction('_openRecordingsFolder', () => {-    try {-      execSync(`explorer "${RECORDINGS_DIR}"`, { stdio: 'ignore' });-    } catch {}-  });--  // Detect window-close as a stop signal-  desktopRecorder.browser.on('disconnected', () => {-    console.log(`[recorder] Browser disconnected`);-    if (desktopRecorder.current) {-      // Best-effort: mark stopped, write whatever we have-      try { if (desktopRecorder.current.fd) fs.closeSync(desktopRecorder.current.fd); } catch {}-      if (desktopRecorder.current.stopResolver) desktopRecorder.current.stopResolver();-      desktopRecorder.current = null;-    }-    desktopRecorder.browser = null;-    desktopRecorder.page = null;-    desktopRecorder.cdp = null;-    desktopRecorder.reason = null;-    desktopRecorder.clipsThisSession = [];-  });--  desktopRecorder.reason = reason;-  desktopRecorder.hudOpenedAt = Date.now();-  desktopRecorder.clipsThisSession = [];-}--async function closeRecorderWindow() {-  if (desktopRecorder.current) {-    // Stop active recording first-    try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}-    // Wait briefly for stop to flush-    const deadline = Date.now() + 3000;-    while (desktopRecorder.current && Date.now() < deadline) {-      await new Promise(r => setTimeout(r, 50));-    }-  }-  const summary = {-    clipCount: desktopRecorder.clipsThisSession.length,-    totalSizeKB: desktopRecorder.clipsThisSession.reduce((a, c) => a + (c.sizeKB || 0), 0),-    reason: desktopRecorder.reason,-    clips: desktopRecorder.clipsThisSession,-  };-  if (desktopRecorder.browser) {-    try { await desktopRecorder.browser.close(); } catch {}-  }-  desktopRecorder.browser = null;-  desktopRecorder.page = null;-  desktopRecorder.cdp = null;-  desktopRecorder.reason = null;-  desktopRecorder.current = null;-  desktopRecorder.clipsThisSession = [];-  return summary;-}--async function desktopRecordStartImpl(opts) {-  const { reason, monitor = 'primary', fps = 30, audio = false,-          videoBitsPerSecond = 4_000_000, maxDurationMs = 600_000,-          confirmDesktopNotTabRecording } = opts;--  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {-    throw new Error('reason is required — explain to the user why you\'re recording');-  }--  // Guardrail: Claude frequently mistakes "record this pup window/tab" for a-  // desktop-recording request. Desktop recording captures the WHOLE screen-  // (including unrelated apps in the foreground); if the pup window is-  // occluded the resulting clip won't show what the user actually wanted.-  // For 95% of "record a pup" asks the right verb is pup_record_start-  // (tab-scoped via CDP, captures regardless of foreground state, ~50fps).-  // Adom-desktop's full-desktop recording is mostly redundant with what-  // Hydrogen ships natively, and only really useful when Hydrogen is-  // already busy doing tab-recording for ralph-loop screenshot tests AND-  // the user wants a parallel desktop video at the same time.-  // Force the caller to explicitly opt in so they re-read this and confirm.-  if (confirmDesktopNotTabRecording !== true) {-    const err = new Error(-      'desktop_record_start records the WHOLE DESKTOP, not a pup tab. ' +-      'If the user said "record a pup" / "record this Chrome window" / "record the page", ' +-      'they almost certainly want pup_record_start instead — that is tab-scoped via ' +-      'CDP Page.startScreencast, captures the pup tab regardless of foreground state, ' +-      'returns a single .webm at ~50fps, and does not need a HUD. ' +-      'Hydrogen also has built-in desktop recording (recording start --share screen) which ' +-      'is usually preferable for general "record my screen" asks. ' +-      'adom-desktop\'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'));+// v2.0.595 (Kyle, 2026-09-16: "i just got a popup for adom bridge trying to control system events+// ... system events shouldn't be required"). NO `tell application "System Events"` anywhere on mac:+// Apple Events to System Events need the Automation TCC grant, which nobody is granted, and since+// Hydrogen forwards every plugin verb through Bridge the prompt reads "Adom Bridge wants to control+// System Events". Hide/unhide/activate go through NSRunningApplication instead, called in-process+// by osascript's JavaScript-for-Automation ObjC bridge: no Apple Event leaves the process, so no+// TCC prompt of any kind. Same doctrine as bridge-macos 1.9.202 and the Rust host-mac crate.+function runJxaPromise(script, { timeout = 8000 } = {}) {+  return new Promise(resolve => {+    execFile('/usr/bin/osascript', ['-l', 'JavaScript', '-e', script], { encoding: 'utf8', timeout }, (err, stdout) => resolve(err ? null : (stdout || '').trim()));+  });+}+// Hide (true) / unhide (false) every window of a process (the Cmd+H state). NSRunningApplication's+// hide/unhide are asynchronous, so the result is judged by `hidden` a beat later, not by the call.+async function macSetAppHiddenByPid(pid, hidden) {+  if (!pid) return false;+  const r = await runJxaPromise(`ObjC.import('AppKit'); var a = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${Number(pid)}); (a && !a.isNil()) ? (${hidden ? 'a.hide' : 'a.unhide'}, delay(0.3), String(a.hidden)) : 'null'`);+  return r === String(hidden);+}+// Unhide + bring the process's windows to the foreground (the user-asked-to-watch path).+async function macActivateByPid(pid) {+  if (!pid) return false;+  const r = await runJxaPromise(`ObjC.import('AppKit'); var a = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${Number(pid)}); (a && !a.isNil()) ? (a.unhide, String(a.activateWithOptions($.NSApplicationActivateAllWindows))) : 'null'`);+  return r !== null && r !== 'null';+}+// The pid that owns a session's browser (detached launches record it; puppeteer.launch+// exposes process()). Null when unresolvable — callers report applied:false.+function macSessionPid(session) {+  try {+    const be = browsers.get(session.profileName);+    const b = be && be.browser;+    if (!b) return null;+    return b._detachedPid || (b.process() && b.process().pid) || null;+  } catch (e) { return null; }+}++// ── v1.9.218 — SELF-HEALING DEPENDENCIES (wiki issue #15) ───────────────────────────────────────+// pup ships as a SOURCE-ONLY zip (node_modules is 88MB of native builds — it cannot go in the wiki+// release), and the design assumes AD runs `npm install` in the bridge dir on spawn. In practice that+// does not reliably happen: AD re-provisions the cache dir on respawn, wiping any manual install, and+// the bridge then dies at this very line with MODULE_NOT_FOUND: Cannot find module 'puppeteer'. The+// user's lived experience, in the reporter's words, is "pup keeps breaking and I always have to+// reinstall it" — and a human had to npm install by hand again TODAY to bring it back.+//+// A bridge that cannot start without a step someone else may not perform is not robust. So: detect+// missing deps HERE, before the require that would crash, and install them ourselves. This is+// idempotent, runs only when deps are actually absent, and turns a hard crash-loop into a slow first+// start. It does not replace AD's install — it makes pup survive when that install did not happen.+function ensureDepsInstalled() {+  try {+    require.resolve('puppeteer');+    return { ok: true, installed: false };+  } catch (e) {+    // Deps are missing. Install them in OUR directory, synchronously, before anything requires them.+    const dir = __dirname;+    console.error(`[deps] node_modules missing (${e.code || 'MODULE_NOT_FOUND'}) — self-installing in ${dir}. This is wiki issue #15: the source-only zip ships no node_modules and AD's install did not run or was wiped by a respawn.`);+    const started = Date.now();+    try {+      const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';+      // #280: do NOT let puppeteer's postinstall block/fail on a Chrome-for-Testing download here. It+      // reliably fails behind npm's allow-scripts gate and on half-populated CfT cache dirs, and pup+      // drives system Chrome/Edge without it. Skip it so the self-install finishes fast and pup is+      // immediately usable via pup_use; CfT is fetched lazily (last resort) only when genuinely no+      // system browser exists.+      execFileSync(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {+        cwd: dir, stdio: 'inherit', timeout: 300000, windowsHide: true,+        env: { ...process.env, PUPPETEER_SKIP_DOWNLOAD: '1', PUPPETEER_SKIP_CHROMIUM_DOWNLOAD: '1' },+      });+      require.resolve('puppeteer');+      console.error(`[deps] self-install SUCCEEDED in ${Math.round((Date.now() - started) / 1000)}s — pup is starting normally. No human npm install needed.`);+      return { ok: true, installed: true };+    } catch (e2) {+      console.error(`[deps] self-install FAILED: ${e2.message}. pup cannot start. Check that node + npm are on PATH for the AD process, then retry, or run: cd "${dir}" && npm install`);+      return { ok: false, installed: false, error: e2.message };+    }+  }+}+// v1.9.334 (wiki issue #468 — Drew: pup "never bound its port", 100% unusable). The heavy NATIVE+// requires below (puppeteer, keytar via credential_vault, sharp, and chrome.js — which itself requires+// puppeteer) block the event loop for ~8s at module load, so the HTTP port only bound at ~8s, long+// after AD's recover_not_listening reaps a not-yet-bound bridge (~2-3.6s). FIX: DEFER them out of the+// SYNCHRONOUS module-load path. Only light modules load synchronously now → the port binds in <1s, AD+// sees it listening and never reaps, and the ~8s of native loading happens in ensureHeavyInit() AFTER+// the bind (first line of the listen callback, plus lazily on the first non-/health request). /health+// needs no heavy module (its chrome.readiness() is guarded for the pre-init window).+let _depState = null, puppeteer = null, credentialVault = null, cardVault = null, chrome = null;+let _heavyInitDone = false;+function ensureHeavyInit() {+  if (_heavyInitDone) return;+  _heavyInitDone = true;+  try { _depState = ensureDepsInstalled(); } catch (e) { _depState = { ok: false, error: e.message }; }+  puppeteer = require('puppeteer');+  credentialVault = require('./credential_vault');+  cardVault = require('./card_vault');+  chrome = require('./chrome'); // Chrome-for-Testing self-heal (also pulls puppeteer internally)+  try { sharp = require('sharp'); }+  catch (e) { console.warn('[pup] sharp unavailable — screenshots served at capture size, not downscaled:', e.message); sharp = null; }+}++// sharp is REQUIRED for resizing screenshots — never send full-res to container.+// v1.8.40+: previously this was wrapped in try/catch with a `let sharp = null`+// fallback. The fallback path silently sent 2559px PNGs to /tmp/adom-desktop-screenshots/,+// which CRASHES Claude many-image sessions at the 2000px limit. Hard-require+// sharp is OPTIONAL — a heavy native dep whose install can fail on a fresh box (no+// prebuild / no build tools / a managed-runtime PATH gap). pup must still spawn and+// work without it: we fall back to serving the screenshot at capture size and reading+// dimensions from the PNG header. When sharp IS present, we downscale to keep images+// under Claude's Read limit (the ideal path). Never let a missing sharp crash the bridge.+let sharp = null;   // v1.9.334: loaded lazily in ensureHeavyInit() (see the #468 deferral above) — never in the sync module-load path+const MAX_SCREENSHOT_DIM = 1568; // Claude's optimal max dimension++// PNG width/height straight from the IHDR header (bytes 16-23, big-endian). Pure JS,+// no sharp needed. puppeteer screenshots are always PNG.+function pngDimensions(buf) {+  try { return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; }+  catch { return { width: 0, height: 0 }; }+}+// Downscale a PNG to <= maxDim (long edge) when sharp is available; otherwise pass it+// through at capture size. Returns the (possibly-resized) buffer + dimensions + flags.+async function processScreenshot(buf, maxDim) {+  if (sharp) {+    const orig = await sharp(buf).metadata();+    const needsResize = orig.width > maxDim || orig.height > maxDim;+    const out = await (needsResize+      ? sharp(buf).resize({ width: maxDim, height: maxDim, fit: 'inside' })+      : sharp(buf)).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();+    const fin = await sharp(out).metadata();+    return { buf: out, width: fin.width, height: fin.height, origWidth: orig.width, origHeight: orig.height, resized: needsResize, sharpUsed: true };+  }+  const d = pngDimensions(buf);+  return { buf, width: d.width, height: d.height, origWidth: d.width, origHeight: d.height, resized: false, sharpUsed: false };+}++const PORT = (() => {+  const idx = process.argv.indexOf('--port');+  return idx !== -1 && process.argv[idx + 1] ? parseInt(process.argv[idx + 1]) : 8851;+})();++// v2.0.50: HUMAN-INITIATED caller identities (taskbar jump-list clicks, the toolbar-extension+// popup). Exempt from the ownership gate (a human operating their own window is always+// sanctioned) and never recorded as a window's owner. Was 4 exact-string compares on+// 'user-taskbar-menu'; the toolbar extension adds 'user-toolbar'.+const USER_CALLERS = new Set(['user-taskbar-menu', 'user-toolbar', 'user-pill']);+const BRIDGE_VERSION = (() => { try { return fs.readFileSync(path.join(__dirname, 'BRIDGE_VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();++const SHOTS_DIR = path.join(__dirname, 'screenshots');+fs.mkdirSync(SHOTS_DIR, { recursive: true });++// v1.8.21: shadow-DOM-piercing + nested-scroll helpers, installed into every page's+// window so pup_eval can use them ($deep / $$deep / deepText) AND so pup_scroll+// / pup_screenshot_element resolve elements + the ACTUAL scroll container the same+// way. Idempotent (guarded by __pupDeepInstalled). Injected via evaluateOnNewDocument+// (persists across navigations) AND ensured on-demand before each verb (covers the+// already-loaded document).+const PUP_DEEP_HELPERS = `(function(){+  if (window.__pupDeepInstalled) return; window.__pupDeepInstalled = true;+  // querySelectorAll piercing OPEN shadow roots+  window.$$deep = function(sel, root){ root = root||document; var out=[];+    var walk=function(n){ var f; try{f=n.querySelectorAll(sel);}catch(e){f=[];}+      for(var i=0;i<f.length;i++)out.push(f[i]);+      var a=n.querySelectorAll('*'); for(var j=0;j<a.length;j++){ if(a[j].shadowRoot) walk(a[j].shadowRoot); } };+    walk(root); return out; };+  window.$deep = function(sel, root){ var a=window.$$deep(sel,root); return a[0]||null; };+  // first VISIBLE element whose own text contains txt (case-insensitive), piercing shadow+  window.deepText = function(txt, root){ root=root||document; txt=(txt||'').toLowerCase(); var best=null;+    var walk=function(n){ var a=n.querySelectorAll('*'); for(var i=0;i<a.length;i++){ var el=a[i], own='';+      for(var k=0;k<el.childNodes.length;k++){ if(el.childNodes[k].nodeType===3) own+=el.childNodes[k].textContent; }+      if(own.toLowerCase().indexOf(txt)!==-1){ var r=el.getBoundingClientRect(); if(r.width>0&&r.height>0){ best=el; return true; } }+      if(el.shadowRoot&&walk(el.shadowRoot)) return true; } return false; };+    walk(root); return best; };+  // nearest scrollable ancestor of el (crosses shadow boundaries via host)+  window.__pupScrollableAncestor = function(el){ var n=el;+    while(n && n!==document.documentElement){ try{ var s=getComputedStyle(n), oy=s.overflowY;+      if((oy==='auto'||oy==='scroll'||oy==='overlay') && n.scrollHeight>n.clientHeight+1) return n; }catch(e){}+      n = n.parentElement || (n.getRootNode&&n.getRootNode().host) || null; }+    return document.scrollingElement||document.documentElement; };+  // the page's MAIN scroller: the document if it scrolls, else the biggest scrollable container+  window.__pupMainScroller = function(){ var doc=document.scrollingElement||document.documentElement;+    if(doc.scrollHeight>doc.clientHeight+1) return doc;+    var best=doc, area=0, all=window.$$deep('*');+    for(var i=0;i<all.length;i++){ var el=all[i]; try{ var s=getComputedStyle(el);+      if((s.overflowY==='auto'||s.overflowY==='scroll'||s.overflowY==='overlay') && el.scrollHeight>el.clientHeight+1){+        var a=(el.scrollHeight-el.clientHeight)*el.clientWidth; if(a>area){ area=a; best=el; } } }catch(e){} }+    return best; };+  window.__pupDesc = function(el){ if(!el) return null;+    var id=el.id?('#'+el.id):''; var cn=(el.className&&el.className.toString)?el.className.toString().trim():'';+    var cls=cn?('.'+cn.split(/\\s+/).slice(0,2).join('.')):''; return (el.tagName||'').toLowerCase()+id+cls; };+})();`;++async function ensureDeepHelpers(page) { try { await page.evaluate(PUP_DEEP_HELPERS); } catch (e) {} }++// Persistent Chrome profiles — each session gets its own userDataDir so IndexedDB, cookies,+// localStorage survive across restarts. v1.8.98: these live OUTSIDE the bridge dir, in the+// stable ~/.adom zone. __dirname is bridges-cache\puppeteer, which AD CLOBBERS on every+// bridge_install — storing profiles there wiped every persistent login (incl. the wiki-auth+// session) on every pup update. A stable home makes "log in once" actually last (across updates,+// restarts, reboots). One-time migration: pull an existing in-cache profiles/ dir over if present.+const ADOM_STATE_DIR = path.join(os.homedir(), '.adom');+const PROFILES_DIR = path.join(ADOM_STATE_DIR, 'pup-profiles');+// v2.0.150: DETERMINISTIC Adom extension IDs. Each manifest carries a fixed `key`, so its ID is+// stable + known (not the fragile path-hash of an unpacked load). This lets us write the toolbar+// pin ORDER directly into Preferences (extensions.pinned_extensions), which is what actually+// controls the left→right icon order. Order the array [pup, annotate, identity] to place the+// profile/identity tile rightmost, next to Chrome's own profile avatar. KEEP IN LOCKSTEP with the+// `key` fields in src/extension*/manifest.json — regenerating a key changes the id here too.+const ADOM_EXT_IDS = {+  pup: 'liomcaladmooagjiebkfgegciadapnpi',+  annotate: 'ghadnmahlldiigemdbekmhcbfcpkmclg',+  identity: 'mngfopllhdfgomjphhejmcdimcohcgnf',+  identityAnon: 'mhoenaodccbppcinfbaifamkgfhbakbe',+};+const SESSIONS_DIR = path.join(ADOM_STATE_DIR, 'pup-sessions');+fs.mkdirSync(PROFILES_DIR, { recursive: true });+fs.mkdirSync(SESSIONS_DIR, { recursive: true });+// One-time migration from the old clobber-prone in-bridge locations (best-effort).+try {+  for (const [oldRel, dest] of [['profiles', PROFILES_DIR], ['sessions', SESSIONS_DIR]]) {+    const oldDir = path.join(__dirname, oldRel);+    if (fs.existsSync(oldDir) && fs.readdirSync(dest).length === 0) {+      for (const entry of fs.readdirSync(oldDir)) {+        try { fs.renameSync(path.join(oldDir, entry), path.join(dest, entry)); } catch (e) {}+      }+      console.log(`[migrate] moved ${oldRel}/ from the bridge cache to ${dest}`);+    }+  }+} catch (e) {}++// Multi-session state: sessions are tabs within shared Chrome browsers+// One Chrome process per profile (userDataDir), multiple tabs per Chrome.+// v2.0.256 (John lost THREE windows to this): the ONE primitive allowed to kill a profile's Chrome.+// #434 established the rule — decide from GROUND TRUTH, never from the cached refCount — but only+// converted ONE of the four call sites. The other three still ran `if (--refCount <= 0) kill`, and+// that counter is provably wrong right after a RESTART: recovery re-creates the browser entry with+// `refCount: 0` and does not count the sessions it just restored. So on AdomLapper, three recovered+// windows (fusion-dashboard, kicad-maint, and the ab web-demo window John was using) sat on the+// shared adom-you Chrome with refCount 0, and the next path to run decremented 0 -> 0 and+// force-killed the process hosting all three. pup then relaunched them and reconciled them away as+// "closed", so all three of the user's windows vanished.+// A refcount is telemetry. The session map is truth. Every kill goes through here.+async function killProfileBrowserIfUnused(profileName, why) {+  const be = browsers.get(profileName);+  if (!be) return { killed: 0, remaining: 0 };+  be.refCount = Math.max(0, be.refCount - 1);   // kept honest for telemetry only — NEVER authorizes a kill+  // Any OTHER session still bound to this profile? _lostBrowser entries count on purpose: rescan may+  // re-adopt that still-open window, so a disconnected record must still veto the kill.+  let others = 0;+  for (const s of sessions.values()) { if (s.profileName === profileName) others++; }+  let busy = 0;+  for (const [sid, st] of _busyState) {+    if (st && st.busy) { const bs = sessions.get(sid); if (bs && bs.profileName === profileName) busy++; }+  }+  if (others || busy) {+    console.log(`Keeping browser for profile "${profileName}" — ${others} other session(s)${busy ? ` and ${busy} verb(s) in flight` : ''} still using it (refCount=${be.refCount}); NOT killing (${why})`);+    return { killed: 0, remaining: others };+  }+  console.log(`No more sessions using profile "${profileName}" — killing browser (${why})`);+  let r = { killed: 0, remaining: 0 };+  try { r = await killBrowserProcess(be.browser, profileName); } catch (e) {}+  browsers.delete(profileName);+  return r;+}+// browsers:  profileName → { browser, refCount }+// sessions:  sessionId → Session (see createSession below)+//+// Session shape (new — supports multiple tabs per session):+//   {+//     tabs:        [{ tabId, page, errors }]  // all open tabs for this session+//     activeTabId: string                      // currently "foregrounded" tab+//     latestShot:  string | null               // last screenshot path+//     profileName: string                      // Chrome profile+//     tabCounter:  number                      // monotonic per-session id counter+//     // Backward-compat virtual properties (getters):+//     page:   active tab's page   (for legacy callers that read session.page)+//     errors: active tab's errors+//   }+let browsers = new Map();+let sessions = new Map();+let activeSessionId = null;+// v1.9.146 (John: 'different ai threads opening pup window tabs into each other's windows ... make+// sure their windows only open in tabs by default in the pup window they own'). activeSessionId is+// a SINGLE GLOBAL overwritten on every open by ANY thread, so pup_open_tab with no sessionId+// dumped the tab into whatever window was last touched — cross-thread contamination. This resolves+// the CALLER THREAD'S OWN window instead: the most recently created live session this thread owns.+function callerOwnWindow(callerThread) {+  if (!callerThread) return null;+  let best = null;+  for (const [sid, s] of sessions) {+    if (s && s._ownerThread === callerThread && isSessionAlive(s)) {+      if (!best || (s.createdAt || 0) >= (sessions.get(best).createdAt || 0)) best = sid;+    }+  }+  return best;+}+// v2.0.103 (John, watching the ah-build thread hwnd-hunt its own window): every open/navigate+// response teaches the caller how to KEEP driving its window with zero ids — the follow-ups an+// AI actually needs next, stated as ready-to-run calls.+function threadNextBlock() {+  return [+    'pup_screenshot {} — screenshot YOUR window (no ids: pup resolves your --ai-thread name to your window)',+    'pup_reload {} — reload it (e.g. after you publish or rebuild something it shows)',+    'pup_my_windows {} — full state of everything your thread owns: windows, tabs, urls, titles',+    'pup_open_window {"url":"..."} — another page = another TAB in your same window',+    'pup_raise_os_window {"foregroundReason":"..."} — bring it onto the user\'s screen (gated)',+    'Windows open MAXIMIZED already. NEVER hunt hwnds (desktop_find_window / desktop_set_window_state) for a pup window — pup verbs already know your window.',+    'pup_screenshot {} captures the PAGE (CDP, pixel-perfect, works in background — the shot you want 99% of the time). Only when you need the browser CHROME in frame (tab strip, toolbar icons): pup_window_info {} hands you this window\'s hwnd for desktop_screenshot_window — no searching, ever.',+  ];+}+// v2.0.110 (John authorized: pup runs its OWN login store so the AI manages credentials without+// Chrome's foreground picker). This rail records a login as it is submitted: on submit of a form+// holding a filled secret field, it stashes {host, username, secret} on window.__pupCapturedLogin.+// The 10s sweep harvests it into the OS-keychain vault (never the index, never a log) and hints+// the caller. Read-and-cleared atomically by the sweep to minimize how long it sits in the page.+const CAPTURE_LOGIN_JS = `(() => {+  if (window !== window.top) return;+  if (window.__pupCapV === '${BRIDGE_VERSION}') return;+  window.__pupCapV = '${BRIDGE_VERSION}';+  function vis(el){ return !!el && !el.disabled && el.offsetParent !== null && el.getClientRects().length > 0; }+  // v2.0.123: a password field is not always type=password — Gusto and many SSO/Keycloak themes use+  // type=text with autocomplete="current-password" (or name/id "password") and mask it themselves.+  function isPwd(el){ return el && el.tagName === 'INPUT' && (el.type === 'password' || el.autocomplete === 'current-password' || el.autocomplete === 'new-password' || /(^|[^a-z])password([^a-z]|$)/i.test(el.name || '') || /(^|[^a-z])password([^a-z]|$)/i.test(el.id || '')); }+  function grab(form){+    try {+      var pwd = [].slice.call((form || document).querySelectorAll('input')).filter(vis).filter(isPwd)[0];+      if (!pwd || !pwd.value) return;+      var all = [].slice.call((form || document).querySelectorAll('input'));+      var pi = all.indexOf(pwd);+      var user = [].slice.call((form || document).querySelectorAll('input[autocomplete=username], input[type=email]')).filter(vis).filter(function(e){ return !isPwd(e); })[0];+      if (!user) {+        var cands = [].slice.call((form || document).querySelectorAll('input[type=text], input[type=tel], input:not([type])')).filter(vis).filter(function(e){ return !isPwd(e); });+        user = cands.slice().reverse().filter(function(c){ return all.indexOf(c) < pi; })[0] || cands[0] || null;+      }+      var rec = { host: location.hostname, u: user ? user.value : '', p: pwd.value, ts: Date.now() };+      // Prefer the SYNCHRONOUS binding — it reaches the bridge BEFORE the submit navigates the+      // page away (the global stash below would be wiped by that navigation, missing the capture).+      if (typeof window.__pupCredCapture === 'function') { try { window.__pupCredCapture(JSON.stringify(rec)); } catch (e) {} }+      window.__pupCapturedLogin = rec;   // fallback for SPA logins that never navigate (sweep harvests it)+    } catch (e) {}+  }+  function formHasPwd(f){ try { return !!f && [].slice.call(f.querySelectorAll('input')).some(isPwd); } catch (e) { return false; } }+  addEventListener('submit', function(e){ grab(e.target && e.target.tagName === 'FORM' ? e.target : null); }, true);+  addEventListener('click', function(e){+    try {+      var t = e.target && e.target.closest && e.target.closest('button, input[type=submit], [role=button], a');+      if (!t) return;+      var f = t.form || (t.closest && t.closest('form'));+      // v2.0.128 (consumer bug report #4): was a literal input[type=password] check, so a masked+      // type=text password field (Rails/Keycloak) never matched and capture missed on click. Use the+      // isPwd matcher; if there is no enclosing form, sweep the document (SPA login with no <form>).+      if (formHasPwd(f)) grab(f); else if (!f) grab(null);+    } catch (e2) {}+  }, true);+  // v2.0.121 (John, Gusto miss): ALSO capture the instant the password field loses focus or+  // changes — this fires BEFORE the form submits and, crucially, before a cross-origin SSO redirect+  // (login.gusto.com -> app.gusto.com) tears the page down. Submit-only capture raced that redirect+  // and lost; grabbing on blur/change delivers the creds through the synchronous binding first.+  addEventListener('change', function(e){ try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);+  addEventListener('blur', function(e){ try { if (isPwd(e.target) && e.target.value) grab(e.target.form || null); } catch (e2) {} }, true);+  // v2.0.128 (consumer bug report #4): a STANDARD Rails login often submits PROGRAMMATICALLY —+  // form.submit() (and requestSubmit's older cousins) does NOT dispatch the 'submit' event, so+  // submit/click capture silently missed it. Hook the prototype so a programmatic submit still grabs.+  try {+    var _origSubmit = HTMLFormElement.prototype.submit;+    HTMLFormElement.prototype.submit = function(){ try { if (formHasPwd(this)) grab(this); } catch (e) {} return _origSubmit.apply(this, arguments); };+  } catch (e) {}+  // Last-resort net: on Enter inside a login form, and on pagehide before ANY navigation tears the+  // page down, grab whatever password field is currently filled. Cheap, synchronous, dedup is+  // downstream (same host+user+pwd within the session is coalesced).+  addEventListener('keydown', function(e){ try { if (e.key === 'Enter' && isPwd(document.activeElement) && document.activeElement.value) grab(document.activeElement.form || null); } catch (e2) {} }, true);+  addEventListener('pagehide', function(){ try { grab(null); } catch (e2) {} }, true);+})()`;+let pupshotUrl = null;+// v1.9.4: profileName -> in-flight launch Promise. A fresh profile isn't in `browsers` until its+// Chrome launch FINISHES (~10-15s), so two near-simultaneous opens for the same profile used to+// each spawn their OWN detached Chrome on the SAME userDataDir; they then collided on Chrome's+// SingletonLock and mutually taskkilled each other's process, so NONE came up and the bridge+// looked wedged for minutes. This map lets concurrent callers JOIN the first launch instead of+// racing a second Chrome onto the same dir. (Root cause of the 2026-07-19 cold-start wedge.)+const _launchInFlight = new Map();+// v1.9.143 (John's 3-phase test, attempt 3) — SERIALIZE OPENS PER PROFILE. Ten PARALLEL opens on+// the shared profile interleaved page-creation with sibling cleanup: 3 died 'Navigating frame was+// detached' even with the _pupCreated guard (the marker lands after creation returns; targets are+// visible mid-creation), and worse, the sibling closes drove windows to zero tabs — Chrome EXITS+// when its last window closes, which took ALL TEN sessions down ('Session not found' across the+// board by phase 3). Opens on DIFFERENT profiles stay fully parallel; opens on the SAME profile+// run in arrival order, each completing create+cleanup before the next starts. Correctness first:+// a fleet of AI threads opening default windows simultaneously is the 10,000-user case.+const _profileOpenChain = new Map(); // profileName -> tail promise+let _tabClaimSeq = 0; // monotonic per-call marker for in-window tab adoption (see addTabToSession)+let _birthSeq = 0;+// v2.0.259 (John's placement rules, R1): a window is CREATED at its final on-screen rect, so we need+// the primary monitor's work area BEFORE any page exists to ask (screen.availWidth comes from a page,+// which is the chicken-and-egg that pushed the old design into staging windows off-screen and moving+// them later). SystemParametersInfo(SPI_GETWORKAREA) answers without a window. Cached — monitors do+// not change often, and a stale value only costs a slightly-off rect that the park corrects.+let _workAreaCache = null;   // { x, y, w, h, at }+async function primaryWorkArea() {+  if (_workAreaCache && Date.now() - _workAreaCache.at < 5 * 60000) return _workAreaCache;+  if (process.platform !== 'win32') return null;+  const ps = `$s=@'+using System; using System.Runtime.InteropServices;+public static class WA {+  [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }+  [DllImport("user32.dll")] public static extern bool SystemParametersInfo(uint a, uint b, ref RECT r, uint c);+}+'@; Add-Type -TypeDefinition $s+$r = New-Object WA+RECT+if([WA]::SystemParametersInfo(0x0030, 0, [ref]$r, 0)) { "{0},{1},{2},{3}" -f $r.L,$r.T,($r.R-$r.L),($r.B-$r.T) } else { "ERR" }`;+  return await new Promise((resolve) => {+    try {+      execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 10000 }, (err, stdout) => {+        const m = /^(-?\d+),(-?\d+),(\d+),(\d+)$/.exec(String(stdout || '').trim());+        if (!m) { console.log(`[workarea] could not read the primary work area (${err ? err.message.slice(0, 60) : String(stdout || '').trim().slice(0, 40)}) — birth will fall back to a conservative on-screen box`); return resolve(null); }+        const w = Number(m[3]), h = Number(m[4]);+        if (!(w > 400 && h > 300)) return resolve(null);+        _workAreaCache = { x: Number(m[1]), y: Number(m[2]), w, h, at: Date.now() };+        console.log(`[workarea] primary work area ${w}x${h} at ${m[1]},${m[2]}`);+        resolve(_workAreaCache);+      });+    } catch (e) { resolve(null); }+  });+}+// R1: the birth rect — the primary work area inset 10px on ALL FOUR sides. The inset is deliberate+// and visible: it is how you can tell at a glance that a window was PLACED by pup rather than landing+// somewhere by accident. Falls back to a conservative on-screen box, never off-screen.+async function birthRect() {+  const wa = await primaryWorkArea();+  if (wa) return { left: wa.x + 10, top: wa.y + 10, width: Math.max(400, wa.w - 20), height: Math.max(300, wa.h - 20) };+  return { left: 10, top: 10, width: 1280, height: 800 };+}+// v2.0.38: SERIALIZE window creation so the set-diff birth capture always sees EXACTLY ONE new+// window handle (position is unreliable — Chrome clamps our -32000 birth coord on-screen, so we+// cannot identify the window by where it is; but "the one handle that appeared while I held the+// lock" is unambiguous regardless of position or concurrency across threads).+let _birthLock = Promise.resolve();+function withBirthLock(fn) {+  const prev = _birthLock;+  let release;+  _birthLock = new Promise(r => { release = r; });+  return prev.then(fn).finally(() => release());+}    // monotonic counter for unique off-screen birth positions (birth-time hwnd resolution)++// ============================================================================+// DETERMINISTIC WINDOW IDENTITY — the Win32 window-property tag (dev-skills/pup-no-wiggle)+// ----------------------------------------------------------------------------+// A pup window is identified by a Win32 WINDOW PROPERTY carrying its sessionId, NOT by geometry.+// SetProp attaches "AdomPupSid=<sessionId>" to the OS window; the property is stored WITH the window+// (owned by Chrome), so it survives a page changing its title, a tab crash, and pup itself+// restarting. VERIFIED 2026-08-22: process A set a prop then exited; process B (separate process)+// read it back intact. To re-find a window we READ this property (a lookup), we NEVER guess by+// size/position and we NEVER nudge a window to see which one moves (the banned "wiggle").+const PUP_WIN_PROP = 'AdomPupSid';+function _runPsEncoded(ps, timeout = 8000) {+  return new Promise((resolve) => {+    if (process.platform !== 'win32') return resolve(null);+    try {+      const enc = Buffer.from(ps, 'utf16le').toString('base64');+      execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', enc],+        { windowsHide: true, timeout }, (err, stdout) => resolve(err ? null : String(stdout || '').trim()));+    } catch (e) { resolve(null); }+  });+}+function _sanSid(sessionId) { return String(sessionId == null ? '' : sessionId).replace(/[\r\n"|]/g, ''); }+// Tag an OS window with its owning sessionId. Idempotent. Called whenever pup confirms a (sessionId,+// hwnd) pair (at birth and on every successful resolve), so every window pup knows carries its tag+// and stays re-findable after any handle loss.+async function tagWindowSession(hwnd, sessionId) {+  if (process.platform !== 'win32' || !hwnd || !sessionId) return false;+  const sid = _sanSid(sessionId);+  // v2.0.310: direct user32 SetProp through koffi; PowerShell below is only the no-koffi fallback.+  const _fast = win32.setProp(hwnd, `${PUP_WIN_PROP}=${sid}`);+  if (_fast !== null) return !!_fast;+  const ps = `Add-Type @"+using System;using System.Runtime.InteropServices;+public class PupTag { [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern bool SetProp(IntPtr h,string s,IntPtr v); }+"@+[void][PupTag]::SetProp([IntPtr]${Number(hwnd)}, "${PUP_WIN_PROP}=${sid}", [IntPtr]1)+Write-Output ok`;+  const r = await _runPsEncoded(ps, 6000);+  return r === 'ok';+}+// Re-find a session's OS window by its property tag — the deterministic recovery path (crash /+// respawn / bridge restart). Uses the reliable ab enumeration to list this profile's Chrome windows,+// then a plain GetProp probe per handle (NO EnumWindows delegate, NO geometry, NO nudge). Returns+// the hwnd whose "AdomPupSid=<sessionId>" property is set, or null. Exactly-one by construction: the+// property name encodes THIS sessionId, so only the window tagged for this session can match.+async function resolveHwndBySessionProp(sessionId) {+  if (process.platform !== 'win32' || !sessionId) return null;+  const sid = _sanSid(sessionId);+  // v2.0.310: one in-process EnumWindows + GetProp sweep (microseconds, no spawn, no ab call).+  const _fastH = win32.findWindowByProp(`${PUP_WIN_PROP}=${sid}`);+  if (_fastH !== null) return _fastH > 0 ? _fastH : null;+  // v2.0.308: the candidate list used to come from a FRESH ab enum (more enum spam). The property is+  // the identity, so enumerate locally and read it in the same script: one PowerShell, zero ab calls.+  const ps = `Add-Type @"+using System;using System.Runtime.InteropServices;using System.Text;+public class PupGP {+  public delegate bool EnumProc(IntPtr h, IntPtr l);+  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);+  [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern IntPtr GetProp(IntPtr h,string s);+  [DllImport("user32.dll",CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n);+  public static long Find(string prop) {+    long found = 0;+    EnumWindows((h, l) => {+      var sb = new StringBuilder(64); GetClassName(h, sb, 64);+      if (sb.ToString() == "Chrome_WidgetWin_1" && GetProp(h, prop) != IntPtr.Zero) { found = h.ToInt64(); return false; }+      return true;+    }, IntPtr.Zero);+    return found;+  }+}+"@+Write-Output ([PupGP]::Find("${PUP_WIN_PROP}=${sid}"))`;+  const out = await _runPsEncoded(ps, 6000);+  const h = Number(String(out || '').trim().split(/\r?\n/)[0] || '0');+  return h && Number.isFinite(h) && h !== 0 ? h : null;+}+let _regenBusy = false;   // a dashboard "regenerate all overlays" sweep is in flight+let _lastRegen = null;    // { at, done, swept, painted, changed, results[] } from the last sweep+const _overlayRefreshTimers = new Map(); // per-session debounce for the centralized post-verb overlay re-sync+// v1.9.110 (issue #22, AdityaAngajala) — ADMISSION-TIME OWNERSHIP CLAIM.+// Two defects he measured: (a) `owner` is optional, so in practice sessions end up unowned and the guard+// that protects a busy window is opt-out by omission; (b) ownership was established when the BROWSER was+// acquired, not when the request was admitted, so two agents cold-starting the same sessionId were BOTH+// told to proceed and whichever launch landed first silently became the owner while the other believed it+// owned that window too. This map is claimed SYNCHRONOUSLY on the open path before any await, so the+// second differently-owned caller is refused immediately instead of receiving a provisioning hint that+// implies "retry and it's yours". Cleared on close. Ownership is still self-reported attribution, never+// authorization — it exists to stop threads trampling each other, not to secure anything.+const _sessionOwnerClaims = new Map();   // sessionId -> owner string+// v1.9.119 (John's design) — CLEANUP REPORT QUEUE. pup should tidy up on its own AND tell somebody it+// did. Silent janitorial work is invisible: John found four zombie sessions cluttering every thread's+// pup_list_windows and had to ask about them. So each cleanup is recorded here and attached to the+// NEXT verb response as `_cleanupReport`, then DROPPED (report-once). The thread that receives it is+// whichever one happens to call next, which is usually NOT the thread whose window was cleaned — that is+// accepted by design: it may be irrelevant to that caller and it can ignore it, but it reaches the user+// eventually, which beats never surfacing at all. Capped so a storm cannot bloat a response.+// v2.0.359 (issue #80). A PER-SESSION notice, because the janitor queue above is a BROADCAST and+// this case needs the opposite. When the bridge is upgraded or restarted, pup re-adopts the windows+// underneath every live session, and tabs can end up regrouped into a different session than the+// one that opened them. The thread that opened them sees tabs vanish with no close call and no+// error: it looks like pup lost their work. That is what happened to the "fort worth" thread while+// I was installing three builds in a row, and their report (#80) correctly said the fix is not to+// stop restarting but to TELL the affected session.+//+// Broadcast is wrong here: drainCleanupReports hands the message to whichever thread calls NEXT,+// which is usually not the thread that was affected. This attaches the notice to the session it+// happened to, and clears it once that session's own next verb carries it.+function noteSessionDisruption(session, msg) {+  try { if (session && msg) session._disruptionNotice = String(msg).slice(0, 300); } catch (e) {}+}+function drainSessionNotice(session) {+  try {+    if (!session || !session._disruptionNotice) return null;+    const m = session._disruptionNotice;+    session._disruptionNotice = null;+    return m;+  } catch (e) { return null; }+}+// Verbs whose ANSWER is a measurement or a picture, and is therefore silently wrong when a viewport+// override is in force. These carry the override warning; the rest do not need the noise.+const VIEWPORT_SENSITIVE = new Set([+  'pup_screenshot', 'pup_screenshot_full_res', 'pup_eval', 'pup_get_text',+  'pup_list_windows', 'pup_my_windows', 'pup_status', 'pup_open_window', 'pup_navigate', 'pup_reload',+]);+const _cleanupReports = [];+const CLEANUP_REPORT_MAX = 12;+function recordCleanup(msg) {+  try {+    if (!msg) return;+    _cleanupReports.push(String(msg).slice(0, 240));+    while (_cleanupReports.length > CLEANUP_REPORT_MAX) _cleanupReports.shift();+    console.log(`[janitor] ${msg}`);+  } catch (e) {}+}+function drainCleanupReports() {+  if (!_cleanupReports.length) return null;+  const items = _cleanupReports.splice(0, _cleanupReports.length);+  return `pup CLEANED UP after itself since your last call (reported once, then forgotten — it may not `+    + `concern YOUR windows, in which case ignore it, but please surface it to the user if it looks `+    + `relevant): ${items.map((m, i) => `(${i + 1}) ${m}`).join('  ')}`;+}+++// ── Tab helpers ────────────────────────────────────────────────────+function createSession(profileName) {+  const session = {+    tabs: [],+    activeTabId: null,+    latestShot: null,+    profileName,+    tabCounter: 0,+    // v1.8.16: advisory ownership. Multiple AI threads share this bridge and+    // sessions are keyed only by sessionId — so one thread could silently+    // navigate away ("steal") a window another thread was using. `owner` is a+    // caller-declared label (pup_open_window {owner:"my-task"}); it can't+    // be authenticated, but it lets us REFUSE declared cross-owner takeovers+    // and reprimand anonymous ones with a loud hint.+    owner: null,+    createdAt: Date.now(),+    // v1.8.26: is this window CURRENTLY meant to be on the user's screen? false =+    // background (the hard default) — pup must never raise it (no bringToFront on+    // navigate/reload/switch_tab). Set true only when opened foreground:true or after+    // pup_raise_os_window, false again after pup_lower_os_window.+    _foreground: false,+    // Ring buffer of recently-closed tabs (capped at 20). Used by+    // resolveTabOrError to enrich tab_not_found errors with `lastKnownUrl`+    // when the bogus tabId matches a tab that closed in the last few seconds.+    // The common case: a popup PDF tab auto-closes after the viewer renders,+    // and the caller tries to eval/fetch against the stale tabId. We can now+    // surface the URL the popup was at so they can pup_fetch_url it+    // directly without having to re-trigger the click.+    recentlyClosedTabs: [],+  };+  // Legacy-compat getters: existing code that reads session.page / session.errors+  // transparently sees the active tab's page / errors.+  Object.defineProperty(session, 'page', {+    enumerable: false,+    get() { const t = getActiveTab(session); return t ? t.page : null; },+  });+  Object.defineProperty(session, 'errors', {+    enumerable: false,+    get() { const t = getActiveTab(session); return t ? t.errors : []; },+  });+  return session;+}++function nextTabId(session) {+  session.tabCounter = (session.tabCounter || 0) + 1;+  return `tab-${session.tabCounter}`;+}++function getActiveTab(session) {+  if (!session || !session.tabs || session.tabs.length === 0) return null;+  if (session.activeTabId) {+    const t = session.tabs.find(t => t.tabId === session.activeTabId);+    if (t) return t;+  }+  return session.tabs[0];+}++function getTabById(session, tabId) {+  if (!session || !tabId) return null;+  return session.tabs.find(t => t.tabId === tabId) || null;+}++// Attach page → tab to a session, wire error collection + console forwarding.+// Called by both launchSession (opening new window) and pup_open_tab.+//+// `opener` documents how the tab came to exist:+//   - "user" (default): Claude requested it via pup_open_window/pup_open_tab+//   - "popup":          Chrome opened it via window.open() / target=_blank — auto-tracked+//                       by the targetcreated listener wired in wirePopupTracking().+//                       openerTabId names the tab that triggered the popup.+// v1.9.48: debounced category re-check, fired by a tab's framenavigated. Debounced because a single+// user navigation emits several frame events (redirects, client-side routes) and each one would+// otherwise cost an AUMID re-register + re-stamp. Re-stamping is only done when the category ACTUALLY+// changed, so a settled window costs one cheap comparison.+const _catRecheckTimers = new Map();+function scheduleCategoryRecheck(sessionId) {+  if (process.platform !== 'win32') return;+  // v1.9.185 minimal-touch: no AUMID re-register / taskbar re-stamp on navigation. Every category+  // change re-registered an AppUserModelID and re-stamped the window — i.e. Windows destroyed and+  // rebuilt that taskbar button — on a SPA that route-changes constantly.+  if (minimalTouch()) return;+  clearTimeout(_catRecheckTimers.get(sessionId));+  _catRecheckTimers.set(sessionId, setTimeout(async () => {+    _catRecheckTimers.delete(sessionId);+    try {+      const s = sessions.get(sessionId);+      if (!s) return;+      const cat = pupCategory(sessionId);+      if (!cat || cat === s._lastCat) return;+      console.log(`[identity] "${sessionId}" category changed ${s._lastCat || 'none'} -> ${cat} (after navigation) — re-stamping`);+      s._lastCat = cat;+      updateThumbnailTooltip(s, sessionId).catch(() => {});+      for (let i = 0; i < 4; i++) {+        let ok = false;+        try { ok = await brandPupWindow(sessionId); } catch (e) {}+        if (ok) { s._badgedAt = Date.now(); return; }+        await new Promise(r => setTimeout(r, 900 * (i + 1)));+      }+      // Do NOT leave _lastCat claiming success: the stamp never landed, so the cached value would+      // suppress every future attempt and freeze the wrong icon permanently.+      s._lastCat = undefined;+      console.log(`[identity] "${sessionId}" post-navigation re-stamp exhausted retries (wanted ${cat}) — category cache cleared so a later trigger retries`);+    } catch (e) {}+  }, 1500));+}++// Make a pup-driven tab IMMUNE to debugger pauses. A page's `debugger;` statement (or a stray+// breakpoint) only pauses when the Debugger domain is enabled — but when it IS enabled (by a stale+// CDP client, a previously-opened DevTools, whatever), a single `debugger;` FREEZES the renderer,+// and because sibling tabs share the process the freeze locks the WHOLE window. John hit exactly+// this: the "Debugger paused in another tab" banner appeared and the wiki 3D viewer stopped+// responding. pup drives via CDP for AUTOMATION, never to debug page code, so it must never honor a+// pause. We enable Debugger, tell V8 to skip ALL pauses, and resume anything already frozen — then+// keep the session attached so the skip stays in force for this tab's whole life.+async function neutralizeDebugger(page) {+  try {+    const cdp = await page.target().createCDPSession();+    await cdp.send('Debugger.enable').catch(() => {});+    await cdp.send('Debugger.setSkipAllPauses', { skip: true }).catch(() => {});+    await cdp.send('Debugger.resume').catch(() => {}); // clears any pause inherited at attach time+    // v1.9.347 (John: "a heavy page should NEVER crash your bridge; you're mishandling the CDP interface").+    // The 1.9.341 version KEPT the Debugger domain enabled for the tab's whole life. That makes Chrome+    // stream a Debugger.scriptParsed event for EVERY script the page ever parses — on a heavy bundle (a+    // Babylon.js 3D viewer is thousands of scripts) that is a flood of CDP messages processed on the+    // bridge's single event loop, plus V8 runs de-optimized while Debugger is on. Under concurrent+    // heavy-page opens it starved the loop so AD could not reach the bridge ("error sending request").+    // FIX: DISABLE Debugger right after clearing any inherited pause. With no client holding the domain,+    // a page's `debugger;` is a no-op anyway — so we keep the freeze immunity WITHOUT the scriptParsed+    // flood or the de-opt, and we detach the session instead of leaking one per tab.+    await cdp.send('Debugger.disable').catch(() => {});+    await cdp.detach().catch(() => {});+  } catch (e) {}+}++// v2.0.141 (John: "surf as normal consumer chrome — that's our right, this really is me surfing+// from a normal chrome, we just use CfT so we can automate it"). Chrome for Testing's UA STRING is+// already indistinguishable from consumer Chrome, but its CLIENT-HINTS brands are not: CfT reports+// only [Not/A)Brand, Chromium] while consumer Google Chrome includes a "Google Chrome" brand.+// Cloudflare/Akamai read exactly that Sec-CH-UA brand list and wall the missing-Google-Chrome case+// as a non-consumer browser (measured live: TI/ST/ams load, but DigiKey/Mouser/Arrow/SnapEDA/CSE/+// Octopart all served "Just a moment…"). This override makes every page present as Google Chrome —+// UA header AND the userAgentData brands, kept CONSISTENT (a UA/brand mismatch is itself a bot+// tell) — via CDP Network.setUserAgentOverride (what page.setUserAgent(ua, meta) drives). The major+// version is read from the live UA so a CfT bump never drifts it. This is not spoofing a different+// device; it is the same Windows Chrome the user runs, correctly declaring itself as Google Chrome.+let _uaOverride = null;   // { ua, metadata } — computed once from the running browser+async function computeConsumerUA(page) {+  if (_uaOverride) return _uaOverride;+  let ua = '';+  try { ua = await page.browser().userAgent(); } catch (e) {}+  if (!ua) { try { ua = await page.evaluate(() => navigator.userAgent); } catch (e) {} }+  if (!ua) return null;+  const m = ua.match(/Chrome\/(\d+)(?:\.\d+)*/);+  const major = m ? m[1] : '148';+  const full = (ua.match(/Chrome\/([\d.]+)/) || [])[1] || (major + '.0.0.0');+  // Consumer Chrome's brand triplet (GREASE brand + Chromium + Google Chrome). Order is not+  // load-bearing; the PRESENCE of "Google Chrome" is what CF checks.+  const brands = [+    { brand: 'Not/A)Brand', version: '99' },+    { brand: 'Google Chrome', version: major },+    { brand: 'Chromium', version: major },+  ];+  _uaOverride = {+    ua,+    metadata: {+      brands,+      fullVersionList: [+        { brand: 'Not/A)Brand', version: '99.0.0.0' },+        { brand: 'Google Chrome', version: full },+        { brand: 'Chromium', version: full },+      ],+      fullVersion: full,+      platform: 'Windows', platformVersion: '15.0.0',+      architecture: 'x86', bitness: '64', model: '', mobile: false,+      wow64: false,+    },+  };+  return _uaOverride;+}+async function applyConsumerUA(page) {+  try {+    const o = await computeConsumerUA(page);+    if (!o) return;+    // page.setUserAgent(ua, metadata) issues Network.setUserAgentOverride with userAgentMetadata,+    // which drives BOTH the UA request header and navigator.userAgentData / Sec-CH-UA consistently.+    await page.setUserAgent(o.ua, o.metadata);+  } catch (e) { /* best-effort — a page that refuses the override just keeps CfT's brands */ }+}+// v2.0.143 (John's "the import never worked" — traced): AD RENAMED the SYSTEM-elevated decrypt+// verb from desktop_decrypt_pup_credentials to desktop_decrypt_browser_credentials, but pup still+// called the OLD name, so every import died with unknown_verb (which the dashboard swallowed —+// exactly why it looked like it never worked). Call the CURRENT name first; fall back to the old+// one on unknown_verb so pup keeps working on an older AD. Both are LOCAL-only (relay_forbidden);+// pup calls over the loopback direct API so it is allowed, and AD fires its own consent + UAC.+async function decryptBrowserCreds(argsObj) {+  const names = ['desktop_decrypt_browser_credentials', 'desktop_decrypt_pup_credentials'];+  let last = null;+  for (const n of names) {+    const resp = await chrome.abCommand(n, argsObj, { timeoutMs: 180000 });+    const p = abPayload(resp) || {};+    const ec = p.errorCode || (resp && resp.errorCode);+    if (ec === 'unknown_verb') { last = resp; continue; }   // wrong name on this AD — try the next+    return resp;+  }+  return last;+}+// ANY pending dialog, anywhere (v2.0.353). John, watching it happen: "it stops my other pup windows+// from even foregrounding so i get stuck on all pup windows cuz of one lame javascript alert dialog+// box." He is right, and it changes the shape of this: pup's windows share ONE Chrome for the+// adom-you profile, so a modal does not block its own window, it blocks the BROWSER. The window+// holding the dialog is therefore usually NOT the window whose verb just failed, and scanning only+// the caller's session finds nothing while every window is stuck.+// ── v2.0.383 (#59/#64): MEASURED progress for slow verbs ─────────────────────────────────+// Rolling per-machine timing history (cold = a browser process was spawned; warm = a window was+// added to a live browser), so estimatedSec is a real median that narrows as samples arrive, and+// confidence is 'measured' only once there are enough of them. Exposed in pup_describe.+const TIMINGS_PATH = path.join(os.homedir(), '.adom', 'pup-timings.json');+function _readTimings() { try { return JSON.parse(fs.readFileSync(TIMINGS_PATH, 'utf8')); } catch (e) { return {}; } }+function pupTimingRecord(kind, ms) {+  try {+    const t = _readTimings(); const arr = Array.isArray(t[kind]) ? t[kind] : [];+    arr.push(Math.round(ms)); while (arr.length > 20) arr.shift(); t[kind] = arr;+    fs.mkdirSync(path.dirname(TIMINGS_PATH), { recursive: true }); fs.writeFileSync(TIMINGS_PATH, JSON.stringify(t));+    console.log(`[launch] timing recorded ${kind}=${Math.round(ms)}ms (${arr.length} samples)`);+  } catch (e) { console.log(`[launch] timing record FAILED for ${kind}: ${e.message}`); }+}+function pupTimingEstimate(kind) {+  const arr = (_readTimings()[kind] || []).slice().sort((a, b) => a - b);+  if (!arr.length) return { estimatedSec: null, samples: 0, confidence: 'unknown' };+  const med = arr[Math.floor(arr.length / 2)];+  return { estimatedSec: Math.max(1, Math.round(med / 1000)), samples: arr.length, confidence: arr.length >= 3 ? 'measured' : 'estimated' };+}+function pupTimingsSummary() { const t = _readTimings(); const o = {}; for (const k of Object.keys(t)) o[k] = pupTimingEstimate(k); return o; }+let _lastLaunchAt = 0, _lastLaunchMs = 0;          // set where the browser process is actually spawned+const _inflightOpens = new Map();                  // id -> { t0, sessionId, thread }+// v2.0.397 (#92): consent toasts live in a registry (src/consent.js) so a verb parked on the+// human's click answers blocked_on_user (stillRunning:true) inside every ab budget instead of+// timing out, and a re-call re-attaches to the same toast. _pendingConsents stays the readiness map.+const _consentMod = require('./consent.js');+const consent = _consentMod.createRegistry({+  abCommand: (v, a, o) => chrome.abCommand(v, a, o), abPayload: (r) => abPayload(r),+  log: (rec, decision, retract) => console.log(`[consent] ${rec.verb} ${decision ? 'ANSWERED "' + decision + '"' : 'TIMED OUT'} (${rec.id}, attached ${rec.attached}x)${decision ? '' : ` — toast retract dismissed=${retract}`}`),+});+const blockedOnUser = _consentMod.blockedOnUser;+const _pendingConsents = consent.map;                // consentId -> { since, verb, what, decided, ... }+// Terminal frame for a finished open (percent is 100 by definition; actuals + startKind ride along).+function openProgressFrame(t0) {+  const launched = _lastLaunchAt >= t0;+  const kind = launched ? 'cold' : 'warm';+  const el = Date.now() - t0; pupTimingRecord('open_window_' + kind, el);+  const est = pupTimingEstimate('open_window_' + kind);+  return { phase: launched ? 'cold_start' : 'open_window', stepLabel: launched ? 'Launched the browser and opened the window' : 'Opened the window in the running browser',+    percent: 100, elapsedSec: Math.round(el / 1000 * 10) / 10, estimatedSec: est.estimatedSec, etaSec: 0, confidence: est.confidence, startKind: kind,+    launchMs: launched ? _lastLaunchMs : undefined };+}+// Live rows for the fast status verb (pup_readiness): one per in-flight phase. Empty = idle.+function activeProgress() {+  const rows = []; const now = Date.now();+  for (const [id, o] of _inflightOpens) {+    if (now - o.t0 > 180000) { _inflightOpens.delete(id); continue; }+    const kind = (_lastLaunchAt >= o.t0) ? 'cold' : 'warm';+    const est = pupTimingEstimate('open_window_' + kind); const el = (now - o.t0) / 1000;+    const pct = est.estimatedSec ? Math.min(95, Math.round(el / est.estimatedSec * 100)) : null;+    rows.push({ id, verb: 'pup_open_window', phase: kind === 'cold' ? 'cold_start' : 'open_window', stepLabel: (kind === 'cold' ? 'Launching the browser' : 'Opening the window') + (o.sessionId ? ` (${o.sessionId})` : ''),+      percent: pct, elapsedSec: Math.round(el), estimatedSec: est.estimatedSec, etaSec: est.estimatedSec ? Math.max(0, Math.round(est.estimatedSec - el)) : null, confidence: est.confidence, startKind: kind });+  }+  try {+    const rd = chrome && chrome.readiness ? chrome.readiness() : null;+    if (rd && rd.installing && rd.installPhase === 'unpacking') {+      // v2.0.398: the unpack is its own phase (it used to sit at 'download 100%' for minutes).+      const est = rd.bytesTotal ? Math.round(rd.bytesTotal * 2.3) : 0;+      const pct = est ? Math.min(99, Math.round((rd.unpackBytes || 0) / est * 100)) : null;+      rows.push({ id: 'cft-unpack', verb: 'pup_prewarm', phase: 'unpack', stepLabel: `Unpacking Chrome for Testing${rd.unpackMethod ? ' (' + rd.unpackMethod + ')' : ''}`, percent: pct,+        bytes: rd.unpackBytes || 0, elapsedSec: rd.unpackElapsedSec, estimatedSec: null, etaSec: null, confidence: 'estimated', startKind: 'cold' });+    } else if (rd && rd.installing) rows.push({ id: 'cft-download', verb: 'pup_prewarm', 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' });+  } catch (e) {}+  for (const row of consent.rows(now)) rows.push(row);+  try {+    const dlg = findAnyPendingDialog();+    if (dlg) rows.push({ id: 'dialog-' + (dlg.sessionId || 'any'), verb: 'pup_dialog', phase: 'dialog', blockedOnUser: true, stepLabel: 'A browser dialog is up' + (dlg.sessionId ? ` in ${dlg.sessionId}` : ''), percent: null, confidence: 'measured', blockedBy: 'a browser dialog (pup_dialog to answer it)' });+  } catch (e) {}+  return rows;+}+// ── v2.0.386 (#83 ask 3): IDENTITY AUDIT — read back what the OS window is ACTUALLY stamped with+// and compare it to what pup believes (_curAppId). A mismatch (a stale generation, a stamp that+// never landed, a window Chrome recreated) is re-stamped through the normal path, which also+// re-registers and nudges the button. One PowerShell per sweep for every live hwnd; every 5 min.+function readStampedAumids(hwnds) {+  return new Promise((resolve) => {+    if (process.platform !== 'win32' || !hwnds.length) return resolve({});+    const ps = `$code=@'+using System; using System.Runtime.InteropServices;+public static class AumidRead {+  [StructLayout(LayoutKind.Sequential)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }+  [StructLayout(LayoutKind.Sequential)] public struct PROPVARIANT { public ushort vt; public ushort r1; public ushort r2; public ushort r3; public IntPtr p; public IntPtr p2; }+  [ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]+  public interface IPropertyStore { int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY pk); int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv); int SetValue(ref PROPERTYKEY key, ref PROPVARIANT pv); int Commit(); }+  [DllImport("shell32.dll")] public static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out IPropertyStore pps);+  static Guid AUM = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");+  static Guid IID = new Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99");+  public static string Read(IntPtr hwnd) {+    IPropertyStore ps; if (SHGetPropertyStoreForWindow(hwnd, ref IID, out ps) != 0) return null;+    PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = AUM; k.pid = 5;+    PROPVARIANT pv; if (ps.GetValue(ref k, out pv) != 0) return null;+    return (pv.vt == 31 && pv.p != IntPtr.Zero) ? Marshal.PtrToStringUni(pv.p) : null;+  }+}+'@+Add-Type -TypeDefinition $code+foreach($h in @(${hwnds.join(',')})){ $a=[AumidRead]::Read([IntPtr]$h); Write-Output ("$h" + [char]9 + $a) }`;+    execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 20000 }, (err, stdout) => {+      const out = {};+      if (!err) for (const line of String(stdout || '').split(/\r?\n/)) { const [h, a] = line.split('\t'); if (h) out[String(h).trim()] = (a || '').trim() || null; }+      resolve(out);+    });+  });+}+async function auditStampedIdentities() {+  if (process.platform !== 'win32' || pupSettings().aumidIcons !== 'on') return;+  const targets = [];+  for (const [sid, s] of sessions) { if (s && s._hwnd && s._curAppId) targets.push({ sid, hwnd: String(s._hwnd), expect: s._curAppId }); }+  if (!targets.length) return;+  const actual = await readStampedAumids(targets.map(t => t.hwnd));+  let mismatches = 0;+  for (const t of targets) {+    const got = actual[t.hwnd];+    if (got === undefined) continue;                       // window not readable this pass (gone, or PS failed)+    if (got === t.expect) continue;+    mismatches++;+    console.log(`[identity] AUDIT MISMATCH "${t.sid}": stamped=${got || '(none)'} expected=${t.expect} — re-stamping (#83)`);+    const s = sessions.get(t.sid); if (!s) continue;+    s._curAppId = null; s._recoveryIdentityVerified = true;+    stampPupIdentity(t.sid, { _commit: true }).catch(() => {});+  }+  if (!mismatches) console.log(`[identity] audit ok: ${targets.length} window(s) stamped as expected`);+}+function findAnyPendingDialog() {+  for (const [sid, session] of sessions) {+    const d = findPendingDialog(session);+    if (d) return { ...d, sessionId: sid };+  }+  return null;+}+// The one pending native dialog on a session, if any. A modal blocks the WHOLE window, so it does+// not matter which tab owns it: the first one found is the one the caller is stuck behind.+// v2.0.397 (#92): the walls a page puts up that only a human can pass. Cheap DOM heuristics,+// read in the background; used by pup_login after a submit. Returns { kind, evidence } | null.+async function detectHumanChallenge(page) {+  if (!page) return null;+  const r = await Promise.race([+    page.evaluate(() => {+      const q = (sel) => { try { return document.querySelector(sel); } catch (e) { return null; } };+      const vis = (el) => { if (!el) return false; const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; };+      const otp = q('input[autocomplete="one-time-code"]') || q('input[name*="otp" i]') || q('input[id*="otp" i]') || q('input[name*="verification" i][inputmode="numeric"]') || q('input[name*="mfa" i]') || q('input[name*="totp" i]') || q('input[name*="2fa" i]');+      if (vis(otp)) return { kind: 'two_factor', evidence: 'one-time-code input: ' + (otp.getAttribute('name') || otp.getAttribute('id') || otp.getAttribute('autocomplete')) };+      const capFrame = Array.from(document.querySelectorAll('iframe')).find(f => /recaptcha|hcaptcha|turnstile|arkose|funcaptcha/i.test(f.src || ''));+      if (capFrame && vis(capFrame)) return { kind: 'captcha', evidence: 'captcha iframe: ' + new URL(capFrame.src).hostname };+      const t = (document.body && document.body.innerText || '').slice(0, 20000);+      if (/verification code|two[- ]factor|2-step verification|authenticator app|enter the code we sent|security code|one-time (pass)?code/i.test(t) && !q('input[type="password"]')) return { kind: 'two_factor', evidence: 'page text mentions a verification code and no password field is present' };+      if (/i'?m not a robot|prove you are human|verify you are human|security check/i.test(t)) return { kind: 'captcha', evidence: 'page text asks for a human check' };+      return null;+    }),+    new Promise((rs) => setTimeout(() => rs(null), 4000)),+  ]);+  return r || null;+}+function findPendingDialog(session) {+  if (!session || !Array.isArray(session.tabs)) return null;+  for (const t of session.tabs) {+    if (t && t.pendingDialog) return { ...t.pendingDialog, tabId: t.tabId };+  }+  return null;+}++function attachTab(session, sessionId, page, { makeActive = true, opener = 'user', openerTabId = null } = {}) {+  const tabId = nextTabId(session);+  const errors = [];+  setupErrorCollection(page, errors);+  // v2.0.141: present as consumer Google Chrome on EVERY tab (see computeConsumerUA).+  applyConsumerUA(page);+  // Immunize this tab against debugger-statement / breakpoint freezes (see neutralizeDebugger).+  neutralizeDebugger(page);+  // v1.9.78 — SURVIVE A RENDERER CRASH (wiki issue #16). Under many heavy-SPA tabs plus rapid+  // navigate/eval/screenshot churn, one tab's renderer can crash (Chrome's per-tab 'Aw, Snap').+  // puppeteer surfaces that as the page 'error' event; we mark the tab so every verb FAST-FAILS+  // with a clear 'reload this tab' instead of hanging for 20s and wedging the whole window. The+  // crash is isolated to the tab — the window and its other tabs stay drivable.+  const _markCrashed = (why) => {+    try {+      const t = (session.tabs || []).find(x => x.page === page);+      if (t && !t._crashed) {+        t._crashed = true; t._crashedAt = Date.now(); t._crashReason = why;+        // v2.0.236: wake anything racing this tab's in-flight verb (replaces a 250ms poll)+        try { const w = t._crashWaiters || []; t._crashWaiters = []; w.forEach(fn => { try { fn(); } catch (e) {} }); } catch (e) {}+        try { const u = page.url(); if (u && u !== 'about:blank') t._crashUrl = u; } catch (e) {}+        console.log(`[crash] session "${sessionId}" tab ${t.tabId} renderer CRASHED (${why}) — auto-recovering; verbs fast-fail until it's back`);+        // v1.9.176 — AUTO-HEAL the crashed renderer ONCE. An 'Aw, Snap' tab is already dead (it shows+        // Chrome's error page), so respawning it loses nothing and is pure recovery — exactly the+        // resilience John asked for. Cap at one auto-attempt: if it re-crashes (memory still pressured),+        // stop and leave it flagged so the caller decides, rather than spinning a reload-crash loop.+        if ((t._crashAutoAttempts || 0) < 1) {+          t._crashAutoAttempts = (t._crashAutoAttempts || 0) + 1;+          const rurl = t._crashUrl;+          setTimeout(async () => {+            try {+              if (!t._crashed) return;   // a verb already recovered it+              const ok = await respawnCrashedRenderer(page, rurl);+              if (ok) {+                t._crashed = false; delete t._crashedAt; delete t._crashReason;+                console.log(`[crash] session "${sessionId}" tab ${t.tabId} AUTO-recovered (renderer respawned)`);+                recordCleanup(`pup auto-recovered a crashed ("Aw, Snap! Out of Memory") tab in session "${sessionId}" by respawning its renderer${rurl ? ` at ${rurl}` : ''} — the tab had run out of memory (usually many heavy tabs sharing one Chrome); the window is back on its own.`);+              } else {+                console.log(`[crash] session "${sessionId}" tab ${t.tabId} auto-recover did not take — left flagged for manual pup_reload`);+              }+            } catch (e) {}+          }, 2500);+        }+      }+    } catch (e) {}+  };+  try { page.on('error', (err) => _markCrashed((err && err.message) || 'Page crashed!')); } catch (e) {}+  // v1.9.185 (John: "what are you doing that's so different controlling Chrome that you cause+  // crashes?" — this was one real answer): the v1.9.78 crash detector opened a SECOND CDP session+  // per tab and NEVER detached it. Every tab therefore held an extra attached debugger session for+  // its whole life, accumulating in the browser process — cross-process state your normal Chrome+  // never carries. puppeteer's page 'error' event already fires on renderer crash (it is driven by+  // the same Inspector.targetCrashed on the page's OWN session), so the extra session bought+  // nothing. Removed: one less permanent CDP attachment per tab, and one less thing pup does TO+  // Chrome that Chrome was not designed to have done to it continuously.+  // v1.8.17: PERSISTENT title-suffix injector. AD's pup_raise/lower_os_window+  // (AD-core verbs, not this bridge — they shadow our fallback handlers) locate the+  // OS window by its " (session: <id>)" title suffix. Post-goto page.evaluate re-adds+  // the suffix, but a NEW page sets its own title BEFORE that runs → a brief window+  // where the suffix is absent and AD's lookup fails ("No visible window with title+  // containing …") right after a navigation. evaluateOnNewDocument installs the+  // enforcer at document-start on EVERY navigation (before page scripts), so the+  // suffix reappears the instant the page sets its title — closing that gap.+  try {+    page.evaluateOnNewDocument((sid, minimal, label, tagOn) => {+      // v1.9.185: carry minimal-touch into the page so the title observer below can skip installing+      // itself (see the note at `observe`). Read at document-start, before any page script runs.+      try { if (minimal) window.__pupMinimalTouch = true; } catch (e) {}+      // TOLERANT marker check (v1.8.19): a tab's own suffix may be+      // " (session: <sid>)" OR " (session: <sid> | <tabId>)". This injector must+      // only ADD a marker when NONE is present — if it enforced an exact+      // " (session: <sid>)" it would fight addTabToSession's " | <tabId>" suffix:+      // two MutationObservers appending different strings ping-pong forever, peg the+      // renderer, and flood the CDP pipe (which HUNG pup_open_tab on Edge and+      // poisoned the whole bridge). Matching on the marker PREFIX makes it cooperate.+      const LBL = label || 'ai-thread';+      const MARKER = ' (' + LBL + ': ' + sid;   // no closing paren — matches both forms+      const BASE = ' (' + LBL + ': ' + sid + ')';+      // v1.9.49: do NOT require a pre-existing title. This guard used to be+      //     if (document.title && ...)+      // so a page that never sets a <title> (plenty of app routes and SPA shells serve one) kept an+      // EMPTY title, the session marker was never injected, and the OS window title stayed a bare+      // "Google Chrome for Testing". AD resolves pup windows by their "(session: <id>)" suffix, so+      // that window became INVISIBLE to every branding call: identity stamp, category flip, overlay+      // and jump list all failed with "No visible window with title containing (session: ...)".+      // That is the failure behind icons that look stale or never update — the window was findable+      // right up until it navigated to a title-less page. An empty title must still get the marker.+      // v1.9.56: SELF-HEALING against DUPLICATES. The old check only asked "is my marker absent?",+      // so once a second copy appeared it was never collapsed — measured live as+      //   "○ Public · Hydrogen Desktop - Adom Wiki (session: hdr-wiki) (session: hdr-wiki)"+      // (107-char title). Two independent MutationObservers touch this title (this session-suffix+      // injector and the wiki state-glyph injector); a page that rewrites its own title can let both+      // fire before either sees the other's write, and each appends. Duplicated tags bloat the+      // taskbar-thumbnail CAPTION (the surface we want for status) and, worse, AD resolves pup windows+      // by "(session: <id>" — the same duplicate-tag class that previously made branding land on the+      // WRONG window. So: count OUR OWN tag and normalise to exactly one. Only our own sid is touched,+      // so an adopted tab's re-tag (retagSessionTitle) is never fought.+      const RE_MINE = new RegExp('\\s*\\((?:session|ai-thread): ' + sid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(?:[^)]*)\\)', 'g');+      const ensure = () => {+        try {+          const cur = String(document.title || '');+          if (!tagOn) { const clean = cur.replace(RE_MINE, ''); if (clean !== cur) document.title = clean; return; }  // OFF: keep title clean+          const n = (cur.match(RE_MINE) || []).length;+          if (n === 1) return;                                   // already exactly one — leave it alone+          if (n === 0) { document.title = cur + BASE; return; }  // none — add ours+          document.title = cur.replace(RE_MINE, '') + BASE;      // duplicates — collapse to one+        } catch (e) {}+      };+      const observe = () => {+        // v1.9.288: when the tag is OFF we strip once (above) but must NOT run a stripping observer -+        // it would instantly remove the TRANSIENT tag pup adds to resolve the window's hwnd, forcing+        // ambiguous geometry (two same-profile windows share a rect => same hwnd, John's dup-hwnd bug).+        if (!tagOn) return true;+        // v2.0.20: ONLY a real <title> node. The old head/documentElement fallback observed the+        // ENTIRE document subtree on titleless pages (raw JSON/API views), firing ensure() on every+        // DOM mutation — a renderer-pegging cost on exactly the pages least able to afford it. A+        // titleless page keeps its once-stamped tag; continuous enforcement is not worth a freeze.+        const t = document.querySelector('title');+        if (!t) return true;+        // v1.9.185 minimal-touch: in MINIMAL mode we stamp the title ONCE and do NOT install a+        // MutationObserver. On a heavy SPA (Google Slides/Sheets rewrite document.title constantly)+        // this observer re-fired on every one of those writes, each time writing the title BACK —+        // an in-page write loop pup itself created, on the exact windows John saw freeze. The+        // window still carries the tag from the initial stamp; only the continuous re-enforcement+        // is dropped. (window.name, set below, is the durable identity anyway.)+        if (window.__pupMinimalTouch) return true;+        try { window.__pupTagObs = new MutationObserver(ensure); window.__pupTagObs.observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {}+        return true;+      };+      ensure();+      if (!observe()) document.addEventListener('DOMContentLoaded', () => { observe(); ensure(); });+    }, sessionId, minimalTouch(), TITLE_LABEL, pupSettings().titleTag === 'on').catch(() => {});+  } catch (e) {}+  // v1.9.284 (John: "a small floating in-window chip that indicates what ai-thread owns this tab,+  // what profile ... it can't get in the way though"). A tiny corner pill showing owner-thread and+  // profile. Design so it can NEVER get in the way or disturb his vibe-coded pages:+  //   - attached to <html> (survives SPA <body> swaps) inside a CLOSED shadow root, so page CSS/JS+  //     can neither style it nor see it, and it cannot affect the page's layout or selectors;+  //   - pointer-events:none, so every click passes straight through it;+  //   - one-shot (no observers/intervals fighting the page), and it self-dims after a few seconds.+  // Gated by the ownerChip setting (default on). Value strips a leading "ai-" so it reads as the+  // plain thread name, matching the new "ai-thread" title label.+  try {+    if (pupSettings().ownerChip === 'on') {+      const _threadDisp = String(sessionId).replace(/^ai-/, '');+      const _prof = (session && session.profileName) || 'adom-you';+      // v1.9.343 (John: "put my Adom profile picture in there — show my adom identity"). Chrome's own+      // profile chip cannot take a custom local image (only a real Google sign-in can — tested), so we+      // show the user's Adom identity HERE instead: the owner chip leads with the user's Adom avatar+      // (set via pup_set_identity → fetched, circle-cropped, embedded as a data URI in settings).+      // Falls back to the ⬢ glyph when no identity is configured.+      const _avatar = pupSettings().identityAvatarB64 || '';+      const _ident = pupSettings().identityName || '';+      page.evaluateOnNewDocument((thread, profile, avatar, ident) => {+        try {+          const add = () => {+            try {+              const root = document.documentElement; if (!root) return;+              if (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 = avatar+                ? '<img class="av" alt="" src="data:image/png;base64,' + avatar + '">'+                : '<span class="hx">⬢</span>';+              const html = '<style>.c{display:flex;align-items:center;gap:6px;font:11px/1.35 "JetBrains Mono",ui-monospace,SFMono-Regular,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;transition:opacity .5s ease;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}.dim{opacity:.3}</style>'+                + '<div class="c" id="pupc" title="' + (ident ? String(ident) + ' — ' : '') + 'Adom pup">' + lead + '<span><b>' + String(thread) + '</b> · ' + String(profile) + '</span></div>';+              if (sh) { sh.innerHTML = html; } else { host.innerHTML = html; }+              root.appendChild(host);+              const el = sh ? sh.getElementById('pupc') : host.querySelector('#pupc');+              setTimeout(() => { try { if (el) el.classList.add('dim'); } catch (e) {} }, 4500);+            } catch (e) {}+          };+          if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', add, { once: true });+          else add();+        } catch (e) {}+      }, _threadDisp, _prof, _avatar, _ident).catch(() => {});+    }+  } catch (e) {}+  // v1.8.21: install the shadow-DOM + scroll helpers on every document so pup_eval+  // can use $deep / $$deep / deepText without hand-rolling a shadow-root walk.+  if (process.platform === 'win32') {+    // v2.0.72: PRECACHE the annotate module in every page so the pencil and hotkey open the+    // toolbar instantly, client-side, with zero bridge round-trip (John: it was laggy).+    try { page.evaluateOnNewDocument(ANNOTATE_OVERLAY_JS).catch(() => {}); page.evaluate(ANNOTATE_OVERLAY_JS).catch(() => {}); } catch (e) {}+    // v2.0.95: always injected — the script self-configures from the baked flags (floaty on/off,+    // hotkey on/off), and injecting the OFF build is what tears an elder pencil out of the page.+    const fj = floatyJs();+    try { page.evaluateOnNewDocument(fj).catch(() => {}); page.evaluate(fj).catch(() => {}); } catch (e) {}+    // v2.0.102: cleanup-only — strip any title prefix an elder bridge's injected keeper left+    // (their evaluateOnNewDocument scripts persist on long-lived pages and run first; this+    // registers after them, so the strip wins).+    const tjClean = titleJs('');+    try { page.evaluateOnNewDocument(tjClean).catch(() => {}); page.evaluate(tjClean).catch(() => {}); } catch (e) {}+    // v2.0.110: login-capture rail — records credentials as they are submitted so the keychain+    // vault grows the adom-you identity automatically (John authorized). Self-guarded, page-local.+    // A synchronous Puppeteer binding stores the cred BEFORE the submit navigates away (a+    // navigation would wipe the page global); the sweep-harvested global is the SPA fallback.+    // v2.0.315: the corner-pencil click PUSHES (binding) instead of being polled every 4s per tab.+    page.exposeFunction('__pupFloatyPush', (tabHint) => {+      try {+        const sF = sessions.get(sessionId); if (!sF) return;+        if (sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) return;+        sF._annotateArmed = Date.now();+        const tF = (sF.tabs || []).find(t => t && t.page === page);+        console.log(`[floaty] "${sessionId}" pencil clicked (push) — arming capture watcher on ${tF ? tF.tabId : '?'}`);+        startAnnotation(sF, sessionId, 'user-floaty', { tool: 'pen', tabId: tF ? tF.tabId : null }).catch(() => {});+      } catch (e) {}+    }).catch(() => {});+    page.exposeFunction('__pupCredCapture', (json) => {+      (async () => {+        try {+          if (pupSettings().captureLogins === 'off') return;+          const rec = JSON.parse(json || '{}');+          if (!rec.host || !rec.p) return;+          await credentialVault.setCredential({ host: rec.host, username: rec.u || '', password: rec.p });+          console.log(`[cred] captured login for ${rec.host}${rec.u ? ` (${rec.u})` : ''} on "${sessionId}" → keychain vault`);+          const sNow = sessions.get(sessionId);+          if (sNow) sNow._pendingCredHint = `pup CAPTURED this login for "${rec.host}"${rec.u ? ` (username ${rec.u})` : ''} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit auto-fills in the background with no Chrome dropdown and no foregrounding; submitting stays reason-gated. 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.`;+        } catch (e) { console.log(`[cred] binding store failed: ${e.message}`); }+      })();+    }).catch(() => { /* already registered on this page (re-attach) — fine */ });+    try { page.evaluateOnNewDocument(CAPTURE_LOGIN_JS).catch(() => {}); page.evaluate(CAPTURE_LOGIN_JS).catch(() => {}); } catch (e) {}+  }+  try { page.evaluateOnNewDocument(PUP_DEEP_HELPERS).catch(() => {}); } catch (e) {}+  // v1.9.48: re-evaluate the window CATEGORY when this tab finishes navigating.+  //+  // Category used to be recomputed ONLY inside updateTabCountBadge, i.e. only when a tab VERB ran.+  // A tab's URL is not known at that instant — the page is still about:blank while the navigation+  // is in flight — so the category was computed from the tabs that happened to have loaded, cached+  // in _lastCat, and then never revisited, because nothing re-checks after a page settles. Opening+  // two tabs back-to-back reliably froze a wiki+web+app window on the plain "wiki" icon instead of+  // "mixed" (caught by the taskbar audit as r1: 3 tabs, registered wiki).+  // The navigation event is the correct trigger: it is exactly when a tab's category becomes known.+  try {+    page.on('framenavigated', (frame) => {+      try { if (frame !== page.mainFrame()) return; } catch (e) { return; }+      scheduleCategoryRecheck(sessionId);+      // New URL → give favicon detection a fresh start: a page that had none may now have one.+      try { session._overlayGenericFinal = false; session._overlayGenericTries = 0; } catch (e) {}+      // v1.9.324 (John: "EVERY verb OR EVENT that could change an icon MUST trigger a recalc").+      // The favicon OVERLAY badge was only recomputed by explicit MUTATING_VERBS. Any navigation+      // pup did not itself initiate — a JS redirect, a <meta> refresh, a form submit, an SPA route+      // change, an OAuth bounce, a link click INSIDE the tab, pup_login's submit — changes the+      // page favicon but fired no verb, so the taskbar kept a STALE badge. THAT is the drift John+      // has caught a dozen times. The navigation event is the true trigger: recompute here, for the+      // active/visible tab (applyAppOverlay re-resolves the visible tab), debounced ~2s so a redirect+      // chain or route-spammy SPA collapses to one repaint, and gated on the overlayBadges pref.+      scheduleOverlayRefresh(sessionId);+    });+  } catch (e) {}+  // v1.8.45: track when the USER activates this window (e.g. clicks its taskbar button to+  // watch it) so pup can report per-window flash state PROGRAMMATICALLY. The window 'focus'+  // event fires when the OS window is activated; we stamp the time. A focus AFTER the last+  // flash means the user cleared that flash. Timestamps use the desktop's own clock — the+  // same machine the bridge runs on — so they're directly comparable to _flashState.+  const PUP_FOCUS_HOOK = () => {+    try {+      if (window.__pupFocusHook) return; window.__pupFocusHook = true;+      if (typeof window.__pupFocusTs !== 'number') window.__pupFocusTs = 0;+      window.addEventListener('focus', () => { try { window.__pupFocusTs = Date.now(); } catch (e) {} }, true);+    } catch (e) {}+  };+  try {+    // Future documents (navigations) get it at document-start…+    page.evaluateOnNewDocument(PUP_FOCUS_HOOK).catch(() => {});+    // …AND the CURRENT document gets it right now. evaluateOnNewDocument alone only fires on+    // the NEXT navigation — a page that had already navigated when attachTab ran never got the+    // hook at all (v1.8.45-55 bug: every clicked window still reported flash 'pending', so the+    // "which windows has the user seen" query silently returned garbage).+    page.evaluate(PUP_FOCUS_HOOK).catch(() => {});+  } catch (e) {}+  // Tag console logs with tabId so the caller can tell which tab emitted them+  // v1.9.163 (crash fix): RATE-LIMIT the console mirror. This synchronously wrote EVERY page+  // console message to the bridge's stdout; a spammy SPA (measured: Autodesk's Kepler portal)+  // floods the Node event loop with blocking writes and stalls CDP for EVERY session (HUNG-alive,+  // CDP-DEAD). Cap to ~8/sec per tab; drop the rest with a one-line "(suppressed N)" summary.+  let _cmWin = 0, _cmCount = 0, _cmDropped = 0;+  page.on('console', msg => {+    try {+      const now = Date.now();+      if (now - _cmWin > 1000) {+        if (_cmDropped > 0) console.log(`[pup console ${sessionId}/${tabId}] (suppressed ${_cmDropped} flood messages in the last second)`);+        _cmWin = now; _cmCount = 0; _cmDropped = 0;+      }+      if (_cmCount >= 8) { _cmDropped++; return; }+      _cmCount++;+      console.log(`[pup console ${sessionId}/${tabId} ${msg.type()}] ${msg.text()}`);+    } catch {}+  });+  // When the user closes a tab via Chrome UI, keep our state in sync+  page.on('close', () => {+    const idx = session.tabs.findIndex(t => t.tabId === tabId);+    if (idx !== -1) {+      // Capture the URL BEFORE the page handle is GC'd, so resolveTabOrError+      // can include it in tab_not_found errors. Critical for popup workflows+      // (PDF popups auto-close after the viewer renders; without lastKnownUrl+      // the caller has to re-trigger the click that spawned the popup).+      let lastKnownUrl = null;+      try { lastKnownUrl = page.url(); } catch {}+      const closedEntry = {+        tabId,+        url: lastKnownUrl,+        opener: opener || 'user',+        openerTabId: openerTabId || null,+        closedAt: Date.now(),+      };+      if (!session.recentlyClosedTabs) session.recentlyClosedTabs = [];+      session.recentlyClosedTabs.push(closedEntry);+      // Cap the ring buffer at 20 so a long-lived session with many popups+      // doesn't slowly leak memory through this list.+      if (session.recentlyClosedTabs.length > 20) {+        session.recentlyClosedTabs.shift();+      }+      session.tabs.splice(idx, 1);+      if (session.activeTabId === tabId) {+        session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;+      }+      console.log(`Tab "${tabId}" of session "${sessionId}" closed by user. Remaining tabs: ${session.tabs.length}. Last URL: ${lastKnownUrl || '(unknown)'}`);+      // v1.9.279: if that was the LAST window of the session, reap it NOW (badge + identity + entry)+      // instead of leaving a stale icon until the next sweep — the aa.com-closed-by-another-thread case.+      if (session.tabs.length === 0) { try { reconcileModel('tab-close').catch(() => {}); } catch (e) {} }+    }+    // Auto-stop any tab recording for this tab+    try {+      if (typeof tabRecordings !== 'undefined') {+        for (const [recId, state] of [...tabRecordings.entries()]) {+          if (state.sessionId === sessionId && state.tabId === tabId && !state.stopping) {+            state.stopReason = 'tabClosed';+            tabRecordStopImpl({ sessionId, recordingId: recId }).catch(e =>+              console.error(`[${recId}] auto-stop on tab close failed: ${e.message}`));+          }+        }+      }+    } catch (e) { console.error(`tab-close recording cleanup failed: ${e.message}`); }+  });+  // NATIVE JS DIALOGS (v2.0.344, John: "do you detect these dialogs and tell the ai and let them+  // agree to the reload?"). Until now pup registered NO dialog handler at all, and the consequences+  // were worse than cosmetic: an alert/confirm/prompt, or a beforeunload "Reload site? Changes you+  // made may not be saved", BLOCKS the whole window. Measured before this fix, on a page with an+  // edited textarea: pup_reload sat for 25s and returned "Navigation timeout of 25000 ms exceeded",+  // pup_screenshot returned "page.screenshot timed out after 20000ms", and even pup_close_window+  // timed out. Three different verbs, three timeout messages, and not one of them said the true+  // cause: a modal is up, waiting for a human who is never coming.+  //+  // We deliberately do NOT auto-dismiss. Puppeteer's default (dismiss when nobody listens) would+  // silently answer "Cancel" to a question about the USER'S UNSAVED WORK, and silently discarding+  // someone's data is worse than blocking. So the dialog is RECORDED and the AI is told, which is+  // exactly what John asked for: detect it, surface it, let the driver decide.+  const tabRec = { tabId, page, errors, opener, openerTabId, pendingDialog: null };+  page.on('dialog', (dialog) => {+    tabRec.pendingDialog = {+      dialog,+      type: dialog.type(),                 // alert | confirm | prompt | beforeunload+      message: dialog.message(),+      defaultValue: dialog.defaultValue(),+      at: Date.now(),+    };+    console.error(`[${sessionId}] tab ${tabId}: ${dialog.type()} dialog opened and is BLOCKING: ${JSON.stringify(dialog.message()).slice(0, 160)}`);+  });+  session.tabs.push(tabRec);+  if (makeActive) session.activeTabId = tabId;++  // v1.6.3+: enable scripted downloads on this page by default. Pup is a+  // tool-driving surface; chip-fetcher and similar tools need JS-triggered+  // downloads (a, fetch+blob, window.location=url) to actually save instead+  // of being silently dropped on the floor by Chrome. See+  // applyDownloadBehavior for rationale.+  //+  // Runs async (don't block attachTab on it) and idempotent — re-applying+  // is harmless. Re-fires on every same-tab main-frame navigation as a+  // safety belt: empirically modern Chrome doesn't reset the download+  // policy across same-context navs, but cross-context (target detach,+  // browser-restored tab) can lose it. Re-applying costs nothing.+  const downloadPath = (session._downloadPath) || path.join(os.homedir(), 'Downloads');+  applyDownloadBehavior(page, downloadPath).catch(() => {});+  page.on('framenavigated', frame => {+    try {+      if (frame === page.mainFrame()) {+        applyDownloadBehavior(page, downloadPath).catch(() => {});+      }+    } catch {}+  });++  // v1.8.80: any tab addition (incl. popup auto-attach) refreshes the tab-count badge+  setTimeout(() => { try { updateTabCountBadge(session, sessionId).catch(() => {}); } catch (e) {} }, 500);+  return tabId;+}++// Find the (sessionId, tabId) tuple whose page matches a Puppeteer Target's page.+// Used by the popup tracker to figure out which existing tab triggered window.open().+async function findTabForTarget(target) {+  let openerPage = null;+  try { openerPage = await target.page(); } catch {}+  if (openerPage) {+    for (const [sid, s] of sessions) {+      for (const t of s.tabs) {+        if (t.page === openerPage) return { sessionId: sid, tabId: t.tabId };+      }+    }+    return null;+  }+  // #425: iframe-originated popup. When a page opens target=_blank from INSIDE a cross-origin/sandboxed+  // iframe (e.g. the wiki README iframe with allow-popups), target.opener() points at the SUBFRAME+  // (OOPIF) target, whose .page() is null — so the identity match above can't see it and the popup gets+  // dropped ("opener target unknown to any session"). Resolve the owning tracked page by matching the+  // opener frame's URL against each page's frames. frames()/url() are synchronous cached data (no new+  // await target.page(), so the CDP-pipe-hang guard in wirePopupTracking is unaffected). Require a+  // UNIQUE match so ambiguous about:blank/about:srcdoc frames never mis-route the popup.+  let openerUrl = '';+  try { openerUrl = target.url() || ''; } catch {}+  if (!openerUrl || openerUrl === 'about:blank' || openerUrl === 'about:srcdoc') return null;+  let hit = null;+  for (const [sid, s] of sessions) {+    for (const t of s.tabs) {+      if (!t.page) continue;+      let frames = [];+      try { frames = t.page.frames(); } catch { continue; }+      for (const f of frames) {+        let fu = ''; try { fu = f.url() || ''; } catch {}+        if (fu && fu === openerUrl) {+          if (hit && (hit.sessionId !== sid || hit.tabId !== t.tabId)) return null; // ambiguous → drop+          hit = { sessionId: sid, tabId: t.tabId };+        }+      }+    }+  }+  return hit;+}++// Wire popup auto-tracking on a freshly launched (or reconnected) browser.+// Whenever Chrome opens a new top-level page that we DIDN'T explicitly create+// (window.open(), target="_blank" form post, <a target="_blank"> click, etc.),+// `targetcreated` fires. We resolve the target's page, find the session that+// owns its opener (via target.opener()), and attach the popup to that session+// as a tracked tab. pup_list_tabs then includes it like any other tab —+// callers can switch to it, eval against it, screenshot it, close it.+//+// Edge cases:+//   - Target has no opener (top-level Chrome window opened by external means)+//     → drop; we don't know which session it belongs to.+//   - Target is already attached (we created it via launchSession or open_tab+//     and the targetcreated event raced our explicit attach) → drop.+//   - Browser is the desktop recorder window (separate profile) → drop.+function wirePopupTracking(browser, profileName) {+  browser.on('targetcreated', async (target) => {+    try {+      if (target.type() !== 'page') return;++      // Check opener() FIRST — it's a synchronous target-info property, no page init.+      // A popup we care about (window.open / target=_blank) HAS an opener; a target+      // with NO opener is either the tab WE just made via browser.newPage() in+      // addTabToSession, or a truly external window — both of which we drop.+      // CRITICAL (v1.8.18): we must NOT `await target.page()` on an openerless target.+      // On Edge, awaiting the page of a target that our own newPage() is still+      // initializing re-enters the single-threaded CDP pipe and HANGS newPage() —+      // which then poisons the whole connection (every later screenshot / list / nav+      // times out). Bailing on opener()===null before target.page() avoids it entirely.+      const openerTarget = target.opener();+      if (!openerTarget) {+        console.log(`[popup-track] new tab on profile "${profileName}" with no opener — not tracking (our own newPage or external)`);+        return;+      }++      const popupPage = await target.page();+      if (!popupPage) return;++      // v1.9.296: skip pup's OWN in-window tab creations. addTabToSession opens a+      // window.open("about:blank") tagged with a unique window.name ("pupclaim_…") and adopts it+      // explicitly. When that blank's opener happened to be observable here, the tracker ALSO+      // attached the very same page — double-attaching it (two tab rows for one tab: the duplicate+      // John saw on a burst of opens). The marker is authoritative: these belong to addTabToSession,+      // so the tracker must not touch them. (Real page-initiated popups have no such marker.)+      try {+        const nm = await popupPage.evaluate(() => window.name);+        if (typeof nm === 'string' && nm.indexOf('pupclaim_') === 0) return;+      } catch (e) { /* can't read it (mid-nav) — fall through to the registered-page dedup below */ }++      // Skip if some session already has this page registered (we attached it+      // explicitly; this is the targetcreated event firing for our own creation).+      for (const s of sessions.values()) {+        for (const t of s.tabs) {+          if (t.page === popupPage) return;+        }+      }+      const ownerInfo = await findTabForTarget(openerTarget);+      if (!ownerInfo) {+        console.log(`[popup-track] new tab on profile "${profileName}", opener target unknown to any session — not tracking`);+        return;+      }+      const session = sessions.get(ownerInfo.sessionId);+      if (!session) return;++      // Attach as a popup-origin tab. attachTab handles the standard error ++      // console + page.on('close') wiring same as user-opened tabs.+      const tabId = attachTab(session, ownerInfo.sessionId, popupPage, {+        makeActive: false, // don't yank focus; let caller decide via pup_switch_tab+        opener: 'popup',+        openerTabId: ownerInfo.tabId,+      });+      let popupUrl = '';+      try { popupUrl = popupPage.url(); } catch {}+      console.log(`[popup-track] new popup attached: session="${ownerInfo.sessionId}" tabId="${tabId}" openedBy="${ownerInfo.tabId}" url="${popupUrl}"`);+    } catch (e) {+      console.log(`[popup-track] error: ${e.message}`);+    }+  });+}++// Resolve which page+errors to operate on. If args.tabId is given, use that+// specific tab; else fall back to the session's active tab.+// Returns { tab, tabId } or throws a structured Error whose .message is JSON+// (so the dispatch layer can surface currentTabs + a hint without re-parsing).+//+// The hint matters most for popup-tab workflows: PDF popups and downloads+// auto-close fast (Chrome PDF viewer with Content-Disposition: attachment+// closes after the download). Callers who try to eval-against-the-popup+// after it's gone used to get the active tab's response silently+// (v1.4.8 and earlier had a quirk where some paths did this). We now error+// loudly with the exact recipe to use instead.+function resolveTabOrError(session, sessionId, args, { allowCrashed = false } = {}) {+  if (args.tabId) {+    const t = getTabById(session, args.tabId);+    if (!t) {+      const available = session.tabs.map(x => x.tabId);+      // v1.4.10: If the requested tabId is one we recently saw close (most+      // commonly a popup PDF that auto-closed after rendering), surface its+      // last known URL + closure metadata so the caller can immediately+      // pup_fetch_url it without having to re-trigger the spawning click.+      let recentlyClosed = null;+      if (Array.isArray(session.recentlyClosedTabs)) {+        const hit = session.recentlyClosedTabs.find(c => c.tabId === args.tabId);+        if (hit) {+          recentlyClosed = {+            lastKnownUrl: hit.url,+            opener: hit.opener,+            openerTabId: hit.openerTabId,+            closedMsAgo: Date.now() - hit.closedAt,+          };+        }+      }+      const payload = {+        error: `Tab "${args.tabId}" not found in session "${sessionId}".`,+        currentTabs: available,+        _hint:+          `Most likely the popup auto-closed (PDF popups close after the ` ++          `viewer renders if Content-Disposition is attachment, or if a ` ++          `download fired). The right pattern with popup tabs is: ` ++          `(1) pup_list_tabs IMMEDIATELY after the click that spawned ` ++          `the popup, (2) read the popup's url field from that response ` ++          `(don't eval against the popup's tabId), (3) pup_fetch_url ` ++          `with that URL — never eval-against-popup-then-fetch.`,+        errorCode: 'tab_not_found',+      };+      if (recentlyClosed) {+        Object.assign(payload, recentlyClosed);+        // Sharpen the hint when we have a URL: the caller can act on it now.+        if (recentlyClosed.lastKnownUrl) {+          payload._hint =+            `This tab closed ${Math.round(recentlyClosed.closedMsAgo / 1000)}s ago at URL ${recentlyClosed.lastKnownUrl}. ` ++            `Call pup_fetch_url with that URL (and the opener tabId "${recentlyClosed.openerTabId || 'tab-1'}" for cookies) to get its bytes — ` ++            `do NOT try to eval against the closed tabId.`;+        }+      }+      const err = new Error(JSON.stringify(payload));+      err._isStructured = true;+      throw err;+    }+    if (!allowCrashed) _throwIfCrashed(t, sessionId);+    return { tab: t, tabId: t.tabId };+  }+  const t = getActiveTab(session);+  if (!t) {+    throw new Error(`Session "${sessionId}" has no open tabs.`);+  }+  if (!allowCrashed) _throwIfCrashed(t, sessionId);+  return { tab: t, tabId: t.tabId };+}+// v1.9.78: a crashed-renderer tab cannot be screenshotted or evaluated — those operations hang+// until the verb times out (20s) and, on a busy window, that read-back wedge is what the user+// perceives as "pup crashed". Fail FAST and tell the caller exactly how to recover.+// v1.9.176: recover a CRASHED renderer IN PLACE. A crashed renderer ('Aw, Snap!') detaches the+// page's main frame, so page.reload()/page.goto() throw 'Attempted to use detached Frame' — which is+// exactly why the crash hint's promised recovery ("pup_reload to recover") did NOT work. Driving+// the RAW target with Page.navigate spawns a fresh renderer in the SAME tab/window/session, which+// puppeteer then re-attaches. Returns true once the fresh page answers a probe, false on failure.+async function respawnCrashedRenderer(page, url) {+  // Anti-race: the auto-heal (from the crash detector) and a manual pup_reload/navigate can both+  // reach here for the SAME crashed page. Two concurrent createCDPSession + Page.navigate on one+  // crashed target hang. So only ONE respawn runs per page; a second caller waits for it and returns+  // its result. Every CDP call is hard-timeout-bounded so a crashed target can never wedge the verb.+  if (page.__pupRespawning) {+    const dl = Date.now() + 10000;+    while (page.__pupRespawning && Date.now() < dl) await new Promise(r => setTimeout(r, 300));+    try { await withTimeout(page.evaluate('1'), 1500, 'respawn-probe'); return true; } catch (e) { return false; }+  }+  page.__pupRespawning = true;+  let cdp = null;+  // STABLY alive, not just a transient hit: a fresh renderer flickers an execution context DURING+  // its navigation, so a single successful probe is a false positive (the page is still mid-crash-+  // recovery and 'Execution context was destroyed' fires a beat later). Require readyState to read+  // as a real value on TWO probes ~700ms apart before declaring recovery.+  const stablyAlive = async () => {+    try {+      const rs = await withTimeout(page.evaluate('document.readyState'), 1500, 'probe');+      return rs === 'loading' || rs === 'interactive' || rs === 'complete';+    } catch (e) { return false; }+  };+  try {+    cdp = await withTimeout(page.target().createCDPSession(), 4000, 'cdp-session');+    try { await withTimeout(cdp.send('Page.navigate', { url: url || 'about:blank' }), 8000, 'page-navigate'); } catch (e) {}+    const deadline = Date.now() + 10000;+    while (Date.now() < deadline) {+      await new Promise(r => setTimeout(r, 500));+      if (await stablyAlive()) {+        await new Promise(r => setTimeout(r, 700));+        if (await stablyAlive()) return true;   // two consecutive good reads = genuinely back+      }+    }+    return false;+  } catch (e) {+    console.log(`[crash] respawnCrashedRenderer failed: ${e.message}`);+    return false;+  } finally {+    page.__pupRespawning = false;+    try { if (cdp) await cdp.detach(); } catch (e) {}+  }+}++// Detached-frame / crashed-renderer error signatures — the raw puppeteer errors that mean the tab's+// renderer is dead and normal page.* calls can't run; the caller should fall back to a respawn.+function _isCrashedFrameError(e) {+  const m = (e && e.message) || String(e || '');+  return /detached Frame|Target crashed|Session closed|Execution context was destroyed|no live page/i.test(m);+}++// The CDP target id behind a page — the stable identity of a Chrome tab across bridge reconnects+// (the puppeteer Page OBJECT is recreated on every reconnect; the target id is not).+function _pageTargetId(page) {+  try { const t = page.target(); return (t && (t._targetId || (t._targetInfo && t._targetInfo.targetId))) || null; } catch (e) { return null; }+}++// v1.9.179: prune DEAD (closed / detached-frame) and DUPLICATE tab entries from a session. Rapid+// bridge restarts + crash reconnects bound the same Chrome tab multiple times (rescan keyed on the+// page OBJECT, which changes on reconnect), so a single-page window accumulated 4 identical entries —+// inflating the taskbar count badge to "4" and, because the active entry was a detached phantom, the+// favicon read (page.evaluate) threw and the overlay fell back to that wrong count. This makes the tab+// list reflect the REAL live tabs again. A probe TIMEOUT means "busy", not dead — those are kept, so a+// merely-slow tab is never wrongly dropped.+async function reconcileSessionTabs(session, sessionId) {+  if (!session || !Array.isArray(session.tabs) || session.tabs.length === 0) return 0;+  // v1.9.182 — EMERGENCY FIX for the v1.9.181 reconciler, which DESTROYED live windows (John:+  // "why did my pup windows just crash?" — smu-deck-pup2 and wiki-review were stripped to 0 tabs+  // while he was USING them). The 1.9.181 version judged liveness from browser.targets() via+  // puppeteer-internal target-id fields; the id sets from browser.targets() and page.target() did+  // not match, so EVERY live tab looked "absent" and was dropped. Lesson enforced here: the+  // reconciler must be able to prove a tab dead with DIRECT evidence before dropping it.+  //   - isClosed() → dead (puppeteer's own state, no internals).+  //   - Otherwise a tab is dropped ONLY if a 1.5s probe fails with a crashed/detached-frame error+  //     (the phantom signature). A timeout means busy → KEEP. No target-set inference, ever.+  //   - Duplicate detection (same target id twice) keeps the FIRST binding and needs no liveness+  //     judgement; unresolvable ids are never treated as duplicates.+  // Cost honesty: this restores a per-tab probe, but ONLY for tabs not already provably closed, and+  // the probe reuses the page's existing session (no renderer paint wake for a throttled page+  // answering a trivial eval). Correctness beats the load micro-win — proven by today's failure.+  const kept = []; const seenTargets = new Set(); let dropped = 0;+  for (const t of session.tabs) {+    let dead = false;+    try {+      if (!t.page || t.page.isClosed()) dead = true;+      else {+        try { await withTimeout(t.page.evaluate('1'), 1500, 'tab-reconcile'); }+        catch (e) { if (_isCrashedFrameError(e)) dead = true; /* timeout = busy → keep */ }+      }+    } catch (e) { dead = true; }+    const tid = dead ? null : _pageTargetId(t.page);+    if (dead || (tid && seenTargets.has(tid))) { dropped++; continue; }+    if (tid) seenTargets.add(tid);+    kept.push(t);+  }+  // Safety rail: NEVER let a reconcile empty a session that had live-looking tabs going in. If every+  // tab judged dead, distrust the judgement (a systemic probe failure looks exactly like this) —+  // keep the tabs and let real evidence (isClosed, disconnects) catch genuinely dead ones later.+  if (kept.length === 0 && session.tabs.length > 0 && !session._lostBrowser) {+    console.log(`[tabs] session "${sessionId}" reconcile judged ALL ${session.tabs.length} tab(s) dead — refusing the wholesale drop (probe failure is more likely than every tab dying at once)`);+    return 0;+  }+  if (dropped > 0) {+    session.tabs = kept;+    if (session.activeTabId && !kept.find(t => t.tabId === session.activeTabId)) {+      session.activeTabId = kept.length ? kept[kept.length - 1].tabId : null;+    }+    console.log(`[tabs] session "${sessionId}" reconciled: dropped ${dropped} dead/duplicate tab(s) → ${kept.length} live`);+    try { refreshWindowChrome(session, sessionId).catch(() => {}); } catch (e) {}  // re-stamp count/favicon overlay+  }+  return dropped;+}++function _throwIfCrashed(t, sessionId) {+  if (!t || !t._crashed) return;+  const secs = Math.round((Date.now() - (t._crashedAt || Date.now())) / 1000);+  const payload = {+    error: `Tab "${t.tabId}" in session "${sessionId}" has a CRASHED renderer (${t._crashReason || 'crash'}, ${secs}s ago).`,+    errorCode: 'tab_renderer_crashed',+    tabId: t.tabId,+    _hint: `The tab's Chrome renderer process died (usually memory pressure from many heavy tabs, or a navigate racing an eval/screenshot on the same tab). The window and its OTHER tabs are fine. To recover THIS tab: pup_reload {sessionId:"${sessionId}", tabId:"${t.tabId}"} (or pup_navigate it to a URL) — that respawns the renderer and clears the crash flag. Then avoid driving a single tab with overlapping navigate/eval/screenshot calls; wait for one to return before the next.`,+  };+  const err = new Error(JSON.stringify(payload));+  err._isStructured = true;+  throw err;+}++// #293 — close the ONE residual 20s hang: when a screenshot/eval op is already IN FLIGHT and the+// renderer crashes mid-op, page.on('error') flips t._crashed a beat later. Race the op against that+// flag so we abandon the doomed op the instant the crash lands and return the structured crash error,+// instead of waiting out the full withTimeout budget. The losing op promise is swallowed so a late+// rejection can't surface as an unhandledRejection. (Crash detection + pre-op fast-fail via+// resolveTabOrError already exist; this only trims the racing-op latency.)+// v2.0.236: this polled every 250ms for the LIFETIME of every screenshot/eval/click — a 4Hz timer+// per in-flight op, watching for a condition the tab already announces. _markCrashed now resolves a+// per-tab waiter, so the race is event-driven: zero timers, and it reacts instantly instead of up+// to 250ms late. (A tab already crashed when the op starts still fast-fails, as before.)+function crashSignal(t) {+  if (!t) return new Promise(() => {});           // nothing to watch+  if (!t._crashWaiters) t._crashWaiters = [];+  return new Promise((resolve) => { t._crashWaiters.push(resolve); });+}+function raceCrash(t, sessionId, opPromise) {+  opPromise.catch(() => {});+  if (t && t._crashed) { try { _throwIfCrashed(t, sessionId); } catch (e) { return Promise.reject(e); } }+  const crashWatch = crashSignal(t).then(() => { _throwIfCrashed(t, sessionId); });+  return Promise.race([opPromise, crashWatch]);+}++// Send a structured-or-plain error from resolveTabOrError. If the error+// message is the structured JSON we throw above, parse it back out and+// surface the fields directly in the response. Otherwise plain string.+function sendTabResolveError(res, e) {+  if (e && e._isStructured) {+    try {+      const parsed = JSON.parse(e.message);+      sendJSON(res, { success: false, ...parsed });+      return;+    } catch {}+  }+  sendJSON(res, { success: false, error: e.message });+}++// Build a plain-object tab description for API responses.+async function describeTab(tab, activeTabId) {+  let url = null, title = null;+  try { url = tab.page.url(); } catch {}+  try { title = await tab.page.title(); } catch {}+  return {+    tabId: tab.tabId,+    url,+    title,+    active: tab.tabId === activeTabId,+    errorCount: tab.errors.length,+    // Provenance: "user" if Claude opened this tab via pup_open_window /+    // pup_open_tab, "popup" if the page itself spawned it via window.open()+    // / target=_blank. openerTabId names the tab that triggered the popup+    // (so callers can correlate "this datasheet popup came from product page X").+    opener: tab.opener || 'user',+    openerTabId: tab.openerTabId || null,+  };+}++// ── Persistent session file helpers ────────────────────────────────+function sessionFilePath(sessionId) {+  return path.join(SESSIONS_DIR, `${sessionId}.json`);+}++function saveSessionFile(sessionId, profileName, url) {+  const entry = browsers.get(profileName);+  let pid = null, cdpPort = null;+  if (entry) {+    // Prefer detached PID (Windows), fall back to Puppeteer's process()+    pid = entry.browser._detachedPid || null;+    try { if (!pid) pid = entry.browser.process()?.pid || null; } catch {}+    // Prefer stored CDP port, fall back to DevToolsActivePort file+    cdpPort = entry.browser._cdpPort || null;+    if (!cdpPort) {+      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');+      try {+        cdpPort = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;+      } catch {}+    }+  }+  const data = {+    sessionId, profile: profileName, url: url || null,+    pid, cdpPort,+    // v1.9.181: persist the user's foreground choice. Without this, EVERY bridge restart forgot that+    // the user had this window up and recovery re-parked it to the bottom mid-use — the single+    // biggest "pup windows don't behave / keep vanishing" driver (John, 2026-07-29). The flag is set+    // by a granted raise OR by pup OBSERVING the user foreground the window themselves (taskbar click).+    foreground: !!((sessions.get(sessionId) || {})._foreground),+    // v1.9.197 BUG 4 (Bills thread): owner was NEVER written here, so every bridge restart erased it+    // and pup_list_windows reported owner:null for every session — silently disabling the+    // session_owned_by_another_thread protection that owner is supposed to back.+    owner: ((sessions.get(sessionId) || {}).owner) || null,+    // v2.0.250 (#51): persist the identity this window is WEARING. After a respawn a reloaded+    // session has _curAppId=null until its first stamp lands, so the boot prune could not tell a+    // live thread key from an orphan and deleted it, un-branding the button until a full re-stamp.+    appId: ((sessions.get(sessionId) || {})._curAppId) || null,+    // v1.9.300: persist the resolved OS window handle. Chrome windows survive a bridge respawn with+    // the SAME hwnd, so restoring + validating this on recovery lets a window keep its handle WITHOUT+    // re-running the fragile width-wiggle — which is what stormed/deadlocked after every ship and left+    // every window hwnd=None. Validated via hwndBelongsToPup before reuse, so a stale/recycled handle+    // is discarded, not trusted.+    _hwnd: ((sessions.get(sessionId) || {})._hwnd) || null,+    createdAt: new Date().toISOString(),+    updatedAt: new Date().toISOString(),+  };+  try {+    fs.writeFileSync(sessionFilePath(sessionId), JSON.stringify(data, null, 2));+  } catch (e) {+    console.error(`Failed to save session file for "${sessionId}": ${e.message}`);+  }+}++function releasePersistedClaim(sessionId) {+  try { for (const [h, sid] of _persistedHwndClaims) { if (sid === sessionId) _persistedHwndClaims.delete(h); } } catch (e) {}+}+function deleteSessionFile(sessionId) {+  releasePersistedClaim(sessionId);+  try { fs.unlinkSync(sessionFilePath(sessionId)); } catch {}+}++// v1.9.300: patch JUST the resolved hwnd into a session's file without rewriting the rest (so a+// full saveSessionFile with url/pid isn't required at every resolution). Called whenever the handle+// is resolved, so a bridge respawn can restore it and skip the wiggle.+function persistSessionHwnd(sessionId, hwnd) {+  // v2.0.17: a real handle landing (birth, park, title-tag, wiggle — every success funnels here)+  // clears the resolution backoff so a session that finally resolved is probed normally again.+  try { const s = sessions.get(sessionId); if (s && hwnd) { s._hwndFailStreak = 0; s._hwndBackoffUntil = 0; } } catch (e) {}+  try {+    const f = sessionFilePath(sessionId);+    if (!fs.existsSync(f)) return;+    const d = JSON.parse(fs.readFileSync(f, 'utf8'));+    if (d._hwnd === (hwnd || null)) return;+    // v2.0.45: keep the persisted-claims map in lockstep (release the old handle, claim the new)+    try { if (d._hwnd && _persistedHwndClaims.get(Number(d._hwnd)) === sessionId) _persistedHwndClaims.delete(Number(d._hwnd)); } catch (e) {}+    try { if (hwnd) _persistedHwndClaims.set(Number(hwnd), sessionId); } catch (e) {}+    d._hwnd = hwnd || null; d.updatedAt = new Date().toISOString();+    fs.writeFileSync(f, JSON.stringify(d, null, 2));+  } catch (e) {}+}++function readSessionFile(sessionId) {+  try {+    return JSON.parse(fs.readFileSync(sessionFilePath(sessionId), 'utf8'));+  } catch { return null; }+}++function listSessionFiles() {+  try {+    return fs.readdirSync(SESSIONS_DIR)+      .filter(f => f.endsWith('.json'))+      .map(f => {+        try { return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); } catch { return null; }+      })+      .filter(Boolean);+  } catch { return []; }+}++function isProcessAlive(pid) {+  if (!pid) return false;+  try {+    process.kill(pid, 0); // signal 0 = check existence, doesn't kill+    return true;+  } catch { return false; }+}++// ── Session-tag extraction (v1.5.1+) ──────────────────────────────────+// launchSession() injects ` (session: <sessionId> | profile: <profileName>)`+// or ` (session: <sessionId>)` into every page's title via a MutationObserver.+// On reconnect / rescan we recover the sessionId from this tag — it's our+// durable mapping from "puppeteer Page handle" → "bridge sessionId" that+// survives across CDP socket drops, bridge restarts, and Chrome stay-alive+// scenarios. Without this tag (e.g. about:blank pages), the page is+// considered an orphan and will be adopted under a generated sessionId+// if rescan is asked to.+// v1.9.284 (John: "call it ai-thread ... and just do the visual parts i see"): the visible title+// tag label. WRITE side uses this; READ side (parse/strip/match) is TOLERANT of the legacy "session"+// label so windows tagged by an older build still resolve during the switchover.+const TITLE_LABEL = 'ai-thread';+const SESSION_TAG_RE = /\((?:session|ai-thread):\s*([^|)]+?)(?:\s*\|.*)?\)\s*$/;+function parseSessionIdFromTitle(title) {+  if (!title) return null;+  const m = String(title).match(SESSION_TAG_RE);+  return m ? m[1].trim() : null;+}++// ── Disconnect-handling shared between every browser.on('disconnected')+// site (v1.5.1+) ──────────────────────────────────────────────────────+//+// Old (≤v1.5.0) behavior: when a browser disconnected (CDP socket dropped,+// network blip, puppeteer-side hiccup), the bridge eagerly purged every+// session for that profile from `sessions` AND deleted the on-disk session+// file. The Chrome window was still alive on the user's desktop, fully+// usable, but every reference to it had been wiped — so subsequent+// pup_navigate/eval/screenshot calls hit "Session not found" and+// (worse) pup_navigate without a sessionId silently retargeted+// to whatever session was still in memory, hijacking unrelated windows.+// Confirmed twice in chip-fetcher's session log on 2026-05-04.+//+// New behavior: keep the session entries in memory + on disk so they+// remain "known but currently disconnected." The next pup_rescan+// (manual) or 30-second health check (auto) will try to reconnect via+// the persisted DevToolsActivePort + verify Chrome's PID is still alive,+// then walk pages and re-attach by parsing the (session: X) tag from+// each page's title.+function handleBrowserDisconnect(browser, profileName) {+  const entry = browsers.get(profileName);+  if (!entry || entry.browser !== browser) return;+  browsers.delete(profileName);+  let touched = 0;+  for (const s of sessions.values()) {+    if (s.profileName === profileName) {+      s._lostBrowser = true;+      s._disconnectedAt = Date.now();+      // v1.9.172 (crash-heal race fix): capture the last-known URL NOW, while the (dead) page handle+      // still reports its cached url. The crash self-heal relaunches from this even if the session+      // file is later reaped, so a crash under load recovers instead of failing "session not found".+      try { const u = s.page && s.page.url(); if (u && u !== 'about:blank') s._lastUrl = u; } catch (e) {}+      touched++;+    }+  }+  console.log(+    `[disconnect] profile "${profileName}" disconnected — ${touched} session(s) marked as awaiting reconnect. ` ++    `Run pup_rescan to recover; the 30s health check will also auto-attempt.`+  );+}++// v1.6.1+: scan the running process list for a Chrome process whose+// command line includes `--user-data-dir=...<profileName>`. Returns+// `[{pid, cdpPort}, ...]` for every match. Used as a third-tier fallback+// when neither the on-disk session file nor the profile's+// DevToolsActivePort file knows about an alive Chrome — which is the+// canonical sleep/wake symptom (DevToolsActivePort is rewritten+// transiently and can be missing after wake).+async function findChromesByProfile(profileName) {+  // Use Windows tasklist + wmic equivalent OR PowerShell on Windows;+  // ps + grep elsewhere. Both return PID + full command line so we can+  // parse out --remote-debugging-port=<NNNN>.+  const matches = [];+  try {+    if (process.platform === 'win32') {+      // PowerShell's CIM is the most reliable way to get full command+      // lines on Windows. Fall back to wmic if PowerShell isn't+      // available (very rare on modern Windows). We use a single+      // -Command invocation so child-process startup cost is paid once.+      const { spawnSync } = require('child_process');+      const result = spawnSync('powershell', [+        '-NoProfile',+        '-Command',+        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match '${psRegexEscape(profileName)}' } | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`,+      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });+      if (result.stdout) {+        let parsed;+        try {+          parsed = JSON.parse(result.stdout);+        } catch {+          parsed = null;+        }+        const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);+        for (const item of items) {+          const cmd = item.CommandLine || '';+          // Only consider the parent Chrome process — skip --type=renderer+          // / --type=gpu-process / --type=crashpad-handler etc. They share+          // the user-data-dir but aren't the listening parent.+          if (/--type=/.test(cmd)) continue;+          // Must look like a pup-managed Chrome — has --remote-debugging-port.+          const portMatch = cmd.match(/--remote-debugging-port=(\d+)/);+          if (!portMatch) continue;+          // Must reference our profile dir specifically (not just the name+          // appearing somewhere in args).+          const profileNeedle = path.join(PROFILES_DIR, profileName).toLowerCase();+          if (!cmd.toLowerCase().includes(profileNeedle.toLowerCase())) continue;+          matches.push({ pid: item.ProcessId, cdpPort: parseInt(portMatch[1], 10) });+        }+      }+    } else {+      // POSIX: ps + grep. Same logic, simpler shape.+      const { spawnSync } = require('child_process');+      const result = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf8', timeout: 5_000 });+      if (result.stdout) {+        for (const line of result.stdout.split('\n')) {+          // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).+          if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue;+          if (/--type=/.test(line)) continue;+          const portMatch = line.match(/--remote-debugging-port=(\d+)/);+          if (!portMatch) continue;+          const profileNeedle = path.join(PROFILES_DIR, profileName);+          if (!line.includes(profileNeedle)) continue;+          const pidMatch = line.trim().match(/^(\d+)/);+          if (!pidMatch) continue;+          matches.push({ pid: parseInt(pidMatch[1], 10), cdpPort: parseInt(portMatch[1], 10) });+        }+      }+    }+  } catch (e) {+    console.log(`[findChromesByProfile] scan failed for "${profileName}": ${e.message}`);+  }+  return matches;+}++// v1.6.1+: kill every Chrome process associated with a profile, then+// remove the lock files Chrome leaves behind. Used before relaunching+// when a previous Chrome died unexpectedly (sleep/wake / crash) and+// left the profile in a state where a fresh `puppeteer.launch()` would+// exit immediately with "Chrome process exited immediately."+//+// Lock files we clean (cross-platform — Chrome creates a different set+// per OS):+//   - lockfile          (Windows — Chrome's own profile lock)+//   - SingletonLock     (Linux/macOS — Chrome's instance singleton)+//   - SingletonSocket   (Linux/macOS — same)+//   - SingletonCookie   (Linux/macOS — same)+//   - DevToolsActivePort (Chrome rotates this — stale value points at a+//     dead Chrome; we delete to force a re-write on next launch)+async function cleanProfileLocks(profileName) {+  const profileDir = path.join(PROFILES_DIR, profileName);+  if (!fs.existsSync(profileDir)) {+    return { cleaned: [], note: 'profile directory does not exist' };+  }+  const lockNames = [+    'lockfile',+    'SingletonLock',+    'SingletonSocket',+    'SingletonCookie',+    'DevToolsActivePort',+  ];+  const cleaned = [];+  for (const name of lockNames) {+    const p = path.join(profileDir, name);+    try {+      // unlinkSync errors if missing — that's fine, ignore.+      fs.unlinkSync(p);+      cleaned.push(name);+    } catch {}+  }+  console.log(`[cleanProfileLocks] profile "${profileName}": cleaned ${JSON.stringify(cleaned)}`);+  return { cleaned };+}++// v1.6.1+: kill every Chrome process matching this profile. Called+// before clean+relaunch when a stale Chrome is holding the profile.+// Idempotent — does nothing if no chrome processes match.+async function killOrphanChromesForProfile(profileName) {+  const found = await findChromesByProfile(profileName);+  let killed = 0;+  for (const { pid } of found) {+    try {+      if (process.platform === 'win32') {+        const { spawnSync } = require('child_process');+        spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { timeout: 5_000, windowsHide: true });+      } else {+        process.kill(pid, 'SIGKILL');+      }+      killed++;+    } catch (e) {+      console.log(`[killOrphan] failed to kill PID ${pid}: ${e.message}`);+    }+  }+  if (killed > 0) {+    console.log(`[killOrphan] killed ${killed} Chrome process tree(s) for profile "${profileName}"`);+    // Give the OS a beat to release file handles before the caller+    // tries to delete profile locks / relaunch.+    await new Promise(r => setTimeout(r, 1500));+  }+  return { killed };+}++// Try to puppeteer.connect to an already-alive Chrome for `profileName`,+// using the on-disk session file's recorded cdpPort + DevToolsActivePort+// fallback. Returns the Browser on success, null on failure. Caller is+// responsible for setting up listeners (handleBrowserDisconnect, popup+// tracking, default permissions). Idempotent — if a live entry already+// exists in `browsers`, returns that one.+async function attemptReconnectProfile(profileName) {+  const existing = browsers.get(profileName);+  if (existing && existing.browser.isConnected()) {+    return existing.browser;+  }+  // Pick any session file for this profile to discover cdpPort / pid.+  const files = listSessionFiles().filter(sf => sf.profile === profileName);+  let port = null, pid = null;+  for (const sf of files) {+    if (sf.cdpPort) port = port || sf.cdpPort;+    if (sf.pid) pid = pid || sf.pid;+  }+  // Fall back to DevToolsActivePort file in the profile dir.+  if (!port) {+    try {+      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');+      port = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;+    } catch {}+  }+  // v1.6.1+: third-tier fallback — scan running Chrome processes for one+  // whose --user-data-dir matches this profile. This catches the canonical+  // sleep/wake symptom: Chrome is alive, has its CDP port open and serving+  // /json/version, but the bridge has no on-disk record of it (e.g. session+  // file was deleted by an old aggressive disconnect handler before v1.5.1,+  // OR the bridge restarted after launch). See findChromesByProfile.+  if (!port) {+    const found = await findChromesByProfile(profileName);+    if (found.length > 0) {+      port = found[0].cdpPort;+      pid = pid || found[0].pid;+      console.log(`[reconnect] discovered orphan chrome via cmdline scan: profile="${profileName}" pid=${pid} port=${port}`);+    }+  }+  if (!port) {+    console.log(`[reconnect] no cdpPort known for profile "${profileName}" — cannot reconnect`);+    return null;+  }+  // Quick liveness probe with a 3s timeout (sleep/wake can leave half-open+  // sockets that hang fetch indefinitely; we'd rather fail fast and let+  // the next probe try again).+  const ac = new AbortController();+  const tid = setTimeout(() => ac.abort(), 3000);+  let ok = false;+  try {+    const resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });+    ok = !!(resp && resp.ok);+  } catch {} finally {+    clearTimeout(tid);+  }+  if (!ok) {+    console.log(`[reconnect] CDP port ${port} for profile "${profileName}" not responding`);+    return null;+  }+  try {+    const 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)),+    ]);+    if (pid) browser._detachedPid = pid;+    browser._cdpPort = port;+    browsers.set(profileName, { browser, refCount: 0 });+    wirePopupTracking(browser, profileName);+    browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));+    console.log(`[reconnect] reconnected to Chrome on port ${port} for profile "${profileName}"`);+    return browser;+  } catch (e) {+    console.log(`[reconnect] puppeteer.connect failed for profile "${profileName}" port ${port}: ${e.message}`);+    return null;+  }+}++// Walk every page in `browser` and rebuild `sessions` entries for any+// page whose title carries a `(session: X)` tag. Pages without a tag+// are left alone unless `adoptOrphans:true` is passed, in which case+// they're attached under a generated sessionId so the caller can drive+// them. Idempotent — pages already attached to a live session are left+// in place.+// v2.0.138 (root cause of John's find_window flood): pages that are NEVER user content and must+// never be adopted as windows. chrome-extension:// covers pup's OWN toolbar/annotate/identity+// popup bubbles — every toolbar click spawns popup.html as a page target, the reclaim sweep saw+// it "unowned" twice and minted a ghost adom-you-win**** session for it, whose hwnd (a popup+// bubble with no title tag) could never resolve — the thing every sweep then hunted forever.+function isNonAdoptableUrl(u) {+  // v2.0.158: data: URLs are internal (the thread-tile render page uses one) — never a user window.+  return !u || u === 'about:blank' || /^(devtools|chrome|edge|chrome-extension|chrome-untrusted|extension|view-source|data):/i.test(u);+}+async function rescanProfile(browser, profileName, { adoptOrphans = false } = {}) {+  const pages = await browser.pages();+  // v1.9.179: key the "already attached" map on the CDP TARGET ID, not the page OBJECT. On a+  // reconnect (bridge restart / crash recovery) puppeteer hands back a BRAND-NEW Page object for the+  // SAME Chrome tab, so an object-keyed map never matched and re-attached the tab as a duplicate —+  // which is how a 1-tab window grew to 4 identical entries. Target ids survive reconnects, so this+  // matches the existing tab and skips it instead of duplicating. (Falls back to the object when a+  // target id can't be read, preserving the old behaviour for that edge.)+  const existingPageTab = new Map();+  const keyFor = (pg) => _pageTargetId(pg) || pg;+  for (const s of sessions.values()) {+    if (s.profileName !== profileName) continue;+    for (const t of s.tabs) existingPageTab.set(keyFor(t.page), { sid: null, t });+  }+  for (const [sid, s] of sessions.entries()) {+    if (s.profileName !== profileName) continue;+    for (const t of s.tabs) {+      const cur = existingPageTab.get(keyFor(t.page));+      if (cur) cur.sid = sid;+    }+  }+  let reattached = 0, adopted = 0, alreadyLive = 0;+  const seenSessions = new Set();+  for (const page of pages) {+    if (existingPageTab.has(keyFor(page))) {+      alreadyLive++;+      const cur = existingPageTab.get(keyFor(page));+      if (cur && cur.sid) seenSessions.add(cur.sid);+      continue;+    }+    let title = '';+    try { title = await page.title(); } catch { /* page closed during rescan */ continue; }+    // v1.9.151: match by window.name FIRST — the durable carrier. Title tags die under SPA+    // title churn (the espn mis-bind); window.name survives every same-tab navigation.+    let sessionId = null;+    try {+      const wn = await page.evaluate(() => window.name);+      if (wn && typeof wn === 'string' && wn.startsWith('pup:')) sessionId = wn.slice(4).trim() || null;+    } catch (e) {}+    if (!sessionId) sessionId = parseSessionIdFromTitle(title);+    if (!sessionId) {+      // No tag — could be a freshly-opened tab the user navigated to via+      // Chrome's New Tab Page, or a page the bridge already managed but+      // whose title got replaced by a script before the MutationObserver+      // could re-inject. Adopt only when explicitly asked — and NEVER a+      // non-user page (extension popups, devtools, chrome:// UI): those are+      // transient browser chrome, not windows (v2.0.138, the ghost source).+      let _au = ''; try { _au = page.url() || ''; } catch (e) {}+      if (isNonAdoptableUrl(_au)) continue;+      if (!adoptOrphans) continue;+      sessionId = `orphan-${profileName}-${Date.now()}-${adopted}`;+      adopted++;+    }+    let session = sessions.get(sessionId);+    if (!session) {+      session = createSession(profileName);+      session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution+      // v1.9.310: restore the PERSISTED OS handle on this adoption path too. Without the width-wiggle,+      // a re-adopted window on a MULTI-window profile has no way to re-derive its handle — but the+      // window kept the SAME hwnd across the respawn, so the persisted value is still correct (and+      // hwndBelongsToPup validates it before any use). This is what keeps recovered windows GREEN+      // instead of red after the wiggle removal. recoverSessions already did this; rescan now matches.+      try { const _sf = readSessionFile(sessionId); if (_sf && _sf._hwnd) session._hwnd = Number(_sf._hwnd); } catch (e) {}+      sessions.set(sessionId, session);+    } else if (session.profileName !== profileName) {+      // SessionId collision across profiles — shouldn't happen in practice+      // since profile defaults to the durable shared \'adom-you\' profile (logins/localStorage persist + are shared across all pup windows); isolated:true for a private throwaway, but guard anyway. Skip rather+      // than clobber.+      console.log(`[rescan] sessionId "${sessionId}" found in profile "${session.profileName}" but page is in profile "${profileName}" — skipping`);+      continue;+    }+    session._lostBrowser = false;+    delete session._disconnectedAt;+    delete session._healGaveUp;   // v1.9.305: a live tab is back — reset the give-up latch+    attachTab(session, sessionId, page, { makeActive: false });+    seenSessions.add(sessionId);+    reattached++;+  }+  // Sessions for this profile that have no live tab any more (Chrome may+  // have closed the tab while disconnected) keep their entries marked+  // _lostBrowser so the strict resolveSession path surfaces them clearly.+  for (const [sid, s] of sessions.entries()) {+    if (s.profileName !== profileName) continue;+    if (!seenSessions.has(sid) && s.tabs.length === 0) {+      s._lostBrowser = true;+    }+  }+  console.log(+    `[rescan] profile "${profileName}": ${reattached} reattached, ${adopted} orphans adopted, ` ++    `${alreadyLive} pages already live`+  );+  // v1.9.179: after re-binding, prune any dead/duplicate tab entries this or a prior reconnect left+  // behind, so the tab count + overlay are correct immediately (pup_rescan triggers this).+  for (const [sid, s] of sessions.entries()) {+    if (s.profileName === profileName) { try { await reconcileSessionTabs(s, sid); } catch (e) {} }+  }+  // v1.9.305 (John: "we came back from sleep and lost all pup windows"): the fix for the reaper-vs-+  // recovery RACE. On wake, rescan reattached a page and cleared _lostBrowser=false, but the prune+  // above then dropped that page as the DEAD pre-sleep tab — leaving the session at 0 live tabs with+  // _lostBrowser already false. The 8s reaper's guard is `_lostBrowser && _disconnectedAt<120s`, so it+  // REAPED the recoverable session before the 20s healer could relaunch it (killed pup-dashboard ++  // noreply-bounce-admin; only hd-wiki-pages won the race). Re-mark any now-empty session as lost and+  // refresh its heal window, so the reaper leaves it alone and the healer brings it back at its URL.+  for (const [sid, s] of sessions.entries()) {+    if (s.profileName !== profileName) continue;+    const liveTabs = (s.tabs || []).filter(t => { try { return t && t.page && !t.page.isClosed(); } catch (e) { return false; } }).length;+    if (liveTabs === 0 && !s._healGaveUp) { s._lostBrowser = true; s._disconnectedAt = Date.now(); }+  }+  // v1.9.243: repaint overlays IMMEDIATELY on reattach. A bridge restart wipes in-memory painted+  // state while the REAL taskbar keeps whatever was on it — the dashboard and taskbar diverge until+  // something repaints (John: "the taskbar icon you show for icon 1 is not consistent with what you+  // show in the windows taskbar"). Converge at the source: every reattached session repaints now, so+  // model == glass within seconds of a restart, not minutes.+  for (const sid of seenSessions) {+    const s2 = sessions.get(sid);+    if (s2 && (s2.tabs || []).length) setTimeout(() => { applyAppOverlay(s2, sid).catch(() => {}); }, 1500);+  }+  // v1.8.57: after a reattach, self-heal a window stranded off-screen by an older build's+  // failed park (one heal per profile — all this profile's sessions share the window).+  if (reattached > 0) {+    for (const sid of seenSessions) {+      const s = sessions.get(sid);+      if (s && s.tabs.length > 0) { healOffscreenWindow(s, sid).catch(() => {}); break; }+    }+  }+  return { reattached, adopted, alreadyLive };+}++// Walk every known profile (in-memory + on-disk) and try to rebuild a+// complete session map. Used by pup_rescan and by the 30s health+// check when it detects a disconnected browser whose Chrome PID is+// still alive. Returns aggregated stats for the response payload.+//+// v1.6.1+: also discovers profiles via a running-process scan. Catches+// the case where the bridge restarted (or was launched fresh after a+// crash) and a Chrome window is still alive on the desktop with no+// in-memory or on-disk record. The chipsmith-after-sleep scenario hit+// exactly this: Chrome alive, bridge had nothing for it.+async function rescanAllProfiles({ adoptOrphans = false } = {}) {+  const profileNames = new Set();+  for (const p of browsers.keys()) profileNames.add(p);+  for (const sf of listSessionFiles()) profileNames.add(sf.profile);+  // v1.6.1: scan for orphan Chrome processes running with a profile dir+  // under our PROFILES_DIR — they're pup-managed Chromes that the bridge+  // has lost track of. Each match is a profile we should try to recover.+  try {+    if (process.platform === 'win32') {+      const { spawnSync } = require('child_process');+      const profilesDir = psRegexEscape(PROFILES_DIR);+      const result = spawnSync('powershell', [+        '-NoProfile', '-Command',+        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match 'remote-debugging-port' -and $_.CommandLine -match '${profilesDir}' -and $_.CommandLine -notmatch '--type=' } | ForEach-Object { if ($_.CommandLine -match 'user-data-dir=([^"]*?)(?=\\s+--|"$|$)') { Split-Path -Leaf $matches[1] } } | Sort-Object -Unique`,+      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });+      if (result.stdout) {+        for (const line of result.stdout.split('\n')) {+          const name = line.trim().replace(/['"]/g, '');+          if (name) profileNames.add(name);+        }+      }+    } else {+      // POSIX equivalent — quick ps + grep + extract.+      const { spawnSync } = require('child_process');+      const result = spawnSync('ps', ['-eo', 'command'], { encoding: 'utf8', timeout: 5_000 });+      if (result.stdout) {+        const seen = new Set();+        for (const line of result.stdout.split('\n')) {+          // Case-insensitive: mac system Chrome is ".../MacOS/Google Chrome" (no lowercase hit).+          if (!/chrome|msedge|Microsoft Edge/i.test(line)) continue;+          if (line.includes('--type=')) continue;+          if (!line.includes('remote-debugging-port')) continue;+          if (!line.includes(PROFILES_DIR)) continue;+          const m = line.match(new RegExp(`user-data-dir=([^\\s]+/${path.basename(PROFILES_DIR)}/[^\\s]+)`));+          if (m) {+            const name = path.basename(m[1]);+            if (!seen.has(name)) { seen.add(name); profileNames.add(name); }+          }+        }+      }+    }+  } catch (e) {+    console.log(`[rescanAll] orphan process scan failed: ${e.message}`);+  }+  let totalReattached = 0, totalAdopted = 0, profilesScanned = 0, profilesReconnected = 0, profilesUnreachable = 0;+  for (const profileName of profileNames) {+    let browser = browsers.get(profileName)?.browser;+    if (!browser || !browser.isConnected()) {+      browser = await attemptReconnectProfile(profileName);+      if (browser) profilesReconnected++;+    }+    if (!browser) {+      profilesUnreachable++;+      // Mark any in-memory sessions for this profile as still disconnected+      for (const s of sessions.values()) {+        if (s.profileName === profileName) s._lostBrowser = true;+      }+      continue;+    }+    try {+      const r = await rescanProfile(browser, profileName, { adoptOrphans });+      totalReattached += r.reattached;+      totalAdopted += r.adopted;+      profilesScanned++;+    } catch (e) {+      console.log(`[rescan] profile "${profileName}" rescan threw: ${e.message}`);+    }+  }+  return { totalReattached, totalAdopted, profilesScanned, profilesReconnected, profilesUnreachable };+}++// Recover sessions from disk on startup — reconnect to existing Chrome processes+// v1.9.45: crash sentinel. Written before the risky recovery ops for ONE session and removed+// after that session settles, so a leftover file at boot names exactly the session that killed us.+const RECOVERY_SENTINEL = path.join(SESSIONS_DIR, '.recovery-in-flight');+let _poisonSessionId = null;++// v2.0.15 (#557 ask 2): TIMEOUT every await in recovery that can hang on a wedged/orphaned Chrome+// (fetch /json/version, puppeteer.connect, browser.pages(), page.evaluate). Since v2.0.13 recovery+// runs AFTER the port binds (fire-and-forget), so a hang can no longer block startup or the health+// probe — but it could still wedge the recovery pass itself forever: sessions half-recovered, the+// RECOVERY_SENTINEL left naming an innocent session, and the next boot quarantining it. Every risky+// await now races a deadline and falls back, so one wedged window costs seconds, never the pass.+// Rejections resolve to the fallback too (callers treated them as fallbacks already).+const _recoverTimeout = (p, ms, fallback) => Promise.race([+  Promise.resolve(p).catch(() => fallback),+  new Promise(r => setTimeout(() => r(fallback), ms)),+]);++const _recoveryProbeMemo = new Map();   // v2.0.392: pid -> probe verdict, one per recovery pass+async function recoverSessions() {+  _recoveryProbeMemo.clear();+  const files = listSessionFiles();+  try {+    if (fs.existsSync(RECOVERY_SENTINEL)) {+      _poisonSessionId = fs.readFileSync(RECOVERY_SENTINEL, 'utf8').trim() || null;+      if (_poisonSessionId) console.error(`[recovery] last boot died while recovering "${_poisonSessionId}" — that ONE session will be quarantined`);+      fs.unlinkSync(RECOVERY_SENTINEL);+    }+  } catch (e) {}+  if (files.length === 0) return;+  // v2.0.45: claim every persisted handle UP FRONT, before any recovery/resolve can race — this is+  // what stops an early-resolving session from stealing a handle whose owner recovers later.+  for (const sf0 of files) { try { if (sf0._hwnd && sf0.sessionId) _persistedHwndClaims.set(Number(sf0._hwnd), sf0.sessionId); } catch (e) {} }+  console.log(`Recovering ${files.length} session(s) from disk... (${_persistedHwndClaims.size} persisted handle claims pre-loaded)`);+  // v1.9.154: pages already bound in THIS recovery pass — two sessions must never share a page+  // (the espn double-bind: a poisoned session file URL-matched another session's page).+  const _recoveryClaimed = new Set();++  for (const sf of files) {+    const { sessionId, profile: profileName, pid, cdpPort, url } = sf;++    // Skip if already in memory+    if (sessions.has(sessionId)) continue;++    // v1.9.6: PERSISTENT CRASH CIRCUIT BREAKER. A NATIVE crash during recovery (inside+    // puppeteer.connect / a CDP call / a native module) kills the process HARD — before the+    // per-session try/catch below can delete the file — so the SAME poison session file+    // re-crashes pup on EVERY boot: the ~60s respawn loop John hit 2026-07-19 (AD's lifecycle log+    // showed spawn-with-no-reap every minute; in-process try/catch can't stop a native crash).+    // Fix: persist a recover-attempt marker BEFORE the risky ops. A file that already has an+    // INCOMPLETE recovery attempt (crashed us last boot) is QUARANTINED — deleted, not retried.+    // A recovery that FAILS gracefully deletes its own file (catch below), and a recovery that+    // SUCCEEDS clears the marker (saveSessionFile rewrites without it) — so a leftover marker+    // means "this file crashed recovery," and one strike quarantines it. Bounds any poison file+    // to a single crash instead of an unbounded loop.+    // v1.9.45: the per-session `recoverAttempts` strike above was WRONG and cost John all 6 of his+    // windows. It quarantined any file carrying an unfinished-attempt marker, but "unfinished" is+    // not the same as "crashed": any restart that races the marker-clearing write leaves the mark+    // behind on a perfectly healthy session, so a NORMAL restart could quarantine EVERYTHING (log:+    // "Recovering 6 session(s) from disk... Recovery complete. 0 session(s) active"). Crash+    // protection must not be able to delete healthy sessions — that is a worse failure than the+    // respawn loop it guards against.+    // The evidence we actually need is WHICH session was in flight when the process died. That is+    // one global sentinel, written before the risky ops and removed after, naming exactly one+    // session. A crash leaves it pointing at the poison file; everything else recovers untouched.+    if (_poisonSessionId === sessionId) {+      console.error(`Session "${sessionId}" was mid-recovery when pup died last boot (native crash) — QUARANTINING just this one; all other sessions still recover`);+      deleteSessionFile(sessionId);+      continue;+    }+    try { fs.writeFileSync(RECOVERY_SENTINEL, sessionId); } catch (e) {}++    // Check if Chrome is still alive+    if (!isProcessAlive(pid)) {+      console.log(`Session "${sessionId}" (PID ${pid}) is dead — removing stale file`);+      deleteSessionFile(sessionId);+      continue;+    }++    // Try to reconnect via CDP+    if (!cdpPort) {+      // Try reading DevToolsActivePort from profile dir+      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');+      try {+        const portData = fs.readFileSync(portFile, 'utf8').trim();+        sf.cdpPort = parseInt(portData.split('\n')[0], 10) || null;+      } catch {}+    }++    const port = sf.cdpPort || cdpPort;+    if (!port) {+      console.log(`Session "${sessionId}" has no CDP port — removing stale file`);+      deleteSessionFile(sessionId);+      continue;+    }++    try {+      // v2.0.392 (release sweep on winvm, 2026-09-04): this used to be ONE 5s probe, and a miss+      // meant SIGKILL of the Chrome PID plus deleting the session file — per session, so seven+      // sessions each tried to kill the same shared Chrome. On a 4-vCPU VM with ten heavy tabs+      // the devtools endpoint simply answered slowly, and pup destroyed nine of the user's windows+      // during a routine bridge update (both laptops answered fast and survived the same restart).+      // A SLOW Chrome is not an orphaned Chrome. Recovery now (a) probes with escalation, 5s -> 15s+      // -> 30s, (b) memoizes ONE verdict per PID for the whole pass, and (c) on a persistent miss+      // NEVER kills and NEVER deletes: the session file stays, the session is left for the 30s+      // health loop / pup_rescan to reconnect. A truly dead process is already handled above by+      // isProcessAlive; a live process that will not answer keeps its windows, which the user can+      // see and close themselves. Destroying visible windows is the worse failure, every time.+      if (!_recoveryProbeMemo.has(pid)) {+        let ok = false;+        for (const cap of [5000, 15000, 30000]) {+          const resp = await _recoverTimeout(fetch(`http://127.0.0.1:${port}/json/version`), cap, null);+          if (resp && resp.ok) { ok = true; break; }+          console.log(`Session "${sessionId}" CDP port ${port} did not answer within ${cap / 1000}s — retrying with a longer cap (Chrome PID ${pid} is alive; a slow Chrome is not an orphan)`);+        }+        _recoveryProbeMemo.set(pid, ok);+      }+      if (!_recoveryProbeMemo.get(pid)) {+        console.log(`Session "${sessionId}" CDP port ${port} still not answering after 50s of probes — LEAVING Chrome PID ${pid} and this session file alone (awaiting reconnect; the health loop and pup_rescan keep trying)`);+        continue;+      }++      // Reconnect browser if needed+      if (!browsers.has(profileName) || !browsers.get(profileName).browser.isConnected()) {+        // 15s cap — puppeteer.connect against a wedged Chrome otherwise hangs the whole pass (#557)+        const browser = await _recoverTimeout(puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }), 15000, null);+        if (!browser) throw new Error(`CDP connect to port ${port} hung or failed (15s cap) — wedged or orphaned Chrome`);+        browser._detachedPid = pid; // Preserve PID so killBrowserProcess can find it+        browser._cdpPort = port;+        browsers.set(profileName, { browser, refCount: 0 });+        wirePopupTracking(browser, profileName);+        // v1.5.1+: shared disconnect handler — keeps sessions intact for+        // recovery instead of erasing them. See handleBrowserDisconnect.+        browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));+        console.log(`Reconnected to Chrome on port ${port} for profile "${profileName}"`);+      }++      // Find the page that matches this session's URL (10s cap on pages() — same wedge risk)+      const browser = browsers.get(profileName).browser;+      let pages = await _recoverTimeout(browser.pages(), 10000, null);+      if (!pages) { console.log(`Session "${sessionId}": browser.pages() did not answer in 10s — one more try with a 30s cap before treating it as wedged`); pages = await _recoverTimeout(browser.pages(), 30000, null); }+      if (!pages) throw new Error('browser.pages() hung (10s + 30s caps) — wedged Chrome; session left for reconnect');+      let page = null;++      // v1.9.154 — THE fourth matcher, fixed (two failed binding proofs cornered it here):+      // 1) window.name FIRST: the durable identity every injector stamps since 1.9.153. A saved+      //    URL can be POISONED by a prior mis-bind (docs-writer's file said espn.com), so URL+      //    matching alone re-creates the bug forever.+      for (const p of pages) {+        if (_recoveryClaimed.has(p)) continue;+        try {+          const wn = await _recoverTimeout(p.evaluate(() => window.name), 3000, '');+          if (wn === 'pup:' + sessionId) { page = p; break; }+        } catch (e) {}+      }+      // 2) URL match — but NEVER a page another session already claimed, and never one that+      //    carries a DIFFERENT session's pup:<id> name.+      if (!page && url) {+        for (const p of pages) {+          if (_recoveryClaimed.has(p)) continue;+          try {+            if (!p.url().startsWith(url.split('?')[0])) continue;+            const wn = await _recoverTimeout(p.evaluate(() => window.name), 3000, '');+            if (wn && wn.startsWith('pup:') && wn !== 'pup:' + sessionId) continue;+            page = p; break;+          } catch (e) {}+        }+      }+      // 3) last unclaimed non-blank page that doesn't belong to someone else.+      if (!page) {+        const cands = [];+        for (const p of pages) {+          if (_recoveryClaimed.has(p)) continue;+          try {+            if (p.url() === 'about:blank') continue;+            const wn = await _recoverTimeout(p.evaluate(() => window.name), 3000, '');+            if (wn && wn.startsWith('pup:') && wn !== 'pup:' + sessionId) continue;+            cands.push(p);+          } catch (e) {}+        }+        page = cands.pop() || null;+      }+      if (page) _recoveryClaimed.add(page);+      // Last resort: any page+      if (!page && pages.length > 0) {+        page = pages[pages.length - 1];+      }++      if (!page) {+        console.log(`Session "${sessionId}" — no pages found in Chrome, removing`);+        deleteSessionFile(sessionId);+        continue;+      }++      const session = createSession(profileName);+      attachTab(session, sessionId, page);+      session._sessionId = sessionId; // v1.8.43: for AD taskbar/flash titleContains resolution+      // v1.9.181: restore the persisted foreground choice BEFORE the saveSessionFile rewrite below+      // (which reads it back out of the session) — otherwise every restart wiped the flag and the+      // park below yanked a window the user was actively using to the bottom (John, 2026-07-29).+      if (sf.foreground) { session._foreground = true; session._userActiveAt = Date.now(); }+      // v2.0.356 (issue #79): MIGRATE AWAY the 'reclaimed' sentinel on restore. Removing the line+      // that WROTE it (2.0.355) was not enough, because sessions persist and the poisoned owner is+      // restored verbatim on every start. John's laptop still showed the tile after the fix landed:+      // the bug was already on disk. Treat the sentinel as the absence it always meant, so an+      // affected machine heals on its next restart instead of carrying the tile forever.+      if (sf.owner === 'reclaimed') {+        console.log(`[migrate] session "${sf.sessionId || ''}": dropping the legacy 'reclaimed' owner sentinel (issue #79) — it is an absence, not a thread name`);+      } else if (sf.owner) session.owner = sf.owner;   // v1.9.197 BUG 4: restore ownership across restarts+      // v1.9.300: restore the persisted OS handle. The Chrome window kept the SAME hwnd across the+      // respawn, so this lets the window skip the width-wiggle entirely; resolveSessionHwndByBounds+      // still validates it via hwndBelongsToPup before any paint, so a stale/recycled handle is+      // discarded. This is the fix for "every window came back hwnd=None after a ship."+      if (sf._hwnd) {+        session._hwnd = Number(sf._hwnd);+        // v2.0.43 (mimic-ralph: EVERY recovered window came back NO-HANDLE/NO-OVERLAY after a+        // respawn): restoring the hwnd is useless if hwndBelongsToPup then REJECTS it — and it did,+        // every time, because all four ownership proofs are dead for a recovered window (no birth+        // capture in this process, stale pid tracking, dead parent, title tag removed in 2.0.24).+        // The honest proof we DO have: this session file also persisted Chrome's pid, and Chrome+        // never restarted across a pup respawn — so "persisted hwnd whose CURRENT owner pid equals+        // the persisted Chrome pid" ties the handle to the exact process it was born under. Record+        // the pid; hwndBelongsToPup accepts that pair as proof (and learns the pid for siblings).+        if (pid) session._recoveredHwndPid = Number(pid) || null;+        console.log(`[recovery] "${sessionId}" restored persisted hwnd ${sf._hwnd} (continuity proof pid ${pid || 'none'})`);+        // v2.0.357: RE-TAG on recovery. The identity property lives in Chrome's process, so it+        // normally survives a pup respawn, but it does NOT survive the case that actually matters:+        // a window that was adopted before 2.0.357 (never tagged) or one whose Chrome restarted.+        // Re-writing it here is idempotent and costs one in-process SetProp, and it means a machine+        // carrying untagged windows heals on its next restart rather than staying unresolvable.+        tagWindowSession(Number(sf._hwnd), sessionId).catch(() => {});+        // v2.0.366 (project-a5): RE-DERIVE THE EMULATION. Recovery restored the window but not its+        // emulation record, and Chrome keeps the CDP override because it lives in the browser. The+        // record defaulted to null, so every consumer confidently reported "not emulated" about a+        // window that was. Measure it back rather than guessing: a page whose innerWidth diverges+        // from its outerWidth is emulated, whatever pup happens to remember.+        (async () => {+          try {+            const t0 = (session.tabs || [])[0];+            if (!t0 || !t0.page) return;+            const m = await measureViewport(t0.page, session);+            if (m && m.emulated) {+              session._emulatedViewport = { width: m.innerWidth, height: m.innerHeight, deviceScaleFactor: m.devicePixelRatio, derived: true };+              session._lastOuterWidth = m.outerWidth;+              console.log(`[recovery] "${sessionId}" is still EMULATED at ${m.innerWidth}x${m.innerHeight} in a ${m.outerWidth}px window — record re-derived (it does not survive recovery on its own)`);+            }+          } catch (e) {}+        })();+        // #80: tell this session it went through a restart, and how many tabs came back, so a+        // thread that opened three and finds one knows WHY rather than concluding pup ate them.+        const _tabsBack = (session.tabs || []).length;+        noteSessionDisruption(session, `pup's bridge restarted (upgrade or respawn) and this window was recovered. ${_tabsBack} tab(s) came back. If you opened more than that, the missing ones were regrouped rather than closed: re-open them and they will stick. pup did not close anything.`);+      }+      // v1.9.202 (John's icon audit): a recovered window came back with NO taskbar badge. Under+      // minimalTouch the overlay is applied only on OPEN and NAVIGATE, and the re-stamp sweeps that+      // used to cover recovery are gated off — so every bridge restart left surviving windows bare+      // (drew-nsf-pcl had favicon+adom:wiki.adom.inc, then lost it). Re-wear it after recovery.+      setTimeout(() => { applyAppOverlay(session, sessionId).catch(() => {}); }, 2500);+      sessions.set(sessionId, session);+      const be = browsers.get(profileName);+      if (be) be.refCount++;+      if (!activeSessionId) activeSessionId = sessionId;++      // v1.9.6: recovered CLEANLY — rewrite the file WITHOUT the recover-attempt marker so the+      // circuit breaker doesn't quarantine a healthy session on the next boot.+      try { let u = null; try { u = page.url(); } catch (e) {} saveSessionFile(sessionId, profileName, u); } catch (e) {}+      console.log(`Recovered session "${sessionId}" (profile: ${profileName}, URL: ${page.url()})`);+      // v1.9.70 — RE-PARK ON RECOVERY. The background-by-default rule was only ever enforced on the+      // LAUNCH path. Recovery re-attaches to a window that already exists, and after a REBOOT the OS+      // restores those Chrome/Edge windows in the FOREGROUND — so every recovered pup window sat on+      // top of the user's screen and nothing ever pushed it back down. That is John's "after my reboot+      // almost all pup windows are opening in the foreground". Park unless the session was explicitly+      // left in the foreground by a user-granted raise (that state is recorded on the session file).+      if (!userIsUsingWindow(session)) {+        parkSessionWindowDirect(page, sessionId)+          .then(r => console.log(`[park] "${sessionId}" (recovered): ${r}`))+          .catch(e => console.log(`[park] "${sessionId}" (recovered) failed: ${e.message}`));+      } else {+        console.log(`[park] "${sessionId}" (recovered): SKIPPED — user had this window foregrounded (persisted flag); leaving it exactly where they had it`);+      }+    } catch (e) {+      console.error(`Failed to recover session "${sessionId}": ${e.message}`);+      deleteSessionFile(sessionId);+    }+  }+  // Every session got through its risky section, so nothing is in flight — drop the sentinel.+  // (Each iteration rewrites it, so a crash always leaves it naming the session that died.)+  try { if (fs.existsSync(RECOVERY_SENTINEL)) fs.unlinkSync(RECOVERY_SENTINEL); } catch (e) {}+  _poisonSessionId = null;+  console.log(`Recovery complete. ${sessions.size} session(s) active.`);+}++// Catch unhandled errors so server never crashes+process.on('uncaughtException', (e) => {+  console.error('Uncaught:', e.message);+});+process.on('unhandledRejection', (e) => {+  console.error('Unhandled rejection:', e);+});+// On bridge shutdown, disconnect from Chrome without killing it.+// Chrome has handleSIGINT/SIGTERM/SIGHUP: false and --remote-debugging-port=0,+// so it'll keep running. The session files on disk let the next bridge reconnect.+process.on('exit', () => {+  for (const [, be] of browsers) {+    try { be.browser.disconnect(); } catch {}+  }+});+process.on('SIGINT', () => { process.exit(0); });+process.on('SIGTERM', () => { process.exit(0); });++// v1.9.164 (John: 'you need the simulated mouse like what nb has so the user knows you're+// controlling the mouse'). An in-page animated cursor for pup's clicks: a teal Adom pointer that+// GLIDES from its last spot to the click target and PULSES a ring on click. Injected via CDP so it+// rides on top of whatever page is loaded (pointer-events:none, max z-index). Only visible when the+// window is on-screen — which is exactly when the user is watching pup drive. Best-effort; a page+// that blocks injection (rare) just doesn't show it, the click still fires.+// v1.9.196 (John: "why didn't you make it look like abe's or hd's? we should have a consistent+// cursor in all of our apps. hd's looks amazing" + "i can't even read the indicator text ... and i+// don't see the adom logo in there either").+//+// This is now HD's AI cursor, ported BYTE-FAITHFUL from the canonical source+// (adom-browser-extension/extension/src/cdp.js `cursorScript`, itself an exact match of HD's+// hd-control AI_CURSOR_JS) so the cursor is IDENTICAL across HD, the extension, and pup. My previous+// hand-drawn version was a fourth variant nobody asked for: it had NO intent label at all (the+// reasoning went only to a bottom-of-screen caption) and NO Adom mark, just a 7px letter "A".+//+// What HD's cursor gives us: the teal arrow (#00b8b0) with dark-teal outline + glow, a PULSING teal+// ring around the tip, and a readable monospace LABEL that narrates what the AI is doing right now,+// which flips to the left of the cursor near the screen edge so it never clips.+//+// ADDITION for pup (John's ask): a small ADOM MARK inside the label chip, so the user can tell it is+// ADOM driving the cursor and not some other AI process. It is the real brand asset (icons/+// pup-overlay-32.png) inlined as a data URI, not a drawn letter.+let _pupCursorMarkUri = null;+function pupCursorMarkUri() {+  if (_pupCursorMarkUri !== null) return _pupCursorMarkUri;+  try { _pupCursorMarkUri = 'data:image/png;base64,' + fs.readFileSync(path.join(__dirname, 'icons', 'pup-overlay-32.png')).toString('base64'); }+  catch (e) { _pupCursorMarkUri = ''; }+  return _pupCursorMarkUri;+}++async function hidePupCursorEverywhere(page) {+  // v1.9.198 (Bills thread): an ORPHANED cursor sat on the username field forever — old-style art,+  // never moving, while the live cursor moved around it. Two causes, both fixed here:+  //   (1) LEGACY ID. Pre-1.9.196 pup drew '#__pupCursor'; the HD port draws '#__adom_cursor__'. The+  //       new code never removed the old element, so any page that had ever hosted the old cursor+  //       kept it on screen forever.+  //   (2) PER-FRAME LEAK. The cursor is injected into whatever DOCUMENT is driven. Interacting with a+  //       field inside an iframe (Chase's iframe#logonbox) draws a cursor in THAT frame's document;+  //       when focus returns to the top frame nothing ever disposed the iframe's instance.+  // So: sweep EVERY frame and remove BOTH ids before drawing, and on explicit hide.+  const frames = (() => { try { return page.frames(); } catch (e) { return [page]; } })();+  await Promise.all(frames.map(fr => fr.evaluate(() => {+    try {+      ['__adom_cursor__', '__pupCursor', '__adom_cursor_ring__', '__adom_cursor_label__'].forEach(id => {+        const el = document.getElementById(id);+        if (el && el.parentNode) el.parentNode.removeChild(el);+      });+    } catch (e) {}+  }).catch(() => {})));+}++// v2.0.321 (#72): trusted input via a DEDICATED, TIMEOUT-BOUNDED CDP session, NOT puppeteer's+// page.mouse/keyboard. puppeteer serializes every mouse op behind an internal lock; when one+// Input.dispatchMouseEvent hangs (a click handler that awaits navigator.clipboard.writeText in an+// UNFOCUSED window neither resolves nor rejects), that lock is held forever and EVERY later dispatch+// on the page wedges — the session's input is dead until the window is recreated, because the lock+// lives in puppeteer's memory where pup_reload/pup_rescan cannot reach it. A per-tab CDP session we+// OWN, with a per-call deadline, isolates the failure: a hang times out, we DISCARD that session,+// and the next call makes a fresh one. One bad page handler can no longer poison the whole session.+async function _inputCdp(tab) {+  if (tab._inputCdp && !tab._inputCdpDead) return tab._inputCdp;+  const sess = await tab.page.target().createCDPSession();+  tab._inputCdp = sess; tab._inputCdpDead = false;+  return sess;+}+function killInputCdp(tab) {+  const sess = tab && tab._inputCdp;+  if (tab) { tab._inputCdp = null; tab._inputCdpDead = false; }+  if (sess) { try { const r = sess.detach(); if (r && r.catch) r.catch(() => {}); } catch (e) {} }+}+async function _rawInput(tab, method, params, ms = 6000) {+  const sess = await _inputCdp(tab);+  try {+    return await Promise.race([+      sess.send(method, params),+      new Promise((_, rej) => setTimeout(() => rej(new Error('input_dispatch_timeout')), ms)),+    ]);+  } catch (e) {+    // a hung/failed command leaves this CDP session with a stuck in-flight send; discard it so the+    // NEXT dispatch is clean. THIS is the #72 fix: the wedge can never persist past one call.+    killInputCdp(tab);+    if (String(e && e.message) === 'input_dispatch_timeout') { const err = new Error('input timed out after ' + ms + 'ms — a page handler hung (e.g. clipboard write in an unfocused window). The input channel was reset; retry, and if it persists the page handler is the cause.'); err.wedge = true; throw err; }+    throw e;+  }+}+const _btnMask = (b) => (b === 'right' ? 2 : b === 'middle' ? 4 : 1);+async function rawTrustedClick(tab, x, y, { button = 'left', clickCount = 1, ms = 6000 } = {}) {+  const b = button === 'right' ? 'right' : button === 'middle' ? 'middle' : 'left';+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, ms);+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: b, buttons: _btnMask(b), clickCount }, ms);+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: b, buttons: 0, clickCount }, ms);+}+async function rawTrustedMove(tab, x, y, { ms = 6000 } = {}) {+  await _rawInput(tab, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none', buttons: 0 }, ms);+}++async function showPupCursor(page, x, y, { click = true, label = null } = {}) {+  try {+    // Remove any stale instance ANYWHERE (legacy id, or one left in an iframe) before drawing.+    await hidePupCursorEverywhere(page);+    await page.evaluate((tx, ty, doClick, lbl, markUri) => {+      var id = '__adom_cursor__';+      var c = document.getElementById(id);+      if (!c) {+        c = document.createElement('div');+        c.id = id;+        c.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;transition:left 0.15s,top 0.15s;display:none;';+        var g = document.createElement('div'); // pulsing teal glow ring+        g.id = '__adom_cursor_ring__';+        g.style.cssText = 'position:absolute;top:-16px;left:-16px;width:40px;height:40px;border-radius:50%;background:rgba(0,184,176,0.15);border:2px solid rgba(0,184,176,0.4);animation:adomCurP 1.5s ease-in-out infinite;';+        c.appendChild(g);+        var a = document.createElement('div'); // the teal Adom arrow (HD's exact SVG)+        a.innerHTML = '<svg width="20" height="28" viewBox="0 0 20 28" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L1 22L6.5 16.5L10.5 26L14 24.5L10 15L17 15Z" fill="#00b8b0" stroke="#004d49" stroke-width="1.2" stroke-linejoin="round"/></svg>';+        a.style.cssText = 'position:absolute;top:0;left:0;filter:drop-shadow(0 0 6px rgba(0,184,176,0.7));';+        c.appendChild(a);+        var l = document.createElement('div'); // the intent label (+ Adom mark)+        l.id = '__adom_cursor_label__';+        l.style.cssText = 'position:absolute;top:32px;left:8px;white-space:nowrap;font-size:11px;color:#00b8b0;background:rgba(13,17,23,0.92);padding:2px 10px;border-radius:4px;border:1px solid rgba(0,184,176,0.6);font-family:monospace;display:flex;align-items:center;gap:6px;';+        c.appendChild(l);+        (document.body || document.documentElement).appendChild(c);+        if (!document.getElementById('__adom_cur_kf__')) {+          var s = document.createElement('style'); s.id = '__adom_cur_kf__';+          s.textContent = '@keyframes adomCurP{0%{transform:scale(1);opacity:0.7}50%{transform:scale(1.5);opacity:0.2}100%{transform:scale(1);opacity:0.7}}@keyframes adomCurClick{0%{transform:scale(1);opacity:0.85}45%{transform:scale(2.2);opacity:0.05}100%{transform:scale(1);opacity:0.7}}';+          document.head.appendChild(s);+        }+      }+      var lab = c.querySelector('#__adom_cursor_label__');+      if (lab) {+        var txt = (lbl != null ? lbl : 'Adom');+        // Adom mark FIRST (so "who is driving" reads before the narration), then the intent text.+        lab.innerHTML = '';+        if (markUri) {+          var im = document.createElement('img');+          im.src = markUri;+          im.style.cssText = 'width:13px;height:13px;flex:0 0 13px;display:block;';+          lab.appendChild(im);+        }+        var sp = document.createElement('span');+        sp.textContent = txt;+        lab.appendChild(sp);+        var nearRight = (tx != null) && (tx > (window.innerWidth - 250));+        if (nearRight) { lab.style.left = 'auto'; lab.style.right = '0px'; lab.style.textAlign = 'right'; }+        else { lab.style.left = '8px'; lab.style.right = 'auto'; lab.style.textAlign = 'left'; }+      }+      if (tx != null && ty != null) { c.style.left = tx + 'px'; c.style.top = ty + 'px'; }+      c.style.display = 'block';+      if (doClick) {+        var ring = c.querySelector('#__adom_cursor_ring__');+        if (ring) { ring.style.animation = 'none'; void ring.offsetWidth; ring.style.animation = 'adomCurClick 0.45s ease-out, adomCurP 1.5s ease-in-out infinite 0.45s'; }+      }+    }, x, y, click, label, pupCursorMarkUri());+  } catch (e) {}+}++// v1.9.221 (John, standing rule): A CAPTION IS NEVER SHOWN LONGER THAN 2 SECONDS. Callers had been+// passing 5s, 6s, 8s and even 15s, which parks text over the user's screen long after it has been+// read — he caught a 15s one live. The cap is enforced HERE, at the single choke point, so no caller+// can violate it regardless of what it requests. A caption is a glance, not a dialog: if something+// genuinely needs the user's attention for longer, use a toast (notify_user), which they dismiss.+// v2.0.294 (John: "you put them up so fast, i don't think you're following your own timer"): he was+// right — this cap was 2000 and silently OVERRODE every caller's stated duration (assisted-click's+// 9s/7s/5s narration, the 4s screenshot caption: all actually showed 2s). The anti-rudeness cap now+// matches the reading-time model's ceiling; callers state honest durations (word-scaled via+// captionMsFor) and this only stops a runaway from squatting past 12s.+const PUP_CAPTION_MAX_MS = 12000;+async function pupCaption(text, expiresInMs = PUP_CAPTION_MAX_MS, id = 'pup-assist', maxMs) {+  // maxMs: a USER-CHOSEN duration (settings slider) may exceed the 2s anti-rudeness cap — the cap+  // exists to stop the AI outstaying its welcome, not to override the user's own preference.+  const _cap = Number(maxMs) > 0 ? Number(maxMs) : PUP_CAPTION_MAX_MS;+  expiresInMs = Math.min(Number(expiresInMs) || _cap, _cap);+  // v1.9.244 (John: "you left the caption up forever. i've told you 100 times: 2 seconds"). The 2s+  // clamp NEVER REACHED AD: current AD's desktop_caption takes `duration`, pup sent `expiresInMs`+  // (an older AD spelling), so AD ignored it and used its 30s default. Send BOTH names so every AD+  // build honors its own; the clamp finally bites.+  // v2.0.293 (John: "you just foregrounded a pup window and never showed an ab caption"): a failed+  // caption used to vanish silently — the raise proceeded and nobody could tell WHY no caption+  // showed. Failures now log loudly; the raise still proceeds (the caption is a courtesy, but the+  // silence made every miss undebuggable).+  try {+    const _r = await chrome.abCommand('desktop_caption', { text, duration: expiresInMs, expiresInMs, id, position: 'bottom', size: 'medium' }, { timeoutMs: 4000 });+    if (!_r || _r.ok === false || _r.success === false) console.log(`[caption] desktop_caption did NOT confirm (id=${id}): ${JSON.stringify(_r || null).slice(0, 200)} — text was: ${String(text).slice(0, 80)}`);+  } catch (e) { console.log(`[caption] desktop_caption FAILED (id=${id}): ${e.message} — text was: ${String(text).slice(0, 80)}`); }+}+// v2.0.137 (John's Activity Log flooded with find_window: "why do you loop like crazy?"):+// NEGATIVE-RESULT BACKOFF for the title hunt. A session whose window cannot be resolved by title+// (a ghost adoption whose page rewrote the title tag, or a window that is simply gone) used to be+// re-hunted by EVERY periodic sweep forever — the user-activity watcher walks every session each+// tick, cachedSessionHwnd only caches SUCCESSES, and each miss fired a fresh desktop_find_window+// into ab's Activity Log. Failures now back off exponentially (1 min doubling to a 30-min cap),+// any success clears the slate, and the 5th consecutive miss surfaces the ghost once via the+// cleanup report instead of grooming it silently until the end of time.+const _hwndHuntFails = new Map();   // sessionId -> { fails, ts }+async function sessionHwnd(sessionId) {+  // v1.9.212: prefer the STORED handle — a title lookup cannot find a PDF-viewer window at all.+  try {+    const _s = sessions.get(sessionId);+    if (_s && _s._hwnd) { _hwndHuntFails.delete(sessionId); return Number(_s._hwnd); }+    if (_s) { const h = await bestSessionHwnd(_s, sessionId); if (h) { _hwndHuntFails.delete(sessionId); return h; } }+  } catch (e) {}+  const _nf = _hwndHuntFails.get(sessionId);+  if (_nf && Date.now() - _nf.ts < Math.min(60000 * Math.pow(2, _nf.fails - 1), 30 * 60000)) return null;+  const _recordMiss = () => {+    const fails = (_nf ? _nf.fails : 0) + 1;+    _hwndHuntFails.set(sessionId, { fails, ts: Date.now() });+    if (fails === 5) {+      console.log(`[hwnd-hunt] "${sessionId}" unresolvable by title 5x in a row — likely a ghost adoption or a rewritten title tag; backing off to 30-min retries`);+      recordCleanup(`window "${sessionId}" cannot be found on the desktop by its title (5 straight misses) — pup stopped hunting for it every sweep (30-min retries now). If this window is real, pup_rescan re-binds it; if it is a ghost, pup_close_window "${sessionId}" clears it.`);+    }+  };+  try {+    const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000, caller: sessionCaller(sessions.get(sessionId)) }));+    const b = fw && (fw.best || (fw.data && fw.data.best));+    const h = b && (b.hwnd != null ? Number(b.hwnd) : null);+    if (h) { _hwndHuntFails.delete(sessionId); return h; }+    _recordMiss();+    return null;+  } catch (e) { _recordMiss(); return null; }+}+// The RESPECT LOOP (John: 'detect whether they alt+tabbed away ... wait until i bring the window+// back'). Blocks until the user's foreground IS this pup window and stays so briefly, captioning+// that we are waiting so we never act while they look elsewhere. true when ready, false on timeout.+async function waitForUserReady(sessionId, hwnd, { maxMs = 120000 } = {}) {+  if (process.platform !== 'win32' || !hwnd) return true;+  const start = Date.now();+  let stableSince = 0, lastCap = 0, capCount = 0;+  // #437 (John: "an ad caption is disturbing my work"): BACKOFF, not a keep-alive loop. Show the+  // wait-for-focus assist a couple of times then go SILENT — an unattended wait can be hours, and a+  // permanent on-screen "I am waiting" nag is exactly the disturbance the caption TTL model exists to+  // prevent. Gated on the user's fgCaption pref and honoring their fgCaptionMs duration (the call site+  // already showed an initial notify_user toast + caption, so 'off' never leaves the user unaware).+  const _capOn = pupSettings().fgCaption === 'on';+  const _capMs = Math.max(500, Math.min(12000, Number(pupSettings().fgCaptionMs) || 2000));+  const MAX_CAPS = 3;   // initial reminder + at most 2 more, then stop+  while (Date.now() - start < maxMs) {+    const fg = await osGetForegroundWindow();+    if (fg === hwnd) {+      if (!stableSince) stableSince = Date.now();+      if (Date.now() - stableSince >= 1200) return true;+    } else {+      stableSince = 0;+      if (_capOn && capCount < MAX_CAPS && Date.now() - lastCap > 3500) {+        lastCap = Date.now(); capCount++;+        pupCaption('Adom is waiting: click back into THIS window when you are ready, and I will continue. (Nothing happens until you do.)', _capMs, 'pup-assist', _capMs).catch(() => {});+      }+    }+    await new Promise(r => setTimeout(r, 400));+  }+  return false;+}+function setupErrorCollection(pg, errorsArr) {+  // v1.9.163 (crash fix): bounded buffer + NO requestfailed listener. The requestfailed listener+  // enabled the CDP Network domain, whose per-request event stream is a firehose on request-heavy+  // SPAs and saturated pup's single shared CDP WebSocket, jamming every session at once. Network+  // failures are rarely the actionable error anyway; console.error + pageerror are. And the buffer+  // is now capped — a spammy page used to grow it unbounded.+  const CAP = 300;+  const push = (e) => { errorsArr.push(e); if (errorsArr.length > CAP) errorsArr.splice(0, errorsArr.length - CAP); };+  pg.on('console', msg => { if (msg.type() === 'error') push({ type: 'console.error', text: msg.text(), ts: Date.now() }); });+  pg.on('pageerror', err => push({ type: 'exception', text: err.message, ts: Date.now() }));+}++// Kill a Chrome browser process forcefully by PID+// ─── Reliable renderer/orphan reaping (close leaving core-pegging renderers → froze the desktop) ──+//+// v1.9.89 — the teardown reap is now AWAITED + VERIFIED + load-tolerant. The old sweep was+// FIRE-AND-FORGET with an 8s timeout on a FULL Win32_Process enumeration, which TIMES OUT precisely+// when the box is pegged by the runaway renderers it exists to kill (measured on AdomLapper: overall+// CPU 88-98% on 16 cores, dwm starved to 38-63%, mouse frozen, forced reboot; 6 CfT procs survived a+// pup_close, 4 --type=renderer children near 100%). Three fixes: (1) push the image-name filter to+// WMI server-side (-Filter "Name='chrome.exe' OR Name='msedge.exe'") so ONLY browser rows are+// materialized — fast even at 98% CPU; (2) AWAIT the reap so close() VERIFIES nothing survives before+// returning ok; (3) return chromeProcsRemaining so the caller KNOWS the teardown completed. Still+// non-blocking: async execFile inside an awaited Promise suspends only THIS request's async chain,+// never the event loop (the v1.9.81 rule was against execFileSync/execSync on the close path).+const _isWin = process.platform === 'win32';+// Enumerate chrome/msedge {pid, cmd} matching a PowerShell -Where clause on CommandLine. The -Filter+// makes WMI return only browser processes server-side, so this stays cheap under heavy CPU load.+function _psProcs(whereClause) {+  return new Promise((resolve) => {+    if (!_isWin) { resolve([]); return; }+    const ps = `Get-CimInstance Win32_Process -Filter "Name='chrome.exe' OR Name='msedge.exe'" | Where-Object { ${whereClause} } | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress`;+    try {+      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+        { encoding: 'utf8', timeout: 7000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }, (err, out) => {+          if (err || !out) { resolve([]); return; }+          let parsed; try { parsed = JSON.parse(out); } catch { parsed = null; }+          const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);+          resolve(items.map(i => ({ pid: i.ProcessId, cmd: i.CommandLine || '' })).filter(x => x.pid));+        });+    } catch (e) { resolve([]); }+  });+}+function _taskkillPids(pids) {+  return Promise.all([...new Set(pids || [])].map(pid => new Promise((resolve) => {+    try { execFile('taskkill', ['/F', '/T', '/PID', String(pid)], { windowsHide: true, timeout: 5000 }, () => resolve()); }+    catch (e) { resolve(); }+  })));+}+// v2.0.130 (#46 P0 — janitor killed a live browser mid-annotation): GROUND-TRUTH helpers. The+// doctrine from that incident: NEVER let a derived model (CDP page handles, session-map state)+// authorize destruction — ask the OS. These two answer "does this pid own a visible top-level+// window?" and "is this hwnd a live, visible window?" straight from user32/the process table.+// Both FAIL SAFE for their callers: on any error the pid-check returns an empty set and the+// hwnd-check returns false, which the call sites treat as "no proof of life" — they then fall+// back to today's behavior, never to a kill they would not otherwise have made.+function _pidsWithVisibleWindow(pids) {+  return new Promise((resolve) => {+    const ids = [...new Set(pids || [])].filter(Number.isFinite);+    if (!_isWin || !ids.length) { resolve(new Set()); return; }+    const ps = `Get-Process -Id ${ids.join(',')} -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -ExpandProperty Id`;+    try {+      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+        { encoding: 'utf8', timeout: 7000, windowsHide: true }, (err, out) => {+          if (err || !out) { resolve(new Set()); return; }+          resolve(new Set(out.split(/\r?\n/).map(l => Number(l.trim())).filter(Number.isFinite)));+        });+    } catch (e) { resolve(new Set()); }+  });+}+// v2.0.230: this is called from the 8s reconciler, and each call was a PowerShell spawn PLUS a+// Roslyn C# compile (~0.5s CPU). A session the ground-truth check SPARES is not deleted, so it+// re-entered the same branch on every tick — a self-sustaining burner that one stuck window could+// pin forever. Verdicts are now cached per hwnd for 60s (a window does not blink in and out of+// existence faster than that, and the reconciler is a safety net, not a realtime probe).+const _hwndAliveCache = new Map();   // hwnd -> { alive, at }+const HWND_ALIVE_TTL = 60000;+function osHwndAlive(hwnd) {+  return new Promise((resolve) => {+    const h = Number(hwnd);+    if (!_isWin || !Number.isFinite(h) || h <= 0) { resolve(false); return; }+    const c = _hwndAliveCache.get(h);+    if (c && Date.now() - c.at < HWND_ALIVE_TTL) { resolve(c.alive); return; }+    if (_hwndAliveCache.size > 200) { for (const [k, v] of _hwndAliveCache) { if (Date.now() - v.at > HWND_ALIVE_TTL) _hwndAliveCache.delete(k); } }+    const ps = `Add-Type -Namespace PW -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);'; if ([PW.U]::IsWindow([IntPtr]${h}) -and [PW.U]::IsWindowVisible([IntPtr]${h})) { 'ALIVE' }`;+    try {+      execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', ps],+        { encoding: 'utf8', timeout: 7000, windowsHide: true }, (err, out) => {+          const alive = !err && /ALIVE/.test(out || '');+          _hwndAliveCache.set(h, { alive, at: Date.now() });+          resolve(alive);+        });+    } catch (e) { resolve(false); }+  });+}+const _psLike = (p) => String(p).replace(/'/g, "''");+// Reap EVERY chrome/msedge (the parent AND any reparented --type=renderer orphan the tree-kill missed)+// still bound to this profile's userDataDir. Awaited + verified, one retry for a straggler mid-spawn.+// Profile-scoped so it can never touch the user's real browser or another session's window.+async function reapProfileChromes(profileName) {+  if (!_isWin || !profileName) return { killed: 0, remaining: 0 };+  const where = `$_.CommandLine -like '*${_psLike(path.join(PROFILES_DIR, profileName))}*'`;+  let procs = await _psProcs(where);+  const killed = procs.length;+  if (procs.length) { await _taskkillPids(procs.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }+  let remaining = await _psProcs(where);+  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = await _psProcs(where); }+  if (killed || remaining.length) console.log(`[reap] profile "${profileName}": killed ${killed}, remaining ${remaining.length}`);+  return { killed, remaining: remaining.length };+}+// Reap ORPHANED pup chrome/msedge — any process under a pup-profiles dir whose profile has NO live+// session (parent AND renderer children). Live sessions are spared by PROFILE (not just parent PID),+// so a session's own renderers are never touched. Used by pup_close (all) + boot/periodic sweeps.+async function reapOrphanPupChromes() {+  if (!_isWin) return { killed: 0, remaining: 0 };+  const liveDirs = new Set();+  for (const [, s] of sessions) { if (s && s.profileName) liveDirs.add(path.join(PROFILES_DIR, s.profileName).toLowerCase()); }+  // v1.9.92 (learned from issue #16) — NEVER SWEEP FROM AN EMPTY SESSION MAP while session FILES+  // still exist on disk. This sweep decides "orphan" as "no live session owns this profile", so an+  // empty map makes EVERY pup window an orphan and we would kill the user's live windows. The map can+  // legitimately be empty-but-pending: at boot before recoverSessions() finishes, or (pre-#16-fix)+  // after a resolver purged it. On-disk session files are the tell that windows are expected. Same+  // doctrine as the AUMID prune below: with nothing to distinguish stale from pending, doing nothing+  // is the correct answer. A genuine no-windows box has no session files, so real orphans still get+  // reaped there.+  if (!liveDirs.size) {+    let pending = 0;+    try { pending = listSessionFiles().length; } catch (e) {}+    if (pending) {+      console.log(`[reap-orphan] SKIPPED — session map is empty but ${pending} session file(s) exist (recovery pending); refusing to treat live windows as orphans`);+      return { killed: 0, remaining: 0, skipped: 'recovery_pending' };+    }+  }+  const where = `$_.CommandLine -like '*${_psLike(PROFILES_DIR)}*'`;+  const isOrphan = (p) => { const c = (p.cmd || '').toLowerCase(); for (const d of liveDirs) { if (c.includes(d)) return false; } return true; };+  let orphans = (await _psProcs(where)).filter(isOrphan);+  // v2.0.130 (#46 P0 — John lost live windows mid-annotation): "no live session owns this profile"+  // is a DERIVED verdict and it was FALSE once (session churn + respawns reaped the sessions while+  // their windows were open; the janitor then taskkilled the live Chrome under John's cursor).+  // GROUND TRUTH GATE: ask the OS which candidate pids own a visible top-level window. Any pid+  // that does is by definition NOT an orphan — someone can SEE that window — so it is spared, and+  // so is every sibling process sharing its user-data-dir (killing a spared parent's renderers+  // would crash the very window we just refused to kill). Spared windows are left for pup_rescan /+  // the health check to re-adopt. If the PS probe fails it returns an empty set, which only makes+  // this pass as permissive as the old code — never more destructive.+  const _visPids = await _pidsWithVisibleWindow(orphans.map(p => p.pid));+  if (_visPids.size) {+    const sparedDirs = [];+    for (const p of orphans) {+      if (!_visPids.has(p.pid)) continue;+      const m = (p.cmd || '').match(/user-data-dir=(?:"([^"]+)"|(\S+))/i);+      const dir = ((m && (m[1] || m[2])) || '').toLowerCase();+      if (dir) sparedDirs.push(dir);+    }+    const _spared = (p) => {+      if (_visPids.has(p.pid)) return true;+      const c = (p.cmd || '').toLowerCase();+      return sparedDirs.some(d => d && c.includes(d));+    };+    const before = orphans.length;+    orphans = orphans.filter(p => !_spared(p));+    if (before !== orphans.length) {+      console.log(`[reap-orphan] GROUND TRUTH spared ${before - orphans.length} process(es): the OS shows visible top-level window(s) on ${_visPids.size} pid(s) — "orphan" verdict overruled, leaving them for rescan/adoption`);+      recordCleanup(`janitor spared ${before - orphans.length} browser process(es) the session model called orphaned but the OS shows with VISIBLE windows — left alive for re-adoption (the #46 ground-truth gate)`);+    }+    // The retry pass below re-enumerates; make it honor the same sparing.+    var _notSpared = (p) => !_spared(p);+  } else { var _notSpared = () => true; }+  const killed = orphans.length;+  if (orphans.length) { await _taskkillPids(orphans.map(p => p.pid)); await new Promise(r => setTimeout(r, 350)); }+  let remaining = (await _psProcs(where)).filter(isOrphan).filter(_notSpared);+  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan).filter(_notSpared); }+  if (killed || remaining.length) {+    recordCleanup(`reaped ${killed} orphaned pup browser process(es) that no live session owned${remaining.length ? ` (${remaining.length} survived and may still be using CPU)` : ''} — ${liveDirs.size} live profile(s) were spared`);+  }+  return { killed, remaining: remaining.length };+}++// v2.0.129: reap LEAKED isolated profile DIRECTORIES on disk. Every isolated:true / wikiView:public+// open mints a throwaway `iso-*` profile under PROFILES_DIR; they are never reused and never meant to+// persist, but nothing deleted the directory when the window closed — so they piled up (387 found on+// one machine, each a full Chrome profile). This removes an iso-* dir only when ALL hold true:+//   (a) no LIVE session owns it, and no on-disk session FILE references it (recovery-pending safety);+//   (b) no live Chrome process is holding it (never delete an in-use dir);+//   (c) it hasn't been modified in the last 15 min (never race an in-flight isolation launch).+// Durable jars (adom-you, named profiles, _recorder) never carry the iso- prefix, so they can NEVER be+// hit. Same empty-map guard as reapOrphanPupChromes. Pass {dryRun:true} to preview without deleting.+async function reapLeakedIsolatedProfiles(opts) {+  const dryRun = !!(opts && opts.dryRun);+  const MIN_AGE_MS = 15 * 60 * 1000;+  const out = { removed: 0, scanned: 0, spared: 0, held: 0, tooFresh: 0, removedNames: [], dryRun };+  let entries = [];+  try { entries = fs.readdirSync(PROFILES_DIR, { withFileTypes: true }).filter(d => d.isDirectory() && /^iso-/.test(d.name)).map(d => d.name); }+  catch (e) { return out; }+  out.scanned = entries.length;+  if (!entries.length) return out;+  // Spare anything a live session OR a session file references.+  const spared = new Set();+  for (const [, s] of sessions) { if (s && s.profileName) spared.add(s.profileName); }+  let sessFiles = [];+  try { sessFiles = listSessionFiles(); } catch (e) {}+  for (const sf of sessFiles) { if (sf && sf.profile) spared.add(sf.profile); }+  // Empty-map + pending-files guard: no live sessions but session files exist → recovery is pending;+  // an iso session could still be re-adopted, so do NOT reap this cycle (mirrors the process reaper).+  if (!sessions.size && sessFiles.length) { out.skipped = 'recovery_pending'; return out; }+  // Which iso profiles have a live Chrome process holding them (never delete an in-use dir).+  const heldDirs = new Set();+  if (_isWin) {+    try {+      const procs = await _psProcs(`$_.CommandLine -like '*${_psLike(PROFILES_DIR)}*'`);+      for (const p of procs) { const c = (p.cmd || '').toLowerCase(); for (const n of entries) { if (c.includes(path.join(PROFILES_DIR, n).toLowerCase())) heldDirs.add(n); } }+    } catch (e) {}+  }+  const now = Date.now();+  for (const name of entries) {+    if (spared.has(name)) { out.spared++; continue; }+    if (heldDirs.has(name)) { out.held++; continue; }+    const dir = path.join(PROFILES_DIR, name);+    let st; try { st = fs.statSync(dir); } catch (e) { continue; }+    if (now - st.mtimeMs < MIN_AGE_MS) { out.tooFresh++; continue; }+    if (dryRun) { out.removed++; if (out.removedNames.length < 50) out.removedNames.push(name); continue; }+    try { fs.rmSync(dir, { recursive: true, force: true }); out.removed++; if (out.removedNames.length < 50) out.removedNames.push(name); }+    catch (e) { /* locked / in-use — leave it for a later cycle */ }+  }+  if (out.removed && !dryRun) recordCleanup(`reaped ${out.removed} leaked isolated profile(s) with no live session (${out.spared} spared, ${out.held} in-use, ${out.tooFresh} too fresh) — durable jars untouched`);+  return out;+}++// Tear down the browser for a profile and VERIFY it. Returns {killed, remaining}; remaining MUST be 0.+// Falls back to browser.close() if PID isn't available. `browser` may be null when we're just cleaning+// up an orphaned lock file and have no live puppeteer handle.+async function killBrowserProcess(browser, profileName) {+  if (!profileName) {+    console.log('killBrowserProcess called without profileName — skipping');+    return { killed: 0, remaining: 0 };+  }+  let pid = null;+  if (browser) {+    pid = browser._detachedPid || null;+    if (!pid) {+      try { pid = browser.process()?.pid; } catch (e) { /* connected browser has no process() */ }+    }+    // Graceful close, but never let a wedged CDP make close() hang the whole teardown (the reap+    // below is what actually guarantees the processes die, so a slow/hung close must not block it).+    // v2.0.116 (John): give it a longer budget than before — a clean exit is what FLUSHES the+    // profile's prefs to disk (toolbar pins, developer_mode, saved logins), and the old 3s race+    // could force-kill mid-flush, which is why the pinned toolbar tiles were lost every relaunch.+    try { await Promise.race([browser.close(), new Promise(r => setTimeout(r, 8000))]); }+    catch (e) { console.log(`Graceful close failed for profile "${profileName}": ${e.message}`); }+  }++  // If no browser handle, try to recover PID from session files on disk+  if (!pid) {+    try {+      const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);+      if (sFiles.length > 0) pid = sFiles[0].pid;+    } catch {}+  }++  // v2.0.116: DO NOT force-kill a process that already exited on its own — that clean exit is+  // exactly what let Chrome finish writing its prefs. Poll the PID for a short grace window after+  // close() resolves; only tree-kill a genuinely WEDGED process. The profile-scoped reap below+  // still guarantees no reparented renderer lingers (those never write the profile prefs).+  const _pidAlive = (p) => { try { process.kill(p, 0); return true; } catch (e) { return e.code === 'EPERM'; } };+  if (pid) {+    let alive = _pidAlive(pid);+    for (let i = 0; i < 12 && alive; i++) { await new Promise(r => setTimeout(r, 250)); alive = _pidAlive(pid); }+    if (!alive) {+      console.log(`Chrome PID ${pid} for profile "${profileName}" exited cleanly — prefs (pins/dev-mode/logins) flushed`);+    } else {+      if (_isWin) { await _taskkillPids([pid]); }+      else { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }+      console.log(`Force-killed WEDGED Chrome PID ${pid} for profile "${profileName}" (graceful close did not exit it in time)`);+    }+  }++  // Then the RELIABLE, AWAITED, VERIFIED profile-scoped reap. taskkill /T kills the parent's tree, but+  // a crashed/backgrounded renderer can be reparented and survive it, lingering — or pegging a full+  // core. This awaited sweep catches those and confirms none remain, which is what makes close() honest.+  const rep = await reapProfileChromes(profileName);++  // Remove any stale SingletonLock so the next open on this profile starts clean.+  try { fs.unlinkSync(path.join(PROFILES_DIR, profileName, 'SingletonLock')); } catch { /* absent/locked */ }+  return rep;+}++// ─── Profile session-restore prevention ──────────────────────────────────+//+// Persistent profiles (--user-data-dir) make Chrome remember the previous+// session. Without intervention, Chrome's "restore previous session" path+// re-opens every tab the user had on last close, AND if Chrome thinks the+// last exit was unclean (which happens whenever puppeteer's child process+// is force-killed) it pops the "Restore tabs?" bubble too. End result is+// stale tab buildup across close/reopen cycles — 8+ tabs after a few iters.+//+// The reliable fix is two-pronged at profile level:+//+// 1. Patch <userDataDir>/Default/Preferences before launch:+//    - profile.exit_type = "Normal"+//    - profile.exited_cleanly = true+//      → Chrome thinks the last exit was clean, skips the restore bubble.+//    - session.restore_on_startup = 5 (open new tab page)+//    - session.startup_urls = []+//      → Even if user had set "On startup → continue where you left off",+//        we override it to "Open the New Tab page".+//+// 2. Delete the session-state blobs that Chrome reads to rebuild tabs:+//    - Default/Last Session, Default/Last Tabs+//    - Default/Current Session, Default/Current Tabs+//    - Default/Sessions/    (newer Chromium session-store dir)+//    - Default/Tabs/         (newer Chromium tab-store dir)+//+// We only run this when LAUNCHING (not when reconnecting to a running+// Chrome via DevToolsActivePort). Reconnect path means Chrome is already+// alive and we shouldn't touch its profile files.+function prepareCleanProfile(userDataDir) {+  if (!userDataDir) return;+  try {+    const defaultDir = path.join(userDataDir, 'Default');+    fs.mkdirSync(defaultDir, { recursive: true });++    // 1. Patch Preferences JSON+    const prefsPath = path.join(defaultDir, 'Preferences');+    let prefs = {};+    try {+      const raw = fs.readFileSync(prefsPath, 'utf8');+      prefs = JSON.parse(raw);+    } catch (e) {+      // Missing or unreadable Preferences file = first launch; that's fine+      prefs = {};+    }+    prefs.profile = prefs.profile || {};+    // v2.0.396 (zero-firewall-prompt, WebRTC leg): the launch switch --force-webrtc-ip-handling-policy+    // is not honored reliably by CfT, and Cloudflare-style challenge pages (DigiKey) gather ICE+    // candidates, which binds paired v4/v6 UDP sockets in the network service - the bind Windows+    // prompts on. These are the exact profile prefs the enterprise WebRtcIPHandling policy writes:+    // WebRTC keeps working through TCP/proxied transport, never a raw UDP bind. pup owns this+    // profile, so writing them here (Chrome closed, pre-launch) is the sanctioned lever.+    prefs.webrtc = Object.assign({}, prefs.webrtc || {}, { ip_handling_policy: 'disable_non_proxied_udp', nonproxied_udp_enabled: false, multiple_routes_enabled: false, allow_legacy_tls_protocols: false });+    // v2.0.53: PIN the Adom Pup toolbar icons by default (MV3 action icons hide in the puzzle menu+    // unless pinned). v2.0.150 (John: "be consistent on icon order... profile at the RIGHT end...+    // might just be the order you load them"): the toolbar order IS the `pinned_extensions` ARRAY+    // order in Preferences — NOT the --load-extension order and NOT the sequence of pin API calls+    // (both proven not to control it). So we WRITE that array here, pre-launch (Chrome closed), in+    // the fixed canonical order. Each extension now carries a stable `key` in its manifest, so its+    // ID is deterministic (ADOM_EXT_IDS) regardless of staging path — the old path-hash id was+    // fragile and is gone. Canonical left→right: Pup (dots), Annotate (pencil), Identity (profile)+    // LAST so the profile tile lands rightmost, next to Chrome's own profile avatar. Any foreign+    // pinned id is preserved BEFORE our block so our Adom trio stays the rightmost group.+    try {+      if (pupSettings().toolbarExtension !== 'off') {+        prefs.extensions = prefs.extensions || {};+        const ours = [ADOM_EXT_IDS.pup, ADOM_EXT_IDS.annotate, ADOM_EXT_IDS.identity, ADOM_EXT_IDS.identityAnon];+        const prev = Array.isArray(prefs.extensions.pinned_extensions) ? prefs.extensions.pinned_extensions : [];+        const foreign = prev.filter(id => !ours.includes(id));+        prefs.extensions.pinned_extensions = [...foreign, ...ours];+      }+    } catch (e) {}+    try { prefs.browser = prefs.browser || {}; prefs.browser.promotional_tabs_enabled = false; } catch (e) {}+    prefs.profile.exit_type = 'Normal';+    prefs.profile.exited_cleanly = true;+    // v2.0.122 (John: "the save dialog is still showing... i'd like you to just always+    // automatically handle this"). Chrome's native "Save password?" bubble is OFF now — pup owns+    // credential capture (the login-capture rail, hardened in 2.0.121 to grab on password+    // blur/change so it beats cross-origin SSO redirects) and fills from its own keychain vault+    // over CDP (no dropdown, no foreground). Chrome's bubble was redundant and the exact dialog+    // John did not want. (Was on since v1.9.129, before pup had its own vault.) Autofill via+    // fillLoginForm is unaffected — it sets values directly, independent of Chrome's manager.+    prefs.credentials_enable_service = false;+    prefs.profile.password_manager_enabled = false;+    prefs.profile.password_manager_leak_detection = false;+    prefs.session = prefs.session || {};+    prefs.session.restore_on_startup = 5; // 5 = new tab page; 1 = restore last+    prefs.session.startup_urls = [];+    fs.writeFileSync(prefsPath, JSON.stringify(prefs));++    // 2. Delete session-state blobs (best effort — Chrome may not have written some)+    const blobs = ['Last Session', 'Last Tabs', 'Current Session', 'Current Tabs'];+    for (const name of blobs) {+      const p = path.join(defaultDir, name);+      try { fs.rmSync(p, { force: true }); } catch {}+    }+    const dirs = ['Sessions', 'Tabs'];+    for (const name of dirs) {+      const p = path.join(defaultDir, name);+      try { fs.rmSync(p, { recursive: true, force: true }); } catch {}+    }+  } catch (e) {+    // Profile cleanup is defense-in-depth — don't fail the launch if it errors+    console.log(`prepareCleanProfile("${userDataDir}") warning: ${e.message}`);+  }+}++// Give a pup profile a visible identity so the user can tell at a glance that a+// window is an Adom pup window (specifically the shared "adom-you" jar) rather+// than their real signed-in Chrome. John asked for this after a Ctrl+N window+// left him unsure which profile he was in.+//+// IMPORTANT — what does NOT work (tested live 2026-08-06, do not re-try):+// Chrome has NO supported way to give a LOCAL (non-Google-signed-in) profile a+// custom image avatar. The only file hook is the GAIA account picture, and+// faking it (writing "Google Profile Picture.png" + spoofing gaia_id/+// use_gaia_picture in info_cache) does NOT render the image AND makes Chrome+// think there is a broken signed-in account → a permanent "Action required"+// nag pill on the toolbar. So we never touch gaia fields. See pup-profile-identity.+//+// What we DO here: set the profile NAME (shows in the profile menu on click, no+// nag). The always-visible signal is the Adom-teal window theme, applied as a+// LAUNCH FLAG at the call site (--install-autogenerated-theme), not here.+//+// Runs pre-launch (Chrome not yet holding this dir), same as prepareCleanProfile —+// Local State can only be safely rewritten while the profile's Chrome is closed.+// v2.0.114: turn on the profile's Developer mode (so CfT enables unpacked extensions) and pin+// every Adom action icon, via the developerPrivate API on a short-lived chrome://extensions page.+// Idempotent + guarded per browser instance; entirely background (a hidden about:blank→extensions+// page opened and closed, never surfaced). Once dev mode is set, Chrome persists it in Secure+// Preferences with a valid HMAC, so later launches of this profile keep it — but running every+// launch is cheap insurance and the only path that fixes a FRESH profile (dev mode starts off).+const _extEnabledFor = new Set();+async function ensureExtensionsEnabled(browser, profileName) {+  if (_extEnabledFor.has(profileName)) return;+  let page = null;+  try {+    page = await browser.newPage();+    await page.goto('chrome://extensions', { waitUntil: 'domcontentloaded', timeout: 8000 }).catch(() => {});+    const r = await page.evaluate(() => new Promise((resolve) => {+      try {+        if (!(chrome && chrome.developerPrivate)) return resolve({ ok: false, why: 'no developerPrivate' });+        chrome.developerPrivate.updateProfileConfiguration({ inDeveloperMode: true }, () => {+          chrome.developerPrivate.getExtensionsInfo((infos) => {+            const adom = (infos || []).filter((i) => /^Adom /.test(i.name || ''));+            if (!adom.length) return resolve({ ok: true, devMode: true, pinned: 0 });+            // v2.0.147 (John: "be consistent on your icon order, users memorize these; profile+            // always at the RIGHT end, next to Chrome's own profile icon"). Chrome lays pinned+            // extension icons out in the ORDER THEY ARE PINNED; the old code pinned all three in+            // PARALLEL, so the completion order (and thus the toolbar order) was random every launch+            // — the exact inconsistency John saw. Fix: a FIXED canonical order — Pup (dots), then+            // Annotate (pencil), then Identity (profile) LAST so it lands rightmost — pinned strictly+            // SEQUENTIALLY (await each before the next) so the layout is deterministic every time.+            // v2.0.150: ORDER is now owned by the pinned_extensions array we write in+            // prepareCleanProfile (pre-launch) — that is the real control. Here we only ENSURE each+            // Adom icon is pinned (a no-op if the pref already pinned it); re-pinning never reorders,+            // so this cannot fight the canonical order. (The old unpin-then-repin dance is gone — it+            // could not control order and risked leaving an icon unpinned mid-sequence.)+            let pinned = 0;+            const pinNext = (idx) => {+              if (idx >= adom.length) return resolve({ ok: true, devMode: true, pinned, total: adom.length });+              chrome.developerPrivate.updateExtensionConfiguration({ extensionId: adom[idx].id, pinnedToToolbar: true }, () => {+                if (!chrome.runtime.lastError) pinned++;+                pinNext(idx + 1);+              });+            };+            pinNext(0);+          });+        });+      } catch (e) { resolve({ ok: false, why: e.message }); }+    }));+    if (r && r.ok) { _extEnabledFor.add(profileName); console.log(`[ext] "${profileName}": developer mode on, pinned ${r.pinned}/${r.total || 0} Adom toolbar icons`); }+    else { console.log(`[ext] "${profileName}": could not enable/pin (${r && r.why})`); }+  } catch (e) { console.log(`[ext] ensureExtensionsEnabled("${profileName}") error: ${e.message}`); }+  finally { try { if (page) await page.close(); } catch (e) {} }+}+function brandPupProfile(userDataDir, profileName, displayName) {+  if (!userDataDir) return;+  try {+    const defaultDir = path.join(userDataDir, 'Default');+    fs.mkdirSync(defaultDir, { recursive: true });++    // Local State → profile.info_cache["Default"] : name only, NO gaia fields.+    const lsPath = path.join(userDataDir, 'Local State');+    let ls = {};+    try { ls = JSON.parse(fs.readFileSync(lsPath, 'utf8')); } catch { ls = {}; }+    ls.profile = ls.profile || {};+    ls.profile.info_cache = ls.profile.info_cache || {};+    const info = ls.profile.info_cache['Default'] || {};+    info.name = displayName;+    info.shortcut_name = displayName;+    info.is_using_default_name = false;+    ls.profile.info_cache['Default'] = info;+    fs.writeFileSync(lsPath, JSON.stringify(ls));++    // Mirror the name into the profile's own Preferences (some surfaces read it+    // from here rather than Local State).+    const prefsPath = path.join(defaultDir, 'Preferences');+    let prefs = {};+    try { prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf8')); } catch { prefs = {}; }+    prefs.profile = prefs.profile || {};+    prefs.profile.name = displayName;+    prefs.profile.using_default_name = false;+    fs.writeFileSync(prefsPath, JSON.stringify(prefs));+  } catch (e) {+    console.log(`brandPupProfile("${profileName}") warning: ${e.message}`);+  }+}++// After launch, sweep any pages Chrome opened that weren't created by us.+// This is the second line of defense in case Preferences-patching missed+// some restore path. session.tabs[] tracks the pages WE registered; any+// browser.pages() entries beyond that are restored leftovers — close them.+// Keeps the page we just opened (matched by URL or by being the only one+// the bridge has registered).+async function closeRestoredLeftoverTabs(browser, keepPage) {+  let closed = 0;+  try {+    const pages = await browser.pages();+    for (const p of pages) {+      if (p === keepPage) continue;+      // v1.9.142 (John's 3-phase test found it): NEVER close a page pup itself created. Two+      // concurrent opens on the same profile each ran this cleanup and shot the SIBLING's+      // just-created page mid-goto ('Navigating frame was detached') — 9 of 10 parallel opens+      // died. Restored leftovers are pages pup did NOT create; the marker is the difference.+      if (p._pupCreated) continue;+      // Skip blank/about:blank (Chrome's default first tab) — handled separately+      let url = '';+      try { url = p.url() || ''; } catch {}+      if (url.startsWith('chrome://newtab') || url === 'about:blank' || url === '') {+        // Don't kill the blank tab here; new-tab reuse logic handles it+        continue;+      }+      try { await p.close({ runBeforeUnload: false }); closed++; } catch {}+    }+  } catch (e) {+    console.log(`closeRestoredLeftoverTabs warning: ${e.message}`);+  }+  return closed;+}++// Get or launch a Chrome browser for a given profile name+// Multiple sessions can share the same browser (as tabs) if they use the same profile+//+// v1.6.3+: full-capability default. Pup is a tool-driving surface, NOT a+// user-facing browser — every chip-fetcher-style flow needs Chrome in+// "everything allowed" mode so JS-triggered downloads don't get silently+// blocked, clipboard access works for pasting into vendor forms, and+// permission prompts don't pop up under automation.+//+// Two CDP mechanisms together give us this:+//+//   1. Browser.setPermission (per-origin permissions) — FULL DEBUG: every+//      permission Chrome accepts is GRANTED (notifications, geolocation,+//      camera, mic, midi, sensors, clipboard, wake-lock, …) so no permission+//      prompt EVER blocks a debug flow. Per-permission so an unknown/rejected+//      descriptor (e.g. clipboard-sanitized-write on some builds) only drops+//      itself, never the rest (the v1.4.12 Browser.grantPermissions array form+//      failed wholesale on Chrome 146 when one name was unrecognized).+//      Opt-out for the rare untrusted-site case: strictPermissions:true.+//+//   2. Page.setDownloadBehavior — see applyDownloadBehavior below. This+//      is the actual mechanism for unblocking scripted downloads. The+//      v1.4.12 attempt at "automaticDownloads" via grantPermissions+//      didn't help with scripted downloads — it controlled the multi-+//      file infobar, which is a different layer. setDownloadBehavior+//      tells Chrome to actually save the file when JS triggers a nav+//      to a binary content-type.+//+// Opt-out: pass { strictPermissions: true } to pup_open_window for+// sessions that genuinely need the user to gate every download (rare).+//+// History: v1.4.12 used Browser.grantPermissions(['automaticDownloads',+// 'notifications', 'clipboardReadWrite']). On Chrome 146 it failed with+// "Unknown permission type: automaticDownloads" — taking the whole+// grant down. v1.6.3 fixes by switching to Browser.setPermission per-+// permission and dropping the unhelpful automaticDownloads (which we+// replace with the actually-effective Page.setDownloadBehavior).+// FULL-DEBUG default: grant EVERYTHING Chrome will accept (John: always launch+// pup with full permissions for full debug). All 'granted', none denied. Applied+// per-permission below so any descriptor a given Chrome build rejects (e.g.+// clipboard-sanitized-write) only drops itself — the rest still land. Opt-out via+// strictPermissions:true on pup_open_window for an untrusted site you're auditing.+const PERMISSIVE_DEFAULT_PERMISSIONS = [+  // v1.8.69: window-management → getScreenDetails() works (multi-monitor enumeration from+  // inside the page; without the grant it hangs on a CDP-suppressed permission prompt).+  { name: 'window-management', setting: 'granted' },+  { name: 'clipboard-read', setting: 'granted' },+  { name: 'clipboard-write', setting: 'granted' },+  { name: 'clipboard-sanitized-write', setting: 'granted' },+  { name: 'notifications', setting: 'granted' },+  { name: 'geolocation', setting: 'granted' },+  { name: 'camera', setting: 'granted' },+  { name: 'microphone', setting: 'granted' },+  { name: 'midi', setting: 'granted' },+  { name: 'midiSysex', setting: 'granted' },+  { name: 'background-sync', setting: 'granted' },+  { name: 'background-fetch', setting: 'granted' },+  { name: 'periodic-background-sync', setting: 'granted' },+  { name: 'payment-handler', setting: 'granted' },+  { name: 'persistent-storage', setting: 'granted' },+  { name: 'durable-storage', setting: 'granted' },+  { name: 'storage-access', setting: 'granted' },+  { name: 'top-level-storage-access', setting: 'granted' },+  { name: 'idle-detection', setting: 'granted' },+  { name: 'display-capture', setting: 'granted' },+  { name: 'window-management', setting: 'granted' },+  { name: 'local-fonts', setting: 'granted' },+  { name: 'nfc', setting: 'granted' },+  { name: 'screen-wake-lock', setting: 'granted' },+  { name: 'system-wake-lock', setting: 'granted' },+  { name: 'accelerometer', setting: 'granted' },+  { name: 'gyroscope', setting: 'granted' },+  { name: 'magnetometer', setting: 'granted' },+  { name: 'ambient-light-sensor', setting: 'granted' },+  { name: 'sensors', setting: 'granted' },+  { name: 'protected-media-identifier', setting: 'granted' },+  { name: 'captured-surface-control', setting: 'granted' },+  { name: 'speaker-selection', setting: 'granted' },+];+async function applyDefaultPermissions(browser, profileName, { strictPermissions = false } = {}) {+  if (strictPermissions) {+    console.log(`[permissions] strictPermissions=true — leaving Chromium defaults in place for profile "${profileName}"`);+    return { applied: false, reason: 'strictPermissions' };+  }+  const session = await browser.target().createCDPSession().catch(() => null);+  if (!session) {+    console.log(`[permissions] could not open CDP session for "${profileName}" — skipping`);+    return { applied: false, reason: 'cdp-session-open-failed' };+  }+  const applied = [];+  const failed = [];+  try {+    for (const p of PERMISSIVE_DEFAULT_PERMISSIONS) {+      try {+        await session.send('Browser.setPermission', {+          permission: { name: p.name },+          setting: p.setting,+          // origin omitted → all origins on default browser context+        });+        applied.push(`${p.name}=${p.setting}`);+      } catch (e) {+        failed.push({ name: p.name, error: e.message });+      }+    }+  } finally {+    try { await session.detach(); } catch {}+  }+  if (applied.length > 0) {+    console.log(`[permissions] profile "${profileName}": applied [${applied.join(', ')}]${failed.length > 0 ? `; failed [${failed.map(f => f.name).join(', ')}]` : ''}`);+  } else if (failed.length > 0) {+    console.log(`[permissions] profile "${profileName}": ALL grants failed: ${JSON.stringify(failed)}`);+  }+  return { applied, failed };+}++// v1.6.3+: enable scripted downloads on a page. This is the SECOND half+// of pup's full-capability default (the first half is applyDefaultPermissions+// above). Without setDownloadBehavior=allow, JS-triggered downloads (a+// click on `<a download href="...">`, a `fetch().then(blob → save)`, a+// vendor "Download Symbol" button that does `window.location = url`) get+// SILENTLY BLOCKED by Chrome — no error, no UI, no file in ~/Downloads.+// chip-fetcher debugged this for hours before figuring out the cause.+//+// Why per-page and not per-browser: setDownloadBehavior is a Page-domain+// command. Each tab needs its own application. We also re-apply on every+// main-frame navigation as a safety belt — empirically Chrome doesn't+// reset across same-context navs, but cross-context (browser-restored+// tabs, target-domain disconnect/reconnect) can lose it.+//+// downloadPath defaults to %USERPROFILE%\Downloads on Windows / ~/Downloads+// on POSIX — chip-fetcher's `desktop_watch_files` polls this exact dir,+// so the two ends meet by default. Caller can override via the+// `downloadPath` arg on pup_open_window if they need somewhere else.+async function applyDownloadBehavior(page, downloadPath) {+  if (!downloadPath) {+    downloadPath = path.join(os.homedir(), 'Downloads');+  }+  // Ensure the dir exists — Chrome silently no-ops if the path doesn't+  // exist on Windows; the file ends up nowhere and the AI debug-loops.+  try { fs.mkdirSync(downloadPath, { recursive: true }); } catch {}+  let cdp;+  try {+    cdp = await page.target().createCDPSession();+    await cdp.send('Page.setDownloadBehavior', {+      behavior: 'allow',+      downloadPath,+    });+  } catch (e) {+    // Older Chromes used the now-deprecated Browser.setDownloadBehavior+    // (with browserContextId). Try that as a fallback. Modern Chrome+    // (≥83) supports both forms; this branch only fires on truly old+    // Chrome for Testing builds.+    try {+      if (cdp) await cdp.send('Browser.setDownloadBehavior', {+        behavior: 'allow',+        downloadPath,+      });+    } catch (e2) {+      console.log(`[downloads] setDownloadBehavior failed for page (non-fatal): ${e.message} / fallback: ${e2.message}`);+    }+  } finally {+    try { if (cdp) await cdp.detach(); } catch {}+  }+}++// The browser that most recently launched (kind/source/executablePath + any fallback+// chain), so pup_open_window's response + hints report what pup ACTUALLY drove —+// which can differ from resolveBrowser()'s top pick if the top candidate had to fall+// through. Read right after getOrLaunchBrowser() returns.+let _lastChosenBrowser = null;++// Build the verbose browser report the AI gets on every open: what we drove, what+// else is available, the current default, and how to switch or install full CfT.+function browserPickReport() {+  const sum = chrome.pickSummary();+  const chosen = _lastChosenBrowser || sum.picked;+  const availTxt = (sum.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+  const def = sum.defaultBrowser;+  const defTxt = def+    ? `Default is ${def.kind}${def.forced ? ' (pinned via pup_use)' : ' (auto — cached from a prior successful launch)'}${def.pendingInstall ? ', still downloading' : ''}.`+    : 'Default is auto (pup drives the first browser that launches, then caches it).';+  const fellBack = chosen && chosen.fallbackFrom && chosen.fallbackFrom.length+    ? ` NOTE: fell back past ${chosen.fallbackFrom.join(', ')} which would not spawn.` : '';+  const hint = chosen+    ? `Driving ${chosen.kind}${chosen.source ? ` (${chosen.source})` : ''}${chosen.executablePath ? ` — ${chosen.executablePath}` : ''}, in a FRESH isolated profile (the user's real tabs/logins are untouched).${fellBack} Available on this machine: ${availTxt}. ${defTxt} To switch the browser pup drives: pup_use {browser:"chrome"|"edge"|"cft"|"auto"}. To install & pin full Chrome for Testing: pup_use {browser:"cft"} (fetches ~150 MB once, then every open uses it) or pup_prewarm.`+    : `No browser resolved. Available on this machine: ${availTxt}.`;+  return {+    browser: chosen ? { kind: chosen.kind, source: chosen.source, executablePath: chosen.executablePath || null } : null,+    availableBrowsers: sum.candidates,+    defaultBrowser: def,+    _hint: hint,+  };+}+++// v1.8.34: capture the user's CURRENT foreground window (decimal HWND string) right+// before we launch a pup browser, so the backgrounder can hand keyboard focus back to it.+async function osIdleMs() {+  // v1.9.292 (John: differentiate HIS taskbar click from a JS/Chrome raise). v2.0.27: native ab verb+  // (GetLastInputInfo in precompiled Rust, ~20ms) — no more runtime-compiled PowerShell.+  if (process.platform !== 'win32') return null;+  try {+    const d = abPayload(await chrome.abCommand('desktop_user_idle_ms', {}, { timeoutMs: 4000 }));+    return (d && d.ok && Number.isFinite(Number(d.idleMs))) ? Number(d.idleMs) : null;+  } catch (e) { return null; }+}+async function osGetForegroundWindow() {+  // v2.0.27: native (desktop_get_foreground). Returns the decimal hwnd as a STRING (legacy shape) or null.+  if (process.platform !== 'win32') return null;+  try {+    const d = abPayload(await chrome.abCommand('desktop_get_foreground', {}, { timeoutMs: 4000 }));+    const h = d && Number(d.hwnd);+    return h ? String(h) : null;+  } catch (e) { return null; }+}++// v1.9.180 — STUCK-OCCLUSION UNSTICK. Root-caused live on John's Slides window (2026-07-29): after a+// browser-process hang, Chrome's native occlusion state for a window can come back CORRUPTED — the+// window is frontmost on screen, yet the page reports document.visibilityState 'hidden', so Chrome+// withholds every frame. The user sees DWM's last composited frame (looks perfect in screenshots) but+// scrolling/typing repaints NOTHING — "pup is frozen" while the page is healthy. Programmatic raise+// (SWP_NOACTIVATE) and even minimize/restore do NOT recompute it; a REAL activation does. So: when a+// pup window IS the OS-foreground window but its page claims hidden, that state is impossible-if-+// healthy — unstick it (CDP lifecycle 'active' first, then a real forced activation) and tell the user.+async function pageSetLifecycleActive(page) {+  let cdp = null;+  try {+    cdp = await withTimeout(page.target().createCDPSession(), 3000, 'cdp-lifecycle');+    await withTimeout(cdp.send('Page.setWebLifecycleState', { state: 'active' }), 3000, 'lifecycle');+    return true;+  } catch (e) { return false; }+  finally { try { if (cdp) await cdp.detach(); } catch (e) {} }+}++// Force a REAL activation of a window (AttachThreadInput + ALT-nudge + SetForegroundWindow) — the+// user-input-equivalent activation that actually recomputes Chrome's occlusion, unlike a plain raise.+async function forceActivateHwnd(hwnd) {+  // v2.0.27: native — desktop_bring_to_front does the AttachThreadInput + SetForegroundWindow dance+  // (foreground-lock bypass) in precompiled Rust; confirmed equivalent by the ab thread on ab#64.+  if (process.platform !== 'win32' || !hwnd) return false;+  try {+    const d = abPayload(await chrome.abCommand('desktop_bring_to_front', { hwnd: Number(hwnd) }, { timeoutMs: 5000 }));+    return !!(d && (d.ok || d.success || d.broughtToFront));+  } catch (e) { return false; }+}++// v1.9.181: cached session hwnds so periodic sweeps don't call AD's find_window per session per tick.+// TTL 10 min; a stale hwnd just fails a foreground match harmlessly and refreshes on the next miss.+// v2.0.254: origins whose /favicon.ico failed on BOTH rails -> ts of that failure. Suppresses the+// re-fetch (and its log lines) for 30 min. Capped so a long-lived pup can't accumulate origins.+const _faviconNegCache = new Map();+function _faviconNegRemember(origin) {+  _faviconNegCache.set(origin, Date.now());+  if (_faviconNegCache.size > 500) { for (const k of [..._faviconNegCache.keys()].slice(0, 200)) _faviconNegCache.delete(k); }+}+const _hwndCache = new Map();     // sessionId -> { hwnd, ts }+async function cachedSessionHwnd(sessionId) {+  const c = _hwndCache.get(sessionId);+  if (c && Date.now() - c.ts < 10 * 60000) return c.hwnd;+  const hwnd = await sessionHwnd(sessionId);+  if (hwnd) _hwndCache.set(sessionId, { hwnd, ts: Date.now() });+  else _hwndCache.delete(sessionId);+  return hwnd;+}++// v1.9.181: is the user actively using this window? True when a granted raise OR an OBSERVED user+// foreground (taskbar click, alt-tab) marked it, with a 10-minute recency window. Every park/demote+// path must check this — pup must NEVER pull a window out from under the user (John, 2026-07-29:+// "pup windows still don't behave well"; root cause was exactly these unguarded demotions).+function userIsUsingWindow(session) {+  if (!session) return false;+  // v2.0.261: a newborn window cannot be one "the user is using" — its taskbar button did not exist+  // until a moment ago. Without this the flags set by Chrome's own self-activation make every+  // demotion path stand down and leave an unasked-for window on top of the user's work.+  if (inBirthGrace(session)) return false;+  if (session._foreground) return true;+  return !!(session._userActiveAt && Date.now() - session._userActiveAt < 10 * 60000);+}++const _unstickLast = new Map();   // sessionId -> ts of last unstick attempt (don't nag every tick)+// v1.9.181: hwnd arrives precomputed from the sweep (which already matched it against the OS+// foreground), so this probes the ONE matched window — the old version evaluated visibility in EVERY+// session every tick, waking throttled renderers bridge-wide (the exact battery/load sin of issue #14).+const _unstickStrikes = new Map();  // sessionId -> consecutive stuck detections+const _unstickClickAt = new Map();  // sessionId -> ts of last REAL-click cure (hard 10-min cap)+async function unstickIfHiddenWhileFront(sessionId, session, fgHwnd, hwnd) {+  if (process.platform !== 'win32' || !session) return false;+  if (!hwnd || String(fgHwnd) !== String(hwnd)) { _unstickStrikes.delete(sessionId); return false; }+  // v1.9.184 (John: "why does the mouse keep jumping to the upper right corner") — the v1.9.183+  // check probed only pup's ACTIVE tab. When the user is viewing a DIFFERENT tab of that window,+  // the probed background tab reports 'hidden' — which is NORMAL — so pup misdiagnosed a healthy+  // window as frozen every sweep and fired its cure ladder, ending in a REAL CLICK on the user's+  // screen every 30s. A window is stuck ONLY if NO tab reports visible: a healthy frontmost window+  // always has exactly one visible tab. Probe across tabs; a visible tab also corrects pup's stale+  // activeTabId to what the user is actually looking at.+  let sawAnswer = false, visibleTab = null;+  for (const t of (session.tabs || [])) {+    if (!t || !t.page || t._crashed) continue;+    try {+      const v = await withTimeout(t.page.evaluate('document.visibilityState'), 1500, 'vis-probe');+      sawAnswer = true;+      if (v === 'visible') { visibleTab = t; break; }+    } catch (e) { /* busy/unreachable tab — not evidence either way */ }+  }+  if (visibleTab) {+    if (session.activeTabId !== visibleTab.tabId) {+      console.log(`[unstick] "${sessionId}" user is viewing ${visibleTab.tabId} (pup thought ${session.activeTabId}) — syncing activeTabId, window is healthy`);+      session.activeTabId = visibleTab.tabId;+    }+    _unstickStrikes.delete(sessionId);+    return false;+  }+  if (!sawAnswer) return false;   // no tab answered — cannot judge; never act on no evidence+  // ALL answering tabs hidden while frontmost = the stuck state. Require it TWICE in a row+  // (~30s apart) so a transient (mid-tab-switch, mid-navigation) can never trigger the cure.+  const strikes = (_unstickStrikes.get(sessionId) || 0) + 1;+  _unstickStrikes.set(sessionId, strikes);+  if (strikes < 2) { console.log(`[unstick] "${sessionId}" all tabs hidden while frontmost (strike 1/2) — watching, not acting`); return false; }+  _unstickStrikes.delete(sessionId);+  const tab = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];+  if (!tab || !tab.page) return false;+  let vis = 'hidden';+  const last = _unstickLast.get(sessionId) || 0;+  if (Date.now() - last < 30000) return false;+  _unstickLast.set(sessionId, Date.now());+  console.log(`[unstick] "${sessionId}" is the FOREGROUND window but its page reports hidden — stuck occlusion; unsticking`);+  await pageSetLifecycleActive(tab.page);+  await new Promise(r => setTimeout(r, 700));+  try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) {}+  if (vis === 'hidden') {+    await forceActivateHwnd(hwnd);+    await new Promise(r => setTimeout(r, 900));+    try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) {}+  }+  if (vis === 'hidden') {+    // v1.9.183: LAST RESORT that actually works — a REAL input click. Proven live twice on John's+    // Slides window: CDP lifecycle 'active' and the ALT-nudge SetForegroundWindow both left the page+    // hidden; a genuine mouse click on the window cleared it instantly both times (Chrome's stuck+    // occlusion only recomputes on real input). Click the EMPTY tab-strip area (right side, clear of+    // the window buttons): pure activation, cannot hit page content. Acceptable cursor blip: this+    // branch only runs when the user is ALREADY looking at this frozen window.+    // v1.9.184: HARD CAP — a real click at most once per session per 10 minutes. A misdiagnosis+    // repeating a real click is far worse than a frozen window staying frozen one sweep longer.+    const lastClick = _unstickClickAt.get(sessionId) || 0;+    if (Date.now() - lastClick < 10 * 60000) {+      console.log(`[unstick] "${sessionId}" real-click cure SKIPPED (10-min cap) — soft nudges failed but a repeat click risks disrupting the user`);+      return false;+    }+    _unstickClickAt.set(sessionId, Date.now());+    try {+      const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 }));+      const r = fw && (fw.best || (fw.data && fw.data.best)); const rect = r && r.rect;+      if (rect && rect.width > 600) {+        const cx = Math.round(rect.left + rect.width - 300);+        const cy = Math.round(rect.top + 10);+        await chrome.abCommand('desktop_click', { x: cx, y: cy, reason: `unstick frame-frozen pup window "${sessionId}" (real activation click on empty tab strip)` }, { timeoutMs: 8000 });+        await new Promise(r2 => setTimeout(r2, 1200));+        try { vis = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'vis-probe'); } catch (e) {}+      }+    } catch (e) {}+  }+  const fixed = vis !== 'hidden';+  if (fixed) {+    recordCleanup(`pup detected your "${sessionId}" window was front-and-center but Chrome had stopped painting it (stuck occlusion after a hang) — pup unstuck it automatically; scrolling and rendering work again.`);+  } else {+    recordCleanup(`pup detected your "${sessionId}" window is frontmost but Chrome is refusing to paint it (stuck occlusion). One real click anywhere on that window fixes it — pup's automatic nudges did not clear it this time.`);+  }+  console.log(`[unstick] "${sessionId}" ${fixed ? 'UNSTUCK — page visible again' : 'still hidden after nudges (needs one real user click)'}`);+  return fixed;+}++// v1.8.40: badge each pup window's TASKBAR BUTTON with the white Adom leaves, so users can+// tell a pup-driven window from their own browser. Done via AD-core desktop_taskbar (custom+// PNG overlay, AD ≥1.9.106) — AD paints it cross-process natively; pup just hands over the+// art (an in-bridge SetOverlayIcon is in-process-only and can't badge the browser's window).+// We resolve the window by its "(session: <id>)" title suffix so AD finds the hwnd itself.+let _pupBadgeB64 = null;+function pupBadgeB64() {+  if (_pupBadgeB64 !== null) return _pupBadgeB64;+  try { _pupBadgeB64 = fs.readFileSync(path.join(__dirname, 'icons', 'pup-overlay-32.png')).toString('base64'); }+  catch (e) { _pupBadgeB64 = ''; }+  return _pupBadgeB64;+}+// v1.8.73: FULL WINDOW-IDENTITY TAKEOVER (AD >=1.9.134, built to pup's spec). Two halves:+// registration (once per bridge run: HKCU AUMID + Start Menu shortcut whose pin launches a REAL+// pup window via adom-desktop-cli) and per-window stamping (AUMID + WM_SETICON, done by AD-core+// because icon HICONs are process-owned USER objects that must outlive the caller — AD holds+// them in a process-lifetime cache; see dev skill "Full icon/identity takeover"). The bridge+// registers ITSELF over the loopback direct-API (trusted transport, Tier-2 verb is ungated+// there) and owns its icon in its install dir — change branding = ship a new pup release with a+// NEW icon FILENAME (Windows caches decoded icons by path). Self-gating: on AD <1.9.134 the+// verbs are unknown -> _identitySupported=false -> the overlay badge fallback keeps working.+// v1.8.78: AD-version awareness. Most capability gaps self-gate silently (identity -> badge,+// bottom/force -> PS fallback) and need no nagging. ONE gap is a POLICY hole, not cosmetics:+// AD <1.9.124 intercepts pup_raise_os_window natively, so pup's foreground gate (reason+// required + audit trail) silently does not exist there. pup can't enforce through an+// interceptor, but it can KNOW and SAY SO — probed once at startup, surfaced in /status and+// open-response hints so users/AIs are told to update AD rather than trusting a gate that+// isn't there.+let _adVersion = null;+let _adRaiseGateBypassed = false;+function _verLt(a, b) {+  const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);+  for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return true; if ((pa[i] || 0) > (pb[i] || 0)) return false; }+  return false;+}+async function probeAdVersion() {+  try {+    const r = await chrome.abCommand('update_status', {}, { timeoutMs: 6000 });+    const raw = r && (typeof r.output === 'string' && r.output ? JSON.parse(r.output) : r.output || r);+    const v = (raw && (raw.currentVersion || (raw.data && raw.data.currentVersion))) || (r && r.currentVersion) || null;+    if (v) {+      _adVersion = v;+      _adRaiseGateBypassed = _verLt(v, '1.9.124');+      console.log(`[abver] AD ${v}${_adRaiseGateBypassed ? ' — WARNING: <1.9.124 intercepts raise, the foreground gate is BYPASSED on this box; recommend updating Adom Bridge' : ''}`);+    }+  } catch (e) {}+}+function adUpgradeHint() {+  return _adRaiseGateBypassed+    ? ` ⚠ Bridge ${_adVersion} on this desktop is older than 1.9.124: it intercepts pup_raise_os_window natively, so pup's foreground gate (foregroundReason + audit trail) does NOT apply to raises here. Recommend the user update Adom Bridge (update_check / the footer banner) — everything else degrades gracefully, this is the one policy gap.`+    : '';+}+// v1.8.82: PUP SETTINGS — user-configurable behavior, persisted OUTSIDE the bridge dir (the+// bridges-cache is clobbered on every update) in ~/.adom/pup-settings.json.+// taskbarGrouping (John's discovery while debugging the AUMID grouping):+//   'split'   (default) — per-session AUMID: ONE TASKBAR BUTTON PER WINDOW, each wearing its+//              own tab-count badge. Best for telling AI threads apart at a glance.+//   'grouped' — shared AUMID: ALL pup windows stack under ONE "Adom Pup" button (tidy taskbar);+//              the badge shows the WINDOW count (>=2) since per-window badges are meaningless+//              on a grouped button.+const PUP_SETTINGS_PATH = path.join(os.homedir(), '.adom', 'pup-settings.json');+let _pupSettings = null;+function pupSettings() {+  if (_pupSettings) return _pupSettings;+  try { _pupSettings = JSON.parse(fs.readFileSync(PUP_SETTINGS_PATH, 'utf8')); } catch (e) { _pupSettings = {}; }+  if (!_pupSettings.taskbarGrouping) _pupSettings.taskbarGrouping = 'split';+  // v1.9.259 (John's settings dialog): every user-facing behavior gets an explicit pref.+  // v2.0.113 (John: "your aumid's seem to be working great now, so the settings i'm running with+  // are the new defaults"). The per-window taskbar identity + jump lists + category icons were+  // defaulted OFF only because AUMID stamping was the prime suspect in the old Chrome crashes;+  // that is resolved, so a FRESH user now gets the good experience John runs with, not the old+  // all-windows-stacked-under-one-button mode. (Existing users' explicit choices are untouched —+  // the _chosen tracking means flipping a default only moves users who never set the key.)+  const D = { aumidIcons: 'on', jumpLists: 'on', fgCaption: 'on', fgCaptionMs: 2000,+              overlayBadges: 'on', sleepRecovery: 'on',+              // v2.0.262 (John): the badge at full size covers the BOTTOM LINE of the thread tile's+              // name, and that text is the whole point of thread tiles ("i'm finding that the text is+              // super valuable so i want to be able to see it"). '50' composites the same badge at half+              // scale inside the same canvas, so Windows draws it half-size in the corner and the tile+              // text stays readable. DEFAULT is '50' — the readable one — with '100' available for+              // anyone who wants the big badge back. v2.0.263: John tried 50 and asked for 66 — the+              // value is a straight PERCENT now, so adding another stop is a one-word change. v2.0.264: 75 added+              // and made the default after he compared 100/66/50 on real tiles.+              overlaySize: '75',+              aumidIconStyle: 'category', taskbarGrouping: 'split',+              // v2.0.152 THREAD TILES (John): group every pup window/tab of one ai-thread under ONE+              // taskbar button whose icon is that thread's NAME rendered on the teal tile (Familjen+              // Grotesk, auto-fit + truncate). It overrides the grouping/style modes so a thread's+              // whole pup estate lives under a single named tile ("oh, that's my shotlog thread").+              // v2.0.174 (John: "put thread tiles at top of settings since its the DEFAULT and my+              // favorite"): now ON by default, and the first row in the Settings dialog. _chosen+              // tracking means this only moves users who never set the key themselves.+              threadTiles: 'on' };+  // v2.0.292: openFlash / autoFlash / agentActivity / fgWatch REMOVED — collapsed into taskbarActivity+  // (progress|flash|off) and notMineCaption. Their gates now read taskbarActivity/notMineCaption.+  // v1.9.268 (John on autoLogin: "this should be on by default" - and it always WAS behavior-default+  // on via the !== 'off' checks, but the dialog rendered the unset key as an OFF toggle: a display+  // lie). Legacy keys get explicit defaults so the dialog reflects actual behavior.+  D.autoLogin = 'on'; D.reportIssues = 'on';+  // v2.0.289 (John): how pup signals activity on a window's taskbar button. 'progress' = a DETERMINATE+  // progress bar sized to the running-average time of the operation (icon-apply, screenshot, ...) —+  // the new default, replacing the orange flash. 'flash' = the old orange taskbar highlight+  // (openFlash/autoFlash). 'off' = neither. Windows-only; stored-but-inert elsewhere.+  D.taskbarActivity = 'progress';+  // v1.9.275 (John: 'shouldn't there be a setting for whether to show the full jump list or not?')+  D.jlViewTasks = 'on'; D.jlCloseAll = 'on';+  // v2.0.42 (John: "can you add a jump list to open the pup dashboard?")+  D.jlDashboard = 'on';+  D.jlAnnotate = 'on';   // v2.0.49+  // v2.0.50 (plan: interactive surface): caption bar baked under captures; toolbar extension family+  D.annCaptionBar = 'on'; D.toolbarExtension = 'on'; D.tbOwnerBadge = 'on'; D.tbSnipHotkey = 'on'; D.tbFloatyPencil = 'off'; D.floatyHitPx = 24;+  D.captureLogins = 'on';   // v2.0.110 (John authorized): auto-capture form logins into the keychain vault+  // v1.9.293 (John): the 'NOT foregrounded by Adom' caption is now OFF by default. He is right that+  // input-timing cannot reliably tell his own raises (click / alt+tab / window click) from a rare+  // JS raise - and Windows blocks most background foreground-steals anyway, so the case it exists+  // for seldom happens. Kept as an OFF-by-default opt-in; the idle-gate stays for anyone who turns it on.+  D.notMineCaption = 'off';+  if (_pupSettings.notMineCaption === 'on' && !(_pupSettings._chosen || {}).notMineCaption) _pupSettings.notMineCaption = 'off';+  D.ownerChip = 'on';        // v1.9.284 (John): the floating owner-thread/profile chip, default on+  // v1.9.286 (John: "i dont like seeing the title mangled anymore"): BOTH title decorations OFF by+  // default. titleTag = the "(ai-thread: X)" resolution suffix (pup resolves transiently while the+  // window is off-screen when this is off, so titles stay clean and window-finding still works);+  // titlePrefix = the "<thread> · " cosmetic prefix at the start.+  // v2.0.20 HOTFIX: titleTag default BACK OFF. The v2.0.19 flip re-armed the title-observer+  // ping-pong the code has always warned about (SPA rewrites title -> observer re-tags -> loop+  // pegs the renderer + floods CDP) and froze a live window within the hour (hd-publish-1.0.0,+  // captureScreenshot Internal error). Ownership visibility stays via the jump-list header name+  // and the in-window owner chip; the title tag remains an OPT-IN.+  D.titleTag = 'off'; D.titlePrefix = 'off';+  if (_pupSettings.autoLogin === 'off' && !chosen.autoLogin) _pupSettings.autoLogin = 'on';+  // v1.9.260 (John: "2s feels too fast cuz i can't read"): readable default. A stored 2000 was the+  // old default, not a choice made in the dialog (it shipped hours ago) - migrate it up once.+  if (D.fgCaptionMs) D.fgCaptionMs = 3500;+  if (_pupSettings.fgCaptionMs === 2000) _pupSettings.fgCaptionMs = 3500;+  for (const k of Object.keys(D)) if (_pupSettings[k] === undefined) _pupSettings[k] = D[k];+  return _pupSettings;+}+function savePupSettings(patch) {+  const cur = { ...pupSettings(), ...patch };+  try { fs.mkdirSync(path.dirname(PUP_SETTINGS_PATH), { recursive: true }); fs.writeFileSync(PUP_SETTINGS_PATH, JSON.stringify(cur, null, 2)); } catch (e) {}+  _pupSettings = cur;+  return cur;+}++// v1.9.185 — MINIMAL-TOUCH MODE (John, 2026-07-29: "you crash chrome with the way you control it …+// what are you doing that's so different trying to control chrome that you cause crashes and freezes+// in the first place? that's where you should be looking").+//+// He is right that the honest experiment is to STOP DOING THE EXTRA THINGS, not to monitor harder.+// Everything pup does to a window BEYOND opening it and driving CDP is cosmetic/管理 machinery that a+// normal Chrome window never experiences, and all of it is cross-process traffic that synchronizes+// with Chrome's UI thread or forces throttled renderers awake:+//   • taskbar identity re-stamps (AUMID changes DESTROY + RECREATE the taskbar button)+//   • tab-count badges + favicon overlays (per-window art pushed through AD → shell)+//   • jump-list rebuilds (every CommitList re-creates the menu)+//   • progress bars, auto-flash, foreground toasts+//   • title re-stamping via an in-page MutationObserver that re-fires on EVERY SPA title change+//   • periodic sweeps that eval into parked/throttled pages (forcing wakes Chrome wanted to avoid)+//   • the unstick real-click, watchdog kills, adoption re-parks+// minimalTouch:'on' keeps ONLY what the user actually asked pup for — windows open in the background,+// drivable over CDP, logins accumulating in the shared profile — and disables every item above.+// If freezes/crashes persist with it on, the cause is NOT pup's window machinery and I must look+// elsewhere; if they stop, this machinery WAS the cause. Either answer is worth more than more+// monitoring. Default OFF (existing behaviour) until measured.+function minimalTouch() {+  try { return pupSettings().minimalTouch === 'on'; } catch (e) { return false; }+}++const PUP_APP_ID = 'Adom.Pup';+const PUP_DISPLAY_NAME = 'Adom Pup';+// v1.9.111 (John's ask) — SAY WHICH MODE the taskbar icon is showing, in the jump-list header row.+// pup already paints five different category icons (public wiki, signed-in wiki, Adom app, plain web,+// mixed) and already names them in the hover tooltip via CAT_LABEL, but the header row just said+// "Adom Pup", so the icon's meaning was guesswork. The header is width-constrained (the flyout is+// ~440px and "Close ALL Adom Pup windows" is about as long as it comfortably gets), so these are+// SHORTER than CAT_LABEL, which stays as-is for the uncapped tooltip. The per-session AUMID already+// encodes the category, so a category flip mints a new appId and the new name takes effect with it.+const CAT_MODE_SHORT = {+  wiki: 'wiki · logged out',+  'wiki-in': 'wiki · LOGGED IN',+  app: 'Adom app',+  web: 'web page',+  mixed: 'mixed content',+};+function pupDisplayName(sessionId) {+  try {+    const cat = sessionId ? pupCategory(sessionId) : null;+    const mode = cat && CAT_MODE_SHORT[cat];+    let name = mode ? `${PUP_DISPLAY_NAME} · ${mode}` : PUP_DISPLAY_NAME;+    // v2.0.19 (John): the jump-list header + pin name carry the OWNING AI THREAD, so a right-click+    // on any pup taskbar button answers "whose window is this" even with the title tag off.+    const s = sessionId && sessions.get(sessionId);+    const own = s && (s.owner || s._ownerThread);+    if (own) name += ` · ${String(own).replace(/^ai[- ]/i, '').slice(0, 28)}`;+    return name;+  } catch (e) { return PUP_DISPLAY_NAME; }+}+let _identitySupported = null;   // null = unprobed+let _identityRegistered = false;+let _jumplistSupported = null;   // null = unprobed (desktop_set_window_jumplist, AD ≥1.9.139)+// v1.8.74: adom-pup-BROWSER.ico — favicon + browser-window inlay (lower-right), so the icon+// says "this is an Adom BROWSER window" at a glance. NEW FILENAME on art change, always —+// Windows' shell + AD's HICON cache key by path (stale-tile risk otherwise). Future home for+// the master art: the Adom brand-icons wiki repo; for now the pup repo owns it.+// v1.9.12: the taskbar icon now CARRIES THE ADOM-WIKI LOGIN STATE (John: "show an indicator in the+// pup icon whether the adom wiki is logged in... maybe that browser window icon gets filled in+// solid"). Two variants of the SAME art, differing only in the lower-right corner glyph:+//   adom-pup-browser3.ico    — browser-window OUTLINE  = logged OUT / not a wiki window (default)+//   adom-pup-wiki-authed1.ico — SOLID PADLOCK          = signed in to the Adom wiki+// Solid-vs-outline is the most legible difference at 16-32px (a mass/contrast change survives+// downscaling; fine shape detail does not), and the padlock adds the semantic meaning on top.+// The OVERLAY slot is NOT used for this — it belongs to the active tab's favicon (v1.9.0).+// NEW FILENAME on any art change, always: the shell + AD cache HICONs by PATH (stale-tile trap).+// v1.9.19: CATEGORY ICONS. A pup window's taskbar icon says WHAT KIND of thing it is showing.+// Every icon is the teal Adom tile (the pup identity, the container) carrying ONE monochrome+// #e6edf3 glyph drawn on the 24x24 house grid, per the brand icon law: custom hand-drawn, single+// colour, no gradients, no shadows, NO EMOJI. Glyphs match the shipped family style (see the brand+// page icons: symbol, step, shotlog).+//   wiki     open book, outline   Adom wiki, public / logged out+//   wiki-in  open book, SOLID     Adom wiki, signed in   (solid vs hollow is the one mass change+//                                 that survives Windows painting the button at ~24px; a padlock+//                                 turned to mush at that size)+//   app      window + prompt      an Adom app: localhost or a cloud slug proxy URL+//   web      globe                anything else on the internet+//   mixed    stacked windows      tabs spanning more than one category+// v1.9.54: the RETIRED default was adom-pup-browser3.ico (the old "Adom mark fills the tile + tiny+// corner window" brand). John retired it in favour of the teal-tile category family. The new default+// (pup-cat-default9.ico) matches that family — teal tile + dark-teal window body + a neutral+// browser-chrome glyph — so a category-less window and the base Adom Pup identity no longer wear the+// old art anywhere. The 5 categories still override it via PUP_CAT_ICONS.+const PUP_ICON_DEFAULT = 'pup-cat-default9.ico';+// v1.9.45 — ICON GENERATION. BUMP THIS whenever the icon FILES or the AUMID branding mechanism+// change, and every live window gets a freshly-painted taskbar button.+//+// WHY it must exist: Win11 bakes a taskbar button's tile when the button is CREATED and never+// re-reads it (AD's measurement, WIN11_TASKBAR_ICON.md Part 7). A window keeps one appId for its+// whole life, so fixing what an appId RESOLVES TO cannot repair a button that already exists. That+// is exactly what happened in 1.9.44: writing IconResource fixed every FUTURE button but left the+// already-created ones white, because their tiles were baked while the AUMID was still unbranded.+// Folding a generation token into the appId turns an icon change into a NEW appId, which forces the+// shell to create a NEW button, which is the ONLY thing that repaints. Cost is one button rebuild.+const PUP_ICON_GEN = 'i8';   // i8: virgin AUMIDs after the 2.0.217-222 registry sweeps orphaned ab's icon-handle cache (registered-by-appId, keys deleted on disk -> stamps skipped the re-write, buttons lost their art). i7 (John): NO NOTCH — base tile is a FULL solid icon; the overlay carries its own background now. BUMPED because the+// icon FILES changed — the AUMID appId carries this token, so a new gen forces Windows to CREATE A NEW+// BUTTON that reads the new (notched) icon. Same-appId re-stamps do NOT repaint a baked Win11 tile, which+// is why the notch never appeared on the live button until this bump. (i3 = pre-notch PNG-frame icons.)+const PUP_CAT_ICONS = {+  wiki: 'pup-cat-wiki9.ico',+  'wiki-in': 'pup-cat-wiki-in9.ico',+  app: 'pup-cat-app9.ico',+  web: 'pup-cat-web9.ico',+  mixed: 'pup-cat-mixed9.ico',+};+function classifyUrl(u) {+  try {+    if (!u) return null;+    if (isAdomUrl(u)) return 'wiki';+    const h = new URL(u).hostname;+    if (/^(localhost|127\.0\.0\.1|\[::1\])$/i.test(h)) return 'app';+    // v1.9.41 (audit): ANY *.adom.cloud slug host is an Adom app, with or without a /proxy/ path.+    // The old rule demanded /proxy/, so an app served at a bare slug (project-manager-xxxx.adom.cloud)+    // was labelled "web" and wore the globe. Caught by auditing my own taskbar.+    if (/\.adom\.cloud$/i.test(h)) return 'app';+    if (/^(about|chrome|edge|devtools):/i.test(u) || u.startsWith('file:')) return null; // ignore chrome pages+    return 'web';+  } catch (e) { return null; }+}+// v2.0.277 — TILES MODE: EVERY window wears a NAME TILE, never a category glyph. Root of John's+// "inconsistent taskbar icons" (2026-08-22): a window that lost its owning ai-thread (recovered /+// reclaimed popup, issue #70) had `owner`/`_ownerThread` both empty, so the tile branches fell+// through to a category AUMID (`Adom.Pup.<sid>.<cat>`) and the button rendered the pup category+// icon sitting next to real thread tiles. This helper removes the downgrade: when the real owner is+// gone, derive a STABLE readable name from the sessionId (cleaned) and FREEZE it on the session, so+// the window still wears a name tile and never churns. Real owner always wins when present; the+// derived name is only a floor so tiles mode is visually consistent no matter what. (Persisting the+// real owner across recovery — the deeper #70 root — is separate; this makes the SYMPTOM+// unreachable regardless.)+function tileOwnerName(sess, sessionId) {+  if (!sess) return null;+  const real = sess.owner || sess._ownerThread;+  if (real) return real;+  if (sess._derivedTileName) return sess._derivedTileName;+  const sid = String(sessionId || sess.sessionId || '').trim();+  let name = sid+    .replace(/^adom-you-?/i, '')          // strip the reclaimed-popup profile prefix+    .replace(/\bwin\d+\b/ig, '')           // strip the random win<rand> suffix+    .replace(/[-_]+/g, ' ')                // dashes/underscores -> spaces so the tile reads as words+    .replace(/\s+/g, ' ')+    .trim();+  if (!name) name = sid || 'pup';+  sess._derivedTileName = name;+  return name;+}+// The window's category across ALL its tabs. One kind -> that kind. Several -> 'mixed'.+function pupCategory(sessionId) {+  try {+    const s = sessionId && sessions.get(sessionId);+    if (!s) return null;+    const kinds = new Set();+    for (const t of (s.tabs || [])) {+      let k = null;+      try { k = classifyUrl(t.page && t.page.url()); } catch (e) {}+      if (k) kinds.add(k);+    }+    // v2.0.61 (John: predict from the URL at open — wiki is the 60% case): while pages are+    // still loading (about:blank), classify the INTENDED url so the FIRST stamp is already the+    // categorized id; and an unchecked wiki page predicts signed-in (auto-login lands there in+    // the common case). A wrong guess re-stamps once when the check resolves — the rare case.+    if (!kinds.size) { const kp = classifyUrl(s._intendedUrl); if (kp) kinds.add(kp); }+    if (!kinds.size) return null;+    if (kinds.size > 1) return 'mixed';+    const only = [...kinds][0];+    if (only === 'wiki' && (s._wikiAuthed || s._wikiAuthed === undefined)) return 'wiki-in';+    return only;+  } catch (e) { return null; }+}+function pupIconPath(sessionId) {+  if (sessionId === 'pup-dashboard') return notchedIconPath(path.join(__dirname, 'icons', PUP_CAT_ICONS.app || PUP_ICON_DEFAULT));+  // v1.9.261 (John): aumidIconStyle 'standard' = ONE icon for every AUMID window (the default pup+  // tile); 'category' = the custom per-category set (wiki signed-out, wiki signed-IN padlock, world+  // icon for the open web, adom app icon, mixed).+  if (pupSettings().aumidIconStyle !== 'category') return notchedIconPath(path.join(__dirname, 'icons', PUP_ICON_DEFAULT));+  const cat = pupCategory(sessionId);+  const file = (cat && PUP_CAT_ICONS[cat]) || PUP_ICON_DEFAULT;+  return notchedIconPath(path.join(__dirname, 'icons', file));+}+// ── Notched category tiles (John, 2026-08-04): "cut a notch out of the main icons so the overlay+// favicon has a clean background to sit on." AD paints the favicon overlay OVER the base tile's+// UPPER-RIGHT (it overhangs, and already shows the taskbar through the tile's transparent rounded+// corners). We punch a bigger transparent rounded notch into the upper-right of each STATIC category+// .ico so the whole favicon floats on the taskbar instead of on the busy teal art. Baked into the+// static tile ⇒ NO per-window / per-favicon churn (only the one-time re-stamp any icon-gen bump+// causes). Generated at runtime on the DESKTOP (sharp; the container has no sharp) into a cache dir.+// v1.9.328 (John: "why cut CIRCLES when the overlay favicon is a SQUARE?"). Right — the overlay is a+// SQUARE (favicon fills 48x48 + Adom mark in the corner), so the notch must be a SQUARE, flush to the+// tile's TOP and RIGHT edges (opening onto the taskbar), with ONLY the inner corner rounded. We get+// that by extending the mask rect PAST the top/right edges (`over`) so those corners fall off-canvas+// and only the inner (bottom-left) corner shows the radius — instead of a rounded-rect that rounds all+// four corners into a blob.+// v1.9.333 (John: "2px cutout on the LEFT of the overlay but only 1px BELOW it — unprofessional").+// The inner (bottom-left) corner radius curved the notch inward exactly where the favicon's bottom-left+// sits, eating the BOTTOM margin while the straight LEFT edge kept its full margin → the asymmetry. A+// near-sharp inner corner makes both the left and bottom cutouts uniform straight edges, so the margin+// around the overlay is even. (rx 0.02 ≈ a hair of anti-alias softening, effectively square at 16px.)+const NOTCH = { frac: 0.50, over: 0.16, rx: 0.02 };   // square side = 50% of tile; inner corner near-sharp for an EVEN margin+const _NOTCH_CACHE = path.join(os.tmpdir(), 'pup-notched-icons', PUP_ICON_GEN + '-sq2');   // -sq2: square notch + baked Adom-mark fallback+let _notchDone = false;+function notchedIconPath(origPath) {+  // Serve the notched copy as soon as it EXISTS on disk (from this run's build OR a prior run's cache),+  // not only after _notchDone — so a bridge respawn immediately re-stamps with the notched tile instead+  // of flashing the un-notched one. The cache path is keyed by gen + shape token, so it can only ever be+  // the CURRENT geometry. _notchDone still gates the one-time force-restamp in ensureNotchedIcons.+  try {+    const dest = path.join(_NOTCH_CACHE, path.basename(origPath));+    if (fs.existsSync(dest) && fs.statSync(dest).size > 0) return dest;+  } catch (e) {}+  return origPath;   // not built yet → original tile (harmless; the enforcer/reconcile swaps it in)+}+// Minimal ICO writer: embed PNG frames (valid on Win10/11 for every size).+function _icoEncode(frames) {+  const count = frames.length;+  const dir = Buffer.alloc(6 + 16 * count);+  dir.writeUInt16LE(0, 0); dir.writeUInt16LE(1, 2); dir.writeUInt16LE(count, 4);+  let offset = 6 + 16 * count; const bodies = [];+  frames.forEach((f, i) => {+    const e = 6 + 16 * i;+    dir[e] = f.size >= 256 ? 0 : f.size;+    dir[e + 1] = f.size >= 256 ? 0 : f.size;+    dir.writeUInt16LE(1, e + 4); dir.writeUInt16LE(32, e + 6);+    dir.writeUInt32LE(f.png.length, e + 8); dir.writeUInt32LE(offset, e + 12);+    offset += f.png.length; bodies.push(f.png);+  });+  return Buffer.concat([dir, ...bodies]);+}+async function _buildNotchedIco(sharp, srcPath, destPath) {+  const largest = icoLargestPng(fs.readFileSync(srcPath));+  if (!largest) throw new Error('no PNG frame in ' + path.basename(srcPath));+  const frames = [];+  for (const N of [256, 128, 64, 48, 32, 16]) {+    // v1.9.348 (John): NO NOTCH. The base taskbar tile is a FULL, solid icon again. The notch was the+    // wrong place to put the overlay's background — it carved a hole in the base tile (so a transparent+    // overlay favicon had *something* behind it), but that made the base icon look chewed-up AND the+    // hole bled through the favicon painted on top. The background now lives ON THE OVERLAY badge itself+    // (see applyAppOverlay: a dark, teal-edged card behind the favicon), so the base stays a clean full+    // tile and every overlay favicon reads well regardless of its own transparency.+    const png = await sharp(largest).resize(N, N, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();+    frames.push({ size: N, png });+  }+  fs.mkdirSync(path.dirname(destPath), { recursive: true });+  fs.writeFileSync(destPath, _icoEncode(frames));+}+let _notchReady = null;+function ensureNotchedIcons() {+  if (process.platform !== 'win32') return Promise.resolve();+  if (_notchReady) return _notchReady;+  _notchReady = (async () => {+    let sharp; try { sharp = require('sharp'); } catch (e) { console.log('[notch] sharp unavailable — using un-notched tiles'); return; }+    const files = [...new Set([PUP_ICON_DEFAULT, ...Object.values(PUP_CAT_ICONS)].filter(Boolean))];+    for (const f of files) {+      try {+        const src = path.join(__dirname, 'icons', f), dest = path.join(_NOTCH_CACHE, f);+        if (!fs.existsSync(src)) continue;+        if (!(fs.existsSync(dest) && fs.statSync(dest).size > 0)) await _buildNotchedIco(sharp, src, dest);+      } catch (e) { console.log(`[notch] FAILED ${f}: ${e.message}`); }+    }+    _notchDone = true;+    console.log(`[notch] ready (${files.length} tiles, notch=${Math.round(NOTCH.frac * 100)}% upper-right) — forcing a re-stamp so buttons pick it up`);+    // Force ONE re-stamp so live buttons swap to the notched icon (the appId is unchanged, but+    // desktop_set_window_identity recreates the button, which is what repaints the tile).+    try { for (const [, s] of sessions) { s._curAppId = null; s._lastCat = undefined; s._frozenAppId = null; s._jlFrozen = false; } await reconcileModel('notched-icons-ready'); } catch (e) {}+  })();+  return _notchReady;+}+// v2.0.40 (John: jump-list items do nothing): the CLI RENAMED from adom-desktop-cli.exe (in+// "Adom Desktop\\") to adom-bridge-cli.exe (in "Adom Bridge\\"). pup hardcoded the OLD path, so+// every jump-list task + header click + pinned launch invoked a MISSING exe and silently no-opped+// (verified live: the old path is gone, the new one exists). The bridge runs ON the desktop, so it+// resolves the real exe from disk (Bridge name first, legacy fallback) and caches it.+let _cliExeCache = null;+function pupCliExe() {+  if (_cliExeCache) { try { if (fs.existsSync(_cliExeCache)) return _cliExeCache; } catch (e) {} _cliExeCache = null; }+  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+  const candidates = [+    path.join(la, 'Adom Bridge', 'adom-bridge-cli.exe'),+    path.join(la, 'Adom Bridge', 'adom-desktop-cli.exe'),+    path.join(la, 'Adom Desktop', 'adom-desktop-cli.exe'),+  ];+  for (const c of candidates) { try { if (fs.existsSync(c)) { _cliExeCache = c; return c; } } catch (e) {} }+  return candidates[0];   // current product name is the best guess if none found on disk+}+function pupRelaunchCommand(sessionId) {+  const cli = pupCliExe();+  const payload = JSON.stringify({ sessionId, foregroundReason: 'user clicked the Adom Pup taskbar header or pinned launcher', caller: { aiThread: 'user-taskbar-launcher' } }).replace(/"/g, '\\"');+  // v1.9.115: same as the task commands — this runs when the user clicks the jump-list HEADER row or a+  // pinned pup launcher, so it needs identity or AD refuses it and the click does nothing.+  return `"${cli}" pup_focus_window "${payload}"`;   // v1.9.121: identity rides in the payload JSON (see pupRelaunchCommand above)+}+// ─── v2.0.41: jump-list tasks call pup DIRECTLY (John: "be way smarter about how to call back into+// ab for the jump lists ... should pup be able to talk back to itself when it's its own bridge?").+// Answer: YES, for pup_* verbs. Old design set NO task `target`, so ab supplied the program (its+// bundled CLI) — an exe path pup neither controls nor can verify, and the very thing the rename broke.+// Now every task carries target=powershell.exe + a pup-OWNED launcher script that:+//   1) reads pup's OWN loopback port from a discovery file pup rewrites on every bind, then+//   2) POSTs the verb straight to http://127.0.0.1:<port>/command — pup talking to itself, no ab+//      relay hop, no CLI exe, no stale path (the two things that broke it), and+//   3) ONLY if that direct hit fails (pup idled/died — the Chrome windows outlive the node process,+//      and just pup→pup can't wake a dead pup) falls back to the resolved adom-bridge-cli.exe, which+//      goes through ab and RESPAWNS pup. Best of both: fast+accurate when up, ab-wake when asleep.+// JSON args ride as BASE64 (argv-safe: no spaces/quotes) so nothing mangles through the ShellLink →+// Explorer → powershell argv re-parse that historically ate raw escaped JSON (v1.9.9).+function pupPortFile() {+  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+  return path.join(la, 'Adom Bridge', 'pup-bridge-port.json');+}+function writePupPortFile(overridePort) {+  if (process.platform !== 'win32') return;+  try {+    const f = pupPortFile();+    fs.mkdirSync(path.dirname(f), { recursive: true });+    // v2.0.231: overridePort lets the EACCES fallback record the port pup ACTUALLY bound, so the+    // file never advertises a port nothing is listening on.+    fs.writeFileSync(f, JSON.stringify({ port: Number(overridePort) || PORT, pid: process.pid, version: BRIDGE_VERSION, host: '127.0.0.1', assignedPort: PORT, fallback: !!overridePort }));+  } catch (e) { console.error('[jumplist] port-discovery write failed:', e.message); }+}+// v2.0.369 (CRR "pup not reachable", John 2026-09-02): the toolbar popup used to GUESS the port+// from a 2-entry hardcoded list, which broke the moment ab assigned anything else (CRR got 50950).+// The bridge now writes its LIVE port into the staged extension dir; the popup fetches+// chrome.runtime.getURL('port.json') — an unpacked extension reads that from disk on every popup+// open, so a bridge respawn on a new port is picked up with NO Chrome relaunch.+function writeExtensionPortFile(overridePort) {+  try {+    const d = path.join(os.homedir(), '.adom', 'pup-extension');+    fs.mkdirSync(d, { recursive: true });+    fs.writeFileSync(path.join(d, 'port.json'), JSON.stringify({ port: Number(overridePort) || PORT, version: BRIDGE_VERSION, pid: process.pid }));+  } catch (e) { console.error('[toolbar] port.json write failed:', e.message); }+}+const PUP_JL_LAUNCHER_VERSION = 3;   // bump to force-rewrite the .ps1 on upgrade+function pupJumplistLauncherPath() {+  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+  return path.join(la, 'Adom Bridge', 'pup-jumplist-call.ps1');+}+function ensurePupJumplistLauncher() {+  if (process.platform !== 'win32') return pupJumplistLauncherPath();+  const p = pupJumplistLauncherPath();+  // The script is pup's OWN callback shim. $Verb + $B64 (base64 of the verb's JSON args). It hits pup+  // directly, and only falls back to the CLI (which wakes pup) if the direct POST throws.+  const script = `# Adom Pup jump-list callback (v${PUP_JL_LAUNCHER_VERSION}) — pup talks to itself; CLI wakes it if asleep.+param([Parameter(Mandatory=$true)][string]$Verb, [string]$B64='')+$ErrorActionPreference='Stop'+$json = if ($B64) { [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($B64)) } else { '{}' }+try { $a = $json | ConvertFrom-Json } catch { $a = @{} }+# stamp caller identity into args too (some verbs read args.caller; the header covers the rest)+try { $a | Add-Member -NotePropertyName caller -NotePropertyValue (@{aiThread='user-taskbar-menu'}) -Force } catch {}+$body = (@{ command=$Verb; args=$a } | ConvertTo-Json -Depth 12 -Compress)+$portFile = Join-Path $env:LOCALAPPDATA 'Adom Bridge\\pup-bridge-port.json'+$ok=$false+try {+  $port = (Get-Content $portFile -Raw -ErrorAction Stop | ConvertFrom-Json).port+  Invoke-RestMethod -Uri ("http://127.0.0.1:{0}/command" -f $port) -Method Post -ContentType 'application/json' -Headers @{'x-adom-caller-thread'='user-taskbar-menu';'x-adom-caller-reason'='jump-list click'} -Body $body -TimeoutSec 6 | Out-Null+  $ok=$true+} catch { $ok=$false }+if (-not $ok) {+  # pup is not answering (idled/died). Go through the CLI so ab respawns it.+  $cli = Join-Path $env:LOCALAPPDATA 'Adom Bridge\\adom-bridge-cli.exe'+  if (-not (Test-Path $cli)) { $cli = Join-Path $env:LOCALAPPDATA 'Adom Desktop\\adom-desktop-cli.exe' }+  if (Test-Path $cli) { & $cli $Verb $json | Out-Null }+}+`;+  try {+    let cur = null; try { cur = fs.readFileSync(p, 'utf8'); } catch (e) {}+    if (cur !== script) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, script); }+  } catch (e) { console.error('[jumplist] launcher write failed:', e.message); }+  return p;+}+// Build a jump-list task that routes through the pup-owned launcher (direct-to-pup, CLI fallback).+function pupTask(verb, obj, meta) {+  const b64 = Buffer.from(JSON.stringify(obj || {}), 'utf8').toString('base64');+  const launcher = pupJumplistLauncherPath();+  return {+    title: meta.title,+    description: meta.description,+    iconPath: meta.iconPath,+    target: 'powershell.exe',+    args: `-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "${launcher}" ${verb} ${b64}`,+  };+}+function _isUnknownVerb(r, raw) { return /unknown (desktop )?command|unknown verb/i.test(((r && r.error) || '') + ' ' + (raw || '')); }+// v2.0.118: delete any "Adom Pup" Start Menu shortcut a prior pup version left (per-user and,+// best-effort, all-users). Pup's own shortcut, safe to remove; HKCU/per-user needs no admin.+function removePupStartMenuShortcut() {+  if (process.platform !== 'win32') return;+  const dirs = [];+  if (process.env.APPDATA) dirs.push(process.env.APPDATA);+  if (process.env.ProgramData) dirs.push(process.env.ProgramData);+  for (const base of dirs) {+    const lnk = path.join(base, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Adom Pup.lnk');+    try { if (fs.existsSync(lnk)) { fs.unlinkSync(lnk); console.log(`[identity] removed stale "Adom Pup" Start Menu shortcut: ${lnk}`); } } catch (e) { /* all-users needs admin; ignore */ }+  }+}+async function ensurePupIdentityRegistered() {+  if (process.platform !== 'win32' || _identitySupported === false) return false;+  if (_identityRegistered) return true;+  try {+    // v2.0.118 (John: Adom users don't want an "Adom Pup" entry in their Start Menu). Register the+    // anchor AUMID registry-ONLY (shortcut:false) and brand it by writing IconResource straight into+    // HKCU — the SAME shortcut-free technique the per-window AUMIDs already use — so grouped taskbar+    // mode still has its grouping anchor but NO Start Menu shortcut is ever created. The old+    // pinnable-launcher shortcut is dropped deliberately: nobody asked for it and users dislike it.+    removePupStartMenuShortcut();   // self-heal any shortcut a prior pup version created on this machine+    writeAumidIcon(PUP_APP_ID, pupIconPath());+    const r = await chrome.abCommand('desktop_register_app_identity', {+      appId: PUP_APP_ID, displayName: PUP_DISPLAY_NAME, iconPath: pupIconPath(), shortcut: false,+    }, { timeoutMs: 8000 });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+    if ((r && r.success === false) || _isUnknownVerb(r, raw)) {+      if (_isUnknownVerb(r, raw)) { _identitySupported = false; console.log('[identity] AD lacks register_app_identity (needs >=1.9.134) — overlay badge stays'); }+      else console.log(`[identity] register failed: ${((r && r.error) || raw || '').slice(0, 140)}`);+      return false;+    }+    _identitySupported = true; _identityRegistered = true;+    console.log('[identity] Adom.Pup anchor AUMID registered (HKCU registry-only, NO Start Menu shortcut — v2.0.118)');+    return true;+  } catch (e) { console.log(`[identity] register threw: ${e.message}`); return false; }+}+// v2.0.25 (John watched the button mutate 3x on open: overlay-on-chrome-icon, then AUMID recreate,+// then overlay again): the create-time stamp fires BEFORE the session registers, so its result had+// nowhere to be recorded — the later re-brand then re-committed the SAME appId (visible button+// recreate #2). Remember pre-registration stamps here; launchSession copies them onto the session+// at registration so the idempotence gate makes the second stamp a true no-op.+const _earlyStamped = new Map();   // sessionId -> appId stamped before session registration+let _stampLandedThisBoot = false;   // v2.0.186 (#51): gates the AUMID prune until re-stamps land after a respawn+async function stampPupIdentity(sessionId, opts) {+  // v1.9.271: the dashboard must never gang onto the shared button. v1.9.272 (John: "if we're in+  // aumid mode then the pup dashboard should be in that mode too. right now you're being+  // inconsistent"): so in AUMID mode it gets its OWN DEDICATED identity (Adom.Pup.Dashboard) - own+  // button always, but wearing the same tile system as every other stamped window. The flag rides+  // to the appId computation below; grouped mode never absorbs it.+  // v1.9.185 minimal-touch: the AUMID stamp is the single most invasive thing pup does to a window+  // it does not own (Windows DESTROYS and RECREATES the taskbar button on an AppUserModelID change).+  // Gated at the choke point so no caller can leak it.+  // v1.9.248 reverted the silent 1.9.245 re-enable (see dev-skills/pup-aumid-decisions).+  // v1.9.259: John EXPLICITLY re-decided, per that skill's process rule, as a USER-CONTROLLED+  // EXPERIMENT toggle (2026-08-01: "a key setting is letting me turn on AUMID taskbar icons or+  // leaving them off... then i can play around with having it on or off and see if aumid is what's+  // crashing the app or not"). Default stays OFF. When the user turns it on: ONE gentle stamp per+  // window at creation ({once}), never the category re-stamp churn.+  // v1.9.279: the stamp is now IDEMPOTENT and SELF-CORRECTING. aumidIcons is the single master+  // switch (works the same in minimal-touch or not). The old {once} guard blocked legitimate+  // identity CHANGES (grouped->split never re-stamped, so John's split setting never took effect);+  // idempotency now comes from the desired-vs-current appId compare below (a real change re-stamps+  // exactly once; no change is a no-op), so `opts` is ignored and reconcileModel can call this every+  // sweep with zero churn.+  if (pupSettings().aumidIcons !== 'on') return false;+  if (process.platform !== 'win32' || !sessionId || _identitySupported === false) return false;+  const _stampGen = _identityGen;   // v2.0.216: abort if a mode change lands while this stamp is in flight+  if (!_identityRegistered) { await ensurePupIdentityRegistered(); if (_identitySupported === false) return false; }+  try {+    // v1.8.81: PER-SESSION AUMID (Adom.Pup.<sessionId>). A single shared AUMID made Windows+    // GROUP every pup window into ONE taskbar button — killing per-window differentiation and+    // making the tab-count overlay show whichever window last updated (John caught 4 windows+    // masquerading as one button with a wrong badge). Per-session ids restore one button per+    // window; the per-window RelaunchIconResource/RelaunchDisplayNameResource AD sets are what+    // brand an UNREGISTERED AUMID's button, and the registered plain "Adom.Pup" stays as the+    // pinnable Start Menu launcher.+    // v1.9.27 (AD's Win11 measurement, WIN11_TASKBAR_ICON.md Part 7): the taskbar TILE is baked+    // when the BUTTON is created and NEVER re-read — WM_SETICON, DeleteTab/AddTab, SHChangeNotify,+    // FRAMECHANGED, RedrawWindow, TaskbarCreated all leave it unchanged. The ONLY live repaint is+    // making the shell create a NEW button, which happens when the window is stamped with a+    // DIFFERENT appId. So the per-session appId now CARRIES THE CATEGORY:+    //     Adom.Pup.<sid>.<category>+    // A category flip = register new appId -> stamp it -> unregister the old one (~1s, no flicker,+    // AD-verified). TRADEOFF (Windows constraint, by design): a taskbar PIN binds to one AUMID, so+    // a pin made under an old category shows the running window as a separate button. Per-session+    // windows are transient and their pins launch via relaunchCommand anyway, so tile-tracks-state+    // wins here; GROUPED mode keeps the stable pinnable Adom.Pup.+    const grouped = pupSettings().taskbarGrouping === 'grouped';+    const sess = sessions.get(sessionId);+    // v2.0.168: pre-registration stamps (no session yet => owner unknowable) are SKIPPED in Thread+    // Tiles mode — they could only mint an intermediate per-session id the button would transition+    // through, violating the direct CfT -> thread-tile rule. The registered-session stamp follows+    // within a beat and carries the real thread identity.+    if (!sess && pupSettings().threadTiles === 'on') return false;+    // v1.9.52 — DO NOT stamp a transient ".plain" identity for a window that is merely still LOADING.+    // A freshly-opened window sits on about:blank for a beat (category null -> "plain"), then lands on+    // its real URL (category web/wiki/app). The old code stamped ".plain" during that beat, which+    // REGISTERED an Adom.Pup.<sid>.plain.<gen> AUMID — a full taskbar identity with NO jump list — and+    // created a button under it, then created ANOTHER button under ".category" a moment later. Windows+    // caches the button->AUMID->jumplist association per button; that plain->category churn is what+    // made the jump list "blip out and need a second right-click" (Explorer first resolved the stale+    // ".plain" association, which had no list). Skipping the plain stamp while a real URL is loading+    // means the button is created ONCE, under its final category AUMID, with its jump list already+    // attached — so the menu resolves on the first click. A genuinely category-less window (a page+    // that never leaves about:blank) still gets "plain" after a few attempts so it is never unbranded.+    const rawCat = pupCategory(sessionId);+    // v2.0.168 THREAD TILES DIRECT TRANSITION (John: "you should be going direct from the standard+    // CfT taskbar icon to your ai-thread name icon — no intermediate AUMID"). A thread-eligible+    // window's identity does not depend on the page category at all, so do NOT wait for the category+    // to settle (and never mint a pre-registration intermediate id, below) — stamp the thread AUMID+    // as the FIRST and ONLY identity the button ever transitions to.+    const _threadEligible = pupSettings().threadTiles === 'on' && sess && tileOwnerName(sess, sessionId);+    if (!grouped && !rawCat && !_threadEligible) {+      const hasRealUrlLoading = (sess && (sess.tabs || []).some(t => {+        try { const u = t.page && t.page.url(); return u && !/^(about:blank|chrome|edge|devtools):/i.test(u); } catch (e) { return false; }+      }));+      const attempts = (sess && sess._plainStampAttempts) || 0;+      if (hasRealUrlLoading || attempts < 6) {+        if (sess) sess._plainStampAttempts = attempts + 1;+        return false; // pending — the retry loop / framenavigated recheck re-stamps once the category is known+      }+    }+    if (sess) sess._plainStampAttempts = 0;+    const cat = rawCat || 'plain';+    const _dashInfra = sessionId === 'pup-dashboard';+    // v1.9.321 (John: "i like that you just group to each category icon"): make GROUPED mode actually+    // group BY CATEGORY when the category icon-style is on — windows of the same category share one+    // AUMID (Adom.Pup.<cat>), so you get up to 5 category buttons (all wiki-signed-in windows under the+    // padlock button, all open-web windows under the globe, etc.), each wearing its category icon.+    // Previously grouped lumped EVERY window under one Adom.Pup button regardless of style. Plain+    // "standard" style still collapses to the single Adom.Pup button (one icon, so per-category buttons+    // would be pointless). Split is unchanged (one button per window).+    const _catGroup = grouped && pupSettings().aumidIconStyle === 'category';+    // v2.0.152 THREAD TILES: when on, every window of an ai-thread shares ONE AUMID keyed on the+    // thread name, so the whole thread collapses to a single named taskbar button. Takes precedence+    // over category/grouped/split. The dashboard keeps its own identity; a window with no owner+    // thread falls through to the normal modes.+    const _thread = (!_dashInfra && pupSettings().threadTiles === 'on' && sess && tileOwnerName(sess, sessionId)) || null;+    let appId = _dashInfra ? `${PUP_APP_ID}.Dashboard.${PUP_ICON_GEN}`+      : _thread ? threadAumid(_thread)+      : _catGroup ? `${PUP_APP_ID}.${cat}.${PUP_ICON_GEN}`+      : grouped ? PUP_APP_ID+      : `${PUP_APP_ID}.${sessionId}.${cat}.${PUP_ICON_GEN}`;+    // v2.0.7 (John chose "stamp once, then freeze"): once a per-session window has been branded with a+    // real category, DO NOT re-stamp it when its category later changes (e.g. adding a web tab to a wiki+    // window flips the category to "mixed"). Every category change recreates the taskbar button plus its+    // jump-list and overlay — the multi-tab churn John kept catching. Freezing the appId after the first+    // real stamp trades a possibly-stale tile (a wiki window that later goes mixed still reads "wiki")+    // for ZERO taskbar churn for the window's life. Grouped/dashboard keep their own identities; the+    // active-tab favicon overlay still updates (that is the point, not churn), and the jump-list still+    // follows a view (public<->authed) flip since that changes the menu, not the button.+    if (sess && sess._frozenAppId && !_dashInfra && !grouped && !_thread) appId = sess._frozenAppId;+    const prevAppId = sess && sess._curAppId;+    // v1.9.279 THE IDEMPOTENCY GATE: only touch the window when its identity actually changes.+    // desktop_set_window_identity RECREATES the taskbar button every call, so firing it when nothing+    // changed is exactly the churn/flicker (and suspected crash pressure) we must never cause. Equal+    // desired-vs-current appId => no-op. This is what lets reconcileModel run every sweep safely.+    if (prevAppId === appId) {+      // v2.0.378 (#83, the blank-button ghost): a RECOVERED window arrives with _curAppId restored,+      // so this gate made its whole identity a no-op — but the shell state behind that id was built+      // by a Chrome relaunch that could precede the registration being readable (or the key could be+      // gone), and nothing ever rebuilt the button: a badge floating on an empty tile. Once per+      // recovered session: re-assert the registration and rebuild the button, then trust the gate.+      if (sess && sess._recovered && !sess._recoveryIdentityVerified) {+        sess._recoveryIdentityVerified = true;+        try { await registerPupAumid(appId, sessionId); } catch (e) {}+        try {+          if (!sess._hwnd) await resolveSessionHwndByBounds(sess, sessionId);+          const _rh = (sess._hwnd && await hwndBelongsToPup(sess._hwnd, sess)) ? sess._hwnd : null;+          if (_rh) { console.log(`[identity] "${sessionId}" recovered window: re-asserted ${appId} + one button rebuild (#83)`); nudgeTaskbarButton(_rh, sessionId); }+        } catch (e) {}+      }+      return true;+    }+    // v2.0.2 COALESCE THE CATEGORY-SETTLE CHURN (John: "why did i see the AUMID applied 3x when i+    // opened that window?"). A freshly-opened window's category refines as it loads and authenticates+    // (loading -> web -> wiki-signed-in), and every change re-stamps, which RECREATES the taskbar+    // button, so a single open flickered the button 2-3 times. Debounce the commit: record the+    // desired appId and stamp ONCE after a short quiet window; a further category change within the+    // window just updates the target and is picked up by the single scheduled commit, so the+    // intermediate tiles never get stamped. Same one-commit-per-real-change rule as the overlay paint.+    // The idempotency gate above already makes a stable window a no-op, so this only ever debounces a+    // genuine category CHANGE, never steady state. The commit re-enters with {_commit:true} to skip+    // the debounce and stamp the (by-then settled) category.+    const AUMID_SETTLE_MS = 1800;+    if (sess && !(opts && opts._commit)) {+      sess._desiredAppId = appId;+      if (!sess._aumidCommitTimer) {+        sess._aumidCommitTimer = setTimeout(() => {+          sess._aumidCommitTimer = null;+          if (sessions.has(sessionId)) stampPupIdentity(sessionId, { _commit: true }).catch(() => {});+        }, AUMID_SETTLE_MS);+      }+      return true; // deferred; the scheduled commit stamps the settled category exactly once+    }+    // Register the NEW appId BEFORE stamping (AD's verified order) so the fresh button resolves+    // its icon/name from the registry the moment it is created.+    if (true) {+      try { await registerPupAumid(appId, sessionId); } catch (e) {}+      // v1.9.61 — ORDER MATTERS (the live-tile trap, from AD's window-identity skill): Win11 bakes a+      // taskbar button's art when the button is CREATED and never re-reads it. Stamping the new appId+      // below is what creates that button. We used to brand the jump-list header AFTER the stamp, so+      // the header baked GENERIC and the later headerBranded:true had no visible effect — exactly what+      // John kept seeing. Brand the header (writes the window's Relaunch trio) FIRST, so the button is+      // born with it.+      if (pupSettings().jumpLists === 'on') { try { await updateWikiJumplist(sess, sessionId, { appId }); } catch (e) {} }+    }+    // v1.9.307 (John turned AUMID on and only 1 of 2 windows stamped — "why can't you get this right?"):+    // the stamp resolved the window by the TITLE TAG, which is OFF, so a window whose title lacks+    // "(ai-thread: X)" (the dashboard, whose page sets its own title) could NEVER be found — the log+    // said "No visible window with title containing (ai-thread: pup-dashboard...". Target by the+    // resolved HWND instead, the exact same handle the overlay paint + flash already use successfully.+    // Title stays as a fallback only for a window we somehow have no hwnd for.+    if (sess && !sess._hwnd) { try { await resolveSessionHwndByBounds(sess, sessionId); } catch (e) {} }+    const _stampHwnd = (sess && sess._hwnd && await hwndBelongsToPup(sess._hwnd, sess)) ? sess._hwnd : null;+    // v2.0.152 THREAD TILES: the per-window Relaunch icon must match the thread tile too, or Windows+    // would paint the default pup icon on the born button (it prefers the window's Relaunch icon over+    // the registry one). registerPupAumid ran just above and cached the .ico, so read it synchronously.+    // v2.0.222: -e<epoch> suffix — the plain-mode registry sweep deletes IconResource keys, and+    // ab's icon-handle cache (keyed by cacheKey) would otherwise skip re-writing them on the next+    // stamp, leaving buttons with the right NAME and no ART (the r1-tiles 0.017-teal ralph fail).+    const _regEpoch = Number(pupSettings()._stripEpoch || 0);+    let _stampIcon = pupIconPath(sessionId), _stampName = pupDisplayName(sessionId), _cacheKey = `pup-${sessionId}-${cat}-e${_regEpoch}`;+    if (appId.startsWith(`${PUP_APP_ID}.thread.`)) {+      const _tn = tileOwnerName(sess, sessionId);+      if (_tn) {+        // v2.0.157 THREAD TILES — render the name tile RIGHT HERE (stampPupIdentity always runs; the+        // registerPupAumid path was short-circuiting on already-registered appIds). Cached + deduped,+        // so a second caller is cheap. Any live browser can draw the offscreen 256px tile.+        let _tp = path.join(THREAD_TILE_DIR, `${threadSlug(_tn)}.ico`);+        let _ok = false; try { _ok = fs.existsSync(_tp) && fs.statSync(_tp).size > 100; } catch (e) {}+        if (!_ok) {+          // Render on an EXISTING page (no new window). Prefer this session's own tab; else any live tab.+          let _rp = sess && (sess.tabs || []).map(t => t && t.page).find(Boolean);+          if (!_rp) { outer: for (const [, s2] of sessions) { for (const t2 of (s2.tabs || [])) { if (t2 && t2.page) { _rp = t2.page; break outer; } } } }+          if (_rp) { try { const r = await renderThreadTileIco(_rp, _tn); if (r) _tp = r; } catch (e) { console.log(`[thread-tile] stamp render err: ${e.message}`); } }+        }+        // v2.0.176 (John: "why do you apply the aumid TWICE? the generic icon, then the ai-thread+        // name icon — that seems overkill"). He was right: _stampIcon defaults to the generic/category+        // pup tile, so if the name tile had not finished rendering yet, the thread AUMID got stamped+        // with the GENERIC icon and the real tile arrived on a LATER stamp — two visible identity+        // changes for one window. There is no reason to ever stamp a thread identity without its+        // tile: bail and let the retry stamp once, when the art is ready. Until then the window+        // simply keeps Chrome's own icon, which is honest (pup has not claimed it yet) and is the+        // single transition John asked for: default Chrome -> thread name tile, nothing in between.+        let _tileReady = false; try { _tileReady = fs.existsSync(_tp) && fs.statSync(_tp).size > 100; } catch (e) {}+        if (!_tileReady) {+          console.log(`[thread-tile] "${sessionId}" stamp DEFERRED — tile for "${_tn}" not rendered yet (no generic-icon placeholder stamp)`);+          return false;+        }+        _stampIcon = _tp;+        _stampName = String(_tn);+        // v2.0.166 THE STALE-HANDLE TRAP: ab caches icon HANDLES by cacheKey for its process+        // lifetime ("ab holds the icon handles for its process lifetime"). An unversioned key meant+        // a thread whose tile was first loaded in the broken PNG-ico era kept serving that dead+        // handle forever — the fixed BMP tile on disk was never re-read, and the button fell back to+        // the default Chrome-for-Testing identity (measured: fusion-verb-tests/reclaimed, first seen+        // AFTER the BMP fix, branded fine; shotlog/sxsw, first seen before it, never did). Version+        // the key with the tile generation so new art always gets a fresh handle.+        // v2.0.270 (John: "why is this pup icon so wrong? its not showing the ai-thread name"):+        // versioning by GENERATION is not enough. ab caches icon HANDLES by cacheKey for its whole+        // process lifetime, and the generation only changes when WE bump it — so a button whose+        // handle ab cached from an earlier stamp (category art, before this thread's tile finished+        // rendering) keeps serving that dead handle forever. Measured on John's "Bills" window: the+        // AUMID was registered, its IconResource pointed at the right .ico, and the .ico itself+        // rendered "Bills" correctly at all four sizes — yet the shell drew the category tile, and a+        // full threadTiles off/on re-register did NOT shift it, because the cacheKey never changed.+        // Key on the tile's CONTENT instead: mtime+size means any re-render is automatically a new+        // handle, and a stale one is structurally impossible rather than something an audit has to+        // notice after the fact.+        let _tsig = '0';+        try { const _stt = fs.statSync(_tp); _tsig = `${Math.round(_stt.mtimeMs)}-${_stt.size}`; } catch (e) {}+        _cacheKey = `pup-thread-${threadSlug(_tn)}-${THREAD_TILE_GEN}-t${_tsig}-e${_regEpoch}`;+      }+    }+    if (_stampGen !== _identityGen) { console.log(`[identity] "${sessionId}" stamp ABORTED (identity generation moved: a mode switch superseded this in-flight stamp)`); return false; }+    const r = await chrome.abCommand('desktop_set_window_identity', {+      ...(_stampHwnd ? { hwnd: _stampHwnd } : {}),+      titleContains: `(${TITLE_LABEL}: ${sessionId}`, cacheKey: _cacheKey,+      appId, displayName: _stampName, iconPath: _stampIcon,+      // v1.9.11 (AD ≥1.9.141): brand the taskbar RIGHT-CLICK MENU HEADER row (it showed the+      // chrome.exe icon for months). There is NO "ApplicationIcon" registry value on AUMID keys+      // and the registry cannot brand the header at all — the header obeys the window's+      // Relaunch* property TRIO, and Windows IGNORES RelaunchIconResource unless RelaunchCommand+      // is ALSO set. So we must pass a real relaunch command. It doubles as what a header click+      // or a taskbar PIN launches, so it has to genuinely refocus THIS session: pup_focus_window+      // carries a foregroundReason (the raise gate refuses without one, and a header click IS a+      // real user request to see the window). Quotes are backslash-escaped for Windows argv —+      // same trap that made jump-list clicks no-op in 1.9.9.+      relaunchCommand: pupRelaunchCommand(sessionId),+    }, { timeoutMs: 6000 });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+    // v2.0.164 THREAD-TILES DIAGNOSTIC (kept, low-noise): the cross-process AUMID read from the+    // container is blind, and the bridge does not surface its console here, so record the ACTUAL+    // stamp decision + AD's reply to a rolling temp file (last ~200 lines) we can cat via ab. This+    // is how we confirm the thread appId + tile icon actually reach desktop_set_window_identity.+    try {+      const _dbgP = path.join(os.tmpdir(), 'pup-stamp-debug.log');+      const dbg = { t: Date.now(), sid: sessionId, owner: (sess && (sess.owner || sess._ownerThread)) || null,+        tt: pupSettings().threadTiles, isThreadAppId: appId.startsWith(`${PUP_APP_ID}.thread.`), appId,+        stampIconLeaf: String(_stampIcon).split(/[\\/]/).pop(), success: r && r.success, reply: (raw || '').slice(0, 160) };+      fs.appendFileSync(_dbgP, JSON.stringify(dbg) + '\n');+    } catch (e) {}+    if ((r && r.success === false) || _isUnknownVerb(r, raw)) {+      if (_isUnknownVerb(r, raw)) _identitySupported = false;+      else console.log(`[identity] "${sessionId}" stamp FAILED (appId=${appId}): ${((r && r.error) || raw || 'no response').slice(0, 140)}`);+      return false;+    }+    const o = raw ? JSON.parse(raw) : {};+    const data = o.data || o;+    const ok = !!(data.ok || (data.applied && data.applied.length));+    if (!ok) console.log(`[identity] "${sessionId}" stamp NOT applied (appId=${appId}): ${(raw || '').slice(0, 140)}`);+    if (ok) {+      console.log(`[identity] "${sessionId}" stamped appId=${appId}: ${(data.applied || []).join(',') || 'ok'}`);+      try { markIconApplied(sessionId); } catch (e) {}   // v2.0.289: end the icon-apply taskbar progress bar+      if (!sess) _earlyStamped.set(sessionId, appId);   // v2.0.25: pre-registration stamp — see above+      if (sess && prevAppId !== appId) {+        _stampLandedThisBoot = true;+        sess._curAppId = appId;+        // v2.0.168: timestamp a landed THREAD stamp — the overlay paint waits a settle beat after+        // this so the shell finishes moving the window onto the thread button before any+        // SetOverlayIcon call can latch the button association (the 2.0.167 regression).+        if (appId.startsWith(`${PUP_APP_ID}.thread.`)) {+          sess._threadStampAt = Date.now();+        }+        // v2.0.180 (the "prove it" run): the button nudge now fires on EVERY landed identity+        // CHANGE, not just thread stamps — this Win11 build never rebuilds a button on an in-place+        // AUMID change, so tiles<->aumid mode switches (and any future identity move) need the same+        // hide/re-show the thread transition needed. Once per (session, appId), foreground-skipped.+        if (true) {+          // v2.0.169 THE BUTTON NUDGE (measured live, the missing last mile): on this Win11 build the+          // shell does NOT recreate a taskbar button when a live window's AUMID changes — the button+          // stays latched to the birth identity (default CfT) no matter how cleanly the stamp applied+          // (AD replied applied:[appId,relaunchIcon,displayName,icon] and the button never moved).+          // A hide + re-show (SW_HIDE -> SW_SHOWNOACTIVATE, or SW_SHOWMINNOACTIVE when the user had+          // it minimized; never activates, never changes what the user chose to see) forces the shell to+          // destroy and recreate the button, which reads the CURRENT thread AUMID -> the name tile+          // appears (verified: the stuck "Google Chrome for Testing" button became "kicad" on nudge).+          // Once per (session, appId); skipped when the window is foreground (a visible 600ms blink+          // of the user's active window would be disruptive — it heals on the next natural recreate).+          if (sess._threadNudged !== appId && _stampHwnd) {+            sess._threadNudged = appId;+            setTimeout(() => { nudgeWhenIconReady(_stampHwnd, sessionId, appId).catch(() => {}); }, 700);+          }+        }+        // v2.0.7: freeze the identity at this first real stamp — no re-stamp on later category changes.+        if (!_dashInfra && !grouped) sess._frozenAppId = appId;+        // v1.9.245: the stamp DESTROYED and RECREATED the taskbar button, so whatever overlay was+        // painted is gone from glass. Mark bare so the 10s enforcer repaints the new button; also+        // drop the cached hwnd — the new button may live on a new handle.+        // v2.0.183: keep sess._hwnd. The v1.9.245 comment feared "the new button may live on a new+        // handle", but a taskbar BUTTON is not the window HWND — an AUMID re-stamp never changes the+        // OS window handle, and nulling it here made every sweep re-resolve every window by bounds+        // (an enum_windows storm, John's 2nd spam report). hwndBelongsToPup still validates on use.+        sess._appOverlay = false; sess._overlayEnforceAt = 0;+        // The jump list attaches to the AUMID — a new appId starts with NO jump list, so force a+        // re-attach on the next updateWikiJumplist pass.+        if (pupSettings().jumpLists === 'on') updateWikiJumplist(sess, sessionId).catch(() => {});   // deduped by _wikiJumplistKey+        // v1.9.112 (John: "still shows for 200ms then disappears; second right-click works") —+        // RE-COMMIT THE JUMP LIST ONCE THE BUTTON IS REAL. Measured elimination got us here: nothing+        // fires while idle (0 log lines in 40s), and windows with exactly ONE AUMID registration blip+        // too, so it is neither a race nor category churn. What every fresh window DOES share is that+        // the stamp above RECREATES the taskbar button (Win11 bakes a button's identity at creation),+        // and the destination list was committed BEFORE that — deliberately, so the header art bakes.+        // So the button is born pointing at a list committed against a button that did not exist yet,+        // and the first right-click is the shell reconciling that, which is why the SECOND click works.+        // Re-committing ~1.6s after the stamp lands the list on the button that actually exists.+        // ⚠ OUTCOME (verified with John, 1.9.112): this did NOT fix the blip. Three fresh windows each+        // got exactly 1 AUMID registration + 1 post-stamp re-commit and ALL still showed the flyout for+        // ~200-300ms before it vanished. Keeping the re-commit because attaching the list to the live+        // button is correct hygiene, but do NOT read this as the cure. The blip is UNRESOLVED and is+        // tracked on AD (it owns desktop_set_window_identity and the shell interaction) — see the+        // pup-bridge-debug skill for the four hypotheses already eliminated, so nobody re-derives them.+        // v1.9.317 (John, ~10th time reporting the blip: "the FIRST right-click shows for a blip then+        // goes away, the 2nd ~1s later works"): this forced re-commit (clearing the dedup key so it+        // RE-attaches) fired a REDUNDANT CommitList ~1.6s after every stamp. Jump lists live on the+        // AUMID and were ALREADY committed during the stamp above, so the re-attach was never needed —+        // it only gave the shell one more chance to rebuild-and-dismiss a flyout the user just opened.+        // DO NOT clear the dedup key: let updateWikiJumplist no-op when the tasks are unchanged (same+        // appId+tasks), and only re-commit if the appId genuinely changed (a new button). This removes+        // pup's contribution to the blip. NOTE (honest): prior testing showed even ONE commit blips for+        // ~200-300ms, so a residual race remains that is the Windows shell / AD's (it rebuilds the menu+        // on ANY CommitList that lands while it is shown) — see dev-skills/pup-jumplist. Until that is+        // solved AD-side, a mimic'd right-click must click TWICE (first blips, second holds).+        setTimeout(() => {+          try {+            if (!sessions.has(sessionId)) return;+            const s2 = sessions.get(sessionId);+            if (!s2 || s2._curAppId !== appId) return;   // superseded by a later stamp — leave it alone+            updateWikiJumplist(s2, sessionId).catch(() => {});   // dedup-respecting: no-op unless tasks changed+          } catch (e) {}+        }, 1600);+        // Unregister the superseded appId (cleanup, AD step 3). Never the pinnable base id.+        if (prevAppId && prevAppId !== PUP_APP_ID) { unregisterPupAumid(prevAppId).catch(() => {}); }+      }+    }+    return ok;+  } catch (e) { return false; }+}+// v1.9.27: registrations are keyed by the FULL category-carrying appId. The category is baked+// into the id itself (Adom.Pup.<sid>.<cat>), so a Set suffices — a state change is a NEW id.+const _registeredPupAumids = new Set();+async function registerPupAumid(appId, sessionId) {+  if (_identitySupported === false || appId === PUP_APP_ID || _registeredPupAumids.has(appId)) return;+  // v2.0.152 THREAD TILES: a thread AUMID (Adom.Pup.thread.<slug>.<gen>) wears the thread's NAME tile+  // instead of the standard/category icon. Render it (once per thread, cached) from a live browser,+  // and fall back to the normal pup icon if the render fails so the button is never left unbranded.+  let iconPath = pupIconPath(sessionId);+  let displayName = pupDisplayName(sessionId);+  const _threadAumid = appId.startsWith(`${PUP_APP_ID}.thread.`);+  if (_threadAumid) {+    try {+      const s = sessions.get(sessionId);+      const threadName = tileOwnerName(s, sessionId);+      // Render on an existing page (no new window) — this session's tab, else any live tab.+      let _rp = s && (s.tabs || []).map(t => t && t.page).find(Boolean);+      if (!_rp) { outer2: for (const [, s2] of sessions) { for (const t2 of (s2.tabs || [])) { if (t2 && t2.page) { _rp = t2.page; break outer2; } } } }+      if (threadName && _rp) {+        const tile = await renderThreadTileIco(_rp, threadName);+        // v2.0.176: never brand a thread AUMID with the GENERIC pup icon (see stampPupIdentity) —+        // that placeholder is exactly the first of the two icon applies John flagged. No tile yet =+        // register nothing; the stamp defers too, and both land once when the art is ready.+        if (!tile) {+          // v2.0.250 (#51 second half): a defer used to be terminal — the window stayed unbranded+          // until unrelated churn or a manual taskbarMode flip forced a fresh sweep (John measured+          // "many minutes"). Re-attempt on a bounded backoff so the tile lands on its own.+          const _k = `tile:${threadName}`;+          const _n = (_tileDeferRetries.get(_k) || 0) + 1;+          if (_n <= 5) {+            _tileDeferRetries.set(_k, _n);+            const _wait = Math.min(60000, 4000 * _n);+            console.log(`[thread-tile] register DEFERRED for "${threadName}" — tile not ready; retry ${_n}/5 in ${Math.round(_wait / 1000)}s`);+            setTimeout(() => { brandSweep(`tile-defer retry ${_n} for "${threadName}"`).catch(() => {}); }, _wait);+          } else {+            console.log(`[thread-tile] register DEFERRED for "${threadName}" — tile STILL not ready after 5 retries; leaving it to the next sweep`);+          }+          return;+        }+        iconPath = tile;+        displayName = String(threadName);   // the taskbar hover/label reads the thread name too+      } else if (threadName) { return; }   // thread window, no page to draw with yet — wait, don't placeholder+    } catch (e) { console.log(`[thread-tile] register error: ${e.message}`); }+  }+  // v1.9.44: write IconResource FIRST, before the button can possibly exist. AD's registration does+  // not write it (see writeAumidIcon), and an AUMID with no icon is what paints white.+  // v2.0.161 (Thread-Tiles taskbar bug): writeAumidIcon is a ~10ms HKCU reg.exe write — NO button+  // destruction, NO shell/AD roundtrip. It is the NON-RACY "belt" that the per-window Relaunch prop+  // ("braces") only RACES for. So it MUST run even under minimal-touch: with minimal-touch on,+  // registerPupAumid used to bail at the very top, leaving a thread/category AUMID branded ONLY by the+  // racy Relaunch prop — which loses the race against Win11 baking the button art and paints the+  // DEFAULT pup icon (measured: 0 Adom.Pup.thread.* registry keys existed). Write the icon here; only+  // the INVASIVE Start-Menu registration below stays gated under minimal-touch.+  writeAumidIcon(appId, iconPath);+  if (minimalTouch()) return;   // registry branded (the belt); skip the AD register_app_identity roundtrip+  try {+    const r = await chrome.abCommand('desktop_register_app_identity', {+      appId, displayName, iconPath, shortcut: false,+    }, { timeoutMs: 6000 });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+    if (_isUnknownVerb(r, raw)) return; // older AD without #207 — the per-window stamp still brands the taskbar button+    _registeredPupAumids.add(appId);+    console.log(`[identity] registered AUMID ${appId} (registry-only, icon=${pupCategory(sessionId) || 'plain'})`);+  } catch (e) {}+}+// v1.9.44 — WHY THE TASKBAR ICON GOES WHITE, and the fix. READ BEFORE TOUCHING ICON CODE.+//+// MEASURED on John's box: 129 of 129 HKCU AppUserModelId\Adom.Pup.* keys had IconResource = NULL.+// AD's desktop_register_app_identity ACCEPTS an iconPath but only materialises it when it also+// builds a Start Menu shortcut; with shortcut:false (our per-session path) the AUMID key is created+// with a DisplayName and NO IconResource at all. So the registry branded nothing, ever.+//+// Everything still LOOKED right because the per-window Relaunch* properties that+// desktop_set_window_identity sets do brand the button. But those are a RACE against Win11 baking+// the tile at button-creation time. Win the race -> correct icon. Lose it -> Windows has nothing to+// resolve for that AUMID and paints the GENERIC WHITE DOCUMENT. That is why the icon was white on+// ONE window (a long-lived session whose button predated its stamp) and correct on its siblings —+// it was never a category or icon-file problem, it was an unbranded AUMID plus a lost race.+//+// FIX: write IconResource into HKCU ourselves. HKCU needs no UAC, this bridge already runs ON the+// desktop (no AD roundtrip, no shell-approval prompt), and a registry value is not a race — it is+// there before and after the button exists. Keep the Relaunch* props too; they are what repaint a+// LIVE category flip. Belt and braces.+//+// If icons go white again, check IconResource FIRST (see pup-bridge-debug -> "white taskbar icon").+function writeAumidIcon(appId, iconPath) {+  if (process.platform !== 'win32' || !appId || !iconPath) return false;+  try {+    if (!fs.existsSync(iconPath)) { console.log(`[identity] icon MISSING on disk: ${iconPath}`); return false; }+    const key = `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`;+    // ",0" selects the first frame. reg.exe is used directly (not PowerShell) — no window, no profile+    // load, ~10ms, and it creates the key if the AD registration has not landed yet.+    execFileSync('reg', ['add', key, '/v', 'IconResource', '/t', 'REG_SZ', '/d', `${iconPath},0`, '/f'],+      { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });+    return true;+  } catch (e) { console.log(`[identity] IconResource write failed for ${appId}: ${e.message}`); return false; }+}++// ── THREAD TILES (v2.0.152, John) ───────────────────────────────────────────────────────────────+// Group every pup window/tab of one ai-thread under ONE taskbar button whose icon is that thread's+// NAME rendered on the teal tile. The name is rendered in the real Adom font (Familjen Grotesk) by+// CHROME — the ONE renderer guaranteed to have the woff2 — into a 256px PNG, which sharp downscales+// to a multi-size .ico. Rendered once per thread name and cached on disk.+const THREAD_TILE_GEN = 't7';   // t7: virgin AUMIDs after the registry-sweep era orphaned ab's cache (same story as i8). t6: font-size FLOOR + truncate-not-shrink (2.0.172, John: small text unreadable); bottom-left anchored. Gen bump = virgin AUMIDs + fresh ab icon-handle cache keys; the janitor reaps prior gens+const THREAD_TILE_DIR = path.join(os.tmpdir(), 'pup-thread-tiles', THREAD_TILE_GEN);+let _fgFontB64 = null;+function fgFontB64() {+  if (_fgFontB64 == null) { try { _fgFontB64 = fs.readFileSync(path.join(__dirname, 'extension', 'fg-700.woff2')).toString('base64'); } catch (e) { _fgFontB64 = ''; } }+  return _fgFontB64;+}+// AUMID- and filename-safe slug of an ai-thread name. Keep it stable + collision-resistant: a short+// hash suffix so "ah build" and "ah-build" never share a tile/button.+function threadSlug(name) {+  const s = String(name || 'thread').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24) || 'thread';+  const h = require('crypto').createHash('sha1').update(String(name || '')).digest('hex').slice(0, 6);+  return `${s}-${h}`;+}+// v2.0.271 — THE AUMID IS THE ICON CACHE KEY. Proven on John's "Bills" window (2026-08-21):+// Windows caches a taskbar button's icon PER AppUserModelID. Once the shell has drawn an AUMID with+// one icon, repointing that AUMID at new art is IGNORED. Measured, in order, all on the same window:+//   * the AUMID was registered and its IconResource pointed at the correct .ico+//   * the .ico itself rendered the "Bills" name tile correctly at 16/32/48/256+//   * a threadTiles off/on full re-register            -> art unchanged+//   * bumping the registration epoch (fresh ab cacheKey) -> art unchanged+//   * WM_SETICON with the tile on the window itself     -> art unchanged+//   * stamping a NOVEL AUMID string                     -> art CORRECT, instantly+// So the only thing that can move a wrong icon is a VIRGIN AUMID. THREAD_TILE_GEN already encodes+// that ("Gen bump = virgin AUMIDs"), but it is a hand-edited constant, so it cannot react when a+// thread's tile is re-rendered — which is exactly when the art changes and the shell must be told.+// Fold the tile's CONTENT into the AUMID: same tile bytes -> same AUMID (stable across restarts,+// no button churn); re-rendered tile -> virgin AUMID -> the shell is structurally forced to re-read+// the art. This is what makes wrong art unreachable instead of something an audit must notice.+function threadTileToken(name) {+  try {+    const st = fs.statSync(path.join(THREAD_TILE_DIR, `${threadSlug(name)}.ico`));+    const sig = `${Math.round(st.mtimeMs)}-${st.size}`;+    return 'i' + require('crypto').createHash('sha1').update(sig).digest('hex').slice(0, 6);+  } catch (e) { return ''; }   // no tile yet: plain gen AUMID; the stamp bails until the art exists+}+function threadAumid(name) {+  return `${PUP_APP_ID}.thread.${threadSlug(name)}.${THREAD_TILE_GEN}${threadTileToken(name)}`;+}+// Wrap a 256x256 PNG buffer into a multi-size .ico (256/48/32/16, each a PNG-compressed entry — the+// modern ICO form Windows Vista+ reads). sharp does the downscales (available on the desktop).+async function pngToIco(png256) {+  let sharp; try { sharp = require('sharp'); } catch (e) { return null; }+  // v2.0.160: the small sizes MUST be BMP/DIB (32bpp), not PNG — Windows' TASKBAR does not render+  // PNG-compressed ICO entries for small icons, so a PNG-only .ico fell back to the AUMID's default+  // registry icon (why the button showed the plain pup mark, not the name). BMP for 16/32/48; PNG is+  // fine (and smaller) only for the 256 entry.+  const sizes = [16, 32, 48, 256];+  const entries = [];+  for (const s of sizes) {+    if (s === 256) {+      entries.push({ s, data: await sharp(png256).resize(256, 256, { fit: 'fill' }).png().toBuffer() });+      continue;+    }+    const { data } = await sharp(png256).resize(s, s, { fit: 'fill', kernel: 'lanczos3' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });+    // BITMAPINFOHEADER: height is DOUBLED (XOR color bitmap + AND mask), 32bpp, uncompressed.+    const hdr = Buffer.alloc(40);+    hdr.writeUInt32LE(40, 0); hdr.writeInt32LE(s, 4); hdr.writeInt32LE(s * 2, 8);+    hdr.writeUInt16LE(1, 12); hdr.writeUInt16LE(32, 14);+    const xor = Buffer.alloc(s * s * 4);         // bottom-up BGRA+    for (let y = 0; y < s; y++) for (let x = 0; x < s; x++) {+      const si = ((s - 1 - y) * s + x) * 4, di = (y * s + x) * 4;+      xor[di] = data[si + 2]; xor[di + 1] = data[si + 1]; xor[di + 2] = data[si]; xor[di + 3] = data[si + 3];+    }+    const andRow = Math.ceil(s / 32) * 4;        // 1bpp mask, 4-byte aligned rows; all-zero = opaque+    const andMask = Buffer.alloc(andRow * s, 0);+    entries.push({ s, data: Buffer.concat([hdr, xor, andMask]) });+  }+  const N = entries.length;+  const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(N, 4);+  const dir = Buffer.alloc(16 * N);+  let offset = 6 + 16 * N;+  entries.forEach((e, i) => {+    const d = dir.subarray(i * 16, i * 16 + 16);+    d.writeUInt8(e.s >= 256 ? 0 : e.s, 0); d.writeUInt8(e.s >= 256 ? 0 : e.s, 1);+    d.writeUInt8(0, 2); d.writeUInt8(0, 3); d.writeUInt16LE(1, 4); d.writeUInt16LE(32, 6);+    d.writeUInt32LE(e.data.length, 8); d.writeUInt32LE(offset, 12); offset += e.data.length;+  });+  return Buffer.concat([header, dir, ...entries.map(e => e.data)]);+}+// v2.0.230: decoded-icon cache for the dashboard's per-render icon endpoints (path+mtime keyed).+const _iconBufCache = new Map();   // "path|mtimeMs" -> Buffer+function cachedIconPng(file, decode) {+  try {+    const mt = fs.statSync(file).mtimeMs;+    const key = `${file}|${mt}`;+    const hit = _iconBufCache.get(key);+    if (hit) return hit;+    const out = decode(fs.readFileSync(file));+    if (out) {+      if (_iconBufCache.size > 60) _iconBufCache.clear();+      _iconBufCache.set(key, out);+    }+    return out;+  } catch (e) { return null; }+}+const _tileDeferRetries = new Map();   // v2.0.250 (#51): bounded retries per thread whose tile render deferred+const _parkNoHwndLogged = new Set();   // v2.0.237: log the no-handle park once per session+const _threadTileInflight = new Map();   // name -> Promise (dedupe concurrent renders)+// Render (or return cached) the .ico path for a thread name. v2.0.158: draw the tile on an OFFSCREEN+// CANVAS inside an EXISTING session page (`page`) — the FG font is injected via the FontFace API — so+// NO new window/tab is created and pup's window machinery has nothing to enumerate/adopt (the old+// browser.newPage() render spawned an OS window that triggered a birth/reclaim enum_windows storm).+// The canvas is never appended to the DOM and the page's own content/title are untouched (invisible,+// non-destructive). Returns a data-URL PNG that the bridge converts to a multi-size .ico.+async function renderThreadTileIco(page, name) {+  const icoPath = path.join(THREAD_TILE_DIR, `${threadSlug(name)}.ico`);+  try {+    if (fs.existsSync(icoPath) && fs.statSync(icoPath).size > 100) {+      try { _tileDeferRetries.delete(`tile:${name}`); } catch (e) {}   // v2.0.250: art exists, stop retrying+      return icoPath;+    }+  } catch (e) {}+  // v2.0.228: a FIRST render means every button of this thread was built before its art existed+  // (Windows bakes a button's icon at creation) — audit shortly after so those get rebuilt. This is+  // the exact defect John's audit found on "ah build thread".+  scheduleIconAudit(`tile rendered for "${name}"`, 12000);+  if (_threadTileInflight.has(name)) return _threadTileInflight.get(name);+  const job = (async () => {+    if (!page) { console.log(`[thread-tile] "${name}" NO page passed`); return null; }+    try {+      fs.mkdirSync(THREAD_TILE_DIR, { recursive: true });+      const dataUrl = await withTimeout(page.evaluate(async (nm, fontB64) => {+        try {+          if (fontB64) { try { const f = new FontFace('FGtile', 'url(data:font/woff2;base64,' + fontB64 + ')'); await f.load(); document.fonts.add(f); } catch (e) {} }+          const box = 256, cv = document.createElement('canvas'); cv.width = box; cv.height = box;+          const ctx = cv.getContext('2d');+          ctx.fillStyle = '#00b8b1'; ctx.fillRect(0, 0, box, box);+          // v2.0.172 (John: "some text is too small to read — go bigger and only show as many chars+          // as you can get in"). Shrink-to-fit alone made long names microscopic ("rock climbing+          // berlin" wrapped to 3 unreadable lines). Now there is a hard FLOOR on the font size: a+          // name that cannot fit at the floor is TRUNCATED instead of shrunk, so every tile stays+          // legible at 32px and shows the leading characters that identify the thread. The floor is+          // calibrated to "reclaimed" (John's readable reference: 2 lines, ~4-5 chars each).+          // Line 1 of a 2-line tile also stays narrower than line 2 to duck the top-right badge.+          const FLOOR = box * 0.36;+          const wFor = (i, total) => (total === 2 && i === 0) ? box * 0.80 : box * 0.92;+          const setF = (s) => { ctx.font = '700 ' + s + "px FGtile,'Segoe UI',sans-serif"; };+          const wid = (t, s) => { setF(s); return ctx.measureText(t).width; };+          const fitSize = (lines) => {+            let s = box * 0.9;+            for (let i = 0; i < 90; i++) {+              let ok = true;+              for (let j = 0; j < lines.length; j++) { if (wid(lines[j], s) > wFor(j, lines.length)) { ok = false; break; } }+              if (ok && lines.length * s * 0.95 <= box * 0.88) break;+              s *= 0.95;+            }+            return s;+          };+          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 m = Math.ceil(nm.length / 2); cands.push([nm.slice(0, m), nm.slice(m)]); }+          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 = (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);+          }+          // v2.0.170 (John): anchor the name BOTTOM-LEFT — the overlay badge owns the top-right+          // corner, so bottom-left keeps the most text visible under it.+          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((ln, i) => ctx.fillText(ln, mx, baseY - (best.length - 1 - i) * lh));+          return cv.toDataURL('image/png');+        } catch (e) { return null; }+      }, name, fgFontB64()), 8000, 'tile.evaluate');+      if (!dataUrl || dataUrl.indexOf('base64,') < 0) { console.log(`[thread-tile] "${name}" canvas render returned nothing`); return null; }+      const png = Buffer.from(dataUrl.split('base64,')[1], 'base64');+      const ico = await pngToIco(png);+      if (!ico) { console.log(`[thread-tile] "${name}" pngToIco returned null (sharp missing?)`); return null; }+      fs.writeFileSync(icoPath, ico);+      console.log(`[thread-tile] rendered tile for "${name}" -> ${icoPath} (${ico.length}B, no window)`);+      return icoPath;+    } catch (e) { console.log(`[thread-tile] render failed for "${name}": ${e.message}`); return null; }+    finally { _threadTileInflight.delete(name); }+  })();+  _threadTileInflight.set(name, job);+  return job;+}+// v1.9.44: the AUMID keys LEAKED. A new key is minted per session per category, but only the+// immediately-superseded one is unregistered, and only in-process — so every bridge restart orphaned+// its whole set (129 keys had accumulated). Sweep on startup: drop any Adom.Pup.<sid>.* key whose+// <sid> is not a live session. Never touch the pinnable base id.+function pruneStaleAumids() {+  if (process.platform !== 'win32') return;+  // v2.0.186 (#51): after a respawn, RELOADED sessions carry _curAppId=null until their first stamp+  // lands, so every live thread key looked orphaned and the boot prune wiped them (measured during+  // the 2.0.182 ladder proof). Until at least one stamp has landed this boot, keys on disk are+  // presumed live; pruning waits. An empty session map (nothing reloaded) still prunes as before.+  if (sessions.size && !_stampLandedThisBoot) { console.log('[identity] AUMID prune skipped — no stamp has landed since boot (reloaded sessions not yet re-branded)'); return; }+  // v2.0.161 — rewritten to key off the LIVE appIds directly, across EVERY AUMID shape+  // (per-session Adom.Pup.<sid>.<cat>.<gen>, category-group Adom.Pup.<cat>.<gen>, dashboard+  // Adom.Pup.Dashboard.<gen>, and THREAD TILES Adom.Pup.thread.<slug>.<gen>). The old code derived+  // a <sid> by stripping known suffixes and kept a key iff that sid was a live session — which+  // BROKE thread tiles: a thread key strips to "thread.<slug>.<gen>" (never a session id), so a+  // perfectly live thread's key looked orphaned and got deleted on every sweep, un-branding the+  // taskbar button. Instead: gather every appId any live session is (or is about to be) stamped+  // with — _curAppId (live), _frozenAppId (frozen), _desiredAppId (mid-open, set before the stamp+  // lands), the pre-registration _earlyStamped map, and _registeredPupAumids — and delete only the+  // Adom.Pup.* keys NOT in that set. The pinnable base launcher (Adom.Pup) always stays.+  const liveAppIds = new Set([PUP_APP_ID]);+  for (const [, s] of sessions) {+    for (const id of [s && s._curAppId, s && s._frozenAppId, s && s._desiredAppId]) { if (id) liveAppIds.add(id); }+  }+  for (const id of _earlyStamped.values()) { if (id) liveAppIds.add(id); }+  for (const id of _registeredPupAumids) { if (id) liveAppIds.add(id); }+  // v2.0.250 (#51): seed from what is ON DISK too. A window that existed before this boot is still+  // on the user's taskbar wearing its identity, so its key is live even though no in-memory session+  // has stamped yet. Two sources: the appId persisted at save time, and the thread AUMID the+  // session's owner implies (same formula the stamp uses), which covers files written before this+  // field existed.+  try {+    for (const sf of listSessionFiles()) {+      if (sf && sf.appId) liveAppIds.add(sf.appId);+      const _own = sf && sf.owner === 'reclaimed' ? null : (sf && sf.owner);   // #79: the sentinel is an absence, never a name+      if (_own) liveAppIds.add(threadAumid(_own));+    }+  } catch (e) {}+  try {+    const out = execFileSync('reg', ['query', 'HKCU\\Software\\Classes\\AppUserModelId'],+      { encoding: 'utf8', timeout: 8000, windowsHide: true });+    let dropped = 0;+    for (const line of out.split(/\r?\n/)) {+      const m = line.match(/\\(Adom\.Pup(?:\..+)?)\s*$/);+      if (!m) continue;+      const appId = m[1];+      if (appId === PUP_APP_ID || liveAppIds.has(appId)) continue;   // launcher + in-use keys stay+      try {+        execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/f'],+          { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });+        dropped++;+      } catch (e) {}+    }+    if (dropped) console.log(`[identity] pruned ${dropped} stale Adom.Pup.* AUMID key(s) from HKCU (kept ${liveAppIds.size - 1} live)`);+  } catch (e) {}+  // The .ico tiles those keys pointed at also accumulate in %TEMP% across generation bumps+  // (t1→t2→t3, i6→i7). Keep ONLY the current-generation dir under each cache root; every sibling is+  // a prior gen no live key references. Cheap readdir, so it rides the same janitor cadence.+  pruneStaleIconGenDirs();+}+// v2.0.169 — force the shell to recreate a window's taskbar button so it re-reads the CURRENT+// AUMID. Win11 (this build, measured) does NOT move a live window to a new button on an AUMID+// change; hide + re-show is the only reliable trigger. SW_SHOWNOACTIVATE never steals focus, and+// the same script skips the nudge when the window is FOREGROUND (blinking the user's active window+// would be disruptive). Fire-and-forget; ~1-2s of powershell incl. Add-Type, once per window per+// thread appId.+// v2.0.175: the hide window is 80ms, NOT 600ms. Measured (churn-ralph round-3 RED + a 200ms+// visibility poll): a 600ms hide made a freshly opened window genuinely invisible for ~600ms, which+// both failed the #46 live-window invariant and was a real flicker on the user's screen. 80ms still+// makes the shell destroy + recreate the button (verified live: a restamped window's button renamed+// to the new thread), so we keep the only effect that matters and drop 87% of the blind spot.+// v2.0.180 (plain mode must apply LIVE, for the "prove it" ladder walk): stamped identity used to+// persist on existing windows until they closed ("already-stamped windows keep their icon"). On a+// switch to plain, strip everything now: drop every session's identity bookkeeping, unregister the+// Adom.Pup.* registry keys, and nudge each window — an UNREGISTERED appId falls back to the owning+// exe's icon (AD's own doc), i.e. the stock Chrome-for-Testing look John calls plain jane.+async function stripAllPupIdentities(reason) {+  _identityGen++;   // v2.0.216: invalidate every in-flight stamp before wiping+  if (process.platform !== 'win32') return;+  const ids = new Set(); const targets = [];+  for (const [sid, s] of [...sessions]) {+    if (s._curAppId && s._curAppId !== PUP_APP_ID) ids.add(s._curAppId);+    s._curAppId = null; s._frozenAppId = null; s._desiredAppId = null; s._threadNudged = null;+    // v2.0.198: the shell-side lists are cleared below — reset the dedup keys too, or the later+    // re-commit believes the list is still attached and skips (John's taskless right-click menu).+    s._wikiJumplistKey = undefined; s._jlFrozen = false;+    targets.push([sid, s]);+  }+  for (const id of ids) {+    try { await chrome.abCommand('desktop_set_window_jumplist', { appId: id, tasks: [] }, { timeoutMs: 5000 }); } catch (e) {}+    try { await chrome.abCommand('desktop_unregister_app_identity', { appId: id }, { timeoutMs: 5000 }); } catch (e) {}+    try { execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${id}`, '/f'], { timeout: 5000, windowsHide: true, stdio: 'ignore' }); } catch (e) {}+  }+  try { _registeredPupAumids.clear(); } catch (e) {}+  // v2.0.223: the 2.0.217 registry SWEEP that lived here was a mistake and is gone. Registry keys+  // with no window property pointing at them are inert — they never split taskbar groups (Chrome's+  // native-vs-empty AUMID drift did, fixed by the convergence below) — and deleting them broke+  // re-entry into tiles/aumid: ab caches icon registration BY APPID, so re-stamps skipped the+  // registry re-write and buttons came back with the right NAME and no ART (ralph r1/r5 fails).+  // v2.0.182 (measured during the ladder proof): deleting the REGISTRY keys is not enough — the+  // per-WINDOW AppUserModel property trio (+ the explicit ID) survives on the window and keeps+  // branding the rebuilt button. Truly plain = wipe those properties with VT_EMPTY via the shell+  // property store, then nudge. One PowerShell run covers every window (verified live: a branded+  // pup window's button reverted to stock "Google Chrome for Testing" the moment this ran).+  const hwnds = [];+  for (const [sid, s] of targets) {+    try { if (!s._hwnd) await resolveSessionHwndByBounds(s, sid); } catch (e) {}+    if (s._hwnd) hwnds.push(Number(s._hwnd));+  }+  {+    // v2.0.221 (John's two-CfT-buttons find): wiping AUMIDs to VT_EMPTY was WRONG. Chrome sets its+    // own per-profile AUMID on every window (ChromeForTestin<hash>.<profile>.Default) and lazily+    // RE-ASSERTS it — so wiped windows grouped under the exe-fallback identity while Chrome-retagged+    // ones grouped under the native id: TWO taskbar buttons, both labeled "Google Chrome for+    // Testing", drifting further apart over time (an Explorer restart rebuilds the same split).+    // Truly plain = CONVERGE every CfT window onto Chrome's OWN native AUMID (idempotent against+    // Chrome's re-assertions), clear pup's Relaunch trio, null pup's WM_SETICON icons, nudge.+    // Covers ALL windows of pup's browser processes — orphans included — not just live sessions.+    // NOTE the correct 24-byte PROPVARIANT: the old 16-byte explicit-layout struct made GetValue+    // silently return zeros, which is why the wipe path never saw Chrome's ids in the first place.+    _nudgeUntil = Date.now() + 12000;+    const _pids = new Set();+    for (const [, s9] of [...sessions]) { try { const pid = Number(macSessionPid(s9) || 0); if (pid) _pids.add(pid); } catch (e) {} }+    try { for (const [, be] of browsers) { const b = be && be.browser; const pid = Number((b && (b._detachedPid || (b.process() && b.process().pid))) || 0); if (pid) _pids.add(pid); } } catch (e) {}+    const _fallbackNative = String(pupSettings()._cftNativeAumid || '');+    const psBody = `$code=@'+using System; using System.Text; using System.Runtime.InteropServices;+public static class Conv {+  [StructLayout(LayoutKind.Sequential)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }+  [StructLayout(LayoutKind.Sequential)] public struct PROPVARIANT { public ushort vt; public ushort r1; public ushort r2; public ushort r3; public IntPtr p; public IntPtr p2; }+  [ComImport, Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]+  public interface IPropertyStore { int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY pk); int GetValue(ref PROPERTYKEY key, out PROPVARIANT pv); int SetValue(ref PROPERTYKEY key, ref PROPVARIANT pv); int Commit(); }+  [DllImport("shell32.dll")] public static extern int SHGetPropertyStoreForWindow(IntPtr hwnd, ref Guid iid, out IPropertyStore pps);+  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr lp);+  public delegate bool EnumProc(IntPtr h, IntPtr lp);+  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);+  [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);+  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);+  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();+  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h);+  [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);+  static Guid AUM = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3");+  static Guid IID = new Guid("886d8eeb-8cf2-4446-8d02-cdba1dbdcf99");+  public static string Read(IntPtr hwnd) {+    IPropertyStore ps; if (SHGetPropertyStoreForWindow(hwnd, ref IID, out ps) != 0) return null;+    PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = AUM; k.pid = 5;+    PROPVARIANT pv; if (ps.GetValue(ref k, out pv) != 0) return null;+    return (pv.vt == 31 && pv.p != IntPtr.Zero) ? Marshal.PtrToStringUni(pv.p) : null;+  }+  public static void Apply(IntPtr hwnd, string id) {+    IPropertyStore ps; if (SHGetPropertyStoreForWindow(hwnd, ref IID, out ps) != 0) return;+    foreach (uint pid in new uint[]{2,3,4}) {+      PROPERTYKEY k = new PROPERTYKEY(); k.fmtid = AUM; k.pid = pid;+      PROPVARIANT em = new PROPVARIANT(); em.vt = 0; ps.SetValue(ref k, ref em);+    }+    PROPERTYKEY k5 = new PROPERTYKEY(); k5.fmtid = AUM; k5.pid = 5;+    if (id != null && id.Length > 0) {+      PROPVARIANT pv = new PROPVARIANT(); pv.vt = 31; pv.p = Marshal.StringToCoTaskMemUni(id);+      ps.SetValue(ref k5, ref pv); ps.Commit(); Marshal.FreeCoTaskMem(pv.p);+    } else {+      PROPVARIANT em2 = new PROPVARIANT(); em2.vt = 0; ps.SetValue(ref k5, ref em2); ps.Commit();+    }+    SendMessage(hwnd, 0x0080, (IntPtr)0, IntPtr.Zero);+    SendMessage(hwnd, 0x0080, (IntPtr)1, IntPtr.Zero);+  }+}+'@+Add-Type -TypeDefinition $code+$targets = @(PIDS_CSV)+$script:wins = @()+[Conv]::EnumWindows({ param($h,$lp)+  if([Conv]::IsWindowVisible($h)){ $p2=0; [void][Conv]::GetWindowThreadProcessId($h,[ref]$p2); if($targets -contains [int]$p2){ $script:wins += $h } }; $true }, [IntPtr]::Zero) | Out-Null+$native = $null+foreach($h in $script:wins){ $a=[Conv]::Read($h); if($a -and $a.StartsWith("ChromeForTestin")){ $native=$a; break } }+if(-not $native){ $native = "FALLBACK_NATIVE" }+if($native -eq ""){ $native = $null }+$fg=[Conv]::GetForegroundWindow(); $n=0+foreach($h in $script:wins){+  $cur=[Conv]::Read($h)+  if($cur -ne $native){ [Conv]::Apply($h,$native); $n++ }+}+if($n -gt 0){+  # BATCH nudge (v2.0.225/226): Win11 bakes a GROUP button's icon at creation, and a button only+  # rebuilds when EVERY member hides at once. The foreground window must be included: exempting it+  # left ITS button alive, Explorer re-associated that surviving button (old art baked) to the+  # native group, and every reshown window JOINED it — the whole group wore stale pup art. This is+  # a user-initiated mode switch, so one sub-second blink of the active window is the right trade;+  # focus is restored immediately after.+  $fgInSet = $script:wins -contains $fg+  foreach($h in $script:wins){ [void][Conv]::ShowWindow($h,0) }+  Start-Sleep -Milliseconds 250+  foreach($h in $script:wins){ [void][Conv]::ShowWindow($h,4) }+  if($fgInSet){ [void][Conv]::SetForegroundWindow($fg) }+}+Write-Output ("NATIVE: {0}" -f $(if($native){$native}else{"(none - emptied)"}))+Write-Output ("CONVERGED {0} of {1}" -f $n, $script:wins.Count)`;+    const psFinal = psBody.replace('PIDS_CSV', [..._pids].join(',') || '0').replace('FALLBACK_NATIVE', _fallbackNative.replace(/"/g, ''));+    try {+      const _tmpPs = path.join(os.tmpdir(), 'pup-plain-converge.ps1');+      fs.writeFileSync(_tmpPs, psFinal);+      execFile('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', _tmpPs], { windowsHide: true, timeout: 45000 }, (err, stdout) => {+        const out = String(stdout || '').trim();+        console.log(`[identity] plain converge: ${err ? 'ERR ' + err.message.slice(0, 80) : out.replace(/\n/g, ' | ')}`);+        const m = out.match(/NATIVE: (ChromeForTestin\S+)/);+        if (m) { try { savePupSettings({ _cftNativeAumid: m[1] }); } catch (e) {} }+      });+    } catch (e) { console.log(`[identity] plain converge spawn failed: ${e.message}`); }+  }+  console.log(`[identity] stripped ${ids.size} registered identit(ies), wiping window props on ${hwnds.length} window(s) (${reason})`);+  // v2.0.200 (#53, reproduced twice in six-state proofs): sessions whose hwnd was unresolved at+  // strip time kept their branded button. Schedule ONE deferred pass: re-resolve and wipe the+  // stragglers ~12s later, when bounds/title resolution has usually caught up.+  const _missed = targets.filter(([, s9]) => !s9._hwnd).map(([sid9]) => sid9);+  if (_missed.length && !reason.endsWith('(deferred)')) {+    setTimeout(() => {+      (async () => {+        try {+          if (pupSettings().aumidIcons === 'on') return;   // mode changed again — stale pass, skip+          let n = 0;+          for (const sid9 of _missed) {+            const s9 = sessions.get(sid9); if (!s9) continue;+            try { if (!s9._hwnd) await resolveSessionHwndByBounds(s9, sid9); } catch (e) {}+            if (s9._hwnd) n++;+          }+          if (n) { console.log(`[identity] deferred plain strip: ${n} straggler(s) now resolvable — re-stripping`); await stripAllPupIdentities(reason + ' (deferred)'); }+        } catch (e) {}+      })();+    }, 12000);+  }+}+let _nudgeUntil = 0;   // v2.0.172: while set, ignore window-opened events caused by our own nudge+// v2.0.227: backoff for a nudge skipped because the window was foreground. Short first (the user+// usually clicks away within seconds), then patient — a window focused for 10 straight minutes is+// being actively used, and blinking it the moment they look away is the whole point of the skip.+const NUDGE_RETRY_DELAYS = [6000, 15000, 45000, 120000, 300000];+// v2.0.251: THE VISIBILITY INVARIANT — "a live pup window is never invisible".+// A human can MINIMIZE a window; a human can never HIDE one. So IsWindowVisible=false on a window+// pup owns is ALWAYS pup's own doing, and re-showing it can never fight the user. That makes this+// repair unconditionally safe, which is why it can run from the periodic sweep as well as from the+// nudge callback. Re-shows with SW_SHOWNOACTIVATE (no focus steal, no z-order pop).+async function ensureWindowShown(hwnd, sessionId, why) {+  if (process.platform !== 'win32' || !hwnd) return null;+  const ps = `$s=@'+using System; using System.Runtime.InteropServices;+public static class EV {+  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);+  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);+  [DllImport("user32.dll")] public static extern bool IsWindow(IntPtr h);+}+'@; Add-Type -TypeDefinition $s; $h=[IntPtr]${hwnd}+if(-not [EV]::IsWindow($h)){ 'GONE' }+elseif([EV]::IsWindowVisible($h)){ 'OK' }+else { [EV]::ShowWindow($h,4)|Out-Null; if([EV]::IsWindowVisible($h)){ 'RESHOWN' } else { 'RESHOW-FAILED' } }`;+  return await new Promise((resolve) => {+    try {+      execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 20000 }, (err, stdout) => {+        const r = String(stdout || '').trim();+        if (r === 'RESHOWN') {+          console.log(`[visibility] "${sessionId}" window was HIDDEN (${why}) — re-shown. A hidden window's taskbar button reveals nothing to a human click; pup must never leave one that way.`);+          try { recordCleanup(`re-showed session "${sessionId}"'s window (it had been left HIDDEN by ${why} — its taskbar button would have revealed nothing when clicked)`); } catch (e) {}+        } else if (r === 'RESHOW-FAILED') {+          console.log(`[visibility] "${sessionId}" window is hidden and would NOT re-show (${why}) — reporting rather than pretending it is fine`);+        }+        resolve(r || null);+      });+    } catch (e) { resolve(null); }+  });+}++// v2.0.275: has the shell got an icon to READ for this AUMID yet? Rebuilding a button before the+// IconResource write is visible is how a virgin AUMID gets "no icon" cached against it FOREVER —+// measured on John's "oven" button, which sat as a bare overlay badge on empty space while its+// .ico, its registry entry and its AUMID were all provably correct. Windows caches per AUMID, so+// there is no second chance: the button must not be built until the art is registered.+function aumidIconRegistered(appId) {+  if (process.platform !== 'win32' || !appId) return Promise.resolve(true);+  return new Promise((resolve) => {+    try {+      execFile('reg', ['query', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/v', 'IconResource'],+        { windowsHide: true, timeout: 4000 }, (err, stdout) => {+          const m = /IconResource\s+REG_SZ\s+(.+)/i.exec(String(stdout || ''));+          if (!m) return resolve(false);+          const p2 = m[1].trim().replace(/,\d+\s*$/, '');+          try { resolve(fs.existsSync(p2) && fs.statSync(p2).size > 100); } catch (e) { resolve(false); }+        });+    } catch (e) { resolve(true); }   // cannot check: do not block the nudge on our own failure+  });+}+async function nudgeWhenIconReady(hwnd, sessionId, appId) {+  for (let i = 0; i < 10; i++) {                 // up to ~2.5s, then proceed regardless+    if (await aumidIconRegistered(appId)) break;+    if (i === 0) console.log(`[thread-tile] "${sessionId}" holding the button rebuild until ${appId}'s icon is registered — building it first is what caches "no icon" against a virgin AUMID`);+    await new Promise(r => setTimeout(r, 250));+  }+  nudgeTaskbarButton(hwnd, sessionId);+}+function nudgeTaskbarButton(hwnd, sessionId) {+  if (process.platform !== 'win32' || !hwnd) return;+  _nudgeUntil = Date.now() + 6000;+  const ps = `$s=@'+using System; using System.Runtime.InteropServices;+public static class SW {+  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);+  [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();+  [DllImport("user32.dll")] public static extern IntPtr SendMessage(IntPtr h, uint m, IntPtr w, IntPtr l);+  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);+  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);+}+'@; Add-Type -TypeDefinition $s; $h=[IntPtr]${hwnd}+if([SW]::GetForegroundWindow() -eq $h){ 'SKIP-FG' } else {+  # v2.0.251 (John: "why can't i open this pup window? ... when i go to click on it as a human you+  # fuck me over"). ROOT CAUSE: this nudge HIDES the user's window, and the re-show was a separate+  # statement in a process node kills at 20s. Reproduced live: after the HIDE half alone the window+  # is IsWindowVisible=false — its taskbar button reveals NOTHING to a human click, for minutes,+  # until some later audit cycle happened to nudge again. So the re-show is now in a FINALLY: the+  # window comes back even if this script throws, and (belt and braces) the caller verifies too.+  # SECOND BUG, same line: the old re-show was always SW_SHOWNOACTIVATE (4), which RESTORES a+  # minimized window — so pup silently un-minimized windows the user had put away (John's window+  # went minimized -> normal on its own while I was probing it). Re-show in the state we found it:+  # 7 = SW_SHOWMINNOACTIVE for a window the user had minimized, 4 otherwise. pup's taskbar+  # bookkeeping must never change what the user chose to see.+  $wasMin = [SW]::IsIconic($h)+  try { [SW]::ShowWindow($h,0)|Out-Null; Start-Sleep -Milliseconds 80 }+  finally { [SW]::ShowWindow($h, $(if($wasMin){7}else{4}))|Out-Null }+  if([SW]::IsWindowVisible($h)){ 'NUDGED' } else { 'NUDGED-STILL-HIDDEN' }+}`;+  try {+    execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 20000 }, (err, stdout) => {+      const r = String(stdout || '').trim();+      console.log(`[thread-tile] "${sessionId}" button nudge: ${err ? 'ERR ' + err.message.slice(0, 80) : r}`);+      // v2.0.251: the nudge hid the user's window; PROVE it came back. The PS finally clause covers a+      // throw, but node KILLS this process at 20s (an Add-Type compile on a loaded box can reach that)+      // and a killed process runs no finally — that is the path that stranded John's window invisible+      // behind a live taskbar button. Verify from here, where the kill cannot reach us.+      if (err || r === 'NUDGED-STILL-HIDDEN') {+        ensureWindowShown(hwnd, sessionId, err ? 'a nudge that was killed or errored mid-sequence' : 'a nudge whose re-show did not take').catch(() => {});+      }+      // The button WAS rebuilt in the still-hidden case (the hide/show ran) — only the re-show+      // failed, and we just repaired that. So downstream bookkeeping treats it as a normal NUDGED.+      const rN = (r === 'NUDGED-STILL-HIDDEN') ? 'NUDGED' : r;+      // v2.0.227 (John's icon audit found fusion-dashboard grouped under the stock "Google Chrome for+      // Testing" button while wearing stale teal art): a SKIP-FG was a DEAD END. Windows never rebuilds+      // a taskbar button on an in-place AUMID change without this hide/show, so a window that merely+      // happened to be FOCUSED at stamp time kept its previous button identity FOREVER. The skill+      // called this a self-healing tradeoff ("heals on the next focus change") but nothing watched for+      // that change. Now the skip is a PENDING state with a bounded backoff retry; the PS itself+      // re-checks foreground, so a retry while still focused simply skips again and re-queues.+      const _sk = sessions.get(sessionId);+      if (!err && rN === 'SKIP-FG' && _sk) {+        const at = (_sk._nudgePending && _sk._nudgePending.attempts || 0) + 1;+        if (at <= NUDGE_RETRY_DELAYS.length) {+          _sk._nudgePending = { since: (_sk._nudgePending && _sk._nudgePending.since) || Date.now(), attempts: at, hwnd };+          const delay = NUDGE_RETRY_DELAYS[at - 1];+          console.log(`[thread-tile] "${sessionId}" nudge SKIPPED (window was focused) — button still wears its OLD identity; retry ${at}/${NUDGE_RETRY_DELAYS.length} in ${Math.round(delay / 1000)}s`);+          if (_sk._nudgeTimer) clearTimeout(_sk._nudgeTimer);+          _sk._nudgeTimer = setTimeout(() => {+            const s2 = sessions.get(sessionId);+            if (!s2 || !s2._nudgePending) return;+            nudgeTaskbarButton(s2._nudgePending.hwnd || hwnd, sessionId);+          }, delay);+        } else {+          console.log(`[thread-tile] "${sessionId}" nudge still skipped after ${NUDGE_RETRY_DELAYS.length} retries — the window has been focused throughout; leaving it pending for the next stamp`);+        }+      }+      if (!err && rN === 'NUDGED' && _sk) { _sk._buttonBuiltAt = Date.now(); }   // v2.0.228: stale-art anchor+      if (!err && rN === 'NUDGED' && _sk && _sk._nudgePending) {+        console.log(`[thread-tile] "${sessionId}" deferred nudge LANDED after ${_sk._nudgePending.attempts} retr(ies) — button rebuilt with its real identity`);+        _sk._nudgePending = null;+        if (_sk._nudgeTimer) { clearTimeout(_sk._nudgeTimer); _sk._nudgeTimer = null; }+      }+      // v2.0.171: the nudge DESTROYS and recreates the button, so any overlay badge already painted+      // is gone from glass while pup's model still says painted — mark bare so the enforcer repaints+      // the favicon badge onto the recreated thread-tile button (John wants tile + overlay together).+      if (!err && rN === 'NUDGED') {+        const s = sessions.get(sessionId);+        if (s) {+          s._appOverlay = false; s._overlayEnforceAt = 0; s._lastPaintSig = null;+          // v2.0.208 (dynamic-switching ralph): marking bare is NOT a repaint — freshly nudged+          // buttons sat badge-less until unrelated churn kicked the enforcer (measured 40s+ on six+          // fresh windows, twice). Repaint DIRECTLY once the shell settles from the rebuild.+          setTimeout(() => { try { if (sessions.has(sessionId)) applyAppOverlay(s, sessionId).catch(() => {}); } catch (e) {} }, 1800);+        }+      }+    });+  } catch (e) { console.log(`[thread-tile] "${sessionId}" nudge spawn failed: ${e.message}`); }+}+// v2.0.161 (John: "you need a janitor AND uninstall cleanup for the aumid icons you register"):+// reap prior-generation icon-cache directories so tile .ico files never pile up in %TEMP%.+function pruneStaleIconGenDirs() {+  const roots = [+    { dir: path.dirname(THREAD_TILE_DIR), keep: path.basename(THREAD_TILE_DIR) },   // pup-thread-tiles/<gen>+    { dir: path.dirname(_NOTCH_CACHE),    keep: path.basename(_NOTCH_CACHE) },       // pup-notched-icons/<gen>+  ];+  for (const { dir, keep } of roots) {+    try {+      if (!fs.existsSync(dir)) continue;+      for (const name of fs.readdirSync(dir)) {+        if (name === keep) continue;+        try { fs.rmSync(path.join(dir, name), { recursive: true, force: true, maxRetries: 2, retryDelay: 200 }); } catch (e) {}+      }+    } catch (e) {}+  }+}+async function unregisterPupAumid(appId) {+  if (process.platform !== 'win32' || !appId || appId === PUP_APP_ID) return;+  // v2.0.161 THREAD TILES: a thread AUMID (Adom.Pup.thread.<slug>.<gen>) is SHARED by every window+  // of that thread, so closing ONE window must NOT unregister the key the siblings still wear —+  // that would un-brand their taskbar button and drop their jump list. Bail if any OTHER live+  // session is still stamped with this appId. (Harmless for unique per-session ids: none will match.)+  for (const [, s] of sessions) {+    if (s && (s._curAppId === appId || s._frozenAppId === appId || s._desiredAppId === appId)) return;+  }+  _registeredPupAumids.delete(appId);+  // v1.9.52: CLEAR this AUMID's jump list before unregistering it. Otherwise its CustomDestinations+  // file lingers in the shell (measured: 6 stale jump-list files for 3 live windows, because every+  // category flip mints a new AUMID and orphans the old one's list). Explorer resolving a button+  // against a pile of stale-but-present jump lists is part of the first-right-click "blip".+  if (_jumplistSupported !== false) {+    try { await chrome.abCommand('desktop_set_window_jumplist', { appId, tasks: [] }, { timeoutMs: 5000 }); } catch (e) {}+  }+  try { await chrome.abCommand('desktop_unregister_app_identity', { appId }, { timeoutMs: 5000 }); } catch (e) {}+  // v2.0.161: also drop the HKCU IconResource key DIRECTLY. Under minimal-touch we write that key via+  // reg.exe (AD's register roundtrip is skipped), so AD's unregister above may leave it behind — delete+  // it here so a closed window's key never lingers until the 5-min janitor. reg.exe is idempotent+  // (a missing key just errors, swallowed). The sibling-guard above already protects shared thread keys.+  try {+    execFileSync('reg', ['delete', `HKCU\\Software\\Classes\\AppUserModelId\\${appId}`, '/f'],+      { encoding: 'utf8', timeout: 5000, windowsHide: true, stdio: 'ignore' });+  } catch (e) {}+}+// v1.9.320 (John: "i turned OFF jump lists in settings but i still see them in the taskbar. did you+// ralph test this at all?"). No — I only tested ON. Toggling OFF never CLEARED the already-committed+// tasks, because updateWikiJumplist returns early when jumpLists is off (so it can't clear), and the+// toggle wasn't wired to anything. This runs on every jumpLists/jlViewTasks/jlCloseAll change: OFF →+// commit an EMPTY list to each window's AUMID (removes the menu tasks); ON/sub-change → force a+// re-commit so the new task set shows immediately.+// v2.0.214 (John: "when i switch taskbar identity states in the settings panel, can you show a+// progress bar with indicator of what step you're doing... then a green check mark you're done.+// cuz i sit there waiting and am not sure if you're done or not"): a mode switch takes several+// seconds (strip/stamp, button-rebuild nudges, overlay repaints) and the dialog gave zero feedback.+// This tracker is driven by the REAL apply promises, not timers alone, so the check mark is honest.+let _modeApply = null;+let _modeApplyTok = 0;+// v2.0.216 (John's plain-mode ralph: "you aren't removing the aumid when i switch to chrome plain+// choice?"): rapid state clicks RACED. A brandSweep fires 9 parallel per-window stamp chains that+// pass the aumidIcons gate instantly; a plain click two seconds later strips all 9 windows, and+// then the in-flight stamps land ON TOP of the wipe. Every identity-affecting change now bumps+// this generation; any stamp that captured an older generation aborts before touching the window.+let _identityGen = 0;+function _startModeApply(val) {+  const steps = [+    { label: val === 'plain' ? 'Stripping pup identities (back to the stock Chrome button)' : 'Stamping taskbar identities', state: 'run' },+    { label: 'Rebuilding taskbar buttons', state: 'pend' },+    { label: 'Painting overlay badges', state: 'pend' },+  ];+  _identityGen++;   // v2.0.216: supersede ALL in-flight identity work before this apply starts+  _modeApply = { at: Date.now(), token: ++_modeApplyTok, mode: val, steps, done: false };+  const my = _modeApply;+  (async () => {+    try {+      if (val === 'plain') {+        await stripAllPupIdentities('settings-taskbarmode-plain');+      } else {+        for (const [, s7] of [...sessions]) { try { s7._curAppId = null; s7._frozenAppId = null; s7._wikiJumplistKey = undefined; s7._jlFrozen = false; } catch (e) {} }+        try { _registeredPupAumids.clear(); } catch (e) {}+        await brandSweep('settings-taskbarmode');+        reconcileModel('setting taskbarMode').catch(() => {});+      }+    } catch (e) {}+    if (_modeApply !== my) return;+    my.steps[0].state = 'done'; my.steps[1].state = 'run';+    // shell settle: the hide/show nudges plus (in tiles mode) the 5s thread-stamp overlay gate+    await new Promise(r => setTimeout(r, val === 'tiles' ? 6000 : 2500));+    if (_modeApply !== my) return;+    if (val === 'plain') {+      // verify pass: any stamp already past its generation check when the click landed has landed+      // by now (the settle above outlasts a desktop_set_window_identity round-trip) — wipe again so+      // the glass converges even in the worst race. Idempotent when nothing slipped through.+      try { await stripAllPupIdentities('settings-taskbarmode-plain (verify pass)'); } catch (e) {}+    }+    my.steps[1].state = 'done';+    if (pupSettings().overlayBadges === 'on') {+      my.steps[2].state = 'run';+      for (const [sid9, s9] of [...sessions]) { try { s9._lastPaintSig = null; await applyAppOverlay(s9, sid9); } catch (e) {} }+    } else {+      my.steps[2].label = 'Overlay badges are off (nothing to paint)';+    }+    if (_modeApply !== my) return;+    my.steps[2].state = 'done'; my.done = true;+    scheduleIconAudit('after mode switch');   // v2.0.228: self-heal whatever the switch left crooked+  })();+}+// v2.0.228 — THE TASKBAR ICON AUDIT (John, 2026-08-18: "whenever i ask you to audit your taskbar+// icons ... i mean detect whether you're off on your aumid tracking, find out why, fix your code,+// then reapply all aumid icons. the goal is that i never have to ask you to audit again").+// So the audit is not a report — it is a SELF-HEAL, and it runs on its own. It compares pup's model+// against the SHELL'S OWN button names (UIA ground truth, the only thing that cannot lie), finds+// every drift class we have ever shipped, repairs each, and re-verifies. Companion dev skill:+// dev-skills/pup-icon-audit.+let _lastAudit = null;+let _auditBusy = false;+async function taskbarButtonNames() {+  if (process.platform !== 'win32') return [];+  const ps = `Add-Type -AssemblyName UIAutomationClient+$bars = [System.Windows.Automation.AutomationElement]::RootElement.FindAll([System.Windows.Automation.TreeScope]::Children, (New-Object System.Windows.Automation.OrCondition((New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_TrayWnd')), (New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ClassNameProperty,'Shell_SecondaryTrayWnd')))))+foreach($bar in $bars){ $btns=$bar.FindAll([System.Windows.Automation.TreeScope]::Descendants,(New-Object System.Windows.Automation.PropertyCondition([System.Windows.Automation.AutomationElement]::ControlTypeProperty,[System.Windows.Automation.ControlType]::Button))); foreach($b in $btns){ Write-Output $b.Current.Name } }`;+  return await new Promise((resolve) => {+    try {+      execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 25000, maxBuffer: 1 << 20 }, (err, stdout) => {+        resolve(err ? [] : String(stdout || '').split(/\r?\n/).map(x => x.trim()).filter(Boolean));+      });+    } catch (e) { resolve([]); }+  });+}+async function auditTaskbarIcons(reason = 'manual', opts = {}) {+  const fix = opts.fix !== false;+  // v2.0.270: `force` re-asserts every button's ART even when nothing was detected. This exists+  // because the detector CANNOT see the glass — it infers art correctness from _buttonBuiltAt vs the+  // tile's mtime, and that inference was clean while John was staring at a wrong icon. When the+  // model and the pixels disagree, the model loses: force bumps the registration epoch (new cacheKey+  // -> ab must re-read the icon) and re-stamps every session.+  const force = !!opts.force;+  if (process.platform !== 'win32') return { ok: true, skipped: 'not win32' };+  if (_auditBusy) return _lastAudit || { ok: true, skipped: 'audit already running' };+  _auditBusy = true;+  try {+    const st = pupSettings();+    const mode = st.aumidIcons !== 'on' ? 'plain' : (st.threadTiles === 'on' ? 'tiles' : 'aumid');+    const names = await taskbarButtonNames();+    const findings = [];+    const live = [...sessions].filter(([sid, s]) => s && sid !== 'pup-dashboard');+    if (!names.length) { const r = { ok: true, mode, reason, skipped: 'UIA returned no buttons (locked screen / no session)', at: Date.now() }; _lastAudit = r; return r; }+    for (const [sid, sess] of live) {+      const thread = sess.owner || sess._ownerThread || '';+      if (mode === 'plain') {+        // plain: NOTHING of pup may appear as a button, and every window rides the stock group+        if (thread && names.some(n => n.startsWith(thread + ' ')) || names.some(n => n.startsWith('Adom Pup'))) {+          findings.push({ sid, thread, kind: 'leftover_identity', why: 'plain mode but a pup-branded button is still on the bar' });+        }+        continue;+      }+      const expect = mode === 'tiles' && thread ? thread : 'Adom Pup';+      const hasButton = names.some(n => n.startsWith(expect));+      if (!hasButton) {+        findings.push({ sid, thread, kind: 'no_button', why: `no taskbar button starts with "${expect}" — the identity never reached the shell (window grouped under the stock Chrome button)` });+        continue;+      }+      if (sess._nudgePending) {+        findings.push({ sid, thread, kind: 'nudge_pending', why: 'identity stamped but the button rebuild is still queued (window keeps being focused)' });+        continue;+      }+      // STALE ART: the button exists with the right NAME, but Windows baked its icon when the+      // button was created — if the icon file was written AFTER that, the glass shows old art.+      if (mode === 'tiles' && thread) {+        try {+          const ico = path.join(THREAD_TILE_DIR, `${threadSlug(thread)}.ico`);+          const mt = fs.existsSync(ico) ? fs.statSync(ico).mtimeMs : 0;+          const built = sess._buttonBuiltAt || 0;+          if (mt && built && mt > built + 1500) {+            findings.push({ sid, thread, kind: 'stale_art', why: `the tile icon was written ${Math.round((mt - built) / 1000)}s AFTER this button was last rebuilt — the shell is showing the icon it baked at creation` });+          } else if (mt && !built) {+            findings.push({ sid, thread, kind: 'unverified_art', why: 'this button has never been rebuilt by pup since the tile was rendered — cannot vouch for the art on glass' });+          }+        } catch (e) {}+      }+    }+    let repaired = 0;+    if (force) {+      console.log(`[icon-audit] (${reason}) FORCE: re-asserting art on ${live.length} button(s) regardless of findings — the detector cannot read the glass, so a clean verdict is not proof.`);+      try { savePupSettings({ _stripEpoch: Number(pupSettings()._stripEpoch || 0) + 1 }); } catch (e) {}+      for (const [sid, sess] of live) {+        try {+          const appId = sess._curAppId;+          if (appId) { try { _registeredPupAumids.delete(appId); } catch (e) {} }+          sess._curAppId = null; sess._frozenAppId = null; sess._threadNudged = null;+          await stampPupIdentity(sid, { reason: 'icon-audit force' });+          if (sess._hwnd) nudgeTaskbarButton(sess._hwnd, sid);+          repaired++;+        } catch (e) {}+      }+    }+    if (fix && findings.length) {+      console.log(`[icon-audit] (${reason}) ${findings.length} finding(s): ${findings.map(f => f.sid + ':' + f.kind).join(', ')} — REPAIRING`);+      // force ab to re-read every icon: bump the registration epoch so cacheKeys change+      try { savePupSettings({ _stripEpoch: Number(pupSettings()._stripEpoch || 0) + 1 }); } catch (e) {}+      for (const f of findings) {+        const sess = sessions.get(f.sid);+        if (!sess) continue;+        try {+          if (f.kind === 'leftover_identity') { await stripAllPupIdentities('icon-audit repair (plain)'); repaired++; break; }+          // re-register (fresh icon handle) + re-stamp + rebuild the button+          const appId = sess._curAppId;+          if (appId) { try { _registeredPupAumids.delete(appId); } catch (e) {} }+          sess._curAppId = null; sess._frozenAppId = null;+          await stampPupIdentity(f.sid, { _commit: true });+          if (!sess._hwnd) { try { await resolveSessionHwndByBounds(sess, f.sid); } catch (e) {} }+          if (sess._hwnd) nudgeTaskbarButton(sess._hwnd, f.sid);+          sess._appOverlay = false; sess._lastPaintSig = null;+          repaired++;+        } catch (e) { console.log(`[icon-audit] repair of "${f.sid}" failed: ${e && e.message}`); }+      }+      // let the shell settle, then repaint badges onto the rebuilt buttons+      setTimeout(() => { for (const [sid2, s2] of [...sessions]) { try { applyAppOverlay(s2, sid2).catch(() => {}); } catch (e) {} } }, 2500);+    }+    const result = { ok: findings.length === 0, mode, reason, at: Date.now(), buttons: names.length,+                     sessions: live.length, findings, repaired, fixed: fix };+    if (!findings.length) console.log(`[icon-audit] (${reason}) CLEAN — ${live.length} session(s) all match the shell in ${mode} mode`);+    _lastAudit = result;+    return result;+  } finally { _auditBusy = false; }+}+// Self-healing cadence: a quiet steady-state check every 4 minutes, plus a pass ~25s after any+// identity churn settles (that is when the classic "button baked before the icon existed" lands).+function scheduleIconAudit(reason, delayMs = 25000) {+  try { if (global.__auditTimer) clearTimeout(global.__auditTimer); } catch (e) {}+  global.__auditTimer = setTimeout(() => { auditTaskbarIcons(reason).catch(() => {}); }, delayMs);+}+if (process.platform === 'win32') {+  // v2.0.315 (audit): the 4-minute steady-state audit (one UIAutomation PowerShell crawl of the whole+  // taskbar, forever) is retired; the audit still runs 25s after every identity churn via scheduleIconAudit.+}+// v2.0.235: the overlayBadges apply loop existed TWICE (dashboard set-setting + pup_configure).+// A progress-tracker edit landed in both copies but its variable was declared in only one, so every+// pup_configure toggle threw "ReferenceError: _obApply is not defined" as an unhandled rejection.+// One implementation now; `track` carries the progress entry when the caller made one.+// v2.0.263: the badge scale as a PERCENT of full size. Stored as a string so it rides the same+// settings/whitelist plumbing as every other key; clamped here so a bad value can never produce a+// zero-size (invisible) badge.+function overlayScalePct() {+  const n = parseInt(String(pupSettings().overlaySize || '75'), 10);+  return (Number.isFinite(n) && n >= 25 && n <= 100) ? n : 75;+}+async function applyOverlayBadgeSetting(val, track) {+  try {+    for (const [sid, sess] of [...sessions]) {+      if (val === 'off') {+        try { await chrome.abCommand('desktop_taskbar', { ...(await windowTarget(sess, sid)), overlay: { badge: 'none' } }, { timeoutMs: 5000 }); } catch (e) {}+        sess._appOverlay = false; sess._lastPaintSig = null;+      } else {+        sess._appOverlay = false; sess._lastPaintSig = null; sess._overlayEnforceAt = 0;+        try { await applyAppOverlay(sess, sid); } catch (e) {}+      }+    }+  } catch (e) { console.log(`[settings] overlayBadges apply failed: ${e && e.message}`); }+  if (track && _modeApply === track) { track.steps[0].state = 'done'; track.done = true; }+}+async function refreshAllJumplists() {+  if (process.platform !== 'win32' || _jumplistSupported === false) return;+  const off = pupSettings().jumpLists !== 'on';+  for (const [sid, s] of [...sessions]) {+    try {+      let appId = s._curAppId;+      if (!appId && pupSettings().threadTiles === 'on' && (s.owner || s._ownerThread)) {+        appId = threadAumid(s.owner || s._ownerThread);   // v2.0.198: the button exists even when the stamp never landed+      }+      if (!appId) continue;+      if (off) {+        await chrome.abCommand('desktop_set_window_jumplist', { appId, tasks: [] }, { timeoutMs: 5000 });+        s._wikiJumplistKey = undefined; s._jlFrozen = false;   // so a later re-enable re-commits from scratch+        console.log(`[jumplist] "${sid}" CLEARED (jumpLists turned off)`);+      } else {+        s._wikiJumplistKey = undefined; s._jlFrozen = false;   // force a real re-commit with the new task set+        await updateWikiJumplist(s, sid);+      }+    } catch (e) {}+  }+}++// v1.9.12: record the wiki auth state and RE-BRAND if it changed, so the taskbar icon flips+// between the browser-window glyph and the solid padlock the moment login state is known.+// Returns true if the state changed (i.e. a re-stamp was issued).+async function setWikiAuthedState(session, sessionId, authed, opts = {}) {+  if (!session) return false;+  // v1.9.93 — an INDETERMINATE probe is not a state change. Callers pass the whole checkWikiAuth+  // result; if it could not determine the answer, keep the last known state and re-stamp NOTHING.+  if (opts.unknown) return false;+  const next = !!authed;+  if (session._wikiAuthed === next) return false;+  // v1.9.93 — FLIP COOLDOWN. Every flip re-stamps the identity, which mints a new AUMID and DESTROYS+  // + recreates the taskbar button; doing that repeatedly is what made the right-click jump list+  // "blip out" (the flyout dies with the button under it). A genuine login/logout happens once and is+  // worth one flip; a rapid re-flip is churn, so absorb it. The state is still recorded — only the+  // expensive, user-visible re-stamp is suppressed — so the icon settles on the next legitimate brand.+  const FLIP_COOLDOWN_MS = 15000;+  const sinceFlip = Date.now() - (session._wikiAuthFlipAt || 0);+  session._wikiAuthed = next;+  if (process.platform !== 'win32') return true;+  if (session._wikiAuthFlipAt && sinceFlip < FLIP_COOLDOWN_MS) {+    console.log(`[identity] "${sessionId}" wiki login state -> ${next ? 'LOGGED IN' : 'logged out'} but SUPPRESSING the re-stamp (${Math.round(sinceFlip / 1000)}s since the last flip; an AUMID flip destroys the taskbar button and would kill an open jump list)`);+    return true;+  }+  session._wikiAuthFlipAt = Date.now();+  console.log(`[identity] "${sessionId}" wiki login state -> ${next ? 'LOGGED IN (solid green glyph)' : 'logged out (hollow glyph)'}`);+  // v1.9.15: RETRY the re-stamp. After a view toggle the window is BRAND NEW and AD resolves it by+  // its "(session: <id>)" title suffix, which the title injector adds only once the relaunched page+  // has loaded — so a single immediate stamp loses the race and AD answers "No visible window with+  // title containing (session: ...)". That is exactly why the icon did not flip on the first try.+  // Retry with backoff (~0.8s,1.6s,2.4s,3.2s,4s) until the title exists; the AUMID re-register runs+  // after, so Alt-Tab/flyout get the matching icon too.+  let stamped = false;+  for (let i = 0; i < 5 && !stamped; i++) {+    try { stamped = await stampPupIdentity(sessionId); } catch (e) {}+    if (!stamped) await new Promise(r => setTimeout(r, 800 * (i + 1)));+  }+  if (!stamped) console.log(`[identity] "${sessionId}" icon re-stamp FAILED after retries — window title never matched`);+  return true;+}+// v1.9.3 (AD #208 — desktop_set_window_jumplist, AD ≥1.9.139): give each WIKI pup window a+// taskbar RIGHT-CLICK task to flip between the logged-in and public view. The jump list attaches+// to the per-session AUMID (registered registry-only in v1.9.2), so it is a PER-WINDOW menu.+// Clicking a task runs AD's bundled CLI → pup_wiki_set_view, which relaunches THIS window+// under the other cookie jar (profile = userDataDir is fixed at browser launch, so a view switch+// = a relaunch). Only meaningful in SPLIT mode (grouped mode shares one taskbar button, so a+// per-window menu would be ambiguous) and only for Adom (adom.inc) URLs; any other URL clears the+// menu. Self-gates off if AD lacks the verb. Idempotent per (session, view) via _wikiJumplistView.+async function updateWikiJumplist(session, sessionId, opts) {+  // v1.9.269 (John toggled jump lists ON and the wiki tasks never appeared): this function carried+  // TWO legacy gates that silently vetoed the user's explicit pref - the minimal-touch return (the+  // pref now IS the user's choice and wins) and the grouped exclusion (under grouped, commit with+  // last-writer-wins semantics, exactly how overlay badges already behave on a shared button).+  if (pupSettings().jumpLists !== 'on') return;+  // v2.0.220 (T5's honest fail in plain mode): a jump list can only attach to a pup AUMID, and in+  // plain mode no window wears one — yet this path still registered a bare Adom.Pup.<sid> registry+  // key per window birth (the fallback id below) purely so it had something to commit to. Plain+  // means plain: no pup AUMIDs, no pup jump lists (the stock Chrome button keeps Chrome's own menu).+  if (pupSettings().aumidIcons !== 'on') return;+  if (process.platform !== 'win32' || _identitySupported !== true || _jumplistSupported === false) return;+  if (!session) return;+  // v1.9.61: callers may pass the appId EXPLICITLY. stampPupIdentity brands the header BEFORE it+  // stamps the new appId onto the window, so at that moment session._curAppId is still the OLD id.+  // v2.0.177: never mint a generic Adom.Pup.<sid> intermediate for a thread-owned window.+  // v2.0.198 (John's screenshot: syr-rental-car button with NO tasks, only Pin/Close): the 2.0.177+  // DEFER starved the list forever when the stamp could not land (unresolvable hwnd, #54) — but the+  // BUTTON lives on the thread AUMID regardless, and that id is COMPUTABLE from the owner alone.+  // So derive it and attach the tasks to it directly: the menu works even when stamping cannot.+  let _jlAppId = (opts && opts.appId) || (session && session._curAppId) || null;+  if (!_jlAppId && session && pupSettings().threadTiles === 'on' && (session.owner || session._ownerThread)) {+    _jlAppId = threadAumid(session.owner || session._ownerThread);+  }+  const appId = _jlAppId || `${PUP_APP_ID}.${sessionId}`;+  let url = null; try { url = (session.page && session.page.url()) || null; } catch (e) {}+  const view = isAdomUrl(url) ? (session._wikiView === 'authed' ? 'authed' : 'public') : null;+  // v1.9.51: the jump-list STATE is now (view + a constant close-all task). The close-all task is+  // on EVERY pup window, so the idempotency key is just the view — a menu that already reflects this+  // view already has the close-all row too.+  // v1.9.61: key on (appId|view). Keying on view alone re-committed the list on every refresh once+  // the appId changed, and EVERY CommitList rebuilds the menu — if one lands while the user is+  // opening it, the shell dismisses it. That is the "first right-click does nothing, second works"+  // wonkiness. One commit per (button, view), never a redundant one.+  // v2.0.7 (B, "stamp once then freeze"): once this window's jump-list has been committed for its+  // frozen identity, never rebuild it again. Category/active-tab/view changes (public->authed, or+  // switching to a web tab) otherwise re-key and re-CommitList — the multi-tab menu churn. The tasks+  // are the same for a wiki window regardless of view (both switch-view tasks always show), so freezing+  // costs at most a slightly stale menu, and buys zero churn — matching the appId freeze.+  if (session._jlFrozen) return;+  const jlKey = `${appId}|${view || 'none'}|${pupSettings().jlViewTasks}|${pupSettings().jlCloseAll}|${pupSettings().jlDashboard}|${pupSettings().jlAnnotate}`;+  if (session._wikiJumplistKey === jlKey) return;+  // v2.0.3: coalesce the jump-list settle churn. On open, the bare per-session appId and the+  // public->authed view each produce a distinct (appId|view) commit, so a single open committed the+  // menu ~3x. Debounce the callers that DON'T pass an explicit appId (the settle-driven refreshes):+  // schedule ONE commit after a short quiet window, by which point appId + view have settled and the+  // (appId|view) dedup above collapses it to a single commit. The stamp path passes opts.appId and+  // must stay synchronous (it brands the header right before creating the button, v1.9.61), so it is+  // exempt; the commit re-entry (opts._commit) is exempt so it can actually fire.+  if (!(opts && opts._commit) && !(opts && opts.appId)) {+    // v2.0.6: RESET the timer on every change (not "schedule once"). A multi-tab open fires jump-list+    // requests spread over seconds (public->authed auth flip, then a second tab flips the category to+    // mixed); a fixed-window timer let an early state (wiki-in) commit before the window settled, so the+    // menu still rebuilt 2-3x. Resetting on each request means we only commit once the window has been+    // QUIET for the settle window, collapsing the whole settle to a single commit of the final state.+    if (session._jlCommitTimer) clearTimeout(session._jlCommitTimer);+    session._jlCommitTimer = setTimeout(() => {+      session._jlCommitTimer = null;+      const s2 = sessions.get(sessionId);+      if (s2) updateWikiJumplist(s2, sessionId, { _commit: true }).catch(() => {});+    }, 1200);+    return;+  }+  // v1.9.62: CLAIM the key before the await. Two callers (the pre-stamp brand and a timer-driven+  // refresh) can both compute the same key and both find it unset, so both commit — a redundant+  // CommitList that rebuilds the menu and can dismiss one the user is opening. Claiming up front+  // makes the second caller a no-op; we roll it back below if the call actually fails.+  const _prevJlKey = session._wikiJumplistKey;+  session._wikiJumplistKey = jlKey;+  // v1.9.6: give each task an icon (iconPath) — without it Windows shows a generic blank-document+  // glyph (John: "why is your icon generic for that menu item?").+  // v1.9.54: use THIS WINDOW'S category icon, not pupIconPath() with no arg — that argless call fell+  // back to PUP_ICON_DEFAULT (adom-pup-browser3.ico), the RETIRED "P + card" pup icon, so the menu+  // rows still wore the old brand. pupIconPath(sessionId) returns the new teal-tile category art+  // (pup-cat-<cat>9.ico), so the task rows match the window's own taskbar icon.+  const _taskIcon = pupIconPath(sessionId);+  // v1.9.9: WINDOWS ARGV QUOTING — THE reason clicking a task did NOTHING. The jump-list task line is+  // parsed by the CLI's argv parser, which STRIPS unescaped double quotes, so raw JSON arrived+  // mangled and the CLI died with `Invalid JSON args`. Wrap JSON in quotes with the inner quotes+  // BACKSLASH-escaped so argv delivers it intact as ONE argument. Any JSON-carrying task MUST use this.+  // v1.9.115 — TASK COMMANDS MUST CARRY IDENTITY. AD (>=1.9.183) refuses ANY command with no caller+  // identity, and a jump-list task is a real CLI command that pup composes. Without --ai-thread John's+  // click on "Switch to logged-in view" was refused with caller_identity_required and silently did+  // NOTHING. Same root cause as the un-branded windows, in the one call path I failed to enumerate:+  // I fixed the bridge's OUTBOUND calls (abCommand) and forgot the commands pup hands to WINDOWS.+  // The thread name is honest here — a human clicked a taskbar menu, so that is who is asking, and it+  // is what the user will recognise in their own Activity Log.+  // v1.9.121 — identity goes in the JSON ARGS, never as a CLI flag. The taskbar tasks invoke the+  // BUNDLED local adom-desktop-cli.exe, which is an OLDER build than the container CLI and rejects+  // --ai-thread outright ("error: unexpected argument '--ai-thread' found"), so my 1.9.115 flag made+  // every task command INVALID and John's clicks stopped working entirely. AD's documented precedence+  // is args > flag > env, and a `caller` key inside the verb's own JSON is just another arg, so it+  // works on every CLI version, old or new. Also space-free by construction, which dodges the+  // Explorer/ad-taskrun argv re-parsing hazard.+  // v2.0.41: every task now routes through pupTask() → the pup-owned launcher (direct-to-pup POST,+  // CLI fallback). We ensure the launcher .ps1 + the port-discovery file exist before committing.+  try { ensurePupJumplistLauncher(); writePupPortFile(); writeExtensionPortFile(); } catch (e) {}+  // v1.9.51: a CLOSE-ALL task on every pup window (John: "i have like 8 open right now"). It calls+  // pup_close with no sessionId, which kills every pup Chrome + clears every session.+  const closeAllTask = pupTask('pup_close', {}, {+    title: 'Close ALL Adom Pup windows',+    description: 'Close every Adom Pup browser window at once (all sessions, every tab)',+    iconPath: _taskIcon,+  });+  // v1.9.53: SHORT titles. John: the "Adom wiki:" prefix pushed the important word ("logged-in")+  // off the end where Windows truncates the task label ("...switch to logged-in vi…"). The whole+  // point of the toggle is the login state, so lead with that and let the DESCRIPTION carry the+  // "Adom wiki page" context (it renders on its own line, untruncated). Keep both under ~24 chars so+  // neither clips at the default jump-list width.+  // v1.9.128 (John): show BOTH view tasks on every wiki window, always. The old code offered only+  // the direction pup BELIEVED was the toggle — but pup's belief goes stale (auth flips in another+  // window, a cookie expires, a recovery misses state), and when it's wrong the ONE task shown is+  // the useless one. John: "you're never really always going to be in sync to know whether the user+  // is logged in or not so might as well always put both." Stable order (logged-in first) so muscle+  // memory works; clicking the view you're already in is just a harmless reload of that view.+  let tasks = [];+  // v1.9.275: each entry family has its own pref (dep of the jumpLists master).+  if (pupSettings().jlViewTasks === 'on' && (view === 'authed' || view === 'public')) {+    tasks.push(pupTask('pup_wiki_set_view', { sessionId, view: 'authed' }, { title: 'Switch to logged-in view', description: 'Reload this Adom wiki page SIGNED IN — private source, drafts, owner-only cards (already signed in? harmless reload)', iconPath: _taskIcon }));+    tasks.push(pupTask('pup_wiki_set_view', { sessionId, view: 'public' }, { title: 'Switch to public view', description: 'Reload this Adom wiki page logged OUT — exactly what the public/world sees (already public? harmless reload)', iconPath: _taskIcon }));+  }+  // v2.0.42 (John: "can you add a jump list to open the pup dashboard?"): one click on any pup+  // taskbar icon opens the live dashboard — the map of every pup window/tab and its owning AI+  // thread. The verb reuses the one dashboard window on repeat clicks, so this is idempotent.+  // v2.0.49 (John: "claude desktop can annotate — add it to pup"): draw on this page from the+  // taskbar; Done captures it for the AI thread that owns the window.+  if (pupSettings().jlAnnotate !== 'off') tasks.push(pupTask('pup_annotate', { sessionId }, {+    title: 'Annotate this page',+    description: 'Draw on this page (pen, arrow, box, text) — Done captures it so the AI thread owning this window sees exactly what you marked',+    iconPath: _taskIcon,+  }));+  if (pupSettings().jlDashboard === 'on') tasks.push(pupTask('pup_dashboard', {}, {+    title: 'Open pup dashboard',+    description: 'Live dashboard of every pup window: owning AI thread, tabs with favicons, foreground state and why, uptime (reuses the dashboard window if already open)',+    iconPath: _taskIcon,+  }));+  if (pupSettings().jlCloseAll === 'on') tasks.push(closeAllTask);+  // v2.0.21 (John: "stop trying to mangle the titles... instead give a jump list item that explains+  // the ai-thread that owns that window, and clicking it shows a native window with all the info"):+  // the FIRST task on every pup window names the owning AI thread; clicking it runs pup_window_info,+  // which paints a native always-on-top caption card just above the taskbar (ab desktop_caption)+  // with everything about this window. This replaces the title-tag approach for good.+  if (pupSettings().jlOwnerTask !== 'off') {+    const _ownT = (session && (session.owner || session._ownerThread)) || null;+    if (_ownT) tasks.unshift(pupTask('pup_window_info', { sessionId }, {+      title: `Owned by: ${String(_ownT).slice(0, 40)}`,+      description: `Show everything about this window as a native card above the taskbar — the AI thread that owns it ("${_ownT}"), its container, profile, tabs, age, and the last agent action`,+      iconPath: _taskIcon,+    }));+  }+  // v2.0.130 (#43): move this window's page into the user's Hydrogen workspace (webview via the+  // Browser Picker), then close the pup window. Only meaningful when Hydrogen runs locally; the+  // verb reports hydrogen_unreachable honestly and leaves the window alone if not.+  tasks.push(pupTask('pup_move_to_workspace', { sessionId }, {+    title: 'Move to my workspace',+    description: 'Hand this page to Hydrogen on this machine (opens in your workspace via the Browser Picker) and close this pup window',+    iconPath: _taskIcon,+  }));+  // The shell ignores a jump list on an unregistered AUMID — ensure it's registered first.+  // v1.9.314 (John: "i turned on jump lists and none of them work"): this guard REQUIRED the appId to+  // be in _registeredPupAumids, but that set is ONLY filled by desktop_register_app_identity, which+  // this AD build silently no-ops (measured: appIdReg=false for every window). So the guard RETURNED+  // before every commit and, because the dedup key is claimed just above, then dedup-skipped forever —+  // the real reason NONE of the jump lists worked. The per-window STAMP already associates the window+  // with this AUMID, and a direct desktop_set_window_jumplist (passing hwnd) commits fine without the+  // registry entry (proven). Register best-effort; NEVER block the commit on it.+  if (!_registeredPupAumids.has(appId)) { try { await registerPupAumid(appId, sessionId); } catch (e) {} }+  // v1.9.60 — BRAND THE JUMP-LIST HEADER ROW (AD >= 1.9.153).+  //+  // The header row (the "Adom Pup" app tile above Pin/Close) is branded by passing `headerIcon` to+  // desktop_set_window_jumplist. TWO non-obvious requirements, both measured:+  //   1. You MUST pass `hwnd`. With only appId, AD cannot locate the window to write the per-window+  //      Relaunch* props on and silently returns headerBranded:false with an EMPTY headerFields and+  //      NO error. `titleContains` does NOT work here either (also headerBranded:false) even though+  //      it works on desktop_set_window_identity. hwnd is the only locator that brands the header.+  //   2. The .ico must pass AD's iconCheck — in particular its 256px entry must be PNG-compressed,+  //      or the shell paints the generic white document regardless (the Vista+ rule that had all of+  //      our icons failing until 1.9.58).+  // The response carries headerBranded / headerFields / headerIconCheck, so this is verifiable in+  // code — no more "right-click it and tell me what you see".+  // v1.9.312 (John: "i turned on jump lists and none of them work"): this resolved the hwnd by TITLE+  // (desktop_find_window titleContains), which returns null when the title tag is off — so payload.hwnd+  // was never set and, per the note above, the jump list could not be committed to the window. The+  // "known Windows 11 bug" comment on the setting was a MISDIAGNOSIS; it was this title lookup. Use the+  // reliable birth-time/persisted handle instead, the same one every other window operation now uses.+  let hwnd = null;+  try {+    if (session && !session._hwnd) await resolveSessionHwndByBounds(session, sessionId);+    if (session && session._hwnd && await hwndBelongsToPup(session._hwnd, session)) hwnd = Number(session._hwnd);+  } catch (e) {}+  try {+    const payload = { appId, tasks };+    if (hwnd) { payload.hwnd = hwnd; payload.headerIcon = pupIconPath(sessionId); }+    const r = await chrome.abCommand('desktop_set_window_jumplist', payload, { timeoutMs: 6000 });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+    if (_isUnknownVerb(r, raw)) { _jumplistSupported = false; session._wikiJumplistKey = _prevJlKey; return; }+    _jumplistSupported = true;+    session._wikiJumplistView = view;+    session._wikiJumplistKey = jlKey;+    // v2.0.8 (B): freeze as soon as we've committed the menu onto a real per-session CATEGORY appId+    // (Adom.Pup.<sid>.<cat>.<gen>), not the bare fallback. That is the button the window keeps, so one+    // commit to it is all we ever need; later view/active-tab flips are no-ops. Checking the appId+    // shape (rather than session._frozenAppId, which is only set after the stamp returns) freezes on the+    // very first real commit instead of a beat later, killing the settle rebuilds too.+    // v2.0.43 (mimic-ralph: windows randomly frozen at a 3-task menu, no view toggles): a wiki+    // window's categorized appId can commit BEFORE its login view resolves, and freezing on that+    // commit locks the incomplete menu forever. Freeze a wiki-category window only once the menu+    // actually carries the view tasks; non-wiki windows still freeze on first categorized commit.+    const _menuComplete = (view === 'authed' || view === 'public') || appId.indexOf('.wiki') < 0;+    if (appId && appId.indexOf('.' + sessionId + '.') >= 0 && _menuComplete) session._jlFrozen = true;+    // Surface the header-branding outcome: it is the ONLY programmatic signal that the app-tile row+    // actually took our icon (the rendered menu itself cannot be captured from the cloud).+    let hb = null, hic = null;+    try { const o2 = raw ? JSON.parse(raw) : {}; const d2 = o2.data || o2; hb = d2.headerBranded; hic = d2.headerIconCheck; } catch (e) {}+    console.log(`[jumplist] "${sessionId}" set ${tasks.length} task(s) on ${appId} (view=${view || 'none'}${view ? '+' : ''}close-all) headerBranded=${hb}${hic && hic.ok === false ? ` ICONCHECK_FAILED:${(hic.issues || []).join('; ')}` : ''}`);+  } catch (e) { session._wikiJumplistKey = _prevJlKey; }+}+// v1.8.80: TAB-COUNT overlay badge (John: "if the ai opens 8 tabs that makes that pup window+// unique"). The overlay slot is FREE on identity-capable AD (the base icon is the Adom Pup+// identity), so it now shows the window's tab count for >=2 tabs — dark circle, white digit,+// 9+ cap. NO badge = 1 tab, so a many-tab window is instantly distinguishable from its+// single-tab neighbors. On AD <1.9.134 (no identity) the overlay stays the pup brand badge —+// branding beats counting there. Updated on open_tab/close_tab/popup-attach; cleared at 1.+const _tabBadgeB64 = new Map(); // count-key -> base64 png (lazy)+function tabBadgeIcon(count) {+  const key = count > 9 ? '9plus' : String(count);+  if (_tabBadgeB64.has(key)) return _tabBadgeB64.get(key);+  try {+    const b = fs.readFileSync(path.join(__dirname, 'icons', 'tab-badges', `badge-${key}.png`)).toString('base64');+    _tabBadgeB64.set(key, b);+    return b;+  } catch (e) { return null; }+}+// v1.8.84: DUAL counter for grouped mode (John: "1 for # of windows and then a total for the+// tabs"). Windows = dark circle upper-right (same language as the split badge); total tabs =+// light teal circle lower-left with dark text. Combos are dynamic -> rendered at runtime from+// an SVG template via sharp (already a dep), cached in memory per (wins,tabs) pair. If sharp+// is unavailable (optional dep), grouped mode falls back to the static windows-count badge.+const _dualBadgeCache = new Map();+async function dualBadgeB64(wins, tabs) {+  const key = `${wins}/${tabs}`;+  if (_dualBadgeCache.has(key)) return _dualBadgeCache.get(key);+  let sharpMod = null;+  try { sharpMod = require('sharp'); } catch (e) { return null; }+  const w = wins > 9 ? '9+' : String(wins);+  const t = tabs > 99 ? '99+' : String(tabs);+  const wSize = w.length > 1 ? 11 : 14;+  const tSize = t.length > 2 ? 7.8 : (t.length > 1 ? 9.8 : 13);+  // v1.8.90: ROUNDED-RECT plates behind the digits (per John — bare digits were hard to see+  // without a background; rects fit digits tighter than circles so the type stays big).+  // Left dark rect = windows (white digit); right teal rect = total tabs (dark digit).+  const wS = w.length > 1 ? 11 : 14;+  const tS = t.length > 2 ? 8 : (t.length > 1 ? 10.5 : 14);+  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">+  <rect x="0.5" y="1" width="14.5" height="15" rx="4.5" fill="#003d40"/>+  <text x="7.75" y="${8.5 + wS * 0.36}" font-family="Segoe UI, Arial, sans-serif" font-size="${wS}" font-weight="700" fill="#ffffff" text-anchor="middle">${w}</text>+  <rect x="17" y="1" width="14.5" height="15" rx="4.5" fill="#7de8e2"/>+  <text x="24.25" y="${8.5 + tS * 0.36}" font-family="Segoe UI, Arial, sans-serif" font-size="${tS}" font-weight="700" fill="#003d40" text-anchor="middle">${t}</text>+</svg>`;+  try {+    const buf = await sharpMod(Buffer.from(svg)).resize(32, 32).png().toBuffer();+    const b64 = buf.toString('base64');+    _dualBadgeCache.set(key, b64);+    return b64;+  } catch (e) { return null; }+}++// v1.9.57 — AGENT-ACTIVITY INDICATOR (taskbar progress bar).+//+// John runs many AI threads at once and asked which pup window an agent is actually driving right+// now. The taskbar PROGRESS bar answers it with zero AD work: ITaskbarList3 progress is already+// exposed by desktop_taskbar, it paints ON the button (so it reads at a glance across the whole+// taskbar), and it is a SEPARATE channel from the overlay icon — the favicon overlay is untouched.+//+// Semantics: 'indeterminate' (marquee) while a thread is issuing verbs at this window, cleared+// ~2.5s after the last one. Deliberately NOT a percentage: an agent's work has no known length, and+// a fake percentage would be a lie. Only transitions are sent to AD (idle->busy once, busy->idle+// once), never one call per verb, so a chatty thread costs 2 AD calls, not 200.+const _busyState = new Map();      // sessionId -> { busy, timer }+const BUSY_IDLE_MS = 2500;+// v1.9.86 (Kyle) — WHY THIS IS GATED, recorded precisely because the obvious reading is WRONG and+// would invite a future un-gate. desktop_taskbar is NOT unsupported on macOS: AD really calls+// NSApplication.requestUserAttention (Dock bounce) for flash and NSDockTile for badge text, and+// every arg shape returns success here. The blocker is the ADDRESSING MODEL, not the capability.+// macOS has no per-window taskbar button, so AD's mac path ignores titleContains/hwnd (it reports+// hwnd:0) and paints AD's OWN Dock tile. pup's use is inherently PER-SESSION-WINDOW — "which of+// these five pup windows is this thread driving" — so un-gating would badge Adom Desktop rather+// than the pup window, and every session would fight over one tile. AD also skips overlay.icon on+// mac outright (no Dock equivalent), which is what this call passes.+// So: capability real, semantics incompatible. Do not un-gate on learning "desktop_taskbar works+// on mac" — it does; it just cannot address what pup needs to address. The mac equivalent of this+// cue is pup_alert_window's toast-and-unhide path, which IS per-window.+async function setSessionProgress(sessionId, busy) {+  if (minimalTouch()) return;   // v1.9.185 minimal-touch: no taskbar progress bars+  // v1.9.152 (John's audit columns): count progress-bar triggers + total ms it ran, per session.+  try {+    const _ps = sessions.get(sessionId);+    if (_ps) {+      if (busy && !_ps._progressSince) { _ps._progressSince = Date.now(); _ps._progressCount = (_ps._progressCount || 0) + 1; }+      if (!busy && _ps._progressSince) { _ps._progressMs = (_ps._progressMs || 0) + (Date.now() - _ps._progressSince); _ps._progressSince = null; }+    }+  } catch (e) {}+  if (process.platform !== 'win32') return;+  try {+    await chrome.abCommand('desktop_taskbar', {+      ...(await windowTarget(sessions.get(sessionId), sessionId)),+      progress: busy ? { state: 'indeterminate' } : { state: 'none' },+    }, { timeoutMs: 4000 });+  } catch (e) {}+  // v2.0.47 (John's zoom: a BARE tile my audit swore was painted): the taskbar progress cycle can+  // clear the button's overlay, but pup's paint memory (_appOverlay/_lastPaintSig) survives it, so+  // the enforcer "sees" a painted window and never repaints — bare on screen, green in the logs.+  // Windows offers no way to QUERY overlay state, so after every progress change assume the overlay+  // may be gone: void the paint memory and let the 4s enforcer sweep repaint (dedup makes it cheap).+  try { const _s3 = sessions.get(sessionId); if (_s3) { _s3._lastPaintSig = null; _s3._appOverlay = false; _s3._overlayEnforceAt = 0; } } catch (e) {}+}+// ============================================================================+// v2.0.289 VERB-TIMING METRICS + DETERMINATE TASKBAR PROGRESS (John's request: replace the orange+// flash with a taskbar PROGRESS BAR sized to how long the operation usually takes). Keep a running+// average per operation, fill the bar over that estimate, start at the EARLIEST point and end at the+// LATEST. Wired into icon-application (until the taskbar icon actually lands) and screenshots; any+// call site can wrap work with withTaskbarProgress(). Setting: taskbarActivity = progress|flash|off.+// ----------------------------------------------------------------------------+const VERB_TIMING_FILE = path.join(require('os').homedir(), '.adom', 'pup-verb-timing.json');+let _verbTiming = {};   // opName -> { n, avgMs } — persisted so the averages survive restarts+try { _verbTiming = JSON.parse(fs.readFileSync(VERB_TIMING_FILE, 'utf8')) || {}; } catch (e) {}+let _verbTimingDirty = false;+function recordTiming(opName, ms) {+  if (!opName || !(ms >= 0) || ms > 120000) return;+  const cur = _verbTiming[opName] || { n: 0, avgMs: ms };+  cur.avgMs = cur.n === 0 ? Math.round(ms) : Math.round(cur.avgMs * 0.75 + ms * 0.25);  // weight recent runs+  cur.n = Math.min((cur.n || 0) + 1, 100000);+  _verbTiming[opName] = cur; _verbTimingDirty = true;+}+setInterval(() => {+  if (!_verbTimingDirty) return; _verbTimingDirty = false;+  try { fs.mkdirSync(path.dirname(VERB_TIMING_FILE), { recursive: true }); fs.writeFileSync(VERB_TIMING_FILE, JSON.stringify(_verbTiming)); } catch (e) {}+}, 30000);+const _DEFAULT_EST_MS = { 'icon-apply': 2200, 'screenshot': 900, 'navigate': 2500, 'open': 3200 };+function estimateMs(opName) { const t = _verbTiming[opName]; return (t && t.avgMs) || _DEFAULT_EST_MS[opName] || 1200; }+function taskbarActivityMode() { const m = pupSettings().taskbarActivity; return (m === 'flash' || m === 'off') ? m : 'progress'; }+async function _pushProgress(target, value) { try { await chrome.abCommand('desktop_taskbar', { ...target, progress: value == null ? { state: 'none' } : { state: 'normal', value } }, { timeoutMs: 3000 }); } catch (e) {} }+// A determinate bar that fills over the running-average estimate for `opName`, held ON for the life of+// `work`, then completed and cleared. Records the real duration to sharpen the estimate. No-op unless+// the setting is 'progress'. The bar caps at 92% until the work actually finishes so it never lies.+async function withTaskbarProgress(sessionId, opName, work) {+  const runOne = () => (typeof work === 'function' ? work() : work);+  if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress' || minimalTouch()) return runOne();+  const sess = sessions.get(sessionId);+  const target = sess && await windowTarget(sess, sessionId).catch(() => null);+  if (!target || !target.hwnd) return runOne();   // no handle yet — nothing to paint on+  const est = estimateMs(opName), started = Date.now(); let done = false;+  await _pushProgress(target, 2);+  const timer = setInterval(() => { if (done) return; const f = Math.min(0.92, (Date.now() - started) / Math.max(200, est)); _pushProgress(target, Math.max(2, Math.round(f * 100))).catch(() => {}); }, 180);+  try { return await runOne(); }+  finally {+    done = true; clearInterval(timer); recordTiming(opName, Date.now() - started);+    await _pushProgress(target, 100);+    setTimeout(() => { _pushProgress(target, null).catch(() => {}); try { if (sess) { sess._lastPaintSig = null; sess._appOverlay = false; sess._overlayEnforceAt = 0; } } catch (e) {} }, 220);+  }+}+// ICON-APPLY progress: a window's taskbar icon is applied ASYNC (birth -> hwnd -> stamp -> tile render+// -> nudge). Show a determinate bar the whole time — start at open (EARLIEST), end when the icon+// actually lands (LATEST) — timed to the running average of past icon-applies. Self-contained: a poll+// resolves the hwnd once it exists, then fills the bar until markIconApplied() fires or it times out.+const _iconApplyRunning = new Set();+function startIconApplyProgress(sessionId) {+  if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress' || minimalTouch()) return;+  if (_iconApplyRunning.has(sessionId)) return;+  _iconApplyRunning.add(sessionId);+  const est = estimateMs('icon-apply'), started = Date.now();+  (async () => {+    let target = null;+    try {+      for (let i = 0; i < 40; i++) {   // up to ~8s: wait for the handle, then fill the bar+        const sess = sessions.get(sessionId);+        if (!sess) break;+        if (sess._iconApplied) break;+        if (!target) target = await windowTarget(sess, sessionId).catch(() => null);+        if (target && target.hwnd) {+          const f = Math.min(0.92, (Date.now() - started) / Math.max(200, est));+          await _pushProgress(target, Math.max(2, Math.round(f * 100)));+        }+        await new Promise(r => setTimeout(r, 200));+      }+    } catch (e) {}+    if (target && target.hwnd) { await _pushProgress(target, 100); setTimeout(() => _pushProgress(target, null).catch(() => {}), 220); }+    recordTiming('icon-apply', Date.now() - started);+    _iconApplyRunning.delete(sessionId);+  })().catch(() => { _iconApplyRunning.delete(sessionId); });+}+function markIconApplied(sessionId) { const s = sessions.get(sessionId); if (s) s._iconApplied = true; }++// v1.9.166 (John, repeatedly: 'use an AD toast to get a human's attention'). Whenever pup FOREGROUNDS+// a window (a reveal / demo / 'show me'), fire an AD toast FIRST so the user's eyes are drawn to what+// just appeared — never silently push a window onto their screen. Best-effort; debounced per session.+const _fgToastAt = new Map();+// v1.9.201 (John): "every time you open a window in the foreground and you force the AI to give you+// a reason you should flash that on screen as an AD caption for 1 second". The foreground GATE already+// makes callers justify themselves and records the reason in lastForeground — but that audit trail was+// only visible if you went looking for it. Now the reason appears ON SCREEN the moment the window+// arrives, so the justification is accountable in real time. Deliberately NOT gated by minimalTouch:+// this is a user-protection signal, not cosmetic machinery.+// v2.0.294 (John: "time your ab captions... the timing is just too fast. we need a longer amount of+// time here, or you need to count the words and adjust"): caption duration scales with how much+// there is to READ. The fgCaptionMs slider is the FLOOR (short captions hold at least that long);+// longer text extends at reading speed (~350ms/word + 900ms to notice it), capped at 12s so a+// runaway reason can never squat on the screen.+function captionMsFor(text) {+  const base = Math.max(500, Math.min(12000, Number(pupSettings().fgCaptionMs) || 3500));+  const words = String(text || '').trim().split(/\s+/).filter(Boolean).length;+  return Math.max(base, Math.min(12000, 900 + words * 350));+}+async function captionForegroundReason(sessionId, reason, verb, thread) {+  if (pupSettings().fgCaption !== 'on') return;+  try {+    const why = String(reason || '').trim();+    if (!why) return;+    // v2.0.290 (John: "if you foreground, the caption must say WHICH ai-thread is doing it, and the+    // reason"). Name the initiating thread (the caller), falling back to the window's owner thread.+    const _s = sessions.get(sessionId);+    const _th = (thread && String(thread).trim()) || (_s && (_s.owner || _s._ownerThread)) || null;+    const who = _th ? `AI thread "${_th}"` : 'Adom';+    const msg = `${who} brought "${sessionId}" to the front. Reason: "${why}"`;+    const _capMs = captionMsFor(msg);+    await pupCaption(msg, _capMs, 'pup-fg-reason', _capMs);+  } catch (e) {}+}++async function toastForeground(sessionId, reason) {+  if (minimalTouch()) return;   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome+  try {+    if (process.platform !== 'win32') return;+    const now = Date.now();+    if (now - (_fgToastAt.get(sessionId) || 0) < 4000) return;   // debounce a burst+    _fgToastAt.set(sessionId, now);+    const body = reason ? String(reason).slice(0, 140) : 'Adom opened a window on your screen.';+    await chrome.abCommand('notify_user', { title: 'Adom is showing you a window', body, focus: false }, { timeoutMs: 4000 });+  } catch (e) {}+}+function markSessionBusy(sessionId) {+  if (process.platform !== 'win32' || !sessionId) return;+  if (taskbarActivityMode() !== 'progress') return;      // v2.0.292: driving-strip folded into taskbarActivity='progress'+  if (!sessions.has(sessionId)) return;+  let st = _busyState.get(sessionId);+  if (!st) { st = { busy: false, timer: null }; _busyState.set(sessionId, st); }+  // v2.0.193: while a caller holds EXPLICIT progress (pup_progress), the automatic busy strip stays+  // hands-off — otherwise its on/off toggling would fight the caller's crawl-stage bar.+  { const _es = sessions.get(sessionId); if (_es && _es._explicitProgress) return; }+  if (!st.busy) {+    st.busy = true;+    console.log(`[activity] "${sessionId}" agent driving — taskbar progress ON`);+    setSessionProgress(sessionId, true).catch(() => {});+  }+  clearTimeout(st.timer);+  st.timer = setTimeout(() => {+    st.busy = false;+    console.log(`[activity] "${sessionId}" idle — taskbar progress OFF`);+    setSessionProgress(sessionId, false).catch(() => {});+  }, BUSY_IDLE_MS);+}+function clearSessionBusy(sessionId) {+  const st = _busyState.get(sessionId);+  if (st) {+    clearTimeout(st.timer);+    // v1.9.166 (John: 'i see you leave those stale quite often'): explicitly turn the OS taskbar+    // progress OFF on teardown. Deleting the internal state alone left the taskbar bar lit whenever+    // the window churned before its idle-timer fired (the stale-bar bug).+    if (st.busy) setSessionProgress(sessionId, false).catch(() => {});+    _busyState.delete(sessionId);+  }+}+// Verbs that are pure status/polling: they do NOT mean an agent is driving the window, and lighting+// the bar for them would make every window look permanently busy (the audit loops alone poll these).+const PASSIVE_VERBS = new Set([+  'pup_annotate', 'pup_floaty_click', 'pup_list_windows', 'pup_list_tabs', 'pup_readiness', 'pup_status', 'pup_maximize', 'pup_hover', 'pup_deps', 'pup_dashboard',+  'pup_describe', 'pup_configure', 'pup_prewarm', 'pup_rescan', 'pup_wait', 'pup_import_scan', 'pup_set_identity',+]);++// v1.9.58 — THUMBNAIL TOOLTIP (AD >= 1.9.152: desktop_taskbar { thumbnailTooltip }).+//+// The hover-preview CAPTION is just the window title and Windows truncates it at ~25-30 chars, so it+// can only ever carry the state glyph plus a clipped page name. The thumbnail tooltip is the only+// UNCAPPED text surface on that popup and it accepts NEWLINES, so this is where the full answer to+// "what is this window?" belongs: that it is Adom Pup, what KIND of content it holds, whether a wiki+// page is the public or the signed-in view, that it is Chrome-driven, and the tab count.+const CAT_LABEL = {+  wiki: 'Adom wiki (PUBLIC view)',+  'wiki-in': 'Adom wiki (SIGNED IN)',+  app: 'Adom app',+  web: 'Web page',+  mixed: 'Mixed content',+};+function buildThumbnailTooltip(session, sessionId) {+  const cat = pupCategory(sessionId);+  const tabs = (session.tabs || []).length;+  // v1.9.101 (John's ask) — LEAD WITH THE OWNING AI THREAD. The hover tooltip is the only UNCAPPED+  // text surface on the taskbar preview (the header above the thumbnail is the window TITLE and+  // truncates around 25-30 chars), so it is the right home for full attribution. With ~20 threads+  // running at once, "which conversation made this window" is the single most useful thing the user+  // can read here — it turns an anonymous Chrome preview into "oh, that's my adom-tsci thread".+  const lines = [];+  const who = session._ownerThread || session._lastThread || null;+  const where = session._ownerContainer || session._lastContainer || null;+  if (who) {+    lines.push(where ? `${who}  ·  ${where}` : `${who}`);+    // v1.9.107 (John: "show the ai-thread name and reason for showing the pup window") — the REASON+    // the thread gave for this window. It only fits here: the preview HEADER is the window title+    // (truncates ~25-30 chars) and a thumbnail-toolbar button carries an icon, not text. This tooltip+    // is uncapped and takes newlines, so prose belongs on it.+    if (session._openReason) lines.push(`why: ${session._openReason}`);+    // A handoff is worth showing: the window belongs to one thread but another just drove it.+    if (session._lastThread && session._lastThread !== who) lines.push(`last driven by: ${session._lastThread}`);+  }+  lines.push(`Adom Pup · ${CAT_LABEL[cat] || 'Browser window'}${who ? '' : ' · (no AI-thread identity yet)'}`);+  try {+    if (cat === 'mixed') {+      // Name the distinct hosts so the user knows WHY it is mixed without opening it.+      const hosts = [];+      for (const t of (session.tabs || [])) {+        try { const h = new URL(t.page.url()).hostname.replace(/^www\./, ''); if (h && !hosts.includes(h)) hosts.push(h); } catch (e) {}+      }+      if (hosts.length) lines.push(hosts.slice(0, 4).join(', '));+    } else {+      const active = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0];+      if (active) {+        let u = ''; try { u = active.page.url(); } catch (e) {}+        if (u) { try { const p = new URL(u); lines.push((p.hostname + p.pathname).replace(/^www\./, '').slice(0, 70)); } catch (e) {} }+      }+    }+  } catch (e) {}+  // v1.9.101: report the browser ACTUALLY driving this window. This said "Chrome for Testing"+  // unconditionally, which has been wrong for every native-first window since v1.9.90.+  let browserLabel = 'Browser';+  try {+    const be = browsers.get(session.profileName);+    const chosen = be && be.browser && be.browser._pupChosen;+    if (chosen && chosen.kind) {+      browserLabel = chosen.kind === 'chrome-for-testing' ? 'Chrome for Testing'+        : chosen.kind === 'edge' ? 'Microsoft Edge'+        : chosen.kind === 'chrome' ? 'Google Chrome' : chosen.kind;+    }+  } catch (e) {}+  lines.push(`${browserLabel} · ${tabs} tab${tabs === 1 ? '' : 's'}  ·  session: ${sessionId}`);+  return lines.join('\n');+}+// v1.9.104 — the caller to attribute a session-scoped AD callback to: the thread that OWNS this+// window (falling back to whoever last drove it). Returned as an abCommand opt so AD can render+// "<thread> (via pup)" on the Activity Log + any approval prompt. Returns null for work that is+// genuinely pup's own (timers, sweeps) — per the contract we must NOT forward a stale identity there.+function sessionCaller(session) {+  try {+    if (!session) return null;+    const thread = session._ownerThread || session._lastThread || null;+    if (!thread) return null;+    return { thread, container: session._ownerContainer || session._lastContainer || null, reason: session._openReason || null };  // #350: forward reason too+  } catch (e) { return null; }+}+async function updateThumbnailTooltip(session, sessionId) {+  if (minimalTouch()) return false;   // v1.9.190: minimal-touch — no taskbar work at all+  if (process.platform !== 'win32' || !session || _thumbTooltipSupported === false) return;+  const tip = buildThumbnailTooltip(session, sessionId);+  if (session._thumbTip === tip) return;   // idempotent: only push real changes+  try {+    const r = await chrome.abCommand('desktop_taskbar', {+      ...(await windowTarget(sessions.get(sessionId), sessionId)), thumbnailTooltip: tip,+    }, { timeoutMs: 5000, caller: sessionCaller(session) });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+    if (_isUnknownVerb(r, raw) || /unknown (arg|field)|unexpected (arg|field)/i.test(raw || '')) {+      _thumbTooltipSupported = false;+      console.log('[thumbtip] AD lacks thumbnailTooltip (needs >=1.9.152) — skipping');+      return;+    }+    _thumbTooltipSupported = true;+    session._thumbTip = tip;+    console.log(`[thumbtip] "${sessionId}" -> ${tip.replace(/\n/g, ' | ')}`);+  } catch (e) {}+}+let _thumbTooltipSupported = null;++async function updateTabCountBadge(session, sessionId) {+  if (minimalTouch()) return;   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome+  if (process.platform !== 'win32' || _identitySupported !== true) return; // overlay busy with brand badge on old AD+  // v1.9.31: a tab verb just ran — cheap moment to notice a drag-out (event-driven, not a scan).+  drainWindowOpenedEvents().catch(() => {});+  // v1.9.59: refresh the hover-preview TOOLTIP here. It was only wired into refreshWindowChrome+  // (drag-out / reclaim / relaunch) and the category-change hook, so the NORMAL OPEN PATH — which+  // calls brandPupWindow directly and never touches either — never set a tooltip at all, and no+  // window that just opened had one. This function is the right home: it already runs on open and on+  // every tab open/close, which is exactly when the tooltip's content (category + tab count) changes.+  // It is idempotent (session._thumbTip) so repeat calls cost nothing.+  updateThumbnailTooltip(session, sessionId).catch(() => {});+  // v1.9.22: if the window's CATEGORY changed (a tab made it mixed, a navigate left the wiki...),+  // re-stamp the base icon NOW — the 10-minute brand debounce is for redundant re-stamps of the+  // SAME icon, not for a category flip the user should see immediately.+  try {+    const cat = pupCategory(sessionId);+    if (cat !== session._lastCat) {+      session._lastCat = cat;+      // v1.9.27: the stamp now swaps to a category-carrying appId, which makes the shell create a+      // FRESH button with the new tile (the only repaint Win11 allows) and handles the AUMID+      // register/unregister + jump-list re-attach itself. v1.9.28: RETRY with backoff — a single+      // silent failure here left the tile latched on the old category (seen live: .mixed AUMID+      // registered but the stamp never applied).+      (async () => {+        for (let i = 0; i < 4; i++) {+          let ok = false;+          try { ok = await brandPupWindow(sessionId); } catch (e) {}+          if (ok) { session._badgedAt = Date.now(); return; }+          await new Promise(r => setTimeout(r, 900 * (i + 1)));+        }+        session._lastCat = undefined;   // same reason as above: never cache an unlanded stamp+        console.log(`[identity] "${sessionId}" category re-stamp exhausted retries (wanted ${cat}) — cache cleared`);+      })().catch(() => {});+    }+  } catch (e) {}+  try {+    // grouped mode: the single stacked button's badge = WINDOW count (per-window tab counts+    // are meaningless on a grouped button). Applied to every window; last write paints the group.+    if (pupSettings().taskbarGrouping === 'grouped') {+      const wins = sessions.size;+      let totalTabs = 0;+      for (const [, s2] of sessions) totalTabs += (s2.tabs || []).length;+      let icon = null;+      if (wins >= 2) icon = (await dualBadgeB64(wins, totalTabs)) || tabBadgeIcon(wins);+      else if (totalTabs >= 2) icon = tabBadgeIcon(totalTabs); // single window: plain tab count+      for (const [sid2] of sessions) {+        const args2 = icon+          ? { ...(await windowTarget(sessions.get(sid2), sid2)), overlay: { icon, tooltip: `Adom Pup — ${wins} window${wins === 1 ? '' : 's'}, ${totalTabs} tab${totalTabs === 1 ? '' : 's'}` } }+          : { ...(await windowTarget(sessions.get(sid2), sid2)), overlay: { badge: 'none' } };+        try { await chrome.abCommand('desktop_taskbar', args2, { timeoutMs: 5000 }); } catch (e) {}+      }+      return;+    }+    // v1.9.0 split-mode overlay RULE (John: favicons > counts): the OVERLAY is the ACTIVE tab's+    // site favicon, whatever the tab count — you always see what you're looking at. The tab count+    // rides in the hover tooltip. Only if the active page has NO favicon do we fall back to a+    // plain count badge (so a many-tab faviconless window isn't a blank Adom tile).+    const count = (session.tabs || []).length;+    const worn = await applyAppOverlay(session, sessionId); // active tab's favicon + count in tooltip+    if (worn) return;+    session._appOverlay = false;+    if (count >= 2) {+      const icon = tabBadgeIcon(count);+      if (!icon) return;+      session._overlayDesc = `count:${count} (no favicon on the active tab, so the tab-count badge)`;+      await chrome.abCommand('desktop_taskbar', {+        ...(await windowTarget(sessions.get(sessionId), sessionId)),+        overlay: { icon, tooltip: `Adom Pup — ${count} tabs` },+      }, { timeoutMs: 5000 });+    } else {+      await chrome.abCommand('desktop_taskbar', {+        ...(await windowTarget(sessions.get(sessionId), sessionId)),+        overlay: { badge: 'none' },+      }, { timeoutMs: 5000 });+    }+  } catch (e) {}+}++// v1.8.79: BRAND SWEEP — unify every live window to the CURRENT identity art. Windows keep+// the identity they were stamped with at birth (per-HWND, AD-cached HICON), so after an icon+// rebrand or a bridge update the taskbar shows MIXED GENERATIONS (John caught a museum of+// today's six releases). The sweep re-asserts each session's title suffix (title churn can+// strand a window un-resolvable) then re-stamps it. Runs at startup (post-reattach) and after+// every rescan.+async function brandSweep(reason) {+  if (minimalTouch()) return;+  const _sweepGen = ++_identityGen;   // every new sweep supersedes ALL previous in-flight identity work   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome+  // v2.0.183 (John, 2nd enum report: "you're spamming enum_windows again"): a sweep re-stamps and+  // NUDGES every window, and each nudge fires a window-opened event whose drain kicked+  // reclaimUnmanagedWindows (an enum) — pup reacting to its own sweep, per window. The per-nudge+  // guard only covered ~6s; a 7-window sweep spans longer. Blanket the whole sweep.+  _nudgeUntil = Date.now() + 45000;+  // v1.9.41 (audit): re-stamp CATEGORY icons for every live session. Windows that predate the+  // category work, or that were recovered across a bridge restart, kept the plain default tile+  // forever because nothing re-evaluated them. Found by auditing my own taskbar: a long-lived+  // window sat on the default icon while its neighbours showed proper categories.+  try {+    for (const [sid, sess] of [...sessions]) {+      sess._lastCat = undefined;               // force a real re-evaluation+      refreshWindowChrome(sess, sid).catch(() => {});+    }+  } catch (e) {}++  if (process.platform !== 'win32') return;+  scheduleIconAudit('after brandSweep: ' + reason);+  let n = 0;+  for (const [sid, sess] of sessions) {+    if (_sweepGen !== _identityGen) { console.log(`[identity] brandSweep(${reason}) ABORTED mid-loop (superseded by a newer mode switch)`); break; }+    try {+      const tab = sess.tabs && sess.tabs[0];+      if (!tab || !tab.page) continue;+      // re-assert the title suffix so AD's resolver can find the window even after title churn+      if (pupSettings().titleTag === 'on') try {+        await tab.page.evaluate((sfx, sid2) => {+          try {+            const esc = sid2.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');+            const re = new RegExp('\\s*\\((?:session|ai-thread): ' + esc + '(?:[^)]*)\\)', 'g');+            const base = String(document.title || '').replace(re, '');   // drop any existing tag (either label)+            const want = base + sfx + ')';+            if (document.title !== want) document.title = want;           // exactly one, new label+          } catch (e) {}+          try { if (window.name !== 'pup:' + sid2) window.name = 'pup:' + sid2; } catch (e) {}+        }, ` (${TITLE_LABEL}: ${sid}`, sid);+      } catch (e) {}+      const ok = await brandPupWindow(sid);+      if (ok) n++;+    } catch (e) {}+  }+  if (n) console.log(`[brand-sweep] (${reason}) re-branded ${n} window(s) to current identity`);+}++// v1.8.92: APP-FAVICON overlay (John's standard: a pup window driven by an app WEARS that+// app's icon). Source of truth = the page's own <link rel="icon"> — the exact artifact the+// adom-ui-design standard mandates every app serve (monochrome white mark; see hd-tab-icons:+// the served favicon is the canonical webview identity, and pup now honors the same contract).+// Explicit overrides: pup_open_window {appIconB64|appIconUrl, appName}. Composited onto a+// dark rounded plate via sharp (white marks need dark backing on the taskbar). An app-looking+// URL (localhost / *.adom.cloud proxy) serving NO favicon gets a loud reprimand hint teaching+// the standard. When an app icon is worn, it takes the overlay slot; the tab count moves to+// the hover tooltip (grouped mode's dual counter is unaffected).+// v1.8.93: WIKI DUAL-VIEW + AUTH. pup drives a fresh anonymous profile by default → the wiki+// renders LOGGED OUT (public view). For the LOGGED-IN view, route to a reserved PERSISTENT+// profile whose on-disk cookie jar holds the SSO session (30-day) — log in once via SSO, reused+// for a month, re-auth only on expiry. No token→cookie exchange exists (wiki auth is cookie-based+// SSO to hydrogen.adom.inc; the container's CLI bearer is a different credential). The two views+// together let a user VERIFY publish visibility — e.g. "did source leak to the public?" (a+// hide_source page shows source to authed, binaries-only to public).+const WIKI_AUTH_PROFILE = 'adom-wiki-authed';+// v1.9.123 (step 2 of John's durable-profile plan) — THE shared, persistent "you" profile.+// Before this, an open with no explicit profile defaulted to the sessionId, i.e. a throwaway+// data dir per window: every new window was signed out of everything and app localStorage+// (e.g. Orbital Lab's saved 3D prefs) vanished. Now the default is ONE durable profile that+// every window shares, so logins/cookies/localStorage ACCUMULATE and are shared across all+// pup windows (the wiki already proved this jar-sharing model with adom-wiki-authed). Each+// window still gets its OWN OS window + taskbar identity — sharing a profile does NOT collapse+// windows into tabs (measured: two sessions on one profile = two windows, two taskbar buttons,+// one Chrome process). Step 1 (reparent so Chrome survives a bridge restart) is what makes a+// durable profile safe: without it, repeated dirty kills would rot the shared jar.+const DEFAULT_PROFILE = 'adom-you';+// v1.8.99: the SSO session covers the whole Adom ecosystem, not just the wiki. Gate the authed+// treatment on ANY adom.inc host (wiki + other Adom properties). NON-Adom sites (ti.com,+// digikey.com, even adom.cloud proxies) get NOTHING — no auth routing, hints, or glyph.+function isAdomUrl(u) { try { const h = new URL(u).hostname; return h === 'adom.inc' || /\.adom\.inc$/i.test(h); } catch (e) { return false; } }+// v2.0.132 (adom/wiki#126, Colby's server-side anonymous render — pup's own ask, now adopted):+// `?view=public` on any wiki page GET makes the SERVER render that one request exactly as a+// signed-out visitor sees it (the request's user is nulled; the real session cookie is untouched;+// verified byte-identical to a no-cookie request, and an owner's private page 404s under it). So+// the public view no longer needs an isolated anonymous profile jar at all — it is a URL rewrite.+function wikiPublicUrl(u) { try { const x = new URL(u); x.searchParams.set('view', 'public'); return x.toString(); } catch (e) { return u; } }+function wikiStripPublicParam(u) { try { const x = new URL(u); if (x.searchParams.get('view') === 'public') { x.searchParams.delete('view'); } return x.toString(); } catch (e) { return u; } }+// Ask the page (using ITS cookies) whether the wiki session is authenticated.+// v1.9.93 (John: "the jump list still blips out") — an INDETERMINATE auth probe must never be+// reported as "logged out". The old version collapsed three different outcomes into {authed:false}:+// a real logged-out answer, a non-ok HTTP response, and a THROWN probe (page mid-navigation, frame+// detached, offline). That made the auth state FLIP-FLOP (authed -> transient failure reads as+// logged-out -> authed again), and each flip re-stamps the window identity, which mints a NEW AUMID,+// which DESTROYS AND RECREATES the taskbar button (documented Win11 behavior). A button destroyed+// under an open right-click flyout is exactly the "menu shows ~300ms then disappears, second click+// works" bug. So: report `unknown:true` when we could not determine, and let callers ignore it.+async function checkWikiAuth(page) {+  try {+    return await page.evaluate(async () => {+      try {+        const r = await fetch('/auth/me', { credentials: 'same-origin' });+        if (!r.ok) return (r.status === 401 || r.status === 403) ? { authed: false } : { authed: false, unknown: true };+        const j = await r.json();+        return { authed: !!(j && j.user), user: j && j.user && (j.user.displayName || j.user.username) };+      }+      catch (e) { return { authed: false, unknown: true }; }   // probe failed — NOT evidence of logout+    });+  } catch (e) { return { authed: false, unknown: true }; }     // evaluate threw — NOT evidence of logout+}+// v1.9.88 — AUTO-SIGN the wiki from AD's stored identity, so pup NEVER makes the user hand-type a+// login into its no-memory browser. John, verbatim: "the ai calling you knows exactly who i am and+// ad knows who i am. so that's 2 potential places to get the info from and then you're asking me to+// login from scratch." Right. AD writes the user's wiki session token to ~/.adom/session.json on+// THIS desktop (the same box the bridge runs on), so pup mints a single-use browser-session magic+// link SERVER-SIDE from that token — the same endpoint an already-authed page uses, but authorized+// by the bearer token instead of a page cookie (verified: POST returns {url, expires_in} for a+// bearer with no browser) — and navigates the vault window straight to it. The human is asked for+// nothing; the shared vault profile then persists the session ~30 days. Manual /login is the+// fallback ONLY when AD itself is signed out (no token) or the session expired.+function readDesktopWikiToken() {+  try {+    const j = JSON.parse(fs.readFileSync(path.join(ADOM_STATE_DIR, 'session.json'), 'utf8'));+    if (!j || !j.session_token) return null;+    const exp = j.expires_at ? Date.parse(j.expires_at) : NaN;+    if (Number.isFinite(exp) && exp < Date.now()) return null;   // expired — force the manual path+    return { token: j.session_token, user: j.user_name || j.user_email || null };+  } catch (e) { return null; }+}+async function mintWikiMagicUrl(nextPath) {+  const tok = readDesktopWikiToken();+  if (!tok) return null;+  try {+    const r = await fetch('https://wiki.adom.inc/api/v1/auth/browser-session', {+      method: 'POST',+      headers: { 'content-type': 'application/json', 'authorization': 'Bearer ' + tok.token, 'user-agent': 'adom-pup-bridge' },+      body: JSON.stringify({ next: nextPath || '/' }),+    });+    if (!r.ok) return null;+    const j = await r.json();+    if (!j || !j.url) return null;+    // The endpoint returns ?ticket=...; the deep-link is appended client-side (same as the in-page+    // mint above + the adom-wiki CLI), else the redeem lands on the wiki ROOT and the user loses the+    // page they were on.+    const sep = j.url.includes('?') ? '&' : '?';+    return j.url + sep + 'next=' + encodeURIComponent(nextPath || '/');+  } catch (e) { return null; }+}+// Sign the given pup page into the wiki vault IN PLACE from the desktop token. Returns the user+// label on success (mint + navigate succeeded), or null to fall back to a manual login. A successful+// mint means the token was accepted (a bad token yields no url), so navigation is the real signal;+// the auth re-check is only to surface the live display name and never gates the result.+async function autoSignWikiFromDesktop(page, nextPath) {+  const magic = await mintWikiMagicUrl(nextPath);+  if (!magic) return null;+  try {+    await page.goto(magic, { waitUntil: 'domcontentloaded', timeout: 25000 });+  } catch (e) { return null; }+  let who = null;+  for (let i = 0; i < 6; i++) {+    let a = null; try { a = await checkWikiAuth(page); } catch (e) {}+    if (a && a.authed) { who = a.user; break; }+    await new Promise(r => setTimeout(r, 1000));+  }+  const tk = readDesktopWikiToken();+  return who || (tk && tk.user) || 'authed';+}+// v1.8.97: gentle IN-TAB mode indicator (John: "indicate which mode that pup window is in ...+// in the chrome ui in a gentle way"). Can't touch the native tab-strip empty space (browser UI,+// not reachable), but the TAB TITLE is: a single leading monochrome glyph shows on the tab.+// U+25CF solid = logged-in wiki view, U+25CB hollow = public/anonymous (geometric text glyphs,+// NOT emoji, per the brand icon law). Idempotent + MutationObserver so the SPA can't wipe+// it, and it doesn't fight the session-suffix injector (that touches the END, this the START).+// v1.9.1: WORD label, not a cryptic emoji (John saw a bare 📖 and had no idea what it meant).+// The tab title leads with "🔓 Logged in · " or "📖 Public · " so the mode is unmistakable.+// v1.9.103 (John: "show that info above the preview") — UNIFIED TITLE DECORATION, one observer.+// The text Windows draws above the taskbar hover-preview IS the window title, and Chrome derives that+// from document.title, so the ONLY way to get the owning AI thread up there is to decorate the title.+// There were already TWO title mutators (this wiki glyph prefixing the START, and the session-suffix+// injector appending the END) and the code comments record why a THIRD is dangerous: two observers+// writing the same region ping-pong forever, peg the renderer and flood CDP (that HUNG pup_open_tab+// on Edge). So instead of adding one, the prefix is now a SINGLE composed slot: an AI-thread tag and the+// wiki label are both written by one applier under one observer, in a fixed order, and stripping only+// ever removes decorations we know we wrote (never a page title that merely contains " · ").+// Result: "adom-tsci · Example Domain (session: x)" — the thread is the FIRST thing visible, so it+// survives the ~25-30 char truncation in the preview header, which is the whole point.+function _titleDecorFn(state) {+  try {+    window.__pupThreadTag = state.threadTag === null ? window.__pupThreadTag : state.threadTag;+    window.__pupWikiLabel = state.wikiLabel === null ? window.__pupWikiLabel : state.wikiLabel;+    const strip = (t) => {+      let out = t;+      const tag = window.__pupThreadTag;+      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++) {}+      return out;+    };+    const apply = () => {+      try {+        const parts = [];+        if (window.__pupThreadTag) parts.push(window.__pupThreadTag);+        if (window.__pupWikiLabel) parts.push(window.__pupWikiLabel);+        const decor = parts.length ? parts.join(' \u00b7 ') + ' \u00b7 ' : '';+        const base = strip(document.title || '');+        const want = decor + base;+        if ((document.title || '') !== want) document.title = want;+      } catch (e) {}+    };+    window.__pupApplyTitleDecor = apply;+    apply();+    const t = document.querySelector('title') || document.head || document.documentElement;+    if (t && !window.__pupDecorObs) {+      try { window.__pupDecorObs = new MutationObserver(apply); window.__pupDecorObs.observe(t, { childList: true, subtree: true, characterData: true }); } catch (e) {}+    }+  } catch (e) {}+}+// Set/refresh the decoration. Pass null for a slot you are not changing.+async function setTitleDecor(page, { threadTag = null, wikiLabel = null } = {}) {+  if (pupSettings().titlePrefix !== 'on') return;   // v1.9.286 (John): the "<thread> · " prefix is opt-in+  const st = { threadTag, wikiLabel };+  try { await page.evaluateOnNewDocument(_titleDecorFn, st); } catch (e) {}+  try { await page.evaluate(_titleDecorFn, st); } catch (e) {}+}+// Back-compat shim: every existing caller passes a wiki label and expects the old behaviour.+async function setWikiModeGlyph(page, label) {+  return setTitleDecor(page, { wikiLabel: label });+}++// v1.9.175: `rawView` is args.wikiView VERBATIM ('authed' | 'public' | undefined) so this can tell+// the three genuinely-different cases apart. The old signature collapsed omit and explicit-public+// into one 'public' and told the caller "you're logged out, this is what the world sees" for BOTH —+// but OMITTING wikiView uses the shared adom-you profile, which is LOGGED IN (the one-shared-profile+// model). Only an explicit wikiView:"public" forces an isolated anonymous jar. An agent verifying+// "did private source leak to the public?" that trusted the old hint would inspect the user's+// LOGGED-IN view and wrongly report it world-visible.+function wikiViewHint(url, rawView, auth) {+  if (!isAdomUrl(url)) return '';+  if (rawView === 'authed') {+    if (auth && auth.authed) return ` 🔓 Wiki opened LOGGED IN BY DEFAULT (as ${auth.user || 'the user'}) — pup auto-signed you in from the desktop token, no prompt. You see private source, drafts, owner-only cards, reply/edit. To instead see the ANONYMOUS/PUBLIC view (exactly what the world sees — e.g. verify a publish didn't leak private source), reopen with noAutoLogin:true (or wikiView:"public") — the server then renders it exactly as a signed-out visitor (?view=public, adom/wiki#126), same window, session untouched.`;+    return ` 🔑 Wiki authed view requested, but NO ONE has done the one-time login yet (or the ~30-day session expired) — so this window is currently logged-out. A thread can't self-login (it's the user's SSO). Offer it: pup_raise_os_window {foregroundReason:"sign into the Adom wiki"} → user does the hydrogen.adom.inc SSO ONCE → then EVERY thread's wikiView:"authed" is silently logged in for ~30 days (shared vault profile). Tell the user it's a one-time login that unlocks the logged-in view everywhere.`;+  }+  if (rawView === 'public') {+    // EXPLICIT public — the server renders the request anonymously via ?view=public+    // (adom/wiki#126): the one true "what the world sees" view, no isolated jar needed.+    return ` (📖 Wiki PUBLIC view — the SERVER rendered this page exactly as a signed-out visitor sees it (?view=public, adom/wiki#126: request user nulled, byte-identical to logged-out, private pages 404). The window stays on the shared profile with the session untouched. Use this to verify a publish didn't leak private source. For the user's logged-in view, use wikiView:"authed".)`;+  }+  // OMITTED wikiView — the shared adom-you profile. LOGGED IN if the user has signed into the wiki+  // (the default, since the wiki login lives on the shared profile now), logged out otherwise. This+  // is NOT guaranteed anonymous — do not treat it as "the public view".+  return ` (📖 Wiki on the SHARED profile — LOGGED IN as the user if they've signed into the wiki (the usual case, one shared login serves every window), logged out only if they never have. This is NOT the guaranteed-anonymous view: to see exactly what the world sees (e.g. confirm a publish didn't leak private source), pass wikiView:"public" (server-rendered anonymous view via ?view=public — same window, session untouched). For the explicit logged-in view, pass wikiView:"authed".)`;+}++const _appOverlayCache = new Map(); // sessionId -> composited b64+// v1.9.204 (John: "the active tab in that first taskbar icon is this you fucktard" — a tab that+// plainly HAS a favicon, while pup reported "no favicon on the active tab"). ROOT CAUSE: pup picked+// the tab from its OWN bookkeeping (session.activeTabId), which drifts out of sync the moment the user+// switches tabs themselves — the same drift that made the unstick misfire. He asked for "the favicon+// from the chrome tab that's in the foreground", so ASK CHROME: in a given window exactly one tab+// reports document.visibilityState === 'visible', and that IS the active tab.+async function resolveVisibleTab(session) {+  const tabs = (session.tabs || []).filter(t => t && t.page && !t._crashed);+  if (!tabs.length) return null;+  if (tabs.length === 1) return tabs[0];+  for (const t of tabs) {+    try {+      const v = await withTimeout(t.page.evaluate('document.visibilityState'), 1200, 'vis');+      if (v === 'visible') return t;+    } catch (e) {}+  }+  return (session.tabs || []).find(t => t.tabId === session.activeTabId) || tabs[0];+}++// v1.9.203 (John: "in the 3rd taskbar icon you only have the little teal adom favicon and not the+// main favicon from the active tab"). ROOT CAUSE: sharp CANNOT decode .ico, and the compositor only+// built the favicon layer when the source was NOT an .ico — so every site serving favicon.ico silently+// lost its favicon and the badge rendered as the Adom mark alone. Most modern .ico files embed a PNG+// per size, so parse the container, take the LARGEST entry, and hand its PNG bytes to sharp.+// ICO layout: 6-byte header (count at offset 4), then 16-byte directory entries+// (w,h,colors,rsv,planes,bpp,bytesLE@8,offsetLE@12).+function icoLargestPng(buf) {+  try {+    if (!buf || buf.length < 22) return null;+    if (buf.readUInt16LE(0) !== 0 || buf.readUInt16LE(2) !== 1) return null;   // not an ICO+    const n = buf.readUInt16LE(4);+    if (!n) return null;+    let best = null;+    for (let i = 0; i < n; i++) {+      const e = 6 + i * 16;+      if (e + 16 > buf.length) break;+      const w = buf[e] === 0 ? 256 : buf[e];+      const h = buf[e + 1] === 0 ? 256 : buf[e + 1];+      const size = buf.readUInt32LE(e + 8);+      const off = buf.readUInt32LE(e + 12);+      if (off + size > buf.length || size <= 0) continue;+      const area = w * h;+      if (!best || area > best.area) best = { area, off, size };+    }+    if (!best) return null;+    const data = buf.subarray(best.off, best.off + best.size);+    // PNG-in-ICO (the common modern case) — usable directly.+    if (data.length > 8 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) return data;+    return null;   // BMP/DIB-encoded entry — sharp can't read it either; caller falls back+  } catch (e) { return null; }+}++function icoLargestDibRgba(buf) {+  // v2.0.212 (#55 root cause, at last): ti.com and mouser.com ship CLASSIC BMP-in-ICO favicons+  // (32bpp uncompressed DIB entries, zero PNG entries). icoLargestPng only handles PNG-in-ICO, so+  // analyzeIconBytes judged these real, correctly-fetched icons "unreadable" and the badge went+  // generic — the network rails were never the problem. Decode the largest 32bpp DIB to raw RGBA+  // for sharp. Rows are bottom-up BGRA; height in the info header is doubled (XOR + AND mask);+  // an all-zero alpha channel means "use the AND mask", so apply it in that case.+  try {+    if (!buf || buf.length < 22) return null;+    if (buf.readUInt16LE(0) !== 0 || buf.readUInt16LE(2) !== 1) return null;+    const n = buf.readUInt16LE(4);+    let best = null;+    for (let i = 0; i < n; i++) {+      const e = 6 + i * 16;+      if (e + 16 > buf.length) break;+      const w = buf[e] === 0 ? 256 : buf[e];+      const h = buf[e + 1] === 0 ? 256 : buf[e + 1];+      const size = buf.readUInt32LE(e + 8);+      const off = buf.readUInt32LE(e + 12);+      if (off + size > buf.length || size <= 40) continue;+      const d = buf.subarray(off, off + size);+      if (d[0] === 0x89 && d[1] === 0x50) continue;                       // PNG entry — icoLargestPng's job+      if (d.readUInt32LE(0) !== 40) continue;                             // only plain BITMAPINFOHEADER+      if (d.readUInt16LE(14) !== 32 || d.readUInt32LE(16) !== 0) continue; // only 32bpp uncompressed+      if (!best || w * h > best.w * best.h) best = { w, h, d };+    }+    if (!best) return null;+    const { w, h, d } = best;+    const stride = w * 4;+    if (40 + stride * h > d.length) return null;+    const out = Buffer.alloc(w * h * 4);+    let maxA = 0;+    for (let y = 0; y < h; y++) {+      const srow = 40 + (h - 1 - y) * stride, drow = y * stride;+      for (let x = 0; x < w; x++) {+        const si = srow + x * 4, di = drow + x * 4;+        out[di] = d[si + 2]; out[di + 1] = d[si + 1]; out[di + 2] = d[si]; out[di + 3] = d[si + 3];+        if (d[si + 3] > maxA) maxA = d[si + 3];+      }+    }+    if (maxA === 0) {                                                     // pre-alpha ICO: AND mask rules+      const maskStride = Math.ceil(w / 32) * 4, maskOff = 40 + stride * h;+      for (let y = 0; y < h; y++) {+        const mrow = maskOff + (h - 1 - y) * maskStride;+        for (let x = 0; x < w; x++) {+          const bit = mrow + (x >> 3) < d.length ? (d[mrow + (x >> 3)] >> (7 - (x & 7))) & 1 : 0;+          out[y * stride + x * 4 + 3] = bit ? 0 : 255;+        }+      }+    }+    return { rgba: out, w, h };+  } catch (e) { return null; }+}++// v1.9.238 (John: "favicons these days can be svg or png, dark mode or light mode, transparent... you+// seem to not be doing much logic there... determine which favicon to retrieve to have it show the+// best/nicest in the windows taskbar"). A proper resolver that (1) enumerates EVERY declared icon with+// its type/sizes/media, (2) picks the best for a DARK taskbar — a site's dark-mode variant is the art+// designed to sit on dark, so it wins; a light-mode variant is usually dark ink that vanishes on the+// taskbar, so it loses — then SVG > sized PNG > apple-touch > ICO for crispness, (3) fetches with the+// page's cookies (authed icons), (4) rasterizes and MEASURES transparency + luminance so a dark-on-dark+// icon can be detected and skipped for a lighter sibling. Returns the full acquisition STATE so the+// dashboard can show, per tab, exactly how the icon was obtained.+const _tabFavMeta = new Map();   // page-url -> { meta, at }+// the generic tab glyph: a neutral page outline, monochrome per the icon rules+const GENERIC_TAB_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect x="3" y="1.5" width="10" height="13" rx="1.5" fill="none" stroke="#8b949e" stroke-width="1.3"/><path d="M5.5 5h5M5.5 7.5h5M5.5 10h3.5" stroke="#8b949e" stroke-width="1.1" stroke-linecap="round"/></svg>';+const _dashToasts = []; let _dashToastId = 0;   // /ui/toast narration queue, delivered over the SSE+async function enumerateFaviconCandidates(page) {+  try {+    return await withTimeout(page.evaluate(() => {+      const q = 'link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"], link[rel="mask-icon"]';+      const links = Array.from(document.querySelectorAll(q));+      const out = links.map(l => ({+        href: l.href, type: (l.type || '').toLowerCase(), sizes: (l.getAttribute('sizes') || '').toLowerCase(),+        media: (l.media || l.getAttribute('media') || '').toLowerCase(), rel: (l.getAttribute('rel') || '').toLowerCase(),+      })).filter(c => c.href && c.href !== 'data:,' && !/^data:,?$/.test(c.href));+      try { out.push({ href: new URL('/favicon.ico', location.origin).href, type: '', sizes: '', media: '', rel: 'icon', synthetic: true }); } catch (e) {}+      return out;+    }), 2500, 'fav-enum').catch(() => []);+  } catch (e) { return []; }+}+function scoreFaviconCandidate(c) {+  let s = 0;+  const m = c.media || '';+  if (/prefers-color-scheme:\s*dark/.test(m)) s += 100;        // taskbar is dark → its dark-mode art+  else if (/prefers-color-scheme:\s*light/.test(m)) s -= 40;   // light-mode art = dark ink, dies on dark+  const base = (c.href || '').split('?')[0];+  const ext = (base.split('.').pop() || '').toLowerCase();+  const isSvg = c.type.includes('svg') || ext === 'svg';+  const isPng = c.type.includes('png') || ext === 'png';+  if (isSvg) s += 50;                                          // crispest at every size+  else if (c.rel.includes('apple-touch')) s += 35;            // usually a large clean PNG+  else if (isPng) { s += 30; const big = parseInt((c.sizes.match(/(\d+)x/) || [])[1] || '0', 10); s += Math.min(big, 256) / 16; }+  else if (ext === 'ico' || c.type.includes('icon')) s += 10;+  if (c.rel.includes('mask-icon')) s -= 60;                    // monochrome Safari pin, not a real favicon+  if (c.synthetic) s -= 5;+  return s;+}+function classifyMode(media) {+  if (/prefers-color-scheme:\s*dark/.test(media || '')) return 'dark';+  if (/prefers-color-scheme:\s*light/.test(media || '')) return 'light';+  return media ? 'media' : 'default';+}+async function fetchIconBytes(page, href) {+  // in-page (cookies) → bridge fetch → local file, mirroring fetchPageFavicon's ladder.+  if (/^data:/i.test(href)) {+    const m = href.match(/^data:([^;,]*)(;base64)?,(.*)$/);+    if (m && m[3]) { const b64 = m[2] ? m[3] : Buffer.from(decodeURIComponent(m[3])).toString('base64'); if (b64) return { b64, mime: m[1] || 'image/png', via: 'inline' }; }+    return null;+  }+  if (/^file:/i.test(href)) {+    try { const fp = decodeURIComponent(new URL(href).pathname).replace(/^\/([A-Za-z]:)/, '$1'); const buf = fs.readFileSync(fp); if (buf && buf.length) return { b64: buf.toString('base64'), mime: '', via: 'disk' }; } catch (e) {}+    return null;+  }+  try {+    const inPage = await withTimeout(page.evaluate(async (u) => {+      try { const r = await fetch(u, { credentials: 'include', cache: 'force-cache' }); if (!r.ok) return null; const b = await r.blob(); if (!b.size) return null;+        const b64 = await new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(String(fr.result).split(',')[1]); fr.onerror = rej; fr.readAsDataURL(b); });+        return b64 ? { b64, mime: b.type || '' } : null; } catch (e) { return null; }+    }, href), 4000, 'fav-inpage').catch(() => null);+    if (inPage && inPage.b64) return { b64: inPage.b64, mime: inPage.mime || '', via: 'in-page (cookies)' };+  } catch (e) {}+  try {+    const r = await fetch(href, { redirect: 'follow', signal: AbortSignal.timeout(4000) });+    if (r && r.ok) { const buf = Buffer.from(await r.arrayBuffer()); if (buf.length) return { b64: buf.toString('base64'), mime: r.headers.get('content-type') || '', via: 'bridge fetch' }; }+  } catch (e) {}+  return null;+}+async function analyzeIconBytes(b64, mime, href) {+  // Rasterize to PNG and measure transparency + luminance. Returns null if truly undecodable.+  const buf = Buffer.from(b64, 'base64');+  const ext = ((href || '').split('?')[0].split('.').pop() || '').toLowerCase();+  let format = 'png', input = buf, _rawOpts = null;+  if (mime.includes('svg') || ext === 'svg') format = 'svg';+  else if (mime.includes('png') || ext === 'png') format = 'png';+  else if (mime.includes('jpeg') || ext === 'jpg' || ext === 'jpeg') format = 'jpeg';+  else if (mime.includes('icon') || mime.includes('ico') || ext === 'ico') { format = 'ico'; const inner = icoLargestPng(buf); if (inner) input = inner; else { const dib = icoLargestDibRgba(buf); if (dib) { input = dib.rgba; _rawOpts = { raw: { width: dib.w, height: dib.h, channels: 4 } }; } } }+  try {+    // v1.9.240: RASTERIZE at a fixed size FIRST, then measure. An SVG with only a viewBox (no+    // width/height — like our own icon.svg) has no intrinsic size, so sharp.stats() on the raw bytes+    // throws "unsupported image" and the icon was wrongly judged unreadable. A high render density+    // keeps the raster crisp; measuring the PNG (always decodable) makes transparency/luminance robust.+    const S = require('sharp');   // module-scope var is named `sharp`; other fns re-require locally — do the same+    const opts = _rawOpts ? _rawOpts : (format === 'svg' ? { density: 384 } : {});+    const pngBuf = await S(input, opts).resize(48, 48, { fit: 'inside', background: { r: 0, g: 0, b: 0, alpha: 0 } }).png().toBuffer();+    const stats = await S(pngBuf).stats();+    const ch = stats.channels || [];+    const lum = ch.length >= 3 ? (0.299 * ch[0].mean + 0.587 * ch[1].mean + 0.114 * ch[2].mean) : (ch[0] ? ch[0].mean : 128);+    return { pngBuf, b64: pngBuf.toString('base64'), format, transparent: !stats.isOpaque, darkDominant: lum < 80, lum: Math.round(lum) };+  } catch (e) { return { pngBuf: null, b64: null, format, transparent: false, darkDominant: false, lum: null, unreadable: true }; }+}+async function resolveFaviconRich(page) {+  const cands = await enumerateFaviconCandidates(page);+  if (!cands.length) return null;+  const ranked = cands.map(c => ({ ...c, score: scoreFaviconCandidate(c) })).sort((a, b) => b.score - a.score);+  let firstReadable = null;+  for (const c of ranked) {+    const bytes = await fetchIconBytes(page, c.href).catch(() => null);+    if (!bytes || !bytes.b64) continue;+    const an = await analyzeIconBytes(bytes.b64, bytes.mime || c.type, c.href);+    if (!an || an.unreadable || !an.b64) continue;+    const meta = {+      b64: an.b64, mime: 'image/png', href: c.href, format: an.format, mode: classifyMode(c.media),+      transparent: an.transparent, darkDominant: an.darkDominant, luminance: an.lum, sizes: c.sizes || null,+      via: bytes.via, kind: c.synthetic ? 'favicon.ico' : 'page-favicon', candidates: cands.length,+      note: an.darkDominant && an.transparent ? 'dark + transparent (risk of vanishing on the dark taskbar)' : null,+    };+    if (!firstReadable) firstReadable = meta;+    // A dark-on-dark transparent icon vanishes on the dark taskbar — keep scanning for a lighter sibling+    // (typically the dark-mode media variant). If none is better, we still return the first readable one.+    if (an.darkDominant && an.transparent) continue;+    return meta;+  }+  return firstReadable;   // best-effort: readable but possibly dark-on-dark+}+// v2.0.210 (#55 final): fetch a favicon through CHROME's own network stack via CDP+// Network.loadNetworkResource — hostile CDNs fingerprint TLS, so node/curl get blocked where the+// real browser passes. Returns a Buffer or null.+async function fetchFaviconViaChrome(page, url) {+  try {+    const client = typeof page._client === 'function' ? page._client() : page._client;+    if (!client) return null;+    const frameId = page.mainFrame() && page.mainFrame()._id;+    const r = await client.send('Network.loadNetworkResource', { frameId, url, options: { disableCache: false, includeCredentials: true } });+    if (!r || !r.resource || !r.resource.success || !r.resource.stream) return null;+    const parts = [];+    for (;;) {+      const chunk = await client.send('IO.read', { handle: r.resource.stream, size: 65536 });+      parts.push(chunk.base64Encoded ? Buffer.from(chunk.data, 'base64') : Buffer.from(chunk.data, 'utf8'));+      if (chunk.eof) break;+    }+    client.send('IO.close', { handle: r.resource.stream }).catch(() => {});+    const buf = Buffer.concat(parts);+    return buf.length > 100 ? buf : null;+  } catch (e) { console.log(`[overlay] CDP favicon fetch threw: ${e && e.message}`); return null; }+}+async function applyAppOverlay(session, sessionId) {+  // v1.9.191 (John): overlays are the GENTLE branding path and are ALLOWED in minimal-touch.+  // ITaskbarList3::SetOverlayIcon paints a badge onto the EXISTING taskbar button; it never+  // destroys and recreates that button the way an AppUserModelID change does (which is what made+  // the old branding so invasive on SPAs that re-registered constantly). So in minimal mode pup+  // stamps NO identity and rebuilds NO jump list, but it DOES wear one composed badge per window.+  if (process.platform !== 'win32' || _identitySupported !== true) return false;+  if (pupSettings().overlayBadges !== 'on') return false;   // v1.9.259 user pref+  // v2.0.167 (John: "i don't see an overlay icon though, so that's sad"): thread tiles DO wear the+  // favicon overlay badge after all. The 2.0.159 suppression was a misdiagnosis — the "globe covering+  // the tile" was the stale category BUTTON ART (the ab icon-handle cache bug, fixed in 2.0.166), not+  // the overlay. The overlay is a small corner badge; the name stays readable. On a grouped thread+  // button Windows shows the overlay of the most recently updated window in the group — acceptable.+  // v2.0.168 THE LATCH GUARD (the 2.0.167 regression): SetOverlayIcon on a window still wearing the+  // DEFAULT CfT identity latches the shell's button association, and the thread stamp that follows+  // never moves the window to its thread button. So for a thread-eligible window the overlay paints+  // ONLY once the thread appId has landed, plus a settle beat for the shell to finish the move.+  // No 30s-grace fallback here — painting the pre-thread button is exactly the latch that breaks the+  // direct CfT -> thread-tile transition (John's rule).+  // v2.0.178 (John: dashboard button had NO overlay, "shame on you"): the latch guard below must+  // only gate sessions whose DESIRED identity is a thread tile. The dashboard has an owner+  // ("pup-dashboard") but deliberately wears its own Adom.Pup.Dashboard identity, never a thread+  // one — so the old guard blocked its overlay FOREVER. Same _dashInfra carve-out as the stamp.+  if (pupSettings().threadTiles === 'on' && sessionId !== 'pup-dashboard' && session && (session.owner || session._ownerThread)) {+    if (!(session._curAppId || '').startsWith(`${PUP_APP_ID}.thread.`)) return false;+    if (!session._threadStampAt || (Date.now() - session._threadStampAt) < 5000) return false;+  }+  // v1.9.281 THE RESURRECTION GUARD (found by ralph: a reaped session's badge kept reappearing).+  // applyAppOverlay is reached from several deferred setTimeout retries (favicon retry, post-nav,+  // heal) that CAPTURE the session object. If the window closed in the meantime, those late calls+  // recomposed and rewrote the badge PNG for a DEAD session — so the reconciler deleted it and the+  // stale paint immediately recreated it, forever. Refuse to paint a session that is no longer live+  // (dropped from the map, or all its windows closed). This is the choke point that kills the ghost.+  if (!sessions.has(sessionId)) return false;+  // v2.0.25 ORDERING (John watched it live: overlay landed on the still-Chrome button, then the+  // identity stamp RECREATED the button — wiping the badge — then the overlay painted again; three+  // visible mutations for one open). When AUMID identity is on, a YOUNG window's overlay defers+  // until the identity stamp has landed (the stamp marks the fresh button bare, which re-triggers+  // this paint), so the sequence is stamp-once -> overlay-once. After a 30s grace the overlay paints+  // regardless, so a window whose stamp cannot land (unresolvable hwnd) is never left bare forever.+  if (pupSettings().aumidIcons === 'on' && session && !session._curAppId && !_earlyStamped.has(sessionId)+      && session.createdAt && (Date.now() - session.createdAt) < 30000) return false;+  { const _live = sessions.get(sessionId); let _open = false; for (const t of ((_live && _live.tabs) || [])) { try { if (t && t.page && !t.page.isClosed()) { _open = true; break; } } catch (e) {} } if (!_open) return false; }+  try {+    // v1.9.0: reflect the ACTIVE tab (John: favicons are more useful than counts — show what+    // the user is looking at). Falls back to session.page.+    // v1.9.204: the ACTUALLY-visible tab, per Chrome — not pup's possibly-stale activeTabId.+    const activeTab = (await resolveVisibleTab(session)) || (session.tabs || [])[0];+    const activePage = (activeTab && activeTab.page) || session.page;+    if (activeTab && activeTab.tabId && session.activeTabId !== activeTab.tabId) {+      session.activeTabId = activeTab.tabId;   // keep bookkeeping honest for everything downstream+    }+    let src = session._appIconB64 ? { b64: session._appIconB64, mime: session._appIconMime || 'image/png', href: 'app-provided' } : null;+    if (src) session._overlayFav = { kind: 'app-provided', format: 'png', mode: 'default', transparent: null, via: 'pup_open_window appIconB64', href: 'app-provided' };+    if (!src) {+      // v1.9.238: rich, dark-taskbar-aware resolution + full state capture.+      const rich = await resolveFaviconRich(activePage).catch(() => null);+      if (rich && rich.b64) { src = { b64: rich.b64, mime: rich.mime, href: rich.href }; session._overlayFav = rich; try { session._lastRealSrc = { b64: rich.b64, mime: rich.mime, href: rich.href, forUrl: (activePage && activePage.url()) || null }; } catch (e) {} }+    }+    if (!src || !src.b64) {+      // v1.9.43: RETRY once shortly after. The old code gave up silently on the first miss, so a+      // favicon that simply had not loaded yet meant NO overlay for the life of the window. That+      // is why some pup icons had overlays and others did not: it was a race, not a rule.+      if (!session._faviconRetried) {+        session._faviconRetried = true;+        setTimeout(() => { applyAppOverlay(session, sessionId).catch(() => {}); }, 2500);+      }+      // v1.9.71: log the missing-favicon case ONCE per session. It was emitted on every tab-badge+      // update, so a faviconless page produced a line per verb — non-consecutive, so the dedupe+      // collapser could not catch it. The fact is static per page; repeating it says nothing new.+      if (!session._noFavLogged) { session._noFavLogged = true; console.log(`[overlay] "${sessionId}" no favicon on the active tab — trying sibling tabs, then the generic glyph`); }+      // v1.9.205 (John: "why does the first taskbar icon still not have a main overlay icon?"): a+      // badge must ALWAYS have a main image. Bailing out here left the button showing the bare Adom+      // mark (or nothing), which is exactly what he kept seeing. Chrome itself never shows "nothing" —+      // a faviconless page gets its generic globe. So: (a) try SIBLING tabs in this window, since a+      // multi-tab window usually has at least one real icon and that is far more informative than a+      // placeholder; (b) if none has one, draw a generic page/globe glyph. Either way the badge keeps+      // its structure — main image + Adom mark inset in the corner — so every pup button looks alike.+      try {+        for (const t of (session.tabs || [])) {+          if (!t || !t.page || t === activeTab || t._crashed) continue;+          const sib = await resolveFaviconRich(t.page).catch(() => null);+          if (sib && sib.b64) {+            const _sk = `${t.tabId}|${sib.href || ''}`;+            if (session._sibFavLogKey !== _sk) { session._sibFavLogKey = _sk; console.log(`[overlay] "${sessionId}" using sibling tab ${t.tabId}'s favicon (active tab has none)`); }+            src = { b64: sib.b64, mime: sib.mime, href: sib.href };+            session._overlayFav = { ...sib, kind: 'sibling-tab-favicon' };+            break;+          }+        }+      } catch (e) {}+      // #globe-flash (John: "skip the generic-globe intermediate after a re-stamp"): NEVER downgrade a+      // window's REAL favicon to the generic globe. If we couldn't re-resolve it THIS cycle but the page+      // is the SAME one we last painted a real favicon for (the classic case: an icon-gen re-stamp+      // recreated the button and repainted before the favicon was re-fetchable), reuse that last real+      // favicon instead of flashing the globe. Gated on the URL being unchanged, so a genuine navigation+      // to a truly faviconless page still correctly falls through to the generic glyph below.+      if ((!src || !src.b64) && session._lastRealSrc && session._lastRealSrc.b64) {+        let _curUrl = ''; try { _curUrl = (activePage && activePage.url()) || ''; } catch (e) {}+        // v2.0.199 (John's syr-renta/fusio-bri generic globes): key the reuse to the ORIGIN, not the+        // exact URL — same-site navigation kept the same favicon but invalidated the cache, so churny+        // sessions downgraded to the globe and STAYED there. Unreadable current URL (busy page) also+        // reuses: a stale real favicon beats a wrong generic every time.+        const _org = (u) => { try { return new URL(u).origin; } catch (e) { return null; } };+        if (session._lastRealSrc.forUrl && (!_curUrl || _org(session._lastRealSrc.forUrl) === _org(_curUrl))) {+          src = { b64: session._lastRealSrc.b64, mime: session._lastRealSrc.mime, href: session._lastRealSrc.href };+          session._overlayFav = { kind: 'page-favicon', format: 'png', mode: 'default', transparent: null, via: 'reused last real favicon (re-fetch missed this cycle — no downgrade to generic)', href: src.href };+          console.log(`[overlay] "${sessionId}" reused last real favicon (${src.href}) instead of the generic globe — same page, favicon just not re-fetchable this cycle`);+        }+      }+      // v2.0.211 (#55): when the in-page favicon path yields nothing, fetch <origin>/favicon.ico+      // out-of-band. TWO rails, each honestly logged: (1) bridge-side node fetch with a browser UA;+      // (2) Chrome's OWN network stack via CDP Network.loadNetworkResource (survives TLS+      // fingerprinting that kills node). The 2.0.209/210 versions failed SILENTLY here: Akamai's bot+      // wall answers 200 with an HTML challenge page, so the node rail "succeeded" with unreadable+      // bytes, skipped the CDP rail, and fell to the generic glyph without a single log line.+      if (!src || !src.b64) {+        try {+          let _u9 = ''; try { _u9 = (activePage && activePage.url()) || ''; } catch (e) {}+          const _o9 = (() => { try { const u = new URL(_u9); return /^https?:$/.test(u.protocol) && !/^(localhost|127\.)/.test(u.hostname) ? u.origin : null; } catch (e) { return null; } })();+          // v2.0.254: NEGATIVE CACHE per origin. 2.0.229 bounded the SUMMARY line (_bothRailsLogKey)+          // but not the work or the two rail-failure lines beneath it, so every overlay re-assert on a+          // faviconless page re-ran BOTH network fetches (a 5s-timeout node fetch + a CDP fetch) and+          // logged two more lines, forever. Measured live on ConfRoomROG 2026-08-20: three lines per+          // pass filled the entire log tail — the exact "the diagnosis died with it" trap the icon-audit+          // skill warns about, and it degraded a real investigation. One attempt per origin per 30 min.+          if (_o9 && _faviconNegCache.get(_o9) > Date.now() - 30 * 60000) {+            // known faviconless — fall straight through to the generic glyph, silently+          } else if (_o9) {+            const _icoUrl = _o9 + '/favicon.ico';+            const _tryIcon = async (buf, mime, rail) => {+              if (!buf || buf.length <= 100) return false;+              const _an = await analyzeIconBytes(buf.toString('base64'), mime || 'image/x-icon', _icoUrl).catch(() => null);+              if (_an && _an.b64 && !_an.unreadable) {+                src = { b64: _an.b64, mime: 'image/png', href: _icoUrl, darkDominant: _an.darkDominant, transparent: _an.transparent };+                session._overlayFav = { kind: 'page-favicon', format: _an.format, mode: 'default', transparent: _an.transparent, darkDominant: _an.darkDominant, via: rail + ' fetch (in-page favicon path came up empty)', href: _icoUrl };+                try { session._lastRealSrc = { b64: src.b64, mime: src.mime, href: _icoUrl, forUrl: _u9 }; } catch (e) {}+                console.log(`[overlay] "${sessionId}" favicon recovered ${rail} from ${_icoUrl}`);+                return true;+              }+              console.log(`[overlay] "${sessionId}" ${rail} fetch got ${buf.length}B from ${_icoUrl} but it is NOT a readable icon (bot-wall HTML?)`);+              return false;+            };+            let _got = false;+            const _ctl = new AbortController(); const _tm = setTimeout(() => _ctl.abort(), 5000);+            const _resp = await fetch(_icoUrl, { redirect: 'follow', signal: _ctl.signal,+              headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36', 'Accept': 'image/*,*/*;q=0.8' } }).catch(() => null);+            clearTimeout(_tm);+            if (_resp && _resp.ok) {+              _got = await _tryIcon(Buffer.from(await _resp.arrayBuffer()), _resp.headers.get('content-type'), 'bridge-side');+            } else {+              console.log(`[overlay] "${sessionId}" bridge-side favicon fetch: ${_resp ? 'HTTP ' + _resp.status : 'network error/timeout'} for ${_icoUrl}`);+            }+            if (!_got) {+              const _cb = await fetchFaviconViaChrome(activePage, _icoUrl);+              if (_cb) _got = await _tryIcon(_cb, null, 'via-Chrome-CDP');+              else console.log(`[overlay] "${sessionId}" via-Chrome-CDP favicon fetch returned nothing for ${_icoUrl}`);+            }+            if (_got) _faviconNegCache.delete(_o9);+            else {+              _faviconNegRemember(_o9);+              if (session._bothRailsLogKey !== _u9) { session._bothRailsLogKey = _u9; console.log(`[overlay] "${sessionId}" favicon fetch failed on BOTH rails for ${_o9} — generic it is; not retrying this origin for 30 min`); }+            }+          }+        } catch (e) { console.log(`[overlay] "${sessionId}" favicon fallback threw: ${e && e.message}`); }+      }+      if (!src || !src.b64) {+        // Generic glyph: a teal-on-dark globe, same silhouette Chrome falls back to.+        // v1.9.206: the glyph must be BRIGHT and BOLD. My first version used a #123033 plate with+        // 2.2px strokes — nearly the taskbar's own colour, and far too fine to survive the ~16px slot,+        // so the whole glyph vanished and only the teal corner mark read (the exact dark-on-dark+        // mistake made earlier today with the badge plate). Solid bright teal fill, dark heavy strokes.+        try {+          // v1.9.208: raw pixels, NOT SVG (sharp throws on SVG without librsvg — see note below).+          const sharpG = require('sharp');+          // v1.9.213: my own 1.9.210 revert left a deliberate throw HERE, and this path runs BEFORE the+          // 1.9.211 generic-icon code — so every faviconless window died on this line and reported+          // "glyph render failed" instead of getting the Chrome-style globe / PDF icon. Two competing+          // no-favicon paths existed and I only converted the second one. Build the generic icon here+          // too, choosing the PDF sheet for a .pdf tab and the grey globe otherwise.+          let _u2 = '';+          try { _u2 = (activePage && activePage.url()) || ''; } catch (e) {}+          const _isPdf2 = /\.pdf(\?|#|$)/i.test(_u2);+          const _svg2 = genericIconSvg(_isPdf2);+          src = { b64: Buffer.from(_svg2).toString('base64'), mime: 'image/svg+xml', href: _isPdf2 ? 'generic:pdf' : 'generic:page' };+          session._overlayFav = { kind: _isPdf2 ? 'generic-pdf' : 'generic-web', format: 'svg', mode: 'default', transparent: false, via: 'generated (no favicon existed anywhere in this window)', href: src.href, candidates: 0 };+          if (session._noFavLogKey !== _u2) { session._noFavLogKey = _u2; console.log(`[overlay] "${sessionId}" no favicon in this session — generic ${_isPdf2 ? 'PDF' : 'webpage'} icon + Adom mark`); }+          // v2.0.199: generic must not be a terminal state — the page often becomes readable moments+          // later (post-respawn churn). One re-resolve per generic paint, 30s out.+          // v2.0.229 (found while auditing icons: a single example.com window produced an endless+          // 30s loop of two failed fetch rails, and the noise pushed every other line out of the+          // log ring — it destroyed the audit trail I needed). A page that genuinely has no favicon+          // never grows one, so retry a FEW times, then stop until the page actually NAVIGATES.+          const _genUrl = (() => { try { return (activePage && activePage.url()) || ''; } catch (e) { return ''; } })();+          if (session._genericRetryUrl !== _genUrl) { session._genericRetryUrl = _genUrl; session._genericRetries = 0; }+          const GENERIC_MAX_RETRIES = 3;+          if (!session._genericRetryTimer && (session._genericRetries || 0) < GENERIC_MAX_RETRIES) {+            session._genericRetries = (session._genericRetries || 0) + 1;+            const _n = session._genericRetries;+            session._genericRetryTimer = setTimeout(() => {+              session._genericRetryTimer = null;+              try { if (sessions.has(sessionId)) { session._appOverlay = false; session._lastPaintSig = null; applyAppOverlay(session, sessionId).catch(() => {}); } } catch (e) {}+            }, 30000 * _n);   // 30s, 60s, 90s — then quiet until the next navigation+          } else if ((session._genericRetries || 0) >= GENERIC_MAX_RETRIES && !session._genericGaveUpLogged) {+            session._genericGaveUpLogged = true;+            console.log(`[overlay] "${sessionId}" this page has no favicon after ${GENERIC_MAX_RETRIES} tries — keeping the generic glyph and going QUIET until it navigates`);+          }++        } catch (e) {+          session._appOverlay = false;+          session._overlayDesc = 'none (no favicon and glyph render failed)';+          return false;+        }+      }+    }+    session._faviconRetried = false;+    // v1.9.16 (John's idea, and the right one): the favicon KEEPS the overlay slot — we only+    // recolour the PLATE it sits on. Signed in to the Adom wiki -> green plate; everything else+    // -> the normal dark plate. You still see which site you're on AND the wiki login state, and+    // at 16px the plate is a far bigger colour target than any glyph tucked in the base icon.+    // v1.9.19: plate is ALWAYS the neutral dark one again. Login state moved to the CATEGORY ICON+    // (monochrome, per the brand icon law), so tinting this plate would be a second, redundant and+    // off-law colour signal. The favicon keeps this slot untouched, which is the whole point of it.+    const plateAuthed = false;+    const plateFill = '#0b2325';+    // v1.9.214 — THE REASON TEN REVISIONS NEVER APPEARED. This composite is memo-cached, and the key+    // did NOT include the bridge build. So the FIRST image built for a given (session, favicon-href)+    // was cached forever: every later run hit the cache, logged success, returned the OLD bytes, and+    // the taskbar never changed no matter what the compositor code now did. That is exactly the+    // symptom John kept reporting — "the code runs, no errors, and the icon is still wrong" — and it+    // silently invalidated every badge fix from 1.9.203 onward, including the generic globe / PDF+    // icons. Keying on BRIDGE_VERSION makes any new build rebuild its badges.+    // v2.0.262: overlaySize is part of the key — without it, flipping the setting would serve the+    // previously-composited badge from cache and nothing would visibly change.+    const cacheKey = BRIDGE_VERSION + ':' + sessionId + ':' + (src.href || 'explicit') + ':' + (plateAuthed ? 'auth' : 'plain') + ':' + (pupSettings().aumidIcons === 'on' ? 'nomark' : 'mark') + ':sz' + overlayScalePct();+    let overlay = _appOverlayCache.get(cacheKey);+    if (!overlay) {+      let sharpMod = null;+      try { sharpMod = require('sharp'); } catch (e) { sharpMod = null; }+      const isIco = /x-icon|vnd\.microsoft\.icon/.test(src.mime) || (src.href || '').endsWith('.ico');+      if (isIco && !sharpMod) {+        overlay = src.b64; // sharp unavailable AND .ico — pass through (no compositing possible)+      } else if (!sharpMod) {+        overlay = src.b64;+      } else {+        try {+          // .ico can't be decoded by sharp — when we NEED the green plate, fall back to drawing+          // the plate alone rather than losing the signal entirely.+          let iconPng = null;+          // v1.9.326 (John: "your icons are STILL wrong in the taskbar"). ROOT CAUSE of the bare Adom+          // mark on Google Docs (and any site whose favicon URL ends in .ico): decide the container by+          // the actual BYTES, not by the href. analyzeIconBytes has ALREADY re-encoded a resolved+          // favicon to PNG, so a source whose URL merely ends in ".ico" (docs-favicon-2026-v2.ico)+          // arrives here as PNG — the old href-based isIco ran icoLargestPng on PNG bytes, got null,+          // dropped the favicon, and left the mark alone. Sniff the real magic bytes instead.+          {+            const _buf = Buffer.from(src.b64, 'base64');+            const _isIcoBytes = _buf.length >= 4 && _buf[0] === 0 && _buf[1] === 0 && _buf[2] === 1 && _buf[3] === 0;+            try {+              if (_isIcoBytes) {+                const inner = icoLargestPng(_buf);+                if (inner) iconPng = await sharpMod(inner).resize(48, 48, { fit: 'cover' }).png().toBuffer();+              } else {+                iconPng = await sharpMod(_buf).resize(48, 48, { fit: 'cover' }).png().toBuffer();+              }+            } catch (e) { iconPng = null; }+          }+          // v1.9.207 — THE ACTUAL HOLE behind John's bare first button, found by tracing the decode+          // path instead of guessing at pixels again. The glyph fallback added in 1.9.205 only covers+          // "no favicon FOUND at all". But when a favicon IS found and sharp cannot DECODE it (a+          // BMP-encoded .ico, which icoLargestPng deliberately returns null for; a malformed or+          // exotic SVG), iconPng stays null while src is truthy — so that fallback never ran, no+          // favicon layer was pushed, and the composite ended up as the Adom mark ALONE. That is+          // precisely the "no main overlay icon" he kept pointing at, through five revisions.+          // Any undecodable favicon now falls back to the glyph, so a badge can NEVER be mark-only.+          if (!iconPng) {+            // v1.9.208 — NEVER DEPEND ON SVG. John kept seeing "only the Adom mark", and the reason my+            // fallbacks never appeared is that they were all SVG: sharp needs librsvg to rasterise SVG+            // and silently THROWS without it. The Adom mark renders because it is a PNG. That also+            // explains the earlier "invisible plate". So the fallback is now built from a raw pixel+            // buffer (sharp `create`, no SVG) plus the mark PNG — both guaranteed to decode. A window+            // with no usable favicon gets a solid Adom-teal tile as its MAIN image, so the badge always+            // has one and never renders as a lone corner mark.+            // v1.9.211 (John): mirror what CHROME does for a faviconless page — a GENERIC GLOBE — and+            // use a distinct GENERIC PDF icon for PDFs, "since that's common in our world cuz of pdf+            // datasheets". Both still take the mini Adom mark inset, so the badge keeps one structure.+            // (Correcting my own earlier claim: sharp DOES rasterise SVG in this build — the wiki's+            // purple badge IS an SVG favicon — so these are drawn as SVG rather than raw pixels.)+            try {+              let u = '';+              try { u = (activePage && activePage.url()) || ''; } catch (e) {}+              const isPdf = /\.pdf(\?|#|$)/i.test(u) || /application\/pdf/i.test(String(src && src.mime || ''));+              const svg = genericIconSvg(isPdf);+              // v1.9.326: render the glyph straight INTO iconPng. The composite below only draws+              // iconPng, so the old code (which set `src` and left iconPng null) fell through to a+              // mark-only badge — exactly the "no main overlay icon" John kept reporting. sharp+              // rasterises SVG in this build (the wiki's purple badge is an SVG favicon).+              iconPng = await sharpMod(Buffer.from(svg)).resize(48, 48, { fit: 'cover' }).png().toBuffer();+              // Mark the source generic so _overlayIsGeneric (derived from src.href) is honest and the+              // dashboard LED shows AMBER, not a lying green, when we fell back to the glyph.+              src.href = isPdf ? 'generic:pdf' : 'generic:page';+              session._overlayFav = { kind: isPdf ? 'generic-pdf' : 'generic-web', format: 'svg', mode: 'default', transparent: false, via: 'generated (favicon undecodable / none declared)', href: isPdf ? 'generic:pdf' : 'generic:page', candidates: 0 };+              console.log(`[overlay] "${sessionId}" favicon undecodable/absent — generic ${isPdf ? 'PDF' : 'webpage'} icon (Chrome-style) + Adom mark`);+            } catch (e) {+              console.log(`[overlay] "${sessionId}" generic icon build FAILED: ${e.message}`);+              session._appOverlay = false;+              session._overlayDesc = 'none (no favicon; generic icon failed)';+              return false;+            }+          }+          // v1.9.191 (John's design): a TRULY BRANDED badge = the page's own favicon as the main+          // image, with the Adom mark tucked into the LOWER-RIGHT corner, so one glance says both+          // "this is that site" and "this is Adom driving it". Layout on the 32x32 badge (it renders+          // ~16px on the taskbar, so the corner mark gets a dark disc behind it to stay legible):+          //   plate  32x32 rounded dark+          //   favicon 23x23 at (1,1)      — dominant, upper-left biased+          //   disc    15x15 at (16,16)    — dark backing so the mark separates from busy favicons+          //   mark    12x12 at (17,17)    — the Adom leaves+          // v1.9.193 (John: "don't do a white background, that's ugly — you are just insetting the+          // adom favicon into the other favicon"). NO PLATE. The badge IS the page's own favicon at+          // full bleed, with the Adom mark inset into its lower-right corner. The favicon keeps the+          // exact look it was designed with (no tinted card behind it), and the corner mark carries+          // the branding. The mark sits on a small dark disc purely so it stays readable over busy+          // or light favicon artwork at ~16px.+          // v1.9.203 (John: "these overlay icons are a disaster"): composite at 48px, not 32px — the+          // same size the WORKING adom-browser-extension badge builder uses. The badge renders in a+          // ~16px taskbar slot, so compositing small and letting Windows shrink it turned both images+          // to mush. Build big, downscale once, and size the Adom mark as a fixed PROPORTION (3/8 of+          // the badge) so it is identical on every window instead of varying with each favicon's+          // internal padding.+          const BSZ = 48, MSZ = 18;   // 18/48 = 3/8+          let img = sharpMod({ create: { width: BSZ, height: BSZ, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } });+          const layers = [];+          // v1.9.348 (John): the overlay carries its OWN background now that the base tile has no notch.+          // Site favicons are frequently transparent, and painted straight onto the taskbar tile they+          // looked terrible (floating fragments over whatever was behind). Put a solid, dark, teal-edged+          // rounded CARD behind the favicon so every badge reads as one clean unit and transparent+          // favicons get a proper, consistent backdrop. This is where the "notch background" belongs.+          // v1.9.349 (John): NO teal border — the 1px border ate space and forced the favicon too small.+          // Just a full-bleed dark card the tone of the Windows taskbar behind the favicon. The favicon+          // is inset only 2px so it stays as LARGE as possible (44/48) while leaving a thin dark margin+          // the eye can see, so the badge still reads as a distinct unit on the tile.+          // v2.0.191 (John: "be smart about dark favicons — the GitHub badge comes out invisible").+          // analyzeIconBytes already measured the chosen favicon: a DARK-dominant glyph on+          // TRANSPARENCY vanishes on the dark card, so give exactly that class a LIGHT card+          // (Chrome's own generic-plate grey). Opaque favicons bring their own background and+          // light/colorful glyphs read fine on dark, so everything else keeps the dark card.+          const _lightCard = !!(src.darkDominant && src.transparent);+          const _cardFill = _lightCard ? '#f1f3f4' : '#202124';+          if (_lightCard) console.log(`[overlay] "${sessionId}" dark transparent favicon (${(src.href || '').slice(0, 60)}) — using LIGHT card for contrast`);+          const _bg = `<svg xmlns="http://www.w3.org/2000/svg" width="${BSZ}" height="${BSZ}"><rect x="0" y="0" width="${BSZ}" height="${BSZ}" rx="6" ry="6" fill="${_cardFill}"/></svg>`;+          layers.push({ input: await sharpMod(Buffer.from(_bg)).png().toBuffer(), gravity: 'northwest' });+          const FIN = 2;   // 2px dark margin around the favicon+          // Use the ALREADY-DECODED bytes — re-decoding src.b64 here is what broke .ico (sharp throws+          // on the raw container), silently dropping the favicon and leaving the Adom mark alone.+          if (iconPng) layers.push({ input: await sharpMod(iconPng).resize(BSZ - FIN * 2, BSZ - FIN * 2, { fit: 'cover' }).png().toBuffer(), top: FIN, left: FIN });+          // v1.9.331 (John: "why put an EXTRA adom mark on the overlay when AUMID is ON? the teal+          // category tile is already the 'this is Adom pup' indicator"). Right. The Adom mark on the+          // favicon exists ONLY to brand the badge when the BASE icon is plain Chrome (AUMID off). When+          // AUMID is on, the base IS the teal Adom category tile, so a second mark is redundant — the+          // overlay is then the CLEAN site favicon, nothing else.+          // v2.0.192 (John: "why put ANOTHER Adom logo on the overlay? the pup taskbar icon already+          // says this is Adom control — an insanely small extra mark makes no sense"). The corner+          // Adom mark is GONE from the badge in every mode. The badge is the site favicon on its+          // contrast card, nothing else; Adom identity lives in the base tile alone.+          if (layers.length) img = img.composite(layers);+          let _built = await img.png().toBuffer();+          // v2.0.262 (John): HALF-SIZE BADGE, and it is the default. At full size the badge covers the+          // thread tile's BOTTOM LINE of text — which is the whole point of thread tiles, since that+          // text is how you tell one thread's windows from another's ("the text is super valuable so i+          // want to be able to see it"). Windows gives us no way to ask the shell for a smaller overlay+          // slot, so we shrink the CONTENT inside the same canvas: composite the finished badge at half+          // scale into a transparent 48x48 and let the shell scale the whole thing as before. Net effect+          // on glass is a badge half the size, tucked in the corner, with the tile text clear.+          // Anchored NORTHEAST: the tile draws its name from the BOTTOM-LEFT, so the far corner from+          // the text is where the badge does the least damage.+          const _pct = overlayScalePct();+          if (_pct < 100) {+            const _h = Math.max(8, Math.round(BSZ * _pct / 100));+            const _small = await sharpMod(_built).resize(_h, _h, { fit: 'cover' }).png().toBuffer();+            _built = await sharpMod({ create: { width: BSZ, height: BSZ, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } } })+              .composite([{ input: _small, gravity: 'northeast' }]).png().toBuffer();+          }+          overlay = _built.toString('base64');+        } catch (e) { return false; }+      }+      _appOverlayCache.set(cacheKey, overlay);+    }+    const tabs = (session.tabs || []).length;+    const nm = session._appName ? `${session._appName} — ` : '';+    // v1.9.212: address the button BY HWND when known — the title tag is unavailable on PDF windows,+    // which is why those buttons never got painted no matter what the compositor produced.+    if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, sessionId); } catch (e) {} }+    // v1.9.278 THE EDGE GUARD: paint ONLY to an hwnd proven to be a pup browser window. If we cannot+    // prove ownership (recycled/dead handle), REFUSE - never fall back to a bare title match that+    // could hit another app. A missed overlay this cycle self-heals next sweep; painting on Edge does not.+    if (!session._hwnd || !(await hwndBelongsToPup(session._hwnd, session))) {+      session._hwnd = null; session._appOverlay = false;+      console.log(`[overlay] "${sessionId}" REFUSED to paint — no pup-owned hwnd confirmed (protecting non-pup taskbar buttons like Edge)`);+      return false;+    }+    const _tooltip = `Adom Pup — ${nm}${plateAuthed ? 'Adom wiki: SIGNED IN · ' : ''}${tabs} tab${tabs === 1 ? '' : 's'}${tabs > 1 ? ' (icon = active tab)' : ''}`;+    // DEDUP THE PAINT (John: "why are you sending overlay icons non-stop? every second? that's insane").+    // desktop_taskbar is a PowerShell round-trip on the user's desktop; re-sending an IDENTICAL overlay+    // is pure churn that spams AD's activity log and needlessly re-hits the taskbar. applyAppOverlay is+    // RE-RUN constantly and legitimately — the 4s enforcer sweep re-checks bare/generic windows, and a+    // framenavigated fires on every SPA route/redirect — but if the composed icon + tooltip + target are+    // unchanged since the last SUCCESSFUL paint, there is nothing to send. The signature includes the+    // appId so a base-tile RE-STAMP (which recreates the button and drops its overlay) still forces a+    // repaint, and the hwnd so a re-resolved handle repaints. Same rule as jump-list dedup: one commit+    // per real change, never a heartbeat.+    const _paintSig = `${session._hwnd}|${session._curAppId || ''}|${_tooltip}|${overlay}`;+    if (session._lastPaintSig === _paintSig) {+      session._appOverlay = true; // identical overlay already on the button — skip the AD round-trip+      return true;+    }+    // v1.9.215 DIAGNOSTIC (now AFTER the dedup so it only fires on a REAL paint, not every re-run):+    // dump the EXACT bytes we are about to hand AD so the composite can be inspected directly. If the+    // PNG contains the generic icon, the compositor is exonerated and the fault is delivery.+    try {+      const _dbgDir = path.join(require('os').tmpdir(), 'pup-badge-debug');+      fs.mkdirSync(_dbgDir, { recursive: true });+      const _dbgPath = path.join(_dbgDir, `${sessionId}.png`);+      const _dbgBuf = Buffer.from(overlay, 'base64');+      fs.writeFileSync(_dbgPath, _dbgBuf);+      session._overlayBytesAt = Date.now();   // v2.0.230: lets the dashboard stop stat-ing per push+      // v2.0.230: this fired on EVERY paint, including the periodic re-asserts, and was a top-3+      // source of log volume. The interesting event is a CHANGE of source, so collapse repeats.+      const _dbgSig = `${(src && src.href) || '?'}|${_dbgBuf.length}`;+      if (session._dbgLogSig !== _dbgSig) {+        session._dbgLogSig = _dbgSig;+        console.log(`[overlay-debug] "${sessionId}" PAINT ${_dbgBuf.length}B -> ${_dbgPath} | src=${(src && src.href) || '?'} | hwnd=${session._hwnd || '(none, using title)'}`);+      }+    } catch (e) { console.log(`[overlay-debug] dump failed: ${e.message}`); }+    await chrome.abCommand('desktop_taskbar', {+      hwnd: session._hwnd,+      overlay: { icon: overlay, tooltip: _tooltip },+    }, { timeoutMs: 5000 });+    session._lastPaintSig = _paintSig;+    session._appOverlay = true;+    session._overlayReassertAt = Date.now();   // v2.0.47: fresh paint restarts the re-assert clock+    // v1.9.150 (John: 'prove what overlay icon you're showing and why'): record the WHAT and WHY+    // so list_windows can report it and test tables can prove it.+    session._overlayWhy = {+      tabId: (activeTab && activeTab.tabId) || null,+      tabPickedBy: 'visibilityState==="visible" (asked Chrome, not pup bookkeeping)',+      source: (src && src.href) || null,+      fetchedVia: (src && /^generic:/.test(src.href || '')) ? 'generic fallback (no usable favicon)'+                : (src && src.href === 'data:inline') ? 'inline data: URI in the page'+                : 'page fetch with cookies, else bridge fetch',+      composed: 'favicon 48x48 cover + Adom mark 18x18 flush lower-right',+      paintedBy: session._hwnd ? ('hwnd ' + session._hwnd) : 'title match "(session: ' + sessionId + '" (NO hwnd — fragile)',+      at: Date.now(),+    };+    try { session._overlayDesc = 'favicon+adom:' + (src && src.href && src.href !== 'data:inline' ? new URL(src.href).hostname : 'inline-icon'); } catch (e) { session._overlayDesc = 'favicon+adom'; }+    // v1.9.237 (John: "why are the overlay icons the standard adom favicon when you have an entire set+    // of rules on picking the favicon from your first or foreground tab and you seem to not be doing+    // that"). A GENERIC fallback (favicon fetch failed/timed out, e.g. during a post-restart paint) is+    // NOT a finished state - the page usually HAS a favicon that becomes fetchable a moment later. Flag+    // it so the health-sweep enforcement re-attempts and UPGRADES the badge to the real favicon. Only a+    // page that genuinely has no favicon anywhere stays generic (correct, e.g. a bare checkout page).+    session._overlayIsGeneric = !!(src && /^generic:/.test(src.href || ''));+    session._overlayGenericAt = session._overlayIsGeneric ? Date.now() : 0;+    return true;+  } catch (e) { return false; }+}+function appIconReprimand(url, hadFavicon) {+  if (hadFavicon) return '';+  const appish = /localhost|127\.0\.0\.1|\.adom\.cloud\/proxy\//i.test(String(url || ''));+  if (!appish) return '';+  return ' 🎨 APP ICON MISSING: this looks like an Adom app, but the page serves NO favicon, so its pup window cannot wear the app\'s icon (and its webview tab shows a blank icon too). The adom-ui-design standard requires every app to serve a favicon (<link rel="icon">, monochrome WHITE mark, clean 24x24 SVG — see the adom-ui-design + app-creator skills). Add one and reopen, or pass appIconB64/appIconUrl on pup_open_window meanwhile.';+}++// v1.9.9: on-screen CAPTION for the taskbar wiki toggle (John's ask: "why don't you show an AD+// screen caption saying 'switching to logged in view of wiki for [email protected]'"). The switch+// relaunches the browser under the other cookie jar and takes several seconds, so WITHOUT this a+// click felt like nothing happened at all. Same `id` on every call so the start caption is+// REPLACED by the result caption instead of stacking. Best-effort; never throws.++// ─── v2.0.50 ANNOTATION (plan: docs/PLAN-interactive-surface.md). Human draws on the page;+// captures are TIGHT-CROPPED to the drawn strokes (+margin — "if the user wants the whole view,+// they'll draw a circle around it"), QUICK MODE captures on every mouse-up, and each capture is+// copied to the Windows clipboard as image + text/plain JSON sidecar so one paste into any AI+// chat carries both. A preview toast shows the exact crop so the user can verify the grab.+// Injected via CDP (CSP-immune), zero external resources; no backticks/interpolation inside the+// overlay string (it lives in a template literal).+const ANNOTATE_OVERLAY_JS = `window.__pupStartAnnotate = function (initTool, initMode) {+  if (window.__pupAnnotate) {+    // v2.0.372: a re-arm with an explicit mode SWITCHES the live overlay's mode (Alt+Shift+M on+    // an already-open toolbar). v2.0.81 rule preserved: a re-arm after emit-hide stays INVISIBLE.+    if (initMode && window.__pupAnnotate.setMode) { try { window.__pupAnnotate.setMode(initMode); } catch (e) {} }+    if (!window.__pupAnnotateHidden) window.__pupAnnotate.show();+    return 'shown';+  }+  initTool = initTool || 'pen';+  // v2.0.370 (John): two explicit capture modes. quick=true (Auto, the default) captures the+  // moment the mouse lifts — the one-red-circle case. quick=false (Multi) accumulates shapes —+  // a red circle here, a blue circle there — and captures ONCE on Done.+  var S = { tool: 'pen', color: '#f85149', strokes: [], cur: null, quick: initMode !== 'multi', seq: 0 };+  var wrap = document.createElement('div');+  wrap.id = '__pup_annotate';+  wrap.style.cssText = 'position:fixed;inset:0;z-index:2147483646;pointer-events:none;';+  var cv = document.createElement('canvas');+  cv.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:auto;touch-action:none;';+  wrap.appendChild(cv);+  var bar = document.createElement('div');+  bar.style.cssText = 'position:fixed;top:14px;left:50%;transform:translateX(-50%);display:flex;gap:6px;align-items:center;background:#161b22f2;border:1px solid #30363d;border-radius:999px;padding:7px 12px;pointer-events:auto;font:13px Satoshi,system-ui,sans-serif;color:#c9d1d9;box-shadow:0 6px 24px #000a;';+  wrap.appendChild(bar);+  // drag grip (floating-windows: draggable by a visible grip, clamped inside the viewport)+  var grip = document.createElement('div');+  grip.title = '';+  grip.style.cssText = 'width:14px;height:22px;cursor:grab;display:grid;grid-template-columns:1fr 1fr;gap:2px;align-content:center;justify-items:center;margin-right:2px;';+  for (var gi = 0; gi < 6; gi++) { var gd = document.createElement('div'); gd.style.cssText = 'width:3px;height:3px;border-radius:50%;background:#8b949e;'; grip.appendChild(gd); }+  bar.appendChild(grip);+  (function () {+    var drag = null;+    grip.addEventListener('pointerdown', function (e) {+      var r = bar.getBoundingClientRect();+      if (bar.style.transform) { bar.style.transform = ''; bar.style.left = r.left + 'px'; bar.style.top = r.top + 'px'; }+      drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };+      grip.setPointerCapture(e.pointerId); grip.style.cursor = 'grabbing'; e.preventDefault();+    });+    grip.addEventListener('pointermove', function (e) {+      if (!drag) return;+      var w = bar.offsetWidth, h = bar.offsetHeight, M = 8;+      var x = Math.min(Math.max(M, e.clientX - drag.dx), innerWidth - w - M);+      var y = Math.min(Math.max(M, e.clientY - drag.dy), innerHeight - h - M);+      bar.style.left = x + 'px'; bar.style.top = y + 'px';+    });+    grip.addEventListener('pointerup', function () { drag = null; grip.style.cursor = 'grab'; });+  })();+  // tooltip singleton (tooltips skill: fixed, body-level z-max, 600ms reveal, 0.18s ease+  // fade+translate, measured AFTER content set, clamped to the viewport on BOTH axes, flips+  // above when it would clip the bottom)+  var tipEl = document.createElement('div');+  tipEl.style.cssText = 'position:fixed;z-index:2147483647;max-width:280px;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:7px 10px;font:12px Satoshi,system-ui,sans-serif;color:#c9d1d9;box-shadow:0 6px 20px #000b;pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity 0.18s ease,transform 0.18s ease;text-transform:none;';+  wrap.appendChild(tipEl);+  var tipTimer = null;+  function tipShow(anchor, text) {+    clearTimeout(tipTimer);+    tipTimer = setTimeout(function () {+      tipEl.textContent = text;+      tipEl.style.opacity = '0';+      // measure only after content is set and laid out+      requestAnimationFrame(function () {+        var a = anchor.getBoundingClientRect(), M = 8;+        var w = tipEl.offsetWidth, h = tipEl.offsetHeight;+        var x = a.left + a.width / 2 - w / 2;+        var y = a.bottom + 10;+        if (y + h > innerHeight - M) y = a.top - h - 10;   // flip above instead of clipping+        x = Math.min(Math.max(M, x), innerWidth - w - M);+        y = Math.min(Math.max(M, y), innerHeight - h - M);+        tipEl.style.left = x + 'px'; tipEl.style.top = y + 'px';+        tipEl.style.opacity = '1'; tipEl.style.transform = 'translateY(0)';+      });+    }, 600);+  }+  function tipHide() { clearTimeout(tipTimer); tipEl.style.opacity = '0'; tipEl.style.transform = 'translateY(4px)'; }+  function tipFor(el, text) { el.addEventListener('mouseenter', function () { tipShow(el, text); }); el.addEventListener('mouseleave', tipHide); el.addEventListener('pointerdown', tipHide); el.removeAttribute('title'); }+  tipFor(grip, 'Drag to move this toolbar anywhere it is not in your way.');+  var ctx = cv.getContext('2d');+  // ── Photoshop-style tool cursors (v2.0.134, John's HN report: "pen mode shows the crosshair,+  // must be old code"): it was NOT old code. The pen/arrow cursors were CSS+  // url(data:image/svg+xml) images, and CSS cursor images load under the PAGE'S CSP img-src —+  // sites that don't allow data: (Hacker News: img-src 'self' ...) silently block the image and+  // CSS falls back to the declared crosshair, so one build looked new on the wiki and old on HN.+  // Fix: never FETCH a cursor at all. Hide the native cursor over the canvas and follow the+  // pointer with an inline-SVG sprite — DOM markup, which no CSP gates — hotspot preserved+  // (pen tip at 5,19; arrow tip at 2,2). Pen still carries the live color swatch.+  var spr = document.createElement('div');+  spr.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;display:none;will-change:transform;';+  wrap.appendChild(spr);+  var sprHot = [0, 0];+  function penSvg() {+    return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'+      + '<path d="M5 19 L6.5 14.5 L16 5 a2 2 0 0 1 2.8 2.8 L9.5 17.5 Z" fill="#e5e7eb" stroke="#111" stroke-width="1.2"/>'+      + '<path d="M5 19 L6.5 14.5 L9.5 17.5 Z" fill="' + S.color + '" stroke="#111" stroke-width="0.8"/>'+      + '<circle cx="19.5" cy="19.5" r="3.5" fill="' + S.color + '" stroke="#fff" stroke-width="1"/></svg>';+  }+  function arrowSvg() {+    return '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24">'+      + '<path d="M2 2 L11 20 L13.5 13.5 L20 11 Z" fill="' + S.color + '" stroke="#fff" stroke-width="1.4"/></svg>';+  }+  function setCursor() {+    if (S.tool === 'pen' || S.tool === 'arrow') {+      cv.style.cursor = 'none';+      spr.innerHTML = S.tool === 'pen' ? penSvg() : arrowSvg();+      sprHot = S.tool === 'pen' ? [5, 19] : [2, 2];+      // shown on the next pointer move, so it never appears at a stale position+    } else {+      cv.style.cursor = S.tool === 'text' ? 'text' : 'crosshair';+      spr.style.display = 'none';+    }+  }+  function sprTrack(e) {+    if (S.tool !== 'pen' && S.tool !== 'arrow') return;+    spr.style.display = 'block';+    spr.style.transform = 'translate(' + (e.clientX - sprHot[0]) + 'px,' + (e.clientY - sprHot[1]) + 'px)';+  }+  cv.addEventListener('pointermove', sprTrack);+  cv.addEventListener('pointerenter', sprTrack);+  cv.addEventListener('pointerleave', function () { spr.style.display = 'none'; });+  function fit() { var d = window.devicePixelRatio || 1; cv.width = innerWidth * d; cv.height = innerHeight * d; ctx.setTransform(d, 0, 0, d, 0, 0); redraw(); }+  function drawStroke(st) {+    ctx.strokeStyle = st.color; ctx.fillStyle = st.color; ctx.lineWidth = 3.5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';+    if (st.tool === 'pen') { ctx.beginPath(); for (var i = 0; i < st.pts.length; i++) { var p = st.pts[i]; if (i === 0) ctx.moveTo(p[0], p[1]); else ctx.lineTo(p[0], p[1]); } ctx.stroke(); }+    else if (st.tool === 'box') { var a = st.pts[0], b = st.pts[st.pts.length - 1]; ctx.strokeRect(Math.min(a[0],b[0]), Math.min(a[1],b[1]), Math.abs(b[0]-a[0]), Math.abs(b[1]-a[1])); }+    else if (st.tool === 'arrow') { var a2 = st.pts[0], b2 = st.pts[st.pts.length - 1]; ctx.beginPath(); ctx.moveTo(a2[0], a2[1]); ctx.lineTo(b2[0], b2[1]); ctx.stroke();+      var ang = Math.atan2(b2[1]-a2[1], b2[0]-a2[0]);+      ctx.beginPath(); ctx.moveTo(b2[0], b2[1]);+      ctx.lineTo(b2[0]-14*Math.cos(ang-0.42), b2[1]-14*Math.sin(ang-0.42));+      ctx.lineTo(b2[0]-14*Math.cos(ang+0.42), b2[1]-14*Math.sin(ang+0.42));+      ctx.closePath(); ctx.fill(); }+    else if (st.tool === 'text') { ctx.font = '600 17px system-ui,sans-serif'; ctx.fillText(st.text || '', st.pts[0][0], st.pts[0][1]); }+    else if (st.tool === 'snip') { var s1 = st.pts[0], s2 = st.pts[st.pts.length - 1]; ctx.save(); ctx.setLineDash([7, 5]); ctx.lineWidth = 2; ctx.strokeStyle = '#00d4cb'; ctx.strokeRect(Math.min(s1[0],s2[0]), Math.min(s1[1],s2[1]), Math.abs(s2[0]-s1[0]), Math.abs(s2[1]-s1[1])); ctx.restore(); }+  }+  function redraw() { ctx.clearRect(0, 0, innerWidth, innerHeight); S.strokes.forEach(drawStroke); if (S.cur) drawStroke(S.cur); }+  // Tight-crop bbox over ALL strokes, padded and clamped to the viewport (1a2).+  function bbox() {+    if (!S.strokes.length) return null;+    var x1 = 1e9, y1 = 1e9, x2 = -1e9, y2 = -1e9;+    S.strokes.forEach(function (st) {+      if (st.tool === 'text') {+        var w = (st.text || '').length * 9.5, p0 = st.pts[0];+        x1 = Math.min(x1, p0[0]); y1 = Math.min(y1, p0[1] - 18); x2 = Math.max(x2, p0[0] + w); y2 = Math.max(y2, p0[1] + 6);+      } else st.pts.forEach(function (p) { x1 = Math.min(x1, p[0]); y1 = Math.min(y1, p[1]); x2 = Math.max(x2, p[0]); y2 = Math.max(y2, p[1]); });+    });+    var M = 48;+    x1 = Math.max(0, x1 - M); y1 = Math.max(0, y1 - M);+    x2 = Math.min(innerWidth, x2 + M); y2 = Math.min(innerHeight, y2 + M);+    if (x2 - x1 < 24 || y2 - y1 < 24) return null;+    return { x: Math.round(x1), y: Math.round(y1), w: Math.round(x2 - x1), h: Math.round(y2 - y1) };+  }+  function emit(extra, hide) {+    S.seq++;+    var r = { done: true, shapes: S.strokes.length, ts: Date.now(), seq: S.seq, bbox: bbox() };+    if (extra) for (var k in extra) r[k] = extra[k];+    if (hide) { bar.style.display = 'none'; cv.style.pointerEvents = 'none'; window.__pupAnnotateHidden = true; }+    window.__pupAnnotateResult = r;+  }+  cv.addEventListener('pointerdown', function (e) {+    if (S.tool === 'text') { var t = prompt('Annotation text:'); if (t) { S.strokes.push({ tool: 'text', color: S.color, pts: [[e.clientX, e.clientY]], text: t }); redraw(); if (S.quick) { emit(null, true); removeEventListener('keydown', onKey, true); } else advanceColor(); } return; }+    S.cur = { tool: S.tool, color: S.color, pts: [[e.clientX, e.clientY]] }; cv.setPointerCapture(e.pointerId);+  });+  cv.addEventListener('pointermove', function (e) { if (S.cur) { S.cur.pts.push([e.clientX, e.clientY]); redraw(); } });+  cv.addEventListener('pointerup', function () {+    if (!S.cur) return;+    if (S.cur.tool === 'snip') {+      var p1 = S.cur.pts[0], p2 = S.cur.pts[S.cur.pts.length - 1];+      var r = { x: Math.min(p1[0], p2[0]), y: Math.min(p1[1], p2[1]), w: Math.abs(p2[0]-p1[0]), h: Math.abs(p2[1]-p1[1]) };+      S.cur = null; redraw();+      if (r.w > 8 && r.h > 8) emit({ snip: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.w), h: Math.round(r.h) } }, true);+      return;+    }+    S.strokes.push(S.cur); S.cur = null; redraw();+    // QUICK MODE (1a3): the moment the mouse lifts, capture fires — keep drawing and each lift+    // supersedes the last capture with the union bbox.+    if (S.quick) { emit(null, true); removeEventListener('keydown', onKey, true); }+    // v2.0.371 (John): in MULTI mode each finished shape auto-advances to the next color, so a+    // red circle then a blue circle needs zero clicks. White is excluded (invisible on light+    // pages); a manual swatch pick overrides and the cycle continues from there.+    else advanceColor();+  });+  function icon(paths, dash) {+    var n = document.createElementNS('http://www.w3.org/2000/svg', 'svg');+    n.setAttribute('viewBox', '0 0 24 24'); n.setAttribute('width', '14'); n.setAttribute('height', '14');+    n.setAttribute('fill', 'none'); n.setAttribute('stroke', 'currentColor');+    n.setAttribute('stroke-width', '1.9'); n.setAttribute('stroke-linecap', 'round'); n.setAttribute('stroke-linejoin', 'round');+    paths.forEach(function (dd, i) { var p2 = document.createElementNS('http://www.w3.org/2000/svg', 'path'); p2.setAttribute('d', dd); if (dash && i === dash - 1) p2.setAttribute('stroke-dasharray', '2.5 2'); n.appendChild(p2); });+    return n;+  }+  var ICONS = {+    pen: [['M4 20l1.5-4.5L16 5a2.1 2.1 0 0 1 3 3L8.5 18.5 4 20z', 'M13.5 7.5l3 3']],+    arrow: [['M5 19L17 7', 'M9 7h8v8']],+    box: [['M4.5 5.5h15v13h-15z']],+    text: [['M6 6h12', 'M12 6v12']],+    snip: [['M4 8V5a1 1 0 0 1 1-1h3M16 4h3a1 1 0 0 1 1 1v3M20 16v3a1 1 0 0 1-1 1h-3M8 20H5a1 1 0 0 1-1-1v-3', 'M8.5 8.5h7v7h-7z'], 2],+    auto: [['M13 3L5 13h5l-1 8 8-10h-5l1-8']],+    multi: [['M4 4.5h10v10H4z', 'M10 9.5h10v10H10z']],+    undo: [['M8 5L4 9l4 4', 'M4 9h9a6 6 0 0 1 0 12h-2']],+    clear: [['M5 7h14', 'M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2', 'M7 7l1 13h8l1-13']],+    done: [['M4.5 12.5l5 5 10-11']],+    cancel: [['M6 6l12 12', 'M18 6L6 18']],+  };+  function btn(label, title, fn, id, iconKey) {+    var b = document.createElement('button');+    if (iconKey && ICONS[iconKey]) b.appendChild(icon(ICONS[iconKey][0], ICONS[iconKey][1]));+    b.appendChild(document.createTextNode(label)); b.title = title;+    b.style.cssText = 'background:none;border:1px solid transparent;border-radius:8px;color:#c9d1d9;padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;';+    b.onmouseenter = function () { b.style.background = '#1c2128'; };+    b.onmouseleave = function () { b.style.background = S.tool === b.dataset.tool ? 'rgba(0,184,177,.13)' : 'none'; };+    b.onclick = fn; if (id) b.dataset.tool = id; bar.appendChild(b);+    if (title) { tipFor(b, title); }+    return b;+  }+  function selTool(id) { S.tool = id; setCursor(); [].forEach.call(bar.querySelectorAll('button[data-tool]'), function (x) { x.style.background = x.dataset.tool === id ? 'rgba(0,184,177,.13)' : 'none'; x.style.borderColor = x.dataset.tool === id ? 'rgba(0,184,177,.45)' : 'transparent'; }); }+  btn('Pen', 'Draw freehand. With Auto on, the capture fires the moment you release the mouse: cropped tight to your drawing, copied to your clipboard.', function () { selTool('pen'); }, 'pen', 'pen');+  btn('Arrow', 'Point at the thing you mean. Drag from tail to tip; the arrowhead lands where you release.', function () { selTool('arrow'); }, 'arrow', 'arrow');+  btn('Box', 'Frame a region with a rectangle. Drag corner to corner.', function () { selTool('box'); }, 'box', 'box');+  btn('Text', 'Drop a short text note onto the page. Click where the note should start.', function () { selTool('text'); }, 'text', 'text');+  btn('Snip', 'Capture exactly a region, like Win+Shift+S: drag a marquee and it is copied to your clipboard the moment you release.', function () { selTool('snip'); }, 'snip', 'snip');+  // v2.0.370 (John): the color control is now a one-click TRIO — red (default), blue, yellow —+  // with the rarer colors one click deeper in a flyout. The old single cycling dot made+  // red→blue three clicks; picking between about 3 colors must be instant.+  var SW_MAIN = ['#f85149', '#64abff', '#d29922'];+  var SW_MORE = ['#3fb950', '#8c6bf7', '#e6edf3'];+  var flyout = document.createElement('div');+  flyout.style.cssText = 'position:absolute;top:calc(100% + 8px);left:50%;transform:translateX(-50%);display:none;gap:7px;background:#161b22f2;border:1px solid #30363d;border-radius:999px;padding:7px 10px;box-shadow:0 6px 24px #000a;pointer-events:auto;';+  var more = document.createElement('button');+  function setColor(c) {+    S.color = c; setCursor(); flyout.style.display = 'none';+    [].forEach.call(wrap.querySelectorAll('button[data-sw]'), function (x) { x.style.borderColor = x.dataset.sw === c ? '#fff' : 'rgba(255,255,255,.25)'; });+    more.style.outline = SW_MORE.indexOf(c) >= 0 ? '2px solid #fff' : 'none';+  }+  // v2.0.371: the multi-mode auto-advance cycle — the quick trio then green/purple, never white.+  function advanceColor() {+    var CYCLE = ['#f85149', '#64abff', '#d29922', '#3fb950', '#8c6bf7'];+    setColor(CYCLE[(CYCLE.indexOf(S.color) + 1) % CYCLE.length]);+  }+  function swBtn(c, parent) {+    var s = document.createElement('button');+    s.dataset.sw = c;+    s.style.cssText = 'width:18px;height:18px;border-radius:50%;cursor:pointer;background:' + c + ';border:2px solid ' + (S.color === c ? '#fff' : 'rgba(255,255,255,.25)') + ';padding:0;flex:none;';+    s.onclick = function () { setColor(c); };+    parent.appendChild(s);+    return s;+  }+  SW_MAIN.forEach(function (c) { swBtn(c, bar); });+  more.style.cssText = 'width:18px;height:18px;border-radius:50%;cursor:pointer;border:2px solid rgba(255,255,255,.25);padding:0;flex:none;background:conic-gradient(#f85149,#d29922,#3fb950,#64abff,#8c6bf7,#f85149);';+  more.onclick = function () { flyout.style.display = flyout.style.display === 'none' ? 'flex' : 'none'; };+  bar.appendChild(more); tipFor(more, 'More colors: green, purple, white.');+  SW_MORE.forEach(function (c) { swBtn(c, flyout); });+  bar.appendChild(flyout);+  // v2.0.370 (John): Auto vs Multi as an explicit two-state control, not a lone toggle.+  var modeWrap = document.createElement('div');+  modeWrap.style.cssText = 'display:flex;border:1px solid #30363d;border-radius:8px;overflow:hidden;margin-left:2px;';+  function modeBtn(label, iconKey, val, tip) {+    var b = document.createElement('button');+    if (ICONS[iconKey]) b.appendChild(icon(ICONS[iconKey][0], ICONS[iconKey][1]));+    b.appendChild(document.createTextNode(label));+    b.style.cssText = 'background:none;border:none;color:#c9d1d9;padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;';+    b.onclick = function () { S.quick = val; paintMode(); };+    modeWrap.appendChild(b); tipFor(b, tip);+    return b;+  }+  var mAuto = modeBtn('Auto', 'auto', true, 'One-shot (the default): draw one thing, lift the mouse, and it is captured and copied to your clipboard instantly.');+  var mMulti = modeBtn('Multi', 'multi', false, 'Compose: draw several shapes and NOTHING is captured until you click Done. The color auto-advances after each shape (red, blue, yellow, green, purple) so every shape is distinct — click a swatch any time to override.');+  function paintMode() {+    mAuto.style.background = S.quick ? 'rgba(0,184,177,.13)' : 'none'; mAuto.style.color = S.quick ? '#00d4cb' : '#c9d1d9';+    mMulti.style.background = !S.quick ? 'rgba(0,184,177,.13)' : 'none'; mMulti.style.color = !S.quick ? '#00d4cb' : '#c9d1d9';+  }+  paintMode(); bar.appendChild(modeWrap);+  btn('Undo', 'Remove the last thing you drew. Click again to keep unwinding.', function () { S.strokes.pop(); redraw(); }, null, 'undo');+  btn('Clear', 'Erase every drawing on this page. The page itself is untouched.', function () { S.strokes = []; redraw(); }, null, 'clear');+  var done = btn('Done', 'Capture the annotated area (tight-cropped around your drawing), copy it to your clipboard, and leave the drawing visible so the AI thread that owns this window sees it too.', function () { emit(null, true); }, null, 'done');+  done.style.cssText += 'background:rgba(0,184,177,.13);border:1px solid rgba(0,184,177,.55);color:#00d4cb;font-weight:600;';+  btn('', 'Close the annotation toolbar and remove all drawings from the page (Esc does this too).', function () { exitAll(); }, null, 'cancel');+  function exitAll() { removeEventListener('keydown', onKey, true); wrap.remove(); window.__pupAnnotate = null; window.__pupAnnotateResult = { done: false, ts: Date.now() }; }+  function onKey(e) { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); exitAll(); } }+  addEventListener('keydown', onKey, true);+  // v2.0.72: called by the bridge the moment the screenshot is TAKEN (never before, so the+  // flash is not in the shot): camera-flash feedback over the captured region, instant toast+  // with the preview, then the drawings fade out so the user is back to normal surfing.+  window.__pupCaptureDone = function (region, previewUrl, line1) {+    try {+      var fl = document.createElement('div');+      if (!region || !region.w) throw 0; // skip the flash only (caught below): John's rule, flash the captured region or nothing — toast and fade still run+      var rg = region;+      fl.style.cssText = 'position:fixed;left:' + rg.x + 'px;top:' + rg.y + 'px;width:' + rg.w + 'px;height:' + rg.h + 'px;z-index:2147483647;background:none;border:2px dashed #00d4cb;border-radius:6px;opacity:1;pointer-events:none;transition:opacity .45s ease;box-sizing:border-box;';+      document.documentElement.appendChild(fl);+      setTimeout(function () { fl.style.opacity = '0'; }, 550);+      setTimeout(function () { try { fl.remove(); } catch (e) {} }, 1100);+    } catch (e) {}+    try {+      var old = document.getElementById('__pup_ann_toast'); if (old) old.remove();+      var t = document.createElement('div'); t.id = '__pup_ann_toast';+      t.style.cssText = 'position:fixed;left:50%;transform:translateX(-50%);bottom:18px;z-index:2147483647;background:#21262df2;border:1px solid #30363d;border-radius:12px;padding:10px;box-shadow:0 8px 30px #000c;font:12px Satoshi,system-ui,sans-serif;color:#c9d1d9;max-width:340px;cursor:pointer;';+      var cap = document.createElement('div'); cap.textContent = line1 || 'Captured'; cap.style.cssText = 'margin-bottom:7px;color:#00d4cb;font-weight:500;';+      t.appendChild(cap);+      // v2.0.135: the preview arrives as RAW RGBA pixels and is PAINTED (putImageData), never+      // LOADED — an <img src="data:..."> is a resource load the page's CSP img-src can veto+      // (Hacker News does), which showed the user a broken-image icon. Pixel writes to a canvas+      // are not loads; no site's CSP can break this. (String form kept for mixed-version overlap.)+      if (previewUrl && previewUrl.b64 && previewUrl.w) {+        try {+          var pc = document.createElement('canvas'); pc.width = previewUrl.w; pc.height = previewUrl.h;+          pc.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #30363d;';+          var bin = atob(previewUrl.b64), n = bin.length, arr = new Uint8ClampedArray(n);+          for (var i2 = 0; i2 < n; i2++) arr[i2] = bin.charCodeAt(i2);+          pc.getContext('2d').putImageData(new ImageData(arr, previewUrl.w, previewUrl.h), 0, 0);+          t.appendChild(pc);+        } catch (e2) {}+      } else if (previewUrl && typeof previewUrl === 'string') { var im = document.createElement('img'); im.src = previewUrl; im.style.cssText = 'display:block;max-width:320px;max-height:200px;border-radius:8px;border:1px solid #30363d;'; t.appendChild(im); }+      t.onclick = function () { t.remove(); };+      document.documentElement.appendChild(t);+      setTimeout(function () { try { t.remove(); } catch (e) {} }, 4500);+    } catch (e) {}+    // fade the drawings out (John: after the snapshot, back to normal surfing)+    setTimeout(function () {+      try {+        cv.style.transition = 'opacity .8s ease'; cv.style.opacity = '0';+        setTimeout(function () { try { wrap.remove(); window.__pupAnnotate = null; window.__pupCaptureDone = null; removeEventListener('keydown', onKey, true); } catch (e) {} }, 900);+      } catch (e) {}+    }, 2200);+  };+  window.__pupAnnotate = {+    show: function () { window.__pupAnnotateHidden = false; wrap.style.display = ''; bar.style.display = 'flex'; cv.style.pointerEvents = 'auto'; addEventListener('keydown', onKey, true); },+    setMode: function (m) { S.quick = m !== 'multi'; paintMode(); },+  };+  addEventListener('resize', fit);+  document.documentElement.appendChild(wrap);+  fit(); selTool(initTool);+  return 'started';+};`;++// crc32 (fallback when zlib.crc32 is unavailable, node <20.15)+let _crcTable = null;+function _crc32(buf) {+  const zlib = require('zlib');+  if (typeof zlib.crc32 === 'function') return zlib.crc32(buf) >>> 0;+  if (!_crcTable) {+    _crcTable = new Uint32Array(256);+    for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); _crcTable[n] = c >>> 0; }+  }+  let c = 0xFFFFFFFF;+  for (let i = 0; i < buf.length; i++) c = _crcTable[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);+  return (c ^ 0xFFFFFFFF) >>> 0;+}+// Insert an iTXt chunk (keyword + UTF-8 JSON) before IEND — the machine-readable sidecar that+// rides INSIDE the PNG, for tooling that reads metadata (sharp strips unknown chunks on+// re-encode, so this runs LAST, after any caption-bar composite).+function pngAddITXt(buf, keyword, text) {+  try {+    const iend = buf.lastIndexOf(Buffer.from([0x49, 0x45, 0x4E, 0x44]));+    if (iend < 8) return buf;+    const kw = Buffer.from(keyword, 'latin1');+    const data = Buffer.concat([kw, Buffer.from([0, 0, 0, 0, 0]), Buffer.from(text, 'utf8')]);+    const type = Buffer.from('iTXt', 'latin1');+    const len = Buffer.alloc(4); len.writeUInt32BE(data.length);+    const crc = Buffer.alloc(4); crc.writeUInt32BE(_crc32(Buffer.concat([type, data])));+    const chunk = Buffer.concat([len, type, data, crc]);+    const chunkStart = iend - 4;+    return Buffer.concat([buf.slice(0, chunkStart), chunk, buf.slice(chunkStart)]);+  } catch (e) { return buf; }+}+// Caption bar (phase 2): a thin strip baked UNDER the crop so any AI the image is pasted into+// sees the context — visible sidecar, immune to clipboard format games. Toggle: annCaptionBar.+async function addCaptionBar(buf, caption) {+  try {+    const sharp = require('sharp');+    const meta = await sharp(buf).metadata();+    const W = meta.width, BAR = 26;+    const esc = String(caption).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/"/g, '&quot;');+    const svg = Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="' + W + '" height="' + BAR + '">'+      + '<rect width="100%" height="100%" fill="#0d1117"/>'+      + '<text x="8" y="17" font-family="Segoe UI, system-ui, sans-serif" font-size="12" fill="#00d4cb">' + esc + '</text></svg>');+    return await sharp(buf)+      .extend({ bottom: BAR, background: '#0d1117' })+      .composite([{ input: svg, top: meta.height, left: 0 }])+      .png().toBuffer();+  } catch (e) { return buf; }+}+// Multi-format clipboard: image + text/plain TOGETHER via one .NET DataObject (plain+// Set-Clipboard cannot do multi-format). One paste into a chat input attaches the image AND+// inserts the sidecar text; custom mime formats would NOT survive into web inputs.+function pupClipHelperPath() {+  const la = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+  return path.join(la, 'Adom Bridge', 'pup-clip.ps1');+}+function ensurePupClipHelper() {+  if (process.platform !== 'win32') return null;+  const p2 = pupClipHelperPath();+  const script = `param([Parameter(Mandatory=$true)][string]$Png, [string]$Txt='')+Add-Type -AssemblyName System.Windows.Forms+Add-Type -AssemblyName System.Drawing+$img = [System.Drawing.Image]::FromFile($Png)+$d = New-Object System.Windows.Forms.DataObject+$d.SetImage($img)+if ($Txt -and (Test-Path $Txt)) { $d.SetText([IO.File]::ReadAllText($Txt)) }+[System.Windows.Forms.Clipboard]::SetDataObject($d, $true)+$img.Dispose()+`;+  try { let cur = null; try { cur = fs.readFileSync(p2, 'utf8'); } catch (e) {}+    if (cur !== script) { fs.mkdirSync(path.dirname(p2), { recursive: true }); fs.writeFileSync(p2, script); } } catch (e) { return null; }+  return p2;+}+function copyCaptureToClipboard(pngPath, sidecarText) {+  return new Promise((resolve) => {+    try {+      const helper = ensurePupClipHelper();+      if (!helper) return resolve(false);+      const txtPath = pngPath.replace(/\.png$/i, '.txt');+      fs.writeFileSync(txtPath, sidecarText);+      const { execFile } = require('child_process');+      execFile('powershell.exe', ['-STA', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', helper, pngPath, txtPath],+        { timeout: 15000, windowsHide: true }, (err) => resolve(!err));+    } catch (e) { resolve(false); }+  });+}++// v2.0.53 (John repinned pup to CfT): the toolbar extension is now a LAUNCH ARG. Chrome for+// Testing still honors --load-extension (branded Chrome >=137 removed it), so when the driving+// browser is CfT and the toolbarExtension setting is on, every profile launch carries the flag+// and every window is BORN with the toolbar: no install step exists at all. This helper just+// stages the extension files at a stable path for the flag to point at.+function _stageExtDir(srcName, dstName) {+  const srcDir = path.join(__dirname, srcName);+  if (!fs.existsSync(path.join(srcDir, 'manifest.json'))) return null;+  const dstDir = path.join(os.homedir(), '.adom', dstName);+  try {+    fs.mkdirSync(dstDir, { recursive: true });+    for (const f of fs.readdirSync(srcDir)) {+      const sp = path.join(srcDir, f), dp = path.join(dstDir, f);+      try { const a = fs.statSync(sp), b = fs.existsSync(dp) && fs.statSync(dp); if (!b || b.size !== a.size || b.mtimeMs < a.mtimeMs) fs.copyFileSync(sp, dp); } catch (e) { fs.copyFileSync(sp, dp); }   // v2.0.96: mtime too — a same-length edit (e.g. a version bump) was silently never staged+    }+    return dstDir;+  } catch (e) { return null; }+}+// v2.0.66 (floaty pencil, server-injected): the extension's content-script rails never fire+// under this --load-extension path (declarative AND chrome.scripting verified dead while the+// popup works), so the floaty rides pup's own guaranteed injection rail instead (the owner-chip+// pattern). Clicking sets window.__pupFloatyClick; the 4s enforcer sweep picks it up and starts+// annotate on THAT tab. Zero extension dependency for discoverability.+function floatyJs() {+  const px = Math.max(6, Math.min(200, Number(pupSettings().floatyHitPx) || 24));+  const floatyOn = pupSettings().tbFloatyPencil !== 'off';+  const hotkeyOn = pupSettings().tbSnipHotkey !== 'off';+  return FLOATY_JS.replace(/__HITPX__/g, String(px)).replace(/__FLOATY_ON__/g, floatyOn ? '1' : '').replace(/__HOTKEY_ON__/g, hotkeyOn ? '1' : '');+}+const FLOATY_JS = `(() => {+  // v2.0.89: VERSIONED guard. evaluateOnNewDocument scripts from every prior bridge process stay+  // registered on long-lived pages and run oldest-first, so a plain boolean guard let the OLDEST+  // floaty win forever (John kept seeing the big hover zone after reloads). The newest module now+  // tears down any elder and rebuilds.+  if (window !== window.top) return;+  if (window.__pupFloatyV === '${BRIDGE_VERSION}:__HITPX__:__FLOATY_ON__:__HOTKEY_ON__') return;+  window.__pupFloatyV = '${BRIDGE_VERSION}:__HITPX__:__FLOATY_ON__:__HOTKEY_ON__'; window.__pupFloaty = true;+  var _oldHost = document.getElementById('__pup_floaty'); if (_oldHost) _oldHost.remove();+  function add() {+    // v2.0.95 (John: the corner hover zone fights buttons in app headers — off by default now+    // that the toolbar pen button covers discovery; the elder-host removal above still runs so+    // flipping the setting OFF tears the pencil out of every open page).+    if (!'__FLOATY_ON__') return;+    if (!document.body) { setTimeout(add, 400); return; }+    var host = document.createElement('div');+    host.id = '__pup_floaty';+    // v2.0.84 (John): TUCKED AWAY by default so it never interferes with page UI — a corner+    // hover zone reveals it (the taskbar-tuck pattern), tracked via mousemove (zero hit-testing+    // interference). Once dragged (grip, floating-windows skill), it pins visible where placed.+    host.style.cssText = 'position:fixed;top:14px;right:14px;z-index:2147483645;pointer-events:auto;display:flex;align-items:center;gap:4px;opacity:0;transform:translateY(-6px);transition:opacity .2s ease,transform .2s ease;';+    var root = host.attachShadow({ mode: 'closed' });+    var row = document.createElement('div');+    row.style.cssText = 'display:flex;align-items:center;gap:4px;';+    var grip = document.createElement('div');+    grip.setAttribute('aria-label', 'Move the annotate button');+    grip.style.cssText = 'width:12px;height:22px;cursor:grab;display:none;grid-template-columns:1fr 1fr;gap:2px;align-content:center;justify-items:center;';+    for (var gi = 0; gi < 6; gi++) { var gd = document.createElement('div'); gd.style.cssText = 'width:2.5px;height:2.5px;border-radius:50%;background:#8b949e;'; grip.appendChild(gd); }+    var btn = document.createElement('button');+    btn.setAttribute('aria-label', 'Annotate this page');+    btn.style.cssText = 'width:30px;height:30px;border-radius:8px;display:grid;place-items:center;background:rgba(13,17,23,.72);border:1px solid rgba(0,184,177,.45);cursor:pointer;padding:0;';+    btn.innerHTML = '<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="#e6edf3" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 20l1.5-4.5L16 5a2.1 2.1 0 0 1 3 3L8.5 18.5 4 20z"/><path d="M13.5 7.5l3 3"/></svg>';+    var tip = document.createElement('div');+    tip.innerHTML = '<div style="font-weight:700;color:#e6edf3;margin-bottom:4px;font-family:Familjen Grotesk,Satoshi,sans-serif;">Annotate this page</div>'+      + '<div style="color:#c9d1d9;">Draw on the page to show your AI exactly what you mean; a red circle says it best. The moment you lift the mouse, the marked-up area is copied to your clipboard, ready to paste into any AI chat.</div>'+      + '<div style="margin-top:7px;color:#8b949e;font-size:11.5px;">Shortcut&nbsp;&nbsp;<span style="font-family:JetBrains Mono,monospace;font-size:10.5px;border:1px solid #30363d;border-radius:4px;padding:1px 6px;color:#c9d1d9;">Alt&nbsp;Shift&nbsp;A</span></div>';+    tip.style.cssText = 'position:fixed;z-index:2147483647;max-width:min(320px,calc(100vw - 40px));background:rgba(22,27,34,.96);color:#e6edf3;border:1px solid rgba(0,184,177,.28);border-radius:10px;padding:9px 13px;font:400 13px/1.4 Satoshi,system-ui,sans-serif;box-shadow:0 12px 40px rgba(0,0,0,.55);backdrop-filter:blur(16px);pointer-events:none;opacity:0;transform:translateY(4px);transition:opacity .18s ease,transform .18s ease;';+    row.appendChild(grip); row.appendChild(btn); root.appendChild(row); root.appendChild(tip);+    var timer = null, pinned = false, shown = false, hideTimer = null, idleTimer = null, moves = 0;+    // v2.0.93 (John: "why is the pen showing in the window you just opened?"): a window that+    // opens maximized UNDER a parked cursor gets ONE synthetic hover mousemove from Chromium at+    // load; if that lands in the corner zone the pencil revealed and stayed stuck (tuck only+    // runs on real movement). A reveal that saw almost no movement is that synthetic case:+    // auto-tuck it. Real hover-in produces dozens of mousemoves and is untouched.+    function reveal() {+      clearTimeout(hideTimer); clearTimeout(idleTimer); moves = 0;+      idleTimer = setTimeout(function () { if (!pinned && moves < 3) { shown = false; host.style.opacity = '0'; host.style.transform = 'translateY(-6px)'; grip.style.display = 'none'; tipHide(); } }, 3500);+      if (shown) return; shown = true; host.style.opacity = '1'; host.style.transform = 'translateY(0)'; grip.style.display = 'grid';+    }+    function tuck() { if (pinned) return; hideTimer = setTimeout(function () { shown = false; host.style.opacity = '0'; host.style.transform = 'translateY(-6px)'; grip.style.display = 'none'; tipHide(); }, 1200); }+    // corner hover zone via mousemove: NOTHING intercepts page clicks+    addEventListener('mousemove', function (e) {+      moves++;+      if (pinned) return;+      var hr = host.getBoundingClientRect();+      var inCorner = e.clientX > innerWidth - __HITPX__ && e.clientY < __HITPX__;   // v2.0.90: user-tunable corner target (floatyHitPx slider in Settings)+      var onHost = shown && e.clientX >= hr.left - 8 && e.clientX <= hr.right + 8 && e.clientY >= hr.top - 8 && e.clientY <= hr.bottom + 8;+      if (inCorner || onHost) reveal(); else if (shown) tuck();+    }, { passive: true });+    function tipShow() { clearTimeout(timer); timer = setTimeout(function () {+      var a = btn.getBoundingClientRect(), M = 8, w = tip.offsetWidth, h = tip.offsetHeight;+      var x = Math.min(Math.max(M, a.right - w), innerWidth - w - M);+      var y = a.bottom + 10; if (y + h > innerHeight - M) y = a.top - h - 10;+      y = Math.min(Math.max(M, y), innerHeight - h - M);+      tip.style.left = x + 'px'; tip.style.top = y + 'px'; tip.style.opacity = '1'; tip.style.transform = 'translateY(0)';+    }, 600); }+    function tipHide() { clearTimeout(timer); tip.style.opacity = '0'; tip.style.transform = 'translateY(4px)'; }+    btn.onmouseenter = tipShow; btn.onmouseleave = tipHide;+    btn.onclick = function () { tipHide(); btn.style.borderColor = '#00d4cb'; if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen'); } window.__pupFloatyClick = Date.now(); if (typeof window.__pupFloatyPush === 'function') { try { window.__pupFloatyPush('btn'); window.__pupFloatyClick = null; } catch (e) {} } setTimeout(function () { btn.style.borderColor = 'rgba(0,184,177,.45)'; }, 1500); };+    // drag by grip (floating-windows skill: visible grip, viewport-clamped); a drag PINS it+    (function () {+      var drag = null;+      grip.addEventListener('pointerdown', function (e) {+        var r = host.getBoundingClientRect();+        host.style.right = ''; host.style.left = r.left + 'px'; host.style.top = r.top + 'px';+        drag = { dx: e.clientX - r.left, dy: e.clientY - r.top };+        grip.setPointerCapture(e.pointerId); grip.style.cursor = 'grabbing'; e.preventDefault();+      });+      grip.addEventListener('pointermove', function (e) {+        if (!drag) return;+        var w = host.offsetWidth, h = host.offsetHeight, M = 8;+        host.style.left = Math.min(Math.max(M, e.clientX - drag.dx), innerWidth - w - M) + 'px';+        host.style.top = Math.min(Math.max(M, e.clientY - drag.dy), innerHeight - h - M) + 'px';+      });+      grip.addEventListener('pointerup', function () { drag = null; pinned = true; grip.style.cursor = 'grab'; });+    })();+    document.body.appendChild(host);+  }+  add();+  if ('__HOTKEY_ON__') addEventListener('keydown', function (e) {+    // v2.0.372 (John): Alt+Shift+A = annotate in Auto; Alt+Shift+M = annotate straight into+    // MULTI (compose shapes, auto-advancing colors, capture once on Done). If the overlay is+    // already up, the hotkey switches its mode instead of being ignored.+    if (e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'A' || e.key === 'a' || e.key === 'M' || e.key === 'm')) {+      e.preventDefault(); e.stopPropagation();+      var mm = (e.key === 'M' || e.key === 'm') ? 'multi' : 'auto';+      if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen', mm); }+      window.__pupFloatyClick = Date.now(); if (typeof window.__pupFloatyPush === 'function') { try { window.__pupFloatyPush('hotkey'); window.__pupFloatyClick = null; } catch (e) {} }+    }+  }, true);+})()`;+// v2.0.102: the owner-in-tab-title experiment (2.0.96-2.0.101) is DEAD — John: "stop mangling+// the title of the page. i can't fucking stand when you do that." The page's title belongs to+// the page. See dev-skills/pup-page-integrity. TITLE_JS survives ONLY as the cleanup rail: the+// empty-owner build strips any prefix a prior version left and kills its keeper interval; the+// owner build must never be injected again.+const TITLE_JS = `(function () {+  if (window !== window.top) return;+  if (window.__pupTitleV === '${BRIDGE_VERSION}:__OWNER__') return;+  window.__pupTitleV = '${BRIDGE_VERSION}:__OWNER__';+  if (window.__pupTitleTimer) { clearInterval(window.__pupTitleTimer); window.__pupTitleTimer = null; }+  try {+    var prev = window.__pupTitlePre, t0 = document.title || '';+    if (prev && t0.slice(0, prev.length) === prev) document.title = t0.slice(prev.length);+  } catch (e) {}+  var OWNER = '__OWNER__';+  if (!OWNER) { window.__pupTitlePre = null; return; }+  // v2.0.96: PREFIX, not suffix — Chrome ellipsizes the tab at a fixed width, so a suffix+  // truncated out of sight and the whole point is that the thread name is DIRECTLY visible.+  var PRE = OWNER + ' \u00b7 ';+  window.__pupTitlePre = PRE;+  function apply() {+    try {+      var t = document.title || '';+      if (t.slice(0, PRE.length) === PRE) return;+      document.title = PRE + t;+    } catch (e) {}+  }+  apply();+  window.__pupTitleTimer = setInterval(apply, 1500);+})()`;+function titleJs(owner) {+  const o = String(owner || '').replace(/[\\'"\r\n]/g, ' ').trim();+  return TITLE_JS.replace(/__OWNER__/g, o);+}+function stageToolbarExtension() {+  if (process.platform !== 'win32') return null;+  if (pupSettings().toolbarExtension === 'off') return null;+  return _stageExtDir('extension', 'pup-extension');+}+// v2.0.57 (John: "we could never show the Adom profile avatar in the chrome toolbar — now we+// can"): a second staged extension whose toolbar icon IS the user's avatar, click → the identity+// card ("surfing as adom-you"). Rides ONLY the adom-you profile (an isolated throwaway carrying+// the identity would lie). The avatar (settings.identityAvatarB64, same source as the owner chip)+// overrides the icon files at stage time; the Adom favicon stays as the fallback.+async function stageIdentityExtension() {+  if (process.platform !== 'win32') return null;+  if (pupSettings().toolbarExtension === 'off') return null;+  const dstDir = _stageExtDir('extension-identity', 'pup-identity-extension');+  if (!dstDir) return null;+  try {+    // v2.0.85 (John: the two toolbar icons feel like brothers from another mother): ONE icon+    // family across every pup surface. The identity icon is the SAME teal rounded tile as the+    // pup icon and the taskbar tiles, with the avatar circle-cropped and inset in the frame —+    // same silhouette, same radius, same teal (#00b8b1), unmistakably siblings.+    const b64 = pupSettings().identityAvatarB64;+    if (b64) {+      const sharp = require('sharp');+      const buf = Buffer.from(String(b64).replace(/^data:image\/[a-z+]+;base64,/, ''), 'base64');+      for (const size of [16, 48, 128]) {+        const rx = Math.max(2, Math.round(size / 5.5));+        const inset = Math.max(1, Math.round(size * 0.11));+        const d = size - inset * 2;+        const tile = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}"><rect width="${size}" height="${size}" rx="${rx}" fill="#00b8b1"/></svg>`);+        const mask = Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="${d}" height="${d}"><circle cx="${d / 2}" cy="${d / 2}" r="${d / 2}" fill="#fff"/></svg>`);+        const avatar = await sharp(buf).resize(d, d, { fit: 'cover' }).composite([{ input: mask, blend: 'dest-in' }]).png().toBuffer();+        const out = await sharp(tile).composite([{ input: avatar, left: inset, top: inset }]).png().toBuffer();+        fs.writeFileSync(path.join(dstDir, `icon${size}.png`), out);+      }+    }+  } catch (e) {}+  return dstDir;+}+async function startAnnotation(session, sessionId, callerThread, opts) {+  const tab = ((opts && opts.tabId) && (session.tabs || []).find(t => t.tabId === opts.tabId)) || null;+  let pg = tab && tab.page;+  if (!pg) {+    for (const t5 of (session.tabs || [])) { try { const vis = await Promise.race([t5.page.evaluate(() => document.visibilityState), new Promise(r => setTimeout(() => r(''), 900))]); if (vis === 'visible') { pg = t5.page; break; } } catch (e) {} }+  }+  if (!pg) { const t0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0]; pg = t0 && t0.page; }+  if (!pg) throw new Error('no page to annotate');+  const tool = (opts && opts.tool) || 'pen';+  // v2.0.370: multi = compose shapes, capture once on Done. v2.0.373: pass a mode ONLY when the+  // caller explicitly asked for one — the floaty/hotkey sweep re-arms the watcher moments after+  // the page-side hotkey already started the overlay, and a defaulted 'auto' here was flipping a+  // fresh Alt+Shift+M overlay straight back to Auto via the re-arm mode switch.+  const mode = (opts && (opts.mode === 'multi' || opts.mode === 'auto')) ? opts.mode : null;+  await pg.evaluate((code) => { if (!window.__pupStartAnnotate) eval(code); }, ANNOTATE_OVERLAY_JS);+  await pg.evaluate((t2, m2) => { window.__pupStartAnnotate(t2, m2); }, tool, mode);   // v2.0.77: NEVER clear __pupAnnotateResult here — arming must not destroy a capture emitted moments earlier+  console.log(`[annotate] "${sessionId}" overlay started (by ${callerThread || 'unknown'}, tool=${tool}, mode=${mode})`);+  // v2.0.76: the PROVEN watcher (2.0.49 pattern) — the fast-loop pickup never dispatched in+  // production and quick capture broke; this waits on the page itself and feeds the new fast+  // pipeline (flash + instant toast + async slow legs). Quick mode re-arms for the next stroke.+  (async () => {+    const t0 = Date.now();+    while (Date.now() - t0 < 15 * 60000) {+      try { await pg.waitForFunction('window.__pupAnnotateResult', { timeout: 15 * 60000 - (Date.now() - t0), polling: 500 }); }+      catch (e) {+        // v2.0.78: navigations (wiki auto-login among them) destroy the execution context and+        // reject the wait — the watcher must survive them, not die silently (the no-toast bug's+        // final layer). Page gone = done; otherwise re-arm and keep watching.+        if (pg.isClosed && pg.isClosed()) return;+        if (/destroyed|detached|Target closed|Session closed/i.test(e.message || '')) { await new Promise(r => setTimeout(r, 600)); continue; }+        return;+      }+      let res = null;+      try { res = await pg.evaluate(() => { const r = window.__pupAnnotateResult; window.__pupAnnotateResult = null; return r; }); } catch (e) { return; }+      if (!res) continue;+      if (!res.done) { console.log(`[annotate] "${sessionId}" cancelled`); return; }+      try { await processAnnotateCapture(session, sessionId, pg, res); } catch (e) { console.log(`[annotate] "${sessionId}" capture failed: ${e.message}`); }+      if (!res.quick) return;+    }+  })();+}++// v2.0.72 CAPTURE PIPELINE, reordered for perceived speed (John: the blip, the slow toast):+// screenshot first, then IMMEDIATELY the client-side camera flash + toast with the preview, and+// only afterwards the slow legs (caption bar, iTXt, clipboard, disk) run in the background.+// v2.0.83: FLICKER-FREE capture. CDP captureScreenshot compositor-swaps the surface, which+// visibly blips on a FOREGROUND hardware-accelerated window (pup's usual background shots never+// showed it; annotate shoots the window the user is watching). A single screencast frame (the+// recorder's rail) has no visual artifact; crop server-side with sharp, DPR-scaled.+async function flickerFreeShot(pg, clipRect) {+  const cdp = await pg.target().createCDPSession();+  try {+    const frameP = new Promise((resolve, reject) => {+      const to = setTimeout(() => reject(new Error('screencast frame timeout (6s)')), 6000);+      cdp.once('Page.screencastFrame', ev => { clearTimeout(to); resolve(ev); });+    });+    await cdp.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 });+    const ev = await frameP;+    cdp.send('Page.screencastFrameAck', { sessionId: ev.sessionId }).catch(() => {});+    await cdp.send('Page.stopScreencast').catch(() => {});+    let buf = Buffer.from(ev.data, 'base64');+    if (clipRect) {+      const sharp = require('sharp');+      const meta = await sharp(buf).metadata();+      const cssW = (ev.metadata && ev.metadata.deviceWidth) || null;+      const scale = cssW ? meta.width / cssW : 1;+      const L = Math.max(0, Math.round(clipRect.x * scale)), T = Math.max(0, Math.round(clipRect.y * scale));+      const W = Math.min(meta.width - L, Math.round(clipRect.w * scale)), H = Math.min(meta.height - T, Math.round(clipRect.h * scale));+      if (W > 8 && H > 8) buf = await sharp(buf).extract({ left: L, top: T, width: W, height: H }).png().toBuffer();+    }+    return buf;+  } finally { try { await cdp.detach(); } catch (e) {} }+}+async function processAnnotateCapture(session, sessionId, pg, res) {+  console.log(`[annotate] "${sessionId}" capture: screenshotting`);+  const ts = new Date().toISOString().replace(/[:.]/g, '-');+  const isSnip = res.snip && res.snip.w > 8 && res.snip.h > 8;+  const clipRect = isSnip ? res.snip : res.bbox;+  const fp = path.join(SHOTS_DIR, `annotate-${isSnip ? 'snip-' : ''}${sessionId}-${ts}.png`);+  let buf;+  try { buf = await flickerFreeShot(pg, clipRect); }+  catch (e) {+    console.log(`[annotate] "${sessionId}" screencast shot failed (${e.message}) — falling back to captureScreenshot`);+    buf = await Promise.race([+      pg.screenshot(clipRect ? { clip: { x: clipRect.x, y: clipRect.y, width: clipRect.w, height: clipRect.h }, captureBeyondViewport: false } : { captureBeyondViewport: false }),+      new Promise((_, rej) => setTimeout(() => rej(new Error('screenshot timed out (10s)')), 10000)),+    ]);+  }+  console.log(`[annotate] "${sessionId}" capture: screenshot ok (${buf.length}B)`);+  // instant feedback: flash + toast (preview resize is fast, everything else waits)+  // v2.0.135 (John: "the image you grabbed is broken in this page?"): the preview was a+  // data:image/png URL in an <img>, and image loads — data: included — are governed by the+  // PAGE'S CSP img-src. CSP-strict sites (Hacker News: img-src 'self' ...) blocked it, so the+  // toast showed a broken-image icon. Same disease as the annotate cursors. Fix: ship RAW RGBA+  // pixels and let the toast PAINT them via canvas putImageData — pure pixel writes, no image+  // load, so no CSP anywhere can break the preview.+  let preview = null;+  try {+    const sharp = require('sharp');+    const { data, info } = await sharp(buf).resize({ width: 320, withoutEnlargement: true }).ensureAlpha().raw().toBuffer({ resolveWithObject: true });+    preview = { b64: data.toString('base64'), w: info.width, h: info.height };+  } catch (e) {}+  const line1 = `${isSnip ? 'Snippet' : 'Annotation'} copied to your clipboard`;+  pg.evaluate((rg, pv, l1) => { if (window.__pupCaptureDone) window.__pupCaptureDone(rg, pv, l1); }, clipRect || null, preview, line1).catch(() => {});+  // slow legs, off the perceived path+  (async () => {+    try {+      const owner = session.owner || session._ownerThread || 'unknown-thread';+      let url = ''; try { url = pg.url() || ''; } catch (e) {}+      const shortUrl = url.replace(/^https?:\/\//, '').slice(0, 80);+      const when = new Date().toTimeString().slice(0, 5);+      if (pupSettings().annCaptionBar !== 'off') buf = await addCaptionBar(buf, `pup · ${owner}'s window · ${shortUrl} · ${when}`);+      const sidecar = { kind: 'pup-annotation', thread: owner, container: session._callerContainer || session._ownerContainer || null, sessionId, url, ts: new Date().toISOString(), shapes: res.shapes || 0, crop: clipRect || 'full', viewport: null };+      buf = pngAddITXt(buf, 'pup-annotation', JSON.stringify(sidecar));+      fs.writeFileSync(fp, buf);+      session._lastAnnotation = { path: fp, ts: Date.now(), shapes: res.shapes, snip: isSnip ? res.snip : null };+      const sidecarText = `pup snippet — ${owner}'s window — ${url} — ${sidecar.ts}\n` + JSON.stringify(sidecar, null, 1);+      const clipped = await copyCaptureToClipboard(fp, sidecarText);+      console.log(`[annotate] "${sessionId}" CAPTURED ${isSnip ? `SNIP ${res.snip.w}x${res.snip.h}` : (clipRect ? `tight-crop ${clipRect.w}x${clipRect.h}` : 'full-view')} -> ${fp} | clipboard=${clipped} | quick=${!!res.quick}`);+    } catch (e) { console.log(`[annotate] "${sessionId}" capture post-processing failed: ${e.message}`); }+  })();+}++async function wikiCaption(text, expiresInMs) {+  try {+    // v1.9.11: AD ≥1.9.144 exposes desktop_caption on the LOOPBACK direct API (the path bridges+    // use). Before that it was relay-only — ~90 inline verbs matched only their BARE names, and+    // the CLI strips the `desktop_` prefix client-side, which is exactly why a CLI test "worked"+    // while pup's identical call silently no-op'd. Both spellings are accepted now.+    // NOTE the arg is `expiresInMs` (NOT `duration`) — default 30000, max 600000.+    // This replaced the in-page DOM toast: with a real OS surface available, pup should not be+    // injecting elements into the user's page.+    const r = await chrome.abCommand('desktop_caption', {+      text, duration: Math.min(Number(expiresInMs) || 2000, 2000), expiresInMs: Math.min(Number(expiresInMs) || 2000, 2000), id: 'pup-wiki-view', position: 'bottom', size: 'medium',+    }, { timeoutMs: 4000 });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || r || ''));+    if (!r || (r.success === false) || /not a built-in desktop verb|unknown/i.test(raw || '')) {+      console.log(`[wiki] desktop_caption FAILED (AD <1.9.144?) — ${(raw || 'no response').slice(0, 120)}`);+    }+  } catch (e) {}+}+// v1.9.7: reusable OS FOREGROUND raise, extracted verbatim from the proven pup_raise_os_window+// path, so the wiki view-toggle callback can actually POP the relaunched window to the user's+// screen. A jump-list click is a genuine USER action, so a real SetForegroundWindow raise is+// warranted — the earlier CDP-only maximize left the window BEHIND whatever the user was looking+// at, which is why John "never saw the switch work." CDP place+maximize → blur → bringToFront →+// clear WS_EX_NOACTIVATE + SetForegroundWindow (via the detached/browser PID, with a WMI child+// fallback for launcher→browser PID relaunches). Best-effort throughout; never throws.+async function osRaiseSessionWindow(session, sessionId, reason, thread) {+  // v2.0.136 (John: "why are you showing this caption when i click annotate in the chrome bar?"):+  // if this window is ALREADY the user's foreground window, there is nothing to bring to the+  // front — the user is literally looking at it (they just clicked a control inside it, e.g. the+  // toolbar Annotate button). The raise was a no-op, but the choke-point caption explaining the+  // "raise" fired anyway — pure noise over their work. Early-exit: an already-foreground window+  // gets a CDP tab bringToFront only (so the right tab shows), no OS raise, NO caption. Raises+  // of genuinely background/minimized windows behave exactly as before, caption included.+  if (process.platform === 'win32') {+    try {+      const _fgNow = await osGetForegroundWindow();+      const _myH = session._hwnd ? Number(session._hwnd) : (await sessionHwnd(sessionId));+      if (_fgNow && _myH && Number(_fgNow) === Number(_myH)) {+        session._foreground = true;+        try { await session.page.bringToFront(); } catch (e) {}+        console.log(`[foreground] "${sessionId}" is already the user's foreground window — raise + caption skipped (no-op)`);+        return;+      }+    } catch (e) {}+  }+  // v1.9.255 (John: "if a user asks to fg, you show a caption for 2 seconds explaining the reason.+  // do you do that? cuz i asked you 100 times"): the pup_raise verb captioned, but the five+  // OTHER raise paths routed here and captioned NOTHING. Enforce at the choke point: every raise+  // through this function captions its reason for 2s. Callers that already narrate (assisted_click)+  // pass reason=null to avoid a double caption.+  // v2.0.290 (John: "you showed the caption ~5s LATER, which defeats the point — always show the+  // caption FIRST, then foreground"). AWAIT the caption so it is on screen BEFORE the raise runs;+  // fire-and-forget let the fast raise win the race and the caption trailed the window.+  try { if (reason) await captionForegroundReason(sessionId, reason, 'osRaiseSessionWindow', thread); } catch (e) {}+  try {+    try {+      const cdp = await session.page.target().createCDPSession();+      const { windowId } = await cdp.send('Browser.getWindowForTarget');+      // v2.0.134 (John: "you're STILL resizing the browser window on me" — traced HERE, not to+      // the relaunch): this used to UNCONDITIONALLY rewrite the normal rect to 1280x800@0,0 and+      // re-maximize on EVERY raise, so even an already-maximized, on-screen window visibly+      // snapped small-then-big each time a view toggle (or any user-click raise) went through.+      // The rewrite exists only to rescue a window stranded at the -32000 birth park, where the+      // normal rect must land on-screen before maximize means anything. So: read the CURRENT+      // bounds first — already-maximized windows are NOT touched at all, on-screen non-maximized+      // windows get maximize only, and only a genuinely off-screen window gets the full+      // rect-rewrite + maximize rescue.+      const _cur = await cdp.send('Browser.getWindowBounds', { windowId }).catch(() => null);+      const _b = _cur && _cur.bounds;+      const _off = _b && ((_b.left != null && _b.left <= -10000) || (_b.top != null && _b.top <= -10000));+      if (_off) {+        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: 1280, height: 800, windowState: 'normal' } });+        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });+      } else if (!_b || (_b.windowState !== 'maximized' && _b.windowState !== 'fullscreen')) {+        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'maximized' } });+      }+      // already maximized (or fullscreen): leave the geometry completely alone — raise only.+    } catch (e) {}+    try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}+    try { await session.page.bringToFront(); } catch (e) {}+    session._foreground = true;+    if (process.platform === 'darwin') {+      // macOS: unhide + activate the session's browser process (the CDP part above sized it).+      const pid = macSessionPid(session);+      const ok = await macActivateByPid(pid);+      console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${ok}`);+      return;+    }+    if (process.platform !== 'win32') return;+    // v1.9.66 — RAISE BY HWND. The CDP-only path above (setWindowBounds + bringToFront) happens to+    // surface a CHROME window, but it does NOT raise an EDGE one: bringToFront activates the TAB+    // inside the window, and setWindowBounds repositions without changing z-order, so a window pup+    // deliberately parked at the BOTTOM of the z-order stays buried. Measured on ConfRoomROG (which+    // is pinned to Edge because Edge was cached as its verified default): raise returned ok:true,+    // the log said "raise GRANTED", and the screen did not change — the user's "I click and no+    // window comes up".+    // The old PID fallback was removed for a good reason (a detached launch's process() is null, and+    // _detachedPid raised the LAUNCHER's window, i.e. the WRONG one). The fix is to name the window+    // exactly: resolve THIS session's hwnd by its "(session: <id>)" title, then have AD foreground+    // that specific hwnd. Precise, browser-agnostic, and no PID guessing.+    try {+      const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))}));+      const hwnd = fw && fw.best && fw.best.hwnd;+      if (hwnd) {+        await chrome.abCommand('desktop_bring_to_front', { hwnd }, { timeoutMs: 6000 });+        console.log(`[foreground] "${sessionId}" raised by hwnd=${hwnd}`);+      } else {+        console.log(`[foreground] "${sessionId}" could not resolve an hwnd to raise`);+      }+    } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }+    return;+    // v2.0.230 cleanup: ~50 lines of dead PowerShell WinFocus raise used to sit here, after an+    // unconditional return — superseded by the hwnd + desktop_bring_to_front path above. Deleted.+  } catch (e) {}+}+// ── v1.9.31: TAB DRAGGED OUT of a pup window → adopt it as its OWN session ──────────────+// John dragged a tab out of a pup window: Chrome created a NEW OS window, which (being a new HWND)+// carries none of pup's identity, so it wore the bare Chrome-for-Testing icon. Worse, the dragged+// page keeps its "(session: <id>)" title suffix, so ONE session suddenly spanned TWO windows and+// AD's title-based window resolution became ambiguous.+//+// Detection is EVENT-DRIVEN, never a scan (John: "scanning every second is wasteful"). AD exposes a+// global UIA subscription — desktop_ui_watch {event:"window-opened"} — whose events land in a+// 500-deep ring we drain with desktop_ui_events {sinceSeq}. A new window announces itself; we then+// do ONE cheap CDP check (Browser.getWindowForTarget per tab) only for sessions that could be+// affected. Draining is piggybacked on existing activity (the 30s health tick + tab verbs), so+// there is no dedicated timer.+//+// Policy (John's call, option A): the dragged-out tab becomes its OWN session — its own sessionId,+// identity, CATEGORY icon and jump list. That keeps "one session = one window" true, keeps titles+// unique, and lets a dragged-out digikey tab correctly show the globe instead of inheriting the+// wiki book.+// v1.9.35: AD's /command answers in TWO shapes depending on path/version: the relay wraps the+// payload in {output:"<json string>"}, while the loopback direct API can return the payload BARE.+// pup assumed the wrapped shape, so a bare payload silently became "" and every field read as+// undefined — that is why the drag-out drain saw ZERO events while a manual call saw dozens. This+// has now bitten twice (desktop_caption, desktop_ui_events), so parse defensively, once, here.+function abPayload(r) {+  if (!r || typeof r !== 'object') return null;+  let raw = r.output;+  if (raw === undefined || raw === null) {+    // BARE payload: the response IS the payload (no output wrapper).+    return (r.data && typeof r.data === 'object') ? r.data : r;+  }+  if (typeof raw !== 'string') return (raw.data && typeof raw.data === 'object') ? raw.data : raw;+  try { const o = JSON.parse(raw); return (o && o.data && typeof o.data === 'object') ? o.data : o; }+  catch (e) { return null; }+}+let _uiWatchId = null;+let _uiWatchSeq = 0;+let _lastDragCheck = 0;+let _lastReclaim = 0;+// Windows seen unowned but not yet adopted (see the two-strike rule in reclaimUnmanagedWindows).+const _reclaimStrikes = new Map();   // v1.9.42 throttle cursor for the drag-out check+let _uiWatchSupported = null;   // null = unprobed, false = AD too old+async function ensureWindowOpenedWatch() {+  if (process.platform !== 'win32' || _uiWatchSupported === false || _uiWatchId) return;+  try {+    // v1.9.34: REUSE an existing global window-opened watch instead of adding another. Every bridge+    // restart used to subscribe again and never unsubscribe, leaking watches (seen live: w1..w4).+    // Events land in ONE shared ring drained by seq, so any existing watch serves us fine.+    try {+      const ep = abPayload(await chrome.abCommand('desktop_ui_events', { sinceSeq: 0, max: 1 }, { timeoutMs: 5000 }));+      if (ep) {+        const existing = ((ep.activeWatches) || []).find(w => w.event === 'window-opened' && w.scope === 'global');+        if (existing && existing.watchId) {+          _uiWatchId = existing.watchId; _uiWatchSupported = true;+          console.log(`[drag] reusing existing window-opened watch (${_uiWatchId}) — no new subscription`);+          return;+        }+      }+    } catch (e) {}+    const r = await chrome.abCommand('desktop_ui_watch', { event: 'window-opened' }, { timeoutMs: 6000 });+    const raw = r && (typeof r.output === 'string' ? r.output : JSON.stringify(r.output || ''));+    if (_isUnknownVerb(r, raw)) { _uiWatchSupported = false; return; }+    const o = raw ? JSON.parse(raw) : {};+    _uiWatchId = ((o.data || o).watchId) || (o.watchId) || 'w1';+    _uiWatchSupported = true;+    console.log(`[drag] subscribed to window-opened (watchId=${_uiWatchId}) — dragged-out tabs adopt themselves`);+  } catch (e) {}+}+// Which OS window (CDP windowId) is this page in?+// v1.9.216 — RESOLVE A WINDOW'S OS HANDLE WITHOUT ITS TITLE. This is the fix for the taskbar button+// John has asked about repeatedly. Every paint/park/lookup addressed windows by their+// "(session: <id>)" TITLE TAG, and Chrome's PDF viewer cannot carry that tag, so on a PDF window the+// paint had no target and vanished — the composed badge was CORRECT the whole time (dumped and+// verified: PDF sheet + Adom mark), it was simply never delivered. v1.9.212 tried to store the handle+// but only did so when park SUCCEEDED, and park fails on exactly these windows: circular.+//+// Break the circle with facts we already have: pup knows the profile's Chrome PID, and CDP tells us+// the window's exact bounds. Enumerate that PID's top-level windows and match on geometry. Works for+// any document type, needs no title, and the handle is then cached on the session.+// v1.9.278 (John: "why do i see a pup icon overlay over the msedge taskbar icon? you should never+// be touching edge?"). Root cause: pup cached a session's hwnd, the real window changed, the cached+// handle DIED, and Windows RECYCLES hwnd numbers - so that number was reassigned to an Edge window,+// and pup's overlay paint (which trusted the stale cache) landed on Edge. THE GUARANTEE, enforced+// below: pup only ever paints/flashes an hwnd it can PROVE belongs to one of its own browser+// processes. Any other hwnd is refused. pup can never touch Edge (or any app it did not launch).+function pupBrowserPidSet(profileName) {+  const pids = new Set();+  for (const [prof, be] of browsers) {+    if (profileName && prof !== profileName) continue;   // v1.9.287: scope to ONE profile when asked+    try {+      const b = be && be.browser; if (!b) continue;+      if (b._detachedPid) pids.add(Number(b._detachedPid));+      const pr = b.process && b.process(); if (pr && pr.pid) pids.add(Number(pr.pid));+      // v1.9.350: pids confirmed to back a pup window via its unique title tag (see hwndBelongsToPup).+      // Stored on the browser entry so they are discarded with it on browsers.delete — a recycled pid+      // can never keep passing after the process it named is gone.+      if (b._confirmedPids) for (const p of b._confirmedPids) pids.add(Number(p));+    } catch (e) {}+  }+  return pids;+}+// v1.9.350: record a pid proven (by unique pup title) to own a pup window, onto its profile's browser+// entry. Folded into pupBrowserPidSet so a shared-process profile (adom-you) self-heals after the first+// title-confirmed window, and cleared automatically when that browser entry is torn down.+function learnConfirmedPid(profileName, pid) {+  if (!profileName || !pid) return;+  try {+    const be = browsers.get(profileName);+    const b = be && be.browser; if (!b) return;+    if (!b._confirmedPids) b._confirmedPids = new Set();+    b._confirmedPids.add(Number(pid));+  } catch (e) {}+}+async function hwndOwnerPid(hwnd) {+  // 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-desktop 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',+      '--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">&middot; 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 &middot; <b id="cT">0</b> tabs &middot; <b id="cB">0</b> icons &middot; <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 &middot; <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> &middot; <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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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':'')+' &middot; '+esc(g.grouping)+' &middot; 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)+' &middot; '+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)+' &mdash; '+esc(p.profileName||p.profileDir)+'</div>'+        +'<div class="sdesc">'+cnt+' &nbsp; '+_idBadges(p.accounts)+' &nbsp; '+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,'&amp;').replace(/</g,'&lt;'); };+      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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));+                  const TIPS = {+                    thread: 'The ai-thread is the identifier for pup windows: you get one pup window per ai-thread, and pup default-creates tabs in that window for everything the thread manages or needs a browser surface for.',+                    container: 'The cloud container the owning AI thread runs in. That is where the AI driving this window lives.',+                    profile: 'The Chrome profile (cookie jar) this window rides. adom-you is the shared signed-in jar every window defaults to; iso-* jars are throwaway anonymous profiles.',+                    tabs: 'Every tab in this window. The owning thread adds tabs here instead of opening new windows, so one taskbar button holds all its pages.',+                    age: 'How long this window has been open. pup windows persist until their thread (or you) closes them.',+                    last: 'The most recent pup action the owning AI ran against this window, with the note it left.',+                    takeover: 'Another thread claimed this window from its previous owner. Takeovers are logged and only allowed when the user asked.',+                  };+                  let rows = '';+                  const row = (k, label, val, accent) => { rows += `<div class="r" data-tip="${esc(TIPS[k] || '')}"><span class="k">${esc(label)}</span><span class="v${accent ? ' a' : ''}">${esc(val)}</span></div>`; };+                  row('thread', 'ai-thread', d.owner, true);+                  if (d.container) row('container', 'container', d.container);+                  row('profile', 'profile', d.profile);+                  row('age', 'open', d.age);+                  if (d.last) row('last', 'last action', d.last);+                  if (d.takeover) row('takeover', 'takeover', d.takeover);+                  let tabsHtml = '';+                  for (const t of (d.tabs || [])) tabsHtml += `<div class="t${t.active ? ' on' : ''}">${t.active ? '▸ ' : ''}${esc(t.label)}</div>`;+                  const html = `<style>+                    .card{width:340px;max-width:calc(100vw - 48px);background:#0d1117;border:1px solid #30363d;border-radius:10px;box-shadow:0 12px 34px rgba(0,0,0,.55);font:12px/1.45 "Segoe UI",system-ui,sans-serif;color:#c9d1d9;overflow:hidden;user-select:none}+                    .hd{display:flex;align-items:center;gap:8px;padding:8px 10px;background:#161b22;border-bottom:1px solid #30363d;cursor:grab}+                    .hd:active{cursor:grabbing}+                    .grip{color:#8b949e;font-size:13px;letter-spacing:1px}+                    .ttl{font-weight:600;color:#e6edf3;flex:1}+                    .x{cursor:pointer;color:#8b949e;padding:2px 6px;border-radius:5px;font-size:13px}+                    .x:hover{color:#e6edf3;background:#21262d}+                    .bd{padding:8px 10px}+                    .r{display:flex;gap:8px;padding:3px 2px;border-radius:5px}+                    .r:hover{background:#161b22}+                    .k{color:#8b949e;min-width:78px}+                    .v{color:#c9d1d9;flex:1;word-break:break-word}+                    .v.a{color:#00b8b1;font-weight:600}+                    .tabs{margin-top:6px;border-top:1px solid #21262d;padding-top:6px}+                    .t{color:#8b949e;padding:2px 2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+                    .t.on{color:#e6edf3}+                    .teach{margin-top:8px;padding:7px 9px;background:#0b2b29;border:1px solid rgba(0,184,177,.35);border-radius:7px;color:#7fe9e2;font-size:11px}+                    .tip{position:fixed;z-index:2147483647;max-width:260px;background:#161b22;border:1px solid #30363d;border-radius:7px;padding:7px 9px;color:#c9d1d9;font-size:11px;line-height:1.45;box-shadow:0 8px 22px rgba(0,0,0,.5);opacity:0;pointer-events:none;transition:opacity .18s ease;text-transform:none}+                    .tip.show{opacity:1}+                  </style>+                  <div class="card">+                    <div class="hd" data-drag><span class="grip">⠿</span><span class="ttl">Adom Pup: window "${esc(d.sid)}"</span><span class="x" data-x title="Close">✕</span></div>+                    <div class="bd">${rows}<div class="tabs" data-tip="${esc(TIPS.tabs)}">${tabsHtml}</div>+                    <div class="teach">One pup window per ai-thread: the thread named above owns this window, and everything it needs a browser for opens as a tab here.</div></div>+                  </div><div class="tip" data-tip-el></div>`;+                  if (sh) sh.innerHTML = html; else host.innerHTML = html;+                  const root = sh || host;+                  document.documentElement.appendChild(host);+                  // close+                  root.querySelector('[data-x]').addEventListener('click', () => host.remove());+                  // drag by the grip header, clamped to the viewport+                  const hd = root.querySelector('[data-drag]');+                  let dx = 0, dy = 0, dragging = false;+                  hd.addEventListener('pointerdown', (e) => {+                    if (e.target.closest('[data-x]')) return;+                    dragging = true; const r = host.getBoundingClientRect(); dx = e.clientX - r.left; dy = e.clientY - r.top;+                    try { hd.setPointerCapture(e.pointerId); } catch (e2) {}+                  });+                  hd.addEventListener('pointermove', (e) => {+                    if (!dragging) return;+                    const w = host.offsetWidth, h = host.offsetHeight;+                    const L = Math.max(8, Math.min(innerWidth - w - 8, e.clientX - dx));+                    const T = Math.max(8, Math.min(innerHeight - h - 8, e.clientY - dy));+                    host.style.left = L + 'px'; host.style.top = T + 'px'; host.style.right = 'auto';+                  });+                  hd.addEventListener('pointerup', () => { dragging = false; });+                  // tooltip singleton: 600ms delay, clamped to the viewport on BOTH axes+                  const tip = root.querySelector('[data-tip-el]');+                  let tmr = null;+                  root.querySelectorAll('[data-tip]').forEach((el) => {+                    el.addEventListener('mouseenter', (e) => {+                      const txt = el.getAttribute('data-tip');+                      if (!txt) return;+                      clearTimeout(tmr);+                      tmr = setTimeout(() => {+                        tip.textContent = txt;+                        tip.classList.add('show');+                        // measure AFTER content set+                        const tw = tip.offsetWidth, th = tip.offsetHeight, M = 8;+                        let x = e.clientX + 14, y = e.clientY + 14;+                        if (x + tw + M > innerWidth) x = e.clientX - tw - 14;+                        if (y + th + M > innerHeight) y = e.clientY - th - 14;+                        x = Math.max(M, Math.min(innerWidth - tw - M, x));+                        y = Math.max(M, Math.min(innerHeight - th - M, y));+                        tip.style.left = x + 'px'; tip.style.top = y + 'px';+                      }, 600);+                    });+                    el.addEventListener('mouseleave', () => { clearTimeout(tmr); tip.classList.remove('show'); });+                  });+                } catch (e) {}+              }, _cardInfo);+              cardShown = true;+            } catch (e) { console.log(`[window-info] in-page card injection failed (${e.message}) — falling back to the ab caption`); }+          }+          // v2.0.131 (John: "why did you show this caption?"): an AI CALLER querying window info is+          // a DATA request — it must never paint anything on the user's screen. The caption is a+          // USER surface, shown only when the USER clicked the jump-list task (user-taskbar-menu);+          // toolbar clicks get the in-page card above. AI callers get JSON only, opt-in+          // showCaption:true for the rare "identify this window to the user" case. (The incident:+          // an AI called this just to get the hwnd and the user got a 12s caption over their work.)+          const _userInitiated = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+          if (!cardShown && (_userInitiated || args.showCaption === true)) {+            try {+              const r = await chrome.abCommand('desktop_caption', {+                id: `pup-info-${sid}`, text: lines.join('\n'),+                position: 'bottom', size: 'medium', duration: 12000,+              }, { timeoutMs: 8000, caller: sessionCaller(session) });+              captionShown = !(r && r.success === false);+            } catch (e) {}+          }+          // v2.0.131: include the hwnd — the pup_open_window _next text promises "pup_window_info+          // hands you this window's hwnd for desktop_screenshot_window", but the response never+          // carried it, so callers hwnd-hunted anyway (and tripped the caption doing it).+          let _hwnd = null;+          try { _hwnd = session._hwnd ? Number(session._hwnd) : (await sessionHwnd(sid)); } catch (e) {}+          sendJSON(res, {+            success: true, sessionId: sid, owner: ownT, ownerContainer: session._ownerContainer || null,+            profile: session.profileName || null, ageMinutes: ageMin, tabs, lastAgentUpdate: lu,+            hwnd: _hwnd || null,+            foreground: !!session._foreground, lastTakeoverBy: session._lastTakeoverBy || null,+            cardShown, captionShown,+            _hint: cardShown+              ? 'An in-page floaty card with this info is showing in the window (draggable by its grip, hover any row for its explanation, ✕ to close).'+              : (captionShown+                ? 'A native card with this info is showing just above the taskbar for ~12s (click-through, auto-dismisses).'+                : 'Info returned as JSON only — nothing was drawn on the user\'s screen (hwnd included for desktop_screenshot_window when you need the browser chrome in frame). A USER clicking the jump-list/toolbar gets the visual card; an AI caller can opt in with showCaption:true when the user asked to have the window identified.'),+          });+          return;+        }+        // v2.0.130 (#43, ah leg revived on John's "do them all"): MOVE this pup window's page into+        // the user's Hydrogen workspace. Design: docs/PLAN-interactive-surface.md Phase 3. The+        // plan's direct workspace-tabs endpoint (port 47083) is not exposed by today's Hydrogen, so+        // this rides the REAL current API: ab's hd_api -> Hydrogen control API POST /open-url,+        // which routes through the Browser Picker (per-domain memory; webview is the default+        // surface, exactly the "move to my workspace" semantic). On success the pup window closes+        // (its owner initiated the move) unless keepWindow:true. hc (cloud) stays blocked on the+        // workspace-mutation auth ask recorded in the plan.+        case 'pup_move_to_workspace': {+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_move_to_workspace'); if (refusal) { sendJSON(res, refusal); return; } }+          let moveUrl = null;+          try { const at = (session.tabs || []).find(t => t.tabId === session.activeTabId) || (session.tabs || [])[0]; moveUrl = at ? at.page.url() : session.page.url(); } catch (e) {}+          if (!moveUrl || /^(about:|chrome:|edge:|devtools:)/i.test(moveUrl)) {+            sendJSON(res, { success: false, errorCode: 'no_movable_url', error: `This window's active tab has no movable URL (${moveUrl || 'none'}).` });+            return;+          }+          let hd = null;+          try { hd = await chrome.abCommand('hd_api', { method: 'POST', path: '/open-url', body: { url: moveUrl } }, { timeoutMs: 12000, caller: sessionCaller(session) });+          } catch (e) { hd = { success: false, error: e.message }; }+          const hdOk = hd && hd.success !== false && !(hd.body && hd.body.error);+          if (!hdOk) {+            sendJSON(res, { success: false, errorCode: 'hydrogen_unreachable', error: `Hydrogen did not accept the URL: ${(hd && (hd.error || (hd.body && hd.body.error))) || 'unknown error'}`, _hint: 'Moving a pup page to the workspace needs Hydrogen (ah) running on this machine — check hd_status. The pup window was left untouched.' });+            return;+          }+          let windowClosed = false;+          if (args.keepWindow !== true) {+            try {+              const profileName = session.profileName;+              const _closingAppId = session._curAppId || `${PUP_APP_ID}.${resolvedId}`;+              for (const t of [...(session.tabs || [])]) { try { await t.page.close(); } catch (e) {} }+              session.tabs.length = 0; session.activeTabId = null;+              sessions.delete(resolvedId);+              deleteSessionFile(resolvedId);+              unregisterPupAumid(_closingAppId).catch(() => {});+              await killProfileBrowserIfUnused(profileName, 'window closed');+              windowClosed = true;+              setTimeout(() => { try { pruneStaleAumids(); } catch (e) {} }, 3000);+            } catch (e) {}+          }+          console.log(`[move-to-workspace] "${resolvedId}" -> Hydrogen (${moveUrl.slice(0, 80)}) — windowClosed=${windowClosed}`);+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, movedUrl: moveUrl, windowClosed, sessionId: resolvedId,+            _hint: windowClosed+              ? 'The page was handed to Hydrogen (its Browser Picker routes it — webview by default, honoring the user\'s per-domain choice) and this pup window closed. The user picks the surface if the picker prompts.'+              : 'The page was handed to Hydrogen; the pup window was KEPT (keepWindow:true).',+          }) });+          return;+        }+        // v1.9.3 (AD #208): the taskbar jump-list callback — flip THIS wiki window between the+        // logged-in and public view IN PLACE. A view switch = a cookie-jar (userDataDir) switch,+        // fixed at browser launch, so it's a close-and-relaunch of the same sessionId under the+        // other profile at the same URL. Invoked when the user clicks the per-window taskbar+        // right-click task (runs AD's bundled CLI → this verb); it's a USER action, so the window+        // is brought on-screen. Not meant for AI threads (they pass wikiView on pup_open_window).+        case 'pup_wiki_set_view': {+          const sessionId = args.sessionId;+          const _lv = requireLiveSession(res, { ...args, sessionId });+          if (!_lv) return;+          const session = _lv.session;+          let url = null; try { url = session.page.url() || null; } catch (e) {}+          if (!url || !isAdomUrl(url)) { sendJSON(res, { success: false, error: 'wiki view toggle applies only to Adom (adom.inc) URLs', output: JSON.stringify({ ok: false, url }) }); return; }+          const curView = session._wikiView === 'authed' ? 'authed' : 'public';+          // v2.0.139 (John: "add a shortcut key like annotate has"): view:"toggle" flips whatever+          // the window currently shows — the Alt+Shift+V hotkey's form, since the extension can't+          // know the current view. Explicit "authed"/"public" behave as before.+          // v2.0.241 (caught while verifying the guard rewrite): an UNRECOGNISED view was silently+          // coerced to 'public'. A caller asking for "signed-in" got the public view plus a response+          // claiming view:"public", unchanged:true — technically true, completely misleading. Reject+          // it and name the accepted values instead of guessing what the caller meant.+          if (args.view !== undefined && !['authed', 'public', 'toggle'].includes(args.view)) {+            sendJSON(res, { success: false, error: `view must be one of authed|public|toggle (got "${args.view}")`,+              _hint: 'authed = the signed-in view, public = what a logged-out visitor sees, toggle = flip whichever it is now. There is no "signed-in"/"private" alias: this used to fall through to public silently.' });+            return;+          }+          const view = args.view === 'toggle'+            ? (curView === 'authed' ? 'public' : 'authed')+            : (args.view === 'authed' ? 'authed' : 'public');+          const owner = session.owner || null;+          const publicProfile = session._publicProfile || sessionId;+          if (curView === view) {+            // v1.9.332 (John clicked "Switch to logged-in view" and "it did nothing"). pup BELIEVED the+            // window was already in this view, so it only brought it to front — no reload. But the task+            // PROMISES a "harmless reload", and pup's view belief is unreliable (a cookie expired, another+            // window flipped auth, or the initial default-authed never actually signed the page in). So a+            // real USER click ALWAYS re-asserts the requested view: it falls through to the mint/navigate+            // path below, which is a genuine reload that GUARANTEES the page is in that view (for authed it+            // re-mints + navigates, i.e. actually signs it in). Only a PROGRAMMATIC same-view call short-+            // circuits with bringToFront.+            const _userClick = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+            if (!_userClick) {+              session._foreground = true;+              try { await session.page.bringToFront(); } catch (e) {}+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, unchanged: true, url }) });+              return;+            }+            console.log(`[wikiview] "${sessionId}" user clicked "${view}" while pup thought it was already ${curView} — re-asserting the view (harmless reload), NOT skipping (this is why the click "did nothing")`);+            // fall through to the switch logic below, which navigates/asserts the requested view+          }+          // v2.0.132 (adom/wiki#126 — Colby shipped pup's own ask; John: "implement that instead"):+          // THE PRIMARY PATH. ?view=public makes the server render any page GET exactly as a+          // signed-out visitor (request's user nulled; the real session untouched; byte-identical;+          // private pages 404). So the toggle is now a plain IN-PLACE reload of every wiki tab with+          // the param appended (public) or stripped (authed): no relaunch, no cookie wipe, no+          // profile switch, works on the shared jar, and the user just sees tabs reload in their+          // window — exactly what John expected all along. The jar-relaunch machinery below+          // survives only as the fallback for old servers that ignore the param, and for windows+          // still sitting on a legacy iso-* jar (their authed direction falls through to the+          // relaunch home to adom-you).+          if (!/^iso-/.test(session.profileName || '') || view === 'public') {+            try {+              const _wTabs = (session.tabs || []).filter(t => { try { return isAdomUrl(t.page.url()); } catch (e) { return false; } });+              if (_wTabs.length) {+                for (const t of _wTabs) {+                  let tu = null; try { tu = t.page.url(); } catch (e) {}+                  if (!tu) continue;+                  const target = view === 'public' ? wikiPublicUrl(tu) : wikiStripPublicParam(tu);+                  try { await t.page.goto(target, { waitUntil: 'domcontentloaded', timeout: 25000 }); } catch (e) {}+                }+                const _at = _wTabs.find(t => t.tabId === session.activeTabId) || _wTabs[0];+                // v2.0.133 (caught live in verification): checkWikiAuth is a same-origin API fetch+                // that carries the REAL session cookie — which ?view=public deliberately leaves+                // untouched — so it always reports "authed" and made this path conclude the server+                // ignored the param, falling back to the jar relaunch it was built to replace.+                // The param changes the RENDER, so probe the RENDER: a logged-out page shows the+                // Login button; a signed-in one shows the profile menu instead.+                let _renderLoggedOut = null;+                try {+                  _renderLoggedOut = await Promise.race([+                    _at.page.evaluate(() => { try { return [...document.querySelectorAll('a,button')].some(e => /^\s*Login\s*$/i.test(e.textContent || '')); } catch (e) { return null; } }),+                    new Promise(r => setTimeout(() => r(null), 6000)),   // covers the bounded load wait above+                  ]);+                } catch (e) {}+                let _a = null;+                if (view === 'authed') { try { _a = await checkWikiAuth(_at.page); } catch (e) {} }+                const _renderedAuthed = view === 'authed' ? !!(_a && _a.authed) : false;+                // v2.0.134 (John: "you're STILL resizing the window on me"): the public direction+                // NEVER falls back to the jar relaunch anymore. The old guard probed the header for+                // the Login button right after domcontentloaded — when the header rendered a beat+                // late the probe missed, wrongly concluded "server ignored the param", and+                // relaunched the window (the resize John kept seeing). The param is live on prod+                // and verified; a possibly-late header paint is never worth destroying the user's+                // window over. The probe survives for LOGGING only.+                if (view === 'public' && _renderLoggedOut === false) {+                  console.log(`[wikiview] "${sessionId}" header probe did not see the logged-out marker yet (late paint or old server) — TRUSTING ?view=public, staying in place (no relaunch, by design)`);+                }+                if (view === 'authed' && !_renderedAuthed) {+                  // Param stripped but the jar holds no live wiki session — continue into the+                  // existing mint / auto-sign / manual-login machinery below with a clean URL.+                  console.log(`[wikiview] "${sessionId}" param strip left the window logged out (no live session) — continuing to the sign-in path`);+                  url = wikiStripPublicParam(url);+                } else {+                  session._wikiView = view;+                  try { await setWikiAuthedState(session, sessionId, view === 'authed', {}); } catch (e) {}+                  try { await setWikiModeGlyph(_at.page, view === 'authed' ? '● Logged in' : '○ Public'); } catch (e) {}+                  session._wikiJumplistKey = undefined;+                  updateWikiJumplist(session, sessionId).catch(() => {});+                  updateThumbnailTooltip(session, sessionId).catch(() => {});+                  const _userClickP = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+                  if (_userClickP) {+                    session._foreground = true;+                    try { await session.page.bringToFront(); } catch (e) {}+                    try { await osRaiseSessionWindow(session, sessionId, 'you clicked the taskbar view switch'); } catch (e) {}+                  }+                  wikiCaption(view === 'authed'+                    ? `Adom wiki: now the logged-in view${_a && _a.user ? ` (${_a.user})` : ''}`+                    : '\u{1F4D6} Adom wiki: now the PUBLIC view — the server rendered it exactly as a signed-out visitor sees it', 6000).catch(() => {});+                  console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE via ?view=public (adom/wiki#126) — ${_wTabs.length} tab(s) reloaded, no relaunch, no profile switch`);+                  sendJSON(res, { success: true, output: JSON.stringify({+                    ok: true, view, inPlace: true, serverParam: true, tabsReloaded: _wTabs.length,+                    url: (() => { try { return _at.page.url(); } catch (e) { return null; } })(),+                    _hint: view === 'public'+                      ? 'PUBLIC view via the server-side ?view=public param (adom/wiki#126): the server renders exactly what a signed-out visitor sees while the window stays on the shared profile — session untouched, no relaunch, no isolated jar. Verify a publish leaked nothing, then flip back any time.'+                      : 'Logged-in view restored by stripping ?view=public — the shared-profile session was untouched throughout, so no re-login was needed.',+                  }) });+                  return;+                }+              }+            } catch (e) { console.log(`[wikiview] "${sessionId}" param path failed (${e.message}) — falling back to the legacy machinery`); }+          }+          // v1.9.63 — IN-PLACE view switch, no close-and-relaunch.+          //+          // A wiki session cookie is per-PROFILE and a profile's userDataDir is fixed at Chrome+          // launch, which is why this used to tear the window down and relaunch it under the other+          // profile (the thing John watched vanish and never come back). Colby's browser-session+          // endpoint removes that: an ALREADY-LOGGED-IN page can mint its own single-use magic link+          // with nothing but its session cookie —+          //     POST /api/v1/auth/browser-session {next:"/adom/page"} -> {url, expires_in:60}+          // so the BRIDGE can mint on the desktop (no container token, no adom-wiki CLI) and just+          // navigate this window. The other direction is a cookie wipe plus a plain navigate. Either+          // way it is one hop and the window/tabs are never destroyed.+          //+          // Guard: only wipe cookies when this window owns its profile. The shared authed profile+          // backs OTHER windows too and clearing it would silently log them all out — fall through+          // to the legacy relaunch in that case.+          // v2.0.48 (John: "what should happen when i click this if there are multiple tabs in that+          // window?"): the unit is the WINDOW — but cookies are per PROFILE and CDP's+          // Network.clearBrowserCookies is BROWSER-wide, so on a SHARED profile the in-place public+          // switch wiped EVERY thread's cookies (silent global logout) and reloaded only one tab.+          // The old guard tested the LEGACY profile name (adom-wiki-authed) and never matched+          // today's shared adom-you. Ground truth instead: the profile is shared if ANY other live+          // session rides it (plus the known shared names). A shared-profile public switch now takes+          // the tab-preserving RELAUNCH path: the whole window (all tabs) moves to a logged-out+          // profile; every other window keeps its login.+          let _profileShared = /adom-wiki-authed|adom-you/i.test(session.profileName || '');+          try { for (const [k5, s5] of sessions) { if (k5 !== sessionId && s5 && s5.profileName === session.profileName) { _profileShared = true; break; } } } catch (e) {}+          // v2.0.57 (John round-tripped the toggle: authed direction signed in the THROWAWAY+          // profile instead of coming home): in-place authed is only correct when the window is+          // NOT on a toggle-created iso-* jar. From a throwaway, the authed direction takes the+          // tab-preserving relaunch back to the shared adom-you jar, so the round trip is+          // symmetric and logins land where they enrich the shared credential store.+          const _canInPlace = (view === 'authed') ? !/^iso-/.test(session.profileName || '') : !_profileShared;+          if (_canInPlace) {+            try {+              let targetUrl = null;+              if (view === 'authed') {+                // Mint from ANY currently-authenticated pup page. Single-use + 60s TTL, so mint+                // immediately before navigating and never cache it (a live link IS a credential).+                let minter = null;+                for (const [, s2] of sessions) {+                  for (const t2 of (s2.tabs || [])) {+                    try {+                      if (!isAdomUrl(t2.page.url())) continue;+                      const a2 = await checkWikiAuth(t2.page);+                      if (a2 && a2.authed) { minter = t2.page; break; }+                    } catch (e) {}+                  }+                  if (minter) break;+                }+                if (minter) {+                  const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return '/'; } })();+                  targetUrl = await minter.evaluate(async (np) => {+                    try {+                      const r = await fetch('/api/v1/auth/browser-session', {+                        method: 'POST', credentials: 'same-origin',+                        headers: { 'content-type': 'application/json' },+                        body: JSON.stringify({ next: np }),+                      });+                      if (!r.ok) return null;+                      const j = await r.json();+                      if (!j || !j.url) return null;+                      // The endpoint does NOT honour a `next` in the body or query — it only ever+                      // returns `?ticket=...`. The CLI bakes the deep-link by APPENDING `&next=<path>`+                      // client-side, so do the same or the redeem lands on the wiki ROOT and the user+                      // loses the page they were on (measured: toggled to authed and landed on "/").+                      return j.url + '&next=' + encodeURIComponent(np);+                    } catch (e) { return null; }+                  }, nextPath);+                }+                if (!targetUrl) {+                  // v1.9.88 — AUTO-SIGN from AD's stored identity BEFORE ever asking the human. No+                  // already-authed pup page exists to mint from, but AD wrote the user's wiki session+                  // token to ~/.adom/session.json on THIS desktop, so pup mints the magic link from+                  // that token server-side and navigates this window straight to it. The user is asked+                  // for nothing; the vault profile then persists it ~30 days. Manual /login is only the+                  // fallback when AD itself is signed out (no token) or the ~30-day session expired.+                  const nextPath = (() => { try { const u2 = new URL(url); return u2.pathname + u2.search; } catch (e) { return "/adom"; } })();+                  const _autoWho = await autoSignWikiFromDesktop(session.page, nextPath);+                  if (_autoWho) {+                    session._wikiView = "authed";+                    try { await setWikiModeGlyph(session.page, "● Logged in"); } catch (e) {}+                    try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}+                    session._wikiJumplistKey = undefined;+                    updateWikiJumplist(session, sessionId).catch(() => {});+                    updateThumbnailTooltip(session, sessionId).catch(() => {});+                    session._foreground = true;+                    try { await session.page.bringToFront(); } catch (e) {}+                    try { await osRaiseSessionWindow(session, sessionId, 'you asked for the signed-in wiki view; you were signed in automatically'); } catch (e) {}+                    wikiCaption(`Adom wiki: signed you in automatically${_autoWho && _autoWho !== 'authed' ? ` as ${_autoWho}` : ''}, no login needed`, 6000).catch(() => {});+                    console.log(`[wikiview] "${sessionId}" AUTO-SIGNED from AD desktop token (${_autoWho}) — no manual login`);+                    sendJSON(res, { success: true, output: JSON.stringify({+                      ok: true, view: "authed", autoSigned: true, url: session.page.url(),+                      _hint: "pup signed this window into the Adom wiki AUTOMATICALLY using the identity Bridge already holds on this desktop (~/.adom/session.json) — no manual login. The shared vault profile now carries the session, so every thread's authed view works and it persists ~30 days.",+                    }) });+                    return;+                  }+                  // Fall back to the manual sign-in page (AD is signed out, or the session expired).+                  const loginUrl = "https://wiki.adom.inc/login?redirect=" + encodeURIComponent(nextPath);+                  console.log(`[wikiview] "${sessionId}" no authed session yet — navigating THIS window to the sign-in page: ${loginUrl}`);+                  await session.page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 25000 });+                  session._wikiView = "authed";+                  session._foreground = true;+                  try { await session.page.bringToFront(); } catch (e) {}+                  try { await osRaiseSessionWindow(session, sessionId, 'you clicked Sign in: finish the one-time wiki sign-in here'); } catch (e) {}   // user clicked the task — SHOW them the sign-in page+                  try { await setWikiModeGlyph(session.page, "○ Public"); } catch (e) {}+                  wikiCaption("\u{1F513} Adom wiki: finish the one-time sign-in HERE — you'll come back to your page signed in", 9000).catch(() => {});+                  // The hydrogen SSO often AUTO-COMPLETES: if the user's SSO session is already alive+                  // (John's was), /login bounces straight back to the wiki page already signed in, with+                  // no form to fill. In that case we must correct the glyph/tab-label/category-icon to+                  // the logged-in state — the caption above said "finish the sign-in" but there was+                  // nothing to finish. Re-check in the background (the redirect chain needs a beat) and+                  // flip everything if we landed authed; if the form is still showing, leave it be.+                  (async () => {+                    // Poll for up to ~90s: covers BOTH the instant SSO auto-complete AND a real+                    // manual sign-in (typing credentials + 2FA takes far longer than a few seconds).+                    // Stops the moment we detect authed; a checkWikiAuth is a cheap same-origin fetch,+                    // and it only returns authed once the window has redirected back to the wiki as the+                    // signed-in user, so this is effectively "wait until sign-in completes, then flip".+                    for (let i = 0; i < 45; i++) {+                      await new Promise(r => setTimeout(r, 2000));+                      if (!sessions.has(sessionId)) return;   // window closed mid-sign-in — stop+                      let a = null; try { a = await checkWikiAuth(session.page); } catch (e) {}+                      if (a && a.authed) {+                        try { await setWikiModeGlyph(session.page, "● Logged in"); } catch (e) {}+                        try { await setWikiAuthedState(session, sessionId, true); } catch (e) {}+                        session._wikiJumplistKey = undefined;+                        updateWikiJumplist(session, sessionId).catch(() => {});+                        updateThumbnailTooltip(session, sessionId).catch(() => {});+                        wikiCaption(`Adom wiki: signed in${a.user ? ` as ${a.user}` : ""} — this is now the logged-in view`, 6000).catch(() => {});+                        console.log(`[wikiview] "${sessionId}" SSO auto-completed on the sign-in nav — flipped to logged-in (${a.user || "authed"})`);+                        return;+                      }+                    }+                  })().catch(() => {});+                  sendJSON(res, { success: true, output: JSON.stringify({+                    ok: true, view: "authed", signInStarted: true, loginUrl, url: session.page.url(),+                    _hint: "No wiki session existed yet, so pup navigated THIS window to the Adom wiki sign-in page (hydrogen SSO) and raised it. The user completes the one-time sign-in and is returned to their wiki page signed in; after that every thread's logged-in view works, because a signed-in window can mint the magic link. This is a USER action (they clicked the taskbar task), so foregrounding is expected.",+                  }) });+                  return;+                }+              } else {+                try {+                  const cdp = await session.page.target().createCDPSession();+                  await cdp.send('Network.clearBrowserCookies');+                  await cdp.detach();+                } catch (e) {}+                targetUrl = url;+              }+              if (targetUrl) {+                await session.page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 25000 });+                session._wikiView = view;+                const a3 = await checkWikiAuth(session.page).catch(() => ({ authed: false }));+                await setWikiAuthedState(session, sessionId, !!(a3 && a3.authed), { unknown: !!(a3 && a3.unknown) });+                try { await setWikiModeGlyph(session.page, (a3 && a3.authed) ? '● Logged in' : '○ Public'); } catch (e) {}+                session._wikiJumplistKey = undefined;   // the toggle's own label flips+                updateWikiJumplist(session, sessionId).catch(() => {});+                updateThumbnailTooltip(session, sessionId).catch(() => {});+                session._foreground = true;+                try { await session.page.bringToFront(); } catch (e) {}+                console.log(`[wikiview] "${sessionId}" -> ${view} IN PLACE (authed=${!!(a3 && a3.authed)}) — no relaunch`);+                sendJSON(res, { success: true, output: JSON.stringify({ ok: true, view, inPlace: true, authed: !!(a3 && a3.authed), url: session.page.url() }) });+                return;+              }+            } catch (e) { console.log(`[wikiview] "${sessionId}" in-place switch failed (${e.message}) — falling back to relaunch`); }+          }++          // v1.9.29 BUG FIX: carry EVERY tab across the switch. The old code relaunched only the+          // active tab's url and closed the rest, silently destroying the user's other tabs (John+          // lost a 5-tab research window to one jump-list click). Snapshot all urls + which was+          // active BEFORE the teardown, then restore them into the relaunched window.+          const _tabUrls = [];+          let _activeIdx = 0;+          try {+            (session.tabs || []).forEach((t, i) => {+              let u = null; try { u = t.page.url(); } catch (e) {}+              if (u && !/^(about:|chrome:|edge:|devtools:)/i.test(u)) {+                if (t.tabId === session.activeTabId) _activeIdx = _tabUrls.length;+                _tabUrls.push(u);+              }+            });+          } catch (e) {}+          if (!_tabUrls.length) _tabUrls.push(url);+          console.log(`[wiki] view toggle "${sessionId}": ${curView} → ${view} (${_tabUrls.length} tab(s), active #${_activeIdx + 1})`);+          // v1.9.9: tell the user IMMEDIATELY that their click registered — the relaunch takes a+          // few seconds and silence reads as "it did nothing". Replaced by the result caption below.+          const _switchMsg = view === 'authed'+            ? '\u{1F513} Switching to the LOGGED-IN view of the Adom wiki…'+            : '\u{1F4D6} Switching to the PUBLIC (logged-out) view of the Adom wiki…';+          // v1.9.11: OS caption (AD >=1.9.144). Fire-and-forget: the old window is torn down+          // moments later, so an OS-level surface is the ONLY thing that survives the switch.+          wikiCaption(_switchMsg, 30000).catch(() => {});+          const oldProfile = session.profileName;+          const _closingAppId = session._curAppId || `${PUP_APP_ID}.${sessionId}`;+          const newProfile = view === 'authed' ? DEFAULT_PROFILE : ('iso-' + sessionId + '-pub');   // v1.9.162: authed = the ONE shared jar (accumulate); public = a clean logged-out jar+          // v2.0.130 (#45, traced root cause): the close-first relaunch DROPPED THE FOREGROUND LOCK.+          // The moment the old window died, Windows handed foreground to the next app (the editor),+          // and by raise time SetForegroundWindow was denied — so a toggle clicked on a FOREGROUND+          // window came back in the background no matter how many raises we retried (2.0.57's+          // delayed second raise lost the same way). CREATE-THEN-SWAP instead: launch the+          // replacement under a temp id, restore all tabs, raise it WHILE the old window still+          // holds foreground (same-app focus handoff is permitted), THEN close the old window and+          // rename the temp session to the original id (owner, hwnd claim, files, jump list,+          // AUMID). Only when the original window was actually foreground AND this is a+          // USER_CALLERS click — AI-driven toggles keep background semantics. Bonus: the+          // zero-window dead-air gap during the switch is gone.+          let _wasForeground = false;+          if (process.platform === 'win32') {+            try {+              const fgH = await osGetForegroundWindow();+              const myH = await sessionHwnd(sessionId);+              _wasForeground = !!(fgH && myH && Number(fgH) === Number(myH));+            } catch (e) {}+          }+          const _userClickSwap = USER_CALLERS.has(_callerThread) || USER_CALLERS.has(args.caller && args.caller.aiThread);+          if (_wasForeground && _userClickSwap) {+            const tempId = sessionId + '.swap';+            try {+              const sNew = await launchSession(tempId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true });+              for (let i = 0; i < _tabUrls.length; i++) {+                if (i === _activeIdx) continue;+                try { await addTabToSession(tempId, _tabUrls[i]); } catch (e) {}+              }+              try { const sT = sessions.get(tempId); const back = sT && sT.tabs && sT.tabs[0]; if (back) { sT.activeTabId = back.tabId; await back.page.bringToFront(); } } catch (e) {}+              // Raise the replacement WHILE we still own foreground via the old window.+              sNew._foreground = true;+              try { await osRaiseSessionWindow(sNew, tempId, 'you clicked the taskbar view switch — handing focus to the replacement window'); } catch (e) {}+              // Retire the old window (foreground stays with our replacement — same-app handoff).+              for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }+              session.tabs.length = 0; session.activeTabId = null;+              sessions.delete(sessionId);+              deleteSessionFile(sessionId);+              unregisterPupAumid(_closingAppId).catch(() => {});+              await killProfileBrowserIfUnused(oldProfile, 'profile steer: old window retired');+              // RENAME temp -> original id: map, files, hwnd claim. The periodic brand sweep ++              // title injector key off the map id, so titles/badges restamp to the original id.+              sessions.delete(tempId);+              sessions.set(sessionId, sNew);+              try { deleteSessionFile(tempId); } catch (e) {}+              try { saveSessionFile(sessionId, newProfile, (() => { try { return sNew.page.url(); } catch (e) { return url; } })()); } catch (e) {}+              try { if (sNew._hwnd) persistSessionHwnd(sessionId, sNew._hwnd); } catch (e) {}+              sNew._wikiView = view;+              if (view === 'public') sNew._publicProfile = newProfile;+              sNew._wikiJumplistView = undefined;+              sNew._wikiJumplistKey = undefined;+              try { await osRaiseSessionWindow(sNew, sessionId, 'finishing the view switch you clicked (create-then-swap)'); } catch (e) {}+              sNew._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked switch to ${view} on a foreground window (create-then-swap)` };+              try { recordAgentUpdate(sNew, 'pup_wiki_set_view', { updateNote: `wiki view → ${view} (create-then-swap, foreground preserved)` }, sNew.activeTabId); } catch (e) {}+              try { flashViaAD(sessionId, {}); } catch (e) {}+              try { await setWikiModeGlyph(sNew.page, view === 'authed' ? '● Logged in' : '○ Public'); } catch (e) {}+              console.log(`[wikiview] "${sessionId}" -> ${view} via CREATE-THEN-SWAP (window was foreground; replacement raised before the old one closed)`);+              sendJSON(res, { success: true, output: JSON.stringify({+                ok: true, view, url, foregrounded: true, swap: true,+                _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view. It was FOREGROUND when the user clicked, so pup used create-then-swap: the replacement was raised while the old window still held focus, then swapped in under the same id — the user never lost the window.`,+              }) });+              const s2 = sNew;   // background cosmetics below expect s2+              (async () => {+                if (process.platform === 'win32') {+                  await new Promise(r => setTimeout(r, 1500));+                  try { await brandPupWindow(sessionId); } catch (e) {}+                  try { await applyAppOverlay(s2, sessionId); } catch (e) {}+                  try { await updateWikiJumplist(s2, sessionId); } catch (e) {}+                }+                if (view === 'authed') {+                  await new Promise(r => setTimeout(r, 300));+                  let a = null;+                  try { a = await checkWikiAuth(s2.page); } catch (e) {}+                  try { await setWikiModeGlyph(s2.page, (a && a.authed) ? '● Logged in' : '○ Public'); } catch (e) {}+                  try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}+                  const _doneMsg = a && a.authed+                    ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`+                    : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in — sign in in this window';+                  wikiCaption(_doneMsg, 6000).catch(() => {});+                } else {+                  try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}+                  wikiCaption('\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view — what the world sees', 6000).catch(() => {});+                }+              })().catch(() => {});+              return;+            } catch (e) {+              console.log(`[wikiview] "${sessionId}" create-then-swap failed (${e.message}) — falling back to the legacy close-first relaunch`);+              // Clean up a half-launched temp session so the legacy path starts clean.+              try {+                const sT = sessions.get(tempId);+                if (sT) { for (const t of [...(sT.tabs || [])]) { try { await t.page.close(); } catch (e2) {} } sessions.delete(tempId); deleteSessionFile(tempId); }+              } catch (e2) {}+              // If the old window survived (launch failed before teardown), the legacy path below+              // re-does the close correctly; if it did not, the close loop no-ops.+            }+          }+          // Close the current window (mirror pup_close_window) so we can relaunch under the+          // other cookie jar.+          if (sessions.has(sessionId)) {+            for (const t of [...session.tabs]) { try { await t.page.close(); } catch (e) {} }+            session.tabs.length = 0; session.activeTabId = null;+            sessions.delete(sessionId);+            deleteSessionFile(sessionId);+            unregisterPupAumid(_closingAppId).catch(() => {});+            await killProfileBrowserIfUnused(oldProfile, 'profile switch');+          }+          // Relaunch the SAME sessionId under the new profile at the same URL.+          let s2 = null;+          try { s2 = await launchSession(sessionId, _tabUrls[_activeIdx] || url, { profile: newProfile, owner, takeover: true, foreground: true }); }+          catch (e) { sendJSON(res, { success: false, error: `wiki view relaunch failed: ${e.message}` }); return; }+          // v1.9.29: restore the OTHER tabs (in their original order) into the relaunched window.+          for (let i = 0; i < _tabUrls.length; i++) {+            if (i === _activeIdx) continue;+            try { await addTabToSession(sessionId, _tabUrls[i]); } catch (e) {}+          }+          // Re-activate the tab the user was actually on.+          try {+            const s3 = sessions.get(sessionId);+            const back = (s3 && s3.tabs && s3.tabs[0]) || null;+            if (back) { s3.activeTabId = back.tabId; await back.page.bringToFront(); }+          } catch (e) {}+          s2._wikiView = view;+          if (view === 'public') s2._publicProfile = newProfile;+          s2._foreground = true;+          s2._wikiJumplistView = undefined;+          s2._wikiJumplistKey = undefined;   // v1.9.62: the key is what gates the commit now+          // v1.9.7: the user clicked a per-window taskbar task → REALLY pop it to the foreground+          // (full OS SetForegroundWindow raise, not the old CDP-only maximize that left it behind+          // the user's active window — the reason John "never saw the switch work"). Plus a taskbar+          // flash as a belt-and-suspenders notice in case Windows' foreground lock denies the raise.+          await osRaiseSessionWindow(s2, sessionId, 'you clicked the taskbar menu to switch this wiki view');+          // v2.0.57 (John: "it re-opened in the bg which didn't make sense, I was in the fg"):+          // one delayed second raise — the first can lose the foreground race while the new+          // window is still materializing.+          setTimeout(() => { try { const s4 = sessions.get(sessionId); if (s4) osRaiseSessionWindow(s4, sessionId, 'finishing the view switch you clicked').catch(() => {}); } catch (e) {} }, 2500);+          // v1.9.29 BUG FIX: VERIFY the window actually came back. John clicked the toggle, the old+          // window closed, captions fired, and NO window returned: the relaunch had happened but the+          // window stayed unfindable/off-screen (the identity stamp logged "No visible window"),+          // leaving him with nothing. A switch that cannot show its window is a FAILED switch, so+          // confirm the shell can see it and self-heal before reporting success.+          let _visible = false;+          for (let i = 0; i < 8 && !_visible; i++) {+            try {+              const fr = await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))});+              const fraw = fr && (typeof fr.output === 'string' ? fr.output : JSON.stringify(fr.output || ''));+              const fo = fraw ? JSON.parse(fraw) : {};+              _visible = !!((fo.data || fo).best);+            } catch (e) {}+            if (!_visible) {+              try { await healOffscreenWindow(s2, sessionId); } catch (e) {}+              try { await osRaiseSessionWindow(s2, sessionId, 'retrying: bringing the wiki window you clicked back on-screen'); } catch (e) {}+              await new Promise(r => setTimeout(r, 700 * (i + 1)));+            }+          }+          if (!_visible) {+            console.log(`[wiki] "${sessionId}" switched but the window never became visible — telling the user`);+            wikiCaption('Adom wiki: the switch finished but the window did not come back on screen. Click the Adom Pup taskbar button to raise it.', 15000).catch(() => {});+          }+          try { flashViaAD(sessionId, {}); } catch (e) {}+          s2._lastForeground = { ts: Date.now(), verb: 'pup_wiki_set_view', reason: `user clicked the taskbar "switch to ${view} view" task` };+          try { recordAgentUpdate(s2, 'pup_wiki_set_view', { updateNote: `wiki view → ${view} (user clicked taskbar task)` }, s2.activeTabId); } catch (e) {}+          // v1.9.7: set an OPTIMISTIC glyph immediately (the window is on the authed profile, and the+          // common case IS logged in) so the tab reflects the switch the instant it pops, then+          // RESPOND — the window is already raised. Everything below runs in the BACKGROUND so a+          // click never waits on cosmetics: branding, favicon overlay, jump list, AND the auth+          // RE-check that corrects the glyph once the authed cookies/SPA settle (the first check+          // races the page load, so a logged-in window was briefly mislabeling "📖 Public" — John+          // saw exactly this).+          try { await setWikiModeGlyph(s2.page, view === 'authed' ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, view, url, foregrounded: true,+            _hint: `This window is now the ${view === 'authed' ? 'LOGGED-IN' : 'PUBLIC'} view (auth is re-confirmed a moment later; the tab label self-corrects if the ~30-day login has expired).`,+          }) });+          (async () => {+            if (process.platform === 'win32') {+              // v1.9.10: WAIT for the title suffix "(session: <id>)" to exist before branding —+              // branding immediately raced the title injector and AD found "No visible window+              // with title containing (session: wiki-demo)" ([badge] FAILED in the log).+              await new Promise(r => setTimeout(r, 1500));+              try { await brandPupWindow(sessionId); } catch (e) {}+              try { await applyAppOverlay(s2, sessionId); } catch (e) {}+              try { await updateWikiJumplist(s2, sessionId); } catch (e) {}+            }+            if (view === 'authed') {+              await new Promise(r => setTimeout(r, 300));+              let a = null;+              try { a = await checkWikiAuth(s2.page); } catch (e) {}+              try { await setWikiModeGlyph(s2.page, (a && a.authed) ? '\u25CF Logged in' : '\u25CB Public'); } catch (e) {}+              try { await setWikiAuthedState(s2, sessionId, a && a.authed, { unknown: !!(a && a.unknown) }); } catch (e) {}+              // v1.9.9: result caption — names WHO you're signed in as (John's ask), or says the+              // one-time sign-in is still needed. Replaces the "Switching…" caption (same id).+              const _doneMsg = a && a.authed+                ? `Adom wiki: now the logged-in view${a.user ? ` (${a.user})` : ''}`+                : '\u{1F513} Adom wiki: logged-in view needs the one-time sign-in — sign in in this window';+              wikiCaption(_doneMsg, 6000).catch(() => {});+            } else {+              try { await setWikiAuthedState(s2, sessionId, false); } catch (e) {}+              const _doneMsg = '\u{1F4D6} Adom wiki: now the PUBLIC (logged-out) view — what the world sees';+              wikiCaption(_doneMsg, 6000).catch(() => {});+            }+          })().catch(() => {});+          return;+        }++        // --- Tab-level commands ---+        case 'pup_open_tab': {+          // v1.9.146: explicit sessionId wins; otherwise the CALLER THREAD's own most-recent window —+          // NEVER the global activeSessionId (that is another thread's window as often as not).+          const sessionId = args.sessionId || callerOwnWindow(_callerThread);+          if (!sessionId || !sessions.has(sessionId)) {+            const mine = _callerThread ? [...sessions].filter(([,s]) => s._ownerThread === _callerThread).map(([id]) => id) : [];+            sendJSON(res, { success: false, errorCode: 'no_target_window',+              error: `No window to add a tab to. ${args.sessionId ? `Session "${args.sessionId}" not found.` : (_callerThread ? `Your thread ("${_callerThread}") owns no open pup window yet.` : `No sessionId given and your thread sent no caller identity.`)} Open one with pup_open_window first (pass a sessionId you'll reuse), then pup_open_tab into it.`,+              yourWindows: mine });+            return;+          }+          // Guard: refuse to add a tab to a window owned by a DIFFERENT thread unless it was named+          // explicitly (a deliberate cross-thread action), so a stray call can't contaminate.+          {+            const _tgt = sessions.get(sessionId);+            if (_tgt && _tgt._ownerThread && _callerThread && _tgt._ownerThread !== _callerThread && !args.takeover) {+              sendJSON(res, { success: false, errorCode: 'session_owned_by_another_thread',+                error: `Window "${sessionId}" is owned by thread "${_tgt._ownerThread}", not "${_callerThread}". Opening a tab here would land in another agent's window. Open your OWN window (pup_open_window) or pass takeover:true if you truly mean to use theirs.` });+              return;+            }+          }+          try {+            const tabId = await addTabToSession(sessionId, args.url);+            const session = sessions.get(sessionId);+            const tab = getTabById(session, tabId);+            recordAgentUpdate(session, 'pup_open_tab', args, tabId); // v1.8.23+            scheduleAutoFlash(session, { silent: !!args.silent });+            updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80+            // v1.8.57: long-lived-app touchpoint (see switch_tab) — heal a stranded window,+            // re-badge debounced (v1.8.58: only debounce on SUCCESS so failures retry).+            if (process.platform === 'win32') {+              // v1.8.67: a new tab's first paint can raise its window — re-assert (force:false+              // skips a user-held window).+              if (!session._foreground) reassertBottomAfterNav(sessionId).catch(() => {});+              healOffscreenWindow(session, sessionId).catch(() => {});+              const now = Date.now();+              if (!session._badgedAt || now - session._badgedAt > 600000) {+                brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});+              }+            }+            // v1.9.110 (closing the set-enumeration gap): RENDER_VERBS has four members and I had wired+            // three. A tab is content on the user's screen exactly like a window, so it gets the same+            // verdict — otherwise "open it in a tab" silently skips the check that "open it in a window"+            // performs. When a behavior belongs to a declared SET, every member gets it.+            try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}+            try { await autoLoginOnLoad(res, tab.page); } catch (e) {}+            try {+              const _rc = res._renderCheck;+              if (_rc && _rc.ok === false) console.log(`[render] "${sessionId}" tab ${tabId} BROKEN: ${_rc.signals.join(', ')}`);+            } catch (e) {}+            const info = await describeTab(tab, session.activeTabId);+            // v1.8.23: return tabId + tabCount + activeTabId at the TOP level (was only inside `info`).+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, tabId, tabCount: session.tabs.length, activeTabId: session.activeTabId, ...info }) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_render_html': {+          // v1.8.23: render a local HTML string in the tab via page.setContent — which+          // handles encoding correctly (UTF-8), unlike a data: URL that mojibakes accented+          // chars without an explicit charset. Pass {html}. Great for previewing generated+          // markup without writing a temp file or fighting data:-URL escaping.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          if (typeof args.html !== 'string') {+            sendJSON(res, { success: false, error: 'pup_render_html requires an html string.', _hint: 'e.g. {"sessionId":"...","html":"<h1>Café</h1>"} — setContent renders it as UTF-8 (a data: URL would mojibake the é).' });+            return;+          }+          try {+            await withTimeout(tab.page.setContent(args.html, { waitUntil: args.waitUntil || 'domcontentloaded', timeout: 20000 }), 25_000, 'page.setContent');+            recordAgentUpdate(session, 'pup_render_html', args, tabId);+            scheduleAutoFlash(session, { silent: !!args.silent });+            let title = null; try { title = await tab.page.title(); } catch {}+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, title, bytes: args.html.length, _hint: 'Rendered your HTML in the tab (UTF-8, no data:-URL mojibake). Screenshot it or drive it like any page.' }) });+          } catch (e) { sendJSON(res, { success: false, error: `pup_render_html failed: ${e.message}` }); }+          return;+        }+        case 'pup_switch_tab': {+          const _live = requireLiveSession(res, args);+          if (!_live) return;+          const { sessionId, session } = _live;+          const tab = getTabById(session, args.tabId);+          if (!tab) {+            const available = session.tabs.map(t => t.tabId).join(', ');+            sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });+            return;+          }+          session.activeTabId = tab.tabId;+          // v2.0.272 (#69, reported against 2.0.269): THIS VERB USED TO LIE ON EVERY BACKGROUND+          // WINDOW. `page.bringToFront()` is the only thing that actually switches Chrome's visible+          // tab, and it was gated on `session._foreground`. pup windows are background BY DEFAULT,+          // so the normal case updated pup's own `activeTabId` bookkeeping, never told Chrome, and+          // still returned success:true — while pup_list_tabs (which reads Chrome's real state)+          // kept reporting the old tab. Two verbs disagreeing, with the honest one looking broken.+          // The v1.8.26 rationale was "only RAISE THE OS WINDOW for a session the user is watching",+          // which conflated two different things: activating a TAB inside a window is not the same+          // as raising that window. The legitimate worry underneath is real though — Target.activate+          // can pull the window forward — so do the activation ALWAYS and hand the user's focus+          // straight back, exactly the one-shot handback used at window birth (v2.0.259). Never a+          // timer, never a loop; the window itself is never moved.+          let _fgBefore = null;+          try { if (!session._foreground && process.platform === 'win32') _fgBefore = await osGetForegroundWindow(); } catch (e) {}+          try { await tab.page.bringToFront(); } catch (e) {}+          if (_fgBefore) {+            try {+              const _after = await osGetForegroundWindow();+              if (_after && String(_after) !== String(_fgBefore)) {+                console.log(`[switch-tab] "${sessionId}" activating ${tab.tabId} pulled the window forward — handing focus back to ${_fgBefore}`);+                await chrome.abCommand('desktop_bring_to_front', { hwnd: Number(_fgBefore) }, { timeoutMs: 4000 });+              }+            } catch (e) {}+          }+          // v1.8.57: switch_tab is a long-lived app's hot path (adom-shotlog switches its+          // shared window's tabs for hours) — self-heal a stranded window + re-badge here+          // (debounced) so the window stays clickable/badged across bridge restarts.+          if (process.platform === 'win32') {+            healOffscreenWindow(session, sessionId).catch(() => {});+            updateTabCountBadge(session, sessionId).catch(() => {}); // v1.9.0: overlay follows the active tab's favicon+            const now = Date.now();+            if (!session._badgedAt || now - session._badgedAt > 600000) {+              // v1.8.58: only debounce on SUCCESS — a failed badge retries on the next call.+              brandPupWindow(sessionId).then(ok => { if (ok) session._badgedAt = Date.now(); }).catch(() => {});+            }+          }+          // v2.0.272: VERIFY, do not assert. #69's whole complaint is a verb that reports ok while+          // nothing happened, so read the activation back from Chrome and say so when it did not take.+          let _isActive = null;+          try { _isActive = await withTimeout(tab.page.evaluate('document.visibilityState'), 1500, 'switch-verify') === 'visible'; } catch (e) {}+          const info = await describeTab(tab, session.activeTabId);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info,+            activated: _isActive,+            ...(_isActive === false ? { _hint: `pup switched its OWN pointer to ${tab.tabId}, but Chrome still does not report that tab as visible. Confirm with pup_list_tabs (it reads Chrome, not pup's bookkeeping) before trusting the switch, and please report it on adom/pup-bridge — a verb that says ok while nothing moved is the exact class issue #69 closed.` } : {}) }) });+          return;+        }+        case 'pup_close_tab': {+          const _live = requireLiveSession(res, args);+          if (!_live) return;+          const { sessionId, session } = _live;+          if (!args.tabId) {+            sendJSON(res, { success: false, error: 'tabId is required for pup_close_tab' });+            return;+          }+          const idx = session.tabs.findIndex(t => t.tabId === args.tabId);+          if (idx === -1) {+            const available = session.tabs.map(t => t.tabId).join(', ');+            sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });+            return;+          }+          const tab = session.tabs[idx];+          try { await tab.page.close(); } catch {}+          // The page.on('close', ...) handler in attachTab removes the tab from+          // session.tabs, but race-proof: remove here too if still present.+          const stillIdx = session.tabs.findIndex(t => t.tabId === args.tabId);+          if (stillIdx !== -1) session.tabs.splice(stillIdx, 1);+          if (session.activeTabId === args.tabId) {+            session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;+          }+          updateTabCountBadge(session, sessionId).catch(() => {}); // v1.8.80+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, closedTab: args.tabId, remaining: session.tabs.length, activeTabId: session.activeTabId }) });+          return;+        }+        case 'pup_list_tabs': {+          drainWindowOpenedEvents().catch(() => {}); // v1.9.32: cheap event-driven drag-out check+          const _live = requireLiveSession(res, args);+          if (!_live) return;+          const { sessionId, session } = _live;+          const tabs = [];+          for (const t of session.tabs) {+            const info = await describeTab(t, session.activeTabId);+            if (t.lastAgentUpdate) info.lastAgentUpdate = t.lastAgentUpdate; // v1.8.23: per-tab+            tabs.push(info);+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, count: tabs.length, activeTabId: session.activeTabId, lastAgentUpdate: session._lastAgentUpdate || null, tabs }) });+          return;+        }++        case 'pup_alert_window': {+          // Flash the taskbar button for a session's window WITHOUT foregrounding it. v1.8.35:+          // {stop:true} (alias {clear:true}) CLEARS a lingering orange highlight instead.+          // (Pre-1.8.35 this wrongly called bringToFront — it raised the window instead of+          // flashing. Now it uses the real FlashWindowEx path, same as the auto-flash.)+          const _live = requireLiveSession(res, args);+          if (!_live) return;+          const { sessionId, session } = _live;+          try {+            const stop = !!(args.stop || args.clear);+            // ── macOS ── there is no taskbar to flash and macOS has no API to bounce ANOTHER+            // app's Dock tile. The mac analog: unhide the window at its z-position (visible but+            // NOT focused — the non-intrusive nudge) + a native AD toast whose click focuses it.+            // {stop} dismisses the toast.+            if (process.platform === 'darwin') {+              const sid = session._sessionId || sessionId;+              const toastId = `pup-alert-${sid}`;+              let applied = false;+              if (stop) {+                try { const r2 = await chrome.abCommand('notify_dismiss', { id: toastId }, { timeoutMs: 5000 }); applied = !!r2; } catch (e) {}+                sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: false, cleared: applied, applied, clearMethod: applied ? 'toast-dismissed' : null, _hint: applied ? 'Alert toast dismissed.' : 'No toast to dismiss (or Bridge unreachable).' }) });+                return;+              }+              try { await macSetAppHiddenByPid(macSessionPid(session), false); } catch (e) {}+              try {+                const r2 = await chrome.abCommand('notify_user', {+                  id: toastId, level: 'info', scenario: 'reminder',+                  title: 'A pup window wants your attention',+                  body: `Session "${sid}" has something to show — click to bring it up.`,+                  focus: { ...(await windowTarget(sessions.get(sid), sid)) },+                }, { timeoutMs: 6000 });+                applied = !!(r2 && r2.success !== false);+              } catch (e) {}+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied, clearMethod: null, _hint: applied ? 'macOS nudge: window un-hidden (not focused) + a native toast posted — its click focuses the window. Pass {stop:true} to dismiss the toast.' : 'Window un-hidden, but the AD toast could not be posted (Bridge unreachable or notify unsupported).' }) });+              return;+            }+            // v1.8.43: route the FLASH through AD's desktop_flash_window (reliable, native).+            // v2.0.243 (pup-bridge#58, real root cause): pup_alert_window is an EXPLICIT request from+            // the caller — never pup's automatic machinery — so it must not be swallowed by the+            // minimal-touch gate. That gate is what made the flash silently no-op on John's box+            // while the response blamed the window title, which sent the investigation at the+            // title/hwnd resolver instead of at the gate.+            // v2.0.304 (John: "i just saw a pup window orange flash, but i thought our new setting was+            // for you to not do the orange flash anymore"): in `progress` mode pup never paints orange,+            // not even for an explicit alert. The nudge becomes a short full progress pulse on the+            // button (fill to 100%, hold ~2.5s, clear), the same signal family the user chose.+            if (!stop && taskbarActivityMode() === 'progress' && process.platform === 'win32') {+              const _sid = session._sessionId || sessionId;+              const _t = await windowTarget(session, _sid).catch(() => null);+              if (_t) { await _pushProgress(_t, 100); setTimeout(() => { _pushProgress(_t, null).catch(() => {}); }, 2500); }+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true, cleared: false, applied: !!_t, method: 'progress-pulse', _hint: 'taskbarActivity is "progress", so the nudge was a 2.5s full progress pulse on the taskbar button instead of the orange flash (pup never paints orange in progress mode). Set taskbarActivity:"flash" to get the old highlight.' }) });+              return;+            }+            const r = await flashViaAD(session._sessionId || sessionId, { stop, force: true });+            let applied = false;+            try { const o = r && abPayload(r); applied = !!(o && (o.ok || o.count)); } catch (e) {}+            // v1.8.45: FLASHW_STOP only stops the PULSE — the Win11 attention tint persists+            // until the window is ACTIVATED. So on {stop} we ALSO clear the tint for real via an+            // off-screen activate-and-restore (no pop, focus handed straight back). This is the+            // fix for "you said you cleared it but the button is still lit."+            let clearMethod = null;+            if (stop && process.platform === 'win32') {+              try {+                const br = browsers.get(session.profileName) && browsers.get(session.profileName).browser;+                if (br && (br._detachedPid || br._cdpPort || session._hwnd)) clearMethod = await osClearFlashByActivate(br._detachedPid, br._cdpPort, session._hwnd);+              } catch (e) {}+              try { _flashState.delete(session._sessionId || sessionId); } catch (e) {}+            }+            const trulyCleared = stop && ['cleared', 'cleared-still-foreground', 'already-foreground'].includes(clearMethod);+            console.log(`${stop ? 'Cleared' : 'Flashed'} taskbar for session "${sessionId}" (AD applied=${applied}, clear=${clearMethod})`);+            const hint = stop+              ? (trulyCleared+                  ? `Flash truly cleared — activated the window off-screen to drop the Win11 attention tint (${clearMethod}), then handed focus back. No pop, no focus steal.`+                  : `Could not fully clear (${clearMethod || 'window not found'}). FLASHW_STOP ran (applied=${applied}) but the persistent tint needs activation and that step failed.`)+              : (applied ? 'Flashed the taskbar (finite 4-count) via AD, no focus steal. Pass {stop:true} to clear.'+                : (r && r._skipped === 'minimal-touch'+                  ? 'Skipped: this desktop runs pup in MINIMAL-TOUCH mode, which suppresses pup-initiated taskbar effects. Turn it off in the dashboard Settings (or pup_configure {minimalTouch:"off"}) if you want alerts to flash.'+                  : 'AD desktop_flash_window could not act on this window. pup targets it by HANDLE (title tags are off by default), so this usually means the window closed or the handle went stale — pup_window_info shows the handle pup is using.'));+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: !stop, cleared: stop ? trulyCleared : false, applied, clearMethod, _hint: hint }) });+          } catch (e) {+            sendJSON(res, { success: false, error: `Alert failed: ${e.message}` });+          }+          return;+        }+        case 'pup_lower_os_window': {+          // Send a pup window to the BACKGROUND (behind the user's windows) without+          // stealing focus. It stays fully drivable + screenshottable. This is the+          // "get it off the user's screen" verb — the counterpart to raise/focus.+          const _live = requireLiveSession(res, args);+          if (!_live) return;+          const { sessionId, session } = _live;+          // v1.8.47b: park on-screen at z-bottom (screenshots keep working). An explicit lower is+          // a deliberate re-park, so clear the user-foreground latch too.+          const be = browsers.get(session.profileName);+          if (be && be.browser) be.browser._userForeground = false;+          let ok;+          if (process.platform === 'darwin') {+            // macOS lower = hide the session's browser process (still fully drivable — CDP+            // keeps rendering hidden windows). Per-process, so shared-profile siblings hide too.+            ok = await macSetAppHiddenByPid(macSessionPid(session), true);+          } else {+            if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, sessionId); } catch (e) {} }+            // v2.0.236: route through the SAME park every internal caller uses instead of the two+            // legacy one-off implementations (osBackgroundWindowByPid took parameters its body no+            // longer read, and osSendWindowToBack re-derived the window by profile). One park now.+            const _pg = (session.tabs && session.tabs[0] && session.tabs[0].page) || session.page || null;+            ok = _pg ? !!(await parkSessionWindowDirect(_pg, sessionId)) : false;+          }+          session._foreground = false;+          session._fgWatchUntil = 0;   // v1.9.260: a deliberate lower is not a "surfaced without pup" event - z-bottom keeps OS focus and the no-fault watch was mislabeling it+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, placement: 'background', windowEtiquette: 'background', applied: ok, _hint: 'Window sent behind the user\'s windows (still drivable/screenshottable). Keep pup in the background unless the user asked to watch.' }) });+          return;+        }+        case 'pup_raise_os_window':+        case 'pup_focus_window': {+          // Actually brings the Chrome window to the foreground using Win32 SetForegroundWindow+          const _live = requireLiveSession(res, args);+          if (!_live) return;+          const { sessionId, session } = _live;+          // v1.8.70: THE FOREGROUND GATE (mirrors abe's userRequestedForeground+foregroundReason+          // dual key — same contract across Adom's browser surfaces). Raising steals the user's+          // screen and keyboard; it is allowed ONLY as a user-requested show-me, and the caller+          // must SAY SO in a recorded, auditable reason. Real incident: a thread raised a window+          // 2 minutes after a background open and the only forensics were a focus-latch+          // timestamp — never again.+          const fgReason = (typeof args.foregroundReason === 'string' && args.foregroundReason.trim().length >= 10) ? args.foregroundReason.trim() : null;+          if (!fgReason) {+            sendJSON(res, {+              success: false,+              errorCode: 'foreground_reason_required',+              error: 'Raise refused: foregroundReason is required.',+              _hint: 'Foregrounding steals the user\'s screen and keyboard focus. It is allowed ONLY when the USER explicitly asked to SEE this window (a "show me", a demo, a live recording). Call again with foregroundReason:"<quote or closely paraphrase what the user said that justifies this>" (min 10 chars) — the reason is logged, recorded in lastAgentUpdate, and auditable by the user. If the user did NOT ask to see it, do NOT raise: use pup_alert_window (taskbar nudge) or rely on the auto-flash. After a granted raise: do the one thing the user asked for, then pup_lower_os_window to return it to the background.',+            });+            return;+          }+          console.log(`[foreground] "${sessionId}" raise GRANTED — reason: ${fgReason}`);+          try { const _rs = sessions.get(sessionId); if (_rs) _rs._raiseGranted = true; } catch (e) {}+          session._lastForeground = { ts: Date.now(), verb: command, reason: fgReason };+          toastForeground(sessionId, fgReason).catch(() => {});   // v1.9.166: ping the human before/as we reveal+          // v1.9.150: the reveal redraws the taskbar button; nothing re-wore the favicon, so all+          // three of John's foregrounded windows lost their overlay. Re-assert it after the raise.+          setTimeout(() => { updateTabCountBadge(session, args.sessionId || session._sessionId).catch(() => {}); }, 1200);+          try { recordAgentUpdate(session, command, { updateNote: `FOREGROUND: ${fgReason}` }, session.activeTabId); } catch (e) {}+          // v1.9.201 / v2.0.290: show the caller's justification (and WHICH ai-thread) on screen+          // BEFORE the window comes forward, so the caption always precedes the reveal — never trails+          // it. AWAITED (fire-and-forget let the raise win the race, John saw the caption ~5s late).+          try { await captionForegroundReason(sessionId, fgReason, command, _callerThread); } catch (e) {}+          try {+            // v1.9.149 (John's audit: two of three raised windows came up 0,0 full-screen, not the+            // standard frame): the reveal must present the SAME work-area-minus-10px geometry the+            // park set — the old block here CDP-MAXIMIZED over it, which is exactly the sibling-path+            // legacy the placement doctrine forbids. Reveal geometry == park geometry, one standard.+            try {+              const cdp = await session.page.target().createCDPSession();+              const { windowId } = await cdp.send('Browser.getWindowForTarget');+              let sc = null;+              try { sc = await session.page.evaluate(() => ({ x: screen.availLeft || 0, y: screen.availTop || 0, w: screen.availWidth, h: screen.availHeight })); } catch (e) {}+              await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });+              if (sc && sc.w > 400) {+                await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: Math.round(sc.x + 10), top: Math.round(sc.y + 10), width: Math.round(sc.w - 20), height: Math.round(sc.h - 20) } });+              }+              try { await cdp.detach(); } catch (e) {}+            } catch (e) {}+            // v1.8.23: BLUR the page's active element BEFORE raising. Raising steals the OS+            // focus, and a keystroke the user was mid-typing can land in whatever input the+            // pup page had focused (a form field became "John Lauer a"). With no focused+            // element, a stray key has nowhere to land. (The residual SetForegroundWindow+            // input-queue timing is AD-owned when AD intercepts this verb — flagged upstream.)+            try { await session.page.evaluate(() => { const a = document.activeElement; if (a && a.blur) a.blur(); }); } catch (e) {}+            // First activate the tab within Chrome+            await session.page.bringToFront();+            activeSessionId = sessionId;+            session._foreground = true; // v1.8.26: user asked to watch — allow later raises++            // v1.9.67 — RAISE BY HWND FIRST (fixes "I click and no window comes up" on Edge boxes).+            //+            // The PID path below is skipped whenever `process()` is null, which is EVERY detached launch on+            // Windows — so on a box with no Chrome (pup falls back to Edge and caches it as the verified+            // default) the only thing that ran was the CDP bounds + bringToFront above. bringToFront activates+            // the TAB inside the window, not the OS window, so a window pup parked at the bottom of the+            // z-order stayed buried. Reproduced on ConfRoomROG: the verb returned ok:true, logged+            // "raise GRANTED", and the screen never changed. The PID fallback was Chrome-only too — it scanned+            // for child processes named "chrome.exe" and would never match msedge.exe.+            //+            // Resolving by the "(session: <id>)" title is browser-agnostic and names the EXACT window (the old+            // _detachedPid attempt raised the LAUNCHER's window). The raise stays in PowerShell rather than+            // handing the hwnd to AD, because backgrounded pup windows carry WS_EX_NOACTIVATE — structurally+            // unable to take focus until that style is CLEARED, which is exactly what Raise() does.+            let _raisedByHwnd = false;+            if (process.platform === 'darwin') {+              // macOS raise = unhide the session's process + make it frontmost. The CDP+              // bounds/maximize above already sized it; NSRunningApplication does the activation.+              const pid = macSessionPid(session);+              _raisedByHwnd = await macActivateByPid(pid);+              console.log(`[foreground] "${sessionId}" mac activate pid=${pid} → ${_raisedByHwnd}`);+            }+            if (process.platform === 'win32') {+              try {+                // v1.9.220 (wiki issue #5, John, 2026-07-06): TRACK THE HANDLE, don't match the title.+                // Title-matching is fragile exactly as he described — right after a navigation the new+                // page sets its own title before the bridge re-appends the "(session: <id>)" suffix, so+                // the lookup fails; and Chrome's PDF viewer never carries the suffix at all, which is+                // what made PDF windows unraisable/unpaintable. sessionHwnd() now prefers the handle+                // pup stored at park (falling back to bounds-matching, then a title lookup), so the+                // raise resolves the SAME window regardless of what its title currently says.+                let hwnd = await sessionHwnd(sessionId);+                if (!hwnd) {+                  const fw = abPayload(await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 5000 , caller: sessionCaller(sessions.get(sessionId))}));+                  hwnd = fw && fw.best && fw.best.hwnd;+                }+                if (hwnd) {+                  const psPath = path.join(require('os').tmpdir(), '_adom_focus_hwnd.ps1');+                  fs.writeFileSync(psPath, [+                    ...PS_RAISE_TYPEDEF,+                    `[WinFocusH]::Raise([IntPtr]${hwnd})`,+                    "Write-Output 'focused'",+                  ].join('\n'), 'utf8');+                  const out = await runPsHiddenPromise(psPath, { timeout: 8000 });+                  // v1.9.68: PS reports 'focused' even when Windows IGNORED the call. SetForegroundWindow is+                  // subject to the FOREGROUND LOCK: a process that does not already own the foreground cannot+                  // steal it, so it silently no-ops (measured: log said FOCUSED, screen unchanged). Use PS only+                  // for what it is uniquely good at — clearing WS_EX_NOACTIVATE and un-minimizing — then let AD,+                  // which owns a foreground-capable path, do the real raise on the SAME hwnd.+                  const _styleCleared = /focused/.test(out || '');+                  try {+                    const br = abPayload(await chrome.abCommand('desktop_bring_to_front', { hwnd, state: 'restore' }, { timeoutMs: 6000 }));+                    _raisedByHwnd = !!(br && (br.ok === undefined || br.ok));+                  } catch (e) { _raisedByHwnd = false; }+                  console.log(`[foreground] "${sessionId}" hwnd=${hwnd} styleCleared=${_styleCleared} adRaise=${_raisedByHwnd}`);+                } else { console.log(`[foreground] "${sessionId}" could not resolve an hwnd by title`); }+              } catch (e) { console.log(`[foreground] "${sessionId}" hwnd raise threw: ${e.message}`); }+            }++            // Get Chrome PID from the browser process+            const browserProcess = browsers.get(session.profileName)?.browser?.process();+            const pid = browserProcess?.pid;+            if (!_raisedByHwnd && pid && process.platform === 'win32') {+              // Write PowerShell script to temp file and execute (avoids quoting hell)+              const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');+              const psContent = [+                ...PS_RAISE_TYPEDEF,+                `$targetPid = ${pid}`,+                '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',+                'if ($p -and $p.MainWindowHandle -ne 0) {',+                '  [WinFocusH]::Raise($p.MainWindowHandle)',+                "  Write-Output 'focused'",+                '} else {',+                '  Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',+                '    $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',+                '    if ($cp -and $cp.MainWindowHandle -ne 0) {',+                '      [WinFocusH]::Raise($cp.MainWindowHandle)',+                "      Write-Output 'focused'",+                '      return',+                '    }',+                '  }',+                '}',+              ].join('\r\n');+              fs.writeFileSync(psScript, psContent);+              try {+                const result = (await runPsHiddenPromise(psScript, { timeout: 8000 })).toString().trim();+                console.log(`Focused session "${sessionId}" via SetForegroundWindow: ${result}`);+              } catch (e) {+                console.error(`SetForegroundWindow failed for session "${sessionId}":`, e.message);+              }+            }+            // v1.9.180: a raise can land on a stuck-occlusion window (frontmost yet page-hidden →+            // frame-frozen in front of the user). Check shortly after the reveal and unstick at once+            // rather than waiting for the 15s sweep.+            setTimeout(async () => {+              try {+                const s = sessions.get(sessionId);+                const fg = await osGetForegroundWindow();+                const hw = s ? await cachedSessionHwnd(sessionId) : null;+                if (s && fg && hw) await unstickIfHiddenWhileFront(sessionId, s, fg, hw);+              } catch (e) {}+            }, 2000);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true, placement: 'foreground', windowEtiquette: 'foreground' }) });   // v2.0.383 (#63): canonical etiquette key+          } catch (e) {+            sendJSON(res, { success: false, error: `Focus failed: ${e.message}` });+          }+          return;+        }++        // --- Existing commands (operate on active session) ---+        case 'pup_launch': {+          sendJSON(res, { success: false, error: 'pup_launch is removed. Use pup_open_window with required sessionId and profile params.' });+          return;+        }+        case 'pup_screenshot': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const fullPage = args.fullPage || false;+          const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM;+          const ts = Date.now();+          // v2.0.286 (John): a capture can sometimes bring the window to the FOREGROUND (pup falling+          // back to a window-level grab when the CDP path can't serve it). When that happens the user+          // must be told WHY — the missing caption is exactly why uncaptioned foregrounds slipped past.+          // Snapshot the OS foreground before the capture so we can detect+caption it after.+          const _fgBefore = await osGetForegroundWindow().catch(() => null);+          // v2.0.289: show a determinate taskbar progress bar for the length of the capture, sized to+          // the running-average screenshot time (John: progress bars instead of the flash).+          let screenshotBuf = null, _capturedVia = 'cdp';+          try {+            screenshotBuf = await withTaskbarProgress(resolvedId, 'screenshot', () => withTimeout(+              // v2.0.186 (#50): captureBeyondViewport defaults TRUE in current puppeteer, which renders+              // from the document ORIGIN — so a viewport screenshot ignored the page's live scroll+              // position (scroll via pup_eval, screenshot still showed the top). Viewport shots now+              // capture the compositor's real viewport; fullPage keeps beyond-viewport ON (it needs it).+              raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })),+              20_000, 'page.screenshot'+            ));+          } catch (e1) {+            // v2.0.386 (#80): a CDP capture on a fully OCCLUDED background window can hang until the+            // compositor produces a frame (pup deliberately lets background windows throttle, #14), and+            // the old path surfaced that as a bare timeout with an empty output. Two fallbacks that+            // never touch OS focus, then an honest error: (1) CDP lifecycle 'active' + one retry;+            // (2) an OS-level PrintWindow capture of the window (works while occluded); (3) errorCode.+            if (!/timed out/i.test(String(e1 && e1.message))) throw e1;+            console.log(`[screenshot] "${resolvedId}" CDP capture timed out — trying lifecycle-active retry, then OS window capture`);+            try {+              await pageSetLifecycleActive(tab.page);+              screenshotBuf = await withTimeout(raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage })), 8000, 'page.screenshot(retry)');+              _capturedVia = 'cdp-after-lifecycle-active';+            } catch (e2) {+              let _osBuf = null;+              try {+                if (!session._hwnd) { try { await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {} }+                if (session._hwnd && !fullPage) {+                  const r = await chrome.abCommand('desktop_screenshot_window', { hwnd: Number(session._hwnd) }, { timeoutMs: 25000, callerThread: _callerThread || undefined, callerReason: 'pup screenshot fallback (CDP capture timed out on an occluded window)' });+                  const p = abPayload(r) || {};+                  const fp = p.fullPath || p.safePath || (r && (r.fullPath || r.safePath));+                  if (fp && fs.existsSync(fp)) _osBuf = fs.readFileSync(fp);+                }+              } catch (e) {}+              if (!_osBuf) {+                sendJSON(res, { success: false, errorCode: 'screenshot_timeout', error: `page.screenshot timed out (CDP), a lifecycle-active retry timed out, and the OS window capture was unavailable${fullPage ? ' (fullPage has no OS fallback)' : ''}`,+                  _hint: 'The window is probably fully occluded and its compositor is idle (background windows throttle on purpose, #14). pup_eval still works, so the page is fine. Options: retry in a moment; call pup_raise_os_window with a foregroundReason if the user asked to see it; or open the window with highFps:true when you need continuous frames. Read errorCode, not output: this failure used to arrive as an empty output.' });+                return;+              }+              screenshotBuf = _osBuf; _capturedVia = 'os-window';+              console.log(`[screenshot] "${resolvedId}" served by OS window capture after CDP timeouts`);+            }+          }+          // If the capture foregrounded THIS window AND there was no recent user input (so it was pup,+          // not the user clicking it), caption "<thread> · screenshotting" so the user knows what put it+          // on their screen. No-op on the common CDP path, which never foregrounds (measured 0/101).+          try {+            const _hw = session && session._hwnd;+            if (_hw) {+              const _fgAfter = await osGetForegroundWindow().catch(() => null);+              if (_fgAfter && String(_fgAfter) === String(_hw) && String(_fgAfter) !== String(_fgBefore)) {+                // v2.0.293 (John: "you keep foregrounding windows without ever giving me ab captions"):+                // the old idle>3000 gate assumed an ACTIVE user must have clicked the window up — but the+                // active-user case is precisely when a silent foreground is most disruptive, and the fg+                // flipping to OUR hwnd mid-capture is pup's doing regardless of user activity. Caption+                // ALWAYS. (The idle reading is kept only for the log.)+                let _idle = -1; try { _idle = await osIdleMs(); } catch (e) {}+                const _thread = session.owner || session._ownerThread || 'an AI thread';+                const _scapMs = captionMsFor(`${_thread} · screenshotting`);+                pupCaption(`${_thread} · screenshotting`, _scapMs, 'pup-screenshot', _scapMs).catch(() => {});+                console.log(`[screenshot] "${resolvedId}" came to the FOREGROUND during capture (user idle ${_idle}ms) — captioned "${_thread} · screenshotting"`);+              }+            }+          } catch (e) {}+          // v1.8.21: fullPage only covers DOCUMENT-level scroll. On SPAs that scroll inside+          // a nested container (the document itself doesn't grow), fullPage silently returns+          // just the visible viewport. Detect that and hand the caller the right tool.+          let nestedScrollHint = null;+          if (fullPage) {+            try {+              await ensureDeepHelpers(tab.page);+              const ns = await tab.page.evaluate(() => {+                const doc = document.scrollingElement || document.documentElement;+                const docScrolls = doc.scrollHeight > doc.clientHeight + 1;+                const main = window.__pupMainScroller();+                const nested = !docScrolls && main !== doc && main.scrollHeight > main.clientHeight + 1;+                return { docScrolls, nested, scroller: window.__pupDesc(main), scrollHeight: main.scrollHeight, clientHeight: main.clientHeight };+              });+              if (ns.nested) nestedScrollHint = `fullPage captured only the viewport: this page scrolls inside a NESTED container (${ns.scroller}, ${ns.scrollHeight}px of content vs ${ns.clientHeight}px visible), not the document — fullPage can't stitch that. To capture the whole thing, either pup_scroll {dy:...} through it and stitch, or (for one region) pup_screenshot_element {selector|text}. To capture a specific card, pup_screenshot_element is the one-shot fix.`;+            } catch (e) {}+          }++          // Downscale to keep the image under Claude's Read limit when sharp is+          // available; otherwise serve at capture size (sharp is optional — see the+          // processScreenshot helper). A ≤1568px image is the ideal path.+          const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);+          const shot = await processScreenshot(screenshotBuf, maxDim);+          screenshotBuf = shot.buf;++          const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);+          fs.writeFileSync(filePath, screenshotBuf);+          const sizeKB = Math.round(screenshotBuf.length / 1024);+          session.latestShot = filePath;+          const base64 = screenshotBuf.toString('base64');++          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true,+            sizeKB,+            filePath,+            base64,+            capturedVia: _capturedVia,   // v2.0.386 (#80): cdp | cdp-after-lifecycle-active | os-window+            width: shot.width,+            height: shot.height,+            origWidth: shot.origWidth,+            origHeight: shot.origHeight,+            resized: shot.resized,+            ...(shot.sharpUsed ? {} : { sharpUnavailable: true, _hint: 'Screenshot served at CAPTURE size — the sharp resize library is not installed on this box, so the image was NOT downscaled and may exceed Claude\'s ~2000px Read limit. If a Read fails on size, capture a smaller viewport, or (rare) this box\'s native-dep install failed — see pup_readiness.' }),+            ...(nestedScrollHint ? { nestedScroll: true, _hint: nestedScrollHint } : {}),+            sessionId: resolvedId,+            tabId,+          }) });+          return;+        }+        case 'pup_set_viewport': {+          // v1.8.21: set the page viewport (CDP device-metrics) deterministically —+          // independent of the OS window size. Fixes "innerWidth came up ~170px" and lets a+          // fullPage/element shot render at a known width. width+height required.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          // v1.9.219 (wiki issue #28, Drew Owens): the override used to PIN a window for life — it+          // survived a hard reload, could not be cleared, and nothing reported it, so a window that+          // silently stopped tracking OS resizing was indistinguishable from an app with broken layout+          // code. That cost him a real debugging session hunting his own resize handlers. Fix 1 of 4:+          // a RESET form. Pass {reset:true} (or omit width/height) to call setViewport(null), which+          // clears Emulation device-metrics and hands the page back to the real OS window.+          const wantsReset = args.reset === true || (args.width === undefined && args.height === undefined);+          if (wantsReset) {+            try {+              // v2.0.365 (project-a5's report). VERIFY THE CLEAR, do not assume it. They measured a+              // reset that returned ok:true with "innerWidth now 2200" — the OVERRIDE width, not the+              // real one — while the override was still in force through a reload. A reset that+              // reports the number it was supposed to remove is worse than a failure, because the+              // caller then trusts a window that is still cropping content off the user's screen.+              const _prev = session._emulatedViewport ? Number(session._emulatedViewport.width) : null;+              await tab.page.setViewport(null);+              // Belt and braces: puppeteer's setViewport(null) does not always reach the metrics+              // override on a page that was emulated earlier in its life, so clear it at the+              // protocol level too. Harmless when there is nothing to clear.+              try { const _cdp = await tab.page.createCDPSession(); await _cdp.send('Emulation.clearDeviceMetricsOverride'); await _cdp.detach().catch(() => {}); } catch (e) {}+              session._emulatedViewport = null;+              // Measure AFTER a forced reflow, and measure the OUTER window too: outerWidth is the+              // one number an emulated viewport cannot fake, so innerWidth-vs-outerWidth is how an+              // AI can finally tell an emulated window from a real one (their ask (b)).+              await tab.page.evaluate(() => new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r))));+              const dims = await tab.page.evaluate(() => ({+                innerWidth: window.innerWidth, innerHeight: window.innerHeight,+                outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio,+              }));+              // Verify by MEASUREMENT. The old check compared against the remembered override+              // width, so on a recovered window (record wiped, override live) there was nothing to+              // compare against and it passed vacuously. innerWidth vs outerWidth cannot be fooled+              // that way, and it also catches the case where the record was never there at all.+              const _stillEmulated = looksEmulated(dims.innerWidth, dims.outerWidth)+                || (_prev && Math.abs(dims.innerWidth - _prev) <= 2);+              if (_stillEmulated) {+                sendJSON(res, { success: false, error: `the viewport override did not clear: the page still reports innerWidth ${dims.innerWidth}, which is the override width`,+                  errorCode: 'viewport_reset_failed',+                  output: JSON.stringify({ ok: false, sessionId: resolvedId, tabId, emulated: true, ...dims }),+                  _hint: `pup could not remove the emulation from this window. Close and reopen it (pup_close_window then pup_open_window) — a fresh window reports the real size. Do NOT trust in-page measurements from this window meanwhile: the page believes it is ${dims.innerWidth}px wide while the OS window is ${dims.outerWidth}px, so the user is seeing it cropped.` });+                return;+              }+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, reset: true, emulated: looksEmulated(dims.innerWidth, dims.outerWidth), ...dims,+                _hint: `Viewport override CLEARED — this window tracks the real OS window again (innerWidth now ${dims.innerWidth}, dpr ${dims.devicePixelRatio}). Resizing the window will reflow the page normally.` }) });+            } catch (e) { sendJSON(res, { success: false, error: `clearing the viewport override failed: ${e.message}` }); }+            return;+          }+          const vw = Number(args.width), vh = Number(args.height);+          if (!Number.isFinite(vw) || !Number.isFinite(vh)) {+            sendJSON(res, { success: false, error: 'pup_set_viewport requires numeric width + height (or {reset:true} to clear an existing override).', _hint: 'Set: {"sessionId":"...","width":1280,"height":900}. Clear: {"sessionId":"...","reset":true}. Optional deviceScaleFactor (default 1).' });+            return;+          }+          const dsf = Number(args.deviceScaleFactor) || 1;+          try {+            // Capture the REAL window width BEFORE emulating. outerWidth is the one number the+            // override cannot fake, and it is what lets every later warning say whether the user is+            // seeing dead space or seeing content cropped off their screen.+            let _outerBefore = null;+            try { _outerBefore = await tab.page.evaluate(() => window.outerWidth); } catch (e) {}+            await tab.page.setViewport({ width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf });+            if (_outerBefore) session._lastOuterWidth = _outerBefore;+            // Fix 2 of 4: RECORD the override so pup_status / pup_list_windows can report it.+            session._emulatedViewport = { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf };+            const dims = await tab.page.evaluate(() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, outerWidth: window.outerWidth, devicePixelRatio: window.devicePixelRatio }));+            // Fix 3 of 4: the hint must state the LIFECYCLE, not just the benefit. The old text sold+            // the feature and never said the effect is permanent for the life of the window.+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, emulated: true, realOuterWidth: _outerBefore, applied: { width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf }, ...dims,+              _hint: `Viewport PINNED to ${Math.round(vw)}×${Math.round(vh)} (innerWidth now ${dims.innerWidth}). ⚠ THIS PERSISTS: it survives navigation AND hard reload, and while it is set the window NO LONGER TRACKS OS RESIZING — a human resizing this window will see the page not reflow, which reads exactly like an app layout bug. CLEAR IT when you are done: pup_set_viewport {"sessionId":"${resolvedId}","reset":true}. It is reported as emulatedViewport in pup_status / pup_list_windows.` }) });+          } catch (e) { sendJSON(res, { success: false, error: `setViewport failed: ${e.message}` }); }+          return;+        }+        case 'pup_scroll': {+          // v1.8.21: first-class scroll. Resolves the ACTUAL scrolling element (nearest+          // ancestor whose scrollHeight>clientHeight, NOT just window — window.scrollTo+          // no-ops on nested-scroll SPAs). Supports scroll-by-delta {dy,dx} AND scroll-to+          // {toSelector|toText, deep} piercing shadow roots; returns the scroller's metrics+          // so the caller can confirm it landed.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          await ensureDeepHelpers(tab.page);+          try {+            const r = await tab.page.evaluate((o) => {+              let target = null;+              if (o.toSelector) target = window.$deep(o.toSelector);+              else if (o.toText) target = window.deepText(o.toText);+              let scroller;+              if (o.toSelector || o.toText) {+                if (!target) return { found: false };+                scroller = window.__pupScrollableAncestor(target);+                target.scrollIntoView({ block: o.block || 'center', inline: 'nearest', behavior: o.behavior || 'auto' });+              } else {+                scroller = window.__pupMainScroller();+                scroller.scrollBy({ top: o.dy || 0, left: o.dx || 0, behavior: o.behavior || 'auto' });+              }+              return { found: true, scrollElement: window.__pupDesc(scroller), scrollTop: Math.round(scroller.scrollTop), scrollLeft: Math.round(scroller.scrollLeft), scrollHeight: scroller.scrollHeight, clientHeight: scroller.clientHeight, atBottom: scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2 };+            }, { dy: Number(args.dy) || 0, dx: Number(args.dx) || 0, toSelector: args.toSelector || null, toText: args.toText || null, block: args.block || null, behavior: args.behavior || 'auto' });+            if (!r.found) {+              sendJSON(res, { success: false, errorCode: 'scroll_target_not_found', error: `pup_scroll: could not resolve ${args.toSelector ? 'selector "'+args.toSelector+'"' : 'text "'+args.toText+'"'} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId });+              return;+            }+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, ...r, _hint: `Scrolled ${args.toSelector||args.toText ? 'to the target' : 'by delta'} inside ${r.scrollElement} — scrollTop ${r.scrollTop}/${r.scrollHeight - r.clientHeight}${r.atBottom ? ' (at bottom)' : ''}. Screenshot to confirm, or pup_screenshot_element to grab just a card.` }) });+          } catch (e) { sendJSON(res, { success: false, error: `pup_scroll failed: ${e.message}` }); }+          return;+        }+        case 'pup_screenshot_element': {+          // v1.8.21: screenshot ONE element, resolved by CSS selector OR visible text+          // (piercing shadow DOM), scrolled into view first. The clean fix for "capture+          // just this card" on component-heavy pages. Returns the same shape as+          // pup_screenshot (base64 + filePath, downscaled for Read).+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          if (!args.selector && !args.text) {+            sendJSON(res, { success: false, error: 'pup_screenshot_element requires a selector OR text.', _hint: 'e.g. {"selector":".download-card"} or {"text":"Download for your machine"}. Pierces shadow DOM by default.' });+            return;+          }+          await ensureDeepHelpers(tab.page);+          let handle;+          try {+            // Resolve by selector or text (shadow-piercing), then optionally climb to the+            // nearest matching ancestor with `closest` — e.g. {text:"Download…", closest:".card"}+            // grabs the whole CARD, not just the text node. Returns the FIRST text match+            // whose closest(sel) exists, so an ambiguous phrase (also in README prose) still+            // lands on the real widget.+            handle = await tab.page.evaluateHandle((sel, txt, closest) => {+              let el = sel ? window.$deep(sel) : null;+              if (!el && txt) {+                if (closest) {+                  // scan all text matches, pick the first whose closest(sel) resolves+                  const marks = [];+                  const walk = (root) => { const a = root.querySelectorAll('*'); for (const e of a) {+                    let own = ''; for (const c of e.childNodes) if (c.nodeType === 3) own += c.textContent;+                    if (own.toLowerCase().indexOf(txt.toLowerCase()) !== -1) { const r = e.getBoundingClientRect(); if (r.width > 0 && r.height > 0) marks.push(e); }+                    if (e.shadowRoot) walk(e.shadowRoot); } };+                  walk(document);+                  for (const m of marks) { const c = m.closest(closest); if (c) { el = c; break; } }+                } else { el = window.deepText(txt); }+              } else if (el && closest) { el = el.closest(closest) || el; }+              return el;+            }, args.selector || null, args.text || null, args.closest || null);+            const el = handle.asElement();+            if (!el) {+              try { await handle.dispose(); } catch {}+              sendJSON(res, { success: false, errorCode: 'element_not_found', error: `pup_screenshot_element: no element matched ${args.selector ? 'selector "'+args.selector+'"' : 'text "'+args.text+'"'}${args.closest ? ' with a closest("'+args.closest+'") ancestor' : ''} (searched, piercing shadow roots).`, sessionId: resolvedId, tabId, _hint: 'If matching by text is ambiguous (the phrase also appears in prose), pass a `selector` for the widget, or a `closest` selector for its card container.' });+              return;+            }+            await el.evaluate((e) => e.scrollIntoView({ block: 'center', inline: 'center' }));+            await new Promise(r => setTimeout(r, 150)); // let layout settle after scroll+            const pad = Number(args.padding) || 0;+            // Document-relative clip (getBoundingClientRect + scroll) + captureBeyondViewport —+            // this is coordinate-correct even when the element is below the fold (puppeteer's+            // boundingBox()/clip spaces mismatched and captured the wrong region).+            const rect = await el.evaluate((e, p) => { const r = e.getBoundingClientRect();+              return { x: r.left + (window.scrollX || 0) - p, y: r.top + (window.scrollY || 0) - p, width: r.width + 2 * p, height: r.height + 2 * p, vw: r.width, vh: r.height };+            }, pad);+            if (rect.vw < 1 || rect.vh < 1) throw new Error('element has no rendered box (hidden/zero-size)');+            const rawBuf = await withTimeout(tab.page.screenshot({ type: 'png', captureBeyondViewport: true, clip: { x: Math.max(0, Math.round(rect.x)), y: Math.max(0, Math.round(rect.y)), width: Math.round(rect.width), height: Math.round(rect.height) } }), 20_000, 'element.screenshot(clip)');+            try { await handle.dispose(); } catch {}+            const shot = await processScreenshot(rawBuf, MAX_SCREENSHOT_DIM);+            const ts = Date.now();+            const filePath = path.join(SHOTS_DIR, `pup-el-${ts}.png`);+            fs.writeFileSync(filePath, shot.buf);+            session.latestShot = filePath;+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, matchedBy: args.selector ? 'selector' : 'text', sizeKB: Math.round(shot.buf.length / 1024), filePath, base64: shot.buf.toString('base64'), width: shot.width, height: shot.height, origWidth: shot.origWidth, origHeight: shot.origHeight, resized: shot.resized, _hint: 'Captured just that element (scrolled into view first). Read the filePath to see it.' }) });+          } catch (e) {+            try { if (handle) await handle.dispose(); } catch {}+            sendJSON(res, { success: false, error: `pup_screenshot_element failed: ${e.message}` });+          }+          return;+        }+        case 'pup_screenshot_full_res': {+          // v1.8.40+: full-resolution variant for the rare "save to disk for+          // external use" case (export to wiki, hand to a designer, etc.).+          // NEVER pass the resulting image to Claude via Read — full-res+          // browser viewports are typically 2559x1398 (or larger on hi-DPI+          // monitors), which exceeds Claude's 2000px many-image limit and+          // crashes the session. For AI-readable screenshots, use+          // pup_screenshot which forces a ≤1568px resize.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const fullPage = args.fullPage || false;+          const ts = Date.now();+          const screenshotBuf = await withTimeout(+            tab.page.screenshot({ type: 'png', fullPage, captureBeyondViewport: fullPage }),+            20_000, 'page.screenshot'+          );+          // Probe dimensions for the warning payload — cheap. No resize, no+          // recompression — the bytes that hit disk are exactly what Chrome captured.+          const meta = sharp ? await sharp(screenshotBuf).metadata() : pngDimensions(screenshotBuf);+          const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`);+          fs.writeFileSync(filePath, screenshotBuf);+          const sizeKB = Math.round(screenshotBuf.length / 1024);+          session.latestShot = filePath;+          const base64 = screenshotBuf.toString('base64');+          sendJSON(res, {+            success: true,+            output: JSON.stringify({+              ok: true,+              sizeKB,+              filePath,+              base64,+              width: meta.width,+              height: meta.height,+              sessionId: resolvedId,+              tabId,+              _warning: `FULL RESOLUTION (${meta.width}x${meta.height}px) — do NOT pass this image to Claude via the Read tool. Images >2000px crash many-image sessions at the Claude API limit. Use pup_screenshot instead for AI-readable screenshots (auto-resized to ≤1568px). This command is for saving to disk / wiki / export only.`,+            }),+          });+          return;+        }+        case 'pup_hover': {+          // v1.9.198 (Bills thread): pup_hover did not exist — the only way to move the pointer+          // without clicking was pup_input_dispatch {type:'move'}, which is unobvious next to+          // pup_click. Hovering also reveals tooltips/menus that only appear on pointer-over.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          try {+            let px = Number(args.x), py = Number(args.y);+            if (args.selector) {+              const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });+              px = box.x; py = box.y;+            }+            if (!Number.isFinite(px) || !Number.isFinite(py)) {+              sendJSON(res, { success: false, errorCode: 'hover_target_missing', error: 'pup_hover needs selector, or x + y.' });+              return;+            }+            const _hIntent = (typeof args.intent === 'string' && args.intent.trim()) ? args.intent.trim() : `Hovering ${args.selector ? `"${args.selector}"` : 'target'}`;+            if (args.showCursor !== false) { try { await showPupCursor(tab.page, px, py, { click: false, label: _hIntent }); } catch (e) {} }+            await withTimeout(tab.page.mouse.move(px, py, { steps: Number(args.steps) || 8 }), 10_000, 'mouse.move');+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, hovered: args.selector || { x: px, y: py },+              _hint: 'Pointer moved (isTrusted) and the visible Adom cursor glided there. Tooltips/hover menus that need a real pointer-over should now be present — read them with pup_eval.' }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'hover_failed', error: `pup_hover failed: ${e.message}` });+          }+          return;+        }+        case 'pup_set_identity': {+          // v1.9.343 (John: "put my Adom profile picture in there — show my adom identity"). Chrome's+          // own profile chip refuses a custom local image (only a real Google sign-in gets one), so we+          // show the user's Adom identity in the floating owner chip instead. This verb stores the+          // avatar (a pre-circle-cropped PNG as base64, so the bridge stays image-lib-free here) + a+          // display name into pup-settings, and repaints every live window's chip so it shows at once.+          try {+            const patch = {};+            if (args.clear) { patch.identityAvatarB64 = ''; patch.identityName = ''; }+            if (typeof args.avatarB64 === 'string') patch.identityAvatarB64 = args.avatarB64.replace(/^data:image\/\w+;base64,/, '');+            if (typeof args.name === 'string') patch.identityName = args.name;+            if (args.enabled === true || args.enabled === false) patch.ownerChip = args.enabled ? 'on' : 'off';+            savePupSettings(patch);+            // Repaint live windows NON-BLOCKING (each per-page evaluate is timeout-bounded inside+            // applyOwnerChipSetting): remove the current chip, then re-add with the new identity if the+            // chip is on. Fire-and-forget so a slow page can never make THIS verb time out.+            applyOwnerChipSetting(false)+              .then(() => { if (pupSettings().ownerChip === 'on') return applyOwnerChipSetting(true); })+              .catch(() => {});+            const s = pupSettings();+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true,+              identityName: s.identityName || null,+              hasAvatar: !!s.identityAvatarB64,+              avatarBytes: s.identityAvatarB64 ? Buffer.from(s.identityAvatarB64, 'base64').length : 0,+              ownerChip: s.ownerChip,+              _hint: 'Adom identity set. It shows as the avatar leading the floating owner chip (bottom-left) on every pup window, applied live. Chrome\'s own profile chip cannot take a custom local image (Google sign-in only), so this is where your Adom identity lives. Turn the chip off with ownerChip:off or {enabled:false}; clear the identity with {clear:true}.' }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'set_identity_failed', error: `pup_set_identity failed: ${e.message}` });+          }+          return;+        }+        case 'pup_maximize': {+          // v1.9.197 GAP 6 (Bills thread): honouring "pup windows must be maximized" required+          // pup_eval + window.resizeTo(...) because desktop_set_window_state is not in every+          // desktop's verb set. This does it properly via CDP Browser.setWindowBounds (the same+          // channel park uses), so it works on any host and never depends on an AD verb being present.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          try {+            const cdp = await session.page.target().createCDPSession();+            const { windowId } = await cdp.send('Browser.getWindowForTarget');+            const want = (args.state === 'normal' || args.state === 'minimized' || args.state === 'fullscreen') ? args.state : 'maximized';+            // A window must be 'normal' before another state can be applied.+            try { await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } }); } catch (e) {}+            await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: want } });+            let bounds = null;+            try { ({ bounds } = await cdp.send('Browser.getWindowBounds', { windowId })); } catch (e) {}+            try { await cdp.detach(); } catch (e) {}+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, state: want, bounds,+              _hint: 'Window state set via CDP. This does NOT foreground the window — it stays background-parked unless you explicitly raise it (pup_raise_os_window with a foregroundReason).' }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'maximize_failed', error: `pup_maximize failed: ${e.message}` });+          }+          return;+        }+        // pup_evaluate is the catalog/manifest name; pup_eval the historical one. Accept both.+        // v2.0.255 (John, adom/pup-bridge#65): this alias was written as a fallthrough label above+        // `case 'pup_hover'` instead of here, so EVERY pup_evaluate call ran the HOVER handler and+        // came back "pup_hover needs selector, or x + y" / hover_target_missing — an error naming a+        // verb the caller never called, while the _hint stayed pup_evaluate's own (that split is the+        // tell for a misdispatch, and it is worth remembering: right docs + wrong error = routing).+        // It made the only verb that can READ the DOM unusable, so a page could be driven but never+        // asserted on, which is exactly where it left John mid-ralph.+        case 'pup_evaluate':+        case 'pup_eval': {+          // He probed expr/expression/script/js/code, all four failing identically because none of+          // them ever reached this handler. Since the catalog did not state the arg name either,+          // accept the obvious synonyms rather than making anyone guess again.+          for (const _a of ['expression', 'script', 'js', 'code']) { if (!args.expr && args[_a]) args.expr = args[_a]; }+          if (!args.expr) {+            sendJSON(res, { success: false, errorCode: 'eval_expr_missing', error: 'Missing required parameter "expr" (aliases: "expression", "script", "js", "code"). Usage: pup_eval { "sessionId": "...", "expr": "document.title" }' });+            return;+          }+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          // v1.8.21: ensure the shadow-DOM helpers are present so an expr can use+          // $deep(sel) / $$deep(sel) / deepText(txt) without hand-rolling a shadow walk.+          await ensureDeepHelpers(tab.page);+          // v1.9.197 GAP 5 (Bills thread): pup_eval only ever ran in the TOP frame, so every call+          // against a login form inside an iframe had to be hand-wrapped in+          // document.getElementById('logonbox').contentDocument — which only works because that frame+          // happened to be SAME-ORIGIN. A cross-origin login iframe (very common) was simply+          // unreachable. `frame` selects the execution context by name/id, url substring, or index;+          // puppeteer talks to each frame over its own CDP session, so cross-origin works too.+          let _evalCtx = tab.page;+          if (args.frame !== undefined && args.frame !== null && args.frame !== '') {+            const _frames = tab.page.frames();+            const _fq = String(args.frame);+            let _f = null;+            if (/^\d+$/.test(_fq)) _f = _frames[Number(_fq)] || null;+            if (!_f) _f = _frames.find(fr => { try { return fr.name() === _fq; } catch (e) { return false; } }) || null;+            if (!_f) _f = _frames.find(fr => { try { return (fr.url() || '').includes(_fq); } catch (e) { return false; } }) || null;+            if (!_f) {+              const _avail = _frames.map((fr, i) => { let nm = '', u = ''; try { nm = fr.name(); } catch (e) {} try { u = fr.url(); } catch (e) {} return `[${i}] name=${nm || '(none)'} url=${(u || '').slice(0, 70)}`; });+              sendJSON(res, { success: false, errorCode: 'frame_not_found',+                error: `No frame matched "${_fq}" in session "${resolvedId}".`,+                frames: _avail,+                _hint: `frame matches by frame NAME, then by URL substring, then by numeric index. Frames currently in this tab:\n${_avail.join('\n')}\nNote an iframe's id attribute is NOT its frame name — use its name attribute, a distinctive piece of its src URL, or the index. Omit frame to run in the top document.` });+              return;+            }+            _evalCtx = _f;+          }+          let result;+          try {+            result = await withTimeout(+              raceCrash(tab, resolvedId, _evalCtx.evaluate(new Function('return (' + args.expr + ')'))),+              20_000, 'page.evaluate'+            );+          } catch (e) {+            // v1.9.158: a DETACHED page handle is a recovery signal, not a result. Re-resolve the+            // session's live page by window.name (the durable carrier) and retry ONCE — this is the+            // armor for the detached-frame-under-load failure (two sessions went permanently+            // undrivable in the 2026-07-27 heavy run because this error was returned as __error).+            if (/detached Frame|Session closed|Target closed/i.test(e.message || '')) {+              try {+                const be2 = browsers.get(session.profileName);+                let fresh = null;+                if (be2 && be2.browser.isConnected()) {+                  for (const pg of await be2.browser.pages()) {+                    const wn = await pg.evaluate(() => window.name).catch(() => '');+                    if (wn === 'pup:' + resolvedId) { fresh = pg; break; }+                  }+                }+                if (fresh) {+                  tab.page = fresh;+                  try { fresh._pupCreated = true; } catch (e3) {}+                  console.log(`[recover] "${resolvedId}" page handle was detached — re-resolved live page by window.name, retrying eval`);+                  result = await withTimeout(fresh.evaluate(new Function('return (' + args.expr + ')')), 20_000, 'page.evaluate(retry)');+                } else { result = { __error: e.message + ' (re-resolve by window.name found no live page)' }; }+              } catch (e2) { result = { __error: e.message + ' (recovery retry failed: ' + e2.message + ')' }; }+            } else {+              result = { __error: e.message };+            }+          }+          // v2.0.274 (#69, second half): STATEMENTS, and stop swallowing the error.+          // The eval above is `new Function('return (' + expr + ')')` — EXPRESSION-only. The reporter+          // sent `location.assign('...'); 'navigating'`, which is two statements, so it was a+          // SyntaxError: nothing ran, the page never navigated, and the handler still answered+          // ok:true with the failure buried in result.__error. Measured verbatim:+          //     result: {"__error": "Unexpected token ';'"}   returned as a SUCCESS+          // Fix 1: on a SYNTAX error, retry through CDP Runtime.evaluate, which has devtools-console+          // semantics — it accepts a statement list and returns the completion value, so the same+          // source works and 'navigating' comes back.+          if (result && result.__error && /Unexpected token|SyntaxError|Unexpected end of input/i.test(String(result.__error))) {+            let cdpSession = null;+            try {+              cdpSession = await _evalCtx.target().createCDPSession();+              const r = await withTimeout(cdpSession.send('Runtime.evaluate', {+                expression: String(args.expr), returnByValue: true, awaitPromise: true, userGesture: true,+              }), 20_000, 'Runtime.evaluate');+              if (r && r.exceptionDetails) {+                result = { __error: (r.exceptionDetails.exception && r.exceptionDetails.exception.description) || r.exceptionDetails.text || 'evaluation threw' };+              } else {+                result = r && r.result ? r.result.value : null;+                console.log(`[eval] "${resolvedId}" expression form was a syntax error — ran it as STATEMENTS via Runtime.evaluate (devtools-console semantics)`);+              }+            } catch (e) {+              // A navigation started by the source tears down the context; that is the code WORKING.+              if (/context was destroyed|Execution context|Target closed/i.test(e.message || '')) {+                console.log(`[eval] "${resolvedId}" execution context was destroyed mid-eval — that means the page NAVIGATED, which is success, not failure`);+                result = { navigated: true };+              }+            } finally { try { if (cdpSession) await cdpSession.detach(); } catch (e) {} }+          }+          // Fix 2: a failed eval is a FAILED verb. #69's whole complaint is verbs that answer ok+          // while nothing happened; returning {ok:true, result:{__error}} is that exact lie.+          if (result && typeof result === 'object' && result.__error) {+            sendJSON(res, { success: false, errorCode: 'eval_failed', error: `pup_eval could not run your code: ${result.__error}`,+              _hint: 'The expression form is tried first, then a statement list via Runtime.evaluate. If this is a syntax error, check the source; if it names a detached frame or closed target, the tab moved and pup already retried once.' });+            return;+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, result, sessionId: resolvedId, tabId }) });+          return;+        }+        // ── Element-level input + history nav (v1.8.8) ─────────────────────────+        // These five were advertised in bridge.json + pup_describe but had no+        // router cases (found by the full verb self-test). Selector-based where it+        // helps; same session/tab auto-recovery as pup_eval.+        case 'pup_click': {+          if (!args.selector && !(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {+            sendJSON(res, { success: false, error: 'pup_click needs "selector" OR "x"+"y". Usage: { "sessionId": "...", "selector": "#submit" } or { "sessionId": "...", "x": 100, "y": 200 }' });+            return;+          }+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          try {+            // v1.9.164: show the animated cursor gliding to the target BEFORE the real click, so the+            // user can see pup is driving. Default on; {showCursor:false} skips it.+            const _cursor = args.showCursor !== false;+            // v1.9.197 BUG 2: build real click options so clickCount/button/delay actually apply.+            const _cc = Number(args.clickCount);+            const _clickOpts = {};+            if (Number.isFinite(_cc) && _cc >= 1) _clickOpts.clickCount = Math.min(3, Math.round(_cc));+            if (typeof args.button === 'string') _clickOpts.button = args.button;+            if (Number.isFinite(Number(args.delay))) _clickOpts.delay = Number(args.delay);+            // v1.9.196: the cursor LABEL must narrate WHAT is being clicked and WHY — that is the whole+            // point of showing a cursor (John: "i can't even read the indicator text you're supposed to+            // put in there explaining your reasoning"). Caller may pass `intent`; otherwise pup composes+            // an HD-style narration from the target itself, so the label is never a bare "Adom".+            const _tgt = args.selector || (args.text ? `"${args.text}"` : null);+            const _intent = (typeof args.intent === 'string' && args.intent.trim())+              ? args.intent.trim()+              : (args.reason && String(args.reason).trim() ? String(args.reason).trim() : (_tgt ? `Clicking ${_tgt}` : 'Clicking'));+            let _method = 'cdp';+            if (args.selector && args.domClick === true) {+              // v2.0.128 (consumer bug report #2): some React controls (heart/like/vote toggles)+              // ignore a CDP "trusted" mouse click but DO fire on a synthetic element.click(). This+              // is the opposite of pup's usual advice, so it's opt-in — the miss-hint below points+              // callers here when a trusted click produced no state change.+              _method = 'domClick';+              const dr = await withTimeout(tab.page.evaluate((sel) => {+                const el = document.querySelector(sel);+                if (!el) return { found: false };+                el.scrollIntoView({ block: 'center', inline: 'center' });+                el.click();+                return { found: true };+              }, args.selector), 8_000, 'domClick.evaluate');+              if (!dr.found) { sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_click(domClick): selector "${args.selector}" not found.`, _hint: `Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` }); return; }+            } else if (args.selector) {+              // v2.0.128 (consumer bug report #3): page.click(selector) re-resolves the element and+              // waits on Puppeteer's visibility machinery, which crawls on huge/animated DOMs and hit+              // the 15s wall — while a plain coordinate click on the SAME element returned instantly.+              // So: locate the element ONCE (scroll into view, read its center), then click by those+              // coordinates — the fast path the caller proved works. Only fall back to page.click when+              // the box can't be resolved (0-size / not laid out yet).+              let _box = null;+              try {+                _box = await withTimeout(tab.page.evaluate((sel) => {+                  const el = document.querySelector(sel);+                  if (!el) return null;+                  el.scrollIntoView({ block: 'center', inline: 'center' });+                  const r = el.getBoundingClientRect();+                  if (r.width < 1 || r.height < 1) return null;+                  return { x: r.x + r.width / 2, y: r.y + r.height / 2 };+                }, args.selector), 6_000, 'locate.box');+              } catch (e) {}+              if (_box) {+                if (_cursor) { await showPupCursor(tab.page, _box.x, _box.y, { label: _intent }); await new Promise(r => setTimeout(r, 440)); }+                _method = 'coordinate';+                await withTimeout(tab.page.mouse.click(_box.x, _box.y, _clickOpts), 10_000, 'mouse.click(box)');+              } else {+                // element not found/visible yet — bounded wait, then Puppeteer's own click (honours clickCount).+                _method = 'page.click';+                await withTimeout(tab.page.click(args.selector, _clickOpts), 12_000, 'page.click');+              }+            } else {+              if (_cursor) { await showPupCursor(tab.page, Number(args.x), Number(args.y), { label: _intent }); await new Promise(r => setTimeout(r, 440)); }+              _method = 'coordinate';+              await withTimeout(tab.page.mouse.click(Number(args.x), Number(args.y), _clickOpts), 10_000, 'mouse.click');+            }+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: args.selector || { x: Number(args.x), y: Number(args.y) }, method: _method, sessionId: resolvedId, tabId, _hint: (args.selector && _method !== 'domClick') ? 'Click dispatched (isTrusted). Verify the effect with pup_eval (a state probe) — a click that hit nothing still returns ok. If a React toggle/like/heart did NOT change state, that control ignores trusted clicks: retry pup_click {selector, domClick:true} to fire the synthetic handler.' : 'Click dispatched. Verify the effect with pup_eval (e.g. document.title or a state probe) — a click that hit nothing still returns ok.' }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'click_failed', error: `pup_click failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist/be visible. Probe it first: pup_eval {"expr":"!!document.querySelector('${args.selector}')"} — or click by x/y from a pup_screenshot.` : 'x/y click failed — coordinates are page-viewport pixels; take a pup_screenshot to aim.' });+          }+          return;+        }+        // v1.9.167 — ASSISTED FOREGROUND CLICK (John's full protocol) for surfaces pup's CDP click+        // can't drive (native dialog, cross-origin FedCM chooser). Caption the reasoning, toast,+        // foreground, WAIT until the user is actually on the window (alt-tab aware), then a TIGHT+        // real-cursor hover->click, captioning each step. x,y = WINDOW-image pixels from a+        // desktop_screenshot_window you took to find the target. reason=why, label=what.+        case 'pup_assisted_click': {+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          if (!(Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y)))) {+            sendJSON(res, { success: false, error: 'pup_assisted_click needs window-image x+y (from a desktop_screenshot_window of this session) plus reason+label.' }); return;+          }+          if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'assisted click is Windows-only (uses the OS cursor + foreground detection).' }); return; }+          const _reason = String(args.reason || 'Adom needs to click something on your screen.').slice(0, 180);+          const _label = String(args.label || 'the highlighted control').slice(0, 80);+          try {+            await pupCaption(`Adom: ${_reason} I will bring this window up and WAIT until you are ready before clicking ${_label}.`, 9000);+            try { await chrome.abCommand('notify_user', { title: 'Adom needs one click', body: `${_label} — ${_reason} I will wait until you are on the window.`, focus: false }, { timeoutMs: 4000 }); } catch (e) {}+            session._foreground = true; session._lastForeground = { ts: Date.now(), verb: command, reason: _reason };+            try { await osRaiseSessionWindow(session, resolvedId, null); } catch (e) {}   // assisted_click narrates its own captions+            const hwnd = await sessionHwnd(resolvedId);+            // v2.0.397 (#92): the in-verb wait is capped under every ab budget; a longer human+            // absence answers blocked_on_user (the caller re-issues the click when the user is back).+            const _maxWait = Math.min(Number(args.maxWaitMs) || 120000, 45000);+            const ready = await waitForUserReady(resolvedId, hwnd, { maxMs: _maxWait });+            if (!ready) {+              await pupCaption('Adom is still waiting for you to come back to this window; your AI will ask again when you are ready.', 7000);+              sendJSON(res, blockedOnUser({ stillRunning: false, reason: 'user_not_ready', blockedBy: 'The user must bring the pup window to the foreground (click it or Alt-Tab to it) so the real-cursor click lands in the right window', waitedSec: Math.round(_maxWait / 1000), error: 'Waited for the user to focus the window; they did not. Nothing was clicked.', _hint: 'Not a failure: the human was not at the window. Tell them what you need clicked and where, then re-issue pup_assisted_click with the same args when they say they are ready; it waits again (up to 45s per call).' }));+              return;+            }+            await pupCaption(`Adom is clicking ${_label} now.`, 5000);+            const shot = abPayload(await chrome.abCommand('desktop_screenshot_window', { ...(await windowTarget(sessions.get(resolvedId), resolvedId)) }, { timeoutMs: 8000, caller: sessionCaller(session) }));+            const shotId = shot && (shot.shotId || (shot.screenshots && shot.screenshots[0] && shot.screenshots[0].shotId));+            if (!shotId) { sendJSON(res, { success: false, error: 'could not capture the window to aim the click' }); return; }+            const X = Math.round(Number(args.x)), Y = Math.round(Number(args.y));+            try { await chrome.abCommand('desktop_hover', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}+            await new Promise(r => setTimeout(r, 900));+            try { await chrome.abCommand('desktop_click', { space: 'image', shotId, x: X, y: Y }, { timeoutMs: 6000, caller: sessionCaller(session) }); } catch (e) {}+            await pupCaption(`Adom clicked ${_label}. Continuing.`, 5000);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, clicked: _label, x: X, y: Y, sessionId: resolvedId }), _hint: `Assisted click done after the user was confirmed on the window. Verify the effect with a screenshot before assuming it advanced.` });+          } catch (e) {+            sendJSON(res, { success: false, error: `assisted click failed: ${e.message}` });+          }+          return;+        }+        case 'pup_type': {+          if (typeof args.text !== 'string') {+            sendJSON(res, { success: false, error: 'pup_type needs "text" (string). Optional "selector" focuses the element first. Usage: { "sessionId": "...", "selector": "#q", "text": "hello" }' });+            return;+          }+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          try {+            // v1.9.197 BUG 3 (Bills thread, top priority): pup_type ALWAYS APPENDED, and with+            // modifiers broken (bug 1) and clickCount ignored (bug 2) there was NO way to replace a+            // field's value at all — the caller got "adominc1adominc1" and fell back to a bash loop of+            // 20 Backspaces. `clear:true` now empties the field first, using REAL key events+            // (select-all then Delete) rather than setting .value, because React-controlled inputs+            // ignore direct value writes — the Chase login form is exactly that case.+            if (args.clear) {+              if (args.selector) { await withTimeout(tab.page.focus(args.selector), 8_000, 'page.focus'); }+              const _selMod = process.platform === 'darwin' ? 'Meta' : 'Control';+              await tab.page.keyboard.down(_selMod);+              try { await tab.page.keyboard.press('a'); } finally { try { await tab.page.keyboard.up(_selMod); } catch (e) {} }+              await tab.page.keyboard.press('Delete');+            }+            if (args.selector) await withTimeout(tab.page.type(args.selector, args.text), 15_000, 'page.type');+            else await withTimeout(tab.page.keyboard.type(args.text), 15_000, 'keyboard.type');+            // v2.0.128 (consumer bug report #1 — highest priority): pup_type reported ok:true on a+            // field that stayed EMPTY, so a sign-in silently no-opped while pup claimed success. Read+            // the value back when we have a selector, and if what we typed did not land, still return+            // ok:true (the keystrokes WERE dispatched) but flag valueLanded:false with the real reason:+            // CDP/page.keyboard keystrokes only register in a FOREGROUNDED window, and this window is+            // very likely in the background. Point at the two working paths.+            let _valueLanded = null, _readLen = null;+            if (args.selector) {+              try {+                _readLen = await withTimeout(tab.page.$eval(args.selector, el => {+                  if (el == null) return -1;+                  if (typeof el.value === 'string') return el.value.length;+                  if (el.isContentEditable) return (el.textContent || '').length;+                  return -2; // element exists but has no value/textContent we can read+                }), 5_000, 'verify.$eval');+                if (_readLen >= 0) _valueLanded = _readLen >= Math.min(args.text.length, 1);+              } catch (e) { _readLen = null; }+            }+            const _emptyMiss = _valueLanded === false && args.text.length > 0;+            const _out = { ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId };+            if (args.selector) { _out.valueLanded = _valueLanded; _out.fieldValueLength = _readLen; }+            _out._hint = _emptyMiss+              ? '⚠ KEYSTROKES DISPATCHED BUT THE FIELD IS STILL EMPTY — this is the silent no-op. CDP/page.keyboard keystrokes ONLY register when the window is FOREGROUNDED, and this window is almost certainly in the background. Two fixes: (1) for a NON-React input, set the value directly — pup_eval will not (expression-only), so use pup_fill {selector,text} which sets .value via the native setter + dispatches input/change and works in the background; (2) for a login form (React-masked fields, real keystrokes required) use pup_login {username,password,realKeystrokes:true}, which foregrounds the window automatically. Do NOT trust a bare ok:true from pup_type on a login field — check valueLanded.'+              : (_valueLanded === true ? 'Typed via CDP (isTrusted); value verified present in the field.' : 'Typed via CDP (isTrusted). Could not read the field back to verify (no selector, or non-standard element) — confirm with a screenshot before assuming it landed.');+            sendJSON(res, { success: true, output: JSON.stringify(_out) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'type_failed', error: `pup_type failed: ${e.message}`, _hint: args.selector ? `Selector "${args.selector}" may not exist. Probe: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` : 'No selector given — ensure an element has focus (pup_click it first).' });+          }+          return;+        }+        // v2.0.128 (consumer bug report #1): the BACKGROUND-safe fallback that pup_type's miss-hint+        // points at. Sets the field's value via the native property setter and dispatches input ++        // change so React/Vue/Angular controlled inputs pick it up — no foreground, no keystrokes.+        // Works for the common "logged-out form filled while the window is in the background" case.+        // For React-MASKED password fields that ignore programmatic value writes, pup_login+        // {realKeystrokes:true} is still the answer (it foregrounds + sends real keys). pup_fill+        // ALWAYS reads the value back and reports valueLanded so a caller never gets a false ok.+        case 'pup_fill': {+          if (!args.selector || typeof args.text !== 'string') {+            sendJSON(res, { success: false, error: 'pup_fill needs "selector" and "text" (string). It sets .value via the native setter + fires input/change (React-safe, works in the background). Usage: { "sessionId": "...", "selector": "#email", "text": "[email protected]" }' });+            return;+          }+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          try {+            const r = await withTimeout(tab.page.evaluate((sel, val) => {+              const el = document.querySelector(sel);+              if (!el) return { found: false };+              // Use the prototype's native value setter so React's descriptor override doesn't swallow it.+              try {+                const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;+                const desc = Object.getOwnPropertyDescriptor(proto, 'value');+                if (desc && desc.set) desc.set.call(el, val); else el.value = val;+              } catch (e) { try { el.value = val; } catch (e2) {} }+              el.dispatchEvent(new Event('input', { bubbles: true }));+              el.dispatchEvent(new Event('change', { bubbles: true }));+              const got = (typeof el.value === 'string') ? el.value : (el.textContent || '');+              return { found: true, landedLen: got.length, matched: got === val };+            }, args.selector, args.text), 8_000, 'pup_fill.evaluate');+            if (!r.found) {+              sendJSON(res, { success: false, errorCode: 'selector_not_found', error: `pup_fill: selector "${args.selector}" not found.`, _hint: `Probe it: pup_eval {"expr":"!!document.querySelector('${args.selector}')"}` });+              return;+            }+            const _landed = r.matched === true || r.landedLen >= Math.min(args.text.length, 1);+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true, filled: args.selector, valueLanded: _landed, fieldValueLength: r.landedLen, sessionId: resolvedId, tabId,+              _hint: _landed+                ? 'Set via native setter + input/change (background-safe). If a React SUBMIT still rejects it as empty, the field is masked/guarded — use pup_login {username,password,realKeystrokes:true} which foregrounds + sends real keystrokes.'+                : '⚠ Value did not stick — this field guards against programmatic writes (React-masked password fields do this). Use pup_login {realKeystrokes:true} (foregrounds + real keys) instead.'+            }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'fill_failed', error: `pup_fill failed: ${e.message}`, _hint: 'The page may have navigated. Re-check the field with pup_eval, or retry.' });+          }+          return;+        }+        case 'pup_press_key': {+          if (!args.key) {+            sendJSON(res, { success: false, error: 'pup_press_key needs "key" — a puppeteer KeyInput name like "Enter", "Tab", "Escape", "ArrowDown", "a". Usage: { "sessionId": "...", "key": "Enter" }' });+            return;+          }+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          try {+            // v1.9.197 BUG 1 (Bills thread): modifiers were parsed by NOBODY — keyboard.press(key)+            // ignores them entirely, so Ctrl+A typed a literal "a" into the field ("adominc1" ->+            // "adominc1a") and every chord (Ctrl+A/C/V) was unusable. Hold each modifier down, press,+            // then release in reverse order — the CDP-correct way to send a real chord.+            const _mods = Array.isArray(args.modifiers) ? args.modifiers.filter(m => typeof m === 'string' && m.trim()) : [];+            const _MODMAP = { control: 'Control', ctrl: 'Control', cmd: 'Meta', command: 'Meta', meta: 'Meta', alt: 'Alt', option: 'Alt', shift: 'Shift' };+            const _norm = _mods.map(m => _MODMAP[String(m).toLowerCase()] || m);+            const _bad = _norm.filter(m => !['Control', 'Meta', 'Alt', 'Shift'].includes(m));+            if (_bad.length) {+              sendJSON(res, { success: false, errorCode: 'bad_modifier', error: `Unknown modifier(s): ${_bad.join(', ')}.`, _hint: 'modifiers must be any of ["Control","Shift","Alt","Meta"] (aliases: ctrl, cmd, command, option accepted).' });+              return;+            }+            for (const m of _norm) await tab.page.keyboard.down(m);+            try {+              await withTimeout(tab.page.keyboard.press(args.key), 10_000, 'keyboard.press');+            } finally {+              for (const m of [..._norm].reverse()) { try { await tab.page.keyboard.up(m); } catch (e) {} }+            }+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, pressed: args.key, sessionId: resolvedId, tabId, _hint: 'Key pressed in the PAGE (isTrusted). For OS-level chords outside the page (alt+f4, ctrl+shift+p in browser chrome), use AD\'s desktop_press_key instead.' }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'press_key_failed', error: `pup_press_key failed: ${e.message}`, _hint: `"${args.key}" must be a valid puppeteer KeyInput name (case-sensitive: "Enter", "ArrowLeft", "F5", single chars like "a").` });+          }+          return;+        }+        case 'pup_back':+        case 'pup_forward': {+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const dir = command === 'pup_back' ? 'back' : 'forward';+          try {+            let before = null; try { before = tab.page.url(); } catch {}+            // goBack/goForward resolve null on a CACHED history nav even though the URL+            // DID change, so we detect "moved" by the URL actually changing, not the+            // (unreliable) return value.+            await withTimeout(dir === 'back' ? tab.page.goBack({ waitUntil: 'domcontentloaded' }) : tab.page.goForward({ waitUntil: 'domcontentloaded' }), 20_000, 'history.' + dir).catch(() => {});+            let url = null; try { url = tab.page.url(); } catch {}+            const moved = !!(url && before && url !== before);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, direction: dir, moved, url, sessionId: resolvedId, tabId, _hint: moved ? `Now at ${url}.` : `No ${dir === 'back' ? 'earlier' : 'later'} history entry — the page did not move (moved:false, not an error).` }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: dir + '_failed', error: `pup_${dir} failed: ${e.message}` });+          }+          return;+        }+        case 'pup_input_dispatch': {+          // Trusted input via puppeteer's CDP-backed page.mouse / page.keyboard.+          // Events fired here have isTrusted=true (Chromium dispatches them at the+          // browser level via Input.dispatchMouseEvent / dispatchKeyEvent), unlike+          // page-side dispatchEvent(new MouseEvent(...)) which is isTrusted=false+          // and silently no-ops on framework click handlers / anti-automation gates.+          //+          // Types:+          //   click  — { x, y } coords, OR { selector } resolves bounding rect+          //   move   — { x, y } mouse move (useful for human-like trajectories)+          //   type   — { text } types into the focused element+          //   key    — { key } presses a single key (Enter, Escape, Tab, ArrowUp, etc.)+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }++          const t = (args.type || '').toLowerCase();+          if (!t) {+            sendJSON(res, { success: false, error: 'pup_input_dispatch: "type" required (click|move|type|key)' });+            return;+          }+          try {+            if (t === 'click') {+              if (args.selector) {+                // SMART SELECTOR PICK (v1.4.8+):+                //+                // Puppeteer's page.click(selector) blindly takes the first+                // document-order match. That bit users on WAGO when+                // `button.wg-button--primary` matched 4 elements (a 54x54+                // cookie-banner X with no text, two visible Add-to-cart+                // buttons, the actual Generate-datasheet button) — and the+                // cookie X got clicked. The relay returned ok:true because+                // a trusted click DID dispatch — just on the wrong element.+                //+                // We now run all matches through page.evaluate() to:+                //   1. Filter out hidden elements (display:none, opacity:0,+                //      offscreen, zero-area, behind the topmost stacking+                //      context modal)+                //   2. Filter out elements with empty text content (modal+                //      close buttons / icon-only utility buttons usually+                //      aren't what the caller wanted when they passed a+                //      class selector)+                //   3. Tie-break by visual area (largest wins — the actual+                //      submit button beats a small icon button)+                //+                // The chosen element's index, rect, and visible text are+                // returned in the response so the caller can sanity-check.+                // Pass `firstMatch:true` to skip filtering and use legacy+                // first-document-order behavior.+                const matches = await tab.page.$$(args.selector);+                if (matches.length === 0) {+                  sendJSON(res, { success: false, error: `selector not found: ${args.selector}`, _hint: 'Selector matches no element. Verify the page is loaded (pup_wait) and the selector is correct (try with pup_eval first).' });+                  return;+                }++                let chosenHandle = matches[0];+                let chosenIndex = 0;+                let chosenMeta = null;+                // v1.4.10: surface which DOM element won the modal-root+                // detection in the response, so callers can debug false-+                // positives. Stays null in the single-match / firstMatch+                // branches since modal scoping doesn't apply there.+                let modalRootInfo = null;++                if (matches.length === 1 || args.firstMatch === true) {+                  // Single match OR explicit opt-out — use document-order first.+                  chosenMeta = await tab.page.evaluate((el) => {+                    if (!el) return null;+                    const r = el.getBoundingClientRect();+                    return {+                      x: Math.round(r.x), y: Math.round(r.y),+                      w: Math.round(r.width), h: Math.round(r.height),+                      text: (el.textContent || '').trim().slice(0, 80),+                    };+                  }, chosenHandle);+                } else {+                  // Multiple matches — score them and pick the best.+                  //+                  // v1.4.9+: when an open modal/dialog is on screen, restrict+                  // the candidate set to elements INSIDE it. The page behind+                  // a modal is visually inert; clicking elements behind a+                  // modal is almost never what the user intended. WAGO bit+                  // on this: their "Add to shopping cart" button (behind the+                  // modal) was 9648 px², the modal's "Generate data sheet"+                  // was 9264 px² — area-only tiebreak picked add-to-cart.+                  // Now we detect the modal root first and only look inside.+                  const evalResult = await tab.page.evaluate((selector) => {+                    // v1.4.10: A modal-root candidate is only "plausible" if it+                    // contains at least one visible interactive element with+                    // non-empty text. Without this filter, empty overlay+                    // containers (Vue-Toastification's 4 toast wrappers,+                    // notification mount points, full-screen ad scrims, etc.)+                    // win the heuristic — they're fixed/absolute, z ≥ 100, cover+                    // >25% of viewport — but contain no interactive content.+                    // Result before the fix: smart-pick scoped to the wrong+                    // root and missed the actual modal entirely (WAGO bug:+                    // pickStrategy ended up "visible-text-largest-no-modal-match"+                    // and clicked "Add to shopping cart" in the page behind+                    // the real Generate-Datasheet modal).+                    function isPlausibleModal(el) {+                      if (!el) return false;+                      const interactives = el.querySelectorAll(+                        'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex], [onclick]'+                      );+                      for (const child of interactives) {+                        const cs = getComputedStyle(child);+                        if (cs.display === 'none' || cs.visibility === 'hidden') continue;+                        if (parseFloat(cs.opacity || '1') === 0) continue;+                        const r = child.getBoundingClientRect();+                        if (r.width === 0 || r.height === 0) continue;+                        const text = (child.textContent || '').trim();+                        if (text.length > 0) return true;+                      }+                      return false;+                    }++                    // Find the topmost modal/dialog root if one is open.+                    // Order matches what a human visually sees:+                    //   1. <dialog open>                    (HTML5 native)+                    //   2. [aria-modal="true"]              (ARIA)+                    //   3. role="dialog" + visible          (ARIA fallback)+                    //   4. fixed/absolute element with z-index ≥ 100+                    //      AND covering ≥25% of viewport    (custom modals)+                    //+                    // v1.4.10: every tier is now post-filtered through+                    // isPlausibleModal so we don't latch onto empty overlays.+                    // Each tier returns the most-recently-added (last in+                    // document order) plausible candidate, walking backward+                    // through matches if the topmost is implausible.+                    function topmostModalRoot() {+                      const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));+                      for (let i = openDialogs.length - 1; i >= 0; i--) {+                        if (isPlausibleModal(openDialogs[i])) return openDialogs[i];+                      }+                      const ariaModals = Array.from(document.querySelectorAll('[aria-modal="true"]'))+                        .filter(el => {+                          const cs = getComputedStyle(el);+                          return cs.display !== 'none' && cs.visibility !== 'hidden';+                        });+                      for (let i = ariaModals.length - 1; i >= 0; i--) {+                        if (isPlausibleModal(ariaModals[i])) return ariaModals[i];+                      }+                      const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))+                        .filter(el => {+                          const cs = getComputedStyle(el);+                          if (cs.display === 'none' || cs.visibility === 'hidden') return false;+                          const r = el.getBoundingClientRect();+                          return r.width > 0 && r.height > 0;+                        });+                      for (let i = dialogs.length - 1; i >= 0; i--) {+                        if (isPlausibleModal(dialogs[i])) return dialogs[i];+                      }+                      // Custom modals (no ARIA, just a styled overlay)+                      const candidates = Array.from(document.querySelectorAll('*'))+                        .filter(el => {+                          const cs = getComputedStyle(el);+                          if (!['fixed', 'absolute'].includes(cs.position)) return false;+                          const z = parseInt(cs.zIndex || '0', 10);+                          if (isNaN(z) || z < 100) return false;+                          const r = el.getBoundingClientRect();+                          const viewportArea = window.innerWidth * window.innerHeight;+                          if (viewportArea === 0) return false;+                          return (r.width * r.height) / viewportArea > 0.25;+                        })+                        .sort((a, b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));+                      for (const c of candidates) {+                        if (isPlausibleModal(c)) return c;+                      }+                      return null;+                    }+                    const modalRoot = topmostModalRoot();+                    const modalRootInfo = modalRoot ? {+                      tag: modalRoot.tagName.toLowerCase(),+                      id: modalRoot.id || null,+                      cls: ((modalRoot.className && typeof modalRoot.className === 'string')+                        ? modalRoot.className+                        : ((modalRoot.className && modalRoot.className.baseVal) || '')).slice(0, 80),+                      z: parseInt(getComputedStyle(modalRoot).zIndex || '0', 10),+                      role: modalRoot.getAttribute('role') || null,+                      ariaModal: modalRoot.getAttribute('aria-modal') || null,+                    } : null;++                    const els = Array.from(document.querySelectorAll(selector));+                    const scored = els.map((el, idx) => {+                      const r = el.getBoundingClientRect();+                      const cs = getComputedStyle(el);+                      const visible =+                        cs.display !== 'none' &&+                        cs.visibility !== 'hidden' &&+                        parseFloat(cs.opacity || '1') > 0 &&+                        r.width > 0 && r.height > 0 &&+                        r.bottom > 0 && r.right > 0 &&+                        r.top < (window.innerHeight || 99999) &&+                        r.left < (window.innerWidth || 99999);+                      const text = (el.textContent || '').trim();+                      const area = r.width * r.height;+                      const insideModal = modalRoot != null && modalRoot.contains(el);+                      return {+                        idx,+                        x: Math.round(r.x), y: Math.round(r.y),+                        w: Math.round(r.width), h: Math.round(r.height),+                        text: text.slice(0, 80),+                        hasText: text.length > 0,+                        visible,+                        area,+                        insideModal,+                        modalDetected: modalRoot != null,+                      };+                    });+                    return { scored, modalRoot: modalRootInfo };+                  }, args.selector);+                  const scored = evalResult.scored;+                  modalRootInfo = evalResult.modalRoot;++                  // Pick priority:+                  //   1. inside-modal AND visible AND has-text → largest area+                  //   2. visible AND has-text                  → largest area+                  //   3. visible (any)                         → largest area+                  //   4. document-order first match (last resort)+                  const modalDetected = scored.length > 0 && scored[0].modalDetected;+                  const inModal = scored.filter(s => s.insideModal && s.visible && s.hasText);+                  const visibleTexted = scored.filter(s => s.visible && s.hasText);+                  const visibleOnly = scored.filter(s => s.visible);+                  let pick = null;+                  let strategy = 'visible-text-largest';+                  if (inModal.length > 0) {+                    pick = inModal.reduce((best, cur) => cur.area > best.area ? cur : best, inModal[0]);+                    strategy = 'modal-scoped-largest';+                  } else if (visibleTexted.length > 0) {+                    pick = visibleTexted.reduce((best, cur) => cur.area > best.area ? cur : best, visibleTexted[0]);+                    strategy = modalDetected ? 'visible-text-largest-no-modal-match' : 'visible-text-largest';+                  } else if (visibleOnly.length > 0) {+                    pick = visibleOnly.reduce((best, cur) => cur.area > best.area ? cur : best, visibleOnly[0]);+                    strategy = 'visible-largest';+                  }+                  if (pick) {+                    chosenIndex = pick.idx;+                    chosenHandle = matches[chosenIndex];+                    chosenMeta = pick;+                    chosenMeta._strategy = strategy;+                  } else {+                    // Nothing visible at all — keep document-order first as last resort+                    // and surface the count so caller knows ambiguity exists.+                    chosenMeta = scored[0];+                    if (chosenMeta) chosenMeta._strategy = 'fallback-first-document-order';+                  }+                }++                // v2.0.321 (#72): scroll into view, resolve the rect, then click via the ISOLATED+                // timeout-bounded CDP path (rawTrustedClick) instead of chosenHandle.click(), which+                // routes through puppeteer's wedge-prone mouse lock. isTrusted=true either way.+                try { await chosenHandle.scrollIntoView(); } catch (e) {}+                let _box = null; try { _box = await chosenHandle.boundingBox(); } catch (e) {}+                if (!_box || _box.width < 1 || _box.height < 1) { sendJSON(res, { success: false, error: 'chosen element has no visible box to click', selector: args.selector }); return; }+                await rawTrustedClick(tab, Math.round(_box.x + _box.width / 2), Math.round(_box.y + _box.height / 2), { button: args.button || 'left', clickCount: args.clickCount || 1 });+                sendJSON(res, { success: true, output: JSON.stringify({+                  ok: true,+                  sessionId: resolvedId,+                  tabId,+                  dispatched: 'click',+                  via: 'selector',+                  selector: args.selector,+                  matchedCount: matches.length,+                  chosenIndex,+                  clickedRect: chosenMeta ? { x: chosenMeta.x, y: chosenMeta.y, w: chosenMeta.w, h: chosenMeta.h } : null,+                  clickedText: chosenMeta ? chosenMeta.text : null,+                  insideModal: chosenMeta && typeof chosenMeta.insideModal === 'boolean' ? chosenMeta.insideModal : null,+                  modalDetected: chosenMeta && typeof chosenMeta.modalDetected === 'boolean' ? chosenMeta.modalDetected : null,+                  // v1.4.10: surface which DOM element won the modal-root+                  // detection so callers can debug false-positives. When the+                  // smart pick lands on the wrong element, this tells you+                  // immediately whether modal detection latched onto the+                  // intended overlay or got fooled by a toast-container etc.+                  modalRoot: modalRootInfo,+                  pickStrategy: matches.length === 1+                    ? 'only-match'+                    : (args.firstMatch === true+                      ? 'first-match'+                      : (chosenMeta && chosenMeta._strategy ? chosenMeta._strategy : 'visible-text-largest')),+                })});+                return;+              }+              if (typeof args.x !== 'number' || typeof args.y !== 'number') {+                sendJSON(res, { success: false, error: 'pup_input_dispatch click: provide either {selector} OR {x, y}' });+                return;+              }+              await rawTrustedClick(tab, args.x, args.y, { button: args.button || 'left', clickCount: args.clickCount || 1 });+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });+              return;+            }+            if (t === 'move') {+              if (typeof args.x !== 'number' || typeof args.y !== 'number') {+                sendJSON(res, { success: false, error: 'pup_input_dispatch move requires {x, y}' });+                return;+              }+              // v1.9.198 (Bills thread): a CDP move returned ok but the DRAWN cursor never budged —+              // only clicks moved it, which undercuts the whole "gliding cursor" idea. Move the+              // visible cursor with the real pointer (no click ripple).+              if (args.showCursor !== false) { try { await showPupCursor(tab.page, Number(args.x), Number(args.y), { click: false, label: args.intent || 'Moving' }); } catch (e) {} }+              await rawTrustedMove(tab, args.x, args.y);+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });+              return;+            }+            if (t === 'type') {+              if (typeof args.text !== 'string') {+                sendJSON(res, { success: false, error: 'pup_input_dispatch type requires {text}' });+                return;+              }+              // Optionally focus a selector first so keystrokes land in the right input.+              if (args.selector) {+                try { await tab.page.focus(args.selector); }+                catch (e) {+                  sendJSON(res, { success: false, error: `Failed to focus ${args.selector}: ${e.message}` });+                  return;+                }+              }+              await withTimeout(tab.page.keyboard.type(args.text, { delay: args.delay || 0 }), 15000, 'input type');+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });+              return;+            }+            if (t === 'key') {+              if (typeof args.key !== 'string') {+                sendJSON(res, { success: false, error: 'pup_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });+                return;+              }+              await withTimeout(tab.page.keyboard.press(args.key, { delay: args.delay || 0 }), 8000, 'input key');+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });+              return;+            }+            sendJSON(res, { success: false, error: `pup_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: e && e.wedge ? 'input_channel_reset' : undefined, error: `pup_input_dispatch ${t} failed: ${e.message}`, _hint: e && e.wedge ? 'The input channel was reset after a hung page handler; the session is NOT wedged — retry the dispatch. If it keeps hanging, the page handler itself (often an unfocused-window clipboard write) is the cause.' : undefined });+          }+          return;+        }+        case 'pup_fetch_url': {+          // Fetch an arbitrary URL with the session's cookies and return the+          // raw bytes (base64 in JSON; CLI optionally writes to disk).+          //+          // Why this exists: when a vendor click spawns a popup tab whose+          // Content-Type is application/pdf, Chrome wraps it in the built-in+          // PDF viewer. In-page `fetch(location.href)` then returns the+          // viewer's HTML wrapper (~200 KB stub), NOT the PDF binary.+          // Bypass: re-issue the request via puppeteer's+          // BrowserContext.request — same cookies, no rendering layer, raw+          // response body. Works for any content type; particularly useful+          // for PDFs, ZIPs, CAD bundles, anything the page would otherwise+          // open in a viewer plugin.+          //+          // saveTo semantics (clarified in v1.4.9 — v1.4.8 was ambiguous):+          //   - This is the BRIDGE side (Windows desktop). The bridge ALWAYS+          //     returns bodyBase64. If the user wants the file on the Docker+          //     side (the common case), the CLI handles the write — see+          //     cli/src/commands.rs::pup_fetch_url. The arg name there+          //     is `saveTo` and it's interpreted as a Docker-side path.+          //   - For the rare case of writing directly on the DESKTOP+          //     filesystem (e.g. dropping a CAD bundle into the user's+          //     Downloads folder so they can open it locally), the CLI+          //     forwards `desktopSaveTo` here, which the bridge writes via+          //     fs.writeFileSync. Returns `desktopSavedTo` field so it's+          //     unambiguous which filesystem the path refers to.+          //+          // Args (bridge-facing):+          //   sessionId       (required)+          //   url             (required)+          //   method          (optional, default GET)+          //   headers         (optional, object — Cookie auto-included from session)+          //   body            (optional, raw string body for POST etc.)+          //   desktopSaveTo   (optional Windows path — write on desktop and+          //                   return desktopSavedTo. Always also returns+          //                   bodyBase64 so caller can verify or save+          //                   container-side too.)+          //   tabId           (optional — picks the cookie context; defaults+          //                   to active tab. Cookies are per-browser-context+          //                   so any tab in the session works.)+          if (!args.url) {+            sendJSON(res, { success: false, error: 'pup_fetch_url requires {url}' });+            return;+          }+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }++          // Helper: optionally write to the desktop filesystem; ALWAYS return+          // bodyBase64 so the CLI can either pass it through to the caller OR+          // base64-decode it and write to a Docker-side path.+          const respondWithBytes = (buf, ct, status) => {+            const out = {+              ok: true,+              bytes: buf.length,+              contentType: ct,+              status,+              sessionId: resolvedId,+              tabId,+              bodyBase64: buf.toString('base64'),+            };+            if (args.desktopSaveTo) {+              try {+                // Resolve the path explicitly so callers can see what was used.+                // Don't silently land at a quirky CWD-relative path like the+                // v1.4.8 saveTo bug did.+                const abs = path.resolve(args.desktopSaveTo);+                const dir = path.dirname(abs);+                fs.mkdirSync(dir, { recursive: true });+                fs.writeFileSync(abs, buf);+                out.desktopSavedTo = abs;+              } catch (e) {+                // Don't lose the bytes — return them in bodyBase64 plus an+                // explicit error so the CLI knows the desktop write failed+                // but the bytes are still recoverable container-side.+                out.desktopSaveError = `desktopSaveTo write to ${args.desktopSaveTo} failed: ${e.message}`;+                out.desktopSavedTo = null;+              }+            }+            sendJSON(res, { success: true, output: JSON.stringify(out) });+          };++          try {+            const browserCtx = tab.page.browserContext();+            const reqClient = browserCtx.request;+            if (!reqClient || typeof reqClient.fetch !== 'function') {+              // Fallback for older puppeteer: do an in-page fetch in a+              // separate clean tab (avoids PDF viewer interception by+              // navigating to about:blank first, then fetching).+              const cleanPage = await browserCtx.newPage();+              try {+                await cleanPage.goto('about:blank');+                const result = await cleanPage.evaluate(async (u, m, h, b) => {+                  const init = { method: m, credentials: 'include', headers: h || {} };+                  if (b != null) init.body = b;+                  const r = await fetch(u, init);+                  const ab = await r.arrayBuffer();+                  const u8 = new Uint8Array(ab);+                  // Convert in chunks — avoids stack overflow on large blobs+                  let bin = '';+                  const CHUNK = 32768;+                  for (let i = 0; i < u8.length; i += CHUNK) {+                    bin += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));+                  }+                  return {+                    base64: btoa(bin),+                    size: u8.length,+                    contentType: r.headers.get('content-type') || '',+                    status: r.status,+                  };+                }, args.url, args.method || 'GET', args.headers || null, args.body || null);+                await cleanPage.close({ runBeforeUnload: false });+                respondWithBytes(Buffer.from(result.base64, 'base64'), result.contentType, result.status);+                return;+              } catch (e) {+                try { await cleanPage.close({ runBeforeUnload: false }); } catch {}+                throw e;+              }+            }++            // Modern puppeteer: BrowserContext.request.fetch — bypasses page+            // rendering entirely. Cookies from the context are auto-included.+            const init = {+              method: args.method || 'GET',+              headers: args.headers || {},+            };+            if (args.body != null) init.data = args.body;+            const resp = await reqClient.fetch(args.url, init);+            const buf = await resp.body();+            const ct = (resp.headers && resp.headers()['content-type']) || '';+            respondWithBytes(buf, ct, resp.status());+          } catch (e) {+            sendJSON(res, { success: false, error: `pup_fetch_url failed: ${e.message}`, _hint: 'If this URL requires the popup tab\'s session cookies, make sure that tab is in the same session and pass its tabId here. The bridge always returns bodyBase64; the CLI handles container-side writes via the saveTo arg.' });+          }+          return;+        }+        // v2.0.142 (John: "you need to download via the browser just like a user does — pup is about+        // the browser"). A BROWSER-NATIVE download: pup drives Chrome to fetch the file exactly as a+        // user clicking the button would — real session, real cookies, real anti-bot cookies, real+        // Content-Disposition — and Chrome's own download manager writes it to the Downloads folder.+        // NO bridge-side fetch/curl (that trips every vendor's anti-bot wall; if a caller wants curl+        // it does that outside pup). Completion is confirmed via CDP Browser.downloadProgress, so the+        // response carries the ACTUAL saved path + byte count, not a guess.+        //   selector : click THIS real download control (the authentic user action). OR+        //   url      : synthesize an <a href=url download> in the page and real-click it (forces the+        //              download path even for a URL Chrome would otherwise render inline, e.g. a PDF).+        case 'pup_download': {+          if (!args.selector && !args.url) { sendJSON(res, { success: false, error: 'pup_download needs {selector} (a real download button to click) OR {url} (downloaded via an in-page <a download> click). Optional saveAs.' }); return; }+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const dir = args.saveDir || (session._downloadPath) || path.join(os.homedir(), 'Downloads');+          let cdp = null;+          try {+            cdp = await tab.page.target().createCDPSession();+            // allow downloads to `dir` AND emit Browser.download* events so we can await completion.+            try { fs.mkdirSync(dir, { recursive: true }); } catch (e) {}+            await cdp.send('Browser.setDownloadBehavior', { behavior: 'allow', downloadPath: dir, eventsEnabled: true });+            let willBegin = null;+            const done = new Promise((resolve, reject) => {+              const to = setTimeout(() => reject(new Error('download did not complete within 90s')), 90000);+              cdp.on('Browser.downloadWillBegin', (e) => { willBegin = e; });+              cdp.on('Browser.downloadProgress', (e) => {+                if (e.state === 'completed') { clearTimeout(to); resolve(e); }+                else if (e.state === 'canceled') { clearTimeout(to); reject(new Error('download was canceled by the page/Chrome')); }+              });+            });+            // Trigger the download the way a user does.+            if (args.selector) {+              await withTimeout(tab.page.click(args.selector, {}), 12000, 'download.click').catch(async () => {+                // fall back to a scroll-into-view + coordinate click if the plain click missed+                const box = await tab.page.$eval(args.selector, el => { el.scrollIntoView({ block: 'center' }); const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }).catch(() => null);+                if (box) await tab.page.mouse.click(box.x, box.y);+              });+            } else {+              await tab.page.evaluate((u, nm) => {+                const a = document.createElement('a'); a.href = u; if (nm) a.download = nm; else a.setAttribute('download', '');+                a.style.display = 'none'; document.body.appendChild(a); a.click(); setTimeout(() => a.remove(), 1000);+              }, args.url, args.saveAs || '');+            }+            const ev = await done;+            // Resolve the real file: allow-mode keeps the suggested name; the guid file is renamed on complete.+            let fname = (willBegin && willBegin.suggestedFilename) || (args.saveAs || '') || ('download-' + (ev.guid || 'file'));+            let abs = path.join(dir, fname);+            // Verify on disk; if the suggested name isn't there yet, find the newest non-.crdownload file.+            let sizeOnDisk = null;+            try { sizeOnDisk = fs.statSync(abs).size; } catch (e) {+              try {+                const recent = fs.readdirSync(dir).filter(f => !f.endsWith('.crdownload'))+                  .map(f => ({ f, m: fs.statSync(path.join(dir, f)).mtimeMs }))+                  .sort((a, b) => b.m - a.m)[0];+                if (recent) { abs = path.join(dir, recent.f); fname = recent.f; sizeOnDisk = fs.statSync(abs).size; }+              } catch (e2) {}+            }+            const magic = (() => { try { return fs.readFileSync(abs).slice(0, 5).toString('latin1').replace(/[^\x20-\x7e]/g, '.'); } catch (e) { return null; } })();+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true, savedTo: abs, filename: fname, bytes: (ev.totalReceivedBytes || ev.receivedBytes || sizeOnDisk), sizeOnDisk, magic,+              triggeredBy: args.selector ? ('click ' + args.selector) : ('a[download] ' + args.url),+              sessionId: resolvedId, tabId,+              _hint: `Chrome downloaded this to ${abs} (${sizeOnDisk != null ? sizeOnDisk + ' bytes' : 'size unread'}${magic ? ', starts "' + magic + '"' : ''}) — the real browser download, session cookies and all. To bring it into the container, pull it with Bridge file verbs (desktop_pull_file).`,+            }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'download_failed', error: `pup_download failed: ${e.message}`, _hint: args.selector ? `The click may not have started a download. Verify the selector is the real download control (pup_screenshot to see it), and that this window is logged in if the file is gated.` : 'The URL did not download — for a gated file, sign in on this window first (pup_login).' });+          } finally { try { if (cdp) await cdp.detach(); } catch (e) {} }+          return;+        }+        case 'pup_errors': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const errs = [...tab.errors];+          if (args.clear !== false) tab.errors.length = 0;+          sendJSON(res, { success: true, output: JSON.stringify({ errors: errs, count: errs.length, sessionId: resolvedId, tabId }) });+          return;+        }+        case 'pup_progress': {+          // v2.0.193 (John: "if a thread is crawling a site through login -> component pages ->+          // datasheets -> step files, it should paint the taskbar progress bar; a simple page open+          // shouldn't — the AI decides, but pup has to TEACH it"). Caller-facing progress control.+          //   pup_progress {mode:"indeterminate", note:"crawling ti.com"}          — pulsing bar+          //   pup_progress {mode:"value", value:35, note:"12/40 datasheets"}      — determinate+          //   pup_progress {mode:"off"}                                            — clear when done+          const _pm = String(args.mode || (typeof args.value === 'number' ? 'value' : 'indeterminate'));+          if (!['indeterminate', 'value', 'off'].includes(_pm)) { sendJSON(res, { success: false, error: 'mode must be indeterminate|value|off' }); return; }+          const _pv = Math.max(0, Math.min(100, Number(args.value) || 0));+          const _note = (typeof args.note === 'string' && args.note.trim()) ? args.note.trim().slice(0, 120) : null;+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }+          if (!session) { sendJSON(res, { success: false, errorCode: 'no_session', error: 'no window to set progress on', _hint: 'Open your window first (pup_open_window), then pup_progress.' }); return; }+          session._explicitProgress = _pm !== 'off';+          if (_pm !== 'off') { if (!session._progressSince) { session._progressSince = Date.now(); session._progressCount = (session._progressCount || 0) + 1; } }+          else if (session._progressSince) { session._progressMs = (session._progressMs || 0) + (Date.now() - session._progressSince); session._progressSince = null; }+          // v2.0.244 (pup-bridge#54, same root cause as #58): a caller asking for a progress bar is+          // an EXPLICIT request, not pup's automatic machinery, so minimal-touch must not silently+          // eat it. That gate is why "pup_progress paints nothing while a direct desktop_taskbar by+          // hwnd paints" — the verb never made the call at all, and still answered ok:true. It now+          // paints regardless, and the response says plainly when the desktop cannot show it.+          let _progressPainted = false, _progressSkip = null;+          if (process.platform !== 'win32') _progressSkip = 'this desktop is not Windows — taskbar progress bars are a Windows shell feature';+          if (process.platform === 'win32') {+            try {+              // v2.0.195: resolve the OS window handle FIRST (same as the identity stamp) — after a+              // bridge respawn the reloaded session has no hwnd yet, and windowTarget's title+              // fallback is dead with titleTag off, so the progress paint silently hit nothing+              // (measured: direct-by-hwnd painted the Win11 underline pill; the verb path did not).+              try { if (!session._hwnd) await resolveSessionHwndByBounds(session, resolvedId); } catch (e) {}+              // v2.0.194: progress + tooltip in ONE desktop_taskbar call — a follow-up call that+              // omitted the progress field was clearing the bar the first call had just set.+              await chrome.abCommand('desktop_taskbar', {+                ...(await windowTarget(session, resolvedId)),+                progress: _pm === 'off' ? { state: 'none' } : _pm === 'value' ? { state: 'normal', value: _pv } : { state: 'indeterminate' },+                ...(_note ? { thumbnailTooltip: `${session.owner || resolvedId} — ${_note}` } : {}),+              }, { timeoutMs: 4000 });+              try { session._lastPaintSig = null; session._appOverlay = false; session._overlayEnforceAt = 0; } catch (e) {}+              _progressPainted = true;+            } catch (e) { _progressSkip = `the shell call failed: ${e && e.message}`; }+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, mode: _pm, painted: _progressPainted, ...(_progressSkip ? { notPainted: _progressSkip } : {}), ...(_pm === 'value' ? { value: _pv } : {}), ...(_note ? { note: _note } : {}),+            _hint: _progressSkip ? `Progress state recorded, but nothing was drawn: ${_progressSkip}.`+              : _pm === 'off'+              ? 'Progress bar cleared. Good hygiene: always clear when the multi-stage task finishes or aborts.'+              : `Taskbar progress ${_pm === 'value' ? `at ${_pv}%` : 'pulsing (indeterminate)'} on "${resolvedId}"${_note ? ` — hover tooltip says "${_note}"` : ''}. Update value/note as stages advance; pup_progress {mode:"off"} when done. The automatic activity strip stays out of your way while this is set.` }) });+          return;+        }+        case 'pup_reload': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }+          catch (e) { sendTabResolveError(res, e); return; }+          tab.errors.length = 0;+          // v1.9.176: a CRASHED renderer can't be page.reload()'d (detached frame) — respawn it via+          // raw CDP instead. This is the recovery the crash hint promised but page.reload() couldn't do.+          if (tab._crashed) {+            const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (e) { return null; } })() || args.url;+            const ok = await respawnCrashedRenderer(tab.page, rurl);+            if (ok) {+              tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+              console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via respawn`);+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });+            } else {+              console.log(`[crash] session "${resolvedId}" tab ${tabId} respawn did NOT take — advising close+reopen`);+              sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn did not recover it (Chrome may be too memory-pressured to spawn a fresh renderer). Close and reopen the window: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${rurl || ''}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });+            }+            return;+          }+          // v1.9.109: keep the reload's own HTTP status — page.reload() returns the response, and this+          // is the verb an AI uses right after "I fixed it", so a still-broken page must be caught here.+          let _rlResp;+          try {+            _rlResp = await withTimeout(+              tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }),+              30_000, 'page.reload'+            );+          } catch (e) {+            // The renderer may have crashed between resolve and reload (detached frame) — fall back to respawn.+            if (_isCrashedFrameError(e)) {+              const rurl = tab._crashUrl || (() => { try { return tab.page.url(); } catch (x) { return null; } })() || args.url;+              const ok = await respawnCrashedRenderer(tab.page, rurl);+              tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+              console.log(`[crash] session "${resolvedId}" tab ${tabId} reload hit a crashed frame — recovered via respawn (${ok ? 'ok' : 'sent'})`);+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, _recovered: `crashed renderer respawned${rurl ? ' at ' + rurl : ''}` }) });+              return;+            }+            throw e;+          }+          try { tab.page.__pupHttpStatus = _rlResp && typeof _rlResp.status === 'function' ? _rlResp.status() : null; } catch (e) {}+          // v1.8.26: the user is signalled by the auto-flash, NOT by raising the window.+          // Only raise if this session is one the user is actively watching.+          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }+          // v1.9.109 — PROBE ON RELOAD TOO. This was the hole: only pup_open_window probed, so a+          // thread that hit a 404, fixed its dev server, called pup_reload and got ok:true with the+          // GENERIC "do not assume it rendered" hint concluded "Mockup is now live again" — reporting+          // success on a page nobody had checked. The verb used to confirm a fix is exactly the verb+          // that must verify it.+          try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}+          try { await autoLoginOnLoad(res, tab.page); } catch (e) {}+          try {+            const _rc = res._renderCheck;+            if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" STILL BROKEN after reload: ${_rc.signals.join(', ')}`);+          } catch (e) {}+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) });+          return;+        }+        case 'pup_navigate': {+          {+            // v1.9.195: same pre-flight as open — never navigate a window to a guaranteed-broken+            // file:// URL (container path, or missing on the desktop) and leave the caller guessing.+            const _pre = urlLint(args.url, res && res._callerContainer);+            if (_pre && (_pre.code === 'url_file_container_path' || _pre.code === 'url_file_not_found')) {+              console.log(`[urlLint] REFUSED navigate to "${args.url}" — ${_pre.code}`);+              sendJSON(res, { success: false, errorCode: _pre.code, error: _pre.message, _urlLint: _pre.message, urlLintCode: _pre.code });+              return;+            }+          }+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          // v1.8.16: refuse a declared cross-owner navigate (window steal).+          { const refusal = ownershipRefusal(session, resolvedId, args, 'pup_navigate'); if (refusal) { sendJSON(res, refusal); return; } }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args, { allowCrashed: true })); }+          catch (e) { sendTabResolveError(res, e); return; }+          // Apply stored basic-auth creds for the navigation target's host+          // BEFORE goto, so the Chrome native auth dialog never appears+          // for sites in the credential vault.+          try {+            const matchedHost = await credentialVault.applyCredentialsToPage(tab.page, args.url);+            if (matchedHost) console.log(`[creds] applied to navigate ${resolvedId}/${tabId}: matched ${matchedHost}`);+          } catch (e) {+            console.log(`[creds] applyCredentialsToPage on navigate failed: ${e.message}`);+          }+          // v1.9.176: navigating a CRASHED renderer via page.goto throws 'detached Frame'. When the tab+          // is flagged crashed (or goto hits a crashed frame), respawn via raw CDP Page.navigate to the+          // requested URL — that spawns a fresh renderer in place and lands on the target page.+          if (tab._crashed) {+            const ok = await respawnCrashedRenderer(tab.page, args.url);+            if (!ok) {+              console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate respawn did NOT take — advising close+reopen`);+              sendJSON(res, { success: false, error: `Tab "${tabId}" renderer crashed and in-place respawn to ${args.url} did not recover it (Chrome may be too memory-pressured). Close and reopen: pup_close_window {sessionId:"${resolvedId}"} then pup_open_window {sessionId:"${resolvedId}", url:"${args.url}"}. Closing other heavy pup windows first frees memory.`, errorCode: 'tab_renderer_crashed_unrecovered', sessionId: resolvedId, tabId });+              return;+            }+            tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+            console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate recovered a crashed renderer via respawn`);+          } else {+            try {+              await withTimeout(+                tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 25000 }),+                30_000, 'page.goto'+              );+            } catch (e) {+              if (_isCrashedFrameError(e)) {+                const ok = await respawnCrashedRenderer(tab.page, args.url);+                tab._crashed = false; delete tab._crashedAt; delete tab._crashReason;+                console.log(`[crash] session "${resolvedId}" tab ${tabId} navigate hit a crashed frame — recovered via respawn (${ok ? 'ok' : 'sent'})`);+              } else {+                throw e;+              }+            }+          }+          tab.errors.length = 0;+          // v1.9.201: Chrome can foreground a window on its own during a navigation (local PDFs do+          // this reliably). Re-assert the park unless the user has this window up on purpose.+          if (!userIsUsingWindow(session)) setTimeout(() => { reassertBottomAfterNav(resolvedId).catch(() => {}); }, 1800);+          // Re-apply title suffix after navigation+          try {+            const pName = session.profileName;+            // When there's more than one tab, include tabId in the title suffix+            const titleSuffix = session.tabs.length > 1+              ? ` (${TITLE_LABEL}: ${resolvedId} | ${tabId})`+              : (pName !== resolvedId+                  ? ` (${TITLE_LABEL}: ${resolvedId} | profile: ${pName})`+                  : ` (${TITLE_LABEL}: ${resolvedId})`);+            await tab.page.evaluate((suffix, sid) => {+              try { window.name = 'pup:' + sid; } catch (e) {}   // v1.9.153: durable carrier on tab retitles too+              // v2.0.24: no visible suffix, no observer — window.name is the carrier.+              const cleaned = String(document.title || '').replace(/\s*\((?:session|ai-thread):[^)]*\)/g, '').trim();+              if (cleaned !== document.title) document.title = cleaned;+            }, titleSuffix, resolvedId);+          } catch {}+          // v1.8.26: signal via auto-flash, NOT by raising the window (that stole focus+          // on every navigate). Only raise for a session the user is actively watching.+          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }+          // Only persist URL for single-tab sessions — the session file is a+          // per-session snapshot and multi-tab URLs can't fit.+          if (session.tabs.length === 1) {+            saveSessionFile(resolvedId, session.profileName, args.url);+          }+          if (session && process.platform === 'win32') {+            setTimeout(() => { applyAppOverlay(session, resolvedId).catch(() => {}); }, 1500); // v1.8.92: new site, new favicon+            // v1.9.43: second pass — many sites inject their <link rel=icon> after first paint.+            setTimeout(() => { if (session) session._faviconRetried = false; applyAppOverlay(session, resolvedId).catch(() => {}); }, 4500);+            // v1.9.1: on an Adom nav, re-check auth (login may have completed since open) and+            // refresh the tab label so it becomes "🔓 Logged in" once actually authenticated.+            // v1.9.103: a navigation replaces the document, so re-assert the thread tag (cheap, and+            // evaluateOnNewDocument already re-runs it at document-start; this covers the live page).+            try { if (session && session._threadTag) setTitleDecor(session.page, { threadTag: session._threadTag }).catch(() => {}); } catch (e) {}+            if (isAdomUrl(args.url)) {+              setTimeout(async () => {+                try {+                  const a = await checkWikiAuth(session.page);+                  // v1.9.93: an indeterminate probe must not repaint the glyph OR flip the identity.+                  if (!(a && a.unknown)) {+                    await setWikiModeGlyph(session.page, a && a.authed ? '\u25CF Logged in' : '\u25CB Public');+                    await setWikiAuthedState(session, resolvedId, a && a.authed, { unknown: false });+                  }+                } catch (e) {}+              }, 1800);+            }+            // v1.9.3: refresh the per-window wiki jump list (adds the toggle on a wiki page,+            // clears it when the window navigated off Adom).+            setTimeout(() => { updateWikiJumplist(session, resolvedId).catch(() => {}); }, 1000);+          }+          if (tab._crashed) { tab._crashed = false; delete tab._crashedAt; delete tab._crashReason; console.log(`[crash] session "${resolvedId}" tab ${tabId} recovered via navigate`); }+          // v1.9.109: probe on navigate as well — same reasoning as reload.+          try { res._renderCheck = await probeRender(tab.page, tab.page.__pupHttpStatus); } catch (e) {}+          try { await autoLoginOnLoad(res, tab.page); } catch (e) {}+          try {+            const _rc = res._renderCheck;+            if (_rc && _rc.ok === false) console.log(`[render] "${resolvedId}" BROKEN after navigate to ${args.url}: ${_rc.signals.join(', ')}`);+          } catch (e) {}+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: args.url, sessionId: resolvedId, tabId, _next: threadNextBlock(), ...(authSteerHint(args.url) ? { _hint: authSteerHint(args.url).trim() } : {}) }) });+          return;+        }+        case 'pup_install_toolbar': {+          // v2.0.53: nothing to install anymore — on CfT the toolbar is a launch arg. This verb+          // reports the state so agents can explain it.+          const cft = (chrome && chrome.readiness) ? chrome.readiness() : {};+          const staged = stageToolbarExtension();+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, mode: 'launch-flag', stagedAt: staged,+            driving: cft.browserKind || null,+            active: !!staged && cft.browserKind === 'chrome-for-testing',+            _hint: staged ? (cft.browserKind === 'chrome-for-testing' ? 'Toolbar auto-loads on every profile launch (CfT --load-extension).' : `Driving browser is ${cft.browserKind}; the toolbar flag only works on Chrome for Testing — pup_use {browser:"cft"} to enable.`) : 'toolbarExtension is off in settings (or non-Windows).',+          }) });+          return;+        }+        case 'pup_floaty_click': {+          // v2.0.69 (John: "tool the clicking so we can ralph this weekly forever"): first-class+          // simulated click on the floaty pencil. Fires the SAME host click handler a mouse does+          // (host.click() -> onclick -> flag -> sweep harvest -> annotate), so the ralph exercises+          // the full production chain without CDP input-simulation flakiness. Honest errors: floaty+          // absent means the setting is off or the page never got the injection.+          const sessionId = args.sessionId;+          const _lv = requireLiveSession(res, { ...args, sessionId });+          if (!_lv) return;+          const session = _lv.session;+          const tab = (args.tabId && (session.tabs || []).find(t => t.tabId === args.tabId)) || (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];+          if (!tab || !tab.page) { sendJSON(res, { success: false, error: 'no tab' }); return; }+          try {+            const r = await tab.page.evaluate(() => {+              const h = document.getElementById('__pup_floaty');+              if (!h) return { clicked: false, reason: 'floaty not present (tbFloatyPencil off, or page not injected)' };+              h.click();+              window.__pupFloatyClick = null;   // consumed here: the verb arms the watcher itself+              return { clicked: true };+            });+            if (r.clicked) { session._annotateArmed = Date.now(); startAnnotation(session, sessionId, 'user-floaty', { tool: 'pen', tabId: tab.tabId }).catch(() => {}); }+            sendJSON(res, { success: !!r.clicked, output: JSON.stringify(r), ...(r.clicked ? {} : { error: r.reason }) });+          } catch (e) { sendJSON(res, { success: false, error: e.message }); }+          return;+        }+        case 'pup_annotate': {+          // start/cancel the human annotation overlay on a window's active tab. Jump-list clicks+          // arrive as user-taskbar-menu (ownership-gate exempt); threads annotate their OWN windows.+          const sessionId = args.sessionId;+          const _lv = requireLiveSession(res, { ...args, sessionId });+          if (!_lv) return;+          const session = _lv.session;+          try {+            if (args.action === 'cancel') {+              const tab0 = (session.tabs || [])[0];+              if (tab0 && tab0.page) await tab0.page.evaluate(() => { const w = document.getElementById('__pup_annotate'); if (w) w.remove(); window.__pupAnnotate = null; });+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cancelled: true }) });+              return;+            }+            await startAnnotation(session, sessionId, _callerThread, { tool: args.tool === 'snip' ? 'snip' : 'pen', mode: args.mode, tabId: args._resolvedTabId || null });+            // v2.0.293 (John: "you just foregrounded a pup window and never showed an ab caption"):+            // the old unconditional raise claimed a jump-list click is user-sanctioned by+            // construction — TRUE only for USER callers (jump list / toolbar). An AI THREAD calling+            // pup_annotate got the same screen-steal, and a scripted annotate workflow re-raised+            // the window on every arm. Now: USER callers get the raise (they clicked, they are+            // waiting for the window); AI callers arm the overlay in the BACKGROUND and must+            // foreground explicitly via pup_raise_os_window + foregroundReason (the reason-gated,+            // captioned path) if the user should draw now.+            const _userCaller = _callerThread === 'user-taskbar-menu' || _callerThread === 'user-toolbar';+            if (_userCaller) {+              session._foreground = true; session._userActiveAt = Date.now();+              try { const t0 = (session.tabs || [])[0]; if (t0 && t0.page) await t0.page.bringToFront(); } catch (e) {}+              try { await osRaiseSessionWindow(session, sessionId, 'you clicked Annotate: draw on the page, then hit Done'); } catch (e) {}+            }+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, started: true, raised: _userCaller, _hint: _userCaller ? 'Overlay is live: pen/arrow/box/text, color dot cycles, Done captures (drawing stays visible so the owning AI thread\'s next screenshot shows it), \u2716 cancels.' : 'Overlay is armed but the window stays in the BACKGROUND (AI-called annotate never steals the screen). If the user should draw NOW, call pup_raise_os_window with a foregroundReason: that captions the reason first, then foregrounds.' }) });+          } catch (e) { sendJSON(res, { success: false, error: `annotate failed: ${e.message}` }); }+          return;+        }+        case 'pup_dashboard': {+          // v1.9.231 (John): the dashboard ALWAYS gets its OWN pup window, never a tab in someone+          // else's, so it carries its own taskbar icon (its served icon.svg) and can be found and+          // closed independently. Fixed sessionId + its own durable profile, so repeat calls REUSE+          // the one window instead of spawning duplicates. Opens in the background like any pup+          // window: it is a monitor, and monitors must not steal the screen.+          const dashUrl = `http://127.0.0.1:${PORT}/dashboard`;+          // v2.0.178 (John: "when I click the jump list to open the dashboard, open it FOREGROUND —+          // it was a human action, not an AI action"). Exactly right: the background-always rule+          // exists so AIs never steal the screen, but a jump-list / toolbar click IS the user asking+          // to see it — backgrounding it just makes the click feel broken. USER_CALLERS is the+          // human-surface marker (taskbar menu, toolbar, pill), so those callers get the window+          // raised; AI callers keep the background default + raise-with-reason path.+          const _humanClick = USER_CALLERS.has(_callerThread);+          const _humanReason = 'you clicked "Open pup dashboard" in the taskbar menu';+          try {+            const existing = sessions.get('pup-dashboard');+            if (existing) {+              try { await existing.page.goto(dashUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch (e) {}+              if (_humanClick) { try { await osRaiseSessionWindow(existing, 'pup-dashboard', _humanReason); } catch (e) {} }+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: true, foregrounded: _humanClick,+                _hint: _humanClick+                  ? `The pup dashboard was already open — refreshed and RAISED to the foreground (a human clicked the jump-list task, so no background etiquette applies).`+                  : `The pup dashboard is already open in its own window (session "pup-dashboard") and was refreshed. It is BACKGROUNDED like any pup window; raise it with pup_raise_os_window + a foregroundReason if the user asked to see it.` }) });+              return;+            }+            const _dashSess = await launchSession('pup-dashboard', dashUrl, { profile: 'pup-dashboard', owner: 'pup-dashboard', foreground: _humanClick });+            if (_humanClick) { try { await osRaiseSessionWindow(_dashSess || sessions.get('pup-dashboard'), 'pup-dashboard', _humanReason); } catch (e) {} }+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: false, foregrounded: _humanClick,+              _hint: `Opened the live pup dashboard in its OWN window (session "pup-dashboard", its own profile so it has its own taskbar icon). It shows every window pup is driving, grouped taskbar button -> window -> tabs, with each tab's favicon, the owning AI thread, foreground/background plus the reason given to foreground, highFps lease, uptime, and how each overlay icon was chosen. Updates live over SSE. ${_humanClick ? 'FOREGROUNDED (human click on a user surface).' : 'Backgrounded by default.'}` }) });+          } catch (e) {+            sendJSON(res, { success: false, errorCode: 'dashboard_open_failed', error: `could not open the dashboard: ${e.message}` });+          }+          return;+        }+        case 'pup_deps': {+          // v1.9.218 (issue #15): report dependency health so "pup is broken" can be answered without+          // reading the spawn log. selfInstalled:true means the bridge repaired itself this boot.+          let resolved = false; try { require.resolve('puppeteer'); resolved = true; } catch (e) {}+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: resolved, depsPresent: resolved,+            selfInstalledThisBoot: !!(_depState && _depState.installed),+            lastError: (_depState && _depState.error) || null,+            dir: __dirname,+            _hint: resolved+              ? (_depState && _depState.installed+                 ? 'Dependencies were MISSING at boot and pup self-installed them (wiki issue #15: the source-only zip ships no node_modules and AD\'s install did not run, or a respawn wiped it). No human npm install is needed.'+                 : 'Dependencies present. Normal state.')+              : 'Dependencies MISSING and self-install failed — pup cannot drive a browser. Ensure node + npm are on PATH for the AD process, then bridge_kill + bridge_resume to retry the self-install.',+          }) });+          return;+        }+        case 'pup_readiness': {+          // READ-ONLY readiness probe. NEVER triggers a download. `ready` reflects both+          // server-up (this verb answered) AND Chrome-for-Testing cached. For the general+          // "what do I have / is it ready" question prefer AD's `bridge_readiness` (it also+          // reports AD-core's Tier-1 prewarm progress); this verb is pup's own detailed view.+          const rd = chrome.readiness();+          const availTxt = (rd.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+          const defTxt = rd.defaultBrowser+            ? ` Default: ${rd.defaultBrowser.kind}${rd.defaultBrowser.forced ? ' (pinned)' : ' (auto)'}${rd.defaultBrowser.pendingInstall ? ', downloading' : ''}.`+            : ' Default: auto (first browser that launches).';+          const switchTxt = ' Switch/install with pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.';+          // v2.0.383 (#59/#64): the fast progress verb. active[] = one row per in-flight phase (this is+          // what animates a bar); progress = the download's own frame while it runs.+          const _act = activeProgress();+          const _dl = rd.installing ? { phase: 'download', stepLabel: 'Downloading Chrome for Testing', percent: rd.installProgressPct || 0, elapsedSec: rd.downloadElapsedSec, estimatedSec: (rd.downloadElapsedSec != null && rd.downloadEtaSec != null) ? rd.downloadElapsedSec + rd.downloadEtaSec : null, etaSec: rd.downloadEtaSec, confidence: rd.downloadEtaSec != null ? 'measured' : 'estimated', startKind: 'cold' } : undefined;+          sendJSON(res, {+            success: true,+            output: JSON.stringify({ ...rd, active: _act, progress: _dl }),+            ...rd,+            active: _act,+            progress: _dl,+            _hint: rd.ready+              ? `Ready — pup will drive ${rd.browserKind} (${rd.browserSource}) at ${rd.browserExecutablePath}. Available: ${availTxt}.${defTxt} No download needed. Proceed with pup_open_window.${switchTxt} RECOVERY NOTE: if you were polling this because pup's verbs were TIMING OUT (dim LED in AD / browser.bridgeRunning:false), that means the bridge PROCESS was down — a status probe like this does NOT respawn it; only a window/tab verb (pup_open_window) auto-spawns the bridge. And never SPAM pup_open_window while it says "bridge_starting" — concurrent opens on one profile used to collide (fixed v1.9.4, but still: fire ONE open, then poll pup_list_windows with backoff).`+              : rd.installing+                ? `Fetching Chrome for Testing (${rd.installProgressPct}%) — this box had no Chrome/Edge. Poll this verb (or bridge_readiness) until ready:true, then pup_open_window. Faster: have the user install Chrome or Edge (Edge ships with Windows) and pup uses it instantly.`+                : rd.installPhase === 'failed'+                  ? `No Chrome/Edge on this box and the Chrome-for-Testing fallback FAILED: ${rd.lastError}. Have the user install Chrome or Edge (fastest, no download), or retry pup_prewarm (usually network/proxy blocking the download, or low disk).`+                  : `No Chrome/Edge detected and no cached Chrome for Testing. Available: ${availTxt}. Have the user install Chrome or Edge (Edge ships with Windows — instant, no download), or call pup_use {browser:"cft"} / pup_prewarm to fetch Chrome for Testing.`,+          });+          return;+        }+        case 'pup_import_scan': {+          // READ-ONLY discovery pass for the comprehensive credential import (adom-you seed).+          // Enumerates EVERY profile of EVERY Chromium browser (Chrome/Edge/Brave) on this box+          // with: source account email(s) (the identity tag — Preferences.account_info[].email),+          // saved-password count (parsed from the Login Data SQLite via the desktop's python,+          // NEVER a byte-scan — see dev-skills/pup-browser-import), Login Data size, and ABE+          // status. It DECRYPTS NOTHING — decryption needs the Bridge-core elevated SYSTEM helper+          // (adom/adom-bridge #51, desktop_decrypt_pup_credentials). This verb is the+          // foundation the first-run prompt + Settings "Import logins" picker read.+          if (process.platform !== 'win32') {+            sendJSON(res, { success: true, output: JSON.stringify({ profiles: [] }), profiles: [], _hint: 'Credential import discovery is Windows-only (Chromium ABE / DPAPI). No profiles enumerated on this platform.' });+            return;+          }+          const scanPs = [+            "$ErrorActionPreference='SilentlyContinue'",+            "$local = $env:LOCALAPPDATA",+            "$browsers = @(",+            "  @{ id='chrome'; name='Google Chrome'; root=(Join-Path $local 'Google\\Chrome\\User Data') },",+            "  @{ id='edge';   name='Microsoft Edge'; root=(Join-Path $local 'Microsoft\\Edge\\User Data') },",+            "  @{ id='brave';  name='Brave';          root=(Join-Path $local 'BraveSoftware\\Brave-Browser\\User Data') }",+            ")",+            "$py = $null",+            "foreach($c in @('python','python3','py')){ $g = (Get-Command $c -ErrorAction SilentlyContinue); if($g){ $py=$g.Source; break } }",+            "$pyLines = @(",+            "  'import sqlite3,sys',",+            "  'c=sqlite3.connect(sys.argv[1])',",+            "  'try:',",+            "  '    n=c.execute(\"select count(*) from logins where blank_password=0\").fetchone()[0]',",+            "  'except Exception:',",+            "  '    n=c.execute(\"select count(*) from logins\").fetchone()[0]',",+            "  'print(n)'",+            ") -join [char]10",+            "$codeFile = $null",+            "if($py){ $codeFile = Join-Path $env:TEMP ('pup_ldcount_' + [guid]::NewGuid().ToString('N') + '.py'); Set-Content -LiteralPath $codeFile -Value $pyLines -Encoding ASCII }",+            "$out = @()",+            "foreach($b in $browsers){",+            "  if(-not (Test-Path $b.root)){ continue }",+            "  $abe=$false; $legacy=$false",+            "  $lsPath = Join-Path $b.root 'Local State'",+            "  if(Test-Path $lsPath){ try { $ls = Get-Content -Raw -LiteralPath $lsPath | ConvertFrom-Json; if($ls.os_crypt.app_bound_encrypted_key){ $abe=$true }; if($ls.os_crypt.encrypted_key){ $legacy=$true } } catch {} }",+            "  $profs = @()",+            "  $d = Get-Item -LiteralPath (Join-Path $b.root 'Default') -ErrorAction SilentlyContinue; if($d){ $profs += $d }",+            "  $profs += (Get-ChildItem -LiteralPath $b.root -Directory -Filter 'Profile *' -ErrorAction SilentlyContinue)",+            "  foreach($pf in $profs){",+            "    if(-not $pf){ continue }",+            "    $prefPath = Join-Path $pf.FullName 'Preferences'",+            "    if(-not (Test-Path $prefPath)){ continue }",+            "    $emails=@(); $pname=$pf.Name",+            "    try { $pref = Get-Content -Raw -LiteralPath $prefPath | ConvertFrom-Json; if($pref.profile.name){ $pname = $pref.profile.name }; if($pref.account_info){ foreach($a in $pref.account_info){ if($a.email){ $emails += $a.email } } } } catch {}",+            "    $ldPath = Join-Path $pf.FullName 'Login Data'",+            "    $pwCount=$null; $ldBytes=0",+            "    if(Test-Path $ldPath){",+            "      $ldBytes = (Get-Item -LiteralPath $ldPath).Length",+            "      if($py -and $codeFile){",+            "        $tmp = Join-Path $env:TEMP ('pup_ld_' + [guid]::NewGuid().ToString('N') + '.db')",+            "        try { Copy-Item -LiteralPath $ldPath -Destination $tmp -Force; $r = & $py $codeFile $tmp 2>$null; if($r){ $pwCount = [int]($r | Select-Object -First 1) } } catch {}",+            "        Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue",+            "      }",+            "    }",+            "    $out += [pscustomobject]@{ browser=$b.id; browserName=$b.name; profileDir=$pf.Name; profileName=$pname; accounts=@($emails); passwordCount=$pwCount; loginDataBytes=$ldBytes; abe=$abe; legacyKey=$legacy }",+            "  }",+            "}",+            "if($codeFile){ Remove-Item -LiteralPath $codeFile -Force -ErrorAction SilentlyContinue }",+            "[pscustomobject]@{ pythonAvailable=[bool]$py; profiles=@($out) } | ConvertTo-Json -Depth 6 -Compress"+          ].join('\n');+          const scanPath = path.join(require('os').tmpdir(), '_pup_import_scan.ps1');+          let scan = { pythonAvailable: false, profiles: [] };+          try {+            fs.writeFileSync(scanPath, scanPs);+            const raw = (await runPsHiddenPromise(scanPath, { timeout: 25000 }) || '').trim();+            if (raw) scan = JSON.parse(raw);+          } catch (e) { console.log('[import-scan] failed:', e && e.message); }+          const profiles = Array.isArray(scan.profiles) ? scan.profiles : (scan.profiles ? [scan.profiles] : []);+          const totalPw = profiles.reduce((s, p) => s + (p.passwordCount || 0), 0);+          const accts = [...new Set(profiles.flatMap(p => p.accounts || []))];+          const anyAbe = profiles.some(p => p.abe);+          sendJSON(res, {+            success: true,+            output: JSON.stringify({ profiles, totalPasswords: totalPw, accounts: accts }),+            profiles,+            totalPasswords: totalPw,+            accounts: accts,+            pythonAvailable: !!scan.pythonAvailable,+            _hint: profiles.length+              ? `Found ${profiles.length} browser profile(s) across ${new Set(profiles.map(p=>p.browser)).size} browser(s), ~${totalPw} saved password(s), identities: ${accts.join(', ') || 'none tagged'}.${anyAbe ? ' These are App-Bound-Encrypted (v20) — a USER process (this bridge) canNOT decrypt them.' : ''} This verb is DISCOVERY ONLY. The actual decrypt is AD's desktop_decrypt_pup_credentials (AD >= 1.9.220): the dashboard "Import logins" button calls it LOCALLY (bridge → AD direct API), the user approves one consent dialog + one UAC prompt, and pup merges the identity-tagged rows into the adom-you vault (credential_vault.js) and drops the plaintext. ${scan.pythonAvailable ? '' : 'NOTE: python not found on PATH, so passwordCount is null — only file presence/size known. '}Next: open the Settings "Import logins from Chrome/Edge" picker, which reads this list.`+              : `No Chromium browser profiles with a Preferences file found under %LOCALAPPDATA% (Chrome/Edge/Brave). Nothing to import.`,+          });+          return;+        }+        // v2.0.143 (John: "import from my native browsers via ab's credential verb — that i don't+        // think ever worked — and get it into your stored credential system"). It DID work, but only+        // from the dashboard's "Import logins" button; there was no AI-callable verb, so a thread+        // couldn't seed pup's vault from the user's real Chrome/Edge. This verb runs the whole flow:+        // scan → call AD's SYSTEM-elevated decrypt LOCALLY (the user approves one consent + one UAC+        // on the desktop; a relayed call is refused, which is why pup — a LOCAL bridge — must be the+        // one to ask) → merge the identity-tagged rows into the keychain vault → DROP the plaintext.+        // Returns COUNTS ONLY, never a password. hostFilter narrows the merge to matching hosts (a+        // surgical import, e.g. just the vendor sites); omit to import everything the user has saved.+        case 'pup_import_browser_logins': {+          if (process.platform !== 'win32') { sendJSON(res, { success: false, error: 'Browser-login import is Windows-only (Chromium ABE/DPAPI).' }); return; }+          // 1) scan for profiles (reuse the discovery verb over loopback).+          let scan = {};+          try {+            const sr = await fetch(`http://127.0.0.1:${PORT}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: 'pup_import_scan', args: {} }) });+            const sj = await sr.json().catch(() => ({}));+            try { scan = JSON.parse(sj.output); } catch (e) { scan = sj; }+          } catch (e) {}+          const allProfiles = Array.isArray(scan.profiles) ? scan.profiles : [];+          const wantBrowsers = Array.isArray(args.browsers) && args.browsers.length ? args.browsers : [...new Set(allProfiles.map(p => p.browser))];+          const browsers = wantBrowsers.filter(Boolean);+          if (!browsers.length) { sendJSON(res, { success: false, error: 'No Chromium browser profiles with saved passwords were found to import from.' }); return; }+          // 2) the SYSTEM-elevated decrypt, called LOCALLY (consent + UAC prompt on the desktop).+          console.log(`[import-verb] decrypt requested for browsers=[${browsers.join(', ')}] — AD helper, consent + UAC will prompt on the desktop`);+          let resp;+          try { resp = await decryptBrowserCreds({ browsers, include: ['passwords'] }); }+          catch (e) { sendJSON(res, { success: false, errorCode: 'decrypt_call_failed', error: 'decrypt helper call failed: ' + (e && e.message || e) }); return; }+          const payload = abPayload(resp) || {};+          const ec = payload.errorCode || (resp && resp.errorCode);+          if (ec) {+            const emap = {+              relay_forbidden: 'AD refused the decrypt as a relayed call — this should not happen from pup (a local bridge). Please report it.',+              consent_declined: 'You declined the Adom Bridge consent dialog on the desktop. Nothing was read.',+              elevation_declined: 'You cancelled the Windows admin (UAC) prompt. Nothing was decrypted.',+              helper_missing: 'The signed decrypt helper is not installed — update/repair Adom Bridge.',+            };+            sendJSON(res, { success: false, errorCode: ec, error: emap[ec] || ('Decrypt failed: ' + ec) });+            return;+          }+          let pwRows = Array.isArray(payload.passwords) ? payload.passwords : [];+          if (!payload.ok && !pwRows.length) { sendJSON(res, { success: false, error: 'decrypt helper returned no data' + (payload.error ? ': ' + payload.error : '') }); return; }+          // 3) optional surgical filter by host substring(s), then merge into the vault.+          const filters = (typeof args.hostFilter === 'string' ? [args.hostFilter] : (Array.isArray(args.hostFilter) ? args.hostFilter : [])).map(s => String(s).toLowerCase()).filter(Boolean);+          let rows = pwRows;+          if (filters.length) rows = pwRows.filter(r => { const h = String((r && (r.host || r.url || r.origin)) || '').toLowerCase(); return filters.some(f => h.includes(f)); });+          let merge = { added: 0, updated: 0, skipped: 0 };+          try { merge = await credentialVault.mergeImportedCredentials(rows); }+          catch (e) { sendJSON(res, { success: false, error: 'vault merge failed: ' + (e && e.message || e) }); return; }+          // 4) DROP all plaintext immediately.+          try { for (const r of pwRows) { if (r) r.password = undefined; } rows.length = 0; pwRows = []; } catch (e) {}+          const st = payload.stats || {};+          console.log(`[import-verb] merge done: +${merge.added} new, ${merge.updated} updated, ${merge.skipped} skipped${filters.length ? ` (filtered to: ${filters.join(', ')})` : ''}`);+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, added: merge.added, updated: merge.updated, skipped: merge.skipped,+            hostFilter: filters.length ? filters : null,+            stats: { profilesRead: st.profilesRead, passwordsOk: st.passwordsOk, passwordsFailed: st.passwordsFailed },+            _hint: `Imported ${merge.added + merge.updated} login(s) into the adom-you vault (${merge.added} new, ${merge.updated} updated${merge.skipped ? ', ' + merge.skipped + ' skipped' : ''})${filters.length ? `, filtered to host(s) matching [${filters.join(', ')}]` : ''}. pup can now auto-sign-in as you on those sites: navigate a pup window to one and it fills + (with submitReason) submits. Passwords were dropped from memory; pup_credentials lists host+username only.`,+          }) });+          return;+        }+        case 'pup_prewarm': {+          // Install Chrome for Testing WITHOUT opening a window — the warm-before-+          // first-use path. Default non-blocking so AD/HD can fire-and-forget on+          // embedded first-run; pass {wait:true} to block until installed.+          const block = !!(args && (args.wait === true || args.block === true));+          await chrome.ensureChromeReady({ background: !block });+          // Prewarm = the AI explicitly wants Chrome for Testing → pin it as the default+          // (unless a different browser was already explicitly pinned). Once cached, every+          // open drives CfT. Pass {setDefault:false} to only download without pinning.+          const pinCft = args && args.setDefault === false ? false : true;+          const curDef = chrome.readDefault();+          if (pinCft && !(curDef && curDef.forced && curDef.kind !== 'chrome-for-testing')) {+            chrome.setDefaultBrowser({ browser: 'cft' });+          }+          const rd = chrome.readiness();+          sendJSON(res, {+            success: rd.installPhase !== 'failed',+            output: JSON.stringify(rd),+            ...rd,+            statusVerb: 'pup_readiness',+            _hint: rd.ready+              ? `Chrome for Testing is ready${pinCft ? ' and pinned as the default' : ''} — pup will drive it on the next pup_open_window. (pup_use {browser:"auto"} reverts to installed Chrome/Edge.)`+              : rd.installing+                ? `Downloading Chrome for Testing (${rd.installProgressPct}%)${pinCft ? '; it is pinned as the default so every open uses it once ready' : ''}. Poll pup_readiness until ready:true. No window is opened by prewarm.`+                : rd.installPhase === 'failed'+                  ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk. pup still works with installed Chrome/Edge — pup_use {browser:"auto"}.`+                  : 'Prewarm started; poll pup_readiness for completion.',+          });+          return;+        }+        // ANSWER a blocking native dialog (v2.0.344). This is the verb the dialog_open error+        // points at. It is deliberately a SEPARATE, explicit call rather than an auto-answer:+        // a beforeunload is a question about the user's unsaved work, and pup answering it on the+        // AI's behalf would silently destroy data in one direction or the other.+        case 'pup_dialog': {+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          // Fall back to whatever window actually holds it: a modal blocks the whole browser, so the+          // caller commonly targets the window they were driving rather than the one that raised it.+          let pending = findPendingDialog(session);+          let holder = resolvedId;+          if (!pending) { const any = findAnyPendingDialog(); if (any) { pending = any; holder = any.sessionId; } }+          if (!pending) {+            sendJSON(res, {+              status: 'ok', handled: false, dialog: null,+              _hint: 'No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else.',+            });+            return;+          }+          const action = (args && args.action) || '';+          if (action !== 'accept' && action !== 'dismiss') {+            sendJSON(res, {+              status: 'error', errorCode: 'dialog_action_required',+              error: `a ${pending.type} dialog is open; pass action:"accept" or action:"dismiss"`,+              dialog: { type: pending.type, message: pending.message, defaultValue: pending.defaultValue },+              _hint: 'accept = press the confirming button (Reload / OK). dismiss = press Cancel. pup will not choose for you.',+            });+            return;+          }+          const holdSession = sessions.get(holder) || session;+          const tab = holdSession.tabs.find(t => t && t.pendingDialog);+          try {+            if (action === 'accept') await pending.dialog.accept(args && args.promptText !== undefined ? String(args.promptText) : undefined);+            else await pending.dialog.dismiss();+          } catch (e) {+            if (tab) tab.pendingDialog = null;   // a stale handle cannot be answered twice+            sendJSON(res, { status: 'error', error: `could not answer the dialog: ${e.message}`, errorCode: 'dialog_answer_failed' });+            return;+          }+          if (tab) tab.pendingDialog = null;+          sendJSON(res, {+            status: 'ok', handled: true, action, window: holder,+            dialog: { type: pending.type, message: pending.message },+            _hint: `Answered "${action}" on window "${holder}". EVERY pup window is unblocked now, not just that one, and the verb that timed out can be retried.`,+          });+          return;+        }++        case 'pup_use': {+          // Pin (or clear) the browser pup drives by default. browser: 'chrome' | 'edge'+          // | 'cft' | 'auto' (or pass an explicit executablePath). 'cft' fetches Chrome+          // for Testing in the background if not cached, then pins it. Persists across+          // bridge restarts. The launch path still spawn-verifies + falls back, so a+          // pinned browser that later breaks won't hard-fail an open.+          const result = chrome.setDefaultBrowser({ browser: args && args.browser, executablePath: args && args.executablePath, install: !!(args && args.install), elevate: args ? args.elevate : undefined, temporary: !!(args && args.temporary) });+          const rd = chrome.readiness();+          const availTxt = (result.candidates || []).map(c => `${c.kind}(${c.source})`).join(', ') || 'none';+          let hint;+          if (!result.ok) {+            hint = `${result.error} Available on this machine: ${availTxt}. Use pup_use {browser:"chrome"|"edge"|"cft"|"auto"}.`;+          } else if (result.cleared) {+            hint = `Default cleared → AUTO. pup will drive the first browser that launches (${availTxt}) and cache it. Next pup_open_window applies it.`;+          } else if (result.installingChrome) {+            // v1.9.86 (Kyle): this hint was entirely Windows-shaped (UAC, awaiting_uac, "click YES"). On+            // macOS the install is a dmg -> ditto -> /Applications (falling back to ~/Applications)+            // with NO elevation step and NO awaiting_uac phase, so a Mac AI reading the old text+            // would poll for a phase that never arrives and tell the user to approve a prompt that+            // never appears. Per the SDK's first principle the AI reads THIS, not the skill file.+            hint = process.platform === 'darwin'+              ? `Downloading & installing REAL Google Chrome (~90 MB) from Google's .dmg and pinning it as the default. On macOS there is NO elevation step and NO approval prompt: pup mounts the dmg with hdiutil and copies Google Chrome.app to /Applications, falling back to ~/Applications if that isn't writable. Poll pup_readiness: chromeStablePhase installing → ready (there is no awaiting_uac phase on macOS — do not wait for one, and do not tell the user to approve anything). If it fails at both destinations the install genuinely failed; read the error. Until then pup drives ${availTxt}, so nothing is blocked.`+              : `Downloading & installing REAL Google Chrome (~90 MB, user-scoped) and pinning it as the default. On a normal PC this is SILENT (no prompt). On a locked-down box (VM / managed device) it needs a Windows approval — the bridge pops a native "click YES on the UAC" notification on the user's desktop, and if it expires it re-notifies with a one-tap "Approve now" (no AI turn needed). Poll pup_readiness: chromeStablePhase installing → awaiting_uac (waiting on the user's click; chromeStableAwaitingApproval:true) → ready. Until then pup drives ${availTxt}, so nothing is blocked. Pass {browser:"chrome", install:true, elevate:false} to skip the prompt path, or elevate:true to go straight to it.`;+          } else if (result.installing) {+            hint = `Pinned Chrome for Testing as the default and started the ~150 MB download in the background. Poll pup_readiness until ready:true — until then pup falls back to installed Chrome/Edge (${availTxt}). Once cached, every open uses CfT.`;+          } else {+            hint = `Default pinned → ${result.defaultBrowser ? result.defaultBrowser.kind : args.browser}. The next pup_open_window drives it. Available: ${availTxt}. Revert anytime with pup_use {browser:"auto"}.`;+            // v2.0.369 (pup-bridge #81): pinning off CfT silently strips the toolbar family from+            // every later launch (--load-extension rides ONLY CfT). Say so at pin time.+            const _pk = result.defaultBrowser ? result.defaultBrowser.kind : args.browser;+            if (_pk && _pk !== 'chrome-for-testing' && _pk !== 'cft' && pupSettings().toolbarExtension !== 'off') {+              hint += ` ⚠ WARNING: the Adom toolbar (pen/dots/identity tiles) rides ONLY Chrome for Testing — windows launched on ${_pk} will have NO toolbar. If this pin is temporary (a comparison/spike), pass {temporary:true} so it expires at the next bridge boot, or revert to auto when done.`;+            }+            if (args && args.temporary) hint += ' This pin is TEMPORARY: it expires automatically at the next bridge boot (back to auto).';+          }+          sendJSON(res, {+            success: !!result.ok,+            output: JSON.stringify({ ...result, readiness: rd }),+            ...result,+            errorCode: result.ok ? undefined : (result.errorCode || 'pup_use_failed'),+            statusVerb: 'pup_readiness',+            _hint: hint,+          });+          return;+        }+        case 'pup_audit_icons': {+          // v2.0.228: detect drift between pup's model and the SHELL's own buttons, repair it, and+          // report what was wrong. fix:false makes it a dry run.+          const _r = await auditTaskbarIcons(args.reason || 'pup_audit_icons verb', { fix: args.fix !== false, force: !!args.force });+          sendJSON(res, { success: true, audit: _r,+            _hint: _r.ok ? 'Every live window matches the shell IN PUP\'S MODEL for this mode. That is not the same as matching the GLASS: the audit cannot read pixels, it infers art from timestamps. If a button looks wrong to a human, believe the human and re-run with {force:true}, which re-asserts every icon with a fresh cache key.'+                         : `${_r.findings.length} drift finding(s), ${_r.repaired} repaired — re-run in ~30s to confirm the glass settled.` });+          return;+        }+        case 'pup_status': {+          // v1.9.172 (jam fix): same resilience as pup_list_windows — parallel + per-session+          // timeout + in-memory fallback, so a busy window can never hang pup_status into null.+          const _entries = [...sessions];+          const list = await Promise.all(_entries.map(([id, s]) =>+            withTimeout(getSessionInfo(id, s), 2500, 'getSessionInfo').catch(() => getSessionInfoFast(id, s))+          ));+          const _degraded = list.filter(x => x && x.degraded).length;+          const statusResult = {+            ok: true,+            // v2.0.190: version + the enum_windows counter belong on the VERB payload (2.0.188 put+            // them on the HTTP /status chip only — the wrong surface for remote no-shell boxes).+            ...statusCore(),+            sessions: list,+            pupshotUrl,+          };+          if (_degraded) statusResult._degraded = `${_degraded} of ${list.length} window(s) were busy (Chrome under load) and show last-known state — pup is NOT frozen; retry shortly for live values.`;+          // Add guidance when no sessions exist so the AI knows what to do next+          if (sessions.size === 0) {+            statusResult.hint = 'No browser sessions are open. Use pup_open_window with sessionId, profile, and url to launch a Chrome window on the desktop before using screenshot/eval/navigate.';+          } else {+            // v1.8.37: nudge to close stale/piled-up windows (each row already has ageMinutes+owner).+            const cln = staleWindowsHint();+            if (cln) statusResult.hint = cln.trim();+          }+          sendJSON(res, { success: true, output: JSON.stringify(statusResult) });+          return;+        }+        case 'pup_close': {+          // With sessionId: close just that session. Without: close everything.+          // Per-session close mirrors pup_close_window's logic — kills the+          // Chrome process for that profile if no other sessions share it.+          if (args.sessionId) {+            const target = sessions.get(args.sessionId);+            if (!target) {+              sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` });+              return;+            }+            // v1.8.16: refuse a declared cross-owner close.+            { const refusal = ownershipRefusal(target, args.sessionId, args, 'pup_close'); if (refusal) { sendJSON(res, refusal); return; } }+            const profileName = target.profileName;+            // Are there other sessions on this profile? If yes, only close+            // this session's tab (Chrome stays alive for the others). If no,+            // kill the Chrome process so the profile is fully released and+            // the next pup_open_window starts from a clean slate.+            // (Session map key IS the sessionId; iterate entries to compare.)+            const otherSessionsOnProfile = [...sessions.entries()].filter(+              ([id, s]) => id !== args.sessionId && s.profileName === profileName+            );+            let _reap = { killed: 0, remaining: 0 };+            try {+              if (otherSessionsOnProfile.length === 0) {+                // Only this session uses this Chrome — kill the whole browser.+                const be = browsers.get(profileName);+                if (be) {+                  _reap = await killBrowserProcess(be.browser, profileName);+                  browsers.delete(profileName);+                }+              } else {+                // Other sessions still using this Chrome — just close this tab.+                if (target.page) {+                  try { await target.page.close({ runBeforeUnload: false }); } catch {}+                }+              }+            } catch (e) {+              console.log(`pup_close (per-session) error: ${e.message}`);+            }+            sessions.delete(args.sessionId);+            deleteSessionFile(args.sessionId);+            if (activeSessionId === args.sessionId) {+              activeSessionId = sessions.size > 0 ? [...sessions.keys()][0] : null;+            }+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true,+              sessionId: args.sessionId,+              message: `Session "${args.sessionId}" closed`,+              chromeKilled: otherSessionsOnProfile.length === 0,+              chromeProcsKilled: _reap.killed,+              chromeProcsRemaining: _reap.remaining,+              remainingSessions: sessions.size,+              ...(_reap.remaining ? { _hint: `WARNING: ${_reap.remaining} chrome/renderer process(es) SURVIVED teardown (should be 0) and may peg a CPU core — retry if it persists.` } : {}),+            })});+            return;+          }++          // No sessionId — close everything (also the taskbar jump-list "Close ALL" task).+          for (const [pName, be] of browsers) {+            await killBrowserProcess(be.browser, pName);+          }+          browsers.clear();+          // v1.9.51: unregister every per-session AUMID as we drop the sessions, so closing all+          // windows leaves NO stale taskbar identity behind (the exact "old icon" class John flagged).+          for (const [id, s] of sessions) {+            deleteSessionFile(id);+            clearSessionBusy(id);                            // v1.9.57+            try { if (s && s._curAppId && s._curAppId !== PUP_APP_ID) unregisterPupAumid(s._curAppId).catch(() => {}); } catch (e) {}+          }+          sessions.clear();+          _sessionOwnerClaims.clear();                       // v1.9.110+          activeSessionId = null;+          // Belt-and-braces: sweep any orphaned Adom.Pup.* keys now that nothing is live.+          try { pruneStaleAumids(); } catch (e) {}+          // Final VERIFIED sweep — with no live session left, reap EVERY pup chrome/renderer still+          // alive (parent or reparented orphan). This is what makes "close all" actually leave zero+          // core-pegging processes behind, and reports the count so the caller knows it's clean.+          const _sweep = await reapOrphanPupChromes();+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true,+            message: 'All sessions and browsers closed',+            remainingSessions: 0,+            chromeProcsKilled: _sweep.killed,+            chromeProcsRemaining: _sweep.remaining,+            ...(_sweep.remaining ? { _hint: `WARNING: ${_sweep.remaining} pup chrome process(es) survived the close-all sweep (should be 0) and may peg CPU — the box may be heavily loaded; retry pup_close.` } : {}),+          })});+          return;+        }+        case 'pup_wait': {+          const ms = args.ms || 3000;+          await new Promise(r => setTimeout(r, ms));+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, waited: ms }) });+          return;+        }++        // ─── Credential vault (HTTP Basic Auth for pup) ────────────────+        // Stored in the OS keychain (DPAPI on Windows, Keychain on macOS,+        // libsecret on Linux). Applied via puppeteer's page.authenticate()+        // before navigation so Chrome's native auth dialog never appears.+        case 'credential_set': {+          try {+            const r = await credentialVault.setCredential({+              host: args.host,+              username: args.username,+              password: args.password,+            });+            sendJSON(res, { success: true, output: JSON.stringify(r) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_credentials':   // v2.0.110: friendlier alias+        case 'credential_list': {+          try {+            const credentials = credentialVault.listCredentials();+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials, _hint: credentials.length ? `${credentials.length} saved login(s) in the OS keychain (passwords never listed). pup auto-fills these in the background; submit is reason-gated via pup_login. Remove one: credential_delete {host}.` : 'No saved logins yet. pup captures them automatically as you sign into sites (captureLogins:"on"), or store one explicitly with credential_set {host, username, password}.' }) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        // v2.0.368 (John: "all of us humans are now using ai as our personal assistant so it has to+        // be able to do everything we can do as a human. but yes, security needs to be taken into+        // account."). REVEAL one stored secret to a LOCAL caller, with the user's click as the gate.+        //+        // Why this exists: Grok Bot runs ON the desktop with a shell and calls adom-bridge natively.+        // The adom-you vault is the list that grows with every login pup makes on John's behalf, and+        // nothing could read a secret back out of it: pup_credentials lists host+username only, and+        // pup_login types the secret into a pup window itself. Right for an AI driving pup; wrong for+        // an assistant that needs the credential in ITS OWN tool. ab's own precedent for handing+        // plaintext to a caller is desktop_decrypt_browser_credentials: LOCAL callers only, a consent+        // dialog naming the requester, plaintext returned ONCE over loopback, never logged. This copies+        // that shape and narrows it: ONE host per request, never bulk.+        //+        // THE GATE IS THE CLICK, NOT THE TRANSPORT CHECK. pup cannot verify where a call came from:+        // caller headers are self-reported attribution (ab knows the verified relay connection; pup+        // does not). So the container-header refusal below is a best-effort tripwire for the common+        // cloud-relay case, and the thing that actually protects the vault is the sticky toast on the+        // user's own screen, naming the caller and the host, that THEY have to click. No click, no secret.+        case 'pup_credential_reveal': {+          const host = String((args && args.host) || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');+          const reason = String((args && args.reason) || '').trim();+          if (!host) { sendJSON(res, { success: false, errorCode: 'host_required', error: 'pup_credential_reveal needs {host}: the site whose login you need, one per call', _hint: 'Example: {"host":"github.com","reason":"signing into GitHub in my own browser to open a PR for the user"}. Bulk export does not exist on purpose.' }); return; }+          if (reason.length < 10) { sendJSON(res, { success: false, errorCode: 'reason_required', error: 'pup_credential_reveal needs a reason (min 10 chars) — it is shown to the user on the consent toast', _hint: 'Say what you will do with it, in words the user will read on their screen before deciding.' }); return; }+          // v2.0.374 (#82): ab 2.0.96+ stamps X-Adom-Verified-Origin from the transport it+          // OBSERVED (relay ws vs loopback vs bridge callback) — the one origin signal a caller+          // cannot forge. This verb hands out plaintext, so the gate is strict allow-list: only a+          // VERIFIED local caller passes. 'relay' and 'bridge' are refused, and a MISSING header+          // (pre-2.0.96 ab, or an unidentified process POSTing at pup's port directly) is refused+          // rather than assumed safe. The self-reported container header stays as a tripwire.+          const _vo = req.headers['x-adom-verified-origin'] || null;+          if (_vo !== 'local' || _callerContainer) {+            console.log(`[credential] reveal REFUSED for ${host}: verified-origin=${_vo || 'MISSING'} caller "${_callerThread || '?'}"${_callerContainer ? ` (container ${_callerContainer})` : ''}`);+            const isRelay = _vo === 'relay' || !!_callerContainer;+            sendJSON(res, {+              success: false,+              errorCode: isRelay ? 'relay_forbidden' : 'origin_unverified',+              error: isRelay+                ? 'pup_credential_reveal is for LOCAL callers on the desktop only; a relayed (cloud container) call is refused, matching ab\'s rule for desktop_decrypt_browser_credentials'+                : `pup_credential_reveal requires a VERIFIED local transport (ab 2.0.96+ X-Adom-Verified-Origin: local); this request arrived with origin "${_vo || 'MISSING'}"`,+              _hint: isRelay+                ? 'From a cloud thread, do not ask for the secret: call pup_login {submit:true, submitReason} and pup signs the site in inside a pup window without the credential ever leaving the vault.'+                : 'Call through the adom-bridge CLI on this desktop (ab 2.0.96+). A bridge callback or a direct loopback POST is not accepted for plaintext reveals.',+            });+            return;+          }+          let cred = null;+          try { cred = await credentialVault.getCredentialForUrl('https://' + host); } catch (e) { cred = null; }+          if (!cred) { sendJSON(res, { success: false, errorCode: 'no_credential', error: `no saved login for ${host}`, _hint: 'pup_credentials {} lists the hosts the vault knows (host + username only).' }); return; }+          const who = _callerThread || 'an unnamed local caller';+          const _ask = await consent.ask({+            key: `pup_credential_reveal|${host}|${_callerThread || 'local'}`, verb: 'pup_credential_reveal',+            what: `Waiting for the user to allow revealing the ${host} login to ${who}`,+            toast: {+              idPrefix: 'credreveal', level: 'warning', scenario: 'reminder',+              title: `Reveal your ${host} login to ${who}?`,+              body: `${who} is asking pup for the saved login for ${host} (username ${cred.username}). Reason given: "${reason}". Reveal sends the password to that caller ONCE. Deny sends nothing. This request came from a process on THIS desktop.`,+              buttons: [{ label: 'Reveal' }, { label: 'Deny' }],+            },+            callerThread: _callerThread || undefined, callerReason: reason,+          });+          if (_ask.pending) {+            console.log(`[credential] reveal for ${host} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) — answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);+            sendJSON(res, blockedOnUser({+              stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Reveal or Deny on the desktop toast "Reveal your ${host} login to ${who}?"`, remainingSec: _ask.remainingSec,+              error: 'waiting on the user, not failed: the consent toast is still on their screen',+              _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_credential_reveal again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,+            }));+            return;+          }+          const decision = _ask.decision;+          if (decision !== 'Reveal') {+            const code = decision ? 'consent_declined' : 'consent_timeout';+            console.log(`[credential] reveal ${code.toUpperCase()} for ${host} by "${who}"`);+            sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined on the desktop; nothing was revealed' : `the user did not answer the consent toast within ${consent.totalSec}s; nothing was revealed`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to reveal it, then call once more if they say yes.' });+            return;+          }+          // Plaintext ONCE. The audit line below is the only record and it carries no secret.+          console.log(`[credential] reveal GRANTED for ${host} (user ${cred.username}) to "${who}" — user clicked Reveal`);+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, host: cred.host, username: cred.username, password: cred.password, sourceAccount: cred.sourceAccount || null, revealedTo: who,+            _hint: 'This secret was released ONCE because the user clicked Reveal on their desktop. Use it, then drop it: never log it, never echo it into another verb\'s args or a response, never write it to disk.',+          }) });+          return;+        }+        // ── Payment-card vault (issue #84: pup could drive a whole checkout, then stall at the+        // card step). Storage mirrors the credential vault; the CONSENT model is stricter: a card+        // is not a password, so EVERY fill prompts the human once, in the moment, with merchant ++        // amount + last4 on the toast. No verb ever returns a PAN or CVV — pup_fill_card types+        // the digits into the page itself and returns booleans, so the AI never handles them.+        case 'pup_card_set': {+          try {+            const r = await cardVault.setCard({ nickname: args.nickname, number: args.number, expiry: args.expiry, cvv: args.cvv, name: args.name });+            console.log(`[card] SET ${r.nickname} (${r.brand} …${r.last4}) by "${_callerThread || '?'}"`);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r, _hint: 'Stored in the OS keychain. Fill it into a checkout with pup_fill_card {nickname, merchant, amount} — the user approves each fill on a desktop toast. Never echo the number/cvv anywhere again; drop them from your context.' }) });+          } catch (e) { sendJSON(res, { success: false, errorCode: 'card_invalid', error: e.message }); }+          return;+        }+        case 'pup_cards': {+          const cards = cardVault.listCards();+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, cards, _hint: cards.length ? 'Nickname/brand/last4/expiry only — the number and CVV never leave the keychain. Fill with pup_fill_card.' : 'No cards saved. Store one with pup_card_set {nickname, number, expiry, cvv, name} — ask the USER to provide it, never harvest it from a page.' }) });+          return;+        }+        case 'pup_card_delete': {+          const r = await cardVault.deleteCard(args.nickname);+          console.log(`[card] DELETE ${args.nickname} (removed=${r.removed}) by "${_callerThread || '?'}"`);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r }) });+          return;+        }+        case 'pup_fill_card': {+          const nickname = String((args && args.nickname) || '').trim().toLowerCase();+          if (!nickname) { sendJSON(res, { success: false, errorCode: 'nickname_required', error: 'pup_fill_card needs {nickname} — pup_cards {} lists what the vault holds', _hint: 'Also pass {merchant, amount} so the user\'s approval toast names the specific charge.' }); return; }+          const _lv = requireLiveSession(res, args);+          if (!_lv) return;+          const session = _lv.session;+          const sessionId = _lv.sessionId || args.sessionId;+          const meta = (cardVault.listCards() || []).find(c => c.nickname === nickname);+          if (!meta) { sendJSON(res, { success: false, errorCode: 'no_card', error: `no saved card named "${nickname}"`, _hint: 'pup_cards {} lists saved cards; pup_card_set stores one.' }); return; }+          const tab0 = (session.activeTabId && (session.tabs || []).find(t => t.tabId === session.activeTabId)) || (session.tabs || [])[0];+          const pg = tab0 && tab0.page;+          if (!pg) { sendJSON(res, { success: false, errorCode: 'no_page', error: 'the target window has no live page' }); return; }+          let merchant = String((args && args.merchant) || '').trim();+          if (!merchant) { try { merchant = new URL(pg.url()).hostname; } catch (e) { merchant = 'this page'; } }+          const amount = String((args && args.amount) || '').trim();+          const who = _callerThread || 'an unnamed caller';+          // THE GATE: per-fill human approval, in the moment, naming the specific charge. This is+          // deliberately allowed for RELAYED callers (unlike pup_credential_reveal): the digits+          // never reach the caller, and the human click is the authorization for this one fill.+          console.log(`[card] fill REQUESTED ${nickname} (…${meta.last4}) on ${merchant}${amount ? ' for ' + amount : ''} by "${who}" — asking the user`);+          const _ask = await consent.ask({+            key: `pup_fill_card|${nickname}|${merchant}|${_callerThread || 'anon'}`, verb: 'pup_fill_card',+            what: `Waiting for the user to approve the ${meta.brand} …${meta.last4} fill on ${merchant}`,+            toast: {+              idPrefix: 'cardfill', level: 'warning', scenario: 'reminder',+              title: `Pay with ${meta.brand} …${meta.last4} on ${merchant}?`,+              body: `${who} wants to fill your card "${nickname}" (${meta.brand} ending ${meta.last4}) into the checkout on ${merchant}${amount ? ` for ${amount}` : ''}. Approve fills the card fields in the pup window (the digits never leave this machine). Deny fills nothing.`,+              buttons: [{ label: 'Approve' }, { label: 'Deny' }],+            },+            callerThread: _callerThread || undefined, callerReason: `fill card ${nickname} on ${merchant}`,+          });+          if (_ask.pending) {+            console.log(`[card] fill ${nickname} on ${merchant} by "${who}" still waiting on the user after ${Math.round(consent.inVerbMs / 1000)}s (${_ask.consentId}) — answering blocked_on_user, toast stays up ${_ask.remainingSec}s more`);+            sendJSON(res, blockedOnUser({+              stillRunning: true, consentId: _ask.consentId, blockedBy: `The user must click Approve or Deny on the desktop toast "Pay with ${meta.brand} …${meta.last4} on ${merchant}?"`, remainingSec: _ask.remainingSec,+              error: 'waiting on the user, not failed: the approval toast is still on their screen',+              _hint: `Not an error. The toast stays up for ${_ask.remainingSec}s more. Call pup_fill_card again with the SAME args to re-attach and collect the decision (no second toast is shown), or poll pup_readiness {} and watch active[] for consentId ${_ask.consentId}. Do not tell the user it failed.`,+            }));+            return;+          }+          const decision = _ask.decision;+          if (decision !== 'Approve') {+            const code = decision ? 'consent_declined' : 'consent_timeout';+            console.log(`[card] fill ${code.toUpperCase()} ${nickname} on ${merchant} by "${who}"`);+            sendJSON(res, { success: false, errorCode: code, error: code === 'consent_declined' ? 'the user declined the card fill on the desktop; nothing was filled' : `the user did not answer the approval toast within ${consent.totalSec}s; nothing was filled`, _hint: 'Do not retry in a loop: the user saw the request. Ask them in chat whether they want to pay with this card, then call once more if they say yes.' });+            return;+          }+          let card = null;+          try { card = await cardVault.getCard(nickname); } catch (e) { card = null; }+          if (!card) { sendJSON(res, { success: false, errorCode: 'no_card', error: 'the card vanished from the keychain between listing and filling' }); return; }+          try {+            const r = await cardVault.fillCardIntoPage(pg, card);+            console.log(`[card] fill GRANTED+DONE ${nickname} (…${meta.last4}) on ${merchant} by "${who}": ${JSON.stringify(r.filled)} (${r.framesScanned} frames)`);+            const missing = Object.entries(r.filled).filter(([k, v]) => !v && (k !== 'name' || card.name)).map(([k]) => k);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, filled: r.filled, framesScanned: r.framesScanned, last4: meta.last4, brand: meta.brand, _hint: missing.length ? `Fields not found on the page: ${missing.join(', ')} — screenshot the checkout and check whether they use an unusual widget; fill those by driving the page directly WITHOUT ever asking for the digits.` : 'All card fields filled. Screenshot to verify, then submit the checkout. The digits never left the desktop; do not try to read them back.' }) });+          } catch (e) { sendJSON(res, { success: false, errorCode: 'fill_failed', error: `card fill failed: ${e.message}` }); }+          return;+        }+        case 'credential_delete': {+          try {+            const r = await credentialVault.deleteCredential(args.host);+            sendJSON(res, { success: true, output: JSON.stringify(r) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        // v1.9.124 — fill (and optionally submit) an HTML login FORM in the BACKGROUND, from the+        // vault or explicit creds, with NO Chrome autofill dropdown and NO foregrounding. This is+        // the answer to Q4: pup owns the creds, so it fills the fields over CDP directly instead of+        // waiting on Chrome's native floaty menu (which pup can't click without stealing focus).+        case 'pup_login': {+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          try {+            const curUrl = tab.page.url();+            let username = args.username, password = args.password, source = 'explicit';+            // Optionally persist an explicit cred so NEXT time it's automatic.+            if (username && password && args.save) {+              let host = args.host;+              try { if (!host) host = new URL(curUrl).hostname; } catch (e) {}+              if (host) { try { await credentialVault.setCredential({ host, username, password }); } catch (e) {} }+            }+            // No explicit creds → pull from the vault by the page's current URL.+            if (!password) {+              const creds = await credentialVault.getCredentialForUrl(curUrl);+              if (creds) { username = creds.username; password = creds.password; source = 'vault'; }+            }+            if (!password) {+              sendJSON(res, { success: false, errorCode: 'no_credential', output: JSON.stringify({ ok: false, url: curUrl }),+                _hint: `No credential to fill. Either pass {username, password} (add {save:true} to remember it for this host), or store one first with credential_set {host:"${(() => { try { return new URL(curUrl).hostname; } catch (e) { return '<host>'; } })()}", username, password} and call pup_login again. pup then fills the form in the BACKGROUND — no Chrome dropdown, no foregrounding.` });+              return;+            }+            // v2.0.110 (John): SUBMIT is reason-gated + domain-confirmed. Sending a stored secret+            // is the irreversible act — refuse submit:true without a submitReason, and regurgitate+            // the LIVE domain so the AI must confirm it is not a lookalike/phishing host first.+            if (args.submit && (!args.submitReason || String(args.submitReason).trim().length < 10)) {+              let liveHost = '<unknown>'; try { liveHost = new URL(curUrl).hostname; } catch (e) {}+              sendJSON(res, { success: false, errorCode: 'submit_reason_required', output: JSON.stringify({ ok: false, url: curUrl, host: liveHost }),+                _hint: `⛔ CONFIRM THE DOMAIN BEFORE SENDING CREDENTIALS. You asked to submit a stored login, but this is the point of no return: the password goes to "${liveHost}" the instant you submit. Is "${liveHost}" truly the site these credentials belong to — not a lookalike, typo-squat, or a host you were redirected to? If you are sure, re-call pup_login {submit:true, submitReason:"<why you are confident ${liveHost} is the correct, intended domain for this login>"} (min 10 chars). Not sure? Do NOT submit — pup already filled the fields, so a human can finish, or you can navigate away.`,+                _autoMode: 'Claude Code AUTO MODE note: this refusal is pup\'s own gate, not the harness safety check — re-calling pup_login with submitReason is a structured verb call and passes Auto review. Do NOT route around it with pup_eval/raw scripting to poke the form; hand-rolled credential scripting is exactly what Auto mode flags. If the user pasted a password into chat, use it once via pup_login {password}, then tell them pup auto-captures logins so they never need to paste it again.' });+              return;+            }+            if (args.submit) { let lh = ''; try { lh = new URL(curUrl).hostname; } catch (e) {} console.log(`[cred] pup_login submit to ${lh} — reason: ${String(args.submitReason).slice(0, 200)}`); }+            // v2.0.125: realKeystrokes forces genuine key events for React-masked fields (Gusto).+            // Those only register when the window is FOREGROUNDED, so raise it first. Default path+            // (.value-set) stays fully background. autoFill-on-load never uses this.+            const useKeystrokes = args.realKeystrokes === true || args.keystrokes === true;+            if (useKeystrokes) { try { await osRaiseSessionWindow(session, resolvedId, 'realKeystrokes login: this site uses a masked field that only accepts real keyboard input, which requires the window on screen for a moment'); await new Promise(r2 => setTimeout(r2, 500)); } catch (e) {} }+            const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit, keystrokes: useKeystrokes });+            if (!r || !r.ok) {+              sendJSON(res, { success: false, errorCode: r && r.reason === 'no_password_field' ? 'no_login_form' : 'login_fill_failed',+                output: JSON.stringify({ ok: false, reason: r && r.reason, url: curUrl }),+                _hint: (r && r.reason === 'no_password_field')+                  ? 'No password field found on this page — it may not be the login page yet, or the form is in an iframe / shadow DOM pup did not reach. Navigate to the actual login page first, then retry.'+                  : 'Could not fill the login form. Screenshot the page to see its state.' });+              return;+            }+            // v2.0.125: a false filledPassword after the default .value-set almost always means a+            // React-controlled MASKED field (Gusto etc.) silently ignored the set — the classic+            // "submits empty / Password is required" trap. Coach the caller to the fix instead of+            // letting it submit an empty password.+            // v2.0.397 (#92): after a SUBMIT, look for the walls only a human can pass (one-time+            // code, authenticator, captcha). That is blocked_on_user, not success.+            if (args.submit && r.submitted) {+              let _wall = null;+              try { await new Promise(rs => setTimeout(rs, 1800)); _wall = await detectHumanChallenge(tab.page); } catch (e) { _wall = null; }+              if (_wall) {+                console.log(`[login] ${liveHost || curUrl}: submitted, now on a ${_wall.kind} wall (${_wall.evidence}) — blocked_on_user`);+                sendJSON(res, blockedOnUser({ stillRunning: false, reason: _wall.kind, blockedBy: _wall.kind === 'captcha' ? 'The user must solve the captcha in the pup window' : 'The user must enter the one-time / two-factor code in the pup window', evidence: _wall.evidence,+                  output: JSON.stringify({ ok: false, url: (() => { try { return tab.page.url(); } catch (e) { return curUrl; } })(), submitted: true, filledUsername: r.filledUsername, filledPassword: r.filledPassword, wall: _wall.kind }),+                  _hint: `The login was filled and submitted; the site now needs the HUMAN (${_wall.kind}). Raise the window with pup_raise_os_window {foregroundReason:"finish the ${_wall.kind} for ${liveHost || 'this site'}"} and tell them what to do, then continue (screenshot to confirm you are past it). Do not re-submit the password.` }));+                return;+              }+            }+            const reactMaskedMiss = !useKeystrokes && r.filledPassword === false;+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true, url: curUrl, credentialSource: source,+              filledUsername: r.filledUsername, filledPassword: r.filledPassword,+              submitted: r.submitted, reactMaskedFieldSuspected: reactMaskedMiss || undefined,+            }),+              _hint: reactMaskedMiss+                ? `⚠ The password did NOT land — this field looks React-controlled/masked (common on SSO/Keycloak logins like Gusto): a background .value-set is ignored and the form would submit EMPTY. Re-call pup_login {realKeystrokes:true, submit:true, submitReason:"…"}. That types with real key events, which such fields honour, but it briefly foregrounds the window (the only way real keystrokes register). Check login-recipes/<domain>.md on the pup wiki page for the site's flow.`+                : `Login form filled in the BACKGROUND from ${source} creds${r.submitted ? ' and SUBMITTED' : ' (not submitted — pass {submit:true} to submit, or drive the submit button yourself)'}. No Chrome dropdown, window never foregrounded. Verify with pup_screenshot before assuming you are signed in.` });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }++        // ─── Desktop recording (HUD) ───────────────────────────────────+        case 'pup_recorder_open': {+          try {+            await ensureRecorderWindowOpen(args.reason);+            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({+              ok: true, opened: true, reason: desktopRecorder.reason,+            })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_screen_record_start': {+          try {+            const r = await desktopRecordStartImpl(args);+            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_screen_record_stop': {+          try {+            const r = await desktopRecordStopImpl(args);+            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_screen_record_status': {+          const cur = desktopRecorder.current;+          const status = {+            ok: true,+            hudOpen: !!desktopRecorder.browser,+            reason: desktopRecorder.reason,+            currentRecording: cur ? {+              recordingId: cur.recordingId,+              durationMs: Date.now() - cur.startedAt,+              filePath: cur.filePath,+              fps: cur.fps,+              audio: cur.audio,+            } : null,+            clipsThisSession: desktopRecorder.clipsThisSession,+          };+          sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify(status) });+          return;+        }+        case 'pup_screen_record_list': {+          sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({+            ok: true, recordings: desktopRecordListImpl(),+          })});+          return;+        }+        case 'pup_recorder_close': {+          try {+            const summary = await closeRecorderWindow();+            sendJSON(res, { success: true, _hint: SCREEN_RECORDER_HINT, output: JSON.stringify({+              ok: true, sessionSummary: summary,+            })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'desktop_list_monitors': {+          // Minimal v1 — return primary monitor only, with best-effort dimensions+          // (full multi-monitor enumeration is v1.1 via Tauri-side EnumDisplayMonitors)+          const monitors = [{+            index: 0, name: 'Primary',+            x: 0, y: 0,+            width: 1920, height: 1080, // best-effort fallback+            primary: true,+          }];+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, monitors })});+          return;+        }++        // ─── Tab recording (CDP screencast, no HUD) ─────────────────────+        // v1.9.76 — renew a high-fps lease. The watchdog WARNS before it downgrades, and a+        // running recording holds the lease open on its own, so an AI only needs this when it+        // knows up front it wants longer than the default without recording the whole time.+        case 'pup_highfps_extend': {+          const sid = args.sessionId || activeSessionId;+          if (!sid || !sessions.has(sid)) { sendJSON(res, { success: false, error: `Session "${sid}" not found` }); return; }+          const info = highFpsInfo(sid);+          if (!info) {+            sendJSON(res, { success: false, error: `Session "${sid}" is not a high-fps window — nothing to extend.`,+              _hint: 'This window runs on Chrome\'s normal power behaviour (the default, and the right choice for almost everything). Only a window opened with pup_open_window {highFps:true} holds a lease. If you need continuous full-rate frames while the window is occluded (background video recording), reopen it with highFps:true.' });+            return;+          }+          const rec = highFpsExtend(sid, args.minutes);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: sid, highFps: highFpsInfo(sid),+            _hint: `High-fps lease extended to ${rec.minutes} min. Reminder: while a recording is actually running the lease CANNOT lapse, so you do not need to keep extending during a take.` }) });+          return;+        }+        case 'pup_record_start': {+          try {+            const sessionId = args.sessionId || activeSessionId;+            if (!sessionId) {+              sendJSON(res, { success: false, error: 'sessionId is required' });+              return;+            }+            // v1.9.76: recording a window that is NOT high-fps yields ~1-2 fps whenever the+            // window is occluded, because Chrome throttles background paints. That is now the+            // DEFAULT power behaviour (issue #14), so warn loudly instead of shipping a bad take.+            const _hf = highFpsInfo(sessionId);+            if (!_hf) console.log(`[highfps] "${sessionId}" recording started on a NORMAL-power window — frames will drop to ~1-2 fps while it is occluded. Reopen with pup_open_window {highFps:true} for a full-rate background take.`);+            const r = await tabRecordStartImpl({ ...args, sessionId });+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_record_stop': {+          try {+            if (!args.recordingId) {+              sendJSON(res, { success: false, error: 'recordingId is required' });+              return;+            }+            const r = await tabRecordStopImpl({ sessionId: args.sessionId, recordingId: args.recordingId });+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'pup_record_status': {+          const active = tabRecordStatusImpl({ sessionId: args.sessionId });+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, active })});+          return;+        }+        case 'pup_record_list': {+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, recordings: tabRecordListImpl(),+          })});+          return;+        }++        default:+          sendJSON(res, { success: false, errorCode: 'unknown_verb', error: `Unknown command: ${command}.`,+            _hint: `Run pup_describe {} for the FULL current verb catalog (names, args, examples) — it is always up to date, unlike any memorized list. Common intents: window/tab work (pup_open_window, pup_open_tab, pup_my_windows), driving (pup_click, pup_type, pup_fill, pup_press_key, pup_navigate), looking (pup_screenshot, pup_eval), logins (pup_login, pup_credentials), capture (pup_annotate, pup_record_start).`,+            _autoMode: 'Claude Code AUTO MODE note: prefer a structured pup_* verb from pup_describe over improvising with desktop_* clicks + eval chains — structured verbs state their intent and pass Auto review; hand-rolled input scripting is what gets flagged.' }, 404);+          return;+      }+    }++    // v2.0.236: the legacy curl endpoints (/launch /navigate /reload /screenshot /screenshot.png+    // /errors /eval /wait) are GONE. Each duplicated a pup_* verb on the "active session" with none+    // of the ownership gate, session recovery, or foreground etiquette the verbs have — so any+    // caller reaching them got subtly different (worse) behavior, and they were the last consumer of+    // getActiveSession(). Nothing in this repo, the skills, or the recorder referenced them. They+    // answer 410 with the verb to use instead, so a forgotten script fails loudly and fixably.+    {+      const LEGACY = {+        '/launch': 'pup_open_window', '/navigate': 'pup_navigate', '/reload': 'pup_reload',+        '/screenshot': 'pup_screenshot', '/screenshot.png': 'pup_screenshot', '/errors': 'pup_errors',+        '/eval': 'pup_eval', '/wait': 'pup_wait',+      };+      if (LEGACY[pathname]) {+        res.writeHead(410, { 'Content-Type': 'application/json' });+        res.end(JSON.stringify({+          ok: false, gone: pathname, use: LEGACY[pathname],+          _hint: `${pathname} was a legacy curl shim that acted on whatever window happened to be "active" — no owner check, no recovery. Call the verb ${LEGACY[pathname]} through adom-bridge-cli instead (it targets YOUR thread's window and recovers a lost one).`,+        }));+        return;+      }+    }++    // DEMO ONLY (v2.0.342). The say-strip's phrase sending moved into the shared newbie-panel+    // module, which does it client-side against the same-origin relays above, so the old+    // /api/say-send is gone rather than left as a second, drifting implementation of the same+    // contract. What stays is the demo, because the demo script is pup's own and the module only+    // knows how to call back into onPlay.+    if (pathname === '/api/demo-send' && req.method === 'POST') {+      const guard = await shellSupportsSend();+      if (!guard.ok) { sendJSON(res, { ok: false, why: guard.why }); return; }+      const r = await hydrogenPost('/ai-threads/run-prompt', {+        new: true, prompt: DEMO_PROMPT, submit: true, name: 'Pup - demo', tags: ['dock:pup-bridge'],+      }, 120000);+      if (!r.status) { sendJSON(res, { ok: false, why: 'Could not reach the AI panel (' + (r.error || 'no response') + ').' }); return; }+      const j = r.json || {};+      // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI contract, dock-bar broadcast): run-prompt now returns+      // in under a second with {ok, job, state:'opening'} and delivered_to is never filled on that+      // path; progress comes from polling /ai-threads/run-prompt/status?job=<job>. working or later+      // counts as sent; submit_failed means the prompt sits in the tab unsent (tell the user to press+      // Enter there). Older builds still answer the delivered_to shape, handled below unchanged.+      if (j && j.job && j.state) {+        const t0 = Date.now(); let st = j.state, last = j;+        const SENT = new Set(['working', 'needs_you', 'done']);+        const FAIL = new Set(['submit_failed', 'submitted_unidentified', 'submit_unconfirmed', 'still_working']);+        while (Date.now() - t0 < 30000 && !SENT.has(st) && !FAIL.has(st)) {+          await new Promise(r2 => setTimeout(r2, 1000));+          const pr = await hydrogenGet('/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job), 5000);+          if (pr.status === 404) { sendJSON(res, { ok: false, why: 'This Hydrogen build has no run-prompt status route; the prompt may still be sending in the new tab.' }); return; }+          if (pr.json && pr.json.state) { last = pr.json; st = pr.json.state; }+        }+        if (SENT.has(st)) { sendJSON(res, { ok: true, state: st, job: j.job }); return; }+        if (st === 'submit_failed') { sendJSON(res, { ok: false, state: st, why: 'The demo prompt is sitting in the new AI tab but send never clicked: press Enter there to run it.' }); return; }+        if (FAIL.has(st)) { sendJSON(res, { ok: false, state: st, why: 'Hydrogen reported ' + st + ' for the new thread.' }); return; }+        sendJSON(res, { ok: true, state: st, job: j.job, why: 'The new AI tab opened; still ' + st + ' after 30s.' }); return;+      }+      // Three states, matching the module: delivered_to is an ARRAY, and a thread that opened+      // without confirming delivery is NOT a failure. Reporting it as one invites a retry, and a+      // retry double-sends the demo into a second thread.+      const dt = j.delivered_to;+      const arr = Array.isArray(dt) ? dt : (dt ? [dt] : []);+      if (arr.length || (j.inject && j.inject.delivered_in_conversation)) { sendJSON(res, { ok: true, state: 'delivered' }); return; }+      if (r.status >= 200 && r.status < 300) { sendJSON(res, { ok: true, state: 'opened', why: (j._hints && j._hints.note) || null }); return; }+      sendJSON(res, { ok: false, why: (j._hints && j._hints.note) || j.error || ('AI threads returned ' + r.status) });+      return;+    }+    // The DOCK CONTRACT status payload (v2.0.337). John, via the Dock Bar guide: "we can't have+    // pup having 2 different dashboards." pup used to serve a second, near-empty "launch page" from+    // a container-side helper on its own port, and the dock pointed at THAT while this dashboard,+    // the one with the taskbar strip, the window tree and icon provenance, went undiscovered. The+    // dock now opens THIS page, so the contract's status feed has to come from here.+    //+    // Building it in the bridge is not just relocation, it removes a class of bug: the helper+    // reached pup over ab and had to GUESS which desktop to ask when several were connected, which+    // is why John's laptop dashboard rendered a "target: ConfRoomROG" chip. The bridge IS the+    // desktop, so there is nothing to resolve and nothing to get wrong.+    if (pathname === '/dashboard/newbie-panel.js') {+      try {+        const js = fs.readFileSync(path.join(__dirname, 'newbie-panel.js'));+        res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'no-store' });+        res.end(js);+      } catch (e) { res.writeHead(500); res.end('newbie-panel.js missing'); }+      return;+    }+    // Same-origin proxies for the two Hydrogen endpoints the module calls. The module defaults+    // apiBase to '' ON PURPOSE so the dashboard's own server relays them: a browser page on+    // 127.0.0.1:8851 fetching 127.0.0.1:47084 is cross-origin, and a missing CORS header makes the+    // browser discard a perfectly good 200. The bridge already talks to Hydrogen server-side, so+    // relaying costs nothing and removes CORS from the picture entirely.+    // RELAY THE BYTES, NOT A RE-SERIALISATION (v2.0.349). This used to JSON.parse the upstream+    // response and JSON.stringify it back, which is a lossy round trip pretending to be a proxy:+    // an integer past 2^53 loses precision, 1.0 becomes 1, and a non-JSON body upstream turns into+    // an invented {"error":...} object. The module reads updater.current_version by splitting on+    // dots and treats delivered_to as an ARRAY, so a retype changes BEHAVIOUR while every+    // status probe stays green. Forward the status, the content-type and the body untouched.+    if (pathname === '/updater/status' && req.method === 'GET') {+      const r = await hydrogenGet('/updater/status', 8000);+      if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }+      res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+      res.end(r.raw);+      return;+    }+    if (pathname === '/ai-threads/run-prompt' && req.method === 'POST') {+      let body = '';+      req.on('data', (d) => { body += d; if (body.length > 65536) req.destroy(); });+      req.on('end', async () => {+        let payload = null;+        try { payload = JSON.parse(body); } catch (e) { res.writeHead(400); res.end('{"error":"bad json"}'); return; }+        // 120s, matching the module's own AbortController: the editor can take 60s just to create+        // a tab on a loaded workbench, and a shorter relay timeout would manufacture a failure the+        // module would then report as "opened" when it actually delivered.+        const r = await hydrogenPost('/ai-threads/run-prompt', payload, 120000);+        if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable' })); return; }+        res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+        res.end(r.raw);+      });+      return;+    }+    // v2.0.388 (Hydrogen 1.0.187+ Send-to-AI): the THIRD proxied route. run-prompt now returns a job+    // id and the page polls this once a second for {state}. The query string (?job=<uuid>) MUST be+    // forwarded intact: a path-only proxy answers about no job at all. Same byte-faithful relay as+    // the two routes above; a missing ah answers 502-with-a-hint, never a 404 the module reads as+    // "route not wired".+    if (pathname === '/ai-threads/run-prompt/status' && req.method === 'GET') {+      const qs = (() => { try { const u = new URL(req.url, 'http://127.0.0.1'); return u.search || ''; } catch (e) { return ''; } })();+      const r = await hydrogenGet('/ai-threads/run-prompt/status' + qs, 8000);+      if (!r.status) { res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.end(JSON.stringify({ error: r.error || 'hydrogen unreachable', _hint: 'Hydrogen (ah) is not reachable from pup; the AI-threads progress route needs the local Hydrogen control API.' })); return; }+      res.writeHead(r.status, { 'Content-Type': r.contentType || 'application/json', 'Cache-Control': 'no-store' });+      res.end(r.raw);+      return;+    }+    if (pathname === '/api/status') {+      const warming = !chrome || !chrome.readiness;+      const rd = warming ? null : chrome.readiness();+      const leds = [];+      const cftOk = !!(rd && (rd.ready === true || rd.installed === true) && rd.installPhase !== 'failed');+      leds.push({+        key: 'chrome', label: 'Chrome for Testing',+        state: warming ? 'warn' : (rd.installPhase === 'failed' ? 'fail' : (cftOk ? 'ok' : 'warn')),+        detail: warming ? 'the bridge is still warming up'+          : (rd.installPhase === 'failed' ? `install FAILED: ${rd.lastError || 'unknown error'}`+          : (rd.installing ? `downloading (${rd.installProgressPct || 0}%)`+          : (cftOk ? (rd.version ? `installed, pinned ${rd.version}` : 'installed and pinned') : (rd.reason || 'not installed yet')))),+        short: warming ? 'warming' : (cftOk ? 'installed' : (rd.installing ? 'installing' : 'not installed')),+        _hint: cftOk ? null : 'Run pup_prewarm to install and warm Chrome for Testing before the first window.',+      });+      // Answering by construction: this response IS the bridge answering, so claiming anything+      // else here would be theatre. The honest variable is the version, not the state.+      leds.push({ key: 'bridge', label: 'Bridge', state: 'ok', detail: `pup ${BRIDGE_VERSION} answering on ${os.hostname()}`, short: 'answering', _hint: null });+      let tabCount = 0, notYou = 0;+      for (const [, s2] of sessions) {+        tabCount += Array.isArray(s2.tabs) ? s2.tabs.length : 0;+        if ((s2.profileName || DEFAULT_PROFILE) !== DEFAULT_PROFILE) notYou++;+      }+      leds.push({+        key: 'windows', label: 'Windows', state: 'ok',+        detail: sessions.size ? `${sessions.size} window(s), ${tabCount} tab(s) open` : 'no pup windows open right now',+        short: `${sessions.size} open`, _hint: null,+      });+      leds.push({+        key: 'profile', label: 'Profile',+        state: notYou ? 'warn' : 'ok',+        detail: notYou ? `${notYou} window(s) are NOT on the shared ${DEFAULT_PROFILE} profile, so their logins build nothing`+                       : `every window is surfing as ${DEFAULT_PROFILE}, so logins accumulate`,+        short: notYou ? `${notYou} not ${DEFAULT_PROFILE}` : DEFAULT_PROFILE, _hint: null,+      });+      const worst = leds.some(l => l.state === 'fail') ? 'fail' : (leds.some(l => l.state === 'warn') ? 'warn' : 'ok');+      sendJSON(res, {+        state: worst, label: 'pup',+        detail: worst === 'ok' ? 'ready for action' : (leds.find(l => l.state !== 'ok') || {}).detail || 'needs attention',+        version: BRIDGE_VERSION, dashboardVersion: BRIDGE_VERSION,+        desktop: os.hostname(), desktops: [os.hostname()], leds,+      });+      return;+    }+    // GET /status+    if (pathname === '/status') {+      // v2.0.13 (John): /status no longer triggers ensureHeavyInit, so on a cold spawn `chrome` may+      // not be loaded yet. Answer the chip probe INSTANTLY with a warming state rather than throwing+      // on chrome.readiness() — that is what let ab's spawn-ready probe succeed instead of reaping us.+      if (!chrome || !chrome.readiness) {+        sendJSON(res, {+          ok: true, bridge: 'puppeteer', version: BRIDGE_VERSION,+          led: 'yellow', summary: 'starting',+          tooltip: `Puppeteer bridge v${BRIDGE_VERSION} — warming up (loading runtime, recovering sessions)`,+          warming: true, ready: false,+          sessionCount: sessions.size, activeSession: activeSessionId,+        });+        return;+      }+      const list = [];+      for (const [id, s] of sessions) {+        let pageUrl = null;+        try { pageUrl = s.page.url(); } catch (e) {}+        list.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });+      }+      // Status-chip contract (AD renders led/summary/tooltip verbatim). The chip+      // now TELLS THE TRUTH about cold-start: yellow while Chrome for Testing is+      // missing/installing, red if its install failed, green when pup is ready.+      const rd = chrome.readiness();+      let led = 'green', summary, tooltip;+      if (rd.installPhase === 'failed') {+        led = 'red'; summary = 'Chrome install failed';+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing install FAILED: ${rd.lastError}\nRetry pup_prewarm (usually a network/proxy or disk issue).`;+      } else if (rd.installing) {+        led = 'yellow'; summary = `Installing Chrome ${rd.installProgressPct}%`;+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nDownloading Chrome for Testing (${rd.installProgressPct}%) — pup not ready yet.\nPoll pup_readiness until ready.`;+      } else if (!rd.ready) {+        // No drivable browser AT ALL (no Chrome, no Edge, no cached CfT) — the rare case.+        led = 'yellow'; summary = 'No browser yet';+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nNo Chrome/Edge detected and Chrome for Testing not cached — pup will fetch CfT on first use (or call pup_prewarm). Installing Chrome or Edge is instant, no download.`;+      } else {+        // READY: a drivable browser exists (installed Chrome/Edge, or cached CfT).+        // Installed-browser-first model — CfT being absent is NOT a warning state.+        led = 'green'; summary = `${sessions.size} window${sessions.size === 1 ? '' : 's'} · ${rd.browserKind}`;+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION} — ready\nDrives ${rd.browserKind} (${rd.browserSource}) · ${sessions.size} session(s)${rd.chromeForTestingInstalled ? `\nCfT ${rd.chromeBuildId || ''} cached as fallback` : ''}`;+      }+      // v1.8.78: the ONE non-cosmetic degradation on old AD gets a visible chip warning.+      if (_adRaiseGateBypassed) {+        if (led === 'green') led = 'yellow';+        tooltip += `\n⚠ AD ${_adVersion} < 1.9.124: raise intercepted — the foreground gate does not apply on this box. Update Adom Bridge.`;+      }+      sendJSON(res, {+        ok: true,+        bridge: 'puppeteer',+        ...statusCore(),+        adVersion: _adVersion,+        raiseGateBypassed: _adRaiseGateBypassed || undefined,+        led, summary, tooltip,+        sessions: list,+        chrome: rd,+        pupshotUrl,+      });+      return;+    }++    // POST /close+    if (pathname === '/close' && req.method === 'POST') {+      for (const [, be] of browsers) {+        try { await be.browser.close(); } catch (e) {}+      }+      browsers.clear();+      sessions.clear();+      activeSessionId = null;+      sendJSON(res, { ok: true, message: 'All sessions closed' });+      return;+    }++    sendJSON(res, { error: 'Unknown endpoint: ' + pathname }, 404);+  } catch (e) {+    // v1.9.79 (issue #16): if a verb blew up (timeout / detached / target closed) on a tab whose+    // renderer CRASHED, do not return a useless generic error. The crash event often lands right as+    // the op times out, so re-check here and return the structured crash error with the+    // reload-to-recover path — turning a 20s silent hang into an actionable answer.+    // A BLOCKING DIALOG LOOKS EXACTLY LIKE A HANG, so say which it is (v2.0.344). Every verb that+    // touches a page with a modal up returns some flavour of "timed out", and three different+    // timeout messages taught the AI nothing about the real cause. If a dialog is pending on this+    // session, the timeout is EXPLAINED rather than reported bare.+    // v2.0.353: scan EVERY session, not args.sessionId. The first cut looked the dialog up on+    // `args.sessionId`, which is the LEGACY id: pup's documented addressing is zero-id ({} resolves+    // to your thread's window) or {window:"..."}, so args.sessionId is normally undefined and this+    // whole branch never ran. The feature worked only for the one calling convention almost nobody+    // uses, which is why it looked shipped and behaved exactly as if it had never been written.+    try {+      const _d = findAnyPendingDialog();+      if (_d && /timed out|timeout|detached|Target closed|Session closed/i.test(e.message || '')) {+        sendJSON(res, {+          status: 'error',+          error: `a ${_d.type} dialog in window "${_d.sessionId}" is BLOCKING EVERY pup window: ${JSON.stringify(_d.message)}`,+          errorCode: 'dialog_open',+          dialog: { type: _d.type, message: _d.message, defaultValue: _d.defaultValue, tabId: _d.tabId, window: _d.sessionId },+          _hint: `EVERY pup window is stuck until this is answered, including windows that have nothing to do with it, because pup's windows share one browser and a modal blocks the browser. The dialog is in window "${_d.sessionId}".` + (_d.type === 'beforeunload'+            ? ` It is asking to confirm leaving a page with UNSAVED CHANGES. pup will not answer it for you: "Cancel" keeps the page and blocks your navigation, "Reload" throws the changes away. Decide, then call pup_dialog {"window":"${_d.sessionId}","action":"accept"} to proceed, or "dismiss" to stay. If the unsaved work might matter, read it out with pup_eval FIRST.`+            : ` Answer it with pup_dialog {"window":"${_d.sessionId}","action":"accept"} or "dismiss"${_d.type === 'prompt' ? ', optionally with promptText' : ''}.`),+        }, 200);+        return;+      }+    } catch (de) {}+    try {+      const _a = (typeof args !== 'undefined' && args) || {};+      const _sess = _a.sessionId && sessions.get(_a.sessionId);+      if (_sess && /timed out|detached|Target closed|Session closed|crashed/i.test(e.message || '')) {+        const _t = _a.tabId ? getTabById(_sess, _a.tabId) : getActiveTab(_sess);+        if (_t && _t._crashed) { _throwIfCrashed(_t, _a.sessionId); }+      }+    } catch (ce) {+      if (ce && ce._isStructured) {+        try { const pl = JSON.parse(ce.message); console.error('Handler error (tab crashed):', pl.error); sendJSON(res, pl, 200); return; } catch (pe) {}+      }+    }+    console.error('Handler error:', e);+    sendJSON(res, { error: e.message }, 500);+  }+});++// Global request timeout — no HTTP request should hang longer than 45s+// v2.0.391 (release sweep on winvm, 2026-09-04): this was 45_000, which silently CLOSED the socket+// under any verb slower than 45s (a heavy vendor site on a small VM, an isolated-profile launch)+// while the handler kept running - the caller saw a transport error and pup still created the+// window, the worst possible split. bridge.json declares up to 240s (prewarm/use) and 150s+// (open_window); the socket budget must exceed the longest declared verb, never undercut it.+server.timeout = 260_000;+server.headersTimeout = 65_000;+server.keepAliveTimeout = 60_000;+server.requestTimeout = 260_000;+server.keepAliveTimeout = 30_000;++// Bind to the host AD tells us to (AD 1.9.63+ passes ADOM_BIND_HOST, always 127.0.0.1),+// falling back to loopback. AD's hard guarantee is NO firewall prompts by default —+// nothing it spawns may bind a public interface (0.0.0.0) unasked. A hostless listen()+// binds every interface and pops the Windows Firewall "allow access?" dialog; honoring+// ADOM_BIND_HOST keeps a loopback-only socket, which is firewall-exempt.+const BIND_HOST = process.env.ADOM_BIND_HOST || '127.0.0.1';+let _pupBoundOnce = false;+const _onPupBound = async () => {+  if (_pupBoundOnce) return; _pupBoundOnce = true;+  console.log(`Puppeteer Bridge running on http://${BIND_HOST}:${PORT} (bind=${BIND_HOST}; no firewall prompt)`);+  // v2.0.41: publish pup's own port so jump-list tasks can talk straight back to us (pup→pup), and+  // (re)write the callback launcher so the .ps1 tracks any format change on upgrade.+  try { writePupPortFile(); writeExtensionPortFile(); ensurePupJumplistLauncher(); } catch (e) {}+  console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');+  console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');+  // v2.0.13 (John, "everyone keeps saying pup is down"): DEFER the heavy native load + warm-up ++  // session recovery OFF the bind tick with setImmediate, so the event loop is free to answer /health+  // and /status (ab's spawn-ready probe) the instant the port binds. Previously ensureHeavyInit() ran+  // synchronously here and blocked the loop for the whole ~8s native load; a probe landing in that+  // window stalled and ab reaped the bridge ("never bound its port 64230") — the cold-spawn flap.+  // Verbs still force ensureHeavyInit() on first use (idempotent), so nothing races ahead of its deps.+  setImmediate(async () => {+    try { ensureHeavyInit(); } catch (e) { console.error('heavy init failed:', e.message); }+    // Self-warm: detect browsers + disk so the FIRST open is instant (cheap no-op if a browser exists).+    // v2.0.369: tell chrome.js whether the toolbar family is wanted BEFORE warmup, so a fresh+    // Edge-only box background-fetches CfT at install time and converges onto the toolbar-capable+    // browser with zero pinning (the Azure-VM Edge regression).+    // v2.0.378 (#81): a pup_use {temporary:true} pin lives only until this boot.+    try { const _exp = chrome && chrome.expireTemporaryDefault && chrome.expireTemporaryDefault(); if (_exp) console.log(`[launch] temporary browser pin (${_exp}) expired at boot → auto`); } catch (e) {}+    try { if (chrome && chrome.setPreferCft) chrome.setPreferCft(pupSettings().toolbarExtension !== 'off'); } catch (e) {}+    try { if (chrome && chrome.warmup) chrome.warmup(); } catch (e) { console.error('warmup failed:', e.message); }+    // Recover sessions from disk (reconnect to Chrome windows that survived a bridge restart).+    // Fire-and-forget: recovery must NEVER gate readiness or block the health probe.+    try { await recoverSessions(); } catch (e) { console.error('Session recovery failed:', e.message); }+  });+  // v1.8.73 / v2.0.118: register the Adom.Pup anchor app identity once per bridge run (HKCU AUMID,+  // registry-only — NO Start Menu shortcut, users disliked it; also self-heals a stale one). Loopback+  // call from the bridge itself — trusted transport, no relay gating. Self-gates on AD <1.9.134.+  setTimeout(() => { ensurePupIdentityRegistered().catch(() => {}); }, 2500);+  setTimeout(() => { probeAdVersion().catch(() => {}); }, 1500);+  // v1.9.116 — ON EVERY BRIDGE START, FORCE A JUMP-LIST RE-COMMIT FOR EVERY RECOVERED SESSION.+  // This is the structural gap that let a fixed bug reach John anyway. A jump-list task embeds a full+  // CLI command line, so when the command FORMAT changes (v1.9.115 had to add --ai-thread or AD refuses+  // the click), every window that already exists keeps its OLD, now-broken command. stampPupIdentity+  // only re-commits when the appId CHANGES, so a window whose category is unchanged across an upgrade+  // never picks up the new format: John clicked "Switch to public view" on a pre-upgrade window and it+  // silently did nothing, while the same verb worked perfectly from the CLI. Clearing the dedupe key+  // before the startup sweep makes the re-attach real rather than a no-op, so a bridge upgrade now+  // repairs task commands on every surviving window. Cheap: one AD call per live window, once per boot.+  setTimeout(() => {+    try {+      let n = 0;+      for (const [, s] of sessions) { if (s) { s._wikiJumplistKey = undefined; s._jlFrozen = false; n++; } }+      if (n) console.log(`[jumplist] bridge start: forcing a re-commit on ${n} recovered session(s) so task commands match this version`);+    } catch (e) {}+    brandSweep('startup').catch(() => {});+  }, 8000);+  setTimeout(() => { ensureWindowOpenedWatch().catch(() => {}); }, 9000);++  // Session recovery now runs in the setImmediate above (fire-and-forget), so it never gates the+  // health probe or blocks the bind tick.++  // v1.9.45: sweep orphaned AUMID keys, but LATE. Running it straight after recoverSessions() looked+  // right and was wrong: when recovery legitimately ends with zero sessions (or is still settling),+  // "not a live session" matches EVERY key and the sweep deletes the whole set, unbranding windows+  // that were about to be re-stamped (observed: "pruned 12 stale keys" then keys: 0). Wait for the+  // session map to settle, and never sweep from an empty map — with no live sessions there is+  // nothing to distinguish stale from pending, so doing nothing is the correct, safe answer.+  setTimeout(() => {+    try {+      if (!sessions.size) { console.log('[identity] AUMID prune skipped — no live sessions to compare against'); return; }+      pruneStaleAumids();+    } catch (e) {}+  }, 60000);++  // v1.9.47: reclaim on boot (catches windows orphaned by a crash/restart) and then as a slow safety+  // net, so an unbranded window can never persist even if its window-opened event was missed.+  setTimeout(() => { reclaimUnmanagedWindows().catch(() => {}); }, 12000);+  setInterval(trackedLoop('reclaim-unmanaged', 90000, () => { if (minimalTouch()) return; return reclaimUnmanagedWindows().catch(() => {}); }), 90000);+  // v1.9.279: the reconciler runs UNGATED (bookkeeping + identity only, never a process kill), so+  // closed windows are reaped and stamp/settings drift is corrected within seconds, in every mode.+  setInterval(trackedLoop('reconcile-model', 8000, () => reconcileModel('periodic').catch(() => {})), 8000);+  // v2.0.161 (John: AUMID icons need a JANITOR, not just close-time cleanup): sweep orphaned+  // Adom.Pup.* registry keys + prior-generation .ico caches on a slow cadence, so leaks from+  // generation bumps, renamed/closed threads, and crashed windows never accumulate over a long+  // uptime. The 60s startup prune skips an empty session map (boot-recovery guard); this steady-state+  // sweep runs every 5 min and reaps even when nothing is live (an empty map long after boot means+  // the keys are genuinely orphaned, and a re-stamp self-heals the rare reclaim race). reg.exe is+  // only spawned every 5 min, so it adds no ab-log noise.+  setInterval(trackedLoop('prune-stale-aumids', 300000, () => { try { pruneStaleAumids(); } catch (e) {} }), 300000);+  // Build the notched category tiles once at boot (John's clean-backdrop-for-the-overlay idea).+  // Fire-and-forget: pupIconPath serves the un-notched tile until this finishes, then it re-stamps.+  ensureNotchedIcons().catch(() => {});+  // v1.9.179 — TAB RECONCILER. Prune dead/detached and duplicate tab entries so the taskbar count+  // badge reflects the REAL live tabs (rapid reconnects had inflated 1-tab windows to "4") and the+  // favicon overlay reads from a LIVE active tab instead of a detached phantom. Runs on a gentle+  // cadence; only sessions whose tab list actually changed re-stamp their overlay.+  // v1.9.182: boot pass + a SLOW patrol (5 min, was 45s). The dup/phantom state only arises from+  // reconnects, and every rescan already reconciles inline — the patrol is just a backstop, and a+  // hot one multiplies any probe-judgement bug across every window (today's lesson).+  setTimeout(() => { for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }, 8000);+  setInterval(trackedLoop('identity-audit', 300000, () => { if (minimalTouch()) return; auditStampedIdentities().catch(() => {}); }), 300000);   // v2.0.386 (#83 ask 3)+  setInterval(trackedLoop('reconcile-session-tabs', 300000, () => { if (minimalTouch()) return; for (const [sid, s] of [...sessions]) reconcileSessionTabs(s, sid).catch(() => {}); }), 300000);+  // v1.9.171 — CRASH SELF-HEAL. The shared Chrome dies two ways on heavy/hostile pages (measured+  // repeatedly on the Autodesk sign-in flow), and pup must recover from BOTH without a human:+  //+  //   (1) CRASHED — Chrome's process exits. puppeteer fires 'disconnected', handleBrowserDisconnect+  //       DELETES the browsers entry and marks each affected session `_lostBrowser`. So by the time+  //       any watchdog runs the browsers map is already EMPTY — probing it finds nothing (this is why+  //       the v1.9.170 browsers-probe never fired). The real signal is the orphaned `_lostBrowser`+  //       sessions, and the real fix is to RELAUNCH them from disk. On the one durable profile they+  //       come back SIGNED IN at their last URL, in the background frame (no foreground steal).+  //   (2) HUNG — Chrome is alive but its CDP socket stops answering (no 'disconnected' fires, so the+  //       entry LINGERS as a dead handle; the next verb returns 'Connection closed' and passive verbs+  //       return null → pup LOOKS frozen, John: 'pup seems frozen again'). A real CDP probe with a+  //       timeout catches this; two consecutive misses (~30s, not a transient slow beat) = discard ++  //       reap + mark sessions lost, which then feed into the same (1) relaunch path.+  //+  // Backoff is mandatory: a page that crashes-Chrome-on-load (Kepler) would otherwise spin an endless+  // relaunch loop and cook the laptop. Cap auto-relaunches per session; after the cap, hands off and+  // tells the user via the cleanup queue that the page itself looks unstable.+  const _healthStrikes = new Map();       // profileName -> consecutive hung-probe misses+  const _healRelaunch = new Map();        // sessionId -> { count, last, gaveUp }+  const _healingIds = new Set();          // sessionIds with a relaunch in flight (guards the 15s re-tick)+  const MAX_HEAL_ATTEMPTS = 2;+  const HEAL_COOLDOWN_MS = 5 * 60 * 1000;+  // v1.9.256 SLEEP/WAKE + LOST-BROWSER RECOVERY (John, after a day away with the lid closed: "a+  // laptop going to sleep is quite a common occurrence so you need to make sure after a wake that+  // you are able to get back to the previous state you were in, i.e. all your browsers should not+  // be lost anymore"). His dashboard sat on 'browser lost' for a day because the crash-recovery+  // loop lived inside the minimalTouch-gated health interval - the THIRD critical feature silently+  // killed by that one gate (overlay enforcer, user-activity watcher, now recovery). Recovery runs+  // UNGATED here: minimal-touch forbids KILLING or RESTARTING a live Chrome behind the user's back;+  // it does not forbid resurrecting DEAD windows to restore the user's state - that restoration is+  // exactly what the user wants after a sleep. (Hung-Chrome teardown, which DOES kill, stays gated.)+  let _lastRecoveryTick = Date.now();+  setInterval(trackedLoop('sleep-wake-heal', 20000, async () => {+    if (process.platform !== 'win32' || pupSettings().sleepRecovery !== 'on') return;+    const now = Date.now();+    const gap = now - _lastRecoveryTick;+    _lastRecoveryTick = now;+    if (gap > 90000) console.log(`[wake] system appears to have SLEPT ~${Math.round(gap / 60000)}min (timer gap) — checking every session for lost browsers`);+    for (const [sid, sess] of [...sessions]) {+      if (!sess._lostBrowser || _healingIds.has(sid)) continue;+      const rec = _healRelaunch.get(sid) || { count: 0, last: 0, gaveUp: false };+      if (rec.count >= MAX_HEAL_ATTEMPTS) {+        if (now - rec.last < HEAL_COOLDOWN_MS) {+          if (!rec.gaveUp) {+            rec.gaveUp = true; _healRelaunch.set(sid, rec);+            try { sess._healGaveUp = true; } catch (e) {}   // v1.9.305: now the reaper may finally clean it up+            console.log(`[heal] "${sid}" exceeded ${MAX_HEAL_ATTEMPTS} relaunch attempts — backing off ${Math.round(HEAL_COOLDOWN_MS / 60000)}m`);+            recordCleanup(`pup tried to bring your "${sid}" window back but it kept failing — pup has stopped retrying for now. Reopen it manually when ready; your logins are safe on disk.`);+          }+          continue;+        }+        rec.count = 0; rec.gaveUp = false;+      }+      _healingIds.add(sid);+      rec.count += 1; rec.last = now; _healRelaunch.set(sid, rec);+      console.log(`[heal] "${sid}" browser lost (crash or sleep) — recovering (attempt ${rec.count}/${MAX_HEAL_ATTEMPTS}) at its last URL`);+      try {+        const r = await recoverOrRelaunchSession({ sessionId: sid });+        if (r && r.session) {+          console.log(`[heal] "${sid}" recovered — window is back, signed in, where it left off`);+          recordCleanup(`pup auto-recovered your "${sid}" window (lost to a crash or the machine sleeping) — relaunched in the background, signed in, at where it left off.`);+        } else {+          console.log(`[heal] "${sid}" recovery did not resolve a live session yet — will retry next tick`);+        }+      } catch (e) {+        console.log(`[heal] "${sid}" recovery failed: ${e.message} — will retry next tick`);+      } finally {+        _healingIds.delete(sid);+      }+    }+  }), 20000);++  // v1.9.255 NO-FAULT WATCH (John: "if the window does get foreground by chrome itself or a focus+  // or some other reason, you do watch it for 20 seconds and put up a caption saying something like+  // 'it wasn't my fault, the javascript, or focus, or chrome caused the foreground. not me!'").+  // For 20s after each background open/nav, poll the OS foreground ONLY while a watch is armed+  // (zero steady-state cost). If the window surfaces without a granted foregroundReason: show the+  // disclaimer caption (2s), mark it foreground (the IRON LAW: whatever raised it, it stays up),+  // disarm. NEVER a knockback - that variant shipped as 1.9.252 and was rightly killed same day.+  setInterval(trackedLoop('notmine-caption-watch', 2500, async () => {+    // v2.0.292: fgWatch removed (its foreground-STEAL purpose died with off-screen birth; it was+    // caption-only, a 20s-scoped duplicate of notMineCaption). The 20s self-foreground caption now+    // honors notMineCaption — the single control for "a window fronted itself, Adom did not do it".+    if (process.platform !== 'win32' || pupSettings().notMineCaption !== 'on') return;+    try {+      const now = Date.now();+      const watched = [...sessions].filter(([, s2]) => s2._fgWatchUntil && s2._fgWatchUntil > now && !s2._foreground && !s2._lostBrowser);+      if (!watched.length) return;+      const fg = await osGetForegroundWindow();+      if (!fg) return;+      for (const [sid2, s2] of watched) {+        try {+          if (!s2._hwnd) { try { await resolveSessionHwndByBounds(s2, sid2); } catch (e) {} }+          if (!s2._hwnd || String(fg) !== String(s2._hwnd)) continue;+          s2._fgWatchUntil = 0;+          s2._foreground = true;   // iron law: it is up, it stays up+          // v1.9.282 (John, furious the caption lingered): (a) the whole caption is a user-toggle+          // (default on) - if off, mark foreground and move on, silently; (b) DEDUPE hard - the+          // watch fires per-session, and rapid fires all reused the id 'pup-nofault', each resetting+          // AD's expiry so the caption never got its 2s and stayed forever. At most one no-fault+          // caption every 10s, so it can NEVER be held open by a re-issue storm.+          if (pupSettings().notMineCaption !== 'on') break;+          if (Date.now() - (globalThis._lastNoFaultCaptionAt || 0) < 10000) break;+          // v1.9.292 (John: "i tend to click the taskbar icon to bring the window to the foreground...+          // i only want to see this message if it was some other reason other than me raising it").+          // A taskbar click is RECENT user input; a JS/Chrome raise has none. If input happened within+          // the last 1.5s, assume the USER raised it and stay SILENT. Errs toward silence (a JS raise+          // that coincides with the user typing elsewhere is also suppressed — the polite direction).+          const _idle = await osIdleMs();+          if (_idle != null && _idle < 1500) { console.log(`[fg-watch] "${sid2}" surfaced with recent user input (${_idle}ms idle) — assuming USER raised it, no caption`); break; }+          globalThis._lastNoFaultCaptionAt = Date.now();+          console.log(`[fg-watch] "${sid2}" surfaced with no pup raise and no recent input (idle ${_idle}ms) — no-fault caption (2s, deduped)`);+          const _nmMsg = `This window was NOT foregrounded by Adom: JavaScript, a focus change, Chrome itself, or your own click raised "${sid2}". Not me!`;+          const _nmMs = captionMsFor(_nmMsg);+          pupCaption(_nmMsg, _nmMs, 'pup-nofault', _nmMs).catch(() => {});+          break;+        } catch (e) {}+      }+    } catch (e) {}+  }), 2500);++  // v1.9.243 (John: "you have red led's galore in your dashboard. why?"). The overlay ENFORCER was+  // dead on arrival: it sat inside the health interval ABOVE, whose first line is `if (minimalTouch())+  // return;` — and minimal-touch is the normal mode, so it never ran once. Overlays are explicitly+  // ALLOWED in minimal-touch (v1.9.191, gentle branding), so enforcement gets its OWN interval with no+  // minimal-touch gate. 10s cadence: a window is never knowingly bare for more than ~10s.+setInterval(trackedLoop('overlay-enforcer', 4000, async () => {+    if (process.platform !== 'win32') return;+    // v2.0.67: floaty-click harvest runs BEFORE the identity gate (the pencil must work even when+    // identity stamping is unsupported) and heartbeats so a dead sweep is visible in the log.+    global.__floatyHb = (global.__floatyHb || 0) + 1;+    if (global.__floatyHb % 15 === 1) console.log(`[floaty] sweep heartbeat #${global.__floatyHb} (${sessions.size} session(s), identitySupported=${_identitySupported})`);+    // v2.0.230 (John: "find code that loops way too much and chews up wasteful cpu"). This sweep used+    // to make THREE CDP round-trips per tab every 4s: harvest the pencil click, harvest a captured+    // login, and run a title-cleanup blob that was a measured NO-OP (both call sites pass an empty+    // owner, so the in-page code returns immediately). At ~10 tabs that was 7.5 renderer wakeups per+    // second, forever, on an idle machine. Now: ONE evaluate per tab returning both harvests, the+    // dead title sweep deleted, and tabs are skipped entirely when neither feature can fire.+    // v2.0.315: both harvests now arrive by BINDING (push) the instant they happen; this poll is only+    // the fallback for a page that somehow lacks the bindings, so it runs every 8th tick (~32s).+    global.__floatyPollTick = (global.__floatyPollTick || 0) + 1;+    if (global.__floatyPollTick % 8 === 0) try {+      const _capOn = pupSettings().captureLogins !== 'off';+      for (const [sidF, sF] of [...sessions]) {+        if (sF._lostBrowser || !(sF.tabs || []).length) continue;+        if (sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) continue;+        for (const tF of (sF.tabs || [])) {+          if (!tF || !tF.page) continue;+          let harvest = null;+          try {+            harvest = await Promise.race([+              tF.page.evaluate((wantCap) => {+                const out = {};+                const c = window.__pupFloatyClick; if (c) { window.__pupFloatyClick = null; out.click = c; }+                if (wantCap) { const l = window.__pupCapturedLogin; if (l) { window.__pupCapturedLogin = null; out.login = l; } }+                return (out.click || out.login) ? out : null;+              }, _capOn),+              new Promise(r => setTimeout(() => r(null), 2500)),+            ]);+          } catch (e) {}+          if (!harvest) continue;+          if (harvest.login && harvest.login.host && harvest.login.p) {+            const cap = harvest.login;+            try {+              await credentialVault.setCredential({ host: cap.host, username: cap.u || '', password: cap.p });+              console.log(`[cred] captured login for ${cap.host}${cap.u ? ` (${cap.u})` : ''} on "${sidF}" → keychain vault`);+              sF._pendingCredHint = `pup CAPTURED this login for "${cap.host}"${cap.u ? ` (username ${cap.u})` : ''} into the OS keychain (encrypted at rest; never logged, never echoed). Next visit auto-fills and signs in with no Chrome dropdown and no foregrounding. This is how the shared adom-you identity grows — every login you do makes future ones automatic. Manage: pup_credentials {} to list, credential_delete {host} to remove, pup_configure {captureLogins:"off"} to stop capturing.`;+            } catch (e) { console.log(`[cred] capture store failed for ${cap.host}: ${e.message}`); }+          }+          if (harvest.click) {+            sF._annotateArmed = Date.now();+            console.log(`[floaty] "${sidF}" pencil clicked — arming capture watcher on ${tF.tabId}`);+            startAnnotation(sF, sidF, 'user-floaty', { tool: 'pen', tabId: tF.tabId }).catch(() => {});+            break;+          }+        }+      }+    } catch (e) {}+    if (_identitySupported !== true) return;+    try {+      for (const [sid2, s2] of [...sessions]) {+        if (s2._lostBrowser || !(s2.tabs || []).length) continue;+        // v1.9.329: re-stamp an unstamped base tile if needed, but DO NOT `continue` — the overlay+        // paint below is independent and MUST still run this cycle. The old `continue` meant an+        // unstamped window never got its overlay either, so it stayed a bare (now notched → hole)+        // button until it happened to stamp. Both are attempted every sweep now.+        if (pupSettings().aumidIcons === 'on' && !s2._curAppId && Date.now() - (s2._stampTriedAt || 0) > 20000) {+          s2._stampTriedAt = Date.now(); stampPupIdentity(sid2, { once: true }).catch(() => {});+        }+        if (pupSettings().overlayBadges !== 'on') continue;+        // v2.0.47: GROUND-TRUTH RE-ASSERT. Overlay state is write-only (Windows can't be asked+        // whether the badge is still there), and shell-side events beyond the progress bar can+        // silently clear it. Every 60s per window, void the paint memory so the block below+        // repaints unconditionally — one cheap ab call per window per minute buys "the badge you+        // see is the badge we believe", instead of memory drifting from the screen forever.+        // v2.0.230: was every 60s per window — one ab/PowerShell taskbar round-trip per window per+        // minute, forever, purely as a safety net against shell-side clears. Stretched to 5 minutes:+        // the real clears (button rebuild, progress bar, TaskbarCreated) all trigger a repaint by+        // EVENT already, so this only has to catch the exotic case, and it can be lazy about it.+        if (s2._appOverlay && Date.now() - (s2._overlayReassertAt || 0) > 300000) {+          s2._overlayReassertAt = Date.now();+          s2._lastPaintSig = null; s2._appOverlay = false;+          s2._overlayReassertQuiet = true;   // v2.0.48: periodic re-assert, not a real bare-heal — log quietly+        }+        const bare = !s2._appOverlay;+        // A window whose page genuinely has NO favicon anywhere sits on the generic glyph forever, and+        // the enforcer would re-fetch + re-compose it every 10s for the life of the window (John saw the+        // churn in AD's log). Once we've re-attempted the upgrade a few times and the page STILL has no+        // favicon, accept the generic glyph as final and stop re-enforcing — until the tab NAVIGATES,+        // which clears the flag (framenavigated) so a new URL gets fresh upgrade attempts.+        const stuckGeneric = s2._appOverlay && s2._overlayIsGeneric && !s2._overlayGenericFinal;+        if (!bare && !stuckGeneric) continue;+        const last = s2._overlayEnforceAt || 0;+        // v1.9.329: a bare window is now a VISIBLE hole (the notch), so recover it fast — 4s, not 20s.+        // v1.9.337: a GENERIC overlay (favicon fetch missed, e.g. right after an icon-gen re-stamp+        // recreated the button) used to wait 90s before the enforcer re-fetched the real favicon —+        // long enough that a re-stamp left every window wearing the ugly generic globe for over a+        // minute (John: "what the fuck happened to the icons?"). Upgrade generic → real fast now.+        const cooldown = bare ? 4000 : 10000;+        if (Date.now() - last < cooldown) continue;+        s2._overlayEnforceAt = Date.now();+        const wasGeneric = stuckGeneric;+        applyAppOverlay(s2, sid2).then(okp => {+          if (!okp) return;+          if (wasGeneric && !s2._overlayIsGeneric) { s2._overlayGenericTries = 0; console.log(`[overlay] "${sid2}" UPGRADED generic → real favicon (enforcer re-fetched it)`); }+          else if (wasGeneric && s2._overlayIsGeneric) {+            s2._overlayGenericTries = (s2._overlayGenericTries || 0) + 1;+            if (s2._overlayGenericTries >= 3) { s2._overlayGenericFinal = true; console.log(`[overlay] "${sid2}" still has no favicon after 3 upgrade attempts — accepting generic glyph as final; will retry on next navigation`); }+          }+          else if (bare) {+            // v2.0.48 (John saw "ENFORCED (was bare)" spam every minute): the 60s ground-truth+            // re-assert is EXPECTED repainting, not a heal — don't announce it as one. Log a real+            // bare-heal loudly, and the periodic re-assert only once per ~10 min per window.+            if (s2._overlayReassertQuiet) {+              s2._overlayReassertQuiet = false;+              if (!s2._reassertLogAt || Date.now() - s2._reassertLogAt > 600000) { s2._reassertLogAt = Date.now(); console.log(`[overlay] "${sid2}" periodic ground-truth re-assert (5 min cycle; logged at most every 10 min)`); }+            } else console.log(`[overlay] "${sid2}" ENFORCED (was bare) — every pup window must wear its overlay`);+          }+        }).catch(() => {});+      }+    } catch (e) {}+  }), 4000);   // v1.9.329: 4s sweep (was 10s) — a bare window is now a visible notch-hole; heal it fast++  let _healthTick = 0;+  setInterval(trackedLoop('health+user-activity', 15000, async () => {+    _healthTick++;+    if (_healthTick % 2 === 0) { try { await healthLegacyTick(); } catch (e) {} }   // the old 30s loop's duties+    if (process.platform !== 'win32') return;+    // v1.9.185 minimal-touch: NEVER kill or relaunch the user's Chrome behind their back. John:+    // "you're just slapping monitoring on top of it so you can fucking restart it on me which is+    // infuriating." In minimal mode pup reports problems and lets the user decide.+    if (minimalTouch()) return;+    // (2) HUNG detection — probe each LIVE browser entry; on 2 misses, tear it down so its sessions+    // become `_lostBrowser` and fall into the relaunch path below.+    for (const [profileName, entry] of [...browsers]) {+      let alive = false;+      try {+        await Promise.race([+          entry.browser.pages(),+          new Promise((_, rej) => setTimeout(() => rej(new Error('probe timeout')), 3500)),+        ]);+        alive = true;+      } catch (e) {}+      if (alive) { _healthStrikes.delete(profileName); continue; }+      const strikes = (_healthStrikes.get(profileName) || 0) + 1;+      _healthStrikes.set(profileName, strikes);+      if (strikes < 2) { console.log(`[health] "${profileName}" failed a liveness probe (strike ${strikes}/2) — watching`); continue; }+      _healthStrikes.delete(profileName);+      console.log(`[health] "${profileName}" HUNG (2 strikes) — discarding the dead handle so its windows can relaunch`);+      try { await entry.browser.close(); } catch (e) {}+      browsers.delete(profileName);+      try { await killOrphanChromesForProfile(profileName); } catch (e) {}+      for (const [, sess] of sessions) { if (sess.profileName === profileName) sess._lostBrowser = true; }+    }+    // (3) USER-ACTIVITY WATCHER + STUCK-OCCLUSION check (v1.9.180/181). One OS foreground read per+    // tick. When the foreground IS a pup window: (a) stamp it user-active + foreground (PERSISTED),+    // so no park/recovery/adoption path ever demotes a window the user chose to bring up — the+    // missing primitive behind "pup keeps yanking my windows" (PLACEMENT-INVARIANT item 1, John's+    // design, finally implemented); (b) run the stuck-occlusion unstick on that ONE window only.+    try {+      // v1.9.185 minimal-touch: no foreground polling, no unstick, no autonomous clicks.+      if (minimalTouch()) throw new Error('minimal-touch');+      if (sessions.size > 0) {+        const fg = await osGetForegroundWindow();+        if (fg) {+          for (const [sid, sess] of [...sessions]) {+            try {+              const hwnd = await cachedSessionHwnd(sid);+              if (!hwnd || String(fg) !== String(hwnd)) continue;+              // v2.0.261: do NOT credit the user for a window that was just born. Chrome+              // self-activates a newly created on-screen window, and this observer running inside+              // that moment would stamp _userActiveAt/_foreground — permanently marking an+              // unasked-for window as "the user's", so nothing would ever move it off their work.+              if (inBirthGrace(sess)) continue;+              sess._userActiveAt = Date.now();+              if (!sess._foreground) {+                sess._foreground = true;+                console.log(`[useractive] "${sid}" observed as the USER'S foreground window — marking foreground (persisted); pup will not re-park it`);+                try { let u = null; try { u = sess.page && sess.page.url(); } catch (e) {} saveSessionFile(sid, sess.profileName, u); } catch (e) {}+              }+              await unstickIfHiddenWhileFront(sid, sess, fg, hwnd);+              break;   // only one window can be foreground+            } catch (e) {}+          }+        }+      }+    } catch (e) {}+  }), 15000);+  // v1.9.166: taskbar-progress reconciler — clear any bar that is lit but shouldn't be.+  // v1.9.181: only for sessions whose bar is ACTUALLY lit (_progressSince set). The old loop fired an+  // AD desktop_taskbar call (a PowerShell round-trip on the desktop) for EVERY session EVERY 20s+  // forever — with 10 windows that was 30 pointless PS invocations a minute pounding the machine pup+  // is supposed to stay invisible on. Steady-state is now zero calls.+  setInterval(trackedLoop('progress-reconciler', 20000, async () => {+    if (process.platform !== 'win32' || taskbarActivityMode() !== 'progress') return;   // v2.0.292+    for (const [sid, sess] of [...sessions]) {+      const st = _busyState.get(sid);+      if ((!st || !st.busy) && sess && sess._progressSince) { try { await setSessionProgress(sid, false); } catch (e) {} }+    }+  }), 20000);+  // v1.9.145 (John found v21-review stranded at -32000 all day) — PLACEMENT SWEEP. The healer+  // (healOffscreenWindow) existed but only ran from a few verb paths, so a long-lived window that+  // predated the on-screen park kept its off-screen geometry FOREVER — made worse by the reparent:+  // windows now survive bridge restarts, so stale geometry survives too. Assert placement for the+  // whole standing population at boot and every 2 minutes; every repair is reported via the+  // cleanup queue so a rescue is never silent. Healthy windows are never touched (the healer+  // no-ops when bounds are on-screen).+  // v2.0.314 (audit): the per-session CDP placement sweep (120s, a CDP session + getWindowBounds per+  // window per tick) is DELETED — sweepVisibility below asserts the same off-screen invariant for+  // the whole population from the OS side, every 30s, in-process.++  // v2.0.251 (John: "why can't i open this pup window? its like you did that off-screen thing again+  // and now when i go to click on it as a human you fuck me over"). The placement sweep above asserts+  // GEOMETRY — it catches a window stranded off-screen, and it explicitly does NOT touch a healthy or+  // minimized one. But a window can be invisible without being mis-placed: pup's taskbar nudge HIDES+  // the window for 80ms to force the shell to rebuild its button, and any interruption in that gap+  // (the 20s execFile kill, a crash, a sleep) left it hidden with nothing anywhere in this codebase+  // watching for it. Reproduced live on AdomLapper: after the hide half alone, IsWindowVisible=false+  // and the taskbar button reveals nothing to a human click — exactly John's report.+  // So placement is no longer the only invariant: VISIBILITY is asserted too, for the whole standing+  // population. Safe by construction — a human can minimize a window but can never hide one, so a+  // hidden pup window is always pup's own mess to clean up (see ensureWindowShown).+  // Deliberately NOT minimalTouch-gated, unlike every other sweep here: minimal-touch exists to keep+  // pup's automatic machinery off the user's taskbar, and a window the user cannot open is not+  // "minimal touch", it is a broken window. Repairing it is the one automatic act that only ever+  // gives the user back something pup took.+  const sweepVisibility = async (cause) => {+    // Never race a nudge's own 80ms hide gap — un-hiding mid-nudge would silently defeat the button+    // rebuild. _nudgeUntil covers the in-flight window; a nudge that STRANDED a window has long since+    // passed it, so the repair still lands (at worst one sweep later).+    if (Date.now() < _nudgeUntil) return;+    // Assert the invariant over the POPULATION, by owning process — deliberately NOT per session.+    // Two earlier cuts of this sweep repaired nothing because they went looking for a session's+    // handle first: windowHwnd -> bestSessionHwnd falls back to enumerate-and-wiggle, which only ever+    // sees VISIBLE windows (circular — 28 straight "NOT resolved" misses on a hidden window sitting+    // right there), and the cached/birth handles are exactly what a window in trouble tends to lack+    // (a park-failed window never persisted one, and a pup restart clears the birth capture).+    // The property that actually matters needs no session mapping at all: NO titled top-level window+    // of a pup-owned Chrome process may be hidden. One enumeration repairs every stranded window,+    // including ones pup has lost track of, and costs one PowerShell per sweep instead of one per+    // session. Titled + WS_CAPTION only, so Chrome's own untitled helper windows are never touched.+    const pids = [...pupBrowserPidSet()].filter(Boolean);+    if (!pids.length) return;+    // v2.0.314: in-process (koffi) — microseconds, no spawn. The PowerShell below is the fallback.+    if (win32.available()) {+      const fixes = win32.fixHiddenOrOffscreen(pids) || [];+      for (const f of fixes) {+        const t = String(f.title || '').slice(0, 60);+        if (f.kind === 'MOVED-ONSCREEN') { console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`moved a pup window back ON-SCREEN ("${t}") — it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {} }+        else if (f.kind === 'MOVE-FAILED') console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is off-screen and would NOT move back — reporting rather than pretending it is fine`);+        else if (f.kind === 'RESHOWN') { console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${f.hwnd}, "${t}") during the ${cause} sweep`); try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${t}") — its taskbar button would have revealed nothing when clicked`); } catch (e) {} }+        else console.log(`[visibility] hwnd ${f.hwnd} ("${t}") is hidden and would NOT re-show — reporting rather than pretending it is fine`);+      }+      return;+    }+    const ps = `$s=@'+using System; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices;+public static class VS {+  public delegate bool EnumProc(IntPtr h, IntPtr l);+  [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc cb, IntPtr l);+  [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h);+  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int cmd);+  [DllImport("user32.dll")] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n);+  [DllImport("user32.dll")] public static extern int GetWindowLong(IntPtr h, int i);+  [DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr h, out int pid);+  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);+  [DllImport("user32.dll")] public static extern int GetSystemMetrics(int i);+  [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr a, int x, int y, int cx, int cy, uint f);+  [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r);+  [DllImport("user32.dll")] public static extern bool GetWindowPlacement(IntPtr h, ref WINDOWPLACEMENT p);+  [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X, Y; }+  [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; }+  [StructLayout(LayoutKind.Sequential)] public struct WINDOWPLACEMENT {+    public int length; public int flags; public int showCmd;+    public POINT ptMinPosition; public POINT ptMaxPosition; public RECT rcNormalPosition; }+  public static List<string> Fix(int[] pids) {+    var outp = new List<string>();+    EnumWindows((h, l) => {+      int pid; GetWindowThreadProcessId(h, out pid);+      if (Array.IndexOf(pids, pid) < 0) return true;+      if ((GetWindowLong(h, -16) & 0x00C00000) == 0) return true;   // WS_CAPTION — real windows only+      var sb = new StringBuilder(300); GetWindowText(h, sb, 300);+      if (sb.Length == 0) return true;+      if (!IsWindowVisible(h)) {+        ShowWindow(h, 4);                                            // SW_SHOWNOACTIVATE+        outp.Add((IsWindowVisible(h) ? "RESHOWN|" : "RESHOW-FAILED|") + h + "|" + sb.ToString());+        return true;+      }+      // v2.0.258: HIDDEN was only half of "the user cannot open this window". OFF-SCREEN is the+      // other half, and it is the one that stranded John's "ab ralph" window at -21845,-21845 behind+      // a live taskbar button. Same invariant, same repair: a minimized window reports the -32000+      // placeholder rect legitimately, so judge position from the RESTORE rect, never GetWindowRect.+      if (IsIconic(h)) return true;                                  // minimized is the user's choice+      WINDOWPLACEMENT wp = new WINDOWPLACEMENT();+      wp.length = Marshal.SizeOf(wp);+      if (!GetWindowPlacement(h, ref wp)) return true;+      int L = wp.rcNormalPosition.L, T = wp.rcNormalPosition.T;+      int W = wp.rcNormalPosition.R - L, H = wp.rcNormalPosition.B - T;+      int vx = GetSystemMetrics(76), vy = GetSystemMetrics(77), vw = GetSystemMetrics(78), vh = GetSystemMetrics(79);+      // Off-screen = the window does not overlap the virtual desktop at all.+      if (L < vx + vw && L + W > vx && T < vy + vh && T + H > vy) return true;+      int nw = W > 200 && W <= vw ? W : (vw - 20), nh = H > 200 && H <= vh ? H : (vh - 20);+      SetWindowPos(h, IntPtr.Zero, vx + 10, vy + 10, nw, nh, 0x14);  // NOACTIVATE|NOZORDER+      RECT chk; GetWindowRect(h, out chk);+      bool fixedNow = chk.L < vx + vw && chk.R > vx && chk.T < vy + vh && chk.B > vy;+      outp.Add((fixedNow ? "MOVED-ONSCREEN|" : "MOVE-FAILED|") + h + "|" + sb.ToString());+      return true;+    }, IntPtr.Zero);+    return outp;+  }+}+'@; Add-Type -TypeDefinition $s; [VS]::Fix(@(${pids.join(',')})) | ForEach-Object { $_ }`;+    await new Promise((resolve) => {+      try {+        execFile('powershell', ['-NoProfile', '-Command', ps], { windowsHide: true, timeout: 25000 }, (err, stdout) => {+          for (const line of String(stdout || '').trim().split(/\r?\n/)) {+            const m = /^(RESHOWN|RESHOW-FAILED|MOVED-ONSCREEN|MOVE-FAILED)\|(\d+)\|(.*)$/.exec(line.trim());+            if (!m) continue;+            if (m[1] === 'MOVED-ONSCREEN') {+              console.log(`[visibility] moved an OFF-SCREEN pup window back on screen (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} sweep — its taskbar button was live while the window sat where no click could reach it`);+              try { recordCleanup(`moved a pup window back ON-SCREEN ("${m[3].slice(0, 60)}") — it was parked off-screen, so its taskbar button revealed nothing when clicked`); } catch (e) {}+            } else if (m[1] === 'MOVE-FAILED') {+              console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is off-screen and would NOT move back — reporting rather than pretending it is fine`);+            } else if (m[1] === 'RESHOWN') {+              console.log(`[visibility] re-showed a HIDDEN pup window (hwnd ${m[2]}, "${m[3].slice(0, 60)}") during the ${cause} visibility sweep — a hidden window's taskbar button reveals nothing to a human click`);+              try { recordCleanup(`re-showed a pup window that had been left HIDDEN ("${m[3].slice(0, 60)}") — its taskbar button would have revealed nothing when clicked`); } catch (e) {}+            } else {+              console.log(`[visibility] hwnd ${m[2]} ("${m[3].slice(0, 60)}") is hidden and would NOT re-show — reporting rather than pretending it is fine`);+            }+          }+          resolve();+        });+      } catch (e) { resolve(); }+    });+  };+  // v2.0.258: every 30s, not 120s. This sweep is the last line of defence for "the user clicked the+  // taskbar button and nothing came up", and John hit that twice in one day — two minutes of an+  // unopenable window is not a safety net, it is the bug with a longer fuse. One PowerShell per+  // 30s across the whole population is cheap next to that.+  setTimeout(() => { sweepVisibility('startup').catch(() => {}); }, 8000);+  setInterval(trackedLoop('visibility-sweep', 30000, () => sweepVisibility('periodic').catch(() => {})), 30000);++  // v1.9.89: reap ORPHANED pup chrome/renderers — any process under a pup-profiles dir whose profile+  // has no live session (a previous bridge crash left them, or renderers that outlived a close and are+  // pegging a core). Boot sweep runs AFTER recovery has settled so it never kills a window recovery+  // just reconnected; then a slow periodic net. Spares every live session BY PROFILE, so it can only+  // ever hit true orphans — never a session's own renderer children.+  setTimeout(() => { reapOrphanPupChromes().catch(() => {}); }, 15000);+  // v2.0.230: skip the PowerShell process enumeration entirely when there is nothing that could+  // have leaked (no live sessions AND no session files on disk) — it was ~20 PowerShell spawns an+  // hour on a totally idle machine.+  setInterval(trackedLoop('reap-orphan-chromes', 180000, () => {+    if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }+    reapOrphanPupChromes().catch(() => {});+  }), 180000);++  // v2.0.129: reap LEAKED isolated profile DIRECTORIES (iso-* throwaways whose window is long gone).+  // Boot pass waits 60s so session recovery has fully settled first (never delete a profile an iso+  // session is mid-recovery on); then a slow 15-min net drains the backlog a few at a time.+  setTimeout(() => { reapLeakedIsolatedProfiles().catch(() => {}); }, 60000);+  setInterval(trackedLoop('reap-leaked-profiles', 900000, () => {+    if (!sessions.size) { try { if (!listSessionFiles().length) return; } catch (e) {} }+    reapLeakedIsolatedProfiles().catch(() => {});+  }), 15 * 60 * 1000);++  // (audit 2.0.314) the 5-minute stale-session-file cleanup was a byte-for-byte duplicate of the+  // tail of the 30s health loop; deleted.+};++// v2.0.16 (#557 ask 3, pup-side defense): EADDRINUSE SELF-HEAL. The #557 permanent outage was ab's+// reaper losing track of a live bridge process (bridge_kill returned killedPids:[]): the orphan kept+// port 64230, so every replacement ab spawned died on the bind conflict, forever, and recovery took a+// manual taskkill. ab's real fix (reap by port owner / child tree) is filed against adom/adom-bridge;+// until it lands, the REPLACEMENT heals it: on EADDRINUSE, probe the holder's /health — if (and ONLY+// if) it identifies as a pup puppeteer bridge, it is by definition an orphan (ab only spawns a+// replacement when the holder is off its books), so kill that ONE pid and retry the bind. A holder+// that is NOT a pup bridge is never touched: log and exit. Convergence: if ab tracks us after the+// heal, routing resumes; if ab already gave up on us, the NEXT spawn heals against us and gets+// tracked — one extra cycle, never a loop (3-retry cap either way).+let _bindRetries = 0;+let _eaccesTries = 0;+server.on('error', (err) => {+  const code = err && err.code;+  // v2.0.231 (John's machine, 2026-08-18): EACCES on a loopback port is almost never a permission+  // problem — on Windows it means the port sits inside a RESERVED range (Hyper-V/WinNAT grabs+  // blocks like 64160-64259 at boot). ab assigns the port, so pup cannot fix routing by itself, and+  // exiting produced an invisible crash-loop: the bridge died on every spawn until the machine was+  // rebooted, with nothing but "fatal listen error (EACCES)" to go on. Now pup NAMES the cause,+  // prints the exact remedy, and binds a fallback port anyway so its own HTTP surface (dashboard,+  // /health) is reachable and the port file tells the truth about where it landed.+  // v2.0.234 (John's AdomLapper, 2026-08-18): EACCES on a loopback port means the port sits inside a+  // Windows RESERVED range — Hyper-V/WinNAT grabs blocks (here 64160-64259, which swallowed ab's+  // assigned 64230) at every boot. Exiting made this a silent permanent outage: ab respawned on the+  // same port, hit the same wall, and after 3 tries went into backoff, so pup stayed dead until+  // someone ran an elevated `net stop winnat`. Now pup STAYS ALIVE and re-attempts the SAME port on+  // a backoff, so the instant the range is released (winnat restart, Docker/WSL shutdown, reboot)+  // it binds and is healthy on the port ab is already routing to — no respawn, no human, no gap.+  if (code === 'EACCES' && process.platform === 'win32') {+    _eaccesTries++;+    if (_eaccesTries === 1) {+      let ranges = '';+      try { ranges = execFileSync('netsh', ['int', 'ipv4', 'show', 'excludedportrange', 'protocol=tcp'], { timeout: 8000, windowsHide: true }).toString(); } catch (e) {}+      let inRange = null;+      for (const m of ranges.matchAll(/^\s*(\d+)\s+(\d+)/gm)) {+        const a = Number(m[1]), b = Number(m[2]);+        if (PORT >= a && PORT <= b) { inRange = `${a}-${b}`; break; }+      }+      console.error(`[bind] EACCES on 127.0.0.1:${PORT}${inRange ? ` — that port is inside the Windows RESERVED range ${inRange}` : ''}.`);+      console.error('[bind] Cause: Windows (Hyper-V/WinNAT) reserves port blocks at boot. Not a pup bug, not a permissions problem, and NOT fixable from this process.');+      console.error('[bind] Release it (elevated): net stop winnat && net start winnat   — or reboot. Filed as adom/adom-bridge#83 so ab stops assigning ports inside these ranges.');+      console.error(`[bind] pup will STAY UP and retry ${PORT} every 15s; it binds automatically the moment the range is released.`);+    } else if (_eaccesTries % 20 === 0) {+      console.error(`[bind] still cannot bind ${PORT} (Windows reserved range) — ${_eaccesTries} attempts; retrying every 15s.`);+    }+    setTimeout(() => {+      try {+        server.listen(PORT, BIND_HOST, () => {+          console.error(`[bind] RECOVERED — the Windows reservation on ${PORT} was released after ${_eaccesTries} attempt(s); pup is live on its assigned port with no respawn needed.`);+          _eaccesTries = 0;+          try { _onPupBound(); } catch (e) {}+        });+      } catch (e) {}+    }, 15000);+    return;+  }+  if (code !== 'EADDRINUSE' || process.platform !== 'win32' || _bindRetries >= 3) {+    console.error(`[bind] fatal listen error (${code || (err && err.message)}) — exiting so the supervisor respawns cleanly`);+    process.exit(1);+  }+  _bindRetries++;+  (async () => {+    try {+      const health = await Promise.race([+        fetch(`http://127.0.0.1:${PORT}/health`).then(r => r.json()).catch(() => null),+        new Promise(r => setTimeout(() => r(null), 2500)),+      ]);+      if (!health || health.bridge !== 'puppeteer') {+        console.error(`[bind] port ${PORT} is held by a NON-pup process — refusing to touch it; exiting`);+        process.exit(1);+      }+      const { execFileSync } = require('child_process');+      const pidOut = execFileSync('powershell.exe', ['-NoProfile', '-Command',+        `Get-NetTCPConnection -LocalPort ${PORT} -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 -ExpandProperty OwningProcess`],+        { encoding: 'utf8', timeout: 8000, windowsHide: true }).trim();+      const holderPid = Number(pidOut) || 0;+      if (!holderPid || holderPid === process.pid) {+        console.error(`[bind] EADDRINUSE but no resolvable holder pid (got "${pidOut}") — retrying bind blind`);+      } else {+        console.error(`[bind] port ${PORT} held by ORPHANED sibling pup bridge v${health.version || '?'} (pid ${holderPid}) — ab spawned this process as its replacement, so the holder is off ab's books. Killing that ONE pid and retrying (attempt ${_bindRetries}/3).`);+        execFileSync('taskkill', ['/PID', String(holderPid), '/F'], { timeout: 8000, windowsHide: true });+      }+      await new Promise(r => setTimeout(r, 1200));+      server.listen(PORT, BIND_HOST, _onPupBound);+    } catch (e) {+      console.error(`[bind] self-heal attempt ${_bindRetries} failed: ${e.message} — exiting for a clean respawn`);+      process.exit(1);+    }+  })();+});++server.listen(PORT, BIND_HOST, _onPupBound);++// ════════════════════════════════════════════════════════════════════════════+//  RECORDING — desktop (HUD) and tab (CDP screencast)+// ════════════════════════════════════════════════════════════════════════════++const RECORDINGS_DIR = path.join(__dirname, 'recordings');+fs.mkdirSync(RECORDINGS_DIR, { recursive: true });+const RECORDER_PROFILE_DIR = path.join(PROFILES_DIR, '_recorder');++// ─── ffmpeg detection (used to add Duration tag to MediaRecorder WebMs) ──+// Probe at module-load so we know upfront whether the remux path works.+// Cached for the life of the process. Try common Windows install dirs in+// addition to PATH so a winget/scoop/choco install is found even if PATH+// hasn't been refreshed in the bridge's environment.+let FFMPEG_PATH = null;+// v1.8.20: re-callable (was a boot-only IIFE). Recording verbs call it LAZILY when+// FFMPEG_PATH is still null, so a user who installs ffmpeg AFTER the bridge started can+// record immediately — no bridge restart needed. Re-scans cleanly each call.+const SCREEN_RECORDER_HINT = 'This is pup\'s CHROME screen-share recorder (getDisplayMedia -> WebM VP9, consent = the visible recorder HUD, system audio + mic mixing, cross-platform). Canonical names: pup_screen_record_start/stop/status/list + pup_recorder_open/close (pup-bridge#94). ab-core serves desktop_record_* NATIVELY since ab 2.1.36 (WGC + system audio to MP4, no browser, no picker): use that for a whole-monitor or single-window recording; use this Chrome recorder when you need the OS share picker, tab audio, WebM, or a non-Windows box.';+function detectFFmpeg() {+  FFMPEG_PATH = null;+  const { spawnSync } = require('child_process');+  // 1. PATH+  try {+    const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+    if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }+  } catch {}+  // 2. Common macOS install paths (Homebrew arm64/x64, MacPorts) — AD spawns the bridge+  // outside a login shell, so /opt/homebrew/bin is usually NOT on PATH here.+  if (process.platform === 'darwin') {+    for (const p of ['/opt/homebrew/bin/ffmpeg', '/usr/local/bin/ffmpeg', '/opt/local/bin/ffmpeg']) {+      try {+        if (fs.existsSync(p)) {+          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+          if (r.status === 0) { FFMPEG_PATH = p; return; }+        }+      } catch {}+    }+  }+  // 3. Common Windows install paths+  if (process.platform === 'win32') {+    const candidates = [+      'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',+      'C:\\ffmpeg\\bin\\ffmpeg.exe',+      'C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe',+      `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe`,+      `${process.env.USERPROFILE}\\scoop\\shims\\ffmpeg.exe`,+    ];+    for (const p of candidates) {+      try {+        if (fs.existsSync(p)) {+          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+          if (r.status === 0) { FFMPEG_PATH = p; return; }+        }+      } catch {}+    }+    // 3. Glob the WinGet directory (version number changes between releases)+    try {+      const winget = `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages`;+      if (fs.existsSync(winget)) {+        for (const e of fs.readdirSync(winget)) {+          if (e.startsWith('Gyan.FFmpeg')) {+            const sub = path.join(winget, e);+            const walk = (dir) => {+              for (const f of fs.readdirSync(dir, { withFileTypes: true })) {+                if (f.isDirectory()) {+                  const r = walk(path.join(dir, f.name));+                  if (r) return r;+                } else if (f.name === 'ffmpeg.exe') {+                  return path.join(dir, f.name);+                }+              }+              return null;+            };+            const found = walk(sub);+            if (found) { FFMPEG_PATH = found; return; }+          }+        }+      }+    } catch {}+  }+  return FFMPEG_PATH;+}+detectFFmpeg();+if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);+else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via '${process.platform === 'darwin' ? 'brew install ffmpeg' : 'winget install Gyan.FFmpeg'}' to enable Duration metadata.`);++/**+ * Remux a WebM file in place to add a SegmentDuration tag.+ * Uses `ffmpeg -i in.webm -c copy out.webm` — copies the stream packets+ * unchanged (no re-encode), and the matroska muxer writes a complete+ * SegmentInfo with Duration. Fast (~100-300ms for typical clips).+ * Atomic: writes to a temp file then renames so a partial run can't+ * corrupt the original.+ */+async function remuxWebmAddDuration(filePath) {+  if (!FFMPEG_PATH) detectFFmpeg();   // lazy re-scan: ffmpeg may have been installed since boot+  if (!FFMPEG_PATH) throw new Error('ffmpeg not found on PATH or in common Windows install dirs');+  const { spawn } = require('child_process');+  const tmpPath = filePath + '.remux.tmp';+  await new Promise((resolve, reject) => {+    const proc = spawn(FFMPEG_PATH, [+      '-y',                       // overwrite tmp if it exists+      '-loglevel', 'error',       // quiet but still surface errors+      '-i', filePath,+      '-c', 'copy',               // no re-encode — just rewrite container+      '-f', 'webm',+      tmpPath,+    ], { stdio: ['ignore', 'ignore', 'pipe'] });+    let stderr = '';+    proc.stderr.on('data', b => { stderr += b.toString(); });+    proc.on('error', reject);+    proc.on('exit', code => {+      if (code === 0) resolve();+      else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));+    });+  });+  // Atomic replace: rename tmp → original. fs.renameSync replaces on Win10+.+  fs.renameSync(tmpPath, filePath);+}++// Desktop recorder (singleton; one HUD across the whole bridge)+const desktopRecorder = {+  browser: null,+  page: null,+  windowId: null,           // Chrome windowId for Browser.setWindowBounds+  cdp: null,                // CDP session to recorder page+  reason: null,+  hudOpenedAt: null,+  current: null,            // active recording state, see below+  clipsThisSession: [],     // [{recordingId, filePath, sizeKB, durationMs, reason}]+  recCounter: 0,+  idleTimer: null,+};++// Tab recordings (concurrent; many at once)+// Map<recordingId, {sessionId, tabId, dirPath, framesDir, concatStream, manifestFrames,+//                  cdp, started_at, frameCount, fps, quality, stopTimer, stopping}>+const tabRecordings = new Map();+let tabRecCounter = 0;++// ─── Desktop recorder helpers ───────────────────────────────────────────────++function recorderHtmlUrl(reason) {+  const file = path.join(__dirname, 'recorder.html').replace(/\\/g, '/');+  const params = new URLSearchParams({ reason: reason || '' }).toString();+  return `file:///${file}?${params}`;+}++async function ensureRecorderWindowOpen(reason) {+  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {+    throw new Error('reason is required — explain to the user why you\'re recording');+  }+  // If browser+page exist and are alive, just update the reason+  if (desktopRecorder.browser && desktopRecorder.browser.isConnected() && desktopRecorder.page) {+    try {+      await desktopRecorder.page.evaluate((r) => window.setSessionReason && window.setSessionReason(r), reason);+      desktopRecorder.reason = reason;+      return;+    } catch {+      // Page died; tear down and respawn+      try { await desktopRecorder.browser.close(); } catch {}+      desktopRecorder.browser = null;+      desktopRecorder.page = null;+      desktopRecorder.cdp = null;+    }+  }++  fs.mkdirSync(RECORDER_PROFILE_DIR, { recursive: true });++  // Single fixed size for the HUD — large enough that every state's content+  // fits without dynamic resizing. Position is set AFTER launch via CDP using+  // screen.availWidth/availHeight (taskbar-aware, DPI-aware) — Chrome's+  // --window-position arg uses physical pixels and doesn't account for the+  // taskbar, which made the HUD half off-screen on non-1920×1080 displays.+  const winW = 360, winH = 420;++  console.log(`[recorder] Spawning HUD window (reason="${reason}")`);++  const launchArgs = [+    `--app=${recorderHtmlUrl(reason)}`,+    `--window-size=${winW},${winH}`,+    // No --window-position; we anchor bottom-right after the page loads+    '--use-fake-ui-for-media-stream',+    '--auto-select-desktop-capture-source=Entire screen',+    '--enable-features=GetDisplayMediaSet',+    '--auto-accept-this-tab-capture',+    '--no-sandbox',+    '--no-first-run',+    '--no-default-browser-check',+    // v2.0.293: MediaRouter's mDNS binds 0.0.0.0:5353 — a non-loopback listener that pops the+    // Windows Firewall dialog (per-exe). pup's hard rule is ZERO firewall prompts, so the+    // recorder Chrome disables it exactly like the main launch path (one flag, comma-merged —+    // never add a second --disable-features here, Chrome only honors the last).+    '--disable-features=Translate,MediaRouter,DialMediaRouteProvider,CastMediaRouteProvider',+    '--media-router=0',+    `--user-data-dir=${RECORDER_PROFILE_DIR}`,+  ];++  desktopRecorder.browser = await puppeteer.launch({+    headless: false,+    defaultViewport: null,+    ignoreDefaultArgs: ['--enable-automation'],+    args: launchArgs,+  });++  // Find the recorder page (the only page in this Chrome)+  const deadline = Date.now() + 5000;+  while (Date.now() < deadline) {+    const pages = await desktopRecorder.browser.pages();+    if (pages.length > 0) {+      desktopRecorder.page = pages[0];+      break;+    }+    await new Promise(r => setTimeout(r, 50));+  }+  if (!desktopRecorder.page) throw new Error('Recorder window did not produce a page');++  await desktopRecorder.page.waitForFunction(() => typeof window.startRecording === 'function', { timeout: 10000 });++  // ── Bug 1 fix: anchor bottom-right via screen.availWidth/availHeight ──+  // Chrome's --window-position uses physical pixels and ignores the taskbar,+  // so a hardcoded fallback can land the HUD off-screen on any monitor that+  // isn't 1920×1080. Query the page's `screen.avail*` (DIPs, taskbar-aware)+  // and use CDP Browser.setWindowBounds to position it precisely.+  try {+    const cdp = await desktopRecorder.page.target().createCDPSession();+    desktopRecorder.cdp = cdp;+    const wInfo = await cdp.send('Browser.getWindowForTarget', {});+    desktopRecorder.windowId = wInfo.windowId;++    const dims = await desktopRecorder.page.evaluate(() => ({+      availLeft: screen.availLeft || 0,+      availTop: screen.availTop || 0,+      availWidth: screen.availWidth,+      availHeight: screen.availHeight,+      outerWidth: window.outerWidth,+      outerHeight: window.outerHeight,+    }));+    const margin = 16;+    const winW = Math.min(dims.outerWidth, dims.availWidth);+    const winH = Math.min(dims.outerHeight, dims.availHeight);+    const left = Math.max(dims.availLeft, dims.availLeft + dims.availWidth - winW - margin);+    const top = Math.max(dims.availTop, dims.availTop + dims.availHeight - winH - margin);+    await cdp.send('Browser.setWindowBounds', {+      windowId: wInfo.windowId,+      bounds: { left, top, width: winW, height: winH, windowState: 'normal' },+    });+    console.log(`[recorder] anchored bottom-right: (${left},${top}) ${winW}x${winH} (work area ${dims.availWidth}x${dims.availHeight})`);+  } catch (e) {+    console.log(`[recorder] anchor failed (non-fatal): ${e.message}`);+  }++  // Wire exposed functions+  await desktopRecorder.page.exposeFunction('_saveChunk', (b64) => {+    if (!desktopRecorder.current || !desktopRecorder.current.fd) return;+    try {+      fs.writeSync(desktopRecorder.current.fd, Buffer.from(b64, 'base64'));+    } catch (e) {+      console.error(`[recorder] saveChunk failed: ${e.message}`);+    }+  });++  await desktopRecorder.page.exposeFunction('_recordingStopped', async ({ sentBytes, durationMs }) => {+    const cur = desktopRecorder.current;+    if (!cur) return;+    try { fs.closeSync(cur.fd); } catch {}+    cur.fd = null;++    // ── Bug 3a fix: ffmpeg remux to write SegmentDuration ──+    // MediaRecorder writes a "live" WebM with no Duration element, so+    // ffprobe reports duration=N/A. Earlier attempts at byte-level EBML+    // patching corrupted the Tracks element / SeekHead offsets and made+    // the resulting files unplayable (nb_streams=0). Ditched.+    //+    // The reliable fix is `ffmpeg -i in.webm -c copy out.webm` — copies+    // streams (no re-encode, fast: ~50-200 MB/s), and the muxer writes+    // a proper SegmentInfo with Duration. We do this asynchronously so+    // the UI's "Saved" state appears immediately; the patched file is+    // ready by the time the user pulls it.+    //+    // If ffmpeg isn't on PATH we leave the file untouched. It's still a+    // valid (playable) WebM — just no Duration tag in the container.+    // Telemetry: log clearly so the user knows whether it ran.+    try {+      await remuxWebmAddDuration(cur.filePath);+      console.log(`[recorder] ffmpeg remux added Duration tag to ${cur.filePath}`);+    } catch (e) {+      console.error(`[recorder] ffmpeg remux failed (file still plays, ffprobe will report N/A): ${e.message}`);+    }++    let sizeKB = 0;+    try { sizeKB = Math.round(fs.statSync(cur.filePath).size / 1024); } catch {}+    cur.durationMs = durationMs;+    cur.sizeKB = sizeKB;+    cur.stoppedAt = Date.now();+    // Write sidecar metadata+    try {+      const sidecar = {+        recordingId: cur.recordingId,+        filePath: cur.filePath,+        startedAt: cur.startedAt,+        stoppedAt: cur.stoppedAt,+        durationMs,+        sizeKB,+        sizeBytes: sentBytes,+        fps: cur.fps,+        audio: cur.audio,+        monitor: cur.monitor,+        reason: cur.reason,+        stopReason: cur.stopReason || 'requested',+      };+      fs.writeFileSync(cur.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));+    } catch (e) {+      console.error(`[recorder] sidecar write failed: ${e.message}`);+    }+    // Add to session clip list+    desktopRecorder.clipsThisSession.push({+      recordingId: cur.recordingId, filePath: cur.filePath,+      sizeKB, durationMs, reason: cur.reason,+    });+    desktopRecorder.current = null;+    console.log(`[recorder] Clip ${cur.recordingId} stopped: ${sizeKB}KB, ${durationMs}ms`);+    // Tell page to show Saved state+    try {+      await desktopRecorder.page.evaluate((clip) => window.showSaved && window.showSaved(clip), {+        filename: path.basename(cur.filePath),+        sizeBytes: sentBytes,+        durationMs,+      });+    } catch {}+    // Resolve any waiter+    if (cur.stopResolver) cur.stopResolver();+  });++  await desktopRecorder.page.exposeFunction('_resizeWindow', async ({ width, height }) => {+    try {+      const cdp = desktopRecorder.cdp || (desktopRecorder.cdp = await desktopRecorder.page.target().createCDPSession());+      const target = desktopRecorder.page.target();+      const targetId = target._targetId || target.target?.()?._targetId || (await target.createCDPSession()).then(()=>{}); // best effort+      // Use Puppeteer's underlying CDP: simpler to use Browser.getWindowForTarget by sending raw CDP+      const { windowId } = await cdp.send('Browser.getWindowForTarget', {});+      desktopRecorder.windowId = windowId;+      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width, height } });+    } catch (e) {+      console.log(`[recorder] resize failed (non-fatal): ${e.message}`);+    }+  });++  await desktopRecorder.page.exposeFunction('_recorderClosed', () => {+    console.log(`[recorder] Page reports closing`);+  });++  await desktopRecorder.page.exposeFunction('_openRecordingsFolder', () => {+    try {+      execSync(`explorer "${RECORDINGS_DIR}"`, { stdio: 'ignore' });+    } catch {}+  });++  // Detect window-close as a stop signal+  desktopRecorder.browser.on('disconnected', () => {+    console.log(`[recorder] Browser disconnected`);+    if (desktopRecorder.current) {+      // Best-effort: mark stopped, write whatever we have+      try { if (desktopRecorder.current.fd) fs.closeSync(desktopRecorder.current.fd); } catch {}+      if (desktopRecorder.current.stopResolver) desktopRecorder.current.stopResolver();+      desktopRecorder.current = null;+    }+    desktopRecorder.browser = null;+    desktopRecorder.page = null;+    desktopRecorder.cdp = null;+    desktopRecorder.reason = null;+    desktopRecorder.clipsThisSession = [];+  });++  desktopRecorder.reason = reason;+  desktopRecorder.hudOpenedAt = Date.now();+  desktopRecorder.clipsThisSession = [];+}++async function closeRecorderWindow() {+  if (desktopRecorder.current) {+    // Stop active recording first+    try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}+    // Wait briefly for stop to flush+    const deadline = Date.now() + 3000;+    while (desktopRecorder.current && Date.now() < deadline) {+      await new Promise(r => setTimeout(r, 50));+    }+  }+  const summary = {+    clipCount: desktopRecorder.clipsThisSession.length,+    totalSizeKB: desktopRecorder.clipsThisSession.reduce((a, c) => a + (c.sizeKB || 0), 0),+    reason: desktopRecorder.reason,+    clips: desktopRecorder.clipsThisSession,+  };+  if (desktopRecorder.browser) {+    try { await desktopRecorder.browser.close(); } catch {}+  }+  desktopRecorder.browser = null;+  desktopRecorder.page = null;+  desktopRecorder.cdp = null;+  desktopRecorder.reason = null;+  desktopRecorder.current = null;+  desktopRecorder.clipsThisSession = [];+  return summary;+}++async function desktopRecordStartImpl(opts) {+  const { reason, monitor = 'primary', fps = 30, audio = false,+          videoBitsPerSecond = 4_000_000, maxDurationMs = 600_000,+          confirmDesktopNotTabRecording } = opts;++  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {+    throw new Error('reason is required — explain to the user why you\'re recording');+  }++  // Guardrail: Claude frequently mistakes "record this pup window/tab" for a+  // desktop-recording request. Desktop recording captures the WHOLE screen+  // (including unrelated apps in the foreground); if the pup window is+  // occluded the resulting clip won't show what the user actually wanted.+  // For 95% of "record a pup" asks the right verb is pup_record_start+  // (tab-scoped via CDP, captures regardless of foreground state, ~50fps).+  // Adom-desktop's full-desktop recording is mostly redundant with what+  // Hydrogen ships natively, and only really useful when Hydrogen is+  // already busy doing tab-recording for ralph-loop screenshot tests AND+  // the user wants a parallel desktop video at the same time.+  // Force the caller to explicitly opt in so they re-read this and confirm.+  if (confirmDesktopNotTabRecording !== true) {+    const err = new Error(+      'desktop_record_start records the WHOLE DESKTOP, not a pup tab. ' ++      'If the user said "record a pup" / "record this Chrome window" / "record the page", ' ++      'they almost certainly want pup_record_start instead — that is tab-scoped via ' ++      'CDP Page.startScreencast, captures the pup tab regardless of foreground state, ' ++      'returns a single .webm at ~50fps, and does not need a HUD. ' ++      'Hydrogen also has built-in desktop recording (recording start --share screen) which ' ++      'is usually preferable for general "record my screen" asks. ' ++      'adom-desktop\'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'));+

Comments

John Lauer ·

Merged (a01207fe). Reviewed the change itself: the only edits are the two mac helpers, the renamed JXA runner, and two log/comment lines; the rest of the diff is line endings. node --check is clean on the merged file, and the three remaining "System Events" mentions are in the explanatory comment. The local tree now carries it, so the next release cannot push the old file back over it.

On shipping it: I have not built the Node release or promoted it. The public tier has been held at 2.0.396 on John's instruction (insiders only), and tools/promote.sh moves it only on the human's exact words. I have asked John for the go; once he gives it I will build the Node release and promote it with his words.

John Lauer ·

Shipped to the public tier: 2.0.601, the Node shell carrying your merge.

  • Release: https://wiki.adom.inc/download/adom/pup-bridge/2.0.601/adom-bridge-puppeteer-v2.0.601.zip
  • The public manifest now reads 2.0.601 (it sat at 2.0.396 since the Rust line went insiders-only), with John's attestation recorded on it.
  • Verified in the published artifact, not just the tree: server.js carries 6 NSRunningApplication sites, node --check is clean, and the single remaining tell application "System Events" is the comment explaining the change.
  • The INSIDERS tier is untouched and still reads 2.0.600 (rust), so nobody on it is pulled back to the Node shell.

Bridge/Hydrogen can reseed plugins/puppeteer from 2.0.601 whenever you are ready, and the bridge-macos seed with it.

Two notes while you are in this area:

  1. pup_import_browser_logins counts a row it cannot decrypt as "skipped", with no separate number. I read 185 skipped today and could not tell refusals from duplicates without going to Chrome's own Login Data. If ab's helper returns passwordsFailed, pup is dropping it: payload["stats"] came back null on this box. Worth a look from your side; I will surface whatever ab sends.
  2. Your host-mac PR is welcome whenever it is ready. The page's rust/ tree builds from a clean pull now (pup-bridge#118), verified by pulling into an empty directory and running cargo check -p adom-pup-bridge.

Log in to comment.