← Commit history
Publish adom-browser-extension source + hero
32 files changed
+2650−2
.gitignore+7AD-REQUESTS.md+41README.md+141−2TASKBAR-FEATURE.md+55bridge/BRIDGE_VERSION+1bridge/bridge.json+85bridge/package.json+14bridge/server.js+477extension/icons/README.md+8extension/icons/icon128.pngextension/icons/icon16.pngextension/icons/icon48.pngextension/manifest.json+44extension/src/cdp.js+340extension/src/native.js+331extension/src/offscreen-record.html+3extension/src/offscreen-record.js+51extension/src/popup.html+27extension/src/popup.js+51extension/src/record.js+94extension/src/sw.js+234host/host-manifest.template.json+9host/native-host.bat+6host/native-host.js+168install/README.md+37install/ad-safety-check.ps1+67install/install.ps1+60screenshots/hero.pngskills/cross-platform-dev/SKILL.md+33skills/demo/SKILL.md+80skills/driving-the-extension/SKILL.md+147wiki/page.json+39.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
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."+ }+ }+}
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 & 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"+}