name: developing-the-bridge description: "DEVELOPER skill for maintainers of the adom-native-browser (nb/nbe) bridge - NOT for driving it. Read this before editing bridge/server.js, host/native-host.js, or extension/. Covers the architecture (bridge + native-messaging host + MV3 extension, and how ab provisions Node), the #1 rule that every nbrowser_* verb must return rich _hint/_next/related/pitfalls via the VERB_META table, the verb contract, local testing, the ab-vs-bridge boundary, hard-won runtime gotchas (chrome.tabs.goBack fails on debugger-driven tabs; UIA Invoke fails on Win32 dialogs), and the safety invariants. Everyday users never need this."
Parent skill: adom-native-browser. This is a maintainer-only skill (not user-invocable). Bridge SDK: https://wiki.adom.inc/adom/adom-bridge-sdk
Developing the native-browser bridge
⭐ THE loudest rule: the AI reads your verb OUTPUT, not this file
ab relays every field of a verb response verbatim to the calling AI. A verb that returns
{success:true} and nothing else makes the AI guess and retry-loop. EVERY nbrowser_* response MUST
carry a rich _hint (what just happened + the obvious next verb + the trap) plus _next /
related / pitfalls. This is the single highest-leverage thing in the whole bridge.
Mechanism in server.js: a single VERB_META table is the source of truth. It feeds BOTH
nbrowser_describe (per-verb hint/related/pitfalls) AND a decorateHint(verb, r) wrapper that stamps
top-level _hint/related/_next onto EVERY dispatch response. Add a verb → add its VERB_META entry
in the same commit, or it ships hint-less.
The 3 components (know which one you're touching)
- The bridge (
bridge/server.js) — ab spawns it (spawn.kind:"node"), so ab provisions Node (SDK runtime contract; you never install Node for it). Binds127.0.0.1:0onprocess.env.ADOM_BIND_HOST || '127.0.0.1'(never0.0.0.0→ no firewall prompt). Writes a discovery file~/.adom/bridges/native-browser/host.json+node-path.txt. Serves/status(health chip: led/summary/tooltip, AND a top-levelversionab reads asliveVersion) +/commandfor ab, plus a loopback TCP listener the host dials. - The native-messaging host (
host/native-host.jsvianative-host.bat) — Chrome launches it, NOT ab, so it does NOT inherit ab's managed-runtime PATH. It reuses ab's Node vianode-path.txt(published by the bridge =process.execPath, backslash-normalized) and falls back to PATHnode. This is the ONLY place system-Node dependence could bite a fresh machine — keep the.bat's two-branch resolution intact. - The MV3 extension (
extension/) — runs inside Chrome/Edge, no runtime. SWconnectNative→ host → bridge loopback. Deterministic ID (manifest.jsonpinskey) so the host manifest'sallowed_originsis known ahead of time.
Data path: container → relay → ab → bridge /command → loopback → host stdio → extension SW → CDP/native.
Hard-won runtime gotchas (extension side)
chrome.tabs.goBack/goForwardthrow on tabs we drive. On a tab navigated viachrome.tabs.update({url})with the debugger lazily attached,chrome.tabs.goBackthrows "Cannot find a next page in history" even whenwindow.history.lengthproves there IS a back entry. Route back/forward through the PAGE's own history instead:chrome.scripting.executeScript({target:{tabId}, func: () => history.back()}). Reliable, and stays banner-free (no debugger attach). (Fixed in ext 0.0.16; verified on ADOMBASELINE.)- UIA Invoke doesn't fire on Win32 common-dialog buttons / some Edge controls.
desktop_ui_click(UIA Invoke) returnssuccess:truebut the dialog's OK/Select-Folder/Reload button doesn't actuate. Fall back to a foregrounddesktop_click {x,y}(SendInput) at the control's screen rect. (See installing-the-extension for the picker + Reload cases.) - Keyboard chords route through ab's
desktop_press_key(full modifier chords) - don't reimplement OS-level modifier handling in the bridge.nbrowser_press_keyis for in-page key events (CDP).
Verb contract
Request {command, args}; response {success, output(JSON string), error?, errorCode?} + the hints.
nbrowser_readiness is READ-ONLY (never spawns/installs) — mirror bridge_readiness. Long ops: return
a job id + statusVerb, don't block. Declare per-verb budgets >60s in bridge.json timeouts
(v1.9.79) so ab doesn't cut them at 60s. nbrowser_describe returns the full catalog.
Test locally
node -c bridge/server.js (syntax). Run server.js standalone → it binds ephemeral + writes the
discovery file; GET 127.0.0.1:<port>/status → led/summary/tooltip + version. End-to-end:
nbrowser_readiness through ab (--target <laptop>), then open bg window → screenshot → close.
Testing on John's VMs (winvm / ADOMBASELINE): John ALWAYS keeps an RDP session open to his VMs —
assume it; don't pause to confirm his RDP view before driving a VM over the --target <vm> ab path.
Full fresh-VM onboarding = install bridge (bridge_install {manifestUrl}), deploy extension/+host/,
write host/host-manifest.json (path→native-host.bat, allowed_origins→chrome-extension://<keyed-id>/),
register the native host in HKCU (…\Microsoft\Edge\NativeMessagingHosts / …\Google\Chrome\…), then load
the extension (Edge honors --load-extension; Chrome 150 is flaky with it - GUI Load-unpacked is the
fallback). The keyed ext id (nncjgceobmlenfliamnibncjojpedbcc) is stable, so the host allowed_origins
matches regardless of load method.
Reaching the user: NEVER notify only the local desktop
The bridge can run on a VM while the user is on their laptop - a notify_user that fires on the
LOCAL ab then lands on an unwatched VM desktop and the user never sees it (real bug: Chrome-install UAC
toast fired on the VM, John on his laptop saw nothing). Route user notifications cross-ab: query
targets via the direct-API; if any PEER desktop exists, send notify_user/notify_response with
target:"all" (top-level field on the /command payload) so it reaches the user wherever they're
attended; no peers (bridge on the user's own machine) = a local toast. adCommand(app,cmd,args,target)
adds payload.target. The toast BODY names the host (…on ${host}) since the action (e.g. a UAC) is on
the bridge's host but the alert is on the user's laptop - they keep an RDP session open to approve it.
The boundary with ab (don't file bridge bugs as ab bugs)
ab owns: spawn/reap (refresh_bridges force-restarts a running bridge as of v1.9.76; restart_bridge
is the explicit primitive), stable port, supervisor, cache install + auto-update, status
classification, the GUI card, liveVersion/staleProcess reporting. YOU own: verbs + hints,
/status, nbrowser_describe, the wiki page + triggers, the manifest + zip, the skills pkg. A generic
missing capability (new lifecycle verb, relay field) → file against ab; a misbehaving nbrowser_* verb
→ fix here.
Safety invariants (never regress — one wiped a user's other AI threads)
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=Nonein the activity log), so the badge never re-appeared and John saw an unbadged window being actively driven. Same class of bug: in-memorysessionHwndwiped 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) andsessions.jsonpersistence 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.
⭐ ab self-updates rotate the control port under you (2026-07-18 incident)
The env vars ab injects at spawn (ADOM_DIRECT_API_URL/_PORT) freeze the port ab had THEN. ab self-updates restart it on a new port while your process keeps running - and every adCommand (desktop_list_windows, desktop_taskbar, notify) starts failing. If those failures are swallowed, whole features (badge, flash, hwnd correlation) die SILENTLY for hours; a Jul-16 bridge process did exactly that until the user noticed a missing badge. Rules: (1) adCommand must re-discover from the ~/.adom/direct-api-port FILE on connection failure, retry, and cache the live base; (2) NEVER swallow an ab-link failure silently - log the outage + recovery to the bridge log AND the activity trail (_ab_link entries) so a missing badge is diagnosable in one query; (3) suspect stale strays: multiple node.exe bridge processes days old = ab lost track of them - kill by PID, never by image name.
⭐ The graceful-degradation ladder + nbe upsell (John, 2026-07-19 - "this is gold")
Getting a user to actually INSTALL the extension is hard. Do not let that block the value: there is a LADDER of capability, and the bridge must serve every rung while nudging users up it.
The rungs:
- No extension anywhere - the bridge still knows every profile (Local State), can wake profiles
with no window (
wake_profile), can OPEN the user's real browser at a URL in the RIGHT profile in the BACKGROUND (open_os_window: brief blip -> minimize -> orange taskbar flash = "come click when ready"), can inventory windows (os_windows), and can clean up (force_close {hwnd}, attested). - Extension in some profiles - full driving there; rung-1 verbs for the rest, with per-profile extension-state diagnosis in every refusal.
- Extension everywhere - the full surface: navigate, read, click, screenshot, guards, badges.
Why rung 1 matters (the killer use case): OAuth consent. adom-google, Autodesk APS, GitHub - the user is ALREADY signed in in their native browser; the AI only needs the consent page OPEN in the right profile, the USER clicks Approve, and the CLI's token poll picks up the result out-of-band. That single capability unlocks whole product setups (adom-google!) with zero extension install. After the auth, the user rarely needs more - so serving this WITHOUT the install is pure value.
The upsell rule: every rung-1/rung-2 response carries a hint that (a) states honestly what the bridge CANNOT do right now ("I opened it but I cannot see or drive it - the user must click"), and (b) names the upgrade + its payoff ("with nbe installed I could fill this form, read the result, and clean up - see installing-the-extension; it installs in about a minute"). The AI relays the upsell to the user at a NATURAL moment - never mid-auth, never nagging. Hints are how the bridge teaches; a degraded mode that fails silently or begs constantly both lose the install.
Background even at OS level, always: rung-1 opens must not steal focus any more than rung-3 ones. Post-launch minimize (ShowWindow SW_MINIMIZE returns focus to the user automatically) + a taskbar flash is the pattern - the user opens the window when THEY are ready. foreground:true stays gated behind foregroundReason + notify, exactly like the extension path.
⭐ Opening native browser windows without nbe — the hard lessons (John, 2026-07-19)
Spent a whole session flailing at "open a background window in the live profile" and CRASHED the user's Edge (and the AI thread running in it) FOUR times. The final root cause was small and the user named it before I did. Read this before writing any browser-launch code.
THE ACTUAL ROOT CAUSE: opening was always fine — the BACKGROUNDING call crashed the browser. I was
minimizing the freshly-launched window with ShowWindow(hwnd, SW_MINIMIZE) inside a 30ms poll loop,
the instant the window first appeared — i.e. hammering ShowWindow on a browser window while Chromium
was still constructing it. That race crashed the whole browser PROCESS (all its windows, including the
workspace), then Edge session-restored (windows come back with NEW hwnds — which looks like "you closed
it"). The launch itself never restarted anything. I wasted the session on exotic theories (singleton
takeover, session isolation, "Edge is fundamentally unsafe") instead of looking at my own two-line
backgrounding code and the ONE working reference implementation.
READ THE WORKING IMPLEMENTATION FIRST. Hydrogen Desktop's browser picker already opens native
browser windows reliably — hydrogen-desktop/src-tauri/src/control.rs, the /open-in-profile handler:
for Edge it just does cmd /c start msedge <url> — no --profile-directory, no --new-window, and
it never minimizes. When a working impl exists in the codebase, copy it before hand-rolling P/Invoke.
The correct recipe (shipped 0.1.41, verified live on the user's single-profile Edge — window opened minimized/background, workspace hwnd unchanged, no crash):
- Launch clean, like Hydrogen: don't pass
--profile-directory=Default(only pass a profile dir when it is NOT the default). Args as a PowerShell ARRAY, never an embedded-quote string ('--profile-directory= "Default"'passes LITERAL quotes to Chromium). e.g.Start-Process $exe -ArgumentList @('--new-window',$url). - Detect the new window READ-ONLY. Poll
EnumWindowsfor a genuinely-newChrome_WidgetWinhwnd (exclude a pre-snapshot + a PROTECTED set: the workspace/editor window — titles- Editor -|Hydrogen| code-server|claude.ai|galliaAprilfor the right brand — and other threads' live-session hwnds). Do NOT touch any window inside this loop. - Background with ONE settled, gentle call. After the window is found,
Start-Sleep 700msto let Chromium FULLY build it, then ONEShowWindow(hwnd, SW_SHOWMINNOACTIVE=7)(minimize WITHOUT stealing focus). One call, on a settled window, no loop. This is the crash-safe part — the whole bug was here.
Do NOT build a footgun, and do NOT wall off a feature you can't root-cause. I twice over-reacted: (a)
added a confirmLiveProfile:true override to a guard, then passed it myself on the live profile; (b)
when crashes continued I re-added a HARD refuse that blocked the exact thing the user asked for. Both
were me guessing instead of fixing the real (tiny) bug. If you're refusing a whole capability, you don't
understand the failure yet — keep debugging.
Verify node --check server.js PASSES before EVERY release. I shipped 0.1.34 and 0.1.36 with syntax
errors because the release commands ran after a failed check in the same && chain wasn't actually
gating them. A syntax error = the bridge won't spawn = native browser is DOWN for the user. Make syntax
verification a hard, separate gate: node --check server.js || exit 1 on its own line.
Never demo a destructive-capable op against the user's LIVE working profile. John's workspace runs inside Edge's only profile; every Edge open I did was aimed at the very session he was using. If there is no safe surface to prove something on, say "I can't safely prove this on your machine" — do not gamble.
Isolated --user-data-dir on a work machine is NOT clean. A fresh Edge profile auto-signs-in the
Windows work account and pops Microsoft's "we're syncing your data across devices" first-run dialog.
It's confined to that throwaway user-data-dir (delete it after), and it does NOT flip sync on the real
Default profile — but it looks alarming to the user. Prefer not launching extra browser instances on the
user's machine at all when you can avoid it.
Diagnostics gotchas: Get-Process msedge | MainWindowTitle is unreliable for a multi-window browser
(one process, one MainWindow) — use EnumWindows + GetWindowText to truly enumerate windows. And
$pid is a READ-ONLY PowerShell automatic variable (the shell's own PID) — never assign to it; your
"pid" readouts will be garbage. When checking "did I just kill a window", enumerate windows directly;
desktop_list_windows returning 0 during ab churn is "can't see", not "gone".
The honest product truth to keep surfacing: for the user's REAL signed-in session, opening a window
via nbe (chrome.windows.create FROM INSIDE the running browser — no process launch at all) is
strictly safer than any external launch. Extension-free open_os_window is the graceful-degradation
wedge; nbe is the clean path. Say so.
⭐ The badge MUST land on the driven window, never a same-profile PWA (John, 2026-07-21)
The taskbar badge is a SECURITY signal — the user has to know which exact browser window an AI is
driving. It broke because badgeSpawnsIn re-correlated a driven session to "the first OS window whose
title contains the profile email" (profileHwnd). On a work profile that matches the "Inbox -
[email protected] - Adom Mail"/Gmail PWA window, so the badge landed on the PWA's taskbar button instead
of the Chrome window the user was watching = the driven window showed NO badge. This is the SAME
wrong-PWA bug that touchBadge warns about, reintroduced in the correlation helper. Rules:
- Correlate each session to its OWN window by the page title, and ONLY match real browser windows
(title ends
- Google Chrome/- Microsoft Edge). Never badge by profile-email-in-title — PWAs (Adom Mail, Google Messages, Gmail-as-app) carry the email too and are NOT the browser window. - Prefer an exact title PREFIX over a substring: the OS title is
<page title> - <Browser>, so the session's page title is a prefix. Two windows on the same site (e.g. two Gusto pages) share a substring but only one is the prefix — prefix-match disambiguates;matchHwnddoes this now. - After a bridge RESTART, active sessions lose their captured hwnd; the badge self-heals on the
session's next drive verb (
touchBadge→extQuery(list_windows)→badgeSpawnsIn→matchHwnd). I restarted the bridge ~20× shipping other fixes that day, so every active thread was un-badged until its next verb — expected, but verify the self-heal actually re-correlates to the RIGHT window.
⭐ A non-2xx from ab is a FAILURE — never treat it as success (2026-07-21, fusion-bridge report)
adCommand only treated a connection failure (status === 0) as an error. Any HTTP error (403, 500)
fell through as "success" with an error body, so chromeWindowsDetailed() parsed no windows and returned
[]. Result: nbrowser_os_windows reported 0 windows while ab saw 21, and — far worse — the taskbar
BADGE and FLASH silently stopped working, because they go through the same call. Nothing logged.
- Root cause: the bridge token. ab injects
ADOM_BRIDGE_TOKENat spawn and rotates it when ab self-updates. A long-running bridge keeps sending the stale one and ab answers 403 to every call (verified: bogus token → 403; no token → 200 + 26 windows). The token is attribution-only, so the correct recovery is to DROP it and retry, then keep going token-less (adTokenRejected). - Rules: treat only 2xx as success; on 401/403 retry without the token; log every ab-link
outage/recovery (
_ab_link) so degradation is never silent. ab self-updates several times a day, so assume the token, the port, AND the API surface can all shift under a running bridge.
⭐ Log every badge paint + flash (John, 2026-07-21)
The taskbar badge is a security signal; a silent failure is indistinguishable from "no AI is driving."
taskbar() now logs an _taskbar activity entry for EVERY call — hwnd, ops
(overlay:composite|overlay:adom|overlay:none|flash:until_focused|progress:x), ok, and the HTTP
status on failure — and nbrowser_activity carries hwnd/ops through so you can query
{verb:"_taskbar"} and see exactly what was painted where and whether ab accepted it.
⭐ A load-unpacked extension has an EMPTY manifest.name on disk (2026-07-21)
adomExtStateForProfile matched extensions.settings[*].manifest.name =~ /adom/, but a Load-unpacked
install stores an empty manifest.name (only path). So a profile that HAD the extension reported
extensionInstalled:false, and the upsell hint told users WITH the extension to install it. Match on the
pinned extension ID (nncjgceobm…, stable because manifest pins key) or the path as well.
Authority order: a LIVE native-messaging connection (what nbrowser_readiness reports) is
authoritative; the on-disk inventory is only a fallback for profiles that are not connected. Never let
two verbs disagree about whether the extension is installed.
⭐ Report "did it launch" separately from "did I get an hwnd" (2026-07-21)
nbrowser_open_os_window returned ok:false when the browser GROUPED the page into an existing window
(no new hwnd within 8s) even though the page opened — for a consent flow that IS success, and a caller
was forced to regex our _hint to detect it. Contract now: ok:true + launched:true whenever the
launch fired, with hwndResolved:bool + hwndReason reported separately. Never make a caller parse
prose to learn an outcome — give them a stable boolean.
⭐ Two badges, and SHOW the human the foreground reason (John, 2026-07-22)
The overlay badge must distinguish the two capability layers, because conflating them is a security ambiguity ("is an AI reading this window, or did it just open it?"):
- LAYER 2 — solid teal Adom favicon (
adom-favicon.png) = the extension is installed and the AI has LIVE CONTROL (read/click/type). Painted atopen_window, re-asserted by every drive verb. - LAYER 1 — HOLLOW teal outline (
adom-favicon-hollow.png) = extension-freeopen_os_window: Adom OPENED this window but cannot see or drive it; the USER is in control. Filled-vs-outline is the convention everyone already reads, and it survives at the true ~24px overlay size (a merely muted variant did not — it was indistinguishable from layer 2, which defeats the whole point). Both use the SAME lifecycle:applyBadgearms the 3-min idle expiry, so they self-clear back to the profile's plain avatar and never linger.
Foreground requires a reason AND now shows it. Background is the hard default in both layers
(layer 2: userRequestedForeground + non-empty foregroundReason, else auto-reverted + logged as a
focus_violation; layer 1: foreground:true without foregroundReason → refused
foreground_unjustified). But the reason only ever reached the AI. When a foreground grant is actually
exercised, announceForeground() now paints a full-desktop desktop_caption with that reason for 3s
(auto-dismiss) and logs _foreground_announce. A takeover the user cannot see explained is exactly what
erodes trust in an agent that can touch their desktop.
⭐ Announce EVERY user-visible open, at the BOTTOM of the screen (John, 2026-07-22)
(Duration: 3s - 2s was tried live and read too fast to actually absorb.)
The rule: if a native-browser open is going to be visible to the user — because that is simply how
the browser behaves — allow it, do not fight it, but put up a 3-second desktop caption naming the
reason. Fighting the browser produced worse bugs than the foregrounding ever did; being unannounced is
the actual failure. Three cases must announce (announceVisibleOpen):
- an explicit foreground grant (
foreground:true/userRequestedForeground+ reason), - a page the browser GROUPED into an existing window — no new window exists to background, so it surfaces regardless (this is what made a Fusion sign-in page appear unannounced),
open_tabwithactive:true— the tab lands in front of the user inside an existing window.
Position: bottom, never top. The user's eye is already at the taskbar — that is where the overlay
badge and the orange flash appear — so a bottom caption sits beside the signals it explains instead of
competing with them from the far edge of the screen.
The reason is never optional and never blank: it falls back to the session's purpose, which
registration already makes mandatory. Worst case it reads "no reason given", which is itself a useful
signal that a calling thread is being sloppy. Every caption is logged (_open_announce) so you can audit
which thread announced what, and whether ab actually rendered it.
Do NOT force-minimize what the browser intends to surface. Background stays the default when a
genuinely new window IS created (verify minimized:true); when it cannot be honored, announce instead of
wrestling the window manager.
⭐ Caller provenance: WHO asked (ab 1.9.180+, wiki #345/#351)
ab stamps X-Adom-Caller-Thread / -Container / -Reason on every relayed request. Self-asserted:
attribution, logging, UX, arbitration hints - NEVER authorization. What this bridge does with it:
- Log it -
logActivitystamps the calling thread on every entry (nbrowser_activityreturnscaller, filterable withcallerThread). - Forward it on every ab callback made while carrying out that thread's verb, plus
X-Adom-Caller-Delegate: native-browser, so ab showschip-fetcher tab 3 (via native-browser). Self-initiated work (timers, sweeps, reapers) sendsnative-browser bridge (self)instead - never a stale identity. - Arbitrate -
sessionCallerrecords the owning thread; a DIFFERENT thread driving that session gets_concurrentAgentsnaming the owner (warn, never refuse; destructive cross-thread ops were already refused). - Label the window -
desktop_set_window_identity {appId:'Adom.NativeBrowser', displayName: 'Adom · <thread> · <purpose>'}so a glance at the taskbar says which conversation owns which window.
Three traps this cost me, all invisible until tested end-to-end:
args.calleris RESERVED. ab/the CLI inject the identity there on EVERY call. I usedargs.calleras an activity FILTER and silently emptied the trail (8 logged, 0 returned). Use your own arg name.- AsyncLocalStorage does NOT survive the socket reply path. Verbs resolve through the extension's
pendingmap, outside the HTTP request's async scope, so ALS is empty inonHostFrame- every badge paint and window-identity call from there would forward "self". Capture the caller INTO the pending record andcallerCtx.run(...)around the reply handler. - A var set in
dispatchis not visible inonHostFrame. They are different functions; carry cross-cutting state (the arbitration warning) on the pending record too. - And the standing one: widening what you LOG means nothing if the query PROJECTION drops the field.
I have now hit that exact bug three times (
hwnd/ops, thencaller). Update the projection in the same commit as the log field.
⭐ bridge.json verbs[] is what ab can SEE, and it silently rots (John, 2026-08-08)
bridge/bridge.json is the manifest ab reads to learn our surface. A verb that is implemented in
VERB_META but missing from verbs[] is invisible to every caller even though the code is right
there and works. Nothing errors. Nothing warns. It just does not exist as far as ab is concerned.
Because the manifest was hand-maintained, 12 verbs drifted out of it, including
nbrowser_events (the ONLY channel by which spawned-window and dialog events reach the AI),
nbrowser_autologin, nbrowser_login_form, nbrowser_hover, nbrowser_download and
nbrowser_caption. They had been shipping unusable for multiple releases. John caught it as
"update your manifest for ab to read all your latest settings", not as a bug report, because from
the outside it looks like the feature was never built.
THE RULE: never hand-edit verbs[]. VERB_META in bridge/server.js is the single source of
truth; regenerate the manifest from it, order included:
node -e '...read VERB_META keys, write bridge.json verbs[]...' # see the publish skill
node tools/check-guard-coverage.mjs # now FAILS on any drift
tools/check-guard-coverage.mjs gained a MANIFEST PARITY gate that fails the build on an
undeclared verb, a dead declared verb, an out-of-order list, or a bridge.json version that does
not match BRIDGE_VERSION. Adding a verb is now: write VERB_META → classify it in
verbs-policy.json → regenerate verbs[] → the gate goes green. Skipping the regenerate is the
failure mode, so let the gate catch it rather than trusting a checklist.
The generalisable lesson: any file that duplicates a list the code already owns will drift. Either generate it or gate it. Preferably both.
⭐ Naming: nb is layer 1, nbe is layer 2 (John, 2026-08-08, definitions v1.9.0)
Locked, and it answers the extension display-name question:
- nb, Adom Native Browser is the PARENT and the layer-1 product: extension-free OS-level control.
- nbe, Adom Native Browser Extension is layer 2, the installed extension.
abeis dead. - ab = Bridge, ah = Hydrogen. Single-letter shorthands are gone; every shorthand is 2+ chars,
and a would-be single letter takes the Adom
aprefix (w→aw, h→ah, b→ab). OS variants:ab win,ah mac, etc. - CLI is
adom-bridge-cliin the container;adom-bridgeis the host-OS app launcher. Theadom-desktopbinary is GONE, not aliased. - pup's verbs are
pup_*, renamed frombrowser_*, no aliases.
The extension's manifest.json name is "Adom Native Browser Extension" and nbrowser_status
reports ext:"nbe" (was "adom-browser-extension"). Per the hard-cutover policy we broke both
loudly rather than aliasing: a caller matching the old string SHOULD fail so it gets found and fixed.
WATCH OUT when sweeping names: a blanket "Adom Desktop"→"Bridge" rewrite corrupts real
filesystem paths. ab's data dir is %LOCALAPPDATA%\Adom Bridge\bridges-cache\ (productName
"Adom Bridge"), NOT \Bridge\, and its recordings moved to %TEMP%\adom-bridge-recordings. The
.ps1 installers hardcode that cache path, so they silently install into a directory ab no longer
reads. Verify every path literal against the ab source, and keep binaries (.gif too, not just
.png) out of any bulk text rewrite.
---
name: developing-the-bridge
description: "DEVELOPER skill for maintainers of the adom-native-browser (nb/nbe) bridge - NOT for driving it. Read this before editing bridge/server.js, host/native-host.js, or extension/. Covers the architecture (bridge + native-messaging host + MV3 extension, and how ab provisions Node), the #1 rule that every nbrowser_* verb must return rich _hint/_next/related/pitfalls via the VERB_META table, the verb contract, local testing, the ab-vs-bridge boundary, hard-won runtime gotchas (chrome.tabs.goBack fails on debugger-driven tabs; UIA Invoke fails on Win32 dialogs), and the safety invariants. Everyday users never need this."
---
Parent skill: **adom-native-browser**. This is a maintainer-only skill (not user-invocable). Bridge
SDK: https://wiki.adom.inc/adom/adom-bridge-sdk
# Developing the native-browser bridge
## ⭐ THE loudest rule: the AI reads your verb OUTPUT, not this file
ab relays every field of a verb response verbatim to the calling AI. A verb that returns
`{success:true}` and nothing else makes the AI guess and retry-loop. EVERY `nbrowser_*` response MUST
carry a rich **`_hint`** (what just happened + the obvious next verb + the trap) plus `_next` /
`related` / `pitfalls`. This is the single highest-leverage thing in the whole bridge.
Mechanism in `server.js`: a single **`VERB_META`** table is the source of truth. It feeds BOTH
`nbrowser_describe` (per-verb hint/related/pitfalls) AND a `decorateHint(verb, r)` wrapper that stamps
top-level `_hint`/`related`/`_next` onto EVERY dispatch response. Add a verb → add its VERB_META entry
in the same commit, or it ships hint-less.
## The 3 components (know which one you're touching)
1. **The bridge** (`bridge/server.js`) — ab spawns it (`spawn.kind:"node"`), so **ab provisions Node**
(SDK runtime contract; you never install Node for it). Binds `127.0.0.1:0` on
`process.env.ADOM_BIND_HOST || '127.0.0.1'` (never `0.0.0.0` → no firewall prompt). Writes a
discovery file `~/.adom/bridges/native-browser/host.json` + `node-path.txt`. Serves `/status`
(health chip: led/summary/tooltip, AND a top-level `version` ab reads as `liveVersion`) + `/command`
for ab, plus a loopback TCP listener the host dials.
2. **The native-messaging host** (`host/native-host.js` via `native-host.bat`) — **Chrome launches it**,
NOT ab, so it does NOT inherit ab's managed-runtime PATH. It reuses ab's Node via
`node-path.txt` (published by the bridge = `process.execPath`, backslash-normalized) and falls back
to PATH `node`. This is the ONLY place system-Node dependence could bite a fresh machine — keep the
`.bat`'s two-branch resolution intact.
3. **The MV3 extension** (`extension/`) — runs inside Chrome/Edge, no runtime. SW `connectNative` →
host → bridge loopback. Deterministic ID (`manifest.json` pins `key`) so the host manifest's
`allowed_origins` is known ahead of time.
Data path: container → relay → ab → bridge `/command` → loopback → host stdio → extension SW → CDP/native.
## Hard-won runtime gotchas (extension side)
- **`chrome.tabs.goBack`/`goForward` throw on tabs we drive.** On a tab navigated via
`chrome.tabs.update({url})` with the debugger lazily attached, `chrome.tabs.goBack` throws *"Cannot
find a next page in history"* even when `window.history.length` proves there IS a back entry. **Route
back/forward through the PAGE's own history** instead: `chrome.scripting.executeScript({target:{tabId},
func: () => history.back()})`. Reliable, and stays banner-free (no debugger attach). (Fixed in ext
0.0.16; verified on ADOMBASELINE.)
- **UIA Invoke doesn't fire on Win32 common-dialog buttons / some Edge controls.** `desktop_ui_click`
(UIA Invoke) returns `success:true` but the dialog's OK/Select-Folder/Reload button doesn't actuate.
Fall back to a foreground `desktop_click {x,y}` (SendInput) at the control's screen rect. (See
installing-the-extension for the picker + Reload cases.)
- **Keyboard chords route through ab's `desktop_press_key`** (full modifier chords) - don't reimplement
OS-level modifier handling in the bridge. `nbrowser_press_key` is for in-page key events (CDP).
## Verb contract
Request `{command, args}`; response `{success, output(JSON string), error?, errorCode?}` + the hints.
`nbrowser_readiness` is READ-ONLY (never spawns/installs) — mirror `bridge_readiness`. Long ops: return
a job id + `statusVerb`, don't block. Declare per-verb budgets >60s in `bridge.json` `timeouts`
(v1.9.79) so ab doesn't cut them at 60s. `nbrowser_describe` returns the full catalog.
## Test locally
`node -c bridge/server.js` (syntax). Run `server.js` standalone → it binds ephemeral + writes the
discovery file; `GET 127.0.0.1:<port>/status` → led/summary/tooltip + `version`. End-to-end:
`nbrowser_readiness` through ab (`--target <laptop>`), then open bg window → screenshot → close.
**Testing on John's VMs (winvm / ADOMBASELINE):** John ALWAYS keeps an RDP session open to his VMs —
**assume it; don't pause to confirm his RDP view before driving a VM over the `--target <vm>` ab path.**
Full fresh-VM onboarding = install bridge (`bridge_install {manifestUrl}`), deploy `extension/`+`host/`,
write `host/host-manifest.json` (path→`native-host.bat`, `allowed_origins`→`chrome-extension://<keyed-id>/`),
register the native host in HKCU (`…\Microsoft\Edge\NativeMessagingHosts` / `…\Google\Chrome\…`), then load
the extension (Edge honors `--load-extension`; Chrome 150 is flaky with it - GUI Load-unpacked is the
fallback). The keyed ext id (`nncjgceobmlenfliamnibncjojpedbcc`) is stable, so the host `allowed_origins`
matches regardless of load method.
## Reaching the user: NEVER notify only the local desktop
The bridge can run on a **VM** while the user is on their **laptop** - a `notify_user` that fires on the
LOCAL ab then lands on an unwatched VM desktop and the user never sees it (real bug: Chrome-install UAC
toast fired on the VM, John on his laptop saw nothing). **Route user notifications cross-ab:** query
`targets` via the direct-API; if any PEER desktop exists, send `notify_user`/`notify_response` with
`target:"all"` (top-level field on the `/command` payload) so it reaches the user wherever they're
attended; no peers (bridge on the user's own machine) = a local toast. `adCommand(app,cmd,args,target)`
adds `payload.target`. The toast BODY names the host (`…on ${host}`) since the action (e.g. a UAC) is on
the bridge's host but the alert is on the user's laptop - they keep an RDP session open to approve it.
## The boundary with ab (don't file bridge bugs as ab bugs)
ab owns: spawn/reap (`refresh_bridges` force-restarts a running bridge as of v1.9.76; `restart_bridge`
is the explicit primitive), stable port, supervisor, cache install + auto-update, status
classification, the GUI card, `liveVersion`/`staleProcess` reporting. YOU own: verbs + hints,
`/status`, `nbrowser_describe`, the wiki page + triggers, the manifest + zip, the skills pkg. A generic
missing capability (new lifecycle verb, relay field) → file against ab; a misbehaving `nbrowser_*` verb
→ fix here.
## Safety invariants (never regress — one wiped a user's other AI threads)
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.
## ⭐ ab self-updates rotate the control port under you (2026-07-18 incident)
The env vars ab injects at spawn (ADOM_DIRECT_API_URL/_PORT) freeze the port ab had THEN. ab
self-updates restart it on a new port while your process keeps running - and every adCommand
(desktop_list_windows, desktop_taskbar, notify) starts failing. If those failures are swallowed, whole
features (badge, flash, hwnd correlation) die SILENTLY for hours; a Jul-16 bridge process did exactly
that until the user noticed a missing badge. Rules: (1) adCommand must re-discover from the
~/.adom/direct-api-port FILE on connection failure, retry, and cache the live base; (2) NEVER swallow an
ab-link failure silently - log the outage + recovery to the bridge log AND the activity trail (_ab_link
entries) so a missing badge is diagnosable in one query; (3) suspect stale strays: multiple node.exe
bridge processes days old = ab lost track of them - kill by PID, never by image name.
## ⭐ The graceful-degradation ladder + nbe upsell (John, 2026-07-19 - "this is gold")
Getting a user to actually INSTALL the extension is hard. Do not let that block the value: there is a
LADDER of capability, and the bridge must serve every rung while nudging users up it.
**The rungs:**
1. **No extension anywhere** - the bridge still knows every profile (Local State), can wake profiles
with no window (`wake_profile`), can OPEN the user's real browser at a URL in the RIGHT profile in
the BACKGROUND (`open_os_window`: brief blip -> minimize -> orange taskbar flash = "come click when
ready"), can inventory windows (`os_windows`), and can clean up (`force_close {hwnd}`, attested).
2. **Extension in some profiles** - full driving there; rung-1 verbs for the rest, with per-profile
extension-state diagnosis in every refusal.
3. **Extension everywhere** - the full surface: navigate, read, click, screenshot, guards, badges.
**Why rung 1 matters (the killer use case):** OAuth consent. adom-google, Autodesk APS, GitHub - the
user is ALREADY signed in in their native browser; the AI only needs the consent page OPEN in the right
profile, the USER clicks Approve, and the CLI's token poll picks up the result out-of-band. That single
capability unlocks whole product setups (adom-google!) with zero extension install. After the auth, the
user rarely needs more - so serving this WITHOUT the install is pure value.
**The upsell rule:** every rung-1/rung-2 response carries a hint that (a) states honestly what the
bridge CANNOT do right now ("I opened it but I cannot see or drive it - the user must click"), and
(b) names the upgrade + its payoff ("with nbe installed I could fill this form, read the result, and
clean up - see installing-the-extension; it installs in about a minute"). The AI relays the upsell to
the user at a NATURAL moment - never mid-auth, never nagging. Hints are how the bridge teaches; a
degraded mode that fails silently or begs constantly both lose the install.
**Background even at OS level, always:** rung-1 opens must not steal focus any more than rung-3 ones.
Post-launch minimize (ShowWindow SW_MINIMIZE returns focus to the user automatically) + a taskbar flash
is the pattern - the user opens the window when THEY are ready. foreground:true stays gated behind
foregroundReason + notify, exactly like the extension path.
## ⭐ Opening native browser windows without nbe — the hard lessons (John, 2026-07-19)
Spent a whole session flailing at "open a background window in the live profile" and CRASHED the user's
Edge (and the AI thread running in it) FOUR times. The final root cause was small and the user named it
before I did. Read this before writing any browser-launch code.
**THE ACTUAL ROOT CAUSE: opening was always fine — the BACKGROUNDING call crashed the browser.** I was
minimizing the freshly-launched window with `ShowWindow(hwnd, SW_MINIMIZE)` **inside a 30ms poll loop,
the instant the window first appeared** — i.e. hammering ShowWindow on a browser window while Chromium
was still constructing it. That race crashed the whole browser PROCESS (all its windows, including the
workspace), then Edge session-restored (windows come back with NEW hwnds — which looks like "you closed
it"). The launch itself never restarted anything. I wasted the session on exotic theories (singleton
takeover, session isolation, "Edge is fundamentally unsafe") instead of looking at my own two-line
backgrounding code and the ONE working reference implementation.
**READ THE WORKING IMPLEMENTATION FIRST.** Hydrogen Desktop's browser picker already opens native
browser windows reliably — `hydrogen-desktop/src-tauri/src/control.rs`, the `/open-in-profile` handler:
for Edge it just does `cmd /c start msedge <url>` — **no `--profile-directory`, no `--new-window`, and
it never minimizes.** When a working impl exists in the codebase, copy it before hand-rolling P/Invoke.
**The correct recipe (shipped 0.1.41, verified live on the user's single-profile Edge — window opened
minimized/background, workspace hwnd unchanged, no crash):**
1. **Launch clean, like Hydrogen:** don't pass `--profile-directory=Default` (only pass a profile dir when it
is NOT the default). Args as a PowerShell ARRAY, never an embedded-quote string (`'--profile-directory=
"Default"'` passes LITERAL quotes to Chromium). e.g. `Start-Process $exe -ArgumentList @('--new-window',$url)`.
2. **Detect the new window READ-ONLY.** Poll `EnumWindows` for a genuinely-new `Chrome_WidgetWin` hwnd
(exclude a pre-snapshot + a PROTECTED set: the workspace/editor window — titles `- Editor -|Hydrogen|
code-server|claude.ai|galliaApril` for the right brand — and other threads' live-session hwnds).
**Do NOT touch any window inside this loop.**
3. **Background with ONE settled, gentle call.** After the window is found, `Start-Sleep 700ms` to let
Chromium FULLY build it, then ONE `ShowWindow(hwnd, SW_SHOWMINNOACTIVE=7)` (minimize WITHOUT stealing
focus). One call, on a settled window, no loop. This is the crash-safe part — the whole bug was here.
**Do NOT build a footgun, and do NOT wall off a feature you can't root-cause.** I twice over-reacted: (a)
added a `confirmLiveProfile:true` override to a guard, then passed it myself on the live profile; (b)
when crashes continued I re-added a HARD refuse that blocked the exact thing the user asked for. Both
were me guessing instead of fixing the real (tiny) bug. If you're refusing a whole capability, you don't
understand the failure yet — keep debugging.
**Verify `node --check server.js` PASSES before EVERY release.** I shipped 0.1.34 and 0.1.36 with syntax
errors because the release commands ran after a failed check in the same `&&` chain wasn't actually
gating them. A syntax error = the bridge won't spawn = native browser is DOWN for the user. Make syntax
verification a hard, separate gate: `node --check server.js || exit 1` on its own line.
**Never demo a destructive-capable op against the user's LIVE working profile.** John's workspace runs
inside Edge's only profile; every Edge open I did was aimed at the very session he was using. If there is
no safe surface to prove something on, say "I can't safely prove this on your machine" — do not gamble.
**Isolated `--user-data-dir` on a work machine is NOT clean.** A fresh Edge profile auto-signs-in the
Windows work account and pops Microsoft's "we're syncing your data across devices" first-run dialog.
It's confined to that throwaway user-data-dir (delete it after), and it does NOT flip sync on the real
Default profile — but it looks alarming to the user. Prefer not launching extra browser instances on the
user's machine at all when you can avoid it.
**Diagnostics gotchas:** `Get-Process msedge | MainWindowTitle` is unreliable for a multi-window browser
(one process, one MainWindow) — use `EnumWindows` + `GetWindowText` to truly enumerate windows. And
`$pid` is a READ-ONLY PowerShell automatic variable (the shell's own PID) — never assign to it; your
"pid" readouts will be garbage. When checking "did I just kill a window", enumerate windows directly;
`desktop_list_windows` returning 0 during ab churn is "can't see", not "gone".
**The honest product truth to keep surfacing:** for the user's REAL signed-in session, opening a window
via **nbe** (`chrome.windows.create` FROM INSIDE the running browser — no process launch at all) is
strictly safer than any external launch. Extension-free `open_os_window` is the graceful-degradation
wedge; nbe is the clean path. Say so.
## ⭐ The badge MUST land on the driven window, never a same-profile PWA (John, 2026-07-21)
The taskbar badge is a SECURITY signal — the user has to know which exact browser window an AI is
driving. It broke because `badgeSpawnsIn` re-correlated a driven session to "the first OS window whose
title contains the profile email" (`profileHwnd`). On a work profile that matches the **"Inbox -
[email protected] - Adom Mail"/Gmail PWA** window, so the badge landed on the PWA's taskbar button instead
of the Chrome window the user was watching = the driven window showed NO badge. This is the SAME
wrong-PWA bug that `touchBadge` warns about, reintroduced in the correlation helper. Rules:
- **Correlate each session to its OWN window by the page title, and ONLY match real browser windows**
(title ends `- Google Chrome` / `- Microsoft Edge`). Never badge by profile-email-in-title — PWAs
(Adom Mail, Google Messages, Gmail-as-app) carry the email too and are NOT the browser window.
- **Prefer an exact title PREFIX** over a substring: the OS title is `<page title> - <Browser>`, so the
session's page title is a prefix. Two windows on the same site (e.g. two Gusto pages) share a
substring but only one is the prefix — prefix-match disambiguates; `matchHwnd` does this now.
- After a bridge RESTART, active sessions lose their captured hwnd; the badge self-heals on the
session's next drive verb (`touchBadge` → `extQuery(list_windows)` → `badgeSpawnsIn` → `matchHwnd`).
I restarted the bridge ~20× shipping other fixes that day, so every active thread was un-badged until
its next verb — expected, but verify the self-heal actually re-correlates to the RIGHT window.
## ⭐ A non-2xx from ab is a FAILURE — never treat it as success (2026-07-21, fusion-bridge report)
`adCommand` only treated a *connection* failure (`status === 0`) as an error. Any HTTP error (403, 500)
fell through as "success" with an error body, so `chromeWindowsDetailed()` parsed no windows and returned
`[]`. Result: `nbrowser_os_windows` reported **0 windows while ab saw 21**, and — far worse — the taskbar
BADGE and FLASH silently stopped working, because they go through the same call. Nothing logged.
- **Root cause: the bridge token.** ab injects `ADOM_BRIDGE_TOKEN` at spawn and **rotates it when ab
self-updates**. A long-running bridge keeps sending the stale one and ab answers **403 to every call**
(verified: bogus token → 403; no token → 200 + 26 windows). The token is **attribution-only**, so the
correct recovery is to **DROP it and retry**, then keep going token-less (`adTokenRejected`).
- **Rules:** treat only 2xx as success; on 401/403 retry without the token; log every ab-link
outage/recovery (`_ab_link`) so degradation is never silent. ab self-updates several times a day, so
assume the token, the port, AND the API surface can all shift under a running bridge.
## ⭐ Log every badge paint + flash (John, 2026-07-21)
The taskbar badge is a security signal; a silent failure is indistinguishable from "no AI is driving."
`taskbar()` now logs an `_taskbar` activity entry for EVERY call — `hwnd`, `ops`
(`overlay:composite|overlay:adom|overlay:none|flash:until_focused|progress:x`), `ok`, and the HTTP
status on failure — and `nbrowser_activity` carries `hwnd`/`ops` through so you can query
`{verb:"_taskbar"}` and see exactly what was painted where and whether ab accepted it.
## ⭐ A load-unpacked extension has an EMPTY manifest.name on disk (2026-07-21)
`adomExtStateForProfile` matched `extensions.settings[*].manifest.name =~ /adom/`, but a **Load-unpacked**
install stores an empty `manifest.name` (only `path`). So a profile that HAD the extension reported
`extensionInstalled:false`, and the upsell hint told users WITH the extension to install it. Match on the
**pinned extension ID** (`nncjgceobm…`, stable because manifest pins `key`) or the **path** as well.
**Authority order:** a LIVE native-messaging connection (what `nbrowser_readiness` reports) is
authoritative; the on-disk inventory is only a fallback for profiles that are not connected. Never let
two verbs disagree about whether the extension is installed.
## ⭐ Report "did it launch" separately from "did I get an hwnd" (2026-07-21)
`nbrowser_open_os_window` returned `ok:false` when the browser GROUPED the page into an existing window
(no new hwnd within 8s) even though the page opened — for a consent flow that IS success, and a caller
was forced to regex our `_hint` to detect it. Contract now: **`ok:true` + `launched:true`** whenever the
launch fired, with **`hwndResolved:bool` + `hwndReason`** reported separately. Never make a caller parse
prose to learn an outcome — give them a stable boolean.
## ⭐ Two badges, and SHOW the human the foreground reason (John, 2026-07-22)
The overlay badge must distinguish the two capability layers, because conflating them is a security
ambiguity ("is an AI reading this window, or did it just open it?"):
- **LAYER 2 — solid teal Adom favicon** (`adom-favicon.png`) = the extension is installed and the AI has
LIVE CONTROL (read/click/type). Painted at `open_window`, re-asserted by every drive verb.
- **LAYER 1 — HOLLOW teal outline** (`adom-favicon-hollow.png`) = extension-free `open_os_window`: Adom
OPENED this window but cannot see or drive it; the USER is in control. Filled-vs-outline is the
convention everyone already reads, and it survives at the true ~24px overlay size (a merely *muted*
variant did not — it was indistinguishable from layer 2, which defeats the whole point).
Both use the SAME lifecycle: `applyBadge` arms the 3-min idle expiry, so they self-clear back to the
profile's plain avatar and never linger.
**Foreground requires a reason AND now shows it.** Background is the hard default in both layers
(layer 2: `userRequestedForeground` + non-empty `foregroundReason`, else auto-reverted + logged as a
`focus_violation`; layer 1: `foreground:true` without `foregroundReason` → refused
`foreground_unjustified`). But the reason only ever reached the AI. When a foreground grant is actually
exercised, `announceForeground()` now paints a full-desktop `desktop_caption` with that reason for 3s
(auto-dismiss) and logs `_foreground_announce`. A takeover the user cannot see explained is exactly what
erodes trust in an agent that can touch their desktop.
## ⭐ Announce EVERY user-visible open, at the BOTTOM of the screen (John, 2026-07-22)
(Duration: **3s** - 2s was tried live and read too fast to actually absorb.)
**The rule:** if a native-browser open is going to be visible to the user — because that is simply how
the browser behaves — **allow it**, do not fight it, but put up a **3-second desktop caption naming the
reason**. Fighting the browser produced worse bugs than the foregrounding ever did; being unannounced is
the actual failure. Three cases must announce (`announceVisibleOpen`):
1. an explicit foreground grant (`foreground:true` / `userRequestedForeground` + reason),
2. **a page the browser GROUPED into an existing window** — no new window exists to background, so it
surfaces regardless (this is what made a Fusion sign-in page appear unannounced),
3. `open_tab` with `active:true` — the tab lands in front of the user inside an existing window.
**Position: `bottom`, never `top`.** The user's eye is already at the taskbar — that is where the overlay
badge and the orange flash appear — so a bottom caption sits beside the signals it explains instead of
competing with them from the far edge of the screen.
**The reason is never optional and never blank:** it falls back to the session's `purpose`, which
registration already makes mandatory. Worst case it reads "no reason given", which is itself a useful
signal that a calling thread is being sloppy. Every caption is logged (`_open_announce`) so you can audit
which thread announced what, and whether ab actually rendered it.
**Do NOT force-minimize what the browser intends to surface.** Background stays the default when a
genuinely new window IS created (verify `minimized:true`); when it cannot be honored, announce instead of
wrestling the window manager.
## ⭐ Caller provenance: WHO asked (ab 1.9.180+, wiki #345/#351)
ab stamps `X-Adom-Caller-Thread` / `-Container` / `-Reason` on every relayed request. **Self-asserted:
attribution, logging, UX, arbitration hints - NEVER authorization.** What this bridge does with it:
1. **Log it** - `logActivity` stamps the calling thread on every entry (`nbrowser_activity` returns
`caller`, filterable with `callerThread`).
2. **Forward it** on every ab callback made while carrying out that thread's verb, plus
`X-Adom-Caller-Delegate: native-browser`, so ab shows `chip-fetcher tab 3 (via native-browser)`.
Self-initiated work (timers, sweeps, reapers) sends `native-browser bridge (self)` instead - never a
stale identity.
3. **Arbitrate** - `sessionCaller` records the owning thread; a DIFFERENT thread driving that session
gets `_concurrentAgents` naming the owner (warn, never refuse; destructive cross-thread ops were
already refused).
4. **Label the window** - `desktop_set_window_identity {appId:'Adom.NativeBrowser', displayName:
'Adom · <thread> · <purpose>'}` so a glance at the taskbar says which conversation owns which window.
**Three traps this cost me, all invisible until tested end-to-end:**
- **`args.caller` is RESERVED.** ab/the CLI inject the identity there on EVERY call. I used `args.caller`
as an activity FILTER and silently emptied the trail (8 logged, 0 returned). Use your own arg name.
- **AsyncLocalStorage does NOT survive the socket reply path.** Verbs resolve through the extension's
`pending` map, outside the HTTP request's async scope, so ALS is empty in `onHostFrame` - every badge
paint and window-identity call from there would forward "self". Capture the caller INTO the pending
record and `callerCtx.run(...)` around the reply handler.
- **A var set in `dispatch` is not visible in `onHostFrame`.** They are different functions; carry
cross-cutting state (the arbitration warning) on the pending record too.
- **And the standing one:** widening what you LOG means nothing if the query PROJECTION drops the field.
I have now hit that exact bug three times (`hwnd`/`ops`, then `caller`). Update the projection in the
same commit as the log field.
## ⭐ bridge.json `verbs[]` is what ab can SEE, and it silently rots (John, 2026-08-08)
`bridge/bridge.json` is the manifest ab reads to learn our surface. A verb that is implemented in
`VERB_META` but missing from `verbs[]` is **invisible to every caller** even though the code is right
there and works. Nothing errors. Nothing warns. It just does not exist as far as ab is concerned.
Because the manifest was hand-maintained, **12 verbs drifted out of it**, including
`nbrowser_events` (the ONLY channel by which spawned-window and dialog events reach the AI),
`nbrowser_autologin`, `nbrowser_login_form`, `nbrowser_hover`, `nbrowser_download` and
`nbrowser_caption`. They had been shipping unusable for multiple releases. John caught it as
"update your manifest for ab to read all your latest settings", not as a bug report, because from
the outside it looks like the feature was never built.
THE RULE: never hand-edit `verbs[]`. `VERB_META` in `bridge/server.js` is the single source of
truth; regenerate the manifest from it, order included:
```bash
node -e '...read VERB_META keys, write bridge.json verbs[]...' # see the publish skill
node tools/check-guard-coverage.mjs # now FAILS on any drift
```
`tools/check-guard-coverage.mjs` gained a MANIFEST PARITY gate that fails the build on an
undeclared verb, a dead declared verb, an out-of-order list, or a `bridge.json` version that does
not match `BRIDGE_VERSION`. Adding a verb is now: write `VERB_META` → classify it in
`verbs-policy.json` → regenerate `verbs[]` → the gate goes green. Skipping the regenerate is the
failure mode, so let the gate catch it rather than trusting a checklist.
The generalisable lesson: **any file that duplicates a list the code already owns will drift.**
Either generate it or gate it. Preferably both.
## ⭐ Naming: nb is layer 1, nbe is layer 2 (John, 2026-08-08, definitions v1.9.0)
Locked, and it answers the extension display-name question:
- **nb, Adom Native Browser** is the PARENT and the layer-1 product: extension-free OS-level control.
- **nbe, Adom Native Browser Extension** is layer 2, the installed extension. **`abe` is dead.**
- **ab** = Bridge, **ah** = Hydrogen. Single-letter shorthands are gone; every shorthand is 2+ chars,
and a would-be single letter takes the Adom `a` prefix (w→aw, h→ah, b→ab). OS variants: `ab win`,
`ah mac`, etc.
- CLI is **`adom-bridge-cli`** in the container; `adom-bridge` is the host-OS app launcher. The
`adom-desktop` binary is GONE, not aliased.
- pup's verbs are **`pup_*`**, renamed from `browser_*`, no aliases.
The extension's `manifest.json` name is **"Adom Native Browser Extension"** and `nbrowser_status`
reports `ext:"nbe"` (was `"adom-browser-extension"`). Per the hard-cutover policy we broke both
loudly rather than aliasing: a caller matching the old string SHOULD fail so it gets found and fixed.
WATCH OUT when sweeping names: a blanket "Adom Desktop"→"Bridge" rewrite corrupts **real
filesystem paths**. ab's data dir is `%LOCALAPPDATA%\Adom Bridge\bridges-cache\` (productName
"Adom Bridge"), NOT `\Bridge\`, and its recordings moved to `%TEMP%\adom-bridge-recordings`. The
`.ps1` installers hardcode that cache path, so they silently install into a directory ab no longer
reads. Verify every path literal against the ab source, and keep binaries (`.gif` too, not just
`.png`) out of any bulk text rewrite.