Skip to main content
Glama
pwa-debug-layer

pwa-debug-layer

by aryanduntley
README.md
# pwa-debug-layer

An **AI-native debugging layer for PWAs and modern web apps**. It lets an AI agent (e.g. Claude Code via MCP) **see and act on your live, logged-in browser** the way a developer with full DevTools open would — DOM, console, network, framework state, store state, service workers, caches, and direct interaction — as structured data the model consumes natively.

It's built for the questions developers actually search for and that Chrome DevTools makes you assemble by hand: *why won't my service worker update? why is my cache stale? why won't my PWA install? why does this component have the wrong state?* — answered by an agent reading the runtime directly, against **your real browser profile** (extensions, auth, and all), not a sterile automated tab.

### What it answers

The PWA failures developers actually search for, read straight from your live runtime:

- **Why won't my service worker update? Why are some users on old code?** — SW lifecycle with waiting-vs-active versions, plus an update-propagation / version-skew analyzer.
- **Why is my cache stale? Why are chunks 404ing after a deploy?** — CacheStorage contents with age, and a cached-HTML-vs-JS skew check.
- **Why won't my PWA install?** — structured installability diagnostics with per-gap remediation, not just "manifest invalid."
- **What's actually in IndexedDB / localStorage right now?** — live storage inspection (*inspect IndexedDB live*).
- **What can this browser actually do?** — a live capability matrix (Push / Background Sync / Periodic Sync / Badging / File System Access / Window Controls Overlay).
- **Does anything break at 360 px, in Spanish, in dark mode, in this app state?** — one `layout_sweep` call checks every page × width × color scheme × state and returns a short list of distinct problems.

All read from your real, logged-in profile — service-worker, cache, and extension state included — which `chrome-devtools-mcp`'s sterile automated Chrome can't see.

The goal is to eliminate the "user is the AI's eyes and hands" loop. Today, debugging a PWA with AI usually means the human copy/pastes DOM snippets, describes console errors, screenshots UI state, and hand-executes clicks. This project replaces that with direct, structured access.

> **Status: working on Linux.** The full MCP→IPC→native-host→service-worker→page-world round-trip is live, and a broad debugging surface is shipped:
> - **Capture** — console / network / error / DOM-mutation / lifecycle, with persistent ring buffers + disk spill.
> - **Framework introspection** — **React, Vue, Svelte, and Solid** (component/element trees, state, find-by-text/role).
> - **Store introspection** — **Redux, Zustand, Pinia, and Jotai** (read, subscribe, dispatch).
> - **Interaction + touch gestures** — click, fill, submit, hover, focus/blur, select, key/type, **drag, scroll, swipe, tap, double-tap, long-press, pinch**.
> - **Library-popup capture/replay** — WalletConnect / SDK modals: record, replay, tail, failure correlation.
> - **Replay & source maps** — rrweb `session_record`/`session_replay`, `source_map_resolve`.
> - **Browser launcher** — one-call `pdl_launch_browser` with `chrome-devtools-mcp` coexistence.
> - **Android phones** — the same tools drive Android Chrome tabs and installed PWAs over adb (`pdl_android_attach`), no desktop browser in the loop.
> - **Responsive layout sweep** — `layout_sweep` emulates widths and color schemes over CDP, walks app states and languages, measures overflow, off-screen elements, tap targets, pinned bars, cut-off text, edge contact, clipped focus/selection rings and unresolved copy, runs your own `custom_checks`, and groups the results into distinct problems.
> - **PWA Runtime Diagnostics** — service-worker lifecycle + versions, CacheStorage contents + age, installability gaps, a live capability matrix, IndexedDB/web-storage inspection, update-propagation / version-skew analysis, and a one-shot runtime-state snapshot.
>
> **Verified on Linux** (the full suite live-tested against a real PWA). macOS/Windows code paths are implemented with unit coverage but still need real-machine retest ([help wanted](#help-wanted-macos--windows-verification)). Firefox is not supported (it doesn't speak CDP).
>
> ⚠️ **macOS / Windows users, beware:** these platforms have **never been run on real hardware** — only unit-tested with injected fakes. Expect rough edges (browser detection, profile paths, native-messaging registration, system-default resolution). Please [open an issue](../../issues) with the failing command and its output — bug reports from real macOS/Windows machines are the single most useful contribution right now. See [Help wanted](#help-wanted-macos--windows-verification) for the specific things to try.

## How it differs from `chrome-devtools-mcp`

Google's [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) gives an AI Chrome DevTools Protocol access (DOM, console, network, screenshots). That covers a lot.

`pwa-debug-layer` is **complementary** — it targets the things CDP can't reach:

- **Your real, logged-in browser.** `chrome-devtools-mcp` spawns a fresh automated Chrome — which shows "controlled by automated test software," blocks extension loading, and has none of your auth/session state, so authenticated apps are hard to debug ([the extensions/auth gap](https://medium.com/@vsanse24/no-extensions-allowed-the-chrome-devtools-mcp-dilemma-d58204aaab1f), [#265](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/265)). pwa-debug-layer's default `existing` mode attaches to **your normal profile**, with its real cookies, extensions, and service-worker state — exactly the real-profile SW/extension visibility people keep asking Google for ([#1173](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/1173), [#96](https://github.com/ChromeDevTools/chrome-devtools-mcp/issues/96)).
- **Framework state.** React fiber trees, Vue reactive state, Svelte component graphs, Solid signals — read via the framework's own devtools hooks (`__REACT_DEVTOOLS_GLOBAL_HOOK__`, `__vue_app__`, `_vnode`, etc.). CDP can't see these.
- **Store state.** Redux / Zustand / Pinia / Jotai — read, subscribe, and dispatch.
- **Service-worker, cache & installability state.** SW lifecycle + versions, CacheStorage contents + age, manifest installability gaps, IndexedDB/web storage, and an update-propagation / version-skew analyzer — the highest-volume PWA pain (stale cache, "SW won't update," install failures, "why are some users on old code") that DevTools makes you piece together by hand.
- **Shadow DOM, iframes, dynamically-injected library widgets.** WalletConnect modals, third-party SDK popups, and other widgets that escape standard DOM tooling.
- **Page-world reach in general.** A MAIN-world script we inject reaches things isolated-world content scripts can't, and reaches them earlier than `initScript`-on-next-nav.
- **Persistent ring buffers + rrweb-style replay** across navigations and reloads.
- **Configurable filters** so the AI receives only the slice it asked for — no full-DOM noise.

The two are designed to coexist: install both, the AI uses each for what it does best, with zero tool-surface duplication.

## Architecture

```
┌──────────────────┐  MCP (stdio)  ┌──────────────────────────┐
│  Claude Code     │ ◄───────────► │  Native Messaging Host   │
│  (or any MCP     │               │  - MCP server            │
│   client)        │               │  - Ring buffers          │
└──────────────────┘               │  - Replay/snapshot store │
                                   └────────────┬─────────────┘
                                                │ Native Messaging
                                                │ (JSON over stdio)
                                                ▼
                                   ┌──────────────────────────┐
                                   │  Extension Service Worker│
                                   │  - chrome.debugger (CDP) │
                                   │  - Tab/router            │
                                   └────────────┬─────────────┘
                                                │
                       ┌────────────────────────┼────────────────────────┐
                       ▼                        ▼                        ▼
            ┌────────────────────┐  ┌────────────────────┐  ┌────────────────────┐
            │ Content Script     │  │ Page-World Script  │  │ DevTools Panel     │
            │ (isolated world)   │  │ (MAIN world)       │  │ (planned)          │
            │ - DOM observe      │  │ - React/Vue hooks  │  │ - Human inspector  │
            │ - Action exec      │  │ - fetch/XHR patch  │  │   of AI session    │
            │ - Bridge to SW     │  │ - Bus/RxJS taps    │  │                    │
            └────────────────────┘  └────────────────────┘  └────────────────────┘
                       └────────── live page (the PWA being debugged) ─────────┘
```

Three components, one installable unit:

- **Extension** owns the page (DOM, content scripts, page-world hooks).
- **Native host** owns persistence and the MCP server (long-lived, can hold buffers, can write files).
- **MCP** owns the AI contract.

Each does what only it can. See [`docs/PLAN.md`](docs/PLAN.md) for the full design.

## Browser support

**Chromium-family only**, sideloaded. Tested against:

- Chromium (native package)
- Google Chrome (`.deb` / `.rpm`)
- Brave Browser
- Microsoft Edge (Linux `.deb`)
- Vivaldi
- Opera

macOS Application Support paths and Windows HKCU-registry registration are implemented and have unit-test coverage; the manual round-trip retest currently runs on Linux.

### Snap browsers are not supported

If you installed your browser via **snap** (e.g. `snap install chromium` on Ubuntu), it will not work with `pwa-debug-layer`.

**Why:** snap's `home` interface allows the browser to *read* files in `$HOME` but blocks *exec* of any binary whose resolved path crosses a hidden directory (`~/.nvm/...`, `~/.config/...`). The native messaging host launcher and the node binary it invokes both live under hidden paths in a normal install, so spawn fails with `Permission denied` and the service worker reports `Native host has exited.` There is no fix on the extension/host side that doesn't require copying ~125 MB of node into a non-hidden install dir per registration; not worth the install bloat for a setup most distros let you avoid.

**What to do:** install your Chromium-family browser from a native package source instead:

- **Ubuntu/Debian:** `apt install chromium` from the universe repo if you've enabled the non-snap source, or `apt install brave-browser` / `microsoft-edge-stable` from their respective `.deb` repos. The Chromium tarball from chromium.org also works.
- **Fedora:** `dnf install chromium` is non-snap by default.
- **Arch:** `pacman -S chromium`.

Flatpak browsers are detected and have a manifest written, but flatpak confinement may also block exec — if it fails, run `flatpak override --user --filesystem=host <app-id>` and retry.

## Installation

### Prerequisites

- Node.js ≥ 20.19 (developed on 23.x)
- pnpm
- A Chromium-family browser **not installed via snap** (see above)
- An MCP-aware client (e.g. [Claude Code](https://docs.claude.com/en/docs/claude-code))

### 1. Build the host and extension

```sh
git clone https://github.com/<your-fork>/pwa-debug-layer
cd pwa-debug-layer
pnpm install
pnpm build      # builds packages/host/dist/main.js and packages/extension/dist/
pnpm test       # full workspace unit suite (shared + host + extension)
```

### 2. Add the host to your MCP client

For Claude Code, add to your `.mcp.json` (project-scoped) or `~/.claude/mcp.json` (global):

```json
{
  "mcpServers": {
    "pwa-debug": {
      "type": "stdio",
      "command": "node",
      "args": ["/absolute/path/to/pwa-debug-layer/packages/host/dist/main.js"]
    }
  }
}
```

Restart Claude Code so it picks up the server.

### 3. Load the extension

1. Open `chrome://extensions` (or `brave://extensions`, etc.) in your browser.
2. Toggle **Developer mode** on.
3. Click **Load unpacked** and select `packages/extension/dist/`.
4. Note the extension's ID (shown on the card). Or, ask Claude to discover it for you in the next step — the extension service worker logs `[pwa-debug/sw] id=<id>` on every boot.

### 4. Tell Claude to set it up

The host registration is **AI-driven**. Six MCP tools are exposed for setup; Claude calls them via a guided flow:

> **Set up pwa-debug. The extension ID is `<your-id>`** *(or omit the ID and Claude can fetch it from the SW console if you also have `chrome-devtools-mcp` installed.)*

Claude will:

1. Call `host_status` to see what's already registered.
2. Call `host_register_extension(id)` — this writes a per-browser native-messaging manifest into every detected install (Chromium-family native, macOS Application Support, Windows HKCU registry), and drops a launcher script with an absolute node path (so the host spawns under sandboxed PATH environments).
3. Tell you to reload the extension at `chrome://extensions`.
4. After reload, call `host_status` again to confirm the manifest is on disk and the SW is connecting.

When the round-trip works you'll see in the SW console (`Inspect views: service worker` on the extension card):

```
[pwa-debug/sw] connected to host
[pwa-debug/sw] pong …
[pwa-debug/sw] hello …          (5s after connect — host-pushed message proving bidirectional flow)
```

## Launching a browser + `chrome-devtools-mcp` coexistence

`pwa-debug` and `chrome-devtools-mcp` are **two separate MCP servers** that share one browser. `pwa-debug` launches (or attaches to) a Chromium browser with a live remote-debugging port; `chrome-devtools-mcp` attaches to that same port over CDP. No proxying, no version coupling.

Register both with your client. For Claude Code:

```sh
# pwa-debug (this project) — adjust the path to your checkout
claude mcp add pwa-debug --scope user -- node /absolute/path/to/pwa-debug-layer/packages/host/dist/main.js

# chrome-devtools-mcp (optional but recommended) — runs via npx, no global install
# The --browserUrl port MUST match the port pwa-debug launches on (launch.defaultPort, default 9222).
claude mcp add chrome-devtools --scope user -- npx -y chrome-devtools-mcp@latest --browserUrl http://127.0.0.1:9222
```

### Or install `pwa-debug` as a Claude Code plugin

The repo ships a `.claude-plugin/` manifest whose MCP server runs the **published npm package** (`npx -y @aryanduntley/pwa-debug@latest`), so the plugin installs with **no clone and no build** — and picks up updates with **`/reload-plugins`**, no full restart:

```sh
# Add the marketplace straight from GitHub (no checkout needed)
/plugin marketplace add aryanduntley/pwa-debug-layer
# Install + enable the pwa-debug plugin
/plugin install pwa-debug@pwa-debug
# Bring the MCP server up with no restart:
/reload-plugins
```

> **No build needed.** The plugin's MCP entry is `npx -y @aryanduntley/pwa-debug@latest`, which fetches the prebuilt host (and its bundled extension) from npm. *Working on a local clone instead?* Skip the plugin and register your own build directly: `claude mcp add pwa-debug --scope user -- node /absolute/path/to/pwa-debug-layer/packages/host/dist/main.js`.
>
> **`chrome-devtools-mcp` is still separate.** The plugin declares **only** the `pwa-debug` host — `chrome-devtools-mcp` stays the optional `claude mcp add chrome-devtools …` above (no version coupling, no owning its launch). The bundled **`chrome-devtools-coexistence`** skill walks you through registering it; with a plugin install, its "make the tools appear" step is `/reload-plugins`.

> **Port must match.** `chrome-devtools-mcp`'s `--browserUrl` has to point at the exact port `pwa-debug` opens — the `launch.defaultPort` setting (default `9222`), or the active port from a current `pdl_launch_browser`. Rather than hand-write this, let Claude call **`pdl_register_chrome_devtools`**, which runs the `claude mcp add` above for you pinned to the right port (and **`pdl_check_setup`** flags a registration that is *unpinned* — no `--browserUrl`, so it spawns its own isolated browser — or pointed at the wrong port).

> **Mind the (re)connect.** A registration added or changed via `claude mcp add` only takes effect when the server (re)connects. Fastest path: open **`/mcp`** and **reconnect** `chrome-devtools` — this reloads it from the current registration (verified to pick up a changed `--browserUrl`) with **no full restart and no context loss**. If the client won't load it that way, **fully restart** Claude Code as a fallback. If instead you install `chrome-devtools-mcp` as a **plugin**, run **`/reload-plugins`**. The bundled **`chrome-devtools-coexistence`** skill walks Claude through all paths and, for the full-restart fallback, hands you a context note to paste back afterward.

> The host has no `install`/`serve` subcommand — `dist/main.js` auto-detects its mode: launched by Chrome (argv starts with `chrome-extension://`) it runs as the native-messaging host; launched by your MCP client it runs as the MCP server.

Then let Claude drive setup and launch:

1. **`pdl_check_setup`** — reports `{ ok, gaps[], recommendations[] }`: whether `chrome-devtools-mcp` is **registered** (read from the `claude` CLI) **and pinned with a `--browserUrl`** at the right debug port — it flags both an *unpinned* registration (which would spawn its own isolated browser instead of attaching to yours) and one pointed at the wrong port; the host manifest is installed (including **per active sandbox profile**); the extension dist is present; and an extension ID is registered. Follow its `next_steps` to close any gap.
2. **`pdl_register_chrome_devtools`** — writes the `chrome-devtools-mcp` registration for you, pinned to the active/`launch.defaultPort` port (idempotent; re-points it if it's unpinned or on the wrong port). *(Mutates your MCP config — Claude will ask first; a restart/reconnect follows, see above.)*
3. **`pdl_install_extension`** — copies the extension to `~/Downloads/pwa-debug-extension` (or a `target` you pass) with `chrome://extensions` "Load unpacked" instructions. *(On most browsers a sandbox mode preloads the extension so you can skip this; on **branded Google Chrome 142+** preload is impossible — this is the manual install the sandbox launch will point you to.)*
4. **`pdl_launch_browser`** — launches/attaches a browser with the debug port live and returns the `browserUrl` to hand to `chrome-devtools-mcp`. **Always launch first:** call this *before* any `chrome-devtools-mcp` tool, so the browser + debug port exist for it to attach to. Follow its `next_steps` — they carry the brand/version-specific guidance (preloaded vs. manual *Load unpacked*, or the modern-Chromium port caveat).
5. **`pdl_browser_status`** — shows what's been launched (browser, profile mode, port, pid), re-probes each debug port for liveness, and reports the extension service-worker heartbeat.

**The validated one-profile recipe** (what these tools converge on, verified live on Brave 148):

```
pdl_launch_browser({ browser: "brave", mode: "sandbox-persistent" })
  → dedicated profile at ~/.pwa-debug/profiles/brave with:
      • the native-messaging manifest auto-written into the profile (so the extension connects), and
      • the pwa-debug extension preloaded via --load-extension (Brave honors it), and
      • --remote-debugging-port live (custom profile dir, so Chromium 136+ opens it).
pdl_register_chrome_devtools()  → pins chrome-devtools-mcp at that port
restart / reconnect chrome-devtools-mcp  → it attaches to the SAME browser
```

Now `chrome-devtools-mcp` (CDP) and pwa-debug (extension → page-world) drive the same tab simultaneously — independent, non-contending channels (the extension uses page-world injection, **not** `chrome.debugger`, so there's no CDP-attacher contention).

## Android phones

The extension cannot run in Android Chrome, so pwa-debug reaches phones over **adb + the Chrome DevTools Protocol** instead. The host injects the same page-world code the extension uses, so once attached **every tool works on the phone tab** — framework and store introspection, PWA diagnostics, interaction and touch gestures, navigation, and console / network / error capture — and you watch it happen on the phone.

**One-time phone setup:** Developer options → **USB debugging** on, connect by USB, accept **"Allow USB debugging?"**, and have Chrome open. You need `adb` ([Android platform-tools](https://developer.android.com/tools/releases/platform-tools)) on PATH or in the Android SDK (`ANDROID_HOME`, `~/Android/Sdk`).

**Then the AI does the rest in one call:**

```text
pdl_android_devices()                       → phones, authorization state, debuggable browsers/WebViews
pdl_android_attach({
  open_url: "http://localhost:5173",        → opens your app in a new phone tab, bridge in place before it loads
  reverse_ports: [5173]                      → adb reverse, so the phone's localhost:5173 is your dev server
})                                          → { connectionId: "cdp:127.0.0.1:<port>:<tab>" }
react_tree({ extension_id: "<connectionId>" }), pdl_tap(...), store_get_state(...), console_tail(...), ...
```

Pass `url_contains` instead of `open_url` to attach to a tab that is already open (add `reload: true` when a framework or store hook must exist before the app starts), or `socket: "webview"` / an app package for a debuggable app WebView. `pdl_cdp_targets` / `pdl_cdp_attach` / `pdl_cdp_detach` are the lower-level equivalents for any CDP endpoint (`adb forward tcp:9223 localabstract:chrome_devtools_remote`, then `browser_url: "9223"`).

pwa-debug only removes the adb forwards and reverses **it created**, when the host shuts down. Your capture settings (site allow/block lists, read controls, enabled kinds, filters) apply to phone connections exactly as they do to the extension. When something is missing — adb not installed, phone unauthorized or offline, Chrome not running — the tools' `next_steps` say exactly what to do, and `pdl_check_setup` reports phone readiness alongside the desktop checks.

| On a phone connection | |
|---|---|
| **Works** | Every page tool: capture tails, `evaluate`, React/Vue/Svelte/Solid, all stores, SW / cache / installability / storage / IndexedDB / update analysis / snapshot, popups, `session_record`/`replay`, `source_map_resolve`, every `pdl_*` interaction and gesture, `pdl_navigate`, `pdl_new_tab` (each new tab is its own connection), `session_ping`, and `layout_sweep` (the phone checks its own size first, then the requested widths). |
| **Not available** | `recent_events` (reads the extension service worker's buffer) — use `console_tail` / `network_tail` / `error_tail`. |
| **Connection-independent** | Setup, launcher, settings, help and the `pdl_cdp_*` / `pdl_android_*` tools themselves. |

Desktop browsers are driven through the extension; the CDP path is for phones.

## Profile modes

`pdl_launch_browser` takes `mode` (default `existing`), `browser` (defaults to your system-default Chromium browser), and `port` (default `9222`).

| Mode | Profile | When to use |
|---|---|---|
| **`existing`** *(default)* | Your normal browser profile | Debugging your real browsing session **with pwa-debug** (your real cookies, extensions, SW/cache state). Degrades gracefully: **(a)** debug port already live → attaches; **(b)** browser running *without* a debug port → opens a new window in the existing session (your extension tools work, `chrome-devtools-mcp` does not); **(c)** browser not running → spawns fresh with your profile. **Modern-Chromium caveat (136+):** Chromium refuses `--remote-debugging-port` on the **default** profile, so on a current browser `existing` mode gives you pwa-debug but **not** a CDP port — the launch reports this (`attached:false`, no false `browserUrl`) and steers you to `sandbox-persistent` for `chrome-devtools-mcp`. |
| **`sandbox-persistent`** | `~/.pwa-debug/profiles/<browser>/` (persists across restarts) | The **canonical both-tools profile** — a dedicated profile beside your main browser, with its own `--user-data-dir` (so the debug port works on Chromium 136+). The launcher auto-writes the native-messaging manifest into the profile and, on most browsers, **preloads** the pwa-debug extension via `--load-extension`. **Branded-Google-Chrome 142+ caveat:** Chrome removed `--load-extension`, so there the launch brings up the **debug port** (for `chrome-devtools-mcp`) but **not** the extension — it returns a one-time manual *Load unpacked* walkthrough (persists in this profile), or steers you to a non-Google Chromium where preload just works. |
| **`sandbox-temp`** | a fresh `mktemp` dir (removed on host shutdown) | One-off / CI / clean-state runs. Same as `sandbox-persistent`, but throwaway — so the Chrome-142+ manual-load step won't persist usefully here; prefer a non-Google Chromium for temp runs that need the extension. |

**Extension preload depends on the browser brand + version** (the launcher detects it from `--version` and adapts):

| Browser | `--load-extension` (sandbox preload) |
|---|---|
| Brave, Chromium, Edge, Opera, Vivaldi, Chrome for Testing | ✅ works on current versions |
| Google Chrome ≤ 136 | ✅ works |
| Google Chrome 137–141 | ✅ works — launcher adds `--disable-features=DisableLoadExtensionCommandLineSwitch` |
| **Google Chrome ≥ 142** | ❌ removed — launcher omits the dead flag and guides a one-time manual *Load unpacked* (or use Brave/Chromium) |

**Recommended coexistence setup:** `sandbox-persistent` on a **non-Google Chromium** (e.g. Brave or Chromium). That single profile gets the extension preloaded **and** a live debug port, so pwa-debug and `chrome-devtools-mcp` drive the same browser with zero manual steps.

## Browser support matrix

Chromium-family only (Firefox doesn't speak CDP). Linux is first-class. macOS/Windows binary detection, profile/user-data-dir paths, **and system-default detection** (macOS LaunchServices, Windows `UserChoice` registry) are all implemented with unit coverage, but **live verification on real macOS/Windows machines is still needed — see [Help wanted](#help-wanted-macos--windows-verification).**

| Browser | PATH names probed | Standard Linux binary | Linux profile dir (`existing` mode) |
|---|---|---|---|
| Chrome | `google-chrome`, `google-chrome-stable` | `/opt/google/chrome/chrome`, `/usr/bin/google-chrome*` | `~/.config/google-chrome` |
| Chromium | `chromium`, `chromium-browser` | `/usr/bin/chromium*`, `/snap/bin/chromium` | `~/.config/chromium` |
| Edge | `microsoft-edge`, `microsoft-edge-stable` | `/opt/microsoft/msedge/msedge` | `~/.config/microsoft-edge` |
| Brave | `brave-browser`, `brave` | `/opt/brave.com/brave/brave-browser` | `~/.config/BraveSoftware/Brave-Browser` |
| Vivaldi | `vivaldi`, `vivaldi-stable` | `/opt/vivaldi/vivaldi` | `~/.config/vivaldi` |
| Opera | `opera` | `/usr/bin/opera`, `/opt/opera/opera` | `~/.config/opera` |

- **System default:** the launcher prefers your system-default browser when you don't pass one — Linux via `xdg-settings get default-web-browser`, macOS via LaunchServices (`defaults read … LSHandlers`), Windows via the HKCU `UrlAssociations\http\UserChoice` ProgId. The macOS/Windows paths are implemented + unit-tested but not yet exercised on a real machine.
- **Default debug port** is `9222` (the `chrome-devtools-mcp` convention); override it without passing `port` each time via the `launch.defaultPort` setting (`settings.set`).
- **Snap profiles:** when launching a snap-packaged browser (`/snap/bin/…`) in `existing` mode, the launcher now resolves its confined profile (`~/snap/<snap>/common/<cfg>`) instead of `~/.config` — but note snap browsers still can't run the native-messaging host (see snap section), so this only matters if/when that confinement is lifted.
- **Brave Shields** can block the content script on a site — set Shields **Down** for the site if `session_ping` reports `page_blocks_scripts`.
- **Snap browsers are unsupported** for the native-messaging host (see below); the launcher can still spawn them, but the host round-trip won't connect. Use a native-package browser.
- **Flatpak** installs get a manifest written, but confinement may block exec — run `flatpak override --user --filesystem=host <app-id>` and retry.

## Help wanted: macOS / Windows verification

Development happens on Linux, so the macOS and Windows code paths are **written and unit-tested with injected fakes, but never run on a real machine.** If you're on macOS or Windows, trying these and reporting back (open an issue with the output) is the single most useful contribution right now:

**macOS**
- `pdl_check_setup` / `pdl_install_extension` — does the extension resolve and copy, and do the `chrome://extensions` instructions work?
- Browser binary detection under `/Applications/*.app/Contents/MacOS/…`.
- System-default detection: `defaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers` — does the parser pick the right browser? (Paste the raw output if it doesn't.)
- `pdl_launch_browser` mode `existing` (profile under `~/Library/Application Support/…`) and both sandbox modes.

**Windows**
- Browser detection under `%PROGRAMFILES%` / `%LOCALAPPDATA%`.
- System-default detection: `reg query "HKCU\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice" /v ProgId` — does the ProgId map to the right browser?
- `pdl_launch_browser` (profile under `%LOCALAPPDATA%\…\User Data`) and the HKCU native-messaging registration.

**Any OS**
- `pdl_browser_status` after restarting the host — launches now persist to `launches.json`, so previously-launched browsers should still be listed (with a fresh liveness re-probe). Confirm the list survives a restart and that closed browsers show as not-live.

The launcher never kills your running browser and sandbox modes use throwaway/dedicated profiles, so this is low-risk to try.

## Troubleshooting

### Verification sequence

When something isn't working, walk this ladder — each step localizes the failure:

1. **`pdl_check_setup`** — are all setup gaps closed? (CDP reachable, manifest installed, extension present, ID registered.)
2. **`pdl_browser_status`** — is a browser launched and is its debug port still live? Is the extension service worker connected (recent heartbeat)?
3. **`host_status`** — is the native-messaging host registered and is an NMH instance connected?
4. **`session_ping`** — does a full MCP → SW → page-world round-trip succeed on the active tab? (See the typed `pageWorldError` table below.)

### Common launcher gotchas

- **"I launched in `existing` mode but `chrome-devtools-mcp` can't attach."** Two causes. **(1)** Chrome opens `--remote-debugging-port` only at process start — if the browser was already running without it, `pdl_launch_browser` opens a new window (sub-state **b**) but can't add the port to the live process (`attached:false`). **(2)** On **Chromium 136+**, the port is refused on the *default* profile entirely, even on a fresh start. Either way: use `mode: sandbox-persistent` (a dedicated profile where the port works) for `chrome-devtools-mcp`.
- **Brave/Chrome says "Opening in existing browser session."** That's sub-state **b** — the binary handed your request to the already-running process instead of starting a fresh one with the port. Same fix as above.
- **Sandbox launch came up but pwa-debug never connects (Google Chrome 142+).** Branded Google Chrome 142+ permanently ignores `--load-extension`, so the sandbox can't auto-preload the extension — the launch says so and gives you a one-time `chrome://extensions` → Developer mode → *Load unpacked* walkthrough (it persists in that dedicated profile). Or relaunch with `browser: "brave"` / `"chromium"`, where preload works automatically. (`chrome-devtools-mcp` still works either way — the debug port is live.)
- **`chrome-devtools-mcp` opened its own blank browser instead of using yours.** Its registration is **unpinned** (no `--browserUrl`). Run `pdl_register_chrome_devtools` (or re-add with `--browserUrl http://127.0.0.1:<port>`) and restart/reconnect it; `pdl_check_setup` flags this.
- **Added the MCP server but the tools don't appear.** `.mcp.json` / client MCP config is read at startup — restart your MCP client (or `/mcp`-reconnect that server) after adding `pwa-debug` or `chrome-devtools`.
- **Tools worked, then stopped after I reloaded the extension.** Reloading the extension at `chrome://extensions` detaches content scripts from already-open tabs. Hard-refresh the page tab (Ctrl+Shift+R); the SW also auto-reinjects on the next `session_ping` (look for `pageWorldSelfHealed: true`). Sandbox modes avoid this entirely (extension preloaded before tabs open).


### `session_ping` returns `pageWorld: null` with a typed `pageWorldError`

`session_ping` reports failure modes as **typed codes** in `pageWorldError` (machine-readable) plus the original chrome-runtime string in `pageWorldErrorMessage` (for logs). The MCP `next_steps[]` field carries imperative, code-specific guidance — AI clients should relay it verbatim. Tabs that simply predated the extension reload (the most common dev-loop friction) are auto-recovered by the SW via `chrome.scripting.executeScript`; when that succeeds, `pageWorld` is populated and `pageWorldSelfHealed: true` appears alongside it. The table below is the canonical mapping (single source of truth: `NEXT_STEPS_BY_CODE` in `packages/host/src/mcp/tools/session_ping.ts`).

| `pageWorldError` | What it means | What to do |
|---|---|---|
| *(absent)* with `pageWorldSelfHealed: true` | The static content script was missing on the active tab; the SW silently re-injected `content-script.js` + `page-world.js` and retried. No action needed. | Informational only. |
| `cs_not_attached_refresh_tab` | Auto-recovery was attempted but did not stick (page rejected the injection or reloaded mid-flight). | Hard-refresh the page tab (Ctrl+Shift+R) and retry. If it repeats, reload the extension at `chrome://extensions` then hard-refresh. |
| `page_blocks_scripts` | A content blocker is rejecting the script (Brave Shields, uBlock Origin, AdGuard, or similar). Site CSP is also possible. | **Brave:** click the lion icon → set Shields **Down** for the site → refresh → retry. **uBlock Origin / similar:** disable for this site → refresh → retry. If neither, the site's own CSP is blocking and pwa-debug cannot bypass it. |
| `page_world_blocked` | The content script attached but the MAIN-world page-world bridge cannot be reached — the site's Content-Security-Policy blocks the inline script tag. | Site-level restriction; cannot bypass. Console + network capture may still work via the content-script side, but live page-world reads (state, evaluate) will not. |
| `restricted_url` | The active tab is on a URL browsers do not allow extensions to touch (`chrome://`, `chromewebstore.google.com`, `about:`, `devtools://`, `file://`, `view-source:`, etc.). | Switch focus to a regular `http(s)` tab of the PWA, then retry. |
| `no_active_tab` | No active `http(s)` tab is focused (DevTools window or extension popup may be focused instead). | Focus a regular browser tab and retry. |
| `cs_inject_failed` | The auto-recovery `chrome.scripting.executeScript` itself failed. The extension cannot reach this tab. | Reload the extension at `chrome://extensions` and hard-refresh the page (Ctrl+Shift+R). If it persists, the URL may be one the browser blocks all extensions from — check the address bar. |

To confirm the content script attached after a successful round-trip, open the page tab's DevTools (F12 on the page itself, **not** the SW console) and look for `[pwa-debug/cs] attached at <url>` in the Console.

## MCP tool surface

Every tool returns a structured response of the form `{ ok, data, error?, next_steps[] }`. The `next_steps` array encodes the rules of engagement for the AI — what to call next based on the actual response shape — mirroring the AIMFP `return_statements` pattern. The canonical list lives in `packages/host/src/mcp/tools/index.ts`.

The server also sends MCP `instructions` on connect: a short orientation the client places in the AI's system prompt (you can drive the page yourself, read state as data rather than screenshots, the startup sequence, the phone workflow). For anything deeper the AI calls `pdl_help`.

### Help

| Tool | Purpose |
|---|---|
| `pdl_help({ topic?, tool? })` | Usage guide as data. No args: capabilities, startup workflow and the topic list. `topic` (`getting_started`, `control`, `reading_state`, `frameworks`, `stores`, `pwa`, `layout`, `popups`, `recording`, `browser`, `chrome_devtools`, `mobile`, `targeting`, `settings`, `setup`, `troubleshooting`): a how-to. `tool`: that tool's description and parameters, read from the live registry. |

### Host management & setup

| Tool | Purpose |
|---|---|
| `host_status` | Install/liveness state: registered IDs, manifest paths, launcher path, active connections. Cheap, idempotent. **Always call first.** |
| `host_register_extension(id)` / `host_unregister_extension(id)` | Add/remove an extension ID across per-browser manifests + launcher script. Idempotent. |
| `host_list_registrations` / `host_reset` | Read registered IDs; destructive cleanup to re-bootstrap. |
| `session_ping` | Full MCP → IPC → NMH → SW → page-world round-trip with typed `pageWorldError` codes + self-heal. |
| `pdl_check_setup` | Diagnose setup → `{ ok, gaps[], recommendations[] }` (CDP reachable, manifest installed, extension present, ID registered). |
| `pdl_install_extension({ target? })` | Copy the extension to a folder for unpacked install. |

### Browser launcher

| Tool | Purpose |
|---|---|
| `pdl_launch_browser({ browser?, port?, mode? })` | Launch/attach a Chromium browser with a live debug port. `mode`: `existing` (default), `sandbox-persistent`, `sandbox-temp`. Returns `browserUrl` for `chrome-devtools-mcp`. |
| `pdl_browser_status` | Managed launches (browser, profile mode, port, pid) with live debug-port re-probe + extension SW heartbeat. |

### Capture, evaluate, replay

| Tool | Purpose |
|---|---|
| `console_tail` / `network_tail` / `error_tail` | Cursor-paginated, filterable tails of the persistent capture ring buffers (memory + disk spill). |
| `recent_events` | Recent captured events across kinds for quick verification. |
| `evaluate` | Evaluate an expression in the page world. |
| `session_record` / `session_replay` | rrweb session recording + cursor-paginated replay. |
| `source_map_resolve` | Resolve generated stack frames to original `src/…:line:col`. |
| `settings_list_schema` / `settings_get` / `settings_set` | Read the typed settings schema; get/set values (allowlist, capture filters, disk-spill, etc.). |

### PWA runtime diagnostics

The namesake suite — read the debugged PWA's service-worker, cache, installability, storage, and update state. All page-world reads against your **real profile**; CDP / `chrome-devtools-mcp` can't surface these.

| Tool | Purpose |
|---|---|
| `sw_status` | Service-worker registrations: installing/waiting/active versions, `updateViaCache`, controller, and whether an update is stuck **waiting** (the #1 "why isn't my update showing"). |
| `sw_lifecycle_tail` | Cursor-paginated stream of SW lifecycle events (updatefound / statechange / controllerchange). |
| `cache_list` / `cache_inspect` / `cache_match` | CacheStorage caches + per-entry `{ url, status, content-type, size, ageSeconds, cache-control }`, and which cache serves a given URL — the core of stale-cache debugging. |
| `pwa_status` | Display mode / standalone, controller, permissions, and a live capability matrix (Push / Background Sync / Periodic Sync / Badging / File System Access / Window Controls Overlay). |
| `pwa_installability` | Discover + parse the manifest and run installability checks → structured `{ supported, gaps[], fixes[] }` with per-gap remediation. |
| `storage_get` | localStorage / sessionStorage snapshot (capped). |
| `idb_list` / `idb_query` | IndexedDB databases + object-store schema, then a read-only capped slice of records — *inspect IndexedDB live*. |
| `pwa_update_analyze` | Correlates SW status + cache ages + recent 404s into a verdict: waiting-update-on-active-client, cached-HTML-older-than-JS version skew, chunk 404s — *why are some users on old code?* |
| `pwa_snapshot` | One capped runtime-state blob (SW + store + web storage + IndexedDB + cache names) for deterministic bug-repro / hand-off. |

### Responsive layout sweep

One call audits a view matrix: **pages × viewport widths × color schemes × app states** (plus any crossed dimensions such as language). For each view the host emulates the viewport and `prefers-color-scheme` over CDP on the real top-level page, applies the app state (a hash template or your own `apply` expression), settles, and measures in the page. The results are grouped into distinct problems, so 150 raw findings that are really two problems come back as two groups, with screenshots on request. Targets: a phone (`pdl_android_attach`) or a browser started by `pdl_launch_browser`. A browser you started yourself is refused, because the host needs its debug port.

| Tool | Purpose |
|---|---|
| `layout_sweep({ pages, base_url?, viewports?, color_schemes?, states?, root_selector?, checks?, custom_checks?, exempt_selectors?, screenshots?, … })` | Start a background sweep → `{ job_id, planned_views, target }`. |
| `layout_sweep_status({ job_id? })` | Progress, then groups (most frequent first, page overflow first), warnings, failed views and the on-disk report path. |
| `layout_sweep_cancel({ job_id? })` | Stop after the current view. Emulation and the starting URL are always restored. |

| Check | Default | Finds |
|---|---|---|
| `page_overflow_x` | on | The page scrolls sideways. |
| `offscreen` | on | Elements past the left/right viewport edge (scroll rows, carousels and parked drawers skipped). |
| `tap_target` | on, `min: 24` | Interactive elements under the minimum (WCAG 2.2; set `44`/`48` for platform guidelines). Inline links are exempt. |
| `pinned_visible` | on | Fixed bars not fully on screen. |
| `text_clipped` | on | Ellipsized or clipped text that needs more room (e.g. a longer language). |
| `edge_contact` | on, `min_inset: 4` | Text or controls touching an edge you can see (a padding that a parent rule zeroed). |
| `ring_clipped` | on, `min_px: 1` | Outline / box-shadow rings (focus, selection, card shadows) cut off by a scrolling or clipping parent. |
| `text_patterns` | on | Unresolved copy: `{{x}}`, `undefined`, `NaN`, `[object Object]`, plus your own patterns (e.g. raw i18n keys). |
| `errors` | on | Page errors and unhandled rejections during the view. |
| `contrast` | off | WCAG AA text contrast (approximate under gradients and images). |

Hidden content (`display: none`, closed `<details>`, collapsed wrappers, screen-reader-only text) is skipped by every check. **Your own checks:** `custom_checks: [{ name, expression }]` (up to 20) run in the page after the built-ins for every view. Each one gets `view` (page, width, scheme, state, dimensions) and a `pdl` helper object (`pdl.elements(selector)` for visible elements, plus `rect`, `style`, `px`, `name`, `edges`, `ink`, `background`). It returns elements, strings or `{ element, message }` items, which become `custom:<name>` findings, grouped and reported like the built-in ones. `pdl_help({ topic: "layout" })` has worked examples.

> **Verify findings before you fix them.** The checks are geometric heuristics over computed styles and rects. They don't see the page the way a person does, so a finding can be a false positive (an intentional full-bleed design, an unusual widget, a state the page never shows a user). Confirm each group before changing code: use `screenshots: "first_per_group"`, look at the element with `evaluate`, or check it on the device. Then re-run the sweep on the affected pages. Desktop emulation of a phone width also uses the desktop browser's fonts and scrollbars, so confirm important findings on a real phone.

### Framework introspection

Read via each framework's own model — things CDP can't see. Tree/state where the framework persists it; element-level find everywhere.

| Tool family | Purpose |
|---|---|
| `react_tree` / `react_get_state` / `react_find_by_text` / `react_find_by_role` | React fiber-tree introspection + props/state/hooks by stable id + component lookup. |
| `vue_tree` / `vue_get_state` / `vue_find_by_text` / `vue_find_by_role` | Vue 3 `ComponentInternalInstance` tree + reactive state + lookup (parity with React). |
| `svelte_components` / `svelte_find_by_text` / `svelte_find_by_role` | Svelte component discovery + `__svelte_meta` source locations + element lookup (no instance state — Svelte exposes none). |
| `solid_detect` / `solid_find_by_text` / `solid_find_by_role` | Solid detection + element-level find (no persisted tree/state without `@solid-devtools`). |

### Store introspection

| Tool family | Purpose |
|---|---|
| `redux_get_state` / `redux_subscribe` / `redux_tail` / `redux_dispatch` | Redux read, change-delta subscribe/tail, JSONPath-lite slice, (opt-in) dispatch. |
| `store_get_state` / `store_subscribe` / `store_tail` / `store_dispatch` | Framework-agnostic adapter covering **Zustand / Pinia / Jotai** through one contract (read / subscribe / dispatch by name). |

### Interaction & touch gestures

Framework-agnostic native-event sequences dispatched so React/Vue/etc. delegated synthetic-event systems fire. Each targets a unified locator (selector / role / text / framework stable-id).

| Tool | Purpose |
|---|---|
| `pdl_click` / `pdl_dblclick` / `pdl_hover` | Pointer interactions. |
| `pdl_fill` / `pdl_submit` / `pdl_select_option` / `pdl_uncheck` | Form interactions. |
| `pdl_focus` / `pdl_blur` / `pdl_key_press` / `pdl_type_sequence` | Focus + keyboard. |
| `pdl_drag` / `pdl_scroll` / `pdl_swipe` / `pdl_tap` / `pdl_double_tap` / `pdl_long_press` / `pdl_pinch` | Touch/gesture primitives (direction/distance/duration/steps model — no hand-built coordinates). |

### Android phones (adb + CDP)

| Tool | Purpose |
|---|---|
| `pdl_android_devices` | Phones over adb with authorization state, model, and DevTools sockets (Chrome, other Chromium browsers, debuggable WebViews by package). |
| `pdl_android_attach({ open_url?, url_contains?, target_id?, reload?, reverse_ports?, serial?, socket? })` | One call: find the phone, map dev-server ports, forward the browser socket, attach or open a tab. Returns the connection id to pass as `extension_id`. |
| `pdl_cdp_targets` / `pdl_cdp_attach` / `pdl_cdp_detach` | The same over any CDP endpoint you have forwarded yourself. |

### Library popups

| Tool | Purpose |
|---|---|
| `popup_tail` | Tail the library-popup event stream (WalletConnect / RainbowKit / SDK modals, incl. shadow-root + nested). |
| `popup_record` / `popup_replay` | Intent-driven recording of a popup's full primary+nested stream (immune to ring-buffer eviction) + flat/primary/tree replay. |
| `popup_failures` | Correlate a popup's auth/connect failure with the console errors + failed requests during its open window. |

## Roadmap

- **Foundation** ✅ — pnpm workspace + build pipeline; MV3 extension loads cleanly; native-messaging round-trip; AI-managed host registration; cross-platform install (Linux native + macOS + Windows registry; snap unsupported); MCP↔IPC↔NMH↔SW bridge.
- **Capture** ✅ — console / network (fetch/XHR/WebSocket) / DOM-mutation / lifecycle producers; host ring buffers with disk spill + archive pruning; filterable, cursor-paginated `console_tail` / `network_tail`.
- **Framework introspection** ✅ — **React, Vue, Svelte, Solid** (trees/state where the framework persists them, find-by-text/role everywhere); page-world `evaluate`.
- **Store introspection** ✅ — **Redux** (read/subscribe/tail/dispatch) plus a framework-agnostic adapter covering **Zustand, Pinia, Jotai**.
- **Interaction & gestures** ✅ — discrete actions (click/fill/submit/hover/focus/blur/select/key/type) + touch gestures (drag/scroll/swipe/tap/double-tap/long-press/pinch) over a unified locator.
- **Library popups** ✅ — `popup_record` / `popup_replay` / `popup_tail` / `popup_failures` for WalletConnect & SDK modals (incl. shadow-root + nested).
- **Replay & source maps** ✅ — rrweb `session_record` / `session_replay`; `source_map_resolve` for stack frames.
- **Settings** ✅ — typed schema store (allowlist/blocklist, per-kind capture filters, per-site read controls, disk-spill).
- **Browser launcher** ✅ — `pdl_launch_browser` (existing + sandbox-persistent + sandbox-temp), `pdl_check_setup`, `pdl_browser_status`, `pdl_close_browser`, `pdl_install_extension`, `pdl_register_chrome_devtools`, and `chrome-devtools-mcp` coexistence.
- **PWA Runtime Diagnostics** ✅ — the namesake suite, live-verified against a real PWA. Service-worker introspection (`sw_status` + `sw_lifecycle_tail`: installing/waiting/active versions, `updateViaCache`, skipWaiting/claim/controllerchange); CacheStorage inspection (`cache_list` / `cache_inspect` / `cache_match` with size/age + match-by-URL); `pwa_status` (display mode, controller, permissions, live capability matrix — Push/Background Sync/Periodic Sync/Badging/File System Access/Window Controls Overlay); installability diagnostics (`pwa_installability` → structured `{gaps[], fixes[]}`); IndexedDB/web-storage inspection (`idb_list` / `idb_query` / `storage_get`); update-propagation / version-skew analysis (`pwa_update_analyze`); and a one-shot runtime-state snapshot (`pwa_snapshot`). Closes the stale-cache / "SW won't update" / install-failure / version-skew pain cluster against your real profile.
- **Android phones** ✅ — `pdl_android_devices` / `pdl_android_attach` drive Android Chrome tabs and installed PWAs over adb + CDP with the full tool surface (only `recent_events` is extension-only); same capture settings; adb mappings cleaned up on shutdown. Live-verified on a Galaxy S24 Ultra (Android 16, Chrome 156).
- **Responsive layout sweep** ✅ — `layout_sweep` / `layout_sweep_status` / `layout_sweep_cancel` over phones and launched browsers: CDP viewport + color-scheme emulation, app-state and language matrices, ten built-in checks, caller `custom_checks`, grouping, per-group screenshots and an on-disk report. Live-verified on a Galaxy S24 Ultra and a launched Brave across an 83-page prototype (4812 views).
- **Later** — DevTools panel for human observation of an AI session; multi-tab routing model; event-causality graph (click → action → request → SW → cache → re-render); production-safe diagnostic mode for real-user failure capture.
- **Deferred** — Firefox port (needs WebDriver BiDi, not CDP); macOS/Windows live verification ([help wanted](#help-wanted-macos--windows-verification)); iOS Safari; hosted/team mode.
- **Intentionally not pursued** — **Chrome Web Store distribution.** The extension grants broad page access (DOM, framework state, stores, network) and is only meaningful alongside its MCP host. It ships **bundled with the MCP only** and is installed via a manual, dev-mode "Load unpacked" (`pdl_install_extension` hands you the path + steps) — so every user knows exactly what they're running and why. Disabling Chrome Developer mode auto-disables it.

## Code style

- FP-only: pure functions, immutability, no OOP, no classes-with-methods.
- Side effects (CDP calls, file I/O, native messaging, MCP transport) at the edges; core logic pure.
- OOP library interfaces (Chrome APIs, MCP SDK, `winreg`) wrapped in thin functional adapters with injection points for tests.

## Repo layout

```
pwa-debug-layer/
├── packages/
│   ├── host/                Native-messaging host + MCP server (Node, ESM, rollup-bundled)
│   ├── extension/           WebExtension (MV3) — service worker, content script, page-world
│   └── shared/              Cross-package types
├── docs/
│   ├── PLAN.md              Full design doc (architecture, capability matrix, phased plan)
│   └── goals.txt
├── examples/                (future: test PWAs)
└── reference/               Read-only reference checkouts
```

## Contributing

This is a personal project under active redesign. PRs welcome but please open an issue first to discuss scope — the architecture is still settling. The FP / no-OOP discipline applies to all contributions; see `CLAUDE.md` for the full rules.

## License

MIT — see [`LICENSE`](LICENSE).