← Commit history
nb 0.5.0: persisted user settings + nbrowser_settings (overlayBadge, taskbarActivity progress|flash|off, flashMode); overlay gate; taskbar progress engine sized by measured verb durations
6 files changed
+135−12
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.4.13+0.5.0
bridge/bridge.json+2−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser",- "version": "0.4.13",+ "version": "0.5.0", "expectedExtensionVersion": "0.2.8", "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",@@ -85,6 +85,7 @@ "nbrowser_record_list", "nbrowser_flash", "nbrowser_flash_stop",+ "nbrowser_settings", "nbrowser_flash_mode", "nbrowser_taskbar", "nbrowser_describe",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.4.13",+ "version": "0.5.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+113−3@@ -579,7 +579,46 @@ function janitor() { } setTimeout(janitor, 10000); setInterval(janitor, 6 * 3600 * 1000).unref();-let flashMode = 'quiet'; // quiet | chatty | off+let flashMode = 'quiet'; // quiet | chatty | off (mirrors SETTINGS.flashMode)+// ── USER SETTINGS (John via the dock-bar thread, 2026-08-23): the dashboard's settings panel must+// drive REAL behavior, and every value must read back literally (the kicad dashboard once stored an+// enum as a bool and ran its fallback branch for days). Typed by the default; enums validated.+// overlayBadge bool paint the Adom-composite avatar badge on a driven window's taskbar button+// taskbarActivity enum progress | flash | off - what the button does WHILE a verb runs+// flashMode enum quiet | chatty | off - auto-flash verbosity on come-look moments+// verbDurations (rolling averages, ms) ride in the same file so the progress bar can be sized.+const SETTINGS_DEFAULTS = { overlayBadge: true, taskbarActivity: 'progress', flashMode: 'quiet' };+const SETTINGS_ENUMS = { taskbarActivity: ['progress', 'flash', 'off'], flashMode: ['quiet', 'chatty', 'off'] };+const SETTINGS_FILE = path.join(os.homedir(), '.adom', 'native-browser', 'settings.json');+let SETTINGS = { ...SETTINGS_DEFAULTS };+let VERB_DUR = {}; // verb -> { avgMs, n }+function loadSettings() {+ try {+ const j = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));+ for (const k of Object.keys(SETTINGS_DEFAULTS)) if (j[k] !== undefined) SETTINGS[k] = j[k];+ if (j.verbDurations && typeof j.verbDurations === 'object') VERB_DUR = j.verbDurations;+ } catch {}+ SETTINGS = validateSettings(SETTINGS);+ flashMode = SETTINGS.flashMode;+}+function validateSettings(obj) {+ const out = { ...SETTINGS_DEFAULTS };+ for (const k of Object.keys(SETTINGS_DEFAULTS)) {+ const v = obj[k]; const d = SETTINGS_DEFAULTS[k];+ if (typeof d === 'boolean') out[k] = (v === true || v === 'true' || v === 1) ? true : (v === false || v === 'false' || v === 0) ? false : d;+ else if (SETTINGS_ENUMS[k]) out[k] = SETTINGS_ENUMS[k].includes(String(v)) ? String(v) : d;+ else out[k] = v === undefined ? d : v;+ }+ return out;+}+let _saveSettingsT = null;+function saveSettings() {+ clearTimeout(_saveSettingsT);+ _saveSettingsT = setTimeout(() => {+ try { fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true }); fs.writeFileSync(SETTINGS_FILE, JSON.stringify({ ...SETTINGS, verbDurations: VERB_DUR }, null, 2)); } catch {}+ }, 150);+}+loadSettings(); const COMELOOK = new Set(['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_screenshot_full_res', 'nbrowser_download']); const ACTIVITY = new Set([...COMELOOK, 'nbrowser_reload', 'nbrowser_click', 'nbrowser_double_click', 'nbrowser_right_click', 'nbrowser_type', 'nbrowser_press_key', 'nbrowser_input_dispatch', 'nbrowser_hover', 'nbrowser_eval', 'nbrowser_evaluate', 'nbrowser_open_tab', 'nbrowser_back', 'nbrowser_forward', 'nbrowser_select', 'nbrowser_wait_for', 'nbrowser_switch_tab', 'nbrowser_switch_window', 'nbrowser_close_tab', 'nbrowser_set_date', 'nbrowser_download_wait']); const b64 = (s) => Buffer.from(s, 'utf8').toString('base64');@@ -603,6 +642,50 @@ function taskbar(hwnd, opts) { }).catch(() => {}); } function flashHwnd(hwnd, stop) { taskbar(hwnd, { flash: stop ? 'stop' : 'until_focused' }); }+// ── TASKBAR ACTIVITY (the dashboard spec, 2026-08-23; pup-taskbar-progress is the reference): while a+// verb runs on a driven window, its taskbar button shows a DETERMINATE progress bar sized by that+// verb's measured average duration (rolling, persisted), starting at dispatch and clearing on reply.+// A verb with no measurement yet, or an open-ended one (wait / record / dialogs the user answers),+// shows an INDETERMINATE marquee instead. Throttled to one paint per ~350ms and never past 95% until+// the reply lands, so the bar cannot lie about being done. Setting taskbarActivity: progress|flash|off.+const OPEN_ENDED = /^nbrowser_(wait|wait_for|record_start|record_stop|download_wait|handle_dialog|autologin|keepalive)$/;+const _act = new Map(); // pending id -> { hwnd, verb, t0, timer }+const _lastFlashAt = new Map(); // hwnd -> ts (flash mode throttle)+function activityStart(id, verb, sid) {+ if (SETTINGS.taskbarActivity === 'off' || !sid) return;+ const hwnd = sessionHwnd.get(sid); if (!hwnd) return;+ if (/^nbrowser_(taskbar|flash|flash_stop|status|ping|list_tabs|events|owned)$/.test(verb)) return; // too short / meta+ if (SETTINGS.taskbarActivity === 'flash') {+ // the old-style cue: an orange flash when work starts on this window, at most once per 5s+ const last = _lastFlashAt.get(hwnd) || 0;+ if (Date.now() - last > 5000) { _lastFlashAt.set(hwnd, Date.now()); flashHwnd(hwnd, false); }+ return;+ }+ const avg = (VERB_DUR[verb] && VERB_DUR[verb].avgMs) || 0;+ const indeterminate = OPEN_ENDED.test(verb) || !avg;+ const t0 = Date.now();+ const rec = { hwnd, verb, t0, timer: null };+ _act.set(id, rec);+ if (indeterminate) { taskbar(hwnd, { progress: { state: 'indeterminate' } }); return; }+ const tick = () => {+ if (!_act.has(id)) return;+ const v = Math.min(0.95, (Date.now() - t0) / Math.max(avg, 250));+ taskbar(hwnd, { progress: { state: 'normal', value: v } });+ rec.timer = setTimeout(tick, 350);+ };+ tick();+}+function activityStop(id, verb, ok, tookMs) {+ if (ok && tookMs > 0 && !OPEN_ENDED.test(verb)) {+ const d = VERB_DUR[verb] || { avgMs: 0, n: 0 };+ d.avgMs = d.n ? Math.round((d.avgMs * Math.min(d.n, 19) + tookMs) / (Math.min(d.n, 19) + 1)) : tookMs;+ d.n = (d.n || 0) + 1; VERB_DUR[verb] = d; saveSettings();+ }+ const rec = _act.get(id); if (!rec) return;+ _act.delete(id); clearTimeout(rec.timer);+ taskbar(rec.hwnd, { progress: { state: ok ? 'none' : 'error' } });+ if (!ok) setTimeout(() => taskbar(rec.hwnd, { progress: { state: 'none' } }), 2500);+} // ── BADGE IDLE-EXPIRY (John, 2026-07-14): the "Adom is driving" badge should reflect ACTIVE driving. If // the AI stops sending commands to a window for a while, the badge auto-clears (so it doesn't imply the AI@@ -813,6 +896,7 @@ function nativeOverlay(hwnd, profileKey) { } function applyBadge(sid, hwnd, profileKey, style) { if (!BADGE_ENABLED || !sid || !hwnd) return;+ if (SETTINGS.overlayBadge === false) return; // the user turned the "Adom is driving" badge off badgeOn.add(sid); armBadgeIdle(sid); paintBadge(hwnd, profileKey || sessionProfile.get(sid), sid, style); }@@ -1381,6 +1465,7 @@ const VERB_META = { nbrowser_record_list: { hint: 'List recent recordings.', related: ['nbrowser_record_status'], pitfalls: ['paths are on the LAPTOP - pull them with adom-bridge 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: ['the flash already auto-clears when the user focuses the window - only stop it early if you no longer need their attention'] },+ nbrowser_settings: { hint: 'Read or set the user settings behind the NB Dashboard Settings panel: overlayBadge (bool), taskbarActivity (progress|flash|off), flashMode (quiet|chatty|off). {} reads; {set:{...}} writes, validated by type, persisted on the desktop, and returns the STORED literal.', related: ['nbrowser_flash_mode', 'nbrowser_taskbar'], pitfalls: ['settings are PER DESKTOP (stored on the machine the bridge runs on) - set them on every --target the user uses', 'a rejected key changes nothing; read the returned settings rather than assuming your write landed', 'overlayBadge:false also restores the native avatar on windows currently badged'] }, 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). 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'] },@@ -1642,6 +1727,7 @@ function dispatch(verb, args) { d('nbrowser_record_list', 'List recent recordings.', {}, { ok: 'bool', recordings: '[...]' }, 20, { example: {} }), d('nbrowser_flash', "Flash the driven window's taskbar button ('come look' cue).", { sessionId: 'required str' }, { ok: 'bool' }, 15, { example: { sessionId: 'work' } }), d('nbrowser_flash_stop', 'Stop the taskbar flash.', { sessionId: 'required str' }, { ok: 'bool' }, 15, { example: { sessionId: 'work' } }),+ d('nbrowser_settings', 'Read/set the dashboard settings (overlayBadge, taskbarActivity, flashMode); writes are validated and the stored literal is returned.', { set: 'optional {overlayBadge?:bool, taskbarActivity?:"progress"|"flash"|"off", flashMode?:"quiet"|"chatty"|"off"}' }, { ok: 'bool', settings: 'obj', defaults: 'obj', enums: 'obj', changed: '[str]', file: 'str' }, 15, { example: { set: { overlayBadge: false } } }), d('nbrowser_flash_mode', 'Set auto-flash verbosity.', { mode: 'required "quiet"|"chatty"|"off"' }, { ok: 'bool', mode: 'str' }, 15, { example: { mode: 'quiet' } }), d('nbrowser_taskbar', 'Paint progress/overlay/flash on the window taskbar button.', { sessionId: 'required str', progress: 'optional {state,value?}', overlay: 'optional {badge}', flash: 'optional "until_focused"|"stop"' }, { ok: 'bool', applied: '[str]' }, 15, { example: { sessionId: 'work', progress: { state: 'indeterminate' } } }), // These 12 were implemented but never described (nor declared in bridge.json verbs[]), so they@@ -2238,12 +2324,33 @@ function dispatch(verb, args) { return resolve({ success: true, output: JSON.stringify({ ok: updated.length > 0 || present.length > 0, origin, updatedManifests: updated, alreadyPresent: present, _hint: (updated.length || present.length) ? 'Origin is in the host manifest now. RELOAD the extension in that browser (edge://extensions -> Reload) so it re-reads allowed_origins and can reach the native host; then nbrowser_profiles should list it.' : 'No host-manifest.json found (checked ~/.adom/native-browser and ~/adom-native-browser/host). Re-run the install, or the manifest is elsewhere.' }) }); } // --- taskbar flash (bridge-local; targets the cached driven-window hwnd) ---+ // nbrowser_settings - read/write the user settings the dashboard's Settings panel exposes. Every+ // write is validated by the type of its default and the STORED literal is returned, so the panel can+ // show recomputed truth rather than what it last clicked.+ if (verb === 'nbrowser_settings') {+ const set = args && args.set && typeof args.set === 'object' ? args.set : null;+ let changed = [];+ if (set) {+ const before = { ...SETTINGS };+ const merged = validateSettings({ ...SETTINGS, ...set });+ const rejected = Object.keys(set).filter((k) => !(k in SETTINGS_DEFAULTS) || (SETTINGS_ENUMS[k] && !SETTINGS_ENUMS[k].includes(String(set[k]))));+ SETTINGS = merged; flashMode = SETTINGS.flashMode; saveSettings();+ changed = Object.keys(SETTINGS_DEFAULTS).filter((k) => before[k] !== SETTINGS[k]);+ if (changed.includes('overlayBadge') && SETTINGS.overlayBadge === false) {+ // turning the badge OFF restores the native avatar on every window we are badging right now+ for (const sid of [...badgeOn]) { const h = sessionHwnd.get(sid); if (h) taskbar(h, { overlay: { badge: 'none' } }); badgeOn.delete(sid); }+ }+ if (rejected.length) return resolve({ success: false, error: `rejected: ${rejected.join(', ')}`, settings: SETTINGS, _hint: `Valid keys: ${Object.keys(SETTINGS_DEFAULTS).join(', ')}. Enums: ${JSON.stringify(SETTINGS_ENUMS)}. Nothing was changed for the rejected keys.` });+ }+ return resolve({ success: true, output: JSON.stringify({ ok: true, settings: SETTINGS, defaults: SETTINGS_DEFAULTS, enums: SETTINGS_ENUMS, changed, file: SETTINGS_FILE, verbDurations: Object.keys(VERB_DUR).length,+ _hint: 'overlayBadge: paint the Adom-composite avatar badge on a driven window\'s taskbar button (the "Adom is driving" tell). taskbarActivity: what the button does WHILE a verb runs - progress (a determinate bar sized by that verb\'s measured average, indeterminate for open-ended verbs), flash (orange flash when work starts), off (nothing). flashMode: auto-flash verbosity on come-look moments (quiet|chatty|off). All persisted on this desktop in ' + SETTINGS_FILE + '. The stored literal is what this verb returns.' }) });+ } 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=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;+ flashMode = m; SETTINGS.flashMode = m; saveSettings(); 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 }) }); }@@ -2413,9 +2520,11 @@ function dispatch(verb, args) { const id = ++seq; const timer = setTimeout(() => { pending.delete(id);+ activityStop(id, verb, false); resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' }); }, timeoutFor(verb));- pending.set(id, { resolve, timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn });+ pending.set(id, { resolve, timer, verb, toFile: !!(args && args.toFile), profileKey: tgt.key, args, caller: currentCaller(), crossThreadWarn, startedAt: Date.now() });+ activityStart(id, verb, args && args.sessionId); sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n'); }; // PIN the session to whatever profile this window opened in, so every later verb carrying this@@ -2453,6 +2562,7 @@ async function onHostFrameInner(frame) { const p = pending.get(frame.id); clearTimeout(p.timer); pending.delete(frame.id);+ if (p.startedAt) activityStop(frame.id, p.verb, true, Date.now() - p.startedAt); if (frame.ok) { let payload = frame.payload || {}; // Screenshot file-save: huge base64 over the relay times out, so when the caller passes
native-browser-bridge-manifest.json+5−5@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.4.13",- "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.4.13/native-browser-bridge-v0.4.13.zip",- "sha256": "6295a09b99e8869b7e8ae9c270f6f22c2b003e76ef6fe74ca442bced7dc833f4",- "size": 107568,- "released_at": "2026-08-23T13:24:01Z"+ "version": "0.5.0",+ "url": "https://wiki.adom.inc/download/adom/nb-bridge/0.5.0/native-browser-bridge-v0.5.0.zip",+ "sha256": "acf0f31f341de460a54fe290d3919c693037f29d9e18c8670832078a086e9901",+ "size": 110441,+ "released_at": "2026-08-23T15:45:45Z" }
tools/verb-battery.mjs+13−1@@ -94,6 +94,18 @@ const T = { reload: () => { const r = call('nbrowser_reload', { sessionId: SID }); return A(r.ok !== false, 'reloaded', JSON.stringify(r).slice(0, 120)); }, // back/forward POLL for the expected URL instead of sampling once - the fixed 800ms sample raced a // slower box (ROG failed 'back' while a manual check with a settle passed; history commits are async).+ settings: () => {+ // write-then-read-back, literal for literal (the settings-layout doctrine): flip overlayBadge+ // off, read it back, restore the ORIGINAL value, read again.+ const before = call('nbrowser_settings', {});+ const orig = before.settings && before.settings.overlayBadge;+ const w = call('nbrowser_settings', { set: { overlayBadge: false, taskbarActivity: 'flash' } });+ const r = call('nbrowser_settings', {});+ const back = call('nbrowser_settings', { set: { overlayBadge: orig !== false, taskbarActivity: (before.settings || {}).taskbarActivity || 'progress' } });+ const bad = call('nbrowser_settings', { set: { taskbarActivity: 'sideways' } });+ return A(w.ok === true && r.settings && r.settings.overlayBadge === false && r.settings.taskbarActivity === 'flash' && back.settings && back.settings.overlayBadge === (orig !== false) && bad.ok !== true,+ `stored literals read back; invalid enum rejected`, `w:${JSON.stringify(w).slice(0, 100)} r:${JSON.stringify(r.settings)} bad:${JSON.stringify(bad).slice(0, 80)}`);+ }, back: () => { call('nbrowser_wait', { sessionId: SID, ms: 800 }); call('nbrowser_back', { sessionId: SID }); const u = pollUrl((q) => q.includes('cb=' + CB) && !q.includes('nav=2')); return A(u.ok, 'went back', `url after back: ${u.last}`); }, forward: () => { call('nbrowser_forward', { sessionId: SID }); const u = pollUrl((q) => q.includes('nav=2')); return A(u.ok, 'went forward', `url after forward: ${u.last}`); }, wait: () => { const t0 = Date.now(); call('nbrowser_wait', { sessionId: SID, ms: 700 }); return A(Date.now() - t0 >= 650, `waited ${Date.now() - t0}ms`, 'returned too fast'); },@@ -249,7 +261,7 @@ export const COVERAGE = { os_windows: 'live', wake_profile: 'live', rescan: 'live', resync_sessions: 'live', open_window: 'live', pin_session: 'live', use_profile: 'live', list_windows: 'live', window_state: 'live', switch_window: 'live', navigate: 'live', reload: 'live',- back: 'live', forward: 'live', wait: 'live', wait_for: 'live', keepalive: 'live',+ settings: 'live', back: 'live', forward: 'live', wait: 'live', wait_for: 'live', keepalive: 'live', eval: 'live', evaluate: 'live', screenshot: 'live', screenshot_full_res: 'live', errors: 'live', fetch_url: 'live', click: 'live', double_click: 'live', right_click: 'live', hover: 'live', type: 'live', press_key: 'live', select: 'live',