← Commit history
.gitignoreadded+6
@@ -0,0 +1,6 @@+node_modules/+*.zip+*.bundle+*.tgz+*.log+profiles/
CHANGELOG.mdadded+38
@@ -0,0 +1,38 @@+# Changelog — Adom Desktop Puppeteer Bridge++## 1.1.0 — 2026-06-29 — Cold-start self-heal + ownership extraction++**Why:** On a fresh PC (Hydrogen Desktop bundles Adom Desktop; the installer pre-installs neither+Node nor Chrome for Testing), the first "open … in pup" failed with a generic+"Failed to launch Chrome after 3 attempts" + a developer-only string ("run npx puppeteer browsers+install chrome") — no `errorCode`, no auto-install, no actionable hint. The AI couldn't recover.++**Fixed — the bridge now self-heals Chrome for Testing (CfT):**+- **`src/chrome.js`** (new) — detect (validates the *actual* executable, not just "some dir exists"),+  auto-install programmatically via the bundled `@puppeteer/browsers` (no npx), pinned to the exact+  buildId this puppeteer expects, with download-progress + install-state dedupe + a readiness snapshot.+- **`browser_open_window`** — a cold-start gate (CfT path only; native-drive skips it) that detects a+  missing CfT, kicks off a NON-BLOCKING background install, and returns a structured+  `{ errorCode:"chrome_for_testing_installing", installing:true, statusVerb:"browser_readiness",+  _hint:"poll browser_readiness then retry" }` — instead of blindly spawning a missing binary 3× then+  throwing. On install failure → `chrome_install_failed`/`chrome_download_failed` with a real hint+  (no more silent continue-to-doomed-launch).+- **`browser_readiness`** (new verb) — `{ ready, chromeForTestingInstalled, installing,+  installProgressPct, ... }`. Poll until `ready:true`.+- **`browser_prewarm`** (new verb) — install CfT WITHOUT opening a window (warm before first use);+  `{wait:true}` blocks.+- **Status chip** — `/status` now emits `led`/`summary`/`tooltip` and tells the TRUTH: yellow+  "Installing Chrome N%" / "Chrome not installed", red on install failure, green when ready (was always+  green, even with no Chrome). `/status` + `/health` both carry `version` + `chrome` readiness.+- **Deep launch throw** is now Chrome-aware (structured `chrome_for_testing_not_installed` when CfT is+  genuinely missing vs a memory/locked-profile failure when it's present).++**Hygiene:**+- `BRIDGE_VERSION` ↔ `bridge.json` version lockstep fixed (was 1.0.14 vs 1.0.16).+- `bridge.json`: `statusVerb:"browser_readiness"` + `updateManifestUrl` so AD auto-updates from this+  page; new verbs registered in `verbs[]` + `browser_describe`.++**Ownership:** extracted from `adom-desktop/plugins/puppeteer` into this standalone wiki repo+(`wiki.adom.inc/adom/adom-desktop-puppeteer-bridge`), mirroring the cloud-owned kicad/fusion model.+Adom Desktop keeps a bundled SEED for first-run/offline and auto-pulls newer versions from here via+`updateManifestUrl`.
CLAUDE.mdadded+59
@@ -0,0 +1,59 @@+# CLAUDE.md — adom-desktop-puppeteer-bridge (READ THIS, it is YOUR project)++This repo is **the Adom Desktop Puppeteer ("pup") bridge** — the canonical source of truth, published+to `wiki.adom.inc/adom/adom-desktop-puppeteer-bridge`. If you are an Adom AI touching anything pup /+`browser_*` / Chrome-for-Testing, **you own and maintain this code here.** Adom Desktop (AD) ships a+bundled SEED of this bridge for first-run/offline, but the live code is THIS repo and AD auto-pulls it+via the `updateManifestUrl` in `bridge.json`. (Extracted from `adom-desktop/plugins/puppeteer` on+2026-06-29, mirroring the kicad/fusion cloud-owned model.)++## You vs adom-desktop (AD) core — the boundary++- **AD core owns:** the `adom-desktop` CLI, the relay / `/command` passthrough (relays your response+  VERBATIM incl. `_hint`/`errorCode`/`statusVerb`), bridge lifecycle (`bridge_install` / streaming this+  bridge into `…\bridges-cache\puppeteer`, the stable port, supervisor), `desktop_install_node`, and the+  `node_not_found` gate that fires BEFORE this bridge can spawn. AD also runs `npm install` in the bridge+  dir on spawn (this bridge ships source-only; node_modules is reconstructed there + present in the seed).+- **YOU (this bridge) own:** every `browser_*` / `desktop_record_*` verb + its response `_hint`s, the+  **Chrome-for-Testing self-heal** (`src/chrome.js` — detect / auto-install / readiness), the status-chip+  fields on `/status`, the `browser_describe` catalog, `SKILL.md` + bundled `skills/`, and the edit →+  publish → reinstall loop.+- So: a `browser_*` verb misbehaves, a hint is missing, Chrome-for-Testing won't install, the chip lies →+  it's **your code**, fix it here and republish. It is NOT an adom-desktop request. If a GENERIC AD+  capability is missing (a new lifecycle verb, calling `browser_prewarm` on embedded first-run), file+  that against AD — don't re-absorb the bridge.++## Map of what you manage (`src/` = the runnable bridge, zipped to the cache)++- `src/server.js` — the bridge HTTP server / verb router. Cold-start gate in `browser_open_window`;+  `/status` + `/health` (led/summary/tooltip chip); `browser_readiness` + `browser_prewarm` verbs.+- `src/chrome.js` — Chrome-for-Testing self-heal. detect (validates the real exe), install+  (programmatic via the bundled `@puppeteer/browsers`, pinned to puppeteer's expected buildId), the+  install-state singleton, `ensureChromeReady` (the gate), `readiness` (powers the verb + chip).+- `src/credential_vault.js` — keychain-backed HTTP-Basic creds for vendor portals.+- `src/bridge.json` + `src/BRIDGE_VERSION` — the manifest/version (KEEP IN LOCKSTEP).+- `src/package.json` / `package-lock.json` — the NODE deps (puppeteer, sharp, keytar). node_modules is+  gitignored; AD `npm install`s it on spawn and the bundled seed ships it.+- `src/recorder.html` / `welcome.html` / icons — recorder UI + assets.+- `adom-bridge-puppeteer-manifest.json` (repo root) — the streaming manifest AD's `updateManifestUrl`+  points at; its `url` is the RELEASE download URL of the zip, with matching `sha256`/`size`.+- `SKILL.md` + `skills/` — the consumer skill(s) installed into `~/.claude/skills/` (via `install.sh` /+  `adom-wiki pkg`). The cold-start playbook lives here.++## The publish/reinstall loop++See `PUBLISHING.md` for the full recipe. Short form: edit `src/` → bump `src/BRIDGE_VERSION` ++`src/bridge.json` together → build `adom-bridge-puppeteer-v<ver>.zip` from `src/` (zip-root, no top dir)+→ `adom-wiki release upload` the zip → point `adom-bridge-puppeteer-manifest.json` at that release URL+(with the zip's real sha256/size) → `adom-wiki repo push` the manifest + changed source + SKILL.md →+`adom-desktop bridge_install {"manifestUrl": ".../files/adom-bridge-puppeteer-manifest.json","force":true}`+to test → verify in pup. Wiki = canonical.++## Hard rules++- **The `.zip` is a RELEASE asset, never a git file.** `*.zip` is gitignored and `repo push --files X.zip`+  silently skips it. Only the small **manifest JSON** goes in the git repo, pointing at the release.+- **`sha256`/`size` in the manifest MUST match the served release asset** or `bridge_install` rejects it.+- **Never hand-edit the bridges-cache or the bundled seed.** They get clobbered. Edit `src/`, publish.+- **Source-only zip.** Do NOT zip node_modules (88 MB, wiki limits, native builds) — AD reconstructs it.+- Pup is **AD-owned-ish but cloud-managed from here now.** Don't republish it from the adom-desktop repo.
PUBLISHING.mdadded+86
@@ -0,0 +1,86 @@+# Building + publishing this bridge++How to publish a new version of the Adom Desktop Puppeteer bridge. The wiki has TWO storage layers —+put each artifact in the right one (every pitfall here was hit live on the kicad/fusion bridges).++| Artifact | Where it goes | How |+|---|---|---|+| Source (`src/*`, `SKILL.md`, the **manifest JSON**, docs) | the page's **git repo** | `adom-wiki repo push --files ...` |+| The build **`.zip`** (binary runtime) | a **release asset** | `adom-wiki release upload ...` |++> ⛔ The `.zip` is a RELEASE, not a git file. `*.zip` is gitignored and `repo push --files X.zip`+> silently skips it (reports ok, the URL 404s). Only the small **manifest JSON** goes in the git repo,+> and it POINTS AT the release asset.++## 1 — bump the version (two files, lockstep)++```bash+printf '1.1.1' > src/BRIDGE_VERSION+sed -i 's/"version": "1.1.0"/"version": "1.1.1"/' src/bridge.json+```++## 2 — build the zip (the `src/` tree at the zip ROOT, no top-level dir)++`zip` may be absent — use Python. node_modules is NOT included (AD `npm install`s it on spawn; the+bundled seed ships it):++```python+import zipfile, os, hashlib+root = "src"+out = "adom-bridge-puppeteer-v1.1.1.zip"+with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:+    for dirpath, dirs, files in os.walk(root):+        if "node_modules" in dirs: dirs.remove("node_modules")+        for f in files:+            full = os.path.join(dirpath, f)+            z.write(full, os.path.relpath(full, root))   # arcname relative to src/ → zip root+print("sha256:", hashlib.sha256(open(out,"rb").read()).hexdigest(), "size:", os.path.getsize(out))+```++## 3 — publish the zip as a RELEASE asset (preserves your sha)++```bash+adom-wiki release create  adom/adom-desktop-puppeteer-bridge 1.1.1                       # idempotent+adom-wiki release upload  adom/adom-desktop-puppeteer-bridge 1.1.1 adom-bridge-puppeteer-v1.1.1.zip --json+# JSON gives sha256, size, download_url (/download/adom/adom-desktop-puppeteer-bridge/1.1.1/<file>)+```++## 4 — point the manifest at the release + push it (text, git-ok)++`adom-bridge-puppeteer-manifest.json`:+```json+{ "manifest_version": 1, "name": "puppeteer", "version": "1.1.1",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.1.1/adom-bridge-puppeteer-v1.1.1.zip",+  "sha256": "<the release asset's sha256>", "size": <bytes>,+  "verbPrefixes": ["browser_","desktop_recorder_","desktop_record_"],+  "healthEndpoint": "/status", "statusVerb": "browser_readiness",+  "released_at": "<UTC ISO8601>" }+```+```bash+adom-wiki repo push adom/adom-desktop-puppeteer-bridge \+  --files adom-bridge-puppeteer-manifest.json src/bridge.json src/BRIDGE_VERSION src/server.js src/chrome.js SKILL.md \+  -m "v1.1.1: <what changed>"+```++> `sha256`/`size` MUST match the served asset or `bridge_install` rejects it. Re-download to confirm:+> `curl -sL <download_url> | sha256sum`.++## 5 — install into Adom Desktop + test++```bash+adom-desktop bridge_install '{"manifestUrl":"https://wiki.adom.inc/api/v1/pages/adom-desktop-puppeteer-bridge/files/adom-bridge-puppeteer-manifest.json","force":true}'+# server-only change → AD reaps + respawns the bridge from the new cache on the next browser_* call.+# Then: adom-desktop browser_readiness '{}'   (confirms the new verbs/code are live)+```++(Multiple desktops on one relay → pass `--target <name>`; `adom-desktop targets` lists them.)++## Pitfall checklist++- `.zip` → `release upload`, never `repo push` (gitignored + silently skipped).+- Manifest JSON → `repo push`; its `url` is the RELEASE download URL, not `/files/`.+- `sha256`/`size` must match the SERVED asset (re-download to confirm).+- `zip` binary may be absent → Python `zipfile`.+- node_modules is NEVER in the zip (size/limits/native builds) — AD reconstructs it on spawn.+- Keep `src/BRIDGE_VERSION` and `src/bridge.json` version in lockstep, and > the bundled seed so+  cache-over-bundled wins.
SKILL.md+137−5
@@ -1,7 +1,139 @@ ----name: adom-desktop-puppeteer-bridge-description: >-  Reference implementation of the Puppeteer bridge — Node + puppeteer-core + Chrome for Testing for headful browser automation, multi-window, per-tab CDP screencast recording.+name: pup+user-invocable: true+description: "Drive Chrome browser windows on the user's desktop via the adom-desktop CLI (the Puppeteer bridge): open/close windows + tabs, navigate, screenshot, eval JS, record, drive the user's REAL signed-in Chrome/Edge/Brave. Includes COLD-START SELF-HEAL — on a fresh PC the bridge auto-installs Chrome for Testing; poll browser_readiness. Trigger words: pup, puppeteer, browser window, open in pup, browser screenshot, browser reload, browser eval, visual debug, browser_open_window, browser_screenshot, browser_readiness, browser_prewarm, chrome for testing, open in browser." ----# Reference bridge: Puppeteer-Reference implementation of the Puppeteer bridge — Node + puppeteer-core + Chrome for Testing for headful browser automation, multi-window, per-tab CDP screencast recording.++# Pup — Puppeteer Browser Control (with cold-start self-heal)++Drive Chrome windows on the user's desktop from the cloud via the `adom-desktop` CLI. All verbs are+Bash commands (NOT MCP tools):++```bash+adom-desktop <command> '<json_args>'+```++## ⭐ FIRST-TIME / COLD-START — read this BEFORE you panic that "open in pup did nothing"++On a brand-new machine (e.g. the user just installed Hydrogen Desktop, which bundles Adom Desktop),+**two things pup needs are NOT pre-installed by the installer**: Node.js and **Chrome for Testing**+(the actual Chrome build pup drives). The bridge now handles both gracefully — your job is to read the+structured response and follow it, not to give up.++When you run `browser_open_window` on a cold PC, one of these happens — handle each:++| Response | What it means | What you do |+|---|---|---|+| `errorCode: "node_not_found"` | Node.js isn't installed (the bridge can't even start). | Run `adom-desktop desktop_install_node '{}'`, wait for it, then retry the `browser_open_window`. |+| `errorCode: "chrome_for_testing_installing"` (`installing:true`) | **Chrome for Testing is missing; the bridge is auto-downloading it now (~150 MB, one time).** This is EXPECTED on a fresh PC — NOT a failure. | **Poll `browser_readiness` until `ready:true`** (usually 1–3 min), THEN re-issue the exact same `browser_open_window`. You do NOT install anything yourself. Do NOT report failure to the user. |+| `errorCode: "chrome_install_failed"` / `chrome_download_failed` | The Chrome download itself failed. | Read `readiness.lastError`. It's almost always no network / a proxy blocking `storage.googleapis.com`, or low disk — NOT a Node problem. Retry `browser_prewarm`; if it persists, surface `lastError` to the user. Fallback: drive their real browser via native mode (below). |+| `ok:true` / a real session | Everything was already warm. | Proceed normally. |++### The clean cold-start recipe++```bash+# 1) (optional but nice) Warm Chrome for Testing WITHOUT opening a window first:+adom-desktop browser_prewarm '{}'              # returns fast; kicks off the install++# 2) Poll readiness until ready (the bridge installs CfT itself):+adom-desktop browser_readiness '{}'            # → {ready:false, installing:true, installProgressPct:42, ...}+#    ...wait a few seconds, poll again until → {ready:true, chromeForTestingInstalled:true}++# 3) Now open the window — it's instant:+adom-desktop browser_open_window '{"sessionId":"chip-fetcher","profile":"chip-fetcher","url":"<url>"}'+```++`browser_prewarm` and `browser_readiness` are the two cold-start verbs. `browser_prewarm '{"wait":true}'`+blocks until the install finishes instead of returning immediately. **Always surface a verb's `_hint`+field verbatim to the user when something isn't ready** — it tells you (and them) exactly what's happening.++> Why this matters: before v1.1.0, a fresh PC with no Chrome for Testing made `browser_open_window`+> fail with a generic "Failed to launch Chrome after 3 attempts" and no actionable hint — the AI+> flailed. Now the bridge detects it, installs it, and tells you to poll readiness. Don't reintroduce+> the panic; just follow the table above.++## How to Run Commands++```bash+adom-desktop browser_open_window '{"sessionId":"myapp","profile":"myapp","url":"http://localhost:3000"}'+adom-desktop browser_navigate    '{"sessionId":"myapp","url":"https://example.com"}'+adom-desktop browser_screenshot  '{"sessionId":"myapp"}'+adom-desktop browser_eval        '{"sessionId":"myapp","expr":"document.title"}'+adom-desktop browser_reload      '{"sessionId":"myapp"}'+```++**These are Bash commands, not MCP tools.** Always pass `sessionId` — `'{}'` targets the *active*+session, which may not be the one you mean.++## Core verbs++| Command | Description | Key args |+|---|---|---|+| `browser_readiness` | Cold-start probe: is Chrome for Testing installed/installing? | none |+| `browser_prewarm` | Install Chrome for Testing WITHOUT opening a window | `wait` (optional bool) |+| `browser_open_window` | Spawn a new session/window (CfT, or native via `nativeBrowser`+`nativeProfile`) | `sessionId`, `profile`, `url` |+| `browser_open_tab` | Add a tab to an existing session | `sessionId`, `url` |+| `browser_navigate` | Drive an existing tab to a URL (reuses — no new window) | `sessionId`, `url`, `tabId?` |+| `browser_screenshot` | Capture the page (lossless PNG) | `sessionId`, `fullPage?` |+| `browser_eval` | Run JS in the page, return the result | `sessionId`, `expr` |+| `browser_errors` | Console/page errors | `sessionId` |+| `browser_status` | All sessions + chrome readiness | none |+| `browser_reload` | Reload + clear error log | `sessionId` |+| `browser_close_window` | Close one session (destructive) | `sessionId` |+| `browser_alert_window` | Flash the taskbar (non-intrusive) | `sessionId` |++Run `adom-desktop browser_describe '{}'` for the full machine-readable verb catalog.++## Don't double-open windows++- New session entirely → `browser_open_window`.+- Reuse an existing session's tab → `browser_navigate` (with `tabId` if multiple tabs).+- A second view in the same session → `browser_open_tab` (NOT a second `browser_open_window`).++**Run `browser_status '{}'` before `browser_open_window`** at the start of a conversation (especially+after a context compaction) — if a session already shows the URL you're about to open, reuse it. Opening+the same content under a new `sessionId` leaves the user staring at two identical windows.++## One window, many tabs (multi-URL tasks)++For a vendor fetch / multi-doc review, put the URLs in **tabs of one window**, not separate windows:++```bash+adom-desktop browser_open_window '{"sessionId":"task","profile":"task","url":"https://first.example.com"}'+adom-desktop browser_open_tab    '{"sessionId":"task","url":"https://second.example.com"}'+adom-desktop browser_switch_tab  '{"sessionId":"task","tabId":"tab-2"}'+adom-desktop browser_screenshot  '{"sessionId":"task"}'+```++Pick a stable `profile` name across runs so the cookie jar persists (the user logs in once, reuses for+months).++## Verify what you opened — `ok:true` is not enough++`browser_open_window` returns `ok:true` as soon as navigation *starts*, not when content rendered.+Sanity-check: `browser_eval '{"sessionId":"...","expr":"document.title"}'` (fastest — 404s, login walls,+and anti-bot blocks all change the title), or a `browser_screenshot` when the title isn't discriminating.++## Driving the user's REAL Chrome/Edge/Brave (native mode)++The default path drives Chrome for Testing (a clean, isolated, fully-controllable Chromium). To drive+the user's **actual signed-in browser** (their cookies, org SSO, device enrollment), pass+`nativeBrowser`+`nativeProfile` (run `desktop_list_browsers` first):++```bash+adom-desktop browser_open_window '{"sessionId":"gcp","nativeBrowser":"chrome","nativeProfile":"Default","url":"https://console.cloud.google.com"}'+```++Native mode is the fallback when Chrome for Testing can't be installed (no network for the CfT+download) but the user's real browser is already on the machine. Never auto-escalate to native for a+plain "open in pup" — that's CfT.++## Key rules++- **Always pass `sessionId`.** Never `'{}'` for session-scoped verbs.+- **It's `adom-desktop browser_<cmd> <json>`** — there is no top-level `pup` subcommand.+- **No semicolons in `browser_eval`** — use the comma operator or an IIFE.+- **Sessions persist** (cookies/localStorage/IndexedDB) keyed by `profile` name — don't close+reopen+  reflexively; `browser_reload` or `browser_navigate` instead.+- **Surface `_hint` verbatim** when a verb reports it isn't ready or failed.+- **Never broadly kill Chrome** — use `browser_close_window` for a specific session.
adom-bridge-puppeteer-manifest.jsonadded+16
@@ -0,0 +1,16 @@+{+  "manifest_version": 1,+  "name": "puppeteer",+  "version": "1.1.0",+  "url": "https://wiki.adom.inc/download/adom/adom-desktop-puppeteer-bridge/1.1.0/adom-bridge-puppeteer-v1.1.0.zip",+  "sha256": "525eabb0350ea0eaf52a949e9b3026f5a4a035868afe9546a7478298b9539b8c",+  "size": 127135,+  "verbPrefixes": [+    "browser_",+    "desktop_recorder_",+    "desktop_record_"+  ],+  "healthEndpoint": "/status",+  "statusVerb": "browser_readiness",+  "released_at": "2026-06-29T20:30:00Z"+}
src/BRIDGE_VERSIONadded+1
@@ -0,0 +1 @@+1.1.0\ No newline at end of file
src/adom-icon.svgadded+12
@@ -0,0 +1,12 @@+<?xml version="1.0" encoding="UTF-8"?>+<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1061.33 1061.38">+  <defs>+    <style>+      .cls-1 {+        stroke-width: 0px;+      }+    </style>+  </defs>+  <path class="cls-1" d="M957.65,103.68c-116.2-116.2-282.77-138.02-372.05-48.73-36.23,36.23-54.16,85.18-54.92,138.34h0s0,.07,0,.07c0,.17,0,.35,0,.52v329.6c0,3.99,3.24,7.23,7.23,7.23h326.06c54.75.14,105.25-17.79,142.42-54.97,89.28-89.28,67.46-255.85-48.74-372.05Z"/>+  <path class="cls-1" d="M103.68,957.7c116.2,116.2,282.77,138.02,372.05,48.73,36.23-36.23,54.16-85.18,54.92-138.34h0s0-.07,0-.07c0-.17,0-.35,0-.52v-329.6c0-3.99-3.24-7.23-7.23-7.23H197.37c-54.75-.14-105.25,17.79-142.42,54.97-89.28,89.28-67.46,255.85,48.74,372.05Z"/>+</svg>\ No newline at end of file
src/bridge.jsonadded+77
@@ -0,0 +1,77 @@+{+  "manifest_version": 1,+  "name": "puppeteer",+  "displayName": "Puppeteer (Chrome for Testing + Native)",+  "version": "1.1.0",+  "description": "Cold-start self-heal (v1.1.0): on a fresh PC the bridge DETECTS that Chrome for Testing is missing, AUTO-INSTALLS it (programmatically via the bundled @puppeteer/browsers — no npx), and reports progress via browser_readiness / browser_prewarm, so a first-ever 'open in pup' works hands-off instead of failing. Headful browser automation. Default path drives Chrome for Testing (CfT) with managed pup profiles. v1.0.6+ optional native-drive: pass nativeBrowser+nativeProfile (from desktop_list_browsers) to browser_open_window to drive the user's REAL Chrome/Edge/Brave with the actual signed-in account + org policies + device enrollment intact. Smart flow: if the browser is CLOSED, AD launches it fresh immediately with NO prompt (nothing to disrupt) and reports back ready-to-drive (launchedFresh:true); if it's OPEN without the port, AD returns errorCode:'native_relaunch_required' so the caller asks the user, then re-call with nativeRelaunch:true. AD then closes only that browser's own PIDs cleanly (WM_CLOSE — Sessions saved; CfT bridge sessions + other Chromium left untouched) and relaunches with --restore-last-session so every tab/window comes back. If the debug port is policy-blocked (e.g. Google Workspace's RemoteDebuggingAllowed disabled — affects Chrome browser-wide, all profiles), native drive returns errorCode:'native_cdp_blocked' with a `diagnosis` of the likely cause (local registry policy vs cloud-managed) plus an `alternatives` list of other installed browsers — Edge usually escapes Google management — so the caller can ask the user which to use; AD won't close that browser again for it. The generic CfT path (browser_open_window with no nativeBrowser) always works as a fallback.",+  "homepage": "https://github.com/adom-inc/adom-desktop",+  "author": "Adom Inc.",+  "license": "MIT",+  "spawn": {+    "kind": "node",+    "entrypoint": "server.js",+    "port": 0,+    "healthEndpoint": "/status",+    "stopMethod": "kill",+    "killImageName": "node.exe"+  },+  "verbPrefixes": [+    "browser_",+    "desktop_recorder_",+    "desktop_record_"+  ],+  "verbs": [+    "browser_open_window",+    "browser_open_tab",+    "browser_close_tab",+    "browser_close_window",+    "browser_navigate",+    "browser_back",+    "browser_forward",+    "browser_reload",+    "browser_click",+    "browser_type",+    "browser_press_key",+    "browser_screenshot",+    "browser_evaluate",+    "browser_fetch_url",+    "browser_input_dispatch",+    "browser_readiness",+    "browser_prewarm",+    "browser_record_start",+    "browser_record_stop",+    "browser_record_status",+    "browser_record_list",+    "desktop_recorder_open",+    "desktop_record_start",+    "desktop_record_stop",+    "browser_describe"+  ],+  "statusVerb": "browser_readiness",+  "updateManifestUrl": "https://wiki.adom.inc/api/v1/pages/adom-desktop-puppeteer-bridge/files/adom-bridge-puppeteer-manifest.json",+  "dependencies": {+    "node": ">=20",+    "chrome": ">=120"+  },+  "category": "Browser Automation",+  "tags": [+    "puppeteer",+    "chrome",+    "headful",+    "recording"+  ],+  "docs": "https://wiki.adom.inc/adom/adom-desktop-puppeteer-bridge",+  "platforms": {+    "windows": {+      "supported": true+    },+    "macos": {+      "supported": false,+      "reason": "macOS port pending — puppeteer-core is cross-platform but the bridge's spawn config uses Windows-only killImageName 'node.exe' and Chrome for Testing path resolution hasn't been tested."+    },+    "linux": {+      "supported": false,+      "reason": "Linux port pending — same killImageName + Chrome resolution issues as macOS."+    }+  }+}
src/chrome.jsadded+237
@@ -0,0 +1,237 @@+// chrome.js — Chrome-for-Testing (CfT) self-heal for the Adom Desktop Puppeteer bridge.+//+// WHY THIS EXISTS (the cold-start bug it fixes):+//   HD bundles AD; installing HD installs AD; AD ships THIS bridge as a pre-installed+//   seed. But the HD/AD installer pre-installs NEITHER Node NOR Chrome for Testing.+//   So on a fresh PC, the first "open chip-fetcher in pup" used to:+//     - (Node missing) → AD returns node_not_found → desktop_install_node  [already good]+//     - (Node present, CfT missing) → server.js blindly spawned a nonexistent Chrome+//       binary 3×, then threw a plain dev-string ("...run npx puppeteer browsers+//       install chrome..."). No errorCode, no auto-install, no actionable hint → the+//       AI flailed and a human had to guess "they have no Chrome for Testing".+//+// WHAT THIS MODULE DOES:+//   - detectChrome(): validates the ACTUAL executable exists (not "some dir exists" —+//     the old coarse check let a partial/corrupt cache read as present and deferred+//     failure to puppeteer.launch()).+//   - installChrome(): installs CfT PROGRAMMATICALLY via the bundled @puppeteer/browsers+//     (no npx, no npm-registry round-trip; only the CfT bytes come from Google's CDN),+//     pinned to the EXACT buildId this puppeteer expects so launch() can find it.+//   - ensureChromeReady(): the gate the bridge calls before opening a window — returns+//     immediately with {state:'installing'} on a cold box (non-blocking) so the first+//     call answers with a pollable status instead of hanging 2–3 min.+//   - readiness(): the snapshot powering browser_readiness + the /status led/summary/tooltip.+//+// Everything here is self-contained in the bridge (no adom-desktop core changes).++'use strict';++const path = require('path');+const fs = require('fs');+const os = require('os');++let puppeteer = null;+try { puppeteer = require('puppeteer'); } catch (e) { /* surfaced via readiness().puppeteerLoadError */ }+const _puppeteerLoadError = puppeteer ? null : 'puppeteer module failed to require — node_modules likely missing (AD runs `npm install` on bridge spawn; if this persists the bundled deps are broken)';++// ── install-state singleton ──────────────────────────────────────────────────+// Shared across every command so concurrent open calls don't double-install and+// browser_readiness / the status chip can report live progress.+const state = {+  phase: 'unknown',      // 'unknown' | 'detecting' | 'installing' | 'ready' | 'failed'+  buildId: null,+  executablePath: null,+  progressPct: 0,        // 0..100 download progress+  bytesDownloaded: 0,+  bytesTotal: 0,+  startedAt: null,+  finishedAt: null,+  error: null,           // last failure message (cleared on success)+  errorCode: null,       // 'chrome_download_failed' | 'chrome_unsupported_platform' | ...+};+let _installInFlight = null; // dedup: the in-flight install promise, if any++function fileExists(p) {+  try { return !!p && fs.statSync(p).isFile(); } catch { return false; }+}++// Puppeteer's default cache (AD sets no PUPPETEER_CACHE_DIR) → ~/.cache/puppeteer.+function cacheDir() {+  return process.env.PUPPETEER_CACHE_DIR || path.join(os.homedir(), '.cache', 'puppeteer');+}++// The buildId THIS puppeteer is pinned to. executablePath() returns the EXPECTED+// path even when the browser isn't installed yet; the build dir encodes the id,+// e.g. .../chrome/win64-140.0.7259.2/chrome-win64/chrome.exe. Installing 'stable'+// instead could fetch a DIFFERENT version than this puppeteer supports → launch()+// would look for the pinned id and not find it. So we pin to exactly this.+function expectedBuildId() {+  try {+    const ep = puppeteer && puppeteer.executablePath ? puppeteer.executablePath() : null;+    if (!ep) return null;+    const m = ep.replace(/\\/g, '/').match(/\/chrome\/(?:win64|win32|mac(?:_arm)?|linux)-([0-9][0-9.]*)\//i);+    return m ? m[1] : null;+  } catch { return null; }+}++// Validate a USABLE Chrome for Testing is present (the actual exe, not a stray dir).+function detectChrome() {+  // 1) Where puppeteer expects it for the pinned build.+  try {+    const ep = puppeteer && puppeteer.executablePath ? puppeteer.executablePath() : null;+    if (fileExists(ep)) return { installed: true, executablePath: ep, source: 'puppeteer.executablePath', buildId: expectedBuildId() };+  } catch { /* fall through */ }+  // 2) Any installed Chrome in the cache, via @puppeteer/browsers (newest wins).+  try {+    const { computeExecutablePath, Browser } = require('@puppeteer/browsers');+    const bid = expectedBuildId();+    if (bid) {+      const ep = computeExecutablePath({ browser: Browser.CHROME, buildId: bid, cacheDir: cacheDir() });+      if (fileExists(ep)) return { installed: true, executablePath: ep, source: 'computeExecutablePath', buildId: bid };+    }+  } catch { /* fall through */ }+  return { installed: false, executablePath: null, buildId: expectedBuildId() };+}++// Programmatic install of the pinned CfT build. Resolves to { ok, executablePath, buildId }+// or { ok:false, errorCode, error }. Deduped via _installInFlight.+function installChrome({ onProgress } = {}) {+  if (_installInFlight) return _installInFlight;++  state.phase = 'installing';+  state.progressPct = 0;+  state.bytesDownloaded = 0;+  state.bytesTotal = 0;+  state.error = null;+  state.errorCode = null;+  state.startedAt = Date.now();+  state.finishedAt = null;++  _installInFlight = (async () => {+    try {+      const browsers = require('@puppeteer/browsers');+      const { install, Browser, detectBrowserPlatform, resolveBuildId } = browsers;+      const platform = detectBrowserPlatform();+      if (!platform) {+        state.phase = 'failed';+        state.errorCode = 'chrome_unsupported_platform';+        state.error = `Could not detect a Chrome-for-Testing platform for ${process.platform}/${process.arch}.`;+        return { ok: false, errorCode: state.errorCode, error: state.error };+      }+      // Pin to puppeteer's expected build; fall back to the channel's stable id.+      let buildId = expectedBuildId();+      if (!buildId) {+        try { buildId = await resolveBuildId(Browser.CHROME, platform, 'stable'); } catch { /* leave null */ }+      }+      if (!buildId) {+        state.phase = 'failed';+        state.errorCode = 'chrome_buildid_unresolved';+        state.error = 'Could not determine which Chrome-for-Testing build to install (puppeteer.executablePath() gave no build id and resolveBuildId failed — network or a broken puppeteer install).';+        return { ok: false, errorCode: state.errorCode, error: state.error };+      }+      state.buildId = buildId;+      console.log(`[chrome] installing Chrome for Testing ${buildId} (${platform}) into ${cacheDir()} ...`);++      const installed = await install({+        browser: Browser.CHROME,+        buildId,+        cacheDir: cacheDir(),+        downloadProgressCallback: (downloaded, total) => {+          state.bytesDownloaded = downloaded;+          state.bytesTotal = total;+          state.progressPct = total ? Math.round((downloaded / total) * 100) : 0;+          if (onProgress) { try { onProgress(state.progressPct, downloaded, total); } catch {} }+        },+      });++      const ep = (installed && installed.executablePath) || detectChrome().executablePath;+      if (!fileExists(ep)) {+        state.phase = 'failed';+        state.errorCode = 'chrome_install_incomplete';+        state.error = `Chrome for Testing install reported success but no executable was found at ${ep || '(unknown path)'}.`;+        return { ok: false, errorCode: state.errorCode, error: state.error };+      }+      state.phase = 'ready';+      state.executablePath = ep;+      state.progressPct = 100;+      state.finishedAt = Date.now();+      state.error = null;+      state.errorCode = null;+      console.log(`[chrome] Chrome for Testing ${buildId} ready at ${ep}`);+      return { ok: true, executablePath: ep, buildId };+    } catch (e) {+      state.phase = 'failed';+      state.errorCode = state.errorCode || 'chrome_download_failed';+      state.error = (e && e.message) || String(e);+      state.finishedAt = Date.now();+      console.error(`[chrome] install failed: ${state.error}`);+      return { ok: false, errorCode: state.errorCode, error: state.error };+    } finally {+      _installInFlight = null;+    }+  })();++  return _installInFlight;+}++// The gate the bridge calls before launching a window.+//   background:true  → kick the install off and return immediately (non-blocking),+//                      so the FIRST cold-start call answers with a pollable status+//                      instead of hanging 2–3 min while CfT downloads.+//   background:false → await the install (used by browser_prewarm when the caller+//                      explicitly wants to block until ready).+// Returns { ready, phase, progressPct, executablePath, errorCode, error }.+async function ensureChromeReady({ background = true, onProgress } = {}) {+  const det = detectChrome();+  if (det.installed) {+    state.phase = 'ready';+    state.executablePath = det.executablePath;+    state.buildId = det.buildId || state.buildId;+    return { ready: true, phase: 'ready', progressPct: 100, executablePath: det.executablePath };+  }+  if (_installInFlight) {+    // already installing — return live progress+    return { ready: false, phase: 'installing', progressPct: state.progressPct, executablePath: null };+  }+  if (background) {+    installChrome({ onProgress }); // fire-and-forget; readiness() tracks it+    return { ready: false, phase: 'installing', progressPct: 0, executablePath: null };+  }+  const r = await installChrome({ onProgress });+  return {+    ready: !!r.ok,+    phase: state.phase,+    progressPct: state.progressPct,+    executablePath: r.executablePath || null,+    errorCode: r.ok ? null : r.errorCode,+    error: r.ok ? null : r.error,+  };+}++// Snapshot for browser_readiness + the /status chip. nodeInstalled is implicitly+// true (this module only runs inside the node bridge process), but we surface it+// so the AI's readiness check is a single source of truth.+function readiness() {+  const det = detectChrome();+  const installing = !!_installInFlight || state.phase === 'installing';+  const ready = det.installed && !installing;+  return {+    ready,+    nodeInstalled: true,+    nodeVersion: process.version,+    chromeForTestingInstalled: det.installed,+    chromeExecutablePath: det.executablePath || state.executablePath || null,+    chromeBuildId: det.buildId || state.buildId || null,+    installing,+    installPhase: state.phase,+    installProgressPct: state.progressPct,+    bytesDownloaded: state.bytesDownloaded,+    bytesTotal: state.bytesTotal,+    lastError: state.error,+    lastErrorCode: state.errorCode,+    cacheDir: cacheDir(),+    puppeteerLoadError: _puppeteerLoadError,+  };+}++module.exports = { detectChrome, installChrome, ensureChromeReady, readiness, expectedBuildId, cacheDir };
src/credential_vault.jsadded+202
@@ -0,0 +1,202 @@+// credential_vault.js — HTTP Basic Auth credential vault for pup sessions.+//+// Why this exists+// ───────────────+// When a pup tab navigates to a host that issues an HTTP Basic Auth challenge+// (nxp.componentsearchengine.com, mouser.componentsearchengine.com, several+// vendor design portals), Chrome shows a NATIVE auth dialog. That dialog+// lives outside the page's DOM and CANNOT be driven via browser_eval / DOM+// manipulation. Without this vault, the user has to manually type the same+// email + password every single time. Users have called this "heinous."+//+// Puppeteer exposes `page.authenticate({username, password})`, which+// intercepts basic-auth challenges BEFORE the dialog shows and supplies+// stored creds programmatically. This module owns the storage + lookup+// half of that flow.+//+// Storage+// ───────+// Two parts:+//   1. The PASSWORD lives in the OS keychain via `keytar` (Windows DPAPI /+//      macOS Keychain / Linux libsecret). Service name `adom-desktop-pup`.+//      Account name is the host pattern. The OS encrypts at rest using the+//      logged-in-user's session key, so even disk theft can't extract creds.+//   2. The INDEX (host pattern + username + timestamps, NEVER passwords)+//      lives at `plugins/puppeteer/credentials.index.json` so we can list+//      entries fast without poking keytar 50 times.+//+// Host matching+// ─────────────+// Patterns support a simple glob:+//   "*.componentsearchengine.com"  → matches any subdomain (suffix match)+//   "componentsearchengine.com"    → exact match only+//   "secure.example.com"           → exact match only+// Best-match wins (longest non-glob suffix beats shorter glob).+// For a navigation to https://nxp.componentsearchengine.com/preview.php,+// host = "nxp.componentsearchengine.com" — would match "*.componentsearchengine.com".+//+// What this module does NOT do+// ────────────────────────────+//   - Ship its own crypto. Never. OS keychain only.+//   - Echo passwords back via list/get APIs. Index returns username + host only.+//   - Persist anything to the wiki / cloud. All local, all per-machine.++const fs = require('fs');+const path = require('path');++const KEYTAR_SERVICE = 'adom-desktop-pup';+const INDEX_PATH = path.join(__dirname, 'credentials.index.json');++let keytar = null;+let keytarLoadError = null;+try {+  // keytar is a native module; load lazily so the bridge starts even if it's+  // missing. The first credential_set call will surface a clear error.+  keytar = require('keytar');+} catch (e) {+  keytarLoadError = e.message;+  console.log(`[credential_vault] keytar load failed (will be required for credential_set): ${e.message}`);+}++// ── Index helpers ──────────────────────────────────────────────────────++function readIndex() {+  try {+    const raw = fs.readFileSync(INDEX_PATH, 'utf8');+    const parsed = JSON.parse(raw);+    if (Array.isArray(parsed.entries)) return parsed.entries;+  } catch {}+  return [];+}++function writeIndex(entries) {+  const data = { version: 1, entries };+  fs.writeFileSync(INDEX_PATH, JSON.stringify(data, null, 2));+}++// ── Glob matching ──────────────────────────────────────────────────────++function hostMatchesPattern(host, pattern) {+  if (!host || !pattern) return false;+  const h = host.toLowerCase();+  const p = pattern.toLowerCase();+  if (p === h) return true;+  if (p.startsWith('*.')) {+    const suffix = p.slice(1); // ".componentsearchengine.com"+    if (h === suffix.slice(1)) return true;     // bare apex is a valid match+    if (h.endsWith(suffix)) return true;+  }+  return false;+}++// "Best match" = longest non-glob suffix wins. Exact match beats glob match+// of the same length. So "nxp.componentsearchengine.com" entry would win+// over "*.componentsearchengine.com" entry for that exact host.+function scorePattern(pattern, host) {+  if (!hostMatchesPattern(host, pattern)) return -1;+  const exact = !pattern.startsWith('*.');+  return (exact ? 1000 : 0) + pattern.length;+}++// ── Public API ─────────────────────────────────────────────────────────++async function setCredential({ host, username, password }) {+  if (!host || !username || !password) {+    throw new Error('host, username, and password are all required');+  }+  if (!keytar) {+    throw new Error(+      `keytar is not installed in plugins/puppeteer/. Install: cd plugins/puppeteer && npm install keytar. Original load error: ${keytarLoadError}`+    );+  }+  await keytar.setPassword(KEYTAR_SERVICE, host, password);+  const entries = readIndex();+  const idx = entries.findIndex(e => e.host === host);+  const now = new Date().toISOString();+  if (idx >= 0) {+    entries[idx] = { ...entries[idx], username, updatedAt: now };+  } else {+    entries.push({ host, username, addedAt: now, updatedAt: now });+  }+  writeIndex(entries);+  return { ok: true, host, username };+}++async function getCredentialForUrl(url) {+  if (!keytar) return null;+  let host;+  try {+    host = new URL(url).hostname;+  } catch {+    return null;+  }+  const entries = readIndex();+  let best = null;+  let bestScore = -1;+  for (const e of entries) {+    const s = scorePattern(e.host, host);+    if (s > bestScore) {+      bestScore = s;+      best = e;+    }+  }+  if (!best) return null;+  const password = await keytar.getPassword(KEYTAR_SERVICE, best.host);+  if (!password) return null;+  return { host: best.host, username: best.username, password };+}++function listCredentials() {+  // Index already excludes passwords by construction+  return readIndex().map(e => ({+    host: e.host,+    username: e.username,+    addedAt: e.addedAt,+    updatedAt: e.updatedAt,+  }));+}++async function deleteCredential(host) {+  if (!host) throw new Error('host required');+  let removed = false;+  if (keytar) {+    try {+      removed = await keytar.deletePassword(KEYTAR_SERVICE, host);+    } catch (e) {+      console.log(`[credential_vault] keytar deletePassword error: ${e.message}`);+    }+  }+  const entries = readIndex();+  const before = entries.length;+  const next = entries.filter(e => e.host !== host);+  if (next.length !== before) {+    writeIndex(next);+    return { ok: true, host, removedFromKeychain: removed, removedFromIndex: true };+  }+  return { ok: true, host, removedFromKeychain: removed, removedFromIndex: false };+}++// Apply stored creds to a page given the target URL it's about to load.+// Idempotent: if no match, no-op. Returns the matched host pattern (or null).+async function applyCredentialsToPage(page, url) {+  const creds = await getCredentialForUrl(url);+  if (!creds) return null;+  try {+    await page.authenticate({ username: creds.username, password: creds.password });+    return creds.host;+  } catch (e) {+    console.log(`[credential_vault] page.authenticate failed for ${creds.host}: ${e.message}`);+    return null;+  }+}++module.exports = {+  setCredential,+  getCredentialForUrl,+  listCredentials,+  deleteCredential,+  applyCredentialsToPage,+  // Exposed for tests + diagnostics+  hostMatchesPattern,+  scorePattern,+};
src/favicon.svgadded+1
@@ -0,0 +1 @@+<?xml version="1.0" encoding="UTF-8"?><svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 244 244"><defs><style>.cls-1{fill:none;}.cls-1,.cls-2,.cls-3,.cls-4{stroke-width:0px;}.cls-2{fill:#00b8b0;}.cls-3{fill:#fff;}.cls-4{fill:#003d40;}.cls-5{clip-path:url(#clippath);}</style><clipPath id="clippath"><rect class="cls-1" x="2.33" y="2.33" width="239.33" height="239.33"/></clipPath></defs><path class="cls-3" d="M184.3,59.7c-16.95-16.95-41.26-20.14-54.29-7.11-5.29,5.29-7.9,12.43-8.01,20.18h0s0,0,0,0c0,.03,0,.05,0,.08v48.09c0,.58.47,1.05,1.05,1.05h47.57-.01c7.99,0,15.37-2.6,20.79-8.02,13.03-13.03,9.84-37.33-7.11-54.29Z"/><path class="cls-3" d="M59.7,184.3c16.95,16.95,41.26,20.14,54.29,7.11,5.29-5.29,7.9-12.43,8.01-20.18h0s0,0,0,0c0-.03,0-.05,0-.08v-48.09c0-.58-.47-1.05-1.05-1.05h-47.57.01c-7.99,0-15.37,2.6-20.79,8.02-13.03,13.03-9.84,37.33,7.11,54.29Z"/><g class="cls-5"><path class="cls-2" d="M206.67,241.67H37.33c-19.33,0-34.99-15.67-34.99-34.99V37.33C2.33,18,18,2.33,37.33,2.33h169.34c19.33,0,34.99,15.67,34.99,34.99v169.34c0,19.33-15.67,34.99-34.99,34.99"/><path class="cls-4" d="M173.76,70.24c-14.09-14.09-34.28-16.73-45.1-5.91-4.39,4.39-6.56,10.33-6.66,16.77h0v40.03c0,.48.39.88.88.88h39.52,0c6.64,0,12.77-2.16,17.28-6.66,10.82-10.82,8.18-31.02-5.91-45.1"/><path class="cls-4" d="M70.24,173.76c14.09,14.09,34.28,16.73,45.1,5.91,4.39-4.39,6.56-10.33,6.66-16.77h0v-40.03c0-.48-.39-.88-.88-.88h-39.52,0c-6.64,0-12.77,2.16-17.28,6.66-10.82,10.82-8.18,31.02,5.91,45.1"/></g></svg>\ No newline at end of file
src/icons/microphone.svgadded+4
@@ -0,0 +1,4 @@+<!-- MDI 'microphone' equivalent — monochrome, currentColor inherits from CSS -->+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">+  <path d="M12 2c-1.66 0-3 1.34-3 3v6c0 1.66 1.34 3 3 3s3-1.34 3-3V5c0-1.66-1.34-3-3-3zm5.91 9c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/>+</svg>
src/icons/monitor.svgadded+4
@@ -0,0 +1,4 @@+<!-- MDI 'monitor' equivalent — monochrome, currentColor inherits from CSS -->+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">+  <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/>+</svg>
src/package-lock.jsonadded+2047
@@ -0,0 +1,2047 @@+{+  "name": "adom-puppeteer-bridge",+  "version": "1.0.0",+  "lockfileVersion": 3,+  "requires": true,+  "packages": {+    "": {+      "name": "adom-puppeteer-bridge",+      "version": "1.0.0",+      "dependencies": {+        "keytar": "^7.9.0",+        "puppeteer": "^24.38.0",+        "sharp": "^0.34.5"+      }+    },+    "node_modules/@babel/code-frame": {+      "version": "7.29.0",+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",+      "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",+      "license": "MIT",+      "dependencies": {+        "@babel/helper-validator-identifier": "^7.28.5",+        "js-tokens": "^4.0.0",+        "picocolors": "^1.1.1"+      },+      "engines": {+        "node": ">=6.9.0"+      }+    },+    "node_modules/@babel/helper-validator-identifier": {+      "version": "7.28.5",+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",+      "license": "MIT",+      "engines": {+        "node": ">=6.9.0"+      }+    },+    "node_modules/@emnapi/runtime": {+      "version": "1.9.0",+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz",+      "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==",+      "license": "MIT",+      "optional": true,+      "dependencies": {+        "tslib": "^2.4.0"+      }+    },+    "node_modules/@img/colour": {+      "version": "1.1.0",+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",+      "license": "MIT",+      "engines": {+        "node": ">=18"+      }+    },+    "node_modules/@img/sharp-darwin-arm64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",+      "cpu": [+        "arm64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "darwin"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-darwin-arm64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-darwin-x64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",+      "cpu": [+        "x64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "darwin"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-darwin-x64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-libvips-darwin-arm64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",+      "cpu": [+        "arm64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "darwin"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-darwin-x64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",+      "cpu": [+        "x64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "darwin"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linux-arm": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",+      "cpu": [+        "arm"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linux-arm64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",+      "cpu": [+        "arm64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linux-ppc64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",+      "cpu": [+        "ppc64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linux-riscv64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",+      "cpu": [+        "riscv64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linux-s390x": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",+      "cpu": [+        "s390x"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linux-x64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",+      "cpu": [+        "x64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",+      "cpu": [+        "arm64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",+      "cpu": [+        "x64"+      ],+      "license": "LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "linux"+      ],+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-linux-arm": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",+      "cpu": [+        "arm"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linux-arm": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linux-arm64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",+      "cpu": [+        "arm64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linux-arm64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linux-ppc64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",+      "cpu": [+        "ppc64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linux-ppc64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linux-riscv64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",+      "cpu": [+        "riscv64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linux-riscv64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linux-s390x": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",+      "cpu": [+        "s390x"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linux-s390x": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linux-x64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",+      "cpu": [+        "x64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linux-x64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linuxmusl-arm64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",+      "cpu": [+        "arm64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-linuxmusl-x64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",+      "cpu": [+        "x64"+      ],+      "license": "Apache-2.0",+      "optional": true,+      "os": [+        "linux"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"+      }+    },+    "node_modules/@img/sharp-wasm32": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",+      "cpu": [+        "wasm32"+      ],+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",+      "optional": true,+      "dependencies": {+        "@emnapi/runtime": "^1.7.0"+      },+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-win32-arm64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",+      "cpu": [+        "arm64"+      ],+      "license": "Apache-2.0 AND LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "win32"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-win32-ia32": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",+      "cpu": [+        "ia32"+      ],+      "license": "Apache-2.0 AND LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "win32"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@img/sharp-win32-x64": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",+      "cpu": [+        "x64"+      ],+      "license": "Apache-2.0 AND LGPL-3.0-or-later",+      "optional": true,+      "os": [+        "win32"+      ],+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      }+    },+    "node_modules/@puppeteer/browsers": {+      "version": "2.13.0",+      "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.13.0.tgz",+      "integrity": "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA==",+      "license": "Apache-2.0",+      "dependencies": {+        "debug": "^4.4.3",+        "extract-zip": "^2.0.1",+        "progress": "^2.0.3",+        "proxy-agent": "^6.5.0",+        "semver": "^7.7.4",+        "tar-fs": "^3.1.1",+        "yargs": "^17.7.2"+      },+      "bin": {+        "browsers": "lib/cjs/main-cli.js"+      },+      "engines": {+        "node": ">=18"+      }+    },+    "node_modules/@tootallnate/quickjs-emscripten": {+      "version": "0.23.0",+      "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",+      "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",+      "license": "MIT"+    },+    "node_modules/@types/node": {+      "version": "25.5.0",+      "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",+      "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",+      "license": "MIT",+      "optional": true,+      "dependencies": {+        "undici-types": "~7.18.0"+      }+    },+    "node_modules/@types/yauzl": {+      "version": "2.10.3",+      "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",+      "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",+      "license": "MIT",+      "optional": true,+      "dependencies": {+        "@types/node": "*"+      }+    },+    "node_modules/agent-base": {+      "version": "7.1.4",+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",+      "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",+      "license": "MIT",+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/ansi-regex": {+      "version": "5.0.1",+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",+      "license": "MIT",+      "engines": {+        "node": ">=8"+      }+    },+    "node_modules/ansi-styles": {+      "version": "4.3.0",+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",+      "license": "MIT",+      "dependencies": {+        "color-convert": "^2.0.1"+      },+      "engines": {+        "node": ">=8"+      },+      "funding": {+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"+      }+    },+    "node_modules/argparse": {+      "version": "2.0.1",+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",+      "license": "Python-2.0"+    },+    "node_modules/ast-types": {+      "version": "0.13.4",+      "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",+      "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",+      "license": "MIT",+      "dependencies": {+        "tslib": "^2.0.1"+      },+      "engines": {+        "node": ">=4"+      }+    },+    "node_modules/b4a": {+      "version": "1.8.0",+      "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz",+      "integrity": "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg==",+      "license": "Apache-2.0",+      "peerDependencies": {+        "react-native-b4a": "*"+      },+      "peerDependenciesMeta": {+        "react-native-b4a": {+          "optional": true+        }+      }+    },+    "node_modules/bare-events": {+      "version": "2.8.2",+      "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz",+      "integrity": "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==",+      "license": "Apache-2.0",+      "peerDependencies": {+        "bare-abort-controller": "*"+      },+      "peerDependenciesMeta": {+        "bare-abort-controller": {+          "optional": true+        }+      }+    },+    "node_modules/bare-fs": {+      "version": "4.5.5",+      "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz",+      "integrity": "sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w==",+      "license": "Apache-2.0",+      "dependencies": {+        "bare-events": "^2.5.4",+        "bare-path": "^3.0.0",+        "bare-stream": "^2.6.4",+        "bare-url": "^2.2.2",+        "fast-fifo": "^1.3.2"+      },+      "engines": {+        "bare": ">=1.16.0"+      },+      "peerDependencies": {+        "bare-buffer": "*"+      },+      "peerDependenciesMeta": {+        "bare-buffer": {+          "optional": true+        }+      }+    },+    "node_modules/bare-os": {+      "version": "3.8.0",+      "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.8.0.tgz",+      "integrity": "sha512-Dc9/SlwfxkXIGYhvMQNUtKaXCaGkZYGcd1vuNUUADVqzu4/vQfvnMkYYOUnt2VwQ2AqKr/8qAVFRtwETljgeFg==",+      "license": "Apache-2.0",+      "engines": {+        "bare": ">=1.14.0"+      }+    },+    "node_modules/bare-path": {+      "version": "3.0.0",+      "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",+      "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",+      "license": "Apache-2.0",+      "dependencies": {+        "bare-os": "^3.0.1"+      }+    },+    "node_modules/bare-stream": {+      "version": "2.8.1",+      "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz",+      "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==",+      "license": "Apache-2.0",+      "dependencies": {+        "streamx": "^2.21.0",+        "teex": "^1.0.1"+      },+      "peerDependencies": {+        "bare-buffer": "*",+        "bare-events": "*"+      },+      "peerDependenciesMeta": {+        "bare-buffer": {+          "optional": true+        },+        "bare-events": {+          "optional": true+        }+      }+    },+    "node_modules/bare-url": {+      "version": "2.3.2",+      "resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz",+      "integrity": "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw==",+      "license": "Apache-2.0",+      "dependencies": {+        "bare-path": "^3.0.0"+      }+    },+    "node_modules/base64-js": {+      "version": "1.5.1",+      "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "license": "MIT"+    },+    "node_modules/basic-ftp": {+      "version": "5.2.0",+      "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.2.0.tgz",+      "integrity": "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==",+      "license": "MIT",+      "engines": {+        "node": ">=10.0.0"+      }+    },+    "node_modules/bl": {+      "version": "4.1.0",+      "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",+      "license": "MIT",+      "dependencies": {+        "buffer": "^5.5.0",+        "inherits": "^2.0.4",+        "readable-stream": "^3.4.0"+      }+    },+    "node_modules/buffer": {+      "version": "5.7.1",+      "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "license": "MIT",+      "dependencies": {+        "base64-js": "^1.3.1",+        "ieee754": "^1.1.13"+      }+    },+    "node_modules/buffer-crc32": {+      "version": "0.2.13",+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",+      "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",+      "license": "MIT",+      "engines": {+        "node": "*"+      }+    },+    "node_modules/callsites": {+      "version": "3.1.0",+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",+      "license": "MIT",+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/chownr": {+      "version": "1.1.4",+      "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",+      "license": "ISC"+    },+    "node_modules/chromium-bidi": {+      "version": "14.0.0",+      "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-14.0.0.tgz",+      "integrity": "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==",+      "license": "Apache-2.0",+      "dependencies": {+        "mitt": "^3.0.1",+        "zod": "^3.24.1"+      },+      "peerDependencies": {+        "devtools-protocol": "*"+      }+    },+    "node_modules/cliui": {+      "version": "8.0.1",+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",+      "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",+      "license": "ISC",+      "dependencies": {+        "string-width": "^4.2.0",+        "strip-ansi": "^6.0.1",+        "wrap-ansi": "^7.0.0"+      },+      "engines": {+        "node": ">=12"+      }+    },+    "node_modules/color-convert": {+      "version": "2.0.1",+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",+      "license": "MIT",+      "dependencies": {+        "color-name": "~1.1.4"+      },+      "engines": {+        "node": ">=7.0.0"+      }+    },+    "node_modules/color-name": {+      "version": "1.1.4",+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",+      "license": "MIT"+    },+    "node_modules/cosmiconfig": {+      "version": "9.0.1",+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",+      "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==",+      "license": "MIT",+      "dependencies": {+        "env-paths": "^2.2.1",+        "import-fresh": "^3.3.0",+        "js-yaml": "^4.1.0",+        "parse-json": "^5.2.0"+      },+      "engines": {+        "node": ">=14"+      },+      "funding": {+        "url": "https://github.com/sponsors/d-fischer"+      },+      "peerDependencies": {+        "typescript": ">=4.9.5"+      },+      "peerDependenciesMeta": {+        "typescript": {+          "optional": true+        }+      }+    },+    "node_modules/data-uri-to-buffer": {+      "version": "6.0.2",+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",+      "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",+      "license": "MIT",+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/debug": {+      "version": "4.4.3",+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",+      "license": "MIT",+      "dependencies": {+        "ms": "^2.1.3"+      },+      "engines": {+        "node": ">=6.0"+      },+      "peerDependenciesMeta": {+        "supports-color": {+          "optional": true+        }+      }+    },+    "node_modules/decompress-response": {+      "version": "6.0.0",+      "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",+      "license": "MIT",+      "dependencies": {+        "mimic-response": "^3.1.0"+      },+      "engines": {+        "node": ">=10"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/deep-extend": {+      "version": "0.6.0",+      "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",+      "license": "MIT",+      "engines": {+        "node": ">=4.0.0"+      }+    },+    "node_modules/degenerator": {+      "version": "5.0.1",+      "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",+      "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",+      "license": "MIT",+      "dependencies": {+        "ast-types": "^0.13.4",+        "escodegen": "^2.1.0",+        "esprima": "^4.0.1"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/detect-libc": {+      "version": "2.1.2",+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",+      "license": "Apache-2.0",+      "engines": {+        "node": ">=8"+      }+    },+    "node_modules/devtools-protocol": {+      "version": "0.0.1581282",+      "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",+      "integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",+      "license": "BSD-3-Clause"+    },+    "node_modules/emoji-regex": {+      "version": "8.0.0",+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",+      "license": "MIT"+    },+    "node_modules/end-of-stream": {+      "version": "1.4.5",+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",+      "license": "MIT",+      "dependencies": {+        "once": "^1.4.0"+      }+    },+    "node_modules/env-paths": {+      "version": "2.2.1",+      "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",+      "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",+      "license": "MIT",+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/error-ex": {+      "version": "1.3.4",+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",+      "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",+      "license": "MIT",+      "dependencies": {+        "is-arrayish": "^0.2.1"+      }+    },+    "node_modules/escalade": {+      "version": "3.2.0",+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",+      "license": "MIT",+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/escodegen": {+      "version": "2.1.0",+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",+      "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",+      "license": "BSD-2-Clause",+      "dependencies": {+        "esprima": "^4.0.1",+        "estraverse": "^5.2.0",+        "esutils": "^2.0.2"+      },+      "bin": {+        "escodegen": "bin/escodegen.js",+        "esgenerate": "bin/esgenerate.js"+      },+      "engines": {+        "node": ">=6.0"+      },+      "optionalDependencies": {+        "source-map": "~0.6.1"+      }+    },+    "node_modules/esprima": {+      "version": "4.0.1",+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",+      "license": "BSD-2-Clause",+      "bin": {+        "esparse": "bin/esparse.js",+        "esvalidate": "bin/esvalidate.js"+      },+      "engines": {+        "node": ">=4"+      }+    },+    "node_modules/estraverse": {+      "version": "5.3.0",+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",+      "license": "BSD-2-Clause",+      "engines": {+        "node": ">=4.0"+      }+    },+    "node_modules/esutils": {+      "version": "2.0.3",+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",+      "license": "BSD-2-Clause",+      "engines": {+        "node": ">=0.10.0"+      }+    },+    "node_modules/events-universal": {+      "version": "1.0.1",+      "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",+      "integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",+      "license": "Apache-2.0",+      "dependencies": {+        "bare-events": "^2.7.0"+      }+    },+    "node_modules/expand-template": {+      "version": "2.0.3",+      "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",+      "license": "(MIT OR WTFPL)",+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/extract-zip": {+      "version": "2.0.1",+      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",+      "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",+      "license": "BSD-2-Clause",+      "dependencies": {+        "debug": "^4.1.1",+        "get-stream": "^5.1.0",+        "yauzl": "^2.10.0"+      },+      "bin": {+        "extract-zip": "cli.js"+      },+      "engines": {+        "node": ">= 10.17.0"+      },+      "optionalDependencies": {+        "@types/yauzl": "^2.9.1"+      }+    },+    "node_modules/fast-fifo": {+      "version": "1.3.2",+      "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",+      "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",+      "license": "MIT"+    },+    "node_modules/fd-slicer": {+      "version": "1.1.0",+      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",+      "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",+      "license": "MIT",+      "dependencies": {+        "pend": "~1.2.0"+      }+    },+    "node_modules/fs-constants": {+      "version": "1.0.0",+      "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",+      "license": "MIT"+    },+    "node_modules/get-caller-file": {+      "version": "2.0.5",+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",+      "license": "ISC",+      "engines": {+        "node": "6.* || 8.* || >= 10.*"+      }+    },+    "node_modules/get-stream": {+      "version": "5.2.0",+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",+      "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",+      "license": "MIT",+      "dependencies": {+        "pump": "^3.0.0"+      },+      "engines": {+        "node": ">=8"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/get-uri": {+      "version": "6.0.5",+      "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.5.tgz",+      "integrity": "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==",+      "license": "MIT",+      "dependencies": {+        "basic-ftp": "^5.0.2",+        "data-uri-to-buffer": "^6.0.2",+        "debug": "^4.3.4"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/github-from-package": {+      "version": "0.0.0",+      "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",+      "license": "MIT"+    },+    "node_modules/http-proxy-agent": {+      "version": "7.0.2",+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",+      "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",+      "license": "MIT",+      "dependencies": {+        "agent-base": "^7.1.0",+        "debug": "^4.3.4"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/https-proxy-agent": {+      "version": "7.0.6",+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",+      "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",+      "license": "MIT",+      "dependencies": {+        "agent-base": "^7.1.2",+        "debug": "4"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/ieee754": {+      "version": "1.2.1",+      "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "license": "BSD-3-Clause"+    },+    "node_modules/import-fresh": {+      "version": "3.3.1",+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",+      "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",+      "license": "MIT",+      "dependencies": {+        "parent-module": "^1.0.0",+        "resolve-from": "^4.0.0"+      },+      "engines": {+        "node": ">=6"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/inherits": {+      "version": "2.0.4",+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",+      "license": "ISC"+    },+    "node_modules/ini": {+      "version": "1.3.8",+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",+      "license": "ISC"+    },+    "node_modules/ip-address": {+      "version": "10.1.0",+      "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",+      "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",+      "license": "MIT",+      "engines": {+        "node": ">= 12"+      }+    },+    "node_modules/is-arrayish": {+      "version": "0.2.1",+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",+      "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",+      "license": "MIT"+    },+    "node_modules/is-fullwidth-code-point": {+      "version": "3.0.0",+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",+      "license": "MIT",+      "engines": {+        "node": ">=8"+      }+    },+    "node_modules/js-tokens": {+      "version": "4.0.0",+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",+      "license": "MIT"+    },+    "node_modules/js-yaml": {+      "version": "4.1.1",+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",+      "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",+      "license": "MIT",+      "dependencies": {+        "argparse": "^2.0.1"+      },+      "bin": {+        "js-yaml": "bin/js-yaml.js"+      }+    },+    "node_modules/json-parse-even-better-errors": {+      "version": "2.3.1",+      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",+      "license": "MIT"+    },+    "node_modules/keytar": {+      "version": "7.9.0",+      "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz",+      "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==",+      "hasInstallScript": true,+      "license": "MIT",+      "dependencies": {+        "node-addon-api": "^4.3.0",+        "prebuild-install": "^7.0.1"+      }+    },+    "node_modules/lines-and-columns": {+      "version": "1.2.4",+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",+      "license": "MIT"+    },+    "node_modules/lru-cache": {+      "version": "7.18.3",+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",+      "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",+      "license": "ISC",+      "engines": {+        "node": ">=12"+      }+    },+    "node_modules/mimic-response": {+      "version": "3.1.0",+      "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",+      "license": "MIT",+      "engines": {+        "node": ">=10"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/minimist": {+      "version": "1.2.8",+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",+      "license": "MIT",+      "funding": {+        "url": "https://github.com/sponsors/ljharb"+      }+    },+    "node_modules/mitt": {+      "version": "3.0.1",+      "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",+      "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",+      "license": "MIT"+    },+    "node_modules/mkdirp-classic": {+      "version": "0.5.3",+      "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",+      "license": "MIT"+    },+    "node_modules/ms": {+      "version": "2.1.3",+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",+      "license": "MIT"+    },+    "node_modules/napi-build-utils": {+      "version": "2.0.0",+      "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",+      "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",+      "license": "MIT"+    },+    "node_modules/netmask": {+      "version": "2.0.2",+      "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",+      "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",+      "license": "MIT",+      "engines": {+        "node": ">= 0.4.0"+      }+    },+    "node_modules/node-abi": {+      "version": "3.90.0",+      "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.90.0.tgz",+      "integrity": "sha512-pZNQT7UnYlMwMBy5N1lV5X/YLTbZM5ncytN3xL7CHEzhDN8uVe0u55yaPUJICIJjaCW8NrM5BFdqr7HLweStNA==",+      "license": "MIT",+      "dependencies": {+        "semver": "^7.3.5"+      },+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/node-addon-api": {+      "version": "4.3.0",+      "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz",+      "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==",+      "license": "MIT"+    },+    "node_modules/once": {+      "version": "1.4.0",+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",+      "license": "ISC",+      "dependencies": {+        "wrappy": "1"+      }+    },+    "node_modules/pac-proxy-agent": {+      "version": "7.2.0",+      "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",+      "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",+      "license": "MIT",+      "dependencies": {+        "@tootallnate/quickjs-emscripten": "^0.23.0",+        "agent-base": "^7.1.2",+        "debug": "^4.3.4",+        "get-uri": "^6.0.1",+        "http-proxy-agent": "^7.0.0",+        "https-proxy-agent": "^7.0.6",+        "pac-resolver": "^7.0.1",+        "socks-proxy-agent": "^8.0.5"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/pac-resolver": {+      "version": "7.0.1",+      "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",+      "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",+      "license": "MIT",+      "dependencies": {+        "degenerator": "^5.0.0",+        "netmask": "^2.0.2"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/parent-module": {+      "version": "1.0.1",+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",+      "license": "MIT",+      "dependencies": {+        "callsites": "^3.0.0"+      },+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/parse-json": {+      "version": "5.2.0",+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",+      "license": "MIT",+      "dependencies": {+        "@babel/code-frame": "^7.0.0",+        "error-ex": "^1.3.1",+        "json-parse-even-better-errors": "^2.3.0",+        "lines-and-columns": "^1.1.6"+      },+      "engines": {+        "node": ">=8"+      },+      "funding": {+        "url": "https://github.com/sponsors/sindresorhus"+      }+    },+    "node_modules/pend": {+      "version": "1.2.0",+      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",+      "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",+      "license": "MIT"+    },+    "node_modules/picocolors": {+      "version": "1.1.1",+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",+      "license": "ISC"+    },+    "node_modules/prebuild-install": {+      "version": "7.1.3",+      "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",+      "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",+      "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",+      "license": "MIT",+      "dependencies": {+        "detect-libc": "^2.0.0",+        "expand-template": "^2.0.3",+        "github-from-package": "0.0.0",+        "minimist": "^1.2.3",+        "mkdirp-classic": "^0.5.3",+        "napi-build-utils": "^2.0.0",+        "node-abi": "^3.3.0",+        "pump": "^3.0.0",+        "rc": "^1.2.7",+        "simple-get": "^4.0.0",+        "tar-fs": "^2.0.0",+        "tunnel-agent": "^0.6.0"+      },+      "bin": {+        "prebuild-install": "bin.js"+      },+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/prebuild-install/node_modules/tar-fs": {+      "version": "2.1.4",+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",+      "license": "MIT",+      "dependencies": {+        "chownr": "^1.1.1",+        "mkdirp-classic": "^0.5.2",+        "pump": "^3.0.0",+        "tar-stream": "^2.1.4"+      }+    },+    "node_modules/prebuild-install/node_modules/tar-stream": {+      "version": "2.2.0",+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",+      "license": "MIT",+      "dependencies": {+        "bl": "^4.0.3",+        "end-of-stream": "^1.4.1",+        "fs-constants": "^1.0.0",+        "inherits": "^2.0.3",+        "readable-stream": "^3.1.1"+      },+      "engines": {+        "node": ">=6"+      }+    },+    "node_modules/progress": {+      "version": "2.0.3",+      "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",+      "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",+      "license": "MIT",+      "engines": {+        "node": ">=0.4.0"+      }+    },+    "node_modules/proxy-agent": {+      "version": "6.5.0",+      "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",+      "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",+      "license": "MIT",+      "dependencies": {+        "agent-base": "^7.1.2",+        "debug": "^4.3.4",+        "http-proxy-agent": "^7.0.1",+        "https-proxy-agent": "^7.0.6",+        "lru-cache": "^7.14.1",+        "pac-proxy-agent": "^7.1.0",+        "proxy-from-env": "^1.1.0",+        "socks-proxy-agent": "^8.0.5"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/proxy-from-env": {+      "version": "1.1.0",+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",+      "license": "MIT"+    },+    "node_modules/pump": {+      "version": "3.0.4",+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",+      "license": "MIT",+      "dependencies": {+        "end-of-stream": "^1.1.0",+        "once": "^1.3.1"+      }+    },+    "node_modules/puppeteer": {+      "version": "24.39.1",+      "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.1.tgz",+      "integrity": "sha512-68Zc9QpcVvfxp2C+3UL88TyUogEAn5tSylXidbEuEXvhiqK1+v65zeBU5ubinAgEHMGr3dcSYqvYrGtdzsPI3w==",+      "hasInstallScript": true,+      "license": "Apache-2.0",+      "dependencies": {+        "@puppeteer/browsers": "2.13.0",+        "chromium-bidi": "14.0.0",+        "cosmiconfig": "^9.0.0",+        "devtools-protocol": "0.0.1581282",+        "puppeteer-core": "24.39.1",+        "typed-query-selector": "^2.12.1"+      },+      "bin": {+        "puppeteer": "lib/cjs/puppeteer/node/cli.js"+      },+      "engines": {+        "node": ">=18"+      }+    },+    "node_modules/puppeteer-core": {+      "version": "24.39.1",+      "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.1.tgz",+      "integrity": "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A==",+      "license": "Apache-2.0",+      "dependencies": {+        "@puppeteer/browsers": "2.13.0",+        "chromium-bidi": "14.0.0",+        "debug": "^4.4.3",+        "devtools-protocol": "0.0.1581282",+        "typed-query-selector": "^2.12.1",+        "webdriver-bidi-protocol": "0.4.1",+        "ws": "^8.19.0"+      },+      "engines": {+        "node": ">=18"+      }+    },+    "node_modules/rc": {+      "version": "1.2.8",+      "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",+      "dependencies": {+        "deep-extend": "^0.6.0",+        "ini": "~1.3.0",+        "minimist": "^1.2.0",+        "strip-json-comments": "~2.0.1"+      },+      "bin": {+        "rc": "cli.js"+      }+    },+    "node_modules/readable-stream": {+      "version": "3.6.2",+      "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",+      "license": "MIT",+      "dependencies": {+        "inherits": "^2.0.3",+        "string_decoder": "^1.1.1",+        "util-deprecate": "^1.0.1"+      },+      "engines": {+        "node": ">= 6"+      }+    },+    "node_modules/require-directory": {+      "version": "2.1.1",+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",+      "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",+      "license": "MIT",+      "engines": {+        "node": ">=0.10.0"+      }+    },+    "node_modules/resolve-from": {+      "version": "4.0.0",+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",+      "license": "MIT",+      "engines": {+        "node": ">=4"+      }+    },+    "node_modules/safe-buffer": {+      "version": "5.2.1",+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "license": "MIT"+    },+    "node_modules/semver": {+      "version": "7.7.4",+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",+      "license": "ISC",+      "bin": {+        "semver": "bin/semver.js"+      },+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/sharp": {+      "version": "0.34.5",+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",+      "hasInstallScript": true,+      "license": "Apache-2.0",+      "dependencies": {+        "@img/colour": "^1.0.0",+        "detect-libc": "^2.1.2",+        "semver": "^7.7.3"+      },+      "engines": {+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"+      },+      "funding": {+        "url": "https://opencollective.com/libvips"+      },+      "optionalDependencies": {+        "@img/sharp-darwin-arm64": "0.34.5",+        "@img/sharp-darwin-x64": "0.34.5",+        "@img/sharp-libvips-darwin-arm64": "1.2.4",+        "@img/sharp-libvips-darwin-x64": "1.2.4",+        "@img/sharp-libvips-linux-arm": "1.2.4",+        "@img/sharp-libvips-linux-arm64": "1.2.4",+        "@img/sharp-libvips-linux-ppc64": "1.2.4",+        "@img/sharp-libvips-linux-riscv64": "1.2.4",+        "@img/sharp-libvips-linux-s390x": "1.2.4",+        "@img/sharp-libvips-linux-x64": "1.2.4",+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",+        "@img/sharp-linux-arm": "0.34.5",+        "@img/sharp-linux-arm64": "0.34.5",+        "@img/sharp-linux-ppc64": "0.34.5",+        "@img/sharp-linux-riscv64": "0.34.5",+        "@img/sharp-linux-s390x": "0.34.5",+        "@img/sharp-linux-x64": "0.34.5",+        "@img/sharp-linuxmusl-arm64": "0.34.5",+        "@img/sharp-linuxmusl-x64": "0.34.5",+        "@img/sharp-wasm32": "0.34.5",+        "@img/sharp-win32-arm64": "0.34.5",+        "@img/sharp-win32-ia32": "0.34.5",+        "@img/sharp-win32-x64": "0.34.5"+      }+    },+    "node_modules/simple-concat": {+      "version": "1.0.1",+      "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "license": "MIT"+    },+    "node_modules/simple-get": {+      "version": "4.0.1",+      "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",+      "funding": [+        {+          "type": "github",+          "url": "https://github.com/sponsors/feross"+        },+        {+          "type": "patreon",+          "url": "https://www.patreon.com/feross"+        },+        {+          "type": "consulting",+          "url": "https://feross.org/support"+        }+      ],+      "license": "MIT",+      "dependencies": {+        "decompress-response": "^6.0.0",+        "once": "^1.3.1",+        "simple-concat": "^1.0.0"+      }+    },+    "node_modules/smart-buffer": {+      "version": "4.2.0",+      "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",+      "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",+      "license": "MIT",+      "engines": {+        "node": ">= 6.0.0",+        "npm": ">= 3.0.0"+      }+    },+    "node_modules/socks": {+      "version": "2.8.7",+      "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz",+      "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==",+      "license": "MIT",+      "dependencies": {+        "ip-address": "^10.0.1",+        "smart-buffer": "^4.2.0"+      },+      "engines": {+        "node": ">= 10.0.0",+        "npm": ">= 3.0.0"+      }+    },+    "node_modules/socks-proxy-agent": {+      "version": "8.0.5",+      "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",+      "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",+      "license": "MIT",+      "dependencies": {+        "agent-base": "^7.1.2",+        "debug": "^4.3.4",+        "socks": "^2.8.3"+      },+      "engines": {+        "node": ">= 14"+      }+    },+    "node_modules/source-map": {+      "version": "0.6.1",+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",+      "license": "BSD-3-Clause",+      "optional": true,+      "engines": {+        "node": ">=0.10.0"+      }+    },+    "node_modules/streamx": {+      "version": "2.23.0",+      "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz",+      "integrity": "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==",+      "license": "MIT",+      "dependencies": {+        "events-universal": "^1.0.0",+        "fast-fifo": "^1.3.2",+        "text-decoder": "^1.1.0"+      }+    },+    "node_modules/string_decoder": {+      "version": "1.3.0",+      "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",+      "license": "MIT",+      "dependencies": {+        "safe-buffer": "~5.2.0"+      }+    },+    "node_modules/string-width": {+      "version": "4.2.3",+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",+      "license": "MIT",+      "dependencies": {+        "emoji-regex": "^8.0.0",+        "is-fullwidth-code-point": "^3.0.0",+        "strip-ansi": "^6.0.1"+      },+      "engines": {+        "node": ">=8"+      }+    },+    "node_modules/strip-ansi": {+      "version": "6.0.1",+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",+      "license": "MIT",+      "dependencies": {+        "ansi-regex": "^5.0.1"+      },+      "engines": {+        "node": ">=8"+      }+    },+    "node_modules/strip-json-comments": {+      "version": "2.0.1",+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",+      "license": "MIT",+      "engines": {+        "node": ">=0.10.0"+      }+    },+    "node_modules/tar-fs": {+      "version": "3.1.2",+      "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.2.tgz",+      "integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",+      "license": "MIT",+      "dependencies": {+        "pump": "^3.0.0",+        "tar-stream": "^3.1.5"+      },+      "optionalDependencies": {+        "bare-fs": "^4.0.1",+        "bare-path": "^3.0.0"+      }+    },+    "node_modules/tar-stream": {+      "version": "3.1.8",+      "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz",+      "integrity": "sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ==",+      "license": "MIT",+      "dependencies": {+        "b4a": "^1.6.4",+        "bare-fs": "^4.5.5",+        "fast-fifo": "^1.2.0",+        "streamx": "^2.15.0"+      }+    },+    "node_modules/teex": {+      "version": "1.0.1",+      "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz",+      "integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",+      "license": "MIT",+      "dependencies": {+        "streamx": "^2.12.5"+      }+    },+    "node_modules/text-decoder": {+      "version": "1.2.7",+      "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz",+      "integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",+      "license": "Apache-2.0",+      "dependencies": {+        "b4a": "^1.6.4"+      }+    },+    "node_modules/tslib": {+      "version": "2.8.1",+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",+      "license": "0BSD"+    },+    "node_modules/tunnel-agent": {+      "version": "0.6.0",+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",+      "license": "Apache-2.0",+      "dependencies": {+        "safe-buffer": "^5.0.1"+      },+      "engines": {+        "node": "*"+      }+    },+    "node_modules/typed-query-selector": {+      "version": "2.12.1",+      "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.1.tgz",+      "integrity": "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA==",+      "license": "MIT"+    },+    "node_modules/undici-types": {+      "version": "7.18.2",+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",+      "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",+      "license": "MIT",+      "optional": true+    },+    "node_modules/util-deprecate": {+      "version": "1.0.2",+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",+      "license": "MIT"+    },+    "node_modules/webdriver-bidi-protocol": {+      "version": "0.4.1",+      "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",+      "integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",+      "license": "Apache-2.0"+    },+    "node_modules/wrap-ansi": {+      "version": "7.0.0",+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",+      "license": "MIT",+      "dependencies": {+        "ansi-styles": "^4.0.0",+        "string-width": "^4.1.0",+        "strip-ansi": "^6.0.0"+      },+      "engines": {+        "node": ">=10"+      },+      "funding": {+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"+      }+    },+    "node_modules/wrappy": {+      "version": "1.0.2",+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",+      "license": "ISC"+    },+    "node_modules/ws": {+      "version": "8.19.0",+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",+      "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",+      "license": "MIT",+      "engines": {+        "node": ">=10.0.0"+      },+      "peerDependencies": {+        "bufferutil": "^4.0.1",+        "utf-8-validate": ">=5.0.2"+      },+      "peerDependenciesMeta": {+        "bufferutil": {+          "optional": true+        },+        "utf-8-validate": {+          "optional": true+        }+      }+    },+    "node_modules/y18n": {+      "version": "5.0.8",+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",+      "license": "ISC",+      "engines": {+        "node": ">=10"+      }+    },+    "node_modules/yargs": {+      "version": "17.7.2",+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",+      "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",+      "license": "MIT",+      "dependencies": {+        "cliui": "^8.0.1",+        "escalade": "^3.1.1",+        "get-caller-file": "^2.0.5",+        "require-directory": "^2.1.1",+        "string-width": "^4.2.3",+        "y18n": "^5.0.5",+        "yargs-parser": "^21.1.1"+      },+      "engines": {+        "node": ">=12"+      }+    },+    "node_modules/yargs-parser": {+      "version": "21.1.1",+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",+      "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",+      "license": "ISC",+      "engines": {+        "node": ">=12"+      }+    },+    "node_modules/yauzl": {+      "version": "2.10.0",+      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",+      "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",+      "license": "MIT",+      "dependencies": {+        "buffer-crc32": "~0.2.3",+        "fd-slicer": "~1.1.0"+      }+    },+    "node_modules/zod": {+      "version": "3.25.76",+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",+      "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",+      "license": "MIT",+      "funding": {+        "url": "https://github.com/sponsors/colinhacks"+      }+    }+  }+}
src/package.jsonadded+14
@@ -0,0 +1,14 @@+{+  "name": "adom-puppeteer-bridge",+  "version": "1.0.0",+  "description": "Puppeteer Bridge — persistent browser instance for visual debugging via Adom Desktop",+  "main": "server.js",+  "scripts": {+    "start": "node server.js"+  },+  "dependencies": {+    "keytar": "^7.9.0",+    "puppeteer": "^24.38.0",+    "sharp": "^0.34.5"+  }+}
src/recorder-icon.svgadded+38
@@ -0,0 +1,38 @@+<?xml version="1.0" encoding="UTF-8"?>+<!--+  Adom Desktop Recorder app icon.++  Variant of gallia/brand/logos/favicon-color.svg with the brand-teal+  background swapped for the brand danger red (#f85149 from+  gallia/viewer/adom-theme.js → THEME.danger). The interlocking Adom+  shapes inside the rounded square are filled white instead of the+  original dark-teal #003d40, so they stand out on red.++  Why a separate icon: the recorder runs as a second visible window+  alongside the main Adom Desktop app. Using the same teal favicon for+  both made the Windows taskbar show two identical icons — confusing+  for the user. Same Adom shape language (rounded square + atomic+  bonds) preserves brand identity; the color shift to red signals+  "recording / alert state" and is immediately distinguishable at+  taskbar size (16-32 px).+-->+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 244 244">+  <defs>+    <style>+      .cls-1{fill:none;}+      .cls-1,.cls-2,.cls-3,.cls-4{stroke-width:0px;}+      .cls-2{fill:#f85149;}      /* brand danger red — was #00b8b0 teal */+      .cls-3{fill:#fff;}          /* white — outer atomic orbits, unchanged */+      .cls-4{fill:#fff;}          /* white inner shapes — was #003d40 dark teal */+      .cls-5{clip-path:url(#clippath);}+    </style>+    <clipPath id="clippath"><rect class="cls-1" x="2.33" y="2.33" width="239.33" height="239.33"/></clipPath>+  </defs>+  <path class="cls-3" d="M184.3,59.7c-16.95-16.95-41.26-20.14-54.29-7.11-5.29,5.29-7.9,12.43-8.01,20.18h0s0,0,0,0c0,.03,0,.05,0,.08v48.09c0,.58.47,1.05,1.05,1.05h47.57-.01c7.99,0,15.37-2.6,20.79-8.02,13.03-13.03,9.84-37.33-7.11-54.29Z"/>+  <path class="cls-3" d="M59.7,184.3c16.95,16.95,41.26,20.14,54.29,7.11,5.29-5.29,7.9-12.43,8.01-20.18h0s0,0,0,0c0-.03,0-.05,0-.08v-48.09c0-.58-.47-1.05-1.05-1.05h-47.57.01c-7.99,0-15.37,2.6-20.79,8.02-13.03,13.03-9.84,37.33,7.11,54.29Z"/>+  <g class="cls-5">+    <path class="cls-2" d="M206.67,241.67H37.33c-19.33,0-34.99-15.67-34.99-34.99V37.33C2.33,18,18,2.33,37.33,2.33h169.34c19.33,0,34.99,15.67,34.99,34.99v169.34c0,19.33-15.67,34.99-34.99,34.99"/>+    <path class="cls-4" d="M173.76,70.24c-14.09-14.09-34.28-16.73-45.1-5.91-4.39,4.39-6.56,10.33-6.66,16.77h0v40.03c0,.48.39.88.88.88h39.52,0c6.64,0,12.77-2.16,17.28-6.66,10.82-10.82,8.18-31.02-5.91-45.1"/>+    <path class="cls-4" d="M70.24,173.76c14.09,14.09,34.28,16.73,45.1,5.91,4.39-4.39,6.56-10.33,6.66-16.77h0v-40.03c0-.48-.39-.88-.88-.88h-39.52,0c-6.64,0-12.77,2.16-17.28,6.66-10.82,10.82-8.18,31.02,5.91,45.1"/>+  </g>+</svg>
src/recorder.htmladded+605
@@ -0,0 +1,605 @@+<!DOCTYPE html>+<html lang="en">+<head>+  <meta charset="UTF-8">+  <title>Adom Desktop Recorder</title>+  <!--+    Brand: Adom. Sources reconciled:+      • gallia/skills/brand/SKILL.md      — operational rules + canonical font URLs+      • gallia/brand/BRAND-GUIDELINES.md  — fonts, palette, weights+      • gallia/viewer/adom-theme.js       — token names/values, button + card patterns++    Fonts: Familjen Grotesk + Satoshi from adom.inc (canonical Adom CDN per+    SKILL.md), JetBrains Mono from Google Fonts (per SKILL.md font-sources+    table). All adom.inc URLs verified to return 200.++    Icons: monochrome white SVGs in icons/ directory per the SKILL.md rule+    "all icons must be monochrome white". No emoji, no multi-color icons.++    App icon: recorder-icon.svg — a red variant of favicon-color.svg, used+    instead of the regular Adom teal favicon so the recorder window is+    visually distinct from the main Adom Desktop app in the Windows taskbar.+  -->+  <!--+    v1.8.131: crisp raster favicon. The recorder runs as a Chrome `--app`+    window, whose Windows taskbar icon is derived from this favicon. An+    SVG-only favicon (the previous `recorder-icon.svg` link) gets rasterized+    by Chrome at a small fixed size and then upscaled by the taskbar → blurry.+    A multi-size `.ico` (16→256, incl. a 256 entry) + a 256px PNG give the+    taskbar a sharp bitmap at every DPI. `recorder-icon.svg` remains the+    source of truth; `recorder-icon.ico` / `recorder-icon-256.png` are+    generated from it (the red record-state variant) and ship in the bundle.+  -->+  <link rel="icon" type="image/x-icon" href="recorder-icon.ico">+  <link rel="icon" type="image/png" sizes="256x256" href="recorder-icon-256.png">+  <style>+    @font-face {+      font-family: 'Familjen Grotesk';+      src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+      font-weight: 700; font-display: swap;+    }+    @font-face {+      font-family: 'Familjen Grotesk';+      src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+      font-weight: 500; font-display: swap;+    }+    @font-face {+      font-family: 'Satoshi';+      src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+      font-weight: 400; font-display: swap;+    }+    @font-face {+      font-family: 'Satoshi';+      src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+      font-weight: 500; font-display: swap;+    }+    @font-face {+      font-family: 'Satoshi';+      src: url('https://adom.inc/fonts/satoshi/satoshi-700-normal.woff2') format('woff2');+      font-weight: 700; font-display: swap;+    }+    @font-face {+      font-family: 'JetBrains Mono';+      src: url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');+      font-weight: 400; font-display: swap;+    }+    @font-face {+      font-family: 'JetBrains Mono';+      src: url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPxTcwhsk.woff2') format('woff2');+      font-weight: 500; font-display: swap;+    }+  </style>+  <style>+    * { margin: 0; padding: 0; box-sizing: border-box; }+    /* Semantic tokens follow gallia/viewer/adom-theme.js naming (--gv-*).+       Same names = future-proof if recorder is later swapped to import+       from adom-theme.js directly. */+    :root {+      --gv-bg:           #0d1117;+      --gv-bg-surface:   #161b22;+      --gv-bg-elevated:  #1c2128;+      --gv-bg-overlay:   #21262d;+      --gv-border:       #30363d;+      --gv-border-muted: #21262d;+      --gv-border-accent: rgba(0, 184, 176, 0.3);+      --gv-text:         #e6edf3;+      --gv-text-secondary: #8b949e;+      --gv-text-muted:   #484f58;+      --gv-accent:       #00b8b0;+      --gv-accent-bright: #00e6dc;+      --gv-accent-muted: rgba(0, 184, 176, 0.12);+      --gv-accent-border: rgba(0, 184, 176, 0.25);+      --gv-danger:       #f85149;     /* adom-theme.js semantic danger */+      --gv-pin-power:    #ef5350;     /* used for the REC pulse dot per brand pin-power red */+      --gv-radius-sm:    4px;+      --gv-radius-md:    6px;+      --gv-radius-lg:    8px;+      /* Brand fonts. Per BRAND-GUIDELINES.md weights:+         Familjen Grotesk: headlines (Bold 700) or sub-headlines (Medium 500)+         Satoshi: body (Regular 400 or Medium 500)+         JetBrains Mono: code/monospace (per SKILL.md font-sources table) */+      --gv-font:         'Satoshi', system-ui, -apple-system, sans-serif;+      --gv-font-display: 'Familjen Grotesk', 'Satoshi', system-ui, sans-serif;+      --gv-font-mono:    'JetBrains Mono', 'SF Mono', 'Cascadia Code', Consolas, monospace;+    }+    html, body {+      min-height: 100%;+      background: var(--gv-bg);+      color: var(--gv-text);+      font-family: var(--gv-font);+      font-weight: 400;            /* Satoshi Regular — body default per BRAND-GUIDELINES.md */+      font-size: 13px;+      user-select: none;+      overflow: auto;              /* graceful degradation on user-resize */+    }+    body {+      padding: 14px 16px;+      display: flex; flex-direction: column; gap: 12px;+      min-height: 100vh;+    }+    /* min-height: 0 lets flex children shrink below their content size+       (CSS default for flex items is min-height: auto). Without this, a+       tall child like an oversized SVG forces the column to grow past+       the viewport and pushes later siblings — Stop button, action row —+       below the fold. */+    .state.active {+      flex: 1; display: flex; flex-direction: column; gap: 12px;+      min-height: 0; min-width: 0;+    }+    /* Action controls + their wrappers must NEVER shrink — they're the+       primary affordance and clipping them is the worst-case failure. */+    .btn, .source-card, .rec-bar, .actions, .saved-info { flex-shrink: 0; }++    .header {+      display: flex; align-items: center; gap: 10px;+      font-family: var(--gv-font-display);   /* Familjen Grotesk Bold */+      font-weight: 700; font-size: 14.5px;+      letter-spacing: -0.012em;              /* Familjen Grotesk reads tighter at headline weight */+    }+    /* `.logo` (NOT scoped to `.header`) so the constraint applies wherever+       the logo appears. Earlier this was `.header .logo`, which meant the+       same <img class="logo"> placed inside `.rec-bar` got no sizing rule+       — SVGs with viewBox but no intrinsic px size then render at the+       browser default (300×150 stretched to flex parent), dominating the+       Recording state and pushing the Stop button below the fold. */+    .logo {+      width: 22px; height: 22px;+      flex-shrink: 0;+      border-radius: 5px;+      box-shadow: 0 0 0 1px var(--gv-border);+    }+    .reason {+      color: var(--gv-text-secondary); font-size: 11.5px;+      line-height: 1.45; word-wrap: break-word;+    }+    .reason b { color: var(--gv-text); font-weight: 500; }+    .divider { height: 1px; background: var(--gv-border); }+    .helper { color: var(--gv-text-secondary); font-size: 11.5px; line-height: 1.45; }+    .helper code {+      font-family: var(--gv-font-mono); font-size: 11px;+      color: var(--gv-text); background: var(--gv-bg-overlay);+      padding: 1px 5px; border-radius: var(--gv-radius-sm);+    }+    .label-section {+      font-family: var(--gv-font-display); font-size: 10px;+      font-weight: 700; letter-spacing: 0.06em;+      color: var(--gv-text-secondary); margin-top: 2px;+    }++    /* Source-cards follow the .card pattern from gallia/viewer/adom-theme.js+       cardCSS(): bg-surface, border, radius-lg (8px). Hover lifts to elevated+       + accent border. */+    .source-card {+      background: var(--gv-bg-surface);+      border: 1px solid var(--gv-border);+      border-radius: var(--gv-radius-lg);+      padding: 12px 14px;+      display: flex; align-items: center; gap: 12px;+      cursor: pointer;+      transition: all 0.15s;+    }+    .source-card:hover {+      border-color: var(--gv-accent);+      background: var(--gv-bg-elevated);+    }+    /* Monochrome white SVG icons (per SKILL.md "all icons must be monochrome+       white"). currentColor on the SVG path inherits this color. */+    .source-card .icon {+      width: 18px; height: 18px; flex-shrink: 0;+      color: var(--gv-text-secondary);+      transition: color 0.15s;+    }+    .source-card:hover .icon { color: var(--gv-text); }+    .source-card .icon svg { width: 100%; height: 100%; display: block; }+    .source-card .label { flex: 1; }+    .source-card .label .name {+      font-family: var(--gv-font-display); font-weight: 500; font-size: 13px;+    }+    .source-card .label .sub { color: var(--gv-text-secondary); font-size: 11px; margin-top: 1px; }+    .badge {+      font-family: var(--gv-font-display); font-size: 10px;+      font-weight: 700; letter-spacing: 0.05em;+      padding: 3px 7px; border-radius: var(--gv-radius-sm);+    }+    /* ACTIVE: brand teal accent (matches toolbarCSS button.active) */+    .badge-active { background: var(--gv-accent-muted); color: var(--gv-accent-bright); }+    .badge-off    { background: var(--gv-bg-overlay);  color: var(--gv-text-muted); }++    /* Buttons follow gallia/viewer/adom-theme.js buttonCSS() ghost pattern:+       transparent surface, secondary text, border. Hover bumps border + text.+       transitions all 0.15s (per SKILL.md "always 0.15s ease"). */+    .btn {+      background: var(--gv-bg-surface); color: var(--gv-text-secondary);+      border: 1px solid var(--gv-border);+      border-radius: var(--gv-radius-md);+      padding: 10px 12px; cursor: pointer;+      font-family: var(--gv-font-display);+      font-size: 13px; font-weight: 500;+      width: 100%;+      display: flex; align-items: center; justify-content: center; gap: 8px;+      transition: all 0.15s;+    }+    .btn:hover {+      background: var(--gv-bg-elevated);+      border-color: var(--gv-text-secondary);+      color: var(--gv-text);+    }+    /* Start/Stop = danger action (REC red). Same ghost pattern with+       brand danger color tones. */+    .btn-start, .btn-stop {+      color: var(--gv-danger);+      border-color: rgba(248, 81, 73, 0.35);+    }+    .btn-start:hover, .btn-stop:hover {+      background: rgba(248, 81, 73, 0.10);+      border-color: var(--gv-danger);+      color: var(--gv-danger);+    }+    .btn .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }+    .btn .square { width: 8px; height: 8px; background: currentColor; }++    /* State 2 — Recording */+    .rec-bar {+      display: flex; align-items: center; gap: 10px;+      font-family: var(--gv-font-display);+      font-size: 14px; font-weight: 700;+    }+    .rec-bar .rec-dot {+      width: 10px; height: 10px; border-radius: 50%;+      background: var(--gv-pin-power);   /* brand pin-power red, slightly warmer */+      animation: pulse 1.4s ease-in-out infinite;+      box-shadow: 0 0 8px var(--gv-pin-power);+    }+    .rec-bar .duration {+      font-family: var(--gv-font-mono); font-weight: 500; letter-spacing: 0.04em;+    }+    .rec-bar .size {+      margin-left: auto; color: var(--gv-text-secondary);+      font-family: var(--gv-font-mono); font-weight: 400; font-size: 11px;+    }+    @keyframes pulse {+      0%, 100% { opacity: 1; }+      50%      { opacity: 0.4; }+    }++    /* State 3 — Saved */+    .saved-header {+      display: flex; align-items: center; gap: 8px;+      color: var(--gv-accent-bright);+      font-family: var(--gv-font-display);+      font-weight: 700; font-size: 14px;+    }+    .saved-info { font-size: 11.5px; color: var(--gv-text-secondary); line-height: 1.5; }+    .saved-info code {+      font-family: var(--gv-font-mono); font-size: 11px; color: var(--gv-text);+    }++    .actions { display: flex; gap: 8px; }+    .actions .btn { flex: 1; }++    /* Hide-by-default */+    .state { display: none; flex-direction: column; gap: 12px; }+    .state.active { display: flex; }+  </style>+</head>+<body>+  <!-- ================ STATE 1 — READY ================ -->+  <div id="state-ready" class="state active">+    <div class="header">+      <img class="logo" src="recorder-icon.svg" alt="Adom Desktop Recorder" />+      <span>Adom Desktop Recorder</span>+    </div>+    <div class="reason"><b>Reason:</b> <span id="reason-ready">—</span></div>+    <div class="divider"></div>+    <div class="helper">Ready to record. Toggle sources below, or wait for Claude.</div>++    <div class="label-section">SOURCES</div>+    <div class="source-card">+      <span class="icon">+        <!-- Monitor icon (MDI 'monitor' equivalent), monochrome white per brand rule -->+        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/></svg>+      </span>+      <div class="label">+        <div class="name">Screen</div>+        <div class="sub">Entire desktop</div>+      </div>+      <span class="badge badge-active">ACTIVE</span>+    </div>+    <div class="source-card">+      <span class="icon">+        <!-- Microphone icon (MDI 'microphone' equivalent), monochrome white per brand rule -->+        <svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 2c-1.66 0-3 1.34-3 3v6c0 1.66 1.34 3 3 3s3-1.34 3-3V5c0-1.66-1.34-3-3-3zm5.91 9c-.49 0-.9.36-.98.85C16.52 14.2 14.47 16 12 16s-4.52-1.8-4.93-4.15c-.08-.49-.49-.85-.98-.85-.61 0-1.09.54-1 1.14.49 3 2.89 5.35 5.91 5.78V20c0 .55.45 1 1 1s1-.45 1-1v-2.08c3.02-.43 5.42-2.78 5.91-5.78.1-.6-.39-1.14-1-1.14z"/></svg>+      </span>+      <div class="label">+        <div class="name">Microphone</div>+        <div class="sub" id="mic-sub">Not enabled</div>+      </div>+      <span class="badge badge-off" id="mic-badge">OFF</span>+    </div>++    <div class="helper" id="clip-summary" style="display:none">Clips this session: <b id="clip-count">0</b><br>Last: <code id="last-clip">—</code></div>++    <button class="btn btn-start" id="start-btn">+      <span class="dot"></span>+      <span>Start recording</span>+    </button>+  </div>++  <!-- ================ STATE 2 — RECORDING ================ -->+  <div id="state-recording" class="state">+    <!-- No logo here: the OS title-bar favicon provides brand identity+         in this compact state, and the REC pulse dot is the dominant+         indicator. Adding the SVG logo here was the bug — it had no+         applied size and dominated the layout. -->+    <div class="rec-bar">+      <span class="rec-dot"></span>+      <span>REC</span>+      <span class="duration" id="duration-el">0:00</span>+      <span class="size" id="size-el">0 KB</span>+    </div>+    <div class="reason" id="reason-recording">—</div>+    <div class="divider"></div>+    <div class="helper">Recording entire desktop. Claude can call <code>desktop_record_stop</code>, or click:</div>+    <button class="btn btn-stop" id="stop-btn">+      <span class="square"></span>+      <span>Stop recording</span>+    </button>+  </div>++  <!-- ================ STATE 3 — SAVED ================ -->+  <div id="state-saved" class="state">+    <div class="saved-header">+      <span>✓</span>+      <span>Clip saved</span>+    </div>+    <div class="reason" id="reason-saved">—</div>+    <div class="divider"></div>+    <div class="saved-info">+      <code id="saved-name">—</code><br>+      <span id="saved-meta">—</span>+    </div>+    <div class="actions">+      <button class="btn" id="open-folder-btn">Open folder</button>+      <button class="btn" id="another-btn">Record another</button>+    </div>+    <div class="helper" id="auto-ready">Auto-ready in <b id="auto-ready-count">3</b>…</div>+  </div>++<script>+// ─── State & DOM ──────────────────────────────────────────────────────+const states = {+  ready: document.getElementById('state-ready'),+  recording: document.getElementById('state-recording'),+  saved: document.getElementById('state-saved'),+};+const ui = {+  reasonReady:     document.getElementById('reason-ready'),+  reasonRecording: document.getElementById('reason-recording'),+  reasonSaved:     document.getElementById('reason-saved'),+  micBadge:        document.getElementById('mic-badge'),+  micSub:          document.getElementById('mic-sub'),+  startBtn:        document.getElementById('start-btn'),+  stopBtn:         document.getElementById('stop-btn'),+  durationEl:      document.getElementById('duration-el'),+  sizeEl:          document.getElementById('size-el'),+  clipSummary:     document.getElementById('clip-summary'),+  clipCount:       document.getElementById('clip-count'),+  lastClip:        document.getElementById('last-clip'),+  savedName:       document.getElementById('saved-name'),+  savedMeta:       document.getElementById('saved-meta'),+  openFolderBtn:   document.getElementById('open-folder-btn'),+  anotherBtn:      document.getElementById('another-btn'),+  autoReady:       document.getElementById('auto-ready'),+  autoReadyCount:  document.getElementById('auto-ready-count'),+};++let mediaRecorder = null;+let stream = null;+let sentBytes = 0;+let startedAt = 0;+let durationTimer = null;+let autoReadyTimer = null;+let session = {+  reason: '—',+  audioRequested: false,+  clipCount: 0,+  lastClip: null,+};++function showState(name) {+  for (const s of Object.values(states)) s.classList.remove('active');+  states[name].classList.add('active');+}++function fmtDuration(ms) {+  const totalSec = Math.floor(ms / 1000);+  const m = Math.floor(totalSec / 60);+  const s = totalSec % 60;+  return `${m}:${String(s).padStart(2, '0')}`;+}+function fmtSize(bytes) {+  if (bytes < 1024) return `${bytes} B`;+  if (bytes < 1024*1024) return `${(bytes/1024).toFixed(0)} KB`;+  return `${(bytes/(1024*1024)).toFixed(1)} MB`;+}++function updateReasonText(reason) {+  session.reason = reason || '—';+  ui.reasonReady.textContent = session.reason;+  ui.reasonRecording.textContent = session.reason;+  ui.reasonSaved.textContent = session.reason;+}++function updateMicUi(enabled) {+  if (enabled) {+    ui.micBadge.textContent = 'ON';+    ui.micBadge.className = 'badge badge-active';+    ui.micSub.textContent = 'Will be captured';+  } else {+    ui.micBadge.textContent = 'OFF';+    ui.micBadge.className = 'badge badge-off';+    ui.micSub.textContent = 'Not enabled';+  }+}++function updateClipSummary() {+  if (session.clipCount === 0) {+    ui.clipSummary.style.display = 'none';+    return;+  }+  ui.clipSummary.style.display = 'block';+  ui.clipCount.textContent = String(session.clipCount);+  ui.lastClip.textContent = session.lastClip || '—';+}++// ─── Public: bridge → page (called via page.evaluate) ──────────────────+window.setSessionReason = (reason) => updateReasonText(reason);++window.startRecording = async (opts = {}) => {+  if (mediaRecorder && mediaRecorder.state === 'recording') {+    throw new Error('Already recording');+  }+  if (opts.reason) updateReasonText(opts.reason);+  session.audioRequested = !!opts.audio;+  updateMicUi(session.audioRequested);++  try {+    stream = await navigator.mediaDevices.getDisplayMedia({+      video: { frameRate: opts.fps || 30, displaySurface: 'monitor' },+      audio: opts.audio === true,+    });+  } catch (e) {+    throw new Error('getDisplayMedia failed: ' + e.message);+  }++  let mimeType = 'video/webm;codecs=vp9';+  if (!MediaRecorder.isTypeSupported(mimeType)) {+    mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp8') ? 'video/webm;codecs=vp8' : 'video/webm';+  }++  mediaRecorder = new MediaRecorder(stream, {+    mimeType,+    videoBitsPerSecond: opts.videoBitsPerSecond || 4_000_000,+  });+  sentBytes = 0;+  startedAt = Date.now();++  mediaRecorder.ondataavailable = async (e) => {+    if (!e.data || !e.data.size) return;+    const buf = await e.data.arrayBuffer();+    let binary = '';+    const bytes = new Uint8Array(buf);+    const chunkSize = 0x8000;+    for (let i = 0; i < bytes.length; i += chunkSize) {+      binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunkSize));+    }+    const b64 = btoa(binary);+    try { await window._saveChunk(b64); } catch (err) { console.error('saveChunk:', err); }+    sentBytes += buf.byteLength;+    ui.sizeEl.textContent = fmtSize(sentBytes);+  };++  mediaRecorder.onstop = async () => {+    if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }+    if (durationTimer) { clearInterval(durationTimer); durationTimer = null; }+    const durationMs = Date.now() - startedAt;+    try {+      await window._recordingStopped({ sentBytes, durationMs });+    } catch (e) {+      console.error('recordingStopped:', e);+    }+    mediaRecorder = null;+  };++  mediaRecorder.start(1000);+  showState('recording');+  durationTimer = setInterval(() => {+    ui.durationEl.textContent = fmtDuration(Date.now() - startedAt);+  }, 250);+  // No dynamic window resize — window stays at its launch size for the+  // life of the session. Each state's content fits within the same+  // window comfortably; switching states only swaps which .state.active+  // div is visible. Eliminates the resize-clipping bug where the Stop+  // button got cut off when the window shrank to fit Recording state.+};++window.stopRecording = async () => {+  if (!mediaRecorder || mediaRecorder.state !== 'recording') return;+  return new Promise((resolve) => {+    mediaRecorder.addEventListener('stop', resolve, { once: true });+    mediaRecorder.stop();+  });+};++// Bridge → page: switch to Saved state with clip metadata+window.showSaved = (clip) => {+  session.clipCount += 1;+  if (clip && clip.filename) session.lastClip = clip.filename + ' · ' + (clip.sizeKBStr || '');+  ui.savedName.textContent = clip && clip.filename ? clip.filename : '—';+  ui.savedMeta.textContent = clip+    ? `${fmtSize(clip.sizeBytes || 0)} · ${fmtDuration(clip.durationMs || 0)}`+    : '—';+  showState('saved');++  // Auto-transition back to Ready after 3s+  if (autoReadyTimer) clearInterval(autoReadyTimer);+  let countdown = 3;+  ui.autoReadyCount.textContent = countdown;+  autoReadyTimer = setInterval(() => {+    countdown -= 1;+    if (countdown <= 0) {+      clearInterval(autoReadyTimer); autoReadyTimer = null;+      goToReady();+    } else {+      ui.autoReadyCount.textContent = countdown;+    }+  }, 1000);+};++window.goToReady = goToReady;+function goToReady() {+  if (autoReadyTimer) { clearInterval(autoReadyTimer); autoReadyTimer = null; }+  updateClipSummary();+  showState('ready');+  // No dynamic window resize — see comment in startRecording above.+}++// ─── User → bridge: in-window button clicks ────────────────────────────+ui.startBtn.addEventListener('click', async () => {+  // Default audio off; user can toggle by clicking the mic card (see below)+  try {+    await window.startRecording({ audio: session.audioRequested });+  } catch (e) {+    alert('Recording failed: ' + e.message);+  }+});+ui.stopBtn.addEventListener('click', async () => {+  await window.stopRecording();+});+ui.openFolderBtn.addEventListener('click', async () => {+  try { await window._openRecordingsFolder(); } catch {}+});+ui.anotherBtn.addEventListener('click', () => goToReady());++// Mic toggle by clicking the source card+document.querySelectorAll('.source-card')[1].addEventListener('click', () => {+  session.audioRequested = !session.audioRequested;+  updateMicUi(session.audioRequested);+});++// Window-close → bridge can detect via beforeunload+window.addEventListener('beforeunload', () => {+  try {+    if (mediaRecorder && mediaRecorder.state === 'recording') {+      mediaRecorder.stop();+    }+    window._recorderClosed && window._recorderClosed();+  } catch {}+});++// Initialize from query string if launched with ?reason=...+const params = new URLSearchParams(location.search);+if (params.get('reason')) updateReasonText(params.get('reason'));+</script>+</body>+</html>
src/server.jsadded+5424
@@ -0,0 +1,5424 @@+// Puppeteer Bridge Server — multi-session persistent browser instances+// Each session gets its own independent Chrome window (not tabs)+// Runs on desktop, controlled via HTTP from container or Adom Desktop app+// Port 8851 by default, configurable via --port+const http = require('http');+const fs = require('fs');+const path = require('path');+const os = require('os');+const { execSync } = require('child_process');+const puppeteer = require('puppeteer');+const credentialVault = require('./credential_vault');+const chrome = require('./chrome'); // Chrome-for-Testing self-heal: detect / auto-install / readiness (cold-start fix)++// sharp is REQUIRED for resizing screenshots — never send full-res to container.+// v1.8.40+: previously this was wrapped in try/catch with a `let sharp = null`+// fallback. The fallback path silently sent 2559px PNGs to /tmp/adom-desktop-screenshots/,+// which CRASHES Claude many-image sessions at the 2000px limit. Hard-require+// sharp now: if it can't load, the bridge fails fast at startup with a clear+// error rather than producing oversized screenshots indefinitely.+const sharp = require('sharp');+const MAX_SCREENSHOT_DIM = 1568; // Claude's optimal max dimension++const PORT = (() => {+  const idx = process.argv.indexOf('--port');+  return idx !== -1 && process.argv[idx + 1] ? parseInt(process.argv[idx + 1]) : 8851;+})();++const BRIDGE_VERSION = (() => { try { return fs.readFileSync(path.join(__dirname, 'BRIDGE_VERSION'), 'utf8').trim(); } catch { return 'unknown'; } })();++const SHOTS_DIR = path.join(__dirname, 'screenshots');+fs.mkdirSync(SHOTS_DIR, { recursive: true });++// Persistent Chrome profiles — each session gets its own userDataDir+// so IndexedDB, cookies, localStorage survive across restarts+const PROFILES_DIR = path.join(__dirname, 'profiles');+fs.mkdirSync(PROFILES_DIR, { recursive: true });++// Persistent session files — survive bridge restarts+// Each session gets a JSON file: sessions/<sessionId>.json+const SESSIONS_DIR = path.join(__dirname, 'sessions');+fs.mkdirSync(SESSIONS_DIR, { recursive: true });++// Multi-session state: sessions are tabs within shared Chrome browsers+// One Chrome process per profile (userDataDir), multiple tabs per Chrome.+// browsers:  profileName → { browser, refCount }+// sessions:  sessionId → Session (see createSession below)+//+// Session shape (new — supports multiple tabs per session):+//   {+//     tabs:        [{ tabId, page, errors }]  // all open tabs for this session+//     activeTabId: string                      // currently "foregrounded" tab+//     latestShot:  string | null               // last screenshot path+//     profileName: string                      // Chrome profile+//     tabCounter:  number                      // monotonic per-session id counter+//     // Backward-compat virtual properties (getters):+//     page:   active tab's page   (for legacy callers that read session.page)+//     errors: active tab's errors+//   }+let browsers = new Map();+let sessions = new Map();+let activeSessionId = null;+let pupshotUrl = null;++// ── Tab helpers ────────────────────────────────────────────────────+function createSession(profileName) {+  const session = {+    tabs: [],+    activeTabId: null,+    latestShot: null,+    profileName,+    tabCounter: 0,+    // Ring buffer of recently-closed tabs (capped at 20). Used by+    // resolveTabOrError to enrich tab_not_found errors with `lastKnownUrl`+    // when the bogus tabId matches a tab that closed in the last few seconds.+    // The common case: a popup PDF tab auto-closes after the viewer renders,+    // and the caller tries to eval/fetch against the stale tabId. We can now+    // surface the URL the popup was at so they can browser_fetch_url it+    // directly without having to re-trigger the click.+    recentlyClosedTabs: [],+  };+  // Legacy-compat getters: existing code that reads session.page / session.errors+  // transparently sees the active tab's page / errors.+  Object.defineProperty(session, 'page', {+    enumerable: false,+    get() { const t = getActiveTab(session); return t ? t.page : null; },+  });+  Object.defineProperty(session, 'errors', {+    enumerable: false,+    get() { const t = getActiveTab(session); return t ? t.errors : []; },+  });+  return session;+}++function nextTabId(session) {+  session.tabCounter = (session.tabCounter || 0) + 1;+  return `tab-${session.tabCounter}`;+}++function getActiveTab(session) {+  if (!session || !session.tabs || session.tabs.length === 0) return null;+  if (session.activeTabId) {+    const t = session.tabs.find(t => t.tabId === session.activeTabId);+    if (t) return t;+  }+  return session.tabs[0];+}++function getTabById(session, tabId) {+  if (!session || !tabId) return null;+  return session.tabs.find(t => t.tabId === tabId) || null;+}++// Attach page → tab to a session, wire error collection + console forwarding.+// Called by both launchSession (opening new window) and browser_open_tab.+//+// `opener` documents how the tab came to exist:+//   - "user" (default): Claude requested it via browser_open_window/browser_open_tab+//   - "popup":          Chrome opened it via window.open() / target=_blank — auto-tracked+//                       by the targetcreated listener wired in wirePopupTracking().+//                       openerTabId names the tab that triggered the popup.+function attachTab(session, sessionId, page, { makeActive = true, opener = 'user', openerTabId = null } = {}) {+  const tabId = nextTabId(session);+  const errors = [];+  setupErrorCollection(page, errors);+  // Tag console logs with tabId so the caller can tell which tab emitted them+  page.on('console', msg => {+    try {+      const kind = msg.type();+      const text = msg.text();+      console.log(`[pup console ${sessionId}/${tabId} ${kind}] ${text}`);+    } catch {}+  });+  // When the user closes a tab via Chrome UI, keep our state in sync+  page.on('close', () => {+    const idx = session.tabs.findIndex(t => t.tabId === tabId);+    if (idx !== -1) {+      // Capture the URL BEFORE the page handle is GC'd, so resolveTabOrError+      // can include it in tab_not_found errors. Critical for popup workflows+      // (PDF popups auto-close after the viewer renders; without lastKnownUrl+      // the caller has to re-trigger the click that spawned the popup).+      let lastKnownUrl = null;+      try { lastKnownUrl = page.url(); } catch {}+      const closedEntry = {+        tabId,+        url: lastKnownUrl,+        opener: opener || 'user',+        openerTabId: openerTabId || null,+        closedAt: Date.now(),+      };+      if (!session.recentlyClosedTabs) session.recentlyClosedTabs = [];+      session.recentlyClosedTabs.push(closedEntry);+      // Cap the ring buffer at 20 so a long-lived session with many popups+      // doesn't slowly leak memory through this list.+      if (session.recentlyClosedTabs.length > 20) {+        session.recentlyClosedTabs.shift();+      }+      session.tabs.splice(idx, 1);+      if (session.activeTabId === tabId) {+        session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;+      }+      console.log(`Tab "${tabId}" of session "${sessionId}" closed by user. Remaining tabs: ${session.tabs.length}. Last URL: ${lastKnownUrl || '(unknown)'}`);+    }+    // Auto-stop any tab recording for this tab+    try {+      if (typeof tabRecordings !== 'undefined') {+        for (const [recId, state] of [...tabRecordings.entries()]) {+          if (state.sessionId === sessionId && state.tabId === tabId && !state.stopping) {+            state.stopReason = 'tabClosed';+            tabRecordStopImpl({ sessionId, recordingId: recId }).catch(e =>+              console.error(`[${recId}] auto-stop on tab close failed: ${e.message}`));+          }+        }+      }+    } catch (e) { console.error(`tab-close recording cleanup failed: ${e.message}`); }+  });+  session.tabs.push({ tabId, page, errors, opener, openerTabId });+  if (makeActive) session.activeTabId = tabId;++  // v1.6.3+: enable scripted downloads on this page by default. Pup is a+  // tool-driving surface; chip-fetcher and similar tools need JS-triggered+  // downloads (a, fetch+blob, window.location=url) to actually save instead+  // of being silently dropped on the floor by Chrome. See+  // applyDownloadBehavior for rationale.+  //+  // Runs async (don't block attachTab on it) and idempotent — re-applying+  // is harmless. Re-fires on every same-tab main-frame navigation as a+  // safety belt: empirically modern Chrome doesn't reset the download+  // policy across same-context navs, but cross-context (target detach,+  // browser-restored tab) can lose it. Re-applying costs nothing.+  const downloadPath = (session._downloadPath) || path.join(os.homedir(), 'Downloads');+  applyDownloadBehavior(page, downloadPath).catch(() => {});+  page.on('framenavigated', frame => {+    try {+      if (frame === page.mainFrame()) {+        applyDownloadBehavior(page, downloadPath).catch(() => {});+      }+    } catch {}+  });++  return tabId;+}++// Find the (sessionId, tabId) tuple whose page matches a Puppeteer Target's page.+// Used by the popup tracker to figure out which existing tab triggered window.open().+async function findTabForTarget(target) {+  let openerPage = null;+  try { openerPage = await target.page(); } catch {}+  if (!openerPage) return null;+  for (const [sid, s] of sessions) {+    for (const t of s.tabs) {+      if (t.page === openerPage) return { sessionId: sid, tabId: t.tabId };+    }+  }+  return null;+}++// Wire popup auto-tracking on a freshly launched (or reconnected) browser.+// Whenever Chrome opens a new top-level page that we DIDN'T explicitly create+// (window.open(), target="_blank" form post, <a target="_blank"> click, etc.),+// `targetcreated` fires. We resolve the target's page, find the session that+// owns its opener (via target.opener()), and attach the popup to that session+// as a tracked tab. browser_list_tabs then includes it like any other tab —+// callers can switch to it, eval against it, screenshot it, close it.+//+// Edge cases:+//   - Target has no opener (top-level Chrome window opened by external means)+//     → drop; we don't know which session it belongs to.+//   - Target is already attached (we created it via launchSession or open_tab+//     and the targetcreated event raced our explicit attach) → drop.+//   - Browser is the desktop recorder window (separate profile) → drop.+function wirePopupTracking(browser, profileName) {+  browser.on('targetcreated', async (target) => {+    try {+      if (target.type() !== 'page') return;+      const popupPage = await target.page();+      if (!popupPage) return;++      // Skip if some session already has this page registered (we attached it+      // explicitly; this is the targetcreated event firing for our own creation).+      for (const s of sessions.values()) {+        for (const t of s.tabs) {+          if (t.page === popupPage) return;+        }+      }++      // Find the session that owns the opener target. If there's no opener,+      // we can't attribute the popup — drop silently.+      const openerTarget = target.opener();+      if (!openerTarget) {+        console.log(`[popup-track] new tab on profile "${profileName}" with no opener — not tracking (likely external)`);+        return;+      }+      const ownerInfo = await findTabForTarget(openerTarget);+      if (!ownerInfo) {+        console.log(`[popup-track] new tab on profile "${profileName}", opener target unknown to any session — not tracking`);+        return;+      }+      const session = sessions.get(ownerInfo.sessionId);+      if (!session) return;++      // Attach as a popup-origin tab. attachTab handles the standard error ++      // console + page.on('close') wiring same as user-opened tabs.+      const tabId = attachTab(session, ownerInfo.sessionId, popupPage, {+        makeActive: false, // don't yank focus; let caller decide via browser_switch_tab+        opener: 'popup',+        openerTabId: ownerInfo.tabId,+      });+      let popupUrl = '';+      try { popupUrl = popupPage.url(); } catch {}+      console.log(`[popup-track] new popup attached: session="${ownerInfo.sessionId}" tabId="${tabId}" openedBy="${ownerInfo.tabId}" url="${popupUrl}"`);+    } catch (e) {+      console.log(`[popup-track] error: ${e.message}`);+    }+  });+}++// Resolve which page+errors to operate on. If args.tabId is given, use that+// specific tab; else fall back to the session's active tab.+// Returns { tab, tabId } or throws a structured Error whose .message is JSON+// (so the dispatch layer can surface currentTabs + a hint without re-parsing).+//+// The hint matters most for popup-tab workflows: PDF popups and downloads+// auto-close fast (Chrome PDF viewer with Content-Disposition: attachment+// closes after the download). Callers who try to eval-against-the-popup+// after it's gone used to get the active tab's response silently+// (v1.4.8 and earlier had a quirk where some paths did this). We now error+// loudly with the exact recipe to use instead.+function resolveTabOrError(session, sessionId, args) {+  if (args.tabId) {+    const t = getTabById(session, args.tabId);+    if (!t) {+      const available = session.tabs.map(x => x.tabId);+      // v1.4.10: If the requested tabId is one we recently saw close (most+      // commonly a popup PDF that auto-closed after rendering), surface its+      // last known URL + closure metadata so the caller can immediately+      // browser_fetch_url it without having to re-trigger the spawning click.+      let recentlyClosed = null;+      if (Array.isArray(session.recentlyClosedTabs)) {+        const hit = session.recentlyClosedTabs.find(c => c.tabId === args.tabId);+        if (hit) {+          recentlyClosed = {+            lastKnownUrl: hit.url,+            opener: hit.opener,+            openerTabId: hit.openerTabId,+            closedMsAgo: Date.now() - hit.closedAt,+          };+        }+      }+      const payload = {+        error: `Tab "${args.tabId}" not found in session "${sessionId}".`,+        currentTabs: available,+        _hint:+          `Most likely the popup auto-closed (PDF popups close after the ` ++          `viewer renders if Content-Disposition is attachment, or if a ` ++          `download fired). The right pattern with popup tabs is: ` ++          `(1) browser_list_tabs IMMEDIATELY after the click that spawned ` ++          `the popup, (2) read the popup's url field from that response ` ++          `(don't eval against the popup's tabId), (3) browser_fetch_url ` ++          `with that URL — never eval-against-popup-then-fetch.`,+        errorCode: 'tab_not_found',+      };+      if (recentlyClosed) {+        Object.assign(payload, recentlyClosed);+        // Sharpen the hint when we have a URL: the caller can act on it now.+        if (recentlyClosed.lastKnownUrl) {+          payload._hint =+            `This tab closed ${Math.round(recentlyClosed.closedMsAgo / 1000)}s ago at URL ${recentlyClosed.lastKnownUrl}. ` ++            `Call browser_fetch_url with that URL (and the opener tabId "${recentlyClosed.openerTabId || 'tab-1'}" for cookies) to get its bytes — ` ++            `do NOT try to eval against the closed tabId.`;+        }+      }+      const err = new Error(JSON.stringify(payload));+      err._isStructured = true;+      throw err;+    }+    return { tab: t, tabId: t.tabId };+  }+  const t = getActiveTab(session);+  if (!t) {+    throw new Error(`Session "${sessionId}" has no open tabs.`);+  }+  return { tab: t, tabId: t.tabId };+}++// Send a structured-or-plain error from resolveTabOrError. If the error+// message is the structured JSON we throw above, parse it back out and+// surface the fields directly in the response. Otherwise plain string.+function sendTabResolveError(res, e) {+  if (e && e._isStructured) {+    try {+      const parsed = JSON.parse(e.message);+      sendJSON(res, { success: false, ...parsed });+      return;+    } catch {}+  }+  sendJSON(res, { success: false, error: e.message });+}++// Build a plain-object tab description for API responses.+async function describeTab(tab, activeTabId) {+  let url = null, title = null;+  try { url = tab.page.url(); } catch {}+  try { title = await tab.page.title(); } catch {}+  return {+    tabId: tab.tabId,+    url,+    title,+    active: tab.tabId === activeTabId,+    errorCount: tab.errors.length,+    // Provenance: "user" if Claude opened this tab via browser_open_window /+    // browser_open_tab, "popup" if the page itself spawned it via window.open()+    // / target=_blank. openerTabId names the tab that triggered the popup+    // (so callers can correlate "this datasheet popup came from product page X").+    opener: tab.opener || 'user',+    openerTabId: tab.openerTabId || null,+  };+}++// ── Persistent session file helpers ────────────────────────────────+function sessionFilePath(sessionId) {+  return path.join(SESSIONS_DIR, `${sessionId}.json`);+}++function saveSessionFile(sessionId, profileName, url) {+  const entry = browsers.get(profileName);+  let pid = null, cdpPort = null;+  if (entry) {+    // Prefer detached PID (Windows), fall back to Puppeteer's process()+    pid = entry.browser._detachedPid || null;+    try { if (!pid) pid = entry.browser.process()?.pid || null; } catch {}+    // Prefer stored CDP port, fall back to DevToolsActivePort file+    cdpPort = entry.browser._cdpPort || null;+    if (!cdpPort) {+      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');+      try {+        cdpPort = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;+      } catch {}+    }+  }+  const data = {+    sessionId, profile: profileName, url: url || null,+    pid, cdpPort,+    createdAt: new Date().toISOString(),+    updatedAt: new Date().toISOString(),+  };+  try {+    fs.writeFileSync(sessionFilePath(sessionId), JSON.stringify(data, null, 2));+  } catch (e) {+    console.error(`Failed to save session file for "${sessionId}": ${e.message}`);+  }+}++function deleteSessionFile(sessionId) {+  try { fs.unlinkSync(sessionFilePath(sessionId)); } catch {}+}++function readSessionFile(sessionId) {+  try {+    return JSON.parse(fs.readFileSync(sessionFilePath(sessionId), 'utf8'));+  } catch { return null; }+}++function listSessionFiles() {+  try {+    return fs.readdirSync(SESSIONS_DIR)+      .filter(f => f.endsWith('.json'))+      .map(f => {+        try { return JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8')); } catch { return null; }+      })+      .filter(Boolean);+  } catch { return []; }+}++function isProcessAlive(pid) {+  if (!pid) return false;+  try {+    process.kill(pid, 0); // signal 0 = check existence, doesn't kill+    return true;+  } catch { return false; }+}++// ── Session-tag extraction (v1.5.1+) ──────────────────────────────────+// launchSession() injects ` (session: <sessionId> | profile: <profileName>)`+// or ` (session: <sessionId>)` into every page's title via a MutationObserver.+// On reconnect / rescan we recover the sessionId from this tag — it's our+// durable mapping from "puppeteer Page handle" → "bridge sessionId" that+// survives across CDP socket drops, bridge restarts, and Chrome stay-alive+// scenarios. Without this tag (e.g. about:blank pages), the page is+// considered an orphan and will be adopted under a generated sessionId+// if rescan is asked to.+const SESSION_TAG_RE = /\(session:\s*([^|)]+?)(?:\s*\|.*)?\)\s*$/;+function parseSessionIdFromTitle(title) {+  if (!title) return null;+  const m = String(title).match(SESSION_TAG_RE);+  return m ? m[1].trim() : null;+}++// ── Disconnect-handling shared between every browser.on('disconnected')+// site (v1.5.1+) ──────────────────────────────────────────────────────+//+// Old (≤v1.5.0) behavior: when a browser disconnected (CDP socket dropped,+// network blip, puppeteer-side hiccup), the bridge eagerly purged every+// session for that profile from `sessions` AND deleted the on-disk session+// file. The Chrome window was still alive on the user's desktop, fully+// usable, but every reference to it had been wiped — so subsequent+// browser_navigate/eval/screenshot calls hit "Session not found" and+// (worse) browser_navigate without a sessionId silently retargeted+// to whatever session was still in memory, hijacking unrelated windows.+// Confirmed twice in chip-fetcher's session log on 2026-05-04.+//+// New behavior: keep the session entries in memory + on disk so they+// remain "known but currently disconnected." The next browser_rescan+// (manual) or 30-second health check (auto) will try to reconnect via+// the persisted DevToolsActivePort + verify Chrome's PID is still alive,+// then walk pages and re-attach by parsing the (session: X) tag from+// each page's title.+function handleBrowserDisconnect(browser, profileName) {+  const entry = browsers.get(profileName);+  if (!entry || entry.browser !== browser) return;+  browsers.delete(profileName);+  let touched = 0;+  for (const s of sessions.values()) {+    if (s.profileName === profileName) {+      s._lostBrowser = true;+      s._disconnectedAt = Date.now();+      touched++;+    }+  }+  console.log(+    `[disconnect] profile "${profileName}" disconnected — ${touched} session(s) marked as awaiting reconnect. ` ++    `Run browser_rescan to recover; the 30s health check will also auto-attempt.`+  );+}++// v1.6.1+: scan the running process list for a Chrome process whose+// command line includes `--user-data-dir=...<profileName>`. Returns+// `[{pid, cdpPort}, ...]` for every match. Used as a third-tier fallback+// when neither the on-disk session file nor the profile's+// DevToolsActivePort file knows about an alive Chrome — which is the+// canonical sleep/wake symptom (DevToolsActivePort is rewritten+// transiently and can be missing after wake).+async function findChromesByProfile(profileName) {+  // Use Windows tasklist + wmic equivalent OR PowerShell on Windows;+  // ps + grep elsewhere. Both return PID + full command line so we can+  // parse out --remote-debugging-port=<NNNN>.+  const matches = [];+  try {+    if (process.platform === 'win32') {+      // PowerShell's CIM is the most reliable way to get full command+      // lines on Windows. Fall back to wmic if PowerShell isn't+      // available (very rare on modern Windows). We use a single+      // -Command invocation so child-process startup cost is paid once.+      const { spawnSync } = require('child_process');+      const result = spawnSync('powershell', [+        '-NoProfile',+        '-Command',+        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match '${profileName.replace(/[\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\]/g, '\\\\$&')}' } | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`,+      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });+      if (result.stdout) {+        let parsed;+        try {+          parsed = JSON.parse(result.stdout);+        } catch {+          parsed = null;+        }+        const items = Array.isArray(parsed) ? parsed : (parsed ? [parsed] : []);+        for (const item of items) {+          const cmd = item.CommandLine || '';+          // Only consider the parent Chrome process — skip --type=renderer+          // / --type=gpu-process / --type=crashpad-handler etc. They share+          // the user-data-dir but aren't the listening parent.+          if (/--type=/.test(cmd)) continue;+          // Must look like a pup-managed Chrome — has --remote-debugging-port.+          const portMatch = cmd.match(/--remote-debugging-port=(\d+)/);+          if (!portMatch) continue;+          // Must reference our profile dir specifically (not just the name+          // appearing somewhere in args).+          const profileNeedle = path.join(PROFILES_DIR, profileName).toLowerCase();+          if (!cmd.toLowerCase().includes(profileNeedle.toLowerCase())) continue;+          matches.push({ pid: item.ProcessId, cdpPort: parseInt(portMatch[1], 10) });+        }+      }+    } else {+      // POSIX: ps + grep. Same logic, simpler shape.+      const { spawnSync } = require('child_process');+      const result = spawnSync('ps', ['-eo', 'pid,command'], { encoding: 'utf8', timeout: 5_000 });+      if (result.stdout) {+        for (const line of result.stdout.split('\n')) {+          if (!line.includes('chrome')) continue;+          if (/--type=/.test(line)) continue;+          const portMatch = line.match(/--remote-debugging-port=(\d+)/);+          if (!portMatch) continue;+          const profileNeedle = path.join(PROFILES_DIR, profileName);+          if (!line.includes(profileNeedle)) continue;+          const pidMatch = line.trim().match(/^(\d+)/);+          if (!pidMatch) continue;+          matches.push({ pid: parseInt(pidMatch[1], 10), cdpPort: parseInt(portMatch[1], 10) });+        }+      }+    }+  } catch (e) {+    console.log(`[findChromesByProfile] scan failed for "${profileName}": ${e.message}`);+  }+  return matches;+}++// v1.6.1+: kill every Chrome process associated with a profile, then+// remove the lock files Chrome leaves behind. Used before relaunching+// when a previous Chrome died unexpectedly (sleep/wake / crash) and+// left the profile in a state where a fresh `puppeteer.launch()` would+// exit immediately with "Chrome process exited immediately."+//+// Lock files we clean (cross-platform — Chrome creates a different set+// per OS):+//   - lockfile          (Windows — Chrome's own profile lock)+//   - SingletonLock     (Linux/macOS — Chrome's instance singleton)+//   - SingletonSocket   (Linux/macOS — same)+//   - SingletonCookie   (Linux/macOS — same)+//   - DevToolsActivePort (Chrome rotates this — stale value points at a+//     dead Chrome; we delete to force a re-write on next launch)+async function cleanProfileLocks(profileName) {+  const profileDir = path.join(PROFILES_DIR, profileName);+  if (!fs.existsSync(profileDir)) {+    return { cleaned: [], note: 'profile directory does not exist' };+  }+  const lockNames = [+    'lockfile',+    'SingletonLock',+    'SingletonSocket',+    'SingletonCookie',+    'DevToolsActivePort',+  ];+  const cleaned = [];+  for (const name of lockNames) {+    const p = path.join(profileDir, name);+    try {+      // unlinkSync errors if missing — that's fine, ignore.+      fs.unlinkSync(p);+      cleaned.push(name);+    } catch {}+  }+  console.log(`[cleanProfileLocks] profile "${profileName}": cleaned ${JSON.stringify(cleaned)}`);+  return { cleaned };+}++// v1.6.1+: kill every Chrome process matching this profile. Called+// before clean+relaunch when a stale Chrome is holding the profile.+// Idempotent — does nothing if no chrome processes match.+async function killOrphanChromesForProfile(profileName) {+  const found = await findChromesByProfile(profileName);+  let killed = 0;+  for (const { pid } of found) {+    try {+      if (process.platform === 'win32') {+        const { spawnSync } = require('child_process');+        spawnSync('taskkill', ['/F', '/T', '/PID', String(pid)], { timeout: 5_000, windowsHide: true });+      } else {+        process.kill(pid, 'SIGKILL');+      }+      killed++;+    } catch (e) {+      console.log(`[killOrphan] failed to kill PID ${pid}: ${e.message}`);+    }+  }+  if (killed > 0) {+    console.log(`[killOrphan] killed ${killed} Chrome process tree(s) for profile "${profileName}"`);+    // Give the OS a beat to release file handles before the caller+    // tries to delete profile locks / relaunch.+    await new Promise(r => setTimeout(r, 1500));+  }+  return { killed };+}++// Try to puppeteer.connect to an already-alive Chrome for `profileName`,+// using the on-disk session file's recorded cdpPort + DevToolsActivePort+// fallback. Returns the Browser on success, null on failure. Caller is+// responsible for setting up listeners (handleBrowserDisconnect, popup+// tracking, default permissions). Idempotent — if a live entry already+// exists in `browsers`, returns that one.+async function attemptReconnectProfile(profileName) {+  const existing = browsers.get(profileName);+  if (existing && existing.browser.isConnected()) {+    return existing.browser;+  }+  // Pick any session file for this profile to discover cdpPort / pid.+  const files = listSessionFiles().filter(sf => sf.profile === profileName);+  let port = null, pid = null;+  for (const sf of files) {+    if (sf.cdpPort) port = port || sf.cdpPort;+    if (sf.pid) pid = pid || sf.pid;+  }+  // Fall back to DevToolsActivePort file in the profile dir.+  if (!port) {+    try {+      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');+      port = parseInt(fs.readFileSync(portFile, 'utf8').trim().split('\n')[0], 10) || null;+    } catch {}+  }+  // v1.6.1+: third-tier fallback — scan running Chrome processes for one+  // whose --user-data-dir matches this profile. This catches the canonical+  // sleep/wake symptom: Chrome is alive, has its CDP port open and serving+  // /json/version, but the bridge has no on-disk record of it (e.g. session+  // file was deleted by an old aggressive disconnect handler before v1.5.1,+  // OR the bridge restarted after launch). See findChromesByProfile.+  if (!port) {+    const found = await findChromesByProfile(profileName);+    if (found.length > 0) {+      port = found[0].cdpPort;+      pid = pid || found[0].pid;+      console.log(`[reconnect] discovered orphan chrome via cmdline scan: profile="${profileName}" pid=${pid} port=${port}`);+    }+  }+  if (!port) {+    console.log(`[reconnect] no cdpPort known for profile "${profileName}" — cannot reconnect`);+    return null;+  }+  // Quick liveness probe with a 3s timeout (sleep/wake can leave half-open+  // sockets that hang fetch indefinitely; we'd rather fail fast and let+  // the next probe try again).+  const ac = new AbortController();+  const tid = setTimeout(() => ac.abort(), 3000);+  let ok = false;+  try {+    const resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });+    ok = !!(resp && resp.ok);+  } catch {} finally {+    clearTimeout(tid);+  }+  if (!ok) {+    console.log(`[reconnect] CDP port ${port} for profile "${profileName}" not responding`);+    return null;+  }+  try {+    const browser = await Promise.race([+      puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }),+      new Promise((_, rej) => setTimeout(() => rej(new Error('puppeteer.connect timeout')), 5000)),+    ]);+    if (pid) browser._detachedPid = pid;+    browser._cdpPort = port;+    browsers.set(profileName, { browser, refCount: 0 });+    wirePopupTracking(browser, profileName);+    browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));+    console.log(`[reconnect] reconnected to Chrome on port ${port} for profile "${profileName}"`);+    return browser;+  } catch (e) {+    console.log(`[reconnect] puppeteer.connect failed for profile "${profileName}" port ${port}: ${e.message}`);+    return null;+  }+}++// Walk every page in `browser` and rebuild `sessions` entries for any+// page whose title carries a `(session: X)` tag. Pages without a tag+// are left alone unless `adoptOrphans:true` is passed, in which case+// they're attached under a generated sessionId so the caller can drive+// them. Idempotent — pages already attached to a live session are left+// in place.+async function rescanProfile(browser, profileName, { adoptOrphans = false } = {}) {+  const pages = await browser.pages();+  // Build the "page → existing tab" map so we know what's already attached.+  const existingPageTab = new Map();+  for (const s of sessions.values()) {+    if (s.profileName !== profileName) continue;+    for (const t of s.tabs) existingPageTab.set(t.page, { sid: null, t });+  }+  for (const [sid, s] of sessions.entries()) {+    if (s.profileName !== profileName) continue;+    for (const t of s.tabs) {+      const cur = existingPageTab.get(t.page);+      if (cur) cur.sid = sid;+    }+  }+  let reattached = 0, adopted = 0, alreadyLive = 0;+  const seenSessions = new Set();+  for (const page of pages) {+    if (existingPageTab.has(page)) {+      alreadyLive++;+      const cur = existingPageTab.get(page);+      if (cur && cur.sid) seenSessions.add(cur.sid);+      continue;+    }+    let title = '';+    try { title = await page.title(); } catch { /* page closed during rescan */ continue; }+    let sessionId = parseSessionIdFromTitle(title);+    if (!sessionId) {+      // No tag — could be a freshly-opened tab the user navigated to via+      // Chrome's New Tab Page, or a page the bridge already managed but+      // whose title got replaced by a script before the MutationObserver+      // could re-inject. Adopt only when explicitly asked.+      if (!adoptOrphans) continue;+      sessionId = `orphan-${profileName}-${Date.now()}-${adopted}`;+      adopted++;+    }+    let session = sessions.get(sessionId);+    if (!session) {+      session = createSession(profileName);+      sessions.set(sessionId, session);+    } else if (session.profileName !== profileName) {+      // SessionId collision across profiles — shouldn't happen in practice+      // since profile defaults to sessionId, but guard anyway. Skip rather+      // than clobber.+      console.log(`[rescan] sessionId "${sessionId}" found in profile "${session.profileName}" but page is in profile "${profileName}" — skipping`);+      continue;+    }+    session._lostBrowser = false;+    delete session._disconnectedAt;+    attachTab(session, sessionId, page, { makeActive: false });+    seenSessions.add(sessionId);+    reattached++;+  }+  // Sessions for this profile that have no live tab any more (Chrome may+  // have closed the tab while disconnected) keep their entries marked+  // _lostBrowser so the strict resolveSession path surfaces them clearly.+  for (const [sid, s] of sessions.entries()) {+    if (s.profileName !== profileName) continue;+    if (!seenSessions.has(sid) && s.tabs.length === 0) {+      s._lostBrowser = true;+    }+  }+  console.log(+    `[rescan] profile "${profileName}": ${reattached} reattached, ${adopted} orphans adopted, ` ++    `${alreadyLive} pages already live`+  );+  return { reattached, adopted, alreadyLive };+}++// Walk every known profile (in-memory + on-disk) and try to rebuild a+// complete session map. Used by browser_rescan and by the 30s health+// check when it detects a disconnected browser whose Chrome PID is+// still alive. Returns aggregated stats for the response payload.+//+// v1.6.1+: also discovers profiles via a running-process scan. Catches+// the case where the bridge restarted (or was launched fresh after a+// crash) and a Chrome window is still alive on the desktop with no+// in-memory or on-disk record. The chipsmith-after-sleep scenario hit+// exactly this: Chrome alive, bridge had nothing for it.+async function rescanAllProfiles({ adoptOrphans = false } = {}) {+  const profileNames = new Set();+  for (const p of browsers.keys()) profileNames.add(p);+  for (const sf of listSessionFiles()) profileNames.add(sf.profile);+  // v1.6.1: scan for orphan Chrome processes running with a profile dir+  // under our PROFILES_DIR — they're pup-managed Chromes that the bridge+  // has lost track of. Each match is a profile we should try to recover.+  try {+    if (process.platform === 'win32') {+      const { spawnSync } = require('child_process');+      const profilesDir = PROFILES_DIR.replace(/[\\.\\^\\$\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\]/g, '\\\\$&');+      const result = spawnSync('powershell', [+        '-NoProfile', '-Command',+        `Get-CimInstance Win32_Process -Filter "Name='chrome.exe'" | Where-Object { $_.CommandLine -match 'remote-debugging-port' -and $_.CommandLine -match '${profilesDir}' -and $_.CommandLine -notmatch '--type=' } | ForEach-Object { if ($_.CommandLine -match 'user-data-dir=([^"]*?)(?=\\s+--|"$|$)') { Split-Path -Leaf $matches[1] } } | Sort-Object -Unique`,+      ], { encoding: 'utf8', timeout: 10_000, windowsHide: true });+      if (result.stdout) {+        for (const line of result.stdout.split('\n')) {+          const name = line.trim().replace(/['"]/g, '');+          if (name) profileNames.add(name);+        }+      }+    } else {+      // POSIX equivalent — quick ps + grep + extract.+      const { spawnSync } = require('child_process');+      const result = spawnSync('ps', ['-eo', 'command'], { encoding: 'utf8', timeout: 5_000 });+      if (result.stdout) {+        const seen = new Set();+        for (const line of result.stdout.split('\n')) {+          if (!line.includes('chrome')) continue;+          if (line.includes('--type=')) continue;+          if (!line.includes('remote-debugging-port')) continue;+          if (!line.includes(PROFILES_DIR)) continue;+          const m = line.match(new RegExp(`user-data-dir=([^\\s]+/${path.basename(PROFILES_DIR)}/[^\\s]+)`));+          if (m) {+            const name = path.basename(m[1]);+            if (!seen.has(name)) { seen.add(name); profileNames.add(name); }+          }+        }+      }+    }+  } catch (e) {+    console.log(`[rescanAll] orphan process scan failed: ${e.message}`);+  }+  let totalReattached = 0, totalAdopted = 0, profilesScanned = 0, profilesReconnected = 0, profilesUnreachable = 0;+  for (const profileName of profileNames) {+    let browser = browsers.get(profileName)?.browser;+    if (!browser || !browser.isConnected()) {+      browser = await attemptReconnectProfile(profileName);+      if (browser) profilesReconnected++;+    }+    if (!browser) {+      profilesUnreachable++;+      // Mark any in-memory sessions for this profile as still disconnected+      for (const s of sessions.values()) {+        if (s.profileName === profileName) s._lostBrowser = true;+      }+      continue;+    }+    try {+      const r = await rescanProfile(browser, profileName, { adoptOrphans });+      totalReattached += r.reattached;+      totalAdopted += r.adopted;+      profilesScanned++;+    } catch (e) {+      console.log(`[rescan] profile "${profileName}" rescan threw: ${e.message}`);+    }+  }+  return { totalReattached, totalAdopted, profilesScanned, profilesReconnected, profilesUnreachable };+}++// Recover sessions from disk on startup — reconnect to existing Chrome processes+async function recoverSessions() {+  const files = listSessionFiles();+  if (files.length === 0) return;+  console.log(`Recovering ${files.length} session(s) from disk...`);++  for (const sf of files) {+    const { sessionId, profile: profileName, pid, cdpPort, url } = sf;++    // Skip if already in memory+    if (sessions.has(sessionId)) continue;++    // Check if Chrome is still alive+    if (!isProcessAlive(pid)) {+      console.log(`Session "${sessionId}" (PID ${pid}) is dead — removing stale file`);+      deleteSessionFile(sessionId);+      continue;+    }++    // Try to reconnect via CDP+    if (!cdpPort) {+      // Try reading DevToolsActivePort from profile dir+      const portFile = path.join(PROFILES_DIR, profileName, 'DevToolsActivePort');+      try {+        const portData = fs.readFileSync(portFile, 'utf8').trim();+        sf.cdpPort = parseInt(portData.split('\n')[0], 10) || null;+      } catch {}+    }++    const port = sf.cdpPort || cdpPort;+    if (!port) {+      console.log(`Session "${sessionId}" has no CDP port — removing stale file`);+      deleteSessionFile(sessionId);+      continue;+    }++    try {+      // Check if Chrome is actually listening+      const resp = await fetch(`http://127.0.0.1:${port}/json/version`).catch(() => null);+      if (!resp || !resp.ok) {+        console.log(`Session "${sessionId}" CDP port ${port} not responding — killing orphaned Chrome PID ${pid}`);+        try { process.kill(pid, 'SIGKILL'); } catch (e) { console.log(`  kill failed: ${e.message}`); }+        deleteSessionFile(sessionId);+        continue;+      }++      // Reconnect browser if needed+      if (!browsers.has(profileName) || !browsers.get(profileName).browser.isConnected()) {+        const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null });+        browser._detachedPid = pid; // Preserve PID so killBrowserProcess can find it+        browser._cdpPort = port;+        browsers.set(profileName, { browser, refCount: 0 });+        wirePopupTracking(browser, profileName);+        // v1.5.1+: shared disconnect handler — keeps sessions intact for+        // recovery instead of erasing them. See handleBrowserDisconnect.+        browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));+        console.log(`Reconnected to Chrome on port ${port} for profile "${profileName}"`);+      }++      // Find the page that matches this session's URL+      const browser = browsers.get(profileName).browser;+      const pages = await browser.pages();+      let page = null;++      // Try to match by URL+      if (url) {+        page = pages.find(p => {+          try { return p.url().startsWith(url.split('?')[0]); } catch { return false; }+        });+      }+      // Fallback: use the last page that isn't about:blank+      if (!page) {+        page = pages.filter(p => { try { return p.url() !== 'about:blank'; } catch { return false; } }).pop();+      }+      // Last resort: any page+      if (!page && pages.length > 0) {+        page = pages[pages.length - 1];+      }++      if (!page) {+        console.log(`Session "${sessionId}" — no pages found in Chrome, removing`);+        deleteSessionFile(sessionId);+        continue;+      }++      const session = createSession(profileName);+      attachTab(session, sessionId, page);+      sessions.set(sessionId, session);+      const be = browsers.get(profileName);+      if (be) be.refCount++;+      if (!activeSessionId) activeSessionId = sessionId;++      console.log(`Recovered session "${sessionId}" (profile: ${profileName}, URL: ${page.url()})`);+    } catch (e) {+      console.error(`Failed to recover session "${sessionId}": ${e.message}`);+      deleteSessionFile(sessionId);+    }+  }+  console.log(`Recovery complete. ${sessions.size} session(s) active.`);+}++// Catch unhandled errors so server never crashes+process.on('uncaughtException', (e) => {+  console.error('Uncaught:', e.message);+});+process.on('unhandledRejection', (e) => {+  console.error('Unhandled rejection:', e);+});+// On bridge shutdown, disconnect from Chrome without killing it.+// Chrome has handleSIGINT/SIGTERM/SIGHUP: false and --remote-debugging-port=0,+// so it'll keep running. The session files on disk let the next bridge reconnect.+process.on('exit', () => {+  for (const [, be] of browsers) {+    try { be.browser.disconnect(); } catch {}+  }+});+process.on('SIGINT', () => { process.exit(0); });+process.on('SIGTERM', () => { process.exit(0); });++function setupErrorCollection(pg, errorsArr) {+  pg.on('console', msg => {+    if (msg.type() === 'error') {+      errorsArr.push({ type: 'console.error', text: msg.text(), ts: Date.now() });+    }+  });+  pg.on('pageerror', err => {+    errorsArr.push({ type: 'exception', text: err.message, ts: Date.now() });+  });+  pg.on('requestfailed', req => {+    if (req.failure()) {+      errorsArr.push({ type: 'network', text: `${req.failure().errorText}: ${req.url()}`, ts: Date.now() });+    }+  });+}++// Kill a Chrome browser process forcefully by PID+// Falls back to browser.close() if PID isn't available.+// `browser` may be null when we're just cleaning up an orphaned lock file+// and have no live puppeteer handle.+async function killBrowserProcess(browser, profileName) {+  if (!profileName) {+    console.log('killBrowserProcess called without profileName — skipping');+    return;+  }+  let pid = null;+  if (browser) {+    pid = browser._detachedPid || null;+    if (!pid) {+      try { pid = browser.process()?.pid; } catch (e) { /* connected browser has no process() */ }+    }+    // Try graceful close first+    try { await browser.close(); } catch (e) {+      console.log(`Graceful close failed for profile "${profileName}": ${e.message}`);+    }+  }++  // If no browser handle, try to recover PID from session files on disk+  if (!pid) {+    try {+      const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);+      if (sFiles.length > 0) pid = sFiles[0].pid;+    } catch {}+  }++  // Then force-kill the PID if it's still around+  if (pid) {+    try {+      if (process.platform === 'win32') {+        execSync(`taskkill /F /T /PID ${pid}`, { stdio: 'ignore' });+      } else {+        process.kill(pid, 'SIGKILL');+      }+      console.log(`Force-killed Chrome PID ${pid} for profile "${profileName}"`);+    } catch (e) {+      // PID already gone — that's fine+    }+  }++  // Also check if Chrome is still locking the profile dir+  const lockFile = path.join(PROFILES_DIR, profileName, 'SingletonLock');+  try { fs.unlinkSync(lockFile); } catch { /* doesn't exist or can't remove */ }+}++// ─── Profile session-restore prevention ──────────────────────────────────+//+// Persistent profiles (--user-data-dir) make Chrome remember the previous+// session. Without intervention, Chrome's "restore previous session" path+// re-opens every tab the user had on last close, AND if Chrome thinks the+// last exit was unclean (which happens whenever puppeteer's child process+// is force-killed) it pops the "Restore tabs?" bubble too. End result is+// stale tab buildup across close/reopen cycles — 8+ tabs after a few iters.+//+// The reliable fix is two-pronged at profile level:+//+// 1. Patch <userDataDir>/Default/Preferences before launch:+//    - profile.exit_type = "Normal"+//    - profile.exited_cleanly = true+//      → Chrome thinks the last exit was clean, skips the restore bubble.+//    - session.restore_on_startup = 5 (open new tab page)+//    - session.startup_urls = []+//      → Even if user had set "On startup → continue where you left off",+//        we override it to "Open the New Tab page".+//+// 2. Delete the session-state blobs that Chrome reads to rebuild tabs:+//    - Default/Last Session, Default/Last Tabs+//    - Default/Current Session, Default/Current Tabs+//    - Default/Sessions/    (newer Chromium session-store dir)+//    - Default/Tabs/         (newer Chromium tab-store dir)+//+// We only run this when LAUNCHING (not when reconnecting to a running+// Chrome via DevToolsActivePort). Reconnect path means Chrome is already+// alive and we shouldn't touch its profile files.+function prepareCleanProfile(userDataDir) {+  if (!userDataDir) return;+  try {+    const defaultDir = path.join(userDataDir, 'Default');+    fs.mkdirSync(defaultDir, { recursive: true });++    // 1. Patch Preferences JSON+    const prefsPath = path.join(defaultDir, 'Preferences');+    let prefs = {};+    try {+      const raw = fs.readFileSync(prefsPath, 'utf8');+      prefs = JSON.parse(raw);+    } catch (e) {+      // Missing or unreadable Preferences file = first launch; that's fine+      prefs = {};+    }+    prefs.profile = prefs.profile || {};+    prefs.profile.exit_type = 'Normal';+    prefs.profile.exited_cleanly = true;+    prefs.session = prefs.session || {};+    prefs.session.restore_on_startup = 5; // 5 = new tab page; 1 = restore last+    prefs.session.startup_urls = [];+    fs.writeFileSync(prefsPath, JSON.stringify(prefs));++    // 2. Delete session-state blobs (best effort — Chrome may not have written some)+    const blobs = ['Last Session', 'Last Tabs', 'Current Session', 'Current Tabs'];+    for (const name of blobs) {+      const p = path.join(defaultDir, name);+      try { fs.rmSync(p, { force: true }); } catch {}+    }+    const dirs = ['Sessions', 'Tabs'];+    for (const name of dirs) {+      const p = path.join(defaultDir, name);+      try { fs.rmSync(p, { recursive: true, force: true }); } catch {}+    }+  } catch (e) {+    // Profile cleanup is defense-in-depth — don't fail the launch if it errors+    console.log(`prepareCleanProfile("${userDataDir}") warning: ${e.message}`);+  }+}++// After launch, sweep any pages Chrome opened that weren't created by us.+// This is the second line of defense in case Preferences-patching missed+// some restore path. session.tabs[] tracks the pages WE registered; any+// browser.pages() entries beyond that are restored leftovers — close them.+// Keeps the page we just opened (matched by URL or by being the only one+// the bridge has registered).+async function closeRestoredLeftoverTabs(browser, keepPage) {+  let closed = 0;+  try {+    const pages = await browser.pages();+    for (const p of pages) {+      if (p === keepPage) continue;+      // Skip blank/about:blank (Chrome's default first tab) — handled separately+      let url = '';+      try { url = p.url() || ''; } catch {}+      if (url.startsWith('chrome://newtab') || url === 'about:blank' || url === '') {+        // Don't kill the blank tab here; new-tab reuse logic handles it+        continue;+      }+      try { await p.close({ runBeforeUnload: false }); closed++; } catch {}+    }+  } catch (e) {+    console.log(`closeRestoredLeftoverTabs warning: ${e.message}`);+  }+  return closed;+}++// Get or launch a Chrome browser for a given profile name+// Multiple sessions can share the same browser (as tabs) if they use the same profile+//+// v1.6.3+: full-capability default. Pup is a tool-driving surface, NOT a+// user-facing browser — every chip-fetcher-style flow needs Chrome in+// "everything allowed" mode so JS-triggered downloads don't get silently+// blocked, clipboard access works for pasting into vendor forms, and+// permission prompts don't pop up under automation.+//+// Two CDP mechanisms together give us this:+//+//   1. Browser.setPermission (per-origin permissions) — granted for+//      clipboard-read/write so paste-into-input-via-eval works; DENIED+//      for notifications + geolocation so prompts don't fire mid-flow.+//      Per-permission so a single unknown-name failure doesn't break+//      the others (the v1.4.12 Browser.grantPermissions array form+//      failed on Chrome 146 because "automaticDownloads" was+//      unrecognized — taking the whole grant down with it).+//+//   2. Page.setDownloadBehavior — see applyDownloadBehavior below. This+//      is the actual mechanism for unblocking scripted downloads. The+//      v1.4.12 attempt at "automaticDownloads" via grantPermissions+//      didn't help with scripted downloads — it controlled the multi-+//      file infobar, which is a different layer. setDownloadBehavior+//      tells Chrome to actually save the file when JS triggers a nav+//      to a binary content-type.+//+// Opt-out: pass { strictPermissions: true } to browser_open_window for+// sessions that genuinely need the user to gate every download (rare).+//+// History: v1.4.12 used Browser.grantPermissions(['automaticDownloads',+// 'notifications', 'clipboardReadWrite']). On Chrome 146 it failed with+// "Unknown permission type: automaticDownloads" — taking the whole+// grant down. v1.6.3 fixes by switching to Browser.setPermission per-+// permission and dropping the unhelpful automaticDownloads (which we+// replace with the actually-effective Page.setDownloadBehavior).+const PERMISSIVE_DEFAULT_PERMISSIONS = [+  { name: 'clipboard-read', setting: 'granted' },+  { name: 'clipboard-write', setting: 'granted' },+  { name: 'clipboard-sanitized-write', setting: 'granted' },+  { name: 'notifications', setting: 'denied' },+  { name: 'geolocation', setting: 'denied' },+  { name: 'midi', setting: 'denied' },+];+async function applyDefaultPermissions(browser, profileName, { strictPermissions = false } = {}) {+  if (strictPermissions) {+    console.log(`[permissions] strictPermissions=true — leaving Chromium defaults in place for profile "${profileName}"`);+    return { applied: false, reason: 'strictPermissions' };+  }+  const session = await browser.target().createCDPSession().catch(() => null);+  if (!session) {+    console.log(`[permissions] could not open CDP session for "${profileName}" — skipping`);+    return { applied: false, reason: 'cdp-session-open-failed' };+  }+  const applied = [];+  const failed = [];+  try {+    for (const p of PERMISSIVE_DEFAULT_PERMISSIONS) {+      try {+        await session.send('Browser.setPermission', {+          permission: { name: p.name },+          setting: p.setting,+          // origin omitted → all origins on default browser context+        });+        applied.push(`${p.name}=${p.setting}`);+      } catch (e) {+        failed.push({ name: p.name, error: e.message });+      }+    }+  } finally {+    try { await session.detach(); } catch {}+  }+  if (applied.length > 0) {+    console.log(`[permissions] profile "${profileName}": applied [${applied.join(', ')}]${failed.length > 0 ? `; failed [${failed.map(f => f.name).join(', ')}]` : ''}`);+  } else if (failed.length > 0) {+    console.log(`[permissions] profile "${profileName}": ALL grants failed: ${JSON.stringify(failed)}`);+  }+  return { applied, failed };+}++// v1.6.3+: enable scripted downloads on a page. This is the SECOND half+// of pup's full-capability default (the first half is applyDefaultPermissions+// above). Without setDownloadBehavior=allow, JS-triggered downloads (a+// click on `<a download href="...">`, a `fetch().then(blob → save)`, a+// vendor "Download Symbol" button that does `window.location = url`) get+// SILENTLY BLOCKED by Chrome — no error, no UI, no file in ~/Downloads.+// chip-fetcher debugged this for hours before figuring out the cause.+//+// Why per-page and not per-browser: setDownloadBehavior is a Page-domain+// command. Each tab needs its own application. We also re-apply on every+// main-frame navigation as a safety belt — empirically Chrome doesn't+// reset across same-context navs, but cross-context (browser-restored+// tabs, target-domain disconnect/reconnect) can lose it.+//+// downloadPath defaults to %USERPROFILE%\Downloads on Windows / ~/Downloads+// on POSIX — chip-fetcher's `desktop_watch_files` polls this exact dir,+// so the two ends meet by default. Caller can override via the+// `downloadPath` arg on browser_open_window if they need somewhere else.+async function applyDownloadBehavior(page, downloadPath) {+  if (!downloadPath) {+    downloadPath = path.join(os.homedir(), 'Downloads');+  }+  // Ensure the dir exists — Chrome silently no-ops if the path doesn't+  // exist on Windows; the file ends up nowhere and the AI debug-loops.+  try { fs.mkdirSync(downloadPath, { recursive: true }); } catch {}+  let cdp;+  try {+    cdp = await page.target().createCDPSession();+    await cdp.send('Page.setDownloadBehavior', {+      behavior: 'allow',+      downloadPath,+    });+  } catch (e) {+    // Older Chromes used the now-deprecated Browser.setDownloadBehavior+    // (with browserContextId). Try that as a fallback. Modern Chrome+    // (≥83) supports both forms; this branch only fires on truly old+    // Chrome for Testing builds.+    try {+      if (cdp) await cdp.send('Browser.setDownloadBehavior', {+        behavior: 'allow',+        downloadPath,+      });+    } catch (e2) {+      console.log(`[downloads] setDownloadBehavior failed for page (non-fatal): ${e.message} / fallback: ${e2.message}`);+    }+  } finally {+    try { if (cdp) await cdp.detach(); } catch {}+  }+}++async function getOrLaunchBrowser(profileName, { freshProfile = false, strictPermissions = false, nativeSite = false } = {}) {+  // Check if browser already running for this profile.+  // isConnected() can be stale after laptop sleep/wake — the WebSocket hasn't+  // noticed the disconnect yet. Validate with a real CDP call (pages()) with+  // a short timeout to catch zombie browser handles.+  const existing = browsers.get(profileName);+  if (existing && existing.browser.isConnected()) {+    try {+      await Promise.race([+        existing.browser.pages(),+        new Promise((_, rej) => setTimeout(() => rej(new Error('health check timeout')), 2000)),+      ]);+      return existing.browser; // Really alive+    } catch (e) {+      console.log(`Cached browser for "${profileName}" failed health check (${e.message}) — discarding`);+    }+  }+  // Clean up stale entry+  if (existing) {+    try { await existing.browser.close(); } catch (e) {}+    browsers.delete(profileName);+  }++  const launchOpts = {+    headless: false,+    defaultViewport: null,+    args: [+      '--no-sandbox', '--disable-setuid-sandbox', '--disable-web-security',+      '--start-maximized', '--remote-debugging-port=0',+      // Suppress "Sign in to Chromium" popup and first-run dialogs+      '--no-first-run', '--no-default-browser-check', '--disable-default-apps',+      '--disable-sync', '--disable-background-networking',+      // Suppress "Chrome for Testing" infobar+      '--disable-infobars',+      // Suppress "Chrome is being controlled by automated test software" bar+      '--enable-automation=false',+      '--disable-blink-features=AutomationControlled',+      // Tab recording uses CDP Page.startScreencast piped to ffmpeg — see+      // tabRecordStartImpl below. CDP screencast is scoped to a specific+      // page target at the protocol level, so cross-tab/cross-Chrome+      // leakage is impossible. The flags below keep the page rendering at+      // full rate even when occluded, so screencast frames stream+      // continuously instead of being paint-throttled to ~0 fps:+      //   --disable-renderer-backgrounding:        keep rendering when in background tab+      //   --disable-backgrounding-occluded-windows: keep rendering when window is occluded+      //   --disable-background-timer-throttling:    keep timers/RAF firing in background+      //   --disable-features=CalculateNativeWinOcclusion:+      //                                              skip the occlusion-detection probe+      // Without these, recording a tab while the user is in another app+      // produces a 1-2 fps video.+      '--disable-renderer-backgrounding',+      '--disable-backgrounding-occluded-windows',+      '--disable-background-timer-throttling',+      '--disable-features=CalculateNativeWinOcclusion',+    ],+    ignoreDefaultArgs: ['--enable-automation'],+    // Let Chrome survive if the bridge process dies — enables reconnect on restart+    handleSIGINT: false,+    handleSIGTERM: false,+    handleSIGHUP: false,+    pipe: false,+  };++  // nativeSite mode: strip flags that break complex Google sites (GCP Console,+  // Google Workspace admin, etc.). These sites rely on background networking,+  // CORS enforcement, and Chrome sync for org/billing context.+  if (nativeSite) {+    const stripFlags = new Set(['--disable-background-networking', '--disable-sync', '--disable-web-security']);+    launchOpts.args = launchOpts.args.filter(a => !stripFlags.has(a));+    console.log(`nativeSite mode: stripped ${stripFlags.size} flags for real-site compatibility`);+  }++  let userDataDir = null;+  if (!freshProfile) {+    userDataDir = path.join(PROFILES_DIR, profileName);+    fs.mkdirSync(userDataDir, { recursive: true });+    launchOpts.userDataDir = userDataDir;+    console.log(`Browser for profile "${profileName}": persistent at ${userDataDir}`);+  } else {+    console.log(`Browser for profile "${profileName}": fresh temporary profile`);+  }++  // Try to reconnect to an existing Chrome first (e.g. after bridge restart).+  // After laptop sleep/wake, fetch() can hang indefinitely on dead sockets,+  // so we apply a 3s timeout via AbortController — failure falls through+  // to the launch path (which has its own retry + orphan cleanup).+  let browser;+  if (userDataDir) {+    const portFile = path.join(userDataDir, 'DevToolsActivePort');+    try {+      const portData = fs.readFileSync(portFile, 'utf8').trim();+      const port = parseInt(portData.split('\n')[0], 10);+      if (port && !isNaN(port)) {+        const ac = new AbortController();+        const tid = setTimeout(() => ac.abort(), 3000);+        let resp = null;+        try {+          resp = await fetch(`http://127.0.0.1:${port}/json/version`, { signal: ac.signal });+        } catch (e) {+          // timed out, refused, or TCP stack hung after sleep — ignore+          console.log(`Reconnect probe to :${port} failed (${e.message}) — will relaunch`);+        } finally {+          clearTimeout(tid);+        }+        if (resp && resp.ok) {+          // Also gate puppeteer.connect with a timeout — it can hang too+          try {+            browser = await Promise.race([+              puppeteer.connect({ browserURL: `http://127.0.0.1:${port}`, defaultViewport: null }),+              new Promise((_, rej) => setTimeout(() => rej(new Error('puppeteer.connect timeout')), 5000)),+            ]);+            browser._cdpPort = port;+            const sFiles = listSessionFiles().filter(sf => sf.profile === profileName && sf.pid);+            if (sFiles.length > 0) browser._detachedPid = sFiles[0].pid;+            console.log(`Reconnected to existing Chrome on port ${port} for profile "${profileName}"`);+          } catch (e) {+            console.log(`puppeteer.connect to :${port} failed (${e.message}) — will relaunch`);+            browser = null;+          }+        }+      }+    } catch { /* DevToolsActivePort doesn't exist or is stale — launch fresh */ }+  }++  if (!browser) {+    // Pre-launch cleanup: kill any orphaned Chrome holding this profile's lock+    if (userDataDir) {+      const lockFile = path.join(userDataDir, 'SingletonLock');+      const lockExists = fs.existsSync(lockFile) || fs.existsSync(path.join(userDataDir, 'lockfile'));+      if (lockExists) {+        console.log(`Profile "${profileName}" has a lock file — checking for orphaned Chrome...`);+        await killBrowserProcess(null, profileName);+        // Give OS time to release the lock+        await new Promise(r => setTimeout(r, 1000));+      }++      // Pup is AI-driven, never human-driven, so Chrome's "restore last+      // session" is pure cost: it reopens stale tabs from previous runs+      // (8+ tab buildup after a few iter cycles) AND those tabs keep+      // running heavy content (3D viewers, animations) under the+      // anti-throttle flags we set, eating CPU. Wipe the session-restore+      // state before launch — the AI knows what it wants open.+      prepareCleanProfile(userDataDir);+    }++    // ─── Helper: kill any Chrome processes locking this profile dir ───+    // v1.6.2+: route through killOrphanChromesForProfile + cleanProfileLocks+    // (PowerShell-based) instead of wmic. wmic was deprecated in 2021 and+    // removed from the default PATH in recent Windows 11 updates — when+    // it's missing, the legacy code silently failed, leaving stale Chrome+    // processes holding the profile and producing the canonical "Chrome+    // process exited immediately" symptom on every retry. Caught the day+    // v1.6.1 shipped because the recovery path exercised this helper more+    // aggressively than before.+    const killChromeHoldingProfile = async () => {+      if (!userDataDir) return;+      console.log(`Killing any Chrome processes holding profile "${profileName}"...`);+      try {+        await killOrphanChromesForProfile(profileName);+      } catch (e) {+        console.log(`killChromeHoldingProfile (orphan kill): ${e.message}`);+      }+      try {+        await cleanProfileLocks(profileName);+      } catch (e) {+        console.log(`killChromeHoldingProfile (lock cleanup): ${e.message}`);+      }+      // Give OS time to release locks+      await new Promise(r => setTimeout(r, 1500));+    };++    // ─── Helper: one detached-launch attempt, returns browser or throws ───+    const tryDetachedLaunch = async (attemptNum) => {+      const { spawn } = require('child_process');+      const chromePath = puppeteer.executablePath();+      const debugPort = 9200 + Math.floor(Math.random() * 800);+      const chromeArgs = [+        ...launchOpts.args.filter(a => a !== '--remote-debugging-port=0'),+        `--remote-debugging-port=${debugPort}`,+        `--user-data-dir=${userDataDir}`,+      ];+      console.log(`Chrome launch attempt ${attemptNum} for profile "${profileName}" on CDP port ${debugPort}`);+      const child = spawn(chromePath, chromeArgs, {+        detached: true,+        stdio: 'ignore',+        windowsHide: false,+      });+      child.unref();++      // Wait for Chrome to start and CDP to be ready (30s — Chrome cold start can be slow)+      let connected = false;+      let childDied = false;+      for (let i = 0; i < 60; i++) {+        await new Promise(r => setTimeout(r, 500));+        // Did the child process die unexpectedly?+        try {+          if (process.platform === 'win32') {+            const out = execSync(`tasklist /FI "PID eq ${child.pid}" /NH /FO CSV`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });+            if (!out.includes(`"${child.pid}"`)) { childDied = true; break; }+          } else {+            process.kill(child.pid, 0); // throws if dead+          }+        } catch { childDied = true; break; }++        try {+          const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+          if (resp.ok) { connected = true; break; }+        } catch {}+      }++      if (!connected) {+        // Kill the zombie we spawned before throwing+        try {+          if (process.platform === 'win32') execSync(`taskkill /F /T /PID ${child.pid}`, { stdio: 'ignore' });+          else process.kill(child.pid, 'SIGKILL');+        } catch {}+        const reason = childDied ? 'Chrome process exited immediately' : 'CDP never became ready';+        throw new Error(`Chrome launch attempt ${attemptNum} failed: ${reason} (port ${debugPort}, PID ${child.pid})`);+      }++      const b = await puppeteer.connect({ browserURL: `http://127.0.0.1:${debugPort}`, defaultViewport: null });+      b._detachedPid = child.pid;+      b._cdpPort = debugPort;+      console.log(`Launched detached Chrome (PID ${child.pid}) on CDP port ${debugPort} for profile "${profileName}"`);+      return b;+    };++    // ─── Launch with up to 3 attempts and orphan cleanup between attempts ───+    const isWinDetached = process.platform === 'win32' && userDataDir;+    const MAX_ATTEMPTS = 3;+    let lastErr = null;++    for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {+      try {+        if (isWinDetached) {+          browser = await tryDetachedLaunch(attempt);+        } else {+          browser = await puppeteer.launch(launchOpts);+        }+        lastErr = null;+        break;+      } catch (err) {+        lastErr = err;+        console.log(`Launch attempt ${attempt}/${MAX_ATTEMPTS} failed: ${err.message}`);+        if (attempt >= MAX_ATTEMPTS) break;+        // Recovery before retry: kill any lingering Chrome holding the profile + clear locks+        await killChromeHoldingProfile();+      }+    }++    if (!browser) {+      // Belt-and-suspenders: the open_window cold-start gate normally catches a+      // missing Chrome for Testing BEFORE we get here. If we still failed, branch+      // on whether CfT is actually present so the error is actionable, not a dev+      // string telling the AI to run npx.+      const cdet = chrome.detectChrome();+      if (!cdet.installed) {+        const e = new Error('Chrome for Testing is not installed — pup cannot launch a browser yet.');+        e.errorCode = 'chrome_for_testing_not_installed';+        e._hint = 'Chrome for Testing (the browser pup drives) is missing. Call browser_prewarm to install it (~150 MB, one time), poll browser_readiness until ready:true, then retry. You do not need to install anything manually.';+        throw e;+      }+      throw new Error(+        `Failed to launch Chrome for profile "${profileName}" after ${MAX_ATTEMPTS} attempts. ` ++        `Last error: ${lastErr && lastErr.message}. ` ++        `Chrome for Testing IS installed (${cdet.executablePath}) — this is more likely low memory, a locked/busy profile, or a crashed Chrome. Retry; if it persists, surface the last error to the user.`+      );+    }+  }++  browsers.set(profileName, { browser, refCount: 0 });++  // v1.4.12: pre-grant the dialog-suppressing permissions before any+  // navigation happens. Done at browser-context level so every tab/origin+  // inherits — caller doesn't need to re-grant per origin. See+  // applyDefaultPermissions for rationale + opt-out via strictPermissions.+  await applyDefaultPermissions(browser, profileName, { strictPermissions });++  // Auto-track popups: window.open() / target=_blank navigation on this Chrome+  // gets attached to the session that owns the opener tab. See wirePopupTracking.+  wirePopupTracking(browser, profileName);++  // Clean up when browser is closed externally (user closed Chrome, crashed, etc.)+  // v1.5.1+: see handleBrowserDisconnect for rationale. Sessions are kept+  // in the in-memory Map + on disk so browser_rescan / health-check can+  // recover the still-alive Chrome window. Old behavior (delete everything)+  // hijacked unrelated windows on subsequent calls.+  browser.on('disconnected', () => handleBrowserDisconnect(browser, profileName));++  return browser;+}++// Launch a session as a tab in a Chrome browser+// profile: which Chrome profile to use (defaults to sessionId)+// If another session already uses that profile, this creates a NEW TAB in the same Chrome+async function launchSession(sessionId, url, { freshProfile = false, profile, strictPermissions = false, downloadPath = null, nativeSite = false } = {}) {+  const profileName = profile || sessionId;++  // If session already exists and page is still alive, just navigate+  const existing = sessions.get(sessionId);+  if (existing) {+    const browserEntry = browsers.get(existing.profileName);+    if (browserEntry && browserEntry.browser.isConnected()) {+      try {+        // Check if the page is still valid+        await existing.page.title();+        if (url) {+          await existing.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });+          existing.errors.length = 0;+        }+        activeSessionId = sessionId;+        return existing;+      } catch (e) {+        // Page was closed — fall through to create new tab+        console.log(`Session "${sessionId}" page was closed — recreating tab`);+      }+    }+    // Stale session — clean up+    sessions.delete(sessionId);+    if (existing.profileName) {+      const be = browsers.get(existing.profileName);+      if (be) be.refCount = Math.max(0, be.refCount - 1);+      // v1.6.1+: kill orphan Chromes + clean profile locks before relaunching.+      // Without this, a stale `lockfile` (Windows) or `SingletonLock` (POSIX)+      // left behind by an unclean shutdown causes the next puppeteer.launch+      // to exit immediately with "Chrome process exited immediately." The+      // sleep/wake recovery scenario hit exactly this: Chrome died during+      // sleep, lockfile remained, every relaunch attempt failed silently.+      try {+        await killOrphanChromesForProfile(existing.profileName);+        await cleanProfileLocks(existing.profileName);+      } catch (e) {+        console.log(`[launchSession] pre-relaunch cleanup failed (non-fatal): ${e.message}`);+      }+    }+  }++  // Get or launch Chrome for this profile+  const browser = await getOrLaunchBrowser(profileName, { freshProfile, strictPermissions, nativeSite });++  // Prefer to REUSE the startup blank tab (every fresh Chrome opens one) instead+  // of creating a new tab and then closing the blank. The startup tab's URL+  // varies across Chrome versions: about:blank, chrome://newtab/,+  // chrome://new-tab-page/, chrome-search://local-ntp/..., so match loosely.+  const isBlank = (u) => {+    if (!u) return true;+    return u === 'about:blank'+      || u.startsWith('chrome://newtab')+      || u.startsWith('chrome://new-tab-page')+      || u.startsWith('chrome-search://local-ntp')+      || u === 'edge://newtab/';+  };++  // Snapshot existing pages and find a reusable blank one NOT already claimed+  // by another session.+  const claimedPages = new Set();+  for (const s of sessions.values()) { if (s.page) claimedPages.add(s.page); }+  const existingPages = await browser.pages();+  let page = existingPages.find(p => {+    if (claimedPages.has(p)) return false;+    try { return isBlank(p.url()); } catch { return false; }+  });++  if (page) {+    console.log(`Reusing existing blank tab (${page.url()}) for session "${sessionId}"`);+  } else {+    page = await browser.newPage();+  }++  // Always open the welcome page first — it shows session identity (sessionId,+  // profile, userDataDir) in the tab title and page body so users can tell+  // which Chrome window belongs to which session.+  const welcomeFile = path.join(__dirname, 'welcome.html');+  const userDataDir = freshProfile ? null : path.join(PROFILES_DIR, profileName);+  const welcomeParams = new URLSearchParams({ sessionId, profile: profileName });+  if (userDataDir) welcomeParams.set('userDataDir', userDataDir);+  const welcomeUrl = `file:///${welcomeFile.replace(/\\/g, '/')}?${welcomeParams}`;+  try {+    await page.goto(welcomeUrl, { waitUntil: 'domcontentloaded', timeout: 10000 });+  } catch (e) {+    console.log(`Welcome page failed to load (non-fatal): ${e.message}`);+  }++  if (url) {+    // Apply stored basic-auth creds for this URL's host BEFORE navigating+    // so the Chrome native auth dialog never appears. No-op if no match.+    try {+      const matchedHost = await credentialVault.applyCredentialsToPage(page, url);+      if (matchedHost) console.log(`[creds] applied to session "${sessionId}" first nav: matched ${matchedHost}`);+    } catch (e) {+      console.log(`[creds] applyCredentialsToPage on launchSession failed: ${e.message}`);+    }+    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });+  }++  // Belt and suspenders: close any tab in this Chrome that's NOT claimed by+  // a registered session. This catches two cases:+  //   (a) leftover blank tabs from reconnect scenarios+  //   (b) tabs Chrome restored from session-restore (despite our+  //       prepareCleanProfile pre-launch pass) — pup is AI-driven, the AI+  //       knows what tabs it wants, so any unclaimed page is by definition+  //       a stale leftover from a prior run.+  // Tabs registered by other live sessions on this same Chrome (multi-session+  // shared profile) ARE in refreshedClaimed and stay open.+  let restoredTabsClosed = 0;+  try {+    const after = await browser.pages();+    const refreshedClaimed = new Set();+    for (const s of sessions.values()) {+      for (const t of s.tabs) refreshedClaimed.add(t.page);+    }+    refreshedClaimed.add(page);+    for (const p of after) {+      if (refreshedClaimed.has(p)) continue;+      try {+        let pUrl = '';+        try { pUrl = p.url() || ''; } catch {}+        await p.close({ runBeforeUnload: false });+        restoredTabsClosed++;+        if (!isBlank(pUrl)) {+          console.log(`Closed restored leftover tab (${pUrl}) on session "${sessionId}" launch`);+        }+      } catch {}+    }+  } catch {}+  if (restoredTabsClosed > 0) {+    console.log(`Session "${sessionId}" launch: closed ${restoredTabsClosed} stale tab(s) — pup is AI-driven, no session-restore needed`);+  }++  // Create the session with this page as its first tab.+  const session = createSession(profileName);+  // v1.6.3+: stash the per-session downloadPath BEFORE attachTab — attachTab+  // reads it to apply Page.setDownloadBehavior on the new page. Default+  // resolves in attachTab itself if unset.+  if (downloadPath) session._downloadPath = downloadPath;+  attachTab(session, sessionId, page);+  sessions.set(sessionId, session);+  activeSessionId = sessionId;++  // Track refCount+  const be = browsers.get(profileName);+  if (be) be.refCount++;++  // Append sessionId (and profile if different) to the page title so user can identify windows+  try {+    const titleSuffix = profileName !== sessionId+      ? ` (session: ${sessionId} | profile: ${profileName})`+      : ` (session: ${sessionId})`;+    await page.evaluate((suffix) => {+      document.title = document.title + suffix;+      const obs = new MutationObserver(() => {+        if (!document.title.endsWith(suffix)) {+          document.title = document.title + suffix;+        }+      });+      obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true });+    }, titleSuffix);+  } catch (e) {+    // Don't fail the session if title injection fails (e.g. about:blank)+  }++  // Persist to disk for recovery after bridge restart+  saveSessionFile(sessionId, profileName, url);++  // Stash one-shot launch metadata so the browser_open_window response can+  // surface it. Cleared after first read in getSessionInfo.+  session.lastLaunchMeta = { restoredTabsClosed };++  console.log(`Session "${sessionId}" launched as tab in profile "${profileName}" (${url || 'about:blank'}). Total sessions: ${sessions.size}`);+  return session;+}++// ─────────────────────────────────────────────────────────────────────────────+// Native browser drive (v1.0.6+) — drive the user's REAL Chrome/Edge/Brave.+//+// v1.0.5 first attempted a "clone the user-data-dir" strategy so we wouldn't+// touch the user's live Chrome. It had three fatal-for-real-use problems:+//   1. Chrome's *managed* profile state (CBCM device enrollment, group+//      policies bound to the Local State + device identity) doesn't follow+//      a copy — the cloned profile prompts "this profile will be managed"+//      and re-runs the enrollment flow, defeating the entire point.+//   2. SQLite files (Cookies, Login Data) take brief exclusive locks during+//      writes. Robocopy skipped them mid-write, so the clone landed without+//      cookies and the cloned Chrome saw a logged-out claude.ai/GCP/etc.+//   3. The clone drifts from the live profile — fine for a brief test, bad+//      for a workflow.+//+// v1.0.6 trades clone-clever for honest: when the user explicitly wants+// native drive, we ASK to close + relaunch their real Chrome with+// `--remote-debugging-port` + `--restore-last-session`. Their open windows+// and tabs come back, every signed-in cookie + org policy + device-bound+// credential stays intact, and CDP gives us full control over the SAME+// profile they were using. The price: the user momentarily sees Chrome+// close and reopen with their tabs restored.+//+// The opt-in shape:+//   browser_open_window {nativeBrowser:"chrome", nativeProfile:"Default", ...}+//     → bridge checks if Chrome is already running with a debug port+//       → if yes (e.g. user always launches Chrome with it), connect.+//       → if no, return errorCode:"native_relaunch_required" with a clear+//         message. Caller surfaces it to the user.+//   browser_open_window {..., nativeRelaunch:true}+//     → user confirmed. Bridge taskkills Chrome cleanly (WM_CLOSE so+//       Sessions save), waits for exit, relaunches with --user-data-dir ++//       --profile-directory + --remote-debugging-port + --restore-last-session,+//       connects via puppeteer.connect.+//+// Subsequent calls with the same (browser, profile) reuse that Chrome —+// one process, every tab/window/eval goes through it. If the user later+// quits Chrome normally, a future native call returns native_relaunch_required+// again, same dance.+//+// Power-user workflow tip we should surface in docs: if you live in this+// flow, just launch Chrome at startup with --remote-debugging-port=<n>+// permanently. Then AD reconnects every session without ever asking.+// ─────────────────────────────────────────────────────────────────────────────++function whichBin(candidates) {+  for (const c of candidates) {+    try {+      const out = execSync(`which ${c}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();+      if (out && fs.existsSync(out)) return out;+    } catch {}+  }+  return null;+}++// Per-browser metadata: how to find the exe + the real User Data root that+// holds <profileId> + Local State. Keys match desktop_list_browsers names.+function nativeBrowserMeta(name) {+  const isWin = process.platform === 'win32';+  const isLinux = process.platform === 'linux';+  const isMac = process.platform === 'darwin';+  const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');+  const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files';+  const programFiles86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';+  const home = os.homedir();++  const firstExisting = (paths) => paths.find(p => fs.existsSync(p));++  switch (name) {+    case 'chrome': {+      if (isWin) {+        const exe = firstExisting([+          path.join(programFiles, 'Google', 'Chrome', 'Application', 'chrome.exe'),+          path.join(programFiles86, 'Google', 'Chrome', 'Application', 'chrome.exe'),+          path.join(local, 'Google', 'Chrome', 'Application', 'chrome.exe'),+          path.join(local, 'Programs', 'Google', 'Chrome', 'Application', 'chrome.exe'),+        ]);+        return exe ? { displayName: 'Chrome', exe, realUserDataDir: path.join(local, 'Google', 'Chrome', 'User Data') } : null;+      }+      if (isLinux) {+        const exe = whichBin(['google-chrome-stable', 'google-chrome', 'chromium']);+        return exe ? { displayName: 'Chrome', exe, realUserDataDir: path.join(home, '.config', 'google-chrome') } : null;+      }+      if (isMac) {+        const exe = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';+        return fs.existsSync(exe) ? { displayName: 'Chrome', exe, realUserDataDir: path.join(home, 'Library', 'Application Support', 'Google', 'Chrome') } : null;+      }+      return null;+    }+    case 'edge': {+      if (isWin) {+        const exe = firstExisting([+          path.join(programFiles86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),+          path.join(programFiles, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),+          path.join(local, 'Microsoft', 'Edge', 'Application', 'msedge.exe'),+        ]);+        return exe ? { displayName: 'Edge', exe, realUserDataDir: path.join(local, 'Microsoft', 'Edge', 'User Data') } : null;+      }+      if (isLinux) {+        const exe = whichBin(['microsoft-edge-stable', 'microsoft-edge']);+        return exe ? { displayName: 'Edge', exe, realUserDataDir: path.join(home, '.config', 'microsoft-edge') } : null;+      }+      if (isMac) {+        const exe = '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge';+        return fs.existsSync(exe) ? { displayName: 'Edge', exe, realUserDataDir: path.join(home, 'Library', 'Application Support', 'Microsoft Edge') } : null;+      }+      return null;+    }+    case 'brave': {+      if (isWin) {+        const exe = firstExisting([+          path.join(programFiles, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),+          path.join(programFiles86, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),+          path.join(local, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),+          path.join(local, 'Programs', 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),+        ]);+        return exe ? { displayName: 'Brave', exe, realUserDataDir: path.join(local, 'BraveSoftware', 'Brave-Browser', 'User Data') } : null;+      }+      if (isLinux) {+        const exe = whichBin(['brave-browser', 'brave']);+        return exe ? { displayName: 'Brave', exe, realUserDataDir: path.join(home, '.config', 'BraveSoftware', 'Brave-Browser') } : null;+      }+      if (isMac) {+        const exe = '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser';+        return fs.existsSync(exe) ? { displayName: 'Brave', exe, realUserDataDir: path.join(home, 'Library', 'Application Support', 'BraveSoftware', 'Brave-Browser') } : null;+      }+      return null;+    }+    default:+      return null;+  }+}++function nativeKey(browserName, profileId) {+  return `native:${browserName}:${profileId}`;+}++function makeNativeError(code, message, extra = {}) {+  const e = new Error(message);+  e.errorCode = code;+  Object.assign(e, extra);+  return e;+}++// --- Native-drive CDP blocklist (v1.0.10) ---------------------------------+// Some real-browser profiles never expose CDP no matter how we relaunch them —+// most commonly enterprise-managed Chrome where the RemoteDebuggingAllowed cloud+// policy is disabled browser-wide (e.g. a Google Workspace account). Closing ++// relaunching such a browser is futile AND disruptive (the user's browser blinks+// for nothing). We remember, per user-data-dir, that a relaunch failed to bring+// up CDP, and fail the *consent* step fast on the next attempt so we never close+// the browser for a doomed relaunch. Entries self-heal after CDP_BLOCK_TTL_MS (in+// case an admin enables the policy), and a successful connect clears them.+const CDP_BLOCK_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days+function cdpBlocklistPath() {+  return path.join(os.homedir(), '.adom', 'native-cdp-blocked.json');+}+function readCdpBlocklist() {+  try { return JSON.parse(fs.readFileSync(cdpBlocklistPath(), 'utf8')) || {}; }+  catch { return {}; }+}+function cdpBlockKey(userDataDir) {+  return (userDataDir || '').replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();+}+function isCdpBlocked(userDataDir) {+  const e = readCdpBlocklist()[cdpBlockKey(userDataDir)];+  if (!e) return false;+  // A numeric, expired timestamp self-heals (retry allowed). A missing/non-numeric+  // timestamp is treated as a hard block until a successful connect clears it.+  if (typeof e.blockedAt === 'number' && Date.now() - e.blockedAt > CDP_BLOCK_TTL_MS) return false;+  return true;+}+function markCdpBlocked(userDataDir, displayName) {+  try {+    const bl = readCdpBlocklist();+    bl[cdpBlockKey(userDataDir)] = { blockedAt: Date.now(), displayName: displayName || '', userDataDir };+    fs.mkdirSync(path.dirname(cdpBlocklistPath()), { recursive: true });+    fs.writeFileSync(cdpBlocklistPath(), JSON.stringify(bl, null, 2));+  } catch (e) { console.log(`[native] could not persist CDP blocklist: ${e.message}`); }+}+function clearCdpBlocked(userDataDir) {+  try {+    const bl = readCdpBlocklist();+    if (bl[cdpBlockKey(userDataDir)]) {+      delete bl[cdpBlockKey(userDataDir)];+      fs.writeFileSync(cdpBlocklistPath(), JSON.stringify(bl, null, 2));+    }+  } catch { /* best effort */ }+}+// Best-effort diagnosis of WHY a freshly-launched browser refused to open its+// debug port. The overwhelmingly common cause is an enterprise policy+// (RemoteDebuggingAllowed disabled) on a managed Chromium browser. We probe the+// local registry for a machine/user policy to upgrade "likely" → "confirmed";+// absent that, a managed profile + a refused port still points squarely at a+// cloud-pushed policy (Chrome Browser Cloud Management / Google Workspace). (v1.0.11)+function diagnoseCdpFailure(meta, browserName) {+  if (process.platform === 'win32') {+    const polKey = browserName === 'edge' ? 'Microsoft\\Edge'+      : browserName === 'brave' ? 'BraveSoftware\\Brave'+      : 'Google\\Chrome';+    for (const hive of ['HKLM', 'HKCU']) {+      try {+        const out = execSync(+          `reg query "${hive}\\SOFTWARE\\Policies\\${polKey}" /v RemoteDebuggingAllowed`,+          { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });+        const m = out.match(/RemoteDebuggingAllowed\s+REG_DWORD\s+0x([0-9a-fA-F]+)/);+        if (m && parseInt(m[1], 16) === 0) {+          return {+            cause: 'policy_remote_debugging_disabled',+            scope: 'local_machine_policy',+            confidence: 'confirmed',+            detail: `a local policy (${hive}\\SOFTWARE\\Policies\\${polKey}\\RemoteDebuggingAllowed = 0) disables remote debugging browser-wide — an admin/MDM set this.`,+          };+        }+      } catch { /* key/value absent — keep looking */ }+    }+  }+  return {+    cause: 'policy_remote_debugging_disabled',+    scope: 'cloud_managed_policy',+    confidence: 'likely',+    detail: `no local registry policy was found, but the debug port refused to bind. On a managed profile this is almost always a cloud-pushed policy (Chrome Browser Cloud Management or a Google Workspace RemoteDebuggingAllowed policy).`,+  };+}++// What ELSE could the caller drive? Other installed Chromium browsers (with+// their current block status) so the AI can offer the user a real alternative.+function nativeAlternatives(attemptedName) {+  const out = [];+  for (const name of ['chrome', 'edge', 'brave']) {+    if (name === attemptedName) continue;+    let m; try { m = nativeBrowserMeta(name); } catch { m = null; }+    if (!m) continue; // not installed+    out.push({ nativeBrowser: name, displayName: m.displayName, cdpBlocked: isCdpBlocked(m.realUserDataDir) });+  }+  return out;+}+function genericPupHint() {+  return `You can ALWAYS fall back to generic pup: call browser_open_window with NO nativeBrowser/nativeProfile for a clean Chrome-for-Testing instance — no user session, no managed policy, fully controllable. If the task needs the user's logged-in session, sign in once in that CfT window.`;+}+function altsBlurb(attemptedName) {+  const alternatives = nativeAlternatives(attemptedName);+  const open = alternatives.filter(a => !a.cdpBlocked);+  const blocked = alternatives.filter(a => a.cdpBlocked);+  let blurb = '';+  if (open.length) {+    blurb += `Other native browsers are installed and NOT known to be policy-blocked: ${open.map(a => `${a.displayName} (nativeBrowser:"${a.nativeBrowser}")`).join(', ')}. `;+    if (open.some(a => a.nativeBrowser === 'edge')) {+      blurb += `Edge in particular is usually outside Google Workspace management, so it often drives natively when Chrome cannot. `;+    }+    blurb += `Ask the user which they'd prefer. `;+  }+  if (blocked.length) {+    blurb += `(Also installed but already known policy-blocked: ${blocked.map(a => a.displayName).join(', ')}.) `;+  }+  return { blurb, alternatives };+}+function cdpBlockedError(meta, browserName) {+  const diagnosis = diagnoseCdpFailure(meta, browserName);+  const { blurb, alternatives } = altsBlurb(browserName);+  return makeNativeError(+    'native_cdp_blocked',+    `${meta.displayName}'s debug port never bound after launch — ${diagnosis.detail} Native drive of ${meta.displayName} is not possible here, and AD will not close/relaunch it again for this. ` ++    blurb + genericPupHint() ++    ` (If an admin later enables RemoteDebuggingAllowed, call again with "nativeRelaunch": true to force a retry.)`,+    { displayName: meta.displayName, realUserDataDir: meta.realUserDataDir, cdpBlocked: true, diagnosis, alternatives, genericPupAvailable: true }+  );+}+function relaunchRequiredError(meta, browserName, profileId, runningCount) {+  const { blurb, alternatives } = altsBlurb(browserName);+  return makeNativeError(+    'native_relaunch_required',+    `Native ${meta.displayName} drive needs the browser running with --remote-debugging-port. ${meta.displayName} is open right now (${runningCount} process(es)) without it, so AD must CLOSE and reopen it (with --restore-last-session, so every tab/window comes back exactly as it was). Ask the user if that's OK, then call browser_open_window again with "nativeRelaunch": true. ` ++    blurb + genericPupHint() ++    ` (Power-user tip: launch ${meta.displayName} with --remote-debugging-port=<N> at startup and AD reconnects every time with no prompt.)`,+    { displayName: meta.displayName, runningProcesses: runningCount, mustCloseBrowser: true, confirmArgs: { nativeBrowser: browserName, nativeProfile: profileId, nativeRelaunch: true }, alternatives, genericPupAvailable: true }+  );+}++// Find the user's real Chrome/Edge/Brave processes whose --user-data-dir is+// (a) the real one for this browser, or (b) absent (which means Chrome is+// using its default = the real one). Returns [{pid, port, cmdline}] where+// port is the --remote-debugging-port value (0 if absent).+function findRealBrowserProcesses(meta) {+  if (process.platform !== 'win32') {+    // POSIX fallback via `ps` — same parsing logic.+    try {+      const exeName = path.basename(meta.exe);+      const out = execSync(`ps -e -o pid=,args=`, { encoding: 'utf8' });+      return out.split('\n')+        .filter(l => l.includes(exeName))+        .map(l => {+          const m = l.match(/^\s*(\d+)\s+(.*)$/);+          if (!m) return null;+          return parseBrowserCmdline(Number(m[1]), m[2], meta.realUserDataDir);+        })+        .filter(Boolean);+    } catch { return []; }+  }+  // Windows: Win32_Process via PowerShell. ConvertTo-Json gives us each+  // process's ProcessId + full CommandLine.+  const exeName = path.basename(meta.exe);+  try {+    const ps = `Get-CimInstance Win32_Process -Filter \"Name='${exeName}'\" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress`;+    const out = execSync(`powershell -NoProfile -Command "${ps.replace(/"/g, '\\"')}"`, {+      encoding: 'utf8',+      stdio: ['ignore', 'pipe', 'ignore'],+      windowsHide: true,+    }).trim();+    if (!out) return [];+    let arr;+    try { arr = JSON.parse(out); } catch { return []; }+    if (!Array.isArray(arr)) arr = [arr];+    return arr+      .map(p => parseBrowserCmdline(Number(p.ProcessId), p.CommandLine || '', meta.realUserDataDir))+      .filter(Boolean);+  } catch (e) {+    console.log(`[native] findRealBrowserProcesses failed: ${e.message}`);+    return [];+  }+}++function parseBrowserCmdline(pid, cmdline, realUserDataDir) {+  // Child processes (renderer, GPU, utility) inherit --user-data-dir but carry+  // --type=. Only the browser process owns top-level windows + the debug port.+  // Exclude children so callers act on real browser processes only (v1.0.8).+  if (/--type=/.test(cmdline)) return null;+  // Determine whether this process uses the REAL user-data-dir. Match the+  // KNOWN realUserDataDir as a substring rather than greedily capturing the+  // flag value: Windows reports the cmdline UNQUOTED, and Chrome's default dir+  // ("...\User Data") contains a space, so a \S+ capture truncates at "User"+  // and never matches the real profile. (v1.0.9: this was the bug that made+  // the native-drive RECONNECT path silently fail against the real profile —+  // it only ever matched no-space side profiles like .adom\native-profiles.)+  const nc = cmdline.replace(/\\/g, '/').toLowerCase();+  const realDir = realUserDataDir.replace(/[\\/]+$/, '').replace(/\\/g, '/').toLowerCase();+  let usesReal;+  if (/--user-data-dir[=\s]/i.test(cmdline)) {+    const esc = realDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');+    usesReal = new RegExp(`--user-data-dir[=\\s]"?${esc}("|\\s|$)`, 'i').test(nc);+  } else {+    // No --user-data-dir flag → Chrome uses its default (= real user-data-dir).+    usesReal = true;+  }+  if (!usesReal) return null;+  const portMatch = cmdline.match(/--remote-debugging-port=(\d+)/);+  return {+    pid,+    port: portMatch ? Number(portMatch[1]) : 0,+    cmdline,+  };+}++// Send WM_CLOSE to every browser process (graceful — Chrome flushes Sessions+// and Cookies cleanly), wait for them to exit, force-kill stragglers. The+// graceful path is what lets --restore-last-session bring tabs back; a+// straight /F kill leaves Chrome's "Restore pages?" interstitial which the+// user has to click manually.+async function closeRealBrowserGracefully(meta, processes) {+  if (processes.length === 0) return;+  const exeName = path.basename(meta.exe);+  console.log(`[native] graceful WM_CLOSE on ${processes.length} ${exeName} process(es)...`);+  if (process.platform === 'win32') {+    // taskkill WITHOUT /F sends WM_CLOSE to the PID's top-level windows. Chrome+    // catches it, runs its shutdown path (writes Sessions, flushes Cookies,+    // closes child processes), then exits. v1.0.8: target the SPECIFIC browser+    // PIDs — NOT /IM chrome.exe, which would also close Chrome-for-Testing+    // bridge sessions and any other Chromium sharing the exe name.+    for (const p of processes) {+      try {+        execSync(`taskkill /PID ${p.pid}`, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });+      } catch {+        // Non-zero exit is common (some refuse; we'll wait then escalate).+      }+    }+  } else {+    for (const p of processes) {+      try { process.kill(p.pid, 'SIGTERM'); } catch {}+    }+  }+  // Wait up to 15s for the exe to actually disappear.+  for (let i = 0; i < 30; i++) {+    await new Promise(r => setTimeout(r, 500));+    if (findRealBrowserProcesses(meta).length === 0) {+      console.log(`[native] all ${exeName} processes exited cleanly after ${(i + 1) * 0.5}s`);+      return;+    }+  }+  // Force-kill anything still alive. Chrome's session file is already on+  // disk by now; --restore-last-session will still bring tabs back, the+  // user just sees the "Chrome didn't shut down correctly" toast briefly.+  console.log(`[native] graceful close took too long — force-killing remaining ${exeName}`);+  try {+    if (process.platform === 'win32') {+      // Per-PID /F /T (kill the process tree) on the specific browser PIDs+      // still alive — never /IM, to spare CfT sessions + other Chromium (v1.0.8).+      for (const p of findRealBrowserProcesses(meta)) {+        try { execSync(`taskkill /F /T /PID ${p.pid}`, { stdio: 'ignore', windowsHide: true }); } catch {}+      }+    } else {+      for (const p of findRealBrowserProcesses(meta)) {+        try { process.kill(p.pid, 'SIGKILL'); } catch {}+      }+    }+  } catch {}+  await new Promise(r => setTimeout(r, 2000));+}++// Spawn the user's REAL browser detached on its REAL user-data-dir with+// a fresh debug port + session restore, return a connected puppeteer.Browser.+async function spawnRealBrowserWithDebugPort(meta, profileId, debugPort) {+  const { spawn } = require('child_process');+  const args = [+    `--user-data-dir=${meta.realUserDataDir}`,+    `--profile-directory=${profileId}`,+    `--remote-debugging-port=${debugPort}`,+    // The whole reason this UX is acceptable: tabs come back exactly as+    // they were before the close. Chrome reads its Sessions/ dir and+    // restores windows/tabs across all profiles that had them open.+    '--restore-last-session',+    // Silence first-run prompts.+    '--no-first-run',+    '--no-default-browser-check',+    // NOTE: deliberately NOT passing --disable-blink-features=AutomationControlled+    // (v1.0.12). (1) It's redundant: we puppeteer.connect() to a browser spawned+    // WITHOUT --enable-automation, so navigator.webdriver is already false and+    // there's no "controlled by automated software" infobar to suppress.+    // (2) Edge treats it as an "unsupported command-line flag" and shows a yellow+    // "stability and security risks" banner for the entire session.+  ];+  console.log(`[native] relaunching ${meta.displayName} on real ${meta.realUserDataDir} with --remote-debugging-port=${debugPort} + --restore-last-session`);+  const child = spawn(meta.exe, args, { detached: true, stdio: 'ignore', windowsHide: false });+  child.unref();++  // Wait up to 30s for CDP to come up. Real Chrome with managed-profile+  // enrollment + session restore takes longer than a fresh CfT launch.+  for (let i = 0; i < 60; i++) {+    await new Promise(r => setTimeout(r, 500));+    try {+      const resp = await fetch(`http://127.0.0.1:${debugPort}/json/version`);+      if (resp.ok) {+        const browser = await puppeteer.connect({+          browserURL: `http://127.0.0.1:${debugPort}`,+          defaultViewport: null,+        });+        browser._cdpPort = debugPort;+        browser._detachedPid = child.pid;+        console.log(`[native] connected to real ${meta.displayName} on CDP :${debugPort} (spawned PID ${child.pid})`);+        // --restore-last-session faithfully reopens the prior session — including+        // any blank / new-tab windows, which pile up as "a bunch of empty windows"+        // before the target URL opens. Prune the blanks (keep every REAL restored+        // tab); the target tab is created afterwards by openTabInNativeBrowser, so+        // closing blanks here can't touch it. Settle first — restore reopens tabs+        // asynchronously, so a brief wait lets them all register before we prune.+        await new Promise(r => setTimeout(r, 1500));+        try {+          const isBlank = (u) => !u || u === 'about:blank' ||+            /^(chrome|edge|brave):\/\/(newtab|new-tab-page)\b/.test(u) ||+            u.startsWith('chrome-search://local-ntp');+          const pages = await browser.pages();+          const blanks = pages.filter(p => isBlank(p.url()));+          const reals  = pages.filter(p => !isBlank(p.url()));+          // Close every blank when real tabs exist; if the whole restored session+          // was blank, keep ONE so the browser doesn't quit before the target opens.+          const toClose = reals.length > 0 ? blanks : blanks.slice(1);+          for (const p of toClose) { try { await p.close({ runBeforeUnload: false }); } catch {} }+          if (toClose.length) {+            console.log(`[native] pruned ${toClose.length} empty restored window(s)/tab(s); kept ${pages.length - toClose.length} (${reals.length} real)`);+          }+        } catch (e) { console.log(`[native] empty-window prune skipped: ${e.message}`); }+        return browser;+      }+    } catch { /* CDP not ready yet — keep polling */ }+  }+  throw new Error(`Real ${meta.displayName} did not expose CDP on :${debugPort} within 30s after relaunch`);+}++// Public: launch a session driving the user's REAL browser.+// Behavior:+//   - If real browser is already running with --remote-debugging-port → connect.+//   - Else if nativeRelaunch:true → close gracefully, relaunch with the port,+//     --restore-last-session brings tabs back.+//   - Else → throw errorCode:"native_relaunch_required" so the caller can+//     surface the consent prompt to the user and re-call with the flag.+async function launchNativeSession(sessionId, url, opts = {}) {+  const { browser: browserName, profileId, nativeRelaunch = false } = opts;++  if (!browserName) throw makeNativeError('native_no_browser', 'nativeBrowser is required');+  if (browserName === 'firefox') {+    throw makeNativeError('firefox_no_cdp',+      'Firefox does not support CDP — cannot drive natively. Use a Chromium-family browser (chrome / edge / brave). Firefox is included in desktop_list_browsers for visibility but rejected here.');+  }+  if (!profileId) throw makeNativeError('native_no_profile', 'nativeProfile is required (e.g. "Default", "Profile 1")');++  const meta = nativeBrowserMeta(browserName);+  if (!meta) {+    throw makeNativeError('native_browser_not_installed',+      `Browser "${browserName}" not detected on this machine. Run desktop_list_browsers to see what's installed.`);+  }+  if (!fs.existsSync(meta.realUserDataDir)) {+    throw makeNativeError('native_user_data_missing',+      `Real User Data dir not found at ${meta.realUserDataDir} — launch ${meta.displayName} once manually so it creates its profile, then retry.`);+  }+  const realProfileDir = path.join(meta.realUserDataDir, profileId);+  if (!fs.existsSync(realProfileDir)) {+    throw makeNativeError('native_profile_not_found',+      `Profile "${profileId}" does not exist under ${meta.realUserDataDir}. Run desktop_list_browsers to list valid profile ids.`);+  }++  const key = nativeKey(browserName, profileId);++  // 1. In-memory hit: this bridge already drives this (browser, profile).+  const existingEntry = browsers.get(key);+  if (existingEntry && existingEntry.browser.isConnected()) {+    const memSession = await openTabInNativeBrowser(existingEntry.browser, sessionId, url, key);+    memSession._narrative = `Already driving ${meta.displayName} (${profileId}) — reused the live connection, nothing relaunched.`;+    return memSession;+  }+  if (existingEntry) browsers.delete(key);++  // 2. Probe the real browser's running processes. If one of them ALREADY+  //    has --remote-debugging-port, the user has either set that up+  //    themselves (the power-user path) or our own previous-session+  //    relaunch is still alive. Either way, just connect.+  const realProcs = findRealBrowserProcesses(meta);+  const procWithPort = realProcs.find(p => p.port > 0);+  if (procWithPort) {+    console.log(`[native] found real ${meta.displayName} PID ${procWithPort.pid} already exposing CDP on :${procWithPort.port} — connecting without relaunch`);+    try {+      const browser = await puppeteer.connect({+        browserURL: `http://127.0.0.1:${procWithPort.port}`,+        defaultViewport: null,+      });+      browser._cdpPort = procWithPort.port;+      browser._detachedPid = procWithPort.pid;+      browsers.set(key, { browser, refCount: 0 });+      browser.on('disconnected', () => browsers.delete(key));+      clearCdpBlocked(meta.realUserDataDir); // it CAN do CDP — forget any prior block+      const reconnSession = await openTabInNativeBrowser(browser, sessionId, url, key);+      reconnSession._narrative = `${meta.displayName} was already running with a debug port — connected without relaunching anything.`;+      return reconnSession;+    } catch (e) {+      console.log(`[native] connect to existing port :${procWithPort.port} failed (${e.message}); falling through to relaunch path`);+    }+  }++  // 3. No usable CDP port on the real browser. First: if we've already learned+  //    this profile can't expose CDP, tell the caller now (diagnosis ++  //    alternatives) — never close/relaunch a browser for a doomed attempt.+  if (isCdpBlocked(meta.realUserDataDir)) throw cdpBlockedError(meta, browserName);++  // 3a. Browser is NOT running → nothing to disrupt → launch it fresh with the+  //     debug port WITHOUT asking the user (no windows to close). If the port+  //     refuses to bind (policy), close the throwaway window + report it.+  if (realProcs.length === 0) {+    console.log(`[native] ${meta.displayName} not running — launching fresh with debug port (no disruption, no consent needed)`);+    const freshPort = 9300 + Math.floor(Math.random() * 700);+    let freshBrowser;+    try {+      freshBrowser = await spawnRealBrowserWithDebugPort(meta, profileId, freshPort);+    } catch (e) {+      markCdpBlocked(meta.realUserDataDir, meta.displayName);+      try { await closeRealBrowserGracefully(meta, findRealBrowserProcesses(meta)); } catch {}+      throw cdpBlockedError(meta, browserName);+    }+    clearCdpBlocked(meta.realUserDataDir);+    browsers.set(key, { browser: freshBrowser, refCount: 0 });+    freshBrowser.on('disconnected', () => browsers.delete(key));+    const freshSession = await openTabInNativeBrowser(freshBrowser, sessionId, url, key);+    freshSession._launchedFresh = true;+    freshSession._narrative = `${meta.displayName} wasn't open, so AD launched it fresh with the debug port — no tabs closed, nothing disrupted. Connected and ready to drive.`;+    return freshSession;+  }++  // 3b. Browser IS running without a debug port. Closing it is disruptive, so+  //     require explicit consent (nativeRelaunch:true) — surfaced with the+  //     close-and-reopen prompt + alternatives the AI can offer the user.+  if (!nativeRelaunch) {+    throw relaunchRequiredError(meta, browserName, profileId, realProcs.length);+  }++  // 4. Caller confirmed. Close real browser cleanly, relaunch with debug port.+  await closeRealBrowserGracefully(meta, realProcs);+  const debugPort = 9300 + Math.floor(Math.random() * 700);+  let browser;+  try {+    browser = await spawnRealBrowserWithDebugPort(meta, profileId, debugPort);+  } catch (e) {+    // Relaunched but never exposed CDP (debug port never bound). Remember this+    // profile so the next consent probe fails fast instead of closing the browser+    // again — most commonly enterprise RemoteDebuggingAllowed (v1.0.10).+    markCdpBlocked(meta.realUserDataDir, meta.displayName);+    throw cdpBlockedError(meta, browserName);+  }+  clearCdpBlocked(meta.realUserDataDir); // success — forget any prior block+  browsers.set(key, { browser, refCount: 0 });+  browser.on('disconnected', () => browsers.delete(key));+  const relaunchedSession = await openTabInNativeBrowser(browser, sessionId, url, key);+  relaunchedSession._narrative = `Closed and reopened ${meta.displayName} with the debug port (tabs restored via --restore-last-session). Connected and ready to drive.`;+  return relaunchedSession;+}++async function openTabInNativeBrowser(browser, sessionId, url, key) {+  // v1.0.7: ALWAYS open a NEW tab for native drive — never hijack one of the+  // user's existing tabs. The old `pages[0]` reuse had two problems: (1) it+  // navigated whatever the user had open in their first tab (data loss), and+  // (2) when that page redirected (e.g. github.com/login/device → /login),+  // the captured frame DETACHED, leaving the session's tab pointing at a dead+  // frame — every subsequent eval / input_dispatch failed with "detached+  // Frame" and browser_rescan could not re-acquire it. A fresh page navigates+  // cleanly through redirects, which is exactly what the device-code / OAuth+  // consent flows need.+  const page = await browser.newPage();+  if (url) {+    try { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 }); }+    catch (e) { console.log(`[native] initial navigate failed (continuing): ${e.message}`); }+  }+  let session = sessions.get(sessionId);+  if (!session) {+    session = createSession(key);+    sessions.set(sessionId, session);+  }+  session.profileName = key;+  session.tabs = [{ tabId: `${sessionId}-tab-1`, page, errors: [] }];+  session.activeTabId = session.tabs[0].tabId;+  session._native = true;+  const be = browsers.get(key);+  if (be) be.refCount++;+  activeSessionId = sessionId;+  return session;+}++// Append a tab to an existing session. Returns the new tabId.+async function addTabToSession(sessionId, url) {+  const session = sessions.get(sessionId);+  if (!session) {+    throw new Error(`Session "${sessionId}" not found. Use browser_open_window first.`);+  }+  const be = browsers.get(session.profileName);+  if (!be || !be.browser.isConnected()) {+    throw new Error(`Session "${sessionId}" browser is not connected.`);+  }+  const page = await be.browser.newPage();+  const tabId = attachTab(session, sessionId, page, { makeActive: true });++  // Append per-tab suffix so the user can distinguish tabs by title+  const suffix = ` (session: ${sessionId} | ${tabId})`;+  try {+    if (url) {+      try {+        const matchedHost = await credentialVault.applyCredentialsToPage(page, url);+        if (matchedHost) console.log(`[creds] applied to new tab "${tabId}": matched ${matchedHost}`);+      } catch (e) {+        console.log(`[creds] applyCredentialsToPage on new tab failed: ${e.message}`);+      }+      await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });+    }+    await page.evaluate((s) => {+      document.title = document.title + s;+      const obs = new MutationObserver(() => {+        if (!document.title.endsWith(s)) document.title = document.title + s;+      });+      obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true });+    }, suffix);+  } catch {}++  console.log(`Session "${sessionId}": added ${tabId} (${url || 'about:blank'}). Total tabs: ${session.tabs.length}`);+  return tabId;+}++// Check if a session's browser is still connected+function isSessionAlive(s) {+  const be = browsers.get(s.profileName);+  return be && be.browser.isConnected();+}++// Get active session, fallback to first, or return null+function getActiveSession() {+  if (activeSessionId && sessions.has(activeSessionId)) {+    const s = sessions.get(activeSessionId);+    if (isSessionAlive(s)) return s;+    sessions.delete(activeSessionId);+  }+  for (const [id, s] of sessions) {+    if (isSessionAlive(s)) {+      activeSessionId = id;+      return s;+    }+    sessions.delete(id);+  }+  activeSessionId = null;+  return null;+}+++// Resolve a session by explicit sessionId or fall back to active.+// Does NOT change the global activeSessionId — safe for concurrent use.+// Returns { session, resolvedId } or null when sessionId was omitted and+// no live session exists. Never silently retargets when an explicit+// sessionId was passed but doesn't match — see resolveSessionOrError.+function resolveSession(args) {+  // Explicit sessionId path: don't fall through to a different session if+  // the requested one is missing or disconnected. v1.5.0 and earlier did,+  // and that's how chip-fetcher's "browser_navigate landed on the wrong+  // window" bug fired (caller passed the right sessionId, the bridge+  // silently retargeted to whatever it had).+  if (args.sessionId) {+    if (sessions.has(args.sessionId)) {+      const s = sessions.get(args.sessionId);+      if (isSessionAlive(s)) {+        s._lostBrowser = false;+        return { session: s, resolvedId: args.sessionId };+      }+      // Session entry exists but its browser is gone (CDP socket dropped,+      // browser process died, profile crashed). Caller will see a+      // structured error from resolveSessionOrError pointing at+      // browser_rescan.+    }+    return null;+  }+  // No sessionId passed: legacy fallback to active or first alive.+  if (activeSessionId && sessions.has(activeSessionId)) {+    const s = sessions.get(activeSessionId);+    if (isSessionAlive(s)) return { session: s, resolvedId: activeSessionId };+    sessions.delete(activeSessionId);+  }+  for (const [id, s] of sessions) {+    if (isSessionAlive(s)) return { session: s, resolvedId: id };+    if (!s._lostBrowser) sessions.delete(id);  // keep disconnected entries for rescan+  }+  return null;+}++// Like resolveSession but throws a STRUCTURED error (JSON-encoded message+// with errorCode + currentSessions + _hint) when nothing resolves.+// v1.5.1+: differentiates "session not found" from "session known but+// browser disconnected" so callers / Claude can give the right next-+// step recipe (browser_rescan vs browser_open_window).+function resolveSessionOrError(args) {+  const resolved = resolveSession(args);+  if (resolved) return resolved;+  const explicit = !!args.sessionId;+  const known = explicit && sessions.has(args.sessionId);+  const requested = args.sessionId || '(none)';+  const currentSessions = [...sessions.keys()];+  const liveSessions = currentSessions.filter(id => {+    const s = sessions.get(id);+    return s && isSessionAlive(s);+  });+  const disconnectedSessions = currentSessions.filter(id => {+    const s = sessions.get(id);+    return s && s._lostBrowser;+  });+  let payload;+  if (known) {+    // Session entry exists but Chrome socket is gone — recoverable.+    payload = {+      error: `Session "${requested}" is known to the bridge but its Chrome browser is currently disconnected. The Chrome window may still be alive on the user's desktop; the CDP socket dropped (network blip, sleep/wake, puppeteer hiccup) and the bridge hasn't reconnected yet.`,+      errorCode: 'session_disconnected',+      currentSessions,+      liveSessions,+      disconnectedSessions,+      _hint:+        `Run \`browser_rescan\` to attempt automatic recovery. The 30s ` ++        `health check will also auto-attempt, but rescan is faster + ` ++        `idempotent. If rescan reports the profile as unreachable, ` ++        `Chrome itself probably crashed — relaunch with browser_open_window.`,+    };+  } else if (explicit) {+    // Caller asked for a specific sessionId we have NO record of.+    payload = {+      error: `Session "${requested}" not found in bridge. Either it was never opened, it was closed via browser_close_window, or the bridge restarted and the on-disk session file was cleaned.`,+      errorCode: 'session_not_found',+      currentSessions,+      liveSessions,+      disconnectedSessions,+      _hint:+        `Live sessions right now: [${liveSessions.join(', ') || '(none)'}]. ` ++        (disconnectedSessions.length > 0+          ? `Disconnected sessions awaiting rescan: [${disconnectedSessions.join(', ')}] — try \`browser_rescan\` first. `+          : '') ++        `If the Chrome window IS still on the desktop but the bridge can't see ` ++        `it, run browser_rescan (it walks every Chrome target and recovers ` ++        `sessions by parsing the (session: X) tag from each page's title). ` ++        `If you need to start fresh, browser_open_window with the right sessionId.`,+    };+  } else {+    // No sessionId passed and nothing is alive.+    payload = {+      error: `No live browser sessions. Use browser_open_window first.`,+      errorCode: 'no_session',+      currentSessions,+      liveSessions,+      disconnectedSessions,+      _hint:+        disconnectedSessions.length > 0+          ? `Disconnected sessions exist on disk: [${disconnectedSessions.join(', ')}]. Run \`browser_rescan\` to see if any are recoverable before opening a new window.`+          : `Open a Chrome window with: browser_open_window {"sessionId":"...","profile":"...","url":"..."}`,+    };+  }+  const err = new Error(JSON.stringify(payload));+  err._isStructured = true;+  throw err;+}++// Send a structured-or-plain session resolve error. Mirror of+// sendTabResolveError — used by every dispatch site that calls+// resolveSessionOrError, so the structured payload (errorCode ++// currentSessions + _hint) reaches the caller intact.+function sendSessionResolveError(res, e) {+  if (e && e._isStructured) {+    try {+      const parsed = JSON.parse(e.message);+      sendJSON(res, { success: false, ...parsed });+      return;+    } catch {}+  }+  sendJSON(res, { success: false, error: e.message });+}++// v1.6.1+: the AUTO-RECOVER path. Tries every layer of recovery before+// surfacing a session-not-found error to the caller. Used by every+// browser_* dispatch site that previously called resolveSessionOrError.+//+// Recovery ladder:+//   (a) Session is alive → return immediately.+//   (b) Session is in `_lostBrowser` state (CDP socket dropped, sleep/wake,+//       transient blip): attemptReconnectProfile(profile) — uses session+//       file → DevToolsActivePort → running-process-cmdline-scan as+//       three tiers of fallback. If reconnect succeeds, rescanProfile+//       walks pages and re-attaches. Return the recovered session.+//   (c) Reconnect failed but we have a last-known URL (saved in the+//       session file at launch / nav time): kill any orphan Chrome+//       holding the profile, clean profile locks (lockfile / Singleton*+//       / DevToolsActivePort), then call launchSession to start a fresh+//       Chrome with the same profile + URL. Caller sees a transparent+//       recovery — same sessionId, fresh page, last URL re-loaded.+//   (d) Session entry doesn't exist BUT a session file does (e.g. bridge+//       restarted): same logic — try reconnect via session file, fall+//       back to relaunch with the file's recorded URL.+//   (e) Nothing works → throw the structured session_not_found /+//       session_disconnected error from resolveSessionOrError so the+//       caller (or its higher-level retry logic) gets a clear signal.+//+// The Docker container's contract: `browser_navigate / eval / screenshot+// / input_dispatch / errors / reload / fetch_url` should NEVER surface+// "Session closed" / "detached Frame" / "Browser disconnected" — those+// become internal retry signals, not user-facing errors.+async function recoverOrRelaunchSession(args) {+  // (a) Fast path: live session, no recovery needed.+  const fast = resolveSession(args);+  if (fast) return fast;++  const requestedId = args.sessionId;+  // No explicit sessionId → fall through to the strict-error path; we+  // can't auto-recover what we don't know. Caller handles this via+  // sendSessionResolveError as before (omitted-sessionId is a real+  // "no session" case, not a recovery case).+  if (!requestedId) {+    return resolveSessionOrError(args);+  }++  const inMem = sessions.get(requestedId);+  const sessionFile = readSessionFile(requestedId);+  // We need at minimum the profile name to do anything. Either from the+  // in-memory session entry or from the on-disk session file.+  const profileName = (inMem && inMem.profileName) || (sessionFile && sessionFile.profile);+  if (!profileName) {+    // Session is genuinely unknown to the bridge. Surface the structured+    // session_not_found error.+    return resolveSessionOrError(args);+  }+  const lastKnownUrl = (sessionFile && sessionFile.url) || null;++  console.log(+    `[recover] session "${requestedId}" needs recovery — profile="${profileName}", ` ++    `inMemory=${!!inMem}, sessionFile=${!!sessionFile}, lastKnownUrl=${lastKnownUrl || '(none)'}`+  );++  // (b) Try reconnect via the existing 3-tier path (session file →+  // DevToolsActivePort → running-process scan).+  let browser = await attemptReconnectProfile(profileName);+  if (browser) {+    try {+      await rescanProfile(browser, profileName);+    } catch (e) {+      console.log(`[recover] rescanProfile after reconnect failed: ${e.message}`);+    }+    // After rescan, the session may now be in the map.+    const after = resolveSession(args);+    if (after) {+      console.log(`[recover] session "${requestedId}" reconnected without relaunch`);+      return after;+    }+  }++  // (c)/(d) Reconnect failed (or got a browser but couldn't find the+  // session inside it). Kill any orphan Chrome for this profile, clean+  // profile locks, then relaunch fresh with the last-known URL.+  console.log(`[recover] reconnect insufficient — killing orphans + cleaning profile + relaunching session "${requestedId}"`);+  await killOrphanChromesForProfile(profileName);+  await cleanProfileLocks(profileName);+  // Drop the dead in-memory entry so launchSession creates a fresh one.+  // (launchSession's existing path checks for an existing session and+  // would try to navigate it — but its page handle is dead.)+  if (inMem) {+    sessions.delete(requestedId);+  }+  try {+    const session = await launchSession(requestedId, lastKnownUrl, { profile: profileName });+    if (session) {+      session._recovered = true;+      session._recoveryReason = browser ? 'reconnect-no-page' : 'relaunch';+      console.log(`[recover] session "${requestedId}" relaunched at ${lastKnownUrl || '(no URL)'}`);+      const final = resolveSession({ sessionId: requestedId });+      if (final) return final;+    }+  } catch (e) {+    console.log(`[recover] launchSession failed during recovery: ${e.message}`);+  }++  // (e) Total failure — surface the structured error so the caller can+  // tell the user (or escalate to browser_open_window with explicit URL).+  return resolveSessionOrError(args);+}++// Wrap an async page operation with a timeout so the HTTP response always comes back+async function withTimeout(promise, ms, label) {+  let timer;+  const timeout = new Promise((_, reject) => {+    timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);+  });+  try {+    return await Promise.race([promise, timeout]);+  } finally {+    clearTimeout(timer);+  }+}++// Periodic health check (v1.5.1+):+//+//   - Detect disconnected browsers and ATTEMPT RECONNECT before removing+//     them. Old (≤v1.5.0) behavior was to immediately delete the browser+//     entry + every session for that profile + every session file. That+//     erased recovery state for what was usually a transient network+//     blip (sleep/wake, puppeteer hiccup), leaving an orphaned-but-alive+//     Chrome window the bridge could no longer see. We now keep state+//     and try to recover.+//+//   - Sessions whose browser is gone get marked _lostBrowser; their entries+//     stay in the Map and on disk so browser_rescan / next health check+//     can recover them. Only delete a session file when its underlying+//     Chrome PID is confirmed dead.+//+//   - Stale-file cleanup still runs (PID dead → file deleted) so we don't+//     accumulate junk for Chromes that genuinely crashed.+setInterval(async () => {+  for (const [profileName, be] of browsers) {+    if (!be.browser.isConnected()) {+      console.log(`[health] browser for profile "${profileName}" disconnected — attempting reconnect`);+      browsers.delete(profileName); // attemptReconnectProfile re-adds on success+      const reconnected = await attemptReconnectProfile(profileName).catch(() => null);+      if (reconnected) {+        try {+          const r = await rescanProfile(reconnected, profileName);+          console.log(`[health] auto-reconnected + rescanned profile "${profileName}": ${r.reattached} reattached`);+        } catch (e) {+          console.log(`[health] rescan after reconnect failed for "${profileName}": ${e.message}`);+        }+      } else {+        console.log(`[health] could not reconnect profile "${profileName}" — sessions remain marked _lostBrowser`);+      }+    }+  }+  // Don't hard-delete sessions whose browser is currently disconnected —+  // they may recover on the next loop. Only purge sessions whose specific+  // PAGE handle is dead (tab was closed individually) AND profile is alive.+  for (const [id, s] of sessions) {+    if (s._lostBrowser) continue; // skip — awaiting reconnect+    if (!isSessionAlive(s)) {+      console.log(`[health] session "${id}" dead (page closed individually) — removing`);+      sessions.delete(id);+      deleteSessionFile(id);+      if (activeSessionId === id) {+        activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;+      }+    }+  }+  // Clean stale session files whose PIDs are confirmed dead. Sessions that+  // are _lostBrowser but whose PID is alive STAY on disk for recovery.+  const files = listSessionFiles();+  for (const sf of files) {+    if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {+      console.log(`[health] stale session file "${sf.sessionId}" (PID ${sf.pid} dead) — deleting`);+      deleteSessionFile(sf.sessionId);+    }+  }+}, 30_000);++// v1.0.16: the single source of truth for per-verb HINTS — used BOTH by+// `browser_describe` (so AD's Verbs tab shows hint/related/pitfalls under each+// verb) AND by sendJSON's runtime injector below (so EVERY browser_* call+// self-documents inline, even for an AI that never read describe). This is the+// reference shape the cloud-owned bridges (kicad/fusion/native-browser) were+// asked to adopt: a calling AI should never have to guess and never have to+// read docs first. { verb: { hint, related[], pitfalls[] } }.+const VERB_META = {+  browser_open_window: { hint: 'Opens a session you then drive by sessionId. Pass nativeBrowser+nativeProfile to drive the user\'s REAL signed-in Chrome/Edge/Brave; omit both for a clean CfT window.', related: ['browser_navigate', 'browser_list_windows', 'browser_close_window'], pitfalls: ['sessionId is required and is your handle for every later call', 'nativeBrowser and nativeProfile must be passed together', 'run desktop_list_browsers first to see installed native profiles'] },+  browser_open_tab: { hint: 'Adds a tab to an existing session; returns the new tabId.', related: ['browser_switch_tab', 'browser_list_tabs', 'browser_close_tab'], pitfalls: ['the session must already exist (browser_open_window first)'] },+  browser_close_tab: { hint: 'Closes one tab; the session stays open.', related: ['browser_close_window', 'browser_list_tabs'], pitfalls: ['omit tabId to close the active tab — closing the last tab may close the window'] },+  browser_close_window: { hint: 'Closes the whole session and all its tabs. Destructive.', related: ['browser_list_windows'], pitfalls: ['this ends the session — its sessionId is no longer valid afterward'] },+  browser_navigate: { hint: 'Drives the active tab to url and waits for load. Verify with a screenshot before asserting success.', related: ['browser_screenshot', 'browser_wait', 'browser_reload'], pitfalls: ['navigation can race SPA routing — confirm with browser_screenshot', 'some sites need browser_wait for a selector after navigate'] },+  browser_back: { hint: 'History back on the active tab.', related: ['browser_forward', 'browser_navigate'], pitfalls: [] },+  browser_forward: { hint: 'History forward on the active tab.', related: ['browser_back', 'browser_navigate'], pitfalls: [] },+  browser_reload: { hint: 'Reloads the active tab.', related: ['browser_navigate', 'browser_wait'], pitfalls: [] },+  browser_click: { hint: 'Clicks by CSS selector (preferred) or by x,y coords. Selector is more robust across layouts.', related: ['browser_type', 'browser_input_dispatch', 'browser_screenshot'], pitfalls: ['selector must match exactly one visible element — verify with browser_evaluate first', 'coords are viewport-relative; a scrolled element may be off-screen'] },+  browser_type: { hint: 'Types into the focused or selector-targeted field.', related: ['browser_click', 'browser_press_key'], pitfalls: ['focus the field first (browser_click the input) or pass selector', 'does not clear existing text — select-all + delete if you need a clean field'] },+  browser_press_key: { hint: 'Presses a key or chord (Enter, Ctrl+A, Tab).', related: ['browser_type', 'browser_input_dispatch'], pitfalls: ['chord syntax is "Ctrl+A" — verify the page has focus on the intended element'] },+  browser_screenshot: { hint: 'PNG of the page. Your primary way to SEE state — call it after navigate/click to confirm.', related: ['browser_screenshot_full_res', 'browser_navigate'], pitfalls: ['default is the viewport; pass fullPage:true for the whole scroll height'] },+  browser_evaluate: { hint: 'Runs JS in the page and returns the result — use it to read DOM state or verify selectors before acting.', related: ['browser_click', 'browser_fetch_url'], pitfalls: ['the expression must be serializable JSON in its return value', 'runs in the page context, not Node'] },+  browser_fetch_url: { hint: 'Fetches a URL using the tab\'s live cookies/session — for authed APIs the bare CLI can\'t reach. Returns bodyBase64.', related: ['browser_navigate', 'browser_evaluate'], pitfalls: ['for session-scoped URLs pass the tabId whose cookies you need', 'returns base64 — decode it caller-side'] },+  browser_input_dispatch: { hint: 'Low-level CDP mouse/keyboard input at coords — beats some overlays/iframes that swallow synthetic clicks.', related: ['browser_click', 'browser_press_key'], pitfalls: ['coords are viewport pixels', 'last-resort when browser_click can\'t reach the element — still cannot pass Cloudflare interactive challenges'] },+  browser_record_start: { hint: 'Starts video recording of the page for this session.', related: ['browser_record_stop', 'browser_record_status'], pitfalls: ['one recording per session — stop the current one before starting another'] },+  browser_record_stop: { hint: 'Stops recording and returns the saved file path.', related: ['browser_record_start', 'browser_record_list'], pitfalls: ['no-op if nothing is recording for this session'] },+  browser_record_status: { hint: 'Whether a recording is active for the session.', related: ['browser_record_start', 'browser_record_stop'], pitfalls: [] },+  browser_record_list: { hint: 'Lists saved page recordings.', related: ['browser_record_start'], pitfalls: [] },+  desktop_recorder_open: { hint: 'Opens the desktop screen-recorder UI window.', related: ['desktop_record_start', 'desktop_recorder_close'], pitfalls: [] },+  desktop_record_start: { hint: 'Starts a full desktop screen recording.', related: ['desktop_record_stop', 'desktop_record_status'], pitfalls: ['only one desktop recording at a time'] },+  desktop_record_stop: { hint: 'Stops the desktop recording and returns the file.', related: ['desktop_record_start', 'desktop_record_list'], pitfalls: [] },+  browser_readiness: { hint: 'Cold-start readiness probe: {ready, chromeForTestingInstalled, installing, installProgressPct}. On a fresh PC, poll this until ready:true before browser_open_window — the bridge auto-installs Chrome for Testing on its own.', related: ['browser_prewarm', 'browser_open_window'], pitfalls: ['ready:false with installing:true just means Chrome for Testing is still downloading — wait + poll, do NOT treat it as an error', 'installPhase:"failed" → surface lastError to the user (usually network/proxy blocking the download, or low disk)'] },+  browser_prewarm: { hint: 'Installs Chrome for Testing (the browser pup drives) WITHOUT opening a window — the warm-before-first-use path. Returns fast; poll browser_readiness for completion.', related: ['browser_readiness', 'browser_open_window'], pitfalls: ['the ~150 MB download needs network access to storage.googleapis.com', 'does not open a window — call browser_open_window once readiness reports ready:true'] },+  browser_describe: { hint: 'This list — the bridge\'s verbs + schemas + hints, for AD\'s Verbs tab. Read it once to learn the surface.', related: ['browser_status'], pitfalls: [] },+};++function sendJSON(res, obj, status = 200) {+  try {+    // v1.0.16: runtime _hint injection — if this response is for a documented+    // verb and the handler didn't already set its own _hint, attach the verb's+    // canonical hint (+ related) from VERB_META. So EVERY browser_* call carries+    // actionable guidance inline, matching the contract AD asks all bridges for.+    if (obj && typeof obj === 'object' && !Array.isArray(obj)) {+      const meta = res && res._adomCommand && VERB_META[res._adomCommand];+      if (meta) {+        if (obj._hint === undefined && meta.hint) obj._hint = meta.hint;+        if (obj.related === undefined && Array.isArray(meta.related) && meta.related.length) obj.related = meta.related;+      }+    }+    res.writeHead(status, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });+    res.end(JSON.stringify(obj));+  } catch (e) { /* response already sent */ }+}++function readBody(req) {+  return new Promise(resolve => {+    let d = '';+    req.on('data', c => d += c);+    req.on('end', () => resolve(d));+  });+}++async function getSessionInfo(sessionId, session) {+  let url = null, title = null;+  try {+    url = session.page?.url() || null;+    title = session.page ? await session.page.title() : null;+  } catch (e) {}+  const info = {+    sessionId,+    url,+    title,+    active: sessionId === activeSessionId,+    errorCount: session.errors.length,+    profile: session.profileName,+    tabCount: session.tabs.length,+    activeTabId: session.activeTabId,+  };+  // One-shot: surface launch-time metadata (restored-tab cleanup count)+  // so callers can verify session-restore prevention worked. Drop after read.+  if (session.lastLaunchMeta) {+    info.restoredTabsClosed = session.lastLaunchMeta.restoredTabsClosed;+    delete session.lastLaunchMeta;+  }+  return info;+}++const server = http.createServer(async (req, res) => {+  const url = new URL(req.url, `http://localhost:${PORT}`);+  const pathname = url.pathname;++  // CORS preflight+  if (req.method === 'OPTIONS') {+    res.writeHead(204, {+      'Access-Control-Allow-Origin': '*',+      'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',+      'Access-Control-Allow-Headers': 'Content-Type',+    });+    res.end();+    return;+  }++  try {+    // GET /health+    if (pathname === '/health') {+      const sessionList = [];+      for (const [id, s] of sessions) {+        let pageUrl = null;+        try { pageUrl = s.page.url(); } catch (e) {}+        sessionList.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });+      }+      sendJSON(res, {+        status: 'ok',+        bridge: 'puppeteer',+        version: BRIDGE_VERSION,+        port: PORT,+        browserCount: browsers.size,+        sessionCount: sessions.size,+        activeSession: activeSessionId,+        sessions: sessionList,+        chrome: chrome.readiness(),+      });+      return;+    }++    // POST /command — generic command dispatch+    if (pathname === '/command' && req.method === 'POST') {+      const body = JSON.parse(await readBody(req));+      const command = body.command;+      const args = body.args || {};+      res._adomCommand = command; // v1.0.16: lets sendJSON inject this verb's _hint/related++      switch (command) {+        // v1.0.15: self-describe — AD's "Verbs" tab POSTs {command:'browser_describe'}+        // and renders this verbatim: one entry per documented verb with+        // {name, summary, input, output, timeoutSeconds, example}. (browser_ is this+        // bridge's primary prefix, so AD calls it browser_describe.)+        case 'browser_describe': {+          // v1.0.16: each entry merges its VERB_META (hint/related/pitfalls) so AD's+          // Verbs tab renders the full self-doc — the reference shape for all bridges.+          const V = (name, summary, input, timeoutSeconds, example) => {+            const m = VERB_META[name] || {};+            return { name, summary, input, output: { success: 'bool' }, timeoutSeconds,+                     statusVerb: null, longRunning: false, example: example || {},+                     hint: m.hint || summary, related: m.related || [], pitfalls: m.pitfalls || [] };+          };+          sendJSON(res, { success: true, bridge: 'puppeteer', verbs: [+            V('browser_open_window', 'Open a browser window/session (CfT, or the user\'s real Chrome/Edge/Brave via nativeBrowser).', { sessionId: 'required str', url: 'optional str', profile: 'optional str', nativeBrowser: 'optional chrome|edge|brave', nativeProfile: 'optional str' }, 60, { sessionId: 'work', url: 'https://example.com' }),+            V('browser_open_tab', 'Open a new tab in a session.', { sessionId: 'required str', url: 'optional str' }, 30, { sessionId: 'work', url: 'https://example.com' }),+            V('browser_close_tab', 'Close a tab.', { sessionId: 'required str', tabId: 'optional str (default active)' }, 15, { sessionId: 'work' }),+            V('browser_close_window', 'Close a window/session (its tabs go too).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+            V('browser_navigate', 'Navigate the active tab to a URL.', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://example.com' }),+            V('browser_back', 'History back.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+            V('browser_forward', 'History forward.', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+            V('browser_reload', 'Reload the active tab.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),+            V('browser_click', 'Click an element (by selector or coords).', { sessionId: 'required str', selector: 'optional str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', selector: 'button#go' }),+            V('browser_type', 'Type text into the focused/selected field.', { sessionId: 'required str', text: 'required str', selector: 'optional str' }, 20, { sessionId: 'work', text: 'hello' }),+            V('browser_press_key', 'Press a key / chord (e.g. Enter, Ctrl+A).', { sessionId: 'required str', key: 'required str' }, 15, { sessionId: 'work', key: 'Enter' }),+            V('browser_screenshot', 'Screenshot the page (PNG; fullPage optional).', { sessionId: 'required str', fullPage: 'optional bool' }, 30, { sessionId: 'work' }),+            V('browser_evaluate', 'Evaluate JS in the page; returns the result.', { sessionId: 'required str', expression: 'required str' }, 30, { sessionId: 'work', expression: 'document.title' }),+            V('browser_fetch_url', 'Fetch a URL\'s content via the browser (cookies/session intact).', { sessionId: 'required str', url: 'required str' }, 45, { sessionId: 'work', url: 'https://api.example.com' }),+            V('browser_input_dispatch', 'Low-level input (mouse/keyboard) at coords — beats some overlays.', { sessionId: 'required str', type: 'required str', x: 'optional int', y: 'optional int' }, 20, { sessionId: 'work', type: 'mousePressed', x: 100, y: 200 }),+            V('browser_record_start', 'Start recording the page (video).', { sessionId: 'required str' }, 20, { sessionId: 'work' }),+            V('browser_record_stop', 'Stop recording; returns the saved file.', { sessionId: 'required str' }, 30, { sessionId: 'work' }),+            V('browser_record_status', 'Recording status for a session.', { sessionId: 'required str' }, 10, { sessionId: 'work' }),+            V('browser_record_list', 'List recordings.', {}, 10, {}),+            V('desktop_recorder_open', 'Open the desktop screen-recorder UI.', {}, 20, {}),+            V('desktop_record_start', 'Start a desktop screen recording.', { outPath: 'optional str' }, 20, {}),+            V('desktop_record_stop', 'Stop the desktop screen recording; returns the file.', {}, 30, {}),+            V('browser_readiness', 'Cold-start readiness: is Chrome for Testing installed / installing? Poll before+after a first open on a fresh PC.', {}, 10, {}),+            V('browser_prewarm', 'Install Chrome for Testing WITHOUT opening a window (warm the cold-start before first use).', { wait: 'optional bool — block until installed' }, 240, {}),+            V('browser_describe', 'This list — the bridge\'s verbs + schemas, for AD\'s Verbs tab.', {}, 10, {}),+          ] });+          return;+        }+        // --- Session management commands ---+        case 'browser_open_window': {+          if (!args.sessionId) {+            sendJSON(res, { success: false, error: 'sessionId is required. Example: browser_open_window { "sessionId": "dart2", "profile": "dart2", "url": "https://..." }' });+            return;+          }+          // v1.0.5+: native drive — nativeBrowser+nativeProfile route to the+          // user's REAL Chrome/Edge/Brave profile via a cloned user-data-dir.+          // Both args required together; mutually exclusive with the pup+          // `profile` arg. Default (no native args) is byte-for-byte the same+          // CfT path as v1.0.4 — strictly additive, no regressions.+          const wantsNative = !!(args.nativeBrowser || args.nativeProfile);+          // ── Cold-start self-heal (CfT path only) ─────────────────────────────+          // The default (non-native) path drives Chrome for Testing. On a fresh PC+          // — HD bundled AD, and the installer pre-installed NEITHER Node NOR CfT —+          // this is the #1 failure ("open in pup did nothing"). Detect + auto-install+          // CfT (non-blocking) and return a POLLABLE structured status instead of+          // blindly spawning a missing binary 3× then throwing a dev-string.+          // Native drive uses the user's REAL browser (not CfT), so it skips this.+          if (!wantsNative) {+            const r = await chrome.ensureChromeReady({ background: true });+            if (!r.ready) {+              const rd = chrome.readiness();+              if (rd.installPhase === 'failed') {+                sendJSON(res, {+                  success: false,+                  errorCode: rd.lastErrorCode || 'chrome_install_failed',+                  error: `Chrome for Testing (the browser pup drives) could not be installed: ${rd.lastError}`,+                  chromeForTestingInstalled: false,+                  readiness: rd,+                  _hint: 'Chrome for Testing failed to download. This is almost always no network / a proxy blocking storage.googleapis.com, or low disk — NOT a Node problem (Node is fine; the bridge is running). Retry with browser_prewarm; if it keeps failing, surface readiness.lastError to the user. As a fallback you can drive the user\'s real browser via browser_open_window with nativeBrowser+nativeProfile (run desktop_list_browsers first).',+                });+                return;+              }+              sendJSON(res, {+                success: false,+                errorCode: 'chrome_for_testing_installing',+                installing: true,+                progressPct: rd.installProgressPct,+                chromeForTestingInstalled: false,+                statusVerb: 'browser_readiness',+                readiness: rd,+                error: 'Chrome for Testing (the browser pup drives) is not installed on this machine yet — the bridge is downloading it now (~150 MB, one time).',+                _hint: 'FIRST-TIME SETUP on this PC: pup needs Chrome for Testing and is installing it for you in the background — you do NOT need to install anything yourself. Poll `browser_readiness` until {ready:true} (typically 1–3 min on a normal connection), THEN re-issue this exact browser_open_window call. Do not report failure; this is expected on a fresh machine.',+              });+              return;+            }+          }+          if (wantsNative) {+            if (!args.nativeBrowser || !args.nativeProfile) {+              sendJSON(res, { success: false, error: 'nativeBrowser and nativeProfile must be passed together. Example: browser_open_window { "sessionId": "gcp", "nativeBrowser": "chrome", "nativeProfile": "Default", "url": "https://console.cloud.google.com" }. Run desktop_list_browsers first to see what is installed.' });+              return;+            }+            if (args.profile) {+              sendJSON(res, { success: false, error: 'nativeBrowser/nativeProfile are mutually exclusive with `profile` (pup-managed CfT). Drop `profile` to drive the real browser, OR drop nativeBrowser/nativeProfile to use CfT.' });+              return;+            }+            const sessionId = args.sessionId;+            try {+              const session = await launchNativeSession(sessionId, args.url, {+                browser: args.nativeBrowser,+                profileId: args.nativeProfile,+                nativeRelaunch: !!args.nativeRelaunch,+              });+              const info = await getSessionInfo(sessionId, session);+              sendJSON(res, {+                success: true,+                output: JSON.stringify({+                  ok: true,+                  native: true,+                  nativeBrowser: args.nativeBrowser,+                  nativeProfile: args.nativeProfile,+                  launchedFresh: !!session._launchedFresh,+                  narrative: session._narrative || null,+                  ...info,+                }),+              });+            } catch (err) {+              // Surface every structured field the caller needs: the consent+              // prompt (confirmArgs/mustCloseBrowser), the policy diagnosis, the+              // alternative browsers it can offer the user, and the always-+              // available generic-pup fallback. (v1.0.11)+              sendJSON(res, {+                success: false,+                errorCode: err.errorCode || 'native_launch_failed',+                error: err.message,+                displayName: err.displayName,+                runningProcesses: err.runningProcesses,+                mustCloseBrowser: err.mustCloseBrowser,+                confirmArgs: err.confirmArgs,+                diagnosis: err.diagnosis,+                alternatives: err.alternatives,+                genericPupAvailable: err.genericPupAvailable,+                cdpBlocked: err.cdpBlocked,+              });+            }+            return;+          }+          if (!args.profile) {+            sendJSON(res, { success: false, error: 'profile is required. Example: browser_open_window { "sessionId": "dart2", "profile": "dart2", "url": "https://..." }. Use same value as sessionId for a dedicated profile, or share another session\'s profile to share IndexedDB/cookies. (For driving your real Chrome instead of CfT, pass nativeBrowser + nativeProfile — see desktop_list_browsers.)' });+            return;+          }+          const sessionId = args.sessionId;+          // v1.6.3+: optional downloadPath override. Defaults to the user's+          // home Downloads dir, which is what desktop_watch_files polls by+          // default — the two ends meet without configuration. Caller can+          // override per-session via this arg if they want downloads to+          // land elsewhere (e.g. straight into a chip-fetcher staging dir).+          const downloadPath = (args.downloadPath && typeof args.downloadPath === 'string')+            ? args.downloadPath+            : path.join(os.homedir(), 'Downloads');+          const session = await launchSession(sessionId, args.url, {+            freshProfile: !!args.freshProfile,+            profile: args.profile,+            strictPermissions: !!args.strictPermissions,+            downloadPath,+            nativeSite: !!args.nativeSite,+          });+          // Stash on the session so attachTab uses it for any subsequent+          // tab open or popup auto-attach in this session.+          if (session) session._downloadPath = downloadPath;+          const info = await getSessionInfo(sessionId, session);+          sendJSON(res, {+            success: true,+            output: JSON.stringify({+              ok: true,+              // Surface what we did to permissions so callers can audit the+              // session's behavior. defaultPermissionsApplied is true on+              // first browser launch for this profile; subsequent sessions+              // sharing the same profile see false (the browser already+              // had the grants from its first launch).+              defaultPermissionsApplied: !args.strictPermissions,+              defaultPermissions: args.strictPermissions ? [] : PERMISSIVE_DEFAULT_PERMISSIONS.map(p => `${p.name}=${p.setting}`),+              // v1.6.3+: downloads enabled by default. Surface the path so+              // the caller knows where downloads will land (and can pass+              // it to desktop_watch_files without re-deriving).+              downloadsEnabled: !args.strictPermissions,+              downloadPath,+              ...info,+            }),+          });+          return;+        }+        case 'browser_switch_window': {+          const sessionId = args.sessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            const available = [...sessions.keys()];+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found. Available: ${available.join(', ')}` });+            return;+          }+          activeSessionId = sessionId;+          const session = sessions.get(sessionId);+          const info = await getSessionInfo(sessionId, session);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...info }) });+          return;+        }+        case 'browser_list_windows': {+          const list = [];+          for (const [id, s] of sessions) {+            list.push(await getSessionInfo(id, s));+          }+          sendJSON(res, { success: true, output: JSON.stringify({ sessions: list, count: list.length, activeSession: activeSessionId }) });+          return;+        }+        case 'browser_rescan': {+          // v1.5.1+: orphan-recovery primitive. Walk every known profile+          // (in-memory + on-disk session files), attempt reconnect for any+          // whose CDP socket dropped, then walk pages of every connected+          // browser and rebuild session entries by parsing the (session: X)+          // tag from each page's title. Idempotent — pages already attached+          // to a live session are left alone.+          //+          // Args:+          //   adoptOrphans (optional bool, default false) — when true,+          //     pages with no (session: X) tag are attached under a+          //     generated sessionId so the caller can drive them. Useful+          //     when Chrome has tabs the bridge has never seen (e.g. user+          //     opened them manually). Default false to avoid surprising+          //     callers with random tabs in their sessions list.+          try {+            const adoptOrphans = !!args.adoptOrphans;+            const stats = await rescanAllProfiles({ adoptOrphans });+            // Build the post-rescan session list so callers can verify+            // their sessionId is back. liveSessions are usable; disconnected+            // are still awaiting Chrome to reappear (PID gone or port dead).+            const liveSessions = [];+            const disconnectedSessions = [];+            for (const [id, s] of sessions) {+              if (isSessionAlive(s)) {+                liveSessions.push(id);+              } else if (s._lostBrowser) {+                disconnectedSessions.push(id);+              }+            }+            sendJSON(res, {+              success: true,+              output: JSON.stringify({+                ok: true,+                rescanned: stats.profilesScanned,+                profilesReconnected: stats.profilesReconnected,+                profilesUnreachable: stats.profilesUnreachable,+                reattached: stats.totalReattached,+                orphansAdopted: stats.totalAdopted,+                liveSessions,+                disconnectedSessions,+                _hint:+                  disconnectedSessions.length > 0+                    ? `Some sessions still disconnected: [${disconnectedSessions.join(', ')}]. Their Chrome PID may be dead — check with browser_list_windows. browser_open_window (with the original sessionId + profile) will start fresh; the on-disk session file is overwritten.`+                    : (liveSessions.length > 0+                      ? `All known sessions are live: [${liveSessions.join(', ')}].`+                      : `No sessions known to the bridge. browser_open_window to start one.`),+              }),+            });+          } catch (e) {+            sendJSON(res, { success: false, error: `rescan failed: ${e.message}` });+          }+          return;+        }+        case 'browser_close_window': {+          const sessionId = args.sessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+            return;+          }+          const session = sessions.get(sessionId);+          const profileName = session.profileName;++          // Close ALL tabs in this session (a window can now hold many tabs)+          for (const t of [...session.tabs]) {+            try { await t.page.close(); } catch (e) {}+          }+          session.tabs.length = 0;+          session.activeTabId = null;+          sessions.delete(sessionId);+          deleteSessionFile(sessionId);++          // Decrement refCount; kill browser if no sessions left using this profile+          const be = browsers.get(profileName);+          if (be) {+            be.refCount = Math.max(0, be.refCount - 1);+            if (be.refCount <= 0) {+              console.log(`No more sessions using profile "${profileName}" — killing browser`);+              await killBrowserProcess(be.browser, profileName);+              browsers.delete(profileName);+            }+          }++          if (activeSessionId === sessionId) {+            activeSessionId = sessions.size > 0 ? sessions.keys().next().value : null;+          }+          console.log(`Session "${sessionId}" closed (all tabs). Remaining sessions: ${sessions.size}`);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, remaining: sessions.size, activeSession: activeSessionId }) });+          return;+        }++        // --- Tab-level commands ---+        case 'browser_open_tab': {+          const sessionId = args.sessionId || activeSessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            const available = [...sessions.keys()].join(', ');+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found. Use browser_open_window first. Available: [${available}]` });+            return;+          }+          try {+            const tabId = await addTabToSession(sessionId, args.url);+            const session = sessions.get(sessionId);+            const tab = getTabById(session, tabId);+            const info = await describeTab(tab, session.activeTabId);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info }) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'browser_switch_tab': {+          const sessionId = args.sessionId || activeSessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+            return;+          }+          const session = sessions.get(sessionId);+          const tab = getTabById(session, args.tabId);+          if (!tab) {+            const available = session.tabs.map(t => t.tabId).join(', ');+            sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });+            return;+          }+          session.activeTabId = tab.tabId;+          try { await tab.page.bringToFront(); } catch {}+          const info = await describeTab(tab, session.activeTabId);+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, ...info }) });+          return;+        }+        case 'browser_close_tab': {+          const sessionId = args.sessionId || activeSessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+            return;+          }+          const session = sessions.get(sessionId);+          if (!args.tabId) {+            sendJSON(res, { success: false, error: 'tabId is required for browser_close_tab' });+            return;+          }+          const idx = session.tabs.findIndex(t => t.tabId === args.tabId);+          if (idx === -1) {+            const available = session.tabs.map(t => t.tabId).join(', ');+            sendJSON(res, { success: false, error: `Tab "${args.tabId}" not found in session "${sessionId}". Available: [${available}]` });+            return;+          }+          const tab = session.tabs[idx];+          try { await tab.page.close(); } catch {}+          // The page.on('close', ...) handler in attachTab removes the tab from+          // session.tabs, but race-proof: remove here too if still present.+          const stillIdx = session.tabs.findIndex(t => t.tabId === args.tabId);+          if (stillIdx !== -1) session.tabs.splice(stillIdx, 1);+          if (session.activeTabId === args.tabId) {+            session.activeTabId = session.tabs.length > 0 ? session.tabs[session.tabs.length - 1].tabId : null;+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, closedTab: args.tabId, remaining: session.tabs.length, activeTabId: session.activeTabId }) });+          return;+        }+        case 'browser_list_tabs': {+          const sessionId = args.sessionId || activeSessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+            return;+          }+          const session = sessions.get(sessionId);+          const tabs = [];+          for (const t of session.tabs) {+            tabs.push(await describeTab(t, session.activeTabId));+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, count: tabs.length, activeTabId: session.activeTabId, tabs }) });+          return;+        }++        case 'browser_alert_window': {+          // Flashes the taskbar icon for a session's Chrome window (does NOT bring to foreground)+          const sessionId = args.sessionId || activeSessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+            return;+          }+          const session = sessions.get(sessionId);+          try {+            await session.page.bringToFront();+            activeSessionId = sessionId;+            console.log(`Alerted session "${sessionId}" (taskbar flash)`);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, alerted: true }) });+          } catch (e) {+            sendJSON(res, { success: false, error: `Alert failed: ${e.message}` });+          }+          return;+        }+        case 'browser_focus_window': {+          // Actually brings the Chrome window to the foreground using Win32 SetForegroundWindow+          const sessionId = args.sessionId || activeSessionId;+          if (!sessionId || !sessions.has(sessionId)) {+            sendJSON(res, { success: false, error: `Session "${sessionId}" not found` });+            return;+          }+          const session = sessions.get(sessionId);+          try {+            // First activate the tab within Chrome+            await session.page.bringToFront();+            activeSessionId = sessionId;++            // Get Chrome PID from the browser process+            const browserProcess = browsers.get(session.profileName)?.browser?.process();+            const pid = browserProcess?.pid;+            if (pid && process.platform === 'win32') {+              // Write PowerShell script to temp file and execute (avoids quoting hell)+              const psScript = path.join(require('os').tmpdir(), '_adom_focus.ps1');+              const psContent = [+                'Add-Type @"',+                'using System; using System.Runtime.InteropServices;',+                'public class WinFocus {',+                '  [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);',+                '  [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);',+                '  [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd);',+                '}',+                '"@',+                `$targetPid = ${pid}`,+                '$p = Get-Process -Id $targetPid -ErrorAction SilentlyContinue',+                'if ($p -and $p.MainWindowHandle -ne 0) {',+                '  if ([WinFocus]::IsIconic($p.MainWindowHandle)) { [WinFocus]::ShowWindow($p.MainWindowHandle, 9) }',+                '  [WinFocus]::SetForegroundWindow($p.MainWindowHandle)',+                "  Write-Output 'focused'",+                '} else {',+                '  Get-WmiObject Win32_Process | Where-Object { $_.ParentProcessId -eq $targetPid -and $_.Name -eq "chrome.exe" } | ForEach-Object {',+                '    $cp = Get-Process -Id $_.ProcessId -ErrorAction SilentlyContinue',+                '    if ($cp -and $cp.MainWindowHandle -ne 0) {',+                '      if ([WinFocus]::IsIconic($cp.MainWindowHandle)) { [WinFocus]::ShowWindow($cp.MainWindowHandle, 9) }',+                '      [WinFocus]::SetForegroundWindow($cp.MainWindowHandle)',+                "      Write-Output 'focused'",+                '      return',+                '    }',+                '  }',+                '}',+              ].join('\r\n');+              fs.writeFileSync(psScript, psContent);+              try {+                const result = execSync(`powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${psScript}"`, { timeout: 8000 }).toString().trim();+                console.log(`Focused session "${sessionId}" via SetForegroundWindow: ${result}`);+              } catch (e) {+                console.error(`SetForegroundWindow failed for session "${sessionId}":`, e.message);+              }+            }+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId, focused: true }) });+          } catch (e) {+            sendJSON(res, { success: false, error: `Focus failed: ${e.message}` });+          }+          return;+        }++        // --- Existing commands (operate on active session) ---+        case 'browser_launch': {+          sendJSON(res, { success: false, error: 'browser_launch is removed. Use browser_open_window with required sessionId and profile params.' });+          return;+        }+        case 'browser_screenshot': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const fullPage = args.fullPage || false;+          const maxWidth = args.maxWidth || MAX_SCREENSHOT_DIM;+          const ts = Date.now();+          let screenshotBuf = await withTimeout(+            tab.page.screenshot({ type: 'png', fullPage }),+            20_000, 'page.screenshot'+          );++          // v1.8.40+: sharp is mandatory — no `if (sharp)` guard, no try/catch+          // fallback. If the resize fails the request errors out cleanly rather+          // than silently sending a 2559px image that would crash a many-image+          // Claude session at the 2000px API limit. For the rare "I want the+          // real resolution to save to disk" case, use browser_screenshot_full_res.+          const origMeta = await sharp(screenshotBuf).metadata();+          const maxDim = Math.min(maxWidth, MAX_SCREENSHOT_DIM);+          const needsResize = origMeta.width > maxDim || origMeta.height > maxDim;+          const pipeline = needsResize+            ? sharp(screenshotBuf).resize({ width: maxDim, height: maxDim, fit: 'inside' })+            : sharp(screenshotBuf);+          screenshotBuf = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();+          // v1.8.40+: probe the FINAL dimensions (post-resize) so the response+          // tells the caller exactly what they're going to Read. Without these+          // the AI can only guess from sizeKB.+          const finalMeta = await sharp(screenshotBuf).metadata();++          const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);+          fs.writeFileSync(filePath, screenshotBuf);+          const sizeKB = Math.round(screenshotBuf.length / 1024);+          session.latestShot = filePath;+          const base64 = screenshotBuf.toString('base64');++          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true,+            sizeKB,+            filePath,+            base64,+            width: finalMeta.width,+            height: finalMeta.height,+            origWidth: origMeta.width,+            origHeight: origMeta.height,+            resized: needsResize,+            sessionId: resolvedId,+            tabId,+          }) });+          return;+        }+        case 'browser_screenshot_full_res': {+          // v1.8.40+: full-resolution variant for the rare "save to disk for+          // external use" case (export to wiki, hand to a designer, etc.).+          // NEVER pass the resulting image to Claude via Read — full-res+          // browser viewports are typically 2559x1398 (or larger on hi-DPI+          // monitors), which exceeds Claude's 2000px many-image limit and+          // crashes the session. For AI-readable screenshots, use+          // browser_screenshot which forces a ≤1568px resize.+          let session, resolvedId;+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const fullPage = args.fullPage || false;+          const ts = Date.now();+          const screenshotBuf = await withTimeout(+            tab.page.screenshot({ type: 'png', fullPage }),+            20_000, 'page.screenshot'+          );+          // Probe dimensions for the warning payload — cheap (sharp just reads+          // the PNG header). No resize, no recompression — the bytes that hit+          // disk are exactly what Chrome captured.+          const meta = await sharp(screenshotBuf).metadata();+          const filePath = path.join(SHOTS_DIR, `pup-fullres-${ts}.png`);+          fs.writeFileSync(filePath, screenshotBuf);+          const sizeKB = Math.round(screenshotBuf.length / 1024);+          session.latestShot = filePath;+          const base64 = screenshotBuf.toString('base64');+          sendJSON(res, {+            success: true,+            output: JSON.stringify({+              ok: true,+              sizeKB,+              filePath,+              base64,+              width: meta.width,+              height: meta.height,+              sessionId: resolvedId,+              tabId,+              _warning: `FULL RESOLUTION (${meta.width}x${meta.height}px) — do NOT pass this image to Claude via the Read tool. Images >2000px crash many-image sessions at the Claude API limit. Use browser_screenshot instead for AI-readable screenshots (auto-resized to ≤1568px). This command is for saving to disk / wiki / export only.`,+            }),+          });+          return;+        }+        case 'browser_eval': {+          if (!args.expr) {+            sendJSON(res, { success: false, error: 'Missing required parameter "expr". Usage: browser_eval { "sessionId": "...", "expr": "document.title" }' });+            return;+          }+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          let result;+          try {+            result = await withTimeout(+              tab.page.evaluate(new Function('return (' + args.expr + ')')),+              20_000, 'page.evaluate'+            );+          } catch (e) {+            result = { __error: e.message };+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, result, sessionId: resolvedId, tabId }) });+          return;+        }+        case 'browser_input_dispatch': {+          // Trusted input via puppeteer's CDP-backed page.mouse / page.keyboard.+          // Events fired here have isTrusted=true (Chromium dispatches them at the+          // browser level via Input.dispatchMouseEvent / dispatchKeyEvent), unlike+          // page-side dispatchEvent(new MouseEvent(...)) which is isTrusted=false+          // and silently no-ops on framework click handlers / anti-automation gates.+          //+          // Types:+          //   click  — { x, y } coords, OR { selector } resolves bounding rect+          //   move   — { x, y } mouse move (useful for human-like trajectories)+          //   type   — { text } types into the focused element+          //   key    — { key } presses a single key (Enter, Escape, Tab, ArrowUp, etc.)+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }++          const t = (args.type || '').toLowerCase();+          if (!t) {+            sendJSON(res, { success: false, error: 'browser_input_dispatch: "type" required (click|move|type|key)' });+            return;+          }+          try {+            if (t === 'click') {+              if (args.selector) {+                // SMART SELECTOR PICK (v1.4.8+):+                //+                // Puppeteer's page.click(selector) blindly takes the first+                // document-order match. That bit users on WAGO when+                // `button.wg-button--primary` matched 4 elements (a 54x54+                // cookie-banner X with no text, two visible Add-to-cart+                // buttons, the actual Generate-datasheet button) — and the+                // cookie X got clicked. The relay returned ok:true because+                // a trusted click DID dispatch — just on the wrong element.+                //+                // We now run all matches through page.evaluate() to:+                //   1. Filter out hidden elements (display:none, opacity:0,+                //      offscreen, zero-area, behind the topmost stacking+                //      context modal)+                //   2. Filter out elements with empty text content (modal+                //      close buttons / icon-only utility buttons usually+                //      aren't what the caller wanted when they passed a+                //      class selector)+                //   3. Tie-break by visual area (largest wins — the actual+                //      submit button beats a small icon button)+                //+                // The chosen element's index, rect, and visible text are+                // returned in the response so the caller can sanity-check.+                // Pass `firstMatch:true` to skip filtering and use legacy+                // first-document-order behavior.+                const matches = await tab.page.$$(args.selector);+                if (matches.length === 0) {+                  sendJSON(res, { success: false, error: `selector not found: ${args.selector}`, _hint: 'Selector matches no element. Verify the page is loaded (browser_wait) and the selector is correct (try with browser_eval first).' });+                  return;+                }++                let chosenHandle = matches[0];+                let chosenIndex = 0;+                let chosenMeta = null;+                // v1.4.10: surface which DOM element won the modal-root+                // detection in the response, so callers can debug false-+                // positives. Stays null in the single-match / firstMatch+                // branches since modal scoping doesn't apply there.+                let modalRootInfo = null;++                if (matches.length === 1 || args.firstMatch === true) {+                  // Single match OR explicit opt-out — use document-order first.+                  chosenMeta = await tab.page.evaluate((el) => {+                    if (!el) return null;+                    const r = el.getBoundingClientRect();+                    return {+                      x: Math.round(r.x), y: Math.round(r.y),+                      w: Math.round(r.width), h: Math.round(r.height),+                      text: (el.textContent || '').trim().slice(0, 80),+                    };+                  }, chosenHandle);+                } else {+                  // Multiple matches — score them and pick the best.+                  //+                  // v1.4.9+: when an open modal/dialog is on screen, restrict+                  // the candidate set to elements INSIDE it. The page behind+                  // a modal is visually inert; clicking elements behind a+                  // modal is almost never what the user intended. WAGO bit+                  // on this: their "Add to shopping cart" button (behind the+                  // modal) was 9648 px², the modal's "Generate data sheet"+                  // was 9264 px² — area-only tiebreak picked add-to-cart.+                  // Now we detect the modal root first and only look inside.+                  const evalResult = await tab.page.evaluate((selector) => {+                    // v1.4.10: A modal-root candidate is only "plausible" if it+                    // contains at least one visible interactive element with+                    // non-empty text. Without this filter, empty overlay+                    // containers (Vue-Toastification's 4 toast wrappers,+                    // notification mount points, full-screen ad scrims, etc.)+                    // win the heuristic — they're fixed/absolute, z ≥ 100, cover+                    // >25% of viewport — but contain no interactive content.+                    // Result before the fix: smart-pick scoped to the wrong+                    // root and missed the actual modal entirely (WAGO bug:+                    // pickStrategy ended up "visible-text-largest-no-modal-match"+                    // and clicked "Add to shopping cart" in the page behind+                    // the real Generate-Datasheet modal).+                    function isPlausibleModal(el) {+                      if (!el) return false;+                      const interactives = el.querySelectorAll(+                        'button, a, input, select, textarea, [role="button"], [role="link"], [role="menuitem"], [tabindex], [onclick]'+                      );+                      for (const child of interactives) {+                        const cs = getComputedStyle(child);+                        if (cs.display === 'none' || cs.visibility === 'hidden') continue;+                        if (parseFloat(cs.opacity || '1') === 0) continue;+                        const r = child.getBoundingClientRect();+                        if (r.width === 0 || r.height === 0) continue;+                        const text = (child.textContent || '').trim();+                        if (text.length > 0) return true;+                      }+                      return false;+                    }++                    // Find the topmost modal/dialog root if one is open.+                    // Order matches what a human visually sees:+                    //   1. <dialog open>                    (HTML5 native)+                    //   2. [aria-modal="true"]              (ARIA)+                    //   3. role="dialog" + visible          (ARIA fallback)+                    //   4. fixed/absolute element with z-index ≥ 100+                    //      AND covering ≥25% of viewport    (custom modals)+                    //+                    // v1.4.10: every tier is now post-filtered through+                    // isPlausibleModal so we don't latch onto empty overlays.+                    // Each tier returns the most-recently-added (last in+                    // document order) plausible candidate, walking backward+                    // through matches if the topmost is implausible.+                    function topmostModalRoot() {+                      const openDialogs = Array.from(document.querySelectorAll('dialog[open]'));+                      for (let i = openDialogs.length - 1; i >= 0; i--) {+                        if (isPlausibleModal(openDialogs[i])) return openDialogs[i];+                      }+                      const ariaModals = Array.from(document.querySelectorAll('[aria-modal="true"]'))+                        .filter(el => {+                          const cs = getComputedStyle(el);+                          return cs.display !== 'none' && cs.visibility !== 'hidden';+                        });+                      for (let i = ariaModals.length - 1; i >= 0; i--) {+                        if (isPlausibleModal(ariaModals[i])) return ariaModals[i];+                      }+                      const dialogs = Array.from(document.querySelectorAll('[role="dialog"]'))+                        .filter(el => {+                          const cs = getComputedStyle(el);+                          if (cs.display === 'none' || cs.visibility === 'hidden') return false;+                          const r = el.getBoundingClientRect();+                          return r.width > 0 && r.height > 0;+                        });+                      for (let i = dialogs.length - 1; i >= 0; i--) {+                        if (isPlausibleModal(dialogs[i])) return dialogs[i];+                      }+                      // Custom modals (no ARIA, just a styled overlay)+                      const candidates = Array.from(document.querySelectorAll('*'))+                        .filter(el => {+                          const cs = getComputedStyle(el);+                          if (!['fixed', 'absolute'].includes(cs.position)) return false;+                          const z = parseInt(cs.zIndex || '0', 10);+                          if (isNaN(z) || z < 100) return false;+                          const r = el.getBoundingClientRect();+                          const viewportArea = window.innerWidth * window.innerHeight;+                          if (viewportArea === 0) return false;+                          return (r.width * r.height) / viewportArea > 0.25;+                        })+                        .sort((a, b) => parseInt(getComputedStyle(b).zIndex || '0') - parseInt(getComputedStyle(a).zIndex || '0'));+                      for (const c of candidates) {+                        if (isPlausibleModal(c)) return c;+                      }+                      return null;+                    }+                    const modalRoot = topmostModalRoot();+                    const modalRootInfo = modalRoot ? {+                      tag: modalRoot.tagName.toLowerCase(),+                      id: modalRoot.id || null,+                      cls: ((modalRoot.className && typeof modalRoot.className === 'string')+                        ? modalRoot.className+                        : ((modalRoot.className && modalRoot.className.baseVal) || '')).slice(0, 80),+                      z: parseInt(getComputedStyle(modalRoot).zIndex || '0', 10),+                      role: modalRoot.getAttribute('role') || null,+                      ariaModal: modalRoot.getAttribute('aria-modal') || null,+                    } : null;++                    const els = Array.from(document.querySelectorAll(selector));+                    const scored = els.map((el, idx) => {+                      const r = el.getBoundingClientRect();+                      const cs = getComputedStyle(el);+                      const visible =+                        cs.display !== 'none' &&+                        cs.visibility !== 'hidden' &&+                        parseFloat(cs.opacity || '1') > 0 &&+                        r.width > 0 && r.height > 0 &&+                        r.bottom > 0 && r.right > 0 &&+                        r.top < (window.innerHeight || 99999) &&+                        r.left < (window.innerWidth || 99999);+                      const text = (el.textContent || '').trim();+                      const area = r.width * r.height;+                      const insideModal = modalRoot != null && modalRoot.contains(el);+                      return {+                        idx,+                        x: Math.round(r.x), y: Math.round(r.y),+                        w: Math.round(r.width), h: Math.round(r.height),+                        text: text.slice(0, 80),+                        hasText: text.length > 0,+                        visible,+                        area,+                        insideModal,+                        modalDetected: modalRoot != null,+                      };+                    });+                    return { scored, modalRoot: modalRootInfo };+                  }, args.selector);+                  const scored = evalResult.scored;+                  modalRootInfo = evalResult.modalRoot;++                  // Pick priority:+                  //   1. inside-modal AND visible AND has-text → largest area+                  //   2. visible AND has-text                  → largest area+                  //   3. visible (any)                         → largest area+                  //   4. document-order first match (last resort)+                  const modalDetected = scored.length > 0 && scored[0].modalDetected;+                  const inModal = scored.filter(s => s.insideModal && s.visible && s.hasText);+                  const visibleTexted = scored.filter(s => s.visible && s.hasText);+                  const visibleOnly = scored.filter(s => s.visible);+                  let pick = null;+                  let strategy = 'visible-text-largest';+                  if (inModal.length > 0) {+                    pick = inModal.reduce((best, cur) => cur.area > best.area ? cur : best, inModal[0]);+                    strategy = 'modal-scoped-largest';+                  } else if (visibleTexted.length > 0) {+                    pick = visibleTexted.reduce((best, cur) => cur.area > best.area ? cur : best, visibleTexted[0]);+                    strategy = modalDetected ? 'visible-text-largest-no-modal-match' : 'visible-text-largest';+                  } else if (visibleOnly.length > 0) {+                    pick = visibleOnly.reduce((best, cur) => cur.area > best.area ? cur : best, visibleOnly[0]);+                    strategy = 'visible-largest';+                  }+                  if (pick) {+                    chosenIndex = pick.idx;+                    chosenHandle = matches[chosenIndex];+                    chosenMeta = pick;+                    chosenMeta._strategy = strategy;+                  } else {+                    // Nothing visible at all — keep document-order first as last resort+                    // and surface the count so caller knows ambiguity exists.+                    chosenMeta = scored[0];+                    if (chosenMeta) chosenMeta._strategy = 'fallback-first-document-order';+                  }+                }++                // page.click does scrollIntoView + bounding-rect resolution + page.mouse.click.+                // Chromium dispatches via Input.dispatchMouseEvent → isTrusted=true.+                await chosenHandle.click({+                  button: args.button || 'left',+                  clickCount: args.clickCount || 1,+                  delay: args.delay || 0,+                });+                sendJSON(res, { success: true, output: JSON.stringify({+                  ok: true,+                  sessionId: resolvedId,+                  tabId,+                  dispatched: 'click',+                  via: 'selector',+                  selector: args.selector,+                  matchedCount: matches.length,+                  chosenIndex,+                  clickedRect: chosenMeta ? { x: chosenMeta.x, y: chosenMeta.y, w: chosenMeta.w, h: chosenMeta.h } : null,+                  clickedText: chosenMeta ? chosenMeta.text : null,+                  insideModal: chosenMeta && typeof chosenMeta.insideModal === 'boolean' ? chosenMeta.insideModal : null,+                  modalDetected: chosenMeta && typeof chosenMeta.modalDetected === 'boolean' ? chosenMeta.modalDetected : null,+                  // v1.4.10: surface which DOM element won the modal-root+                  // detection so callers can debug false-positives. When the+                  // smart pick lands on the wrong element, this tells you+                  // immediately whether modal detection latched onto the+                  // intended overlay or got fooled by a toast-container etc.+                  modalRoot: modalRootInfo,+                  pickStrategy: matches.length === 1+                    ? 'only-match'+                    : (args.firstMatch === true+                      ? 'first-match'+                      : (chosenMeta && chosenMeta._strategy ? chosenMeta._strategy : 'visible-text-largest')),+                })});+                return;+              }+              if (typeof args.x !== 'number' || typeof args.y !== 'number') {+                sendJSON(res, { success: false, error: 'browser_input_dispatch click: provide either {selector} OR {x, y}' });+                return;+              }+              await tab.page.mouse.click(args.x, args.y, {+                button: args.button || 'left',+                clickCount: args.clickCount || 1,+                delay: args.delay || 0,+              });+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'click', via: 'coords', x: args.x, y: args.y }) });+              return;+            }+            if (t === 'move') {+              if (typeof args.x !== 'number' || typeof args.y !== 'number') {+                sendJSON(res, { success: false, error: 'browser_input_dispatch move requires {x, y}' });+                return;+              }+              await tab.page.mouse.move(args.x, args.y, { steps: args.steps || 1 });+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'move', x: args.x, y: args.y }) });+              return;+            }+            if (t === 'type') {+              if (typeof args.text !== 'string') {+                sendJSON(res, { success: false, error: 'browser_input_dispatch type requires {text}' });+                return;+              }+              // Optionally focus a selector first so keystrokes land in the right input.+              if (args.selector) {+                try { await tab.page.focus(args.selector); }+                catch (e) {+                  sendJSON(res, { success: false, error: `Failed to focus ${args.selector}: ${e.message}` });+                  return;+                }+              }+              await tab.page.keyboard.type(args.text, { delay: args.delay || 0 });+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'type', length: args.text.length }) });+              return;+            }+            if (t === 'key') {+              if (typeof args.key !== 'string') {+                sendJSON(res, { success: false, error: 'browser_input_dispatch key requires {key} (Enter|Escape|Tab|ArrowUp|...)' });+                return;+              }+              await tab.page.keyboard.press(args.key, { delay: args.delay || 0 });+              sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId, dispatched: 'key', key: args.key }) });+              return;+            }+            sendJSON(res, { success: false, error: `browser_input_dispatch: unknown type "${args.type}". Supported: click | move | type | key` });+          } catch (e) {+            sendJSON(res, { success: false, error: `browser_input_dispatch ${t} failed: ${e.message}` });+          }+          return;+        }+        case 'browser_fetch_url': {+          // Fetch an arbitrary URL with the session's cookies and return the+          // raw bytes (base64 in JSON; CLI optionally writes to disk).+          //+          // Why this exists: when a vendor click spawns a popup tab whose+          // Content-Type is application/pdf, Chrome wraps it in the built-in+          // PDF viewer. In-page `fetch(location.href)` then returns the+          // viewer's HTML wrapper (~200 KB stub), NOT the PDF binary.+          // Bypass: re-issue the request via puppeteer's+          // BrowserContext.request — same cookies, no rendering layer, raw+          // response body. Works for any content type; particularly useful+          // for PDFs, ZIPs, CAD bundles, anything the page would otherwise+          // open in a viewer plugin.+          //+          // saveTo semantics (clarified in v1.4.9 — v1.4.8 was ambiguous):+          //   - This is the BRIDGE side (Windows desktop). The bridge ALWAYS+          //     returns bodyBase64. If the user wants the file on the Docker+          //     side (the common case), the CLI handles the write — see+          //     cli/src/commands.rs::browser_fetch_url. The arg name there+          //     is `saveTo` and it's interpreted as a Docker-side path.+          //   - For the rare case of writing directly on the DESKTOP+          //     filesystem (e.g. dropping a CAD bundle into the user's+          //     Downloads folder so they can open it locally), the CLI+          //     forwards `desktopSaveTo` here, which the bridge writes via+          //     fs.writeFileSync. Returns `desktopSavedTo` field so it's+          //     unambiguous which filesystem the path refers to.+          //+          // Args (bridge-facing):+          //   sessionId       (required)+          //   url             (required)+          //   method          (optional, default GET)+          //   headers         (optional, object — Cookie auto-included from session)+          //   body            (optional, raw string body for POST etc.)+          //   desktopSaveTo   (optional Windows path — write on desktop and+          //                   return desktopSavedTo. Always also returns+          //                   bodyBase64 so caller can verify or save+          //                   container-side too.)+          //   tabId           (optional — picks the cookie context; defaults+          //                   to active tab. Cookies are per-browser-context+          //                   so any tab in the session works.)+          if (!args.url) {+            sendJSON(res, { success: false, error: 'browser_fetch_url requires {url}' });+            return;+          }+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }++          // Helper: optionally write to the desktop filesystem; ALWAYS return+          // bodyBase64 so the CLI can either pass it through to the caller OR+          // base64-decode it and write to a Docker-side path.+          const respondWithBytes = (buf, ct, status) => {+            const out = {+              ok: true,+              bytes: buf.length,+              contentType: ct,+              status,+              sessionId: resolvedId,+              tabId,+              bodyBase64: buf.toString('base64'),+            };+            if (args.desktopSaveTo) {+              try {+                // Resolve the path explicitly so callers can see what was used.+                // Don't silently land at a quirky CWD-relative path like the+                // v1.4.8 saveTo bug did.+                const abs = path.resolve(args.desktopSaveTo);+                const dir = path.dirname(abs);+                fs.mkdirSync(dir, { recursive: true });+                fs.writeFileSync(abs, buf);+                out.desktopSavedTo = abs;+              } catch (e) {+                // Don't lose the bytes — return them in bodyBase64 plus an+                // explicit error so the CLI knows the desktop write failed+                // but the bytes are still recoverable container-side.+                out.desktopSaveError = `desktopSaveTo write to ${args.desktopSaveTo} failed: ${e.message}`;+                out.desktopSavedTo = null;+              }+            }+            sendJSON(res, { success: true, output: JSON.stringify(out) });+          };++          try {+            const browserCtx = tab.page.browserContext();+            const reqClient = browserCtx.request;+            if (!reqClient || typeof reqClient.fetch !== 'function') {+              // Fallback for older puppeteer: do an in-page fetch in a+              // separate clean tab (avoids PDF viewer interception by+              // navigating to about:blank first, then fetching).+              const cleanPage = await browserCtx.newPage();+              try {+                await cleanPage.goto('about:blank');+                const result = await cleanPage.evaluate(async (u, m, h, b) => {+                  const init = { method: m, credentials: 'include', headers: h || {} };+                  if (b != null) init.body = b;+                  const r = await fetch(u, init);+                  const ab = await r.arrayBuffer();+                  const u8 = new Uint8Array(ab);+                  // Convert in chunks — avoids stack overflow on large blobs+                  let bin = '';+                  const CHUNK = 32768;+                  for (let i = 0; i < u8.length; i += CHUNK) {+                    bin += String.fromCharCode.apply(null, u8.subarray(i, i + CHUNK));+                  }+                  return {+                    base64: btoa(bin),+                    size: u8.length,+                    contentType: r.headers.get('content-type') || '',+                    status: r.status,+                  };+                }, args.url, args.method || 'GET', args.headers || null, args.body || null);+                await cleanPage.close({ runBeforeUnload: false });+                respondWithBytes(Buffer.from(result.base64, 'base64'), result.contentType, result.status);+                return;+              } catch (e) {+                try { await cleanPage.close({ runBeforeUnload: false }); } catch {}+                throw e;+              }+            }++            // Modern puppeteer: BrowserContext.request.fetch — bypasses page+            // rendering entirely. Cookies from the context are auto-included.+            const init = {+              method: args.method || 'GET',+              headers: args.headers || {},+            };+            if (args.body != null) init.data = args.body;+            const resp = await reqClient.fetch(args.url, init);+            const buf = await resp.body();+            const ct = (resp.headers && resp.headers()['content-type']) || '';+            respondWithBytes(buf, ct, resp.status());+          } catch (e) {+            sendJSON(res, { success: false, error: `browser_fetch_url failed: ${e.message}`, _hint: 'If this URL requires the popup tab\'s session cookies, make sure that tab is in the same session and pass its tabId here. The bridge always returns bodyBase64; the CLI handles container-side writes via the saveTo arg.' });+          }+          return;+        }+        case 'browser_errors': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          const errs = [...tab.errors];+          if (args.clear !== false) tab.errors.length = 0;+          sendJSON(res, { success: true, output: JSON.stringify({ errors: errs, count: errs.length, sessionId: resolvedId, tabId }) });+          return;+        }+        case 'browser_reload': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          tab.errors.length = 0;+          await withTimeout(+            tab.page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }),+            30_000, 'page.reload'+          );+          // Flash taskbar to signal reload to the user+          try { await tab.page.bringToFront(); } catch {}+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, sessionId: resolvedId, tabId }) });+          return;+        }+        case 'browser_navigate': {+          let session, resolvedId;+          // v1.6.1+: auto-recover (reconnect or relaunch) before surfacing+          // any error. The Docker container should never see "Session+          // closed" / "detached Frame" — those become internal retry+          // signals. See recoverOrRelaunchSession.+          try { ({ session, resolvedId } = await recoverOrRelaunchSession(args)); }+          catch (e) { sendSessionResolveError(res, e); return; }+          let tab, tabId;+          try { ({ tab, tabId } = resolveTabOrError(session, resolvedId, args)); }+          catch (e) { sendTabResolveError(res, e); return; }+          // Apply stored basic-auth creds for the navigation target's host+          // BEFORE goto, so the Chrome native auth dialog never appears+          // for sites in the credential vault.+          try {+            const matchedHost = await credentialVault.applyCredentialsToPage(tab.page, args.url);+            if (matchedHost) console.log(`[creds] applied to navigate ${resolvedId}/${tabId}: matched ${matchedHost}`);+          } catch (e) {+            console.log(`[creds] applyCredentialsToPage on navigate failed: ${e.message}`);+          }+          await withTimeout(+            tab.page.goto(args.url, { waitUntil: 'domcontentloaded', timeout: 25000 }),+            30_000, 'page.goto'+          );+          tab.errors.length = 0;+          // Re-apply title suffix after navigation+          try {+            const pName = session.profileName;+            // When there's more than one tab, include tabId in the title suffix+            const titleSuffix = session.tabs.length > 1+              ? ` (session: ${resolvedId} | ${tabId})`+              : (pName !== resolvedId+                  ? ` (session: ${resolvedId} | profile: ${pName})`+                  : ` (session: ${resolvedId})`);+            await tab.page.evaluate((suffix) => {+              document.title = document.title + suffix;+              const obs = new MutationObserver(() => {+                if (!document.title.endsWith(suffix)) document.title = document.title + suffix;+              });+              obs.observe(document.querySelector('title') || document.head, { childList: true, subtree: true, characterData: true });+            }, titleSuffix);+          } catch {}+          // Flash taskbar to signal navigation to the user+          try { await tab.page.bringToFront(); } catch {}+          // Only persist URL for single-tab sessions — the session file is a+          // per-session snapshot and multi-tab URLs can't fit.+          if (session.tabs.length === 1) {+            saveSessionFile(resolvedId, session.profileName, args.url);+          }+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, url: args.url, sessionId: resolvedId, tabId }) });+          return;+        }+        case 'browser_readiness': {+          // Cold-start readiness probe: is Chrome for Testing installed / installing?+          // The AI polls this on a fresh PC before/after browser_open_window.+          const rd = chrome.readiness();+          sendJSON(res, {+            success: true,+            output: JSON.stringify(rd),+            ...rd,+            _hint: rd.ready+              ? 'Ready — Chrome for Testing is installed and pup can open windows. Proceed with browser_open_window.'+              : rd.installing+                ? `Chrome for Testing is installing (${rd.installProgressPct}%). Poll browser_readiness until ready:true (usually 1–3 min), then browser_open_window.`+                : rd.installPhase === 'failed'+                  ? `Chrome for Testing install FAILED: ${rd.lastError}. Retry browser_prewarm; if it persists, surface this to the user (usually network/proxy blocking the download, or low disk).`+                  : 'Chrome for Testing is not installed yet. Call browser_prewarm (or just browser_open_window — it self-installs) to fetch it.',+          });+          return;+        }+        case 'browser_prewarm': {+          // Install Chrome for Testing WITHOUT opening a window — the warm-before-+          // first-use path. Default non-blocking so AD/HD can fire-and-forget on+          // embedded first-run; pass {wait:true} to block until installed.+          const block = !!(args && (args.wait === true || args.block === true));+          await chrome.ensureChromeReady({ background: !block });+          const rd = chrome.readiness();+          sendJSON(res, {+            success: rd.installPhase !== 'failed',+            output: JSON.stringify(rd),+            ...rd,+            statusVerb: 'browser_readiness',+            _hint: rd.ready+              ? 'Chrome for Testing is ready — pup can open windows instantly now.'+              : rd.installing+                ? `Warming up: Chrome for Testing downloading (${rd.installProgressPct}%). Poll browser_readiness until ready:true. No window is opened by prewarm.`+                : rd.installPhase === 'failed'+                  ? `Prewarm FAILED: ${rd.lastError}. Usually network/proxy blocking storage.googleapis.com or low disk.`+                  : 'Prewarm started; poll browser_readiness for completion.',+          });+          return;+        }+        case 'browser_status': {+          const list = [];+          for (const [id, s] of sessions) {+            list.push(await getSessionInfo(id, s));+          }+          const statusResult = {+            ok: true,+            sessionCount: sessions.size,+            activeSession: activeSessionId,+            sessions: list,+            pupshotUrl,+          };+          // Add guidance when no sessions exist so the AI knows what to do next+          if (sessions.size === 0) {+            statusResult.hint = 'No browser sessions are open. Use browser_open_window with sessionId, profile, and url to launch a Chrome window on the desktop before using screenshot/eval/navigate.';+          }+          sendJSON(res, { success: true, output: JSON.stringify(statusResult) });+          return;+        }+        case 'browser_close': {+          // With sessionId: close just that session. Without: close everything.+          // Per-session close mirrors browser_close_window's logic — kills the+          // Chrome process for that profile if no other sessions share it.+          if (args.sessionId) {+            const target = sessions.get(args.sessionId);+            if (!target) {+              sendJSON(res, { success: false, error: `Session "${args.sessionId}" not found. Active sessions: ${[...sessions.keys()].join(', ') || '(none)'}.` });+              return;+            }+            const profileName = target.profileName;+            // Are there other sessions on this profile? If yes, only close+            // this session's tab (Chrome stays alive for the others). If no,+            // kill the Chrome process so the profile is fully released and+            // the next browser_open_window starts from a clean slate.+            // (Session map key IS the sessionId; iterate entries to compare.)+            const otherSessionsOnProfile = [...sessions.entries()].filter(+              ([id, s]) => id !== args.sessionId && s.profileName === profileName+            );+            try {+              if (otherSessionsOnProfile.length === 0) {+                // Only this session uses this Chrome — kill the whole browser.+                const be = browsers.get(profileName);+                if (be) {+                  await killBrowserProcess(be.browser, profileName);+                  browsers.delete(profileName);+                }+              } else {+                // Other sessions still using this Chrome — just close this tab.+                if (target.page) {+                  try { await target.page.close({ runBeforeUnload: false }); } catch {}+                }+              }+            } catch (e) {+              console.log(`browser_close (per-session) error: ${e.message}`);+            }+            sessions.delete(args.sessionId);+            deleteSessionFile(args.sessionId);+            if (activeSessionId === args.sessionId) {+              activeSessionId = sessions.size > 0 ? [...sessions.keys()][0] : null;+            }+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true,+              sessionId: args.sessionId,+              message: `Session "${args.sessionId}" closed`,+              chromeKilled: otherSessionsOnProfile.length === 0,+              remainingSessions: sessions.size,+            })});+            return;+          }++          // No sessionId — close everything (legacy behavior)+          for (const [pName, be] of browsers) {+            await killBrowserProcess(be.browser, pName);+          }+          browsers.clear();+          for (const [id] of sessions) { deleteSessionFile(id); }+          sessions.clear();+          activeSessionId = null;+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true,+            message: 'All sessions and browsers closed',+            remainingSessions: 0,+          })});+          return;+        }+        case 'browser_wait': {+          const ms = args.ms || 3000;+          await new Promise(r => setTimeout(r, ms));+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, waited: ms }) });+          return;+        }++        // ─── Credential vault (HTTP Basic Auth for pup) ────────────────+        // Stored in the OS keychain (DPAPI on Windows, Keychain on macOS,+        // libsecret on Linux). Applied via puppeteer's page.authenticate()+        // before navigation so Chrome's native auth dialog never appears.+        case 'credential_set': {+          try {+            const r = await credentialVault.setCredential({+              host: args.host,+              username: args.username,+              password: args.password,+            });+            sendJSON(res, { success: true, output: JSON.stringify(r) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'credential_list': {+          try {+            const credentials = credentialVault.listCredentials();+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, credentials }) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'credential_delete': {+          try {+            const r = await credentialVault.deleteCredential(args.host);+            sendJSON(res, { success: true, output: JSON.stringify(r) });+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }++        // ─── Desktop recording (HUD) ───────────────────────────────────+        case 'desktop_recorder_open': {+          try {+            await ensureRecorderWindowOpen(args.reason);+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true, opened: true, reason: desktopRecorder.reason,+            })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'desktop_record_start': {+          try {+            const r = await desktopRecordStartImpl(args);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'desktop_record_stop': {+          try {+            const r = await desktopRecordStopImpl(args);+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'desktop_record_status': {+          const cur = desktopRecorder.current;+          const status = {+            ok: true,+            hudOpen: !!desktopRecorder.browser,+            reason: desktopRecorder.reason,+            currentRecording: cur ? {+              recordingId: cur.recordingId,+              durationMs: Date.now() - cur.startedAt,+              filePath: cur.filePath,+              fps: cur.fps,+              audio: cur.audio,+            } : null,+            clipsThisSession: desktopRecorder.clipsThisSession,+          };+          sendJSON(res, { success: true, output: JSON.stringify(status) });+          return;+        }+        case 'desktop_record_list': {+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, recordings: desktopRecordListImpl(),+          })});+          return;+        }+        case 'desktop_recorder_close': {+          try {+            const summary = await closeRecorderWindow();+            sendJSON(res, { success: true, output: JSON.stringify({+              ok: true, sessionSummary: summary,+            })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'desktop_list_monitors': {+          // Minimal v1 — return primary monitor only, with best-effort dimensions+          // (full multi-monitor enumeration is v1.1 via Tauri-side EnumDisplayMonitors)+          const monitors = [{+            index: 0, name: 'Primary',+            x: 0, y: 0,+            width: 1920, height: 1080, // best-effort fallback+            primary: true,+          }];+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, monitors })});+          return;+        }++        // ─── Tab recording (CDP screencast, no HUD) ─────────────────────+        case 'browser_record_start': {+          try {+            const sessionId = args.sessionId || activeSessionId;+            if (!sessionId) {+              sendJSON(res, { success: false, error: 'sessionId is required' });+              return;+            }+            const r = await tabRecordStartImpl({ ...args, sessionId });+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'browser_record_stop': {+          try {+            if (!args.recordingId) {+              sendJSON(res, { success: false, error: 'recordingId is required' });+              return;+            }+            const r = await tabRecordStopImpl({ sessionId: args.sessionId, recordingId: args.recordingId });+            sendJSON(res, { success: true, output: JSON.stringify({ ok: true, ...r })});+          } catch (e) {+            sendJSON(res, { success: false, error: e.message });+          }+          return;+        }+        case 'browser_record_status': {+          const active = tabRecordStatusImpl({ sessionId: args.sessionId });+          sendJSON(res, { success: true, output: JSON.stringify({ ok: true, active })});+          return;+        }+        case 'browser_record_list': {+          sendJSON(res, { success: true, output: JSON.stringify({+            ok: true, recordings: tabRecordListImpl(),+          })});+          return;+        }++        default:+          sendJSON(res, { success: false, error: `Unknown command: ${command}. Valid commands: browser_open_window, browser_switch_window, browser_list_windows, browser_close_window, browser_alert_window, browser_focus_window, browser_open_tab, browser_switch_tab, browser_close_tab, browser_list_tabs, browser_screenshot, browser_screenshot_full_res, browser_eval, browser_errors, browser_reload, browser_navigate, browser_status, browser_close, browser_wait, desktop_recorder_open, desktop_record_start, desktop_record_stop, desktop_record_status, desktop_record_list, desktop_recorder_close, desktop_list_monitors, browser_record_start, browser_record_stop, browser_record_status, browser_record_list. Tab-aware commands (screenshot, eval, errors, reload, navigate) accept an optional "tabId" to target a specific tab.` }, 404);+          return;+      }+    }++    // Legacy HTTP endpoints (for direct curl access) — operate on active session++    // POST /launch+    if (pathname === '/launch' && req.method === 'POST') {+      const body = JSON.parse(await readBody(req));+      if (body.pupshotUrl) pupshotUrl = body.pupshotUrl;+      const session = await launchSession(body.sessionId || 'default', body.url);+      session.errors.length = 0;+      sendJSON(res, { ok: true, url: body.url, sessionId: activeSessionId });+      return;+    }++    // POST /navigate+    if (pathname === '/navigate' && req.method === 'POST') {+      const body = JSON.parse(await readBody(req));+      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();+      await session.page.goto(body.url, { waitUntil: 'domcontentloaded', timeout: 30000 });+      session.errors.length = 0;+      sendJSON(res, { ok: true, url: body.url });+      return;+    }++    // POST /reload+    if (pathname === '/reload' && req.method === 'POST') {+      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();+      session.errors.length = 0;+      await session.page.reload({ waitUntil: 'domcontentloaded', timeout: 30000 });+      sendJSON(res, { ok: true });+      return;+    }++    // GET /screenshot+    if (pathname === '/screenshot') {+      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();+      const ts = Date.now();+      const fp = url.searchParams.get('fullPage') === 'true';+      let shotBuf = await session.page.screenshot({ type: 'png', fullPage: fp });+      // v1.8.40+: resize is mandatory (no guard, no fallback). See block at+      // top of file + browser_screenshot case for context.+      const origMeta = await sharp(shotBuf).metadata();+      const needsResize = origMeta.width > MAX_SCREENSHOT_DIM || origMeta.height > MAX_SCREENSHOT_DIM;+      const pipeline = needsResize+        ? sharp(shotBuf).resize({ width: MAX_SCREENSHOT_DIM, height: MAX_SCREENSHOT_DIM, fit: 'inside' })+        : sharp(shotBuf);+      shotBuf = await pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer();+      const finalMeta = await sharp(shotBuf).metadata();+      const filePath = path.join(SHOTS_DIR, `pup-${ts}.png`);+      fs.writeFileSync(filePath, shotBuf);+      const sizeKB = Math.round(shotBuf.length / 1024);+      session.latestShot = filePath;++      let container = null;+      if (pupshotUrl) {+        try {+          const buf = fs.readFileSync(filePath);+          const b64 = buf.toString('base64');+          const postBody = JSON.stringify({ image: 'data:image/png;base64,' + b64 });+          const resp = await fetch(pupshotUrl, {+            method: 'POST',+            headers: { 'Content-Type': 'application/json' },+            body: postBody,+          });+          container = await resp.json();+        } catch (e) {+          container = { error: e.message };+        }+      }++      sendJSON(res, {+        ok: true,+        sizeKB,+        filePath,+        width: finalMeta.width,+        height: finalMeta.height,+        origWidth: origMeta.width,+        origHeight: origMeta.height,+        resized: needsResize,+        container,+      });+      return;+    }++    // GET /screenshot.png (serves latest shot regardless of format)+    if (pathname === '/screenshot.png') {+      const session = getActiveSession();+      const shot = session?.latestShot;+      if (shot && fs.existsSync(shot)) {+        const buf = fs.readFileSync(shot);+        res.writeHead(200, { 'Content-Type': 'image/png', 'Access-Control-Allow-Origin': '*' });+        res.end(buf);+      } else {+        res.writeHead(404);+        res.end('No screenshot yet');+      }+      return;+    }++    // GET /errors+    if (pathname === '/errors') {+      const session = getActiveSession();+      const errs = session ? session.errors : [];+      sendJSON(res, { errors: errs, count: errs.length });+      return;+    }++    // POST /eval+    if (pathname === '/eval' && req.method === 'POST') {+      const body = JSON.parse(await readBody(req));+      const expr = String(body.expr || '');+      const session = (() => { const s = getActiveSession(); if (!s) throw new Error('No session. Use /launch first.'); return s; })();+      let result;+      try {+        result = await session.page.evaluate(new Function('return (' + expr + ')'));+      } catch (e) {+        result = { __error: e.message };+      }+      sendJSON(res, { ok: true, result });+      return;+    }++    // GET /wait?ms=N+    if (pathname === '/wait') {+      const ms = parseInt(url.searchParams.get('ms') || '3000');+      await new Promise(r => setTimeout(r, ms));+      sendJSON(res, { ok: true, waited: ms });+      return;+    }++    // GET /status+    if (pathname === '/status') {+      const list = [];+      for (const [id, s] of sessions) {+        let pageUrl = null;+        try { pageUrl = s.page.url(); } catch (e) {}+        list.push({ sessionId: id, url: pageUrl, active: id === activeSessionId });+      }+      // Status-chip contract (AD renders led/summary/tooltip verbatim). The chip+      // now TELLS THE TRUTH about cold-start: yellow while Chrome for Testing is+      // missing/installing, red if its install failed, green when pup is ready.+      const rd = chrome.readiness();+      let led = 'green', summary, tooltip;+      if (rd.installPhase === 'failed') {+        led = 'red'; summary = 'Chrome install failed';+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing install FAILED: ${rd.lastError}\nRetry browser_prewarm (usually a network/proxy or disk issue).`;+      } else if (rd.installing) {+        led = 'yellow'; summary = `Installing Chrome ${rd.installProgressPct}%`;+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nDownloading Chrome for Testing (${rd.installProgressPct}%) — pup not ready yet.\nPoll browser_readiness until ready.`;+      } else if (!rd.chromeForTestingInstalled) {+        led = 'yellow'; summary = 'Chrome not installed';+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION}\nChrome for Testing not installed yet — pup will fetch it on first use (or call browser_prewarm).`;+      } else {+        led = 'green'; summary = `${sessions.size} window${sessions.size === 1 ? '' : 's'}`;+        tooltip = `Puppeteer bridge v${BRIDGE_VERSION} — ready\nChrome for Testing ${rd.chromeBuildId || 'installed'} · ${sessions.size} session(s)`;+      }+      sendJSON(res, {+        ok: true,+        bridge: 'puppeteer',+        version: BRIDGE_VERSION,+        led, summary, tooltip,+        sessionCount: sessions.size,+        activeSession: activeSessionId,+        sessions: list,+        chrome: rd,+        pupshotUrl,+      });+      return;+    }++    // POST /close+    if (pathname === '/close' && req.method === 'POST') {+      for (const [, be] of browsers) {+        try { await be.browser.close(); } catch (e) {}+      }+      browsers.clear();+      sessions.clear();+      activeSessionId = null;+      sendJSON(res, { ok: true, message: 'All sessions closed' });+      return;+    }++    sendJSON(res, { error: 'Unknown endpoint: ' + pathname }, 404);+  } catch (e) {+    console.error('Handler error:', e);+    sendJSON(res, { error: e.message }, 500);+  }+});++// Global request timeout — no HTTP request should hang longer than 45s+server.timeout = 45_000;+server.keepAliveTimeout = 30_000;++server.listen(PORT, async () => {+  console.log(`Puppeteer Bridge running on http://localhost:${PORT}`);+  console.log('Multi-session support: sessions are tabs sharing Chrome profiles via userDataDir');+  console.log('Endpoints: /health, /command, /launch, /navigate, /reload, /screenshot, /screenshot.png, /errors, /eval, /wait, /status, /close');+  // Recover sessions from disk (reconnect to Chrome windows that survived bridge restart)+  try {+    await recoverSessions();+  } catch (e) {+    console.error('Session recovery failed:', e.message);+  }++  // Periodic cleanup: every 5 minutes, remove stale session files with dead PIDs+  setInterval(async () => {+    const files = listSessionFiles();+    for (const sf of files) {+      if (!sessions.has(sf.sessionId) && !isProcessAlive(sf.pid)) {+        console.log(`Cleanup: removing stale session file "${sf.sessionId}" (PID ${sf.pid} dead)`);+        deleteSessionFile(sf.sessionId);+      }+    }+  }, 5 * 60 * 1000);+});++// ════════════════════════════════════════════════════════════════════════════+//  RECORDING — desktop (HUD) and tab (CDP screencast)+// ════════════════════════════════════════════════════════════════════════════++const RECORDINGS_DIR = path.join(__dirname, 'recordings');+fs.mkdirSync(RECORDINGS_DIR, { recursive: true });+const RECORDER_PROFILE_DIR = path.join(PROFILES_DIR, '_recorder');++// ─── ffmpeg detection (used to add Duration tag to MediaRecorder WebMs) ──+// Probe at module-load so we know upfront whether the remux path works.+// Cached for the life of the process. Try common Windows install dirs in+// addition to PATH so a winget/scoop/choco install is found even if PATH+// hasn't been refreshed in the bridge's environment.+let FFMPEG_PATH = null;+(function detectFFmpeg() {+  const { spawnSync } = require('child_process');+  // 1. PATH+  try {+    const r = spawnSync('ffmpeg', ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+    if (r.status === 0) { FFMPEG_PATH = 'ffmpeg'; return; }+  } catch {}+  // 2. Common Windows install paths+  if (process.platform === 'win32') {+    const candidates = [+      'C:\\Program Files\\ffmpeg\\bin\\ffmpeg.exe',+      'C:\\ffmpeg\\bin\\ffmpeg.exe',+      'C:\\ProgramData\\chocolatey\\bin\\ffmpeg.exe',+      `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages\\Gyan.FFmpeg_Microsoft.Winget.Source_8wekyb3d8bbwe\\ffmpeg-8.0-full_build\\bin\\ffmpeg.exe`,+      `${process.env.USERPROFILE}\\scoop\\shims\\ffmpeg.exe`,+    ];+    for (const p of candidates) {+      try {+        if (fs.existsSync(p)) {+          const r = spawnSync(p, ['-version'], { stdio: ['ignore', 'pipe', 'ignore'] });+          if (r.status === 0) { FFMPEG_PATH = p; return; }+        }+      } catch {}+    }+    // 3. Glob the WinGet directory (version number changes between releases)+    try {+      const winget = `${process.env.LOCALAPPDATA}\\Microsoft\\WinGet\\Packages`;+      if (fs.existsSync(winget)) {+        for (const e of fs.readdirSync(winget)) {+          if (e.startsWith('Gyan.FFmpeg')) {+            const sub = path.join(winget, e);+            const walk = (dir) => {+              for (const f of fs.readdirSync(dir, { withFileTypes: true })) {+                if (f.isDirectory()) {+                  const r = walk(path.join(dir, f.name));+                  if (r) return r;+                } else if (f.name === 'ffmpeg.exe') {+                  return path.join(dir, f.name);+                }+              }+              return null;+            };+            const found = walk(sub);+            if (found) { FFMPEG_PATH = found; return; }+          }+        }+      }+    } catch {}+  }+})();+if (FFMPEG_PATH) console.log(`[recorder] ffmpeg available at: ${FFMPEG_PATH}`);+else console.warn(`[recorder] ffmpeg NOT FOUND — desktop WebMs will play fine but ffprobe format=duration will be N/A. Install via 'winget install Gyan.FFmpeg' to enable Duration metadata.`);++/**+ * Remux a WebM file in place to add a SegmentDuration tag.+ * Uses `ffmpeg -i in.webm -c copy out.webm` — copies the stream packets+ * unchanged (no re-encode), and the matroska muxer writes a complete+ * SegmentInfo with Duration. Fast (~100-300ms for typical clips).+ * Atomic: writes to a temp file then renames so a partial run can't+ * corrupt the original.+ */+async function remuxWebmAddDuration(filePath) {+  if (!FFMPEG_PATH) throw new Error('ffmpeg not found on PATH or in common Windows install dirs');+  const { spawn } = require('child_process');+  const tmpPath = filePath + '.remux.tmp';+  await new Promise((resolve, reject) => {+    const proc = spawn(FFMPEG_PATH, [+      '-y',                       // overwrite tmp if it exists+      '-loglevel', 'error',       // quiet but still surface errors+      '-i', filePath,+      '-c', 'copy',               // no re-encode — just rewrite container+      '-f', 'webm',+      tmpPath,+    ], { stdio: ['ignore', 'ignore', 'pipe'] });+    let stderr = '';+    proc.stderr.on('data', b => { stderr += b.toString(); });+    proc.on('error', reject);+    proc.on('exit', code => {+      if (code === 0) resolve();+      else reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));+    });+  });+  // Atomic replace: rename tmp → original. fs.renameSync replaces on Win10+.+  fs.renameSync(tmpPath, filePath);+}++// Desktop recorder (singleton; one HUD across the whole bridge)+const desktopRecorder = {+  browser: null,+  page: null,+  windowId: null,           // Chrome windowId for Browser.setWindowBounds+  cdp: null,                // CDP session to recorder page+  reason: null,+  hudOpenedAt: null,+  current: null,            // active recording state, see below+  clipsThisSession: [],     // [{recordingId, filePath, sizeKB, durationMs, reason}]+  recCounter: 0,+  idleTimer: null,+};++// Tab recordings (concurrent; many at once)+// Map<recordingId, {sessionId, tabId, dirPath, framesDir, concatStream, manifestFrames,+//                  cdp, started_at, frameCount, fps, quality, stopTimer, stopping}>+const tabRecordings = new Map();+let tabRecCounter = 0;++// ─── Desktop recorder helpers ───────────────────────────────────────────────++function recorderHtmlUrl(reason) {+  const file = path.join(__dirname, 'recorder.html').replace(/\\/g, '/');+  const params = new URLSearchParams({ reason: reason || '' }).toString();+  return `file:///${file}?${params}`;+}++async function ensureRecorderWindowOpen(reason) {+  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {+    throw new Error('reason is required — explain to the user why you\'re recording');+  }+  // If browser+page exist and are alive, just update the reason+  if (desktopRecorder.browser && desktopRecorder.browser.isConnected() && desktopRecorder.page) {+    try {+      await desktopRecorder.page.evaluate((r) => window.setSessionReason && window.setSessionReason(r), reason);+      desktopRecorder.reason = reason;+      return;+    } catch {+      // Page died; tear down and respawn+      try { await desktopRecorder.browser.close(); } catch {}+      desktopRecorder.browser = null;+      desktopRecorder.page = null;+      desktopRecorder.cdp = null;+    }+  }++  fs.mkdirSync(RECORDER_PROFILE_DIR, { recursive: true });++  // Single fixed size for the HUD — large enough that every state's content+  // fits without dynamic resizing. Position is set AFTER launch via CDP using+  // screen.availWidth/availHeight (taskbar-aware, DPI-aware) — Chrome's+  // --window-position arg uses physical pixels and doesn't account for the+  // taskbar, which made the HUD half off-screen on non-1920×1080 displays.+  const winW = 360, winH = 420;++  console.log(`[recorder] Spawning HUD window (reason="${reason}")`);++  const launchArgs = [+    `--app=${recorderHtmlUrl(reason)}`,+    `--window-size=${winW},${winH}`,+    // No --window-position; we anchor bottom-right after the page loads+    '--use-fake-ui-for-media-stream',+    '--auto-select-desktop-capture-source=Entire screen',+    '--enable-features=GetDisplayMediaSet',+    '--auto-accept-this-tab-capture',+    '--no-sandbox',+    '--no-first-run',+    '--no-default-browser-check',+    '--disable-features=Translate',+    `--user-data-dir=${RECORDER_PROFILE_DIR}`,+  ];++  desktopRecorder.browser = await puppeteer.launch({+    headless: false,+    defaultViewport: null,+    ignoreDefaultArgs: ['--enable-automation'],+    args: launchArgs,+  });++  // Find the recorder page (the only page in this Chrome)+  const deadline = Date.now() + 5000;+  while (Date.now() < deadline) {+    const pages = await desktopRecorder.browser.pages();+    if (pages.length > 0) {+      desktopRecorder.page = pages[0];+      break;+    }+    await new Promise(r => setTimeout(r, 50));+  }+  if (!desktopRecorder.page) throw new Error('Recorder window did not produce a page');++  await desktopRecorder.page.waitForFunction(() => typeof window.startRecording === 'function', { timeout: 10000 });++  // ── Bug 1 fix: anchor bottom-right via screen.availWidth/availHeight ──+  // Chrome's --window-position uses physical pixels and ignores the taskbar,+  // so a hardcoded fallback can land the HUD off-screen on any monitor that+  // isn't 1920×1080. Query the page's `screen.avail*` (DIPs, taskbar-aware)+  // and use CDP Browser.setWindowBounds to position it precisely.+  try {+    const cdp = await desktopRecorder.page.target().createCDPSession();+    desktopRecorder.cdp = cdp;+    const wInfo = await cdp.send('Browser.getWindowForTarget', {});+    desktopRecorder.windowId = wInfo.windowId;++    const dims = await desktopRecorder.page.evaluate(() => ({+      availLeft: screen.availLeft || 0,+      availTop: screen.availTop || 0,+      availWidth: screen.availWidth,+      availHeight: screen.availHeight,+      outerWidth: window.outerWidth,+      outerHeight: window.outerHeight,+    }));+    const margin = 16;+    const winW = Math.min(dims.outerWidth, dims.availWidth);+    const winH = Math.min(dims.outerHeight, dims.availHeight);+    const left = Math.max(dims.availLeft, dims.availLeft + dims.availWidth - winW - margin);+    const top = Math.max(dims.availTop, dims.availTop + dims.availHeight - winH - margin);+    await cdp.send('Browser.setWindowBounds', {+      windowId: wInfo.windowId,+      bounds: { left, top, width: winW, height: winH, windowState: 'normal' },+    });+    console.log(`[recorder] anchored bottom-right: (${left},${top}) ${winW}x${winH} (work area ${dims.availWidth}x${dims.availHeight})`);+  } catch (e) {+    console.log(`[recorder] anchor failed (non-fatal): ${e.message}`);+  }++  // Wire exposed functions+  await desktopRecorder.page.exposeFunction('_saveChunk', (b64) => {+    if (!desktopRecorder.current || !desktopRecorder.current.fd) return;+    try {+      fs.writeSync(desktopRecorder.current.fd, Buffer.from(b64, 'base64'));+    } catch (e) {+      console.error(`[recorder] saveChunk failed: ${e.message}`);+    }+  });++  await desktopRecorder.page.exposeFunction('_recordingStopped', async ({ sentBytes, durationMs }) => {+    const cur = desktopRecorder.current;+    if (!cur) return;+    try { fs.closeSync(cur.fd); } catch {}+    cur.fd = null;++    // ── Bug 3a fix: ffmpeg remux to write SegmentDuration ──+    // MediaRecorder writes a "live" WebM with no Duration element, so+    // ffprobe reports duration=N/A. Earlier attempts at byte-level EBML+    // patching corrupted the Tracks element / SeekHead offsets and made+    // the resulting files unplayable (nb_streams=0). Ditched.+    //+    // The reliable fix is `ffmpeg -i in.webm -c copy out.webm` — copies+    // streams (no re-encode, fast: ~50-200 MB/s), and the muxer writes+    // a proper SegmentInfo with Duration. We do this asynchronously so+    // the UI's "Saved" state appears immediately; the patched file is+    // ready by the time the user pulls it.+    //+    // If ffmpeg isn't on PATH we leave the file untouched. It's still a+    // valid (playable) WebM — just no Duration tag in the container.+    // Telemetry: log clearly so the user knows whether it ran.+    try {+      await remuxWebmAddDuration(cur.filePath);+      console.log(`[recorder] ffmpeg remux added Duration tag to ${cur.filePath}`);+    } catch (e) {+      console.error(`[recorder] ffmpeg remux failed (file still plays, ffprobe will report N/A): ${e.message}`);+    }++    let sizeKB = 0;+    try { sizeKB = Math.round(fs.statSync(cur.filePath).size / 1024); } catch {}+    cur.durationMs = durationMs;+    cur.sizeKB = sizeKB;+    cur.stoppedAt = Date.now();+    // Write sidecar metadata+    try {+      const sidecar = {+        recordingId: cur.recordingId,+        filePath: cur.filePath,+        startedAt: cur.startedAt,+        stoppedAt: cur.stoppedAt,+        durationMs,+        sizeKB,+        sizeBytes: sentBytes,+        fps: cur.fps,+        audio: cur.audio,+        monitor: cur.monitor,+        reason: cur.reason,+        stopReason: cur.stopReason || 'requested',+      };+      fs.writeFileSync(cur.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));+    } catch (e) {+      console.error(`[recorder] sidecar write failed: ${e.message}`);+    }+    // Add to session clip list+    desktopRecorder.clipsThisSession.push({+      recordingId: cur.recordingId, filePath: cur.filePath,+      sizeKB, durationMs, reason: cur.reason,+    });+    desktopRecorder.current = null;+    console.log(`[recorder] Clip ${cur.recordingId} stopped: ${sizeKB}KB, ${durationMs}ms`);+    // Tell page to show Saved state+    try {+      await desktopRecorder.page.evaluate((clip) => window.showSaved && window.showSaved(clip), {+        filename: path.basename(cur.filePath),+        sizeBytes: sentBytes,+        durationMs,+      });+    } catch {}+    // Resolve any waiter+    if (cur.stopResolver) cur.stopResolver();+  });++  await desktopRecorder.page.exposeFunction('_resizeWindow', async ({ width, height }) => {+    try {+      const cdp = desktopRecorder.cdp || (desktopRecorder.cdp = await desktopRecorder.page.target().createCDPSession());+      const target = desktopRecorder.page.target();+      const targetId = target._targetId || target.target?.()?._targetId || (await target.createCDPSession()).then(()=>{}); // best effort+      // Use Puppeteer's underlying CDP: simpler to use Browser.getWindowForTarget by sending raw CDP+      const { windowId } = await cdp.send('Browser.getWindowForTarget', {});+      desktopRecorder.windowId = windowId;+      await cdp.send('Browser.setWindowBounds', { windowId, bounds: { width, height } });+    } catch (e) {+      console.log(`[recorder] resize failed (non-fatal): ${e.message}`);+    }+  });++  await desktopRecorder.page.exposeFunction('_recorderClosed', () => {+    console.log(`[recorder] Page reports closing`);+  });++  await desktopRecorder.page.exposeFunction('_openRecordingsFolder', () => {+    try {+      execSync(`explorer "${RECORDINGS_DIR}"`, { stdio: 'ignore' });+    } catch {}+  });++  // Detect window-close as a stop signal+  desktopRecorder.browser.on('disconnected', () => {+    console.log(`[recorder] Browser disconnected`);+    if (desktopRecorder.current) {+      // Best-effort: mark stopped, write whatever we have+      try { if (desktopRecorder.current.fd) fs.closeSync(desktopRecorder.current.fd); } catch {}+      if (desktopRecorder.current.stopResolver) desktopRecorder.current.stopResolver();+      desktopRecorder.current = null;+    }+    desktopRecorder.browser = null;+    desktopRecorder.page = null;+    desktopRecorder.cdp = null;+    desktopRecorder.reason = null;+    desktopRecorder.clipsThisSession = [];+  });++  desktopRecorder.reason = reason;+  desktopRecorder.hudOpenedAt = Date.now();+  desktopRecorder.clipsThisSession = [];+}++async function closeRecorderWindow() {+  if (desktopRecorder.current) {+    // Stop active recording first+    try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}+    // Wait briefly for stop to flush+    const deadline = Date.now() + 3000;+    while (desktopRecorder.current && Date.now() < deadline) {+      await new Promise(r => setTimeout(r, 50));+    }+  }+  const summary = {+    clipCount: desktopRecorder.clipsThisSession.length,+    totalSizeKB: desktopRecorder.clipsThisSession.reduce((a, c) => a + (c.sizeKB || 0), 0),+    reason: desktopRecorder.reason,+    clips: desktopRecorder.clipsThisSession,+  };+  if (desktopRecorder.browser) {+    try { await desktopRecorder.browser.close(); } catch {}+  }+  desktopRecorder.browser = null;+  desktopRecorder.page = null;+  desktopRecorder.cdp = null;+  desktopRecorder.reason = null;+  desktopRecorder.current = null;+  desktopRecorder.clipsThisSession = [];+  return summary;+}++async function desktopRecordStartImpl(opts) {+  const { reason, monitor = 'primary', fps = 30, audio = false,+          videoBitsPerSecond = 4_000_000, maxDurationMs = 600_000,+          confirmDesktopNotTabRecording } = opts;++  if (!reason || typeof reason !== 'string' || reason.trim().length === 0) {+    throw new Error('reason is required — explain to the user why you\'re recording');+  }++  // Guardrail: Claude frequently mistakes "record this pup window/tab" for a+  // desktop-recording request. Desktop recording captures the WHOLE screen+  // (including unrelated apps in the foreground); if the pup window is+  // occluded the resulting clip won't show what the user actually wanted.+  // For 95% of "record a pup" asks the right verb is browser_record_start+  // (tab-scoped via CDP, captures regardless of foreground state, ~50fps).+  // Adom-desktop's full-desktop recording is mostly redundant with what+  // Hydrogen ships natively, and only really useful when Hydrogen is+  // already busy doing tab-recording for ralph-loop screenshot tests AND+  // the user wants a parallel desktop video at the same time.+  // Force the caller to explicitly opt in so they re-read this and confirm.+  if (confirmDesktopNotTabRecording !== true) {+    const err = new Error(+      'desktop_record_start records the WHOLE DESKTOP, not a pup tab. ' ++      'If the user said "record a pup" / "record this Chrome window" / "record the page", ' ++      'they almost certainly want browser_record_start instead — that is tab-scoped via ' ++      'CDP Page.startScreencast, captures the pup tab regardless of foreground state, ' ++      'returns a single .webm at ~50fps, and does not need a HUD. ' ++      'Hydrogen also has built-in desktop recording (recording start --share screen) which ' ++      'is usually preferable for general "record my screen" asks. ' ++      'adom-desktop\'s desktop_record_* is mainly for the case where Hydrogen is already ' ++      'doing tab recording (ralph-loop screenshot tests) and you want a parallel desktop ' ++      'video at the same time. ' ++      'If you have read this and you really do want full-desktop recording (not pup tab), ' ++      'pass confirmDesktopNotTabRecording: true and resend the call.'+    );+    err.code = 'desktop_record_needs_confirmation';+    err.hint = 'For pup tab recording: adom-desktop browser_record_start \'{"sessionId":"<id>","fps":30}\'. ' ++               'For Hydrogen-driven desktop recording: hydrogen recording start --share screen. ' ++               'For a parallel desktop clip alongside Hydrogen tab capture: re-call with confirmDesktopNotTabRecording: true.';+    throw err;+  }++  if (desktopRecorder.current && !desktopRecorder.current.stopping) {+    throw new Error(`A recording is already in progress (${desktopRecorder.current.recordingId}). Stop it first.`);+  }++  await ensureRecorderWindowOpen(reason);++  const recordingId = `rec-${++desktopRecorder.recCounter}`;+  const ts = Date.now();+  const filePath = path.join(RECORDINGS_DIR, `rec-desktop-${ts}.webm`);+  const fd = fs.openSync(filePath, 'w');++  desktopRecorder.current = {+    recordingId, filePath, fd,+    startedAt: ts, stoppedAt: null,+    durationMs: 0, sizeKB: 0,+    fps, audio, monitor, reason,+    stopping: false, stopResolver: null,+    stopReason: null,+  };++  // Safety cap+  desktopRecorder.current.maxDurationTimer = setTimeout(async () => {+    if (desktopRecorder.current && desktopRecorder.current.recordingId === recordingId) {+      desktopRecorder.current.stopReason = 'maxDuration';+      try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch {}+    }+  }, maxDurationMs);++  // Tell the page to start recording+  await desktopRecorder.page.evaluate((startOpts) => window.startRecording(startOpts), {+    fps, audio, videoBitsPerSecond, reason,+  });++  return {+    recordingId, filePath, startedAt: ts,+    monitor: { name: monitor }, reason,+  };+}++async function desktopRecordStopImpl({ recordingId } = {}) {+  if (!desktopRecorder.current) throw new Error('No active desktop recording');+  const cur = desktopRecorder.current;+  if (recordingId && cur.recordingId !== recordingId) {+    throw new Error(`Recording ${recordingId} not active (current is ${cur.recordingId})`);+  }+  if (cur.stopping) throw new Error('Already stopping');+  cur.stopping = true;+  if (cur.maxDurationTimer) clearTimeout(cur.maxDurationTimer);++  const stopWait = new Promise(r => { cur.stopResolver = r; });+  try { await desktopRecorder.page.evaluate(() => window.stopRecording && window.stopRecording()); } catch (e) {+    // If evaluate fails (e.g. page died), we still resolve below via disconnected handler+    console.log(`[recorder] page.stopRecording threw: ${e.message}`);+  }+  // Wait for _recordingStopped exposed function to fire (with timeout)+  await Promise.race([stopWait, new Promise(r => setTimeout(r, 10000))]);++  const stopReason = cur.stopReason || 'requested';+  return {+    recordingId: cur.recordingId,+    filePath: cur.filePath,+    sizeKB: cur.sizeKB,+    durationMs: cur.durationMs,+    stopReason,+  };+}++// ─── Tab recording (CDP screencast) ────────────────────────────────────────++// ── Tab recording via CDP Page.startScreencast → ffmpeg pipe ──────────────+//+// The previous incarnation of this code (v1.3.32-attempt) tried Chrome's+// in-tab MediaRecorder + getDisplayMedia({preferCurrentTab:true}). That+// path turned out to be unreliable for tab scoping: Chromium's+// --use-fake-ui-for-media-stream defaults to "Entire Screen" capture, and+// --auto-select-tab-capture-source-by-title scans across ALL Chrome+// processes (it picked the user's other Chrome windows instead of pup's).+// No flag combination reliably scoped the capture to the calling tab.+//+// CDP Page.startScreencast IS reliably tab-scoped at the protocol level —+// the CDP session is bound to a specific Page target, and screencastFrame+// only emits frames for that Page. We pipe those JPEG frames directly into+// ffmpeg's stdin (no per-frame disk write, no tar bundle). ffmpeg encodes+// VP9 in real time and produces a single .webm file. ffmpeg is already+// installed (FFMPEG_PATH probed at module load; same dep as the desktop+// recorder's duration-remux step).+//+// Caller note: Chrome throttles paint on occluded windows, so call+// browser_raise_os_window first if you want frames at the requested fps.++// Brief pause to let any in-flight Page.captureScreenshot settle before+// detaching the CDP session. Twice the frame interval is a safe upper bound+// for the longest a single capture can take.+function intervalSafeWait(fps) {+  return Math.min(200, Math.max(50, Math.round(2 * 1000 / fps)));+}++async function tabRecordStartImpl({+  sessionId, tabId,+  fps = 30,+  quality = 85,+  maxDurationMs = 600_000,+}) {+  if (!FFMPEG_PATH) {+    throw new Error('ffmpeg not found on Windows. Install via: winget install Gyan.FFmpeg');+  }++  const session = sessions.get(sessionId);+  if (!session) throw new Error(`Session "${sessionId}" not found`);+  const { tab } = resolveTabOrError(session, sessionId, { tabId });++  // One recording per tab.+  for (const r of tabRecordings.values()) {+    if (r.sessionId === sessionId && r.tabId === tab.tabId && !r.stopping) {+      throw new Error(`Tab ${tab.tabId} in session ${sessionId} is already being recorded (${r.recordingId})`);+    }+  }++  const recordingId = `rec-tab-${++tabRecCounter}`;+  const ts = Date.now();+  const filePath = path.join(+    RECORDINGS_DIR,+    `rec-tab-${sessionId}-${tab.tabId}-${ts}.webm`,+  );++  // Frames go to a temp dir during capture; ffmpeg muxes them to webm at+  // stop time via image2 demuxer with the f%06d.jpg pattern. This avoids+  // ffmpeg-stdin-streaming pitfalls (image2pipe and mjpeg both exit early+  // when input briefly stalls — observed in testing).+  const framesDir = path.join(RECORDINGS_DIR, `${path.basename(filePath, '.webm')}.frames`);+  fs.mkdirSync(framesDir, { recursive: true });++  const cdp = await tab.page.target().createCDPSession();++  const state = {+    recordingId, sessionId, tabId: tab.tabId,+    cdp, filePath, framesDir,+    fps, quality,+    startedAt: ts, stoppedAt: null,+    frameCount: 0, sizeBytes: 0,+    frameTimestamps: [], // wall-clock ms for each captured frame, used to mux with real timing+    stopping: false, stopTimer: null, stopReason: null,+    captureLoop: null,+  };+  tabRecordings.set(recordingId, state);++  // Bring tab to front so Chrome's compositor stays warm+  try { await tab.page.bringToFront(); } catch {}++  // ── CDP Page.startScreencast with frame ACK ─────────────────────────────+  // Chrome's compositor pushes JPEG frames as the page paints. Each frame+  // arrives with a sessionId that MUST be acknowledged via+  // Page.screencastFrameAck or Chrome stops sending after the first frame+  // (backpressure). Earlier impl (1.3.32) missed the ack — that's why we+  // saw "1 frame and stops" behavior, not paint-throttling. Tab-scoped at+  // the protocol level — the CDP session is bound to a specific Page target.+  //+  // everyNthFrame=1 means "every paint frame"; combined with the page's+  // own paint rate (60fps when foregrounded), this gives 60 fps capture+  // ceiling. Quality 60-85 keeps per-frame size manageable.+  state.frameHandler = async (event) => {+    if (state.stopping) return;+    const { data, sessionId: ackId } = event;+    const buf = Buffer.from(data, 'base64');+    const capturedAt = Date.now();+    state.frameCount += 1;+    state.sizeBytes += buf.length;+    state.frameTimestamps.push(capturedAt - state.startedAt);+    if (state.frameCount === 1) {+      console.log(`[${recordingId}] FIRST screencast frame (${buf.length} bytes)`);+    }+    const fname = `f${String(state.frameCount).padStart(6, '0')}.jpg`;+    try { fs.writeFileSync(path.join(state.framesDir, fname), buf); }+    catch (e) { console.error(`[${recordingId}] frame write failed: ${e.message}`); }+    // ACK so Chrome continues sending. Without this, only frame 1 arrives.+    try { await cdp.send('Page.screencastFrameAck', { sessionId: ackId }); }+    catch (e) {+      if (state.frameCount < 3) console.log(`[${recordingId}] ack error: ${e.message}`);+    }+  };+  cdp.on('Page.screencastFrame', state.frameHandler);++  await cdp.send('Page.startScreencast', {+    format: 'jpeg',+    quality,+    everyNthFrame: Math.max(1, Math.round(60 / fps)), // 1 -> 60fps, 2 -> 30fps, etc+  });++  console.log(`[${recordingId}] CDP screencast started @ target ${fps}fps (everyNthFrame=${Math.max(1, Math.round(60 / fps))}) → ${framesDir}`);++  state.stopTimer = setTimeout(() => {+    state.stopReason = 'maxDuration';+    tabRecordStopImpl({ sessionId, recordingId }).catch(() => {});+  }, maxDurationMs);++  return { recordingId, sessionId, tabId: tab.tabId, filePath, startedAt: ts };+}++async function tabRecordStopImpl({ sessionId, recordingId }) {+  const state = tabRecordings.get(recordingId);+  if (!state) throw new Error(`Recording ${recordingId} not found`);+  if (sessionId && state.sessionId !== sessionId) throw new Error(`Recording ${recordingId} belongs to a different session`);+  if (state.stopping) {+    const deadline = Date.now() + 5000;+    while (tabRecordings.has(recordingId) && Date.now() < deadline) await new Promise(r => setTimeout(r, 50));+    throw new Error(`Recording ${recordingId} already stopping`);+  }+  state.stopping = true;+  if (state.stopTimer) clearTimeout(state.stopTimer);++  // Stop screencast — Chrome stops emitting Page.screencastFrame events+  try { await state.cdp.send('Page.stopScreencast'); } catch {}+  // Detach the CDP frame handler so any late frames are dropped+  if (state.frameHandler) {+    try { state.cdp.off('Page.screencastFrame', state.frameHandler); } catch {}+    state.frameHandler = null;+  }+  // Brief settle window for in-flight frames already in the JS event loop+  await new Promise(r => setTimeout(r, intervalSafeWait(state.fps)));+  try { await state.cdp.detach(); } catch {}++  state.stoppedAt = Date.now();+  const durationMs = state.stoppedAt - state.startedAt;++  // ── Mux the frames dir to webm via ffmpeg concat demuxer ──────────────+  // Each captured frame has a wall-clock timestamp (state.frameTimestamps).+  // We emit a concat list with explicit per-frame durations so playback+  // matches real wall time even when actual capture rate fell below the+  // requested fps (Page.captureScreenshot is GPU-bound; on a busy machine+  // 30 fps requested often delivers ~10-15 fps actual). Using the concat+  // demuxer with per-frame durations produces a video that plays in+  // real-time regardless of capture-rate variance.+  if (state.frameCount > 0) {+    const { spawn } = require('child_process');+    const totalDurMs = (state.frameTimestamps[state.frameTimestamps.length - 1] || 0) + 1; // +1 to avoid zero+    const actualFps = state.frameCount / (totalDurMs / 1000);++    // Build concat list: each frame followed by `duration <seconds>`. The+    // last file entry must be repeated (concat demuxer quirk) so the final+    // frame is held for its proper duration instead of clipped.+    const concatLines = [];+    for (let i = 0; i < state.frameCount; i++) {+      const fname = `f${String(i + 1).padStart(6, '0')}.jpg`;+      concatLines.push(`file '${fname}'`);+      const next = state.frameTimestamps[i + 1];+      const cur = state.frameTimestamps[i];+      const durSec = next != null+        ? Math.max(0.001, (next - cur) / 1000)+        : Math.max(0.001, 1 / Math.max(1, actualFps));+      concatLines.push(`duration ${durSec.toFixed(4)}`);+    }+    // Repeat the last file entry (no duration) — concat demuxer requirement+    concatLines.push(`file 'f${String(state.frameCount).padStart(6, '0')}.jpg'`);+    const concatPath = path.join(state.framesDir, 'concat.txt');+    fs.writeFileSync(concatPath, concatLines.join('\n'));++    const muxArgs = [+      '-y',+      '-loglevel', 'error',+      '-f', 'concat',+      '-safe', '0',+      '-i', concatPath,+      '-vsync', 'vfr',+      '-c:v', 'libvpx-vp9',+      '-b:v', '0',+      '-crf', '32',+      '-pix_fmt', 'yuv420p',+      '-row-mt', '1',+      '-deadline', 'realtime',+      '-cpu-used', '8',+      '-an',+      state.filePath,+    ];+    console.log(`[${state.recordingId}] muxing ${state.frameCount} frames over ${(totalDurMs/1000).toFixed(2)}s (avg ${actualFps.toFixed(1)} fps actual, ${state.fps} target)`);+    const muxStart = Date.now();+    await new Promise((resolve, reject) => {+      const proc = spawn(FFMPEG_PATH, muxArgs, { stdio: ['ignore', 'ignore', 'pipe'] });+      let stderr = '';+      proc.stderr.on('data', b => { stderr += b.toString(); });+      proc.on('error', reject);+      proc.on('exit', code => {+        if (code === 0) return resolve();+        reject(new Error(`ffmpeg mux exited ${code}: ${stderr.slice(0, 400) || '(no stderr)'}`));+      });+    }).catch(e => {+      console.error(`[${state.recordingId}] mux failed: ${e.message}`);+    });+    console.log(`[${state.recordingId}] muxed ${state.frameCount} frames → ${state.filePath} in ${Date.now() - muxStart}ms`);++    // Clean up frames dir (keep on mux failure for debugging)+    if (fs.existsSync(state.filePath)) {+      try { fs.rmSync(state.framesDir, { recursive: true, force: true }); }+      catch (e) { console.log(`[${state.recordingId}] frames cleanup failed: ${e.message}`); }+    }+  } else {+    console.log(`[${state.recordingId}] zero frames captured — no webm produced`);+  }++  // Sidecar metadata (used by browser_record_list)+  try {+    const sidecar = {+      recordingId, sessionId: state.sessionId, tabId: state.tabId,+      filePath: state.filePath, fps: state.fps, quality: state.quality,+      startedAt: state.startedAt, stoppedAt: state.stoppedAt,+      durationMs, frameCount: state.frameCount, sizeBytes: state.sizeBytes,+      stopReason: state.stopReason || 'requested',+    };+    fs.writeFileSync(state.filePath.replace(/\.webm$/, '.json'), JSON.stringify(sidecar, null, 2));+  } catch (e) {+    console.error(`[${state.recordingId}] sidecar write failed: ${e.message}`);+  }++  let sizeKB = 0;+  try { sizeKB = Math.round(fs.statSync(state.filePath).size / 1024); } catch {}++  const actualFps = durationMs > 0 ? +(state.frameCount / (durationMs / 1000)).toFixed(2) : 0;++  tabRecordings.delete(recordingId);+  return {+    recordingId,+    sessionId: state.sessionId,+    tabId: state.tabId,+    filePath: state.filePath,+    sizeKB,+    durationMs,+    frameCount: state.frameCount,+    fpsTarget: state.fps,+    fpsActual: actualFps,+    stopReason: state.stopReason || 'requested',+  };+}++function tabRecordStatusImpl({ sessionId } = {}) {+  const active = [];+  for (const s of tabRecordings.values()) {+    if (sessionId && s.sessionId !== sessionId) continue;+    if (s.stopping) continue;+    const elapsedMs = Date.now() - s.startedAt;+    const actualFps = elapsedMs > 100 ? +(s.frameCount / (elapsedMs / 1000)).toFixed(2) : null;+    active.push({+      recordingId: s.recordingId, sessionId: s.sessionId, tabId: s.tabId,+      filePath: s.filePath,+      fpsTarget: s.fps,+      fpsActual: actualFps,+      frameCount: s.frameCount,+      durationMs: elapsedMs,+      sizeBytesApprox: s.sizeBytes,+      startedAt: s.startedAt,+    });+  }+  return active;+}++function tabRecordListImpl() {+  // Scan RECORDINGS_DIR for completed tab recordings (.webm files matching+  // the tab pattern). Each has an optional sidecar .json with metadata.+  let entries = [];+  try {+    entries = fs.readdirSync(RECORDINGS_DIR)+      .filter(f => f.startsWith('rec-tab-') && f.endsWith('.webm'))+      .map(f => {+        const fp = path.join(RECORDINGS_DIR, f);+        const stat = fs.statSync(fp);+        const sidecarPath = fp.replace(/\.webm$/, '.json');+        let sidecar = {};+        try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}+        return {+          recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),+          filePath: fp,+          sizeKB: Math.round(stat.size / 1024),+          durationMs: sidecar.durationMs || null,+          fps: sidecar.fps || null,+          sessionId: sidecar.sessionId || null,+          tabId: sidecar.tabId || null,+          mtime: stat.mtime.toISOString(),+        };+      });+  } catch {}+  return entries;+}++function desktopRecordListImpl() {+  let entries = [];+  try {+    entries = fs.readdirSync(RECORDINGS_DIR)+      .filter(f => f.startsWith('rec-desktop-') && f.endsWith('.webm'))+      .map(f => {+        const fp = path.join(RECORDINGS_DIR, f);+        const stat = fs.statSync(fp);+        const sidecarPath = fp.replace(/\.webm$/, '.json');+        let sidecar = {};+        try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, 'utf8')); } catch {}+        return {+          recordingId: sidecar.recordingId || f.replace(/\.webm$/, ''),+          filePath: fp,+          sizeKB: Math.round(stat.size / 1024),+          durationMs: sidecar.durationMs || null,+          reason: sidecar.reason || null,+          mtime: stat.mtime.toISOString(),+        };+      });+  } catch {}+  return entries;+}++// ════════════════════════════════════════════════════════════════════════════++// Graceful shutdown: disconnect from Chrome instead of killing it+// Chrome stays alive for reconnect on next bridge startup+async function gracefulShutdown(signal) {+  console.log(`\n[${signal}] Draining recordings + disconnecting from Chrome (Chrome stays alive for reconnect)...`);++  // Drain active desktop recording+  if (desktopRecorder.current) {+    try {+      desktopRecorder.current.stopReason = 'bridgeShutdown';+      await desktopRecordStopImpl({ recordingId: desktopRecorder.current.recordingId });+    } catch (e) {+      console.error(`  desktop recording stop failed: ${e.message}`);+    }+  }+  // Close recorder window so its WebM has a valid trailing cluster+  if (desktopRecorder.browser) {+    try { await desktopRecorder.browser.close(); } catch {}+  }++  // Drain active tab recordings+  for (const [recId, _state] of [...tabRecordings.entries()]) {+    try {+      _state.stopReason = 'bridgeShutdown';+      await tabRecordStopImpl({ sessionId: _state.sessionId, recordingId: recId });+    } catch (e) {+      console.error(`  tab recording ${recId} stop failed: ${e.message}`);+    }+  }++  for (const [name, entry] of browsers) {+    try {+      entry.browser.disconnect();+      console.log(`  Disconnected from browser "${name}"`);+    } catch (e) {+      console.error(`  Error disconnecting "${name}": ${e.message}`);+    }+  }+  browsers.clear();+  sessions.clear();+  process.exit(0);+}+process.on('SIGINT', () => gracefulShutdown('SIGINT'));+process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
src/welcome.htmladded+449
@@ -0,0 +1,449 @@+<!DOCTYPE html>+<html lang="en">+<head>+<meta charset="UTF-8">+<meta name="viewport" content="width=device-width, initial-scale=1.0">+<title>Pup — Adom Desktop</title>+<style>+  @font-face {+    font-family: 'Familjen Grotesk';+    src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-700-normal.woff2') format('woff2');+    font-weight: 700; font-display: swap;+  }+  @font-face {+    font-family: 'Familjen Grotesk';+    src: url('https://adom.inc/fonts/familjen-grotesk/familjen-grotesk-latin-500-normal.woff2') format('woff2');+    font-weight: 500; font-display: swap;+  }+  @font-face {+    font-family: 'Satoshi';+    src: url('https://adom.inc/fonts/satoshi/satoshi-400-normal.woff2') format('woff2');+    font-weight: 400; font-display: swap;+  }+  @font-face {+    font-family: 'Satoshi';+    src: url('https://adom.inc/fonts/satoshi/satoshi-500-normal.woff2') format('woff2');+    font-weight: 500; font-display: swap;+  }+  @font-face {+    font-family: 'JetBrains Mono';+    src: url('https://fonts.gstatic.com/s/jetbrainsmono/v24/tDbv2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKwBNntkaToggR7BYRbKPx3cwhsk.woff2') format('woff2');+    font-weight: 400; font-display: swap;+  }++  :root {+    --bg: #0d1117;+    --surface: #161b22;+    --elevated: #1c2128;+    --overlay: #21262d;+    --border: #30363d;+    --border-muted: #21262d;+    --text: #e6edf3;+    --text2: #8b949e;+    --text3: #484f58;+    --accent: #00b8b0;+    --accent-bright: #00e6dc;+    --accent-muted: rgba(0, 184, 176, 0.12);+    --accent-border: rgba(0, 184, 176, 0.25);+    --purple: #8C6BF7;+    --purple-light: #C5B3FF;+    --blue: #0061EF;+    --blue-light: #64ABFF;+    --teal-light: #8FD3D4;+  }++  * { margin: 0; padding: 0; box-sizing: border-box; }+  * { transition: background 0.15s, color 0.15s, border-color 0.15s, opacity 0.15s; }++  body {+    background: var(--bg);+    color: var(--text);+    font-family: 'Satoshi', sans-serif;+    font-weight: 400;+    min-height: 100vh;+    display: flex;+    flex-direction: column;+    align-items: center;+    overflow-x: hidden;+  }++  h1, h2, h3, .title { font-family: 'Familjen Grotesk', sans-serif; font-weight: 700; }+  code, pre, .mono { font-family: 'JetBrains Mono', monospace; }++  /* ── Gradient accent bar at top ── */+  .accent-bar {+    width: 100%;+    height: 3px;+    background: linear-gradient(90deg, var(--blue) 0%, var(--accent) 40%, var(--purple) 100%);+  }++  /* ── Main content ── */+  .container {+    max-width: 820px;+    width: 100%;+    padding: 40px 40px 32px;+    flex: 1;+    display: flex;+    flex-direction: column;+    gap: 24px;+  }++  /* ── Hero ── */+  .hero {+    display: flex;+    align-items: center;+    gap: 20px;+  }+  .hero-icon {+    width: 52px;+    height: 52px;+    flex-shrink: 0;+  }+  .hero-icon svg { width: 100%; height: 100%; }+  .hero-text h1 {+    font-size: 30px;+    letter-spacing: -0.5px;+    line-height: 1.1;+  }+  .hero-text h1 .pup { color: var(--accent); }+  .hero-text h1 .sep { color: var(--text3); font-weight: 500; margin: 0 6px; }+  .hero-text h1 .sub { color: var(--text2); font-weight: 500; font-size: 22px; }+  .hero-text .tagline {+    margin-top: 4px;+    color: var(--text2);+    font-size: 14px;+    line-height: 1.5;+  }++  /* ── Session identity card ── */+  .session-card {+    background: var(--accent-muted);+    border: 1px solid var(--accent-border);+    border-radius: 10px;+    padding: 18px 22px;+    display: none; /* shown by JS when params present */+  }+  .session-card.visible { display: block; }+  .session-card .session-heading {+    font-size: 11px;+    text-transform: uppercase;+    letter-spacing: 0.5px;+    color: var(--accent);+    font-weight: 600;+    margin-bottom: 10px;+  }+  .session-card .session-grid {+    display: grid;+    grid-template-columns: auto 1fr;+    gap: 6px 16px;+    align-items: baseline;+  }+  .session-card .session-label {+    font-size: 11px;+    text-transform: uppercase;+    letter-spacing: 0.3px;+    color: var(--text3);+    font-weight: 600;+    white-space: nowrap;+  }+  .session-card .session-value {+    font-size: 13px;+    color: var(--text);+    font-family: 'JetBrains Mono', monospace;+    word-break: break-all;+  }++  /* ── Pipeline diagram ── */+  .pipeline {+    display: flex;+    align-items: center;+    gap: 0;+    padding: 18px 22px;+    background: rgba(22,27,34,0.92);+    backdrop-filter: blur(16px);+    border: 1px solid rgba(0,184,177,0.2);+    border-radius: 10px;+    box-shadow: 0 12px 40px rgba(0,0,0,0.55);+    overflow-x: auto;+  }+  .pipeline-node {+    display: flex;+    flex-direction: column;+    align-items: center;+    gap: 5px;+    flex-shrink: 0;+    min-width: 90px;+  }+  .pipeline-node .icon {+    width: 34px;+    height: 34px;+    border-radius: 8px;+    display: flex;+    align-items: center;+    justify-content: center;+    font-size: 17px;+  }+  .pipeline-node .label {+    font-size: 10px;+    font-weight: 500;+    color: var(--text2);+    text-align: center;+    text-transform: uppercase;+    letter-spacing: 0.4px;+  }+  .pipeline-arrow {+    color: var(--text3);+    font-size: 16px;+    margin: 0 6px;+    flex-shrink: 0;+    margin-bottom: 16px;+  }+  .node-cloud .icon { background: rgba(0,97,239,0.15); color: var(--blue-light); }+  .node-relay .icon { background: rgba(140,107,247,0.15); color: var(--purple-light); }+  .node-desktop .icon { background: rgba(0,184,176,0.12); color: var(--accent-bright); }+  .node-pup .icon { background: rgba(0,184,176,0.2); color: var(--accent-bright); }+  .node-chrome .icon { background: rgba(143,211,212,0.15); color: var(--teal-light); }++  /* ── Description card ── */+  .desc-card {+    background: var(--surface);+    border: 1px solid var(--border);+    border-radius: 10px;+    padding: 20px 24px;+  }+  .desc-card p {+    color: var(--text2);+    font-size: 13px;+    line-height: 1.65;+  }+  .desc-card p strong { color: var(--text); font-weight: 500; }+  .desc-card p code {+    background: var(--overlay);+    padding: 2px 6px;+    border-radius: 4px;+    font-size: 11px;+    color: var(--accent);+  }++  /* ── Feature chips ── */+  .features {+    display: flex;+    flex-direction: column;+    gap: 10px;+  }+  .features-title {+    font-size: 11px;+    text-transform: uppercase;+    letter-spacing: 0.5px;+    color: var(--accent);+    font-weight: 600;+  }+  .chip-grid {+    display: flex;+    flex-wrap: wrap;+    gap: 7px;+  }+  .chip {+    display: inline-flex;+    align-items: center;+    gap: 6px;+    padding: 5px 12px;+    background: var(--elevated);+    border: 1px solid var(--border-muted);+    border-radius: 6px;+    font-size: 11px;+    color: var(--text2);+    font-family: 'JetBrains Mono', monospace;+  }+  .chip:hover {+    border-color: rgba(0,184,176,0.3);+    color: var(--text);+  }+  .chip .dot {+    width: 5px;+    height: 5px;+    border-radius: 50%;+    background: var(--accent);+    flex-shrink: 0;+  }++  /* ── Footer info bar ── */+  .footer {+    width: 100%;+    background: var(--surface);+    border-top: 1px solid var(--border-muted);+    padding: 9px 40px;+    display: flex;+    align-items: center;+    gap: 20px;+    flex-wrap: wrap;+  }+  .footer-item {+    display: flex;+    align-items: center;+    gap: 6px;+  }+  .footer-label {+    font-size: 10px;+    text-transform: uppercase;+    letter-spacing: 0.3px;+    color: var(--text3);+    font-weight: 600;+  }+  .footer-value {+    font-size: 11px;+    color: var(--text2);+    font-family: 'JetBrains Mono', monospace;+  }+  .footer-value.live {+    color: #3fb950;+  }+  .footer-sep {+    width: 1px;+    height: 14px;+    background: var(--border-muted);+  }+</style>+</head>+<body>++<div class="accent-bar"></div>++<div class="container">++  <!-- Hero -->+  <div class="hero">+    <div class="hero-icon">+      <svg viewBox="0 0 1061.33 1061.38" xmlns="http://www.w3.org/2000/svg">+        <path fill="#00b8b0" d="M957.65,103.68c-116.2-116.2-282.77-138.02-372.05-48.73-36.23,36.23-54.16,85.18-54.92,138.34h0s0,.07,0,.07c0,.17,0,.35,0,.52v329.6c0,3.99,3.24,7.23,7.23,7.23h326.06c54.75.14,105.25-17.79,142.42-54.97,89.28-89.28,67.46-255.85-48.74-372.05Z"/>+        <path fill="#00b8b0" d="M103.68,957.7c116.2,116.2,282.77,138.02,372.05,48.73,36.23-36.23,54.16-85.18,54.92-138.34h0s0-.07,0-.07c0-.17,0-.35,0-.52v-329.6c0-3.99-3.24-7.23-7.23-7.23H197.37c-54.75-.14-105.25,17.79-142.42,54.97-89.28,89.28-67.46,255.85,48.74,372.05Z"/>+      </svg>+    </div>+    <div class="hero-text">+      <h1><span class="pup">pup</span><span class="sep">—</span><span class="sub">Adom Desktop</span></h1>+      <p class="tagline">Browser automation for your desktop, controlled by Adom's cloud AI</p>+    </div>+  </div>++  <!-- Session identity (populated by JS from URL params) -->+  <div class="session-card" id="sessionCard">+    <div class="session-heading">This Chrome window belongs to</div>+    <div class="session-grid">+      <span class="session-label">Session</span>+      <span class="session-value" id="sessionId">—</span>+      <span class="session-label">Profile</span>+      <span class="session-value" id="profileName">—</span>+      <span class="session-label">User Data</span>+      <span class="session-value" id="userDataDir">—</span>+    </div>+  </div>++  <!-- Pipeline -->+  <div class="pipeline">+    <div class="pipeline-node node-cloud">+      <div class="icon">☁️</div>+      <div class="label">Adom Cloud</div>+    </div>+    <div class="pipeline-arrow">→</div>+    <div class="pipeline-node node-relay">+      <div class="icon">🔗</div>+      <div class="label">Relay Server</div>+    </div>+    <div class="pipeline-arrow">→</div>+    <div class="pipeline-node node-desktop">+      <div class="icon">🖥️</div>+      <div class="label">Desktop App</div>+    </div>+    <div class="pipeline-arrow">→</div>+    <div class="pipeline-node node-pup">+      <div class="icon">🐶</div>+      <div class="label">Pup Bridge</div>+    </div>+    <div class="pipeline-arrow">→</div>+    <div class="pipeline-node node-chrome">+      <div class="icon">🌐</div>+      <div class="label">Chrome</div>+    </div>+  </div>++  <!-- Description -->+  <div class="desc-card">+    <p>+      <strong>Pup</strong> is a Node.js bridge on port <code>8851</code> that wraps a persistent+      <strong>Chrome for Testing</strong> browser. Your Adom web app sends <code>browser_*</code>+      commands through the cloud relay to the Desktop App, which forwards them to Pup.+      Chrome launches visibly on your screen — not headless — so you can watch the AI+      browse, screenshot, and debug your projects in real time.+    </p>+  </div>++  <!-- Features -->+  <div class="features">+    <div class="features-title">Capabilities</div>+    <div class="chip-grid">+      <div class="chip"><span class="dot"></span>browser_launch</div>+      <div class="chip"><span class="dot"></span>browser_screenshot</div>+      <div class="chip"><span class="dot"></span>browser_navigate</div>+      <div class="chip"><span class="dot"></span>browser_eval</div>+      <div class="chip"><span class="dot"></span>browser_errors</div>+      <div class="chip"><span class="dot"></span>browser_reload</div>+      <div class="chip"><span class="dot"></span>browser_wait</div>+      <div class="chip"><span class="dot"></span>browser_status</div>+      <div class="chip"><span class="dot"></span>browser_close</div>+    </div>+  </div>++</div>++<!-- Footer -->+<div class="footer">+  <div class="footer-item">+    <span class="footer-label">Bridge</span>+    <span class="footer-value">Puppeteer</span>+  </div>+  <div class="footer-sep"></div>+  <div class="footer-item">+    <span class="footer-label">Port</span>+    <span class="footer-value">8851</span>+  </div>+  <div class="footer-sep"></div>+  <div class="footer-item">+    <span class="footer-label">Mode</span>+    <span class="footer-value">headless: false</span>+  </div>+  <div class="footer-sep"></div>+  <div class="footer-item">+    <span class="footer-label">Screenshots</span>+    <span class="footer-value">lossless PNG ≤ 1568px</span>+  </div>+  <div class="footer-sep"></div>+  <div class="footer-item">+    <span class="footer-label">Status</span>+    <span class="footer-value live">● live</span>+  </div>+</div>++<script>+  // Read session info from URL query params+  const params = new URLSearchParams(window.location.search);+  const sessionId = params.get('sessionId');+  const profile = params.get('profile');+  const userDataDir = params.get('userDataDir');++  if (sessionId) {+    // Show the session identity card+    document.getElementById('sessionCard').classList.add('visible');+    document.getElementById('sessionId').textContent = sessionId;+    document.getElementById('profileName').textContent = profile || sessionId;+    document.getElementById('userDataDir').textContent = userDataDir || '(default)';++    // Set the page title to show session info in the Chrome tab+    const titleParts = ['Pup'];+    titleParts.push(`session: ${sessionId}`);+    if (profile && profile !== sessionId) titleParts.push(`profile: ${profile}`);+    document.title = titleParts.join(' — ');+  }+</script>++</body>+</html>