// adom-native-browser :: native-API verbs (chrome.tabs / chrome.windows / downloads / fetch)
// --------------------------------------------------------------------------------------------
// No debugger attach → no banner. A "session" maps to a real Chrome window; tabId in our API is the
// real chrome tab id (stringified). The session→window map is PERSISTED to chrome.storage.local so it
// survives service-worker restarts (dev_reload) and browser restarts — pruned against live windows.

const sessionWindows = new Map(); // sessionId label -> chrome windowId (the OPENED registry; ownership root)
const sessionMeta = new Map();    // sessionId -> { thread, purpose, profile, createdAt } (from registration)
let seq = 0;
const newSessionId = () => 's' + (++seq);

// ── SPAWN registries — windows/tabs a DRIVEN page opened indirectly (compose, target=_blank, window.open,
// OAuth popup, PDF viewer). These belong to no session by construction, so without adoption they leak: no
// sessionId ever reaches them and no close verb can find them. We ADOPT them (strictly, only when the
// opener is an agent surface — see the onCreated listeners) so they can be listed, cleaned up, and badged.
const spawnedWindows = new Map(); // windowId -> { spawnedBy, openerTabId, url, title, createdAt }
const spawnedTabs = new Map();    // tabId    -> { spawnedBy, openerTabId, windowId, url, createdAt }
const _dirtySessions = new Set(); // sessions with unacknowledged spawns → next mutating verb refused

const SKEY = 'nb_sessions';
const MKEY = 'nb_session_meta';
const PKEY = 'nb_spawns';
let loaded = false;

async function persist() {
  try {
    await chrome.storage.local.set({
      [SKEY]: { seq, sessions: [...sessionWindows] },
      [MKEY]: [...sessionMeta],
      [PKEY]: { windows: [...spawnedWindows], tabs: [...spawnedTabs] },
    });
  } catch {}
}
function windowExists(winId) {
  return new Promise((r) => chrome.windows.get(winId, () => r(!chrome.runtime.lastError)));
}
async function ensureLoaded() {
  if (loaded) return;
  loaded = true;
  await reconcile();
}
// Re-read the persisted map, MERGE it into memory (never lose an in-memory session that hasn't been
// persisted yet), and reconcile against LIVE windows. A session whose window no longer exists is MOVED
// to _closedSessions - NOT silently dropped - so a later verb reports "closed" and NEVER falls back to
// the user's active tab. Safe to call anytime; nbrowser_resync_sessions calls it. This is what makes
// tracking survive SW sleep + external window closes (SetForegroundWindow / WM_CLOSE from another tool).
async function tabExists(tabId) {
  return new Promise((r) => chrome.tabs.get(Number(tabId), () => r(!chrome.runtime.lastError)));
}
async function reconcile() {
  try {
    const got = await chrome.storage.local.get([SKEY, MKEY, PKEY]);
    const obj = got[SKEY];
    if (obj) {
      seq = Math.max(seq, obj.seq || 0);
      for (const [sid, winId] of obj.sessions || []) if (!sessionWindows.has(sid)) sessionWindows.set(sid, winId);
    }
    for (const [sid, meta] of got[MKEY] || []) if (!sessionMeta.has(sid)) sessionMeta.set(sid, meta);
    const sp = got[PKEY];
    if (sp) {
      for (const [wid, v] of sp.windows || []) if (!spawnedWindows.has(wid)) spawnedWindows.set(wid, v);
      for (const [tid, v] of sp.tabs || []) if (!spawnedTabs.has(tid)) spawnedTabs.set(tid, v);
    }
  } catch {}
  for (const [sid, winId] of [...sessionWindows]) {
    if (!(await windowExists(winId))) {
      sessionWindows.delete(sid);
      if (!_closedSessions.has(sid)) _closedSessions.set(sid, { windowId: winId, byUser: null, ts: Date.now(), external: true });
    }
  }
  // Evict spawns whose window/tab is gone (same reconcile-against-live discipline as sessions).
  for (const [wid] of [...spawnedWindows]) if (!(await windowExists(wid))) spawnedWindows.delete(wid);
  for (const [tid] of [...spawnedTabs]) if (!(await tabExists(tid))) spawnedTabs.delete(tid);
  await persist();
}

function tabView(t) {
  return { tabId: String(t.id), url: t.url, title: t.title, active: !!t.active, windowId: t.windowId };
}

// ---- user-action detection: window/tab closes + hints ----------------------
// So the AI is TOLD when its windows are closed (esp. by the user) instead of only finding out when a
// later verb mysteriously fails. We distinguish user-closed from agent-closed.
const _events = [];                 // recent lifecycle events for nbrowser_events
const _agentClosing = new Set();    // windowIds the AGENT is closing (so we don't mislabel as user-closed)
const _closedSessions = new Map();  // sessionId -> {windowId, byUser, ts}
function _emit(e) { e.ts = Date.now(); _events.push(e); if (_events.length > 300) _events.shift(); }
// focus_violations are PERSISTED (not just kept in the in-memory _events, which an MV3 service-worker
// suspension wipes) so the telemetry survives until polled - the user can always see when an AI tried to
// steal focus. Capped ring buffer in chrome.storage.local.
const VKEY = 'nb_violations';
async function persistViolation(v) {
  try {
    const got = await chrome.storage.local.get(VKEY);
    const arr = got[VKEY] || [];
    arr.push(v); while (arr.length > 20) arr.shift();
    await chrome.storage.local.set({ [VKEY]: arr });
  } catch {}
}
export async function drainEvents() {
  const e = _events.splice(0);
  let violations = [];
  try {
    const got = await chrome.storage.local.get(VKEY);
    violations = (got[VKEY] || []).map((v) => ({ event: 'focus_violation', ...v }));
    if (violations.length) await chrome.storage.local.set({ [VKEY]: [] });
  } catch {}
  const all = e.concat(violations);
  return { ok: true, events: all, count: all.length };
}
export function recentClose(sessionId) { return _closedSessions.get(sessionId) || null; }

chrome.windows.onRemoved.addListener(async (windowId) => {
  await ensureLoaded();
  const byUser = !_agentClosing.has(windowId);
  _agentClosing.delete(windowId);
  for (const [sid, wid] of [...sessionWindows]) {
    if (wid === windowId) {
      sessionWindows.delete(sid);
      _closedSessions.set(sid, { windowId, byUser, ts: Date.now() });
      _emit({ event: 'window_closed', sessionId: sid, windowId, byUser });
    }
  }
  if (spawnedWindows.has(windowId)) { const s = spawnedWindows.get(windowId); spawnedWindows.delete(windowId); _emit({ event: 'spawned_closed', windowId, spawnedBy: s.spawnedBy, byUser }); }
  await persist();
});
chrome.tabs.onRemoved.addListener((tabId, info) => {
  if (spawnedTabs.has(tabId)) spawnedTabs.delete(tabId);
  _emit({ event: 'tab_closed', tabId: String(tabId), windowId: info.windowId, windowClosing: !!info.isWindowClosing });
});

// ── SPAWN ADOPTION — the fix for the leaked-compose-window incident. ────────────────────────────────
// A page a session drives can open a NEW window/tab we did not ask for (Gmail compose, target=_blank,
// window.open, OAuth popup). We ADOPT it into the originating session so it can be listed/cleaned/badged.
// THE ANTI-OVER-ADOPTION RULE (safety linchpin): adopt ONLY when the new tab's openerTabId resolves to a
// tab living in a window we own or already adopted (ownedOrSpawnedWindowSet). No opener, or an opener
// outside an agent surface → it is the USER's own window/tab and is NEVER adopted. Strictly transitive
// from an owned root; never a focus/z-order/timing heuristic. So cleanup/force_close can never reach the
// user's windows.
async function adoptFromOpener(newTab) {
  if (!newTab || newTab.openerTabId == null) return; // no opener → user action → do NOT adopt
  if (sessionWindows.has(newTab.windowId) || [...sessionWindows.values()].includes(newTab.windowId)) {
    // window is an agent-OPENED window (our own); a plain new tab in it is fine, not a "spawn" to police
  }
  let opener;
  try { opener = await chrome.tabs.get(newTab.openerTabId); } catch { return; }
  if (!ownedOrSpawnedWindowSet().has(opener.windowId)) return; // opener not an agent surface → user's own
  const spawnedBy = sessionForWindow(opener.windowId) || (spawnedWindows.get(opener.windowId) || {}).spawnedBy;
  if (!spawnedBy) return;
  const isNewWindow = !ownedWindowSet().has(newTab.windowId) && !spawnedWindows.has(newTab.windowId) && newTab.windowId !== opener.windowId;
  const url = newTab.pendingUrl || newTab.url || '';
  if (isNewWindow) {
    if (spawnedWindows.has(newTab.windowId)) return; // already adopted
    spawnedWindows.set(newTab.windowId, { spawnedBy, openerTabId: newTab.openerTabId, url, title: newTab.title || '', createdAt: Date.now() });
  } else {
    if (spawnedTabs.has(newTab.id) || ownedWindowSet().has(newTab.windowId)) return; // tab in our own opened window: not policed
    spawnedTabs.set(newTab.id, { spawnedBy, openerTabId: newTab.openerTabId, windowId: newTab.windowId, url, createdAt: Date.now() });
  }
  _dirtySessions.add(spawnedBy); // its next mutating verb is refused until the thread acknowledges
  await persist();
  _emit({ event: 'spawned_window', windowId: newTab.windowId, tabId: String(newTab.id), spawnedBy, openerTabId: newTab.openerTabId, url, kind: isNewWindow ? 'window' : 'tab' });
}
chrome.tabs.onCreated.addListener(async (tab) => { await ensureLoaded(); await adoptFromOpener(tab); });
chrome.windows.onCreated.addListener(async (win) => {
  await ensureLoaded();
  if (spawnedWindows.has(win.id) || [...sessionWindows.values()].includes(win.id)) return; // handled elsewhere
  // window.onCreated has no opener → look at the new window's first tab and read ITS openerTabId (short
  // retry for the race where the tab isn't attached yet). This is a de-dup backstop; tabs.onCreated is primary.
  for (let i = 0; i < 3; i++) {
    let tabs = [];
    try { tabs = await chrome.tabs.query({ windowId: win.id }); } catch {}
    if (tabs && tabs[0]) { await adoptFromOpener(tabs[0]); return; }
    await new Promise((r) => setTimeout(r, 120));
  }
});

// ⛔ FIRST-CLASS PWA GUARD — never hijack an installed app window.
// Installed PWAs / "Add to taskbar" apps (Google Chat, Google Messages, Gmail-as-app, etc.) run in
// their OWN window, which Chrome reports as type:'app' (standalone apps are 'app'; popups 'popup';
// ordinary browsing 'normal'). Driving or NAVIGATING a tab inside an 'app' window would seize the
// user's standalone application — exactly the regression we hit (the Google Chat PWA got navigated to
// Gusto). HARD RULE: refuse to target a non-'normal' window unless the caller EXPLICITLY opts in with
// allowPwa:true (i.e. the user specifically asked to drive that exact app window).
async function assertDriveableWindow(tabId, args) {
  const allow = !!(args && (args.allowPwa === true || args.allowAppWindow === true));
  if (allow) return;
  let tab, win;
  try {
    tab = await chrome.tabs.get(Number(tabId));
    win = await chrome.windows.get(tab.windowId);
  } catch { return; } // lookup failed for an unrelated reason — let the real verb surface it
  if (win && win.type && win.type !== 'normal') {
    const kind = win.type === 'app' ? 'PWA / installed-app' : `${win.type}`;
    throw new Error(
      `⛔ refusing to drive a ${kind} window (tab ${tabId}: "${(tab.title || '').slice(0, 60)}") — ` +
      `this would hijack the user's standalone app (e.g. a taskbar PWA like Google Chat/Messages). ` +
      `Open a dedicated browser window with nbrowser_open_window and target THAT. ` +
      `Pass allowPwa:true ONLY if the user explicitly asked to drive this specific app window.`);
  }
}

// ⛔ OWNERSHIP GUARD — the agent may only ACT on windows IT opened (nbrowser_open_window).
// sessionWindows doubles as the ownership registry: entries are created ONLY by openWindow,
// persisted, and pruned when the window closes. Every mutating verb refuses a target outside it,
// and refuses a BARE call (no sessionId/tabId) outright — the active-tab fallback is what let an
// agent navigate a user's working tab and wipe their other AI threads. There is deliberately NO
// agent-passable override; skills/hooks reinforce this, the code enforces it.
export class GuardError extends Error {
  constructor(code, msg, hint) { super(msg); this.code = code; this.hintText = hint; }
}

const OPEN_WINDOW_HINT =
  'Correct flow: nbrowser_profiles (map profiles → Google identities) → nbrowser_open_window ' +
  '{profile:"chrome:<email>", url:…} (opens in the BACKGROUND and auto-pins its sessionId) → drive ' +
  'ONLY via that sessionId. There is NO agent-side override for driving the user\'s own tabs; if the ' +
  'user wants you inside one of their tabs, they must say so and you still work in a window you open.';

function ownedWindowSet() { return new Set(sessionWindows.values()); }
function ownedOrSpawnedWindowSet() { return new Set([...sessionWindows.values(), ...spawnedWindows.keys()]); }
// Who owns a live windowId? 'agent-opened' (a session's primary window), 'agent-spawned' (adopted), or
// 'user' (never adopted). Used by list labels + force_close attribution.
function ownerOfWindow(winId) {
  const sid = sessionForWindow(winId);
  if (sid) return { owner: 'agent-opened', sessionId: sid };
  if (spawnedWindows.has(winId)) return { owner: 'agent-spawned', sessionId: spawnedWindows.get(winId).spawnedBy };
  return { owner: 'user', sessionId: null };
}

function requireTarget(args, verb) {
  if (args && (args.tabId != null || args.sessionId != null)) return;
  throw new GuardError('no_target',
    `⛔ no_target: ${verb} with no sessionId/tabId would act on the USER'S ACTIVE TAB — refused.`,
    `The active tab is whatever the user is working in RIGHT NOW; a bare ${verb} once wiped a user's other AI threads. ${OPEN_WINDOW_HINT}`);
}

// REGISTRATION (guard #1). Every window/tab you open must be registered with WHO is driving (thread) and
// WHY (purpose) so it can be tracked, cleaned up, and shown in the Session Monitor. Hard requirement.
function requireRegistration(args, verb) {
  const missing = [];
  if (!args || args.sessionId == null || args.sessionId === '') missing.push('sessionId');
  if (!args || !args.thread) missing.push('thread');
  if (!args || !args.purpose) missing.push('purpose');
  if (!missing.length) return;
  throw new GuardError('session_unregistered',
    `⛔ session_unregistered: ${verb} requires ${missing.join(' + ')}. Every window you open is registered so it can be tracked, cleaned up, and shown in the Session Monitor (who is driving each window, and why).`,
    `Call ${verb} {sessionId:"<short task id>", thread:"<which AI thread you are>", purpose:"<human-readable, e.g. finishing John's IRS signup>", url:…}. ${OPEN_WINDOW_HINT}`);
}

// UNACKNOWLEDGED SPAWNS (guard #2). If a driven page spawned a window/tab (compose/popup), refuse the
// session's next DRIVING verb until the thread deals with it (list via nbrowser_owned, close via cleanup).
function assertSpawnsAcknowledged(sid, verb) {
  if (!sid || !_dirtySessions.has(sid)) return;
  const items = [...spawnedWindows].filter(([, v]) => v.spawnedBy === sid).map(([wid, v]) => ({ windowId: wid, url: v.url }))
    .concat([...spawnedTabs].filter(([, v]) => v.spawnedBy === sid).map(([tid, v]) => ({ tabId: tid, url: v.url })));
  throw new GuardError('unacknowledged_spawns',
    `⛔ unacknowledged_spawns: a page session '${sid}' drove spawned ${items.length} new window(s)/tab(s) you have not dealt with — ${verb} is blocked until you do. You cannot forget your own litter.`,
    `See them: nbrowser_owned {sessionId:"${sid}"}. Then KEEP them (they are now tracked) or close the litter: nbrowser_cleanup {sessionId:"${sid}"}. (e.g. clicking an email in Gmail Contacts spawns a compose window.)`);
}
// Acknowledging = the thread looked (owned) or cleaned (cleanup). Clears the block.
function acknowledgeSpawns(sid) { if (sid) _dirtySessions.delete(sid); }

// PROFILE MISMATCH (guard #6). If a verb names a profile that contradicts the profile this session was
// pinned to at open time, refuse — guards against acting on the wrong Google account (personal vs corp).
// (The bridge also routes by profile; this is the extension-side backstop using sessionMeta.profile.)
function assertProfileMatch(args, verb) {
  if (!args || args.sessionId == null || args.profile == null) return;
  const meta = sessionMeta.get(args.sessionId);
  if (!meta || !meta.profile) return; // session not pinned to a profile → nothing to contradict
  if (String(args.profile) !== String(meta.profile)) {
    throw new GuardError('profile_mismatch',
      `⛔ profile_mismatch: session '${args.sessionId}' is pinned to profile '${meta.profile}', but ${verb} was called with profile '${args.profile}'. Refused so you never act on the wrong account.`,
      `Drop the profile arg (the session already routes to '${meta.profile}'), or open a NEW registered session in '${args.profile}' if you truly meant that account.`);
  }
}

// NO KEEPALIVE (guard #7, warning). A session driving a known idle-timeout host for >10 min with no
// keepalive risks a mid-task logout (an IRS/ID.me idle-out cost a full re-login here). Non-fatal.
const IDLE_TIMEOUT_HOSTS = /(^|\.)(gusto\.com|intuit\.com|quickbooks\.|irs\.gov|id\.me|adp\.com|login\.gov)$/i;
function keepaliveWarning(sid, url) {
  try {
    if (!sid || _keepalives.has(sid)) return null;
    const meta = sessionMeta.get(sid); if (!meta) return null;
    if (Date.now() - (meta.createdAt || Date.now()) < 10 * 60 * 1000) return null;
    const host = new URL(url || '').hostname || '';
    if (!IDLE_TIMEOUT_HOSTS.test(host)) return null;
    return `⚠️ no_keepalive: session '${sid}' has driven ${host} for >10 min with no keepalive — this host idle-times-out (a logout mid-task cost a full re-login once). Set nbrowser_keepalive {sessionId:"${sid}"} now.`;
  } catch { return null; }
}

// Ownership summary for the dirty_session nag in nbrowser_status.
export function ownershipSummary() {
  const openWins = sessionWindows.size;
  const spawns = spawnedWindows.size + spawnedTabs.size;
  return {
    ownedWindows: openWins, spawnedWindows: spawns,
    sessions: [...sessionWindows.keys()],
    _nag: (openWins || spawns)
      ? `You still own ${openWins} window(s)${spawns ? ` + ${spawns} spawned` : ''}. Before ending the task, nbrowser_owned {sessionId} to review and nbrowser_close_window {sessionId, includeSpawned:true} (or nbrowser_cleanup) to close them — don't leave litter for the user.`
      : undefined,
  };
}

async function assertOwned(tabId, verb) {
  await ensureLoaded();
  let tab;
  try { tab = await chrome.tabs.get(Number(tabId)); } catch { return; } // let the verb surface the real error
  if (!ownedWindowSet().has(tab.windowId)) {
    throw new GuardError('not_owned',
      `⛔ not_owned: tab ${tabId} ("${(tab.title || '').slice(0, 60)}") is in a window the USER opened — ${verb} refused.`,
      `You may only drive windows you created this connection. ${OPEN_WINDOW_HINT}`);
  }
}

// ── POLITENESS / ANTI-FOREGROUND ENFORCEMENT ──────────────────────────────────
// The bridge GUARANTEES it can never shove a window in front of the user, no matter how the driving AI
// behaves. Windows open in the BACKGROUND; a raise happens ONLY when the user EXPLICITLY asked to watch
// live (userRequestedForeground:true). Any other path that leaves a driven window topmost is auto-reverted
// to the background and logged as a focus_violation. This removes the rudeness AND the temptation to fall
// back to OS-level foreground launches (the old bug: a blocked background path pushed the AI to
// Start-Process/bring_to_front, which is exactly the disruptive behavior).
const POLITE_HINT =
  'Open in the BACKGROUND. NEVER raise or maximize a window over the user\'s other work - it is rude and ' +
  'disruptive. NEVER fall back to OS-level window launches (Start-Process/launch) or bring_to_front/maximize ' +
  'to open or surface windows. If a background open is blocked, return a shareable link instead of ' +
  'foregrounding. Foregrounding is the user\'s choice alone.';

// A raise/foreground is honored ONLY when the user explicitly asked AND the AI justified it. Foregrounding
// STEALS the user's keyboard focus: users automating the browser almost always have 5 other things in the
// foreground, and a raised window captures their keystrokes (real incident: it typed a user's chat message
// into a username field and dismissed Chrome's password dropdown). So a bare userRequestedForeground:true is
// NOT enough — it must ALSO carry a foregroundReason. Missing the reason ⇒ stays in the background.
function userWantsForeground(args) { return !!(args && args.userRequestedForeground === true && args.foregroundReason && String(args.foregroundReason).trim()); }
// The AI asked to foreground but gave no reason → we keep it background and teach it how to do it properly.
function foregroundUnjustified(args) { return !!(args && args.userRequestedForeground === true && !(args.foregroundReason && String(args.foregroundReason).trim())); }
const FOREGROUND_JUSTIFY_HINT =
  '⚠️ Kept this window in the BACKGROUND. Foregrounding STEALS the user\'s keyboard focus — if they are typing, ' +
  'your window captures their keystrokes (it has typed a user\'s message into a username field and dismissed the ' +
  'password dropdown). Background is where automation SUCCEEDS (the user keeps working; screenshots, eval, clicks, ' +
  'and typing all work on a background window). To genuinely foreground you must: (1) pass foregroundReason:"<why ' +
  'the user must see this, or which browser/auth feature requires a foreground window>", and (2) 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>"}). ' +
  'Only then set userRequestedForeground:true + foregroundReason.';
// Note on the response when a raise IS granted — remind the AI it should have notified the user.
const FOREGROUND_GRANTED_HINT = 'Raised to the FOREGROUND (you justified it). This interrupts the user — make sure you sent a Bridge notify first, do the ONE thing you needed the foreground for, then return to the background so you stop capturing their focus.';
// Attach the teaching warning (unjustified → stayed background) or log the justified raise. Call on every
// foreground-capable verb's result.
function foregroundNote(args, res, sid) {
  if (foregroundUnjustified(args)) { res._warning = (res._warning ? res._warning + ' ' : '') + FOREGROUND_JUSTIFY_HINT; res.keptBackground = true; }
  else if (userWantsForeground(args)) { _emit({ event: 'foreground_raised', sessionId: sid || null, reason: args.foregroundReason }); res._foreground = FOREGROUND_GRANTED_HINT; }
  return res;
}

function sessionForWindow(winId) { for (const [sid, wid] of sessionWindows) if (wid === winId) return sid; return null; }

// The chrome window currently focused (i.e. Chrome is in the OS foreground), or null when the user is in
// another app. Used to know which window to hand focus BACK to if a driven window steals it.
async function currentForeground() {
  try { const w = await chrome.windows.getLastFocused(); return w && w.focused ? w.id : null; } catch { return null; }
}

// Backstop invoked after any open/navigate/reload/switch/state change: if the driven window ended up
// topmost (focused) and the user did NOT ask to watch live, send it back behind the user's window and log
// a focus_violation. Chrome only exposes `focused` (not true OS z-order), so focused===true is our best
// proxy for "landed on top". We cannot unfocus a window directly (Chrome forbids windows.update{focused:
// false}), so we re-raise whatever window the user had, which drops ours behind it. Never throws.
async function enforceBackground(winId, args, verb, prevFocusedId) {
  if (userWantsForeground(args)) return null; // the user asked to see it - allowed
  if (winId == null) return null;
  // If the window was ALREADY the foreground before this verb ran, the verb did NOT steal focus - it was
  // already there (e.g. the user raised it to watch, or it's the only window). Leave it alone; otherwise
  // we'd wrongly minimize/demote a window every time we, say, open a background tab into it.
  if (prevFocusedId != null && prevFocusedId === winId) return null;
  let w;
  try { w = await chrome.windows.get(winId); } catch { return null; }
  if (!w || !w.focused) return null; // not topmost → nothing to correct
  let how = null;
  // Preferred: hand focus back to whatever window the user had before, which drops ours behind it.
  if (prevFocusedId != null && prevFocusedId !== winId) {
    try { await chrome.windows.update(prevFocusedId, { focused: true }); how = 'restored-previous'; } catch {}
  }
  // Fallback (the user was in a NON-Chrome app, so there is no Chrome window to hand focus back to):
  // we cannot unfocus a window directly (Chrome forbids windows.update{focused:false}), so MINIMIZE ours
  // to physically remove it from the user's foreground. This is the case that matches the user's actual
  // complaint (a window shoved in front while they multitask in another app) - it must never be left up.
  if (!how) {
    try { await chrome.windows.update(winId, { state: 'minimized' }); how = 'minimized'; } catch {}
  }
  await persistViolation({ verb, sessionId: sessionForWindow(winId), windowId: winId, autoReverted: true, how, ts: Date.now() });
  return {
    autoReverted: true, how,
    _warning: `⚠️ focus_violation: ${verb} left a driven window in the FOREGROUND over the user's work; it was auto-sent to the BACKGROUND (${how === 'restored-previous' ? "focus returned to the user's previous window" : how === 'minimized' ? 'minimized, because the user was in another app' : 'could not be corrected'}). ${POLITE_HINT}`,
  };
}

// Fold an enforceBackground() warning into a verb's result object (no-op if there was no violation).
function withWarn(res, warn) { if (warn && warn._warning) { res._warning = warn._warning; res.focusAutoReverted = true; } return res; }

// Resolve which tab a verb targets. Keyed off the REAL session→window map, never an "active tab"
// heuristic when a sessionId/tabId is given:
//   explicit tabId   → that tab (ownership re-checked for mutating verbs)
//   explicit sessionId → the active tab of THAT SESSION'S OWN window; if the session isn't tracked
//                        (window closed/re-created externally, or the map was lost while the SW slept)
//                        it THROWS a clear session_untracked/closed error - it NEVER acts on whatever
//                        tab the user happens to be in. (That fallback is the bug this fixes.)
//   bare (neither)   → mutating verbs are refused; only a read-only bare call may use the active tab.
async function resolveTabId(args, opts = {}) {
  await ensureLoaded();
  const verb = opts.verb || 'this verb';

  // Driving verbs are blocked while the session has unacknowledged spawns (close/cleanup/owned are the
  // ways to acknowledge, so they are exempt). Also enforce profile-pin match (wrong-account guard).
  if (opts.requireOwned && args && args.sessionId != null) {
    assertProfileMatch(args, verb);
    if (!/close|cleanup|owned/.test(verb)) assertSpawnsAcknowledged(args.sessionId, verb);
  }

  if (args && args.tabId != null) {
    const tabId = Number(args.tabId);
    await assertDriveableWindow(tabId, args);
    if (opts.requireOwned) await assertOwned(tabId, verb);
    return tabId;
  }

  if (args && args.sessionId != null) {
    const tabId = await tabForSession(args.sessionId, verb); // reconciles; throws if untracked - NEVER active-tab
    await assertDriveableWindow(tabId, args);
    return tabId; // owned by construction: the session→window map is written ONLY by openWindow
  }

  // Bare call - no sessionId, no tabId.
  // requireOwned (mutating) OR requireTarget (CDP read-only that ATTACHES the debugger) → refuse: never
  // touch the user's tab. For CDP verbs this also stops the "debugging this browser" banner from appearing
  // on the user's OWN active tab (it can only ever show on a window the AI explicitly targeted).
  if (opts.requireOwned || opts.requireTarget) requireTarget(args, verb);
  const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); // read-only bare only
  if (!tab) throw new Error('no target tab found');
  await assertDriveableWindow(tab.id, args);
  return tab.id;
}

// Resolve the tab for an EXPLICIT session strictly from the session→window map. Reconciles first (so a
// map lost from memory but present in storage is recovered), and if the session's window is truly gone
// it records the close and throws a clear error - it NEVER falls back to the user's active tab.
async function tabForSession(sid, verb) {
  if (!sessionWindows.has(sid)) await reconcile();
  if (sessionWindows.has(sid)) {
    const windowId = sessionWindows.get(sid);
    if (await windowExists(windowId)) {
      let [tab] = await chrome.tabs.query({ active: true, windowId });
      if (!tab) [tab] = await chrome.tabs.query({ windowId }); // OUR window, any tab
      if (tab) return tab.id;
    }
    sessionWindows.delete(sid);
    if (!_closedSessions.has(sid)) _closedSessions.set(sid, { windowId, byUser: null, ts: Date.now(), external: true });
    await persist();
  }
  const c = _closedSessions.get(sid);
  throw new GuardError('session_untracked',
    c
      ? `session '${sid}' is closed (${c.byUser === true ? 'by the user' : c.byUser === false ? 'by the agent' : 'its window was closed externally'}) — open a fresh window with nbrowser_open_window. NOT retrying into your active tab.`
      : `session '${sid}' isn't tracked (its window was likely closed/re-created outside the extension). Run nbrowser_resync_sessions to rebuild tracking, then nbrowser_open_window for a fresh window. NOT falling back to your active tab.`,
    OPEN_WINDOW_HINT);
}

// Reconcile-aware window resolve for window-level verbs (open_tab/close_window/switch_window/…).
async function resolveWindowId(args) {
  if (!(args && args.sessionId != null)) return null;
  if (!sessionWindows.has(args.sessionId)) await reconcile();
  return sessionWindows.has(args.sessionId) ? sessionWindows.get(args.sessionId) : null;
}

function waitForLoad(tabId, timeoutMs = 30000) {
  return new Promise((resolve) => {
    let done = false;
    const finish = () => { if (done) return; done = true; chrome.tabs.onUpdated.removeListener(onUpd); resolve(); };
    const onUpd = (id, info) => { if (id === tabId && info.status === 'complete') finish(); };
    chrome.tabs.onUpdated.addListener(onUpd);
    chrome.tabs.get(tabId, (t) => { if (!chrome.runtime.lastError && t && t.status === 'complete') finish(); });
    setTimeout(finish, timeoutMs);
  });
}

// ---- windows / sessions ----------------------------------------------------
export async function openWindow(args = {}) {
  await ensureLoaded();
  requireRegistration(args, 'nbrowser_open_window'); // guard #1: sessionId + thread + purpose
  // Open in the BACKGROUND by default — never steal the user's focus. The window is fully drivable +
  // capturable while occluded. It is raised ONLY when the user EXPLICITLY asked to watch live
  // (userRequestedForeground:true); a bare focused:true is IGNORED. (This is the whole point of
  // native-browser driving: the user keeps working in their foreground window while we operate another.)
  const fg = userWantsForeground(args);
  const prev = fg ? null : await currentForeground();
  const win = await chrome.windows.create({ url: args.url, focused: fg });
  const sid = args.sessionId;
  sessionWindows.set(sid, win.id);
  sessionMeta.set(sid, { thread: args.thread, purpose: args.purpose, profile: args.profile || null, createdAt: Date.now() });
  await persist();
  const warn = await enforceBackground(win.id, args, 'nbrowser_open_window', prev); // backstop: never leaves it topmost
  const tab = (win.tabs && win.tabs[0]) || {};
  // Return the window's BOUNDS so the bridge can correlate its OS hwnd by rect (profile-independent) for
  // the taskbar badge — the old "new Chrome window" diff can't tell profiles apart and badged the wrong one.
  let bounds; try { const w2 = await chrome.windows.get(win.id); bounds = { left: w2.left, top: w2.top, width: w2.width, height: w2.height }; } catch { bounds = { left: win.left, top: win.top, width: win.width, height: win.height }; }
  const res = {
    ok: true, sessionId: sid, tabId: String(tab.id), windowId: win.id, url: tab.url || args.url, title: tab.title, bounds,
    _hint: 'New window opened in the BACKGROUND — the user\'s focus is deliberately NOT stolen. ' + POLITE_HINT +
      ' Screenshot it where it sits with nbrowser_screenshot (CDP) or ab desktop_screenshot_window (WGC); both capture a background/occluded window, so NEVER foreground it to grab a shot. To raise it you must justify it (userRequestedForeground:true + foregroundReason) AND warn the user first. Remember to nbrowser_close_window {sessionId:"' + sid + '"} when done so you don\'t leave abandoned windows.',
  };
  return foregroundNote(args, withWarn(res, warn), sid);
}

export async function closeWindow(args = {}) {
  await ensureLoaded();
  const winId = await resolveWindowId(args);
  if (winId == null) {
    const c = _closedSessions.get(args.sessionId);
    throw new Error(c ? `session '${args.sessionId}' was already closed ${c.byUser ? 'by the USER' : 'by the agent'}` : `unknown sessionId '${args.sessionId}'`);
  }
  // includeSpawned:true → also close everything this session SPAWNED (leaked composes/popups), so the
  // one call leaves nothing behind. Never touches the user's own windows.
  const alsoClosedSpawned = [];
  if (args.includeSpawned) {
    for (const [wid, v] of [...spawnedWindows]) if (v.spawnedBy === args.sessionId) { _agentClosing.add(wid); try { await chrome.windows.remove(wid); alsoClosedSpawned.push(wid); } catch {} spawnedWindows.delete(wid); }
    for (const [tid, v] of [...spawnedTabs]) if (v.spawnedBy === args.sessionId) { try { await chrome.tabs.remove(tid); } catch {} spawnedTabs.delete(tid); }
  }
  acknowledgeSpawns(args.sessionId);
  sessionMeta.delete(args.sessionId);
  _agentClosing.add(winId); // mark so onRemoved labels this agent-closed (byUser:false), not user-closed
  await chrome.windows.remove(winId);
  await persist();
  // NOTE: session deletion + the window_closed event are handled in the onRemoved listener (single
  // source of truth), so agent- and user-closes are recorded identically (just different byUser).
  return { ok: true, closed: true, sessionId: args.sessionId, alsoClosedSpawned,
    _hint: alsoClosedSpawned.length ? `Also closed ${alsoClosedSpawned.length} spawned window(s) this session opened. Nothing left behind.` : (spawnCountFor(args.sessionId) ? `Note: this session still has ${spawnCountFor(args.sessionId)} spawned window(s) open — pass includeSpawned:true or call nbrowser_cleanup to close them too.` : undefined) };
}
function spawnCountFor(sid) { return [...spawnedWindows.values()].filter((v) => v.spawnedBy === sid).length + [...spawnedTabs.values()].filter((v) => v.spawnedBy === sid).length; }

export async function switchWindow(args = {}) {
  await ensureLoaded();
  const winId = await resolveWindowId(args);
  if (winId == null) throw new Error('unknown sessionId');
  // BACKGROUND by default — switching which window the agent targets must NEVER raise it into the user's
  // face. Every verb already targets a window by its sessionId, so this is a no-op for driving; raise it
  // ONLY when the USER explicitly asked to watch live (userRequestedForeground:true).
  const fg = userWantsForeground(args);
  const prev = fg ? null : await currentForeground();
  if (fg) await chrome.windows.update(winId, { focused: true });
  const warn = await enforceBackground(winId, args, 'nbrowser_switch_window', prev);
  const res = { ok: true, focused: fg, _hint: fg ? FOREGROUND_GRANTED_HINT : 'Targeted in the BACKGROUND, not raised — drive it by passing this sessionId. ' + POLITE_HINT };
  return foregroundNote(args, withWarn(res, warn), args.sessionId);
}

// Window state — fullscreen / maximized / normal / minimized, via chrome.windows.update.
// This is the CORRECT way to toggle fullscreen: a synthetic F11 key can't (the browser
// intercepts browser-chrome shortcuts before the page ever sees the CDP key event).
export async function windowState(args = {}) {
  await ensureLoaded();
  const winId = await resolveWindowId(args);
  if (winId == null) throw new Error('unknown sessionId');
  const fg = userWantsForeground(args);
  const prev = fg ? null : await currentForeground();
  // Move/resize path — any of left/top/width/height present. chrome.windows requires the
  // window be in 'normal' state before bounds take, so force it first.
  const hasBounds = ['left', 'top', 'width', 'height'].some((k) => args[k] != null);
  if (hasBounds) {
    await chrome.windows.update(winId, { state: 'normal' });
    const b = {};
    for (const k of ['left', 'top', 'width', 'height']) if (args[k] != null) b[k] = Math.round(args[k]);
    const w = await chrome.windows.update(winId, b);
    const warn = await enforceBackground(winId, args, 'nbrowser_window_state', prev);
    return foregroundNote(args, withWarn({ ok: true, windowId: winId, bounds: { left: w.left, top: w.top, width: w.width, height: w.height }, _hint: 'Moved/resized via chrome.windows — no bar. Use list_windows to read work-area bounds for side-by-side tiling.' }, warn), args.sessionId);
  }
  let state = String(args.state || 'fullscreen').toLowerCase();
  if (state === 'restore' || state === 'windowed') state = 'normal';
  if (!['normal', 'minimized', 'maximized', 'fullscreen'].includes(state)) {
    return { ok: false, error: `bad state '${state}'`, _hint: 'state ∈ fullscreen | maximized | normal | minimized — or pass left/top/width/height to move/resize' };
  }
  await chrome.windows.update(winId, { state });
  // maximize/fullscreen are foregrounding actions (they make the window large + can pull it forward).
  // They are USER-only: the window still stays in the BACKGROUND (auto-reverted) unless the user asked to
  // watch live (userRequestedForeground:true). Resizing for a clean recording therefore still works, but
  // the AI can never use maximize/fullscreen to shove the window in front of the user.
  const warn = await enforceBackground(winId, args, 'nbrowser_window_state', prev);
  const raiseState = state === 'maximized' || state === 'fullscreen';
  return foregroundNote(args, withWarn({ ok: true, windowId: winId, state, _hint: 'Native window-state change — no debugger, no bar. fullscreen hides tabs+URL bar+taskbar; normal restores.' + (raiseState ? ' This enlarges the window but keeps it in the BACKGROUND (auto-reverted) unless you justify a foreground (userRequestedForeground:true + foregroundReason). ' + POLITE_HINT : '') }, warn), args.sessionId);
}

export async function listWindows(args = {}) {
  await ensureLoaded();
  const wins = await chrome.windows.getAll({ populate: true });
  const tracked = new Map([...sessionWindows].map(([k, v]) => [v, k]));
  let sessions = wins.map((w) => {
    const o = ownerOfWindow(w.id);
    return {
      id: tracked.get(w.id) || String(w.id),
      windowId: w.id,
      tracked: tracked.has(w.id),
      owner: o.owner,                                     // agent-opened | agent-spawned | user
      spawnedBy: o.owner === 'agent-spawned' ? o.sessionId : undefined,
      purpose: o.owner === 'agent-opened' ? (sessionMeta.get(o.sessionId) || {}).purpose : undefined,
      tabCount: (w.tabs || []).length,
      active: !!w.focused,
      state: w.state,
      bounds: { left: w.left, top: w.top, width: w.width, height: w.height },
      url: (w.tabs || []).find((t) => t.active)?.url,
      title: (w.tabs || []).find((t) => t.active)?.title, // for DPI-independent hwnd correlation (OS title match)
    };
  });
  if (args.mine) sessions = sessions.filter((s) => s.owner !== 'user'); // only windows an agent opened/spawned
  return { ok: true, sessions, count: sessions.length,
    _hint: sessions.some((s) => s.owner === 'agent-spawned') ? 'Windows marked owner:"agent-spawned" were opened INDIRECTLY by a page you drove — close leftovers with nbrowser_cleanup {sessionId}.' : undefined };
}

// reconcile the session map against live windows (drop closed ones)
export async function rescan() {
  await reconcile();
  return listWindows();
}

// nbrowser_resync_sessions — LIGHTWEIGHT rebuild of session tracking WITHOUT a full dev_reload. Re-reads
// the persisted map, merges it into memory, and re-validates every session against live windows (gone
// windows → marked closed). Use it when session verbs start failing with session_untracked after the
// user interacted with Chrome or another tool moved/closed windows - it un-sticks tracking in one call.
export async function resyncSessions() {
  loaded = false;              // force a fresh read from storage
  await ensureLoaded();
  const tracked = [...sessionWindows].map(([sessionId, windowId]) => ({ sessionId, windowId }));
  const closed = [...(_closedSessions.entries())].map(([sessionId, c]) => ({ sessionId, byUser: c.byUser, external: !!c.external }));
  return {
    ok: true, tracked, trackedCount: tracked.length, closed,
    _hint: tracked.length
      ? `Session tracking rebuilt: ${tracked.length} live agent window(s) are drivable by their sessionId. Closed/untracked sessions are listed under "closed" - open fresh windows for those with nbrowser_open_window.`
      : 'No live agent windows are tracked. Open one with nbrowser_open_window (it auto-pins its sessionId).',
  };
}

// nbrowser_owned — everything a session (or all sessions) owns: opened windows + adopted spawns. Calling
// this ACKNOWLEDGES the session's spawns (clears the unacknowledged_spawns block). Use it before ending a
// task to see what to clean up.
export async function owned(args = {}) {
  await ensureLoaded();
  await reconcile();
  const sid = args.sessionId;
  const age = (t) => Date.now() - (t || Date.now());
  const enrich = async (winId) => { try { const w = await chrome.windows.get(winId, { populate: true }); const t = (w.tabs || []).find((x) => x.active) || (w.tabs || [])[0] || {}; return { url: t.url, title: t.title, bounds: { left: w.left, top: w.top, width: w.width, height: w.height } }; } catch { return {}; } };
  const opened = [];
  for (const [s, winId] of sessionWindows) {
    if (sid && s !== sid) continue;
    const e = await enrich(winId);
    opened.push({ sessionId: s, windowId: winId, url: e.url, title: e.title, purpose: (sessionMeta.get(s) || {}).purpose, ageMs: age((sessionMeta.get(s) || {}).createdAt) });
  }
  const spawned = [];
  for (const [winId, v] of spawnedWindows) { if (sid && v.spawnedBy !== sid) continue; const e = await enrich(winId); spawned.push({ kind: 'window', windowId: winId, spawnedBy: v.spawnedBy, opener: v.openerTabId, url: e.url || v.url, title: e.title, bounds: e.bounds, ageMs: age(v.createdAt) }); }
  for (const [tabId, v] of spawnedTabs) { if (sid && v.spawnedBy !== sid) continue; spawned.push({ kind: 'tab', tabId: String(tabId), windowId: v.windowId, spawnedBy: v.spawnedBy, opener: v.openerTabId, url: v.url, ageMs: age(v.createdAt) }); }
  if (sid) acknowledgeSpawns(sid);
  return { ok: true, opened, spawned, count: { opened: opened.length, spawned: spawned.length },
    _hint: spawned.length
      ? `This session owns ${spawned.length} SPAWNED window(s)/tab(s) (a page you drove opened them - e.g. a Gmail compose). Keep the ones you still need; close the litter with nbrowser_cleanup {sessionId:"${sid || '<id>'}"}. They are now acknowledged, so driving verbs are unblocked.`
      : 'No spawned windows. At task end, close your opened windows with nbrowser_close_window {sessionId} so you never leave litter for the user.' };
}

// nbrowser_cleanup — close ONLY what this session SPAWNED (never the user's windows, never the primary
// opened window). This is the incident fix: it reaches the leaked compose windows. Acknowledges spawns.
export async function cleanup(args = {}) {
  await ensureLoaded();
  const sid = args.sessionId;
  if (!sid) throw new Error('nbrowser_cleanup needs a sessionId (the session whose spawned windows to close)');
  const closed = [];
  for (const [winId, v] of [...spawnedWindows]) { if (v.spawnedBy !== sid) continue; _agentClosing.add(winId); try { await chrome.windows.remove(winId); closed.push({ windowId: winId, url: v.url }); } catch {} spawnedWindows.delete(winId); }
  for (const [tabId, v] of [...spawnedTabs]) { if (v.spawnedBy !== sid) continue; try { await chrome.tabs.remove(tabId); closed.push({ tabId: String(tabId), url: v.url }); } catch {} spawnedTabs.delete(tabId); }
  acknowledgeSpawns(sid);
  await persist();
  return { ok: true, closed, count: closed.length, keptOpenedWindow: sessionWindows.get(sid) || null,
    _hint: `Closed ${closed.length} spawned window(s)/tab(s) for session '${sid}'. Your primary window (if any) is KEPT and the user's own windows were never touched. At task end also nbrowser_close_window {sessionId:"${sid}"} (or use includeSpawned:true) to close your primary window.` };
}

// nbrowser_force_close — LAST-RESORT override for a TRUE ORPHAN (a window nbe cannot attribute to any live
// session) or the CALLING session's own window. Two-step attestation: call 1 returns a challenge ticket +
// exactly what will be destroyed; call 2 with the ticket + attestation destroys it. It can NEVER close a
// window owned by a DIFFERENT live thread — cross-thread protection is absolute and not overridable.
const _overrideTickets = new Map(); // ticket -> { windowId, expires }
let _ticketSeq = 0;
export async function forceClose(args = {}) {
  await ensureLoaded();
  await reconcile();
  const winId = Number(args.windowId);
  if (!winId || !(await windowExists(winId))) throw new GuardError('bad_window', `⛔ force_close: windowId ${args.windowId} is not a live window.`, 'List candidates with nbrowser_list_windows {mine:false} — a true orphan shows owner:"user" with no live session driving it.');
  const o = ownerOfWindow(winId);
  // HARD INVARIANT: never pierce another live thread's ownership.
  if (o.owner !== 'user' && o.sessionId && o.sessionId !== args.sessionId) {
    throw new GuardError('owned_by_other', `⛔ force_close REFUSED: window ${winId} is owned by another live session ('${o.sessionId}'). Cross-thread ownership is ABSOLUTE and can NEVER be force-closed.`, 'Only that thread may close its own window. If it is truly abandoned, its own thread cleans up, or the user closes it manually.');
  }
  let title = '', url = '';
  try { const w = await chrome.windows.get(winId, { populate: true }); const t = (w.tabs || []).find((x) => x.active) || (w.tabs || [])[0] || {}; title = t.title || ''; url = t.url || ''; } catch {}
  const willDestroy = { windowId: winId, title, url, owner: o.owner === 'user' ? 'none (orphan)' : o.owner, spawnedBy: o.sessionId || null, ageMs: spawnedWindows.get(winId) ? Date.now() - spawnedWindows.get(winId).createdAt : null };
  // STEP 1 — no ticket: mint a challenge and REFUSE (a single call NEVER force-closes; that is the point).
  if (!args.overrideTicket) {
    const ticket = 'ovr-' + (++_ticketSeq) + '-' + winId;
    _overrideTickets.set(ticket, { windowId: winId, expires: Date.now() + 60000 });
    return { ok: false, needsAttestation: true, overrideTicket: ticket, willDestroy,
      _hint: `⚠️ FORCE-CLOSE is a destructive last resort. This will PERMANENTLY close window ${winId} ("${(title || url || '').slice(0, 60)}") and lose any unsaved content in it. To proceed you MUST call again with EXACTLY: nbrowser_force_close {windowId:${winId}, overrideTicket:"${ticket}", attestOrphan:true, reason:"<why this window must be force-closed>"}. The ticket expires in 60s and closes only this one window. force_close can NEVER touch another thread's window.` };
  }
  // STEP 2 — validate ticket + attestation, re-check attribution (TOCTOU), then close.
  const t = _overrideTickets.get(args.overrideTicket);
  if (!t || t.windowId !== winId) throw new GuardError('bad_ticket', `⛔ force_close: overrideTicket does not match window ${winId}. Request a fresh ticket by calling force_close with just {windowId} first.`, 'One ticket = one window, one use.');
  if (Date.now() > t.expires) { _overrideTickets.delete(args.overrideTicket); throw new GuardError('ticket_expired', '⛔ force_close: the override ticket expired (60s). Request a fresh one.', 'Call force_close with just {windowId} to get a new ticket, then confirm promptly.'); }
  if (args.attestOrphan !== true || !args.reason) throw new GuardError('attestation_required', '⛔ force_close: you must attest {attestOrphan:true, reason:"…"} to proceed. This deliberate friction ensures force-close is never reflexive.', 'Include attestOrphan:true and a human-readable reason for the override.');
  const o2 = ownerOfWindow(winId); // re-check: another thread may have adopted it since step 1
  if (o2.owner !== 'user' && o2.sessionId && o2.sessionId !== args.sessionId) throw new GuardError('owned_by_other', `⛔ force_close REFUSED at commit: window ${winId} is now owned by another live session. Cross-thread ownership is absolute.`, 'Aborted to protect another thread.');
  _overrideTickets.delete(args.overrideTicket);
  _agentClosing.add(winId);
  try { await chrome.windows.remove(winId); } catch (e) { throw new Error('force_close failed: ' + ((e && e.message) || e)); }
  spawnedWindows.delete(winId);
  await persist();
  _emit({ event: 'force_closed', windowId: winId, reason: args.reason, by: args.sessionId || null });
  return { ok: true, closed: true, windowId: winId, reason: args.reason, _hint: 'Orphan force-closed. This should be RARE - prefer nbrowser_cleanup for tracked spawns. If you hit orphans often, spawn-adoption is missing an opener path; report it.' };
}

// ---- tabs ------------------------------------------------------------------
export async function openTab(args = {}) {
  await ensureLoaded();
  const windowId = await resolveWindowId(args);
  if (windowId == null) {
    throw new GuardError('no_target',
      "\u26d4 no_target: nbrowser_open_tab without the sessionId of a window YOU opened would drop a tab into the USER'S OWN window \u2014 refused.",
      'Pass the sessionId from nbrowser_open_window, or open a new window instead. ' + OPEN_WINDOW_HINT);
  }
  if (!sessionMeta.has(args.sessionId)) requireRegistration(args, 'nbrowser_open_tab'); // register a legacy/unregistered session (new sessions always carry meta)
  // BACKGROUND by default — a new tab must not steal the user's focus or yank their active tab.
  // chrome.tabs.create defaults to active:true; force active:false unless the user EXPLICITLY asked to
  // watch live (userRequestedForeground:true). A bare focused:true/active:true is IGNORED.
  const fg = userWantsForeground(args);
  const prev = fg ? null : await currentForeground();
  const base = { url: args.url, active: fg };
  const tab = await chrome.tabs.create(windowId != null ? { windowId, ...base } : base);
  const warn = await enforceBackground(windowId, args, 'nbrowser_open_tab', prev); // backstop: opening a tab must not raise the window
  // Echo args.url (chrome.tabs.create returns the tab BEFORE it navigates, so tab.url/title are empty at
  // this instant - returning them raw made callers think the open "returned nothing"). The tab IS open
  // (tabId proves it); it may still be loading, so drive it by tabId / wait with nbrowser_wait_for.
  const res = { ok: true, sessionId: args.sessionId, tabId: String(tab.id), url: tab.url || args.url, title: tab.title || undefined, _hint: 'Tab opened in the BACKGROUND (active:false) at ' + (tab.url || args.url) + ' — it may still be loading (drive it by this tabId; nbrowser_wait_for to await a selector/URL). ' + POLITE_HINT };
  return foregroundNote(args, withWarn(res, warn), args.sessionId);
}

export async function closeTab(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_close_tab' });
  await chrome.tabs.remove(tabId);
  return { ok: true, tabId: String(tabId) };
}

export async function switchTab(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_switch_tab' });
  await chrome.tabs.update(tabId, { active: true });
  return { ok: true, tabId: String(tabId) };
}

export async function listTabs(args = {}) {
  await ensureLoaded();
  const windowId = await resolveWindowId(args);
  const tabs = await chrome.tabs.query(windowId != null ? { windowId } : {});
  const active = tabs.find((t) => t.active);
  const owned = ownedWindowSet();
  let out = tabs.map((t) => {
    const st = spawnedTabs.get(t.id);
    const owner = st ? 'agent-spawned' : (owned.has(t.windowId) ? 'agent-opened' : (spawnedWindows.has(t.windowId) ? 'agent-spawned' : 'user'));
    return { ...tabView(t), owner, spawnedBy: st ? st.spawnedBy : (spawnedWindows.get(t.windowId) || {}).spawnedBy };
  });
  if (args.mine) out = out.filter((t) => t.owner !== 'user');
  return { ok: true, count: out.length, activeTabId: active ? String(active.id) : null, tabs: out };
}

// ---- navigation ------------------------------------------------------------
export async function navigate(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_navigate' });
  const prev = userWantsForeground(args) ? null : await currentForeground();
  await chrome.tabs.update(tabId, { url: args.url });
  await waitForLoad(tabId);
  const t = await chrome.tabs.get(tabId);
  const warn = await enforceBackground(t.windowId, args, 'nbrowser_navigate', prev); // navigating must never raise the window
  const res = withWarn({ ok: true, url: t.url, tabId: String(tabId), sessionId: args.sessionId }, warn);
  const kw = keepaliveWarning(args.sessionId, t.url); if (kw) res._keepaliveWarning = kw;
  return res;
}

export async function reload(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_reload' });
  const prev = userWantsForeground(args) ? null : await currentForeground();
  await chrome.tabs.reload(tabId);
  await waitForLoad(tabId);
  const t = await chrome.tabs.get(tabId);
  const warn = await enforceBackground(t.windowId, args, 'nbrowser_reload', prev);
  return withWarn({ ok: true, tabId: String(tabId), sessionId: args.sessionId }, warn);
}

// Route back/forward through the PAGE's own history, not chrome.tabs.goBack/goForward. The tabs API
// throws "Cannot find a next/previous page in history" on tabs whose navigations were driven by us
// (chrome.tabs.update + a lazily-attached debugger), even when window.history genuinely has entries.
// history.back()/forward() in the page context is reliable and stays banner-free (no debugger attach).
export async function back(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_back' });
  const [{ result } = {}] = await chrome.scripting.executeScript({
    target: { tabId },
    func: () => { if (history.length <= 1) return 'no-history'; history.back(); return 'ok'; },
  });
  if (result === 'no-history') throw new Error('Cannot go back: no previous page in this tab history.');
  await waitForLoad(tabId);
  return { ok: true, tabId: String(tabId), sessionId: args.sessionId };
}

export async function forward(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_forward' });
  await chrome.scripting.executeScript({ target: { tabId }, func: () => { history.forward(); } });
  await waitForLoad(tabId);
  return { ok: true, tabId: String(tabId), sessionId: args.sessionId };
}

export async function wait(args = {}) {
  const ms = Math.min(Number(args.ms) || 3000, 120000);
  await new Promise((r) => setTimeout(r, ms));
  return { ok: true, waited: ms };
}

// nbrowser_wait_for - poll the page until a CSS selector exists / the URL contains a substring / the
// visible text contains a substring, or timeoutMs elapses. Deterministic post-click/nav wait instead
// of fixed sleeps + coordinate races. Pass one or more of {selector, urlIncludes, textIncludes}.
export async function waitFor(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_wait_for' });
  const { selector, urlIncludes, textIncludes } = args;
  if (!selector && !urlIncludes && !textIncludes) throw new Error('wait_for needs one of: selector, urlIncludes, textIncludes');
  const timeoutMs = Math.min(Number(args.timeoutMs) || 15000, 120000);
  const t0 = Date.now();
  while (Date.now() - t0 < timeoutMs) {
    let res;
    try {
      const [{ result } = {}] = await chrome.scripting.executeScript({
        target: { tabId },
        args: [selector || null, urlIncludes || null, textIncludes || null],
        func: (sel, u, t) => {
          try {
            if (sel && document.querySelector(sel)) return { matched: true, by: 'selector', url: location.href };
            if (u && location.href.includes(u)) return { matched: true, by: 'url', url: location.href };
            if (t && ((document.body && document.body.innerText) || '').includes(t)) return { matched: true, by: 'text', url: location.href };
            return { matched: false, url: location.href };
          } catch (e) { return { matched: false, error: String(e) }; }
        },
      });
      res = result;
    } catch (e) { res = null; } // page mid-navigation (no doc yet) - keep polling
    if (res && res.matched) return { ok: true, matched: true, by: res.by, url: res.url, waitedMs: Date.now() - t0, tabId: String(tabId), sessionId: args.sessionId };
    await new Promise((r) => setTimeout(r, 250));
  }
  return { ok: false, matched: false, timedOut: true, waitedMs: Date.now() - t0, tabId: String(tabId), sessionId: args.sessionId };
}

// nbrowser_keepalive - replay a benign no-op interaction (mousemove + focus, NO navigation/side-effects)
// on an interval to stave off vendor idle sign-outs (Gusto/Intuit ~10-15 min). Per-session; auto-clears
// when the tab/window closes. Pass {stop:true} to cancel. Re-calling replaces the interval.
const _keepalives = new Map(); // sessionId -> interval handle
export async function keepalive(args = {}) {
  const sid = args.sessionId;
  if (!sid) throw new Error('keepalive needs a sessionId');
  const existing = _keepalives.get(sid);
  if (existing) { clearInterval(existing); _keepalives.delete(sid); }
  if (args.stop) return { ok: true, keepalive: 'off', stopped: true, sessionId: sid };
  const intervalSec = Math.max(30, Math.min(Number(args.intervalSec) || 240, 900)); // 30s..15min, default 4min
  const stopSelf = () => { const h = _keepalives.get(sid); if (h) { clearInterval(h); _keepalives.delete(sid); } };
  const tick = async () => {
    try {
      const tabId = await resolveTabId({ sessionId: sid });
      await chrome.scripting.executeScript({ target: { tabId }, func: () => {
        try { document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 2, clientY: 2 })); } catch {}
        try { window.dispatchEvent(new Event('focus')); } catch {}
      } });
    } catch (e) { stopSelf(); } // tab/window gone -> stop
  };
  const h = setInterval(tick, intervalSec * 1000);
  _keepalives.set(sid, h);
  tick();
  return { ok: true, keepalive: 'on', intervalSec, sessionId: sid };
}

// Detect whether the user appears SIGNED IN to a site, by inspecting its cookies. Chrome does
// NOT expose saved passwords to extensions (security), so we can't read a stored credential —
// but auth/session cookies tell us if a live session exists, which is what you need before
// driving a logout (and to know a re-login is required afterward). Re-login is then driven by
// focusing the login form and letting Chrome AUTOFILL the saved credential (the secret never
// reaches Adom), or by snapshotting these cookies before logout and restoring them.
export async function loginState(args = {}) {
  if (!args.url) throw new Error('url required (e.g. https://www.mouser.com)');
  let host;
  try { host = new URL(args.url).hostname; } catch { throw new Error('bad url'); }
  const base = host.replace(/^www\./, '');
  const cookies = await chrome.cookies.getAll({ domain: base });
  const authRe = /sess|auth|token|\bsid\b|login|account|uid|csrf|jwt|__Secure|__Host/i;
  const auth = cookies.filter((c) => authRe.test(c.name) || (c.secure && c.httpOnly));
  const strong = auth.some((c) => /sess|auth|token|login|jwt/i.test(c.name));
  const loggedIn = auth.length > 0;
  return {
    ok: true,
    host: base,
    loggedIn,
    confidence: loggedIn ? (strong ? 'high' : 'medium') : 'low',
    cookieCount: cookies.length,
    authCookieNames: auth.map((c) => c.name).slice(0, 12),
    _hint: loggedIn
      ? 'Auth/session cookies present → user appears SIGNED IN. Chrome does NOT expose saved passwords to extensions, so to re-login after a logout: drive the login form and let Chrome AUTOFILL the saved credential (you never read the secret), then submit. Or snapshot these cookies BEFORE logout and restore them (only works if logout did not invalidate them server-side).'
      : 'No obvious auth cookies → likely SIGNED OUT. If a credential is saved, Chrome autofill on the login form can sign in without exposing the password to Adom.',
  };
}

// ---- nbrowser_clear_cookies: BACKGROUND logout — drop a site's cookies (incl. httpOnly) -------------
// chrome.cookies can remove httpOnly/secure session cookies that page JS can't touch — a clean, background
// way to sign a site out. Scoped to ONE registrable domain so it never nukes unrelated sessions (e.g.
// clearing gusto.com leaves your google.com session intact, so Google SSO still works on the re-login).
export async function clearCookies(args = {}) {
  const raw = (args.host || args.domain || args.url || '').replace(/^https?:\/\//, '').split('/')[0];
  if (!raw) throw new Error('host required (e.g. host:"gusto.com")');
  const base = raw.split('.').slice(-2).join('.'); // registrable domain: app.gusto.com -> gusto.com
  const cookies = await chrome.cookies.getAll({ domain: base }); // matches base + all its subdomains
  let removed = 0; const hosts = new Set();
  for (const c of cookies) {
    const d = c.domain.replace(/^\./, '');
    const url = (c.secure ? 'https://' : 'http://') + d + (c.path || '/');
    try { await chrome.cookies.remove({ url, name: c.name, storeId: c.storeId }); removed++; hosts.add(d); } catch (e) {}
  }
  return { ok: true, domain: base, removed, hosts: [...hosts].slice(0, 8),
    _hint: `Cleared ${removed} cookie(s) for ${base} (background logout). Navigate to the site → it should show the login. Other sites' sessions (e.g. Google for SSO) are untouched.` };
}

// ---- M4: authed fetch (uses the user's real cookies) + downloads -----------
export async function fetchUrl(args = {}) {
  if (!args.url) throw new Error('url required');
  const res = await fetch(args.url, {
    method: args.method || 'GET',
    credentials: 'include', // <-- the native advantage: the user's real session cookies
    headers: args.headers || {},
    body: args.body,
  });
  const ct = res.headers.get('content-type') || '';
  const isText = /text|json|xml|javascript|html|svg|csv|urlencoded/.test(ct) || !ct;
  let body, encoding;
  if (isText) { body = await res.text(); encoding = 'utf8'; }
  else {
    const buf = new Uint8Array(await res.arrayBuffer());
    let bin = ''; for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);
    body = btoa(bin); encoding = 'base64';
  }
  let downloadId;
  if (args.download || args.desktopSaveTo || args.filename) {
    downloadId = await new Promise((r) => chrome.downloads.download({ url: args.url, filename: args.filename }, (id) => { void chrome.runtime.lastError; r(id); }));
  }
  return { ok: true, url: res.url, status: res.status, contentType: ct, encoding, length: body.length, body, downloadId };
}

export async function download(args = {}) {
  if (!args.url) throw new Error('url required');
  const id = await new Promise((resolve, reject) => {
    chrome.downloads.download({ url: args.url, filename: args.filename, saveAs: !!args.saveAs }, (downloadId) => {
      const e = chrome.runtime.lastError; if (e) reject(new Error(e.message)); else resolve(downloadId);
    });
  });
  return { ok: true, downloadId: id, url: args.url, filename: args.filename };
}

// ---- demo aids: on-page captions (subtitle | alert) + bring-to-front -------
// Injected into the page (self-contained; chrome.scripting serializes via toString()).
// style 'subtitle' = clean bottom narration (Hydrogen feel);
// style 'alert'    = red PULSING attention banner (ab feel). pulse via Web Animations API
// so page CSP can't block it.
function injectCaption(o) {
  var id = '__adom_caption__';
  var old = document.getElementById(id);
  if (old) { if (old.__anim) try { old.__anim.cancel(); } catch (e) {} old.remove(); }
  if (!o.text) return; // empty text = dismiss
  var alert = o.style === 'alert';
  var pos = o.position || (alert ? 'top' : 'bottom');
  var d = document.createElement('div');
  d.id = id; d.textContent = o.text;
  var s = d.style;
  s.position = 'fixed'; s.left = '50%'; s.zIndex = '2147483647'; s.pointerEvents = 'none';
  s.textAlign = 'center'; s.maxWidth = '92vw'; s.boxSizing = 'border-box'; s.opacity = '0';
  s.fontFamily = 'system-ui,Segoe UI,Arial,sans-serif'; s.transition = 'opacity .3s ease';
  var ty = '-50%';
  if (pos === 'center') { s.top = '50%'; ty = '-50%,-50%'; s.transform = 'translate(-50%,-50%)'; }
  else if (pos === 'top') { s.top = '22px'; s.transform = 'translateX(-50%)'; }
  else { s.bottom = '8%'; s.transform = 'translateX(-50%)'; }
  if (alert) {
    s.color = '#fff'; s.background = 'rgba(176,0,0,0.93)'; s.border = '4px solid #ff4d4d';
    s.font = '800 ' + (o.fontSize || 38) + 'px/1.2 system-ui,Arial,sans-serif';
    s.padding = '20px 34px'; s.borderRadius = '14px'; s.letterSpacing = '0.5px';
    s.boxShadow = '0 8px 44px rgba(255,0,0,0.55)';
  } else {
    s.color = '#fff'; s.background = 'rgba(0,0,0,0.80)';
    s.font = '600 ' + (o.fontSize || 30) + 'px/1.35 system-ui,Arial,sans-serif';
    s.padding = '14px 28px'; s.borderRadius = '12px'; s.boxShadow = '0 4px 24px rgba(0,0,0,0.55)';
  }
  (document.documentElement || document.body).appendChild(d);
  requestAnimationFrame(function () { d.style.opacity = '1'; });
  if (o.pulse !== false && (alert || o.pulse)) {
    try { d.__anim = d.animate([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], { duration: 850, iterations: Infinity }); } catch (e) {}
  }
  if (o.durationMs) setTimeout(function () {
    var e = document.getElementById(id);
    if (e) { if (e.__anim) try { e.__anim.cancel(); } catch (x) {} e.style.opacity = '0'; setTimeout(function () { try { e.remove(); } catch (x) {} }, 350); }
  }, o.durationMs);
}

export async function caption(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_caption' });
  await chrome.scripting.executeScript({
    target: { tabId },
    func: injectCaption,
    args: [{
      text: args.action === 'hide' ? '' : String(args.text || ''),
      style: args.style === 'alert' ? 'alert' : 'subtitle',
      position: args.position,
      fontSize: args.fontSize ? Number(args.fontSize) : undefined,
      pulse: args.pulse,
      durationMs: args.durationMs === undefined ? 5000 : Number(args.durationMs),
    }],
  });
  return { ok: true, tabId: String(tabId), text: args.text, style: args.style || 'subtitle' };
}

// Select the tab within its window + (politely) flash its taskbar button. It does NOT raise the window
// above the user's foreground app unless the user EXPLICITLY asked to watch live
// (userRequestedForeground:true). Without that flag we only drawAttention (a taskbar flash) + set the
// active tab — never a focus/raise — so the user's foreground work is never yanked.
export async function focus(args = {}) {
  const tabId = await resolveTabId(args, { requireOwned: true, verb: 'nbrowser_focus' });
  const tab = await chrome.tabs.get(tabId);
  const fg = userWantsForeground(args);
  await chrome.tabs.update(tabId, { active: true }); // select the tab within its window (no raise)
  if (fg) await chrome.windows.update(tab.windowId, { focused: true }); // user asked → raise
  else { try { await chrome.windows.update(tab.windowId, { drawAttention: true }); } catch {} } // polite taskbar flash only
  return foregroundNote(args, {
    ok: true, tabId: String(tabId), windowId: tab.windowId, title: tab.title, raised: fg,
    _hint: fg
      ? FOREGROUND_GRANTED_HINT
      : `Did NOT raise the window — only selected the tab + flashed its taskbar button, so the user's focus is never stolen. ${POLITE_HINT} Capture it in place with nbrowser_screenshot (works on a background/occluded window).`,
  }, args.sessionId);
}

// shared resolver for CDP verbs (screenshot/eval/input target the same tab)
export { resolveTabId };

// ── #6 Downloads: buffer completed downloads + optional dest subfolder ─────────
// Listeners registered at module load so downloads are caught even before download_wait is called.
const _downloads = [];              // {id, filename, finishedAt, bytes}
let _dlSubfolder = null;            // pending saveToSubfolder (relative to the Downloads dir)
try {
  chrome.downloads.onChanged.addListener((delta) => {
    if (delta && delta.state && delta.state.current === 'complete') {
      try { chrome.downloads.search({ id: delta.id }, (items) => { const it = items && items[0]; if (it) _downloads.push({ id: it.id, filename: it.filename, finishedAt: Date.now(), bytes: it.fileSize }); }); } catch {}
    }
  });
  if (chrome.downloads.onDeterminingFilename) {
    chrome.downloads.onDeterminingFilename.addListener((item, suggest) => {
      if (_dlSubfolder) { const base = (item.filename || 'download').split(/[\\/]/).pop(); suggest({ filename: String(_dlSubfolder).replace(/[\\/]+$/, '') + '/' + base, conflictAction: 'uniquify' }); }
      else suggest();
    });
  }
} catch {}

// nbrowser_download_wait - return filenames that COMPLETED within the last sinceMs (or wait up to
// timeoutMs for one to land). Set saveToSubfolder to route subsequent downloads into a subfolder of
// the Downloads dir. Beats polling the Downloads folder by timestamp.
export async function downloadWait(args = {}) {
  if (args.saveToSubfolder !== undefined) _dlSubfolder = args.saveToSubfolder || null;
  const sinceMs = Math.min(Number(args.sinceMs) || 60000, 3600000);
  const timeoutMs = Math.min(Number(args.timeoutMs) || 60000, 300000);
  const cutoff = Date.now() - sinceMs;
  const collect = () => _downloads.filter((x) => x.finishedAt >= cutoff);
  const t0 = Date.now();
  let hits = collect();
  while (!hits.length && Date.now() - t0 < timeoutMs) { await new Promise((r) => setTimeout(r, 500)); hits = collect(); }
  return { ok: true, files: hits.map((x) => x.filename), count: hits.length, timedOut: !hits.length, saveToSubfolder: _dlSubfolder || undefined,
    _hint: hits.length ? undefined : 'No download completed in the window. Increase timeoutMs, or the click that triggers the export may not have fired - verify it, then call download_wait again.' };
}
