app
Pup - the AI's own browser
Public Made by Adomby adom
pup gives your AI a real browser on your desktop that it drives completely, in the background, signed in as you: windows that never jump in front of your work, one shared profile that learns your logins, and every window labeled on the taskbar with its AI thread's name.
← Commit history
v1.1.0: cold-start self-heal (auto-install Chrome for Testing + browser_readiness/browser_prewarm + truthful status chip); extract to standalone src/ bridge repo with manifest + ownership docs
20 files changed
+9457−5
.gitignore+6CHANGELOG.md+38CLAUDE.md+59PUBLISHING.md+86SKILL.md+137−5adom-bridge-puppeteer-manifest.json+16src/BRIDGE_VERSION+1src/adom-icon.svg+12src/bridge.json+77src/chrome.js+237src/credential_vault.js+202src/favicon.svg+1src/icons/microphone.svg+4src/icons/monitor.svg+4src/package-lock.json+2047src/package.json+14src/recorder-icon.svg+38src/recorder.html+605src/server.js+5424src/welcome.html+449.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
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>
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>
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>