// Puppeteer Bridge Server — multi-session persistent browser instances
// Each session gets its own independent Chrome window (not tabs)
// Runs on desktop, controlled via HTTP from container or Adom Desktop app
// Port 8851 by default, configurable via --port
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync, execFileSync, execFile } = require('child_process');
// v1.8.29: run a PowerShell script file with NO console window (windowsHide) and no cmd
// shell (execFileSync directly), so pup's window-management PS never flashes a terminal on
// the user's screen. -WindowStyle Hidden is belt-and-suspenders. Sync variant returns stdout.
// v1.9.71 — LOG JANITOR (bridge owns VOLUME; AD owns the FILE).
// Deliberate split, revisited in v1.9.73 and kept:
//   - AD spawns us and captures our stdout, so the FILE and its rotation are AD's. A bridge
//     cannot rotate a file another process opened, and every bridge (incl. third-party ones)
//     needs bounded logs — that has to be a platform guarantee, not per-author boilerplate.
//   - What IS ours is how much we EMIT. A tidy platform cannot save us from being a firehose.
// We briefly wrote our own rotated pup.log and reverted it: two log files means two sources of
// truth and double the disk for the same bytes, and it papered over an AD-side bug worth fixing
// properly (bridge_log_read interleaves AD lifecycle lines with the bridge's own output). Collapse consecutive identical lines into one + a repeat count, so a
// retry loop or a chatty page costs 2 lines instead of 200. Only exact repeats are collapsed, so no
// distinct event is ever lost.
const _origConsoleLog = console.log.bind(console);
let _lastLogMsg = null, _lastLogCount = 0, _lastLogTs = 0;
const LOG_DEDUPE_MS = 60000;
console.log = (...args) => {
  let msg;
  try { msg = args.length === 1 && typeof args[0] === 'string' ? args[0] : args.map(a => (typeof a === 'string' ? a : String(a))).join(' '); }
  catch (e) { return _origConsoleLog(...args); }
  const now = Date.now();
  if (msg === _lastLogMsg && (now - _lastLogTs) < LOG_DEDUPE_MS) { _lastLogCount++; _lastLogTs = now; return; }
  if (_lastLogCount > 1) _origConsoleLog(`  ^ last line repeated ${_lastLogCount}x`);
  _lastLogMsg = msg; _lastLogCount = 1; _lastLogTs = now;
  _origConsoleLog(msg);
};

// v1.9.86 (fleet mac-porting audit, Kyle): these three helpers are the ONLY powershell.exe
// spawn points, and they now self-guard on win32. Every caller is already gated today
// (audited: 10/10 call sites), so this changes no behavior — it removes a LATENT trap.
// The mirror-image of the PATH trap: a Windows call left ungated on the mac path fails
// SAFE (ENOENT → the error branch → the same answer), so it never surfaces as a bug —
// you just burn a doomed process spawn on every call, forever. Guarding the primitive
// instead of trusting every future caller is the same defense-in-depth as making a
// module import-safe under the wrong interpreter: you don't own who calls you next.
// v2.0.27: the SYNCHRONOUS PowerShell runner is DELETED — zero callers remain. Window
// management is native ab verbs now; the few remaining scripts (focus-raise dance, base-icon
// extraction, credential import scan) run through the ASYNC runner only.
// Fire-and-forget async variant (never blocks the driving verb).
// Promise variant: resolves with stdout (trimmed) or '' on error. For PS whose RESULT matters
// but which must not block the event loop (e.g. the background watchdog).
function runPsHiddenPromise(scriptPath, { timeout = 8000 } = {}) {
  if (process.platform !== 'win32') return Promise.resolve('');
  return new Promise(resolve => {
    execFile('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-WindowStyle', 'Hidden', '-File', scriptPath], { encoding: 'utf8', timeout, windowsHide: true }, (err, stdout) => {
      resolve((stdout || '').trim());
    });
  });
}
// ── macOS window-management primitives (the darwin analogs of the Win32 helpers) ──
// Strategy on macOS: no cross-process per-window z-bottom API exists, and AppKit CLAMPS
// windows onto a visible screen (the -32000 off-screen launch trick doesn't hold). The
// reliable "background by default" primitive is the APP-LEVEL HIDE (the Cmd+H state):
// every pup session runs its own browser process (one per profile), so hiding that
// process hides exactly that session's windows — while CDP keeps driving + rendering
// them (--disable-backgrounding-occluded-windows), so screenshot/eval still work.
// Raise = unhide + activate. Driven via System Events (rides the Automation grant the
// embedding Adom Desktop already holds; a denied grant degrades to applied:false, never
// a throw). Hide is PER-PROCESS, so multi-session shared-profile windows hide/show as a
// group on mac — a documented DEGRADED vs the per-hwnd Windows path.
function runOsascript(script, { timeout = 6000 } = {}) {
  try { return execFileSync('/usr/bin/osascript', ['-e', script], { encoding: 'utf8', timeout }).trim(); }
  catch (e) { return null; }
}
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, 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');
  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');
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.
// 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;
}
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.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)
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.
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) {}
}

function attachTab(session, sessionId, page, { makeActive = true, opener = 'user', openerTabId = null } = {}) {
  const tabId = nextTabId(session);
  const errors = [];
  setupErrorCollection(page, errors);
  // 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;
        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) {}
    if (pupSettings().tbFloatyPencil !== 'off') {
      const fj = floatyJs();
      try { page.evaluateOnNewDocument(fj).catch(() => {}); page.evaluate(fj).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}`); }
  });
  session.tabs.push({ tabId, page, errors, opener, openerTabId });
  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.)
function raceCrash(t, sessionId, opPromise) {
  opPromise.catch(() => {});
  let iv;
  const crashWatch = new Promise((_, reject) => {
    iv = setInterval(() => {
      if (t && t._crashed) { try { _throwIfCrashed(t, sessionId); } catch (e) { reject(e); } }
    }, 250);
  });
  return Promise.race([opPromise, crashWatch]).finally(() => clearInterval(iv));
}

// 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,
    // 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 '${profileName.replace(/[\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\]/g, '\\\\$&')}' } | 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.
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.
      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 = PROFILES_DIR.replace(/[\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\]/g, '\\\\$&');
      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)),
]);

async function recoverSessions() {
  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 {
      // Check if Chrome is actually listening (5s cap — a wedged Chrome must not hang the pass)
      const resp = await _recoverTimeout(fetch(`http://127.0.0.1:${port}/json/version`), 5000, null);
      if (!resp || !resp.ok) {
        console.log(`Session "${sessionId}" CDP port ${port} not responding — killing orphaned Chrome PID ${pid}`);
        try { process.kill(pid, 'SIGKILL'); } catch (e) { console.log(`  kill failed: ${e.message}`); }
        deleteSessionFile(sessionId);
        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;
      const pages = await _recoverTimeout(browser.pages(), 10000, null);
      if (!pages) throw new Error('browser.pages() hung (10s cap) — wedged Chrome');
      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(); }
      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'})`);
      }
      // 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(() => {})));
}

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.167 (John's assisted-foreground protocol) — a general OS caption (what/why narration).
// v1.9.199 (John, 2026-07-29, after I typed into HIS window): NATIVE OS INPUT GUARD.
//
// desktop_click / desktop_type / desktop_press_key do NOT target a window — they go 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, every time. An agent (me) fired desktop_type without ever
// warning him, he had already alt-tabbed back to his own work, and the text landed in HIS window.
// Then I misdiagnosed it as "Windows failed to foreground the window" — blaming the OS for a human
// simply taking their focus back, because nobody had asked them not to.
//
// This guard makes that impossible by construction. Every native-input path MUST call it immediately
// before EACH event (not once per sequence — focus can change between two keystrokes, and half a
// password in the wrong window is real harm). It: warns via AD toast + on-screen caption, tells the
// user explicitly to stop typing, waits until the target window is genuinely the OS foreground, and
// re-verifies at dispatch time. Returns false if we never got a safe window — callers MUST NOT
// dispatch on false.
async function ensureSafeForNativeInput(sessionId, { reason = 'drive the window directly', warn = true } = {}) {
  if (process.platform !== 'win32') return true;
  const hwnd = await sessionHwnd(sessionId);
  if (!hwnd) {
    console.log(`[native-guard] "${sessionId}": no hwnd — REFUSING native input (cannot prove where it would land)`);
    return false;
  }
  const fg = await osGetForegroundWindow();
  if (String(fg) === String(hwnd)) return true;   // already ours; safe to dispatch

  if (!warn) {
    console.log(`[native-guard] "${sessionId}": window is NOT foreground — REFUSING native input`);
    return false;
  }
  // Warn BEFORE asking for the screen, and say the quiet part out loud: hands off the keyboard.
  const msg = `Adom needs your "${sessionId}" window in front to ${reason}. Please DON'T type or click until I say done.`;
  try { await chrome.abCommand('notify_user', { title: 'Adom needs the keyboard', body: msg }, { timeoutMs: 5000 }); } catch (e) {}
  try { await pupCaption(msg, 15000, 'pup-native-guard'); } catch (e) {}
  const ready = await waitForUserReady(sessionId, hwnd, { maxMs: 60000 });
  if (!ready) {
    try { await pupCaption('Adom skipped that step: the window never came to the front, so nothing was typed.', 6000, 'pup-native-guard'); } catch (e) {}
    console.log(`[native-guard] "${sessionId}": user never brought the window forward — REFUSED native input`);
    return false;
  }
  // Re-verify at the last possible moment.
  const fg2 = await osGetForegroundWindow();
  if (String(fg2) !== String(hwnd)) {
    console.log(`[native-guard] "${sessionId}": focus moved again at dispatch time — REFUSED native input`);
    return false;
  }
  return true;
}

// 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.
const PUP_CAPTION_MAX_MS = 2000;
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.
  try { await chrome.abCommand('desktop_caption', { text, duration: expiresInMs, expiresInMs, id, position: 'bottom', size: 'medium' }, { timeoutMs: 4000 }); } catch (e) {}
}
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) return Number(_s._hwnd);
    if (_s) { const h = await resolveSessionHwndByBounds(_s, sessionId); if (h) return h; }
  } catch (e) {}
  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));
    return b && (b.hwnd != null ? Number(b.hwnd) : null);
  } catch (e) { 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(5000, 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(); }
  })));
}
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);
  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);
  if (remaining.length) { await _taskkillPids(remaining.map(p => p.pid)); await new Promise(r => setTimeout(r, 300)); remaining = (await _psProcs(where)).filter(isOrphan); }
  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 };
}

// 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).
    try { await Promise.race([browser.close(), new Promise(r => setTimeout(r, 3000))]); }
    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 {}
  }

  // Tree-kill the tracked parent first (fast; catches the common case). Non-blocking async taskkill.
  if (pid) {
    if (_isWin) { await _taskkillPids([pid]); }
    else { try { process.kill(pid, 'SIGKILL'); } catch (e) {} }
    console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);
  }

  // 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.53: PIN the Adom Pup toolbar icon by default (MV3 action icons hide in the puzzle
    // menu unless pinned). The unpacked-extension id is a hash of its stable staging path, so
    // it is the same on every machine that stages to ~/.adom/pup-extension.
    try {
      if (pupSettings().toolbarExtension !== 'off') {
        prefs.extensions = prefs.extensions || {};
        const pins = Array.isArray(prefs.extensions.pinned_extensions) ? prefs.extensions.pinned_extensions : [];
        for (const d of [path.join(os.homedir(), '.adom', 'pup-annotate-extension'), path.join(os.homedir(), '.adom', 'pup-extension'), path.join(os.homedir(), '.adom', 'pup-identity-extension')]) {
          const id2 = unpackedExtensionId(d);
          if (!pins.includes(id2)) pins.push(id2);
        }
        prefs.extensions.pinned_extensions = pins;
      }
    } 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;
    // v1.9.129: the durable profile exists to ACCUMULATE logins — make sure Chrome's own
    // password manager is on so the "Save password?" bubble can do its job.
    prefs.credentials_enable_service = true;
    prefs.profile.password_manager_enabled = true;
    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.
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,
  };
}

// Push a pup window to the BACK of the z-order WITHOUT activating it. Occluded, NOT
// minimized — it keeps compositing, so screenshots/eval still work while it sits behind
// the user's windows. This is what makes "open in pup" non-disruptive BY DEFAULT: the AI
// drives it invisibly and only an explicit request ever brings it forward. Scoped to THIS
// session's Chrome pid (+ its child chrome/msedge processes) so it never touches the
// user's real browser windows. Windows-only; no-op elsewhere.
async function osSendWindowToBack(profileName) {
  // v2.0.27: native + async (was a SYNC PowerShell that blocked the event loop and was MISSED by the
  // 2.0.23 sweep — its variable was named psScript, not scriptPath; the honest census found it).
  if (process.platform !== 'win32') return false;
  const be = browsers.get(profileName);
  const pid = (be && be.browser && (be.browser._detachedPid || (be.browser.process() && be.browser.process().pid))) || null;
  if (!pid) return false;
  try {
    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 sent = false;
    for (const r of rows) {
      if (!kids.has(r.pid)) continue;
      try {
        const d = abPayload(await chrome.abCommand('desktop_set_window_state', { hwnd: r.h, state: 'bottom', force: false }, { timeoutMs: 5000 }));
        if (d && (d.sentToBack || d.ok)) sent = true;
      } catch (e) {}
    }
    return sent;
  } catch (e) { console.error(`[bg] send-to-back failed for "${profileName}": ${e.message}`); return false; }
}

// 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.
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;
  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.
  const D = { aumidIcons: 'off', jumpLists: 'off', fgWatch: 'on', fgCaption: 'on', fgCaptionMs: 2000,
              overlayBadges: 'on', autoFlash: 'off', sleepRecovery: 'on', openFlash: 'off',
              aumidIconStyle: 'standard' };
  D.openFlash = 'on';   // v1.9.262 (John: "why did you default it to off when its defaulted to on")
  if (_pupSettings.openFlash === 'off' && !_pupSettings._openFlashChosen) _pupSettings.openFlash = 'on';
  // v1.9.268 (John on autoFlash: "this should be on by default")
  D.autoFlash = 'on';
  const chosen = _pupSettings._chosen || {};
  if (_pupSettings.autoFlash === 'off' && !chosen.autoFlash) _pupSettings.autoFlash = 'on';
  // 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'; D.agentActivity = 'on';
  // 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 = 'on'; D.floatyHitPx = 24;
  // 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 = 'i7';   // 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; }
}
// 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 pupSessionAuthed(sessionId) {
  try { const s = sessionId && sessions.get(sessionId); return !!(s && s._wikiAuthed); } catch (e) { return false; }
}
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() {
  if (process.platform !== 'win32') return;
  try {
    const f = pupPortFile();
    fs.mkdirSync(path.dirname(f), { recursive: true });
    fs.writeFileSync(f, JSON.stringify({ port: PORT, pid: process.pid, version: BRIDGE_VERSION, host: '127.0.0.1' }));
  } catch (e) { console.error('[jumplist] port-discovery 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 || '')); }
async function ensurePupIdentityRegistered() {
  if (process.platform !== 'win32' || _identitySupported === false) return false;
  if (_identityRegistered) return true;
  try {
    const r = await chrome.abCommand('desktop_register_app_identity', {
      appId: PUP_APP_ID, displayName: PUP_DISPLAY_NAME, iconPath: pupIconPath(),
      args: 'pup_open_window {"sessionId":"pinned","owner":"pinned-launch","url":"https://wiki.adom.inc"}',
    }, { 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 registered (HKCU AUMID + Start Menu pin -> managed pup window)');
    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
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;
  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);
    // 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);
    if (!grouped && !rawCat) {
      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';
    let appId = _dashInfra ? `${PUP_APP_ID}.Dashboard.${PUP_ICON_GEN}`
      : _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) 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) 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;
    const r = await chrome.abCommand('desktop_set_window_identity', {
      ...(_stampHwnd ? { hwnd: _stampHwnd } : {}),
      titleContains: `(${TITLE_LABEL}: ${sessionId}`, cacheKey: `pup-${sessionId}-${cat}`,
      appId, displayName: pupDisplayName(sessionId), iconPath: pupIconPath(sessionId),
      // 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 || ''));
    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'}`);
      if (!sess) _earlyStamped.set(sessionId, appId);   // v2.0.25: pre-registration stamp — see above
      if (sess && prevAppId !== appId) {
        sess._curAppId = appId;
        // 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.
        sess._appOverlay = false; sess._overlayEnforceAt = 0; sess._hwnd = null;
        // 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 (minimalTouch()) return false;   // v1.9.190: minimal-touch — no taskbar work at all
  if (_identitySupported === false || appId === PUP_APP_ID || _registeredPupAumids.has(appId)) return;
  // 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.
  writeAumidIcon(appId, pupIconPath(sessionId));
  try {
    const r = await chrome.abCommand('desktop_register_app_identity', {
      appId, displayName: pupDisplayName(sessionId), iconPath: pupIconPath(sessionId), 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; }
}
// 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;
  try {
    const out = execFileSync('reg', ['query', 'HKCU\\Software\\Classes\\AppUserModelId'],
      { encoding: 'utf8', timeout: 8000, windowsHide: true });
    const live = new Set(sessions.keys());
    let dropped = 0;
    for (const line of out.split(/\r?\n/)) {
      const m = line.match(/\\(Adom\.Pup\.(.+))$/);
      if (!m) continue;
      const appId = m[1], rest = m[2];
      if (appId === PUP_APP_ID) continue;                 // the pinnable launcher stays
      const sid = rest.replace(/\.i\d+$/, '').replace(/\.(wiki-in|wiki|app|web|mixed|plain)$/, '');
      // v1.9.189 (John's taskbar audit): a LIVE session can own SEVERAL keys, because every category
      // change registered a NEW AUMID and nothing removed the previous one (audit found one window
      // holding 3: .kimi-about-mockup, .kimi-about-mockup.app.i3, .kimi-about-mockup.plain.i3). Keep
      // only the key the session is CURRENTLY stamped with; drop its superseded ones.
      if (live.has(sid)) {
        const cur = (sessions.get(sid) || {})._curAppId;
        if (!cur || cur === appId) continue;   // current key (or unknown) — keep
      }
      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`);
  } catch (e) {}
}
async function unregisterPupAumid(appId) {
  if (process.platform !== 'win32' || !appId || appId === PUP_APP_ID) 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) {}
}
// 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.
async function refreshAllJumplists() {
  if (process.platform !== 'win32' || _jumplistSupported === false) return;
  const off = pupSettings().jumpLists !== 'on';
  for (const [sid, s] of [...sessions]) {
    try {
      const appId = s._curAppId; 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;
  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.
  const appId = (opts && opts.appId) || (session && session._curAppId) || `${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(); } 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,
    }));
  }
  // 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) {}
}
// 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.
async function captionForegroundReason(sessionId, reason, verb) {
  // v1.9.259: user prefs — toggle + duration slider (500..5000ms, default 2000).
  if (pupSettings().fgCaption !== 'on') return;
  const _capMs = Math.max(500, Math.min(5000, Number(pupSettings().fgCaptionMs) || 2000));
  try {
    const why = String(reason || '').trim();
    if (!why) return;
    await pupCaption(`Adom brought "${sessionId}" to the front. Reason: "${why}"`, _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 (pupSettings().agentActivity === 'off') return;      // user opt-out via pup_configure
  if (!sessions.has(sessionId)) return;
  let st = _busyState.get(sessionId);
  if (!st) { st = { busy: false, timer: null }; _busyState.set(sessionId, st); }
  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;   // v1.9.185 minimal-touch: no cosmetic/shell machinery pushed at Chrome
  // 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;
  let n = 0;
  for (const [sid, sess] of sessions) {
    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';
function isWikiUrl(u) { try { return /(^|\.)wiki\.adom\.inc$/i.test(new URL(u).hostname); } catch (e) { return false; } }
// 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; } }
// 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"), an isolated logged-out jar.`;
    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 — an isolated anonymous jar, genuinely logged out. This is the one true
    // "what the world sees" view.
    return ` (📖 Wiki PUBLIC view — an ISOLATED anonymous jar, genuinely LOGGED OUT, exactly what the world sees. Use this to verify a publish didn't leak private source. For the user's logged-in view (private source, drafts, owner cards, replying), 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" (isolated logged-out jar). For the explicit logged-in vault view, pass wikiView:"authed".)`;
}

async function fetchPageFavicon(page) {
  // v1.9.130: collect candidate URLs in-page (a DOM read — CORS-free), but FETCH them from the
  // BRIDGE process. The old in-page fetch() only ever worked because --disable-web-security had
  // CORS off globally; when v1.9.129 restored the same-origin policy, any favicon whose fetch
  // crosses origins (accounts.google.com redirects its /favicon.ico to www.google.com) started
  // CORS-failing and those windows silently lost their overlay — John spotted the missing icon
  // within the hour. Node has no same-origin policy, so a bridge-side fetch cannot regress this.
  // (Tradeoff, accepted: the bridge fetch carries no page cookies, so a favicon behind auth won't
  // resolve — rare, and the count-badge fallback covers it. localhost favicons still work: the
  // bridge runs on the same machine as Chrome.)
  let hrefs = [];
  try {
    hrefs = await page.evaluate(() => {
      // v1.9.43: a page can declare a PLACEHOLDER icon (example.com ships <link href="data:,">) —
      // drop obvious junk, always keep /favicon.ico as the last resort.
      const links = Array.from(document.querySelectorAll('link[rel~="icon"], link[rel="shortcut icon"], link[rel="apple-touch-icon"]'));
      const out = links.map(l => l.href).filter(h => h && h !== 'data:,' && !/^data:,?$/.test(h));
      out.push(new URL('/favicon.ico', location.origin).href);
      return out;
    });
  } catch (e) { return null; }
  // v1.9.217 (John: "the website being loaded has its favicon?" — dashboard-v2.adom.inc showed the
  // GENERIC icon). The bridge-side fetch below carries NO COOKIES, so any favicon behind auth returns
  // 401/403 and pup falls back to a generic icon. The old comment called that "rare"; it is not — it
  // hits EVERY authed Adom dashboard and app. So try IN-PAGE first: the page's own fetch carries its
  // cookies and same-origin favicons (the overwhelmingly common case, /favicon.svg on the same host)
  // resolve cleanly. Only if that fails do we fall back to the cookie-less bridge fetch, which is
  // still the right tool for a cross-origin icon the page itself cannot read.
  for (const href of (hrefs || [])) {
    if (href.startsWith('data:')) break;   // handled by the loop below
    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, 'favicon-inpage').catch(() => null);
      if (inPage && inPage.b64) {
        console.log(`[overlay] favicon fetched IN-PAGE (with cookies) from ${href.slice(0, 70)}`);
        return { b64: inPage.b64, mime: inPage.mime || '', href };
      }
    } catch (e) {}
  }
  for (const href of (hrefs || [])) {
    try {
      if (href.startsWith('data:')) {
        // Inline icon — decode directly, nothing to fetch.
        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', href: 'data:inline' };
        }
        continue;
      }
      // v1.9.204: a file:// page's favicon href is itself file:// — Node's fetch CANNOT fetch that,
      // so every local-file window silently lost its icon and rendered no overlay at all (John's first
      // taskbar button). The bridge runs on the SAME machine as Chrome, so just read it off disk.
      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) {
            const ext = (fp.split('.').pop() || '').toLowerCase();
            const mime = ext === 'ico' ? 'image/x-icon' : ext === 'svg' ? 'image/svg+xml' : ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : 'image/png';
            return { b64: buf.toString('base64'), mime, href };
          }
        } catch (e) {}
        continue;
      }
      const r = await fetch(href, { redirect: 'follow', signal: AbortSignal.timeout(4000) });
      if (!r || !r.ok) continue;
      const buf = Buffer.from(await r.arrayBuffer());
      if (!buf.length) continue;
      return { b64: buf.toString('base64'), mime: r.headers.get('content-type') || '', href };
    } catch (e) {}
  }
  return null;
}
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; }
}

// 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;
  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; }
  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 = 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
}
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
  // 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) {}
        if (_curUrl && session._lastRealSrc.forUrl === _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`);
        }
      }
      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 = _isPdf2
            ? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">'
              + '<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>'
              + '<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">'
              + '<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>'
              + '<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>';
          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 };
          console.log(`[overlay] "${sessionId}" no favicon in this session — generic ${_isPdf2 ? 'PDF' : 'webpage'} icon + Adom mark`);
          console.log(`[overlay] "${sessionId}" no favicon anywhere in this window — using the generic glyph so the badge is never bare`);
        } 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.
    const cacheKey = BRIDGE_VERSION + ':' + sessionId + ':' + (src.href || 'explicit') + ':' + (plateAuthed ? 'auth' : 'plain') + ':' + (pupSettings().aumidIcons === 'on' ? 'nomark' : 'mark');
    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 = isPdf
                // Document sheet with a folded corner + PDF wordmark, on Adobe-ish red so a datasheet
                // reads instantly as a PDF at 16px.
                ? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">'
                  + '<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>'
                  + '<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>'
                // Chrome's generic page icon: a grey globe on a light plate.
                : '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48">'
                  + '<rect x="0" y="0" width="48" height="48" rx="9" fill="#f1f3f4"/>'
                  + '<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>';
              // 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.
          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="#202124"/></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.
          const _wantMark = pupSettings().aumidIcons !== 'on';
          if (_wantMark) {
            try {
              const markB64 = pupBadgeB64();
              if (markB64) {
                // NO disc, NO container. The Adom mark occupies the lower-right quadrant, flush to both
                // edges, sitting directly on the page's own favicon.
                layers.push({ input: await sharpMod(Buffer.from(markB64, 'base64')).resize(MSZ, MSZ, { fit: 'fill' }).png().toBuffer(), top: BSZ - MSZ, left: BSZ - MSZ });
              }
            } catch (e) { /* no mark available — favicon-only badge still valid */ }
          }
          if (layers.length) img = img.composite(layers);
          overlay = (await img.png().toBuffer()).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`);
      fs.writeFileSync(_dbgPath, Buffer.from(overlay, 'base64'));
      console.log(`[overlay-debug] "${sessionId}" PAINT ${Buffer.from(overlay, 'base64').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 john@adom.inc'"). 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) {
  if (window.__pupAnnotate) { if (!window.__pupAnnotateHidden) window.__pupAnnotate.show(); return 'shown'; }   // v2.0.81: a re-arm after emit-hide stays INVISIBLE (the post-draw blip John saw)
  initTool = initTool || 'pen';
  var S = { tool: 'pen', color: '#f85149', strokes: [], cur: null, quick: true, seq: 0 };
  var COLORS = ['#f85149', '#d29922', '#3fb950', '#64abff', '#8c6bf7', '#e6edf3'];
  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: SVG data-URI, hotspot at the working tip, pen carries a
  // live swatch of the current color (rebuilt on color change).
  function penCur() {
    var svg = '<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>';
    return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '") 5 19, crosshair';
  }
  function arrowCur() {
    var svg = '<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>';
    return 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '") 2 2, crosshair';
  }
  function setCursor() {
    cv.style.cursor = S.tool === 'pen' ? penCur() : S.tool === 'arrow' ? arrowCur()
      : S.tool === 'text' ? 'text' : 'crosshair';
  }
  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); } } 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); }
  });
  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']],
    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');
  var dot = document.createElement('button');
  dot.removeAttribute && dot.removeAttribute('title'); dot.style.cssText = 'width:20px;height:20px;border-radius:50%;border:2px solid #8b949e;cursor:pointer;background:' + S.color + ';';
  dot.onclick = function () { var i = (COLORS.indexOf(S.color) + 1) % COLORS.length; S.color = COLORS[i]; dot.style.background = S.color; setCursor(); };
  bar.appendChild(dot); tipFor(dot, 'Drawing color. Click to cycle: red, yellow, green, blue, purple, white. The pen cursor shows the current color.');
  var qk = document.createElement('button');
  function qkPaint() { qk.style.cssText = 'background:' + (S.quick ? 'rgba(0,184,177,.13)' : 'none') + ';border:1px solid ' + (S.quick ? 'rgba(0,184,177,.55)' : 'transparent') + ';border-radius:8px;color:' + (S.quick ? '#00d4cb' : '#c9d1d9') + ';padding:3px 8px;cursor:pointer;font:inherit;display:flex;align-items:center;gap:5px;'; }
  qk.appendChild(icon(ICONS.auto[0])); qk.appendChild(document.createTextNode('Auto')); 
  qk.onclick = function () { S.quick = !S.quick; qkPaint(); };
  qkPaint(); bar.appendChild(qk); tipFor(qk, 'Auto capture (on by default): circle it, lift the mouse, and it is captured, copied to your clipboard, and the toolbar closes. Turn off to compose multi-step drawings and capture once with Done. Esc exits any time.');
  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);
      if (previewUrl) { 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); } };
  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); }
  });
}
// In-page confirmation toast WITH a thumbnail of the ACTUAL captured PNG — the user sees their
// red circle and the grabbed margin before tabbing away.
async function showCaptureToast(pg, previewDataUrl, line1) {
  try {
    await pg.evaluate(function (dataUrl, txt) {
      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 = txt; cap.style.cssText = 'margin-bottom:7px;color:#00d4cb;font-weight:500;';
      t.appendChild(cap);
      if (dataUrl) { var im = document.createElement('img'); im.src = dataUrl; 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);
    }, previewDataUrl, line1);
  } catch (e) {}
}

// 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) fs.copyFileSync(sp, dp); } catch (e) { fs.copyFileSync(sp, dp); }
    }
    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));
  return FLOATY_JS.replace(/__HITPX__/g, String(px));
}
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__') return;
  window.__pupFloatyV = '${BRIDGE_VERSION}:__HITPX__'; window.__pupFloaty = true;
  var _oldHost = document.getElementById('__pup_floaty'); if (_oldHost) _oldHost.remove();
  function add() {
    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(); 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();
  addEventListener('keydown', function (e) {
    if (e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey && (e.key === 'A' || e.key === 'a')) {
      e.preventDefault(); e.stopPropagation();
      if (window.__pupStartAnnotate) { window.__pupStartAnnotate('pen'); }
      window.__pupFloatyClick = Date.now();
    }
  }, true);
})()`;
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;
}
// Chromium's unpacked-extension id: first 16 bytes of SHA-256 over the UTF-16LE path, hex mapped
// a..p. Validated against a live id 2026-08-10 (utf16le is the correct encoding on Windows).
function unpackedExtensionId(dir) {
  const h = require('crypto').createHash('sha256').update(Buffer.from(dir, 'utf16le')).digest('hex').slice(0, 32);
  return [...h].map(c => String.fromCharCode('a'.charCodeAt(0) + parseInt(c, 16))).join('');
}
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';
  await pg.evaluate((code) => { if (!window.__pupStartAnnotate) eval(code); }, ANNOTATE_OVERLAY_JS);
  await pg.evaluate((t2) => { window.__pupStartAnnotate(t2); }, tool);   // 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})`);
  // 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)
  let preview = null;
  try { const sharp = require('sharp'); const small = await sharp(buf).resize({ width: 320, withoutEnlargement: true }).png().toBuffer(); preview = 'data:image/png;base64,' + small.toString('base64'); } 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) {
  // 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.
  try { if (reason) captionForegroundReason(sessionId, reason, 'osRaiseSessionWindow').catch(() => {}); } catch (e) {}
  try {
    try {
      const cdp = await session.page.target().createCDPSession();
      const { windowId } = await cdp.send('Browser.getWindowForTarget');
      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' } });
    } 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;
    // v1.9.30: resolve the PID EXACTLY like the proven pup_raise_os_window handler does:
    // browser.process() ONLY. For a DETACHED launch (every Windows pup browser) process() is null,
    // so the PS SetForegroundWindow step is SKIPPED and the CDP place+maximize+bringToFront above
    // is what shows the window — and that path demonstrably works. The old code fell back to
    // _detachedPid, which made PS raise the LAUNCHER pid's MainWindowHandle: a different window
    // than the session's. It reported "focused" every time while the real window stayed hidden,
    // which is why the wiki toggle left John with captions and no window.
    const be = browsers.get(session.profileName);
    let pid = null;
    try { pid = (be && be.browser && be.browser.process() && be.browser.process().pid) || null; } catch (e) {}
    if (!pid) return;
    const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');
    const psContent = [
      'Add-Type @"',
      'using System; using System.Runtime.InteropServices;',
      'public class WinFocus {',
      '  [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);',
      '  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);',
      '  }',
      '}',
      '"@',
      `$targetPid = ${pid}`,
      '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',
      'if ($p -and $p.MainWindowHandle -ne 0) {',
      '  [WinFocus]::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) {',
      '      [WinFocus]::Raise($cp.MainWindowHandle)',
      "      Write-Output 'focused'",
      '      return',
      '    }',
      '  }',
      '}',
    ].join('\r\n');
    try { fs.writeFileSync(psScript, psContent); } catch (e) { return; }
    // ASYNC PS (runPsHiddenPromise) — the sync runPsHidden blocks pup's event loop for up to 8s,
    // stalling every other request; the async variant frees the loop while the raise runs.
    try { const r = (await runPsHiddenPromise(psScript, { timeout: 8000 })).toString().trim(); console.log(`[wiki] raised session "${sessionId}" to foreground: ${r}`); } catch (e) {}
  } 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;
  try {
    const d = abPayload(await chrome.abCommand('desktop_enum_windows', { className: 'Chrome_WidgetWin_1', includeOffscreen: true }, { timeoutMs: 6000 }));
    const row = ((d && d.windows) || []).find(w => Number(w.hwnd) === Number(hwnd));
    if (!row || row.visible === false) 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 info = await hwndOwnerPid(hwnd);
  const pid = info && info.pid;
  if (!pid) return false;
  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. Learn
  // the owner pid so every sibling window on the shared Chrome process passes instantly afterwards.
  try {
    const born = session && (session.tabs || []).some(t => t && t.page && Number(t.page.__pupHwnd) === Number(hwnd));
    if (born) { learnConfirmedPid(prof, pid); return true; }
  } catch (e) {}
  // 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}].
async function enumChromeWidgetWindows() {
  // v2.0.27: native (desktop_enum_windows, ab 2.0.16+) — the EnumWindows-by-class PS is gone. Keeps
  // the legacy row shape {h,pid,L,T,W,H}; includeOffscreen so a just-born window at -32000 resolves.
  if (process.platform !== 'win32') return [];
  try {
    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 || '') });
    }
    return rows;
  } catch (e) { return []; }
}
// 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.
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) };
  } catch (e) {}
  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);
  let _releaseWiggle = null;   // v1.9.291: hoisted so the outer catch can always release the lock
  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; 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));
        console.log(`[hwnd] "${sessionId}" resolved OS handle ${bh} — birth-time capture (off-screen at creation)`);
        return Number(bh);
      }
    } 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.
    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.
    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;
    // v1.9.290 UNIQUE-WIDTH WIGGLE: parked windows share the identical rect, so plain geometry can't
    // tell two same-profile windows apart (dup-hwnd bug). Give THIS window a UNIQUE width via CDP
    // (pup controls it directly — no page observer can fight it), match on that exact width, restore.
    let bounds = null, cdp = null, windowId = null, markerW = null, _widthChanged = false;
    try {
      cdp = await page.target().createCDPSession();
      ({ windowId } = await cdp.send('Browser.getWindowForTarget'));
      ({ bounds } = await cdp.send('Browser.getWindowBounds', { windowId }));
    } catch (e) { try { if (cdp) await cdp.detach(); } catch (x) {} return null; }
    if (!bounds || !Number.isFinite(bounds.width)) { try { if (cdp) await cdp.detach(); } catch (x) {} return null; }
    const be = browsers.get(session.profileName);
    const pid = be && be.browser && (be.browser._detachedPid || (() => { try { return be.browser.process()?.pid; } catch (e) { return null; } })());
    if (!pid) { try { if (cdp) await cdp.detach(); } catch (x) {} return null; }
    const _origW = bounds.width;
    // v1.9.291: SERIALIZE the wiggle. The enforcer fires applyAppOverlay (hence resolve) for many
    // sessions concurrently; if their width-changes + enumerations overlap, every session matches
    // whichever window is momentarily at a marker width — collapsing them onto one hwnd. Take a
    // global lock so exactly one wiggle (set width -> enumerate -> restore) runs at a time.
    // v1.9.308 (John: "why did you resize this window so weird? what the fuck?"): the width-WIGGLE is
    // DELETED. It shrank the window to a marker width to identify it, and when the restore failed it
    // left the window stuck narrow — visibly deforming John's window. NEVER acceptable. Handle
    // resolution now uses ONLY read-only signals: the birth-time capture, the persisted handle, and
    // the sole-window-of-a-profile check below — none of which touch window geometry. A window none of
    // those can resolve is left BARE (no overlay), which is fine; mangling the user's window is not.
    // markerW stays null, so the matching loop below is pure read-only geometry (no setWindowBounds).
    // v2.0.27: NATIVE enumeration (ab desktop_enum_windows) — the EnumWindows PowerShell, its
    // unique temp files, and the EBUSY class are all gone. Same read-only geometry doctrine as
    // v1.9.308 (the width-wiggle stays deleted; bare > wrong, forever).
    const rows = await enumChromeWidgetWindows();
    if (!rows.length) { if (_releaseWiggle) { try { _releaseWiggle(); } catch (e) {} } try { if (cdp) await cdp.detach(); } catch (e) {} return null; }
    // Every chrome.exe under this profile counts (renderers have no windows, so PID may be the parent
    // or a sibling in the tree — accept any, then disambiguate purely on geometry).
    // v1.9.302: collect ALL matching windows and demand EXACTLY ONE. The old code broke on the first
    // match, so an accidental collision silently won. If 0 or 2+ windows match, we cannot know which is
    // ours, so we LEAVE IT BARE rather than paint a wrong window (John's rule: bare > wrong, forever
    // after the Edge-overlay incident). A bare window self-heals on the next resolve; a wrong one lies.
    const matches = new Set();
    for (const { h, L, T, W } of rows) {
      if (!h || !Number.isFinite(W)) continue;
      for (const sc of [1, 1.25, 1.5, 1.75, 2]) {
        if (markerW != null) {
          if (Math.abs(W - Math.round(markerW * sc)) <= 3) { matches.add(h); break; }
        } else {
          const dx = Math.abs(L - Math.round(bounds.left * sc));
          const dy = Math.abs(T - Math.round(bounds.top * sc));
          const dw = Math.abs(W - Math.round(bounds.width * sc));
          if (dx <= 30 && dy <= 30 && dw <= 40) { matches.add(h); break; }
        }
      }
    }
    try { if (_widthChanged) await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width: _origW } }); } catch (e) {}
    if (_releaseWiggle) { try { _releaseWiggle(); } catch (e) {} }
    try { if (cdp) await cdp.detach(); } catch (e) {}
    // v1.9.303: the RELIABLE, wiggle-free path (the wiggle never worked on maximized windows — proven).
    // Windows owned by THIS profile's browser process ARE this profile's windows. If the profile has
    // exactly ONE such window, that IS this session's window, maximized or not — no geometry guessing.
    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 = null, how = '';
    if (!_geomBackoff && matches.size === 1) { resolved = [...matches][0]; how = markerW != null ? 'unique-width wiggle' : 'window bounds'; }
    else if (!_geomBackoff && _profWins.size === 1) { resolved = [..._profWins][0]; how = 'sole window of profile process (wiggle-free)'; }
    if (resolved && hwndOwnedByOtherSession(resolved, sessionId)) { console.log(`[hwnd] "${sessionId}" geometry picked ${resolved} but another session owns it — refusing`); resolved = null; }
    if (resolved) {
      session._hwnd = resolved;
      persistSessionHwnd(sessionId, resolved);
      console.log(`[hwnd] "${sessionId}" resolved OS handle ${resolved} — ${how}`);
      return resolved;
    }
    // v2.0.32: LAST-RESORT RE-CAPTURE for a bare BACKGROUND window (a window opened before the birth
    // fix, or re-attached by recovery/drag-out, so it has no __pupHwnd, on a crowded shared profile
    // where geometry cannot disambiguate — the raw-Chrome-icon case). Move THIS window's CDP target
    // to a UNIQUE far-off-screen slot, enumerate, grab the window now at that exact coordinate (its
    // identity by construction), then move it straight back on-screen at z-bottom. INVISIBLE because
    // the window is already background/z-bottom; NEVER done to a window the user is using. This gives
    // an on-screen window the same unambiguous handle a birth-time window gets.
    if (!userIsUsingWindow(session) && (!session._recaptureAt || Date.now() - session._recaptureAt > 30000)) {
      session._recaptureAt = Date.now();
      try {
        const tab0 = (session.tabs || [])[0];
        const pg = tab0 && tab0.page;
        if (pg) {
          const cdp2 = await pg.target().createCDPSession();
          try {
            const { windowId } = await cdp2.send('Browser.getWindowForTarget');
            const gb = await cdp2.send('Browser.getWindowBounds', { windowId });
            const orig = gb && gb.bounds;
            // v2.0.44 (mimic-ralph: re-capture failed SILENTLY on every recovered window): the old
            // probe matched the ABSOLUTE -33000 position, but this box CLAMPS off-screen coordinates
            // (the same clamp that forced birth capture to set-diff in 2.0.38) — the moved window
            // never appears at -33000, so the probe never matched, said nothing, and left the window
            // wherever the clamp put it. Identify by bounds CHANGE instead: snapshot every candidate
            // row, move OUR window via CDP, and the one hwnd whose rect changed is ours no matter
            // where Windows actually placed it. Then restore the ORIGINAL rect exactly.
            const _before = new Map((await enumChromeWidgetWindows()).map(r => [r.h, `${r.L},${r.T},${r.W},${r.H}`]));
            const probeLeft = -33000 - (Math.floor(Date.now() / 7) % 500);   // still aim off-screen: invisible when NOT clamped
            await cdp2.send('Browser.setWindowBounds', { windowId, bounds: { left: probeLeft, top: -33000, width: 900, height: 700 } });
            let got = null;
            for (let i = 0; i < 8 && !got; i++) {
              const rr = await enumChromeWidgetWindows();
              const moved = rr.filter(r => !hwndOwnedByOtherSession(r.h, sessionId) && _before.has(r.h) && _before.get(r.h) !== `${r.L},${r.T},${r.W},${r.H}`);
              if (moved.length === 1) got = moved[0].h;
              else if (moved.length > 1) { console.log(`[hwnd] "${sessionId}" re-capture saw ${moved.length} windows move at once — refusing (concurrent motion)`); break; }
              else await new Promise(r => setTimeout(r, 120));
            }
            if (!got) console.log(`[hwnd] "${sessionId}" re-capture probe: no unique moved window — leaving bare`);
            // restore the ORIGINAL rect exactly (never dump the window at 0,0 — that repositioning
            // was itself user-visible wonk when the clamp made the probe land on-screen)
            try { await cdp2.send('Browser.setWindowBounds', { windowId, bounds: orig && Number.isFinite(orig.width) ? { left: orig.left, top: orig.top, width: orig.width, height: orig.height } : { windowState: 'normal', left: 40, top: 40, width: 1200, height: 800 } }); } catch (e) {}
            if (got) {
              try { if (pg) pg.__pupHwnd = got; } catch (e) {}
              session._hwnd = got; persistSessionHwnd(sessionId, got);
              session._hwndFailStreak = 0; session._hwndBackoffUntil = 0;
              if (_releaseWiggle) { try { _releaseWiggle(); } catch (e) {} }
              try { if (cdp) await cdp.detach(); } catch (e) {}
              try { await cdp2.detach(); } catch (e) {}
              console.log(`[hwnd] "${sessionId}" resolved OS handle ${got} — off-screen RE-CAPTURE (background bare window, no birth handle)`);
              if (!userIsUsingWindow(session)) parkSessionWindowDirect(pg, sessionId).catch(() => {});
              return got;
            }
          } finally { try { await cdp2.detach(); } catch (e) {} }
        }
      } catch (e) { console.log(`[hwnd] "${sessionId}" re-capture probe failed: ${e.message}`); }
    }
    // Multiple windows on the profile and no unique wiggle match → we cannot know which is ours, so
    // leave it BARE rather than paint a guessed/shared handle (John's rule: bare > wrong). This is the
    // case the birth-time resolution (todo #5) will finally fix for multi-window profiles.
    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, ${matches.size} wiggle-matches; leaving bare + BACKING OFF resolution for 5 min (${session._hwndFailStreak} straight misses — stop hammering enumeration for an answer that isn't changing)`);
    } else {
      console.log(`[hwnd] "${sessionId}" NOT resolved — ${_profWins.size} windows on this profile, ${matches.size} wiggle-matches; leaving bare rather than guessing`);
    }
    return null;
  } catch (e) {
    if (_releaseWiggle) { try { _releaseWiggle(); } catch (x) {} }
    // 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) {}
    }
  }
}
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;
      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);
      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.
    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);
      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 pages are not user content.
        if (!u || u === 'about:blank' || /^(devtools|chrome|edge):/i.test(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;
        ns.owner = 'reclaimed';
        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) {}
        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)`);
  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;
    // 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.
    const THROTTLE_RECLAIM = 4000;
    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.

// v1.8.26: THE real "background by default" enforcement, done RIGHT. A freshly-launched
// --start-maximized browser grabs foreground; osSendWindowToBack only lowers z-order and
// SetForegroundWindow(userWindow) is BLOCKED by Windows' foreground lock — so the earlier
// attempts left pup on top, stealing focus. The reliable primitive: minimize the pup
// window (ShowWindow SW_MINIMIZE) — Windows then AUTOMATICALLY hands foreground to the
// user's previous window, no lock to fight — then re-show it WITHOUT activating
// (SW_SHOWNOACTIVATE) so it still composites for screenshots, and drop it to z-bottom.
// We only ever touch OUR OWN window, which is always allowed. SELF-VERIFYING: returns
// 'backgrounded' | 'still-foreground' | 'no-window' | 'error' by re-reading the
// foreground window after, so callers (and tests) know for CERTAIN whether it worked.
// cdpPort (the browser's CDP debug port) resolves the real browser via its LISTENING
// process — reliable across the launcher→browser pid relaunch that Edge/Chrome do.
async function osBackgroundWindowByPid(pid, cdpPort, restoreHwnd, respectUser, targetHwnd) {
  // v2.0.27: native. Resolve the pid tree's real frame (or use the caller's hwnd) and z-bottom it via
  // ab. respectUser maps to ab's force flag: an explicit lower verb may demote a foreground window
  // (deliberate user-requested action through the AI), a respectful one skips it. restoreHwnd/cdpPort
  // kept for signature compatibility; the native path never steals focus so there is nothing to restore.
  if (process.platform !== 'win32') return 'error';
  try {
    let target = Number(targetHwnd) || null;
    if (!target && pid) target = await largestChromeWindowOfPidTree(pid);
    if (!target) return 'no-window';
    const d = abPayload(await chrome.abCommand('desktop_set_window_state', { hwnd: Number(target), state: 'bottom', force: !respectUser }, { timeoutMs: 5000 }));
    return (d && (d.sentToBack || d.ok)) ? 'backgrounded' : 'error';
  } catch (e) { return 'error'; }
}

// 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.
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';
      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(() => {
  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);

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;
    };
    const moveOffScreen = async () => {
      try {
        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { windowState: 'normal' } });
        await cdp.send('Browser.setWindowBounds', { windowId, bounds: { left: 0, top: 0, width: 1920, height: 1040 } }); // v1.9.134: near-fullscreen fallback; the SetWindowPos park right after corrects to the TRUE screen size ($scrW/$scrH). v1.9.133 (John): park ON-SCREEN at bottom z-order, never off-screen — a taskbar click must always reveal a correctly-placed window
      } catch (e) {}
    };
    // 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.
    const zConfirmedBelow = async (targetHwnd) => {
      try {
        const r = await chrome.abCommand('desktop_list_windows', {}, { timeoutMs: 5000 });
        const raw = r && (typeof r.output === 'string' && r.output ? JSON.parse(r.output) : r.output || r);
        const ws = ((raw && (raw.data || raw)).windows) || [];
        if (!ws.length) return false;
        const mine = ws.find(w => Number(w.hwnd) === Number(targetHwnd));
        if (!mine) return false;
        if (Number(mine.z) === 0) return false;
        // at least one NON-pup window above ours
        return ws.some(w => Number(w.z) < Number(mine.z) && !/\((?:session|ai-thread): /.test(w.title || ''));
      } catch (e) { return false; }
    };
    // 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.
        const r = await chrome.abCommand('desktop_set_window_state', { ...(await windowTarget(sessions.get(sessionId), sessionId)), 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 }; }
    };
    let bottomed = false, lastErr = 'error', parkHwnd = null;
    for (let attempt = 0; attempt < 8; attempt++) {
      const z = await adBottom();
      if (z.ok) { bottomed = true; parkHwnd = z.hwnd; break; }
      lastErr = z.err || 'error';
      // Two cases need the legacy PS path (BOTH auto-obsolete as desktops update to AD ≥1.9.115,
      // whose force:true is honored above — delete this block once the fleet is there):
      //  (a) older AD that rejects state:'bottom' entirely;
      //  (b) AD 1.9.114 exactly: has state:'bottom' but ignores force and SKIPS a foreground
      //      window (wasForeground:true, sentToBack:false). At LAUNCH, foreground is NOT a user
      //      signal (post-restart foreground reassignment — the v61-3 case), so force via PS.
      if (z.wasForeground || /invalid|unknown|must be one of|maximize\|minimize/i.test(lastErr)) {
        let hwnd = null;
        try {
          const r = await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 4000 , caller: sessionCaller(sessions.get(sessionId))});
          const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || r);
          hwnd = (o && ((o.best && o.best.hwnd) || o.hwnd)) || null;
        } catch (e) {}
        {
          const zr = hwnd ? await osBottomUnlessForeground(hwnd, true, sessions.get(sessionId)) : 'error';
          if (zr === 'bottomed') { bottomed = true; parkHwnd = hwnd; break; }
          // v2.0.26: the user raised it while we were parking — respect it, remember it, stop parking.
          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));
    }
    if (!bottomed) {
      console.log(`[park] "${sessionId}": z-bottom NOT confirmed (${lastErr}) — leaving off-screen (no pop), self-heal will retry`);
      return 'park-failed';
    }
    // On-screen full-size WITHOUT activation — AD MoveWindow preserves z-order (we're at bottom).
    if (!parkHwnd) {
      try {
        const r = await chrome.abCommand('desktop_find_window', { titleContains: `(${TITLE_LABEL}: ${sessionId}` }, { timeoutMs: 4000 , caller: sessionCaller(sessions.get(sessionId))});
        const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || r);
        parkHwnd = (o && ((o.best && o.best.hwnd) || o.hwnd)) || null;
      } catch (e) {}
    }
    if (!parkHwnd) { console.log(`[park] "${sessionId}": no hwnd for placement — leaving off-screen, self-heal will retry`); return 'park-failed'; }
    // v1.9.212 — STORE THE HWND ON THE SESSION. This is the fix John has been waiting for me to
    // actually make instead of describing. Everything downstream (taskbar paint, park, resolution)
    // has been finding windows by their "(session: <id>)" TITLE TAG, and Chrome's PDF viewer cannot
    // carry that tag — so on a PDF window every one of those lookups failed: no badge painted,
    // park-failed, and the reclaim janitor kept re-adopting the window as yet another session
    // (five of them by now, all fighting over one taskbar button). A window handle is stable and
    // title-independent, so hold onto it here where we have already resolved it.
    try { const _s = sessions.get(sessionId); if (_s) { _s._hwnd = Number(parkHwnd); persistSessionHwnd(sessionId, Number(parkHwnd)); } } catch (e) {}
    // Place on-screen full-size (CDP plain bounds — the only move that doesn't raise), then
    // RE-BOTTOM (trust nothing) and CONFIRM the z-order. Unconfirmable → back off-screen.
    const placed = await placeOnScreen();
    if (!placed) {
      console.log(`[park] "${sessionId}": screen-size probe failed — leaving off-screen, self-heal will retry`);
      return 'park-failed';
    }
    let zOk = false;
    for (let i = 0; i < 2 && !zOk; i++) {
      await adBottom(); // re-assert after the move, every time
      zOk = await zConfirmedBelow(parkHwnd);
    }
    if (!zOk) {
      console.log(`[park] "${sessionId}": Z-ORDER UNCONFIRMED after placement — moving back OFF-SCREEN (never leave a window that might cover the user)`);
      await moveOffScreen();
      return 'park-failed';
    }
    // Verify POSITION too (a stuck-off-screen window must not read as parked).
    let verified = false;
    try {
      const gb = await cdp.send('Browser.getWindowBounds', { windowId });
      const b = gb && gb.bounds;
      verified = !!(b && b.left > -1000 && b.top > -1000);
    } catch (e) {}
    return verified ? 'backgrounded' : 'park-failed';
  } 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;
  opts = opts || {};
  if (process.platform !== 'win32' || !sessionId) return null;
  // 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 resolveSessionHwndByBounds(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
let _hwndWiggle = 0;   // v1.9.290: monotonic-ish counter for unique resolution widths
let _wiggleLock = Promise.resolve();   // v1.9.291: serialize wiggle resolves (enforcer fires them concurrently)
// 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_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']);
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 (pupSettings().autoFlash !== 'on') return;   // v1.9.259: explicit pref (default off = old minimal-touch behavior)
  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).
  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()');
  }

  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',
      '--disable-features=WinLaunchOnLogon,LaunchChromeOnWindowsLogon,StartupBoost,BeginBrowsingInstantly,DesktopStartupPromo,IPH_LaunchOnStartup',
      '--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`);
  }

  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) {
        console.log(`Profile "${profileName}" has a lock file — 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.
      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.
      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 = [
        ...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();
    const _idExtDir = (path.basename(userDataDir || '') === 'adom-you') ? await stageIdentityExtension() : 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) {
      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 {
            browser = await puppeteer.launch(launchOpts);
          }
          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 });

  // 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).
  const birthLeft = -32000 - ((_birthSeq++ % 60) * 220);   // v2.0.35: 220px apart (was 40) so set-diff's nearest-match can't cross-wire adjacent births
  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 () => {
      let beforeHwnds = new Set();
      if (process.platform === 'win32') { try { beforeHwnds = new Set((await enumChromeWidgetWindows()).map(r => Number(r.h))); } catch (e) {} }
      await cdp.send('Target.createTarget', { url: 'about:blank', newWindow: true, left: birthLeft, top: -32000, width: 1280, height: 800 });
      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') {
        for (let i = 0; i < 20 && !capturedHwnd; i++) {
          const rows = await enumChromeWidgetWindows();
          const fresh2 = rows.filter(r => !beforeHwnds.has(Number(r.h)) && !hwndOwnedByOtherSession(r.h, null));
          if (fresh2.length === 1) { capturedHwnd = fresh2[0].h; break; }
          if (fresh2.length > 1) {
            // more than one appeared despite the lock (a sibling thread's stuck-off-screen leftover
            // enumerated late): prefer the off-screen one, else the one nearest my birthLeft.
            const off = fresh2.filter(r => r.T < -10000);
            const pool = off.length ? off : fresh2;
            let best = pool[0].h, bestErr = 1e9;
            for (const r of pool) for (const sc of [1, 1.25, 1.5, 1.75, 2]) { const e = Math.abs(r.L - Math.round(birthLeft * sc)); if (e < bestErr) { bestErr = e; best = r.h; } }
            capturedHwnd = best; break;
          }
          await new Promise(r => setTimeout(r, 120));
        }
      }
    });
    // Resolve the OS handle NOW, by the unique off-screen birth position, before park moves it on-
    // screen. Parked siblings are at positive L; only newborns sit far off-screen, so "the off-screen
    // window of this profile at ~birthLeft" is unambiguous. Stash it on the page for the resolver.
    if (fresh && capturedHwnd) { fresh.__pupHwnd = capturedHwnd; console.log(`[hwnd] birth-resolved new window on "${profileName}" -> ${capturedHwnd} (locked set-diff, birthLeft ${birthLeft})`); }
    else if (fresh) console.log(`[hwnd] birth capture on "${profileName}" produced no handle — will resolve later`);
    try {
      const nowFg = await osGetForegroundWindow();
      // Diagnostic only. The playbook (dev-skills/pup-bridge-dev, SOLVED PLAYBOOK) says this can
      // never show a steal: with --no-startup-window the browser is a background process and the OS
      // denies it the foreground. Measured TRUE on 2026-07-31. If this line EVER logs a steal, the
      // playbook assumption broke - investigate, do not add re-park loops (they are banned there).
      if (prevFg && nowFg && String(nowFg) !== String(prevFg)) console.log(`[focus] MEASURED: createTarget(newWindow) moved the OS foreground (${prevFg} -> ${nowFg}) — PLAYBOOK ASSUMPTION BROKEN, investigate`);
    } catch (e) {}
    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
  }

  // 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 (pupSettings().openFlash === 'on') { try { _flashState.set(sessionId, Date.now()); _openFlashDone.set(sessionId, Date.now()); console.log(`[flash] "${sessionId}" (launch)`); flashViaAD(sessionId, { force: true }); } catch (e) {} }
  }

  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
  // 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;
  sessions.set(sessionId, session);
  // 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;
}

// Get active session, fallback to first, or return null.
// v1.9.92 (issue #16): NON-DESTRUCTIVE. This is a RESOLVER, not a reaper — it must never delete from
// the session map. The old version deleted every session whose isSessionAlive() was false, with no
// `_lostBrowser` guard (unlike resolveSession, which deliberately keeps disconnected entries for
// rescan). So after a transient disconnect, ONE call to this function silently wiped the ENTIRE
// session map — destroying the exact entries handleBrowserDisconnect had just preserved and told the
// user to recover with pup_rescan. That is the reported bug: pup_list_windows returns 0
// windows while the windows are still open and drivable by sessionId, and rescan can't help because
// the sessionId→profile records it needs are gone from memory. Mark, don't delete; removal belongs to
// the close and rescan paths.
function getActiveSession() {
  if (activeSessionId && sessions.has(activeSessionId)) {
    const s = sessions.get(activeSessionId);
    if (isSessionAlive(s)) return s;
    s._lostBrowser = true;                 // keep the entry so pup_rescan can recover it
  }
  for (const [id, s] of sessions) {
    if (isSessionAlive(s)) {
      activeSessionId = id;
      return s;
    }
    s._lostBrowser = true;                 // was: sessions.delete(id) — the map-wiping bug
  }
  activeSessionId = null;
  return null;
}


// 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.
    payload = {
      error: `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',
      currentSessions,
      liveSessions,
      disconnectedSessions,
      _hint:
        `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.
function sendSessionResolveError(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 });
}

// 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.
setInterval(async () => {
  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);
    }
  }
}, 30_000);

// 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'], pitfalls: [] },
  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_recorder_open: { hint: 'Opens the desktop screen-recorder UI window.', related: ['desktop_record_start', 'desktop_recorder_close'], pitfalls: [] },
  desktop_record_start: { hint: 'Starts a full desktop screen recording.', related: ['desktop_record_stop', 'desktop_record_status'], pitfalls: ['only one desktop recording at a time'] },
  desktop_record_stop: { hint: 'Stops the desktop recording and returns the file.', related: ['desktop_record_start', 'desktop_record_list'], pitfalls: [] },
  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.
const PUP_ISSUES_REF = 'adom/adom-desktop-puppeteer-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 = 'on';
    try { mode = (pupSettings().autoLogin === 'off') ? 'off' : 'on'; } 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;
    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 form for "${creds.host}" from your saved credential (username ${creds.username}) — in the BACKGROUND, NOT submitted, no Chrome dropdown, no foregrounding. To sign in: pup_login {sessionId,submit:true} or click the submit button yourself. Wrong form? pup_configure {autoLogin:"off"} turns this off.`;
    }
  } 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(() => {
        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,
          brokenImgs: document.images
            ? Array.from(document.images).filter(i => i.complete && i.naturalWidth === 0).length : 0,
        };
      }),
      new Promise(r => setTimeout(() => r(null), 4000)),
    ]);
    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 (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.)`;
}
function maybeAttachBugInvite(obj, verb) {
  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.
    maybeAttachBugInvite(obj, res && res._adomCommand);
    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;
          // 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.125: surface any background auto-login note.
              if (res && res._autoLoginNote && inner._autoLogin === undefined) { inner._autoLogin = res._autoLoginNote; touched = true; }
              // 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;
      })(),
      overlayLed: (() => {
        const painted = !!sess._appOverlay;
        const hasBytes = (() => { try { return fs.existsSync(path.join(require('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'; });
    btn.led = leds.includes('red') ? 'red' : leds.includes('amber') ? 'amber' : 'green';
    btn.ledWhy = 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() },
    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.
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}
a{color:#64abff}
header{position:sticky;top:0;z-index:10;background:#161b22;border-bottom:1px solid #30363d;height:44px;padding:0 12px;display:flex;gap:10px;align-items: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}

/* 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}
#dlgWrap{position:fixed;inset:0;z-index:99998}
#dlgBg{position:absolute;inset:0;background:rgba(0,0,0,.55)}
#dlg{position:absolute;top:60px;left:50%;transform:translateX(-50%);width:min(560px,92vw);max-height:calc(100vh - 120px);overflow:auto;background:#161b22;border:1px solid #30363d;border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.6)}
.dlghead{position:sticky;top:0;display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:#161b22;border-bottom:1px solid #30363d;font-family:"Familjen Grotesk",Satoshi,sans-serif;font-weight:600;color:#e6edf3}
#dlgBody{padding:6px 16px 16px}
.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}
.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{flex:0 0 auto;width:40px;height:22px;border-radius:999px;background:#21262d;border:1px solid #30363d;position:relative;cursor:pointer;transition:background .15s ease;margin-top:2px}
.tgl::after{content:"";position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:#8b949e;transition:left .15s ease,background .15s ease}
.tgl.is-on{background:#00b8b1;border-color:#00b8b1}
.tgl.is-on::after{left:20px;background:#05221f}
.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}
.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="3" y="8" width="13" height="12" rx="2"/><rect x="10" y="4" width="11" height="8" rx="2" opacity=".45"/><path d="M5 15h2l1.4-3 2 5 1.6-2h2"/></svg>
    <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/adom-desktop-puppeteer-bridge" target="_blank" rel="noopener">wiki: adom-desktop-puppeteer-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="gear" title="pup 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?" title="close every pup window" aria-label="close all windows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
  </div>
</header>
<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 / session..." size="20">
</div>
<div id="toasts"></div>
<div id="dlgWrap" class="hidden">
  <div id="dlgBg"></div>
  <div id="dlg" role="dialog" aria-label="pup settings">
    <div class="dlghead">pup settings<button class="ibtn" id="dlgClose" title="close settings" aria-label="close"><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 id="dlgBody"></div>
  </div>
</div>
<div id="taskbar"></div>
<main id="m"><div class="empty">connecting...</div></main>
<footer id="ft"></footer>
<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 session 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');
}
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','regen-overlays':'regenerated all overlay icons','import-scan':'scanned your browsers for logins','import-run':'import'};
async function act(p){
  try{
    const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p)});
    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=>{
    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:'aumidIcons',l:'AUMID taskbar icons',info:'AUMID = Application User Model ID, a feature built into Windows itself. Every taskbar button is really a GROUP: Windows collects windows that share the same ID onto one button. Normally all windows of one app share the app ID, which is why 5 Chrome windows stack under one Chrome button. But the ID can be set PER WINDOW: pup writes a unique ID onto each pup window using the Windows property system (the same mechanism apps like Office use), and Windows then gives that window its own taskbar button with its own icon. The catch, and why this is an experiment: whenever a window ID changes, Windows destroys and recreates its taskbar button, and that churn is a suspect in the Chrome crashes. pup stamps each window exactly once, at creation, to minimize it.',d:'EXPERIMENT: give every pup window its OWN taskbar icon by stamping a per-window identity. Toggle on/off to test whether AUMID stamping is what crashes Chrome. Off = all windows share one Chrome button (current mode). Already-stamped windows keep their icon until closed.',img:'aumid'},
 {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 — e.g. 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'],dep:'aumidIcons',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'],dep:'aumidIcons',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. Off by default. Quirk: the FIRST right-click can blip the menu closed — right-click again ~1s 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},
 {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},
 {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.'},
 {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.'},
 {k:'tbOwnerBadge',l:'',d:'Per-tab colored badge on the toolbar icon showing which AI thread owns the window.',dep:'toolbarExtension',subtgl:1},
 {k:'tbSnipHotkey',l:'',d:'Alt+Shift+A starts annotate (red pen) on the current pup window.',dep:'toolbarExtension',subtgl:1},
 {k:'tbFloatyPencil',l:'',d:'The tiny pencil in the upper-right corner of each page: one click starts annotate. Applies to newly loaded pages.',dep:'toolbarExtension',subtgl:1},
 {k:'floatyHitPx',l:'',d:'Pencil corner hit area (px): how big the invisible top-right target is that reveals the pencil. Applies to open pages immediately.',slider:[6,120,2],dep:'tbFloatyPencil'},
 {k:'fgWatch',l:'20s foreground watch',d:'For 20s after a window opens or navigates in the background, watch whether something (JavaScript, Chrome, a focus change) brings it to the foreground, and caption that it was not Adom. Never moves the window.'},
 {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.'},
 {k:'fgCaptionMs',l:'',d:'',slider:[500,5000,250],dep:'fgCaption',test:1},
 {k:'overlayBadges',l:'Favicon overlay badges',d:'Paint each pup taskbar icon with the favicon of its active tab plus the Adom mark.'},
 {k:'openFlash',l:'Taskbar flash on new window',d:'Flash the taskbar button (the Windows attention flash; accent-colored, salmon on your theme) when a new pup window opens, so you can spot it.',testflash:1},
 {k:'autoFlash',l:'Taskbar flash on agent activity',d:'Flash a window taskbar button when an AI changes that window (navigate, click, type). One flash per burst.'},
 {k:'agentActivity',l:'Taskbar progress bar',d:'Show a progress strip on a window taskbar button while an AI is actively driving it.'},
 {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 (never submit) a matching saved login when a login page loads in a pup window.'},
 {k:'reportIssues',l:'Issue-report hints',d:'Let pup invite AIs to file a wiki issue when they hit something broken.'},
 {k:'titleTag',l:'Session tag in the window title',d:'Append "(ai-thread: <name>)" to each window title so pup (and you) can tell windows apart from the title bar. Off by default because it can look like the page mangled its own title; pup still resolves windows without it (transiently, while off-screen).'},
 {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.'},
 {k:'notMineCaption',l:'"Not foregrounded by Adom" caption',d:'Low importance. When a window comes to the front on its own (JavaScript, a focus change, Chrome), briefly caption that Adom did NOT do it. It stays SILENT when YOU raised the window (detected via recent taskbar/keyboard input), so your own clicks never trigger it. Default on.'},
 {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 (jlauer12@gmail.com vs john@adom.inc vs media@adom.inc) 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');
}
function renderDlg(){
  const st=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};
  _q('#dlgBody').innerHTML=SETTINGS_DEF.map(def=>{
    const _imgName=def.imgBy?def.imgBy[st[def.k]]||def.imgBy[Object.keys(def.imgBy)[0]]:def.img;
    const exImg=_imgName?'<img class="sexample" src="/dashboard/example?n='+_imgName+'&v='+PAGE_VERSION+'" alt="example" data-fallback="hide">':'';
    if(def.slider){
      const on=st[def.dep]==='on';
      const v=Number(st[def.k])||3500;
      return '<div class="sub'+(on?'':' sdis')+'" id="row-'+def.k+'"><span class="sdesc">caption duration</span>'
        +'<input type="range" min="'+def.slider[0]+'" max="'+def.slider[1]+'" step="'+def.slider[2]+'" value="'+v+'" data-slider="'+def.k+'" aria-label="caption duration">'
        +'<span class="sval" id="val-'+def.k+'">'+(v/1000).toFixed(1)+'s</span>'
        +(def.test?'<button class="btn sm" data-testcap="1" title="show a sample caption on your screen right now, at the configured duration">test</button>':'')+'</div>';
    }
    if(def.subtgl){
      const pOn=st[def.dep]==='on';
      const on=st[def.k]==='on';
      return '<div class="sub'+(pOn?'':' sdis')+'" id="row-'+def.k+'"><div style="flex:1 1 auto;min-width:0"><span class="sdesc">'+esc(def.d)+'</span>'+exImg+'</div>'
        +'<div class="tgl'+(on?' is-on':'')+'" role="switch" tabindex="0" aria-checked="'+on+'" aria-label="'+esc(def.d)+'" data-tgl="'+def.k+'"></div></div>';
    }
    if(def.sel){
      const on=!def.dep||st[def.dep]==='on';
      return '<div class="sub'+(on?'':' sdis')+'" id="row-'+def.k+'"><div style="flex:1 1 auto;min-width:0"><span class="sdesc">'+esc(def.d)+'</span>'+exImg+'</div>'
        +'<select data-sel="'+def.k+'" aria-label="'+esc(def.l)+'">'+def.sel.map(x=>'<option'+(st[def.k]===x?' selected':'')+'>'+x+'</option>').join('')+'</select></div>';
    }
    const on=st[def.k]==='on';
    const testRow=def.testflash?'<div class="sub"><span class="sdesc">'+((SNAP&&SNAP.counts&&SNAP.counts.windows)||0)+' pup window(s) open right now; their taskbar buttons will flash. Note: Windows never flashes the button of the window you are LOOKING AT, so if this dashboard is your active window its own button stays unlit.</span>'
      +'<button class="btn sm" data-act="flash-all" title="flash every open pup taskbar button so you can see what this looks like">test flash</button>'
      +'<button class="btn sm" data-act="unflash-all" title="clear every taskbar flash">clear flash</button></div>':'';
    return '<div class="srow"><div class="stxt"><div class="slbl">'+esc(def.l)+(def.info?' <span class="ibadge" data-tip="'+esc(def.info)+'">i</span>':'')+'</div><div class="sdesc">'+esc(def.d)+'</div>'+exImg+testRow+'</div>'
      +'<div class="tgl'+(on?' is-on':'')+'" role="switch" tabindex="0" aria-checked="'+on+'" aria-label="'+esc(def.l)+'" data-tgl="'+def.k+'"></div></div>';
  }).join('')+importSectionHtml();
}
async function setSetting(key,value){
  const j=await act({action:'set-setting',key,value});
  if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;renderDlg();}
}
document.addEventListener('click',ev=>{
  if(ev.target.closest('#gear')){renderDlg();_q('#dlgWrap').classList.remove('hidden');return;}
  if(ev.target.closest('#dlgClose')||ev.target.id==='dlgBg'){_q('#dlgWrap').classList.add('hidden');return;}
  const t=ev.target.closest('[data-tgl]');
  if(t){setSetting(t.dataset.tgl,t.classList.contains('is-on')?'off':'on');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=(Number(r.value)/1000).toFixed(1)+'s';}
});
document.addEventListener('change',ev=>{
  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');
</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') {
    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").
          const ms = Math.max(500, Math.min(5000, Number(pupSettings().fgCaptionMs) || 3500));
          pupCaption(`This is what an AI-foreground caption looks like. Reason: "you clicked Test in pup settings" (${(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 chrome.abCommand('desktop_decrypt_pup_credentials',
              { browsers, include: ['passwords'] }, { timeoutMs: 180000 });
          } 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 === 'set-setting') {
          // v1.9.259: the dashboard settings dialog. Whitelisted keys only; values validated.
          const ALLOWED = {
            aumidIcons: ['on', 'off'], jumpLists: ['on', 'off'], fgWatch: ['on', 'off'],
            fgCaption: ['on', 'off'], overlayBadges: ['on', 'off'], autoFlash: ['on', 'off'],
            openFlash: ['on', 'off'], sleepRecovery: ['on', 'off'], agentActivity: ['on', 'off'],
            reportIssues: ['on', 'off'], autoLogin: ['on', 'off'], minimalTouch: ['on', 'off'],
            taskbarGrouping: ['split', 'grouped'],
            aumidIconStyle: ['standard', 'category'],
            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;
          if (key === 'floatyHitPx') {
            val = Math.max(6, Math.min(200, Number(val) || 24));
          } else if (key === 'fgCaptionMs') {
            val = Math.max(500, Math.min(5000, Number(val) || 2000));
          } 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 === 'openFlash' ? { [key]: val, _openFlashChosen: true, _chosen: _ch } : { [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'].includes(key)) reconcileModel('setting ' + key).catch(() => {});
          if (['jumpLists', 'jlViewTasks', 'jlCloseAll', 'jlDashboard', 'jlAnnotate'].includes(key)) refreshAllJumplists().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') {
            (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' });
      res.end('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
        + '<rect x="1" y="1" width="30" height="30" rx="7" fill="#0d1117" stroke="#30363d" stroke-width="1"/>'
        + '<rect x="13" y="5" width="14" height="11" rx="2.2" fill="none" stroke="#e6edf3" stroke-width="1.6" opacity="0.45"/>'
        + '<rect x="5" y="11" width="17" height="15" rx="2.6" fill="none" stroke="#e6edf3" stroke-width="1.8"/>'
        + '<path d="M7.5 19 H10 L11.5 15.5 L13.5 22.5 L15.5 19 H19" fill="none" stroke="#e6edf3" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/>'
        + '</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-z]/g, '');
        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; }
    }
    if (pathname === '/dashboard/stampicon') {
      // 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 = icoLargestPng(fs.readFileSync(pupIconPath(sid)));
        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/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.
        sessionList.push({ sessionId: id, url: pageUrl, active: id === activeSessionId, owner: 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',
        version: BRIDGE_VERSION,
        port: PORT,
        toolbar: { enabled: pupSettings().toolbarExtension !== 'off', ownerBadge: pupSettings().tbOwnerBadge !== 'off', snipHotkey: pupSettings().tbSnipHotkey !== 'off', floaty: pupSettings().tbFloatyPencil !== 'off' },
        browserCount: browsers.size,
        sessionCount: sessions.size,
        activeSession: activeSessionId,
        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.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; }
          }
        }
      }
      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);
        const OWNER_GATED = !_isUserTaskbar && !_isGlobalClose && (MUTATING_VERBS.has(command)
          || ['pup_close_tab', 'pup_close_window', 'pup_close', 'pup_configure', '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] || {};
            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', verbs: [
            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', 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, which is LOGGED IN as the user if they have signed into the wiki (the usual case — one shared login serves every window); this is NOT anonymous. (2) "public" = an ISOLATED anonymous jar, genuinely LOGGED OUT — exactly what the world sees; use this to verify a publish did not leak private source. (3) "authed" = the explicit logged-in vault 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" — do NOT rely on omit for the public view, it shows the logged-in shared profile.', 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 — owner AI thread, container, profile, tabs, age, last agent action — AND (Windows) a native always-on-top caption card just above the taskbar showing it to the user (ab desktop_caption, click-through, ~12s). This is the callback behind the per-window jump-list task "Owned by: <thread>"; AI threads may also call it to identify an unfamiliar window before deciding whether to open their own. Read-only, never owner-gated.', { sessionId: 'optional str (default: active session)' }, 15, { sessionId: 'work' }),
            V('pup_wiki_set_view', 'Flip a WIKI pup window between the logged-in and public view IN PLACE (relaunches the same window under the other cookie jar). This is the callback for the per-window taskbar right-click jump-list task (WINDOWS ONLY — macOS has no taskbar or jump list, so this callback never fires there; flip views with pup_open_window {wikiView} instead) — it is a USER-initiated action (the window is brought on-screen). AI threads should NOT call this; pass wikiView:"authed"|"public" on pup_open_window instead.', { sessionId: 'required str', view: 'required "authed"|"public"' }, 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_reload', 'Reload the active tab.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),
            V('pup_click', 'Click an element (by selector or coords).', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int' }, 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. Pass clear:true to REPLACE the existing value instead of appending (this is what you want on any prefilled/React form — it selects-all then deletes with real key events, which React-controlled inputs honour, unlike setting .value).', { 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_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"). 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', 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; returns the result.', { sessionId: 'required str', expression: 'required str' }, 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_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('desktop_recorder_open', 'Open the desktop screen-recorder UI.', {}, 20, {}),
            V('desktop_record_start', 'Start a desktop screen recording.', { outPath: 'optional str' }, 20, {}),
            V('desktop_record_stop', 'Stop the desktop screen recording; returns the file.', {}, 30, {}),
            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 vault (matched on the tab URL; store via credential_set or save:true) or passed explicitly. Passwords are NEVER echoed back. Vault-matched login pages also AUTO-FILL on load (_autoLogin on the response; pup_configure {autoLogin:"off"} disables). Pairs with the durable shared profile: sign in once, every window stays signed in.', { sessionId: 'required str', username: 'optional str', password: 'optional str — with username, fills these instead of the vault', submit: 'optional bool — click the submit button after filling (default false)', 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' }, 20, { sessionId: 'work', submit: 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('desktop_record_status', 'Status of the desktop screen recorder + any active clip.', {}, 10, {}),
            V('desktop_record_list', 'List completed desktop recordings on disk.', {}, 10, {}),
            V('desktop_recorder_close', 'Close the desktop screen-recorder UI.', {}, 15, {}),
            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"', agentActivity: 'optional "on"|"off" — the taskbar progress bar marking which window a thread is driving', 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' }, 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': {
          // 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}.` });
                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; }
          if (args.wikiView === 'public' && isAdomUrl(args.url)) args.isolated = true;
          if (args.isolated === true) {
            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;
            const _wantOwner = _aiThread || _explicitOwner;   // ai-thread PRIMARY; owner arg is the legacy alias
            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}`);
          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) {
            if (!session._ownerThread) { session._ownerThread = _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) {}
            // v1.8.21: ALSO pin the page VIEWPORT (CDP device-metrics) to width×height.
            // The OS-window bounds alone don't guarantee a matching inner viewport (DPI /
            // window-state quirks left it at ~170px on some laptops); page.setViewport makes
            // innerWidth/innerHeight deterministic, which is what fullPage/element shots need.
            try { await session.page.setViewport({ width: Math.round(w), height: Math.round(h), deviceScaleFactor: Number(args.deviceScaleFactor) || 1 }); } 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.`;
          // 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,
              // 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',
              // 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,
              // 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': {
          // 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;
          }
          // v1.9.57: agentActivity 'on'|'off' — the taskbar progress bar that marks which window a
          // thread is currently driving. On by default; this is the user's taskbar, so give them the
          // switch rather than assuming everyone wants the motion.
          const aa = args.agentActivity;
          if (aa !== undefined && !['on', 'off'].includes(aa)) {
            sendJSON(res, { success: false, error: `agentActivity must be one of on|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: autoLogin on|off — pup auto-filling a vault-matched login form on page load.
          const al = args.autoLogin;
          if (al !== undefined && !['on', 'off'].includes(al)) {
            sendJSON(res, { success: false, error: `autoLogin must be one of on|off` });
            return;
          }
          // 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 (aa) {
            savePupSettings({ agentActivity: aa });
            console.log(`[configure] agentActivity -> ${aa}`);
            if (aa === 'off') { for (const [sid] of _busyState) { setSessionProgress(sid, false).catch(() => {}); } _busyState.clear(); }
          }
          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(() => {});
          }
          // 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 ? '' : ' Both taskbarGrouping and agentActivity are Windows-only in effect; they are stored but inert here.'}`;
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, settings: pupSettings(), platform: process.platform, taskbarSupported: _isWin, _hint: _gHint }) });
          return;
        }
                case 'pup_rescan': {
          // 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;
            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': {
          const sessionId = args.sessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            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: decide the kill from GROUND TRUTH, not the cached refCount. The old code killed the
          // profile's Chrome whenever `be.refCount` hit 0. That counter DRIFTS: a session record that
          // was transiently lost / being restored (_lostBrowser), or one whose increment was missed,
          // leaves refCount at 0 while ANOTHER session is still open on the same SHARED profile and
          // actively receiving verbs. Result: closing session A (e.g. ralph-reap) force-killed the
          // shared "adom-you" Chrome out from under live session B (cowboy-order). So never let the
          // refcount alone authorize a destructive kill: count the live session map (this session was
          // already deleted above) for ANY entry on this profile, and refuse while a verb is in flight.
          let _reap = { killed: 0, remaining: 0 };
          const be = browsers.get(profileName);
          if (be) {
            be.refCount = Math.max(0, be.refCount - 1); // keep the counter honest for telemetry only
            // Any OTHER session still bound to this profile? Includes _lostBrowser entries on purpose —
            // rescan may re-adopt the still-open window, so a disconnected record must still veto the kill.
            let _otherSessions = 0;
            for (const _s of sessions.values()) { if (_s.profileName === profileName) _otherSessions++; }
            // Any verb in flight on this profile right now?
            let _busyOnProfile = 0;
            for (const [_sid, _st] of _busyState) {
              if (_st && _st.busy) { const _bs = sessions.get(_sid); if (_bs && _bs.profileName === profileName) _busyOnProfile++; }
            }
            if (_otherSessions === 0 && _busyOnProfile === 0) {
              console.log(`No more sessions using profile "${profileName}" — killing browser`);
              _reap = await killBrowserProcess(be.browser, profileName);
              browsers.delete(profileName);
            } else {
              console.log(`Keeping browser for profile "${profileName}" — ${_otherSessions} other session(s)${_busyOnProfile ? ` and ${_busyOnProfile} verb(s) in flight` : ''} still using it (refCount=${be.refCount}); NOT killing`);
            }
          }

          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`);
          let captionShown = false;
          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) {}
          sendJSON(res, {
            success: true, sessionId: sid, owner: ownT, ownerContainer: session._ownerContainer || null,
            profile: session.profileName || null, ageMinutes: ageMin, tabs, lastAgentUpdate: lu,
            foreground: !!session._foreground, lastTakeoverBy: session._lastTakeoverBy || null,
            captionShown,
            _hint: captionShown
              ? 'A native card with this info is showing just above the taskbar for ~12s (click-through, auto-dismisses).'
              : 'Caption could not be shown (desktop_caption unavailable on this ab?) — the same info is in this JSON.',
          });
          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 view = args.view === 'authed' ? 'authed' : 'public';
          if (!sessionId || !sessions.has(sessionId)) { sendJSON(res, { success: false, error: `Session "${sessionId}" not found` }); return; }
          const session = sessions.get(sessionId);
          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';
          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
          }
          // 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(() => {});
          // Close the current window (mirror pup_close_window) so we can relaunch under the
          // other cookie jar.
          const oldProfile = session.profileName;
          const _closingAppId = session._curAppId || `${PUP_APP_ID}.${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(() => {});
          const be = browsers.get(oldProfile);
          if (be) {
            be.refCount = Math.max(0, be.refCount - 1);
            if (be.refCount <= 0) { try { await killBrowserProcess(be.browser, oldProfile); } catch (e) {} browsers.delete(oldProfile); }
          }
          // Relaunch the SAME sessionId under the new profile at the same URL.
          const newProfile = view === 'authed' ? DEFAULT_PROFILE : ('iso-' + sessionId + '-pub');   // v1.9.162: authed = the ONE shared jar (accumulate); public = a clean logged-out jar
          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 sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          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;
          // v1.8.26: only raise the OS window for a session the user is watching. For a
          // background window, just track the active tab (pup targets tabs by id).
          if (session._foreground) { try { await tab.page.bringToFront(); } catch {} }
          // 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(() => {});
            }
          }
          const info = await describeTab(tab, session.activeTabId);
          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info }) });
          return;
        }
        case 'pup_close_tab': {
          const sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          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 sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          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 sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          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).
            const r = await flashViaAD(session._sessionId || sessionId, { stop });
            let applied = false;
            try { const o = r && (typeof r.output === 'string' ? JSON.parse(r.output) : r.output || 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.' : 'AD desktop_flash_window did not match the window (title changed? window closed?).');
            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 sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          // 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) {} }
            ok = (be && be.browser) ? ((await osBackgroundWindowByPid(be.browser._detachedPid, be.browser._cdpPort, be.browser._priorForeground, false, session._hwnd)) === 'backgrounded') : await osSendWindowToBack(session.profileName);
          }
          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', 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 sessionId = args.sessionId || activeSessionId;
          if (!sessionId || !sessions.has(sessionId)) {
            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });
            return;
          }
          const session = sessions.get(sessionId);
          // 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}`);
          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: show the caller's justification on screen for ~1s, so a foreground is never
          // silent and John can see WHY it happened as it happens.
          captionForegroundReason(sessionId, fgReason, command).catch(() => {});
          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, [
                    '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);',
                    '  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);',
                    '  }',
                    '}',
                    '"@',
                    `[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 = [
                'Add-Type @"',
                'using System; using System.Runtime.InteropServices;',
                'public class WinFocus {',
                '  [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);',
                // v1.8.32: 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);',
                '  }',
                '}',
                '"@',
                `$targetPid = ${pid}`,
                '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',
                'if ($p -and $p.MainWindowHandle -ne 0) {',
                '  [WinFocus]::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) {',
                '      [WinFocus]::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 }) });
          } 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();
          let screenshotBuf = await withTimeout(
            raceCrash(tab, resolvedId, tab.page.screenshot({ type: 'png', fullPage })),
            20_000, 'page.screenshot'
          );
          // 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,
            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 {
              await tab.page.setViewport(null);
              session._emulatedViewport = null;
              const dims = await tab.page.evaluate(() => ({ innerWidth: window.innerWidth, innerHeight: window.innerHeight, devicePixelRatio: window.devicePixelRatio }));
              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, reset: true, ...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 {
            await tab.page.setViewport({ width: Math.round(vw), height: Math.round(vh), deviceScaleFactor: dsf });
            // 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, 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, 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 }),
            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;
        }
        // pup_evaluate is the catalog/manifest name; pup_eval the historical one.
        // Accept both, with either `expr` or `expression`.
        case 'pup_evaluate':
        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;
        }
        case 'pup_eval': {
          if (!args.expr && args.expression) args.expr = args.expression;
          if (!args.expr) {
            sendJSON(res, { success: false, error: 'Missing required parameter "expr" (alias: "expression"). 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 };
            }
          }
          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'));
            if (args.selector) {
              let _box = null;
              try { _box = await tab.page.$eval(args.selector, el => { const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; }); } catch (e) {}
              if (_cursor && _box) { await showPupCursor(tab.page, _box.x, _box.y, { label: _intent }); await new Promise(r => setTimeout(r, 440)); }
              // v1.9.197 BUG 2: clickCount was accepted and silently ignored (a triple-click did a
              // single click and selected nothing). Honour it — 3 selects the field's text.
              await withTimeout(tab.page.click(args.selector, _clickOpts), 10_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)); }
              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) }, sessionId: resolvedId, tabId, _hint: 'Click dispatched (isTrusted, CDP-level). 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);
            const ready = await waitForUserReady(resolvedId, hwnd, { maxMs: Number(args.maxWaitMs) || 120000 });
            if (!ready) {
              await pupCaption('Adom stopped waiting: you did not return to the window. Ask me to try again when ready.', 7000);
              sendJSON(res, { success: false, errorCode: 'user_not_ready', error: 'Timed out waiting for you to focus the window. Nothing was clicked.', _hint: 'The user alt-tabbed away and did not return within the wait window. Re-issue pup_assisted_click when they are ready.' });
              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');
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, typed: args.text.length + ' chars', into: args.selector || 'focused element', sessionId: resolvedId, tabId, _hint: 'Typed via CDP (isTrusted). Verify with pup_eval reading the field value.' }) });
          } 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;
        }
        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';
                  }
                }

                // page.click does scrollIntoView + bounding-rect resolution + page.mouse.click.
                // Chromium dispatches via Input.dispatchMouseEvent → isTrusted=true.
                await chosenHandle.click({
                  button: args.button || 'left',
                  clickCount: args.clickCount || 1,
                  delay: args.delay || 0,
                });
                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 tab.page.mouse.click(args.x, args.y, {
                button: args.button || 'left',
                clickCount: args.clickCount || 1,
                delay: args.delay || 0,
              });
              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 tab.page.mouse.move(args.x, args.y, { steps: args.steps || 1 });
              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 tab.page.keyboard.type(args.text, { delay: args.delay || 0 });
              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 tab.page.keyboard.press(args.key, { delay: args.delay || 0 });
              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, error: `pup_input_dispatch ${t} failed: ${e.message}` });
          }
          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;
        }
        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_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, ...(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;
          if (!sessionId || !sessions.has(sessionId)) { sendJSON(res, { success: false, error: `Session "${sessionId}" not found` }); return; }
          const session = sessions.get(sessionId);
          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;
          if (!sessionId || !sessions.has(sessionId)) { sendJSON(res, { success: false, error: `Session "${sessionId}" not found` }); return; }
          const session = sessions.get(sessionId);
          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', tabId: args._resolvedTabId || null });
            // the human is about to draw — the window must be in front of them. A jump-list click IS
            // the user asking, so this raise is user-sanctioned by construction.
            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, _hint: '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.' }) });
          } 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`;
          try {
            const existing = sessions.get('pup-dashboard');
            if (existing) {
              try { await existing.page.goto(dashUrl, { waitUntil: 'domcontentloaded', timeout: 15000 }); } catch (e) {}
              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: true,
                _hint: `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;
            }
            await launchSession('pup-dashboard', dashUrl, { profile: 'pup-dashboard', owner: 'pup-dashboard' });
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: 'pup-dashboard', url: dashUrl, reused: false,
              _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. 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"}.';
          sendJSON(res, {
            success: true,
            output: JSON.stringify(rd),
            ...rd,
            _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;
        }
        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;
        }
        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 });
          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"}.`;
          }
          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_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,
            sessionCount: sessions.size,
            activeSession: activeSessionId,
            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 'credential_list': {
          try {
            const credentials = credentialVault.listCredentials();
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials }) });
          } catch (e) {
            sendJSON(res, { success: false, error: 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;
            }
            const r = await credentialVault.fillLoginForm(tab.page, { username, password, submit: !!args.submit });
            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;
            }
            sendJSON(res, { success: true, output: JSON.stringify({
              ok: true, url: curUrl, credentialSource: source,
              filledUsername: r.filledUsername, filledPassword: r.filledPassword,
              submitted: r.submitted, usernameFilled: r.usernameValue,   // username only; password is NEVER returned
            }),
              _hint: `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 autofill dropdown was involved and the window was 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 'desktop_recorder_open': {
          try {
            await ensureRecorderWindowOpen(args.reason);
            sendJSON(res, { success: true, output: JSON.stringify({
              ok: true, opened: true, reason: desktopRecorder.reason,
            })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_record_start': {
          try {
            const r = await desktopRecordStartImpl(args);
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_record_stop': {
          try {
            const r = await desktopRecordStopImpl(args);
            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});
          } catch (e) {
            sendJSON(res, { success: false, error: e.message });
          }
          return;
        }
        case 'desktop_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, output: JSON.stringify(status) });
          return;
        }
        case 'desktop_record_list': {
          sendJSON(res, { success: true, output: JSON.stringify({
            ok: true, recordings: desktopRecordListImpl(),
          })});
          return;
        }
        case 'desktop_recorder_close': {
          try {
            const summary = await closeRecorderWindow();
            sendJSON(res, { success: true, 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, error: `Unknown command: ${command}. Valid commands: pup_open_window, pup_switch_window, pup_list_windows, pup_close_window, pup_alert_window, pup_focus_window, pup_open_tab, pup_switch_tab, pup_close_tab, pup_list_tabs, pup_window_info, pup_screenshot, pup_screenshot_full_res, pup_eval, pup_errors, pup_reload, pup_navigate, pup_status, pup_configure, pup_close, pup_wait, desktop_recorder_open, desktop_record_start, desktop_record_stop, desktop_record_status, desktop_record_list, desktop_recorder_close, desktop_list_monitors, pup_record_start, pup_record_stop, pup_record_status, pup_record_list. Tab-aware commands (screenshot, eval, errors, reload, navigate) accept an optional "tabId" to target a specific tab.` }, 404);
          return;
      }
    }

    // Legacy HTTP endpoints (for direct curl access) — operate on active session

    // POST /launch
    if (pathname === '/launch' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      if (body.pupshotUrl) pupshotUrl = body.pupshotUrl;
      const session = await launchSession(body.sessionId || 'default', body.url);
      session.errors.length = 0;
      sendJSON(res, { ok: true, url: body.url, sessionId: activeSessionId });
      return;
    }

    // POST /navigate
    if (pathname === '/navigate' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      await session.page.goto(body.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
      session.errors.length = 0;
      sendJSON(res, { ok: true, url: body.url });
      return;
    }

    // POST /reload
    if (pathname === '/reload' && req.method === 'POST') {
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      session.errors.length = 0;
      await session.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });
      sendJSON(res, { ok: true });
      return;
    }

    // GET /screenshot
    if (pathname === '/screenshot') {
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      const ts = Date.now();
      const fp = url.searchParams.get('fullPage') === 'true';
      let shotBuf = await session.page.screenshot({ type: 'png', fullPage: fp });
      // Downscale when sharp is available; else serve at capture size (sharp optional).
      const shot = await processScreenshot(shotBuf, MAX_SCREENSHOT_DIM);
      shotBuf = shot.buf;
      const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);
      fs.writeFileSync(filePath, shotBuf);
      const sizeKB = Math.round(shotBuf.length / 1024);
      session.latestShot = filePath;

      let container = null;
      if (pupshotUrl) {
        try {
          const buf = fs.readFileSync(filePath);
          const b64 = buf.toString('base64');
          const postBody = JSON.stringify({ image: 'data:image/png;base64,' + b64 });
          const resp = await fetch(pupshotUrl, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: postBody,
          });
          container = await resp.json();
        } catch (e) {
          container = { error: e.message };
        }
      }

      sendJSON(res, {
        ok: true,
        sizeKB,
        filePath,
        width: shot.width,
        height: shot.height,
        origWidth: shot.origWidth,
        origHeight: shot.origHeight,
        resized: shot.resized,
        container,
      });
      return;
    }

    // GET /screenshot.png (serves latest shot regardless of format)
    if (pathname === '/screenshot.png') {
      const session = getActiveSession();
      const shot = session?.latestShot;
      if (shot && fs.existsSync(shot)) {
        const buf = fs.readFileSync(shot);
        res.writeHead(200, { 'Content-Type': 'image/png', 'Access-Control-Allow-Origin': '*' });
        res.end(buf);
      } else {
        res.writeHead(404);
        res.end('No screenshot yet');
      }
      return;
    }

    // GET /errors
    if (pathname === '/errors') {
      const session = getActiveSession();
      const errs = session ? session.errors : [];
      sendJSON(res, { errors: errs, count: errs.length });
      return;
    }

    // POST /eval
    if (pathname === '/eval' && req.method === 'POST') {
      const body = JSON.parse(await readBody(req));
      const expr = String(body.expr || '');
      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();
      let result;
      try {
        result = await session.page.evaluate(new Function('return (' + expr + ')'));
      } catch (e) {
        result = { __error: e.message };
      }
      sendJSON(res, { ok: true, result });
      return;
    }

    // GET /wait?ms=N
    if (pathname === '/wait') {
      const ms = parseInt(url.searchParams.get('ms') || '3000');
      await new Promise(r => setTimeout(r, ms));
      sendJSON(res, { ok: true, waited: ms });
      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',
        version: BRIDGE_VERSION,
        adVersion: _adVersion,
        raiseGateBypassed: _adRaiseGateBypassed || undefined,
        led, summary, tooltip,
        sessionCount: sessions.size,
        activeSession: activeSessionId,
        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.
    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
server.timeout = 45_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(); 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).
    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: register the Adom.Pup app identity once per bridge run (HKCU AUMID + Start Menu
  // shortcut whose pin launches a managed pup window). Loopback call from the bridge itself —
  // trusted transport, no relay gating. Self-gates on AD <1.9.134 (unknown verb).
  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(() => { if (minimalTouch()) 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(() => { reconcileModel('periodic').catch(() => {}); }, 8000);
  // 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(() => { 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(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(async () => {
    if (process.platform !== 'win32' || pupSettings().fgWatch !== '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)`);
          pupCaption(`This window was NOT foregrounded by Adom: JavaScript, a focus change, Chrome itself, or your own click raised "${sid2}". Not me!`, 2000, 'pup-nofault').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(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})`);
    try {
      for (const [sidF, sF] of [...sessions]) {
        if (sF._lostBrowser || !(sF.tabs || []).length || sF._annotateArmed && Date.now() - sF._annotateArmed < 4000) continue;
        for (const tF of (sF.tabs || [])) {
          if (!tF || !tF.page) continue;
          let clickedF = null;
          try { clickedF = await Promise.race([tF.page.evaluate(() => { const c = window.__pupFloatyClick; window.__pupFloatyClick = null; return c || null; }), new Promise(r => setTimeout(() => r(null), 2500))]); } catch (e) {}
          if (clickedF) { 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.
        if (s2._appOverlay && Date.now() - (s2._overlayReassertAt || 0) > 60000) {
          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 (60s 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

  setInterval(async () => {
    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;
              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(async () => {
    if (process.platform !== 'win32' || pupSettings().agentActivity === 'off') return;
    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).
  const sweepPlacement = async (cause) => {
    for (const [sid, sess] of [...sessions]) {
      try {
        if (!sess || !(sess.tabs || []).length) continue;
        const r = await healOffscreenWindow(sess, sid);
        if (r && r !== 'park-failed') {
          recordCleanup(`moved session "${sid}" back ON-SCREEN (found stranded off-screen during the ${cause} placement sweep — its taskbar button would have revealed nothing)`);
        }
      } catch (e) {}
    }
  };
  setTimeout(() => { sweepPlacement('startup').catch(() => {}); }, 15000);
  setInterval(() => { if (minimalTouch()) return; sweepPlacement('periodic').catch(() => {}); }, 120000);

  // 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);
  setInterval(() => { reapOrphanPupChromes().catch(() => {}); }, 180000);

  // Periodic cleanup: every 5 minutes, remove stale session files with dead PIDs
  setInterval(async () => {
    const files = listSessionFiles();
    for (const sf of files) {
      if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {
        console.log(`Cleanup: removing stale session file "${sf.sessionId}" (PID ${sf.pid} dead)`);
        deleteSessionFile(sf.sessionId);
      }
    }
  }, 5 * 60 * 1000);
};

// 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;
server.on('error', (err) => {
  const code = err && err.code;
  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.
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',
    '--disable-features=Translate',
    `--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 { fs.writeFileSync(path.join(state.framesDir, fname), buf); }
    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;
}

function tabRecordListImpl() {
  // Scan RECORDINGS_DIR for completed tab recordings (.webm files matching
  // the tab pattern). Each has an optional sidecar .json with metadata.
  let entries = [];
  try {
    entries = fs.readdirSync(RECORDINGS_DIR)
      .filter(f => f.startsWith('rec-tab-') && f.endsWith('.webm'))
      .map(f => {
        const fp = path.join(RECORDINGS_DIR, f);
        const stat = fs.statSync(fp);
        const sidecarPath = fp.replace(/\.webm$/, '.json');
        let sidecar = {};
        try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}
        return {
          recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),
          filePath: fp,
          sizeKB: Math.round(stat.size / 1024),
          durationMs: sidecar.durationMs || null,
          fps: sidecar.fps || null,
          sessionId: sidecar.sessionId || null,
          tabId: sidecar.tabId || null,
          mtime: stat.mtime.toISOString(),
        };
      });
  } catch {}
  return entries;
}

function desktopRecordListImpl() {
  let entries = [];
  try {
    entries = fs.readdirSync(RECORDINGS_DIR)
      .filter(f => f.startsWith('rec-desktop-') && f.endsWith('.webm'))
      .map(f => {
        const fp = path.join(RECORDINGS_DIR, f);
        const stat = fs.statSync(fp);
        const sidecarPath = fp.replace(/\.webm$/, '.json');
        let sidecar = {};
        try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}
        return {
          recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),
          filePath: fp,
          sizeKB: Math.round(stat.size / 1024),
          durationMs: sidecar.durationMs || null,
          reason: sidecar.reason || null,
          mtime: stat.mtime.toISOString(),
        };
      });
  } catch {}
  return entries;
}

// ════════════════════════════════════════════════════════════════════════════

// 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'));
