← Commit history
.gitignoreadded+7
@@ -0,0 +1,7 @@+node_modules/+dist/+*.crx+*.pem+*.zip+.DS_Store+host-manifest.json
AD-REQUESTS.mdadded+41
@@ -0,0 +1,41 @@+# adom-desktop features the native-browser bridge needs++Running tracker of AD fixes/features surfaced while building this bridge. Status as of 2026-06-19.++## Shipped+- **1.8.148** — WGC per-window recorder (`desktop_record_window_*`); foreground-steal fix;+  spawn-storm backoff; `send_files` honors `dest`; `pull_file` path resolution.+- **1.8.149** — control-API discovery contract (`ADOM_DIRECT_API_URL` env injected into spawned+  bridges; `~/.adom/direct-api-port` = `host:port`); `bridge_install` cache-lock retry.+- Containment **Phase 1** green-lit (kill-switch + `--safe-mode` + crash-loop guard + headroom gate).++## Open — first-class verbs to kill `shell_execute` inline-quoting friction+The #1 time-sink integrating this bridge is shell-quoting through JSON→bash→cmd→PowerShell.+Each verb below replaces a class of quoting pain hit repeatedly this session:+1. **`run_script`** {interpreter: powershell|cmd|node|python, body, args?, cwd?} → {stdout,stderr,exitCode}.+   AD writes `body` to a temp file + runs it. THE big one — I currently `send_files` a `.ps1` then+   `shell_execute -File` it as a workaround, every time.+2. **`read_file` / `write_file`** (text or base64, absolute path, resolved in AD's own context —+   fixes intermittent `%USERPROFILE%` non-expansion in `shell_execute`).+3. **`registry_get/set/delete`** (HKCU/HKLM) — replaces `reg query/add/delete` backslash-escaping+   (native-messaging host registration was painful + error-prone).+4. **`process_list`** {nameFilter?, pidFilter?} → [{pid,name,commandLine,parentPid,handles,memory}].+   Replaces `tasklist`/`wmic`/`Get-Process`; `wmic` CommandLine returned empty (privilege). Needed+   the whole incident debug. (Also feeds the Phase-2 watchdog.)+5. **`process_kill`** {pid|name}.+6. **`net_stats`** → {tcpTotal, listening, established, timeWait, ephemeralHeadroom, perPid:{...}}.+   Replaces `netstat`/`Get-NetTCPConnection` counting; identical to what the containment watchdog needs.+7. **`launch_process`** {path, args:[], env?, cwd?, window?} → {pid}. Args as an ARRAY (no quoted-string+   parsing); replaces `Start-Process` arg-array pain.++## Open — bridge lifecycle question+- Does AD keep an on-demand-spawned bridge **alive**, or tear it down when idle with no connected+  client? The `native-browser` bridge must **persist** so the Chrome-launched native host can dial it+  (host.json port). Observed: after an `nbrowser_*` call the bridge process is gone (node=0), which+  may be why the extension's host can't establish a steady connection. Need the contract.++## Bridge-side TODO (mine, not AD)+- Wire `ADOM_DIRECT_API_URL` env discovery (1.8.149 contract) into the bridge's `discoverAdDirectPort`.+- Harden the host singleton: a stale `host.pid` whose PID was **reused** by a non-host process caused+  every new host to exit (false positive). Verify the incumbent PID is actually a node/native-host+  process before deferring; otherwise take over.
README.md+141−2
@@ -1,3 +1,142 @@-# Adom Native Browser (Chrome + Edge)+# adom-browser-extension -Drive your real, logged-in Chrome/Edge — the native-browser counterpart to pup, past every login wall.+**Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the native-browser counterpart to+`pup`.** Same command surface as the puppeteer bridge (`nbrowser_*` mirrors `browser_*`), but every+verb runs inside the user's *actual* signed-in browser, so cookies, SSO, saved logins, shopping+carts, and "I'm not a robot" human-trust signals all just work.++Cloud containers, Hydrogen Desktop, and the AD CLI reach it through the exact same+`relay → adom-desktop → bridge` path they already use for pup.++## Architecture (why it's shaped this way)++An MV3 extension can't host a listening port, and per+[`adom-desktop/skills/PORTS.md`](../adom-desktop/skills/PORTS.md) **no fixed/scan port survives+Windows** (HNS silently reserves contiguous chunks). So the transport is **native messaging**:++```+AD ──HTTP /command (ephemeral, discovery file)──► bridge/server.js+                                                      ▲ raw-TCP loopback (ephemeral)+                                                      │+                              host/native-host.js (Chrome-launched, stdio↔TCP pump)+                                                      ▲ native-messaging framing over stdio+                                                      │+                              extension/  (MV3 SW holds connectNative port)+                                ├─ chrome.debugger/CDP  → screenshot, eval, input, navigate (pup parity)+                                └─ chrome.tabs/downloads/cookies → native-API verbs+```++- **bridge/** — AD-spawned Node server. Binds `127.0.0.1:0` (ephemeral, PORTS.md-compliant), writes a+  discovery file, exposes `/status` + `/command` to AD, and a raw-TCP loopback the host dials.+- **host/** — tiny native-messaging host. Chrome/Edge launch it on+  `connectNative('inc.adom.native_browser')`; it reads the bridge's discovery file and pumps stdio ↔+  the bridge loopback. The extension never touches a TCP port.+- **extension/** — the MV3 extension (one codebase, Chrome + Edge). SW stays alive via the host's+  ~20s keepalive ping (no offscreen document needed).++## Repo layout++| Path | What |+|---|---|+| `extension/` | MV3 extension (manifest, `src/sw.js`, `src/cdp.js`, `src/native.js`, popup) |+| `bridge/`    | AD bridge (`bridge.json` manifest + `server.js` + `package.json`) |+| `host/`      | native-messaging host (`native-host.js`, `.bat`, manifest template) |+| `install/`   | bridges-cache copy + HKCU host registration + force-install policy |+| `skills/`    | repo-local skills (incl. the Windows-first → Kyle-ports-Mac dev flow) |+| `wiki/`      | `page.json` — this repo's own Adom Wiki page metadata |++## What you can do (live)++Driving the user's real browser, verified end-to-end:++- **Navigate & manage** windows/tabs — `open_window`, `navigate`, `switch_tab`, … (native `chrome.*`,+  **no debugger bar**).+- **`isTrusted` input** that React/SPAs accept — `click`, `double_click`, `right_click`, `hover`,+  `type`, `press_key` (via `chrome.debugger`/CDP). **Click by visible text** and the smart finder reads+  the element's `getBoundingClientRect` to compute its xy — DOM-truth, not pixel-guessing.+- **The gliding Adom cursor** — `cursor:true` + `intent:"Clicking the Pay button"` shows a teal arrow ++  pulsing ring that glides to the target while **narrating its intention** (trust-building, mimics HD).+- **Screenshots** — `screenshot {toFile:true}` → pull the PNG (avoids relay base64 timeouts).+- **Authed `fetch_url`** — uses the user's REAL cookies, so it grabs files behind a login.+- **Taskbar status** — `flash`/`taskbar` paint a "come look" flash, a progress bar+  (green/yellow-for-human-gates/red), and an **"Adom is driving" overlay badge** on the real browser's+  taskbar button (modes: `quiet`/`chatty`/`off`). See [`TASKBAR-FEATURE.md`](TASKBAR-FEATURE.md).+- **Recording** — `record_start {method:"wgc"}` (Windows, via adom-desktop — no picker, background) or+  `getdisplaymedia` (cross-platform).++**The `chrome.debugger` "started debugging this browser" bar** appears only while a CDP verb (input /+screenshot / eval) is active — the bridge lazy-attaches and idle-detaches after ~8s. Native verbs never+show it. Full breakdown + the verb-by-verb table: [`skills/driving-the-extension/SKILL.md`](skills/driving-the-extension/SKILL.md).++### ⭐ The use case it's built for+EE CAD sources (SnapEDA/SnapMagic, Ultralibrarian, Mouser, DigiKey) gate downloads behind **login ++captcha** — pup's empty profile hits the wall. The native browser uses the engineer's **real logged-in+session**, so the STEP / KiCad / Altium / Fusion `.lbr` bundles just download. Drive the chip-fetcher+ladder with `nbrowser_*` instead of `browser_*` — same flow, real session, no wall.++### Try the handshake locally (dev)++```bash+# 1. Run the bridge standalone (binds an ephemeral port + writes the discovery file):+node bridge/server.js+#    -> prints: [native-browser] bridge up: AD http :<P>  host tcp :<H>+#    GET http://127.0.0.1:<P>/status  ->  {"ok":true,"bridge":"native-browser",...}++# 2. Load extension/ unpacked in Chrome AND Edge (chrome://extensions → Developer mode → Load unpacked)+#    note the assigned extension ID.++# 3. Register the native host (Windows, per-user, no UAC) — see install/ for the scripted version:+#    host-manifest.json "path" -> absolute path to host/native-host.bat+#    host-manifest.json "allowed_origins" -> ["chrome-extension://<ext-id>/"]+#    HKCU\Software\Google\Chrome\NativeMessagingHosts\inc.adom.native_browser -> manifest path+#    (Edge: ...\Microsoft\Edge\NativeMessagingHosts\...)++# 4. The extension popup should show "connected". Then through AD:+#    adom-desktop nbrowser_status   ->  {ok:true, ext:"adom-browser-extension", browser:"chrome"|"edge"}+```++## Recording — two methods (`nbrowser_record_start {method}`)++The browser can be recorded for demos with **either** of two engines. Pick with `method`; on Windows+it defaults to `wgc` (via adom-desktop), otherwise `getdisplaymedia`.++| | **`getdisplaymedia`** (a.k.a. desktopCapture) | **`wgc`** (Windows Graphics Capture, via adom-desktop 1.8.148+) |+|---|---|---|+| **Platforms** | ✅ macOS, Ubuntu, Windows (cross-platform) | ⛔ Windows only (needs adom-desktop 1.8.148+) |+| **Engine** | Same WebRTC pipeline Google Meet uses | OS-level Windows Graphics Capture → mp4 |+| **FPS** | High (30–60) | High (up to 60) |+| **Picker** | ❌ Chrome forces a one-time "choose what to share" picker per recording — no silent variant exists in any browser | ✅ None — agent picks the window by handle |+| **Background** | ✅ keeps capturing the chosen surface even when occluded… | ✅ true background |+| **Display wake-lock** | ⚠️ holds the display awake while sharing (can't blank the screen) | ✅ none |+| **Output** | WebM (base64 returned) | mp4 (file path) |+| **Where it runs** | the extension (offscreen `MediaRecorder`) | the **bridge → adom-desktop** (`desktop_record_window_*`, standalone — never HD) |++**Rule of thumb:** on Windows (adom-desktop 1.8.148+), use **`wgc`** — it's picker-free and truly background,+ideal for "record while the user does something else." Everywhere else (Mac/Ubuntu, or older AD), use+**`getdisplaymedia`** and accept the one-time share picker. The verbs expose both so the caller/AI can+choose and we can compare them head-to-head.++### The share picker — and how to skip it (agent-driven, zero clicks)++`getDisplayMedia`/`desktopCapture` shows a **forced "choose what to share" picker** — Chrome security+forbids an extension from selecting the source programmatically (anti-spyware). There is **no API+bypass**. To record **without a human click**, use one of:++1. **`wgc` (Windows + HD)** — the agent records a window *by handle*; no picker exists. ✅ best on Win.+2. **`getdisplaymedia` + Chrome launch flag `--auto-select-desktop-capture-source="<window title>"`** —+   when *we* launch Chrome (e.g. HD launches it), the picker **auto-selects** the matching window and+   is skipped. ✅ **cross-platform** (Mac/Ubuntu/Windows) → this is the Mac/Ubuntu answer.++On an **already-running, un-managed Chrome** (not launched by us, no HD) the pick is unavoidable —+that's the manual/ad-hoc fallback only.++> Status: `getdisplaymedia` (with picker) implemented in-extension; non-blocking (`record_start`+> returns immediately, poll `record_status` until `recording`). **Pending:** (a) `wgc` bridge→HD+> delegation; (b) HD launching Chrome with `--auto-select-desktop-capture-source` for click-free+> cross-platform capture; (c) large-recording file-save (base64 return is fine for short clips only).++## Distribution++Three channels (see the plan): self-hosted `.crx` + force-install policy (primary, managed machines),+Chrome Web Store + Edge Add-ons (official public — $5 once on Chrome, free on Edge), load-unpacked+(dev). Verbs stay `nbrowser_*` regardless of store display name ("Adom for Chrome & Edge").
TASKBAR-FEATURE.mdadded+55
@@ -0,0 +1,55 @@+# Taskbar status feature — design (flash + progress + overlay + self-teaching hints)++Goal: when the AI drives the user's real browser, paint glanceable status onto that window's Windows+taskbar button — so the user has an "ongoing task list" cue (pup-style) without watching the screen.+Designed with John, 2026-06-20.++## What renders on the BROWSER (Chrome/Edge) taskbar button+Only these work cross-window (ITaskbarList3 + FlashWindowEx take any hwnd):+- **Flash** (`FlashWindowEx`, FLASHW_TRAY|FLASHW_TIMERNOFG) — orange "come look" until the user focuses+  the window. Self-clears on focus; bridge also stops it on close/shutdown.+- **Progress bar** (`SetProgressState`/`SetProgressValue`) — determinate 0–100% or indeterminate+  marquee, color-coded: green=running, **yellow=paused (human-in-the-loop: captcha/2FA/login)**, red=error.+- **Overlay badge icon** (`SetOverlayIcon`) — small corner glyph. **DEFAULT = the Adom logo whenever a+  session is driving the window ("Adom is driving your browser")**. AI / 3rd-party bridge can override+  with a custom icon; `"none"` clears.++(Thumbnail-toolbar buttons, jump lists, iconic/live-preview only work on the owner's OWN window, so+they'd be AD's button — "AI cockpit", phase 2. A DWM live thumbnail of the browser inside AD is the+juicy phase-2 item.)++## Flash modes (set by USER or AI)+- `quiet` (default) — flash only on "come look" moments: navigate-complete, screenshot taken, error,+  task done. Plus progress/overlay stay live.+- `chatty` — flash on every activity verb (click/type/navigate/eval/screenshot). For anxiety-driven+  users who want to watch everything.+- `off` — no auto-flash; explicit `nbrowser_flash` still works.++## Bridge verb surface (bridge owns the logic; AD owns the OS primitive)+- `nbrowser_flash {sessionId?}` / `nbrowser_flash_stop {sessionId?}`+- `nbrowser_flash_mode {mode:"quiet"|"chatty"|"off"}`+- `nbrowser_taskbar {sessionId?, progress?:{state,value?}, overlay?:{badge,tooltip?}}`+- **hwnd captured at control-start**: on `nbrowser_open_window` the bridge resolves the OS hwnd (via+  AD GetForegroundWindow / desktop_list_windows — the just-opened window is focused) and caches+  `sessionId → hwnd`. All taskbar ops target the cached hwnd → never the wrong window.+- On control-start the bridge sets overlay=Adom by default.++## Self-teaching hints (a fresh AI thread must DISCOVER this — it isn't obvious)+- `nbrowser_open_window` reply: full hint — capability exists, current flashMode, the verbs, when to+  use (done/error/gate), that the USER can prefer chatty/quiet/off, "never leave it strobing".+- First activity per session: one-line reminder.+- `nbrowser_help`: complete reference + best-practice playbook.+- `nbrowser_flash_mode` echoes the new mode + that the user can change it anytime.+- Teach WHY/WHEN (courtesy to the user, tied to their preference), not just HOW.++## Dependency: AD `desktop_taskbar` verb (prompt sent to AD thread)+`desktop_taskbar { hwnd, flash?, progress?:{state,value?}, overlay?:{badge:"adom"(default)|"<named>"|"none", icon?:"<path|base64>", tooltip?} } -> {ok}`+- Lightweight persistent COM (ITaskbarList3 init once), no per-call process spawn.+- Ships the Adom overlay icon; accepts custom icons for 3rd-party bridges.+- Flash is the only channel doable via run_script fallback before this lands; progress/overlay need it.++## Build order+1. AD confirms `desktop_taskbar` shape (+ a foreground-hwnd helper if needed).+2. Bridge: hwnd capture on open_window + the 4 verbs + modes + auto-flash hook + Adom-overlay default+   + the rich hints. Extension: report active-window title if needed for hwnd resolution of adopted windows.+3. Test: open window → correct hwnd flashes; progress green/yellow/red; Adom badge shows while driving.
bridge/BRIDGE_VERSIONadded+1
@@ -0,0 +1 @@+0.0.10\ No newline at end of file
bridge/bridge.jsonadded+85
@@ -0,0 +1,85 @@+{+  "manifest_version": 1,+  "name": "native-browser",+  "displayName": "Adom Native Browser (Chrome + Edge)",+  "version": "0.0.10",+  "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",+  "author": "Adom Inc.",+  "license": "MIT",+  "spawn": {+    "kind": "node",+    "entrypoint": "server.js",+    "port": 0,+    "healthEndpoint": "/status",+    "stopMethod": "kill",+    "killImageName": "node.exe",+    "persistent": true+  },+  "verbPrefixes": [+    "nbrowser_"+  ],+  "verbs": [+    "nbrowser_status",+    "nbrowser_ping",+    "nbrowser_open_window",+    "nbrowser_close_window",+    "nbrowser_switch_window",+    "nbrowser_list_windows",+    "nbrowser_rescan",+    "nbrowser_open_tab",+    "nbrowser_close_tab",+    "nbrowser_switch_tab",+    "nbrowser_list_tabs",+    "nbrowser_navigate",+    "nbrowser_reload",+    "nbrowser_back",+    "nbrowser_forward",+    "nbrowser_wait",+    "nbrowser_screenshot",+    "nbrowser_screenshot_full_res",+    "nbrowser_eval",+    "nbrowser_evaluate",+    "nbrowser_input_dispatch",+    "nbrowser_click",+    "nbrowser_type",+    "nbrowser_press_key",+    "nbrowser_fetch_url",+    "nbrowser_errors",+    "nbrowser_close",+    "nbrowser_record_start",+    "nbrowser_record_stop",+    "nbrowser_record_status",+    "nbrowser_record_list",+    "nbrowser_flash",+    "nbrowser_flash_stop",+    "nbrowser_flash_mode",+    "nbrowser_taskbar"+  ],+  "dependencies": {+    "node": ">=18"+  },+  "category": "Browser Automation",+  "tags": [+    "native-browser",+    "chrome",+    "edge",+    "extension",+    "nbrowser",+    "pup-parity"+  ],+  "docs": "https://wiki.adom.inc/adom/adom-browser-extension",+  "platforms": {+    "windows": {+      "supported": true+    },+    "macos": {+      "supported": false,+      "reason": "Windows-first; Kyle ports to macOS as a fast-follow (NativeMessagingHosts live under ~/Library/Application Support/<browser>/NativeMessagingHosts; bridge/host are OS-agnostic Node)."+    },+    "linux": {+      "supported": false,+      "reason": "Pending; bridge + host are OS-agnostic Node, only the install/registration layer is Windows-specific."+    }+  }+}\ No newline at end of file
bridge/package.jsonadded+14
@@ -0,0 +1,14 @@+{+  "name": "adom-native-browser-bridge",+  "version": "0.0.1",+  "private": true,+  "description": "adom-desktop bridge for the Adom native-browser extension (Chrome + Edge)",+  "main": "server.js",+  "bin": {+    "adom-native-browser-bridge": "server.js"+  },+  "scripts": {+    "start": "node server.js"+  },+  "license": "MIT"+}
bridge/server.jsadded+477
@@ -0,0 +1,477 @@+#!/usr/bin/env node+'use strict';+/*+ * adom-browser-extension :: bridge+ * --------------------------------+ * AD-spawned Node server. Two listeners, BOTH ephemeral (per adom-desktop/skills/PORTS.md — a fixed+ * or scan-range port dies silently on HNS-reserved Windows machines):+ *+ *   - HTTP server  : AD talks /status + /command here (the standard bridge contract). AD allocates+ *                    the port (bridge.json port:0) and passes it via `--port`; we fall back to an OS+ *                    ephemeral when run standalone.+ *   - raw-TCP server (host loopback): the native-messaging host (Chrome-launched) dials this and+ *                    pumps newline-delimited JSON frames to/from the real extension.+ *+ * The host can't be told the port out-of-band, so we write it to a discovery file the host reads:+ *   ~/.adom/bridges/native-browser/host.json  -> { host, hostPort, pid, version }+ *+ * Frame protocol (bridge <-> host, newline-delimited JSON):+ *   down (bridge -> host -> ext): { id, verb, args }+ *   up   (ext -> host -> bridge): { id, ok, payload } | { id, ok:false, error, _hint }+ *   event(ext -> host -> bridge): { event, sessionId, tabId, data }   (buffered in later milestones)+ */++const http = require('http');+const net = require('net');+const fs = require('fs');+const os = require('os');+const path = require('path');++const BRIDGE = 'native-browser';+let VERSION = '0.0.1';+try { VERSION = require('./package.json').version || VERSION; } catch {}++const HOSTFILE = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'host.json');+const DEBUGLOG = path.join(os.homedir(), '.adom', 'bridges', BRIDGE, 'bridge-debug.log');++// ---- persistence hardening -------------------------------------------------+// AD spawns dynamic bridges fire-and-forget and REUSES a still-running one via its health probe+// (confirmed by the AD team: AD never kills dynamic bridges). So we must be a long-lived server and+// never exit on our own. The silent killers to guard:+//   - EPIPE when AD drops our stdout/stderr pipe after spawn (a stray write would otherwise crash us),+//   - any uncaught exception / rejection (log it, don't die),+//   - the event loop somehow emptying (a no-op interval pins it open).+function dlog(m) { try { fs.appendFileSync(DEBUGLOG, `${new Date().toISOString()} ${m}\n`); } catch {} }+process.stdout.on('error', () => {});+process.stderr.on('error', () => {});+process.on('uncaughtException', (e) => dlog(`uncaughtException: ${(e && e.stack) || e}`));+process.on('unhandledRejection', (e) => dlog(`unhandledRejection: ${(e && e.stack) || e}`));+setInterval(() => {}, 60000); // keep-alive: the listeners already pin the loop, this is belt-and-suspenders++// ---- pending AD commands awaiting an extension reply -----------------------+let seq = 0;+const pending = new Map(); // id -> { resolve, timer }+let hostSock = null;       // the single connected native-messaging host+let connectedSince = 0;    // when the extension host connected (for the self-reported status)+let lastVerb = null, lastActivityAt = 0;++// Bridge-self-reported chip status (led/summary/tooltip). AD is a pure renderer — ALL the reasoning+// about what green/yellow/red means lives HERE, since only this bridge knows its own domain. AD just+// shows the color + prints the tooltip on hover; it owns only the offline/gray state (a dead bridge+// can't self-report). See /status.+function statusReport() {+  const now = Date.now();+  if (now < cbTrippedUntil) {+    return {+      led: 'red',+      summary: 'Connection storm',+      tooltip: `Circuit breaker tripped: too many extension-host connections in a short window (runaway loop suspected). Refusing new host connections for ${Math.ceil((cbTrippedUntil - now) / 1000)}s.\nSee ${DEBUGLOG}.`,+    };+  }+  if (hostSock) {+    const since = connectedSince ? `${Math.round((now - connectedSince) / 1000)}s` : 'just now';+    const last = lastVerb ? `\nLast verb: ${lastVerb} (${Math.round((now - lastActivityAt) / 1000)}s ago).` : '';+    return {+      led: 'green',+      summary: 'Extension connected',+      tooltip: `Adom browser extension connected - driving the real Chrome/Edge through this bridge.\nConnected for ${since}.${last}`,+    };+  }+  return {+    led: 'yellow',+    summary: 'No extension',+    tooltip: 'Bridge is running, but no browser extension is connected.\nOpen a window in the Chrome/Edge profile where the Adom extension is loaded (its service worker sleeps when that profile has no windows), or verify it at chrome://extensions.',+  };+}++function timeoutFor(verb) {+  if (/screenshot|record|fetch_url|input_dispatch/.test(verb)) return 120000;+  return 60000;+}++// Rich self-documentation, answered by the BRIDGE so it works even when the extension is disconnected.+const HELP = {+  overview: 'Drive the user\'s REAL logged-in Chrome/Edge. Verbs mirror pup\'s browser_* as nbrowser_*.',+  pitfalls: [+    'The extension lives in ONE browser PROFILE and DIES if that profile has no open windows — if you get "not connected", ask the user to open a window in that profile (or open one yourself once reconnected). Never close your LAST window in that profile.',+    'Multiple Adom Desktops on the relay → pass `--target <name>` (e.g. AdomLapper) or you get ambiguous_target.',+    'Screenshots are large base64 and can TIME OUT over the relay — retry, or prefer AD desktop_screenshot_window for proof shots.',+    'nbrowser_eval runs in the PAGE world: chrome.* APIs are NOT available there (only DOM/window).',+    'ALWAYS clean up windows you open (nbrowser_close_window) — abandoned windows annoy the user.',+    'Poll nbrowser_events to learn if the USER closed your windows/tabs (window_closed {byUser:true}).',+  ],+  bestPractices: [+    'Before driving, nbrowser_status to confirm connected. After a dev_reload, expect a ~3-6s reconnect.',+    'For demos: nbrowser_caption (in-page subtitle/alert) OR AD desktop_caption (fullscreen; use position:bottom — top covers the URL bar + Chrome warning). Bring the window to front with AD desktop_bring_to_front {hwnd,state:maximize} (chrome.windows focus can\'t beat Windows foreground-lock).',+    'Mouse verbs: pass cursor:true to show the gliding Adom cursor, and intent:"Clicking the Pay button" so the cursor LABEL narrates the action (builds user trust).',+    'Recording: method "wgc" (Windows, via adom-desktop 1.8.148+; no picker, no banner, background-capturable, mp4 on disk) is best; "getdisplaymedia" is cross-platform but FORCES a share picker; "tabcapture" = the friendly "Record this tab" toolbar button (no picker, records current tab).',+  ],+  verbs: {+    'open_window/open_tab/close_window/close_tab/switch_window/switch_tab/list_windows/list_tabs/rescan': 'Sessions = a real Chrome window (durable across reloads). tabId = real chrome tab id. close what you open.',+    'navigate/reload/back/forward/wait': 'wait{ms}. navigate/reload await load-complete.',+    'screenshot/screenshot_full_res/eval': 'screenshot resized <=1568 (base64); _full_res unresized. eval runs in page world.',+    'click/double_click/right_click/hover/type/press_key/input_dispatch/cursor': 'isTrusted (CDP). selector uses modal-aware scoring. cursor:true + intent for the narrated Adom cursor. hover reveals tooltips.',+    'fetch_url/download': 'fetch_url uses the user\'s REAL cookies (authed). download via chrome.downloads.',+    'errors/events': 'errors = console/page errors for a tab. events = window/tab closes incl. byUser.',+    'record_start/stop/status/list': 'method: wgc|getdisplaymedia|tabcapture. record_start is non-blocking for getdisplaymedia (poll record_status). base64 return is for SHORT clips only.',+    'caption/focus': 'caption{style:subtitle|alert,position}. focus activates the tab (use AD desktop_bring_to_front to actually raise the window).',+    'flash/flash_stop/flash_mode/taskbar': 'Paint glanceable status on the driven browser window\'s Windows TASKBAR button (hwnd captured at open_window; an "Adom is driving" badge goes on automatically). flash = orange "come look" cue (auto-clears on focus). flash_mode{quiet|chatty|off}: quiet(default)=flash on come-look moments (load/screenshot/error/done); chatty=every action (users who want to watch); off=no auto-flash. nbrowser_taskbar{progress:{state:"normal"|"paused"|"error"|"indeterminate"|"none",value?},overlay:{badge:"adom"|"none"}} = progress bar (green/YELLOW-for-gates/red/marquee) + overlay badge for long tasks. Use paused(yellow)+flash on captcha/2FA/login. Clears on close.',+  },+};++// ---- WGC recording: bridge -> AD (standalone, NO HD dependency) -----------------------------------+// WGC per-window capture can't be done in a browser, so it lives in AD (which is always present with+// this bridge). We delegate to AD's direct API. AD writes the mp4 to a WINDOWS path (so pull_file+// grabs it directly — no WSL distro). NEVER call HD here: AD must run standalone without HD.+// AD 1.8.149 control-API discovery contract: env var first (AD injects it into every spawned bridge —+// no file race, no stale port), then the host:port discovery file, then AD's fixed 47200..47209 range.+function discoverAdBase() {+  if (process.env.ADOM_DIRECT_API_URL) return process.env.ADOM_DIRECT_API_URL.replace(/\/+$/, '');+  if (process.env.ADOM_DIRECT_API_PORT) return `http://127.0.0.1:${process.env.ADOM_DIRECT_API_PORT}`;+  try {+    const addr = fs.readFileSync(path.join(os.homedir(), '.adom', 'direct-api-port'), 'utf8').trim();+    if (addr) return /^\d+$/.test(addr) ? `http://127.0.0.1:${addr}` : `http://${addr}`; // bare port (old) or host:port+  } catch {}+  return 'http://127.0.0.1:47200';+}+function adCommand(app, command, args) {+  return new Promise((resolve) => {+    let u; try { u = new URL('/command', discoverAdBase()); } catch { u = new URL('http://127.0.0.1:47200/command'); }+    const data = Buffer.from(JSON.stringify({ app, command, args: args || {} }));+    const req = http.request({ host: u.hostname, port: u.port || 80, path: u.pathname, method: 'POST', headers: { 'content-type': 'application/json', 'content-length': data.length }, 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 }); });+    });+    req.on('error', (e) => resolve({ status: 0, body: { error: String((e && e.message) || e) } }));+    req.on('timeout', () => { req.destroy(); resolve({ status: 0, body: { error: 'AD direct-api timed out' } }); });+    req.write(data); req.end();+  });+}+// AD's native per-window recorder shipped in adom-desktop 1.8.148 (standalone Rust WGC + Media+// Foundation — no picker, no banner, background-capturable, real .mp4 under %TEMP%\adom-desktop-+// recordings\, pullable with pull_file). We delegate method:"wgc" straight to it.+async function wgcRecord(verb, args) {+  const map = {+    nbrowser_record_start: 'desktop_record_window_start',+    nbrowser_record_stop: 'desktop_record_window_stop',+    nbrowser_record_status: 'desktop_record_window_status',+    nbrowser_record_list: 'desktop_record_window_status',+  };+  const adArgs = { ...args };+  delete adArgs.method; // 'wgc' is the bridge-side selector, not an AD arg+  const r = await adCommand('desktop', map[verb], adArgs);+  let out = r.body;+  // AD wraps results differently across surfaces — unwrap {output:"<json>"} and/or {data:{...}} so+  // recordingId/path/fileExists are reachable no matter which shape the direct API returns.+  if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }+  if (out && out.data && typeof out.data === 'object') out = out.data;+  const unknown = out && out.error && /unknown (verb|command)|not found/i.test(out.error);+  if (unknown) {+    return {+      success: false,+      error: 'AD is too old for native window recording',+      _hint: 'desktop_record_window_* shipped in adom-desktop 1.8.148 — upgrade AD (adom-desktop --version must be >= 1.8.148; older builds mis-route to the puppeteer bridge). Meanwhile use method:"getdisplaymedia" or the "Record this tab" toolbar button.',+    };+  }+  if (r.status >= 200 && r.status < 300 && out && out.success !== false && !out.error) {+    const stopHint = 'mp4 is on the laptop at out.path (under %TEMP%\\adom-desktop-recordings) — fetch it with: adom-desktop pull_file {"filePaths":["<path>"]}';+    const startHint = 'recording started in the background (no picker, no banner; the window may be occluded/off-screen). Poll nbrowser_record_status {method:"wgc",recordingId}; stop with nbrowser_record_stop {method:"wgc",recordingId}.';+    return { success: true, output: JSON.stringify({+      ok: true, method: 'wgc',+      recordingId: out.recordingId, path: out.path, fileExists: out.fileExists,+      ad: out,+      _hint: verb === 'nbrowser_record_stop' ? stopHint : startHint,+    }) };+  }+  // a real AD-side failure (window not found, capture error, …) — surface it, don't pretend it's missing+  return {+    success: false,+    error: (out && out.error) || `WGC ${verb} failed`,+    _hint: 'ensure titleContains/hwnd targets a real window (same logic as desktop_screenshot_window); check desktop_record_window_status captureError.',+    ad: out,+  };+}++// ---- taskbar status (flash now; Adom-overlay + progress when AD ships desktop_taskbar) ----------+// We paint a "come look" cue on the REAL browser window's Windows taskbar button. The hwnd is captured+// at control-start (the just-opened window is foreground) and cached, so flashes never hit the wrong+// window. Flash works today via run_script FlashWindowEx; overlay/progress need AD's desktop_taskbar+// (ITaskbarList3 — can't be driven cleanly from PowerShell). See TASKBAR-FEATURE.md.+let lastHwnd = 0;                  // most-recently driven window+const sessionHwnd = new Map();     // sessionId -> hwnd+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 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+// badge, painted on the real browser window by hwnd. (Replaces the old run_script FlashWindowEx hack.)+function taskbar(hwnd, opts) {+  if (!hwnd) return Promise.resolve();+  return adCommand('desktop', 'desktop_taskbar', { hwnd, ...opts }).catch(() => {});+}+function flashHwnd(hwnd, stop) { taskbar(hwnd, { flash: stop ? 'stop' : 'until_focused' }); }+// Capture the driven window's hwnd by DIFFING the real-Chrome window list across the open (the new+// hwnd is the one we opened). Robust vs Windows foreground-lock — no GetForegroundWindow guessing.+let preOpenHwnds = new Set();+async function chromeHwnds() {+  const r = await adCommand('desktop', 'desktop_list_windows', {});+  let out = r.body;+  if (out && typeof out.output === 'string') { try { out = JSON.parse(out.output); } catch {} }+  const ws = (out && out.data && out.data.windows) || (out && out.windows) || [];+  return new Set(ws.filter((w) => /Chrome_WidgetWin/.test(w.className || '') && !/for Testing/.test(w.title || '')).map((w) => w.hwnd));+}+// auto-flash after a successful verb, per the user's chosen mode+function maybeAutoFlash(verb, payload) {+  if (flashMode === 'off') return;+  const hwnd = (payload && payload.sessionId && sessionHwnd.get(payload.sessionId)) || lastHwnd;+  if (!hwnd) return;+  const fire = flashMode === 'chatty' ? ACTIVITY.has(verb) : COMELOOK.has(verb);+  if (fire) flashHwnd(hwnd, false);+}+const FLASH_HINT = 'This window\'s Windows taskbar can signal the user. flashMode=' ;+function flashHint() {+  return `${FLASH_HINT}${flashMode}. Get the user's attention: nbrowser_flash {sessionId}. The USER may prefer more/less noise — nbrowser_flash_mode {mode:"quiet"|"chatty"|"off"} (quiet=come-look moments only [default]; chatty=every action, for users who want to watch; off=silent). On a human-in-the-loop gate (captcha/2FA/login), nbrowser_flash so they come help. Never leave it strobing — it auto-clears on focus, or nbrowser_flash_stop.`;+}++function dispatch(verb, args) {+  return new Promise((resolve) => {+    if (verb === 'nbrowser_help') {+      return resolve({ success: true, output: JSON.stringify({ ok: true, bridge: BRIDGE, version: VERSION, extConnected: !!hostSock, flashMode, ...HELP }) });+    }+    // --- taskbar flash (bridge-local; targets the cached driven-window hwnd) ---+    if (verb === 'nbrowser_flash_mode') {+      const m = String((args && args.mode) || '').toLowerCase();+      if (!['quiet', 'chatty', 'off'].includes(m)) {+        return resolve({ success: false, error: 'mode must be quiet|chatty|off', _hint: 'quiet=flash the browser taskbar on come-look moments (page load/screenshot/error/done) [default]; chatty=flash on EVERY action (for users who want to watch the AI); off=no auto-flash.' });+      }+      flashMode = m;+      const note = m === 'chatty' ? 'Will flash the browser taskbar on EVERY activity verb. Tell the user they can say "quiet it down" anytime.' : m === 'off' ? 'Auto-flash disabled; nbrowser_flash still works manually.' : 'Will flash only on come-look moments (page load, screenshot, error, done).';+      return resolve({ success: true, output: JSON.stringify({ ok: true, flashMode, _hint: note }) });+    }+    if (verb === 'nbrowser_flash' || verb === 'nbrowser_flash_stop') {+      const stop = verb === 'nbrowser_flash_stop';+      const hwnd = (args && args.sessionId && sessionHwnd.get(args.sessionId)) || lastHwnd;+      if (!hwnd) {+        return resolve({ success: false, error: 'no driven window captured yet', _hint: 'Open/drive a browser window first (nbrowser_open_window) — the bridge captures its taskbar hwnd at that moment. Then nbrowser_flash works.' });+      }+      flashHwnd(hwnd, stop);+      return resolve({ success: true, output: JSON.stringify({ ok: true, hwnd, stopped: stop, _hint: stop ? 'Taskbar flash cleared.' : 'Flashing the browser taskbar ORANGE until the user focuses that window (auto-clears on focus). nbrowser_flash_stop clears it early.' }) });+    }+    if (verb === 'nbrowser_taskbar') {+      const hwnd = (args && args.sessionId && sessionHwnd.get(args.sessionId)) || lastHwnd;+      if (!hwnd) return resolve({ success: false, error: 'no driven window captured yet', _hint: 'Open/drive a browser window first (nbrowser_open_window).' });+      const opts = {};+      if (args && args.progress) opts.progress = args.progress;+      if (args && args.overlay) opts.overlay = args.overlay;+      if (args && args.flash) opts.flash = args.flash;+      taskbar(hwnd, opts).then(() => resolve({ success: true, output: JSON.stringify({ ok: true, hwnd, applied: Object.keys(opts), _hint: 'progress.state: normal(green) | paused(YELLOW — use for captcha/2FA/login human-gates) | error(red) | indeterminate(marquee) | none. overlay.badge: "adom" (the driven-by-Adom badge) | "none". Clear ALL at task-end: {progress:{state:"none"},overlay:{badge:"none"},flash:"stop"}.' }) }));+      return;+    }+    // WGC recording is handled HERE (bridge -> AD direct API, standalone — never HD), not in the+    // extension (a browser can't capture another window). method:"wgc".+    if (verb.startsWith('nbrowser_record_') && args && args.method === 'wgc') {+      return wgcRecord(verb, args).then(resolve);+    }+    if (!hostSock) {+      return resolve({+        success: false,+        error: 'native browser extension not connected',+        _hint: 'The extension\'s browser PROFILE likely has NO OPEN WINDOWS (closing them all kills its service worker), or the browser/extension isn\'t running. Ask the user to open a window in that profile (where the extension was loaded), then retry. Run nbrowser_help for more.',+      });+    }+    lastVerb = verb; lastActivityAt = Date.now(); // for the self-reported status tooltip+    const forward = () => {+      const id = ++seq;+      const timer = setTimeout(() => {+        pending.delete(id);+        resolve({ success: false, error: `verb ${verb} timed out`, _hint: 'extension did not reply in time' });+      }, timeoutFor(verb));+      pending.set(id, { resolve, timer, verb, toFile: !!(args && args.toFile) });+      hostSock.write(JSON.stringify({ id, verb, args: args || {} }) + '\n');+    };+    // 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);+    else forward();+  });+}++function onHostFrame(frame) {+  if (frame && frame.id != null && pending.has(frame.id)) {+    const p = pending.get(frame.id);+    clearTimeout(p.timer);+    pending.delete(frame.id);+    if (frame.ok) {+      let payload = frame.payload || {};+      // Screenshot file-save: huge base64 over the relay times out, so when the caller passes+      // toFile:true we write the PNG to a laptop temp file here and return its path instead of the+      // base64. Pull it with `adom-desktop pull_file` (binary chunked) — fast + no relay timeout.+      const isShot = /screenshot/.test(p.verb);+      const isRec = p.verb === 'nbrowser_record_stop';+      if (payload.base64 && ((isShot && p.toFile) || isRec)) {+        try {+          const sub = isRec ? 'adom-nbrowser-recordings' : 'adom-nbrowser-shots';+          const dir = path.join(process.env.TEMP || os.tmpdir(), sub);+          fs.mkdirSync(dir, { recursive: true });+          const file = path.join(dir, `${isRec ? 'rec' : 'shot'}-${Date.now()}.${isRec ? 'webm' : 'png'}`);+          fs.writeFileSync(file, Buffer.from(payload.base64, 'base64'));+          const sizeKB = Math.round(Buffer.byteLength(payload.base64, 'base64') / 1024);+          payload = { ...payload, path: file, savedToFile: true, sizeKB, _hint: 'Pull this with `adom-desktop pull_file {filePaths:["' + file.replace(/\\/g, '\\\\') + '"]}` (binary, chunked, no relay timeout).' };+          delete payload.base64;+        } catch (e) { payload._fileError = String((e && e.message) || e); }+      }+      // taskbar: capture the driven window's hwnd at control-start, auto-flash per mode, and TEACH the+      // feature on open (a fresh AI thread must discover it — it isn't obvious).+      if (p.verb === 'nbrowser_open_window') {+        const sid = payload.sessionId;+        chromeHwnds().then((after) => {+          const neu = [...after].find((h) => !preOpenHwnds.has(h)); // the window that appeared = ours+          if (neu) {+            lastHwnd = neu;+            if (sid) sessionHwnd.set(sid, neu);+            // the "Adom is driving your browser" overlay badge — on for the window's whole driven life+            taskbar(neu, { overlay: { badge: 'adom', tooltip: 'Adom is driving this browser' } });+          }+        }).catch(() => {});+        payload = { ...payload, _taskbarHint: flashHint() };+      }+      if (p.verb === 'nbrowser_close_window') { // clean up the taskbar when we stop driving+        const sid = payload.sessionId;+        const h = (sid && sessionHwnd.get(sid)) || lastHwnd;+        if (h) { taskbar(h, { progress: { state: 'none' }, overlay: { badge: 'none' }, flash: 'stop' }); if (sid) sessionHwnd.delete(sid); }+      }+      maybeAutoFlash(p.verb, payload);+      resolve_ok(p.resolve, payload);+    } else p.resolve({ success: false, error: frame.error || 'unknown error', _hint: frame._hint });+    return;+  }+  // Unsolicited events are surfaced to the AI via nbrowser_events (the extension buffers them).+}+function resolve_ok(resolve, payload) { resolve({ success: true, output: JSON.stringify(payload) }); }++// ---- HTTP server for AD ----------------------------------------------------+const httpSrv = http.createServer((req, res) => {+  const reply = (code, obj) => {+    const body = Buffer.from(JSON.stringify(obj));+    res.writeHead(code, { 'content-type': 'application/json', 'content-length': body.length });+    res.end(body);+  };++  if (req.method === 'GET' && req.url === '/status') {+    return reply(200, { ok: true, bridge: BRIDGE, version: VERSION, extConnected: !!hostSock, ...statusReport() });+  }++  if (req.method === 'POST' && req.url === '/command') {+    let body = '';+    req.on('data', (c) => { body += c; if (body.length > 8 * 1024 * 1024) req.destroy(); });+    req.on('end', async () => {+      let parsed;+      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));+    });+    return;+  }++  reply(404, { success: false, error: 'not found' });+});++// ---- circuit breaker -------------------------------------------------------+// Backstop: if hosts connect in a tight storm (the runaway-accumulation signature), STOP feeding the+// loop — log loudly and refuse new host connections for a cooldown. Even if the host/SW backoff ever+// regressed, the bridge alone caps the damage. Healthy operation is ~1 connect per host lifetime, so a+// burst of 20 connects in 10s is unambiguously pathological.+const CB_WINDOW_MS = 10000;+const CB_MAX_CONNECTS = 20;+const CB_COOLDOWN_MS = 60000;+let cbConnects = [];+let cbTrippedUntil = 0;++// ---- raw-TCP server for the native-messaging host --------------------------+const hostSrv = net.createServer((sock) => {+  const now = Date.now();+  if (now < cbTrippedUntil) { try { sock.destroy(); } catch {} return; } // breaker open — refuse+  cbConnects = cbConnects.filter((t) => now - t < CB_WINDOW_MS);+  cbConnects.push(now);+  if (cbConnects.length > CB_MAX_CONNECTS) {+    cbTrippedUntil = now + CB_COOLDOWN_MS;+    cbConnects = [];+    process.stderr.write(`[native-browser] CIRCUIT BREAKER TRIPPED: >${CB_MAX_CONNECTS} host connects in ${CB_WINDOW_MS}ms — refusing new host connections for ${CB_COOLDOWN_MS}ms (runaway native-host loop suspected)\n`);+    try { sock.destroy(); } catch {}+    return;+  }+  if (hostSock) { try { hostSock.destroy(); } catch {} } // newest host wins+  hostSock = sock;+  connectedSince = Date.now();+  let buf = '';+  sock.on('data', (chunk) => {+    buf += chunk.toString('utf8');+    let nl;+    while ((nl = buf.indexOf('\n')) >= 0) {+      const line = buf.slice(0, nl);+      buf = buf.slice(nl + 1);+      if (!line.trim()) continue;+      try { onHostFrame(JSON.parse(line)); } catch {}+    }+  });+  const drop = () => { if (hostSock === sock) { hostSock = null; connectedSince = 0; } };+  sock.on('close', drop);+  sock.on('error', drop);+});++// ---- discovery file + lifecycle -------------------------------------------+function writeHostFile(hostPort) {+  fs.mkdirSync(path.dirname(HOSTFILE), { recursive: true });+  fs.writeFileSync(HOSTFILE, JSON.stringify({ host: '127.0.0.1', hostPort, pid: process.pid, version: VERSION }));+}+// PID-aware cleanup: ONLY delete host.json if it still points at *this* process. Otherwise a churning+// instance (AD respawn, a manual run, a crash-restart) deletes the discovery file that another LIVE+// bridge owns — exactly what stranded the extension (host.json vanished while a bridge was up). Never+// blow away a sibling's file.+function cleanup() {+  try {+    const cur = JSON.parse(fs.readFileSync(HOSTFILE, 'utf8'));+    if (cur && cur.pid === process.pid) fs.unlinkSync(HOSTFILE);+  } catch {}+}+process.on('exit', cleanup);+process.on('SIGINT', () => { cleanup(); process.exit(0); });+process.on('SIGTERM', () => { cleanup(); process.exit(0); });++function argPort() {+  const i = process.argv.indexOf('--port');+  if (i >= 0) { const p = parseInt(process.argv[i + 1], 10); if (!Number.isNaN(p)) return p; }+  return 0; // ephemeral+}++// Resilient bind. If AD hands us a --port that's already held (HNS reservation, a zombie bridge, a+// double-spawn), DON'T die silently — fall back to an ephemeral port and keep serving. The hostSrv ++// discovery file get written regardless, so the extension always has a live port to dial.+let httpFellBack = false;+function startHttp(p) {+  httpSrv.once('error', (e) => {+    if (e && e.code === 'EADDRINUSE' && !httpFellBack) {+      httpFellBack = true;+      process.stderr.write(`[native-browser] http :${p} in use (${e.code}) — falling back to ephemeral\n`);+      try { httpSrv.close(); } catch {}+      setTimeout(() => startHttp(0), 50);+    } else {+      process.stderr.write(`[native-browser] http listen error: ${e && e.code}\n`);+    }+  });+  httpSrv.listen(p, '127.0.0.1', () => {+    const port = httpSrv.address().port;+    if (hostSrv.listening) { // already came up on a prior attempt; just log the new http port+      process.stdout.write(`[native-browser] bridge up: AD http :${port}  host tcp :${hostSrv.address().port}\n`);+      return;+    }+    hostSrv.once('error', (e) => process.stderr.write(`[native-browser] host listen error: ${e && e.code}\n`));+    hostSrv.listen(0, '127.0.0.1', () => {+      const hostPort = hostSrv.address().port;+      writeHostFile(hostPort);+      process.stdout.write(`[native-browser] bridge up: AD http :${port}  host tcp :${hostPort}\n`);+    });+  });+}+startHttp(argPort());
extension/icons/README.mdadded+8
@@ -0,0 +1,8 @@+# icons++Drop the Adom brand icons here (`icon16.png`, `icon48.png`, `icon128.png`) from the gallia `brand`+skill (`gallia/brand/logos/`), then re-add the `"icons"` and `action.default_icon` keys to+`extension/manifest.json`.++Left out of the manifest in Milestone 1 so the extension loads unpacked without binary assets.+Per the brand rules, do NOT regenerate the approved PNGs from SVG — copy the signed-off files.
extension/icons/icon128.pngadded
⋯ 1 unchanged line ⋯
extension/icons/icon16.pngadded
⋯ 1 unchanged line ⋯
extension/icons/icon48.pngadded
⋯ 1 unchanged line ⋯
extension/manifest.jsonadded+44
@@ -0,0 +1,44 @@+{+  "manifest_version": 3,+  "name": "Adom for Chrome & Edge",+  "short_name": "Adom",+  "version": "0.0.2",+  "description": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem — the native-browser counterpart to pup.",+  "minimum_chrome_version": "116",+  "background": {+    "service_worker": "src/sw.js",+    "type": "module"+  },+  "action": {+    "default_popup": "src/popup.html",+    "default_title": "Adom",+    "default_icon": {+      "16": "icons/icon16.png",+      "48": "icons/icon48.png",+      "128": "icons/icon128.png"+    }+  },+  "permissions": [+    "nativeMessaging",+    "debugger",+    "tabs",+    "scripting",+    "downloads",+    "cookies",+    "webNavigation",+    "storage",+    "alarms",+    "tabCapture",+    "desktopCapture",+    "offscreen"+  ],+  "host_permissions": [+    "<all_urls>"+  ],+  "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAr75abHbp7QsA45nIRV1y4GoxUlT+5nba4PNPL15awcl5kz3EC1bvApBUknwH1yduMc96vpXgbWJ3luXMM+XOwcEvgs/6EYrfBvm9HsVFc7ll8UWeeeJCEfalB11cZ9L7BPBolYGgH9yD6iDqm7y8m8z9W12Vwd/V5nohO/X/vLUfe3wbjXS9Vi9MV3dF9ORgk7ZF9yTZyQD7MbwEBNLc0J+K+QU4IJPpTfyOmgdnzmszJRunGtHobN5ThfyR+TZ962oW0SLNuPHC71vyywAUD92gncgwDTtA5mblWn5BBEZf+GQednqaNk7Rf7y4w5jsWo4QIgPBa1yLwILsdXTwUQIDAQAB",+  "icons": {+    "16": "icons/icon16.png",+    "48": "icons/icon48.png",+    "128": "icons/icon128.png"+  }+}
extension/src/cdp.jsadded+340
@@ -0,0 +1,340 @@+// adom-browser-extension :: CDP helpers (chrome.debugger)+// --------------------------------------------------------+// Used ONLY for verbs that need DevTools Protocol parity with pup: full-page screenshots and+// arbitrary expression eval. Navigation/tabs/windows go through native chrome.* APIs (native.js) so+// they never raise the "started debugging this browser" banner.+//+// Banner policy: attach lazily on first CDP use of a tab, auto-detach after IDLE_MS of no CDP use, so+// the banner only shows while actively driving. On Adom-managed machines the browser is launched with+// --silent-debugger-extension-api, which removes it entirely.++const PROTO = '1.3';+const IDLE_MS = 8000;+const attached = new Map(); // tabId -> idleTimer++function debuggee(tabId) { return { tabId }; }++function send(tabId, method, params) {+  return new Promise((resolve, reject) => {+    chrome.debugger.sendCommand(debuggee(tabId), method, params || {}, (res) => {+      const err = chrome.runtime.lastError;+      if (err) reject(new Error(err.message)); else resolve(res);+    });+  });+}++function bumpIdle(tabId) {+  const t = attached.get(tabId);+  if (t) clearTimeout(t);+  attached.set(tabId, setTimeout(() => detach(tabId).catch(() => {}), IDLE_MS));+}++async function attach(tabId) {+  if (attached.has(tabId)) { bumpIdle(tabId); return; }+  await new Promise((resolve, reject) => {+    chrome.debugger.attach(debuggee(tabId), PROTO, () => {+      const err = chrome.runtime.lastError;+      if (err && !/already attached/i.test(err.message)) reject(new Error(err.message));+      else resolve();+    });+  });+  attached.set(tabId, null);+  await send(tabId, 'Page.enable').catch(() => {});+  await send(tabId, 'Runtime.enable').catch(() => {});+  await send(tabId, 'Log.enable').catch(() => {});+  bumpIdle(tabId);+}++export async function detach(tabId) {+  const t = attached.get(tabId);+  if (t) clearTimeout(t);+  if (!attached.has(tabId)) return;+  attached.delete(tabId);+  await new Promise((resolve) => chrome.debugger.detach(debuggee(tabId), () => { void chrome.runtime.lastError; resolve(); }));+}++// ---- nbrowser_screenshot / nbrowser_screenshot_full_res --------------------+export async function screenshot(tabId, { fullPage = false, maxWidth = 1568, resize = true } = {}) {+  await attach(tabId);+  const params = { format: 'png', captureBeyondViewport: !!fullPage };+  if (fullPage) {+    const m = await send(tabId, 'Page.getLayoutMetrics');+    const css = m.cssContentSize || m.contentSize;+    params.clip = { x: 0, y: 0, width: Math.ceil(css.width), height: Math.ceil(css.height), scale: 1 };+  }+  const res = await send(tabId, 'Page.captureScreenshot', params);+  bumpIdle(tabId);+  const out = resize ? await resizePng(res.data, maxWidth) : await pngDims(res.data);+  return { ok: true, base64: out.base64, width: out.width, height: out.height,+    origWidth: out.origWidth ?? out.width, origHeight: out.origHeight ?? out.height,+    resized: !!out.resized, sizeKB: Math.round((out.base64.length * 3 / 4) / 1024) };+}++// ---- nbrowser_eval / nbrowser_evaluate ------------------------------------+export async function evaluate(tabId, expr) {+  await attach(tabId);+  const res = await send(tabId, 'Runtime.evaluate', {+    expression: `(${expr})`,+    returnByValue: true,+    awaitPromise: true,+    userGesture: true,+  });+  bumpIdle(tabId);+  if (res.exceptionDetails) {+    const e = res.exceptionDetails;+    return { __error: (e.exception && (e.exception.description || e.exception.value)) || e.text };+  }+  return res.result ? res.result.value : undefined;+}++// ---- input dispatch (CDP Input domain → isTrusted events) ------------------+const KEYMAP = {+  Enter: { key: 'Enter', code: 'Enter', vk: 13 },+  Tab: { key: 'Tab', code: 'Tab', vk: 9 },+  Escape: { key: 'Escape', code: 'Escape', vk: 27 },+  Backspace: { key: 'Backspace', code: 'Backspace', vk: 8 },+  Delete: { key: 'Delete', code: 'Delete', vk: 46 },+  ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', vk: 38 },+  ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', vk: 40 },+  ArrowLeft: { key: 'ArrowLeft', code: 'ArrowLeft', vk: 37 },+  ArrowRight: { key: 'ArrowRight', code: 'ArrowRight', vk: 39 },+  Home: { key: 'Home', code: 'Home', vk: 36 },+  End: { key: 'End', code: 'End', vk: 35 },+};++async function pressKey(tabId, name) {+  const k = KEYMAP[name] || { key: name, code: name, vk: name.length === 1 ? name.toUpperCase().charCodeAt(0) : 0 };+  const base = { key: k.key, code: k.code, windowsVirtualKeyCode: k.vk, nativeVirtualKeyCode: k.vk };+  await send(tabId, 'Input.dispatchKeyEvent', { type: 'keyDown', ...base });+  await send(tabId, 'Input.dispatchKeyEvent', { type: 'keyUp', ...base });+}++async function mouseClick(tabId, x, y, button = 'left', clickCount = 1) {+  await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });+  await send(tabId, 'Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button, clickCount, buttons: button === 'right' ? 2 : 1 });+  await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button, clickCount, buttons: 0 });+}++// Port of pup's modal-aware smart-pick: resolve a selector to the best visible element's center.+function scoreExpr(selector, firstMatch) {+  const sel = JSON.stringify(selector);+  return `(function(){+    var els=[].slice.call(document.querySelectorAll(${sel}));+    if(!els.length)return{found:false,count:0};+    function vis(el){var r=el.getBoundingClientRect(),s=getComputedStyle(el);+      return r.width>0&&r.height>0&&s.visibility!=='hidden'&&s.display!=='none'&&parseFloat(s.opacity||'1')>0.01;}+    var c=els.filter(vis); if(!c.length)c=els;+    var modal=document.querySelector('dialog[open],[aria-modal="true"],[role="dialog"]');+    if(modal){var m=c.filter(function(e){return modal.contains(e);});if(m.length)c=m;}+    var chosen=${firstMatch ? 'c[0]' : 'c.reduce(function(a,b){var ra=a.getBoundingClientRect(),rb=b.getBoundingClientRect();return (rb.width*rb.height)>(ra.width*ra.height)?b:a;})'};+    if(chosen.scrollIntoView)chosen.scrollIntoView({block:'center',inline:'center'});+    var r=chosen.getBoundingClientRect();+    return{found:true,count:c.length,x:Math.round(r.left+r.width/2),y:Math.round(r.top+r.height/2),+      rect:{x:Math.round(r.left),y:Math.round(r.top),w:Math.round(r.width),h:Math.round(r.height)},+      text:((chosen.innerText||chosen.value||'')+'').slice(0,60),modal:!!modal};+  })()`;+}+function focusExpr(selector) {+  const sel = JSON.stringify(selector);+  return `(function(){var e=document.querySelector(${sel});if(!e)return{found:false};if(e.focus)e.focus();if(e.scrollIntoView)e.scrollIntoView({block:'center'});return{found:true};})()`;+}++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++// ---- visible Adom cursor — EXACT match to HD's AI cursor (hd-control AI_CURSOR_JS) --------------+// Teal Adom arrow (#00b8b0) + dark-teal outline + teal glow, a pulsing teal ring, and a monospace+// "Adom" label so the user always knows it's an Adom-driven cursor (trust). Kept byte-faithful to HD+// so the cursor looks identical across the whole product.+function cursorScript(action, x, y, label) {+  var id = '__adom_cursor__';+  var c = document.getElementById(id);+  if (action === 'hide') { if (c) c.style.display = 'none'; return; }+  if (!c) {+    c = document.createElement('div');+    c.id = id;+    c.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;transition:left 0.15s,top 0.15s;display:none;';+    var g = document.createElement('div'); // pulsing teal glow ring+    g.id = '__adom_cursor_ring__';+    g.style.cssText = 'position:absolute;top:-16px;left:-16px;width:40px;height:40px;border-radius:50%;background:rgba(0,184,176,0.15);border:2px solid rgba(0,184,176,0.4);animation:adomCurP 1.5s ease-in-out infinite;';+    c.appendChild(g);+    var a = document.createElement('div'); // the teal Adom arrow (HD's exact SVG)+    a.innerHTML = '<svg width="20" height="28" viewBox="0 0 20 28" xmlns="http://www.w3.org/2000/svg"><path d="M1 1L1 22L6.5 16.5L10.5 26L14 24.5L10 15L17 15Z" fill="#00b8b0" stroke="#004d49" stroke-width="1.2" stroke-linejoin="round"/></svg>';+    a.style.cssText = 'position:absolute;top:0;left:0;filter:drop-shadow(0 0 6px rgba(0,184,176,0.7));';+    c.appendChild(a);+    var l = document.createElement('div'); // the "Adom" trust label+    l.id = '__adom_cursor_label__';+    l.style.cssText = 'position:absolute;top:32px;left:8px;white-space:nowrap;font-size:11px;color:#00b8b0;background:rgba(13,17,23,0.92);padding:2px 10px;border-radius:4px;border:1px solid rgba(0,184,176,0.6);font-family:monospace;';+    c.appendChild(l);+    (document.body || document.documentElement).appendChild(c);+    if (!document.getElementById('__adom_cur_kf__')) {+      var s = document.createElement('style'); s.id = '__adom_cur_kf__';+      s.textContent = '@keyframes adomCurP{0%{transform:scale(1);opacity:0.7}50%{transform:scale(1.5);opacity:0.2}100%{transform:scale(1);opacity:0.7}}@keyframes adomCurClick{0%{transform:scale(1);opacity:0.85}45%{transform:scale(2.2);opacity:0.05}100%{transform:scale(1);opacity:0.7}}';+      document.head.appendChild(s);+    }+  }+  var lab = c.querySelector('#__adom_cursor_label__');+  if (lab) {+    lab.textContent = (label != null ? label : 'Adom');+    // flip the label to the LEFT of the cursor when near the right edge, so it never clips offscreen+    var nearRight = (x != null) && (x > (window.innerWidth - 250));+    if (nearRight) { lab.style.left = 'auto'; lab.style.right = '0px'; lab.style.textAlign = 'right'; }+    else { lab.style.left = '8px'; lab.style.right = 'auto'; lab.style.textAlign = 'left'; }+  }+  if (x != null && y != null) { c.style.left = x + 'px'; c.style.top = y + 'px'; }+  c.style.display = 'block';+  if (action === 'click') {+    var ring = c.querySelector('#__adom_cursor_ring__');+    if (ring) { ring.style.animation = 'none'; void ring.offsetWidth; ring.style.animation = 'adomCurClick 0.45s ease-out, adomCurP 1.5s ease-in-out infinite 0.45s'; }+  }+}+export async function cursor(tabId, action, x, y, label) {+  try { await chrome.scripting.executeScript({ target: { tabId }, func: cursorScript, args: [action, x == null ? null : x, y == null ? null : y, label == null ? null : String(label)] }); } catch (e) {}+}++// HD-style intention narration for the cursor label. phase 'find' = while gliding there; 'act' = doing it.+function phaseLabel(type, tgt, phase) {+  const name = tgt ? `"${tgt}"` : 'this';+  if (type === 'hover') return phase === 'act' ? `Hovering ${name}` : `Moving to ${name}`;+  if (type === 'dblclick' || type === 'doubleclick') return phase === 'act' ? `Double-clicking ${name}` : `Finding ${name}`;+  if (type === 'rightclick' || type === 'contextmenu') return phase === 'act' ? `Right-clicking ${name}` : `Finding ${name}`;+  return phase === 'act' ? `Clicking ${name}` : `Finding ${name}`;+}++// ---- smart finder: locate visible interactive elements (for the screenshot->find->click loop) -----+// Injected via chrome.scripting (no debugger banner). Matches by visible text / aria-label.+function findFn(query, limit) {+  var q = (query || '').toLowerCase();+  var sel = 'a,button,[role=button],[role=link],[role=menuitem],[role=tab],[role=checkbox],input,select,textarea,summary,[onclick],[tabindex]';+  var els = [].slice.call(document.querySelectorAll(sel));+  function vis(el) { var r = el.getBoundingClientRect(), s = getComputedStyle(el); return r.width > 1 && r.height > 1 && s.visibility !== 'hidden' && s.display !== 'none' && parseFloat(s.opacity || '1') > 0.01 && r.bottom > 0 && r.right > 0 && r.top < innerHeight && r.left < innerWidth; }+  function txt(el) { return ((el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('title') || '') + '').replace(/\s+/g, ' ').trim(); }+  var out = [];+  for (var i = 0; i < els.length; i++) {+    var el = els[i]; if (!vis(el)) continue; var t = txt(el); var al = (el.getAttribute('aria-label') || '').toLowerCase();+    if (q && t.toLowerCase().indexOf(q) < 0 && al.indexOf(q) < 0) continue;+    var r = el.getBoundingClientRect();+    out.push({ text: t.slice(0, 80), tag: el.tagName.toLowerCase(), role: el.getAttribute('role') || '', type: el.type || '', x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2), rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } });+  }+  out.sort(function (a, b) { var ae = a.text.toLowerCase() === q ? 0 : 1, be = b.text.toLowerCase() === q ? 0 : 1; if (ae !== be) return ae - be; return (b.rect.w * b.rect.h) - (a.rect.w * a.rect.h); });+  return out.slice(0, limit || 40);+}+export async function find(tabId, query, limit) {+  try {+    const r = await chrome.scripting.executeScript({ target: { tabId }, func: findFn, args: [query || '', limit || 40] });+    const els = (r && r[0] && r[0].result) || [];+    return { ok: true, query: query || '', count: els.length, elements: els };+  } catch (e) { return { ok: false, error: String((e && e.message) || e) }; }+}++async function resolveCoords(tabId, args) {+  if (args.selector) {+    const r = await evaluate(tabId, scoreExpr(args.selector, !!args.firstMatch));+    if (!r || !r.found) return { found: false };+    return { found: true, x: r.x, y: r.y, info: { selector: args.selector, matchedCount: r.count, clickedRect: r.rect, clickedText: r.text, modalDetected: r.modal } };+  }+  if (args.text) { // click/hover BY VISIBLE TEXT — the smart-click path+    const f = await find(tabId, args.text, 10);+    if (!f.ok || !f.elements.length) return { found: false };+    const el = f.elements[0];+    return { found: true, x: el.x, y: el.y, info: { matchedText: el.text, matchedTag: el.tag, via: 'text', candidates: f.count } };+  }+  return { found: true, x: args.x, y: args.y, info: {} };+}++// type ∈ move | hover | click | dblclick | rightclick | key | type+// args.cursor:true shows the gliding visual cursor (demos); off by default for fast automation.+export async function inputDispatch(tabId, args) {+  await attach(tabId);+  const type = (args.type || 'click').toLowerCase();+  const showCursor = args.cursor === true;+  try {+    if (type === 'key') { await pressKey(tabId, args.key); return { ok: true, dispatched: 'key', key: args.key }; }+    if (type === 'type') {+      if (args.selector) {+        const f = await evaluate(tabId, focusExpr(args.selector));+        if (!f || !f.found) return { ok: false, error: `selector not found: ${args.selector}`, _hint: 'check the selector or wait for the field' };+      }+      await send(tabId, 'Input.insertText', { text: String(args.text || '') });+      return { ok: true, dispatched: 'type', length: String(args.text || '').length, via: args.selector ? 'selector' : 'focused' };+    }+    // pointer types: move | hover | click | dblclick | rightclick+    const c = await resolveCoords(tabId, args);+    if (!c.found) return { ok: false, error: `selector not found: ${args.selector}`, _hint: 'element not visible/present; wait or refine the selector' };+    const { x, y, info } = c;+    // The cursor label narrates intention (HD-style). args.intent overrides; else auto from element text.+    const intent = args.intent || args.label;+    const tgt = (info.clickedText || '').trim().slice(0, 40);+    if (showCursor) { await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'find')); await sleep(args.cursorGlideMs || 450); }+    if (type === 'move' || type === 'hover') {+      if (showCursor) await cursor(tabId, 'move', x, y, intent || phaseLabel(type, tgt, 'act'));+      await send(tabId, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }); // triggers :hover / tooltips+      return { ok: true, dispatched: type, x, y, intentLabel: intent || phaseLabel(type, tgt, 'act'), ...info };+    }+    if (showCursor) await cursor(tabId, 'click', x, y, intent || phaseLabel(type, tgt, 'act')); // ripple + label+    if (type === 'dblclick' || type === 'doubleclick') {+      await mouseClick(tabId, x, y, 'left', 1); await mouseClick(tabId, x, y, 'left', 2);+      return { ok: true, dispatched: 'dblclick', x, y, ...info };+    }+    if (type === 'rightclick' || type === 'contextmenu') {+      await mouseClick(tabId, x, y, 'right', 1);+      return { ok: true, dispatched: 'rightclick', x, y, ...info };+    }+    await mouseClick(tabId, x, y, args.button || 'left', args.clickCount || 1);+    return { ok: true, dispatched: 'click', via: args.selector ? 'selector' : 'coords', x, y, ...info };+  } finally {+    bumpIdle(tabId);+  }+}++// ---- nbrowser_errors: buffered console/page errors per tab ------------------+const errorBuffers = new Map(); // tabId -> [{type,text,ts}]+chrome.debugger.onEvent.addListener((source, method, params) => {+  const tabId = source.tabId;+  if (tabId == null) return;+  let entry = null;+  if (method === 'Runtime.exceptionThrown') {+    const e = params.exceptionDetails || {};+    entry = { type: 'exception', text: (e.exception && (e.exception.description || e.exception.value)) || e.text, ts: Date.now() };+  } else if (method === 'Runtime.consoleAPICalled' && params.type === 'error') {+    entry = { type: 'console.error', text: (params.args || []).map((a) => a.value ?? a.description ?? '').join(' '), ts: Date.now() };+  } else if (method === 'Log.entryAdded' && params.entry && params.entry.level === 'error') {+    entry = { type: 'log', text: params.entry.text, ts: Date.now() };+  }+  if (!entry) return;+  const buf = errorBuffers.get(tabId) || [];+  buf.push(entry);+  errorBuffers.set(tabId, buf.slice(-200));+});++export async function getErrors(tabId, clear = true) {+  const buf = errorBuffers.get(tabId) || [];+  if (clear) errorBuffers.delete(tabId);+  return { ok: true, errors: buf, count: buf.length, tabId: String(tabId) };+}++// ---- PNG helpers (OffscreenCanvas, runs in the service worker) -------------+async function bitmapFromB64(b64) {+  const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));+  return createImageBitmap(new Blob([bytes], { type: 'image/png' }));+}+async function pngDims(b64) {+  const bmp = await bitmapFromB64(b64);+  const out = { base64: b64, width: bmp.width, height: bmp.height };+  bmp.close && bmp.close();+  return out;+}+async function resizePng(b64, maxWidth) {+  const bmp = await bitmapFromB64(b64);+  const { width, height } = bmp;+  if (width <= maxWidth) { bmp.close && bmp.close(); return { base64: b64, width, height, resized: false }; }+  const w = maxWidth;+  const h = Math.round(height * (maxWidth / width));+  const canvas = new OffscreenCanvas(w, h);+  canvas.getContext('2d').drawImage(bmp, 0, 0, w, h);+  bmp.close && bmp.close();+  const blob = await canvas.convertToBlob({ type: 'image/png' });+  const buf = new Uint8Array(await blob.arrayBuffer());+  let bin = '';+  for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);+  return { base64: btoa(bin), width: w, height: h, origWidth: width, origHeight: height, resized: true };+}
extension/src/native.jsadded+331
@@ -0,0 +1,331 @@+// adom-browser-extension :: native-API verbs (chrome.tabs / chrome.windows / downloads / fetch)+// --------------------------------------------------------------------------------------------+// No debugger attach → no banner. A "session" maps to a real Chrome window; tabId in our API is the+// real chrome tab id (stringified). The session→window map is PERSISTED to chrome.storage.local so it+// survives service-worker restarts (dev_reload) and browser restarts — pruned against live windows.++const sessionWindows = new Map(); // sessionId label -> chrome windowId+let seq = 0;+const newSessionId = () => 's' + (++seq);++const SKEY = 'nb_sessions';+let loaded = false;++async function persist() {+  try { await chrome.storage.local.set({ [SKEY]: { seq, sessions: [...sessionWindows] } }); } catch {}+}+function windowExists(winId) {+  return new Promise((r) => chrome.windows.get(winId, () => r(!chrome.runtime.lastError)));+}+async function ensureLoaded() {+  if (loaded) return;+  loaded = true;+  try {+    const got = await chrome.storage.local.get(SKEY);+    const obj = got[SKEY];+    if (obj) {+      seq = obj.seq || 0;+      for (const [sid, winId] of obj.sessions || []) sessionWindows.set(sid, winId);+      for (const [sid, winId] of [...sessionWindows]) if (!(await windowExists(winId))) sessionWindows.delete(sid);+    }+  } catch {}+}++function tabView(t) {+  return { tabId: String(t.id), url: t.url, title: t.title, active: !!t.active, windowId: t.windowId };+}++// ---- user-action detection: window/tab closes + hints ----------------------+// So the AI is TOLD when its windows are closed (esp. by the user) instead of only finding out when a+// later verb mysteriously fails. We distinguish user-closed from agent-closed.+const _events = [];                 // recent lifecycle events for nbrowser_events+const _agentClosing = new Set();    // windowIds the AGENT is closing (so we don't mislabel as user-closed)+const _closedSessions = new Map();  // sessionId -> {windowId, byUser, ts}+function _emit(e) { e.ts = Date.now(); _events.push(e); if (_events.length > 300) _events.shift(); }+export function drainEvents() { const e = _events.splice(0); return { ok: true, events: e, count: e.length }; }+export function recentClose(sessionId) { return _closedSessions.get(sessionId) || null; }++chrome.windows.onRemoved.addListener(async (windowId) => {+  await ensureLoaded();+  const byUser = !_agentClosing.has(windowId);+  _agentClosing.delete(windowId);+  for (const [sid, wid] of [...sessionWindows]) {+    if (wid === windowId) {+      sessionWindows.delete(sid);+      _closedSessions.set(sid, { windowId, byUser, ts: Date.now() });+      _emit({ event: 'window_closed', sessionId: sid, windowId, byUser });+    }+  }+  await persist();+});+chrome.tabs.onRemoved.addListener((tabId, info) => {+  _emit({ event: 'tab_closed', tabId: String(tabId), windowId: info.windowId, windowClosing: !!info.isWindowClosing });+});++// Resolve which tab a verb targets: explicit tabId > active tab of the session > active tab overall.+async function resolveTabId(args) {+  await ensureLoaded();+  if (args && args.tabId != null) return Number(args.tabId);+  if (args && args.sessionId != null && !sessionWindows.has(args.sessionId)) {+    const c = _closedSessions.get(args.sessionId);+    if (c) throw new Error(`session '${args.sessionId}' was closed ${c.byUser ? 'BY THE USER' : 'by the agent'} — open a new window with nbrowser_open_window`);+  }+  let windowId;+  if (args && args.sessionId != null && sessionWindows.has(args.sessionId)) windowId = sessionWindows.get(args.sessionId);+  const q = windowId != null ? { active: true, windowId } : { active: true, lastFocusedWindow: true };+  const [tab] = await chrome.tabs.query(q);+  if (!tab) throw new Error('no target tab found');+  return tab.id;+}++function resolveWindowId(args) {+  if (args && args.sessionId != null && sessionWindows.has(args.sessionId)) return sessionWindows.get(args.sessionId);+  return null;+}++function waitForLoad(tabId, timeoutMs = 30000) {+  return new Promise((resolve) => {+    let done = false;+    const finish = () => { if (done) return; done = true; chrome.tabs.onUpdated.removeListener(onUpd); resolve(); };+    const onUpd = (id, info) => { if (id === tabId && info.status === 'complete') finish(); };+    chrome.tabs.onUpdated.addListener(onUpd);+    chrome.tabs.get(tabId, (t) => { if (!chrome.runtime.lastError && t && t.status === 'complete') finish(); });+    setTimeout(finish, timeoutMs);+  });+}++// ---- windows / sessions ----------------------------------------------------+export async function openWindow(args = {}) {+  await ensureLoaded();+  const win = await chrome.windows.create({ url: args.url, focused: true });+  const sid = args.sessionId || newSessionId();+  sessionWindows.set(sid, win.id);+  await persist();+  const tab = (win.tabs && win.tabs[0]) || {};+  return {+    ok: true, sessionId: sid, tabId: String(tab.id), windowId: win.id, url: tab.url || args.url,+    _hint: 'New window opened (but NOT raised above other apps — chrome.windows focus can\'t beat the Windows foreground-lock). To truly foreground it, call Adom Desktop desktop_bring_to_front {"titleContains":"<the page title>","state":"maximize"}. Remember to nbrowser_close_window {sessionId:"' + sid + '"} when done so you don\'t leave abandoned windows.',+  };+}++export async function closeWindow(args = {}) {+  await ensureLoaded();+  const winId = resolveWindowId(args);+  if (winId == null) {+    const c = _closedSessions.get(args.sessionId);+    throw new Error(c ? `session '${args.sessionId}' was already closed ${c.byUser ? 'by the USER' : 'by the agent'}` : `unknown sessionId '${args.sessionId}'`);+  }+  _agentClosing.add(winId); // mark so onRemoved labels this agent-closed (byUser:false), not user-closed+  await chrome.windows.remove(winId);+  // NOTE: session deletion + the window_closed event are handled in the onRemoved listener (single+  // source of truth), so agent- and user-closes are recorded identically (just different byUser).+  return { ok: true, closed: true, sessionId: args.sessionId };+}++export async function switchWindow(args = {}) {+  await ensureLoaded();+  const winId = resolveWindowId(args);+  if (winId == null) throw new Error('unknown sessionId');+  await chrome.windows.update(winId, { focused: true });+  return { ok: true };+}++export async function listWindows() {+  await ensureLoaded();+  const wins = await chrome.windows.getAll({ populate: true });+  const tracked = new Map([...sessionWindows].map(([k, v]) => [v, k]));+  const sessions = wins.map((w) => ({+    id: tracked.get(w.id) || String(w.id),+    windowId: w.id,+    tracked: tracked.has(w.id),+    tabCount: (w.tabs || []).length,+    active: !!w.focused,+    url: (w.tabs || []).find((t) => t.active)?.url,+  }));+  return { ok: true, sessions, count: sessions.length };+}++// reconcile the session map against live windows (drop closed ones)+export async function rescan() {+  await ensureLoaded();+  for (const [sid, winId] of [...sessionWindows]) if (!(await windowExists(winId))) sessionWindows.delete(sid);+  await persist();+  return listWindows();+}++// ---- tabs ------------------------------------------------------------------+export async function openTab(args = {}) {+  await ensureLoaded();+  const windowId = resolveWindowId(args);+  const tab = await chrome.tabs.create(windowId != null ? { windowId, url: args.url } : { url: args.url });+  return { ok: true, sessionId: args.sessionId, tabId: String(tab.id), url: tab.url, title: tab.title };+}++export async function closeTab(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.tabs.remove(tabId);+  return { ok: true, tabId: String(tabId) };+}++export async function switchTab(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.tabs.update(tabId, { active: true });+  return { ok: true, tabId: String(tabId) };+}++export async function listTabs(args = {}) {+  await ensureLoaded();+  const windowId = resolveWindowId(args);+  const tabs = await chrome.tabs.query(windowId != null ? { windowId } : {});+  const active = tabs.find((t) => t.active);+  return { ok: true, count: tabs.length, activeTabId: active ? String(active.id) : null, tabs: tabs.map(tabView) };+}++// ---- navigation ------------------------------------------------------------+export async function navigate(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.tabs.update(tabId, { url: args.url });+  await waitForLoad(tabId);+  const t = await chrome.tabs.get(tabId);+  return { ok: true, url: t.url, tabId: String(tabId), sessionId: args.sessionId };+}++export async function reload(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.tabs.reload(tabId);+  await waitForLoad(tabId);+  return { ok: true, tabId: String(tabId), sessionId: args.sessionId };+}++export async function back(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.tabs.goBack(tabId);+  await waitForLoad(tabId);+  return { ok: true, tabId: String(tabId), sessionId: args.sessionId };+}++export async function forward(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.tabs.goForward(tabId);+  await waitForLoad(tabId);+  return { ok: true, tabId: String(tabId), sessionId: args.sessionId };+}++export async function wait(args = {}) {+  const ms = Math.min(Number(args.ms) || 3000, 120000);+  await new Promise((r) => setTimeout(r, ms));+  return { ok: true, waited: ms };+}++// ---- M4: authed fetch (uses the user's real cookies) + downloads -----------+export async function fetchUrl(args = {}) {+  if (!args.url) throw new Error('url required');+  const res = await fetch(args.url, {+    method: args.method || 'GET',+    credentials: 'include', // <-- the native advantage: the user's real session cookies+    headers: args.headers || {},+    body: args.body,+  });+  const ct = res.headers.get('content-type') || '';+  const isText = /text|json|xml|javascript|html|svg|csv|urlencoded/.test(ct) || !ct;+  let body, encoding;+  if (isText) { body = await res.text(); encoding = 'utf8'; }+  else {+    const buf = new Uint8Array(await res.arrayBuffer());+    let bin = ''; for (let i = 0; i < buf.length; i++) bin += String.fromCharCode(buf[i]);+    body = btoa(bin); encoding = 'base64';+  }+  let downloadId;+  if (args.download || args.desktopSaveTo || args.filename) {+    downloadId = await new Promise((r) => chrome.downloads.download({ url: args.url, filename: args.filename }, (id) => { void chrome.runtime.lastError; r(id); }));+  }+  return { ok: true, url: res.url, status: res.status, contentType: ct, encoding, length: body.length, body, downloadId };+}++export async function download(args = {}) {+  if (!args.url) throw new Error('url required');+  const id = await new Promise((resolve, reject) => {+    chrome.downloads.download({ url: args.url, filename: args.filename, saveAs: !!args.saveAs }, (downloadId) => {+      const e = chrome.runtime.lastError; if (e) reject(new Error(e.message)); else resolve(downloadId);+    });+  });+  return { ok: true, downloadId: id, url: args.url, filename: args.filename };+}++// ---- demo aids: on-page captions (subtitle | alert) + bring-to-front -------+// Injected into the page (self-contained; chrome.scripting serializes via toString()).+// style 'subtitle' = clean bottom narration (HD / web-hydrogen feel);+// style 'alert'    = red PULSING attention banner (AD feel). pulse via Web Animations API+// so page CSP can't block it.+function injectCaption(o) {+  var id = '__adom_caption__';+  var old = document.getElementById(id);+  if (old) { if (old.__anim) try { old.__anim.cancel(); } catch (e) {} old.remove(); }+  if (!o.text) return; // empty text = dismiss+  var alert = o.style === 'alert';+  var pos = o.position || (alert ? 'top' : 'bottom');+  var d = document.createElement('div');+  d.id = id; d.textContent = o.text;+  var s = d.style;+  s.position = 'fixed'; s.left = '50%'; s.zIndex = '2147483647'; s.pointerEvents = 'none';+  s.textAlign = 'center'; s.maxWidth = '92vw'; s.boxSizing = 'border-box'; s.opacity = '0';+  s.fontFamily = 'system-ui,Segoe UI,Arial,sans-serif'; s.transition = 'opacity .3s ease';+  var ty = '-50%';+  if (pos === 'center') { s.top = '50%'; ty = '-50%,-50%'; s.transform = 'translate(-50%,-50%)'; }+  else if (pos === 'top') { s.top = '22px'; s.transform = 'translateX(-50%)'; }+  else { s.bottom = '8%'; s.transform = 'translateX(-50%)'; }+  if (alert) {+    s.color = '#fff'; s.background = 'rgba(176,0,0,0.93)'; s.border = '4px solid #ff4d4d';+    s.font = '800 ' + (o.fontSize || 38) + 'px/1.2 system-ui,Arial,sans-serif';+    s.padding = '20px 34px'; s.borderRadius = '14px'; s.letterSpacing = '0.5px';+    s.boxShadow = '0 8px 44px rgba(255,0,0,0.55)';+  } else {+    s.color = '#fff'; s.background = 'rgba(0,0,0,0.80)';+    s.font = '600 ' + (o.fontSize || 30) + 'px/1.35 system-ui,Arial,sans-serif';+    s.padding = '14px 28px'; s.borderRadius = '12px'; s.boxShadow = '0 4px 24px rgba(0,0,0,0.55)';+  }+  (document.documentElement || document.body).appendChild(d);+  requestAnimationFrame(function () { d.style.opacity = '1'; });+  if (o.pulse !== false && (alert || o.pulse)) {+    try { d.__anim = d.animate([{ opacity: 1 }, { opacity: 0.45 }, { opacity: 1 }], { duration: 850, iterations: Infinity }); } catch (e) {}+  }+  if (o.durationMs) setTimeout(function () {+    var e = document.getElementById(id);+    if (e) { if (e.__anim) try { e.__anim.cancel(); } catch (x) {} e.style.opacity = '0'; setTimeout(function () { try { e.remove(); } catch (x) {} }, 350); }+  }, o.durationMs);+}++export async function caption(args = {}) {+  const tabId = await resolveTabId(args);+  await chrome.scripting.executeScript({+    target: { tabId },+    func: injectCaption,+    args: [{+      text: args.action === 'hide' ? '' : String(args.text || ''),+      style: args.style === 'alert' ? 'alert' : 'subtitle',+      position: args.position,+      fontSize: args.fontSize ? Number(args.fontSize) : undefined,+      pulse: args.pulse,+      durationMs: args.durationMs === undefined ? 5000 : Number(args.durationMs),+    }],+  });+  return { ok: true, tabId: String(tabId), text: args.text, style: args.style || 'subtitle' };+}++// Activate the tab + best-effort window focus. NOTE: on Windows this does NOT actually raise the+// window (OS foreground-lock blocks a background app from stealing focus — you just get a taskbar+// flash). So we return the window title + the exact AD recipe to TRULY foreground it.+export async function focus(args = {}) {+  const tabId = await resolveTabId(args);+  const tab = await chrome.tabs.get(tabId);+  await chrome.windows.update(tab.windowId, { focused: true, drawAttention: true });+  await chrome.tabs.update(tabId, { active: true });+  const t = (tab.title || '').slice(0, 40);+  return {+    ok: true, tabId: String(tabId), windowId: tab.windowId, title: tab.title,+    _hint: `Activated the tab, but chrome.windows focus CANNOT raise the window above other apps on Windows (OS foreground-lock). To TRULY bring it to the front, call Adom Desktop: desktop_bring_to_front {"titleContains":${JSON.stringify(t)},"state":"maximize"}. The extension cannot foreground a window itself.`,+  };+}++// shared resolver for CDP verbs (screenshot/eval/input target the same tab)+export { resolveTabId };
extension/src/offscreen-record.htmladded+3
@@ -0,0 +1,3 @@+<!doctype html>+<html><head><meta charset="utf-8"></head>+<body><script src="offscreen-record.js"></script></body></html>
extension/src/offscreen-record.jsadded+51
@@ -0,0 +1,51 @@+// offscreen-record.js — runs MediaRecorder (not available in the MV3 service worker).+// The SW hands us a tabCapture streamId; we record to WebM and return it as base64 on stop.++let recorder = null;+let chunks = [];+let mime = 'video/webm';++chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {+  if (!msg || msg.target !== 'offscreen-record') return;+  if (msg.action === 'start') { start(msg).then(sendResponse).catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) })); return true; }+  if (msg.action === 'stop') { stop().then(sendResponse).catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) })); return true; }+});++async function start(msg) {+  const src = msg.source === 'desktop' ? 'desktop' : 'tab'; // desktopCapture vs legacy tabCapture+  const fps = Number(msg.fps) || 30;+  const stream = await navigator.mediaDevices.getUserMedia({+    audio: msg.audio ? { mandatory: { chromeMediaSource: src, chromeMediaSourceId: msg.streamId } } : false,+    video: { mandatory: { chromeMediaSource: src, chromeMediaSourceId: msg.streamId, maxFrameRate: fps } },+  });+  chunks = [];+  // vp9 for quality/fps; fall back if unsupported+  mime = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') ? 'video/webm;codecs=vp9' : 'video/webm';+  recorder = new MediaRecorder(stream, { mimeType: mime, videoBitsPerSecond: msg.bitrate || 6000000 });+  recorder.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };+  // stop cleanly if the user ends the share from Chrome's "Stop sharing" bar+  stream.getVideoTracks()[0].addEventListener('ended', () => { try { if (recorder && recorder.state !== 'inactive') recorder.stop(); } catch (e) {} });+  recorder.start(1000); // 1s timeslices+  return { ok: true, mimeType: mime, fps };+}++function stop() {+  return new Promise((resolve) => {+    if (!recorder) return resolve({ ok: false, error: 'not recording' });+    recorder.onstop = async () => {+      try {+        const blob = new Blob(chunks, { type: mime });+        const buf = new Uint8Array(await blob.arrayBuffer());+        let bin = '';+        const CH = 0x8000;+        for (let i = 0; i < buf.length; i += CH) bin += String.fromCharCode.apply(null, buf.subarray(i, i + CH));+        const out = { ok: true, base64: btoa(bin), mimeType: mime, sizeKB: Math.round(buf.length / 1024) };+        recorder.stream.getTracks().forEach((t) => t.stop());+        recorder = null;+        chunks = [];+        resolve(out);+      } catch (e) { resolve({ ok: false, error: String((e && e.message) || e) }); }+    };+    recorder.stop();+  });+}
extension/src/popup.htmladded+27
@@ -0,0 +1,27 @@+<!doctype html>+<html>+<head>+  <meta charset="utf-8" />+  <style>+    body { font-family: system-ui, sans-serif; width: 250px; margin: 0; padding: 14px; }+    h1 { font-size: 14px; margin: 0 0 10px; }+    .row { display: flex; align-items: center; gap: 8px; font-size: 13px; }+    .dot { width: 10px; height: 10px; border-radius: 50%; background: #c0392b; }+    .dot.on { background: #27ae60; }+    .muted { color: #888; font-size: 11px; margin-top: 8px; }+    button { margin-top: 14px; width: 100%; padding: 10px; font-size: 14px; font-weight: 600;+      border: none; border-radius: 8px; background: #1769ff; color: #fff; cursor: pointer; }+    button.rec { background: #c0392b; }+    button:disabled { opacity: .6; cursor: default; }+    #recmsg { font-size: 11px; color: #888; margin-top: 6px; min-height: 14px; }+  </style>+</head>+<body>+  <h1>Adom for Chrome &amp; Edge</h1>+  <div class="row"><span id="dot" class="dot"></span><span id="status">checking…</span></div>+  <div class="muted" id="detail"></div>+  <button id="rec">⏺ Record this tab</button>+  <div id="recmsg"></div>+  <script src="popup.js"></script>+</body>+</html>
extension/src/popup.jsadded+51
@@ -0,0 +1,51 @@+// Popup: connection status + the friendly "Record this tab" button (no share-picker).+async function renderStatus() {+  const { connected, since } = await chrome.storage.session.get(['connected', 'since']);+  const dot = document.getElementById('dot');+  const status = document.getElementById('status');+  const detail = document.getElementById('detail');+  if (connected) {+    dot.classList.add('on');+    status.textContent = 'Connected to adom-desktop';+    detail.textContent = since ? `since ${new Date(since).toLocaleTimeString()}` : '';+  } else {+    dot.classList.remove('on');+    status.textContent = 'Not connected';+    detail.textContent = 'Is adom-desktop running with the native-browser bridge installed?';+  }+}+renderStatus();+chrome.storage.session.onChanged.addListener(renderStatus);++const recBtn = document.getElementById('rec');+const recMsg = document.getElementById('recmsg');++async function refreshRec() {+  const st = await chrome.runtime.sendMessage({ type: 'popup-record-state' }).catch(() => null);+  const recording = st && st.state === 'recording';+  recBtn.dataset.recording = recording ? '1' : '';+  recBtn.textContent = recording ? '⏹ Stop & save' : '⏺ Record this tab';+  recBtn.classList.toggle('rec', recording);+  if (recording && st.elapsedMs) recMsg.textContent = `recording… ${Math.round(st.elapsedMs / 1000)}s`;+}++recBtn.onclick = async () => {+  recBtn.disabled = true;+  try {+    if (recBtn.dataset.recording) {+      recMsg.textContent = 'saving…';+      const r = await chrome.runtime.sendMessage({ type: 'popup-record-stop' });+      recMsg.textContent = r && r.ok ? (r.downloaded ? `saved (${r.sizeKB} KB) to Downloads` : 'stopped') : `stop failed: ${(r && r.error) || ''}`;+    } else {+      const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });+      recMsg.textContent = 'starting…';+      const r = await chrome.runtime.sendMessage({ type: 'popup-record-start', tabId: tab.id });+      recMsg.textContent = r && r.ok ? 'recording this tab — no picker!' : `failed: ${(r && r.error) || ''} ${(r && r._hint) || ''}`;+    }+  } catch (e) { recMsg.textContent = String((e && e.message) || e); }+  recBtn.disabled = false;+  refreshRec();+};++refreshRec();+setInterval(refreshRec, 1000);
extension/src/record.jsadded+94
@@ -0,0 +1,94 @@+// record.js — SW-side control for browser recording (nbrowser_record_*).+//+// TWO methods (caller picks via args.method; README has the full pros/cons):+//   • 'getdisplaymedia' (desktopCapture) — CROSS-PLATFORM (Mac/Ubuntu/Windows). High-fps (the WebRTC+//     pipeline Google Meet uses). Chrome REQUIRES a one-time "choose what to share" picker per+//     recording (no silent variant in any browser). Records the chosen surface in the background.+//   • 'wgc' — WINDOWS-only, handled BRIDGE-SIDE via HD's Windows-Graphics-Capture recorder: no picker,+//     fully background, no wake-lock, 60fps mp4. The bridge intercepts method:'wgc' before the+//     extension; reaching here means HD/bridge WGC isn't wired on this machine.+//+// record_start is NON-BLOCKING: it shows the picker and returns immediately with a recordId in state+// 'awaiting_pick'. The picker can take as long as the user needs (the old blocking version timed out+// the relay request). Poll nbrowser_record_status until state=='recording', then drive, then stop.+// The MediaRecorder lives in the offscreen document, so it keeps recording even if the SW idles.++const OFFSCREEN_URL = 'src/offscreen-record.html';+const active = new Map(); // recordId -> { state, method, startedAt?, mimeType?, error? }+let seq = 0;++async function ensureOffscreen() {+  const has = chrome.offscreen.hasDocument ? await chrome.offscreen.hasDocument() : false;+  if (!has) await chrome.offscreen.createDocument({ url: OFFSCREEN_URL, reasons: ['USER_MEDIA'], justification: 'Record the browser to WebM for demos' });+}++export async function recordStart(tabId, args = {}) {+  const method = (args.method || 'getdisplaymedia').toLowerCase();+  if (method === 'wgc') {+    return { ok: false, error: 'method:wgc is handled by the bridge (HD WGC recorder), not the extension',+      _hint: 'the bridge intercepts method:"wgc"; reaching the extension means HD/bridge WGC is not wired here — use method:"getdisplaymedia"' };+  }+  // 'tabcapture' — record the CURRENT tab with NO picker. Needs activeTab, which is granted when the+  // user clicks the Adom toolbar icon (that gesture). This powers the friendly "Record this tab" button.+  if (method === 'tabcapture' || method === 'thistab') {+    let streamId;+    try {+      streamId = await new Promise((res, rej) => chrome.tabCapture.getMediaStreamId({ targetTabId: Number(tabId) }, (id) =>+        (chrome.runtime.lastError || !id) ? rej(new Error((chrome.runtime.lastError && chrome.runtime.lastError.message) || 'tabCapture failed')) : res(id)));+    } catch (e) {+      return { ok: false, error: String((e && e.message) || e), _hint: 'tabCapture needs activeTab — start it from the Adom toolbar button ("Record this tab"); that click grants it.' };+    }+    const recordId = 'r' + (++seq);+    await ensureOffscreen();+    const res = await chrome.runtime.sendMessage({ target: 'offscreen-record', action: 'start', streamId, source: 'tab', fps: Number(args.fps) || 30, audio: args.audio === true });+    if (res && res.ok) { active.set(recordId, { state: 'recording', method: 'tabcapture', startedAt: Date.now(), mimeType: res.mimeType }); return { ok: true, recordId, method: 'tabcapture', state: 'recording' }; }+    return { ok: false, error: (res && res.error) || 'recorder failed to start' };+  }++  const recordId = 'r' + (++seq);+  active.set(recordId, { state: 'awaiting_pick', method: 'getdisplaymedia' });+  const tab = tabId != null ? await chrome.tabs.get(Number(tabId)).catch(() => null) : null;++  // Picker callback — runs whenever the user picks (or cancels). Recording starts HERE, async.+  const onPick = async (streamId) => {+    if (chrome.runtime.lastError || !streamId) { active.set(recordId, { state: 'cancelled', method }); return; }+    try {+      await ensureOffscreen();+      const res = await chrome.runtime.sendMessage({ target: 'offscreen-record', action: 'start', streamId, source: 'desktop', fps: Number(args.fps) || 30, audio: args.audio === true });+      if (res && res.ok) active.set(recordId, { state: 'recording', method: 'getdisplaymedia', startedAt: Date.now(), mimeType: res.mimeType });+      else active.set(recordId, { state: 'error', method, error: (res && res.error) || 'recorder start failed' });+    } catch (e) { active.set(recordId, { state: 'error', method, error: String((e && e.message) || e) }); }+  };++  try {+    if (tab) chrome.desktopCapture.chooseDesktopMedia(['window', 'screen', 'tab'], tab, onPick);+    else chrome.desktopCapture.chooseDesktopMedia(['window', 'screen'], onPick);+  } catch (e) {+    active.set(recordId, { state: 'error', method, error: String((e && e.message) || e) });+    return { ok: false, error: String((e && e.message) || e) };+  }+  return { ok: true, recordId, method: 'getdisplaymedia', state: 'awaiting_pick',+    _hint: 'a share picker is up — once the user picks, state becomes "recording". Poll nbrowser_record_status {recordId}; then drive; then nbrowser_record_stop.' };+}++export async function recordStop(args = {}) {+  const recordId = args.recordId || [...active.keys()].reverse().find((id) => active.get(id).state === 'recording');+  const meta = recordId ? active.get(recordId) : null;+  if (!meta) return { ok: false, error: 'no recording found' };+  if (meta.state !== 'recording') return { ok: false, error: `recording is in state '${meta.state}', not recording`, state: meta.state };+  const res = await chrome.runtime.sendMessage({ target: 'offscreen-record', action: 'stop' });+  active.delete(recordId);+  if (![...active.values()].some((m) => m.state === 'recording') && chrome.offscreen.closeDocument) chrome.offscreen.closeDocument().catch(() => {});+  if (!res || !res.ok) return { ok: false, error: (res && res.error) || 'recorder failed to stop' };+  return { ok: true, recordId, method: meta.method, durationMs: Date.now() - meta.startedAt, mimeType: res.mimeType, sizeKB: res.sizeKB, base64: res.base64 };+}++export function recordStatus(args = {}) {+  const recordId = args.recordId || [...active.keys()].pop();+  const m = recordId ? active.get(recordId) : null;+  if (!m) return { ok: true, recordId: recordId || null, state: 'none' };+  return { ok: true, recordId, state: m.state, method: m.method, error: m.error, elapsedMs: m.startedAt ? Date.now() - m.startedAt : 0 };+}+export function recordList() {+  return { ok: true, count: active.size, recordings: [...active].map(([id, m]) => ({ recordId: id, state: m.state, method: m.method, elapsedMs: m.startedAt ? Date.now() - m.startedAt : 0 })) };+}
extension/src/sw.jsadded+234
@@ -0,0 +1,234 @@+// adom-browser-extension :: MV3 service worker+// ---------------------------------------------+// Holds the native-messaging port to the AD bridge (via the native host), routes nbrowser_* commands+// to native.js (chrome.tabs/windows — no banner) or cdp.js (chrome.debugger — screenshot/eval), and+// answers the host's keepalive ping (which keeps this service worker resident).++import * as native from './native.js';+import * as cdp from './cdp.js';+import * as record from './record.js';++const HOST = 'inc.adom.native_browser';+const VERSION = chrome.runtime.getManifest().version;++let port = null;+let connecting = false;+let gotMessage = false;++// Bounded reconnect: exponential backoff + a give-up state. NEVER a fixed-rate spin — a fast reconnect+// loop here re-spawns native-host processes, which is what exhausted the machine's sockets on+// 2026-06-19. After SW_GIVEUP misses we stop the fast loop; the 0.5-min keepalive alarm then re-arms+// exactly ONE attempt per tick (a slow heartbeat), so we still recover without hammering connectNative.+const SW_BASE = 1500, SW_MAX = 60000, SW_GIVEUP = 40;+let swBackoff = SW_BASE;+let swAttempts = 0;+let givenUp = false;+let reconnectTimer = null;++function setStatus(connected) {+  chrome.storage.session.set({ connected, since: Date.now() }).catch(() => {});+}++function scheduleConnect() {+  if (givenUp || port || connecting || reconnectTimer) return; // never stack timers / parallel attempts+  if (swAttempts >= SW_GIVEUP) { givenUp = true; setStatus(false); return; }+  const delay = Math.min(swBackoff, SW_MAX) + Math.floor(Math.random() * 400);+  swBackoff = Math.min(swBackoff * 2, SW_MAX);+  reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);+}++// Idempotent: exactly ONE native port / one in-flight attempt at a time. Multiple callers (onDisconnect+// + keepalive alarm + onStartup) must NOT open parallel ports — that spawns multiple host processes and+// the bridge flaps between them ("newest host wins"). The guards below make concurrent calls no-ops.+function connect() {+  if (port || connecting || givenUp) return;+  connecting = true;+  swAttempts++;+  let p;+  try {+    p = chrome.runtime.connectNative(HOST);+  } catch (e) {+    connecting = false;+    setStatus(false);+    scheduleConnect();+    return;+  }+  port = p;+  connecting = false;+  gotMessage = false;+  setStatus(true);+  port.onMessage.addListener(onMessage);+  port.onDisconnect.addListener(() => {+    if (port === p) port = null;+    setStatus(false);+    scheduleConnect(); // backoff, NOT a fixed 1.5s retry+  });+}++function reply(id, ok, payload, error, hint) {+  if (!port) return;+  port.postMessage(ok ? { id, ok: true, payload } : { id, ok: false, error, _hint: hint });+}++async function onMessage(msg) {+  if (!msg) return;+  if (!gotMessage) { gotMessage = true; swAttempts = 0; swBackoff = SW_BASE; } // proven-good connection+  if (msg.ping) { try { port && port.postMessage({ pong: true }); } catch {} return; }+  const { id, verb, args } = msg;+  if (id == null || !verb) return;+  try {+    reply(id, true, await dispatch(verb, args || {}));+  } catch (e) {+    reply(id, false, undefined, String((e && e.message) || e), 'verb threw in extension');+  }+}++async function dispatch(verb, args) {+  switch (verb) {+    // --- meta -------------------------------------------------------------+    case 'nbrowser_status':+      return { ok: true, ext: 'adom-browser-extension', version: VERSION, engine: 'native', browser: detectBrowser() };+    case 'nbrowser_ping':+      return { ok: true, pong: true };+    case 'nbrowser_dev_reload': {+      // HARD-GATED. chrome.runtime.reload() restarts the SW, which re-launches the native host. This is+      // a bridge-DISPATCHABLE verb, so a looping/buggy bridge could call it repeatedly → hosts+      // accumulate → port exhaustion (the 2026-06-19 incident). Throttle to at most once per 10 min,+      // persisted in storage so an SW restart cannot reset the clock and bypass the throttle.+      const MIN_INTERVAL = 10 * 60 * 1000;+      const now = Date.now();+      const { _lastReload = 0 } = await chrome.storage.local.get('_lastReload');+      if (now - _lastReload < MIN_INTERVAL) {+        return { ok: false, error: 'dev_reload throttled',+          _hint: `last reload ${Math.round((now - _lastReload) / 1000)}s ago; min interval ${MIN_INTERVAL / 1000}s. If you truly need it now, reload from chrome://extensions.` };+      }+      await chrome.storage.local.set({ _lastReload: now });+      setTimeout(() => chrome.runtime.reload(), 150); // reply first, then reload+      return { ok: true, reloading: true };+    }++    // --- windows / sessions ----------------------------------------------+    case 'nbrowser_open_window':   return native.openWindow(args);+    case 'nbrowser_close_window':  return native.closeWindow(args);+    case 'nbrowser_switch_window': return native.switchWindow(args);+    case 'nbrowser_list_windows':  return native.listWindows();+    case 'nbrowser_rescan':        return native.rescan();++    // --- demo aids -------------------------------------------------------+    case 'nbrowser_caption':       return native.caption(args);+    case 'nbrowser_focus':         return native.focus(args);++    // --- tabs -------------------------------------------------------------+    case 'nbrowser_open_tab':      return native.openTab(args);+    case 'nbrowser_close_tab':     return native.closeTab(args);+    case 'nbrowser_switch_tab':    return native.switchTab(args);+    case 'nbrowser_list_tabs':     return native.listTabs(args);++    // --- navigation -------------------------------------------------------+    case 'nbrowser_navigate':      return native.navigate(args);+    case 'nbrowser_reload':        return native.reload(args);+    case 'nbrowser_back':          return native.back(args);+    case 'nbrowser_forward':       return native.forward(args);+    case 'nbrowser_wait':          return native.wait(args);++    // --- input (CDP, isTrusted) ------------------------------------------+    case 'nbrowser_input_dispatch':+      return cdp.inputDispatch(await native.resolveTabId(args), args);+    case 'nbrowser_click':+      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'click' });+    case 'nbrowser_type':+      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'type' });+    case 'nbrowser_press_key':+      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'key' });+    case 'nbrowser_hover':+      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'hover' });+    case 'nbrowser_double_click':+      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'dblclick' });+    case 'nbrowser_right_click':+      return cdp.inputDispatch(await native.resolveTabId(args), { ...args, type: 'rightclick' });+    case 'nbrowser_cursor': {+      const tabId = await native.resolveTabId(args);+      await cdp.cursor(tabId, args.action || 'move', args.x, args.y, args.label || args.intent);+      return { ok: true, tabId: String(tabId), action: args.action || 'move', label: args.label || args.intent || 'Adom' };+    }++    // --- errors / lifecycle events ---------------------------------------+    case 'nbrowser_errors':+      return cdp.getErrors(await native.resolveTabId(args), args.clear !== false);+    case 'nbrowser_events':+      // window/tab closes (incl. byUser) since last drain — so the AI learns the user closed things+      return native.drainEvents();++    // --- data (M4: authed fetch via the user's cookies, downloads) --------+    case 'nbrowser_fetch_url':     return native.fetchUrl(args);+    case 'nbrowser_download':      return native.download(args);++    // --- recording (tab → WebM via offscreen MediaRecorder) ---------------+    case 'nbrowser_record_start':  return record.recordStart(await native.resolveTabId(args), args);+    case 'nbrowser_record_stop':   return record.recordStop(args);+    case 'nbrowser_record_status': return record.recordStatus(args);+    case 'nbrowser_record_list':   return record.recordList();++    // --- CDP: screenshot / eval ------------------------------------------+    case 'nbrowser_screenshot':+      return cdp.screenshot(await native.resolveTabId(args), { fullPage: !!args.fullPage, maxWidth: args.maxWidth || 1568, resize: true });+    case 'nbrowser_screenshot_full_res':+      return cdp.screenshot(await native.resolveTabId(args), { fullPage: !!args.fullPage, resize: false });+    case 'nbrowser_eval':+    case 'nbrowser_evaluate': {+      const tabId = await native.resolveTabId(args);+      const result = await cdp.evaluate(tabId, String(args.expr ?? args.expression ?? ''));+      return { ok: true, result, tabId: String(tabId), sessionId: args.sessionId };+    }++    default:+      return { ok: false, error: `verb not implemented yet: ${verb}`,+        _hint: 'Milestone 2 ships windows/tabs/navigate/reload/screenshot/eval; input_dispatch/errors/fetch_url/record land in M3–M4.' };+  }+}++function detectBrowser() {+  try { return navigator.userAgent.includes('Edg') ? 'edge' : 'chrome'; } catch { return 'unknown'; }+}++chrome.runtime.onStartup.addListener(connect);+chrome.runtime.onInstalled.addListener(connect);++// Popup-driven "Record this tab" (tabCapture, no picker). The user's icon-click grants activeTab.+chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {+  if (!msg || !msg.type) return;+  if (msg.type === 'popup-record-start') {+    record.recordStart(msg.tabId, { method: 'tabcapture', fps: 30 }).then(sendResponse);+    return true;+  }+  if (msg.type === 'popup-record-stop') {+    (async () => {+      const r = await record.recordStop({});+      if (r.ok && r.base64) {+        try {+          await chrome.downloads.download({ url: `data:${r.mimeType};base64,${r.base64}`, filename: `adom-tab-recording-${Date.now()}.webm` });+          r.downloaded = true;+        } catch (e) { r.downloadError = String((e && e.message) || e); }+        delete r.base64; // don't ship the big payload back to the popup+      }+      sendResponse(r);+    })();+    return true;+  }+  if (msg.type === 'popup-record-state') { sendResponse(record.recordStatus({})); return true; }+});++// Keepalive + self-heal. The host's ping keeps the SW warm WHILE connected, but if the SW is ever+// terminated the host dies too and nothing restarts it — so an alarm (which wakes a terminated MV3+// service worker) re-opens the native port. This is what makes the bridge reliably reachable.+chrome.alarms.create('nb-keepalive', { periodInMinutes: 0.5 });+chrome.alarms.onAlarm.addListener((a) => {+  if (a.name !== 'nb-keepalive') return;+  // If the fast reconnect loop gave up, this tick re-arms exactly ONE attempt (a 0.5-min heartbeat) so+  // we still recover when the bridge returns — bounded, never a spin.+  if (givenUp) { givenUp = false; swAttempts = 0; swBackoff = SW_BASE; }+  if (!port) { connect(); return; }+  try { port.postMessage({ keepalive: true }); } catch (e) { port = null; setStatus(false); scheduleConnect(); }+});++connect();
host/host-manifest.template.jsonadded+9
@@ -0,0 +1,9 @@+{+  "name": "inc.adom.native_browser",+  "description": "Adom native-browser bridge host (stdio <-> adom-desktop)",+  "path": "__ABSOLUTE_PATH_TO__\\native-host.bat",+  "type": "stdio",+  "allowed_origins": [+    "chrome-extension://__EXTENSION_ID__/"+  ]+}
host/native-host.batadded+6
@@ -0,0 +1,6 @@+@echo off+REM Chrome/Edge launch this as the native-messaging host "path".+REM It runs the Node stdio<->bridge pump. Node is resolved from PATH; the installer may rewrite this+REM line with an absolute node.exe path (the way adom-desktop resolves node for its bridges) if PATH+REM is unreliable under the browser's launch environment.+node "%~dp0native-host.js"
host/native-host.jsadded+168
@@ -0,0 +1,168 @@+#!/usr/bin/env node+'use strict';+/*+ * adom-browser-extension :: native-messaging host+ * ------------------------------------------------+ * Chrome/Edge launch this process when the extension calls+ *   chrome.runtime.connectNative('inc.adom.native_browser')+ *+ * It is a thin pump between two transports:+ *   - stdio : Chrome native-messaging framing  (4-byte LE length prefix + JSON), bidirectional+ *   - TCP   : the bridge's host-loopback (newline-delimited JSON), discovered from host.json+ *+ *   ext  --stdio-->  host  --tcp-->  bridge   (results, events, pong)+ *   ext  <--stdio--  host  <--tcp--  bridge   (commands)+ *+ * The extension never learns a port — that's the whole point (PORTS.md: no port survives HNS, and an+ * MV3 extension can't read a discovery file anyway). The host CAN read disk, so it does the lookup.+ *+ * A ~20s keepalive ping to the extension keeps the MV3 service worker resident.+ */++const fs = require('fs');+const net = require('net');+const os = require('os');+const path = require('path');++const BRIDGE_DIR = path.join(os.homedir(), '.adom', 'bridges', 'native-browser');+const HOSTFILE = path.join(BRIDGE_DIR, 'host.json');+const PIDFILE = path.join(BRIDGE_DIR, 'host.pid');++// ---- SINGLETON guard -------------------------------------------------------+// Native-messaging hosts must NOT accumulate. The 2026-06-19 machine-wide port exhaustion was caused+// by N hosts each retrying connect()/sec. Exactly one host per machine: on startup, if a LIVE host PID+// is already recorded, exit immediately. Stale-PID-aware (a dead PID's file is reclaimed).+// Is `pid` a LIVE native-host? Must be alive AND actually a node process — a stale pidfile whose PID+// got reused by some unrelated app otherwise makes every new host defer forever (the 2026-06-19 false+// positive that blocked the live load). If we can't confirm it's node, take over (availability beats a+// rare duplicate, which the backoff + bridge circuit-breaker catch anyway).+function isLiveHost(pid) {+  if (!pid || pid === process.pid) return false;+  try { process.kill(pid, 0); } catch (e) { if (e.code !== 'EPERM') return false; } // dead -> reclaim+  try {+    const { execSync } = require('child_process');+    if (process.platform === 'win32') {+      return /"node\.exe"/i.test(execSync(`tasklist /fi "pid eq ${pid}" /fo csv /nh`, { encoding: 'utf8', timeout: 4000, windowsHide: true }));+    }+    return /node/i.test(execSync(`ps -p ${pid} -o comm=`, { encoding: 'utf8', timeout: 4000 }));+  } catch { return false; } // can't verify -> assume reused -> take over+}+function acquireSingleton() {+  try { fs.mkdirSync(BRIDGE_DIR, { recursive: true }); } catch {}+  try {+    const existing = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);+    if (isLiveHost(existing)) {+      process.stderr.write(`[native-host] another host (pid ${existing}) is already live — exiting (singleton)\n`);+      process.exit(0);+    }+  } catch {} // no/garbage pidfile -> we take ownership+  try { fs.writeFileSync(PIDFILE, String(process.pid)); } catch {}+}+function releaseSingleton() {+  try {+    const cur = parseInt(String(fs.readFileSync(PIDFILE, 'utf8')).trim(), 10);+    if (cur === process.pid) fs.unlinkSync(PIDFILE); // only delete OUR pidfile, never a sibling's+  } catch {}+}+process.on('exit', releaseSingleton);+acquireSingleton();++// ---- reconnect policy: exponential backoff + give-up (NO fixed-rate spin) ---+const BASE_DELAY = 500;            // first retry+const MAX_DELAY = 30000;           // backoff cap+const MAX_ATTEMPTS = 60;           // give up after this many attempts WITHOUT ever connecting+const MAX_UNREACHABLE_MS = 10 * 60 * 1000; // ...or this long unreachable, whichever first+const STABLE_MS = 5000;            // a connection up >= this long is "proven good" -> reset budget+let backoff = BASE_DELAY;+let attempts = 0;+let everConnected = false;+let connectedAt = 0;+const startedAt = Date.now();++// ---- Chrome native-messaging framing (stdout to the extension) -------------+function writeToExtension(obj) {+  const json = Buffer.from(JSON.stringify(obj), 'utf8');+  const len = Buffer.alloc(4);+  len.writeUInt32LE(json.length, 0);+  process.stdout.write(len);+  process.stdout.write(json);+}++// ---- stdin (from the extension) -> bridge ----------------------------------+let bridge = null;+let inbuf = Buffer.alloc(0);+function pumpStdin() {+  while (inbuf.length >= 4) {+    const len = inbuf.readUInt32LE(0);+    if (inbuf.length < 4 + len) break;+    const json = inbuf.slice(4, 4 + len);+    inbuf = inbuf.slice(4 + len);+    let msg;+    try { msg = JSON.parse(json.toString('utf8')); } catch { continue; }+    if (msg && msg.pong) continue; // keepalive ack — don't forward+    if (bridge && !bridge.destroyed) bridge.write(JSON.stringify(msg) + '\n');+  }+}+process.stdin.on('data', (c) => { inbuf = Buffer.concat([inbuf, c]); pumpStdin(); });+process.stdin.on('end', () => process.exit(0));+process.stdin.on('error', () => process.exit(0));++// ---- bridge connection (newline JSON) -> extension -------------------------+function readHostPort() {+  try { return JSON.parse(fs.readFileSync(HOSTFILE, 'utf8')).hostPort; } catch { return null; }+}+// Schedule the next attempt with exponential backoff + jitter, OR give up (exit) if the bridge has+// been unreachable past the caps. Giving up is SAFE: stdin stays open, so Chrome keeps the extension's+// port; the SW's own bounded keepalive will re-launch a fresh host later if the bridge comes back. The+// one thing we must NEVER do is spin at a fixed fast rate — that is what exhausted the machine's ports.+function scheduleReconnect() {+  if (!everConnected) {+    if (attempts >= MAX_ATTEMPTS) {+      process.stderr.write(`[native-host] bridge unreachable after ${attempts} attempts — exiting\n`);+      process.exit(0);+    }+    if (Date.now() - startedAt > MAX_UNREACHABLE_MS) {+      process.stderr.write(`[native-host] bridge unreachable for >${MAX_UNREACHABLE_MS}ms — exiting\n`);+      process.exit(0);+    }+  }+  const jitter = Math.floor(Math.random() * 250);+  const delay = Math.min(backoff, MAX_DELAY) + jitter;+  backoff = Math.min(backoff * 2, MAX_DELAY); // double every miss; reset only on a proven-good connect+  setTimeout(connectBridge, delay);+}++function connectBridge() {+  const port = readHostPort();+  if (!port) { attempts++; scheduleReconnect(); return; } // bridge not up yet — backoff, don't spin+  attempts++;+  const sock = net.connect(port, '127.0.0.1');+  let buf = '';+  sock.on('connect', () => { bridge = sock; everConnected = true; connectedAt = Date.now(); });+  sock.on('data', (chunk) => {+    buf += chunk.toString('utf8');+    let nl;+    while ((nl = buf.indexOf('\n')) >= 0) {+      const line = buf.slice(0, nl);+      buf = buf.slice(nl + 1);+      if (!line.trim()) continue;+      let msg;+      try { msg = JSON.parse(line); } catch { continue; }+      writeToExtension(msg); // command -> extension+    }+  });+  let done = false;+  const onDone = () => {+    if (done) return; done = true;            // 'close' + 'error' both fire — schedule once+    if (bridge === sock) bridge = null;+    if (connectedAt && Date.now() - connectedAt >= STABLE_MS) { backoff = BASE_DELAY; attempts = 0; }+    connectedAt = 0;+    scheduleReconnect();+  };+  sock.on('close', onDone);+  sock.on('error', onDone);+}+connectBridge();++// ---- keepalive (keeps the MV3 service worker alive) ------------------------+setInterval(() => { try { writeToExtension({ ping: true }); } catch {} }, 15000);
install/README.mdadded+37
@@ -0,0 +1,37 @@+# install/++Three registrations on a managed Windows machine (golden image / HD / winvm):++1. **Bridge → AD `bridges-cache`** — `bridge/` copied to `%LOCALAPPDATA%\Adom Desktop\bridges-cache\+   native-browser\`; adom-desktop's registry auto-discovers it and routes `nbrowser_*` to it.+2. **Native-messaging host (HKCU, no UAC)** — render `host-manifest.json` from the template (absolute+   `native-host.bat` path + the extension ID in `allowed_origins`) and register it under+   `HKCU\Software\<vendor>\NativeMessagingHosts\inc.adom.native_browser` for Chrome and/or Edge.+3. **Extension force-install (HKLM, managed only)** — `ExtensionInstallForcelist` → `<id>;<update.xml>`.++`install.ps1` does all three. **Run it OUTSIDE Claude Code.**++## ⚠ The wcifs / AppContainer trap (read adom-desktop/CLAUDE.md)++For Claude-spawned tooling, `%LOCALAPPDATA%\Adom Desktop\...` is silently redirected to a per-package+shadow — PowerShell, `Start-Process`, and the Write/Edit tool all hit the shadow, not the real path.+So **from a Claude session, do the bridges-cache copy via WSL2 `cp`** (bypasses wcifs):++```bash+cp -r bridge/* "/c/Users/<user>/AppData/Local/Adom Desktop/bridges-cache/native-browser/"+```++…or via `adom-desktop shell_execute`. The HKCU/HKLM registry writes and the `.bat`/manifest writes+(under this repo) are NOT in the trapped paths and are fine from PowerShell run outside Claude.++## Better long-term: wiki-streamed bridge++adom-desktop already streams bridges from the wiki (`bridge_cache.rs` / `WIKI_STATIC_BASE`). Publishing+`bridge/` as a wiki package lets AD pull + cache it with no manual copy — the preferred delivery once+this stabilizes. See the plan's distribution section.++## update.xml++`update.xml` is the self-hosted `.crx` auto-update manifest the force-install policy points at. Sign+the `.crx` with our own keypair (`.pem`); the extension ID is the hash of its public key, so **reuse+the same key for every update**. Template lands when we cut the first signed `.crx` (Milestone 6).
install/ad-safety-check.ps1added+67
@@ -0,0 +1,67 @@+<#+  adom-browser-extension :: AD-safety neutralize + verify+  -------------------------------------------------------+  Run this LOCALLY on the Windows laptop (NOT through adom-desktop) BEFORE re-opening AD, after the+  2026-06-19 port-exhaustion incident. No admin / UAC needed — it only touches HKCU + the user profile.++  It ONLY touches adom-browser-extension artifacts:+    - the native-messaging host registration named 'inc.adom.native_browser' (Chrome + Edge)+    - the 'native-browser' bridge dir in AD's bridges-cache+    - the bridge discovery/pidfiles under ~/.adom/bridges/native-browser+    - node.exe processes running native-host.js+  Nothing else on the machine is read or modified.++  Removing the host registration + the cached bridge makes the runaway loop PHYSICALLY impossible:+  Chrome/Edge can no longer spawn the host, and AD can no longer auto-spawn the bridge.++  Usage (PowerShell, normal window — no admin):+    powershell -ExecutionPolicy Bypass -File ad-safety-check.ps1+#>+$ErrorActionPreference = 'SilentlyContinue'+$HostName = 'inc.adom.native_browser'+$pending  = 0+Write-Host "=== adom-browser-extension safety check  $(Get-Date) ===" -ForegroundColor Cyan++# 1) KILL-SWITCH: native-messaging host registration (Chrome + Edge).+#    Without this, connectNative() cannot launch a host -> the loop cannot start.+foreach ($v in 'Google\Chrome','Microsoft\Edge') {+  $key = "HKCU:\Software\$v\NativeMessagingHosts\$HostName"+  if (Test-Path $key) {+    Write-Host "[FOUND]  host registration: $key  -> removing" -ForegroundColor Yellow+    Remove-Item $key -Force+  } else { Write-Host "[clean]  no host registration under $v" -ForegroundColor Green }+}++# 2) Bridge files in AD's cache (so AD cannot auto-spawn the broken bridge on startup).+$cache = Join-Path $env:LOCALAPPDATA 'Adom Desktop\bridges-cache\native-browser'+if (Test-Path $cache) {+  Write-Host "[FOUND]  bridge in cache: $cache  -> removing" -ForegroundColor Yellow+  Remove-Item $cache -Recurse -Force+} else { Write-Host "[clean]  no native-browser bridge in bridges-cache" -ForegroundColor Green }++# 3) Discovery file + pidfile (host.json / host.pid).+$bdir = Join-Path $env:USERPROFILE '.adom\bridges\native-browser'+if (Test-Path $bdir) {+  Write-Host "[FOUND]  discovery dir: $bdir  -> removing" -ForegroundColor Yellow+  Get-ChildItem $bdir | ForEach-Object { Write-Host "           $($_.Name)" }+  Remove-Item $bdir -Recurse -Force+} else { Write-Host "[clean]  no discovery/pidfiles" -ForegroundColor Green }++# 4) Running looping hosts (node.exe -> native-host.js).+$hosts = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*native-host.js*' }+if ($hosts) {+  Write-Host "[FOUND]  $($hosts.Count) native-host process(es) running -> killing" -ForegroundColor Yellow+  $hosts | ForEach-Object { Write-Host "           pid $($_.ProcessId)"; Stop-Process -Id $_.ProcessId -Force }+} else { Write-Host "[clean]  no native-host.js processes running" -ForegroundColor Green }++# 5) Socket health (did the machine recover from the WSAENOBUFS / 10055 exhaustion?).+$tcp  = (Get-NetTCPConnection).Count+$node = (Get-CimInstance Win32_Process -Filter "Name='node.exe'").Count+Write-Host "[info]   total TCP connections: $tcp   (healthy idle machine is typically a few hundred)"+Write-Host "[info]   total node.exe processes: $node"++Write-Host "`n=== RESULT ===" -ForegroundColor Cyan+Write-Host "All [clean]/[info]  ->  SAFE to open AD:" -ForegroundColor Green+Write-Host "  * Chrome/Edge can no longer spawn the host (registration removed)"+Write-Host "  * AD can no longer auto-spawn the bridge (cache removed)"+Write-Host "Open AD, let it reconnect to galliaApril, then tell Claude 'AD is up'."
install/install.ps1added+60
@@ -0,0 +1,60 @@+<#+  adom-browser-extension :: Windows install (managed machine / golden-image bake)+  --------------------------------------------------------------------------------+  Registers the three pieces. Run OUTSIDE Claude Code (the %LOCALAPPDATA%\Adom Desktop path is+  AppContainer/wcifs-virtualized for Claude-spawned tools — see adom-desktop/CLAUDE.md). From a+  Claude session, do the bridges-cache copy via WSL2 `cp` or `adom-desktop shell_execute` instead.++  Usage:+    powershell -ExecutionPolicy Bypass -File install.ps1 -ExtensionId <id> [-Edge]+#>+param(+  [Parameter(Mandatory = $true)][string]$ExtensionId,+  [switch]$Edge,                 # also register for Edge (default registers Chrome)+  [string]$UpdateXmlUrl = ""     # self-hosted .crx update manifest URL (force-install)+)++$ErrorActionPreference = 'Stop'+$repo   = Split-Path -Parent $PSScriptRoot+$hostJs = Join-Path $repo 'host\native-host.bat'+$HostName = 'inc.adom.native_browser'++# 1) Bridge -> AD bridges-cache  (auto-discovered by adom-desktop's registry)+$cache = Join-Path $env:LOCALAPPDATA 'Adom Desktop\bridges-cache\native-browser'+New-Item -ItemType Directory -Force -Path $cache | Out-Null+Copy-Item -Recurse -Force (Join-Path $repo 'bridge\*') $cache+Write-Host "bridge -> $cache"++# 2) Native-messaging host manifest (rendered from template) + HKCU registration (no UAC)+$manifest = @{+  name           = $HostName+  description    = 'Adom native-browser bridge host (stdio <-> adom-desktop)'+  path           = $hostJs+  type           = 'stdio'+  allowed_origins = @("chrome-extension://$ExtensionId/")+} | ConvertTo-Json -Depth 5+$manifestPath = Join-Path $repo 'host\host-manifest.json'+$manifest | Set-Content -Path $manifestPath -Encoding utf8+Write-Host "host manifest -> $manifestPath"++function Register-Host($vendorKey) {+  $key = "HKCU:\Software\$vendorKey\NativeMessagingHosts\$HostName"+  New-Item -Path $key -Force | Out-Null+  Set-ItemProperty -Path $key -Name '(default)' -Value $manifestPath+  Write-Host "registered $key"+}+Register-Host 'Google\Chrome'+if ($Edge) { Register-Host 'Microsoft\Edge' }++# 3) Force-install policy (managed browsers) — HKLM, needs elevation+if ($UpdateXmlUrl -ne "") {+  function Force-Install($policyRoot) {+    New-Item -Path $policyRoot -Force | Out-Null+    Set-ItemProperty -Path $policyRoot -Name '1' -Value "$ExtensionId;$UpdateXmlUrl"+    Write-Host "force-install -> $policyRoot"+  }+  Force-Install 'HKLM:\SOFTWARE\Policies\Google\Chrome\ExtensionInstallForcelist'+  if ($Edge) { Force-Install 'HKLM:\SOFTWARE\Policies\Microsoft\Edge\ExtensionInstallForcelist' }+}++Write-Host "done. Restart the browser; the extension popup should show 'Connected to adom-desktop'."
screenshots/hero.pngadded
⋯ 1 unchanged line ⋯
skills/cross-platform-dev/SKILL.mdadded+33
@@ -0,0 +1,33 @@+---+name: cross-platform-dev+description: How adom-browser-extension ships across Windows and macOS — Windows-first, Kyle ports to Mac. Read before adding platform-specific code.+---++# Cross-platform dev flow (Windows-first → Kyle ports to Mac)++Adom's standard flow, applied to this repo: **John builds and ships on Windows; Kyle ports to macOS+as a fast-follow.** John is a Windows user, Kyle is the Mac user. Design accordingly so Kyle's port is+a thin layer, never a rewrite.++## The rule++Keep the **bridge** (`bridge/server.js`), the **native host** (`host/native-host.js`), and the+**extension** (`extension/`) **OS-agnostic** — plain Node + standard MV3 APIs. Isolate everything+Windows-specific to the **install/registration layer** only:++| Concern | Windows (build first) | macOS (Kyle ports) |+|---|---|---|+| Native-messaging host manifest | `HKCU\Software\<vendor>\NativeMessagingHosts\inc.adom.native_browser` | `~/Library/Application Support/<Google/Chrome \| Microsoft Edge>/NativeMessagingHosts/inc.adom.native_browser.json` |+| Host launcher | `native-host.bat` → `node native-host.js` | `native-host.sh` → `node native-host.js` (chmod +x) |+| Bridge cache path | `%LOCALAPPDATA%\Adom Desktop\bridges-cache\native-browser\` | `~/Library/Application Support/Adom Desktop/bridges-cache/native-browser/` |+| Extension force-install | `HKLM\...\ExtensionInstallForcelist` | configuration profile / MDM `ExtensionInstallForcelist` |+| Discovery file | `os.homedir()/.adom/bridges/native-browser/host.json` | identical (uses `os.homedir()` — already cross-platform) |++## Implications++- Never put `process.platform === 'win32'` branches in `bridge/` or `host/` core logic. If you need a+  platform path, derive it from `os.homedir()` (already cross-platform) — don't hardcode `%...%`.+- The `--silent-debugger-extension-api` launch-flag wiring and registry writes are Windows-only by+  nature; keep them in `install/` so Kyle swaps that folder, not the engine.+- When you add a Windows install step, add a one-line "macOS equivalent" note next to it so the port+  is mechanical.
skills/demo/SKILL.mdadded+80
@@ -0,0 +1,80 @@+---+name: demo+description: Run a WATCHABLE, narrated live demo of the Adom browser extension driving the user's real Chrome. The two rules — caption EVERY action, and bring the window to the FRONT for every action — so the user never misses what's happening.+---++# Demoing the Adom Browser Extension++A demo only lands if the user can *see* it happen on their screen. Two non-negotiable rules:++1. **Caption EVERY action.** Before (or as) each action, put up a caption saying what's happening. No+   silent steps — if you do something, narrate it. Use AD's fullscreen overlay as the spine and the+   in-page caption for the page-level label.+2. **Bring the window to the FRONT for every action.** The user must be looking at the right window.+   `nbrowser_focus` alone does NOT raise a window on Windows (OS foreground-lock) — you MUST also call+   AD's `desktop_bring_to_front`.++Always pass `--target AdomLapper` (the laptop) — with the winvm on the relay, no-target errors+`ambiguous_target`.++## Helpers (paste at the top of any demo script)+```bash+T="--target AdomLapper"+# fullscreen, unmissable, top-level overlay (AD) — the spine of the narration.+# position MUST be "bottom" — "top" covers the address bar + Chrome's "debugging" warning banner.+adcap(){ adom-desktop $T desktop_caption "{\"text\":\"$1\",\"id\":\"demo\",\"size\":\"large\",\"position\":\"bottom\",\"duration\":${2:-8000}}" >/dev/null 2>&1; }+# in-page caption: $3 = subtitle (default, calm bottom) | alert (red pulsing top)+cap(){ adom-desktop $T nbrowser_caption "{\"tabId\":$1,\"text\":\"$2\",\"style\":\"${3:-subtitle}\",\"durationMs\":${4:-7000}}" >/dev/null 2>&1; }+# raise the WINDOW to the foreground (Win32) — by page-title substring+front(){ adom-desktop $T desktop_bring_to_front "{\"titleContains\":\"$1\",\"state\":\"restore\"}" >/dev/null 2>&1; }+# activate the right TAB inside its window+foc(){ adom-desktop $T nbrowser_focus "{\"tabId\":$1}" >/dev/null 2>&1; }+tid(){ python3 -c "import sys,json;print(json.loads(json.load(sys.stdin)['output'])['tabId'])"; }+```++## The per-step pattern (do this for EVERY action)+```bash+adcap "STEP N — <what's about to happen>"   # fullscreen narration (can't be missed)+foc   $TID                                   # activate the tab+front "<distinctive page title>"             # RAISE the window so the user sees it+cap   $TID "<same label>" alert              # in-page caption (alert=red pulse for key moments)+sleep 3                                       # let the user read + look+<do the action: type / click / navigate / open_tab / ...>+sleep 3                                       # let the result land on screen+```+Pace it slow — 3–5s per step. A demo that races by is a demo the user misses.++## Caption style guide+- **`adcap` (AD fullscreen)** — the spine: one per step, big, top, replaces by `id:"demo"`. The user+  cannot miss it. **Dismiss at the end** with `desktop_caption {"action":"hide","id":"demo"}` (it also+  auto-expires, but clean up anyway).+- **`cap` alert (red pulse)** — for headline/important steps (the "wow" moments, e.g. reading the real+  signed-in account).+- **`cap` subtitle** — for calmer narration / showing results (e.g. "Scraped 41 links, 6 headings").++## Reliable "wow" beats to include+- **Multi-tab:** open 2–3 tabs, raising + captioning each.+- **isTrusted typing:** `nbrowser_type` into a visible search box (`input.landing-search-input` on the+  wiki) — the user watches text appear.+- **Scraping:** `nbrowser_eval` to pull `document.querySelectorAll('a').length` + headings, then caption+  the result.+- **🔑 Real-session proof** (the thesis): open `https://www.google.com`, eval+  `document.querySelector('a[aria-label*=Account i]')?.getAttribute('aria-label')` → it reads the user's+  actual signed-in account (e.g. `Google Account: … ([email protected])`). No sandboxed automation could do+  that — caption it as `alert`.++## Cleanup (always, at the end)+```bash+adom-desktop $T desktop_caption '{"action":"hide","id":"demo"}' >/dev/null 2>&1+adom-desktop $T nbrowser_close_window '{"sessionId":"demo"}' >/dev/null 2>&1   # close the demo window+```++## Run it in the background + tell the user to watch+A full demo has long `sleep`s (~50s) — launch it as a background task and, in the SAME message, tell the+user **"👀 watch your screen now"** so they're looking before windows start popping. Report the captured+results (tab titles, scraped counts, the signed-in account) when it finishes.++## Notes+- Transient relay timeouts on `screenshot`/`eval` happen (worse with winvm on the relay) — retry.+- Don't trust `d['output']` one-liner parses — verbs succeed even when the parse prints None/errors;+  re-check raw JSON. (See `skills/driving-the-extension/SKILL.md`.)
skills/driving-the-extension/SKILL.mdadded+147
@@ -0,0 +1,147 @@+---+name: driving-the-extension+description: How to drive the Adom browser extension (nbrowser_* verbs) — control the user's REAL logged-in Chrome/Edge from the cloud. Covers the CDP-vs-native split (and the "debugging this browser" bar), the full verb surface, isTrusted input (click-by-text + xy-finding, type/press_key, hover, right-click), the gliding Adom cursor with intent narration, screenshots, the taskbar status feature (flash/progress/Adom-overlay), bringing the window to the foreground, captions, the chip-fetcher use case, and every pitfall learned building it.+---++# Driving the Adom Browser Extension (`nbrowser_*`)++This extension turns the user's **real, logged-in Chrome/Edge** into something the agent drives — the+native-browser counterpart to `pup`. Because it runs **inside the user's actual profile**, their+cookies, SSO, saved logins and human-trust signals all apply, so it sails through login walls and+captchas that block pup's empty controlled-Chromium. That is the whole point.++**Path:** `cloud → relay → adom-desktop (laptop) → native-browser bridge → native-messaging host →+extension service worker → back`. You call `adom-desktop nbrowser_* '<json>'`.++## Targeting (READ FIRST)+If more than one Adom Desktop is on the relay (laptop **AdomLapper** + the **winvm**), every call must+name one or it errors `ambiguous_target`:+```bash+adom-desktop --target AdomLapper nbrowser_status+```+A "session" = a real Chrome window (`sessionId` you choose, or auto `s1`…). `tabId` = the real chrome+tab id. The session→window map persists to `chrome.storage`, so sessions survive reloads.++## ⭐ The #1 thing to understand: CDP vs native (and the "debugging this browser" bar)+The extension is a **CDP-primary hybrid** with two engines:+- **`chrome.debugger` (CDP)** — needed for *real* `isTrusted` input, full-page screenshots, and page+  `eval`. **Attaching it is what makes Chrome show the yellow `"Adom for Chrome & Edge" started+  debugging this browser` bar.** The bridge **lazy-attaches** only for CDP verbs and **idle-detaches+  ~8s** later, so the bar appears during active clicking/typing and clears when idle.+- **Native `chrome.*` APIs** — windows/tabs/navigation/downloads. **No debugger → no bar.**++| 🟡 Shows the bar (CDP) | ⚪ No bar (native) |+|---|---|+| `click`, `double_click`, `right_click`, `hover` | `open_window`, `close_window`, `switch_window`, `list_windows` |+| `type`, `press_key`, `input_dispatch`, `cursor` | `open_tab`, `close_tab`, `switch_tab`, `list_tabs`, `rescan` |+| `screenshot`, `screenshot_full_res` | `navigate`, `reload`, `back`, `forward`, `wait` |+| `eval` / `evaluate`, `errors` | `fetch_url`, `download`, `caption`, `focus`, `flash`/`taskbar` |++The bar is a Chrome security guarantee and **cannot be hidden on consumer Chrome** while driving. On+Adom-managed machines the `--silent-debugger-extension-api` launch flag removes it. If you only need to+read/navigate, prefer native verbs and the bar never shows.++## Verb surface+- **meta:** `status`, `ping`, `help` (full inline reference), `dev_reload` (throttled ≤1/10min)+- **windows/sessions:** `open_window`, `close_window`, `switch_window`, `list_windows`, `rescan`+- **tabs:** `open_tab`, `close_tab`, `switch_tab`, `list_tabs`+- **navigation:** `navigate`, `reload`, `back`, `forward`, `wait{ms}`+- **input (isTrusted, CDP):** `click`, `double_click`, `right_click`, `hover`, `type`, `press_key`,+  `input_dispatch`, `cursor`+- **capture/eval:** `screenshot`, `screenshot_full_res`, `eval`/`evaluate`+- **data (native advantage):** `fetch_url` (uses the user's REAL cookies — authed), `download`+- **status/errors:** `errors`, `events` (window/tab closes incl. `byUser:true`)+- **demo/UX:** `caption`, `focus`, **`flash`/`flash_stop`/`flash_mode`/`taskbar`**+- **recording:** `record_start`/`stop`/`status`/`list` — `method: wgc|getdisplaymedia|tabcapture`++## Input — click, type, hover, right-click, and how xy is found+You don't guess pixels. The **smart finder** scores the DOM, takes the matched element's+`getBoundingClientRect`, and computes its center xy — then CDP dispatches the `isTrusted` event there.+Targeting is **DOM-truth**, not pixel-guessing.+```bash+# click by VISIBLE TEXT (finder computes xy) — returns {x,y,matchedText}+adom-desktop -t AdomLapper nbrowser_click '{"sessionId":"s1","text":"Pay","cursor":true,"intent":"Clicking the Pay button"}'+adom-desktop -t AdomLapper nbrowser_click '{"sessionId":"s1","selector":"#download"}'         # or by selector+adom-desktop -t AdomLapper nbrowser_type  '{"sessionId":"s1","selector":"#search","text":"STM32F103C8T6"}'+adom-desktop -t AdomLapper nbrowser_press_key '{"sessionId":"s1","key":"Enter"}'              # Enter/Tab/ArrowDown/…+adom-desktop -t AdomLapper nbrowser_hover '{"sessionId":"s1","text":"Hover me"}'              # fires :hover + tooltips+adom-desktop -t AdomLapper nbrowser_right_click '{"sessionId":"s1","selector":".ctx"}'        # real context menu+```+**The gliding Adom cursor:** pass `cursor:true` + `intent:"…"` on any input verb and the gorgeous Adom+cursor (teal arrow + pulsing ring + "Adom" label) glides to the target **while narrating its intention**+("Clicking the Pay button", "Hovering the card to reveal the tooltip"). Builds user trust — they always+see what the AI is doing and why. Mimics Hydrogen Desktop's cursor.++## Screenshots+```bash+adom-desktop -t AdomLapper nbrowser_screenshot '{"sessionId":"s1","toFile":true}'   # writes a laptop PNG, returns its path+# then: adom-desktop -t AdomLapper pull_file '{"filePaths":["<path>"],"saveTo":"/tmp"}'+```+Screenshots are large base64 — over the relay they can time out, so **prefer `toFile:true` + `pull_file`**+(binary, chunked). `screenshot_full_res` is unresized; default resizes ≤1568 for the model.++## Taskbar status — flash, progress, the "Adom is driving" overlay badge+Paint glanceable status on the **real browser window's Windows taskbar button** (the hwnd is captured at+`open_window`; an **Adom overlay badge** goes on automatically while driving — a trust tell). Driven via+AD's `desktop_taskbar` (1.8.152+).+- `nbrowser_flash {sessionId}` / `nbrowser_flash_stop` — orange "come look" cue (auto-clears on focus).+- `nbrowser_flash_mode {mode}` — **`quiet`** (default; flash only on come-look moments: load/screenshot/+  error/done) · **`chatty`** (flash every action, for users who want to watch) · **`off`**.+- `nbrowser_taskbar {progress:{state,value?}, overlay:{badge,tooltip?}}` — progress states:+  `normal`(green) · **`paused`(YELLOW — use for captcha/2FA/login human-gates)** · `error`(red) ·+  `indeterminate`(marquee) · `none`. Overlay `badge:"adom"|"none"`.++Play it like a status light: indeterminate green while working → yellow + flash on a human-gate → clear++ one flash on done. **Respect the user's mode** (anxiety-driven users want `chatty`; trusting users want+`quiet`). Never leave it strobing.++## Bringing the Chrome window to the FOREGROUND+`nbrowser_focus` activates the right *tab* but **can't reliably raise the window** (Windows+foreground-lock). To pop the *window* forward use AD's Win32 verb:+```bash+adom-desktop -t AdomLapper desktop_bring_to_front '{"titleContains":"<page title>","state":"restore"}'  # or {hwnd}+```+Pattern: `nbrowser_focus` (tab) → `desktop_bring_to_front` (window).++## Captions (two systems)+- **In-page** `nbrowser_caption {text, style, position}` — `style:"subtitle"` (calm white-on-dark, the+  HD/web-hydrogen look) or `"alert"` (red **pulsing** banner). Tied to a page (cleared on navigation).+- **Fullscreen** AD `desktop_caption {text, position:"bottom", id}` — Win32 always-on-top over every app,+  truly unmissable; auto-expires. (`position:"bottom"` — top covers the URL bar + the debugger banner.)++## ⭐ The killer use case: chip-fetcher through the native browser+EE CAD sources (**SnapEDA/SnapMagic, Ultralibrarian, Mouser, DigiKey**) gate the actual download behind+a **login** and often a **captcha**. `pup`'s empty controlled-Chromium hits the wall. The native browser+uses the engineer's **real logged-in session**, so the STEP / KiCad symbol+footprint / Altium / Fusion+`.lbr` bundles just download. Drive the chip-fetcher sourcing ladder (manufacturer-first → SnapMagic →+Mouser → DigiKey → … → LCSC last) with `nbrowser_*` instead of `browser_*` — same flow, real session, no+wall. This is what changes the EE workflow.++## Pitfalls + best practices+- **Confirm connected first** (`nbrowser_status`). After `dev_reload` OR an **AD restart**, the native+  host re-launches and re-dials the bridge — expect a reconnect delay (see RECONNECT below).+- **The extension dies if its profile has NO open windows** (MV3 SW sleeps) → `"not connected"`. Open a+  window in that profile; never close your LAST window there.+- **Always clean up windows you open** (`nbrowser_close_window`) — abandoned windows annoy the user, and+  closing clears that window's taskbar status.+- **Poll `nbrowser_events`** to learn if the USER closed your windows/tabs (`window_closed{byUser:true}`).+- **`nbrowser_eval` runs in the PAGE world** — `chrome.*` is NOT available there (only DOM/window).+- **`fetch_url` is the authed shortcut** — real cookies; grabs a file behind a login without clicking.+- **Run `nbrowser_help`** anytime — the bridge self-documents every verb + the flash/taskbar playbook.+- **Driving AD itself:** use the 1.8.151+ first-class verbs (`run_script` with base64 `scriptB64`,+  `read_file`/`write_file`, `process_list`, `net_stats`, `registry_*`) instead of hand-quoting+  `shell_execute` — they sidestep the JSON→cmd→PowerShell nested-quote trap.++## RECONNECT (the one operational gotcha)+Each bridge restart (deploying new bridge code) or AD restart drops the extension; the native host then+re-dials and reconnects (currently 30–60s — being fixed AD-side with a stable host port). If+`nbrowser_status` says "not connected" but you didn't change anything, AD probably restarted to deploy a+fix — wait, then retry. If it persists, check `process_list` for **stray bridge instances** (kill all but+one) and that `bridge_list` runtimePort matches the live bridge's port.++## Install / publish+Extension is loaded unpacked into the user's profile (Chrome 137+ removed `--load-extension`, so it's a+one-time `chrome://extensions → Developer mode → Load unpacked`; production = Chrome Web Store+force-install). Bridge: `bridge_install '{"manifestUrl":"…native-browser-bridge-manifest.json"}'` →+`bridges-cache`; native-messaging host registers in HKCU (Chrome + Edge); `persistent:true` keeps one+bridge alive on a stable port. See `install/`, `AD-REQUESTS.md`, `TASKBAR-FEATURE.md`.
wiki/page.jsonadded+39
@@ -0,0 +1,39 @@+{+  "slug": "adom-browser-extension",+  "org": "adom",+  "title": "Adom Native Browser (Chrome + Edge)",+  "summary": "Drive your real, logged-in Chrome/Edge from the Adom ecosystem \u2014 the native-browser counterpart to pup. Same nbrowser_* surface as the puppeteer bridge, but inside the actual signed-in profile so cookies, SSO, saved logins, and captcha human-trust all work.",+  "category": "Browser Automation",+  "tags": [+    "browser",+    "extension",+    "chrome",+    "edge",+    "nbrowser",+    "pup",+    "native-messaging",+    "adom-desktop",+    "bridge"+  ],+  "status": "live",+  "discovery": {+    "trigger_words": [+      "native browser",+      "control my real browser",+      "adom browser extension",+      "nbrowser",+      "drive chrome",+      "drive edge",+      "logged-in browser",+      "real chrome",+      "pup native",+      "browser extension",+      "captcha real browser"+    ]+  },+  "hero": {+    "type": "image",+    "path": "screenshots/hero.png"+  },+  "visibility": "public"+}\ No newline at end of file