← Commit history
bridge/BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.1.50\ No newline at end of file+0.1.51\ No newline at end of file
bridge/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "native-browser",   "displayName": "Adom Browser Extension",-  "version": "0.1.50",+  "version": "0.1.51",   "expectedExtensionVersion": "0.1.3",   "description": "Drives the user's REAL signed-in Chrome/Edge. MOST verbs require the Adom extension installed in that browser profile (CDP driving: navigate/click/read/screenshot). TWO verbs need NO extension at all and work on any machine: nbrowser_open_os_window (open a URL in the user's real profile, background+flashing - the OAuth/consent path) and nbrowser_os_windows (enumerate browser windows). Also extension-free: nbrowser_profiles, nbrowser_wake_profile, nbrowser_force_close. The bridge binds an ephemeral loopback port and the native-messaging host pumps stdio <-> bridge.",   "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.50",+  "version": "0.1.51",   "private": true,   "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)",   "main": "server.js",
bridge/server.js+78−11
@@ -326,14 +326,36 @@ function discoverAdBase(fileOnly) {   return 'http://127.0.0.1:47200'; } let adTokenRejected = false; // AD rotates the bridge token on self-update; a stale one 403s every call-function adRequest(base, payload, noToken) {+// ── CALLER PROVENANCE (wiki #345/#351) ─────────────────────────────────────────────────────────────+// AD hands us WHO asked (X-Adom-Caller-*). We (a) log it, (b) FORWARD it on every AD callback we make+// while acting on that thread's behalf, adding X-Adom-Caller-Delegate so AD can show the user both+// facts: "chip-fetcher tab 3 (via native-browser)". Self-asserted: never used for authorization.+const { AsyncLocalStorage } = require('async_hooks');+const callerCtx = new AsyncLocalStorage();+function withCaller(caller, fn) { return callerCtx.run(caller && (caller.thread || caller.container) ? caller : null, fn); }+function currentCaller() { return callerCtx.getStore() || null; }+function callerLabel() { const c = currentCaller(); return c && c.thread ? c.thread : null; }+// Headers for an AD call. Acting FOR a thread -> echo its identity + name ourselves as the delegate.+// Acting on our OWN behalf (idle timers, startup sweeps, reapers) -> no stale identity is forwarded;+// callers of adCommand pass selfCaller:true and we send our own name instead.+function callerHeaders(self) {+  const c = self ? null : currentCaller();+  if (!c) return { 'x-adom-caller-thread': `${BRIDGE} bridge (self)`, 'x-adom-caller-container': 'local', 'x-adom-caller-delegate': BRIDGE };+  return {+    'x-adom-caller-thread': c.thread || '',+    'x-adom-caller-container': c.container || '',+    'x-adom-caller-reason': c.reason || '',+    'x-adom-caller-delegate': BRIDGE,+  };+}+function adRequest(base, payload, noToken, self) {   return new Promise((resolve) => {     let u; try { u = new URL('/command', base); } catch { u = new URL('http://127.0.0.1:47200/command'); }     const data = Buffer.from(JSON.stringify(payload));     // X-Adom-Bridge-Token is ATTRIBUTION only (AD 1.9.84) - AD injects ADOM_BRIDGE_TOKEN into our spawn     // env; sending it badges our outbound calls as "bridge" in the Activity Log. It is NOT an approval     // gate (we're trusted-by-install; AD binds 127.0.0.1 only). Omitting it is fine; a stale token 403s.-    const headers = { 'content-type': 'application/json', 'content-length': data.length };+    const headers = { 'content-type': 'application/json', 'content-length': data.length, ...callerHeaders(self) };     if (process.env.ADOM_BRIDGE_TOKEN && !noToken && !adTokenRejected) headers['x-adom-bridge-token'] = process.env.ADOM_BRIDGE_TOKEN;     const req = http.request({ host: u.hostname, port: u.port || 80, path: u.pathname, method: 'POST', headers, timeout: 20000 }, (res) => {       let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { let j; try { j = JSON.parse(b); } catch { j = b; } resolve({ status: res.statusCode, body: j }); });@@ -343,7 +365,7 @@ function adRequest(base, payload, noToken) {     req.write(data); req.end();   }); }-function adCommand(app, command, args, target) {+function adCommand(app, command, args, target, self) {   // app is OPTIONAL on AD 1.9.84+ (inferred) - omit it for top-level verbs like notify_user, keep it   // when a caller passes one (e.g. desktop_* recording delegation).   const payload = { command, args: args || {} };@@ -353,13 +375,13 @@ function adCommand(app, command, args, target) {   if (target) payload.target = target;   const first = discoverAdBase();   const ok2xx = (r) => r && r.status >= 200 && r.status < 300;-  return adRequest(first, payload).then(async (r) => {+  return adRequest(first, payload, false, self).then(async (r) => {     // A STALE BRIDGE TOKEN 403s EVERY CALL (AD rotates it when it self-updates). The token is     // ATTRIBUTION-ONLY, so the correct recovery is to DROP it and retry - otherwise every AD-backed     // feature (window correlation, taskbar badge, flash) dies SILENTLY. Real incident 2026-07-21: the     // fusion-bridge maintainer saw nbrowser_os_windows report 0 windows while AD saw 21.     if (r.status === 401 || r.status === 403) {-      const retry = await adRequest(first, payload, true);+      const retry = await adRequest(first, payload, true, self);       if (ok2xx(retry)) {         if (!adTokenRejected) { adTokenRejected = true; console.error('[native-browser] AD rejected our bridge token (403) - it is attribution-only, so dropping it and continuing. AD likely self-updated and rotated it.'); logActivity({ verb: '_ad_link', ok: true, errorCode: 'token_dropped', internal: true }); }         adDownSince = 0;@@ -379,7 +401,7 @@ function adCommand(app, command, args, target) {     }     // Connection-level failure -> AD may have restarted on a new port. Re-discover from the FILE and retry.     const fromFile = discoverAdBase(true);-    const retry = fromFile && fromFile !== first ? adRequest(fromFile, payload) : Promise.resolve(r);+    const retry = fromFile && fromFile !== first ? adRequest(fromFile, payload, false, self) : Promise.resolve(r);     return retry.then((r2) => {       if (ok2xx(r2)) {         adBaseOverride = fromFile;@@ -472,6 +494,7 @@ const sessionHwnd = new Map();     // sessionId -> hwnd (opened windows) const spawnHwnd = new Map();       // chrome windowId -> hwnd (adopted SPAWNED windows: compose/popups) const sessionProfile = new Map();  // sessionId -> profile KEY (pin: verbs on this session route here by default) const sessionThread = new Map();   // sessionId -> thread label (from registration) — for the activity log+const sessionCaller = new Map();   // sessionId -> AD-stamped caller thread (wiki #345: who ACTUALLY owns this session) const sessionPurpose = new Map();  // sessionId -> purpose (from registration)  // ── ACTIVITY LOG — a bounded, queryable "who drove what, when" trail so we can answer questions like@@ -487,6 +510,9 @@ const ACT_FILE = path.join(os.homedir(), '.adom', 'bridge-logs', 'native-browser try { fs.mkdirSync(path.dirname(ACT_FILE), { recursive: true }); } catch {} function logActivity(e) {   e.ts = Date.now();+  // WHO asked (wiki #345 ask 1): stamp the calling AI thread on every logged verb so concurrent-agent+  // interference is reconstructable after the fact. Self-asserted; attribution only.+  if (e.caller === undefined) { const c = (typeof currentCaller === 'function') && currentCaller(); if (c && c.thread) e.caller = c.thread; }   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();@@ -703,6 +729,23 @@ function resolvePendingProfiles() { // avatar). If the composite can't be built yet (unresolved profile / no avatar file), we skip the // paint, nudge profile resolution, and the caller's badgeFallback retry upgrades it on a later touch. const badgeFallback = new Set(); // sids whose badge paint is deferred until the composite is buildable+// WINDOW OWNERSHIP LABEL (wiki #345 ask 3 - the payoff John asked for): stamp the OWNING AI THREAD on+// the window's taskbar identity, so hovering a taskbar button tells the user which of their ~20+// conversations owns that window. Best-effort: a failure here never blocks driving.+function labelWindowOwner(hwnd, sid) {+  if (!hwnd) return;+  const owner = (sid && sessionCaller.get(sid)) || callerLabel();+  if (!owner) return;+  const purpose = (sid && sessionPurpose.get(sid)) || '';+  adCommand('desktop', 'desktop_set_window_identity', {+    hwnd,+    appId: 'Adom.NativeBrowser',+    displayName: `Adom · ${owner}${purpose ? ' · ' + String(purpose).slice(0, 60) : ''}`,+  }).then((r) => {+    const ok = !!(r && r.status >= 200 && r.status < 300);+    logActivity({ verb: '_window_identity', sessionId: sid || null, hwnd, ops: owner, ok, internal: true });+  }).catch(() => {});+} function paintBadge(hwnd, profileKey, sid, style) {   if (!hwnd) return;   const hollow = style === 'hollow';@@ -1322,7 +1365,7 @@ function dispatch(verb, args) {       // dead flash is debuggable straight from this trail, not just "something failed".       const entries = rows.slice(-limit).map((r) => ({ agoMs: now - r.ts, verb: r.verb, sessionId: r.sessionId, thread: r.thread, profile: r.profile, ok: r.ok, errorCode: r.errorCode || undefined, error: r.error || undefined, hwnd: r.hwnd || undefined, ops: r.ops || undefined, internal: r.internal || undefined }));       const bySession = {};-      for (const r of rows) { if (!r.sessionId) continue; const s = bySession[r.sessionId] || (bySession[r.sessionId] = { sessionId: r.sessionId, thread: sessionThread.get(r.sessionId) || r.thread, purpose: sessionPurpose.get(r.sessionId) || null, profile: r.profile, count: 0, lastAgoMs: Infinity }); s.count++; s.lastAgoMs = Math.min(s.lastAgoMs, now - r.ts); }+      for (const r of rows) { if (!r.sessionId) continue; const s = bySession[r.sessionId] || (bySession[r.sessionId] = { sessionId: r.sessionId, thread: sessionThread.get(r.sessionId) || r.thread, ownerCaller: sessionCaller.get(r.sessionId) || null, purpose: sessionPurpose.get(r.sessionId) || null, profile: r.profile, count: 0, lastAgoMs: Infinity }); s.count++; s.lastAgoMs = Math.min(s.lastAgoMs, now - r.ts); }       return resolve({ success: true, output: JSON.stringify({ ok: true, count: entries.length, totalLogged: ACTIVITY_LOG.length, windowMs: ACT_LOG_TTL_MS, activeSessions: Object.values(bySession).sort((a, b) => a.lastAgoMs - b.lastAgoMs), entries, _hint: 'Bounded activity trail (max 600 entries / ~1h, in-memory, resets on bridge restart). Filter: {sessionId, thread, profile, verb, errorsOnly:true, sinceMs, limit} - verb:"_taskbar" shows every overlay-badge/flash paint (hwnd + ops + ok), verb:"_ad_link" shows AD-link outages/recoveries. activeSessions = who drove what + how long ago (lastAgoMs); a large lastAgoMs means that session is idle (its badge should have faded).' }) });     }     // nbrowser_readiness - READ-ONLY cold-start probe (SDK Cold-start tiers). Never spawns work, never@@ -1733,7 +1776,7 @@ function dispatch(verb, args) {         // LAYER-1 BADGE (hollow): mark the window Adom OPENED even though it cannot drive it - provenance         // matters most on an OAuth consent page. Uses applyBadge so the normal 3-min idle expiry restores         // the native avatar; nothing lingers.-        if (hwnd) applyBadge(a.sessionId, hwnd, kp.key, 'hollow');+        if (hwnd) { applyBadge(a.sessionId, hwnd, kp.key, 'hollow'); labelWindowOwner(hwnd, a.sessionId); }         logActivity({ verb: 'nbrowser_open_os_window', sessionId: a.sessionId, thread: a.thread, profile: kp.key, ok: true,           ops: `fg=${a.foreground === true}·hwndResolved=${!!hwnd}·minimized=${minimized}`, hwnd: hwnd || undefined,           errorCode: hwnd ? null : 'grouped_into_existing_window' });@@ -2012,6 +2055,7 @@ function dispatch(verb, args) {         }       } catch (e) { return resolve({ success: false, error: 'upload staging failed: ' + ((e && e.message) || e) }); }     }+    let crossThreadWarn = null;     const tgt = targetConn(args);     if (tgt && tgt.mismatch) {       return resolve({ success: false, errorCode: 'profile_mismatch', error: `profile_mismatch: session '${tgt.sessionId}' is pinned to profile '${tgt.pin}', but you called with profile '${tgt.wantKey}'.`, _hint: `Refused so you never act on the WRONG account (personal vs corporate). Drop the profile arg (the session already routes to '${tgt.pin}'), or open a NEW registered session in '${tgt.wantKey}' if you truly meant that account.` });@@ -2052,7 +2096,19 @@ function dispatch(verb, args) {     };     // PIN the session to whatever profile this window opened in, so every later verb carrying this     // sessionId auto-routes back to the SAME profile (no cross-profile mistakes) even without `profile:`.-    if (verb === 'nbrowser_open_window' && args && args.sessionId) { sessionProfile.set(args.sessionId, tgt.key); if (args.thread) sessionThread.set(args.sessionId, args.thread); if (args.purpose) sessionPurpose.set(args.sessionId, args.purpose); persistSessions(); }+    if (verb === 'nbrowser_open_window' && args && args.sessionId) { sessionProfile.set(args.sessionId, tgt.key); if (args.thread) sessionThread.set(args.sessionId, args.thread); if (args.purpose) sessionPurpose.set(args.sessionId, args.purpose); const _c = callerLabel(); if (_c) sessionCaller.set(args.sessionId, _c); persistSessions(); }+    // CONCURRENT-AGENT ARBITRATION (wiki #345 ask 2): ~20 AI threads share one desktop. If a DIFFERENT+    // thread drives a session someone else registered, warn and NAME the owner so they can coordinate.+    // A warning, not a refusal - the owning-thread refusal already exists for destructive cross-thread+    // ops (force_close). Self-asserted identity: never an authorization decision.+    if (args && args.sessionId && verb !== 'nbrowser_open_window') {+      const owner = sessionCaller.get(args.sessionId);+      const nowCaller = callerLabel();+      if (owner && nowCaller && owner !== nowCaller) {+        crossThreadWarn = `⚠️ CONCURRENT AGENTS: session '${args.sessionId}' was opened by AI thread "${owner}", but this call came from "${nowCaller}". You are driving another thread's window - coordinate with them or open your own window (nbrowser_open_window) instead of sharing one. Allowed, but the other thread may be mid-task.`;+        logActivity({ verb: '_cross_thread', sessionId: args.sessionId, ok: true, ops: `${nowCaller} -> ${owner}`, internal: true });+      }+    }     // For open_window, snapshot the Chrome window list BEFORE forwarding so the post-open diff reliably     // catches the new window (the old fire-and-forget snapshot raced the window appearing).     if (verb === 'nbrowser_open_window') chromeHwnds().then((s) => { preOpenHwnds = s; forward(); }).catch(forward);@@ -2123,7 +2179,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); persistSessions(); } applyBadge(sid, neu, p.profileKey); if (p.args && p.args.userRequestedForeground === true && p.args.foregroundReason) announceForeground(p.args.foregroundReason, sid); 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); labelWindowOwner(neu, sid); if (p.args && p.args.userRequestedForeground === true && p.args.foregroundReason) announceForeground(p.args.foregroundReason, sid); 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(),@@ -2156,6 +2212,7 @@ async function onHostFrame(frame) {         if (payload.sessionId) touchBadge(payload.sessionId, p.profileKey);         else touchProfileBadge(p.profileKey);       }+      if (crossThreadWarn && payload && typeof payload === 'object') payload._concurrentAgents = crossThreadWarn;       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, ...(p._internal ? { internal: true } : {}) });       resolve_ok(p.resolve, payload);@@ -2194,7 +2251,17 @@ const httpSrv = http.createServer((req, res) => {       try { parsed = JSON.parse(body || '{}'); } catch { return reply(400, { success: false, error: 'bad json' }); }       const verb = parsed.command || parsed.verb;       if (!verb) return reply(400, { success: false, error: 'missing command/verb' });-      reply(200, await dispatch(verb, parsed.args));+      // CALLER PROVENANCE (AD 1.9.180+, wiki #345/#351): AD stamps WHO asked on every relayed request.+      // SELF-ASSERTED - attribution/logging/UX/arbitration only, NEVER authorization.+      const caller = {+        thread: String(req.headers['x-adom-caller-thread'] || (parsed.args && parsed.args.caller && parsed.args.caller.aiThread) || '').slice(0, 120),+        container: String(req.headers['x-adom-caller-container'] || (parsed.args && parsed.args.caller && parsed.args.caller.containerName) || '').slice(0, 120),+        reason: String(req.headers['x-adom-caller-reason'] || '').slice(0, 200),+      };+      // Run the verb inside an ambient caller context so EVERY AD callback we make while carrying it+      // out (taskbar badge, caption, window enumeration) forwards that identity automatically. The+      // bridge is deeply async, so threading it by hand through every call site would rot immediately.+      reply(200, await withCaller(caller, () => dispatch(verb, parsed.args)));     });     return;   }
native-browser-bridge-manifest.json+5−5
@@ -1,8 +1,8 @@ {   "manifest_version": 1,-  "version": "0.1.50",-  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.50/native-browser-bridge-v0.1.50.zip",-  "sha256": "6d7144fe1911f57cd40a0ea8102ee6e451dece5559f5b88d43d79e7ecaf474f8",-  "size": 97050,-  "released_at": "2026-07-24T00:00:00Z"+  "version": "0.1.51",+  "url": "https://wiki.adom.inc/download/adom/adom-browser-extension/0.1.51/native-browser-bridge-v0.1.51.zip",+  "sha256": "02fa75c3af5cd3f4b4ff7cec6fdff66749d6a35edce356670add923f734072a0",+  "size": 98922,+  "released_at": "2026-07-25T00:00:00Z" }