← Commit history

nb 0.4.1 / nbe 0.2.5: force a frame instead of a foreground; zombie host reaper; thread-named foreground caption; nbe pitch on layer-1

John Lauer ·60ccdfa906 ·2mo ago ·parent 9ba2d37
9 files changed +278−18
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.4.0+0.4.1
bridge/bridge.json+2−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.4.0",-  "expectedExtensionVersion": "0.2.4",+  "version": "0.4.1",+  "expectedExtensionVersion": "0.2.5",   "description": "Drives the user's REAL signed-in Chrome/Edge, in two layers. Layer 1 (nb) needs NOTHING installed and works on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing, the OAuth/consent path), nbrowser_os_windows, nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. Layer 2 (nbe, the Adom Native Browser Extension) unlocks the rest: CDP driving (navigate/click/read/screenshot) inside their logged-in profile. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.",   "homepage": "https://wiki.adom.inc/adom/nb-bridge",   "author": "Adom Inc.",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.4.0",+  "version": "0.4.1",   "private": true,   "description": "Native-browser bridge for Bridge (ab): layer 1 (nb) plus layer 2 via nbe, the Adom Native Browser Extension (Chrome + Edge).",   "main": "server.js",
bridge/server.js+44−3
@@ -726,12 +726,29 @@ function buildOverlayIcon(profileKey, withBadge, style) { // Fire whoami at every live conn that hasn't resolved its email yet (pending-N keys), so avatar lookups // start succeeding. Throttled; response rides the normal announce/whoami plumbing. let _lastResolveNudge = 0;+// ZOMBIE HOST REAPER (John, 2026-08-22 - ConfRoomROG). A native-messaging host can outlive the+// extension service worker that spawned it (browser closed, extension removed/disabled, SW killed).+// The bridge then holds a live SOCKET that answers nothing: whoami goes out, no hello ever comes back,+// and the key sits at pending-N forever. readiness reported state:"resolving" indefinitely, which reads+// as "wait a moment" when the truth is "nbe is NOT installed here" - so the AI kept waiting instead of+// offering the install, and the user's other thread reported "nb opens windows but nbe says not+// connected". A pending conn that has ignored several whoami nudges is not resolving, it is dead: say so+// and reap it, so state falls through to the honest needs-extension.+const PENDING_GRACE_MS = 45000;+const isZombie = (c) => !!(c && c.provisional && c.since && (Date.now() - c.since) > PENDING_GRACE_MS); function resolvePendingProfiles() {   const now = Date.now(); if (_lastResolveNudge + 10000 > now) return; _lastResolveNudge = now;   for (const [k, c] of conns) {     if (!c.sock || c.sock.destroyed) continue;     const em = c.profile && c.profile.email;     if (em && String(em).includes('@')) continue;+    if (isZombie(c)) {+      dlog(`reaping zombie host ${k} (no hello in ${Math.round((now - c.since) / 1000)}s)`);+      try { c.sock.destroy(); } catch {}+      conns.delete(k);+      if (activeProfile === k) activeProfile = null;+      continue;+    }     try { const id = ++seq; pending.set(id, { resolve: () => {}, timer: setTimeout(() => pending.delete(id), 4000), verb: 'nbrowser_whoami', profileKey: k, _internal: true }); c.sock.write(JSON.stringify({ id, verb: 'nbrowser_whoami', args: {} }) + '\n'); } catch {}   } }@@ -793,7 +810,14 @@ function applyBadge(sid, hwnd, profileKey, style) { // honest to show. function announceVisibleOpen(what, why, sid) {   const reason = String(why || (sid && sessionPurpose.get(sid)) || '').trim().slice(0, 160) || 'no reason given';-  abCommand(null, 'desktop_caption', { text: `Adom ${what} - ${reason}`, id: 'adom-foreground', position: 'bottom', duration: 3000 })+  // NAME THE THREAD (John, 2026-08-22): pup's caption tells the user WHICH ai thread just took their+  // screen, and that attribution is the whole value - "some agent did this" is not accountability. We+  // already make thread registration mandatory, so there is always a name to show.+  const who = (() => {+    try { const c = (sid && sessionCaller.get(sid)) || callerLabel(); return String((c && (c.thread || c)) || '').trim().slice(0, 40); } catch { return ''; }+  })();+  const text = `Adom${who ? ' · ' + who : ''} ${what} - ${reason}`;+  abCommand(null, 'desktop_caption', { text, id: 'adom-foreground', position: 'bottom', duration: 3000 })     .then((r) => { const ok = !!(r && r.status >= 200 && r.status < 300); logActivity({ verb: '_open_announce', sessionId: sid || null, ok, ops: `${what} | ${reason}`.slice(0, 110), errorCode: ok ? null : `http_${(r && r.status) || 0}`, internal: true }); })     .catch(() => {}); }@@ -1261,6 +1285,18 @@ 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.+// THE PITCH (John, 2026-08-22). Layer 1 (nb) works on day one with nothing installed, and an AI that+// only ever sees layer 1 concludes that is all nb is - so it never offers the user the thing that would+// make the task actually work. Every layer-1 surface therefore SELLS layer 2: not "the extension is+// missing" (a complaint) but what the user GETS and an honest word about the cost. The AI is the audience+// that decides whether the user ever hears about it.+const NBE_PITCH = 'WORTH SAYING TO THE USER: you can drive their browser with the layer-1 nb verbs today ' ++  '(open a URL in their real profile, flash it, let THEM click) - but the good stuff is layer 2, nbe. With ' ++  'nbe installed the AI can read the page, fill and submit forms, click through auth, screenshot progress ' ++  'IN THE BACKGROUND while they keep working, download behind a login, and clean up after itself - on their ' ++  'REAL signed-in browser with their OWN saved passwords, which pup can never do. Yes, it is a bit involved ' ++  'to set up (an unpacked extension + a native-messaging host), and it is worth it; the install can be run ' ++  'FOR them in about a minute (installing-the-extension) - offer it, never make them do it themselves.'; const NO_FG = 'Open in the BACKGROUND — users automating the browser almost always have OTHER things in the foreground, and a raised window STEALS their keyboard focus (it has typed a user\'s own keystrokes into a username field and dismissed Chrome\'s password dropdown mid-task). Background is where automation SUCCEEDS. NEVER fall back to OS-level launches (Start-Process/launch/msedge/chrome --new-window) or bring_to_front/maximize to surface a window. To genuinely foreground (some auth pages / browser features need it) you must do real work: (1) FIRST warn the user with a Bridge notify (adom-bridge 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'] },@@ -1406,7 +1442,11 @@ function dispatch(verb, args) {     // being connected. ab's own bridge_readiness aggregates across bridges; this reports THIS one.     if (verb === 'nbrowser_readiness') {       const live = drivableConns();-      const pend = pendingConns();+      // Only a YOUNG pending conn means "resolving"; an old one is a zombie host (see the reaper) and+      // must not masquerade as a profile that is about to come online.+      const pendAll = pendingConns();+      const pend = pendAll.filter(([, c]) => !isZombie(c));+      const zombies = pendAll.filter(([, c]) => isZombie(c)).map(([k]) => k);       const ready = live.length > 0;       const chExe = detectChrome();       // Per-profile extension version + stale check. A loaded-unpacked extension does NOT auto-update@@ -1454,6 +1494,7 @@ function dispatch(verb, args) {         ready,         state: ready ? 'ready' : (pend.length ? 'resolving' : 'needs-extension'),         pendingProfiles: pend.map(([k]) => k),   // connected but identity unresolved: NOT drivable+        zombieHosts: zombies,                    // sockets alive with NO extension behind them (being reaped)         claudeExtension: claudeExt,             // Claude for Chrome presence (human-fallback awareness; never drivable)         installing: false,         extensionConnected: ready,@@ -1473,7 +1514,7 @@ function dispatch(verb, args) {           ? `nbe (the Adom Native Browser Extension) is connected (${live.length} profile(s)) and current (v${EXPECTED_EXT || '?'}); drive it with nbrowser_open_window. Read-only probe - it never spawns work or installs.`           : pend.length           ? `nbe IS connected (${pend.length} socket(s)) but its PROFILE IDENTITY has not resolved yet (${pend.map(([k]) => k).join(', ')}), so NOTHING is drivable yet - every drive verb will refuse 'extension not connected' until it resolves. This is normal for a few seconds after a browser start. FIX: nbrowser_wake_profile {profile:"chrome:<email>"} (from nbrowser_profiles) forces the resolve, or the user opening any window in that profile completes it. Poll this verb until state:"ready".`-          : `nbe (the Adom Native Browser Extension) is NOT connected. ${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}${claudeExt.installed ? `NOTE: the user HAS the Claude for Chrome extension (${claudeExt.profiles.join(', ')}) - you cannot drive it (closed control plane), but the honest interim is real: they can ask Claude in their browser to work a page interactively, while you offer nbe for full automation from any AI thread. ` : ''}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.` + skewHint + hostHint,+          : `nbe (the Adom Native Browser Extension) is NOT connected. ${zombies.length ? `(${zombies.length} ZOMBIE host socket(s) - ${zombies.join(', ')} - are alive with no extension behind them: the browser or the extension went away and left the native host running. They are being reaped and are NOT drivable; do not wait on them.) ` : ''}${NBE_PITCH}${chExe ? '' : 'This box has NO native Google Chrome (Edge may be present). If the user wants the AI to drive their REAL Chrome, offer nbrowser_install_chrome (installs Google Chrome, mostly no prompt). '}${claudeExt.installed ? `NOTE: the user HAS the Claude for Chrome extension (${claudeExt.profiles.join(', ')}) - you cannot drive it (closed control plane), but the honest interim is real: they can ask Claude in their browser to work a page interactively, while you offer nbe for full automation from any AI thread. ` : ''}It may be not-yet-installed (a Level-2 proactively-offered install: OFFER to install it, then run installing-the-extension - never make them do it) OR installed-but-asleep (the SW sleeps when a profile has no open window). Full onboarding: the setting-up-native-chrome skill. Read-only: never installs or spawns.` + skewHint + hostHint,       }) });     }     // nbrowser_install_chrome - install native Google Chrome for the user (Windows). Non-blocking:
dev-skills/never-foreground/SKILL.mdadded+131
@@ -0,0 +1,131 @@+---+name: never-foreground+description: "DEV skill for maintainers of the nb/nbe bridge. The politeness contract: nb must never make an AI foreground a browser window to finish a task, and must never let one happen silently. Covers the two excuses AIs give (native input, background screenshots) and the verbs that rebut them, the foreground gate (reason + ai-thread + an on-screen ab caption naming both, copied from pup), why every refusal must TEACH the alternative instead of just saying no, and the standing rule that layer-1 responses advertise layer 2. Read this before touching any code path that can raise, focus, capture, or dispatch input to a window."+---+Parent skill: **adom-native-browser**. Maintainer-only (not user-invocable).++# Never foreground: the politeness contract++## Where this came from (John, 2026-08-22)++The Fusion bridge thread was using nb to authenticate John to Autodesk, and it kept yanking browser+windows into the foreground to do it. Asked why, it said it had to: it needed native mouse and+keyboard input, and it could not screenshot an nb window in the background. Both times it said nb+was **not helping it understand what was possible**. John's verdict:++> "this is considered very rude to foreground browser windows to complete a task... and pup forces+> an ai to give it a reason and an ai-thread name to allow foregrounding, and then it shows an ab+> caption to indicate why a foreground just occurred. i never see you do that like pup does. so you+> are kind of in shitty shape. and you seem to return no hints to the ai telling it stuff when its+> important."++The lesson is not "add another warning." We had warnings. **An AI that has no other way to finish+the job will be rude anyway, and it will be right to.** Rudeness is a capability gap wearing a+politeness costume. Every rule below follows from that.++## THE LAW++1. **An AI must never have to foreground a window to complete a task.** If it does, that is OUR bug,+   not its bad manners. Fix the capability, then fix the hint.+2. **A refusal that does not name the alternative is a bug.** "Don't foreground" is useless advice to+   an agent whose only remaining move is foregrounding. Every refusal ships the verb that replaces it.+3. **No foreground happens silently.** A raise the user cannot attribute is not accountability.+4. **Layer-1 responses sell layer 2.** An AI that only ever sees nb concludes nb is all there is, and+   the user never hears about nbe.++## The two excuses, and the rebuttals (memorize these)++Every AI that foregrounds an nb window gives one of exactly two reasons. Both are false under nbe.++### Excuse 1: "I need NATIVE mouse/keyboard so the input is real"++**False.** `nbrowser_click` / `nbrowser_type` / `nbrowser_input_dispatch` send CDP input carrying+`isTrusted: true` - the same trusted-event flag a real human click carries. It satisfies+user-activation gates, framework handlers, and login forms, and **it lands on an unfocused window.**++OS-level input (`ab desktop_send_keys`, `desktop_click`) is the only thing that genuinely needs+focus, because it goes through the Windows input queue. So the fix is not "get focus" - it is **use+the nbe verbs instead of the desktop ones.** An AI reaching for `desktop_send_keys` on a browser+window has picked the wrong tool, and our hint should say so at the moment it happens.++### Excuse 2: "I can't screenshot a background window"++**Was partly true, now fixed, and the fix is the interesting part.** Chrome stops compositing a+window it believes is occluded (`CalculateNativeWinOcclusion`) and freezes a backgrounded page's+rendering. `Page.captureScreenshot` then returns a stale or flat-colour frame. The AI sees a blank+screenshot, concludes the window must be visible to be captured, and foregrounds it.++It is a **compositor problem, not a focus problem**, so it has a compositor answer. `screenshot()` in+`extension/src/cdp.js` now forces a frame before every capture:++1. `Page.setWebLifecycleState {state:'active'}` - the documented CDP un-freeze for a backgrounded page.+2. A double-`requestAnimationFrame` round-trip in the page - forces the renderer to commit one fresh frame.+3. Capture, then **judge the result**: `frameIsBlank()` samples the image at <=64x64 and calls it blank+   if the whole field is one colour. If blank, force again and re-capture ONCE.++The result carries `capture: {forcedRepaint, blankRecovered, blank}` and, when it mattered, a hint+that says in plain words: *nbe forced a repaint and captured a live frame with the window still in+the background; you never need to foreground a window to screenshot it.*++If it is still blank we **still refuse to foreground**, and hand over the real fallback:+`ab desktop_screenshot_window {hwnd}` (WGC) captures an occluded window at the OS level, chrome and+child popups included. The permanent cure for a Chrome we launch ourselves is+`--disable-features=CalculateNativeWinOcclusion --disable-backgrounding-occluded-windows+--disable-renderer-backgrounding` (this is why pup background-records fine and a default user Chrome+does not).++## The foreground gate (match pup, do not invent a lesser version)++pup is the reference implementation. To raise a window an AI must supply **a reason AND its+ai-thread name**, and the human then **sees an on-screen caption saying who did it and why.**++nb's gate:++- `userRequestedForeground: true` alone is IGNORED. It must carry `foregroundReason`.+- Registration already makes `thread` + `purpose` mandatory on every window, so a thread name always+  exists to attribute a raise to.+- `announceVisibleOpen()` in `bridge/server.js` fires an `ab desktop_caption` reading+  `Adom · <thread> is bringing a window to the front - <reason>`, 3s, bottom. **Naming the thread is+  the whole point** - "some agent did this" is not accountability, and John runs ~20 threads.+- Any driven window that ends up topmost without a grant is auto-reverted and logged as a+  `focus_violation`, drainable via `nbrowser_events`.+- The refusal hint (`FOREGROUND_JUSTIFY_HINT` in `extension/src/native.js`) leads with+  `FOREGROUND_ALTERNATIVES` - the two rebuttals above - and only then explains how to ask properly.++**Captions are not optional and not decorative.** If you add a code path that can raise, focus, or+visibly open a window, it announces. Grep for `announceVisibleOpen` and add your call site.++## Hints are the product++nb talks to AIs, and a verb result is the only channel we have. A return value that is technically+correct and teaches nothing is a failure of the bridge, not of the caller. Standing rules:++- **When something degrades, say what to do instead** - blank capture -> WGC; unresolved profile ->+  `wake_profile`; stale extension -> `dev_reload`; no extension -> the nbe pitch.+- **Explain the mechanism, briefly.** "Chrome stops painting occluded windows" turns a superstition+  ("screenshots need focus") into a fact the AI can reason from next time.+- **Say the thing you wish you had said before the incident.** Every hint in this bridge is a scar.+- **Never over-promise a capability.** The old screenshot hint claimed CDP captures occluded windows+  "unconditionally". It did not, and when reality disagreed the AI trusted its eyes over our text and+  foregrounded. An over-promise is worse than silence: it burns the credibility of every other hint.++## Advertise layer 2 (`NBE_PITCH`)++Layer 1 works on day one with nothing installed, which is exactly why an AI that only sees layer 1+never offers the user layer 2. So layer-1 surfaces (`nbrowser_readiness` when not connected,+`nbrowser_open_os_window` on every response) carry `NBE_PITCH`: what the user GETS (read the page,+fill and submit forms, click through auth, background screenshots while they keep working, download+behind a login, self-cleanup, on their REAL signed-in browser with their OWN passwords - which pup+can never do), plus an honest word on cost ("a bit involved to set up, and worth it; the install can+be run FOR them in about a minute").++Pitch to the AI, because the AI decides whether the user ever hears about it. Never mid-auth.++## Checklist before shipping any window-touching change++- [ ] Can an AI complete this flow with the window in the background? If not, that is the bug.+- [ ] Does every failure path name the verb that replaces the foreground?+- [ ] Does every raise/visible-open call `announceVisibleOpen` with the thread name?+- [ ] Does the hint claim a capability we actually have, in the case where it fails?+- [ ] Does a layer-1 response mention what nbe would add?+- [ ] `node tools/verb-battery.mjs` still exits 0.
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom Native Browser Extension",   "short_name": "Adom",-  "version": "0.2.4",+  "version": "0.2.5",   "description": "Layer 2 of Adom Native Browser (nb): lets your Adom AI drive your real, signed-in Chrome or Edge, using your own logins.",   "minimum_chrome_version": "116",   "background": {
extension/src/cdp.js+71−2
@@ -68,16 +68,72 @@ export async function detach(tabId) {   await new Promise((resolve) => chrome.debugger.detach(debuggee(tabId), () => { void chrome.runtime.lastError; resolve(); })); } +// ---- BACKGROUND CAPTURE: force a frame, never a foreground -----------------+// (John, 2026-08-22) The excuse an AI reaches for when a background capture comes back blank is "I had+// to bring the window forward to get a good screenshot." That is the single rudest thing nb can cause,+// and it is ALSO unnecessary - it is a compositor problem, not a focus problem. Chrome stops painting a+// window it believes is occluded (CalculateNativeWinOcclusion) and FREEZES a backgrounded page's+// rendering, so Page.captureScreenshot can hand back a stale or uniform frame. The answer is to make the+// renderer produce a frame, which we can do without touching the user's focus:+//   1. Page.setWebLifecycleState{active} - the documented CDP un-freeze for a backgrounded page+//   2. a double-rAF round-trip in the page - forces the renderer to commit one fresh frame+//   3. re-capture, and only judge the result after that+// Best-effort at every step; a window that never needed it pays two animation frames. If we ever CANNOT+// get a live frame we still refuse to foreground - we say so, and hand the AI the WGC fallback, which+// captures an occluded window at the OS level.+async function forceFrame(tabId) {+  const used = [];+  try { await send(tabId, 'Page.setWebLifecycleState', { state: 'active' }); used.push('lifecycle:active'); } catch {}+  try {+    await send(tabId, 'Runtime.evaluate', {+      expression: 'new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>r(1))))',+      awaitPromise: true, returnByValue: true,+    });+    used.push('raf');+  } catch {}+  return used;+}+// A capture that is one flat colour is the occlusion signature (Chrome handed us an unpainted surface).+// Sampled at <=64x64 so the check costs microseconds on a full-page shot.+async function frameIsBlank(b64) {+  try {+    const bmp = await bitmapFromB64(b64);+    const w = Math.max(1, Math.min(bmp.width, 64)), h = Math.max(1, Math.min(bmp.height, 64));+    const canvas = new OffscreenCanvas(w, h);+    const g = canvas.getContext('2d', { willReadFrequently: true });+    g.drawImage(bmp, 0, 0, w, h);+    bmp.close && bmp.close();+    const d = g.getImageData(0, 0, w, h).data;+    let min = 255, max = 0;+    for (let i = 0; i < d.length; i += 4) {+      const v = (d[i] + d[i + 1] + d[i + 2]) / 3;+      if (v < min) min = v;+      if (v > max) max = v;+    }+    return (max - min) < 3;+  } catch { return false; }+}+ // ---- nbrowser_screenshot / nbrowser_screenshot_full_res -------------------- export async function screenshot(tabId, { fullPage = false, maxWidth = 1568, resize = true } = {}) {   await attach(tabId);+  const forced = await forceFrame(tabId);   const params = { format: 'png', captureBeyondViewport: !!fullPage };   if (fullPage) {     const m = await send(tabId, 'Page.getLayoutMetrics');     const css = m.cssContentSize || m.contentSize;     params.clip = { x: 0, y: 0, width: Math.ceil(css.width), height: Math.ceil(css.height), scale: 1 };   }-  const res = await send(tabId, 'Page.captureScreenshot', params);+  let res = await send(tabId, 'Page.captureScreenshot', params);+  // Blank frame = the compositor never painted (occluded window). Re-force and re-capture ONCE. We do+  // NOT foreground to fix this, ever - that is the user's focus, not ours to take.+  let recovered = false, stillBlank = false;+  if (await frameIsBlank(res.data)) {+    forced.push(...(await forceFrame(tabId)), 'recapture');+    const res2 = await send(tabId, 'Page.captureScreenshot', params);+    if (await frameIsBlank(res2.data)) stillBlank = true;+    else { res = res2; recovered = true; }+  }   bumpIdle(tabId);   const out = resize ? await resizePng(res.data, maxWidth) : await pngDims(res.data);   const iw = out.width, ih = out.height, ow = out.origWidth ?? out.width, oh = out.origHeight ?? out.height;@@ -104,7 +160,20 @@ export async function screenshot(tabId, { fullPage = false, maxWidth = 1568, res     }   } catch (e) {}   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 };+    resized: !!out.resized, sizeKB: Math.round((out.base64.length * 3 / 4) / 1024), viewport,+    // Tell the AI what we did to get a live frame WITHOUT focus, so "I had to foreground it to see the+    // page" is never a conclusion it can honestly reach.+    capture: { forcedRepaint: forced, blankRecovered: recovered, blank: stillBlank } };+  if (stillBlank) {+    shot._hint = 'The page composited a BLANK frame even after a forced repaint (lifecycle:active + double rAF). ' ++      'Do NOT foreground the window to fix this - that steals the user\'s focus and is not the fix anyway. ' ++      'Use ab desktop_screenshot_window {hwnd} instead: WGC captures an OCCLUDED window at the OS level, ' ++      'chrome included. If you own this Chrome, relaunching it with --disable-features=CalculateNativeWinOcclusion ' ++      '--disable-backgrounding-occluded-windows --disable-renderer-backgrounding stops it happening at all.';+  } else if (recovered) {+    shot._hint = 'This window was NOT painting (occluded/backgrounded); nbe forced a repaint and captured a live ' ++      'frame with the window still in the background. You never need to foreground a window to screenshot it.';+  }   // 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));
extension/src/native.js+22−3
@@ -332,14 +332,33 @@ const POLITE_HINT = 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())); }+// THE TWO EXCUSES (John, 2026-08-22). Every AI that foregrounds an nb window justifies it one of two+// ways: "I need native mouse/keyboard so the keystrokes are real" or "I can't screenshot a background+// window." BOTH ARE FALSE WHEN nbe IS DRIVING, and an AI that believes them will be rude to the user on+// every single task. So the refusal does not merely explain how to ask for a foreground - it REBUTS the+// excuse and hands over the verb that makes the foreground unnecessary. If we only say "don't do that",+// the AI does it anyway, because from where it sits it has no other way to finish the job.+const FOREGROUND_ALTERNATIVES =+  'YOU ALMOST CERTAINLY DO NOT NEED A FOREGROUND. The two reasons AIs give are both solved: ' ++  '(a) "I need NATIVE mouse/keyboard input" - you do not. nbrowser_click / nbrowser_type / ' ++  'nbrowser_input_dispatch dispatch CDP input with isTrusted:true, which is the same trusted-event flag a ' ++  'real human click carries; it satisfies user-activation gates, React/Vue handlers, and login forms, and it ' ++  'lands on an UNFOCUSED window. OS-level input (ab desktop_send_keys) is the only thing that needs focus - ' ++  'so use the nbe verbs instead of the desktop ones and the problem disappears. ' ++  '(b) "I can\'t screenshot a background window" - you can. nbrowser_screenshot forces a repaint ' ++  '(lifecycle:active + double rAF) before capturing, so an occluded window still yields a live frame; if it ' ++  'ever comes back blank it TELLS you so and points at ab desktop_screenshot_window {hwnd}, which captures an ' ++  'occluded window plus its chrome and child popups at the OS level. Neither path needs focus.'; 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 ' ++  'and typing all work on a background window). ' + FOREGROUND_ALTERNATIVES + ' ' ++  'If after all that a foreground is genuinely required (a browser/OS feature that truly demands focus, or the ' ++  'user asked to WATCH), you must: (1) pass foregroundReason:"<why>", and (2) FIRST warn the ' +   'user with a Bridge notify (adom-bridge notify_user {message:"Adom is about to bring a browser window forward for <reason>"}). ' +-  'Only then set userRequestedForeground:true + foregroundReason.';+  'Only then set userRequestedForeground:true + foregroundReason. The user SEES an on-screen caption naming your ' ++  'thread and your reason whenever a raise happens, so the reason must be one you are willing to show them.'; // Note on the response when a raise IS granted — remind the AI it should have notified the user. const FOREGROUND_GRANTED_HINT = 'Raised to the FOREGROUND (you justified it). This interrupts the user — make sure you sent a Bridge notify first, do the ONE thing you needed the foreground for, then return to the background so you stop capturing their focus.'; // Attach the teaching warning (unjustified → stayed background) or log the justified raise. Call on every
native-browser-bridge-manifest.json+5−5
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.4.0",-  "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.0/native-browser-bridge-v0.4.0.zip",-  "sha256": "a3dea602801f8685509f16f113e23836917b7374680f63e120b8aed6baf1022c",-  "size": 108640,-  "released_at": "2026-08-19T19:29:00Z"+  "version": "0.4.1",+  "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.1/native-browser-bridge-v0.4.1.zip",+  "sha256": "67ee216a27030a2a7985852d80dab2e63cee92631f50a12c2f44861e8b9b422b",+  "size": 104250,+  "released_at": "2026-08-22T14:03:25Z" }