← Commit history
0.3.0: nbrowser_state + nbrowser_demo (issue #22: own your window identity, no title-guessing)
6 files changed
+88−10
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.2.6+0.3.0
bridge/bridge.json+3−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.2.6",+ "version": "0.3.0", "expectedExtensionVersion": "0.2.0", "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",@@ -89,6 +89,8 @@ "nbrowser_taskbar", "nbrowser_describe", "nbrowser_handle_dialog",+ "nbrowser_state",+ "nbrowser_demo", "nbrowser_uninstall", "nbrowser_dev_reload", "nbrowser_clear_cookies",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.2.6",+ "version": "0.3.0", "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+63@@ -1318,6 +1318,8 @@ const VERB_META = { nbrowser_taskbar: { hint: 'Paint progress/overlay/flash on the window taskbar button (e.g. paused=yellow for a human gate). The "Adom is driving" badge is AUTOMATIC: the bridge composites the profile\'s circle avatar + the Adom favicon at open/drive, and idle-expires it (~3 min) back to the plain native avatar - you rarely need to touch overlay yourself.', related: ['nbrowser_flash'], pitfalls: ['clear progress+flash at task end: {progress:{state:"none"},flash:"stop"} - the badge clears itself (idle-expiry / close)', 'overlay.badge:"none" RESTORES the native avatar look (the bridge never blanks the overlay slot - blanking would erase the user\'s profile avatar)'] }, nbrowser_describe: { hint: 'Returns per-verb schemas + hint/related/pitfalls for ab\'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_state: { hint: 'ONE call for "what windows am I driving, and what do they look like": every session with its EXACT hwnd + tabId + live url/title, and (screenshot:true) the page bitmap inline as base64. Use this instead of matching a browser window by TITLE - a title is the ACTIVE TAB, it changes while loading, and several windows can share a substring (that is exactly what broke the ab web-control demo, issue #22).', related: ['nbrowser_open_window', 'nbrowser_screenshot', 'nbrowser_os_windows'], pitfalls: ['hwnd is null until the window is correlated (a page grouped into an existing window has no new hwnd) - check before passing it to desktop_* verbs', 'this reports only THIS bridge\'s sessions; for every browser window on the machine use nbrowser_os_windows', 'screenshot:true costs a real CDP capture - omit it when you only need the logical state'] },+ nbrowser_demo: { hint: 'ONE-CALL SHOWCASE of layer 2: picks a live profile, opens a representative page in the user\'s REAL signed-in browser (background + badged), and returns that exact window\'s hwnd/tabId plus an inline base64 screenshot with per-step progress. Built for embedding "drive my real browser" in a web demo without any window hunting.', related: ['nbrowser_state', 'nbrowser_open_window', 'nbrowser_close_window'], pitfalls: ['it OPENS a real window on the user\'s desktop - clean up with nbrowser_close_window {sessionId} (default "nbdemo") when the demo ends', 'idempotent: re-running closes its previous demo window first', 'needs nbe connected in some profile - if readiness says needs-extension it returns no drivable profile'] }, nbrowser_uninstall: { hint: 'CASCADE HOOK, not for normal use: ab calls this itself during bridge_uninstall (ab 2.0.35+) with {cascade:true} BEFORE deleting the bridge cache, so nb can remove artifacts that live outside it - above all the HKCU native-messaging host registration, which would otherwise leave every profile dialing a deleted binary forever ("Native host has exited"). Also clears ~/.adom/bridges/native-browser, the activity trail, and the icon cache.', related: ['nbrowser_readiness'], pitfalls: ['you almost never call this directly - run ab\'s bridge_uninstall and let it cascade', 'it deliberately does NOT remove the nbe extension from the user\'s browser (their profile, their choice) - it only stops working; use uninstalling-the-extension to remove it properly', 'best-effort by contract: a failure here still lets ab delete the cache, so check removed[]/failed[] rather than assuming'] }, 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'] },@@ -1346,6 +1348,12 @@ function decorateHint(verb, r) { return r; } +// Unwrap a dispatch() result into its parsed payload - so composite verbs (state/demo) can reuse the+// real verb paths instead of duplicating their logic (and inheriting their guards for free).+async function callSelf(verb, args) {+ try { const r = await dispatch(verb, args || {}); if (r && typeof r.output === 'string') { try { return JSON.parse(r.output); } catch { return { ok: r.success !== false, raw: r.output }; } } return r || {}; }+ catch (e) { return { ok: false, error: String((e && e.message) || e) }; }+} function dispatch(verb, args) { return new Promise((rawResolve) => { const resolve = (r) => rawResolve(decorateHint(verb, r));@@ -1557,6 +1565,8 @@ function dispatch(verb, args) { d('nbrowser_clear_cookies', 'Background logout: drop one registrable domain\'s cookies (incl. httpOnly), leaving other sites (e.g. Google SSO) intact.', { host: 'required str (e.g. "gusto.com"; url/domain also accepted)' }, { ok: 'bool', removed: 'int', hosts: '[str]' }, 30, { example: { host: 'gusto.com' } }), d('nbrowser_caption', 'Paint an on-page caption (subtitle, or red alert banner) for demos and narration.', { sessionId: S, text: 'required str (or action:"hide")', style: 'optional "subtitle"(default)|"alert"', position: 'optional str', fontSize: 'optional int', pulse: 'optional bool', durationMs: 'optional int (default 5000)', action: 'optional "hide"' }, { ok: 'bool', tabId: 'str', style: 'str' }, 20, { example: { sessionId: 's1', text: 'Signing in to DigiKey' } }), d('nbrowser_cursor', 'Show or move the gliding Adom cursor with an intent label (demo narration). Visual only, it does not click.', { sessionId: S, action: 'optional "move"(default)|"hide"', x: 'optional int', y: 'optional int', label: 'optional str (or intent)' }, { ok: 'bool', tabId: 'str', action: 'str', label: 'str' }, 20, { example: { sessionId: 's1', x: 400, y: 300, label: 'Clicking Sign in' } }),+ d('nbrowser_state', 'The EXACT windows this bridge drives: per session hwnd + tabId + live url/title (+ inline base64 page bitmap with screenshot:true). Use instead of title-matching a browser window.', { sessionId: 'optional str (default: all my sessions)', screenshot: 'optional bool - include the page bitmap inline' }, { ok: 'bool', count: 'int', sessions: '[{sessionId,hwnd,windowId,tabId,url,title,profile,thread,purpose,osOpened}]', screenshot: '{sessionId,base64,width,height}?' }, 30, { example: { screenshot: true } }),+ d('nbrowser_demo', 'One-call showcase: open a representative page in the user\'s REAL signed-in browser (background, badged) and return that exact window + an inline base64 screenshot + step progress.', { url: 'optional str (default the nb-bridge wiki page)', profile: 'optional str (default: first live profile)', sessionId: 'optional str (default "nbdemo")', thread: 'optional str' }, { ok: 'bool', sessionId: 'str', profile: 'str', hwnd: 'int?', tabId: 'str?', base64: 'str', width: 'int', height: 'int', steps: '[{step,ok}]' }, 60, { example: {} }), d('nbrowser_uninstall', 'CASCADE HOOK (ab 2.0.35+): ab calls this during bridge_uninstall, before deleting the cache, so nb removes its own out-of-cache artifacts (HKCU native-messaging host for Chrome + Edge, ~/.adom/bridges/native-browser, activity trail, icon cache). Never removes the extension from the user\'s browser.', { cascade: 'bool (set by ab)', name: 'str (set by ab)' }, { ok: 'bool', removed: '[str]', failed: '[str]', extensionStillInstalled: '[str]' }, 30, { example: { cascade: true, name: 'native-browser' } }), d('nbrowser_dev_reload', 'MAINTAINER-only: reload the extension to pick up on-disk code changes (2-pass, reconnects in ~10s). Throttled to 1 per 90s.', { profile: P }, { ok: 'bool', reloaded: 'bool', throttled: 'bool?' }, 30, { example: { profile: 'chrome:[email protected]' } }), d('nbrowser_describe', 'This verb: returns per-verb schemas for the ab Verbs tab.', {}, { success: 'bool', verbs: '[{name,summary,input,output,timeoutSeconds,statusVerb,longRunning,example}]' }, 15, { example: {} }),@@ -1826,6 +1836,59 @@ function dispatch(verb, args) { // with the extension gets "Native host has exited" with no way to diagnose it. We own that key, so // we clean it. We deliberately do NOT remove the extension from the user's browser: that lives in // THEIR profile and is THEIR choice (uninstalling-the-extension walks them through it if they want).+ // nbrowser_state - "what am I driving right now, and what does it look like" in ONE call (issue #22).+ // A caller must never title-guess a browser window: we OWN the windows we opened, so we return the+ // exact hwnd + tabId + live url/title per session, and optionally the page bitmap inline (base64),+ // so a WEB page or an AI can render it without a second round-trip or any window hunting.+ if (verb === 'nbrowser_state') {+ (async () => {+ const want = a && a.sessionId;+ const wins = await callSelf('nbrowser_list_windows', want ? { sessionId: want } : {});+ const rows = (wins.windows || wins.sessions || []).filter((w) => !want || w.sessionId === want);+ const sessions = rows.map((w) => {+ const sid = w.sessionId;+ return { sessionId: sid, hwnd: sessionHwnd.get(sid) || w.hwnd || null, windowId: w.windowId ?? null,+ tabId: w.tabId ?? w.activeTabId ?? null, url: w.url || null, title: w.title || null,+ profile: sessionProfile.get(sid) || w.profile || null, thread: sessionThread.get(sid) || null,+ purpose: sessionPurpose.get(sid) || null, osOpened: osOpened.has(sid) };+ });+ let shot = null;+ if (a && a.screenshot && sessions.length) {+ const s = await callSelf('nbrowser_screenshot', { sessionId: want || sessions[0].sessionId });+ if (s && s.base64) shot = { sessionId: want || sessions[0].sessionId, base64: s.base64, width: s.width, height: s.height };+ }+ resolve({ success: true, output: JSON.stringify({ ok: true, count: sessions.length, sessions, screenshot: shot,+ _hint: sessions.length+ ? `The EXACT windows this bridge is driving - hwnd is authoritative, so never match a browser window by TITLE (a title is the active tab and changes while loading; several windows can share a substring - that is what broke the web-control demo, issue #22). Pass screenshot:true to get the page bitmap inline (base64) in the same call. Act on a window with its hwnd (ab desktop_screenshot_window {hwnd} for the frame + native popups) or its sessionId (nbrowser_* for the page).`+ : 'This bridge is driving NO windows right now. Open one with nbrowser_open_window {sessionId, thread, purpose, profile, url} - it returns the hwnd + tabId of exactly that window.' }) });+ })();+ return;+ }+ // nbrowser_demo - one call that showcases layer 2: open a representative page in the user's REAL+ // signed-in browser and hand back the exact window + an inline screenshot. Built for the ab+ // web-control demo (issue #22) so a page can show "drive my real browser" with zero title-guessing.+ if (verb === 'nbrowser_demo') {+ (async () => {+ const steps = [];+ const url = (a && a.url) || 'https://wiki.adom.inc/adom/nb-bridge';+ const sid = (a && a.sessionId) || 'nbdemo';+ let profile = a && a.profile;+ if (!profile) { const ps = await callSelf('nbrowser_profiles'); const live = (ps.profiles || []).find((p) => p.live && p.email) || (ps.profiles || []).find((p) => p.email); profile = live && live.profile; steps.push({ step: 'resolve profile', ok: !!profile, profile }); }+ if (!profile) return resolve({ success: false, error: 'no drivable profile found', _hint: 'Run nbrowser_readiness: if state is needs-extension, offer the nbe install (installing-the-extension).', steps });+ await callSelf('nbrowser_close_window', { sessionId: sid }); // idempotent re-run+ const open = await callSelf('nbrowser_open_window', { sessionId: sid, thread: (a && a.thread) || 'nbrowser_demo', purpose: 'nb demo: show the AI driving the real signed-in browser', profile, url });+ steps.push({ step: 'open in real browser (background)', ok: open.ok === true, hwnd: open.hwnd, tabId: open.tabId });+ if (open.ok !== true) return resolve({ success: false, error: open.error || 'open failed', steps });+ await new Promise((r) => setTimeout(r, 1200));+ const shot = await callSelf('nbrowser_screenshot', { sessionId: sid });+ steps.push({ step: 'screenshot the exact window', ok: !!shot.base64, bytes: shot.sizeKB });+ resolve({ success: true, output: JSON.stringify({ ok: true, sessionId: sid, profile, url, hwnd: open.hwnd || null, tabId: open.tabId || null,+ base64: shot.base64 || null, width: shot.width, height: shot.height, steps,+ _hint: `Opened ${url} in the user's REAL signed-in browser (profile ${profile}) in the BACKGROUND, badged as AI-driven, and captured that EXACT window - no title matching anywhere. Render base64 inline. Clean up with nbrowser_close_window {sessionId:"${sid}"} when the demo is done.`,+ _next: 'nbrowser_close_window' }) });+ })();+ return;+ } if (verb === 'nbrowser_uninstall') { (async () => { const removed = [], failed = [];
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.2.6",- "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.2.6/native-browser-bridge-v0.2.6.zip",- "sha256": "59eb1fa3ab714636a31e23bd71a31ff243122087763b1ab5c09fdea202e49a7f",- "size": 103186,- "released_at": "2026-08-14T11:42:31Z"+ "version": "0.3.0",+ "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.3.0/native-browser-bridge-v0.3.0.zip",+ "sha256": "94b763d5357a0221a7d9ff8605a5fb0a922dfe6c0772b1aca514973e2fe5bf58",+ "size": 105779,+ "released_at": "2026-08-18T14:52:21Z" }
tools/verb-battery.mjs+15−2@@ -183,6 +183,19 @@ const T = { const c = call('nbrowser_close_window', { sessionId: SID + '-l1' }); return A(c.closed === true || c.ok === true, 'layer-1 open + close_window os path (WM_CLOSE)', JSON.stringify(c).slice(0, 160)); },+ state: () => {+ const r = call('nbrowser_state', { sessionId: SID, screenshot: true });+ const s = (r.sessions || [])[0];+ if (!s) return [false, 'no session reported by state: ' + JSON.stringify(r).slice(0, 160)];+ const hasShot = !!(r.screenshot && r.screenshot.base64);+ return A(s.hwnd && String(s.url || '').includes('test-harness') && hasShot, `hwnd:${s.hwnd} url matched, inline shot ${hasShot ? 'present' : 'MISSING'}`, `session:${JSON.stringify(s).slice(0, 160)} shot:${hasShot}`);+ },+ demo: () => {+ const r = call('nbrowser_demo', { sessionId: 'nbbattery-demo', url: HARNESS, profile: PROFILE, thread: THREAD }, 120000);+ const ok = r.ok === true && !!r.base64 && !!r.hwnd;+ call('nbrowser_close_window', { sessionId: 'nbbattery-demo' });+ return A(ok, `demo opened hwnd:${r.hwnd} + returned ${r.width}x${r.height} inline shot`, JSON.stringify(r).slice(0, 200));+ }, // guards-as-tests guard_autologin: () => { const r = call('nbrowser_autologin', { sessionId: SID, host: 'wiki.adom.inc' }); return A(r.ok !== true && r.loggedIn !== true, 'refused without confirm:true', 'AUTOLOGIN RAN WITHOUT CONFIRM: ' + JSON.stringify(r).slice(0, 120)); }, guard_close_all: () => { const r = call('nbrowser_close'); return A(r.ok !== true || /confirm/i.test(JSON.stringify(r)), 'close-all demands confirmCloseAll', 'CLOSE-ALL RAN UNCONFIRMED: ' + JSON.stringify(r).slice(0, 120)); },@@ -220,7 +233,7 @@ export const COVERAGE = { handle_dialog: 'quarantine:nb-bridge#21 confirm() wedges the page', login_state: 'live', login_form: 'live', credentials: 'live', totp: 'live', clear_cookies: 'live', add_extension_origin: 'live', owned: 'live', cleanup: 'live', close_window: 'live', profile_block: 'live', profile_unblock: 'live',- open_os_window: 'live',+ open_os_window: 'live', state: 'live', demo: 'live', autologin: 'guard', close: 'guard', force_close: 'guard', events: 'live:events_and_owned', install_chrome: 'skip:starts a real Chrome install on the desktop — exercised manually on fresh boxes',@@ -257,7 +270,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { 'open_tab','list_tabs','switch_tab','close_tab', 'record_start','record_status','record_stop','record_list', 'download','download_wait','login_state','login_form','credentials','totp','clear_cookies','add_extension_origin',- 'open_os_window','guard_autologin','guard_record_fallback','guard_force_close','guard_close_all','profile_block','profile_unblock',+ 'state','demo','open_os_window','guard_autologin','guard_record_fallback','guard_force_close','guard_close_all','profile_block','profile_unblock', 'events_and_owned','handle_dialog','cleanup','owned','close_window']; for (const name of order) { const fn = T[name]; if (!fn) { record(name, '??', false, 'no test fn'); continue; }