#!/usr/bin/env node
'use strict';
/*
 * adom-native-browser :: bridge
 * --------------------------------
 * ab-spawned Node server. Two listeners, BOTH ephemeral (per adom-bridge/skills/PORTS.md - a fixed
 * or scan-range port dies silently on HNS-reserved Windows machines):
 *
 *   - HTTP server  : ab talks /status + /command here (the standard bridge contract). ab allocates
 *                    the port (bridge.json port:0) and passes it via `--port`; we fall back to an OS
 *                    ephemeral when run standalone.
 *   - raw-TCP server (host loopback): the native-messaging host (Chrome-launched) dials this and
 *                    pumps newline-delimited JSON frames to/from the real extension.
 *
 * The host can't be told the port out-of-band, so we write it to a discovery file the host reads:
 *   ~/.adom/bridges/native-browser/host.json  -> { host, hostPort, pid, version }
 *
 * Frame protocol (bridge <-> host, newline-delimited JSON):
 *   down (bridge -> host -> ext): { id, verb, args }
 *   up   (ext -> host -> bridge): { id, ok, payload } | { id, ok:false, error, _hint }
 *   event(ext -> host -> bridge): { event, sessionId, tabId, data }   (buffered in later milestones)
 */

const http = require('http');
const net = require('net');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFile } = require('child_process');

const BRIDGE = 'native-browser';
let VERSION = '0.0.1';
try { VERSION = require('./package.json').version || VERSION; } catch {}
// bridge.json LAST so it wins - it's the manifest ab itself reads and reports, and it's what we bump
// per release, so the code's self-reported VERSION can never drift from the release version.
try { VERSION = require('./bridge.json').version || VERSION; } catch {}

// The extension version this bridge release expects. Bumped in bridge.json alongside every extension
// change. readiness compares each connected profile's reported version against this so a stale extension
// (loaded-unpacked builds do NOT auto-update - only the bridge does) is FLAGGED loudly instead of
// surfacing later as a confusing "verb not implemented yet". null = don't check (older bridge.json).
let EXPECTED_EXT = null;
try { EXPECTED_EXT = require('./bridge.json').expectedExtensionVersion || null; } catch {}
// semver-lite numeric compare (a<b -> -1, a==b -> 0, a>b -> 1). Non-numeric/missing parts count as 0.
function cmpVer(a, b) {
  const pa = String(a || '0').split('.').map((n) => parseInt(n, 10) || 0);
  const pb = String(b || '0').split('.').map((n) => parseInt(n, 10) || 0);
  for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; }
  return 0;
}

const HOSTFILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'host.json');
// The Chrome-launched native-messaging host needs a Node to run its stdio pump, but Chrome does NOT
// inherit ab's managed-runtime PATH (ab only puts its Node on the PATH of processes IT spawns). THIS
// bridge, however, IS spawned by ab, so process.execPath is exactly the Node ab provisioned (system or
// the portable no-UAC copy). We publish that absolute path here in plain text so native-host.bat can
// point at a guaranteed-present Node even on a machine with no system Node. Self-heals across ab runtime
// upgrades: every respawn rewrites it. See the adom-bridge-sdk SDK "runtime contract".
const NODEPATHFILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'node-path.txt');
const DEBUGLOG = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'bridge-debug.log');

// ---- persistence hardening -------------------------------------------------
// ab spawns dynamic bridges fire-and-forget and REUSES a still-running one via its health probe
// (confirmed by the ab team: ab never kills dynamic bridges). So we must be a long-lived server and
// never exit on our own. The silent killers to guard:
//   - EPIPE when ab drops our stdout/stderr pipe after spawn (a stray write would otherwise crash us),
//   - any uncaught exception / rejection (log it, don't die),
//   - the event loop somehow emptying (a no-op interval pins it open).
function dlog(m) { try { fs.appendFileSync(DEBUGLOG, `${new Date().toISOString()} ${m}\n`); } catch {} }
process.stdout.on('error', () => {});
process.stderr.on('error', () => {});
process.on('uncaughtException', (e) => dlog(`uncaughtException: ${(e && e.stack) || e}`));
process.on('unhandledRejection', (e) => dlog(`unhandledRejection: ${(e && e.stack) || e}`));
setInterval(() => {}, 60000); // keep-alive: the listeners already pin the loop, this is belt-and-suspenders

// ---- pending ab commands awaiting an extension reply -----------------------
let seq = 0;
const pending = new Map(); // id -> { resolve, timer }
let lastVerb = null, lastActivityAt = 0;

// ---- multi-profile connections --------------------------------------------
// One bridge can hold a host connection PER BROWSER PROFILE (you@example.com, you2@example.com, …) so the
// AI can drive any of the user's signed-in browsers. By default we'll drive all of them (the user can
// switch the active one or blocklist any they don't want touched). Each host sends a `hello {profile}`
// handshake on connect; we key the connection by profile email (or a stable fallback id).
const conns = new Map();   // profileKey -> { sock, profile, since }
let activeProfile = null;  // default target for action verbs (instantly switchable, no window-closing)
let provSeq = 0;           // provisional keys for hosts that haven't sent their hello yet
// Connections are keyed by BROWSER + PROFILE so the same account signed into Chrome AND Edge (or any
// browser/profile combo) are DISTINCT, independently-addressable connections that coexist - letting
// multiple AI threads each drive a different browser/profile in parallel without colliding. e.g.
// "chrome:you@example.com", "edge:you@example.com", "chrome:work@example.com".
function profileKey(p) {
  const b = (p && p.browser) || 'chrome';
  const id = (p && (p.email || p.id)) || 'unknown';
  return `${b}:${id}`;
}
function profileLabel(p) { return (p && (p.email || p.name || p.id)) || 'unknown'; }

// Blocklist: profiles the user told us to NEVER drive. Persisted so it survives bridge respawns.
const BLOCKFILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'profile-blocklist.json');
let blocklist = new Set();
try { const j = JSON.parse(fs.readFileSync(BLOCKFILE, 'utf8')); if (Array.isArray(j.blocklist)) blocklist = new Set(j.blocklist.map(String)); } catch {}
function saveBlocklist() { try { fs.writeFileSync(BLOCKFILE, JSON.stringify({ blocklist: [...blocklist] }, null, 2)); } catch {} }
function isBlocked(key) { return blocklist.has(key); }

function liveConns() { return [...conns.entries()].filter(([k, c]) => c.sock && !c.sock.destroyed && !isBlocked(k)); }
function anyConnected() { return liveConns().length > 0; }
// Pick the connection an action verb should target: explicit args.profile, else the active profile,
// else the only/most-recent live one. Returns null if none drivable (or the target is blocklisted).
function targetConn(args) {
  const want = args && (args.profile || args.profileEmail);
  const wantBrowser = args && args.browser; // optional: disambiguate the SAME email across browsers
  if (want) {
    if (isBlocked(String(want))) return { blocked: true, key: String(want) };
    // `want` may be a full key ("edge:you@example.com") or just an email - match either, and if a browser
    // is also given require it (so parallel threads can pin "this email in THIS browser").
    for (const [k, c] of conns) {
      if (!(c.sock && !c.sock.destroyed)) continue;
      if ((k === want || c.profile.email === want) && (!wantBrowser || c.profile.browser === wantBrowser)) {
        // profile_mismatch (guard #6): if this session was pinned to a DIFFERENT profile at open time, the
        // named profile contradicts it → refuse rather than act on the wrong account (personal vs corp).
        const pin = args && args.sessionId && sessionProfile.get(args.sessionId);
        if (pin && pin !== k) return { mismatch: true, pin, wantKey: k, sessionId: args.sessionId };
        return { conn: c, key: k };
      }
    }
    return null;
  }
  if (wantBrowser) { // browser-only target → that browser's active/first live profile
    for (const [k, c] of conns) if (c.profile.browser === wantBrowser && c.sock && !c.sock.destroyed && !isBlocked(k)) return { conn: c, key: k };
    return null;
  }
  // A session PINNED to a profile (via open_window or nbrowser_pin_session) routes there by default -
  // so you can drive several accounts and never accidentally hit the wrong one by forgetting `profile:`.
  const pin = args && args.sessionId && sessionProfile.get(args.sessionId);
  if (pin) {
    for (const [k, c] of conns) if (k === pin && c.sock && !c.sock.destroyed && !isBlocked(k)) return { conn: c, key: k, pinned: true };
    // pinned profile no longer live → fall through to the normal default rather than hard-failing.
  }
  const live = liveConns();
  if (!live.length) return null;
  const act = live.find(([k]) => k === activeProfile);
  const [k, c] = act || live[live.length - 1];
  return { conn: c, key: k };
}
// Register a freshly-connected host BEFORE its hello, under a provisional key, so even an old extension
// (no hello) is drivable - and so we can dev_reload it to pick up the hello-sending build. The hello
// then re-keys it to the real profile (registerConn).
function registerProvisional(sock) {
  const key = 'pending-' + (++provSeq);
  conns.set(key, { sock, profile: { id: key, email: '' }, since: Date.now(), provisional: true });
  if (!activeProfile) activeProfile = key;
  return key;
}
// ---- credential QUERY LAYER (reads the BROWSER's own saved-login store) -----
// We do NOT store or replace anything - Chrome/Edge remain the source of truth for passwords. This is a
// read-only layer that QUERIES the browser's native credential store so the AI can tell which vendors a
// user already has a login for (origins + usernames; NEVER passwords - those stay encrypted and Chrome/
// Edge autofill them at login time). A browser EXTENSION can't read this (sandbox); the bridge runs
// natively, so it reads the `Login Data` SQLite for BOTH Chromium browsers. Dependency-free leaf reader.
const LOCALAPP = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
const BROWSER_UD = {
  chrome: path.join(LOCALAPP, 'Google', 'Chrome', 'User Data'),
  edge: path.join(LOCALAPP, 'Microsoft', 'Edge', 'User Data'),
};
function sqliteLogins(file) {
  const buf = fs.readFileSync(file);
  let pageSize = buf.readUInt16BE(16); if (pageSize === 1) pageSize = 65536;
  const n = Math.floor(buf.length / pageSize), out = [];
  const varint = (off) => { let r = 0n, i = 0; for (; i < 9; i++) { const b = buf[off + i]; if (i === 8) { r = (r << 8n) | BigInt(b); i++; break; } r = (r << 7n) | BigInt(b & 0x7f); if (!(b & 0x80)) { i++; break; } } return [r, i]; };
  for (let p = 1; p <= n; p++) {
    const base = (p - 1) * pageSize, hdr = p === 1 ? base + 100 : base;
    if (buf[hdr] !== 0x0d) continue;
    const cells = buf.readUInt16BE(hdr + 3), ptrArr = hdr + 8;
    for (let c = 0; c < cells; c++) {
      let o = base + buf.readUInt16BE(ptrArr + c * 2);
      let [, l1] = varint(o); o += l1; let [, l2] = varint(o); o += l2;
      const recStart = o; let [hLen, hb] = varint(o); const hEnd = recStart + Number(hLen); o += hb;
      const serials = []; while (o < hEnd) { const [s, sb] = varint(o); o += sb; serials.push(Number(s)); }
      let d = hEnd; const cols = [];
      for (const s of serials) { let v = null, sz = 0; if (s === 0 || s === 8 || s === 9) sz = 0; else if (s >= 1 && s <= 4) sz = s; else if (s === 5) sz = 6; else if (s === 6 || s === 7) sz = 8; else if (s >= 12 && s % 2 === 0) sz = (s - 12) / 2; else if (s >= 13) { sz = (s - 13) / 2; v = buf.toString('utf8', d, d + sz); } cols.push(v); d += sz; }
      if (cols[0] && /^https?:\/\//.test(cols[0])) out.push({ host: cols[0].split('/')[2], username: cols[3] || '' });
    }
  }
  const seen = new Set(); return out.filter((r) => { const k = r.host + '|' + r.username; if (seen.has(k)) return false; seen.add(k); return true; });
}
function profilesOf(browser) {
  try { const ls = JSON.parse(fs.readFileSync(path.join(BROWSER_UD[browser], 'Local State'), 'utf8')); return Object.entries(ls.profile.info_cache || {}).map(([dir, info]) => ({ dir, email: info.user_name || '', name: info.name || '' })); } catch { return []; }
}
function readLoginsFor(browser, dir) {
  const src = path.join(BROWSER_UD[browser], dir, 'Login Data');
  const tmp = path.join(os.tmpdir(), `nb-logins-${browser}-${dir.replace(/\W/g, '_')}-${Date.now()}.db`);
  try { fs.copyFileSync(src, tmp); return sqliteLogins(tmp); } catch (e) { return { error: String((e && e.message) || e) }; } finally { try { fs.unlinkSync(tmp); } catch {} }
}
// Query saved logins across Chrome + Edge (or a filtered browser/profile), each tagged with its source.
function queryCredentials({ browser, profile, match } = {}) {
  const re = match ? new RegExp(String(match), 'i') : null;
  const browsers = browser ? [browser] : Object.keys(BROWSER_UD);
  const out = [];
  for (const b of browsers) {
    if (!BROWSER_UD[b] || !fs.existsSync(BROWSER_UD[b])) continue;
    let profs = profilesOf(b);
    if (profile) profs = profs.filter((p) => p.email.toLowerCase() === String(profile).toLowerCase() || p.dir === profile);
    for (const pr of profs) {
      const rows = readLoginsFor(b, pr.dir);
      if (!Array.isArray(rows)) continue;
      for (const r of rows) if (!re || re.test(r.host) || re.test(r.username)) out.push({ browser: b, profile: pr.email || pr.dir, host: r.host, username: r.username });
    }
  }
  return out;
}

// (Path-A bridge-side password decryption removed - v20 app-bound passwords can't be decrypted by any
// external process; nbrowser_autologin defers legacy-v10 decryption to the in-Chrome host. See skill.)

function registerConn(sock, profile, browser, version) {
  // Normalize to an object + tag the browser so chrome:me and edge:me are DISTINCT coexisting conns.
  const p = (profile && typeof profile === 'object') ? { ...profile } : { email: String(profile || ''), id: String(profile || '') };
  p.browser = browser || p.browser || 'chrome';
  const key = profileKey(p);
  profile = p;
  // Re-key any provisional/old entry for THIS socket to the real profile.
  for (const [k, c] of [...conns]) if (c.sock === sock && k !== key) { conns.delete(k); if (activeProfile === k) activeProfile = null; }
  const prev = conns.get(key);
  if (prev && prev.sock !== sock) { try { prev.sock.destroy(); } catch {} } // newest host wins per profile
  // Keep the version the extension reported in its hello (used by readiness to flag a stale build).
  // Fall back to a same-profile prior value if this hello somehow omitted it.
  conns.set(key, { sock, profile: profile || {}, since: Date.now(), version: version || (prev && prev.version) || null });
  if (!activeProfile || !liveConns().some(([k]) => k === activeProfile)) activeProfile = isBlocked(key) ? activeProfile : key;
  dlog(`hello: profile ${key} registered (${conns.size} total)`);
}

// Bridge-self-reported chip status (led/summary/tooltip). ab is a pure renderer - ALL the reasoning
// about what green/yellow/red means lives HERE, since only this bridge knows its own domain. ab just
// shows the color + prints the tooltip on hover; it owns only the offline/gray state (a dead bridge
// can't self-report). See /status.
function statusReport() {
  const now = Date.now();
  if (now < cbTrippedUntil) {
    return {
      led: 'red',
      summary: 'Connection storm',
      tooltip: `Circuit breaker tripped: too many extension-host connections in a short window (runaway loop suspected). Refusing new host connections for ${Math.ceil((cbTrippedUntil - now) / 1000)}s.\nSee ${DEBUGLOG}.`,
    };
  }
  const live = liveConns();
  if (live.length) {
    const names = live.map(([k]) => (k === activeProfile ? `▶ ${k}` : k)).join(', ');
    const last = lastVerb ? `\nLast verb: ${lastVerb} (${Math.round((now - lastActivityAt) / 1000)}s ago).` : '';
    const blocked = blocklist.size ? `\nBlocked (never driven): ${[...blocklist].join(', ')}.` : '';
    return {
      led: 'green',
      summary: live.length === 1 ? 'Extension connected' : `${live.length} profiles connected`,
      tooltip: `nbe driving ${live.length} profile(s): ${names}.\n▶ = active default target (switch with nbrowser_use_profile).${last}${blocked}`,
    };
  }
  return {
    led: 'yellow',
    summary: 'No extension',
    tooltip: 'Bridge is running, but no browser extension is connected.\nOpen a window in a Chrome/Edge profile where the Adom extension is loaded (its service worker sleeps when that profile has no windows), or verify it at chrome://extensions.',
  };
}

function timeoutFor(verb) {
  if (/screenshot|record|fetch_url|input_dispatch/.test(verb)) return 120000;
  return 60000;
}

// Rich self-documentation, answered by the BRIDGE so it works even when the extension is disconnected.
const HELP = {
  overview: 'Drive the user\'s REAL logged-in Chrome/Edge. Verbs mirror pup\'s browser_* as nbrowser_*.',
  pitfalls: [
    'The extension lives in ONE browser PROFILE and DIES if that profile has no open windows - if you get "not connected", ask the user to open a window in that profile (or open one yourself once reconnected). Never close your LAST window in that profile.',
    'Multiple Bridges on the relay → pass `--target <name>` (e.g. AdomLapper) or you get ambiguous_target.',
    'Screenshots are large base64 and can TIME OUT over the relay - retry, or prefer ab desktop_screenshot_window for proof shots.',
    'nbrowser_eval runs in the PAGE world: chrome.* APIs are NOT available there (only DOM/window).',
    'ALWAYS clean up windows you open (nbrowser_close_window) - abandoned windows annoy the user.',
    'Poll nbrowser_events to learn if the USER closed your windows/tabs (window_closed {byUser:true}), or if a foregrounding attempt was auto-reverted (focus_violation {verb, autoReverted:true}) - a focus_violation means you tried to shove a window in front of the user; stop foregrounding.',
  ],
  bestPractices: [
    '⛔ NEVER reuse a window the user already has open. Do not nbrowser_navigate / reload it, and do NOT desktop_navigate it either - that yanks their live page out from under them. For ANY page YOU need (driving, reading, or screenshotting a chrome://extensions / edge://extensions page for a guide), open a NEW window (nbrowser_open_window, or a fresh OS window you then desktop_navigate), work in THAT window, and close it when done. The only thing you may do to one of their existing windows is desktop_bring_to_front to surface it for them.',
    '⛔ BACKGROUND BY DEFAULT - NEVER foreground a window unless the user explicitly asked to watch. nbrowser_open_window opens UNFOCUSED, open_tab opens active:false, switch_window does NOT raise - keep it that way. To capture a chrome:// / edge:// page (e.g. chrome://extensions for a guide), DO NOT launch the browser yourself (msedge/chrome `--new-window` via launch/launch_process RAISES the new window to the foreground). Instead: nbrowser_open_window {url:"about:blank"} (background) → desktop_navigate that window\'s hwnd to the chrome:// URL (background, no foreground) → desktop_screenshot_window (WGC captures a background/occluded window) → nbrowser_close_window. Same for recording (WGC) and screenshots (nbrowser_screenshot CDP) - both capture occluded windows, so foregrounding is never needed. Foregrounding is the USER\'s choice; do it only on explicit request (desktop_bring_to_front).',
    'Before driving, nbrowser_status to confirm connected. After a dev_reload, expect a ~3-6s reconnect.',
    '⭐ BACKGROUND IS THE WHOLE POINT - NEVER foreground the driven window to screenshot OR record. The user must keep working while a demo records behind their other windows; yanking Chrome forward makes the feature useless. nbrowser_screenshot (CDP) captures a background/occluded window unconditionally. WGC recording captures a background/occluded window TOO - but only when the recorded Chrome was launched with the occlusion-disable flags (see Recording); without them an occluded window paints blank. Foregrounding is the USER\'s choice, made by them when they want to watch - it is never something the AI should do on its own.',
    'For demos: nbrowser_caption (in-page subtitle/alert) OR ab desktop_caption (fullscreen; use position:bottom - top covers the URL bar + Chrome warning).',
    'Mouse verbs: pass cursor:true to show the gliding Adom cursor, and intent:"Clicking the Pay button" so the cursor LABEL narrates the action (builds user trust).',
    '⭐ Recording a browser window: the BEST tool is the CORE Bridge verb desktop_record_window_start (WGC) - or nbrowser_record_start {method:"wgc"}, which delegates to it. Background, up to 60fps, NO source picker, NO banner, captures OCCLUDED windows, never steals focus. This bridge\'s own getDisplayMedia/tabcapture recording is only a LAST-RESORT FALLBACK and is REFUSED unless you pass fallbackConfirmed:true, because a browser extension CANNOT silently screen-record (Chrome forces a manual share-source picker a human must click + a persistent banner, and won\'t reliably capture a background window). Prefer WGC every time; the extension is the fallback. ⚠️ For WGC background capture of an OCCLUDED Chrome window, Chrome must be launched with `--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling --disable-features=CalculateNativeWinOcclusion` (pup\'s Chrome ships these; a default user Chrome does NOT - without them Chrome stops painting the occluded window and WGC records a blank surface). Stop with nbrowser_record_stop {method:"wgc",recordingId}.',
  ],
  verbs: {
    'open_window/open_tab/close_window/close_tab/switch_window/switch_tab/list_windows/list_tabs/rescan': 'Sessions = a real Chrome window (durable across reloads). tabId = real chrome tab id. close what you open.',
    'navigate/reload/back/forward/wait': 'wait{ms}. navigate/reload await load-complete.',
    'screenshot/screenshot_full_res/eval': 'screenshot resized <=1568 (base64); _full_res unresized. eval runs in page world.',
    'click/double_click/right_click/hover/type/press_key/input_dispatch/cursor': 'isTrusted (CDP). selector uses modal-aware scoring. cursor:true + intent for the narrated Adom cursor. hover reveals tooltips.',
    'fetch_url/download': 'fetch_url uses the user\'s REAL cookies (authed). download via chrome.downloads.',
    'errors/events': 'errors = console/page errors for a tab. events = window/tab closes incl. byUser.',
    'record_start/stop/status/list': 'method: wgc|getdisplaymedia|tabcapture. record_start is non-blocking for getdisplaymedia (poll record_status). base64 return is for SHORT clips only.',
    'caption/focus': 'caption{style:subtitle|alert,position}. focus selects the tab + flashes its taskbar button (does NOT raise the window - by design, never steals the user\'s focus). Capture in place; the window is raised ONLY on explicit user request (userRequestedForeground:true, or the core desktop_bring_to_front). NEVER foreground or OS-launch a window to surface it - foregrounding is the user\'s choice alone.',
    'flash/flash_stop/flash_mode/taskbar': 'Paint glanceable status on the driven browser window\'s Windows TASKBAR button (hwnd captured at open_window; the "Adom is driving" badge goes on automatically - on opened windows at open, and on adopted SPAWNED windows when you call nbrowser_owned / nbrowser_list_windows). The badge is a COMPOSITE the bridge builds: the profile\'s own circle avatar + the Adom favicon in its corner - so the user still sees WHICH profile it is. It idle-expires after ~3 min of no driving (restoring the plain native avatar - the overlay slot is never blanked), and re-appears on your next drive verb. flash = orange "come look" cue (auto-clears on focus). flash_mode{quiet|chatty|off}: quiet(default)=auto-flash on come-look moments (page load, screenshot, an error/refusal, a human gate, and freshly SPAWNED windows) - flash manually with nbrowser_flash at task-DONE; chatty=every action (users who want to watch); off=NO auto-flash at all (the user opt-out - set this if they say the flashing is annoying). nbrowser_taskbar{progress:{state:"normal"|"paused"|"error"|"indeterminate"|"none",value?},overlay:{badge:"adom"|"none"}} = progress bar (green/YELLOW-for-gates/red/marquee) + overlay badge for long tasks. Use paused(yellow)+flash on captcha/2FA/login. Clears on close.',
  },
};

// ---- WGC recording: bridge -> ab (standalone, no Hydrogen dependency) -----------------------------------
// WGC per-window capture can't be done in a browser, so it lives in ab (which is always present with
// this bridge). We delegate to ab's direct API. ab writes the mp4 to a WINDOWS path (so pull_file
// grabs it directly - no WSL distro). NEVER call Hydrogen here: ab must run standalone without Hydrogen.
// ab 1.8.149 control-API discovery contract: env var first (ab injects it into every spawned bridge -
// no file race, no stale port), then the host:port discovery file, then ab's fixed 47200..47209 range.
// ⚠️ The spawn-time env values FREEZE the port ab had when it spawned us. ab SELF-UPDATES rotate the
// control port while we keep running (real incident, 2026-07-18: a Jul-16 bridge process hammered a dead
// port for hours - hwnd capture, badge painting, and repair ALL failed SILENTLY). So: env/override is
// only the first try; on any connection failure abCommand re-discovers from the FILE (which ab keeps
// current), retries once, caches the working base, and LOGS the outage + recovery.
let abBaseOverride = null; // set when file-rediscovery finds a LIVE base that differs from the env one
let abDownSince = 0;
function discoverAdBase(fileOnly) {
  if (!fileOnly) {
    if (abBaseOverride) return abBaseOverride;
    if (process.env.ADOM_DIRECT_API_URL) return process.env.ADOM_DIRECT_API_URL.replace(/\/+$/, '');
    if (process.env.ADOM_DIRECT_API_PORT) return `http://127.0.0.1:${process.env.ADOM_DIRECT_API_PORT}`;
  }
  try {
    const addr = fs.readFileSync(path.join(os.homedir(), '.adom', 'direct-api-port'), 'utf8').trim();
    if (addr) return /^\d+$/.test(addr) ? `http://127.0.0.1:${addr}` : `http://${addr}`; // bare port (old) or host:port
  } catch {}
  return 'http://127.0.0.1:47200';
}
let abTokenRejected = false; // ab rotates the bridge token on self-update; a stale one 403s every call
// ── CALLER PROVENANCE (wiki #345/#351) ─────────────────────────────────────────────────────────────
// ab hands us WHO asked (X-Adom-Caller-*). We (a) log it, (b) FORWARD it on every ab callback we make
// while acting on that thread's behalf, adding X-Adom-Caller-Delegate so ab can show the user both
// facts: "chip-fetcher tab 3 (via native-browser)". Self-asserted: never used for authorization.
const { AsyncLocalStorage } = require('async_hooks');
const callerCtx = new AsyncLocalStorage();
function withCaller(caller, fn) { return callerCtx.run(caller && (caller.thread || caller.container) ? caller : null, fn); }
function currentCaller() { return callerCtx.getStore() || null; }
function callerLabel() { const c = currentCaller(); return c && c.thread ? c.thread : null; }
// Headers for a Bridge call. Acting FOR a thread -> echo its identity + name ourselves as the delegate.
// Acting on our OWN behalf (idle timers, startup sweeps, reapers) -> no stale identity is forwarded;
// callers of abCommand pass selfCaller:true and we send our own name instead.
function callerHeaders(self) {
  const c = self ? null : currentCaller();
  if (!c) return { 'x-adom-caller-thread': `${BRIDGE} bridge (self)`, 'x-adom-caller-container': 'local', 'x-adom-caller-delegate': BRIDGE };
  return {
    'x-adom-caller-thread': c.thread || '',
    'x-adom-caller-container': c.container || '',
    'x-adom-caller-reason': c.reason || '',
    'x-adom-caller-delegate': BRIDGE,
  };
}
function abRequest(base, payload, noToken, self) {
  return new Promise((resolve) => {
    let u; try { u = new URL('/command', base); } catch { u = new URL('http://127.0.0.1:47200/command'); }
    const data = Buffer.from(JSON.stringify(payload));
    // X-Adom-Bridge-Token is ATTRIBUTION only (ab 1.9.84) - ab injects ADOM_BRIDGE_TOKEN into our spawn
    // env; sending it badges our outbound calls as "bridge" in the Activity Log. It is NOT an approval
    // gate (we're trusted-by-install; ab binds 127.0.0.1 only). Omitting it is fine; a stale token 403s.
    const headers = { 'content-type': 'application/json', 'content-length': data.length, ...callerHeaders(self) };
    if (process.env.ADOM_BRIDGE_TOKEN && !noToken && !abTokenRejected) headers['x-adom-bridge-token'] = process.env.ADOM_BRIDGE_TOKEN;
    const req = http.request({ host: u.hostname, port: u.port || 80, path: u.pathname, method: 'POST', headers, timeout: 20000 }, (res) => {
      let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { let j; try { j = JSON.parse(b); } catch { j = b; } resolve({ status: res.statusCode, body: j }); });
    });
    req.on('error', (e) => resolve({ status: 0, body: { error: String((e && e.message) || e) } }));
    req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: { error: 'ab direct-api timed out' } }); });
    req.write(data); req.end();
  });
}
function abCommand(app, command, args, target, self) {
  // app is OPTIONAL on ab 1.9.84+ (inferred) - omit it for top-level verbs like notify_user, keep it
  // when a caller passes one (e.g. desktop_* recording delegation).
  const payload = { command, args: args || {} };
  if (app) payload.app = app;
  // CROSS-ab routing: `target` (a peer clientName from `targets`, or "all") routes this call to a PEER
  // ab via the relay - so a bridge running on a VM reaches the user on their LAPTOP. Omitted = local ab.
  if (target) payload.target = target;
  const first = discoverAdBase();
  const ok2xx = (r) => r && r.status >= 200 && r.status < 300;
  return abRequest(first, payload, false, self).then(async (r) => {
    // A STALE BRIDGE TOKEN 403s EVERY CALL (ab rotates it when it self-updates). The token is
    // ATTRIBUTION-ONLY, so the correct recovery is to DROP it and retry - otherwise every ab-backed
    // feature (window correlation, taskbar badge, flash) dies SILENTLY. Real incident 2026-07-21: the
    // fusion-bridge maintainer saw nbrowser_os_windows report 0 windows while ab saw 21.
    if (r.status === 401 || r.status === 403) {
      const retry = await abRequest(first, payload, true, self);
      if (ok2xx(retry)) {
        if (!abTokenRejected) { abTokenRejected = true; console.error('[native-browser] ab rejected our bridge token (403) - it is attribution-only, so dropping it and continuing. ab likely self-updated and rotated it.'); logActivity({ verb: '_ab_link', ok: true, errorCode: 'token_dropped', internal: true }); }
        abDownSince = 0;
        return retry;
      }
      r = retry;
    }
    if (ok2xx(r)) {
      if (abDownSince) { console.error(`[native-browser] ab link RECOVERED after ${Math.round((Date.now() - abDownSince) / 1000)}s`); logActivity({ verb: '_ab_link', ok: true, internal: true }); abDownSince = 0; }
      return r;
    }
    // Any other non-2xx is a REAL failure - never treat it as success (that is what silently produced
    // empty window lists). Log it so a missing badge/flash is diagnosable from nbrowser_activity.
    if (r.status !== 0) {
      if (!abDownSince) { abDownSince = Date.now(); console.error(`[native-browser] ab returned HTTP ${r.status} for ${command} - ab-backed features (badge/flash/window correlation) are degraded`); logActivity({ verb: '_ab_link', ok: false, errorCode: `http_${r.status}`, internal: true }); }
      return r;
    }
    // Connection-level failure -> ab may have restarted on a new port. Re-discover from the FILE and retry.
    const fromFile = discoverAdBase(true);
    const retry = fromFile && fromFile !== first ? abRequest(fromFile, payload, false, self) : Promise.resolve(r);
    return retry.then((r2) => {
      if (ok2xx(r2)) {
        abBaseOverride = fromFile;
        console.error(`[native-browser] ab port rotated (${first} dead) - rediscovered ${fromFile} from file, link restored`);
        logActivity({ verb: '_ab_link', ok: true, internal: true });
        abDownSince = 0;
        return r2;
      }
      if (!abDownSince) {
        abDownSince = Date.now();
        console.error(`[native-browser] ab UNREACHABLE at ${first}${fromFile !== first ? ` and ${fromFile}` : ''} (${(r2.body && r2.body.error) || 'connect failed'}) - taskbar badge/flash + window correlation degraded until the link recovers`);
        logActivity({ verb: '_ab_link', ok: false, errorCode: 'ad_unreachable', internal: true });
      }
      return r2;
    });
  });
}
// ⭐ The hint we hand back whenever an AI tries to record WITHOUT choosing WGC. The AI is not assumed
// to know the core Bridge window recorder exists, so we spell it out and explain - in detail -
// why the extension's own screen-capture is a heinous last resort. (Lesson from a real thread that
// blundered into the getDisplayMedia picker before discovering desktop_record_window_start on its own.)
const RECORD_WGC_HINT =
  '⭐ BEST PATH - record the window with WGC, NOT the extension. Use the CORE Bridge verb ' +
  'desktop_record_window_start {"titleContains":"<window title>"} (or {"hwnd":<n>}) - or call THIS verb ' +
  'with method:"wgc", which delegates straight to it. WGC (Windows Graphics Capture) records ONE specific ' +
  'window in the BACKGROUND at HIGH frame rate (up to 60fps), with NO source picker, NO "sharing" banner, ' +
  'captures the window even while it is OCCLUDED/behind other windows, and NEVER steals the user\'s focus. ' +
  'Stop with desktop_record_window_stop (or nbrowser_record_stop {"method":"wgc","recordingId":...}); poll ' +
  'desktop_record_window_status. This is the right tool for recording a browser window - reach for it first.\n' +
  '⛔ The extension\'s OWN recording (method:"getdisplaymedia"/"tabcapture") is a LAST-RESORT FALLBACK, used ' +
  'ONLY if WGC is genuinely unavailable. By Chrome\'s security design a browser extension CANNOT silently ' +
  'screen-record: getDisplayMedia FORCES a share-source PICKER that a HUMAN must click to choose the window/ ' +
  'screen (yes - heinous: the whole point of a logged-in browser extension is that it should never need a ' +
  'manual picker), it paints a persistent recording banner, and it does NOT reliably capture a background/ ' +
  'occluded window. I am the fallback, not the default. If WGC truly will not work, re-call with ' +
  'method:"getdisplaymedia" AND fallbackConfirmed:true to acknowledge the picker + banner tradeoff.';

// ab's native per-window recorder shipped in Bridge (standalone Rust WGC + Media
// Foundation - no picker, no banner, background-capturable, real .mp4 under %TEMP%\adom-bridge-
// recordings\, pullable with pull_file). We delegate method:"wgc" straight to it.
async function wgcRecord(verb, args) {
  const map = {
    nbrowser_record_start: 'desktop_record_window_start',
    nbrowser_record_stop: 'desktop_record_window_stop',
    nbrowser_record_status: 'desktop_record_window_status',
    nbrowser_record_list: 'desktop_record_window_status',
  };
  const abArgs = { ...args };
  delete abArgs.method; // 'wgc' is the bridge-side selector, not a Bridge arg
  const r = await abCommand('desktop', map[verb], abArgs);
  let out = r.body;
  // ab wraps results differently across surfaces - unwrap {output:"<json>"} and/or {data:{...}} so
  // recordingId/path/fileExists are reachable no matter which shape the direct API returns.
  if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }
  if (out && out.data && typeof out.data === 'object') out = out.data;
  const unknown = out && out.error && /unknown (verb|command)|not found/i.test(out.error);
  if (unknown) {
    return {
      success: false,
      error: 'ab is too old for native window recording',
      _hint: 'desktop_record_window_* shipped in Bridge - upgrade ab (adom-bridge-cli --version must be >= 1.8.148; older builds mis-route to the puppeteer bridge). Meanwhile use method:"getdisplaymedia" or the "Record this tab" toolbar button.',
    };
  }
  if (r.status >= 200 && r.status < 300 && out && out.success !== false && !out.error) {
    const stopHint = 'mp4 is on the laptop at out.path (under %TEMP%\\adom-bridge-recordings) - fetch it with: adom-bridge-cli pull_file {"filePaths":["<path>"]}';
    const startHint = 'recording started in the BACKGROUND - the user keeps working; do NOT foreground the window (foregrounding is the user\'s choice, never the AI\'s). ⚠️ If frames come out BLANK, the recorded Chrome was NOT launched with the occlusion-disable flags (--disable-backgrounding-occluded-windows --disable-renderer-backgrounding --disable-background-timer-throttling --disable-features=CalculateNativeWinOcclusion) - Chrome stops painting an occluded window without them. pup\'s Chrome already has these (so it background-records fine); a default user Chrome must be relaunched with them. Poll nbrowser_record_status {method:"wgc",recordingId}; stop with nbrowser_record_stop {method:"wgc",recordingId}.';
    return { success: true, output: JSON.stringify({
      ok: true, method: 'wgc',
      recordingId: out.recordingId, path: out.path, fileExists: out.fileExists,
      ab: out,
      _hint: verb === 'nbrowser_record_stop' ? stopHint : startHint,
    }) };
  }
  // a real ab-side failure (window not found, capture error, …) - surface it, don't pretend it's missing
  return {
    success: false,
    error: (out && out.error) || `WGC ${verb} failed`,
    _hint: 'ensure titleContains/hwnd targets a real window (same logic as desktop_screenshot_window); check desktop_record_window_status captureError.',
    ab: out,
  };
}

// ---- taskbar status (flash now; Adom-overlay + progress when ab ships desktop_taskbar) ----------
// We paint a "come look" cue on the REAL browser window's Windows taskbar button. The hwnd is captured
// at control-start (the just-opened window is foreground) and cached, so flashes never hit the wrong
// window. Flash works today via run_script FlashWindowEx; overlay/progress need ab's desktop_taskbar
// (ITaskbarList3 - can't be driven cleanly from PowerShell). See TASKBAR-FEATURE.md.
let lastHwnd = 0;                  // most-recently driven window
const sessionHwnd = new Map();     // sessionId -> hwnd (opened windows)
const spawnHwnd = new Map();       // chrome windowId -> hwnd (adopted SPAWNED windows: compose/popups)
const sessionProfile = new Map();  // sessionId -> profile KEY (pin: verbs on this session route here by default)
const sessionThread = new Map();   // sessionId -> thread label (from registration) — for the activity log
const sessionCaller = new Map();   // sessionId -> ab-stamped caller thread (wiki #345: who ACTUALLY owns this session)
const sessionPurpose = new Map();  // sessionId -> purpose (from registration)

// ── ACTIVITY LOG — a bounded, queryable "who drove what, when" trail so we can answer questions like
// "why did that profile get badged?" without guessing. Two tiers:
//   1. In-memory ring (600 entries / 1h TTL) — the fast path nbrowser_activity queries.
//   2. Persistent JSONL on disk — survives bridge restarts so we can investigate how OTHER AI threads
//      interacted with our features after the fact. Self-JANITORED so it can never grow unbounded:
//      trimmed on startup + every 6h to the newest ~4000 lines AND nothing older than 14 days.
const ACTIVITY_LOG = [];
const ACT_LOG_MAX = 600;
const ACT_LOG_TTL_MS = 60 * 60 * 1000; // keep ~1 hour in memory
const ACT_FILE = path.join(os.homedir(), '.adom', 'bridge-logs', 'native-browser-activity.jsonl');
try { fs.mkdirSync(path.dirname(ACT_FILE), { recursive: true }); } catch {}
function logActivity(e) {
  e.ts = Date.now();
  // WHO asked (wiki #345 ask 1): stamp the calling AI thread on every logged verb so concurrent-agent
  // interference is reconstructable after the fact. Self-asserted; attribution only.
  if (e.caller === undefined) { const c = (typeof currentCaller === 'function') && currentCaller(); if (c && c.thread) e.caller = c.thread; }
  ACTIVITY_LOG.push(e);
  const cutoff = e.ts - ACT_LOG_TTL_MS;
  while (ACTIVITY_LOG.length > ACT_LOG_MAX || (ACTIVITY_LOG.length && ACTIVITY_LOG[0].ts < cutoff)) ACTIVITY_LOG.shift();
  try { fs.appendFile(ACT_FILE, JSON.stringify(e) + '\n', () => {}); } catch {}
}
// Janitor: bound the persistent trail (size + age) and sweep stale composited icons out of ICON_DIR.
// Runs at startup and every 6h; interval is unref'd so it never holds the process open.
const ACT_FILE_MAX_BYTES = 8 * 1024 * 1024, ACT_FILE_KEEP_LINES = 4000, ACT_FILE_MAX_AGE_MS = 14 * 24 * 3600 * 1000;
function janitor() {
  try {
    const st = fs.statSync(ACT_FILE);
    if (st.size > ACT_FILE_MAX_BYTES) {
      const lines = fs.readFileSync(ACT_FILE, 'utf8').split('\n').filter(Boolean).slice(-ACT_FILE_KEEP_LINES);
      const cutoff = Date.now() - ACT_FILE_MAX_AGE_MS;
      const kept = lines.filter((l) => { try { return (JSON.parse(l).ts || 0) >= cutoff; } catch { return false; } });
      fs.writeFileSync(ACT_FILE, kept.join('\n') + (kept.length ? '\n' : ''));
    }
  } catch {}
  try { // staged upload files (set_file_input contentBase64 mode): sweep anything older than 24h
    const updir = path.join(os.tmpdir(), 'adom-nbrowser-uploads');
    const cutoff = Date.now() - 24 * 3600 * 1000;
    for (const d of fs.readdirSync(updir)) {
      const dp = path.join(updir, d);
      for (const f of fs.readdirSync(dp)) { const fp = path.join(dp, f); try { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp); } catch {} }
      try { if (!fs.readdirSync(dp).length) fs.rmdirSync(dp); } catch {}
    }
  } catch {}
  try { // composited overlay icons are mtime-keyed per avatar; sweep ones not touched in 30 days
    const cutoff = Date.now() - 30 * 24 * 3600 * 1000;
    for (const f of fs.readdirSync(ICON_DIR)) {
      if (!f.startsWith('ov_') || !f.endsWith('.png')) continue;
      const p = path.join(ICON_DIR, f);
      try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch {}
    }
  } catch {}
}
setTimeout(janitor, 10000);
setInterval(janitor, 6 * 3600 * 1000).unref();
let flashMode = 'quiet';           // quiet | chatty | off
const COMELOOK = new Set(['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_screenshot_full_res', 'nbrowser_download']);
const ACTIVITY = new Set([...COMELOOK, 'nbrowser_reload', 'nbrowser_click', 'nbrowser_double_click', 'nbrowser_right_click', 'nbrowser_type', 'nbrowser_press_key', 'nbrowser_input_dispatch', 'nbrowser_hover', 'nbrowser_eval', 'nbrowser_evaluate', 'nbrowser_open_tab', 'nbrowser_back', 'nbrowser_forward', 'nbrowser_select', 'nbrowser_wait_for', 'nbrowser_switch_tab', 'nbrowser_switch_window', 'nbrowser_close_tab', 'nbrowser_set_date', 'nbrowser_download_wait']);
const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');

// All taskbar status goes through ab's desktop_taskbar (1.8.152): flash + progress + the Adom overlay
// badge, painted on the real browser window by hwnd. (Replaces the old run_script FlashWindowEx hack.)
function taskbar(hwnd, opts) {
  if (!hwnd) return Promise.resolve();
  return abCommand('desktop', 'desktop_taskbar', { hwnd, ...opts }).then((r) => {
    // LOG every overlay/flash (John, 2026-07-21): "log when you place the adom icon and when you flash
    // the taskbar so we can debug you better." A silently-failing badge is a broken TRUST signal, so it
    // must be visible in nbrowser_activity, with the HTTP status when ab refuses.
    const ops = [];
    const o = opts && opts.overlay;
    if (o) ops.push(o.icon ? 'overlay:composite' : `overlay:${o.badge || '?'}`);
    if (opts && opts.flash) ops.push(`flash:${opts.flash}`);
    if (opts && opts.progress) ops.push(`progress:${(opts.progress.state) || '?'}`);
    const ok = !!(r && r.status >= 200 && r.status < 300);
    logActivity({ verb: '_taskbar', hwnd, ops: ops.join('+') || 'none', ok, errorCode: ok ? null : `http_${(r && r.status) || 0}`, internal: true });
    return r;
  }).catch(() => {});
}
function flashHwnd(hwnd, stop) { taskbar(hwnd, { flash: stop ? 'stop' : 'until_focused' }); }

// ── BADGE IDLE-EXPIRY (John, 2026-07-14): the "Adom is driving" badge should reflect ACTIVE driving. If
// the AI stops sending commands to a window for a while, the badge auto-clears (so it doesn't imply the AI
// is still driving when it's idle/done); it re-appears on the next command. Re-touched on every drive verb.
const BADGE_IDLE_MS = 180000; // 3 min of no commands → badge fades
const badgeIdle = new Map();  // sessionId -> clear timer
const badgeOn = new Set();    // sessionIds currently badged
function armBadgeIdle(sid) {
  const prev = badgeIdle.get(sid); if (prev) clearTimeout(prev);
  badgeIdle.set(sid, setTimeout(() => { const h = sessionHwnd.get(sid); if (h) nativeOverlay(h, sessionProfile.get(sid)); badgeOn.delete(sid); badgeIdle.delete(sid); }, BADGE_IDLE_MS));
}
// Map a driven profile → its Chrome/Edge profile DIRECTORY, so ab (>=1.9.110) can composite the Adom mark
// onto that profile's AVATAR instead of evicting it (the taskbar overlay has one slot = the avatar's slot).
// ab can't derive this itself: Chrome/Edge run ONE browser process for all profiles, so hwnd→PID→cmdline
// has no --profile-directory. We read the browser's Local State (profile.info_cache: dir → {user_name}).
const ADOM_EXT_ID_PREFIX = 'nncjgceobm'; // our pinned (keyed) extension id - stable across packed/unpacked
const _avatarCache = new Map(); // profileKey -> {userDataDir, profileDir} | null
function avatarForProfile(profileKey) {
  if (!profileKey) return null;
  // pending-N / id keys carry NO email (fresh announce after a bridge restart, not yet resolved). Try
  // the LIVE connection's announced profile; if still unresolved, return null WITHOUT caching - the
  // caller must not paint a destructive fallback, and a later call may succeed once whoami resolves.
  let keyStr = String(profileKey);
  if (!keyStr.includes('@')) {
    const c = conns.get(keyStr);
    const em = c && c.profile && c.profile.email;
    if (em && String(em).includes('@')) keyStr = `${(c.profile.browser) || 'chrome'}:${em}`;
    else return null;
  }
  if (_avatarCache.has(keyStr)) return _avatarCache.get(keyStr);
  const profileKeyResolved = keyStr;
  let result = null;
  try {
    const i = profileKeyResolved.indexOf(':');
    const browser = i >= 0 ? profileKeyResolved.slice(0, i) : 'chrome';
    const email = i >= 0 ? profileKeyResolved.slice(i + 1) : profileKeyResolved;
    const local = process.env.LOCALAPPDATA;
    if (local && email.includes('@')) {
      const root = browser === 'edge' ? path.join(local, 'Microsoft', 'Edge', 'User Data') : path.join(local, 'Google', 'Chrome', 'User Data');
      const j = JSON.parse(fs.readFileSync(path.join(root, 'Local State'), 'utf8'));
      const cache = (j.profile && j.profile.info_cache) || {};
      for (const [dir, info] of Object.entries(cache)) {
        if (String((info && info.user_name) || '').toLowerCase() === email.toLowerCase()) {
          // Chrome writes the signed-in avatar to "Google Profile Picture.png"; Edge to "Edge Profile
          // Picture.png". Prefer the browser's own name, but try both so either browser resolves an avatar.
          const names = browser === 'edge' ? ['Edge Profile Picture.png', 'Google Profile Picture.png'] : ['Google Profile Picture.png', 'Edge Profile Picture.png'];
          let avatarPath = null;
          for (const n of names) { const p = path.join(root, dir, n); if (fs.existsSync(p)) { avatarPath = p; break; } }
          result = { userDataDir: root, profileDir: dir, avatarPath };
          break;
        }
      }
    }
  } catch {}
  _avatarCache.set(profileKeyResolved, result);
  return result;
}
const BADGE_ENABLED = true;
// The BRIDGE is the browser expert, so WE build the FINAL taskbar overlay icon ourselves and hand ab a
// ready PNG via overlay.icon (ab stays generic - no browser/avatar/folder knowledge). The icon is the
// profile's own avatar CIRCLE-cropped (the way Chrome/Edge draw it) with the Adom favicon sitting flush
// in the lower-right corner. Compositing runs natively on Windows via PowerShell + System.Drawing (GDI+):
// zero npm deps (the bridge has none), antialiased circle-crop + HighQualityBicubic resize, reads any PNG.
// Two variants per profile: WITH the favicon (driving) and WITHOUT it (the plain avatar = the native look,
// painted back when driving stops so the taskbar returns to exactly how Chrome had it - no blanked slot).
const FAVICON_PNG = path.join(__dirname, 'adom-favicon.png');            // LAYER 2: solid = "Adom is DRIVING this window"
const FAVICON_HOLLOW_PNG = path.join(__dirname, 'adom-favicon-hollow.png'); // LAYER 1: hollow = "Adom OPENED it, you're driving"
const ICON_DIR = path.join(os.tmpdir(), 'adom-nbrowser-icons');
const PS1_PATH = path.join(ICON_DIR, 'compose-overlay.ps1');
const PS1_BODY = [
  'param([string]$Avatar,[string]$Favicon,[string]$Out,[int]$Size,[int]$Badge)',
  '$ErrorActionPreference="Stop"',
  'Add-Type -AssemblyName System.Drawing',
  '$S=$Size; $W=$S*4',                                   // supersample 4x then downscale = clean edges
  '$av=[System.Drawing.Image]::FromFile($Avatar)',
  '$avSq=New-Object System.Drawing.Bitmap($W,$W,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)',
  '$g=[System.Drawing.Graphics]::FromImage($avSq)',
  '$g.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',
  '$g.PixelOffsetMode=[System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality',
  '$aw=$av.Width;$ah=$av.Height;$m=[Math]::Min($aw,$ah);$sx=($aw-$m)/2;$sy=($ah-$m)/2',   // center-crop to square
  '$g.DrawImage($av,(New-Object System.Drawing.Rectangle(0,0,$W,$W)),$sx,$sy,$m,$m,[System.Drawing.GraphicsUnit]::Pixel)',
  '$g.Dispose()',
  '$big=New-Object System.Drawing.Bitmap($W,$W,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)',
  '$g2=[System.Drawing.Graphics]::FromImage($big)',
  '$g2.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::AntiAlias',
  '$g2.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',
  '$brush=New-Object System.Drawing.TextureBrush($avSq)',                  // circle-crop = antialiased FillEllipse
  '$g2.FillEllipse($brush,0,0,($W-1),($W-1))',
  '$brush.Dispose()',
  'if($Badge -ne 0){',
  '  $fav=[System.Drawing.Image]::FromFile($Favicon)',
  '  $fs=[int][Math]::Round($W*0.46)',                                     // favicon ~46% of the icon
  '  $g2.DrawImage($fav,($W-$fs),($W-$fs),$fs,$fs)',                       // flush lower-right corner (no inset)
  '  $fav.Dispose()',
  '}',
  '$g2.Dispose()',
  '$fin=New-Object System.Drawing.Bitmap($S,$S,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb)',
  '$g3=[System.Drawing.Graphics]::FromImage($fin)',
  '$g3.InterpolationMode=[System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic',
  '$g3.PixelOffsetMode=[System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality',
  '$g3.SmoothingMode=[System.Drawing.Drawing2D.SmoothingMode]::HighQuality',
  '$g3.DrawImage($big,(New-Object System.Drawing.Rectangle(0,0,$S,$S)),0,0,$W,$W,[System.Drawing.GraphicsUnit]::Pixel)',
  '$g3.Dispose()',
  '$fin.Save($Out,[System.Drawing.Imaging.ImageFormat]::Png)',
  '$fin.Dispose();$big.Dispose();$avSq.Dispose();$av.Dispose()'
].join('\r\n');
try { fs.mkdirSync(ICON_DIR, { recursive: true }); fs.writeFileSync(PS1_PATH, PS1_BODY); } catch {}
const _iconCache = new Map(); // profileKey -> { mtime, badge: path|null, plain: path|null }
const _sanitizeKey = (k) => String(k).replace(/[^a-z0-9]+/gi, '_').slice(0, 60);
// Build (or return cached) the overlay PNG for a profile. withBadge=true → avatar + Adom favicon; false →
// plain circle avatar (the native look). Cache keyed on the avatar file's mtime, so a changed profile pic
// regenerates. Resolves to a file PATH (ab reads it - same machine) or null (no avatar photo for this
// profile → caller falls back to ab's built-in plain badge / clears).
function buildOverlayIcon(profileKey, withBadge, style) {
  return new Promise((resolve) => {
    try {
      const av = avatarForProfile(profileKey);
      if (!av || !av.avatarPath) return resolve(null);
      let mtime = 0; try { mtime = Math.round(fs.statSync(av.avatarPath).mtimeMs); } catch {}
      const hollow = style === 'hollow';
      const slot = withBadge ? (hollow ? 'badge_hollow' : 'badge') : 'plain';
      const cached = _iconCache.get(profileKey);
      if (cached && cached.mtime === mtime && cached[slot] && fs.existsSync(cached[slot])) return resolve(cached[slot]);
      const out = path.join(ICON_DIR, `ov_${_sanitizeKey(profileKey)}_${slot}_${mtime}.png`);
      const record = (p) => { const c = (cached && cached.mtime === mtime) ? cached : { mtime }; c.mtime = mtime; c[slot] = p; _iconCache.set(profileKey, c); };
      if (fs.existsSync(out)) { record(out); return resolve(out); }
      execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', PS1_PATH,
        '-Avatar', av.avatarPath, '-Favicon', hollow ? FAVICON_HOLLOW_PNG : FAVICON_PNG, '-Out', out, '-Size', '48', '-Badge', withBadge ? '1' : '0'],
        { timeout: 15000, windowsHide: true }, (err) => {
          if (err || !fs.existsSync(out)) return resolve(null);
          record(out); resolve(out);
        });
    } catch { resolve(null); }
  });
}
// Fire whoami at every live conn that hasn't resolved its email yet (pending-N keys), so avatar lookups
// start succeeding. Throttled; response rides the normal announce/whoami plumbing.
let _lastResolveNudge = 0;
function resolvePendingProfiles() {
  const now = Date.now(); if (_lastResolveNudge + 10000 > now) return; _lastResolveNudge = now;
  for (const [k, c] of conns) {
    if (!c.sock || c.sock.destroyed) continue;
    const em = c.profile && c.profile.email;
    if (em && String(em).includes('@')) continue;
    try { const id = ++seq; pending.set(id, { resolve: () => {}, timer: setTimeout(() => pending.delete(id), 4000), verb: 'nbrowser_whoami', profileKey: k, _internal: true }); c.sock.write(JSON.stringify({ id, verb: 'nbrowser_whoami', args: {} }) + '\n'); } catch {}
  }
}
// Paint the "Adom is driving" badge: our composite (circle avatar + favicon corner) or NOTHING yet.
// ⛔ NEVER fall back to the bare Adom badge on a real browser button - the overlay slot holds the
// user's PROFILE AVATAR, and stomping it with a full favicon is exactly what this feature exists to
// prevent (John hit it live, 2026-07-18: a pending-N key -> no avatar -> plain badge evicted his work
// avatar). If the composite can't be built yet (unresolved profile / no avatar file), we skip the
// paint, nudge profile resolution, and the caller's badgeFallback retry upgrades it on a later touch.
const badgeFallback = new Set(); // sids whose badge paint is deferred until the composite is buildable
// WINDOW OWNERSHIP LABEL (wiki #345 ask 3 - the payoff John asked for): stamp the OWNING AI THREAD on
// the window's taskbar identity, so hovering a taskbar button tells the user which of their ~20
// conversations owns that window. Best-effort: a failure here never blocks driving.
function labelWindowOwner(hwnd, sid) {
  if (!hwnd) return;
  const owner = (sid && sessionCaller.get(sid)) || callerLabel();
  if (!owner) return;
  const purpose = (sid && sessionPurpose.get(sid)) || '';
  abCommand('desktop', 'desktop_set_window_identity', {
    hwnd,
    appId: 'Adom.NativeBrowser',
    displayName: `Adom · ${owner}${purpose ? ' · ' + String(purpose).slice(0, 60) : ''}`,
  }).then((r) => {
    const ok = !!(r && r.status >= 200 && r.status < 300);
    logActivity({ verb: '_window_identity', sessionId: sid || null, hwnd, ops: owner, ok, internal: true });
  }).catch(() => {});
}
function paintBadge(hwnd, profileKey, sid, style) {
  if (!hwnd) return;
  const hollow = style === 'hollow';
  const tip = hollow ? 'Adom opened this window - you are in control' : 'Adom is driving this browser';
  buildOverlayIcon(profileKey, true, style).then((icon) => {
    if (icon) { taskbar(hwnd, { overlay: { icon, tooltip: tip } }); if (sid) badgeFallback.delete(sid); }
    else { if (sid) badgeFallback.add(sid); resolvePendingProfiles(); }
  });
}
// Return a window to its NATIVE look: the plain circle avatar (same image Chrome shows), NOT a blanked
// overlay slot. If we have no avatar photo, clear to none (best we can do).
function nativeOverlay(hwnd, profileKey) {
  if (!hwnd) return;
  buildOverlayIcon(profileKey, false).then((icon) => {
    taskbar(hwnd, icon ? { overlay: { icon } } : { overlay: { badge: 'none' } });
  });
}
function applyBadge(sid, hwnd, profileKey, style) {
  if (!BADGE_ENABLED || !sid || !hwnd) return;
  badgeOn.add(sid); armBadgeIdle(sid);
  paintBadge(hwnd, profileKey || sessionProfile.get(sid), sid, style);
}
// ── FOREGROUND ANNOUNCE (John, 2026-07-22): background is the hard default and foreground already
// REQUIRES a written reason — but that reason only ever reached the AI. When a foreground grant is
// actually exercised, SHOW THE HUMAN why, full-screen, for 3s. A takeover you can't explain is exactly
// what erodes trust in an agent that can touch your desktop.
// ANY open the user will actually SEE gets announced (John, 2026-07-22): "if you know it's going to
// foreground on its own cuz that's just the way the browser acts, that's fine, allow it, but put up a
// 2 second caption with the reason." We do NOT fight the browser's natural behavior - we just never let
// a page appear in front of the user without saying what it is and why. 2s, auto-dismiss. `why` falls
// back to the session PURPOSE, which registration already makes mandatory, so there is always something
// honest to show.
function announceVisibleOpen(what, why, sid) {
  const reason = String(why || (sid && sessionPurpose.get(sid)) || '').trim().slice(0, 160) || 'no reason given';
  abCommand(null, 'desktop_caption', { text: `Adom ${what} - ${reason}`, id: 'adom-foreground', position: 'bottom', duration: 3000 })
    .then((r) => { const ok = !!(r && r.status >= 200 && r.status < 300); logActivity({ verb: '_open_announce', sessionId: sid || null, ok, ops: `${what} | ${reason}`.slice(0, 110), errorCode: ok ? null : `http_${(r && r.status) || 0}`, internal: true }); })
    .catch(() => {});
}
function announceForeground(reason, sid) { announceVisibleOpen('is bringing a window to the front', reason, sid); }
// ── SESSION PERSISTENCE — sessionHwnd/sessionProfile are the badge's memory, and they used to be
// in-memory only: every bridge redeploy WIPED them, so a thread that kept driving after a restart never
// got its badge back ("no badge until it opens a fresh window"). Persist {sid -> hwnd+profile} to disk on
// every change and restore at startup (validating each hwnd still exists - browser windows survive a
// bridge restart, so the hwnds are usually still good).
const SESS_FILE = path.join(ICON_DIR, 'sessions.json');
function persistSessions() {
  try {
    const out = {};
    // Persist every pinned session (profile routing matters across restarts even before an hwnd is
    // captured) plus every hwnd-mapped one.
    for (const [sid, profile] of sessionProfile) out[sid] = { hwnd: sessionHwnd.get(sid) || null, profile, thread: sessionThread.get(sid) || null, purpose: sessionPurpose.get(sid) || null, os: osOpened.has(sid) || undefined };
    for (const [sid, hwnd] of sessionHwnd) if (!out[sid]) out[sid] = { hwnd, profile: sessionProfile.get(sid) || null, thread: sessionThread.get(sid) || null, purpose: sessionPurpose.get(sid) || null };
    fs.writeFileSync(SESS_FILE, JSON.stringify(out));
  } catch {}
}
function restoreSessions(liveHwnds) {
  try {
    const j = JSON.parse(fs.readFileSync(SESS_FILE, 'utf8'));
    for (const [sid, s] of Object.entries(j)) {
      if (!s) continue;
      // hwnd only if that window still exists; profile pin/thread/purpose restore regardless (they
      // keep sid-keyed verbs routing to the RIGHT profile across our restart).
      if (s.hwnd && liveHwnds.has(s.hwnd) && !sessionHwnd.has(sid)) sessionHwnd.set(sid, s.hwnd);
      if (s.profile && !sessionProfile.has(sid)) sessionProfile.set(sid, s.profile);
      if (s.thread && !sessionThread.has(sid)) sessionThread.set(sid, s.thread);
      if (s.purpose && !sessionPurpose.has(sid)) sessionPurpose.set(sid, s.purpose);
      if (s.os) osOpened.add(sid);
    }
    persistSessions();
  } catch {}
}
// ── BRIDGE-ORIGINATED EXTENSION QUERY — lets the bridge ask a profile's extension something on its own
// behalf (same pending/frame plumbing the ab relay uses). Used to re-correlate a profile's windows when a
// thread drives PROFILE-KEYED (no sessionId) and we have no hwnd for it.
function extQuery(profileKey, verb, args, timeoutMs) {
  return new Promise((resolve) => {
    const tgt = targetConn({ profile: profileKey });
    if (!tgt || tgt.blocked || !tgt.conn) return resolve(null);
    const id = ++seq;
    const timer = setTimeout(() => { pending.delete(id); resolve(null); }, timeoutMs || 8000);
    pending.set(id, { resolve: (r) => resolve(r && r.success !== false ? r : null), timer, verb, profileKey: tgt.key, _internal: true });
    try { tgt.conn.sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n'); } catch { clearTimeout(timer); pending.delete(id); resolve(null); }
  });
}
// ── PROFILE-KEYED BADGE TOUCH — THE RULE (learned 2026-07-15, John caught it live): every cross-cutting
// feature must fire on EVERY addressing path a verb supports. Threads legitimately drive with profile:
// only (no sessionId) - the shopping thread did exactly that (open_window with sid, then profile-keyed
// screenshots) and the badge never re-asserted because touchBadge keyed ONLY on sessionId. This is the
// profile-keyed twin: touch every known session pinned to the profile; if none has an hwnd, lazily ask
// the profile's own extension for its windows (throttled) and repair via badgeSpawnsIn - which also
// re-badges adopted spawns. The extension query is exact (it knows its own windows), so this can never
// hit another profile's window or a PWA.
const _profCorrAt = new Map(); // profileKey -> last correlation attempt ts
const _osCloseTickets = new Map(); // OS-level force_close: ticket -> {hwnd, expires}
const osOpened = new Set(); // sessionIds created by nbrowser_open_os_window (no extension session behind them)
function touchProfileBadge(profileKey) {
  if (!BADGE_ENABLED || !profileKey) return;
  let touched = false;
  for (const [sid, pk] of sessionProfile) if (pk === profileKey && sessionHwnd.has(sid)) { touchBadge(sid, profileKey); touched = true; }
  if (touched) return;
  const now = Date.now();
  if ((_profCorrAt.get(profileKey) || 0) + 30000 > now) return; // throttle: at most one repair query / 30s / profile
  _profCorrAt.set(profileKey, now);
  extQuery(profileKey, 'nbrowser_list_windows', {}).then((r) => {
    // pending resolves with the ab-facing wrapper {success, output:"<json>"} - unwrap to the raw payload
    let payload = r;
    if (r && typeof r.output === 'string') { try { payload = JSON.parse(r.output); } catch { payload = null; } }
    if (payload) badgeSpawnsIn(payload, profileKey).catch(() => {});
  });
}
// Called on every DRIVE verb: the badge tracks ACTIVE driving, not mere ownership. On the first activity
// (or after a bridge restart) it lazily correlates the driven profile's taskbar window; then it just
// re-arms the idle timer. An IDLE owned window (a thread opened it then went quiet) is NEVER badged here,
// so it fades and stays faded — fixing the "personal profile badged when nothing's driving it" bug.
function touchBadge(sid, profileKey) {
  if (!sid) return;
  // ONLY the window we captured at open_window (the real browser window the AI created). We deliberately
  // do NOT re-correlate by OS title here - that is exactly what reached the wrong window (a Gmail PWA
  // that merely had the profile email in its title). Restart amnesia is handled elsewhere now: the
  // sid->hwnd map is persisted + restored at startup, and profile-keyed drives re-correlate via the
  // profile's OWN extension (touchProfileBadge -> badgeSpawnsIn), which cannot hit a PWA.
  const h = sessionHwnd.get(sid);
  if (!h) {
    // Self-heal: no hwnd for an ACTIVELY-DRIVEN session (e.g. it opened before a bridge restart that
    // predates sessions.json). Ask the profile's own extension for its windows (throttled) and repair.
    const pk = profileKey || sessionProfile.get(sid);
    if (pk) { const now = Date.now(); if ((_profCorrAt.get(pk) || 0) + 30000 <= now) { _profCorrAt.set(pk, now); extQuery(pk, 'nbrowser_list_windows', {}).then((r) => { let pl = r; if (r && typeof r.output === 'string') { try { pl = JSON.parse(r.output); } catch { pl = null; } } if (pl) badgeSpawnsIn(pl, pk).catch(() => {}); }); } }
    return;
  }
  if (!badgeOn.has(sid)) applyBadge(sid, h, profileKey);
  else { armBadgeIdle(sid); if (badgeFallback.has(sid)) applyBadge(sid, h, profileKey); } // deferred paint: retry the composite now that the profile may have resolved
}
function clearBadge(sid) { const t = badgeIdle.get(sid); if (t) clearTimeout(t); badgeIdle.delete(sid); badgeOn.delete(sid); }
// Capture the driven window's hwnd by DIFFING the real-Chrome window list across the open (the new
// hwnd is the one we opened). Robust vs Windows foreground-lock - no GetForegroundWindow guessing.
let preOpenHwnds = new Set();
async function chromeHwnds() {
  const r = await abCommand('desktop', 'desktop_list_windows', {});
  let out = r.body;
  if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }
  const ws = (out && out.data && out.data.windows) || (out && out.windows) || [];
  return new Set(ws.filter((w) => /Chrome_WidgetWin/.test(w.className || '') && !/for Testing/.test(w.title || '')).map((w) => w.hwnd));
}
// Full Chrome window list (with rects/titles) so we can correlate a SPAWNED window's bounds/title to its
// OS hwnd. Spawns have their own hwnd but no sessionId, so we can't diff them at open like open_window.
async function chromeWindowsDetailed() {
  const r = await abCommand('desktop', 'desktop_list_windows', {});
  let out = r.body;
  if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }
  const ws = (out && out.data && out.data.windows) || (out && out.windows) || [];
  return ws.filter((w) => /Chrome_WidgetWin/.test(w.className || '') && !/for Testing/.test(w.title || ''));
}
function matchHwnd(list, bounds, title) {
  // TITLE first — DPI-INDEPENDENT. chrome.windows bounds are LOGICAL px, but the OS window rects are
  // PHYSICAL px, so on a scaled display (e.g. 150%) bounds won't match. The OS window title is
  // "<page title> - <Browser>", so the session's page title is a PREFIX of it. Prefer that exact prefix
  // (disambiguates two windows whose titles share a substring, e.g. two Gusto pages); fall back to a
  // substring match. Exclude PWA/app windows (they can carry the profile email in-title but are NOT the
  // browser window the user is watching) by requiring the "- Google Chrome"/"- Microsoft Edge" suffix.
  const t = title && String(title).trim();
  if (t && t.length >= 6) {
    const isBrowserWin = (w) => / - (Google Chrome|Microsoft.?\s*Edge)\s*$/i.test(String(w.title || ''));
    const pre = list.find((w) => isBrowserWin(w) && String(w.title || '').startsWith(t));            // exact page-title prefix
    if (pre) return pre.hwnd;
    const sub = list.find((w) => isBrowserWin(w) && String(w.title || '').includes(t.slice(0, 40)));  // substring, still a real browser window
    if (sub) return sub.hwnd;
  }
  // Fallback: bounds (only reliable at 100% scaling).
  if (bounds) for (const w of list) { const r = w.rect || {}; if (r.left != null && r.top != null && Math.abs(r.left - bounds.left) <= 24 && Math.abs(r.top - bounds.top) <= 24) return w.hwnd; }
  return null;
}
// (Re)paint the "Adom is driving" badge from a nbrowser_owned / nbrowser_list_windows payload, correlating
// each window's OS hwnd by BOUNDS (profile-independent). This badges adopted SPAWNED windows AND
// SELF-HEALS agent-OPENED windows: if the open-time badge landed on the wrong profile's window (the old
// diff bug), a list call re-correlates by bounds, clears the STALE badge, and paints the correct one.
function emailOf(key) { const s = String(key || ''); const i = s.indexOf(':'); return i >= 0 ? s.slice(i + 1) : s; }
async function badgeSpawnsIn(payload, profileKey) {
  const spawns = []; // { windowId, bounds, title }
  const owned = [];  // { sid, bounds, title }
  if (payload && Array.isArray(payload.spawned)) for (const s of payload.spawned) if (s.kind === 'window' && s.windowId != null) spawns.push({ windowId: s.windowId, bounds: s.bounds, title: s.title });
  if (payload && Array.isArray(payload.sessions)) for (const s of payload.sessions) {
    if (s.windowId == null) continue;
    if (s.owner === 'agent-spawned') spawns.push({ windowId: s.windowId, bounds: s.bounds, title: s.title || s.url });
    else if (s.owner === 'agent-opened' && s.id) owned.push({ sid: s.id, bounds: s.bounds, title: s.title || s.url });
  }
  const fresh = spawns.filter((s) => !spawnHwnd.has(s.windowId));
  if (!fresh.length && !owned.length) return;
  let list; try { list = await chromeWindowsDetailed(); } catch { return; }
  for (const s of fresh) { const h = matchHwnd(list, s.bounds, s.title); if (h) { spawnHwnd.set(s.windowId, h); paintBadge(h, profileKey); } }
  // Correlate each owned session to its OWN browser window by its page title (matchHwnd, which prefers
  // an exact title prefix and only matches real browser windows). The old "profileHwnd = first window
  // whose title contains the profile email" shortcut is REMOVED: it matched the john@adom.inc "Adom
  // Mail"/Gmail PWA window and badged THAT instead of the Chrome window the user was watching — a real
  // regression (badge invisible on the driven work-Chrome button; the whole trust signal broke).
  for (const o of owned) {
    const h = matchHwnd(list, o.bounds, o.title);
    if (!h) continue;
    const old = sessionHwnd.get(o.sid);
    if (old !== h) { if (old && old !== h) nativeOverlay(old, profileKey); sessionHwnd.set(o.sid, h); persistSessions(); lastHwnd = h; clearBadge(o.sid); applyBadge(o.sid, h); }
    else touchBadge(o.sid);
  }
}
// A "come look" moment = a verb in COMELOOK, OR a semantic signal in the payload that the user should
// glance over: an error/refusal, an idle-timeout risk, a human gate (captcha/2FA/login), or fresh spawned
// windows to review. This is what makes the orange cue fire on the moments that matter, not just navigate/
// screenshot. (John: it wasn't flashing enough on updates.)
function isComeLook(verb, payload) {
  if (COMELOOK.has(verb)) return true;
  if (!payload || typeof payload !== 'object') return false;
  if (payload.errorCode || (payload.ok === false && payload.error)) return true;     // an error/refusal
  if (payload._keepaliveWarning || payload._warning) return true;                     // idle-timeout / focus revert
  if (payload.needs2FA || payload.needsHuman || payload.captcha || payload.isHumanGate) return true; // human gate
  if (Array.isArray(payload.spawned) && payload.spawned.length) return true;          // fresh spawns to review
  return false;
}
// auto-flash after a verb, per the user's chosen mode. off = total silence (the user opt-out).
function maybeAutoFlash(verb, payload) {
  if (flashMode === 'off') return; // user opt-out: no auto-flash at all
  const hwnd = (payload && payload.sessionId && sessionHwnd.get(payload.sessionId)) || (payload && payload.windowId && spawnHwnd.get(payload.windowId)) || lastHwnd;
  if (!hwnd) return;
  const fire = flashMode === 'chatty' ? (ACTIVITY.has(verb) || isComeLook(verb, payload)) : isComeLook(verb, payload);
  if (fire) flashHwnd(hwnd, false);
}
const FLASH_HINT = 'This window\'s Windows taskbar can signal the user. flashMode=' ;
function flashHint() {
  return `${FLASH_HINT}${flashMode}. Get the user's attention: nbrowser_flash {sessionId}. The USER may prefer more/less noise - nbrowser_flash_mode {mode:"quiet"|"chatty"|"off"} (quiet=come-look moments only [default]; chatty=every action, for users who want to watch; off=silent). On a human-in-the-loop gate (captcha/2FA/login), nbrowser_flash so they come help. Never leave it strobing - it auto-clears on focus, or nbrowser_flash_stop.`;
}

// ============================================================================================
// Native Google Chrome installer — for a user on an Edge-only box who wants the AI to drive their
// REAL Chrome. The whole point of this bridge is the AI controlling the user's native browser; if they
// have no Chrome, we get them one. Mirrors pup's proven flow: download Google's OFFICIAL offline
// installer, run a QUIET user-scoped install (.exe --silent --install, no admin on a normal PC → %LOCALAPPDATA%\
// Google\Chrome), and on a locked-down box (VM/managed) ESCALATE to a notify-FIRST UAC (fire a sticky
// "Approve now" toast via the ab direct-API, wait for the tap, THEN raise the prompt so the secure
// desktop doesn't hide the toast; re-arm if it expires; ping peer abs if the user is on another
// machine). Non-blocking: nbrowser_install_chrome returns immediately; poll nbrowser_chrome_status.
// ============================================================================================
const CHROME_STABLE_URL = 'https://dl.google.com/chrome/install/standalonesetup64.exe';
// The enterprise MSI is the RELIABLE escalation path: msiexec /i /qn installs system-wide directly as
// admin with NO de-elevation handoff (the standalone .exe's `--expect-de-elevated` step fails under
// automation on locked-down boxes) and no INVALID_OPTION arg-format surprises. Used when the quiet
// user-scoped .exe install doesn't land. ~156 MB.
const CHROME_MSI_URL = 'https://dl.google.com/chrome/install/googlechromestandaloneenterprise64.msi';
const chromeState = { phase: 'idle', error: null, errorCode: null, startedAt: null, finishedAt: null, awaitingUacSince: null, notifyChannel: null };
let _chromeInFlight = null;

function detectChrome() {
  if (process.platform !== 'win32') return null;
  const home = process.env.USERPROFILE || os.homedir();
  const pf = process.env['ProgramFiles'] || 'C:\\Program Files';
  const pf86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
  const cands = [
    path.join(pf, 'Google', 'Chrome', 'Application', 'chrome.exe'),
    path.join(pf86, 'Google', 'Chrome', 'Application', 'chrome.exe'),
    path.join(home, 'AppData', 'Local', 'Google', 'Chrome', 'Application', 'chrome.exe'),
  ];
  for (const c of cands) { try { if (fs.existsSync(c)) return c; } catch {} }
  return null;
}
const _sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const _psq = (s) => "'" + String(s).replace(/'/g, "''") + "'";

function _downloadChrome(url, destPath, redirects = 0) {
  return new Promise((resolve, reject) => {
    if (redirects > 5) return reject(new Error('too many redirects fetching the Chrome installer'));
    const https = require('https');
    const file = fs.createWriteStream(destPath);
    https.get(url, (resp) => {
      if ([301, 302, 303, 307, 308].includes(resp.statusCode) && resp.headers.location) {
        file.close(); try { fs.unlinkSync(destPath); } catch {}
        return resolve(_downloadChrome(resp.headers.location, destPath, redirects + 1));
      }
      if (resp.statusCode !== 200) { file.close(); try { fs.unlinkSync(destPath); } catch {} return reject(new Error(`HTTP ${resp.statusCode} fetching ${url}`)); }
      resp.pipe(file);
      file.on('finish', () => file.close(() => resolve(destPath)));
      file.on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });
    }).on('error', (e) => { try { fs.unlinkSync(destPath); } catch {} reject(e); });
  });
}

// notify helpers via the ab direct-API (app-less on ab 1.9.84). Best-effort: null on any failure.
// WHERE the toast lands: a bridge on a VM must reach the user on their LAPTOP, not fire on the VM's own
// (unwatched) desktop. So if the relay shows any PEER desktop, we notify `target:"all"` (the SDK cross-ab
// pattern) - the user sees it wherever they're attended. No peers (we're on the user's own machine) =
// local toast. Cached ~30s so we don't hit `targets` on every poll. (Users keep an RDP session open to
// their VMs, so a laptop toast that says "approve the UAC on <host>" is actionable immediately.)
let _ntCache = { at: 0, val: null };
async function _notifyTarget() {
  const now = Date.now();
  if (now - _ntCache.at < 30000) return _ntCache.val;
  let target = null;
  try {
    const r = await abCommand(null, 'targets', {});
    const list = (r && r.body && (r.body.targets || (r.body.data && r.body.data.targets) || [])) || [];
    let self = ''; try { self = os.hostname().toLowerCase(); } catch {}
    const peers = list.filter((t) => String((t && (t.name || t.hostname)) || '').toLowerCase() !== self);
    if (peers.length) target = 'all';
  } catch {}
  _ntCache = { at: now, val: target };
  return target;
}
const _notify = async (args) => abCommand(null, 'notify_user', args, await _notifyTarget());
const _notifyResp = async (id) => abCommand(null, 'notify_response', { id }, await _notifyTarget());
async function _adCanNotify() {
  const r = await abCommand(null, 'notify_user', { id: '_probe_', title: '', body: '', level: 'info', _probe: true }).catch(() => null);
  // if ab accepted (any 2xx-ish body) treat notify as available; unknown-verb → unavailable
  const body = r && r.body;
  if (!r || r.status === 0) return false;
  const err = body && (body.error || body.errorCode);
  if (err && /unknown/i.test(String(err))) return false;
  return true;
}

// Raise the UAC by running msiexec ELEVATED on the enterprise MSI. -Wait -PassThru so we get msiexec's
// real exit code (0 = installed; 1602/1223 = user cancelled the UAC/install; others = failure). NOT
// hidden and NOT the .exe path - a hidden elevated .exe hit the `--expect-de-elevated` handoff failure.
function _raiseElevatedMsi(msiPath) {
  return new Promise((resolve) => {
    try {
      const { spawn } = require('child_process');
      const ps = `try { $p = Start-Process -FilePath 'msiexec.exe' -ArgumentList '/i',${_psq(msiPath)},'/qn','/norestart' -Verb RunAs -Wait -PassThru -ErrorAction Stop; exit $p.ExitCode } catch { exit 1223 }`;
      const child = spawn('powershell.exe', ['-NoProfile', '-Command', ps], { windowsHide: true });
      let done = false; const fin = (c) => { if (!done) { done = true; resolve(c); } };
      child.on('error', () => fin(-1));
      child.on('exit', (code) => fin(code == null ? -1 : code));
      setTimeout(() => fin(-1), 5 * 60 * 1000); // msiexec install can take a couple minutes; guard at 5
    } catch { resolve(-1); }
  });
}

// Locked-down box path: quiet install didn't land → drive the user to the UAC. Notify-FIRST if ab can
// notify (fire toast, WAIT for tap, THEN raise UAC so the secure desktop doesn't hide it; re-arm on
// expiry). If ab can't notify, raise ONCE and surface awaiting_uac for the caller to relay (no spam).
async function _elevatedChromeWithNotify(msiPath) {
  const id = 'nbrowser-chrome-install';
  chromeState.phase = 'awaiting_uac'; chromeState.awaitingUacSince = Date.now();
  const canNotify = await _adCanNotify();
  chromeState.notifyChannel = canNotify;
  const host = (() => { try { return os.hostname(); } catch { return 'this PC'; } })();
  const pollChrome = async (ms) => { const end = Date.now() + ms; while (Date.now() < end) { await _sleep(2500); const c = detectChrome(); if (c) return c; } return null; };

  if (!canNotify) {
    // no way to notify - raise the UAC once (msiexec blocks -Wait until the user answers) and check.
    await _raiseElevatedMsi(msiPath);
    const c = detectChrome(); return c || await pollChrome(30 * 1000);
  }
  const doneToast = () => _notify({ id, level: 'success', title: 'Chrome is installed ✓', body: 'Google Chrome is ready. Next: sign into your Google account, then load the Adom extension (setting-up-native-chrome).' });

  // Is the user on a DIFFERENT machine? (this bridge runs on a VM; the user watches it over RDP from their
  // laptop.) Then the UAC will appear on THIS host - VISIBLE in their open RDP session - and the alert
  // toast lands on their LAPTOP (a different machine), which the VM's secure desktop can't hide. So do NOT
  // wait for a tap first (that left the UAC never raised and the user, watching the VM, saw nothing - the
  // exact bug). Alert + raise the UAC RIGHT AWAY; they click YES in their RDP window. Retry once.
  const remote = (await _notifyTarget()) === 'all';
  if (remote) {
    for (let attempt = 0; attempt < 2; attempt++) {
      await _notify({ id, level: 'warning', scenario: 'reminder',
        title: attempt ? `Chrome still needs approval on ${host}` : `Chrome needs your approval on ${host}`,
        body: `A Windows UAC prompt is opening on ${host} (your open RDP session) to install Google Chrome - click YES on it.` });
      await _raiseElevatedMsi(msiPath);                  // shows the UAC on THIS host; -Wait blocks until answered
      const c = detectChrome() || await pollChrome(20 * 1000);
      if (c) { await doneToast(); return c; }
    }
    return null;
  }

  // LOCAL (user's OWN machine): notify-FIRST, wait for the tap, THEN raise - so the LOCAL secure desktop
  // doesn't hide the LOCAL toast before they've read it. Re-notify ONLY after a tap whose UAC didn't
  // complete (never on a timer - that stormed the user). scenario:reminder keeps the single toast sticky.
  const fireToast = (again) => _notify({ id, level: 'warning', scenario: 'reminder', buttons: [{ label: 'Approve now' }],
    title: again ? 'Chrome still needs approval' : 'Chrome needs your approval',
    body: `Tap 'Approve now', then click YES on the Windows security prompt to install Google Chrome on ${host}.` });
  await fireToast(false);
  const OVERALL_MS = 10 * 60 * 1000; const t0 = Date.now();
  while (Date.now() - t0 < OVERALL_MS) {
    await _sleep(2500);
    const c = detectChrome();
    if (c) { await doneToast(); return c; }
    const resp = await _notifyResp(id);
    const rb = resp && resp.body;
    if (rb && rb.pending === false && rb.action) {
      await _raiseElevatedMsi(msiPath);                 // they tapped -> run msiexec elevated (blocks until UAC answered + install done)
      const c2 = detectChrome() || await pollChrome(20 * 1000);
      if (c2) { await doneToast(); return c2; }
      await fireToast(true);                             // UAC declined/failed -> ONE re-notify, keep polling
    }
  }
  return null;
}

// opts.elevate: 'auto' (default) quiet-then-escalate | true skip-quiet | false quiet-only-no-prompt.
function installNativeChrome(opts = {}) {
  if (_chromeInFlight) return _chromeInFlight;
  const elevate = opts.elevate === undefined ? 'auto' : opts.elevate;
  _chromeInFlight = (async () => {
    chromeState.phase = 'installing'; chromeState.error = null; chromeState.errorCode = null;
    chromeState.startedAt = Date.now(); chromeState.finishedAt = null; chromeState.awaitingUacSince = null;
    try {
      const pre = detectChrome();
      if (pre) { chromeState.phase = 'ready'; chromeState.finishedAt = Date.now(); return { ok: true, executablePath: pre, alreadyInstalled: true }; }
      if (process.platform !== 'win32') {
        chromeState.phase = 'failed'; chromeState.errorCode = 'chrome_unsupported_os';
        chromeState.error = 'Auto-installing Google Chrome is wired up for Windows only.';
        return { ok: false, errorCode: chromeState.errorCode, error: chromeState.error };
      }
      let found = null;
      // Phase 1 (quiet, no admin): the standalone .exe with the CORRECT `--silent --install` args does a
      // per-user install on a normal PC with NO prompt. (The old code passed `/silent /install`, which
      // this newer Google-Updater installer REJECTS with a "Setup error: INVALID_OPTION" dialog - the
      // root cause of every silent-install failure.)
      const exeTmp = path.join(os.tmpdir(), `chrome_installer_${process.pid}.exe`);
      if (elevate !== true) {
        try {
          await _downloadChrome(CHROME_STABLE_URL, exeTmp);
          const { spawn } = require('child_process');
          try { spawn(exeTmp, ['--silent', '--install'], { windowsHide: true }); } catch {}
          for (let i = 0; i < 30; i++) { found = detectChrome(); if (found) break; await _sleep(1500); } // ~45s
        } catch {}
      }
      try { fs.unlinkSync(exeTmp); } catch {}
      // Phase 2 (escalate): download the enterprise MSI and run msiexec /i /qn ELEVATED via the
      // notify-first UAC. The MSI installs system-wide directly (no de-elevation handoff) - reliable
      // where the .exe's elevated path fails on locked-down boxes.
      if (!found && elevate !== false) {
        const msiTmp = path.join(os.tmpdir(), `chrome_enterprise_${process.pid}.msi`);
        try { await _downloadChrome(CHROME_MSI_URL, msiTmp); found = await _elevatedChromeWithNotify(msiTmp); } catch {}
        try { fs.unlinkSync(msiTmp); } catch {}
      }
      if (!found) {
        chromeState.phase = 'failed';
        if (elevate === false) { chromeState.errorCode = 'chrome_not_detected'; chromeState.error = 'Ran the Chrome installer but chrome.exe did not appear (no elevation, elevate:false).'; }
        else if (chromeState.notifyChannel) { chromeState.errorCode = 'chrome_needs_approval'; chromeState.error = 'Chrome needs a Windows approval (UAC) that was not accepted in time. A sticky "Approve now" toast is on the desktop; tapping it re-opens the prompt.'; }
        else { chromeState.errorCode = 'chrome_needs_approval_uncced'; chromeState.error = 'Chrome needs a Windows approval (UAC), but this ab cannot pop a notification. RELAY TO THE USER: go to the machine and click YES on the Google Chrome installer prompt, then re-call nbrowser_install_chrome.'; }
        return { ok: false, errorCode: chromeState.errorCode, error: chromeState.error };
      }
      chromeState.phase = 'ready'; chromeState.finishedAt = Date.now();
      return { ok: true, executablePath: found.executablePath || found };
    } catch (e) {
      chromeState.phase = 'failed';
      const msg = (e && e.message) || String(e);
      chromeState.errorCode = /ENOSPC|space/i.test(msg) ? 'chrome_install_no_disk' : /ENOTFOUND|ETIMEDOUT|ECONNRESET|network|proxy|getaddrinfo/i.test(msg) ? 'chrome_download_network' : 'chrome_install_failed';
      chromeState.error = msg;
      return { ok: false, errorCode: chromeState.errorCode, error: msg };
    } finally { _chromeInFlight = null; }
  })();
  return _chromeInFlight;
}
function chromeStatus() {
  const exe = detectChrome();
  return { installed: !!exe, executablePath: exe || null, phase: exe ? 'ready' : chromeState.phase, error: chromeState.error, errorCode: chromeState.errorCode, installing: !!_chromeInFlight, awaitingUac: chromeState.phase === 'awaiting_uac', notifyChannel: chromeState.notifyChannel };
}

// ============================================================================================
// #3 TOTP — per-host 2FA. The seed is stored 0600 on the laptop and NEVER returned; the bridge (Node)
// computes the current RFC-6238 code and the extension types it into the focused field, so the AGENT
// never sees the seed OR the code. This is the #1 unblock for unattended vendor automation.
// ============================================================================================
const crypto = require('crypto');
const TOTP_FILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'totp-seeds.json');
function _b32decode(s) {
  const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
  s = String(s).toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '');
  let bits = 0, val = 0; const out = [];
  for (const ch of s) { val = (val << 5) | A.indexOf(ch); bits += 5; if (bits >= 8) { bits -= 8; out.push((val >>> bits) & 0xff); } }
  return Buffer.from(out);
}
function _totpCode(seed, { step = 30, digits = 6, t = Date.now() } = {}) {
  const key = _b32decode(seed);
  if (!key.length) throw new Error('empty/invalid base32 seed');
  const counter = Math.floor(t / 1000 / step);
  const buf = Buffer.alloc(8); buf.writeBigUInt64BE(BigInt(counter));
  const h = crypto.createHmac('sha1', key).update(buf).digest();
  const off = h[h.length - 1] & 0xf;
  const bin = ((h[off] & 0x7f) << 24) | ((h[off + 1] & 0xff) << 16) | ((h[off + 2] & 0xff) << 8) | (h[off + 3] & 0xff);
  return String(bin % (10 ** digits)).padStart(digits, '0');
}
function _readTotp() { try { return JSON.parse(fs.readFileSync(TOTP_FILE, 'utf8')); } catch { return {}; } }
function _writeTotp(o) { fs.mkdirSync(path.dirname(TOTP_FILE), { recursive: true }); fs.writeFileSync(TOTP_FILE, JSON.stringify(o, null, 2), { mode: 0o600 }); try { fs.chmodSync(TOTP_FILE, 0o600); } catch {} }
const _hostKey = (h) => String(h || '').replace(/^https?:\/\//, '').split('/')[0].toLowerCase().trim();

// #1 native-messaging host manifest locations we may need to append an extension origin to.
function _hostManifestPaths() {
  const home = process.env.USERPROFILE || os.homedir();
  return [
    path.join(home, '.adom', 'native-browser', 'host-manifest.json'),
    path.join(home, 'adom-native-browser', 'host', 'host-manifest.json'),
    path.join(home, 'adom-browser-extension', 'host', 'host-manifest.json'), // pre-rename clone dir
  ];
}

// ============================================================================================
// VERB_META — ONE source of truth for per-verb hint/related/pitfalls. Feeds BOTH (a) nbrowser_describe
// (so ab's Verbs tab shows guidance + chaining + traps) AND (b) the rich `_hint` attached to EVERY
// verb response (decorateHint). The whole point: a calling AI should never have to guess or read docs,
// and should be STEERED away from the dangerous moves these powerful tools allow. ASCII only (no em-dashes).
// ⛔ SAFETY THREAD: this bridge drives the user's REAL, signed-in browser. The cardinal rule, repeated on
// every navigate/reload/close/window verb: only ever act on a window/tab YOU opened (nbrowser_open_window).
// Never touch one the user already has open - that can wipe their live work and other AI threads' tabs.
const OWN_ONLY = 'ONLY operate on a window/tab YOU opened via nbrowser_open_window. NEVER navigate/reload/close a window or tab the user already had open - the bridge drives their REAL browser, so you would destroy their live page, forms, sessions, or other AI threads.';
// The politeness mandate, repeated verbatim on every focus-relevant verb. The bridge ENFORCES it in code
// (background-by-default + focus-steal auto-revert + focus_violation events); this hint tells the AI why.
const NO_FG = 'Open in the BACKGROUND — users automating the browser almost always have OTHER things in the foreground, and a raised window STEALS their keyboard focus (it has typed a user\'s own keystrokes into a username field and dismissed Chrome\'s password dropdown mid-task). Background is where automation SUCCEEDS. NEVER fall back to OS-level launches (Start-Process/launch/msedge/chrome --new-window) or bring_to_front/maximize to surface a window. To genuinely foreground (some auth pages / browser features need it) you must do real work: (1) FIRST warn the user with a Bridge notify (adom-bridge-cli notify_user {message:"Adom is about to bring a browser window forward for <reason>"}), then (2) pass userRequestedForeground:true AND foregroundReason:"<why>". A bare userRequestedForeground with no foregroundReason STAYS background; any driven window that lands topmost is auto-reverted + logged (focus_violation / foreground_raised).';
const VERB_META = {
  nbrowser_status: { hint: 'Connectivity check: connected profiles + active browser. Call this first if any verb errors "not connected".', related: ['nbrowser_profiles', 'nbrowser_ping', 'nbrowser_open_window'], pitfalls: ['a profile\'s extension SLEEPS when that profile has no open windows - it then shows disconnected; open a window to wake it'] },
  nbrowser_readiness: { hint: 'READ-ONLY cold-start probe: is the native-browser extension connected + ready to drive? NEVER spawns work or installs anything. state:"ready" = drive now; state:"needs-extension" = offer to install it (Level-2, see installing-the-extension) or it is asleep (open a window in that profile). Also reports chromeInstalled: if false, the user has no native Chrome and you can offer nbrowser_install_chrome.', related: ['nbrowser_status', 'nbrowser_open_window', 'nbrowser_install_chrome'], pitfalls: ['"needs-extension" can mean not-installed OR installed-but-asleep - nbrowser_status\'s tooltip distinguishes', 'this verb never installs; to install, run the installing-the-extension flow', 'for a machine-wide "what do I have" answer prefer ab\'s bridge_readiness (aggregates all bridges, spawns none)'] },
  nbrowser_install_chrome: { hint: 'Install NATIVE Google Chrome for the user (Windows). Downloads Google\'s official installer + runs a QUIET user-scoped install (no admin on a normal PC). NON-BLOCKING - returns immediately; poll nbrowser_chrome_status. On a locked-down box it escalates to a notify-first UAC (fires a sticky "Approve now" toast, waits for the tap, then raises the prompt). This is step 1 of getting the AI onto the user\'s REAL browser - after Chrome is in, help them sign into Google + load the extension.', related: ['nbrowser_chrome_status', 'nbrowser_readiness', 'installing-the-extension'], pitfalls: ['non-blocking: do NOT expect Chrome to exist on return - POLL nbrowser_chrome_status until phase:"ready"', 'awaiting_uac means a prompt is on the user\'s desktop - relay it; do not re-trigger install', 'Windows only for now', 'this only INSTALLS Chrome; the user still signs into Google themselves (their credential) - guide them via setting-up-native-chrome'] },
  nbrowser_chrome_status: { hint: 'READ-ONLY: is native Google Chrome installed, and how is an in-flight install going? Returns {installed, executablePath, phase, installing, awaitingUac}. phase: idle|installing|awaiting_uac|ready|failed. Poll this after nbrowser_install_chrome. Never installs or spawns.', related: ['nbrowser_install_chrome', 'nbrowser_readiness'], pitfalls: ['awaiting_uac + notifyChannel:true = a toast is on the desktop, keep polling; notifyChannel:false = relay the UAC to the user yourself', 'installed:true means Chrome.exe is present - next help the user sign into Google + load the extension'] },
  nbrowser_ping: { hint: 'Round-trip liveness through to the extension.', related: ['nbrowser_status'], pitfalls: ['a pong proves the extension is reachable, NOT that any profile has a drivable window — use nbrowser_status/readiness for that'] },
  nbrowser_activity: { hint: 'Bounded "who drove what, when" activity trail. Query recent verbs + a per-session summary (activeSessions, each with lastAgoMs) to debug things like "why is this profile badged" or "what is running right now". Filter with {sessionId, thread, profile, verb, errorsOnly, sinceMs, limit}.', related: ['nbrowser_events', 'nbrowser_owned', 'nbrowser_status'], pitfalls: ['the verb serves the in-memory ring (max 600 entries / ~1h, resets on restart); a persistent self-janitored JSONL survives restarts at ~/.adom/bridge-logs/native-browser-activity.jsonl for deeper forensics', 'a session with a large lastAgoMs is idle - its "Adom is driving" badge idle-expires after ~3 min'] },
  nbrowser_open_window: { hint: 'Open a NEW background window YOU own and get a sessionId; do ALL your work through that sessionId. REGISTER it: pass sessionId + thread (which AI thread you are) + purpose (human-readable, why) or it is refused (session_unregistered). ' + NO_FG, related: ['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_owned', 'nbrowser_close_window'], next: 'nbrowser_navigate', pitfalls: ['REQUIRES sessionId + thread + purpose (registration) — a bare open is refused session_unregistered', 'opens UNFOCUSED in the BACKGROUND - a bare focused:true is IGNORED; only userRequestedForeground:true raises it', 'NEVER fall back to an OS-level launch (Start-Process/msedge/chrome --new-window) - that RAISES it to the foreground; this verb is the polite path and always works in the background', 'always drive a window you opened, never the user\'s existing ones — and close everything you own at task end (nbrowser_owned → nbrowser_close_window)'] },
  nbrowser_close_window: { hint: 'Close a window you opened. Pass includeSpawned:true to also close everything that window\'s pages SPAWNED (composes/popups). Clean up your own sessions when done.', related: ['nbrowser_owned', 'nbrowser_cleanup', 'nbrowser_list_windows'], pitfalls: ['⛔ only close a sessionId YOU opened - closing the user\'s window can wipe their live work and other AI threads', 'if the session spawned windows (a page opened a compose/popup), they stay open unless you pass includeSpawned:true or call nbrowser_cleanup'] },
  nbrowser_owned: { hint: 'List everything a session owns: opened windows + windows/tabs a page it drove SPAWNED (compose, target=_blank, OAuth popup). Calling it ACKNOWLEDGES the spawns (clears an unacknowledged_spawns block). Use it before ending a task to see what to clean up.', related: ['nbrowser_cleanup', 'nbrowser_close_window', 'nbrowser_list_windows'], next: 'nbrowser_cleanup', pitfalls: ['spawned windows belong to no session until adopted — this is how you find leaked ones (e.g. a Gmail Contacts compose)', 'after seeing spawns, KEEP the ones you need or nbrowser_cleanup the litter'] },
  nbrowser_cleanup: { hint: 'Close ONLY what this session SPAWNED (leaked composes/popups) — never the user\'s windows, never your primary opened window. The fix for spawned-window leaks.', related: ['nbrowser_owned', 'nbrowser_close_window'], pitfalls: ['needs the sessionId whose spawns to close', 'your primary opened window is KEPT — close it separately with nbrowser_close_window {sessionId, includeSpawned:true}'] },
  nbrowser_force_close: { hint: 'LAST-RESORT override to close a TRUE ORPHAN window (one nbe cannot attribute to any live session) or your own - INCLUDING litter another tool left behind (pup_open/pup windows from an ah test, an abandoned launch). TWO forms, SAME two-step gate: {windowId} when the extension can see the window; {hwnd} (OS-level, from desktop_list_windows) when it cannot - e.g. an Edge window in a profile with no extension. Call once → overrideTicket + exactly what will be destroyed; call again with {…, overrideTicket, attestOrphan:true, reason} to actually close. NEVER closes another live thread\'s window.', related: ['nbrowser_owned', 'nbrowser_cleanup', 'nbrowser_list_windows'], pitfalls: ['a single call NEVER closes anything — that is the point; you must attest on a second call with a real reason', 'refused (owned_by_other) if the window belongs to another live session — cross-thread ownership is absolute', 'prefer nbrowser_cleanup for tracked spawns; force_close is only for true orphans', 'the {hwnd} form only targets REAL browser windows (Chrome_WidgetWin) and sends a graceful WM_CLOSE - unsaved pages may prompt instead of closing'] },
  nbrowser_switch_window: { hint: 'Target a window for later verbs. Does NOT raise it. ' + NO_FG, related: ['nbrowser_list_windows'], pitfalls: ['does not foreground (by design); only userRequestedForeground:true raises it, and only when the user explicitly asked to watch live'] },
  nbrowser_list_windows: { hint: 'List live windows/sessions with bounds+state + an owner label: agent-opened | agent-spawned | user. Pass mine:true for only windows an agent opened/spawned.', related: ['nbrowser_owned', 'nbrowser_switch_window', 'nbrowser_rescan'], pitfalls: ['owner:"agent-spawned" = a page you drove opened it indirectly (compose/popup) — clean it up with nbrowser_cleanup', 'owner:"user" windows are the user\'s own — NEVER drive/close them'] },
  nbrowser_window_state: { hint: 'Maximize/fullscreen/normal/minimize or move+resize. ' + NO_FG, related: ['nbrowser_fullscreen'], pitfalls: ['maximize/fullscreen are USER-only foregrounding actions: they enlarge the window but it STAYS in the background (auto-reverted) unless userRequestedForeground:true - so recordings still work but you can never shove it in front of the user', 'resizing a window the USER is in disrupts them; act on your own session windows'] },
  nbrowser_fullscreen: { hint: 'Fullscreen the window (hides tabs/URL bar) - great for clean recordings of a BACKGROUND window. ' + NO_FG, related: ['nbrowser_window_state'], pitfalls: ['F11-via-key does NOT work; use this verb', 'stays in the background (auto-reverted) unless userRequestedForeground:true - it does not raise the window over the user\'s work'] },
  nbrowser_login_state: { hint: 'Is the profile signed in to a URL right now (via chrome.cookies)?', related: ['nbrowser_credentials'], pitfalls: ['cookie-based inference, not definitive - a page may still challenge for 2FA even when auth cookies exist', 'never returns the password; Chrome autofills it when you focus the field'] },
  nbrowser_credentials: { hint: 'Read-only QUERY of the browser\'s own saved-login store (which vendors the user can auto-login to). Never returns passwords.', related: ['nbrowser_login_state', 'nbrowser_navigate'], pitfalls: ['if a vendor is not listed, the user has no saved login - ask them to save one once', 'never exposes the password; Chrome autofills it when you focus the field'] },
  nbrowser_profiles: { hint: 'List every BROWSER x PROFILE you can drive + which is active. Each row carries browser (chrome|edge), email, displayName (the human label the user sees on the browser profile button, e.g. "John Personal" vs "adom.inc"), and profileDir. Pick work-vs-personal from the email DOMAIN + displayName, never from which window happens to be focused. Drive several in parallel with profile:"<key>".', related: ['nbrowser_use_profile', 'nbrowser_profile_block', 'nbrowser_resolve_profiles'], pitfalls: ['active is only the default when no profile is passed', 'an entry with unresolved:true has no email yet - nbrowser_resolve_profiles first, or you may drive the wrong account', 'the same email can live in Chrome AND Edge - match browser too', 'asleep:true = the profile exists (Local State) but has no open window so its extension is not running - it cannot be driven until the user opens a window in it; never claim a profile "does not exist" when it is merely asleep'] },
  nbrowser_use_profile: { hint: 'Set the default profile that verbs target.', related: ['nbrowser_profiles', 'nbrowser_pin_session'], pitfalls: ['you can still override per-call with profile:"<email>"'] },
  nbrowser_pin_session: { hint: 'Pin a sessionId to a profile so EVERY verb carrying that sessionId auto-routes to it (no cross-profile mistakes when driving several accounts). open_window pins automatically to the profile it opened in; use this to re-pin explicitly.', related: ['nbrowser_profiles', 'nbrowser_resolve_profiles', 'nbrowser_open_window'], pitfalls: ['pins are per-session; a verb with an explicit profile: still overrides the pin', 'if the pinned profile goes offline, verbs fall back to the active profile'] },
  nbrowser_open_os_window: { hint: 'EXTENSION-FREE: open a URL in the user\'s REAL browser profile (Chrome or Edge) when nbe is not installed there - the graceful-degradation path for flows where the USER does the clicking, above all OAuth consent (adom-google, Autodesk APS: they are already signed in; they just click Approve). BACKGROUND by default: brief blip, then minimized + ORANGE flashing taskbar button = "come click when ready". Registration (sessionId+thread+purpose) required like every window.', related: ['nbrowser_profiles', 'nbrowser_os_windows', 'nbrowser_force_close', 'nbrowser_wake_profile'], pitfalls: ['you CANNOT read, drive, or screenshot this window - no extension in that profile; poll the outcome out-of-band (OAuth token poll) or ask the user', 'if the extension IS installed in the profile, prefer wake_profile + open_window (full control, guards, cleanup)', 'foreground:true needs foregroundReason (+ notify_user first) - background+flash is almost always right', 'the background MINIMIZE never touches the workspace/editor window or another thread\'s live session (they are excluded); a plain new window just adds to the running browser, it does not restart it', 'opens MINIMIZED + flashing by default; to raise it later use desktop_bring_to_front {hwnd} (returned in the result) - only when the user asks to see it, never for your own convenience', 'close it afterwards with nbrowser_force_close {hwnd} - never leave auth litter', 'every response nudges the nbe install - relay the upsell to the user at a natural moment, never mid-auth'] },
  nbrowser_os_windows: { hint: 'OS-level browser-window inventory measured ON the laptop by the bridge: every Chrome/Edge/pup window (hwnd, title, rect, z) annotated with the nbe session that owns it. THE reliable probe when your own desktop_list_windows returns 0/null (wrong --target, ab hiccup) or when a profile has no extension (Edge) so list_windows cannot see it.', related: ['nbrowser_force_close', 'nbrowser_list_windows', 'nbrowser_profiles'], pitfalls: ['a window with nbeSession set belongs to a LIVE thread - never force-close it', 'ok:false = the bridge\'s ab link is down, meaning "unknown", NOT "no windows"'] },
  nbrowser_wake_profile: { hint: 'Wake an ASLEEP profile (exists in nbrowser_profiles with asleep:true but not drivable) WITHOUT opening any window: launches the browser with --no-startup-window so the profile + its extension load in background mode - nothing foregrounds, no focus is stolen. When it returns woke:true, drive normally.', related: ['nbrowser_profiles', 'nbrowser_open_window', 'nbrowser_resolve_profiles'], pitfalls: ['NEVER launch a foreground browser window to wake a profile - this verb exists so waking honors background-by-default', 'woke:false usually means the Adom extension is not installed in THAT profile (extensions are per-profile)'] },
  nbrowser_resolve_profiles: { hint: 'Force every connected extension to re-resolve its Google email and re-announce, so pending-N / empty-email keys eagerly become chrome:<email> / edge:<email> - call it up front so you never drive the wrong (unresolved) profile.', related: ['nbrowser_profiles', 'nbrowser_pin_session', 'nbrowser_whoami'], pitfalls: ['takes ~2.5s (waits for each re-announce)', 'a profile not signed into Google stays email-less - target it by id-key or browser:'] },
  nbrowser_whoami: { hint: 'Ask ONE profile to (re)resolve + report its Google email (and re-announce it to the bridge). For all profiles at once use nbrowser_resolve_profiles.', related: ['nbrowser_resolve_profiles', 'nbrowser_profiles'], pitfalls: ['signedIn:false = this browser profile has no Google account, so no email'] },
  nbrowser_profile_block: { hint: 'Blocklist a profile so it is NEVER driven (user safety whitelist; persists).', related: ['nbrowser_profile_unblock', 'nbrowser_profiles'], pitfalls: ['respect the blocklist - it is the user telling you hands-off'] },
  nbrowser_profile_unblock: { hint: 'Remove a profile from the blocklist.', related: ['nbrowser_profile_block'], pitfalls: ['only unblock a profile the user has OK\'d driving again — the blocklist is the user saying hands-off'] },
  nbrowser_rescan: { hint: 'Reconcile the session map against live windows (drop/mark-closed the gone ones).', related: ['nbrowser_list_windows', 'nbrowser_resync_sessions'], pitfalls: ['does NOT revive a genuinely-closed window - open a fresh one with nbrowser_open_window', 'for a session_untracked error after user interaction, prefer nbrowser_resync_sessions'] },
  nbrowser_resync_sessions: { hint: 'LIGHTWEIGHT rebuild of session tracking WITHOUT a dev_reload. Re-reads the persisted session→window map, merges it into memory, and re-validates every session against live windows. Use it the moment a session verb fails with session_untracked (e.g. after the user interacted with Chrome, or another tool moved/closed windows via SetForegroundWindow/WM_CLOSE) - it un-sticks tracking in ONE call. Returns the drivable sessions + which are closed.', related: ['nbrowser_open_window', 'nbrowser_profiles', 'nbrowser_rescan'], pitfalls: ['a session whose window was genuinely closed cannot be revived - open a fresh one with nbrowser_open_window', 'you do NOT need this in the normal path; sessions survive SW sleep on their own now - reach for it only on a session_untracked error'] },
  nbrowser_open_tab: { hint: 'Open a background tab in one of YOUR session windows. ' + NO_FG, related: ['nbrowser_close_tab', 'nbrowser_list_tabs'], pitfalls: ['opens active:false (background) - a bare focused:true/active:true is IGNORED; only userRequestedForeground:true activates it', 'open tabs in a window you created, not the user\'s'] },
  nbrowser_close_tab: { hint: 'Close a tab you opened.', related: ['nbrowser_list_tabs'], pitfalls: ['⛔ only close a tab YOU opened - closing the user\'s tab destroys their live page/session'] },
  nbrowser_switch_tab: { hint: 'Make a tab active within its window (does not raise the window).', related: ['nbrowser_list_tabs'], pitfalls: ['only switch tabs in a window YOU opened; never reorder the user\'s tabs'] },
  nbrowser_list_tabs: { hint: 'List tabs (tabId/url/title/active) + an owner label per tab (agent-opened|agent-spawned|user). Pass mine:true for only your tabs.', related: ['nbrowser_switch_tab', 'nbrowser_owned'], pitfalls: ['owner:"agent-spawned" tabs were opened indirectly by a page you drove - clean up with nbrowser_cleanup', 'owner:"user" tabs are off-limits'] },
  nbrowser_navigate: { hint: 'Navigate a tab to a URL. ' + OWN_ONLY, related: ['nbrowser_open_window', 'nbrowser_screenshot', 'nbrowser_wait'], next: 'nbrowser_screenshot', pitfalls: [OWN_ONLY, 'navigation can race - verify with nbrowser_screenshot before asserting success'] },
  nbrowser_reload: { hint: 'Reload a tab. ' + OWN_ONLY, related: ['nbrowser_navigate'], pitfalls: ['⛔ never reload a window the user is in - you lose their unsaved state; reload only your own session'] },
  nbrowser_back: { hint: 'History back on your own session tab (via the page history API, not chrome.tabs - reliable on debugger-driven tabs, no banner).', related: ['nbrowser_forward', 'nbrowser_navigate'], pitfalls: ['only on a window you opened', 'errors if the tab has no previous page to go back to'] },
  nbrowser_forward: { hint: 'History forward on your own session tab (via the page history API, not chrome.tabs).', related: ['nbrowser_back', 'nbrowser_navigate'], pitfalls: ['only on a window you opened', 'no-op if there is no forward entry'] },
  nbrowser_wait: { hint: 'Sleep server-side (let a page settle) before the next verb.', related: ['nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['prefer nbrowser_wait_for for a DETERMINISTIC wait (selector/url/text) instead of a fixed sleep'] },
  nbrowser_wait_for: { hint: 'DETERMINISTIC wait after a click/nav: block until a CSS selector exists / the URL contains a substring / the visible text contains a substring, or timeoutMs. Use this instead of nbrowser_wait + fixed sleeps + coordinate races. Returns {matched, by, url, waitedMs}.', related: ['nbrowser_click', 'nbrowser_navigate', 'nbrowser_eval'], pitfalls: ['pass at least one of selector / urlIncludes / textIncludes', 'matched:false + timedOut:true means the condition never appeared - screenshot to see the real page', 'selector matches the top document only (not inside cross-origin iframes)'] },
  nbrowser_keepalive: { hint: 'Keep a session ACTIVE to avoid vendor idle sign-outs (Gusto/Intuit ~10-15 min): replays a benign mousemove/focus on an interval (NO navigation, no side-effects). Per-session; auto-stops when the window closes. Call with {stop:true} to cancel. Set at the START of a long unattended automation.', related: ['nbrowser_open_window', 'nbrowser_login_state'], pitfalls: ['client-side activity only - a vendor that expires the SERVER session regardless may still sign out; combine with periodic real actions if so', 'default interval 4min (30s..15min); pick below the vendor idle timeout'] },
  nbrowser_screenshot: { hint: 'CDP screenshot of the PAGE - captures a BACKGROUND/occluded window WITHOUT foregrounding it. Your DEFAULT way to "see" a page. It captures page CONTENT only - NOT the browser chrome (URL/tabs) and NOT native popups/dialogs (Chrome password dropdown, print dialog, alert/confirm) which live OUTSIDE the page.', related: ['nbrowser_eval', 'nbrowser_click', 'nbrowser_handle_dialog'], pitfalls: ['resized <=1568 for the model; pass toFile:true to save full-res to a laptop file (pull_file it)', 'to see the WINDOW FRAME or a native popup/dropdown/dialog, use ab desktop_screenshot_window {hwnd | titleContains} (WGC): it captures the window PLUS its OWNED CHILD windows (returns a parent/child hwnd array, so you see popups like the password dropdown), and it is background-safe (no foregrounding)', '⛔ NEVER take a fullscreen / whole-monitor screenshot to inspect a browser window - it grabs the user\'s other work, is imprecise, and often implies foregrounding. Page → nbrowser_screenshot; window/chrome/popup → desktop_screenshot_window'] },
  nbrowser_screenshot_full_res: { hint: 'Full-resolution, full-PAGE screenshot via CDP (the entire scrollable page, not just the viewport). Still PAGE content only.', related: ['nbrowser_screenshot'], pitfalls: ['this is full-PAGE, NOT "fullscreen monitor" - it never captures the desktop or the user\'s other windows', 'large base64 can be slow over the relay - prefer toFile then pull_file', 'for the window frame or a native dialog/popup, use ab desktop_screenshot_window {hwnd | titleContains} (parent/child hwnds), never a monitor capture'] },
  nbrowser_eval: { hint: 'Run JS in the page and get the result. Powerful - use read-only expressions to inspect state.', related: ['nbrowser_screenshot'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },
  nbrowser_evaluate: { hint: 'Alias of nbrowser_eval.', related: ['nbrowser_eval'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },
  nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input (click/move/type/key).', related: ['nbrowser_click', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy'] },
  nbrowser_click: { hint: 'Click by visible text / selector / xy (isTrusted). Scrolls the target into view, re-resolves its rect, and RETURNS changed:bool - whether the URL/DOM actually changed after the click - so you can detect "click landed but nothing happened" and retry. Optional gliding cursor + intent label. The finder PIERCES open shadow roots, so custom-element controls (Schwab/QBO) are clickable by text/selector.', related: ['nbrowser_select', 'nbrowser_wait_for', 'nbrowser_screenshot', 'nbrowser_type'], next: 'nbrowser_wait_for', pitfalls: ['attaches the debugger (yellow "debugging" bar; idle-detaches ~8s)', 'changed:false means the click dispatched but the page did not visibly change (disabled/covered element, missed target) - re-resolve or retry', 'for a deterministic post-click wait use nbrowser_wait_for, not a fixed sleep', 'to PICK an option from a custom dropdown, use nbrowser_select (waits for options + returns the value) - never coordinate-click a virtualized list'] },
  nbrowser_select: { hint: 'Pick an option from a dropdown/combobox BY ITS VISIBLE TEXT - the reliable primitive for custom + native selects. Give {label} (the control\'s accessible name) OR {near} (a visible label beside it) to find the trigger, plus {optionText}. It opens the control with an isTrusted click, WAITS for the options to render, clicks the EXACT text match (piercing shadow roots), and RETURNS the newly-selected value so you can verify. Fixes coordinate-clicks landing on the wrong row of a virtualized list.', related: ['nbrowser_click', 'nbrowser_wait_for'], pitfalls: ['pass label OR near to locate the trigger; optionText must match the option\'s exact visible text (case-insensitive)', 'ALWAYS check the returned `selected` value before continuing - confirm it is the row you meant (mis-selecting a bank account by one row is the exact bug this prevents)', 'raise timeoutMs for a slow/virtualized option list (default 4000, max 20000)'] },
  nbrowser_set_file_input: { hint: 'Populate an <input type=file> over CDP - NO OS file picker, fully background. Direct mode: {sessionId, selector|text, filePaths:[abs laptop paths]} - the finder walks a Browse button/label to its input (hidden inputs fine). Transient-input UIs (input created inside the Browse handler): {armForNextChooser:true, filePaths} then nbrowser_click the Browse control - the chooser is satisfied silently; confirm with {chooserStatus:true}. Cloud shortcut: files:[{name, contentBase64}] and the bridge stages them on the laptop for you.', related: ['nbrowser_click', 'nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['filePaths are LAPTOP paths - send_files first (or use contentBase64)', 'after injection many UIs still need a Submit/Upload click - screenshot to see the state', 'sites using the File System Access API (showOpenFilePicker) CANNOT be driven over CDP at all - use their drag-drop zone manually', 'input+change are dispatched for you; do not synthesize extra events'] },
  nbrowser_set_date: { hint: 'Fill a react-aria SEGMENTED date field (role=spinbutton month/day/year, common in finance apps) - focuses it and types each digit as a real keydown so the segments advance. date="YYYY-MM-DD". Pass order:"DMY" for day-first locales (default MDY).', related: ['nbrowser_type', 'nbrowser_click'], pitfalls: ['for react-aria/segmented fields; a plain <input type=date> may just take nbrowser_type', 'US MDY order by default - pass order:"DMY" if the field is day-first', 'check fieldValue in the response; screenshot if it looks off'] },
  nbrowser_totp: { hint: 'Type the current 6-digit 2FA code for a host into the FOCUSED field (or pass selector). The bridge computes the RFC-6238 code from the stored seed and the extension types it - the agent NEVER sees the seed or the code. Pass submit:true to press Enter after. Register the seed once with nbrowser_register_totp. The #1 unblock for unattended vendor automation.', related: ['nbrowser_register_totp', 'nbrowser_autologin', 'nbrowser_login_state'], pitfalls: ['errorCode:"no_totp_seed" = register it first with nbrowser_register_totp', 'focus the 2FA input first (click it) or pass selector', 'codes rotate every 30s - call this right when you need it'] },
  nbrowser_register_totp: { hint: 'One-time: register a base32 TOTP secret for a host (the "manual entry / setup key" the vendor shows by the 2FA QR). Stored 0600 on the laptop, NEVER returned. Then nbrowser_totp {host} types codes.', related: ['nbrowser_totp'], pitfalls: ['base32 only (A-Z, 2-7)', 'stored on the laptop; the agent never sees it back', 'one seed per host - re-registering replaces it'] },
  nbrowser_add_extension_origin: { hint: 'Add an extension ID to the native-messaging host manifest\'s allowed_origins so a browser whose loaded-unpacked ID differs (esp. Edge) can reach the host + appear in nbrowser_profiles. Pass {id} from edge://extensions. RELOAD the extension afterward so the browser re-reads it.', related: ['nbrowser_profiles', 'nbrowser_rescan'], pitfalls: ['MUST reload the extension after (edge://extensions -> Reload) for the browser to re-read allowed_origins', 'our manifest pins a key so IDs SHOULD match across browsers - if Edge diverged, add its id here'] },
  nbrowser_type: { hint: 'Type into the focused field (isTrusted).', related: ['nbrowser_click', 'nbrowser_press_key'], pitfalls: ['click the field FIRST so it has focus'] },
  nbrowser_press_key: { hint: 'Press a key (Enter/Tab/etc.) as an in-page CDP key event.', related: ['nbrowser_type'], pitfalls: ['the target field must be FOCUSED first (click it)', 'browser-chrome chords (Ctrl+T, F11, Ctrl+L) do NOT work here - the browser intercepts them before the page; use ab desktop_press_key or the dedicated verb (e.g. nbrowser_fullscreen for F11)'] },
  nbrowser_fetch_url: { hint: 'Authed fetch using the user\'s REAL cookies - perfect for logged-in JSON/APIs that pup cannot reach.', related: ['nbrowser_navigate'], pitfalls: ['results come from the user\'s live session - treat as sensitive'] },
  nbrowser_download_wait: { hint: 'Wait for a browser DOWNLOAD to complete and return the landed filename(s) via chrome.downloads events (not timestamp-polling the Downloads folder). Optional saveToSubfolder routes subsequent downloads into a subfolder of Downloads. Call it right after you trigger an export.', related: ['nbrowser_click', 'nbrowser_fetch_url'], pitfalls: ['timedOut:true = nothing landed in the window - verify the export click fired, then call again', 'saveToSubfolder takes effect for downloads started AFTER this call', 'the buffer is in-memory; a service-worker restart clears history (use a fresh sinceMs)'] },
  nbrowser_errors: { hint: 'Buffered page/console errors for a tab.', related: ['nbrowser_eval'], pitfalls: ['the buffer CLEARS on read by default (pass clear:false to keep it)', 'errors are only captured once the tab has been driven (debugger attached) - an empty list is not proof of no errors on a never-driven tab'] },
  nbrowser_close: { hint: 'Not an extension verb - close a specific window/tab instead.', related: ['nbrowser_close_window', 'nbrowser_close_tab'], pitfalls: ['there is no global detach-all; the debugger idle-detaches on its own'] },
  nbrowser_record_start: { hint: RECORD_WGC_HINT, related: ['nbrowser_record_stop', 'nbrowser_record_status'], next: 'nbrowser_record_status', pitfalls: ['⭐ prefer method:"wgc" (delegates to core desktop_record_window_start)', 'getdisplaymedia/tabcapture are REFUSED without fallbackConfirmed:true - a browser extension cannot silently screen-record'] },
  nbrowser_record_stop: { hint: 'Stop a recording and finalize the mp4 (pull it with pull_file).', related: ['nbrowser_record_status'], pitfalls: ['needs the recordingId from record_start, not a sessionId'] },
  nbrowser_record_status: { hint: 'Poll a recording.', related: ['nbrowser_record_start'], pitfalls: ['pass the SAME method (wgc|getdisplaymedia|tabcapture) + recordingId you started with', 'blank frames on a WGC recording = the recorded Chrome lacks the occlusion-disable launch flags'] },
  nbrowser_record_list: { hint: 'List recent recordings.', related: ['nbrowser_record_status'], pitfalls: ['paths are on the LAPTOP - pull them with adom-bridge-cli pull_file, do not expect them in the container'] },
  nbrowser_flash: { hint: 'Flash the driven window\'s taskbar button - the "come look" cue (e.g. at a captcha/2FA/login human-gate).', related: ['nbrowser_flash_stop', 'nbrowser_flash_mode'], pitfalls: ['auto-clears on focus; nbrowser_flash_stop to clear early'] },
  nbrowser_flash_stop: { hint: 'Stop the taskbar flash.', related: ['nbrowser_flash'], pitfalls: ['the flash already auto-clears when the user focuses the window - only stop it early if you no longer need their attention'] },
  nbrowser_flash_mode: { hint: 'Set auto-flash verbosity: quiet (come-look moments: page load, screenshot, error/refusal, human-gate, spawned window) | chatty (every action) | off (no auto-flash at all). Flash manually with nbrowser_flash at task-done.', related: ['nbrowser_flash'], pitfalls: ['off is the user opt-out - if the user says the orange flashing is annoying, set flash_mode:"off" and it stops entirely', 'quiet is the default; the flash is a "come look" cue, so keep it for human-gates even if you dial the rest down'] },
  nbrowser_taskbar: { hint: 'Paint progress/overlay/flash on the window taskbar button (e.g. paused=yellow for a human gate). The "Adom is driving" badge is AUTOMATIC: the bridge composites the profile\'s circle avatar + the Adom favicon at open/drive, and idle-expires it (~3 min) back to the plain native avatar - you rarely need to touch overlay yourself.', related: ['nbrowser_flash'], pitfalls: ['clear progress+flash at task end: {progress:{state:"none"},flash:"stop"} - the badge clears itself (idle-expiry / close)', 'overlay.badge:"none" RESTORES the native avatar look (the bridge never blanks the overlay slot - blanking would erase the user\'s profile avatar)'] },
  nbrowser_describe: { hint: 'Returns per-verb schemas + hint/related/pitfalls for ab\'s Verbs tab.', related: ['nbrowser_help'], pitfalls: ['this is the catalog - a few extension-only verbs (e.g. nbrowser_login_form) are real but resolved in the extension, not listed here'] },
  nbrowser_handle_dialog: { hint: 'Press OK/Cancel (or answer a prompt) on a native JS dialog (alert/confirm/prompt/beforeunload) BLOCKING the page. accept:true=OK/Yes/Leave · accept:false=Cancel/Stay · text:"…" answers a prompt.', related: ['nbrowser_screenshot', 'nbrowser_click'], next: 'nbrowser_screenshot', pitfalls: ['these dialogs are NOT in a screenshot and have NO hwnd (Chrome renders them) - you learn of them via a modal_open error or a screenshot _modal note', 'the page is FROZEN until you handle it: clicks/typing/eval return modal_open until you do', 'if the AI "keeps failing to fill a form", suspect an open dialog - handle it, then re-screenshot'] },
  nbrowser_dev_reload: { hint: 'MAINTAINER-only: reload the extension to pick up on-disk code changes (2-pass, reconnects in ~10s).', related: ['nbrowser_status'], pitfalls: ['throttled to 1/90s (a reload restarts the SW + native host)', 'not for normal driving - only after editing the extension source', 'reloads ONE profile; other profiles pick up the new code on their own next reload'] },
  nbrowser_clear_cookies: { hint: 'Background logout: drop a site\'s cookies (incl. httpOnly) for ONE registrable domain, leaving other sites (e.g. Google SSO) intact.', related: ['nbrowser_login_state'], pitfalls: ['scoped to one domain so it never nukes unrelated sessions', '⚠️ do NOT use this to log OUT of a site that ties device-trust to cookies (e.g. Gusto) - it forces a fresh 2FA; use the site\'s own Sign out'] },
  nbrowser_caption: { hint: 'Paint an on-page caption (subtitle or red alert banner) for demos/narration.', related: ['nbrowser_cursor'], pitfalls: ['only on a window you opened', 'pass action:"hide" or empty text to dismiss'] },
  nbrowser_focus: { hint: 'Select a tab within its window + flash its taskbar button. Does NOT raise the window unless you justify a foreground (userRequestedForeground:true + foregroundReason).', related: ['nbrowser_flash', 'nbrowser_screenshot'], pitfalls: ['does NOT foreground by design - the user\'s focus is never stolen; capture in place with nbrowser_screenshot', 'to actually raise it you must justify + warn the user (ab notify) first'] },
  nbrowser_hover: { hint: 'Hover an element (by selector/text) to reveal menus/tooltips (isTrusted).', related: ['nbrowser_click'], pitfalls: ['some menus need a dwell - hover then screenshot before clicking the revealed item'] },
  nbrowser_double_click: { hint: 'Double-click an element (isTrusted).', related: ['nbrowser_click'], pitfalls: ['click-by-text matching >1 element is refused (ambiguous_target) - pass a selector'] },
  nbrowser_right_click: { hint: 'Right-click an element to open a context menu (isTrusted).', related: ['nbrowser_click'], pitfalls: ['the native context menu is OS chrome, not page DOM - you often cannot click its items via CDP; prefer the page\'s own UI'] },
  nbrowser_login_form: { hint: 'Detect a login form\'s state (autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call before driving a login.', related: ['nbrowser_autologin', 'nbrowser_totp', 'nbrowser_login_state'], next: 'nbrowser_autologin', pitfalls: ['if already autofilled, just submit (background) - do NOT retype (retyping can dismiss Chrome\'s password dropdown)', 'never foreground the window to "make the dropdown appear" - that steals the user\'s focus'] },
  nbrowser_autologin: { hint: 'Fill + submit a saved-credential login. The bridge injects the laptop-decrypted secret; the agent NEVER sees it. Gated on confirm:true + host:.', related: ['nbrowser_login_form', 'nbrowser_totp'], pitfalls: ['requires confirm:true + host:', 'returns only {loggedIn,needs2FA} - never the secret', 'if 2FA is required next, use nbrowser_totp'] },
  nbrowser_cursor: { hint: 'Show/move the gliding Adom cursor with an intent label (demo narration).', related: ['nbrowser_caption', 'nbrowser_click'], pitfalls: ['visual only - it does not click; use nbrowser_click to act'] },
  nbrowser_events: { hint: 'Drain buffered lifecycle events since last call: window_closed (incl. byUser), tab_closed, spawned_window (a page you drove opened a compose/popup), focus_violation (a foregrounding attempt was auto-reverted), foreground_raised, and dialog (a native alert/confirm/prompt fired - it is auto-dismissed while you are actively driving, but the message is reported here so you KNOW it happened).', related: ['nbrowser_owned', 'nbrowser_cleanup', 'nbrowser_handle_dialog'], pitfalls: ['draining clears the buffer', 'a spawned_window event means you now have litter to acknowledge - nbrowser_owned then nbrowser_cleanup', 'a dialog event means a page popped an alert/confirm - if it reported something important (a save, an error, a "are you sure"), act on it'] },
  nbrowser_download: { hint: 'Download a URL to the user\'s Downloads folder via chrome.downloads.', related: ['nbrowser_download_wait', 'nbrowser_fetch_url'], pitfalls: ['lands on the LAPTOP (the user\'s real Downloads), not the container', 'use nbrowser_download_wait to know when it completed'] },
};
// Attach a rich `_hint` (+ related/_next) to EVERY response from VERB_META, so an AI calling cold is
// always told what it got, what to do next, and what to avoid - without ever reading docs first.
function decorateHint(verb, r) {
  if (!r || typeof r !== 'object') return r;
  const m = VERB_META[verb];
  if (!m) return r;
  if (r._hint == null && m.hint) r._hint = m.hint;          // never clobber a more-specific hint
  if (r.success !== false) {                                 // chaining aids only on success
    if (r.related == null && m.related && m.related.length) r.related = m.related;
    if (r._next == null && m.next) r._next = m.next;
  }
  return r;
}

function dispatch(verb, args) {
  return new Promise((rawResolve) => {
    const resolve = (r) => rawResolve(decorateHint(verb, r));
    if (verb === 'nbrowser_help') {
      return resolve({ success: true, output: JSON.stringify({ ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), flashMode, ...HELP }) });
    }
    if (verb === 'nbrowser_activity') {
      // Bounded, queryable "who drove what, when" trail — to debug things like a stray badge.
      const now = Date.now();
      let rows = ACTIVITY_LOG.slice();
      if (args && args.sessionId) rows = rows.filter((r) => r.sessionId === args.sessionId);
      if (args && args.profile) rows = rows.filter((r) => (r.profile || '').includes(String(args.profile)));
      if (args && args.thread) rows = rows.filter((r) => r.thread === args.thread);
      // NB: args.caller is RESERVED - ab/the CLI inject the identity there on every call. Filter by
      // `callerThread` instead, and only when it is a plain string.
      if (args && typeof args.callerThread === 'string' && args.callerThread) rows = rows.filter((r) => String(r.caller || '').includes(args.callerThread));
      if (args && args.verb) rows = rows.filter((r) => r.verb === args.verb || r.verb === 'nbrowser_' + args.verb);
      if (args && args.errorsOnly) rows = rows.filter((r) => r.ok === false);
      if (args && args.sinceMs) rows = rows.filter((r) => r.ts >= now - Number(args.sinceMs));
      const limit = Math.min(Number(args && args.limit) || 100, 600);
      // Carry the taskbar/window detail through (hwnd + which overlay/flash op) so a missing badge or a
      // dead flash is debuggable straight from this trail, not just "something failed".
      const entries = rows.slice(-limit).map((r) => ({ agoMs: now - r.ts, verb: r.verb, sessionId: r.sessionId, thread: r.thread, profile: r.profile, ok: r.ok, errorCode: r.errorCode || undefined, error: r.error || undefined, hwnd: r.hwnd || undefined, ops: r.ops || undefined, internal: r.internal || undefined, caller: r.caller || undefined }));
      const bySession = {};
      for (const r of rows) { if (!r.sessionId) continue; const s = bySession[r.sessionId] || (bySession[r.sessionId] = { sessionId: r.sessionId, thread: sessionThread.get(r.sessionId) || r.thread, ownerCaller: sessionCaller.get(r.sessionId) || null, purpose: sessionPurpose.get(r.sessionId) || null, profile: r.profile, count: 0, lastAgoMs: Infinity }); s.count++; s.lastAgoMs = Math.min(s.lastAgoMs, now - r.ts); }
      return resolve({ success: true, output: JSON.stringify({ ok: true, count: entries.length, totalLogged: ACTIVITY_LOG.length, windowMs: ACT_LOG_TTL_MS, activeSessions: Object.values(bySession).sort((a, b) => a.lastAgoMs - b.lastAgoMs), entries, _hint: 'Bounded activity trail (max 600 entries / ~1h, in-memory, resets on bridge restart). Filter: {sessionId, thread, profile, verb, errorsOnly:true, sinceMs, limit} - verb:"_taskbar" shows every overlay-badge/flash paint (hwnd + ops + ok), verb:"_ab_link" shows ab-link outages/recoveries. activeSessions = who drove what + how long ago (lastAgoMs); a large lastAgoMs means that session is idle (its badge should have faded).' }) });
    }
    // nbrowser_readiness - READ-ONLY cold-start probe (SDK Cold-start tiers). Never spawns work, never
    // installs. This bridge has no heavy runtime (tiny Node), so "readiness" == the Level-2 extension
    // being connected. ab's own bridge_readiness aggregates across bridges; this reports THIS one.
    if (verb === 'nbrowser_readiness') {
      const live = liveConns();
      const ready = live.length > 0;
      const chExe = detectChrome();
      // Per-profile extension version + stale check. A loaded-unpacked extension does NOT auto-update
      // (only the bridge does), so a box can sit on an old build whose new verbs return "not implemented
      // yet". Flag it here (loudly, with the fix) instead of letting the agent trip over it mid-task.
      const extensionVersions = {};
      const staleProfiles = [];
      const unidentifiedProfiles = [];   // connected but hasn't (re)sent its hello yet - version not yet known
      for (const [k, c] of live) {
        if (!c.version) { extensionVersions[k] = 'unknown'; unidentifiedProfiles.push(k); continue; }
        extensionVersions[k] = c.version;
        // ONLY a KNOWN version below expected is "stale". An unknown version (fresh reconnect, pre-hello,
        // e.g. right after a bridge restart) is NOT assumed stale - that would cry wolf on every restart.
        if (EXPECTED_EXT && cmpVer(c.version, EXPECTED_EXT) < 0) staleProfiles.push(k);
      }
      const extensionStale = staleProfiles.length > 0;
      const staleDetail = staleProfiles.map((k) => `${k} has ${extensionVersions[k]}`).join(', ');
      // ON-DISK SKEW (Drew Owens, wiki #305, 2026-07-24): the version check above only sees CONNECTED
      // profiles - but a skew OLD ENOUGH to break the native-messaging handshake means the extension
      // NEVER connects, so a connection-based check can never fire (the host just exits: Chrome shows
      // "Native host has exited"). So ALSO read each known profile's extension version straight off disk
      // (Secure Preferences) and flag one that is installed-but-older-than-expected yet NOT connected.
      // This converts the mystery into a one-call, actionable answer.
      // Surface the host's own last exit reason if it wrote one (host-side, wiki #305). Chrome only shows
      // the user "Native host has exited"; the host now records WHY next to the discovery file.
      let hostExit = null;
      try { const he = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.adom', 'bridges', 'native-browser', 'host-exit-reason.json'), 'utf8')); if (he && he.reason && (Date.now() - Date.parse(he.at || 0)) < 10 * 60 * 1000) hostExit = he; } catch {}
      const connectedKeys = new Set(live.map(([k]) => k));
      const staleOnDisk = [];
      if (EXPECTED_EXT) {
        for (const kp of knownProfiles()) {
          if (connectedKeys.has(kp.key)) continue;                 // connected path already handled above
          let st; try { st = adomExtStateForProfile(kp.browser, kp.profileDir); } catch { continue; }
          if (st && st.installed && st.version && cmpVer(st.version, EXPECTED_EXT) < 0) {
            staleOnDisk.push({ profile: kp.key, installed: st.version, expected: EXPECTED_EXT });
          }
        }
      }
      const hostHint = hostExit ? ` The native host last exited because: "${hostExit.reason}" (host v${hostExit.hostVersion || '?'}, ${hostExit.at}).` : '';
      const skewHint = staleOnDisk.length
        ? ` ⛔ VERSION SKEW (likely the cause of "Native host has exited"): ${staleOnDisk.map((x) => `${x.profile} has extension ${x.installed}`).join(', ')}, but this bridge (${VERSION}) expects extension ${EXPECTED_EXT}. An extension/host that old cannot complete the native-messaging handshake, so the host exits with no message and the SW never connects. FIX: update the extension in that profile to ${EXPECTED_EXT} (re-sync the extension/ + host/ folders per installing-the-extension), then nbrowser_dev_reload {"profile":"<profile>"}. Note: the extension line (${EXPECTED_EXT}) and the skills-package line (a 0.5.x number on the wiki page) are DIFFERENT version streams - match the extension against expectedExtensionVersion, not the pkg version.`
        : '';
      return resolve({ success: true, output: JSON.stringify({
        ready,
        state: ready ? 'ready' : 'needs-extension',
        installing: false,
        extensionConnected: ready,
        profiles: live.map(([k]) => k),
        extensionVersions,                      // { "chrome:you@x": "0.0.18", ... } as reported in each hello
        expectedExtensionVersion: EXPECTED_EXT, // what THIS bridge release ships against (null = not checked)
        extensionStale,                         // true if any connected profile runs a KNOWN older build than expected
        staleProfiles,                          // the profile keys that are behind (pass one to nbrowser_dev_reload)
        staleOnDisk,                            // installed-but-old extensions that are TOO old to even connect (the "native host has exited" cause)
        hostExit,                               // the native host's own last exit reason, if it recorded one (wiki #305)
        unidentifiedProfiles,                   // connected but pre-hello (version unknown yet); resolves on next hello
        chromeInstalled: !!chExe,               // is native Google Chrome present on the box?
        chromeInstallPhase: chExe ? 'ready' : chromeState.phase,  // idle|installing|awaiting_uac|ready|failed
        _hint: extensionStale
          ? `STALE EXTENSION: ${staleDetail}; this bridge expects ${EXPECTED_EXT}. New verbs (wait_for/keepalive/download_wait/set_date/totp/robust-click) will return "verb not implemented yet" on the stale profile(s). SELF-HEAL: (1) refresh the unpacked extension dir to the current build (installing-the-extension -> Updating, or re-sync the extension/ folder), then (2) nbrowser_dev_reload {"profile":"<staleProfile>"} - it re-reads disk in 2 passes and reconnects in ~10s. If the on-disk build is ALREADY current, step (2) alone heals it.` + skewHint
          : ready
          ? `nbe (the Adom Native Browser Extension) is connected (${live.length} profile(s)) and current (v${EXPECTED_EXT || '?'}); drive it with nbrowser_open_window. Read-only probe - it never spawns work or installs.`
          : `nbe (the Adom Native Browser Extension) is NOT connected. ${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.` + skewHint + hostHint,
      }) });
    }
    // nbrowser_install_chrome - install native Google Chrome for the user (Windows). Non-blocking:
    // returns immediately; poll nbrowser_chrome_status / nbrowser_readiness. Quiet user-scoped install
    // (no admin on a normal PC); on a locked-down box escalates to a notify-first UAC.
    if (verb === 'nbrowser_install_chrome') {
      const exe = detectChrome();
      if (exe) return resolve({ success: true, output: JSON.stringify({ ok: true, installed: true, alreadyInstalled: true, executablePath: exe, _hint: 'Native Google Chrome is already installed. Next in the onboarding: help the user sign into their Google account (so passwords/bookmarks/autofill sync), then load the Adom extension (installing-the-extension). See setting-up-native-chrome.' }) });
      installNativeChrome({ elevate: (args && args.elevate !== undefined) ? args.elevate : 'auto' }); // fire-and-forget; poll status
      return resolve({ success: true, output: JSON.stringify({ ok: true, installing: true, phase: 'installing', _hint: 'Installing Google Chrome in the BACKGROUND (downloading Google\'s official installer, then a quiet user-scoped install). On a normal PC this is silent, NO prompt. On a locked-down box (VM/managed) it needs a Windows approval - the bridge fires a sticky "Approve now" toast and waits for the user to tap it, then raises the UAC. Poll nbrowser_chrome_status: phase goes installing -> awaiting_uac -> ready. Then help the user sign into Google + load the extension (setting-up-native-chrome).', statusVerb: 'nbrowser_chrome_status' }) });
    }
    // nbrowser_chrome_status - READ-ONLY: is native Chrome installed / how's an in-flight install going?
    if (verb === 'nbrowser_chrome_status') {
      const st = chromeStatus();
      const hint = st.installed
        ? 'Native Google Chrome is installed. Next: help the user sign into their Google account (open chrome://settings or accounts.google.com and turn ON sync so passwords/bookmarks/autofill carry over), then load the Adom extension (installing-the-extension). Full flow: setting-up-native-chrome.'
        : st.awaitingUac
          ? (st.notifyChannel ? 'Waiting on the user to approve the Windows security prompt. A sticky "Approve now" toast is on their desktop; tapping it re-opens the UAC. Keep polling; do NOT re-trigger install.' : 'A UAC was raised once but this ab cannot pop a notification. RELAY TO THE USER: go to the machine and click YES on the Google Chrome installer prompt, then re-poll.')
          : st.installing ? 'Chrome install in progress (downloading / silent install). Poll again in a few seconds.'
          : st.phase === 'failed' ? `Chrome install failed (${st.errorCode}): ${st.error}. Fixes depend on the code - e.g. free disk, check network, or ask the user to install Chrome manually.`
          : 'No native Chrome yet and no install running. Call nbrowser_install_chrome to install it.';
      return resolve({ success: true, output: JSON.stringify({ ...st, _hint: hint }) });
    }
    // nbrowser_describe - per-verb schemas for ab's Verbs tab + inline runner (ab v1.9.23+).
    // ab POSTs {command:"nbrowser_describe",args:{}} and renders each verb expandable with these I/O
    // docs, prefilling the runner with `example`. One entry per declared verb (+ describe itself).
    if (verb === 'nbrowser_describe') {
      const S = 'optional sessionId of a window opened via nbrowser_open_window';
      const P = 'optional profile key "chrome:<email>" / "edge:<email>" (default = active)';
      const d = (name, summary, input, output, timeoutSeconds, opts = {}) => {
        const m = VERB_META[name] || {};
        return { name, summary, input, output, timeoutSeconds, statusVerb: opts.statusVerb || null, longRunning: !!opts.longRunning, example: opts.example || {}, hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] };
      };
      const verbs = [
        d('nbrowser_status', 'Is the extension connected? Reports ext id/version + active browser.', {}, { ok: 'bool', ext: 'str', version: 'str', engine: 'str', browser: '"chrome"|"edge"' }, 15),
        d('nbrowser_activity', 'Bounded "who drove what, when" activity trail (max 600 entries / ~1h, in-memory, resets on bridge restart). Returns recent verb entries + a per-session summary. Debug a stray badge or see what is running.', { sessionId: 'optional str', thread: 'optional str', profile: 'optional str', verb: 'optional str', errorsOnly: 'optional bool', sinceMs: 'optional int', limit: 'optional int (<=600)' }, { ok: 'bool', entries: '[{agoMs,verb,sessionId,thread,profile,ok,errorCode}]', activeSessions: '[{sessionId,thread,purpose,count,lastAgoMs}]' }, 15, { example: {} }),
        d('nbrowser_readiness', 'READ-ONLY cold-start probe: is the native-browser extension connected + ready to drive? Never spawns work or installs. Use for "is it ready / do I have the browser extension" instead of nbrowser_status. Also flags a STALE extension (loaded build older than this bridge expects) so you fix it up-front instead of hitting "verb not implemented yet" mid-task.', {}, { ready: 'bool', state: '"ready"|"needs-extension"', installing: 'bool', extensionConnected: 'bool', profiles: '[str]', extensionVersions: '{profile:version}', expectedExtensionVersion: 'str|null', extensionStale: 'bool', staleProfiles: '[str]', chromeInstalled: 'bool', chromeInstallPhase: 'str' }, 15, { example: {} }),
        d('nbrowser_install_chrome', 'Install NATIVE Google Chrome for the user (Windows). Downloads Google\'s official installer + runs a quiet user-scoped install (no admin on a normal PC); escalates to a notify-first UAC on a locked-down box. NON-BLOCKING - poll nbrowser_chrome_status. Step 1 of putting the AI onto the user\'s real signed-in browser.', { elevate: 'optional "auto"(default) | true (skip quiet, straight to UAC) | false (quiet only, no prompt)' }, { ok: 'bool', installing: 'bool', installed: 'bool?', alreadyInstalled: 'bool?', executablePath: 'str?', statusVerb: 'str' }, 30, { statusVerb: 'nbrowser_chrome_status', example: {} }),
        d('nbrowser_chrome_status', 'READ-ONLY: is native Google Chrome installed / how is an install going? Poll after nbrowser_install_chrome.', {}, { installed: 'bool', executablePath: 'str?', phase: '"idle"|"installing"|"awaiting_uac"|"ready"|"failed"', installing: 'bool', awaitingUac: 'bool', errorCode: 'str?' }, 15, { example: {} }),
        d('nbrowser_ping', 'Round-trip liveness check through to the extension.', {}, { ok: 'bool', pong: 'bool' }, 15),
        d('nbrowser_open_window', 'Open a NEW window in the BACKGROUND (unfocused) and return a sessionId to drive it. REGISTRATION REQUIRED: sessionId + thread + purpose, or refused (session_unregistered). Never steals focus; a topmost driven window is auto-reverted (focus_violation).', { sessionId: 'required str (your short task id)', thread: 'required str (which AI thread you are)', purpose: 'required str (human-readable, e.g. "finishing John\'s IRS signup")', url: 'required str', profile: P, userRequestedForeground: 'optional bool - raise to foreground ONLY when the user asked or a browser/auth feature requires it (default false)', foregroundReason: 'REQUIRED with userRequestedForeground - human-readable why; without it the window STAYS background. Warn the user via ab notify_user FIRST.' }, { ok: 'bool', sessionId: 'str', tabId: 'str', windowId: 'int', hwnd: 'int (OS window handle - use with desktop_screenshot_window to capture the window+chrome+child popups)', url: 'str', _warning: 'str?', keptBackground: 'bool?' }, 30, { example: { sessionId: 'irs', thread: 'thread-7', purpose: 'finishing John IRS signup', url: 'https://wiki.adom.inc' } }),
        d('nbrowser_close_window', 'Close a window the agent opened. includeSpawned:true also closes everything that session SPAWNED (composes/popups).', { sessionId: 'required str', includeSpawned: 'optional bool (also close this session\'s spawned windows)', profile: P }, { ok: 'bool', closed: 'bool', sessionId: 'str', alsoClosedSpawned: '[int]' }, 20, { example: { sessionId: 'work', includeSpawned: true } }),
        d('nbrowser_owned', 'List everything a session owns: opened windows + adopted SPAWNED windows/tabs (compose/popup/target=_blank a driven page opened). Acknowledges spawns.', { sessionId: 'optional str (all sessions if omitted)', profile: P }, { ok: 'bool', opened: '[{sessionId,windowId,url,title,purpose,ageMs}]', spawned: '[{kind,windowId,tabId,url,ageMs,spawnedBy,opener}]' }, 20, { example: { sessionId: 'irs' } }),
        d('nbrowser_cleanup', 'Close ONLY what a session SPAWNED (leaked composes/popups). Never the user\'s windows, never your primary opened window.', { sessionId: 'required str', profile: P }, { ok: 'bool', closed: '[{windowId,url}]', count: 'int', keptOpenedWindow: 'int?' }, 20, { example: { sessionId: 'irs' } }),
        d('nbrowser_force_close', 'LAST-RESORT: close a TRUE ORPHAN window (unattributable to any live session) or your own - incl. pup_open/ah-test litter. TWO-STEP: call 1 {windowId} (extension-visible) OR {hwnd} (OS-level, extension-unreachable e.g. Edge sans extension) returns overrideTicket + willDestroy; call 2 adds {overrideTicket,attestOrphan:true,reason} and closes (hwnd form = graceful WM_CLOSE). NEVER closes another live thread\'s window.', { windowId: 'int (extension-visible form)', hwnd: 'int (OS-level form, from desktop_list_windows)', sessionId: 'optional str (yours, to close your own)', overrideTicket: 'str (from call 1)', attestOrphan: 'bool (call 2: must be true)', reason: 'str (call 2: why)', profile: P }, { ok: 'bool', needsAttestation: 'bool?', overrideTicket: 'str?', willDestroy: 'obj?', closed: 'bool?' }, 20, { example: { windowId: 12345 } }),
        d('nbrowser_switch_window', 'Target a window for subsequent verbs; does NOT raise it unless the user asked to watch live.', { sessionId: 'required str', userRequestedForeground: 'optional bool (default false = do not raise; only true, and only on explicit user request, raises it)' }, { ok: 'bool', focused: 'bool' }, 15, { example: { sessionId: 'work' } }),
        d('nbrowser_list_windows', 'List the live windows/sessions, each with an owner label (agent-opened|agent-spawned|user). mine:true = only agent windows.', { profile: P, mine: 'optional bool' }, { ok: 'bool', sessions: '[{id,windowId,owner,spawnedBy,purpose,tabCount,active,state,bounds,url}]', count: 'int' }, 20, { example: { mine: true } }),
        d('nbrowser_window_state', 'Set window state (fullscreen/maximized/normal/minimized) or move/resize via left/top/width/height. maximize/fullscreen enlarge the window but keep it in the BACKGROUND (auto-reverted) unless userRequestedForeground:true.', { sessionId: 'required str', state: 'optional fullscreen|maximized|normal|minimized', left: 'optional int', top: 'optional int', width: 'optional int', height: 'optional int', userRequestedForeground: 'optional bool - allow maximize/fullscreen to raise the window, ONLY when the user asked to watch live (default false)' }, { ok: 'bool', windowId: 'int', state: 'str', _warning: 'str?' }, 20, { example: { sessionId: 'work', state: 'maximized' } }),
        d('nbrowser_fullscreen', 'Toggle the window to fullscreen (hides tabs+URL bar) for clean recordings; stays in the background unless userRequestedForeground:true.', { sessionId: 'required str', userRequestedForeground: 'optional bool (default false)' }, { ok: 'bool', state: '"fullscreen"' }, 20, { example: { sessionId: 'work' } }),
        d('nbrowser_login_state', 'Report sign-in state for the profile.', { profile: P }, { ok: 'bool' }, 20, { example: {} }),
        d('nbrowser_credentials', "Read-only query of the browser's saved-login store (host+username only; never passwords).", { match: 'optional vendor substring', browser: 'optional "chrome"|"edge"', profile: P }, { ok: 'bool', count: 'int', credentials: '[{browser,profile,host,username}]' }, 20, { example: { match: 'digikey' } }),
        d('nbrowser_profiles', 'List connected BROWSER×PROFILE targets + which is active.', {}, { ok: 'bool', profiles: '[{profile,email,browser,active,blocked,live}]', active: 'str' }, 15, { example: {} }),
        d('nbrowser_use_profile', 'Set the default profile action verbs target.', { profile: 'required str (key or email)' }, { ok: 'bool', active: 'str' }, 15, { example: { profile: 'chrome:you@example.com' } }),
        d('nbrowser_pin_session', 'Pin a sessionId to a profile so every verb on that session auto-routes there (prevents cross-profile mistakes). open_window pins automatically.', { sessionId: 'required str', profile: 'required str (key or email)' }, { ok: 'bool', sessionId: 'str', pinnedTo: 'str' }, 15, { example: { sessionId: 'schwab', profile: 'chrome:you@example.com' } }),
        d('nbrowser_open_os_window', 'EXTENSION-FREE open of a URL in the user\'s real browser profile (OAuth consent etc). Background by default (minimize + orange taskbar flash); foreground needs foregroundReason. Returns hwnd (close later via force_close). Upsells the nbe install in every response.', { sessionId: 'required str', thread: 'required str', purpose: 'required str', profile: 'required str (key or email)', url: 'required http(s) str', browser: 'optional chrome|edge', foreground: 'optional bool', foregroundReason: 'required if foreground' }, { ok: 'bool', hwnd: 'int? (foreground later via desktop_bring_to_front {hwnd})', minimized: 'bool', flashed: 'bool', extensionInstalled: 'bool' }, 20, { example: { sessionId: 'gauth', thread: 'adom-google-setup', purpose: 'open Google OAuth consent for adom-google', profile: 'chrome:user@gmail.com', url: 'https://accounts.google.com/o/oauth2/auth?...' } }),
        d('nbrowser_os_windows', 'OS-level browser-window inventory from the laptop (bridge-side): all Chrome/Edge/pup windows with hwnd/title/rect/z + which nbe session owns each. Reliable even when the caller\'s desktop_list_windows returns 0 (wrong target / ab hiccup) or a profile has no extension.', {}, { ok: 'bool (false = ab link down, data unknown)', count: 'int', windows: '[{hwnd,title,rect,z,kind,nbeSession}]' }, 15, { example: {} }),
        d('nbrowser_wake_profile', 'Wake an asleep browser profile with NO window (launches --no-startup-window; nothing foregrounds). Profile becomes live/drivable when its extension connects.', { profile: 'required str (key or email)', browser: 'optional chrome|edge' }, { ok: 'bool', woke: 'bool', alreadyLive: 'bool?', profile: 'str' }, 15, { example: { profile: 'chrome:media@example.com' } }),
        d('nbrowser_resolve_profiles', 'Force every connected extension to re-resolve its Google email + re-announce, so pending-N/empty-email keys become chrome:<email>/edge:<email>.', {}, { ok: 'bool', profiles: '[{profile,email,browser}]' }, 15, { example: {} }),
        d('nbrowser_whoami', 'Ask one profile to (re)resolve + report its Google email and re-announce it.', { profile: P }, { ok: 'bool', email: 'str', signedIn: 'bool', browser: 'str' }, 15, { example: {} }),
        d('nbrowser_profile_block', 'Blocklist a profile so it is NEVER driven (persists).', { profile: 'required str' }, { ok: 'bool', blocklist: '[str]' }, 15, { example: { profile: 'edge:you@example.com' } }),
        d('nbrowser_profile_unblock', 'Remove a profile from the blocklist.', { profile: 'required str' }, { ok: 'bool', blocklist: '[str]' }, 15, { example: { profile: 'edge:you@example.com' } }),
        d('nbrowser_rescan', 'Reconcile the session map against live windows (drop/mark-closed the gone ones).', {}, { ok: 'bool', sessions: '[...]' }, 20, { example: {} }),
        d('nbrowser_resync_sessions', 'Lightweight rebuild of session tracking (no dev_reload): re-read persisted map + re-validate vs live windows. Use on a session_untracked error.', {}, { ok: 'bool', tracked: '[{sessionId,windowId}]', trackedCount: 'int', closed: '[{sessionId,byUser,external}]' }, 15, { example: {} }),
        d('nbrowser_open_tab', 'Open a tab in the BACKGROUND (active:false) in one of YOUR registered session windows.', { sessionId: 'required str (a registered session from open_window)', url: 'required str', userRequestedForeground: 'optional bool - activate ONLY when the user asked to watch live (default false; a bare focused:true is ignored)' }, { ok: 'bool', sessionId: 'str', tabId: 'str', url: 'str', title: 'str' }, 30, { example: { sessionId: 'work', url: 'https://example.com' } }),
        d('nbrowser_close_tab', 'Close a tab.', { sessionId: S, tabId: 'required str' }, { ok: 'bool', tabId: 'str' }, 20, { example: { sessionId: 'work', tabId: '123' } }),
        d('nbrowser_switch_tab', 'Make a tab active within its window.', { sessionId: S, tabId: 'required str' }, { ok: 'bool', tabId: 'str' }, 15, { example: { sessionId: 'work', tabId: '123' } }),
        d('nbrowser_list_tabs', 'List tabs.', { profile: P, sessionId: S }, { ok: 'bool', tabs: '[{tabId,url,title,active}]' }, 20, { example: { sessionId: 'work' } }),
        d('nbrowser_navigate', 'Navigate the active (or given) tab to a URL.', { sessionId: S, tabId: 'optional str', url: 'required str' }, { ok: 'bool', url: 'str', tabId: 'str' }, 45, { example: { sessionId: 'work', url: 'https://example.com' } }),
        d('nbrowser_reload', 'Reload the tab.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool' }, 45, { example: { sessionId: 'work' } }),
        d('nbrowser_back', 'History back.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work' } }),
        d('nbrowser_forward', 'History forward.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work' } }),
        d('nbrowser_wait', 'Sleep server-side for ms (or wait on the tab).', { sessionId: S, ms: 'optional int' }, { ok: 'bool' }, 125, { example: { ms: 1000 } }),
        d('nbrowser_wait_for', 'DETERMINISTIC wait: block until a selector exists / URL contains / visible text contains, or timeoutMs. Use instead of fixed sleeps after a click/nav.', { sessionId: S, selector: 'optional CSS selector', urlIncludes: 'optional url substring', textIncludes: 'optional visible-text substring', timeoutMs: 'optional int (default 15000, max 120000)' }, { ok: 'bool', matched: 'bool', by: '"selector"|"url"|"text"', url: 'str', waitedMs: 'int', timedOut: 'bool?' }, 125, { example: { selector: '#dashboard' } }),
        d('nbrowser_keepalive', 'Keep a session active (benign mousemove/focus on an interval) to avoid vendor idle sign-outs. Per-session; auto-stops on window close; {stop:true} to cancel.', { sessionId: S, intervalSec: 'optional int (30..900, default 240)', stop: 'optional bool' }, { ok: 'bool', keepalive: '"on"|"off"', intervalSec: 'int' }, 15, { example: { intervalSec: 240 } }),
        d('nbrowser_screenshot', 'Screenshot the tab via CDP (captures a BACKGROUND/occluded window; resized <=1568).', { profile: P, sessionId: S, tabId: 'optional str', maxWidth: 'optional int' }, { ok: 'bool', base64: 'str', filePath: 'str?' }, 120, { example: { sessionId: 'work', maxWidth: 1500 } }),
        d('nbrowser_screenshot_full_res', 'Full-resolution / full-page screenshot via CDP.', { profile: P, sessionId: S, tabId: 'optional str', fullPage: 'optional bool' }, { ok: 'bool', base64: 'str' }, 120, { example: { sessionId: 'work', fullPage: true } }),
        d('nbrowser_eval', 'Run JS in the page context and return the result.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: 'document.title' } }),
        d('nbrowser_evaluate', 'Alias of nbrowser_eval.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: '1+1' } }),
        d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"move"|"type"|"key"', x: 'int?', y: 'int?', text: 'str?', key: 'str?' }, { ok: 'bool' }, 120, { example: { sessionId: 'work', type: 'click', x: 200, y: 300 } }),
        d('nbrowser_click', 'Click by visible text / selector / xy; scrolls into view + returns changed:bool (did the URL/DOM actually change). Finder pierces open shadow roots. Optional gliding cursor + intent label.', { sessionId: S, tabId: 'optional str', text: 'optional str', selector: 'optional str', x: 'int?', y: 'int?', cursor: 'optional bool', intent: 'optional str', settleMs: 'optional int (default 500)' }, { ok: 'bool', changed: 'bool', urlBefore: 'str', urlAfter: 'str' }, 30, { example: { sessionId: 'work', text: 'Sign in', cursor: true, intent: 'Clicking Sign in' } }),
        d('nbrowser_select', 'Pick a dropdown/combobox option BY VISIBLE TEXT (custom or native): finds the trigger by label|near, opens it, waits for options, clicks the exact match (shadow-piercing), returns the selected value to verify.', { sessionId: S, tabId: 'optional str', label: 'optional str (control accessible name)', near: 'optional str (visible label beside it)', optionText: 'required str (exact option text)', timeoutMs: 'optional int (default 4000, max 20000)' }, { ok: 'bool', selected: 'str', matchedExact: 'bool', clickedText: 'str', via: '"custom-dropdown"|"native-select"' }, 25, { example: { sessionId: 'work', near: 'Account', optionText: 'Individual …3456' } }),
        d('nbrowser_set_file_input', 'Upload files into an <input type=file> over CDP - no OS picker, background-safe. Direct (selector/text -> input, incl. hidden) or armForNextChooser:true for inputs created on Browse-click. files:[{name,contentBase64}] = bridge stages small files on the laptop.', { sessionId: 'required str', selector: 'optional str', text: 'optional str', filePaths: 'optional [abs laptop paths]', files: 'optional [{name, contentBase64}]', armForNextChooser: 'optional bool', chooserStatus: 'optional bool' }, { ok: 'bool', inputFound: 'bool?', via: 'direct|chooser', files: '[names]', armed: 'bool?', served: 'bool?' }, 20, { example: { sessionId: 'work', selector: 'input[type=file]', filePaths: ['C:/Users/me/Downloads/foo.png'] } }),
        d('nbrowser_set_date', 'Fill a react-aria segmented date field (role=spinbutton MM/DD/YYYY). Types each digit as a real keydown so segments advance.', { sessionId: S, selector: 'required CSS selector of the date field/segment', date: 'required "YYYY-MM-DD"', order: 'optional "MDY"(default)|"DMY"' }, { ok: 'bool', fieldValue: 'str?' }, 30, { example: { sessionId: 'work', selector: '[aria-label="Start date"]', date: '2026-03-15' } }),
        d('nbrowser_totp', 'Type the current 6-digit 2FA code for a host into the focused field (agent never sees seed or code). Register the seed once with nbrowser_register_totp.', { sessionId: S, host: 'required host, e.g. "gusto.com"', selector: 'optional 2FA field selector (else the focused field)', submit: 'optional bool (press Enter after)' }, { ok: 'bool', typed: 'bool', digits: 'int' }, 20, { example: { sessionId: 'work', host: 'gusto.com', submit: true } }),
        d('nbrowser_register_totp', 'One-time: register a base32 TOTP secret for a host (stored 0600 on the laptop, never returned).', { host: 'required host', seed: 'required base32 secret' }, { ok: 'bool', host: 'str' }, 15, { example: { host: 'gusto.com', seed: 'JBSWY3DPEHPK3PXP' } }),
        d('nbrowser_add_extension_origin', 'Add an extension ID to the native-messaging host allowed_origins (so Edge/other-ID loads can reach the host). Reload the extension after.', { id: 'required extension id from edge://extensions' }, { ok: 'bool', origin: 'str', updatedManifests: '[str]' }, 15, { example: { id: 'abcdefghijklmnopabcdefghijklmnop' } }),
        d('nbrowser_type', 'Type text into the focused field (isTrusted).', { sessionId: S, tabId: 'optional str', text: 'required str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work', text: 'hello world' } }),
        d('nbrowser_press_key', 'Press a key (Enter, Tab, etc.).', { sessionId: S, tabId: 'optional str', key: 'required str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work', key: 'Enter' } }),
        d('nbrowser_handle_dialog', 'Dismiss a native JS dialog (alert/confirm/prompt/beforeunload) blocking the page. accept:true=OK/Yes/Leave, false=Cancel/Stay; text answers a prompt.', { sessionId: S, tabId: 'optional str', accept: 'optional bool (default true)', text: 'optional str (prompt answer)' }, { ok: 'bool', handled: 'bool', accepted: 'bool' }, 15, { example: { sessionId: 'work', accept: true } }),
        d('nbrowser_fetch_url', "Fetch a URL using the profile's real cookies/session; optional save to desktop.", { url: 'required str', profile: P, desktopSaveTo: 'optional Windows path' }, { ok: 'bool', status: 'int', body: 'str?', savedTo: 'str?' }, 120, { longRunning: true, example: { url: 'https://example.com/api.json' } }),
        d('nbrowser_download_wait', 'Wait for a download to complete; returns the landed filename(s). Optional saveToSubfolder routes downloads into a Downloads subfolder.', { sessionId: S, sinceMs: 'optional int (look back window, default 60000)', timeoutMs: 'optional int (default 60000, max 300000)', saveToSubfolder: 'optional str' }, { ok: 'bool', files: '[str]', count: 'int', timedOut: 'bool' }, 120, { longRunning: true, example: { sessionId: 'work', timeoutMs: 60000 } }),
        d('nbrowser_errors', 'Return buffered page/console errors for the tab.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool', errors: '[...]' }, 20, { example: { sessionId: 'work' } }),
        d('nbrowser_close', 'Detach the debugger / close managed windows. No sessionId = all.', { sessionId: 'optional str' }, { ok: 'bool' }, 20, { example: {} }),
        d('nbrowser_record_start', '⭐ Record a browser window. STRONGLY prefer method:"wgc" (delegates to the core Bridge verb desktop_record_window_start: background, up to 60fps, NO picker, NO banner, captures occluded windows, no focus theft). method:"getdisplaymedia"/"tabcapture" is a LAST-RESORT fallback (Chrome forces a manual share picker + banner) and is REFUSED unless you also pass fallbackConfirmed:true. Calling with no method returns the WGC guidance hint.', { method: 'required "wgc" (recommended) | "getdisplaymedia" | "tabcapture"', hwnd: 'int? (or titleContains)', titleContains: 'str?', fps: 'optional int (default 30)', codec: 'optional "h264"|"hevc"', fallbackConfirmed: 'required true ONLY for getdisplaymedia/tabcapture, to acknowledge the picker/banner tradeoff' }, { ok: 'bool', recordingId: 'str', path: 'str' }, 120, { statusVerb: 'nbrowser_record_status', longRunning: true, example: { method: 'wgc', titleContains: 'chip-fetcher' } }),
        d('nbrowser_record_stop', 'Stop a recording and finalize the mp4.', { method: 'required "wgc"', recordingId: 'required str' }, { ok: 'bool', path: 'str', frames: 'int' }, 120, { example: { method: 'wgc', recordingId: 'rec-...' } }),
        d('nbrowser_record_status', 'Poll a recording.', { method: 'required "wgc"', recordingId: 'optional str' }, { ok: 'bool', status: 'str' }, 20, { example: { method: 'wgc' } }),
        d('nbrowser_record_list', 'List recent recordings.', {}, { ok: 'bool', recordings: '[...]' }, 20, { example: {} }),
        d('nbrowser_flash', "Flash the driven window's taskbar button ('come look' cue).", { sessionId: 'required str' }, { ok: 'bool' }, 15, { example: { sessionId: 'work' } }),
        d('nbrowser_flash_stop', 'Stop the taskbar flash.', { sessionId: 'required str' }, { ok: 'bool' }, 15, { example: { sessionId: 'work' } }),
        d('nbrowser_flash_mode', 'Set auto-flash verbosity.', { mode: 'required "quiet"|"chatty"|"off"' }, { ok: 'bool', mode: 'str' }, 15, { example: { mode: 'quiet' } }),
        d('nbrowser_taskbar', 'Paint progress/overlay/flash on the window taskbar button.', { sessionId: 'required str', progress: 'optional {state,value?}', overlay: 'optional {badge}', flash: 'optional "until_focused"|"stop"' }, { ok: 'bool', applied: '[str]' }, 15, { example: { sessionId: 'work', progress: { state: 'indeterminate' } } }),
        // These 12 were implemented but never described (nor declared in bridge.json verbs[]), so they
        // were invisible to every caller for several releases. Keep this list in step with VERB_META;
        // tools/check-guard-coverage.mjs now fails the build if it drifts again.
        d('nbrowser_events', 'Drain buffered lifecycle events since your last call: window_closed (with byUser), tab_closed, spawned_window (a page you drove opened a compose/popup), focus_violation, foreground_raised, dialog. THE channel by which you learn the user closed your window or a page spawned one behind your back.', { limit: 'optional int' }, { ok: 'bool', events: '[{event,windowId?,tabId?,byUser?,url?,agoMs}]', count: 'int' }, 20, { example: {} }),
        d('nbrowser_hover', 'Hover an element (by selector or visible text) to reveal menus/tooltips. Real isTrusted input.', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.info-icon' } }),
        d('nbrowser_double_click', 'Double-click an element (isTrusted).', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '#row-3' } }),
        d('nbrowser_right_click', 'Right-click an element to open a context menu (isTrusted).', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.ctx' } }),
        d('nbrowser_focus', 'Select a tab within its window and politely flash its taskbar button. Does NOT raise the window unless you justify a foreground.', { sessionId: S, tabId: 'optional str', userRequestedForeground: 'optional bool', foregroundReason: 'REQUIRED with userRequestedForeground' }, { ok: 'bool', tabId: 'str', foregrounded: 'bool' }, 15, { example: { sessionId: 's1' } }),
        d('nbrowser_login_form', 'Detect a login form\'s state (already autofilled? SSO? 2FA?) and recommend the smartest path. ALWAYS call this before driving a login.', { sessionId: S }, { ok: 'bool', hasForm: 'bool', autofilled: 'bool', sso: 'bool', needs2FA: 'bool', recommendation: 'str' }, 30, { statusVerb: null, example: { sessionId: 's1' } }),
        d('nbrowser_autologin', 'Fill and submit a saved-credential login. The bridge injects the laptop-decrypted secret directly; the agent NEVER sees it. Gated on confirm:true + host.', { sessionId: S, host: 'required str (e.g. "gusto.com")', confirm: 'required true', username: 'optional str' }, { ok: 'bool', loggedIn: 'bool', needs2FA: 'bool' }, 60, { example: { sessionId: 's1', host: 'gusto.com', confirm: true } }),
        d('nbrowser_download', 'Download a URL to the user\'s real Downloads folder via chrome.downloads (lands on the LAPTOP, not the container).', { sessionId: S, url: 'required str', filename: 'optional str', saveAs: 'optional bool' }, { ok: 'bool', downloadId: 'int', filename: 'str?' }, 60, { statusVerb: 'nbrowser_download_wait', example: { sessionId: 's1', url: 'https://example.com/datasheet.pdf' } }),
        d('nbrowser_clear_cookies', 'Background logout: drop one registrable domain\'s cookies (incl. httpOnly), leaving other sites (e.g. Google SSO) intact.', { host: 'required str (e.g. "gusto.com"; url/domain also accepted)' }, { ok: 'bool', removed: 'int', hosts: '[str]' }, 30, { example: { host: 'gusto.com' } }),
        d('nbrowser_caption', 'Paint an on-page caption (subtitle, or red alert banner) for demos and narration.', { sessionId: S, text: 'required str (or action:"hide")', style: 'optional "subtitle"(default)|"alert"', position: 'optional str', fontSize: 'optional int', pulse: 'optional bool', durationMs: 'optional int (default 5000)', action: 'optional "hide"' }, { ok: 'bool', tabId: 'str', style: 'str' }, 20, { example: { sessionId: 's1', text: 'Signing in to DigiKey' } }),
        d('nbrowser_cursor', 'Show or move the gliding Adom cursor with an intent label (demo narration). Visual only, it does not click.', { sessionId: S, action: 'optional "move"(default)|"hide"', x: 'optional int', y: 'optional int', label: 'optional str (or intent)' }, { ok: 'bool', tabId: 'str', action: 'str', label: 'str' }, 20, { example: { sessionId: 's1', x: 400, y: 300, label: 'Clicking Sign in' } }),
        d('nbrowser_dev_reload', 'MAINTAINER-only: reload the extension to pick up on-disk code changes (2-pass, reconnects in ~10s). Throttled to 1 per 90s.', { profile: P }, { ok: 'bool', reloaded: 'bool', throttled: 'bool?' }, 30, { example: { profile: 'chrome:john@adom.inc' } }),
        d('nbrowser_describe', 'This verb: returns per-verb schemas for the ab Verbs tab.', {}, { success: 'bool', verbs: '[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]' }, 15, { example: {} }),
      ];
      return resolve({ success: true, verbs });
    }
    // --- multi-profile management (bridge-local) ---
    if (verb === 'nbrowser_profiles') {
      // The bridge is the BROWSER EXPERT: enrich each live connection with what the browser itself
      // knows about the profile (Local State info_cache) - the human display label the user sees in
      // Chrome/Edge ("John Personal", "adom.inc"), the profile DIRECTORY, and whether it has an avatar.
      // Without this an AI cannot reliably tell work from personal (the incident: "the extension status
      // doesn't expose the Chrome profile's label").
      const list = [...conns.entries()].map(([k, c]) => {
        const row = { profile: k, label: profileLabel(c.profile), email: c.profile.email || '', browser: c.profile.browser || 'chrome', active: k === activeProfile, blocked: isBlocked(k), live: !!(c.sock && !c.sock.destroyed) };
        const av = avatarForProfile(k);
        if (av) { row.profileDir = av.profileDir; row.hasAvatar = !!av.avatarPath; }
        try {
          if (row.email) {
            const local = process.env.LOCALAPPDATA;
            const root = row.browser === 'edge' ? path.join(local, 'Microsoft', 'Edge', 'User Data') : path.join(local, 'Google', 'Chrome', 'User Data');
            const jj = JSON.parse(fs.readFileSync(path.join(root, 'Local State'), 'utf8'));
            for (const [dir, info] of Object.entries((jj.profile && jj.profile.info_cache) || {})) {
              if (String((info && info.user_name) || '').toLowerCase() === row.email.toLowerCase()) { row.displayName = String((info && info.name) || ''); row.profileDir = dir; break; }
            }
          }
        } catch {}
        if (!row.email) row.unresolved = true;
        return row;
      });
      // COMPLETENESS: also report every profile the BROWSER knows about (Local State) whose extension is
      // NOT connected right now - a profile with no open window has a SLEEPING service worker, so it
      // never announces, but it absolutely exists and the user may use it daily (real gripe: media@
      // missing from the list read as "the bridge is lazy"). asleep:true = exists, not drivable until a
      // window opens in it.
      const liveEmails = new Set(list.map((r) => `${r.browser}:${(r.email || '').toLowerCase()}`));
      for (const kp of knownProfiles()) {
        if (liveEmails.has(`${kp.browser}:${kp.email.toLowerCase()}`)) continue;
        const st = adomExtStateForProfile(kp.browser, kp.profileDir);
        const row = { profile: kp.key, label: kp.email, email: kp.email, browser: kp.browser, displayName: kp.displayName, profileDir: kp.profileDir, hasAvatar: kp.hasAvatar, active: false, blocked: isBlocked(kp.key), live: false, asleep: true };
        if (st.checked) { row.extensionInstalled = !!st.installed; if (st.installed) row.extensionEnabled = !!st.enabled; if (!st.installed || !st.enabled) row.fix = extFixHint(kp.browser, kp.profileDir, st); }
        list.push(row);
      }
      return resolve({ success: true, output: JSON.stringify({ ok: true, profiles: list, active: activeProfile, blocklist: [...blocklist], _hint: 'Each entry is one BROWSER×PROFILE the AI can drive - `profile` is the addressable key like "edge:you@example.com" (browser:email). displayName = the human label the USER sees on the browser\'s own profile button ("John Personal", "adom.inc") - use it together with the email DOMAIN to pick work vs personal (corporate domain = work; gmail.com etc = personal). NEVER assume "the" browser: the same email can exist in Chrome AND Edge - match `browser` too. unresolved:true = connected but email not announced yet (nbrowser_resolve_profiles first, or you may drive the wrong account). asleep:true = the profile EXISTS on this machine but has NO OPEN WINDOW, so its extension service worker is asleep and it CANNOT be driven yet - wake it with nbrowser_wake_profile {profile:\'<key>\'} (opens NO window, foregrounds NOTHING), then it appears live here. NEVER launch a foreground browser window just to wake a profile. Target profiles in PARALLEL by passing profile:"<key>" on any verb. ▶active is only the default when no target is passed. Switch with nbrowser_use_profile; exclude with nbrowser_profile_block.' }) });
    }
    if (verb === 'nbrowser_use_profile') {
      const want = String((args && (args.profile || args.email)) || '');
      const hit = [...conns.keys()].find((k) => k === want || (conns.get(k).profile.email === want));
      if (!hit) return resolve({ success: false, error: `no connected profile matches '${want}'`, _hint: 'Run nbrowser_profiles to list connected profiles by email.' });
      if (isBlocked(hit)) return resolve({ success: false, error: `profile '${hit}' is blocklisted`, _hint: 'nbrowser_profile_unblock first.' });
      activeProfile = hit;
      return resolve({ success: true, output: JSON.stringify({ ok: true, active: activeProfile, _hint: 'Action verbs now default to this profile. You can still override per-call with profile:"<email>".' }) });
    }
    // PIN a session to a profile: every later verb carrying this sessionId auto-routes there (without
    // needing profile: on each call), so driving several accounts can't cross wires. open_window pins
    // automatically; this is the explicit/re-pin path.
    if (verb === 'nbrowser_pin_session') {
      const sid = args && args.sessionId;
      const want = String((args && (args.profile || args.email)) || '');
      if (!sid) return resolve({ success: false, error: 'sessionId required', _hint: 'nbrowser_pin_session {sessionId, profile} pins that session to a profile so later verbs on it auto-route there.' });
      const hit = [...conns.keys()].find((k) => k === want || (conns.get(k).profile.email === want));
      if (!hit) return resolve({ success: false, error: `no connected profile matches '${want}'`, _hint: 'Run nbrowser_profiles to list connected profiles by key/email.' });
      sessionProfile.set(sid, hit);
      return resolve({ success: true, output: JSON.stringify({ ok: true, sessionId: sid, pinnedTo: hit, _hint: `Session "${sid}" is pinned to ${hit}. Any verb carrying this sessionId (without an explicit profile:) now routes there. Re-pin anytime.` }) });
    }
    // Force every connected extension to RE-RESOLVE its Google email and re-announce, so pending-N/id keys
    // eagerly become chrome:<email>/edge:<email> - up front, before you drive and risk the wrong profile.
    if (verb === 'nbrowser_resolve_profiles') {
      const live = liveConns();
      if (!live.length) return resolve({ success: true, output: JSON.stringify({ ok: true, profiles: [], _hint: 'No profiles connected yet.' }) });
      for (const [, c] of live) { try { const id = ++seq; pending.set(id, { resolve: () => {}, timer: setTimeout(() => pending.delete(id), 4000), verb: 'nbrowser_whoami' }); c.sock.write(JSON.stringify({ id, verb: 'nbrowser_whoami', args: {} }) + '\n'); } catch {} }
      setTimeout(() => {
        const list = [...conns.entries()].filter(([, c]) => c.sock && !c.sock.destroyed).map(([k, c]) => ({ profile: k, email: c.profile.email || '', browser: c.profile.browser || 'chrome' }));
        resolve({ success: true, output: JSON.stringify({ ok: true, profiles: list, _hint: 'Re-resolved each connected extension\'s Google email + re-announced; keys now reflect chrome:<email> / edge:<email> where signed in. A profile with an empty email is not signed into Google - target it by its id-key or by browser:.' }) });
      }, 2500);
      return;
    }
    // OS-LEVEL force_close: {hwnd} instead of {windowId} - for browser windows in a profile whose
    // extension is NOT connected (e.g. Edge without the extension, or pup_open/pup litter from an
    // ah test). SAME dramatic two-step gate as the extension path: ticket + willDestroy + attestation,
    // and a window belonging to a live nbe session is refused absolutely. Restricted to REAL browser
    // windows (Chrome_WidgetWin class) so this can never close an arbitrary app. Uses a graceful
    // WM_CLOSE - a page with unsaved state may prompt instead of dying.
    if (verb === 'nbrowser_force_close' && args && args.hwnd && !args.windowId) {
      const hwnd = Number(args.hwnd);
      (async () => {
        let ws = [];
        try {
          const r = await abCommand('desktop', 'desktop_list_windows', {});
          let out = r.body; if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }
          ws = ((out && out.data && out.data.windows) || (out && out.windows) || []).filter((w) => /Chrome_WidgetWin/.test(w.className || ''));
        } catch {}
        const w = ws.find((x) => x.hwnd === hwnd);
        if (!w) return resolve({ success: false, errorCode: 'bad_window', error: `hwnd ${hwnd} is not a live BROWSER window`, _hint: 'OS-level force_close only targets real Chrome/Edge windows (Chrome_WidgetWin class). Find candidates with desktop_list_windows; for windows the extension CAN see, use force_close {windowId} instead.' });
        for (const [sid, h] of sessionHwnd) if (h === hwnd) return resolve({ success: false, errorCode: 'owned_by_other', error: `⛔ force_close REFUSED: hwnd ${hwnd} belongs to live nbe session '${sid}'. Cross-thread ownership is ABSOLUTE.`, _hint: 'Only that thread may close its window (nbrowser_close_window). If it is truly abandoned its badge idle-expires and its thread cleans up.' });
        if (!args.overrideTicket) {
          const ticket = 'os-' + Math.random().toString(36).slice(2, 10);
          _osCloseTickets.set(ticket, { hwnd, expires: Date.now() + 60000 });
          return resolve({ success: false, errorCode: 'attestation_required', output: JSON.stringify({ ok: false, needsAttestation: true, overrideTicket: ticket, willDestroy: { hwnd, title: w.title, className: w.className, osLevel: true }, _hint: `⚠️ OS-LEVEL FORCE-CLOSE is a destructive last resort. This will close the REAL browser window "${String(w.title || '').slice(0, 70)}" (hwnd ${hwnd}) and any unsaved content in it. To proceed call again with EXACTLY: nbrowser_force_close {hwnd:${hwnd}, overrideTicket:"${ticket}", attestOrphan:true, reason:"<why this window must go>"}. Ticket expires in 60s, closes only this one window, and a window owned by a live nbe session can NEVER be closed this way.` }) });
        }
        const t = _osCloseTickets.get(args.overrideTicket);
        if (!t || t.hwnd !== hwnd) return resolve({ success: false, errorCode: 'bad_ticket', error: 'overrideTicket does not match this hwnd', _hint: 'One ticket = one window, one use. Re-request with just {hwnd}.' });
        if (Date.now() > t.expires) { _osCloseTickets.delete(args.overrideTicket); return resolve({ success: false, errorCode: 'ticket_expired', error: 'override ticket expired (60s)', _hint: 'Request a fresh one with just {hwnd}, then confirm promptly.' }); }
        if (args.attestOrphan !== true || !args.reason) return resolve({ success: false, errorCode: 'attestation_required', error: '⛔ you must attest {attestOrphan:true, reason:"…"} to proceed - this deliberate friction ensures force-close is never reflexive.', _hint: 'Include attestOrphan:true and a human-readable reason.' });
        _osCloseTickets.delete(args.overrideTicket);
        const psBody = [
          'Add-Type @"',
          'using System;using System.Runtime.InteropServices;',
          'public class WMC { [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l); }',
          '"@',
          `[WMC]::PostMessage([IntPtr]${hwnd},0x0010,[IntPtr]::Zero,[IntPtr]::Zero)`,
        ].join('\r\n');
        const rr = await abCommand(null, 'run_script', { interpreter: 'powershell', body: psBody, timeoutSeconds: 20 }); // direct API takes plain `body` (scriptB64 is a CLI-ism)
        const rb = rr && rr.body;
        const sent = !!(rr && rr.status && rr.status < 300 && rb && (rb.success === true || rb.ok === true) && (rb.exitCode === undefined || rb.exitCode === 0));
        logActivity({ verb: 'nbrowser_force_close', sessionId: (args && args.sessionId) || null, thread: (args && args.thread) || null, profile: 'os-hwnd', ok: sent, errorCode: sent ? null : 'wm_close_failed' });
        if (!sent) return resolve({ success: false, errorCode: 'wm_close_failed', error: `attestation accepted but the WM_CLOSE could not be delivered (${(rb && (rb.error || rb.stderr)) || 'ab run_script failed'})`, _hint: 'The window was NOT closed. Retry force_close from call 1 ({hwnd} for a fresh ticket); if ab is unreachable, taskbar/flash are degraded too - check nbrowser_activity for _ab_link entries.' });
        return resolve({ success: true, output: JSON.stringify({ ok: true, closed: true, hwnd, osLevel: true, reason: args.reason, _hint: 'Sent a graceful WM_CLOSE. A window with unsaved state may show a confirm dialog instead of closing - verify with desktop_list_windows.' }) });
      })();
      return;
    }
    // AUTHORITATIVE OS-level browser-window inventory, measured ON the laptop BY the bridge - so an AI
    // whose own desktop_list_windows returns 0 (wrong --target with two abs connected, transient ab
    // hiccup, DPI-blind PowerShell EnumWindows) still has one reliable probe. Annotates each hwnd with
    // the live nbe session that owns it (never force-close those) and flags pup/Chrome-for-Testing.
    // EXTENSION-FREE OS OPEN — the graceful-degradation wedge (John, 2026-07-19). Getting a user to
    // install nbe is hard; but MANY high-value tasks only need their REAL browser OPENED at a URL in the
    // right profile - OAuth consent (adom-google, Autodesk APS) where they're already signed in and just
    // click Approve. So: open the native browser extension-free, BACKGROUND by default (brief blip, then
    // minimized + orange taskbar flash = "come click when you're ready"), registered like every window,
    // and every response UPSELLS the nbe install for the full feature set.
    if (verb === 'nbrowser_open_os_window') {
      const a = args || {};
      if (!a.sessionId || !a.thread || !a.purpose) {
        return resolve({ success: false, errorCode: 'session_unregistered', error: 'nbrowser_open_os_window requires sessionId + thread + purpose (registration - same as open_window).', _hint: `Corrected call: nbrowser_open_os_window {sessionId:"<task id>", thread:"<which AI thread>", purpose:"<why, human-readable>", profile:"chrome:user@example.com", url:"https://..."}` });
      }
      const url = String(a.url || '');
      if (!/^https?:\/\//i.test(url)) return resolve({ success: false, error: 'url must be http(s)', _hint: 'Pass the full URL to open (e.g. the OAuth consent link).' });
      const want = String(a.profile || a.email || '');
      const wantEmail = (want.includes(':') ? want.split(':').slice(1).join(':') : want).toLowerCase();
      const wantBrowser = want.includes(':') ? want.split(':')[0] : (a.browser || null);
      const kp = knownProfiles().find((pr) => pr.email.toLowerCase() === wantEmail && (!wantBrowser || pr.browser === wantBrowser));
      if (!kp) return resolve({ success: false, error: `no local browser profile matches '${want}'`, _hint: 'nbrowser_profiles lists every profile on this machine (live + asleep) with emails and displayNames - pick the identity the task needs.' });
      if (isBlocked(kp.key)) return resolve({ success: false, error: `profile '${kp.key}' is blocklisted`, _hint: 'The user blocked this profile from being driven.' });
      if (a.foreground === true && !a.foregroundReason) {
        return resolve({ success: false, errorCode: 'foreground_unjustified', error: 'foreground:true requires foregroundReason (and you should notify_user first).', _hint: 'Background is the default and is almost always right: the window opens, minimizes, and its taskbar button FLASHES orange - the user clicks it when THEY are ready. Only pass foreground:true + foregroundReason for a flow that genuinely breaks when unfocused.' });
      }
      // ── LIVE-PROFILE GUARD + protected-hwnd hardening (John, 2026-07-19 incident: an Edge open into the
      // Default profile - which was hosting the user's live workspace/AI-thread - likely disrupted it).
      // Two protections: (1) if the target browser has a window that looks like the Adom WORKSPACE or an
      // active AI-thread host, REFUSE unless confirmLiveProfile:true - never blindly launch a sibling
      // browser into the profile the user is working in; (2) whatever we do open, the background
      // watcher is handed an EXCLUDE list (protectedHwnds) so it can NEVER minimize/close the workspace
      // or another thread's live session window, even in the same profile.
      const BRAND = kp.browser === 'edge' ? /Microsoft.?\s*Edge\s*$/i : /Google Chrome\s*$/i;
      const WORKSPACE = /(?:-\s*Editor\s*-|Hydrogen|Adom Workspace|code-server|claude\.ai|galliaApril)/i;
      (async () => {
      let curWins = [];
      try { curWins = await chromeWindowsDetailed(); } catch {}
      const protectedHwnds = new Set();
      for (const w of curWins) { const t = String(w.title || ''); if (BRAND.test(t) && WORKSPACE.test(t)) protectedHwnds.add(w.hwnd); }
      for (const [sid, pk] of sessionProfile) { if (pk === kp.key && sid !== a.sessionId && !osOpened.has(sid)) { const h = sessionHwnd.get(sid); if (h) protectedHwnds.add(h); } }
      const local = process.env.LOCALAPPDATA;
      const exes = kp.browser === 'edge'
        ? ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe']
        : ['C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', local ? path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') : ''];
      const exe = exes.find((e) => e && fs.existsSync(e));
      if (!exe) return resolve({ success: false, error: `${kp.browser} executable not found at a standard path` });
      // AUTHORITATIVE ORDER: a LIVE native-messaging connection for this profile proves the extension is
      // installed AND running - that is what nbrowser_readiness reports. The on-disk inventory is only a
      // FALLBACK for profiles that are not connected (it cannot see a running-but-unlisted extension, and
      // it under-reports load-unpacked installs). Reporting them inconsistently made the upsell hint tell
      // users WITH the extension to install it (fusion-bridge report, 2026-07-21).
      const liveConn = (() => { for (const [k, c] of conns) { if (k === kp.key && c.sock && !c.sock.destroyed) return c; } return null; })();
      const extSt = liveConn
        ? { checked: true, installed: true, enabled: true, live: true, version: (liveConn.profile && liveConn.profile.version) || null }
        : adomExtStateForProfile(kp.browser, kp.profileDir);
        let hwnd = null, minimized = false, flashed = false;
        if (a.foreground !== true) {
          // OPEN like Hydrogen Desktop's browser picker (control.rs /open-in-profile), which opens native
          // browser windows reliably: a plain launch, NO `--profile-directory=Default` (only pass a
          // profile dir when it is NOT the default), and NEVER touch the window while it is being created.
          // The old code minimized the new window INSIDE a 30ms poll loop — hammering ShowWindow on a
          // browser window mid-construction, which CRASHED the whole Edge process (John, 2026-07-19). The
          // open was never the problem; the racy background call was. Fix: detect the new window read-only,
          // let it FULLY settle, then ONE gentle SW_SHOWMINNOACTIVE (minimize without stealing focus).
          const urlArg = url.replace(/['"]/g, '');
          const parts = [];
          if (kp.profileDir && kp.profileDir !== 'Default') parts.push(`'--profile-directory=${kp.profileDir.replace(/'/g, '')}'`);
          parts.push("'--new-window'", `'${urlArg}'`);
          const argArr = `@(${parts.join(',')})`;
          const psBody = [
            'Add-Type @"',
            'using System;using System.Text;using System.Collections.Generic;using System.Runtime.InteropServices;',
            'public class WW2 {',
            '  public delegate bool EnumProc(IntPtr h, IntPtr l);',
            '  [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr l);',
            '  [DllImport("user32.dll")] static extern int GetClassName(IntPtr h, StringBuilder sb, int max);',
            '  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int c);',
            '  public static List<long> Browsers() { var o = new List<long>(); EnumProc cb = delegate(IntPtr h, IntPtr l) { var sb = new StringBuilder(64); GetClassName(h, sb, 64); if (sb.ToString().StartsWith("Chrome_WidgetWin")) o.Add((long)h); return true; }; EnumWindows(cb, IntPtr.Zero); return o; }',
            '}',
            '"@',
            `$protected = @(${[...protectedHwnds].map((h) => `[long]${h}`).join(',')})`,
            '$pre = [WW2]::Browsers()',
            'foreach ($p in $protected) { if ($pre -notcontains $p) { $pre += $p } }',
            `Start-Process -FilePath "${exe}" -ArgumentList ${argArr}`,
            // DETECT ONLY — no ShowWindow in this loop. Just find the new window's hwnd.
            '$deadline = (Get-Date).AddSeconds(12)',
            '$found = 0',
            'while ((Get-Date) -lt $deadline) {',
            '  foreach ($h in [WW2]::Browsers()) { if (($pre -notcontains $h) -and ($protected -notcontains $h)) { $found = $h; break } }',
            '  if ($found -ne 0) { break }',
            '  Start-Sleep -Milliseconds 60',
            '}',
            // LET IT FULLY SETTLE, then ONE gentle minimize-without-activate. This is the crash-safe part.
            'if ($found -ne 0) { Start-Sleep -Milliseconds 700; [WW2]::ShowWindow([IntPtr]$found, 7) | Out-Null }',
            'Write-Output ("HWND=" + $found)',
          ].join(String.fromCharCode(13, 10));
          const rr = await abCommand(null, 'run_script', { interpreter: 'powershell', body: psBody, timeoutSeconds: 25 });
          const m = String((rr && rr.body && rr.body.stdout) || '').match(/HWND=(\d+)/);
          if (m && m[1] !== '0') { hwnd = Number(m[1]); minimized = true; }
          if (hwnd) { try { await taskbar(hwnd, { flash: 'until_focused' }); flashed = true; } catch {} }
        } else {
          const pre = new Set([...curWins.map((w) => w.hwnd), ...protectedHwnds]);
          abCommand('desktop', 'launch_process', { path: exe, args: [`--profile-directory=${kp.profileDir}`, '--new-window', url] });
          const t0 = Date.now();
          while (!hwnd && Date.now() - t0 < 8000) {
            await new Promise((r) => setTimeout(r, 500));
            try { const list = await chromeWindowsDetailed(); hwnd = list.map((w) => w.hwnd).find((h) => !pre.has(h) && !protectedHwnds.has(h)) || null; } catch {}
          }
        }
        if (a.foreground === true) announceForeground(a.foregroundReason, a.sessionId);
        else if (!hwnd) {
          // BACKGROUND WAS REQUESTED BUT COULD NOT BE HONORED: no new window was created (the browser
          // grouped the page into an existing one), so there is nothing to minimize and the page lands
          // in front of the user. Tell them what appeared and why - John, 2026-07-22: a sign-in page
          // showing up unannounced is the failure, whether or not the AI asked for foreground.
          announceVisibleOpen(`opened ${(() => { try { return new URL(url).hostname; } catch { return 'a page'; } })()} in your browser`, a.purpose, a.sessionId);
        }
        // LAYER-1 BADGE (hollow): mark the window Adom OPENED even though it cannot drive it - provenance
        // matters most on an OAuth consent page. Uses applyBadge so the normal 3-min idle expiry restores
        // the native avatar; nothing lingers.
        if (hwnd) { applyBadge(a.sessionId, hwnd, kp.key, 'hollow'); labelWindowOwner(hwnd, a.sessionId); }
        logActivity({ verb: 'nbrowser_open_os_window', sessionId: a.sessionId, thread: a.thread, profile: kp.key, ok: true,
          ops: `fg=${a.foreground === true}·hwndResolved=${!!hwnd}·minimized=${minimized}`, hwnd: hwnd || undefined,
          errorCode: hwnd ? null : 'grouped_into_existing_window' });
        sessionThread.set(a.sessionId, a.thread); sessionPurpose.set(a.sessionId, a.purpose); sessionProfile.set(a.sessionId, kp.key); if (hwnd) sessionHwnd.set(a.sessionId, hwnd); osOpened.add(a.sessionId); persistSessions();
        const upsell = extSt && extSt.installed
          ? `NOTE: the Adom extension IS installed in this profile${extSt.enabled ? '' : ' (but DISABLED)'} - you could have FULL control instead: nbrowser_wake_profile then nbrowser_open_window gives you navigation, reading, clicking, screenshots, and ownership guards. Prefer that path.`
          : `UPSELL (tell the user when the moment is right): this window is OPEN but I cannot see or drive it - the Adom extension is not installed in this profile. With nbe installed (see installing-the-extension - it can be installed FOR the user in ~a minute) the assistant could fill the form, read the result, screenshot progress, and clean up after itself. Until then the USER must do the clicking.`;
        // STABLE SUCCESS CONTRACT (fusion-bridge report, 2026-07-21): a fired launch IS success for a
        // consent flow - "the page is now in front of the user". hwnd resolution is a SEPARATE fact.
        // Callers must never have to regex _hint. ok/launched = did the browser launch; hwndResolved =
        // can we address the window afterwards (minimize/close/foreground).
        const hwndReason = hwnd ? null : 'grouped_into_existing_window';
        resolve({ success: true, output: JSON.stringify({ ok: true, launched: true, hwndResolved: !!hwnd, hwndReason, hwnd, minimized, flashed, profile: kp.key, displayName: kp.displayName, extensionInstalled: !!(extSt && extSt.installed), extensionLive: !!(extSt && extSt.live), url,
          _hint: hwnd
            ? `Opened ${url} in the user's REAL ${kp.browser} profile '${kp.displayName}' (${kp.profileDir}) WITHOUT the extension. ${a.foreground === true ? 'Foregrounded per your foregroundReason.' : `It opened MINIMIZED (background) with an ORANGE flashing taskbar button - the user clicks it when ready (perfect for OAuth consent: they are already signed in; they just approve). TO FOREGROUND IT (only when the user asks to see it, e.g. "bring it up" / a step that needs their eyes): desktop_bring_to_front {hwnd:${hwnd}} - that restores + raises this exact window (the flashing button already points them at it, so you rarely need to). Do NOT foreground for your own convenience - it steals their focus.`} You CANNOT read/drive this window (no extension in this profile) - poll the OUTCOME out-of-band (e.g. the OAuth CLI's token poll, or ask the user). Close it afterwards via nbrowser_force_close {hwnd:${hwnd}} (two-step attestation). ${upsell}`
            : `LAUNCH SUCCEEDED (ok:true, launched:true) but hwndResolved:false - ${kp.browser} almost certainly GROUPED the page into an existing window instead of creating a new one, so there is no new hwnd to address. The page IS in front of the user, which is success for a consent flow. Consequence: no minimize/flash/close by hwnd for this one - find it with nbrowser_os_windows if you need to address it. Do NOT treat this as a failure. ${upsell}` }) });
      })();
      return;
    }
    if (verb === 'nbrowser_os_windows') {
      (async () => {
        let ws = []; let abOk = true;
        try {
          const r = await abCommand('desktop', 'desktop_list_windows', {});
          let out = r.body; if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }
          ws = ((out && out.data && out.data.windows) || (out && out.windows) || []);
          if (r.status === 0) abOk = false;
        } catch { abOk = false; }
        const bySess = {}; for (const [sid, h] of sessionHwnd) bySess[h] = sid;
        const browsers = ws.filter((w) => /Chrome_WidgetWin/.test(w.className || '')).map((w) => ({ hwnd: w.hwnd, title: w.title, rect: w.rect, z: w.z, kind: /for Testing/.test(w.title || '') ? 'pup/chrome-for-testing' : 'native-browser', nbeSession: bySess[w.hwnd] || null }));
        resolve({ success: true, output: JSON.stringify({ ok: abOk, count: browsers.length, windows: browsers, totalOsWindows: ws.length, _hint: abOk ? 'Browser windows as the LAPTOP sees them right now (bridge-side enumeration - no --target guessing, DPI-safe). nbeSession = a live nbe session owns that hwnd (NEVER force-close it; that thread cleans up its own). kind pup/chrome-for-testing = pup_open/pup litter. Edge windows appear even when Edge has no extension. To close an untracked one: nbrowser_force_close {hwnd} (two-step attestation). If your own desktop_list_windows returned 0/null: most likely ab was mid-restart (it SELF-UPDATES and restarts, sometimes several times a day - anything in flight returns empty), or you targeted the WRONG desktop when multiple abs are connected (check `targets`; pass --target for the laptop).' : 'ab link is DOWN from the bridge right now (desktop_list_windows unreachable) - window data unavailable, not "0 windows". Check nbrowser_activity for _ab_link entries; retry shortly.' }) });
      })();
      return;
    }
    if (verb === 'nbrowser_wake_profile') {
      const want = String((args && (args.profile || args.email)) || '');
      if (!want) return resolve({ success: false, error: 'profile required', _hint: 'Pass profile:"chrome:media@example.com" (or just the email, optionally browser:"edge"). nbrowser_profiles lists asleep profiles.' });
      const wantEmail = (want.includes(':') ? want.split(':').slice(1).join(':') : want).toLowerCase();
      const wantBrowser = want.includes(':') ? want.split(':')[0] : ((args && args.browser) || null);
      const findLive = () => { for (const [k, c] of conns) if (c.sock && !c.sock.destroyed && String(c.profile.email || '').toLowerCase() === wantEmail && (!wantBrowser || c.profile.browser === wantBrowser)) return k; return null; };
      const already = findLive();
      if (already) return resolve({ success: true, output: JSON.stringify({ ok: true, alreadyLive: true, profile: already, _hint: 'Profile is already connected - drive it now (open_window still opens in the BACKGROUND by default).' }) });
      const kp = knownProfiles().find((pr) => pr.email.toLowerCase() === wantEmail && (!wantBrowser || pr.browser === wantBrowser));
      if (!kp) return resolve({ success: false, error: `no local browser profile matches '${want}'`, _hint: 'nbrowser_profiles lists every profile on this machine (live + asleep). Check the email/browser.' });
      if (isBlocked(kp.key)) return resolve({ success: false, error: `profile '${kp.key}' is blocklisted`, _hint: 'The user blocked this profile. nbrowser_profile_unblock first (with the user\'s consent).' });
      const local = process.env.LOCALAPPDATA;
      const exes = kp.browser === 'edge'
        ? ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe']
        : ['C:/Program Files/Google/Chrome/Application/chrome.exe', 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe', local ? path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe') : ''];
      const exe = exes.find((e) => e && fs.existsSync(e));
      if (!exe) return resolve({ success: false, error: `${kp.browser} executable not found at a standard path` });
      // --no-startup-window loads the profile (and its extensions) in BACKGROUND mode: NO window is
      // created, NOTHING foregrounds, no focus is stolen. The extension service worker starts, dials our
      // native host, and stays alive on the open port - the profile then announces and shows live. This
      // honors the background-by-default rule: waking a profile never needs a foreground window.
      abCommand('desktop', 'launch_process', { path: exe, args: [`--profile-directory=${kp.profileDir}`, '--no-startup-window'] });
      const t0 = Date.now();
      const poll = () => {
        const k = findLive();
        if (k) return resolve({ success: true, output: JSON.stringify({ ok: true, woke: true, profile: k, tookMs: Date.now() - t0, _hint: 'Profile is awake WITHOUT any window - nothing was foregrounded, no focus was stolen. Drive it normally now: nbrowser_open_window {sessionId, thread, purpose, profile} (which itself opens in the BACKGROUND, as always).' }) });
        _lastResolveNudge = 0; resolvePendingProfiles();
        if (Date.now() - t0 > 12000) {
          const st = adomExtStateForProfile(kp.browser, kp.profileDir);
          return resolve({ success: true, output: JSON.stringify({ ok: false, woke: false, profile: kp.key, extensionState: st, _hint: `Launched the profile in background mode but its extension did not connect within 12s. DIAGNOSIS: ${extFixHint(kp.browser, kp.profileDir, st)}` }) });
        }
        setTimeout(poll, 700);
      };
      setTimeout(poll, 1200);
      return;
    }
    if (verb === 'nbrowser_profile_block' || verb === 'nbrowser_profile_unblock') {
      const want = String((args && (args.profile || args.email)) || '');
      if (!want) return resolve({ success: false, error: 'profile (email) required' });
      if (verb === 'nbrowser_profile_block') { blocklist.add(want); if (activeProfile === want) activeProfile = (liveConns()[0] || [])[0] || null; }
      else blocklist.delete(want);
      saveBlocklist();
      return resolve({ success: true, output: JSON.stringify({ ok: true, blocklist: [...blocklist], active: activeProfile, _hint: verb === 'nbrowser_profile_block' ? 'Adom will NEVER drive this profile until unblocked (security whitelist). Persisted across restarts.' : 'Profile is drivable again.' }) });
    }
    // The CREDENTIAL LIBRARY - which vendors this user has a saved login for (origins+usernames; NEVER
    // passwords). Chrome's extension API can't read this; the bridge reads Chrome's Login Data DB
    // natively. Use it to know up-front which sites you can auto-login to, then drive the login.
    if (verb === 'nbrowser_credentials') {
      const match = args && (args.match || args.vendor || args.host);
      const browser = args && args.browser;   // optional: "chrome" | "edge"
      const profile = args && (args.profile || args.email); // optional: a specific profile email
      const rows = queryCredentials({ browser, profile, match });
      return resolve({ success: true, output: JSON.stringify({ ok: true, count: rows.length, credentials: rows, _hint: 'Read-only query of the BROWSER\'s own saved-login store (Chrome + Edge) - we do NOT store or replace passwords; the browser stays the source of truth. Each row = {browser, profile, host, username} you CAN auto-login to (password is NOT exposed - Chrome/Edge autofill it when you focus the login field). Flow: credentials {match:"digikey"} -> if present, navigate to the sign-in page -> click the user/email field (autofills) -> click submit -> login_state. Filters: match:"<vendor>", browser:"chrome"|"edge", profile:"<email>".' }) });
    }
    // AUTOLOGIN (path A): decrypt the saved password LOCALLY and forward it DOWNSTREAM to the extension,
    // which injects it into the password field via CDP. Opt-in (confirm:true) + host-scoped. The
    // plaintext is NEVER returned, logged, or sent up the relay - only {ok,loggedIn,needs2FA} comes back.
    if (verb === 'nbrowser_autologin') {
      const tgt0 = targetConn(args);
      if (!tgt0 || tgt0.blocked || !tgt0.conn) return resolve({ success: false, error: 'native browser extension not connected', _hint: 'Open a window in the target profile first; nbrowser_profiles lists connected profiles.' });
      if (!args || args.confirm !== true) return resolve({ success: false, error: 'autologin is gated - pass confirm:true to authorize using the saved password for this host', _hint: 'Opt-in per call (so it can never fire silently for arbitrary sites). The AI never sees the password: it is decrypted on the laptop and injected by the extension. Also pass host:"login.<vendor>.com" and optionally username.' });
      const host = String(args.host || '').replace(/^https?:\/\//, '').split('/')[0].toLowerCase();
      if (!host) return resolve({ success: false, error: 'host required (e.g. host:"login.gusto.com")', _hint: 'The host whose saved password to inject - must be explicit (no wildcard).' });
      const browser = (tgt0.conn.profile && tgt0.conn.profile.browser) || 'chrome';
      const profileEmail = (tgt0.conn.profile && tgt0.conn.profile.email) || '';
      // The bridge is sandboxed (its DPAPI scope can't decrypt Chrome's blobs - verified 2026-06-23), so we
      // DEFER decryption to the native host, which runs in Chrome's real context. Flag it + forward; the
      // host attaches __secret downstream. The bridge NEVER sees the password.
      dlog(`autologin: host=${host} profile=${browser}:${profileEmail} (decrypt deferred to in-Chrome host)`);
      args = { ...args, host, __decryptHere: true };
      // fall through to the normal targetConn + forward below.
    }
    // #3 register a TOTP seed for a host (bridge-local; seed stored 0600, NEVER returned).
    if (verb === 'nbrowser_register_totp') {
      const host = _hostKey(args && args.host); const seed = args && args.seed;
      if (!host || !seed) return resolve({ success: false, error: 'need {host, seed} (the base32 TOTP secret, e.g. from the vendor QR "manual entry" key)', _hint: 'One-time setup per host. The seed is stored 0600 on the laptop and never returned; nbrowser_totp {host} then types the current code into the focused 2FA field.' });
      try { _totpCode(seed); } catch { return resolve({ success: false, error: 'that is not a valid base32 TOTP secret', _hint: 'Use the base32 "manual entry / setup key" the vendor shows next to the 2FA QR code (letters A-Z + digits 2-7).' }); }
      const o = _readTotp(); o[host] = { seed: String(seed).replace(/[^A-Za-z2-7]/g, ''), addedAt: Date.now() }; _writeTotp(o);
      return resolve({ success: true, output: JSON.stringify({ ok: true, host, _hint: `TOTP seed for ${host} stored 0600 on the laptop - the agent never sees it. When a 2FA prompt appears, focus the code field (or pass selector) and call nbrowser_totp {host} to type the current 6-digit code.` }) });
    }
    // #3 compute the current TOTP code for a host and forward it to the extension to TYPE (as __code -
    // never returned to the agent). Compute-then-fall-through, like autologin.
    if (verb === 'nbrowser_totp') {
      const host = _hostKey(args && args.host);
      if (!host) return resolve({ success: false, error: 'host required (e.g. host:"gusto.com")', _hint: 'Which host to type the 2FA code for. Register its seed once with nbrowser_register_totp.' });
      const rec = _readTotp()[host];
      if (!rec || !rec.seed) return resolve({ success: false, error: `no TOTP seed registered for ${host}`, errorCode: 'no_totp_seed', _hint: `Register it once: nbrowser_register_totp {host:"${host}", seed:"<base32>"}.` });
      let code; try { code = _totpCode(rec.seed); } catch { return resolve({ success: false, error: 'failed to compute TOTP (stored seed invalid) - re-register it' }); }
      dlog(`totp: host=${host} (code injected downstream, never returned)`);
      args = { ...args, __code: code }; // forward to the extension; it types __code, returns only {typed}
      // fall through to the normal targetConn + forward below.
    }
    // #1 append an extension ORIGIN to the native-messaging host manifest(s) so a browser whose
    // loaded-unpacked ID differs (esp. Edge) can reach the host. Bridge-local file edit.
    if (verb === 'nbrowser_add_extension_origin') {
      const id = (args && args.id) || '';
      if (!id) return resolve({ success: false, error: 'need {id} - the extension ID from edge://extensions or chrome://extensions', _hint: 'Load the extension, copy its ID off the extensions page, then call this; then RELOAD the extension so the browser re-reads allowed_origins.' });
      const origin = /^chrome-extension:\/\//.test(id) ? id.replace(/\/?$/, '/') : `chrome-extension://${id}/`;
      const updated = [], present = [];
      for (const mp of _hostManifestPaths()) {
        try {
          if (!fs.existsSync(mp)) continue;
          const m = JSON.parse(fs.readFileSync(mp, 'utf8')); m.allowed_origins = Array.isArray(m.allowed_origins) ? m.allowed_origins : [];
          if (m.allowed_origins.includes(origin)) { present.push(mp); continue; }
          m.allowed_origins.push(origin); fs.writeFileSync(mp, JSON.stringify(m, null, 2)); updated.push(mp);
        } catch {}
      }
      return resolve({ success: true, output: JSON.stringify({ ok: updated.length > 0 || present.length > 0, origin, updatedManifests: updated, alreadyPresent: present, _hint: (updated.length || present.length) ? 'Origin is in the host manifest now. RELOAD the extension in that browser (edge://extensions -> Reload) so it re-reads allowed_origins and can reach the native host; then nbrowser_profiles should list it.' : 'No host-manifest.json found (checked ~/.adom/native-browser and ~/adom-native-browser/host). Re-run the install, or the manifest is elsewhere.' }) });
    }
    // --- taskbar flash (bridge-local; targets the cached driven-window hwnd) ---
    if (verb === 'nbrowser_flash_mode') {
      const m = String((args && args.mode) || '').toLowerCase();
      if (!['quiet', 'chatty', 'off'].includes(m)) {
        return resolve({ success: false, error: 'mode must be quiet|chatty|off', _hint: 'quiet=auto-flash on come-look moments (page load, screenshot, error/refusal, human gate, spawned window) [default]; chatty=flash on EVERY action (for users who want to watch the AI); off=NO auto-flash at all (user opt-out).' });
      }
      flashMode = m;
      const note = m === 'chatty' ? 'Will flash the browser taskbar on EVERY activity verb. Tell the user they can say "quiet it down" anytime.' : m === 'off' ? 'Auto-flash disabled entirely (the user opt-out); nbrowser_flash still works manually.' : 'Will flash on come-look moments (page load, screenshot, error/refusal, human gate, spawned window); flash manually at task-done.';
      return resolve({ success: true, output: JSON.stringify({ ok: true, flashMode, _hint: note }) });
    }
    if (verb === 'nbrowser_flash' || verb === 'nbrowser_flash_stop') {
      const stop = verb === 'nbrowser_flash_stop';
      const hwnd = (args && args.sessionId && sessionHwnd.get(args.sessionId)) || lastHwnd;
      if (!hwnd) {
        return resolve({ success: false, error: 'no driven window captured yet', _hint: 'Open/drive a browser window first (nbrowser_open_window) - the bridge captures its taskbar hwnd at that moment. Then nbrowser_flash works.' });
      }
      flashHwnd(hwnd, stop);
      return resolve({ success: true, output: JSON.stringify({ ok: true, hwnd, stopped: stop, _hint: stop ? 'Taskbar flash cleared.' : 'Flashing the browser taskbar ORANGE until the user focuses that window (auto-clears on focus). nbrowser_flash_stop clears it early.' }) });
    }
    if (verb === 'nbrowser_taskbar') {
      const hwnd = (args && args.sessionId && sessionHwnd.get(args.sessionId)) || lastHwnd;
      if (!hwnd) return resolve({ success: false, error: 'no driven window captured yet', _hint: 'Open/drive a browser window first (nbrowser_open_window).' });
      const opts = {};
      if (args && args.progress) opts.progress = args.progress;
      if (args && args.overlay) opts.overlay = args.overlay;
      if (args && args.flash) opts.flash = args.flash;
      // TRAP GUARD: on a REAL browser the overlay slot is where Chrome/Edge draw the PROFILE AVATAR, so
      // literally clearing it (badge:"none") would blank the user's avatar - the destructive bug we fixed.
      // Remap: badge:"none" = "stop showing Adom" -> repaint the NATIVE look (plain circle avatar);
      // badge:"adom" -> the composite (avatar + Adom favicon) applyBadge builds. AIs keep the simple API.
      if (opts.overlay && opts.overlay.badge === 'none' && !opts.overlay.icon) {
        const pk = (args && args.sessionId && sessionProfile.get(args.sessionId)) || null;
        delete opts.overlay;
        if (args && args.sessionId) clearBadge(args.sessionId);
        nativeOverlay(hwnd, pk);
      } else if (opts.overlay && opts.overlay.badge === 'adom' && !opts.overlay.icon) {
        delete opts.overlay;
        if (args && args.sessionId) applyBadge(args.sessionId, hwnd, null); else paintBadge(hwnd, null);
      }
      taskbar(hwnd, opts).then(() => resolve({ success: true, output: JSON.stringify({ ok: true, hwnd, applied: Object.keys(opts).length ? Object.keys(opts) : ['overlay:managed'], _hint: 'progress.state: normal(green) | paused(YELLOW - use for captcha/2FA/login human-gates) | error(red) | indeterminate(marquee) | none. overlay.badge: "adom" = the driving badge (the bridge composites the profile\'s CIRCLE avatar + the Adom favicon - never a bare square) | "none" = restore the NATIVE avatar look (never blanks the slot). The badge is AUTOMATIC while driving and idle-expires after ~3 min, so you rarely need to set it. Clear progress+flash at task-end: {progress:{state:"none"},flash:"stop"}.' }) }));
      return;
    }
    // WGC recording is handled HERE (bridge -> ab direct API, standalone - never Hydrogen), not in the
    // extension (a browser can't capture another window). method:"wgc".
    if (verb.startsWith('nbrowser_record_') && args && args.method === 'wgc') {
      return wgcRecord(verb, args).then(resolve);
    }
    // ⭐ Recording guidance gate. The AI is "dumb" about recording options, so STEER it: any record_start
    // that isn't WGC gets a great hint pointing at the core WGC recorder, and the extension's own
    // getDisplayMedia/tabCapture path is refused until the AI explicitly acknowledges it as a fallback
    // (fallbackConfirmed:true). Without this, a naive record_start silently lands on Chrome's share picker.
    if (verb === 'nbrowser_record_start' && (!args || args.method !== 'wgc')) {
      const m = args && args.method;
      const acknowledged = (m === 'getdisplaymedia' || m === 'tabcapture') && args && args.fallbackConfirmed === true;
      if (!acknowledged) {
        return resolve({
          success: false,
          error: m
            ? `method:"${m}" is the inferior fallback - WGC is strongly preferred for recording a browser window`
            : 'no recording method chosen - WGC is strongly recommended (this is a fallback recorder)',
          recommended: { method: 'wgc', coreVerb: 'desktop_record_window_start' },
          _hint: RECORD_WGC_HINT,
        });
      }
      // acknowledged fallback → fall through to the extension's getDisplayMedia/tabCapture path below.
    }
    // OS-level window raise/focus is owned by the CORE Bridge verb desktop_bring_to_front. The
    // extension deliberately does NOT implement a focus/raise-window verb - it must never steal the
    // user's focus. So we don't expose a phantom verb; but an AI coming from pup will guess
    // pup_focus_window -> nbrowser_focus_window. Rather than fail opaquely in the extension,
    // redirect it to the core verb (with THIS session's captured hwnd) - it would find it on its own.
    const RAISE_REDIRECT = {
      nbrowser_focus_window: 'OS window raise/focus',
      nbrowser_raise_os_window: 'OS window raise',
      nbrowser_raise_window: 'OS window raise',
      nbrowser_bring_to_front: 'OS window raise',
    };
    if (RAISE_REDIRECT[verb]) {
      const hwnd = (args && args.sessionId && sessionHwnd.get(args.sessionId)) || lastHwnd;
      const target = hwnd ? `{"hwnd":${hwnd}}` : '{"titleContains":"<the driven window\'s title>"}';
      return resolve({
        success: false,
        error: `${verb} is not an extension verb - ${RAISE_REDIRECT[verb]} is owned by the core Bridge verb desktop_bring_to_front`,
        _hint: `Use the CORE ab verb instead: desktop_bring_to_front ${target}. The extension intentionally never raises or steals the user's focus - nbrowser_focus only activates the tab + flashes its taskbar button; desktop_bring_to_front is the explicit "bring it to the front so the user can watch" action.`,
      });
    }
    // Other pup verbs with no 1:1 extension verb - redirect to the right one instead of failing opaquely.
    if (verb === 'nbrowser_alert_window') {
      return resolve({ success: false, error: 'nbrowser_alert_window is not an extension verb', _hint: 'Flash the driven window\'s taskbar button with nbrowser_flash {sessionId} (orange "come look" cue; nbrowser_flash_stop to clear). For an arbitrary OS window, the core ab verb is desktop_flash_window {hwnd}.' });
    }
    if (verb === 'nbrowser_close') {
      return resolve({ success: false, error: 'nbrowser_close is not an extension verb', _hint: 'Close a specific window with nbrowser_close_window {sessionId} or a tab with nbrowser_close_tab {sessionId,tabId}. There is no global "detach all" - the extension idle-detaches the debugger automatically when no verb is running.' });
    }
    // First-party close for OS-OPENED sessions (nbrowser_open_os_window): no extension session exists
    // behind them, so close_window is handled bridge-side with a graceful WM_CLOSE. force_close correctly
    // refuses these (they ARE owned - by the session that opened them); this is that owner's close path.
    if (verb === 'nbrowser_close_window' && args && args.sessionId && osOpened.has(args.sessionId)) {
      const sid = args.sessionId; const h = sessionHwnd.get(sid);
      (async () => {
        let closed = false;
        if (h) {
          const psBody = ['Add-Type @"','using System;using System.Runtime.InteropServices;','public class WMC3 { [DllImport("user32.dll")] public static extern bool PostMessage(IntPtr h, uint m, IntPtr w, IntPtr l); }','"@',`[WMC3]::PostMessage([IntPtr]${h},0x0010,[IntPtr]::Zero,[IntPtr]::Zero)`].join(String.fromCharCode(13,10));
          const rr = await abCommand(null, 'run_script', { interpreter: 'powershell', body: psBody, timeoutSeconds: 15 });
          closed = !!(rr && rr.body && (rr.body.success === true || rr.body.ok === true));
        }
        sessionHwnd.delete(sid); osOpened.delete(sid); clearBadge(sid); persistSessions();
        logActivity({ verb: 'nbrowser_close_window', sessionId: sid, thread: sessionThread.get(sid) || null, profile: sessionProfile.get(sid) || null, ok: closed, errorCode: closed ? null : (h ? 'wm_close_failed' : 'no_hwnd') });
        resolve({ success: true, output: JSON.stringify({ ok: true, closed, osLevel: true, hwnd: h || null, _hint: closed ? 'OS-opened window closed (graceful WM_CLOSE) and the session cleaned up.' : 'No live hwnd for this OS-opened session (window may already be closed) - session cleaned up.' }) });
      })();
      return;
    }
    // nbrowser_set_file_input pre-flight (the bridge lives ON the laptop, so it can stat + stage):
    //  - files:[{name, contentBase64}] -> write to a janitored temp dir and substitute filePaths, so a
    //    cloud agent uploads small files in ONE call (no send_files round-trip);
    //  - stat every filePath BEFORE forwarding so a bad path fails fast as file_missing (CDP's own
    //    error for a missing file is cryptic).
    if (verb === 'nbrowser_set_file_input' && args) {
      try {
        if (Array.isArray(args.files) && args.files.length && !(args.filePaths || []).length) {
          const dir = path.join(os.tmpdir(), 'adom-nbrowser-uploads', String(args.sessionId || 'nosession').replace(/[^a-z0-9_-]/gi, '_'));
          fs.mkdirSync(dir, { recursive: true });
          args.filePaths = args.files.map((f, i) => {
            const name = String((f && f.name) || `file${i}`).replace(/[^a-z0-9._ -]/gi, '_');
            const fp = path.join(dir, name);
            fs.writeFileSync(fp, Buffer.from(String((f && f.contentBase64) || ''), 'base64'));
            return fp;
          });
          delete args.files;
        }
        if (Array.isArray(args.filePaths) && args.filePaths.length && args.chooserStatus !== true) { // both real modes read the files at serve time; a status probe needs no files
          const missing = (args.filePaths || []).filter((fp) => { try { return !fs.existsSync(fp); } catch { return true; } });
          if (missing.length) return resolve({ success: false, errorCode: 'file_missing', error: `file_missing: ${missing.join(', ')} not found on the laptop`, _hint: 'Paths must be ABSOLUTE paths on the machine running the browser. Push files there first with adom-bridge-cli send_files, or pass files:[{name, contentBase64}] and the bridge stages them for you.' });
        }
      } catch (e) { return resolve({ success: false, error: 'upload staging failed: ' + ((e && e.message) || e) }); }
    }
    let crossThreadWarn = null;
    const tgt = targetConn(args);
    if (tgt && tgt.mismatch) {
      return resolve({ success: false, errorCode: 'profile_mismatch', error: `profile_mismatch: session '${tgt.sessionId}' is pinned to profile '${tgt.pin}', but you called with profile '${tgt.wantKey}'.`, _hint: `Refused so you never act on the WRONG account (personal vs corporate). Drop the profile arg (the session already routes to '${tgt.pin}'), or open a NEW registered session in '${tgt.wantKey}' if you truly meant that account.` });
    }
    if (!tgt || tgt.blocked || !tgt.conn) {
      if (tgt && tgt.blocked) {
        return resolve({ success: false, error: `profile '${tgt.key}' is blocklisted`, _hint: 'The user blocked this profile from being driven. nbrowser_profile_unblock to allow it.' });
      }
      // A named profile that exists locally deserves a DIAGNOSIS, not a mystery: is the extension not
      // installed there, installed-but-disabled, or just asleep? (Real gripe: an ah thread burned a
      // session flailing at Edge because every verb said only "not connected".)
      let diag = '';
      try {
        const want0 = args && (args.profile || args.profileEmail);
        if (want0) {
          const wantEmail0 = (String(want0).includes(':') ? String(want0).split(':').slice(1).join(':') : String(want0)).toLowerCase();
          const wb0 = String(want0).includes(':') ? String(want0).split(':')[0] : (args && args.browser) || null;
          const kp0 = knownProfiles().find((pr) => pr.email.toLowerCase() === wantEmail0 && (!wb0 || pr.browser === wb0));
          if (kp0) { const st0 = adomExtStateForProfile(kp0.browser, kp0.profileDir); diag = ` DIAGNOSIS for ${kp0.key} (${kp0.displayName}, ${kp0.profileDir}): ${extFixHint(kp0.browser, kp0.profileDir, st0)} If it IS installed+enabled, nbrowser_wake_profile {profile:"${kp0.key}"} wakes it with NO window and NO foreground.`; }
        }
      } catch {}
      return resolve({
        success: false,
        error: 'native browser extension not connected',
        _hint: 'No drivable profile is connected. A profile\'s extension DIES if that profile has NO OPEN WINDOWS (its service worker sleeps) - nbrowser_wake_profile wakes it without any window. nbrowser_profiles lists every profile (live + asleep) with per-profile extension state.' + diag,
      });
    }
    const sock = tgt.conn.sock;
    lastVerb = verb; lastActivityAt = Date.now(); // for the self-reported status tooltip
    const forward = () => {
      const id = ++seq;
      const timer = setTimeout(() => {
        pending.delete(id);
        resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' });
      }, timeoutFor(verb));
      pending.set(id, { resolve, timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn });
      sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n');
    };
    // PIN the session to whatever profile this window opened in, so every later verb carrying this
    // sessionId auto-routes back to the SAME profile (no cross-profile mistakes) even without `profile:`.
    if (verb === 'nbrowser_open_window' && args && args.sessionId) { sessionProfile.set(args.sessionId, tgt.key); if (args.thread) sessionThread.set(args.sessionId, args.thread); if (args.purpose) sessionPurpose.set(args.sessionId, args.purpose); const _c = callerLabel(); if (_c) sessionCaller.set(args.sessionId, _c); persistSessions(); }
    // CONCURRENT-AGENT ARBITRATION (wiki #345 ask 2): ~20 AI threads share one desktop. If a DIFFERENT
    // thread drives a session someone else registered, warn and NAME the owner so they can coordinate.
    // A warning, not a refusal - the owning-thread refusal already exists for destructive cross-thread
    // ops (force_close). Self-asserted identity: never an authorization decision.
    if (args && args.sessionId && verb !== 'nbrowser_open_window') {
      const owner = sessionCaller.get(args.sessionId);
      const nowCaller = callerLabel();
      if (owner && nowCaller && owner !== nowCaller) {
        crossThreadWarn = `⚠️ CONCURRENT AGENTS: session '${args.sessionId}' was opened by AI thread "${owner}", but this call came from "${nowCaller}". You are driving another thread's window - coordinate with them or open your own window (nbrowser_open_window) instead of sharing one. Allowed, but the other thread may be mid-task.`;
        logActivity({ verb: '_cross_thread', sessionId: args.sessionId, ok: true, ops: `${nowCaller} -> ${owner}`, internal: true });
      }
    }
    // For open_window, snapshot the Chrome window list BEFORE forwarding so the post-open diff reliably
    // catches the new window (the old fire-and-forget snapshot raced the window appearing).
    if (verb === 'nbrowser_open_window') chromeHwnds().then((s) => { preOpenHwnds = s; forward(); }).catch(forward);
    else forward();
  });
}

async function onHostFrame(frame) {
  // Re-enter the ORIGINATING caller's context: this reply arrives on the extension socket, outside the
  // HTTP request's async scope, so ALS is empty here. Without this, every badge paint / window-identity
  // / caption we fire from the reply path would forward "self" instead of the thread that asked.
  const _p0 = (frame && frame.id != null) ? pending.get(frame.id) : null;
  if (_p0 && _p0.caller) return callerCtx.run(_p0.caller, () => onHostFrameInner(frame));
  return onHostFrameInner(frame);
}
async function onHostFrameInner(frame) {
  if (frame && frame.id != null && pending.has(frame.id)) {
    const p = pending.get(frame.id);
    clearTimeout(p.timer);
    pending.delete(frame.id);
    if (frame.ok) {
      let payload = frame.payload || {};
      // Screenshot file-save: huge base64 over the relay times out, so when the caller passes
      // toFile:true we write the PNG to a laptop temp file here and return its path instead of the
      // base64. Pull it with `adom-bridge-cli pull_file` (binary chunked) - fast + no relay timeout.
      const isShot = /screenshot/.test(p.verb);
      const isRec = p.verb === 'nbrowser_record_stop';
      if (payload.base64 && ((isShot && p.toFile) || isRec)) {
        try {
          const sub = isRec ? 'adom-nbrowser-recordings' : 'adom-nbrowser-shots';
          const dir = path.join(process.env.TEMP || os.tmpdir(), sub);
          fs.mkdirSync(dir, { recursive: true });
          const file = path.join(dir, `${isRec ? 'rec' : 'shot'}-${Date.now()}.${isRec ? 'webm' : 'png'}`);
          fs.writeFileSync(file, Buffer.from(payload.base64, 'base64'));
          const sizeKB = Math.round(Buffer.byteLength(payload.base64, 'base64') / 1024);
          payload = { ...payload, path: file, savedToFile: true, sizeKB, _hint: 'Pull this with `adom-bridge-cli pull_file {filePaths:["' + file.replace(/\\/g, '\\\\') + '"]}` (binary, chunked, no relay timeout).' };
          delete payload.base64;
        } catch (e) { payload._fileError = String((e && e.message) || e); }
      }
      // Finder debug ({debug}/{resolveOnly}): spill annotatedShot + shots[] base64 to laptop files so the
      // (possibly several) PNGs never bloat the relay. Always spill when present - debug is opt-in already.
      if (payload.finder && typeof payload.finder === 'object') {
        try {
          const dir = path.join(process.env.TEMP || os.tmpdir(), 'adom-nbrowser-shots');
          fs.mkdirSync(dir, { recursive: true });
          const spill = (obj, tag) => {
            if (obj && obj.base64) {
              const file = path.join(dir, `finder-${tag}-${Date.now()}-${Math.floor(Math.random() * 1e4)}.png`);
              fs.writeFileSync(file, Buffer.from(obj.base64, 'base64'));
              obj.sizeKB = Math.round(Buffer.byteLength(obj.base64, 'base64') / 1024);
              obj.path = file; delete obj.base64;
            }
          };
          const f = payload.finder;
          if (f.annotatedShot) spill(f.annotatedShot, 'annotated');
          if (Array.isArray(f.shots)) f.shots.forEach((s, i) => spill(s, (s.label || 'shot') + '-' + i));
          f._hint = 'Pull annotatedShot.path / shots[].path with `adom-bridge-cli pull_file {filePaths:[...]}` (binary, no relay timeout). In the annotated overlay each candidate is a numbered box - ★=chosen, ⛔=occluded - and the blue crosshair is the click point.';
        } catch (e) { payload._finderFileError = String((e && e.message) || e); }
      }
      // taskbar: capture the driven window's hwnd at control-start, auto-flash per mode, and TEACH the
      // feature on open (a fresh AI thread must discover it - it isn't obvious).
      if (p.verb === 'nbrowser_open_window') {
        const sid = payload.sessionId;
        // Robust hwnd capture: the diff can miss on the first pass (window not painted yet), so retry
        // briefly - so a window is never silently UNBADGED, and we can hand the AI its OS hwnd for
        // desktop_screenshot_window (the window+chrome+child-popup capture technique).
        let neu = null;
        for (let i = 0; i < 4; i++) {
          try {
            const list = await chromeWindowsDetailed();
            // Correlate by BOUNDS first (profile-independent + precise — the new-window DIFF can't tell
            // profiles apart and badged the WRONG profile's taskbar button). Diff only as a fallback.
            // The window the AI JUST created = the new hwnd (set-difference vs the pre-open snapshot). This
            // is reliable, DPI-safe, and PWA-safe: it's always the real browser window we opened, never a
            // Gmail/Chat PWA or some other profile window. Title/bounds are only a fallback.
            neu = list.map((w) => w.hwnd).find((h) => !preOpenHwnds.has(h)) || null;
            if (!neu) neu = matchHwnd(list, payload.bounds, payload.title);
          } catch {}
          if (neu) { lastHwnd = neu; if (sid) { sessionHwnd.set(sid, neu); persistSessions(); } applyBadge(sid, neu, p.profileKey); labelWindowOwner(neu, sid); if (p.args && p.args.userRequestedForeground === true && p.args.foregroundReason) announceForeground(p.args.foregroundReason, sid); break; } // applyBadge = badge (composited onto the profile avatar) + arm idle-expiry
          await new Promise((r) => setTimeout(r, 250));
        }
        payload = { ...payload, ...(neu ? { hwnd: neu } : {}), _taskbarHint: flashHint(),
          _screenshotHint: 'To SEE this window without foregrounding it: nbrowser_screenshot {sessionId} = the PAGE bitmap (fast, default). To see the WINDOW FRAME or a native popup/dialog (Chrome password dropdown, print dialog, alert/confirm), use ab desktop_screenshot_window {hwnd:' + (neu || '<from desktop_list_windows by title>') + '} - it captures the window + its OWNED CHILD windows (parent/child hwnds). NEVER a fullscreen/monitor screenshot to inspect a browser window.' };
      }
      // NOTE: list_windows/owned deliberately do NOT badge here anymore — badging an owned-but-IDLE window
      // is what left a "driving" badge on a profile no thread was actually driving. The badge is applied
      // ONLY by active driving (touchBadge, below) + open_window, and idle-expires on its own.
      if (p.verb === 'nbrowser_close_window' || p.verb === 'nbrowser_cleanup') { // clean up the taskbar when we stop driving
        const sid = payload.sessionId;
        const h = (sid && sessionHwnd.get(sid)) || lastHwnd;
        if (h && p.verb === 'nbrowser_close_window') { taskbar(h, { progress: { state: 'none' }, overlay: { badge: 'none' }, flash: 'stop' }); if (sid) { sessionHwnd.delete(sid); persistSessions(); } }
        if (sid) clearBadge(sid); // stop the idle-expiry timer for a closed session
        // clear the badge on any spawned windows this close/cleanup removed
        for (const wid of (payload.alsoClosedSpawned || []).concat((payload.closed || []).map((c) => c.windowId).filter(Boolean))) {
          const sh = spawnHwnd.get(wid); if (sh) { taskbar(sh, { overlay: { badge: 'none' }, flash: 'stop' }); spawnHwnd.delete(wid); }
        }
      }
      maybeAutoFlash(p.verb, payload);
      // Keep the badge alive while driving, on EVERY addressing path: sessionId-keyed verbs touch their
      // session; profile-keyed verbs (no sessionId - a thread driving by profile: alone) touch every
      // session pinned to that profile, lazily re-correlating via the extension if the hwnd is unknown
      // (e.g. after a bridge restart). Idle-expires when commands stop.
      // open_tab with active:true lands IN FRONT of the user inside an existing window - normal browser
      // behavior, allowed, but announced.
      if (p.verb === 'nbrowser_open_tab' && p.args && p.args.active === true && payload && payload.ok !== false) {
        announceVisibleOpen('opened a tab in your browser', p.args.reason, (payload && payload.sessionId) || (p.args && p.args.sessionId));
      }
      if (payload && ACTIVITY.has(p.verb) && !p._internal) {
        if (payload.sessionId) touchBadge(payload.sessionId, p.profileKey);
        else touchProfileBadge(p.profileKey);
      }
      if (p.crossThreadWarn && payload && typeof payload === 'object') payload._concurrentAgents = p.crossThreadWarn;
      const sid = payload && payload.sessionId;
      logActivity({ verb: p.verb, sessionId: sid || null, thread: sessionThread.get(sid) || null, profile: p.profileKey || null, ok: payload && payload.ok !== false, errorCode: (payload && payload.errorCode) || null, ...(p._internal ? { internal: true } : {}) });
      resolve_ok(p.resolve, payload);
    } else {
      logActivity({ verb: p.verb, sessionId: null, profile: p.profileKey || null, ok: false, errorCode: 'error', error: String(frame.error || '').slice(0, 120) });
      p.resolve({ success: false, error: frame.error || 'unknown error', _hint: frame._hint });
    }
    return;
  }
  // Unsolicited events are surfaced to the AI via nbrowser_events (the extension buffers them).
}
function resolve_ok(resolve, payload) { resolve({ success: true, output: JSON.stringify(payload) }); }

// Loopback bind host. ab (v1.9.63+) passes ADOM_BIND_HOST (always 127.0.0.1) to every bridge it
// spawns; bind to it so we never default to 0.0.0.0 and pop a Windows Firewall "allow access?"
// dialog. Both our listeners are loopback-only regardless.
const BIND = process.env.ADOM_BIND_HOST || '127.0.0.1';

// ---- HTTP server for ab ----------------------------------------------------
const httpSrv = http.createServer((req, res) => {
  const reply = (code, obj) => {
    const body = Buffer.from(JSON.stringify(obj));
    res.writeHead(code, { 'content-type': 'application/json', 'content-length': body.length });
    res.end(body);
  };

  if (req.method === 'GET' && req.url === '/status') {
    return reply(200, { ok: true, bridge: BRIDGE, version: VERSION, extConnected: anyConnected(), profiles: [...conns.keys()], active: activeProfile, ...statusReport() });
  }

  if (req.method === 'POST' && req.url === '/command') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 8 * 1024 * 1024) req.destroy(); });
    req.on('end', async () => {
      let parsed;
      try { parsed = JSON.parse(body || '{}'); } catch { return reply(400, { success: false, error: 'bad json' }); }
      const verb = parsed.command || parsed.verb;
      if (!verb) return reply(400, { success: false, error: 'missing command/verb' });
      // CALLER PROVENANCE (ab 1.9.180+, wiki #345/#351): ab stamps WHO asked on every relayed request.
      // SELF-ASSERTED - attribution/logging/UX/arbitration only, NEVER authorization.
      const caller = {
        thread: String(req.headers['x-adom-caller-thread'] || (parsed.args && parsed.args.caller && parsed.args.caller.aiThread) || '').slice(0, 120),
        container: String(req.headers['x-adom-caller-container'] || (parsed.args && parsed.args.caller && parsed.args.caller.containerName) || '').slice(0, 120),
        reason: String(req.headers['x-adom-caller-reason'] || '').slice(0, 200),
      };
      // Run the verb inside an ambient caller context so EVERY ab callback we make while carrying it
      // out (taskbar badge, caption, window enumeration) forwards that identity automatically. The
      // bridge is deeply async, so threading it by hand through every call site would rot immediately.
      reply(200, await withCaller(caller, () => dispatch(verb, parsed.args)));
    });
    return;
  }

  reply(404, { success: false, error: 'not found' });
});

// ---- circuit breaker -------------------------------------------------------
// Backstop: if hosts connect in a tight storm (the runaway-accumulation signature), STOP feeding the
// loop - log loudly and refuse new host connections for a cooldown. Even if the host/SW backoff ever
// regressed, the bridge alone caps the damage. Healthy operation is ~1 connect per host lifetime, so a
// burst of 20 connects in 10s is unambiguously pathological.
const CB_WINDOW_MS = 10000;
const CB_MAX_CONNECTS = 20;
const CB_COOLDOWN_MS = 60000;
let cbConnects = [];
let cbTrippedUntil = 0;

// ---- raw-TCP server for the native-messaging host --------------------------
const hostSrv = net.createServer((sock) => {
  const now = Date.now();
  if (now < cbTrippedUntil) { try { sock.destroy(); } catch {} return; } // breaker open - refuse
  cbConnects = cbConnects.filter((t) => now - t < CB_WINDOW_MS);
  cbConnects.push(now);
  if (cbConnects.length > CB_MAX_CONNECTS) {
    cbTrippedUntil = now + CB_COOLDOWN_MS;
    cbConnects = [];
    process.stderr.write(`[native-browser] CIRCUIT BREAKER TRIPPED: >${CB_MAX_CONNECTS} host connects in ${CB_WINDOW_MS}ms - refusing new host connections for ${CB_COOLDOWN_MS}ms (runaway native-host loop suspected)\n`);
    try { sock.destroy(); } catch {}
    return;
  }
  // Register provisionally so this host is drivable immediately; the `hello` re-keys it to the real
  // profile. Several profiles can be connected at once.
  registerProvisional(sock);
  let buf = '';
  sock.on('data', (chunk) => {
    buf += chunk.toString('utf8');
    let nl;
    while ((nl = buf.indexOf('\n')) >= 0) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + 1);
      if (!line.trim()) continue;
      let frame;
      try { frame = JSON.parse(line); } catch { continue; }
      if (frame && frame.type === 'hello') { registerConn(sock, frame.profile, frame.browser, frame.version); continue; }
      onHostFrame(frame);
    }
  });
  const drop = () => { for (const [k, c] of conns) if (c.sock === sock) { conns.delete(k); if (activeProfile === k) activeProfile = (liveConns()[0] || [])[0] || null; } };
  sock.on('close', drop);
  sock.on('error', drop);
});

// ---- discovery file + lifecycle -------------------------------------------
function writeHostFile(hostPort) {
  fs.mkdirSync(path.dirname(HOSTFILE), { recursive: true });
  fs.writeFileSync(HOSTFILE, JSON.stringify({ host: '127.0.0.1', hostPort, pid: process.pid, version: VERSION }));
  // Publish the Node ab gave us so the Chrome-launched host can reuse it (see NODEPATHFILE note above).
  // Normalize to native Windows backslashes - node-path.txt is consumed by a .bat, and a quoted
  // forward-slash exe path is not reliably executable in cmd.exe.
  try { fs.writeFileSync(NODEPATHFILE, process.execPath.split('/').join('\\')); } catch {}
}
// PID-aware cleanup: ONLY delete host.json if it still points at *this* process. Otherwise a churning
// instance (ab respawn, a manual run, a crash-restart) deletes the discovery file that another LIVE
// bridge owns - exactly what stranded the extension (host.json vanished while a bridge was up). Never
// blow away a sibling's file.
function cleanup() {
  try {
    const cur = JSON.parse(fs.readFileSync(HOSTFILE, 'utf8'));
    if (cur && cur.pid === process.pid) fs.unlinkSync(HOSTFILE);
  } catch {}
}
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(0); });
process.on('SIGTERM', () => { cleanup(); process.exit(0); });

function argPort() {
  const i = process.argv.indexOf('--port');
  if (i >= 0) { const p = parseInt(process.argv[i + 1], 10); if (!Number.isNaN(p)) return p; }
  return 0; // ephemeral
}

// Resilient bind. If ab hands us a --port that's already held (HNS reservation, a zombie bridge, a
// double-spawn), DON'T die silently - fall back to an ephemeral port and keep serving. The hostSrv +
// discovery file get written regardless, so the extension always has a live port to dial.
let httpFellBack = false;
function startHttp(p) {
  httpSrv.once('error', (e) => {
    if (e && e.code === 'EADDRINUSE' && !httpFellBack) {
      httpFellBack = true;
      process.stderr.write(`[native-browser] http :${p} in use (${e.code}) - falling back to ephemeral\n`);
      try { httpSrv.close(); } catch {}
      setTimeout(() => startHttp(0), 50);
    } else {
      process.stderr.write(`[native-browser] http listen error: ${e && e.code}\n`);
    }
  });
  httpSrv.listen(p, BIND, () => {
    const port = httpSrv.address().port;
    if (hostSrv.listening) { // already came up on a prior attempt; just log the new http port
      process.stdout.write(`[native-browser] bridge up: ab http :${port}  host tcp :${hostSrv.address().port}\n`);
      return;
    }
    hostSrv.once('error', (e) => process.stderr.write(`[native-browser] host listen error: ${e && e.code}\n`));
    hostSrv.listen(0, BIND, () => {
      const hostPort = hostSrv.address().port;
      writeHostFile(hostPort);
      process.stdout.write(`[native-browser] bridge up: ab http :${port}  host tcp :${hostPort}\n`);
    });
  });
}
startHttp(argPort());

// Enumerate all Chrome + Edge profiles from each browser's Local State (info_cache: dir -> {user_name}),
// so we can map a live window (its OS title carries the signed-in email on Google pages) back to a KEY.
function knownProfiles() {
  const out = [];
  const local = process.env.LOCALAPPDATA;
  if (!local) return out;
  const roots = [['chrome', path.join(local, 'Google', 'Chrome', 'User Data')], ['edge', path.join(local, 'Microsoft', 'Edge', 'User Data')]];
  for (const [browser, root] of roots) {
    try {
      const j = JSON.parse(fs.readFileSync(path.join(root, 'Local State'), 'utf8'));
      const cache = (j.profile && j.profile.info_cache) || {};
      for (const [dir, info] of Object.entries(cache)) {
        const email = String((info && info.user_name) || '');
        if (!email.includes('@')) continue;
        const names = browser === 'edge' ? ['Edge Profile Picture.png', 'Google Profile Picture.png'] : ['Google Profile Picture.png', 'Edge Profile Picture.png'];
        let hasAvatar = false;
        for (const n of names) if (fs.existsSync(path.join(root, dir, n))) { hasAvatar = true; break; }
        out.push({ email, key: `${browser}:${email}`, browser, displayName: String((info && info.name) || ''), profileDir: dir, hasAvatar });
      }
    } catch {}
  }
  return out;
}
// DIAGNOSE the Adom extension's state inside a profile that is NOT connected, by reading what the
// browser itself recorded on disk (Preferences / Secure Preferences -> extensions.settings). This is
// what turns "native browser extension not connected" from a MYSTERY into an actionable answer: not
// installed at all vs installed-but-DISABLED vs enabled-but-not-dialing are three different fixes, and
// the AI deserves to know which one it is (real gripe: an ah thread flailed at Edge for a whole session).
function adomExtStateForProfile(browser, profileDir) {
  try {
    const local = process.env.LOCALAPPDATA;
    if (!local || !profileDir) return { checked: false };
    const root = browser === 'edge' ? path.join(local, 'Microsoft', 'Edge', 'User Data') : path.join(local, 'Google', 'Chrome', 'User Data');
    for (const f of ['Secure Preferences', 'Preferences']) {
      let j; try { j = JSON.parse(fs.readFileSync(path.join(root, profileDir, f), 'utf8')); } catch { continue; }
      const settings = (j.extensions && j.extensions.settings) || {};
      for (const [id, ent] of Object.entries(settings)) {
        const nm = String((ent && ent.manifest && ent.manifest.name) || '');
        const pth = String((ent && ent.path) || '');
        // A LOAD-UNPACKED extension often has an EMPTY manifest.name in Secure Preferences, so matching
        // on the name alone reported installed:false for a profile that HAD it (real bug, 2026-07-21).
        // Match on our pinned extension ID or the source path too.
        const hit = /adom/i.test(nm) || id.startsWith(ADOM_EXT_ID_PREFIX) || /adom-native-browser|adom-browser-extension|[\\/]adom[\\/]/i.test(pth);
        if (!hit) continue;
        return { checked: true, installed: true, id, name: nm || 'Adom (unpacked)', version: (ent.manifest && ent.manifest.version) || null, enabled: ent.state !== 0, unpackedPath: ent.path || null, unpacked: !nm };
      }
    }
    return { checked: true, installed: false };
  } catch { return { checked: false }; }
}
// The unpacked source dir of the extension, learned from ANY profile that has it - so a fix hint can
// say exactly what path to Load-unpacked into the broken profile.
function adomExtKnownPath() {
  for (const kp of knownProfiles()) {
    const st = adomExtStateForProfile(kp.browser, kp.profileDir);
    if (st.installed && st.unpackedPath) return st.unpackedPath;
  }
  return null;
}
// One actionable sentence for a profile whose extension is not connected. Used by wake_profile,
// not-connected refusals, and the asleep rows in nbrowser_profiles.
function extFixHint(browser, profileDir, st) {
  const scheme = browser === 'edge' ? 'edge://extensions' : 'chrome://extensions';
  if (!st || !st.checked) return 'Could not read the profile\'s extension inventory - check the profile exists (nbrowser_profiles).';
  if (!st.installed) {
    const src = adomExtKnownPath();
    return `The Adom extension is NOT INSTALLED in this ${browser} profile (${profileDir}) - extensions are per-profile. Fix: install it into THAT profile via the installing-the-extension skill (open ${scheme} in a window of that profile -> Developer mode -> Load unpacked${src ? ` -> "${src}" (the same source dir the working profile uses)` : ''}).`;
  }
  if (!st.enabled) return `The Adom extension IS installed in this ${browser} profile (${st.name} v${st.version}) but it is DISABLED. Fix: enable its toggle on ${scheme} in that profile (driving-the-desktop can do this in the background via UIA), or ask the user to flip it on.`;
  return `The Adom extension is installed AND enabled in this ${browser} profile (v${st.version}) but its service worker has not dialed the bridge. Fix: retry nbrowser_wake_profile (SWs can be slow), and if it still never connects, verify the native-messaging host manifest allows THIS browser's extension id (nbrowser_add_extension_origin {id:"${st.id}"}).`;
}
// On startup, undo any STALE "Adom is driving" badge a PRIOR bridge left behind (bridges are KILLED, not
// gracefully stopped, so in-memory idle timers can't clear badges across a redeploy). We must NOT blank the
// overlay slot — that evicts Chrome's OWN profile avatar (the destructive regression we had). Instead, for
// each live browser window we can map to a known profile (title carries the signed-in email), we repaint the
// NATIVE look: the plain circle avatar (same image Chrome uses, minus the Adom mark). Undriven profiles look
// unchanged; any stale badge is wiped. Unmappable windows are left untouched; a currently-driven window gets
// its badge back on the thread's next verb.
async function resetStaleBadgesOnStartup(attempt = 0) {
  let list; try { list = await chromeWindowsDetailed(); } catch { list = null; }
  if (!list || !list.length) { if (attempt < 4) setTimeout(() => resetStaleBadgesOnStartup(attempt + 1), 3000); return; }
  // Restore persisted session->hwnd mappings FIRST (validated against the live window list), so threads
  // that were mid-drive across our restart keep their badge on their very next verb - no fresh window needed.
  restoreSessions(new Set(list.map((w) => w.hwnd)));
  const known = knownProfiles();
  const seen = new Set();
  for (const w of list) {
    const t = String(w.title || '');
    const hit = known.find((p) => p.email && t.includes(p.email));
    if (hit && !seen.has(w.hwnd)) { seen.add(w.hwnd); nativeOverlay(w.hwnd, hit.key); }
  }
}
// ── REAP USER-CLOSED WINDOWS (John, 2026-07-22): "if a user closes the ai created nb window, you should
// detect that and remove your overlay icon." A closed window's taskbar button disappears with it, but the
// BRIDGE would still think it owns that hwnd - it would keep arming idle timers, re-painting a dead
// window, and reporting it in nbrowser_owned. Poll the live browser-window set and forget what is gone.
async function reapClosedWindows() {
  if (!sessionHwnd.size && !spawnHwnd.size) return;
  let live;
  try { live = new Set((await chromeWindowsDetailed()).map((w) => w.hwnd)); } catch { return; }
  if (!live.size) return; // ab blind - never reap on no data
  let reaped = 0;
  for (const [sid, h] of [...sessionHwnd]) {
    if (live.has(h)) continue;
    sessionHwnd.delete(sid); clearBadge(sid); osOpened.delete(sid); reaped++;
    logActivity({ verb: '_window_reaped', sessionId: sid, thread: sessionThread.get(sid) || null, profile: sessionProfile.get(sid) || null, hwnd: h, ops: 'closed_by_user', ok: true, internal: true });
  }
  for (const [wid, h] of [...spawnHwnd]) { if (!live.has(h)) { spawnHwnd.delete(wid); reaped++; } }
  if (reaped) persistSessions();
}
setInterval(() => { reapClosedWindows().catch(() => {}); }, 30000).unref();
setTimeout(() => resetStaleBadgesOnStartup(), 4000);
