← 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

John Lauer ·52dd1269c1 ·2mo ago ·parent aaa8097
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',