← Commit history

v0.16.2 (insiders): nb-bridge#41: hover/move/wheel accept raw {x,y} (the stale_coordinates gate is clicks-only and names the verb), input_dispatch refuses an unknown type instead of clicking, real isTrusted wheel via input_dispatch type:wheel and the new nbrowser_scroll verb, screenshot toFile honours a caller path, readiness staleOnDisk covers connected profiles; extension 0.2.10; skills: updating-the-extension recipe.

John Lauer ·2ae6d1869e ·2mo ago ·parent 1a371e4
10 files changed +54−18
bin/nb-dashboard+1−1
@@ -20,7 +20,7 @@ const { execFile, spawn } = require('child_process'); const fs = require('fs'); const crypto = require('crypto'); -const VERSION = '0.16.1';           // keep in lockstep with package.json (pkg version)+const VERSION = '0.16.2';           // keep in lockstep with package.json (pkg version) // SEND TO AI IS UN-PARKED (John: "yeah, we are re-enabling the send to ai. it seems to be good now"). // The ah build thread certified the crash family dead on the gate-fixed build and the delivery leg is // proven end to end. The park const is DELETED rather than flipped, so no dead branch can resurrect it.
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.16.1\ No newline at end of file+0.16.2\ No newline at end of file
bridge/bridge.json+3−2
@@ -2,8 +2,8 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Native Browser",-  "version": "0.16.1",-  "expectedExtensionVersion": "0.2.9",+  "version": "0.16.2",+  "expectedExtensionVersion": "0.2.10",   "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.",@@ -67,6 +67,7 @@     "nbrowser_eval",     "nbrowser_evaluate",     "nbrowser_input_dispatch",+    "nbrowser_scroll",     "nbrowser_click",     "nbrowser_select",     "nbrowser_set_file_input",
bridge/package.json+1−1
@@ -1,6 +1,6 @@ {   "name": "adom-native-browser-bridge",-  "version": "0.16.1",+  "version": "0.16.2",   "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+14−8
@@ -1504,7 +1504,8 @@ const VERB_META = {   nbrowser_screenshot_full_res: { hint: 'Full-resolution, full-PAGE screenshot via CDP (the entire scrollable page, not just the viewport). Still PAGE content only.', related: ['nbrowser_screenshot'], pitfalls: ['this is full-PAGE, NOT "fullscreen monitor" - it never captures the desktop or the user\'s other windows', 'large base64 can be slow over the relay - prefer toFile then pull_file', 'for the window frame or a native dialog/popup, use ab desktop_screenshot_window {hwnd | titleContains} (parent/child hwnds), never a monitor capture'] },   nbrowser_eval: { hint: 'Run JS in the page and get the result. Powerful - use read-only expressions to inspect state.', related: ['nbrowser_screenshot'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },   nbrowser_evaluate: { hint: 'Alias of nbrowser_eval.', related: ['nbrowser_eval'], pitfalls: ['runs arbitrary JS on the user\'s LIVE session - never run destructive scripts'] },-  nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input (click/move/type/key).', related: ['nbrowser_click', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy'] },+  nbrowser_input_dispatch: { hint: 'Low-level isTrusted CDP input. type: click | dblclick | rightclick | move | hover | wheel | type | key. An unknown type is REFUSED (unknown_input_type), it never becomes a click (from ext 0.2.10). wheel takes deltaX/deltaY at {x,y} (or selector/text) and is what a canvas camera zoom needs.', related: ['nbrowser_click', 'nbrowser_scroll', 'nbrowser_type'], pitfalls: ['prefer nbrowser_click (smart selector) over raw xy', 'the stale_coordinates freshness gate applies to CLICKS only: hover/move/wheel accept raw {x,y} without atScrollX/Y (ext 0.2.10)', 'there is no "mouseWheel" type; say wheel (mousewheel and scroll are accepted aliases)'] },+  nbrowser_scroll: { hint: 'Real isTrusted mouse WHEEL at a point: {sessionId, x, y | selector | text, deltaX?, deltaY, steps?}. deltaY:-300 zooms a canvas camera in (or scrolls up), +300 out (or down). This is the verb for Babylon/three.js canvas zoom and for scrolling a pane the page owns; press_key cannot do it and page-level scrolling is not it.', related: ['nbrowser_input_dispatch', 'nbrowser_hover', 'nbrowser_screenshot'], pitfalls: ['pageScrolled:false is NORMAL on a canvas that consumed the wheel (a zoom is not a scroll): verify with a screenshot or an eval of the app state', 'no freshness gate: a wheel at a stale point is harmless motion, unlike a click', 'split a big delta with steps (e.g. steps:5) when an app clamps per-event zoom'] },   nbrowser_click: { hint: 'Click by visible text / selector / xy (isTrusted). Scrolls the target into view, re-resolves its rect, and RETURNS changed:bool - whether the URL/DOM actually changed after the click - so you can detect "click landed but nothing happened" and retry. Optional gliding cursor + intent label. The finder PIERCES open shadow roots, so custom-element controls (Schwab/QBO) are clickable by text/selector.', related: ['nbrowser_select', 'nbrowser_wait_for', 'nbrowser_screenshot', 'nbrowser_type'], next: 'nbrowser_wait_for', pitfalls: ['attaches the debugger (yellow "debugging" bar; idle-detaches ~8s)', 'changed:false means the click dispatched but the page did not visibly change (disabled/covered element, missed target) - re-resolve or retry', 'for a deterministic post-click wait use nbrowser_wait_for, not a fixed sleep', 'to PICK an option from a custom dropdown, use nbrowser_select (waits for options + returns the value) - never coordinate-click a virtualized list'] },   nbrowser_select: { hint: 'Pick an option from a dropdown/combobox BY ITS VISIBLE TEXT - the reliable primitive for custom + native selects. Give {label} (the control\'s accessible name) OR {near} (a visible label beside it) to find the trigger, plus {optionText}. It opens the control with an isTrusted click, WAITS for the options to render, clicks the EXACT text match (piercing shadow roots), and RETURNS the newly-selected value so you can verify. Fixes coordinate-clicks landing on the wrong row of a virtualized list.', related: ['nbrowser_click', 'nbrowser_wait_for'], pitfalls: ['pass label OR near to locate the trigger; optionText must match the option\'s exact visible text (case-insensitive)', 'ALWAYS check the returned `selected` value before continuing - confirm it is the row you meant (mis-selecting a bank account by one row is the exact bug this prevents)', 'raise timeoutMs for a slow/virtualized option list (default 4000, max 20000)'] },   nbrowser_set_file_input: { hint: 'Populate an <input type=file> over CDP - NO OS file picker, fully background. Direct mode: {sessionId, selector|text, filePaths:[abs laptop paths]} - the finder walks a Browse button/label to its input (hidden inputs fine). Transient-input UIs (input created inside the Browse handler): {armForNextChooser:true, filePaths} then nbrowser_click the Browse control - the chooser is satisfied silently; confirm with {chooserStatus:true}. Cloud shortcut: files:[{name, contentBase64}] and the bridge stages them on the laptop for you.', related: ['nbrowser_click', 'nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['filePaths are LAPTOP paths - send_files first (or use contentBase64)', 'after injection many UIs still need a Submit/Upload click - screenshot to see the state', 'sites using the File System Access API (showOpenFilePicker) CANNOT be driven over CDP at all - use their drag-drop zone manually', 'input+change are dispatched for you; do not synthesize extra events'] },@@ -1536,7 +1537,7 @@ const VERB_META = {   nbrowser_clear_cookies: { hint: 'Background logout: drop a site\'s cookies (incl. httpOnly) for ONE registrable domain, leaving other sites (e.g. Google SSO) intact.', related: ['nbrowser_login_state'], pitfalls: ['scoped to one domain so it never nukes unrelated sessions', '⚠️ do NOT use this to log OUT of a site that ties device-trust to cookies (e.g. Gusto) - it forces a fresh 2FA; use the site\'s own Sign out'] },   nbrowser_caption: { hint: 'Paint an on-page caption (subtitle or red alert banner) for demos/narration.', related: ['nbrowser_cursor'], pitfalls: ['only on a window you opened', 'pass action:"hide" or empty text to dismiss'] },   nbrowser_focus: { hint: 'Select a tab within its window + flash its taskbar button. Does NOT raise the window unless you justify a foreground (userRequestedForeground:true + foregroundReason).', related: ['nbrowser_flash', 'nbrowser_screenshot'], pitfalls: ['does NOT foreground by design - the user\'s focus is never stolen; capture in place with nbrowser_screenshot', 'to actually raise it you must justify + warn the user (ab notify) first'] },-  nbrowser_hover: { hint: 'Hover an element (by selector/text) to reveal menus/tooltips (isTrusted).', related: ['nbrowser_click'], pitfalls: ['some menus need a dwell - hover then screenshot before clicking the revealed item'] },+  nbrowser_hover: { hint: 'Hover an element (by selector/text, or raw {x,y}) to reveal menus/tooltips (isTrusted). Raw {x,y} needs NO atScrollX/Y: a hover is read-only pointer motion, so the stale_coordinates gate does not apply (ext 0.2.10; it used to refuse with a message that said click).', related: ['nbrowser_click', 'nbrowser_scroll'], pitfalls: ['some menus need a dwell - hover then screenshot before clicking the revealed item', 'a coordinate scan of a canvas (hover a grid, read the tooltip after each) is fine with raw {x,y}'] },   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'] },@@ -1658,7 +1659,8 @@ function dispatch(verb, args) {       const staleOnDisk = [];       if (EXPECTED_EXT) {         for (const kp of knownProfiles()) {-          if (connectedKeys.has(kp.key)) continue;                 // connected path already handled above+          // Connected profiles are checked too (nb-bridge#41 side note): the running service worker can be current+          // while the load-unpacked FOLDER on disk is old, and dev_reload then reloads that old folder.           let st; try { st = adomExtStateForProfile(kp.browser, kp.profileDir); } catch { continue; }           if (st && st.installed && st.version && cmpVer(st.version, EXPECTED_EXT) < 0) {             staleOnDisk.push({ profile: kp.key, installed: st.version, expected: EXPECTED_EXT });@@ -1773,7 +1775,8 @@ function dispatch(verb, args) {         d('nbrowser_screenshot_full_res', 'Full-resolution / full-page screenshot via CDP.', { profile: P, sessionId: S, tabId: 'optional str', fullPage: 'optional bool' }, { ok: 'bool', base64: 'str' }, 120, { example: { sessionId: 'work', fullPage: true } }),         d('nbrowser_eval', 'Run JS in the page context and return the result.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: 'document.title' } }),         d('nbrowser_evaluate', 'Alias of nbrowser_eval.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: '1+1' } }),-        d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"move"|"type"|"key"', x: 'int?', y: 'int?', text: 'str?', key: 'str?' }, { ok: 'bool' }, 120, { example: { sessionId: 'work', type: 'click', x: 200, y: 300 } }),+        d('nbrowser_input_dispatch', 'Low-level isTrusted CDP input. Unknown type is refused (unknown_input_type), never a click.', { sessionId: S, tabId: 'optional str', type: 'required "click"|"dblclick"|"rightclick"|"move"|"hover"|"wheel"|"type"|"key"', x: 'int?', y: 'int?', selector: 'str?', text: 'str? (visible text to resolve, or the text to type for type:"type")', key: 'str?', deltaX: 'int? (wheel)', deltaY: 'int? (wheel; -300 = zoom in / scroll up)', steps: 'int? (wheel, split the delta)', atScrollX: 'int? (clicks by raw xy only)', atScrollY: 'int?' }, { ok: 'bool', dispatched: 'str', errorCode: 'str? (unknown_input_type | stale_coordinates | ambiguous_target)' }, 120, { example: { sessionId: 'work', type: 'wheel', x: 400, y: 300, deltaY: -300 } }),+        d('nbrowser_scroll', 'Real isTrusted mouse wheel at a point (canvas camera zoom, pane scroll). No freshness gate.', { sessionId: S, x: 'int?', y: 'int?', selector: 'str?', text: 'str?', deltaX: 'int?', deltaY: 'int (px; negative = up / zoom in)', steps: 'int? (1..20)', settleMs: 'int?' }, { ok: 'bool', dispatched: '"wheel"', pageScrolled: 'bool', scrollBefore: '{sx,sy}', scrollAfter: '{sx,sy}' }, 30, { example: { sessionId: 's1', x: 442, y: 589, deltaY: -300 } }),         d('nbrowser_click', 'Click by visible text / selector / xy; scrolls into view + returns changed:bool (did the URL/DOM actually change). Finder pierces open shadow roots. Optional gliding cursor + intent label.', { sessionId: S, tabId: 'optional str', text: 'optional str', selector: 'optional str', x: 'int?', y: 'int?', cursor: 'optional bool', intent: 'optional str', settleMs: 'optional int (default 500)' }, { ok: 'bool', changed: 'bool', urlBefore: 'str', urlAfter: 'str' }, 30, { example: { sessionId: 'work', text: 'Sign in', cursor: true, intent: 'Clicking Sign in' } }),         d('nbrowser_select', 'Pick a dropdown/combobox option BY VISIBLE TEXT (custom or native): finds the trigger by label|near, opens it, waits for options, clicks the exact match (shadow-piercing), returns the selected value to verify.', { sessionId: S, tabId: 'optional str', label: 'optional str (control accessible name)', near: 'optional str (visible label beside it)', optionText: 'required str (exact option text)', timeoutMs: 'optional int (default 4000, max 20000)' }, { ok: 'bool', selected: 'str', matchedExact: 'bool', clickedText: 'str', via: '"custom-dropdown"|"native-select"' }, 25, { example: { sessionId: 'work', near: 'Account', optionText: 'Individual …3456' } }),         d('nbrowser_set_file_input', 'Upload files into an <input type=file> over CDP - no OS picker, background-safe. Direct (selector/text -> input, incl. hidden) or armForNextChooser:true for inputs created on Browse-click. files:[{name,contentBase64}] = bridge stages small files on the laptop.', { sessionId: 'required str', selector: 'optional str', text: 'optional str', filePaths: 'optional [abs laptop paths]', files: 'optional [{name, contentBase64}]', armForNextChooser: 'optional bool', chooserStatus: 'optional bool' }, { ok: 'bool', inputFound: 'bool?', via: 'direct|chooser', files: '[names]', armed: 'bool?', served: 'bool?' }, 20, { example: { sessionId: 'work', selector: 'input[type=file]', filePaths: ['C:/Users/me/Downloads/foo.png'] } }),@@ -1801,7 +1804,7 @@ function dispatch(verb, args) {         // were invisible to every caller for several releases. Keep this list in step with VERB_META;         // tools/check-guard-coverage.mjs now fails the build if it drifts again.         d('nbrowser_events', 'Drain buffered lifecycle events since your last call: window_closed (with byUser), tab_closed, spawned_window (a page you drove opened a compose/popup), focus_violation, foreground_raised, dialog. THE channel by which you learn the user closed your window or a page spawned one behind your back.', { limit: 'optional int' }, { ok: 'bool', events: '[{event,windowId?,tabId?,byUser?,url?,agoMs}]', count: 'int' }, 20, { example: {} }),-        d('nbrowser_hover', 'Hover an element (by selector or visible text) to reveal menus/tooltips. Real isTrusted input.', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.info-icon' } }),+        d('nbrowser_hover', 'Hover an element (by selector, visible text, or raw {x,y}; no atScrollX/Y needed) to reveal menus/tooltips. Real isTrusted input.', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int', hoverDwellMs: 'optional int (default 600)' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.info-icon' } }),         d('nbrowser_double_click', 'Double-click an element (isTrusted).', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '#row-3' } }),         d('nbrowser_right_click', 'Right-click an element to open a context menu (isTrusted).', { sessionId: S, selector: 'optional css str', text: 'optional visible-text str', x: 'optional int', y: 'optional int' }, { ok: 'bool', matchedText: 'str?' }, 30, { example: { sessionId: 's1', selector: '.ctx' } }),         d('nbrowser_focus', 'Select a tab within its window and politely flash its taskbar button. Does NOT raise the window unless you justify a foreground.', { sessionId: S, tabId: 'optional str', userRequestedForeground: 'optional bool', foregroundReason: 'REQUIRED with userRequestedForeground' }, { ok: 'bool', tabId: 'str', foregrounded: 'bool' }, 15, { example: { sessionId: 's1' } }),@@ -2591,7 +2594,7 @@ function dispatch(verb, args) {         resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' });       }, timeoutFor(verb));       const startedAt = Date.now();-      pending.set(id, { resolve: (r) => resolve(attachProgress(verb, startedAt, r)), timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt });+      pending.set(id, { resolve: (r) => resolve(attachProgress(verb, startedAt, r)), timer, verb, toFile: (args && args.toFile) || false, profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt });       activityStart(id, verb, args && args.sessionId);       sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n');     };@@ -2641,9 +2644,12 @@ async function onHostFrameInner(frame) {       if (payload.base64 && ((isShot && p.toFile) || isRec)) {         try {           const sub = isRec ? 'adom-nbrowser-recordings' : 'adom-nbrowser-shots';-          const dir = path.join(process.env.TEMP || os.tmpdir(), sub);+          // nb-bridge#41 item 3: toFile:"<absolute path>" is HONOURED (the caller's path, parent dirs created);+          // toFile:true keeps the fixed temp dir. It used to accept a path and silently write elsewhere.+          const want = (isShot && typeof p.toFile === 'string' && /^[a-zA-Z]:[\\/]|^\\\\|^\//.test(p.toFile)) ? path.normalize(p.toFile) : null;+          const dir = want ? path.dirname(want) : path.join(process.env.TEMP || os.tmpdir(), sub);           fs.mkdirSync(dir, { recursive: true });-          const file = path.join(dir, `${isRec ? 'rec' : 'shot'}-${Date.now()}.${isRec ? 'webm' : 'png'}`);+          const file = want || path.join(dir, `${isRec ? 'rec' : 'shot'}-${Date.now()}.${isRec ? 'webm' : 'png'}`);           fs.writeFileSync(file, Buffer.from(payload.base64, 'base64'));           const sizeKB = Math.round(Buffer.byteLength(payload.base64, 'base64') / 1024);           payload = { ...payload, path: file, savedToFile: true, sizeKB, _hint: 'Pull this with `adom-bridge pull_file {filePaths:["' + file.replace(/\\/g, '\\\\') + '"]}` (binary, chunked, no relay timeout).' };
extension/manifest.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 3,   "name": "Adom Native Browser Extension",   "short_name": "Adom",-  "version": "0.2.9",+  "version": "0.2.10",   "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+32−3
@@ -656,7 +656,16 @@ export async function inputDispatch(tabId, args) {       await send(tabId, 'Input.insertText', { text: String(args.text || '') });       return { ok: true, dispatched: 'type', length: String(args.text || '').length, via: args.selector ? 'selector' : 'focused', ...(finder ? { finder } : {}) };     }-    // pointer types: move | hover | click | dblclick | rightclick+    // pointer types: move | hover | click | dblclick | rightclick | wheel+    // nb-bridge#41 item 2: an UNKNOWN type used to fall through to a click (four "zoom" attempts became four+    // clicks on a canvas editor with a tool armed). Refuse it by name instead.+    const KNOWN = ['click', 'dblclick', 'doubleclick', 'rightclick', 'contextmenu', 'move', 'hover', 'wheel', 'mousewheel', 'scroll'];+    if (!KNOWN.includes(type)) {+      return { ok: false, errorCode: 'unknown_input_type', dispatched: false,+        error: `unknown_input_type: "${args.type}" is not an input type I dispatch - refusing rather than guessing (an unknown type used to become a CLICK).`,+        _hint: 'type must be one of click | dblclick | rightclick | move | hover | wheel | type | key. For a mouse wheel / canvas zoom use type:"wheel" with deltaX/deltaY (or nbrowser_scroll).' };+    }+    const isWheel = (type === 'wheel' || type === 'mousewheel' || type === 'scroll');     const c = await resolveCoords(tabId, args, wantFinder);     if (!c.found) {       const out = { ok: false, error: `target not found: ${args.selector || args.text || '(no selector/text)'}`, _hint: 'element not visible/present; wait or refine the selector/text' };@@ -677,12 +686,14 @@ export async function inputDispatch(tabId, args) {     // 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) {+    // nb-bridge#41 item 1: the gate applies to CLICKS only. A hover, move or wheel is read-only pointer motion:+    // a stale hover shows the wrong tooltip, it never presses the wrong control. Those accept raw {x,y}.+    if (/click|contextmenu/.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.',+          error: `stale_coordinates: a raw {x,y} ${type} 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)) {@@ -700,6 +711,24 @@ export async function inputDispatch(tabId, args) {     const intent = args.intent || args.label;     const tgt = (info.clickedText || info.matchedText || '').trim().slice(0, 40);     if (showCursor) { await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'find')); await sleep(args.cursorGlideMs || 450); }+    if (isWheel) {+      // Real CDP wheel (isTrusted): what a canvas camera (Babylon ArcRotateCamera, three.js OrbitControls) and+      // a scrollable pane both listen for. deltaY < 0 zooms in / scrolls up, > 0 zooms out / scrolls down.+      const deltaX = Number(args.deltaX) || 0, deltaY = Number(args.deltaY) || 0;+      if (!deltaX && !deltaY) return { ok: false, errorCode: 'bad_args', error: 'wheel needs deltaX and/or deltaY (px; deltaY:-300 zooms in / scrolls up)' };+      const steps = Math.max(1, Math.min(20, Number(args.steps) || 1));+      const before = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+      await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });+      for (let i = 0; i < steps; i++) {+        await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseWheel', x, y, deltaX: deltaX / steps, deltaY: deltaY / steps });+        if (steps > 1) await sleep(Number(args.stepDelayMs) || 40);+      }+      await sleep(Number(args.settleMs) || 150);+      const after = await evaluate(tabId, '({sx:Math.round(scrollX),sy:Math.round(scrollY)})');+      return { ok: true, dispatched: 'wheel', x, y, deltaX, deltaY, steps, scrollBefore: before, scrollAfter: after,+        pageScrolled: !!(before && after && (before.sx !== after.sx || before.sy !== after.sy)), ...info, ...fin,+        _hint: 'isTrusted wheel at that point. pageScrolled:false is normal on a canvas (the app consumed the wheel, e.g. a camera zoom); verify the effect with nbrowser_screenshot or an eval of the app state.' };+    }     if (type === 'move' || type === 'hover') {       if (showCursor) await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'act'));       // Approach from just outside, then settle on center, then dwell — a single mouseMoved
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "nb-bridge",-  "version": "0.16.1",+  "version": "0.16.2",   "type": "app",   "title": "NB - Native Browser Bridge",   "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",
tools/promote.sh

  
tools/ship.sh