# browsing

Adopt this skill: https://proskills.md/skills/other/obra-superpowers-chrome-skills-browsing

Page: https://proskills.md/skills/other/obra-superpowers-chrome-skills-browsing
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/browsing/SKILL.md
size: 37488
sha: 6f7f5d77f7d6070bb60645c6ef7034cd9553123a
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/SKILL.md
truncated: true

```text
---
name: browsing
description: Use when you need direct browser control - teaches Chrome DevTools Protocol for controlling existing browser sessions, multi-tab management, form automation, and content extraction via use_browser MCP tool
allowed-tools: mcp__chrome__use_browser
---

# Browsing with Chrome Direct

## Overview

Control Chrome via DevTools Protocol using the `use_browser` MCP tool. Single unified interface with auto-starting Chrome.

**Announce:** "I'm using the browsing skill to control Chrome."

## When to Use

**Use this when:**
- Controlling authenticated sessions
- Managing multiple tabs in running browser
- Playwright MCP unavailable or excessive

**Use Playwright MCP when:**
- Need fresh browser instances
- Generating screenshots/PDFs
- Prefer higher-level abstractions

## Auto-Capture

Every DOM action (navigate, click, type, select, eval, keyboard_press, hover, drag_drop, double_click, right_click, file_upload) automatically saves:
- `{prefix}.png` — viewport screenshot
- `{prefix}.md` — page content as structured markdown
- `{prefix}.html` — full rendered DOM
- `{prefix}-console.txt` — browser console messages

Files are saved to the session directory with sequential prefixes (001-navigate, 002-click, etc.). You must check these before using extract or screenshot actions.

**Pause switch (`pause_capture`/`resume_capture`):** the shape/marker/URL defenses below only catch a secret with a recognized shape, an opt-in `data-sen-secret` mark, or a known-sensitive URL. A page that reveals a secret a different way — a "show password"/"reveal" click that renders the plaintext into an ordinary, unremarkable element (no secret-looking id/name/class, no sensitive URL) — slips past all three, and the very next auto-capture writes it to disk. Call `pause_capture` immediately BEFORE any action you expect to reveal a secret this way; every auto-capture (DOM/markdown/screenshot/console/dialog) is skipped — no CDP call is even made — until you call `resume_capture`. The action that triggers the reveal still runs normally; only its capture is skipped. An explicit `screenshot` call also refuses outright while paused. Pause state lives on the session and persists across actions until you resume: forgetting to call `resume_capture` just means no more captures for the rest of the session, never a silent leak. **Caveat:** this is in-memory, process-local state — it does NOT survive the MCP server process restarting (e.g. `restart_chrome` re-adopting a still-running Chrome keeps the SAME process and stays paused, but a full MCP server restart starts a fresh `capturePaused:false`). If the server restarts while a secret is still on-screen from a paused reveal, the next auto-capture after that restart will NOT be paused — the only signal is that the pause notice stops appearing in responses. Use the credential broker to capture the value itself while paused; resume as soon as the secret is off-screen.

**Credential-shaped pages:** when a page shows a token or secret (Slack `xoxb-`/`xapp-`, GitHub `ghp_`/`github_pat_`, 1Password `ops_`/`A3-` keys, `otpauth://` seeds, or any element marked `data-sen-secret`), no files are written for that action and the response says `⚠️ Page shows credential-shaped content; auto-capture and DOM output suppressed.` with only metadata. Token-shaped values in `extract`/`eval` output are replaced by `[REDACTED credential-shaped]`, and `screenshot` refuses. Capture secrets with a credential broker. On a token-shaped page, use `eval` only for value-blind queries (e.g. "is the token field present?"). On a page with any `data-sen-secret` element, `eval` refuses outright, and `extract`/`attr` refuse for the marked element or strip it from HTML/markdown output. The marker scan covers the top document, open shadow roots, and same-origin `iframe`/`frame`/`object`/`embed`; it does not see closed shadow roots or cross-origin frames. Markup inside an `<iframe srcdoc="…">` attribute is not stripped: `extract` html and `attr srcdoc` return it verbatim.

**When to mark a secret:** mark its element with `set_attr` (`data-sen-secret`) as soon as the action that revealed it returns, before any other action on that page, then capture the value with the credential broker. Marking only affects later actions. The capture files the revealing action already wrote (e.g. `003-click.html`/`.md`) still hold the value and are not deleted, so don't read them back. This is an accident guard for a cooperating agent, not a security boundary. `SUPERPOWERS_CHROME_ALLOW_CREDENTIAL_CAPTURE=1` turns all of this off.

Separately (and unconditionally): a plain password or one-time code has no shape the check above recognizes, so a page's own handler mirroring one into an attribute, a hidden input, or visible text would otherwise land in the `.html`/`.md`/`-diff.txt` files. Whatever gets written is scrubbed on an inert clone (`document.implementation.createHTMLDocument` + `importNode`, never `cloneNode` on the live document): `value` and every `data-*`/`aria-*` attribute are stripped from `input[type="password"]` (case-insensitive; remembered on that same element after a "show password" toggle flips it to `type="text"`), any field whose `autocomplete` contains `current-password`, `new-password`, `one-time-code`, `cc-number`, or `cc-csc` (case-insensitive substring), and `data-sen-secret`-marked elements. A field with none of those signals is still caught if its own live `.value` exactly matches one of its OTHER attributes (a self-mirror) -- the case for Google's 2-step verification `totpPin` field, which has no recognized type or autocomplete but copies the typed code into `data-initial-value` itself. Self-mirror detection skips inputs nobody types into (`submit`, `button`, `reset`, `checkbox`, `radio`, `hidden`, `image`) and ignores attributes that name or label a field (`type`, `name`, `id`, `aria-label`, `title`, `placeholder`, `for`, `class`). The live `.value` of every field found any of these ways, plus its HTML-entity-escaped forms (including `&nbsp;`), is also replaced with `[REDACTED]` wherever it occurs in the output -- any attribute, on any element, not just the field's own `value` -- if it is at least 4 characters long (3 for `cc-csc`). Dialog captures are redacted with the values from the most recent page capture; a value typed in the same action that opened the dialog is not. Redaction is a plain string replace, so a secret that also appears as ordinary page text (a password of `password`) replaces that text too; the artifact is degraded but nothing leaks. Self-mirror detection can also flag a non-secret typeable field whose value happens to equal one of its other attributes (a search box synced into `data-query`), with the same effect. The live page is never touched.

**Known gaps (the secret is written to disk in clear):** split one-digit OTP boxes feeding an aggregate hidden input (each digit is below the length floor, so the aggregate leaks); a field cleared on submit whose mirror remains; a "show password" toggle that swaps in a new element instead of changing `type`; a self-mirrored value shorter than the length floor (the field is still found, but its value is too short to substring-redact). Screenshots are pixels, not scrubbed text: a visible (`type="text"`) typed value can appear in a `.png`.

**Default secret-pattern detection (no marking, no click required):** everything above needs a recognized type/autocomplete, or a value already mirrored somewhere -- neither helps a secret already in the DOM at page load, before any action (and so before any marking) has run. Real case: Slack's 2FA setup page puts the TOTP seed in a hidden `#init_key_code` element from load, so the very first navigate's auto-capture would otherwise leak it. Any element whose `id`/`name`/`class`/`autocomplete`/`aria-label` contains `secret`, `totp`, `otp`, `2fa`, `mfa`, `key_code`, `seed`, `recovery`, `backup_code`, `api_key`, or `token` as a WORD (split on `-`/`_`/cam
```

## File: skills/browsing/README.md
size: 1563
sha: 4cfa837b825bd9ea9c1851019529e82b8fe24b4a
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/README.md

````text
# chrome-ws

Chrome DevTools Protocol CLI - zero dependencies.

## Installation

```bash
chmod +x chrome-ws
```

## Quick Test

```bash
./chrome-ws start                        # Launch Chrome (auto-detects platform)
./chrome-ws new "https://example.com"   # Create tab
./chrome-ws navigate 0 "https://example.com"
./chrome-ws extract 0 "h1"              # Extract heading
```

## Documentation

- `SKILL.md` - Complete usage guide
- `EXAMPLES.md` - Real-world examples

## Testing

```bash
./test-raw.sh       # Raw JSON-RPC
./test-tabs.sh      # Tab management
./test-navigate.sh  # Navigation
./test-interact.sh  # Form interaction
./test-extract.sh   # Content extraction
./test-wait.sh      # Wait commands
./test-e2e.sh       # End-to-end workflow
```

## Requirements

- Node.js 16+
- Chrome with `--remote-debugging-port=9222`

## Environment Variables

| Variable | Purpose |
|---|---|
| `CHROME_WS_BROWSER` | Path to browser executable (overrides auto-detection) |
| `CHROME_WS_HOST` | Debug host address (default `127.0.0.1`) |
| `CHROME_WS_PORT` | Debug port (default `9222`) |
| `CHROME_WS_PROFILE` | Profile name. Default `superpowers-chrome` with auto-disambiguation when contended; set explicitly to share a Chrome across processes. |
| `CHROME_EXTRA_ARGS` | Whitespace-separated extra flags appended to the Chrome command line. |

## Windows Notes

- The CLI now binds to `127.0.0.1:9222` by default to avoid name-resolution issues on Windows.
- Customize the connection via `CHROME_WS_HOST` / `CHROME_WS_PORT` if you forward DevTools elsewhere.
````

## File: skills/browsing/.gitignore
size: 95
sha: e0a9879414b0feb75cb0d7e7a4db7109a2ae688b
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/.gitignore

```text
node_modules/
package-lock.json
IMPLEMENTATION_SUMMARY.md
TEST_RESULTS.md
test-*.png
test-*.md
```

## File: skills/browsing/chrome-ws
size: 31118
sha: d8465f218d5f8d1edb213ab054ac6ab512b971d9
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/chrome-ws

```text
#!/usr/bin/env node

const process = require('process');

// Parse --port=N flag from anywhere in argv (filter it out of positional args)
const allArgs = process.argv.slice(2);
const portArg = allArgs.find(a => a.startsWith('--port='));
const positionalArgs = allArgs.filter(a => !a.startsWith('--port='));
const [command, wsUrlOrIndex, ...args] = positionalArgs;

// Handle --help and --version before any other processing
if (command === '--help' || command === '-h' || !command) {
  console.log(`Usage: chrome-ws <command> [args]

Commands:
  start [port]                                  Start Chrome with remote debugging
  stop                                           Kill Chrome
  pid                                            Print Chrome PID
  info                                           Print Chrome info (JSON)
  tabs                                           List open tabs
  new <url>                                      Open a new tab
  close <tab>                                    Close a tab
  navigate <tab> <url>                           Navigate tab to URL
  extract <tab> <selector>                       Extract element text content
  attr <tab> <selector> <attribute>             Get element attribute
  html <tab> [selector]                         Get HTML content
  click <tab> <selector>                         Click an element
  fill <tab> <selector> <text>                   Fill an input field
  select <tab> <selector> <value>               Select a dropdown option
  eval <tab> <js>                                Evaluate JavaScript
  wait-for <tab> <selector> [timeout-ms]        Wait for element to appear
  wait-text <tab> <text> [timeout-ms]           Wait for text to appear
  screenshot <tab> <filename.png> [--fullpage]  Take a screenshot
  markdown <tab> <filename.md>                  Save page as markdown
  har <tab> <filename.har>                      Export HAR (after har-start)
  raw <ws-url> <json-rpc-payload>               Send raw CDP command

  --help, -h                                     Show this help
  --version, -v                                  Show version
  --port=N                                       Override CHROME_WS_PORT env var

Tab arg: numeric index (0, 1, 2...) or full ws:// URL.
`);
  process.exit(0);
}
if (command === '--version' || command === '-v') {
  const pkg = require('../../package.json');
  console.log(pkg.version);
  process.exit(0);
}

const hostOverride = require('./host-override').createOverride();
const { createSession } = require('./chrome-ws-lib');
const { buildChromeArgs } = require('./lib/chrome-launcher-helpers');
const CHROME_DEBUG_HOST = hostOverride.getHost();
const CHROME_DEBUG_PORT = hostOverride.getPort();
const WS_OVERRIDE_ENABLED = hostOverride.isOverrideEnabled();
const rewriteWsUrl = hostOverride.rewriteWsUrl;

// Effective port: --port=N flag overrides CHROME_WS_PORT env / default 9222
const effectivePort = portArg ? parseInt(portArg.split('=')[1], 10) : CHROME_DEBUG_PORT;

// Session pointed at the effective port. Built after effectivePort is known
// so the lib's pooled connections target the right Chrome instance.
const session = createSession({ host: CHROME_DEBUG_HOST, port: effectivePort });

// Minimal WebSocket client implementation (dependency-free)
class WebSocketClient {
  constructor(url) {
    this.url = new URL(url);
    this.callbacks = {};
    this.socket = null;
    this.buffer = Buffer.alloc(0);
  }

  on(event, callback) {
    this.callbacks[event] = callback;
  }

  connect() {
    return new Promise((resolve, reject) => {
      const http = require('http');
      const crypto = require('crypto');

      const key = crypto.randomBytes(16).toString('base64');

      const options = {
        hostname: this.url.hostname,
        port: this.url.port || 80,
        path: this.url.pathname + this.url.search,
        headers: {
          'Upgrade': 'websocket',
          'Connection': 'Upgrade',
          'Sec-WebSocket-Key': key,
          'Sec-WebSocket-Version': '13'
        }
      };

      const req = http.request(options);

      req.on('upgrade', (res, socket) => {
        this.socket = socket;

        socket.on('data', (data) => {
          this.buffer = Buffer.concat([this.buffer, data]);
          this.processFrames();
        });

        socket.on('error', (err) => {
          if (this.callbacks.error) this.callbacks.error(err);
        });

        if (this.callbacks.open) this.callbacks.open();
        resolve();
      });

      req.on('error', reject);
      req.end();
    });
  }

  processFrames() {
    while (this.buffer.length >= 2) {
      const firstByte = this.buffer[0];
      const secondByte = this.buffer[1];

      const fin = (firstByte & 0x80) !== 0;
      const opcode = firstByte & 0x0F;
      const masked = (secondByte & 0x80) !== 0;
      let payloadLen = secondByte & 0x7F;

      let offset = 2;

      if (payloadLen === 126) {
        if (this.buffer.length < 4) return;
        payloadLen = this.buffer.readUInt16BE(2);
        offset = 4;
      } else if (payloadLen === 127) {
        if (this.buffer.length < 10) return;
        payloadLen = Number(this.buffer.readBigUInt64BE(2));
        offset = 10;
      }

      if (this.buffer.length < offset + payloadLen) return;

      let payload = this.buffer.slice(offset, offset + payloadLen);
      this.buffer = this.buffer.slice(offset + payloadLen);

      if (opcode === 0x1 && this.callbacks.message) {
        this.callbacks.message(payload.toString('utf8'));
      }
    }
  }

  send(data) {
    const payload = Buffer.from(data, 'utf8');
    const payloadLen = payload.length;

    let frame;
    let offset = 2;

    if (payloadLen < 126) {
      frame = Buffer.alloc(payloadLen + 6);
      frame[1] = payloadLen | 0x80;
    } else if (payloadLen < 65536) {
      frame = Buffer.alloc(payloadLen + 8);
      frame[1] = 126 | 0x80;
      frame.writeUInt16BE(payloadLen, 2);
      offset = 4;
    } else {
      frame = Buffer.alloc(payloadLen + 14);
      frame[1] = 127 | 0x80;
      frame.writeBigUInt64BE(BigInt(payloadLen), 2);
      offset = 10;
    }

    frame[0] = 0x81; // FIN + text frame

    const mask = Buffer.alloc(4);
    require('crypto').randomFillSync(mask);
    mask.copy(frame, offset);
    offset += 4;

    for (let i = 0; i < payloadLen; i++) {
      frame[offset + i] = payload[i] ^ mask[i % 4];
    }

    this.socket.write(frame);
  }

  close() {
    if (this.socket) {
      this.socket.end();
      this.socket = null;
    }
  }
}

// Helper to convert string tab specifier to the type expected by session methods.
// session.fill/evaluate/etc. use getPageSession which accepts a number (index) or
// a ws:// string — but NOT a numeric string like "0".
function resolveTabArg(wsUrlOrIndex) {
  if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) {
    return wsUrlOrIndex; // Already a ws URL string
  }
  const index = parseInt(wsUrlOrIndex, 10);
  if (!isNaN(index)) {
    return index; // Numeric tab index as a number
  }
  throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
}

// Helper to resolve tab index or ws URL to actual ws URL
async function resolveWsUrl(wsUrlOrIndex) {
  // If it's already a WebSocket URL, return it
  if (wsUrlOrIndex && wsUrlOrIndex.startsWith('ws://')) {
    return wsUrlOrIndex;
  }

  // If it's a number (tab index), resolve it
  const index = parseInt(wsUrlOrIndex);
  if (!isNaN(index)) {
    const tabs = await chromeHttp('/json');
    const pageTabs = Array.isArray(tabs)
      ? tabs
          .filter(t => t.type === 'page')
          .map(tab => WS_OVERRIDE_ENABLED
            ? { ...tab, webSocketDebuggerUrl: rewriteWsUrl(tab.webSocketDebuggerUrl) }
            : tab
          )
      : [];

    // Auto-create tab if none exist (similar to auto-start Chrome behavior)
    if (pageTabs.length === 0) {
      const newTabInfo = await chromeHttp('/json/new?about:blank', 'PUT');
      return WS_OVERRIDE_ENABLED ? rewriteWsUrl(newTabInfo.webSocketDebuggerUrl) : newTabInfo.webSocketDebuggerUrl;
    }

    if (index < 0 || index >= pageTabs.length) {
      throw new Error(`Tab index ${index} out of range (0-${pageTabs.length - 1})`);
    }
    return WS_OVERRIDE_ENABLED ? rewriteWsUrl(pageTabs[index].webSocketDebuggerUrl) : pageTabs[index].webSocketDebuggerUrl;
  }

  throw new Error(`Invalid tab specifier: ${wsUrlOrIndex}`);
}

// Helper to make HTTP requests to Chrome on the effective port
async function chromeHttp(path, method = 'GET') {
  const http = require('http');

  return new Promise((resolve, reject) => {
    const options = {
      hostname: CHROME_DEBUG_HOST,
      port: effectivePort,
      path,
      method: method
    };

    const req = http.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        if (!data) {
          resolve({});
          return;
        }
        try {
          resolve(JSON.parse(data));
        } catch (e) {
          // Some endpoints return plain text (e.g., "Target is closing")
          resolve({ message: data });
        }
      });
    });

    req.on('error', reject);
    req.end();
  });
}

// Command: start - launch Chrome with remote debugging
if (command === 'start') {
  const { spawn } = require('child_process');
  const { existsSync } = require('fs');
  const os = require('os');

  // Platform-specific Chrome paths
  const chromePaths = {
    darwin: [
      '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
      '/Applications/Chromium.app/Contents/MacOS/Chromium'
    ],
    linux: [
      '/usr/bin/google-chrome',
      '/usr/bin/google-chrome-stable',
      '/usr/bin/chromium',
      '/usr/bin/chromium-browser'
    ],
    win32: [
      'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
      'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
      'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
      'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe'
    ]
  };

  const platform = os.platform();
  const paths = chromePaths[platform];

  if (!paths) {
    console.error(`Unsupported platform: ${platform}`);
    process.exit(1);
  }

  // Find Chrome executable (CHROME_WS_BROWSER env var overrides auto-detection)
  const chromePath = process.env.CHROME_WS_BROWSER || paths.find(p => existsSync(p));

  if (!chromePath) {
    console.error('Chrome not found. Searched:');
    paths.forEach(p => console.error(`  ${p}`));
    process.exit(1);
  }

  // Launch Chrome
  const userDataDir = platform === 'win32'
    ? 'C:\\temp\\chrome-debug'
    : '/tmp/chrome-debug';

  const chromeArgs = buildChromeArgs({
    chosenPort: effectivePort,
    chromeUserDataDir: userDataDir,
    chromeHeadless: false,
  });

  console.log(`Starting Chrome: ${chromePath}`);

  // Capture Chrome's stderr to a tempfile (issue #35): with stdio 'ignore',
  // Chrome's own complaint (sandbox, missing libs, profile lock) vanished and
  // every launch failure looked like an ambiguous port timeout.
  const { openSync, closeSync, readFileSync: readFile, unlinkSync } = require('fs');
  const pathMod = require('path');
  const stderrLogPath = pathMod.join(os.tmpdir(), `chrome-ws-start-${process.pid}.log`);
  const stderrFd = openSync(stderrLogPath, 'w');

  const chrome = spawn(chromePath, chromeArgs, {
    detached: true,
    stdio: ['ignore', 'ignore', stderrFd]
  });

  // Race Chrome's exit against the port poll so "Chrome died at launch" is
  // reported as that, not as "debug port not accessible".
  let exited = null;
  chrome.on('exit', (code, signal) => { exited = { code, signal }; });
  chrome.on('error', (err) => {
    console.error(`Failed to launch Chrome (${chromePath}): ${err.message}`);
    process.exit(1);
  });
  chrome.unref();
  closeSync(stderrFd); // the child keeps its own descriptor

  const stderrTail = () => {
    try {
      const lines = readFile(stderrLogPath, 'utf8').trim().split('\n');
      return lines.slice(-20).join('\n').trim();
    } catch (_e) {
      return '';
    }
  };

  const debugBase = `http://${CHROME_DEBUG_HOST}:${effectivePort}`;

  // Wait and verify
  setTimeout(async () => {
    try {
      const version = await chromeHttp('/json/version');
      console.log(`Chrome started: ${version.Browser}`);
      console.log(`Remote debugging: ${debugBase}`);
      try { unlinkSync(stderrLogPath); } catch (_e) { /* best-effort */ }
    } catch (e) {
      if (exited) {
        const how = exited.signal ? `signal ${exited.signal}` : `code ${exited.code}`;
        console.error(`Chrome exited with ${how} before opening the debug port`);
      } else {
        console.error(`Chrome is running but the debug port at ${debugBase} is not responding`);
        console.error(`Try: curl ${debugBase}/json/version`);
      }
      const tail = stderrTail();
      if (tail) {
        console.error(`Chrome stderr (full log: ${stderrLogPath}):`);
        console.error(tail);
      }
      process.exit(1);
    }
  }, 2000);

  return;
}

// Command: stop - kill the Chrome process this session manages
if (command === 'stop') {
  (async () => {
    try {
      await session.killChrome();
      console.log('Chrome stopped');
    } catch (e) {
      console.error('Failed to stop Chrome:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: pid - print Chrome PID (for X11 window management, etc.)
if (command === 'pid') {
  const pid = session.getChromePid();
  if (pid === null) {
    console.error('Chrome is not running (started via MCP). PID is only available when Chrome was started in this process.');
    process.exit(1);
  }
  console.log(pid);
  return;
}

// Command: info - print Chrome info as JSON (pid, mode, profile, port)
if (command === 'info') {
  (async () => {
    try {
      const mode = await session.getBrowserMode();
      // Also try to get PID from meta.json if available
      const meta = session.readProfileMeta ? session.readProfileMeta(mode.profile) : null;
      const info = {
        pid: meta ? meta.pid : mode.pid,
        port: meta ? meta.port : mode.port,
        mode: meta ? (meta.headless ? 'headless' : 'headed') : mode.mode,
        profile: mode.profile,
        profileDir: mode.profileDir,
        running: meta !== null
      };
      console.log(JSON.stringify(info, null, 2));
    } catch (e) {
      console.error('Failed to get Chrome info:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: tabs - list all tabs
if (command === 'tabs') {
  (async () => {
    try {
      const tabs = await chromeHttp('/json');
      tabs.forEach(tab => {
        if (tab.type === 'page') {
          console.log(`${tab.id}\t${tab.url}\t${tab.title}`);
        }
      });
    } catch (e) {
      console.error('Failed to list tabs:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: new - create new tab
if (command === 'new') {
  // For this command, wsUrlOrIndex variable contains the URL parameter
  if (!wsUrlOrIndex) {
    console.error('Usage: chrome-ws new <url>');
    process.exit(1);
  }
  const url = wsUrlOrIndex;
  (async () => {
    try {
      const encoded = encodeURIComponent(url);
      const tab = await chromeHttp(`/json/new?${encoded}`, 'PUT');
      const wsUrl = WS_OVERRIDE_ENABLED ? rewriteWsUrl(tab.webSocketDebuggerUrl) : tab.webSocketDebuggerUrl;
      console.log(wsUrl);
    } catch (e) {
      console.error('Failed to create tab:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: close - close tab by ws URL or numeric index
if (command === 'close') {
  if (!wsUrlOrIndex) {
    console.error('Usage: chrome-ws close <tab>');
    process.exit(1);
  }
  (async () => {
    try {
      const tabWsUrl = await resolveWsUrl(wsUrlOrIndex);
      // Extract tab ID from ws URL
      const match = tabWsUrl.match(/\/devtools\/page\/([A-F0-9-]+)/i);
      if (!match) {
        console.error('Invalid WebSocket URL');
        process.exit(1);
      }
      await chromeHttp(`/json/close/${match[1]}`);
      console.log('Tab closed');
    } catch (e) {
      console.error('Failed to close tab:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Helper to send CDP command via WebSocket
async function sendCdpCommand(wsUrl, method, params = {}) {
  return new Promise(async (resolve, reject) => {
    const ws = new WebSocketClient(wsUrl);
    const id = Math.floor(Math.random() * 1000000);

    const timeout = setTimeout(() => {
      ws.close();
      reject(new Error('Timeout after 30s'));
    }, 30000);

    ws.on('message', (data) => {
      const response = JSON.parse(data);
      if (response.id === id) {
        clearTimeout(timeout);
        if (response.error) {
          ws.close();
          reject(new Error(response.error.message));
        } else {
          ws.close();
          resolve(response.result);
        }
      }
    });

    ws.on('error', (err) => {
      clearTimeout(timeout);
      reject(err);
    });

    try {
      await ws.connect();
      ws.send(JSON.stringify({ id, method, params }));
    } catch (err) {
      clearTimeout(timeout);
      reject(err);
    }
  });
}

// Command: navigate
if (command === 'navigate') {
  const [url] = args;
  if (!wsUrlOrIndex || !url) {
    console.error('Usage: chrome-ws navigate <tab-index-or-ws-url> <url>');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);
      await sendCdpCommand(wsUrl, 'Page.navigate', { url });
      console.log(`Navigated to ${url}`);
    } catch (e) {
      console.error('Navigation failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: wait-for - wait for selector to appear
if (command === 'wait-for') {
  const [selector, timeoutArg] = args;
  if (!wsUrlOrIndex || !selector) {
    console.error('Usage: chrome-ws wait-for <tab-index-or-ws-url> <selector> [timeout-ms]');
    process.exit(1);
  }
  const timeout = timeoutArg ? parseInt(timeoutArg, 10) : 5000;
  if (Number.isNaN(timeout) || timeout < 0) {
    console.error(`Invalid timeout: ${timeoutArg}`);
    process.exit(1);
  }
  (async () => {
    try {
      await session.waitForElement(resolveTabArg(wsUrlOrIndex), selector, timeout);
      console.log(`Element found: ${selector}`);
      process.exit(0);
    } catch (e) {
      console.error('Wait failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: click
if (command === 'click') {
  const [selector] = args;
  if (!wsUrlOrIndex || !selector) {
    console.error('Usage: chrome-ws click <tab-index-or-ws-url> <selector>');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);
      const js = `document.querySelector(${JSON.stringify(selector)}).click()`;
      await sendCdpCommand(wsUrl, 'Runtime.evaluate', { expression: js });
      console.log(`Clicked: ${selector}`);
    } catch (e) {
      console.error('Click failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: fill
if (command === 'fill') {
  const [selector, value] = args;
  if (!wsUrlOrIndex || !selector || value === undefined) {
    console.error('Usage: chrome-ws fill <tab-index-or-ws-url> <selector> <value>');
    process.exit(1);
  }
  (async () => {
    try {
      await session.fill(resolveTabArg(wsUrlOrIndex), selector, value);
      console.log(`Filled: ${selector}`);
      process.exit(0);
    } catch (e) {
      console.error('Fill failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: select - select dropdown option
if (command === 'select') {
  const [selector, value] = args;
  if (!wsUrlOrIndex || !selector || value === undefined) {
    console.error('Usage: chrome-ws select <tab-index-or-ws-url> <selector> <value-or-label-or-json-array>');
    process.exit(1);
  }
  (async () => {
    try {
      // Accept JSON array (multi-select) or plain string (value or label).
      let selectValue = value;
      if (typeof value === 'string' && value.trim().startsWith('[')) {
        try {
          const parsed = JSON.parse(value);
          if (Array.isArray(parsed) && parsed.every(v => typeof v === 'string')) {
            selectValue = parsed;
          }
        } catch (_e) { /* not JSON, treat as plain string */ }
      }
      const result = await session.selectOption(resolveTabArg(wsUrlOrIndex), selector, selectValue);
      console.log(JSON.stringify(result.matched.map(o => o.value)));
      process.exit(0);
    } catch (e) {
      console.error('Select failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: eval - evaluate JavaScript
if (command === 'eval') {
  const expression = args.join(' ');
  if (!wsUrlOrIndex || !expression) {
    console.error('Usage: chrome-ws eval <tab-index-or-ws-url> <js-expression>');
    process.exit(1);
  }
  (async () => {
    try {
      const value = await session.evaluate(resolveTabArg(wsUrlOrIndex), expression);
      console.log(JSON.stringify(value, null, 2));
      process.exit(0);
    } catch (e) {
      console.error('Eval failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: extract - get element text content
if (command === 'extract') {
  const [selector] = args;
  if (!wsUrlOrIndex || !selector) {
    console.error('Usage: chrome-ws extract <tab-index-or-ws-url> <selector>');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);
      const js = `document.querySelector(${JSON.stringify(selector)})?.textContent`;
      const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
        expression: js,
        returnByValue: true
      });
      console.log(result.result.value);
    } catch (e) {
      console.error('Extract failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: attr - get element attribute
if (command === 'attr') {
  const [selector, attrName] = args;
  if (!wsUrlOrIndex || !selector || !attrName) {
    console.error('Usage: chrome-ws attr <tab-index-or-ws-url> <selector> <attribute>');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);
      const js = `document.querySelector(${JSON.stringify(selector)})?.getAttribute(${JSON.stringify(attrName)})`;
      const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
        expression: js,
        returnByValue: true
      });
      console.log(result.result.value);
    } catch (e) {
      console.error('Attr failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: html - get HTML content
if (command === 'html') {
  const [selector] = args;
  if (!wsUrlOrIndex) {
    console.error('Usage: chrome-ws html <tab-index-or-ws-url> [selector]');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);
      const js = selector
        ? `document.querySelector(${JSON.stringify(selector)})?.innerHTML`
        : 'document.documentElement.outerHTML';
      const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
        expression: js,
        returnByValue: true
      });
      console.log(result.result.value);
    } catch (e) {
      console.error('HTML failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: wait-text - wait for text to appear
if (command === 'wait-text') {
  // Last positional arg is treated as timeout if it parses as a non-negative
  // integer; otherwise everything is text. This handles both:
  //   wait-text 0 "the text" 3000
  //   wait-text 0 "text without timeout"
  if (!wsUrlOrIndex || args.length === 0) {
    console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]');
    process.exit(1);
  }
  let textArgs = args;
  let timeout = 5000;
  const last = args[args.length - 1];
  const parsedLast = parseInt(last, 10);
  if (args.length >= 2 && Number.isFinite(parsedLast) && parsedLast >= 0 && String(parsedLast) === last.trim()) {
    timeout = parsedLast;
    textArgs = args.slice(0, -1);
  }
  const text = textArgs.join(' ');
  if (!text) {
    console.error('Usage: chrome-ws wait-text <tab-index-or-ws-url> <text> [timeout-ms]');
    process.exit(1);
  }
  (async () => {
    try {
      await session.waitForText(resolveTabArg(wsUrlOrIndex), text, timeout);
      console.log(`Text found: ${text}`);
      process.exit(0);
    } catch (e) {
      console.error('Wait failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: screenshot - capture screenshot
if (command === 'screenshot') {
  const fullPage = args.includes('--fullpage');
  const cleanArgs = args.filter(a => a !== '--fullpage');
  const [filename] = cleanArgs;
  if (!wsUrlOrIndex || !filename) {
    console.error('Usage: chrome-ws screenshot <tab-index-or-ws-url> <filename.png> [--fullpage]');
    process.exit(1);
  }
  (async () => {
    try {
      const savedPath = await session.screenshot(resolveTabArg(wsUrlOrIndex), filename, null, fullPage);
      console.log(`Screenshot saved to ${savedPath}`);
      process.exit(0);
    } catch (e) {
      console.error('Screenshot failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: markdown - save page as markdown
if (command === 'markdown') {
  const [filename] = args;
  if (!wsUrlOrIndex || !filename) {
    console.error('Usage: chrome-ws markdown <tab-index-or-ws-url> <filename.md>');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);

      // Extract page content intelligently
      const js = `
        (() => {
          const title = document.title;
          const url = window.location.href;

          // Get main content (try article, main, or body)
          let content = document.querySelector('article') ||
                       document.querySelector('main') ||
                       document.body;

          // Convert to markdown-ish text
          function nodeToMarkdown(node, level = 0) {
            let md = '';

            if (node.nodeType === Node.TEXT_NODE) {
              const text = node.textContent.trim();
              return text ? text + ' ' : '';
            }

            if (node.nodeType !== Node.ELEMENT_NODE) return '';

            const tag = node.tagName.toLowerCase();

            // Headers
            if (/^h[1-6]$/.test(tag)) {
              const hLevel = parseInt(tag[1]);
              md += '\\n' + '#'.repeat(hLevel) + ' ' + node.textContent.trim() + '\\n\\n';
              return md;
            }

            // Paragraphs
            if (tag === 'p') {
              md += node.textContent.trim() + '\\n\\n';
              return md;
            }

            // Links
            if (tag === 'a') {
              const href = node.getAttribute('href') || '';
              const text = node.textContent.trim();
              return \`[\${text}](\${href}) \`;
            }

            // Lists
            if (tag === 'li') {
              return '- ' + node.textContent.trim() + '\\n';
            }

            // Code
            if (tag === 'code' || tag === 'pre') {
              return '\`' + node.textContent.trim() + '\` ';
            }

            // Recurse for other elements
            for (const child of node.childNodes) {
              md += nodeToMarkdown(child, level + 1);
            }

            if (tag === 'div' || tag === 'section') md += '\\n';

            return md;
          }

          const markdown = nodeToMarkdown(content);

          return \`# \${title}\\n\\nSource: \${url}\\n\\n\${markdown}\`;
        })()
      `;

      const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
        expression: js,
        returnByValue: true
      });

      const fs = require('fs');
      fs.writeFileSync(filename, result.result.value);
      console.log(`Markdown saved to ${filename}`);
    } catch (e) {
      console.error('Markdown conversion failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Command: har - save network traffic as HAR
if (command === 'har') {
  const [filename] = args;
  if (!wsUrlOrIndex || !filename) {
    console.error('Usage: chrome-ws har <tab-index-or-ws-url> <filename.har>');
    console.error('Note: Start recording with "chrome-ws har-start <tab>" first');
    process.exit(1);
  }
  (async () => {
    try {
      const wsUrl = await resolveWsUrl(wsUrlOrIndex);

      // Get HAR data
      const js = `window.__chrome_ws_har__ || []`;
      const result = await sendCdpCommand(wsUrl, 'Runtime.evaluate', {
        expression: js,
        returnByValue: true
      });

      const har = {
        log: {
          version: '1.2',
          creator: { name: 'chrome-ws', version: '1.0.0' },
          entries: result.result.value || []
        }
      };

      const fs = require('fs');
      fs.writeFileSync(filename, JSON.stringify(har, null, 2));
      console.log(`HAR saved to ${filename} (${har.log.entries.length} entries)`);
    } catch (e) {
      console.error('HAR export failed:', e.message);
      process.exit(1);
    }
  })();
  return;
}

// Past all the named-command dispatches without a return → either it's the
// raw escape hatch or it's a typo. Separate those cases so users get an
// actionable error instead of a confusing "Usage: chrome-ws raw ..." banner.
if (command !== 'raw') {
  console.error(`Unknown command: ${command}`);
  console.error(`Run 'chrome-ws --help' for the list of commands.`);
  process.exit(1);
}

if (!wsUrlOrIndex || args.length === 0) {
  console.error('Usage: chrome-ws raw <tab-index-or-ws-url> <json-rpc-payload>');
  process.exit(1);
}

const payload = args.join(' ');
let message;
try {
  message = JSON.parse(payload);
} catch (e) {
  console.error('Invalid JSON payload:', e.message);
  process.exit(1);
}

// For raw command, wsUrlOrIndex must be a full WebSocket URL (not an index)
// since this is the low-level escape hatch
if (!wsUrlOrIndex.startsWith('ws://')) {
  console.error('raw command requires full WebSocket URL, not tab index');
  console.error('Use: chrome-ws tabs  # to get WebSocket URLs');
  process.exit(1);
}

(async () => {
  const ws = new WebSocketClient(wsUrlOrIndex);

  const timeout = setTimeout(() => {
    console.error('Timeout after 30s');
    ws.close();
    process.exit(1);
  }, 30000);

  ws.on('message', (data) => {
    const response = JSON.parse(data);
    if (response.id === message.id) {
      clearTimeout(timeout);
      console.log(JSON.stringify(response, null, 2));
      ws.close();
      process.exit(0);
    }
  });

  ws.on('error', (error) => {
    clearTimeout(timeout);
    console.error('WebSocket error:', error.message);
    process.exit(1);
  });

  try {
    await ws.connect();
    ws.send(JSON.stringify(message));
  } catch (err) {
    clearTimeout(timeout);
    console.error('Connection failed:', err.message);
    process.exit(1);
  }
})();
```

## File: skills/browsing/chrome-ws-lib.js
size: 19391
sha: 07650226d27da32b8b16bbb1927e7207357ef5c6
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/chrome-ws-lib.js

```text
/**
 * Chrome WebSocket Library - Core CDP automation functions
 * Used by both CLI and MCP server
 *
 * Fixes implemented:
 * - JRV-130: Connection pooling for persistent focus
 * - JRV-127: keyboard_press action for special keys
 * - JRV-123: React-compatible input via Input.insertText
 * - JRV-124: React-compatible click via Input.dispatchMouseEvent
 * - JRV-125: Tab key handling (via keyboard_press)
 * - JRV-126: Better eval return handling
 * - JRV-128: SPA navigation support
 * - JRV-129: Multi-element selector warnings
 */

const { getElementSelector } = require('./lib/element-selector');
const { KEY_DEFINITIONS } = require('./lib/key-definitions');
const { generateHtmlDiff } = require('./lib/html-diff');
const { createState } = require('./lib/session-state');
const { attachCookies } = require('./lib/cookies');
const { attachViewport } = require('./lib/viewport');
const { attachEvaluation } = require('./lib/evaluation');
const { attachMouse } = require('./lib/mouse');
const { attachChromeProcess } = require('./lib/chrome-process');
const { attachCapture } = require('./lib/capture');
const { attachNavigation } = require('./lib/navigation');
const { attachKeyboardInput } = require('./lib/keyboard-input');
const { attachExtraction } = require('./lib/extraction');
const { attachScreenshot } = require('./lib/screenshot');
const { attachTabs, createPageSessionResolver } = require('./lib/tabs');
const { createBrowserSession } = require('./lib/browser-session');
const { attachBrowserBridge } = require('./lib/browser-bridge');
const { attachFileUpload } = require('./lib/file-upload');
const { attachConsoleLogging } = require('./lib/console-logging');
const { attachSelectOption } = require('./lib/select-option');
const { attachSetAttribute } = require('./lib/set-attribute');
const { attachDialogs, DialogRefusedError } = require('./lib/dialogs');
const { renderSyntheticArtifacts } = require('./lib/dialogs-render');
const {
  getXdgCacheHome,
  getChromeProfileDir,
  getProfileMetaPath,
  readProfileMeta,
  writeProfileMeta,
  clearProfileMeta,
  findAvailablePort,
  buildChromeArgs,
} = require('./lib/chrome-launcher-helpers');

/**
 * Session methods whose CDP work targets the page (tab) target.
 * When a native browser dialog is open, these methods will wedge waiting for a
 * CDP response that never arrives because the dialog blocks the JS runtime.
 * The session-boundary wrapper below refuses them with a descriptive error
 * rather than hanging until timeout.
 *
 * Browser-target methods (getTabs, newTab, closeTab, startChrome, …) are NOT
 * listed here — they route through the browser target and work fine while a
 * dialog is open.
 */
const PAGE_TARGET_SESSION_METHODS = new Set([
  'navigate',
  'back',
  'forward',
  'click',
  'fill',
  'selectOption',
  'evaluate',
  'extractText',
  'getHtml',
  'getSanitizedHtml',
  'getAttribute',
  'setAttribute',
  'extractPageText',
  'waitForElement',
  'waitForText',
  'screenshot',
  'hover',
  'drag',
  'mouseMove',
  'scroll',
  'doubleClick',
  'rightClick',
  'humanType',
  'fileUpload',
  'keyboardPress',
  'clickWithCapture',
  'fillWithCapture',
  'selectOptionWithCapture',
  'evaluateWithCapture',
  'setAttributeWithCapture',
  'screenshotUnlessCredentialShaped',
  'pageTextReadRefused',
  // captureActionWithDiff is intentionally excluded: it is a meta-wrapper whose
  // second arg is an action-type string ('type', 'click', …), not a selector.
  // The inner actions it wraps (humanType, click, hover, etc.) are individually
  // listed above and each have their own dialog gating via
  // withDialogAwarenessForSession in capture.js.  Re-gating the wrapper at this
  // boundary would cause it to refuse dialog::* selectors before the inner action
  // ever sees them (scenario 10C basic-auth typing bug).
  'setViewport',
  'clearViewport',
  'getViewport',
]);

/**
 * Build a fresh Chrome session — a state-bag scoped to a single Chrome target.
 *
 * Pre-factory, every consumer that required this file shared module-level
 * state: the connection pool, console-message buffers, the chosen profile
 * name, the launched Chrome process handle, the active CDP port, and the
 * host-override config. Two consumers in the same process therefore drove a
 * single Chrome — fine for the CLI and the MCP server (each owns its
 * process), but a hazard for any caller that wants to drive multiple Chromes
 * concurrently from one Node process (different ports, different profiles).
 *
 * `createSession({ host, port })` returns a fresh instance with private state
 * and methods bound to that state. Two instances do not share a connection
 * pool, console-message map, profile, Chrome process, or host-override —
 * mutating one (e.g. setProfileName, startChrome) has no effect on the other.
 * Pass `host`/`port` to seed the host-override; omit them to seed from the
 * `CHROME_WS_HOST` / `CHROME_WS_PORT` env vars exactly as before.
 *
 * The returned object preserves the legacy module-level export shape — the
 * one-line consumer migration is `require(...)` becomes
 * `require(...).createSession()`.
 */
function createSession({ host, port, _testFakes } = {}) {
  const state = createState({ host, port });

  // =============================================================================
  const dialogs = attachDialogs({ state });

  const { chromeHttp, resolveWsUrl, getTabs, newTab, closeTab } = attachTabs({ state });

  // Bridge primitives — single root WebSocket with flatten-mode page sessions.
  // The browser-session is constructed immediately (lazy connect on first use).
  // attachBrowserBridge issues Target.setDiscoverTargets which connects the root
  // WS, so we defer it behind state.ensureBridge() (lazy).
  const effectiveChromeHttp = (_testFakes && _testFakes.chromeHttp) ? _testFakes.chromeHttp : chromeHttp;
  const browserSessionFactory = () => createBrowserSession({
    host: state.hostOverride.getHost(),
    port: state.hostOverride.getPort(),
    rewriteWsUrl: state.rewriteWsUrl,
    chromeHttp: effectiveChromeHttp,
    WebSocketClient: _testFakes && _testFakes.WebSocketClient,
  });
  state.browserSession = browserSessionFactory();

  let bridgePromise = null;

  // Reset all bridge-layer state so the next ensureBridge() call re-attaches from
  // scratch. Called by killChrome (explicit kill) and ensureBridge (stale detection).
  // Does NOT call detach on cached pageSessions — the underlying WebSocket is
  // already dead at call time, so detach would fail. Use resolver.release() per-tab
  // before calling resetBridge if graceful cleanup is possible.
  state.resetBridge = () => {
    if (state.pageSessionResolver) {
      state.pageSessionResolver.releaseAll();
    }
    state.pageSessionResolver = null;
    state.browserBridge = null;
    state.browserSession = browserSessionFactory();
    bridgePromise = null;
  };

  state.ensureBridge = () => {
    // Detect stale bridge: if the cached browserSession is no longer connected,
    // reset everything so we re-attach to the restarted Chrome process.
    if (state.browserBridge && state.browserSession && !state.browserSession.isConnected()) {
      state.resetBridge();
    }
    if (state.browserBridge) return Promise.resolve(state.browserBridge);
    if (bridgePromise) return bridgePromise;
    bridgePromise = (async () => {
      const bridge = await attachBrowserBridge({
        browser: state.browserSession,
        host: state.hostOverride.getHost(),
        port: state.hostOverride.getPort(),
        rewriteWsUrl: state.rewriteWsUrl,
        autoAttach: true,
        onPageSession: async (ps) => {
          // Install dialog shim before the paused target resumes.
          // This gives popups, OAuth windows, and child frames dialog
          // handling from their very first script.
          try {
            await dialogs.attachToPageSession(ps);
          } catch (e) {
            console.error('onPageSession dialog attach failed:', e);
          }
          // Prime the pageSession resolver cache so subsequent getPageSession(popup)
          // calls return THIS session rather than issuing a duplicate Target.attachToTarget.
          // The dialog is registered under THIS session's sessionId; agent commands
          // must route through the same session to handle the dialog.
          if (state.pageSessionResolver && ps.targetId) {
            state.pageSessionResolver.prime(ps.targetId, ps);
          }
        },
      });
      state.browserBridge = bridge;
      state.pageSessionResolver = createPageSessionResolver({ bridge });
      return bridge;
    })();
    // Clear bridgePromise on failure so the next call retries
    bridgePromise.catch(() => { bridgePromise = null; });
    return bridgePromise;
  };

  // getPageSession(tabIndexOrWsUrl) — shared resolver for pageSession-migrated libs.
  // Accepts either a numeric tab index or a ws:// URL, lazy-boots the bridge, and
  // returns a cached pageSession for the target. Reused by E2-E13 migration libs.
  async function getPageSession(tabIndexOrWsUrl) {
    await state.ensureBridge();
    let tab;
    if (typeof tabIndexOrWsUrl === 'number') {
      const tabs = await getTabs();
      tab = tabs[tabIndexOrWsUrl];
      if (!tab) throw new Error(`No tab at index ${tabIndexOrWsUrl}`);
    } else if (typeof tabIndexOrWsUrl === 'string') {
      // Extract targetId from a ws URL like ws://host:port/devtools/page/<targetId>
      const m = /\/devtools\/page\/([^/]+)$/.exec(tabIndexOrWsUrl);
      if (!m) throw new Error(`Cannot extract targetId from: ${tabIndexOrWsUrl}`);
      tab = { id: m[1] };
    } else if (tabIndexOrWsUrl && tabIndexOrWsUrl.id) {
      // Already a tab handle
      tab = tabIndexOrWsUrl;
    } else {
      throw new Error('Unrecognized tabIndexOrWsUrl');
    }
    const ps = await state.pageSessionResolver(tab);
    // Ensure dialog event listeners are wired up on the bridge session the first
    // time a page session is obtained. This enables Page.javascriptDialogOpening
    // events to arrive via the bridge path (stored under sessionId).
    await dialogs.attachToPageSession(ps);
    return ps;
  }

  const { click, hover, drag, mouseMove, scroll, doubleClick, rightClick } =
    attachMouse({ getPageSession, dialogs });

  const { keyboardPress, fill, humanType } =
    attachKeyboardInput({ state, getPageSession, click, dialogs });

  const { fileUpload } = attachFileUpload({ getPageSession });

  const { selectOption } = attachSelectOption({ getPageSession });

  const { setAttribute } = attachSetAttribute({ getPageSession });

  const { evaluate } = attachEvaluation({ getPageSession });

  // =============================================================================

  const { extractText, getHtml, getSanitizedHtml, getAttribute } = attachExtraction({ getPageSession });

  // getSessionDir is a lazy thunk: capture.js populates state.sessionDir via
  // initializeSession(). We close over `state` so screenshot.js always reads
  // the freshly-set value. If no capture has happened yet, we delegate to
  // captureInitializer (set below after attachCapture) to create the dir.
  // The ref itself must live before attachScreenshot and attachCapture, but the
  // actual initializeSession function is injected after attachCapture runs.
  const screenshotDirRef = { initializeSession: null };

  const { screenshot } = attachScreenshot({
    getPageSession,
    state,
    initializeSession: () => {
      if (screenshotDirRef.initializeSession) return screenshotDirRef.initializeSession();
      // Fallback if called before attachCapture (shouldn't happen in normal flow).
      if (state.sessionDir) return state.sessionDir;
      throw new Error('Session directory not yet initialized. Call an auto-capture action first.');
    },
  });

  const { startChrome, killChrome, showBrowser, hideBrowser, getBrowserMode, getChromePid, getActivePort, getProfileName, setProfileName } =
    attachChromeProcess({ state, chromeHttp, getTabs, newTab });

  const { enableConsoleLogging, getConsoleMessages, clearConsoleMessages } =
    attachConsoleLogging({ state, getPageSession });

  const {
    initializeSession,
    cleanupSession,
    createCapturePrefix,
    generateDomSummary,
    getPageSize,
    generateMarkdown,
    capturePageArtifacts,
    captureActionWithDiff,
    screenshotUnlessCredentialShaped,
    clickWithCapture,
    fillWithCapture,
    selectOptionWithCapture,
    evaluateWithCapture,
    extractPageText,
    setAttributeWithCapture,
    getPageUrl,
    pauseCapture,
    resumeCapture,
    isCapturePaused,
    pageTextReadRefused,
  } = attachCapture({
    state,
    getPageSession,
    getHtml,
    screenshot,
    actions: { click, fill, selectOption, evaluate, setAttribute },
    dialogs,
  });

  // Wire the forward reference so screenshot.js can call initializeSession.
  screenshotDirRef.initializeSession = initializeSession;

  const { navigate, waitForElement, waitForText, back, forward } =
    attachNavigation({ state, getPageSession, capturePageArtifacts, evaluate });

  const { setViewport, clearViewport, getViewport } = attachViewport({ getPageSession });
  const { clearCookies } = attachCookies({ getPageSession });

  // ---------------------------------------------------------------------------
  // Session-boundary dialog gate
  //
  // Wraps every page-target method so that any call issued while a native dialog
  // is open returns a structured refusal instead of hanging until a CDP timeout.
  //
  // Convention (mirrors all other page-target methods in this library):
  //   fn(tabIndexOrWsUrl, selectorOrArg, ...rest)
  //
  // If the second argument is a string beginning with "dialog::", it is a
  // dialog-selector call (e.g. click("dialog::accept")) and must be allowed
  // through so the existing internal routers in mouse.js and keyboard-input.js
  // can handle it.
  // ---------------------------------------------------------------------------
  function wrapWithDialogGate(_name, fn) {
    return async function dialogGated(tabIndexOrWsUrl, secondArg, ...rest) {
      // Resolve the ws URL so we can look up dialog state.
      // resolveWsUrl may throw (e.g., no Chrome running) — let it propagate
      // naturally; that's not a dialog problem.
      let wsUrl;
      try {
        wsUrl = await resolveWsUrl(tabIndexOrWsUrl);
      } catch {
        // Can't resolve the URL — delegate and let the method surface the error.
        return fn(tabIndexOrWsUrl, secondArg, ...rest);
      }

      // Look up dialog state keyed by sessionId (via targetId→sessionId map populated
      // by attachToPageSession). dialogs.getOpen() handles both direct sessionId keys
      // and wsUrl paths by extracting the targetId from the URL.
      const open = dialogs.getOpen(wsUrl);

      const isDialogSelector = typeof secondArg === 'string' && secondArg.startsWith('dialog::');

      if (open && !isDialogSelector) {
        // Pause-aware (same rationale as dialogs.js's withDialogAwareness/
        // withDialogAwarenessForSession): this session-boundary gate wraps
        // EVERY PAGE_TARGET_SESSION_METHODS entry, including clickWithCapture
        // et al, and runs BEFORE those methods' own dialog-aware code ever
        // sees the call -- so it is a SEPARATE place the same leak can
        // happen, not a redundant one. While state.capturePaused is set,
        // narrow `dialog` to {kind} and never render the message/
        // defaultPrompt text at all.
        if (state.capturePaused) {
          throw new DialogRefusedError({ dialog: { kind: open.kind }, artifacts: null });
        }
        throw new DialogRefusedError({ dialog: open, artifacts: renderSyntheticArtifacts(open) });
      }

      return fn(tabIndexOrWsUrl, secondArg, ...rest);
    };
  }

  // Build the raw session object, then wrap page-target methods.
  const rawSession = {
    // State bag (exposed for bridge consumers and testing)
    state,

    // Internal helpers (exported for testing)
    getElementSelector,

    // Core browser actions (click/fill now use CDP events by default for React compatibility)
    getTabs,
    newTab,
    closeTab,
    navigate,
    click,           // Uses CDP mouse events, falls back to el.click()
    fill,            // Uses CDP insertText, falls back to el.value=
    selectOption,    // Warns if selector matches multiple elements
    evaluate,
    extractText,
    getHtml,
    getSanitizedHtml,
    getAttribute,
    setAttribute,
    waitForElement,
    waitForText,
    back,
    forward,
    screenshot,

    // Mouse actions (CDP-level, bypasses synthetic event restrictions)
    hover,            // Move mouse over element (CSS :hover, tooltips)
    drag,             // Drag-and-drop via native mouse event sequence
    mouseMove,        // Raw coordinate mouse movement
    scroll,           // Mouse wheel scrolling
    doubleClick,      // Double-click with dblclick event
    rightClick,       // Right-click with contextmenu event

    // Human-like typing (individual keyDown/keyUp with realistic timing)
    humanType,

    // File upload (DOM.setFileInputFiles — can't be done via JS)
    fileUpload,

    // Keyboard support for special keys (Tab, Enter, Escape, Arrow keys, etc.)
    keyboardPress,
    KEY_DEFINITIONS,

    // Chrome lifecycle
    startChrome,
    buildChromeArgs,
    killChrome,
    showBrowser,
    hideBrowser,
    getBrowserMode,
    getChromePid,

    // Profile management
    getChromeProfileDir,
    getProfileName,
    setProfileName,

    // Console logging
    enableConsoleLogging,
    getConsoleMessages,
    clearConsoleMessages,

    // Session management
    getXdgCacheHome,
    initializeSession,
    cleanupSession,
    createCapturePrefix,

    // Auto-capture utilities
    generateDomSummary,
    getPageSize,
    generateMarkdown,
    capturePageArtifacts,
    clickWithCapture,
    fillWithCapture,
    selectOptionWithCapture,
    evaluateWithCapture,
    extractPageText,
    setAttributeWithCapture,

    // DOM diff capture (before/after with diff)
    generateHtmlDiff,
    captureActionWithDiff,

    // Credential-shaped content guard (see lib/credential-guard.js)
    screenshotUnlessCredentialShaped,
    getPageUrl,
    // PRI-3360: whole-page text-read gate (sensitive-url.js's
    // pageTextReadRefused), exposed at tabIndex granularity for
    // mcp/src/index.ts's EXTRACT markdown branch.
    pageTextReadRefused,

    // Pause switch for automatic captures (see lib/capture-pause.js)
    pauseCapture,
    resumeCapture,
    isCapturePaused,

    // Dynamic port allocation and per-profile meta.json
    getActivePort,
    findAvailablePort,
    getProfileMetaPath,
    readProfileMeta,
    writeProfileMeta,
    clearProfileMeta,

    // Viewport/device emulation
    setViewport,
    clearViewport,
    getViewport,

    // Cookie management
    clearCookies,

    // Dialog awareness
    dialogs,

  };

  // Apply the session-boundary dialog gate to every page-target method.
  for (const name of PAGE_TARGET_SESSION_METHODS) {
    if (typeof rawSession[name] === 'function') {
      rawSession[name] = wrapWithDialogGate(name, rawSession[name]);
    }
  }

  return rawSession;
}

module.exports = { createSession, PAGE_TARGET_SESSION_METHODS, DialogRefusedError };
```

## File: skills/browsing/COMMANDLINE-USAGE.md
size: 14796
sha: 97598ab63da5ac8711ba29b06b2e9246cab12f30
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/COMMANDLINE-USAGE.md

````text
# Command-Line Usage: chrome-ws Tool

Direct command-line access to Chrome DevTools Protocol via the `chrome-ws` bash tool.

**Note**: For use within Claude Code, the MCP `use_browser` tool is recommended. This document is for direct command-line usage or integration with other tools.

## Setup

```bash
cd ~/.claude/plugins/cache/using-chrome-directly/skills/using-chrome-directly
chmod +x chrome-ws
./chrome-ws start    # Auto-detects platform, launches Chrome
./chrome-ws tabs     # Verify running
```

Chrome starts headed with `--remote-debugging-port=9222`, a separate profile in `/tmp/chrome-debug` (or `C:\temp\chrome-debug` on Windows), and the same automation flag set the MCP launch path uses.

## Environment Variables

| Variable | Default | Description |
|----------|---------|-------------|
| `CHROME_WS_BROWSER` | (auto-detect) | Path to browser executable. Overrides auto-detection. |
| `CHROME_WS_HOST` | `127.0.0.1` | Debug host address |
| `CHROME_WS_PORT` | `9222` | Debug port number |
| `CHROME_EXTRA_ARGS` | (none) | Whitespace-separated extra Chrome flags appended at launch. Needed on headless Linux / as root, e.g. `--no-sandbox --headless=new --disable-dev-shm-usage`. |
| `CHROME_WS_PROFILE` | (auto) | Profile name. Default is `superpowers-chrome`; if another live process holds that profile's lock, the CLI / MCP falls through to `superpowers-chrome-2`, `-3`, etc. Set this to opt out — explicit names always claim the named profile (sharing with whoever else has it). |

**Examples:**

```bash
# Force Chromium instead of Chrome
CHROME_WS_BROWSER=/usr/bin/chromium ./chrome-ws start

# Use custom port
CHROME_WS_PORT=9333 ./chrome-ws start

# Use Brave browser
CHROME_WS_BROWSER="/usr/bin/brave-browser" ./chrome-ws start
```

## Command Reference

**Lifecycle:**
```bash
chrome-ws start [port]          # Launch Chrome (auto-detects platform)
chrome-ws stop                  # Kill Chrome
chrome-ws pid                   # Print Chrome PID
chrome-ws info                  # Print Chrome info (JSON: pid, port, mode, profile, profileDir, running)
chrome-ws --help                # Show usage
chrome-ws --version             # Print version
```

`chrome-ws --port=N <command>` overrides `CHROME_WS_PORT` for a single invocation.

Unknown commands now print `Unknown command: <name>` and point at `--help` instead of the raw-specific usage banner.

**Tab Management:**
```bash
chrome-ws tabs                  # List tabs as TSV (id<TAB>url<TAB>title); use `info` for JSON
chrome-ws new <url>            # Create tab
chrome-ws close <tab>          # Close tab (accepts numeric index or ws-url)
```

**Navigation:**
```bash
chrome-ws navigate <tab> <url>        # Navigate
chrome-ws wait-for <tab> <selector>   # Wait for element
chrome-ws wait-text <tab> <text>      # Wait for text
```

**Interaction:**
```bash
chrome-ws click <tab> <selector>              # Click
chrome-ws fill <tab> <selector> <value>       # Fill input
chrome-ws select <tab> <selector> <value>     # Select dropdown
```

**Extraction:**
```bash
chrome-ws eval <tab> <js>               # Execute JavaScript
chrome-ws extract <tab> <selector>      # Get text content
chrome-ws attr <tab> <selector> <attr>  # Get attribute
chrome-ws html <tab> [selector]         # Get HTML
```

**Export:**
```bash
chrome-ws screenshot <tab> <file.png>   # Capture screenshot
chrome-ws markdown <tab> <file.md>      # Save as markdown
```

**Raw Protocol:**
```bash
chrome-ws raw <ws-url> <json-rpc>       # Direct CDP access
```

`<tab>` accepts either tab index (0, 1, 2) or full WebSocket URL.

## Examples

### Basic Operations

**Extract page content:**
```bash
chrome-ws navigate 0 "https://example.com"
chrome-ws wait-for 0 "h1"

# Get page title
TITLE=$(chrome-ws eval 0 "document.title")

# Get main heading
HEADING=$(chrome-ws extract 0 "h1")

# Get first link URL
LINK=$(chrome-ws attr 0 "a" "href")
```

**Get all links:**
```bash
chrome-ws navigate 0 "https://example.com"
LINKS=$(chrome-ws eval 0 "Array.from(document.querySelectorAll('a')).map(a => ({
  text: a.textContent.trim(),
  href: a.href
}))")
echo "$LINKS"
```

**Extract table data:**
```bash
chrome-ws navigate 0 "https://example.com/data"
chrome-ws wait-for 0 "table"

# Convert table to JSON array
TABLE=$(chrome-ws eval 0 "
  Array.from(document.querySelectorAll('table tr')).map(row =>
    Array.from(row.cells).map(cell => cell.textContent.trim())
  )
")
```

### Form Automation

**Simple login:**
```bash
chrome-ws navigate 0 "https://app.example.com/login"
chrome-ws wait-for 0 "input[name=email]"

# Fill credentials
chrome-ws fill 0 "input[name=email]" "user@example.com"
chrome-ws fill 0 "input[name=password]" "securepass123"

# Submit and wait for dashboard
chrome-ws click 0 "button[type=submit]"
chrome-ws wait-text 0 "Dashboard"
```

**Multi-step form:**
```bash
chrome-ws navigate 0 "https://example.com/register"

# Step 1: Personal information
chrome-ws fill 0 "input[name=firstName]" "John"
chrome-ws fill 0 "input[name=lastName]" "Doe"
chrome-ws fill 0 "input[name=email]" "john@example.com"
chrome-ws click 0 "button.next"

# Wait for step 2 to load
chrome-ws wait-for 0 "input[name=address]"

# Step 2: Address
chrome-ws fill 0 "input[name=address]" "123 Main St"
chrome-ws select 0 "select[name=state]" "IL"
chrome-ws fill 0 "input[name=zip]" "62701"
chrome-ws click 0 "button.submit"

chrome-ws wait-text 0 "Registration complete"
```

**Search with filters:**
```bash
chrome-ws navigate 0 "https://library.example.com/search"
chrome-ws wait-for 0 "form"

# Select category dropdown
chrome-ws select 0 "select[name=category]" "books"

# Fill search term
chrome-ws fill 0 "input[name=query]" "chrome devtools"

# Submit search
chrome-ws click 0 "button[type=submit]"
chrome-ws wait-for 0 ".results"

# Count results
RESULTS=$(chrome-ws eval 0 "document.querySelectorAll('.result').length")
echo "Found $RESULTS results"
```

### Web Scraping

**Article content:**
```bash
chrome-ws navigate 0 "https://blog.example.com/article"
chrome-ws wait-for 0 "article"

# Extract metadata
TITLE=$(chrome-ws extract 0 "article h1")
AUTHOR=$(chrome-ws extract 0 ".author-name")
DATE=$(chrome-ws extract 0 "time")
CONTENT=$(chrome-ws extract 0 "article .content")

# Save to file
cat > article.txt <<EOF
Title: $TITLE
Author: $AUTHOR
Date: $DATE

$CONTENT
EOF
```

**Product information:**
```bash
chrome-ws navigate 0 "https://shop.example.com/product/123"
chrome-ws wait-for 0 ".product-details"

NAME=$(chrome-ws extract 0 "h1.product-name")
PRICE=$(chrome-ws extract 0 ".price")
IMAGE=$(chrome-ws attr 0 ".product-image img" "src")
STOCK=$(chrome-ws extract 0 ".stock-status")

# Output as JSON
cat <<EOF
{
  "name": "$NAME",
  "price": "$PRICE",
  "image": "$IMAGE",
  "in_stock": "$STOCK"
}
EOF
```

**Batch process URLs:**
```bash
URLS=("page1" "page2" "page3")

for URL in "${URLS[@]}"; do
  chrome-ws navigate 0 "https://example.com/$URL"
  chrome-ws wait-for 0 "h1"
  TITLE=$(chrome-ws extract 0 "h1")
  echo "$URL: $TITLE" >> results.txt
done
```

### Multi-Tab Workflows

**Email extraction:**
```bash
# List all tabs
chrome-ws tabs

# Use the email tab index from output (e.g., tab 2)
EMAIL_TAB=2

# Click specific email
chrome-ws click $EMAIL_TAB "a[title*='Organization receipt']"

# Wait for email to load
chrome-ws wait-for $EMAIL_TAB ".email-body"

# Extract donation amount
AMOUNT=$(chrome-ws extract $EMAIL_TAB ".donation-amount")
echo "Donation: $AMOUNT"
```

**Price comparison:**
```bash
chrome-ws navigate 0 "https://store1.com/product"
chrome-ws new "https://store2.com/product"
chrome-ws new "https://store3.com/product"
sleep 3  # Let pages load

PRICE1=$(chrome-ws extract 0 ".price")
PRICE2=$(chrome-ws extract 1 ".price")
PRICE3=$(chrome-ws extract 2 ".price")

echo "Store 1: $PRICE1"
echo "Store 2: $PRICE2"
echo "Store 3: $PRICE3"
```

**Cross-reference between sites:**
```bash
# Get phone number from company site
chrome-ws navigate 0 "https://company.com/contact"
chrome-ws wait-for 0 ".phone"
PHONE=$(chrome-ws extract 0 ".phone")

# Look up phone number in verification site
chrome-ws new "https://lookup.com"
chrome-ws fill 1 "input[name=phone]" "$PHONE"
chrome-ws click 1 "button.search"
chrome-ws wait-for 1 ".results"
chrome-ws extract 1 ".verification-status"
```

### Dynamic Content

**Wait for AJAX to complete:**
```bash
chrome-ws navigate 0 "https://app.com/dashboard"

# Wait for spinner to disappear
chrome-ws eval 0 "new Promise(resolve => {
  const check = () => {
    if (!document.querySelector('.spinner')) {
      resolve(true);
    } else {
      setTimeout(check, 100);
    }
  };
  check();
})"

# Now safe to extract
chrome-ws extract 0 ".dashboard-data"
```

**Infinite scroll:**
```bash
chrome-ws navigate 0 "https://example.com/feed"
chrome-ws wait-for 0 ".feed-item"

# Scroll 5 times
for i in {1..5}; do
  chrome-ws eval 0 "window.scrollTo(0, document.body.scrollHeight)"
  sleep 2
done

# Count loaded items
chrome-ws eval 0 "document.querySelectorAll('.feed-item').length"
```

**Monitor for changes:**
```bash
chrome-ws navigate 0 "https://example.com/status"
END=$(($(date +%s) + 300))

while [ $(date +%s) -lt $END ]; do
  STATUS=$(chrome-ws extract 0 ".status")
  echo "[$(date +%H:%M:%S)] $STATUS"

  if [[ "$STATUS" == *"ERROR"* ]]; then
    echo "ALERT: Error detected"
    break
  fi

  sleep 10
done
```

### Advanced Patterns

**Multi-step workflow:**
```bash
chrome-ws navigate 0 "https://booking.example.com"

# Search
chrome-ws fill 0 "input[name=destination]" "San Francisco"
chrome-ws fill 0 "input[name=checkin]" "2025-12-01"
chrome-ws click 0 "button.search"

# Select hotel
chrome-ws wait-for 0 ".hotel-results"
chrome-ws click 0 ".hotel-card:first-child .select"

# Choose room
chrome-ws wait-for 0 ".room-options"
chrome-ws click 0 ".room[data-type=deluxe] .book"

# Fill guest info
chrome-ws wait-for 0 "form.guest-info"
chrome-ws fill 0 "input[name=firstName]" "Jane"
chrome-ws fill 0 "input[name=lastName]" "Smith"
chrome-ws fill 0 "input[name=email]" "jane@example.com"

# Review
chrome-ws click 0 "button.review"
chrome-ws wait-for 0 ".summary"

# Extract confirmation
HOTEL=$(chrome-ws extract 0 ".hotel-name")
TOTAL=$(chrome-ws extract 0 ".total-price")
echo "$HOTEL: $TOTAL"
```

**Cookies and localStorage:**
```bash
# Get cookies
chrome-ws eval 0 "document.cookie"

# Set cookie
chrome-ws eval 0 "document.cookie = 'theme=dark; path=/'"

# Get localStorage
chrome-ws eval 0 "JSON.stringify(localStorage)"

# Set localStorage
chrome-ws eval 0 "localStorage.setItem('lastVisit', new Date().toISOString())"
```

**Handle modals:**
```bash
chrome-ws click 0 "button.open-modal"
chrome-ws wait-for 0 ".modal.visible"

# Fill modal form
chrome-ws fill 0 ".modal input[name=username]" "testuser"
chrome-ws click 0 ".modal button.submit"

# Wait for modal to close
chrome-ws eval 0 "new Promise(resolve => {
  const check = () => {
    if (!document.querySelector('.modal.visible')) {
      resolve(true);
    } else {
      setTimeout(check, 100);
    }
  };
  check();
})"
```

**Network monitoring with raw CDP:**
```bash
# Enable network monitoring
chrome-ws raw 0 '{"id":1,"method":"Network.enable","params":{}}'

# Navigate and capture traffic
chrome-ws navigate 0 "https://api.example.com"

# Get performance metrics
chrome-ws raw 0 '{"id":2,"method":"Performance.getMetrics","params":{}}'
```

**Screenshots and PDF:**
```bash
# Capture screenshot
chrome-ws screenshot 0 "page.png"

# Or use raw CDP for more control
SCREENSHOT=$(chrome-ws raw 0 '{
  "id":1,
  "method":"Page.captureScreenshot",
  "params":{"format":"png","quality":80}
}')

# Extract base64 and save
echo "$SCREENSHOT" | node -pe "JSON.parse(require('fs').readFileSync(0)).result.data" | base64 -d > screenshot.png
```

## Error Handling

**Check element exists:**
```bash
# Verify button exists
EXISTS=$(chrome-ws eval 0 "!!document.querySelector('.important-button')")

if [ "$EXISTS" = "true" ]; then
  chrome-ws click 0 ".important-button"
else
  echo "Button not found on page"
fi
```

**Verify command success:**
```bash
if ! chrome-ws navigate 0 "https://example.com"; then
  echo "Navigation failed - Chrome not running?"
  exit 1
fi
```

**Retry pattern:**
```bash
for attempt in {1..3}; do
  if chrome-ws click 0 ".submit-button"; then
    echo "Click succeeded"
    break
  fi
  echo "Attempt $attempt failed, retrying..."
  sleep 2
done
```

## Best Practices

**Always wait before interaction:**
```bash
# BAD - might fail if page slow to load
chrome-ws navigate 0 "https://example.com"
chrome-ws click 0 "button"  # May fail!

# GOOD - wait for element first
chrome-ws navigate 0 "https://example.com"
chrome-ws wait-for 0 "button"
chrome-ws click 0 "button"
```

**Use specific selectors:**
```bash
# BAD - matches first button on page
chrome-ws click 0 "button"

# GOOD - specific selector
chrome-ws click 0 "button[type=submit]"
chrome-ws click 0 "button.login-button"
chrome-ws click 0 "#submit-form"
```

**Test selectors with html command:**
```bash
# Check page structure
chrome-ws html 0 | grep "submit"

# Check specific element exists
chrome-ws html 0 "form"
```

**Escape special characters:**
```bash
# Use double quotes for variables
chrome-ws fill 0 "input[name=search]" "$SEARCH_TERM"

# Use single quotes for literal strings with special chars
chrome-ws eval 0 'document.querySelector(".item").textContent'
```

## Common Pitfalls

**Don't cache tab indices** - they change when tabs close:
```bash
# BAD - index might be stale
TAB=2
# ... much later ...
chrome-ws click $TAB "button"  # Tab 2 might not exist anymore

# GOOD - fetch fresh before use
chrome-ws tabs
chrome-ws click 2 "button"
```

**Don't forget to wait for dynamic content:**
```bash
# BAD - tries to extract before content loads
chrome-ws navigate 0 "https://app.com"
chrome-ws extract 0 ".user-name"  # Might be empty!

# GOOD - wait for content
chrome-ws navigate 0 "https://app.com"
chrome-ws wait-for 0 ".user-name"
chrome-ws extract 0 ".user-name"
```

**Handle element state:**
```bash
# Check if button is disabled
DISABLED=$(chrome-ws eval 0 "document.querySelector('button.submit').disabled")

if [ "$DISABLED" = "false" ]; then
  chrome-ws click 0 "button.submit"
else
  echo "Button is disabled"
fi
```

## Troubleshooting

**Connection refused:** Verify Chrome running with `curl http://127.0.0.1:9222/json`

**Element not found:** Check page structure with `chrome-ws html 0`

**Timeout:** Use `wait-for` before interaction. Chrome has 30s timeout.

**Tab index out of range:** Run `chrome-ws tabs` to get current indices.

## Protocol Reference

Full CDP documentation: https://chromedevtools.github.io/devtools-protocol/

Common methods via `raw` command:
- `Page.navigate`
- `Runtime.evaluate`
- `Network.enable`
- `Performance.getMetrics`
````

## File: skills/browsing/EXAMPLES.md
size: 20523
sha: 5d4da7cbe057e93fe6091a2f0665115bea942e53
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/EXAMPLES.md

````text
# Chrome Direct Access Examples (MCP Tool)

Examples using the `use_browser` MCP tool. For command-line bash examples, see [COMMANDLINE-USAGE.md](COMMANDLINE-USAGE.md).

## Table of Contents

1. [Basic Operations](#basic-operations)
2. [Form Automation](#form-automation)
3. [Web Scraping](#web-scraping)
4. [Multi-Tab Workflows](#multi-tab-workflows)
5. [Dynamic Content](#dynamic-content)
6. [Dialogs](#dialogs) — basic-auth, JS confirm, popup-with-confirm
7. [Recovery](#recovery) — auto-restart, kill/restart cycle
8. [Multi-MCP isolation](#multi-mcp-isolation)
9. [Advanced Patterns](#advanced-patterns)

---

## Basic Operations

### Extract Page Content

Navigate to a page and extract various elements:

```
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "h1"}

// Get page title
{action: "eval", payload: "document.title"}

// Get main heading text
{action: "extract", payload: "text", selector: "h1"}

// Get first link URL
{action: "attr", selector: "a", payload: "href"}
```

### Get All Links

Use JavaScript evaluation to get structured data:

```
{action: "navigate", payload: "https://example.com"}
{action: "eval", payload: "Array.from(document.querySelectorAll('a')).map(a => ({ text: a.textContent.trim(), href: a.href }))"}
```

### Extract Table Data

Convert HTML table to structured data:

```
{action: "navigate", payload: "https://example.com/data"}
{action: "await_element", selector: "table"}

// Convert table to JSON array
{action: "eval", payload: "Array.from(document.querySelectorAll('table tr')).map(row => Array.from(row.cells).map(cell => cell.textContent.trim()))"}
```

### Get Page as Markdown

Extract entire page content in markdown format:

```
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "body"}
{action: "extract", payload: "markdown"}
```

---

## Form Automation

### Simple Login

Navigate, fill credentials, and submit:

```
{action: "navigate", payload: "https://app.example.com/login"}
{action: "await_element", selector: "input[name=email]"}

// Fill credentials
{action: "type", selector: "input[name=email]", payload: "user@example.com"}
{action: "type", selector: "input[name=password]", payload: "securepass123\n"}

// Wait for successful login
{action: "await_text", payload: "Dashboard"}
```

Note: The `\n` at the end of the password submits the form.

### Multi-Step Form

Handle forms that show steps progressively:

```
{action: "navigate", payload: "https://example.com/register"}

// Step 1: Personal information
{action: "type", selector: "input[name=firstName]", payload: "John"}
{action: "type", selector: "input[name=lastName]", payload: "Doe"}
{action: "type", selector: "input[name=email]", payload: "john@example.com"}
{action: "click", selector: "button.next"}

// Wait for step 2 to load
{action: "await_element", selector: "input[name=address]"}

// Step 2: Address
{action: "type", selector: "input[name=address]", payload: "123 Main St"}
{action: "select", selector: "select[name=state]", payload: "IL"}
{action: "type", selector: "input[name=zip]", payload: "62701"}
{action: "click", selector: "button.submit"}

{action: "await_text", payload: "Registration complete"}
```

### Search with Filters

Use dropdowns and text inputs together:

```
{action: "navigate", payload: "https://library.example.com/search"}
{action: "await_element", selector: "form"}

// Select category dropdown
{action: "select", selector: "select[name=category]", payload: "books"}

// Fill search term
{action: "type", selector: "input[name=query]", payload: "chrome devtools"}

// Submit and count results
{action: "click", selector: "button[type=submit]"}
{action: "await_element", selector: ".results"}

// Count results
{action: "eval", payload: "document.querySelectorAll('.result').length"}
```

---

## Web Scraping

### Article Content

Extract article metadata and content:

```
{action: "navigate", payload: "https://blog.example.com/article"}
{action: "await_element", selector: "article"}

// Extract metadata
{action: "extract", payload: "text", selector: "article h1"}
{action: "extract", payload: "text", selector: ".author-name"}
{action: "extract", payload: "text", selector: "time"}
{action: "extract", payload: "text", selector: "article .content"}
```

### Product Information

Scrape product details from e-commerce site:

```
{action: "navigate", payload: "https://shop.example.com/product/123"}
{action: "await_element", selector: ".product-details"}

// Extract product data
{action: "extract", payload: "text", selector: "h1.product-name"}
{action: "extract", payload: "text", selector: ".price"}
{action: "attr", selector: ".product-image img", payload: "src"}
{action: "extract", payload: "text", selector: ".stock-status"}
```

### Batch Extract Structured Data

Get multiple products at once using JavaScript:

```
{action: "navigate", payload: "https://shop.example.com/category/electronics"}
{action: "await_element", selector: ".product-grid"}

// Extract all products as structured data
{action: "eval", payload: `
  Array.from(document.querySelectorAll('.product-card')).map(card => ({
    name: card.querySelector('.product-name').textContent,
    price: card.querySelector('.price').textContent,
    image: card.querySelector('img').src,
    url: card.querySelector('a').href
  }))
`}
```

---

## Multi-Tab Workflows

### Email Extraction

List tabs, then switch to the correct tab and extract data:

```
// Find email tab
{action: "list_tabs"}

// Switch to tab 2 (from list_tabs output), then operate on active tab
{action: "switch_tab", payload: 2}
{action: "click", selector: "a[title*='Organization receipt']"}
{action: "await_element", selector: ".email-body"}

// Extract donation amount
{action: "extract", payload: "text", selector: ".donation-amount"}
```

### Price Comparison

Open multiple stores and compare prices:

```
// Navigate first tab (already active)
{action: "navigate", payload: "https://store1.com/product"}

// Open additional tabs and navigate each
{action: "new_tab"}
{action: "navigate", payload: "https://store2.com/product"}

{action: "new_tab"}
{action: "navigate", payload: "https://store3.com/product"}

// Switch back to each tab and extract prices
{action: "switch_tab", payload: "store1.com"}
{action: "await_element", selector: ".price"}
{action: "extract", payload: "text", selector: ".price"}

{action: "switch_tab", payload: "store2.com"}
{action: "await_element", selector: ".price"}
{action: "extract", payload: "text", selector: ".price"}

{action: "switch_tab", payload: "store3.com"}
{action: "await_element", selector: ".price"}
{action: "extract", payload: "text", selector: ".price"}
```

### Cross-Reference Between Sites

Extract data from one site and use in another:

```
// Get phone number from company site
{action: "navigate", payload: "https://company.com/contact"}
{action: "await_element", selector: ".phone"}
{action: "extract", payload: "text", selector: ".phone"}

// Store the result, then open verification site in a new tab
{action: "new_tab"}
{action: "navigate", payload: "https://lookup.com"}
{action: "await_element", selector: "input[name=phone]"}

// Fill with extracted phone number (new tab is already active)
{action: "type", selector: "input[name=phone]", payload: "<phone-from-previous-extract>"}
{action: "click", selector: "button.search"}
{action: "await_element", selector: ".results"}
{action: "extract", payload: "text", selector: ".verification-status"}
```

---

## Dynamic Content

### Wait for AJAX to Complete

Wait for loading spinner to disappear:

```
{action: "navigate", payload: "https://app.com/dashboard"}

// Wait for spinner to disappear using custom JavaScript
{action: "eval", payload: `
  new Promise(resolve => {
    const check = () => {
      if (!document.querySelector('.spinner')) {
        resolve(true);
      } else {
        setTimeout(check, 100);
      }
    };
    check();
  })
`}

// Now safe to extract
{action: "extract", payload: "text", selector: ".dashboard-data"}
```

### Infinite Scroll

Scroll to load more content:

```
{action: "navigate", payload: "https://example.com/feed"}
{action: "await_element", selector: ".feed-item"}

// Scroll multiple times
{action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"}
{action: "await_element", selector: ".feed-item", timeout: 2000}

{action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"}
{action: "await_element", selector: ".feed-item", timeout: 2000}

{action: "eval", payload: "window.scrollTo(0, document.body.scrollHeight)"}
{action: "await_element", selector: ".feed-item", timeout: 2000}

// Count loaded items
{action: "eval", payload: "document.querySelectorAll('.feed-item').length"}
```

### Wait for Element to Become Enabled

Wait for button to be clickable:

```
{action: "click", selector: "button.start"}

// Wait for continue button to enable
{action: "eval", payload: `
  new Promise(resolve => {
    const check = () => {
      const btn = document.querySelector('button.continue');
      if (btn && !btn.disabled) {
        resolve(true);
      } else {
        setTimeout(check, 100);
      }
    };
    check();
  })
`}

{action: "click", selector: "button.continue"}
```

---

## Dialogs

### HTTP basic-auth (dialog surfaces during navigate)

When the page returns 401 + `WWW-Authenticate`, Chrome stages a basic-auth
dialog. The bridge intercepts the `Fetch.authRequired` event, holds the
navigation, and surfaces a dialog refusal — `navigate` throws with the
dialog grammar in the message. The response text contains `basic-auth`
and lists the `dialog::username`/`dialog::password`/`dialog::accept`
selectors.

```
# Step 1: navigate fails with the dialog payload
{action: "navigate", payload: "http://localhost:8766/", timeout: 15000}
# (response includes "basic-auth", "dialog::username", "dialog::password")

# Step 2-4: stage credentials and submit
{action: "type", selector: "dialog::username", payload: "alice"}
{action: "type", selector: "dialog::password", payload: "secret"}
{action: "click", selector: "dialog::accept"}

# Step 5: the original navigation completes; the page is now loaded
{action: "extract", selector: "h1", payload: "text"}
# → "hi alice"
```

### JS confirm/alert dispatched by a click

A button whose `onclick` calls `confirm()` opens a dialog as soon as the
click event fires. The click itself may report a CDP timeout (Chrome
pauses the main thread on the dialog); that's expected. The bridge has
already populated `state.dialogs[sid]` and any subsequent page-targeted
call gets refused with the dialog grammar.

```
{action: "navigate", payload: "<page with onclick=confirm('Proceed?')>"}
{action: "click", selector: "#ask"}
# Click times out — expected.

{action: "extract", selector: "#result", payload: "text"}
# Refused: response contains "Page is behind a dialog", "dialog::accept",
# "dialog::dismiss", and the prompt "Proceed?".

{action: "click", selector: "dialog::accept"}
# Dialog accepted; state.dialogs cleared eagerly.

{action: "eval", payload: "window.__userChoice"}
# → true
```

### Popup with synchronous dialog (Phase F headline case)

A page that opens a popup whose first inline script calls
`confirm()` works without races. The bridge attaches to the popup
target via `Target.setAutoAttach({waitForDebuggerOnStart: true})`,
installs the dialog shim, then resumes execution — so the synchronous
confirm is observed.

```
{action: "navigate", payload: "http://localhost:8765/popup-opener.html"}
{action: "click", selector: "#open"}     # opens window.open('popup.html')
{action: "list_tabs"}                     # popup is enumerated
{action: "switch_tab", payload: "Popup"}  # route to the popup tab
{action: "extract", selector: "*", payload: "text"}
# Refused with dialog grammar — the popup's confirm was caught.

{action: "click", selector: "dialog::accept"}
{action: "eval", payload: "window.__userChoice"}
# → true
```

## Recovery

### Chrome killed externally

If something kills your Chrome (`kill -9 <pid>`, OOM killer, a user
closing the headed window), the bridge auto-restarts on the next page
action. The response is prefixed with a banner so you know the previous
URL/tab state is gone.

```
{action: "navigate", payload: "https://example.com"}
{action: "extract", selector: "h1", payload: "text"}    # → "Example Domain"
{action: "browser_mode"}                                # records pid=N

# (from your shell or another process: kill -9 N)

{action: "navigate", payload: "https://example.com"}
# Response starts with:
#   [Chrome auto-restarted; URL reset to about:blank. Re-navigate to continue.]
#   Navigated to https://example.com
#   ...
```

`browser_mode` also reports the real PID even when the bridge adopted a
Chrome it didn't spawn (a leftover from a previous MCP session). So
"get pid, kill -9 it, watch the restart" works regardless of how Chrome
got there.

### Explicit kill + restart cycle

```
{action: "kill_chrome"}     # Chrome killed.
{action: "restart_chrome"}  # Chrome restarted in headless mode.
{action: "navigate", payload: "data:text/html,<h1>fresh</h1>"}
```

## Multi-MCP isolation

By default the bridge handles parallel MCP servers on the same host
automatically: the first claims `superpowers-chrome:9222`, the next
silently falls through to `superpowers-chrome-2:9223`, then `-3:9224`,
etc. Each MCP drives its own Chrome with its own profile directory.

To intentionally **share** a Chrome between processes (e.g., a
long-lived `chrome-ws start` from the shell + a Claude MCP attaching to
it), pick a fixed profile name on both sides:

```
# Shell:
CHROME_WS_PROFILE=shared chrome-ws start

# In the MCP, on first call:
{action: "set_profile", payload: "shared"}
{action: "navigate", payload: "https://example.com"}
# Reconnects to the shell-started Chrome — same tabs, same cookies.
```

Either set `CHROME_WS_PROFILE=shared` in the MCP's environment, or call
`set_profile` at runtime. Both mark the profile as explicit, so the
bridge shares rather than disambiguates.

---

## Advanced Patterns

### Multi-Step Workflow

Complete booking flow with validation:

```
{action: "navigate", payload: "https://booking.example.com"}

// Search
{action: "type", selector: "input[name=destination]", payload: "San Francisco"}
{action: "type", selector: "input[name=checkin]", payload: "2025-12-01"}
{action: "click", selector: "button.search"}

// Select hotel
{action: "await_element", selector: ".hotel-results"}
{action: "click", selector: ".hotel-card:first-child .select"}

// Choose room
{action: "await_element", selector: ".room-options"}
{action: "click", selector: ".room[data-type=deluxe] .book"}

// Fill guest info
{action: "await_element", selector: "form.guest-info"}
{action: "type", selector: "input[name=firstName]", payload: "Jane"}
{action: "type", selector: "input[name=lastName]", payload: "Smith"}
{action: "type", selector: "input[name=email]", payload: "jane@example.com"}

// Review (don't complete)
{action: "click", selector: "button.review"}
{action: "await_element", selector: ".summary"}

// Extract confirmation details
{action: "extract", payload: "text", selector: ".hotel-name"}
{action: "extract", payload: "text", selector: ".total-price"}
```

### Cookies and LocalStorage

Access browser storage:

```
// Get cookies
{action: "eval", payload: "document.cookie"}

// Set cookie
{action: "eval", payload: "document.cookie = 'theme=dark; path=/'"}

// Get localStorage
{action: "eval", payload: "JSON.stringify(localStorage)"}

// Set localStorage
{action: "eval", payload: "localStorage.setItem('lastVisit', new Date().toISOString())"}
```

### Handle Modals

Interact with modal dialogs:

```
{action: "click", selector: "button.open-modal"}
{action: "await_element", selector: ".modal.visible"}

// Fill modal form
{action: "type", selector: ".modal input[name=username]", payload: "testuser"}
{action: "click", selector: ".modal button.submit"}

// Wait for modal to close
{action: "eval", payload: `
  new Promise(resolve => {
    const check = () => {
      if (!document.querySelector('.modal.visible')) {
        resolve(true);
      } else {
        setTimeout(check, 100);
      }
    };
    check();
  })
`}
```

### Screenshots

Capture full page or specific elements:

```
// Full page screenshot
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "body"}
{action: "screenshot", payload: "/tmp/page.png"}

// Element-specific screenshot
{action: "screenshot", payload: "/tmp/element.png", selector: ".important-section"}
```

### Check Element State

Verify element properties before interaction:

```
// Check if button is disabled
{action: "eval", payload: "document.querySelector('button.submit').disabled"}

// Check if element is visible
{action: "eval", payload: "!!document.querySelector('.important-button') && window.getComputedStyle(document.querySelector('.important-button')).display !== 'none'"}

// Check element exists
{action: "eval", payload: "!!document.querySelector('.important-button')"}
```

---

## Tips and Best Practices

### Always Wait Before Interaction

Don't interact with elements immediately after navigation:

```
// BAD - might fail if page slow to load
{action: "navigate", payload: "https://example.com"}
{action: "click", selector: "button"}  // May fail!

// GOOD - wait for element first
{action: "navigate", payload: "https://example.com"}
{action: "await_element", selector: "button"}
{action: "click", selector: "button"}
```

### Use Specific Selectors

Avoid generic selectors that match multiple elements:

```
// BAD - matches first button on page
{action: "click", selector: "button"}

// GOOD - specific selector
{action: "click", selector: "button[type=submit]"}
{action: "click", selector: "button.login-button"}
{action: "click", selector: "#submit-form"}
```

### Verify Selectors First

Check page structure before building workflow:

```
// Check page HTML
{action: "extract", payload: "html"}

// Or check specific element
{action: "extract", payload: "html", selector: "form"}
```

### Handle Dynamic Content

Wait for content to load before extraction:

```
// BAD - tries to extract before content loads
{action: "navigate", payload: "https://app.com"}
{action: "extract", payload: "text", selector: ".user-name"}  // Might be empty!

// GOOD - wait for content
{action: "navigate", payload: "https://app.com"}
{action: "await_element", selector: ".user-name"}
{action: "extract", payload: "text", selector: ".user-name"}
```

### Use \n for Form Submission

Append newline to auto-submit forms:

```
// Submit search without explicit click
{action: "type", selector: "#search-input", payload: "my query\n"}

// Submit login form
{action: "type", selector: "input[name=email]", payload: "user@example.com"}
{action: "type", selector: "input[name=password]", payload: "password123\n"}
```

---

## Common Pitfalls

### Don't Rely on Tab Indices

Tab indices change when tabs close — use URL or title substrings for reliable switching:

```
// BAD - index might be stale after closing tabs
{action: "switch_tab", payload: 2}
{action: "click", selector: "button"}

// GOOD - switch by URL or title substring (stable across tab changes)
{action: "switch_tab", payload: "example.com"}
{action: "click", selector: "button"}

// Or list tabs first to confirm the index
{action: "list_tabs"}
{action: "switch_tab", payload: 2}
{action: "click", selector: "button"}
```

### Increase Timeout for Slow Pages

Default timeout is 5000ms, increase if needed:

```
// For slow-loading elements
{action: "await_element", selector: ".lazy-content", timeout: 30000}

// For slow AJAX requests
{action: "await_text", payload: "Data loaded", timeout: 15000}
```

### Extract Structured Data with JavaScript

For complex data extraction, use JavaScript evaluation:

```
// Instead of multiple extract calls, use one eval
{action: "eval", payload: `
  {
    title: document.querySelector('h1').textContent,
    author: document.querySelector('.author').textContent,
    date: document.querySelector('time').textContent,
    links: Array.from(document.querySelectorAll('a')).map(a => a.href)
  }
`}
```

---

## Reference

- [SKILL.md](SKILL.md) - Complete tool reference
- [COMMANDLINE-USAGE.md](COMMANDLINE-USAGE.md) - Command-line bash examples
- [Chrome DevTools Protocol](https://chromedevtools.github.io/devtools-protocol/) - Full protocol documentation
````

## File: skills/browsing/host-override.js
size: 3320
sha: 2bfbbb5b869bebb83f9dca2a7db6e37563cdd660
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/host-override.js

```text
const DEFAULT_PORT = 9222;
const DEFAULT_HOST = '127.0.0.1';

/**
 * Build a per-instance host-override configuration.
 *
 * The legacy module-level constants and `rewriteWsUrl` above are baked at
 * module-load time and shared across every consumer that requires this
 * file. That's fine for the single-Chrome use case — the CLI and the MCP
 * server each own their process, so module-level state is effectively
 * per-process. It breaks down when one process needs to drive several
 * independent Chrome instances concurrently (different host/port pairs):
 * the load-time constants can only describe one of them.
 *
 * `createOverride({ host, port })` returns a fresh state-bag with its own
 * host/port/override-enabled flag, plus methods (`getHost`, `getPort`,
 * `getBase`, `isOverrideEnabled`, `rewriteWsUrl`, `setDefaults`) bound to
 * that state. Two instances do not share state — mutating one via
 * `setDefaults()` does not affect the other. Callers that don't need
 * per-instance isolation can keep using the module-level constants and
 * `rewriteWsUrl` exactly as before; nothing about the legacy API has
 * changed.
 *
 * Defaults: if both `host` and `port` are omitted, the instance seeds from
 * the `CHROME_WS_HOST` / `CHROME_WS_PORT` env vars. If either argument is
 * supplied, both are taken from the arguments (filling in defaults for the
 * missing one) and the instance's `overrideEnabled` flag starts true —
 * matching `setDefaults()` semantics.
 */
function createOverride({ host, port } = {}) {
  let instanceHost;
  let instancePort;
  let instanceOverrideEnabled;

  if (host !== undefined || port !== undefined) {
    instanceHost = host !== undefined ? host : DEFAULT_HOST;
    instancePort = port !== undefined ? port : DEFAULT_PORT;
    instanceOverrideEnabled = true;
  } else {
    instanceHost = process.env.CHROME_WS_HOST || DEFAULT_HOST;
    const parsed = parseInt(process.env.CHROME_WS_PORT || `${DEFAULT_PORT}`, 10);
    instancePort = Number.isNaN(parsed) ? DEFAULT_PORT : parsed;
    instanceOverrideEnabled =
      process.env.CHROME_WS_HOST !== undefined || process.env.CHROME_WS_PORT !== undefined;
  }

  function setDefaults(nextHost, nextPort) {
    instanceHost = nextHost;
    instancePort = nextPort;
    instanceOverrideEnabled = true;
  }

  function getHost() {
    return instanceHost;
  }

  function getPort() {
    return instancePort;
  }

  function getBase() {
    return `http://${instanceHost}:${instancePort}`;
  }

  function isOverrideEnabled() {
    return instanceOverrideEnabled;
  }

  function instanceRewriteWsUrl(originalUrl, overrideHost, overridePort) {
    if (!originalUrl || typeof originalUrl !== 'string') {
      return originalUrl;
    }
    if (!instanceOverrideEnabled) {
      return originalUrl;
    }
    const useHost = overrideHost !== undefined ? overrideHost : instanceHost;
    const usePort = overridePort !== undefined ? overridePort : instancePort;
    try {
      const url = new URL(originalUrl);
      url.hostname = useHost;
      url.port = `${usePort}`;
      return url.toString();
    } catch {
      return originalUrl;
    }
  }

  return {
    setDefaults,
    getHost,
    getPort,
    getBase,
    isOverrideEnabled,
    rewriteWsUrl: instanceRewriteWsUrl,
  };
}

module.exports = { createOverride };
```

## File: skills/browsing/lib/browser-bridge.js
size: 6968
sha: bddb9941c0f581c268b4c348547d5bf0e2ce1b89
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/browser-bridge.js

```text
'use strict';

const { createCdpRouter } = require('./cdp-router');
const { attachPageSession, buildPageSessionFromAttached } = require('./page-session');

/**
 * attachBrowserBridge({browser, host, port, rewriteWsUrl}) — consumer-facing
 * bridge over the browser-session.
 *
 * Exposes:
 *   targets.list()                 — synchronous snapshot of current targets
 *   targets.onCreated(handler)     — register listener; returns unsub fn
 *   targets.onDestroyed(handler)
 *   targets.waitForNew(predicate, {timeoutMs})
 *   createBrowserContext({proxyServer?}) -> {browserContextId, createPage, dispose}
 *   attachPageSession(targetId)    — page session over the browser-WS via flatten mode
 *
 * host/port/rewriteWsUrl are needed by createBrowserContext.createPage to construct
 * per-page WS URLs for callers that still want one (the bridge itself never uses
 * them — page sessions ride the browser-WS).
 */
async function attachBrowserBridge({ browser, host, port, rewriteWsUrl, autoAttach = false, onPageSession = null }) {
  // The cdp-router sits between browser-session and bridge consumers.
  // Page-session-tagged messages dispatch to the right session; root-session
  // events (Target.*, etc.) fire root listeners. Command responses without
  // sessionId stay correlated by browser-session.js's pendingRequests
  // (single source of truth for root-session correlation).
  const router = createCdpRouter({ browser });

  const targetMap = new Map();      // targetId -> targetInfo
  const onCreatedFns = new Set();
  const onDestroyedFns = new Set();

  router.getRootListeners().add((msg) => {
    if (msg.method === 'Target.targetCreated') {
      const t = msg.params.targetInfo;
      targetMap.set(t.targetId, t);
      for (const fn of onCreatedFns) {
        try { fn(t); } catch (e) { console.error('targets onCreated handler threw:', e); }
      }
    } else if (msg.method === 'Target.targetInfoChanged') {
      const t = msg.params.targetInfo;
      targetMap.set(t.targetId, t);
    } else if (msg.method === 'Target.targetDestroyed') {
      const t = targetMap.get(msg.params.targetId);
      targetMap.delete(msg.params.targetId);
      if (t) {
        for (const fn of onDestroyedFns) {
          try { fn(t); } catch (e) { console.error('targets onDestroyed handler threw:', e); }
        }
      }
    }
  });

  // Handle auto-attached targets (popups, child frames, etc.) when autoAttach is on.
  // Chrome emits this with an already-allocated sessionId — no Target.attachToTarget needed.
  //
  // Only act on targets that are paused (waitingForDebugger: true). Existing tabs
  // that Chrome retrospectively reports via attachedToTarget (waitingForDebugger: false)
  // are not paused and will be set up through the normal getPageSession/attachPageSession
  // path. Installing a page session here for those targets would register the same
  // sessionId twice in the cdp-router, causing duplicate-id protocol errors.
  router.getRootListeners().add(async (msg) => {
    if (msg.method !== 'Target.attachedToTarget') return;
    const { sessionId, targetInfo, waitingForDebugger } = msg.params;

    if (!waitingForDebugger) return;

    const ps = buildPageSessionFromAttached({ browser, router, sessionId, targetId: targetInfo.targetId });

    // Only run the onPageSession hook for page-type targets. Non-page targets
    // (service_worker, background_page, etc.) don't support the Page CDP domain
    // and would cause 'Page.enable' wasn't found errors in the hook.
    if (onPageSession && targetInfo.type === 'page') {
      try { await onPageSession(ps); }
      catch (e) { console.error('onPageSession hook threw:', e); }
    }

    // Resume the paused target AFTER the hook so any shims (e.g. dialog) are
    // installed before the page's scripts run.
    try { await ps.send('Runtime.runIfWaitingForDebugger', {}); }
    catch (e) { console.error('Runtime.runIfWaitingForDebugger failed:', e); }
  });

  // Subscribe — replays existing targets as targetCreated events.
  await browser.send('Target.setDiscoverTargets', { discover: true });

  if (autoAttach) {
    await browser.send('Target.setAutoAttach', {
      autoAttach: true,
      waitForDebuggerOnStart: true,
      flatten: true,
    });
  }

  function list() { return Array.from(targetMap.values()); }
  function onCreated(fn) { onCreatedFns.add(fn); return () => onCreatedFns.delete(fn); }
  function onDestroyed(fn) { onDestroyedFns.add(fn); return () => onDestroyedFns.delete(fn); }

  function waitForNew(predicate, { timeoutMs = 15000 } = {}) {
    return new Promise((resolve, reject) => {
      let unsub = null;
      const timeout = setTimeout(() => {
        if (unsub) unsub();
        reject(new Error(`waitForNew: timed out after ${timeoutMs}ms`));
      }, timeoutMs);
      unsub = onCreated((t) => {
        let match;
        try { match = predicate(t); }
        catch (e) {
          clearTimeout(timeout);
          if (unsub) unsub();
          reject(e);
          return;
        }
        if (match) {
          clearTimeout(timeout);
          if (unsub) unsub();
          resolve(t);
        }
      });
    });
  }

  /**
   * createBrowserContext({proxyServer?}) — creates a Chrome BrowserContext.
   * Returns {browserContextId, createPage, dispose}.
   *
   * createPage(url) calls Target.createTarget({url, browserContextId}) and
   * constructs a tab-shape-compatible page handle whose webSocketDebuggerUrl
   * is run through rewriteWsUrl.
   *
   * dispose() is atomic — Chrome tears down cookies/storage/IDB/SW for the
   * context in one call.
   */
  async function createBrowserContext(opts = {}) {
    const params = {};
    if (opts.proxyServer) params.proxyServer = opts.proxyServer;
    const { browserContextId } = await browser.send('Target.createBrowserContext', params);
    let disposed = false;

    async function createPage(url = 'about:blank') {
      if (disposed) throw new Error('BrowserContext disposed');
      const { targetId } = await browser.send('Target.createTarget', { url, browserContextId });
      const rawWsUrl = `ws://${host}:${port}/devtools/page/${targetId}`;
      return {
        id: targetId, targetId,
        webSocketDebuggerUrl: rewriteWsUrl(rawWsUrl, host, port),
        type: 'page', url, browserContextId,
      };
    }

    async function dispose() {
      if (disposed) return;
      disposed = true;
      try { await browser.send('Target.disposeBrowserContext', { browserContextId }); }
      catch (e) { console.warn('BrowserContext.dispose() failed:', e && e.message); }
    }

    return { browserContextId, createPage, dispose };
  }

  async function attachPage(targetId) {
    return attachPageSession({ browser, router }, targetId);
  }

  return {
    targets: { list, onCreated, onDestroyed, waitForNew },
    createBrowserContext,
    attachPageSession: attachPage,
    router,
  };
}

module.exports = { attachBrowserBridge };
```

## File: skills/browsing/lib/browser-session.js
size: 5187
sha: 707206f6d4bcca8fabc6ffe915444cae20c0996b
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/browser-session.js

```text
'use strict';

const { WebSocketClient: DefaultWebSocketClient } = require('./websocket-client');

/**
 * createBrowserSession({host, port, rewriteWsUrl, chromeHttp, WebSocketClient?}) -> bridge handle.
 *
 * Owns the one root WebSocket to /devtools/browser/<id>. Page-action commands ride
 * per-page sessions (attached via Target.attachToTarget({flatten:true})) and envelope
 * messages with a sessionId via sendRaw — the page session manages its own pendingRequests
 * (in the cdp-router). browser-session correlates ROOT-session command responses only.
 *
 * Returned API:
 *   send(method, params?, {timeoutMs?})  -> Promise<result>  // root command
 *   onEvent(handler)                     -> unsub fn
 *   close()                              -> Promise<void>
 *   isConnected()                        -> boolean
 *   sendRaw(json)                        -> void
 */
function createBrowserSession({ host, port, rewriteWsUrl, chromeHttp, WebSocketClient = DefaultWebSocketClient }) {
  let ws = null;
  const pendingRequests = new Map(); // id -> {resolve, reject, timeout}
  let messageIdCounter = 1;
  const eventListeners = new Set();
  let connectPromise = null;
  let closed = false;

  async function ensureConnected() {
    if (ws && ws.isConnected()) return;
    if (connectPromise) { await connectPromise; return; }
    connectPromise = (async () => {
      try {
        const versionInfo = await chromeHttp('/json/version');
        if (!versionInfo || !versionInfo.webSocketDebuggerUrl) {
          throw new Error('chromeHttp(/json/version) returned no webSocketDebuggerUrl');
        }
        const url = rewriteWsUrl(versionInfo.webSocketDebuggerUrl, host, port);
        const next = new WebSocketClient(url);
        next.on('message', (raw) => {
          let data;
          try { data = JSON.parse(raw); } catch (e) {
            console.error('browser-session: bad JSON from CDP:', e);
            return;
          }
          // Correlate ROOT-session command responses (id without sessionId). Page-session
          // responses carry {id, result, sessionId} and fall through to event listeners
          // for the cdp-router to dispatch.
          if (data.id !== undefined && data.sessionId === undefined) {
            const pending = pendingRequests.get(data.id);
            if (pending) {
              clearTimeout(pending.timeout);
              pendingRequests.delete(data.id);
              if (data.error) {
                pending.reject(new Error(data.error.message || JSON.stringify(data.error)));
              } else {
                pending.resolve(data.result);
              }
              return;
            }
          }
          for (const fn of eventListeners) {
            try { fn(data); } catch (e) { console.error('browser-session listener threw:', e); }
          }
        });
        next.on('close', () => {
          for (const [, p] of pendingRequests) {
            clearTimeout(p.timeout);
            p.reject(new Error('Browser session WS closed'));
          }
          pendingRequests.clear();
        });
        await next.connect();
        // Assign ws only after a successful connect so concurrent callers that hit the
        // `ws && ws.isConnected()` early-return don't see a half-initialized socket.
        ws = next;
      } catch (e) {
        // Allow retry after a transient failure (network blip, Chrome not yet ready, etc.).
        // We do NOT null on success — leaving the resolved promise in place makes subsequent
        // ensureConnected() awaits a no-op.
        connectPromise = null;
        throw e;
      }
    })();
    await connectPromise;
  }

  async function send(method, params = {}, { timeoutMs = 10000 } = {}) {
    if (closed) throw new Error('Browser session closed');
    await ensureConnected();
    if (closed) throw new Error('Browser session closed');
    const id = messageIdCounter++;
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        pendingRequests.delete(id);
        reject(new Error(`Browser session timeout: ${method}`));
      }, timeoutMs);
      pendingRequests.set(id, { resolve, reject, timeout });
      try {
        ws.send(JSON.stringify({ id, method, params }));
      } catch (e) {
        clearTimeout(timeout);
        pendingRequests.delete(id);
        reject(e);
      }
    });
  }

  function onEvent(handler) {
    eventListeners.add(handler);
    return () => eventListeners.delete(handler);
  }

  async function close() {
    closed = true;
    if (ws) { ws.close(); ws = null; }
    for (const [, p] of pendingRequests) {
      clearTimeout(p.timeout);
      p.reject(new Error('Browser session closed'));
    }
    pendingRequests.clear();
    eventListeners.clear();
  }

  function isConnected() { return ws !== null && ws.isConnected(); }

  function sendRaw(json) {
    if (closed) throw new Error('Browser session closed');
    if (!ws || !ws.isConnected()) {
      throw new Error('Browser WS not connected (call send() first to lazy-open)');
    }
    ws.send(json);
  }

  return { send, onEvent, close, isConnected, sendRaw };
}

module.exports = { createBrowserSession };
```

## File: skills/browsing/lib/capture-pause.js
size: 3555
sha: 69a581e1fdd11c9d9f9758295c4166c94a97623d
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/capture-pause.js

```text
/**
 * Pause switch for automatic page captures.
 *
 * Every auto-capture action (click/fill/select/eval/set_attr/navigate/
 * hover/drag/scroll/...) drops a DOM snapshot (.html/.md), a screenshot,
 * a console-log placeholder, and — when a native dialog is open — a
 * synthetic dialog artifact into the session dir. None of that is gated
 * on content: capture.js's credential-shape/marker/URL checks only catch
 * a secret with a recognizable SHAPE or an opt-in data-sen-secret MARK.
 * A page that reveals a secret only after a user action (Google Admin's
 * reset-password dialog, a "show password" toggle, a backup-codes modal)
 * in a plain element with no secret-looking id/name/class, on a URL with
 * nothing sensitive in it, slips past every one of those checks — and the
 * very next auto-capture writes it to disk.
 *
 * pause_capture/resume_capture (see lib/capture.js's pauseCapture/
 * resumeCapture, wired to session state) is the tool-level answer: an
 * agent that is about to reveal a secret this way pauses capture first,
 * performs the reveal, reads/relays the value through the credential
 * broker, then resumes once the secret is off-screen. State lives on the
 * session (see session-state.js), so it survives across actions until
 * resume_capture is called — an agent that forgets to resume simply gets
 * no more captures for the rest of the session, never a silent leak.
 *
 * Deliberately NOT auto-resumed on navigation to a different origin: that
 * would need to compare the ORIGIN at pause time against the ORIGIN after
 * every navigation (cross-tab, redirects, client-side routing all count),
 * and a same-origin page can still carry the secret forward (a SPA's
 * post-reveal route change, a same-origin redirect back to the dashboard
 * with the value still in a toast). An agent-driven resume_capture is the
 * one signal that actually means "the secret is gone now" — the pause
 * is already fail-safe (captures just stay off) if that call is forgotten.
 *
 * Caveat: state.capturePaused lives on the
 * in-memory per-session state bag (session-state.js), not anywhere
 * persisted — it survives restart_chrome (same process, same state
 * object) but NOT the MCP SERVER process restarting and re-adopting an
 * already-running Chrome (chrome-process.js's orphan-adoption path, or
 * meta.json). A server restart always comes back with a fresh
 * capturePaused:false, even if the revealed secret is still sitting on
 * the readopted page. This is fail-OPEN for that one scenario — the only
 * signal is that the pause notice stops appearing in responses — and is
 * a documented gap (see SKILL.md/README.md), not something this module
 * works around.
 */

const CAPTURE_PAUSED_NOTICE =
  '⏸️ Capture is paused for this session: automatic DOM/markdown/screenshot/console/dialog captures are OFF. ' +
  'Call resume_capture as soon as the secret is off-screen.';

// Unlike CREDENTIAL_SUPPRESSED_NOTICE (credential-guard.js), there is no
// "capture happened but was redacted" middle ground here: pause means NO
// auto-capture artifact is produced at all while it's on, by design (see
// module comment above).
function capturePausedScreenshotRefusal() {
  return (
    'screenshot refused: capture is paused for this session. ' +
    'An explicit screenshot could write the same secret pause_capture exists to keep off disk. ' +
    'Call resume_capture once the secret is off-screen, then screenshot.'
  );
}

module.exports = {
  CAPTURE_PAUSED_NOTICE,
  capturePausedScreenshotRefusal,
};
```

## File: skills/browsing/lib/capture.js
size: 46279
sha: 99945663332fe7e408b709dac15ad0fb1ac98a16
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/capture.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/capture.js

## File: skills/browsing/lib/cdp-router.js
size: 2584
sha: c82b1d177933f993fade0ca5d1e4f9460ad2847e
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/cdp-router.js

```text
'use strict';

/**
 * createCdpRouter({browser}) — sessionId-aware dispatcher for browser-WS messages.
 *
 * Routing rules:
 *   - msg.sessionId set         -> per-session pendingRequests (if msg.id) or eventListeners (if msg.method)
 *   - msg.method, no sessionId  -> root listeners (target events, etc.)
 *   - msg.id, no sessionId      -> falls through (browser-session.js owns root correlation)
 *
 * Per-session message-id counters are independent. {id:1, sessionId:"A"} and
 * {id:1, sessionId:"B"} correlate independently on one WS — collapsing id space
 * across sessions would silently break correlation.
 */
function createCdpRouter({ browser }) {
  const sessions = new Map(); // sessionId -> { pendingRequests, eventListeners }
  const rootListeners = new Set();

  browser.onEvent((msg) => {
    const sid = msg.sessionId;
    if (sid) {
      const sess = sessions.get(sid);
      if (!sess) return; // detached or never registered — drop silently
      if (msg.id !== undefined) {
        const pending = sess.pendingRequests.get(msg.id);
        if (pending) {
          clearTimeout(pending.timeout);
          sess.pendingRequests.delete(msg.id);
          if (msg.error) {
            pending.reject(new Error(msg.error.message || JSON.stringify(msg.error)));
          } else {
            pending.resolve(msg.result);
          }
        }
      } else if (msg.method) {
        for (const fn of sess.eventListeners) {
          try { fn(msg); } catch (e) { console.error('cdp-router page listener threw:', e); }
        }
      }
    } else if (msg.method) {
      for (const fn of rootListeners) {
        try { fn(msg); } catch (e) { console.error('cdp-router root listener threw:', e); }
      }
    }
    // Untagged responses (msg.id with no sessionId, no method) intentionally
    // fall through — browser-session.js's pendingRequests Map handles them.
  });

  function registerSession(sessionId) {
    const sess = { pendingRequests: new Map(), eventListeners: new Set() };
    sessions.set(sessionId, sess);
    return sess;
  }

  function unregisterSession(sessionId) {
    const sess = sessions.get(sessionId);
    if (!sess) return;
    for (const [, p] of sess.pendingRequests) {
      clearTimeout(p.timeout);
      p.reject(new Error('Page session detached'));
    }
    sess.pendingRequests.clear();
    sess.eventListeners.clear();
    sessions.delete(sessionId);
  }

  function getRootListeners() { return rootListeners; }

  return { registerSession, unregisterSession, getRootListeners };
}

module.exports = { createCdpRouter };
```

## File: skills/browsing/lib/cdp-utils.js
size: 764
sha: f0892f5192e6e34f660b9cf8c08d99f073720fab
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/cdp-utils.js

```text
/**
 * Shared utilities for CDP responses.
 *
 * `throwIfExceptionDetails(result)` inspects a `Runtime.evaluate` reply and
 * throws if the page-side JS threw or a Promise rejected. Without this,
 * callers silently see `undefined` instead of the actual error — which has
 * caused real bugs (waitForElement timeouts swallowed, evaluate returning {}
 * for thrown errors). Use after every `sendCdpCommand(...,'Runtime.evaluate',...)`.
 */
function throwIfExceptionDetails(result) {
  if (!result || !result.exceptionDetails) return;
  const desc = result.exceptionDetails.exception?.description
    || result.exceptionDetails.text
    || 'unknown evaluation error';
  throw new Error(`evaluate failed: ${desc}`);
}

module.exports = { throwIfExceptionDetails };
```

## File: skills/browsing/lib/chrome-launcher-helpers.js
size: 12906
sha: a5db9af6564cf140255e731a0e4c15d3ed7c89f8
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/chrome-launcher-helpers.js

```text
/**
 * Pure helpers used by the Chrome launcher: HTTP probing, profile path
 * resolution, meta.json read/write, port allocation, and Chrome flag list
 * construction. None of these touch session state — every input is passed
 * explicitly. Kept together because they share no dependency on the rest
 * of chrome-ws-lib.
 */

const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const net = require('net');

// HTTP request to Chrome's DevTools endpoint at an explicit host:port.
// Used for probing arbitrary ports before settling on activePort.
async function chromeHttpAt(host, port, urlPath, method = 'GET') {
  return new Promise((resolve, reject) => {
    const options = { hostname: host, port, path: urlPath, method };

    const req = http.request(options, (res) => {
      let data = '';
      res.on('data', chunk => data += chunk);
      res.on('end', () => {
        if (!data) { resolve({}); return; }
        try { resolve(JSON.parse(data)); }
        catch (_e) { resolve({ message: data }); }
      });
    });

    req.on('error', reject);
    req.end();
  });
}

function getXdgCacheHome() {
  if (process.env.XDG_CACHE_HOME) {
    return process.env.XDG_CACHE_HOME;
  }

  const platform = os.platform();
  const homeDir = os.homedir();

  if (platform === 'darwin') {
    return path.join(homeDir, 'Library', 'Caches');
  } else if (platform === 'win32') {
    return process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
  } else {
    return path.join(homeDir, '.cache');
  }
}

function getChromeProfileDir(profileName = 'superpowers-chrome') {
  return path.join(getXdgCacheHome(), 'superpowers', 'browser-profiles', profileName);
}

// --- Per-profile meta.json ---
//
// Each profile gets a sibling meta.json file next to its data directory:
//   ~/.cache/superpowers/browser-profiles/superpowers-chrome/       ← profile data
//   ~/.cache/superpowers/browser-profiles/superpowers-chrome.meta.json ← port/pid tracking
//
// Enables: reconnection across sessions, parallel Chrome instances per
// profile, and collision detection.

function getProfileMetaPath(profileName = 'superpowers-chrome') {
  return path.join(getXdgCacheHome(), 'superpowers', 'browser-profiles', `${profileName}.meta.json`);
}

function readProfileMeta(profileName = 'superpowers-chrome') {
  try {
    const data = fs.readFileSync(getProfileMetaPath(profileName), 'utf8');
    return JSON.parse(data);
  } catch {
    return null;
  }
}

function writeProfileMeta(profileName, data) {
  const metaPath = getProfileMetaPath(profileName);
  fs.mkdirSync(path.dirname(metaPath), { recursive: true });
  fs.writeFileSync(metaPath, JSON.stringify(data, null, 2) + '\n');
}

function clearProfileMeta(profileName) {
  try {
    fs.unlinkSync(getProfileMetaPath(profileName));
  } catch {
    // Already absent — nothing to do
  }
}

// Check if a port has a live Chrome DevTools instance, optionally verify PID.
async function isPortAlive(host, port, expectedPid = null) {
  try {
    const data = await chromeHttpAt(host, port, '/json/version');
    if (!data || !data.Browser) return false;
    if (expectedPid) {
      try { process.kill(expectedPid, 0); } // signal 0 = existence check
      catch { return false; }
    }
    return true;
  } catch {
    return false;
  }
}

// Probe whether a port is free (no listener) using a temporary TCP server.
// "Free" means free on BOTH IPv4 and IPv6 — Chrome may bind ::1 only on
// some macOS configurations, and a port bound on ::1 still appears free
// from a 127.0.0.1 probe. Without checking both, we'd start a second
// Chrome that races the first for the same port number on different
// stacks, with non-deterministic answers to /json HTTP requests.
function isPortFreeOn(host, port) {
  return new Promise((resolve) => {
    const server = net.createServer();
    // Resolve with the OS error code so the caller can tell "port in use"
    // (EADDRINUSE) apart from "this loopback/address family isn't available
    // here at all" (EADDRNOTAVAIL / EAFNOSUPPORT) — very different signals.
    server.once('error', (err) => resolve({ free: false, code: err.code }));
    server.once('listening', () => { server.close(() => resolve({ free: true })); });
    server.listen(port, host);
  });
}

// Pure decision over the IPv4 and IPv6 loopback probe results. A port is free
// only if IPv4 loopback is free. The IPv6 probe is a race-guard for hosts where
// Chrome may bind ::1 only (some macOS configs) — but an UNAVAILABLE IPv6
// loopback (e.g. a container with net.ipv6.conf.lo.disable_ipv6=1, where every
// ::1 bind returns EADDRNOTAVAIL) is NOT a port conflict and must not veto the
// port. Only a genuine in-use signal on ::1 vetoes. Exported for testing.
function portFreeFromProbes(v4, v6) {
  if (!v4.free) return false;
  if (v6.free) return true;
  if (v6.code === 'EADDRNOTAVAIL' || v6.code === 'EAFNOSUPPORT') return true;
  return false;
}

async function isPortFree(port) {
  const v4 = await isPortFreeOn('127.0.0.1', port);
  if (!v4.free) return false;
  const v6 = await isPortFreeOn('::1', port);
  return portFreeFromProbes(v4, v6);
}

// Port range tried sequentially, starting at 9222 for backward compat.
const PORT_RANGE_START = 9222;
const PORT_RANGE_END = 12111;

// Find first available port in range. Defaults span the full PORT_RANGE.
async function findAvailablePort(start = PORT_RANGE_START, end = PORT_RANGE_END) {
  for (let port = start; port <= end; port++) {
    if (await isPortFree(port)) return port;
  }
  throw new Error(`No available port in range ${start}-${end}`);
}

// Find the PID of the process holding `port`, or null if none.
// Uses platform-native tools — lsof on macOS/Linux, netstat on Windows.
// Returns null on any failure (parsing, missing tool, no listener).
function findPidOnPort(port) {
  const { execFileSync } = require('child_process');
  // Guard against a non-numeric or out-of-range port before using it.
  const portNum = Number(port);
  if (!Number.isInteger(portNum) || portNum <= 0 || portNum > 65535) {
    return null;
  }
  try {
    if (process.platform === 'darwin' || process.platform === 'linux') {
      // argv form, no shell: the port can never be shell-interpreted.
      const out = execFileSync('lsof', [`-ti:${portNum}`, '-sTCP:LISTEN'], {
        encoding: 'utf8',
        stdio: ['ignore', 'pipe', 'ignore']
      }).trim();
      if (!out) return null;
      const first = out.split('\n')[0];
      const pid = parseInt(first, 10);
      return Number.isFinite(pid) ? pid : null;
    }
    if (process.platform === 'win32') {
      // execFileSync can't express the old `netstat -ano | findstr :PORT`
      // pipeline (pipes need a shell), so filter in JS instead: LISTENING
      // lines whose local-address column ends with exactly `:PORT`.
      const out = execFileSync('netstat', ['-ano'], {
        encoding: 'utf8',
        stdio: ['ignore', 'pipe', 'ignore']
      });
      const portSuffix = `:${portNum}`;
      const lines = out.split(/\r?\n/).filter(l => {
        if (!/LISTENING/i.test(l)) return false;
        const cols = l.trim().split(/\s+/);
        return cols.length >= 2 && cols[1].endsWith(portSuffix);
      });
      if (!lines.length) return null;
      const cols = lines[0].trim().split(/\s+/);
      const pid = parseInt(cols[cols.length - 1], 10);
      return Number.isFinite(pid) ? pid : null;
    }
  } catch (_e) {
    return null;
  }
  return null;
}

// Scan running processes for a Chrome holding our profile's lock.
// Used to adopt orphan Chrome instances (meta.json missing/stale).
// Returns { pid, port } for first match, or null.
//
// Scans ps output for Chrome processes with:
//   --user-data-dir=<our profileDir> AND --remote-debugging-port=<N>
// Skips Chrome Helper processes (renderer, GPU, etc).
function findOrphanChromeForProfile(profileName) {
  const { execSync } = require('child_process');
  try {
    const profileDir = getChromeProfileDir(profileName);
    let psOutput;

    if (process.platform === 'darwin' || process.platform === 'linux') {
      // ps auxw: full command line per process
      psOutput = execSync('ps auxw', {
        encoding: 'utf8',
        stdio: ['ignore', 'pipe', 'ignore']
      });
    } else if (process.platform === 'win32') {
      // Windows: use wmic to list processes with their full command line
      psOutput = execSync('wmic process list full', {
        encoding: 'utf8',
        stdio: ['ignore', 'pipe', 'ignore']
      });
    } else {
      return null; // Unsupported platform
    }

    const lines = psOutput.split('\n');
    for (const line of lines) {
      // Skip empty lines and Chrome Helper processes (rendering, GPU, etc)
      if (!line.trim() || line.includes('Chrome Helper') || line.includes('chrome.exe --type=')) {
        continue;
      }

      // Must contain our profile dir
      if (!line.includes(profileDir)) {
        continue;
      }

      // Must contain --remote-debugging-port
      const portMatch = line.match(/--remote-debugging-port=(\d+)/);
      if (!portMatch || !portMatch[1]) {
        continue;
      }

      const port = parseInt(portMatch[1], 10);

      // Extract PID: position varies by platform, but it's early in the line.
      // macOS/Linux: "USER PID ..." — PID is second field after spaces
      // Windows wmic: "ProcessId=..." or first numeric field
      let pid;
      if (process.platform === 'darwin' || process.platform === 'linux') {
        const fields = line.split(/\s+/);
        if (fields.length >= 2) {
          pid = parseInt(fields[1], 10);
        }
      } else if (process.platform === 'win32') {
        const pidMatch = line.match(/ProcessId=(\d+)|^(\d+)\s/);
        if (pidMatch) {
          pid = parseInt(pidMatch[1] || pidMatch[2], 10);
        }
      }

      if (Number.isFinite(pid) && Number.isFinite(port)) {
        return { pid, port };
      }
    }

    return null;
  } catch (_e) {
    // ps or wmic failed, no process info available
    return null;
  }
}

// Chrome's sandbox cannot work as root, and usually not inside containers
// (no user namespaces), so --no-sandbox is required there. Everywhere else
// the sandbox stays on: this browser navigates to agent-chosen URLs, so
// exploit containment matters. Params are injectable for tests; defaults
// read the real environment.
function sandboxDisableNeeded({
  uid = (process.getuid ? process.getuid() : null),
  dockerEnv = fs.existsSync('/.dockerenv'),
  cgroup = readInitCgroup(),
} = {}) {
  if (uid === 0) return true;
  if (dockerEnv) return true;
  if (cgroup && /docker|kubepods|containerd|lxc/.test(cgroup)) return true;
  return false;
}

function readInitCgroup() {
  try {
    return fs.readFileSync('/proc/1/cgroup', 'utf8');
  } catch (_e) {
    return null; // not Linux, or unreadable — no container signal
  }
}

function buildChromeArgs({ chosenPort, chromeUserDataDir, chromeHeadless, noSandbox = sandboxDisableNeeded() }) {
  const args = [
    `--remote-debugging-port=${chosenPort}`,
    `--user-data-dir=${chromeUserDataDir}`,
    '--no-first-run',
    '--no-default-browser-check',
    '--disable-search-engine-choice-screen',
    '--password-store=basic',
    '--use-mock-keychain',
    '--disable-background-networking',
    '--disable-background-timer-throttling',
    '--disable-backgrounding-occluded-windows',
    '--disable-breakpad',
    '--disable-client-side-phishing-detection',
    '--disable-component-update',
    '--disable-default-apps',
    '--disable-dev-shm-usage',
    '--disable-extensions',
    '--disable-features=Translate,TranslateUI,OptimizationHints',
    '--disable-hang-monitor',
    '--disable-ipc-flooding-protection',
    '--disable-popup-blocking',
    '--disable-prompt-on-repost',
    '--disable-sync',
    '--force-color-profile=srgb',
    '--metrics-recording-only',
    '--safebrowsing-disable-auto-update',
    '--disable-blink-features=AutomationControlled'
  ];

  if (noSandbox) {
    args.push('--no-sandbox');
  }

  if (chromeHeadless) {
    args.push('--headless=new');
  }

  // CHROME_EXTRA_ARGS: whitespace-separated extra flags to append, e.g. for
  // software WebGL in headless containers:
  //   CHROME_EXTRA_ARGS="--use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader"
  const extraArgs = process.env.CHROME_EXTRA_ARGS;
  if (extraArgs) {
    const tokens = extraArgs.split(/\s+/).filter(Boolean);
    args.push(...tokens);
  }

  return args;
}

module.exports = {
  PORT_RANGE_START,
  PORT_RANGE_END,
  chromeHttpAt,
  getXdgCacheHome,
  getChromeProfileDir,
  getProfileMetaPath,
  readProfileMeta,
  writeProfileMeta,
  clearProfileMeta,
  isPortAlive,
  isPortFree,
  portFreeFromProbes,
  findAvailablePort,
  findPidOnPort,
  findOrphanChromeForProfile,
  buildChromeArgs,
  sandboxDisableNeeded,
};
```

## File: skills/browsing/lib/chrome-process.js
size: 17839
sha: c6d9112688cd306137184cdae2e510e80b7a0771
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/chrome-process.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/chrome-process.js

## File: skills/browsing/lib/code-list-detector.js
size: 15267
sha: 4fe5e7b3dda63d35a6433f9c7b49d79e83851655
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/code-list-detector.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/code-list-detector.js

## File: skills/browsing/lib/console-logging.js
size: 2623
sha: a433296612406e804c0914192d7bfb0c728b4206
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/console-logging.js

```text
/**
 * Page console-message capture.
 *
 * `enableConsoleLogging` subscribes to `Runtime.consoleAPICalled` events on
 * the existing pageSession (bridge) connection and streams console output into
 * `state.consoleMessages` keyed by `sessionId`.
 *
 * `getConsoleMessages` reads the buffer — optionally filtered by timestamp.
 * `clearConsoleMessages` resets the buffer for a tab.
 *
 * `attachConsoleLogging({ state, getPageSession })` returns the bound API.
 */
function attachConsoleLogging({ state, getPageSession }) {
  async function enableConsoleLogging(tabIndexOrWsUrl) {
    const ps = await getPageSession(tabIndexOrWsUrl);

    if (!state.consoleMessages.has(ps.sessionId)) {
      state.consoleMessages.set(ps.sessionId, []);
    }

    await ps.enableDomain('Runtime');

    ps.onEvent((msg) => {
      if (msg.method === 'Runtime.consoleAPICalled') {
        const entry = msg.params;
        const timestamp = new Date().toISOString();
        const level = entry.type || 'log';
        const args = entry.args || [];

        const text = args.map(arg => {
          if (arg.type === 'string') return arg.value;
          if (arg.type === 'number') return String(arg.value);
          if (arg.type === 'boolean') return String(arg.value);
          if (arg.type === 'object') return arg.description || '[Object]';
          return String(arg.value || arg.description || arg.type);
        }).join(' ');

        const messages = state.consoleMessages.get(ps.sessionId) || [];
        // Dedup: skip if the last entry has the same level+text at the same
        // timestamp (prevents double-fire when multiple CDP event listeners
        // route the same console call through the same handler).
        const last = messages[messages.length - 1];
        if (!last || last.timestamp !== timestamp || last.level !== level || last.text !== text) {
          messages.push({ timestamp, level, text });
          state.consoleMessages.set(ps.sessionId, messages);
        }
      }
    });
  }

  async function getConsoleMessages(tabIndexOrWsUrl, sinceTime = null) {
    const ps = await getPageSession(tabIndexOrWsUrl);
    const messages = state.consoleMessages.get(ps.sessionId) || [];

    if (!sinceTime) {
      return messages;
    }

    return messages.filter(msg => new Date(msg.timestamp) > sinceTime);
  }

  async function clearConsoleMessages(tabIndexOrWsUrl) {
    const ps = await getPageSession(tabIndexOrWsUrl);
    state.consoleMessages.set(ps.sessionId, []);
  }

  return { enableConsoleLogging, getConsoleMessages, clearConsoleMessages };
}

module.exports = { attachConsoleLogging };
```

## File: skills/browsing/lib/cookies.js
size: 582
sha: e7e16199adc16c3155ee74df271d1e9632b6a231
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/cookies.js

```text
/**
 * Cookie management — currently just a single "clear everything" action.
 *
 * Takes `getPageSession(tabIndexOrWsUrl)`: a resolver provided by chrome-ws-lib
 * that handles both tab-index and ws-url inputs, lazy-bootstraps the CDP bridge,
 * and returns a pageSession driving CDP via flatten mode.
 */
function attachCookies({ getPageSession }) {
  async function clearCookies(tabIndexOrWsUrl) {
    const ps = await getPageSession(tabIndexOrWsUrl);
    await ps.send('Network.clearBrowserCookies', {});
  }

  return { clearCookies };
}

module.exports = { attachCookies };
```

## File: skills/browsing/lib/credential-guard.js
size: 19631
sha: 72900d16a21c42634533beb8cbfe7f4128619269
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/credential-guard.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/credential-guard.js

## File: skills/browsing/lib/dialogs-render.js
size: 5346
sha: abda773d857a769508f1747d6467df367f48e5c7
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/dialogs-render.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/dialogs-render.js

## File: skills/browsing/lib/dialogs-router.js
size: 5189
sha: ee662fb9d9a289873b8d4218aafed6c9553e95d1
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/dialogs-router.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/dialogs-router.js

## File: skills/browsing/lib/dialogs.js
size: 10964
sha: dd6696d9479fff43073b0c0fa8b02cbd244c981a
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/dialogs.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/dialogs.js

## File: skills/browsing/lib/element-selector.js
size: 3933
sha: 2f633956629eea515e63adf9435bd2f9a15b6fec
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/element-selector.js

```text
/**
 * Element selector helpers — pure functions that produce JavaScript source
 * strings to be evaluated in the page via CDP Runtime.evaluate.
 *
 * No closure state, no Chrome session dependency. The returned strings are
 * interpolated into larger CDP `expression` payloads.
 */

// Generate element selection code (supports CSS and XPath).
// Prefers visible elements (non-zero bounding rect) over hidden ones.
// Falls back to first DOM match with a console.warn if all matches are hidden.
// For XPath with text()='...', also tries normalize-space() fallback for mixed content elements.
function getElementSelector(selector) {
  if (selector.startsWith('/') || selector.startsWith('//')) {
    // XPath selector - collect all matches, prefer visible
    const hasTextEquals = /text\(\)\s*=\s*['"]/.test(selector);
    const xpaths = [JSON.stringify(selector)];
    if (hasTextEquals) {
      const fallbackSelector = selector.replace(/text\(\)\s*=\s*(['"])(.*?)\1/g, "normalize-space()=$1$2$1");
      xpaths.push(JSON.stringify(fallbackSelector));
    }
    return `(() => {
      var all = [];
      var seen = new Set();
      [${xpaths.join(', ')}].forEach(function(xpath) {
        var iter = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
        var node;
        while (node = iter.iterateNext()) {
          if (!seen.has(node)) { seen.add(node); all.push(node); }
        }
      });
      if (all.length === 0) return null;
      var visible = all.find(function(el) {
        var r = el.getBoundingClientRect();
        return r.width > 0 && r.height > 0;
      });
      if (visible) return visible;
      console.warn('[superpowers-chrome] All ' + all.length + ' elements matching XPath have zero dimensions; using first match');
      return all[0];
    })()`;
  } else {
    // CSS selector - prefer visible elements
    return `(() => {
      var all = document.querySelectorAll(${JSON.stringify(selector)});
      if (all.length === 0) return null;
      var visible = Array.from(all).find(function(el) {
        var r = el.getBoundingClientRect();
        return r.width > 0 && r.height > 0;
      });
      if (visible) return visible;
      console.warn('[superpowers-chrome] All ' + all.length + ' elements matching ' + ${JSON.stringify(JSON.stringify(selector))} + ' have zero dimensions; using first match');
      return all[0];
    })()`;
  }
}

// Get all matching elements (used by multi-element warnings).
// For XPath with text()='...', also tries normalize-space() fallback for mixed content elements.
function getElementSelectorAll(selector) {
  if (selector.startsWith('/') || selector.startsWith('//')) {
    // XPath - get all matches, with fallback for text()='...' patterns
    const hasTextEquals = /text\(\)\s*=\s*['"]/.test(selector);
    if (hasTextEquals) {
      const fallbackSelector = selector.replace(/text\(\)\s*=\s*(['"])(.*?)\1/g, "normalize-space()=$1$2$1");
      return `(() => {
        const result = [];
        const seen = new Set();
        for (const xpath of [${JSON.stringify(selector)}, ${JSON.stringify(fallbackSelector)}]) {
          const iterator = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
          let node;
          while (node = iterator.iterateNext()) {
            if (!seen.has(node)) { seen.add(node); result.push(node); }
          }
        }
        return result;
      })()`;
    }
    return `(() => {
      const result = [];
      const iterator = document.evaluate(${JSON.stringify(selector)}, document, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE, null);
      let node;
      while (node = iterator.iterateNext()) result.push(node);
      return result;
    })()`;
  } else {
    // CSS selector
    return `Array.from(document.querySelectorAll(${JSON.stringify(selector)}))`;
  }
}

module.exports = { getElementSelector, getElementSelectorAll };
```

## File: skills/browsing/lib/evaluation.js
size: 2876
sha: 918950453b039bc57d044dfd3f4edf590f675019
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/evaluation.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/evaluation.js

## File: skills/browsing/lib/extraction.js
size: 12563
sha: 760116ca2e94ee100ee13f547d5610707204395c
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/extraction.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/extraction.js

## File: skills/browsing/lib/file-upload.js
size: 1796
sha: 0164592de7255ef7773f3c04e9b3c8c4826526f4
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/file-upload.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/file-upload.js

## File: skills/browsing/lib/html-diff.js
size: 3375
sha: d8402b0f1bc32d05e43d6b5959935878ceae45a5
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/html-diff.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/html-diff.js

## File: skills/browsing/lib/key-definitions.js
size: 4790
sha: 804f4fd106f069936fa1898507073151db0ee684
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/key-definitions.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/key-definitions.js

## File: skills/browsing/lib/keyboard-input.js
size: 10300
sha: 186f3a46ea87722cd7cc54faa84173de6f018c8a
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/keyboard-input.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/keyboard-input.js

## File: skills/browsing/lib/mouse.js
size: 15814
sha: 0dfa362984ed3a3908c2ea40da731b1b84863a2d
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/mouse.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/mouse.js

## File: skills/browsing/lib/navigation.js
size: 10786
sha: 48e1636c3dbc0a6c9395d79369974bded69246da
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/navigation.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/navigation.js

## File: skills/browsing/lib/page-scripts/dom-summary.js
size: 1719
sha: 81b81681ead51757b24d33da66df1788c223b9cb
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/dom-summary.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/dom-summary.js

## File: skills/browsing/lib/page-scripts/html-with-scrub.js
size: 32385
sha: db9696af82197b0eeee0f0a64f46d531c422e454
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/html-with-scrub.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/html-with-scrub.js

## File: skills/browsing/lib/page-scripts/markdown.js
size: 11202
sha: 9cb82b4cfd944a3ddfee9e6f1ee180e16391b52d
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/markdown.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/markdown.js

## File: skills/browsing/lib/page-scripts/permission-shim.js
size: 3034
sha: ef387c98758c9f44778b35e2bc46fc38c44fc5f5
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/permission-shim.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/permission-shim.js

## File: skills/browsing/lib/page-scripts/rendered-text.js
size: 1165
sha: c5cb2079950baad5612d14f6ba1974ce91925369
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/rendered-text.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-scripts/rendered-text.js

## File: skills/browsing/lib/page-session.js
size: 3877
sha: a059eb0ac41d39a5aef7546a6fa5fb27d381610b
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-session.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/page-session.js

## File: skills/browsing/lib/profile-lock.js
size: 6425
sha: 3996913266f9d5e31ba345053388621cef3c900b
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/profile-lock.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/profile-lock.js

## File: skills/browsing/lib/screenshot.js
size: 6436
sha: ac9a994f427ea95ae908e9f40044d34a8cb030f6
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/screenshot.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/screenshot.js

## File: skills/browsing/lib/secret-marker.js
size: 6787
sha: 3fc2f7e0798b426d9374bf8426dbd09631e9d75b
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/secret-marker.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/secret-marker.js

## File: skills/browsing/lib/secret-pattern.js
size: 19051
sha: daef7a180ba9f6e3887279791b1ea29724f85f94
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/secret-pattern.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/secret-pattern.js

## File: skills/browsing/lib/select-option.js
size: 3559
sha: cea236861ba4dc072ffee586c91e7de23415afe2
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/select-option.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/select-option.js

## File: skills/browsing/lib/sensitive-url.js
size: 24298
sha: 3b1694f1fd0f43c394c9ac3072778bd7de1d32ba
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/sensitive-url.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/sensitive-url.js

## File: skills/browsing/lib/session-state.js
size: 2851
sha: f4c8b66c8c0a9591ed27e57f2ba5561ee986f528
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/session-state.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/session-state.js

## File: skills/browsing/lib/set-attribute.js
size: 10095
sha: d3e2221b81bf0ab86c5629d77d9d7022e6736e47
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/set-attribute.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/set-attribute.js

## File: skills/browsing/lib/tabs.js
size: 5944
sha: af35f31ab7982b2327085a18282e480e5079665b
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/tabs.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/tabs.js

## File: skills/browsing/lib/viewport.js
size: 4224
sha: 5606b66c509a4fa0dc0e182da9f57e62568a2436
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/viewport.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/viewport.js

## File: skills/browsing/lib/websocket-client.js
size: 4383
sha: e821a03a98db221ae074c49c68e0d671c31d6da3
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/websocket-client.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/lib/websocket-client.js

## File: skills/browsing/package.json
size: 249
sha: c951bba0e1bf27f1d9015316888601ad5583b742
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/package.json

```text
{
  "name": "chrome-ws",
  "version": "1.0.0",
  "description": "Lightweight Chrome DevTools Protocol client for direct browser control - zero dependencies",
  "bin": {
    "chrome-ws": "./chrome-ws"
  },
  "engines": {
    "node": ">=16.0.0"
  }
}
```

## File: skills/browsing/test-chrome-args.js
size: 3281
sha: de449c0ebaa9112f115b766ba742a9e2def9b74c
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-chrome-args.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-chrome-args.js

## File: skills/browsing/test-cookies.js
size: 525
sha: 403296bd964ce929cd92631bf058ee91e0c5ef8c
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-cookies.js

```text
/**
 * Simple test for cookie functions
 * Run with: node test-cookies.js
 */

const chromeLib = require('./chrome-ws-lib.js').createSession();

async function testCookieFunctionsExist() {
  console.log('Testing that cookie functions are exported...');

  if (typeof chromeLib.clearCookies !== 'function') {
    throw new Error('clearCookies not exported');
  }

  console.log('All cookie functions exported');
}

testCookieFunctionsExist().catch(err => {
  console.error('Test failed:', err.message);
  process.exit(1);
});
```

## File: skills/browsing/test-e2e.sh
size: 1157
sha: 0da934fa08692aca2748971974df80be20f25425
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-e2e.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-e2e.sh

## File: skills/browsing/test-extract.sh
size: 399
sha: 3280e0a3f7acf25cf427410c6c3cf38ab4e7f274
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-extract.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-extract.sh

## File: skills/browsing/test-interact.sh
size: 304
sha: 8b4e651295dacc03cd230d5ed4b9639c559bd4d1
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-interact.sh

```text
#!/usr/bin/env bash
# Test interaction commands

echo "=== Navigate to search page ==="
./chrome-ws navigate 0 "https://google.com"

echo "=== Fill search box ==="
./chrome-ws fill 0 "textarea[name=q]" "chrome devtools protocol"

echo "=== Click search button ==="
./chrome-ws click 0 "input[name=btnK]"
```

## File: skills/browsing/test-navigate.sh
size: 243
sha: 03c37fa95b73cae54aa80c4a3bf9c3fd2bd2bf2f
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-navigate.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-navigate.sh

## File: skills/browsing/test-raw.sh
size: 361
sha: 6a3d46a7a802dc89184767541e730abd9acd2540
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-raw.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-raw.sh

## File: skills/browsing/test-tabs.sh
size: 336
sha: f66c276a37fddee14d27888627656bd3518426d7
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-tabs.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-tabs.sh

## File: skills/browsing/test-viewport.js
size: 750
sha: ced700f37a635db636cf1f47a206fe5fef7c4b12
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-viewport.js

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-viewport.js

## File: skills/browsing/test-wait.sh
size: 233
sha: 68c8230056ede998d730871847d7d7573c6a3dd2
upstream: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-wait.sh

Content is not inlined in the catalog. Read the upstream file: https://github.com/obra/superpowers-chrome/blob/main/skills/browsing/test-wait.sh
