← Commit history
v0.1.0: #148 ownership guardrails (registration + spawn adoption + owned/cleanup/force_close + targeting guards) + foreground-justification + debugger-banner debounce + native JS dialog detection + hint/CI hardening
19 files changed
+719−137
bridge/BRIDGE_VERSION+1−1bridge/bridge.json+6−2bridge/package.json+1−1bridge/server.js+116−40extension/manifest.json+1−1extension/src/cdp.js+82−2extension/src/native.js+332−35extension/src/sw.js+30−15native-browser-bridge-manifest.json+5−5package.json+1−1skills/driving-the-desktop/SKILL.md+1−1skills/driving-the-extension-demo/SKILL.md+1−1skills/driving-the-extension/SKILL.md+49−14skills/installing-the-extension/SKILL.md+2−2skills/native-browser-recording/SKILL.md+15skills/native-browser-safety/SKILL.md+59−14skills/uninstalling-the-extension/SKILL.md+1−1tools/check-guard-coverage.mjs+11−1tools/verbs-policy.json+5bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.0.41+0.1.0
bridge/bridge.json+6−2@@ -2,8 +2,8 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser (Chrome + Edge)",- "version": "0.0.41",- "expectedExtensionVersion": "0.0.23",+ "version": "0.1.0",+ "expectedExtensionVersion": "0.1.0", "description": "Drives the user's REAL signed-in Chrome/Edge via an installed extension (native-messaging transport). Mirrors the puppeteer bridge's verb surface as nbrowser_* but runs inside the actual logged-in profile, so cookies/SSO/saved-logins/captcha human-trust all work. The bridge binds an ephemeral loopback port (PORTS.md compliant) and writes a discovery file; the native-messaging host pumps stdio <-> this bridge; the extension never touches a TCP port.", "homepage": "https://wiki.adom.inc/adom/adom-browser-extension", "author": "Adom Inc.",@@ -40,6 +40,10 @@ "nbrowser_profile_unblock", "nbrowser_rescan", "nbrowser_resync_sessions",+ "nbrowser_owned",+ "nbrowser_cleanup",+ "nbrowser_force_close",+ "nbrowser_handle_dialog", "nbrowser_open_tab", "nbrowser_close_tab", "nbrowser_switch_tab",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.0.41",+ "version": "0.1.0", "private": true, "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)", "main": "server.js",
bridge/server.js+116−40@@ -116,7 +116,13 @@ function targetConn(args) { // 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)) return { conn: c, key: k };+ 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; }@@ -289,7 +295,7 @@ const HELP = { '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; an "Adom is driving" badge goes on automatically). flash = orange "come look" cue (auto-clears on focus). flash_mode{quiet|chatty|off}: quiet(default)=flash on come-look moments (load/screenshot/error/done); chatty=every action (users who want to watch); off=no auto-flash. 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.',+ '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). 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.', }, }; @@ -403,7 +409,8 @@ async function wgcRecord(verb, args) { // window. Flash works today via run_script FlashWindowEx; overlay/progress need AD'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+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) let flashMode = 'quiet'; // quiet | chatty | off const COMELOOK = new Set(['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_screenshot_full_res', 'nbrowser_download']);@@ -427,12 +434,50 @@ async function chromeHwnds() { 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)); }-// auto-flash after a successful verb, per the user's chosen mode+// 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 adCommand('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) {+ 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; }+ if (title) { const t = String(title).slice(0, 24); const m = list.find((w) => (w.title || '').includes(t)); if (m) return m.hwnd; }+ return null;+}+// Paint the "Adom is driving" badge on any adopted SPAWNED window in a nbrowser_owned / nbrowser_list_windows+// payload we haven't badged yet — so child/popup windows get the trust tell too, not just open_window.+async function badgeSpawnsIn(payload) {+ const spawns = [];+ 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.owner === 'agent-spawned' && s.windowId != null) spawns.push({ windowId: s.windowId, bounds: s.bounds, title: s.url });+ const fresh = spawns.filter((s) => !spawnHwnd.has(s.windowId));+ if (!fresh.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); taskbar(h, { overlay: { badge: 'adom', tooltip: 'Adom is driving this browser' } }); } }+}+// 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;- const hwnd = (payload && payload.sessionId && sessionHwnd.get(payload.sessionId)) || lastHwnd;+ 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) : COMELOOK.has(verb);+ 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=' ;@@ -713,20 +758,23 @@ function _hostManifestPaths() { 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. 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 (pass userRequestedForeground:true only when the user explicitly asked to watch live). A driven window that lands in the foreground is auto-sent back and logged as a focus_violation.';+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 an AD notify (adom-desktop 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 AD\'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: [] },- nbrowser_open_window: { hint: 'Open a NEW background window YOU own and get a sessionId; do ALL your work through that sessionId. This is how you get a surface to drive WITHOUT touching the user\'s own windows. ' + NO_FG, related: ['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_close_window'], next: 'nbrowser_navigate', pitfalls: ['opens UNFOCUSED in the BACKGROUND - a bare focused:true is IGNORED; only userRequestedForeground:true (user asked to watch) raises it', 'NEVER fall back to an OS-level launch (Start-Process/msedge/chrome --new-window) to open a window - that RAISES it to the foreground; this verb is the polite path and always works in the background', 'this is the safe way to work: always drive a window you opened, never the user\'s existing ones'] },- nbrowser_close_window: { hint: 'Close a window you opened. Clean up your own sessions when done.', related: ['nbrowser_list_windows'], pitfalls: ['⛔ only close a sessionId YOU opened - closing the user\'s window can wipe their live work and other AI threads'] },+ 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_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 ABE cannot attribute to any live session) or your own. TWO-STEP: call once with {windowId} → get an overrideTicket + exactly what will be destroyed; call again with {windowId, overrideTicket, attestOrphan:true, reason} to actually close it. 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', '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'] }, 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.', related: ['nbrowser_switch_window', 'nbrowser_rescan'], pitfalls: [] },+ 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: [] },+ 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. Drive several in parallel with profile:"<key>".', related: ['nbrowser_use_profile', 'nbrowser_profile_block'], pitfalls: ['active is only the default when no profile is passed'] }, 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>"'] },@@ -734,13 +782,13 @@ const VERB_META = { 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: [] },- nbrowser_rescan: { hint: 'Reconcile the session map against live windows (drop/mark-closed the gone ones).', related: ['nbrowser_list_windows', 'nbrowser_resync_sessions'], pitfalls: [] },+ 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.', related: ['nbrowser_list_tabs'], pitfalls: [] },- nbrowser_list_tabs: { hint: 'List tabs (tabId/url/title/active).', related: ['nbrowser_switch_tab'], pitfalls: [] },+ 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'] },@@ -760,20 +808,33 @@ const VERB_META = { 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.).', related: ['nbrowser_type'], pitfalls: [] },+ 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 AD 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: [] },+ 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: [] },- nbrowser_record_list: { hint: 'List recent recordings.', related: ['nbrowser_record_status'], pitfalls: [] },+ 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-desktop 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: [] },- nbrowser_flash_mode: { hint: 'Set auto-flash verbosity (quiet/chatty/off).', related: ['nbrowser_flash'], pitfalls: [] },+ 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).', related: ['nbrowser_flash'], pitfalls: ['clear everything at task end: {progress:{state:"none"},overlay:{badge:"none"},flash:"stop"}'] },- nbrowser_describe: { hint: 'Returns per-verb schemas + hint/related/pitfalls for AD\'s Verbs tab.', related: ['nbrowser_help'], pitfalls: [] },+ nbrowser_describe: { hint: 'Returns per-verb schemas + hint/related/pitfalls for AD\'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 (AD 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.@@ -874,10 +935,13 @@ function dispatch(verb, args) { 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. Never steals focus; a driven window that lands topmost is auto-reverted + logged (focus_violation).', { url: 'required str', profile: P, userRequestedForeground: 'optional bool - raise it to the foreground ONLY when the USER explicitly asked to watch live (default false = background; a bare focused:true is ignored)' }, { ok: 'bool', sessionId: 'str', tabId: 'str', windowId: 'int', url: 'str', _warning: 'str? (present if a foregrounding attempt was auto-reverted)' }, 30, { example: { url: 'https://wiki.adom.inc' } }),- d('nbrowser_close_window', 'Close a window the agent opened.', { sessionId: 'required str', profile: P }, { ok: 'bool', closed: 'bool', sessionId: 'str' }, 20, { example: { sessionId: 'work' } }),+ 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 AD notify_user FIRST.' }, { ok: 'bool', sessionId: 'str', tabId: 'str', windowId: 'int', 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. TWO-STEP: call 1 {windowId} returns overrideTicket + willDestroy; call 2 {windowId,overrideTicket,attestOrphan:true,reason} closes it. NEVER closes another live thread\'s window.', { windowId: 'required int', 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.', { profile: P }, { ok: 'bool', sessions: '[{id,windowId,tabCount,active,state,bounds,url}]', count: 'int' }, 20, { example: {} }),+ 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: {} }),@@ -891,7 +955,7 @@ function dispatch(verb, args) { d('nbrowser_profile_unblock', 'Remove a profile from the blocklist.', { profile: 'required str' }, { ok: 'bool', blocklist: '[str]' }, 15, { example: { profile: 'edge:[email protected]' } }), 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 a window.', { sessionId: S, url: 'required str', userRequestedForeground: 'optional bool - activate the tab 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_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' } }),@@ -915,6 +979,7 @@ function dispatch(verb, args) { 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' } }),@@ -1045,10 +1110,10 @@ function dispatch(verb, args) { 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=flash the browser taskbar on come-look moments (page load/screenshot/error/done) [default]; chatty=flash on EVERY action (for users who want to watch the AI); off=no auto-flash.' });+ 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; nbrowser_flash still works manually.' : 'Will flash only on come-look moments (page load, screenshot, error, done).';+ 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') {@@ -1122,6 +1187,9 @@ function dispatch(verb, args) { 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.' }); } 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.' });@@ -1201,21 +1269,29 @@ function onHostFrame(frame) { // feature on open (a fresh AI thread must discover it - it isn't obvious). if (p.verb === 'nbrowser_open_window') { const sid = payload.sessionId;- chromeHwnds().then((after) => {- const neu = [...after].find((h) => !preOpenHwnds.has(h)); // the window that appeared = ours- if (neu) {- lastHwnd = neu;- if (sid) sessionHwnd.set(sid, neu);- // the "Adom is driving your browser" overlay badge - on for the window's whole driven life- taskbar(neu, { overlay: { badge: 'adom', tooltip: 'Adom is driving this browser' } });+ // Robust hwnd capture: the diff can miss on the first pass (window not painted yet), so retry+ // briefly so a window never ends up silently UNBADGED.+ (async () => {+ for (let i = 0; i < 4; i++) {+ let neu;+ try { const after = await chromeHwnds(); neu = [...after].find((h) => !preOpenHwnds.has(h)); } catch {}+ if (neu) { lastHwnd = neu; if (sid) sessionHwnd.set(sid, neu); taskbar(neu, { overlay: { badge: 'adom', tooltip: 'Adom is driving this browser' } }); return; }+ await new Promise((r) => setTimeout(r, 250)); }- }).catch(() => {});+ })(); payload = { ...payload, _taskbarHint: flashHint() }; }- if (p.verb === 'nbrowser_close_window') { // clean up the taskbar when we stop driving+ // Badge any adopted SPAWNED windows surfaced by owned/list_windows (they have their own hwnd, no+ // sessionId, so we correlate by bounds on-demand — there is no push channel).+ if (p.verb === 'nbrowser_owned' || p.verb === 'nbrowser_list_windows') { badgeSpawnsIn(payload).catch(() => {}); }+ 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) { taskbar(h, { progress: { state: 'none' }, overlay: { badge: 'none' }, flash: 'stop' }); if (sid) sessionHwnd.delete(sid); }+ if (h && p.verb === 'nbrowser_close_window') { taskbar(h, { progress: { state: 'none' }, overlay: { badge: 'none' }, flash: 'stop' }); if (sid) sessionHwnd.delete(sid); }+ // 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); resolve_ok(p.resolve, payload);
extension/manifest.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Adom for Chrome & Edge", "short_name": "Adom",- "version": "0.0.23",+ "version": "0.1.0", "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup.", "minimum_chrome_version": "116", "background": {
extension/src/cdp.js+82−2@@ -7,9 +7,18 @@ // Banner policy: attach lazily on first CDP use of a tab, auto-detach after IDLE_MS of no CDP use, so // the banner only shows while actively driving. On Adom-managed machines the browser is launched with // --silent-debugger-extension-api, which removes it entirely.+//+// DEBOUNCE (John, 2026-07-14): the banner slides IN on attach and OUT on detach, and each transition+// REFLOWS the page (content shifts up/down), which moves where a click lands and ruins the user's own+// clicks in a window they're looking at. A short idle window made it flicker in/out between every verb+// burst (think/page-load pauses > 8s detached it, then the next verb re-attached). So IDLE_MS is long+// enough to span normal per-verb gaps: the banner stays STEADY through a drive session and only slides+// out once the AI has genuinely stopped for a while (not left up forever - screen real estate matters).+// We also NEVER attach to a tab the caller didn't explicitly target (see resolveTabId requireTarget), so+// the banner can only ever appear on a window the AI is actually driving, not the user's active tab. const PROTO = '1.3';-const IDLE_MS = 8000;+const IDLE_MS = 45000; // was 8000 — long enough to not flicker between verbs, short enough to not linger const attached = new Map(); // tabId -> idleTimer function debuggee(tabId) { return { tabId }; }@@ -94,8 +103,13 @@ export async function screenshot(tabId, { fullPage = false, maxWidth = 1568, res }; } } catch (e) {}- return { ok: true, base64: out.base64, width: iw, height: ih, origWidth: ow, origHeight: oh,+ const shot = { ok: true, base64: out.base64, width: iw, height: ih, origWidth: ow, origHeight: oh, resized: !!out.resized, sizeKB: Math.round((out.base64.length * 3 / 4) / 1024), viewport };+ // A native JS dialog does NOT appear in a CDP capture (it's browser chrome, not page content), so tell+ // the AI explicitly - otherwise it screenshots, sees a normal page, and never realizes a modal is up.+ const dlg = _openDialogs.get(Number(tabId));+ if (dlg) shot._modal = `⚠️ a native ${dlg.type} dialog is open ("${(dlg.message || '').slice(0, 120)}") - it is NOT in this screenshot (dialogs are browser chrome) and it is BLOCKING the page. Dismiss it with nbrowser_handle_dialog {sessionId, accept:true|false} before doing anything else.`;+ return shot; } // ---- nbrowser_eval / nbrowser_evaluate ------------------------------------@@ -525,6 +539,7 @@ async function resolveCoords(tabId, args, rich) { // args.cursor:true shows the gliding visual cursor (demos); off by default for fast automation. export async function inputDispatch(tabId, args) { await attach(tabId);+ const _m = modalGuard(tabId); if (_m && !args.resolveOnly) return _m; // a native dialog is blocking the page const type = (args.type || 'click').toLowerCase(); const showCursor = args.cursor === true; const resolveOnly = args.resolveOnly === true; // dry-run: resolve + return finder, dispatch NOTHING@@ -556,6 +571,33 @@ export async function inputDispatch(tabId, args) { return out; } const { x, y, info } = c;+ // GUARD ambiguous_target — clicking BY TEXT that matches >1 visible element is refused; demand a+ // disambiguating selector (or pass acceptAmbiguous:true / index to override). Stops the AI clicking the+ // wrong one of two "Next"/"Back" buttons or three "Yes" radios.+ if (/click/.test(type) && info.via === 'text' && Number(info.candidates) > 1 && args.acceptAmbiguous !== true && args.index == null && !resolveOnly) {+ return { ok: false, errorCode: 'ambiguous_target',+ error: `ambiguous_target: text "${args.text}" matches ${info.candidates} visible elements — refusing to guess which to click.`,+ _hint: 'Pass a unique selector (nbrowser_click {selector:"…"}), or {debug:true} to see the numbered candidate overlay and pick one, or acceptAmbiguous:true to accept the top-scored match.',+ ...(wantFinder ? { finder: await buildFinder(tabId, c.query || {}, c.candidates || [], c.chosenIndex == null ? 0 : c.chosenIndex, x, y) } : {}) };+ }+ // GUARD stale_coordinates — a raw {x,y} pointer verb is only safe if the page hasn't scrolled since+ // those coords were computed. Require the scroll baseline (atScrollX/atScrollY, from a screenshot's+ // coordMap) and refuse if it drifted — a cached pixel silently becomes a DIFFERENT element after a+ // scroll/re-render (the "Next" that turned into "Back"). Re-resolve by selector/text instead.+ if (/click|move|hover/.test(type) && !args.selector && !args.text && (args.x != null || args.y != null) && !resolveOnly) {+ const live = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+ const bx = args.atScrollX, by = args.atScrollY;+ if (bx == null && by == null) {+ return { ok: false, errorCode: 'stale_coordinates',+ error: 'stale_coordinates: a raw {x,y} click carries no freshness proof — the page may have scrolled/re-rendered since those coords were valid.',+ _hint: 'Prefer nbrowser_click {selector:"…"} or {text:"…"} (re-resolved live). If you must click by pixel, pass atScrollX/atScrollY from your latest screenshot coordMap so I can verify the page has not scrolled.' };+ }+ if ((bx != null && Math.abs(Number(bx) - live.sx) > 2) || (by != null && Math.abs(Number(by) - live.sy) > 2)) {+ return { ok: false, errorCode: 'stale_coordinates',+ error: `stale_coordinates: the page scrolled since these coords were captured (baseline ${bx},${by}; now ${live.sx},${live.sy}) — {x:${args.x},y:${args.y}} now points at different content.`,+ _hint: 'Re-screenshot and re-resolve, or click by selector/text. Never reuse pixel coordinates across a scroll or re-render.' };+ }+ } // Build the debug finder (candidates + scores + annotated overlay shot) BEFORE acting, so a // resolveOnly dry-run and a debug click see the same pre-action page state. const finder = wantFinder ? await buildFinder(tabId, c.query || {}, c.candidates || [], c.chosenIndex == null ? 0 : c.chosenIndex, x, y) : null;@@ -692,11 +734,49 @@ export async function detectLoginForm(tabId) { return { ok: true, action, recommendation, ...f, _hint: 'ALWAYS call this before driving a login. If passwordFilled=true, the browser already autofilled it — just submit (background); never reach for the autofill popup or SSO when the form is already filled.' }; } +// ---- native JS dialogs (alert/confirm/prompt/beforeunload) — modal detection ----------------------+// These are NOT OS child windows (no hwnd) - Chrome renders them itself and they BLOCK the page's main+// thread, so an AI that doesn't know one is up will keep firing clicks/eval into a frozen page (real+// incident: an Intuit "questionnaire saved" alert sat over the form while the AI kept trying to fill it).+// We catch Page.javascriptDialogOpening (the debugger is attached during any CDP drive), record it, and+// surface it on the session's NEXT verb (modal_open) with an easy way to dismiss it.+const _openDialogs = new Map(); // tabId -> { type, message, defaultPrompt, ts } (currently OPEN)+const _recentDialogs = []; // persistent ring: every dialog we SAW (survives auto-dismiss) for nbrowser_events+export function dialogFor(tabId) { return _openDialogs.get(Number(tabId)) || null; }+export function drainDialogs() { const d = _recentDialogs.splice(0); return d; }+// nbrowser_handle_dialog — click OK/Cancel (or answer a prompt) on the native dialog. accept:true = OK/Yes/Leave.+export async function handleDialog(tabId, accept, promptText) {+ await attach(tabId);+ const dlg = _openDialogs.get(Number(tabId));+ try { await send(tabId, 'Page.handleJavaScriptDialog', { accept: accept !== false, promptText: promptText != null ? String(promptText) : undefined }); }+ catch (e) { return { ok: false, error: 'no dialog to handle (it may have already closed): ' + ((e && e.message) || e), _hint: 'The modal may have closed. Re-check with a nbrowser_screenshot.' }; }+ _openDialogs.delete(Number(tabId));+ return { ok: true, handled: true, accepted: accept !== false, was: dlg || null, tabId: String(tabId), _hint: 'Dialog dismissed — the page is unblocked. Continue driving; re-screenshot to see the current state.' };+}+// Guard called at the start of page-ACTION verbs: if a native dialog is blocking this tab, refuse the+// action and tell the AI to deal with the modal first (it cannot succeed while the page is frozen).+export function modalGuard(tabId) {+ const dlg = _openDialogs.get(Number(tabId));+ if (!dlg) return null;+ return { ok: false, errorCode: 'modal_open',+ error: `modal_open: a native ${dlg.type} dialog is blocking this tab: "${(dlg.message || '').slice(0, 140)}". The page is FROZEN until it is handled - your clicks/typing/eval will not work.`,+ dialog: { type: dlg.type, message: dlg.message },+ _hint: `Handle it first: nbrowser_handle_dialog {sessionId, accept:true} to press OK/Yes/Leave, or accept:false to Cancel/Stay${dlg.type === 'prompt' ? ', with text:"…" to answer a prompt' : ''}. Then re-screenshot and continue.` };+}+ // ---- nbrowser_errors: buffered console/page errors per tab ------------------ const errorBuffers = new Map(); // tabId -> [{type,text,ts}] chrome.debugger.onEvent.addListener((source, method, params) => { const tabId = source.tabId; if (tabId == null) return;+ if (method === 'Page.javascriptDialogOpening') {+ const d = { type: params.type || 'alert', message: params.message || '', defaultPrompt: params.defaultPrompt || '', ts: Date.now() };+ _openDialogs.set(tabId, d);+ _recentDialogs.push({ event: 'dialog', tabId: String(tabId), dialogType: d.type, message: d.message, ts: d.ts });+ while (_recentDialogs.length > 20) _recentDialogs.shift();+ return;+ }+ if (method === 'Page.javascriptDialogClosed') { const o = _openDialogs.get(tabId); _openDialogs.delete(tabId); if (o) _recentDialogs.push({ event: 'dialog_closed', tabId: String(tabId), dialogType: o.type, result: params.result, ts: Date.now() }); return; } let entry = null; if (method === 'Runtime.exceptionThrown') { const e = params.exceptionDetails || {};
extension/src/native.js+332−35@@ -4,15 +4,32 @@ // 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+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] } }); } catch {}+ 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)));@@ -27,14 +44,23 @@ async function ensureLoaded() { // 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);+ 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))) {@@ -42,6 +68,9 @@ async function reconcile() { 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(); } @@ -92,12 +121,59 @@ chrome.windows.onRemoved.addListener(async (windowId) => { _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';@@ -140,6 +216,15 @@ const OPEN_WINDOW_HINT = '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;@@ -148,6 +233,73 @@ function requireTarget(args, verb) { `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;@@ -172,10 +324,31 @@ const POLITE_HINT = '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 to watch live. Legacy focused:true /-// active:true are NOT sufficient by themselves (an impatient AI sets them) - they are IGNORED unless-// userRequestedForeground:true is also present.-function userWantsForeground(args) { return !!(args && args.userRequestedForeground === true); }+// 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 an AD notify (adom-desktop 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 an AD 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; } @@ -234,6 +407,13 @@ 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);@@ -248,7 +428,10 @@ async function resolveTabId(args, opts = {}) { } // Bare call - no sessionId, no tabId.- if (opts.requireOwned) requireTarget(args, verb); // mutating → refuse; never touch the user's tab+ // 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);@@ -300,6 +483,7 @@ function waitForLoad(tabId, timeoutMs = 30000) { // ---- 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@@ -307,17 +491,18 @@ export async function openWindow(args = {}) { 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 || newSessionId();+ 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]) || {}; const res = { ok: true, sessionId: sid, tabId: String(tab.id), windowId: win.id, url: tab.url || args.url, _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 AD desktop_screenshot_window (WGC); both capture a background/occluded window, so NEVER foreground it to grab a shot. Raise it ONLY if the user EXPLICITLY asked to watch live (pass userRequestedForeground:true). Remember to nbrowser_close_window {sessionId:"' + sid + '"} when done so you don\'t leave abandoned windows.',+ ' Screenshot it where it sits with nbrowser_screenshot (CDP) or AD 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 withWarn(res, warn);+ return foregroundNote(args, withWarn(res, warn), sid); } export async function closeWindow(args = {}) {@@ -327,12 +512,24 @@ export async function closeWindow(args = {}) { 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 };+ 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();@@ -345,8 +542,8 @@ export async function switchWindow(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 ? 'Raised for the user (explicit userRequestedForeground:true).' : 'Targeted in the BACKGROUND, not raised — drive it by passing this sessionId. ' + POLITE_HINT };- return withWarn(res, warn);+ 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.@@ -367,7 +564,7 @@ export async function windowState(args = {}) { 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 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);+ 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';@@ -381,24 +578,32 @@ export async function windowState(args = {}) { // 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 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 userRequestedForeground:true. ' + POLITE_HINT : '') }, warn);+ 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() {+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]));- const sessions = wins.map((w) => ({- id: tracked.get(w.id) || String(w.id),- windowId: w.id,- tracked: tracked.has(w.id),- 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,- }));- return { ok: true, sessions, count: sessions.length };+ 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,+ };+ });+ 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)@@ -424,6 +629,88 @@ export async function resyncSessions() { }; } +// 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 ABE 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();@@ -433,6 +720,7 @@ export async function openTab(args = {}) { "\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.@@ -445,7 +733,7 @@ export async function openTab(args = {}) { // 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 withWarn(res, warn);+ return foregroundNote(args, withWarn(res, warn), args.sessionId); } export async function closeTab(args = {}) {@@ -465,7 +753,14 @@ export async function listTabs(args = {}) { const windowId = await resolveWindowId(args); const tabs = await chrome.tabs.query(windowId != null ? { windowId } : {}); const active = tabs.find((t) => t.active);- return { ok: true, count: tabs.length, activeTabId: active ? String(active.id) : null, tabs: tabs.map(tabView) };+ 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 ------------------------------------------------------------@@ -476,7 +771,9 @@ export async function navigate(args = {}) { 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- return withWarn({ ok: true, url: t.url, tabId: String(tabId), sessionId: args.sessionId }, warn);+ 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 = {}) {@@ -730,12 +1027,12 @@ export async function focus(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 {+ return foregroundNote(args, { ok: true, tabId: String(tabId), windowId: tab.windowId, title: tab.title, raised: fg, _hint: fg- ? 'Raised for the user (explicit userRequestedForeground:true).'- : `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). Raise it only on explicit user request (pass userRequestedForeground:true).`,- };+ ? 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)
extension/src/sw.js+30−15@@ -128,12 +128,14 @@ async function onMessage(msg) { // First-contact safety banner: teach the cardinal rule once per SW lifetime, in-band. if (!_safetySent && payload && typeof payload === 'object' && !Array.isArray(payload)) { _safetySent = true;- payload._safety = '\u26d4 Cardinal rules: (1) only drive windows YOU open (nbrowser_open_window \u2192 sessionId); ' +- 'bare mutating verbs and targets in user-opened windows are refused (no_target / not_owned). ' +- '(2) Work in the BACKGROUND. NEVER raise or maximize a window over the user\u2019s other work, and NEVER fall back ' +- 'to OS-level launches (Start-Process/launch) or bring_to_front/maximize to surface a window \u2014 it is rude and ' +- 'is auto-reverted + logged as a focus_violation. Foregrounding is the user\u2019s choice alone (userRequestedForeground:true). ' +- 'Start with nbrowser_profiles to pick the profile matching the identity your task needs.';+ payload._safety = '\u26d4 Cardinal rules: (1) REGISTER every window: nbrowser_open_window needs sessionId + thread + purpose ' ++ '(who is driving + why) or it is refused (session_unregistered). (2) Only drive windows YOU open; bare mutating verbs ' ++ 'and targets in user-opened windows are refused (no_target / not_owned). (3) A page you drive can SPAWN windows ' ++ '(a Gmail-Contacts email click opens a compose window) \u2014 those are auto-adopted; your next verb is refused ' ++ '(unacknowledged_spawns) until you nbrowser_owned then nbrowser_cleanup. At task end, close everything you own. ' ++ '(4) Work in the BACKGROUND. NEVER raise/maximize over the user\u2019s work or fall back to OS-level launches \u2014 it is auto-reverted + ' ++ 'logged as a focus_violation. Foregrounding is the user\u2019s choice alone (userRequestedForeground:true). ' ++ 'Start with nbrowser_profiles to pick the right profile.'; } reply(id, true, payload); } catch (e) {@@ -146,8 +148,10 @@ async function onMessage(msg) { async function dispatch(verb, args) { switch (verb) { // --- meta -------------------------------------------------------------- case 'nbrowser_status':- return { ok: true, ext: 'adom-browser-extension', version: VERSION, engine: 'native', browser: detectBrowser() };+ case 'nbrowser_status': {+ const own = native.ownershipSummary(); // dirty_session nag: surfaced while owned/spawned windows remain+ return { ok: true, ext: 'adom-browser-extension', version: VERSION, engine: 'native', browser: detectBrowser(), ownedWindows: own.ownedWindows, spawnedWindows: own.spawnedWindows, sessions: own.sessions, _hint: own._nag };+ } case 'nbrowser_whoami': { // Force a fresh Google-email resolve + RE-announce, so the bridge re-keys this connection to // "chrome:[email protected]" eagerly (fixes "pending-N / empty email until driven"). Returns the email.@@ -189,7 +193,10 @@ async function dispatch(verb, args) { case 'nbrowser_open_window': return native.openWindow(args); case 'nbrowser_close_window': return native.closeWindow(args); case 'nbrowser_switch_window': return native.switchWindow(args);- case 'nbrowser_list_windows': return native.listWindows();+ case 'nbrowser_list_windows': return native.listWindows(args);+ case 'nbrowser_owned': return native.owned(args); // opened + spawned windows this session owns+ case 'nbrowser_cleanup': return native.cleanup(args); // close ONLY this session's spawned windows+ case 'nbrowser_force_close': return native.forceClose(args); // last-resort orphan override (two-step attestation) case 'nbrowser_window_state': return native.windowState(args); case 'nbrowser_fullscreen': return native.windowState({ ...args, state: args.state || 'fullscreen' }); case 'nbrowser_login_state': return native.loginState(args);@@ -255,10 +262,15 @@ async function dispatch(verb, args) { // --- errors / lifecycle events --------------------------------------- case 'nbrowser_errors':- return cdp.getErrors(await native.resolveTabId(args), args.clear !== false);- case 'nbrowser_events':- // window/tab closes (incl. byUser) since last drain — so the AI learns the user closed things- return native.drainEvents();+ return cdp.getErrors(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_errors' }), args.clear !== false);+ case 'nbrowser_events': {+ // window/tab closes (incl. byUser) + spawned_window/focus_violation since last drain — so the AI+ // learns the user closed things, a page spawned a window, or a native JS dialog fired.+ const e = await native.drainEvents();+ const dlgs = cdp.drainDialogs();+ if (dlgs.length) { e.events = (e.events || []).concat(dlgs); e.count = (e.events || []).length; }+ return e;+ } // --- data (M4: authed fetch via the user's cookies, downloads) -------- case 'nbrowser_fetch_url': return native.fetchUrl(args);@@ -272,15 +284,18 @@ async function dispatch(verb, args) { // --- CDP: screenshot / eval ------------------------------------------ case 'nbrowser_screenshot':- return cdp.screenshot(await native.resolveTabId(args), { fullPage: !!args.fullPage, maxWidth: args.maxWidth || 1568, resize: true });+ return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot' }), { fullPage: !!args.fullPage, maxWidth: args.maxWidth || 1568, resize: true }); case 'nbrowser_screenshot_full_res':- return cdp.screenshot(await native.resolveTabId(args), { fullPage: !!args.fullPage, resize: false });+ return cdp.screenshot(await native.resolveTabId(args, { requireTarget: true, verb: 'nbrowser_screenshot_full_res' }), { fullPage: !!args.fullPage, resize: false }); case 'nbrowser_eval': case 'nbrowser_evaluate': { const tabId = await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_eval' });+ const m = cdp.modalGuard(tabId); if (m) return m; // a native JS dialog is blocking the page const result = await cdp.evaluate(tabId, String(args.expr ?? args.expression ?? '')); return { ok: true, result, tabId: String(tabId), sessionId: args.sessionId }; }+ case 'nbrowser_handle_dialog': // press OK/Cancel (or answer a prompt) on a native JS dialog blocking the page+ return cdp.handleDialog(await native.resolveTabId(args, { requireOwned: true, verb: 'nbrowser_handle_dialog' }), args.accept, args.text ?? args.promptText); default: return { ok: false, error: `verb not implemented yet: ${verb}`,
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.0.41",- "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.0.41/native-browser-bridge-v0.0.41.bin",- "sha256": "b16240ba924d648ef73466ae7ae56497db9fe65feaa5bf9b90f824d2b774e1bd",- "size": 45507,- "released_at": "2026-07-08T00:00:00Z"+ "version": "0.1.0",+ "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.0/native-browser-bridge-v0.1.0.bin",+ "sha256": "93d416ed71c3922a1b48fba3b955bc55a697846805043c422f5c73bd658f528d",+ "size": 52755,+ "released_at": "2026-07-14T00:00:00Z" }
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "adom-browser-extension",- "version": "0.3.25",+ "version": "0.4.0", "type": "app", "title": "Adom Native Browser Extension (Chrome + Edge)", "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem: the nbrowser_* native-browser bridge (counterpart to pup). Multi-profile driving, a read-only credential-library query over the browser's own saved logins (Chrome + Edge), and hands-free vendor login automation, so chip CAD / datasheets download past every login wall in your real signed-in session.",
skills/driving-the-desktop/SKILL.md+1−1@@ -80,7 +80,7 @@ it **auto-hides after 60s** (refreshed on each show/move). Extend with `ttlMs`, hide yourself. **No need to hide "to be safe"** - only hide early to clear it sooner. ## Recipe - open a `chrome://`/`edge://` page (CDP can't) - BACKGROUND (AD 1.8.176)-Open a background window first (`nbrowser_open_window {profile, url:"https://example.com", focused:false}`,+Open a background window first (`nbrowser_open_window {sessionId:"drive", thread:"<you>", purpose:"<why>", profile, url:"https://example.com"}`, then find its `hwnd`), then: ``` desktop_navigate {hwnd:<W>, url:"edge://extensions"} # SetValue omnibox + posted Enter - NO foreground
skills/driving-the-extension-demo/SKILL.md+1−1@@ -71,7 +71,7 @@ nbrowser_window_state {"sessionId":"demo","left":60,"top":50,"width":1050,"heigh nbrowser_window_state {"sessionId":"demo","state":"maximized"} nbrowser_list_windows {} # -> sessions[].bounds {left,top,width,height}; halve the width # open a 2nd window and tile (work area ~1707x1020 on a 150%-scaled 2560px screen):-nbrowser_open_window {"sessionId":"demo2","url":"https://www.ultralibrarian.com"}+nbrowser_open_window {"sessionId":"demo2","thread":"demo-thread","purpose":"UltraLibrarian walkthrough demo","url":"https://www.ultralibrarian.com"} nbrowser_window_state {"sessionId":"demo","left":0,"top":0,"width":853,"height":1015} nbrowser_window_state {"sessionId":"demo2","left":853,"top":0,"width":853,"height":1015} ```
skills/driving-the-extension/SKILL.md+49−14@@ -18,8 +18,9 @@ either.** That yanks their live page out from under them and is rightly seen as to **everything you do for your own needs** - driving a workflow, reading a doc, and especially **grabbing a screenshot** (e.g. of a `chrome://extensions` / `edge://extensions` page for a guide). -The rule, every time: **open a NEW window** (`nbrowser_open_window {url}`, or a fresh OS window you-then `desktop_navigate`), do your work and capture in THAT window, and **close it when done.** The+The rule, every time: **open a NEW window** (`nbrowser_open_window {sessionId, thread, purpose, url}`, or+a fresh OS window you then `desktop_navigate`), do your work and capture in THAT window, and **close it+when done** (`nbrowser_close_window {sessionId, includeSpawned:true}`). The only thing you may do to one of their existing windows is `desktop_bring_to_front` to surface it for them - never to drive or navigate it. @@ -44,8 +45,9 @@ Before driving ANYTHING, in this order: `[email protected]` → drive `chrome:[email protected]`, not whatever profile happens to be focused). If the needed profile isn't listed, ask the user to open a window in it — don't improvise in another one.-3. `nbrowser_open_window {profile:"<key>", url:…}` → a BACKGROUND window auto-pinned to that profile.-4. Drive ONLY via that `sessionId`, and `nbrowser_close_window` when done.+3. `nbrowser_open_window {sessionId:"<task id>", thread:"<which AI thread>", purpose:"<why, human-readable>", profile:"<key>", url:…}` → a BACKGROUND window auto-pinned to that profile. **All three of sessionId/thread/purpose are REQUIRED** (registration) or it refuses `session_unregistered`; `purpose` shows in the Session Monitor.+4. Drive ONLY via that `sessionId`. If a page you drive **spawns** a window (a Gmail-Contacts email click pops a compose; `target=_blank`/OAuth popups), your next verb is refused `unacknowledged_spawns` → `nbrowser_owned {sessionId}` to see it, `nbrowser_cleanup {sessionId}` to close the litter.+5. **End the task by closing what you own:** `nbrowser_close_window {sessionId, includeSpawned:true}`. ## ⛔ Background by default - NEVER foreground unless the user asks @@ -54,7 +56,12 @@ in another app. So your work MUST **default to the background, especially when y windows**: a window that jumps to the front interrupts whatever they were doing and (worse) their next keystrokes land in the control you just surfaced. Background is not politeness, it is the **default posture**; foreground is **opt-in, only when the user explicitly asks to watch.**-**Never raise a window to the foreground** for your own needs.+**Never raise a window to the foreground** for your own needs. 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 killed Chrome's password dropdown). As of v0.1.0 a bare `userRequestedForeground:true`+is **not enough**: without a `foregroundReason` the window stays background. To genuinely foreground+(some auth pages need it): **first** `adom-desktop notify_user` to warn the user, **then** pass+`userRequestedForeground:true` + `foregroundReason:"<why>"`, do the one thing, and return to background. The verbs are built for this: `nbrowser_open_window` opens **unfocused**, `open_tab` opens `active:false`, `switch_window` does **not** raise - leave those defaults alone. Capture without foregrounding: **`nbrowser_screenshot` (CDP) and `desktop_screenshot_window` (WGC) both capture a@@ -65,7 +72,7 @@ never a reason to pull a window forward to grab a shot or a recording. trap: do **NOT** launch the browser yourself - `msedge` / `chrome --new-window` (via `launch` / `launch_process`) **raises the new window to the foreground.** Instead: -1. `nbrowser_open_window {url:"about:blank"}` → a fresh **background** window (note its `hwnd`).+1. `nbrowser_open_window {sessionId:"guide", thread:"<you>", purpose:"screenshot chrome://extensions for a guide", url:"about:blank"}` → a fresh **background** window (note its `hwnd`). 2. `desktop_navigate {hwnd, url:"chrome://extensions"}` → loads the `chrome://` page **in the background**, no foreground. 3. `desktop_screenshot_window {hwnd}` → WGC captures it where it sits.@@ -114,9 +121,14 @@ read/navigate, prefer native verbs and the bar never shows. ## Verb surface - **meta:** `status`, `ping`, `help` (full inline reference), `dev_reload` (self-completing 2-pass; throttled 1/90s)-- **windows/sessions:** `open_window`, `close_window`, `switch_window`, `list_windows` (returns- `bounds`+`state`), `window_state`/`fullscreen` (maximize·fullscreen·normal·minimize, or- `left/top/width/height` to move+resize - native, no bar; F11-via-key does NOT work), `rescan`+- **windows/sessions:** `open_window` (REQUIRES sessionId+thread+purpose), `close_window`+ (`includeSpawned:true` also closes spawns), `switch_window`, `list_windows` (returns `bounds`+`state`+an+ `owner` label agent-opened|agent-spawned|user; `mine:true` filters to yours), `window_state`/`fullscreen`+ (maximize·fullscreen·normal·minimize, or `left/top/width/height` to move+resize - native, no bar;+ F11-via-key does NOT work), `rescan`, `resync_sessions`+- **ownership / cleanup:** `owned {sessionId}` (opened + SPAWNED windows/tabs a driven page opened;+ acknowledges spawns) · `cleanup {sessionId}` (close ONLY what this session spawned, never the user's) ·+ `force_close {windowId}` (two-step attested last resort for a TRUE orphan; never another thread's window) - **account / credentials:** `login_state {url}` (signed-in NOW? via `chrome.cookies`) · `credentials {match?, browser?, profile?}` - a **read-only QUERY of the browser's OWN saved-login store** (we don't store or replace anything; Chrome/Edge stay the source of truth). Returns@@ -257,7 +269,14 @@ This is **not** screenshot→eyeball-a-pixel→click automation, so it does **no drives is SAFE** (a nice `nbrowser_caption`) - the opposite of the usual "don't touch anything" fear. - **The only path that CAN go stale** is passing **raw `{x,y}`** to `input_dispatch` (a literal pixel, no re-resolve). Prefer `text`/`selector` - they re-resolve every call. AD is **not** involved in click- xy at all (it only does OS-level foreground/taskbar/window-screenshots).+ xy at all (it only does OS-level foreground/taskbar/window-screenshots). **This is now ENFORCED+ (`stale_coordinates`, v0.1.0):** a raw `{x,y}` click is refused unless you pass `atScrollX`/`atScrollY`+ from your latest screenshot's coordMap AND the page hasn't scrolled since (the "Next" pixel that became+ "Back" after a re-render). Just click by `text`/`selector`.+- **Click-by-`text` that matches >1 visible element is refused (`ambiguous_target`, v0.1.0)** - two+ "Next"/"Back" buttons, three "Yes" radios. Pass a unique `selector`, or `{debug:true}` to see the+ numbered candidate overlay and pick one, or `acceptAmbiguous:true` to take the top-scored match. Verify+ `matchedText`/`changed` in the response after a text click. ```bash # click by VISIBLE TEXT (finder computes xy) - returns {x,y,matchedText} adom-desktop -t AdomLapper nbrowser_click '{"sessionId":"s1","text":"Pay","cursor":true,"intent":"Clicking the Pay button"}'@@ -279,6 +298,16 @@ cursor (teal arrow + pulsing ring + "Adom" label) glides to the target **while n ("Clicking the Pay button", "Hovering the card to reveal the tooltip"). Builds user trust - they always see what the AI is doing and why. Mimics Hydrogen Desktop's cursor. +### Native JS dialogs (alert / confirm / prompt) - the invisible blocker+A native `alert()`/`confirm()`/`prompt()`/`beforeunload` is **not an OS window (no hwnd)** and does+**not appear in a screenshot** (Chrome renders it as browser chrome), but it **FREEZES the page's main+thread**. Symptom: your clicks/typing/eval suddenly do nothing and you "can't fill the form" (real+incident: an Intuit "questionnaire saved" alert sat over the form while the AI kept trying). ABE now+**auto-detects** it: the blocked verb returns `modal_open` (with the dialog text) and a screenshot carries+a `_modal` note. **Dismiss it:** `nbrowser_handle_dialog {sessionId, accept:true}` (OK/Yes/Leave),+`accept:false` (Cancel/Stay), or `text:"…"` to answer a prompt - then re-screenshot and continue. If an+action mysteriously stops working, suspect a dialog.+ ## Screenshots ```bash adom-desktop -t AdomLapper nbrowser_screenshot '{"sessionId":"s1","toFile":true}' # writes a laptop PNG, returns its path@@ -304,11 +333,17 @@ page, so those coords are page-absolute. ## Taskbar status - flash, progress, the "Adom is driving" overlay badge Paint glanceable status on the **real browser window's Windows taskbar button** (the hwnd is captured at-`open_window`; an **Adom overlay badge** goes on automatically while driving - a trust tell). Driven via-AD's `desktop_taskbar` (1.8.152+).+`open_window`; an **Adom overlay badge** goes on automatically while driving - a trust tell). It lands on+**opened windows at open, and on adopted SPAWNED windows** when you call `nbrowser_owned` /+`nbrowser_list_windows` (that is when the bridge learns their hwnd). If a badge is ever missing (immersive+fullscreen hides the taskbar; a diff missed), re-apply `nbrowser_taskbar {overlay:{badge:"adom"}}`. Driven+via AD's `desktop_taskbar` (1.8.152+). - `nbrowser_flash {sessionId}` / `nbrowser_flash_stop` - orange "come look" cue (auto-clears on focus).-- `nbrowser_flash_mode {mode}` - **`quiet`** (default; flash only on come-look moments: load/screenshot/- error/done) · **`chatty`** (flash every action, for users who want to watch) · **`off`**.+- `nbrowser_flash_mode {mode}` - **`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`** (flash every action, for users who want to watch) ·+ **`off`** (NO auto-flash at all - the user opt-out; set this the moment the user says the flashing is+ annoying). - `nbrowser_taskbar {progress:{state,value?}, overlay:{badge,tooltip?}}` - progress states: `normal`(green) · **`paused`(YELLOW - use for captcha/2FA/login human-gates)** · `error`(red) · `indeterminate`(marquee) · `none`. Overlay `badge:"adom"|"none"`.
skills/installing-the-extension/SKILL.md+2−2@@ -103,7 +103,7 @@ Rules, in order: ## The flow (background; never foreground the user's work) Open a fresh background window you own, get its `hwnd`, and drive **that** (never the user's tabs): ```-nbrowser_open_window {profile, url:"about:blank", focused:false} # your own window; note hwnd <W>+nbrowser_open_window {sessionId:"install", thread:"<you>", purpose:"driving chrome://extensions to load the extension", profile, url:"about:blank"} # your own window; note hwnd <W> desktop_navigate {hwnd:<W>, url:"chrome://extensions"} # edge://extensions for Edge - scheme MUST match the browser desktop_screenshot_window {hwnd:<W>} # verify the page loaded (posted Enter is app-specific) ```@@ -208,7 +208,7 @@ button) and poll `notify_response {id}` until they click Start. Verified round-t ### Step D - first useful action Drive a real verb in a window **you** open (see `native-browser-safety`): e.g.-`nbrowser_open_window {url:"https://www.digikey.com", focused:false}` -> `nbrowser_screenshot`.+`nbrowser_open_window {sessionId:"verify", thread:"<you>", purpose:"verify the extension drives", url:"https://www.digikey.com"}` -> `nbrowser_screenshot`. ## Chrome AND Edge They are independent. Repeat the flow per browser (matching scheme: `chrome://` vs `edge://`; the toggle
skills/native-browser-recording/SKILL.md+15@@ -88,6 +88,21 @@ primitive - get it solid and the rest is easy. - Leave the flags on **globally/permanently** - it drains laptop battery. Enter the mode, record, offer to revert. - Upload a raw recording to the wiki without the `-g 30 -keyint_min 30` keyframe re-encode (see demo-recording). +## Pitfalls+- **Blank/black frames** = 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`). pup's Chrome+ has them; a default user Chrome does not - relaunch it with them (never foreground to "fix" it).+- **`method:"getdisplaymedia"`/`"tabcapture"` are REFUSED without `fallbackConfirmed:true`** - a browser+ extension can't silently screen-record (Chrome forces a manual share-picker + a persistent banner). Use+ `method:"wgc"` (delegates to core `desktop_record_window_start`): background, up to 60fps, no picker.+- **Recording paths are on the LAPTOP**, not the container - `pull_file` to fetch them.+- **Do NOT maximize/foreground to "frame" the recording.** WGC captures a background/occluded window; a+ `window_state maximize` that raises the window is auto-reverted (it stays background) - size it with+ explicit `left/top/width/height` bounds instead if you need it bigger.+- **Register the window you record** like any other (`sessionId`+`thread`+`purpose` on `open_window`) and+ **close it when done** (`nbrowser_close_window {sessionId, includeSpawned:true}`) - don't leave litter.+ ## Hand-off Post-production (speedup, voiceover, storyboard review, concat, hero, wiki upload) → **Adom Video Post-Production** (`adom-video-post`), which bundles the `demo-recording` skill. Surface selection, scripting, TTS, the keyframe rule, the cross-surface mechanics → the **demo-recording** skill.
skills/native-browser-safety/SKILL.md+59−14@@ -13,8 +13,37 @@ through logins and captchas pup cannot), and it is exactly why a careless move i rules are not optional polish; one of them exists because an agent wiped a user's other Claude threads. ## ⛔ Cardinal rule: only ever drive a window/tab YOU opened-**Open your own surface and work only in it.** `nbrowser_open_window {url}` gives you a background-window + a `sessionId`; do ALL your driving through that `sessionId`.+**Open your own surface and work only in it.** `nbrowser_open_window` gives you a background window ++a `sessionId`; do ALL your driving through that `sessionId`.++**REGISTER every window (v0.1.0+).** `nbrowser_open_window` now REQUIRES three fields or it refuses+(`session_unregistered`):+```+nbrowser_open_window {sessionId:"<short task id>", thread:"<which AI thread you are>",+ purpose:"<human-readable, e.g. finishing John's IRS signup>", url:"…"}+```+`purpose` shows up in `nbrowser_status` and the Session Monitor so the user can see who is driving each+window and why. No sessionId/thread/purpose = no window.++## ⛔ Track & reap the windows a page SPAWNS (the leaked-compose fix)+A page you drive can open a **new window you did not ask for**: clicking an email in Google Contacts+pops a **Gmail compose window**; `target=_blank` links, `window.open`, OAuth popups, and PDF viewers do+the same. These are standalone windows in **no session** - historically unreachable, so they leaked.++Now they are **auto-adopted** into your session. The moment one appears:+- a `spawned_window` event fires (see `nbrowser_events`), and+- your **next driving verb is refused (`unacknowledged_spawns`)** until you deal with it.++Deal with it: `nbrowser_owned {sessionId}` lists everything you own (`opened` + `spawned`); then either+keep what you need or **`nbrowser_cleanup {sessionId}`** to close the litter (it closes ONLY what you+spawned, never the user's own windows). **End every task** by closing what you own:+`nbrowser_close_window {sessionId, includeSpawned:true}`. If you ever find a TRUE orphan ABE can't+attribute, `nbrowser_force_close` is a deliberately hard, two-step last resort (it can never close+another thread's window).++⚠️ **Pitfall — reading a contact spawns a compose:** to read a person's email/phone in Google Contacts,+**do not click the mailto/email link** (that spawns a compose window). Navigate the person's contact URL+or `nbrowser_fetch_url` the data instead. **NEVER** navigate, reload, close, or repurpose a window or tab the user already had open: - not with `nbrowser_navigate` / `nbrowser_reload` / `nbrowser_close_tab` / `nbrowser_close_window`,@@ -76,19 +105,35 @@ after every open/navigate/switch/state change the bridge checks the z-order - if topmost, it is **auto-sent back to the background** (focus returned to the user's previous window, or the window minimized if the user was in another app) and the response carries a `_warning` + `focusAutoReverted:true`. Each auto-correction is logged as a `focus_violation` event-(`nbrowser_events`) so the user can see when a thread tried to steal focus. The ONLY way to legitimately-raise a window is `userRequestedForeground:true`, and you pass it **only when the user explicitly asked-to watch live** - never to "make sure it worked" or because a background open felt slow. And **never**-fall back to an OS-level launch (`launch_process` / `Start-Process` / `msedge`/`chrome --new-window`) or-`desktop_bring_to_front` to open or surface a window - that is the exact rude behavior the enforcement-exists to stop; if a background open is ever blocked, hand the user a shareable link instead.+(`nbrowser_events`) so the user can see when a thread tried to steal focus.++**Foregrounding takes real work now (v0.1.0+), because it STEALS the user's keyboard focus.** Users+automating the browser almost always have 5 other things in the foreground; 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, twice). So a bare `userRequestedForeground:true` is **no longer enough** -+without a `foregroundReason` the window **stays background**. To genuinely foreground (some auth pages /+browser features truly need it):+1. **Warn the user first** with an AD notify: `adom-desktop notify_user {message:"Adom is about to bring a browser window forward for <reason>"}`.+2. Then pass **`userRequestedForeground:true` + `foregroundReason:"<why>"`**.+3. Do the ONE thing that needed the foreground, then return to the background so you stop capturing focus.+Every justified raise is logged as a `foreground_raised` event. And **never** fall back to an OS-level+launch (`launch_process` / `Start-Process` / `msedge`/`chrome --new-window`) or `desktop_bring_to_front`+to surface a window - that is the exact rude behavior the enforcement exists to stop; if a background+open is ever blocked, hand the user a shareable link instead. -## The "debugging this browser" bar is expected, not a bug-CDP verbs (`click`, `type`, `screenshot`, `eval`, ...) attach `chrome.debugger`, which makes Chrome-show the yellow `"Adom for Chrome & Edge" started debugging this browser` bar. The bridge-lazy-attaches only for those verbs and idle-detaches ~8s later, so it appears while driving and-clears when idle. It is a Chrome security guarantee and **cannot be hidden on consumer Chrome**. If-you only need to read/navigate your own window, prefer the native verbs (no bar).+## The "debugging this browser" bar is expected - and now steadier+CDP verbs (`click`, `type`, `screenshot`, `eval`, ...) attach `chrome.debugger`, which makes Chrome show+the yellow `"Adom for Chrome & Edge" started debugging this browser` bar. It is a Chrome security+guarantee and **cannot be hidden on consumer Chrome** (Adom-managed machines launch with+`--silent-debugger-extension-api`, which removes it). Two things you should know:+- **It slides in/out and REFLOWS the page** - each transition shifts content up/down, which can move+ where a click lands. As of v0.1.0 the idle-detach window is **long (~45s)** so the bar stays STEADY+ through a drive session instead of flickering between verbs; it still clears once you genuinely stop+ (not left up forever). Do a burst of driving, then let it settle - don't attach/detach rapidly.+- **It only appears on a window you TARGETED.** CDP verbs no longer fall back to the user's active tab -+ `nbrowser_screenshot`/`nbrowser_eval`/`nbrowser_errors` need a sessionId/tabId, so the bar can never+ land on the user's own tab. If you only need to read/navigate your own window, prefer the native verbs+ (list/navigate - no bar). ## Credentials and auto-login are gated - and you never see secrets - `nbrowser_credentials` is a **read-only query** of which vendors the user has a saved login for. It
skills/uninstalling-the-extension/SKILL.md+1−1@@ -28,7 +28,7 @@ Like install, this is **our** recipe driving adom-desktop's **generic** OS verbs ## Step 1 - open chrome://extensions in YOUR OWN background window ```-nbrowser_open_window {profile, url:"about:blank", focused:false} # your window; find its hwnd via desktop_list_windows (match a unique title)+nbrowser_open_window {sessionId:"uninstall", thread:"<you>", purpose:"driving chrome://extensions to remove the extension", profile, url:"about:blank"} # your window; find its hwnd via desktop_list_windows (match a unique title) desktop_navigate {hwnd:<W>, url:"chrome://extensions"} # edge://extensions for Edge - scheme MUST match the browser desktop_screenshot_window {hwnd:<W>} # read it: is the Adom card present? ```
tools/check-guard-coverage.mjs+11−1@@ -11,22 +11,32 @@ import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); const sw = readFileSync(join(root, 'extension/src/sw.js'), 'utf8'); const native = readFileSync(join(root, 'extension/src/native.js'), 'utf8');+const server = readFileSync(join(root, 'bridge/server.js'), 'utf8'); const policy = JSON.parse(readFileSync(join(root, 'tools/verbs-policy.json'), 'utf8')); const dispatched = [...new Set([...sw.matchAll(/case 'nbrowser_([a-z_]+)'/g)].map((m) => m[1]))]; const src = sw + native; const errors = []; +// Parse the VERB_META table: verb -> its (single-line) entry text, so we can assert HINT COVERAGE — every+// verb an AI can call must ship a non-empty hint + non-empty pitfalls, or the AI is left guessing.+const metaEntry = new Map([...server.matchAll(/^ {2}nbrowser_([a-z_]+): (\{.*\}),?\s*$/gm)].map((m) => [m[1], m[2]]));+ for (const verb of dispatched) { const cls = policy[verb]; if (!cls) { errors.push(`UNCLASSIFIED verb 'nbrowser_${verb}' — add it to tools/verbs-policy.json (readonly|meta|mutating|owned_implicit|global|alias)`); continue; } if (cls === 'mutating' && !src.includes(`verb: 'nbrowser_${verb}'`)) { errors.push(`UNGATED mutating verb 'nbrowser_${verb}' — its resolveTabId call must pass { requireOwned: true, verb: 'nbrowser_${verb}' }`); }+ if (cls === 'alias') continue; // aliases inherit the canonical verb's hint+ const entry = metaEntry.get(verb);+ if (!entry) { errors.push(`NO HINT: verb 'nbrowser_${verb}' has no VERB_META entry in bridge/server.js — add { hint, related, pitfalls } so the AI knows what it does / what next / the traps`); continue; }+ if (/hint:\s*(''|"")/.test(entry) || !/hint:/.test(entry)) errors.push(`EMPTY HINT: 'nbrowser_${verb}' VERB_META has no hint`);+ if (/pitfalls:\s*\[\s*\]/.test(entry)) errors.push(`EMPTY PITFALLS: 'nbrowser_${verb}' VERB_META has pitfalls:[] — add at least one trap the AI should avoid`); } for (const verb of Object.keys(policy)) { if (!dispatched.includes(verb)) errors.push(`STALE policy entry 'nbrowser_${verb}' — not dispatched in sw.js`); } if (errors.length) { console.error('guard-coverage FAILED:\n ' + errors.join('\n ')); process.exit(1); }-console.log(`guard-coverage OK: ${dispatched.length} verbs classified, ${Object.values(policy).filter((c) => c === 'mutating').length} mutating verbs gated`);+console.log(`guard-coverage OK: ${dispatched.length} verbs classified, ${Object.values(policy).filter((c) => c === 'mutating').length} mutating verbs gated, ${dispatched.filter((v) => policy[v] !== 'alias').length} verbs with non-empty hint + pitfalls`);
tools/verbs-policy.json+5@@ -7,6 +7,10 @@ "close_window": "owned_implicit", "switch_window": "owned_implicit", "list_windows": "readonly",+ "resync_sessions": "readonly",+ "owned": "readonly",+ "cleanup": "owned_implicit",+ "force_close": "global", "window_state": "owned_implicit", "fullscreen": "owned_implicit", "login_state": "readonly",@@ -36,6 +40,7 @@ "hover": "mutating", "double_click": "mutating", "right_click": "mutating",+ "handle_dialog": "mutating", "login_form": "mutating", "autologin": "mutating", "cursor": "mutating",