← Commit history
Update 6 file(s)
6 files changed
+148−20
bridge/BRIDGE_VERSION+1−1@@ -1 +1 @@-0.1.15+0.1.16
bridge/bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "native-browser", "displayName": "Adom Native Browser (Chrome + Edge)",- "version": "0.1.15",+ "version": "0.1.16", "expectedExtensionVersion": "0.1.2", "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",
bridge/package.json+1−1@@ -1,6 +1,6 @@ { "name": "adom-native-browser-bridge",- "version": "0.1.15",+ "version": "0.1.16", "private": true, "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)", "main": "server.js",
bridge/server.js+117−13@@ -417,20 +417,50 @@ const sessionThread = new Map(); // sessionId -> thread label (from registrati const sessionPurpose = new Map(); // sessionId -> purpose (from registration) // ── ACTIVITY LOG — a bounded, queryable "who drove what, when" trail so we can answer questions like-// "why did that profile get badged?" without guessing. SIZE- AND TIME-capped (ring + 1h TTL) so it can-// NEVER bloat, consistent with the extension's capped event buffers. In-memory (resets on bridge restart).+// "why did that profile get badged?" without guessing. Two tiers:+// 1. In-memory ring (600 entries / 1h TTL) — the fast path nbrowser_activity queries.+// 2. Persistent JSONL on disk — survives bridge restarts so we can investigate how OTHER AI threads+// interacted with our features after the fact. Self-JANITORED so it can never grow unbounded:+// trimmed on startup + every 6h to the newest ~4000 lines AND nothing older than 14 days. const ACTIVITY_LOG = []; const ACT_LOG_MAX = 600;-const ACT_LOG_TTL_MS = 60 * 60 * 1000; // keep ~1 hour+const ACT_LOG_TTL_MS = 60 * 60 * 1000; // keep ~1 hour in memory+const ACT_FILE = path.join(os.homedir(), '.adom', 'bridge-logs', 'native-browser-activity.jsonl');+try { fs.mkdirSync(path.dirname(ACT_FILE), { recursive: true }); } catch {} function logActivity(e) { e.ts = Date.now(); ACTIVITY_LOG.push(e); const cutoff = e.ts - ACT_LOG_TTL_MS; while (ACTIVITY_LOG.length > ACT_LOG_MAX || (ACTIVITY_LOG.length && ACTIVITY_LOG[0].ts < cutoff)) ACTIVITY_LOG.shift();+ try { fs.appendFile(ACT_FILE, JSON.stringify(e) + '\n', () => {}); } catch {} }+// Janitor: bound the persistent trail (size + age) and sweep stale composited icons out of ICON_DIR.+// Runs at startup and every 6h; interval is unref'd so it never holds the process open.+const ACT_FILE_MAX_BYTES = 8 * 1024 * 1024, ACT_FILE_KEEP_LINES = 4000, ACT_FILE_MAX_AGE_MS = 14 * 24 * 3600 * 1000;+function janitor() {+ try {+ const st = fs.statSync(ACT_FILE);+ if (st.size > ACT_FILE_MAX_BYTES) {+ const lines = fs.readFileSync(ACT_FILE, 'utf8').split('\n').filter(Boolean).slice(-ACT_FILE_KEEP_LINES);+ const cutoff = Date.now() - ACT_FILE_MAX_AGE_MS;+ const kept = lines.filter((l) => { try { return (JSON.parse(l).ts || 0) >= cutoff; } catch { return false; } });+ fs.writeFileSync(ACT_FILE, kept.join('\n') + (kept.length ? '\n' : ''));+ }+ } catch {}+ try { // composited overlay icons are mtime-keyed per avatar; sweep ones not touched in 30 days+ const cutoff = Date.now() - 30 * 24 * 3600 * 1000;+ for (const f of fs.readdirSync(ICON_DIR)) {+ if (!f.startsWith('ov_') || !f.endsWith('.png')) continue;+ const p = path.join(ICON_DIR, f);+ try { if (fs.statSync(p).mtimeMs < cutoff) fs.unlinkSync(p); } catch {}+ }+ } catch {}+}+setTimeout(janitor, 10000);+setInterval(janitor, 6 * 3600 * 1000).unref(); let flashMode = 'quiet'; // quiet | chatty | off 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']);+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'); // All taskbar status goes through AD's desktop_taskbar (1.8.152): flash + progress + the Adom overlay@@ -581,6 +611,69 @@ function applyBadge(sid, hwnd, profileKey) { badgeOn.add(sid); armBadgeIdle(sid); paintBadge(hwnd, profileKey || sessionProfile.get(sid)); }+// ── SESSION PERSISTENCE — sessionHwnd/sessionProfile are the badge's memory, and they used to be+// in-memory only: every bridge redeploy WIPED them, so a thread that kept driving after a restart never+// got its badge back ("no badge until it opens a fresh window"). Persist {sid -> hwnd+profile} to disk on+// every change and restore at startup (validating each hwnd still exists - browser windows survive a+// bridge restart, so the hwnds are usually still good).+const SESS_FILE = path.join(ICON_DIR, 'sessions.json');+function persistSessions() {+ try {+ const out = {};+ for (const [sid, hwnd] of sessionHwnd) out[sid] = { hwnd, profile: sessionProfile.get(sid) || null, thread: sessionThread.get(sid) || null, purpose: sessionPurpose.get(sid) || null };+ fs.writeFileSync(SESS_FILE, JSON.stringify(out));+ } catch {}+}+function restoreSessions(liveHwnds) {+ try {+ const j = JSON.parse(fs.readFileSync(SESS_FILE, 'utf8'));+ for (const [sid, s] of Object.entries(j)) {+ if (!s || !liveHwnds.has(s.hwnd)) continue; // window is gone - drop the stale entry+ if (!sessionHwnd.has(sid)) sessionHwnd.set(sid, s.hwnd);+ if (s.profile && !sessionProfile.has(sid)) sessionProfile.set(sid, s.profile);+ if (s.thread && !sessionThread.has(sid)) sessionThread.set(sid, s.thread);+ if (s.purpose && !sessionPurpose.has(sid)) sessionPurpose.set(sid, s.purpose);+ }+ persistSessions();+ } catch {}+}+// ── BRIDGE-ORIGINATED EXTENSION QUERY — lets the bridge ask a profile's extension something on its own+// behalf (same pending/frame plumbing the AD relay uses). Used to re-correlate a profile's windows when a+// thread drives PROFILE-KEYED (no sessionId) and we have no hwnd for it.+function extQuery(profileKey, verb, args, timeoutMs) {+ return new Promise((resolve) => {+ const tgt = targetConn({ profile: profileKey });+ if (!tgt || tgt.blocked || !tgt.conn) return resolve(null);+ const id = ++seq;+ const timer = setTimeout(() => { pending.delete(id); resolve(null); }, timeoutMs || 8000);+ pending.set(id, { resolve: (r) => resolve(r && r.success !== false ? r : null), timer, verb, profileKey: tgt.key, _internal: true });+ try { tgt.conn.sock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n'); } catch { clearTimeout(timer); pending.delete(id); resolve(null); }+ });+}+// ── PROFILE-KEYED BADGE TOUCH — THE RULE (learned 2026-07-15, John caught it live): every cross-cutting+// feature must fire on EVERY addressing path a verb supports. Threads legitimately drive with profile:+// only (no sessionId) - the shopping thread did exactly that (open_window with sid, then profile-keyed+// screenshots) and the badge never re-asserted because touchBadge keyed ONLY on sessionId. This is the+// profile-keyed twin: touch every known session pinned to the profile; if none has an hwnd, lazily ask+// the profile's own extension for its windows (throttled) and repair via badgeSpawnsIn - which also+// re-badges adopted spawns. The extension query is exact (it knows its own windows), so this can never+// hit another profile's window or a PWA.+const _profCorrAt = new Map(); // profileKey -> last correlation attempt ts+function touchProfileBadge(profileKey) {+ if (!BADGE_ENABLED || !profileKey) return;+ let touched = false;+ for (const [sid, pk] of sessionProfile) if (pk === profileKey && sessionHwnd.has(sid)) { touchBadge(sid, profileKey); touched = true; }+ if (touched) return;+ const now = Date.now();+ if ((_profCorrAt.get(profileKey) || 0) + 30000 > now) return; // throttle: at most one repair query / 30s / profile+ _profCorrAt.set(profileKey, now);+ extQuery(profileKey, 'nbrowser_list_windows', {}).then((r) => {+ // pending resolves with the AD-facing wrapper {success, output:"<json>"} - unwrap to the raw payload+ let payload = r;+ if (r && typeof r.output === 'string') { try { payload = JSON.parse(r.output); } catch { payload = null; } }+ if (payload) badgeSpawnsIn(payload, profileKey).catch(() => {});+ });+} // Called on every DRIVE verb: the badge tracks ACTIVE driving, not mere ownership. On the first activity // (or after a bridge restart) it lazily correlates the driven profile's taskbar window; then it just // re-arms the idle timer. An IDLE owned window (a thread opened it then went quiet) is NEVER badged here,@@ -588,9 +681,10 @@ function applyBadge(sid, hwnd, profileKey) { function touchBadge(sid, profileKey) { if (!sid) return; // ONLY the window we captured at open_window (the real browser window the AI created). We deliberately- // do NOT re-correlate by profile/title here - that is exactly what reached the wrong window (a Gmail PWA- // that merely had the profile email in its title). If the captured hwnd is gone (e.g. bridge restarted),- // no badge until the thread opens a fresh window. Correctness over persistence.+ // do NOT re-correlate by OS title here - that is exactly what reached the wrong window (a Gmail PWA+ // that merely had the profile email in its title). Restart amnesia is handled elsewhere now: the+ // sid->hwnd map is persisted + restored at startup, and profile-keyed drives re-correlate via the+ // profile's OWN extension (touchProfileBadge -> badgeSpawnsIn), which cannot hit a PWA. const h = sessionHwnd.get(sid); if (!h) return; if (!badgeOn.has(sid)) applyBadge(sid, h, profileKey); else armBadgeIdle(sid);@@ -653,7 +747,7 @@ async function badgeSpawnsIn(payload, profileKey) { const h = profileHwnd || matchHwnd(list, o.bounds, o.title); if (!h) continue; const old = sessionHwnd.get(o.sid);- if (old !== h) { if (old && old !== h) nativeOverlay(old, profileKey); sessionHwnd.set(o.sid, h); lastHwnd = h; clearBadge(o.sid); applyBadge(o.sid, h); }+ if (old !== h) { if (old && old !== h) nativeOverlay(old, profileKey); sessionHwnd.set(o.sid, h); persistSessions(); lastHwnd = h; clearBadge(o.sid); applyBadge(o.sid, h); } else touchBadge(o.sid); } }@@ -963,7 +1057,7 @@ const VERB_META = { nbrowser_install_chrome: { hint: 'Install NATIVE Google Chrome for the user (Windows). Downloads Google\'s official installer + runs a QUIET user-scoped install (no admin on a normal PC). NON-BLOCKING - returns immediately; poll nbrowser_chrome_status. On a locked-down box it escalates to a notify-first UAC (fires a sticky "Approve now" toast, waits for the tap, then raises the prompt). This is step 1 of getting the AI onto the user\'s REAL browser - after Chrome is in, help them sign into Google + load the extension.', related: ['nbrowser_chrome_status', 'nbrowser_readiness', 'installing-the-extension'], pitfalls: ['non-blocking: do NOT expect Chrome to exist on return - POLL nbrowser_chrome_status until phase:"ready"', 'awaiting_uac means a prompt is on the user\'s desktop - relay it; do not re-trigger install', 'Windows only for now', 'this only INSTALLS Chrome; the user still signs into Google themselves (their credential) - guide them via setting-up-native-chrome'] }, nbrowser_chrome_status: { hint: 'READ-ONLY: is native Google Chrome installed, and how is an in-flight install going? Returns {installed, executablePath, phase, installing, awaitingUac}. phase: idle|installing|awaiting_uac|ready|failed. Poll this after nbrowser_install_chrome. Never installs or spawns.', related: ['nbrowser_install_chrome', 'nbrowser_readiness'], pitfalls: ['awaiting_uac + notifyChannel:true = a toast is on the desktop, keep polling; notifyChannel:false = relay the UAC to the user yourself', 'installed:true means Chrome.exe is present - next help the user sign into Google + load the extension'] }, nbrowser_ping: { hint: 'Round-trip liveness through to the extension.', related: ['nbrowser_status'], pitfalls: ['a pong proves the extension is reachable, NOT that any profile has a drivable window — use nbrowser_status/readiness for that'] },- nbrowser_activity: { hint: 'Bounded "who drove what, when" activity trail. Query recent verbs + a per-session summary (activeSessions, each with lastAgoMs) to debug things like "why is this profile badged" or "what is running right now". Filter with {sessionId, thread, profile, verb, errorsOnly, sinceMs, limit}.', related: ['nbrowser_events', 'nbrowser_owned', 'nbrowser_status'], pitfalls: ['in-memory + capped (max 600 entries / ~1h) so it RESETS on a bridge restart and can never bloat', 'a session with a large lastAgoMs is idle - its "Adom is driving" badge idle-expires after ~3 min'] },+ nbrowser_activity: { hint: 'Bounded "who drove what, when" activity trail. Query recent verbs + a per-session summary (activeSessions, each with lastAgoMs) to debug things like "why is this profile badged" or "what is running right now". Filter with {sessionId, thread, profile, verb, errorsOnly, sinceMs, limit}.', related: ['nbrowser_events', 'nbrowser_owned', 'nbrowser_status'], pitfalls: ['the verb serves the in-memory ring (max 600 entries / ~1h, resets on restart); a persistent self-janitored JSONL survives restarts at ~/.adom/bridge-logs/native-browser-activity.jsonl for deeper forensics', 'a session with a large lastAgoMs is idle - its "Adom is driving" badge idle-expires after ~3 min'] }, nbrowser_open_window: { hint: 'Open a NEW background window YOU own and get a sessionId; do ALL your work through that sessionId. REGISTER it: pass sessionId + thread (which AI thread you are) + purpose (human-readable, why) or it is refused (session_unregistered). ' + NO_FG, related: ['nbrowser_navigate', 'nbrowser_screenshot', 'nbrowser_owned', 'nbrowser_close_window'], next: 'nbrowser_navigate', pitfalls: ['REQUIRES sessionId + thread + purpose (registration) — a bare open is refused session_unregistered', 'opens UNFOCUSED in the BACKGROUND - a bare focused:true is IGNORED; only userRequestedForeground:true raises it', 'NEVER fall back to an OS-level launch (Start-Process/msedge/chrome --new-window) - that RAISES it to the foreground; this verb is the polite path and always works in the background', 'always drive a window you opened, never the user\'s existing ones — and close everything you own at task end (nbrowser_owned → nbrowser_close_window)'] }, nbrowser_close_window: { hint: 'Close a window you opened. Pass includeSpawned:true to also close everything that window\'s pages SPAWNED (composes/popups). Clean up your own sessions when done.', related: ['nbrowser_owned', 'nbrowser_cleanup', 'nbrowser_list_windows'], pitfalls: ['⛔ only close a sessionId YOU opened - closing the user\'s window can wipe their live work and other AI threads', 'if the session spawned windows (a page opened a compose/popup), they stay open unless you pass includeSpawned:true or call nbrowser_cleanup'] }, nbrowser_owned: { hint: 'List everything a session owns: opened windows + windows/tabs a page it drove SPAWNED (compose, target=_blank, OAuth popup). Calling it ACKNOWLEDGES the spawns (clears an unacknowledged_spawns block). Use it before ending a task to see what to clean up.', related: ['nbrowser_cleanup', 'nbrowser_close_window', 'nbrowser_list_windows'], next: 'nbrowser_cleanup', pitfalls: ['spawned windows belong to no session until adopted — this is how you find leaked ones (e.g. a Gmail Contacts compose)', 'after seeing spawns, KEEP the ones you need or nbrowser_cleanup the litter'] },@@ -1513,7 +1607,7 @@ async function onHostFrame(frame) { neu = list.map((w) => w.hwnd).find((h) => !preOpenHwnds.has(h)) || null; if (!neu) neu = matchHwnd(list, payload.bounds, payload.title); } catch {}- if (neu) { lastHwnd = neu; if (sid) sessionHwnd.set(sid, neu); applyBadge(sid, neu, p.profileKey); break; } // applyBadge = badge (composited onto the profile avatar) + arm idle-expiry+ if (neu) { lastHwnd = neu; if (sid) { sessionHwnd.set(sid, neu); persistSessions(); } applyBadge(sid, neu, p.profileKey); break; } // applyBadge = badge (composited onto the profile avatar) + arm idle-expiry await new Promise((r) => setTimeout(r, 250)); } payload = { ...payload, ...(neu ? { hwnd: neu } : {}), _taskbarHint: flashHint(),@@ -1525,7 +1619,7 @@ async function onHostFrame(frame) { if (p.verb === 'nbrowser_close_window' || p.verb === 'nbrowser_cleanup') { // clean up the taskbar when we stop driving const sid = payload.sessionId; const h = (sid && sessionHwnd.get(sid)) || lastHwnd;- if (h && p.verb === 'nbrowser_close_window') { taskbar(h, { progress: { state: 'none' }, overlay: { badge: 'none' }, flash: 'stop' }); if (sid) sessionHwnd.delete(sid); }+ if (h && p.verb === 'nbrowser_close_window') { taskbar(h, { progress: { state: 'none' }, overlay: { badge: 'none' }, flash: 'stop' }); if (sid) { sessionHwnd.delete(sid); persistSessions(); } } if (sid) clearBadge(sid); // stop the idle-expiry timer for a closed session // clear the badge on any spawned windows this close/cleanup removed for (const wid of (payload.alsoClosedSpawned || []).concat((payload.closed || []).map((c) => c.windowId).filter(Boolean))) {@@ -1533,9 +1627,16 @@ async function onHostFrame(frame) { } } maybeAutoFlash(p.verb, payload);- if (payload && payload.sessionId && ACTIVITY.has(p.verb)) touchBadge(payload.sessionId, p.profileKey); // keep the open-captured badge alive while driving; idle-expires when commands stop+ // Keep the badge alive while driving, on EVERY addressing path: sessionId-keyed verbs touch their+ // session; profile-keyed verbs (no sessionId - a thread driving by profile: alone) touch every+ // session pinned to that profile, lazily re-correlating via the extension if the hwnd is unknown+ // (e.g. after a bridge restart). Idle-expires when commands stop.+ if (payload && ACTIVITY.has(p.verb) && !p._internal) {+ if (payload.sessionId) touchBadge(payload.sessionId, p.profileKey);+ else touchProfileBadge(p.profileKey);+ } const sid = payload && payload.sessionId;- logActivity({ verb: p.verb, sessionId: sid || null, thread: sessionThread.get(sid) || null, profile: p.profileKey || null, ok: payload && payload.ok !== false, errorCode: (payload && payload.errorCode) || null });+ logActivity({ verb: p.verb, sessionId: sid || null, thread: sessionThread.get(sid) || null, profile: p.profileKey || null, ok: payload && payload.ok !== false, errorCode: (payload && payload.errorCode) || null, ...(p._internal ? { internal: true } : {}) }); resolve_ok(p.resolve, payload); } else { logActivity({ verb: p.verb, sessionId: null, profile: p.profileKey || null, ok: false, errorCode: 'error', error: String(frame.error || '').slice(0, 120) });@@ -1715,6 +1816,9 @@ function knownProfiles() { async function resetStaleBadgesOnStartup(attempt = 0) { let list; try { list = await chromeWindowsDetailed(); } catch { list = null; } if (!list || !list.length) { if (attempt < 4) setTimeout(() => resetStaleBadgesOnStartup(attempt + 1), 3000); return; }+ // Restore persisted session->hwnd mappings FIRST (validated against the live window list), so threads+ // that were mid-drive across our restart keep their badge on their very next verb - no fresh window needed.+ restoreSessions(new Set(list.map((w) => w.hwnd))); const known = knownProfiles(); const seen = new Set(); for (const w of list) {
dev-skills/developing-the-bridge/SKILL.md+24@@ -92,3 +92,27 @@ missing capability (new lifecycle verb, relay field) → file against AD; a misb Only ever drive a window YOU opened; NEVER navigate/reload/close the user's existing window/tab (incl. OS `desktop_navigate`). Background by default; foreground is the user's choice. `nbrowser_credentials` read-only, never returns passwords; auto-login gated on `confirm:true`+`host:`. Honor the blocklist.++## ⭐ Cross-cutting features must fire on EVERY addressing path (learned 2026-07-15)+Verbs can be addressed THREE ways: `sessionId`-keyed, **profile-keyed** (`profile:` only, NO sessionId+- fully legitimate and common), and spawn-adopted. Any cross-cutting feature - the taskbar badge, flash,+the activity log, guards, future telemetry - must hook ALL of them, or it silently half-works and the+USER is the one who notices. The incident: the "Adom is driving" badge re-asserted only in+`touchBadge(payload.sessionId, ...)`; a shopping thread opened with a sessionId but then drove with+profile-keyed screenshots (`sid=None` in the activity log), so the badge never re-appeared and John saw+an unbadged window being actively driven. Same class of bug: in-memory `sessionHwnd` wiped by every+bridge redeploy ("no badge until a fresh window"). The fixes to keep intact: `touchProfileBadge`+(profile-keyed twin of touchBadge; repairs hwnds by asking the profile's OWN extension - PWA-safe, never+OS-title guessing) and `sessions.json` persistence restored+validated at startup. **Test matrix for any+badge/flash/log change: (1) sid-keyed drive, (2) profile-keyed drive with no sessionId, (3) drive+continuing across a bridge restart.** If a feature keys off one map, ask what happens on the other paths.++## Log everything (bounded) + janitor it - so you can investigate other threads' behavior+You cannot debug "why did thread X see Y?" from memory that a restart wipes. The bridge keeps TWO tiers:+the in-memory ring `nbrowser_activity` serves (600 / ~1h, fast), and a persistent JSONL at+`~/.adom/bridge-logs/native-browser-activity.jsonl` that survives restarts. EVERY forwarded verb logs+{ts, verb, sessionId, thread, profile, ok, errorCode} (+`internal:true` for bridge-originated extQuery+calls). The janitor keeps it bounded FOREVER: startup + every 6h, trim to newest 4000 lines AND <=14+days when >8MB; it also sweeps stale composited overlay icons (>30 days) from the icon temp dir. If you+add a new persistent artifact (log, cache, icon, temp file), you MUST add it to the janitor in the same+PR - unbounded growth on the user's machine is a regression, not a nice-to-have.
native-browser-bridge-manifest.json+4−4@@ -1,8 +1,8 @@ { "manifest_version": 1,- "version": "0.1.15",- "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.15/native-browser-bridge-v0.1.15.bin",- "sha256": "5c061203023855078213c9adc0e7193312f9fd88c945cb1808cc356add6d9102",- "size": 68227,+ "version": "0.1.16",+ "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.16/native-browser-bridge-v0.1.16.bin",+ "sha256": "07c4460b391a11b16c8b8ff0e95b69ca95242b5b5cfe2a8915366a9374659737",+ "size": 70617, "released_at": "2026-07-15T00:00:00Z" }