← Commit history
feature batch 1 (roadmap #81): nbrowser_wait_for (deterministic selector/url/text wait) + nbrowser_keepalive (anti-idle-signout); bridge 0.0.31, ext 0.0.17
8 files changed
+77−10
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.0.30+0.0.31
bridge/bridge.json+3−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser (Chrome + Edge)",- "version": "0.0.30",+ "version": "0.0.31", "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.",@@ -47,6 +47,8 @@ "nbrowser_back", "nbrowser_forward", "nbrowser_wait",+ "nbrowser_keepalive",+ "nbrowser_wait_for", "nbrowser_screenshot", "nbrowser_screenshot_full_res", "nbrowser_eval",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.0.30",+ "version": "0.0.31", "private": true, "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)", "main": "server.js",
bridge/server.js+5−1@@ -637,7 +637,9 @@ const VERB_META = { nbrowser_reload: { hint: 'Reload a tab. ' + OWN_ONLY, related: ['nbrowser_navigate'], pitfalls: ['⛔ never reload a window the user is in - you lose their unsaved state; reload only your own session'] }, nbrowser_back: { hint: 'History back on your own session tab (via the page history API, not chrome.tabs - reliable on debugger-driven tabs, no banner).', related: ['nbrowser_forward', 'nbrowser_navigate'], pitfalls: ['only on a window you opened', 'errors if the tab has no previous page to go back to'] }, nbrowser_forward: { hint: 'History forward on your own session tab (via the page history API, not chrome.tabs).', related: ['nbrowser_back', 'nbrowser_navigate'], pitfalls: ['only on a window you opened', 'no-op if there is no forward entry'] },- nbrowser_wait: { hint: 'Sleep server-side (let a page settle) before the next verb.', related: ['nbrowser_screenshot'], pitfalls: [] },+ nbrowser_wait: { hint: 'Sleep server-side (let a page settle) before the next verb.', related: ['nbrowser_wait_for', 'nbrowser_screenshot'], pitfalls: ['prefer nbrowser_wait_for for a DETERMINISTIC wait (selector/url/text) instead of a fixed sleep'] },+ nbrowser_wait_for: { hint: 'DETERMINISTIC wait after a click/nav: block until a CSS selector exists / the URL contains a substring / the visible text contains a substring, or timeoutMs. Use this instead of nbrowser_wait + fixed sleeps + coordinate races. Returns {matched, by, url, waitedMs}.', related: ['nbrowser_click', 'nbrowser_navigate', 'nbrowser_eval'], pitfalls: ['pass at least one of selector / urlIncludes / textIncludes', 'matched:false + timedOut:true means the condition never appeared - screenshot to see the real page', 'selector matches the top document only (not inside cross-origin iframes)'] },+ nbrowser_keepalive: { hint: 'Keep a session ACTIVE to avoid vendor idle sign-outs (Gusto/Intuit ~10-15 min): replays a benign mousemove/focus on an interval (NO navigation, no side-effects). Per-session; auto-stops when the window closes. Call with {stop:true} to cancel. Set at the START of a long unattended automation.', related: ['nbrowser_open_window', 'nbrowser_login_state'], pitfalls: ['client-side activity only - a vendor that expires the SERVER session regardless may still sign out; combine with periodic real actions if so', 'default interval 4min (30s..15min); pick below the vendor idle timeout'] }, nbrowser_screenshot: { hint: 'CDP screenshot - captures a BACKGROUND/occluded window WITHOUT foregrounding it. This is how you "see" a page; never pull a window forward to grab a shot.', related: ['nbrowser_eval', 'nbrowser_click'], pitfalls: ['resized <=1568 for the model; pass toFile:true to save full-res to a laptop file (pull_file it)'] }, nbrowser_screenshot_full_res: { hint: 'Full-resolution / full-page screenshot via CDP.', related: ['nbrowser_screenshot'], pitfalls: ['large base64 can be slow over the relay - prefer toFile then pull_file'] }, 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'] },@@ -758,6 +760,8 @@ function dispatch(verb, args) { d('nbrowser_back', 'History back.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work' } }), d('nbrowser_forward', 'History forward.', { sessionId: S, tabId: 'optional str' }, { ok: 'bool' }, 30, { example: { sessionId: 'work' } }), d('nbrowser_wait', 'Sleep server-side for ms (or wait on the tab).', { sessionId: S, ms: 'optional int' }, { ok: 'bool' }, 125, { example: { ms: 1000 } }),+ d('nbrowser_wait_for', 'DETERMINISTIC wait: block until a selector exists / URL contains / visible text contains, or timeoutMs. Use instead of fixed sleeps after a click/nav.', { sessionId: S, selector: 'optional CSS selector', urlIncludes: 'optional url substring', textIncludes: 'optional visible-text substring', timeoutMs: 'optional int (default 15000, max 120000)' }, { ok: 'bool', matched: 'bool', by: '"selector"|"url"|"text"', url: 'str', waitedMs: 'int', timedOut: 'bool?' }, 125, { example: { selector: '#dashboard' } }),+ d('nbrowser_keepalive', 'Keep a session active (benign mousemove/focus on an interval) to avoid vendor idle sign-outs. Per-session; auto-stops on window close; {stop:true} to cancel.', { sessionId: S, intervalSec: 'optional int (30..900, default 240)', stop: 'optional bool' }, { ok: 'bool', keepalive: '"on"|"off"', intervalSec: 'int' }, 15, { example: { intervalSec: 240 } }), d('nbrowser_screenshot', 'Screenshot the tab via CDP (captures a BACKGROUND/occluded window; resized <=1568).', { profile: P, sessionId: S, tabId: 'optional str', maxWidth: 'optional int' }, { ok: 'bool', base64: 'str', filePath: 'str?' }, 120, { example: { sessionId: 'work', maxWidth: 1500 } }), d('nbrowser_screenshot_full_res', 'Full-resolution / full-page screenshot via CDP.', { profile: P, sessionId: S, tabId: 'optional str', fullPage: 'optional bool' }, { ok: 'bool', base64: 'str' }, 120, { example: { sessionId: 'work', fullPage: true } }), d('nbrowser_eval', 'Run JS in the page context and return the result.', { sessionId: S, tabId: 'optional str', expression: 'required str (JS)' }, { ok: 'bool', result: 'any' }, 45, { example: { sessionId: 'work', expression: 'document.title' } }),
extension/manifest.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 3, "name": "Adom for Chrome & Edge", "short_name": "Adom",- "version": "0.0.16",+ "version": "0.0.17", "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/native.js+59@@ -295,6 +295,65 @@ export async function wait(args = {}) { return { ok: true, waited: ms }; } +// nbrowser_wait_for - poll the page until a CSS selector exists / the URL contains a substring / the+// visible text contains a substring, or timeoutMs elapses. Deterministic post-click/nav wait instead+// of fixed sleeps + coordinate races. Pass one or more of {selector, urlIncludes, textIncludes}.+export async function waitFor(args = {}) {+ const tabId = await resolveTabId(args);+ const { selector, urlIncludes, textIncludes } = args;+ if (!selector && !urlIncludes && !textIncludes) throw new Error('wait_for needs one of: selector, urlIncludes, textIncludes');+ const timeoutMs = Math.min(Number(args.timeoutMs) || 15000, 120000);+ const t0 = Date.now();+ while (Date.now() - t0 < timeoutMs) {+ let res;+ try {+ const [{ result } = {}] = await chrome.scripting.executeScript({+ target: { tabId },+ args: [selector || null, urlIncludes || null, textIncludes || null],+ func: (sel, u, t) => {+ try {+ if (sel && document.querySelector(sel)) return { matched: true, by: 'selector', url: location.href };+ if (u && location.href.includes(u)) return { matched: true, by: 'url', url: location.href };+ if (t && ((document.body && document.body.innerText) || '').includes(t)) return { matched: true, by: 'text', url: location.href };+ return { matched: false, url: location.href };+ } catch (e) { return { matched: false, error: String(e) }; }+ },+ });+ res = result;+ } catch (e) { res = null; } // page mid-navigation (no doc yet) - keep polling+ if (res && res.matched) return { ok: true, matched: true, by: res.by, url: res.url, waitedMs: Date.now() - t0, tabId: String(tabId), sessionId: args.sessionId };+ await new Promise((r) => setTimeout(r, 250));+ }+ return { ok: false, matched: false, timedOut: true, waitedMs: Date.now() - t0, tabId: String(tabId), sessionId: args.sessionId };+}++// nbrowser_keepalive - replay a benign no-op interaction (mousemove + focus, NO navigation/side-effects)+// on an interval to stave off vendor idle sign-outs (Gusto/Intuit ~10-15 min). Per-session; auto-clears+// when the tab/window closes. Pass {stop:true} to cancel. Re-calling replaces the interval.+const _keepalives = new Map(); // sessionId -> interval handle+export async function keepalive(args = {}) {+ const sid = args.sessionId;+ if (!sid) throw new Error('keepalive needs a sessionId');+ const existing = _keepalives.get(sid);+ if (existing) { clearInterval(existing); _keepalives.delete(sid); }+ if (args.stop) return { ok: true, keepalive: 'off', stopped: true, sessionId: sid };+ const intervalSec = Math.max(30, Math.min(Number(args.intervalSec) || 240, 900)); // 30s..15min, default 4min+ const stopSelf = () => { const h = _keepalives.get(sid); if (h) { clearInterval(h); _keepalives.delete(sid); } };+ const tick = async () => {+ try {+ const tabId = await resolveTabId({ sessionId: sid });+ await chrome.scripting.executeScript({ target: { tabId }, func: () => {+ try { document.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, clientX: 2, clientY: 2 })); } catch {}+ try { window.dispatchEvent(new Event('focus')); } catch {}+ } });+ } catch (e) { stopSelf(); } // tab/window gone -> stop+ };+ const h = setInterval(tick, intervalSec * 1000);+ _keepalives.set(sid, h);+ tick();+ return { ok: true, keepalive: 'on', intervalSec, sessionId: sid };+}+ // Detect whether the user appears SIGNED IN to a site, by inspecting its cookies. Chrome does // NOT expose saved passwords to extensions (security), so we can't read a stored credential — // but auth/session cookies tell us if a live session exists, which is what you need before
extension/src/sw.js+2@@ -167,6 +167,8 @@ async function dispatch(verb, args) { case 'nbrowser_back': return native.back(args); case 'nbrowser_forward': return native.forward(args); case 'nbrowser_wait': return native.wait(args);+ case 'nbrowser_wait_for': return native.waitFor(args);+ case 'nbrowser_keepalive': return native.keepalive(args); // --- input (CDP, isTrusted) ------------------------------------------ case 'nbrowser_input_dispatch':
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.0.30",- "url": "native-browser-bridge-v0.0.30.bin",- "sha256": "f67eaf62abf8247dfc0d4694f840182367c6d37d0800e68a4c83c6ed18a2a8c6",- "size": 35020,- "released_at": "2026-07-05T14:02:24Z"+ "version": "0.0.31",+ "url": "native-browser-bridge-v0.0.31.bin",+ "sha256": "57a4d6e47f8ccc4e8eff532481cd4f822d7fb436054573cec2f836ed970a26a7",+ "size": 35815,+ "released_at": "2026-07-05T17:27:13Z" }