[
  {
    "path": ".github/CONTRIBUTING.md",
    "content": "# Contributing to Open Browser\n\nThank you for your interest in contributing!\n\n## Getting Started\n\n1. Fork the repository\n2. Clone your fork: `git clone https://github.com/YOUR_USERNAME/openbrowser.git`\n3. Install dependencies: `bun install`\n4. Create a branch: `git checkout -b my-feature`\n5. Make your changes and add tests\n6. Run tests: `bun run test`\n7. Submit a pull request\n\n## Code Style\n\nWe use [Biome](https://biomejs.dev/) for formatting and linting. Run `bun run format` before committing.\n\n## Reporting Issues\n\nPlease use GitHub Issues to report bugs or request features. Include:\n- Steps to reproduce\n- Expected vs actual behavior\n- Browser and OS version\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - uses: oven-sh/setup-bun@v2\n      - run: bun install\n      - run: bun run build\n      - run: bun run test\n      - run: bun run lint\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules/\ndist/\n.env\n*.tsbuildinfo\n.DS_Store\ntraces/\ncoverage/\nrecordings/\ntmp/\n*.log\n"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2024-2026 Open Browser Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<h1 align=\"center\">Open Browser</h1>\n\n<p align=\"center\">\n  <b>AI-powered autonomous web browsing framework for TypeScript.</b>\n</p>\n\n<p align=\"center\">\n  <a href=\"https://github.com/ntegrals/openbrowser/blob/main/LICENSE\"><img src=\"https://img.shields.io/badge/license-MIT-blue.svg\" alt=\"License\"></a>\n  <a href=\"https://github.com/ntegrals/openbrowser\"><img src=\"https://img.shields.io/github/stars/ntegrals/openbrowser?style=social\" alt=\"GitHub stars\"></a>\n</p>\n\n<img src=\"./media/header.png\" alt=\"Header\"></a>\n\n---\n\nGive an AI agent a browser. It clicks, types, navigates, and extracts data — autonomously completing tasks on any website. Built on Playwright with first-class support for OpenAI, Anthropic, and Google models.\n\n> **Production-ready since v1.0.** Contributions welcome.\n\n## Why Open Browser?\n\n- **Autonomous agents**: Describe a task in natural language, and an AI agent navigates the web to complete it — clicking, typing, scrolling, and extracting data without manual scripting\n- **Multi-model support**: Works with OpenAI, Anthropic, and Google out of the box via the Vercel AI SDK — swap models with a single flag\n- **Interactive REPL**: Drop into a live browser session and issue commands interactively — great for debugging, prototyping, and exploration\n- **Sandboxed execution**: Run agents in resource-limited environments with CPU/memory monitoring, timeouts, and domain restrictions\n- **Production-ready**: Stall detection, cost tracking, session management, replay recording, and comprehensive error handling\n- **Open source**: MIT licensed, fully extensible, bring your own API keys\n\n## Quick Start\n\n```bash\n# Install dependencies\nbun install\n\n# Set up your API keys\ncp .env.example .env\n# Edit .env with your API keys\n\n# Run an agent\nbun run open-browser run \"Find the top story on Hacker News and summarize it\"\n\n# Or open a browser interactively\nbun run open-browser interactive\n```\n\n## Architecture\n\nOpen Browser is a monorepo with three packages:\n\n| Package                     | Description                                                                |\n| --------------------------- | -------------------------------------------------------------------------- |\n| **`open-browser`**          | Core library — agent logic, browser control, DOM analysis, LLM integration |\n| **`@open-browser/cli`**     | Command-line interface for running agents and browser commands             |\n| **`@open-browser/sandbox`** | Sandboxed execution with resource limits and monitoring                    |\n\n## CLI Commands\n\n### Run an AI Agent\n\n```bash\nopen-browser run <task> [options]\n```\n\nDescribe what you want done. The agent figures out the rest.\n\n```bash\n# Search and extract information\nopen-browser run \"Find the price of the MacBook Pro on apple.com\"\n\n# Fill out forms\nopen-browser run \"Sign up for the newsletter on example.com with test@email.com\"\n\n# Multi-step workflows\nopen-browser run \"Go to GitHub, find the open-browser repo, and star it\"\n```\n\n| Option                       | Description                               |\n| ---------------------------- | ----------------------------------------- |\n| `-m, --model <model>`        | Model to use (default: `gpt-4o`)          |\n| `-p, --provider <provider>`  | Provider: `openai`, `anthropic`, `google` |\n| `--headless / --no-headless` | Show or hide the browser window           |\n| `--max-steps <n>`            | Max agent steps (default: `25`)           |\n| `-v, --verbose`              | Show detailed step info                   |\n| `--no-cost`                  | Hide cost tracking                        |\n\n### Browser Commands\n\n```bash\nopen-browser open <url>              # Open a URL\nopen-browser click <selector>        # Click an element\nopen-browser type <selector> <text>  # Type into an input\nopen-browser screenshot [output]     # Capture a screenshot\nopen-browser eval <expression>       # Run JavaScript on the page\nopen-browser extract <goal>          # Extract content as markdown\nopen-browser state                   # Show current URL, title, and tabs\nopen-browser sessions                # List active browser sessions\n```\n\n### Interactive REPL\n\n```bash\nopen-browser interactive\n```\n\nDrop into a live `browser>` prompt with full control:\n\n```\nbrowser> open https://news.ycombinator.com\nbrowser> extract \"top 5 stories with titles and points\"\nbrowser> click .morelink\nbrowser> screenshot front-page.png\nbrowser> help\n```\n\n## Using as a Library\n\n```typescript\nimport { Agent, createViewport, createModel } from 'open-browser'\n\nconst viewport = await createViewport({ headless: true })\nconst model = createModel('openai', 'gpt-4o')\n\nconst agent = new Agent({\n  viewport,\n  model,\n  task: 'Go to example.com and extract the main heading',\n  settings: {\n    stepLimit: 50,\n    enableScreenshots: true,\n  },\n})\n\nconst result = await agent.run()\nconsole.log(result)\n```\n\n### Sandboxed Execution\n\nRun agents with resource limits and monitoring:\n\n```typescript\nimport { Sandbox } from '@open-browser/sandbox'\n\nconst sandbox = new Sandbox({\n  timeout: 300_000, // 5 minute timeout\n  maxMemoryMB: 512, // Memory limit\n  allowedDomains: ['example.com'],\n  stepLimit: 100,\n  captureOutput: true,\n})\n\nconst result = await sandbox.run({\n  task: 'Complete the checkout form',\n  model: languageModel,\n})\n\nconsole.log(result.metrics) // steps, URLs visited, CPU time\n```\n\n## Configuration\n\n### Environment Variables\n\n```bash\n# LLM Provider Keys (at least one required)\nOPENAI_API_KEY=sk-...\nANTHROPIC_API_KEY=sk-ant-...\nGOOGLE_GENERATIVE_AI_API_KEY=...\n\n# Browser\nBROWSER_HEADLESS=true\nBROWSER_DISABLE_SECURITY=false\n\n# Recording & Debugging\nOPEN_BROWSER_TRACE_PATH=./traces\nOPEN_BROWSER_SAVE_RECORDING_PATH=./recordings\n```\n\n### Agent Configuration\n\n| Setting             | Default  | Description                               |\n| ------------------- | -------- | ----------------------------------------- |\n| `stepLimit`         | `100`    | Maximum agent iterations                  |\n| `commandsPerStep`   | `10`     | Actions per agent step                    |\n| `failureThreshold`  | `5`      | Consecutive failures before stopping      |\n| `enableScreenshots` | `true`   | Include page screenshots in agent context |\n| `contextWindowSize` | `128000` | Token budget for conversation             |\n| `allowedUrls`       | `[]`     | Restrict navigation to specific URLs      |\n| `blockedUrls`       | `[]`     | Block navigation to specific URLs         |\n\n### Viewport Configuration\n\n| Setting            | Default         | Description                                 |\n| ------------------ | --------------- | ------------------------------------------- |\n| `headless`         | `true`          | Run browser without visible window          |\n| `width` / `height` | `1280` / `1100` | Browser window dimensions                   |\n| `relaxedSecurity`  | `false`         | Disable browser security features           |\n| `proxy`            | —               | Proxy server configuration                  |\n| `cookieFile`       | —               | Path to cookie file for persistent sessions |\n\n## How It Works\n\n```\n                    ┌─────────────┐\n  \"Book a flight\"   │             │\n  ───────────────►  │    Agent    │  ◄── LLM (OpenAI / Anthropic / Google)\n                    │             │\n                    └──────┬──────┘\n                           │\n                    ┌──────▼──────┐\n                    │   Commands  │  click, type, scroll, extract, navigate...\n                    └──────┬──────┘\n                           │\n                    ┌──────▼──────┐\n                    │  Viewport   │  Playwright browser instance\n                    └──────┬──────┘\n                           │\n                    ┌──────▼──────┐\n                    │  DOM / Page │  Snapshot, interactive elements, content\n                    └─────────────┘\n```\n\n1. You describe a **task** in natural language\n2. The **Agent** sends the current page state + task to an LLM\n3. The LLM decides what **commands** to execute (click, type, navigate, extract...)\n4. Commands execute against the **Viewport** (Playwright browser)\n5. The agent observes the result, detects stalls, and loops until the task is complete\n\n## Model Support\n\n| Provider      | Example Models                                  | Flag           |\n| ------------- | ----------------------------------------------- | -------------- |\n| **OpenAI**    | `gpt-4o`, `gpt-4o-mini`, `o1`                   | `-p openai`    |\n| **Anthropic** | `claude-sonnet-4-5-20250929`, `claude-opus-4-6` | `-p anthropic` |\n| **Google**    | `gemini-2.0-flash`, `gemini-2.5-pro`            | `-p google`    |\n\n## Project Structure\n\n```\npackages/\n├── core/                    # Core library (open-browser)\n│   └── src/\n│       ├── agent/           # Agent logic, conversation, stall detection\n│       ├── commands/        # Action schemas and executor (25+ commands)\n│       ├── viewport/        # Browser control, events, guards\n│       ├── page/            # DOM analysis, content extraction\n│       ├── model/           # LLM adapter and message formatting\n│       ├── metering/        # Cost tracking\n│       ├── bridge/          # IPC server/client\n│       └── config/          # Configuration types\n├── cli/                     # CLI (@open-browser/cli)\n│   └── src/\n│       ├── commands/        # CLI command implementations\n│       └── index.ts         # Entry point\n└── sandbox/                 # Sandbox (@open-browser/sandbox)\n    └── src/\n        └── sandbox.ts       # Resource-limited execution\n```\n\n## Development\n\n```bash\n# Install dependencies\nbun install\n\n# Type check\nbun run build\n\n# Run tests\nbun run test\n\n# Lint\nbun run lint\n\n# Format\nbun run format\n```\n\n## Contributing\n\nContributions are welcome! Please see [CONTRIBUTING.md](.github/CONTRIBUTING.md) for guidelines.\n\n## License\n\n[MIT](LICENSE)\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/1.9.0/schema.json\",\n  \"organizeImports\": {\n    \"enabled\": true\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": true,\n      \"complexity\": {\n        \"noForEach\": \"off\"\n      },\n      \"style\": {\n        \"noNonNullAssertion\": \"off\",\n        \"useConst\": \"warn\"\n      },\n      \"suspicious\": {\n        \"noExplicitAny\": \"off\"\n      }\n    }\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"indentStyle\": \"tab\",\n    \"indentWidth\": 2,\n    \"lineWidth\": 120\n  },\n  \"javascript\": {\n    \"formatter\": {\n      \"quoteStyle\": \"single\",\n      \"semicolons\": \"always\",\n      \"trailingCommas\": \"all\"\n    }\n  },\n  \"files\": {\n    \"ignore\": [\"node_modules\", \"dist\", \"*.json\", \"*.d.ts\"]\n  }\n}\n"
  },
  {
    "path": "bunfig.toml",
    "content": "[install]\npeer = false\n\n[test]\ntimeout = 60000\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"open-browser-monorepo\",\n  \"private\": true,\n  \"workspaces\": [\"packages/*\"],\n  \"scripts\": {\n    \"build\": \"bun run --filter '*' build\",\n    \"test\": \"bun run --filter '*' test\",\n    \"lint\": \"biome check .\",\n    \"format\": \"biome format --write .\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"^1.9.4\",\n    \"@types/bun\": \"^1.2.0\",\n    \"typescript\": \"^5.8.0\"\n  },\n  \"trustedDependencies\": [\n    \"@biomejs/biome\"\n  ]\n}\n"
  },
  {
    "path": "packages/cli/package.json",
    "content": "{\n  \"name\": \"@open-browser/cli\",\n  \"version\": \"1.1.0\",\n  \"description\": \"CLI for Open Browser - AI-powered autonomous web browsing\",\n  \"type\": \"module\",\n  \"main\": \"src/index.ts\",\n  \"bin\": {\n    \"open-browser\": \"src/index.ts\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc --noEmit\",\n    \"test\": \"bun test\",\n    \"start\": \"bun run src/index.ts\"\n  },\n  \"dependencies\": {\n    \"open-browser\": \"workspace:*\",\n    \"commander\": \"^12.1.0\",\n    \"chalk\": \"^5.4.0\"\n  },\n  \"license\": \"MIT\"\n}\n"
  },
  {
    "path": "packages/cli/src/commands/click.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { sessionManager } from '../globals.js';\n\nexport function registerClickCommand(program: Command): void {\n\tprogram\n\t\t.command('click')\n\t\t.description('Click on an element matching the given CSS selector')\n\t\t.argument('<selector>', 'CSS selector of the element to click')\n\t\t.option('-s, --session <id>', 'Session ID to use')\n\t\t.action(async (selector: string, options: { session?: string }) => {\n\t\t\ttry {\n\t\t\t\tconst browser = options.session\n\t\t\t\t\t? sessionManager.get(options.session)\n\t\t\t\t\t: sessionManager.getDefault();\n\n\t\t\t\tif (!browser) {\n\t\t\t\t\tconsole.error(chalk.red('No active session. Use \"open\" command first.'));\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\n\t\t\t\tawait browser.click(selector);\n\t\t\t\tconsole.log(chalk.green('Clicked:'), selector);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to click:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/eval.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { sessionManager } from '../globals.js';\n\nexport function registerEvalCommand(program: Command): void {\n\tprogram\n\t\t.command('eval')\n\t\t.description('Evaluate a JavaScript expression in the browser')\n\t\t.argument('<expression>', 'JavaScript expression to evaluate')\n\t\t.option('-s, --session <id>', 'Session ID to use')\n\t\t.action(async (expression: string, options: { session?: string }) => {\n\t\t\ttry {\n\t\t\t\tconst browser = options.session\n\t\t\t\t\t? sessionManager.get(options.session)\n\t\t\t\t\t: sessionManager.getDefault();\n\n\t\t\t\tif (!browser) {\n\t\t\t\t\tconsole.error(chalk.red('No active session. Use \"open\" command first.'));\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\n\t\t\t\tconst result = await browser.evaluate(expression);\n\n\t\t\t\tif (result === undefined) {\n\t\t\t\t\tconsole.log(chalk.dim('undefined'));\n\t\t\t\t} else if (result === null) {\n\t\t\t\t\tconsole.log(chalk.dim('null'));\n\t\t\t\t} else if (typeof result === 'object') {\n\t\t\t\t\tconsole.log(JSON.stringify(result, null, 2));\n\t\t\t\t} else {\n\t\t\t\t\tconsole.log(String(result));\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Evaluation failed:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/extract.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { extractMarkdown } from 'open-browser';\nimport { sessionManager } from '../globals.js';\n\nexport function registerExtractCommand(program: Command): void {\n\tprogram\n\t\t.command('extract')\n\t\t.description('Extract content from the current page as markdown')\n\t\t.argument('<goal>', 'Description of what to extract (used as a label)')\n\t\t.option('-s, --session <id>', 'Session ID to use')\n\t\t.action(async (goal: string, options: { session?: string }) => {\n\t\t\ttry {\n\t\t\t\tconst browser = options.session\n\t\t\t\t\t? sessionManager.get(options.session)\n\t\t\t\t\t: sessionManager.getDefault();\n\n\t\t\t\tif (!browser) {\n\t\t\t\t\tconsole.error(chalk.red('No active session. Use \"open\" command first.'));\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\n\t\t\t\tconsole.log(chalk.dim(`Extracting: ${goal}`));\n\n\t\t\t\tconst markdown = await extractMarkdown(browser.currentPage);\n\n\t\t\t\tif (!markdown) {\n\t\t\t\t\tconsole.log(chalk.yellow('No content extracted from the page.'));\n\t\t\t\t} else {\n\t\t\t\t\tconsole.log(markdown);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Extraction failed:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/interactive.ts",
    "content": "import * as readline from 'node:readline';\nimport type { Command } from 'commander';\nimport chalk from 'chalk';\nimport {\n\tViewport,\n\textractMarkdown,\n} from 'open-browser';\nimport {\n\tSpinner,\n\tdisplayInfo,\n\tdisplayError,\n\tdisplaySeparator,\n} from '../display.js';\n\ninterface InteractiveOptions {\n\theadless: boolean;\n}\n\n/**\n * Interactive REPL-like session for browser automation.\n * Supports commands: open, click, type, eval, extract, screenshot, state, back, forward, tabs, help, quit\n */\nexport function registerInteractiveCommand(program: Command): void {\n\tprogram\n\t\t.command('interactive')\n\t\t.alias('repl')\n\t\t.description('Start an interactive browser session (REPL mode)')\n\t\t.option('--headless', 'Run browser in headless mode', false)\n\t\t.action(async (options: InteractiveOptions) => {\n\t\t\tconsole.log(chalk.bold.white('Interactive Browser Session'));\n\t\t\tconsole.log(chalk.dim('Type \"help\" for available commands, \"quit\" to exit.'));\n\t\t\tdisplaySeparator();\n\n\t\t\tlet browser: Viewport | null = null;\n\n\t\t\ttry {\n\t\t\t\tconst spinner = new Spinner('Starting browser...');\n\t\t\t\tspinner.start();\n\n\t\t\t\tbrowser = new Viewport({\n\t\t\t\t\theadless: options.headless,\n\t\t\t\t});\n\t\t\t\tawait browser.start();\n\n\t\t\t\tspinner.stop(chalk.green('Browser ready.'));\n\t\t\t\tconsole.log('');\n\n\t\t\t\tconst rl = readline.createInterface({\n\t\t\t\t\tinput: process.stdin,\n\t\t\t\t\toutput: process.stdout,\n\t\t\t\t\tprompt: chalk.cyan('browser> '),\n\t\t\t\t\tterminal: true,\n\t\t\t\t});\n\n\t\t\t\trl.prompt();\n\n\t\t\t\trl.on('line', async (line) => {\n\t\t\t\t\tconst trimmed = line.trim();\n\t\t\t\t\tif (!trimmed) {\n\t\t\t\t\t\trl.prompt();\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst [command, ...args] = parseCommandLine(trimmed);\n\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst shouldQuit = await handleCommand(\n\t\t\t\t\t\t\tcommand.toLowerCase(),\n\t\t\t\t\t\t\targs,\n\t\t\t\t\t\t\tbrowser!,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (shouldQuit) {\n\t\t\t\t\t\t\trl.close();\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tdisplayError(\n\t\t\t\t\t\t\terror instanceof Error ? error.message : String(error),\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\trl.prompt();\n\t\t\t\t});\n\n\t\t\t\trl.on('close', async () => {\n\t\t\t\t\tconsole.log('');\n\t\t\t\t\tdisplayInfo('Closing browser session...');\n\t\t\t\t\tif (browser) {\n\t\t\t\t\t\tawait browser.close().catch(() => {});\n\t\t\t\t\t}\n\t\t\t\t\tprocess.exit(0);\n\t\t\t\t});\n\t\t\t} catch (error) {\n\t\t\t\tdisplayError(\n\t\t\t\t\terror instanceof Error ? error.message : String(error),\n\t\t\t\t);\n\t\t\t\tif (browser) {\n\t\t\t\t\tawait browser.close().catch(() => {});\n\t\t\t\t}\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n\n// ── Command Parsing ──\n\nfunction parseCommandLine(input: string): string[] {\n\tconst tokens: string[] = [];\n\tlet current = '';\n\tlet inQuote: string | null = null;\n\n\tfor (const char of input) {\n\t\tif (inQuote) {\n\t\t\tif (char === inQuote) {\n\t\t\t\tinQuote = null;\n\t\t\t} else {\n\t\t\t\tcurrent += char;\n\t\t\t}\n\t\t} else if (char === '\"' || char === \"'\") {\n\t\t\tinQuote = char;\n\t\t} else if (char === ' ' || char === '\\t') {\n\t\t\tif (current) {\n\t\t\t\ttokens.push(current);\n\t\t\t\tcurrent = '';\n\t\t\t}\n\t\t} else {\n\t\t\tcurrent += char;\n\t\t}\n\t}\n\n\tif (current) {\n\t\ttokens.push(current);\n\t}\n\n\treturn tokens;\n}\n\n// ── Command Handler ──\n\nasync function handleCommand(\n\tcommand: string,\n\targs: string[],\n\tbrowser: Viewport,\n): Promise<boolean> {\n\tswitch (command) {\n\t\tcase 'open':\n\t\tcase 'goto':\n\t\tcase 'navigate': {\n\t\t\tconst url = args[0];\n\t\t\tif (!url) {\n\t\t\t\tdisplayError('Usage: open <url>');\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst spinner = new Spinner(`Navigating to ${url}...`);\n\t\t\tspinner.start();\n\t\t\tawait browser.navigate(url);\n\t\t\tconst finalUrl = browser.currentPage.url();\n\t\t\tspinner.stop(`${chalk.green('Loaded:')} ${finalUrl}`);\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'tap': {\n\t\t\tconst selector = args.join(' ');\n\t\t\tif (!selector) {\n\t\t\t\tdisplayError('Usage: click <selector>');\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait browser.click(selector);\n\t\t\tconsole.log(chalk.green('Clicked:'), selector);\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'type': {\n\t\t\tconst selector = args[0];\n\t\t\tconst text = args.slice(1).join(' ');\n\t\t\tif (!selector || !text) {\n\t\t\t\tdisplayError('Usage: type <selector> <text>');\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tawait browser.type(selector, text);\n\t\t\tconsole.log(chalk.green('Typed:'), text);\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'eval':\n\t\tcase 'js': {\n\t\t\tconst expression = args.join(' ');\n\t\t\tif (!expression) {\n\t\t\t\tdisplayError('Usage: eval <expression>');\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tconst result = await browser.evaluate(expression);\n\t\t\tif (result === undefined) {\n\t\t\t\tconsole.log(chalk.dim('undefined'));\n\t\t\t} else if (result === null) {\n\t\t\t\tconsole.log(chalk.dim('null'));\n\t\t\t} else if (typeof result === 'object') {\n\t\t\t\tconsole.log(JSON.stringify(result, null, 2));\n\t\t\t} else {\n\t\t\t\tconsole.log(String(result));\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'extract':\n\t\tcase 'markdown': {\n\t\t\tconst spinner = new Spinner('Extracting page content...');\n\t\t\tspinner.start();\n\t\t\tconst markdown = await extractMarkdown(browser.currentPage);\n\t\t\tspinner.stop();\n\t\t\tif (markdown) {\n\t\t\t\t// Show first 2000 chars\n\t\t\t\tconst preview = markdown.length > 2000\n\t\t\t\t\t? `${markdown.slice(0, 2000)}\\n${chalk.dim(`... (${markdown.length} chars total)`)}`\n\t\t\t\t\t: markdown;\n\t\t\t\tconsole.log(preview);\n\t\t\t} else {\n\t\t\t\tconsole.log(chalk.yellow('No content found.'));\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'capture': {\n\t\t\tconst outputPath = args[0] || 'screenshot.png';\n\t\t\tconst result = await browser.screenshot(false);\n\t\t\tconst fs = await import('node:fs');\n\t\t\tconst path = await import('node:path');\n\t\t\tconst buffer = Buffer.from(result.base64, 'base64');\n\t\t\tconst resolved = path.resolve(outputPath);\n\t\t\tfs.writeFileSync(resolved, buffer);\n\t\t\tconsole.log(chalk.green('Screenshot saved:'), resolved);\n\t\t\tconsole.log(chalk.dim(`${result.width}x${result.height}`));\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'state':\n\t\tcase 'info': {\n\t\t\tconst state = await browser.getState();\n\t\t\tconsole.log(`${chalk.white('URL:')}   ${state.url}`);\n\t\t\tconsole.log(`${chalk.white('Title:')} ${state.title}`);\n\t\t\tif (state.tabs.length > 1) {\n\t\t\t\tconsole.log(`${chalk.white('Tabs:')}`);\n\t\t\t\tfor (const tab of state.tabs) {\n\t\t\t\t\tconst marker = tab.isActive ? chalk.cyan(' > ') : '   ';\n\t\t\t\t\tconsole.log(`${marker}[${tab.tabId}] ${tab.title || '(untitled)'} - ${tab.url}`);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'back': {\n\t\t\tawait browser.currentPage.goBack({ timeout: 5000 }).catch(() => {});\n\t\t\tconsole.log(chalk.green('Navigated back'));\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'forward': {\n\t\t\tawait browser.currentPage.goForward({ timeout: 5000 }).catch(() => {});\n\t\t\tconsole.log(chalk.green('Navigated forward'));\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'tabs': {\n\t\t\tconst state = await browser.getState();\n\t\t\tfor (const tab of state.tabs) {\n\t\t\t\tconst marker = tab.isActive ? chalk.cyan(' > ') : '   ';\n\t\t\t\tconsole.log(`${marker}[${tab.tabId}] ${tab.title || '(untitled)'} - ${tab.url}`);\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'url': {\n\t\t\tconsole.log(browser.currentPage.url());\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'title': {\n\t\t\tconst title = await browser.currentPage.title();\n\t\t\tconsole.log(title);\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'reload':\n\t\tcase 'refresh': {\n\t\t\tawait browser.currentPage.reload({ timeout: 10000 }).catch(() => {});\n\t\t\tconsole.log(chalk.green('Page reloaded'));\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'wait': {\n\t\t\tconst ms = Number.parseInt(args[0] || '1000', 10);\n\t\t\tconsole.log(chalk.dim(`Waiting ${ms}ms...`));\n\t\t\tawait new Promise((resolve) => setTimeout(resolve, ms));\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'help': {\n\t\t\tprintHelp();\n\t\t\treturn false;\n\t\t}\n\n\t\tcase 'quit':\n\t\tcase 'exit':\n\t\tcase 'q': {\n\t\t\treturn true;\n\t\t}\n\n\t\tdefault: {\n\t\t\tconsole.log(chalk.yellow(`Unknown command: ${command}`));\n\t\t\tconsole.log(chalk.dim('Type \"help\" for available commands.'));\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\nfunction printHelp(): void {\n\tconsole.log(chalk.bold('Available commands:'));\n\tconsole.log('');\n\tconst commands = [\n\t\t['open <url>', 'Navigate to a URL'],\n\t\t['click <selector>', 'Click an element'],\n\t\t['type <selector> <text>', 'Type text into an element'],\n\t\t['eval <expression>', 'Run JavaScript in the browser'],\n\t\t['extract', 'Extract page content as markdown'],\n\t\t['screenshot [path]', 'Take a screenshot'],\n\t\t['state', 'Show current browser state'],\n\t\t['back', 'Navigate back'],\n\t\t['forward', 'Navigate forward'],\n\t\t['tabs', 'List open tabs'],\n\t\t['url', 'Show current URL'],\n\t\t['title', 'Show current page title'],\n\t\t['reload', 'Reload the current page'],\n\t\t['wait [ms]', 'Wait for the specified time'],\n\t\t['help', 'Show this help message'],\n\t\t['quit', 'Exit the interactive session'],\n\t];\n\n\tfor (const [cmd, desc] of commands) {\n\t\tconsole.log(`  ${chalk.cyan(cmd.padEnd(25))} ${desc}`);\n\t}\n}\n"
  },
  {
    "path": "packages/cli/src/commands/open.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { sessionManager } from '../globals.js';\n\nexport function registerOpenCommand(program: Command): void {\n\tprogram\n\t\t.command('open')\n\t\t.description('Open a URL in the browser')\n\t\t.argument('<url>', 'URL to navigate to')\n\t\t.option('--headless', 'Run in headless mode', false)\n\t\t.option('-s, --session <id>', 'Reuse an existing session')\n\t\t.action(async (url: string, options: { headless: boolean; session?: string }) => {\n\t\t\ttry {\n\t\t\t\tlet sessionId = options.session;\n\n\t\t\t\tif (sessionId) {\n\t\t\t\t\tconst browser = sessionManager.get(sessionId);\n\t\t\t\t\tif (!browser) {\n\t\t\t\t\t\tconsole.error(chalk.red(`Session \"${sessionId}\" not found.`));\n\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t}\n\t\t\t\t\tawait browser.navigate(url);\n\t\t\t\t} else {\n\t\t\t\t\t// Try to reuse the default session, or create a new one\n\t\t\t\t\tsessionId = sessionManager.getDefaultId();\n\n\t\t\t\t\tif (!sessionId) {\n\t\t\t\t\t\tsessionId = await sessionManager.create({\n\t\t\t\t\t\t\theadless: options.headless,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tconst browser = sessionManager.get(sessionId)!;\n\t\t\t\t\tawait browser.navigate(url);\n\t\t\t\t}\n\n\t\t\t\tconst browser = sessionManager.get(sessionId)!;\n\t\t\t\tconst finalUrl = browser.currentPage.url();\n\n\t\t\t\tconsole.log(chalk.green('Session:'), sessionId);\n\t\t\t\tconsole.log(chalk.green('URL:'), finalUrl);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to open URL:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/run.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport {\n\tAgent,\n\tViewport,\n\tVercelModelAdapter,\n\ttype LanguageModel,\n\ttype CommandResult,\n\ttype StepRecord,\n} from 'open-browser';\nimport {\n\tSpinner,\n\tdisplayStep,\n\tdisplayTotalCost,\n\tdisplayResult,\n\tdisplayHeader,\n\tdisplaySeparator,\n\tdisplayError,\n} from '../display.js';\n\ninterface RunOptions {\n\tmodel: string;\n\tprovider: string;\n\theadless: boolean;\n\tstepLimit: number;\n\tverbose: boolean;\n\tnoCost: boolean;\n}\n\n/**\n * Dynamically import and create a Vercel AI SDK language model\n * based on the provider and model ID strings.\n */\nasync function createModel(provider: string, modelId: string): Promise<LanguageModel> {\n\tlet languageModel: import('ai').LanguageModelV1;\n\n\tswitch (provider) {\n\t\tcase 'openai': {\n\t\t\tconst { createOpenAI } = await import('@ai-sdk/openai');\n\t\t\tconst openai = createOpenAI({});\n\t\t\tlanguageModel = openai(modelId);\n\t\t\tbreak;\n\t\t}\n\t\tcase 'anthropic': {\n\t\t\tconst { createAnthropic } = await import('@ai-sdk/anthropic');\n\t\t\tconst anthropic = createAnthropic({});\n\t\t\tlanguageModel = anthropic(modelId);\n\t\t\tbreak;\n\t\t}\n\t\tcase 'google': {\n\t\t\tconst { createGoogleGenerativeAI } = await import('@ai-sdk/google');\n\t\t\tconst google = createGoogleGenerativeAI({});\n\t\t\tlanguageModel = google(modelId);\n\t\t\tbreak;\n\t\t}\n\t\tdefault:\n\t\t\tthrow new Error(\n\t\t\t\t`Unsupported provider: ${provider}. ` +\n\t\t\t\t'Supported: openai, anthropic, google',\n\t\t\t);\n\t}\n\n\treturn new VercelModelAdapter({ model: languageModel });\n}\n\nexport function registerRunCommand(program: Command): void {\n\tprogram\n\t\t.command('run')\n\t\t.description('Run an AI agent to complete a browser task')\n\t\t.argument('<task>', 'Description of the task for the agent to complete')\n\t\t.option('-m, --model <model>', 'Model ID to use', 'gpt-4o')\n\t\t.option('-p, --provider <provider>', 'LLM provider (openai, anthropic, google)', 'openai')\n\t\t.option('--headless', 'Run browser in headless mode', true)\n\t\t.option('--no-headless', 'Show the browser window')\n\t\t.option('--max-steps <n>', 'Maximum number of agent steps', '25')\n\t\t.option('-v, --verbose', 'Show detailed step information', false)\n\t\t.option('--no-cost', 'Hide cost tracking information')\n\t\t.action(async (task: string, options: RunOptions) => {\n\t\t\tconst stepLimit = Number.parseInt(String(options.stepLimit), 10);\n\n\t\t\tdisplayHeader(`Agent Task: ${task}`);\n\t\t\tconsole.log(\n\t\t\t\t`${chalk.dim('model:')} ${options.model}  ` +\n\t\t\t\t`${chalk.dim('provider:')} ${options.provider}  ` +\n\t\t\t\t`${chalk.dim('max steps:')} ${stepLimit}`,\n\t\t\t);\n\t\t\tdisplaySeparator();\n\n\t\t\tconst spinner = new Spinner('Starting browser...');\n\t\t\tspinner.start();\n\n\t\t\tlet browser: Viewport | null = null;\n\n\t\t\ttry {\n\t\t\t\t// Initialize the LLM\n\t\t\t\tspinner.update('Loading model...');\n\t\t\t\tconst model = await createModel(options.provider, options.model);\n\n\t\t\t\t// Initialize the browser\n\t\t\t\tspinner.update('Starting browser...');\n\t\t\t\tbrowser = new Viewport({\n\t\t\t\t\theadless: options.headless,\n\t\t\t\t});\n\t\t\t\tawait browser.start();\n\t\t\t\tspinner.update('Browser ready, starting agent...');\n\n\t\t\t\t// Track per-step timing\n\t\t\t\tconst stepTimings = new Map<number, number>();\n\t\t\t\tlet currentStepStart = 0;\n\n\t\t\t\t// Create the agent\n\t\t\t\tconst agent = new Agent({\n\t\t\t\t\ttask,\n\t\t\t\t\tmodel,\n\t\t\t\t\tbrowser,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit,\n\t\t\t\t\t},\n\t\t\t\t\tonStepStart: (step) => {\n\t\t\t\t\t\tcurrentStepStart = Date.now();\n\t\t\t\t\t\tstepTimings.set(step, currentStepStart);\n\t\t\t\t\t\tspinner.update(`Step ${step}: thinking...`);\n\t\t\t\t\t},\n\t\t\t\t\tonStepEnd: (step, results) => {\n\t\t\t\t\t\tconst durationMs = Date.now() - (stepTimings.get(step) ?? currentStepStart);\n\n\t\t\t\t\t\tspinner.stop();\n\n\t\t\t\t\t\t// Display each action result for this step\n\t\t\t\t\t\tfor (const result of results) {\n\t\t\t\t\t\t\tdisplayStep({\n\t\t\t\t\t\t\t\tstep,\n\t\t\t\t\t\t\t\taction: extractActionName(result),\n\t\t\t\t\t\t\t\ttarget: extractActionTarget(result),\n\t\t\t\t\t\t\t\tdurationMs,\n\t\t\t\t\t\t\t\tsuccess: result.success,\n\t\t\t\t\t\t\t\terror: result.error,\n\t\t\t\t\t\t\t\textractedContent: result.extractedContent,\n\t\t\t\t\t\t\t});\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tif (options.verbose) {\n\t\t\t\t\t\t\tdisplaySeparator();\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Restart spinner for next step\n\t\t\t\t\t\tspinner.start();\n\t\t\t\t\t\tspinner.update(`Step ${step + 1}: thinking...`);\n\t\t\t\t\t},\n\t\t\t\t});\n\n\t\t\t\tspinner.update('Agent running...');\n\n\t\t\t\t// Execute the agent\n\t\t\t\tconst result = await agent.run();\n\n\t\t\t\tspinner.stop();\n\n\t\t\t\t// Display result\n\t\t\t\tdisplayResult(result.success, result.finalResult);\n\n\t\t\t\t// Display cost summary\n\t\t\t\tif (!options.noCost && result.totalCost) {\n\t\t\t\t\tdisplayTotalCost({\n\t\t\t\t\t\tsteps: result.history.entries.length,\n\t\t\t\t\t\tinputTokens: result.totalCost.totalInputTokens,\n\t\t\t\t\t\toutputTokens: result.totalCost.totalOutputTokens,\n\t\t\t\t\t\ttotalCost: result.totalCost.totalCost,\n\t\t\t\t\t\tdurationMs: computeTotalDuration(result.history.entries),\n\t\t\t\t\t});\n\t\t\t\t} else if (!options.noCost) {\n\t\t\t\t\t// Show basic timing even without cost data\n\t\t\t\t\tconst totalMs = computeTotalDuration(result.history.entries);\n\t\t\t\t\tconsole.log('');\n\t\t\t\t\tconsole.log(\n\t\t\t\t\t\tchalk.dim(\n\t\t\t\t\t\t\t`Completed in ${result.history.entries.length} step(s), ` +\n\t\t\t\t\t\t\t`${(totalMs / 1000).toFixed(1)}s`,\n\t\t\t\t\t\t),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Display errors if any\n\t\t\t\tif (result.errors.length > 0) {\n\t\t\t\t\tconsole.log('');\n\t\t\t\t\tconsole.log(chalk.bold.yellow('Errors encountered:'));\n\t\t\t\t\tfor (const err of result.errors) {\n\t\t\t\t\t\tconsole.log(`  ${chalk.red('-')} ${err}`);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Exit with appropriate code\n\t\t\t\tprocess.exit(result.success ? 0 : 1);\n\t\t\t} catch (error) {\n\t\t\t\tspinner.stop();\n\t\t\t\tdisplayError(\n\t\t\t\t\terror instanceof Error ? error.message : String(error),\n\t\t\t\t);\n\t\t\t\tprocess.exit(1);\n\t\t\t} finally {\n\t\t\t\tif (browser) {\n\t\t\t\t\tawait browser.close().catch(() => {});\n\t\t\t\t}\n\t\t\t}\n\t\t});\n}\n\n// ── Helpers ──\n\nfunction extractActionName(result: CommandResult): string {\n\tif (result.isDone) return 'done';\n\tif (result.extractedContent) return 'extract';\n\treturn result.success ? 'action' : 'failed_action';\n}\n\nfunction extractActionTarget(result: CommandResult): string | undefined {\n\tif (result.extractedContent) {\n\t\treturn result.extractedContent.slice(0, 80);\n\t}\n\treturn undefined;\n}\n\nfunction computeTotalDuration(entries: StepRecord[]): number {\n\treturn entries.reduce((sum, e) => sum + e.duration, 0);\n}\n"
  },
  {
    "path": "packages/cli/src/commands/screenshot.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { sessionManager } from '../globals.js';\n\nexport function registerScreenshotCommand(program: Command): void {\n\tprogram\n\t\t.command('screenshot')\n\t\t.description('Take a screenshot of the current page')\n\t\t.argument('[output]', 'Output file path', 'screenshot.png')\n\t\t.option('-s, --session <id>', 'Session ID to use')\n\t\t.option('--full-page', 'Capture the full page', false)\n\t\t.action(async (output: string, options: { session?: string; fullPage: boolean }) => {\n\t\t\ttry {\n\t\t\t\tconst browser = options.session\n\t\t\t\t\t? sessionManager.get(options.session)\n\t\t\t\t\t: sessionManager.getDefault();\n\n\t\t\t\tif (!browser) {\n\t\t\t\t\tconsole.error(chalk.red('No active session. Use \"open\" command first.'));\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\n\t\t\t\tconst result = await browser.screenshot(options.fullPage);\n\t\t\t\tconst buffer = Buffer.from(result.base64, 'base64');\n\n\t\t\t\tconst outputPath = path.resolve(output);\n\t\t\t\tfs.writeFileSync(outputPath, buffer);\n\n\t\t\t\tconsole.log(chalk.green('Screenshot saved:'), outputPath);\n\t\t\t\tconsole.log(chalk.green('Dimensions:'), `${result.width}x${result.height}`);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to take screenshot:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/sessions.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { sessionManager } from '../globals.js';\n\nexport function registerSessionsCommand(program: Command): void {\n\tprogram\n\t\t.command('sessions')\n\t\t.description('List all active browser sessions')\n\t\t.action(() => {\n\t\t\ttry {\n\t\t\t\tconst sessions = sessionManager.list();\n\n\t\t\t\tif (sessions.length === 0) {\n\t\t\t\t\tconsole.log(chalk.yellow('No active sessions.'));\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tconsole.log(chalk.bold(`Active Sessions (${sessions.length}):`));\n\t\t\t\tfor (const session of sessions) {\n\t\t\t\t\tconst created = new Date(session.createdAt).toLocaleTimeString();\n\t\t\t\t\tconst accessed = new Date(session.lastAccessedAt).toLocaleTimeString();\n\t\t\t\t\tconsole.log(`  ${chalk.cyan(session.id)}  created ${created}  last used ${accessed}`);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to list sessions:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n\n\tprogram\n\t\t.command('sessions:close')\n\t\t.description('Close a specific session or all sessions')\n\t\t.argument('[id]', 'Session ID to close (omit to close all)')\n\t\t.action(async (id?: string) => {\n\t\t\ttry {\n\t\t\t\tif (id) {\n\t\t\t\t\tconst closed = await sessionManager.close(id);\n\t\t\t\t\tif (closed) {\n\t\t\t\t\t\tconsole.log(chalk.green('Closed session:'), id);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tconsole.error(chalk.red(`Session \"${id}\" not found.`));\n\t\t\t\t\t\tprocess.exit(1);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tconst count = sessionManager.activeCount;\n\t\t\t\t\tawait sessionManager.closeAll();\n\t\t\t\t\tconsole.log(chalk.green(`Closed ${count} session(s).`));\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to close session:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/state.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { sessionManager } from '../globals.js';\n\nexport function registerStateCommand(program: Command): void {\n\tprogram\n\t\t.command('state')\n\t\t.description('Print the current browser state (URL, title, tabs)')\n\t\t.option('-s, --session <id>', 'Session ID to use')\n\t\t.action(async (options: { session?: string }) => {\n\t\t\ttry {\n\t\t\t\tconst browser = options.session\n\t\t\t\t\t? sessionManager.get(options.session)\n\t\t\t\t\t: sessionManager.getDefault();\n\n\t\t\t\tif (!browser) {\n\t\t\t\t\tconsole.error(chalk.red('No active session. Use \"open\" command first.'));\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\n\t\t\t\tconst state = await browser.getState();\n\n\t\t\t\tconsole.log(chalk.bold('Browser State'));\n\t\t\t\tconsole.log(chalk.green('URL:'), state.url);\n\t\t\t\tconsole.log(chalk.green('Title:'), state.title);\n\t\t\t\tconsole.log(chalk.green('Tabs:'), state.tabs.length);\n\n\t\t\t\tfor (const tab of state.tabs) {\n\t\t\t\t\tconst marker = tab.isActive ? chalk.cyan('→') : ' ';\n\t\t\t\t\tconsole.log(`  ${marker} [${tab.tabId}] ${tab.title || '(untitled)'} - ${tab.url}`);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to get state:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/commands/type.ts",
    "content": "import type { Command } from 'commander';\nimport chalk from 'chalk';\nimport { sessionManager } from '../globals.js';\n\nexport function registerTypeCommand(program: Command): void {\n\tprogram\n\t\t.command('type')\n\t\t.description('Type text into an element matching the given CSS selector')\n\t\t.argument('<selector>', 'CSS selector of the input element')\n\t\t.argument('<text>', 'Text to type into the element')\n\t\t.option('-s, --session <id>', 'Session ID to use')\n\t\t.action(async (selector: string, text: string, options: { session?: string }) => {\n\t\t\ttry {\n\t\t\t\tconst browser = options.session\n\t\t\t\t\t? sessionManager.get(options.session)\n\t\t\t\t\t: sessionManager.getDefault();\n\n\t\t\t\tif (!browser) {\n\t\t\t\t\tconsole.error(chalk.red('No active session. Use \"open\" command first.'));\n\t\t\t\t\tprocess.exit(1);\n\t\t\t\t}\n\n\t\t\t\tawait browser.type(selector, text);\n\t\t\t\tconsole.log(chalk.green('Typed into:'), selector);\n\t\t\t} catch (error) {\n\t\t\t\tconsole.error(chalk.red('Failed to type:'), error instanceof Error ? error.message : String(error));\n\t\t\t\tprocess.exit(1);\n\t\t\t}\n\t\t});\n}\n"
  },
  {
    "path": "packages/cli/src/display.ts",
    "content": "import chalk from 'chalk';\n\n// ── Spinner ──\n\nconst SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];\n\nexport class Spinner {\n\tprivate intervalId: ReturnType<typeof setInterval> | null = null;\n\tprivate frameIndex = 0;\n\tprivate message: string;\n\n\tconstructor(message: string) {\n\t\tthis.message = message;\n\t}\n\n\tstart(): void {\n\t\tif (this.intervalId) return;\n\t\tthis.frameIndex = 0;\n\n\t\tthis.intervalId = setInterval(() => {\n\t\t\tconst frame = SPINNER_FRAMES[this.frameIndex % SPINNER_FRAMES.length];\n\t\t\tprocess.stdout.write(`\\r${chalk.cyan(frame)} ${this.message}`);\n\t\t\tthis.frameIndex++;\n\t\t}, 80);\n\t}\n\n\tupdate(message: string): void {\n\t\tthis.message = message;\n\t}\n\n\tstop(finalMessage?: string): void {\n\t\tif (this.intervalId) {\n\t\t\tclearInterval(this.intervalId);\n\t\t\tthis.intervalId = null;\n\t\t}\n\t\t// Clear the spinner line\n\t\tprocess.stdout.write('\\r\\x1b[K');\n\t\tif (finalMessage) {\n\t\t\tconsole.log(finalMessage);\n\t\t}\n\t}\n}\n\n// ── Step Display ──\n\nexport interface StepDisplayInfo {\n\tstep: number;\n\taction: string;\n\ttarget?: string;\n\tdurationMs: number;\n\tsuccess: boolean;\n\terror?: string;\n\textractedContent?: string;\n}\n\n/**\n * Format and display a single agent step with its result.\n */\nexport function displayStep(info: StepDisplayInfo): void {\n\tconst stepLabel = chalk.bold.white(`Step ${info.step}`);\n\tconst actionLabel = chalk.yellow(info.action);\n\tconst durationLabel = chalk.dim(`${info.durationMs}ms`);\n\tconst statusIcon = info.success ? chalk.green('✓') : chalk.red('✗');\n\n\tconsole.log(`${stepLabel} ${statusIcon} ${actionLabel} ${durationLabel}`);\n\n\tif (info.target) {\n\t\tconsole.log(`  ${chalk.dim('target:')} ${info.target}`);\n\t}\n\n\tif (info.error) {\n\t\tconsole.log(`  ${chalk.red('error:')} ${info.error}`);\n\t}\n\n\tif (info.extractedContent) {\n\t\tconst preview = info.extractedContent.length > 120\n\t\t\t? `${info.extractedContent.slice(0, 120)}...`\n\t\t\t: info.extractedContent;\n\t\tconsole.log(`  ${chalk.dim('output:')} ${preview}`);\n\t}\n}\n\n// ── Cost Display ──\n\nexport interface CostDisplayInfo {\n\tinputTokens: number;\n\toutputTokens: number;\n\ttotalCost: number;\n}\n\n/**\n * Display token usage and cost for a single step.\n */\nexport function displayStepCost(info: CostDisplayInfo): void {\n\tconst tokens = chalk.dim(\n\t\t`tokens: ${info.inputTokens.toLocaleString()} in / ${info.outputTokens.toLocaleString()} out`,\n\t);\n\tconst cost = chalk.dim(`cost: $${info.totalCost.toFixed(4)}`);\n\tconsole.log(`  ${tokens}  ${cost}`);\n}\n\n/**\n * Display a summary of total cost and token usage.\n */\nexport function displayTotalCost(info: CostDisplayInfo & { steps: number; durationMs: number }): void {\n\tconsole.log('');\n\tconsole.log(chalk.bold('Summary'));\n\tconsole.log(chalk.dim('─'.repeat(50)));\n\tconsole.log(`  ${chalk.white('Steps:')}        ${info.steps}`);\n\tconsole.log(`  ${chalk.white('Duration:')}     ${(info.durationMs / 1000).toFixed(1)}s`);\n\tconsole.log(`  ${chalk.white('Input tokens:')} ${info.inputTokens.toLocaleString()}`);\n\tconsole.log(`  ${chalk.white('Output tokens:')} ${info.outputTokens.toLocaleString()}`);\n\tconsole.log(`  ${chalk.white('Total tokens:')} ${(info.inputTokens + info.outputTokens).toLocaleString()}`);\n\tconsole.log(`  ${chalk.white('Total cost:')}   $${info.totalCost.toFixed(4)}`);\n\tconsole.log(chalk.dim('─'.repeat(50)));\n}\n\n// ── Progress Bar ──\n\nexport function displayProgressBar(current: number, total: number, width = 30): void {\n\tconst ratio = Math.min(current / total, 1);\n\tconst filled = Math.round(ratio * width);\n\tconst empty = width - filled;\n\tconst bar = chalk.green('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));\n\tconst pct = (ratio * 100).toFixed(0).padStart(3);\n\tprocess.stdout.write(`\\r  [${bar}] ${pct}% (${current}/${total})`);\n}\n\n// ── Result Display ──\n\nexport function displayResult(success: boolean, output?: string): void {\n\tconsole.log('');\n\tif (success) {\n\t\tconsole.log(chalk.bold.green('Task completed successfully'));\n\t} else {\n\t\tconsole.log(chalk.bold.red('Task failed'));\n\t}\n\n\tif (output) {\n\t\tconsole.log('');\n\t\tconsole.log(chalk.bold('Result:'));\n\t\tconsole.log(output);\n\t}\n}\n\n// ── Helpers ──\n\nexport function displayError(message: string): void {\n\tconsole.error(chalk.red('Error:'), message);\n}\n\nexport function displayWarning(message: string): void {\n\tconsole.warn(chalk.yellow('Warning:'), message);\n}\n\nexport function displayInfo(message: string): void {\n\tconsole.log(chalk.blue('Info:'), message);\n}\n\nexport function displaySeparator(): void {\n\tconsole.log(chalk.dim('─'.repeat(60)));\n}\n\nexport function displayHeader(title: string): void {\n\tconsole.log('');\n\tconsole.log(chalk.bold.white(title));\n\tconsole.log(chalk.dim('═'.repeat(60)));\n}\n"
  },
  {
    "path": "packages/cli/src/globals.ts",
    "content": "import { SessionManager } from './sessions.js';\n\nexport const sessionManager = new SessionManager();\n"
  },
  {
    "path": "packages/cli/src/index.ts",
    "content": "#!/usr/bin/env bun\nimport { Command } from 'commander';\nimport { registerOpenCommand } from './commands/open.js';\nimport { registerClickCommand } from './commands/click.js';\nimport { registerTypeCommand } from './commands/type.js';\nimport { registerStateCommand } from './commands/state.js';\nimport { registerScreenshotCommand } from './commands/screenshot.js';\nimport { registerEvalCommand } from './commands/eval.js';\nimport { registerExtractCommand } from './commands/extract.js';\nimport { registerSessionsCommand } from './commands/sessions.js';\nimport { registerRunCommand } from './commands/run.js';\nimport { registerInteractiveCommand } from './commands/interactive.js';\n\nconst program = new Command();\n\nprogram\n\t.name('open-browser')\n\t.description('AI-powered autonomous web browsing CLI')\n\t.version('0.1.0');\n\n// ── Browser manipulation commands ──\nregisterOpenCommand(program);\nregisterClickCommand(program);\nregisterTypeCommand(program);\nregisterStateCommand(program);\nregisterScreenshotCommand(program);\nregisterEvalCommand(program);\nregisterExtractCommand(program);\nregisterSessionsCommand(program);\n\n// ── Agent and interactive commands ──\nregisterRunCommand(program);\nregisterInteractiveCommand(program);\n\nprogram.parse();\n"
  },
  {
    "path": "packages/cli/src/protocol.ts",
    "content": "export interface CLIRequest {\n\tid: string;\n\tcommand: string;\n\targs: Record<string, unknown>;\n}\n\nexport interface CLIResponse {\n\tid: string;\n\tsuccess: boolean;\n\tdata?: unknown;\n\terror?: string;\n}\n\nexport function serializeRequest(req: CLIRequest): string {\n\treturn JSON.stringify(req) + '\\n';\n}\n\nexport function parseRequest(data: string): CLIRequest | null {\n\ttry {\n\t\treturn JSON.parse(data.trim()) as CLIRequest;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\nexport function serializeResponse(res: CLIResponse): string {\n\treturn JSON.stringify(res) + '\\n';\n}\n\nexport function parseResponse(data: string): CLIResponse | null {\n\ttry {\n\t\treturn JSON.parse(data.trim()) as CLIResponse;\n\t} catch {\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "packages/cli/src/server.ts",
    "content": "import * as net from 'node:net';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport { SessionManager } from './sessions.js';\nimport { type CLIRequest, type CLIResponse, parseRequest, serializeResponse } from './protocol.js';\n\nconst SOCKET_DIR = path.join(os.tmpdir(), 'open-browser');\nconst SOCKET_PATH = path.join(SOCKET_DIR, 'server.sock');\n\nexport class CLIServer {\n\tprivate server: net.Server | null = null;\n\treadonly sessions: SessionManager;\n\n\tconstructor() {\n\t\tthis.sessions = new SessionManager();\n\t}\n\n\tasync start(): Promise<string> {\n\t\tif (!fs.existsSync(SOCKET_DIR)) {\n\t\t\tfs.mkdirSync(SOCKET_DIR, { recursive: true });\n\t\t}\n\n\t\t// Clean up stale socket\n\t\tif (fs.existsSync(SOCKET_PATH)) {\n\t\t\tfs.unlinkSync(SOCKET_PATH);\n\t\t}\n\n\t\treturn new Promise((resolve, reject) => {\n\t\t\tthis.server = net.createServer((socket) => {\n\t\t\t\tlet buffer = '';\n\n\t\t\t\tsocket.on('data', async (data) => {\n\t\t\t\t\tbuffer += data.toString();\n\t\t\t\t\tconst lines = buffer.split('\\n');\n\t\t\t\t\tbuffer = lines.pop() ?? '';\n\n\t\t\t\t\tfor (const line of lines) {\n\t\t\t\t\t\tif (!line.trim()) continue;\n\t\t\t\t\t\tconst request = parseRequest(line);\n\t\t\t\t\t\tif (request) {\n\t\t\t\t\t\t\tconst response = await this.handleRequest(request);\n\t\t\t\t\t\t\tsocket.write(serializeResponse(response));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tsocket.on('error', () => {\n\t\t\t\t\t// Client disconnected\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tthis.server.on('error', reject);\n\t\t\tthis.server.listen(SOCKET_PATH, () => {\n\t\t\t\tresolve(SOCKET_PATH);\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate async handleRequest(request: CLIRequest): Promise<CLIResponse> {\n\t\ttry {\n\t\t\tswitch (request.command) {\n\t\t\t\tcase 'open': {\n\t\t\t\t\tconst url = request.args.url as string;\n\t\t\t\t\tlet sessionId = request.args.session as string | undefined;\n\n\t\t\t\t\tif (!sessionId) {\n\t\t\t\t\t\tsessionId = this.sessions.getDefaultId();\n\t\t\t\t\t}\n\n\t\t\t\t\tif (!sessionId) {\n\t\t\t\t\t\tsessionId = await this.sessions.create({\n\t\t\t\t\t\t\theadless: request.args.headless as boolean | undefined,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\n\t\t\t\t\tconst browser = this.sessions.get(sessionId)!;\n\t\t\t\t\tawait browser.navigate(url);\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: request.id,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: { sessionId, url: browser.currentPage.url() },\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tcase 'tap': {\n\t\t\t\t\tconst browser = this.getSessionBrowser(request);\n\t\t\t\t\tconst selector = request.args.selector as string;\n\t\t\t\t\tawait browser.click(selector);\n\t\t\t\t\treturn { id: request.id, success: true };\n\t\t\t\t}\n\n\t\t\t\tcase 'type': {\n\t\t\t\t\tconst browser = this.getSessionBrowser(request);\n\t\t\t\t\tconst selector = request.args.selector as string;\n\t\t\t\t\tconst text = request.args.text as string;\n\t\t\t\t\tawait browser.type(selector, text);\n\t\t\t\t\treturn { id: request.id, success: true };\n\t\t\t\t}\n\n\t\t\t\tcase 'state': {\n\t\t\t\t\tconst browser = this.getSessionBrowser(request);\n\t\t\t\t\tconst state = await browser.getState();\n\t\t\t\t\treturn { id: request.id, success: true, data: state };\n\t\t\t\t}\n\n\t\t\t\tcase 'capture': {\n\t\t\t\t\tconst browser = this.getSessionBrowser(request);\n\t\t\t\t\tconst result = await browser.screenshot(request.args.fullPage as boolean);\n\t\t\t\t\treturn { id: request.id, success: true, data: result };\n\t\t\t\t}\n\n\t\t\t\tcase 'eval': {\n\t\t\t\t\tconst browser = this.getSessionBrowser(request);\n\t\t\t\t\tconst expression = request.args.expression as string;\n\t\t\t\t\tconst result = await browser.evaluate(expression);\n\t\t\t\t\treturn { id: request.id, success: true, data: result };\n\t\t\t\t}\n\n\t\t\t\tcase 'sessions': {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: request.id,\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\tdata: this.sessions.list(),\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tcase 'close': {\n\t\t\t\t\tconst sessionId = request.args.session as string | undefined;\n\t\t\t\t\tif (sessionId) {\n\t\t\t\t\t\tawait this.sessions.close(sessionId);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tawait this.sessions.closeAll();\n\t\t\t\t\t}\n\t\t\t\t\treturn { id: request.id, success: true };\n\t\t\t\t}\n\n\t\t\t\tdefault:\n\t\t\t\t\treturn {\n\t\t\t\t\t\tid: request.id,\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: `Unknown command: ${request.command}`,\n\t\t\t\t\t};\n\t\t\t}\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tid: request.id,\n\t\t\t\tsuccess: false,\n\t\t\t\terror: error instanceof Error ? error.message : String(error),\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate getSessionBrowser(request: CLIRequest) {\n\t\tconst sessionId = request.args.session as string | undefined;\n\t\tconst browser = sessionId\n\t\t\t? this.sessions.get(sessionId)\n\t\t\t: this.sessions.getDefault();\n\n\t\tif (!browser) {\n\t\t\tthrow new Error('No active session. Use \"open\" command first.');\n\t\t}\n\n\t\treturn browser;\n\t}\n\n\tasync stop(): Promise<void> {\n\t\tawait this.sessions.closeAll();\n\n\t\tif (this.server) {\n\t\t\treturn new Promise((resolve) => {\n\t\t\t\tthis.server!.close(() => {\n\t\t\t\t\tif (fs.existsSync(SOCKET_PATH)) {\n\t\t\t\t\t\tfs.unlinkSync(SOCKET_PATH);\n\t\t\t\t\t}\n\t\t\t\t\tresolve();\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\t}\n\n\tstatic get socketPath(): string {\n\t\treturn SOCKET_PATH;\n\t}\n}\n"
  },
  {
    "path": "packages/cli/src/sessions.ts",
    "content": "import { Viewport, type ViewportOptions } from 'open-browser';\nimport { nanoid } from 'nanoid';\n\ninterface ManagedSession {\n\tid: string;\n\tbrowser: Viewport;\n\tcreatedAt: number;\n\tlastAccessedAt: number;\n}\n\nexport class SessionManager {\n\tprivate sessions = new Map<string, ManagedSession>();\n\n\tasync create(options?: ViewportOptions): Promise<string> {\n\t\tconst id = nanoid(8);\n\t\tconst browser = new Viewport(options);\n\t\tawait browser.start();\n\n\t\tthis.sessions.set(id, {\n\t\t\tid,\n\t\t\tbrowser,\n\t\t\tcreatedAt: Date.now(),\n\t\t\tlastAccessedAt: Date.now(),\n\t\t});\n\n\t\treturn id;\n\t}\n\n\tget(id: string): Viewport | undefined {\n\t\tconst session = this.sessions.get(id);\n\t\tif (session) {\n\t\t\tsession.lastAccessedAt = Date.now();\n\t\t\treturn session.browser;\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tasync close(id: string): Promise<boolean> {\n\t\tconst session = this.sessions.get(id);\n\t\tif (!session) return false;\n\n\t\tawait session.browser.close();\n\t\tthis.sessions.delete(id);\n\t\treturn true;\n\t}\n\n\tasync closeAll(): Promise<void> {\n\t\tfor (const session of this.sessions.values()) {\n\t\t\tawait session.browser.close();\n\t\t}\n\t\tthis.sessions.clear();\n\t}\n\n\tlist(): Array<{ id: string; createdAt: number; lastAccessedAt: number }> {\n\t\treturn [...this.sessions.values()].map((s) => ({\n\t\t\tid: s.id,\n\t\t\tcreatedAt: s.createdAt,\n\t\t\tlastAccessedAt: s.lastAccessedAt,\n\t\t}));\n\t}\n\n\tget activeCount(): number {\n\t\treturn this.sessions.size;\n\t}\n\n\tgetDefault(): Viewport | undefined {\n\t\tconst first = this.sessions.values().next();\n\t\tif (first.done) return undefined;\n\t\tfirst.value.lastAccessedAt = Date.now();\n\t\treturn first.value.browser;\n\t}\n\n\tgetDefaultId(): string | undefined {\n\t\tconst first = this.sessions.keys().next();\n\t\treturn first.done ? undefined : first.value;\n\t}\n}\n"
  },
  {
    "path": "packages/cli/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "packages/core/package.json",
    "content": "{\n  \"name\": \"open-browser\",\n  \"version\": \"1.1.0\",\n  \"description\": \"AI-powered autonomous web browsing library for TypeScript\",\n  \"type\": \"module\",\n  \"main\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc --noEmit\",\n    \"test\": \"bun test\",\n    \"lint\": \"biome check src/\"\n  },\n  \"dependencies\": {\n    \"ai\": \"^4.2.0\",\n    \"@ai-sdk/openai\": \"^1.1.0\",\n    \"@ai-sdk/anthropic\": \"^1.1.0\",\n    \"@ai-sdk/google\": \"^1.1.0\",\n    \"zod\": \"^3.24.0\",\n    \"playwright\": \"^1.51.0\",\n    \"mitt\": \"^3.0.2\",\n    \"nanoid\": \"^5.1.0\",\n    \"turndown\": \"^7.2.1\",\n    \"dotenv\": \"^16.5.0\"\n  },\n  \"devDependencies\": {\n    \"@types/turndown\": \"^5.0.5\"\n  },\n  \"peerDependencies\": {\n    \"sharp\": \">=0.33.0\"\n  },\n  \"peerDependenciesMeta\": {\n    \"sharp\": {\n      \"optional\": true\n    }\n  },\n  \"license\": \"MIT\"\n}\n"
  },
  {
    "path": "packages/core/src/agent/agent.test.ts",
    "content": "import { test, expect, describe, beforeEach, mock } from 'bun:test';\nimport { Agent, type AgentOptions } from '../agent/agent.js';\nimport type { PageAnalyzer } from '../page/page-analyzer.js';\n\n// ── Mock PageAnalyzer factory (injected via AgentOptions.domService) ──\n\nconst mockExtractState = mock(async () => ({\n\ttree: '<div>[1] <button>Click me</button></div>',\n\tselectorMap: { 1: 'button' },\n\telementCount: 10,\n\tinteractiveElementCount: 1,\n\tscrollPosition: { x: 0, y: 0 },\n\tviewportSize: { width: 1280, height: 1100 },\n\tdocumentSize: { width: 1280, height: 2000 },\n\tpixelsAbove: 0,\n\tpixelsBelow: 900,\n}));\n\nfunction createMockPageAnalyzer(): PageAnalyzer {\n\treturn {\n\t\textractState: mockExtractState,\n\t\tclickElementByIndex: mock(async () => {}),\n\t\tgetCachedTree: mock(() => null),\n\t\tgetCachedSelectorMap: mock(() => null),\n\t\tclearCache: mock(() => {}),\n\t\tgetInteractedElements: mock(() => []),\n\t\tclearInteractedElements: mock(() => {}),\n\t\tgetElementSelector: mock(async () => undefined),\n\t\tgetElementByBackendNodeId: mock(async () => null),\n\t\tclickAtCoordinates: mock(async () => {}),\n\t\tinputTextByIndex: mock(async () => {}),\n\t\textractWithIframes: mock(async () => ({ mainTree: null, iframeTrees: [] })),\n\t} as unknown as PageAnalyzer;\n}\nimport type { RunOutcome } from './types.js';\nimport type { LanguageModel, InferenceOptions } from '../model/interface.js';\nimport type { InferenceResult, InferenceUsage } from '../model/types.js';\nimport type { Viewport } from '../viewport/viewport.js';\nimport type { ViewportSnapshot } from '../viewport/types.js';\nimport type { CommandExecutor } from '../commands/executor.js';\nimport type { Command, CommandResult, ExecutionContext } from '../commands/types.js';\nimport type { CommandCatalog } from '../commands/catalog/catalog.js';\n\n// ── Mock Factories ──\n\nfunction createMockUsage(input = 100, output = 50): InferenceUsage {\n\treturn { inputTokens: input, outputTokens: output, totalTokens: input + output };\n}\n\nfunction createMockModel(options?: {\n\tresponses?: Array<{\n\t\tcurrentState: { evaluation: string; memory: string; nextGoal: string };\n\t\tactions: Command[];\n\t}>;\n\tmodelId?: string;\n}): LanguageModel {\n\tlet callCount = 0;\n\tconst responses = options?.responses ?? [\n\t\t{\n\t\t\tcurrentState: {\n\t\t\t\tevaluation: 'Page loaded',\n\t\t\t\tmemory: '',\n\t\t\t\tnextGoal: 'Click element',\n\t\t\t},\n\t\t\tactions: [{ action: 'tap', index: 1, clickCount: 1 } as Command],\n\t\t},\n\t];\n\n\treturn {\n\t\tmodelId: options?.modelId ?? 'test-model',\n\t\tprovider: 'custom',\n\t\tinvoke: async <T>(_options: InferenceOptions<T>): Promise<InferenceResult<T>> => {\n\t\t\tconst responseIndex = Math.min(callCount, responses.length - 1);\n\t\t\tcallCount++;\n\t\t\treturn {\n\t\t\t\tparsed: responses[responseIndex] as unknown as T,\n\t\t\t\tusage: createMockUsage(),\n\t\t\t\tfinishReason: 'stop',\n\t\t\t};\n\t\t},\n\t};\n}\n\nfunction createDoneOnStepModel(doneOnStep: number, result = 'Task completed'): LanguageModel {\n\tconst responses: Array<{\n\t\tcurrentState: { evaluation: string; memory: string; nextGoal: string };\n\t\tactions: Command[];\n\t}> = [];\n\n\tfor (let i = 1; i < doneOnStep; i++) {\n\t\tresponses.push({\n\t\t\tcurrentState: {\n\t\t\t\tevaluation: `Step ${i} assessment`,\n\t\t\t\tmemory: '',\n\t\t\t\tnextGoal: `Goal for step ${i + 1}`,\n\t\t\t},\n\t\t\tactions: [{ action: 'tap', index: i, clickCount: 1 } as Command],\n\t\t});\n\t}\n\n\tresponses.push({\n\t\tcurrentState: {\n\t\t\tevaluation: 'Task done',\n\t\t\tmemory: '',\n\t\t\tnextGoal: 'Report result',\n\t\t},\n\t\tactions: [{ action: 'finish', text: result, success: true } as Command],\n\t});\n\n\treturn createMockModel({ responses });\n}\n\nfunction createMockBrowserState(): ViewportSnapshot {\n\treturn {\n\t\turl: 'https://example.com',\n\t\ttitle: 'Example Page',\n\t\ttabs: [\n\t\t\t{ tabId: 0 as any, url: 'https://example.com', title: 'Example Page', isActive: true },\n\t\t],\n\t\tactiveTabIndex: 0,\n\t};\n}\n\nfunction createMockRegistry(): CommandCatalog{\n\treturn {\n\t\tregister: mock(() => {}),\n\t\tget: mock(() => undefined),\n\t\tgetAll: mock(() => []),\n\t\tgetActionDescriptions: mock(() => 'click: Click on an element'),\n\t\tgetPromptDescription: mock(() => 'click: Click on an element by its index\\ngo_to_url: Navigate to a URL'),\n\t\thas: mock(() => false),\n\t} as unknown as CommandCatalog;\n}\n\nfunction createMockTools(actionResults?: CommandResult[]): CommandExecutor {\n\tconst defaultResults: CommandResult[] = [{ success: true }];\n\treturn {\n\t\tregistry: createMockRegistry(),\n\t\tcommandsPerStep: 10,\n\t\tsetCoordinateClicking: mock(() => {}),\n\t\texecuteActions: mock(async (_actions: Command[], _ctx: ExecutionContext) => {\n\t\t\treturn actionResults ?? defaultResults;\n\t\t}),\n\t\texecuteAction: mock(async (_action: Command, _ctx: ExecutionContext) => {\n\t\t\treturn (actionResults ?? defaultResults)[0];\n\t\t}),\n\t} as unknown as CommandExecutor;\n}\n\nfunction createMockBrowser(overrides?: {\n\tbrowserState?: ViewportSnapshot;\n\tisConnected?: boolean;\n}): Viewport {\n\tconst state = overrides?.browserState ?? createMockBrowserState();\n\treturn {\n\t\tisConnected: overrides?.isConnected ?? true,\n\t\tstart: mock(async () => {}),\n\t\tgetState: mock(async () => state),\n\t\tscreenshot: mock(async () => ({ base64: 'fake_screenshot', width: 1280, height: 1100 })),\n\t\tnavigate: mock(async () => {}),\n\t\tcurrentPage: {\n\t\t\tviewportSize: () => ({ width: 1280, height: 1100 }),\n\t\t\tevaluate: mock(async () => ({})),\n\t\t} as any,\n\t\tcdp: {\n\t\t\tsend: mock(async () => ({})),\n\t\t} as any,\n\t} as unknown as Viewport;\n}\n\nfunction createDefaultAgentOptions(overrides?: Partial<AgentOptions>): AgentOptions {\n\treturn {\n\t\ttask: 'Find the price of the product',\n\t\tmodel: createDoneOnStepModel(2),\n\t\tbrowser: createMockBrowser(),\n\t\ttools: createMockTools([{ success: true, isDone: false }]),\n\t\tdomService: createMockPageAnalyzer(),\n\t\tsettings: {\n\t\t\tstepLimit: 5,\n\t\t\tenableScreenshots: false,\n\t\t\tcommandDelayMs: 0,\n\t\t\tretryDelay: 0,\n\t\t\tautoNavigateToUrls: false,\n\t\t\tcontextWindowSize: 50000,\n\t\t},\n\t\t...overrides,\n\t};\n}\n\n// ── Tests ──\n\ndescribe('Agent', () => {\n\tdescribe('constructor', () => {\n\t\ttest('creates agent with default settings merged', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tconst state = agent.getState();\n\t\t\texpect(state.step).toBe(0);\n\t\t\texpect(state.isRunning).toBe(false);\n\t\t\texpect(state.isDone).toBe(false);\n\t\t\texpect(state.failureCount).toBe(0);\n\t\t\texpect(state.consecutiveFailures).toBe(0);\n\t\t});\n\n\t\ttest('overrides default settings with provided values', () => {\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 50,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\t\t\tconst state = agent.getState();\n\t\t\texpect(state.stepLimit).toBe(50);\n\t\t});\n\n\t\ttest('initializes cost tracking to zero', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tconst cost = agent.getAccumulatedCost();\n\t\t\texpect(cost.totalCost).toBe(0);\n\t\t\texpect(cost.totalInputTokens).toBe(0);\n\t\t\texpect(cost.totalOutputTokens).toBe(0);\n\t\t});\n\n\t\ttest('initializes empty history', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tconst history = agent.getHistory();\n\t\t\texpect(history.entries).toHaveLength(0);\n\t\t\texpect(history.task).toBe('Find the price of the product');\n\t\t});\n\n\t\ttest('uses custom tools when provided', () => {\n\t\t\tconst customTools = createMockTools();\n\t\t\tconst agent = new Agent(createDefaultAgentOptions({ tools: customTools }));\n\t\t\texpect(agent).toBeDefined();\n\t\t});\n\t});\n\n\tdescribe('run() basic flow', () => {\n\t\ttest('completes when done action is returned', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'The price is $42');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'The price is $42' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\n\t\t\texpect(result.finalResult).toBe('The price is $42');\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(result.errors).toHaveLength(0);\n\t\t});\n\n\t\ttest('sets isRunning to false after completion', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst state = agent.getState();\n\t\t\texpect(state.isRunning).toBe(false);\n\t\t});\n\n\t\ttest('calls onStepStart callback', async () => {\n\t\t\tconst stepStarts: number[] = [];\n\n\t\t\tconst doneModel = createDoneOnStepModel(2, 'Result');\n\t\t\tlet callCount = 0;\n\t\t\tconst tools = createMockTools();\n\t\t\t(tools.executeActions as any) = mock(async () => {\n\t\t\t\tcallCount++;\n\t\t\t\tif (callCount >= 2) {\n\t\t\t\t\treturn [{ success: true, isDone: true, extractedContent: 'Result' }];\n\t\t\t\t}\n\t\t\t\treturn [{ success: true }];\n\t\t\t});\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel: doneModel,\n\t\t\t\t\ttools,\n\t\t\t\t\tonStepStart: (step) => stepStarts.push(step),\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tawait agent.run();\n\n\t\t\texpect(stepStarts.length).toBeGreaterThan(0);\n\t\t\texpect(stepStarts[0]).toBe(1);\n\t\t});\n\n\t\ttest('calls onDone callback with result', async () => {\n\t\t\tlet doneResult: RunOutcome | undefined;\n\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Final answer');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Final answer' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel: doneModel,\n\t\t\t\t\ttools,\n\t\t\t\t\tonDone: (r) => { doneResult = r; },\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tawait agent.run();\n\n\t\t\texpect(doneResult).toBeDefined();\n\t\t\texpect(doneResult!.finalResult).toBe('Final answer');\n\t\t});\n\n\t\ttest('starts browser if not connected', async () => {\n\t\t\tconst browser = createMockBrowser({ isConnected: false });\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Result');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Result' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ browser, model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\texpect(browser.start).toHaveBeenCalled();\n\t\t});\n\t});\n\n\tdescribe('step execution', () => {\n\t\ttest('invokes browser.getState() on each step', async () => {\n\t\t\tconst browser = createMockBrowser();\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ browser, model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\texpect(browser.getState).toHaveBeenCalled();\n\t\t});\n\n\t\ttest('invokes PageAnalyzer.extractState on each step', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tmockExtractState.mockClear();\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\texpect(mockExtractState).toHaveBeenCalled();\n\t\t});\n\n\t\ttest('records history entries for each step', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst tools = createMockTools();\n\t\t\t(tools.executeActions as any) = mock(async () => {\n\t\t\t\tcallCount++;\n\t\t\t\tif (callCount >= 3) {\n\t\t\t\t\treturn [{ success: true, isDone: true, extractedContent: 'Done' }];\n\t\t\t\t}\n\t\t\t\treturn [{ success: true }];\n\t\t\t});\n\n\t\t\tconst model = createDoneOnStepModel(3, 'Done');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst history = agent.getHistory();\n\t\t\texpect(history.entries.length).toBeGreaterThanOrEqual(1);\n\t\t});\n\n\t\ttest('token usage is tracked across steps', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst tools = createMockTools();\n\t\t\t(tools.executeActions as any) = mock(async () => {\n\t\t\t\tcallCount++;\n\t\t\t\tif (callCount >= 2) {\n\t\t\t\t\treturn [{ success: true, isDone: true, extractedContent: 'Done' }];\n\t\t\t\t}\n\t\t\t\treturn [{ success: true }];\n\t\t\t});\n\n\t\t\tconst model = createDoneOnStepModel(2, 'Done');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst state = agent.getState();\n\t\t\texpect(state.totalInputTokens).toBeGreaterThan(0);\n\t\t\texpect(state.totalOutputTokens).toBeGreaterThan(0);\n\t\t});\n\t});\n\n\tdescribe('failure recovery', () => {\n\t\ttest('consecutive failures increment failure count', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst errorModel: LanguageModel = {\n\t\t\t\tmodelId: 'test-model',\n\t\t\t\tprovider: 'custom',\n\t\t\t\tinvoke: async <T>(): Promise<InferenceResult<T>> => {\n\t\t\t\t\tcallCount++;\n\t\t\t\t\tthrow new Error(`Simulated error ${callCount}`);\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel: errorModel,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 10,\n\t\t\t\t\t\tfailureThreshold: 3,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\t\t\texpect(result.errors.length).toBeGreaterThan(0);\n\t\t});\n\n\t\ttest('agent records error about consecutive failures after failureThreshold', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst errorModel: LanguageModel = {\n\t\t\t\tmodelId: 'test-model',\n\t\t\t\tprovider: 'custom',\n\t\t\t\tinvoke: async <T>(): Promise<InferenceResult<T>> => {\n\t\t\t\t\tcallCount++;\n\t\t\t\t\tthrow new Error(`Error ${callCount}`);\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel: errorModel,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 20,\n\t\t\t\t\t\tfailureThreshold: 3,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\t\t\tconst hasFailureError = result.errors.some(\n\t\t\t\t(e) => e.includes('consecutive failures'),\n\t\t\t);\n\t\t\texpect(hasFailureError).toBe(true);\n\t\t});\n\n\t\ttest('successful step resets consecutive failure count', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst model: LanguageModel = {\n\t\t\t\tmodelId: 'test-model',\n\t\t\t\tprovider: 'custom',\n\t\t\t\tinvoke: async <T>(): Promise<InferenceResult<T>> => {\n\t\t\t\t\tcallCount++;\n\t\t\t\t\tif (callCount === 1) {\n\t\t\t\t\t\tthrow new Error('Transient error');\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\tparsed: {\n\t\t\t\t\t\t\tcurrentState: { evaluation: 'Done', memory: '', nextGoal: '' },\n\t\t\t\t\t\t\tactions: [{ action: 'finish', text: 'Success', success: true }],\n\t\t\t\t\t\t} as unknown as T,\n\t\t\t\t\t\tusage: createMockUsage(),\n\t\t\t\t\t\tfinishReason: 'stop',\n\t\t\t\t\t};\n\t\t\t\t},\n\t\t\t};\n\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Success' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel,\n\t\t\t\t\ttools,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 10,\n\t\t\t\t\t\tfailureThreshold: 5,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\t\t\texpect(result.finalResult).toBe('Success');\n\t\t});\n\t});\n\n\tdescribe('done action detection and result extraction', () => {\n\t\ttest('detects done action and extracts result text', async () => {\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Product costs $99' },\n\t\t\t]);\n\n\t\t\tconst model = createDoneOnStepModel(1, 'Product costs $99');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\t\t\tconst result = await agent.run();\n\n\t\t\texpect(result.finalResult).toBe('Product costs $99');\n\t\t\texpect(result.success).toBe(true);\n\t\t});\n\n\t\ttest('handles done action with success=false', async () => {\n\t\t\tconst model = createMockModel({\n\t\t\t\tresponses: [{\n\t\t\t\t\tcurrentState: { evaluation: 'Cannot find', memory: '', nextGoal: '' },\n\t\t\t\t\tactions: [{ action: 'finish', text: 'Could not find', success: false } as Command],\n\t\t\t\t}],\n\t\t\t});\n\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: false, isDone: true, extractedContent: 'Could not find' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\t\t\tconst result = await agent.run();\n\n\t\t\texpect(result.finalResult).toBe('Could not find');\n\t\t\texpect(result.success).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('pause / resume / stop', () => {\n\t\ttest('pause sets isPaused flag', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tagent.pause();\n\t\t\texpect(agent.getState().isPaused).toBe(true);\n\t\t});\n\n\t\ttest('resume clears isPaused flag', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tagent.pause();\n\t\t\tagent.resume();\n\t\t\texpect(agent.getState().isPaused).toBe(false);\n\t\t});\n\n\t\ttest('stop sets isRunning to false', async () => {\n\t\t\tlet stepCount = 0;\n\t\t\tconst tools = createMockTools();\n\t\t\t(tools.executeActions as any) = mock(async () => {\n\t\t\t\tstepCount++;\n\t\t\t\treturn [{ success: true }];\n\t\t\t});\n\n\t\t\tconst model = createMockModel();\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel,\n\t\t\t\t\ttools,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 100,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst runPromise = agent.run();\n\n\t\t\t// Stop after a brief moment\n\t\t\tawait new Promise((r) => setTimeout(r, 50));\n\t\t\tagent.stop();\n\n\t\t\tawait runPromise;\n\t\t\tconst state = agent.getState();\n\t\t\texpect(state.isRunning).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('max steps reached', () => {\n\t\ttest('returns error when max steps exceeded without done', async () => {\n\t\t\tconst model = createMockModel();\n\t\t\tconst tools = createMockTools([{ success: true }]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel,\n\t\t\t\t\ttools,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 3,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\n\t\t\tconst hasMaxStepsError = result.errors.some(\n\t\t\t\t(e) => e.includes('maximum steps'),\n\t\t\t);\n\t\t\texpect(hasMaxStepsError).toBe(true);\n\t\t});\n\n\t\ttest('run() accepts stepLimit parameter to override settings', async () => {\n\t\t\tconst model = createMockModel();\n\t\t\tconst tools = createMockTools([{ success: true }]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel,\n\t\t\t\t\ttools,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 100,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst result = await agent.run(2);\n\n\t\t\tconst hasMaxStepsError = result.errors.some(\n\t\t\t\t(e) => e.includes('maximum steps'),\n\t\t\t);\n\t\t\texpect(hasMaxStepsError).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('sensitive data filtering', () => {\n\t\ttest('filters sensitive values from action results', async () => {\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tisDone: true,\n\t\t\t\t\textractedContent: 'Your API key is sk-12345 and password is hunter2',\n\t\t\t\t},\n\t\t\t]);\n\n\t\t\tconst model = createDoneOnStepModel(1, 'Done');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({\n\t\t\t\t\tmodel,\n\t\t\t\t\ttools,\n\t\t\t\t\tsettings: {\n\t\t\t\t\t\tstepLimit: 5,\n\t\t\t\t\t\tenableScreenshots: false,\n\t\t\t\t\t\tcommandDelayMs: 0,\n\t\t\t\t\t\tretryDelay: 0,\n\t\t\t\t\t\tautoNavigateToUrls: false,\n\t\t\t\t\t\tcontextWindowSize: 50000,\n\t\t\t\t\t\tmaskedValues: {\n\t\t\t\t\t\t\tapiKey: 'sk-12345',\n\t\t\t\t\t\t\tpassword: 'hunter2',\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\n\t\t\tconst history = agent.getHistory();\n\t\t\tfor (const entry of history.entries) {\n\t\t\t\tfor (const ar of entry.actionResults) {\n\t\t\t\t\tif (ar.extractedContent) {\n\t\t\t\t\t\texpect(ar.extractedContent).not.toContain('sk-12345');\n\t\t\t\t\t\texpect(ar.extractedContent).not.toContain('hunter2');\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\ttest('returns unmodified results when no sensitive data configured', async () => {\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\tisDone: true,\n\t\t\t\t\textractedContent: 'Plain text result',\n\t\t\t\t},\n\t\t\t]);\n\n\t\t\tconst model = createDoneOnStepModel(1, 'Done');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\n\t\t\tconst result = await agent.run();\n\t\t\texpect(result.finalResult).toBe('Plain text result');\n\t\t});\n\t});\n\n\tdescribe('history recording', () => {\n\t\ttest('history entries contain step number', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst tools = createMockTools();\n\t\t\t(tools.executeActions as any) = mock(async () => {\n\t\t\t\tcallCount++;\n\t\t\t\tif (callCount >= 2) {\n\t\t\t\t\treturn [{ success: true, isDone: true, extractedContent: 'Done' }];\n\t\t\t\t}\n\t\t\t\treturn [{ success: true }];\n\t\t\t});\n\n\t\t\tconst model = createDoneOnStepModel(2, 'Done');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst history = agent.getHistory();\n\t\t\texpect(history.entries.length).toBeGreaterThanOrEqual(1);\n\t\t\texpect(history.entries[0].step).toBe(1);\n\t\t});\n\n\t\ttest('history entries contain browser state info', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst history = agent.getHistory();\n\t\t\texpect(history.entries.length).toBeGreaterThanOrEqual(1);\n\t\t\texpect(history.entries[0].browserState.url).toBe('https://example.com');\n\t\t\texpect(history.entries[0].browserState.title).toBe('Example Page');\n\t\t});\n\n\t\ttest('history entries contain usage info', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst history = agent.getHistory();\n\t\t\texpect(history.entries.length).toBeGreaterThanOrEqual(1);\n\t\t\texpect(history.entries[0].usage).toBeDefined();\n\t\t\texpect(history.entries[0].usage!.inputTokens).toBe(100);\n\t\t\texpect(history.entries[0].usage!.outputTokens).toBe(50);\n\t\t});\n\n\t\ttest('history is finalized after run', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst history = agent.getHistory();\n\t\t\texpect(history.endTime).toBeDefined();\n\t\t\texpect(history.totalDuration).toBeDefined();\n\t\t});\n\t});\n\n\tdescribe('cost tracking', () => {\n\t\ttest('cumulative cost accumulates across steps', async () => {\n\t\t\tlet callCount = 0;\n\t\t\tconst tools = createMockTools();\n\t\t\t(tools.executeActions as any) = mock(async () => {\n\t\t\t\tcallCount++;\n\t\t\t\tif (callCount >= 3) {\n\t\t\t\t\treturn [{ success: true, isDone: true, extractedContent: 'Done' }];\n\t\t\t\t}\n\t\t\t\treturn [{ success: true }];\n\t\t\t});\n\n\t\t\tconst model = createDoneOnStepModel(3, 'Done');\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst cost = agent.getAccumulatedCost();\n\t\t\texpect(cost.totalInputTokens).toBeGreaterThanOrEqual(100);\n\t\t\texpect(cost.totalOutputTokens).toBeGreaterThanOrEqual(50);\n\t\t});\n\t});\n\n\tdescribe('follow-up tasks', () => {\n\t\ttest('addNewTask stores follow-up tasks', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tagent.addNewTask('Follow up: check price again');\n\t\t\tagent.addNewTask('Follow up: compare with competitor');\n\n\t\t\tconst tasks = agent.getFollowUpTasks();\n\t\t\texpect(tasks).toHaveLength(2);\n\t\t\texpect(tasks[0]).toBe('Follow up: check price again');\n\t\t\texpect(tasks[1]).toBe('Follow up: compare with competitor');\n\t\t});\n\n\t\ttest('getFollowUpTasks returns a copy', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tagent.addNewTask('Task 1');\n\n\t\t\tconst tasks1 = agent.getFollowUpTasks();\n\t\t\tconst tasks2 = agent.getFollowUpTasks();\n\t\t\texpect(tasks1).toEqual(tasks2);\n\t\t\texpect(tasks1).not.toBe(tasks2);\n\t\t});\n\t});\n\n\tdescribe('getState', () => {\n\t\ttest('returns a copy of the state', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tconst state1 = agent.getState();\n\t\t\tconst state2 = agent.getState();\n\t\t\texpect(state1).toEqual(state2);\n\t\t\texpect(state1).not.toBe(state2);\n\t\t});\n\n\t\ttest('tracks current URL after run', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Done');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Done' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tawait agent.run();\n\n\t\t\tconst state = agent.getState();\n\t\t\texpect(state.currentUrl).toBe('https://example.com');\n\t\t});\n\t});\n\n\tdescribe('getAccumulatedCost', () => {\n\t\ttest('returns a copy of cost data', () => {\n\t\t\tconst agent = new Agent(createDefaultAgentOptions());\n\t\t\tconst cost1 = agent.getAccumulatedCost();\n\t\t\tconst cost2 = agent.getAccumulatedCost();\n\t\t\texpect(cost1).toEqual(cost2);\n\t\t\texpect(cost1).not.toBe(cost2);\n\t\t});\n\t});\n\n\tdescribe('run result structure', () => {\n\t\ttest('result contains all expected fields', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Answer');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Answer' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tconst result = await agent.run();\n\n\t\t\texpect(result).toHaveProperty('finalResult');\n\t\t\texpect(result).toHaveProperty('success');\n\t\t\texpect(result).toHaveProperty('history');\n\t\t\texpect(result).toHaveProperty('errors');\n\t\t\texpect(result).toHaveProperty('totalCost');\n\t\t});\n\n\t\ttest('result.history is an ExecutionLog', async () => {\n\t\t\tconst doneModel = createDoneOnStepModel(1, 'Answer');\n\t\t\tconst tools = createMockTools([\n\t\t\t\t{ success: true, isDone: true, extractedContent: 'Answer' },\n\t\t\t]);\n\n\t\t\tconst agent = new Agent(\n\t\t\t\tcreateDefaultAgentOptions({ model: doneModel, tools }),\n\t\t\t);\n\t\t\tconst result = await agent.run();\n\n\t\t\texpect(result.history).toBeDefined();\n\t\t\texpect(result.history.task).toBe('Find the price of the product');\n\t\t\texpect(typeof result.history.finalResult).toBe('function');\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/agent/agent.ts",
    "content": "import { z, ZodError } from 'zod';\nimport type { LanguageModel, InferenceOptions } from '../model/interface.js';\nimport type { Viewport } from '../viewport/viewport.js';\nimport type { FileAccess } from '../sandbox/file-access.js';\nimport { PageAnalyzer } from '../page/page-analyzer.js';\nimport { CommandExecutor } from '../commands/executor.js';\nimport type { Command, CommandResult, ExecutionContext } from '../commands/types.js';\nimport { CommandSchema } from '../commands/types.js';\nimport { InstructionBuilder } from './instructions.js';\nimport { ConversationManager } from './conversation/service.js';\nimport { StallDetector, hashPageTree, hashTextContent } from './stall-detector.js';\nimport { ReplayRecorder } from './replay-recorder.js';\nimport { ResultEvaluator } from './evaluator.js';\nimport {\n\ttype AgentConfig,\n\ttype AgentState,\n\ttype AgentDecision,\n\ttype StepRecord,\n\tExecutionLog,\n\ttype RunOutcome,\n\ttype AccumulatedCost,\n\ttype EvaluationResult,\n\ttype QuickCheckResult,\n\tReasoningSchema,\n\tAgentDecisionCompactSchema,\n\tAgentDecisionDirectSchema,\n\tPlanRevisionSchema,\n\tDEFAULT_AGENT_CONFIG,\n\tcalculateStepCost,\n\tsupportsDeepReasoning,\n\tsupportsCoordinateMode,\n\tisCompactModel,\n} from './types.js';\nimport {\n\tAgentError,\n\tStepLimitExceededError,\n\tAgentStalledError,\n\tModelThrottledError,\n} from '../errors.js';\nimport {\n\tTimer,\n\tsleep,\n\ttruncateText,\n\twithDeadline,\n\textractUrls,\n\tescapeRegExp,\n} from '../utils.js';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('agent');\n\n// ── Agent Options ──\n\nexport interface AgentOptions {\n\ttask: string;\n\tmodel: LanguageModel;\n\tbrowser: Viewport;\n\ttools?: CommandExecutor;\n\t/** Pre-configured PageAnalyzer instance (defaults to a new PageAnalyzer) */\n\tdomService?: PageAnalyzer;\n\tsettings?: Partial<AgentConfig>;\n\t/** Separate model for the judge (defaults to main model) */\n\tjudgeModel?: LanguageModel;\n\t/** Separate model for extraction actions (defaults to main model) */\n\textractionModel?: LanguageModel;\n\t/** File system access for sandbox operations */\n\tfileSystem?: FileAccess;\n\tonStepStart?: (step: number) => void;\n\tonStepEnd?: (step: number, result: CommandResult[]) => void;\n\tonDone?: (result: RunOutcome) => void;\n}\n\n// ── Agent ──\n\nexport class Agent {\n\tprivate model: LanguageModel;\n\tprivate browser: Viewport;\n\tprivate tools: CommandExecutor;\n\tprivate domService: PageAnalyzer;\n\tprivate messageManager: ConversationManager;\n\tprivate loopDetector: StallDetector;\n\tprivate gifRecorder?: ReplayRecorder;\n\tprivate judge?: ResultEvaluator;\n\tprivate settings: AgentConfig;\n\tprivate extractionModel?: LanguageModel;\n\tprivate fileSystem?: FileAccess;\n\n\tprivate state: AgentState;\n\tprivate historyList: ExecutionLog;\n\tprivate startTime = 0;\n\tprivate followUpTasks: string[] = [];\n\n\tprivate onStepStart?: (step: number) => void;\n\tprivate onStepEnd?: (step: number, result: CommandResult[]) => void;\n\tprivate onDone?: (result: RunOutcome) => void;\n\n\tconstructor(options: AgentOptions) {\n\t\tthis.model = options.model;\n\t\tthis.browser = options.browser;\n\t\tthis.settings = { ...DEFAULT_AGENT_CONFIG, ...options.settings, task: options.task };\n\t\tthis.extractionModel = options.extractionModel;\n\t\tthis.fileSystem = options.fileSystem;\n\n\t\tthis.tools = options.tools ?? new CommandExecutor({\n\t\t\tmodel: this.extractionModel ?? this.model,\n\t\t\tallowedUrls: this.settings.allowedUrls,\n\t\t\tblockedUrls: this.settings.blockedUrls,\n\t\t\tcommandsPerStep: this.settings.commandsPerStep,\n\t\t});\n\n\t\tthis.domService = options.domService ?? new PageAnalyzer({\n\t\t\tcapturedAttributes: this.settings.capturedAttributes,\n\t\t});\n\n\t\tthis.messageManager = new ConversationManager({\n\t\t\tcontextWindowSize: this.settings.contextWindowSize,\n\t\t\tincludeLastScreenshot: this.settings.enableScreenshots,\n\t\t\tmaskedValues: this.settings.maskedValues,\n\t\t\tcompaction: this.settings.conversationCompaction,\n\t\t});\n\n\t\tthis.loopDetector = new StallDetector();\n\n\t\tif (this.settings.replayOutputPath) {\n\t\t\tthis.gifRecorder = new ReplayRecorder({\n\t\t\t\toutputPath: this.settings.replayOutputPath,\n\t\t\t});\n\t\t}\n\n\t\t// Judge setup\n\t\tif (this.settings.enableEvaluation || this.settings.enableSimpleJudge) {\n\t\t\tconst judgeModel = options.judgeModel ?? this.model;\n\t\t\tthis.judge = new ResultEvaluator(judgeModel);\n\t\t}\n\n\t\t// Auto-enable coordinate clicking for supported models\n\t\tif (this.settings.autoEnableCoordinateClicking) {\n\t\t\tif (supportsCoordinateMode(this.model.modelId)) {\n\t\t\t\tthis.tools.setCoordinateClicking(true);\n\t\t\t\tlogger.info(`Coordinate clicking auto-enabled for model ${this.model.modelId}`);\n\t\t\t}\n\t\t}\n\n\t\t// Initialize state\n\t\tthis.state = {\n\t\t\tstep: 0,\n\t\t\tstepLimit: this.settings.stepLimit,\n\t\t\tfailureCount: 0,\n\t\t\tconsecutiveFailures: 0,\n\t\t\tisRunning: false,\n\t\t\tisPaused: false,\n\t\t\tisDone: false,\n\t\t\ttotalInputTokens: 0,\n\t\t\ttotalOutputTokens: 0,\n\t\t\tcumulativeCost: {\n\t\t\t\ttotalInputTokens: 0,\n\t\t\t\ttotalOutputTokens: 0,\n\t\t\t\ttotalInputCost: 0,\n\t\t\t\ttotalOutputCost: 0,\n\t\t\t\ttotalCost: 0,\n\t\t\t},\n\t\t};\n\n\t\tthis.historyList = new ExecutionLog({\n\t\t\ttask: this.settings.task,\n\t\t});\n\n\t\tthis.onStepStart = options.onStepStart;\n\t\tthis.onStepEnd = options.onStepEnd;\n\t\tthis.onDone = options.onDone;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Main run loop\n\t// ────────────────────────────────────────\n\n\tasync run(stepLimit?: number): Promise<RunOutcome> {\n\t\tconst effectiveMaxSteps = stepLimit ?? this.settings.stepLimit;\n\t\tthis.state.stepLimit = effectiveMaxSteps;\n\t\tthis.state.isRunning = true;\n\t\tthis.startTime = Date.now();\n\n\t\t// Ensure browser is started\n\t\tif (!this.browser.isConnected) {\n\t\t\tawait this.browser.start();\n\t\t}\n\n\t\t// Build system prompt (may be rebuilt per step if dynamicCommandSchema is on)\n\t\tthis.rebuildInstructionBuilder();\n\n\t\t// URL extraction: auto-navigate to first URL found in task text\n\t\tif (this.settings.autoNavigateToUrls) {\n\t\t\tawait this.autoNavigateFromTask();\n\t\t}\n\n\t\t// Execute initial actions before the main loop\n\t\tif (this.settings.preflightCommands.length > 0) {\n\t\t\tawait this.executeInitialActions();\n\t\t}\n\n\t\tconst errors: string[] = [];\n\t\tlet finalResult: string | undefined;\n\t\tlet success = false;\n\t\tlet judgement: EvaluationResult | undefined;\n\t\tlet simpleJudgement: QuickCheckResult | undefined;\n\n\t\ttry {\n\t\t\tfor (let step = 1; step <= effectiveMaxSteps; step++) {\n\t\t\t\tif (!this.state.isRunning || this.state.isDone) break;\n\n\t\t\t\t// Pause support\n\t\t\t\twhile (this.state.isPaused) {\n\t\t\t\t\tawait sleep(100);\n\t\t\t\t}\n\n\t\t\t\tthis.state.step = step;\n\t\t\t\tthis.onStepStart?.(step);\n\n\t\t\t\ttry {\n\t\t\t\t\t// Wrap step execution in optional timeout\n\t\t\t\t\tconst stepPromise = this.executeStep(step, effectiveMaxSteps);\n\t\t\t\t\tconst result = this.settings.stepDeadlineMs > 0\n\t\t\t\t\t\t? await withDeadline(\n\t\t\t\t\t\t\t\tstepPromise,\n\t\t\t\t\t\t\t\tthis.settings.stepDeadlineMs,\n\t\t\t\t\t\t\t\t`Step ${step} timed out after ${this.settings.stepDeadlineMs}ms`,\n\t\t\t\t\t\t  )\n\t\t\t\t\t\t: await stepPromise;\n\n\t\t\t\t\tthis.state.consecutiveFailures = 0;\n\n\t\t\t\t\t// Check if done\n\t\t\t\t\tconst doneResult = result.find((r) => r.isDone);\n\t\t\t\t\tif (doneResult) {\n\t\t\t\t\t\tfinalResult = doneResult.extractedContent;\n\t\t\t\t\t\tsuccess = doneResult.success;\n\n\t\t\t\t\t\t// Simple judge: quick validation before accepting the result\n\t\t\t\t\t\tif (this.settings.enableSimpleJudge && this.judge && finalResult) {\n\t\t\t\t\t\t\tsimpleJudgement = await this.judge.simpleEvaluate(\n\t\t\t\t\t\t\t\tthis.settings.task,\n\t\t\t\t\t\t\t\tfinalResult,\n\t\t\t\t\t\t\t);\n\n\t\t\t\t\t\t\tif (simpleJudgement.shouldRetry && step < effectiveMaxSteps) {\n\t\t\t\t\t\t\t\tlogger.info(\n\t\t\t\t\t\t\t\t\t`Simple judge suggests retry: ${simpleJudgement.reason}`,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\tthis.messageManager.addCommandResultMessage(\n\t\t\t\t\t\t\t\t\t`The result was reviewed and found lacking: ${simpleJudgement.reason}. ` +\n\t\t\t\t\t\t\t\t\t'Please try a different approach to complete the task.',\n\t\t\t\t\t\t\t\t\tstep,\n\t\t\t\t\t\t\t\t);\n\t\t\t\t\t\t\t\t// Don't mark as done -- continue the loop\n\t\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthis.state.isDone = true;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t\tthis.onStepEnd?.(step, result);\n\n\t\t\t\t\t// Planning: periodically update the plan\n\t\t\t\t\tif (this.settings.enableStrategy && this.shouldUpdatePlan(step)) {\n\t\t\t\t\t\tawait this.updatePlan(step);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Replan on stall: if loop detector shows stuck + planning enabled\n\t\t\t\t\tif (this.settings.restrategizeOnStall && this.settings.enableStrategy) {\n\t\t\t\t\t\tconst loopCheck = this.loopDetector.isStuck();\n\t\t\t\t\t\tif (loopCheck.stuck && loopCheck.severity >= 2) {\n\t\t\t\t\t\t\tlogger.info('Agent stalled, triggering replan');\n\t\t\t\t\t\t\tawait this.updatePlan(step);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Message compaction: every N steps (LLM-based)\n\t\t\t\t\tif (this.messageManager.shouldCompactWithLlm()) {\n\t\t\t\t\t\tconst compacted = await this.messageManager.compactWithLlm(this.model);\n\t\t\t\t\t\tif (compacted) {\n\t\t\t\t\t\t\tlogger.debug(`Messages compacted at step ${step}`);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\t// Save conversation per step if configured\n\t\t\t\t\tif (this.settings.conversationOutputPath) {\n\t\t\t\t\t\tawait this.saveConversation(step);\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\t// Rate limit retry with exponential backoff\n\t\t\t\t\tif (error instanceof ModelThrottledError) {\n\t\t\t\t\t\tconst waitMs = error.retryAfterMs ?? Math.min(\n\t\t\t\t\t\t\t60_000,\n\t\t\t\t\t\t\tthis.settings.retryDelay * 1000 * 2 ** this.state.consecutiveFailures,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tlogger.warn(`Rate limited, waiting ${waitMs}ms before retry`);\n\t\t\t\t\t\tawait sleep(waitMs);\n\t\t\t\t\t\tthis.state.consecutiveFailures++;\n\t\t\t\t\t\t// Don't count rate limits toward max failures\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\t\t\terrors.push(`Step ${step}: ${message}`);\n\n\t\t\t\t\tthis.state.failureCount++;\n\t\t\t\t\tthis.state.consecutiveFailures++;\n\n\t\t\t\t\tif (this.state.consecutiveFailures >= this.settings.failureThreshold) {\n\t\t\t\t\t\t// Failure recovery: make one final LLM call to diagnose\n\t\t\t\t\t\tconst failureSummary = await this.makeFailureRecoveryCall(errors);\n\t\t\t\t\t\tif (failureSummary) {\n\t\t\t\t\t\t\tfinalResult = failureSummary;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tthrow new AgentError(\n\t\t\t\t\t\t\t`Too many consecutive failures (${this.state.consecutiveFailures})`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\n\t\t\t\t\t// Add error message to conversation\n\t\t\t\t\tthis.messageManager.addCommandResultMessage(\n\t\t\t\t\t\t`Error: ${truncateText(message, 400)}`,\n\t\t\t\t\t\tstep,\n\t\t\t\t\t);\n\n\t\t\t\t\t// Wait before retry\n\t\t\t\t\tawait sleep(this.settings.retryDelay * 1000);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (!this.state.isDone && this.state.step >= effectiveMaxSteps) {\n\t\t\t\tthrow new StepLimitExceededError(this.state.step, effectiveMaxSteps);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tif (\n\t\t\t\terror instanceof StepLimitExceededError ||\n\t\t\t\terror instanceof AgentStalledError ||\n\t\t\t\terror instanceof AgentError\n\t\t\t) {\n\t\t\t\terrors.push(error.message);\n\t\t\t} else {\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t} finally {\n\t\t\tthis.state.isRunning = false;\n\n\t\t\t// Save recording\n\t\t\tif (this.gifRecorder) {\n\t\t\t\tawait this.gifRecorder.save();\n\t\t\t}\n\t\t}\n\n\t\t// Full judge evaluation after completion\n\t\tif (this.settings.enableEvaluation && this.judge && finalResult) {\n\t\t\tjudgement = await this.judge.evaluate(\n\t\t\t\tthis.settings.task,\n\t\t\t\tfinalResult,\n\t\t\t\tthis.historyList.entries,\n\t\t\t\t{\n\t\t\t\t\texpectedOutcome: this.settings.expectedOutcome,\n\t\t\t\t\tincludeScreenshots: this.settings.enableScreenshots,\n\t\t\t\t},\n\t\t\t);\n\t\t}\n\n\t\t// Finalize history\n\t\tthis.historyList.finish();\n\n\t\tconst runResult: RunOutcome = {\n\t\t\tfinalResult,\n\t\t\tsuccess,\n\t\t\thistory: this.historyList,\n\t\t\terrors,\n\t\t\tjudgement,\n\t\t\tsimpleJudgement,\n\t\t\ttotalCost: { ...this.state.cumulativeCost },\n\t\t};\n\n\t\tthis.onDone?.(runResult);\n\t\treturn runResult;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Step Execution\n\t// ────────────────────────────────────────\n\n\tprivate async executeStep(step: number, stepLimit: number): Promise<CommandResult[]> {\n\t\tconst timer = new Timer();\n\n\t\t// Get browser state\n\t\tconst browserState = await this.browser.getState();\n\t\tthis.state.currentUrl = browserState.url;\n\n\t\t// Dynamic action schema: rebuild system prompt per step based on current URL\n\t\tif (this.settings.dynamicCommandSchema) {\n\t\t\tthis.rebuildInstructionBuilder(browserState.url);\n\t\t}\n\n\t\t// Extract DOM\n\t\tconst domState = await this.domService.extractState(\n\t\t\tthis.browser.currentPage,\n\t\t\tthis.browser.cdp!,\n\t\t);\n\n\t\t// Take screenshot if using vision\n\t\tlet screenshot: string | undefined;\n\t\tif (this.settings.enableScreenshots) {\n\t\t\tconst screenshotResult = await this.browser.screenshot();\n\t\t\tscreenshot = screenshotResult.base64;\n\n\t\t\tif (this.gifRecorder) {\n\t\t\t\tconst actionLabel = browserState.url;\n\t\t\t\tthis.gifRecorder.addFrame(screenshot, step, actionLabel);\n\t\t\t}\n\t\t}\n\n\t\t// Build state message\n\t\tconst stateText = InstructionBuilder.buildStatePrompt(\n\t\t\tbrowserState.url,\n\t\t\tbrowserState.title,\n\t\t\tbrowserState.tabs,\n\t\t\tdomState.tree,\n\t\t\tstep,\n\t\t\tstepLimit,\n\t\t\tdomState.pixelsAbove,\n\t\t\tdomState.pixelsBelow,\n\t\t);\n\n\t\t// Check for loop\n\t\tconst loopCheck = this.loopDetector.isStuck();\n\t\tlet additionalContext = '';\n\t\tif (loopCheck.stuck) {\n\t\t\tadditionalContext = InstructionBuilder.buildLoopNudge(\n\t\t\t\tthis.loopDetector.getLoopNudgeMessage(),\n\t\t\t);\n\n\t\t\t// Severe loop: throw stuck error\n\t\t\tif (loopCheck.severity >= 3) {\n\t\t\t\tthrow new AgentStalledError(\n\t\t\t\t\t`Agent stuck: ${loopCheck.reason} (severity ${loopCheck.severity})`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// Add plan context if planning is enabled\n\t\tif (this.settings.enableStrategy && this.state.currentPlan) {\n\t\t\tadditionalContext += InstructionBuilder.buildPlanPrompt(this.state.currentPlan);\n\t\t}\n\n\t\t// Add messages\n\t\tthis.messageManager.addStateMessage(\n\t\t\tstateText + additionalContext,\n\t\t\tscreenshot,\n\t\t\tstep,\n\t\t);\n\n\t\t// Determine output schema based on mode\n\t\tconst outputSchema = this.getOutputSchema();\n\n\t\t// Invoke LLM with optional timeout and Zod recovery\n\t\tconst completion = await this.invokeLlmWithRecovery(outputSchema, step);\n\n\t\t// Update token tracking\n\t\tthis.state.totalInputTokens += completion.usage.inputTokens;\n\t\tthis.state.totalOutputTokens += completion.usage.outputTokens;\n\n\t\t// Cost tracking\n\t\tthis.updateCostTracking(completion.usage.inputTokens, completion.usage.outputTokens, step);\n\n\t\tconst output = completion.parsed;\n\n\t\t// Normalize output to standard AgentDecision shape\n\t\tconst normalizedOutput = this.normalizeOutput(output);\n\n\t\t// Add assistant response\n\t\tthis.messageManager.addAssistantMessage(\n\t\t\tJSON.stringify(normalizedOutput.currentState),\n\t\t\tstep,\n\t\t);\n\n\t\t// Execute actions\n\t\tconst context: ExecutionContext = {\n\t\t\tpage: this.browser.currentPage,\n\t\t\tcdpSession: this.browser.cdp!,\n\t\t\tdomService: this.domService,\n\t\t\tbrowserSession: this.browser,\n\t\t\textractionLlm: this.extractionModel,\n\t\t\tfileSystem: this.fileSystem,\n\t\t\tmaskedValues: this.settings.maskedValues,\n\t\t};\n\n\t\tconst actions = normalizedOutput.actions as Command[];\n\t\tconst results = await this.tools.executeActions(actions, context);\n\n\t\t// Record for loop detection (with enhanced fingerprint)\n\t\tthis.loopDetector.recordAction(actions);\n\t\tthis.loopDetector.recordFingerprint({\n\t\t\turl: browserState.url,\n\t\t\tdomHash: hashPageTree(domState.tree),\n\t\t\tscrollY: domState.scrollPosition.y,\n\t\t\telementCount: domState.elementCount,\n\t\t\ttextHash: hashTextContent(domState.tree.slice(0, 2000)),\n\t\t});\n\n\t\t// Filter sensitive data from results\n\t\tconst filteredResults = this.filterSensitiveData(results);\n\n\t\t// Add action results to conversation\n\t\tconst resultText = filteredResults\n\t\t\t.map((r, i) => {\n\t\t\t\tconst actionName = actions[i]?.action ?? 'unknown';\n\t\t\t\tconst status = r.success ? 'success' : `error: ${r.error}`;\n\t\t\t\tconst content = r.extractedContent\n\t\t\t\t\t? `\\nContent: ${r.extractedContent}`\n\t\t\t\t\t: '';\n\t\t\t\treturn `${actionName}: ${status}${content}`;\n\t\t\t})\n\t\t\t.join('\\n');\n\n\t\tif (resultText) {\n\t\t\tthis.messageManager.addCommandResultMessage(resultText, step);\n\t\t}\n\n\t\t// Wait between actions\n\t\tif (this.settings.commandDelayMs > 0) {\n\t\t\tawait sleep(this.settings.commandDelayMs * 1000);\n\t\t}\n\n\t\t// Record history entry\n\t\tconst entry: StepRecord = {\n\t\t\tstep,\n\t\t\ttimestamp: Date.now(),\n\t\t\tbrowserState: {\n\t\t\t\turl: browserState.url,\n\t\t\t\ttitle: browserState.title,\n\t\t\t\ttabs: browserState.tabs,\n\t\t\t\tinteractedElements: actions\n\t\t\t\t\t.filter((a): a is Command & { index: number } => 'index' in a)\n\t\t\t\t\t.map((a) => ({\n\t\t\t\t\t\tindex: a.index,\n\t\t\t\t\t\tdescription: '',\n\t\t\t\t\t\taction: a.action,\n\t\t\t\t\t})),\n\t\t\t\tscreenshot,\n\t\t\t},\n\t\t\tagentOutput: normalizedOutput as AgentDecision,\n\t\t\tactionResults: filteredResults,\n\t\t\tusage: completion.usage,\n\t\t\tduration: timer.elapsed(),\n\t\t\tmetadata: {\n\t\t\t\tstepNumber: step,\n\t\t\t\tdurationMs: timer.elapsed(),\n\t\t\t\tinputTokens: completion.usage.inputTokens,\n\t\t\t\toutputTokens: completion.usage.outputTokens,\n\t\t\t\tactionCount: actions.length,\n\t\t\t\turl: browserState.url,\n\t\t\t\tstartedAt: Date.now() - timer.elapsed(),\n\t\t\t\tcompletedAt: Date.now(),\n\t\t\t},\n\t\t};\n\n\t\tthis.historyList.addEntry(entry);\n\n\t\treturn results;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  LLM Invocation with Zod Recovery\n\t// ────────────────────────────────────────\n\n\tprivate async invokeLlmWithRecovery(\n\t\toutputSchema: z.ZodType<unknown>,\n\t\tstep: number,\n\t\tretryCount = 0,\n\t): Promise<{\n\t\tparsed: Record<string, unknown>;\n\t\tusage: { inputTokens: number; outputTokens: number; totalTokens: number };\n\t}> {\n\t\tconst messages = this.messageManager.getMessages();\n\n\t\tconst invokeOptions: InferenceOptions<unknown> = {\n\t\t\tmessages,\n\t\t\tresponseSchema: outputSchema,\n\t\t\tschemaName: this.getSchemaName(),\n\t\t\tschemaDescription: 'Agent decision with current state assessment and actions to take',\n\t\t};\n\n\t\t// Extended thinking: pass thinking budget as maxTokens\n\t\tif (\n\t\t\tthis.settings.enableDeepReasoning &&\n\t\t\tsupportsDeepReasoning(this.model.modelId)\n\t\t) {\n\t\t\tinvokeOptions.maxTokens = this.settings.reasoningBudget;\n\t\t}\n\n\t\ttry {\n\t\t\t// Wrap LLM call in optional timeout\n\t\t\tconst invokePromise = this.model.invoke(invokeOptions);\n\t\t\tconst completion =\n\t\t\t\tthis.settings.modelDeadlineMs > 0\n\t\t\t\t\t? await withDeadline(\n\t\t\t\t\t\t\tinvokePromise,\n\t\t\t\t\t\t\tthis.settings.modelDeadlineMs,\n\t\t\t\t\t\t\t`LLM call timed out after ${this.settings.modelDeadlineMs}ms`,\n\t\t\t\t\t  )\n\t\t\t\t\t: await invokePromise;\n\n\t\t\treturn {\n\t\t\t\tparsed: completion.parsed as Record<string, unknown>,\n\t\t\t\tusage: completion.usage,\n\t\t\t};\n\t\t} catch (error) {\n\t\t\t// Zod validation error recovery: re-prompt with the error details\n\t\t\tif (error instanceof ZodError && retryCount < 2) {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t`Zod validation failed (attempt ${retryCount + 1}), re-prompting LLM`,\n\t\t\t\t);\n\n\t\t\t\tconst issues = error.issues\n\t\t\t\t\t.map((issue) => `- ${issue.path.join('.')}: ${issue.message}`)\n\t\t\t\t\t.join('\\n');\n\n\t\t\t\tthis.messageManager.addCommandResultMessage(\n\t\t\t\t\t'Your previous response had a validation error. ' +\n\t\t\t\t\t'Please fix the following issues and respond again:\\n' +\n\t\t\t\t\t`${issues}\\n\\n` +\n\t\t\t\t\t'Make sure your response matches the expected JSON schema exactly.',\n\t\t\t\t\tstep,\n\t\t\t\t);\n\n\t\t\t\treturn this.invokeLlmWithRecovery(outputSchema, step, retryCount + 1);\n\t\t\t}\n\n\t\t\t// Re-throw rate limit errors for special handling in the main loop\n\t\t\tif (error instanceof ModelThrottledError) {\n\t\t\t\tthrow error;\n\t\t\t}\n\n\t\t\tthrow error;\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Output Schema Selection\n\t// ────────────────────────────────────────\n\n\tprivate getOutputSchema(): z.ZodType<unknown> {\n\t\t// Flash mode: simpler schema for cheaper / faster models\n\t\tif (this.settings.compactMode || isCompactModel(this.model.modelId)) {\n\t\t\treturn AgentDecisionCompactSchema as z.ZodType<unknown>;\n\t\t}\n\n\t\t// Extended thinking: model reasons internally, skip brain schema\n\t\tif (\n\t\t\tthis.settings.enableDeepReasoning &&\n\t\t\tsupportsDeepReasoning(this.model.modelId)\n\t\t) {\n\t\t\treturn AgentDecisionDirectSchema as z.ZodType<unknown>;\n\t\t}\n\n\t\t// Default full schema with brain + typed action union\n\t\treturn z.object({\n\t\t\tcurrentState: ReasoningSchema,\n\t\t\tactions: z.array(CommandSchema),\n\t\t}) as z.ZodType<unknown>;\n\t}\n\n\tprivate getSchemaName(): string {\n\t\tif (this.settings.compactMode || isCompactModel(this.model.modelId)) {\n\t\t\treturn 'AgentDecisionCompact';\n\t\t}\n\t\tif (\n\t\t\tthis.settings.enableDeepReasoning &&\n\t\t\tsupportsDeepReasoning(this.model.modelId)\n\t\t) {\n\t\t\treturn 'AgentDecisionDirect';\n\t\t}\n\t\treturn 'AgentDecision';\n\t}\n\n\t/**\n\t * Normalize the various output schema shapes into the standard AgentDecision.\n\t */\n\tprivate normalizeOutput(output: Record<string, unknown>): AgentDecision {\n\t\t// Flash schema: { goal, actions }\n\t\tif ('goal' in output && !('currentState' in output)) {\n\t\t\treturn {\n\t\t\t\tcurrentState: {\n\t\t\t\t\tevaluation: String(output.goal ?? ''),\n\t\t\t\t\tmemory: '',\n\t\t\t\t\tnextGoal: String(output.goal ?? ''),\n\t\t\t\t},\n\t\t\t\tactions: (output.actions ?? []) as Record<string, unknown>[],\n\t\t\t};\n\t\t}\n\n\t\t// No-thinking schema: { actions } only\n\t\tif (!('currentState' in output) && 'actions' in output) {\n\t\t\treturn {\n\t\t\t\tcurrentState: {\n\t\t\t\t\tevaluation: '',\n\t\t\t\t\tmemory: '',\n\t\t\t\t\tnextGoal: '',\n\t\t\t\t},\n\t\t\t\tactions: (output.actions ?? []) as Record<string, unknown>[],\n\t\t\t};\n\t\t}\n\n\t\t// Standard schema passthrough\n\t\treturn output as AgentDecision;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Planning System\n\t// ────────────────────────────────────────\n\n\tprivate shouldUpdatePlan(step: number): boolean {\n\t\tif (!this.settings.enableStrategy) return false;\n\t\tconst interval =\n\t\t\tthis.settings.strategyInterval > 0 ? this.settings.strategyInterval : 5;\n\t\tconst lastPlan = this.state.lastPlanStep ?? 0;\n\t\treturn step - lastPlan >= interval;\n\t}\n\n\tprivate async updatePlan(step: number): Promise<void> {\n\t\ttry {\n\t\t\tconst recentHistory = this.historyList.entries\n\t\t\t\t.slice(-5)\n\t\t\t\t.map(\n\t\t\t\t\t(e) =>\n\t\t\t\t\t\t`Step ${e.step}: ${e.agentOutput.currentState?.evaluation ?? '(no eval)'}`,\n\t\t\t\t)\n\t\t\t\t.join('\\n');\n\n\t\t\tconst planPrompt =\n\t\t\t\t`Task: ${this.settings.task}\\n\\n` +\n\t\t\t\t`Current step: ${step}/${this.state.stepLimit}\\n` +\n\t\t\t\t(this.state.currentPlan\n\t\t\t\t\t? `Current plan:\\n${this.state.currentPlan}\\n\\n`\n\t\t\t\t\t: '') +\n\t\t\t\t`Recent progress:\\n${recentHistory}\\n\\n` +\n\t\t\t\t'Based on the current progress, provide an updated plan. ' +\n\t\t\t\t'Include what has been accomplished and what remains.';\n\n\t\t\t// Use ephemeral message so the plan prompt doesn't persist\n\t\t\tthis.messageManager.addEphemeralMessage(planPrompt);\n\n\t\t\tconst completion = await this.model.invoke({\n\t\t\t\tmessages: this.messageManager.getMessages(),\n\t\t\t\tresponseSchema: PlanRevisionSchema,\n\t\t\t\tschemaName: 'PlanRevision',\n\t\t\t\ttemperature: 0.3,\n\t\t\t});\n\n\t\t\tthis.state.currentPlan = completion.parsed.plan;\n\t\t\tthis.state.lastPlanStep = step;\n\n\t\t\tlogger.info(`Plan updated at step ${step}: ${completion.parsed.reasoning}`);\n\t\t} catch (error) {\n\t\t\tlogger.warn(\n\t\t\t\t`Plan update failed at step ${step}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  System Prompt Management\n\t// ────────────────────────────────────────\n\n\t/**\n\t * (Re)build the system prompt. When `pageUrl` is provided, the registry\n\t * can filter action descriptions to show only domain-relevant actions.\n\t */\n\tprivate rebuildInstructionBuilder(pageUrl?: string): void {\n\t\tconst systemPrompt = InstructionBuilder.fromSettings(\n\t\t\tthis.settings,\n\t\t\tthis.tools.registry,\n\t\t\tpageUrl,\n\t\t);\n\t\tthis.messageManager.setInstructionBuilder(systemPrompt.build());\n\t}\n\n\t// ────────────────────────────────────────\n\t//  URL Extraction from Task Text\n\t// ────────────────────────────────────────\n\n\tprivate async autoNavigateFromTask(): Promise<void> {\n\t\tconst urls = extractUrls(this.settings.task);\n\t\tif (urls.length === 0) return;\n\n\t\tconst firstUrl = urls[0];\n\t\tlogger.info(`Auto-navigating to URL found in task: ${firstUrl}`);\n\n\t\ttry {\n\t\t\tawait this.browser.navigate(firstUrl);\n\t\t\t// Give the page a moment to load\n\t\t\tawait sleep(1000);\n\t\t} catch (error) {\n\t\t\tlogger.warn(\n\t\t\t\t`Auto-navigation to ${firstUrl} failed: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Initial Actions\n\t// ────────────────────────────────────────\n\n\tprivate async executeInitialActions(): Promise<void> {\n\t\tlogger.info(\n\t\t\t`Executing ${this.settings.preflightCommands.length} initial action(s)`,\n\t\t);\n\n\t\tconst context: ExecutionContext = {\n\t\t\tpage: this.browser.currentPage,\n\t\t\tcdpSession: this.browser.cdp!,\n\t\t\tdomService: this.domService,\n\t\t\tbrowserSession: this.browser,\n\t\t\textractionLlm: this.extractionModel,\n\t\t\tfileSystem: this.fileSystem,\n\t\t\tmaskedValues: this.settings.maskedValues,\n\t\t};\n\n\t\tfor (const action of this.settings.preflightCommands) {\n\t\t\ttry {\n\t\t\t\tawait this.tools.executeAction(action, context);\n\t\t\t\tlogger.debug(`Initial action ${action.action} completed`);\n\t\t\t} catch (error) {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t`Initial action ${action.action} failed: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tawait sleep(500);\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Failure Recovery\n\t// ────────────────────────────────────────\n\n\t/**\n\t * On max failures, make one final LLM call to produce a diagnostic\n\t * summary. Returns a description of what went wrong, or undefined\n\t * if the recovery call itself fails.\n\t */\n\tprivate async makeFailureRecoveryCall(\n\t\terrors: string[],\n\t): Promise<string | undefined> {\n\t\ttry {\n\t\t\tconst errorSummary = errors.slice(-5).join('\\n');\n\n\t\t\tconst recoverySchema = z.object({\n\t\t\t\tdiagnosis: z.string().describe('What went wrong'),\n\t\t\t\tsuggestion: z.string().describe('What could be tried differently'),\n\t\t\t});\n\n\t\t\tconst completion = await this.model.invoke({\n\t\t\t\tmessages: [\n\t\t\t\t\t{\n\t\t\t\t\t\trole: 'system' as const,\n\t\t\t\t\t\tcontent:\n\t\t\t\t\t\t\t'You are a diagnostic assistant. Analyze the errors that occurred during ' +\n\t\t\t\t\t\t\t'a web browsing automation task and provide a brief diagnosis.',\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\trole: 'user' as const,\n\t\t\t\t\t\tcontent:\n\t\t\t\t\t\t\t`Task: ${this.settings.task}\\n\\n` +\n\t\t\t\t\t\t\t`Errors encountered:\\n${errorSummary}\\n\\n` +\n\t\t\t\t\t\t\t'Provide a brief diagnosis of what went wrong and what could be tried differently.',\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tresponseSchema: recoverySchema,\n\t\t\t\tschemaName: 'FailureRecovery',\n\t\t\t\ttemperature: 0,\n\t\t\t});\n\n\t\t\tconst result =\n\t\t\t\t`Task failed. Diagnosis: ${completion.parsed.diagnosis}. ` +\n\t\t\t\t`Suggestion: ${completion.parsed.suggestion}`;\n\t\t\tlogger.info(`Failure recovery: ${result}`);\n\t\t\treturn result;\n\t\t} catch {\n\t\t\tlogger.debug('Failure recovery call itself failed');\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Cost Tracking\n\t// ────────────────────────────────────────\n\n\tprivate updateCostTracking(\n\t\tinputTokens: number,\n\t\toutputTokens: number,\n\t\tstep: number,\n\t): void {\n\t\tconst stepCost = calculateStepCost(\n\t\t\tinputTokens,\n\t\t\toutputTokens,\n\t\t\tthis.model.modelId,\n\t\t);\n\n\t\tthis.state.cumulativeCost.totalInputTokens += inputTokens;\n\t\tthis.state.cumulativeCost.totalOutputTokens += outputTokens;\n\n\t\tif (stepCost) {\n\t\t\tthis.state.cumulativeCost.totalInputCost += stepCost.inputCost;\n\t\t\tthis.state.cumulativeCost.totalOutputCost += stepCost.outputCost;\n\t\t\tthis.state.cumulativeCost.totalCost += stepCost.totalCost;\n\n\t\t\tlogger.debug(\n\t\t\t\t`Step ${step} cost: $${stepCost.totalCost.toFixed(4)} ` +\n\t\t\t\t`(cumulative: $${this.state.cumulativeCost.totalCost.toFixed(4)})`,\n\t\t\t);\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Sensitive Data Filtering\n\t// ────────────────────────────────────────\n\n\tprivate filterSensitiveData(results: CommandResult[]): CommandResult[] {\n\t\tif (!this.settings.maskedValues) return results;\n\n\t\treturn results.map((r) => {\n\t\t\tif (!r.extractedContent) return r;\n\n\t\t\tlet content = r.extractedContent;\n\t\t\tfor (const [key, value] of Object.entries(this.settings.maskedValues!)) {\n\t\t\t\tcontent = content.replace(\n\t\t\t\t\tnew RegExp(escapeRegExp(value), 'g'),\n\t\t\t\t\t`<${key}>`,\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn { ...r, extractedContent: content };\n\t\t});\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Save Conversation\n\t// ────────────────────────────────────────\n\n\tprivate async saveConversation(step: number): Promise<void> {\n\t\tif (!this.settings.conversationOutputPath) return;\n\n\t\ttry {\n\t\t\tconst filePath = this.settings.conversationOutputPath.replace(\n\t\t\t\t/\\{step\\}/g,\n\t\t\t\tstep.toString(),\n\t\t\t);\n\t\t\tawait this.messageManager.saveToFile(filePath);\n\t\t} catch (error) {\n\t\t\tlogger.debug(\n\t\t\t\t`Failed to save conversation at step ${step}: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Follow-up Tasks\n\t// ────────────────────────────────────────\n\n\t/**\n\t * Add a follow-up task to be executed after the current task completes.\n\t * Tasks are stored and can be retrieved via getFollowUpTasks().\n\t */\n\taddNewTask(task: string): void {\n\t\tthis.followUpTasks.push(task);\n\t\tlogger.info(`Follow-up task added: ${truncateText(task, 100)}`);\n\t}\n\n\tgetFollowUpTasks(): string[] {\n\t\treturn [...this.followUpTasks];\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Control Methods\n\t// ────────────────────────────────────────\n\n\tpause(): void {\n\t\tthis.state.isPaused = true;\n\t}\n\n\tresume(): void {\n\t\tthis.state.isPaused = false;\n\t}\n\n\tstop(): void {\n\t\tthis.state.isRunning = false;\n\t}\n\n\tgetState(): AgentState {\n\t\treturn { ...this.state };\n\t}\n\n\tgetHistory(): ExecutionLog {\n\t\treturn this.historyList;\n\t}\n\n\tgetAccumulatedCost(): AccumulatedCost {\n\t\treturn { ...this.state.cumulativeCost };\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/agent/conversation/service.ts",
    "content": "import { z } from 'zod';\nimport type { Message } from '../../model/messages.js';\nimport {\n\tsystemMessage,\n\tuserMessage,\n\tassistantMessage,\n\timageContent,\n\ttextContent,\n\ttype ContentPart,\n} from '../../model/messages.js';\nimport type { LanguageModel } from '../../model/interface.js';\nimport type {\n\tConversationManagerOptions,\n\tTrackedMessage,\n\tConversationManagerState,\n\tConversationEntry,\n\tSerializedTrackedMessage,\n\tMessageCategory,\n} from './types.js';\nimport {\n\testimateTokens,\n\testimateMessageTokens,\n\tredactMessages,\n\textractTextContent,\n\ttruncate,\n} from './utils.js';\n\n// ── LLM Compaction Summary Schema ──\n\nconst CompactionSummarySchema = z.object({\n\tsummary: z.string().describe('Concise summary of the conversation so far'),\n});\n\n// ── ConversationManager ──\n\nexport class ConversationManager {\n\tprivate messages: TrackedMessage[] = [];\n\tprivate systemPromptMessage: Message | null = null;\n\tprivate systemPromptText: string | null = null;\n\tprivate options: ConversationManagerOptions;\n\tprivate historyItems: ConversationEntry[] = [];\n\tprivate currentStep = 0;\n\tprivate lastCompactionStep = 0;\n\n\tconstructor(options: ConversationManagerOptions) {\n\t\tthis.options = options;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  System Prompt\n\t// ────────────────────────────────────────\n\n\tsetInstructionBuilder(prompt: string): void {\n\t\tthis.systemPromptText = prompt;\n\t\tthis.systemPromptMessage = systemMessage(prompt);\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Add Messages\n\t// ────────────────────────────────────────\n\n\taddStateMessage(\n\t\tstateText: string,\n\t\tscreenshot?: string,\n\t\tstep?: number,\n\t): void {\n\t\tconst content: ContentPart[] = [textContent(stateText)];\n\n\t\tif (screenshot && this.options.includeLastScreenshot) {\n\t\t\tcontent.push(imageContent(screenshot, 'image/png'));\n\t\t}\n\n\t\tif (step !== undefined) this.currentStep = step;\n\n\t\tthis.messages.push({\n\t\t\tmessage: userMessage(content),\n\t\t\tisCompactable: true,\n\t\t\ttokenEstimate: estimateMessageTokens(content),\n\t\t\tstep,\n\t\t\tcategory: 'state',\n\t\t\taddedAt: Date.now(),\n\t\t});\n\n\t\tthis.recordConversationEntry(step ?? this.currentStep, 'state', stateText, !!screenshot);\n\t}\n\n\taddAssistantMessage(text: string, step?: number): void {\n\t\tif (step !== undefined) this.currentStep = step;\n\n\t\tthis.messages.push({\n\t\t\tmessage: assistantMessage(text),\n\t\t\tisCompactable: true,\n\t\t\ttokenEstimate: estimateTokens(text),\n\t\t\tstep,\n\t\t\tcategory: 'assistant',\n\t\t\taddedAt: Date.now(),\n\t\t});\n\n\t\tthis.recordConversationEntry(step ?? this.currentStep, 'assistant', text);\n\t}\n\n\taddCommandResultMessage(text: string, step?: number): void {\n\t\tif (step !== undefined) this.currentStep = step;\n\n\t\tthis.messages.push({\n\t\t\tmessage: userMessage(text),\n\t\t\tisCompactable: true,\n\t\t\ttokenEstimate: estimateTokens(text),\n\t\t\tstep,\n\t\t\tcategory: 'action_result',\n\t\t\taddedAt: Date.now(),\n\t\t});\n\n\t\tthis.recordConversationEntry(step ?? this.currentStep, 'action_result', text);\n\t}\n\n\taddUserMessage(text: string): void {\n\t\tthis.messages.push({\n\t\t\tmessage: userMessage(text),\n\t\t\tisCompactable: false,\n\t\t\ttokenEstimate: estimateTokens(text),\n\t\t\tcategory: 'user',\n\t\t\taddedAt: Date.now(),\n\t\t});\n\n\t\tthis.recordConversationEntry(this.currentStep, 'user', text);\n\t}\n\n\t/**\n\t * Add an ephemeral message that is included in the next getMessages() call\n\t * and then automatically removed. Useful for one-shot instructions or\n\t * temporary context that should not persist across steps.\n\t */\n\taddEphemeralMessage(text: string, role: 'user' | 'assistant' = 'user'): void {\n\t\tconst msg =\n\t\t\trole === 'user' ? userMessage(text) : assistantMessage(text);\n\n\t\tthis.messages.push({\n\t\t\tmessage: msg,\n\t\t\tisCompactable: false,\n\t\t\ttokenEstimate: estimateTokens(text),\n\t\t\tcategory: role === 'user' ? 'user' : 'assistant',\n\t\t\tephemeral: true,\n\t\t\tephemeralRead: false,\n\t\t\taddedAt: Date.now(),\n\t\t});\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Get Messages (with compaction + filtering)\n\t// ────────────────────────────────────────\n\n\tgetMessages(): Message[] {\n\t\tconst result: Message[] = [];\n\n\t\tif (this.systemPromptMessage) {\n\t\t\tresult.push(this.systemPromptMessage);\n\t\t}\n\n\t\t// Check if we need to compact\n\t\tconst totalTokens = this.estimateTotalTokens();\n\t\tif (totalTokens > this.options.contextWindowSize) {\n\t\t\tthis.compact();\n\t\t}\n\n\t\tfor (const managed of this.messages) {\n\t\t\tresult.push(managed.message);\n\t\t}\n\n\t\t// Mark ephemeral messages as read so they can be cleaned up\n\t\tthis.consumeEphemeralMessages();\n\n\t\t// Apply sensitive data filtering\n\t\tif (this.options.maskedValues && Object.keys(this.options.maskedValues).length > 0) {\n\t\t\treturn redactMessages(result, this.options.maskedValues);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Ephemeral Message Lifecycle\n\t// ────────────────────────────────────────\n\n\t/**\n\t * After getMessages() has been called, remove ephemeral messages that were already read.\n\t * Freshly-added ephemeral messages are marked as read (so they survive one getMessages call).\n\t */\n\tprivate consumeEphemeralMessages(): void {\n\t\t// Remove previously-read ephemeral messages\n\t\tthis.messages = this.messages.filter(\n\t\t\t(m) => !(m.ephemeral && m.ephemeralRead),\n\t\t);\n\n\t\t// Mark remaining ephemeral messages as read for the next pass\n\t\tfor (const m of this.messages) {\n\t\t\tif (m.ephemeral && !m.ephemeralRead) {\n\t\t\t\tm.ephemeralRead = true;\n\t\t\t}\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Token Estimation\n\t// ────────────────────────────────────────\n\n\testimateTotalTokens(): number {\n\t\tlet total = 0;\n\t\tif (this.systemPromptMessage) {\n\t\t\ttotal += estimateTokens(\n\t\t\t\ttypeof this.systemPromptMessage.content === 'string'\n\t\t\t\t\t? this.systemPromptMessage.content\n\t\t\t\t\t: '',\n\t\t\t);\n\t\t}\n\t\tfor (const managed of this.messages) {\n\t\t\ttotal += managed.tokenEstimate;\n\t\t}\n\t\treturn total;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Basic Compaction (image removal + old message replacement)\n\t// ────────────────────────────────────────\n\n\tprivate compact(): void {\n\t\t// Remove screenshots from older messages (keep only last)\n\t\tlet foundLast = false;\n\t\tfor (let i = this.messages.length - 1; i >= 0; i--) {\n\t\t\tconst msg = this.messages[i];\n\t\t\tif (!msg.isCompactable) continue;\n\n\t\t\tconst content = msg.message.content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\tconst hasImage = content.some(\n\t\t\t\t\t(p) => typeof p === 'object' && p !== null && (p as ContentPart).type === 'image',\n\t\t\t\t);\n\t\t\t\tif (hasImage) {\n\t\t\t\t\tif (foundLast) {\n\t\t\t\t\t\t// Remove images from this message\n\t\t\t\t\t\tconst filtered = content.filter(\n\t\t\t\t\t\t\t(p) =>\n\t\t\t\t\t\t\t\ttypeof p !== 'object' ||\n\t\t\t\t\t\t\t\tp === null ||\n\t\t\t\t\t\t\t\t(p as ContentPart).type !== 'image',\n\t\t\t\t\t\t);\n\t\t\t\t\t\tif (filtered.length > 0) {\n\t\t\t\t\t\t\tmsg.message = userMessage(filtered as ContentPart[]);\n\t\t\t\t\t\t\tmsg.tokenEstimate = estimateMessageTokens(filtered);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tfoundLast = true;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// If still over budget, remove old compactable state messages\n\t\twhile (\n\t\t\tthis.estimateTotalTokens() > this.options.contextWindowSize &&\n\t\t\tthis.messages.length > 4\n\t\t) {\n\t\t\t// Find first compactable message\n\t\t\tconst idx = this.messages.findIndex((m) => m.isCompactable);\n\t\t\tif (idx === -1) break;\n\n\t\t\t// Replace with a summary\n\t\t\tconst removed = this.messages.splice(idx, 1)[0];\n\t\t\tconst summary = `[Step ${removed.step ?? '?'} state omitted to save tokens]`;\n\t\t\tthis.messages.splice(idx, 0, {\n\t\t\t\tmessage: userMessage(summary),\n\t\t\t\tisCompactable: true,\n\t\t\t\ttokenEstimate: estimateTokens(summary),\n\t\t\t\tstep: removed.step,\n\t\t\t\tcategory: 'compaction_summary',\n\t\t\t\taddedAt: Date.now(),\n\t\t\t});\n\t\t}\n\t}\n\n\t// ────────────────────────────────────────\n\t//  LLM-Based Compaction\n\t// ────────────────────────────────────────\n\n\t/**\n\t * Run LLM-based message compaction: send the older portion of the conversation\n\t * to a summarization model and replace it with a single summary message.\n\t *\n\t * Call this periodically (e.g. every N steps as configured in compaction.interval).\n\t * Returns true if compaction was performed, false if skipped.\n\t */\n\tasync compactWithLlm(model?: LanguageModel): Promise<boolean> {\n\t\tconst compactionConfig = this.options.compaction;\n\t\tif (!compactionConfig) return false;\n\n\t\tconst llm = model ?? this.options.compactionModel;\n\t\tif (!llm) return false;\n\n\t\t// Only compact if enough steps have passed since last compaction\n\t\tif (\n\t\t\tcompactionConfig.interval > 0 &&\n\t\t\tthis.currentStep - this.lastCompactionStep < compactionConfig.interval\n\t\t) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst targetTokens =\n\t\t\tcompactionConfig.targetTokens ??\n\t\t\tMath.floor(this.options.contextWindowSize * 0.6);\n\n\t\t// If we're under the target, no need to compact\n\t\tif (this.estimateTotalTokens() <= targetTokens) return false;\n\n\t\t// Split messages: keep the last few messages intact, summarize the rest\n\t\tconst keepCount = Math.min(6, Math.floor(this.messages.length / 2));\n\t\tconst toSummarize = this.messages.slice(0, this.messages.length - keepCount);\n\t\tconst toKeep = this.messages.slice(this.messages.length - keepCount);\n\n\t\tif (toSummarize.length === 0) return false;\n\n\t\t// Build a transcript of the messages to summarize\n\t\tconst transcript = toSummarize\n\t\t\t.map((m) => {\n\t\t\t\tconst role = m.message.role;\n\t\t\t\tconst text = extractTextContent(m.message);\n\t\t\t\tconst stepLabel = m.step !== undefined ? ` (step ${m.step})` : '';\n\t\t\t\treturn `[${role}${stepLabel}]: ${truncate(text, 500)}`;\n\t\t\t})\n\t\t\t.join('\\n');\n\n\t\tconst prompt = [\n\t\t\tsystemMessage(\n\t\t\t\t'You are a conversation summarizer. Summarize the following agent-browser conversation transcript. ' +\n\t\t\t\t'Preserve key facts: URLs visited, actions taken, errors encountered, extracted data, and the current task state. ' +\n\t\t\t\t'Be concise but complete.',\n\t\t\t),\n\t\t\tuserMessage(\n\t\t\t\t`Summarize this conversation transcript:\\n\\n${transcript}`,\n\t\t\t),\n\t\t];\n\n\t\ttry {\n\t\t\tconst completion = await llm.invoke({\n\t\t\t\tmessages: prompt,\n\t\t\t\tresponseSchema: CompactionSummarySchema,\n\t\t\t\tschemaName: 'CompactionSummary',\n\t\t\t\tschemaDescription: 'A concise summary of the conversation so far',\n\t\t\t\tmaxTokens: compactionConfig.maxTokens,\n\t\t\t\ttemperature: 0,\n\t\t\t});\n\n\t\t\tconst summaryText = `[Conversation summary of steps 1-${toSummarize[toSummarize.length - 1]?.step ?? '?'}]\\n${completion.parsed.summary}`;\n\n\t\t\t// Replace the summarized messages with a single summary\n\t\t\tthis.messages = [\n\t\t\t\t{\n\t\t\t\t\tmessage: userMessage(summaryText),\n\t\t\t\t\tisCompactable: false, // Don't re-compact the summary\n\t\t\t\t\ttokenEstimate: estimateTokens(summaryText),\n\t\t\t\t\tcategory: 'compaction_summary',\n\t\t\t\t\taddedAt: Date.now(),\n\t\t\t\t},\n\t\t\t\t...toKeep,\n\t\t\t];\n\n\t\t\tthis.lastCompactionStep = this.currentStep;\n\t\t\treturn true;\n\t\t} catch {\n\t\t\t// If LLM compaction fails, fall back to basic compaction silently\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/**\n\t * Check whether LLM compaction should run at the current step.\n\t * This is a convenience check; the caller can use it to decide whether\n\t * to call compactWithLlm().\n\t */\n\tshouldCompactWithLlm(): boolean {\n\t\tconst config = this.options.compaction;\n\t\tif (!config || config.interval <= 0) return false;\n\t\treturn (\n\t\t\tthis.currentStep - this.lastCompactionStep >= config.interval &&\n\t\t\tthis.estimateTotalTokens() > (config.targetTokens ?? this.options.contextWindowSize * 0.6)\n\t\t);\n\t}\n\n\t// ────────────────────────────────────────\n\t//  History Items & Description\n\t// ────────────────────────────────────────\n\n\tprivate recordConversationEntry(\n\t\tstep: number,\n\t\tcategory: MessageCategory,\n\t\tcontent: string,\n\t\thasScreenshot?: boolean,\n\t): void {\n\t\tthis.historyItems.push({\n\t\t\tstep,\n\t\t\tcategory,\n\t\t\tsummary: truncate(content, 120),\n\t\t\tcontent: truncate(content, 2000),\n\t\t\thasScreenshot,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\t/**\n\t * Build a human-readable description of the agent's history,\n\t * with \"N steps omitted\" truncation for long histories.\n\t *\n\t * @param stepLimitShown Maximum number of steps to show in full detail.\n\t *   If the history is longer, middle steps are replaced with a \"N steps omitted\" line.\n\t */\n\tagentHistoryDescription(stepLimitShown = 10): string {\n\t\t// Group history items by step\n\t\tconst byStep = new Map<number, ConversationEntry[]>();\n\t\tfor (const item of this.historyItems) {\n\t\t\tconst existing = byStep.get(item.step);\n\t\t\tif (existing) {\n\t\t\t\texisting.push(item);\n\t\t\t} else {\n\t\t\t\tbyStep.set(item.step, [item]);\n\t\t\t}\n\t\t}\n\n\t\tconst stepNumbers = [...byStep.keys()].sort((a, b) => a - b);\n\t\tif (stepNumbers.length === 0) return '(no history)';\n\n\t\tconst lines: string[] = [];\n\n\t\tif (stepNumbers.length <= stepLimitShown) {\n\t\t\t// Show all steps\n\t\t\tfor (const stepNum of stepNumbers) {\n\t\t\t\tlines.push(this.formatStepDescription(stepNum, byStep.get(stepNum)!));\n\t\t\t}\n\t\t} else {\n\t\t\t// Show first few, omitted middle, last few\n\t\t\tconst headCount = Math.ceil(stepLimitShown / 2);\n\t\t\tconst tailCount = stepLimitShown - headCount;\n\t\t\tconst headSteps = stepNumbers.slice(0, headCount);\n\t\t\tconst tailSteps = stepNumbers.slice(stepNumbers.length - tailCount);\n\t\t\tconst omittedCount = stepNumbers.length - headCount - tailCount;\n\n\t\t\tfor (const stepNum of headSteps) {\n\t\t\t\tlines.push(this.formatStepDescription(stepNum, byStep.get(stepNum)!));\n\t\t\t}\n\n\t\t\tlines.push(`  ... (${omittedCount} steps omitted) ...`);\n\n\t\t\tfor (const stepNum of tailSteps) {\n\t\t\t\tlines.push(this.formatStepDescription(stepNum, byStep.get(stepNum)!));\n\t\t\t}\n\t\t}\n\n\t\treturn lines.join('\\n');\n\t}\n\n\tprivate formatStepDescription(step: number, items: ConversationEntry[]): string {\n\t\tconst parts = items.map((item) => {\n\t\t\tconst prefix = item.category === 'state' ? 'State' :\n\t\t\t\titem.category === 'assistant' ? 'Agent' :\n\t\t\t\titem.category === 'action_result' ? 'Result' :\n\t\t\t\titem.category === 'user' ? 'User' : item.category;\n\t\t\treturn `${prefix}: ${item.summary}`;\n\t\t});\n\t\treturn `Step ${step}:\\n  ${parts.join('\\n  ')}`;\n\t}\n\n\t/** Get all recorded history items. */\n\tgetConversationEntrys(): readonly ConversationEntry[] {\n\t\treturn this.historyItems;\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Save / Load (Conversation Persistence)\n\t// ────────────────────────────────────────\n\n\t/**\n\t * Serialize the current state to a persistence-friendly snapshot.\n\t * Screenshots are stripped (replaced with placeholder text) to keep size manageable.\n\t */\n\tsave(): ConversationManagerState {\n\t\tconst serialized: SerializedTrackedMessage[] = this.messages.map((m) => ({\n\t\t\trole: m.message.role,\n\t\t\tcontent: extractTextContent(m.message),\n\t\t\tisCompactable: m.isCompactable,\n\t\t\ttokenEstimate: m.tokenEstimate,\n\t\t\tstep: m.step,\n\t\t\tcategory: m.category,\n\t\t}));\n\n\t\treturn {\n\t\t\tsystemPrompt: this.systemPromptText,\n\t\t\tmessages: serialized,\n\t\t\thistoryItems: [...this.historyItems],\n\t\t\tcurrentStep: this.currentStep,\n\t\t};\n\t}\n\n\t/**\n\t * Restore the ConversationManager from a previously saved state.\n\t * This replaces all current messages and history.\n\t */\n\tload(state: ConversationManagerState): void {\n\t\tif (state.systemPrompt) {\n\t\t\tthis.setInstructionBuilder(state.systemPrompt);\n\t\t} else {\n\t\t\tthis.systemPromptMessage = null;\n\t\t\tthis.systemPromptText = null;\n\t\t}\n\n\t\tthis.messages = state.messages.map((s) => ({\n\t\t\tmessage:\n\t\t\t\ts.role === 'assistant'\n\t\t\t\t\t? assistantMessage(s.content)\n\t\t\t\t\t: userMessage(s.content),\n\t\t\tisCompactable: s.isCompactable,\n\t\t\ttokenEstimate: s.tokenEstimate,\n\t\t\tstep: s.step,\n\t\t\tcategory: s.category,\n\t\t\taddedAt: Date.now(),\n\t\t}));\n\n\t\tthis.historyItems = [...state.historyItems];\n\t\tthis.currentStep = state.currentStep;\n\t}\n\n\t/**\n\t * Save the conversation state to a JSON file.\n\t */\n\tasync saveToFile(filePath: string): Promise<string> {\n\t\tconst { writeFile, mkdir } = await import('node:fs/promises');\n\t\tconst { dirname } = await import('node:path');\n\t\tawait mkdir(dirname(filePath), { recursive: true });\n\t\tconst json = JSON.stringify(this.save(), null, 2);\n\t\tawait writeFile(filePath, json, 'utf-8');\n\t\treturn filePath;\n\t}\n\n\t/**\n\t * Load conversation state from a JSON file.\n\t */\n\tasync loadFromFile(filePath: string): Promise<void> {\n\t\tconst { readFile } = await import('node:fs/promises');\n\t\tconst raw = await readFile(filePath, 'utf-8');\n\t\tconst state = JSON.parse(raw) as ConversationManagerState;\n\t\tthis.load(state);\n\t}\n\n\t// ────────────────────────────────────────\n\t//  Accessors\n\t// ────────────────────────────────────────\n\n\tget messageCount(): number {\n\t\treturn this.messages.length + (this.systemPromptMessage ? 1 : 0);\n\t}\n\n\tget step(): number {\n\t\treturn this.currentStep;\n\t}\n\n\tclear(): void {\n\t\tthis.messages = [];\n\t\tthis.historyItems = [];\n\t\tthis.currentStep = 0;\n\t\tthis.lastCompactionStep = 0;\n\t}\n\n\t/**\n\t * Remove all messages but preserve history items and step counter.\n\t * Useful when restarting message context without losing the history summary.\n\t */\n\tresetMessages(): void {\n\t\tthis.messages = [];\n\t\tthis.lastCompactionStep = 0;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/agent/conversation/types.ts",
    "content": "import type { Message } from '../../model/messages.js';\nimport type { CompactionPolicy } from '../types.js';\nimport type { LanguageModel } from '../../model/interface.js';\n\n// ── Message Manager Options ──\n\nexport interface ConversationManagerOptions {\n\tcontextWindowSize: number;\n\testimateTokens?: (text: string) => number;\n\tincludeLastScreenshot: boolean;\n\t/** Sensitive key-value pairs to mask in outgoing messages. */\n\tmaskedValues?: Record<string, string>;\n\t/** LLM-based compaction configuration. */\n\tcompaction?: CompactionPolicy;\n\t/** LanguageModel used for LLM-based compaction. Ignored if compaction is not set. */\n\tcompactionModel?: LanguageModel;\n}\n\n// ── Managed Message ──\n\nexport type MessageCategory =\n\t| 'system'\n\t| 'state'\n\t| 'action_result'\n\t| 'assistant'\n\t| 'user'\n\t| 'compaction_summary';\n\nexport interface TrackedMessage {\n\tmessage: Message;\n\tisCompactable: boolean;\n\ttokenEstimate: number;\n\tstep?: number;\n\t/** Semantic category for structured history tracking. */\n\tcategory?: MessageCategory;\n\t/** When true, this message is only included on the next getMessages() call then removed. */\n\tephemeral?: boolean;\n\t/** When true, this message has already been read (consumed) in an ephemeral pass. */\n\tephemeralRead?: boolean;\n\t/** Timestamp when this message was added. */\n\taddedAt?: number;\n}\n\n// ── History Item ──\n\n/**\n * A structured entry in the agent's conversation history, richer than TrackedMessage.\n * Used for building human-readable summaries and for save/load.\n */\nexport interface ConversationEntry {\n\t/** Step number this item belongs to. */\n\tstep: number;\n\t/** Category of this history item. */\n\tcategory: MessageCategory;\n\t/** Brief human-readable summary of this item (e.g. \"Clicked element 5\" or \"Navigated to google.com\"). */\n\tsummary: string;\n\t/** The full text content (truncated for large payloads). */\n\tcontent?: string;\n\t/** Whether this item included a screenshot. */\n\thasScreenshot?: boolean;\n\t/** Timestamp. */\n\ttimestamp: number;\n}\n\n// ── Message Manager State (persistence) ──\n\n/**\n * Serializable snapshot of the ConversationManager for save/load.\n */\nexport interface ConversationManagerState {\n\tsystemPrompt: string | null;\n\tmessages: SerializedTrackedMessage[];\n\thistoryItems: ConversationEntry[];\n\t/** Step count at the time of snapshot. */\n\tcurrentStep: number;\n}\n\n/**\n * Serializable form of TrackedMessage (Message content may contain base64\n * screenshots, which are replaced with placeholders during serialization).\n */\nexport interface SerializedTrackedMessage {\n\trole: string;\n\tcontent: string;\n\tisCompactable: boolean;\n\ttokenEstimate: number;\n\tstep?: number;\n\tcategory?: MessageCategory;\n}\n"
  },
  {
    "path": "packages/core/src/agent/conversation/utils.ts",
    "content": "import type { Message } from '../../model/messages.js';\nimport type { ContentPart } from '../../model/messages.js';\n\n/**\n * Rough token estimation: ~4 characters per token.\n */\nexport function estimateTokens(text: string): number {\n\treturn Math.ceil(text.length / 4);\n}\n\nexport function estimateMessageTokens(content: string | unknown[]): number {\n\tif (typeof content === 'string') {\n\t\treturn estimateTokens(content);\n\t}\n\n\tlet total = 0;\n\tfor (const part of content) {\n\t\tif (typeof part === 'object' && part !== null) {\n\t\t\tconst p = part as Record<string, unknown>;\n\t\t\tif (p.type === 'text' && typeof p.text === 'string') {\n\t\t\t\ttotal += estimateTokens(p.text);\n\t\t\t} else if (p.type === 'image') {\n\t\t\t\ttotal += 1000; // Approximate cost for an image\n\t\t\t}\n\t\t}\n\t}\n\treturn total;\n}\n\n// ── Sensitive Data Filtering ──\n\nconst MASK = '***';\n\n/**\n * Replace all occurrences of each sensitive value in `text` with a mask.\n * Keys are used only for logging context; values are the secrets to redact.\n */\nexport function redactSensitiveValues(\n\ttext: string,\n\tmaskedValues: Record<string, string>,\n): string {\n\tlet result = text;\n\tfor (const [_key, value] of Object.entries(maskedValues)) {\n\t\tif (!value) continue;\n\t\t// Escape regex special characters in the value\n\t\tconst escaped = value.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\t\tresult = result.replace(new RegExp(escaped, 'g'), MASK);\n\t}\n\treturn result;\n}\n\n/**\n * Deep-filter a Message, masking any sensitive values found in text content.\n * Returns a new message (does not mutate the original).\n */\nexport function redactMessage(\n\tmessage: Message,\n\tmaskedValues: Record<string, string>,\n): Message {\n\tconst entries = Object.entries(maskedValues);\n\tif (entries.length === 0) return message;\n\n\tconst content = message.content;\n\n\tif (typeof content === 'string') {\n\t\treturn {\n\t\t\t...message,\n\t\t\tcontent: redactSensitiveValues(content, maskedValues),\n\t\t} as Message;\n\t}\n\n\tif (Array.isArray(content)) {\n\t\tconst filtered = (content as ContentPart[]).map((part) => {\n\t\t\tif (part.type === 'text') {\n\t\t\t\treturn {\n\t\t\t\t\t...part,\n\t\t\t\t\ttext: redactSensitiveValues(part.text, maskedValues),\n\t\t\t\t};\n\t\t\t}\n\t\t\t// Images are left as-is (binary data)\n\t\t\treturn part;\n\t\t});\n\t\treturn {\n\t\t\t...message,\n\t\t\tcontent: filtered,\n\t\t} as Message;\n\t}\n\n\treturn message;\n}\n\n/**\n * Filter an array of Messages, masking sensitive data in each.\n */\nexport function redactMessages(\n\tmessages: Message[],\n\tmaskedValues: Record<string, string>,\n): Message[] {\n\tif (Object.keys(maskedValues).length === 0) return messages;\n\treturn messages.map((m) => redactMessage(m, maskedValues));\n}\n\n/**\n * Extract the text content from a Message as a plain string.\n * For multi-part content, concatenates all text parts.\n */\nexport function extractTextContent(message: Message): string {\n\tconst content = message.content;\n\tif (typeof content === 'string') return content;\n\tif (Array.isArray(content)) {\n\t\treturn (content as ContentPart[])\n\t\t\t.filter((p): p is Extract<ContentPart, { type: 'text' }> => p.type === 'text')\n\t\t\t.map((p) => p.text)\n\t\t\t.join('\\n');\n\t}\n\treturn '';\n}\n\n/**\n * Truncate a string to maxLen characters, appending an ellipsis if truncated.\n */\nexport function truncate(text: string, maxLen: number): string {\n\tif (text.length <= maxLen) return text;\n\treturn `${text.slice(0, maxLen - 3)}...`;\n}\n"
  },
  {
    "path": "packages/core/src/agent/conversation.test.ts",
    "content": "import { test, expect, describe, beforeEach } from 'bun:test';\nimport { ConversationManager } from './conversation/service.js';\nimport type { ConversationManagerOptions } from './conversation/types.js';\nimport type { LanguageModel, InferenceOptions } from '../model/interface.js';\nimport type { InferenceResult } from '../model/types.js';\n\n// ── Helpers ──\n\nfunction createManager(\n\toverrides: Partial<ConversationManagerOptions> = {},\n): ConversationManager {\n\treturn new ConversationManager({\n\t\tcontextWindowSize: 10000,\n\t\tincludeLastScreenshot: true,\n\t\t...overrides,\n\t});\n}\n\nfunction createMockModel(summary = 'Summary of the conversation'): LanguageModel {\n\treturn {\n\t\tmodelId: 'test-model',\n\t\tprovider: 'custom',\n\t\tinvoke: async <T>(_options: InferenceOptions<T>): Promise<InferenceResult<T>> => {\n\t\t\treturn {\n\t\t\t\tparsed: { summary } as unknown as T,\n\t\t\t\tusage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },\n\t\t\t\tfinishReason: 'stop',\n\t\t\t};\n\t\t},\n\t};\n}\n\n// ── Tests ──\n\ndescribe('ConversationManager', () => {\n\tlet mm: ConversationManager;\n\n\tbeforeEach(() => {\n\t\tmm = createManager();\n\t});\n\n\tdescribe('system prompt', () => {\n\t\ttest('setInstructionBuilder stores the system prompt', () => {\n\t\t\tmm.setInstructionBuilder('You are a helpful assistant');\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages[0]).toEqual({\n\t\t\t\trole: 'system',\n\t\t\t\tcontent: 'You are a helpful assistant',\n\t\t\t});\n\t\t});\n\n\t\ttest('system prompt appears first in getMessages', () => {\n\t\t\tmm.setInstructionBuilder('System');\n\t\t\tmm.addStateMessage('State text', undefined, 1);\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages[0].role).toBe('system');\n\t\t\texpect(messages[1].role).toBe('user');\n\t\t});\n\n\t\ttest('changing system prompt replaces the previous one', () => {\n\t\t\tmm.setInstructionBuilder('First');\n\t\t\tmm.setInstructionBuilder('Second');\n\t\t\tconst messages = mm.getMessages();\n\t\t\tconst systemMessages = messages.filter((m) => m.role === 'system');\n\t\t\texpect(systemMessages).toHaveLength(1);\n\t\t\texpect(systemMessages[0].content).toBe('Second');\n\t\t});\n\t});\n\n\tdescribe('addStateMessage', () => {\n\t\ttest('adds a user message with state text', () => {\n\t\t\tmm.addStateMessage('Page state info', undefined, 1);\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages).toHaveLength(1);\n\t\t\texpect(messages[0].role).toBe('user');\n\t\t});\n\n\t\ttest('includes screenshot when provided and vision enabled', () => {\n\t\t\tmm.addStateMessage('State', 'base64screenshot', 1);\n\t\t\tconst messages = mm.getMessages();\n\t\t\tconst content = messages[0].content;\n\t\t\texpect(Array.isArray(content)).toBe(true);\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\texpect(content).toHaveLength(2);\n\t\t\t\texpect(content[0]).toEqual({ type: 'text', text: 'State' });\n\t\t\t\texpect(content[1]).toHaveProperty('type', 'image');\n\t\t\t}\n\t\t});\n\n\t\ttest('excludes screenshot when vision disabled', () => {\n\t\t\tconst noVision = createManager({ includeLastScreenshot: false });\n\t\t\tnoVision.addStateMessage('State', 'base64screenshot', 1);\n\t\t\tconst messages = noVision.getMessages();\n\t\t\tconst content = messages[0].content;\n\t\t\t// Content should be text-only array\n\t\t\texpect(Array.isArray(content)).toBe(true);\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\texpect(content).toHaveLength(1);\n\t\t\t\texpect(content[0]).toHaveProperty('type', 'text');\n\t\t\t}\n\t\t});\n\n\t\ttest('updates messageCount', () => {\n\t\t\texpect(mm.messageCount).toBe(0);\n\t\t\tmm.addStateMessage('State 1', undefined, 1);\n\t\t\texpect(mm.messageCount).toBe(1);\n\t\t\tmm.addStateMessage('State 2', undefined, 2);\n\t\t\texpect(mm.messageCount).toBe(2);\n\t\t});\n\t});\n\n\tdescribe('addAssistantMessage', () => {\n\t\ttest('adds an assistant role message', () => {\n\t\t\tmm.addAssistantMessage('Agent response', 1);\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages[0].role).toBe('assistant');\n\t\t\texpect(messages[0].content).toBe('Agent response');\n\t\t});\n\t});\n\n\tdescribe('addCommandResultMessage', () => {\n\t\ttest('adds a user role message for action results', () => {\n\t\t\tmm.addCommandResultMessage('click: success', 1);\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages[0].role).toBe('user');\n\t\t\texpect(messages[0].content).toBe('click: success');\n\t\t});\n\t});\n\n\tdescribe('getMessages ordering', () => {\n\t\ttest('returns messages in correct order', () => {\n\t\t\tmm.setInstructionBuilder('System prompt');\n\t\t\tmm.addStateMessage('State text', undefined, 1);\n\t\t\tmm.addAssistantMessage('Agent thought', 1);\n\t\t\tmm.addCommandResultMessage('Action result', 1);\n\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages).toHaveLength(4);\n\t\t\texpect(messages[0].role).toBe('system');\n\t\t\texpect(messages[1].role).toBe('user');\n\t\t\texpect(messages[2].role).toBe('assistant');\n\t\t\texpect(messages[3].role).toBe('user');\n\t\t});\n\t});\n\n\tdescribe('compaction - screenshot removal', () => {\n\t\ttest('removes old screenshots when over token budget, keeps last', () => {\n\t\t\t// 3 screenshots: each ~1000 tokens for image + ~2 for text = ~3006 total.\n\t\t\t// Budget of 1500: after removing 2 old screenshots (saving 2000),\n\t\t\t// total becomes ~1006 < 1500, so compact exits successfully.\n\t\t\tconst small = createManager({ contextWindowSize: 1500 });\n\t\t\tsmall.addStateMessage('State 1', 'screenshot1', 1);\n\t\t\tsmall.addStateMessage('State 2', 'screenshot2', 2);\n\t\t\tsmall.addStateMessage('State 3', 'screenshot3', 3);\n\n\t\t\tconst messages = small.getMessages();\n\t\t\t// After compaction, older screenshots should be removed\n\t\t\t// The last message should still have its image\n\t\t\tconst lastMessage = messages[messages.length - 1];\n\t\t\tconst lastContent = lastMessage.content;\n\t\t\texpect(Array.isArray(lastContent)).toBe(true);\n\t\t\tif (Array.isArray(lastContent)) {\n\t\t\t\tconst hasImage = lastContent.some(\n\t\t\t\t\t(p: any) => typeof p === 'object' && p.type === 'image',\n\t\t\t\t);\n\t\t\t\texpect(hasImage).toBe(true);\n\n\t\t\t\t// Older messages should have had their images removed\n\t\t\t\tconst firstMsg = messages[0];\n\t\t\t\tconst firstContent = firstMsg.content;\n\t\t\t\tif (Array.isArray(firstContent)) {\n\t\t\t\t\tconst firstHasImage = firstContent.some(\n\t\t\t\t\t\t(p: any) => typeof p === 'object' && p.type === 'image',\n\t\t\t\t\t);\n\t\t\t\t\texpect(firstHasImage).toBe(false);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe('compaction - token budget behavior', () => {\n\t\ttest('does not trigger compaction when under budget', () => {\n\t\t\t// Budget of 10000 means no compaction needed for a few messages\n\t\t\tconst large = createManager({ contextWindowSize: 10000, includeLastScreenshot: false });\n\t\t\tlarge.addStateMessage('Short state', undefined, 1);\n\t\t\tlarge.addAssistantMessage('Short response', 1);\n\n\t\t\tconst messages = large.getMessages();\n\t\t\t// No summaries should be present\n\t\t\tconst summaryMessages = messages.filter(\n\t\t\t\t(m) =>\n\t\t\t\t\ttypeof m.content === 'string' &&\n\t\t\t\t\tm.content.includes('omitted to save tokens'),\n\t\t\t);\n\t\t\texpect(summaryMessages).toHaveLength(0);\n\t\t});\n\n\t\ttest('estimateTotalTokens reflects actual message content', () => {\n\t\t\tconst mm2 = createManager({ contextWindowSize: 100000, includeLastScreenshot: false });\n\t\t\tmm2.addStateMessage('A'.repeat(400), undefined, 1); // ~100 tokens\n\t\t\tmm2.addStateMessage('B'.repeat(800), undefined, 2); // ~200 tokens\n\n\t\t\tconst total = mm2.estimateTotalTokens();\n\t\t\t// Total should be roughly 300 tokens for 1200 chars\n\t\t\texpect(total).toBeGreaterThanOrEqual(250);\n\t\t\texpect(total).toBeLessThanOrEqual(400);\n\t\t});\n\t});\n\n\tdescribe('token estimation', () => {\n\t\ttest('estimateTotalTokens includes system prompt', () => {\n\t\t\tmm.setInstructionBuilder('System prompt text');\n\t\t\tconst tokensWithSystem = mm.estimateTotalTokens();\n\t\t\texpect(tokensWithSystem).toBeGreaterThan(0);\n\t\t});\n\n\t\ttest('estimateTotalTokens grows with messages', () => {\n\t\t\tconst before = mm.estimateTotalTokens();\n\t\t\tmm.addStateMessage('Some state text', undefined, 1);\n\t\t\tconst after = mm.estimateTotalTokens();\n\t\t\texpect(after).toBeGreaterThan(before);\n\t\t});\n\n\t\ttest('estimateTotalTokens counts images as ~1000 tokens', () => {\n\t\t\tmm.addStateMessage('Text', 'screenshot', 1);\n\t\t\tconst tokens = mm.estimateTotalTokens();\n\t\t\t// Text ~4 chars = 1 token, plus ~1000 for image\n\t\t\texpect(tokens).toBeGreaterThanOrEqual(1000);\n\t\t});\n\t});\n\n\tdescribe('history items', () => {\n\t\ttest('records history for each added message', () => {\n\t\t\tmm.addStateMessage('State text', undefined, 1);\n\t\t\tmm.addAssistantMessage('Agent response', 1);\n\t\t\tmm.addCommandResultMessage('Result text', 1);\n\n\t\t\tconst items = mm.getConversationEntrys();\n\t\t\texpect(items).toHaveLength(3);\n\t\t\texpect(items[0].category).toBe('state');\n\t\t\texpect(items[1].category).toBe('assistant');\n\t\t\texpect(items[2].category).toBe('action_result');\n\t\t});\n\n\t\ttest('history items include step number', () => {\n\t\t\tmm.addStateMessage('State', undefined, 5);\n\t\t\tconst items = mm.getConversationEntrys();\n\t\t\texpect(items[0].step).toBe(5);\n\t\t});\n\n\t\ttest('history items include truncated summary', () => {\n\t\t\tconst longText = 'a'.repeat(200);\n\t\t\tmm.addStateMessage(longText, undefined, 1);\n\t\t\tconst items = mm.getConversationEntrys();\n\t\t\t// Summary should be truncated to 120 chars\n\t\t\texpect(items[0].summary.length).toBeLessThanOrEqual(123); // 120 + '...'\n\t\t});\n\n\t\ttest('history items track screenshot presence', () => {\n\t\t\tmm.addStateMessage('State', 'screenshot_data', 1);\n\t\t\tconst items = mm.getConversationEntrys();\n\t\t\texpect(items[0].hasScreenshot).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('agentHistoryDescription', () => {\n\t\ttest('returns \"(no history)\" when empty', () => {\n\t\t\texpect(mm.agentHistoryDescription()).toBe('(no history)');\n\t\t});\n\n\t\ttest('shows all steps when under stepLimitShown', () => {\n\t\t\tmm.addStateMessage('State 1', undefined, 1);\n\t\t\tmm.addAssistantMessage('Agent 1', 1);\n\t\t\tmm.addStateMessage('State 2', undefined, 2);\n\t\t\tmm.addAssistantMessage('Agent 2', 2);\n\n\t\t\tconst desc = mm.agentHistoryDescription(10);\n\t\t\texpect(desc).toContain('Step 1:');\n\t\t\texpect(desc).toContain('Step 2:');\n\t\t});\n\n\t\ttest('truncates with \"steps omitted\" when exceeding stepLimitShown', () => {\n\t\t\tfor (let i = 1; i <= 20; i++) {\n\t\t\t\tmm.addStateMessage(`State ${i}`, undefined, i);\n\t\t\t\tmm.addAssistantMessage(`Agent ${i}`, i);\n\t\t\t}\n\n\t\t\tconst desc = mm.agentHistoryDescription(4);\n\t\t\texpect(desc).toContain('steps omitted');\n\t\t\t// Should show first 2 and last 2 steps\n\t\t\texpect(desc).toContain('Step 1:');\n\t\t\texpect(desc).toContain('Step 2:');\n\t\t\texpect(desc).toContain('Step 19:');\n\t\t\texpect(desc).toContain('Step 20:');\n\t\t});\n\n\t\ttest('includes category prefixes in description', () => {\n\t\t\tmm.addStateMessage('Page loaded', undefined, 1);\n\t\t\tmm.addAssistantMessage('Clicking button', 1);\n\t\t\tmm.addCommandResultMessage('click: success', 1);\n\n\t\t\tconst desc = mm.agentHistoryDescription();\n\t\t\texpect(desc).toContain('State:');\n\t\t\texpect(desc).toContain('Agent:');\n\t\t\texpect(desc).toContain('Result:');\n\t\t});\n\t});\n\n\tdescribe('ephemeral messages', () => {\n\t\ttest('ephemeral message appears in first getMessages call', () => {\n\t\t\tmm.addEphemeralMessage('Temporary instruction');\n\t\t\tconst messages = mm.getMessages();\n\t\t\tconst found = messages.some(\n\t\t\t\t(m) => typeof m.content === 'string' && m.content === 'Temporary instruction',\n\t\t\t);\n\t\t\texpect(found).toBe(true);\n\t\t});\n\n\t\ttest('ephemeral message is removed after being consumed', () => {\n\t\t\tmm.addEphemeralMessage('Temp');\n\n\t\t\t// First call: message is present and gets marked as read\n\t\t\tconst first = mm.getMessages();\n\t\t\texpect(first.some((m) => typeof m.content === 'string' && m.content === 'Temp')).toBe(true);\n\n\t\t\t// Second call: message is still in result (removal happens after building result),\n\t\t\t// then gets removed during consumeEphemeralMessages\n\t\t\tconst second = mm.getMessages();\n\n\t\t\t// Third call: message is now actually gone from this.messages\n\t\t\tconst third = mm.getMessages();\n\t\t\tconst found = third.some(\n\t\t\t\t(m) => typeof m.content === 'string' && m.content === 'Temp',\n\t\t\t);\n\t\t\texpect(found).toBe(false);\n\t\t});\n\n\t\ttest('ephemeral message with assistant role', () => {\n\t\t\tmm.addEphemeralMessage('Agent thought', 'assistant');\n\t\t\tconst messages = mm.getMessages();\n\t\t\tconst found = messages.find(\n\t\t\t\t(m) => m.role === 'assistant' && m.content === 'Agent thought',\n\t\t\t);\n\t\t\texpect(found).toBeDefined();\n\t\t});\n\n\t\ttest('multiple ephemeral messages all appear then get cleaned up', () => {\n\t\t\tmm.addEphemeralMessage('Temp 1');\n\t\t\tmm.addEphemeralMessage('Temp 2');\n\n\t\t\t// First call: both present, marked as read\n\t\t\tconst first = mm.getMessages();\n\t\t\texpect(first).toHaveLength(2);\n\n\t\t\t// Second call: still in result (removal after build), then removed\n\t\t\tmm.getMessages();\n\n\t\t\t// Third call: messages have been removed\n\t\t\tconst third = mm.getMessages();\n\t\t\texpect(third).toHaveLength(0);\n\t\t});\n\t});\n\n\tdescribe('save / load round-trip', () => {\n\t\ttest('save and load preserves system prompt', () => {\n\t\t\tmm.setInstructionBuilder('My system prompt');\n\t\t\tmm.addStateMessage('State 1', undefined, 1);\n\n\t\t\tconst saved = mm.save();\n\t\t\tconst restored = createManager();\n\t\t\trestored.load(saved);\n\n\t\t\tconst messages = restored.getMessages();\n\t\t\texpect(messages[0].role).toBe('system');\n\t\t\texpect(messages[0].content).toBe('My system prompt');\n\t\t});\n\n\t\ttest('save and load preserves messages', () => {\n\t\t\tmm.addStateMessage('State 1', undefined, 1);\n\t\t\tmm.addAssistantMessage('Response 1', 1);\n\t\t\tmm.addCommandResultMessage('Result 1', 1);\n\n\t\t\tconst saved = mm.save();\n\t\t\tconst restored = createManager();\n\t\t\trestored.load(saved);\n\n\t\t\tconst messages = restored.getMessages();\n\t\t\texpect(messages).toHaveLength(3);\n\t\t\texpect(messages[0].role).toBe('user');\n\t\t\texpect(messages[1].role).toBe('assistant');\n\t\t\texpect(messages[2].role).toBe('user');\n\t\t});\n\n\t\ttest('save and load preserves history items', () => {\n\t\t\tmm.addStateMessage('State 1', undefined, 1);\n\t\t\tmm.addAssistantMessage('Response 1', 1);\n\n\t\t\tconst saved = mm.save();\n\t\t\tconst restored = createManager();\n\t\t\trestored.load(saved);\n\n\t\t\tconst items = restored.getConversationEntrys();\n\t\t\texpect(items).toHaveLength(2);\n\t\t\texpect(items[0].category).toBe('state');\n\t\t\texpect(items[1].category).toBe('assistant');\n\t\t});\n\n\t\ttest('save and load preserves currentStep', () => {\n\t\t\tmm.addStateMessage('State', undefined, 7);\n\t\t\tconst saved = mm.save();\n\t\t\texpect(saved.currentStep).toBe(7);\n\n\t\t\tconst restored = createManager();\n\t\t\trestored.load(saved);\n\t\t\texpect(restored.step).toBe(7);\n\t\t});\n\n\t\ttest('save strips screenshots (text only in serialized form)', () => {\n\t\t\tmm.addStateMessage('State with screenshot', 'base64data', 1);\n\t\t\tconst saved = mm.save();\n\t\t\t// Serialized content should be text-only, no base64\n\t\t\tfor (const msg of saved.messages) {\n\t\t\t\texpect(msg.content).not.toContain('base64data');\n\t\t\t}\n\t\t});\n\n\t\ttest('load with null system prompt clears system prompt', () => {\n\t\t\tmm.setInstructionBuilder('Initial prompt');\n\t\t\tconst saved = mm.save();\n\t\t\tsaved.systemPrompt = null;\n\n\t\t\tmm.load(saved);\n\t\t\tconst messages = mm.getMessages();\n\t\t\tconst hasSystem = messages.some((m) => m.role === 'system');\n\t\t\texpect(hasSystem).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('sensitive data filtering', () => {\n\t\ttest('masks sensitive values in outgoing messages', () => {\n\t\t\tconst sensitive = createManager({\n\t\t\t\tmaskedValues: { password: 'secret123', apiKey: 'key-abc' },\n\t\t\t});\n\t\t\tsensitive.addStateMessage('Login with password secret123', undefined, 1);\n\t\t\tsensitive.addAssistantMessage('Using key-abc to authenticate', 1);\n\n\t\t\tconst messages = sensitive.getMessages();\n\n\t\t\t// Text should have been masked\n\t\t\tconst stateMsg = messages[0];\n\t\t\tif (typeof stateMsg.content === 'string') {\n\t\t\t\texpect(stateMsg.content).not.toContain('secret123');\n\t\t\t\texpect(stateMsg.content).toContain('***');\n\t\t\t} else if (Array.isArray(stateMsg.content)) {\n\t\t\t\tconst textPart = stateMsg.content.find((p: any) => p.type === 'text');\n\t\t\t\texpect((textPart as any).text).not.toContain('secret123');\n\t\t\t}\n\n\t\t\tconst assistantMsg = messages[1];\n\t\t\tif (typeof assistantMsg.content === 'string') {\n\t\t\t\texpect(assistantMsg.content).not.toContain('key-abc');\n\t\t\t\texpect(assistantMsg.content).toContain('***');\n\t\t\t}\n\t\t});\n\n\t\ttest('no filtering when maskedValues is empty', () => {\n\t\t\tconst noSensitive = createManager({ maskedValues: {} });\n\t\t\tnoSensitive.addStateMessage('Plain text with secret123', undefined, 1);\n\t\t\tconst messages = noSensitive.getMessages();\n\n\t\t\tconst content = messages[0].content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\tconst textPart = content.find((p: any) => p.type === 'text');\n\t\t\t\texpect((textPart as any).text).toContain('secret123');\n\t\t\t}\n\t\t});\n\n\t\ttest('no filtering when maskedValues is not set', () => {\n\t\t\tmm.addStateMessage('Text with sensitive data', undefined, 1);\n\t\t\tconst messages = mm.getMessages();\n\t\t\tconst content = messages[0].content;\n\t\t\tif (Array.isArray(content)) {\n\t\t\t\tconst textPart = content.find((p: any) => p.type === 'text');\n\t\t\t\texpect((textPart as any).text).toContain('sensitive data');\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe('LLM-based compaction', () => {\n\t\ttest('shouldCompactWithLlm returns false when no compaction config', () => {\n\t\t\texpect(mm.shouldCompactWithLlm()).toBe(false);\n\t\t});\n\n\t\ttest('shouldCompactWithLlm returns false when interval not reached', () => {\n\t\t\tconst withCompaction = createManager({\n\t\t\t\tcompaction: { interval: 10, maxTokens: 500 },\n\t\t\t});\n\t\t\t// Only 1 message, interval not reached\n\t\t\twithCompaction.addStateMessage('State', undefined, 1);\n\t\t\texpect(withCompaction.shouldCompactWithLlm()).toBe(false);\n\t\t});\n\n\t\ttest('compactWithLlm returns false without a model', async () => {\n\t\t\tconst withCompaction = createManager({\n\t\t\t\tcontextWindowSize: 100000,\n\t\t\t\tincludeLastScreenshot: false,\n\t\t\t\tcompaction: { interval: 1, maxTokens: 500, targetTokens: 10 },\n\t\t\t});\n\t\t\t// Add enough messages so estimateTotalTokens > targetTokens (10)\n\t\t\tfor (let i = 1; i <= 5; i++) {\n\t\t\t\twithCompaction.addStateMessage('x'.repeat(100), undefined, i);\n\t\t\t}\n\t\t\tconst result = await withCompaction.compactWithLlm();\n\t\t\texpect(result).toBe(false);\n\t\t});\n\n\t\ttest('compactWithLlm performs compaction with model', async () => {\n\t\t\tconst model = createMockModel('Summarized: visited pages and clicked buttons');\n\t\t\t// Use large contextWindowSize so getMessages() doesn't trigger basic compact(),\n\t\t\t// but low targetTokens so the LLM compaction decides to run.\n\t\t\tconst longText = 'A'.repeat(500);\n\t\t\tconst withCompaction = createManager({\n\t\t\t\tcontextWindowSize: 100000,\n\t\t\t\tincludeLastScreenshot: false,\n\t\t\t\tcompaction: { interval: 1, maxTokens: 500, targetTokens: 500 },\n\t\t\t});\n\n\t\t\t// Add lots of messages to exceed targetTokens (500).\n\t\t\t// Each 500-char message = ~125 tokens. 10 messages = ~1250 tokens > 500.\n\t\t\tfor (let i = 1; i <= 10; i++) {\n\t\t\t\twithCompaction.addStateMessage(`${longText} step ${i}`, undefined, i);\n\t\t\t\twithCompaction.addAssistantMessage(`${longText} response ${i}`, i);\n\t\t\t}\n\n\t\t\tconst result = await withCompaction.compactWithLlm(model);\n\t\t\texpect(result).toBe(true);\n\n\t\t\t// After compaction, message count should be reduced\n\t\t\tconst messages = withCompaction.getMessages();\n\t\t\texpect(messages.length).toBeLessThan(20);\n\n\t\t\t// First message should be the summary\n\t\t\tconst firstContent = messages[0].content;\n\t\t\texpect(typeof firstContent).toBe('string');\n\t\t\texpect(firstContent as string).toContain('Conversation summary');\n\t\t});\n\t});\n\n\tdescribe('clear and resetMessages', () => {\n\t\ttest('clear removes all messages and history', () => {\n\t\t\tmm.setInstructionBuilder('System');\n\t\t\tmm.addStateMessage('State', undefined, 1);\n\t\t\tmm.addAssistantMessage('Response', 1);\n\n\t\t\tmm.clear();\n\n\t\t\texpect(mm.messageCount).toBe(1); // system prompt still present via setInstructionBuilder\n\t\t\texpect(mm.getConversationEntrys()).toHaveLength(0);\n\t\t\texpect(mm.step).toBe(0);\n\t\t});\n\n\t\ttest('resetMessages removes messages but preserves history', () => {\n\t\t\tmm.addStateMessage('State', undefined, 1);\n\t\t\tmm.addAssistantMessage('Response', 1);\n\n\t\t\tconst historyBefore = mm.getConversationEntrys().length;\n\t\t\tmm.resetMessages();\n\n\t\t\t// Messages cleared\n\t\t\tconst messages = mm.getMessages();\n\t\t\texpect(messages).toHaveLength(0);\n\n\t\t\t// History preserved\n\t\t\texpect(mm.getConversationEntrys()).toHaveLength(historyBefore);\n\t\t});\n\t});\n\n\tdescribe('messageCount', () => {\n\t\ttest('includes system prompt in count', () => {\n\t\t\tmm.setInstructionBuilder('System');\n\t\t\texpect(mm.messageCount).toBe(1);\n\n\t\t\tmm.addStateMessage('State', undefined, 1);\n\t\t\texpect(mm.messageCount).toBe(2);\n\t\t});\n\n\t\ttest('does not count system prompt when not set', () => {\n\t\t\texpect(mm.messageCount).toBe(0);\n\t\t\tmm.addStateMessage('State', undefined, 1);\n\t\t\texpect(mm.messageCount).toBe(1);\n\t\t});\n\t});\n\n\tdescribe('step tracking', () => {\n\t\ttest('step reflects the most recent step from added messages', () => {\n\t\t\tmm.addStateMessage('State 1', undefined, 1);\n\t\t\texpect(mm.step).toBe(1);\n\n\t\t\tmm.addStateMessage('State 5', undefined, 5);\n\t\t\texpect(mm.step).toBe(5);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/agent/evaluator.ts",
    "content": "import type { LanguageModel } from '../model/interface.js';\nimport type { Message, ContentPart } from '../model/messages.js';\nimport { systemMessage, userMessage, imageContent, textContent } from '../model/messages.js';\nimport {\n\tEvaluationResultSchema,\n\tQuickCheckResultSchema,\n\ttype EvaluationResult,\n\ttype QuickCheckResult,\n\ttype StepRecord,\n} from './types.js';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('judge');\n\n// ── Judge System Prompts ──\n\nconst JUDGE_SYSTEM_PROMPT = `You are an expert task completion judge. Your job is to evaluate whether a web browser automation agent completed its assigned task successfully.\n\nYou will be provided with:\n1. The task description\n2. A history of steps the agent took (including actions and their results)\n3. Screenshots from during execution (if available)\n4. Optionally, ground truth information about the expected result\n\nEvaluate thoroughly:\n- Did the agent actually complete the task, or just claim to?\n- Is the extracted information correct and complete?\n- Did the agent handle errors and edge cases appropriately?\n- Was the agent stuck at any point without recovery?\n\nIf ground truth is provided, compare the agent's result against it.\n\nBe strict but fair. Partial completions should be marked with lower confidence.`;\n\nconst SIMPLE_JUDGE_SYSTEM_PROMPT = `You are a quick-check validator for web browser automation results.\nGiven a task and the agent's final result, determine if the result appears correct.\nBe concise. Focus on whether the result directly answers/completes the task.`;\n\nexport class ResultEvaluator {\n\tprivate model: LanguageModel;\n\n\tconstructor(model: LanguageModel) {\n\t\tthis.model = model;\n\t}\n\n\t/**\n\t * Full evaluation with step history, screenshots, and optional ground truth.\n\t * Provides detailed verdict with failure analysis.\n\t */\n\tasync evaluate(\n\t\ttask: string,\n\t\tresult: string,\n\t\thistory: StepRecord[],\n\t\toptions?: {\n\t\t\texpectedOutcome?: string;\n\t\t\tincludeScreenshots?: boolean;\n\t\t},\n\t): Promise<EvaluationResult> {\n\t\tconst messages = constructEvaluatorMessages(task, result, history, options);\n\n\t\ttry {\n\t\t\tconst completion = await this.model.invoke({\n\t\t\t\tmessages,\n\t\t\t\tresponseSchema: EvaluationResultSchema,\n\t\t\t\tschemaName: 'EvaluationResult',\n\t\t\t\ttemperature: 0,\n\t\t\t});\n\n\t\t\tlogger.info(\n\t\t\t\t`Judge verdict: complete=${completion.parsed.isComplete}, ` +\n\t\t\t\t`confidence=${completion.parsed.confidence}, ` +\n\t\t\t\t`verdict=${completion.parsed.verdict ?? 'n/a'}`,\n\t\t\t);\n\n\t\t\treturn completion.parsed;\n\t\t} catch (error) {\n\t\t\tlogger.error('Judge evaluation failed', error);\n\t\t\treturn {\n\t\t\t\tisComplete: false,\n\t\t\t\treason: `Judge evaluation failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\tconfidence: 0,\n\t\t\t\tverdict: 'unknown',\n\t\t\t};\n\t\t}\n\t}\n\n\t/**\n\t * Lightweight always-on validation.\n\t * Quick pass/fail check without detailed history analysis.\n\t * Useful for running after every \"done\" action to catch obvious errors.\n\t */\n\tasync simpleEvaluate(\n\t\ttask: string,\n\t\tresult: string,\n\t): Promise<QuickCheckResult> {\n\t\tconst messages = constructQuickCheckMessages(task, result);\n\n\t\ttry {\n\t\t\tconst completion = await this.model.invoke({\n\t\t\t\tmessages,\n\t\t\t\tresponseSchema: QuickCheckResultSchema,\n\t\t\t\tschemaName: 'QuickCheckResult',\n\t\t\t\ttemperature: 0,\n\t\t\t});\n\n\t\t\tlogger.debug(\n\t\t\t\t`Simple judge: passed=${completion.parsed.passed}, reason=${completion.parsed.reason}`,\n\t\t\t);\n\n\t\t\treturn completion.parsed;\n\t\t} catch (error) {\n\t\t\tlogger.error('Simple judge evaluation failed', error);\n\t\t\treturn {\n\t\t\t\tpassed: true, // Default to pass on error to avoid blocking\n\t\t\t\treason: `Simple judge failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\tshouldRetry: false,\n\t\t\t};\n\t\t}\n\t}\n}\n\n// ── Message Construction ──\n\n/**\n * Build the full message array for detailed judge evaluation.\n * Includes step-by-step history, screenshots (if enabled), and ground truth.\n */\nexport function constructEvaluatorMessages(\n\ttask: string,\n\tresult: string,\n\thistory: StepRecord[],\n\toptions?: {\n\t\texpectedOutcome?: string;\n\t\tincludeScreenshots?: boolean;\n\t},\n): Message[] {\n\tconst messages: Message[] = [\n\t\tsystemMessage(JUDGE_SYSTEM_PROMPT),\n\t];\n\n\t// Build the evaluation prompt\n\tconst parts: string[] = [];\n\tparts.push(`## Task\\n${task}`);\n\tparts.push(`## Agent's Final Result\\n${result}`);\n\n\t// Step history summary\n\tif (history.length > 0) {\n\t\tconst stepSummaries: string[] = [];\n\t\tfor (const entry of history) {\n\t\t\tconst actions = entry.agentOutput.actions\n\t\t\t\t.map((a) => {\n\t\t\t\t\tconst actionObj = a as Record<string, unknown>;\n\t\t\t\t\treturn actionObj.action ?? 'unknown';\n\t\t\t\t})\n\t\t\t\t.join(', ');\n\n\t\t\tconst results = entry.actionResults\n\t\t\t\t.map((r) => {\n\t\t\t\t\tif (r.isDone) return `DONE: ${r.extractedContent?.slice(0, 200) ?? ''}`;\n\t\t\t\t\tif (r.error) return `ERROR: ${r.error.slice(0, 150)}`;\n\t\t\t\t\tif (r.extractedContent) return `OK: ${r.extractedContent.slice(0, 150)}`;\n\t\t\t\t\treturn r.success ? 'OK' : 'FAILED';\n\t\t\t\t})\n\t\t\t\t.join('; ');\n\n\t\t\tconst evaluation = entry.agentOutput.currentState?.evaluation ?? '';\n\t\t\tstepSummaries.push(\n\t\t\t\t`Step ${entry.step} [${entry.browserState.url}]:\\n` +\n\t\t\t\t`  Eval: ${evaluation.slice(0, 200)}\\n` +\n\t\t\t\t`  Actions: ${actions}\\n` +\n\t\t\t\t`  Results: ${results}`,\n\t\t\t);\n\t\t}\n\n\t\tparts.push(`## Step History (${history.length} steps)\\n${stepSummaries.join('\\n\\n')}`);\n\t}\n\n\t// Ground truth\n\tif (options?.expectedOutcome) {\n\t\tparts.push(\n\t\t\t`## Ground Truth (Expected Result)\\n${options.expectedOutcome}\\n\\n` +\n\t\t\t'Compare the agent\\'s result against this ground truth carefully.',\n\t\t);\n\t}\n\n\tparts.push(\n\t\t'## Instructions\\n' +\n\t\t'Evaluate the task completion. Provide:\\n' +\n\t\t'- isComplete: whether the task was fully completed\\n' +\n\t\t'- reason: detailed explanation\\n' +\n\t\t'- confidence: 0-1 score\\n' +\n\t\t'- verdict: \"success\", \"partial\", \"failed\", or \"unknown\"\\n' +\n\t\t'- failureReason: if failed, explain why\\n' +\n\t\t'- impossibleTask: true if the task appears impossible\\n' +\n\t\t'- reachedCaptcha: true if a CAPTCHA blocked progress',\n\t);\n\n\t// If screenshots are requested and available, include the last few\n\tif (options?.includeScreenshots) {\n\t\tconst screenshotEntries = history\n\t\t\t.filter((e) => e.browserState.screenshot)\n\t\t\t.slice(-3); // Last 3 screenshots\n\n\t\tif (screenshotEntries.length > 0) {\n\t\t\tconst content: ContentPart[] = [\n\t\t\t\ttextContent(`${parts.join('\\n\\n')}\\n\\nBelow are screenshots from the agent's execution:`),\n\t\t\t];\n\n\t\t\tfor (const entry of screenshotEntries) {\n\t\t\t\tif (entry.browserState.screenshot) {\n\t\t\t\t\tcontent.push(\n\t\t\t\t\t\ttextContent(`Screenshot from step ${entry.step} (${entry.browserState.url}):`),\n\t\t\t\t\t);\n\t\t\t\t\tcontent.push(imageContent(entry.browserState.screenshot));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tmessages.push(userMessage(content));\n\t\t\treturn messages;\n\t\t}\n\t}\n\n\tmessages.push(userMessage(parts.join('\\n\\n')));\n\treturn messages;\n}\n\n/**\n * Build messages for lightweight simple judge evaluation.\n * Only includes task and result -- no history or screenshots.\n */\nexport function constructQuickCheckMessages(\n\ttask: string,\n\tresult: string,\n): Message[] {\n\treturn [\n\t\tsystemMessage(SIMPLE_JUDGE_SYSTEM_PROMPT),\n\t\tuserMessage(\n\t\t\t`Task: ${task}\\n\\n` +\n\t\t\t`Agent's Result: ${result}\\n\\n` +\n\t\t\t'Does this result correctly complete the task? ' +\n\t\t\t'If not, should the agent retry with a different approach?',\n\t\t),\n\t];\n}\n"
  },
  {
    "path": "packages/core/src/agent/index.ts",
    "content": "export { Agent, type AgentOptions } from '../agent/agent.js';\nexport {\n\tInstructionBuilder,\n\tStepPromptBuilder,\n\tbuildCommandDescriptions,\n\tbuildContextualCommands,\n\tbuildExtractionInstructionBuilder,\n\tbuildExtractionUserPrompt,\n\tclearTemplateCache,\n\ttype PromptTemplate,\n\ttype InstructionBuilderOptions,\n\ttype StepInfo,\n\ttype StepPromptBuilderOptions,\n} from './instructions.js';\nexport { ConversationManager } from './conversation/service.js';\nexport {\n\tStallDetector,\n\thashPageTree,\n\thashTextContent,\n\ttype PageSignature,\n\ttype StallDetectorConfig,\n\ttype StallCheckResult,\n} from './stall-detector.js';\nexport {\n\tResultEvaluator,\n\tconstructEvaluatorMessages,\n\tconstructQuickCheckMessages,\n} from './evaluator.js';\nexport { ReplayRecorder, type ReplayRecorderOptions } from './replay-recorder.js';\nexport {\n\ttype AgentConfig,\n\ttype AgentState,\n\ttype AgentDecision,\n\ttype AgentDecisionCompact,\n\ttype AgentDecisionDirect,\n\ttype StepRecord,\n\tExecutionLog,\n\ttype RunOutcome,\n\ttype Reasoning,\n\ttype PlanStep,\n\ttype EvaluationResult,\n\ttype QuickCheckResult,\n\ttype CompactionPolicy,\n\ttype StepTelemetry,\n\ttype ExtractedVariable,\n\ttype AccumulatedCost,\n\ttype StepCostBreakdown,\n\ttype PricingTable,\n\ttype PlanRevision,\n\tAgentDecisionSchema,\n\tAgentDecisionCompactSchema,\n\tAgentDecisionDirectSchema,\n\tReasoningSchema,\n\tEvaluationResultSchema,\n\tQuickCheckResultSchema,\n\tPlanStepSchema,\n\tStrategyPlanSchema,\n\tPlanRevisionSchema,\n\tPRICING_TABLE,\n\tcalculateStepCost,\n\tsupportsDeepReasoning,\n\tsupportsCoordinateMode,\n\tisCompactModel,\n\tDEFAULT_AGENT_CONFIG,\n} from './types.js';\nexport type {\n\tConversationManagerOptions,\n\tTrackedMessage,\n\tConversationManagerState,\n\tConversationEntry,\n\tSerializedTrackedMessage,\n\tMessageCategory,\n} from './conversation/types.js';\nexport {\n\testimateTokens,\n\testimateMessageTokens,\n\tredactSensitiveValues,\n\tredactMessage,\n\tredactMessages,\n\textractTextContent,\n\ttruncate,\n} from './conversation/utils.js';\n"
  },
  {
    "path": "packages/core/src/agent/instructions/instructions-compact.md",
    "content": "You are an AI agent that controls a web browser to complete tasks. You operate in an iterative loop: observe, decide, act, repeat.\n\nYour task: {{task}}\n\n<language_settings>Default: English. Match the task's language.</language_settings>\n\n<browser_state>\nElements: `[index]<type>text</type>`. Only `[indexed]` elements are interactive. Indentation = child. `*[` = new element.\n</browser_state>\n\n<rules>\n- Only interact with elements that have a numeric [index]\n- If research is needed, open a **new tab** instead of reusing the current one\n- If the page changes after an input action, analyze new elements (e.g., suggestions) before proceeding\n- If an action sequence was interrupted, complete remaining actions in the next step\n- For autocomplete fields: type text, WAIT for suggestions, click the correct one or press Enter\n- Handle popups/modals/cookie banners immediately before other actions\n- If blocked by captcha/login/403, try alternative approaches rather than retrying\n- ALWAYS look for filter/sort options FIRST when the task specifies criteria\n- Detect unproductive loops: if same URL for 3+ steps without progress, change approach\n</rules>\n\n<action_rules>\nMaximum {{maxActionsPerStep}} actions per step. If the page changes after an action, remaining actions are skipped.\nCheck browser state each step to verify your previous action succeeded.\nWhen chaining actions, never take consequential actions (form submissions, critical button clicks) without confirming changes occurred.\n</action_rules>\n\n<available_actions>\n{{actionDescriptions}}\n</available_actions>\n\n<efficiency>\nCombine actions when sensible. Do not predict actions that do not apply to the current page.\n**Recommended combinations:**\n- `input_text` + `click` -> Fill field and submit\n- `input_text` + `input_text` -> Fill multiple fields\n- `click` + `click` -> Multi-step flows (when page does not navigate between clicks)\n\nDo not chain actions that change browser state multiple times (e.g., click then navigate). Always have one clear goal per step.\n</efficiency>\n\n<output>\nRespond with valid JSON:\n```json\n{\n  \"currentState\": {\n    \"evaluation\": \"One-sentence analysis of last action. State success, failure, or uncertain.\",\n    \"memory\": \"1-3 sentences: progress tracking, data found, approaches tried.\",\n    \"nextGoal\": \"Next immediate goal in one clear sentence.\"\n  },\n  \"actions\": [{\"action_name\": {\"param\": \"value\"}}]\n}\n```\nAction list should NEVER be empty.\n</output>\n\n<task_completion>\nCall `done` when:\n- Task is fully completed\n- Reached max steps (even if incomplete)\n- Absolutely impossible to continue\n\nSet `success=true` ONLY if the full task is completed. Put ALL findings in the `text` field.\nBefore calling done with success=true: re-read the task, verify every requirement is met, confirm actions completed via page state, ensure no data was fabricated.\n</task_completion>\n\n<error_recovery>\n1. Verify state using screenshot as ground truth\n2. Handle blocking popups/overlays first\n3. If element not found, scroll to reveal more content\n4. If action fails 2-3 times, try alternative approach\n5. If blocked by login/captcha/403, try alternative sites\n6. If stuck in a loop, acknowledge and change strategy\n</error_recovery>\n"
  },
  {
    "path": "packages/core/src/agent/instructions/instructions-direct.md",
    "content": "You are an AI agent that controls a web browser to complete tasks. You operate in an iterative loop: observe the current page state, decide on actions, execute them, and repeat until the task is done.\n\nYour task: {{task}}\n\n<capabilities>\nYou excel at:\n1. Navigating complex websites and extracting precise information\n2. Automating form submissions and interactive web actions\n3. Gathering and organizing information across multiple pages\n4. Operating effectively in an iterative agent loop\n5. Adapting strategies when encountering obstacles\n</capabilities>\n\n<language_settings>\n- Default working language: **English**\n- Always respond in the same language as the task description\n</language_settings>\n\n<input>\nAt every step, your input will consist of:\n1. **Agent history**: A chronological event stream including your previous actions and their results.\n2. **Browser state**: Current URL, open tabs, interactive elements indexed for actions, and visible page content.\n3. **Screenshot** (when vision is enabled): A screenshot of the current page with bounding boxes around interactive elements.\n</input>\n\n<browser_state>\nBrowser state is given as:\n- **Current URL**: The URL of the page you are currently viewing.\n- **Open Tabs**: Open tabs with their IDs.\n- **Interactive Elements**: All interactive elements in the format `[index]<type>text</type>` where:\n  - `index`: Numeric identifier for interaction\n  - `type`: HTML element type (button, input, etc.)\n  - `text`: Element description\n\nImportant notes:\n- Only elements with numeric indexes in `[]` are interactive\n- Indentation (with tab) means the element is a child of the element above\n- Elements tagged with `*[` are **new** interactive elements that appeared since the last step\n- Pure text elements without `[]` are not interactive\n</browser_state>\n\n<screenshot>\nIf vision is enabled, you will receive a screenshot of the current page with bounding boxes around interactive elements.\n- This is your **ground truth**: use it to evaluate your progress\n- If an interactive element has no text in browser_state, its index is at the top center of its bounding box\n</screenshot>\n\n<rules>\nStrictly follow these rules while using the browser:\n- Only interact with elements that have a numeric `[index]`\n- Only use indexes that are explicitly provided\n- If research is needed, open a **new tab** instead of reusing the current one\n- If the page changes after an action, analyze new elements before proceeding\n- By default, only elements in the visible viewport are listed\n- If the page is not fully loaded, use the wait action\n- Use extract_content only if information is NOT visible in browser_state\n- extract_content is expensive - do NOT call it multiple times on the same page\n- If you fill an input field and your action sequence is interrupted, something changed (e.g., suggestions appeared)\n- Complete any remaining actions from interrupted sequences in the next step\n- For autocomplete fields: type text, WAIT for suggestions, click the correct one or press Enter\n- If the task specifies criteria (price, rating, location, etc.), look for filter/sort options FIRST\n- Handle popups, modals, cookie banners immediately before other actions\n- If blocked by captcha/login/403, try alternative approaches\n- Detect loops: if same URL for 3+ steps without progress, change approach\n- Do not log in unless the task requires it and you have credentials\n</rules>\n\n<output_format>\n## Output Format\nRespond with:\n1. **currentState**: Your assessment including:\n   - `evaluation`: Assessment of how the last action went\n   - `memory`: Important information to remember\n   - `nextGoal`: The next immediate goal\n2. **actions**: A list of actions to execute (max {{maxActionsPerStep}} per step)\n</output_format>\n\n<action_rules>\nMaximum {{maxActionsPerStep}} actions per step, executed sequentially.\n- If the page changes after an action, remaining actions are skipped and you get the new state.\n- Check browser state each step to verify your previous action achieved its goal.\n- When chaining actions, never take consequential actions without confirming changes occurred.\n</action_rules>\n\n<available_actions>\n{{actionDescriptions}}\n</available_actions>\n\n<efficiency>\nCombine actions when sensible. Do not predict actions that do not apply to the current page.\n\n**Recommended combinations:**\n- `input_text` + `input_text` + `click` -> Fill multiple fields then submit\n- `input_text` + `send_keys` -> Fill a field and press Enter\n- `scroll` + `scroll` -> Scroll further down\n\nDo not try multiple paths in one step. Have one clear goal per step.\nPlace page-changing actions **last** in your action list.\n</efficiency>\n\n<reasoning>\nBe clear and concise in your decision-making:\n1. Analyze the last action result - state success, failure, or uncertain\n2. Analyze browser state and screenshot to understand current position\n3. If stuck, consider alternative approaches\n4. Store concise, actionable context in memory\n5. State your next immediate goal clearly\n</reasoning>\n\n<task_completion>\nCall `done` when:\n- Task is fully completed\n- Reached max steps (even if incomplete)\n- Absolutely impossible to continue\n\nRules:\n- Set `success=true` ONLY if the full task is completed\n- Put ALL relevant findings in the `text` field\n- Call `done` as a single action - never combine with other actions\n\n**Before calling done with success=true, verify:**\n1. Re-read the original task and check every requirement\n2. Verify correct count, filters, format\n3. Confirm actions completed via page state/screenshot\n4. Ensure no fabricated data\n5. If anything is unmet or uncertain, set success to false\n</task_completion>\n\n<error_recovery>\nWhen encountering errors:\n1. Verify state using screenshot as ground truth\n2. Check for blocking popups/overlays\n3. If element not found, scroll to reveal content\n4. If action fails 2-3 times, try alternative approach\n5. If blocked by login/captcha/403, try alternative sites\n6. If page structure differs from expected, re-analyze and adapt\n7. If stuck in loop, acknowledge in memory and change strategy\n8. If max_steps approaching, prioritize most important parts\n</error_recovery>\n\n<examples>\n**Good evaluation examples:**\n- \"Successfully navigated to the product page and found the target information. Verdict: Success\"\n- \"Failed to input text into the search bar - element not visible. Verdict: Failure\"\n\n**Good memory examples:**\n- \"Visited 2 of 5 target websites. Collected pricing from Amazon ($39.99) and eBay ($42.00). Still need Walmart, Target, Best Buy.\"\n- \"Search returned results but no filter applied. User wants items under $50 with 4+ stars. Will apply price filter first.\"\n\n**Good next goal examples:**\n- \"Click 'Add to Cart' to proceed with purchase flow.\"\n- \"Apply price filter to narrow results to items under $50.\"\n</examples>\n\n<critical_reminders>\n1. ALWAYS verify action success using screenshot/browser state\n2. ALWAYS handle popups/modals before other actions\n3. ALWAYS apply filters when task specifies criteria\n4. NEVER repeat failing actions more than 2-3 times\n5. NEVER assume success without verification\n6. Track progress in memory to avoid loops\n7. Match requested output format exactly\n8. Be efficient - combine actions when possible\n</critical_reminders>\n"
  },
  {
    "path": "packages/core/src/agent/instructions/instructions.md",
    "content": "You are an AI agent that controls a web browser to complete tasks. You operate in an iterative loop: observe the current page state, decide on actions, execute them, and repeat until the task is done.\n\nYour task: {{task}}\n\n<capabilities>\nYou excel at:\n1. Navigating complex websites and extracting precise information\n2. Automating form submissions and interactive web actions\n3. Gathering and organizing information across multiple pages\n4. Operating effectively in an iterative agent loop\n5. Adapting strategies when encountering obstacles\n</capabilities>\n\n<language_settings>\n- Default working language: **English**\n- Always respond in the same language as the task description\n</language_settings>\n\n<input>\nAt every step, your input will consist of:\n1. **Agent history**: A chronological event stream including your previous actions and their results.\n2. **Browser state**: Current URL, open tabs, interactive elements indexed for actions, and visible page content.\n3. **Screenshot** (when vision is enabled): A screenshot of the current page with bounding boxes around interactive elements.\n</input>\n\n<browser_state>\nBrowser state is given as:\n- **Current URL**: The URL of the page you are currently viewing.\n- **Open Tabs**: Open tabs with their IDs.\n- **Interactive Elements**: All interactive elements in the format `[index]<type>text</type>` where:\n  - `index`: Numeric identifier for interaction\n  - `type`: HTML element type (button, input, etc.)\n  - `text`: Element description\n\nExamples:\n```\n[33]<div>User form</div>\n\t*[35]<button aria-label='Submit form'>Submit</button>\n```\n\nImportant notes:\n- Only elements with numeric indexes in `[]` are interactive\n- Indentation (with tab) means the element is a child of the element above\n- Elements tagged with `*[` are **new** interactive elements that appeared since the last step. Your previous actions caused that change. Consider if you need to interact with them.\n- Pure text elements without `[]` are not interactive\n</browser_state>\n\n<screenshot>\nIf vision is enabled, you will receive a screenshot of the current page with bounding boxes around interactive elements.\n- This is your **ground truth**: use it to evaluate your progress\n- If an interactive element has no text in browser_state, its index is written at the top center of its bounding box in the screenshot\n- Use the screenshot action if you need more visual information\n</screenshot>\n\n<rules>\nStrictly follow these rules while using the browser:\n\n**Element Interaction:**\n- Only interact with elements that have a numeric `[index]` assigned\n- Only use indexes that are explicitly provided in the current browser state\n- If a page changes after an action (e.g., input text triggers suggestions), analyze new elements before proceeding\n\n**Navigation:**\n- If research is needed, open a **new tab** instead of reusing the current one\n- By default, only elements in the visible viewport are listed\n- If the page is not fully loaded, use the wait action\n\n**Content Extraction:**\n- Use extract_content on specific pages to gather structured information from the entire page, including parts not currently visible\n- Only call extract_content if the information is NOT already visible in browser_state - prefer using text directly from browser_state\n- extract_content is expensive - do NOT call it multiple times with the same query on the same page\n\n**Input Handling:**\n- If you fill an input field and your action sequence is interrupted, something likely changed (e.g., suggestions appeared)\n- If the action sequence was interrupted in a previous step, complete any remaining actions that were not executed\n- For autocomplete/combobox fields: type your text, then WAIT for suggestions in the next step. If suggestions appear (marked with `*[`), click the correct one. If none appear, press Enter.\n- After input, you may need to press Enter, click a search button, or select from a dropdown\n\n**Filters and Criteria:**\n- If the task includes specific criteria (product type, rating, price, location, etc.), ALWAYS look for filter/sort options FIRST before browsing results\n\n**Error Recovery:**\n- If a captcha appears, attempt solving it. If blocked after 3-4 steps, try alternative approaches or report the limitation\n- Handle popups, modals, cookie banners, and overlays immediately before other actions\n- If you encounter access denied (403), bot detection, or rate limiting, do NOT retry the same URL repeatedly - try alternatives\n- Detect and break out of unproductive loops: if you are on the same URL for 3+ steps without progress, or the same action fails 2-3 times, try a different approach\n\n**Authentication:**\n- Do not log into a page unless required by the task and you have credentials\n</rules>\n\n<output_format>\n## Output Format\nRespond with:\n1. **currentState**: Your assessment of the current state including:\n   - `evaluation`: Assessment of how the last action went\n   - `memory`: Important information to remember (progress, data found, approaches tried)\n   - `nextGoal`: The next immediate goal to pursue\n2. **actions**: A list of actions to execute (max {{maxActionsPerStep}} per step)\n</output_format>\n\n<action_rules>\nYou are allowed to use a maximum of {{maxActionsPerStep}} actions per step.\nMultiple actions execute sequentially (one after another).\n- If the page changes after an action, remaining actions are automatically skipped and you get the new state.\n- Check the browser state each step to verify your previous action achieved its goal.\n</action_rules>\n\n<available_actions>\n{{actionDescriptions}}\n</available_actions>\n\n<efficiency>\nYou can output multiple actions in one step. Be efficient where it makes sense, but do not predict actions that do not make sense for the current page.\n\n**Action categories:**\n- **Page-changing (always last):** navigate, search_google, go_back, switch_tab - these always change the page. Remaining actions after them are skipped automatically.\n- **Potentially page-changing:** click (on links/buttons that navigate) - monitored at runtime; if the page changes, remaining actions are skipped.\n- **Safe to chain:** input_text, scroll, extract_content, find_elements - these do not change the page and can be freely combined.\n\n**Recommended combinations:**\n- `input_text` + `input_text` + `click` -> Fill multiple form fields then submit\n- `input_text` + `send_keys` -> Fill a field and press Enter\n- `scroll` + `scroll` -> Scroll further down the page\n\nDo not try multiple different paths in one step. Always have one clear goal per step.\nPlace any page-changing action **last** in your action list.\n</efficiency>\n\n<reasoning>\nYou must reason systematically at every step:\n1. Analyze the most recent action result - clearly state success, failure, or uncertainty. Never assume success without verification.\n2. Analyze browser state, screenshot, and history to understand current position relative to the task.\n3. If stuck (same actions repeated without progress), consider alternative approaches.\n4. Decide what concise, actionable context should be stored in memory.\n5. State your next immediate goal clearly.\n</reasoning>\n\n<task_completion>\nYou must use the `done` action when:\n- You have fully completed the task\n- You reach the final allowed step, even if the task is incomplete\n- It is absolutely impossible to continue\n\nRules for `done`:\n- Set `success` to `true` only if the FULL task has been completed\n- If any part is missing, incomplete, or uncertain, set `success` to `false`\n- Put ALL relevant findings in the `text` field\n- You are ONLY allowed to call `done` as a single action - never combine it with other actions\n\n**Before calling done with success=true, verify:**\n1. Re-read the original task and list every concrete requirement\n2. Check each requirement against your results (correct count, filters applied, format matched)\n3. Verify actions actually completed (check page state/screenshot)\n4. Ensure no data was fabricated - every fact must come from pages you visited\n5. If ANY requirement is unmet or uncertain, set success to false\n</task_completion>\n\n<budget_management>\n- When you reach 75% of your step budget, critically evaluate whether you can complete the full task in remaining steps\n- If completion is unlikely, shift strategy: focus on highest-value remaining items and consolidate results\n- For large multi-item tasks, estimate per-item cost from the first few items and prioritize if the task will exceed your budget\n</budget_management>\n\n<error_recovery>\nWhen encountering errors or unexpected states:\n1. Verify the current state using screenshot as ground truth\n2. Check if a popup, modal, or overlay is blocking interaction\n3. If an element is not found, scroll to reveal more content\n4. If an action fails repeatedly (2-3 times), try an alternative approach\n5. If blocked by login/captcha/403, consider alternative sites or search engines\n6. If the page structure is different than expected, re-analyze and adapt\n7. If stuck in a loop, explicitly acknowledge it in memory and change strategy\n8. If max_steps is approaching, prioritize completing the most important parts\n</error_recovery>\n\n<examples>\n**Good evaluation examples:**\n- \"Successfully navigated to the product page and found the target information. Verdict: Success\"\n- \"Failed to input text into the search bar - element not visible. Verdict: Failure\"\n\n**Good memory examples:**\n- \"Visited 2 of 5 target websites. Collected pricing data from Amazon ($39.99) and eBay ($42.00). Still need Walmart, Target, Best Buy.\"\n- \"Search returned results but no filter applied yet. User wants items under $50 with 4+ stars. Will apply price filter first.\"\n- \"Captcha appeared twice on this site. Will try alternative approach via search engine.\"\n\n**Good next goal examples:**\n- \"Click the 'Add to Cart' button to proceed with the purchase flow.\"\n- \"Apply price filter to narrow results to items under $50.\"\n- \"Close the popup blocking the main content.\"\n</examples>\n\n<critical_reminders>\n1. ALWAYS verify action success using screenshot/browser state before proceeding\n2. ALWAYS handle popups/modals/cookie banners before other actions\n3. ALWAYS apply filters when the task specifies criteria\n4. NEVER repeat the same failing action more than 2-3 times\n5. NEVER assume success without verification\n6. Track progress in memory to avoid loops\n7. Match the task's requested output format exactly\n8. Be efficient - combine actions when possible but verify between major steps\n</critical_reminders>\n"
  },
  {
    "path": "packages/core/src/agent/instructions.ts",
    "content": "import { readFileSync } from 'node:fs';\nimport { resolve, dirname } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport type { AgentConfig } from './types.js';\nimport type { ViewportSnapshot, TabDescriptor } from '../viewport/types.js';\nimport type { CommandCatalog } from '../commands/catalog/catalog.js';\nimport type { ContentPart } from '../model/messages.js';\nimport { textContent, imageContent } from '../model/messages.js';\nimport { isNewTabPage, sanitizeSurrogates, dedent } from '../utils.js';\n\n// ── Template types ──\n\nexport type PromptTemplate = 'default' | 'flash' | 'no-thinking';\n\nexport interface InstructionBuilderOptions {\n\t/** Maximum actions the agent can take per step. */\n\tcommandsPerStep: number;\n\t/** Override the entire system prompt with a custom string. */\n\toverrideInstructionBuilder?: string;\n\t/** Append additional instructions to the system prompt. */\n\textendInstructionBuilder?: string;\n\t/** Which template variant to use. Defaults to 'default'. */\n\ttemplate?: PromptTemplate;\n\t/** Whether to include sensitive-data warnings. */\n\thasSensitiveData?: boolean;\n}\n\nexport interface StepInfo {\n\tstep: number;\n\tstepLimit: number;\n}\n\nexport interface StepPromptBuilderOptions {\n\tbrowserState: ViewportSnapshot;\n\ttask: string;\n\tstepInfo?: StepInfo;\n\tactionDescriptions?: string;\n\tpageFilteredActions?: string;\n\tagentHistoryDescription?: string;\n\tmaskedValues?: string;\n\tplanDescription?: string;\n\tscreenshots?: string[];\n\tenableScreenshots?: boolean;\n\tmaxElementsLength?: number;\n}\n\n// ── Template loading ──\n\n/**\n * Directory containing the .md system prompt templates.\n * Resolved relative to this file's location so it works regardless of\n * the current working directory or whether the package is installed.\n */\nconst TEMPLATES_DIR = resolve(dirname(fileURLToPath(import.meta.url)), 'instructions');\n\n/** Cache loaded templates so we only hit the filesystem once per variant. */\nconst templateCache = new Map<string, string>();\n\n/**\n * Map from PromptTemplate variant to the corresponding filename.\n */\nconst TEMPLATE_FILES: Record<PromptTemplate, string> = {\n\tdefault: 'instructions.md',\n\tflash: 'instructions-compact.md',\n\t'no-thinking': 'instructions-direct.md',\n};\n\n/**\n * Load a system-prompt template from disk. Results are cached.\n *\n * @param variant - Which prompt template to load.\n * @returns The raw template string with `{{variable}}` placeholders.\n * @throws If the template file cannot be read.\n */\nfunction loadTemplate(variant: PromptTemplate): string {\n\tconst cached = templateCache.get(variant);\n\tif (cached !== undefined) return cached;\n\n\tconst filename = TEMPLATE_FILES[variant];\n\tconst filepath = resolve(TEMPLATES_DIR, filename);\n\n\ttry {\n\t\tconst content = readFileSync(filepath, 'utf-8');\n\t\ttemplateCache.set(variant, content);\n\t\treturn content;\n\t} catch (error) {\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tthrow new Error(`Failed to load system prompt template \"${filename}\": ${message}`);\n\t}\n}\n\n/**\n * Interpolate `{{key}}` placeholders in a template string.\n * Unmatched placeholders are left as-is so downstream code can detect them.\n */\nfunction interpolate(template: string, variables: Record<string, string>): string {\n\treturn template.replace(/\\{\\{(\\w+)\\}\\}/g, (match, key: string) => {\n\t\treturn key in variables ? variables[key] : match;\n\t});\n}\n\n/**\n * Clear the template cache. Useful for testing or hot-reloading.\n */\nexport function clearTemplateCache(): void {\n\ttemplateCache.clear();\n}\n\n// ── InstructionBuilder ──\n\n/**\n * Builds the system prompt for the browser automation agent.\n *\n * In the simplest case it loads a `.md` template from the `system-prompts/`\n * directory and interpolates variables like `{{task}}`, `{{commandsPerStep}}`,\n * and `{{actionDescriptions}}`.\n *\n * The class also exposes static helpers for building per-step state messages,\n * action results, and other ancillary prompt fragments that are injected as\n * user messages during the agent loop.\n */\nexport class InstructionBuilder {\n\tprivate options: InstructionBuilderOptions;\n\tprivate actionDescriptions: string;\n\n\tconstructor(options: InstructionBuilderOptions, actionDescriptions: string) {\n\t\tthis.options = options;\n\t\tthis.actionDescriptions = actionDescriptions;\n\t}\n\n\t/**\n\t * Build and return the complete system prompt string.\n\t *\n\t * If `overrideInstructionBuilder` is set, it is returned verbatim (after\n\t * optional extension). Otherwise, the appropriate `.md` template is\n\t * loaded and interpolated with the current settings.\n\t */\n\tbuild(): string {\n\t\tif (this.options.overrideInstructionBuilder) {\n\t\t\tlet prompt = this.options.overrideInstructionBuilder;\n\t\t\tif (this.options.extendInstructionBuilder) {\n\t\t\t\tprompt += `\\n${this.options.extendInstructionBuilder}`;\n\t\t\t}\n\t\t\treturn prompt;\n\t\t}\n\n\t\tconst variant = this.options.template ?? 'default';\n\t\tconst template = loadTemplate(variant);\n\n\t\tconst variables: Record<string, string> = {\n\t\t\ttask: '(set per-step in user messages)',\n\t\t\tcommandsPerStep: String(this.options.commandsPerStep),\n\t\t\tactionDescriptions: this.actionDescriptions,\n\t\t};\n\n\t\tlet prompt = interpolate(template, variables);\n\n\t\tif (this.options.extendInstructionBuilder) {\n\t\t\tprompt += `\\n${this.options.extendInstructionBuilder}`;\n\t\t}\n\n\t\treturn prompt;\n\t}\n\n\t/**\n\t * Convenience: create a InstructionBuilder from AgentConfig + a CommandCatalog.\n\t * Pulls action descriptions directly from the registry, optionally\n\t * filtered by the current page URL.\n\t */\n\tstatic fromSettings(settings: AgentConfig, registry: CommandCatalog, pageUrl?: string): InstructionBuilder {\n\t\tconst descriptions = registry.getPromptDescription(pageUrl);\n\n\t\treturn new InstructionBuilder(\n\t\t\t{\n\t\t\t\tcommandsPerStep: settings.commandsPerStep,\n\t\t\t\toverrideInstructionBuilder: settings.overrideInstructionBuilder,\n\t\t\t\textendInstructionBuilder: settings.extendInstructionBuilder,\n\t\t\t\thasSensitiveData: settings.maskedValues !== undefined,\n\t\t\t},\n\t\t\tdescriptions,\n\t\t);\n\t}\n\n\t// ── Static prompt fragment builders ──\n\n\tstatic buildTaskPrompt(task: string): string {\n\t\treturn `Your current task: ${task}`;\n\t}\n\n\tstatic buildStatePrompt(\n\t\turl: string,\n\t\ttitle: string,\n\t\ttabs: Array<{ url: string; title: string; isActive: boolean }>,\n\t\tdomTree: string,\n\t\tstep: number,\n\t\tstepLimit: number,\n\t\tpixelsAbove?: number,\n\t\tpixelsBelow?: number,\n\t): string {\n\t\tconst parts: string[] = [];\n\n\t\tparts.push(`[Step ${step}/${stepLimit}]`);\n\t\tparts.push(`Current URL: ${url}`);\n\t\tparts.push(`Page Title: ${title}`);\n\n\t\tif (tabs.length > 1) {\n\t\t\tconst tabList = tabs\n\t\t\t\t.map((t, i) => `  [${i}] ${t.isActive ? '(active) ' : ''}${t.title} - ${t.url}`)\n\t\t\t\t.join('\\n');\n\t\t\tparts.push(`Open Tabs:\\n${tabList}`);\n\t\t}\n\n\t\tif (pixelsAbove !== undefined && pixelsAbove > 0) {\n\t\t\tparts.push(`Scroll position: ${pixelsAbove}px from top`);\n\t\t}\n\t\tif (pixelsBelow !== undefined && pixelsBelow > 0) {\n\t\t\tparts.push(`${pixelsBelow}px of content below the visible area`);\n\t\t}\n\n\t\tparts.push(`\\nPage content:\\n${domTree}`);\n\n\t\treturn parts.join('\\n');\n\t}\n\n\tstatic buildCommandResultPrompt(results: Array<{ action: string; result: string }>): string {\n\t\tif (results.length === 0) return '';\n\n\t\tconst formatted = results\n\t\t\t.map((r) => `Action: ${r.action}\\nResult: ${r.result}`)\n\t\t\t.join('\\n---\\n');\n\n\t\treturn `Previous action results:\\n${formatted}`;\n\t}\n\n\tstatic buildLoopNudge(message: string): string {\n\t\treturn `\\nIMPORTANT: ${message}`;\n\t}\n\n\tstatic buildPlanPrompt(currentPlan: string): string {\n\t\treturn `\\nCurrent plan:\\n${currentPlan}`;\n\t}\n}\n\n// ── StepPromptBuilder ──\n\n/**\n * Constructs the per-step user message for the agent.\n *\n * Each step of the agent loop sends a user message containing:\n * - The current browser state (URL, tabs, interactive elements)\n * - Scroll position and page boundaries\n * - Agent history summary\n * - Step information (step N of M)\n * - Optionally: screenshots, sensitive data warnings, plan description\n * - Optionally: page-specific action descriptions\n *\n * The message can be returned as a plain string or as a multipart content\n * array (text + images) when vision is enabled.\n */\nexport class StepPromptBuilder {\n\tprivate browserState: ViewportSnapshot;\n\tprivate task: string;\n\tprivate stepInfo?: StepInfo;\n\tprivate actionDescriptions?: string;\n\tprivate pageFilteredActions?: string;\n\tprivate agentHistoryDescription?: string;\n\tprivate maskedValues?: string;\n\tprivate planDescription?: string;\n\tprivate screenshots: string[];\n\tprivate enableScreenshots: boolean;\n\tprivate maxElementsLength: number;\n\n\tconstructor(options: StepPromptBuilderOptions) {\n\t\tthis.browserState = options.browserState;\n\t\tthis.task = options.task;\n\t\tthis.stepInfo = options.stepInfo;\n\t\tthis.actionDescriptions = options.actionDescriptions;\n\t\tthis.pageFilteredActions = options.pageFilteredActions;\n\t\tthis.agentHistoryDescription = options.agentHistoryDescription;\n\t\tthis.maskedValues = options.maskedValues;\n\t\tthis.planDescription = options.planDescription;\n\t\tthis.screenshots = options.screenshots ?? [];\n\t\tthis.enableScreenshots = options.enableScreenshots ?? false;\n\t\tthis.maxElementsLength = options.maxElementsLength ?? 40_000;\n\t}\n\n\t/**\n\t * Build the user message content.\n\t *\n\t * When vision is disabled (or no screenshots are available), returns a\n\t * single string. When vision is enabled and screenshots exist, returns\n\t * a `ContentPart[]` array interleaving text and image parts.\n\t */\n\tgetUserMessage(): string | ContentPart[] {\n\t\t// Skip screenshots on step 0 for new-tab pages with a single tab\n\t\tlet effectiveVision = this.enableScreenshots;\n\t\tif (\n\t\t\tisNewTabPage(this.browserState.url) &&\n\t\t\tthis.stepInfo?.step === 0 &&\n\t\t\tthis.browserState.tabs.length <= 1\n\t\t) {\n\t\t\teffectiveVision = false;\n\t\t}\n\n\t\tconst stateDescription = this.buildStateDescription();\n\n\t\tif (effectiveVision && this.screenshots.length > 0) {\n\t\t\tconst parts: ContentPart[] = [textContent(stateDescription)];\n\n\t\t\tfor (let i = 0; i < this.screenshots.length; i++) {\n\t\t\t\tconst label =\n\t\t\t\t\ti === this.screenshots.length - 1 ? 'Current screenshot:' : 'Previous screenshot:';\n\t\t\t\tparts.push(textContent(label));\n\t\t\t\tparts.push(imageContent(this.screenshots[i], 'image/png'));\n\t\t\t}\n\n\t\t\treturn parts;\n\t\t}\n\n\t\treturn stateDescription;\n\t}\n\n\t/**\n\t * Build the complete text description of the current state.\n\t * This includes agent history, agent state (task, step info, plan),\n\t * and browser state (URL, tabs, elements, scroll position).\n\t */\n\tprivate buildStateDescription(): string {\n\t\tconst sections: string[] = [];\n\n\t\t// Agent history\n\t\tsections.push(this.buildAgentHistorySection());\n\n\t\t// Agent state (task, step info, plan, sensitive data)\n\t\tsections.push(this.buildAgentStateSection());\n\n\t\t// Browser state (URL, tabs, elements)\n\t\tsections.push(this.buildBrowserStateSection());\n\n\t\t// Page-specific actions (if any domain-filtered actions apply)\n\t\tif (this.pageFilteredActions) {\n\t\t\tsections.push(\n\t\t\t\t`<page_specific_actions>\\n${this.pageFilteredActions}\\n</page_specific_actions>`,\n\t\t\t);\n\t\t}\n\n\t\t// Sanitize surrogates to prevent JSON serialization issues\n\t\treturn sanitizeSurrogates(sections.join('\\n\\n'));\n\t}\n\n\tprivate buildAgentHistorySection(): string {\n\t\tconst history = this.agentHistoryDescription?.trim() ?? '';\n\t\treturn `<agent_history>\\n${history}\\n</agent_history>`;\n\t}\n\n\tprivate buildAgentStateSection(): string {\n\t\tconst parts: string[] = [];\n\n\t\tparts.push(`<user_request>\\n${this.task}\\n</user_request>`);\n\n\t\tif (this.planDescription) {\n\t\t\tparts.push(`<plan>\\n${this.planDescription}\\n</plan>`);\n\t\t}\n\n\t\tif (this.maskedValues) {\n\t\t\tparts.push(`<sensitive_data>${this.maskedValues}</sensitive_data>`);\n\t\t}\n\n\t\tif (this.stepInfo) {\n\t\t\tconst today = new Date().toISOString().slice(0, 10);\n\t\t\tparts.push(\n\t\t\t\t`<step_info>Step ${this.stepInfo.step + 1} of ${this.stepInfo.stepLimit} | Today: ${today}</step_info>`,\n\t\t\t);\n\t\t}\n\n\t\treturn `<agent_state>\\n${parts.join('\\n')}\\n</agent_state>`;\n\t}\n\n\tprivate buildBrowserStateSection(): string {\n\t\tconst parts: string[] = [];\n\n\t\t// Tabs\n\t\tconst tabsText = this.buildTabsText();\n\t\tif (tabsText) {\n\t\t\tparts.push(tabsText);\n\t\t}\n\n\t\t// Scroll / page info\n\t\tconst pageInfo = this.buildPageInfoText();\n\t\tif (pageInfo) {\n\t\t\tparts.push(pageInfo);\n\t\t}\n\n\t\t// Interactive elements\n\t\tparts.push(this.buildElementsText());\n\n\t\treturn `<browser_state>\\n${parts.join('\\n')}\\n</browser_state>`;\n\t}\n\n\tprivate buildTabsText(): string {\n\t\tconst { tabs, url, title } = this.browserState;\n\t\tif (tabs.length === 0) return '';\n\n\t\t// Try to identify the current tab\n\t\tconst currentCandidates = tabs.filter((t) => t.url === url && t.title === title);\n\t\tconst currentTabId =\n\t\t\tcurrentCandidates.length === 1 ? currentCandidates[0].tabId : undefined;\n\n\t\tconst lines: string[] = [];\n\t\tif (currentTabId) {\n\t\t\tlines.push(`Current tab: ${String(currentTabId).slice(-4)}`);\n\t\t}\n\n\t\tlines.push('Available tabs:');\n\t\tfor (const tab of tabs) {\n\t\t\tlines.push(`Tab ${String(tab.tabId).slice(-4)}: ${tab.url} - ${tab.title.slice(0, 30)}`);\n\t\t}\n\n\t\treturn lines.join('\\n');\n\t}\n\n\tprivate buildPageInfoText(): string {\n\t\tconst { pixelsAbove, pixelsBelow } = this.browserState;\n\t\tconst parts: string[] = [];\n\n\t\tif (pixelsAbove !== undefined && pixelsAbove > 0) {\n\t\t\t// Estimate \"pages above\" assuming ~900px viewport height\n\t\t\tconst pagesAbove = (pixelsAbove / 900).toFixed(1);\n\t\t\tparts.push(`${pagesAbove} pages above`);\n\t\t}\n\t\tif (pixelsBelow !== undefined && pixelsBelow > 0) {\n\t\t\tconst pagesBelow = (pixelsBelow / 900).toFixed(1);\n\t\t\tparts.push(`${pagesBelow} pages below`);\n\t\t}\n\n\t\tif (parts.length === 0) return '';\n\t\treturn `<page_info>${parts.join(', ')}</page_info>`;\n\t}\n\n\tprivate buildElementsText(): string {\n\t\tlet elementsText = this.browserState.domTree ?? '';\n\n\t\tif (!elementsText) {\n\t\t\treturn 'Interactive elements:\\nempty page';\n\t\t}\n\n\t\t// Truncate if too long\n\t\tlet truncatedNote = '';\n\t\tif (elementsText.length > this.maxElementsLength) {\n\t\t\telementsText = elementsText.slice(0, this.maxElementsLength);\n\t\t\ttruncatedNote = ` (truncated to ${this.maxElementsLength} characters)`;\n\t\t}\n\n\t\t// Add start/end of page markers based on scroll position\n\t\tconst hasContentAbove =\n\t\t\tthis.browserState.pixelsAbove !== undefined && this.browserState.pixelsAbove > 0;\n\t\tconst hasContentBelow =\n\t\t\tthis.browserState.pixelsBelow !== undefined && this.browserState.pixelsBelow > 0;\n\n\t\tif (!hasContentAbove) {\n\t\t\telementsText = `[Start of page]\\n${elementsText}`;\n\t\t}\n\t\tif (!hasContentBelow) {\n\t\t\telementsText = `${elementsText}\\n[End of page]`;\n\t\t}\n\n\t\treturn `Interactive elements${truncatedNote}:\\n${elementsText}`;\n\t}\n}\n\n// ── Dynamic action descriptions ──\n\n/**\n * Build action descriptions from a registry, optionally filtered by\n * the current page URL. Returns a formatted string suitable for\n * injection into the system prompt's `{{actionDescriptions}}` slot.\n */\nexport function buildCommandDescriptions(registry: CommandCatalog, pageUrl?: string): string {\n\treturn registry.getPromptDescription(pageUrl);\n}\n\n/**\n * Build a description of actions that are specific to the current page's domain.\n * Returns `undefined` if there are no domain-specific actions beyond the\n * universal set.\n *\n * This is injected as a `<page_specific_actions>` section in the per-step\n * user message when the page URL triggers extra actions.\n */\nexport function buildContextualCommands(registry: CommandCatalog, pageUrl: string): string | undefined {\n\tconst allActions = registry.getAll();\n\tconst domainActions = registry.getActionsForDomain(extractDomain(pageUrl));\n\n\t// If all actions are already shown (no domain filtering), nothing extra to show\n\tif (domainActions.length === allActions.length) return undefined;\n\n\t// Find domain-specific actions (ones that have a domainFilter)\n\tconst extraActions = domainActions.filter(\n\t\t(a) => a.domainFilter && a.domainFilter.length > 0,\n\t);\n\n\tif (extraActions.length === 0) return undefined;\n\n\tconst lines = extraActions.map(\n\t\t(a) => `- ${a.name}: ${a.description}`,\n\t);\n\n\treturn `The following actions are available on this page:\\n${lines.join('\\n')}`;\n}\n\n// ── Rerun / extraction prompt helpers ──\n\n/**\n * Build a system prompt for the extraction/AI-step action used during reruns.\n */\nexport function buildExtractionInstructionBuilder(): string {\n\treturn dedent(`\n\t\tYou are an expert at extracting data from webpages.\n\n\t\tYou will be given:\n\t\t1. A query describing what to extract\n\t\t2. The markdown of the webpage (filtered to remove noise)\n\t\t3. Optionally, a screenshot of the current page state\n\n\t\tInstructions:\n\t\t- Extract information from the webpage that is relevant to the query\n\t\t- ONLY use the information available in the webpage - do not make up information\n\t\t- If the information is not available, mention that clearly\n\t\t- If the query asks for all items, list all of them\n\n\t\tOutput:\n\t\t- Present ALL relevant information in a concise way\n\t\t- Do not use conversational format - directly output the relevant information\n\t\t- If information is unavailable, state that clearly\n\t`);\n}\n\n/**\n * Build a user prompt for the extraction/AI-step action.\n */\nexport function buildExtractionUserPrompt(\n\tquery: string,\n\tstatsSummary: string,\n\tcontent: string,\n): string {\n\treturn [\n\t\t`<query>\\n${query}\\n</query>`,\n\t\t`<content_stats>\\n${statsSummary}\\n</content_stats>`,\n\t\t`<webpage_content>\\n${content}\\n</webpage_content>`,\n\t].join('\\n\\n');\n}\n\n// ── Helpers ──\n\nfunction extractDomain(url: string): string {\n\ttry {\n\t\treturn new URL(url).hostname.replace(/^www\\./, '').toLowerCase();\n\t} catch {\n\t\treturn '';\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/agent/replay-recorder.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('gif-recorder');\n\nexport interface ReplayRecorderOptions {\n\t/** Output file path. Extension determines format (.gif or .png for fallback). */\n\toutputPath: string;\n\t/** Delay between frames in milliseconds */\n\tframeDelay?: number;\n\t/** Resize frames to this width (maintains aspect ratio). 0 = no resize. */\n\tresizeWidth?: number;\n\t/** Quality (1-30, lower = better quality). Only used for GIF encoding. */\n\tquality?: number;\n}\n\ninterface FrameData {\n\tbuffer: Buffer;\n\tstepNumber: number;\n\tlabel?: string;\n}\n\n/**\n * Records agent screenshots and encodes them into an animated GIF.\n *\n * Uses the `sharp` library (optional dependency) for image processing\n * and compositing step-number overlays. If sharp is not available,\n * falls back to saving individual PNG frames.\n *\n * Usage:\n *   const recorder = new ReplayRecorder({ outputPath: './recording.gif' });\n *   recorder.addFrame(screenshotBase64, 1);\n *   // ... more frames ...\n *   await recorder.save(); // -> path to GIF or frames directory\n */\nexport class ReplayRecorder {\n\tprivate frames: FrameData[] = [];\n\tprivate outputPath: string;\n\tprivate frameDelay: number;\n\tprivate resizeWidth: number;\n\tprivate quality: number;\n\n\tconstructor(options: ReplayRecorderOptions) {\n\t\tthis.outputPath = options.outputPath;\n\t\tthis.frameDelay = options.frameDelay ?? 500;\n\t\tthis.resizeWidth = options.resizeWidth ?? 800;\n\t\tthis.quality = options.quality ?? 10;\n\t}\n\n\t/**\n\t * Add a screenshot frame to the recording.\n\t * @param screenshotBase64 - PNG screenshot as base64 string\n\t * @param stepNumber - Step number for the overlay annotation\n\t * @param label - Optional label text (e.g., the action taken)\n\t */\n\taddFrame(screenshotBase64: string, stepNumber?: number, label?: string): void {\n\t\tconst buffer = Buffer.from(screenshotBase64, 'base64');\n\t\tthis.frames.push({\n\t\t\tbuffer,\n\t\t\tstepNumber: stepNumber ?? this.frames.length + 1,\n\t\t\tlabel,\n\t\t});\n\t}\n\n\t/**\n\t * Save the recording. Attempts GIF encoding with sharp, falls back\n\t * to individual PNG frames if sharp is not available.\n\t *\n\t * @param generateGif - true to generate a GIF, 'path' to override output path,\n\t *                      false to only save individual frames\n\t * @returns The path where the recording was saved\n\t */\n\tasync save(generateGif: string | boolean = true): Promise<string> {\n\t\tif (this.frames.length === 0) {\n\t\t\tlogger.debug('No frames to save');\n\t\t\treturn this.outputPath;\n\t\t}\n\n\t\tconst effectivePath = typeof generateGif === 'string' ? generateGif : this.outputPath;\n\t\tconst dir = path.dirname(effectivePath);\n\t\tif (!fs.existsSync(dir)) {\n\t\t\tfs.mkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\t// Always save individual frames as fallback / debug\n\t\tawait this.saveFrames(effectivePath);\n\n\t\tif (generateGif === false) {\n\t\t\treturn effectivePath;\n\t\t}\n\n\t\t// Try to generate actual GIF using sharp\n\t\ttry {\n\t\t\tconst gifPath = await this.encodeGif(effectivePath);\n\t\t\tlogger.info(`GIF saved: ${gifPath} (${this.frames.length} frames)`);\n\t\t\treturn gifPath;\n\t\t} catch (error) {\n\t\t\tlogger.warn(\n\t\t\t\t`GIF encoding failed, falling back to individual frames: ${\n\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t}`,\n\t\t\t);\n\t\t\treturn effectivePath;\n\t\t}\n\t}\n\n\t/**\n\t * Encode frames into an animated GIF using sharp.\n\t * Sharp must be installed as a peer dependency.\n\t */\n\tprivate async encodeGif(outputPath: string): Promise<string> {\n\t\t// Dynamic import -- sharp is an optional dependency.\n\t\t// Use indirect require to avoid TS module resolution error.\n\t\t// eslint-disable-next-line @typescript-eslint/no-explicit-any\n\t\tlet sharpModule: any;\n\t\ttry {\n\t\t\t// Indirect dynamic import avoids TS2307 for optional peer deps\n\t\t\tconst moduleName = 'sharp';\n\t\t\tsharpModule = await import(/* webpackIgnore: true */ moduleName);\n\t\t} catch {\n\t\t\tthrow new Error(\n\t\t\t\t'sharp is not installed. Install it with: npm install sharp',\n\t\t\t);\n\t\t}\n\n\t\t// Resolve the default export (handles both ESM and CJS)\n\t\tconst sharp = sharpModule.default ?? sharpModule;\n\n\t\tconst gifPath = outputPath.replace(/\\.[^.]+$/, '.gif');\n\t\tconst processedFrames: Buffer[] = [];\n\n\t\tfor (const frame of this.frames) {\n\t\t\tlet img = sharp(frame.buffer);\n\n\t\t\t// Resize if configured\n\t\t\tif (this.resizeWidth > 0) {\n\t\t\t\timg = img.resize(this.resizeWidth, undefined, {\n\t\t\t\t\tfit: 'inside',\n\t\t\t\t\twithoutEnlargement: true,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\t// Composite a step number overlay onto the frame\n\t\t\tconst overlayBuffer = this.createStepOverlaySvg(\n\t\t\t\tframe.stepNumber,\n\t\t\t\tframe.label,\n\t\t\t);\n\n\t\t\timg = img.composite([\n\t\t\t\t{\n\t\t\t\t\tinput: Buffer.from(overlayBuffer),\n\t\t\t\t\tgravity: 'northwest',\n\t\t\t\t},\n\t\t\t]);\n\n\t\t\t// Convert to PNG for further processing\n\t\t\tconst processed = await img\n\t\t\t\t.flatten({ background: { r: 255, g: 255, b: 255 } })\n\t\t\t\t.png()\n\t\t\t\t.toBuffer();\n\n\t\t\tprocessedFrames.push(processed);\n\t\t}\n\n\t\t// Attempt to assemble an animated GIF from the processed frames\n\t\ttry {\n\t\t\tconst firstFrame = sharp(processedFrames[0]);\n\t\t\tconst metadata = await firstFrame.metadata();\n\t\t\tconst width = metadata.width ?? this.resizeWidth;\n\t\t\tconst height = metadata.height ?? 600;\n\n\t\t\t// Convert each frame to raw RGBA\n\t\t\tconst rawFrames: Buffer[] = [];\n\t\t\tfor (const frameBuffer of processedFrames) {\n\t\t\t\tconst raw = await sharp(frameBuffer)\n\t\t\t\t\t.resize(width, height, {\n\t\t\t\t\t\tfit: 'contain',\n\t\t\t\t\t\tbackground: { r: 255, g: 255, b: 255 },\n\t\t\t\t\t})\n\t\t\t\t\t.raw()\n\t\t\t\t\t.ensureAlpha()\n\t\t\t\t\t.toBuffer();\n\t\t\t\trawFrames.push(raw);\n\t\t\t}\n\n\t\t\t// Concatenate all raw frames and encode as animated GIF\n\t\t\tconst combinedRaw = Buffer.concat(rawFrames);\n\t\t\tawait sharp(combinedRaw, {\n\t\t\t\traw: {\n\t\t\t\t\twidth,\n\t\t\t\t\theight,\n\t\t\t\t\tchannels: 4,\n\t\t\t\t\tpages: rawFrames.length,\n\t\t\t\t},\n\t\t\t})\n\t\t\t\t.gif({\n\t\t\t\t\tdelay: Array(rawFrames.length).fill(this.frameDelay),\n\t\t\t\t\tloop: 0,\n\t\t\t\t})\n\t\t\t\t.toFile(gifPath);\n\n\t\t\treturn gifPath;\n\t\t} catch (animatedError) {\n\t\t\t// If animated GIF creation fails, save the last frame as a static image\n\t\t\tlogger.debug(\n\t\t\t\t`Animated GIF assembly failed, saving static image: ${\n\t\t\t\t\tanimatedError instanceof Error\n\t\t\t\t\t\t? animatedError.message\n\t\t\t\t\t\t: String(animatedError)\n\t\t\t\t}`,\n\t\t\t);\n\t\t\tconst lastFrame = processedFrames[processedFrames.length - 1];\n\t\t\tconst staticPath = outputPath.replace(/\\.[^.]+$/, '.png');\n\t\t\tawait sharp(lastFrame).png().toFile(staticPath);\n\t\t\treturn staticPath;\n\t\t}\n\t}\n\n\t/**\n\t * Create an SVG overlay with the step number and optional label.\n\t * Returns an SVG string that can be composited onto the frame.\n\t */\n\tprivate createStepOverlaySvg(stepNumber: number, label?: string): string {\n\t\tconst labelText = label ? ` - ${label.slice(0, 40)}` : '';\n\t\tconst text = `Step ${stepNumber}${labelText}`;\n\t\tconst width = Math.max(200, text.length * 10 + 20);\n\t\tconst height = 36;\n\n\t\treturn `<svg width=\"${width}\" height=\"${height}\" xmlns=\"http://www.w3.org/2000/svg\">\n\t\t\t<rect x=\"0\" y=\"0\" width=\"${width}\" height=\"${height}\" rx=\"4\" fill=\"rgba(0,0,0,0.7)\"/>\n\t\t\t<text x=\"10\" y=\"24\" font-family=\"monospace\" font-size=\"16\" fill=\"white\">${this.escapeXml(text)}</text>\n\t\t</svg>`;\n\t}\n\n\t/**\n\t * Save individual PNG frames to a directory alongside the output path.\n\t */\n\tprivate async saveFrames(outputPath: string): Promise<string> {\n\t\tconst framesDir = outputPath.replace(/\\.[^.]+$/, '_frames');\n\t\tif (!fs.existsSync(framesDir)) {\n\t\t\tfs.mkdirSync(framesDir, { recursive: true });\n\t\t}\n\n\t\tfor (let i = 0; i < this.frames.length; i++) {\n\t\t\tconst frame = this.frames[i];\n\t\t\tconst framePath = path.join(\n\t\t\t\tframesDir,\n\t\t\t\t`frame_${frame.stepNumber.toString().padStart(4, '0')}.png`,\n\t\t\t);\n\t\t\tfs.writeFileSync(framePath, frame.buffer);\n\t\t}\n\n\t\t// Also save the last frame as the preview image\n\t\tif (this.frames.length > 0) {\n\t\t\tconst lastFrame = this.frames[this.frames.length - 1];\n\t\t\tconst previewPath = outputPath.replace(/\\.[^.]+$/, '_preview.png');\n\t\t\tfs.writeFileSync(previewPath, lastFrame.buffer);\n\t\t}\n\n\t\tlogger.debug(`Saved ${this.frames.length} frames to ${framesDir}`);\n\t\treturn framesDir;\n\t}\n\n\t/** Escape XML special characters for SVG text content */\n\tprivate escapeXml(text: string): string {\n\t\treturn text\n\t\t\t.replace(/&/g, '&amp;')\n\t\t\t.replace(/</g, '&lt;')\n\t\t\t.replace(/>/g, '&gt;')\n\t\t\t.replace(/\"/g, '&quot;')\n\t\t\t.replace(/'/g, '&apos;');\n\t}\n\n\tget frameCount(): number {\n\t\treturn this.frames.length;\n\t}\n\n\tclear(): void {\n\t\tthis.frames = [];\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/agent/stall-detector.test.ts",
    "content": "import { test, expect, describe, beforeEach } from 'bun:test';\nimport {\n\tStallDetector,\n\thashPageTree,\n\thashTextContent,\n\ttype PageSignature,\n} from './stall-detector.js';\nimport type { Command } from '../commands/types.js';\n\n// ── Helpers ──\n\nfunction clickAction(index: number): Command {\n\treturn { action: 'tap', index, clickCount: 1 };\n}\n\nfunction inputAction(index: number, text: string): Command {\n\treturn { action: 'type_text', index, text, clearFirst: true };\n}\n\nfunction navigateAction(url: string): Command {\n\treturn { action: 'navigate', url };\n}\n\nfunction scrollAction(direction: 'up' | 'down', index?: number): Command {\n\treturn { action: 'scroll', direction, index };\n}\n\nfunction doneAction(text: string): Command {\n\treturn { action: 'finish', text, success: true };\n}\n\nfunction searchGoogleAction(query: string): Command {\n\treturn { action: 'web_search', query };\n}\n\nfunction makeFingerprint(overrides: Partial<PageSignature> = {}): PageSignature {\n\treturn {\n\t\turl: 'https://example.com',\n\t\tdomHash: 'abc123',\n\t\tscrollY: 0,\n\t\telementCount: 50,\n\t\ttextHash: 'texthash1',\n\t\t...overrides,\n\t};\n}\n\n// ── Tests ──\n\ndescribe('StallDetector', () => {\n\tlet detector: StallDetector;\n\n\tbeforeEach(() => {\n\t\tdetector = new StallDetector();\n\t});\n\n\tdescribe('initial state', () => {\n\t\ttest('isStuck returns not stuck when no actions recorded', () => {\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t\texpect(result.severity).toBe(0);\n\t\t});\n\n\t\ttest('getTotalRepetitions returns 0 initially', () => {\n\t\t\texpect(detector.getTotalRepetitions()).toBe(0);\n\t\t});\n\n\t\ttest('getLoopNudgeMessage returns empty string when not stuck', () => {\n\t\t\texpect(detector.getLoopNudgeMessage()).toBe('');\n\t\t});\n\t});\n\n\tdescribe('recordAction and repeated action detection', () => {\n\t\ttest('does not flag non-repeated actions', () => {\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\t\t\tdetector.recordAction([clickAction(3)]);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\n\t\ttest('flags the same action repeated maxRepeatedActions times (default 3)', () => {\n\t\t\tdetector.recordAction([clickAction(5)]);\n\t\t\tdetector.recordAction([clickAction(5)]);\n\t\t\tdetector.recordAction([clickAction(5)]);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\texpect(result.reason).toContain('repeated');\n\t\t\texpect(result.reason).toContain('3');\n\t\t});\n\n\t\ttest('flags repeated multi-action steps', () => {\n\t\t\tconst actions: Command[] = [clickAction(1), inputAction(2, 'hello')];\n\t\t\tdetector.recordAction(actions);\n\t\t\tdetector.recordAction(actions);\n\t\t\tdetector.recordAction(actions);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t});\n\n\t\ttest('does not flag when only two repeated actions (below threshold)', () => {\n\t\t\tdetector.recordAction([clickAction(5)]);\n\t\t\tdetector.recordAction([clickAction(5)]);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\n\t\ttest('custom maxRepeatedActions threshold', () => {\n\t\t\t// With maxRepeatedActions=5, only 5+ trailing repeats should trigger.\n\t\t\t// Note: cycle detection (A->B->A->B) fires with 4 identical actions\n\t\t\t// because all 4 being the same matches the pattern. So we can only test\n\t\t\t// that at exactly 3 trailing repeats (below our custom threshold of 5,\n\t\t\t// and below the cycle check threshold of 4 identical entries), it's not stuck.\n\t\t\tconst custom = new StallDetector({ maxRepeatedActions: 5 });\n\t\t\tcustom.recordAction([clickAction(10)]); // prefix to avoid cycle match\n\t\t\tcustom.recordAction([clickAction(1)]);\n\t\t\tcustom.recordAction([clickAction(1)]);\n\t\t\tcustom.recordAction([clickAction(1)]);\n\t\t\t// 3 trailing repeats < 5 threshold, and cycle check sees [10,1,1,1] which is not A->B->A->B\n\t\t\texpect(custom.isStuck().stuck).toBe(false);\n\n\t\t\t// Add two more to reach 5 trailing repeats\n\t\t\tcustom.recordAction([clickAction(1)]);\n\t\t\tcustom.recordAction([clickAction(1)]);\n\t\t\texpect(custom.isStuck().stuck).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('action cycle detection (A -> B -> A -> B)', () => {\n\t\ttest('detects alternating two-action cycle', () => {\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\texpect(result.reason).toContain('cycle');\n\t\t});\n\n\t\ttest('does not falsely detect A -> B -> A -> C as a cycle', () => {\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(3)]);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('triple cycle detection (A -> B -> C -> A -> B -> C)', () => {\n\t\ttest('detects 3-step cycle', () => {\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\t\t\tdetector.recordAction([clickAction(3)]);\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\t\t\tdetector.recordAction([clickAction(3)]);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\texpect(result.reason).toContain('3-step');\n\t\t});\n\n\t\ttest('does not detect partial triple cycle', () => {\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\t\t\tdetector.recordAction([clickAction(3)]);\n\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\tdetector.recordAction([clickAction(2)]);\n\n\t\t\t// Only 5 entries, needs 6 for triple check\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('fingerprint-based stuck detection', () => {\n\t\ttest('detects repeated page fingerprints', () => {\n\t\t\tconst fp = makeFingerprint();\n\t\t\tdetector.recordFingerprint(fp);\n\t\t\tdetector.recordFingerprint(fp);\n\t\t\tdetector.recordFingerprint(fp);\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\texpect(result.reason).toContain('Page state unchanged');\n\t\t});\n\n\t\ttest('different fingerprints do not trigger stuck', () => {\n\t\t\tdetector.recordFingerprint(makeFingerprint({ domHash: 'hash1' }));\n\t\t\tdetector.recordFingerprint(makeFingerprint({ domHash: 'hash2' }));\n\t\t\tdetector.recordFingerprint(makeFingerprint({ domHash: 'hash3' }));\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\n\t\ttest('scroll position bucketed (200px buckets) - same bucket triggers stuck', () => {\n\t\t\t// scrollY 0 and 100 are in the same bucket (both floor to 0)\n\t\t\tdetector.recordFingerprint(makeFingerprint({ scrollY: 0 }));\n\t\t\tdetector.recordFingerprint(makeFingerprint({ scrollY: 50 }));\n\t\t\tdetector.recordFingerprint(makeFingerprint({ scrollY: 100 }));\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t});\n\n\t\ttest('different scroll buckets not considered stuck', () => {\n\t\t\tdetector.recordFingerprint(makeFingerprint({ scrollY: 0 }));\n\t\t\tdetector.recordFingerprint(makeFingerprint({ scrollY: 200 }));\n\t\t\tdetector.recordFingerprint(makeFingerprint({ scrollY: 400 }));\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\n\t\ttest('custom maxRepeatedFingerprints threshold', () => {\n\t\t\tconst custom = new StallDetector({ maxRepeatedFingerprints: 5 });\n\t\t\tconst fp = makeFingerprint();\n\t\t\tfor (let i = 0; i < 4; i++) {\n\t\t\t\tcustom.recordFingerprint(fp);\n\t\t\t}\n\t\t\texpect(custom.isStuck().stuck).toBe(false);\n\n\t\t\tcustom.recordFingerprint(fp);\n\t\t\texpect(custom.isStuck().stuck).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('consecutive stagnant pages detection', () => {\n\t\ttest('detects stagnant pages with same URL and similar element count', () => {\n\t\t\tconst detector5 = new StallDetector({ maxStagnantPages: 5 });\n\t\t\tfor (let i = 0; i < 5; i++) {\n\t\t\t\t// Different domHash/scrollY so fingerprint hashing is distinct,\n\t\t\t\t// but same URL and elementCount triggers stagnant detection.\n\t\t\t\tdetector5.recordFingerprint(\n\t\t\t\t\tmakeFingerprint({\n\t\t\t\t\t\tdomHash: `hash_${i}`,\n\t\t\t\t\t\tscrollY: i * 200,\n\t\t\t\t\t\telementCount: 50,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst result = detector5.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\texpect(result.reason).toContain('stagnant');\n\t\t});\n\n\t\ttest('different URLs do not trigger stagnant detection', () => {\n\t\t\tfor (let i = 0; i < 5; i++) {\n\t\t\t\tdetector.recordFingerprint(\n\t\t\t\t\tmakeFingerprint({\n\t\t\t\t\t\turl: `https://example.com/page${i}`,\n\t\t\t\t\t\tdomHash: `hash_${i}`,\n\t\t\t\t\t\tscrollY: i * 200,\n\t\t\t\t\t\telementCount: 50,\n\t\t\t\t\t}),\n\t\t\t\t);\n\t\t\t}\n\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('escalating nudge messages', () => {\n\t\ttest('severity 0 for repetitions below 5', () => {\n\t\t\t// 3 repetitions -> gets flagged as stuck but severity 0\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\t}\n\t\t\tconst result = detector.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\texpect(result.severity).toBe(0);\n\t\t});\n\n\t\ttest('severity 1 at 5+ total repetitions via cycle detection', () => {\n\t\t\t// Cycle detection path uses getSeverity(this.totalRepetitions)\n\t\t\t// so accumulating enough totalRepetitions can reach severity 1.\n\t\t\tconst det = new StallDetector({ maxRepeatedActions: 3 });\n\n\t\t\t// First: accumulate 3 via repeated actions\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tdet.recordAction([clickAction(1)]);\n\t\t\t}\n\t\t\tdet.isStuck(); // totalRepetitions += 3\n\n\t\t\t// Break the trailing sequence, then trigger a 2-cycle\n\t\t\tdet.recordAction([clickAction(10)]);\n\t\t\t// A->B->A->B cycle adds 2 to totalRepetitions -> total 5\n\t\t\tdet.recordAction([clickAction(20)]);\n\t\t\tdet.recordAction([clickAction(10)]);\n\t\t\tdet.recordAction([clickAction(20)]);\n\t\t\tconst result = det.isStuck();\n\t\t\texpect(result.stuck).toBe(true);\n\t\t\t// totalRepetitions = 3 + 2 = 5, getSeverity(5) = 1\n\t\t\texpect(result.severity).toBe(1);\n\t\t});\n\n\t\ttest('nudge message contains appropriate text', () => {\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\t}\n\t\t\tconst msg = detector.getLoopNudgeMessage();\n\t\t\texpect(msg).toContain('Warning:');\n\t\t\texpect(msg.length).toBeGreaterThan(0);\n\t\t});\n\t});\n\n\tdescribe('action hash normalization', () => {\n\t\ttest('click actions normalized by index only', () => {\n\t\t\t// Two click actions with same index but different click counts\n\t\t\t// should both normalize to \"click:5\"\n\t\t\tconst d1 = new StallDetector();\n\t\t\tconst d2 = new StallDetector();\n\n\t\t\tconst act1: Command = { action: 'tap', index: 5, clickCount: 1 };\n\t\t\tconst act2: Command = { action: 'tap', index: 5, clickCount: 2 };\n\n\t\t\t// Record 3 of each in separate detectors\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\td1.recordAction([act1]);\n\t\t\t\td2.recordAction([act2]);\n\t\t\t}\n\n\t\t\t// Both should detect as stuck since click is normalized by index\n\t\t\texpect(d1.isStuck().stuck).toBe(true);\n\t\t\texpect(d2.isStuck().stuck).toBe(true);\n\t\t});\n\n\t\ttest('search queries normalized for order independence', () => {\n\t\t\t// \"best pizza NYC\" and \"NYC best pizza\" should produce same hash\n\t\t\tconst d = new StallDetector();\n\t\t\td.recordAction([searchGoogleAction('best pizza NYC')]);\n\t\t\td.recordAction([searchGoogleAction('NYC best pizza')]);\n\t\t\td.recordAction([searchGoogleAction('pizza best NYC')]);\n\n\t\t\texpect(d.isStuck().stuck).toBe(true);\n\t\t});\n\n\t\ttest('different navigate URLs not considered same action', () => {\n\t\t\tdetector.recordAction([navigateAction('https://a.com')]);\n\t\t\tdetector.recordAction([navigateAction('https://b.com')]);\n\t\t\tdetector.recordAction([navigateAction('https://c.com')]);\n\n\t\t\texpect(detector.isStuck().stuck).toBe(false);\n\t\t});\n\n\t\ttest('scroll actions include direction and index', () => {\n\t\t\t// Same direction, same index -> stuck\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tdetector.recordAction([scrollAction('down', 1)]);\n\t\t\t}\n\t\t\texpect(detector.isStuck().stuck).toBe(true);\n\t\t});\n\n\t\ttest('done actions include text prefix', () => {\n\t\t\tdetector.recordAction([doneAction('Task completed successfully')]);\n\t\t\tdetector.recordAction([doneAction('Task completed successfully')]);\n\t\t\tdetector.recordAction([doneAction('Task completed successfully')]);\n\n\t\t\texpect(detector.isStuck().stuck).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('reset', () => {\n\t\ttest('clears all history and repetitions', () => {\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tdetector.recordAction([clickAction(1)]);\n\t\t\t\tdetector.recordFingerprint(makeFingerprint());\n\t\t\t}\n\t\t\texpect(detector.isStuck().stuck).toBe(true);\n\n\t\t\tdetector.reset();\n\n\t\t\texpect(detector.isStuck().stuck).toBe(false);\n\t\t\texpect(detector.getTotalRepetitions()).toBe(0);\n\t\t\texpect(detector.getLoopNudgeMessage()).toBe('');\n\t\t});\n\t});\n\n\tdescribe('window size pruning', () => {\n\t\ttest('keeps action history within bounds', () => {\n\t\t\tconst smallWindow = new StallDetector({ windowSize: 5 });\n\n\t\t\t// Record 15 unique actions, then 3 repeated\n\t\t\tfor (let i = 0; i < 15; i++) {\n\t\t\t\tsmallWindow.recordAction([clickAction(i)]);\n\t\t\t}\n\n\t\t\t// Now repeat same action 3 times\n\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\tsmallWindow.recordAction([clickAction(99)]);\n\t\t\t}\n\n\t\t\t// Should still detect the repetition\n\t\t\texpect(smallWindow.isStuck().stuck).toBe(true);\n\t\t});\n\t});\n});\n\ndescribe('hashPageTree', () => {\n\ttest('produces consistent hash for same input', () => {\n\t\tconst hash1 = hashPageTree('<div>hello</div>');\n\t\tconst hash2 = hashPageTree('<div>hello</div>');\n\t\texpect(hash1).toBe(hash2);\n\t});\n\n\ttest('produces different hash for different input', () => {\n\t\tconst hash1 = hashPageTree('<div>hello</div>');\n\t\tconst hash2 = hashPageTree('<div>world</div>');\n\t\texpect(hash1).not.toBe(hash2);\n\t});\n\n\ttest('returns a base-36 string', () => {\n\t\tconst hash = hashPageTree('some content');\n\t\texpect(typeof hash).toBe('string');\n\t\t// Base-36 characters: 0-9, a-z, and optional leading minus\n\t\texpect(hash).toMatch(/^-?[0-9a-z]+$/);\n\t});\n\n\ttest('handles empty string', () => {\n\t\tconst hash = hashPageTree('');\n\t\texpect(hash).toBe('0');\n\t});\n});\n\ndescribe('hashTextContent', () => {\n\ttest('produces consistent hash for same input', () => {\n\t\tconst hash1 = hashTextContent('Hello World');\n\t\tconst hash2 = hashTextContent('Hello World');\n\t\texpect(hash1).toBe(hash2);\n\t});\n\n\ttest('normalizes case: same hash for different casing', () => {\n\t\tconst hash1 = hashTextContent('Hello World');\n\t\tconst hash2 = hashTextContent('hello world');\n\t\texpect(hash1).toBe(hash2);\n\t});\n\n\ttest('normalizes whitespace: collapses multiple spaces', () => {\n\t\tconst hash1 = hashTextContent('hello    world');\n\t\tconst hash2 = hashTextContent('hello world');\n\t\texpect(hash1).toBe(hash2);\n\t});\n\n\ttest('removes punctuation for content-based matching', () => {\n\t\tconst hash1 = hashTextContent('hello, world!');\n\t\tconst hash2 = hashTextContent('hello world');\n\t\texpect(hash1).toBe(hash2);\n\t});\n\n\ttest('handles empty string', () => {\n\t\tconst hash = hashTextContent('');\n\t\texpect(hash).toBe('0');\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/agent/stall-detector.ts",
    "content": "import type { Command } from '../commands/types.js';\n\n// ── Enhanced Page Fingerprint ──\n\nexport interface PageSignature {\n\turl: string;\n\tdomHash: string;\n\tscrollY: number;\n\telementCount?: number;\n\ttextHash?: string;\n}\n\nexport interface StallDetectorConfig {\n\tmaxRepeatedActions: number;\n\tmaxRepeatedFingerprints: number;\n\twindowSize: number;\n\t/** Number of consecutive stagnant pages before raising stall alert */\n\tmaxStagnantPages: number;\n}\n\nconst DEFAULT_OPTIONS: StallDetectorConfig = {\n\tmaxRepeatedActions: 3,\n\tmaxRepeatedFingerprints: 3,\n\twindowSize: 10,\n\tmaxStagnantPages: 5,\n};\n\nexport interface StallCheckResult {\n\tstuck: boolean;\n\treason?: string;\n\t/** Escalation level: 0 = not stuck, 1 = mild, 2 = moderate, 3 = severe */\n\tseverity: number;\n}\n\n/**\n * Nudge messages that escalate in urgency as repetitions increase.\n * Thresholds: 5 repetitions = mild, 8 = moderate, 12 = severe.\n */\nconst ESCALATING_NUDGES = [\n\t{\n\t\tthreshold: 5,\n\t\tseverity: 1,\n\t\tmessage:\n\t\t\t'You seem to be repeating similar actions. Consider trying a different approach:\\n' +\n\t\t\t'- Click a different element\\n' +\n\t\t\t'- Try an alternative navigation path\\n' +\n\t\t\t'- Use search to find what you need',\n\t},\n\t{\n\t\tthreshold: 8,\n\t\tseverity: 2,\n\t\tmessage:\n\t\t\t'WARNING: You are stuck in a loop and have been repeating actions. You MUST change your approach:\\n' +\n\t\t\t'- Navigate to a completely different page\\n' +\n\t\t\t'- Try a fundamentally different strategy\\n' +\n\t\t\t'- If the current approach is not working, consider using the done action to report the issue',\n\t},\n\t{\n\t\tthreshold: 12,\n\t\tseverity: 3,\n\t\tmessage:\n\t\t\t'CRITICAL: You have been stuck for many steps. This approach is NOT working.\\n' +\n\t\t\t'You MUST either:\\n' +\n\t\t\t'1. Use the done action to report that the task cannot be completed with your current approach\\n' +\n\t\t\t'2. Navigate to a completely different website or page\\n' +\n\t\t\t'3. Try a radically different interaction method\\n' +\n\t\t\t'Do NOT repeat the same actions again.',\n\t},\n];\n\nexport class StallDetector {\n\tprivate actionHistory: string[] = [];\n\tprivate fingerprintHistory: PageSignature[] = [];\n\tprivate fingerprintHashes: string[] = [];\n\tprivate options: StallDetectorConfig;\n\tprivate totalRepetitions = 0;\n\n\tconstructor(options?: Partial<StallDetectorConfig>) {\n\t\tthis.options = { ...DEFAULT_OPTIONS, ...options };\n\t}\n\n\trecordAction(actions: Command[]): void {\n\t\tconst key = this.normalizeActionHash(actions);\n\t\tthis.actionHistory.push(key);\n\n\t\t// Keep only the window\n\t\tif (this.actionHistory.length > this.options.windowSize * 2) {\n\t\t\tthis.actionHistory = this.actionHistory.slice(-this.options.windowSize * 2);\n\t\t}\n\t}\n\n\trecordFingerprint(fingerprint: PageSignature): void {\n\t\tthis.fingerprintHistory.push(fingerprint);\n\t\tconst hash = this.hashFingerprint(fingerprint);\n\t\tthis.fingerprintHashes.push(hash);\n\n\t\tif (this.fingerprintHistory.length > this.options.windowSize * 2) {\n\t\t\tthis.fingerprintHistory = this.fingerprintHistory.slice(-this.options.windowSize * 2);\n\t\t\tthis.fingerprintHashes = this.fingerprintHashes.slice(-this.options.windowSize * 2);\n\t\t}\n\t}\n\n\tisStuck(): StallCheckResult {\n\t\t// Check for repeated actions\n\t\tconst actionRepetitions = this.countTrailingRepetitions(this.actionHistory);\n\n\t\tif (actionRepetitions >= this.options.maxRepeatedActions) {\n\t\t\tthis.totalRepetitions += actionRepetitions;\n\t\t\tconst severity = this.getSeverity(actionRepetitions);\n\t\t\treturn {\n\t\t\t\tstuck: true,\n\t\t\t\treason: `Same action repeated ${actionRepetitions} times`,\n\t\t\t\tseverity,\n\t\t\t};\n\t\t}\n\n\t\t// Check for action cycle (A -> B -> A -> B)\n\t\tif (this.actionHistory.length >= 4) {\n\t\t\tconst last4 = this.actionHistory.slice(-4);\n\t\t\tif (last4[0] === last4[2] && last4[1] === last4[3]) {\n\t\t\t\tthis.totalRepetitions += 2;\n\t\t\t\treturn {\n\t\t\t\t\tstuck: true,\n\t\t\t\t\treason: 'Detected action cycle (alternating between two actions)',\n\t\t\t\t\tseverity: this.getSeverity(this.totalRepetitions),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for triple cycle (A -> B -> C -> A -> B -> C)\n\t\tif (this.actionHistory.length >= 6) {\n\t\t\tconst last6 = this.actionHistory.slice(-6);\n\t\t\tif (\n\t\t\t\tlast6[0] === last6[3] &&\n\t\t\t\tlast6[1] === last6[4] &&\n\t\t\t\tlast6[2] === last6[5]\n\t\t\t) {\n\t\t\t\tthis.totalRepetitions += 3;\n\t\t\t\treturn {\n\t\t\t\t\tstuck: true,\n\t\t\t\t\treason: 'Detected 3-step action cycle',\n\t\t\t\t\tseverity: this.getSeverity(this.totalRepetitions),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Check for repeated fingerprints (same page state)\n\t\tconst fpRepetitions = this.countTrailingRepetitions(this.fingerprintHashes);\n\n\t\tif (fpRepetitions >= this.options.maxRepeatedFingerprints) {\n\t\t\tthis.totalRepetitions += fpRepetitions;\n\t\t\treturn {\n\t\t\t\tstuck: true,\n\t\t\t\treason: `Page state unchanged for ${fpRepetitions} steps`,\n\t\t\t\tseverity: this.getSeverity(fpRepetitions),\n\t\t\t};\n\t\t}\n\n\t\t// Check for consecutive stagnant pages (URL + elementCount unchanged)\n\t\tconst stagnantCount = this.countConsecutiveStagnantPages();\n\t\tif (stagnantCount >= this.options.maxStagnantPages) {\n\t\t\tthis.totalRepetitions += stagnantCount;\n\t\t\treturn {\n\t\t\t\tstuck: true,\n\t\t\t\treason: `Page appears stagnant for ${stagnantCount} consecutive steps (same URL and element structure)`,\n\t\t\t\tseverity: this.getSeverity(stagnantCount),\n\t\t\t};\n\t\t}\n\n\t\treturn { stuck: false, severity: 0 };\n\t}\n\n\tgetLoopNudgeMessage(): string {\n\t\tconst result = this.isStuck();\n\t\tif (!result.stuck) {\n\t\t\treturn '';\n\t\t}\n\n\t\t// Find the appropriate escalating nudge\n\t\tconst nudge = this.getEscalatingNudge();\n\t\treturn `Warning: ${result.reason ?? 'You appear to be stuck'}.\\n${nudge}`;\n\t}\n\n\t/** Get total number of detected repetitions across the session */\n\tgetTotalRepetitions(): number {\n\t\treturn this.totalRepetitions;\n\t}\n\n\treset(): void {\n\t\tthis.actionHistory = [];\n\t\tthis.fingerprintHistory = [];\n\t\tthis.fingerprintHashes = [];\n\t\tthis.totalRepetitions = 0;\n\t}\n\n\t// ── Private helpers ──\n\n\t/**\n\t * Normalize action hash for better deduplication:\n\t * - Sort search token strings for order-independent matching\n\t * - Use element index (not full params) for click actions\n\t * - Use URL (not full params) for navigate actions\n\t */\n\tprivate normalizeActionHash(actions: Command[]): string {\n\t\tconst normalized = actions.map((action) => {\n\t\t\tswitch (action.action) {\n\t\t\t\tcase 'tap':\n\t\t\t\t\t// Normalize click: use index as the primary key, ignore transient params\n\t\t\t\t\treturn `click:${action.index}`;\n\n\t\t\t\tcase 'type_text':\n\t\t\t\t\treturn `input_text:${action.index}:${action.text}`;\n\n\t\t\t\tcase 'navigate':\n\t\t\t\t\t// Normalize: just the URL\n\t\t\t\t\treturn `go_to_url:${action.url}`;\n\n\t\t\t\tcase 'web_search':\n\t\t\t\t\t// Sort search terms for order-independent matching\n\t\t\t\t\treturn `search_google:${this.normalizeSearchQuery(action.query)}`;\n\n\t\t\t\tcase 'search': {\n\t\t\t\t\tconst q = 'query' in action ? String((action as Record<string, unknown>).query) : '';\n\t\t\t\t\treturn `search_page:${this.normalizeSearchQuery(q)}`;\n\t\t\t\t}\n\n\t\t\t\tcase 'scroll':\n\t\t\t\t\treturn `scroll:${action.direction}:${action.index ?? 'page'}`;\n\n\t\t\t\tcase 'finish':\n\t\t\t\t\treturn `done:${action.text.slice(0, 50)}`;\n\n\t\t\t\tdefault:\n\t\t\t\t\t// Generic fallback: action name + stringified params\n\t\t\t\t\treturn JSON.stringify(action);\n\t\t\t}\n\t\t});\n\n\t\treturn normalized.join('|');\n\t}\n\n\t/**\n\t * Normalize a search query by lowercasing and sorting tokens.\n\t * \"best pizza NYC\" and \"NYC best pizza\" produce the same hash.\n\t */\n\tprivate normalizeSearchQuery(query: string): string {\n\t\treturn query\n\t\t\t.toLowerCase()\n\t\t\t.split(/\\s+/)\n\t\t\t.filter(Boolean)\n\t\t\t.sort()\n\t\t\t.join(' ');\n\t}\n\n\t/**\n\t * Hash a page fingerprint for quick equality checks.\n\t * Includes URL, element count, text hash, and scroll position bucket.\n\t */\n\tprivate hashFingerprint(fp: PageSignature): string {\n\t\tconst scrollBucket = Math.floor(fp.scrollY / 200);\n\t\tconst parts = [\n\t\t\tfp.url,\n\t\t\tfp.domHash,\n\t\t\tscrollBucket.toString(),\n\t\t];\n\t\tif (fp.elementCount !== undefined) {\n\t\t\tparts.push(`e:${fp.elementCount}`);\n\t\t}\n\t\tif (fp.textHash) {\n\t\t\tparts.push(`t:${fp.textHash}`);\n\t\t}\n\t\treturn parts.join('|');\n\t}\n\n\t/**\n\t * Count how many trailing entries in a history array are identical.\n\t */\n\tprivate countTrailingRepetitions(history: string[]): number {\n\t\tif (history.length === 0) return 0;\n\t\tconst last = history[history.length - 1];\n\t\tlet count = 0;\n\t\tfor (let i = history.length - 1; i >= 0; i--) {\n\t\t\tif (history[i] === last) {\n\t\t\t\tcount++;\n\t\t\t} else {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\t\treturn count;\n\t}\n\n\t/**\n\t * Count consecutive stagnant pages: same URL and similar element count.\n\t * \"Similar\" means within 5% or 10 elements of each other.\n\t */\n\tprivate countConsecutiveStagnantPages(): number {\n\t\tif (this.fingerprintHistory.length < 2) return 0;\n\n\t\tconst latest = this.fingerprintHistory[this.fingerprintHistory.length - 1];\n\t\tlet count = 1;\n\n\t\tfor (let i = this.fingerprintHistory.length - 2; i >= 0; i--) {\n\t\t\tconst fp = this.fingerprintHistory[i];\n\t\t\tif (fp.url !== latest.url) break;\n\n\t\t\tif (latest.elementCount !== undefined && fp.elementCount !== undefined) {\n\t\t\t\tconst diff = Math.abs(latest.elementCount - fp.elementCount);\n\t\t\t\tconst threshold = Math.max(10, Math.floor(latest.elementCount * 0.05));\n\t\t\t\tif (diff > threshold) break;\n\t\t\t}\n\n\t\t\tcount++;\n\t\t}\n\n\t\treturn count;\n\t}\n\n\t/**\n\t * Map repetition count to severity level (0-3).\n\t */\n\tprivate getSeverity(repetitions: number): number {\n\t\tif (repetitions >= 12) return 3;\n\t\tif (repetitions >= 8) return 2;\n\t\tif (repetitions >= 5) return 1;\n\t\treturn 0;\n\t}\n\n\t/**\n\t * Get the appropriate escalating nudge message based on total repetitions.\n\t */\n\tprivate getEscalatingNudge(): string {\n\t\t// Pick the highest-threshold nudge that applies\n\t\tlet bestNudge = ESCALATING_NUDGES[0];\n\t\tfor (const nudge of ESCALATING_NUDGES) {\n\t\t\tif (this.totalRepetitions >= nudge.threshold) {\n\t\t\t\tbestNudge = nudge;\n\t\t\t}\n\t\t}\n\t\treturn bestNudge.message;\n\t}\n}\n\n/**\n * Compute a fast 32-bit hash of a DOM tree string.\n * Used for quick fingerprint comparison.\n */\nexport function hashPageTree(domTree: string): string {\n\tlet hash = 0;\n\tfor (let i = 0; i < domTree.length; i++) {\n\t\tconst char = domTree.charCodeAt(i);\n\t\thash = ((hash << 5) - hash + char) | 0;\n\t}\n\treturn hash.toString(36);\n}\n\n/**\n * Compute a content-based text hash from visible page text.\n * More robust than DOM hash for detecting actual content changes.\n */\nexport function hashTextContent(text: string): string {\n\t// Normalize: lowercase, collapse whitespace, remove punctuation\n\tconst normalized = text\n\t\t.toLowerCase()\n\t\t.replace(/\\s+/g, ' ')\n\t\t.replace(/[^\\w\\s]/g, '')\n\t\t.trim();\n\n\tlet hash = 0;\n\tfor (let i = 0; i < normalized.length; i++) {\n\t\tconst char = normalized.charCodeAt(i);\n\t\thash = ((hash << 5) - hash + char) | 0;\n\t}\n\treturn hash.toString(36);\n}\n"
  },
  {
    "path": "packages/core/src/agent/types.ts",
    "content": "import { z } from 'zod';\nimport type { Command, CommandResult } from '../commands/types.js';\nimport type { ViewportSnapshot, ViewportHistory } from '../viewport/types.js';\nimport type { InferenceUsage } from '../model/types.js';\n\n// ── Agent Settings ──\n\nexport interface AgentConfig {\n\ttask: string;\n\tstepLimit: number;\n\tcommandsPerStep: number;\n\tfailureThreshold: number;\n\tretryDelay: number;\n\tenableScreenshots: boolean;\n\tenableScreenshotsForTextExtraction: boolean;\n\tcontextWindowSize: number;\n\tcapturedAttributes: string[];\n\tcommandDelayMs: number;\n\tallowedUrls?: string[];\n\tblockedUrls?: string[];\n\ttraceOutputPath?: string;\n\treplayOutputPath?: string;\n\tstrategyInterval: number;\n\tmaskedValues?: Record<string, string>;\n\toverrideInstructionBuilder?: string;\n\textendInstructionBuilder?: string;\n\tinlineCommands: boolean;\n\tconversationCompaction?: CompactionPolicy;\n\n\t// Extended thinking\n\tenableDeepReasoning: boolean;\n\treasoningBudget: number;\n\n\t// Flash mode\n\tcompactMode: boolean;\n\n\t// Timeouts (0 = no timeout)\n\tstepDeadlineMs: number;\n\tmodelDeadlineMs: number;\n\n\t// Planning system\n\tenableStrategy: boolean;\n\trestrategizeOnStall: boolean;\n\n\t// URL extraction from task text\n\tautoNavigateToUrls: boolean;\n\n\t// Coordinate clicking auto-enable per model\n\tautoEnableCoordinateClicking: boolean;\n\n\t// Judge integration\n\tenableEvaluation: boolean;\n\tenableSimpleJudge: boolean;\n\texpectedOutcome?: string;\n\n\t// Demo mode\n\tenableVisualTracer: boolean;\n\n\t// Initial actions before main loop\n\tpreflightCommands: Command[];\n\n\t// Save conversation per step\n\tconversationOutputPath?: string;\n\n\t// Dynamic action schema rebuild per step\n\tdynamicCommandSchema: boolean;\n}\n\nexport const DEFAULT_AGENT_CONFIG: AgentConfig = {\n\ttask: '',\n\tstepLimit: 100,\n\tcommandsPerStep: 10,\n\tfailureThreshold: 5,\n\tretryDelay: 10,\n\tenableScreenshots: true,\n\tenableScreenshotsForTextExtraction: false,\n\tcontextWindowSize: 128000,\n\tcapturedAttributes: [\n\t\t'title', 'type', 'name', 'role', 'tabindex',\n\t\t'aria-label', 'placeholder', 'value', 'alt', 'aria-expanded',\n\t],\n\tcommandDelayMs: 1,\n\tstrategyInterval: 0,\n\tinlineCommands: true,\n\n\tenableDeepReasoning: false,\n\treasoningBudget: 10000,\n\tcompactMode: false,\n\tstepDeadlineMs: 0,\n\tmodelDeadlineMs: 0,\n\tenableStrategy: false,\n\trestrategizeOnStall: false,\n\tautoNavigateToUrls: true,\n\tautoEnableCoordinateClicking: false,\n\tenableEvaluation: false,\n\tenableSimpleJudge: false,\n\tenableVisualTracer: false,\n\tpreflightCommands: [],\n\tdynamicCommandSchema: false,\n};\n\n// ── Message Compaction Settings ──\n\nexport interface CompactionPolicy {\n\t/** Run LLM-based compaction every N steps (0 = disabled). */\n\tinterval: number;\n\t/** Model ID to use for summarization. If omitted, uses the agent's main model. */\n\tmodel?: string;\n\t/** Max tokens for the compaction summary output. */\n\tmaxTokens: number;\n\t/** Target token budget after compaction. Defaults to 60% of contextWindowSize. */\n\ttargetTokens?: number;\n}\n\n// ── Agent Brain (LLM thought process) ──\n\nexport const ReasoningSchema = z.object({\n\tevaluation: z.string().describe('Assessment of the current state'),\n\tmemory: z.string().describe('Important information to remember'),\n\tnextGoal: z.string().describe('Next immediate goal'),\n});\n\nexport type Reasoning = z.infer<typeof ReasoningSchema>;\n\n// ── Agent Output (what LLM returns each step) ──\n\nexport const AgentDecisionSchema = z.object({\n\tcurrentState: ReasoningSchema,\n\tactions: z.array(z.record(z.unknown())).describe('Actions to execute'),\n\tthinking: z.string().optional().describe('Extended thinking / chain-of-thought'),\n\tevaluation: z.string().optional().describe('Top-level evaluation (mirrors currentState.evaluation for convenience)'),\n\tmemory: z.string().optional().describe('Top-level memory note (mirrors currentState.memory for convenience)'),\n\tnextGoal: z.string().optional().describe('Top-level next goal (mirrors currentState.nextGoal for convenience)'),\n});\n\nexport type AgentDecision = z.infer<typeof AgentDecisionSchema>;\n\n/**\n * Simplified output schema for flash / lightweight models that skip extended thinking.\n * Only contains the essential fields: current state evaluation + actions.\n */\nexport const AgentDecisionCompactSchema = z.object({\n\tcurrentState: z.object({\n\t\tevaluation: z.string().describe('Brief assessment'),\n\t\tnextGoal: z.string().describe('Next immediate goal'),\n\t}),\n\tactions: z.array(z.record(z.unknown())).describe('Actions to execute'),\n});\n\nexport type AgentDecisionCompact = z.infer<typeof AgentDecisionCompactSchema>;\n\n/**\n * Output variant that omits the extended thinking field.\n * Used when the model does not support or should not produce chain-of-thought.\n */\nexport const AgentDecisionDirectSchema = z.object({\n\tcurrentState: ReasoningSchema,\n\tactions: z.array(z.record(z.unknown())).describe('Actions to execute'),\n});\n\nexport type AgentDecisionDirect = z.infer<typeof AgentDecisionDirectSchema>;\n\n// ── Step Metadata ──\n\nexport interface StepTelemetry {\n\t/** Step number (1-based). */\n\tstepNumber: number;\n\t/** Wall-clock duration of this step in milliseconds. */\n\tdurationMs: number;\n\t/** Token usage for this step. */\n\tinputTokens: number;\n\toutputTokens: number;\n\t/** Number of actions attempted in this step. */\n\tactionCount: number;\n\t/** URL at the start of this step. */\n\turl?: string;\n\t/** Path to screenshot file if one was saved. */\n\tscreenshotPath?: string;\n\t/** Timestamp when the step started. */\n\tstartedAt: number;\n\t/** Timestamp when the step completed. */\n\tcompletedAt: number;\n}\n\n// ── Detected Variable ──\n\n/**\n * A variable or piece of data detected during agent execution,\n * e.g. a confirmation number, order ID, or extracted value.\n */\nexport interface ExtractedVariable {\n\t/** Human-readable name (e.g. \"order_id\", \"confirmation_number\"). */\n\tname: string;\n\t/** The detected value as a string. */\n\tvalue: string;\n\t/** Where this variable was found. */\n\tsource: 'extraction' | 'action_result' | 'page_content' | 'user_input';\n\t/** Step number where this variable was detected. */\n\tstep?: number;\n}\n\n// ── Agent State ──\n\nexport interface AgentState {\n\tstep: number;\n\tstepLimit: number;\n\tfailureCount: number;\n\tconsecutiveFailures: number;\n\tisRunning: boolean;\n\tisPaused: boolean;\n\tisDone: boolean;\n\tlastResult?: string;\n\tcurrentUrl?: string;\n\ttotalInputTokens: number;\n\ttotalOutputTokens: number;\n\tcumulativeCost: AccumulatedCost;\n\tcurrentPlan?: string;\n\tlastPlanStep?: number;\n}\n\n// ── History ──\n\nexport interface StepRecord {\n\tstep: number;\n\ttimestamp: number;\n\tbrowserState: ViewportHistory;\n\tagentOutput: AgentDecision;\n\tactionResults: CommandResult[];\n\terror?: string;\n\tusage?: InferenceUsage;\n\tduration: number;\n\tmetadata?: StepTelemetry;\n\tdetectedVariables?: ExtractedVariable[];\n}\n\n/**\n * Concrete class wrapping agent execution history with helper methods.\n *\n * Replaces the plain ExecutionLog interface so that consumers can call\n * convenience methods like `finalResult()`, `isDone()`, `urls()`, etc.\n */\nexport class ExecutionLog {\n\treadonly entries: StepRecord[];\n\treadonly task: string;\n\treadonly startTime: number;\n\tendTime?: number;\n\ttotalDuration?: number;\n\ttotalSteps: number;\n\ttotalInputTokens: number;\n\ttotalOutputTokens: number;\n\n\tconstructor(init: {\n\t\tentries?: StepRecord[];\n\t\ttask: string;\n\t\tstartTime?: number;\n\t}) {\n\t\tthis.entries = init.entries ?? [];\n\t\tthis.task = init.task;\n\t\tthis.startTime = init.startTime ?? Date.now();\n\t\tthis.totalSteps = this.entries.length;\n\t\tthis.totalInputTokens = 0;\n\t\tthis.totalOutputTokens = 0;\n\t\tthis.recomputeTotals();\n\t}\n\n\t/** Recalculate aggregate totals from entries. Called internally and from static factories. */\n\trecomputeTotals(): void {\n\t\tthis.totalSteps = this.entries.length;\n\t\tthis.totalInputTokens = 0;\n\t\tthis.totalOutputTokens = 0;\n\t\tfor (const entry of this.entries) {\n\t\t\tif (entry.usage) {\n\t\t\t\tthis.totalInputTokens += entry.usage.inputTokens;\n\t\t\t\tthis.totalOutputTokens += entry.usage.outputTokens;\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Push a new entry and update totals. */\n\taddEntry(entry: StepRecord): void {\n\t\tthis.entries.push(entry);\n\t\tthis.recomputeTotals();\n\t}\n\n\t/** Mark the history as finished. */\n\tfinish(): void {\n\t\tthis.endTime = Date.now();\n\t\tthis.totalDuration = this.endTime - this.startTime;\n\t\tthis.recomputeTotals();\n\t}\n\n\t/**\n\t * Returns the final result text from the last \"done\" action, or undefined\n\t * if the agent never completed with a done action.\n\t */\n\tfinalResult(): string | undefined {\n\t\tfor (let i = this.entries.length - 1; i >= 0; i--) {\n\t\t\tconst entry = this.entries[i];\n\t\t\tfor (const result of entry.actionResults) {\n\t\t\t\tif (result.isDone && result.extractedContent) {\n\t\t\t\t\treturn result.extractedContent;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\t/**\n\t * Whether the agent reached a \"done\" action at any point.\n\t */\n\tisDone(): boolean {\n\t\treturn this.entries.some((entry) =>\n\t\t\tentry.actionResults.some((r) => r.isDone),\n\t\t);\n\t}\n\n\t/**\n\t * Deduplicated list of all URLs visited during execution (in order of first visit).\n\t */\n\turls(): string[] {\n\t\tconst seen = new Set<string>();\n\t\tconst result: string[] = [];\n\t\tfor (const entry of this.entries) {\n\t\t\tconst url = entry.browserState.url;\n\t\t\tif (url && !seen.has(url)) {\n\t\t\t\tseen.add(url);\n\t\t\t\tresult.push(url);\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * All screenshot base64 strings collected during execution (chronological).\n\t */\n\tscreenshots(): string[] {\n\t\tconst result: string[] = [];\n\t\tfor (const entry of this.entries) {\n\t\t\tif (entry.browserState.screenshot) {\n\t\t\t\tresult.push(entry.browserState.screenshot);\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * All errors encountered during execution.\n\t */\n\terrors(): string[] {\n\t\tconst result: string[] = [];\n\t\tfor (const entry of this.entries) {\n\t\t\tif (entry.error) {\n\t\t\t\tresult.push(entry.error);\n\t\t\t}\n\t\t\tfor (const ar of entry.actionResults) {\n\t\t\t\tif (ar.error) {\n\t\t\t\t\tresult.push(ar.error);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * All detected variables across all steps.\n\t */\n\tallExtractedVariables(): ExtractedVariable[] {\n\t\tconst result: ExtractedVariable[] = [];\n\t\tfor (const entry of this.entries) {\n\t\t\tif (entry.detectedVariables) {\n\t\t\t\tresult.push(...entry.detectedVariables);\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\t/**\n\t * Serialize the full history to a JSON-compatible object for saving to disk.\n\t */\n\ttoJSON(): Record<string, unknown> {\n\t\treturn {\n\t\t\ttask: this.task,\n\t\t\tstartTime: this.startTime,\n\t\t\tendTime: this.endTime,\n\t\t\ttotalDuration: this.totalDuration,\n\t\t\ttotalSteps: this.totalSteps,\n\t\t\ttotalInputTokens: this.totalInputTokens,\n\t\t\ttotalOutputTokens: this.totalOutputTokens,\n\t\t\tentries: this.entries.map((e) => ({\n\t\t\t\t...e,\n\t\t\t\t// Strip screenshot data from serialized form to keep file size down\n\t\t\t\tbrowserState: {\n\t\t\t\t\t...e.browserState,\n\t\t\t\t\tscreenshot: e.browserState.screenshot ? '[screenshot omitted]' : undefined,\n\t\t\t\t},\n\t\t\t})),\n\t\t};\n\t}\n\n\t/**\n\t * Save the history to a file at the given path (JSON format).\n\t * Returns the written path.\n\t */\n\tasync saveToFile(filePath: string): Promise<string> {\n\t\tconst { writeFile, mkdir } = await import('node:fs/promises');\n\t\tconst { dirname } = await import('node:path');\n\t\tawait mkdir(dirname(filePath), { recursive: true });\n\t\tconst json = JSON.stringify(this.toJSON(), null, 2);\n\t\tawait writeFile(filePath, json, 'utf-8');\n\t\treturn filePath;\n\t}\n\n\t/**\n\t * Load history from a JSON file. Screenshots will be placeholders.\n\t */\n\tstatic async loadFromFile(filePath: string): Promise<ExecutionLog> {\n\t\tconst { readFile } = await import('node:fs/promises');\n\t\tconst raw = await readFile(filePath, 'utf-8');\n\t\tconst data = JSON.parse(raw) as Record<string, unknown>;\n\t\tconst list = new ExecutionLog({\n\t\t\ttask: (data.task as string) ?? '',\n\t\t\tstartTime: (data.startTime as number) ?? Date.now(),\n\t\t});\n\t\tlist.endTime = data.endTime as number | undefined;\n\t\tlist.totalDuration = data.totalDuration as number | undefined;\n\n\t\tconst entries = (data.entries ?? []) as StepRecord[];\n\t\tfor (const entry of entries) {\n\t\t\tlist.entries.push(entry);\n\t\t}\n\t\tlist.recomputeTotals();\n\t\treturn list;\n\t}\n}\n\n// ── Plan ──\n\nexport const PlanStepSchema = z.object({\n\tid: z.number(),\n\tdescription: z.string(),\n\tstatus: z.enum(['pending', 'in_progress', 'completed', 'failed', 'blocked', 'skipped']),\n\tnote: z.string().optional(),\n});\n\nexport type PlanStep = z.infer<typeof PlanStepSchema>;\n\nexport const StrategyPlanSchema = z.object({\n\titems: z.array(PlanStepSchema),\n});\n\n// ── Judgement ──\n\nexport const EvaluationResultSchema = z.object({\n\tisComplete: z.boolean(),\n\treason: z.string(),\n\tconfidence: z.number().min(0).max(1),\n\tverdict: z.string().optional().describe('Short human-readable verdict (e.g. \"success\", \"partial\", \"failed\")'),\n\tfailureReason: z.string().optional().describe('Detailed reason if the task failed'),\n\timpossibleTask: z.boolean().optional().describe('Whether the task appears impossible to complete'),\n\treachedCaptcha: z.boolean().optional().describe('Whether a CAPTCHA was encountered that blocked progress'),\n});\n\nexport type EvaluationResult = z.infer<typeof EvaluationResultSchema>;\n\n/**\n * Lightweight judgement result for simple pass/fail evaluation\n * without confidence scoring or detailed analysis.\n */\nexport const QuickCheckResultSchema = z.object({\n\tpassed: z.boolean(),\n\treason: z.string(),\n\tshouldRetry: z.boolean().optional().describe('Whether the agent should retry with a different approach'),\n});\n\nexport type QuickCheckResult = z.infer<typeof QuickCheckResultSchema>;\n\n// ── Cost Tracking ──\n\nexport interface StepCostBreakdown {\n\tinputCost: number;\n\toutputCost: number;\n\ttotalCost: number;\n}\n\nexport interface AccumulatedCost {\n\ttotalInputTokens: number;\n\ttotalOutputTokens: number;\n\ttotalInputCost: number;\n\ttotalOutputCost: number;\n\ttotalCost: number;\n}\n\n/** Per-model pricing in USD per 1M tokens */\nexport interface PricingTable {\n\tinputPer1M: number;\n\toutputPer1M: number;\n}\n\nexport const PRICING_TABLE: Record<string, PricingTable> = {\n\t'gpt-4o': { inputPer1M: 2.5, outputPer1M: 10 },\n\t'gpt-4o-mini': { inputPer1M: 0.15, outputPer1M: 0.6 },\n\t'gpt-4-turbo': { inputPer1M: 10, outputPer1M: 30 },\n\t'claude-3-opus': { inputPer1M: 15, outputPer1M: 75 },\n\t'claude-3-5-sonnet': { inputPer1M: 3, outputPer1M: 15 },\n\t'claude-3-5-haiku': { inputPer1M: 0.8, outputPer1M: 4 },\n\t'claude-3-haiku': { inputPer1M: 0.25, outputPer1M: 1.25 },\n\t'gemini-2.0-flash': { inputPer1M: 0.1, outputPer1M: 0.4 },\n\t'gemini-1.5-pro': { inputPer1M: 1.25, outputPer1M: 5 },\n\t'gemini-1.5-flash': { inputPer1M: 0.075, outputPer1M: 0.3 },\n};\n\nexport function calculateStepCost(\n\tinputTokens: number,\n\toutputTokens: number,\n\tmodelId: string,\n): StepCostBreakdown | undefined {\n\tlet pricing: PricingTable | undefined;\n\tfor (const [key, value] of Object.entries(PRICING_TABLE)) {\n\t\tif (modelId.startsWith(key)) {\n\t\t\tpricing = value;\n\t\t\tbreak;\n\t\t}\n\t}\n\tif (!pricing) return undefined;\n\n\tconst inputCost = (inputTokens / 1_000_000) * pricing.inputPer1M;\n\tconst outputCost = (outputTokens / 1_000_000) * pricing.outputPer1M;\n\treturn { inputCost, outputCost, totalCost: inputCost + outputCost };\n}\n\n// ── Plan Update ──\n\nexport const PlanRevisionSchema = z.object({\n\tplan: z.string().describe('Updated plan based on current progress'),\n\treasoning: z.string().describe('Why the plan was updated'),\n});\n\nexport type PlanRevision = z.infer<typeof PlanRevisionSchema>;\n\n// ── Model capability helpers ──\n\nconst EXTENDED_THINKING_MODELS = [\n\t'claude-3-5-sonnet',\n\t'claude-3-opus',\n\t'claude-3-7-sonnet',\n\t'claude-4',\n\t'o1',\n\t'o1-pro',\n\t'o3',\n\t'o3-mini',\n\t'gemini-2.0-flash-thinking',\n\t'deepseek-r1',\n];\n\nexport function supportsDeepReasoning(modelId: string): boolean {\n\treturn EXTENDED_THINKING_MODELS.some((m) => modelId.includes(m));\n}\n\nconst COORDINATE_CLICK_MODELS = [\n\t'gpt-4o',\n\t'claude-3-5-sonnet',\n\t'claude-4',\n\t'gemini-2.0',\n\t'gemini-1.5-pro',\n];\n\nexport function supportsCoordinateMode(modelId: string): boolean {\n\treturn COORDINATE_CLICK_MODELS.some((m) => modelId.includes(m));\n}\n\nconst FLASH_MODELS = [\n\t'gpt-4o-mini',\n\t'claude-3-haiku',\n\t'claude-3-5-haiku',\n\t'gemini-1.5-flash',\n\t'gemini-2.0-flash',\n];\n\nexport function isCompactModel(modelId: string): boolean {\n\treturn FLASH_MODELS.some((m) => modelId.includes(m));\n}\n\n// ── Agent Run Result ──\n\nexport interface RunOutcome {\n\tfinalResult?: string;\n\tsuccess: boolean;\n\thistory: ExecutionLog;\n\terrors: string[];\n\tdetectedVariables?: ExtractedVariable[];\n\tjudgement?: EvaluationResult;\n\tsimpleJudgement?: QuickCheckResult;\n\ttotalCost?: AccumulatedCost;\n}\n"
  },
  {
    "path": "packages/core/src/bridge/adapter.ts",
    "content": "import { z, type ZodTypeAny } from 'zod';\nimport type { CommandExecutor } from '../commands/executor.js';\n\nexport interface MCPToolDefinition {\n\tname: string;\n\tdescription: string;\n\tinputSchema: Record<string, unknown>;\n}\n\nexport class BridgeAdapter {\n\tprivate tools: CommandExecutor;\n\n\tconstructor(tools: CommandExecutor) {\n\t\tthis.tools = tools;\n\t}\n\n\tgetToolDefinitions(): MCPToolDefinition[] {\n\t\treturn this.tools.registry.getAll().map((action) => ({\n\t\t\tname: `browser_${action.name}`,\n\t\t\tdescription: action.description,\n\t\t\tinputSchema: this.zodToJsonSchema(action.schema),\n\t\t}));\n\t}\n\n\tgetToolNames(): string[] {\n\t\treturn this.tools.registry.getNames().map((name) => `browser_${name}`);\n\t}\n\n\tparseToolName(mcpToolName: string): string | null {\n\t\tif (mcpToolName.startsWith('browser_')) {\n\t\t\treturn mcpToolName.slice(8);\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate zodToJsonSchema(schema: ZodTypeAny): Record<string, unknown> {\n\t\tconst jsonSchema: Record<string, unknown> = { type: 'object' };\n\n\t\tif (schema instanceof z.ZodObject) {\n\t\t\tconst shape = schema.shape;\n\t\t\tconst properties: Record<string, unknown> = {};\n\t\t\tconst required: string[] = [];\n\n\t\t\tfor (const [key, value] of Object.entries(shape)) {\n\t\t\t\tconst fieldSchema = value as ZodTypeAny;\n\t\t\t\tproperties[key] = this.fieldToJsonSchema(fieldSchema);\n\t\t\t\tif (!(fieldSchema instanceof z.ZodOptional)) {\n\t\t\t\t\trequired.push(key);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tjsonSchema.properties = properties;\n\t\t\tif (required.length > 0) {\n\t\t\t\tjsonSchema.required = required;\n\t\t\t}\n\t\t}\n\n\t\treturn jsonSchema;\n\t}\n\n\tprivate fieldToJsonSchema(schema: ZodTypeAny): Record<string, unknown> {\n\t\tif (schema instanceof z.ZodString) {\n\t\t\treturn { type: 'string', description: schema.description };\n\t\t}\n\t\tif (schema instanceof z.ZodNumber) {\n\t\t\treturn { type: 'number', description: schema.description };\n\t\t}\n\t\tif (schema instanceof z.ZodBoolean) {\n\t\t\treturn { type: 'boolean', description: schema.description };\n\t\t}\n\t\tif (schema instanceof z.ZodEnum) {\n\t\t\treturn { type: 'string', enum: schema.options, description: schema.description };\n\t\t}\n\t\tif (schema instanceof z.ZodArray) {\n\t\t\treturn {\n\t\t\t\ttype: 'array',\n\t\t\t\titems: this.fieldToJsonSchema(schema.element),\n\t\t\t\tdescription: schema.description,\n\t\t\t};\n\t\t}\n\t\tif (schema instanceof z.ZodOptional) {\n\t\t\treturn this.fieldToJsonSchema(schema.unwrap());\n\t\t}\n\t\tif (schema instanceof z.ZodDefault) {\n\t\t\tconst inner = this.fieldToJsonSchema(schema.removeDefault());\n\t\t\t(inner as any).default = schema._def.defaultValue();\n\t\t\treturn inner;\n\t\t}\n\t\tif (schema instanceof z.ZodLiteral) {\n\t\t\treturn { const: schema.value };\n\t\t}\n\t\treturn { type: 'object', description: schema.description };\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/bridge/client.ts",
    "content": "import { type ChildProcess, spawn } from 'node:child_process';\nimport { EventEmitter } from 'node:events';\nimport type { CustomCommandSpec } from '../commands/types.js';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('mcp-client');\n\n// ── Types ──\n\nexport interface BridgeClientOptions {\n\tcommand: string;\n\targs?: string[];\n\tenv?: Record<string, string>;\n\t/** Timeout per JSON-RPC request in ms (default: 30_000) */\n\trequestTimeoutMs?: number;\n\t/** Maximum reconnection attempts (default: 5) */\n\tmaxReconnectAttempts?: number;\n\t/** Initial reconnection delay in ms, doubles each attempt (default: 1000) */\n\treconnectDelayMs?: number;\n\t/** Interval between health checks in ms (0 to disable, default: 0) */\n\thealthCheckIntervalMs?: number;\n}\n\nexport interface MCPTool {\n\tname: string;\n\tdescription: string;\n\tinputSchema: Record<string, unknown>;\n}\n\nexport type MCPConnectionState = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';\n\ninterface PendingRequest {\n\tresolve: (value: unknown) => void;\n\treject: (error: Error) => void;\n\ttimer: ReturnType<typeof setTimeout>;\n\tmethod: string;\n}\n\nexport interface BridgeClientEvents {\n\tstateChange: [state: MCPConnectionState, previousState: MCPConnectionState];\n\terror: [error: Error];\n\tnotification: [method: string, params: Record<string, unknown> | undefined];\n}\n\n/**\n * MCP client that connects to external MCP servers and converts their tools\n * into custom browser actions.\n *\n * Features:\n * - Reconnection with exponential backoff\n * - Per-call request timeout\n * - Concurrent request multiplexing (multiple in-flight requests)\n * - Tool list caching with invalidation\n * - Health check / ping\n * - Event emitter for connection state changes\n * - Graceful shutdown with pending request drain\n */\nexport class BridgeClient extends EventEmitter<BridgeClientEvents> {\n\tprivate process: ChildProcess | null = null;\n\tprivate requestId = 0;\n\tprivate pendingRequests = new Map<string | number, PendingRequest>();\n\tprivate options: BridgeClientOptions;\n\tprivate buffer = '';\n\n\t// ── Connection state ──\n\tprivate _state: MCPConnectionState = 'disconnected';\n\tprivate reconnectAttempts = 0;\n\tprivate reconnectTimer: ReturnType<typeof setTimeout> | null = null;\n\n\t// ── Tool caching ──\n\tprivate cachedTools: MCPTool[] | null = null;\n\tprivate toolsCacheTimestamp = 0;\n\n\t// ── Health check ──\n\tprivate healthCheckTimer: ReturnType<typeof setInterval> | null = null;\n\n\t// ── Config ──\n\tprivate readonly requestTimeoutMs: number;\n\tprivate readonly maxReconnectAttempts: number;\n\tprivate readonly reconnectDelayMs: number;\n\tprivate readonly healthCheckIntervalMs: number;\n\n\tconstructor(options: BridgeClientOptions) {\n\t\tsuper();\n\t\tthis.options = options;\n\t\tthis.requestTimeoutMs = options.requestTimeoutMs ?? 30_000;\n\t\tthis.maxReconnectAttempts = options.maxReconnectAttempts ?? 5;\n\t\tthis.reconnectDelayMs = options.reconnectDelayMs ?? 1000;\n\t\tthis.healthCheckIntervalMs = options.healthCheckIntervalMs ?? 0;\n\t}\n\n\t// ── Public accessors ──\n\n\tget state(): MCPConnectionState {\n\t\treturn this._state;\n\t}\n\n\tget isConnected(): boolean {\n\t\treturn this._state === 'connected';\n\t}\n\n\t// ── Connection lifecycle ──\n\n\tasync connect(): Promise<void> {\n\t\tif (this._state === 'connected') {\n\t\t\tlogger.debug('Already connected, skipping connect()');\n\t\t\treturn;\n\t\t}\n\n\t\tthis.setState('connecting');\n\t\tawait this.spawnProcess();\n\t\tawait this.initialize();\n\t\tthis.setState('connected');\n\t\tthis.reconnectAttempts = 0;\n\n\t\t// Warm the tool cache\n\t\tawait this.listTools();\n\n\t\t// Start health checks if configured\n\t\tthis.startHealthChecks();\n\n\t\tlogger.info(`Connected to MCP server: ${this.options.command}`);\n\t}\n\n\tprivate async spawnProcess(): Promise<void> {\n\t\tthis.process = spawn(this.options.command, this.options.args ?? [], {\n\t\t\tstdio: ['pipe', 'pipe', 'pipe'],\n\t\t\tenv: { ...process.env, ...this.options.env },\n\t\t});\n\n\t\tthis.process.stdout?.setEncoding('utf-8');\n\t\tthis.process.stdout?.on('data', (data: string) => {\n\t\t\tthis.buffer += data;\n\t\t\tthis.processBuffer();\n\t\t});\n\n\t\tthis.process.stderr?.on('data', (data: Buffer) => {\n\t\t\tlogger.warn(`[MCP stderr] ${data.toString().trimEnd()}`);\n\t\t});\n\n\t\tthis.process.on('close', (code: number | null) => {\n\t\t\tlogger.info(`MCP server process exited with code ${code}`);\n\t\t\tthis.handleProcessClose();\n\t\t});\n\n\t\tthis.process.on('error', (error: Error) => {\n\t\t\tlogger.error(`MCP server process error: ${error.message}`);\n\t\t\tthis.emit('error', error);\n\t\t\tthis.handleProcessClose();\n\t\t});\n\t}\n\n\tprivate async initialize(): Promise<void> {\n\t\tawait this.send('initialize', {\n\t\t\tprotocolVersion: '2024-11-05',\n\t\t\tcapabilities: {},\n\t\t\tclientInfo: { name: 'open-browser', version: '0.1.0' },\n\t\t});\n\n\t\t// Send initialized notification (no id, no response expected)\n\t\tthis.sendNotification('notifications/initialized');\n\t}\n\n\t// ── State management ──\n\n\tprivate setState(newState: MCPConnectionState): void {\n\t\tconst previousState = this._state;\n\t\tif (previousState === newState) return;\n\n\t\tthis._state = newState;\n\t\tlogger.debug(`Connection state: ${previousState} -> ${newState}`);\n\t\tthis.emit('stateChange', newState, previousState);\n\t}\n\n\t// ── Reconnection ──\n\n\tprivate handleProcessClose(): void {\n\t\tconst wasPreviouslyConnected = this._state === 'connected';\n\n\t\t// Reject all pending requests\n\t\tfor (const [id, pending] of this.pendingRequests) {\n\t\t\tclearTimeout(pending.timer);\n\t\t\tpending.reject(new Error('MCP server disconnected'));\n\t\t}\n\t\tthis.pendingRequests.clear();\n\t\tthis.process = null;\n\t\tthis.buffer = '';\n\n\t\tif (wasPreviouslyConnected) {\n\t\t\tthis.attemptReconnect();\n\t\t} else {\n\t\t\tthis.setState('disconnected');\n\t\t}\n\t}\n\n\tprivate attemptReconnect(): void {\n\t\tif (this.reconnectAttempts >= this.maxReconnectAttempts) {\n\t\t\tlogger.error(`Max reconnection attempts (${this.maxReconnectAttempts}) reached`);\n\t\t\tthis.setState('disconnected');\n\t\t\tthis.emit('error', new Error('MCP server reconnection failed after all attempts'));\n\t\t\treturn;\n\t\t}\n\n\t\tthis.setState('reconnecting');\n\t\tthis.reconnectAttempts++;\n\n\t\tconst delay = this.reconnectDelayMs * 2 ** (this.reconnectAttempts - 1);\n\t\tlogger.info(\n\t\t\t`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`,\n\t\t);\n\n\t\tthis.reconnectTimer = setTimeout(async () => {\n\t\t\tthis.reconnectTimer = null;\n\t\t\ttry {\n\t\t\t\tawait this.spawnProcess();\n\t\t\t\tawait this.initialize();\n\t\t\t\tthis.setState('connected');\n\t\t\t\tthis.reconnectAttempts = 0;\n\n\t\t\t\t// Invalidate tool cache on reconnect -- server may have changed\n\t\t\t\tthis.invalidateToolCache();\n\t\t\t\tawait this.listTools();\n\n\t\t\t\tthis.startHealthChecks();\n\t\t\t\tlogger.info('Reconnected to MCP server');\n\t\t\t} catch (error) {\n\t\t\t\tlogger.warn(\n\t\t\t\t\t`Reconnect attempt ${this.reconnectAttempts} failed: ${\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error)\n\t\t\t\t\t}`,\n\t\t\t\t);\n\t\t\t\tthis.attemptReconnect();\n\t\t\t}\n\t\t}, delay);\n\t}\n\n\t// ── Tool caching ──\n\n\tasync listTools(): Promise<MCPTool[]> {\n\t\tif (this.cachedTools) {\n\t\t\treturn this.cachedTools;\n\t\t}\n\n\t\tconst result = (await this.send('tools/list', {})) as { tools: MCPTool[] };\n\t\tthis.cachedTools = result.tools ?? [];\n\t\tthis.toolsCacheTimestamp = Date.now();\n\n\t\tlogger.debug(`Cached ${this.cachedTools.length} tools from MCP server`);\n\t\treturn this.cachedTools;\n\t}\n\n\t/** Get cached tools synchronously. Returns empty array if cache is cold. */\n\tgetTools(): MCPTool[] {\n\t\treturn this.cachedTools ?? [];\n\t}\n\n\t/** Force-invalidate the tool cache. Next listTools() call will re-fetch. */\n\tinvalidateToolCache(): void {\n\t\tthis.cachedTools = null;\n\t\tthis.toolsCacheTimestamp = 0;\n\t}\n\n\t/** Returns when the tool cache was last populated (epoch ms), or 0 if empty. */\n\tget toolsCacheAge(): number {\n\t\treturn this.toolsCacheTimestamp > 0 ? Date.now() - this.toolsCacheTimestamp : 0;\n\t}\n\n\t// ── Tool invocation ──\n\n\ttoCustomActions(): CustomCommandSpec[] {\n\t\tconst { z } = require('zod');\n\t\tconst tools = this.getTools();\n\n\t\treturn tools.map((tool) => ({\n\t\t\tname: `mcp_${tool.name}`,\n\t\t\tdescription: `[MCP] ${tool.description}`,\n\t\t\tschema: z.object({}),\n\t\t\thandler: async (params: Record<string, unknown>) => {\n\t\t\t\tconst result = await this.callTool(tool.name, params);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\textractedContent: typeof result === 'string' ? result : JSON.stringify(result),\n\t\t\t\t};\n\t\t\t},\n\t\t}));\n\t}\n\n\tasync callTool(name: string, args: Record<string, unknown>): Promise<unknown> {\n\t\tconst result = (await this.send('tools/call', { name, arguments: args })) as {\n\t\t\tcontent: Array<{ type: string; text?: string }>;\n\t\t\tisError?: boolean;\n\t\t};\n\n\t\tif (result.isError) {\n\t\t\tconst errorText = result.content?.find((c) => c.type === 'text')?.text;\n\t\t\tthrow new Error(errorText ?? 'MCP tool call failed');\n\t\t}\n\n\t\tconst textContent = result.content?.find((c) => c.type === 'text');\n\t\treturn textContent?.text ?? result;\n\t}\n\n\t// ── Health check ──\n\n\t/** Send a ping to verify the server is responsive. Rejects if no pong within timeout. */\n\tasync ping(): Promise<void> {\n\t\tawait this.send('ping', {});\n\t}\n\n\tprivate startHealthChecks(): void {\n\t\tthis.stopHealthChecks();\n\n\t\tif (this.healthCheckIntervalMs <= 0) return;\n\n\t\tthis.healthCheckTimer = setInterval(async () => {\n\t\t\ttry {\n\t\t\t\tawait this.ping();\n\t\t\t} catch {\n\t\t\t\tlogger.warn('Health check failed');\n\t\t\t}\n\t\t}, this.healthCheckIntervalMs);\n\t}\n\n\tprivate stopHealthChecks(): void {\n\t\tif (this.healthCheckTimer) {\n\t\t\tclearInterval(this.healthCheckTimer);\n\t\t\tthis.healthCheckTimer = null;\n\t\t}\n\t}\n\n\t// ── JSON-RPC transport ──\n\n\tprivate send(method: string, params?: Record<string, unknown>): Promise<unknown> {\n\t\tif (!this.process?.stdin?.writable) {\n\t\t\treturn Promise.reject(new Error('MCP client is not connected'));\n\t\t}\n\n\t\tconst id = ++this.requestId;\n\n\t\treturn new Promise((resolve, reject) => {\n\t\t\t// Per-call timeout\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.pendingRequests.delete(id);\n\t\t\t\treject(new Error(`MCP request timed out after ${this.requestTimeoutMs}ms: ${method}`));\n\t\t\t}, this.requestTimeoutMs);\n\n\t\t\tthis.pendingRequests.set(id, { resolve, reject, timer, method });\n\n\t\t\tconst request = JSON.stringify({\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid,\n\t\t\t\tmethod,\n\t\t\t\tparams,\n\t\t\t});\n\n\t\t\tthis.process?.stdin?.write(`${request}\\n`);\n\t\t});\n\t}\n\n\t/** Send a JSON-RPC notification (no id, no response expected). */\n\tprivate sendNotification(method: string, params?: Record<string, unknown>): void {\n\t\tif (!this.process?.stdin?.writable) return;\n\n\t\tconst notification = JSON.stringify({\n\t\t\tjsonrpc: '2.0',\n\t\t\tmethod,\n\t\t\t...(params ? { params } : {}),\n\t\t});\n\n\t\tthis.process.stdin.write(`${notification}\\n`);\n\t}\n\n\tprivate processBuffer(): void {\n\t\tconst lines = this.buffer.split('\\n');\n\t\tthis.buffer = lines.pop() ?? '';\n\n\t\tfor (const line of lines) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tconst message = JSON.parse(line);\n\n\t\t\t\t// JSON-RPC notification from server (no id field)\n\t\t\t\tif (message.id === undefined || message.id === null) {\n\t\t\t\t\tthis.handleServerNotification(message);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// Response to a pending request\n\t\t\t\tconst pending = this.pendingRequests.get(message.id);\n\t\t\t\tif (pending) {\n\t\t\t\t\tclearTimeout(pending.timer);\n\t\t\t\t\tthis.pendingRequests.delete(message.id);\n\t\t\t\t\tif (message.error) {\n\t\t\t\t\t\tpending.reject(new Error(message.error.message));\n\t\t\t\t\t} else {\n\t\t\t\t\t\tpending.resolve(message.result);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\t// Ignore malformed responses\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate handleServerNotification(message: {\n\t\tmethod: string;\n\t\tparams?: Record<string, unknown>;\n\t}): void {\n\t\tlogger.debug(`Server notification: ${message.method}`);\n\t\tthis.emit('notification', message.method, message.params);\n\n\t\t// If server signals tool list changed, invalidate cache\n\t\tif (message.method === 'notifications/tools/list_changed') {\n\t\t\tthis.invalidateToolCache();\n\t\t}\n\t}\n\n\t// ── Graceful shutdown ──\n\n\t/**\n\t * Disconnect gracefully: wait for pending requests to drain (up to a timeout),\n\t * then kill the server process.\n\t */\n\tasync disconnect(drainTimeoutMs = 5000): Promise<void> {\n\t\tthis.stopHealthChecks();\n\n\t\tif (this.reconnectTimer) {\n\t\t\tclearTimeout(this.reconnectTimer);\n\t\t\tthis.reconnectTimer = null;\n\t\t}\n\n\t\t// Wait for pending requests to drain\n\t\tif (this.pendingRequests.size > 0) {\n\t\t\tlogger.debug(\n\t\t\t\t`Waiting for ${this.pendingRequests.size} pending request(s) to drain...`,\n\t\t\t);\n\n\t\t\tawait Promise.race([\n\t\t\t\tthis.waitForPendingDrain(),\n\t\t\t\tnew Promise<void>((resolve) => setTimeout(resolve, drainTimeoutMs)),\n\t\t\t]);\n\t\t}\n\n\t\t// Reject any still-pending requests\n\t\tfor (const [id, pending] of this.pendingRequests) {\n\t\t\tclearTimeout(pending.timer);\n\t\t\tpending.reject(new Error('MCP client shutting down'));\n\t\t}\n\t\tthis.pendingRequests.clear();\n\n\t\t// Kill the process\n\t\tif (this.process) {\n\t\t\tthis.process.removeAllListeners();\n\t\t\tthis.process.kill();\n\t\t\tthis.process = null;\n\t\t}\n\n\t\tthis.buffer = '';\n\t\tthis.setState('disconnected');\n\t\tlogger.info('MCP client disconnected');\n\t}\n\n\tprivate waitForPendingDrain(): Promise<void> {\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tconst check = () => {\n\t\t\t\tif (this.pendingRequests.size === 0) {\n\t\t\t\t\tresolve();\n\t\t\t\t} else {\n\t\t\t\t\tsetTimeout(check, 50);\n\t\t\t\t}\n\t\t\t};\n\t\t\tcheck();\n\t\t});\n\t}\n\n\t/** Get the number of in-flight requests. */\n\tget pendingRequestCount(): number {\n\t\treturn this.pendingRequests.size;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/bridge/index.ts",
    "content": "export { BridgeServer, type BridgeServerOptions } from './server.js';\nexport { BridgeClient, type BridgeClientOptions } from './client.js';\nexport { BridgeAdapter } from './adapter.js';\n"
  },
  {
    "path": "packages/core/src/bridge/mcp-types.ts",
    "content": "/**\n * Experimental MCP (Model Context Protocol) server types.\n * @experimental\n */\n\nexport interface MCPServerOptions {\n\tport?: number;\n\thost?: string;\n\tcapabilities?: MCPCapability[];\n}\n\nexport type MCPCapability = 'browse' | 'extract' | 'screenshot' | 'interact';\n\nexport interface MCPRequest {\n\tmethod: string;\n\tparams: Record<string, unknown>;\n}\n\nexport interface MCPResponse {\n\tresult?: unknown;\n\terror?: { code: number; message: string };\n}\n"
  },
  {
    "path": "packages/core/src/bridge/server.test.ts",
    "content": "import { test, expect, describe, beforeEach, mock } from 'bun:test';\nimport { BridgeServer, type MCPRequest, type MCPResponse } from './server.js';\nimport { CommandExecutor } from '../commands/executor.js';\n\n// ── Mock factories ──\n\nfunction makeMockViewport() {\n\treturn {\n\t\tcurrentPage: {\n\t\t\tgoBack: mock(() => Promise.resolve()),\n\t\t\tevaluate: mock(() => Promise.resolve({})),\n\t\t\tmouse: { click: mock(() => Promise.resolve()) },\n\t\t\tkeyboard: { press: mock(() => Promise.resolve()) },\n\t\t},\n\t\tcdp: {\n\t\t\tsend: mock(() => Promise.resolve({})),\n\t\t},\n\t\tnavigate: mock(() => Promise.resolve()),\n\t\twaitForPageReady: mock(() => Promise.resolve()),\n\t\tswitchTab: mock(() => Promise.resolve()),\n\t\tnewTab: mock(() => Promise.resolve()),\n\t\tcloseTab: mock(() => Promise.resolve()),\n\t\tscreenshot: mock(() =>\n\t\t\tPromise.resolve({ base64: 'abc123', width: 1280, height: 800 }),\n\t\t),\n\t\tisConnected: true,\n\t\tgetState: mock(() =>\n\t\t\tPromise.resolve({\n\t\t\t\turl: 'https://example.com',\n\t\t\t\ttitle: 'Example',\n\t\t\t\ttabs: [{ url: 'https://example.com', title: 'Example' }],\n\t\t\t}),\n\t\t),\n\t} as any;\n}\n\nfunction makeMockPageAnalyzer() {\n\treturn {\n\t\textractState: mock(() =>\n\t\t\tPromise.resolve({\n\t\t\t\ttree: '<html>...</html>',\n\t\t\t\tselectorMap: {},\n\t\t\t\telementCount: 5,\n\t\t\t\tinteractiveElementCount: 2,\n\t\t\t\tscrollPosition: { x: 0, y: 0 },\n\t\t\t\tviewportSize: { width: 1280, height: 800 },\n\t\t\t\tdocumentSize: { width: 1280, height: 2000 },\n\t\t\t\tpixelsAbove: 0,\n\t\t\t\tpixelsBelow: 1200,\n\t\t\t}),\n\t\t),\n\t\tclickElementByIndex: mock(() => Promise.resolve()),\n\t\tinputTextByIndex: mock(() => Promise.resolve()),\n\t\tgetElementSelector: mock(() => Promise.resolve('#el')),\n\t} as any;\n}\n\nfunction makeRequest(\n\tmethod: string,\n\tid: number | string = 1,\n\tparams?: Record<string, unknown>,\n): MCPRequest & { id: number | string } {\n\treturn {\n\t\tjsonrpc: '2.0' as const,\n\t\tid,\n\t\tmethod,\n\t\t...(params ? { params } : {}),\n\t};\n}\n\n// ── Tests ──\n\ndescribe('BridgeServer', () => {\n\tlet server: BridgeServer;\n\tlet browser: ReturnType<typeof makeMockViewport>;\n\tlet domService: ReturnType<typeof makeMockPageAnalyzer>;\n\tlet tools: CommandExecutor;\n\n\tbeforeEach(() => {\n\t\tbrowser = makeMockViewport();\n\t\tdomService = makeMockPageAnalyzer();\n\t\ttools = new CommandExecutor();\n\n\t\tserver = new BridgeServer({\n\t\t\tbrowser,\n\t\t\tdomService,\n\t\t\ttools,\n\t\t\tname: 'test-server',\n\t\t\tversion: '1.0.0',\n\t\t});\n\t});\n\n\tdescribe('handleRequest: initialize', () => {\n\t\ttest('returns server info and capabilities', async () => {\n\t\t\tconst response = await server.handleRequest(makeRequest('initialize'));\n\n\t\t\texpect(response.jsonrpc).toBe('2.0');\n\t\t\texpect(response.id).toBe(1);\n\t\t\texpect(response.result).toBeDefined();\n\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.protocolVersion).toBe('2024-11-05');\n\t\t\texpect(result.serverInfo.name).toBe('test-server');\n\t\t\texpect(result.serverInfo.version).toBe('1.0.0');\n\t\t\texpect(result.capabilities.tools).toBeDefined();\n\t\t\texpect(result.capabilities.resources).toBeDefined();\n\t\t\texpect(result.capabilities.resources.subscribe).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('handleRequest: tools/list', () => {\n\t\ttest('returns list of available tools', async () => {\n\t\t\tconst response = await server.handleRequest(makeRequest('tools/list'));\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(Array.isArray(result.tools)).toBe(true);\n\t\t\texpect(result.tools.length).toBeGreaterThan(0);\n\n\t\t\t// Each tool should have name, description, inputSchema\n\t\t\tconst firstTool = result.tools[0];\n\t\t\texpect(firstTool.name).toBeDefined();\n\t\t\texpect(firstTool.description).toBeDefined();\n\t\t\texpect(firstTool.inputSchema).toBeDefined();\n\n\t\t\t// Tool names should be prefixed with browser_\n\t\t\texpect(firstTool.name.startsWith('browser_')).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('handleRequest: tools/call', () => {\n\t\ttest('executes a browser tool and returns result', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('tools/call', 1, {\n\t\t\t\t\tname: 'browser_tap',\n\t\t\t\t\targuments: { index: 0 },\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.content).toBeDefined();\n\t\t\texpect(Array.isArray(result.content)).toBe(true);\n\t\t\texpect(result.content[0].type).toBe('text');\n\t\t\texpect(result.isError).toBe(false);\n\t\t});\n\n\t\ttest('returns error for unknown tool', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('tools/call', 1, {\n\t\t\t\t\tname: 'unknown_tool',\n\t\t\t\t\targuments: {},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.code).toBe(-32602);\n\t\t\texpect(response.error!.message).toContain('Unknown tool');\n\t\t});\n\n\t\ttest('returns error for tool that does not start with browser_', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('tools/call', 1, {\n\t\t\t\t\tname: 'not_browser_tool',\n\t\t\t\t\targuments: {},\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.code).toBe(-32602);\n\t\t});\n\n\t\ttest('returns success content for done action', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('tools/call', 1, {\n\t\t\t\t\tname: 'browser_finish',\n\t\t\t\t\targuments: { text: 'All done' },\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.content[0].text).toContain('All done');\n\t\t});\n\t});\n\n\tdescribe('handleRequest: resources/list', () => {\n\t\ttest('returns available resources', async () => {\n\t\t\tconst response = await server.handleRequest(makeRequest('resources/list'));\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(Array.isArray(result.resources)).toBe(true);\n\n\t\t\tconst uris = result.resources.map((r: any) => r.uri);\n\t\t\texpect(uris).toContain('browser://state');\n\t\t\texpect(uris).toContain('browser://dom');\n\t\t\texpect(uris).toContain('browser://screenshot');\n\t\t\texpect(uris).toContain('browser://tabs');\n\n\t\t\t// Each resource should have standard fields\n\t\t\tfor (const resource of result.resources) {\n\t\t\t\texpect(resource.name).toBeDefined();\n\t\t\t\texpect(resource.description).toBeDefined();\n\t\t\t\texpect(resource.mimeType).toBeDefined();\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe('handleRequest: resources/read', () => {\n\t\ttest('reads browser://state resource', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, { uri: 'browser://state' }),\n\t\t\t);\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.contents).toBeDefined();\n\t\t\texpect(result.contents[0].uri).toBe('browser://state');\n\t\t\texpect(result.contents[0].mimeType).toBe('application/json');\n\t\t\texpect(result.contents[0].text).toBeDefined();\n\n\t\t\tconst state = JSON.parse(result.contents[0].text);\n\t\t\texpect(state.url).toBe('https://example.com');\n\t\t});\n\n\t\ttest('reads browser://dom resource', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, { uri: 'browser://dom' }),\n\t\t\t);\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.contents[0].uri).toBe('browser://dom');\n\t\t\texpect(result.contents[0].mimeType).toBe('text/plain');\n\t\t\texpect(result.contents[0].text).toContain('<html>');\n\t\t});\n\n\t\ttest('reads browser://screenshot resource', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, { uri: 'browser://screenshot' }),\n\t\t\t);\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.contents[0].uri).toBe('browser://screenshot');\n\t\t\texpect(result.contents[0].mimeType).toBe('image/png');\n\t\t\texpect(result.contents[0].blob).toBe('abc123');\n\t\t});\n\n\t\ttest('reads browser://tabs resource', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, { uri: 'browser://tabs' }),\n\t\t\t);\n\n\t\t\texpect(response.result).toBeDefined();\n\t\t\tconst result = response.result as any;\n\t\t\texpect(result.contents[0].uri).toBe('browser://tabs');\n\t\t\tconst tabs = JSON.parse(result.contents[0].text);\n\t\t\texpect(Array.isArray(tabs)).toBe(true);\n\t\t});\n\n\t\ttest('returns error for unknown resource URI', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, { uri: 'browser://nonexistent' }),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.message).toContain('Unknown resource URI');\n\t\t});\n\n\t\ttest('returns error when uri parameter is missing', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, {}),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.message).toContain('Missing required parameter');\n\t\t});\n\t});\n\n\tdescribe('handleRequest: unknown method', () => {\n\t\ttest('returns method not found error', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('unknown/method'),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.code).toBe(-32601);\n\t\t\texpect(response.error!.message).toContain('Method not found');\n\t\t});\n\t});\n\n\tdescribe('handleRequest: ping', () => {\n\t\ttest('responds to ping', async () => {\n\t\t\tconst response = await server.handleRequest(makeRequest('ping'));\n\n\t\t\texpect(response.jsonrpc).toBe('2.0');\n\t\t\texpect(response.result).toEqual({});\n\t\t});\n\t});\n\n\tdescribe('handleRequest: resources/subscribe', () => {\n\t\ttest('subscribes to a valid resource', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/subscribe', 1, { uri: 'browser://state' }),\n\t\t\t);\n\n\t\t\texpect(response.result).toEqual({});\n\t\t\texpect(response.error).toBeUndefined();\n\t\t});\n\n\t\ttest('returns error for unknown resource URI', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/subscribe', 1, { uri: 'browser://invalid' }),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.message).toContain('Unknown resource URI');\n\t\t});\n\n\t\ttest('returns error when uri is missing', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/subscribe', 1, {}),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t});\n\t});\n\n\tdescribe('handleRequest: resources/unsubscribe', () => {\n\t\ttest('unsubscribes from a resource', async () => {\n\t\t\t// First subscribe\n\t\t\tawait server.handleRequest(\n\t\t\t\tmakeRequest('resources/subscribe', 1, { uri: 'browser://state' }),\n\t\t\t);\n\n\t\t\t// Then unsubscribe\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/unsubscribe', 2, { uri: 'browser://state' }),\n\t\t\t);\n\n\t\t\texpect(response.result).toEqual({});\n\t\t});\n\n\t\ttest('returns error when uri is missing', async () => {\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/unsubscribe', 1, {}),\n\t\t\t);\n\n\t\t\texpect(response.error).toBeDefined();\n\t\t});\n\t});\n\n\tdescribe('error handling', () => {\n\t\ttest('returns error response for synchronously thrown errors', async () => {\n\t\t\t// Test with a method that will cause a synchronous error in the handler\n\t\t\t// The try/catch in handleRequest catches synchronous errors from switch cases\n\t\t\tconst response = await server.handleRequest(\n\t\t\t\tmakeRequest('resources/read', 1, { uri: 'browser://nonexistent' }),\n\t\t\t);\n\n\t\t\texpect(response.jsonrpc).toBe('2.0');\n\t\t\texpect(response.error).toBeDefined();\n\t\t\texpect(response.error!.message).toContain('Unknown resource URI');\n\t\t});\n\n\t\ttest('returns error for tools/call when execution fails', async () => {\n\t\t\t// Modify the domService to throw on clickElementByIndex\n\t\t\tdomService.clickElementByIndex = mock(() =>\n\t\t\t\tPromise.reject(new Error('Unexpected crash')),\n\t\t\t);\n\n\t\t\tconst failServer = new BridgeServer({\n\t\t\t\tbrowser,\n\t\t\t\tdomService,\n\t\t\t\ttools,\n\t\t\t});\n\n\t\t\t// CommandFailedError propagates from registry.execute through\n\t\t\t// handleToolsCall. Since handleRequest returns (not awaits) the\n\t\t\t// promise from handleToolsCall, the error may propagate as a\n\t\t\t// rejection. We handle both cases.\n\t\t\ttry {\n\t\t\t\tconst response = await failServer.handleRequest(\n\t\t\t\t\tmakeRequest('tools/call', 1, {\n\t\t\t\t\t\tname: 'browser_tap',\n\t\t\t\t\t\targuments: { index: 0 },\n\t\t\t\t\t}),\n\t\t\t\t);\n\n\t\t\t\t// If it returns a response, it should have an error field\n\t\t\t\texpect(response.jsonrpc).toBe('2.0');\n\t\t\t\tconst hasError = response.error !== undefined;\n\t\t\t\tconst hasIsError = (response.result as any)?.isError === true;\n\t\t\t\texpect(hasError || hasIsError).toBe(true);\n\t\t\t} catch (error) {\n\t\t\t\t// If the error propagates as a rejection, that is acceptable too\n\t\t\t\texpect(error).toBeDefined();\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe('handleMessage (with notifications)', () => {\n\t\ttest('returns null for notification (no id)', async () => {\n\t\t\tconst notification: MCPRequest = {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tmethod: 'notifications/initialized',\n\t\t\t};\n\n\t\t\tconst response = await server.handleMessage(notification);\n\t\t\texpect(response).toBeNull();\n\t\t});\n\n\t\ttest('returns response for request (with id)', async () => {\n\t\t\tconst request: MCPRequest = {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: 1,\n\t\t\t\tmethod: 'ping',\n\t\t\t};\n\n\t\t\tconst response = await server.handleMessage(request);\n\t\t\texpect(response).not.toBeNull();\n\t\t\texpect(response!.result).toEqual({});\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/bridge/server.ts",
    "content": "import type { IncomingMessage, ServerResponse } from 'node:http';\nimport type { Viewport } from '../viewport/viewport.js';\nimport type { PageAnalyzer } from '../page/page-analyzer.js';\nimport type { CommandExecutor } from '../commands/executor.js';\nimport type { ExecutionContext } from '../commands/types.js';\nimport { BridgeAdapter, type MCPToolDefinition } from './adapter.js';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('mcp-server');\n\n// ── JSON-RPC types ──\n\nexport interface BridgeServerOptions {\n\tbrowser: Viewport;\n\tdomService: PageAnalyzer;\n\ttools: CommandExecutor;\n\tname?: string;\n\tversion?: string;\n\t/** Port for SSE transport (default: 3100) */\n\tssePort?: number;\n}\n\nexport interface MCPRequest {\n\tjsonrpc: '2.0';\n\tid?: string | number;\n\tmethod: string;\n\tparams?: Record<string, unknown>;\n}\n\nexport interface MCPResponse {\n\tjsonrpc: '2.0';\n\tid: string | number;\n\tresult?: unknown;\n\terror?: { code: number; message: string; data?: unknown };\n}\n\nexport interface MCPNotification {\n\tjsonrpc: '2.0';\n\tmethod: string;\n\tparams?: Record<string, unknown>;\n}\n\n// ── Resource types ──\n\nexport interface MCPResource {\n\turi: string;\n\tname: string;\n\tdescription: string;\n\tmimeType: string;\n}\n\nexport interface MCPResourceContent {\n\turi: string;\n\tmimeType: string;\n\ttext?: string;\n\tblob?: string;\n}\n\n// ── Subscription tracking ──\n\ninterface ResourceSubscription {\n\turi: string;\n\t/** Callback that receives the notification to send to the client */\n\tnotify: (notification: MCPNotification) => void;\n}\n\n/**\n * MCP (Model Context Protocol) server that exposes browser actions as tools\n * and browser state as resources. Supports stdio and SSE transports.\n *\n * Implements:\n * - initialize / tools/list / tools/call (existing)\n * - resources/list / resources/read (browser state as resources)\n * - resources/subscribe / resources/unsubscribe (live updates)\n * - notifications/progress (step progress notifications)\n * - SSE transport via HTTP\n */\nexport class BridgeServer {\n\tprivate controller: BridgeAdapter;\n\tprivate browser: Viewport;\n\tprivate domService: PageAnalyzer;\n\tprivate tools: CommandExecutor;\n\tprivate name: string;\n\tprivate version: string;\n\tprivate ssePort: number;\n\n\t/** Active SSE connections that receive notifications */\n\tprivate sseClients = new Set<ServerResponse>();\n\n\t/** Resource subscriptions keyed by URI */\n\tprivate subscriptions = new Map<string, Set<ResourceSubscription>>();\n\n\t/** Last screenshot base64 cache for resource reads */\n\tprivate lastScreenshotBase64: string | null = null;\n\n\t/** HTTP server reference for SSE transport */\n\tprivate httpServer: import('node:http').Server | null = null;\n\n\tconstructor(options: BridgeServerOptions) {\n\t\tthis.browser = options.browser;\n\t\tthis.domService = options.domService;\n\t\tthis.tools = options.tools;\n\t\tthis.controller = new BridgeAdapter(options.tools);\n\t\tthis.name = options.name ?? 'open-browser';\n\t\tthis.version = options.version ?? '0.1.0';\n\t\tthis.ssePort = options.ssePort ?? 3100;\n\t}\n\n\t// ── Static resource definitions ──\n\n\tprivate getResourceDefinitions(): MCPResource[] {\n\t\treturn [\n\t\t\t{\n\t\t\t\turi: 'browser://state',\n\t\t\t\tname: 'Browser State',\n\t\t\t\tdescription: 'Current browser state summary including URL, title, and active tab',\n\t\t\t\tmimeType: 'application/json',\n\t\t\t},\n\t\t\t{\n\t\t\t\turi: 'browser://dom',\n\t\t\t\tname: 'DOM Tree',\n\t\t\t\tdescription: 'Current page DOM tree serialized for LLM consumption',\n\t\t\t\tmimeType: 'text/plain',\n\t\t\t},\n\t\t\t{\n\t\t\t\turi: 'browser://screenshot',\n\t\t\t\tname: 'Screenshot',\n\t\t\t\tdescription: 'Last screenshot of the current page as base64 PNG',\n\t\t\t\tmimeType: 'image/png',\n\t\t\t},\n\t\t\t{\n\t\t\t\turi: 'browser://tabs',\n\t\t\t\tname: 'Open Tabs',\n\t\t\t\tdescription: 'List of all open browser tabs with URLs and titles',\n\t\t\t\tmimeType: 'application/json',\n\t\t\t},\n\t\t];\n\t}\n\n\t// ── Request dispatcher ──\n\n\tasync handleMessage(message: MCPRequest): Promise<MCPResponse | null> {\n\t\t// JSON-RPC notifications have no `id` field -- they are fire-and-forget\n\t\tif (message.id === undefined || message.id === null) {\n\t\t\tawait this.handleNotification(message);\n\t\t\treturn null;\n\t\t}\n\n\t\treturn this.handleRequest(message as MCPRequest & { id: string | number });\n\t}\n\n\tasync handleRequest(request: MCPRequest & { id: string | number }): Promise<MCPResponse> {\n\t\ttry {\n\t\t\tswitch (request.method) {\n\t\t\t\tcase 'initialize':\n\t\t\t\t\treturn this.handleInitialize(request);\n\t\t\t\tcase 'tools/list':\n\t\t\t\t\treturn this.handleToolsList(request);\n\t\t\t\tcase 'tools/call':\n\t\t\t\t\treturn this.handleToolsCall(request);\n\t\t\t\tcase 'resources/list':\n\t\t\t\t\treturn this.handleResourcesList(request);\n\t\t\t\tcase 'resources/read':\n\t\t\t\t\treturn this.handleResourcesRead(request);\n\t\t\t\tcase 'resources/subscribe':\n\t\t\t\t\treturn this.handleResourcesSubscribe(request);\n\t\t\t\tcase 'resources/unsubscribe':\n\t\t\t\t\treturn this.handleResourcesUnsubscribe(request);\n\t\t\t\tcase 'ping':\n\t\t\t\t\treturn { jsonrpc: '2.0', id: request.id, result: {} };\n\t\t\t\tdefault:\n\t\t\t\t\treturn {\n\t\t\t\t\t\tjsonrpc: '2.0',\n\t\t\t\t\t\tid: request.id,\n\t\t\t\t\t\terror: { code: -32601, message: `Method not found: ${request.method}` },\n\t\t\t\t\t};\n\t\t\t}\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: {\n\t\t\t\t\tcode: -32603,\n\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t}\n\n\t/** Handle incoming JSON-RPC notifications (no response expected). */\n\tprivate async handleNotification(message: MCPRequest): Promise<void> {\n\t\tswitch (message.method) {\n\t\t\tcase 'notifications/initialized':\n\t\t\t\tlogger.debug('Client confirmed initialization');\n\t\t\t\tbreak;\n\t\t\tcase 'notifications/cancelled': {\n\t\t\t\tconst requestId = message.params?.requestId;\n\t\t\t\tlogger.debug(`Client cancelled request ${requestId}`);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\tlogger.debug(`Received unknown notification: ${message.method}`);\n\t\t}\n\t}\n\n\t// ── Protocol handlers ──\n\n\tprivate handleInitialize(request: MCPRequest & { id: string | number }): MCPResponse {\n\t\treturn {\n\t\t\tjsonrpc: '2.0',\n\t\t\tid: request.id,\n\t\t\tresult: {\n\t\t\t\tprotocolVersion: '2024-11-05',\n\t\t\t\tcapabilities: {\n\t\t\t\t\ttools: {},\n\t\t\t\t\tresources: {\n\t\t\t\t\t\tsubscribe: true,\n\t\t\t\t\t\tlistChanged: true,\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tserverInfo: {\n\t\t\t\t\tname: this.name,\n\t\t\t\t\tversion: this.version,\n\t\t\t\t},\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate handleToolsList(request: MCPRequest & { id: string | number }): MCPResponse {\n\t\tconst tools = this.controller.getToolDefinitions();\n\t\treturn {\n\t\t\tjsonrpc: '2.0',\n\t\t\tid: request.id,\n\t\t\tresult: {\n\t\t\t\ttools: tools.map((t) => ({\n\t\t\t\t\tname: t.name,\n\t\t\t\t\tdescription: t.description,\n\t\t\t\t\tinputSchema: t.inputSchema,\n\t\t\t\t})),\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate async handleToolsCall(request: MCPRequest & { id: string | number }): Promise<MCPResponse> {\n\t\tconst params = request.params ?? {};\n\t\tconst toolName = params.name as string;\n\t\tconst args = (params.arguments ?? {}) as Record<string, unknown>;\n\n\t\tconst actionName = this.controller.parseToolName(toolName);\n\t\tif (!actionName) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: { code: -32602, message: `Unknown tool: ${toolName}` },\n\t\t\t};\n\t\t}\n\n\t\t// Emit progress notification at start\n\t\tthis.emitProgress(request.id, 0, `Executing ${toolName}...`);\n\n\t\tconst context: ExecutionContext = {\n\t\t\tpage: this.browser.currentPage,\n\t\t\tcdpSession: this.browser.cdp!,\n\t\t\tdomService: this.domService,\n\t\t\tbrowserSession: this.browser,\n\t\t};\n\n\t\tconst result = await this.tools.registry.execute(actionName, args, context);\n\n\t\t// Emit progress notification at completion\n\t\tthis.emitProgress(request.id, 1, 'Complete');\n\n\t\t// Notify subscribers that browser state may have changed\n\t\tthis.notifyResourceChanged('browser://state');\n\t\tthis.notifyResourceChanged('browser://dom');\n\n\t\treturn {\n\t\t\tjsonrpc: '2.0',\n\t\t\tid: request.id,\n\t\t\tresult: {\n\t\t\t\tcontent: [\n\t\t\t\t\t{\n\t\t\t\t\t\ttype: 'text',\n\t\t\t\t\t\ttext: result.extractedContent ?? (result.success ? 'Success' : `Error: ${result.error}`),\n\t\t\t\t\t},\n\t\t\t\t],\n\t\t\t\tisError: !result.success,\n\t\t\t},\n\t\t};\n\t}\n\n\t// ── Resource handlers ──\n\n\tprivate handleResourcesList(request: MCPRequest & { id: string | number }): MCPResponse {\n\t\treturn {\n\t\t\tjsonrpc: '2.0',\n\t\t\tid: request.id,\n\t\t\tresult: {\n\t\t\t\tresources: this.getResourceDefinitions(),\n\t\t\t},\n\t\t};\n\t}\n\n\tprivate async handleResourcesRead(request: MCPRequest & { id: string | number }): Promise<MCPResponse> {\n\t\tconst uri = request.params?.uri as string;\n\t\tif (!uri) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: { code: -32602, message: 'Missing required parameter: uri' },\n\t\t\t};\n\t\t}\n\n\t\ttry {\n\t\t\tconst content = await this.readResource(uri);\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\tresult: {\n\t\t\t\t\tcontents: [content],\n\t\t\t\t},\n\t\t\t};\n\t\t} catch (error) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: {\n\t\t\t\t\tcode: -32602,\n\t\t\t\t\tmessage: error instanceof Error ? error.message : String(error),\n\t\t\t\t},\n\t\t\t};\n\t\t}\n\t}\n\n\tprivate async readResource(uri: string): Promise<MCPResourceContent> {\n\t\tswitch (uri) {\n\t\t\tcase 'browser://state': {\n\t\t\t\tconst state = await this.browser.getState();\n\t\t\t\treturn {\n\t\t\t\t\turi,\n\t\t\t\t\tmimeType: 'application/json',\n\t\t\t\t\ttext: JSON.stringify(state, null, 2),\n\t\t\t\t};\n\t\t\t}\n\t\t\tcase 'browser://dom': {\n\t\t\t\tconst domState = await this.domService.extractState(\n\t\t\t\t\tthis.browser.currentPage,\n\t\t\t\t\tthis.browser.cdp!,\n\t\t\t\t);\n\t\t\t\treturn {\n\t\t\t\t\turi,\n\t\t\t\t\tmimeType: 'text/plain',\n\t\t\t\t\ttext: domState.tree,\n\t\t\t\t};\n\t\t\t}\n\t\t\tcase 'browser://screenshot': {\n\t\t\t\tconst screenshot = await this.browser.screenshot();\n\t\t\t\tthis.lastScreenshotBase64 = screenshot.base64;\n\t\t\t\treturn {\n\t\t\t\t\turi,\n\t\t\t\t\tmimeType: 'image/png',\n\t\t\t\t\tblob: screenshot.base64,\n\t\t\t\t};\n\t\t\t}\n\t\t\tcase 'browser://tabs': {\n\t\t\t\tconst state = await this.browser.getState();\n\t\t\t\treturn {\n\t\t\t\t\turi,\n\t\t\t\t\tmimeType: 'application/json',\n\t\t\t\t\ttext: JSON.stringify(state.tabs, null, 2),\n\t\t\t\t};\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\tthrow new Error(`Unknown resource URI: ${uri}`);\n\t\t}\n\t}\n\n\tprivate handleResourcesSubscribe(request: MCPRequest & { id: string | number }): MCPResponse {\n\t\tconst uri = request.params?.uri as string;\n\t\tif (!uri) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: { code: -32602, message: 'Missing required parameter: uri' },\n\t\t\t};\n\t\t}\n\n\t\tconst validUris = new Set(this.getResourceDefinitions().map((r) => r.uri));\n\t\tif (!validUris.has(uri)) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: { code: -32602, message: `Unknown resource URI: ${uri}` },\n\t\t\t};\n\t\t}\n\n\t\t// The subscription is tracked; actual notification delivery happens\n\t\t// via emitNotification which writes to all connected transports\n\t\tif (!this.subscriptions.has(uri)) {\n\t\t\tthis.subscriptions.set(uri, new Set());\n\t\t}\n\n\t\tlogger.debug(`Client subscribed to resource: ${uri}`);\n\t\treturn { jsonrpc: '2.0', id: request.id, result: {} };\n\t}\n\n\tprivate handleResourcesUnsubscribe(request: MCPRequest & { id: string | number }): MCPResponse {\n\t\tconst uri = request.params?.uri as string;\n\t\tif (!uri) {\n\t\t\treturn {\n\t\t\t\tjsonrpc: '2.0',\n\t\t\t\tid: request.id,\n\t\t\t\terror: { code: -32602, message: 'Missing required parameter: uri' },\n\t\t\t};\n\t\t}\n\n\t\tthis.subscriptions.delete(uri);\n\t\tlogger.debug(`Client unsubscribed from resource: ${uri}`);\n\t\treturn { jsonrpc: '2.0', id: request.id, result: {} };\n\t}\n\n\t// ── Notification emission ──\n\n\t/** Emit a progress notification for an in-flight request. */\n\temitProgress(requestId: string | number, progress: number, message?: string): void {\n\t\tconst notification: MCPNotification = {\n\t\t\tjsonrpc: '2.0',\n\t\t\tmethod: 'notifications/progress',\n\t\t\tparams: {\n\t\t\t\tprogressToken: requestId,\n\t\t\t\tprogress,\n\t\t\t\ttotal: 1,\n\t\t\t\t...(message ? { message } : {}),\n\t\t\t},\n\t\t};\n\t\tthis.broadcastNotification(notification);\n\t}\n\n\t/** Notify subscribers that a resource has changed. */\n\tprivate notifyResourceChanged(uri: string): void {\n\t\tif (!this.subscriptions.has(uri)) return;\n\n\t\tconst notification: MCPNotification = {\n\t\t\tjsonrpc: '2.0',\n\t\t\tmethod: 'notifications/resources/updated',\n\t\t\tparams: { uri },\n\t\t};\n\t\tthis.broadcastNotification(notification);\n\t}\n\n\t/** Send a notification to all connected transports (SSE clients + stdio). */\n\tprivate broadcastNotification(notification: MCPNotification): void {\n\t\tconst serialized = JSON.stringify(notification);\n\n\t\t// SSE clients\n\t\tfor (const client of this.sseClients) {\n\t\t\ttry {\n\t\t\t\tclient.write(`data: ${serialized}\\n\\n`);\n\t\t\t} catch {\n\t\t\t\t// Client may have disconnected; will be cleaned up\n\t\t\t\tthis.sseClients.delete(client);\n\t\t\t}\n\t\t}\n\t}\n\n\t// ── Stdio transport ──\n\n\tasync startStdio(): Promise<void> {\n\t\tconst stdin = process.stdin;\n\t\tconst stdout = process.stdout;\n\n\t\tstdin.setEncoding('utf-8');\n\n\t\tlet buffer = '';\n\n\t\tstdin.on('data', async (data: string) => {\n\t\t\tbuffer += data;\n\n\t\t\tconst lines = buffer.split('\\n');\n\t\t\tbuffer = lines.pop() ?? '';\n\n\t\t\tfor (const line of lines) {\n\t\t\t\tif (!line.trim()) continue;\n\n\t\t\t\ttry {\n\t\t\t\t\tconst message = JSON.parse(line) as MCPRequest;\n\t\t\t\t\tconst response = await this.handleMessage(message);\n\t\t\t\t\tif (response) {\n\t\t\t\t\t\tstdout.write(`${JSON.stringify(response)}\\n`);\n\t\t\t\t\t}\n\t\t\t\t} catch {\n\t\t\t\t\tconst errorResponse: MCPResponse = {\n\t\t\t\t\t\tjsonrpc: '2.0',\n\t\t\t\t\t\tid: 0,\n\t\t\t\t\t\terror: { code: -32700, message: 'Parse error' },\n\t\t\t\t\t};\n\t\t\t\t\tstdout.write(`${JSON.stringify(errorResponse)}\\n`);\n\t\t\t\t}\n\t\t\t}\n\t\t});\n\n\t\tstdin.on('end', () => {\n\t\t\tprocess.exit(0);\n\t\t});\n\t}\n\n\t// ── SSE transport ──\n\n\t/**\n\t * Start an HTTP server that exposes the MCP protocol over Server-Sent Events.\n\t *\n\t * Endpoints:\n\t * - GET  /sse       -- SSE event stream for notifications and responses\n\t * - POST /message   -- Send JSON-RPC requests\n\t * - GET  /health    -- Health check\n\t */\n\tasync startSSE(port?: number): Promise<void> {\n\t\tconst http = await import('node:http');\n\t\tconst listenPort = port ?? this.ssePort;\n\n\t\tthis.httpServer = http.createServer(async (req: IncomingMessage, res: ServerResponse) => {\n\t\t\t// CORS headers for browser clients\n\t\t\tres.setHeader('Access-Control-Allow-Origin', '*');\n\t\t\tres.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');\n\t\t\tres.setHeader('Access-Control-Allow-Headers', 'Content-Type');\n\n\t\t\tif (req.method === 'OPTIONS') {\n\t\t\t\tres.writeHead(204);\n\t\t\t\tres.end();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst url = req.url ?? '/';\n\n\t\t\tif (req.method === 'GET' && url === '/sse') {\n\t\t\t\tthis.handleSSEConnection(res);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (req.method === 'POST' && url === '/message') {\n\t\t\t\tawait this.handleSSEMessage(req, res);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (req.method === 'GET' && url === '/health') {\n\t\t\t\tres.writeHead(200, { 'Content-Type': 'application/json' });\n\t\t\t\tres.end(JSON.stringify({\n\t\t\t\t\tstatus: 'ok',\n\t\t\t\t\tserver: this.name,\n\t\t\t\t\tversion: this.version,\n\t\t\t\t\tbrowserConnected: this.browser.isConnected,\n\t\t\t\t}));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tres.writeHead(404, { 'Content-Type': 'application/json' });\n\t\t\tres.end(JSON.stringify({ error: 'Not found' }));\n\t\t});\n\n\t\treturn new Promise<void>((resolve) => {\n\t\t\tthis.httpServer!.listen(listenPort, () => {\n\t\t\t\tlogger.info(`MCP SSE server listening on port ${listenPort}`);\n\t\t\t\tresolve();\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate handleSSEConnection(res: ServerResponse): void {\n\t\tres.writeHead(200, {\n\t\t\t'Content-Type': 'text/event-stream',\n\t\t\t'Cache-Control': 'no-cache',\n\t\t\tConnection: 'keep-alive',\n\t\t});\n\n\t\t// Send endpoint info as the first event so the client knows where to POST\n\t\tconst endpointEvent = JSON.stringify({ endpoint: '/message' });\n\t\tres.write(`event: endpoint\\ndata: ${endpointEvent}\\n\\n`);\n\n\t\tthis.sseClients.add(res);\n\t\tlogger.debug(`SSE client connected (total: ${this.sseClients.size})`);\n\n\t\tres.on('close', () => {\n\t\t\tthis.sseClients.delete(res);\n\t\t\tlogger.debug(`SSE client disconnected (total: ${this.sseClients.size})`);\n\t\t});\n\t}\n\n\tprivate async handleSSEMessage(req: IncomingMessage, res: ServerResponse): Promise<void> {\n\t\tlet body = '';\n\n\t\tfor await (const chunk of req) {\n\t\t\tbody += chunk;\n\t\t}\n\n\t\ttry {\n\t\t\tconst message = JSON.parse(body) as MCPRequest;\n\t\t\tconst response = await this.handleMessage(message);\n\n\t\t\tif (response) {\n\t\t\t\t// Send response both as HTTP response and as SSE event\n\t\t\t\tres.writeHead(200, { 'Content-Type': 'application/json' });\n\t\t\t\tres.end(JSON.stringify(response));\n\n\t\t\t\t// Also push to SSE stream for clients that expect it there\n\t\t\t\tconst serialized = JSON.stringify(response);\n\t\t\t\tfor (const client of this.sseClients) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tclient.write(`event: message\\ndata: ${serialized}\\n\\n`);\n\t\t\t\t\t} catch {\n\t\t\t\t\t\tthis.sseClients.delete(client);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Notification -- no response body\n\t\t\t\tres.writeHead(202);\n\t\t\t\tres.end();\n\t\t\t}\n\t\t} catch {\n\t\t\tres.writeHead(400, { 'Content-Type': 'application/json' });\n\t\t\tres.end(JSON.stringify({ jsonrpc: '2.0', id: 0, error: { code: -32700, message: 'Parse error' } }));\n\t\t}\n\t}\n\n\t/** Stop the SSE HTTP server and disconnect all clients. */\n\tasync stopSSE(): Promise<void> {\n\t\tfor (const client of this.sseClients) {\n\t\t\ttry {\n\t\t\t\tclient.end();\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\t\tthis.sseClients.clear();\n\n\t\tif (this.httpServer) {\n\t\t\treturn new Promise<void>((resolve) => {\n\t\t\t\tthis.httpServer!.close(() => {\n\t\t\t\t\tthis.httpServer = null;\n\t\t\t\t\tlogger.info('MCP SSE server stopped');\n\t\t\t\t\tresolve();\n\t\t\t\t});\n\t\t\t});\n\t\t}\n\t}\n\n\t/** Stop all transports and clean up. */\n\tasync stop(): Promise<void> {\n\t\tawait this.stopSSE();\n\t\tthis.subscriptions.clear();\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/commands/catalog/catalog.ts",
    "content": "import { z, type ZodTypeAny } from 'zod';\nimport type { CatalogEntry, CatalogOptions } from './types.js';\nimport type { CommandResult, ExecutionContext, CustomCommandSpec } from '../types.js';\nimport { CommandFailedError } from '../../errors.js';\nimport { escapeRegExp } from '../../utils.js';\n\n// ── Special parameter names ──\n// These parameter names, when found in a handler's function signature,\n// are automatically injected from the ExecutionContext instead of from\n// the action's validated params.\n\nconst SPECIAL_PARAMS = new Set([\n\t'browserSession',\n\t'cdpSession',\n\t'page',\n\t'domService',\n\t'extractionLlm',\n\t'fileSystem',\n\t'maskedValues',\n]);\n\n/**\n * Parse the parameter names from a function's source text.\n * Handles arrow functions, regular functions, destructured params, etc.\n */\nfunction inspectHandlerParams(handler: Function): string[] {\n\tconst source = handler.toString();\n\n\t// Match parameter list: function(a, b) / (a, b) => / async (a, b) =>\n\t// Also handles single param without parens: a =>\n\tconst arrowMatch = source.match(/^(?:async\\s+)?\\(([^)]*)\\)/);\n\tconst funcMatch = source.match(/^(?:async\\s+)?function\\s*\\w*\\s*\\(([^)]*)\\)/);\n\tconst singleParamArrow = source.match(/^(?:async\\s+)?(\\w+)\\s*=>/);\n\n\tlet paramString: string | undefined;\n\tif (arrowMatch) {\n\t\tparamString = arrowMatch[1];\n\t} else if (funcMatch) {\n\t\tparamString = funcMatch[1];\n\t} else if (singleParamArrow) {\n\t\treturn [singleParamArrow[1]];\n\t}\n\n\tif (!paramString || !paramString.trim()) {\n\t\treturn [];\n\t}\n\n\t// Split on commas, handling nested braces/brackets for destructuring\n\tconst params: string[] = [];\n\tlet depth = 0;\n\tlet current = '';\n\n\tfor (const char of paramString) {\n\t\tif (char === '{' || char === '[' || char === '(') {\n\t\t\tdepth++;\n\t\t\tcurrent += char;\n\t\t} else if (char === '}' || char === ']' || char === ')') {\n\t\t\tdepth--;\n\t\t\tcurrent += char;\n\t\t} else if (char === ',' && depth === 0) {\n\t\t\tparams.push(current.trim());\n\t\t\tcurrent = '';\n\t\t} else {\n\t\t\tcurrent += char;\n\t\t}\n\t}\n\tif (current.trim()) {\n\t\tparams.push(current.trim());\n\t}\n\n\t// Clean up: remove type annotations, defaults, destructuring\n\treturn params.map((p) => {\n\t\t// Remove default values: param = defaultVal\n\t\tconst withoutDefault = p.split('=')[0].trim();\n\t\t// Remove type annotations: param: Type\n\t\tconst withoutType = withoutDefault.split(':')[0].trim();\n\t\t// If it's a destructured param like { a, b }, keep the braces stripped name\n\t\t// For our purposes we only care about top-level named params\n\t\treturn withoutType.replace(/^[{[(]|[})\\]]$/g, '').trim();\n\t});\n}\n\n/**\n * Detect which special parameters a handler function expects,\n * based on its parameter names (beyond the standard params + context args).\n */\nfunction detectSpecialParams(handler: Function): Set<string> {\n\tconst paramNames = inspectHandlerParams(handler);\n\tconst detected = new Set<string>();\n\tfor (const name of paramNames) {\n\t\tif (SPECIAL_PARAMS.has(name)) {\n\t\t\tdetected.add(name);\n\t\t}\n\t}\n\treturn detected;\n}\n\n/**\n * Resolve a special parameter value from the ExecutionContext.\n */\nfunction resolveSpecialParam(\n\tname: string,\n\tcontext: ExecutionContext,\n): unknown {\n\tswitch (name) {\n\t\tcase 'browserSession':\n\t\t\treturn context.browserSession;\n\t\tcase 'cdpSession':\n\t\t\treturn context.cdpSession;\n\t\tcase 'page':\n\t\t\treturn context.page;\n\t\tcase 'domService':\n\t\t\treturn context.domService;\n\t\tcase 'extractionLlm':\n\t\t\treturn context.extractionLlm;\n\t\tcase 'fileSystem':\n\t\t\treturn context.fileSystem;\n\t\tcase 'maskedValues':\n\t\t\treturn context.maskedValues;\n\t\tdefault:\n\t\t\treturn undefined;\n\t}\n}\n\nexport class CommandCatalog {\n\tprivate actions = new Map<string, CatalogEntry>();\n\tprivate specialParamsCache = new Map<string, Set<string>>();\n\tprivate options: CatalogOptions;\n\n\tconstructor(options?: CatalogOptions) {\n\t\tthis.options = options ?? {};\n\t}\n\n\tregister(action: CatalogEntry): void {\n\t\tif (this.options.excludeActions?.includes(action.name)) return;\n\t\tif (\n\t\t\tthis.options.includeActions &&\n\t\t\tthis.options.includeActions.length > 0 &&\n\t\t\t!this.options.includeActions.includes(action.name)\n\t\t) {\n\t\t\treturn;\n\t\t}\n\n\t\tthis.actions.set(action.name, action);\n\n\t\t// Pre-compute which special parameters the handler expects\n\t\tconst specialParams = detectSpecialParams(action.handler);\n\t\tif (specialParams.size > 0) {\n\t\t\tthis.specialParamsCache.set(action.name, specialParams);\n\t\t}\n\t}\n\n\tregisterCustom(definition: CustomCommandSpec): void {\n\t\tthis.register({\n\t\t\tname: definition.name,\n\t\t\tdescription: definition.description,\n\t\t\tschema: definition.schema,\n\t\t\thandler: definition.handler,\n\t\t\tterminatesSequence: definition.terminatesSequence,\n\t\t});\n\t}\n\n\tunregister(name: string): void {\n\t\tthis.actions.delete(name);\n\t\tthis.specialParamsCache.delete(name);\n\t}\n\n\tget(name: string): CatalogEntry | undefined {\n\t\treturn this.actions.get(name);\n\t}\n\n\thas(name: string): boolean {\n\t\treturn this.actions.has(name);\n\t}\n\n\tgetAll(): CatalogEntry[] {\n\t\treturn [...this.actions.values()];\n\t}\n\n\tgetNames(): string[] {\n\t\treturn [...this.actions.keys()];\n\t}\n\n\tasync execute(\n\t\tname: string,\n\t\tparams: Record<string, unknown>,\n\t\tcontext: ExecutionContext,\n\t): Promise<CommandResult> {\n\t\tconst action = this.actions.get(name);\n\t\tif (!action) {\n\t\t\tthrow new CommandFailedError(name, `Action \"${name}\" is not registered`);\n\t\t}\n\n\t\ttry {\n\t\t\t// Validate params against schema\n\t\t\tconst validated = action.schema.parse(params);\n\n\t\t\t// Inject special parameters from context into the validated params\n\t\t\tconst enriched = this.injectSpecialParams(name, validated, context);\n\n\t\t\treturn await action.handler(enriched, context);\n\t\t} catch (error) {\n\t\t\tif (error instanceof CommandFailedError) throw error;\n\n\t\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\t\tthrow new CommandFailedError(name, message, {\n\t\t\t\tcause: error instanceof Error ? error : undefined,\n\t\t\t});\n\t\t}\n\t}\n\n\t/**\n\t * Return the set of special parameter names detected for a given action.\n\t * Returns an empty set if no special params were detected.\n\t */\n\tgetSpecialParams(name: string): Set<string> {\n\t\treturn this.specialParamsCache.get(name) ?? new Set();\n\t}\n\n\t/**\n\t * Inject special parameters from ExecutionContext into the params object.\n\t * Special params are resolved from context and merged into the params\n\t * so the handler can destructure them directly from its first argument.\n\t */\n\tprivate injectSpecialParams(\n\t\tactionName: string,\n\t\tparams: Record<string, unknown>,\n\t\tcontext: ExecutionContext,\n\t): Record<string, unknown> {\n\t\tconst specialParams = this.specialParamsCache.get(actionName);\n\t\tif (!specialParams || specialParams.size === 0) {\n\t\t\treturn params;\n\t\t}\n\n\t\tconst enriched = { ...params };\n\t\tfor (const paramName of specialParams) {\n\t\t\t// Only inject if not already present in the validated params\n\t\t\tif (!(paramName in enriched)) {\n\t\t\t\tconst value = resolveSpecialParam(paramName, context);\n\t\t\t\tif (value !== undefined) {\n\t\t\t\t\tenriched[paramName] = value;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn enriched;\n\t}\n\n\tbuildDynamicSchema(): z.ZodType {\n\t\tconst actionSchemas = this.getAll().map((action) => {\n\t\t\tif (action.schema instanceof z.ZodObject) {\n\t\t\t\treturn action.schema.extend({\n\t\t\t\t\taction: z.literal(action.name),\n\t\t\t\t});\n\t\t\t}\n\t\t\treturn action.schema;\n\t\t});\n\n\t\tif (actionSchemas.length === 0) {\n\t\t\treturn z.object({ action: z.string() });\n\t\t}\n\n\t\tif (actionSchemas.length === 1) {\n\t\t\treturn actionSchemas[0];\n\t\t}\n\n\t\treturn z.union(actionSchemas as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]]);\n\t}\n\n\tget size(): number {\n\t\treturn this.actions.size;\n\t}\n\n\t// ── Prompt description ──\n\n\t/**\n\t * Build a formatted multi-line description of all available actions.\n\t * Optionally filter by page URL domain so only relevant actions appear.\n\t */\n\tgetPromptDescription(pageUrl?: string): string {\n\t\tlet actions = this.getAll();\n\n\t\t// If a URL is provided, filter out actions whose domainFilter does not match\n\t\tif (pageUrl) {\n\t\t\tconst domain = extractDomain(pageUrl);\n\t\t\tif (domain) {\n\t\t\t\tactions = actions.filter((a) => {\n\t\t\t\t\t// Actions without a domainFilter are always shown\n\t\t\t\t\tif (!a.domainFilter || a.domainFilter.length === 0) return true;\n\t\t\t\t\treturn a.domainFilter.some(\n\t\t\t\t\t\t(pattern) =>\n\t\t\t\t\t\t\tdomain === pattern ||\n\t\t\t\t\t\t\tdomain.endsWith(`.${pattern}`),\n\t\t\t\t\t);\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\n\t\tconst lines: string[] = [];\n\t\tfor (const action of actions) {\n\t\t\tconst termFlag = action.terminatesSequence ? ' [terminates]' : '';\n\t\t\tlines.push(`- ${action.name}: ${action.description}${termFlag}`);\n\n\t\t\t// Describe the schema parameters\n\t\t\tif (action.schema instanceof z.ZodObject) {\n\t\t\t\tconst shape = action.schema.shape as Record<string, ZodTypeAny>;\n\t\t\t\tfor (const [key, zodType] of Object.entries(shape)) {\n\t\t\t\t\tif (key === 'action') continue;\n\t\t\t\t\tconst desc = zodType.description ?? '';\n\t\t\t\t\tconst isOptional = zodType.isOptional?.() ?? false;\n\t\t\t\t\tconst optLabel = isOptional ? ' (optional)' : '';\n\t\t\t\t\tlines.push(`    ${key}${optLabel}: ${desc}`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn lines.join('\\n');\n\t}\n\n\t// ── Domain-based filtering ──\n\n\t/**\n\t * Return actions that have a domainFilter matching the given domain,\n\t * plus all actions that have no domainFilter (universal actions).\n\t */\n\tgetActionsForDomain(domain: string): CatalogEntry[] {\n\t\tconst normalized = domain.replace(/^www\\./, '').toLowerCase();\n\n\t\treturn this.getAll().filter((action) => {\n\t\t\tif (!action.domainFilter || action.domainFilter.length === 0) return true;\n\n\t\t\treturn action.domainFilter.some((pattern) => {\n\t\t\t\tconst p = pattern.toLowerCase();\n\t\t\t\treturn normalized === p || normalized.endsWith(`.${p}`);\n\t\t\t});\n\t\t});\n\t}\n\n\t// ── Sensitive data replacement ──\n\n\t/**\n\t * Replace sensitive data values in text with `<key>` placeholders.\n\t * Keys are sorted longest-value-first to avoid partial replacements.\n\t */\n\treplaceSensitiveData(\n\t\ttext: string,\n\t\tmaskedValues: Record<string, string>,\n\t): string {\n\t\tif (!text) return text;\n\n\t\t// Sort entries by value length descending so longer values are replaced first\n\t\tconst entries = Object.entries(maskedValues).sort(\n\t\t\t(a, b) => b[1].length - a[1].length,\n\t\t);\n\n\t\tlet result = text;\n\t\tfor (const [key, value] of entries) {\n\t\t\tif (!value) continue;\n\t\t\tconst pattern = new RegExp(escapeRegExp(value), 'g');\n\t\t\tresult = result.replace(pattern, `<${key}>`);\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t// ── Actions that terminate the sequence ──\n\n\t/**\n\t * Return the names of all actions marked as terminatesSequence.\n\t */\n\tgetTerminatingActions(): string[] {\n\t\treturn this.getAll()\n\t\t\t.filter((a) => a.terminatesSequence)\n\t\t\t.map((a) => a.name);\n\t}\n\n\t/**\n\t * Check whether a given action name is marked as terminatesSequence.\n\t */\n\tisTerminating(name: string): boolean {\n\t\tconst action = this.actions.get(name);\n\t\treturn action?.terminatesSequence === true;\n\t}\n}\n\n// ── Helpers ──\n\nfunction extractDomain(url: string): string | null {\n\ttry {\n\t\treturn new URL(url).hostname.replace(/^www\\./, '').toLowerCase();\n\t} catch {\n\t\treturn null;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/commands/catalog/types.ts",
    "content": "import type { z } from 'zod';\nimport type { CommandResult, ExecutionContext } from '../types.js';\n\nexport interface CatalogEntry {\n\tname: string;\n\tdescription: string;\n\tschema: z.ZodTypeAny;\n\thandler: (params: Record<string, unknown>, context: ExecutionContext) => Promise<CommandResult>;\n\tterminatesSequence?: boolean;\n\tdomainFilter?: string[];\n}\n\nexport interface CatalogOptions {\n\texcludeActions?: string[];\n\tincludeActions?: string[];\n}\n"
  },
  {
    "path": "packages/core/src/commands/catalog.test.ts",
    "content": "import { test, expect, describe, beforeEach, mock } from 'bun:test';\nimport { z } from 'zod';\nimport { CommandCatalog } from './catalog/catalog.js';\nimport { CommandFailedError } from '../errors.js';\nimport type { ExecutionContext, CommandResult } from './types.js';\n\n// ── Helpers ──\n\nfunction makeHandler(\n\tresult: CommandResult = { success: true },\n): (params: Record<string, unknown>, ctx: ExecutionContext) => Promise<CommandResult> {\n\treturn mock(() => Promise.resolve(result));\n}\n\nfunction makeContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {\n\treturn {\n\t\tpage: {} as any,\n\t\tcdpSession: {} as any,\n\t\tdomService: {} as any,\n\t\tbrowserSession: {} as any,\n\t\t...overrides,\n\t};\n}\n\nconst testSchema = z.object({\n\tvalue: z.string(),\n\tcount: z.number().optional(),\n});\n\n// ── Tests ──\n\ndescribe('CommandCatalog', () => {\n\tlet registry: CommandCatalog\n\n\tbeforeEach(() => {\n\t\tregistry = new CommandCatalog();\n\t});\n\n\tdescribe('register and unregister', () => {\n\t\ttest('registers an action', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'test_action',\n\t\t\t\tdescription: 'A test action',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\texpect(registry.has('test_action')).toBe(true);\n\t\t\texpect(registry.size).toBe(1);\n\t\t});\n\n\t\ttest('unregisters an action', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'test_action',\n\t\t\t\tdescription: 'A test action',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tregistry.unregister('test_action');\n\t\t\texpect(registry.has('test_action')).toBe(false);\n\t\t\texpect(registry.size).toBe(0);\n\t\t});\n\n\t\ttest('get returns registered action', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'my_action',\n\t\t\t\tdescription: 'Mine',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst action = registry.get('my_action');\n\t\t\texpect(action).toBeDefined();\n\t\t\texpect(action!.name).toBe('my_action');\n\t\t\texpect(action!.description).toBe('Mine');\n\t\t});\n\n\t\ttest('get returns undefined for unregistered action', () => {\n\t\t\texpect(registry.get('nonexistent')).toBeUndefined();\n\t\t});\n\n\t\ttest('respects excludeActions option', () => {\n\t\t\tconst filtered = new CommandCatalog({ excludeActions: ['blocked'] });\n\n\t\t\tfiltered.register({\n\t\t\t\tname: 'blocked',\n\t\t\t\tdescription: 'Should not register',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tfiltered.register({\n\t\t\t\tname: 'allowed',\n\t\t\t\tdescription: 'Should register',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\texpect(filtered.has('blocked')).toBe(false);\n\t\t\texpect(filtered.has('allowed')).toBe(true);\n\t\t});\n\n\t\ttest('respects includeActions option', () => {\n\t\t\tconst filtered = new CommandCatalog({ includeActions: ['only_this'] });\n\n\t\t\tfiltered.register({\n\t\t\t\tname: 'only_this',\n\t\t\t\tdescription: 'Should register',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tfiltered.register({\n\t\t\t\tname: 'other',\n\t\t\t\tdescription: 'Should not register',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\texpect(filtered.has('only_this')).toBe(true);\n\t\t\texpect(filtered.has('other')).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('getAll and getNames', () => {\n\t\ttest('returns all registered actions', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'alpha',\n\t\t\t\tdescription: 'Alpha',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\t\t\tregistry.register({\n\t\t\t\tname: 'beta',\n\t\t\t\tdescription: 'Beta',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst all = registry.getAll();\n\t\t\texpect(all).toHaveLength(2);\n\n\t\t\tconst names = registry.getNames();\n\t\t\texpect(names).toContain('alpha');\n\t\t\texpect(names).toContain('beta');\n\t\t});\n\t});\n\n\tdescribe('execute', () => {\n\t\ttest('executes registered action with valid params', async () => {\n\t\t\tconst handler = makeHandler({ success: true, extractedContent: 'result' });\n\t\t\tregistry.register({\n\t\t\t\tname: 'exec_test',\n\t\t\t\tdescription: 'Test execute',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler,\n\t\t\t});\n\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await registry.execute('exec_test', { value: 'hello' }, ctx);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(result.extractedContent).toBe('result');\n\t\t\texpect(handler).toHaveBeenCalledTimes(1);\n\t\t});\n\n\t\ttest('throws CommandFailedError for unregistered action', async () => {\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait expect(\n\t\t\t\tregistry.execute('nonexistent', {}, ctx),\n\t\t\t).rejects.toThrow(CommandFailedError);\n\t\t});\n\n\t\ttest('throws CommandFailedError when schema validation fails', async () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'strict',\n\t\t\t\tdescription: 'Strict schema',\n\t\t\t\tschema: z.object({ required: z.string() }),\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait expect(\n\t\t\t\tregistry.execute('strict', { wrong: 'param' }, ctx),\n\t\t\t).rejects.toThrow(CommandFailedError);\n\t\t});\n\n\t\ttest('wraps handler errors in CommandFailedError', async () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'failing',\n\t\t\t\tdescription: 'Fails',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: async () => {\n\t\t\t\t\tthrow new Error('Internal failure');\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait expect(\n\t\t\t\tregistry.execute('failing', { value: 'x' }, ctx),\n\t\t\t).rejects.toThrow(CommandFailedError);\n\t\t});\n\n\t\ttest('re-throws CommandFailedError without wrapping', async () => {\n\t\t\tconst original = new CommandFailedError('tool', 'original error');\n\t\t\tregistry.register({\n\t\t\t\tname: 'rethrow',\n\t\t\t\tdescription: 'Rethrow',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: async () => {\n\t\t\t\t\tthrow original;\n\t\t\t\t},\n\t\t\t});\n\n\t\t\tconst ctx = makeContext();\n\n\t\t\ttry {\n\t\t\t\tawait registry.execute('rethrow', { value: 'x' }, ctx);\n\t\t\t\texpect.unreachable('Should have thrown');\n\t\t\t} catch (error) {\n\t\t\t\texpect(error).toBe(original);\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe('domain-based filtering', () => {\n\t\ttest('returns universal actions for any domain', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'universal',\n\t\t\t\tdescription: 'No filter',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst actions = registry.getActionsForDomain('example.com');\n\t\t\texpect(actions.map((a) => a.name)).toContain('universal');\n\t\t});\n\n\t\ttest('returns domain-specific actions matching the domain', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'github_only',\n\t\t\t\tdescription: 'GitHub',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tdomainFilter: ['github.com'],\n\t\t\t});\n\n\t\t\tconst githubActions = registry.getActionsForDomain('github.com');\n\t\t\texpect(githubActions.map((a) => a.name)).toContain('github_only');\n\n\t\t\tconst otherActions = registry.getActionsForDomain('example.com');\n\t\t\texpect(otherActions.map((a) => a.name)).not.toContain('github_only');\n\t\t});\n\n\t\ttest('matches subdomains', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'google_all',\n\t\t\t\tdescription: 'Google subdomains',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tdomainFilter: ['google.com'],\n\t\t\t});\n\n\t\t\tconst actions = registry.getActionsForDomain('mail.google.com');\n\t\t\texpect(actions.map((a) => a.name)).toContain('google_all');\n\t\t});\n\n\t\ttest('strips www prefix from domain', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'example',\n\t\t\t\tdescription: 'Example',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tdomainFilter: ['example.com'],\n\t\t\t});\n\n\t\t\tconst actions = registry.getActionsForDomain('www.example.com');\n\t\t\texpect(actions.map((a) => a.name)).toContain('example');\n\t\t});\n\t});\n\n\tdescribe('terminatesSequence flag', () => {\n\t\ttest('isTerminating returns true for terminating actions', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'finish',\n\t\t\t\tdescription: 'Finish',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tterminatesSequence: true,\n\t\t\t});\n\n\t\t\texpect(registry.isTerminating('finish')).toBe(true);\n\t\t});\n\n\t\ttest('isTerminating returns false for non-terminating actions', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'continue',\n\t\t\t\tdescription: 'Continue',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\texpect(registry.isTerminating('continue')).toBe(false);\n\t\t});\n\n\t\ttest('getTerminatingActions returns all terminating action names', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'finish',\n\t\t\t\tdescription: 'Done',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tterminatesSequence: true,\n\t\t\t});\n\t\t\tregistry.register({\n\t\t\t\tname: 'abort',\n\t\t\t\tdescription: 'Abort',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tterminatesSequence: true,\n\t\t\t});\n\t\t\tregistry.register({\n\t\t\t\tname: 'tap',\n\t\t\t\tdescription: 'Click',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst terminating = registry.getTerminatingActions();\n\t\t\texpect(terminating).toContain('finish');\n\t\t\texpect(terminating).toContain('abort');\n\t\t\texpect(terminating).not.toContain('tap');\n\t\t});\n\t});\n\n\tdescribe('getPromptDescription', () => {\n\t\ttest('returns formatted description of all actions', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'tap',\n\t\t\t\tdescription: 'Click on an element',\n\t\t\t\tschema: z.object({\n\t\t\t\t\tindex: z.number().describe('Element index'),\n\t\t\t\t}),\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\t\t\tregistry.register({\n\t\t\t\tname: 'finish',\n\t\t\t\tdescription: 'Mark task as done',\n\t\t\t\tschema: z.object({\n\t\t\t\t\ttext: z.string().describe('Result text'),\n\t\t\t\t}),\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tterminatesSequence: true,\n\t\t\t});\n\n\t\t\tconst desc = registry.getPromptDescription();\n\t\t\texpect(desc).toContain('- tap: Click on an element');\n\t\t\texpect(desc).toContain('index');\n\t\t\texpect(desc).toContain('Element index');\n\t\t\texpect(desc).toContain('- finish: Mark task as done [terminates]');\n\t\t});\n\n\t\ttest('filters by page URL domain', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'universal',\n\t\t\t\tdescription: 'Universal action',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\t\t\tregistry.register({\n\t\t\t\tname: 'github_only',\n\t\t\t\tdescription: 'GitHub action',\n\t\t\t\tschema: testSchema,\n\t\t\t\thandler: makeHandler(),\n\t\t\t\tdomainFilter: ['github.com'],\n\t\t\t});\n\n\t\t\tconst githubDesc = registry.getPromptDescription('https://github.com/repo');\n\t\t\texpect(githubDesc).toContain('universal');\n\t\t\texpect(githubDesc).toContain('github_only');\n\n\t\t\tconst otherDesc = registry.getPromptDescription('https://example.com');\n\t\t\texpect(otherDesc).toContain('universal');\n\t\t\texpect(otherDesc).not.toContain('github_only');\n\t\t});\n\t});\n\n\tdescribe('sensitive data replacement', () => {\n\t\ttest('replaces sensitive values with placeholders', () => {\n\t\t\tconst result = registry.replaceSensitiveData(\n\t\t\t\t'The password is hunter2 and the key is abc123',\n\t\t\t\t{ PASSWORD: 'hunter2', API_KEY: 'abc123' },\n\t\t\t);\n\n\t\t\texpect(result).toBe('The password is <PASSWORD> and the key is <API_KEY>');\n\t\t});\n\n\t\ttest('replaces longer values first to avoid partial replacements', () => {\n\t\t\tconst result = registry.replaceSensitiveData(\n\t\t\t\t'Token: my-long-secret-token and key: secret',\n\t\t\t\t{ TOKEN: 'my-long-secret-token', KEY: 'secret' },\n\t\t\t);\n\n\t\t\t// \"my-long-secret-token\" should be replaced first, not the inner \"secret\"\n\t\t\texpect(result).toBe('Token: <TOKEN> and key: <KEY>');\n\t\t});\n\n\t\ttest('handles empty text', () => {\n\t\t\tconst result = registry.replaceSensitiveData('', { KEY: 'value' });\n\t\t\texpect(result).toBe('');\n\t\t});\n\n\t\ttest('handles empty sensitive data', () => {\n\t\t\tconst result = registry.replaceSensitiveData('some text', {});\n\t\t\texpect(result).toBe('some text');\n\t\t});\n\n\t\ttest('handles special regex characters in values', () => {\n\t\t\tconst result = registry.replaceSensitiveData(\n\t\t\t\t'Found: $100.00 (USD)',\n\t\t\t\t{ PRICE: '$100.00' },\n\t\t\t);\n\n\t\t\texpect(result).toBe('Found: <PRICE> (USD)');\n\t\t});\n\t});\n\n\tdescribe('parameter inspection and injection', () => {\n\t\ttest('detects special parameters from handler function', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'with_page',\n\t\t\t\tdescription: 'Uses page',\n\t\t\t\tschema: z.object({}),\n\t\t\t\thandler: async (params, ctx) => {\n\t\t\t\t\treturn { success: true };\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// The handler doesn't use named special params, so set should be empty\n\t\t\tconst special = registry.getSpecialParams('with_page');\n\t\t\texpect(special.size).toBe(0);\n\t\t});\n\n\t\ttest('returns empty set for unregistered action', () => {\n\t\t\tconst special = registry.getSpecialParams('nonexistent');\n\t\t\texpect(special.size).toBe(0);\n\t\t});\n\t});\n\n\tdescribe('buildDynamicSchema', () => {\n\t\ttest('builds a union schema from registered actions', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'tap',\n\t\t\t\tdescription: 'Click',\n\t\t\t\tschema: z.object({ index: z.number() }),\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\t\t\tregistry.register({\n\t\t\t\tname: 'finish',\n\t\t\t\tdescription: 'Done',\n\t\t\t\tschema: z.object({ text: z.string() }),\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst schema = registry.buildDynamicSchema();\n\t\t\texpect(schema).toBeDefined();\n\n\t\t\t// Should parse a click action\n\t\t\tconst clickResult = schema.safeParse({ action: 'tap', index: 5 });\n\t\t\texpect(clickResult.success).toBe(true);\n\n\t\t\t// Should parse a done action\n\t\t\tconst doneResult = schema.safeParse({ action: 'finish', text: 'finished' });\n\t\t\texpect(doneResult.success).toBe(true);\n\t\t});\n\n\t\ttest('returns simple object schema when no actions registered', () => {\n\t\t\tconst schema = registry.buildDynamicSchema();\n\t\t\tconst result = schema.safeParse({ action: 'anything' });\n\t\t\texpect(result.success).toBe(true);\n\t\t});\n\n\t\ttest('returns single schema when only one action registered', () => {\n\t\t\tregistry.register({\n\t\t\t\tname: 'only',\n\t\t\t\tdescription: 'Only action',\n\t\t\t\tschema: z.object({ x: z.number() }),\n\t\t\t\thandler: makeHandler(),\n\t\t\t});\n\n\t\t\tconst schema = registry.buildDynamicSchema();\n\t\t\tconst result = schema.safeParse({ action: 'only', x: 42 });\n\t\t\texpect(result.success).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('registerCustom', () => {\n\t\ttest('registers a custom action definition', () => {\n\t\t\tregistry.registerCustom({\n\t\t\t\tname: 'custom_action',\n\t\t\t\tdescription: 'A custom action',\n\t\t\t\tschema: z.object({ query: z.string() }),\n\t\t\t\thandler: async () => ({ success: true }),\n\t\t\t});\n\n\t\t\texpect(registry.has('custom_action')).toBe(true);\n\t\t});\n\n\t\ttest('registers with terminatesSequence flag', () => {\n\t\t\tregistry.registerCustom({\n\t\t\t\tname: 'custom_done',\n\t\t\t\tdescription: 'Custom done',\n\t\t\t\tschema: z.object({}),\n\t\t\t\thandler: async () => ({ success: true, isDone: true }),\n\t\t\t\tterminatesSequence: true,\n\t\t\t});\n\n\t\t\texpect(registry.isTerminating('custom_done')).toBe(true);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/commands/executor.test.ts",
    "content": "import { test, expect, describe, beforeEach, mock } from 'bun:test';\nimport { CommandExecutor } from './executor.js';\nimport type { Command, ExecutionContext, CommandResult } from './types.js';\nimport { UrlBlockedError, CommandFailedError } from '../errors.js';\n\n// ── Mock factories ──\n\nfunction makeMockPageAnalyzer() {\n\treturn {\n\t\tclickElementByIndex: mock(() => Promise.resolve()),\n\t\tinputTextByIndex: mock(() => Promise.resolve()),\n\t\tgetElementSelector: mock(() => Promise.resolve('#selector')),\n\t\textractState: mock(() =>\n\t\t\tPromise.resolve({\n\t\t\t\ttree: '<html></html>',\n\t\t\t\tselectorMap: {},\n\t\t\t\telementCount: 0,\n\t\t\t\tinteractiveElementCount: 0,\n\t\t\t\tscrollPosition: { x: 0, y: 0 },\n\t\t\t\tviewportSize: { width: 1280, height: 800 },\n\t\t\t\tdocumentSize: { width: 1280, height: 2000 },\n\t\t\t\tpixelsAbove: 0,\n\t\t\t\tpixelsBelow: 0,\n\t\t\t}),\n\t\t),\n\t} as any;\n}\n\nfunction makeMockViewport() {\n\treturn {\n\t\tnavigate: mock(() => Promise.resolve()),\n\t\twaitForPageReady: mock(() => Promise.resolve()),\n\t\tswitchTab: mock(() => Promise.resolve()),\n\t\tnewTab: mock(() => Promise.resolve()),\n\t\tcloseTab: mock(() => Promise.resolve()),\n\t\tscreenshot: mock(() =>\n\t\t\tPromise.resolve({ base64: 'abc', width: 1280, height: 800 }),\n\t\t),\n\t\tcurrentPage: makeMockPage(),\n\t\tcdp: makeMockCdpSession(),\n\t\tisConnected: true,\n\t} as any;\n}\n\nfunction makeMockPage() {\n\treturn {\n\t\tgoBack: mock(() => Promise.resolve()),\n\t\tevaluate: mock(() => Promise.resolve([])),\n\t\tmouse: {\n\t\t\tclick: mock(() => Promise.resolve()),\n\t\t},\n\t\tkeyboard: {\n\t\t\tpress: mock(() => Promise.resolve()),\n\t\t},\n\t\tfill: mock(() => Promise.resolve()),\n\t\tclick: mock(() => Promise.resolve()),\n\t\tselectOption: mock(() => Promise.resolve()),\n\t\t$: mock(() => Promise.resolve({ setInputFiles: mock(() => Promise.resolve()) })),\n\t} as any;\n}\n\nfunction makeMockCdpSession() {\n\treturn {\n\t\tsend: mock(() => Promise.resolve({})),\n\t} as any;\n}\n\nfunction makeContext(overrides: Partial<ExecutionContext> = {}): ExecutionContext {\n\tconst browser = makeMockViewport();\n\treturn {\n\t\tpage: browser.currentPage,\n\t\tcdpSession: browser.cdp,\n\t\tdomService: makeMockPageAnalyzer(),\n\t\tbrowserSession: browser,\n\t\t...overrides,\n\t};\n}\n\n/**\n * Helper to create action objects. Zod schemas with .default() produce\n * required fields in the inferred output type, but at runtime the defaults\n * are applied during validation. We cast through `any` to allow omitting\n * fields that have Zod defaults.\n */\nfunction action(a: Record<string, unknown>): Command {\n\treturn a as Command;\n}\n\n// ── Tests ──\n\ndescribe('CommandExecutor', () => {\n\tlet tools: CommandExecutor;\n\n\tbeforeEach(() => {\n\t\ttools = new CommandExecutor();\n\t});\n\n\tdescribe('constructor and registration', () => {\n\t\ttest('registers all built-in actions', () => {\n\t\t\tconst names = tools.registry.getNames();\n\t\t\texpect(names).toContain('tap');\n\t\t\texpect(names).toContain('type_text');\n\t\t\texpect(names).toContain('navigate');\n\t\t\texpect(names).toContain('back');\n\t\t\texpect(names).toContain('scroll');\n\t\t\texpect(names).toContain('press_keys');\n\t\t\texpect(names).toContain('extract');\n\t\t\texpect(names).toContain('finish');\n\t\t\texpect(names).toContain('focus_tab');\n\t\t\texpect(names).toContain('new_tab');\n\t\t\texpect(names).toContain('close_tab');\n\t\t\texpect(names).toContain('web_search');\n\t\t\texpect(names).toContain('capture');\n\t\t\texpect(names).toContain('read_page');\n\t\t\texpect(names).toContain('wait');\n\t\t\texpect(names).toContain('scroll_to');\n\t\t\texpect(names).toContain('find');\n\t\t\texpect(names).toContain('search');\n\t\t\texpect(names).toContain('extract_structured');\n\t\t});\n\n\t\ttest('has default commandsPerStep of 10', () => {\n\t\t\texpect(tools.commandsPerStep).toBe(10);\n\t\t});\n\n\t\ttest('respects custom commandsPerStep', () => {\n\t\t\tconst custom = new CommandExecutor({ commandsPerStep: 5 });\n\t\t\texpect(custom.commandsPerStep).toBe(5);\n\t\t});\n\t});\n\n\tdescribe('click action', () => {\n\t\ttest('delegates to domService.clickElementByIndex', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'tap', index: 0 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.domService.clickElementByIndex).toHaveBeenCalledWith(\n\t\t\t\tctx.page,\n\t\t\t\tctx.cdpSession,\n\t\t\t\t0,\n\t\t\t);\n\t\t});\n\n\t\ttest('supports multiple clicks via clickCount', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tawait tools.executeAction(\n\t\t\t\taction({ action: 'tap', index: 0, clickCount: 3 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\t// First call + 2 additional\n\t\t\texpect(ctx.domService.clickElementByIndex).toHaveBeenCalledTimes(3);\n\t\t});\n\n\t\ttest('uses coordinate-based clicking when enabled', async () => {\n\t\t\ttools.setCoordinateClicking(true);\n\t\t\tconst ctx = makeContext();\n\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'tap', index: 0, coordinateX: 100, coordinateY: 200 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.page.mouse.click).toHaveBeenCalledWith(100, 200);\n\t\t\t// domService should NOT have been called\n\t\t\texpect(ctx.domService.clickElementByIndex).not.toHaveBeenCalled();\n\t\t});\n\n\t\ttest('coordinate click supports clickCount', async () => {\n\t\t\ttools.setCoordinateClicking(true);\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait tools.executeAction(\n\t\t\t\taction({ action: 'tap', index: 0, coordinateX: 50, coordinateY: 50, clickCount: 2 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(ctx.page.mouse.click).toHaveBeenCalledTimes(2);\n\t\t});\n\n\t\ttest('falls back to index-based click when coordinate clicking disabled', async () => {\n\t\t\t// Default: coordinate clicking is disabled\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait tools.executeAction(\n\t\t\t\taction({ action: 'tap', index: 0, coordinateX: 100, coordinateY: 200 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\t// Should use domService, not coordinates\n\t\t\texpect(ctx.domService.clickElementByIndex).toHaveBeenCalled();\n\t\t});\n\t});\n\n\tdescribe('navigate action', () => {\n\t\ttest('navigates to valid URL', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'navigate', url: 'https://example.com' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.browserSession.navigate).toHaveBeenCalledWith('https://example.com');\n\t\t});\n\n\t\ttest('throws CommandFailedError wrapping UrlBlockedError for blocked URL', async () => {\n\t\t\tconst restricted = new CommandExecutor({ blockedUrls: ['evil.com'] });\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait expect(\n\t\t\t\trestricted.executeAction(\n\t\t\t\t\taction({ action: 'navigate', url: 'https://evil.com/page' }),\n\t\t\t\t\tctx,\n\t\t\t\t),\n\t\t\t).rejects.toThrow(CommandFailedError);\n\t\t});\n\n\t\ttest('throws when URL not in allowlist', async () => {\n\t\t\tconst restricted = new CommandExecutor({ allowedUrls: ['safe.com'] });\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait expect(\n\t\t\t\trestricted.executeAction(\n\t\t\t\t\taction({ action: 'navigate', url: 'https://other.com' }),\n\t\t\t\t\tctx,\n\t\t\t\t),\n\t\t\t).rejects.toThrow(CommandFailedError);\n\t\t});\n\t});\n\n\tdescribe('input_text action', () => {\n\t\ttest('inputs text into element', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'type_text', index: 3, text: 'hello' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.domService.inputTextByIndex).toHaveBeenCalledWith(\n\t\t\t\tctx.page,\n\t\t\t\tctx.cdpSession,\n\t\t\t\t3,\n\t\t\t\t'hello',\n\t\t\t\ttrue, // clearFirst defaults to true\n\t\t\t);\n\t\t});\n\n\t\ttest('passes clearFirst=false when specified', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tawait tools.executeAction(\n\t\t\t\taction({ action: 'type_text', index: 0, text: 'append', clearFirst: false }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(ctx.domService.inputTextByIndex).toHaveBeenCalledWith(\n\t\t\t\tctx.page,\n\t\t\t\tctx.cdpSession,\n\t\t\t\t0,\n\t\t\t\t'append',\n\t\t\t\tfalse,\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe('scroll action', () => {\n\t\ttest('scrolls the page when no index provided', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'scroll', direction: 'down' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t});\n\n\t\ttest('scrolls an element when index is provided', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'scroll', direction: 'up', index: 5 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.domService.getElementSelector).toHaveBeenCalledWith(5);\n\t\t});\n\t});\n\n\tdescribe('search_google action', () => {\n\t\ttest('navigates to Google search URL', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'web_search', query: 'bun test runner' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.browserSession.navigate).toHaveBeenCalled();\n\t\t\tconst navigateArg = (ctx.browserSession.navigate as any).mock.calls[0][0] as string;\n\t\t\texpect(navigateArg).toContain('google.com/search');\n\t\t\texpect(navigateArg).toContain('bun%20test%20runner');\n\t\t});\n\t});\n\n\tdescribe('done action', () => {\n\t\ttest('returns isDone=true with text', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'finish', text: 'Task completed successfully' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(result.isDone).toBe(true);\n\t\t\texpect(result.extractedContent).toBe('Task completed successfully');\n\t\t\texpect(result.includeInMemory).toBe(true);\n\t\t});\n\n\t\ttest('respects explicit success=false', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'finish', text: 'Could not complete', success: false }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(false);\n\t\t\texpect(result.isDone).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('go_back action', () => {\n\t\ttest('calls page.goBack and waits for ready', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'back' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.page.goBack).toHaveBeenCalled();\n\t\t\texpect(ctx.browserSession.waitForPageReady).toHaveBeenCalled();\n\t\t});\n\t});\n\n\tdescribe('send_keys action', () => {\n\t\ttest('presses keyboard keys', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'press_keys', keys: 'Enter' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.page.keyboard.press).toHaveBeenCalledWith('Enter');\n\t\t});\n\t});\n\n\tdescribe('find_elements action', () => {\n\t\ttest('returns found elements description', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tpage.evaluate = mock(() =>\n\t\t\t\tPromise.resolve([\n\t\t\t\t\t{ tag: 'button', text: 'Submit', attributes: { id: 'btn-submit' } },\n\t\t\t\t\t{ tag: 'a', text: 'Home', attributes: {} },\n\t\t\t\t]),\n\t\t\t);\n\t\t\tconst ctx = makeContext({ page });\n\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'find', query: 'submit' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(result.extractedContent).toContain('Found 2 element(s)');\n\t\t\texpect(result.extractedContent).toContain('button');\n\t\t\texpect(result.extractedContent).toContain('Submit');\n\t\t});\n\n\t\ttest('returns message when no elements found', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tpage.evaluate = mock(() => Promise.resolve([]));\n\t\t\tconst ctx = makeContext({ page });\n\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'find', query: 'nonexistent' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(result.extractedContent).toContain('No elements found');\n\t\t});\n\t});\n\n\tdescribe('extract_content action (fallback, no LLM)', () => {\n\t\ttest('returns error/fallback when no extraction service', async () => {\n\t\t\t// Tools without model won't have an extraction service\n\t\t\t// The handler falls back to extractMarkdown which we mock via page.evaluate\n\t\t\tconst ctx = makeContext();\n\t\t\t// extractMarkdown eventually calls page.evaluate\n\t\t\t// For this test, just verify no crash. The actual extractMarkdown module\n\t\t\t// import might require more setup, so we test the branch\n\t\t\ttry {\n\t\t\t\tawait tools.executeAction(\n\t\t\t\t\taction({ action: 'extract', goal: 'get all links' }),\n\t\t\t\t\tctx,\n\t\t\t\t);\n\t\t\t} catch {\n\t\t\t\t// Expected - extractMarkdown import/evaluation may fail in test env\n\t\t\t}\n\t\t});\n\t});\n\n\tdescribe('search_page action (multi-engine)', () => {\n\t\ttest('navigates to DuckDuckGo when specified', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'search', query: 'hello', engine: 'duckduckgo' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\tconst url = (ctx.browserSession.navigate as any).mock.calls[0][0] as string;\n\t\t\texpect(url).toContain('duckduckgo.com');\n\t\t});\n\n\t\ttest('navigates to Bing when specified', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'search', query: 'hello', engine: 'bing' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\tconst url = (ctx.browserSession.navigate as any).mock.calls[0][0] as string;\n\t\t\texpect(url).toContain('bing.com/search');\n\t\t});\n\n\t\ttest('defaults to Google', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tawait tools.executeAction(\n\t\t\t\taction({ action: 'search', query: 'hello' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\tconst url = (ctx.browserSession.navigate as any).mock.calls[0][0] as string;\n\t\t\texpect(url).toContain('google.com/search');\n\t\t});\n\t});\n\n\tdescribe('sensitive data masking', () => {\n\t\ttest('masks sensitive data in action results', async () => {\n\t\t\tconst ctx = makeContext({\n\t\t\t\tmaskedValues: {\n\t\t\t\t\tPASSWORD: 'secret123',\n\t\t\t\t\tAPI_KEY: 'sk-abc',\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Execute done action with text containing sensitive data\n\t\t\tconst result = await tools.executeActions(\n\t\t\t\t[action({ action: 'finish', text: 'Found password: secret123 and key: sk-abc' })],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result[0].success).toBe(true);\n\t\t\texpect(result[0].extractedContent).toContain('<PASSWORD>');\n\t\t\texpect(result[0].extractedContent).toContain('<API_KEY>');\n\t\t\texpect(result[0].extractedContent).not.toContain('secret123');\n\t\t\texpect(result[0].extractedContent).not.toContain('sk-abc');\n\t\t});\n\n\t\ttest('does not mask when no sensitive data configured', async () => {\n\t\t\tconst ctx = makeContext(); // no maskedValues\n\n\t\t\tconst result = await tools.executeActions(\n\t\t\t\t[action({ action: 'finish', text: 'Plain text with no secrets' })],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result[0].extractedContent).toBe('Plain text with no secrets');\n\t\t});\n\t});\n\n\tdescribe('action sequence execution', () => {\n\t\ttest('executes multiple actions in sequence', async () => {\n\t\t\tconst ctx = makeContext();\n\n\t\t\tconst results = await tools.executeActions(\n\t\t\t\t[\n\t\t\t\t\taction({ action: 'tap', index: 0 }),\n\t\t\t\t\taction({ action: 'tap', index: 1 }),\n\t\t\t\t],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results).toHaveLength(2);\n\t\t\texpect(results[0].success).toBe(true);\n\t\t\texpect(results[1].success).toBe(true);\n\t\t});\n\n\t\ttest('stops at done action', async () => {\n\t\t\tconst ctx = makeContext();\n\n\t\t\tconst results = await tools.executeActions(\n\t\t\t\t[\n\t\t\t\t\taction({ action: 'tap', index: 0 }),\n\t\t\t\t\taction({ action: 'finish', text: 'Finished' }),\n\t\t\t\t\taction({ action: 'tap', index: 1 }), // should not execute\n\t\t\t\t],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results).toHaveLength(2);\n\t\t\texpect(results[1].isDone).toBe(true);\n\t\t});\n\n\t\ttest('respects commandsPerStep limit', async () => {\n\t\t\tconst limited = new CommandExecutor({ commandsPerStep: 2 });\n\t\t\tconst ctx = makeContext();\n\n\t\t\tconst results = await limited.executeActions(\n\t\t\t\t[\n\t\t\t\t\taction({ action: 'tap', index: 0 }),\n\t\t\t\t\taction({ action: 'tap', index: 1 }),\n\t\t\t\t\taction({ action: 'tap', index: 2 }), // should not execute (limit=2)\n\t\t\t\t],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results).toHaveLength(2);\n\t\t});\n\n\t\ttest('handles errors gracefully in sequence', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tctx.domService.clickElementByIndex = mock(() =>\n\t\t\t\tPromise.reject(new Error('Element is not visible')),\n\t\t\t);\n\n\t\t\tconst results = await tools.executeActions(\n\t\t\t\t[action({ action: 'tap', index: 0 })],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results).toHaveLength(1);\n\t\t\texpect(results[0].success).toBe(false);\n\t\t\texpect(results[0].error).toBeDefined();\n\t\t\texpect(results[0].error).toContain('not visible');\n\t\t});\n\n\t\ttest('stops sequence on non-retryable error', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tctx.domService.clickElementByIndex = mock(() =>\n\t\t\t\tPromise.reject(new Error('browser has been closed')),\n\t\t\t);\n\n\t\t\tconst results = await tools.executeActions(\n\t\t\t\t[\n\t\t\t\t\taction({ action: 'tap', index: 0 }),\n\t\t\t\t\taction({ action: 'tap', index: 1 }), // should not run\n\t\t\t\t],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results).toHaveLength(1);\n\t\t\texpect(results[0].success).toBe(false);\n\t\t});\n\n\t\ttest('continues after retryable error', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tlet callCount = 0;\n\t\t\tctx.domService.clickElementByIndex = mock(() => {\n\t\t\t\tcallCount++;\n\t\t\t\tif (callCount === 1) {\n\t\t\t\t\treturn Promise.reject(new Error('Element is not visible'));\n\t\t\t\t}\n\t\t\t\treturn Promise.resolve();\n\t\t\t});\n\n\t\t\tconst results = await tools.executeActions(\n\t\t\t\t[\n\t\t\t\t\taction({ action: 'tap', index: 0 }),\n\t\t\t\t\taction({ action: 'tap', index: 1 }),\n\t\t\t\t],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results).toHaveLength(2);\n\t\t\texpect(results[0].success).toBe(false);\n\t\t\texpect(results[1].success).toBe(true);\n\t\t});\n\n\t\ttest('masks sensitive data in error messages', async () => {\n\t\t\tconst ctx = makeContext({\n\t\t\t\tmaskedValues: { TOKEN: 'my-secret-token' },\n\t\t\t});\n\t\t\tctx.domService.clickElementByIndex = mock(() =>\n\t\t\t\tPromise.reject(new Error('Failed with my-secret-token')),\n\t\t\t);\n\n\t\t\tconst results = await tools.executeActions(\n\t\t\t\t[action({ action: 'tap', index: 0 })],\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(results[0].error).not.toContain('my-secret-token');\n\t\t\texpect(results[0].error).toContain('<TOKEN>');\n\t\t});\n\t});\n\n\tdescribe('switch_tab action', () => {\n\t\ttest('switches to specified tab', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'focus_tab', tabIndex: 1 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.browserSession.switchTab).toHaveBeenCalledWith(1);\n\t\t});\n\t});\n\n\tdescribe('open_tab action', () => {\n\t\ttest('opens new tab with URL', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'new_tab', url: 'https://example.com' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.browserSession.newTab).toHaveBeenCalledWith('https://example.com');\n\t\t});\n\n\t\ttest('throws for blocked URL', async () => {\n\t\t\tconst restricted = new CommandExecutor({ blockedUrls: ['banned.com'] });\n\t\t\tconst ctx = makeContext();\n\n\t\t\tawait expect(\n\t\t\t\trestricted.executeAction(\n\t\t\t\t\taction({ action: 'new_tab', url: 'https://banned.com' }),\n\t\t\t\t\tctx,\n\t\t\t\t),\n\t\t\t).rejects.toThrow(CommandFailedError);\n\t\t});\n\t});\n\n\tdescribe('close_tab action', () => {\n\t\ttest('closes specified tab', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'close_tab', tabIndex: 2 }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(ctx.browserSession.closeTab).toHaveBeenCalledWith(2);\n\t\t});\n\t});\n\n\tdescribe('screenshot action', () => {\n\t\ttest('takes a screenshot', async () => {\n\t\t\tconst ctx = makeContext();\n\t\t\tconst result = await tools.executeAction(\n\t\t\t\taction({ action: 'capture' }),\n\t\t\t\tctx,\n\t\t\t);\n\n\t\t\texpect(result.success).toBe(true);\n\t\t\texpect(result.extractedContent).toContain('Screenshot taken');\n\t\t\texpect(ctx.browserSession.screenshot).toHaveBeenCalled();\n\t\t});\n\t});\n\n\tdescribe('setCoordinateClicking', () => {\n\t\ttest('enables coordinate-based clicking', () => {\n\t\t\ttools.setCoordinateClicking(true);\n\t\t\t// Verified through click behavior in click action tests above\n\t\t\texpect(tools).toBeDefined();\n\t\t});\n\n\t\ttest('disables coordinate-based clicking', () => {\n\t\t\ttools.setCoordinateClicking(true);\n\t\t\ttools.setCoordinateClicking(false);\n\t\t\texpect(tools).toBeDefined();\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/commands/executor.ts",
    "content": "import type { Page, CDPSession } from 'playwright';\nimport { z } from 'zod';\nimport { CommandCatalog } from './catalog/catalog.js';\nimport type {\n\tCommand,\n\tCommandResult,\n\tExecutionContext,\n\tInterpretedViewportError,\n\tViewportErrorCategory,\n} from './types.js';\nimport {\n\tTapCommandSchema,\n\tTypeTextCommandSchema,\n\tNavigateCommandSchema,\n\tBackCommandSchema,\n\tScrollCommandSchema,\n\tPressKeysCommandSchema,\n\tExtractCommandSchema,\n\tFinishCommandSchema,\n\tFocusTabCommandSchema,\n\tNewTabCommandSchema,\n\tCloseTabCommandSchema,\n\tWebSearchCommandSchema,\n\tUploadCommandSchema,\n\tSelectCommandSchema,\n\tCaptureCommandSchema,\n\tReadPageCommandSchema,\n\tWaitCommandSchema,\n\tScrollToCommandSchema,\n\tFindCommandSchema,\n\tSearchCommandSchema,\n\tListOptionsCommandSchema,\n\tPickOptionCommandSchema,\n\tExtractStructuredCommandSchema,\n} from './types.js';\nimport type { Viewport } from '../viewport/viewport.js';\nimport type { PageAnalyzer } from '../page/page-analyzer.js';\nimport type { LanguageModel } from '../model/interface.js';\nimport { ContentExtractor } from './extraction/extractor.js';\nimport { scrollPage, scrollElement, buildGoogleSearchUrl } from './utils.js';\nimport { extractMarkdown } from '../page/content-extractor.js';\nimport { isUrlPermitted } from '../utils.js';\nimport {\n\tUrlBlockedError,\n\tNavigationFailedError,\n\tViewportCrashedError,\n} from '../errors.js';\nimport { sleep } from '../utils.js';\n\nexport interface CommandExecutorOptions {\n\tmodel?: LanguageModel;\n\tallowedUrls?: string[];\n\tblockedUrls?: string[];\n\tcommandsPerStep?: number;\n}\n\nexport class CommandExecutor {\n\treadonly registry: CommandCatalog\n\tprivate extractionService?: ContentExtractor;\n\tprivate allowedUrls?: string[];\n\tprivate blockedUrls?: string[];\n\treadonly commandsPerStep: number;\n\tprivate coordinateClickingEnabled = false;\n\n\tconstructor(options?: CommandExecutorOptions) {\n\t\tthis.registry = new CommandCatalog();\n\t\tthis.allowedUrls = options?.allowedUrls;\n\t\tthis.blockedUrls = options?.blockedUrls;\n\t\tthis.commandsPerStep = options?.commandsPerStep ?? 10;\n\n\t\tif (options?.model) {\n\t\t\tthis.extractionService = new ContentExtractor(options.model);\n\t\t}\n\n\t\tthis.registerBuiltinActions();\n\t}\n\n\t/**\n\t * Enable or disable coordinate-based clicking.\n\t * When enabled, click actions with coordinateX/coordinateY will use\n\t * page.mouse.click instead of element index lookup.\n\t */\n\tsetCoordinateClicking(enabled: boolean): void {\n\t\tthis.coordinateClickingEnabled = enabled;\n\t}\n\n\tprivate registerBuiltinActions(): void {\n\t\t// Click\n\t\tthis.registry.register({\n\t\t\tname: 'tap',\n\t\t\tdescription: 'Click on an element by its index',\n\t\t\tschema: TapCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { index, clickCount, coordinateX, coordinateY } = params as {\n\t\t\t\t\tindex: number;\n\t\t\t\t\tclickCount?: number;\n\t\t\t\t\tcoordinateX?: number;\n\t\t\t\t\tcoordinateY?: number;\n\t\t\t\t};\n\n\t\t\t\t// Coordinate-based clicking\n\t\t\t\tif (\n\t\t\t\t\tthis.coordinateClickingEnabled &&\n\t\t\t\t\tcoordinateX !== undefined &&\n\t\t\t\t\tcoordinateY !== undefined\n\t\t\t\t) {\n\t\t\t\t\tconst clicks = clickCount ?? 1;\n\t\t\t\t\tfor (let i = 0; i < clicks; i++) {\n\t\t\t\t\t\tawait ctx.page.mouse.click(coordinateX, coordinateY);\n\t\t\t\t\t}\n\t\t\t\t\treturn { success: true };\n\t\t\t\t}\n\n\t\t\t\tawait ctx.domService.clickElementByIndex(ctx.page, ctx.cdpSession, index);\n\t\t\t\tif (clickCount && clickCount > 1) {\n\t\t\t\t\tfor (let i = 1; i < clickCount; i++) {\n\t\t\t\t\t\tawait ctx.domService.clickElementByIndex(ctx.page, ctx.cdpSession, index);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Input text\n\t\tthis.registry.register({\n\t\t\tname: 'type_text',\n\t\t\tdescription: 'Type text into an input element',\n\t\t\tschema: TypeTextCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { index, text, clearFirst } = params as {\n\t\t\t\t\tindex: number;\n\t\t\t\t\ttext: string;\n\t\t\t\t\tclearFirst?: boolean;\n\t\t\t\t};\n\t\t\t\tawait ctx.domService.inputTextByIndex(\n\t\t\t\t\tctx.page,\n\t\t\t\t\tctx.cdpSession,\n\t\t\t\t\tindex,\n\t\t\t\t\ttext,\n\t\t\t\t\tclearFirst ?? true,\n\t\t\t\t);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Navigate\n\t\tthis.registry.register({\n\t\t\tname: 'navigate',\n\t\t\tdescription: 'Navigate to a URL',\n\t\t\tschema: NavigateCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { url } = params as { url: string };\n\t\t\t\tif (!isUrlPermitted(url, this.allowedUrls, this.blockedUrls)) {\n\t\t\t\t\tthrow new UrlBlockedError(url);\n\t\t\t\t}\n\t\t\t\tawait ctx.browserSession.navigate(url);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Go back\n\t\tthis.registry.register({\n\t\t\tname: 'back',\n\t\t\tdescription: 'Go back to previous page',\n\t\t\tschema: BackCommandSchema.omit({ action: true }),\n\t\t\thandler: async (_params, ctx) => {\n\t\t\t\tawait ctx.page.goBack({ timeout: 5000 }).catch(() => {});\n\t\t\t\tawait ctx.browserSession.waitForPageReady();\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Scroll\n\t\tthis.registry.register({\n\t\t\tname: 'scroll',\n\t\t\tdescription: 'Scroll the page or an element',\n\t\t\tschema: ScrollCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { direction, amount, index } = params as {\n\t\t\t\t\tdirection: 'up' | 'down';\n\t\t\t\t\tamount?: number;\n\t\t\t\t\tindex?: number;\n\t\t\t\t};\n\n\t\t\t\tif (index !== undefined) {\n\t\t\t\t\tconst selector = await ctx.domService.getElementSelector(index);\n\t\t\t\t\tif (selector) {\n\t\t\t\t\t\tawait scrollElement(ctx.page, selector, direction, amount);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tawait scrollPage(ctx.page, direction, amount);\n\t\t\t\t}\n\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Send keys\n\t\tthis.registry.register({\n\t\t\tname: 'press_keys',\n\t\t\tdescription: 'Send keyboard keys (e.g., Enter, Escape, Control+a)',\n\t\t\tschema: PressKeysCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { keys } = params as { keys: string };\n\t\t\t\tawait ctx.page.keyboard.press(keys);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Extract content\n\t\tthis.registry.register({\n\t\t\tname: 'extract',\n\t\t\tdescription: 'Extract specific information from the current page',\n\t\t\tschema: ExtractCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { goal, outputSchema } = params as {\n\t\t\t\t\tgoal: string;\n\t\t\t\t\toutputSchema?: Record<string, unknown>;\n\t\t\t\t};\n\n\t\t\t\t// Use the extraction LLM from context if available, otherwise fall back\n\t\t\t\tconst extractionModel = ctx.extractionLlm;\n\t\t\t\tconst service =\n\t\t\t\t\textractionModel\n\t\t\t\t\t\t? new ContentExtractor(extractionModel)\n\t\t\t\t\t\t: this.extractionService;\n\n\t\t\t\tif (!service) {\n\t\t\t\t\t// Fallback: just extract markdown\n\t\t\t\t\tconst markdown = await extractMarkdown(ctx.page);\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\textractedContent: markdown.slice(0, 5000),\n\t\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// If an outputSchema is provided, use structured extraction from text\n\t\t\t\tif (outputSchema) {\n\t\t\t\t\tconst markdown = await extractMarkdown(ctx.page);\n\t\t\t\t\tconst content = await service.extractFromText(\n\t\t\t\t\t\tmarkdown.slice(0, 8000),\n\t\t\t\t\t\tgoal,\n\t\t\t\t\t\toutputSchema,\n\t\t\t\t\t);\n\t\t\t\t\treturn { success: true, extractedContent: content, includeInMemory: true };\n\t\t\t\t}\n\n\t\t\t\tconst content = await service.extract(ctx.page, goal);\n\t\t\t\treturn { success: true, extractedContent: content, includeInMemory: true };\n\t\t\t},\n\t\t});\n\n\t\t// Done\n\t\tthis.registry.register({\n\t\t\tname: 'finish',\n\t\t\tdescription: 'Mark the task as completed with a result',\n\t\t\tschema: FinishCommandSchema.omit({ action: true }),\n\t\t\tterminatesSequence: true,\n\t\t\thandler: async (params) => {\n\t\t\t\tconst { text, success } = params as { text: string; success?: boolean };\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: success ?? true,\n\t\t\t\t\tisDone: true,\n\t\t\t\t\textractedContent: text,\n\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\n\t\t// Switch tab\n\t\tthis.registry.register({\n\t\t\tname: 'focus_tab',\n\t\t\tdescription: 'Switch to a different browser tab',\n\t\t\tschema: FocusTabCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { tabIndex } = params as { tabIndex: number };\n\t\t\t\tawait ctx.browserSession.switchTab(tabIndex);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Open tab\n\t\tthis.registry.register({\n\t\t\tname: 'new_tab',\n\t\t\tdescription: 'Open a new tab with a URL',\n\t\t\tschema: NewTabCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { url } = params as { url: string };\n\t\t\t\tif (!isUrlPermitted(url, this.allowedUrls, this.blockedUrls)) {\n\t\t\t\t\tthrow new UrlBlockedError(url);\n\t\t\t\t}\n\t\t\t\tawait ctx.browserSession.newTab(url);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Close tab\n\t\tthis.registry.register({\n\t\t\tname: 'close_tab',\n\t\t\tdescription: 'Close a browser tab',\n\t\t\tschema: CloseTabCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { tabIndex } = params as { tabIndex?: number };\n\t\t\t\tawait ctx.browserSession.closeTab(tabIndex);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Search Google\n\t\tthis.registry.register({\n\t\t\tname: 'web_search',\n\t\t\tdescription: 'Search Google for a query',\n\t\t\tschema: WebSearchCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { query } = params as { query: string };\n\t\t\t\tconst url = buildGoogleSearchUrl(query);\n\t\t\t\tawait ctx.browserSession.navigate(url);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Upload file\n\t\tthis.registry.register({\n\t\t\tname: 'upload',\n\t\t\tdescription: 'Upload files to a file input',\n\t\t\tschema: UploadCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { index, filePaths } = params as { index: number; filePaths: string[] };\n\n\t\t\t\t// If a fileSystem is available in context, resolve relative paths\n\t\t\t\t// against the sandbox directory\n\t\t\t\tlet resolvedPaths = filePaths;\n\t\t\t\tif (ctx.fileSystem) {\n\t\t\t\t\tconst sandboxDir = ctx.fileSystem.getSandboxDir();\n\t\t\t\t\tconst { resolve: pathResolve } = await import('node:path');\n\t\t\t\t\tresolvedPaths = filePaths.map((fp) =>\n\t\t\t\t\t\tfp.startsWith('/') ? fp : pathResolve(sandboxDir, fp),\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tconst selector = await ctx.domService.getElementSelector(index);\n\t\t\t\tif (!selector) {\n\t\t\t\t\treturn { success: false, error: `Element ${index} not found` };\n\t\t\t\t}\n\t\t\t\tconst fileInput = await ctx.page.$(selector);\n\t\t\t\tif (!fileInput) {\n\t\t\t\t\treturn { success: false, error: `File input element not found` };\n\t\t\t\t}\n\t\t\t\tawait fileInput.setInputFiles(resolvedPaths);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Select option\n\t\tthis.registry.register({\n\t\t\tname: 'select',\n\t\t\tdescription: 'Select an option in a dropdown',\n\t\t\tschema: SelectCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { index, value } = params as { index: number; value: string };\n\t\t\t\tconst selector = await ctx.domService.getElementSelector(index);\n\t\t\t\tif (!selector) {\n\t\t\t\t\treturn { success: false, error: `Element ${index} not found` };\n\t\t\t\t}\n\t\t\t\tawait ctx.page.selectOption(selector, value);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Screenshot\n\t\tthis.registry.register({\n\t\t\tname: 'capture',\n\t\t\tdescription: 'Take a screenshot of the current page',\n\t\t\tschema: CaptureCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { fullPage } = params as { fullPage?: boolean };\n\t\t\t\tconst result = await ctx.browserSession.screenshot(fullPage);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\textractedContent: `Screenshot taken (${result.width}x${result.height})`,\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\n\t\t// Read content\n\t\tthis.registry.register({\n\t\t\tname: 'read_page',\n\t\t\tdescription: 'Read the text content of the current page',\n\t\t\tschema: ReadPageCommandSchema.omit({ action: true }),\n\t\t\thandler: async (_params, ctx) => {\n\t\t\t\tconst markdown = await extractMarkdown(ctx.page);\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\textractedContent: markdown.slice(0, 10000),\n\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\n\t\t// Wait\n\t\tthis.registry.register({\n\t\t\tname: 'wait',\n\t\t\tdescription: 'Wait for a specified number of seconds',\n\t\t\tschema: WaitCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params) => {\n\t\t\t\tconst { seconds } = params as { seconds?: number };\n\t\t\t\tawait sleep((seconds ?? 3) * 1000);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// ── New actions ──\n\n\t\t// Scroll to text\n\t\tthis.registry.register({\n\t\t\tname: 'scroll_to',\n\t\t\tdescription: 'Scroll to a specific text on the page',\n\t\t\tschema: ScrollToCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { text } = params as { text: string };\n\n\t\t\t\tconst found = await ctx.page.evaluate((searchText: string) => {\n\t\t\t\t\t// Use TreeWalker to find text nodes containing the search text\n\t\t\t\t\tconst walker = document.createTreeWalker(\n\t\t\t\t\t\tdocument.body,\n\t\t\t\t\t\tNodeFilter.SHOW_TEXT,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tacceptNode(node) {\n\t\t\t\t\t\t\t\tif (\n\t\t\t\t\t\t\t\t\tnode.textContent &&\n\t\t\t\t\t\t\t\t\tnode.textContent.toLowerCase().includes(searchText.toLowerCase())\n\t\t\t\t\t\t\t\t) {\n\t\t\t\t\t\t\t\t\treturn NodeFilter.FILTER_ACCEPT;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\treturn NodeFilter.FILTER_REJECT;\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t);\n\n\t\t\t\t\tconst node = walker.nextNode();\n\t\t\t\t\tif (!node?.parentElement) return false;\n\n\t\t\t\t\tnode.parentElement.scrollIntoView({\n\t\t\t\t\t\tbehavior: 'smooth',\n\t\t\t\t\t\tblock: 'center',\n\t\t\t\t\t});\n\t\t\t\t\treturn true;\n\t\t\t\t}, text);\n\n\t\t\t\tif (!found) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: `Text \"${text}\" not found on the page`,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// Allow time for the smooth scroll to finish\n\t\t\t\tawait sleep(500);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Find elements\n\t\tthis.registry.register({\n\t\t\tname: 'find',\n\t\t\tdescription: 'Find elements on the page matching a description',\n\t\t\tschema: FindCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { query } = params as { query: string };\n\n\t\t\t\tconst elements = await ctx.page.evaluate((searchQuery: string) => {\n\t\t\t\t\tconst results: Array<{\n\t\t\t\t\t\ttag: string;\n\t\t\t\t\t\ttext: string;\n\t\t\t\t\t\tattributes: Record<string, string>;\n\t\t\t\t\t}> = [];\n\t\t\t\t\tconst queryLower = searchQuery.toLowerCase();\n\n\t\t\t\t\t// Search through interactive and content elements\n\t\t\t\t\tconst selectors = [\n\t\t\t\t\t\t'a',\n\t\t\t\t\t\t'button',\n\t\t\t\t\t\t'input',\n\t\t\t\t\t\t'select',\n\t\t\t\t\t\t'textarea',\n\t\t\t\t\t\t'[role=\"button\"]',\n\t\t\t\t\t\t'[role=\"link\"]',\n\t\t\t\t\t\t'[role=\"tab\"]',\n\t\t\t\t\t\t'[role=\"menuitem\"]',\n\t\t\t\t\t\t'h1',\n\t\t\t\t\t\t'h2',\n\t\t\t\t\t\t'h3',\n\t\t\t\t\t\t'h4',\n\t\t\t\t\t\t'h5',\n\t\t\t\t\t\t'h6',\n\t\t\t\t\t\t'label',\n\t\t\t\t\t\t'[aria-label]',\n\t\t\t\t\t];\n\n\t\t\t\t\tfor (const selector of selectors) {\n\t\t\t\t\t\tfor (const el of document.querySelectorAll(selector)) {\n\t\t\t\t\t\t\tconst htmlEl = el as HTMLElement;\n\t\t\t\t\t\t\tconst text = (htmlEl.innerText || htmlEl.textContent || '').trim();\n\t\t\t\t\t\t\tconst ariaLabel = el.getAttribute('aria-label') || '';\n\t\t\t\t\t\t\tconst placeholder = el.getAttribute('placeholder') || '';\n\t\t\t\t\t\t\tconst title = el.getAttribute('title') || '';\n\n\t\t\t\t\t\t\tconst searchableText =\n\t\t\t\t\t\t\t\t`${text} ${ariaLabel} ${placeholder} ${title}`.toLowerCase();\n\n\t\t\t\t\t\t\tif (searchableText.includes(queryLower)) {\n\t\t\t\t\t\t\t\tconst attrs: Record<string, string> = {};\n\t\t\t\t\t\t\t\tif (el.id) attrs.id = el.id;\n\t\t\t\t\t\t\t\tif (el.className && typeof el.className === 'string') {\n\t\t\t\t\t\t\t\t\tattrs.class = el.className;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\tif (ariaLabel) attrs['aria-label'] = ariaLabel;\n\t\t\t\t\t\t\t\tif (placeholder) attrs.placeholder = placeholder;\n\n\t\t\t\t\t\t\t\tresults.push({\n\t\t\t\t\t\t\t\t\ttag: el.tagName.toLowerCase(),\n\t\t\t\t\t\t\t\t\ttext: text.slice(0, 100),\n\t\t\t\t\t\t\t\t\tattributes: attrs,\n\t\t\t\t\t\t\t\t});\n\t\t\t\t\t\t\t}\n\n\t\t\t\t\t\t\t// Cap at 20 results\n\t\t\t\t\t\t\tif (results.length >= 20) break;\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif (results.length >= 20) break;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn results;\n\t\t\t\t}, query);\n\n\t\t\t\tif (elements.length === 0) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\textractedContent: `No elements found matching \"${query}\"`,\n\t\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tconst descriptions = elements.map((el, i) => {\n\t\t\t\t\tconst attrStr = Object.entries(el.attributes)\n\t\t\t\t\t\t.map(([k, v]) => `${k}=\"${v}\"`)\n\t\t\t\t\t\t.join(' ');\n\t\t\t\t\treturn `[${i}] <${el.tag}${attrStr ? ` ${attrStr}` : ''}> ${el.text}`;\n\t\t\t\t});\n\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\textractedContent: `Found ${elements.length} element(s):\\n${descriptions.join('\\n')}`,\n\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\n\t\t// Search page (multi-engine)\n\t\tthis.registry.register({\n\t\t\tname: 'search',\n\t\t\tdescription: 'Search the web using a specified search engine',\n\t\t\tschema: SearchCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { query, engine } = params as {\n\t\t\t\t\tquery: string;\n\t\t\t\t\tengine?: 'google' | 'duckduckgo' | 'bing';\n\t\t\t\t};\n\n\t\t\t\tconst searchEngine = engine ?? 'google';\n\t\t\t\tconst url = buildSearchUrl(query, searchEngine);\n\n\t\t\t\tif (!isUrlPermitted(url, this.allowedUrls, this.blockedUrls)) {\n\t\t\t\t\tthrow new UrlBlockedError(url);\n\t\t\t\t}\n\n\t\t\t\tawait ctx.browserSession.navigate(url);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Get dropdown options\n\t\tthis.registry.register({\n\t\t\tname: 'list_options',\n\t\t\tdescription: 'Get all options from a select/dropdown element',\n\t\t\tschema: ListOptionsCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { index } = params as { index: number };\n\t\t\t\tconst selector = await ctx.domService.getElementSelector(index);\n\t\t\t\tif (!selector) {\n\t\t\t\t\treturn { success: false, error: `Element ${index} not found` };\n\t\t\t\t}\n\n\t\t\t\tconst options = await ctx.page.evaluate((sel: string) => {\n\t\t\t\t\tconst selectEl = document.querySelector(sel) as HTMLSelectElement | null;\n\t\t\t\t\tif (!selectEl || selectEl.tagName !== 'SELECT') {\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\n\t\t\t\t\treturn Array.from(selectEl.options).map((opt) => ({\n\t\t\t\t\t\tvalue: opt.value,\n\t\t\t\t\t\ttext: opt.text.trim(),\n\t\t\t\t\t\tselected: opt.selected,\n\t\t\t\t\t}));\n\t\t\t\t}, selector);\n\n\t\t\t\tif (!options) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: `Element ${index} is not a select element`,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tconst formatted = options\n\t\t\t\t\t.map(\n\t\t\t\t\t\t(opt, i) =>\n\t\t\t\t\t\t\t`[${i}] \"${opt.text}\" (value=\"${opt.value}\")${opt.selected ? ' [selected]' : ''}`,\n\t\t\t\t\t)\n\t\t\t\t\t.join('\\n');\n\n\t\t\t\treturn {\n\t\t\t\t\tsuccess: true,\n\t\t\t\t\textractedContent: `Dropdown options:\\n${formatted}`,\n\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t};\n\t\t\t},\n\t\t});\n\n\t\t// Select dropdown option (by text match)\n\t\tthis.registry.register({\n\t\t\tname: 'pick_option',\n\t\t\tdescription: 'Select a dropdown option by its visible text',\n\t\t\tschema: PickOptionCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { index, optionText } = params as {\n\t\t\t\t\tindex: number;\n\t\t\t\t\toptionText: string;\n\t\t\t\t};\n\t\t\t\tconst selector = await ctx.domService.getElementSelector(index);\n\t\t\t\tif (!selector) {\n\t\t\t\t\treturn { success: false, error: `Element ${index} not found` };\n\t\t\t\t}\n\n\t\t\t\t// Find the option value by matching text content\n\t\t\t\tconst matchedValue = await ctx.page.evaluate(\n\t\t\t\t\t({ sel, text }: { sel: string; text: string }) => {\n\t\t\t\t\t\tconst selectEl = document.querySelector(sel) as HTMLSelectElement | null;\n\t\t\t\t\t\tif (!selectEl || selectEl.tagName !== 'SELECT') return null;\n\n\t\t\t\t\t\tconst textLower = text.toLowerCase();\n\n\t\t\t\t\t\t// Try exact match first\n\t\t\t\t\t\tfor (const opt of selectEl.options) {\n\t\t\t\t\t\t\tif (opt.text.trim().toLowerCase() === textLower) {\n\t\t\t\t\t\t\t\treturn opt.value;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Try partial / includes match\n\t\t\t\t\t\tfor (const opt of selectEl.options) {\n\t\t\t\t\t\t\tif (opt.text.trim().toLowerCase().includes(textLower)) {\n\t\t\t\t\t\t\t\treturn opt.value;\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t},\n\t\t\t\t\t{ sel: selector, text: optionText },\n\t\t\t\t);\n\n\t\t\t\tif (matchedValue === null) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: `No option matching \"${optionText}\" found in dropdown at element ${index}`,\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tawait ctx.page.selectOption(selector, matchedValue);\n\t\t\t\treturn { success: true };\n\t\t\t},\n\t\t});\n\n\t\t// Structured output\n\t\tthis.useStructuredOutputAction();\n\t}\n\n\t/**\n\t * Register the structured_output action.\n\t * Uses the extraction LLM to produce structured JSON output from\n\t * the current page content according to a caller-provided JSON schema.\n\t */\n\tprivate useStructuredOutputAction(): void {\n\t\tthis.registry.register({\n\t\t\tname: 'extract_structured',\n\t\t\tdescription:\n\t\t\t\t'Extract structured data from the current page content. Returns JSON conforming to the provided schema.',\n\t\t\tschema: ExtractStructuredCommandSchema.omit({ action: true }),\n\t\t\thandler: async (params, ctx) => {\n\t\t\t\tconst { goal, outputSchema, maxContentLength } = params as {\n\t\t\t\t\tgoal: string;\n\t\t\t\t\toutputSchema: Record<string, unknown>;\n\t\t\t\t\tmaxContentLength?: number;\n\t\t\t\t};\n\n\t\t\t\tconst contentLimit = maxContentLength ?? 8000;\n\n\t\t\t\t// Resolve the extraction model: prefer context-provided, fall back to Tools-level\n\t\t\t\tconst extractionModel = ctx.extractionLlm;\n\t\t\t\tconst service = extractionModel\n\t\t\t\t\t? new ContentExtractor(extractionModel)\n\t\t\t\t\t: this.extractionService;\n\n\t\t\t\tif (!service) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror:\n\t\t\t\t\t\t\t'No extraction LLM configured. Provide a model via CommandExecutorOptions or ExecutionContext.extractionLlm.',\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\t// Extract page content as markdown\n\t\t\t\tconst markdown = await extractMarkdown(ctx.page);\n\t\t\t\tif (!markdown.trim()) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: 'No content found on the page for structured extraction.',\n\t\t\t\t\t};\n\t\t\t\t}\n\n\t\t\t\tconst truncatedContent = markdown.slice(0, contentLimit);\n\n\t\t\t\ttry {\n\t\t\t\t\tconst result = await service.extractFromText(\n\t\t\t\t\t\ttruncatedContent,\n\t\t\t\t\t\tgoal,\n\t\t\t\t\t\toutputSchema,\n\t\t\t\t\t);\n\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: true,\n\t\t\t\t\t\textractedContent: result,\n\t\t\t\t\t\tincludeInMemory: true,\n\t\t\t\t\t};\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconst message =\n\t\t\t\t\t\terror instanceof Error ? error.message : String(error);\n\t\t\t\t\treturn {\n\t\t\t\t\t\tsuccess: false,\n\t\t\t\t\t\terror: `Structured extraction failed: ${message}`,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t},\n\t\t});\n\t}\n\n\tasync executeAction(\n\t\taction: Command,\n\t\tcontext: ExecutionContext,\n\t): Promise<CommandResult> {\n\t\tconst { action: actionName, ...params } = action;\n\t\treturn this.registry.execute(actionName, params, context);\n\t}\n\n\tasync executeActions(\n\t\tactions: Command[],\n\t\tcontext: ExecutionContext,\n\t): Promise<CommandResult[]> {\n\t\tconst results: CommandResult[] = [];\n\t\tconst limit = Math.min(actions.length, this.commandsPerStep);\n\n\t\tfor (let i = 0; i < limit; i++) {\n\t\t\ttry {\n\t\t\t\tconst result = await this.executeAction(actions[i], context);\n\n\t\t\t\t// Mask sensitive data in extracted content\n\t\t\t\tconst maskedResult = this.maskSensitiveResult(result, context);\n\t\t\t\tresults.push(maskedResult);\n\n\t\t\t\t// Stop if we hit a terminating action (done, or custom terminatesSequence)\n\t\t\t\tif (maskedResult.isDone) break;\n\n\t\t\t\tconst actionName = actions[i].action;\n\t\t\t\tif (this.registry.isTerminating(actionName)) break;\n\t\t\t} catch (error) {\n\t\t\t\t// Interpret the browser error for a more meaningful result\n\t\t\t\tconst interpreted = classifyViewportError(error);\n\t\t\t\tconst errorMessage = `${interpreted.message} | Suggestion: ${interpreted.suggestion}`;\n\n\t\t\t\t// Mask sensitive data in error messages too\n\t\t\t\tconst maskedMessage = this.maskSensitiveText(errorMessage, context);\n\t\t\t\tresults.push({\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: maskedMessage,\n\t\t\t\t});\n\n\t\t\t\t// If the error is not retryable (e.g., browser crash), stop the sequence\n\t\t\t\tif (!interpreted.isRetryable) break;\n\t\t\t}\n\t\t}\n\n\t\treturn results;\n\t}\n\n\t// ── Sensitive data masking ──\n\n\t/**\n\t * Mask sensitive data values in an CommandResult's extractedContent and error fields.\n\t */\n\tprivate maskSensitiveResult(\n\t\tresult: CommandResult,\n\t\tcontext: ExecutionContext,\n\t): CommandResult {\n\t\tif (!context.maskedValues) return result;\n\n\t\tconst masked = { ...result };\n\t\tif (masked.extractedContent) {\n\t\t\tmasked.extractedContent = this.registry.replaceSensitiveData(\n\t\t\t\tmasked.extractedContent,\n\t\t\t\tcontext.maskedValues,\n\t\t\t);\n\t\t}\n\t\tif (masked.error) {\n\t\t\tmasked.error = this.registry.replaceSensitiveData(\n\t\t\t\tmasked.error,\n\t\t\t\tcontext.maskedValues,\n\t\t\t);\n\t\t}\n\t\treturn masked;\n\t}\n\n\t/**\n\t * Mask sensitive data in a plain text string.\n\t */\n\tprivate maskSensitiveText(\n\t\ttext: string,\n\t\tcontext: ExecutionContext,\n\t): string {\n\t\tif (!context.maskedValues) return text;\n\t\treturn this.registry.replaceSensitiveData(text, context.maskedValues);\n\t}\n}\n\n// ── Helpers ──\n\nfunction buildSearchUrl(\n\tquery: string,\n\tengine: 'google' | 'duckduckgo' | 'bing',\n): string {\n\tconst encoded = encodeURIComponent(query);\n\tswitch (engine) {\n\t\tcase 'google':\n\t\t\treturn `https://www.google.com/search?q=${encoded}&udm=14`;\n\t\tcase 'duckduckgo':\n\t\t\treturn `https://duckduckgo.com/?q=${encoded}`;\n\t\tcase 'bing':\n\t\t\treturn `https://www.bing.com/search?q=${encoded}`;\n\t}\n}\n\n// ── Browser error interpretation ──\n\n/**\n * Error pattern matcher: maps regex patterns against error messages to\n * categories, human-readable messages, and actionable suggestions.\n */\nconst ERROR_PATTERNS: Array<{\n\tpattern: RegExp;\n\tcategory: ViewportErrorCategory;\n\tmessage: (match: RegExpMatchArray) => string;\n\tsuggestion: string;\n\tisRetryable: boolean;\n}> = [\n\t{\n\t\tpattern: /net::ERR_NAME_NOT_RESOLVED/i,\n\t\tcategory: 'network',\n\t\tmessage: () => 'DNS resolution failed - the domain could not be found.',\n\t\tsuggestion: 'Check the URL for typos or try a different URL.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /net::ERR_CONNECTION_REFUSED/i,\n\t\tcategory: 'network',\n\t\tmessage: () => 'Connection refused by the server.',\n\t\tsuggestion: 'The server may be down. Try again later or use a different URL.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /net::ERR_CONNECTION_TIMED_OUT/i,\n\t\tcategory: 'network',\n\t\tmessage: () => 'Connection timed out.',\n\t\tsuggestion: 'The server is not responding. Try again or use a different URL.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /net::ERR_SSL/i,\n\t\tcategory: 'network',\n\t\tmessage: () => 'SSL/TLS connection error.',\n\t\tsuggestion: 'The site has an invalid certificate. Try an alternative URL.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /net::ERR_CERT/i,\n\t\tcategory: 'network',\n\t\tmessage: () => 'Certificate verification failed.',\n\t\tsuggestion: 'The site has a certificate issue. Try a different URL.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /net::ERR_ABORTED/i,\n\t\tcategory: 'navigation',\n\t\tmessage: () => 'Navigation was aborted.',\n\t\tsuggestion: 'The page load was interrupted. Try navigating again.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /net::ERR_/i,\n\t\tcategory: 'network',\n\t\tmessage: (m) => `Network error: ${m[0]}`,\n\t\tsuggestion: 'A network error occurred. Check the URL and try again.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /Navigation timeout of \\d+ms exceeded/i,\n\t\tcategory: 'timeout',\n\t\tmessage: () => 'Page navigation timed out.',\n\t\tsuggestion: 'The page took too long to load. Try again or navigate to a simpler page.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /Timeout \\d+ms exceeded/i,\n\t\tcategory: 'timeout',\n\t\tmessage: () => 'Operation timed out.',\n\t\tsuggestion: 'The operation took too long. Try a simpler action or wait and retry.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /waiting for selector/i,\n\t\tcategory: 'timeout',\n\t\tmessage: () => 'Timed out waiting for an element to appear.',\n\t\tsuggestion: 'The element may not exist on this page. Check the page content and try a different selector or index.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /Element is not visible/i,\n\t\tcategory: 'element_not_interactable',\n\t\tmessage: () => 'The element exists but is not visible.',\n\t\tsuggestion: 'Try scrolling to make the element visible, or use a different element.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /Element is not attached to the DOM/i,\n\t\tcategory: 'element_stale',\n\t\tmessage: () => 'The element reference is stale - the element was removed from the page.',\n\t\tsuggestion: 'The page content has changed. Re-read the page and use updated element indices.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /Element is outside of the viewport/i,\n\t\tcategory: 'element_not_interactable',\n\t\tmessage: () => 'The element is outside the visible viewport.',\n\t\tsuggestion: 'Scroll to bring the element into view before interacting with it.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /Element is not (?:enabled|editable)/i,\n\t\tcategory: 'element_not_interactable',\n\t\tmessage: () => 'The element is disabled or read-only.',\n\t\tsuggestion: 'The element cannot be interacted with in its current state. Look for an alternative element or action.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /intercepts pointer events/i,\n\t\tcategory: 'element_not_interactable',\n\t\tmessage: () => 'Another element is covering the target element.',\n\t\tsuggestion: 'An overlay or dialog may be blocking the click. Try closing it first, or use send_keys as an alternative.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /(?:Element|Node)\\s+(?:\\d+\\s+)?not found/i,\n\t\tcategory: 'element_not_found',\n\t\tmessage: () => 'The specified element was not found on the page.',\n\t\tsuggestion: 'The element index may be invalid. Re-read the page content to get updated element indices.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /frame was detached/i,\n\t\tcategory: 'element_stale',\n\t\tmessage: () => 'The frame containing the element has been detached.',\n\t\tsuggestion: 'The page structure changed. Navigate to a stable page and retry.',\n\t\tisRetryable: true,\n\t},\n\t{\n\t\tpattern: /browser has been closed/i,\n\t\tcategory: 'crash',\n\t\tmessage: () => 'The browser has been closed unexpectedly.',\n\t\tsuggestion: 'The browser session is no longer available.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /Target (?:page|context|browser) (?:closed|crashed)/i,\n\t\tcategory: 'crash',\n\t\tmessage: () => 'The browser page or context has crashed.',\n\t\tsuggestion: 'The browser session is no longer available.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /Protocol error/i,\n\t\tcategory: 'crash',\n\t\tmessage: () => 'Browser protocol communication error.',\n\t\tsuggestion: 'The browser may have crashed or become unresponsive.',\n\t\tisRetryable: false,\n\t},\n\t{\n\t\tpattern: /Permission denied|not allowed/i,\n\t\tcategory: 'permission',\n\t\tmessage: () => 'Permission denied for this operation.',\n\t\tsuggestion: 'The action requires permissions that are not available. Try an alternative approach.',\n\t\tisRetryable: false,\n\t},\n];\n\n/**\n * Analyze a browser or tool error and return a structured interpretation\n * with a human-readable message, category, and actionable suggestion.\n */\nexport function classifyViewportError(error: unknown): InterpretedViewportError {\n\tconst rawMessage = error instanceof Error ? error.message : String(error);\n\n\t// Check for known error types first\n\tif (error instanceof NavigationFailedError) {\n\t\treturn {\n\t\t\tcategory: 'navigation',\n\t\t\tmessage: `Navigation failed for ${error.url}: ${rawMessage}`,\n\t\t\tsuggestion: 'Check the URL for correctness and try again.',\n\t\t\tisRetryable: true,\n\t\t};\n\t}\n\n\tif (error instanceof ViewportCrashedError) {\n\t\treturn {\n\t\t\tcategory: 'crash',\n\t\t\tmessage: rawMessage,\n\t\t\tsuggestion: 'The browser has crashed and the session must be restarted.',\n\t\t\tisRetryable: false,\n\t\t};\n\t}\n\n\tif (error instanceof UrlBlockedError) {\n\t\treturn {\n\t\t\tcategory: 'permission',\n\t\t\tmessage: rawMessage,\n\t\t\tsuggestion: 'This URL is blocked by the allowed/blocked URL configuration. Use a different URL.',\n\t\t\tisRetryable: false,\n\t\t};\n\t}\n\n\t// Match against known patterns\n\tfor (const entry of ERROR_PATTERNS) {\n\t\tconst match = rawMessage.match(entry.pattern);\n\t\tif (match) {\n\t\t\treturn {\n\t\t\t\tcategory: entry.category,\n\t\t\t\tmessage: entry.message(match),\n\t\t\t\tsuggestion: entry.suggestion,\n\t\t\t\tisRetryable: entry.isRetryable,\n\t\t\t};\n\t\t}\n\t}\n\n\t// Unknown error - default interpretation\n\treturn {\n\t\tcategory: 'unknown',\n\t\tmessage: rawMessage,\n\t\tsuggestion: 'An unexpected error occurred. Try a different action or approach.',\n\t\tisRetryable: true,\n\t};\n}\n"
  },
  {
    "path": "packages/core/src/commands/extraction/extractor.ts",
    "content": "import type { Page } from 'playwright';\nimport type { LanguageModel } from '../../model/interface.js';\nimport { z } from 'zod';\nimport {\n\textractMarkdown,\n\tchunkText,\n\textractLinks as extractPageLinks,\n} from '../../page/content-extractor.js';\nimport { systemMessage, userMessage } from '../../model/messages.js';\n\nconst ExtractionResultSchema = z.object({\n\tcontent: z.string().describe('The extracted information'),\n\tconfidence: z.number().min(0).max(1).describe('Confidence in the extraction (0-1)'),\n});\n\ntype ExtractionResult = z.infer<typeof ExtractionResultSchema>;\n\nexport class ContentExtractor {\n\tprivate model: LanguageModel;\n\n\tconstructor(model: LanguageModel) {\n\t\tthis.model = model;\n\t}\n\n\tasync extract(page: Page, goal: string, startFromChar?: number): Promise<string> {\n\t\tconst markdown = await extractMarkdown(page, {\n\t\t\tstartFromChar: startFromChar && startFromChar > 0 ? startFromChar : undefined,\n\t\t});\n\n\t\tif (!markdown.trim()) {\n\t\t\treturn 'No content found on the page.';\n\t\t}\n\n\t\t// For short pages, extract directly\n\t\tif (markdown.length <= 8000) {\n\t\t\treturn this.extractFromText(markdown, goal);\n\t\t}\n\n\t\t// For longer pages, chunk and extract from each chunk\n\t\tconst chunks = chunkText(markdown, 6000);\n\t\tconst results: string[] = [];\n\n\t\tfor (const chunk of chunks) {\n\t\t\tconst result = await this.extractFromText(chunk, goal);\n\t\t\tif (result && result !== 'No relevant information found.') {\n\t\t\t\tresults.push(result);\n\t\t\t}\n\t\t}\n\n\t\tif (results.length === 0) {\n\t\t\treturn 'No relevant information found on the page.';\n\t\t}\n\n\t\tif (results.length === 1) {\n\t\t\treturn results[0];\n\t\t}\n\n\t\t// Combine results\n\t\treturn this.combineExtractions(results, goal);\n\t}\n\n\t// ── Structured extraction ──\n\n\t/**\n\t * Extract information from a page and validate against a Zod schema.\n\t * The LLM is prompted to return JSON conforming to the schema, then the\n\t * output is parsed/validated with Zod.\n\t */\n\tasync extractStructured<T>(\n\t\tpage: Page,\n\t\tgoal: string,\n\t\tschema: z.ZodType<T>,\n\t): Promise<T> {\n\t\tconst markdown = await extractMarkdown(page);\n\n\t\tif (!markdown.trim()) {\n\t\t\tthrow new Error('No content found on the page for structured extraction.');\n\t\t}\n\n\t\t// Build a JSON schema description for the prompt\n\t\tconst schemaDescription =\n\t\t\tschema instanceof z.ZodObject\n\t\t\t\t? JSON.stringify(\n\t\t\t\t\t\t(schema as z.ZodObject<z.ZodRawShape>).shape,\n\t\t\t\t\t\t(_key, value) => {\n\t\t\t\t\t\t\tif (value?._def?.description) return `(${value._def.description})`;\n\t\t\t\t\t\t\tif (value?._def?.typeName) return value._def.typeName;\n\t\t\t\t\t\t\treturn value;\n\t\t\t\t\t\t},\n\t\t\t\t\t\t2,\n\t\t\t\t\t)\n\t\t\t\t: 'See schema constraints';\n\n\t\tconst text = markdown.length > 8000 ? markdown.slice(0, 8000) : markdown;\n\n\t\tconst StructuredOutputSchema = z.object({\n\t\t\tresult: z.string().describe('JSON string conforming to the requested schema'),\n\t\t});\n\n\t\tconst response = await this.model.invoke({\n\t\t\tmessages: [\n\t\t\t\tsystemMessage(\n\t\t\t\t\t'You are a precise information extractor. Extract the requested information from the provided text and return it as a valid JSON string in the \"result\" field. The JSON must conform to the schema described below.',\n\t\t\t\t),\n\t\t\t\tuserMessage(\n\t\t\t\t\t`Goal: ${goal}\\n\\nExpected schema:\\n${schemaDescription}\\n\\nText content:\\n${text}\\n\\nReturn the extracted data as a JSON string in the \"result\" field.`,\n\t\t\t\t),\n\t\t\t],\n\t\t\tresponseSchema: StructuredOutputSchema,\n\t\t\tschemaName: 'StructuredOutput',\n\t\t\ttemperature: 0,\n\t\t});\n\n\t\tconst parsed = JSON.parse(response.parsed.result);\n\t\treturn schema.parse(parsed);\n\t}\n\n\t// ── Link extraction ──\n\n\t/**\n\t * Extract all links from a page, returning text, url, and whether external.\n\t */\n\tasync extractLinks(\n\t\tpage: Page,\n\t): Promise<Array<{ text: string; url: string; isExternal: boolean }>> {\n\t\treturn extractPageLinks(page);\n\t}\n\n\t// ── Text extraction with optional JSON schema ──\n\n\tasync extractFromText(\n\t\ttext: string,\n\t\tgoal: string,\n\t\toutputJsonSchema?: Record<string, unknown>,\n\t): Promise<string> {\n\t\t// If a JSON schema is provided, ask the LLM to produce structured output\n\t\tif (outputJsonSchema) {\n\t\t\treturn this.extractFromTextWithJsonSchema(text, goal, outputJsonSchema);\n\t\t}\n\n\t\tconst result = await this.model.invoke({\n\t\t\tmessages: [\n\t\t\t\tsystemMessage(\n\t\t\t\t\t'You are a precise information extractor. Extract only the requested information from the provided text. Be concise and accurate.',\n\t\t\t\t),\n\t\t\t\tuserMessage(\n\t\t\t\t\t`Goal: ${goal}\\n\\nText content:\\n${text}\\n\\nExtract the information specified in the goal. If the information is not found, say \"No relevant information found.\"`,\n\t\t\t\t),\n\t\t\t],\n\t\t\tresponseSchema: ExtractionResultSchema,\n\t\t\tschemaName: 'ExtractionResult',\n\t\t\ttemperature: 0,\n\t\t});\n\n\t\treturn result.parsed.content;\n\t}\n\n\t// ── Private helpers ──\n\n\tprivate async extractFromTextWithJsonSchema(\n\t\ttext: string,\n\t\tgoal: string,\n\t\tjsonSchema: Record<string, unknown>,\n\t): Promise<string> {\n\t\tconst schemaStr = JSON.stringify(jsonSchema, null, 2);\n\n\t\tconst JsonExtractionSchema = z.object({\n\t\t\tjson: z.string().describe('JSON conforming to the requested schema'),\n\t\t});\n\n\t\tconst result = await this.model.invoke({\n\t\t\tmessages: [\n\t\t\t\tsystemMessage(\n\t\t\t\t\t'You are a precise information extractor. Extract the requested information and return it as valid JSON conforming to the provided schema. Put the JSON string in the \"json\" field.',\n\t\t\t\t),\n\t\t\t\tuserMessage(\n\t\t\t\t\t`Goal: ${goal}\\n\\nRequired JSON schema:\\n${schemaStr}\\n\\nText content:\\n${text}\\n\\nExtract and return as JSON.`,\n\t\t\t\t),\n\t\t\t],\n\t\t\tresponseSchema: JsonExtractionSchema,\n\t\t\tschemaName: 'JsonExtraction',\n\t\t\ttemperature: 0,\n\t\t});\n\n\t\t// Validate the JSON parses correctly\n\t\tconst parsed = JSON.parse(result.parsed.json);\n\t\treturn JSON.stringify(parsed);\n\t}\n\n\tprivate async combineExtractions(results: string[], goal: string): Promise<string> {\n\t\tconst combined = results.map((r, i) => `Part ${i + 1}:\\n${r}`).join('\\n\\n');\n\n\t\tconst result = await this.model.invoke({\n\t\t\tmessages: [\n\t\t\t\tsystemMessage(\n\t\t\t\t\t'Combine the following extracted information into a single coherent response. Remove duplicates and organize logically.',\n\t\t\t\t),\n\t\t\t\tuserMessage(`Goal: ${goal}\\n\\nExtracted parts:\\n${combined}`),\n\t\t\t],\n\t\t\tresponseSchema: ExtractionResultSchema,\n\t\t\tschemaName: 'ExtractionResult',\n\t\t\ttemperature: 0,\n\t\t});\n\n\t\treturn result.parsed.content;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/commands/index.ts",
    "content": "export { CommandExecutor, type CommandExecutorOptions, classifyViewportError } from './executor.js';\nexport { CommandCatalog } from './catalog/catalog.js';\nexport { ContentExtractor } from './extraction/extractor.js';\nexport { type CatalogEntry, type CatalogOptions } from './catalog/types.js';\nexport {\n\tCommandSchema,\n\ttype Command,\n\ttype CommandName,\n\ttype CommandResult,\n\ttype ExecutionContext,\n\ttype CustomCommandSpec,\n\ttype ViewportErrorCategory,\n\ttype InterpretedViewportError,\n\tTapCommandSchema,\n\tTypeTextCommandSchema,\n\tNavigateCommandSchema,\n\tBackCommandSchema,\n\tScrollCommandSchema,\n\tPressKeysCommandSchema,\n\tExtractCommandSchema,\n\tFinishCommandSchema,\n\tFocusTabCommandSchema,\n\tNewTabCommandSchema,\n\tCloseTabCommandSchema,\n\tWebSearchCommandSchema,\n\tUploadCommandSchema,\n\tSelectCommandSchema,\n\tCaptureCommandSchema,\n\tReadPageCommandSchema,\n\tWaitCommandSchema,\n\tScrollToCommandSchema,\n\tFindCommandSchema,\n\tSearchCommandSchema,\n\tListOptionsCommandSchema,\n\tPickOptionCommandSchema,\n\tExtractStructuredCommandSchema,\n} from './types.js';\n"
  },
  {
    "path": "packages/core/src/commands/types.ts",
    "content": "import { z } from 'zod';\n\n// ── Individual action schemas ──\n\nexport const TapCommandSchema = z.object({\n\taction: z.literal('tap'),\n\tindex: z.number().describe('Element index to click'),\n\tclickCount: z.number().optional().default(1).describe('Number of clicks'),\n\tcoordinateX: z.number().optional().describe('X coordinate for coordinate-based clicking'),\n\tcoordinateY: z.number().optional().describe('Y coordinate for coordinate-based clicking'),\n});\n\nexport const TypeTextCommandSchema = z.object({\n\taction: z.literal('type_text'),\n\tindex: z.number().describe('Element index to type into'),\n\ttext: z.string().describe('Text to input'),\n\tclearFirst: z.boolean().optional().default(true).describe('Clear existing text first'),\n});\n\nexport const NavigateCommandSchema = z.object({\n\taction: z.literal('navigate'),\n\turl: z.string().describe('URL to navigate to'),\n});\n\nexport const BackCommandSchema = z.object({\n\taction: z.literal('back'),\n});\n\nexport const ScrollCommandSchema = z.object({\n\taction: z.literal('scroll'),\n\tdirection: z.enum(['up', 'down']).describe('Scroll direction'),\n\tamount: z.number().optional().describe('Scroll amount in pixels or pages'),\n\tindex: z.number().optional().describe('Element index to scroll within'),\n\tpages: z.number().optional().describe('Number of pages to scroll (fractional allowed)'),\n});\n\nexport const PressKeysCommandSchema = z.object({\n\taction: z.literal('press_keys'),\n\tkeys: z.string().describe('Keys to send (e.g., \"Enter\", \"Escape\", \"Control+a\")'),\n});\n\nexport const ExtractCommandSchema = z.object({\n\taction: z.literal('extract'),\n\tgoal: z.string().describe('What information to extract from the page'),\n\toutputSchema: z.record(z.unknown()).optional().describe('Optional JSON schema for structured output'),\n});\n\nexport const FinishCommandSchema = z.object({\n\taction: z.literal('finish'),\n\ttext: z.string().describe('Final result text'),\n\tsuccess: z.boolean().optional().default(true),\n});\n\nexport const FocusTabCommandSchema = z.object({\n\taction: z.literal('focus_tab'),\n\ttabIndex: z.number().describe('Tab index to switch to'),\n});\n\nexport const NewTabCommandSchema = z.object({\n\taction: z.literal('new_tab'),\n\turl: z.string().describe('URL to open in new tab'),\n});\n\nexport const CloseTabCommandSchema = z.object({\n\taction: z.literal('close_tab'),\n\ttabIndex: z.number().optional().describe('Tab index to close (current if omitted)'),\n});\n\nexport const WebSearchCommandSchema = z.object({\n\taction: z.literal('web_search'),\n\tquery: z.string().describe('Search query'),\n});\n\nexport const UploadCommandSchema = z.object({\n\taction: z.literal('upload'),\n\tindex: z.number().describe('File input element index'),\n\tfilePaths: z.array(z.string()).describe('File paths to upload'),\n});\n\nexport const SelectCommandSchema = z.object({\n\taction: z.literal('select'),\n\tindex: z.number().describe('Select element index'),\n\tvalue: z.string().describe('Option value to select'),\n});\n\nexport const CaptureCommandSchema = z.object({\n\taction: z.literal('capture'),\n\tfullPage: z.boolean().optional().default(false),\n});\n\nexport const ReadPageCommandSchema = z.object({\n\taction: z.literal('read_page'),\n});\n\nexport const WaitCommandSchema = z.object({\n\taction: z.literal('wait'),\n\tseconds: z.number().optional().default(3).describe('Seconds to wait'),\n});\n\n// ── New action schemas ──\n\nexport const ScrollToCommandSchema = z.object({\n\taction: z.literal('scroll_to'),\n\ttext: z.string().describe('Text to scroll to on the page'),\n});\n\nexport const FindCommandSchema = z.object({\n\taction: z.literal('find'),\n\tquery: z.string().describe('Description of elements to find (e.g., \"all submit buttons\")'),\n});\n\nexport const SearchCommandSchema = z.object({\n\taction: z.literal('search'),\n\tquery: z.string().describe('Search query'),\n\tengine: z.enum(['google', 'duckduckgo', 'bing']).optional().default('google'),\n});\n\nexport const ListOptionsCommandSchema = z.object({\n\taction: z.literal('list_options'),\n\tindex: z.number().describe('Select element index'),\n});\n\nexport const PickOptionCommandSchema = z.object({\n\taction: z.literal('pick_option'),\n\tindex: z.number().describe('Select element index'),\n\toptionText: z.string().describe('Text of the option to select'),\n});\n\nexport const ExtractStructuredCommandSchema = z.object({\n\taction: z.literal('extract_structured'),\n\tgoal: z.string().describe('Description of what data to extract from the page'),\n\toutputSchema: z\n\t\t.record(z.unknown())\n\t\t.describe(\n\t\t\t'JSON Schema describing the structure of the expected output. The LLM will return data conforming to this schema.',\n\t\t),\n\tmaxContentLength: z\n\t\t.number()\n\t\t.optional()\n\t\t.default(8000)\n\t\t.describe('Maximum number of characters of page content to send to the LLM'),\n});\n\n// ── Discriminated union of all actions ──\n\nexport const CommandSchema = z.discriminatedUnion('action', [\n\tTapCommandSchema,\n\tTypeTextCommandSchema,\n\tNavigateCommandSchema,\n\tBackCommandSchema,\n\tScrollCommandSchema,\n\tPressKeysCommandSchema,\n\tExtractCommandSchema,\n\tFinishCommandSchema,\n\tFocusTabCommandSchema,\n\tNewTabCommandSchema,\n\tCloseTabCommandSchema,\n\tWebSearchCommandSchema,\n\tUploadCommandSchema,\n\tSelectCommandSchema,\n\tCaptureCommandSchema,\n\tReadPageCommandSchema,\n\tWaitCommandSchema,\n\tScrollToCommandSchema,\n\tFindCommandSchema,\n\tSearchCommandSchema,\n\tListOptionsCommandSchema,\n\tPickOptionCommandSchema,\n\tExtractStructuredCommandSchema,\n]);\n\nexport type Command = z.infer<typeof CommandSchema>;\n\nexport type CommandName = Command['action'];\n\n// ── Action result ──\n\nexport interface CommandResult {\n\tsuccess: boolean;\n\textractedContent?: string;\n\terror?: string;\n\tisDone?: boolean;\n\tincludeInMemory?: boolean;\n}\n\n// ── Browser error categories ──\n\nexport type ViewportErrorCategory =\n\t| 'navigation'\n\t| 'element_not_found'\n\t| 'element_stale'\n\t| 'element_not_interactable'\n\t| 'timeout'\n\t| 'permission'\n\t| 'network'\n\t| 'crash'\n\t| 'unknown';\n\nexport interface InterpretedViewportError {\n\tcategory: ViewportErrorCategory;\n\tmessage: string;\n\tsuggestion: string;\n\tisRetryable: boolean;\n}\n\n// ── Custom action definition ──\n\nexport interface CustomCommandSpec {\n\tname: string;\n\tdescription: string;\n\tschema: z.ZodObject<any>;\n\thandler: (params: Record<string, unknown>, context: ExecutionContext) => Promise<CommandResult>;\n\tterminatesSequence?: boolean;\n}\n\nexport interface ExecutionContext {\n\tpage: import('playwright').Page;\n\tcdpSession: import('playwright').CDPSession;\n\tdomService: import('../page/page-analyzer.js').PageAnalyzer;\n\tbrowserSession: import('../viewport/viewport.js').Viewport;\n\textractionLlm?: import('../model/interface.js').LanguageModel;\n\tfileSystem?: import('../sandbox/file-access.js').FileAccess;\n\tmaskedValues?: Record<string, string>;\n}\n"
  },
  {
    "path": "packages/core/src/commands/utils.ts",
    "content": "import type { Page } from 'playwright';\n\nexport async function scrollPage(\n\tpage: Page,\n\tdirection: 'up' | 'down',\n\tamount?: number,\n): Promise<void> {\n\tconst scrollAmount = amount ?? 500;\n\tconst delta = direction === 'down' ? scrollAmount : -scrollAmount;\n\n\tawait page.evaluate((d) => {\n\t\twindow.scrollBy(0, d);\n\t}, delta);\n\n\t// Wait for scroll to complete\n\tawait new Promise((resolve) => setTimeout(resolve, 200));\n}\n\nexport async function scrollElement(\n\tpage: Page,\n\tselector: string,\n\tdirection: 'up' | 'down',\n\tamount?: number,\n): Promise<void> {\n\tconst scrollAmount = amount ?? 300;\n\tconst delta = direction === 'down' ? scrollAmount : -scrollAmount;\n\n\tawait page.evaluate(\n\t\t({ sel, d }) => {\n\t\t\tconst el = document.querySelector(sel);\n\t\t\tif (el) el.scrollBy(0, d);\n\t\t},\n\t\t{ sel: selector, d: delta },\n\t);\n\n\tawait new Promise((resolve) => setTimeout(resolve, 200));\n}\n\nexport function buildGoogleSearchUrl(query: string): string {\n\treturn `https://www.google.com/search?q=${encodeURIComponent(query)}&udm=14`;\n}\n"
  },
  {
    "path": "packages/core/src/config/config.ts",
    "content": "import { config as loadDotenv } from 'dotenv';\nimport * as path from 'node:path';\nimport * as os from 'node:os';\nimport * as fs from 'node:fs';\nimport { type GlobalConfig, GlobalConfigSchema, type ConfigFileContents } from './types.js';\nimport type { DeepPartial } from '../types.js';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('config');\n\nlet _instance: Config | undefined;\n\nexport class Config {\n\treadonly config: GlobalConfig;\n\n\tprivate constructor(overrides: DeepPartial<GlobalConfig> = {}) {\n\t\tloadDotenv();\n\n\t\t// Load from config file first, then merge env and overrides\n\t\tconst fileConfig = Config.loadConfigFile();\n\t\tconst merged = this.deepMerge(\n\t\t\tthis.mergeEnvDefaults({}),\n\t\t\tfileConfig,\n\t\t\toverrides,\n\t\t);\n\t\tthis.config = GlobalConfigSchema.parse(merged);\n\t}\n\n\tstatic instance(overrides?: DeepPartial<GlobalConfig>): Config {\n\t\tif (!_instance) {\n\t\t\t_instance = new Config(overrides);\n\t\t}\n\t\treturn _instance;\n\t}\n\n\tstatic reset(): void {\n\t\t_instance = undefined;\n\t}\n\n\tprivate mergeEnvDefaults(overrides: DeepPartial<GlobalConfig>): DeepPartial<GlobalConfig> {\n\t\tconst env = process.env;\n\n\t\tconst proxy = env.OPEN_BROWSER_PROXY_SERVER\n\t\t\t? {\n\t\t\t\t\tserver: env.OPEN_BROWSER_PROXY_SERVER,\n\t\t\t\t\tusername: env.OPEN_BROWSER_PROXY_USERNAME,\n\t\t\t\t\tpassword: env.OPEN_BROWSER_PROXY_PASSWORD,\n\t\t\t\t}\n\t\t\t: (env.HTTP_PROXY || env.HTTPS_PROXY)\n\t\t\t\t? { server: (env.HTTPS_PROXY || env.HTTP_PROXY)! }\n\t\t\t\t: undefined;\n\n\t\treturn {\n\t\t\tbrowser: {\n\t\t\t\theadless: env.BROWSER_HEADLESS !== 'false',\n\t\t\t\trelaxedSecurity: env.BROWSER_DISABLE_SECURITY === 'true',\n\t\t\t\tbrowserBinaryPath: env.BROWSER_BINARY_PATH ?? undefined,\n\t\t\t\tuserDataDir: env.BROWSER_USER_DATA_DIR ?? undefined,\n\t\t\t\t...(proxy ? { proxy } : {}),\n\t\t\t\t...overrides.browser,\n\t\t\t},\n\t\t\ttracePath: env.OPEN_BROWSER_TRACE_PATH ?? overrides.tracePath,\n\t\t\trecordingPath: env.OPEN_BROWSER_SAVE_RECORDING_PATH ?? overrides.recordingPath,\n\t\t\t...overrides,\n\t\t};\n\t}\n\n\tprivate deepMerge(...objects: DeepPartial<GlobalConfig>[]): DeepPartial<GlobalConfig> {\n\t\tconst result: Record<string, unknown> = {};\n\n\t\tfor (const obj of objects) {\n\t\t\tif (!obj) continue;\n\t\t\tfor (const [key, value] of Object.entries(obj)) {\n\t\t\t\tif (\n\t\t\t\t\tvalue !== null &&\n\t\t\t\t\tvalue !== undefined &&\n\t\t\t\t\ttypeof value === 'object' &&\n\t\t\t\t\t!Array.isArray(value) &&\n\t\t\t\t\ttypeof result[key] === 'object' &&\n\t\t\t\t\tresult[key] !== null &&\n\t\t\t\t\t!Array.isArray(result[key])\n\t\t\t\t) {\n\t\t\t\t\tresult[key] = this.deepMerge(\n\t\t\t\t\t\tresult[key] as DeepPartial<GlobalConfig>,\n\t\t\t\t\t\tvalue as DeepPartial<GlobalConfig>,\n\t\t\t\t\t);\n\t\t\t\t} else if (value !== undefined) {\n\t\t\t\t\tresult[key] = value;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn result as DeepPartial<GlobalConfig>;\n\t}\n\n\tget browser() {\n\t\treturn this.config.browser;\n\t}\n\n\tget agent() {\n\t\treturn this.config.agent;\n\t}\n\n\tstatic get configDir(): string {\n\t\tconst dir = path.join(os.homedir(), '.open-browser');\n\t\tif (!fs.existsSync(dir)) {\n\t\t\tfs.mkdirSync(dir, { recursive: true });\n\t\t}\n\t\treturn dir;\n\t}\n\n\tstatic get tmpDir(): string {\n\t\tconst dir = path.join(Config.configDir, 'tmp');\n\t\tif (!fs.existsSync(dir)) {\n\t\t\tfs.mkdirSync(dir, { recursive: true });\n\t\t}\n\t\treturn dir;\n\t}\n\n\tstatic get configFilePath(): string {\n\t\treturn path.join(Config.configDir, 'config.json');\n\t}\n\n\tstatic loadConfigFile(): DeepPartial<GlobalConfig> {\n\t\ttry {\n\t\t\tconst filePath = Config.configFilePath;\n\t\t\tif (fs.existsSync(filePath)) {\n\t\t\t\tconst raw = fs.readFileSync(filePath, 'utf-8');\n\t\t\t\tconst parsed = JSON.parse(raw) as ConfigFileContents;\n\t\t\t\tlogger.debug(`Loaded config from ${filePath}`);\n\t\t\t\treturn parsed;\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.warn(`Failed to load config file: ${error}`);\n\t\t}\n\t\treturn {};\n\t}\n\n\tstatic saveConfigFile(config: ConfigFileContents): void {\n\t\tconst filePath = Config.configFilePath;\n\t\tconst dir = path.dirname(filePath);\n\t\tif (!fs.existsSync(dir)) {\n\t\t\tfs.mkdirSync(dir, { recursive: true });\n\t\t}\n\t\tfs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');\n\t\tlogger.info(`Config saved to ${filePath}`);\n\t}\n\n\tstatic isDocker(): boolean {\n\t\ttry {\n\t\t\tif (fs.existsSync('/.dockerenv')) return true;\n\t\t\tif (fs.existsSync('/proc/1/cgroup')) {\n\t\t\t\tconst cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8');\n\t\t\t\treturn cgroup.includes('docker') || cgroup.includes('kubepods');\n\t\t\t}\n\t\t} catch {\n\t\t\t// Not on Linux, definitely not Docker\n\t\t}\n\t\treturn false;\n\t}\n\n\tstatic hasDisplay(): boolean {\n\t\tif (process.platform === 'win32') return true;\n\t\tif (process.platform === 'darwin') return true;\n\t\treturn !!process.env.DISPLAY || !!process.env.WAYLAND_DISPLAY;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/config/index.ts",
    "content": "export { Config } from './config.js';\nexport {\n\ttype ViewportConfig,\n\tViewportConfigSchema,\n\ttype AgentConfig,\n\tAgentConfigSchema,\n\ttype GlobalConfig,\n\tGlobalConfigSchema,\n} from './types.js';\n"
  },
  {
    "path": "packages/core/src/config/types.ts",
    "content": "import { z } from 'zod';\n\nexport const ProxyConfigSchema = z.object({\n\tserver: z.string(),\n\tusername: z.string().optional(),\n\tpassword: z.string().optional(),\n\tbypass: z.array(z.string()).optional(),\n});\n\nexport type ProxyConfig = z.infer<typeof ProxyConfigSchema>;\n\nexport const ViewportConfigSchema = z.object({\n\theadless: z.boolean().default(true),\n\trelaxedSecurity: z.boolean().default(false),\n\textraChromiumArgs: z.array(z.string()).default([]),\n\twindowWidth: z.number().default(1280),\n\twindowHeight: z.number().default(1100),\n\tproxy: ProxyConfigSchema.optional(),\n\tminWaitPageLoadMs: z.number().default(500),\n\twaitForNetworkIdleMs: z.number().default(1000),\n\tmaxWaitPageLoadMs: z.number().default(5000),\n\tcookieFile: z.string().optional(),\n\tminimumWaitBetweenActions: z.number().default(1000),\n\tmaxErrorLength: z.number().default(400),\n\tcommandsPerStep: z.number().default(10),\n\tbrowserBinaryPath: z.string().optional(),\n\tuserDataDir: z.string().optional(),\n\tpersistAfterClose: z.boolean().default(false),\n\tchannelName: z.string().optional(),\n\tdeterministicRendering: z.boolean().default(false),\n\tmaxIframes: z.number().default(3),\n\tdownloadsPath: z.string().optional(),\n});\n\nexport type ViewportConfig = z.infer<typeof ViewportConfigSchema>;\n\nexport const AgentConfigSchema = z.object({\n\tstepLimit: z.number().default(100),\n\tcommandsPerStep: z.number().default(10),\n\tfailureThreshold: z.number().default(5),\n\tretryDelay: z.number().default(10),\n\tenableScreenshots: z.boolean().default(true),\n\tenableScreenshotsForTextExtraction: z.boolean().default(false),\n\tcontextWindowSize: z.number().default(128000),\n\tinlineCommands: z.boolean().default(true),\n\tcapturedAttributes: z.array(z.string()).default([\n\t\t'title',\n\t\t'type',\n\t\t'name',\n\t\t'role',\n\t\t'tabindex',\n\t\t'aria-label',\n\t\t'placeholder',\n\t\t'value',\n\t\t'alt',\n\t\t'aria-expanded',\n\t]),\n\tcommandDelayMs: z.number().default(1),\n\tallowedUrls: z.array(z.string()).optional(),\n\tblockedUrls: z.array(z.string()).optional(),\n\ttraceOutputPath: z.string().optional(),\n\treplayOutputPath: z.string().optional(),\n\tstrategyInterval: z.number().default(0),\n\tplannerModel: z.any().optional(),\n\tenableStrategy: z.boolean().default(false),\n\tenableEvaluation: z.boolean().default(false),\n\tstepTimeout: z.number().default(60000),\n\tllmTimeout: z.number().default(30000),\n\tmaxElementsInDom: z.number().default(2000),\n\tcoordinateClicking: z.boolean().default(false),\n\tcompactMode: z.boolean().default(false),\n});\n\nexport type AgentConfig = z.infer<typeof AgentConfigSchema>;\n\nexport const GlobalConfigSchema = z.object({\n\tbrowser: ViewportConfigSchema.default({}),\n\tagent: AgentConfigSchema.default({}),\n\ttracePath: z.string().default('./traces'),\n\trecordingPath: z.string().default('./recordings'),\n});\n\nexport type GlobalConfig = z.infer<typeof GlobalConfigSchema>;\n\nexport interface ConfigFileContents {\n\tbrowser?: Partial<ViewportConfig>;\n\tagent?: Partial<AgentConfig>;\n\ttracePath?: string;\n\trecordingPath?: string;\n}\n"
  },
  {
    "path": "packages/core/src/errors.ts",
    "content": "export class OpenBrowserError extends Error {\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'OpenBrowserError';\n\t}\n}\n\nexport class ViewportError extends OpenBrowserError {\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'ViewportError';\n\t}\n}\n\nexport class LaunchFailedError extends ViewportError {\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'LaunchFailedError';\n\t}\n}\n\nexport class NavigationFailedError extends ViewportError {\n\tconstructor(\n\t\tmessage: string,\n\t\tpublic readonly url: string,\n\t\toptions?: ErrorOptions,\n\t) {\n\t\tsuper(message, options);\n\t\tthis.name = 'NavigationFailedError';\n\t}\n}\n\nexport class ViewportCrashedError extends ViewportError {\n\tconstructor(message = 'Browser has crashed', options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'ViewportCrashedError';\n\t}\n}\n\nexport class AgentError extends OpenBrowserError {\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'AgentError';\n\t}\n}\n\nexport class AgentStalledError extends AgentError {\n\tconstructor(message = 'Agent is stuck in a loop', options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'AgentStalledError';\n\t}\n}\n\nexport class StepLimitExceededError extends AgentError {\n\tpublic readonly stepsTaken: number;\n\tpublic readonly stepLimit: number;\n\n\tconstructor(stepsTaken: number, stepLimit: number, options?: ErrorOptions) {\n\t\tsuper(`Agent reached maximum steps (${stepsTaken}/${stepLimit})`, options);\n\t\tthis.name = 'StepLimitExceededError';\n\t\tthis.stepsTaken = stepsTaken;\n\t\tthis.stepLimit = stepLimit;\n\t}\n}\n\nexport class UrlBlockedError extends OpenBrowserError {\n\tpublic readonly url: string;\n\n\tconstructor(url: string, options?: ErrorOptions) {\n\t\tsuper(`URL not allowed: ${url}`, options);\n\t\tthis.name = 'UrlBlockedError';\n\t\tthis.url = url;\n\t}\n}\n\nexport class PageExtractionError extends OpenBrowserError {\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'PageExtractionError';\n\t}\n}\n\nexport class ModelError extends OpenBrowserError {\n\tconstructor(message: string, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'ModelError';\n\t}\n}\n\nexport class ModelThrottledError extends ModelError {\n\tpublic readonly retryAfterMs?: number;\n\n\tconstructor(message: string, retryAfterMs?: number, options?: ErrorOptions) {\n\t\tsuper(message, options);\n\t\tthis.name = 'ModelThrottledError';\n\t\tthis.retryAfterMs = retryAfterMs;\n\t}\n}\n\nexport class CommandFailedError extends OpenBrowserError {\n\tpublic readonly toolName: string;\n\n\tconstructor(toolName: string, message: string, options?: ErrorOptions) {\n\t\tsuper(`Tool \"${toolName}\" failed: ${message}`, options);\n\t\tthis.name = 'CommandFailedError';\n\t\tthis.toolName = toolName;\n\t}\n}\n\nexport class ContextualViewportError extends ViewportError {\n\tpublic readonly pageUrl: string;\n\tpublic readonly pageTitle: string;\n\tpublic readonly stepNumber: number;\n\n\tconstructor(\n\t\tmessage: string,\n\t\tcontext: { pageUrl: string; pageTitle: string; stepNumber: number },\n\t\toptions?: ErrorOptions,\n\t) {\n\t\tsuper(\n\t\t\t`[Step ${context.stepNumber}] ${message} (url: ${context.pageUrl})`,\n\t\t\toptions,\n\t\t);\n\t\tthis.name = 'ContextualViewportError';\n\t\tthis.pageUrl = context.pageUrl;\n\t\tthis.pageTitle = context.pageTitle;\n\t\tthis.stepNumber = context.stepNumber;\n\t}\n}\n\nexport class ProviderError extends ModelError {\n\tpublic readonly provider: string;\n\tpublic readonly statusCode?: number;\n\n\tconstructor(\n\t\tprovider: string,\n\t\tmessage: string,\n\t\tstatusCode?: number,\n\t\toptions?: ErrorOptions,\n\t) {\n\t\tsuper(`[${provider}] ${message}`, options);\n\t\tthis.name = 'ProviderError';\n\t\tthis.provider = provider;\n\t\tthis.statusCode = statusCode;\n\t}\n\n\tget isRetryable(): boolean {\n\t\tif (this.statusCode === undefined) return false;\n\t\treturn this.statusCode === 429 || this.statusCode >= 500;\n\t}\n}\n\nexport class SchemaViolationError extends OpenBrowserError {\n\tpublic readonly field: string;\n\tpublic readonly issues: string[];\n\n\tconstructor(field: string, issues: string[], options?: ErrorOptions) {\n\t\tsuper(`Validation failed for \"${field}\": ${issues.join('; ')}`, options);\n\t\tthis.name = 'SchemaViolationError';\n\t\tthis.field = field;\n\t\tthis.issues = issues;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/index.ts",
    "content": "// ── Core types ──\nexport {\n\ttype TargetId,\n\ttype SessionId,\n\ttype ElementRef,\n\ttype TabId,\n\ttargetId,\n\tsessionId,\n\telementIndex,\n\ttabId,\n\ttype Result,\n\tok,\n\terr,\n\ttype Position,\n\ttype Rect,\n\tLogLevel,\n\ttype DeepPartial,\n\ttype Awaitable,\n} from './types.js';\n\n// ── Errors ──\nexport {\n\tOpenBrowserError,\n\tViewportError,\n\tLaunchFailedError,\n\tNavigationFailedError,\n\tViewportCrashedError,\n\tContextualViewportError,\n\tAgentError,\n\tAgentStalledError,\n\tStepLimitExceededError,\n\tUrlBlockedError,\n\tPageExtractionError,\n\tModelError,\n\tModelThrottledError,\n\tCommandFailedError,\n\tProviderError,\n\tSchemaViolationError,\n} from './errors.js';\n\n// ── Logging ──\nexport {\n\tLogger,\n\tcreateLogger,\n\tsetGlobalLogLevel,\n\tgetGlobalLogLevel,\n\tsetLogColors,\n\tsetLogTimestamps,\n} from './logging.js';\n\n// ── Observability ──\nexport {\n\ttimed,\n\twithTiming,\n\tStopwatch,\n\ttype TimingResult,\n} from './telemetry.js';\n\n// ── Utils ──\nexport { generateId, matchesUrlPattern, isUrlPermitted, sleep, withDeadline, Timer } from './utils.js';\n\n// ── Config ──\nexport { Config } from './config/index.js';\nexport type { ViewportConfig, AgentConfig as AgentConfigSchema, GlobalConfig } from './config/index.js';\n\n// ── LLM ──\nexport {\n\ttype LanguageModel,\n\ttype InferenceOptions,\n\ttype ModelProvider,\n\ttype InferenceResult,\n\ttype InferenceUsage,\n\ttype Message,\n\ttype SystemMessage,\n\ttype UserMessage,\n\ttype AssistantMessage,\n\ttype ToolResultMessage,\n\ttype ToolCall,\n\ttype ContentPart,\n\ttype TextContent,\n\ttype ImageContent,\n\tsystemMessage,\n\tuserMessage,\n\tassistantMessage,\n\ttoolResultMessage,\n\ttextContent,\n\timageContent,\n\tVercelModelAdapter,\n\ttype VercelModelAdapterOptions,\n\tzodToJsonSchema,\n\toptimizeSchemaForModel,\n\toptimizeJsonSchemaForModel,\n\ttype SchemaOptimizationOptions,\n} from './model/index.js';\n\n// ── Browser ──\nexport {\n\tViewport,\n\ttype ViewportOptions,\n\tLaunchProfile,\n\tEventHub,\n\tBaseGuard,\n\ttype GuardContext,\n\tVisualTracer,\n\ttype VisualTracerOptions,\n\ttype TabDescriptor,\n\ttype ViewportSnapshot,\n\ttype ViewportHistory,\n\ttype LaunchOptions,\n\ttype PageState,\n\ttype ViewportEventMap,\n\ttype ViewportRequestMap,\n\ttype NavigateEvent,\n\ttype ClickEvent,\n\ttype InputEvent,\n\ttype ScrollEvent,\n\ttype ScreenshotEvent,\n\ttype ScreenshotResult,\n\ttype DownloadEvent,\n\ttype PopupEvent,\n\ttype SecurityEvent,\n\ttype CrashEvent,\n} from './viewport/index.js';\n\n// ── DOM ──\nexport {\n\tPageAnalyzer,\n\ttype PageAnalyzerOptions,\n\tSnapshotBuilder,\n\tTreeRenderer,\n\ttype RendererOptions,\n\textractMarkdown,\n\thtmlToMarkdown,\n\textractTextContent,\n\textractLinks,\n\tchunkText,\n\ttype MarkdownExtractionOptions,\n\ttype PageTreeNode,\n\ttype SelectorIndex,\n\ttype RenderedPageState,\n\ttype DOMRect,\n\ttype CDPSnapshotResult,\n\ttype AXNode,\n\ttype TargetInfo,\n\ttype TargetAllTrees,\n\ttype InteractedElement,\n\ttype MatchLevel,\n\ttype SimplifiedNode,\n} from './page/index.js';\n\n// ── FileAccess ──\nexport {\n\tFileAccess,\n\ttype FileAccessOptions,\n\ttype FileInfo,\n\ttype FileAccessState,\n} from './sandbox/index.js';\n\n// ── Commands ──\nexport {\n\tCommandExecutor,\n\ttype CommandExecutorOptions,\n\tclassifyViewportError,\n\tCommandCatalog,\n\tContentExtractor,\n\ttype CatalogEntry,\n\ttype CatalogOptions,\n\tCommandSchema,\n\ttype Command,\n\ttype CommandName,\n\ttype CommandResult,\n\ttype ExecutionContext,\n\ttype CustomCommandSpec,\n\ttype ViewportErrorCategory,\n\ttype InterpretedViewportError,\n\tTapCommandSchema,\n\tTypeTextCommandSchema,\n\tNavigateCommandSchema,\n\tBackCommandSchema,\n\tScrollCommandSchema,\n\tPressKeysCommandSchema,\n\tExtractCommandSchema,\n\tFinishCommandSchema,\n\tFocusTabCommandSchema,\n\tNewTabCommandSchema,\n\tCloseTabCommandSchema,\n\tWebSearchCommandSchema,\n\tUploadCommandSchema,\n\tSelectCommandSchema,\n\tCaptureCommandSchema,\n\tReadPageCommandSchema,\n\tWaitCommandSchema,\n\tScrollToCommandSchema,\n\tFindCommandSchema,\n\tSearchCommandSchema,\n\tListOptionsCommandSchema,\n\tPickOptionCommandSchema,\n\tExtractStructuredCommandSchema,\n} from './commands/index.js';\n\n// ── Agent ──\nexport {\n\tAgent,\n\ttype AgentOptions,\n\tInstructionBuilder,\n\tStepPromptBuilder,\n\tbuildCommandDescriptions,\n\tbuildContextualCommands,\n\tbuildExtractionInstructionBuilder,\n\tbuildExtractionUserPrompt,\n\tclearTemplateCache,\n\ttype PromptTemplate,\n\ttype InstructionBuilderOptions,\n\ttype StepInfo,\n\ttype StepPromptBuilderOptions,\n\tConversationManager,\n\tStallDetector,\n\thashPageTree,\n\thashTextContent,\n\ttype PageSignature,\n\ttype StallDetectorConfig,\n\ttype StallCheckResult,\n\tResultEvaluator,\n\tconstructEvaluatorMessages,\n\tconstructQuickCheckMessages,\n\tReplayRecorder,\n\ttype ReplayRecorderOptions,\n\ttype AgentConfig,\n\ttype AgentState,\n\ttype AgentDecision,\n\ttype AgentDecisionCompact,\n\ttype AgentDecisionDirect,\n\ttype StepRecord,\n\tExecutionLog,\n\ttype RunOutcome,\n\ttype Reasoning,\n\ttype PlanStep,\n\ttype EvaluationResult,\n\ttype QuickCheckResult,\n\ttype CompactionPolicy,\n\ttype StepTelemetry,\n\ttype ExtractedVariable,\n\ttype AccumulatedCost,\n\ttype StepCostBreakdown,\n\ttype PricingTable as AgentPricingTable,\n\ttype PlanRevision,\n\tAgentDecisionSchema,\n\tAgentDecisionCompactSchema,\n\tAgentDecisionDirectSchema,\n\tReasoningSchema,\n\tEvaluationResultSchema,\n\tQuickCheckResultSchema,\n\tPlanStepSchema,\n\tStrategyPlanSchema,\n\tPlanRevisionSchema,\n\tPRICING_TABLE,\n\tcalculateStepCost,\n\tsupportsDeepReasoning,\n\tsupportsCoordinateMode,\n\tisCompactModel,\n\tDEFAULT_AGENT_CONFIG,\n\ttype ConversationManagerOptions,\n\ttype TrackedMessage,\n\ttype ConversationManagerState,\n\ttype ConversationEntry,\n\ttype SerializedTrackedMessage,\n\ttype MessageCategory,\n\testimateTokens,\n\testimateMessageTokens,\n\tredactSensitiveValues,\n\tredactMessage,\n\tredactMessages,\n\textractTextContent as extractMessageTextContent,\n\ttruncate,\n} from './agent/index.js';\n\n// ── Bridge ──\nexport { BridgeServer, type BridgeServerOptions, BridgeClient, type BridgeClientOptions, BridgeAdapter } from './bridge/index.js';\n\n// ── Metering ──\nexport {\n\tUsageMeter,\n\tCompositeUsageMeter,\n\tBudgetDepletedError,\n\testimateTokenCount,\n\tDEFAULT_COST_RATES,\n\ttype UsageRecord,\n\ttype CostRates,\n\ttype PricingTable,\n\ttype ModelRole,\n\ttype ActionUsageRecord,\n\ttype MeteringSummary,\n\ttype ModelUsageBreakdown,\n\ttype RoleUsageBreakdown,\n\ttype BudgetPolicy,\n\ttype BudgetState,\n} from './metering/index.js';\n"
  },
  {
    "path": "packages/core/src/logging.ts",
    "content": "import { LogLevel } from './types.js';\n\nconst LEVEL_NAMES: Record<number, string> = {\n\t[LogLevel.DEBUG]: 'DEBUG',\n\t[LogLevel.INFO]: 'INFO',\n\t[LogLevel.WARN]: 'WARN',\n\t[LogLevel.ERROR]: 'ERROR',\n};\n\nconst LEVEL_COLORS: Record<number, string> = {\n\t[LogLevel.DEBUG]: '\\x1b[36m', // cyan\n\t[LogLevel.INFO]: '\\x1b[32m',  // green\n\t[LogLevel.WARN]: '\\x1b[33m',  // yellow\n\t[LogLevel.ERROR]: '\\x1b[31m', // red\n};\n\nconst RESET = '\\x1b[0m';\nconst DIM = '\\x1b[2m';\nconst BOLD = '\\x1b[1m';\n\nlet globalLevel: LogLevel = LogLevel.INFO;\nlet useColors = true;\nlet logTimestamps = true;\n\nexport function setGlobalLogLevel(level: LogLevel): void {\n\tglobalLevel = level;\n}\n\nexport function getGlobalLogLevel(): LogLevel {\n\treturn globalLevel;\n}\n\nexport function setLogColors(enabled: boolean): void {\n\tuseColors = enabled;\n}\n\nexport function setLogTimestamps(enabled: boolean): void {\n\tlogTimestamps = enabled;\n}\n\nfunction formatTimestamp(): string {\n\tconst now = new Date();\n\tconst h = now.getHours().toString().padStart(2, '0');\n\tconst m = now.getMinutes().toString().padStart(2, '0');\n\tconst s = now.getSeconds().toString().padStart(2, '0');\n\tconst ms = now.getMilliseconds().toString().padStart(3, '0');\n\treturn `${h}:${m}:${s}.${ms}`;\n}\n\nfunction formatMessage(\n\tlevel: LogLevel,\n\tname: string,\n\tmessage: string,\n): string {\n\tconst parts: string[] = [];\n\n\tif (logTimestamps) {\n\t\tconst ts = formatTimestamp();\n\t\tparts.push(useColors ? `${DIM}${ts}${RESET}` : ts);\n\t}\n\n\tconst levelName = LEVEL_NAMES[level] ?? 'UNKNOWN';\n\tconst color = LEVEL_COLORS[level] ?? '';\n\n\tif (useColors) {\n\t\tparts.push(`${color}${levelName.padEnd(5)}${RESET}`);\n\t\tparts.push(`${BOLD}[${name}]${RESET}`);\n\t} else {\n\t\tparts.push(levelName.padEnd(5));\n\t\tparts.push(`[${name}]`);\n\t}\n\n\tparts.push(message);\n\treturn parts.join(' ');\n}\n\nexport class Logger {\n\treadonly name: string;\n\tprivate level: LogLevel | null = null;\n\n\tconstructor(name: string) {\n\t\tthis.name = name;\n\t}\n\n\tsetLevel(level: LogLevel): void {\n\t\tthis.level = level;\n\t}\n\n\tgetEffectiveLevel(): LogLevel {\n\t\treturn this.level ?? globalLevel;\n\t}\n\n\tisEnabled(level: LogLevel): boolean {\n\t\treturn level >= this.getEffectiveLevel();\n\t}\n\n\tdebug(message: string, ...args: unknown[]): void {\n\t\tthis.log(LogLevel.DEBUG, message, ...args);\n\t}\n\n\tinfo(message: string, ...args: unknown[]): void {\n\t\tthis.log(LogLevel.INFO, message, ...args);\n\t}\n\n\twarn(message: string, ...args: unknown[]): void {\n\t\tthis.log(LogLevel.WARN, message, ...args);\n\t}\n\n\terror(message: string, ...args: unknown[]): void {\n\t\tthis.log(LogLevel.ERROR, message, ...args);\n\t}\n\n\tprivate log(level: LogLevel, message: string, ...args: unknown[]): void {\n\t\tif (!this.isEnabled(level)) return;\n\n\t\tconst formatted = formatMessage(level, this.name, message);\n\n\t\tswitch (level) {\n\t\t\tcase LogLevel.ERROR:\n\t\t\t\tconsole.error(formatted, ...args);\n\t\t\t\tbreak;\n\t\t\tcase LogLevel.WARN:\n\t\t\t\tconsole.warn(formatted, ...args);\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tconsole.log(formatted, ...args);\n\t\t}\n\t}\n}\n\nconst loggerCache = new Map<string, Logger>();\n\nexport function createLogger(name: string): Logger {\n\tlet logger = loggerCache.get(name);\n\tif (!logger) {\n\t\tlogger = new Logger(name);\n\t\tloggerCache.set(name, logger);\n\t}\n\treturn logger;\n}\n"
  },
  {
    "path": "packages/core/src/metering/index.ts",
    "content": "export { UsageMeter, CompositeUsageMeter, BudgetDepletedError, estimateTokenCount } from './tracker.js';\nexport {\n\tDEFAULT_COST_RATES,\n\ttype UsageRecord,\n\ttype CostRates,\n\ttype PricingTable,\n\ttype ModelRole,\n\ttype ActionUsageRecord,\n\ttype MeteringSummary,\n\ttype ModelUsageBreakdown,\n\ttype RoleUsageBreakdown,\n\ttype BudgetPolicy,\n\ttype BudgetState,\n} from './types.js';\n"
  },
  {
    "path": "packages/core/src/metering/tracker.test.ts",
    "content": "import { test, expect, describe, beforeEach, mock } from 'bun:test';\nimport {\n\tUsageMeter,\n\tCompositeUsageMeter,\n\tBudgetDepletedError,\n\testimateTokenCount,\n} from './tracker.js';\nimport type { PricingTable } from './types.js';\n\n// ── Shared pricing for predictable cost calculations ──\n\nconst TEST_PRICING: PricingTable = {\n\t'gpt-4o': { inputCostPerMillion: 2.5, outputCostPerMillion: 10.0 },\n\t'gpt-4o-mini': { inputCostPerMillion: 0.15, outputCostPerMillion: 0.6 },\n\t'claude-3-5-sonnet': { inputCostPerMillion: 3.0, outputCostPerMillion: 15.0 },\n};\n\n// ── UsageMeter ──\n\ndescribe('UsageMeter', () => {\n\tlet tracker: UsageMeter;\n\n\tbeforeEach(() => {\n\t\ttracker = new UsageMeter('gpt-4o', TEST_PRICING);\n\t});\n\n\tdescribe('record and getTotalUsage', () => {\n\t\ttest('records token usage and returns totals', () => {\n\t\t\ttracker.record(100, 50);\n\n\t\t\tconst usage = tracker.getTotalUsage();\n\t\t\texpect(usage.inputTokens).toBe(100);\n\t\t\texpect(usage.outputTokens).toBe(50);\n\t\t\texpect(usage.totalTokens).toBe(150);\n\t\t});\n\n\t\ttest('accumulates across multiple records', () => {\n\t\t\ttracker.record(100, 50);\n\t\t\ttracker.record(200, 100);\n\t\t\ttracker.record(300, 150);\n\n\t\t\tconst usage = tracker.getTotalUsage();\n\t\t\texpect(usage.inputTokens).toBe(600);\n\t\t\texpect(usage.outputTokens).toBe(300);\n\t\t\texpect(usage.totalTokens).toBe(900);\n\t\t});\n\n\t\ttest('returns a copy of usage object', () => {\n\t\t\ttracker.record(100, 50);\n\t\t\tconst usage1 = tracker.getTotalUsage();\n\t\t\tconst usage2 = tracker.getTotalUsage();\n\t\t\texpect(usage1).not.toBe(usage2);\n\t\t\texpect(usage1).toEqual(usage2);\n\t\t});\n\t});\n\n\tdescribe('getEstimatedCost', () => {\n\t\ttest('computes correct cost for gpt-4o', () => {\n\t\t\t// gpt-4o: $2.50/M input, $10.00/M output\n\t\t\ttracker.record(1_000_000, 500_000);\n\n\t\t\tconst cost = tracker.getEstimatedCost();\n\t\t\t// input: 1M * 2.5/M = 2.5; output: 0.5M * 10/M = 5.0\n\t\t\texpect(cost).toBeCloseTo(7.5, 4);\n\t\t});\n\n\t\ttest('returns 0 for unknown model', () => {\n\t\t\tconst unknown = new UsageMeter('unknown-model', TEST_PRICING);\n\t\t\tunknown.record(1000, 500);\n\n\t\t\texpect(unknown.getEstimatedCost()).toBe(0);\n\t\t});\n\n\t\ttest('formats cost as dollar string', () => {\n\t\t\ttracker.record(100_000, 50_000);\n\t\t\tconst formatted = tracker.getEstimatedCostFormatted();\n\t\t\texpect(formatted).toMatch(/^\\$\\d+\\.\\d{4}$/);\n\t\t});\n\t});\n\n\tdescribe('getStepUsages', () => {\n\t\ttest('tracks per-step usage', () => {\n\t\t\ttracker.record(100, 50);\n\t\t\ttracker.record(200, 100);\n\n\t\t\tconst steps = tracker.getStepUsages();\n\t\t\texpect(steps).toHaveLength(2);\n\t\t\texpect(steps[0]).toEqual({ inputTokens: 100, outputTokens: 50, totalTokens: 150 });\n\t\t\texpect(steps[1]).toEqual({ inputTokens: 200, outputTokens: 100, totalTokens: 300 });\n\t\t});\n\n\t\ttest('returns a copy of step usages array', () => {\n\t\t\ttracker.record(100, 50);\n\t\t\tconst steps1 = tracker.getStepUsages();\n\t\t\tconst steps2 = tracker.getStepUsages();\n\t\t\texpect(steps1).not.toBe(steps2);\n\t\t});\n\t});\n\n\tdescribe('getSummary', () => {\n\t\ttest('returns formatted summary string', () => {\n\t\t\ttracker.record(1000, 500);\n\n\t\t\tconst summary = tracker.getSummary();\n\t\t\texpect(summary).toContain('Model: gpt-4o');\n\t\t\texpect(summary).toContain('Steps: 1');\n\t\t\texpect(summary).toContain('Input tokens:');\n\t\t\texpect(summary).toContain('Output tokens:');\n\t\t\texpect(summary).toContain('Total tokens:');\n\t\t\texpect(summary).toContain('Estimated cost: $');\n\t\t});\n\t});\n\n\tdescribe('reset', () => {\n\t\ttest('resets all usage data', () => {\n\t\t\ttracker.record(1000, 500);\n\t\t\ttracker.record(2000, 1000);\n\n\t\t\ttracker.reset();\n\n\t\t\tconst usage = tracker.getTotalUsage();\n\t\t\texpect(usage.inputTokens).toBe(0);\n\t\t\texpect(usage.outputTokens).toBe(0);\n\t\t\texpect(usage.totalTokens).toBe(0);\n\t\t\texpect(tracker.getStepUsages()).toHaveLength(0);\n\t\t\texpect(tracker.getEstimatedCost()).toBe(0);\n\t\t});\n\t});\n\n\tdescribe('partial model matching', () => {\n\t\ttest('matches model by partial ID', () => {\n\t\t\t// \"gpt-4o\" pricing should match \"gpt-4o-2024-08-06\" via partial match\n\t\t\tconst versioned = new UsageMeter('gpt-4o-2024-08-06', TEST_PRICING);\n\t\t\tversioned.record(1_000_000, 0);\n\n\t\t\t// Should find gpt-4o pricing ($2.50/M input)\n\t\t\texpect(versioned.getEstimatedCost()).toBeCloseTo(2.5, 4);\n\t\t});\n\t});\n});\n\n// ── CompositeUsageMeter ──\n\ndescribe('CompositeUsageMeter', () => {\n\tlet multiTracker: CompositeUsageMeter;\n\n\tbeforeEach(() => {\n\t\tmultiTracker = new CompositeUsageMeter(TEST_PRICING);\n\t});\n\n\tdescribe('record and getTotalUsage', () => {\n\t\ttest('records usage for a single model', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t});\n\n\t\t\tconst usage = multiTracker.getTotalUsage();\n\t\t\texpect(usage.inputTokens).toBe(1000);\n\t\t\texpect(usage.outputTokens).toBe(500);\n\t\t\texpect(usage.totalTokens).toBe(1500);\n\t\t});\n\n\t\ttest('aggregates across multiple models', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t});\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o-mini',\n\t\t\t\trole: 'extraction',\n\t\t\t\tinputTokens: 2000,\n\t\t\t\toutputTokens: 800,\n\t\t\t});\n\n\t\t\tconst usage = multiTracker.getTotalUsage();\n\t\t\texpect(usage.inputTokens).toBe(3000);\n\t\t\texpect(usage.outputTokens).toBe(1300);\n\t\t\texpect(usage.totalTokens).toBe(4300);\n\t\t});\n\n\t\ttest('returns estimated cost for the recorded call', () => {\n\t\t\tconst cost = multiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1_000_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\t// gpt-4o: $2.50/M input\n\t\t\texpect(cost).toBeCloseTo(2.5, 4);\n\t\t});\n\t});\n\n\tdescribe('getTotalCost', () => {\n\t\ttest('sums costs across all models', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1_000_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o-mini',\n\t\t\t\trole: 'extraction',\n\t\t\t\tinputTokens: 1_000_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\tconst totalCost = multiTracker.getTotalCost();\n\t\t\t// gpt-4o: $2.50; gpt-4o-mini: $0.15\n\t\t\texpect(totalCost).toBeCloseTo(2.65, 4);\n\t\t});\n\n\t\ttest('formats total cost', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 100_000,\n\t\t\t\toutputTokens: 50_000,\n\t\t\t});\n\n\t\t\tconst formatted = multiTracker.getTotalCostFormatted();\n\t\t\texpect(formatted).toMatch(/^\\$\\d+\\.\\d{4}$/);\n\t\t});\n\t});\n\n\tdescribe('getTracker', () => {\n\t\ttest('returns per-model tracker', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 500,\n\t\t\t\toutputTokens: 200,\n\t\t\t});\n\n\t\t\tconst tracker = multiTracker.getTracker('gpt-4o');\n\t\t\texpect(tracker.getTotalUsage().inputTokens).toBe(500);\n\t\t});\n\n\t\ttest('creates tracker on first access', () => {\n\t\t\tconst tracker = multiTracker.getTracker('claude-3-5-sonnet');\n\t\t\texpect(tracker).toBeDefined();\n\t\t\texpect(tracker.getTotalUsage().totalTokens).toBe(0);\n\t\t});\n\t});\n\n\tdescribe('budget alerts', () => {\n\t\ttest('fires threshold callback when cost crosses threshold', () => {\n\t\t\tconst thresholdCrossed = mock(() => {});\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 1.0,\n\t\t\t\tthresholds: [0.5, 0.8, 1.0],\n\t\t\t\tonThresholdCrossed: thresholdCrossed,\n\t\t\t});\n\n\t\t\t// Record enough to cross 0.5 threshold ($0.50)\n\t\t\t// gpt-4o: $2.50/M input -> need 200k tokens for $0.50\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 200_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\texpect(thresholdCrossed).toHaveBeenCalledTimes(1);\n\t\t\tconst call = (thresholdCrossed as any).mock.calls[0];\n\t\t\texpect(call[1]).toBe(0.5); // threshold\n\t\t\texpect(call[2]).toBe(1.0); // maxCost\n\t\t});\n\n\t\ttest('fires multiple thresholds as cost increases', () => {\n\t\t\tconst thresholdCrossed = mock(() => {});\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 1.0,\n\t\t\t\tthresholds: [0.5, 1.0],\n\t\t\t\tonThresholdCrossed: thresholdCrossed,\n\t\t\t});\n\n\t\t\t// Cross 0.5 threshold\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 200_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\t// Cross 1.0 threshold\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 200_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\texpect(thresholdCrossed).toHaveBeenCalledTimes(2);\n\t\t});\n\n\t\ttest('does not fire same threshold twice', () => {\n\t\t\tconst thresholdCrossed = mock(() => {});\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 1.0,\n\t\t\t\tthresholds: [0.5],\n\t\t\t\tonThresholdCrossed: thresholdCrossed,\n\t\t\t});\n\n\t\t\t// Cross 0.5 threshold twice\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 200_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 10_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\texpect(thresholdCrossed).toHaveBeenCalledTimes(1);\n\t\t});\n\n\t\ttest('throws BudgetDepletedError when budget exceeded and callback returns false', () => {\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 0.01,\n\t\t\t\tthresholds: [1.0],\n\t\t\t\tonThresholdCrossed: () => {},\n\t\t\t\tonBudgetExhausted: () => false,\n\t\t\t});\n\n\t\t\texpect(() =>\n\t\t\t\tmultiTracker.record({\n\t\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\t\trole: 'main',\n\t\t\t\t\tinputTokens: 1_000_000,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t}),\n\t\t\t).toThrow(BudgetDepletedError);\n\t\t});\n\n\t\ttest('allows continuing when onBudgetExhausted returns true', () => {\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 0.01,\n\t\t\t\tthresholds: [1.0],\n\t\t\t\tonThresholdCrossed: () => {},\n\t\t\t\tonBudgetExhausted: () => true,\n\t\t\t});\n\n\t\t\texpect(() =>\n\t\t\t\tmultiTracker.record({\n\t\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\t\trole: 'main',\n\t\t\t\t\tinputTokens: 1_000_000,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t}),\n\t\t\t).not.toThrow();\n\t\t});\n\n\t\ttest('getBudgetState reflects current state', () => {\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 10.0,\n\t\t\t\tthresholds: [0.5],\n\t\t\t\tonThresholdCrossed: () => {},\n\t\t\t});\n\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1_000_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\tconst status = multiTracker.getBudgetState();\n\t\t\texpect(status.maxCostUsd).toBe(10.0);\n\t\t\texpect(status.currentCostUsd).toBeCloseTo(2.5, 2);\n\t\t\texpect(status.fractionUsed).toBeCloseTo(0.25, 2);\n\t\t\texpect(status.isExhausted).toBe(false);\n\t\t});\n\n\t\ttest('clearBudget removes budget configuration', () => {\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 1.0,\n\t\t\t\tthresholds: [0.5],\n\t\t\t\tonThresholdCrossed: () => {},\n\t\t\t});\n\n\t\t\tmultiTracker.clearBudget();\n\n\t\t\tconst status = multiTracker.getBudgetState();\n\t\t\texpect(status.maxCostUsd).toBeUndefined();\n\t\t\texpect(status.fractionUsed).toBeUndefined();\n\t\t\texpect(status.isExhausted).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('MeteringSummary generation', () => {\n\t\ttest('generates comprehensive summary', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t\tstepIndex: 0,\n\t\t\t\tactionName: 'tap',\n\t\t\t});\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o-mini',\n\t\t\t\trole: 'extraction',\n\t\t\t\tinputTokens: 2000,\n\t\t\t\toutputTokens: 300,\n\t\t\t\tstepIndex: 1,\n\t\t\t\tactionName: 'extract',\n\t\t\t});\n\n\t\t\tconst summary = multiTracker.getSummary();\n\n\t\t\texpect(summary.totalInputTokens).toBe(3000);\n\t\t\texpect(summary.totalOutputTokens).toBe(800);\n\t\t\texpect(summary.totalTokens).toBe(3800);\n\t\t\texpect(summary.totalCalls).toBe(2);\n\t\t\texpect(summary.totalEstimatedCost).toBeGreaterThan(0);\n\n\t\t\t// By model breakdown\n\t\t\texpect(summary.byModel).toHaveLength(2);\n\t\t\tconst gpt4o = summary.byModel.find((m) => m.modelId === 'gpt-4o');\n\t\t\texpect(gpt4o).toBeDefined();\n\t\t\texpect(gpt4o!.inputTokens).toBe(1000);\n\t\t\texpect(gpt4o!.callCount).toBe(1);\n\n\t\t\t// By role breakdown\n\t\t\texpect(summary.byRole).toHaveLength(2);\n\t\t\tconst mainRole = summary.byRole.find((r) => r.role === 'main');\n\t\t\texpect(mainRole).toBeDefined();\n\t\t\texpect(mainRole!.callCount).toBe(1);\n\n\t\t\t// Action trace\n\t\t\texpect(summary.actionTrace).toHaveLength(2);\n\t\t\texpect(summary.actionTrace[0].actionName).toBe('tap');\n\t\t\texpect(summary.actionTrace[1].actionName).toBe('extract');\n\n\t\t\t// Duration\n\t\t\texpect(summary.durationMs).toBeDefined();\n\t\t\texpect(summary.durationMs!).toBeGreaterThanOrEqual(0);\n\t\t});\n\n\t\ttest('generates human-readable summary text', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 10000,\n\t\t\t\toutputTokens: 5000,\n\t\t\t});\n\n\t\t\tconst text = multiTracker.getSummaryText();\n\t\t\texpect(text).toContain('Token Usage Summary');\n\t\t\texpect(text).toContain('Total:');\n\t\t\texpect(text).toContain('Cost:');\n\t\t\texpect(text).toContain('Calls:');\n\t\t\texpect(text).toContain('Duration:');\n\t\t\texpect(text).toContain('By Role');\n\t\t\texpect(text).toContain('By Model');\n\t\t});\n\n\t\ttest('includes budget info in summary text when configured', () => {\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 5.0,\n\t\t\t\tthresholds: [],\n\t\t\t\tonThresholdCrossed: () => {},\n\t\t\t});\n\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 100_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\tconst text = multiTracker.getSummaryText();\n\t\t\texpect(text).toContain('Budget:');\n\t\t\texpect(text).toContain('$5.0000');\n\t\t});\n\t});\n\n\tdescribe('reset', () => {\n\t\ttest('clears all tracking data', () => {\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 1000,\n\t\t\t\toutputTokens: 500,\n\t\t\t});\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o-mini',\n\t\t\t\trole: 'extraction',\n\t\t\t\tinputTokens: 500,\n\t\t\t\toutputTokens: 200,\n\t\t\t});\n\n\t\t\tmultiTracker.reset();\n\n\t\t\tconst usage = multiTracker.getTotalUsage();\n\t\t\texpect(usage.totalTokens).toBe(0);\n\t\t\texpect(multiTracker.getTotalCost()).toBe(0);\n\n\t\t\tconst summary = multiTracker.getSummary();\n\t\t\texpect(summary.totalCalls).toBe(0);\n\t\t\texpect(summary.byModel).toHaveLength(0);\n\t\t\texpect(summary.byRole).toHaveLength(0);\n\t\t\texpect(summary.durationMs).toBeUndefined();\n\t\t});\n\n\t\ttest('resets budget thresholds', () => {\n\t\t\tconst thresholdCrossed = mock(() => {});\n\t\t\tmultiTracker.setBudget({\n\t\t\t\tmaxCostUsd: 1.0,\n\t\t\t\tthresholds: [0.5],\n\t\t\t\tonThresholdCrossed: thresholdCrossed,\n\t\t\t});\n\n\t\t\t// Cross 0.5 threshold\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 200_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\tmultiTracker.reset();\n\n\t\t\t// Record again -- should fire threshold callback again since it was reset\n\t\t\t// But reset() clears crossedThresholds AND trackers, so cost starts at 0\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 200_000,\n\t\t\t\toutputTokens: 0,\n\t\t\t});\n\n\t\t\t// Both before and after reset should have fired\n\t\t\texpect(thresholdCrossed).toHaveBeenCalledTimes(2);\n\t\t});\n\t});\n\n\tdescribe('auto-start', () => {\n\t\ttest('automatically starts timer on first record', () => {\n\t\t\tconst summary1 = multiTracker.getSummary();\n\t\t\texpect(summary1.durationMs).toBeUndefined();\n\n\t\t\tmultiTracker.record({\n\t\t\t\tmodelId: 'gpt-4o',\n\t\t\t\trole: 'main',\n\t\t\t\tinputTokens: 100,\n\t\t\t\toutputTokens: 50,\n\t\t\t});\n\n\t\t\tconst summary2 = multiTracker.getSummary();\n\t\t\texpect(summary2.durationMs).toBeDefined();\n\t\t});\n\n\t\ttest('explicit start() sets the timer', () => {\n\t\t\tmultiTracker.start();\n\n\t\t\tconst summary = multiTracker.getSummary();\n\t\t\texpect(summary.durationMs).toBeDefined();\n\t\t\texpect(summary.durationMs!).toBeGreaterThanOrEqual(0);\n\t\t});\n\t});\n});\n\n// ── estimateTokenCount ──\n\ndescribe('estimateTokenCount', () => {\n\ttest('estimates roughly 1 token per 4 chars', () => {\n\t\texpect(estimateTokenCount('hello world')).toBe(3); // ceil(11/4)\n\t});\n\n\ttest('returns 0 for empty string', () => {\n\t\texpect(estimateTokenCount('')).toBe(0);\n\t});\n\n\ttest('rounds up', () => {\n\t\texpect(estimateTokenCount('a')).toBe(1); // ceil(1/4) = 1\n\t});\n});\n\n// ── BudgetDepletedError ──\n\ndescribe('BudgetDepletedError', () => {\n\ttest('has correct properties', () => {\n\t\tconst error = new BudgetDepletedError(5.5, 5.0);\n\t\texpect(error.name).toBe('BudgetDepletedError');\n\t\texpect(error.currentCost).toBe(5.5);\n\t\texpect(error.maxCost).toBe(5.0);\n\t\texpect(error.message).toContain('$5.5000');\n\t\texpect(error.message).toContain('$5.0000');\n\t});\n\n\ttest('is instanceof Error', () => {\n\t\tconst error = new BudgetDepletedError(1, 1);\n\t\texpect(error instanceof Error).toBe(true);\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/metering/tracker.ts",
    "content": "import type {\n\tUsageRecord,\n\tCostRates,\n\tPricingTable,\n\tModelRole,\n\tActionUsageRecord,\n\tMeteringSummary,\n\tModelUsageBreakdown,\n\tRoleUsageBreakdown,\n\tBudgetPolicy,\n\tBudgetState,\n} from './types.js';\nimport { DEFAULT_COST_RATES } from './types.js';\n\n// ── Single-model tracker (unchanged public API) ──\n\nexport class UsageMeter {\n\tprivate usage: UsageRecord = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };\n\tprivate pricing: PricingTable;\n\tprivate modelId: string;\n\tprivate stepUsages: UsageRecord[] = [];\n\n\tconstructor(modelId: string, customPricing?: PricingTable) {\n\t\tthis.modelId = modelId;\n\t\tthis.pricing = customPricing ?? DEFAULT_COST_RATES;\n\t}\n\n\trecord(inputTokens: number, outputTokens: number): void {\n\t\tconst stepUsage: UsageRecord = {\n\t\t\tinputTokens,\n\t\t\toutputTokens,\n\t\t\ttotalTokens: inputTokens + outputTokens,\n\t\t};\n\n\t\tthis.usage.inputTokens += inputTokens;\n\t\tthis.usage.outputTokens += outputTokens;\n\t\tthis.usage.totalTokens += inputTokens + outputTokens;\n\t\tthis.stepUsages.push(stepUsage);\n\t}\n\n\tgetTotalUsage(): UsageRecord {\n\t\treturn { ...this.usage };\n\t}\n\n\tgetStepUsages(): UsageRecord[] {\n\t\treturn [...this.stepUsages];\n\t}\n\n\tgetEstimatedCost(): number {\n\t\tconst cost = this.getModelCost();\n\t\tif (!cost) return 0;\n\n\t\treturn (\n\t\t\t(this.usage.inputTokens / 1_000_000) * cost.inputCostPerMillion +\n\t\t\t(this.usage.outputTokens / 1_000_000) * cost.outputCostPerMillion\n\t\t);\n\t}\n\n\tgetEstimatedCostFormatted(): string {\n\t\tconst cost = this.getEstimatedCost();\n\t\treturn `$${cost.toFixed(4)}`;\n\t}\n\n\tprivate getModelCost(): CostRates | undefined {\n\t\treturn resolveModelCost(this.modelId, this.pricing);\n\t}\n\n\tgetSummary(): string {\n\t\tconst lines = [\n\t\t\t`Model: ${this.modelId}`,\n\t\t\t`Steps: ${this.stepUsages.length}`,\n\t\t\t`Input tokens: ${this.usage.inputTokens.toLocaleString()}`,\n\t\t\t`Output tokens: ${this.usage.outputTokens.toLocaleString()}`,\n\t\t\t`Total tokens: ${this.usage.totalTokens.toLocaleString()}`,\n\t\t\t`Estimated cost: ${this.getEstimatedCostFormatted()}`,\n\t\t];\n\t\treturn lines.join('\\n');\n\t}\n\n\treset(): void {\n\t\tthis.usage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };\n\t\tthis.stepUsages = [];\n\t}\n}\n\n// ── Multi-model tracker ──\n\n/**\n * Tracks token usage across multiple LLM roles (main, extraction, judge, compaction)\n * with per-action cost breakdown, budget alerts, and comprehensive summaries.\n */\nexport class CompositeUsageMeter {\n\tprivate readonly pricing: PricingTable;\n\tprivate readonly trackers = new Map<string, UsageMeter>();\n\tprivate readonly actionTrace: ActionUsageRecord[] = [];\n\tprivate budgetConfig: BudgetPolicy | undefined;\n\tprivate crossedThresholds = new Set<number>();\n\tprivate startTime: number | undefined;\n\n\tconstructor(customPricing?: PricingTable) {\n\t\tthis.pricing = customPricing ?? DEFAULT_COST_RATES;\n\t}\n\n\t/** Start the session timer. Called automatically on first record if not called explicitly. */\n\tstart(): void {\n\t\tthis.startTime = Date.now();\n\t}\n\n\t/**\n\t * Configure budget alerts. Thresholds default to [0.5, 0.8, 1.0].\n\t * Returns this for chaining.\n\t */\n\tsetBudget(config: BudgetPolicy): this {\n\t\tthis.budgetConfig = {\n\t\t\t...config,\n\t\t\tthresholds: config.thresholds ?? [0.5, 0.8, 1.0],\n\t\t};\n\t\tthis.crossedThresholds.clear();\n\t\treturn this;\n\t}\n\n\t/** Clear the budget configuration. */\n\tclearBudget(): void {\n\t\tthis.budgetConfig = undefined;\n\t\tthis.crossedThresholds.clear();\n\t}\n\n\t/**\n\t * Record token usage for a specific model and role.\n\t * Returns the estimated cost for this single call.\n\t * Throws if budget is exhausted and onBudgetExhausted returns false.\n\t */\n\trecord(opts: {\n\t\tmodelId: string;\n\t\trole: ModelRole;\n\t\tinputTokens: number;\n\t\toutputTokens: number;\n\t\tstepIndex?: number;\n\t\tactionName?: string;\n\t}): number {\n\t\tif (!this.startTime) this.start();\n\n\t\t// Get or create per-model tracker\n\t\tconst tracker = this.getOrCreateTracker(opts.modelId);\n\t\ttracker.record(opts.inputTokens, opts.outputTokens);\n\n\t\t// Compute cost for this call\n\t\tconst cost = computeCost(opts.inputTokens, opts.outputTokens, opts.modelId, this.pricing);\n\n\t\t// Append to action trace\n\t\tconst entry: ActionUsageRecord = {\n\t\t\tstepIndex: opts.stepIndex ?? this.actionTrace.length,\n\t\t\tactionName: opts.actionName ?? 'unknown',\n\t\t\trole: opts.role,\n\t\t\tmodelId: opts.modelId,\n\t\t\tusage: {\n\t\t\t\tinputTokens: opts.inputTokens,\n\t\t\t\toutputTokens: opts.outputTokens,\n\t\t\t\ttotalTokens: opts.inputTokens + opts.outputTokens,\n\t\t\t},\n\t\t\tcost,\n\t\t\ttimestamp: Date.now(),\n\t\t};\n\t\tthis.actionTrace.push(entry);\n\n\t\t// Check budget thresholds\n\t\tthis.checkBudget();\n\n\t\treturn cost;\n\t}\n\n\t/** Get the per-model UsageMeter (creates one if missing). */\n\tgetTracker(modelId: string): UsageMeter {\n\t\treturn this.getOrCreateTracker(modelId);\n\t}\n\n\t/** Total estimated cost across all models. */\n\tgetTotalCost(): number {\n\t\tlet total = 0;\n\t\tfor (const tracker of this.trackers.values()) {\n\t\t\ttotal += tracker.getEstimatedCost();\n\t\t}\n\t\treturn total;\n\t}\n\n\t/** Formatted total cost string. */\n\tgetTotalCostFormatted(): string {\n\t\treturn `$${this.getTotalCost().toFixed(4)}`;\n\t}\n\n\t/** Aggregate token usage across all models. */\n\tgetTotalUsage(): UsageRecord {\n\t\tlet inputTokens = 0;\n\t\tlet outputTokens = 0;\n\t\tfor (const tracker of this.trackers.values()) {\n\t\t\tconst u = tracker.getTotalUsage();\n\t\t\tinputTokens += u.inputTokens;\n\t\t\toutputTokens += u.outputTokens;\n\t\t}\n\t\treturn { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens };\n\t}\n\n\t/** Get the current budget status. */\n\tgetBudgetState(): BudgetState {\n\t\tconst currentCost = this.getTotalCost();\n\t\tconst maxCost = this.budgetConfig?.maxCostUsd;\n\n\t\treturn {\n\t\t\tcurrentCostUsd: currentCost,\n\t\t\tmaxCostUsd: maxCost,\n\t\t\tfractionUsed: maxCost != null ? currentCost / maxCost : undefined,\n\t\t\tisExhausted: maxCost != null ? currentCost >= maxCost : false,\n\t\t\tcrossedThresholds: [...this.crossedThresholds].sort((a, b) => a - b),\n\t\t};\n\t}\n\n\t/** Build a full MeteringSummary with per-model and per-role breakdowns. */\n\tgetSummary(): MeteringSummary {\n\t\tconst totalUsage = this.getTotalUsage();\n\n\t\treturn {\n\t\t\ttotalInputTokens: totalUsage.inputTokens,\n\t\t\ttotalOutputTokens: totalUsage.outputTokens,\n\t\t\ttotalTokens: totalUsage.totalTokens,\n\t\t\ttotalEstimatedCost: this.getTotalCost(),\n\t\t\ttotalCalls: this.actionTrace.length,\n\t\t\tbyModel: this.buildModelBreakdown(),\n\t\t\tbyRole: this.buildRoleBreakdown(),\n\t\t\tactionTrace: [...this.actionTrace],\n\t\t\tdurationMs: this.startTime ? Date.now() - this.startTime : undefined,\n\t\t};\n\t}\n\n\t/** Human-readable summary string. */\n\tgetSummaryText(): string {\n\t\tconst s = this.getSummary();\n\t\tconst lines: string[] = [\n\t\t\t'=== Token Usage Summary ===',\n\t\t\t`Total: ${s.totalTokens.toLocaleString()} tokens (${s.totalInputTokens.toLocaleString()} in / ${s.totalOutputTokens.toLocaleString()} out)`,\n\t\t\t`Cost: $${s.totalEstimatedCost.toFixed(4)}`,\n\t\t\t`Calls: ${s.totalCalls}`,\n\t\t];\n\n\t\tif (s.durationMs != null) {\n\t\t\tlines.push(`Duration: ${(s.durationMs / 1000).toFixed(1)}s`);\n\t\t}\n\n\t\tif (s.byRole.length > 0) {\n\t\t\tlines.push('', '--- By Role ---');\n\t\t\tfor (const r of s.byRole) {\n\t\t\t\tlines.push(\n\t\t\t\t\t`  ${r.role}: ${r.totalTokens.toLocaleString()} tokens, $${r.estimatedCost.toFixed(4)} (${r.callCount} calls)`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tif (s.byModel.length > 0) {\n\t\t\tlines.push('', '--- By Model ---');\n\t\t\tfor (const m of s.byModel) {\n\t\t\t\tlines.push(\n\t\t\t\t\t`  ${m.modelId}: ${m.totalTokens.toLocaleString()} tokens, $${m.estimatedCost.toFixed(4)} (${m.callCount} calls)`,\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tconst budget = this.getBudgetState();\n\t\tif (budget.maxCostUsd != null) {\n\t\t\tconst pct = ((budget.fractionUsed ?? 0) * 100).toFixed(1);\n\t\t\tlines.push(\n\t\t\t\t'',\n\t\t\t\t`Budget: $${budget.currentCostUsd.toFixed(4)} / $${budget.maxCostUsd.toFixed(4)} (${pct}%)`,\n\t\t\t);\n\t\t}\n\n\t\treturn lines.join('\\n');\n\t}\n\n\t/** Reset all tracking data. */\n\treset(): void {\n\t\tfor (const tracker of this.trackers.values()) {\n\t\t\ttracker.reset();\n\t\t}\n\t\tthis.trackers.clear();\n\t\tthis.actionTrace.length = 0;\n\t\tthis.crossedThresholds.clear();\n\t\tthis.startTime = undefined;\n\t}\n\n\t// ── Private helpers ──\n\n\tprivate getOrCreateTracker(modelId: string): UsageMeter {\n\t\tlet tracker = this.trackers.get(modelId);\n\t\tif (!tracker) {\n\t\t\ttracker = new UsageMeter(modelId, this.pricing);\n\t\t\tthis.trackers.set(modelId, tracker);\n\t\t}\n\t\treturn tracker;\n\t}\n\n\tprivate checkBudget(): void {\n\t\tif (!this.budgetConfig) return;\n\n\t\tconst currentCost = this.getTotalCost();\n\t\tconst { maxCostUsd, thresholds, onThresholdCrossed, onBudgetExhausted } = this.budgetConfig;\n\n\t\t// Check each threshold\n\t\tfor (const threshold of thresholds ?? []) {\n\t\t\tif (this.crossedThresholds.has(threshold)) continue;\n\n\t\t\tconst thresholdCost = maxCostUsd * threshold;\n\t\t\tif (currentCost >= thresholdCost) {\n\t\t\t\tthis.crossedThresholds.add(threshold);\n\t\t\t\tonThresholdCrossed(currentCost, threshold, maxCostUsd);\n\t\t\t}\n\t\t}\n\n\t\t// Check full exhaustion\n\t\tif (currentCost >= maxCostUsd) {\n\t\t\tif (onBudgetExhausted) {\n\t\t\t\tconst allow = onBudgetExhausted(currentCost, maxCostUsd);\n\t\t\t\tif (!allow) {\n\t\t\t\t\tthrow new BudgetDepletedError(currentCost, maxCostUsd);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate buildModelBreakdown(): ModelUsageBreakdown[] {\n\t\tconst map = new Map<string, ModelUsageBreakdown>();\n\n\t\tfor (const entry of this.actionTrace) {\n\t\t\tlet mb = map.get(entry.modelId);\n\t\t\tif (!mb) {\n\t\t\t\tmb = {\n\t\t\t\t\tmodelId: entry.modelId,\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\testimatedCost: 0,\n\t\t\t\t\tcallCount: 0,\n\t\t\t\t};\n\t\t\t\tmap.set(entry.modelId, mb);\n\t\t\t}\n\t\t\tmb.inputTokens += entry.usage.inputTokens;\n\t\t\tmb.outputTokens += entry.usage.outputTokens;\n\t\t\tmb.totalTokens += entry.usage.totalTokens;\n\t\t\tmb.estimatedCost += entry.cost;\n\t\t\tmb.callCount++;\n\t\t}\n\n\t\treturn [...map.values()].sort((a, b) => b.estimatedCost - a.estimatedCost);\n\t}\n\n\tprivate buildRoleBreakdown(): RoleUsageBreakdown[] {\n\t\tconst map = new Map<ModelRole, RoleUsageBreakdown>();\n\n\t\tfor (const entry of this.actionTrace) {\n\t\t\tlet rb = map.get(entry.role);\n\t\t\tif (!rb) {\n\t\t\t\trb = {\n\t\t\t\t\trole: entry.role,\n\t\t\t\t\tinputTokens: 0,\n\t\t\t\t\toutputTokens: 0,\n\t\t\t\t\ttotalTokens: 0,\n\t\t\t\t\testimatedCost: 0,\n\t\t\t\t\tcallCount: 0,\n\t\t\t\t};\n\t\t\t\tmap.set(entry.role, rb);\n\t\t\t}\n\t\t\trb.inputTokens += entry.usage.inputTokens;\n\t\t\trb.outputTokens += entry.usage.outputTokens;\n\t\t\trb.totalTokens += entry.usage.totalTokens;\n\t\t\trb.estimatedCost += entry.cost;\n\t\t\trb.callCount++;\n\t\t}\n\n\t\treturn [...map.values()].sort((a, b) => b.estimatedCost - a.estimatedCost);\n\t}\n}\n\n// ── Budget error ──\n\nexport class BudgetDepletedError extends Error {\n\treadonly currentCost: number;\n\treadonly maxCost: number;\n\n\tconstructor(currentCost: number, maxCost: number) {\n\t\tsuper(\n\t\t\t`Token budget exhausted: $${currentCost.toFixed(4)} spent, limit is $${maxCost.toFixed(4)}`,\n\t\t);\n\t\tthis.name = 'BudgetDepletedError';\n\t\tthis.currentCost = currentCost;\n\t\tthis.maxCost = maxCost;\n\t}\n}\n\n// ── Shared utilities ──\n\nexport function estimateTokenCount(text: string): number {\n\treturn Math.ceil(text.length / 4);\n}\n\n/** Resolve pricing for a model ID with exact-match then partial-match fallback. */\nfunction resolveModelCost(modelId: string, pricing: PricingTable): CostRates | undefined {\n\tif (pricing[modelId]) return pricing[modelId];\n\n\tfor (const [key, value] of Object.entries(pricing)) {\n\t\tif (modelId.includes(key) || key.includes(modelId)) {\n\t\t\treturn value;\n\t\t}\n\t}\n\treturn undefined;\n}\n\n/** Compute cost in USD for a single call. */\nfunction computeCost(\n\tinputTokens: number,\n\toutputTokens: number,\n\tmodelId: string,\n\tpricing: PricingTable,\n): number {\n\tconst cost = resolveModelCost(modelId, pricing);\n\tif (!cost) return 0;\n\treturn (\n\t\t(inputTokens / 1_000_000) * cost.inputCostPerMillion +\n\t\t(outputTokens / 1_000_000) * cost.outputCostPerMillion\n\t);\n}\n"
  },
  {
    "path": "packages/core/src/metering/types.ts",
    "content": "export interface UsageRecord {\n\tinputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n}\n\nexport interface CostRates {\n\tinputCostPerMillion: number;\n\toutputCostPerMillion: number;\n}\n\nexport interface PricingTable {\n\t[modelId: string]: CostRates;\n}\n\n/**\n * Role that a model can serve in the agent pipeline.\n * - main: primary reasoning / action-selection model\n * - extraction: lightweight model for page content extraction\n * - judge: evaluates task completion\n * - compaction: summarizes / compresses conversation history\n */\nexport type ModelRole = 'main' | 'extraction' | 'judge' | 'compaction';\n\n/** Token usage attributed to a single agent action (step). */\nexport interface ActionUsageRecord {\n\tstepIndex: number;\n\tactionName: string;\n\trole: ModelRole;\n\tmodelId: string;\n\tusage: UsageRecord;\n\tcost: number;\n\ttimestamp: number;\n}\n\n/** Per-model aggregated usage. */\nexport interface ModelUsageBreakdown {\n\tmodelId: string;\n\tinputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n\testimatedCost: number;\n\tcallCount: number;\n}\n\n/** Per-role aggregated usage. */\nexport interface RoleUsageBreakdown {\n\trole: ModelRole;\n\tinputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n\testimatedCost: number;\n\tcallCount: number;\n}\n\n/** Comprehensive usage summary across all models and roles. */\nexport interface MeteringSummary {\n\t/** Aggregate across everything. */\n\ttotalInputTokens: number;\n\ttotalOutputTokens: number;\n\ttotalTokens: number;\n\ttotalEstimatedCost: number;\n\ttotalCalls: number;\n\n\t/** Breakdown by model ID. */\n\tbyModel: ModelUsageBreakdown[];\n\n\t/** Breakdown by role. */\n\tbyRole: RoleUsageBreakdown[];\n\n\t/** Per-action cost trace (chronological). */\n\tactionTrace: ActionUsageRecord[];\n\n\t/** Wall-clock duration of the tracked session in ms (if available). */\n\tdurationMs?: number;\n}\n\n/** Configuration for budget alerts. */\nexport interface BudgetPolicy {\n\t/** Maximum allowed cost in USD. */\n\tmaxCostUsd: number;\n\n\t/**\n\t * Warning thresholds as fractions of maxCostUsd (e.g. [0.5, 0.8, 1.0]).\n\t * Callbacks fire when cost first crosses each threshold.\n\t */\n\tthresholds?: number[];\n\n\t/** Called each time a threshold is crossed. */\n\tonThresholdCrossed: (currentCost: number, threshold: number, maxCost: number) => void;\n\n\t/** Called when the budget is fully exhausted. Return true to allow continuing. */\n\tonBudgetExhausted?: (currentCost: number, maxCost: number) => boolean;\n}\n\n/** Status of budget consumption. */\nexport interface BudgetState {\n\tcurrentCostUsd: number;\n\tmaxCostUsd: number | undefined;\n\t/** Fraction 0..1+ of budget consumed. undefined if no budget set. */\n\tfractionUsed: number | undefined;\n\tisExhausted: boolean;\n\tcrossedThresholds: number[];\n}\n\n// ── Comprehensive default pricing ──\n\nexport const DEFAULT_COST_RATES: PricingTable = {\n\t// OpenAI\n\t'gpt-4o': { inputCostPerMillion: 2.5, outputCostPerMillion: 10.0 },\n\t'gpt-4o-mini': { inputCostPerMillion: 0.15, outputCostPerMillion: 0.6 },\n\t'gpt-4-turbo': { inputCostPerMillion: 10.0, outputCostPerMillion: 30.0 },\n\t'gpt-4.5-preview': { inputCostPerMillion: 75.0, outputCostPerMillion: 150.0 },\n\t'o1': { inputCostPerMillion: 15.0, outputCostPerMillion: 60.0 },\n\t'o1-mini': { inputCostPerMillion: 3.0, outputCostPerMillion: 12.0 },\n\t'o1-preview': { inputCostPerMillion: 15.0, outputCostPerMillion: 60.0 },\n\t'o3-mini': { inputCostPerMillion: 1.1, outputCostPerMillion: 4.4 },\n\n\t// Anthropic\n\t'claude-3-5-sonnet': { inputCostPerMillion: 3.0, outputCostPerMillion: 15.0 },\n\t'claude-3-5-haiku': { inputCostPerMillion: 0.8, outputCostPerMillion: 4.0 },\n\t'claude-3-opus': { inputCostPerMillion: 15.0, outputCostPerMillion: 75.0 },\n\t'claude-3-haiku': { inputCostPerMillion: 0.25, outputCostPerMillion: 1.25 },\n\t'claude-4-sonnet': { inputCostPerMillion: 3.0, outputCostPerMillion: 15.0 },\n\t'claude-4-opus': { inputCostPerMillion: 15.0, outputCostPerMillion: 75.0 },\n\n\t// Google\n\t'gemini-1.5-pro': { inputCostPerMillion: 1.25, outputCostPerMillion: 5.0 },\n\t'gemini-1.5-flash': { inputCostPerMillion: 0.075, outputCostPerMillion: 0.3 },\n\t'gemini-2.0-flash': { inputCostPerMillion: 0.1, outputCostPerMillion: 0.4 },\n\t'gemini-2.0-pro': { inputCostPerMillion: 1.25, outputCostPerMillion: 5.0 },\n\t'gemini-2.5-pro': { inputCostPerMillion: 1.25, outputCostPerMillion: 10.0 },\n\t'gemini-2.5-flash': { inputCostPerMillion: 0.15, outputCostPerMillion: 0.6 },\n\n\t// Mistral\n\t'mistral-large': { inputCostPerMillion: 2.0, outputCostPerMillion: 6.0 },\n\t'mistral-small': { inputCostPerMillion: 0.2, outputCostPerMillion: 0.6 },\n\t'codestral': { inputCostPerMillion: 0.3, outputCostPerMillion: 0.9 },\n\n\t// DeepSeek\n\t'deepseek-chat': { inputCostPerMillion: 0.14, outputCostPerMillion: 0.28 },\n\t'deepseek-reasoner': { inputCostPerMillion: 0.55, outputCostPerMillion: 2.19 },\n};\n"
  },
  {
    "path": "packages/core/src/model/adapters/vercel.ts",
    "content": "import { generateObject, type CoreMessage, type CoreUserMessage } from 'ai';\nimport type { LanguageModelV1 } from 'ai';\nimport type { ZodType } from 'zod';\nimport type { LanguageModel, InferenceOptions, ModelProvider } from '../interface.js';\nimport type { InferenceResult, InferenceUsage } from '../types.js';\nimport type { Message, ContentPart } from '../messages.js';\nimport { ModelError, ModelThrottledError } from '../../errors.js';\n\nexport interface VercelModelAdapterOptions {\n\tmodel: LanguageModelV1;\n\t/** Override provider detection (otherwise inferred from model.provider or modelId). */\n\tprovider?: ModelProvider;\n\ttemperature?: number;\n\tmaxTokens?: number;\n\tmaxRetries?: number;\n}\n\nexport class VercelModelAdapter implements LanguageModel {\n\tprivate readonly model: LanguageModelV1;\n\tprivate readonly defaultTemperature: number;\n\tprivate readonly defaultMaxTokens: number;\n\tprivate readonly maxRetries: number;\n\tprivate readonly _provider: ModelProvider;\n\n\tconstructor(options: VercelModelAdapterOptions) {\n\t\tthis.model = options.model;\n\t\tthis.defaultTemperature = options.temperature ?? 0;\n\t\tthis.defaultMaxTokens = options.maxTokens ?? 4096;\n\t\tthis.maxRetries = options.maxRetries ?? 3;\n\t\tthis._provider = options.provider ?? inferProvider(this.model.modelId, this.model.provider);\n\t}\n\n\tget modelId(): string {\n\t\treturn this.model.modelId;\n\t}\n\n\tget provider(): ModelProvider {\n\t\treturn this._provider;\n\t}\n\n\tasync invoke<T>(options: InferenceOptions<T>): Promise<InferenceResult<T>> {\n\t\tconst messages = this.convertMessages(options.messages);\n\n\t\ttry {\n\t\t\tconst result = await generateObject({\n\t\t\t\tmodel: this.model,\n\t\t\t\tschema: options.responseSchema as ZodType<T>,\n\t\t\t\tschemaName: options.schemaName ?? 'AgentDecision',\n\t\t\t\tschemaDescription: options.schemaDescription,\n\t\t\t\tmessages,\n\t\t\t\ttemperature: options.temperature ?? this.defaultTemperature,\n\t\t\t\tmaxTokens: options.maxTokens ?? this.defaultMaxTokens,\n\t\t\t\tmaxRetries: this.maxRetries,\n\t\t\t});\n\n\t\t\tconst usage: InferenceUsage = {\n\t\t\t\tinputTokens: result.usage?.promptTokens ?? 0,\n\t\t\t\toutputTokens: result.usage?.completionTokens ?? 0,\n\t\t\t\ttotalTokens:\n\t\t\t\t\t(result.usage?.promptTokens ?? 0) + (result.usage?.completionTokens ?? 0),\n\t\t\t};\n\n\t\t\treturn {\n\t\t\t\tparsed: result.object,\n\t\t\t\tusage,\n\t\t\t\tfinishReason: mapFinishReason(result.finishReason),\n\t\t\t};\n\t\t} catch (error: any) {\n\t\t\tif (error?.statusCode === 429 || error?.message?.includes('rate limit')) {\n\t\t\t\tconst retryAfter = error?.headers?.['retry-after'];\n\t\t\t\tthrow new ModelThrottledError(\n\t\t\t\t\terror.message ?? 'Rate limited',\n\t\t\t\t\tretryAfter ? Number.parseInt(retryAfter) * 1000 : undefined,\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow new ModelError(\n\t\t\t\t`LLM invocation failed: ${error?.message ?? String(error)}`,\n\t\t\t\t{ cause: error },\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate convertMessages(messages: Message[]): CoreMessage[] {\n\t\treturn messages.map((msg): CoreMessage => {\n\t\t\tswitch (msg.role) {\n\t\t\t\tcase 'system':\n\t\t\t\t\treturn { role: 'system', content: msg.content };\n\n\t\t\t\tcase 'user': {\n\t\t\t\t\tif (typeof msg.content === 'string') {\n\t\t\t\t\t\treturn { role: 'user', content: msg.content };\n\t\t\t\t\t}\n\t\t\t\t\treturn {\n\t\t\t\t\t\trole: 'user',\n\t\t\t\t\t\tcontent: msg.content.map((part) => this.convertContentPart(part)),\n\t\t\t\t\t} as CoreUserMessage;\n\t\t\t\t}\n\n\t\t\t\tcase 'assistant': {\n\t\t\t\t\tconst content = typeof msg.content === 'string'\n\t\t\t\t\t\t? msg.content\n\t\t\t\t\t\t: msg.content.map((part) => {\n\t\t\t\t\t\t\t\tif (part.type === 'text') return { type: 'text' as const, text: part.text };\n\t\t\t\t\t\t\t\treturn { type: 'text' as const, text: '[image]' };\n\t\t\t\t\t\t\t});\n\t\t\t\t\treturn { role: 'assistant', content };\n\t\t\t\t}\n\n\t\t\t\tcase 'tool':\n\t\t\t\t\treturn {\n\t\t\t\t\t\trole: 'user',\n\t\t\t\t\t\tcontent: `[Tool Result (${msg.toolCallId})]: ${msg.content}`,\n\t\t\t\t\t};\n\t\t\t}\n\t\t});\n\t}\n\n\tprivate convertContentPart(\n\t\tpart: ContentPart,\n\t): { type: 'text'; text: string } | { type: 'image'; image: string | URL } {\n\t\tswitch (part.type) {\n\t\t\tcase 'text':\n\t\t\t\treturn { type: 'text', text: part.text };\n\t\t\tcase 'image':\n\t\t\t\tif (part.source.type === 'base64') {\n\t\t\t\t\treturn {\n\t\t\t\t\t\ttype: 'image',\n\t\t\t\t\t\timage: part.source.data,\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t\treturn {\n\t\t\t\t\ttype: 'image',\n\t\t\t\t\timage: new URL(part.source.url),\n\t\t\t\t};\n\t\t}\n\t}\n}\n\nfunction mapFinishReason(\n\treason: string,\n): 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other' {\n\tswitch (reason) {\n\t\tcase 'stop':\n\t\t\treturn 'stop';\n\t\tcase 'length':\n\t\t\treturn 'length';\n\t\tcase 'content-filter':\n\t\t\treturn 'content-filter';\n\t\tcase 'tool-calls':\n\t\t\treturn 'tool-calls';\n\t\tcase 'error':\n\t\t\treturn 'error';\n\t\tdefault:\n\t\t\treturn 'other';\n\t}\n}\n\nconst PROVIDER_PATTERNS: Array<[RegExp, ModelProvider]> = [\n\t[/anthropic|claude/i, 'anthropic'],\n\t[/openai|gpt|o1|o3/i, 'openai'],\n\t[/google|gemini/i, 'google'],\n\t[/mistral/i, 'mistral'],\n\t[/deepseek/i, 'deepseek'],\n\t[/groq/i, 'groq'],\n\t[/fireworks/i, 'fireworks'],\n\t[/together/i, 'together'],\n];\n\nfunction inferProvider(modelId: string, providerHint?: string): ModelProvider {\n\tconst combined = `${providerHint ?? ''} ${modelId}`;\n\tfor (const [pattern, provider] of PROVIDER_PATTERNS) {\n\t\tif (pattern.test(combined)) return provider;\n\t}\n\treturn 'custom';\n}\n"
  },
  {
    "path": "packages/core/src/model/index.ts",
    "content": "export { type LanguageModel, type InferenceOptions, type ModelProvider } from './interface.js';\nexport { type InferenceResult, type InferenceUsage } from './types.js';\nexport {\n\ttype Message,\n\ttype SystemMessage,\n\ttype UserMessage,\n\ttype AssistantMessage,\n\ttype ToolResultMessage,\n\ttype ToolCall,\n\ttype ContentPart,\n\ttype TextContent,\n\ttype ImageContent,\n\tsystemMessage,\n\tuserMessage,\n\tassistantMessage,\n\ttoolResultMessage,\n\ttextContent,\n\timageContent,\n} from './messages.js';\nexport { VercelModelAdapter, type VercelModelAdapterOptions } from './adapters/vercel.js';\nexport {\n\tzodToJsonSchema,\n\toptimizeSchemaForModel,\n\toptimizeJsonSchemaForModel,\n\ttype SchemaOptimizationOptions,\n} from './schema-optimizer.js';\n"
  },
  {
    "path": "packages/core/src/model/interface.ts",
    "content": "import type { ZodType } from 'zod';\nimport type { Message } from './messages.js';\nimport type { InferenceResult } from './types.js';\n\n/** Known LLM provider identifiers. */\nexport type ModelProvider =\n\t| 'anthropic'\n\t| 'openai'\n\t| 'google'\n\t| 'mistral'\n\t| 'deepseek'\n\t| 'groq'\n\t| 'fireworks'\n\t| 'together'\n\t| 'custom';\n\nexport interface InferenceOptions<T> {\n\tmessages: Message[];\n\tresponseSchema: ZodType<T>;\n\tschemaName?: string;\n\tschemaDescription?: string;\n\ttemperature?: number;\n\tmaxTokens?: number;\n\n\t/**\n\t * Token budget for extended thinking / chain-of-thought.\n\t * Only honored by models that support thinking (Claude 3.5+, o1, etc.).\n\t * Set to 0 to disable thinking even when the model supports it.\n\t */\n\tthinkingBudget?: number;\n\n\t/**\n\t * Enable prompt caching for this call. When true, the adapter should\n\t * set cache-control headers / parameters where the provider supports it\n\t * (e.g. Anthropic prompt caching, OpenAI predicted outputs).\n\t */\n\tcache?: boolean;\n\n\t/**\n\t * Per-call timeout in milliseconds. Overrides any default timeout\n\t * configured on the LanguageModel instance.\n\t */\n\ttimeout?: number;\n}\n\nexport interface LanguageModel {\n\tinvoke<T>(options: InferenceOptions<T>): Promise<InferenceResult<T>>;\n\n\t/** The model identifier string (e.g. \"claude-3-5-sonnet-20241022\"). */\n\treadonly modelId: string;\n\n\t/** The LLM provider this model belongs to. */\n\treadonly provider: ModelProvider;\n}\n"
  },
  {
    "path": "packages/core/src/model/messages.ts",
    "content": "export interface TextContent {\n\ttype: 'text';\n\ttext: string;\n}\n\nexport interface ImageContent {\n\ttype: 'image';\n\tsource:\n\t\t| { type: 'base64'; mediaType: string; data: string }\n\t\t| { type: 'url'; url: string };\n}\n\nexport type ContentPart = TextContent | ImageContent;\n\nexport interface SystemMessage {\n\trole: 'system';\n\tcontent: string;\n}\n\nexport interface UserMessage {\n\trole: 'user';\n\tcontent: string | ContentPart[];\n}\n\nexport interface AssistantMessage {\n\trole: 'assistant';\n\tcontent: string | ContentPart[];\n\ttoolCalls?: ToolCall[];\n}\n\nexport interface ToolResultMessage {\n\trole: 'tool';\n\ttoolCallId: string;\n\tcontent: string;\n}\n\nexport interface ToolCall {\n\tid: string;\n\tname: string;\n\targs: Record<string, unknown>;\n}\n\nexport type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;\n\n// ── Helpers ──\n\nexport function systemMessage(content: string): SystemMessage {\n\treturn { role: 'system', content };\n}\n\nexport function userMessage(content: string | ContentPart[]): UserMessage {\n\treturn { role: 'user', content };\n}\n\nexport function assistantMessage(\n\tcontent: string | ContentPart[],\n\ttoolCalls?: ToolCall[],\n): AssistantMessage {\n\treturn { role: 'assistant', content, toolCalls };\n}\n\nexport function toolResultMessage(toolCallId: string, content: string): ToolResultMessage {\n\treturn { role: 'tool', toolCallId, content };\n}\n\nexport function textContent(text: string): TextContent {\n\treturn { type: 'text', text };\n}\n\nexport function imageContent(base64: string, mediaType = 'image/png'): ImageContent {\n\treturn {\n\t\ttype: 'image',\n\t\tsource: { type: 'base64', mediaType, data: base64 },\n\t};\n}\n"
  },
  {
    "path": "packages/core/src/model/schema-optimizer.ts",
    "content": "import { z, type ZodTypeAny } from 'zod';\nimport type { ModelProvider } from './interface.js';\n\n// ── Configuration ──\n\nexport interface SchemaOptimizationOptions {\n\t/** LLM provider to apply provider-specific tweaks for. */\n\tprovider?: ModelProvider;\n\n\t/**\n\t * Maximum number of variants in a discriminated union before collapsing\n\t * infrequently used ones into a generic fallback.\n\t */\n\tmaxUnionVariants?: number;\n\n\t/**\n\t * Maximum nesting depth before flattening deeply nested objects\n\t * into dot-separated flat keys.\n\t */\n\tmaxNestingDepth?: number;\n\n\t/**\n\t * Maximum number of enum values before collapsing similar ones.\n\t */\n\tmaxEnumValues?: number;\n}\n\nconst DEFAULTS: Required<Omit<SchemaOptimizationOptions, 'provider'>> = {\n\tmaxUnionVariants: 15,\n\tmaxNestingDepth: 4,\n\tmaxEnumValues: 30,\n};\n\n// ── Main entry point ──\n\n/**\n * Optimizes a JSON Schema (as a plain object) for LLM consumption.\n * Applies union collapsing, enum simplification, provider-specific tweaks,\n * and nested object flattening.\n */\nexport function optimizeJsonSchemaForModel(\n\tschema: Record<string, unknown>,\n\toptions: SchemaOptimizationOptions = {},\n): Record<string, unknown> {\n\tconst opts = { ...DEFAULTS, ...options };\n\tlet result = structuredClone(schema);\n\n\tresult = collapseUnions(result, opts.maxUnionVariants);\n\tresult = collapseEnums(result, opts.maxEnumValues);\n\tresult = flattenNesting(result, opts.maxNestingDepth);\n\n\tif (opts.provider) {\n\t\tresult = applyProviderTweaks(result, opts.provider);\n\t}\n\n\treturn result;\n}\n\n/**\n * Optimizes Zod schemas for LLM consumption by simplifying complex unions\n * and removing unnecessary constraints that confuse models.\n *\n * This works at the Zod level for simple transformations, but for deeper\n * optimization, convert to JSON Schema first with zodToJsonSchema() and\n * then call optimizeJsonSchemaForModel().\n */\nexport function optimizeSchemaForModel<T extends ZodTypeAny>(\n\tschema: T,\n\toptions: SchemaOptimizationOptions = {},\n): T {\n\t// For discriminated unions with too many variants, wrap in a transformation\n\t// that strips the union down. We operate at the Zod type level where possible.\n\tif (schema instanceof z.ZodDiscriminatedUnion) {\n\t\tconst variants = [...schema.options.values()] as ZodTypeAny[];\n\t\tconst maxVariants = options.maxUnionVariants ?? DEFAULTS.maxUnionVariants;\n\n\t\tif (variants.length > maxVariants) {\n\t\t\t// Keep the first maxVariants-1 variants and add a catch-all object\n\t\t\tconst kept = variants.slice(0, maxVariants - 1);\n\t\t\tconst catchAll = z.object({}).passthrough().describe('Other action (see documentation)');\n\t\t\tconst unionMembers = [...kept, catchAll] as unknown as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]];\n\t\t\treturn z.union(unionMembers) as any;\n\t\t}\n\t}\n\n\tif (schema instanceof z.ZodUnion) {\n\t\tconst variants = schema.options as ZodTypeAny[];\n\t\tconst maxVariants = options.maxUnionVariants ?? DEFAULTS.maxUnionVariants;\n\n\t\tif (variants.length > maxVariants) {\n\t\t\tconst kept = variants.slice(0, maxVariants - 1);\n\t\t\tconst catchAll = z.object({}).passthrough().describe('Other variant');\n\t\t\tconst unionMembers = [...kept, catchAll] as unknown as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]];\n\t\t\treturn z.union(unionMembers) as any;\n\t\t}\n\t}\n\n\treturn schema;\n}\n\n// ── Union collapsing ──\n\n/**\n * When a oneOf / anyOf has more variants than maxVariants, collapse the\n * excess into a single permissive object schema.\n */\nfunction collapseUnions(\n\tschema: Record<string, unknown>,\n\tmaxVariants: number,\n): Record<string, unknown> {\n\tschema = walkSchema(schema, (node) => {\n\t\tconst unionKey = node.oneOf ? 'oneOf' : node.anyOf ? 'anyOf' : undefined;\n\t\tif (!unionKey) return node;\n\n\t\tconst variants = node[unionKey] as Record<string, unknown>[];\n\t\tif (!Array.isArray(variants) || variants.length <= maxVariants) return node;\n\n\t\t// Keep the first N-1 variants, replace the rest with a permissive catch-all\n\t\tconst kept = variants.slice(0, maxVariants - 1);\n\t\tconst catchAll: Record<string, unknown> = {\n\t\t\ttype: 'object',\n\t\t\tdescription: `One of ${variants.length - maxVariants + 1} additional variants (see documentation)`,\n\t\t\tadditionalProperties: true,\n\t\t};\n\n\t\treturn { ...node, [unionKey]: [...kept, catchAll] };\n\t});\n\n\treturn schema;\n}\n\n// ── Enum collapsing ──\n\n/**\n * When an enum has too many values, collapse similar values by removing\n * duplicates after case-normalization, and truncate with an annotation.\n */\nfunction collapseEnums(\n\tschema: Record<string, unknown>,\n\tmaxValues: number,\n): Record<string, unknown> {\n\treturn walkSchema(schema, (node) => {\n\t\tif (!Array.isArray(node.enum)) return node;\n\n\t\tconst values = node.enum as unknown[];\n\t\tif (values.length <= maxValues) return node;\n\n\t\t// Deduplicate by lowercase string representation\n\t\tconst seen = new Set<string>();\n\t\tconst deduped: unknown[] = [];\n\t\tfor (const v of values) {\n\t\t\tconst key = String(v).toLowerCase();\n\t\t\tif (!seen.has(key)) {\n\t\t\t\tseen.add(key);\n\t\t\t\tdeduped.push(v);\n\t\t\t}\n\t\t}\n\n\t\t// If still too many, truncate and annotate\n\t\tif (deduped.length > maxValues) {\n\t\t\tconst truncated = deduped.slice(0, maxValues);\n\t\t\tconst description = node.description\n\t\t\t\t? `${node.description} (${deduped.length - maxValues} more values omitted)`\n\t\t\t\t: `${deduped.length - maxValues} additional values omitted`;\n\t\t\treturn { ...node, enum: truncated, description };\n\t\t}\n\n\t\treturn { ...node, enum: deduped };\n\t});\n}\n\n// ── Nested object flattening ──\n\n/**\n * Flattens objects nested beyond maxDepth by lifting nested properties\n * to the parent level with dot-separated keys.\n */\nfunction flattenNesting(\n\tschema: Record<string, unknown>,\n\tmaxDepth: number,\n): Record<string, unknown> {\n\treturn walkSchema(schema, (node) => {\n\t\tif (node.type !== 'object' || !node.properties) return node;\n\n\t\tconst flatProps: Record<string, unknown> = {};\n\t\tconst flatRequired: string[] = [];\n\t\tconst origRequired = new Set(\n\t\t\tArray.isArray(node.required) ? (node.required as string[]) : [],\n\t\t);\n\n\t\tflattenProperties(\n\t\t\tnode.properties as Record<string, Record<string, unknown>>,\n\t\t\torigRequired,\n\t\t\t'',\n\t\t\t0,\n\t\t\tmaxDepth,\n\t\t\tflatProps,\n\t\t\tflatRequired,\n\t\t);\n\n\t\t// Only return the flattened version if we actually changed something\n\t\tconst origKeys = Object.keys(node.properties as object);\n\t\tconst flatKeys = Object.keys(flatProps);\n\t\tif (\n\t\t\tflatKeys.length === origKeys.length &&\n\t\t\tflatKeys.every((k) => origKeys.includes(k))\n\t\t) {\n\t\t\treturn node;\n\t\t}\n\n\t\tconst result: Record<string, unknown> = { ...node, properties: flatProps };\n\t\tif (flatRequired.length > 0) {\n\t\t\tresult.required = flatRequired;\n\t\t} else {\n\t\t\tdelete result.required;\n\t\t}\n\t\treturn result;\n\t});\n}\n\nfunction flattenProperties(\n\tproperties: Record<string, Record<string, unknown>>,\n\trequired: Set<string>,\n\tprefix: string,\n\tcurrentDepth: number,\n\tmaxDepth: number,\n\tout: Record<string, unknown>,\n\toutRequired: string[],\n): void {\n\tfor (const [key, schema] of Object.entries(properties)) {\n\t\tconst fullKey = prefix ? `${prefix}.${key}` : key;\n\t\tconst isRequired = required.has(key);\n\n\t\tif (\n\t\t\tschema.type === 'object' &&\n\t\t\tschema.properties &&\n\t\t\tcurrentDepth >= maxDepth\n\t\t) {\n\t\t\t// Flatten: lift child properties up\n\t\t\tconst childRequired = new Set(\n\t\t\t\tArray.isArray(schema.required) ? (schema.required as string[]) : [],\n\t\t\t);\n\t\t\tflattenProperties(\n\t\t\t\tschema.properties as Record<string, Record<string, unknown>>,\n\t\t\t\tchildRequired,\n\t\t\t\tfullKey,\n\t\t\t\tcurrentDepth + 1,\n\t\t\t\tmaxDepth,\n\t\t\t\tout,\n\t\t\t\toutRequired,\n\t\t\t);\n\t\t} else {\n\t\t\tout[fullKey] = schema;\n\t\t\tif (isRequired) {\n\t\t\t\toutRequired.push(fullKey);\n\t\t\t}\n\t\t}\n\t}\n}\n\n// ── Provider-specific tweaks ──\n\n/**\n * Apply provider-specific schema modifications:\n * - Gemini: requires description on all properties\n * - OpenAI: prefers simpler schemas, removes redundant constraints\n */\nfunction applyProviderTweaks(\n\tschema: Record<string, unknown>,\n\tprovider: ModelProvider,\n): Record<string, unknown> {\n\tswitch (provider) {\n\t\tcase 'google':\n\t\t\treturn applyGeminiTweaks(schema);\n\t\tcase 'openai':\n\t\t\treturn applyOpenAITweaks(schema);\n\t\tdefault:\n\t\t\treturn schema;\n\t}\n}\n\n/**\n * Gemini requires description fields on all object properties.\n * Without descriptions, Gemini may produce empty or incorrect output.\n */\nfunction applyGeminiTweaks(schema: Record<string, unknown>): Record<string, unknown> {\n\treturn walkSchema(schema, (node) => {\n\t\tif (node.type !== 'object' || !node.properties) return node;\n\n\t\tconst props = node.properties as Record<string, Record<string, unknown>>;\n\t\tconst patched: Record<string, Record<string, unknown>> = {};\n\n\t\tfor (const [key, propSchema] of Object.entries(props)) {\n\t\t\tif (!propSchema.description) {\n\t\t\t\tpatched[key] = {\n\t\t\t\t\t...propSchema,\n\t\t\t\t\tdescription: humanizePropertyName(key),\n\t\t\t\t};\n\t\t\t} else {\n\t\t\t\tpatched[key] = propSchema;\n\t\t\t}\n\t\t}\n\n\t\treturn { ...node, properties: patched };\n\t});\n}\n\n/**\n * OpenAI models work better with simpler schemas:\n * - Remove additionalProperties: false (it's the default for structured output)\n * - Ensure all required fields are listed\n */\nfunction applyOpenAITweaks(schema: Record<string, unknown>): Record<string, unknown> {\n\treturn walkSchema(schema, (node) => {\n\t\tif (node.type !== 'object') return node;\n\n\t\tconst cleaned = { ...node };\n\n\t\t// OpenAI structured output doesn't need additionalProperties: false\n\t\tif (cleaned.additionalProperties === false) {\n\t\t\tdelete cleaned.additionalProperties;\n\t\t}\n\n\t\t// Ensure all properties are marked required (OpenAI prefers explicit required lists)\n\t\tif (cleaned.properties && !cleaned.required) {\n\t\t\tcleaned.required = Object.keys(cleaned.properties as object);\n\t\t}\n\n\t\treturn cleaned;\n\t});\n}\n\n// ── Schema walking utility ──\n\ntype SchemaVisitor = (node: Record<string, unknown>) => Record<string, unknown>;\n\n/**\n * Recursively walks a JSON Schema tree, applying a visitor function\n * to each schema node (depth-first, post-order).\n */\nfunction walkSchema(\n\tschema: Record<string, unknown>,\n\tvisitor: SchemaVisitor,\n): Record<string, unknown> {\n\tlet node = { ...schema };\n\n\t// Walk into properties\n\tif (node.properties && typeof node.properties === 'object') {\n\t\tconst props: Record<string, unknown> = {};\n\t\tfor (const [key, val] of Object.entries(node.properties as Record<string, unknown>)) {\n\t\t\tif (val && typeof val === 'object' && !Array.isArray(val)) {\n\t\t\t\tprops[key] = walkSchema(val as Record<string, unknown>, visitor);\n\t\t\t} else {\n\t\t\t\tprops[key] = val;\n\t\t\t}\n\t\t}\n\t\tnode.properties = props;\n\t}\n\n\t// Walk into array items\n\tif (node.items && typeof node.items === 'object' && !Array.isArray(node.items)) {\n\t\tnode.items = walkSchema(node.items as Record<string, unknown>, visitor);\n\t}\n\n\t// Walk into oneOf / anyOf / allOf\n\tfor (const combiner of ['oneOf', 'anyOf', 'allOf'] as const) {\n\t\tif (Array.isArray(node[combiner])) {\n\t\t\tnode[combiner] = (node[combiner] as Record<string, unknown>[]).map((s) =>\n\t\t\t\ttypeof s === 'object' && s !== null ? walkSchema(s, visitor) : s,\n\t\t\t);\n\t\t}\n\t}\n\n\t// Walk into additionalProperties\n\tif (\n\t\tnode.additionalProperties &&\n\t\ttypeof node.additionalProperties === 'object'\n\t) {\n\t\tnode.additionalProperties = walkSchema(\n\t\t\tnode.additionalProperties as Record<string, unknown>,\n\t\t\tvisitor,\n\t\t);\n\t}\n\n\treturn visitor(node);\n}\n\n// ── Helpers ──\n\n/**\n * Converts a camelCase or snake_case property name to a human-readable description.\n * Used for Gemini which requires descriptions on all properties.\n */\nfunction humanizePropertyName(name: string): string {\n\t// Split on camelCase boundaries and underscores\n\tconst words = name\n\t\t.replace(/([a-z])([A-Z])/g, '$1 $2')\n\t\t.replace(/[_-]/g, ' ')\n\t\t.toLowerCase()\n\t\t.split(/\\s+/);\n\n\tif (words.length === 0) return name;\n\n\t// Capitalize first word\n\twords[0] = words[0].charAt(0).toUpperCase() + words[0].slice(1);\n\treturn words.join(' ');\n}\n\n// ── zodToJsonSchema (existing, unchanged) ──\n\n/**\n * Converts a Zod schema to a JSON Schema representation suitable for LLM tool use.\n */\nexport function zodToJsonSchema(schema: ZodTypeAny): Record<string, unknown> {\n\tconst jsonSchema: Record<string, unknown> = {};\n\n\tif (schema instanceof z.ZodObject) {\n\t\tjsonSchema.type = 'object';\n\t\tconst shape = schema.shape;\n\t\tconst properties: Record<string, unknown> = {};\n\t\tconst required: string[] = [];\n\n\t\tfor (const [key, value] of Object.entries(shape)) {\n\t\t\tproperties[key] = zodToJsonSchema(value as ZodTypeAny);\n\t\t\tif (!(value instanceof z.ZodOptional)) {\n\t\t\t\trequired.push(key);\n\t\t\t}\n\t\t}\n\n\t\tjsonSchema.properties = properties;\n\t\tif (required.length > 0) {\n\t\t\tjsonSchema.required = required;\n\t\t}\n\t} else if (schema instanceof z.ZodString) {\n\t\tjsonSchema.type = 'string';\n\t} else if (schema instanceof z.ZodNumber) {\n\t\tjsonSchema.type = 'number';\n\t} else if (schema instanceof z.ZodBoolean) {\n\t\tjsonSchema.type = 'boolean';\n\t} else if (schema instanceof z.ZodArray) {\n\t\tjsonSchema.type = 'array';\n\t\tjsonSchema.items = zodToJsonSchema(schema.element);\n\t} else if (schema instanceof z.ZodOptional) {\n\t\treturn zodToJsonSchema(schema.unwrap()) as any;\n\t} else if (schema instanceof z.ZodDefault) {\n\t\tconst inner = zodToJsonSchema(schema.removeDefault()) as any;\n\t\tinner.default = schema._def.defaultValue();\n\t\treturn inner as any;\n\t} else if (schema instanceof z.ZodEnum) {\n\t\tjsonSchema.type = 'string';\n\t\tjsonSchema.enum = schema.options;\n\t} else if (schema instanceof z.ZodLiteral) {\n\t\tjsonSchema.const = schema.value;\n\t} else if (schema instanceof z.ZodUnion) {\n\t\tjsonSchema.oneOf = (schema.options as ZodTypeAny[]).map(zodToJsonSchema);\n\t} else if (schema instanceof z.ZodDiscriminatedUnion) {\n\t\tjsonSchema.oneOf = [...schema.options.values()].map((opt: ZodTypeAny) =>\n\t\t\tzodToJsonSchema(opt),\n\t\t);\n\t} else if (schema instanceof z.ZodNullable) {\n\t\tconst inner = zodToJsonSchema(schema.unwrap());\n\t\treturn { oneOf: [inner, { type: 'null' }] } as any;\n\t} else if (schema instanceof z.ZodRecord) {\n\t\tjsonSchema.type = 'object';\n\t\tjsonSchema.additionalProperties = zodToJsonSchema(schema.element);\n\t} else {\n\t\tjsonSchema.type = 'object';\n\t}\n\n\tif (schema.description) {\n\t\tjsonSchema.description = schema.description;\n\t}\n\n\treturn jsonSchema as any;\n}\n"
  },
  {
    "path": "packages/core/src/model/types.ts",
    "content": "import { z } from 'zod';\n\nexport interface InferenceUsage {\n\tinputTokens: number;\n\toutputTokens: number;\n\ttotalTokens: number;\n}\n\nexport interface InferenceResult<T = unknown> {\n\tparsed: T;\n\trawText?: string;\n\tusage: InferenceUsage;\n\tfinishReason: 'stop' | 'length' | 'content-filter' | 'tool-calls' | 'error' | 'other';\n}\n\nexport const InferenceUsageSchema = z.object({\n\tinputTokens: z.number(),\n\toutputTokens: z.number(),\n\ttotalTokens: z.number(),\n});\n"
  },
  {
    "path": "packages/core/src/page/content-extractor.ts",
    "content": "import TurndownService from 'turndown';\nimport type { Page } from 'playwright';\n\nlet turndownInstance: TurndownService | null = null;\n\nfunction getTurndown(): TurndownService {\n\tif (!turndownInstance) {\n\t\tturndownInstance = new TurndownService({\n\t\t\theadingStyle: 'atx',\n\t\t\tcodeBlockStyle: 'fenced',\n\t\t\temDelimiter: '*',\n\t\t});\n\n\t\t// Remove scripts, styles, and other non-content elements\n\t\tturndownInstance.remove(['script', 'style', 'nav', 'footer', 'header', 'noscript']);\n\n\t\t// Preserve tables as markdown tables\n\t\tturndownInstance.addRule('table', {\n\t\t\tfilter: 'table',\n\t\t\treplacement: (_content, node) => {\n\t\t\t\tconst table = node as HTMLTableElement;\n\t\t\t\treturn htmlTableToMarkdown(table);\n\t\t\t},\n\t\t});\n\n\t\t// Preserve code blocks with enhanced language detection from class attributes.\n\t\t// Supports patterns: language-xxx, lang-xxx, highlight-xxx, brush:xxx, and bare lang names.\n\t\tturndownInstance.addRule('codeBlock', {\n\t\t\tfilter: (node) => {\n\t\t\t\treturn (\n\t\t\t\t\tnode.nodeName === 'PRE' &&\n\t\t\t\t\tnode.firstChild !== null &&\n\t\t\t\t\tnode.firstChild.nodeName === 'CODE'\n\t\t\t\t);\n\t\t\t},\n\t\t\treplacement: (_content, node) => {\n\t\t\t\tconst codeEl = node.firstChild as HTMLElement;\n\t\t\t\tconst lang = detectCodeLanguage(codeEl);\n\t\t\t\tconst code = codeEl?.textContent ?? '';\n\t\t\t\treturn `\\n\\`\\`\\`${lang}\\n${code}\\n\\`\\`\\`\\n`;\n\t\t\t},\n\t\t});\n\t}\n\treturn turndownInstance;\n}\n\nfunction htmlTableToMarkdown(table: HTMLTableElement): string {\n\tconst rows: string[][] = [];\n\tconst tableRows = table.querySelectorAll('tr');\n\n\tfor (const row of tableRows) {\n\t\tconst cells: string[] = [];\n\t\tfor (const cell of row.querySelectorAll('th, td')) {\n\t\t\tcells.push((cell.textContent ?? '').trim().replace(/\\|/g, '\\\\|'));\n\t\t}\n\t\tif (cells.length > 0) {\n\t\t\trows.push(cells);\n\t\t}\n\t}\n\n\tif (rows.length === 0) return '';\n\n\tconst maxCols = Math.max(...rows.map((r) => r.length));\n\n\t// Pad rows to same column count\n\tfor (const row of rows) {\n\t\twhile (row.length < maxCols) {\n\t\t\trow.push('');\n\t\t}\n\t}\n\n\tconst lines: string[] = [];\n\t// Header\n\tlines.push(`| ${rows[0].join(' | ')} |`);\n\tlines.push(`| ${rows[0].map(() => '---').join(' | ')} |`);\n\n\t// Body\n\tfor (let i = 1; i < rows.length; i++) {\n\t\tlines.push(`| ${rows[i].join(' | ')} |`);\n\t}\n\n\treturn '\\n' + lines.join('\\n') + '\\n';\n}\n\n/**\n * Known programming language names used as a fallback for bare class name matching.\n */\nconst KNOWN_LANGUAGES = new Set([\n\t'javascript', 'typescript', 'python', 'ruby', 'java', 'go', 'rust', 'c',\n\t'cpp', 'csharp', 'swift', 'kotlin', 'scala', 'php', 'perl', 'lua',\n\t'bash', 'shell', 'sh', 'zsh', 'powershell', 'sql', 'html', 'css',\n\t'scss', 'less', 'json', 'yaml', 'yml', 'xml', 'toml', 'ini',\n\t'markdown', 'md', 'jsx', 'tsx', 'graphql', 'r', 'matlab', 'dart',\n\t'elixir', 'erlang', 'haskell', 'ocaml', 'clojure', 'vim', 'dockerfile',\n\t'makefile', 'cmake', 'protobuf', 'terraform', 'hcl',\n]);\n\n/**\n * Detect the programming language from a code element's class attribute.\n * Tries multiple patterns commonly used by syntax highlighters:\n * - language-xxx (Prism, highlight.js)\n * - lang-xxx (some highlighters)\n * - highlight-xxx / hljs xxx\n * - brush: xxx (SyntaxHighlighter)\n * - data-lang attribute\n * - bare class name matching a known language\n */\nfunction detectCodeLanguage(codeEl: HTMLElement | null): string {\n\tif (!codeEl) return '';\n\n\t// Check data-lang attribute first (used by some markdown renderers)\n\tconst dataLang = codeEl.getAttribute?.('data-lang') ?? '';\n\tif (dataLang) return dataLang.toLowerCase();\n\n\tconst className = codeEl.getAttribute?.('class') ?? '';\n\tif (!className) return '';\n\n\t// Pattern: language-xxx or lang-xxx\n\tconst langPrefixMatch = className.match(/(?:language|lang)-(\\w+)/);\n\tif (langPrefixMatch) return langPrefixMatch[1].toLowerCase();\n\n\t// Pattern: highlight-xxx\n\tconst highlightMatch = className.match(/highlight-(\\w+)/);\n\tif (highlightMatch) return highlightMatch[1].toLowerCase();\n\n\t// Pattern: brush: xxx (SyntaxHighlighter legacy)\n\tconst brushMatch = className.match(/brush:\\s*(\\w+)/);\n\tif (brushMatch) return brushMatch[1].toLowerCase();\n\n\t// Fallback: check if any class token is a known language name\n\tconst tokens = className.split(/\\s+/);\n\tfor (const token of tokens) {\n\t\tconst lower = token.toLowerCase();\n\t\tif (KNOWN_LANGUAGES.has(lower)) return lower;\n\t}\n\n\treturn '';\n}\n\n/**\n * Tracks reading position across multiple extractMarkdown calls,\n * allowing incremental content consumption without re-reading.\n */\nexport class ReadingState {\n\tprivate charOffset = 0;\n\tprivate totalLength = 0;\n\tprivate pageUrl = '';\n\n\t/**\n\t * Get the current character offset for the next read.\n\t */\n\tget currentOffset(): number {\n\t\treturn this.charOffset;\n\t}\n\n\t/**\n\t * Get the total length of the last-known content.\n\t */\n\tget contentLength(): number {\n\t\treturn this.totalLength;\n\t}\n\n\t/**\n\t * Whether there is more content to read.\n\t */\n\tget hasMore(): boolean {\n\t\treturn this.charOffset < this.totalLength;\n\t}\n\n\t/**\n\t * Fraction of content consumed so far (0..1).\n\t */\n\tget progress(): number {\n\t\tif (this.totalLength === 0) return 0;\n\t\treturn Math.min(1, this.charOffset / this.totalLength);\n\t}\n\n\t/**\n\t * Advance the reading position by the given number of characters.\n\t */\n\tadvance(chars: number): void {\n\t\tthis.charOffset = Math.min(this.charOffset + chars, this.totalLength);\n\t}\n\n\t/**\n\t * Update state with fresh content metadata. If the URL changes,\n\t * the offset resets to the beginning.\n\t */\n\tupdate(url: string, totalLength: number): void {\n\t\tif (url !== this.pageUrl) {\n\t\t\tthis.charOffset = 0;\n\t\t\tthis.pageUrl = url;\n\t\t}\n\t\tthis.totalLength = totalLength;\n\t}\n\n\t/**\n\t * Reset the reading state to the beginning.\n\t */\n\treset(): void {\n\t\tthis.charOffset = 0;\n\t\tthis.totalLength = 0;\n\t\tthis.pageUrl = '';\n\t}\n}\n\nexport interface MarkdownExtractionOptions {\n\tstartFromChar?: number;\n\tmaxLength?: number;\n\textractLinks?: boolean;\n\treadingState?: ReadingState;\n}\n\nexport async function extractMarkdown(\n\tpage: Page,\n\toptions?: MarkdownExtractionOptions,\n): Promise<string> {\n\tconst html = await page.evaluate(() => {\n\t\t// Try to get main content first\n\t\tconst main = document.querySelector('main, article, [role=\"main\"], .content, #content');\n\t\tif (main) return main.innerHTML;\n\n\t\t// Fallback to body\n\t\treturn document.body?.innerHTML ?? '';\n\t});\n\n\tlet markdown = htmlToMarkdown(html);\n\tconst fullLength = markdown.length;\n\n\t// Update reading state if provided\n\tconst readingState = options?.readingState;\n\tif (readingState) {\n\t\tconst url = page.url();\n\t\treadingState.update(url, fullLength);\n\t}\n\n\t// Determine the starting offset: explicit option takes priority,\n\t// then reading state's tracked position, then 0.\n\tconst startOffset = options?.startFromChar ??\n\t\t(readingState ? readingState.currentOffset : 0);\n\n\tif (startOffset > 0) {\n\t\tmarkdown = markdown.slice(startOffset);\n\t}\n\n\t// Apply max length\n\tlet truncated = false;\n\tif (options?.maxLength && markdown.length > options.maxLength) {\n\t\tmarkdown = markdown.slice(0, options.maxLength);\n\t\t// Try to break at a paragraph boundary\n\t\tconst lastParagraph = markdown.lastIndexOf('\\n\\n');\n\t\tif (lastParagraph > markdown.length * 0.8) {\n\t\t\tmarkdown = markdown.slice(0, lastParagraph);\n\t\t}\n\t\ttruncated = true;\n\t}\n\n\t// Advance reading state by the number of characters consumed\n\tif (readingState) {\n\t\treadingState.advance(markdown.length);\n\t}\n\n\tif (truncated) {\n\t\tconst remaining = fullLength - startOffset - markdown.length;\n\t\tmarkdown += `\\n\\n[... content truncated, ~${remaining} chars remaining]`;\n\t}\n\n\t// Append links section if requested\n\tif (options?.extractLinks) {\n\t\tconst links = await extractLinks(page);\n\t\tif (links.length > 0) {\n\t\t\tmarkdown += '\\n\\n## Links\\n';\n\t\t\tfor (const link of links) {\n\t\t\t\tconst marker = link.isExternal ? ' (external)' : '';\n\t\t\t\tmarkdown += `- [${link.text}](${link.url})${marker}\\n`;\n\t\t\t}\n\t\t}\n\t}\n\n\treturn markdown;\n}\n\nexport function htmlToMarkdown(html: string): string {\n\tconst turndown = getTurndown();\n\tconst markdown = turndown.turndown(html);\n\n\t// Clean up excessive whitespace\n\treturn markdown\n\t\t.replace(/\\n{3,}/g, '\\n\\n')\n\t\t.replace(/^\\s+|\\s+$/gm, (match) => match.replace(/ +/g, ''))\n\t\t.trim();\n}\n\n/**\n * Extract all links from a page as a structured list.\n */\nexport async function extractLinks(\n\tpage: Page,\n): Promise<Array<{ text: string; url: string; isExternal: boolean }>> {\n\treturn page.evaluate(() => {\n\t\tconst links: Array<{ text: string; url: string; isExternal: boolean }> = [];\n\t\tconst currentHost = window.location.hostname;\n\n\t\tfor (const anchor of document.querySelectorAll('a[href]')) {\n\t\t\tconst href = anchor.getAttribute('href');\n\t\t\tif (!href || href.startsWith('#') || href.startsWith('javascript:')) continue;\n\n\t\t\tlet url: string;\n\t\t\ttry {\n\t\t\t\turl = new URL(href, window.location.href).href;\n\t\t\t} catch {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst text = (anchor.textContent ?? '').trim().slice(0, 200);\n\t\t\tif (!text) continue;\n\n\t\t\tlet isExternal = false;\n\t\t\ttry {\n\t\t\t\tisExternal = new URL(url).hostname !== currentHost;\n\t\t\t} catch {\n\t\t\t\t// ignore\n\t\t\t}\n\n\t\t\tlinks.push({ text, url, isExternal });\n\t\t}\n\n\t\treturn links;\n\t});\n}\n\nexport async function extractTextContent(page: Page): Promise<string> {\n\treturn page.evaluate(() => {\n\t\tconst main = document.querySelector('main, article, [role=\"main\"], .content, #content');\n\t\tconst element = (main ?? document.body) as HTMLElement | null;\n\t\treturn element?.innerText ?? '';\n\t});\n}\n\nexport function chunkText(text: string, maxChunkSize: number): string[] {\n\tif (text.length <= maxChunkSize) return [text];\n\n\tconst chunks: string[] = [];\n\tconst paragraphs = text.split(/\\n\\n+/);\n\tlet currentChunk = '';\n\n\tfor (const para of paragraphs) {\n\t\tif (currentChunk.length + para.length + 2 > maxChunkSize) {\n\t\t\tif (currentChunk) {\n\t\t\t\tchunks.push(currentChunk.trim());\n\t\t\t\tcurrentChunk = '';\n\t\t\t}\n\n\t\t\t// If a single paragraph is too long, split by sentences\n\t\t\tif (para.length > maxChunkSize) {\n\t\t\t\tconst sentences = para.split(/(?<=[.!?])\\s+/);\n\t\t\t\tfor (const sentence of sentences) {\n\t\t\t\t\tif (currentChunk.length + sentence.length + 1 > maxChunkSize) {\n\t\t\t\t\t\tif (currentChunk) chunks.push(currentChunk.trim());\n\t\t\t\t\t\tcurrentChunk = sentence;\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcurrentChunk += (currentChunk ? ' ' : '') + sentence;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tcurrentChunk = para;\n\t\t\t}\n\t\t} else {\n\t\t\tcurrentChunk += (currentChunk ? '\\n\\n' : '') + para;\n\t\t}\n\t}\n\n\tif (currentChunk) {\n\t\tchunks.push(currentChunk.trim());\n\t}\n\n\treturn chunks;\n}\n"
  },
  {
    "path": "packages/core/src/page/index.ts",
    "content": "export { PageAnalyzer, type PageAnalyzerOptions } from './page-analyzer.js';\nexport { SnapshotBuilder } from './snapshot-builder.js';\nexport { TreeRenderer, type RendererOptions } from './renderer/tree-renderer.js';\nexport {\n\textractMarkdown,\n\thtmlToMarkdown,\n\textractTextContent,\n\textractLinks,\n\tchunkText,\n\ttype MarkdownExtractionOptions,\n} from './content-extractor.js';\nexport {\n\ttype PageTreeNode,\n\ttype SelectorIndex,\n\ttype RenderedPageState,\n\ttype DOMRect,\n\ttype CDPSnapshotResult,\n\ttype AXNode,\n\ttype TargetInfo,\n\ttype TargetAllTrees,\n\ttype InteractedElement,\n\ttype MatchLevel,\n\ttype SimplifiedNode,\n} from './types.js';\n"
  },
  {
    "path": "packages/core/src/page/page-analyzer.test.ts",
    "content": "import { test, expect, describe, beforeEach, mock } from 'bun:test';\nimport { PageAnalyzer } from './page-analyzer.js';\nimport { PageExtractionError } from '../errors.js';\nimport type { PageTreeNode, SelectorIndex, RenderedPageState } from './types.js';\nimport type { ElementRef } from '../types.js';\n\n// ── Mock factories ──\n\nfunction makeMockPage(overrides: Record<string, unknown> = {}) {\n\treturn {\n\t\tviewportSize: () => ({ width: 1280, height: 800 }),\n\t\tevaluate: mock(() => Promise.resolve({ x: 0, y: 0 })),\n\t\tclick: mock(() => Promise.resolve()),\n\t\tfill: mock(() => Promise.resolve()),\n\t\tmouse: {\n\t\t\tclick: mock(() => Promise.resolve()),\n\t\t},\n\t\tkeyboard: {\n\t\t\ttype: mock(() => Promise.resolve()),\n\t\t},\n\t\tframes: () => [],\n\t\t...overrides,\n\t} as any;\n}\n\nfunction makeMockCdpSession(overrides: Record<string, unknown> = {}) {\n\treturn {\n\t\tsend: mock(() => Promise.resolve({})),\n\t\t...overrides,\n\t} as any;\n}\n\nfunction makeNode(overrides: Partial<PageTreeNode> = {}): PageTreeNode {\n\treturn {\n\t\ttagName: 'div',\n\t\tnodeType: 'element',\n\t\tattributes: {},\n\t\tchildren: [],\n\t\tisVisible: true,\n\t\tisInteractive: false,\n\t\tisClickable: false,\n\t\tisEditable: false,\n\t\tisScrollable: false,\n\t\t...overrides,\n\t};\n}\n\n// ── Tests ──\n\ndescribe('PageAnalyzer', () => {\n\tlet service: PageAnalyzer;\n\n\tbeforeEach(() => {\n\t\tservice = new PageAnalyzer();\n\t});\n\n\tdescribe('constructor defaults', () => {\n\t\ttest('has default viewport expansion of 0', () => {\n\t\t\t// The service is created with defaults, including viewportExpansion = 0\n\t\t\texpect(service).toBeDefined();\n\t\t});\n\n\t\ttest('accepts custom options', () => {\n\t\t\tconst custom = new PageAnalyzer({\n\t\t\t\tviewportExpansion: 500,\n\t\t\t\tmaxElementsInDom: 100,\n\t\t\t\tmaxIframes: 1,\n\t\t\t\tcapturedAttributes: ['title'],\n\t\t\t});\n\t\t\texpect(custom).toBeDefined();\n\t\t});\n\t});\n\n\tdescribe('cache management', () => {\n\t\ttest('getCachedTree returns null initially', () => {\n\t\t\texpect(service.getCachedTree()).toBeNull();\n\t\t});\n\n\t\ttest('getCachedSelectorMap returns null initially', () => {\n\t\t\texpect(service.getCachedSelectorMap()).toBeNull();\n\t\t});\n\n\t\ttest('clearCache resets tree and selector map', () => {\n\t\t\t// We can't set cachedTree directly, but clearCache should work on empty state\n\t\t\tservice.clearCache();\n\t\t\texpect(service.getCachedTree()).toBeNull();\n\t\t\texpect(service.getCachedSelectorMap()).toBeNull();\n\t\t});\n\t});\n\n\tdescribe('interaction recording', () => {\n\t\ttest('getInteractedElements returns empty array initially', () => {\n\t\t\texpect(service.getInteractedElements()).toEqual([]);\n\t\t});\n\n\t\ttest('clearInteractedElements resets the list', () => {\n\t\t\tservice.clearInteractedElements();\n\t\t\texpect(service.getInteractedElements()).toEqual([]);\n\t\t});\n\n\t\ttest('getInteractedElements returns a copy', () => {\n\t\t\tconst elements = service.getInteractedElements();\n\t\t\texpect(elements).not.toBe(service.getInteractedElements());\n\t\t});\n\t});\n\n\tdescribe('clickElementByIndex', () => {\n\t\ttest('throws PageExtractionError when element not in selector map', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tconst cdp = makeMockCdpSession();\n\n\t\t\tawait expect(\n\t\t\t\tservice.clickElementByIndex(page, cdp, 42),\n\t\t\t).rejects.toThrow(PageExtractionError);\n\t\t});\n\n\t\ttest('Strategy 1: uses CDP box model when backendNodeId is available', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() =>\n\t\t\t\t\tPromise.resolve({\n\t\t\t\t\t\tmodel: {\n\t\t\t\t\t\t\tcontent: [10, 10, 110, 10, 110, 60, 10, 60],\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t});\n\n\t\t\t// Inject a selector map with a backendNodeId\n\t\t\tconst selectorMap: SelectorIndex = {\n\t\t\t\t0: {\n\t\t\t\t\tcssSelector: '#btn',\n\t\t\t\t\tbackendNodeId: 123,\n\t\t\t\t\ttagName: 'button',\n\t\t\t\t},\n\t\t\t};\n\t\t\t// Use the private cachedSelectorMap via prototype access\n\t\t\t(service as any).cachedSelectorMap = selectorMap;\n\n\t\t\tawait service.clickElementByIndex(page, cdp, 0);\n\n\t\t\t// Should have used mouse.click with center coordinates\n\t\t\texpect(page.mouse.click).toHaveBeenCalledTimes(1);\n\t\t\t// Center of quad: ((10+110+110+10)/4, (10+10+60+60)/4) = (60, 35)\n\t\t\texpect(page.mouse.click).toHaveBeenCalledWith(60, 35);\n\n\t\t\t// Should have recorded the interaction\n\t\t\tconst interactions = service.getInteractedElements();\n\t\t\texpect(interactions).toHaveLength(1);\n\t\t\texpect(interactions[0].action).toBe('click');\n\t\t\texpect(interactions[0].tagName).toBe('button');\n\t\t});\n\n\t\ttest('Strategy 2: falls back to JS getBoundingClientRect when CDP fails', async () => {\n\t\t\tconst evaluateMock = mock(() =>\n\t\t\t\tPromise.resolve({ x: 50, y: 25 }),\n\t\t\t);\n\t\t\tconst page = makeMockPage({ evaluate: evaluateMock });\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() => Promise.reject(new Error('CDP failed'))),\n\t\t\t});\n\n\t\t\tconst selectorMap: SelectorIndex = {\n\t\t\t\t0: {\n\t\t\t\t\tcssSelector: '#btn',\n\t\t\t\t\tbackendNodeId: 123,\n\t\t\t\t\ttagName: 'button',\n\t\t\t\t},\n\t\t\t};\n\t\t\t(service as any).cachedSelectorMap = selectorMap;\n\n\t\t\tawait service.clickElementByIndex(page, cdp, 0);\n\n\t\t\t// Should have called page.evaluate (JS fallback)\n\t\t\texpect(evaluateMock).toHaveBeenCalled();\n\t\t\t// Then mouse.click with the returned coords\n\t\t\texpect(page.mouse.click).toHaveBeenCalledWith(50, 25);\n\t\t});\n\n\t\ttest('Strategy 3: falls back to CSS selector click when JS rect returns null', async () => {\n\t\t\tconst evaluateMock = mock(() => Promise.resolve(null));\n\t\t\tconst page = makeMockPage({ evaluate: evaluateMock });\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() => Promise.reject(new Error('CDP failed'))),\n\t\t\t});\n\n\t\t\tconst selectorMap: SelectorIndex = {\n\t\t\t\t0: {\n\t\t\t\t\tcssSelector: '.my-btn',\n\t\t\t\t\tbackendNodeId: 123,\n\t\t\t\t\ttagName: 'button',\n\t\t\t\t},\n\t\t\t};\n\t\t\t(service as any).cachedSelectorMap = selectorMap;\n\n\t\t\tawait service.clickElementByIndex(page, cdp, 0);\n\n\t\t\t// Should have fallen through to page.click(cssSelector)\n\t\t\texpect(page.click).toHaveBeenCalledWith('.my-btn', { timeout: 5000 });\n\t\t});\n\n\t\ttest('uses CSS selector click when no backendNodeId', async () => {\n\t\t\tconst evaluateMock = mock(() => Promise.resolve(null));\n\t\t\tconst page = makeMockPage({ evaluate: evaluateMock });\n\t\t\tconst cdp = makeMockCdpSession();\n\n\t\t\tconst selectorMap: SelectorIndex = {\n\t\t\t\t0: {\n\t\t\t\t\tcssSelector: '#submit',\n\t\t\t\t\ttagName: 'button',\n\t\t\t\t\t// No backendNodeId\n\t\t\t\t},\n\t\t\t};\n\t\t\t(service as any).cachedSelectorMap = selectorMap;\n\n\t\t\tawait service.clickElementByIndex(page, cdp, 0);\n\n\t\t\texpect(page.click).toHaveBeenCalledWith('#submit', { timeout: 5000 });\n\t\t});\n\t});\n\n\tdescribe('clickAtCoordinates', () => {\n\t\ttest('clicks at the specified coordinates', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tawait service.clickAtCoordinates(page, 100, 200);\n\t\t\texpect(page.mouse.click).toHaveBeenCalledWith(100, 200);\n\t\t});\n\t});\n\n\tdescribe('inputTextByIndex', () => {\n\t\ttest('throws when element not in selector map', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tconst cdp = makeMockCdpSession();\n\n\t\t\tawait expect(\n\t\t\t\tservice.inputTextByIndex(page, cdp, 99, 'hello'),\n\t\t\t).rejects.toThrow(PageExtractionError);\n\t\t});\n\n\t\ttest('fills input with text when clearFirst is true (default)', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tconst cdp = makeMockCdpSession();\n\n\t\t\t(service as any).cachedSelectorMap = {\n\t\t\t\t0: { cssSelector: '#name', tagName: 'input' },\n\t\t\t};\n\n\t\t\tawait service.inputTextByIndex(page, cdp, 0, 'Alice');\n\n\t\t\texpect(page.fill).toHaveBeenCalledWith('#name', 'Alice');\n\t\t\texpect(service.getInteractedElements()).toHaveLength(1);\n\t\t\texpect(service.getInteractedElements()[0].action).toBe('input');\n\t\t});\n\n\t\ttest('types text without clearing when clearFirst is false', async () => {\n\t\t\tconst page = makeMockPage();\n\t\t\tconst cdp = makeMockCdpSession();\n\n\t\t\t(service as any).cachedSelectorMap = {\n\t\t\t\t0: { cssSelector: '#name', tagName: 'input' },\n\t\t\t};\n\n\t\t\tawait service.inputTextByIndex(page, cdp, 0, 'Bob', false);\n\n\t\t\texpect(page.click).toHaveBeenCalledWith('#name');\n\t\t\texpect(page.keyboard.type).toHaveBeenCalledWith('Bob');\n\t\t});\n\t});\n\n\tdescribe('getElementSelector', () => {\n\t\ttest('returns undefined when no selector map cached', async () => {\n\t\t\tconst result = await service.getElementSelector(0);\n\t\t\texpect(result).toBeUndefined();\n\t\t});\n\n\t\ttest('returns CSS selector when element is in the map', async () => {\n\t\t\t(service as any).cachedSelectorMap = {\n\t\t\t\t5: { cssSelector: '.item-5', tagName: 'div' },\n\t\t\t};\n\n\t\t\tconst result = await service.getElementSelector(5);\n\t\t\texpect(result).toBe('.item-5');\n\t\t});\n\t});\n\n\tdescribe('getElementByBackendNodeId', () => {\n\t\ttest('returns selector with ID when available', async () => {\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() =>\n\t\t\t\t\tPromise.resolve({\n\t\t\t\t\t\tnode: {\n\t\t\t\t\t\t\tnodeName: 'DIV',\n\t\t\t\t\t\t\tattributes: ['id', 'main-content', 'class', 'wrapper'],\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t});\n\n\t\t\tconst result = await service.getElementByBackendNodeId(cdp, 42);\n\t\t\texpect(result).toEqual({ selector: '#main-content' });\n\t\t});\n\n\t\ttest('returns tag name when no ID attribute', async () => {\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() =>\n\t\t\t\t\tPromise.resolve({\n\t\t\t\t\t\tnode: {\n\t\t\t\t\t\t\tnodeName: 'BUTTON',\n\t\t\t\t\t\t\tattributes: ['class', 'primary'],\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t),\n\t\t\t});\n\n\t\t\tconst result = await service.getElementByBackendNodeId(cdp, 42);\n\t\t\texpect(result).toEqual({ selector: 'button' });\n\t\t});\n\n\t\ttest('returns null when CDP call fails', async () => {\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() => Promise.reject(new Error('not found'))),\n\t\t\t});\n\n\t\t\tconst result = await service.getElementByBackendNodeId(cdp, 42);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\n\t\ttest('returns null when node has no result', async () => {\n\t\t\tconst cdp = makeMockCdpSession({\n\t\t\t\tsend: mock(() => Promise.resolve({ node: null })),\n\t\t\t});\n\n\t\t\tconst result = await service.getElementByBackendNodeId(cdp, 42);\n\t\t\texpect(result).toBeNull();\n\t\t});\n\t});\n\n\tdescribe('collectHiddenElementHints (via private access)', () => {\n\t\ttest('collects hints for elements below the viewport', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'button',\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\tisVisible: false,\n\t\t\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\t\t\tariaLabel: 'Submit form',\n\t\t\t\t\t\trect: { x: 0, y: 2000, width: 100, height: 30 },\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst viewport = { width: 1280, height: 800 };\n\t\t\tconst scroll = { x: 0, y: 0 };\n\n\t\t\tconst hints = (service as any).collectHiddenElementHints(root, viewport, scroll);\n\n\t\t\texpect(hints).toHaveLength(1);\n\t\t\texpect(hints[0]).toContain('Submit form');\n\t\t\texpect(hints[0]).toContain('pages below');\n\t\t});\n\n\t\ttest('collects hints for elements above the viewport', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'a',\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\tisVisible: false,\n\t\t\t\t\t\thighlightIndex: 1 as ElementRef,\n\t\t\t\t\t\ttext: 'Top link',\n\t\t\t\t\t\trect: { x: 0, y: 100, width: 80, height: 20 },\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst viewport = { width: 1280, height: 800 };\n\t\t\tconst scroll = { x: 0, y: 1600 }; // scrolled way down\n\n\t\t\tconst hints = (service as any).collectHiddenElementHints(root, viewport, scroll);\n\n\t\t\texpect(hints).toHaveLength(1);\n\t\t\texpect(hints[0]).toContain('Top link');\n\t\t\texpect(hints[0]).toContain('pages above');\n\t\t});\n\n\t\ttest('ignores visible or non-interactive elements', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'button',\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\tisVisible: true, // visible elements are not collected\n\t\t\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\t\t\trect: { x: 0, y: 2000, width: 100, height: 30 },\n\t\t\t\t\t}),\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'div',\n\t\t\t\t\t\tisInteractive: false, // non-interactive\n\t\t\t\t\t\tisVisible: false,\n\t\t\t\t\t\thighlightIndex: 1 as ElementRef,\n\t\t\t\t\t\trect: { x: 0, y: 2000, width: 100, height: 30 },\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst viewport = { width: 1280, height: 800 };\n\t\t\tconst scroll = { x: 0, y: 0 };\n\n\t\t\tconst hints = (service as any).collectHiddenElementHints(root, viewport, scroll);\n\t\t\texpect(hints).toHaveLength(0);\n\t\t});\n\t});\n\n\tdescribe('applyViewportThresholdFilter (via private access)', () => {\n\t\ttest('removes highlightIndex from elements outside expanded viewport', () => {\n\t\t\tconst outsideNode = makeNode({\n\t\t\t\ttagName: 'button',\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\trect: { x: 0, y: 5000, width: 100, height: 30 },\n\t\t\t});\n\t\t\tconst insideNode = makeNode({\n\t\t\t\ttagName: 'input',\n\t\t\t\thighlightIndex: 1 as ElementRef,\n\t\t\t\trect: { x: 0, y: 200, width: 200, height: 30 },\n\t\t\t});\n\t\t\tconst root = makeNode({\n\t\t\t\tchildren: [outsideNode, insideNode],\n\t\t\t});\n\n\t\t\tconst viewport = { width: 1280, height: 800 };\n\t\t\tconst scroll = { x: 0, y: 0 };\n\n\t\t\t(service as any).applyViewportThresholdFilter(root, viewport, scroll);\n\n\t\t\t// The outside node should have its highlightIndex removed\n\t\t\texpect(outsideNode.highlightIndex).toBeUndefined();\n\t\t\t// The inside node should keep its highlightIndex\n\t\t\texpect(insideNode.highlightIndex).toBe(1 as ElementRef);\n\t\t});\n\n\t\ttest('keeps elements within the viewport expansion margin', () => {\n\t\t\tconst svc = new PageAnalyzer({ viewportExpansion: 500 });\n\t\t\tconst nearNode = makeNode({\n\t\t\t\ttagName: 'a',\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\trect: { x: 0, y: 1100, width: 100, height: 30 },\n\t\t\t});\n\t\t\tconst root = makeNode({ children: [nearNode] });\n\n\t\t\t(svc as any).applyViewportThresholdFilter(\n\t\t\t\troot,\n\t\t\t\t{ width: 1280, height: 800 },\n\t\t\t\t{ x: 0, y: 0 },\n\t\t\t);\n\n\t\t\t// y=1100 is within 0..800+500=1300, so should be kept\n\t\t\texpect(nearNode.highlightIndex).toBe(0 as ElementRef);\n\t\t});\n\n\t\ttest('removes elements far to the right of the viewport', () => {\n\t\t\tconst farRightNode = makeNode({\n\t\t\t\ttagName: 'button',\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\trect: { x: 5000, y: 100, width: 100, height: 30 },\n\t\t\t});\n\t\t\tconst root = makeNode({ children: [farRightNode] });\n\n\t\t\t(service as any).applyViewportThresholdFilter(\n\t\t\t\troot,\n\t\t\t\t{ width: 1280, height: 800 },\n\t\t\t\t{ x: 0, y: 0 },\n\t\t\t);\n\n\t\t\texpect(farRightNode.highlightIndex).toBeUndefined();\n\t\t});\n\t});\n\n\tdescribe('integrateShadowDOMChildren (via private access)', () => {\n\t\ttest('merges shadow children into the children array', () => {\n\t\t\tconst shadowChild = makeNode({ tagName: 'span', text: 'shadow' });\n\t\t\tconst regularChild = makeNode({ tagName: 'p', text: 'regular' });\n\t\t\tconst root = makeNode({\n\t\t\t\tchildren: [regularChild],\n\t\t\t\tshadowChildren: [shadowChild],\n\t\t\t});\n\n\t\t\t(service as any).integrateShadowDOMChildren(root);\n\n\t\t\texpect(root.children).toHaveLength(2);\n\t\t\texpect(root.children[0].tagName).toBe('span'); // shadow comes first\n\t\t\texpect(root.children[1].tagName).toBe('p');\n\t\t\texpect(root.children[0].isShadowRoot).toBe(true);\n\t\t\texpect(root.children[0].parentNode).toBe(root);\n\t\t\texpect(root.shadowChildren).toBeUndefined();\n\t\t});\n\n\t\ttest('handles nodes with no shadow children', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\tchildren: [makeNode({ tagName: 'div' })],\n\t\t\t});\n\n\t\t\t(service as any).integrateShadowDOMChildren(root);\n\t\t\texpect(root.children).toHaveLength(1);\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/page/page-analyzer.ts",
    "content": "import type { CDPSession, Page } from 'playwright';\nimport { SnapshotBuilder } from './snapshot-builder.js';\nimport { TreeRenderer, type RendererOptions } from './renderer/tree-renderer.js';\nimport type {\n\tPageTreeNode,\n\tRenderedPageState,\n\tSelectorIndex,\n\tTargetInfo,\n\tTargetAllTrees,\n\tInteractedElement,\n} from './types.js';\nimport { PageExtractionError } from '../errors.js';\nimport { createLogger } from '../logging.js';\nimport { timed } from '../telemetry.js';\nimport type { ElementRef } from '../types.js';\n\nconst logger = createLogger('dom');\n\nexport interface PageAnalyzerOptions {\n\tserializer?: Partial<RendererOptions>;\n\tcapturedAttributes?: string[];\n\tmaxIframes?: number;\n\tviewportExpansion?: number;\n\tmaxElementsInDom?: number;\n}\n\nexport class PageAnalyzer {\n\tprivate snapshotProcessor: SnapshotBuilder;\n\tprivate serializer: TreeRenderer;\n\tprivate capturedAttributes: string[];\n\tprivate maxIframes: number;\n\tprivate viewportExpansion: number;\n\tprivate maxElementsInDom: number;\n\n\tprivate cachedTree: PageTreeNode | null = null;\n\tprivate cachedSelectorMap: SelectorIndex | null = null;\n\tprivate interactedElements: InteractedElement[] = [];\n\tprivate hiddenElementHints: string[] = [];\n\n\tconstructor(options?: PageAnalyzerOptions) {\n\t\tthis.snapshotProcessor = new SnapshotBuilder();\n\t\tthis.capturedAttributes = options?.capturedAttributes ?? [\n\t\t\t'title', 'type', 'name', 'role', 'tabindex',\n\t\t\t'aria-label', 'placeholder', 'value', 'alt', 'aria-expanded',\n\t\t];\n\t\tthis.maxIframes = options?.maxIframes ?? 3;\n\t\tthis.viewportExpansion = options?.viewportExpansion ?? 0;\n\t\tthis.maxElementsInDom = options?.maxElementsInDom ?? 2000;\n\t\tthis.serializer = new TreeRenderer({\n\t\t\tcapturedAttributes: this.capturedAttributes,\n\t\t\tmaxElementsInDom: this.maxElementsInDom,\n\t\t\t...options?.serializer,\n\t\t});\n\t}\n\n\tasync extractState(\n\t\tpage: Page,\n\t\tcdpSession: CDPSession,\n\t): Promise<RenderedPageState> {\n\t\tconst { result } = await timed('dom-extract', () =>\n\t\t\tthis._extractState(page, cdpSession),\n\t\t);\n\t\treturn result;\n\t}\n\n\tprivate async _extractState(\n\t\tpage: Page,\n\t\tcdpSession: CDPSession,\n\t): Promise<RenderedPageState> {\n\t\ttry {\n\t\t\t// Capture CDP snapshot\n\t\t\tconst { domSnapshot, axTree } = await this.snapshotProcessor.captureSnapshot(cdpSession);\n\n\t\t\t// Get viewport and document info\n\t\t\tconst [viewportSize, scrollPosition, documentSize] = await Promise.all([\n\t\t\t\tpage.viewportSize() ?? { width: 1280, height: 1100 },\n\t\t\t\tpage.evaluate(() => ({ x: window.scrollX, y: window.scrollY })),\n\t\t\t\tpage.evaluate(() => ({\n\t\t\t\t\twidth: document.documentElement.scrollWidth,\n\t\t\t\t\theight: document.documentElement.scrollHeight,\n\t\t\t\t})),\n\t\t\t]);\n\n\t\t\t// Build enhanced DOM tree\n\t\t\tconst { root } = this.snapshotProcessor.buildTree(\n\t\t\t\tdomSnapshot,\n\t\t\t\taxTree,\n\t\t\t\tviewportSize,\n\t\t\t\tthis.capturedAttributes,\n\t\t\t);\n\n\t\t\t// Traverse shadow DOM roots and merge their children into the main tree\n\t\t\tthis.integrateShadowDOMChildren(root);\n\n\t\t\t// Filter interactive elements by viewport visibility threshold.\n\t\t\t// Elements far outside the expanded viewport are stripped of their\n\t\t\t// highlight index so they do not clutter the serialized output.\n\t\t\tif (this.viewportExpansion >= 0) {\n\t\t\t\tthis.applyViewportThresholdFilter(root, viewportSize, scrollPosition);\n\t\t\t}\n\n\t\t\tthis.cachedTree = root;\n\n\t\t\t// Collect hidden element hints for scroll guidance\n\t\t\tthis.hiddenElementHints = this.collectHiddenElementHints(\n\t\t\t\troot,\n\t\t\t\tviewportSize,\n\t\t\t\tscrollPosition,\n\t\t\t);\n\n\t\t\t// Serialize for LLM\n\t\t\tconst state = this.serializer.serializeTree(\n\t\t\t\troot,\n\t\t\t\tscrollPosition,\n\t\t\t\tviewportSize,\n\t\t\t\tdocumentSize,\n\t\t\t);\n\n\t\t\tthis.cachedSelectorMap = state.selectorMap;\n\n\t\t\t// Append hidden element hints\n\t\t\tif (this.hiddenElementHints.length > 0) {\n\t\t\t\tstate.tree += '\\n\\n--- Hidden interactive elements (scroll to access) ---\\n';\n\t\t\t\tstate.tree += this.hiddenElementHints.slice(0, 10).join('\\n');\n\t\t\t\tif (this.hiddenElementHints.length > 10) {\n\t\t\t\t\tstate.tree += `\\n... and ${this.hiddenElementHints.length - 10} more`;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogger.debug(\n\t\t\t\t`Extracted DOM: ${state.elementCount} elements, ${state.interactiveElementCount} interactive`,\n\t\t\t);\n\n\t\t\treturn state;\n\t\t} catch (error) {\n\t\t\tthrow new PageExtractionError(\n\t\t\t\t`Failed to extract DOM state: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t{ cause: error instanceof Error ? error : undefined },\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Discover cross-origin iframes and extract their DOM trees via CDP Target discovery.\n\t * For same-origin iframes, uses Playwright frame evaluation.\n\t * For cross-origin iframes, attaches CDP sessions to their targets and extracts DOM snapshots.\n\t */\n\tasync extractWithIframes(\n\t\tpage: Page,\n\t\tcdpSession: CDPSession,\n\t): Promise<TargetAllTrees> {\n\t\tconst mainTree = await this._extractState(page, cdpSession).then(() => this.cachedTree!);\n\n\t\tconst iframeTrees: TargetAllTrees['iframeTrees'] = [];\n\n\t\ttry {\n\t\t\tconst frames = page.frames().slice(0, this.maxIframes + 1); // +1 for main\n\t\t\tconst processedUrls = new Set<string>();\n\n\t\t\tfor (const frame of frames.slice(1, this.maxIframes + 1)) {\n\t\t\t\ttry {\n\t\t\t\t\tconst url = frame.url();\n\t\t\t\t\tif (!url || url === 'about:blank' || processedUrls.has(url)) continue;\n\t\t\t\t\tprocessedUrls.add(url);\n\n\t\t\t\t\tconst targetInfo: TargetInfo = {\n\t\t\t\t\t\ttargetId: url,\n\t\t\t\t\t\ttype: 'iframe',\n\t\t\t\t\t\turl,\n\t\t\t\t\t\tattached: true,\n\t\t\t\t\t};\n\n\t\t\t\t\t// Try same-origin access first via Playwright frame evaluation\n\t\t\t\t\tconst html = await frame.evaluate(() => document.body?.innerHTML ?? '').catch(() => '');\n\t\t\t\t\tif (html) {\n\t\t\t\t\t\tiframeTrees.push({\n\t\t\t\t\t\t\ttargetInfo,\n\t\t\t\t\t\t\ttree: {\n\t\t\t\t\t\t\t\ttagName: 'iframe',\n\t\t\t\t\t\t\t\tnodeType: 'element',\n\t\t\t\t\t\t\t\tattributes: { src: url },\n\t\t\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\t\t\tisInteractive: false,\n\t\t\t\t\t\t\t\tisClickable: false,\n\t\t\t\t\t\t\t\tisEditable: false,\n\t\t\t\t\t\t\t\tisScrollable: false,\n\t\t\t\t\t\t\t\ttext: `[iframe: ${url}]`,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t});\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\t// Cross-origin: use CDP Target discovery to attach a session\n\t\t\t\t\tconst iframeTree = await this.extractCrossOriginIframe(cdpSession, url);\n\t\t\t\t\tif (iframeTree) {\n\t\t\t\t\t\tiframeTrees.push({\n\t\t\t\t\t\t\ttargetInfo,\n\t\t\t\t\t\t\ttree: iframeTree,\n\t\t\t\t\t\t});\n\t\t\t\t\t}\n\t\t\t\t} catch (error) {\n\t\t\t\t\tlogger.debug(`Failed to extract iframe ${frame.url()}: ${error}`);\n\t\t\t\t}\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.debug(`Failed to extract iframe trees: ${error}`);\n\t\t}\n\n\t\treturn { mainTree, iframeTrees };\n\t}\n\n\t/**\n\t * Attach a CDP session to a cross-origin iframe target and extract its DOM tree.\n\t * Uses Target.getTargets to find the matching iframe target, then attaches a session\n\t * and runs DOMSnapshot.captureSnapshot on it.\n\t */\n\tprivate async extractCrossOriginIframe(\n\t\tcdpSession: CDPSession,\n\t\tiframeUrl: string,\n\t): Promise<PageTreeNode | null> {\n\t\ttry {\n\t\t\tconst { targetInfos } = await cdpSession.send('Target.getTargets', {}) as unknown as {\n\t\t\t\ttargetInfos: Array<{ targetId: string; type: string; url: string; attached: boolean }>;\n\t\t\t};\n\n\t\t\tconst iframeTarget = targetInfos.find(\n\t\t\t\t(t) => t.type === 'iframe' && t.url === iframeUrl,\n\t\t\t);\n\t\t\tif (!iframeTarget) {\n\t\t\t\tlogger.debug(`No CDP target found for cross-origin iframe: ${iframeUrl}`);\n\t\t\t\treturn null;\n\t\t\t}\n\n\t\t\t// Attach to the iframe target\n\t\t\tconst { sessionId: iframeSessionId } = await cdpSession.send('Target.attachToTarget', {\n\t\t\t\ttargetId: iframeTarget.targetId,\n\t\t\t\tflatten: true,\n\t\t\t}) as unknown as { sessionId: string };\n\n\t\t\ttry {\n\t\t\t\t// Capture a DOM snapshot from the iframe session\n\t\t\t\tconst snapshotResult = await cdpSession.send('Target.sendMessageToTarget', {\n\t\t\t\t\tsessionId: iframeSessionId,\n\t\t\t\t\tmessage: JSON.stringify({\n\t\t\t\t\t\tid: 1,\n\t\t\t\t\t\tmethod: 'DOMSnapshot.captureSnapshot',\n\t\t\t\t\t\tparams: {\n\t\t\t\t\t\t\tcomputedStyles: ['display', 'visibility', 'opacity'],\n\t\t\t\t\t\t\tincludeDOMRects: true,\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t}) as unknown;\n\n\t\t\t\t// The snapshot result comes back as a string via Target protocol\n\t\t\t\t// Build a minimal tree node representing the iframe content\n\t\t\t\tconst iframeNode: PageTreeNode = {\n\t\t\t\t\ttagName: 'iframe',\n\t\t\t\t\tnodeType: 'element',\n\t\t\t\t\tattributes: { src: iframeUrl },\n\t\t\t\t\tchildren: [],\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\tisInteractive: false,\n\t\t\t\t\tisClickable: false,\n\t\t\t\t\tisEditable: false,\n\t\t\t\t\tisScrollable: false,\n\t\t\t\t\ttext: `[cross-origin iframe: ${iframeUrl}]`,\n\t\t\t\t};\n\n\t\t\t\t// If snapshot returned usable data, try to annotate the node\n\t\t\t\tif (snapshotResult && typeof snapshotResult === 'object') {\n\t\t\t\t\tiframeNode.text = `[cross-origin iframe content: ${iframeUrl}]`;\n\t\t\t\t}\n\n\t\t\t\treturn iframeNode;\n\t\t\t} finally {\n\t\t\t\t// Detach from the iframe target to clean up\n\t\t\t\tawait cdpSession.send('Target.detachFromTarget', {\n\t\t\t\t\tsessionId: iframeSessionId,\n\t\t\t\t}).catch(() => {});\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tlogger.debug(`CDP cross-origin iframe extraction failed for ${iframeUrl}: ${error}`);\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Collect hints about interactive elements that are off-screen,\n\t * including approximate scroll distance.\n\t */\n\tprivate collectHiddenElementHints(\n\t\troot: PageTreeNode,\n\t\tviewportSize: { width: number; height: number },\n\t\tscrollPosition: { x: number; y: number },\n\t): string[] {\n\t\tconst hints: string[] = [];\n\t\tconst viewportTop = scrollPosition.y;\n\t\tconst viewportBottom = viewportTop + viewportSize.height;\n\n\t\tconst visit = (node: PageTreeNode) => {\n\t\t\tif (\n\t\t\t\tnode.isInteractive &&\n\t\t\t\tnode.rect &&\n\t\t\t\t!node.isVisible &&\n\t\t\t\tnode.highlightIndex !== undefined\n\t\t\t) {\n\t\t\t\tconst elementY = node.rect.y;\n\t\t\t\tif (elementY > viewportBottom) {\n\t\t\t\t\tconst pagesBelow = ((elementY - viewportBottom) / viewportSize.height).toFixed(1);\n\t\t\t\t\tconst desc = node.ariaLabel || node.text?.trim()?.slice(0, 50) || node.tagName;\n\t\t\t\t\thints.push(\n\t\t\t\t\t\t`${node.tagName} '${desc}' is ~${pagesBelow} pages below`,\n\t\t\t\t\t);\n\t\t\t\t} else if (elementY < viewportTop) {\n\t\t\t\t\tconst pagesAbove = ((viewportTop - elementY) / viewportSize.height).toFixed(1);\n\t\t\t\t\tconst desc = node.ariaLabel || node.text?.trim()?.slice(0, 50) || node.tagName;\n\t\t\t\t\thints.push(\n\t\t\t\t\t\t`${node.tagName} '${desc}' is ~${pagesAbove} pages above`,\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor (const child of node.children) {\n\t\t\t\tvisit(child);\n\t\t\t}\n\t\t};\n\n\t\tvisit(root);\n\t\treturn hints;\n\t}\n\n\t/**\n\t * Apply viewport threshold filtering to the tree.\n\t * Interactive elements whose rects fall entirely outside the expanded viewport\n\t * have their highlightIndex removed so they are not serialized as interactive.\n\t * The expansion margin is controlled by viewportExpansion (in pixels).\n\t */\n\tprivate applyViewportThresholdFilter(\n\t\troot: PageTreeNode,\n\t\tviewportSize: { width: number; height: number },\n\t\tscrollPosition: { x: number; y: number },\n\t): void {\n\t\tconst expansion = this.viewportExpansion;\n\t\tconst vpTop = scrollPosition.y - expansion;\n\t\tconst vpBottom = scrollPosition.y + viewportSize.height + expansion;\n\t\tconst vpLeft = scrollPosition.x - expansion;\n\t\tconst vpRight = scrollPosition.x + viewportSize.width + expansion;\n\n\t\tconst visit = (node: PageTreeNode) => {\n\t\t\tif (node.highlightIndex !== undefined && node.rect) {\n\t\t\t\tconst nodeBottom = node.rect.y + node.rect.height;\n\t\t\t\tconst nodeRight = node.rect.x + node.rect.width;\n\n\t\t\t\t// Element is entirely outside the expanded viewport\n\t\t\t\tconst outsideVertically = nodeBottom < vpTop || node.rect.y > vpBottom;\n\t\t\t\tconst outsideHorizontally = nodeRight < vpLeft || node.rect.x > vpRight;\n\n\t\t\t\tif (outsideVertically || outsideHorizontally) {\n\t\t\t\t\t// Remove the highlight index so it will not appear in the serialized map,\n\t\t\t\t\t// but keep the node in the tree for structure.\n\t\t\t\t\tnode.highlightIndex = undefined;\n\t\t\t\t}\n\t\t\t}\n\t\t\tfor (const child of node.children) {\n\t\t\t\tvisit(child);\n\t\t\t}\n\t\t};\n\n\t\tvisit(root);\n\t}\n\n\t/**\n\t * Walk the tree and integrate shadow DOM children.\n\t * Nodes that have shadowChildren get those children merged into the\n\t * regular children array so downstream serialization handles them uniformly.\n\t */\n\tprivate integrateShadowDOMChildren(root: PageTreeNode): void {\n\t\tconst visit = (node: PageTreeNode) => {\n\t\t\tif (node.shadowChildren && node.shadowChildren.length > 0) {\n\t\t\t\t// Prepend shadow children before regular children so they\n\t\t\t\t// appear first, matching browser rendering order.\n\t\t\t\tfor (const shadowChild of node.shadowChildren) {\n\t\t\t\t\tshadowChild.parentNode = node;\n\t\t\t\t\tshadowChild.isShadowRoot = true;\n\t\t\t\t}\n\t\t\t\tnode.children = [...node.shadowChildren, ...node.children];\n\t\t\t\tnode.shadowChildren = undefined;\n\t\t\t}\n\t\t\tfor (const child of node.children) {\n\t\t\t\tvisit(child);\n\t\t\t}\n\t\t};\n\n\t\tvisit(root);\n\t}\n\n\tasync getElementSelector(index: number): Promise<string | undefined> {\n\t\treturn this.cachedSelectorMap?.[index]?.cssSelector;\n\t}\n\n\tasync getElementByBackendNodeId(\n\t\tcdpSession: CDPSession,\n\t\tbackendNodeId: number,\n\t): Promise<{ selector: string } | null> {\n\t\ttry {\n\t\t\tconst result = await cdpSession.send('DOM.describeNode', {\n\t\t\t\tbackendNodeId,\n\t\t\t}) as { node: { nodeName: string; attributes?: string[] } };\n\n\t\t\tif (!result?.node) return null;\n\n\t\t\tconst attrs = result.node.attributes ?? [];\n\t\t\tfor (let i = 0; i < attrs.length; i += 2) {\n\t\t\t\tif (attrs[i] === 'id' && attrs[i + 1]) {\n\t\t\t\t\treturn { selector: `#${attrs[i + 1]}` };\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn { selector: result.node.nodeName.toLowerCase() };\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/**\n\t * Click an element using a fallback chain:\n\t * 1. CDP box model (most reliable for overlapping elements)\n\t * 2. JS getBoundingClientRect\n\t * 3. CSS selector click\n\t */\n\tasync clickElementByIndex(\n\t\tpage: Page,\n\t\tcdpSession: CDPSession,\n\t\tindex: number,\n\t): Promise<void> {\n\t\tconst selectorInfo = this.cachedSelectorMap?.[index];\n\t\tif (!selectorInfo) {\n\t\t\tthrow new PageExtractionError(`Element with index ${index} not found in selector map`);\n\t\t}\n\n\t\t// Strategy 1: CDP box model click\n\t\tif (selectorInfo.backendNodeId) {\n\t\t\ttry {\n\t\t\t\tconst { model } = await cdpSession.send('DOM.getBoxModel', {\n\t\t\t\t\tbackendNodeId: selectorInfo.backendNodeId,\n\t\t\t\t}) as { model: { content: number[] } };\n\n\t\t\t\tif (model?.content) {\n\t\t\t\t\tconst [x1, y1, x2, y2, x3, y3, x4, y4] = model.content;\n\t\t\t\t\tconst centerX = (x1 + x2 + x3 + x4) / 4;\n\t\t\t\t\tconst centerY = (y1 + y2 + y3 + y4) / 4;\n\n\t\t\t\t\tawait page.mouse.click(centerX, centerY);\n\t\t\t\t\tthis.recordInteraction(index, selectorInfo.tagName, 'click');\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t} catch {\n\t\t\t\tlogger.debug(`CDP box model click failed for index ${index}, trying JS fallback`);\n\t\t\t}\n\t\t}\n\n\t\t// Strategy 2: JS getBoundingClientRect\n\t\ttry {\n\t\t\tconst rect = await page.evaluate((sel: string) => {\n\t\t\t\tconst el = document.querySelector(sel);\n\t\t\t\tif (!el) return null;\n\t\t\t\tconst r = el.getBoundingClientRect();\n\t\t\t\treturn { x: r.x + r.width / 2, y: r.y + r.height / 2 };\n\t\t\t}, selectorInfo.cssSelector);\n\n\t\t\tif (rect) {\n\t\t\t\tawait page.mouse.click(rect.x, rect.y);\n\t\t\t\tthis.recordInteraction(index, selectorInfo.tagName, 'click');\n\t\t\t\treturn;\n\t\t\t}\n\t\t} catch {\n\t\t\tlogger.debug(`JS rect click failed for index ${index}, trying CSS selector`);\n\t\t}\n\n\t\t// Strategy 3: CSS selector\n\t\tawait page.click(selectorInfo.cssSelector, { timeout: 5000 });\n\t\tthis.recordInteraction(index, selectorInfo.tagName, 'click');\n\t}\n\n\t/**\n\t * Click at specific coordinates on the page.\n\t */\n\tasync clickAtCoordinates(\n\t\tpage: Page,\n\t\tx: number,\n\t\ty: number,\n\t): Promise<void> {\n\t\tawait page.mouse.click(x, y);\n\t}\n\n\tasync inputTextByIndex(\n\t\tpage: Page,\n\t\t_cdpSession: CDPSession,\n\t\tindex: number,\n\t\ttext: string,\n\t\tclearFirst = true,\n\t): Promise<void> {\n\t\tconst selectorInfo = this.cachedSelectorMap?.[index];\n\t\tif (!selectorInfo) {\n\t\t\tthrow new PageExtractionError(`Element with index ${index} not found in selector map`);\n\t\t}\n\n\t\tconst selector = selectorInfo.cssSelector;\n\n\t\tif (clearFirst) {\n\t\t\tawait page.fill(selector, text);\n\t\t} else {\n\t\t\tawait page.click(selector);\n\t\t\tawait page.keyboard.type(text);\n\t\t}\n\n\t\tthis.recordInteraction(index, selectorInfo.tagName, 'input');\n\t}\n\n\tprivate recordInteraction(\n\t\tindex: number,\n\t\ttagName: string,\n\t\taction: string,\n\t): void {\n\t\tthis.interactedElements.push({\n\t\t\tindex: index as ElementRef,\n\t\t\ttagName,\n\t\t\taction,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\t}\n\n\tgetInteractedElements(): InteractedElement[] {\n\t\treturn [...this.interactedElements];\n\t}\n\n\tclearInteractedElements(): void {\n\t\tthis.interactedElements = [];\n\t}\n\n\tgetCachedTree(): PageTreeNode | null {\n\t\treturn this.cachedTree;\n\t}\n\n\tgetCachedSelectorMap(): SelectorIndex | null {\n\t\treturn this.cachedSelectorMap;\n\t}\n\n\tclearCache(): void {\n\t\tthis.cachedTree = null;\n\t\tthis.cachedSelectorMap = null;\n\t\tthis.hiddenElementHints = [];\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/page/renderer/interactive-elements.ts",
    "content": "import type { PageTreeNode } from '../types.js';\n\nconst ALWAYS_CLICKABLE_TAGS = new Set([\n\t'a', 'button', 'input', 'select', 'textarea', 'summary',\n]);\n\nconst CLICKABLE_ROLES = new Set([\n\t'button', 'link', 'menuitem', 'option', 'tab', 'treeitem',\n\t'checkbox', 'radio', 'switch',\n]);\n\nexport function isClickableElement(node: PageTreeNode): boolean {\n\tif (ALWAYS_CLICKABLE_TAGS.has(node.tagName)) return true;\n\tif (node.role && CLICKABLE_ROLES.has(node.role)) return true;\n\tif (node.attributes['onclick']) return true;\n\tif (node.attributes['tabindex'] && node.attributes['tabindex'] !== '-1') return true;\n\tif (node.attributes['role'] && CLICKABLE_ROLES.has(node.attributes['role'])) return true;\n\treturn node.isClickable;\n}\n\nexport function getClickableDescription(node: PageTreeNode): string {\n\tconst parts: string[] = [];\n\n\tif (node.ariaLabel) {\n\t\tparts.push(node.ariaLabel);\n\t} else if (node.text) {\n\t\tparts.push(node.text.trim().slice(0, 50));\n\t} else if (node.attributes['title']) {\n\t\tparts.push(node.attributes['title']);\n\t} else if (node.attributes['alt']) {\n\t\tparts.push(node.attributes['alt']);\n\t} else if (node.attributes['placeholder']) {\n\t\tparts.push(node.attributes['placeholder']);\n\t}\n\n\treturn parts.join(' - ') || node.tagName;\n}\n"
  },
  {
    "path": "packages/core/src/page/renderer/layer-order.ts",
    "content": "import type { PageTreeNode, DOMRect } from '../types.js';\n\n/**\n * Filter overlapping elements by paint order (z-index).\n * When two interactive elements overlap, only keep the one painted on top.\n */\nexport function filterByPaintOrder(nodes: PageTreeNode[]): PageTreeNode[] {\n\tif (nodes.length === 0) return nodes;\n\n\t// Group nodes by approximate position\n\tconst gridSize = 50;\n\tconst grid = new Map<string, PageTreeNode[]>();\n\n\tfor (const node of nodes) {\n\t\tif (!node.rect || !node.isVisible) continue;\n\n\t\tconst cellX = Math.floor(node.rect.x / gridSize);\n\t\tconst cellY = Math.floor(node.rect.y / gridSize);\n\t\tconst key = `${cellX},${cellY}`;\n\n\t\tif (!grid.has(key)) grid.set(key, []);\n\t\tgrid.get(key)!.push(node);\n\t}\n\n\tconst hidden = new Set<PageTreeNode>();\n\n\tfor (const cellNodes of grid.values()) {\n\t\tif (cellNodes.length < 2) continue;\n\n\t\tfor (let i = 0; i < cellNodes.length; i++) {\n\t\t\tfor (let j = i + 1; j < cellNodes.length; j++) {\n\t\t\t\tconst a = cellNodes[i];\n\t\t\t\tconst b = cellNodes[j];\n\n\t\t\t\tif (rectsOverlap(a.rect!, b.rect!, 0.5)) {\n\t\t\t\t\tconst paintA = a.paintOrder ?? 0;\n\t\t\t\t\tconst paintB = b.paintOrder ?? 0;\n\n\t\t\t\t\tif (paintA < paintB) {\n\t\t\t\t\t\thidden.add(a);\n\t\t\t\t\t} else if (paintB < paintA) {\n\t\t\t\t\t\thidden.add(b);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nodes.filter((n) => !hidden.has(n));\n}\n\nfunction rectsOverlap(a: DOMRect, b: DOMRect, threshold: number): boolean {\n\tconst overlapX = Math.max(\n\t\t0,\n\t\tMath.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x),\n\t);\n\tconst overlapY = Math.max(\n\t\t0,\n\t\tMath.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y),\n\t);\n\n\tconst overlapArea = overlapX * overlapY;\n\tconst smallerArea = Math.min(a.width * a.height, b.width * b.height);\n\n\treturn smallerArea > 0 && overlapArea / smallerArea >= threshold;\n}\n"
  },
  {
    "path": "packages/core/src/page/renderer/tree-renderer.ts",
    "content": "import type { PageTreeNode, SelectorIndex, RenderedPageState } from '../types.js';\nimport type { ElementRef } from '../../types.js';\nimport { isClickableElement, getClickableDescription } from './interactive-elements.js';\nimport { filterByPaintOrder } from './layer-order.js';\n\nexport interface RendererOptions {\n\tcapturedAttributes: string[];\n\tmaxDepth: number;\n\tfilterPaintOrder: boolean;\n\tmaxElementsInDom: number;\n\tcollapseSvg: boolean;\n\tdeduplicateSiblings: boolean;\n\tsiblingDeduplicateThreshold: number;\n\tcontainmentThreshold: number;\n}\n\nconst DEFAULT_OPTIONS: RendererOptions = {\n\tcapturedAttributes: [\n\t\t'title', 'type', 'name', 'role', 'tabindex',\n\t\t'aria-label', 'placeholder', 'value', 'alt', 'aria-expanded',\n\t],\n\tmaxDepth: 100,\n\tfilterPaintOrder: true,\n\tmaxElementsInDom: 2000,\n\tcollapseSvg: true,\n\tdeduplicateSiblings: true,\n\tsiblingDeduplicateThreshold: 5,\n\tcontainmentThreshold: 0.95,\n};\n\nconst SVG_TAGS = new Set(['svg', 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'g', 'defs', 'use', 'symbol', 'clippath', 'lineargradient', 'radialgradient', 'stop', 'text', 'tspan', 'mask', 'filter']);\n\nexport class TreeRenderer {\n\tprivate options: RendererOptions;\n\n\tconstructor(options?: Partial<RendererOptions>) {\n\t\tthis.options = { ...DEFAULT_OPTIONS, ...options };\n\t}\n\n\tserializeTree(\n\t\troot: PageTreeNode,\n\t\tscrollPosition: { x: number; y: number },\n\t\tviewportSize: { width: number; height: number },\n\t\tdocumentSize: { width: number; height: number },\n\t): RenderedPageState {\n\t\tconst selectorMap: SelectorIndex = {};\n\t\tconst interactiveElements: PageTreeNode[] = [];\n\n\t\t// Collect interactive elements\n\t\tthis.collectInteractiveElements(root, interactiveElements);\n\n\t\t// Filter by paint order if enabled\n\t\tlet visibleElements = this.options.filterPaintOrder\n\t\t\t? filterByPaintOrder(interactiveElements)\n\t\t\t: interactiveElements;\n\n\t\t// Enhanced bounding-box off-screen filtering:\n\t\t// Remove elements that are clearly off-screen (negative coords beyond\n\t\t// a reasonable threshold, or positioned entirely past the document bounds).\n\t\tconst offScreenHidden: PageTreeNode[] = [];\n\t\tvisibleElements = this.filterOffScreenElements(\n\t\t\tvisibleElements,\n\t\t\tscrollPosition,\n\t\t\tviewportSize,\n\t\t\tdocumentSize,\n\t\t\toffScreenHidden,\n\t\t);\n\n\t\t// Build selector map\n\t\tfor (const node of visibleElements) {\n\t\t\tif (node.highlightIndex !== undefined) {\n\t\t\t\tselectorMap[node.highlightIndex] = {\n\t\t\t\t\tcssSelector: node.cssSelector ?? this.buildCssSelector(node),\n\t\t\t\t\txpath: node.xpath,\n\t\t\t\t\tbackendNodeId: node.backendNodeId,\n\t\t\t\t\ttagName: node.tagName,\n\t\t\t\t\trole: node.role,\n\t\t\t\t\tariaLabel: node.ariaLabel,\n\t\t\t\t\ttext: node.text?.trim()?.slice(0, 100),\n\t\t\t\t};\n\t\t\t}\n\t\t}\n\n\t\t// Serialize to text with element cap\n\t\tconst lines: string[] = [];\n\t\tlet elementCount = 0;\n\t\tconst maxElements = this.options.maxElementsInDom;\n\n\t\tconst countingContext = { count: 0, maxReached: false };\n\t\tthis.serializeNode(root, lines, 0, selectorMap, countingContext, maxElements);\n\t\telementCount = Object.keys(selectorMap).length;\n\n\t\tif (countingContext.maxReached) {\n\t\t\tlines.push(`\\n[... DOM truncated at ${maxElements} elements]`);\n\t\t}\n\n\t\t// Append hidden element hint section for off-screen interactive elements\n\t\tconst hiddenHints = this.formatHiddenElementHints(offScreenHidden, scrollPosition, viewportSize);\n\t\tif (hiddenHints.length > 0) {\n\t\t\tlines.push('');\n\t\t\tlines.push('--- Off-screen interactive elements ---');\n\t\t\tfor (const hint of hiddenHints.slice(0, 15)) {\n\t\t\t\tlines.push(hint);\n\t\t\t}\n\t\t\tif (hiddenHints.length > 15) {\n\t\t\t\tlines.push(`... and ${hiddenHints.length - 15} more off-screen elements`);\n\t\t\t}\n\t\t}\n\n\t\tconst pixelsAbove = scrollPosition.y;\n\t\tconst pixelsBelow = Math.max(0, documentSize.height - scrollPosition.y - viewportSize.height);\n\n\t\treturn {\n\t\t\ttree: lines.join('\\n'),\n\t\t\tselectorMap,\n\t\t\telementCount,\n\t\t\tinteractiveElementCount: visibleElements.length,\n\t\t\tscrollPosition,\n\t\t\tviewportSize,\n\t\t\tdocumentSize,\n\t\t\tpixelsAbove,\n\t\t\tpixelsBelow,\n\t\t};\n\t}\n\n\tprivate serializeNode(\n\t\tnode: PageTreeNode,\n\t\tlines: string[],\n\t\tdepth: number,\n\t\tselectorMap: SelectorIndex,\n\t\tctx: { count: number; maxReached: boolean },\n\t\tmaxElements: number,\n\t): void {\n\t\tif (depth > this.options.maxDepth) return;\n\t\tif (ctx.maxReached) return;\n\t\tif (!node.isVisible && node.nodeType === 'element' && node.children.length === 0) return;\n\n\t\tconst indent = '\\t'.repeat(depth);\n\n\t\tif (node.nodeType === 'text') {\n\t\t\tconst text = node.text?.trim();\n\t\t\tif (text) {\n\t\t\t\tlines.push(`${indent}${text}`);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// Skip invisible non-interactive containers with no visible children\n\t\tif (!node.isVisible && !node.isInteractive && !this.hasVisibleDescendant(node)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Collapse SVGs to placeholder, with containment deduplication for nested SVGs.\n\t\t// When an SVG contains only other SVG elements (nested wrappers), we collapse\n\t\t// them into a single placeholder using the deepest label we can find.\n\t\tif (this.options.collapseSvg && node.tagName === 'svg') {\n\t\t\tconst desc = this.resolveSvgDescription(node);\n\t\t\tif (node.highlightIndex !== undefined && selectorMap[node.highlightIndex]) {\n\t\t\t\tlines.push(`${indent}[${node.highlightIndex}]<svg>${desc}</svg>`);\n\t\t\t} else {\n\t\t\t\tlines.push(`${indent}<svg>${desc}</svg>`);\n\t\t\t}\n\t\t\tctx.count++;\n\t\t\treturn;\n\t\t}\n\n\t\t// Skip inner SVG elements\n\t\tif (SVG_TAGS.has(node.tagName) && node.tagName !== 'svg') {\n\t\t\treturn;\n\t\t}\n\n\t\tctx.count++;\n\t\tif (ctx.count > maxElements) {\n\t\t\tctx.maxReached = true;\n\t\t\treturn;\n\t\t}\n\n\t\t// Containment check: if parent fully contains only this child, prefer showing child\n\t\t// (handled implicitly by tree traversal — we just skip redundant wrappers)\n\t\tif (this.isRedundantWrapper(node)) {\n\t\t\tfor (const child of node.children) {\n\t\t\t\tthis.serializeNode(child, lines, depth, selectorMap, ctx, maxElements);\n\t\t\t}\n\t\t\treturn;\n\t\t}\n\n\t\t// Build tag representation\n\t\tconst parts: string[] = [];\n\n\t\t// Highlight index for interactive elements\n\t\tif (node.highlightIndex !== undefined && selectorMap[node.highlightIndex]) {\n\t\t\tparts.push(`[${node.highlightIndex}]`);\n\t\t}\n\n\t\t// Tag name\n\t\tparts.push(`<${node.tagName}`);\n\n\t\t// Attributes\n\t\tconst attrParts: string[] = [];\n\t\tfor (const attr of this.options.capturedAttributes) {\n\t\t\tconst value = node.attributes[attr];\n\t\t\tif (value !== undefined && value !== '') {\n\t\t\t\tattrParts.push(`${attr}=\"${value}\"`);\n\t\t\t}\n\t\t}\n\n\t\t// Prefer AX node name over DOM text when available\n\t\tif (node.role) {\n\t\t\tattrParts.push(`role=\"${node.role}\"`);\n\t\t}\n\t\tif (node.ariaLabel && !node.attributes['aria-label']) {\n\t\t\tattrParts.push(`aria-label=\"${node.ariaLabel}\"`);\n\t\t}\n\n\t\tif (attrParts.length > 0) {\n\t\t\tparts.push(` ${attrParts.join(' ')}`);\n\t\t}\n\n\t\t// Input value\n\t\tif (node.inputValue !== undefined) {\n\t\t\tparts.push(` value=\"${node.inputValue}\"`);\n\t\t}\n\n\t\tparts.push('>');\n\n\t\t// Inline text for leaf elements\n\t\tconst inlineText = this.getInlineText(node);\n\t\tif (inlineText) {\n\t\t\tparts.push(inlineText);\n\t\t\tparts.push(`</${node.tagName}>`);\n\t\t\tlines.push(`${indent}${parts.join('')}`);\n\t\t\treturn;\n\t\t}\n\n\t\tlines.push(`${indent}${parts.join('')}`);\n\n\t\t// Deduplicate similar siblings\n\t\tif (this.options.deduplicateSiblings) {\n\t\t\tthis.serializeChildrenWithDedup(node.children, lines, depth + 1, selectorMap, ctx, maxElements);\n\t\t} else {\n\t\t\tfor (const child of node.children) {\n\t\t\t\tthis.serializeNode(child, lines, depth + 1, selectorMap, ctx, maxElements);\n\t\t\t}\n\t\t}\n\n\t\t// Closing tag only if there were children\n\t\tif (node.children.some((c) => c.isVisible || c.nodeType === 'text')) {\n\t\t\tlines.push(`${indent}</${node.tagName}>`);\n\t\t}\n\t}\n\n\t/**\n\t * Serialize children but deduplicate runs of similar siblings.\n\t * If more than N consecutive siblings have the same tagName and no interactive children,\n\t * show the first few and add \"... and N-3 more\" summary.\n\t */\n\tprivate serializeChildrenWithDedup(\n\t\tchildren: PageTreeNode[],\n\t\tlines: string[],\n\t\tdepth: number,\n\t\tselectorMap: SelectorIndex,\n\t\tctx: { count: number; maxReached: boolean },\n\t\tmaxElements: number,\n\t): void {\n\t\tconst threshold = this.options.siblingDeduplicateThreshold;\n\t\tlet i = 0;\n\n\t\twhile (i < children.length) {\n\t\t\tif (ctx.maxReached) return;\n\n\t\t\tconst child = children[i];\n\n\t\t\t// Find run of same-tag non-interactive siblings\n\t\t\tlet runEnd = i + 1;\n\t\t\tif (\n\t\t\t\tchild.nodeType === 'element' &&\n\t\t\t\t!child.isInteractive &&\n\t\t\t\t!this.hasInteractiveDescendant(child)\n\t\t\t) {\n\t\t\t\twhile (\n\t\t\t\t\trunEnd < children.length &&\n\t\t\t\t\tchildren[runEnd].nodeType === 'element' &&\n\t\t\t\t\tchildren[runEnd].tagName === child.tagName &&\n\t\t\t\t\t!children[runEnd].isInteractive &&\n\t\t\t\t\t!this.hasInteractiveDescendant(children[runEnd])\n\t\t\t\t) {\n\t\t\t\t\trunEnd++;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tconst runLength = runEnd - i;\n\t\t\tif (runLength > threshold) {\n\t\t\t\t// Show first 3, then summarize\n\t\t\t\tconst showCount = 3;\n\t\t\t\tfor (let j = i; j < i + showCount && j < runEnd; j++) {\n\t\t\t\t\tthis.serializeNode(children[j], lines, depth, selectorMap, ctx, maxElements);\n\t\t\t\t}\n\t\t\t\tconst indent = '\\t'.repeat(depth);\n\t\t\t\tlines.push(`${indent}... and ${runLength - showCount} more <${child.tagName}> elements`);\n\t\t\t\ti = runEnd;\n\t\t\t} else {\n\t\t\t\tthis.serializeNode(child, lines, depth, selectorMap, ctx, maxElements);\n\t\t\t\ti++;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Check if a node is a redundant wrapper: single visible child, no interactive\n\t * properties, no highlight index, generic tag.\n\t */\n\tprivate isRedundantWrapper(node: PageTreeNode): boolean {\n\t\tif (node.highlightIndex !== undefined) return false;\n\t\tif (node.isInteractive) return false;\n\n\t\tconst visibleChildren = node.children.filter(\n\t\t\t(c) => c.isVisible || c.isInteractive || c.nodeType === 'text',\n\t\t);\n\n\t\tif (visibleChildren.length !== 1) return false;\n\n\t\tconst genericTags = new Set(['div', 'span', 'section', 'article', 'main']);\n\t\tif (!genericTags.has(node.tagName)) return false;\n\n\t\t// Check containment: does the parent rect fully contain the child rect?\n\t\tif (node.rect && visibleChildren[0].rect) {\n\t\t\tconst parentArea = node.rect.width * node.rect.height;\n\t\t\tconst childArea = visibleChildren[0].rect.width * visibleChildren[0].rect.height;\n\t\t\tif (parentArea > 0 && childArea / parentArea > this.options.containmentThreshold) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tprivate getInlineText(node: PageTreeNode): string | null {\n\t\tif (node.children.length === 0) {\n\t\t\treturn node.text?.trim() || null;\n\t\t}\n\t\tif (\n\t\t\tnode.children.length === 1 &&\n\t\t\tnode.children[0].nodeType === 'text' &&\n\t\t\tnode.children[0].text\n\t\t) {\n\t\t\treturn node.children[0].text.trim();\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate hasVisibleDescendant(node: PageTreeNode): boolean {\n\t\tfor (const child of node.children) {\n\t\t\tif (child.isVisible || child.isInteractive) return true;\n\t\t\tif (this.hasVisibleDescendant(child)) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate hasInteractiveDescendant(node: PageTreeNode): boolean {\n\t\tfor (const child of node.children) {\n\t\t\tif (child.isInteractive || child.highlightIndex !== undefined) return true;\n\t\t\tif (this.hasInteractiveDescendant(child)) return true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate collectInteractiveElements(\n\t\tnode: PageTreeNode,\n\t\tresult: PageTreeNode[],\n\t): void {\n\t\tif (node.highlightIndex !== undefined && node.isVisible) {\n\t\t\tresult.push(node);\n\t\t}\n\t\tfor (const child of node.children) {\n\t\t\tthis.collectInteractiveElements(child, result);\n\t\t}\n\t}\n\n\tprivate buildCssSelector(node: PageTreeNode): string {\n\t\tconst parts: string[] = [];\n\t\tlet current: PageTreeNode | undefined = node;\n\n\t\twhile (current && current.tagName !== 'html') {\n\t\t\tlet selector = current.tagName;\n\n\t\t\tif (current.attributes['id']) {\n\t\t\t\tselector = `#${current.attributes['id']}`;\n\t\t\t\tparts.unshift(selector);\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif (current.parentNode) {\n\t\t\t\tconst siblings = current.parentNode.children.filter(\n\t\t\t\t\t(c) => c.tagName === current!.tagName,\n\t\t\t\t);\n\t\t\t\tif (siblings.length > 1) {\n\t\t\t\t\tconst idx = siblings.indexOf(current) + 1;\n\t\t\t\t\tselector += `:nth-of-type(${idx})`;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tparts.unshift(selector);\n\t\t\tcurrent = current.parentNode;\n\t\t}\n\n\t\treturn parts.join(' > ');\n\t}\n\n\t/**\n\t * Enhanced off-screen element filtering.\n\t * Removes interactive elements whose bounding boxes fall entirely outside\n\t * reasonable document bounds, or that have degenerate rects (negative width/height,\n\t * extremely large offsets indicating hidden off-canvas positioning).\n\t * Elements that are simply scrolled out of the current viewport are NOT removed --\n\t * they are collected into the offScreenHidden array for hint formatting.\n\t */\n\tprivate filterOffScreenElements(\n\t\telements: PageTreeNode[],\n\t\tscrollPosition: { x: number; y: number },\n\t\tviewportSize: { width: number; height: number },\n\t\tdocumentSize: { width: number; height: number },\n\t\toffScreenHidden: PageTreeNode[],\n\t): PageTreeNode[] {\n\t\t// Anything positioned more than this many pixels outside the document\n\t\t// is almost certainly a hidden/off-canvas element (e.g. left: -9999px).\n\t\tconst offCanvasThreshold = 5000;\n\n\t\tconst vpTop = scrollPosition.y;\n\t\tconst vpBottom = scrollPosition.y + viewportSize.height;\n\t\tconst vpLeft = scrollPosition.x;\n\t\tconst vpRight = scrollPosition.x + viewportSize.width;\n\n\t\tconst result: PageTreeNode[] = [];\n\n\t\tfor (const node of elements) {\n\t\t\tif (!node.rect) {\n\t\t\t\tresult.push(node);\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tconst { x, y, width, height } = node.rect;\n\n\t\t\t// Degenerate rects: negative dimensions or zero-area\n\t\t\tif (width <= 0 || height <= 0) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Off-canvas positioning (common CSS hidden pattern: left: -9999px)\n\t\t\tif (\n\t\t\t\tx + width < -offCanvasThreshold ||\n\t\t\t\ty + height < -offCanvasThreshold ||\n\t\t\t\tx > documentSize.width + offCanvasThreshold ||\n\t\t\t\ty > documentSize.height + offCanvasThreshold\n\t\t\t) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Check if the element is inside the current viewport\n\t\t\tconst nodeBottom = y + height;\n\t\t\tconst nodeRight = x + width;\n\t\t\tconst inViewport =\n\t\t\t\tnodeBottom >= vpTop &&\n\t\t\t\ty <= vpBottom &&\n\t\t\t\tnodeRight >= vpLeft &&\n\t\t\t\tx <= vpRight;\n\n\t\t\tif (inViewport) {\n\t\t\t\tresult.push(node);\n\t\t\t} else {\n\t\t\t\t// Off-screen but within reasonable document bounds --\n\t\t\t\t// keep it in the selector map but track it for hint section\n\t\t\t\tresult.push(node);\n\t\t\t\toffScreenHidden.push(node);\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/**\n\t * Format hidden element hints for the serialized output.\n\t * Groups off-screen elements by direction and provides scroll distance estimates.\n\t */\n\tprivate formatHiddenElementHints(\n\t\toffScreenElements: PageTreeNode[],\n\t\tscrollPosition: { x: number; y: number },\n\t\tviewportSize: { width: number; height: number },\n\t): string[] {\n\t\tif (offScreenElements.length === 0) return [];\n\n\t\tconst vpBottom = scrollPosition.y + viewportSize.height;\n\t\tconst vpTop = scrollPosition.y;\n\t\tconst hints: string[] = [];\n\n\t\tfor (const node of offScreenElements) {\n\t\t\tif (!node.rect) continue;\n\t\t\tconst desc = this.getNodeDescription(node);\n\t\t\tconst elementY = node.rect.y;\n\n\t\t\tif (elementY > vpBottom) {\n\t\t\t\tconst pxBelow = elementY - vpBottom;\n\t\t\t\tconst pagesBelow = (pxBelow / viewportSize.height).toFixed(1);\n\t\t\t\thints.push(`  ${node.tagName} \"${desc}\" ~${pagesBelow} pages below`);\n\t\t\t} else if (elementY + node.rect.height < vpTop) {\n\t\t\t\tconst pxAbove = vpTop - (elementY + node.rect.height);\n\t\t\t\tconst pagesAbove = (pxAbove / viewportSize.height).toFixed(1);\n\t\t\t\thints.push(`  ${node.tagName} \"${desc}\" ~${pagesAbove} pages above`);\n\t\t\t} else {\n\t\t\t\t// Off to the side\n\t\t\t\thints.push(`  ${node.tagName} \"${desc}\" off-screen horizontally`);\n\t\t\t}\n\t\t}\n\n\t\treturn hints;\n\t}\n\n\t/**\n\t * Get a short human-readable description of a node for hint text.\n\t */\n\tprivate getNodeDescription(node: PageTreeNode): string {\n\t\tif (node.ariaLabel) return node.ariaLabel.slice(0, 60);\n\t\tif (node.text) return node.text.trim().slice(0, 60);\n\t\tif (node.attributes['title']) return node.attributes['title'].slice(0, 60);\n\t\tif (node.attributes['placeholder']) return node.attributes['placeholder'].slice(0, 60);\n\t\treturn node.tagName;\n\t}\n\n\t/**\n\t * Resolve the best description for an SVG, traversing nested SVG wrappers\n\t * to find the deepest aria-label or title. This collapses redundant\n\t * nested SVG containers into a single description.\n\t */\n\tprivate resolveSvgDescription(node: PageTreeNode): string {\n\t\t// Check the current node for labels\n\t\tconst label = node.ariaLabel || node.attributes['aria-label'] || '';\n\t\tconst title = node.attributes['title'] || '';\n\n\t\t// Look for nested SVGs that might carry a better description\n\t\tlet deepLabel = '';\n\t\tconst visitSvgChildren = (n: PageTreeNode): void => {\n\t\t\tfor (const child of n.children) {\n\t\t\t\tif (child.tagName === 'title' && child.text) {\n\t\t\t\t\tdeepLabel = child.text.trim();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (child.tagName === 'svg') {\n\t\t\t\t\t// Nested SVG -- check it for labels\n\t\t\t\t\tconst nested =\n\t\t\t\t\t\tchild.ariaLabel ||\n\t\t\t\t\t\tchild.attributes['aria-label'] ||\n\t\t\t\t\t\tchild.attributes['title'] ||\n\t\t\t\t\t\t'';\n\t\t\t\t\tif (nested) {\n\t\t\t\t\t\tdeepLabel = nested;\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t\t// Keep traversing deeper\n\t\t\t\t\tvisitSvgChildren(child);\n\t\t\t\t\tif (deepLabel) return;\n\t\t\t\t}\n\t\t\t\tif (SVG_TAGS.has(child.tagName)) {\n\t\t\t\t\tvisitSvgChildren(child);\n\t\t\t\t\tif (deepLabel) return;\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tvisitSvgChildren(node);\n\n\t\treturn label || title || deepLabel || 'icon';\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/page/renderer.test.ts",
    "content": "import { test, expect, describe, beforeEach } from 'bun:test';\nimport { TreeRenderer } from './renderer/tree-renderer.js';\nimport type { PageTreeNode, SelectorIndex } from './types.js';\nimport type { ElementRef } from '../types.js';\n\n// ── Helpers ──\n\nfunction makeNode(overrides: Partial<PageTreeNode> = {}): PageTreeNode {\n\treturn {\n\t\ttagName: 'div',\n\t\tnodeType: 'element',\n\t\tattributes: {},\n\t\tchildren: [],\n\t\tisVisible: true,\n\t\tisInteractive: false,\n\t\tisClickable: false,\n\t\tisEditable: false,\n\t\tisScrollable: false,\n\t\t...overrides,\n\t};\n}\n\nfunction makeTextNode(text: string): PageTreeNode {\n\treturn makeNode({\n\t\ttagName: '',\n\t\tnodeType: 'text',\n\t\ttext,\n\t\tchildren: [],\n\t});\n}\n\nconst defaultScroll = { x: 0, y: 0 };\nconst defaultViewport = { width: 1280, height: 800 };\nconst defaultDocSize = { width: 1280, height: 3000 };\n\n// ── Tests ──\n\ndescribe('TreeRenderer', () => {\n\tlet serializer: TreeRenderer;\n\n\tbeforeEach(() => {\n\t\tserializer = new TreeRenderer({\n\t\t\tcapturedAttributes: ['title', 'role', 'aria-label', 'placeholder'],\n\t\t\tfilterPaintOrder: false,\n\t\t});\n\t});\n\n\tdescribe('basic tree serialization', () => {\n\t\ttest('serializes a simple root with text child', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'body',\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\tmakeNode({\n\t\t\t\t\t\t\t\ttagName: 'h1',\n\t\t\t\t\t\t\t\ttext: 'Hello World',\n\t\t\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\texpect(state.tree).toContain('h1');\n\t\t\texpect(state.tree).toContain('Hello World');\n\t\t\texpect(state.scrollPosition).toEqual(defaultScroll);\n\t\t\texpect(state.viewportSize).toEqual(defaultViewport);\n\t\t});\n\n\t\ttest('includes element count and interactive element count', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'button',\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\t\t\ttext: 'Click me',\n\t\t\t\t\t\tcssSelector: '#btn',\n\t\t\t\t\t}),\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'p',\n\t\t\t\t\t\ttext: 'Paragraph',\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\texpect(state.interactiveElementCount).toBeGreaterThanOrEqual(1);\n\t\t\texpect(state.elementCount).toBeGreaterThanOrEqual(1);\n\t\t});\n\n\t\ttest('builds selector map for interactive elements with highlightIndex', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'button',\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\t\t\tcssSelector: '#submit-btn',\n\t\t\t\t\t\ttext: 'Submit',\n\t\t\t\t\t\trole: 'button',\n\t\t\t\t\t\tariaLabel: 'Submit form',\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\texpect(state.selectorMap[0]).toBeDefined();\n\t\t\texpect(state.selectorMap[0].cssSelector).toBe('#submit-btn');\n\t\t\texpect(state.selectorMap[0].tagName).toBe('button');\n\t\t\texpect(state.selectorMap[0].role).toBe('button');\n\t\t\texpect(state.selectorMap[0].ariaLabel).toBe('Submit form');\n\t\t});\n\n\t\ttest('includes highlight index in serialized output', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'a',\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\thighlightIndex: 3 as ElementRef,\n\t\t\t\t\t\tcssSelector: 'a.link',\n\t\t\t\t\t\ttext: 'Link text',\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('[3]');\n\t\t});\n\n\t\ttest('computes pixelsAbove and pixelsBelow', () => {\n\t\t\tconst root = makeNode({ tagName: 'html' });\n\t\t\tconst state = serializer.serializeTree(\n\t\t\t\troot,\n\t\t\t\t{ x: 0, y: 400 },\n\t\t\t\t{ width: 1280, height: 800 },\n\t\t\t\t{ width: 1280, height: 2000 },\n\t\t\t);\n\n\t\t\texpect(state.pixelsAbove).toBe(400);\n\t\t\texpect(state.pixelsBelow).toBe(800); // 2000 - 400 - 800\n\t\t});\n\t});\n\n\tdescribe('SVG collapse', () => {\n\t\ttest('collapses SVG to placeholder with icon label', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'svg',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\tmakeNode({\n\t\t\t\t\t\t\t\ttagName: 'path',\n\t\t\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\t\t\tattributes: { d: 'M0 0L10 10' },\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('<svg>icon</svg>');\n\t\t});\n\n\t\ttest('uses aria-label from SVG if available', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'svg',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tariaLabel: 'Search icon',\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('<svg>Search icon</svg>');\n\t\t});\n\n\t\ttest('finds title in nested SVG structure', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'svg',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\tmakeNode({\n\t\t\t\t\t\t\t\ttagName: 'title',\n\t\t\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\t\t\ttext: 'Close button',\n\t\t\t\t\t\t\t\tnodeType: 'element',\n\t\t\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('<svg>Close button</svg>');\n\t\t});\n\n\t\ttest('includes highlight index on interactive SVG', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'svg',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tisInteractive: true,\n\t\t\t\t\t\thighlightIndex: 5 as ElementRef,\n\t\t\t\t\t\tcssSelector: 'svg.icon',\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('[5]<svg>');\n\t\t});\n\n\t\ttest('does not collapse SVG when collapseSvg is disabled', () => {\n\t\t\tconst noCollapse = new TreeRenderer({\n\t\t\t\tcollapseSvg: false,\n\t\t\t\tfilterPaintOrder: false,\n\t\t\t});\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'svg',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [\n\t\t\t\t\t\t\tmakeNode({\n\t\t\t\t\t\t\t\ttagName: 'rect',\n\t\t\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\t\t}),\n\t\t\t\t\t\t],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = noCollapse.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\t// Should not be collapsed to a single <svg>icon</svg> placeholder\n\t\t\texpect(state.tree).toContain('<svg>');\n\t\t\t// Inner SVG elements (path, rect, etc.) are always skipped by the\n\t\t\t// SVG_TAGS filter, so they won't appear. The key difference is\n\t\t\t// collapseSvg=false does NOT produce the collapsed placeholder format.\n\t\t\texpect(state.tree).not.toContain('<svg>icon</svg>');\n\t\t});\n\t});\n\n\tdescribe('sibling deduplication', () => {\n\t\ttest('deduplicates runs of same-tag non-interactive siblings', () => {\n\t\t\t// Create 8 identical li elements (threshold = 5)\n\t\t\tconst listItems = Array.from({ length: 8 }, (_, i) =>\n\t\t\t\tmakeNode({\n\t\t\t\t\ttagName: 'li',\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\ttext: `Item ${i}`,\n\t\t\t\t\tchildren: [],\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'ul',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tchildren: listItems,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\t// Should show first 3 and then \"... and 5 more\" summary\n\t\t\texpect(state.tree).toContain('Item 0');\n\t\t\texpect(state.tree).toContain('Item 1');\n\t\t\texpect(state.tree).toContain('Item 2');\n\t\t\texpect(state.tree).toContain('... and 5 more <li> elements');\n\t\t\texpect(state.tree).not.toContain('Item 7');\n\t\t});\n\n\t\ttest('does not deduplicate when below threshold', () => {\n\t\t\tconst items = Array.from({ length: 3 }, (_, i) =>\n\t\t\t\tmakeNode({\n\t\t\t\t\ttagName: 'li',\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\ttext: `Item ${i}`,\n\t\t\t\t\tchildren: [],\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'ul',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tchildren: items,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\texpect(state.tree).toContain('Item 0');\n\t\t\texpect(state.tree).toContain('Item 1');\n\t\t\texpect(state.tree).toContain('Item 2');\n\t\t\texpect(state.tree).not.toContain('... and');\n\t\t});\n\n\t\ttest('does not deduplicate siblings with interactive descendants', () => {\n\t\t\tconst items = Array.from({ length: 8 }, (_, i) =>\n\t\t\t\tmakeNode({\n\t\t\t\t\ttagName: 'li',\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\tchildren: [\n\t\t\t\t\t\tmakeNode({\n\t\t\t\t\t\t\ttagName: 'a',\n\t\t\t\t\t\t\tisInteractive: i === 4, // one has interactive child\n\t\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\t\thighlightIndex: i === 4 ? (10 as ElementRef) : undefined,\n\t\t\t\t\t\t\ttext: `Link ${i}`,\n\t\t\t\t\t\t}),\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'ul',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tchildren: items,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\t// Because item 4 has an interactive descendant, the run is broken\n\t\t\t// and items should not all be deduped away\n\t\t\texpect(state.tree).toContain('Link 4');\n\t\t});\n\n\t\ttest('does not deduplicate when deduplicateSiblings is disabled', () => {\n\t\t\tconst noDedup = new TreeRenderer({\n\t\t\t\tdeduplicateSiblings: false,\n\t\t\t\tfilterPaintOrder: false,\n\t\t\t});\n\n\t\t\tconst items = Array.from({ length: 8 }, (_, i) =>\n\t\t\t\tmakeNode({\n\t\t\t\t\ttagName: 'li',\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\ttext: `Item ${i}`,\n\t\t\t\t\tchildren: [],\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'ul',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tchildren: items,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = noDedup.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).not.toContain('... and');\n\t\t\texpect(state.tree).toContain('Item 7');\n\t\t});\n\t});\n\n\tdescribe('max elements cap', () => {\n\t\ttest('truncates tree when max elements is exceeded', () => {\n\t\t\tconst small = new TreeRenderer({\n\t\t\t\tmaxElementsInDom: 5,\n\t\t\t\tfilterPaintOrder: false,\n\t\t\t\tdeduplicateSiblings: false,\n\t\t\t});\n\n\t\t\tconst children = Array.from({ length: 20 }, (_, i) =>\n\t\t\t\tmakeNode({\n\t\t\t\t\ttagName: 'p',\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\ttext: `Para ${i}`,\n\t\t\t\t\tchildren: [],\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'body',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tchildren,\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = small.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('DOM truncated at 5 elements');\n\t\t});\n\t});\n\n\tdescribe('containment threshold (redundant wrappers)', () => {\n\t\ttest('skips redundant div wrapper when child fills parent', () => {\n\t\t\tconst innerButton = makeNode({\n\t\t\t\ttagName: 'button',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: true,\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\tcssSelector: 'button',\n\t\t\t\ttext: 'Click',\n\t\t\t\trect: { x: 0, y: 0, width: 200, height: 50 },\n\t\t\t});\n\n\t\t\tconst wrapper = makeNode({\n\t\t\t\ttagName: 'div',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: false,\n\t\t\t\trect: { x: 0, y: 0, width: 200, height: 50 },\n\t\t\t\tchildren: [innerButton],\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [wrapper],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\t// The redundant div wrapper should be skipped in output;\n\t\t\t// the button should appear directly\n\t\t\texpect(state.tree).toContain('button');\n\t\t\texpect(state.tree).toContain('Click');\n\t\t});\n\n\t\ttest('does not skip wrapper when it has a highlightIndex', () => {\n\t\t\tconst inner = makeNode({\n\t\t\t\ttagName: 'span',\n\t\t\t\tisVisible: true,\n\t\t\t\ttext: 'Text',\n\t\t\t\trect: { x: 0, y: 0, width: 100, height: 20 },\n\t\t\t});\n\n\t\t\tconst wrapper = makeNode({\n\t\t\t\ttagName: 'div',\n\t\t\t\tisVisible: true,\n\t\t\t\thighlightIndex: 1 as ElementRef,\n\t\t\t\tcssSelector: 'div#parent',\n\t\t\t\trect: { x: 0, y: 0, width: 100, height: 20 },\n\t\t\t\tchildren: [inner],\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [wrapper],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('<div');\n\t\t});\n\n\t\ttest('does not skip non-generic tag wrappers', () => {\n\t\t\tconst inner = makeNode({\n\t\t\t\ttagName: 'p',\n\t\t\t\tisVisible: true,\n\t\t\t\ttext: 'Hello',\n\t\t\t\trect: { x: 0, y: 0, width: 100, height: 20 },\n\t\t\t});\n\n\t\t\tconst wrapper = makeNode({\n\t\t\t\ttagName: 'nav', // not in genericTags set\n\t\t\t\tisVisible: true,\n\t\t\t\trect: { x: 0, y: 0, width: 100, height: 20 },\n\t\t\t\tchildren: [inner],\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [wrapper],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('<nav');\n\t\t});\n\t});\n\n\tdescribe('off-screen element filtering', () => {\n\t\ttest('filters out elements with degenerate rects (zero area)', () => {\n\t\t\tconst zeroWidth = makeNode({\n\t\t\t\ttagName: 'button',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: true,\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\tcssSelector: 'button.hidden',\n\t\t\t\trect: { x: 0, y: 0, width: 0, height: 30 },\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [zeroWidth],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\t// Zero-width element should be filtered from the selector map\n\t\t\texpect(state.selectorMap[0]).toBeUndefined();\n\t\t});\n\n\t\ttest('filters out elements with extreme off-canvas positioning', () => {\n\t\t\tconst offCanvas = makeNode({\n\t\t\t\ttagName: 'a',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: true,\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\tcssSelector: 'a.sr-only',\n\t\t\t\trect: { x: -10000, y: 0, width: 100, height: 20 },\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [offCanvas],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.selectorMap[0]).toBeUndefined();\n\t\t});\n\n\t\ttest('keeps elements that are off-viewport but within document bounds', () => {\n\t\t\tconst belowViewport = makeNode({\n\t\t\t\ttagName: 'button',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: true,\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\tcssSelector: 'button.below',\n\t\t\t\trect: { x: 100, y: 2000, width: 100, height: 30 },\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [belowViewport],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\t// Should be kept in selector map even though off-viewport\n\t\t\texpect(state.selectorMap[0]).toBeDefined();\n\t\t\texpect(state.selectorMap[0].cssSelector).toBe('button.below');\n\t\t});\n\t});\n\n\tdescribe('hidden element hints formatting', () => {\n\t\ttest('formats hints for off-screen elements below viewport', () => {\n\t\t\tconst belowElement = makeNode({\n\t\t\t\ttagName: 'button',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: true,\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\tcssSelector: 'button.far',\n\t\t\t\tariaLabel: 'Load more',\n\t\t\t\trect: { x: 100, y: 2400, width: 100, height: 30 },\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [belowElement],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\texpect(state.tree).toContain('Off-screen interactive elements');\n\t\t\texpect(state.tree).toContain('Load more');\n\t\t\texpect(state.tree).toContain('pages below');\n\t\t});\n\n\t\ttest('formats hints for elements above viewport', () => {\n\t\t\tconst aboveElement = makeNode({\n\t\t\t\ttagName: 'a',\n\t\t\t\tisVisible: true,\n\t\t\t\tisInteractive: true,\n\t\t\t\thighlightIndex: 0 as ElementRef,\n\t\t\t\tcssSelector: 'a.header',\n\t\t\t\tariaLabel: 'Home link',\n\t\t\t\trect: { x: 100, y: 50, width: 100, height: 30 },\n\t\t\t});\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [aboveElement],\n\t\t\t});\n\n\t\t\t// Scrolled down so element is above\n\t\t\tconst state = serializer.serializeTree(\n\t\t\t\troot,\n\t\t\t\t{ x: 0, y: 1000 },\n\t\t\t\tdefaultViewport,\n\t\t\t\tdefaultDocSize,\n\t\t\t);\n\n\t\t\texpect(state.tree).toContain('Home link');\n\t\t\texpect(state.tree).toContain('pages above');\n\t\t});\n\n\t\ttest('limits hints to 15 off-screen elements', () => {\n\t\t\tconst children = Array.from({ length: 20 }, (_, i) =>\n\t\t\t\tmakeNode({\n\t\t\t\t\ttagName: 'button',\n\t\t\t\t\tisVisible: true,\n\t\t\t\t\tisInteractive: true,\n\t\t\t\t\thighlightIndex: i as ElementRef,\n\t\t\t\t\tcssSelector: `button.item-${i}`,\n\t\t\t\t\tariaLabel: `Button ${i}`,\n\t\t\t\t\trect: { x: 100, y: 2000 + i * 100, width: 100, height: 30 },\n\t\t\t\t}),\n\t\t\t);\n\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren,\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\n\t\t\t// Should cap at 15 and say \"... and N more\"\n\t\t\texpect(state.tree).toContain('more off-screen elements');\n\t\t});\n\t});\n\n\tdescribe('attributes serialization', () => {\n\t\ttest('includes configured attributes in output', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'input',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tattributes: {\n\t\t\t\t\t\t\tplaceholder: 'Enter email',\n\t\t\t\t\t\t\ttitle: 'Email field',\n\t\t\t\t\t\t},\n\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('placeholder=\"Enter email\"');\n\t\t\texpect(state.tree).toContain('title=\"Email field\"');\n\t\t});\n\n\t\ttest('includes role and aria-label from node properties', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'div',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\trole: 'navigation',\n\t\t\t\t\t\tariaLabel: 'Main menu',\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [makeTextNode('Menu')],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('role=\"navigation\"');\n\t\t\texpect(state.tree).toContain('aria-label=\"Main menu\"');\n\t\t});\n\n\t\ttest('includes input value in output', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'input',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tinputValue: 'current text',\n\t\t\t\t\t\tattributes: {},\n\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('value=\"current text\"');\n\t\t});\n\t});\n\n\tdescribe('text node handling', () => {\n\t\ttest('renders text content inline for leaf elements', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'p',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\ttext: 'Hello world',\n\t\t\t\t\t\tchildren: [],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('Hello world');\n\t\t});\n\n\t\ttest('renders text node children', () => {\n\t\t\tconst root = makeNode({\n\t\t\t\ttagName: 'html',\n\t\t\t\tchildren: [\n\t\t\t\t\tmakeNode({\n\t\t\t\t\t\ttagName: 'p',\n\t\t\t\t\t\tisVisible: true,\n\t\t\t\t\t\tchildren: [makeTextNode('Some text content')],\n\t\t\t\t\t}),\n\t\t\t\t],\n\t\t\t});\n\n\t\t\tconst state = serializer.serializeTree(root, defaultScroll, defaultViewport, defaultDocSize);\n\t\t\texpect(state.tree).toContain('Some text content');\n\t\t});\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/page/snapshot-builder.ts",
    "content": "import type { CDPSession } from 'playwright';\nimport type {\n\tCDPSnapshotResult,\n\tAXNode,\n\tPageTreeNode,\n\tDOMRect,\n} from './types.js';\nimport { type ElementRef, elementIndex } from '../types.js';\n\nconst INTERACTIVE_TAGS = new Set([\n\t'a', 'button', 'input', 'select', 'textarea', 'details', 'summary',\n\t'label', 'option', 'fieldset', 'legend',\n]);\n\nconst INTERACTIVE_ROLES = new Set([\n\t'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',\n\t'listbox', 'menu', 'menuitem', 'menuitemcheckbox', 'menuitemradio',\n\t'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',\n\t'treeitem', 'gridcell', 'columnheader', 'rowheader',\n]);\n\nconst INVISIBLE_TAGS = new Set([\n\t'script', 'style', 'link', 'meta', 'head', 'noscript', 'template',\n]);\n\nexport class SnapshotBuilder {\n\tprivate indexCounter = 0;\n\n\tasync captureSnapshot(cdpSession: CDPSession): Promise<{\n\t\tdomSnapshot: CDPSnapshotResult;\n\t\taxTree: AXNode;\n\t}> {\n\t\tconst [domSnapshot, axTree] = await Promise.all([\n\t\t\tcdpSession.send('DOMSnapshot.captureSnapshot', {\n\t\t\t\tcomputedStyles: ['display', 'visibility', 'opacity', 'overflow'],\n\t\t\t\tincludeDOMRects: true,\n\t\t\t\tincludePaintOrder: true,\n\t\t\t}) as Promise<unknown> as Promise<CDPSnapshotResult>,\n\t\t\tcdpSession.send('Accessibility.getFullAXTree', {}) as Promise<unknown> as Promise<{ nodes: AXNode[] }>,\n\t\t]);\n\n\t\t// Convert flat AX tree list to the root node\n\t\tconst rootAx: AXNode = axTree.nodes?.[0] ?? {\n\t\t\tnodeId: '0',\n\t\t\trole: { value: 'WebArea' },\n\t\t};\n\n\t\treturn { domSnapshot, axTree: rootAx };\n\t}\n\n\tbuildTree(\n\t\tsnapshot: CDPSnapshotResult,\n\t\taxTree: AXNode,\n\t\tviewportSize: { width: number; height: number },\n\t\tcapturedAttributes: string[] = [],\n\t): { root: PageTreeNode; indexCounter: number } {\n\t\tthis.indexCounter = 0;\n\t\tconst doc = snapshot.documents[0];\n\t\tif (!doc) {\n\t\t\treturn {\n\t\t\t\troot: this.createEmptyNode(),\n\t\t\t\tindexCounter: 0,\n\t\t\t};\n\t\t}\n\n\t\tconst { nodes, layout, strings } = doc;\n\n\t\t// Build backend node ID → AX node map\n\t\tconst axNodeMap = new Map<number, AXNode>();\n\t\tthis.buildAXMap(axTree, axNodeMap);\n\n\t\t// Build layout index map\n\t\tconst layoutMap = new Map<number, { bounds: number[]; text?: string; paintOrder?: number }>();\n\t\tfor (let i = 0; i < layout.nodeIndex.length; i++) {\n\t\t\tconst nodeIdx = layout.nodeIndex[i];\n\t\t\tlayoutMap.set(nodeIdx, {\n\t\t\t\tbounds: layout.bounds[i],\n\t\t\t\ttext: layout.text[i] !== -1 ? strings[layout.text[i]] : undefined,\n\t\t\t\tpaintOrder: layout.paintOrder?.[i],\n\t\t\t});\n\t\t}\n\n\t\t// Build clickable set\n\t\tconst clickableSet = new Set<number>();\n\t\tif (nodes.isClickable) {\n\t\t\tfor (const idx of nodes.isClickable.index) {\n\t\t\t\tclickableSet.add(idx);\n\t\t\t}\n\t\t}\n\n\t\t// Build input value map\n\t\tconst inputValueMap = new Map<number, string>();\n\t\tif (nodes.inputValue) {\n\t\t\tfor (let i = 0; i < nodes.inputValue.index.length; i++) {\n\t\t\t\tconst nodeIdx = nodes.inputValue.index[i];\n\t\t\t\tconst valueIdx = nodes.inputValue.value[i];\n\t\t\t\tinputValueMap.set(nodeIdx, strings[valueIdx]);\n\t\t\t}\n\t\t}\n\n\t\t// Build the tree recursively\n\t\tconst root = this.buildNodeTree(\n\t\t\t0,\n\t\t\tnodes,\n\t\t\tstrings,\n\t\t\tlayoutMap,\n\t\t\taxNodeMap,\n\t\t\tclickableSet,\n\t\t\tinputValueMap,\n\t\t\tviewportSize,\n\t\t\tcapturedAttributes,\n\t\t);\n\n\t\treturn { root, indexCounter: this.indexCounter };\n\t}\n\n\tprivate buildNodeTree(\n\t\tnodeIndex: number,\n\t\tnodes: CDPSnapshotResult['documents'][0]['nodes'],\n\t\tstrings: string[],\n\t\tlayoutMap: Map<number, { bounds: number[]; text?: string; paintOrder?: number }>,\n\t\taxNodeMap: Map<number, AXNode>,\n\t\tclickableSet: Set<number>,\n\t\tinputValueMap: Map<number, string>,\n\t\tviewportSize: { width: number; height: number },\n\t\tcapturedAttributes: string[],\n\t): PageTreeNode {\n\t\tconst nodeType = nodes.nodeType[nodeIndex];\n\t\tconst tagName = strings[nodes.nodeName[nodeIndex]]?.toLowerCase() ?? '';\n\t\tconst backendNodeId = nodes.backendNodeId[nodeIndex];\n\n\t\t// Check layout\n\t\tconst layoutInfo = layoutMap.get(nodeIndex);\n\t\tlet rect: DOMRect | undefined;\n\t\tlet isVisible = false;\n\n\t\tif (layoutInfo) {\n\t\t\tconst [x, y, w, h] = layoutInfo.bounds;\n\t\t\trect = { x, y, width: w, height: h };\n\t\t\tisVisible = w > 0 && h > 0 && !INVISIBLE_TAGS.has(tagName);\n\t\t}\n\n\t\t// Parse attributes\n\t\tconst rawAttrs = nodes.attributes[nodeIndex] ?? [];\n\t\tconst attributes: Record<string, string> = {};\n\t\tfor (let i = 0; i < rawAttrs.length; i += 2) {\n\t\t\tconst name = strings[rawAttrs[i]];\n\t\t\tconst value = strings[rawAttrs[i + 1]];\n\t\t\tif (name && (capturedAttributes.length === 0 || capturedAttributes.includes(name))) {\n\t\t\t\tattributes[name] = value ?? '';\n\t\t\t}\n\t\t}\n\n\t\t// Get AX info\n\t\tconst axNode = axNodeMap.get(backendNodeId);\n\t\tconst role = axNode?.role?.value;\n\t\tconst ariaLabel = axNode?.name?.value;\n\n\t\t// Determine interactivity\n\t\tconst isInteractive =\n\t\t\tINTERACTIVE_TAGS.has(tagName) ||\n\t\t\t(role ? INTERACTIVE_ROLES.has(role) : false) ||\n\t\t\tclickableSet.has(nodeIndex) ||\n\t\t\tattributes['tabindex'] !== undefined ||\n\t\t\tattributes['contenteditable'] === 'true';\n\n\t\tconst isEditable =\n\t\t\ttagName === 'input' ||\n\t\t\ttagName === 'textarea' ||\n\t\t\tattributes['contenteditable'] === 'true' ||\n\t\t\trole === 'textbox' ||\n\t\t\trole === 'searchbox';\n\n\t\tconst isScrollable =\n\t\t\ttagName === 'body' || tagName === 'html' || attributes['role'] === 'scrollbar';\n\n\t\t// Build node\n\t\tconst node: PageTreeNode = {\n\t\t\ttagName,\n\t\t\tnodeType: nodeType === 3 ? 'text' : 'element',\n\t\t\ttext: nodeType === 3 ? strings[nodes.nodeValue[nodeIndex]] : layoutInfo?.text,\n\t\t\tattributes,\n\t\t\tchildren: [],\n\t\t\tisVisible,\n\t\t\trect,\n\t\t\trole: role && role !== 'none' && role !== 'generic' ? role : undefined,\n\t\t\tariaLabel,\n\t\t\tisInteractive,\n\t\t\tisClickable: clickableSet.has(nodeIndex) || INTERACTIVE_TAGS.has(tagName),\n\t\t\tisEditable,\n\t\t\tisScrollable,\n\t\t\tbackendNodeId,\n\t\t\tpaintOrder: layoutInfo?.paintOrder,\n\t\t\tinputValue: inputValueMap.get(nodeIndex),\n\t\t};\n\n\t\t// Assign highlight index for interactive/visible elements\n\t\tif (isInteractive && isVisible) {\n\t\t\tnode.highlightIndex = elementIndex(this.indexCounter++);\n\t\t}\n\n\t\t// Build children\n\t\tconst childIndexes: number[] = nodes.childNodeIndexes?.[nodeIndex] ?? [];\n\t\tfor (const childIdx of childIndexes) {\n\t\t\tconst child = this.buildNodeTree(\n\t\t\t\tchildIdx,\n\t\t\t\tnodes,\n\t\t\t\tstrings,\n\t\t\t\tlayoutMap,\n\t\t\t\taxNodeMap,\n\t\t\t\tclickableSet,\n\t\t\t\tinputValueMap,\n\t\t\t\tviewportSize,\n\t\t\t\tcapturedAttributes,\n\t\t\t);\n\t\t\tchild.parentNode = node;\n\t\t\tnode.children.push(child);\n\t\t}\n\n\t\treturn node;\n\t}\n\n\tprivate buildAXMap(node: AXNode, map: Map<number, AXNode>): void {\n\t\tif (node.backendDOMNodeId) {\n\t\t\tmap.set(node.backendDOMNodeId, node);\n\t\t}\n\t\tif (node.children) {\n\t\t\tfor (const child of node.children) {\n\t\t\t\tthis.buildAXMap(child, map);\n\t\t\t}\n\t\t}\n\t}\n\n\tprivate createEmptyNode(): PageTreeNode {\n\t\treturn {\n\t\t\ttagName: 'html',\n\t\t\tnodeType: 'element',\n\t\t\tattributes: {},\n\t\t\tchildren: [],\n\t\t\tisVisible: false,\n\t\t\tisInteractive: false,\n\t\t\tisClickable: false,\n\t\t\tisEditable: false,\n\t\t\tisScrollable: false,\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/page/types.ts",
    "content": "import type { ElementRef } from '../types.js';\n\nexport interface DOMRect {\n\tx: number;\n\ty: number;\n\twidth: number;\n\theight: number;\n}\n\nexport interface TargetInfo {\n\ttargetId: string;\n\ttype: 'page' | 'iframe' | 'worker' | 'other';\n\turl: string;\n\ttitle?: string;\n\tattached: boolean;\n}\n\nexport interface TargetAllTrees {\n\tmainTree: PageTreeNode;\n\tiframeTrees: Array<{\n\t\ttargetInfo: TargetInfo;\n\t\ttree: PageTreeNode;\n\t\tparentNodeId?: number;\n\t}>;\n}\n\nexport interface InteractedElement {\n\tindex: ElementRef;\n\ttagName: string;\n\ttext?: string;\n\trole?: string;\n\tariaLabel?: string;\n\taction: string;\n\ttimestamp: number;\n}\n\nexport const MatchLevel = {\n\tEXACT: 'exact',\n\tPARTIAL: 'partial',\n\tFUZZY: 'fuzzy',\n\tNONE: 'none',\n} as const;\nexport type MatchLevel = (typeof MatchLevel)[keyof typeof MatchLevel];\n\nexport interface SimplifiedNode {\n\ttag: string;\n\ttext?: string;\n\tattrs: Record<string, string>;\n\tchildren: SimplifiedNode[];\n\tindex?: ElementRef;\n\tisInteractive: boolean;\n}\n\nexport interface PageTreeNode {\n\ttagName: string;\n\tnodeType: 'element' | 'text';\n\ttext?: string;\n\tattributes: Record<string, string>;\n\tchildren: PageTreeNode[];\n\n\t// Layout info\n\tisVisible: boolean;\n\trect?: DOMRect;\n\n\t// A11y info\n\trole?: string;\n\tariaLabel?: string;\n\tariaExpanded?: boolean;\n\n\t// Interaction info\n\tisInteractive: boolean;\n\tisClickable: boolean;\n\tisEditable: boolean;\n\tisScrollable: boolean;\n\n\t// Index for LLM reference\n\thighlightIndex?: ElementRef;\n\n\t// Parent reference (not serialized)\n\tparentNode?: PageTreeNode;\n\n\t// CDP node info\n\tbackendNodeId?: number;\n\tnodeId?: number;\n\n\t// Selector info\n\tcssSelector?: string;\n\txpath?: string;\n\n\t// Shadow DOM\n\tisShadowRoot?: boolean;\n\tshadowChildren?: PageTreeNode[];\n\n\t// Input state\n\tinputValue?: string;\n\tisChecked?: boolean;\n\tselectedOption?: string;\n\n\t// Paint order for z-index filtering\n\tpaintOrder?: number;\n}\n\nexport interface SelectorIndex {\n\t[index: number]: {\n\t\tcssSelector: string;\n\t\txpath?: string;\n\t\tbackendNodeId?: number;\n\t\ttagName: string;\n\t\trole?: string;\n\t\tariaLabel?: string;\n\t\ttext?: string;\n\t};\n}\n\nexport interface RenderedPageState {\n\ttree: string;\n\tselectorMap: SelectorIndex;\n\telementCount: number;\n\tinteractiveElementCount: number;\n\tscrollPosition: { x: number; y: number };\n\tviewportSize: { width: number; height: number };\n\tdocumentSize: { width: number; height: number };\n\tpixelsAbove: number;\n\tpixelsBelow: number;\n}\n\nexport interface CDPDOMNode {\n\tnodeType: number;\n\tnodeName: string;\n\tnodeValue: string;\n\tbackendNodeId: number;\n\tchildNodeIndexes?: number[];\n\tattributes?: string[];\n\tparentIndex?: number;\n\tcontentDocumentIndex?: number;\n\tshadowRootType?: string;\n\tisClickable?: boolean;\n\tinputValue?: { value: string; type?: string };\n\tcurrentSourceURL?: string;\n\ttextValue?: string;\n\tlayoutNodeIndex?: number;\n}\n\nexport interface CDPLayoutNode {\n\tnodeIndex: number;\n\tbounds: number[];\n\ttext?: string;\n\tstackingContexts?: { index: number }[];\n\tpaintOrder?: number;\n\tisStackingContext?: boolean;\n}\n\nexport interface CDPSnapshotResult {\n\tdocuments: Array<{\n\t\tnodes: {\n\t\t\tnodeType: number[];\n\t\t\tnodeName: number[];\n\t\t\tnodeValue: number[];\n\t\t\tbackendNodeId: number[];\n\t\t\tchildNodeIndexes?: number[][];\n\t\t\tattributes: Array<number[]>;\n\t\t\tparentIndex: number[];\n\t\t\tcontentDocumentIndex?: { index: number[] };\n\t\t\tshadowRootType?: { index: number[]; value: number[] };\n\t\t\tisClickable?: { index: number[] };\n\t\t\tinputValue?: { index: number[]; value: number[] };\n\t\t\tcurrentSourceURL?: { index: number[]; value: number[] };\n\t\t};\n\t\tlayout: {\n\t\t\tnodeIndex: number[];\n\t\t\tbounds: number[][];\n\t\t\ttext: number[];\n\t\t\tstackingContexts?: { index: number[] };\n\t\t\tpaintOrder?: number[];\n\t\t\tstyles: number[][];\n\t\t};\n\t\ttextBoxes: {\n\t\t\tlayoutIndex: number[];\n\t\t\tbounds: number[][];\n\t\t};\n\t\tstrings: string[];\n\t}>;\n}\n\nexport interface AXNode {\n\tnodeId: string;\n\trole: { value: string };\n\tname?: { value: string };\n\tdescription?: { value: string };\n\tvalue?: { value: string };\n\tproperties?: Array<{\n\t\tname: string;\n\t\tvalue: { value: unknown };\n\t}>;\n\tchildren?: AXNode[];\n\tbackendDOMNodeId?: number;\n\tignored?: boolean;\n}\n"
  },
  {
    "path": "packages/core/src/sandbox/file-access.ts",
    "content": "import * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport { createLogger } from '../logging.js';\n\nconst logger = createLogger('filesystem');\n\nconst ALLOWED_EXTENSIONS = new Set([\n\t'.txt', '.md', '.json', '.csv', '.html', '.xml', '.yaml', '.yml',\n\t'.js', '.ts', '.py', '.rb', '.go', '.rs', '.java', '.c', '.cpp',\n\t'.css', '.scss', '.less', '.svg', '.log', '.env', '.toml', '.ini',\n\t'.sh', '.bash', '.zsh', '.sql', '.graphql',\n]);\n\nconst MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB\n\nexport interface FileAccessOptions {\n\tsandboxDir: string;\n\tallowedExtensions?: Set<string>;\n\tmaxFileSize?: number;\n\treadOnly?: boolean;\n}\n\nexport interface FileInfo {\n\tname: string;\n\tpath: string;\n\tsize: number;\n\tisDirectory: boolean;\n\tmodifiedAt: Date;\n\textension: string;\n}\n\nexport interface FileAccessState {\n\tfiles: Map<string, FileInfo>;\n\ttotalSize: number;\n\toperationCount: number;\n}\n\nexport class FileAccess {\n\tprivate sandboxDir: string;\n\tprivate allowedExtensions: Set<string>;\n\tprivate maxFileSize: number;\n\tprivate readOnly: boolean;\n\tprivate state: FileAccessState;\n\n\tconstructor(options: FileAccessOptions) {\n\t\tthis.sandboxDir = path.resolve(options.sandboxDir);\n\t\tthis.allowedExtensions = options.allowedExtensions ?? ALLOWED_EXTENSIONS;\n\t\tthis.maxFileSize = options.maxFileSize ?? MAX_FILE_SIZE;\n\t\tthis.readOnly = options.readOnly ?? false;\n\n\t\tthis.state = {\n\t\t\tfiles: new Map(),\n\t\t\ttotalSize: 0,\n\t\t\toperationCount: 0,\n\t\t};\n\n\t\t// Ensure sandbox directory exists\n\t\tif (!fs.existsSync(this.sandboxDir)) {\n\t\t\tfs.mkdirSync(this.sandboxDir, { recursive: true });\n\t\t}\n\n\t\t// Index existing files\n\t\tthis.indexDirectory();\n\t}\n\n\tprivate indexDirectory(): void {\n\t\ttry {\n\t\t\tconst entries = fs.readdirSync(this.sandboxDir, { withFileTypes: true });\n\t\t\tfor (const entry of entries) {\n\t\t\t\tconst fullPath = path.join(this.sandboxDir, entry.name);\n\t\t\t\tif (entry.isFile()) {\n\t\t\t\t\tconst stat = fs.statSync(fullPath);\n\t\t\t\t\tthis.state.files.set(entry.name, {\n\t\t\t\t\t\tname: entry.name,\n\t\t\t\t\t\tpath: fullPath,\n\t\t\t\t\t\tsize: stat.size,\n\t\t\t\t\t\tisDirectory: false,\n\t\t\t\t\t\tmodifiedAt: stat.mtime,\n\t\t\t\t\t\textension: path.extname(entry.name).toLowerCase(),\n\t\t\t\t\t});\n\t\t\t\t\tthis.state.totalSize += stat.size;\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\tlogger.debug('Failed to index sandbox directory');\n\t\t}\n\t}\n\n\tprivate resolvePath(relativePath: string): string {\n\t\tconst resolved = path.resolve(this.sandboxDir, relativePath);\n\t\t// Prevent path traversal\n\t\tif (!resolved.startsWith(this.sandboxDir)) {\n\t\t\tthrow new Error(`Path traversal detected: ${relativePath}`);\n\t\t}\n\t\treturn resolved;\n\t}\n\n\tprivate validateExtension(filePath: string): void {\n\t\tconst ext = path.extname(filePath).toLowerCase();\n\t\tif (!this.allowedExtensions.has(ext)) {\n\t\t\tthrow new Error(\n\t\t\t\t`File extension \"${ext}\" is not allowed. Allowed: ${[...this.allowedExtensions].join(', ')}`,\n\t\t\t);\n\t\t}\n\t}\n\n\tprivate isBinaryFile(filePath: string): boolean {\n\t\tconst ext = path.extname(filePath).toLowerCase();\n\t\tconst binaryExts = new Set([\n\t\t\t'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp',\n\t\t\t'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',\n\t\t\t'.zip', '.tar', '.gz', '.7z', '.rar',\n\t\t\t'.exe', '.dll', '.so', '.dylib',\n\t\t\t'.mp3', '.mp4', '.avi', '.mkv', '.wav',\n\t\t\t'.woff', '.woff2', '.ttf', '.eot',\n\t\t]);\n\t\treturn binaryExts.has(ext);\n\t}\n\n\tasync read(relativePath: string): Promise<string> {\n\t\tconst fullPath = this.resolvePath(relativePath);\n\n\t\tif (!fs.existsSync(fullPath)) {\n\t\t\tthrow new Error(`File not found: ${relativePath}`);\n\t\t}\n\n\t\tif (this.isBinaryFile(fullPath)) {\n\t\t\tthrow new Error(`Cannot read binary file: ${relativePath}`);\n\t\t}\n\n\t\tconst stat = fs.statSync(fullPath);\n\t\tif (stat.size > this.maxFileSize) {\n\t\t\tthrow new Error(\n\t\t\t\t`File too large: ${(stat.size / 1024 / 1024).toFixed(1)}MB (max: ${(this.maxFileSize / 1024 / 1024).toFixed(1)}MB)`,\n\t\t\t);\n\t\t}\n\n\t\tthis.state.operationCount++;\n\t\tlogger.debug(`Read file: ${relativePath} (${stat.size} bytes)`);\n\t\treturn fs.readFileSync(fullPath, 'utf-8');\n\t}\n\n\tasync write(relativePath: string, content: string): Promise<void> {\n\t\tif (this.readOnly) {\n\t\t\tthrow new Error('File system is read-only');\n\t\t}\n\n\t\tconst fullPath = this.resolvePath(relativePath);\n\t\tthis.validateExtension(fullPath);\n\n\t\tconst contentSize = Buffer.byteLength(content, 'utf-8');\n\t\tif (contentSize > this.maxFileSize) {\n\t\t\tthrow new Error(`Content too large: ${(contentSize / 1024 / 1024).toFixed(1)}MB`);\n\t\t}\n\n\t\t// Ensure parent directory exists\n\t\tconst dir = path.dirname(fullPath);\n\t\tif (!fs.existsSync(dir)) {\n\t\t\tfs.mkdirSync(dir, { recursive: true });\n\t\t}\n\n\t\tfs.writeFileSync(fullPath, content, 'utf-8');\n\n\t\tconst info: FileInfo = {\n\t\t\tname: path.basename(relativePath),\n\t\t\tpath: fullPath,\n\t\t\tsize: contentSize,\n\t\t\tisDirectory: false,\n\t\t\tmodifiedAt: new Date(),\n\t\t\textension: path.extname(relativePath).toLowerCase(),\n\t\t};\n\n\t\tthis.state.files.set(relativePath, info);\n\t\tthis.state.totalSize += contentSize;\n\t\tthis.state.operationCount++;\n\t\tlogger.debug(`Wrote file: ${relativePath} (${contentSize} bytes)`);\n\t}\n\n\tasync list(relativeDir = '.'): Promise<FileInfo[]> {\n\t\tconst fullPath = this.resolvePath(relativeDir);\n\n\t\tif (!fs.existsSync(fullPath)) {\n\t\t\treturn [];\n\t\t}\n\n\t\tconst entries = fs.readdirSync(fullPath, { withFileTypes: true });\n\t\tconst result: FileInfo[] = [];\n\n\t\tfor (const entry of entries) {\n\t\t\tconst entryPath = path.join(fullPath, entry.name);\n\t\t\tconst stat = fs.statSync(entryPath);\n\t\t\tresult.push({\n\t\t\t\tname: entry.name,\n\t\t\t\tpath: entryPath,\n\t\t\t\tsize: stat.size,\n\t\t\t\tisDirectory: entry.isDirectory(),\n\t\t\t\tmodifiedAt: stat.mtime,\n\t\t\t\textension: path.extname(entry.name).toLowerCase(),\n\t\t\t});\n\t\t}\n\n\t\tthis.state.operationCount++;\n\t\treturn result;\n\t}\n\n\tasync delete(relativePath: string): Promise<void> {\n\t\tif (this.readOnly) {\n\t\t\tthrow new Error('File system is read-only');\n\t\t}\n\n\t\tconst fullPath = this.resolvePath(relativePath);\n\n\t\tif (!fs.existsSync(fullPath)) {\n\t\t\tthrow new Error(`File not found: ${relativePath}`);\n\t\t}\n\n\t\tconst stat = fs.statSync(fullPath);\n\t\tfs.unlinkSync(fullPath);\n\n\t\tthis.state.files.delete(relativePath);\n\t\tthis.state.totalSize -= stat.size;\n\t\tthis.state.operationCount++;\n\t\tlogger.debug(`Deleted file: ${relativePath}`);\n\t}\n\n\tasync exists(relativePath: string): Promise<boolean> {\n\t\tconst fullPath = this.resolvePath(relativePath);\n\t\treturn fs.existsSync(fullPath);\n\t}\n\n\tgetState(): FileAccessState {\n\t\treturn {\n\t\t\tfiles: new Map(this.state.files),\n\t\t\ttotalSize: this.state.totalSize,\n\t\t\toperationCount: this.state.operationCount,\n\t\t};\n\t}\n\n\tgetSandboxDir(): string {\n\t\treturn this.sandboxDir;\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/sandbox/index.ts",
    "content": "export { FileAccess, type FileAccessOptions, type FileInfo, type FileAccessState } from './file-access.js';\n"
  },
  {
    "path": "packages/core/src/telemetry.ts",
    "content": "import { createLogger } from './logging.js';\n\nconst logger = createLogger('perf');\n\nexport interface TimingResult<T> {\n\tresult: T;\n\tdurationMs: number;\n}\n\n/**\n * Wraps an async function to measure and log its execution time.\n * Returns the result along with timing information.\n */\nexport async function timed<T>(\n\tlabel: string,\n\tfn: () => Promise<T>,\n): Promise<TimingResult<T>> {\n\tconst start = performance.now();\n\ttry {\n\t\tconst result = await fn();\n\t\tconst durationMs = performance.now() - start;\n\t\tlogger.debug(`${label}: ${durationMs.toFixed(1)}ms`);\n\t\treturn { result, durationMs };\n\t} catch (error) {\n\t\tconst durationMs = performance.now() - start;\n\t\tlogger.debug(`${label}: FAILED after ${durationMs.toFixed(1)}ms`);\n\t\tthrow error;\n\t}\n}\n\n/**\n * Creates a decorator-style wrapper that times all calls to the provided function.\n */\nexport function withTiming<Args extends unknown[], R>(\n\tlabel: string,\n\tfn: (...args: Args) => Promise<R>,\n): (...args: Args) => Promise<R> {\n\treturn async (...args: Args): Promise<R> => {\n\t\tconst { result } = await timed(label, () => fn(...args));\n\t\treturn result;\n\t};\n}\n\n/**\n * Simple stopwatch for manual timing control.\n */\nexport class Stopwatch {\n\tprivate startTime: number;\n\tprivate splits: Array<{ label: string; timeMs: number }> = [];\n\n\tconstructor() {\n\t\tthis.startTime = performance.now();\n\t}\n\n\tsplit(label: string): number {\n\t\tconst elapsed = performance.now() - this.startTime;\n\t\tthis.splits.push({ label, timeMs: elapsed });\n\t\treturn elapsed;\n\t}\n\n\telapsed(): number {\n\t\treturn performance.now() - this.startTime;\n\t}\n\n\treset(): void {\n\t\tthis.startTime = performance.now();\n\t\tthis.splits = [];\n\t}\n\n\tgetSplits(): Array<{ label: string; timeMs: number }> {\n\t\treturn [...this.splits];\n\t}\n\n\tsummary(): string {\n\t\tconst lines = this.splits.map(\n\t\t\t(s) => `  ${s.label}: ${s.timeMs.toFixed(1)}ms`,\n\t\t);\n\t\tlines.push(`  total: ${this.elapsed().toFixed(1)}ms`);\n\t\treturn lines.join('\\n');\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/types.ts",
    "content": "import { z } from 'zod';\n\n// ── Branded types for compile-time safety ──\n\ndeclare const __brand: unique symbol;\ntype Brand<T, B extends string> = T & { readonly [__brand]: B };\n\nexport type TargetId = Brand<string, 'TargetId'>;\nexport type SessionId = Brand<string, 'SessionId'>;\nexport type ElementRef = Brand<number, 'ElementRef'>;\nexport type TabId = Brand<number, 'TabId'>;\n\nexport function targetId(id: string): TargetId {\n\treturn id as TargetId;\n}\n\nexport function sessionId(id: string): SessionId {\n\treturn id as SessionId;\n}\n\nexport function elementIndex(index: number): ElementRef {\n\treturn index as ElementRef;\n}\n\nexport function tabId(id: number): TabId {\n\treturn id as TabId;\n}\n\n// ── Result type for error handling ──\n\nexport type Result<T, E = Error> = { ok: true; value: T } | { ok: false; error: E };\n\nexport function ok<T>(value: T): Result<T, never> {\n\treturn { ok: true, value };\n}\n\nexport function err<E>(error: E): Result<never, E> {\n\treturn { ok: false, error };\n}\n\n// ── Position & geometry ──\n\nexport const PositionSchema = z.object({\n\tx: z.number(),\n\ty: z.number(),\n});\nexport type Position = z.infer<typeof PositionSchema>;\n\nexport const RectSchema = z.object({\n\tx: z.number(),\n\ty: z.number(),\n\twidth: z.number(),\n\theight: z.number(),\n});\nexport type Rect = z.infer<typeof RectSchema>;\n\n// ── Common enums ──\n\nexport const LogLevel = {\n\tDEBUG: 0,\n\tINFO: 1,\n\tWARN: 2,\n\tERROR: 3,\n} as const;\nexport type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];\n\n// ── Utility types ──\n\nexport type DeepPartial<T> = {\n\t[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];\n};\n\nexport type Awaitable<T> = T | Promise<T>;\n"
  },
  {
    "path": "packages/core/src/utils.ts",
    "content": "import { nanoid } from 'nanoid';\n\n// ── ID generation ──\n\nexport function generateId(size = 12): string {\n\treturn nanoid(size);\n}\n\n// ── URL matching ──\n\nexport function matchesUrlPattern(url: string, pattern: string): boolean {\n\tif (pattern === '*') return true;\n\n\ttry {\n\t\tconst urlObj = new URL(url);\n\t\tconst patternObj = new URL(pattern.includes('://') ? pattern : `https://${pattern}`);\n\n\t\tif (patternObj.hostname.startsWith('*.')) {\n\t\t\tconst baseDomain = patternObj.hostname.slice(2);\n\t\t\tif (!urlObj.hostname.endsWith(baseDomain) && urlObj.hostname !== baseDomain) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t} else if (urlObj.hostname !== patternObj.hostname) {\n\t\t\treturn false;\n\t\t}\n\n\t\tif (patternObj.pathname !== '/' && patternObj.pathname !== '/*') {\n\t\t\tconst patternPath = patternObj.pathname.replace(/\\*/g, '.*');\n\t\t\tconst regex = new RegExp(`^${patternPath}`);\n\t\t\tif (!regex.test(urlObj.pathname)) {\n\t\t\t\treturn false;\n\t\t\t}\n\t\t}\n\n\t\treturn true;\n\t} catch {\n\t\treturn url.includes(pattern);\n\t}\n}\n\nexport function isUrlPermitted(\n\turl: string,\n\tallowedUrls?: string[],\n\tblockedUrls?: string[],\n): boolean {\n\tif (blockedUrls?.some((pattern) => matchesUrlPattern(url, pattern))) {\n\t\treturn false;\n\t}\n\tif (allowedUrls && allowedUrls.length > 0) {\n\t\treturn allowedUrls.some((pattern) => matchesUrlPattern(url, pattern));\n\t}\n\treturn true;\n}\n\n// ── Text utilities ──\n\nexport function sanitizeText(text: string): string {\n\treturn text\n\t\t.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F\\x7F]/g, '')\n\t\t.replace(/\\s+/g, ' ')\n\t\t.trim();\n}\n\nexport function truncateText(text: string, maxLength: number, suffix = '...'): string {\n\tif (text.length <= maxLength) return text;\n\treturn text.slice(0, maxLength - suffix.length) + suffix;\n}\n\nexport function removeTags(html: string): string {\n\treturn html.replace(/<[^>]*>/g, '');\n}\n\n// ── Timing ──\n\nexport function sleep(ms: number): Promise<void> {\n\treturn new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport async function withDeadline<T>(\n\tpromise: Promise<T>,\n\tms: number,\n\tmessage = 'Operation timed out',\n): Promise<T> {\n\tconst timer = new Promise<never>((_, reject) =>\n\t\tsetTimeout(() => reject(new Error(message)), ms),\n\t);\n\treturn Promise.race([promise, timer]);\n}\n\nexport class Timer {\n\tprivate startTime: number;\n\n\tconstructor() {\n\t\tthis.startTime = Date.now();\n\t}\n\n\telapsed(): number {\n\t\treturn Date.now() - this.startTime;\n\t}\n\n\telapsedSeconds(): number {\n\t\treturn this.elapsed() / 1000;\n\t}\n\n\treset(): void {\n\t\tthis.startTime = Date.now();\n\t}\n}\n\n// ── Retry ──\n\nexport interface RetryOptions {\n\tmaxRetries: number;\n\tinitialDelayMs: number;\n\tmaxDelayMs: number;\n\tbackoffFactor: number;\n}\n\nconst DEFAULT_RETRY: RetryOptions = {\n\tmaxRetries: 3,\n\tinitialDelayMs: 1000,\n\tmaxDelayMs: 30000,\n\tbackoffFactor: 2,\n};\n\nexport async function withRetry<T>(\n\tfn: () => Promise<T>,\n\toptions: Partial<RetryOptions> = {},\n): Promise<T> {\n\tconst opts = { ...DEFAULT_RETRY, ...options };\n\tlet lastError: Error | undefined;\n\tlet delay = opts.initialDelayMs;\n\n\tfor (let attempt = 0; attempt <= opts.maxRetries; attempt++) {\n\t\ttry {\n\t\t\treturn await fn();\n\t\t} catch (error) {\n\t\t\tlastError = error instanceof Error ? error : new Error(String(error));\n\t\t\tif (attempt < opts.maxRetries) {\n\t\t\t\tawait sleep(Math.min(delay, opts.maxDelayMs));\n\t\t\t\tdelay *= opts.backoffFactor;\n\t\t\t}\n\t\t}\n\t}\n\n\tthrow lastError;\n}\n\n// ── Misc ──\n\nexport function groupBy<T, K extends string | number>(\n\titems: T[],\n\tkeyFn: (item: T) => K,\n): Record<K, T[]> {\n\treturn items.reduce(\n\t\t(acc, item) => {\n\t\t\tconst key = keyFn(item);\n\t\t\t(acc[key] ??= []).push(item);\n\t\t\treturn acc;\n\t\t},\n\t\t{} as Record<K, T[]>,\n\t);\n}\n\nexport function dedent(str: string): string {\n\tconst lines = str.split('\\n');\n\tif (lines[0]?.trim() === '') lines.shift();\n\tif (lines[lines.length - 1]?.trim() === '') lines.pop();\n\n\tconst minIndent = lines\n\t\t.filter((line) => line.trim().length > 0)\n\t\t.reduce((min, line) => {\n\t\t\tconst match = line.match(/^(\\s*)/);\n\t\t\treturn Math.min(min, match ? match[1].length : 0);\n\t\t}, Number.POSITIVE_INFINITY);\n\n\tif (minIndent === Number.POSITIVE_INFINITY) return str;\n\treturn lines.map((line) => line.slice(minIndent)).join('\\n');\n}\n\n// ── URL utilities ──\n\n/**\n * Match a URL against a domain pattern like \"*.example.com\" or \"example.com/path/*\".\n * More comprehensive than matchesUrlPattern — handles port stripping, www normalization.\n */\nexport function matchUrlWithDomainPattern(url: string, pattern: string): boolean {\n\ttry {\n\t\tconst urlObj = new URL(url);\n\t\tconst urlHost = urlObj.hostname.replace(/^www\\./, '');\n\n\t\t// Pattern can be a plain domain, wildcard domain, or full URL pattern\n\t\tif (pattern.startsWith('*.')) {\n\t\t\tconst base = pattern.slice(2);\n\t\t\treturn urlHost === base || urlHost.endsWith(`.${base}`);\n\t\t}\n\n\t\t// Try parsing as URL\n\t\tconst patternHost = pattern.includes('://')\n\t\t\t? new URL(pattern).hostname.replace(/^www\\./, '')\n\t\t\t: pattern.replace(/^www\\./, '').split('/')[0];\n\n\t\treturn urlHost === patternHost;\n\t} catch {\n\t\treturn url.includes(pattern);\n\t}\n}\n\nconst NEW_TAB_URLS = new Set([\n\t'about:blank',\n\t'about:newtab',\n\t'chrome://newtab/',\n\t'chrome://new-tab-page/',\n\t'edge://newtab/',\n\t'about:home',\n]);\n\nexport function isNewTabPage(url: string): boolean {\n\treturn NEW_TAB_URLS.has(url) || url === '' || url === 'about:blank';\n}\n\n/**\n * Remove unpaired surrogates from a string to prevent JSON serialization issues.\n */\nexport function sanitizeSurrogates(text: string): string {\n\treturn text.replace(\n\t\t/[\\uD800-\\uDBFF](?![\\uDC00-\\uDFFF])|(?<![\\uD800-\\uDBFF])[\\uDC00-\\uDFFF]/g,\n\t\t'\\uFFFD',\n\t);\n}\n\nconst URL_REGEX = /https?:\\/\\/[^\\s<>\"{}|\\\\^`\\[\\]]+/g;\n\n/**\n * Extract all URLs from a text string.\n */\nexport function extractUrls(text: string): string[] {\n\treturn [...text.matchAll(URL_REGEX)].map((m) => m[0]);\n}\n\n/**\n * Escape special regex characters in a string.\n */\nexport function escapeRegExp(string: string): string {\n\treturn string.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n}\n"
  },
  {
    "path": "packages/core/src/viewport/event-hub.ts",
    "content": "type Handler<T = unknown> = (payload: T) => void;\ntype RequestHandler<Req = unknown, Res = unknown> = (payload: Req) => Promise<Res>;\n\nexport class EventHub<\n\tEventMap extends { [K in keyof EventMap]: EventMap[K] } = Record<string, unknown>,\n\tRequestMap extends { [K in keyof RequestMap]: { request: unknown; response: unknown } } = Record<\n\t\tstring,\n\t\t{ request: unknown; response: unknown }\n\t>,\n> {\n\tprivate handlers = new Map<string, Set<Handler>>();\n\tprivate requestHandlers = new Map<string, RequestHandler>();\n\tprivate history: Array<{ event: string; payload: unknown; timestamp: number }> = [];\n\tprivate maxHistory: number;\n\n\tconstructor(options?: { maxHistory?: number }) {\n\t\tthis.maxHistory = options?.maxHistory ?? 100;\n\t}\n\n\ton<K extends keyof EventMap & string>(event: K, handler: Handler<EventMap[K]>): () => void {\n\t\tif (!this.handlers.has(event)) {\n\t\t\tthis.handlers.set(event, new Set());\n\t\t}\n\t\tthis.handlers.get(event)!.add(handler as Handler);\n\n\t\treturn () => {\n\t\t\tthis.handlers.get(event)?.delete(handler as Handler);\n\t\t};\n\t}\n\n\tonce<K extends keyof EventMap & string>(event: K, handler: Handler<EventMap[K]>): () => void {\n\t\tconst wrappedHandler: Handler<EventMap[K]> = (payload) => {\n\t\t\toff();\n\t\t\thandler(payload);\n\t\t};\n\t\tconst off = this.on(event, wrappedHandler);\n\t\treturn off;\n\t}\n\n\temit<K extends keyof EventMap & string>(event: K, payload: EventMap[K]): void {\n\t\tthis.recordHistory(event, payload);\n\t\tconst handlers = this.handlers.get(event);\n\t\tif (handlers) {\n\t\t\tfor (const handler of handlers) {\n\t\t\t\ttry {\n\t\t\t\t\thandler(payload);\n\t\t\t\t} catch (error) {\n\t\t\t\t\tconsole.error(`Error in event handler for \"${event}\":`, error);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tonRequest<K extends keyof RequestMap & string>(\n\t\tevent: K,\n\t\thandler: RequestHandler<RequestMap[K]['request'], RequestMap[K]['response']>,\n\t): () => void {\n\t\tthis.requestHandlers.set(event, handler as RequestHandler);\n\t\treturn () => {\n\t\t\tthis.requestHandlers.delete(event);\n\t\t};\n\t}\n\n\tasync request<K extends keyof RequestMap & string>(\n\t\tevent: K,\n\t\tpayload: RequestMap[K]['request'],\n\t\ttimeoutMs = 30000,\n\t): Promise<RequestMap[K]['response']> {\n\t\tconst handler = this.requestHandlers.get(event);\n\t\tif (!handler) {\n\t\t\tthrow new Error(`No handler registered for request \"${event}\"`);\n\t\t}\n\n\t\tconst result = await Promise.race([\n\t\t\thandler(payload),\n\t\t\tnew Promise<never>((_, reject) =>\n\t\t\t\tsetTimeout(() => reject(new Error(`Request \"${event}\" timed out after ${timeoutMs}ms`)), timeoutMs),\n\t\t\t),\n\t\t]);\n\n\t\treturn result as RequestMap[K]['response'];\n\t}\n\n\toff<K extends keyof EventMap & string>(event: K, handler?: Handler<EventMap[K]>): void {\n\t\tif (handler) {\n\t\t\tthis.handlers.get(event)?.delete(handler as Handler);\n\t\t} else {\n\t\t\tthis.handlers.delete(event);\n\t\t}\n\t}\n\n\tremoveAllListeners(): void {\n\t\tthis.handlers.clear();\n\t\tthis.requestHandlers.clear();\n\t}\n\n\tgetHistory(event?: string): Array<{ event: string; payload: unknown; timestamp: number }> {\n\t\tif (event) {\n\t\t\treturn this.history.filter((h) => h.event === event);\n\t\t}\n\t\treturn [...this.history];\n\t}\n\n\tclearHistory(): void {\n\t\tthis.history = [];\n\t}\n\n\tprivate recordHistory(event: string, payload: unknown): void {\n\t\tthis.history.push({ event, payload, timestamp: Date.now() });\n\t\tif (this.history.length > this.maxHistory) {\n\t\t\tthis.history = this.history.slice(-this.maxHistory);\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/events.ts",
    "content": "import type { ElementRef } from '../types.js';\n\n// ── Event payload types ──\n\nexport interface NavigateEvent {\n\turl: string;\n\twaitUntil?: 'load' | 'domcontentloaded' | 'networkidle';\n}\n\nexport interface ClickEvent {\n\telementIndex: ElementRef;\n\tclickCount?: number;\n}\n\nexport interface InputEvent {\n\telementIndex: ElementRef;\n\ttext: string;\n\tclearFirst?: boolean;\n}\n\nexport interface SelectOptionEvent {\n\telementIndex: ElementRef;\n\tvalue: string;\n}\n\nexport interface ScrollEvent {\n\tdirection: 'up' | 'down';\n\tamount?: number;\n\telementIndex?: ElementRef;\n}\n\nexport interface ScreenshotEvent {\n\tfullPage?: boolean;\n}\n\nexport interface ScreenshotResult {\n\tbase64: string;\n\twidth: number;\n\theight: number;\n}\n\nexport interface TabSwitchEvent {\n\ttabIndex: number;\n}\n\nexport interface FileUploadEvent {\n\telementIndex: ElementRef;\n\tfilePaths: string[];\n}\n\nexport interface KeyPressEvent {\n\tkey: string;\n}\n\nexport interface BrowserStateEvent {\n\turl: string;\n\ttitle: string;\n\ttabCount: number;\n}\n\nexport interface DownloadEvent {\n\turl: string;\n\tsuggestedFilename: string;\n\tpath?: string;\n}\n\nexport interface PopupEvent {\n\turl: string;\n\ttype: 'popup' | 'dialog';\n}\n\nexport interface SecurityEvent {\n\ttype: 'navigation-blocked' | 'download-blocked' | 'popup-blocked';\n\turl: string;\n\treason: string;\n}\n\nexport interface CrashEvent {\n\treason: string;\n}\n\n// ── Event map ──\n\nexport interface ViewportEventMap {\n\t'navigation': NavigateEvent;\n\t'click': ClickEvent;\n\t'input': InputEvent;\n\t'selection': SelectOptionEvent;\n\t'scroll': ScrollEvent;\n\t'capture': ScreenshotEvent;\n\t'capture-result': ScreenshotResult;\n\t'tab-changed': TabSwitchEvent;\n\t'tab-closed': { tabIndex: number };\n\t'tab-opened': { url: string };\n\t'file-uploaded': FileUploadEvent;\n\t'keystroke': KeyPressEvent;\n\t'viewport-state': BrowserStateEvent;\n\t'download': DownloadEvent;\n\t'popup': PopupEvent;\n\t'policy-violation': SecurityEvent;\n\t'crash': CrashEvent;\n\t'page-ready': { url: string };\n\t'content-ready': void;\n\t'shutdown': void;\n}\n\n// ── Request-response event map ──\n\nexport interface ViewportRequestMap {\n\t'get-screenshot': { request: ScreenshotEvent; response: ScreenshotResult };\n\t'get-state': { request: void; response: BrowserStateEvent };\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guard-base.ts",
    "content": "import type { Page, BrowserContext } from 'playwright';\nimport type { EventHub } from './event-hub.js';\nimport type { ViewportEventMap, ViewportRequestMap } from './events.js';\n\nexport interface GuardContext {\n\tpage: Page;\n\tcontext: BrowserContext;\n\teventBus: EventHub<ViewportEventMap, ViewportRequestMap>;\n}\n\n/**\n * Base class for browser watchdogs that monitor and react to browser events.\n * Each watchdog handles a specific concern (security, popups, downloads, etc.).\n */\nexport abstract class BaseGuard {\n\tprotected ctx!: GuardContext;\n\tprotected cleanupFns: Array<() => void> = [];\n\tprivate _active = false;\n\n\tget active(): boolean {\n\t\treturn this._active;\n\t}\n\n\tabstract readonly name: string;\n\tabstract readonly priority: number;\n\n\tasync attach(ctx: GuardContext): Promise<void> {\n\t\tthis.ctx = ctx;\n\t\tthis._active = true;\n\t\tawait this.setup();\n\t}\n\n\tasync detach(): Promise<void> {\n\t\tthis._active = false;\n\t\tfor (const cleanup of this.cleanupFns) {\n\t\t\ttry {\n\t\t\t\tcleanup();\n\t\t\t} catch {\n\t\t\t\t// Ignore cleanup errors\n\t\t\t}\n\t\t}\n\t\tthis.cleanupFns = [];\n\t\tawait this.teardown();\n\t}\n\n\tprotected abstract setup(): Promise<void>;\n\n\tprotected async teardown(): Promise<void> {\n\t\t// Override if needed\n\t}\n\n\tprotected onEvent<K extends keyof ViewportEventMap & string>(\n\t\tevent: K,\n\t\thandler: (payload: ViewportEventMap[K]) => void,\n\t): void {\n\t\tconst off = this.ctx.eventBus.on(event, handler);\n\t\tthis.cleanupFns.push(off);\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/blank-page.ts",
    "content": "import { BaseGuard } from '../guard-base.js';\n\n/**\n * Handles about:blank pages. If the page navigates to about:blank,\n * attempts to navigate back to the previous page.\n */\nexport class BlankPageGuard extends BaseGuard {\n\treadonly name = 'about-blank';\n\treadonly priority = 400;\n\n\tprotected async setup(): Promise<void> {\n\t\tconst handler = () => {\n\t\t\tconst url = this.ctx.page.url();\n\t\t\tif (url === 'about:blank') {\n\t\t\t\tthis.ctx.page.goBack().catch(() => {\n\t\t\t\t\t// Cannot go back; ignore\n\t\t\t\t});\n\t\t\t}\n\t\t};\n\n\t\tthis.ctx.page.on('framenavigated', handler);\n\t\tthis.cleanupFns.push(() => this.ctx.page.off('framenavigated', handler));\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/crash.ts",
    "content": "import { BaseGuard } from '../guard-base.js';\n\n/**\n * Monitors for browser page crashes. Emits crash events\n * and attempts recovery by creating a new page.\n */\nexport class CrashGuard extends BaseGuard {\n\treadonly name = 'crash';\n\treadonly priority = 500;\n\n\tprotected async setup(): Promise<void> {\n\t\tconst handler = () => {\n\t\t\tthis.ctx.eventBus.emit('crash', {\n\t\t\t\treason: 'Page crashed unexpectedly',\n\t\t\t});\n\n\t\t\t// Attempt recovery by creating a new page\n\t\t\tthis.ctx.context\n\t\t\t\t.newPage()\n\t\t\t\t.then((newPage) => {\n\t\t\t\t\tthis.ctx.page = newPage;\n\t\t\t\t})\n\t\t\t\t.catch(() => {\n\t\t\t\t\t// Recovery failed; context may be closed\n\t\t\t\t});\n\t\t};\n\n\t\tthis.ctx.page.on('crash', handler);\n\t\tthis.cleanupFns.push(() => this.ctx.page.off('crash', handler));\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/default-handler.ts",
    "content": "import type { Dialog } from 'playwright';\nimport { BaseGuard } from '../guard-base.js';\n\n/**\n * Monitors for default browser actions that need to be handled,\n * such as catching unhandled dialogs and auto-dismissing them.\n */\nexport class DefaultHandlerGuard extends BaseGuard {\n\treadonly name = 'default-action';\n\treadonly priority = 100;\n\n\tprotected async setup(): Promise<void> {\n\t\tconst handler = async (dialog: Dialog) => {\n\t\t\tthis.ctx.eventBus.emit('popup', {\n\t\t\t\turl: this.ctx.page.url(),\n\t\t\t\ttype: 'dialog',\n\t\t\t});\n\t\t\ttry {\n\t\t\t\tawait dialog.accept();\n\t\t\t} catch {\n\t\t\t\t// Dialog may already be dismissed\n\t\t\t}\n\t\t};\n\n\t\tthis.ctx.page.on('dialog', handler);\n\t\tthis.cleanupFns.push(() => this.ctx.page.off('dialog', handler));\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/downloads.ts",
    "content": "import type { Download } from 'playwright';\nimport * as fs from 'node:fs';\nimport * as path from 'node:path';\nimport * as crypto from 'node:crypto';\nimport { BaseGuard } from '../guard-base.js';\nimport { createLogger } from '../../logging.js';\n\nconst logger = createLogger('watchdog:downloads');\n\n// ── Options ──\n\nexport interface DownloadGuardOptions {\n\t/** Directory to save downloads to. Defaults to OS temp directory. */\n\tdownloadsPath?: string;\n\t/** Automatically accept all downloads without prompting. Defaults to true. */\n\tautoAccept?: boolean;\n\t/** Settings for PDF printing when a page triggers a print-to-PDF download. */\n\tpdfSettings?: {\n\t\tprintBackground: boolean;\n\t\tlandscape: boolean;\n\t};\n}\n\n// ── Download tracking ──\n\nexport type DownloadStatus = 'started' | 'completed' | 'failed';\n\nexport interface DownloadInfo {\n\turl: string;\n\tsuggestedFilename: string;\n\tsavedPath?: string;\n\tstatus: DownloadStatus;\n\tstartTime: number;\n\tendTime?: number;\n\tfileSize?: number;\n}\n\n// ── Watchdog ──\n\n/**\n * Monitors for file downloads with full lifecycle tracking.\n *\n * Features:\n * - Configures CDP download behavior for reliable acceptance\n * - Tracks every download from start to completion/failure\n * - Deduplicates filenames with UUID suffixes when collisions occur\n * - Provides download history and a promise-based wait API\n */\nexport class DownloadGuard extends BaseGuard {\n\treadonly name = 'downloads';\n\treadonly priority = 300;\n\n\tprivate readonly options: Required<DownloadGuardOptions>;\n\tprivate readonly downloads = new Map<string, DownloadInfo>();\n\tprivate downloadCounter = 0;\n\n\t/**\n\t * Listeners waiting for the next download to complete.\n\t * Each call to `waitForDownload` pushes a resolver here;\n\t * it is removed once a download completes or the timeout fires.\n\t */\n\tprivate pendingWaiters: Array<{\n\t\tresolve: (info: DownloadInfo) => void;\n\t\treject: (err: Error) => void;\n\t\ttimer: ReturnType<typeof setTimeout>;\n\t}> = [];\n\n\tconstructor(options?: DownloadGuardOptions) {\n\t\tsuper();\n\t\tconst defaultPath = path.join(\n\t\t\t(typeof process !== 'undefined' && process.env.TMPDIR) || '/tmp',\n\t\t\t'open-browser-downloads',\n\t\t);\n\t\tthis.options = {\n\t\t\tdownloadsPath: options?.downloadsPath ?? defaultPath,\n\t\t\tautoAccept: options?.autoAccept ?? true,\n\t\t\tpdfSettings: options?.pdfSettings ?? {\n\t\t\t\tprintBackground: true,\n\t\t\t\tlandscape: false,\n\t\t\t},\n\t\t};\n\t}\n\n\t// ── Setup / Teardown ──\n\n\tprotected async setup(): Promise<void> {\n\t\t// Ensure the downloads directory exists.\n\t\tthis.ensureDownloadsDir();\n\n\t\t// Try to enable CDP-level auto-accept so the browser never shows a\n\t\t// \"Save As\" dialog, even for cross-origin downloads.\n\t\tawait this.configureCdpDownloadBehavior();\n\n\t\t// Listen for Playwright download events on the page.\n\t\tconst handler = (download: Download) => {\n\t\t\tthis.handleDownload(download).catch((err) => {\n\t\t\t\tlogger.error('Unhandled error processing download', err);\n\t\t\t});\n\t\t};\n\n\t\tthis.ctx.page.on('download', handler);\n\t\tthis.cleanupFns.push(() => this.ctx.page.off('download', handler));\n\n\t\tlogger.debug(`Downloads watchdog active – saving to ${this.options.downloadsPath}`);\n\t}\n\n\tprotected async teardown(): Promise<void> {\n\t\t// Reject any pending waiters so they don't hang forever.\n\t\tfor (const waiter of this.pendingWaiters) {\n\t\t\tclearTimeout(waiter.timer);\n\t\t\twaiter.reject(new Error('DownloadGuard detached before download completed'));\n\t\t}\n\t\tthis.pendingWaiters = [];\n\t\tlogger.debug('Downloads watchdog detached');\n\t}\n\n\t// ── CDP configuration ──\n\n\tprivate async configureCdpDownloadBehavior(): Promise<void> {\n\t\tif (!this.options.autoAccept) return;\n\n\t\ttry {\n\t\t\tconst cdpSession = await this.ctx.page.context().newCDPSession(this.ctx.page);\n\t\t\tawait (cdpSession.send('Page.setDownloadBehavior', {\n\t\t\t\tbehavior: 'allow',\n\t\t\t\tdownloadPath: this.options.downloadsPath,\n\t\t\t}) as Promise<unknown> as Promise<void>);\n\n\t\t\tthis.cleanupFns.push(() => {\n\t\t\t\tcdpSession.detach().catch(() => {\n\t\t\t\t\t// Session may already be closed.\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tlogger.debug('CDP download behavior set to \"allow\"');\n\t\t} catch (err) {\n\t\t\t// CDP may not be available (e.g. Firefox). Fall back to Playwright-only handling.\n\t\t\tlogger.warn('Could not set CDP download behavior – falling back to Playwright handling', err);\n\t\t}\n\t}\n\n\t// ── Download handler ──\n\n\tprivate async handleDownload(download: Download): Promise<void> {\n\t\tconst id = `dl_${++this.downloadCounter}`;\n\t\tconst suggestedFilename = download.suggestedFilename();\n\t\tconst url = download.url();\n\n\t\tconst info: DownloadInfo = {\n\t\t\turl,\n\t\t\tsuggestedFilename,\n\t\t\tstatus: 'started',\n\t\t\tstartTime: Date.now(),\n\t\t};\n\t\tthis.downloads.set(id, info);\n\n\t\tlogger.info(`Download started: ${suggestedFilename} (${url})`);\n\n\t\t// Emit the initial event so consumers know a download has begun.\n\t\tthis.ctx.eventBus.emit('download', {\n\t\t\turl,\n\t\t\tsuggestedFilename,\n\t\t});\n\n\t\ttry {\n\t\t\tconst destPath = this.resolveUniquePath(suggestedFilename);\n\n\t\t\t// Save the file to our chosen path.\n\t\t\tawait download.saveAs(destPath);\n\n\t\t\t// Gather file size.\n\t\t\tlet fileSize: number | undefined;\n\t\t\ttry {\n\t\t\t\tconst stat = fs.statSync(destPath);\n\t\t\t\tfileSize = stat.size;\n\t\t\t} catch {\n\t\t\t\t// File may have been moved/deleted by another process.\n\t\t\t}\n\n\t\t\tinfo.savedPath = destPath;\n\t\t\tinfo.status = 'completed';\n\t\t\tinfo.endTime = Date.now();\n\t\t\tinfo.fileSize = fileSize;\n\n\t\t\tconst elapsed = info.endTime - info.startTime;\n\t\t\tlogger.info(\n\t\t\t\t`Download completed: ${suggestedFilename} → ${destPath} (${formatBytes(fileSize)} in ${elapsed}ms)`,\n\t\t\t);\n\n\t\t\t// Emit a follow-up download event with the saved path.\n\t\t\tthis.ctx.eventBus.emit('download', {\n\t\t\t\turl,\n\t\t\t\tsuggestedFilename,\n\t\t\t\tpath: destPath,\n\t\t\t});\n\n\t\t\t// Resolve any pending waiters.\n\t\t\tthis.notifyWaiters(info);\n\t\t} catch (err) {\n\t\t\tinfo.status = 'failed';\n\t\t\tinfo.endTime = Date.now();\n\n\t\t\tconst reason = err instanceof Error ? err.message : String(err);\n\t\t\tlogger.error(`Download failed: ${suggestedFilename} – ${reason}`);\n\t\t}\n\t}\n\n\t// ── Filename collision handling ──\n\n\t/**\n\t * Returns a path inside the downloads directory. If a file with the same\n\t * name already exists, a short UUID is inserted before the extension.\n\t */\n\tprivate resolveUniquePath(suggestedFilename: string): string {\n\t\tconst candidate = path.join(this.options.downloadsPath, suggestedFilename);\n\n\t\tif (!fs.existsSync(candidate)) {\n\t\t\treturn candidate;\n\t\t}\n\n\t\tconst ext = path.extname(suggestedFilename);\n\t\tconst base = path.basename(suggestedFilename, ext);\n\t\tconst uuid = crypto.randomUUID().slice(0, 8);\n\t\tconst uniqueName = `${base}-${uuid}${ext}`;\n\n\t\tlogger.debug(`File \"${suggestedFilename}\" already exists – saving as \"${uniqueName}\"`);\n\t\treturn path.join(this.options.downloadsPath, uniqueName);\n\t}\n\n\t// ── Directory helpers ──\n\n\tprivate ensureDownloadsDir(): void {\n\t\tif (!fs.existsSync(this.options.downloadsPath)) {\n\t\t\tfs.mkdirSync(this.options.downloadsPath, { recursive: true });\n\t\t\tlogger.debug(`Created downloads directory: ${this.options.downloadsPath}`);\n\t\t}\n\t}\n\n\t// ── Public API ──\n\n\t/**\n\t * Returns a snapshot of all tracked downloads (both in-progress and finished).\n\t */\n\tgetDownloadHistory(): DownloadInfo[] {\n\t\treturn Array.from(this.downloads.values());\n\t}\n\n\t/**\n\t * Returns a promise that resolves with the `DownloadInfo` of the next\n\t * download that completes (or rejects after `timeout` ms).\n\t *\n\t * @param timeout Maximum milliseconds to wait. Defaults to 30 000 ms.\n\t */\n\twaitForDownload(timeout = 30_000): Promise<DownloadInfo> {\n\t\treturn new Promise<DownloadInfo>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.removePendingWaiter(waiter);\n\t\t\t\treject(new Error(`waitForDownload timed out after ${timeout}ms`));\n\t\t\t}, timeout);\n\n\t\t\tconst waiter = { resolve, reject, timer };\n\t\t\tthis.pendingWaiters.push(waiter);\n\t\t});\n\t}\n\n\t// ── Waiter helpers ──\n\n\tprivate notifyWaiters(info: DownloadInfo): void {\n\t\tconst waiters = this.pendingWaiters.splice(0);\n\t\tfor (const waiter of waiters) {\n\t\t\tclearTimeout(waiter.timer);\n\t\t\twaiter.resolve(info);\n\t\t}\n\t}\n\n\tprivate removePendingWaiter(waiter: (typeof this.pendingWaiters)[number]): void {\n\t\tconst idx = this.pendingWaiters.indexOf(waiter);\n\t\tif (idx !== -1) {\n\t\t\tthis.pendingWaiters.splice(idx, 1);\n\t\t}\n\t}\n}\n\n// ── Helpers ──\n\nfunction formatBytes(bytes: number | undefined): string {\n\tif (bytes == null) return '? bytes';\n\tif (bytes < 1024) return `${bytes} B`;\n\tif (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n\treturn `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/har-capture.ts",
    "content": "import { writeFile, mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport type { CDPSession } from 'playwright';\nimport { BaseGuard } from '../guard-base.js';\n\n// ── HAR 1.2 types ──\n\ninterface HarRequest {\n\tmethod: string;\n\turl: string;\n\thttpVersion: string;\n\theaders: Array<{ name: string; value: string }>;\n\tqueryString: Array<{ name: string; value: string }>;\n\theadersSize: number;\n\tbodySize: number;\n}\n\ninterface HarResponse {\n\tstatus: number;\n\tstatusText: string;\n\thttpVersion: string;\n\theaders: Array<{ name: string; value: string }>;\n\tcontent: {\n\t\tsize: number;\n\t\tmimeType: string;\n\t};\n\theadersSize: number;\n\tbodySize: number;\n\tredirectURL: string;\n}\n\ninterface HarEntry {\n\tstartedDateTime: string;\n\ttime: number;\n\trequest: HarRequest;\n\tresponse: HarResponse;\n\tcache: Record<string, never>;\n\ttimings: {\n\t\tsend: number;\n\t\twait: number;\n\t\treceive: number;\n\t};\n}\n\ninterface PendingRequest {\n\trequestId: string;\n\tstartTime: number;\n\tmethod: string;\n\turl: string;\n\theaders: Record<string, string>;\n}\n\ninterface ResponseInfo {\n\tstatus: number;\n\tstatusText: string;\n\theaders: Record<string, string>;\n\tmimeType: string;\n\tencodedDataLength: number;\n}\n\n/**\n * Records network traffic in HAR 1.2 format using CDP Network domain events.\n * On teardown, writes the complete HAR log to the configured output path.\n */\nexport class HarCaptureGuard extends BaseGuard {\n\treadonly name = 'har-recording';\n\treadonly priority = 500;\n\n\tprivate readonly outputPath: string;\n\tprivate cdpSession: CDPSession | null = null;\n\tprivate pendingRequests = new Map<string, PendingRequest>();\n\tprivate responses = new Map<string, ResponseInfo>();\n\tprivate entries: HarEntry[] = [];\n\n\tconstructor(outputPath: string) {\n\t\tsuper();\n\t\tthis.outputPath = outputPath;\n\t}\n\n\tprotected async setup(): Promise<void> {\n\t\tthis.cdpSession = await this.ctx.page.context().newCDPSession(this.ctx.page);\n\n\t\tawait this.cdpSession.send('Network.enable');\n\n\t\tthis.cdpSession.on('Network.requestWillBeSent', (params) => {\n\t\t\tconst { requestId, request, timestamp } = params as {\n\t\t\t\trequestId: string;\n\t\t\t\trequest: { method: string; url: string; headers: Record<string, string> };\n\t\t\t\ttimestamp: number;\n\t\t\t};\n\n\t\t\tthis.pendingRequests.set(requestId, {\n\t\t\t\trequestId,\n\t\t\t\tstartTime: timestamp,\n\t\t\t\tmethod: request.method,\n\t\t\t\turl: request.url,\n\t\t\t\theaders: request.headers,\n\t\t\t});\n\t\t});\n\n\t\tthis.cdpSession.on('Network.responseReceived', (params) => {\n\t\t\tconst { requestId, response } = params as {\n\t\t\t\trequestId: string;\n\t\t\t\tresponse: {\n\t\t\t\t\tstatus: number;\n\t\t\t\t\tstatusText: string;\n\t\t\t\t\theaders: Record<string, string>;\n\t\t\t\t\tmimeType: string;\n\t\t\t\t\tencodedDataLength: number;\n\t\t\t\t};\n\t\t\t};\n\n\t\t\tthis.responses.set(requestId, {\n\t\t\t\tstatus: response.status,\n\t\t\t\tstatusText: response.statusText,\n\t\t\t\theaders: response.headers,\n\t\t\t\tmimeType: response.mimeType,\n\t\t\t\tencodedDataLength: response.encodedDataLength,\n\t\t\t});\n\t\t});\n\n\t\tthis.cdpSession.on('Network.loadingFinished', (params) => {\n\t\t\tconst { requestId, timestamp, encodedDataLength } = params as {\n\t\t\t\trequestId: string;\n\t\t\t\ttimestamp: number;\n\t\t\t\tencodedDataLength: number;\n\t\t\t};\n\n\t\t\tthis.finalizeEntry(requestId, timestamp, encodedDataLength);\n\t\t});\n\n\t\tthis.cdpSession.on('Network.loadingFailed', (params) => {\n\t\t\tconst { requestId, timestamp } = params as {\n\t\t\t\trequestId: string;\n\t\t\t\ttimestamp: number;\n\t\t\t};\n\n\t\t\t// Still record failed requests with a zero-length response\n\t\t\tthis.finalizeEntry(requestId, timestamp, 0);\n\t\t});\n\n\t\tthis.cleanupFns.push(() => {\n\t\t\tthis.cdpSession?.detach().catch(() => {\n\t\t\t\t// Ignore detach errors during cleanup\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate finalizeEntry(requestId: string, endTimestamp: number, encodedDataLength: number): void {\n\t\tconst pending = this.pendingRequests.get(requestId);\n\t\tif (!pending) return;\n\n\t\tconst response = this.responses.get(requestId);\n\t\tconst elapsedMs = (endTimestamp - pending.startTime) * 1000;\n\n\t\tconst harRequest: HarRequest = {\n\t\t\tmethod: pending.method,\n\t\t\turl: pending.url,\n\t\t\thttpVersion: 'HTTP/1.1',\n\t\t\theaders: toHeaderArray(pending.headers),\n\t\t\tqueryString: parseQueryString(pending.url),\n\t\t\theadersSize: -1,\n\t\t\tbodySize: -1,\n\t\t};\n\n\t\tconst harResponse: HarResponse = response\n\t\t\t? {\n\t\t\t\t\tstatus: response.status,\n\t\t\t\t\tstatusText: response.statusText,\n\t\t\t\t\thttpVersion: 'HTTP/1.1',\n\t\t\t\t\theaders: toHeaderArray(response.headers),\n\t\t\t\t\tcontent: {\n\t\t\t\t\t\tsize: encodedDataLength,\n\t\t\t\t\t\tmimeType: response.mimeType,\n\t\t\t\t\t},\n\t\t\t\t\theadersSize: -1,\n\t\t\t\t\tbodySize: encodedDataLength,\n\t\t\t\t\tredirectURL: response.headers['location'] ?? '',\n\t\t\t\t}\n\t\t\t: {\n\t\t\t\t\tstatus: 0,\n\t\t\t\t\tstatusText: '',\n\t\t\t\t\thttpVersion: 'HTTP/1.1',\n\t\t\t\t\theaders: [],\n\t\t\t\t\tcontent: { size: 0, mimeType: '' },\n\t\t\t\t\theadersSize: -1,\n\t\t\t\t\tbodySize: 0,\n\t\t\t\t\tredirectURL: '',\n\t\t\t\t};\n\n\t\tthis.entries.push({\n\t\t\tstartedDateTime: new Date(pending.startTime * 1000).toISOString(),\n\t\t\ttime: Math.max(0, elapsedMs),\n\t\t\trequest: harRequest,\n\t\t\tresponse: harResponse,\n\t\t\tcache: {},\n\t\t\ttimings: {\n\t\t\t\tsend: 0,\n\t\t\t\twait: Math.max(0, elapsedMs),\n\t\t\t\treceive: 0,\n\t\t\t},\n\t\t});\n\n\t\tthis.pendingRequests.delete(requestId);\n\t\tthis.responses.delete(requestId);\n\t}\n\n\tprotected override async teardown(): Promise<void> {\n\t\tconst har = {\n\t\t\tlog: {\n\t\t\t\tversion: '1.2',\n\t\t\t\tcreator: {\n\t\t\t\t\tname: 'open-browser',\n\t\t\t\t\tversion: '1.0.0',\n\t\t\t\t},\n\t\t\t\tentries: this.entries,\n\t\t\t},\n\t\t};\n\n\t\tawait mkdir(dirname(this.outputPath), { recursive: true });\n\t\tawait writeFile(this.outputPath, JSON.stringify(har, null, 2), 'utf-8');\n\t}\n}\n\n// ── Helpers ──\n\nfunction toHeaderArray(headers: Record<string, string>): Array<{ name: string; value: string }> {\n\treturn Object.entries(headers).map(([name, value]) => ({ name, value }));\n}\n\nfunction parseQueryString(url: string): Array<{ name: string; value: string }> {\n\ttry {\n\t\tconst parsed = new URL(url);\n\t\treturn [...parsed.searchParams.entries()].map(([name, value]) => ({ name, value }));\n\t} catch {\n\t\treturn [];\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/local-instance.ts",
    "content": "import { BaseGuard } from '../guard-base.js';\n\n/**\n * Ensures a local browser is connected by verifying\n * the page is accessible during setup.\n */\nexport class LocalInstanceGuard extends BaseGuard {\n\treadonly name = 'local-browser';\n\treadonly priority = 10;\n\n\tprotected async setup(): Promise<void> {\n\t\t// Verify the page is accessible by checking its URL.\n\t\t// This is a no-op check that throws if the page is not connected.\n\t\tthis.ctx.page.url();\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/page-ready.ts",
    "content": "import { BaseGuard } from '../guard-base.js';\nimport { createLogger } from '../../logging.js';\n\nconst logger = createLogger('watchdog:dom');\n\n// ── Options ──\n\nexport interface PageReadyGuardOptions {\n\t/**\n\t * Milliseconds of mutation silence required before the DOM is considered\n\t * \"stable\". Defaults to 500 ms.\n\t */\n\tidleTimeoutMs?: number;\n\n\t/**\n\t * Debounce interval for grouping rapid-fire mutation callbacks.\n\t * Defaults to 100 ms.\n\t */\n\tdebounceMs?: number;\n}\n\n// ── Load-state tracking ──\n\nexport type LoadState = 'domcontentloaded' | 'load' | 'networkidle';\n\n// ── Watchdog ──\n\n/**\n * Monitors DOM readiness and mutation activity.\n *\n * Features:\n * - Listens for standard Playwright page lifecycle events\n *   (`domcontentloaded`, `load`, `networkidle`)\n * - Injects a MutationObserver via `page.evaluate` to detect in-page DOM\n *   changes and determine when the page has \"settled\"\n * - Emits `dom-ready` once the DOM is stable (no mutations for `idleTimeoutMs`)\n * - Exposes `waitForDomStable()` for external consumers\n * - Tracks cumulative mutation count for debugging\n */\nexport class PageReadyGuard extends BaseGuard {\n\treadonly name = 'dom';\n\treadonly priority = 200;\n\n\tprivate readonly idleTimeoutMs: number;\n\tprivate readonly debounceMs: number;\n\n\t/** Which lifecycle states the current page has reached. */\n\tprivate reachedStates = new Set<LoadState>();\n\n\t/** Running total of mutation batches observed (useful for debugging). */\n\tprivate mutationCount = 0;\n\n\t/** Whether we currently consider the DOM to be stable. */\n\tprivate stable = false;\n\n\t/** Timer handle for the idle-detection window. */\n\tprivate idleTimer: ReturnType<typeof setTimeout> | null = null;\n\n\t/** Timer handle for the debounce window. */\n\tprivate debounceTimer: ReturnType<typeof setTimeout> | null = null;\n\n\t/** Resolvers for external callers waiting on `waitForDomStable`. */\n\tprivate stableWaiters: Array<{\n\t\tresolve: () => void;\n\t\treject: (err: Error) => void;\n\t\ttimer: ReturnType<typeof setTimeout>;\n\t}> = [];\n\n\t/** Callback used for `page.exposeFunction` – stored so we can reference it. */\n\tprivate readonly exposedFnName = '__ob_dom_mutation';\n\n\tconstructor(options?: PageReadyGuardOptions) {\n\t\tsuper();\n\t\tthis.idleTimeoutMs = options?.idleTimeoutMs ?? 500;\n\t\tthis.debounceMs = options?.debounceMs ?? 100;\n\t}\n\n\t// ── Setup ──\n\n\tprotected async setup(): Promise<void> {\n\t\tthis.reachedStates.clear();\n\t\tthis.mutationCount = 0;\n\t\tthis.stable = false;\n\n\t\t// 1. Standard lifecycle events.\n\t\tthis.setupLifecycleListeners();\n\n\t\t// 2. MutationObserver bridge via an exposed function.\n\t\tawait this.setupMutationObserver();\n\n\t\tlogger.debug(\n\t\t\t`DOM watchdog active (idleTimeout=${this.idleTimeoutMs}ms, debounce=${this.debounceMs}ms)`,\n\t\t);\n\t}\n\n\t// ── Teardown ──\n\n\tprotected async teardown(): Promise<void> {\n\t\tthis.clearTimers();\n\n\t\t// Reject pending waiters.\n\t\tfor (const waiter of this.stableWaiters) {\n\t\t\tclearTimeout(waiter.timer);\n\t\t\twaiter.reject(new Error('PageReadyGuard detached before DOM became stable'));\n\t\t}\n\t\tthis.stableWaiters = [];\n\n\t\tlogger.debug(\n\t\t\t`DOM watchdog detached (observed ${this.mutationCount} mutation batches)`,\n\t\t);\n\t}\n\n\t// ── Lifecycle listeners ──\n\n\tprivate setupLifecycleListeners(): void {\n\t\tconst onDomContentLoaded = () => {\n\t\t\tthis.reachedStates.add('domcontentloaded');\n\t\t\tlogger.debug('Page reached domcontentloaded');\n\t\t\tthis.resetIdleTimer();\n\t\t};\n\n\t\tconst onLoad = () => {\n\t\t\tthis.reachedStates.add('load');\n\t\t\tlogger.debug('Page reached load');\n\t\t\tthis.resetIdleTimer();\n\t\t};\n\n\t\tthis.ctx.page.on('domcontentloaded', onDomContentLoaded);\n\t\tthis.ctx.page.on('load', onLoad);\n\n\t\tthis.cleanupFns.push(\n\t\t\t() => this.ctx.page.off('domcontentloaded', onDomContentLoaded),\n\t\t\t() => this.ctx.page.off('load', onLoad),\n\t\t);\n\n\t\t// `networkidle` is not a standard event – we wait for it asynchronously\n\t\t// after page load to avoid blocking setup.\n\t\tconst watchNetworkIdle = async () => {\n\t\t\ttry {\n\t\t\t\tawait this.ctx.page.waitForLoadState('networkidle');\n\t\t\t\tif (!this.active) return;\n\t\t\t\tthis.reachedStates.add('networkidle');\n\t\t\t\tlogger.debug('Page reached networkidle');\n\t\t\t\tthis.resetIdleTimer();\n\t\t\t} catch {\n\t\t\t\t// Navigation may have occurred or page closed – ignore.\n\t\t\t}\n\t\t};\n\n\t\t// Fire-and-forget; we do not await.\n\t\twatchNetworkIdle();\n\t}\n\n\t// ── MutationObserver bridge ──\n\n\tprivate async setupMutationObserver(): Promise<void> {\n\t\t// Expose a function so the in-page MutationObserver can call back into Node.\n\t\ttry {\n\t\t\tawait this.ctx.page.exposeFunction(this.exposedFnName, (count: number) => {\n\t\t\t\tthis.onMutationBatch(count);\n\t\t\t});\n\t\t} catch {\n\t\t\t// Function may already be exposed from a previous attach cycle.\n\t\t\tlogger.debug('Mutation bridge function already exposed – reusing');\n\t\t}\n\n\t\t// Inject the observer. We re-inject on every `domcontentloaded` so it\n\t\t// survives navigations.\n\t\tconst injectObserver = async () => {\n\t\t\ttry {\n\t\t\t\tawait this.ctx.page.evaluate((fnName: string) => {\n\t\t\t\t\tconst win = window as unknown as Record<string, unknown>;\n\n\t\t\t\t\t// Avoid double-installing on the same document.\n\t\t\t\t\tif (win.__ob_observer_installed) return;\n\t\t\t\t\twin.__ob_observer_installed = true;\n\n\t\t\t\t\tlet pending = 0;\n\t\t\t\t\tconst observer = new MutationObserver((mutations) => {\n\t\t\t\t\t\tpending += mutations.length;\n\t\t\t\t\t});\n\n\t\t\t\t\tobserver.observe(document.documentElement, {\n\t\t\t\t\t\tchildList: true,\n\t\t\t\t\t\tsubtree: true,\n\t\t\t\t\t\tattributes: true,\n\t\t\t\t\t\tcharacterData: true,\n\t\t\t\t\t});\n\n\t\t\t\t\t// Flush accumulated mutation count periodically rather than on\n\t\t\t\t\t// every single micro-mutation.\n\t\t\t\t\tsetInterval(() => {\n\t\t\t\t\t\tif (pending > 0) {\n\t\t\t\t\t\t\tconst count = pending;\n\t\t\t\t\t\t\tpending = 0;\n\t\t\t\t\t\t\tconst fn = win[fnName];\n\t\t\t\t\t\t\tif (typeof fn === 'function') fn(count);\n\t\t\t\t\t\t}\n\t\t\t\t\t}, 50);\n\t\t\t\t}, this.exposedFnName);\n\t\t\t} catch {\n\t\t\t\t// Page may have navigated away or closed.\n\t\t\t}\n\t\t};\n\n\t\t// Inject immediately for the current document...\n\t\tawait injectObserver();\n\n\t\t// ...and re-inject on future navigations.\n\t\tconst onDomContentLoaded = () => {\n\t\t\tinjectObserver();\n\t\t};\n\t\tthis.ctx.page.on('domcontentloaded', onDomContentLoaded);\n\t\tthis.cleanupFns.push(() => this.ctx.page.off('domcontentloaded', onDomContentLoaded));\n\t}\n\n\t// ── Mutation handling ──\n\n\tprivate onMutationBatch(count: number): void {\n\t\tthis.mutationCount += count;\n\t\tthis.stable = false;\n\n\t\t// Debounce: delay the idle-timer reset so we don't restart it on\n\t\t// every single mutation callback.\n\t\tif (this.debounceTimer) {\n\t\t\tclearTimeout(this.debounceTimer);\n\t\t}\n\t\tthis.debounceTimer = setTimeout(() => {\n\t\t\tthis.debounceTimer = null;\n\t\t\tthis.resetIdleTimer();\n\t\t}, this.debounceMs);\n\t}\n\n\t// ── Idle detection ──\n\n\tprivate resetIdleTimer(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t}\n\n\t\tthis.idleTimer = setTimeout(() => {\n\t\t\tthis.idleTimer = null;\n\t\t\tthis.markStable();\n\t\t}, this.idleTimeoutMs);\n\t}\n\n\tprivate markStable(): void {\n\t\tif (this.stable) return;\n\n\t\tthis.stable = true;\n\t\tlogger.debug(\n\t\t\t`DOM stable after ${this.mutationCount} mutation batches ` +\n\t\t\t`(states: ${[...this.reachedStates].join(', ') || 'none'})`,\n\t\t);\n\n\t\tthis.ctx.eventBus.emit('content-ready', undefined as void);\n\t\tthis.notifyStableWaiters();\n\t}\n\n\t// ── Public API ──\n\n\t/**\n\t * Returns a promise that resolves once the DOM is considered stable\n\t * (no mutations for `idleTimeoutMs`).\n\t *\n\t * If the DOM is already stable the promise resolves immediately.\n\t *\n\t * @param timeout Maximum milliseconds to wait. Defaults to 10 000 ms.\n\t */\n\twaitForDomStable(timeout = 10_000): Promise<void> {\n\t\tif (this.stable) {\n\t\t\treturn Promise.resolve();\n\t\t}\n\n\t\treturn new Promise<void>((resolve, reject) => {\n\t\t\tconst timer = setTimeout(() => {\n\t\t\t\tthis.removeStableWaiter(waiter);\n\t\t\t\treject(new Error(`waitForDomStable timed out after ${timeout}ms`));\n\t\t\t}, timeout);\n\n\t\t\tconst waiter = { resolve, reject, timer };\n\t\t\tthis.stableWaiters.push(waiter);\n\t\t});\n\t}\n\n\t/**\n\t * Returns the set of lifecycle states the current page has reached.\n\t */\n\tgetReachedStates(): ReadonlySet<LoadState> {\n\t\treturn this.reachedStates;\n\t}\n\n\t/**\n\t * Returns the total number of mutation batches observed since the\n\t * watchdog was attached.\n\t */\n\tgetMutationCount(): number {\n\t\treturn this.mutationCount;\n\t}\n\n\t/**\n\t * Whether the DOM is currently considered stable.\n\t */\n\tisStable(): boolean {\n\t\treturn this.stable;\n\t}\n\n\t// ── Waiter helpers ──\n\n\tprivate notifyStableWaiters(): void {\n\t\tconst waiters = this.stableWaiters.splice(0);\n\t\tfor (const waiter of waiters) {\n\t\t\tclearTimeout(waiter.timer);\n\t\t\twaiter.resolve();\n\t\t}\n\t}\n\n\tprivate removeStableWaiter(waiter: (typeof this.stableWaiters)[number]): void {\n\t\tconst idx = this.stableWaiters.indexOf(waiter);\n\t\tif (idx !== -1) {\n\t\t\tthis.stableWaiters.splice(idx, 1);\n\t\t}\n\t}\n\n\t// ── Timer cleanup ──\n\n\tprivate clearTimers(): void {\n\t\tif (this.idleTimer) {\n\t\t\tclearTimeout(this.idleTimer);\n\t\t\tthis.idleTimer = null;\n\t\t}\n\t\tif (this.debounceTimer) {\n\t\t\tclearTimeout(this.debounceTimer);\n\t\t\tthis.debounceTimer = null;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/permissions.ts",
    "content": "import type { CDPSession } from 'playwright';\nimport { BaseGuard } from '../guard-base.js';\n\n/**\n * Grants browser permissions (geolocation, notifications, camera, etc.)\n * via CDP. Re-grants permissions when the page navigates to a new origin.\n */\nexport class PermissionsGuard extends BaseGuard {\n\treadonly name = 'permissions';\n\treadonly priority = 400;\n\n\tprivate readonly permissions: string[];\n\tprivate cdpSession: CDPSession | null = null;\n\tprivate lastOrigin: string | null = null;\n\n\tconstructor(permissions: string[]) {\n\t\tsuper();\n\t\tthis.permissions = permissions;\n\t}\n\n\tprotected async setup(): Promise<void> {\n\t\tthis.cdpSession = await this.ctx.page.context().newCDPSession(this.ctx.page);\n\n\t\t// Grant permissions for the current page origin\n\t\tawait this.grantForCurrentPage();\n\n\t\t// Re-grant permissions when navigating to a new origin\n\t\tconst handler = () => {\n\t\t\tthis.grantForCurrentPage().catch(() => {\n\t\t\t\t// Ignore errors from navigations to about:blank, etc.\n\t\t\t});\n\t\t};\n\n\t\tthis.ctx.page.on('framenavigated', handler);\n\t\tthis.cleanupFns.push(() => this.ctx.page.off('framenavigated', handler));\n\t\tthis.cleanupFns.push(() => {\n\t\t\tthis.cdpSession?.detach().catch(() => {\n\t\t\t\t// Ignore detach errors during cleanup\n\t\t\t});\n\t\t});\n\t}\n\n\tprivate async grantForCurrentPage(): Promise<void> {\n\t\tconst url = this.ctx.page.url();\n\t\tlet origin: string;\n\t\ttry {\n\t\t\torigin = new URL(url).origin;\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\n\t\t// Skip non-http origins and avoid re-granting for the same origin\n\t\tif (!origin.startsWith('http') || origin === this.lastOrigin) return;\n\n\t\tthis.lastOrigin = origin;\n\t\tif (!this.cdpSession) return;\n\t\t// CDP types require PermissionType[] but we accept string[] for ergonomics\n\t\ttype SendFn = (method: string, params: Record<string, unknown>) => Promise<unknown>;\n\t\tawait (this.cdpSession.send as unknown as SendFn)(\n\t\t\t'Browser.grantPermissions',\n\t\t\t{ permissions: this.permissions, origin },\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/persistence.ts",
    "content": "import { readFile, writeFile, mkdir } from 'node:fs/promises';\nimport { dirname } from 'node:path';\nimport { BaseGuard } from '../guard-base.js';\n\n/**\n * Handles saving and restoring browser storage state (cookies, localStorage).\n * Persists state to a file so it can be restored across sessions.\n */\nexport class PersistenceGuard extends BaseGuard {\n\treadonly name = 'storage-state';\n\treadonly priority = 600;\n\n\tprivate readonly storagePath: string;\n\n\tconstructor(storagePath: string) {\n\t\tsuper();\n\t\tthis.storagePath = storagePath;\n\t}\n\n\tprotected async setup(): Promise<void> {\n\t\t// Try to restore storage state from file if it exists\n\t\ttry {\n\t\t\tconst data = await readFile(this.storagePath, 'utf-8');\n\t\t\tconst storageState = JSON.parse(data) as {\n\t\t\t\tcookies?: Array<{\n\t\t\t\t\tname: string;\n\t\t\t\t\tvalue: string;\n\t\t\t\t\tdomain: string;\n\t\t\t\t\tpath: string;\n\t\t\t\t\texpires?: number;\n\t\t\t\t\thttpOnly?: boolean;\n\t\t\t\t\tsecure?: boolean;\n\t\t\t\t\tsameSite?: 'Strict' | 'Lax' | 'None';\n\t\t\t\t}>;\n\t\t\t};\n\t\t\tif (storageState.cookies) {\n\t\t\t\tawait this.ctx.context.addCookies(storageState.cookies);\n\t\t\t}\n\t\t} catch {\n\t\t\t// File doesn't exist or is invalid; start fresh\n\t\t}\n\t}\n\n\t/**\n\t * Saves the current context storage state to the configured file path.\n\t */\n\tasync save(): Promise<void> {\n\t\tconst storageState = await this.ctx.context.storageState();\n\t\tawait mkdir(dirname(this.storagePath), { recursive: true });\n\t\tawait writeFile(this.storagePath, JSON.stringify(storageState, null, 2), 'utf-8');\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/popups.ts",
    "content": "import type { Page } from 'playwright';\nimport { BaseGuard } from '../guard-base.js';\n\n/**\n * Monitors for popups and new windows/tabs. Listens for new pages\n * created in the browser context and emits tab-created events.\n */\nexport class PopupGuard extends BaseGuard {\n\treadonly name = 'popups';\n\treadonly priority = 150;\n\n\tprotected async setup(): Promise<void> {\n\t\tconst handler = async (page: Page) => {\n\t\t\ttry {\n\t\t\t\tawait page.waitForLoadState('domcontentloaded');\n\t\t\t} catch {\n\t\t\t\t// Page may have been closed before load\n\t\t\t}\n\n\t\t\tconst url = page.url();\n\t\t\tthis.ctx.eventBus.emit('tab-opened', { url });\n\n\t\t\t// Bring focus to the new page\n\t\t\ttry {\n\t\t\t\tawait page.bringToFront();\n\t\t\t} catch {\n\t\t\t\t// Page may have been closed\n\t\t\t}\n\t\t};\n\n\t\tthis.ctx.context.on('page', handler);\n\t\tthis.cleanupFns.push(() => this.ctx.context.off('page', handler));\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/screenshot.ts",
    "content": "import type { ScreenshotEvent, ScreenshotResult } from '../events.js';\nimport { BaseGuard } from '../guard-base.js';\n\n/**\n * Handles screenshot requests by registering a request handler\n * for 'get-screenshot' on the event bus.\n */\nexport class ScreenshotGuard extends BaseGuard {\n\treadonly name = 'screenshot';\n\treadonly priority = 700;\n\n\tprotected async setup(): Promise<void> {\n\t\tconst off = this.ctx.eventBus.onRequest(\n\t\t\t'get-screenshot',\n\t\t\tasync (event: ScreenshotEvent): Promise<ScreenshotResult> => {\n\t\t\t\tconst buffer = await this.ctx.page.screenshot({\n\t\t\t\t\tfullPage: event?.fullPage ?? false,\n\t\t\t\t\ttype: 'png',\n\t\t\t\t});\n\n\t\t\t\tconst base64 = buffer.toString('base64');\n\t\t\t\tconst viewport = this.ctx.page.viewportSize();\n\n\t\t\t\treturn {\n\t\t\t\t\tbase64,\n\t\t\t\t\twidth: viewport?.width ?? 0,\n\t\t\t\t\theight: viewport?.height ?? 0,\n\t\t\t\t};\n\t\t\t},\n\t\t);\n\n\t\tthis.cleanupFns.push(off);\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/url-policy.ts",
    "content": "import type { Route } from 'playwright';\nimport { BaseGuard } from '../guard-base.js';\nimport { isUrlPermitted } from '../../utils.js';\n\n/**\n * Monitors for security concerns by intercepting navigation requests.\n * Checks URLs against allowed/blocked lists before permitting navigation.\n */\nexport class UrlPolicyGuard extends BaseGuard {\n\treadonly name = 'policy-violation';\n\treadonly priority = 50;\n\n\tprivate readonly allowedUrls: string[];\n\tprivate readonly blockedUrls: string[];\n\n\tconstructor(allowedUrls: string[] = [], blockedUrls: string[] = []) {\n\t\tsuper();\n\t\tthis.allowedUrls = allowedUrls;\n\t\tthis.blockedUrls = blockedUrls;\n\t}\n\n\tprotected async setup(): Promise<void> {\n\t\tconst handler = async (route: Route) => {\n\t\t\tconst url = route.request().url();\n\n\t\t\tif (\n\t\t\t\troute.request().isNavigationRequest() &&\n\t\t\t\t!isUrlPermitted(url, this.allowedUrls, this.blockedUrls)\n\t\t\t) {\n\t\t\t\tthis.ctx.eventBus.emit('policy-violation', {\n\t\t\t\t\ttype: 'navigation-blocked',\n\t\t\t\t\turl,\n\t\t\t\t\treason: `URL not allowed by security policy: ${url}`,\n\t\t\t\t});\n\t\t\t\tawait route.abort('blockedbyclient');\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tawait route.continue();\n\t\t};\n\n\t\tawait this.ctx.page.route('**/*', handler);\n\t\tthis.cleanupFns.push(() => {\n\t\t\tthis.ctx.page.unroute('**/*', handler).catch(() => {\n\t\t\t\t// Ignore errors during cleanup\n\t\t\t});\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/guards/video-capture.ts",
    "content": "import { mkdir, writeFile } from 'node:fs/promises';\nimport { dirname, join } from 'node:path';\nimport type { CDPSession } from 'playwright';\nimport { BaseGuard } from '../guard-base.js';\nimport { createLogger } from '../../logging.js';\n\nconst logger = createLogger('watchdog:video-recording');\n\n// ── Options ──\n\nexport interface VideoRecordingOptions {\n\t/** Path for the Playwright trace archive (.zip). */\n\toutputPath: string;\n\t/**\n\t * Recording mode. `'tracing'` uses Playwright's built-in tracing API\n\t * (screenshots + DOM snapshots). `'screencast'` falls back to CDP\n\t * Page.startScreencast for raw frame capture. `'auto'` tries tracing\n\t * first and falls back to screencast on failure.\n\t *\n\t * @default 'auto'\n\t */\n\tmode?: 'tracing' | 'screencast' | 'auto';\n\t/**\n\t * Maximum frames per second for CDP screencast mode.\n\t * Ignored when using Playwright tracing.\n\t *\n\t * @default 5\n\t */\n\tmaxFrameRate?: number;\n\t/**\n\t * Screencast image format.\n\t * @default 'jpeg'\n\t */\n\tformat?: 'jpeg' | 'png';\n\t/**\n\t * Screencast image quality (1-100). Only applies to JPEG.\n\t * @default 60\n\t */\n\tquality?: number;\n\t/**\n\t * Maximum width of captured screencast frames in pixels.\n\t * The browser scales down if the viewport is larger.\n\t *\n\t * @default 1280\n\t */\n\tmaxWidth?: number;\n\t/**\n\t * Maximum height of captured screencast frames in pixels.\n\t * @default 720\n\t */\n\tmaxHeight?: number;\n}\n\n// ── Resolved defaults ──\n\ninterface ResolvedOptions {\n\toutputPath: string;\n\tmode: 'tracing' | 'screencast' | 'auto';\n\tmaxFrameRate: number;\n\tformat: 'jpeg' | 'png';\n\tquality: number;\n\tmaxWidth: number;\n\tmaxHeight: number;\n}\n\nfunction resolveOptions(opts: VideoRecordingOptions): ResolvedOptions {\n\treturn {\n\t\toutputPath: opts.outputPath,\n\t\tmode: opts.mode ?? 'auto',\n\t\tmaxFrameRate: opts.maxFrameRate ?? 5,\n\t\tformat: opts.format ?? 'jpeg',\n\t\tquality: opts.quality ?? 60,\n\t\tmaxWidth: opts.maxWidth ?? 1280,\n\t\tmaxHeight: opts.maxHeight ?? 720,\n\t};\n}\n\n// ── Watchdog ──\n\n/**\n * Records browser activity using Playwright's tracing API or CDP\n * Page.startScreencast as a fallback.\n *\n * - **Tracing mode** captures screenshots and DOM snapshots viewable in\n *   the Playwright Trace Viewer. Produces a `.zip` archive.\n * - **Screencast mode** uses CDP to capture individual frames at a\n *   configurable frame rate and quality. Produces numbered image files\n *   written into a directory alongside the output path.\n *\n * Supports pause/resume so callers can temporarily halt recording\n * (e.g. during long waits) and restart without losing earlier frames.\n */\nexport class VideoCaptureGuard extends BaseGuard {\n\treadonly name = 'video-recording';\n\treadonly priority = 500;\n\n\tprivate readonly options: ResolvedOptions;\n\n\t// ── Tracing state ──\n\tprivate tracingStarted = false;\n\n\t// ── Screencast state ──\n\tprivate cdpSession: CDPSession | null = null;\n\tprivate screencastActive = false;\n\tprivate paused = false;\n\tprivate frameCount = 0;\n\tprivate readonly frames: Array<{ data: string; timestamp: number }> = [];\n\n\tconstructor(options: VideoRecordingOptions) {\n\t\tsuper();\n\t\tthis.options = resolveOptions(options);\n\t}\n\n\t// ── Setup ──\n\n\tprotected async setup(): Promise<void> {\n\t\tconst { mode } = this.options;\n\n\t\tif (mode === 'tracing' || mode === 'auto') {\n\t\t\tconst tracingOk = await this.startTracing();\n\t\t\tif (tracingOk) return;\n\t\t\tif (mode === 'tracing') {\n\t\t\t\tlogger.warn('Tracing failed and mode is \"tracing\" – recording will be unavailable');\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlogger.info('Tracing unavailable, falling back to CDP screencast');\n\t\t}\n\n\t\tawait this.startScreencast();\n\t}\n\n\t// ── Teardown ──\n\n\tprotected override async teardown(): Promise<void> {\n\t\tif (this.tracingStarted) {\n\t\t\tawait this.stopTracing();\n\t\t} else if (this.screencastActive) {\n\t\t\tawait this.stopScreencast();\n\t\t}\n\t}\n\n\t// ── Pause / Resume ──\n\n\t/**\n\t * Temporarily pauses frame capture (screencast only).\n\t * Tracing mode does not support granular pause/resume.\n\t */\n\tpause(): void {\n\t\tif (!this.screencastActive || this.paused) return;\n\t\tthis.paused = true;\n\t\tlogger.debug('Screencast paused');\n\t}\n\n\t/**\n\t * Resumes frame capture after a pause (screencast only).\n\t */\n\tresume(): void {\n\t\tif (!this.screencastActive || !this.paused) return;\n\t\tthis.paused = false;\n\t\tlogger.debug('Screencast resumed');\n\t}\n\n\t/** Whether the recording is currently paused. */\n\tget isPaused(): boolean {\n\t\treturn this.paused;\n\t}\n\n\t/** Number of frames captured so far (screencast mode). */\n\tget capturedFrameCount(): number {\n\t\treturn this.frameCount;\n\t}\n\n\t// ── Tracing ──\n\n\tprivate async startTracing(): Promise<boolean> {\n\t\ttry {\n\t\t\tawait this.ctx.context.tracing.start({\n\t\t\t\tscreenshots: true,\n\t\t\t\tsnapshots: true,\n\t\t\t});\n\t\t\tthis.tracingStarted = true;\n\t\t\tlogger.info('Playwright tracing started');\n\t\t\treturn true;\n\t\t} catch (err) {\n\t\t\tconst reason = err instanceof Error ? err.message : String(err);\n\t\t\tlogger.debug(`Could not start tracing: ${reason}`);\n\t\t\treturn false;\n\t\t}\n\t}\n\n\tprivate async stopTracing(): Promise<void> {\n\t\ttry {\n\t\t\tawait mkdir(dirname(this.options.outputPath), { recursive: true });\n\t\t\tawait this.ctx.context.tracing.stop({\n\t\t\t\tpath: this.options.outputPath,\n\t\t\t});\n\t\t\tlogger.info(`Trace saved to ${this.options.outputPath}`);\n\t\t} catch (err) {\n\t\t\tconst reason = err instanceof Error ? err.message : String(err);\n\t\t\tlogger.error(`Failed to save trace: ${reason}`);\n\t\t}\n\t\tthis.tracingStarted = false;\n\t}\n\n\t// ── Screencast ──\n\n\tprivate async startScreencast(): Promise<void> {\n\t\ttry {\n\t\t\tthis.cdpSession = await this.ctx.page.context().newCDPSession(this.ctx.page);\n\n\t\t\tthis.cdpSession.on('Page.screencastFrame', (params) => {\n\t\t\t\tconst { data, metadata, sessionId } = params as {\n\t\t\t\t\tdata: string;\n\t\t\t\t\tmetadata: { timestamp: number };\n\t\t\t\t\tsessionId: number;\n\t\t\t\t};\n\n\t\t\t\t// Acknowledge the frame so the browser keeps sending them.\n\t\t\t\tthis.cdpSession?.send('Page.screencastFrameAck', { sessionId }).catch(() => {\n\t\t\t\t\t// Ignore ack errors; session may have closed.\n\t\t\t\t});\n\n\t\t\t\tif (this.paused) return;\n\n\t\t\t\tthis.frameCount++;\n\t\t\t\tthis.frames.push({ data, timestamp: metadata.timestamp });\n\n\t\t\t\tif (this.frameCount % 50 === 0) {\n\t\t\t\t\tlogger.debug(`Screencast: captured ${this.frameCount} frames`);\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tawait (this.cdpSession.send('Page.startScreencast', {\n\t\t\t\tformat: this.options.format,\n\t\t\t\tquality: this.options.format === 'jpeg' ? this.options.quality : undefined,\n\t\t\t\tmaxWidth: this.options.maxWidth,\n\t\t\t\tmaxHeight: this.options.maxHeight,\n\t\t\t\teveryNthFrame: Math.max(1, Math.round(60 / this.options.maxFrameRate)),\n\t\t\t}) as Promise<unknown> as Promise<void>);\n\n\t\t\tthis.screencastActive = true;\n\n\t\t\tthis.cleanupFns.push(() => {\n\t\t\t\tthis.cdpSession?.detach().catch(() => {\n\t\t\t\t\t// Ignore detach errors during cleanup.\n\t\t\t\t});\n\t\t\t});\n\n\t\t\tlogger.info(\n\t\t\t\t`CDP screencast started (${this.options.maxWidth}x${this.options.maxHeight}, ` +\n\t\t\t\t\t`${this.options.format} q${this.options.quality}, ~${this.options.maxFrameRate} fps)`,\n\t\t\t);\n\t\t} catch (err) {\n\t\t\tconst reason = err instanceof Error ? err.message : String(err);\n\t\t\tlogger.error(`Failed to start CDP screencast: ${reason}`);\n\t\t}\n\t}\n\n\tprivate async stopScreencast(): Promise<void> {\n\t\tif (!this.cdpSession) return;\n\n\t\ttry {\n\t\t\tawait (this.cdpSession.send('Page.stopScreencast') as Promise<unknown> as Promise<void>);\n\t\t} catch {\n\t\t\t// Session may already be closed.\n\t\t}\n\n\t\tthis.screencastActive = false;\n\t\tlogger.info(`Screencast stopped – ${this.frameCount} frames captured`);\n\n\t\tawait this.saveFrames();\n\t}\n\n\tprivate async saveFrames(): Promise<void> {\n\t\tif (this.frames.length === 0) {\n\t\t\tlogger.debug('No screencast frames to save');\n\t\t\treturn;\n\t\t}\n\n\t\tconst framesDir = join(dirname(this.options.outputPath), 'screencast-frames');\n\t\tawait mkdir(framesDir, { recursive: true });\n\n\t\tconst ext = this.options.format === 'png' ? 'png' : 'jpg';\n\t\tconst manifest: Array<{ file: string; timestamp: number }> = [];\n\n\t\tfor (let i = 0; i < this.frames.length; i++) {\n\t\t\tconst frame = this.frames[i];\n\t\t\tconst filename = `frame-${String(i).padStart(5, '0')}.${ext}`;\n\t\t\tconst filePath = join(framesDir, filename);\n\t\t\tawait writeFile(filePath, Buffer.from(frame.data, 'base64'));\n\t\t\tmanifest.push({ file: filename, timestamp: frame.timestamp });\n\t\t}\n\n\t\t// Write a JSON manifest alongside the frames for downstream tooling.\n\t\tconst manifestPath = join(framesDir, 'manifest.json');\n\t\tawait writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');\n\n\t\tlogger.info(`Saved ${this.frames.length} frames to ${framesDir}`);\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/index.ts",
    "content": "export { Viewport, type ViewportOptions } from './viewport.js';\nexport { LaunchProfile } from './launch-profile.js';\nexport { EventHub } from './event-hub.js';\nexport { BaseGuard, type GuardContext } from './guard-base.js';\nexport { VisualTracer, type VisualTracerOptions } from './visual-tracer.js';\nexport {\n\ttype TabDescriptor,\n\ttype ViewportSnapshot,\n\ttype ViewportHistory,\n\ttype LaunchOptions,\n\ttype PageState,\n} from './types.js';\nexport {\n\ttype ViewportEventMap,\n\ttype ViewportRequestMap,\n\ttype NavigateEvent,\n\ttype ClickEvent,\n\ttype InputEvent,\n\ttype ScrollEvent,\n\ttype ScreenshotEvent,\n\ttype ScreenshotResult,\n\ttype DownloadEvent,\n\ttype PopupEvent,\n\ttype SecurityEvent,\n\ttype CrashEvent,\n} from './events.js';\n"
  },
  {
    "path": "packages/core/src/viewport/launch-profile.test.ts",
    "content": "import { test, expect, describe } from 'bun:test';\nimport {\n\tLaunchProfile,\n\tCHROME_AUTOMATION_FLAGS,\n\tCHROME_STRIPPED_FEATURES,\n\tANTI_DETECTION_FLAGS,\n\tCONTAINER_FLAGS,\n\tREPRODUCIBLE_RENDER_FLAGS,\n\tRELAXED_SECURITY_FLAGS,\n} from './launch-profile.js';\n\ndescribe('LaunchProfile', () => {\n\tdescribe('static create', () => {\n\t\ttest('returns a LaunchProfile instance', () => {\n\t\t\tconst profile = LaunchProfile.create();\n\t\t\texpect(profile).toBeInstanceOf(LaunchProfile);\n\t\t});\n\t});\n\n\tdescribe('default build', () => {\n\t\ttest('produces headless true by default', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.headless).toBe(true);\n\t\t});\n\n\t\ttest('produces default window size 1280x1100', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.windowWidth).toBe(1280);\n\t\t\texpect(opts.windowHeight).toBe(1100);\n\t\t});\n\n\t\ttest('persistAfterClose defaults to false', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.persistAfterClose).toBe(false);\n\t\t});\n\n\t\ttest('relaxedSecurity defaults to false', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.relaxedSecurity).toBe(false);\n\t\t});\n\n\t\ttest('includes CHROME_AUTOMATION_FLAGS in extraArgs', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\tfor (const arg of CHROME_AUTOMATION_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t});\n\n\t\ttest('includes disabled components feature flag', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\tconst disableFeatures = opts.extraArgs.find((a) =>\n\t\t\t\ta.startsWith('--disable-features='),\n\t\t\t);\n\t\t\texpect(disableFeatures).toBeDefined();\n\t\t\tfor (const component of CHROME_STRIPPED_FEATURES) {\n\t\t\t\texpect(disableFeatures).toContain(component);\n\t\t\t}\n\t\t});\n\n\t\ttest('includes window-size arg', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.extraArgs).toContain('--window-size=1280,1100');\n\t\t});\n\n\t\ttest('proxy is undefined by default', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.proxy).toBeUndefined();\n\t\t});\n\n\t\ttest('userDataDir is undefined by default', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.userDataDir).toBeUndefined();\n\t\t});\n\n\t\ttest('channelName is undefined by default', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\texpect(opts.channelName).toBeUndefined();\n\t\t});\n\t});\n\n\tdescribe('.headless()', () => {\n\t\ttest('headless(true) sets headless to true', () => {\n\t\t\tconst opts = LaunchProfile.create().headless(true).build();\n\t\t\texpect(opts.headless).toBe(true);\n\t\t});\n\n\t\ttest('headless(false) sets headless to false', () => {\n\t\t\tconst opts = LaunchProfile.create().headless(false).build();\n\t\t\texpect(opts.headless).toBe(false);\n\t\t});\n\n\t\ttest('headless() with no argument defaults to true', () => {\n\t\t\tconst opts = LaunchProfile.create().headless().build();\n\t\t\texpect(opts.headless).toBe(true);\n\t\t});\n\t});\n\n\tdescribe('.headful() equivalent', () => {\n\t\ttest('headless(false) creates headful mode', () => {\n\t\t\tconst opts = LaunchProfile.create().headless(false).build();\n\t\t\texpect(opts.headless).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('.stealthMode()', () => {\n\t\ttest('adds stealth args when enabled', () => {\n\t\t\tconst opts = LaunchProfile.create().stealthMode().build();\n\t\t\tfor (const arg of ANTI_DETECTION_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t});\n\n\t\ttest('does not add stealth args when disabled', () => {\n\t\t\tconst opts = LaunchProfile.create().stealthMode(false).build();\n\t\t\t// ANTI_DETECTION_FLAGS[1] is --disable-features=AutomationControlled\n\t\t\t// which won't be in the base args (only in ANTI_DETECTION_FLAGS)\n\t\t\t// But CHROME_AUTOMATION_FLAGS also contains --disable-blink-features=AutomationControlled\n\t\t\t// so check for the features one specifically\n\t\t\tconst stealthOnlyArg = '--disable-features=AutomationControlled';\n\t\t\tconst hasStealthOnlyArg = opts.extraArgs.some(\n\t\t\t\t(a) => a === stealthOnlyArg,\n\t\t\t);\n\t\t\texpect(hasStealthOnlyArg).toBe(false);\n\t\t});\n\n\t\ttest('returns this for chaining', () => {\n\t\t\tconst profile = LaunchProfile.create();\n\t\t\tconst result = profile.stealthMode();\n\t\t\texpect(result).toBe(profile);\n\t\t});\n\t});\n\n\tdescribe('.dockerMode()', () => {\n\t\ttest('adds docker args when enabled', () => {\n\t\t\tconst opts = LaunchProfile.create().dockerMode().build();\n\t\t\tfor (const arg of CONTAINER_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t});\n\n\t\ttest('does not add docker args when disabled', () => {\n\t\t\tconst opts = LaunchProfile.create().dockerMode(false).build();\n\t\t\t// --no-sandbox should not be present when docker mode is off\n\t\t\texpect(opts.extraArgs).not.toContain('--no-sandbox');\n\t\t});\n\t});\n\n\tdescribe('.deterministicRendering()', () => {\n\t\ttest('adds deterministic rendering args when enabled', () => {\n\t\t\tconst opts = LaunchProfile.create().deterministicRendering().build();\n\t\t\tfor (const arg of REPRODUCIBLE_RENDER_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t});\n\n\t\ttest('does not add deterministic args when disabled', () => {\n\t\t\tconst opts = LaunchProfile.create().deterministicRendering(false).build();\n\t\t\texpect(opts.extraArgs).not.toContain('--deterministic-mode');\n\t\t});\n\t});\n\n\tdescribe('.relaxedSecurity()', () => {\n\t\ttest('adds security-disable args when enabled', () => {\n\t\t\tconst opts = LaunchProfile.create().relaxedSecurity().build();\n\t\t\texpect(opts.relaxedSecurity).toBe(true);\n\t\t\tfor (const arg of RELAXED_SECURITY_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t});\n\n\t\ttest('does not add security args when disabled', () => {\n\t\t\tconst opts = LaunchProfile.create().relaxedSecurity(false).build();\n\t\t\texpect(opts.relaxedSecurity).toBe(false);\n\t\t\texpect(opts.extraArgs).not.toContain('--disable-web-security');\n\t\t});\n\t});\n\n\tdescribe('.downloadsPath()', () => {\n\t\ttest('adds download-default-directory arg', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.downloadsPath('/tmp/downloads')\n\t\t\t\t.build();\n\t\t\texpect(opts.extraArgs).toContain(\n\t\t\t\t'--download-default-directory=/tmp/downloads',\n\t\t\t);\n\t\t});\n\t});\n\n\tdescribe('.maxIframes()', () => {\n\t\ttest('returns this for chaining', () => {\n\t\t\tconst profile = LaunchProfile.create();\n\t\t\tconst result = profile.maxIframes(5);\n\t\t\texpect(result).toBe(profile);\n\t\t});\n\t});\n\n\tdescribe('.addExtension()', () => {\n\t\ttest('adds single extension path to load-extension arg', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.addExtension('/path/to/ext1')\n\t\t\t\t.build();\n\t\t\tconst loadExtArg = opts.extraArgs.find((a) =>\n\t\t\t\ta.startsWith('--load-extension='),\n\t\t\t);\n\t\t\texpect(loadExtArg).toBe('--load-extension=/path/to/ext1');\n\t\t});\n\n\t\ttest('adds multiple extensions as comma-separated list', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.addExtension('/path/to/ext1')\n\t\t\t\t.addExtension('/path/to/ext2')\n\t\t\t\t.build();\n\t\t\tconst loadExtArg = opts.extraArgs.find((a) =>\n\t\t\t\ta.startsWith('--load-extension='),\n\t\t\t);\n\t\t\texpect(loadExtArg).toBe(\n\t\t\t\t'--load-extension=/path/to/ext1,/path/to/ext2',\n\t\t\t);\n\t\t});\n\n\t\ttest('no load-extension arg when no extensions added', () => {\n\t\t\tconst opts = LaunchProfile.create().build();\n\t\t\tconst loadExtArg = opts.extraArgs.find((a) =>\n\t\t\t\ta.startsWith('--load-extension='),\n\t\t\t);\n\t\t\texpect(loadExtArg).toBeUndefined();\n\t\t});\n\t});\n\n\tdescribe('.windowSize()', () => {\n\t\ttest('sets custom window dimensions', () => {\n\t\t\tconst opts = LaunchProfile.create().windowSize(1920, 1080).build();\n\t\t\texpect(opts.windowWidth).toBe(1920);\n\t\t\texpect(opts.windowHeight).toBe(1080);\n\t\t\texpect(opts.extraArgs).toContain('--window-size=1920,1080');\n\t\t});\n\t});\n\n\tdescribe('.proxy()', () => {\n\t\ttest('sets proxy server', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.proxy('http://proxy:8080')\n\t\t\t\t.build();\n\t\t\texpect(opts.proxy).toEqual({\n\t\t\t\tserver: 'http://proxy:8080',\n\t\t\t\tusername: undefined,\n\t\t\t\tpassword: undefined,\n\t\t\t});\n\t\t});\n\n\t\ttest('sets proxy with credentials', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.proxy('http://proxy:8080', 'user', 'pass')\n\t\t\t\t.build();\n\t\t\texpect(opts.proxy).toEqual({\n\t\t\t\tserver: 'http://proxy:8080',\n\t\t\t\tusername: 'user',\n\t\t\t\tpassword: 'pass',\n\t\t\t});\n\t\t});\n\t});\n\n\tdescribe('.userDataDir()', () => {\n\t\ttest('sets user data directory', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.userDataDir('/tmp/chrome-data')\n\t\t\t\t.build();\n\t\t\texpect(opts.userDataDir).toBe('/tmp/chrome-data');\n\t\t});\n\t});\n\n\tdescribe('.browserBinary()', () => {\n\t\ttest('sets browser binary path', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.browserBinary('/usr/bin/chromium')\n\t\t\t\t.build();\n\t\t\texpect(opts.browserBinaryPath).toBe('/usr/bin/chromium');\n\t\t});\n\t});\n\n\tdescribe('.persistAfterClose()', () => {\n\t\ttest('sets persistAfterClose to true', () => {\n\t\t\tconst opts = LaunchProfile.create().persistAfterClose().build();\n\t\t\texpect(opts.persistAfterClose).toBe(true);\n\t\t});\n\n\t\ttest('sets persistAfterClose to false', () => {\n\t\t\tconst opts = LaunchProfile.create().persistAfterClose(false).build();\n\t\t\texpect(opts.persistAfterClose).toBe(false);\n\t\t});\n\t});\n\n\tdescribe('.channel()', () => {\n\t\ttest('sets channel name', () => {\n\t\t\tconst opts = LaunchProfile.create().channel('chrome').build();\n\t\t\texpect(opts.channelName).toBe('chrome');\n\t\t});\n\t});\n\n\tdescribe('.extraArgs()', () => {\n\t\ttest('appends extra args to the end', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.extraArgs('--custom-flag', '--another-flag')\n\t\t\t\t.build();\n\t\t\texpect(opts.extraArgs).toContain('--custom-flag');\n\t\t\texpect(opts.extraArgs).toContain('--another-flag');\n\t\t});\n\n\t\ttest('user extra args can override earlier args', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.extraArgs('--override=value')\n\t\t\t\t.build();\n\t\t\t// The user arg should be at the end of the array (after CHROME_AUTOMATION_FLAGS)\n\t\t\tconst lastArgs = opts.extraArgs.slice(-1);\n\t\t\texpect(lastArgs).toContain('--override=value');\n\t\t});\n\t});\n\n\tdescribe('builder chaining', () => {\n\t\ttest('multiple methods can be chained together', () => {\n\t\t\tconst opts = LaunchProfile.create()\n\t\t\t\t.headless(false)\n\t\t\t\t.stealthMode()\n\t\t\t\t.dockerMode()\n\t\t\t\t.deterministicRendering()\n\t\t\t\t.windowSize(800, 600)\n\t\t\t\t.downloadsPath('/downloads')\n\t\t\t\t.addExtension('/ext')\n\t\t\t\t.persistAfterClose()\n\t\t\t\t.build();\n\n\t\t\texpect(opts.headless).toBe(false);\n\t\t\texpect(opts.persistAfterClose).toBe(true);\n\t\t\texpect(opts.windowWidth).toBe(800);\n\t\t\texpect(opts.windowHeight).toBe(600);\n\t\t\texpect(opts.extraArgs).toContain('--window-size=800,600');\n\n\t\t\tfor (const arg of ANTI_DETECTION_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t\tfor (const arg of CONTAINER_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t\tfor (const arg of REPRODUCIBLE_RENDER_FLAGS) {\n\t\t\t\texpect(opts.extraArgs).toContain(arg);\n\t\t\t}\n\t\t});\n\t});\n});\n\ndescribe('CHROME_AUTOMATION_FLAGS', () => {\n\ttest('is a non-empty array', () => {\n\t\texpect(Array.isArray(CHROME_AUTOMATION_FLAGS)).toBe(true);\n\t\texpect(CHROME_AUTOMATION_FLAGS.length).toBeGreaterThan(10);\n\t});\n\n\ttest('contains essential flags', () => {\n\t\texpect(CHROME_AUTOMATION_FLAGS).toContain('--no-first-run');\n\t\texpect(CHROME_AUTOMATION_FLAGS).toContain('--disable-popup-blocking');\n\t\texpect(CHROME_AUTOMATION_FLAGS).toContain('--disable-infobars');\n\t});\n\n\ttest('all entries are strings starting with --', () => {\n\t\tfor (const arg of CHROME_AUTOMATION_FLAGS) {\n\t\t\texpect(typeof arg).toBe('string');\n\t\t\texpect(arg.startsWith('--')).toBe(true);\n\t\t}\n\t});\n});\n\ndescribe('CHROME_STRIPPED_FEATURES', () => {\n\ttest('is a non-empty array', () => {\n\t\texpect(Array.isArray(CHROME_STRIPPED_FEATURES)).toBe(true);\n\t\texpect(CHROME_STRIPPED_FEATURES.length).toBeGreaterThan(10);\n\t});\n\n\ttest('contains known components', () => {\n\t\texpect(CHROME_STRIPPED_FEATURES).toContain('Translate');\n\t\texpect(CHROME_STRIPPED_FEATURES).toContain('MediaRouter');\n\t\texpect(CHROME_STRIPPED_FEATURES).toContain('Prerender2');\n\t});\n\n\ttest('all entries are non-empty strings', () => {\n\t\tfor (const component of CHROME_STRIPPED_FEATURES) {\n\t\t\texpect(typeof component).toBe('string');\n\t\t\texpect(component.length).toBeGreaterThan(0);\n\t\t}\n\t});\n});\n"
  },
  {
    "path": "packages/core/src/viewport/launch-profile.ts",
    "content": "import type { LaunchOptions } from './types.js';\nimport { Config } from '../config/config.js';\n\n/**\n * Chrome default args for automation — standard flags to disable\n * background noise, throttling, and other non-essential features.\n */\nexport const CHROME_AUTOMATION_FLAGS = [\n\t'--no-first-run',\n\t'--no-default-browser-check',\n\t'--disable-background-networking',\n\t'--disable-background-timer-throttling',\n\t'--disable-backgrounding-occluded-windows',\n\t'--disable-breakpad',\n\t'--disable-component-update',\n\t'--disable-default-apps',\n\t'--disable-dev-shm-usage',\n\t'--disable-extensions-except=',\n\t'--disable-hang-monitor',\n\t'--disable-ipc-flooding-protection',\n\t'--disable-popup-blocking',\n\t'--disable-prompt-on-repost',\n\t'--disable-renderer-backgrounding',\n\t'--disable-sync',\n\t'--disable-translate',\n\t'--metrics-recording-only',\n\t'--no-pings',\n\t'--password-store=basic',\n\t'--use-mock-keychain',\n\t'--disable-blink-features=AutomationControlled',\n\t'--disable-infobars',\n\t'--disable-session-crashed-bubble',\n\t'--force-color-profile=srgb',\n];\n\n/**\n * Chrome disabled-components flag values that reduce resource usage\n * and prevent interfering background services.\n */\nexport const CHROME_STRIPPED_FEATURES = [\n\t'InterestFeedContentSuggestions',\n\t'Translate',\n\t'OptimizationHints',\n\t'MediaRouter',\n\t'DialMediaRouteProvider',\n\t'CalculatorTool',\n\t'CrashedTabFinder',\n\t'AutofillServerCommunication',\n\t'BackgroundTracing',\n\t'NtpTiles',\n\t'OneGoogleBar',\n\t'ReadLater',\n\t'NTPArticleSuggestions',\n\t'CrossDeviceSync',\n\t'PrivacySandboxSettings4',\n\t'SidePanelPinning',\n\t'HistoryEmbeddings',\n\t'PrivacySandboxPromptV2',\n\t'GlobalMediaControls',\n\t'ComposeService',\n\t'AutofillFeature',\n\t'NTPSigninPromo',\n\t'Prerender2',\n\t'TabGroupsSave',\n];\n\nexport const ANTI_DETECTION_FLAGS = [\n\t'--disable-blink-features=AutomationControlled',\n\t'--disable-features=AutomationControlled',\n];\n\nexport const CONTAINER_FLAGS = [\n\t'--no-sandbox',\n\t'--disable-gpu',\n\t'--disable-software-rasterizer',\n\t'--disable-setuid-sandbox',\n\t'--single-process',\n];\n\nexport const RELAXED_SECURITY_FLAGS = [\n\t'--disable-web-security',\n\t'--disable-site-isolation-trials',\n\t'--disable-features=IsolateOrigins,site-per-process',\n];\n\nexport const REPRODUCIBLE_RENDER_FLAGS = [\n\t'--deterministic-mode',\n\t'--disable-skia-runtime-opts',\n\t'--disable-font-subpixel-positioning',\n\t'--force-color-profile=srgb',\n\t'--disable-lcd-text',\n];\n\n/**\n * Builder pattern for browser profile configuration.\n * Replaces the Python ViewportConfig with a fluent API.\n */\nexport class LaunchProfile {\n\tprivate options: Partial<LaunchOptions> = {};\n\tprivate _stealthMode = false;\n\tprivate _dockerMode = false;\n\tprivate _deterministicRendering = false;\n\tprivate _maxIframes = 3;\n\tprivate _downloadsPath?: string;\n\tprivate _extensions: string[] = [];\n\n\tstatic create(): LaunchProfile {\n\t\treturn new LaunchProfile();\n\t}\n\n\theadless(value = true): this {\n\t\tthis.options.headless = value;\n\t\treturn this;\n\t}\n\n\trelaxedSecurity(value = true): this {\n\t\tthis.options.relaxedSecurity = value;\n\t\treturn this;\n\t}\n\n\twindowSize(width: number, height: number): this {\n\t\tthis.options.windowWidth = width;\n\t\tthis.options.windowHeight = height;\n\t\treturn this;\n\t}\n\n\tproxy(server: string, username?: string, password?: string): this {\n\t\tthis.options.proxy = { server, username, password };\n\t\treturn this;\n\t}\n\n\tuserDataDir(dir: string): this {\n\t\tthis.options.userDataDir = dir;\n\t\treturn this;\n\t}\n\n\tbrowserBinary(path: string): this {\n\t\tthis.options.browserBinaryPath = path;\n\t\treturn this;\n\t}\n\n\tpersistAfterClose(value = true): this {\n\t\tthis.options.persistAfterClose = value;\n\t\treturn this;\n\t}\n\n\tchannel(name: string): this {\n\t\tthis.options.channelName = name;\n\t\treturn this;\n\t}\n\n\textraArgs(...args: string[]): this {\n\t\tthis.options.extraArgs = [...(this.options.extraArgs ?? []), ...args];\n\t\treturn this;\n\t}\n\n\tstealthMode(value = true): this {\n\t\tthis._stealthMode = value;\n\t\treturn this;\n\t}\n\n\tdockerMode(value = true): this {\n\t\tthis._dockerMode = value;\n\t\treturn this;\n\t}\n\n\tdeterministicRendering(value = true): this {\n\t\tthis._deterministicRendering = value;\n\t\treturn this;\n\t}\n\n\tdownloadsPath(path: string): this {\n\t\tthis._downloadsPath = path;\n\t\treturn this;\n\t}\n\n\tmaxIframes(max: number): this {\n\t\tthis._maxIframes = max;\n\t\treturn this;\n\t}\n\n\taddExtension(extensionPath: string): this {\n\t\tthis._extensions.push(extensionPath);\n\t\treturn this;\n\t}\n\n\t/**\n\t * Auto-detect and apply Docker settings if running inside a container.\n\t */\n\tautoDetect(): this {\n\t\tif (Config.isDocker()) {\n\t\t\tthis._dockerMode = true;\n\t\t\t// Force headless in Docker if no display\n\t\t\tif (!Config.hasDisplay()) {\n\t\t\t\tthis.options.headless = true;\n\t\t\t}\n\t\t}\n\t\treturn this;\n\t}\n\n\tbuild(): LaunchOptions {\n\t\tconst args = [...CHROME_AUTOMATION_FLAGS];\n\n\t\t// Disabled components\n\t\targs.push(`--disable-component-extensions-with-background-pages`);\n\t\targs.push(`--disable-features=${CHROME_STRIPPED_FEATURES.join(',')}`);\n\n\t\t// Mode-specific args\n\t\tif (this._stealthMode) {\n\t\t\targs.push(...ANTI_DETECTION_FLAGS);\n\t\t}\n\n\t\tif (this._dockerMode) {\n\t\t\targs.push(...CONTAINER_FLAGS);\n\t\t}\n\n\t\tif (this._deterministicRendering) {\n\t\t\targs.push(...REPRODUCIBLE_RENDER_FLAGS);\n\t\t}\n\n\t\tif (this.options.relaxedSecurity) {\n\t\t\targs.push(...RELAXED_SECURITY_FLAGS);\n\t\t}\n\n\t\t// Window size\n\t\tconst width = this.options.windowWidth ?? 1280;\n\t\tconst height = this.options.windowHeight ?? 1100;\n\t\targs.push(`--window-size=${width},${height}`);\n\n\t\t// Extensions\n\t\tif (this._extensions.length > 0) {\n\t\t\targs.push(`--load-extension=${this._extensions.join(',')}`);\n\t\t}\n\n\t\t// Downloads\n\t\tif (this._downloadsPath) {\n\t\t\targs.push(`--download-default-directory=${this._downloadsPath}`);\n\t\t}\n\n\t\t// User extra args (last, so they can override)\n\t\tif (this.options.extraArgs) {\n\t\t\targs.push(...this.options.extraArgs);\n\t\t}\n\n\t\treturn {\n\t\t\theadless: this.options.headless ?? true,\n\t\t\trelaxedSecurity: this.options.relaxedSecurity ?? false,\n\t\t\textraArgs: args,\n\t\t\twindowWidth: width,\n\t\t\twindowHeight: height,\n\t\t\tproxy: this.options.proxy,\n\t\t\tuserDataDir: this.options.userDataDir,\n\t\t\tbrowserBinaryPath: this.options.browserBinaryPath,\n\t\t\tpersistAfterClose: this.options.persistAfterClose ?? false,\n\t\t\tchannelName: this.options.channelName,\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/types.ts",
    "content": "import { z } from 'zod';\nimport type { TabId } from '../types.js';\n\nexport interface TabDescriptor {\n\ttabId: TabId;\n\turl: string;\n\ttitle: string;\n\tisActive: boolean;\n}\n\nexport interface ViewportSnapshot {\n\turl: string;\n\ttitle: string;\n\ttabs: TabDescriptor[];\n\tactiveTabIndex: number;\n\tscreenshot?: string;\n\tdomTree?: string;\n\tselectorMap?: Record<number, string>;\n\tpixelsAbove?: number;\n\tpixelsBelow?: number;\n}\n\nexport interface ViewportHistory {\n\turl: string;\n\ttitle: string;\n\ttabs: TabDescriptor[];\n\tinteractedElements: Array<{\n\t\tindex: number;\n\t\tdescription: string;\n\t\taction: string;\n\t}>;\n\tscreenshot?: string;\n}\n\nexport const LaunchOptionsSchema = z.object({\n\theadless: z.boolean().default(true),\n\trelaxedSecurity: z.boolean().default(false),\n\textraArgs: z.array(z.string()).default([]),\n\twindowWidth: z.number().default(1280),\n\twindowHeight: z.number().default(1100),\n\tproxy: z\n\t\t.object({\n\t\t\tserver: z.string(),\n\t\t\tusername: z.string().optional(),\n\t\t\tpassword: z.string().optional(),\n\t\t})\n\t\t.optional(),\n\tuserDataDir: z.string().optional(),\n\tbrowserBinaryPath: z.string().optional(),\n\tpersistAfterClose: z.boolean().default(false),\n\tchannelName: z.string().optional(),\n});\n\nexport type LaunchOptions = z.infer<typeof LaunchOptionsSchema>;\n\nexport interface PageState {\n\turl: string;\n\ttitle: string;\n\tcontent?: string;\n\tscreenshot?: string;\n}\n"
  },
  {
    "path": "packages/core/src/viewport/viewport.ts",
    "content": "import {\n\tchromium,\n\ttype Browser,\n\ttype BrowserContext,\n\ttype Page,\n\ttype CDPSession,\n} from 'playwright';\nimport { EventHub } from './event-hub.js';\nimport type { ViewportEventMap, ViewportRequestMap } from './events.js';\nimport type { LaunchOptions, ViewportSnapshot, TabDescriptor } from './types.js';\nimport { LaunchProfile } from './launch-profile.js';\nimport { BaseGuard, type GuardContext } from './guard-base.js';\nimport { LaunchFailedError, ViewportCrashedError } from '../errors.js';\nimport { tabId, targetId, type TargetId } from '../types.js';\nimport { createLogger } from '../logging.js';\nimport { timed } from '../telemetry.js';\nimport { isNewTabPage } from '../utils.js';\n\n// Watchdogs\nimport { LocalInstanceGuard } from './guards/local-instance.js';\nimport { UrlPolicyGuard } from './guards/url-policy.js';\nimport { DefaultHandlerGuard } from './guards/default-handler.js';\nimport { PopupGuard } from './guards/popups.js';\nimport { PageReadyGuard } from './guards/page-ready.js';\nimport { DownloadGuard } from './guards/downloads.js';\nimport { BlankPageGuard } from './guards/blank-page.js';\nimport { CrashGuard } from './guards/crash.js';\nimport { PersistenceGuard } from './guards/persistence.js';\nimport { ScreenshotGuard } from './guards/screenshot.js';\n\nconst logger = createLogger('browser-session');\n\n// ── Multi-target tracking ──\n\n/** Represents a single CDP target (page, iframe, service worker, etc.) */\nexport interface Target {\n\ttargetId: TargetId;\n\ttype: 'page' | 'iframe' | 'service_worker' | 'worker' | 'other';\n\turl: string;\n\ttitle: string;\n}\n\n/** Viewport dimensions as detected via CDP */\nexport interface ViewportInfo {\n\twidth: number;\n\theight: number;\n\tdeviceScaleFactor: number;\n\tisMobile: boolean;\n}\n\nexport interface ViewportOptions {\n\t/** Launch options (or use LaunchProfile) */\n\tlaunchOptions?: Partial<LaunchOptions>;\n\t/** Pre-built browser profile */\n\tprofile?: LaunchProfile;\n\t/** Connect to existing browser via WebSocket URL */\n\twsEndpoint?: string;\n\t/** Connect to existing browser via CDP URL */\n\tcdpUrl?: string;\n\t/** Headless mode shortcut */\n\theadless?: boolean;\n\t/** Allowed URLs for security watchdog */\n\tallowedUrls?: string[];\n\t/** Blocked URLs for security watchdog */\n\tblockedUrls?: string[];\n\t/** Storage state file path */\n\tstorageStatePath?: string;\n\t/** Extra watchdogs */\n\twatchdogs?: BaseGuard[];\n\t/** Minimum wait after page load (ms) */\n\tminWaitPageLoadMs?: number;\n\t/** Wait for network idle (ms) */\n\twaitForNetworkIdleMs?: number;\n\t/** Max wait for page load (ms) */\n\tmaxWaitPageLoadMs?: number;\n\t/** Max reconnection attempts */\n\tmaxReconnectAttempts?: number;\n\t/** Delay between reconnection attempts (ms) */\n\treconnectDelayMs?: number;\n}\n\nexport class Viewport {\n\tprivate browser: Browser | null = null;\n\tprivate context: BrowserContext | null = null;\n\tprivate _currentPage: Page | null = null;\n\tprivate cdpSession: CDPSession | null = null;\n\n\treadonly eventBus: EventHub<ViewportEventMap, ViewportRequestMap>;\n\tprivate watchdogs: BaseGuard[] = [];\n\tprivate options: ViewportOptions;\n\tprivate launchOptions: LaunchOptions;\n\tprivate _isConnected = false;\n\n\tprivate readonly minWaitPageLoadMs: number;\n\tprivate readonly waitForNetworkIdleMs: number;\n\tprivate readonly maxWaitPageLoadMs: number;\n\tprivate readonly maxReconnectAttempts: number;\n\tprivate readonly reconnectDelayMs: number;\n\n\t/** Tracks known CDP targets keyed by targetId */\n\tprivate knownTargets = new Map<string, Target>();\n\n\t/** Cached viewport info, invalidated on page/tab switch */\n\tprivate cachedViewport: ViewportInfo | null = null;\n\n\t/** Tracks whether a reconnection is currently in progress */\n\tprivate reconnecting = false;\n\n\tconstructor(options: ViewportOptions = {}) {\n\t\tthis.options = options;\n\t\tthis.eventBus = new EventHub({ maxHistory: 200 });\n\n\t\tif (options.profile) {\n\t\t\tthis.launchOptions = options.profile.build();\n\t\t} else {\n\t\t\tthis.launchOptions = {\n\t\t\t\theadless: options.headless ?? options.launchOptions?.headless ?? true,\n\t\t\t\trelaxedSecurity: options.launchOptions?.relaxedSecurity ?? false,\n\t\t\t\textraArgs: options.launchOptions?.extraArgs ?? [],\n\t\t\t\twindowWidth: options.launchOptions?.windowWidth ?? 1280,\n\t\t\t\twindowHeight: options.launchOptions?.windowHeight ?? 1100,\n\t\t\t\tproxy: options.launchOptions?.proxy,\n\t\t\t\tuserDataDir: options.launchOptions?.userDataDir,\n\t\t\t\tbrowserBinaryPath: options.launchOptions?.browserBinaryPath,\n\t\t\t\tpersistAfterClose: options.launchOptions?.persistAfterClose ?? false,\n\t\t\t\tchannelName: options.launchOptions?.channelName,\n\t\t\t};\n\t\t}\n\n\t\tthis.minWaitPageLoadMs = options.minWaitPageLoadMs ?? 500;\n\t\tthis.waitForNetworkIdleMs = options.waitForNetworkIdleMs ?? 1000;\n\t\tthis.maxWaitPageLoadMs = options.maxWaitPageLoadMs ?? 5000;\n\t\tthis.maxReconnectAttempts = options.maxReconnectAttempts ?? 3;\n\t\tthis.reconnectDelayMs = options.reconnectDelayMs ?? 1000;\n\t}\n\n\tget isConnected(): boolean {\n\t\treturn this._isConnected;\n\t}\n\n\tget currentPage(): Page {\n\t\tif (!this._currentPage) {\n\t\t\tthrow new ViewportCrashedError('No active page');\n\t\t}\n\t\treturn this._currentPage;\n\t}\n\n\tget browserContext(): BrowserContext {\n\t\tif (!this.context) {\n\t\t\tthrow new ViewportCrashedError('No active browser context');\n\t\t}\n\t\treturn this.context;\n\t}\n\n\tget cdp(): CDPSession | null {\n\t\treturn this.cdpSession;\n\t}\n\n\t// ── Lifecycle ──\n\n\tasync start(): Promise<void> {\n\t\tconst { durationMs } = await timed('browser-session.start', async () => {\n\t\t\ttry {\n\t\t\t\tlogger.info('Starting browser session');\n\n\t\t\t\tif (this.options.wsEndpoint) {\n\t\t\t\t\tlogger.debug(`Connecting via WebSocket: ${this.options.wsEndpoint}`);\n\t\t\t\t\tthis.browser = await chromium.connect(this.options.wsEndpoint);\n\t\t\t\t} else if (this.options.cdpUrl) {\n\t\t\t\t\tlogger.debug(`Connecting via CDP: ${this.options.cdpUrl}`);\n\t\t\t\t\tthis.browser = await chromium.connectOverCDP(this.options.cdpUrl);\n\t\t\t\t} else {\n\t\t\t\t\tthis.browser = await this.launchBrowser();\n\t\t\t\t}\n\n\t\t\t\tconst contexts = this.browser.contexts();\n\t\t\t\tif (contexts.length > 0) {\n\t\t\t\t\tthis.context = contexts[0];\n\t\t\t\t\tlogger.debug('Reusing existing browser context');\n\t\t\t\t} else {\n\t\t\t\t\tthis.context = await this.createContext();\n\t\t\t\t\tlogger.debug('Created new browser context');\n\t\t\t\t}\n\n\t\t\t\tconst pages = this.context.pages();\n\t\t\t\tif (pages.length > 0) {\n\t\t\t\t\tthis._currentPage = pages[0];\n\t\t\t\t} else {\n\t\t\t\t\tthis._currentPage = await this.context.newPage();\n\t\t\t\t}\n\n\t\t\t\t// Create CDP session\n\t\t\t\tthis.cdpSession = await this._currentPage.context().newCDPSession(this._currentPage);\n\n\t\t\t\tthis._isConnected = true;\n\n\t\t\t\t// Wire up disconnect detection on the browser\n\t\t\t\tthis.setupDisconnectHandler();\n\n\t\t\t\t// Discover initial targets\n\t\t\t\tawait this.refreshTargets();\n\n\t\t\t\t// Detect initial viewport via CDP\n\t\t\t\tthis.cachedViewport = null;\n\t\t\t\tawait this.detectViewport();\n\n\t\t\t\t// Initialize watchdogs\n\t\t\t\tawait this.initializeWatchdogs();\n\n\t\t\t\t// Set up page lifecycle listeners on the context\n\t\t\t\tthis.setupPageLifecycleListeners();\n\n\t\t\t\tconst pageUrl = this._currentPage.url();\n\t\t\t\tconst pageTitle = await this._currentPage.title();\n\n\t\t\t\t// Emit initial lifecycle events\n\t\t\t\tthis.eventBus.emit('content-ready', undefined as any);\n\n\t\t\t\tif (!isNewTabPage(pageUrl)) {\n\t\t\t\t\tthis.eventBus.emit('page-ready', { url: pageUrl });\n\t\t\t\t}\n\n\t\t\t\tthis.eventBus.emit('viewport-state', {\n\t\t\t\t\turl: pageUrl,\n\t\t\t\t\ttitle: pageTitle,\n\t\t\t\t\ttabCount: this.context.pages().length,\n\t\t\t\t});\n\n\t\t\t\tlogger.info(`Browser session started: ${pageUrl}`);\n\t\t\t} catch (error) {\n\t\t\t\tthrow new LaunchFailedError(\n\t\t\t\t\t`Failed to start browser: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t{ cause: error instanceof Error ? error : undefined },\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\n\t\tlogger.debug(`start() completed in ${durationMs.toFixed(1)}ms`);\n\t}\n\n\tprivate setupDisconnectHandler(): void {\n\t\tif (!this.browser) return;\n\n\t\tthis.browser.on('disconnected', () => {\n\t\t\tlogger.warn('Browser disconnected');\n\t\t\tthis._isConnected = false;\n\t\t\tthis.eventBus.emit('crash', { reason: 'Browser disconnected unexpectedly' });\n\t\t});\n\t}\n\n\tprivate setupPageLifecycleListeners(): void {\n\t\tif (!this.context) return;\n\n\t\t// Track new pages (tabs) being created\n\t\tthis.context.on('page', async (page: Page) => {\n\t\t\tconst url = page.url();\n\t\t\tlogger.debug(`New page created: ${url}`);\n\t\t\tthis.eventBus.emit('tab-opened', { url });\n\n\t\t\t// Refresh target list when new pages appear\n\t\t\tawait this.refreshTargets();\n\n\t\t\t// Emit browser-state update\n\t\t\ttry {\n\t\t\t\tthis.eventBus.emit('viewport-state', {\n\t\t\t\t\turl: this._currentPage?.url() ?? url,\n\t\t\t\t\ttitle: this._currentPage ? await this._currentPage.title() : '',\n\t\t\t\t\ttabCount: this.context?.pages().length ?? 1,\n\t\t\t\t});\n\t\t\t} catch {\n\t\t\t\t// Page might be closed already\n\t\t\t}\n\n\t\t\t// When the new page loads, emit page-loaded\n\t\t\tpage.on('load', () => {\n\t\t\t\tconst loadedUrl = page.url();\n\t\t\t\tif (!isNewTabPage(loadedUrl)) {\n\t\t\t\t\tlogger.debug(`Page loaded in new tab: ${loadedUrl}`);\n\t\t\t\t}\n\t\t\t});\n\t\t});\n\t}\n\n\t// ── Multi-target tracking ──\n\n\t/**\n\t * Queries CDP for the current list of targets (pages, iframes, workers, etc.)\n\t * and updates the internal target map.\n\t */\n\tasync getTargets(): Promise<Target[]> {\n\t\tawait this.refreshTargets();\n\t\treturn Array.from(this.knownTargets.values());\n\t}\n\n\tprivate async refreshTargets(): Promise<void> {\n\t\tif (!this.cdpSession) return;\n\n\t\ttry {\n\t\t\tconst result = await (\n\t\t\t\tthis.cdpSession.send('Target.getTargets') as Promise<unknown>\n\t\t\t) as Promise<{ targetInfos: Array<{ targetId: string; type: string; url: string; title: string }> }>;\n\n\t\t\tconst { targetInfos } = await result;\n\n\t\t\tthis.knownTargets.clear();\n\t\t\tfor (const info of targetInfos) {\n\t\t\t\tconst type = normalizeTargetType(info.type);\n\t\t\t\tthis.knownTargets.set(info.targetId, {\n\t\t\t\t\ttargetId: targetId(info.targetId),\n\t\t\t\t\ttype,\n\t\t\t\t\turl: info.url,\n\t\t\t\t\ttitle: info.title,\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tlogger.debug(`Refreshed targets: ${this.knownTargets.size} found`);\n\t\t} catch (error) {\n\t\t\tlogger.debug(\n\t\t\t\t`Failed to refresh targets: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t}\n\t}\n\n\t/**\n\t * Find a target by its targetId.\n\t */\n\tfindTarget(id: TargetId): Target | undefined {\n\t\treturn this.knownTargets.get(id);\n\t}\n\n\t/**\n\t * Get only page-type targets, filtering out new-tab pages.\n\t */\n\tasync getPageTargets(): Promise<Target[]> {\n\t\tconst targets = await this.getTargets();\n\t\treturn targets.filter((t) => t.type === 'page' && !isNewTabPage(t.url));\n\t}\n\n\t// ── Viewport detection via CDP ──\n\n\t/**\n\t * Detects the actual viewport dimensions by evaluating JavaScript in the page\n\t * via CDP Runtime.evaluate. This is more accurate than Playwright's viewportSize()\n\t * because it reflects the real rendered viewport including device pixel ratio.\n\t */\n\tasync detectViewport(): Promise<ViewportInfo> {\n\t\tif (this.cachedViewport) {\n\t\t\treturn this.cachedViewport;\n\t\t}\n\n\t\tif (!this.cdpSession) {\n\t\t\t// Fallback to launch options if no CDP session\n\t\t\tconst fallback: ViewportInfo = {\n\t\t\t\twidth: this.launchOptions.windowWidth,\n\t\t\t\theight: this.launchOptions.windowHeight,\n\t\t\t\tdeviceScaleFactor: 1,\n\t\t\t\tisMobile: false,\n\t\t\t};\n\t\t\tthis.cachedViewport = fallback;\n\t\t\treturn fallback;\n\t\t}\n\n\t\ttry {\n\t\t\tconst { result: viewportResult } = await timed('detectViewport', async () => {\n\t\t\t\tconst evalResult = await (\n\t\t\t\t\tthis.cdpSession!.send('Runtime.evaluate', {\n\t\t\t\t\t\texpression: `JSON.stringify({\n\t\t\t\t\t\t\twidth: window.innerWidth,\n\t\t\t\t\t\t\theight: window.innerHeight,\n\t\t\t\t\t\t\tdeviceScaleFactor: window.devicePixelRatio || 1,\n\t\t\t\t\t\t\tisMobile: /Mobi|Android/i.test(navigator.userAgent)\n\t\t\t\t\t\t})`,\n\t\t\t\t\t\treturnByValue: true,\n\t\t\t\t\t}) as Promise<unknown>\n\t\t\t\t) as Promise<{ result: { value: string } }>;\n\t\t\t\treturn evalResult;\n\t\t\t});\n\n\t\t\tconst parsed = JSON.parse(viewportResult.result.value) as ViewportInfo;\n\t\t\tthis.cachedViewport = parsed;\n\t\t\tlogger.debug(\n\t\t\t\t`Viewport detected: ${parsed.width}x${parsed.height} @${parsed.deviceScaleFactor}x`,\n\t\t\t);\n\t\t\treturn parsed;\n\t\t} catch (error) {\n\t\t\tlogger.warn(\n\t\t\t\t`Viewport detection failed, using defaults: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t);\n\t\t\tconst fallback: ViewportInfo = {\n\t\t\t\twidth: this.launchOptions.windowWidth,\n\t\t\t\theight: this.launchOptions.windowHeight,\n\t\t\t\tdeviceScaleFactor: 1,\n\t\t\t\tisMobile: false,\n\t\t\t};\n\t\t\tthis.cachedViewport = fallback;\n\t\t\treturn fallback;\n\t\t}\n\t}\n\n\t/** Invalidates the cached viewport, forcing a fresh CDP detection on next access. */\n\tinvalidateViewportCache(): void {\n\t\tthis.cachedViewport = null;\n\t}\n\n\t// ── Reconnection logic ──\n\n\t/**\n\t * Attempts to reconnect to the browser after a disconnect. Uses the original\n\t * connection method (wsEndpoint, cdpUrl, or local launch). Retries up to\n\t * maxReconnectAttempts with exponential backoff.\n\t *\n\t * Returns true if reconnection succeeded, false otherwise.\n\t */\n\tasync reconnect(): Promise<boolean> {\n\t\tif (this.reconnecting) {\n\t\t\tlogger.warn('Reconnection already in progress, skipping');\n\t\t\treturn false;\n\t\t}\n\n\t\tthis.reconnecting = true;\n\t\tlogger.info('Attempting to reconnect browser session');\n\n\t\ttry {\n\t\t\t// Clean up current state without emitting close event\n\t\t\tawait this.cleanupForReconnect();\n\n\t\t\tlet delay = this.reconnectDelayMs;\n\n\t\t\tfor (let attempt = 1; attempt <= this.maxReconnectAttempts; attempt++) {\n\t\t\t\tlogger.info(`Reconnect attempt ${attempt}/${this.maxReconnectAttempts}`);\n\n\t\t\t\ttry {\n\t\t\t\t\tif (this.options.wsEndpoint) {\n\t\t\t\t\t\tthis.browser = await chromium.connect(this.options.wsEndpoint);\n\t\t\t\t\t} else if (this.options.cdpUrl) {\n\t\t\t\t\t\tthis.browser = await chromium.connectOverCDP(this.options.cdpUrl);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// For locally launched browsers, we need to launch a new instance\n\t\t\t\t\t\tthis.browser = await this.launchBrowser();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Re-establish context\n\t\t\t\t\tconst contexts = this.browser.contexts();\n\t\t\t\t\tif (contexts.length > 0) {\n\t\t\t\t\t\tthis.context = contexts[0];\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis.context = await this.createContext();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Re-establish page\n\t\t\t\t\tconst pages = this.context.pages();\n\t\t\t\t\tif (pages.length > 0) {\n\t\t\t\t\t\tthis._currentPage = pages[0];\n\t\t\t\t\t} else {\n\t\t\t\t\t\tthis._currentPage = await this.context.newPage();\n\t\t\t\t\t}\n\n\t\t\t\t\t// Re-create CDP session\n\t\t\t\t\tthis.cdpSession = await this._currentPage.context().newCDPSession(this._currentPage);\n\n\t\t\t\t\tthis._isConnected = true;\n\t\t\t\t\tthis.cachedViewport = null;\n\n\t\t\t\t\t// Re-wire handlers\n\t\t\t\t\tthis.setupDisconnectHandler();\n\t\t\t\t\tthis.setupPageLifecycleListeners();\n\n\t\t\t\t\t// Refresh targets after reconnect\n\t\t\t\t\tawait this.refreshTargets();\n\n\t\t\t\t\t// Re-initialize watchdogs\n\t\t\t\t\tawait this.initializeWatchdogs();\n\n\t\t\t\t\tlogger.info(`Reconnected successfully on attempt ${attempt}`);\n\n\t\t\t\t\t// Emit lifecycle events for the reconnected state\n\t\t\t\t\tconst url = this._currentPage.url();\n\t\t\t\t\tconst title = await this._currentPage.title();\n\n\t\t\t\t\tthis.eventBus.emit('viewport-state', {\n\t\t\t\t\t\turl,\n\t\t\t\t\t\ttitle,\n\t\t\t\t\t\ttabCount: this.context.pages().length,\n\t\t\t\t\t});\n\n\t\t\t\t\treturn true;\n\t\t\t\t} catch (error) {\n\t\t\t\t\tlogger.warn(\n\t\t\t\t\t\t`Reconnect attempt ${attempt} failed: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t\t);\n\n\t\t\t\t\tif (attempt < this.maxReconnectAttempts) {\n\t\t\t\t\t\tawait new Promise((resolve) => setTimeout(resolve, delay));\n\t\t\t\t\t\tdelay *= 2; // Exponential backoff\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlogger.error(`All ${this.maxReconnectAttempts} reconnect attempts failed`);\n\t\t\tthis.eventBus.emit('crash', { reason: 'Reconnection failed after all attempts' });\n\t\t\treturn false;\n\t\t} finally {\n\t\t\tthis.reconnecting = false;\n\t\t}\n\t}\n\n\t/**\n\t * Cleans up internal state in preparation for a reconnect attempt,\n\t * without emitting lifecycle events or clearing the event bus.\n\t */\n\tprivate async cleanupForReconnect(): Promise<void> {\n\t\t// Detach watchdogs\n\t\tfor (const watchdog of this.watchdogs) {\n\t\t\ttry {\n\t\t\t\tawait watchdog.detach();\n\t\t\t} catch {\n\t\t\t\t// Ignore detach errors during reconnect\n\t\t\t}\n\t\t}\n\t\tthis.watchdogs = [];\n\n\t\t// Detach CDP session\n\t\tif (this.cdpSession) {\n\t\t\ttry {\n\t\t\t\tawait this.cdpSession.detach();\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t\tthis.cdpSession = null;\n\t\t}\n\n\t\t// Don't close the browser if connecting remotely -- it's already disconnected\n\t\tif (this.browser && !this.options.wsEndpoint && !this.options.cdpUrl) {\n\t\t\ttry {\n\t\t\t\tawait this.browser.close();\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\n\t\tthis.browser = null;\n\t\tthis.context = null;\n\t\tthis._currentPage = null;\n\t\tthis._isConnected = false;\n\t\tthis.knownTargets.clear();\n\t\tthis.cachedViewport = null;\n\t}\n\n\t// ── DOM stability ──\n\n\t/**\n\t * Waits for the DOM to stop mutating. Uses a MutationObserver injected via\n\t * page.evaluate to detect when no DOM changes occur for a quiet period.\n\t *\n\t * @param timeout - Maximum time to wait in ms (default: 3000)\n\t * @param quietPeriodMs - How long the DOM must be silent to be considered stable (default: 300)\n\t */\n\tasync waitForStableDOM(timeout = 3000, quietPeriodMs = 300): Promise<void> {\n\t\tconst page = this.currentPage;\n\n\t\tconst { durationMs } = await timed('waitForStableDOM', async () => {\n\t\t\ttry {\n\t\t\t\tawait page.evaluate(\n\t\t\t\t\t({ timeoutMs, quietMs }) => {\n\t\t\t\t\t\treturn new Promise<void>((resolve) => {\n\t\t\t\t\t\t\tlet timer: ReturnType<typeof setTimeout>;\n\t\t\t\t\t\t\tlet overallTimer: ReturnType<typeof setTimeout>;\n\n\t\t\t\t\t\t\tconst observer = new MutationObserver(() => {\n\t\t\t\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\t\t\t\t\tobserver.disconnect();\n\t\t\t\t\t\t\t\t\tclearTimeout(overallTimer);\n\t\t\t\t\t\t\t\t\tresolve();\n\t\t\t\t\t\t\t\t}, quietMs);\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\tobserver.observe(document.body, {\n\t\t\t\t\t\t\t\tchildList: true,\n\t\t\t\t\t\t\t\tsubtree: true,\n\t\t\t\t\t\t\t\tattributes: true,\n\t\t\t\t\t\t\t\tcharacterData: true,\n\t\t\t\t\t\t\t});\n\n\t\t\t\t\t\t\t// Start the quiet period timer immediately -- if no mutations\n\t\t\t\t\t\t\t// happen at all, we resolve after quietMs\n\t\t\t\t\t\t\ttimer = setTimeout(() => {\n\t\t\t\t\t\t\t\tobserver.disconnect();\n\t\t\t\t\t\t\t\tclearTimeout(overallTimer);\n\t\t\t\t\t\t\t\tresolve();\n\t\t\t\t\t\t\t}, quietMs);\n\n\t\t\t\t\t\t\t// Overall timeout: resolve even if mutations keep happening\n\t\t\t\t\t\t\toverallTimer = setTimeout(() => {\n\t\t\t\t\t\t\t\tobserver.disconnect();\n\t\t\t\t\t\t\t\tclearTimeout(timer);\n\t\t\t\t\t\t\t\tresolve();\n\t\t\t\t\t\t\t}, timeoutMs);\n\t\t\t\t\t\t});\n\t\t\t\t\t},\n\t\t\t\t\t{ timeoutMs: timeout, quietMs: quietPeriodMs },\n\t\t\t\t);\n\t\t\t} catch (error) {\n\t\t\t\t// If the page navigated or was closed, just return\n\t\t\t\tlogger.debug(\n\t\t\t\t\t`waitForStableDOM interrupted: ${error instanceof Error ? error.message : String(error)}`,\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\n\t\tlogger.debug(`DOM stabilized in ${durationMs.toFixed(1)}ms`);\n\t}\n\n\t// ── Visible HTML extraction ──\n\n\t/**\n\t * Returns the HTML of elements currently visible in the viewport.\n\t * Uses IntersectionObserver logic evaluated in-page to collect only\n\t * elements that are within the visible area, then serializes them.\n\t */\n\tasync getVisibleHtml(): Promise<string> {\n\t\tconst page = this.currentPage;\n\n\t\tconst { result: html } = await timed('getVisibleHtml', async () => {\n\t\t\treturn page.evaluate(() => {\n\t\t\t\tfunction isInViewport(el: Element): boolean {\n\t\t\t\t\tconst rect = el.getBoundingClientRect();\n\t\t\t\t\t// Element is at least partially visible\n\t\t\t\t\treturn (\n\t\t\t\t\t\trect.bottom > 0 &&\n\t\t\t\t\t\trect.right > 0 &&\n\t\t\t\t\t\trect.top < window.innerHeight &&\n\t\t\t\t\t\trect.left < window.innerWidth &&\n\t\t\t\t\t\trect.width > 0 &&\n\t\t\t\t\t\trect.height > 0\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\tfunction isVisible(el: Element): boolean {\n\t\t\t\t\tconst style = window.getComputedStyle(el);\n\t\t\t\t\treturn (\n\t\t\t\t\t\tstyle.display !== 'none' &&\n\t\t\t\t\t\tstyle.visibility !== 'hidden' &&\n\t\t\t\t\t\tstyle.opacity !== '0' &&\n\t\t\t\t\t\tisInViewport(el)\n\t\t\t\t\t);\n\t\t\t\t}\n\n\t\t\t\t// Walk the DOM and collect visible top-level elements\n\t\t\t\tconst visibleParts: string[] = [];\n\t\t\t\tconst body = document.body;\n\t\t\t\tif (!body) return '<body></body>';\n\n\t\t\t\t// Collect direct children of body that are visible,\n\t\t\t\t// or recurse one level for major containers\n\t\t\t\tfor (const child of Array.from(body.children)) {\n\t\t\t\t\tif (isVisible(child)) {\n\t\t\t\t\t\t// Clone the element and remove hidden descendants\n\t\t\t\t\t\tconst clone = child.cloneNode(true) as Element;\n\t\t\t\t\t\tconst hiddenDescendants = Array.from(clone.querySelectorAll('*')).filter(\n\t\t\t\t\t\t\t(desc) => {\n\t\t\t\t\t\t\t\tconst s = window.getComputedStyle(desc);\n\t\t\t\t\t\t\t\treturn s.display === 'none' || s.visibility === 'hidden';\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t);\n\t\t\t\t\t\tfor (const hidden of hiddenDescendants) {\n\t\t\t\t\t\t\thidden.remove();\n\t\t\t\t\t\t}\n\t\t\t\t\t\tvisibleParts.push(clone.outerHTML);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tif (visibleParts.length === 0) {\n\t\t\t\t\t// Fallback: return the body's innerHTML truncated\n\t\t\t\t\treturn body.innerHTML.slice(0, 50000);\n\t\t\t\t}\n\n\t\t\t\treturn visibleParts.join('\\n');\n\t\t\t});\n\t\t});\n\n\t\treturn html;\n\t}\n\n\t// ── Launch & context setup (existing) ──\n\n\tprivate async launchBrowser(): Promise<Browser> {\n\t\tconst args = this.buildChromiumArgs();\n\n\t\tlogger.debug(`Launching chromium with ${args.length} args`);\n\n\t\treturn chromium.launch({\n\t\t\theadless: this.launchOptions.headless,\n\t\t\targs,\n\t\t\texecutablePath: this.launchOptions.browserBinaryPath || undefined,\n\t\t\tchannel: this.launchOptions.channelName || undefined,\n\t\t\tproxy: this.launchOptions.proxy\n\t\t\t\t? {\n\t\t\t\t\t\tserver: this.launchOptions.proxy.server,\n\t\t\t\t\t\tusername: this.launchOptions.proxy.username,\n\t\t\t\t\t\tpassword: this.launchOptions.proxy.password,\n\t\t\t\t\t}\n\t\t\t\t: undefined,\n\t\t});\n\t}\n\n\tprivate buildChromiumArgs(): string[] {\n\t\tconst args = [\n\t\t\t`--window-size=${this.launchOptions.windowWidth},${this.launchOptions.windowHeight}`,\n\t\t\t...this.launchOptions.extraArgs,\n\t\t];\n\n\t\tif (this.launchOptions.relaxedSecurity) {\n\t\t\targs.push(\n\t\t\t\t'--disable-web-security',\n\t\t\t\t'--disable-site-isolation-trials',\n\t\t\t\t'--disable-features=IsolateOrigins,site-per-process',\n\t\t\t);\n\t\t}\n\n\t\treturn args;\n\t}\n\n\tprivate async createContext(): Promise<BrowserContext> {\n\t\tconst context = await this.browser!.newContext({\n\t\t\tviewport: {\n\t\t\t\twidth: this.launchOptions.windowWidth,\n\t\t\t\theight: this.launchOptions.windowHeight,\n\t\t\t},\n\t\t\tuserAgent: undefined, // Use default\n\t\t\tjavaScriptEnabled: true,\n\t\t\tignoreHTTPSErrors: this.launchOptions.relaxedSecurity,\n\t\t\tacceptDownloads: true,\n\t\t});\n\n\t\treturn context;\n\t}\n\n\tprivate async initializeWatchdogs(): Promise<void> {\n\t\tconst ctx: GuardContext = {\n\t\t\tpage: this._currentPage!,\n\t\t\tcontext: this.context!,\n\t\t\teventBus: this.eventBus,\n\t\t};\n\n\t\t// Create default watchdogs\n\t\tthis.watchdogs = [\n\t\t\tnew LocalInstanceGuard(),\n\t\t\tnew UrlPolicyGuard(this.options.allowedUrls, this.options.blockedUrls),\n\t\t\tnew DefaultHandlerGuard(),\n\t\t\tnew PopupGuard(),\n\t\t\tnew PageReadyGuard(),\n\t\t\tnew DownloadGuard(),\n\t\t\tnew BlankPageGuard(),\n\t\t\tnew CrashGuard(),\n\t\t\tnew ScreenshotGuard(),\n\t\t\t...(this.options.watchdogs ?? []),\n\t\t];\n\n\t\tif (this.options.storageStatePath) {\n\t\t\tthis.watchdogs.push(new PersistenceGuard(this.options.storageStatePath));\n\t\t}\n\n\t\t// Sort by priority (lower = higher priority)\n\t\tthis.watchdogs.sort((a, b) => a.priority - b.priority);\n\n\t\t// Attach all watchdogs\n\t\tfor (const watchdog of this.watchdogs) {\n\t\t\tawait watchdog.attach(ctx);\n\t\t}\n\n\t\tlogger.debug(`Initialized ${this.watchdogs.length} watchdogs`);\n\t}\n\n\t// ── Navigation & interaction (existing, enhanced) ──\n\n\tasync navigate(url: string): Promise<void> {\n\t\tconst page = this.currentPage;\n\n\t\tlogger.debug(`Navigating to: ${url}`);\n\n\t\ttry {\n\t\t\tawait page.goto(url, {\n\t\t\t\twaitUntil: 'domcontentloaded',\n\t\t\t\ttimeout: this.maxWaitPageLoadMs,\n\t\t\t});\n\t\t} catch (error) {\n\t\t\t// Timeout is OK, page might still be loading\n\t\t\tif (error instanceof Error && !error.message.includes('Timeout')) {\n\t\t\t\tthrow error;\n\t\t\t}\n\t\t}\n\n\t\tawait this.waitForPageReady();\n\n\t\t// Invalidate viewport cache after navigation (page dimensions may change)\n\t\tthis.cachedViewport = null;\n\n\t\t// Refresh targets (navigation may create/destroy targets)\n\t\tawait this.refreshTargets();\n\n\t\tthis.eventBus.emit('page-ready', { url: page.url() });\n\t\tthis.eventBus.emit('viewport-state', {\n\t\t\turl: page.url(),\n\t\t\ttitle: await page.title(),\n\t\t\ttabCount: this.context!.pages().length,\n\t\t});\n\t}\n\n\tasync waitForPageReady(): Promise<void> {\n\t\tconst page = this.currentPage;\n\n\t\t// Minimum wait\n\t\tawait new Promise((resolve) => setTimeout(resolve, this.minWaitPageLoadMs));\n\n\t\t// Wait for network idle\n\t\ttry {\n\t\t\tawait page.waitForLoadState('networkidle', {\n\t\t\t\ttimeout: this.waitForNetworkIdleMs,\n\t\t\t});\n\t\t} catch {\n\t\t\t// Timeout is OK\n\t\t}\n\t}\n\n\tasync click(selector: string): Promise<void> {\n\t\tawait this.currentPage.click(selector, { timeout: 5000 });\n\t}\n\n\tasync type(selector: string, text: string): Promise<void> {\n\t\tawait this.currentPage.fill(selector, text);\n\t}\n\n\tasync pressKey(key: string): Promise<void> {\n\t\tawait this.currentPage.keyboard.press(key);\n\t}\n\n\tasync screenshot(fullPage = false): Promise<{ base64: string; width: number; height: number }> {\n\t\tconst page = this.currentPage;\n\t\tconst buffer = await page.screenshot({\n\t\t\tfullPage,\n\t\t\ttype: 'png',\n\t\t});\n\t\tconst base64 = buffer.toString('base64');\n\t\tconst viewport = page.viewportSize();\n\n\t\treturn {\n\t\t\tbase64,\n\t\t\twidth: viewport?.width ?? this.launchOptions.windowWidth,\n\t\t\theight: viewport?.height ?? this.launchOptions.windowHeight,\n\t\t};\n\t}\n\n\tasync getState(): Promise<ViewportSnapshot> {\n\t\tconst page = this.currentPage;\n\t\tconst pages = this.context!.pages();\n\t\tconst activeIndex = pages.indexOf(page);\n\n\t\tconst tabs: TabDescriptor[] = pages.map((p, i) => ({\n\t\t\ttabId: tabId(i),\n\t\t\turl: p.url(),\n\t\t\ttitle: '', // Will be populated async\n\t\t\tisActive: i === activeIndex,\n\t\t}));\n\n\t\t// Get titles in parallel\n\t\tawait Promise.all(\n\t\t\ttabs.map(async (tab, i) => {\n\t\t\t\ttry {\n\t\t\t\t\ttab.title = await pages[i].title();\n\t\t\t\t} catch {\n\t\t\t\t\ttab.title = '';\n\t\t\t\t}\n\t\t\t}),\n\t\t);\n\n\t\treturn {\n\t\t\turl: page.url(),\n\t\t\ttitle: await page.title(),\n\t\t\ttabs,\n\t\t\tactiveTabIndex: activeIndex,\n\t\t};\n\t}\n\n\tasync switchTab(tabIndex: number): Promise<void> {\n\t\tconst pages = this.context!.pages();\n\t\tif (tabIndex < 0 || tabIndex >= pages.length) {\n\t\t\tthrow new Error(`Invalid tab index: ${tabIndex}. Available tabs: ${pages.length}`);\n\t\t}\n\n\t\tthis._currentPage = pages[tabIndex];\n\t\tawait this._currentPage.bringToFront();\n\n\t\t// Re-create CDP session for new page\n\t\tthis.cdpSession = await this._currentPage.context().newCDPSession(this._currentPage);\n\n\t\t// Invalidate viewport cache when switching tabs\n\t\tthis.cachedViewport = null;\n\n\t\t// Refresh target list\n\t\tawait this.refreshTargets();\n\n\t\tthis.eventBus.emit('tab-changed', { tabIndex });\n\t}\n\n\tasync closeTab(tabIndex?: number): Promise<void> {\n\t\tconst pages = this.context!.pages();\n\t\tconst index = tabIndex ?? pages.indexOf(this.currentPage);\n\n\t\tif (pages.length <= 1) {\n\t\t\tthrow new Error('Cannot close the last tab');\n\t\t}\n\n\t\tconst pageToClose = pages[index];\n\t\tawait pageToClose.close();\n\n\t\t// Switch to remaining page\n\t\tconst remainingPages = this.context!.pages();\n\t\tif (remainingPages.length > 0) {\n\t\t\tconst newIndex = Math.min(index, remainingPages.length - 1);\n\t\t\tthis._currentPage = remainingPages[newIndex];\n\t\t\tawait this._currentPage.bringToFront();\n\t\t\tthis.cdpSession = await this._currentPage.context().newCDPSession(this._currentPage);\n\t\t}\n\n\t\t// Invalidate caches\n\t\tthis.cachedViewport = null;\n\n\t\t// Refresh targets after closing a tab\n\t\tawait this.refreshTargets();\n\n\t\tthis.eventBus.emit('tab-closed', { tabIndex: index });\n\t}\n\n\tasync newTab(url?: string): Promise<void> {\n\t\tconst page = await this.context!.newPage();\n\t\tthis._currentPage = page;\n\n\t\tif (url) {\n\t\t\tawait this.navigate(url);\n\t\t}\n\n\t\tthis.cdpSession = await this._currentPage.context().newCDPSession(this._currentPage);\n\n\t\t// Invalidate caches\n\t\tthis.cachedViewport = null;\n\t}\n\n\tasync evaluate<T>(expression: string): Promise<T> {\n\t\treturn this.currentPage.evaluate(expression) as Promise<T>;\n\t}\n\n\tasync setPage(page: Page): Promise<void> {\n\t\tthis._currentPage = page;\n\t\tthis.cdpSession = await page.context().newCDPSession(page);\n\t\tthis.cachedViewport = null;\n\t}\n\n\t// ── Cleanup ──\n\n\tasync close(): Promise<void> {\n\t\tlogger.info('Closing browser session');\n\n\t\t// Detach all watchdogs\n\t\tfor (const watchdog of this.watchdogs) {\n\t\t\tawait watchdog.detach();\n\t\t}\n\t\tthis.watchdogs = [];\n\n\t\t// Close CDP session\n\t\tif (this.cdpSession) {\n\t\t\ttry {\n\t\t\t\tawait this.cdpSession.detach();\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t\tthis.cdpSession = null;\n\t\t}\n\n\t\t// Close browser\n\t\tif (this.browser && !this.launchOptions.persistAfterClose) {\n\t\t\ttry {\n\t\t\t\tawait this.browser.close();\n\t\t\t} catch {\n\t\t\t\t// Ignore\n\t\t\t}\n\t\t}\n\n\t\tthis.browser = null;\n\t\tthis.context = null;\n\t\tthis._currentPage = null;\n\t\tthis._isConnected = false;\n\t\tthis.knownTargets.clear();\n\t\tthis.cachedViewport = null;\n\n\t\tthis.eventBus.emit('shutdown', undefined as any);\n\t\tthis.eventBus.removeAllListeners();\n\n\t\tlogger.info('Browser session closed');\n\t}\n\n\t// AsyncDisposable support\n\tasync [Symbol.asyncDispose](): Promise<void> {\n\t\tawait this.close();\n\t}\n}\n\n// ── Helpers ──\n\n/**\n * Normalizes a CDP target type string to our Target type union.\n */\nfunction normalizeTargetType(\n\tcdpType: string,\n): 'page' | 'iframe' | 'service_worker' | 'worker' | 'other' {\n\tswitch (cdpType) {\n\t\tcase 'page':\n\t\t\treturn 'page';\n\t\tcase 'iframe':\n\t\t\treturn 'iframe';\n\t\tcase 'service_worker':\n\t\t\treturn 'service_worker';\n\t\tcase 'worker':\n\t\tcase 'shared_worker':\n\t\t\treturn 'worker';\n\t\tdefault:\n\t\t\treturn 'other';\n\t}\n}\n"
  },
  {
    "path": "packages/core/src/viewport/visual-tracer.ts",
    "content": "import type { Page } from 'playwright';\n\nexport interface VisualTracerOptions {\n\thighlightColor?: string;\n\thighlightDuration?: number;\n\tannotationFontSize?: number;\n\tshowTimeline?: boolean;\n\tshowCoordinates?: boolean;\n\tactionColors?: Record<string, string>;\n}\n\nconst DEFAULT_OPTIONS: Required<VisualTracerOptions> = {\n\thighlightColor: 'rgba(255, 0, 0, 0.3)',\n\thighlightDuration: 2000,\n\tannotationFontSize: 14,\n\tshowTimeline: false,\n\tshowCoordinates: false,\n\tactionColors: {\n\t\tclick: '#ff4444',\n\t\tscroll: '#44aaff',\n\t\ttype: '#44cc44',\n\t\tnavigate: '#ff9900',\n\t\tdefault: '#aa44ff',\n\t},\n};\n\nconst OVERLAY_ATTR = 'data-demo-mode-overlay';\n\nexport class VisualTracer {\n\tprivate options: Required<VisualTracerOptions>;\n\n\tconstructor(options?: VisualTracerOptions) {\n\t\tthis.options = {\n\t\t\t...DEFAULT_OPTIONS,\n\t\t\t...options,\n\t\t\tactionColors: { ...DEFAULT_OPTIONS.actionColors, ...options?.actionColors },\n\t\t};\n\t}\n\n\t// ───────────────────────────────────────────\n\t// Existing methods\n\t// ───────────────────────────────────────────\n\n\tasync highlightElement(page: Page, selector: string, label?: string): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ selector, color, duration, label, fontSize, attr }) => {\n\t\t\t\tconst element = document.querySelector(selector);\n\t\t\t\tif (!element) return;\n\n\t\t\t\tconst rect = element.getBoundingClientRect();\n\t\t\t\tconst overlay = document.createElement('div');\n\t\t\t\toverlay.setAttribute(attr, '');\n\t\t\t\toverlay.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: ${rect.left}px;\n\t\t\t\t\ttop: ${rect.top}px;\n\t\t\t\t\twidth: ${rect.width}px;\n\t\t\t\t\theight: ${rect.height}px;\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\tborder: 2px solid red;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t\ttransition: opacity 0.3s;\n\t\t\t\t`;\n\n\t\t\t\tif (label) {\n\t\t\t\t\tconst labelEl = document.createElement('div');\n\t\t\t\t\tlabelEl.textContent = label;\n\t\t\t\t\tlabelEl.style.cssText = `\n\t\t\t\t\t\tposition: absolute;\n\t\t\t\t\t\ttop: -24px;\n\t\t\t\t\t\tleft: 0;\n\t\t\t\t\t\tbackground: red;\n\t\t\t\t\t\tcolor: white;\n\t\t\t\t\t\tpadding: 2px 6px;\n\t\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\t\tborder-radius: 3px;\n\t\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\t`;\n\t\t\t\t\toverlay.appendChild(labelEl);\n\t\t\t\t}\n\n\t\t\t\tdocument.body.appendChild(overlay);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\toverlay.style.opacity = '0';\n\t\t\t\t\tsetTimeout(() => overlay.remove(), 300);\n\t\t\t\t}, duration);\n\t\t\t},\n\t\t\t{\n\t\t\t\tselector,\n\t\t\t\tcolor: this.options.highlightColor,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tlabel,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\tasync showAction(page: Page, action: string, details?: string): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ action, details, fontSize, attr }) => {\n\t\t\t\tconst toast = document.createElement('div');\n\t\t\t\ttoast.setAttribute(attr, '');\n\t\t\t\ttoast.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tbottom: 20px;\n\t\t\t\t\tright: 20px;\n\t\t\t\t\tbackground: rgba(0, 0, 0, 0.8);\n\t\t\t\t\tcolor: white;\n\t\t\t\t\tpadding: 12px 20px;\n\t\t\t\t\tborder-radius: 8px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t\tmax-width: 400px;\n\t\t\t\t\ttransition: opacity 0.3s;\n\t\t\t\t`;\n\t\t\t\ttoast.innerHTML = `<strong>${action}</strong>${details ? `<br>${details}` : ''}`;\n\n\t\t\t\tdocument.body.appendChild(toast);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\ttoast.style.opacity = '0';\n\t\t\t\t\tsetTimeout(() => toast.remove(), 300);\n\t\t\t\t}, 2000);\n\t\t\t},\n\t\t\t{ action, details, fontSize: this.options.annotationFontSize, attr: OVERLAY_ATTR },\n\t\t);\n\t}\n\n\t// ───────────────────────────────────────────\n\t// Action-specific visual overlays\n\t// ───────────────────────────────────────────\n\n\t/**\n\t * Shows an expanding circle animation at the given click coordinates.\n\t * Optionally displays a label next to the click point.\n\t */\n\tasync highlightClick(page: Page, x: number, y: number, label?: string): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ x, y, label, color, duration, fontSize, attr }) => {\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\t// Inject keyframes for the expanding ring\n\t\t\t\tconst styleEl = document.createElement('style');\n\t\t\t\tstyleEl.textContent = `\n\t\t\t\t\t@keyframes demo-click-ring {\n\t\t\t\t\t\t0% { transform: translate(-50%, -50%) scale(0); opacity: 1; }\n\t\t\t\t\t\t70% { opacity: 0.6; }\n\t\t\t\t\t\t100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(styleEl);\n\n\t\t\t\t// Create three staggered rings for a ripple effect\n\t\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\t\tconst ring = document.createElement('div');\n\t\t\t\t\tring.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: ${x}px;\n\t\t\t\t\t\ttop: ${y}px;\n\t\t\t\t\t\twidth: 60px;\n\t\t\t\t\t\theight: 60px;\n\t\t\t\t\t\tborder: 3px solid ${color};\n\t\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t\tanimation: demo-click-ring ${duration * 0.6}ms ease-out ${i * 120}ms forwards;\n\t\t\t\t\t`;\n\t\t\t\t\tcontainer.appendChild(ring);\n\t\t\t\t}\n\n\t\t\t\t// Small filled dot at click center\n\t\t\t\tconst dot = document.createElement('div');\n\t\t\t\tdot.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: ${x}px;\n\t\t\t\t\ttop: ${y}px;\n\t\t\t\t\twidth: 10px;\n\t\t\t\t\theight: 10px;\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\ttransform: translate(-50%, -50%);\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\ttransition: opacity 0.3s;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(dot);\n\n\t\t\t\t// Optional label\n\t\t\t\tif (label) {\n\t\t\t\t\tconst labelEl = document.createElement('div');\n\t\t\t\t\tlabelEl.textContent = label;\n\t\t\t\t\tlabelEl.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: ${x + 16}px;\n\t\t\t\t\t\ttop: ${y - 12}px;\n\t\t\t\t\t\tbackground: ${color};\n\t\t\t\t\t\tcolor: white;\n\t\t\t\t\t\tpadding: 2px 8px;\n\t\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\t\tborder-radius: 3px;\n\t\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t`;\n\t\t\t\t\tcontainer.appendChild(labelEl);\n\t\t\t\t}\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tcontainer.style.opacity = '0';\n\t\t\t\t\tsetTimeout(() => container.remove(), 300);\n\t\t\t\t}, duration);\n\t\t\t},\n\t\t\t{\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tlabel,\n\t\t\t\tcolor: this.options.actionColors.click,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t/**\n\t * Shows an arrow animation indicating the scroll direction.\n\t */\n\tasync highlightScroll(page: Page, direction: 'up' | 'down'): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ direction, color, duration, fontSize, attr }) => {\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\tconst styleEl = document.createElement('style');\n\t\t\t\tconst translateY = direction === 'up' ? '-40px' : '40px';\n\t\t\t\tstyleEl.textContent = `\n\t\t\t\t\t@keyframes demo-scroll-arrow {\n\t\t\t\t\t\t0% { opacity: 0; transform: translateX(-50%) translateY(0); }\n\t\t\t\t\t\t30% { opacity: 1; }\n\t\t\t\t\t\t100% { opacity: 0; transform: translateX(-50%) translateY(${translateY}); }\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(styleEl);\n\n\t\t\t\tconst arrowChar = direction === 'up' ? '\\u25B2' : '\\u25BC';\n\n\t\t\t\t// Show three staggered arrows along the right side\n\t\t\t\tfor (let i = 0; i < 3; i++) {\n\t\t\t\t\tconst arrow = document.createElement('div');\n\t\t\t\t\tconst topOffset = direction === 'up' ? 60 + i * 40 : 40 + i * 40;\n\t\t\t\t\tarrow.textContent = arrowChar;\n\t\t\t\t\tarrow.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tright: 30px;\n\t\t\t\t\t\ttop: ${topOffset}%;\n\t\t\t\t\t\ttransform: translateX(-50%);\n\t\t\t\t\t\tcolor: ${color};\n\t\t\t\t\t\tfont-size: ${fontSize * 2}px;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t\tanimation: demo-scroll-arrow ${duration * 0.5}ms ease-out ${i * 150}ms forwards;\n\t\t\t\t\t`;\n\t\t\t\t\tcontainer.appendChild(arrow);\n\t\t\t\t}\n\n\t\t\t\t// Direction label\n\t\t\t\tconst label = document.createElement('div');\n\t\t\t\tlabel.textContent = `Scroll ${direction}`;\n\t\t\t\tlabel.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tright: 12px;\n\t\t\t\t\ttop: 50%;\n\t\t\t\t\ttransform: translateY(-50%);\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\tcolor: white;\n\t\t\t\t\tpadding: 4px 12px;\n\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\tborder-radius: 4px;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\ttransition: opacity 0.3s;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(label);\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tcontainer.style.opacity = '0';\n\t\t\t\t\tsetTimeout(() => container.remove(), 300);\n\t\t\t\t}, duration);\n\t\t\t},\n\t\t\t{\n\t\t\t\tdirection,\n\t\t\t\tcolor: this.options.actionColors.scroll,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t/**\n\t * Shows a keyboard icon animation near the target element with a preview of the text being typed.\n\t */\n\tasync highlightType(page: Page, selector: string, text: string): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ selector, text, color, duration, fontSize, attr }) => {\n\t\t\t\tconst element = document.querySelector(selector);\n\t\t\t\tif (!element) return;\n\n\t\t\t\tconst rect = element.getBoundingClientRect();\n\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\tconst styleEl = document.createElement('style');\n\t\t\t\tstyleEl.textContent = `\n\t\t\t\t\t@keyframes demo-type-blink {\n\t\t\t\t\t\t0%, 100% { border-right-color: transparent; }\n\t\t\t\t\t\t50% { border-right-color: white; }\n\t\t\t\t\t}\n\t\t\t\t\t@keyframes demo-type-fadein {\n\t\t\t\t\t\t0% { opacity: 0; transform: translateY(4px); }\n\t\t\t\t\t\t100% { opacity: 1; transform: translateY(0); }\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(styleEl);\n\n\t\t\t\t// Highlight the target element\n\t\t\t\tconst highlight = document.createElement('div');\n\t\t\t\thighlight.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: ${rect.left - 2}px;\n\t\t\t\t\ttop: ${rect.top - 2}px;\n\t\t\t\t\twidth: ${rect.width + 4}px;\n\t\t\t\t\theight: ${rect.height + 4}px;\n\t\t\t\t\tborder: 2px solid ${color};\n\t\t\t\t\tborder-radius: 3px;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\ttransition: opacity 0.3s;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(highlight);\n\n\t\t\t\t// Keyboard icon (simplified as a unicode symbol + label)\n\t\t\t\tconst kbIcon = document.createElement('div');\n\t\t\t\tkbIcon.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: ${rect.left}px;\n\t\t\t\t\ttop: ${rect.bottom + 6}px;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tgap: 6px;\n\t\t\t\t\tanimation: demo-type-fadein 0.2s ease-out forwards;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t`;\n\n\t\t\t\tconst iconSpan = document.createElement('span');\n\t\t\t\ticonSpan.textContent = '\\u2328';\n\t\t\t\ticonSpan.style.cssText = `\n\t\t\t\t\tfont-size: ${fontSize * 1.4}px;\n\t\t\t\t\tcolor: ${color};\n\t\t\t\t`;\n\t\t\t\tkbIcon.appendChild(iconSpan);\n\n\t\t\t\t// Text preview bubble with blinking cursor\n\t\t\t\tconst textBubble = document.createElement('div');\n\t\t\t\tconst truncated = text.length > 40 ? `${text.slice(0, 37)}...` : text;\n\t\t\t\ttextBubble.textContent = truncated;\n\t\t\t\ttextBubble.style.cssText = `\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\tcolor: white;\n\t\t\t\t\tpadding: 3px 10px;\n\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\tborder-radius: 4px;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\tborder-right: 2px solid white;\n\t\t\t\t\tanimation: demo-type-blink 0.7s step-end infinite;\n\t\t\t\t`;\n\t\t\t\tkbIcon.appendChild(textBubble);\n\n\t\t\t\tcontainer.appendChild(kbIcon);\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tcontainer.style.opacity = '0';\n\t\t\t\t\tsetTimeout(() => container.remove(), 300);\n\t\t\t\t}, duration);\n\t\t\t},\n\t\t\t{\n\t\t\t\tselector,\n\t\t\t\ttext,\n\t\t\t\tcolor: this.options.actionColors.type,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t/**\n\t * Shows a URL bar-like overlay at the top of the viewport to indicate navigation.\n\t */\n\tasync highlightNavigation(page: Page, url: string): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ url, color, duration, fontSize, attr }) => {\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\tconst styleEl = document.createElement('style');\n\t\t\t\tstyleEl.textContent = `\n\t\t\t\t\t@keyframes demo-nav-slide {\n\t\t\t\t\t\t0% { transform: translateY(-100%); opacity: 0; }\n\t\t\t\t\t\t15% { transform: translateY(0); opacity: 1; }\n\t\t\t\t\t\t85% { transform: translateY(0); opacity: 1; }\n\t\t\t\t\t\t100% { transform: translateY(-100%); opacity: 0; }\n\t\t\t\t\t}\n\t\t\t\t\t@keyframes demo-nav-progress {\n\t\t\t\t\t\t0% { width: 0%; }\n\t\t\t\t\t\t100% { width: 100%; }\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(styleEl);\n\n\t\t\t\t// URL bar\n\t\t\t\tconst bar = document.createElement('div');\n\t\t\t\tbar.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tbackground: rgba(0, 0, 0, 0.9);\n\t\t\t\t\tpadding: 10px 16px;\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tgap: 10px;\n\t\t\t\t\tanimation: demo-nav-slide ${duration}ms ease-in-out forwards;\n\t\t\t\t\tborder-bottom: 2px solid ${color};\n\t\t\t\t`;\n\n\t\t\t\t// Globe icon\n\t\t\t\tconst globe = document.createElement('span');\n\t\t\t\tglobe.textContent = '\\uD83C\\uDF10';\n\t\t\t\tglobe.style.cssText = `font-size: ${fontSize * 1.2}px;`;\n\t\t\t\tbar.appendChild(globe);\n\n\t\t\t\t// URL text in a pill\n\t\t\t\tconst urlPill = document.createElement('div');\n\t\t\t\turlPill.style.cssText = `\n\t\t\t\t\tflex: 1;\n\t\t\t\t\tbackground: rgba(255, 255, 255, 0.1);\n\t\t\t\t\tborder: 1px solid rgba(255, 255, 255, 0.2);\n\t\t\t\t\tborder-radius: 20px;\n\t\t\t\t\tpadding: 6px 14px;\n\t\t\t\t\tcolor: white;\n\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\toverflow: hidden;\n\t\t\t\t\ttext-overflow: ellipsis;\n\t\t\t\t`;\n\t\t\t\turlPill.textContent = url;\n\t\t\t\tbar.appendChild(urlPill);\n\n\t\t\t\t// Navigate label\n\t\t\t\tconst label = document.createElement('div');\n\t\t\t\tlabel.textContent = 'Navigate';\n\t\t\t\tlabel.style.cssText = `\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\tcolor: white;\n\t\t\t\t\tpadding: 4px 10px;\n\t\t\t\t\tfont-size: ${fontSize - 2}px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\tborder-radius: 4px;\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t`;\n\t\t\t\tbar.appendChild(label);\n\n\t\t\t\tcontainer.appendChild(bar);\n\n\t\t\t\t// Progress bar\n\t\t\t\tconst progress = document.createElement('div');\n\t\t\t\tprogress.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\theight: 3px;\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\tanimation: demo-nav-progress ${duration * 0.7}ms ease-out forwards;\n\t\t\t\t\tz-index: 1;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(progress);\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => container.remove(), duration + 100);\n\t\t\t},\n\t\t\t{\n\t\t\t\turl,\n\t\t\t\tcolor: this.options.actionColors.navigate,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t// ───────────────────────────────────────────\n\t// Multi-element and composite overlays\n\t// ───────────────────────────────────────────\n\n\t/**\n\t * Highlights multiple elements with numbered labels, useful for showing a sequence of targets.\n\t */\n\tasync showElementSequence(\n\t\tpage: Page,\n\t\telements: Array<{ selector: string; label: string }>,\n\t): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ elements, color, duration, fontSize, attr }) => {\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\tconst styleEl = document.createElement('style');\n\t\t\t\tstyleEl.textContent = `\n\t\t\t\t\t@keyframes demo-seq-appear {\n\t\t\t\t\t\t0% { transform: scale(0); opacity: 0; }\n\t\t\t\t\t\t60% { transform: scale(1.15); }\n\t\t\t\t\t\t100% { transform: scale(1); opacity: 1; }\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(styleEl);\n\n\t\t\t\t// Draw connecting lines between sequential elements\n\t\t\t\tconst rects: DOMRect[] = [];\n\t\t\t\tfor (const { selector } of elements) {\n\t\t\t\t\tconst el = document.querySelector(selector);\n\t\t\t\t\tif (el) {\n\t\t\t\t\t\trects.push(el.getBoundingClientRect());\n\t\t\t\t\t} else {\n\t\t\t\t\t\trects.push(new DOMRect(0, 0, 0, 0));\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// SVG for connecting lines\n\t\t\t\tif (rects.length > 1) {\n\t\t\t\t\tconst svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n\t\t\t\t\tsvg.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: 0;\n\t\t\t\t\t\ttop: 0;\n\t\t\t\t\t\twidth: 100%;\n\t\t\t\t\t\theight: 100%;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t`;\n\t\t\t\t\tfor (let i = 0; i < rects.length - 1; i++) {\n\t\t\t\t\t\tconst from = rects[i];\n\t\t\t\t\t\tconst to = rects[i + 1];\n\t\t\t\t\t\tif (from.width === 0 || to.width === 0) continue;\n\t\t\t\t\t\tconst line = document.createElementNS('http://www.w3.org/2000/svg', 'line');\n\t\t\t\t\t\tline.setAttribute('x1', String(from.left + from.width / 2));\n\t\t\t\t\t\tline.setAttribute('y1', String(from.top + from.height / 2));\n\t\t\t\t\t\tline.setAttribute('x2', String(to.left + to.width / 2));\n\t\t\t\t\t\tline.setAttribute('y2', String(to.top + to.height / 2));\n\t\t\t\t\t\tline.setAttribute('stroke', color);\n\t\t\t\t\t\tline.setAttribute('stroke-width', '2');\n\t\t\t\t\t\tline.setAttribute('stroke-dasharray', '6,4');\n\t\t\t\t\t\tline.setAttribute('opacity', '0.5');\n\t\t\t\t\t\tsvg.appendChild(line);\n\t\t\t\t\t}\n\t\t\t\t\tcontainer.appendChild(svg);\n\t\t\t\t}\n\n\t\t\t\t// Numbered badges and highlight boxes for each element\n\t\t\t\telements.forEach(({ selector, label }, index) => {\n\t\t\t\t\tconst el = document.querySelector(selector);\n\t\t\t\t\tif (!el) return;\n\n\t\t\t\t\tconst rect = el.getBoundingClientRect();\n\n\t\t\t\t\t// Highlight box\n\t\t\t\t\tconst box = document.createElement('div');\n\t\t\t\t\tbox.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: ${rect.left - 3}px;\n\t\t\t\t\t\ttop: ${rect.top - 3}px;\n\t\t\t\t\t\twidth: ${rect.width + 6}px;\n\t\t\t\t\t\theight: ${rect.height + 6}px;\n\t\t\t\t\t\tborder: 2px solid ${color};\n\t\t\t\t\t\tborder-radius: 4px;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t\tanimation: demo-seq-appear 0.3s ease-out ${index * 150}ms both;\n\t\t\t\t\t`;\n\t\t\t\t\tcontainer.appendChild(box);\n\n\t\t\t\t\t// Numbered badge\n\t\t\t\t\tconst badge = document.createElement('div');\n\t\t\t\t\tbadge.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: ${rect.left - 12}px;\n\t\t\t\t\t\ttop: ${rect.top - 12}px;\n\t\t\t\t\t\twidth: 24px;\n\t\t\t\t\t\theight: 24px;\n\t\t\t\t\t\tbackground: ${color};\n\t\t\t\t\t\tcolor: white;\n\t\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\t\tdisplay: flex;\n\t\t\t\t\t\talign-items: center;\n\t\t\t\t\t\tjustify-content: center;\n\t\t\t\t\t\tfont-size: ${fontSize - 2}px;\n\t\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\t\tfont-weight: bold;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t\tanimation: demo-seq-appear 0.3s ease-out ${index * 150}ms both;\n\t\t\t\t\t`;\n\t\t\t\t\tbadge.textContent = String(index + 1);\n\t\t\t\t\tcontainer.appendChild(badge);\n\n\t\t\t\t\t// Label text\n\t\t\t\t\tconst labelEl = document.createElement('div');\n\t\t\t\t\tlabelEl.textContent = label;\n\t\t\t\t\tlabelEl.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: ${rect.left + 16}px;\n\t\t\t\t\t\ttop: ${rect.top - 28}px;\n\t\t\t\t\t\tbackground: ${color};\n\t\t\t\t\t\tcolor: white;\n\t\t\t\t\t\tpadding: 2px 8px;\n\t\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\t\tborder-radius: 3px;\n\t\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t\tanimation: demo-seq-appear 0.3s ease-out ${index * 150 + 80}ms both;\n\t\t\t\t\t`;\n\t\t\t\t\tcontainer.appendChild(labelEl);\n\t\t\t\t});\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tcontainer.style.opacity = '0';\n\t\t\t\t\tcontainer.style.transition = 'opacity 0.3s';\n\t\t\t\t\tsetTimeout(() => container.remove(), 300);\n\t\t\t\t}, duration);\n\t\t\t},\n\t\t\t{\n\t\t\t\telements,\n\t\t\t\tcolor: this.options.actionColors.default,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t/**\n\t * Shows a horizontal timeline panel at the bottom of the viewport summarizing actions taken.\n\t */\n\tasync showTimeline(\n\t\tpage: Page,\n\t\tsteps: Array<{ action: string; timestamp: number; success: boolean }>,\n\t): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ steps, colors, duration, fontSize, attr }) => {\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\tconst styleEl = document.createElement('style');\n\t\t\t\tstyleEl.textContent = `\n\t\t\t\t\t@keyframes demo-timeline-slide {\n\t\t\t\t\t\t0% { transform: translateY(100%); opacity: 0; }\n\t\t\t\t\t\t10% { transform: translateY(0); opacity: 1; }\n\t\t\t\t\t\t90% { transform: translateY(0); opacity: 1; }\n\t\t\t\t\t\t100% { transform: translateY(100%); opacity: 0; }\n\t\t\t\t\t}\n\t\t\t\t\t@keyframes demo-timeline-dot {\n\t\t\t\t\t\t0% { transform: scale(0); }\n\t\t\t\t\t\t60% { transform: scale(1.3); }\n\t\t\t\t\t\t100% { transform: scale(1); }\n\t\t\t\t\t}\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(styleEl);\n\n\t\t\t\t// Timeline panel\n\t\t\t\tconst panel = document.createElement('div');\n\t\t\t\tpanel.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tbottom: 0;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\tright: 0;\n\t\t\t\t\tbackground: rgba(0, 0, 0, 0.92);\n\t\t\t\t\tpadding: 14px 20px 18px;\n\t\t\t\t\tanimation: demo-timeline-slide ${duration}ms ease-in-out forwards;\n\t\t\t\t\tborder-top: 2px solid rgba(255, 255, 255, 0.15);\n\t\t\t\t`;\n\n\t\t\t\t// Title\n\t\t\t\tconst title = document.createElement('div');\n\t\t\t\ttitle.textContent = 'Action Timeline';\n\t\t\t\ttitle.style.cssText = `\n\t\t\t\t\tcolor: rgba(255, 255, 255, 0.6);\n\t\t\t\t\tfont-size: ${fontSize - 2}px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\tmargin-bottom: 10px;\n\t\t\t\t\ttext-transform: uppercase;\n\t\t\t\t\tletter-spacing: 1px;\n\t\t\t\t`;\n\t\t\t\tpanel.appendChild(title);\n\n\t\t\t\t// Timeline track\n\t\t\t\tconst track = document.createElement('div');\n\t\t\t\ttrack.style.cssText = `\n\t\t\t\t\tdisplay: flex;\n\t\t\t\t\talign-items: center;\n\t\t\t\t\tgap: 0;\n\t\t\t\t\toverflow-x: auto;\n\t\t\t\t\tpadding-bottom: 4px;\n\t\t\t\t`;\n\n\t\t\t\tsteps.forEach((step, index) => {\n\t\t\t\t\t// Step item\n\t\t\t\t\tconst item = document.createElement('div');\n\t\t\t\t\titem.style.cssText = `\n\t\t\t\t\t\tdisplay: flex;\n\t\t\t\t\t\talign-items: center;\n\t\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\t`;\n\n\t\t\t\t\t// Dot\n\t\t\t\t\tconst actionKey = step.action.toLowerCase();\n\t\t\t\t\tconst dotColor = step.success\n\t\t\t\t\t\t? (colors[actionKey] || colors.default)\n\t\t\t\t\t\t: '#ff4444';\n\n\t\t\t\t\tconst dot = document.createElement('div');\n\t\t\t\t\tdot.style.cssText = `\n\t\t\t\t\t\twidth: 14px;\n\t\t\t\t\t\theight: 14px;\n\t\t\t\t\t\tborder-radius: 50%;\n\t\t\t\t\t\tbackground: ${dotColor};\n\t\t\t\t\t\tborder: 2px solid ${step.success ? 'transparent' : '#ff0000'};\n\t\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\t\tanimation: demo-timeline-dot 0.3s ease-out ${index * 100}ms both;\n\t\t\t\t\t`;\n\t\t\t\t\titem.appendChild(dot);\n\n\t\t\t\t\t// Label below\n\t\t\t\t\tconst label = document.createElement('div');\n\t\t\t\t\tconst time = new Date(step.timestamp).toLocaleTimeString([], {\n\t\t\t\t\t\thour: '2-digit',\n\t\t\t\t\t\tminute: '2-digit',\n\t\t\t\t\t\tsecond: '2-digit',\n\t\t\t\t\t});\n\t\t\t\t\tlabel.innerHTML = `\n\t\t\t\t\t\t<div style=\"color: white; font-size: ${fontSize - 1}px; font-family: monospace; white-space: nowrap;\">\n\t\t\t\t\t\t\t${step.action}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t\t<div style=\"color: rgba(255,255,255,0.4); font-size: ${fontSize - 3}px; font-family: monospace;\">\n\t\t\t\t\t\t\t${time}\n\t\t\t\t\t\t</div>\n\t\t\t\t\t`;\n\t\t\t\t\tlabel.style.cssText = `\n\t\t\t\t\t\tmargin-left: 4px;\n\t\t\t\t\t\tmargin-right: 4px;\n\t\t\t\t\t`;\n\t\t\t\t\titem.appendChild(label);\n\n\t\t\t\t\ttrack.appendChild(item);\n\n\t\t\t\t\t// Connector line between steps\n\t\t\t\t\tif (index < steps.length - 1) {\n\t\t\t\t\t\tconst connector = document.createElement('div');\n\t\t\t\t\t\tconnector.style.cssText = `\n\t\t\t\t\t\t\twidth: 30px;\n\t\t\t\t\t\t\theight: 2px;\n\t\t\t\t\t\t\tbackground: rgba(255, 255, 255, 0.2);\n\t\t\t\t\t\t\tflex-shrink: 0;\n\t\t\t\t\t\t\tmargin: 0 2px;\n\t\t\t\t\t\t`;\n\t\t\t\t\t\ttrack.appendChild(connector);\n\t\t\t\t\t}\n\t\t\t\t});\n\n\t\t\t\tpanel.appendChild(track);\n\t\t\t\tcontainer.appendChild(panel);\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => container.remove(), duration + 100);\n\t\t\t},\n\t\t\t{\n\t\t\t\tsteps,\n\t\t\t\tcolors: this.options.actionColors,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t/**\n\t * Shows a crosshair and coordinate text at the given position.\n\t */\n\tasync showCoordinates(page: Page, x: number, y: number): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ x, y, color, duration, fontSize, attr }) => {\n\t\t\t\tconst container = document.createElement('div');\n\t\t\t\tcontainer.setAttribute(attr, '');\n\t\t\t\tcontainer.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t\tz-index: 999999;\n\t\t\t\t`;\n\n\t\t\t\t// Horizontal crosshair line\n\t\t\t\tconst hLine = document.createElement('div');\n\t\t\t\thLine.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: 0;\n\t\t\t\t\ttop: ${y}px;\n\t\t\t\t\twidth: 100%;\n\t\t\t\t\theight: 1px;\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\topacity: 0.4;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(hLine);\n\n\t\t\t\t// Vertical crosshair line\n\t\t\t\tconst vLine = document.createElement('div');\n\t\t\t\tvLine.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: ${x}px;\n\t\t\t\t\ttop: 0;\n\t\t\t\t\twidth: 1px;\n\t\t\t\t\theight: 100%;\n\t\t\t\t\tbackground: ${color};\n\t\t\t\t\topacity: 0.4;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(vLine);\n\n\t\t\t\t// Center crosshair marks (thicker, shorter lines)\n\t\t\t\tconst crossSize = 12;\n\t\t\t\tconst marks = [\n\t\t\t\t\t// Left mark\n\t\t\t\t\t{ left: x - crossSize, top: y, width: crossSize - 3, height: 2 },\n\t\t\t\t\t// Right mark\n\t\t\t\t\t{ left: x + 3, top: y, width: crossSize - 3, height: 2 },\n\t\t\t\t\t// Top mark\n\t\t\t\t\t{ left: x, top: y - crossSize, width: 2, height: crossSize - 3 },\n\t\t\t\t\t// Bottom mark\n\t\t\t\t\t{ left: x, top: y + 3, width: 2, height: crossSize - 3 },\n\t\t\t\t];\n\t\t\t\tfor (const m of marks) {\n\t\t\t\t\tconst mark = document.createElement('div');\n\t\t\t\t\tmark.style.cssText = `\n\t\t\t\t\t\tposition: fixed;\n\t\t\t\t\t\tleft: ${m.left}px;\n\t\t\t\t\t\ttop: ${m.top}px;\n\t\t\t\t\t\twidth: ${m.width}px;\n\t\t\t\t\t\theight: ${m.height}px;\n\t\t\t\t\t\tbackground: ${color};\n\t\t\t\t\t\tpointer-events: none;\n\t\t\t\t\t`;\n\t\t\t\t\tcontainer.appendChild(mark);\n\t\t\t\t}\n\n\t\t\t\t// Coordinate label\n\t\t\t\tconst label = document.createElement('div');\n\t\t\t\tlabel.textContent = `(${Math.round(x)}, ${Math.round(y)})`;\n\t\t\t\tlabel.style.cssText = `\n\t\t\t\t\tposition: fixed;\n\t\t\t\t\tleft: ${x + 14}px;\n\t\t\t\t\ttop: ${y + 14}px;\n\t\t\t\t\tbackground: rgba(0, 0, 0, 0.8);\n\t\t\t\t\tcolor: ${color};\n\t\t\t\t\tpadding: 3px 8px;\n\t\t\t\t\tfont-size: ${fontSize}px;\n\t\t\t\t\tfont-family: monospace;\n\t\t\t\t\tborder-radius: 3px;\n\t\t\t\t\tborder: 1px solid ${color};\n\t\t\t\t\twhite-space: nowrap;\n\t\t\t\t\tpointer-events: none;\n\t\t\t\t`;\n\t\t\t\tcontainer.appendChild(label);\n\n\t\t\t\tdocument.body.appendChild(container);\n\t\t\t\tsetTimeout(() => {\n\t\t\t\t\tcontainer.style.opacity = '0';\n\t\t\t\t\tcontainer.style.transition = 'opacity 0.3s';\n\t\t\t\t\tsetTimeout(() => container.remove(), 300);\n\t\t\t\t}, duration);\n\t\t\t},\n\t\t\t{\n\t\t\t\tx,\n\t\t\t\ty,\n\t\t\t\tcolor: this.options.actionColors.default,\n\t\t\t\tduration: this.options.highlightDuration,\n\t\t\t\tfontSize: this.options.annotationFontSize,\n\t\t\t\tattr: OVERLAY_ATTR,\n\t\t\t},\n\t\t);\n\t}\n\n\t// ───────────────────────────────────────────\n\t// Cleanup\n\t// ───────────────────────────────────────────\n\n\t/**\n\t * Removes all demo-mode overlays currently on the page.\n\t */\n\tasync clearOverlays(page: Page): Promise<void> {\n\t\tawait page.evaluate(\n\t\t\t({ attr }) => {\n\t\t\t\tconst overlays = document.querySelectorAll(`[${attr}]`);\n\t\t\t\tfor (const overlay of overlays) {\n\t\t\t\t\toverlay.remove();\n\t\t\t\t}\n\t\t\t},\n\t\t\t{ attr: OVERLAY_ATTR },\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "packages/core/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "packages/sandbox/package.json",
    "content": "{\n  \"name\": \"@open-browser/sandbox\",\n  \"version\": \"1.1.0\",\n  \"description\": \"Sandboxed execution environment for Open Browser\",\n  \"type\": \"module\",\n  \"main\": \"src/index.ts\",\n  \"types\": \"src/index.ts\",\n  \"exports\": {\n    \".\": \"./src/index.ts\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc --noEmit\",\n    \"test\": \"bun test\"\n  },\n  \"dependencies\": {\n    \"open-browser\": \"workspace:*\"\n  },\n  \"license\": \"MIT\"\n}\n"
  },
  {
    "path": "packages/sandbox/src/index.ts",
    "content": "export { Sandbox } from './sandbox.js';\nexport type {\n\tSandboxOptions,\n\tSandboxResult,\n\tSandboxError,\n\tSandboxErrorCategory,\n\tSandboxMetrics,\n\tCapturedOutput,\n\tResourceSnapshot,\n} from './types.js';\n"
  },
  {
    "path": "packages/sandbox/src/sandbox.ts",
    "content": "import type {\n\tSandboxOptions,\n\tSandboxResult,\n\tSandboxError,\n\tSandboxMetrics,\n\tCapturedOutput,\n\tResourceSnapshot,\n\tSandboxErrorCategory,\n} from './types.js';\nimport { Viewport, Agent, type AgentOptions, type CommandResult } from 'open-browser';\n\n// ── Defaults ──\n\nconst DEFAULT_OPTIONS: Required<SandboxOptions> = {\n\ttimeout: 300_000,\n\tmaxMemoryMB: 512,\n\tallowedDomains: [],\n\tblockedDomains: [],\n\tenableNetworking: true,\n\tenableFileAccess: false,\n\tworkDir: process.cwd(),\n\tresourceCheckIntervalMs: 1_000,\n\tcaptureOutput: true,\n\tstepLimit: 100,\n};\n\n// ── Resource Monitor ──\n\n/**\n * Monitors memory and CPU usage during sandbox execution.\n * Takes periodic snapshots and detects OOM conditions.\n */\nclass ResourceMonitor {\n\tprivate intervalId: ReturnType<typeof setInterval> | null = null;\n\tprivate snapshots: ResourceSnapshot[] = [];\n\tprivate peakMemoryMB = 0;\n\tprivate startCpuUsage: NodeJS.CpuUsage | null = null;\n\tprivate readonly limitMB: number;\n\tprivate onOOM: (() => void) | null = null;\n\n\tconstructor(limitMB: number) {\n\t\tthis.limitMB = limitMB;\n\t}\n\n\tstart(intervalMs: number, onOOM: () => void): void {\n\t\tthis.startCpuUsage = process.cpuUsage();\n\t\tthis.onOOM = onOOM;\n\t\tthis.takeSnapshot();\n\n\t\tthis.intervalId = setInterval(() => {\n\t\t\tthis.takeSnapshot();\n\t\t}, intervalMs);\n\t}\n\n\tstop(): void {\n\t\tif (this.intervalId !== null) {\n\t\t\tclearInterval(this.intervalId);\n\t\t\tthis.intervalId = null;\n\t\t}\n\t\t// Final snapshot\n\t\tthis.takeSnapshot();\n\t}\n\n\tprivate takeSnapshot(): void {\n\t\tconst mem = process.memoryUsage();\n\t\tconst cpu = this.startCpuUsage\n\t\t\t? process.cpuUsage(this.startCpuUsage)\n\t\t\t: process.cpuUsage();\n\n\t\tconst rssMB = mem.rss / (1024 * 1024);\n\t\tconst heapUsedMB = mem.heapUsed / (1024 * 1024);\n\t\tconst heapTotalMB = mem.heapTotal / (1024 * 1024);\n\t\tconst externalMB = mem.external / (1024 * 1024);\n\n\t\tconst snapshot: ResourceSnapshot = {\n\t\t\ttimestampMs: Date.now(),\n\t\t\theapUsedMB,\n\t\t\theapTotalMB,\n\t\t\trssMB,\n\t\t\texternalMB,\n\t\t\tcpuUserMs: cpu.user / 1000,\n\t\t\tcpuSystemMs: cpu.system / 1000,\n\t\t};\n\n\t\tthis.snapshots.push(snapshot);\n\n\t\tif (rssMB > this.peakMemoryMB) {\n\t\t\tthis.peakMemoryMB = rssMB;\n\t\t}\n\n\t\t// Check OOM condition against RSS (total process memory)\n\t\tif (rssMB > this.limitMB && this.onOOM) {\n\t\t\tthis.onOOM();\n\t\t}\n\t}\n\n\tgetPeakMemoryMB(): number {\n\t\treturn Math.round(this.peakMemoryMB * 100) / 100;\n\t}\n\n\tgetCpuTimeMs(): number {\n\t\tif (!this.startCpuUsage) return 0;\n\t\tconst usage = process.cpuUsage(this.startCpuUsage);\n\t\treturn Math.round((usage.user + usage.system) / 1000);\n\t}\n\n\tgetSnapshots(): ResourceSnapshot[] {\n\t\treturn [...this.snapshots];\n\t}\n\n\tgetCurrentMemoryMB(): number {\n\t\tconst mem = process.memoryUsage();\n\t\treturn Math.round((mem.rss / (1024 * 1024)) * 100) / 100;\n\t}\n}\n\n// ── Output Capture ──\n\n/**\n * Captures stdout and stderr output during execution.\n * Intercepts process.stdout.write and process.stderr.write.\n */\nclass OutputCapture {\n\tprivate stdoutChunks: string[] = [];\n\tprivate stderrChunks: string[] = [];\n\tprivate originalStdoutWrite: typeof process.stdout.write | null = null;\n\tprivate originalStderrWrite: typeof process.stderr.write | null = null;\n\tprivate active = false;\n\n\tstart(): void {\n\t\tif (this.active) return;\n\t\tthis.active = true;\n\t\tthis.stdoutChunks = [];\n\t\tthis.stderrChunks = [];\n\n\t\tthis.originalStdoutWrite = process.stdout.write.bind(process.stdout);\n\t\tthis.originalStderrWrite = process.stderr.write.bind(process.stderr);\n\n\t\tprocess.stdout.write = ((chunk: string | Uint8Array, ...args: unknown[]): boolean => {\n\t\t\tconst text = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);\n\t\t\tthis.stdoutChunks.push(text);\n\t\t\t// Still write to original stdout for real-time visibility\n\t\t\treturn this.originalStdoutWrite!(chunk as string, ...args as []);\n\t\t}) as typeof process.stdout.write;\n\n\t\tprocess.stderr.write = ((chunk: string | Uint8Array, ...args: unknown[]): boolean => {\n\t\t\tconst text = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);\n\t\t\tthis.stderrChunks.push(text);\n\t\t\treturn this.originalStderrWrite!(chunk as string, ...args as []);\n\t\t}) as typeof process.stderr.write;\n\t}\n\n\tstop(): void {\n\t\tif (!this.active) return;\n\t\tthis.active = false;\n\n\t\tif (this.originalStdoutWrite) {\n\t\t\tprocess.stdout.write = this.originalStdoutWrite as typeof process.stdout.write;\n\t\t\tthis.originalStdoutWrite = null;\n\t\t}\n\t\tif (this.originalStderrWrite) {\n\t\t\tprocess.stderr.write = this.originalStderrWrite as typeof process.stderr.write;\n\t\t\tthis.originalStderrWrite = null;\n\t\t}\n\t}\n\n\tgetOutput(): CapturedOutput {\n\t\treturn {\n\t\t\tstdout: this.stdoutChunks.join(''),\n\t\t\tstderr: this.stderrChunks.join(''),\n\t\t};\n\t}\n}\n\n// ── Sandbox ──\n\n/**\n * Sandboxed execution environment for browser automation.\n * Runs agent tasks in an isolated context with resource limits,\n * output capture, and comprehensive metrics.\n */\nexport class Sandbox {\n\tprivate options: Required<SandboxOptions>;\n\n\tconstructor(options?: SandboxOptions) {\n\t\tthis.options = { ...DEFAULT_OPTIONS, ...options };\n\t}\n\n\t/**\n\t * Run an agent task inside the sandbox with resource monitoring,\n\t * output capture, and timeout enforcement.\n\t */\n\tasync run(agentOptions: Omit<AgentOptions, 'browser'>): Promise<SandboxResult> {\n\t\tconst startTime = Date.now();\n\t\tconst resourceMonitor = new ResourceMonitor(this.options.maxMemoryMB);\n\t\tconst outputCapture = new OutputCapture();\n\n\t\t// Track visited URLs and step/action counts\n\t\tconst visitedUrls = new Set<string>();\n\t\tlet stepsExecuted = 0;\n\t\tlet totalActions = 0;\n\n\t\t// OOM abort controller\n\t\tlet oomTriggered = false;\n\t\tconst abortController = new AbortController();\n\n\t\tconst browser = new Viewport({\n\t\t\theadless: true,\n\t\t\tallowedUrls: this.options.allowedDomains.length > 0\n\t\t\t\t? this.options.allowedDomains\n\t\t\t\t: undefined,\n\t\t\tblockedUrls: this.options.blockedDomains.length > 0\n\t\t\t\t? this.options.blockedDomains\n\t\t\t\t: undefined,\n\t\t});\n\n\t\t// Start resource monitoring with OOM callback\n\t\tresourceMonitor.start(this.options.resourceCheckIntervalMs, () => {\n\t\t\tif (!oomTriggered) {\n\t\t\t\toomTriggered = true;\n\t\t\t\tabortController.abort();\n\t\t\t}\n\t\t});\n\n\t\t// Start output capture\n\t\tif (this.options.captureOutput) {\n\t\t\toutputCapture.start();\n\t\t}\n\n\t\ttry {\n\t\t\tawait browser.start();\n\n\t\t\tconst agent = new Agent({\n\t\t\t\t...agentOptions,\n\t\t\t\tbrowser,\n\t\t\t\tsettings: {\n\t\t\t\t\t...agentOptions.settings,\n\t\t\t\t\tallowedUrls: this.options.allowedDomains,\n\t\t\t\t\tblockedUrls: this.options.blockedDomains,\n\t\t\t\t\tstepLimit: this.options.stepLimit,\n\t\t\t\t},\n\t\t\t\tonStepStart: (step) => {\n\t\t\t\t\tstepsExecuted = step;\n\t\t\t\t\t// Track URL at step start\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst url = browser.currentPage?.url();\n\t\t\t\t\t\tif (url && url !== 'about:blank') {\n\t\t\t\t\t\t\tvisitedUrls.add(url);\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Page may not be ready\n\t\t\t\t\t}\n\t\t\t\t\t// Delegate to caller's onStepStart if provided\n\t\t\t\t\tagentOptions.onStepStart?.(step);\n\t\t\t\t},\n\t\t\t\tonStepEnd: (step, results) => {\n\t\t\t\t\ttotalActions += results.length;\n\t\t\t\t\t// Track URL at step end (may have changed)\n\t\t\t\t\ttry {\n\t\t\t\t\t\tconst url = browser.currentPage?.url();\n\t\t\t\t\t\tif (url && url !== 'about:blank') {\n\t\t\t\t\t\t\tvisitedUrls.add(url);\n\t\t\t\t\t\t}\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// Page may not be ready\n\t\t\t\t\t}\n\t\t\t\t\tagentOptions.onStepEnd?.(step, results);\n\t\t\t\t},\n\t\t\t});\n\n\t\t\t// Race the agent execution against timeout and OOM\n\t\t\tconst result = await Promise.race([\n\t\t\t\tthis.executeAgent(agent, startTime),\n\t\t\t\tthis.createTimeoutPromise(startTime),\n\t\t\t\tthis.createOOMPromise(abortController.signal, startTime, resourceMonitor),\n\t\t\t]);\n\n\t\t\t// Build metrics\n\t\t\tconst metrics = this.buildMetrics(\n\t\t\t\tstartTime,\n\t\t\t\tresourceMonitor,\n\t\t\t\tstepsExecuted,\n\t\t\t\tvisitedUrls,\n\t\t\t\ttotalActions,\n\t\t\t);\n\n\t\t\treturn {\n\t\t\t\t...result,\n\t\t\t\tmemoryUsageMB: resourceMonitor.getCurrentMemoryMB(),\n\t\t\t\tcapturedOutput: this.options.captureOutput ? outputCapture.getOutput() : undefined,\n\t\t\t\tmetrics,\n\t\t\t};\n\t\t} catch (error) {\n\t\t\tconst sandboxError = this.classifyError(error, oomTriggered);\n\t\t\tconst metrics = this.buildMetrics(\n\t\t\t\tstartTime,\n\t\t\t\tresourceMonitor,\n\t\t\t\tstepsExecuted,\n\t\t\t\tvisitedUrls,\n\t\t\t\ttotalActions,\n\t\t\t);\n\n\t\t\treturn {\n\t\t\t\tsuccess: false,\n\t\t\t\terror: sandboxError,\n\t\t\t\terrorMessage: sandboxError.message,\n\t\t\t\tduration: Date.now() - startTime,\n\t\t\t\tmemoryUsageMB: resourceMonitor.getCurrentMemoryMB(),\n\t\t\t\tcapturedOutput: this.options.captureOutput ? outputCapture.getOutput() : undefined,\n\t\t\t\tmetrics,\n\t\t\t};\n\t\t} finally {\n\t\t\t// Always clean up in reverse order\n\t\t\tresourceMonitor.stop();\n\t\t\tif (this.options.captureOutput) {\n\t\t\t\toutputCapture.stop();\n\t\t\t}\n\t\t\tawait this.forceCleanup(browser);\n\t\t}\n\t}\n\n\t/**\n\t * Execute the agent and wrap the result.\n\t */\n\tprivate async executeAgent(\n\t\tagent: Agent,\n\t\tstartTime: number,\n\t): Promise<SandboxResult> {\n\t\tconst result = await agent.run();\n\t\treturn {\n\t\t\tsuccess: result.success,\n\t\t\toutput: result.finalResult,\n\t\t\tduration: Date.now() - startTime,\n\t\t};\n\t}\n\n\t/**\n\t * Create a timeout promise that resolves with a timeout error.\n\t */\n\tprivate createTimeoutPromise(startTime: number): Promise<SandboxResult> {\n\t\treturn new Promise<SandboxResult>((resolve) => {\n\t\t\tsetTimeout(() => {\n\t\t\t\tresolve({\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcategory: 'timeout',\n\t\t\t\t\t\tmessage: `Sandbox timeout after ${this.options.timeout}ms`,\n\t\t\t\t\t},\n\t\t\t\t\terrorMessage: `Sandbox timeout after ${this.options.timeout}ms`,\n\t\t\t\t\tduration: Date.now() - startTime,\n\t\t\t\t});\n\t\t\t}, this.options.timeout);\n\t\t});\n\t}\n\n\t/**\n\t * Create a promise that rejects when OOM is detected via the AbortSignal.\n\t */\n\tprivate createOOMPromise(\n\t\tsignal: AbortSignal,\n\t\tstartTime: number,\n\t\tmonitor: ResourceMonitor,\n\t): Promise<SandboxResult> {\n\t\treturn new Promise<SandboxResult>((resolve) => {\n\t\t\tconst onAbort = () => {\n\t\t\t\tresolve({\n\t\t\t\t\tsuccess: false,\n\t\t\t\t\terror: {\n\t\t\t\t\t\tcategory: 'oom',\n\t\t\t\t\t\tmessage: `Memory limit exceeded: ${monitor.getPeakMemoryMB()}MB > ${this.options.maxMemoryMB}MB`,\n\t\t\t\t\t},\n\t\t\t\t\terrorMessage: `Memory limit exceeded: ${monitor.getPeakMemoryMB()}MB > ${this.options.maxMemoryMB}MB`,\n\t\t\t\t\tduration: Date.now() - startTime,\n\t\t\t\t\tmemoryUsageMB: monitor.getPeakMemoryMB(),\n\t\t\t\t});\n\t\t\t};\n\n\t\t\tif (signal.aborted) {\n\t\t\t\tonAbort();\n\t\t\t} else {\n\t\t\t\tsignal.addEventListener('abort', onAbort, { once: true });\n\t\t\t}\n\t\t});\n\t}\n\n\t/**\n\t * Classify an error into a SandboxError with the appropriate category.\n\t */\n\tprivate classifyError(error: unknown, oomTriggered: boolean): SandboxError {\n\t\tif (oomTriggered) {\n\t\t\treturn {\n\t\t\t\tcategory: 'oom',\n\t\t\t\tmessage: 'Execution terminated due to memory limit exceeded',\n\t\t\t\tstack: error instanceof Error ? error.stack : undefined,\n\t\t\t};\n\t\t}\n\n\t\tconst message = error instanceof Error ? error.message : String(error);\n\t\tconst stack = error instanceof Error ? error.stack : undefined;\n\n\t\t// Detect browser crashes\n\t\tif (\n\t\t\tmessage.includes('browser has been closed') ||\n\t\t\tmessage.includes('Target page') ||\n\t\t\tmessage.includes('Target closed') ||\n\t\t\tmessage.includes('Protocol error')\n\t\t) {\n\t\t\treturn { category: 'crash', message, stack };\n\t\t}\n\n\t\t// Detect timeout patterns\n\t\tif (\n\t\t\tmessage.includes('timeout') ||\n\t\t\tmessage.includes('Timeout') ||\n\t\t\tmessage.includes('ETIMEDOUT')\n\t\t) {\n\t\t\treturn { category: 'timeout', message, stack };\n\t\t}\n\n\t\t// Detect agent-specific errors\n\t\tif (\n\t\t\tmessage.includes('Agent') ||\n\t\t\tmessage.includes('maximum steps') ||\n\t\t\tmessage.includes('stuck in a loop')\n\t\t) {\n\t\t\treturn { category: 'agent_error', message, stack };\n\t\t}\n\n\t\t// Detect browser/navigation errors\n\t\tif (\n\t\t\tmessage.includes('net::ERR_') ||\n\t\t\tmessage.includes('Navigation') ||\n\t\t\tmessage.includes('navigation')\n\t\t) {\n\t\t\treturn { category: 'browser_error', message, stack };\n\t\t}\n\n\t\treturn { category: 'unknown', message, stack };\n\t}\n\n\t/**\n\t * Build metrics from the execution data.\n\t */\n\tprivate buildMetrics(\n\t\tstartTime: number,\n\t\tmonitor: ResourceMonitor,\n\t\tstepsExecuted: number,\n\t\tvisitedUrls: Set<string>,\n\t\ttotalActions: number,\n\t): SandboxMetrics {\n\t\treturn {\n\t\t\tdurationMs: Date.now() - startTime,\n\t\t\tpeakMemoryMB: monitor.getPeakMemoryMB(),\n\t\t\tstepsExecuted,\n\t\t\tpagesVisited: visitedUrls.size,\n\t\t\tvisitedUrls: [...visitedUrls],\n\t\t\ttotalActions,\n\t\t\tcpuTimeMs: monitor.getCpuTimeMs(),\n\t\t};\n\t}\n\n\t/**\n\t * Force cleanup of browser resources. Catches and ignores errors\n\t * since the browser may already be crashed or closed.\n\t */\n\tprivate async forceCleanup(browser: Viewport): Promise<void> {\n\t\ttry {\n\t\t\tawait Promise.race([\n\t\t\t\tbrowser.close(),\n\t\t\t\t// Give cleanup 5 seconds max, then move on\n\t\t\t\tnew Promise<void>((resolve) => setTimeout(resolve, 5_000)),\n\t\t\t]);\n\t\t} catch {\n\t\t\t// Browser may already be closed or crashed - ignore\n\t\t}\n\t}\n\n\t/**\n\t * Get the current sandbox configuration.\n\t */\n\tgetOptions(): Readonly<Required<SandboxOptions>> {\n\t\treturn { ...this.options };\n\t}\n}\n"
  },
  {
    "path": "packages/sandbox/src/types.ts",
    "content": "// ── Sandbox configuration ──\n\nexport interface SandboxOptions {\n\t/** Maximum execution time in milliseconds (default: 300000 = 5 minutes) */\n\ttimeout?: number;\n\t/** Maximum memory usage in MB (default: 512) */\n\tmaxMemoryMB?: number;\n\t/** Domains the agent is allowed to visit */\n\tallowedDomains?: string[];\n\t/** Domains the agent is blocked from visiting */\n\tblockedDomains?: string[];\n\t/** Whether network access is allowed (default: true) */\n\tenableNetworking?: boolean;\n\t/** Whether file system access is allowed (default: false) */\n\tenableFileAccess?: boolean;\n\t/** Working directory for the sandbox */\n\tworkDir?: string;\n\t/** Interval in ms to check resource usage (default: 1000) */\n\tresourceCheckIntervalMs?: number;\n\t/** Whether to capture stdout/stderr from the agent execution (default: true) */\n\tcaptureOutput?: boolean;\n\t/** Maximum number of agent steps (default: 100) */\n\tstepLimit?: number;\n}\n\n// ── Sandbox error categories ──\n\nexport type SandboxErrorCategory =\n\t| 'timeout'\n\t| 'oom'\n\t| 'crash'\n\t| 'agent_error'\n\t| 'browser_error'\n\t| 'unknown';\n\nexport interface SandboxError {\n\tcategory: SandboxErrorCategory;\n\tmessage: string;\n\t/** Original stack trace if available */\n\tstack?: string;\n}\n\n// ── Output capture ──\n\nexport interface CapturedOutput {\n\tstdout: string;\n\tstderr: string;\n}\n\n// ── Metrics ──\n\nexport interface SandboxMetrics {\n\t/** Total execution time in milliseconds */\n\tdurationMs: number;\n\t/** Peak memory usage in MB */\n\tpeakMemoryMB: number;\n\t/** Number of agent steps executed */\n\tstepsExecuted: number;\n\t/** Number of unique pages visited */\n\tpagesVisited: number;\n\t/** URLs of pages visited */\n\tvisitedUrls: string[];\n\t/** Number of actions taken across all steps */\n\ttotalActions: number;\n\t/** CPU time used (user + system) in milliseconds */\n\tcpuTimeMs: number;\n}\n\n// ── Sandbox result ──\n\nexport interface SandboxResult {\n\tsuccess: boolean;\n\toutput?: string;\n\terror?: SandboxError;\n\t/** Legacy string error for backwards compatibility */\n\terrorMessage?: string;\n\tduration: number;\n\tmemoryUsageMB?: number;\n\t/** Captured stdout/stderr from the execution */\n\tcapturedOutput?: CapturedOutput;\n\t/** Detailed execution metrics */\n\tmetrics?: SandboxMetrics;\n}\n\n// ── Resource monitor state ──\n\nexport interface ResourceSnapshot {\n\ttimestampMs: number;\n\theapUsedMB: number;\n\theapTotalMB: number;\n\trssMB: number;\n\texternalMB: number;\n\tcpuUserMs: number;\n\tcpuSystemMs: number;\n}\n"
  },
  {
    "path": "packages/sandbox/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"outDir\": \"dist\"\n  },\n  \"include\": [\"src/**/*.ts\"]\n}\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"esModuleInterop\": true,\n    \"strict\": true,\n    \"skipLibCheck\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"composite\": true,\n    \"incremental\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"noFallthroughCasesInSwitch\": true,\n    \"allowImportingTsExtensions\": true,\n    \"noEmit\": true,\n    \"types\": [\"bun\"]\n  }\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ESNext\",\n    \"module\": \"ESNext\",\n    \"moduleResolution\": \"bundler\",\n    \"lib\": [\"ESNext\"],\n    \"outDir\": \"dist\",\n    \"rootDir\": \"src\",\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"resolveJsonModule\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"skipLibCheck\": true\n  },\n  \"include\": [\"src/**/*.ts\"],\n  \"exclude\": [\"node_modules\", \"dist\", \"**/__tests__/**\"]\n}\n"
  }
]