From 1ff5618b6a2df6febfe6fb976b42f42ecf0dffea Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 20:50:10 +0530 Subject: [PATCH 001/192] docs: add plugin system documentation to README - CLI section: add plugin list/install/remove commands - New ## Plugins section: install, official plugins table, usage examples, manage commands, write-your-own guide with SKILL.md explanation Co-Authored-By: Claude Sonnet 4.6 --- README.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/README.md b/README.md index 6a4dc5b..c6efe63 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,20 @@ Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP- | Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** | | Playwright API | Partial | No | Full | No | **Full** | +## Your Credentials Stay Yours + +Every other approach asks you to hand over something: an API key, an OAuth token, stored passwords, session cookies in a config file. BrowserForce asks for none of it. + +**Why?** Because you're already logged in. BrowserForce talks to your running Chrome — it doesn't extract credentials, store cookies, or replay tokens. The browser handles auth exactly as it always has. Your agent inherits your sessions the same way a new Chrome tab does. + +What you never need to provide: +- No passwords +- No API keys +- No OAuth tokens +- No session cookies in env vars or config files + +It's a security win *and* a setup win — there are no secrets to rotate, leak, or manage. Your logins live in Chrome. They stay in Chrome. + ## Setup ### 1. Install @@ -153,10 +167,76 @@ browserforce snapshot [n] # Accessibility tree of tab n browserforce screenshot [n] # Screenshot tab n (PNG to stdout) browserforce navigate # Open URL in a new tab browserforce -e "" # Run Playwright JavaScript (one-shot) +browserforce plugin list # List installed plugins +browserforce plugin install # Install a plugin from the registry +browserforce plugin remove # Remove an installed plugin ``` Each `-e` command is one-shot — state does not persist between calls. For persistent state, use the MCP server. +## Plugins + +Plugins add custom helpers directly into the `execute` tool scope. Install once — your agent calls them like built-in functions. + +### Install a plugin + +```bash +browserforce plugin install highlight +``` + +That's it. Restart MCP (or Claude Desktop) and `highlight()` is available in every `execute` call. + +### Official plugins + +| Plugin | What it adds | Install | +|--------|-------------|---------| +| `highlight` | `highlight(selector, color?)` — outlines matching elements; `clearHighlights()` — removes them | `browserforce plugin install highlight` | + +### Use an installed plugin + +After installing `highlight`, your agent can call it directly: + +```javascript +// Outline all buttons in blue +await highlight('button', 'blue'); + +// Highlight the specific element you're about to click +await highlight('[data-testid="submit"]', 'red'); +return await screenshotWithAccessibilityLabels(); +``` + +The helper receives the active page, context, and state automatically — no plumbing needed. + +### Manage plugins + +```bash +browserforce plugin list # See what's installed +browserforce plugin remove highlight # Uninstall +``` + +Plugins are stored at `~/.browserforce/plugins/`. Each one is a folder with an `index.js`. + +### Write your own + +```javascript +// ~/.browserforce/plugins/my-plugin/index.js +export default { + name: 'my-plugin', + helpers: { + async scrollToBottom(page, ctx, state) { + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + }, + async countLinks(page, ctx, state) { + return page.evaluate(() => document.querySelectorAll('a').length); + }, + }, +}; +``` + +Drop it in `~/.browserforce/plugins/my-plugin/`, restart MCP, and call `await scrollToBottom()` or `await countLinks()` from any `execute` call. + +Add a `SKILL.md` file alongside `index.js` and its content is automatically appended to the `execute` tool's description — so your agent knows the helpers exist without you having to explain them every time. + ### Any Playwright Script ```javascript From 43f7d553273697c75b207b88e751a0af0994ba8b Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 20:50:46 +0530 Subject: [PATCH 002/192] chore: update package version to 1.0.9 and adjust repository URL format - Bump version from 1.0.8 to 1.0.9 - Change repository URL format to use 'git+' prefix - Update bin path for browserforce to remove './' prefix --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index e4037bf..abc9d59 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,12 @@ { "name": "browserforce", - "version": "1.0.8", + "version": "1.0.9", "type": "module", "description": "Give AI agents your real Chrome browser with progressive examples: simple reads, form interactions, multi-tab workflows, and state persistence. Search X and GitHub, extract ProductHunt data, test forms, compare A/B variants, monitor status pages. Works with OpenClaw, Claude, and any MCP agent.", "homepage": "https://github.com/ivalsaraj/browserforce", "repository": { "type": "git", - "url": "https://github.com/ivalsaraj/browserforce.git" + "url": "git+https://github.com/ivalsaraj/browserforce.git" }, "license": "MIT", "keywords": [ @@ -24,7 +24,7 @@ "node": ">=18.3.0" }, "bin": { - "browserforce": "./bin.js" + "browserforce": "bin.js" }, "files": [ "README.md", From 6ea5ce7f2220e7c147e4be236d53c252f1ea3cf0 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 20:51:07 +0530 Subject: [PATCH 003/192] docs: add comprehensive documentation for BrowserForce plugin system - Introduced a new PLUGINS.md file detailing the plugin architecture, installation process, and usage examples. - Included sections on various plugin functionalities such as HAR capture, DOM diffing, E2E test recording, and more. - Provided code snippets for minimal plugin creation and advanced use cases for both developers and automated agents. --- docs/PLUGINS.md | 446 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 docs/PLUGINS.md diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md new file mode 100644 index 0000000..2720475 --- /dev/null +++ b/docs/PLUGINS.md @@ -0,0 +1,446 @@ +# BrowserForce Plugins + +Extend BrowserForce with local JS files — no framework, no build step, no registry. + +Plugins live in `~/.browserforce/plugins/`. Each file exports a plain object. The MCP server loads them at startup and merges their helpers, tools, and hooks into the runtime. + +**Minimal plugin — 10 lines:** + +```js +// ~/.browserforce/plugins/hello.js +export default { + name: 'hello', + helpers: { + async greet(page) { + const title = await page.title(); + return `Hello from: ${title}`; + } + } +} +``` + +After installing, `greet(page)` is available as a global inside every `execute()` call. + +--- + +## How to Install a Plugin + +1. Drop a `.js` file in `~/.browserforce/plugins/` +2. Restart the MCP server +3. Done — helpers are injected, tools are registered + +No config changes. No manifest edits. The directory is auto-scanned on startup. + +--- + +## For Developers + +Use cases for people with browser UI access — debugging, testing, and development workflows. + +--- + +### HAR / Network Capture + +Record every network request and response during a session. Discover the private APIs powering a site's UI. Debug form submissions that silently fail. + +```js +await startCapture(page); +await page.click('#submit'); +const har = await stopCapture(); +// har.entries → full request/response log with timings and bodies +``` + +--- + +### DOM Diff + +Snapshot the page's DOM before and after an action, then diff them. Know exactly what changed after a form submit, a route transition, or an AJAX update — without guessing. + +```js +await snapshotDOM('before'); +await page.click('#apply-filters'); +await waitForPageLoad(); +const diff = await diffDOM('before', 'after'); +// diff → added/removed/changed nodes +``` + +--- + +### E2E Test Recorder + +Every agent action gets recorded as Playwright test code. The agent explores a workflow once — the plugin auto-generates a `.test.js` regression file. Agents leave behind test suites instead of tribal knowledge. + +```js +await startRecording(); +await page.click('#checkout'); +await page.fill('#card-number', '4111111111111111'); +await stopRecording('~/tests/checkout.test.js'); +// checkout.test.js is written to disk, ready to run +``` + +--- + +### Request Interceptor / API Mocker + +Return fake data for specific endpoints without touching the backend. Test error states, empty states, and edge cases against a live UI. + +```js +await mockAPI(page, '**/api/products', { + status: 200, + body: { products: [] } // empty state +}); +await page.reload(); +// UI now renders the empty state — no backend change needed +``` + +--- + +### Session State Snapshots + +Capture all cookies, localStorage, and sessionStorage under a named key. Restore any state instantly. Test workflows as different user roles without logging out. + +```js +await saveState('admin-logged-in'); +// ... test admin workflows ... +await restoreState('free-user'); +// now running as free user — zero re-authentication +``` + +--- + +### PDF Export + +Export the current page as a PDF. Generate reports, invoices, or documentation directly from browser content — pixel-perfect, with real fonts and styles. + +```js +const buffer = await printBuffer({ format: 'A4', printBackground: true }); +// or write directly to disk: +await savePDF('~/exports/invoice-2024.pdf'); +``` + +--- + +### Clipboard Bridge + +Read and write the system clipboard. Bypass sites that block copy-paste. Agents can write extracted data directly to clipboard for the user to paste elsewhere. + +```js +// Write a result to clipboard +await writeClipboard('Order ID: 98431-B'); + +// Read what the user copied +const copied = await readClipboard(); +``` + +--- + +## For OpenClaw & Automated Agents + +Use cases for headless and non-interactive workflows — AI agents running autonomously, no browser UI required. + +--- + +### Zero Credential Exposure + +BrowserForce agents inherit the user's real browser sessions — no passwords, no API keys, no OAuth tokens in config. An `extractBearerToken` helper watches live network traffic and plucks `Authorization` headers, giving agents API access through the existing session. Credentials never leave the browser. + +This is the core differentiator vs every other agent tool. + +```js +// In an automated workflow — no credentials configured anywhere +const token = await extractBearerToken(page, 'api.example.com'); +// token → "Bearer eyJ..." pulled from live browser traffic +// now usable for direct API calls within the same agent run +``` + +--- + +### Download Capture + +Run a callback (e.g. click "Export CSV"), intercept the file download, return the content directly — no temp files, no manual download folder management. Sites that only expose data via download buttons become fully automatable. + +```js +const csv = await captureDownload(async () => { + await page.click('#export-csv'); +}); +const rows = csv.split('\n').map(r => r.split(',')); +// process rows directly — no file system involved +``` + +--- + +### Page Monitor + +Watch a URL and fire when content changes. Price trackers, job boards, CI dashboards, stock alerts. Long-running monitoring without constant agent polling. + +```js +// MCP tool: monitor_page +// Or use the helper directly: +await waitForContentChange(page, '.price-display', { timeout: 3_600_000 }); +// resolves when the element's text changes — up to 1 hour wait +``` + +--- + +### Desktop & Webhook Notifications + +System notifications and webhook delivery for long-running agent tasks. "When the product restocks, notify me" becomes a one-liner. + +```js +// Desktop notification +await notify('Restock Alert', 'Nike Air Max 90 is back in stock'); + +// MCP tool: send_webhook +// Or call directly: +await sendWebhook('https://hooks.slack.com/...', { + text: 'Job scrape complete — 47 new listings found' +}); +``` + +--- + +### Multi-Tab Session Orchestration + +BrowserForce sees every open tab. Plugins can extract data from one authenticated tab and inject it into another. Cross-tab RPA that no headless tool supports — because headless tools can't access existing logged-in sessions. + +```js +const pages = await context.pages(); +const dashboardPage = pages.find(p => p.url().includes('/dashboard')); +const data = await dashboardPage.evaluate(() => window.__APP_STATE__); + +const reportPage = pages.find(p => p.url().includes('/reports')); +await reportPage.evaluate((d) => window.loadExternalData(d), data); +``` + +--- + +### File Upload Helper + +Handle file inputs cleanly — from disk or from memory. Automate workflows that require uploading documents, images, or generated data without writing temp files. + +```js +// Upload from disk +await uploadFromDisk(page, '#profile-photo', '~/photos/avatar.png'); + +// Upload generated content directly from memory +await uploadFromMemory(page, '#import-csv', csvString, 'import.csv'); +``` + +--- + +## Building Your Own Plugin + +Full plugin shape — all fields are optional except `name`: + +```js +// ~/.browserforce/plugins/my-plugin.js +export default { + // Required. Must be unique across all plugins. + name: 'my-plugin', + + // Runs once when the MCP server starts. + // Use for initializing state, opening connections, reading config. + async setup({ browser }) { + // browser → Playwright Browser instance + }, + + // Functions injected as globals into every execute() call. + // Signature: async (page, ...args) → any + helpers: { + async myHelper(page, param) { + return await page.evaluate((p) => window.someAPI(p), param); + } + }, + + // Standalone MCP tools registered alongside execute/reset/screenshot_with_labels. + // Agents can call these directly by name. + tools: [{ + name: 'my_tool', + description: 'What this tool does and when to use it.', + schema: { + param: { type: 'string', description: 'Input value' } + }, + async handler({ param }, { browser, context }) { + // browser → Playwright Browser + // context → Playwright BrowserContext + return { + content: [{ type: 'text', text: `Result: ${param}` }] + }; + } + }], + + // Playwright browser lifecycle hooks. + // Fired automatically — no agent action required. + hooks: { + onPage: async (page) => {}, // new page created + onNavigation: async (page, url) => {}, // page navigated + onRequest: async (request, page) => {}, // network request fired + onResponse: async (response, page) => {}, // network response received + } +} +``` + + +| Field | Type | When to use | +| --------- | ------------------------------------------------- | --------------------------------------------------------------- | +| `setup` | `async ({ browser }) => void` | One-time init — open DB connections, load config, warm caches | +| `helpers` | `{ name: async (page, ...args) => any }` | Reusable page utilities injected into `execute()` scope | +| `tools` | `[{ name, description, schema, handler }]` | Standalone agent-callable MCP tools with their own input schema | +| `hooks` | `{ onPage, onNavigation, onRequest, onResponse }` | Passive observers — monitoring, logging, request interception | + + +--- + +## Plugin Ecosystem + +### Contributing a Plugin + +Plugins live in the BrowserForce repo under `plugins/`. To publish one: + +1. Fork the repo +2. Create a folder: `plugins/community/my-plugin/` +3. Add `index.js` (the plugin code) and `SKILL.md` (AI instructions) inside it +4. Add an entry to `plugins/registry.json` +5. Open a PR — official plugins get reviewed and merged to main + +That's it. No separate registry service. No npm publishing required. + +--- + +### The Registry + +A single JSON file at `plugins/registry.json` in the repo is the source of truth. The Chrome extension and CLI fetch it directly from GitHub's raw content URL — no server, no API. + +**Registry entry shape:** + +```json +{ + "name": "network", + "displayName": "HAR / Network Capture", + "description": "Record all network requests and responses during a session.", + "author": "browserforce", + "official": true, + "version": "1.0.0", + "audience": ["developer"], + "capabilities": ["helpers", "hooks"], + "file": "plugins/official/network/index.js", + "skill": "plugins/official/network/SKILL.md" +} +``` + + +| Field | Description | +| -------------- | ------------------------------------------------------------------- | +| `official` | `true` for BrowserForce-maintained plugins, `false` for community | +| `audience` | `"developer"`, `"headless"`, or both | +| `capabilities` | Which plugin surfaces it uses: `helpers`, `tools`, `hooks`, `setup` | +| `file` | Path to `index.js` in the repo — fetched on install | +| `skill` | Path to `SKILL.md` — fetched on install, injected into AI context | + + +--- + +### Chrome Extension — Plugin Directory + +The extension popup gains a **Plugins** tab (or opens as a fullscreen options page). It: + +1. Fetches `registry.json` from GitHub on open (cached for 10 minutes) +2. Shows all plugins — official first, community below — with audience tags and capability badges +3. Marks which ones are currently installed +4. Install/remove buttons call the relay's plugin API (the extension can't write to disk directly) + +**Why the relay is the bridge:** +Chrome extensions have no filesystem access. The relay runs at `127.0.0.1:19222` and can write to `~/.browserforce/plugins/`. The extension POSTs to the relay; the relay fetches the plugin file from GitHub and writes it to disk. + +``` +Extension UI + │ POST /plugins/install { name: "network" } + ▼ +Relay (127.0.0.1:19222) + │ fetches index.js + SKILL.md from GitHub + │ writes to ~/.browserforce/plugins/network/ + ▼ +~/.browserforce/plugins/ +``` + +**Relay plugin endpoints:** + + +| Method | Path | Action | +| -------- | ------------------ | -------------------------------------------- | +| `GET` | `/plugins` | List installed plugins + their metadata | +| `POST` | `/plugins/install` | Download plugin from registry, write to disk | +| `DELETE` | `/plugins/:name` | Remove plugin file from disk | + + +Plugins take effect on next MCP server restart (the extension shows a restart prompt). + +--- + +### CLI — For Headless Users + +Users without browser UI access manage plugins through the CLI: + +```bash +# List all available plugins from the registry +browserforce plugin list + +# Install a plugin +browserforce plugin install network + +# Install from a local file (for development) +browserforce plugin install ./my-plugin.js + +# Remove a plugin +browserforce plugin remove network + +# Show installed plugins +browserforce plugin status +``` + +`plugin install` fetches the JS directly from GitHub's raw content URL and writes it to `~/.browserforce/plugins/`. Same outcome as the extension UI, different path. + +--- + +### Plugin Directory Structure (in repo) + +``` +plugins/ + registry.json ← single source of truth + official/ + network/ + index.js ← HAR capture plugin code + SKILL.md ← AI instructions for this plugin + session/ + index.js + SKILL.md + pdf/ + index.js + SKILL.md + community/ + salesforce/ ← community-contributed + index.js + SKILL.md + linear/ + index.js + SKILL.md +``` + +Official plugins are maintained by the BrowserForce team. Community plugins are reviewed for safety (no `eval`, no network calls to external servers, no credential exfiltration) before merge. + +--- + +### Security Model + +Plugins are arbitrary JS running in Node.js — they have full filesystem and network access. The safety contract is: + +- **Official plugins**: reviewed and maintained by BrowserForce +- **Community plugins**: reviewed before merge (same bar as official) +- **Local plugins**: `~/.browserforce/plugins/*.js` — user's own files, not from the registry, fully trusted + +The relay install endpoint only fetches from the known GitHub repo URL — no arbitrary URLs. The extension UI only shows registry plugins. Users who want to run untrusted code drop files manually into the plugins folder. + +No sandboxing beyond that. Plugins are as trusted as any npm package you install. + +--- + From 33b06848c64ea2610b45040413fd2fe229342287 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 21:14:55 +0530 Subject: [PATCH 004/192] docs: add comprehensive guide for building BrowserForce plugins - Introduced a new `BUILDING_PLUGINS.md` file detailing the process of creating, testing, and submitting plugins. - Included step-by-step instructions for building a sample highlight plugin, along with code snippets for helper functions and usage examples. - Explained different plugin surfaces and the importance of the SKILL.md companion file for plugin context. --- docs/BUILDING_PLUGINS.md | 477 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 docs/BUILDING_PLUGINS.md diff --git a/docs/BUILDING_PLUGINS.md b/docs/BUILDING_PLUGINS.md new file mode 100644 index 0000000..a8c5be4 --- /dev/null +++ b/docs/BUILDING_PLUGINS.md @@ -0,0 +1,477 @@ +# Building BrowserForce Plugins + +Adding a plugin extends BrowserForce for yourself or the whole community. Personal plugins stay in `~/.browserforce/plugins/` and are never shared unless you choose to. Public plugins get reviewed and merged into the repo, appearing in the plugin directory for anyone to install. + +This guide walks through everything: building, testing, and submitting a plugin. + +--- + +## 1. Build Your First Plugin + +### Step 1 — Create the folder + +```bash +mkdir -p ~/.browserforce/plugins/highlight +touch ~/.browserforce/plugins/highlight/index.js +touch ~/.browserforce/plugins/highlight/SKILL.md +``` + +### Step 2 — Write the export + +Start with just `name` and one helper. Here is a complete `highlight.js` plugin that visually highlights any element on the page: + +```js +// ~/.browserforce/plugins/highlight/index.js + +export default { + name: 'highlight', + + helpers: { + /** + * Visually highlight a DOM element by selector. + * + * @param {import('playwright').Page} page + * @param {string} selector - CSS selector for the element to highlight + * @param {string} [color] - CSS color value (default: '#ff0' — yellow) + * @param {number} [duration] - ms to hold the highlight (0 = permanent, default: 2000) + * @returns {Promise<{ found: boolean, selector: string }>} + */ + async highlight(page, selector, color = '#ff0', duration = 2000) { + const found = await page.evaluate( + ({ sel, col, dur }) => { + const el = document.querySelector(sel); + if (!el) return false; + + const prev = el.style.cssText; + el.style.outline = `3px solid ${col}`; + el.style.backgroundColor = col; + el.style.transition = 'outline 0.1s, background-color 0.1s'; + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + + if (dur > 0) { + setTimeout(() => { + el.style.cssText = prev; + }, dur); + } + + return true; + }, + { sel: selector, col: color, dur: duration } + ); + + return { found, selector }; + }, + + /** + * Clear all highlights applied by this plugin. + * + * @param {import('playwright').Page} page + */ + async clearHighlights(page) { + await page.evaluate(() => { + document.querySelectorAll('[data-bf-highlighted]').forEach(el => { + el.removeAttribute('style'); + el.removeAttribute('data-bf-highlighted'); + }); + }); + } + } +}; +``` + +### Step 3 — Restart the MCP server + +Plugins are loaded at startup. Kill and restart the MCP server after dropping a new file: + +```bash +# If using Claude Desktop, restart it. +# If running manually: +pnpm mcp +``` + +### Step 4 — Call the helper from execute + +Once loaded, `highlight` and `clearHighlights` are available as globals inside every `execute()` call: + +```js +// In an execute() block: +const result = await highlight(page, 'button[type="submit"]', '#f90', 3000); +if (!result.found) return 'Element not found'; +return `Highlighted: ${result.selector}`; +``` + +```js +// Highlight multiple elements: +await highlight(page, 'h1', '#0ff', 0); // permanent cyan on heading +await highlight(page, '.price', '#f0f', 0); // permanent magenta on price +``` + +### Step 5 — Write a SKILL.md companion + +See [Section 4](#4-the-skillmd-companion) for what to include. + +### Step 6 — Submit as a PR (optional) + +See [Section 8](#8-submitting-a-plugin-pr-checklist) for the full checklist. + +--- + +## 2. Choosing the Right Surface + +Every plugin capability maps to one of four surfaces. Pick the one that matches how the capability will be used. + +### `helpers` — page utilities called from `execute()` + +Use when the capability needs to compose with other execute code inline — extracting data, manipulating the DOM, reading state. The agent writes a script that calls your helper as a function and uses the return value immediately. + +```js +helpers: { + async extractTableData(page, tableSelector) { + return page.evaluate((sel) => { + const rows = [...document.querySelectorAll(`${sel} tr`)]; + return rows.map(row => + [...row.querySelectorAll('td,th')].map(cell => cell.innerText.trim()) + ); + }, tableSelector); + } +} +``` + +Called from execute: +```js +const data = await extractTableData(page, '#results-table'); +return JSON.stringify(data); +``` + +### `tools` — standalone MCP tools with their own schema + +Use when the capability stands alone and the AI should invoke it directly by name, not compose it inside a script. PDF export, sending a notification, or fetching data from an external system are good fits. Tools return the MCP content format directly. + +```js +tools: [{ + name: 'export_pdf', + description: 'Export the current page as a PDF file. Use when the user wants to save or share a page.', + schema: { + path: { type: 'string', description: 'Output file path, e.g. ~/exports/report.pdf' } + }, + async handler({ path }, { browser, context }) { + const pages = context.pages(); + const page = pages[pages.length - 1]; + const resolvedPath = path.replace('~', process.env.HOME); + await page.pdf({ path: resolvedPath, format: 'A4', printBackground: true }); + return { content: [{ type: 'text', text: `PDF saved to ${resolvedPath}` }] }; + } +}] +``` + +### `hooks` — passive browser lifecycle observers + +Use when you need to react to browser events without any agent action triggering them. Logging all navigations, capturing every network request, or building a HAR store automatically are hook use cases. + +```js +hooks: { + onNavigation: async (page, url) => { + console.error(`[nav] ${url}`); + }, + onRequest: async (request, page) => { + // fires for every network request — keep processing minimal + if (request.url().includes('/api/')) { + store.push({ url: request.url(), method: request.method() }); + } + } +} +``` + +> `onRequest` and `onResponse` fire for every network event on every page. Keep hook handlers fast. Anything slow here slows the whole browser. + +### `setup` — one-time init at MCP server startup + +Use when multiple helpers share state that needs to be initialized before any of them run: opening a database connection, creating an in-memory HAR store, loading a config file. + +```js +let harStore = null; + +export default { + name: 'network', + async setup({ browser }) { + harStore = { entries: [], startedAt: Date.now() }; + }, + helpers: { + async startCapture(page) { harStore.capturing = true; }, + async stopCapture(page) { + harStore.capturing = false; + return harStore; + } + } +}; +``` + +--- + +## 3. The SKILL.md Companion + +Every plugin should ship a `SKILL.md` alongside the `.js` file. This file is read by the AI agent at startup. It tells the agent when to use the plugin, when not to, and how to call it correctly. Without it, the agent has no context for the plugin's capabilities. + +**Required sections:** + +```markdown +# highlight plugin + +Use `highlight(page, selector, color, duration)` / `clearHighlights(page)` when you need to: +- Visually mark an element for debugging or demonstration +- Show a user which element the agent is about to interact with +- Annotate a screenshot for reporting + +## When NOT to use this +- Don't highlight before taking a screenshot if you need the original unmodified view +- Don't leave permanent highlights (duration: 0) unless intentional — they persist across agent turns + +## Parameters +- `selector` — any valid CSS selector +- `color` — any CSS color value: `'#f90'`, `'red'`, `'rgba(255,0,0,0.3)'` +- `duration` — milliseconds to hold the highlight; `0` = permanent until `clearHighlights()` + +## Example +\`\`\`js +// Highlight the submit button in orange for 3 seconds +const { found } = await highlight(page, 'button[type="submit"]', '#f90', 3000); +if (!found) return 'Submit button not found on this page'; +\`\`\` + +## Common mistakes +- Calling `highlight` on a selector that matches zero elements — always check `result.found` +- Forgetting to `clearHighlights()` before capturing a clean screenshot +``` + +--- + +## 4. Rules — What's Not Allowed + +The following will cause a PR to be rejected without review. + +### Code quality + +- No obfuscated code — all plugin code must be readable line by line +- No minified code — even if it's a build output, submit the readable source +- No transpiled-only output — submit the original source, not compiled JS +- No code that requires a build step to understand or modify + +### Security + +- No network requests to external servers — plugins run locally and must stay local +- No `eval()`, `new Function(string)`, or any dynamic execution of remotely sourced strings +- No credential harvesting — never read, log, store, or transmit passwords, tokens, session cookies, or API keys to anything outside the browser context +- No shell execution (`child_process.exec`, `execSync`, `spawn`) unless the plugin is explicitly a local system integration and the shell command is hardcoded and clearly documented +- No writing to paths outside `~/.browserforce/` without explicit user configuration + +### Behavior + +- No modifying BrowserForce's own runtime state or files +- No overriding built-in helpers: `snapshot`, `waitForPageLoad`, `getLogs`, `clearLogs` +- No relying on undocumented BrowserForce internals — only use the API surfaces defined in this guide + +--- + +## 5. Best Practices + +**Single responsibility.** One plugin, one concern. Don't bundle 10 unrelated helpers into one file. If it needs its own README section, it needs its own plugin. + +**Name helpers specifically.** Helper names become globals in `execute()`. Use descriptive names that won't collide with built-ins or other plugins: + +| Bad | Good | +|-----|------| +| `capture` | `captureHAR` | +| `save` | `saveSessionState` | +| `extract` | `extractTableData` | + +**Handle errors and return useful values.** Wrap page interactions in try/catch. Return a summary the agent can act on — don't return `undefined` when you could return `{ found: false, reason: 'selector matched 0 elements' }`. + +```js +// Bad +async highlight(page, selector) { + await page.evaluate((sel) => { + document.querySelector(sel).style.outline = '3px solid red'; + }, selector); +} + +// Good +async highlight(page, selector) { + try { + const found = await page.evaluate((sel) => { + const el = document.querySelector(sel); + if (!el) return false; + el.style.outline = '3px solid red'; + return true; + }, selector); + return { found, selector }; + } catch (err) { + return { found: false, selector, error: err.message }; + } +} +``` + +**Write MCP tool descriptions the AI can understand.** Vague descriptions produce wrong tool calls. + +| Bad | Good | +|-----|------| +| `"Exports the page"` | `"Export the current page as a PDF. Use when the user asks to save or share the page as a document."` | + +**Keep hooks lightweight.** `onRequest` and `onResponse` fire for every network event. Do not run async calls, DOM access, or heavy processing inside them. Accumulate to an in-memory array; process it in a helper when the agent asks. + +**Use `setup()` for shared state.** If multiple helpers share a data store, initialize it once in `setup()` and close over it. Module-level mutable globals can leak state across tool invocations. + +**Test against a real browser.** Plugins interact with a live Chrome session. Integration test on real pages, not mocks. + +--- + +## 6. Testing Your Plugin + +Three levels before submitting. + +### Level 1 — Smoke test (required) + +Install locally, restart the MCP server, run a minimal `execute()` call: + +```js +// Minimal smoke test +return await highlight(page, 'body', '#ff0', 1000); +``` + +Verify: no crash, no uncaught exception, return value looks correct. + +### Level 2 — Real-world test (required) + +Run against at least one real website for each helper and tool the plugin exposes. Document what you ran and what came back in your PR description under `## Test Results`. + +Example test results entry: + +``` +highlight(page, 'h1', '#f90', 2000) on https://example.com +→ { found: true, selector: 'h1' } +Element glowed orange for 2s, reverted cleanly. + +highlight(page, '.nonexistent', '#f90') on https://example.com +→ { found: false, selector: '.nonexistent' } +No crash, correct not-found response. +``` + +### Level 3 — Data correctness (for helpers that extract or transform data) + +If your plugin extracts or transforms data, verify 2-3 representative cases: input page state → expected helper output. These can be manual — you are not required to write automated tests, but you must have confirmed the output is correct and stable before submitting. + +--- + +## 7. Submitting a Plugin (PR Checklist) + +Before opening a PR, verify all of the following: + +- [ ] Plugin folder created at `plugins/community/your-plugin/` +- [ ] `index.js` and `SKILL.md` both present inside that folder +- [ ] Code is readable — no minification, no obfuscation +- [ ] `registry.json` entry added with all required fields (see format below) +- [ ] Plugin tested against at least one real website per helper/tool +- [ ] No external network calls +- [ ] No `eval()` or dynamic code execution +- [ ] No credentials or secrets in code or comments +- [ ] Helper names are specific enough to avoid collisions +- [ ] PR description includes a `## Test Results` section with actual output + +### registry.json entry format + +```json +{ + "name": "highlight", + "displayName": "Element Highlighter", + "description": "Visually highlight DOM elements with a colored outline. Useful for debugging, demonstration, and annotated screenshots.", + "author": "your-github-handle", + "official": false, + "version": "1.0.0", + "audience": ["developer"], + "capabilities": ["helpers"], + "file": "plugins/community/highlight.js", + "readme": "plugins/community/highlight.md" +} +``` + +--- + +## 8. Plugin Versioning + +The registry references versioned releases, not the `main` branch directly. + +When updating an existing plugin: + +1. Bump `version` in `registry.json` (follow semver) +2. Existing installs do not auto-update — users re-install to get the new version +3. Breaking changes to helper signatures (renamed params, changed return shape) warrant a **major version bump** +4. Add a `## Migration` section to `SKILL.md` for any breaking change + +```markdown +## Migration — v1 → v2 + +`highlight(page, selector, color)` now returns `{ found, selector }` instead of a boolean. + +Before: +\`\`\`js +const ok = await highlight(page, 'h1', '#f90'); +\`\`\` + +After: +\`\`\`js +const { found } = await highlight(page, 'h1', '#f90'); +\`\`\` +``` + +--- + +## Full Plugin Shape Reference + +```js +// ~/.browserforce/plugins/my-plugin.js + +export default { + // Required. Unique across all plugins. + name: 'my-plugin', + + // One-time init when the MCP server starts. + async setup({ browser }) { + // browser → Playwright Browser instance + }, + + // Page utilities injected as globals into every execute() call. + // First argument is always `page`. Return values are available to the agent. + helpers: { + async myHelper(page, param) { + return await page.evaluate((p) => window.someAPI(p), param); + } + }, + + // Standalone MCP tools. Agents call these by name, not from execute(). + tools: [{ + name: 'my_tool', + description: 'What this tool does and when the agent should call it.', + schema: { + param: { type: 'string', description: 'Input value' } + }, + async handler({ param }, { browser, context }) { + // Must return MCP content format. + return { content: [{ type: 'text', text: `Result: ${param}` }] }; + } + }], + + // Passive browser lifecycle observers. No agent trigger required. + hooks: { + onPage: async (page) => {}, // new page created + onNavigation: async (page, url) => {}, // page navigated + onRequest: async (request, page) => {}, // network request sent + onResponse: async (response, page) => {}, // network response received + } +}; +``` + +| Surface | Receives | Returns | When to use | +|---------|----------|---------|-------------| +| `setup` | `{ browser }` | void | One-time init: open connections, warm state | +| `helpers` | `(page, ...args)` | any | Inline page utilities composed inside `execute()` | +| `tools` | `(params, { browser, context })` | MCP content | Standalone agent-callable actions with own schema | +| `hooks` | varies by hook | void | Passive observers — logging, monitoring, interception | From 8a1b7a590ba5143f3255a174252896008f8358ba Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 22:12:00 +0530 Subject: [PATCH 005/192] fix(plugins): correct registry URL to point at ivalsaraj/browserforce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The highlight plugin url/skill_url pointed to the non-existent org browserforce/plugins. Updated to the actual file location: ivalsaraj/browserforce/main/plugins/official/highlight/ Also removed the now-dead BASE_RAW constant from both installers — it was a leftover from before the schema change (entry.url replaced the old BASE_RAW + entry.file pattern). Co-Authored-By: Claude Sonnet 4.6 --- mcp/src/plugin-installer.js | 1 - plugins/registry.json | 4 ++-- relay/src/plugin-installer.cjs | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/mcp/src/plugin-installer.js b/mcp/src/plugin-installer.js index 517abc7..647f998 100644 --- a/mcp/src/plugin-installer.js +++ b/mcp/src/plugin-installer.js @@ -4,7 +4,6 @@ import { createHash } from 'node:crypto'; import https from 'node:https'; const REGISTRY_URL = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/registry.json'; -const BASE_RAW = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/'; function httpsGetRaw(url) { return new Promise((resolve, reject) => { diff --git a/plugins/registry.json b/plugins/registry.json index 8370cc1..ec5a90e 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -5,8 +5,8 @@ "name": "highlight", "description": "Highlight elements on the page using CSS outline", "version": "1.0.0", - "url": "https://raw.githubusercontent.com/browserforce/plugins/main/highlight/index.js", - "skill_url": "https://raw.githubusercontent.com/browserforce/plugins/main/highlight/SKILL.md", + "url": "https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/official/highlight/index.js", + "skill_url": "https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/official/highlight/SKILL.md", "sha256": "d302bd9a0f6e96bd0c7a8666b560e01ab88f9f9e4c4694f14d97019f4cc04424" } ] diff --git a/relay/src/plugin-installer.cjs b/relay/src/plugin-installer.cjs index 084a5b3..bd7d51d 100644 --- a/relay/src/plugin-installer.cjs +++ b/relay/src/plugin-installer.cjs @@ -5,7 +5,6 @@ const crypto = require('node:crypto'); const https = require('node:https'); const REGISTRY_URL = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/registry.json'; -const BASE_RAW = 'https://raw.githubusercontent.com/ivalsaraj/browserforce/main/'; function httpsGetRaw(url) { return new Promise((resolve, reject) => { From 40efa360ccb4dbcd89fe69f8abdfddcd1c31f956 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 22:32:20 +0530 Subject: [PATCH 006/192] feat: add non-blocking update notifier to CLI Checks registry.npmjs.org/browserforce/latest once per day (cached in ~/.browserforce/update-check.json). Runs async in background alongside the command; shows a one-liner to stderr after completion if a newer version is available. Skipped for long-running serve/mcp commands. Zero new dependencies. Co-Authored-By: Claude Sonnet 4.6 --- bin.js | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/bin.js b/bin.js index c3be658..c2c00a6 100644 --- a/bin.js +++ b/bin.js @@ -3,6 +3,7 @@ import { parseArgs } from 'node:util'; import http from 'node:http'; +import https from 'node:https'; const { values, positionals } = parseArgs({ options: { @@ -68,6 +69,61 @@ function httpFetch(method, url, body, authToken) { }); } +// ─── Update Notifier ──────────────────────────────────────────────────────── + +function semverGt(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) return true; + if ((pa[i] || 0) < (pb[i] || 0)) return false; + } + return false; +} + +async function checkForUpdate() { + try { + const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { homedir } = await import('node:os'); + + const current = JSON.parse(readFileSync(new URL('./package.json', import.meta.url).pathname, 'utf8')).version; + const cacheDir = join(homedir(), '.browserforce'); + const cacheFile = join(cacheDir, 'update-check.json'); + + // Return cached result if fresh (< 24 h) + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + if (Date.now() - cached.checkedAt < 86_400_000) { + return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null; + } + } catch { /* no cache yet */ } + + // Fetch latest version from npm registry + const latest = await new Promise((resolve, reject) => { + const req = https.get( + 'https://registry.npmjs.org/browserforce/latest', + { headers: { 'User-Agent': 'browserforce-cli' } }, + (res) => { + if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } + let data = ''; + res.on('data', d => (data += d)); + res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } }); + }, + ); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); + }); + + // Persist cache + try { mkdirSync(cacheDir, { recursive: true }); writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); } catch { /* ignore */ } + + return semverGt(latest, current) ? { current, latest } : null; + } catch { + return null; + } +} + async function connectBrowser() { const { getCdpUrl, ensureRelay } = await import('./mcp/src/exec-engine.js'); await ensureRelay(); @@ -356,9 +412,22 @@ if (!handler) { process.exit(1); } +// Start update check in background — skipped for long-running commands +const updatePromise = (command !== 'serve' && command !== 'mcp') + ? checkForUpdate() + : null; + try { await handler(); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } + +// Show update notice after command finishes (wait at most 500 ms) +if (updatePromise) { + const update = await Promise.race([updatePromise, new Promise(r => setTimeout(r, 500, null))]); + if (update) { + process.stderr.write(`\n Update available: ${update.current} → ${update.latest}\n Run: npm install -g browserforce\n\n`); + } +} From 8685fd875af6bcbeff21173b9dd9f74269da397f Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 22:37:24 +0530 Subject: [PATCH 007/192] feat: agent-visible update notices via MCP + browserforce update command Extract update check logic to mcp/src/update-check.js (shared module). MCP server: fires update check at startup, injects a one-line notice into the first execute tool response when a newer version is on npm. The agent surfaces this to the user who can say "update browserforce". CLI: add `browserforce update` command (runs npm install -g browserforce) so the agent has a single command to trigger the upgrade. Co-Authored-By: Claude Sonnet 4.6 --- bin.js | 82 ++++++++++++----------------------------- mcp/src/index.js | 15 ++++++++ mcp/src/update-check.js | 65 ++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 59 deletions(-) create mode 100644 mcp/src/update-check.js diff --git a/bin.js b/bin.js index c2c00a6..c9bb38a 100644 --- a/bin.js +++ b/bin.js @@ -3,7 +3,7 @@ import { parseArgs } from 'node:util'; import http from 'node:http'; -import https from 'node:https'; +import { checkForUpdate } from './mcp/src/update-check.js'; const { values, positionals } = parseArgs({ options: { @@ -69,61 +69,6 @@ function httpFetch(method, url, body, authToken) { }); } -// ─── Update Notifier ──────────────────────────────────────────────────────── - -function semverGt(a, b) { - const pa = a.split('.').map(Number); - const pb = b.split('.').map(Number); - for (let i = 0; i < 3; i++) { - if ((pa[i] || 0) > (pb[i] || 0)) return true; - if ((pa[i] || 0) < (pb[i] || 0)) return false; - } - return false; -} - -async function checkForUpdate() { - try { - const { readFileSync, writeFileSync, mkdirSync } = await import('node:fs'); - const { join } = await import('node:path'); - const { homedir } = await import('node:os'); - - const current = JSON.parse(readFileSync(new URL('./package.json', import.meta.url).pathname, 'utf8')).version; - const cacheDir = join(homedir(), '.browserforce'); - const cacheFile = join(cacheDir, 'update-check.json'); - - // Return cached result if fresh (< 24 h) - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - if (Date.now() - cached.checkedAt < 86_400_000) { - return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null; - } - } catch { /* no cache yet */ } - - // Fetch latest version from npm registry - const latest = await new Promise((resolve, reject) => { - const req = https.get( - 'https://registry.npmjs.org/browserforce/latest', - { headers: { 'User-Agent': 'browserforce-cli' } }, - (res) => { - if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } - let data = ''; - res.on('data', d => (data += d)); - res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } }); - }, - ); - req.on('error', reject); - req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); - }); - - // Persist cache - try { mkdirSync(cacheDir, { recursive: true }); writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); } catch { /* ignore */ } - - return semverGt(latest, current) ? { current, latest } : null; - } catch { - return null; - } -} - async function connectBrowser() { const { getCdpUrl, ensureRelay } = await import('./mcp/src/exec-engine.js'); await ensureRelay(); @@ -360,6 +305,23 @@ async function cmdPlugin() { process.exit(1); } +async function cmdUpdate() { + const { spawnSync } = await import('node:child_process'); + console.log('Checking for updates...'); + const update = await checkForUpdate(); + if (!update) { + console.log('Already up to date.'); + return; + } + console.log(`Updating ${update.current} → ${update.latest}...`); + const result = spawnSync('npm', ['install', '-g', 'browserforce'], { stdio: 'inherit' }); + if (result.status !== 0) { + console.error('Update failed. Run manually: npm install -g browserforce'); + process.exit(1); + } + console.log(`Updated to ${update.latest}.`); +} + function cmdHelp() { console.log(` BrowserForce — Give AI agents your real Chrome browser @@ -375,6 +337,7 @@ function cmdHelp() { browserforce plugin list List installed plugins browserforce plugin install Install a plugin from the registry browserforce plugin remove Remove an installed plugin + browserforce update Update to the latest version browserforce -e "" Execute Playwright JavaScript (one-shot) Options: @@ -387,6 +350,7 @@ function cmdHelp() { browserforce tabs browserforce plugin list browserforce plugin install highlight + browserforce update browserforce -e "return await snapshot()" browserforce -e "await page.goto('https://github.com'); return await snapshot()" browserforce screenshot 0 > page.png @@ -402,7 +366,7 @@ function cmdHelp() { const commands = { serve: cmdServe, mcp: cmdMcp, status: cmdStatus, tabs: cmdTabs, screenshot: cmdScreenshot, snapshot: cmdSnapshot, navigate: cmdNavigate, - execute: cmdExecute, plugin: cmdPlugin, help: cmdHelp, + execute: cmdExecute, plugin: cmdPlugin, update: cmdUpdate, help: cmdHelp, }; const handler = commands[command]; @@ -412,8 +376,8 @@ if (!handler) { process.exit(1); } -// Start update check in background — skipped for long-running commands -const updatePromise = (command !== 'serve' && command !== 'mcp') +// Start update check in background — skipped for long-running or self-update commands +const updatePromise = (command !== 'serve' && command !== 'mcp' && command !== 'update') ? checkForUpdate() : null; diff --git a/mcp/src/index.js b/mcp/src/index.js index 5bb01e0..ad06862 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -11,6 +11,7 @@ import { } from './exec-engine.js'; import { screenshotWithLabels } from './a11y-labels.js'; import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js'; +import { checkForUpdate } from './update-check.js'; // ─── Console Log Capture ───────────────────────────────────────────────────── @@ -107,6 +108,12 @@ let userState = {}; let plugins = []; let pluginHelpers = {}; +// ─── Update State ──────────────────────────────────────────────────────────── +// Checked once at startup; notice injected into first execute response only. + +let pendingUpdate = null; // { current, latest } or null +let updateNoticeSent = false; + // ─── MCP Server ────────────────────────────────────────────────────────────── const server = new McpServer({ @@ -319,6 +326,11 @@ function registerExecuteTool(skillAppendix = '') { try { const result = await runCode(code, execCtx, timeout); const formatted = formatResult(result); + // Inject update notice into the first text response of the session (once only) + if (pendingUpdate && !updateNoticeSent && formatted.type === 'text') { + updateNoticeSent = true; + formatted.text += `\n\n[BrowserForce update available: ${pendingUpdate.current} → ${pendingUpdate.latest}]\n[Run: browserforce update or: npm install -g browserforce]`; + } return { content: [formatted] }; } catch (err) { const isTimeout = err instanceof CodeExecutionTimeoutError; @@ -453,6 +465,9 @@ async function main() { await initPlugins(); registerExecuteTool(buildPluginSkillAppendix(plugins)); + // Fire update check in background — result stored in pendingUpdate for execute handler + checkForUpdate().then(info => { pendingUpdate = info; }).catch(() => {}); + try { await ensureBrowser(); process.stderr.write('[bf-mcp] Connected to relay\n'); diff --git a/mcp/src/update-check.js b/mcp/src/update-check.js new file mode 100644 index 0000000..2815f7d --- /dev/null +++ b/mcp/src/update-check.js @@ -0,0 +1,65 @@ +import https from 'node:https'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export function semverGt(a, b) { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < 3; i++) { + if ((pa[i] || 0) > (pb[i] || 0)) return true; + if ((pa[i] || 0) < (pb[i] || 0)) return false; + } + return false; +} + +/** + * Check npm registry for a newer version of browserforce. + * Result is cached for 24 h in ~/.browserforce/update-check.json. + * Returns { current, latest } if an update is available, otherwise null. + * Never throws — all errors resolve to null. + */ +export async function checkForUpdate() { + try { + // package.json is two levels up from mcp/src/ + const pkgPath = new URL('../../package.json', import.meta.url).pathname; + const current = JSON.parse(readFileSync(pkgPath, 'utf8')).version; + + const cacheDir = join(homedir(), '.browserforce'); + const cacheFile = join(cacheDir, 'update-check.json'); + + // Return cached result if still fresh (< 24 h) + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + if (Date.now() - cached.checkedAt < 86_400_000) { + return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null; + } + } catch { /* no cache yet, or invalid */ } + + // Fetch latest from npm registry + const latest = await new Promise((resolve, reject) => { + const req = https.get( + 'https://registry.npmjs.org/browserforce/latest', + { headers: { 'User-Agent': 'browserforce-cli' } }, + (res) => { + if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } + let data = ''; + res.on('data', d => (data += d)); + res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } }); + }, + ); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); + }); + + // Persist to cache + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); + } catch { /* ignore cache write errors */ } + + return semverGt(latest, current) ? { current, latest } : null; + } catch { + return null; + } +} From 5b7cdd1d68aefb969a276a333237483631bbe180 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 22:55:04 +0530 Subject: [PATCH 008/192] fix: correct path handling, update-check errors, and plugin docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use fileURLToPath() instead of .pathname on file: URLs in update-check.js and bin.js — fixes silent failures on paths with spaces or on Windows - checkForUpdate() now propagates network/parse errors instead of swallowing them as null; cmdUpdate catches and reports failures instead of printing "Already up to date." on offline/DNS errors - Execute tool update banner is now a second content[] item rather than mutating the first result's text — prevents corrupting structured output parsed by callers - Fix BUILDING_PLUGINS.md registry example: replace stale file/readme fields with the actual installer contract (url, sha256, skill_url) - Bump version to 1.0.10 Co-Authored-By: Claude Sonnet 4.6 --- bin.js | 11 +++++-- docs/BUILDING_PLUGINS.md | 10 ++++-- mcp/src/index.js | 7 ++-- mcp/src/update-check.js | 71 +++++++++++++++++++--------------------- package.json | 2 +- 5 files changed, 56 insertions(+), 45 deletions(-) diff --git a/bin.js b/bin.js index c9bb38a..6e723a0 100644 --- a/bin.js +++ b/bin.js @@ -3,6 +3,7 @@ import { parseArgs } from 'node:util'; import http from 'node:http'; +import { fileURLToPath } from 'node:url'; import { checkForUpdate } from './mcp/src/update-check.js'; const { values, positionals } = parseArgs({ @@ -75,7 +76,7 @@ async function connectBrowser() { // playwright-core lives in mcp/node_modules (pnpm workspace sub-package). // Use createRequire from the mcp package context to locate it, then dynamic-import. const { createRequire } = await import('node:module'); - const mReq = createRequire(new URL('./mcp/src/exec-engine.js', import.meta.url).pathname); + const mReq = createRequire(fileURLToPath(new URL('./mcp/src/exec-engine.js', import.meta.url))); const pwPath = mReq.resolve('playwright-core'); const { default: pw } = await import(pwPath); const { chromium } = pw; @@ -308,7 +309,13 @@ async function cmdPlugin() { async function cmdUpdate() { const { spawnSync } = await import('node:child_process'); console.log('Checking for updates...'); - const update = await checkForUpdate(); + let update; + try { + update = await checkForUpdate(); + } catch (err) { + console.error(`Update check failed: ${err.message}`); + return; + } if (!update) { console.log('Already up to date.'); return; diff --git a/docs/BUILDING_PLUGINS.md b/docs/BUILDING_PLUGINS.md index a8c5be4..34e6928 100644 --- a/docs/BUILDING_PLUGINS.md +++ b/docs/BUILDING_PLUGINS.md @@ -388,11 +388,17 @@ Before opening a PR, verify all of the following: "version": "1.0.0", "audience": ["developer"], "capabilities": ["helpers"], - "file": "plugins/community/highlight.js", - "readme": "plugins/community/highlight.md" + "url": "https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/community/highlight/index.js", + "sha256": "abc123...", + "skill_url": "https://raw.githubusercontent.com/ivalsaraj/browserforce/main/plugins/community/highlight/SKILL.md" } ``` +> **Field reference** +> - `url` *(required)* — absolute URL to the plugin JS file; fetched and installed by the plugin installer +> - `sha256` *(recommended)* — hex SHA-256 of the JS file for integrity verification; omit only in dev/test mode +> - `skill_url` *(optional)* — absolute URL to a `SKILL.md` Claude skill file bundled with the plugin + --- ## 8. Plugin Versioning diff --git a/mcp/src/index.js b/mcp/src/index.js index ad06862..0f61800 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -326,12 +326,13 @@ function registerExecuteTool(skillAppendix = '') { try { const result = await runCode(code, execCtx, timeout); const formatted = formatResult(result); - // Inject update notice into the first text response of the session (once only) + const content = [formatted]; + // Append update notice as a separate content item (once only per session) if (pendingUpdate && !updateNoticeSent && formatted.type === 'text') { updateNoticeSent = true; - formatted.text += `\n\n[BrowserForce update available: ${pendingUpdate.current} → ${pendingUpdate.latest}]\n[Run: browserforce update or: npm install -g browserforce]`; + content.push({ type: 'text', text: `[BrowserForce update available: ${pendingUpdate.current} → ${pendingUpdate.latest}]\n[Run: browserforce update or: npm install -g browserforce]` }); } - return { content: [formatted] }; + return { content }; } catch (err) { const isTimeout = err instanceof CodeExecutionTimeoutError; const hint = isTimeout ? '' : '\n\n[If connection lost, call reset tool to reconnect]'; diff --git a/mcp/src/update-check.js b/mcp/src/update-check.js index 2815f7d..8e6aa60 100644 --- a/mcp/src/update-check.js +++ b/mcp/src/update-check.js @@ -2,6 +2,7 @@ import https from 'node:https'; import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; +import { fileURLToPath } from 'node:url'; export function semverGt(a, b) { const pa = a.split('.').map(Number); @@ -20,46 +21,42 @@ export function semverGt(a, b) { * Never throws — all errors resolve to null. */ export async function checkForUpdate() { - try { - // package.json is two levels up from mcp/src/ - const pkgPath = new URL('../../package.json', import.meta.url).pathname; - const current = JSON.parse(readFileSync(pkgPath, 'utf8')).version; + // package.json is two levels up from mcp/src/ + const pkgPath = fileURLToPath(new URL('../../package.json', import.meta.url)); + const current = JSON.parse(readFileSync(pkgPath, 'utf8')).version; - const cacheDir = join(homedir(), '.browserforce'); - const cacheFile = join(cacheDir, 'update-check.json'); + const cacheDir = join(homedir(), '.browserforce'); + const cacheFile = join(cacheDir, 'update-check.json'); - // Return cached result if still fresh (< 24 h) - try { - const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); - if (Date.now() - cached.checkedAt < 86_400_000) { - return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null; - } - } catch { /* no cache yet, or invalid */ } + // Return cached result if still fresh (< 24 h) + try { + const cached = JSON.parse(readFileSync(cacheFile, 'utf8')); + if (Date.now() - cached.checkedAt < 86_400_000) { + return semverGt(cached.latest, current) ? { current, latest: cached.latest } : null; + } + } catch { /* no cache yet, or invalid */ } - // Fetch latest from npm registry - const latest = await new Promise((resolve, reject) => { - const req = https.get( - 'https://registry.npmjs.org/browserforce/latest', - { headers: { 'User-Agent': 'browserforce-cli' } }, - (res) => { - if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } - let data = ''; - res.on('data', d => (data += d)); - res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } }); - }, - ); - req.on('error', reject); - req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); - }); + // Fetch latest from npm registry — let errors propagate to caller + const latest = await new Promise((resolve, reject) => { + const req = https.get( + 'https://registry.npmjs.org/browserforce/latest', + { headers: { 'User-Agent': 'browserforce-cli' } }, + (res) => { + if (res.statusCode !== 200) { res.resume(); return reject(new Error(`HTTP ${res.statusCode}`)); } + let data = ''; + res.on('data', d => (data += d)); + res.on('end', () => { try { resolve(JSON.parse(data).version); } catch { reject(new Error('parse error')); } }); + }, + ); + req.on('error', reject); + req.setTimeout(5000, () => { req.destroy(); reject(new Error('timeout')); }); + }); - // Persist to cache - try { - mkdirSync(cacheDir, { recursive: true }); - writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); - } catch { /* ignore cache write errors */ } + // Persist to cache + try { + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cacheFile, JSON.stringify({ checkedAt: Date.now(), latest })); + } catch { /* ignore cache write errors */ } - return semverGt(latest, current) ? { current, latest } : null; - } catch { - return null; - } + return semverGt(latest, current) ? { current, latest } : null; } diff --git a/package.json b/package.json index abc9d59..7fbc4e0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browserforce", - "version": "1.0.9", + "version": "1.0.10", "type": "module", "description": "Give AI agents your real Chrome browser with progressive examples: simple reads, form interactions, multi-tab workflows, and state persistence. Search X and GitHub, extract ProductHunt data, test forms, compare A/B variants, monitor status pages. Works with OpenClaw, Claude, and any MCP agent.", "homepage": "https://github.com/ivalsaraj/browserforce", From 67baea3e191dde03f6273871964b7fecc3eb60ac Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:23:46 +0530 Subject: [PATCH 009/192] fix: include extension/ in npm package files The extension/ directory was previously omitted from the npm package, preventing npm-installed users from accessing the Chrome extension files. This change adds extension/ to the files array so the full extension bundle (manifest, background.js, popup UI, and icons) ships with the published package. Co-Authored-By: Claude Sonnet 4.6 --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 7fbc4e0..850577c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browserforce", - "version": "1.0.10", + "version": "1.0.11", "type": "module", "description": "Give AI agents your real Chrome browser with progressive examples: simple reads, form interactions, multi-tab workflows, and state persistence. Search X and GitHub, extract ProductHunt data, test forms, compare A/B variants, monitor status pages. Works with OpenClaw, Claude, and any MCP agent.", "homepage": "https://github.com/ivalsaraj/browserforce", @@ -29,6 +29,7 @@ "files": [ "README.md", "bin.js", + "extension/", "relay/src/", "relay/package.json", "mcp/src/", From b7a116f831710507d9e547574b72cb36fd1e23ce Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:25:34 +0530 Subject: [PATCH 010/192] feat: add install-extension command with VERSION sentinel Co-Authored-By: Claude Sonnet 4.6 --- bin.js | 42 ++++++++++++++++++++++++++++++++++++++++-- test/cli.test.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) diff --git a/bin.js b/bin.js index 6e723a0..9f19d44 100644 --- a/bin.js +++ b/bin.js @@ -329,6 +329,42 @@ async function cmdUpdate() { console.log(`Updated to ${update.latest}.`); } +async function doInstallExtension(quiet) { + const { cpSync, mkdirSync, writeFileSync, readFileSync } = await import('node:fs'); + const { join, dirname } = await import('node:path'); + const { homedir } = await import('node:os'); + + const pkgDir = dirname(fileURLToPath(import.meta.url)); + const src = join(pkgDir, 'extension'); + const dest = process.env.BF_EXT_DIR || join(homedir(), '.browserforce', 'extension'); + + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); + + // VERSION sentinel — tracks npm package version, NOT manifest.json version (those are separate tracks) + const pkgVersion = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version; + writeFileSync(join(dest, 'VERSION'), pkgVersion); + + if (!quiet) { + console.log(`Extension installed to: ${dest}`); + console.log(''); + console.log('To load in Chrome:'); + console.log(' 1. Open chrome://extensions/'); + console.log(' 2. Enable Developer mode (toggle, top-right)'); + console.log(' 3. Click "Load unpacked" → select:'); + console.log(` ${dest}`); + console.log(''); + console.log('❗ After any BrowserForce update, re-run: browserforce install-extension'); + console.log(' Then reload the extension in chrome://extensions/ (click the ↺ icon).'); + } + + return { dest, pkgVersion }; +} + +async function cmdInstallExtension() { + await doInstallExtension(false); +} + function cmdHelp() { console.log(` BrowserForce — Give AI agents your real Chrome browser @@ -345,6 +381,7 @@ function cmdHelp() { browserforce plugin install Install a plugin from the registry browserforce plugin remove Remove an installed plugin browserforce update Update to the latest version + browserforce install-extension Copy extension to ~/.browserforce/extension/ browserforce -e "" Execute Playwright JavaScript (one-shot) Options: @@ -373,7 +410,8 @@ function cmdHelp() { const commands = { serve: cmdServe, mcp: cmdMcp, status: cmdStatus, tabs: cmdTabs, screenshot: cmdScreenshot, snapshot: cmdSnapshot, navigate: cmdNavigate, - execute: cmdExecute, plugin: cmdPlugin, update: cmdUpdate, help: cmdHelp, + execute: cmdExecute, plugin: cmdPlugin, update: cmdUpdate, + 'install-extension': cmdInstallExtension, help: cmdHelp, }; const handler = commands[command]; @@ -385,7 +423,7 @@ if (!handler) { // Start update check in background — skipped for long-running or self-update commands const updatePromise = (command !== 'serve' && command !== 'mcp' && command !== 'update') - ? checkForUpdate() + ? checkForUpdate().catch(() => null) : null; try { diff --git a/test/cli.test.js b/test/cli.test.js index b13b4ed..307d259 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -198,3 +198,42 @@ describe('CLI plugin commands', () => { } }); }); + +describe('CLI install-extension', () => { + let tmpExt; + + before(() => { + tmpExt = join(tmpdir(), `bf-ext-${Math.random().toString(36).slice(2)}`); + }); + + after(() => { + rmSync(tmpExt, { recursive: true, force: true }); + }); + + it('install-extension copies extension files and writes VERSION', async () => { + const { stdout } = await exec('node', ['bin.js', 'install-extension'], { + env: { ...process.env, BF_EXT_DIR: tmpExt }, + }); + // Check output + assert.ok(stdout.includes('Extension installed to:')); + assert.ok(stdout.includes(tmpExt)); + assert.ok(stdout.includes('Load unpacked')); + assert.ok(stdout.includes('❗')); + assert.ok(stdout.includes('↺')); + + // Check files were copied + const { existsSync, readFileSync } = await import('node:fs'); + assert.ok(existsSync(join(tmpExt, 'manifest.json'))); + assert.ok(existsSync(join(tmpExt, 'background.js'))); + + // Check VERSION sentinel + const version = readFileSync(join(tmpExt, 'VERSION'), 'utf8').trim(); + const pkgVersion = JSON.parse(readFileSync('package.json', 'utf8')).version; + assert.equal(version, pkgVersion); + }); + + it('install-extension is listed in help', async () => { + const { stdout } = await exec('node', ['bin.js', 'help']); + assert.ok(stdout.includes('install-extension')); + }); +}); From 2109e009ff0587b9801c01e555dd96c5a9cf0cbc Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:27:12 +0530 Subject: [PATCH 011/192] feat: auto-sync extension after browserforce update Co-Authored-By: Claude Sonnet 4.6 --- bin.js | 15 +++++++++++++++ test/cli.test.js | 15 +++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/bin.js b/bin.js index 9f19d44..e061b80 100644 --- a/bin.js +++ b/bin.js @@ -327,6 +327,21 @@ async function cmdUpdate() { process.exit(1); } console.log(`Updated to ${update.latest}.`); + + // Auto-sync extension if user has previously run install-extension + const { readFileSync: readFs } = await import('node:fs'); + const { join: pathJoin } = await import('node:path'); + const { homedir: osHomedir } = await import('node:os'); + const extDir = process.env.BF_EXT_DIR || pathJoin(osHomedir(), '.browserforce', 'extension'); + try { + readFs(pathJoin(extDir, 'VERSION'), 'utf8'); // existence check + const { dest } = await doInstallExtension(true); + console.log(`Extension updated in ${dest}`); + console.log('❗ Reload the extension in chrome://extensions/ (click the ↺ icon).'); + } catch { + // No VERSION file — user hasn't run install-extension yet + console.log('Tip: run browserforce install-extension to set up the Chrome extension.'); + } } async function doInstallExtension(quiet) { diff --git a/test/cli.test.js b/test/cli.test.js index 307d259..f5bf1d5 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -236,4 +236,19 @@ describe('CLI install-extension', () => { const { stdout } = await exec('node', ['bin.js', 'help']); assert.ok(stdout.includes('install-extension')); }); + + it('install-extension replaces stale VERSION with current package version', async () => { + const { mkdirSync, writeFileSync, readFileSync } = await import('node:fs'); + // Simulate a previously-installed-but-stale extension + mkdirSync(tmpExt, { recursive: true }); + writeFileSync(join(tmpExt, 'VERSION'), '0.0.1'); + + // Re-running install-extension should overwrite VERSION with current version + await exec('node', ['bin.js', 'install-extension'], { + env: { ...process.env, BF_EXT_DIR: tmpExt }, + }); + const version = readFileSync(join(tmpExt, 'VERSION'), 'utf8').trim(); + const pkgVersion = JSON.parse(readFileSync('package.json', 'utf8')).version; + assert.equal(version, pkgVersion); + }); }); From 08985dd8aec1d93e130b14349336722db7c55786 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:30:14 +0530 Subject: [PATCH 012/192] feat: warn in serve when installed extension is outdated Co-Authored-By: Claude Sonnet 4.6 --- bin.js | 16 +++++++++++++ test/cli.test.js | 58 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/bin.js b/bin.js index e061b80..94d723a 100644 --- a/bin.js +++ b/bin.js @@ -231,6 +231,22 @@ async function cmdExecute() { } async function cmdServe() { + // Warn if installed extension is outdated vs current package + try { + const { readFileSync } = await import('node:fs'); + const { join, dirname } = await import('node:path'); + const { homedir } = await import('node:os'); + const pkgDir = dirname(fileURLToPath(import.meta.url)); + const pkgVersion = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version; + const extDir = process.env.BF_EXT_DIR || join(homedir(), '.browserforce', 'extension'); + const installedVersion = readFileSync(join(extDir, 'VERSION'), 'utf8').trim(); + if (installedVersion !== pkgVersion) { + process.stderr.write(`⚠ Extension is outdated (installed: ${installedVersion}, current: ${pkgVersion}).\n`); + process.stderr.write(` Run: browserforce install-extension\n`); + process.stderr.write(`❗ Then reload the extension in chrome://extensions/ (click the ↺ icon).\n\n`); + } + } catch { /* no VERSION file — git clone or first install; skip */ } + const { RelayServer } = await import('./relay/src/index.js'); const port = parseInt(process.env.RELAY_PORT || positionals[1] || '19222', 10); const relay = new RelayServer(port); diff --git a/test/cli.test.js b/test/cli.test.js index f5bf1d5..fdddfc5 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -251,4 +251,62 @@ describe('CLI install-extension', () => { const pkgVersion = JSON.parse(readFileSync('package.json', 'utf8')).version; assert.equal(version, pkgVersion); }); + + it('serve warns when extension VERSION is outdated', async () => { + const { mkdirSync, writeFileSync } = await import('node:fs'); + const staleDir = join(tmpdir(), `bf-ext-stale-${Math.random().toString(36).slice(2)}`); + mkdirSync(staleDir, { recursive: true }); + writeFileSync(join(staleDir, 'VERSION'), '0.0.1'); // intentionally stale + + const warning = await new Promise((resolve, reject) => { + const child = spawn('node', ['bin.js', 'serve'], { + env: { ...process.env, BF_EXT_DIR: staleDir }, + }); + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('serve timed out without producing stderr')); + }, 5000); + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + if (stderr.includes('❗')) { + clearTimeout(timer); + child.kill('SIGKILL'); + resolve(stderr); + } + }); + child.on('error', (err) => { clearTimeout(timer); reject(err); }); + }); + + assert.ok(warning.includes('outdated')); + assert.ok(warning.includes('install-extension')); + assert.ok(warning.includes('❗')); + + rmSync(staleDir, { recursive: true, force: true }); + }); + + it('serve does NOT warn when VERSION matches current package', async () => { + const { mkdirSync, writeFileSync, readFileSync } = await import('node:fs'); + const freshDir = join(tmpdir(), `bf-ext-fresh-${Math.random().toString(36).slice(2)}`); + mkdirSync(freshDir, { recursive: true }); + const currentVersion = JSON.parse(readFileSync('package.json', 'utf8')).version; + writeFileSync(join(freshDir, 'VERSION'), currentVersion); + + const result = await new Promise((resolve) => { + const child = spawn('node', ['bin.js', 'serve'], { + env: { ...process.env, BF_EXT_DIR: freshDir }, + }); + let stderr = ''; + // Give it 1.5s to produce any warning, then declare "no warning" + setTimeout(() => { + child.kill('SIGKILL'); + resolve(stderr); + }, 1500); + child.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + }); + + assert.ok(!result.includes('outdated'), `Unexpected warning: ${result}`); + + rmSync(freshDir, { recursive: true, force: true }); + }); }); From 67ddaade1ee23dfc64ad9e30c1b557dab22cd32c Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:31:12 +0530 Subject: [PATCH 013/192] docs: update setup instructions for npm install-extension flow --- README.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c6efe63..ec89a30 100644 --- a/README.md +++ b/README.md @@ -55,10 +55,22 @@ pnpm install ### 2. Load the Chrome extension +**If you installed via npm:** + +1. Run: `browserforce install-extension` +2. Open `chrome://extensions/` in Chrome +3. Enable **Developer mode** (top-right toggle) +4. Click **Load unpacked** → select the path printed in step 1 + +❗ After every BrowserForce update, re-run `browserforce install-extension`, then reload the extension in `chrome://extensions/` (click the ↺ icon next to BrowserForce). + +**If you cloned the repo:** + 1. Open `chrome://extensions/` in Chrome 2. Enable **Developer mode** (top-right toggle) 3. Click **Load unpacked** → select the `extension/` folder -4. Extension icon appears in your toolbar (gray = disconnected) + +After loading, the extension icon appears in your toolbar (gray = disconnected). ### 3. Done @@ -79,7 +91,7 @@ Most OpenClaw users chat with their agent from Telegram or WhatsApp. BrowserForc **Quick setup** (copy-paste into your terminal): ```bash -npm install -g browserforce && npx -y skills add ivalsaraj/browserforce +npm install -g browserforce && browserforce install-extension && npx -y skills add ivalsaraj/browserforce ``` Then start the relay (keep this running): From 5b7cca9c625f288f6693178a7c08e4c17f1a9efd Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:32:36 +0530 Subject: [PATCH 014/192] chore: bump version to 1.0.12 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 850577c..51c77d4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browserforce", - "version": "1.0.11", + "version": "1.0.12", "type": "module", "description": "Give AI agents your real Chrome browser with progressive examples: simple reads, form interactions, multi-tab workflows, and state persistence. Search X and GitHub, extract ProductHunt data, test forms, compare A/B variants, monitor status pages. Works with OpenClaw, Claude, and any MCP agent.", "homepage": "https://github.com/ivalsaraj/browserforce", From 93ccfefcec0f114afdd0bee07f908755cc20594e Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Mon, 23 Feb 2026 23:39:32 +0530 Subject: [PATCH 015/192] =?UTF-8?q?fix:=20review=20findings=20=E2=80=94=20?= =?UTF-8?q?stale-file=20purge,=20src=20guard,=20test=20port=20isolation,?= =?UTF-8?q?=20update=20error=20separation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- README.md | 2 ++ bin.js | 15 ++++++++++----- test/cli.test.js | 4 ++-- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ec89a30..e071c6b 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,8 @@ browserforce -e "" # Run Playwright JavaScript (one-shot) browserforce plugin list # List installed plugins browserforce plugin install # Install a plugin from the registry browserforce plugin remove # Remove an installed plugin +browserforce update # Update to the latest version +browserforce install-extension # Copy extension to ~/.browserforce/extension/ ``` Each `-e` command is one-shot — state does not persist between calls. For persistent state, use the MCP server. diff --git a/bin.js b/bin.js index 94d723a..dfdabfc 100644 --- a/bin.js +++ b/bin.js @@ -349,19 +349,19 @@ async function cmdUpdate() { const { join: pathJoin } = await import('node:path'); const { homedir: osHomedir } = await import('node:os'); const extDir = process.env.BF_EXT_DIR || pathJoin(osHomedir(), '.browserforce', 'extension'); - try { - readFs(pathJoin(extDir, 'VERSION'), 'utf8'); // existence check + let hasVersion = false; + try { readFs(pathJoin(extDir, 'VERSION'), 'utf8'); hasVersion = true; } catch { /* not installed */ } + if (hasVersion) { const { dest } = await doInstallExtension(true); console.log(`Extension updated in ${dest}`); console.log('❗ Reload the extension in chrome://extensions/ (click the ↺ icon).'); - } catch { - // No VERSION file — user hasn't run install-extension yet + } else { console.log('Tip: run browserforce install-extension to set up the Chrome extension.'); } } async function doInstallExtension(quiet) { - const { cpSync, mkdirSync, writeFileSync, readFileSync } = await import('node:fs'); + const { cpSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } = await import('node:fs'); const { join, dirname } = await import('node:path'); const { homedir } = await import('node:os'); @@ -369,6 +369,11 @@ async function doInstallExtension(quiet) { const src = join(pkgDir, 'extension'); const dest = process.env.BF_EXT_DIR || join(homedir(), '.browserforce', 'extension'); + if (!existsSync(src)) { + throw new Error(`Extension source not found at ${src}.\nIs browserforce installed via npm? Try: npm install -g browserforce`); + } + + rmSync(dest, { recursive: true, force: true }); mkdirSync(dest, { recursive: true }); cpSync(src, dest, { recursive: true }); diff --git a/test/cli.test.js b/test/cli.test.js index fdddfc5..e6c2ed9 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -260,7 +260,7 @@ describe('CLI install-extension', () => { const warning = await new Promise((resolve, reject) => { const child = spawn('node', ['bin.js', 'serve'], { - env: { ...process.env, BF_EXT_DIR: staleDir }, + env: { ...process.env, BF_EXT_DIR: staleDir, RELAY_PORT: String(getRandomPort()) }, }); let stderr = ''; const timer = setTimeout(() => { @@ -294,7 +294,7 @@ describe('CLI install-extension', () => { const result = await new Promise((resolve) => { const child = spawn('node', ['bin.js', 'serve'], { - env: { ...process.env, BF_EXT_DIR: freshDir }, + env: { ...process.env, BF_EXT_DIR: freshDir, RELAY_PORT: String(getRandomPort()) }, }); let stderr = ''; // Give it 1.5s to produce any warning, then declare "no warning" From bef3093086a371b7ec54a086a6b36e94f072dcec Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 15:53:39 +0530 Subject: [PATCH 016/192] feat: implement extension reload functionality via HTTP endpoint - Added a new endpoint to the relay server for reloading the extension, which acknowledges the reload request and waits for an acknowledgment from the extension before confirming the reload status. - Updated the installation command to attempt reloading the extension after installation, providing feedback based on whether the reload was successful. - Enhanced the background script to handle reload messages from the relay server. - Added tests for the new reload endpoint to ensure proper authentication and response handling. This feature improves the user experience by automating the extension reload process after updates. --- bin.js | 41 ++++++++++-- extension/background.js | 8 +++ relay/src/index.js | 37 +++++++++++ relay/test/relay-server.test.js | 107 ++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 5 deletions(-) diff --git a/bin.js b/bin.js index dfdabfc..3a32ced 100644 --- a/bin.js +++ b/bin.js @@ -352,14 +352,39 @@ async function cmdUpdate() { let hasVersion = false; try { readFs(pathJoin(extDir, 'VERSION'), 'utf8'); hasVersion = true; } catch { /* not installed */ } if (hasVersion) { - const { dest } = await doInstallExtension(true); + const { dest, reloaded } = await doInstallExtension(true); console.log(`Extension updated in ${dest}`); - console.log('❗ Reload the extension in chrome://extensions/ (click the ↺ icon).'); + if (reloaded) { + console.log(' Reloading extension... ✓'); + } else { + console.log('❗ Reload the extension in chrome://extensions/ (click the ↺ icon).'); + } } else { console.log('Tip: run browserforce install-extension to set up the Chrome extension.'); } } +async function attemptExtensionReload() { + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { homedir } = await import('node:os'); + const tokenFile = join(homedir(), '.browserforce', 'auth-token'); + let authToken = ''; + try { authToken = readFileSync(tokenFile, 'utf8').trim(); } catch { return false; } + if (!authToken) return false; + + const { getRelayHttpUrl } = await import('./mcp/src/exec-engine.js'); + let baseUrl; + try { baseUrl = getRelayHttpUrl(); } catch { baseUrl = 'http://127.0.0.1:19222'; } + + try { + const { status, body } = await httpFetch('POST', `${baseUrl}/extension/reload`, {}, authToken); + return status === 200 && body?.reloaded === true; + } catch { + return false; // relay not running + } +} + async function doInstallExtension(quiet) { const { cpSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } = await import('node:fs'); const { join, dirname } = await import('node:path'); @@ -381,6 +406,8 @@ async function doInstallExtension(quiet) { const pkgVersion = JSON.parse(readFileSync(join(pkgDir, 'package.json'), 'utf8')).version; writeFileSync(join(dest, 'VERSION'), pkgVersion); + const reloaded = await attemptExtensionReload(); + if (!quiet) { console.log(`Extension installed to: ${dest}`); console.log(''); @@ -390,11 +417,15 @@ async function doInstallExtension(quiet) { console.log(' 3. Click "Load unpacked" → select:'); console.log(` ${dest}`); console.log(''); - console.log('❗ After any BrowserForce update, re-run: browserforce install-extension'); - console.log(' Then reload the extension in chrome://extensions/ (click the ↺ icon).'); + if (reloaded) { + console.log(' Reloading extension... ✓'); + } else { + console.log('❗ After any BrowserForce update, re-run: browserforce install-extension'); + console.log(' Then reload the extension in chrome://extensions/ (click the ↺ icon).'); + } } - return { dest, pkgVersion }; + return { dest, pkgVersion, reloaded }; } async function cmdInstallExtension() { diff --git a/extension/background.js b/extension/background.js index e32bf9c..efc8447 100644 --- a/extension/background.js +++ b/extension/background.js @@ -146,6 +146,14 @@ function handleRelayMessage(msg) { return; } + if (msg.method === 'reload') { + // Ack before restarting so relay knows the message was received + send({ method: 'reload-ack' }); + // Yield so the send flushes before the service worker restarts + setTimeout(() => chrome.runtime.reload(), 0); + return; + } + // Command from relay (has id) if (msg.id !== undefined) { executeCommand(msg) diff --git a/relay/src/index.js b/relay/src/index.js index 0fca042..5459f5e 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -148,6 +148,9 @@ class RelayServer { // State this.autoAttachEnabled = false; this.autoAttachParams = null; + + // Pending extension reload ack resolver (at most one at a time) + this._extReloadResolve = null; } start({ writeCdpUrl = true } = {}) { @@ -295,6 +298,35 @@ class RelayServer { return; } + if (url.pathname === '/extension/reload' && req.method === 'POST') { + if (!this._requireAuth(req, res)) return; + if (!this.ext || this.ext.ws.readyState !== WebSocket.OPEN) { + res.end(JSON.stringify({ reloaded: false, reason: 'not connected' })); + return; + } + // Await ack with 2.5s timeout; extension sends 'reload-ack' before restarting + const reloaded = await new Promise((resolve) => { + const timer = setTimeout(() => { + this._extReloadResolve = null; + resolve(false); + }, 2500); + this._extReloadResolve = () => { + clearTimeout(timer); + this._extReloadResolve = null; + resolve(true); + }; + try { + this.ext.ws.send(JSON.stringify({ method: 'reload' })); + } catch { + clearTimeout(timer); + this._extReloadResolve = null; + resolve(false); + } + }); + res.end(JSON.stringify({ reloaded })); + return; + } + res.statusCode = 404; res.end(JSON.stringify({ error: 'Not found' })); } @@ -439,6 +471,11 @@ class RelayServer { // Events from extension if (msg.method === 'pong') return; + if (msg.method === 'reload-ack') { + if (this._extReloadResolve) this._extReloadResolve(); + return; + } + if (msg.method === 'cdpEvent') { this._handleCdpEventFromExt(msg.params); return; diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index dc4ad66..593bc70 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -240,6 +240,113 @@ describe('Plugin API Endpoints', () => { }); }); +// ─── Extension Reload Endpoint ─────────────────────────────────────────────── + +describe('Extension Reload Endpoint', () => { + let relay, port; + + function httpRequest(method, url, body, headers = {}) { + return new Promise((resolve, reject) => { + const opts = new URL(url); + const payload = body ? JSON.stringify(body) : undefined; + const req = http.request({ + hostname: opts.hostname, port: opts.port, + path: opts.pathname, method, + headers: { + 'Content-Type': 'application/json', + ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + ...headers, + }, + }, (res) => { + let data = ''; + res.on('data', c => { data += c; }); + res.on('end', () => { + try { resolve({ status: res.statusCode, body: JSON.parse(data) }); } + catch { resolve({ status: res.statusCode, body: data }); } + }); + }); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); + } + + before(async () => { + port = getRandomPort(); + relay = new RelayServer(port); + relay.start({ writeCdpUrl: false }); + await sleep(200); + }); + + after(() => relay.stop()); + + it('POST /extension/reload without token returns 401', async () => { + const { status, body } = await httpRequest('POST', `http://127.0.0.1:${port}/extension/reload`, {}); + assert.equal(status, 401); + assert.ok(body.error.includes('Unauthorized')); + }); + + it('POST /extension/reload with invalid token returns 401', async () => { + const { status, body } = await httpRequest('POST', `http://127.0.0.1:${port}/extension/reload`, {}, { + Authorization: 'Bearer bad-token', + }); + assert.equal(status, 401); + assert.ok(body.error.includes('Unauthorized')); + }); + + it('POST /extension/reload with valid token but no extension returns { reloaded: false }', async () => { + const { status, body } = await httpRequest('POST', `http://127.0.0.1:${port}/extension/reload`, {}, { + Authorization: `Bearer ${relay.authToken}`, + }); + assert.equal(status, 200); + assert.equal(body.reloaded, false); + }); + + it('POST /extension/reload with extension connected and ack returns { reloaded: true }', async () => { + // Connect a mock extension that sends reload-ack + const extWs = await connectWs(`ws://127.0.0.1:${port}/extension`, { + headers: { Origin: 'chrome-extension://test' }, + }); + + extWs.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.method === 'reload') { + extWs.send(JSON.stringify({ method: 'reload-ack' })); + } + }); + + const { status, body } = await httpRequest('POST', `http://127.0.0.1:${port}/extension/reload`, {}, { + Authorization: `Bearer ${relay.authToken}`, + }); + + extWs.close(); + assert.equal(status, 200); + assert.equal(body.reloaded, true); + }); + + it('POST /extension/reload with extension connected but no ack times out to { reloaded: false }', async () => { + // Re-start relay to get a fresh extension slot (previous test's close may not have fully cleaned up) + relay.stop(); + await sleep(100); + relay = new RelayServer(port); + relay.start({ writeCdpUrl: false }); + await sleep(200); + + // Connect a mock extension that does NOT send reload-ack + const extWs = await connectWs(`ws://127.0.0.1:${port}/extension`, { + headers: { Origin: 'chrome-extension://test' }, + }); + + const { status, body } = await httpRequest('POST', `http://127.0.0.1:${port}/extension/reload`, {}, { + Authorization: `Bearer ${relay.authToken}`, + }); + + extWs.close(); + assert.equal(status, 200); + assert.equal(body.reloaded, false); + }); +}); + // ─── WebSocket Security ────────────────────────────────────────────────────── describe('WebSocket Security', () => { From 5884b6db042df837857a0c189745f823cf643b24 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 16:39:49 +0530 Subject: [PATCH 017/192] docs: align tool references with execute-only helper model --- GUIDE.md | 5 ++--- docs/PLUGINS.md | 3 +-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 4b5b2f9..966a2d2 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -212,12 +212,11 @@ browserforce navigate https://gmail.com ## MCP Tools Reference -When connected via MCP (OpenClaw, Claude Desktop, Claude Code), the AI has three tools: +When connected via MCP (OpenClaw, Claude Desktop, Claude Code), the AI has two tools: | Tool | What it does | |------|-------------| -| `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, and Node.js globals. | -| `screenshot_with_labels` | Take a screenshot with Vimium-style accessibility labels overlaid on interactive elements. | +| `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, `screenshotWithAccessibilityLabels()`, `cleanHTML()`, `pageMarkdown()`, and Node.js globals. | | `reset` | Reconnect to the relay and clear state. Use when the connection drops. | The `execute` tool gives the agent full Playwright access — it can navigate, click, type, screenshot, read accessibility trees, and run JavaScript in the page context. All within your real browser session. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 2720475..0a0c362 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -252,7 +252,7 @@ export default { } }, - // Standalone MCP tools registered alongside execute/reset/screenshot_with_labels. + // Standalone MCP tools registered alongside execute/reset. // Agents can call these directly by name. tools: [{ name: 'my_tool', @@ -443,4 +443,3 @@ The relay install endpoint only fetches from the known GitHub repo URL — no ar No sandboxing beyond that. Plugins are as trusted as any npm package you install. --- - From 477f8e372d766023041f4ea80caa6af0913fe8d1 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 16:40:03 +0530 Subject: [PATCH 018/192] feat(mcp): consolidate screenshot and content helpers into execute --- README.md | 3 +- mcp/src/clean-html.js | 166 +++ mcp/src/exec-engine.js | 23 + mcp/src/index.js | 92 +- mcp/src/page-markdown.js | 114 ++ mcp/src/vendor/readability.bundle.js | 2064 ++++++++++++++++++++++++++ mcp/test/exec-engine-plugins.test.js | 29 +- mcp/test/mcp-tools.test.js | 34 +- 8 files changed, 2420 insertions(+), 105 deletions(-) create mode 100644 mcp/src/clean-html.js create mode 100644 mcp/src/page-markdown.js create mode 100644 mcp/src/vendor/readability.bundle.js diff --git a/README.md b/README.md index e071c6b..74a8a59 100644 --- a/README.md +++ b/README.md @@ -303,8 +303,7 @@ state.results = await page.evaluate(() => document.title); | Tool | Description | |------|-------------| -| `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, and Node.js globals. | -| `screenshot_with_labels` | Take a screenshot with Vimium-style accessibility labels overlaid on interactive elements. | +| `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, `screenshotWithAccessibilityLabels()`, `cleanHTML()`, `pageMarkdown()`, and Node.js globals. | | `reset` | Reconnect to the relay and clear state. Use when the connection drops. | ## Examples diff --git a/mcp/src/clean-html.js b/mcp/src/clean-html.js new file mode 100644 index 0000000..c47261b --- /dev/null +++ b/mcp/src/clean-html.js @@ -0,0 +1,166 @@ +// Clean HTML extraction — runs entirely in the browser via page.evaluate(). +// Strips scripts, styles, decorative elements; keeps semantic attributes. + +/** + * Extracts cleaned HTML from a Playwright page or locator. + * All processing happens in-page via DOM manipulation — no server-side parsing deps. + * + * @param {import('playwright-core').Page} page + * @param {string} [selector] - CSS selector to scope extraction (default: document) + * @param {{ maxAttrLen?: number, maxContentLen?: number }} [opts] + * @returns {Promise} + */ +export async function getCleanHTML(page, selector, opts = {}) { + const maxAttrLen = opts.maxAttrLen ?? 200; + const maxContentLen = opts.maxContentLen ?? 500; + + const html = await page.evaluate(({ selector, maxAttrLen, maxContentLen }) => { + const TAGS_TO_REMOVE = new Set([ + 'script', 'style', 'link', 'meta', 'noscript', + 'svg', 'head', 'iframe', 'object', 'embed', + ]); + + const ATTRS_TO_KEEP = new Set([ + 'href', 'src', 'alt', 'title', 'name', 'value', 'checked', + 'placeholder', 'type', 'role', 'target', 'label', 'for', + 'aria-label', 'aria-placeholder', 'aria-valuetext', + 'aria-roledescription', 'aria-hidden', 'aria-expanded', + 'aria-checked', 'aria-selected', 'aria-disabled', + 'aria-pressed', 'aria-required', 'aria-current', + 'data-testid', 'data-test', 'data-cy', 'data-qa', + ]); + + const SEMANTIC_TAGS = new Set([ + 'html', 'body', 'main', 'header', 'footer', + 'nav', 'section', 'article', 'aside', + ]); + + const FORM_TAGS = new Set(['input', 'select', 'textarea', 'button']); + + function truncate(str, max) { + if (str.length <= max) return str; + return str.slice(0, max) + '...[' + (str.length - max) + ' more]'; + } + + function shouldKeepAttr(name) { + if (ATTRS_TO_KEEP.has(name)) return true; + if (name.startsWith('aria-')) return true; + if (name.startsWith('data-test') || name.startsWith('data-cy') || name.startsWith('data-qa')) return true; + return false; + } + + function hasUsefulContent(el) { + if (el.nodeType === Node.TEXT_NODE) { + return el.textContent.trim().length > 0; + } + if (el.nodeType !== Node.ELEMENT_NODE) return false; + + const tag = el.tagName.toLowerCase(); + if (FORM_TAGS.has(tag)) return true; + if (tag === 'img' && el.getAttribute('alt')?.trim()) return true; + if (tag === 'a' && el.getAttribute('href')) return true; + + for (const child of el.childNodes) { + if (hasUsefulContent(child)) return true; + } + return false; + } + + function cleanNode(el) { + if (el.nodeType === Node.COMMENT_NODE) { + el.remove(); + return; + } + + if (el.nodeType === Node.TEXT_NODE) { + if (el.textContent.trim().length === 0) return; + el.textContent = truncate(el.textContent, maxContentLen); + return; + } + + if (el.nodeType !== Node.ELEMENT_NODE) return; + + const tag = el.tagName.toLowerCase(); + + if (TAGS_TO_REMOVE.has(tag)) { + el.remove(); + return; + } + + if (el.getAttribute('aria-hidden') === 'true') { + el.remove(); + return; + } + + // Strip non-semantic attributes + const attrsToRemove = []; + for (const attr of el.attributes) { + if (!shouldKeepAttr(attr.name)) { + attrsToRemove.push(attr.name); + } + } + for (const name of attrsToRemove) { + el.removeAttribute(name); + } + + // Truncate long attribute values + for (const attr of el.attributes) { + if (attr.value.length > maxAttrLen) { + el.setAttribute(attr.name, truncate(attr.value, maxAttrLen)); + } + } + + // Recurse children (iterate in reverse since we may remove) + const children = Array.from(el.childNodes); + for (const child of children) { + cleanNode(child); + } + + // After cleaning children: remove decorative elements (no text, no form elements) + if (!SEMANTIC_TAGS.has(tag) && !FORM_TAGS.has(tag) && !hasUsefulContent(el)) { + el.remove(); + return; + } + + // Unwrap unnecessary wrappers: single-child divs/spans with no attributes + if (el.attributes.length === 0 && el.children.length === 1 && el.childNodes.length === 1) { + const onlyChild = el.children[0]; + if (onlyChild && onlyChild.nodeType === Node.ELEMENT_NODE) { + el.replaceWith(onlyChild); + } + } + } + + // Determine root to clean + let root; + if (selector) { + const target = document.querySelector(selector); + if (!target) return ''; + root = target.cloneNode(true); + } else { + root = document.documentElement.cloneNode(true); + } + + cleanNode(root); + + // Remove empty elements in multiple passes + let changed = true; + while (changed) { + changed = false; + for (const el of root.querySelectorAll('*')) { + if ( + el.attributes.length === 0 && + el.childNodes.length === 0 && + !FORM_TAGS.has(el.tagName.toLowerCase()) + ) { + el.remove(); + changed = true; + } + } + } + + return root.outerHTML || root.innerHTML || ''; + }, { selector: selector || null, maxAttrLen, maxContentLen }); + + return html; +} diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index e59f0b7..96ca77c 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -10,6 +10,9 @@ import { TEST_ID_ATTRS, buildSnapshotText, parseSearchPattern, annotateStableAttrs, } from './snapshot.js'; +import { screenshotWithLabels } from './a11y-labels.js'; +import { getCleanHTML } from './clean-html.js'; +import { getPageMarkdown } from './page-markdown.js'; // ─── Configuration ─────────────────────────────────────────────────────────── @@ -444,6 +447,19 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { if (consoleLogs) consoleLogs.set(activePage(), []); }; + const screenshotWithAccessibilityLabels = async ({ selector, interactiveOnly = true } = {}) => { + const page = activePage(); + const { screenshot, snapshot: snapText, labelCount } = await screenshotWithLabels(page, { + selector, + interactiveOnly, + }); + return { _bf_type: 'labeled_screenshot', screenshot, snapshot: snapText, labelCount }; + }; + + const cleanHTML = (selector, opts) => getCleanHTML(activePage(), selector, opts); + + const pageMarkdown = () => getPageMarkdown(activePage()); + // Wrap plugin helpers to auto-inject (page, ctx, state) as first three args const wrappedPluginHelpers = {}; for (const [name, fn] of Object.entries(pluginHelpers)) { @@ -458,6 +474,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win page: defaultPage, context: ctx, state: userState, snapshot, waitForPageLoad, getLogs, clearLogs, + screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, }; @@ -481,6 +498,12 @@ export function formatResult(result) { if (result === undefined || result === null) { return { type: 'text', text: String(result) }; } + if (result && typeof result === 'object' && result._bf_type === 'labeled_screenshot') { + return [ + { type: 'image', data: result.screenshot.toString('base64'), mimeType: 'image/jpeg' }, + { type: 'text', text: `Labels: ${result.labelCount} interactive elements\n\n${result.snapshot}` }, + ]; + } if (Buffer.isBuffer(result)) { return { type: 'image', data: result.toString('base64'), mimeType: 'image/png' }; } diff --git a/mcp/src/index.js b/mcp/src/index.js index 0f61800..51edc83 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -1,5 +1,5 @@ // BrowserForce — MCP Server -// 3-tool architecture: execute (run Playwright code) + reset (reconnect) + screenshot_with_labels (visual a11y labels) +// 2-tool architecture: execute (run Playwright code) + reset (reconnect) // Connects to the relay via Playwright's CDP client. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -9,7 +9,6 @@ import { chromium } from 'playwright-core'; import { getCdpUrl, ensureRelay, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, } from './exec-engine.js'; -import { screenshotWithLabels } from './a11y-labels.js'; import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js'; import { checkForUpdate } from './update-check.js'; @@ -138,6 +137,16 @@ Helpers: waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState). getLogs({ count? }) Browser console logs captured for current page. clearLogs() Clear captured console logs. + screenshotWithAccessibilityLabels({ selector?, interactiveOnly? }) + Vimium-style labeled screenshot + accessibility snapshot. + Returns image with color-coded element labels (e1, e2...) and + matching text snapshot. Use when visual layout matters. + cleanHTML(selector?, opts?) Cleaned HTML — strips scripts, styles, decorative elements. + Keeps semantic attrs: href, src, role, aria-*, data-testid. + opts: { maxAttrLen?, maxContentLen? } + pageMarkdown() Article content via Mozilla Readability (Firefox Reader View). + Strips nav/ads/sidebars. Returns title + metadata + body text. + Falls back to raw body text for non-article pages. Globals: fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder @@ -309,7 +318,7 @@ function registerExecuteTool(skillAppendix = '') { 'execute', EXECUTE_PROMPT + skillAppendix, { - code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs in scope'), + code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'), }, async ({ code, timeout = 30000 }) => { @@ -326,9 +335,9 @@ function registerExecuteTool(skillAppendix = '') { try { const result = await runCode(code, execCtx, timeout); const formatted = formatResult(result); - const content = [formatted]; + const content = Array.isArray(formatted) ? [...formatted] : [formatted]; // Append update notice as a separate content item (once only per session) - if (pendingUpdate && !updateNoticeSent && formatted.type === 'text') { + if (pendingUpdate && !updateNoticeSent && content[0]?.type === 'text') { updateNoticeSent = true; content.push({ type: 'text', text: `[BrowserForce update available: ${pendingUpdate.current} → ${pendingUpdate.latest}]\n[Run: browserforce update or: npm install -g browserforce]` }); } @@ -373,79 +382,6 @@ server.tool( } ); -// ─── Screenshot with Labels Tool ────────────────────────────────────────────── - -const SCREENSHOT_LABELS_PROMPT = `Take a screenshot with Vimium-style accessibility labels on interactive elements. - -Returns TWO content items: -1. JPEG screenshot with color-coded labels (e1, e2, e3...) on buttons, links, inputs, etc. -2. Text accessibility snapshot with matching refs and role/name locators - -Labels are color-coded by role: -- Yellow: links -- Orange: buttons, menu items, tabs -- Red/pink: text inputs, search boxes -- Green: checkboxes, radio buttons -- Blue: sliders, spinbuttons, media -- Purple: switches - -Use this tool when: -- You need to understand the visual layout of a page -- Text snapshot alone can't convey spatial relationships -- You need to verify element positions (dashboards, grids, maps) -- You need both visual context AND element refs for interaction - -After getting the screenshot, use the refs to interact via the execute tool: - await state.page.locator('role=button[name="Submit"]').click(); - -Parameters: -- selector: CSS selector to scope labels to part of the page (e.g., '#main', '.sidebar'). Main frame only. -- interactiveOnly: Only label interactive elements like buttons/links/inputs (default: true) - -Limitations: -- Main frame only — does not label elements inside cross-origin iframes -- Locators are role/name based — no data-testid matching`; - -server.tool( - 'screenshot_with_labels', - SCREENSHOT_LABELS_PROMPT, - { - selector: z.string().optional().describe('CSS selector to scope labels to a subtree of the main frame'), - interactiveOnly: z.boolean().optional().describe('Only label interactive elements (default: true)'), - }, - async ({ selector, interactiveOnly = true }) => { - await ensureBrowser(); - const ctx = getContext(); - const page = (userState.page && !userState.page.isClosed()) - ? userState.page - : ctx.pages()[0] || null; - if (!page) { - return { - content: [{ type: 'text', text: 'Error: No pages available. Open a tab first.' }], - isError: true, - }; - } - - try { - const { screenshot, snapshot, labelCount } = await screenshotWithLabels(page, { - selector, - interactiveOnly, - }); - return { - content: [ - { type: 'image', data: screenshot.toString('base64'), mimeType: 'image/jpeg' }, - { type: 'text', text: `Labels: ${labelCount} interactive elements\n\n${snapshot}` }, - ], - }; - } catch (err) { - return { - content: [{ type: 'text', text: `Error: ${err.message}` }], - isError: true, - }; - } - } -); - // ─── Plugin Init ───────────────────────────────────────────────────────────── async function initPlugins() { diff --git a/mcp/src/page-markdown.js b/mcp/src/page-markdown.js new file mode 100644 index 0000000..cd2ece9 --- /dev/null +++ b/mcp/src/page-markdown.js @@ -0,0 +1,114 @@ +// Page markdown extraction — uses Mozilla Readability (Firefox Reader View algorithm). +// Injects a pre-bundled Readability IIFE into the page, then extracts article content. + +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let readabilityCode = null; + +function getReadabilityCode() { + if (readabilityCode) return readabilityCode; + const bundlePath = join(__dirname, 'vendor', 'readability.bundle.js'); + readabilityCode = readFileSync(bundlePath, 'utf-8'); + return readabilityCode; +} + +/** + * Extracts page content as structured markdown using Mozilla Readability. + * Strips nav, ads, sidebars — returns article body with metadata. + * + * @param {import('playwright-core').Page} page + * @returns {Promise} + */ +export async function getPageMarkdown(page) { + // Inject Readability if not already present + const hasReadability = await page.evaluate(() => !!globalThis.__readability); + if (!hasReadability) { + await page.evaluate(getReadabilityCode()); + } + + const result = await page.evaluate(() => { + const { Readability, isProbablyReaderable } = globalThis.__readability; + + const documentClone = document.cloneNode(true); + + if (!isProbablyReaderable(documentClone)) { + return { + content: document.body?.innerText || '', + title: document.title || null, + author: null, + excerpt: null, + siteName: null, + lang: document.documentElement?.lang || null, + publishedTime: null, + wordCount: (document.body?.innerText || '').split(/\s+/).filter(Boolean).length, + readable: false, + }; + } + + const article = new Readability(documentClone).parse(); + + if (!article) { + return { + content: document.body?.innerText || '', + title: document.title || null, + author: null, + excerpt: null, + siteName: null, + lang: document.documentElement?.lang || null, + publishedTime: null, + wordCount: (document.body?.innerText || '').split(/\s+/).filter(Boolean).length, + readable: false, + }; + } + + return { + content: article.textContent || '', + title: article.title || null, + author: article.byline || null, + excerpt: article.excerpt || null, + siteName: article.siteName || null, + lang: article.lang || null, + publishedTime: article.publishedTime || null, + wordCount: (article.textContent || '').split(/\s+/).filter(Boolean).length, + readable: true, + }; + }); + + // Format output as structured markdown + const lines = []; + + if (result.title) { + lines.push(`# ${result.title}`, ''); + } + + const metadata = []; + if (result.author) metadata.push(`Author: ${result.author}`); + if (result.siteName) metadata.push(`Site: ${result.siteName}`); + if (result.publishedTime) metadata.push(`Published: ${result.publishedTime}`); + if (metadata.length > 0) { + lines.push(metadata.join(' | '), ''); + } + + if (result.excerpt && result.content && result.excerpt !== result.content.slice(0, result.excerpt.length)) { + lines.push(`> ${result.excerpt}`, ''); + } + + lines.push(result.content); + + if (!result.readable) { + lines.push('', '---', '_Note: Page was not recognized as an article. Returned raw body text._'); + } + + let markdown = lines.join('\n').trim(); + + // Sanitize unpaired surrogates that break JSON encoding + if (typeof markdown.toWellFormed === 'function') { + markdown = markdown.toWellFormed(); + } + + return markdown; +} diff --git a/mcp/src/vendor/readability.bundle.js b/mcp/src/vendor/readability.bundle.js new file mode 100644 index 0000000..0288cf9 --- /dev/null +++ b/mcp/src/vendor/readability.bundle.js @@ -0,0 +1,2064 @@ +// Auto-generated by scripts/bundle-readability.js — do not edit +(() => { + var __getOwnPropNames = Object.getOwnPropertyNames; + var __commonJS = (cb, mod) => function __require() { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + }; + + // ../node_modules/.pnpm/@mozilla+readability@0.6.0/node_modules/@mozilla/readability/Readability.js + var require_Readability = __commonJS({ + "../node_modules/.pnpm/@mozilla+readability@0.6.0/node_modules/@mozilla/readability/Readability.js"(exports, module) { + function Readability(doc, options) { + if (options && options.documentElement) { + doc = options; + options = arguments[2]; + } else if (!doc || !doc.documentElement) { + throw new Error( + "First argument to Readability constructor should be a document object." + ); + } + options = options || {}; + this._doc = doc; + this._docJSDOMParser = this._doc.firstChild.__JSDOMParser__; + this._articleTitle = null; + this._articleByline = null; + this._articleDir = null; + this._articleSiteName = null; + this._attempts = []; + this._metadata = {}; + this._debug = !!options.debug; + this._maxElemsToParse = options.maxElemsToParse || this.DEFAULT_MAX_ELEMS_TO_PARSE; + this._nbTopCandidates = options.nbTopCandidates || this.DEFAULT_N_TOP_CANDIDATES; + this._charThreshold = options.charThreshold || this.DEFAULT_CHAR_THRESHOLD; + this._classesToPreserve = this.CLASSES_TO_PRESERVE.concat( + options.classesToPreserve || [] + ); + this._keepClasses = !!options.keepClasses; + this._serializer = options.serializer || function(el) { + return el.innerHTML; + }; + this._disableJSONLD = !!options.disableJSONLD; + this._allowedVideoRegex = options.allowedVideoRegex || this.REGEXPS.videos; + this._linkDensityModifier = options.linkDensityModifier || 0; + this._flags = this.FLAG_STRIP_UNLIKELYS | this.FLAG_WEIGHT_CLASSES | this.FLAG_CLEAN_CONDITIONALLY; + if (this._debug) { + let logNode = function(node) { + if (node.nodeType == node.TEXT_NODE) { + return `${node.nodeName} ("${node.textContent}")`; + } + let attrPairs = Array.from(node.attributes || [], function(attr) { + return `${attr.name}="${attr.value}"`; + }).join(" "); + return `<${node.localName} ${attrPairs}>`; + }; + this.log = function() { + if (typeof console !== "undefined") { + let args = Array.from(arguments, (arg) => { + if (arg && arg.nodeType == this.ELEMENT_NODE) { + return logNode(arg); + } + return arg; + }); + args.unshift("Reader: (Readability)"); + console.log(...args); + } else if (typeof dump !== "undefined") { + var msg = Array.prototype.map.call(arguments, function(x) { + return x && x.nodeName ? logNode(x) : x; + }).join(" "); + dump("Reader: (Readability) " + msg + "\n"); + } + }; + } else { + this.log = function() { + }; + } + } + Readability.prototype = { + FLAG_STRIP_UNLIKELYS: 1, + FLAG_WEIGHT_CLASSES: 2, + FLAG_CLEAN_CONDITIONALLY: 4, + // https://developer.mozilla.org/en-US/docs/Web/API/Node/nodeType + ELEMENT_NODE: 1, + TEXT_NODE: 3, + // Max number of nodes supported by this parser. Default: 0 (no limit) + DEFAULT_MAX_ELEMS_TO_PARSE: 0, + // The number of top candidates to consider when analysing how + // tight the competition is among candidates. + DEFAULT_N_TOP_CANDIDATES: 5, + // Element tags to score by default. + DEFAULT_TAGS_TO_SCORE: "section,h2,h3,h4,h5,h6,p,td,pre".toUpperCase().split(","), + // The default number of chars an article must have in order to return a result + DEFAULT_CHAR_THRESHOLD: 500, + // All of the regular expressions in use within readability. + // Defined up here so we don't instantiate them repeatedly in loops. + REGEXPS: { + // NOTE: These two regular expressions are duplicated in + // Readability-readerable.js. Please keep both copies in sync. + unlikelyCandidates: /-ad-|ai2html|banner|breadcrumbs|combx|comment|community|cover-wrap|disqus|extra|footer|gdpr|header|legends|menu|related|remark|replies|rss|shoutbox|sidebar|skyscraper|social|sponsor|supplemental|ad-break|agegate|pagination|pager|popup|yom-remote/i, + okMaybeItsACandidate: /and|article|body|column|content|main|shadow/i, + positive: /article|body|content|entry|hentry|h-entry|main|page|pagination|post|text|blog|story/i, + negative: /-ad-|hidden|^hid$| hid$| hid |^hid |banner|combx|comment|com-|contact|footer|gdpr|masthead|media|meta|outbrain|promo|related|scroll|share|shoutbox|sidebar|skyscraper|sponsor|shopping|tags|widget/i, + extraneous: /print|archive|comment|discuss|e[\-]?mail|share|reply|all|login|sign|single|utility/i, + byline: /byline|author|dateline|writtenby|p-author/i, + replaceFonts: /<(\/?)font[^>]*>/gi, + normalize: /\s{2,}/g, + videos: /\/\/(www\.)?((dailymotion|youtube|youtube-nocookie|player\.vimeo|v\.qq)\.com|(archive|upload\.wikimedia)\.org|player\.twitch\.tv)/i, + shareElements: /(\b|_)(share|sharedaddy)(\b|_)/i, + nextLink: /(next|weiter|continue|>([^\|]|$)|»([^\|]|$))/i, + prevLink: /(prev|earl|old|new|<|«)/i, + tokenize: /\W+/g, + whitespace: /^\s*$/, + hasContent: /\S$/, + hashUrl: /^#.+/, + srcsetUrl: /(\S+)(\s+[\d.]+[xw])?(\s*(?:,|$))/g, + b64DataUrl: /^data:\s*([^\s;,]+)\s*;\s*base64\s*,/i, + // Commas as used in Latin, Sindhi, Chinese and various other scripts. + // see: https://en.wikipedia.org/wiki/Comma#Comma_variants + commas: /\u002C|\u060C|\uFE50|\uFE10|\uFE11|\u2E41|\u2E34|\u2E32|\uFF0C/g, + // See: https://schema.org/Article + jsonLdArticleTypes: /^Article|AdvertiserContentArticle|NewsArticle|AnalysisNewsArticle|AskPublicNewsArticle|BackgroundNewsArticle|OpinionNewsArticle|ReportageNewsArticle|ReviewNewsArticle|Report|SatiricalArticle|ScholarlyArticle|MedicalScholarlyArticle|SocialMediaPosting|BlogPosting|LiveBlogPosting|DiscussionForumPosting|TechArticle|APIReference$/, + // used to see if a node's content matches words commonly used for ad blocks or loading indicators + adWords: /^(ad(vertising|vertisement)?|pub(licité)?|werb(ung)?|广告|Реклама|Anuncio)$/iu, + loadingWords: /^((loading|正在加载|Загрузка|chargement|cargando)(…|\.\.\.)?)$/iu + }, + UNLIKELY_ROLES: [ + "menu", + "menubar", + "complementary", + "navigation", + "alert", + "alertdialog", + "dialog" + ], + DIV_TO_P_ELEMS: /* @__PURE__ */ new Set([ + "BLOCKQUOTE", + "DL", + "DIV", + "IMG", + "OL", + "P", + "PRE", + "TABLE", + "UL" + ]), + ALTER_TO_DIV_EXCEPTIONS: ["DIV", "ARTICLE", "SECTION", "P", "OL", "UL"], + PRESENTATIONAL_ATTRIBUTES: [ + "align", + "background", + "bgcolor", + "border", + "cellpadding", + "cellspacing", + "frame", + "hspace", + "rules", + "style", + "valign", + "vspace" + ], + DEPRECATED_SIZE_ATTRIBUTE_ELEMS: ["TABLE", "TH", "TD", "HR", "PRE"], + // The commented out elements qualify as phrasing content but tend to be + // removed by readability when put into paragraphs, so we ignore them here. + PHRASING_ELEMS: [ + // "CANVAS", "IFRAME", "SVG", "VIDEO", + "ABBR", + "AUDIO", + "B", + "BDO", + "BR", + "BUTTON", + "CITE", + "CODE", + "DATA", + "DATALIST", + "DFN", + "EM", + "EMBED", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MARK", + "MATH", + "METER", + "NOSCRIPT", + "OBJECT", + "OUTPUT", + "PROGRESS", + "Q", + "RUBY", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "VAR", + "WBR" + ], + // These are the classes that readability sets itself. + CLASSES_TO_PRESERVE: ["page"], + // These are the list of HTML entities that need to be escaped. + HTML_ESCAPE_MAP: { + lt: "<", + gt: ">", + amp: "&", + quot: '"', + apos: "'" + }, + /** + * Run any post-process modifications to article content as necessary. + * + * @param Element + * @return void + **/ + _postProcessContent(articleContent) { + this._fixRelativeUris(articleContent); + this._simplifyNestedElements(articleContent); + if (!this._keepClasses) { + this._cleanClasses(articleContent); + } + }, + /** + * Iterates over a NodeList, calls `filterFn` for each node and removes node + * if function returned `true`. + * + * If function is not passed, removes all the nodes in node list. + * + * @param NodeList nodeList The nodes to operate on + * @param Function filterFn the function to use as a filter + * @return void + */ + _removeNodes(nodeList, filterFn) { + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _removeNodes"); + } + for (var i = nodeList.length - 1; i >= 0; i--) { + var node = nodeList[i]; + var parentNode = node.parentNode; + if (parentNode) { + if (!filterFn || filterFn.call(this, node, i, nodeList)) { + parentNode.removeChild(node); + } + } + } + }, + /** + * Iterates over a NodeList, and calls _setNodeTag for each node. + * + * @param NodeList nodeList The nodes to operate on + * @param String newTagName the new tag name to use + * @return void + */ + _replaceNodeTags(nodeList, newTagName) { + if (this._docJSDOMParser && nodeList._isLiveNodeList) { + throw new Error("Do not pass live node lists to _replaceNodeTags"); + } + for (const node of nodeList) { + this._setNodeTag(node, newTagName); + } + }, + /** + * Iterate over a NodeList, which doesn't natively fully implement the Array + * interface. + * + * For convenience, the current object context is applied to the provided + * iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return void + */ + _forEachNode(nodeList, fn) { + Array.prototype.forEach.call(nodeList, fn, this); + }, + /** + * Iterate over a NodeList, and return the first node that passes + * the supplied test function + * + * For convenience, the current object context is applied to the provided + * test function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The test function. + * @return void + */ + _findNode(nodeList, fn) { + return Array.prototype.find.call(nodeList, fn, this); + }, + /** + * Iterate over a NodeList, return true if any of the provided iterate + * function calls returns true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _someNode(nodeList, fn) { + return Array.prototype.some.call(nodeList, fn, this); + }, + /** + * Iterate over a NodeList, return true if all of the provided iterate + * function calls return true, false otherwise. + * + * For convenience, the current object context is applied to the + * provided iterate function. + * + * @param NodeList nodeList The NodeList. + * @param Function fn The iterate function. + * @return Boolean + */ + _everyNode(nodeList, fn) { + return Array.prototype.every.call(nodeList, fn, this); + }, + _getAllNodesWithTag(node, tagNames) { + if (node.querySelectorAll) { + return node.querySelectorAll(tagNames.join(",")); + } + return [].concat.apply( + [], + tagNames.map(function(tag) { + var collection = node.getElementsByTagName(tag); + return Array.isArray(collection) ? collection : Array.from(collection); + }) + ); + }, + /** + * Removes the class="" attribute from every element in the given + * subtree, except those that match CLASSES_TO_PRESERVE and + * the classesToPreserve array from the options object. + * + * @param Element + * @return void + */ + _cleanClasses(node) { + var classesToPreserve = this._classesToPreserve; + var className = (node.getAttribute("class") || "").split(/\s+/).filter((cls) => classesToPreserve.includes(cls)).join(" "); + if (className) { + node.setAttribute("class", className); + } else { + node.removeAttribute("class"); + } + for (node = node.firstElementChild; node; node = node.nextElementSibling) { + this._cleanClasses(node); + } + }, + /** + * Tests whether a string is a URL or not. + * + * @param {string} str The string to test + * @return {boolean} true if str is a URL, false if not + */ + _isUrl(str) { + try { + new URL(str); + return true; + } catch { + return false; + } + }, + /** + * Converts each and uri in the given element to an absolute URI, + * ignoring #ref URIs. + * + * @param Element + * @return void + */ + _fixRelativeUris(articleContent) { + var baseURI = this._doc.baseURI; + var documentURI = this._doc.documentURI; + function toAbsoluteURI(uri) { + if (baseURI == documentURI && uri.charAt(0) == "#") { + return uri; + } + try { + return new URL(uri, baseURI).href; + } catch (ex) { + } + return uri; + } + var links = this._getAllNodesWithTag(articleContent, ["a"]); + this._forEachNode(links, function(link) { + var href = link.getAttribute("href"); + if (href) { + if (href.indexOf("javascript:") === 0) { + if (link.childNodes.length === 1 && link.childNodes[0].nodeType === this.TEXT_NODE) { + var text = this._doc.createTextNode(link.textContent); + link.parentNode.replaceChild(text, link); + } else { + var container = this._doc.createElement("span"); + while (link.firstChild) { + container.appendChild(link.firstChild); + } + link.parentNode.replaceChild(container, link); + } + } else { + link.setAttribute("href", toAbsoluteURI(href)); + } + } + }); + var medias = this._getAllNodesWithTag(articleContent, [ + "img", + "picture", + "figure", + "video", + "audio", + "source" + ]); + this._forEachNode(medias, function(media) { + var src = media.getAttribute("src"); + var poster = media.getAttribute("poster"); + var srcset = media.getAttribute("srcset"); + if (src) { + media.setAttribute("src", toAbsoluteURI(src)); + } + if (poster) { + media.setAttribute("poster", toAbsoluteURI(poster)); + } + if (srcset) { + var newSrcset = srcset.replace( + this.REGEXPS.srcsetUrl, + function(_, p1, p2, p3) { + return toAbsoluteURI(p1) + (p2 || "") + p3; + } + ); + media.setAttribute("srcset", newSrcset); + } + }); + }, + _simplifyNestedElements(articleContent) { + var node = articleContent; + while (node) { + if (node.parentNode && ["DIV", "SECTION"].includes(node.tagName) && !(node.id && node.id.startsWith("readability"))) { + if (this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } else if (this._hasSingleTagInsideElement(node, "DIV") || this._hasSingleTagInsideElement(node, "SECTION")) { + var child = node.children[0]; + for (var i = 0; i < node.attributes.length; i++) { + child.setAttributeNode(node.attributes[i].cloneNode()); + } + node.parentNode.replaceChild(child, node); + node = child; + continue; + } + } + node = this._getNextNode(node); + } + }, + /** + * Get the article title as an H1. + * + * @return string + **/ + _getArticleTitle() { + var doc = this._doc; + var curTitle = ""; + var origTitle = ""; + try { + curTitle = origTitle = doc.title.trim(); + if (typeof curTitle !== "string") { + curTitle = origTitle = this._getInnerText( + doc.getElementsByTagName("title")[0] + ); + } + } catch (e) { + } + var titleHadHierarchicalSeparators = false; + function wordCount(str) { + return str.split(/\s+/).length; + } + if (/ [\|\-\\\/>»] /.test(curTitle)) { + titleHadHierarchicalSeparators = / [\\\/>»] /.test(curTitle); + let allSeparators = Array.from(origTitle.matchAll(/ [\|\-\\\/>»] /gi)); + curTitle = origTitle.substring(0, allSeparators.pop().index); + if (wordCount(curTitle) < 3) { + curTitle = origTitle.replace(/^[^\|\-\\\/>»]*[\|\-\\\/>»]/gi, ""); + } + } else if (curTitle.includes(": ")) { + var headings = this._getAllNodesWithTag(doc, ["h1", "h2"]); + var trimmedTitle = curTitle.trim(); + var match = this._someNode(headings, function(heading) { + return heading.textContent.trim() === trimmedTitle; + }); + if (!match) { + curTitle = origTitle.substring(origTitle.lastIndexOf(":") + 1); + if (wordCount(curTitle) < 3) { + curTitle = origTitle.substring(origTitle.indexOf(":") + 1); + } else if (wordCount(origTitle.substr(0, origTitle.indexOf(":"))) > 5) { + curTitle = origTitle; + } + } + } else if (curTitle.length > 150 || curTitle.length < 15) { + var hOnes = doc.getElementsByTagName("h1"); + if (hOnes.length === 1) { + curTitle = this._getInnerText(hOnes[0]); + } + } + curTitle = curTitle.trim().replace(this.REGEXPS.normalize, " "); + var curTitleWordCount = wordCount(curTitle); + if (curTitleWordCount <= 4 && (!titleHadHierarchicalSeparators || curTitleWordCount != wordCount(origTitle.replace(/[\|\-\\\/>»]+/g, "")) - 1)) { + curTitle = origTitle; + } + return curTitle; + }, + /** + * Prepare the HTML document for readability to scrape it. + * This includes things like stripping javascript, CSS, and handling terrible markup. + * + * @return void + **/ + _prepDocument() { + var doc = this._doc; + this._removeNodes(this._getAllNodesWithTag(doc, ["style"])); + if (doc.body) { + this._replaceBrs(doc.body); + } + this._replaceNodeTags(this._getAllNodesWithTag(doc, ["font"]), "SPAN"); + }, + /** + * Finds the next node, starting from the given node, and ignoring + * whitespace in between. If the given node is an element, the same node is + * returned. + */ + _nextNode(node) { + var next = node; + while (next && next.nodeType != this.ELEMENT_NODE && this.REGEXPS.whitespace.test(next.textContent)) { + next = next.nextSibling; + } + return next; + }, + /** + * Replaces 2 or more successive
elements with a single

. + * Whitespace between
elements are ignored. For example: + *

foo
bar


abc
+ * will become: + *
foo
bar

abc

+ */ + _replaceBrs(elem) { + this._forEachNode(this._getAllNodesWithTag(elem, ["br"]), function(br) { + var next = br.nextSibling; + var replaced = false; + while ((next = this._nextNode(next)) && next.tagName == "BR") { + replaced = true; + var brSibling = next.nextSibling; + next.remove(); + next = brSibling; + } + if (replaced) { + var p = this._doc.createElement("p"); + br.parentNode.replaceChild(p, br); + next = p.nextSibling; + while (next) { + if (next.tagName == "BR") { + var nextElem = this._nextNode(next.nextSibling); + if (nextElem && nextElem.tagName == "BR") { + break; + } + } + if (!this._isPhrasingContent(next)) { + break; + } + var sibling = next.nextSibling; + p.appendChild(next); + next = sibling; + } + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + if (p.parentNode.tagName === "P") { + this._setNodeTag(p.parentNode, "DIV"); + } + } + }); + }, + _setNodeTag(node, tag) { + this.log("_setNodeTag", node, tag); + if (this._docJSDOMParser) { + node.localName = tag.toLowerCase(); + node.tagName = tag.toUpperCase(); + return node; + } + var replacement = node.ownerDocument.createElement(tag); + while (node.firstChild) { + replacement.appendChild(node.firstChild); + } + node.parentNode.replaceChild(replacement, node); + if (node.readability) { + replacement.readability = node.readability; + } + for (var i = 0; i < node.attributes.length; i++) { + replacement.setAttributeNode(node.attributes[i].cloneNode()); + } + return replacement; + }, + /** + * Prepare the article node for display. Clean out any inline styles, + * iframes, forms, strip extraneous

tags, etc. + * + * @param Element + * @return void + **/ + _prepArticle(articleContent) { + this._cleanStyles(articleContent); + this._markDataTables(articleContent); + this._fixLazyImages(articleContent); + this._cleanConditionally(articleContent, "form"); + this._cleanConditionally(articleContent, "fieldset"); + this._clean(articleContent, "object"); + this._clean(articleContent, "embed"); + this._clean(articleContent, "footer"); + this._clean(articleContent, "link"); + this._clean(articleContent, "aside"); + var shareElementThreshold = this.DEFAULT_CHAR_THRESHOLD; + this._forEachNode(articleContent.children, function(topCandidate) { + this._cleanMatchedNodes(topCandidate, function(node, matchString) { + return this.REGEXPS.shareElements.test(matchString) && node.textContent.length < shareElementThreshold; + }); + }); + this._clean(articleContent, "iframe"); + this._clean(articleContent, "input"); + this._clean(articleContent, "textarea"); + this._clean(articleContent, "select"); + this._clean(articleContent, "button"); + this._cleanHeaders(articleContent); + this._cleanConditionally(articleContent, "table"); + this._cleanConditionally(articleContent, "ul"); + this._cleanConditionally(articleContent, "div"); + this._replaceNodeTags( + this._getAllNodesWithTag(articleContent, ["h1"]), + "h2" + ); + this._removeNodes( + this._getAllNodesWithTag(articleContent, ["p"]), + function(paragraph) { + var contentElementCount = this._getAllNodesWithTag(paragraph, [ + "img", + "embed", + "object", + "iframe" + ]).length; + return contentElementCount === 0 && !this._getInnerText(paragraph, false); + } + ); + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["br"]), + function(br) { + var next = this._nextNode(br.nextSibling); + if (next && next.tagName == "P") { + br.remove(); + } + } + ); + this._forEachNode( + this._getAllNodesWithTag(articleContent, ["table"]), + function(table) { + var tbody = this._hasSingleTagInsideElement(table, "TBODY") ? table.firstElementChild : table; + if (this._hasSingleTagInsideElement(tbody, "TR")) { + var row = tbody.firstElementChild; + if (this._hasSingleTagInsideElement(row, "TD")) { + var cell = row.firstElementChild; + cell = this._setNodeTag( + cell, + this._everyNode(cell.childNodes, this._isPhrasingContent) ? "P" : "DIV" + ); + table.parentNode.replaceChild(cell, table); + } + } + } + ); + }, + /** + * Initialize a node with the readability object. Also checks the + * className/id for special names to add to its score. + * + * @param Element + * @return void + **/ + _initializeNode(node) { + node.readability = { contentScore: 0 }; + switch (node.tagName) { + case "DIV": + node.readability.contentScore += 5; + break; + case "PRE": + case "TD": + case "BLOCKQUOTE": + node.readability.contentScore += 3; + break; + case "ADDRESS": + case "OL": + case "UL": + case "DL": + case "DD": + case "DT": + case "LI": + case "FORM": + node.readability.contentScore -= 3; + break; + case "H1": + case "H2": + case "H3": + case "H4": + case "H5": + case "H6": + case "TH": + node.readability.contentScore -= 5; + break; + } + node.readability.contentScore += this._getClassWeight(node); + }, + _removeAndGetNext(node) { + var nextNode = this._getNextNode(node, true); + node.remove(); + return nextNode; + }, + /** + * Traverse the DOM from node to node, starting at the node passed in. + * Pass true for the second parameter to indicate this node itself + * (and its kids) are going away, and we want the next node over. + * + * Calling this in a loop will traverse the DOM depth-first. + * + * @param {Element} node + * @param {boolean} ignoreSelfAndKids + * @return {Element} + */ + _getNextNode(node, ignoreSelfAndKids) { + if (!ignoreSelfAndKids && node.firstElementChild) { + return node.firstElementChild; + } + if (node.nextElementSibling) { + return node.nextElementSibling; + } + do { + node = node.parentNode; + } while (node && !node.nextElementSibling); + return node && node.nextElementSibling; + }, + // compares second text to first one + // 1 = same text, 0 = completely different text + // works the way that it splits both texts into words and then finds words that are unique in second text + // the result is given by the lower length of unique parts + _textSimilarity(textA, textB) { + var tokensA = textA.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + var tokensB = textB.toLowerCase().split(this.REGEXPS.tokenize).filter(Boolean); + if (!tokensA.length || !tokensB.length) { + return 0; + } + var uniqTokensB = tokensB.filter((token) => !tokensA.includes(token)); + var distanceB = uniqTokensB.join(" ").length / tokensB.join(" ").length; + return 1 - distanceB; + }, + /** + * Checks whether an element node contains a valid byline + * + * @param node {Element} + * @param matchString {string} + * @return boolean + */ + _isValidByline(node, matchString) { + var rel = node.getAttribute("rel"); + var itemprop = node.getAttribute("itemprop"); + var bylineLength = node.textContent.trim().length; + return (rel === "author" || itemprop && itemprop.includes("author") || this.REGEXPS.byline.test(matchString)) && !!bylineLength && bylineLength < 100; + }, + _getNodeAncestors(node, maxDepth) { + maxDepth = maxDepth || 0; + var i = 0, ancestors = []; + while (node.parentNode) { + ancestors.push(node.parentNode); + if (maxDepth && ++i === maxDepth) { + break; + } + node = node.parentNode; + } + return ancestors; + }, + /*** + * grabArticle - Using a variety of metrics (content score, classname, element types), find the content that is + * most likely to be the stuff a user wants to read. Then return it wrapped up in a div. + * + * @param page a document to run upon. Needs to be a full document, complete with body. + * @return Element + **/ + /* eslint-disable-next-line complexity */ + _grabArticle(page) { + this.log("**** grabArticle ****"); + var doc = this._doc; + var isPaging = page !== null; + page = page ? page : this._doc.body; + if (!page) { + this.log("No body found in document. Abort."); + return null; + } + var pageCacheHtml = page.innerHTML; + while (true) { + this.log("Starting grabArticle loop"); + var stripUnlikelyCandidates = this._flagIsActive( + this.FLAG_STRIP_UNLIKELYS + ); + var elementsToScore = []; + var node = this._doc.documentElement; + let shouldRemoveTitleHeader = true; + while (node) { + if (node.tagName === "HTML") { + this._articleLang = node.getAttribute("lang"); + } + var matchString = node.className + " " + node.id; + if (!this._isProbablyVisible(node)) { + this.log("Removing hidden node - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + if (node.getAttribute("aria-modal") == "true" && node.getAttribute("role") == "dialog") { + node = this._removeAndGetNext(node); + continue; + } + if (!this._articleByline && !this._metadata.byline && this._isValidByline(node, matchString)) { + var endOfSearchMarkerNode = this._getNextNode(node, true); + var next = this._getNextNode(node); + var itemPropNameNode = null; + while (next && next != endOfSearchMarkerNode) { + var itemprop = next.getAttribute("itemprop"); + if (itemprop && itemprop.includes("name")) { + itemPropNameNode = next; + break; + } else { + next = this._getNextNode(next); + } + } + this._articleByline = (itemPropNameNode ?? node).textContent.trim(); + node = this._removeAndGetNext(node); + continue; + } + if (shouldRemoveTitleHeader && this._headerDuplicatesTitle(node)) { + this.log( + "Removing header: ", + node.textContent.trim(), + this._articleTitle.trim() + ); + shouldRemoveTitleHeader = false; + node = this._removeAndGetNext(node); + continue; + } + if (stripUnlikelyCandidates) { + if (this.REGEXPS.unlikelyCandidates.test(matchString) && !this.REGEXPS.okMaybeItsACandidate.test(matchString) && !this._hasAncestorTag(node, "table") && !this._hasAncestorTag(node, "code") && node.tagName !== "BODY" && node.tagName !== "A") { + this.log("Removing unlikely candidate - " + matchString); + node = this._removeAndGetNext(node); + continue; + } + if (this.UNLIKELY_ROLES.includes(node.getAttribute("role"))) { + this.log( + "Removing content with role " + node.getAttribute("role") + " - " + matchString + ); + node = this._removeAndGetNext(node); + continue; + } + } + if ((node.tagName === "DIV" || node.tagName === "SECTION" || node.tagName === "HEADER" || node.tagName === "H1" || node.tagName === "H2" || node.tagName === "H3" || node.tagName === "H4" || node.tagName === "H5" || node.tagName === "H6") && this._isElementWithoutContent(node)) { + node = this._removeAndGetNext(node); + continue; + } + if (this.DEFAULT_TAGS_TO_SCORE.includes(node.tagName)) { + elementsToScore.push(node); + } + if (node.tagName === "DIV") { + var p = null; + var childNode = node.firstChild; + while (childNode) { + var nextSibling = childNode.nextSibling; + if (this._isPhrasingContent(childNode)) { + if (p !== null) { + p.appendChild(childNode); + } else if (!this._isWhitespace(childNode)) { + p = doc.createElement("p"); + node.replaceChild(p, childNode); + p.appendChild(childNode); + } + } else if (p !== null) { + while (p.lastChild && this._isWhitespace(p.lastChild)) { + p.lastChild.remove(); + } + p = null; + } + childNode = nextSibling; + } + if (this._hasSingleTagInsideElement(node, "P") && this._getLinkDensity(node) < 0.25) { + var newNode = node.children[0]; + node.parentNode.replaceChild(newNode, node); + node = newNode; + elementsToScore.push(node); + } else if (!this._hasChildBlockElement(node)) { + node = this._setNodeTag(node, "P"); + elementsToScore.push(node); + } + } + node = this._getNextNode(node); + } + var candidates = []; + this._forEachNode(elementsToScore, function(elementToScore) { + if (!elementToScore.parentNode || typeof elementToScore.parentNode.tagName === "undefined") { + return; + } + var innerText = this._getInnerText(elementToScore); + if (innerText.length < 25) { + return; + } + var ancestors2 = this._getNodeAncestors(elementToScore, 5); + if (ancestors2.length === 0) { + return; + } + var contentScore = 0; + contentScore += 1; + contentScore += innerText.split(this.REGEXPS.commas).length; + contentScore += Math.min(Math.floor(innerText.length / 100), 3); + this._forEachNode(ancestors2, function(ancestor, level) { + if (!ancestor.tagName || !ancestor.parentNode || typeof ancestor.parentNode.tagName === "undefined") { + return; + } + if (typeof ancestor.readability === "undefined") { + this._initializeNode(ancestor); + candidates.push(ancestor); + } + if (level === 0) { + var scoreDivider = 1; + } else if (level === 1) { + scoreDivider = 2; + } else { + scoreDivider = level * 3; + } + ancestor.readability.contentScore += contentScore / scoreDivider; + }); + }); + var topCandidates = []; + for (var c = 0, cl = candidates.length; c < cl; c += 1) { + var candidate = candidates[c]; + var candidateScore = candidate.readability.contentScore * (1 - this._getLinkDensity(candidate)); + candidate.readability.contentScore = candidateScore; + this.log("Candidate:", candidate, "with score " + candidateScore); + for (var t = 0; t < this._nbTopCandidates; t++) { + var aTopCandidate = topCandidates[t]; + if (!aTopCandidate || candidateScore > aTopCandidate.readability.contentScore) { + topCandidates.splice(t, 0, candidate); + if (topCandidates.length > this._nbTopCandidates) { + topCandidates.pop(); + } + break; + } + } + } + var topCandidate = topCandidates[0] || null; + var neededToCreateTopCandidate = false; + var parentOfTopCandidate; + if (topCandidate === null || topCandidate.tagName === "BODY") { + topCandidate = doc.createElement("DIV"); + neededToCreateTopCandidate = true; + while (page.firstChild) { + this.log("Moving child out:", page.firstChild); + topCandidate.appendChild(page.firstChild); + } + page.appendChild(topCandidate); + this._initializeNode(topCandidate); + } else if (topCandidate) { + var alternativeCandidateAncestors = []; + for (var i = 1; i < topCandidates.length; i++) { + if (topCandidates[i].readability.contentScore / topCandidate.readability.contentScore >= 0.75) { + alternativeCandidateAncestors.push( + this._getNodeAncestors(topCandidates[i]) + ); + } + } + var MINIMUM_TOPCANDIDATES = 3; + if (alternativeCandidateAncestors.length >= MINIMUM_TOPCANDIDATES) { + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName !== "BODY") { + var listsContainingThisAncestor = 0; + for (var ancestorIndex = 0; ancestorIndex < alternativeCandidateAncestors.length && listsContainingThisAncestor < MINIMUM_TOPCANDIDATES; ancestorIndex++) { + listsContainingThisAncestor += Number( + alternativeCandidateAncestors[ancestorIndex].includes( + parentOfTopCandidate + ) + ); + } + if (listsContainingThisAncestor >= MINIMUM_TOPCANDIDATES) { + topCandidate = parentOfTopCandidate; + break; + } + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + parentOfTopCandidate = topCandidate.parentNode; + var lastScore = topCandidate.readability.contentScore; + var scoreThreshold = lastScore / 3; + while (parentOfTopCandidate.tagName !== "BODY") { + if (!parentOfTopCandidate.readability) { + parentOfTopCandidate = parentOfTopCandidate.parentNode; + continue; + } + var parentScore = parentOfTopCandidate.readability.contentScore; + if (parentScore < scoreThreshold) { + break; + } + if (parentScore > lastScore) { + topCandidate = parentOfTopCandidate; + break; + } + lastScore = parentOfTopCandidate.readability.contentScore; + parentOfTopCandidate = parentOfTopCandidate.parentNode; + } + parentOfTopCandidate = topCandidate.parentNode; + while (parentOfTopCandidate.tagName != "BODY" && parentOfTopCandidate.children.length == 1) { + topCandidate = parentOfTopCandidate; + parentOfTopCandidate = topCandidate.parentNode; + } + if (!topCandidate.readability) { + this._initializeNode(topCandidate); + } + } + var articleContent = doc.createElement("DIV"); + if (isPaging) { + articleContent.id = "readability-content"; + } + var siblingScoreThreshold = Math.max( + 10, + topCandidate.readability.contentScore * 0.2 + ); + parentOfTopCandidate = topCandidate.parentNode; + var siblings = parentOfTopCandidate.children; + for (var s = 0, sl = siblings.length; s < sl; s++) { + var sibling = siblings[s]; + var append = false; + this.log( + "Looking at sibling node:", + sibling, + sibling.readability ? "with score " + sibling.readability.contentScore : "" + ); + this.log( + "Sibling has score", + sibling.readability ? sibling.readability.contentScore : "Unknown" + ); + if (sibling === topCandidate) { + append = true; + } else { + var contentBonus = 0; + if (sibling.className === topCandidate.className && topCandidate.className !== "") { + contentBonus += topCandidate.readability.contentScore * 0.2; + } + if (sibling.readability && sibling.readability.contentScore + contentBonus >= siblingScoreThreshold) { + append = true; + } else if (sibling.nodeName === "P") { + var linkDensity = this._getLinkDensity(sibling); + var nodeContent = this._getInnerText(sibling); + var nodeLength = nodeContent.length; + if (nodeLength > 80 && linkDensity < 0.25) { + append = true; + } else if (nodeLength < 80 && nodeLength > 0 && linkDensity === 0 && nodeContent.search(/\.( |$)/) !== -1) { + append = true; + } + } + } + if (append) { + this.log("Appending node:", sibling); + if (!this.ALTER_TO_DIV_EXCEPTIONS.includes(sibling.nodeName)) { + this.log("Altering sibling:", sibling, "to div."); + sibling = this._setNodeTag(sibling, "DIV"); + } + articleContent.appendChild(sibling); + siblings = parentOfTopCandidate.children; + s -= 1; + sl -= 1; + } + } + if (this._debug) { + this.log("Article content pre-prep: " + articleContent.innerHTML); + } + this._prepArticle(articleContent); + if (this._debug) { + this.log("Article content post-prep: " + articleContent.innerHTML); + } + if (neededToCreateTopCandidate) { + topCandidate.id = "readability-page-1"; + topCandidate.className = "page"; + } else { + var div = doc.createElement("DIV"); + div.id = "readability-page-1"; + div.className = "page"; + while (articleContent.firstChild) { + div.appendChild(articleContent.firstChild); + } + articleContent.appendChild(div); + } + if (this._debug) { + this.log("Article content after paging: " + articleContent.innerHTML); + } + var parseSuccessful = true; + var textLength = this._getInnerText(articleContent, true).length; + if (textLength < this._charThreshold) { + parseSuccessful = false; + page.innerHTML = pageCacheHtml; + this._attempts.push({ + articleContent, + textLength + }); + if (this._flagIsActive(this.FLAG_STRIP_UNLIKELYS)) { + this._removeFlag(this.FLAG_STRIP_UNLIKELYS); + } else if (this._flagIsActive(this.FLAG_WEIGHT_CLASSES)) { + this._removeFlag(this.FLAG_WEIGHT_CLASSES); + } else if (this._flagIsActive(this.FLAG_CLEAN_CONDITIONALLY)) { + this._removeFlag(this.FLAG_CLEAN_CONDITIONALLY); + } else { + this._attempts.sort(function(a, b) { + return b.textLength - a.textLength; + }); + if (!this._attempts[0].textLength) { + return null; + } + articleContent = this._attempts[0].articleContent; + parseSuccessful = true; + } + } + if (parseSuccessful) { + var ancestors = [parentOfTopCandidate, topCandidate].concat( + this._getNodeAncestors(parentOfTopCandidate) + ); + this._someNode(ancestors, function(ancestor) { + if (!ancestor.tagName) { + return false; + } + var articleDir = ancestor.getAttribute("dir"); + if (articleDir) { + this._articleDir = articleDir; + return true; + } + return false; + }); + return articleContent; + } + } + }, + /** + * Converts some of the common HTML entities in string to their corresponding characters. + * + * @param str {string} - a string to unescape. + * @return string without HTML entity. + */ + _unescapeHtmlEntities(str) { + if (!str) { + return str; + } + var htmlEscapeMap = this.HTML_ESCAPE_MAP; + return str.replace(/&(quot|amp|apos|lt|gt);/g, function(_, tag) { + return htmlEscapeMap[tag]; + }).replace(/&#(?:x([0-9a-f]+)|([0-9]+));/gi, function(_, hex, numStr) { + var num = parseInt(hex || numStr, hex ? 16 : 10); + if (num == 0 || num > 1114111 || num >= 55296 && num <= 57343) { + num = 65533; + } + return String.fromCodePoint(num); + }); + }, + /** + * Try to extract metadata from JSON-LD object. + * For now, only Schema.org objects of type Article or its subtypes are supported. + * @return Object with any metadata that could be extracted (possibly none) + */ + _getJSONLD(doc) { + var scripts = this._getAllNodesWithTag(doc, ["script"]); + var metadata; + this._forEachNode(scripts, function(jsonLdElement) { + if (!metadata && jsonLdElement.getAttribute("type") === "application/ld+json") { + try { + var content = jsonLdElement.textContent.replace( + /^\s*\s*$/g, + "" + ); + var parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + parsed = parsed.find((it) => { + return it["@type"] && it["@type"].match(this.REGEXPS.jsonLdArticleTypes); + }); + if (!parsed) { + return; + } + } + var schemaDotOrgRegex = /^https?\:\/\/schema\.org\/?$/; + var matches = typeof parsed["@context"] === "string" && parsed["@context"].match(schemaDotOrgRegex) || typeof parsed["@context"] === "object" && typeof parsed["@context"]["@vocab"] == "string" && parsed["@context"]["@vocab"].match(schemaDotOrgRegex); + if (!matches) { + return; + } + if (!parsed["@type"] && Array.isArray(parsed["@graph"])) { + parsed = parsed["@graph"].find((it) => { + return (it["@type"] || "").match(this.REGEXPS.jsonLdArticleTypes); + }); + } + if (!parsed || !parsed["@type"] || !parsed["@type"].match(this.REGEXPS.jsonLdArticleTypes)) { + return; + } + metadata = {}; + if (typeof parsed.name === "string" && typeof parsed.headline === "string" && parsed.name !== parsed.headline) { + var title = this._getArticleTitle(); + var nameMatches = this._textSimilarity(parsed.name, title) > 0.75; + var headlineMatches = this._textSimilarity(parsed.headline, title) > 0.75; + if (headlineMatches && !nameMatches) { + metadata.title = parsed.headline; + } else { + metadata.title = parsed.name; + } + } else if (typeof parsed.name === "string") { + metadata.title = parsed.name.trim(); + } else if (typeof parsed.headline === "string") { + metadata.title = parsed.headline.trim(); + } + if (parsed.author) { + if (typeof parsed.author.name === "string") { + metadata.byline = parsed.author.name.trim(); + } else if (Array.isArray(parsed.author) && parsed.author[0] && typeof parsed.author[0].name === "string") { + metadata.byline = parsed.author.filter(function(author) { + return author && typeof author.name === "string"; + }).map(function(author) { + return author.name.trim(); + }).join(", "); + } + } + if (typeof parsed.description === "string") { + metadata.excerpt = parsed.description.trim(); + } + if (parsed.publisher && typeof parsed.publisher.name === "string") { + metadata.siteName = parsed.publisher.name.trim(); + } + if (typeof parsed.datePublished === "string") { + metadata.datePublished = parsed.datePublished.trim(); + } + } catch (err) { + this.log(err.message); + } + } + }); + return metadata ? metadata : {}; + }, + /** + * Attempts to get excerpt and byline metadata for the article. + * + * @param {Object} jsonld — object containing any metadata that + * could be extracted from JSON-LD object. + * + * @return Object with optional "excerpt" and "byline" properties + */ + _getArticleMetadata(jsonld) { + var metadata = {}; + var values = {}; + var metaElements = this._doc.getElementsByTagName("meta"); + var propertyPattern = /\s*(article|dc|dcterm|og|twitter)\s*:\s*(author|creator|description|published_time|title|site_name)\s*/gi; + var namePattern = /^\s*(?:(dc|dcterm|og|twitter|parsely|weibo:(article|webpage))\s*[-\.:]\s*)?(author|creator|pub-date|description|title|site_name)\s*$/i; + this._forEachNode(metaElements, function(element) { + var elementName = element.getAttribute("name"); + var elementProperty = element.getAttribute("property"); + var content = element.getAttribute("content"); + if (!content) { + return; + } + var matches = null; + var name = null; + if (elementProperty) { + matches = elementProperty.match(propertyPattern); + if (matches) { + name = matches[0].toLowerCase().replace(/\s/g, ""); + values[name] = content.trim(); + } + } + if (!matches && elementName && namePattern.test(elementName)) { + name = elementName; + if (content) { + name = name.toLowerCase().replace(/\s/g, "").replace(/\./g, ":"); + values[name] = content.trim(); + } + } + }); + metadata.title = jsonld.title || values["dc:title"] || values["dcterm:title"] || values["og:title"] || values["weibo:article:title"] || values["weibo:webpage:title"] || values.title || values["twitter:title"] || values["parsely-title"]; + if (!metadata.title) { + metadata.title = this._getArticleTitle(); + } + const articleAuthor = typeof values["article:author"] === "string" && !this._isUrl(values["article:author"]) ? values["article:author"] : void 0; + metadata.byline = jsonld.byline || values["dc:creator"] || values["dcterm:creator"] || values.author || values["parsely-author"] || articleAuthor; + metadata.excerpt = jsonld.excerpt || values["dc:description"] || values["dcterm:description"] || values["og:description"] || values["weibo:article:description"] || values["weibo:webpage:description"] || values.description || values["twitter:description"]; + metadata.siteName = jsonld.siteName || values["og:site_name"]; + metadata.publishedTime = jsonld.datePublished || values["article:published_time"] || values["parsely-pub-date"] || null; + metadata.title = this._unescapeHtmlEntities(metadata.title); + metadata.byline = this._unescapeHtmlEntities(metadata.byline); + metadata.excerpt = this._unescapeHtmlEntities(metadata.excerpt); + metadata.siteName = this._unescapeHtmlEntities(metadata.siteName); + metadata.publishedTime = this._unescapeHtmlEntities(metadata.publishedTime); + return metadata; + }, + /** + * Check if node is image, or if node contains exactly only one image + * whether as a direct child or as its descendants. + * + * @param Element + **/ + _isSingleImage(node) { + while (node) { + if (node.tagName === "IMG") { + return true; + } + if (node.children.length !== 1 || node.textContent.trim() !== "") { + return false; + } + node = node.children[0]; + } + return false; + }, + /** + * Find all

-Alternative: MCP server (advanced) +MCP setup for OpenClaw, Claude, Codex, Cursor, and Antigravity -If you prefer MCP over the skill, add to `~/.openclaw/openclaw.json`: +#### OpenClaw (MCP adapter) + +Add to `~/.openclaw/openclaw.json`: ```json { @@ -123,7 +125,7 @@ If you prefer MCP over the skill, add to `~/.openclaw/openclaw.json`: "name": "browserforce", "transport": "stdio", "command": "npx", - "args": ["-y", "browserforce", "mcp"] + "args": ["-y", "browserforce@latest", "mcp"] } ] } @@ -133,9 +135,7 @@ If you prefer MCP over the skill, add to `~/.openclaw/openclaw.json`: } ``` -
- -### Claude Desktop +#### Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -144,13 +144,13 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: "mcpServers": { "browserforce": { "command": "npx", - "args": ["-y", "browserforce", "mcp"] + "args": ["-y", "browserforce@latest", "mcp"] } } } ``` -### Claude Code +#### Claude Code Add to `~/.claude/mcp.json`: @@ -159,12 +159,61 @@ Add to `~/.claude/mcp.json`: "mcpServers": { "browserforce": { "command": "npx", - "args": ["-y", "browserforce", "mcp"] + "args": ["-y", "browserforce@latest", "mcp"] } } } ``` +#### Codex + +Add to `~/.codex/config.toml`: + +```toml +[mcp_servers.browserforce] +command = "npx" +args = ["-y", "browserforce@latest", "mcp"] +``` + +#### Cursor + +Add to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "browserforce": { + "command": "npx", + "args": ["-y", "browserforce@latest", "mcp"] + } + } +} +``` + +#### Antigravity + +In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. +Add the same `mcpServers` entry: + +```json +{ + "mcpServers": { + "browserforce": { + "command": "npx", + "args": ["-y", "browserforce@latest", "mcp"] + } + } +} +``` + +If MCP startup fails with `connection closed: initialize response`: + +1. Ensure args include `"mcp"` (without it, BrowserForce prints help and exits). +2. If running from a local clone, install deps first: `pnpm install`. +3. Validate the launch command manually: `npx -y browserforce@latest mcp` + + + ### CLI ```bash From 73e0769c6a9c4d4ff3563cf3f205fc64b56611f5 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 16:44:19 +0530 Subject: [PATCH 020/192] docs(guide): add codex and cursor MCP config + handshake troubleshooting --- GUIDE.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index 966a2d2..f798650 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -166,6 +166,37 @@ Add to your Claude config: } ``` +**Option B.1: Codex (via MCP)** + +Add to `~/.codex/config.toml`: + +```toml +[mcp_servers.browserforce] +command = "npx" +args = ["-y", "browserforce@latest", "mcp"] +``` + +**Option B.2: Cursor (via MCP)** + +Add to `~/.cursor/mcp.json`: + +```json +{ + "mcpServers": { + "browserforce": { + "command": "npx", + "args": ["-y", "browserforce@latest", "mcp"] + } + } +} +``` + +If startup fails with `connection closed: initialize response`: + +1. Ensure args include `"mcp"` (without it, BrowserForce exits after printing help). +2. If launching from a local clone, run `pnpm install` first. +3. Verify manually: `npx -y browserforce@latest mcp` + Then just talk to Claude: *"Open twitter.com and take a screenshot"* **Option C: Custom Playwright script** From 55e3c000e831923c1c9886a012b8980b17cb75a0 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 16:46:34 +0530 Subject: [PATCH 021/192] chore: update .gitignore and README for installation instructions - Added '.superset' to .gitignore to exclude it from version control. - Updated README to reflect the addition of new tools in the installation instructions and clarified the steps for loading the unpacked extension. --- .gitignore | 3 ++- README.md | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index a485ef2..74c7dfb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ node_modules/ .npm pnpm-debug.log* .worktrees/ -docs/plans/* \ No newline at end of file +docs/plans/* +.superset \ No newline at end of file diff --git a/README.md b/README.md index 9c9e1c6..7750656 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP- | Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **All tabs, automatic** | | Autonomous | Yes | Yes | No (manual click) | No (manual click) | **Yes (fully autonomous)** | | Context method | Screenshots (100KB+) | Screenshots + snapshots | A11y snapshots (5-20KB) | Screenshots (100KB+) | **A11y snapshots (5-20KB)** | -| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **1 `execute` tool** | +| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **3 tools: `execute`, `screenshot_with_labels`, `reset`** | | Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** | | Playwright API | Partial | No | Full | No | **Full** | @@ -57,10 +57,12 @@ pnpm install **If you installed via npm:** -1. Run: `browserforce install-extension` +1. Run: `browserforce install-extension` — note the path it prints (e.g. `/Users/you/.browserforce/extension`) 2. Open `chrome://extensions/` in Chrome 3. Enable **Developer mode** (top-right toggle) -4. Click **Load unpacked** → select the path printed in step 1 +4. Click **Load unpacked** → a file picker opens + - **macOS**: press `Cmd+Shift+G`, paste the path from step 1, press Enter + - **Windows/Linux**: paste the path directly into the address bar of the dialog ❗ After every BrowserForce update, re-run `browserforce install-extension`, then reload the extension in `chrome://extensions/` (click the ↺ icon next to BrowserForce). From f6b5bad2068f026d36636942f18f2aafb26aa9fd Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:13:31 +0530 Subject: [PATCH 022/192] docs: add design for diffing parity and cdp logging --- ...aywriter-parity-diff-cdp-logging-design.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/plans/2026-02-24-playwriter-parity-diff-cdp-logging-design.md diff --git a/docs/plans/2026-02-24-playwriter-parity-diff-cdp-logging-design.md b/docs/plans/2026-02-24-playwriter-parity-diff-cdp-logging-design.md new file mode 100644 index 0000000..06d05fc --- /dev/null +++ b/docs/plans/2026-02-24-playwriter-parity-diff-cdp-logging-design.md @@ -0,0 +1,109 @@ +# Playwriter Parity Diffing + CDP Logging Design + +## Goal +Implement two P0 features in BrowserForce with playwriter behavior parity: +- Diff-aware extraction helpers (`snapshot`, `cleanHTML`, `pageMarkdown`) with `showDiffSinceLastCall` +- Relay-side JSONL CDP traffic logging queryable with `jq` + +## Scope Decisions (Approved) +- `showDiffSinceLastCall` default: `true` (playwriter parity) +- Relay CDP log lifecycle: recreate/truncate on each relay start +- Execution model: subagent-driven implementation after plan creation + +## Non-Goals +- Reworking extension protocol +- Changing CDP routing semantics +- Adding non-essential relay dependencies + +## Current State +- `mcp/src/snapshot.js` already has `createSmartDiff(oldText, newText)` but helper wiring is missing. +- `mcp/src/exec-engine.js` exposes `snapshot({ selector, search })`, `cleanHTML(selector, opts)`, and `pageMarkdown()` without diff mode state. +- `relay/src/index.js` has operational console logging but no structured CDP JSONL log. + +## Proposed Design + +### 1) MCP Diffing Parity + +#### `snapshot` +- Extend helper signature to `snapshot({ selector?, search?, showDiffSinceLastCall? } = {})`. +- Keep existing snapshot build pipeline and ref table unchanged. +- Cache last snapshot text per page (only for full-page snapshot, same practical behavior as playwriter page-scoped caching). +- If `showDiffSinceLastCall` is `true` and a previous snapshot exists: + - `createSmartDiff` result `no-change` => return a clear no-change message with guidance to set `false` for full output. + - `diff` => return diff text. + - `full` => return full snapshot text. +- If no previous snapshot or `showDiffSinceLastCall: false`, return full snapshot text. + +#### `cleanHTML` +- Add option `showDiffSinceLastCall` to `getCleanHTML(page, selector, opts)`. +- Maintain per-page/per-selector snapshot cache via `WeakMap>`. +- Preserve existing HTML cleaning output and current options (`maxAttrLen`, `maxContentLen`). +- Diff behavior mirrors `snapshot` no-change/full/diff handling. + +#### `pageMarkdown` +- Update to `getPageMarkdown(page, opts = {})` with `showDiffSinceLastCall` and optional `search`. +- Maintain per-page snapshot cache via `WeakMap`. +- Preserve current readability extraction and markdown structure. +- Diff behavior mirrors `cleanHTML`. + +### 2) Relay JSONL CDP Logging + +#### Logging module +- Add `relay/src/cdp-log.js` to encapsulate JSONL writing: + - file path default: `~/.browserforce/cdp.jsonl` + - env overrides: + - `BROWSERFORCE_CDP_LOG_FILE_PATH` + - `BROWSERFORCE_CDP_LOG_MAX_STRING_LENGTH` + - truncating replacer for large strings + circular safety + - async append queue to preserve ordering + - truncate file on relay startup (approved behavior) + +#### Relay integration points (`relay/src/index.js`) +- Instantiate logger once in `RelayServer` lifecycle. +- Log entries with shape `{ timestamp, direction, message, clientId?, source? }`. +- Directions: + - `from-playwright`: inbound CDP client commands + - `to-extension`: forwarded `cdpCommand` payloads + - `from-extension`: inbound extension `cdpEvent` + - `to-playwright`: outbound events/responses sent to CDP clients +- Hook points: + - `_handleCdpClientMessage` + - `_forwardToTab` / `_sendToExt` path for `cdpCommand` + - `_handleCdpEventFromExt` + - `_broadcastCdp` and direct response send paths + +### 3) Test Strategy + +#### MCP tests +- Extend `mcp/test/exec-engine-plugins.test.js` (integration surface for `buildExecContext` helpers): + - snapshot returns no-change message on repeated identical calls + - snapshot returns diff on small change + - `cleanHTML`/`pageMarkdown` support `showDiffSinceLastCall: false` full output fallback +- Keep existing pure diff unit tests in `mcp/test/mcp-tools.test.js` intact. + +#### Relay tests +- Extend `relay/test/relay-server.test.js` with `CDP Logging` suite: + - log file created/truncated on startup + - command forward and event forward paths produce JSONL entries with expected directions/methods + - entries are valid JSON per line and queryable with `jq`-style field access + +### 4) Documentation +- Update user-facing docs (likely `README.md`/`GUIDE.md`) to include: + - new helper parameters and defaults + - no-change messaging semantics + - CDP JSONL path and example `jq` command + +## Risks and Mitigations +- Behavior shift from full outputs to diff-by-default may surprise existing flows. + - Mitigation: explicit docs + clear no-change/full fallback message. +- High-volume CDP logs can grow quickly. + - Mitigation: per-start truncation plus string length truncation controls. +- Logging must not affect CDP routing correctness. + - Mitigation: append queue is fire-and-forget and never blocks forwarding decisions. + +## Acceptance Criteria +- Repeated helper calls default to diff behavior with playwriter-like semantics. +- `showDiffSinceLastCall: false` reliably returns full output. +- Relay writes `~/.browserforce/cdp.jsonl` with structured entries for command/event/response traffic. +- New/updated tests pass in `mcp` and `relay` packages. +- Docs explain feature usage and debugging workflow. From 090dd93613ff25aa55736af303167f221a871a13 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:17:44 +0530 Subject: [PATCH 023/192] test(mcp): add failing tests for diff-aware helper wiring --- mcp/test/exec-engine-plugins.test.js | 114 +++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/mcp/test/exec-engine-plugins.test.js b/mcp/test/exec-engine-plugins.test.js index 05e3907..9df25bc 100644 --- a/mcp/test/exec-engine-plugins.test.js +++ b/mcp/test/exec-engine-plugins.test.js @@ -5,6 +5,73 @@ import { buildExecContext, runCode, formatResult } from '../src/exec-engine.js'; const mockPage = { isClosed: () => false, url: () => 'about:blank', title: async () => 'Test' }; const mockCtx = { pages: () => [mockPage] }; +function createSnapshotPage() { + return { + isClosed: () => false, + url: () => 'https://example.test', + title: async () => 'Snapshot Test', + evaluate: async (_fn, arg) => { + if (arg && typeof arg === 'object' && Array.isArray(arg.testIdAttrs)) { + return {}; + } + return { + role: 'WebArea', + name: '', + children: [ + { + role: 'main', + name: '', + children: [{ role: 'button', name: 'Submit', children: [] }], + }, + ], + }; + }, + }; +} + +function createCleanHtmlPage() { + return { + isClosed: () => false, + evaluate: async (_fn, arg) => { + if (arg && typeof arg === 'object' && Object.hasOwn(arg, 'maxAttrLen')) { + return '
clean body
'; + } + throw new Error('Unexpected evaluate call in cleanHTML test'); + }, + }; +} + +function createPageMarkdownPage() { + return { + isClosed: () => false, + evaluate: async (arg) => { + if (typeof arg === 'function') { + const fnSource = arg.toString(); + if (fnSource.includes('!!globalThis.__readability')) { + return true; + } + if (fnSource.includes('isProbablyReaderable')) { + return { + content: 'Markdown content line', + title: 'Markdown Title', + author: null, + excerpt: null, + siteName: null, + lang: 'en', + publishedTime: null, + wordCount: 3, + readable: true, + }; + } + } + if (typeof arg === 'string') { + return undefined; + } + throw new Error('Unexpected evaluate call in pageMarkdown test'); + }, + }; +} + test('plugin helpers are available in execute scope', async () => { const pluginHelpers = { myHelper: async (page, ctx, state, arg) => `result:${arg}`, @@ -64,3 +131,50 @@ test('formatResult returns multi-content for labeled screenshot sentinel', () => assert.equal(formatted[1].type, 'text'); assert.ok(formatted[1].text.includes('Labels: 1 interactive elements')); }); + +test('snapshot diff wiring returns full, then no-change guidance, then full when disabled', async () => { + const page = createSnapshotPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const first = await ctx.snapshot({ showDiffSinceLastCall: true }); + assert.ok(first.includes('Page: Snapshot Test (https://example.test)')); + assert.ok(first.includes('- button "Submit" [ref=e1]')); + + const second = await ctx.snapshot({ showDiffSinceLastCall: true }); + assert.ok(second.includes('No changes since last snapshot')); + assert.ok(second.includes('showDiffSinceLastCall: false')); + + const full = await ctx.snapshot({ showDiffSinceLastCall: false }); + assert.ok(full.includes('Page: Snapshot Test (https://example.test)')); +}); + +test('cleanHTML diff wiring returns no-change guidance on identical repeated calls', async () => { + const page = createCleanHtmlPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const first = await ctx.cleanHTML('body', { showDiffSinceLastCall: true }); + assert.ok(first.includes('
clean body
')); + + const second = await ctx.cleanHTML('body', { showDiffSinceLastCall: true }); + assert.ok(second.includes('No changes since last call')); + assert.ok(second.includes('showDiffSinceLastCall: false')); + + const full = await ctx.cleanHTML('body', { showDiffSinceLastCall: false }); + assert.ok(full.includes('
clean body
')); +}); + +test('pageMarkdown option forwarding and diff wiring returns no-change guidance on repeated calls', async () => { + const page = createPageMarkdownPage(); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + const first = await ctx.pageMarkdown({ showDiffSinceLastCall: true }); + assert.ok(first.includes('# Markdown Title')); + assert.ok(first.includes('Markdown content line')); + + const second = await ctx.pageMarkdown({ showDiffSinceLastCall: true }); + assert.ok(second.includes('No changes since last call')); + assert.ok(second.includes('showDiffSinceLastCall: false')); + + const full = await ctx.pageMarkdown({ showDiffSinceLastCall: false }); + assert.ok(full.includes('# Markdown Title')); +}); From 16585e9dd4437683d7b77c3f5d9c963fe60a332b Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:24:50 +0530 Subject: [PATCH 024/192] feat(mcp): add playwriter-style diff mode to snapshot and content helpers --- mcp/src/clean-html.js | 25 +++++++++++++- mcp/src/exec-engine.js | 25 +++++++++++--- mcp/src/page-markdown.js | 70 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/mcp/src/clean-html.js b/mcp/src/clean-html.js index c47261b..e34480d 100644 --- a/mcp/src/clean-html.js +++ b/mcp/src/clean-html.js @@ -1,18 +1,23 @@ // Clean HTML extraction — runs entirely in the browser via page.evaluate(). // Strips scripts, styles, decorative elements; keeps semantic attributes. +import { createSmartDiff } from './snapshot.js'; + +const lastHtmlSnapshots = new WeakMap(); + /** * Extracts cleaned HTML from a Playwright page or locator. * All processing happens in-page via DOM manipulation — no server-side parsing deps. * * @param {import('playwright-core').Page} page * @param {string} [selector] - CSS selector to scope extraction (default: document) - * @param {{ maxAttrLen?: number, maxContentLen?: number }} [opts] + * @param {{ maxAttrLen?: number, maxContentLen?: number, showDiffSinceLastCall?: boolean }} [opts] * @returns {Promise} */ export async function getCleanHTML(page, selector, opts = {}) { const maxAttrLen = opts.maxAttrLen ?? 200; const maxContentLen = opts.maxContentLen ?? 500; + const showDiffSinceLastCall = opts.showDiffSinceLastCall ?? true; const html = await page.evaluate(({ selector, maxAttrLen, maxContentLen }) => { const TAGS_TO_REMOVE = new Set([ @@ -162,5 +167,23 @@ export async function getCleanHTML(page, selector, opts = {}) { return root.outerHTML || root.innerHTML || ''; }, { selector: selector || null, maxAttrLen, maxContentLen }); + let pageSnapshots = lastHtmlSnapshots.get(page); + if (!pageSnapshots) { + pageSnapshots = new Map(); + lastHtmlSnapshots.set(page, pageSnapshots); + } + + const snapshotKey = selector || '__full_page__'; + const previousSnapshot = pageSnapshots.get(snapshotKey); + pageSnapshots.set(snapshotKey, html); + + if (showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, html); + if (diffResult.type === 'no-change') { + return 'No changes since last call. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + return html; } diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 96ca77c..064d297 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -7,7 +7,7 @@ import { homedir } from 'node:os'; import { fileURLToPath } from 'node:url'; import { spawn } from 'node:child_process'; import { - TEST_ID_ATTRS, + TEST_ID_ATTRS, createSmartDiff, buildSnapshotText, parseSearchPattern, annotateStableAttrs, } from './snapshot.js'; import { screenshotWithLabels } from './a11y-labels.js'; @@ -409,6 +409,7 @@ export class CodeExecutionTimeoutError extends Error { // instead of referencing module-level singletons. export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}, pluginHelpers = {}) { const { consoleLogs, setupConsoleCapture } = consoleHelpers; + const lastSnapshots = userState.__lastSnapshots || (userState.__lastSnapshots = new WeakMap()); const activePage = () => { if (userState.page && !userState.page.isClosed()) return userState.page; @@ -416,7 +417,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { throw new Error('No active page. Create one first: state.page = await context.newPage()'); }; - const snapshot = async ({ selector, search } = {}) => { + const snapshot = async ({ selector, search, showDiffSinceLastCall = true } = {}) => { const page = activePage(); const axRoot = await getAccessibilityTree(page, selector); if (!axRoot) return 'No accessibility tree available for this page.'; @@ -429,7 +430,23 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { : ''; const title = await page.title().catch(() => ''); const pageUrl = page.url(); - return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + const fullSnapshot = `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + + const shouldCacheSnapshot = !selector; + const previousSnapshot = shouldCacheSnapshot ? lastSnapshots.get(page) : undefined; + if (shouldCacheSnapshot) { + lastSnapshots.set(page, fullSnapshot); + } + + if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, fullSnapshot); + if (diffResult.type === 'no-change') { + return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + + return fullSnapshot; }; const waitForPageLoad = (opts = {}) => @@ -458,7 +475,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { const cleanHTML = (selector, opts) => getCleanHTML(activePage(), selector, opts); - const pageMarkdown = () => getPageMarkdown(activePage()); + const pageMarkdown = (opts) => getPageMarkdown(activePage(), opts); // Wrap plugin helpers to auto-inject (page, ctx, state) as first three args const wrappedPluginHelpers = {}; diff --git a/mcp/src/page-markdown.js b/mcp/src/page-markdown.js index cd2ece9..fe8424b 100644 --- a/mcp/src/page-markdown.js +++ b/mcp/src/page-markdown.js @@ -4,10 +4,21 @@ import { readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createSmartDiff } from './snapshot.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); let readabilityCode = null; +const lastMarkdownSnapshots = new WeakMap(); + +function isRegExp(value) { + return ( + typeof value === 'object' && + value !== null && + typeof value.test === 'function' && + typeof value.exec === 'function' + ); +} function getReadabilityCode() { if (readabilityCode) return readabilityCode; @@ -21,9 +32,13 @@ function getReadabilityCode() { * Strips nav, ads, sidebars — returns article body with metadata. * * @param {import('playwright-core').Page} page + * @param {{ search?: string | RegExp, showDiffSinceLastCall?: boolean }} [opts] * @returns {Promise} */ -export async function getPageMarkdown(page) { +export async function getPageMarkdown(page, opts = {}) { + const search = opts.search; + const showDiffSinceLastCall = opts.showDiffSinceLastCall ?? true; + // Inject Readability if not already present const hasReadability = await page.evaluate(() => !!globalThis.__readability); if (!hasReadability) { @@ -110,5 +125,58 @@ export async function getPageMarkdown(page) { markdown = markdown.toWellFormed(); } + const previousSnapshot = lastMarkdownSnapshots.get(page); + lastMarkdownSnapshots.set(page, markdown); + + if (showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, markdown); + if (diffResult.type === 'no-change') { + return 'No changes since last call. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + + if (search) { + const lines = markdown.split('\n'); + const matchIndices = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const isMatch = isRegExp(search) + ? search.test(line) + : line.toLowerCase().includes(String(search).toLowerCase()); + if (isMatch) { + matchIndices.push(i); + if (matchIndices.length >= 10) break; + } + } + + if (matchIndices.length === 0) { + return 'No matches found'; + } + + const CONTEXT_LINES = 5; + const includedLines = new Set(); + for (const idx of matchIndices) { + const start = Math.max(0, idx - CONTEXT_LINES); + const end = Math.min(lines.length - 1, idx + CONTEXT_LINES); + for (let i = start; i <= end; i++) { + includedLines.add(i); + } + } + + const sortedIndices = [...includedLines].sort((a, b) => a - b); + const resultLines = []; + for (let i = 0; i < sortedIndices.length; i++) { + const lineIdx = sortedIndices[i]; + if (i > 0 && sortedIndices[i - 1] !== lineIdx - 1) { + resultLines.push('---'); + } + resultLines.push(lines[lineIdx]); + } + + return resultLines.join('\n'); + } + return markdown; } From 8031301b0044afcc2a7354bac47f5afb8230ee0e Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:32:44 +0530 Subject: [PATCH 025/192] fix(mcp): preserve pageMarkdown search semantics with diff mode --- mcp/src/page-markdown.js | 31 +++++++++++++++++----------- mcp/test/exec-engine-plugins.test.js | 29 +++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 15 deletions(-) diff --git a/mcp/src/page-markdown.js b/mcp/src/page-markdown.js index fe8424b..f17be92 100644 --- a/mcp/src/page-markdown.js +++ b/mcp/src/page-markdown.js @@ -20,6 +20,16 @@ function isRegExp(value) { ); } +function lineMatchesSearch(search, line) { + if (!isRegExp(search)) { + return line.toLowerCase().includes(String(search).toLowerCase()); + } + if (search.global || search.sticky) { + search.lastIndex = 0; + } + return search.test(line); +} + function getReadabilityCode() { if (readabilityCode) return readabilityCode; const bundlePath = join(__dirname, 'vendor', 'readability.bundle.js'); @@ -128,24 +138,13 @@ export async function getPageMarkdown(page, opts = {}) { const previousSnapshot = lastMarkdownSnapshots.get(page); lastMarkdownSnapshots.set(page, markdown); - if (showDiffSinceLastCall && previousSnapshot) { - const diffResult = createSmartDiff(previousSnapshot, markdown); - if (diffResult.type === 'no-change') { - return 'No changes since last call. Use showDiffSinceLastCall: false to see full content.'; - } - return diffResult.content; - } - if (search) { const lines = markdown.split('\n'); const matchIndices = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; - const isMatch = isRegExp(search) - ? search.test(line) - : line.toLowerCase().includes(String(search).toLowerCase()); - if (isMatch) { + if (lineMatchesSearch(search, line)) { matchIndices.push(i); if (matchIndices.length >= 10) break; } @@ -178,5 +177,13 @@ export async function getPageMarkdown(page, opts = {}) { return resultLines.join('\n'); } + if (showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, markdown); + if (diffResult.type === 'no-change') { + return 'No changes since last call. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + return markdown; } diff --git a/mcp/test/exec-engine-plugins.test.js b/mcp/test/exec-engine-plugins.test.js index 9df25bc..3f87d44 100644 --- a/mcp/test/exec-engine-plugins.test.js +++ b/mcp/test/exec-engine-plugins.test.js @@ -41,7 +41,8 @@ function createCleanHtmlPage() { }; } -function createPageMarkdownPage() { +function createPageMarkdownPage(content = 'Markdown content line', options = {}) { + const title = options.title === undefined ? 'Markdown Title' : options.title; return { isClosed: () => false, evaluate: async (arg) => { @@ -52,8 +53,8 @@ function createPageMarkdownPage() { } if (fnSource.includes('isProbablyReaderable')) { return { - content: 'Markdown content line', - title: 'Markdown Title', + content, + title, author: null, excerpt: null, siteName: null, @@ -178,3 +179,25 @@ test('pageMarkdown option forwarding and diff wiring returns no-change guidance const full = await ctx.pageMarkdown({ showDiffSinceLastCall: false }); assert.ok(full.includes('# Markdown Title')); }); + +test('pageMarkdown search takes precedence over diff mode on repeated calls', async () => { + const page = createPageMarkdownPage('alpha line\nfind me here\nomega line'); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + + await ctx.pageMarkdown({ showDiffSinceLastCall: true }); + const searched = await ctx.pageMarkdown({ search: 'find me' }); + + assert.ok(searched.includes('find me here')); + assert.ok(!searched.includes('No changes since last call')); +}); + +test('pageMarkdown search resets regex state for g/y regex flags', async () => { + const page = createPageMarkdownPage('target on only line', { title: null }); + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + const search = /target/g; + search.lastIndex = 1; + + const result = await ctx.pageMarkdown({ search, showDiffSinceLastCall: false }); + assert.ok(result.includes('target on only line')); + assert.ok(!result.includes('No matches found')); +}); From 9edf832d5abde62886fae5d6a3ed696ff508de82 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:35:19 +0530 Subject: [PATCH 026/192] test(mcp): add prompt regression guards for tactical guidance --- mcp/src/index.js | 18 ++++++++++++++++++ mcp/test/mcp-tools.test.js | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/mcp/src/index.js b/mcp/src/index.js index 51edc83..1fd080a 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -241,6 +241,21 @@ If snapshot shows [ref=some-id] for an element with a data-testid or id: For text content: const text = await state.page.locator('role=heading').textContent(); +Selector priority: + 1. Use [ref=...] locators from snapshot output immediately after observing + 2. Use role/name locators from snapshot + 3. Use stable test IDs (data-testid) if present + 4. Avoid brittle nth()/deep CSS selectors unless no stable option exists + +Before interacting, handle page blockers (cookie/consent banners, age gates, login popups): + const blockers = await snapshot({ search: /cookie|consent|accept|reject|allow|age|verify|login|sign.in/i }); + // Dismiss blockers first, then continue with the main task + +Avoid stale locator usage: + // BAD: using a stale locator from an old snapshot after DOM changes + // GOOD: refresh observation first, then act with new refs/locators + await snapshot(); + ═══ COMMON PATTERNS ═══ Navigate and read: @@ -272,6 +287,9 @@ Wait for specific element: Debug with console logs: return getLogs({ count: 20 }); +When you need the full tree instead of diff output: + return await snapshot({ showDiffSinceLastCall: false }); + ═══ ANTI-PATTERNS ═══ ✗ Don't navigate the user's existing tabs — create your own via context.newPage() diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 4bb507d..64e9e37 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -113,6 +113,23 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('ANTI-PATTERN') || promptBlock.includes('Don\'t') || promptBlock.includes('✗'), 'should include anti-patterns'); }); + it('execute prompt includes tactical anti-pattern and decision guidance', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok(promptBlock.includes('Selector priority'), 'should include selector ranking guidance'); + assert.ok(promptBlock.includes('login popups'), 'should include login popup handling'); + assert.ok(promptBlock.includes('cookie') || promptBlock.includes('consent'), 'should include consent modal handling'); + assert.ok(promptBlock.includes('stale locator'), 'should include stale locator warning'); + assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); + }); + it('execute tool has code and optional timeout params', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), From 8d31393d53fddbba7f6cf7cb4dad413ba535ebef Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:38:10 +0530 Subject: [PATCH 027/192] test(relay): add failing coverage for cdp jsonl traffic logging --- relay/test/relay-server.test.js | 143 ++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 593bc70..89d3312 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -72,6 +72,15 @@ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } +function readJsonlEntries(logFilePath) { + const raw = fs.readFileSync(logFilePath, 'utf8').trim(); + if (!raw) return []; + return raw + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + // ─── Token Persistence ─────────────────────────────────────────────────────── describe('Token Persistence', () => { @@ -928,6 +937,140 @@ describe('CDP Event Forwarding', () => { }); }); +// ─── CDP JSONL Logging ────────────────────────────────────────────────────── + +describe('CDP JSONL Logging', () => { + let logDir; + let logFilePath; + let originalLogFileEnv; + + beforeEach(() => { + logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bf-cdp-log-')); + logFilePath = path.join(logDir, 'cdp-traffic.jsonl'); + originalLogFileEnv = process.env.BROWSERFORCE_CDP_LOG_FILE_PATH; + process.env.BROWSERFORCE_CDP_LOG_FILE_PATH = logFilePath; + }); + + afterEach(() => { + if (originalLogFileEnv === undefined) delete process.env.BROWSERFORCE_CDP_LOG_FILE_PATH; + else process.env.BROWSERFORCE_CDP_LOG_FILE_PATH = originalLogFileEnv; + fs.rmSync(logDir, { recursive: true, force: true }); + }); + + it('creates and truncates the CDP JSONL log file on relay start', async () => { + let firstRelay; + let secondRelay; + + try { + firstRelay = new RelayServer(getRandomPort()); + await firstRelay.start({ writeCdpUrl: false }); + assert.equal(fs.existsSync(logFilePath), true, 'CDP log file should be created on start'); + + firstRelay.stop(); + firstRelay = null; + + fs.writeFileSync(logFilePath, '{"stale":true}\n'); + assert.ok(fs.statSync(logFilePath).size > 0, 'CDP log file should contain stale data before restart'); + + secondRelay = new RelayServer(getRandomPort()); + await secondRelay.start({ writeCdpUrl: false }); + assert.equal(fs.existsSync(logFilePath), true, 'CDP log file should still exist after restart'); + assert.equal(fs.readFileSync(logFilePath, 'utf8'), '', 'CDP log file should be truncated on each start'); + } finally { + secondRelay?.stop(); + firstRelay?.stop(); + } + }); + + it('logs command/event traffic with direction and method in JSONL entries', async () => { + let relay; + let ext; + let cdp; + + try { + relay = new RelayServer(getRandomPort()); + await relay.start({ writeCdpUrl: false }); + + ext = await connectWs(`ws://127.0.0.1:${relay.port}/extension`, { + headers: { Origin: 'chrome-extension://test' }, + }); + + ext.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.method === 'ping') { ext.send(JSON.stringify({ method: 'pong' })); return; } + if (msg.id === undefined) return; + + if (msg.method === 'createTab') { + ext.send(JSON.stringify({ + id: msg.id, + result: { + tabId: 501, + targetId: 'real-target-501', + sessionId: msg.params.sessionId, + targetInfo: { + targetId: 'real-target-501', + type: 'page', + title: 'Logging Test', + url: msg.params.url || 'about:blank', + }, + }, + })); + } else if (msg.method === 'cdpCommand' && msg.params.method === 'Runtime.evaluate') { + ext.send(JSON.stringify({ + id: msg.id, + result: { result: { type: 'string', value: 'ok' } }, + })); + } + }); + + cdp = await connectWs(`ws://127.0.0.1:${relay.port}/cdp?token=${relay.authToken}`); + const cdpMessages = []; + cdp.on('message', (data) => cdpMessages.push(JSON.parse(data.toString()))); + + cdp.send(JSON.stringify({ id: 1, method: 'Target.createTarget', params: { url: 'https://example.com' } })); + await sleep(300); + + const attached = cdpMessages.find((m) => m.method === 'Target.attachedToTarget'); + assert.ok(attached, 'Expected Target.attachedToTarget after createTarget'); + const sessionId = attached.params.sessionId; + + cdp.send(JSON.stringify({ + id: 2, + method: 'Runtime.evaluate', + params: { expression: '"ok"' }, + sessionId, + })); + await sleep(200); + + ext.send(JSON.stringify({ + method: 'cdpEvent', + params: { + tabId: 501, + method: 'Page.loadEventFired', + params: { timestamp: 42 }, + }, + })); + await sleep(300); + + assert.equal(fs.existsSync(logFilePath), true, 'CDP log file should exist'); + const entries = readJsonlEntries(logFilePath); + const directions = new Set(entries.map((entry) => entry.direction)); + const methods = entries.map((entry) => entry?.message?.method).filter(Boolean); + + assert.ok(directions.has('from-playwright'), 'Should log from-playwright direction'); + assert.ok(directions.has('to-extension'), 'Should log to-extension direction'); + assert.ok(directions.has('from-extension'), 'Should log from-extension direction'); + assert.ok(directions.has('to-playwright'), 'Should log to-playwright direction'); + assert.ok(methods.includes('Runtime.evaluate'), 'Should log Runtime.evaluate method'); + assert.ok(methods.includes('Page.loadEventFired'), 'Should log Page.loadEventFired method'); + } finally { + cdp?.close(); + ext?.close(); + relay?.stop(); + } + }); +}); + // ─── Tab Lifecycle ─────────────────────────────────────────────────────────── describe('Tab Lifecycle', () => { From 409ab6bd77dac726615a50b9e24de62b10ae8629 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:39:21 +0530 Subject: [PATCH 028/192] feat(mcp): expand execute prompt with tactical web automation playbooks --- mcp/src/index.js | 70 ++++++++++++++++++++++++++++++++++++++ mcp/test/mcp-tools.test.js | 15 ++++++++ 2 files changed, 85 insertions(+) diff --git a/mcp/src/index.js b/mcp/src/index.js index 1fd080a..d46599c 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -256,6 +256,76 @@ Avoid stale locator usage: // GOOD: refresh observation first, then act with new refs/locators await snapshot(); +Typing text with newlines: + // Use fill() for multiline blocks to avoid accidental Enter key submissions + await state.page.locator('role=textbox[name="Message"]').fill('Line 1\\nLine 2'); + +═══ TACTICAL ANTI-PATTERNS ═══ + +Popup control: + ✗ Don’t click through a popup without confirming what changed + ✓ Dismiss popup, then run snapshot() immediately to confirm main UI is usable + +Consent blockers: + ✗ Don’t continue form/page actions while consent banners block focus + ✓ Handle cookie/consent overlays first, then retry the intended action + +Stale locators: + ✗ Don’t reuse [ref=...] values after DOM/nav updates + ✓ Refresh snapshot() and use the newest refs/role locators + +Newline typing: + ✗ Don’t use keyboard Enter loops for multiline textareas unless explicitly needed + ✓ Prefer locator.fill('line1\\nline2') for deterministic multiline input + +═══ EXTRACTION DECISION TREE ═══ + +snapshot vs cleanHTML vs pageMarkdown: + 1) Use snapshot() when you need current interactive structure, labels, and refs. + 2) Use cleanHTML(selector?) when you need structured DOM content for parsing/extraction. + 3) Use pageMarkdown() for article/blog/news pages where nav/ads should be removed. + 4) Use screenshotWithAccessibilityLabels() only when layout/visual evidence is required. + +═══ DEBUGGING WORKFLOW ═══ + +Combine snapshot + logs: + 1) snapshot({ search: /target text|button|error/i }) to verify element presence and naming + 2) getLogs({ count: 30 }) for runtime/network/console errors + 3) page.evaluate(() => { ...visibility checks... }) to validate hidden/disabled/overlay states + +Example visibility check: + return await state.page.evaluate(() => { + const el = document.querySelector('[data-testid="submit"]'); + if (!el) return { found: false }; + const s = getComputedStyle(el); + const r = el.getBoundingClientRect(); + return { found: true, visible: s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0 }; + }); + +═══ ADVANCED PATTERNS ═══ + +Authenticated fetch: + // Reuse browser session cookies/headers from the current page context + return await state.page.evaluate(async () => { + const res = await fetch('/api/me', { credentials: 'include' }); + return { status: res.status, body: await res.text() }; + }); + +Network interception: + await state.page.route('**/api/**', async (route) => { + const request = route.request(); + // Inspect/modify request here if needed before continuing + await route.continue(); + }); + +Downloads: + // Use expect_download pattern and save path after click/navigation trigger + const [download] = await Promise.all([ + state.page.waitForEvent('download'), + state.page.locator('role=button[name="Export CSV"]').click(), + ]); + return { suggestedFilename: download.suggestedFilename() }; + ═══ COMMON PATTERNS ═══ Navigate and read: diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 64e9e37..dcff140 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -130,6 +130,21 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); }); + it('execute prompt includes tool-selection and debugging decision trees', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok(promptBlock.includes('snapshot vs cleanHTML vs pageMarkdown'), 'should include extraction decision tree'); + assert.ok(promptBlock.includes('Combine snapshot + logs'), 'should include debugging workflow'); + assert.ok(promptBlock.includes('Authenticated fetch'), 'should include authenticated fetch pattern'); + assert.ok(promptBlock.includes('Downloads'), 'should include download pattern'); + }); + it('execute tool has code and optional timeout params', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), From 0cb0585031214e5809b477505887e77a35895491 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:42:50 +0530 Subject: [PATCH 029/192] test(relay): stabilize failing cdp logging tests --- relay/test/relay-server.test.js | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 89d3312..3b36b2a 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -81,6 +81,20 @@ function readJsonlEntries(logFilePath) { .map((line) => JSON.parse(line)); } +async function waitForCondition(check, { + timeoutMs = 3000, + intervalMs = 25, + description = 'condition', +} = {}) { + const startedAt = Date.now(); + while (Date.now() - startedAt < timeoutMs) { + const result = check(); + if (result) return result; + await sleep(intervalMs); + } + throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms`); +} + // ─── Token Persistence ─────────────────────────────────────────────────────── describe('Token Persistence', () => { @@ -1028,10 +1042,10 @@ describe('CDP JSONL Logging', () => { cdp.on('message', (data) => cdpMessages.push(JSON.parse(data.toString()))); cdp.send(JSON.stringify({ id: 1, method: 'Target.createTarget', params: { url: 'https://example.com' } })); - await sleep(300); - - const attached = cdpMessages.find((m) => m.method === 'Target.attachedToTarget'); - assert.ok(attached, 'Expected Target.attachedToTarget after createTarget'); + const attached = await waitForCondition( + () => cdpMessages.find((m) => m.method === 'Target.attachedToTarget'), + { description: 'Target.attachedToTarget event after createTarget' }, + ); const sessionId = attached.params.sessionId; cdp.send(JSON.stringify({ @@ -1040,7 +1054,10 @@ describe('CDP JSONL Logging', () => { params: { expression: '"ok"' }, sessionId, })); - await sleep(200); + await waitForCondition( + () => cdpMessages.find((m) => m.id === 2), + { description: 'Runtime.evaluate response' }, + ); ext.send(JSON.stringify({ method: 'cdpEvent', @@ -1050,7 +1067,10 @@ describe('CDP JSONL Logging', () => { params: { timestamp: 42 }, }, })); - await sleep(300); + await waitForCondition( + () => cdpMessages.find((m) => m.method === 'Page.loadEventFired'), + { description: 'Page.loadEventFired event routed to CDP client' }, + ); assert.equal(fs.existsSync(logFilePath), true, 'CDP log file should exist'); const entries = readJsonlEntries(logFilePath); From dfafc62f074513e6d16bc3ad8ba5560a7afd7d25 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:45:03 +0530 Subject: [PATCH 030/192] feat(mcp): expose refToLocator helper in execute context --- mcp/src/exec-engine.js | 12 +++++++++++- mcp/src/index.js | 8 +++++--- mcp/test/mcp-tools.test.js | 10 ++++++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 064d297..3edbb50 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -410,6 +410,7 @@ export class CodeExecutionTimeoutError extends Error { export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}, pluginHelpers = {}) { const { consoleLogs, setupConsoleCapture } = consoleHelpers; const lastSnapshots = userState.__lastSnapshots || (userState.__lastSnapshots = new WeakMap()); + const lastRefToLocator = userState.__lastRefToLocator || (userState.__lastRefToLocator = new WeakMap()); const activePage = () => { if (userState.page && !userState.page.isClosed()) return userState.page; @@ -425,6 +426,8 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { annotateStableAttrs(axRoot, stableIds); const searchPattern = parseSearchPattern(search); const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern); + const refMap = new Map(refs.map(({ ref, locator }) => [ref, locator])); + lastRefToLocator.set(page, refMap); const refTable = refs.length > 0 ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') : ''; @@ -449,6 +452,13 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return fullSnapshot; }; + const refToLocator = ({ ref, page: targetPage } = {}) => { + const p = targetPage || activePage(); + const map = lastRefToLocator.get(p); + if (!map) return null; + return map.get(ref) ?? null; + }; + const waitForPageLoad = (opts = {}) => smartWaitForPageLoad(activePage(), opts.timeout ?? 30000); @@ -490,7 +500,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win page: defaultPage, context: ctx, state: userState, - snapshot, waitForPageLoad, getLogs, clearLogs, + snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, diff --git a/mcp/src/index.js b/mcp/src/index.js index d46599c..b0c6ca8 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -134,6 +134,7 @@ Variables: Helpers: snapshot({ selector?, search? }) Accessibility tree as text. 10-100x cheaper than screenshots. + refToLocator({ ref }) Resolve a snapshot ref (e.g., e3) to a Playwright locator string. waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState). getLogs({ count? }) Browser console logs captured for current page. clearLogs() Clear captured console logs. @@ -235,8 +236,9 @@ Use Playwright locators with accessibility roles (from snapshot output): await state.page.locator('role=textbox[name="Search"]').fill('query'); await state.page.locator('role=link[name="Settings"]').click(); -If snapshot shows [ref=some-id] for an element with a data-testid or id: - await state.page.locator('[data-testid="some-id"]').click(); +If snapshot shows [ref=e3], resolve it with refToLocator({ ref }) before acting: + const locator = refToLocator({ ref: 'e3' }); + if (locator) await state.page.locator(locator).click(); For text content: const text = await state.page.locator('role=heading').textContent(); @@ -406,7 +408,7 @@ function registerExecuteTool(skillAppendix = '') { 'execute', EXECUTE_PROMPT + skillAppendix, { - code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), + code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'), }, async ({ code, timeout = 30000 }) => { diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index dcff140..261c87e 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -106,6 +106,7 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('snapshot'), 'should mention snapshot-first approach'); assert.ok(promptBlock.includes('waitForPageLoad'), 'should mention waitForPageLoad'); assert.ok(promptBlock.includes('screenshotWithAccessibilityLabels'), 'should mention screenshotWithAccessibilityLabels helper'); + assert.ok(promptBlock.includes('refToLocator({ ref })'), 'should mention refToLocator helper usage'); assert.ok(promptBlock.includes('cleanHTML'), 'should mention cleanHTML helper'); assert.ok(promptBlock.includes('pageMarkdown'), 'should mention pageMarkdown helper'); assert.ok(promptBlock.includes('newPage'), 'should mention creating new tabs'); @@ -182,6 +183,15 @@ describe('Tool Definitions', () => { assert.ok(!source.includes("'screenshot_with_labels'"), 'screenshot_with_labels tool should be removed'); assert.ok(!source.includes('SCREENSHOT_LABELS_PROMPT'), 'dedicated screenshot prompt should be removed'); }); + + it('exec context source exposes refToLocator helper', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/exec-engine.js'), + 'utf8' + ); + + assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); + }); }); // ─── MCP Response Format ───────────────────────────────────────────────────── From 96aff087a57937c621b4b3c38c335c12ae1666a5 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:50:37 +0530 Subject: [PATCH 031/192] feat(mcp): add getCDPSession helper for relay-safe raw CDP access --- mcp/src/exec-engine.js | 10 +++++++++- mcp/src/index.js | 6 ++++++ mcp/test/mcp-tools.test.js | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 3edbb50..dfbc088 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -474,6 +474,14 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { if (consoleLogs) consoleLogs.set(activePage(), []); }; + const getCDPSession = async ({ page: targetPage } = {}) => { + const p = targetPage || activePage(); + if (!p || p.isClosed()) { + throw new Error('Cannot create CDP session for closed page'); + } + return p.context().newCDPSession(p); + }; + const screenshotWithAccessibilityLabels = async ({ selector, interactiveOnly = true } = {}) => { const page = activePage(); const { screenshot, snapshot: snapText, labelCount } = await screenshotWithLabels(page, { @@ -500,7 +508,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win page: defaultPage, context: ctx, state: userState, - snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, + snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, getCDPSession, screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, diff --git a/mcp/src/index.js b/mcp/src/index.js index b0c6ca8..d966fc2 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -148,6 +148,8 @@ Helpers: pageMarkdown() Article content via Mozilla Readability (Firefox Reader View). Strips nav/ads/sidebars. Returns title + metadata + body text. Falls back to raw body text for non-article pages. + getCDPSession({ page }) Create a relay-safe raw CDP session for a page. + Use this instead of page.context().newCDPSession(page). Globals: fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder @@ -280,6 +282,10 @@ Newline typing: ✗ Don’t use keyboard Enter loops for multiline textareas unless explicitly needed ✓ Prefer locator.fill('line1\\nline2') for deterministic multiline input +Raw CDP sessions: + ✗ Don’t call page.context().newCDPSession(page) directly + ✓ Use getCDPSession({ page }) for relay-safe CDP session creation + ═══ EXTRACTION DECISION TREE ═══ snapshot vs cleanHTML vs pageMarkdown: diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 261c87e..7008e4f 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -107,6 +107,7 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('waitForPageLoad'), 'should mention waitForPageLoad'); assert.ok(promptBlock.includes('screenshotWithAccessibilityLabels'), 'should mention screenshotWithAccessibilityLabels helper'); assert.ok(promptBlock.includes('refToLocator({ ref })'), 'should mention refToLocator helper usage'); + assert.ok(promptBlock.includes('getCDPSession({ page })'), 'should mention relay-safe getCDPSession helper usage'); assert.ok(promptBlock.includes('cleanHTML'), 'should mention cleanHTML helper'); assert.ok(promptBlock.includes('pageMarkdown'), 'should mention pageMarkdown helper'); assert.ok(promptBlock.includes('newPage'), 'should mention creating new tabs'); @@ -191,6 +192,7 @@ describe('Tool Definitions', () => { ); assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); + assert.ok(source.includes('const getCDPSession = async'), 'exec engine should define getCDPSession helper'); }); }); From e81d78d3f4eddd42d0df482a8e40093297856aba Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:56:49 +0530 Subject: [PATCH 032/192] feat(mcp): add snapshot diff mode with showDiffSinceLastCall toggle --- mcp/src/exec-engine.js | 37 ++++++++++++++++++++++++++----------- mcp/src/index.js | 3 ++- mcp/test/mcp-tools.test.js | 9 +++++++++ 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index dfbc088..9c5ca6d 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -425,23 +425,33 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { const stableIds = await getStableIds(page, selector); annotateStableAttrs(axRoot, stableIds); const searchPattern = parseSearchPattern(search); - const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern); - const refMap = new Map(refs.map(({ ref, locator }) => [ref, locator])); + const { text: fullSnapshotText, refs: fullRefs } = buildSnapshotText(axRoot, null, null); + const refMap = new Map(fullRefs.map(({ ref, locator }) => [ref, locator])); lastRefToLocator.set(page, refMap); - const refTable = refs.length > 0 - ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') - : ''; const title = await page.title().catch(() => ''); const pageUrl = page.url(); - const fullSnapshot = `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + const formatSnapshot = (snapshotText, refs) => { + const refTable = refs.length > 0 + ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') + : ''; + return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + }; + const fullSnapshot = formatSnapshot(fullSnapshotText, fullRefs); - const shouldCacheSnapshot = !selector; - const previousSnapshot = shouldCacheSnapshot ? lastSnapshots.get(page) : undefined; - if (shouldCacheSnapshot) { - lastSnapshots.set(page, fullSnapshot); + let pageSnapshots = lastSnapshots.get(page); + if (!(pageSnapshots instanceof Map)) { + const migratedSnapshots = new Map(); + if (typeof pageSnapshots === 'string') { + migratedSnapshots.set('__full_page__', pageSnapshots); + } + pageSnapshots = migratedSnapshots; + lastSnapshots.set(page, pageSnapshots); } + const snapshotKey = selector || '__full_page__'; + const previousSnapshot = pageSnapshots.get(snapshotKey); + pageSnapshots.set(snapshotKey, fullSnapshot); - if (showDiffSinceLastCall && previousSnapshot && shouldCacheSnapshot) { + if (!search && showDiffSinceLastCall && previousSnapshot) { const diffResult = createSmartDiff(previousSnapshot, fullSnapshot); if (diffResult.type === 'no-change') { return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; @@ -449,6 +459,11 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return diffResult.content; } + if (searchPattern) { + const { text: filteredSnapshotText, refs: filteredRefs } = buildSnapshotText(axRoot, null, searchPattern); + return formatSnapshot(filteredSnapshotText, filteredRefs); + } + return fullSnapshot; }; diff --git a/mcp/src/index.js b/mcp/src/index.js index d966fc2..3f35e01 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -133,7 +133,7 @@ Variables: state Persistent object across calls (cleared on reset). Store your working page here. Helpers: - snapshot({ selector?, search? }) Accessibility tree as text. 10-100x cheaper than screenshots. + snapshot({ selector?, search?, showDiffSinceLastCall? }) Accessibility tree as text. 10-100x cheaper than screenshots. refToLocator({ ref }) Resolve a snapshot ref (e.g., e3) to a Playwright locator string. waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState). getLogs({ count? }) Browser console logs captured for current page. @@ -391,6 +391,7 @@ If timeout: Increase timeout param, or break into smaller steps snapshot(options?) options.selector CSS selector to scope the snapshot (e.g., '#main', '.sidebar') options.search Regex string to filter tree nodes (e.g., 'button|link') + options.showDiffSinceLastCall When true (default), returns a smart diff from previous snapshot when unchanged scope+search is not used Returns: Text accessibility tree with interactive element refs waitForPageLoad(options?) diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 7008e4f..08377df 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -130,6 +130,7 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('cookie') || promptBlock.includes('consent'), 'should include consent modal handling'); assert.ok(promptBlock.includes('stale locator'), 'should include stale locator warning'); assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); + assert.ok(promptBlock.includes('options.showDiffSinceLastCall'), 'should document snapshot diff toggle in API reference'); }); it('execute prompt includes tool-selection and debugging decision trees', () => { @@ -193,6 +194,14 @@ describe('Tool Definitions', () => { assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); assert.ok(source.includes('const getCDPSession = async'), 'exec engine should define getCDPSession helper'); + assert.ok( + source.includes('No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'), + 'exec engine should return snapshot no-change guidance' + ); + assert.ok( + source.includes('!search && showDiffSinceLastCall') || source.includes('showDiffSinceLastCall && !search'), + 'snapshot diff mode should only run when search is not provided' + ); }); }); From 1f50d6172eb22458418691c32ef379a6150d3992 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:02:43 +0530 Subject: [PATCH 033/192] fix(mcp): diff snapshot only for full-page views --- mcp/src/exec-engine.js | 2 +- mcp/test/mcp-tools.test.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 9c5ca6d..c4f2e86 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -451,7 +451,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { const previousSnapshot = pageSnapshots.get(snapshotKey); pageSnapshots.set(snapshotKey, fullSnapshot); - if (!search && showDiffSinceLastCall && previousSnapshot) { + if (!selector && !search && showDiffSinceLastCall && previousSnapshot) { const diffResult = createSmartDiff(previousSnapshot, fullSnapshot); if (diffResult.type === 'no-change') { return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 08377df..fcefc4b 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -199,8 +199,9 @@ describe('Tool Definitions', () => { 'exec engine should return snapshot no-change guidance' ); assert.ok( - source.includes('!search && showDiffSinceLastCall') || source.includes('showDiffSinceLastCall && !search'), - 'snapshot diff mode should only run when search is not provided' + source.includes('!selector && !search && showDiffSinceLastCall') || + source.includes('showDiffSinceLastCall && !selector && !search'), + 'snapshot diff mode should only run for full-page snapshots with no search' ); }); }); From eb37c44fca0e0d8c7ad8eced3322abedf59ec088 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:27:05 +0530 Subject: [PATCH 034/192] feat(relay): add jsonl cdp traffic logging with playwriter-style directions --- relay/src/cdp-log.js | 66 +++++++++++++++++++++++++++++++ relay/src/index.js | 92 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 151 insertions(+), 7 deletions(-) create mode 100644 relay/src/cdp-log.js diff --git a/relay/src/cdp-log.js b/relay/src/cdp-log.js new file mode 100644 index 0000000..84ce83a --- /dev/null +++ b/relay/src/cdp-log.js @@ -0,0 +1,66 @@ +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const BF_DIR = path.join(os.homedir(), '.browserforce'); +const LOG_CDP_FILE_PATH = process.env.BROWSERFORCE_CDP_LOG_FILE_PATH || path.join(BF_DIR, 'cdp.jsonl'); +const DEFAULT_MAX_STRING_LENGTH = 2000; + +function resolveMaxStringLength(maxStringLength) { + if (Number.isFinite(maxStringLength) && maxStringLength > 0) { + return Math.floor(maxStringLength); + } + const fromEnv = Number(process.env.BROWSERFORCE_CDP_LOG_MAX_STRING_LENGTH); + if (Number.isFinite(fromEnv) && fromEnv > 0) { + return Math.floor(fromEnv); + } + return DEFAULT_MAX_STRING_LENGTH; +} + +function truncateString(value, maxLength) { + if (value.length <= maxLength) { + return value; + } + const truncatedCount = value.length - maxLength; + return `${value.slice(0, maxLength)}...[truncated ${truncatedCount} chars]`; +} + +function createTruncatingCircularReplacer(maxStringLength) { + const seen = new WeakSet(); + return (_key, value) => { + if (typeof value === 'string') { + return truncateString(value, maxStringLength); + } + if (value && typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + } + return value; + }; +} + +function createCdpLogger({ logFilePath, maxStringLength } = {}) { + const resolvedLogFilePath = logFilePath || process.env.BROWSERFORCE_CDP_LOG_FILE_PATH || LOG_CDP_FILE_PATH; + fs.mkdirSync(path.dirname(resolvedLogFilePath), { recursive: true }); + fs.writeFileSync(resolvedLogFilePath, ''); + + const resolvedMaxStringLength = resolveMaxStringLength(maxStringLength); + let queue = Promise.resolve(); + + return { + logFilePath: resolvedLogFilePath, + log(entry) { + const line = JSON.stringify(entry, createTruncatingCircularReplacer(resolvedMaxStringLength)); + queue = queue + .then(() => fs.promises.appendFile(resolvedLogFilePath, `${line}\n`)) + .catch(() => {}); + }, + }; +} + +module.exports = { + LOG_CDP_FILE_PATH, + createCdpLogger, +}; diff --git a/relay/src/index.js b/relay/src/index.js index 5459f5e..f114e3a 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -4,6 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const os = require('node:os'); const { WebSocketServer, WebSocket } = require('ws'); +const { createCdpLogger } = require('./cdp-log.js'); // ─── Constants ─────────────────────────────────────────────────────────────── @@ -151,9 +152,13 @@ class RelayServer { // Pending extension reload ack resolver (at most one at a time) this._extReloadResolve = null; + + // CDP traffic logger, initialized on start. + this.cdpLogger = null; } start({ writeCdpUrl = true } = {}) { + this.cdpLogger = createCdpLogger(); const server = http.createServer((req, res) => this._handleHttp(req, res)); this.extWss = new WebSocketServer({ noServer: true }); @@ -185,6 +190,16 @@ class RelayServer { }); } + _logCdp(entry) { + if (!this.cdpLogger || typeof this.cdpLogger.log !== 'function') { + return; + } + this.cdpLogger.log({ + timestamp: new Date().toISOString(), + ...entry, + }); + } + // ─── HTTP ──────────────────────────────────────────────────────────────── async _handleHttp(req, res) { @@ -535,7 +550,13 @@ class RelayServer { _handleCdpEventFromExt({ tabId, method, params, childSessionId }) { const sessionId = this.tabToSession.get(tabId); - if (!sessionId) return; + if (!sessionId) { + this._logCdp({ + direction: 'from-extension', + message: { method, params, tabId, childSessionId }, + }); + return; + } // Track child sessions (iframes / OOPIFs) if (method === 'Target.attachedToTarget' && params?.sessionId) { @@ -550,6 +571,11 @@ class RelayServer { ? (this.childSessions.get(childSessionId)?.parentSessionId || sessionId) : sessionId; + this._logCdp({ + direction: 'from-extension', + message: { method, params, tabId, sessionId: outerSessionId, childSessionId }, + }); + this._broadcastCdp({ method, params, sessionId: outerSessionId }); } @@ -627,17 +653,25 @@ class RelayServer { async _handleCdpClientMessage(ws, msg) { const { id, method, params, sessionId } = msg; + this._logCdp({ + direction: 'from-playwright', + message: { id, method, params, sessionId }, + }); try { let result; if (sessionId) { - result = await this._forwardToTab(sessionId, method, params); + result = await this._forwardToTab(sessionId, method, params, id); } else { result = await this._handleBrowserCommand(ws, id, method, params); } if (result !== undefined) { const response = { id, result }; if (sessionId) response.sessionId = sessionId; + this._logCdp({ + direction: 'to-playwright', + message: response, + }); ws.send(JSON.stringify(response)); } } catch (err) { @@ -646,6 +680,10 @@ class RelayServer { error: { code: -32000, message: err.message }, }; if (sessionId) response.sessionId = sessionId; + this._logCdp({ + direction: 'to-playwright', + message: response, + }); ws.send(JSON.stringify(response)); } } @@ -663,7 +701,7 @@ class RelayServer { case 'Target.setDiscoverTargets': // Emit targetCreated for all known targets for (const [, target] of this.targets) { - ws.send(JSON.stringify({ + const event = { method: 'Target.targetCreated', params: { targetInfo: { @@ -675,7 +713,12 @@ class RelayServer { browserContextId: DEFAULT_BROWSER_CONTEXT_ID, }, }, - })); + }; + this._logCdp({ + direction: 'to-playwright', + message: event, + }); + ws.send(JSON.stringify(event)); } return {}; @@ -683,6 +726,10 @@ class RelayServer { this.autoAttachEnabled = true; this.autoAttachParams = params; // Respond immediately, then attach tabs asynchronously + this._logCdp({ + direction: 'to-playwright', + message: { id: msgId, result: {} }, + }); ws.send(JSON.stringify({ id: msgId, result: {} })); this._autoAttachAllTabs(ws).catch((e) => { logErr('[relay] Auto-attach error:', e.message); @@ -804,7 +851,7 @@ class RelayServer { } _sendAttachedEvent(ws, sessionId, target) { - ws.send(JSON.stringify({ + const event = { method: 'Target.attachedToTarget', params: { sessionId, @@ -818,7 +865,12 @@ class RelayServer { }, waitingForDebugger: false, }, - })); + }; + this._logCdp({ + direction: 'to-playwright', + message: event, + }); + ws.send(JSON.stringify(event)); } async _createTarget(ws, params) { @@ -891,7 +943,7 @@ class RelayServer { // ─── CDP Command Forwarding ───────────────────────────────────────────── - async _forwardToTab(sessionId, method, params) { + async _forwardToTab(sessionId, method, params, id) { // Main session const target = this.targets.get(sessionId); if (target) { @@ -906,6 +958,16 @@ class RelayServer { target._triggerMethod = method; await this._ensureDebuggerAttached(target, sessionId); } + this._logCdp({ + direction: 'to-extension', + message: { + id, + method, + params: params || {}, + sessionId, + tabId: target.tabId, + }, + }); return this._sendToExt('cdpCommand', { tabId: target.tabId, method, @@ -922,6 +984,18 @@ class RelayServer { if (parentTarget && !parentTarget.debuggerAttached) { await this._ensureDebuggerAttached(parentTarget, parentSessionId); } + this._logCdp({ + direction: 'to-extension', + message: { + id, + method, + params: params || {}, + sessionId, + tabId: child.tabId, + childSessionId: sessionId, + parentSessionId, + }, + }); return this._sendToExt('cdpCommand', { tabId: child.tabId, method, @@ -936,6 +1010,10 @@ class RelayServer { // ─── Broadcast ────────────────────────────────────────────────────────── _broadcastCdp(msg) { + this._logCdp({ + direction: 'to-playwright', + message: msg, + }); const data = JSON.stringify(msg); for (const client of this.clients) { if (client.readyState === WebSocket.OPEN) { From 8b34054ed143fcebe0116df330e6b45f3aae13c1 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:30:44 +0530 Subject: [PATCH 035/192] fix(mcp): align execute schema helpers and add helper exposure test --- mcp/src/index.js | 2 +- mcp/test/exec-engine-plugins.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/mcp/src/index.js b/mcp/src/index.js index 3f35e01..b3bc12d 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -415,7 +415,7 @@ function registerExecuteTool(skillAppendix = '') { 'execute', EXECUTE_PROMPT + skillAppendix, { - code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), + code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/getCDPSession/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'), }, async ({ code, timeout = 30000 }) => { diff --git a/mcp/test/exec-engine-plugins.test.js b/mcp/test/exec-engine-plugins.test.js index 3f87d44..0a22cab 100644 --- a/mcp/test/exec-engine-plugins.test.js +++ b/mcp/test/exec-engine-plugins.test.js @@ -113,6 +113,31 @@ test('buildExecContext exposes screenshot and content helpers in execute scope', assert.equal(typeof ctx.pageMarkdown, 'function'); }); +test('buildExecContext exposes callable ref and CDP helpers', async () => { + const fakeSession = { send: async () => ({}) }; + const page = { + isClosed: () => false, + context: () => ({ + newCDPSession: async (targetPage) => { + assert.equal(targetPage, page); + return fakeSession; + }, + }), + }; + + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + assert.equal(typeof ctx.refToLocator, 'function'); + assert.equal(typeof ctx.getCDPSession, 'function'); + + const session = await ctx.getCDPSession({ page }); + assert.equal(session, fakeSession); + + await assert.rejects( + () => ctx.getCDPSession({ page: { isClosed: () => true } }), + /Cannot create CDP session for closed page/ + ); +}); + test('formatResult returns multi-content for labeled screenshot sentinel', () => { const fakeBuffer = Buffer.from('fake-jpeg-data'); const formatted = formatResult({ From 4d15beb6609be115436c9417847106761d474006 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:35:12 +0530 Subject: [PATCH 036/192] fix(relay): harden cdp logger startup and file permissions --- relay/src/cdp-log.js | 15 +++++++++++++-- relay/src/index.js | 8 +++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/relay/src/cdp-log.js b/relay/src/cdp-log.js index 84ce83a..a04bbdf 100644 --- a/relay/src/cdp-log.js +++ b/relay/src/cdp-log.js @@ -6,6 +6,14 @@ const BF_DIR = path.join(os.homedir(), '.browserforce'); const LOG_CDP_FILE_PATH = process.env.BROWSERFORCE_CDP_LOG_FILE_PATH || path.join(BF_DIR, 'cdp.jsonl'); const DEFAULT_MAX_STRING_LENGTH = 2000; +function chmodBestEffort(filePath, mode) { + try { + fs.chmodSync(filePath, mode); + } catch { + // Best effort only: some platforms/filesystems do not support POSIX modes. + } +} + function resolveMaxStringLength(maxStringLength) { if (Number.isFinite(maxStringLength) && maxStringLength > 0) { return Math.floor(maxStringLength); @@ -43,8 +51,11 @@ function createTruncatingCircularReplacer(maxStringLength) { function createCdpLogger({ logFilePath, maxStringLength } = {}) { const resolvedLogFilePath = logFilePath || process.env.BROWSERFORCE_CDP_LOG_FILE_PATH || LOG_CDP_FILE_PATH; - fs.mkdirSync(path.dirname(resolvedLogFilePath), { recursive: true }); - fs.writeFileSync(resolvedLogFilePath, ''); + const logDir = path.dirname(resolvedLogFilePath); + fs.mkdirSync(logDir, { recursive: true }); + chmodBestEffort(logDir, 0o700); + fs.writeFileSync(resolvedLogFilePath, '', { mode: 0o600 }); + chmodBestEffort(resolvedLogFilePath, 0o600); const resolvedMaxStringLength = resolveMaxStringLength(maxStringLength); let queue = Promise.resolve(); diff --git a/relay/src/index.js b/relay/src/index.js index f114e3a..5326072 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -158,7 +158,13 @@ class RelayServer { } start({ writeCdpUrl = true } = {}) { - this.cdpLogger = createCdpLogger(); + try { + this.cdpLogger = createCdpLogger(); + } catch (err) { + const message = err && err.message ? err.message : String(err); + log('[relay] Warning: CDP logger disabled:', message); + this.cdpLogger = null; + } const server = http.createServer((req, res) => this._handleHttp(req, res)); this.extWss = new WebSocketServer({ noServer: true }); From 84ee6c8ab730a8b6fa52df321d8a301dd91f6d8b Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:35:19 +0530 Subject: [PATCH 037/192] test(mcp): add prompt regression guards for tactical guidance --- mcp/src/index.js | 18 ++++++++++++++++++ mcp/test/mcp-tools.test.js | 17 +++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/mcp/src/index.js b/mcp/src/index.js index 51edc83..1fd080a 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -241,6 +241,21 @@ If snapshot shows [ref=some-id] for an element with a data-testid or id: For text content: const text = await state.page.locator('role=heading').textContent(); +Selector priority: + 1. Use [ref=...] locators from snapshot output immediately after observing + 2. Use role/name locators from snapshot + 3. Use stable test IDs (data-testid) if present + 4. Avoid brittle nth()/deep CSS selectors unless no stable option exists + +Before interacting, handle page blockers (cookie/consent banners, age gates, login popups): + const blockers = await snapshot({ search: /cookie|consent|accept|reject|allow|age|verify|login|sign.in/i }); + // Dismiss blockers first, then continue with the main task + +Avoid stale locator usage: + // BAD: using a stale locator from an old snapshot after DOM changes + // GOOD: refresh observation first, then act with new refs/locators + await snapshot(); + ═══ COMMON PATTERNS ═══ Navigate and read: @@ -272,6 +287,9 @@ Wait for specific element: Debug with console logs: return getLogs({ count: 20 }); +When you need the full tree instead of diff output: + return await snapshot({ showDiffSinceLastCall: false }); + ═══ ANTI-PATTERNS ═══ ✗ Don't navigate the user's existing tabs — create your own via context.newPage() diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 4bb507d..64e9e37 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -113,6 +113,23 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('ANTI-PATTERN') || promptBlock.includes('Don\'t') || promptBlock.includes('✗'), 'should include anti-patterns'); }); + it('execute prompt includes tactical anti-pattern and decision guidance', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok(promptBlock.includes('Selector priority'), 'should include selector ranking guidance'); + assert.ok(promptBlock.includes('login popups'), 'should include login popup handling'); + assert.ok(promptBlock.includes('cookie') || promptBlock.includes('consent'), 'should include consent modal handling'); + assert.ok(promptBlock.includes('stale locator'), 'should include stale locator warning'); + assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); + }); + it('execute tool has code and optional timeout params', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), From 5db8d39e44200c3645ce35f5d1411d49471b160b Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:39:21 +0530 Subject: [PATCH 038/192] feat(mcp): expand execute prompt with tactical web automation playbooks --- mcp/src/index.js | 70 ++++++++++++++++++++++++++++++++++++++ mcp/test/mcp-tools.test.js | 15 ++++++++ 2 files changed, 85 insertions(+) diff --git a/mcp/src/index.js b/mcp/src/index.js index 1fd080a..d46599c 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -256,6 +256,76 @@ Avoid stale locator usage: // GOOD: refresh observation first, then act with new refs/locators await snapshot(); +Typing text with newlines: + // Use fill() for multiline blocks to avoid accidental Enter key submissions + await state.page.locator('role=textbox[name="Message"]').fill('Line 1\\nLine 2'); + +═══ TACTICAL ANTI-PATTERNS ═══ + +Popup control: + ✗ Don’t click through a popup without confirming what changed + ✓ Dismiss popup, then run snapshot() immediately to confirm main UI is usable + +Consent blockers: + ✗ Don’t continue form/page actions while consent banners block focus + ✓ Handle cookie/consent overlays first, then retry the intended action + +Stale locators: + ✗ Don’t reuse [ref=...] values after DOM/nav updates + ✓ Refresh snapshot() and use the newest refs/role locators + +Newline typing: + ✗ Don’t use keyboard Enter loops for multiline textareas unless explicitly needed + ✓ Prefer locator.fill('line1\\nline2') for deterministic multiline input + +═══ EXTRACTION DECISION TREE ═══ + +snapshot vs cleanHTML vs pageMarkdown: + 1) Use snapshot() when you need current interactive structure, labels, and refs. + 2) Use cleanHTML(selector?) when you need structured DOM content for parsing/extraction. + 3) Use pageMarkdown() for article/blog/news pages where nav/ads should be removed. + 4) Use screenshotWithAccessibilityLabels() only when layout/visual evidence is required. + +═══ DEBUGGING WORKFLOW ═══ + +Combine snapshot + logs: + 1) snapshot({ search: /target text|button|error/i }) to verify element presence and naming + 2) getLogs({ count: 30 }) for runtime/network/console errors + 3) page.evaluate(() => { ...visibility checks... }) to validate hidden/disabled/overlay states + +Example visibility check: + return await state.page.evaluate(() => { + const el = document.querySelector('[data-testid="submit"]'); + if (!el) return { found: false }; + const s = getComputedStyle(el); + const r = el.getBoundingClientRect(); + return { found: true, visible: s.display !== 'none' && s.visibility !== 'hidden' && r.width > 0 && r.height > 0 }; + }); + +═══ ADVANCED PATTERNS ═══ + +Authenticated fetch: + // Reuse browser session cookies/headers from the current page context + return await state.page.evaluate(async () => { + const res = await fetch('/api/me', { credentials: 'include' }); + return { status: res.status, body: await res.text() }; + }); + +Network interception: + await state.page.route('**/api/**', async (route) => { + const request = route.request(); + // Inspect/modify request here if needed before continuing + await route.continue(); + }); + +Downloads: + // Use expect_download pattern and save path after click/navigation trigger + const [download] = await Promise.all([ + state.page.waitForEvent('download'), + state.page.locator('role=button[name="Export CSV"]').click(), + ]); + return { suggestedFilename: download.suggestedFilename() }; + ═══ COMMON PATTERNS ═══ Navigate and read: diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 64e9e37..dcff140 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -130,6 +130,21 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); }); + it('execute prompt includes tool-selection and debugging decision trees', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok(promptBlock.includes('snapshot vs cleanHTML vs pageMarkdown'), 'should include extraction decision tree'); + assert.ok(promptBlock.includes('Combine snapshot + logs'), 'should include debugging workflow'); + assert.ok(promptBlock.includes('Authenticated fetch'), 'should include authenticated fetch pattern'); + assert.ok(promptBlock.includes('Downloads'), 'should include download pattern'); + }); + it('execute tool has code and optional timeout params', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), From cba8c89247319f0fd8ec39221851ea75aa105d96 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:45:03 +0530 Subject: [PATCH 039/192] feat(mcp): expose refToLocator helper in execute context --- mcp/src/exec-engine.js | 13 ++++++++++++- mcp/src/index.js | 8 +++++--- mcp/test/mcp-tools.test.js | 10 ++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 96ca77c..64fb6fd 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -409,6 +409,8 @@ export class CodeExecutionTimeoutError extends Error { // instead of referencing module-level singletons. export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}, pluginHelpers = {}) { const { consoleLogs, setupConsoleCapture } = consoleHelpers; + const lastSnapshots = userState.__lastSnapshots || (userState.__lastSnapshots = new WeakMap()); + const lastRefToLocator = userState.__lastRefToLocator || (userState.__lastRefToLocator = new WeakMap()); const activePage = () => { if (userState.page && !userState.page.isClosed()) return userState.page; @@ -424,6 +426,8 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { annotateStableAttrs(axRoot, stableIds); const searchPattern = parseSearchPattern(search); const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern); + const refMap = new Map(refs.map(({ ref, locator }) => [ref, locator])); + lastRefToLocator.set(page, refMap); const refTable = refs.length > 0 ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') : ''; @@ -432,6 +436,13 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; }; + const refToLocator = ({ ref, page: targetPage } = {}) => { + const p = targetPage || activePage(); + const map = lastRefToLocator.get(p); + if (!map) return null; + return map.get(ref) ?? null; + }; + const waitForPageLoad = (opts = {}) => smartWaitForPageLoad(activePage(), opts.timeout ?? 30000); @@ -473,7 +484,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win page: defaultPage, context: ctx, state: userState, - snapshot, waitForPageLoad, getLogs, clearLogs, + snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, diff --git a/mcp/src/index.js b/mcp/src/index.js index d46599c..b0c6ca8 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -134,6 +134,7 @@ Variables: Helpers: snapshot({ selector?, search? }) Accessibility tree as text. 10-100x cheaper than screenshots. + refToLocator({ ref }) Resolve a snapshot ref (e.g., e3) to a Playwright locator string. waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState). getLogs({ count? }) Browser console logs captured for current page. clearLogs() Clear captured console logs. @@ -235,8 +236,9 @@ Use Playwright locators with accessibility roles (from snapshot output): await state.page.locator('role=textbox[name="Search"]').fill('query'); await state.page.locator('role=link[name="Settings"]').click(); -If snapshot shows [ref=some-id] for an element with a data-testid or id: - await state.page.locator('[data-testid="some-id"]').click(); +If snapshot shows [ref=e3], resolve it with refToLocator({ ref }) before acting: + const locator = refToLocator({ ref: 'e3' }); + if (locator) await state.page.locator(locator).click(); For text content: const text = await state.page.locator('role=heading').textContent(); @@ -406,7 +408,7 @@ function registerExecuteTool(skillAppendix = '') { 'execute', EXECUTE_PROMPT + skillAppendix, { - code: z.string().describe('JavaScript to run — page/context/state/snapshot/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), + code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'), }, async ({ code, timeout = 30000 }) => { diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index dcff140..261c87e 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -106,6 +106,7 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('snapshot'), 'should mention snapshot-first approach'); assert.ok(promptBlock.includes('waitForPageLoad'), 'should mention waitForPageLoad'); assert.ok(promptBlock.includes('screenshotWithAccessibilityLabels'), 'should mention screenshotWithAccessibilityLabels helper'); + assert.ok(promptBlock.includes('refToLocator({ ref })'), 'should mention refToLocator helper usage'); assert.ok(promptBlock.includes('cleanHTML'), 'should mention cleanHTML helper'); assert.ok(promptBlock.includes('pageMarkdown'), 'should mention pageMarkdown helper'); assert.ok(promptBlock.includes('newPage'), 'should mention creating new tabs'); @@ -182,6 +183,15 @@ describe('Tool Definitions', () => { assert.ok(!source.includes("'screenshot_with_labels'"), 'screenshot_with_labels tool should be removed'); assert.ok(!source.includes('SCREENSHOT_LABELS_PROMPT'), 'dedicated screenshot prompt should be removed'); }); + + it('exec context source exposes refToLocator helper', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/exec-engine.js'), + 'utf8' + ); + + assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); + }); }); // ─── MCP Response Format ───────────────────────────────────────────────────── From 8eb92a8ced69789fd731811b2bc2643899e8de60 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:50:37 +0530 Subject: [PATCH 040/192] feat(mcp): add getCDPSession helper for relay-safe raw CDP access --- mcp/src/exec-engine.js | 10 +++++++++- mcp/src/index.js | 6 ++++++ mcp/test/mcp-tools.test.js | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 64fb6fd..de50de4 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -458,6 +458,14 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { if (consoleLogs) consoleLogs.set(activePage(), []); }; + const getCDPSession = async ({ page: targetPage } = {}) => { + const p = targetPage || activePage(); + if (!p || p.isClosed()) { + throw new Error('Cannot create CDP session for closed page'); + } + return p.context().newCDPSession(p); + }; + const screenshotWithAccessibilityLabels = async ({ selector, interactiveOnly = true } = {}) => { const page = activePage(); const { screenshot, snapshot: snapText, labelCount } = await screenshotWithLabels(page, { @@ -484,7 +492,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win page: defaultPage, context: ctx, state: userState, - snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, + snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, getCDPSession, screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, diff --git a/mcp/src/index.js b/mcp/src/index.js index b0c6ca8..d966fc2 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -148,6 +148,8 @@ Helpers: pageMarkdown() Article content via Mozilla Readability (Firefox Reader View). Strips nav/ads/sidebars. Returns title + metadata + body text. Falls back to raw body text for non-article pages. + getCDPSession({ page }) Create a relay-safe raw CDP session for a page. + Use this instead of page.context().newCDPSession(page). Globals: fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder @@ -280,6 +282,10 @@ Newline typing: ✗ Don’t use keyboard Enter loops for multiline textareas unless explicitly needed ✓ Prefer locator.fill('line1\\nline2') for deterministic multiline input +Raw CDP sessions: + ✗ Don’t call page.context().newCDPSession(page) directly + ✓ Use getCDPSession({ page }) for relay-safe CDP session creation + ═══ EXTRACTION DECISION TREE ═══ snapshot vs cleanHTML vs pageMarkdown: diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 261c87e..7008e4f 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -107,6 +107,7 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('waitForPageLoad'), 'should mention waitForPageLoad'); assert.ok(promptBlock.includes('screenshotWithAccessibilityLabels'), 'should mention screenshotWithAccessibilityLabels helper'); assert.ok(promptBlock.includes('refToLocator({ ref })'), 'should mention refToLocator helper usage'); + assert.ok(promptBlock.includes('getCDPSession({ page })'), 'should mention relay-safe getCDPSession helper usage'); assert.ok(promptBlock.includes('cleanHTML'), 'should mention cleanHTML helper'); assert.ok(promptBlock.includes('pageMarkdown'), 'should mention pageMarkdown helper'); assert.ok(promptBlock.includes('newPage'), 'should mention creating new tabs'); @@ -191,6 +192,7 @@ describe('Tool Definitions', () => { ); assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); + assert.ok(source.includes('const getCDPSession = async'), 'exec engine should define getCDPSession helper'); }); }); From 16c7cd9ab41a743c4cd3fdd081940dc5f1d6e2f7 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 17:56:49 +0530 Subject: [PATCH 041/192] feat(mcp): add snapshot diff mode with showDiffSinceLastCall toggle --- mcp/src/exec-engine.js | 45 ++++++++++++++++++++++++++++++++------ mcp/src/index.js | 3 ++- mcp/test/mcp-tools.test.js | 9 ++++++++ 3 files changed, 49 insertions(+), 8 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index de50de4..dd4962b 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -418,22 +418,53 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { throw new Error('No active page. Create one first: state.page = await context.newPage()'); }; - const snapshot = async ({ selector, search } = {}) => { + const snapshot = async ({ selector, search, showDiffSinceLastCall = true } = {}) => { const page = activePage(); const axRoot = await getAccessibilityTree(page, selector); if (!axRoot) return 'No accessibility tree available for this page.'; const stableIds = await getStableIds(page, selector); annotateStableAttrs(axRoot, stableIds); const searchPattern = parseSearchPattern(search); - const { text: snapshotText, refs } = buildSnapshotText(axRoot, null, searchPattern); - const refMap = new Map(refs.map(({ ref, locator }) => [ref, locator])); + const { text: fullSnapshotText, refs: fullRefs } = buildSnapshotText(axRoot, null, null); + const refMap = new Map(fullRefs.map(({ ref, locator }) => [ref, locator])); lastRefToLocator.set(page, refMap); - const refTable = refs.length > 0 - ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') - : ''; const title = await page.title().catch(() => ''); const pageUrl = page.url(); - return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + const formatSnapshot = (snapshotText, refs) => { + const refTable = refs.length > 0 + ? '\n\n--- Ref → Locator ---\n' + refs.map(r => `${r.ref}: ${r.locator}`).join('\n') + : ''; + return `Page: ${title} (${pageUrl})\nRefs: ${refs.length} interactive elements\n\n${snapshotText}${refTable}`; + }; + const fullSnapshot = formatSnapshot(fullSnapshotText, fullRefs); + + let pageSnapshots = lastSnapshots.get(page); + if (!(pageSnapshots instanceof Map)) { + const migratedSnapshots = new Map(); + if (typeof pageSnapshots === 'string') { + migratedSnapshots.set('__full_page__', pageSnapshots); + } + pageSnapshots = migratedSnapshots; + lastSnapshots.set(page, pageSnapshots); + } + const snapshotKey = selector || '__full_page__'; + const previousSnapshot = pageSnapshots.get(snapshotKey); + pageSnapshots.set(snapshotKey, fullSnapshot); + + if (!search && showDiffSinceLastCall && previousSnapshot) { + const diffResult = createSmartDiff(previousSnapshot, fullSnapshot); + if (diffResult.type === 'no-change') { + return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; + } + return diffResult.content; + } + + if (searchPattern) { + const { text: filteredSnapshotText, refs: filteredRefs } = buildSnapshotText(axRoot, null, searchPattern); + return formatSnapshot(filteredSnapshotText, filteredRefs); + } + + return fullSnapshot; }; const refToLocator = ({ ref, page: targetPage } = {}) => { diff --git a/mcp/src/index.js b/mcp/src/index.js index d966fc2..3f35e01 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -133,7 +133,7 @@ Variables: state Persistent object across calls (cleared on reset). Store your working page here. Helpers: - snapshot({ selector?, search? }) Accessibility tree as text. 10-100x cheaper than screenshots. + snapshot({ selector?, search?, showDiffSinceLastCall? }) Accessibility tree as text. 10-100x cheaper than screenshots. refToLocator({ ref }) Resolve a snapshot ref (e.g., e3) to a Playwright locator string. waitForPageLoad({ timeout? }) Smart load detection (filters analytics/ads, polls readyState). getLogs({ count? }) Browser console logs captured for current page. @@ -391,6 +391,7 @@ If timeout: Increase timeout param, or break into smaller steps snapshot(options?) options.selector CSS selector to scope the snapshot (e.g., '#main', '.sidebar') options.search Regex string to filter tree nodes (e.g., 'button|link') + options.showDiffSinceLastCall When true (default), returns a smart diff from previous snapshot when unchanged scope+search is not used Returns: Text accessibility tree with interactive element refs waitForPageLoad(options?) diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 7008e4f..08377df 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -130,6 +130,7 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('cookie') || promptBlock.includes('consent'), 'should include consent modal handling'); assert.ok(promptBlock.includes('stale locator'), 'should include stale locator warning'); assert.ok(promptBlock.includes('snapshot({ showDiffSinceLastCall'), 'should include diff usage guidance'); + assert.ok(promptBlock.includes('options.showDiffSinceLastCall'), 'should document snapshot diff toggle in API reference'); }); it('execute prompt includes tool-selection and debugging decision trees', () => { @@ -193,6 +194,14 @@ describe('Tool Definitions', () => { assert.ok(source.includes('refToLocator'), 'exec engine should expose refToLocator helper'); assert.ok(source.includes('const getCDPSession = async'), 'exec engine should define getCDPSession helper'); + assert.ok( + source.includes('No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'), + 'exec engine should return snapshot no-change guidance' + ); + assert.ok( + source.includes('!search && showDiffSinceLastCall') || source.includes('showDiffSinceLastCall && !search'), + 'snapshot diff mode should only run when search is not provided' + ); }); }); From 032a2da21b7c39747e94781f6affd4e27b458719 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:02:43 +0530 Subject: [PATCH 042/192] fix(mcp): diff snapshot only for full-page views --- mcp/src/exec-engine.js | 2 +- mcp/test/mcp-tools.test.js | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index dd4962b..b0d59e1 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -451,7 +451,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { const previousSnapshot = pageSnapshots.get(snapshotKey); pageSnapshots.set(snapshotKey, fullSnapshot); - if (!search && showDiffSinceLastCall && previousSnapshot) { + if (!selector && !search && showDiffSinceLastCall && previousSnapshot) { const diffResult = createSmartDiff(previousSnapshot, fullSnapshot); if (diffResult.type === 'no-change') { return 'No changes since last snapshot. Use showDiffSinceLastCall: false to see full content.'; diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 08377df..fcefc4b 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -199,8 +199,9 @@ describe('Tool Definitions', () => { 'exec engine should return snapshot no-change guidance' ); assert.ok( - source.includes('!search && showDiffSinceLastCall') || source.includes('showDiffSinceLastCall && !search'), - 'snapshot diff mode should only run when search is not provided' + source.includes('!selector && !search && showDiffSinceLastCall') || + source.includes('showDiffSinceLastCall && !selector && !search'), + 'snapshot diff mode should only run for full-page snapshots with no search' ); }); }); From cd12e5798bd2b506d8cd64ab7c82e3573dea7007 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:30:44 +0530 Subject: [PATCH 043/192] fix(mcp): align execute schema helpers and add helper exposure test --- mcp/src/index.js | 2 +- mcp/test/exec-engine-plugins.test.js | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/mcp/src/index.js b/mcp/src/index.js index 3f35e01..b3bc12d 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -415,7 +415,7 @@ function registerExecuteTool(skillAppendix = '') { 'execute', EXECUTE_PROMPT + skillAppendix, { - code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), + code: z.string().describe('JavaScript to run — page/context/state/snapshot/refToLocator/getCDPSession/waitForPageLoad/getLogs/cleanHTML/pageMarkdown in scope'), timeout: z.number().optional().describe('Max execution time in ms (default: 30000)'), }, async ({ code, timeout = 30000 }) => { diff --git a/mcp/test/exec-engine-plugins.test.js b/mcp/test/exec-engine-plugins.test.js index 05e3907..150a204 100644 --- a/mcp/test/exec-engine-plugins.test.js +++ b/mcp/test/exec-engine-plugins.test.js @@ -45,6 +45,31 @@ test('buildExecContext exposes screenshot and content helpers in execute scope', assert.equal(typeof ctx.pageMarkdown, 'function'); }); +test('buildExecContext exposes callable ref and CDP helpers', async () => { + const fakeSession = { send: async () => ({}) }; + const page = { + isClosed: () => false, + context: () => ({ + newCDPSession: async (targetPage) => { + assert.equal(targetPage, page); + return fakeSession; + }, + }), + }; + + const ctx = buildExecContext(page, { pages: () => [page] }, {}, {}, {}); + assert.equal(typeof ctx.refToLocator, 'function'); + assert.equal(typeof ctx.getCDPSession, 'function'); + + const session = await ctx.getCDPSession({ page }); + assert.equal(session, fakeSession); + + await assert.rejects( + () => ctx.getCDPSession({ page: { isClosed: () => true } }), + /Cannot create CDP session for closed page/ + ); +}); + test('formatResult returns multi-content for labeled screenshot sentinel', () => { const fakeBuffer = Buffer.from('fake-jpeg-data'); const formatted = formatResult({ From 7a76e8694c31191b93162b1a8479e0a5d14a2804 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 18:39:40 +0530 Subject: [PATCH 044/192] docs: document diff-aware helpers and cdp jsonl logging --- GUIDE.md | 17 +++++++++++++++++ README.md | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index f798650..d831550 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -250,6 +250,17 @@ When connected via MCP (OpenClaw, Claude Desktop, Claude Code), the AI has two t | `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, `screenshotWithAccessibilityLabels()`, `cleanHTML()`, `pageMarkdown()`, and Node.js globals. | | `reset` | Reconnect to the relay and clear state. Use when the connection drops. | +### Diff-Aware Helpers + +Use `showDiffSinceLastCall` to control diff output vs full output in execute helper calls: + +```javascript +await snapshot({ showDiffSinceLastCall: true }); +await snapshot({ showDiffSinceLastCall: false }); +await cleanHTML('body', { showDiffSinceLastCall: false }); +await pageMarkdown({ showDiffSinceLastCall: true }); +``` + The `execute` tool gives the agent full Playwright access — it can navigate, click, type, screenshot, read accessibility trees, and run JavaScript in the page context. All within your real browser session. ## Examples @@ -416,3 +427,9 @@ A: Yes. All tabs across all Chrome windows are visible. | AI sees 0 pages | Open at least one regular webpage (not `chrome://`) | | Extension keeps disconnecting | Normal MV3 behavior — it auto-reconnects | | Port already in use | Run `lsof -ti:19222 \| xargs kill -9` to kill stale process | + +CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay start). Summarize traffic by direction + method: + +```bash +jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c +``` diff --git a/README.md b/README.md index 7750656..294fdb0 100644 --- a/README.md +++ b/README.md @@ -357,6 +357,17 @@ state.results = await page.evaluate(() => document.title); | `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, `screenshotWithAccessibilityLabels()`, `cleanHTML()`, `pageMarkdown()`, and Node.js globals. | | `reset` | Reconnect to the relay and clear state. Use when the connection drops. | +### Diff-Aware Helpers + +Use `showDiffSinceLastCall` to control diff output vs full output in execute helper calls: + +```javascript +await snapshot({ showDiffSinceLastCall: true }); +await snapshot({ showDiffSinceLastCall: false }); +await cleanHTML('body', { showDiffSinceLastCall: false }); +await pageMarkdown({ showDiffSinceLastCall: true }); +``` + ## Examples Get started with simple prompts. The AI generates code and does the work. @@ -576,4 +587,10 @@ RELAY_PORT=19333 browserforce serve | Extension keeps reconnecting | Normal — MV3 kills idle workers; it auto-recovers | | Port in use | `lsof -ti:19222 \| xargs kill -9` | +CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay start). Summarize traffic by direction + method: + +```bash +jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c +``` + > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. From 74fbcaa06348514556982a1fcb50b53b64ab4cb3 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 19:09:24 +0530 Subject: [PATCH 045/192] fix(extension): resync attached tab group naming on group changes --- extension/background.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/extension/background.js b/extension/background.js index efc8447..a3f0cbc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -224,6 +224,8 @@ async function attachTab(tabId, sessionId) { if (attachedTabs.has(tabId)) { const existing = attachedTabs.get(tabId); existing.sessionId = sessionId; + // Ensure attached tabs are always reconciled into the browserforce group. + queueSyncTabGroup(); return existing; } @@ -491,20 +493,27 @@ function onTabRemoved(tabId) { function onTabUpdated(tabId, changeInfo) { if (!attachedTabs.has(tabId)) return; - if (!changeInfo.url && !changeInfo.title) return; + if (!changeInfo.url && !changeInfo.title && changeInfo.groupId === undefined) return; + + // Reconcile group membership/title if user or Chrome moved this attached tab. + if (changeInfo.groupId !== undefined) { + queueSyncTabGroup(); + } const entry = attachedTabs.get(tabId); if (changeInfo.url) entry.targetInfo.url = changeInfo.url; if (changeInfo.title) entry.targetInfo.title = changeInfo.title; - send({ - method: 'tabUpdated', - params: { - tabId, - url: changeInfo.url, - title: changeInfo.title, - }, - }); + if (changeInfo.url || changeInfo.title) { + send({ + method: 'tabUpdated', + params: { + tabId, + url: changeInfo.url, + title: changeInfo.title, + }, + }); + } } // ─── Helpers ───────────────────────────────────────────────────────────────── From 29fa1578a262894d585ec6b53d58057d02f524f4 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 19:09:24 +0530 Subject: [PATCH 046/192] fix(extension): resync attached tab group naming on group changes --- extension/background.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/extension/background.js b/extension/background.js index efc8447..a3f0cbc 100644 --- a/extension/background.js +++ b/extension/background.js @@ -224,6 +224,8 @@ async function attachTab(tabId, sessionId) { if (attachedTabs.has(tabId)) { const existing = attachedTabs.get(tabId); existing.sessionId = sessionId; + // Ensure attached tabs are always reconciled into the browserforce group. + queueSyncTabGroup(); return existing; } @@ -491,20 +493,27 @@ function onTabRemoved(tabId) { function onTabUpdated(tabId, changeInfo) { if (!attachedTabs.has(tabId)) return; - if (!changeInfo.url && !changeInfo.title) return; + if (!changeInfo.url && !changeInfo.title && changeInfo.groupId === undefined) return; + + // Reconcile group membership/title if user or Chrome moved this attached tab. + if (changeInfo.groupId !== undefined) { + queueSyncTabGroup(); + } const entry = attachedTabs.get(tabId); if (changeInfo.url) entry.targetInfo.url = changeInfo.url; if (changeInfo.title) entry.targetInfo.title = changeInfo.title; - send({ - method: 'tabUpdated', - params: { - tabId, - url: changeInfo.url, - title: changeInfo.title, - }, - }); + if (changeInfo.url || changeInfo.title) { + send({ + method: 'tabUpdated', + params: { + tabId, + url: changeInfo.url, + title: changeInfo.title, + }, + }); + } } // ─── Helpers ───────────────────────────────────────────────────────────────── From 91d901d8e1492ec1963fb63d6b5b763599e56084 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 20:29:18 +0530 Subject: [PATCH 047/192] docs: expand controlled-tab guidance and persona use cases --- GUIDE.md | 62 ++++++++- README.md | 59 ++++++--- docs/USE_CASES.md | 326 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 docs/USE_CASES.md diff --git a/GUIDE.md b/GUIDE.md index d831550..bcae7bc 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -215,6 +215,62 @@ for (const page of pages) { } ``` +## Controlled Tabs Playbook + +Use this section when you want strict control over what the agent can touch. + +### 1) Manually Attach A Tab + +1. Open the exact tab you want the agent to use. +2. Click the BrowserForce extension icon. +3. In the popup, click **+ Attach Current Tab**. +4. Confirm it appears under **Controlled Tabs**. + +This is the fastest way to grant access to an already logged-in page without exposing other tabs. + +### 2) Single-Tab Locked Workflow + +For high-safety tasks (admin pages, billing pages, production dashboards): + +1. Set **Mode** to `Manual`. +2. Attach only one tab using **+ Attach Current Tab**. +3. Enable **No new tabs**. +4. Optionally enable **Lock URL** and **Read-only** depending on the task. + +Result: the agent is constrained to one attached tab and cannot open additional tabs. + +### 3) Multi-Tab Controlled Workflow + +If the task needs a few trusted tabs: + +1. Keep **Mode** on `Manual`. +2. Switch to each required tab and click **+ Attach Current Tab**. +3. Keep **No new tabs** on if you want to block any extra tab creation. + +Result: the agent can work only across the tabs you explicitly attached. + +### 4) Restriction Modes (How To Combine Them) + +- **Lock URL**: blocks navigation away from the current page (reload is still possible). +- **No new tabs**: blocks agent-driven tab creation. +- **Read-only**: blocks interaction methods (click/type/edit); useful for inspection-only runs. + +Common presets: + +- **Audit preset**: `Manual + No new tabs + Read-only` +- **Form testing preset**: `Manual + No new tabs` (leave Read-only off) +- **Pinned page preset**: `Manual + Lock URL + No new tabs` + +### 5) Auto-Cleanup After Use + +- **Auto-detach inactive tabs**: detaches tabs after 5-60 minutes of inactivity. +- **Auto-close agent tabs**: closes tabs created by the agent after 5-60 minutes. + +Recommended: + +- Use `10-15 min` auto-detach for normal sessions. +- Use auto-close when running broad exploration tasks that open many tabs. + ## CLI Once installed globally (`npm install -g browserforce`), the CLI is available: @@ -261,6 +317,8 @@ await cleanHTML('body', { showDiffSinceLastCall: false }); await pageMarkdown({ showDiffSinceLastCall: true }); ``` +Need concrete persona-based workflows? See [Actionable Use Cases](docs/USE_CASES.md). + The `execute` tool gives the agent full Playwright access — it can navigate, click, type, screenshot, read accessibility trees, and run JavaScript in the page context. All within your real browser session. ## Examples @@ -413,7 +471,7 @@ A: Any AI that supports MCP (OpenClaw, Claude Desktop, Claude Code) or any tool A: Chrome aggressively kills MV3 extensions after 30 seconds of inactivity. The relay sends keepalive pings every 5 seconds to prevent this. If the extension does restart, it auto-reconnects. **Q: Can I control which tabs the AI accesses?** -A: Yes. Click the extension icon to switch between Auto mode (agent sees all tabs) and Manual mode (you select which tabs). You can also lock URLs, block new tabs, or enable read-only mode. +A: Yes. In Auto mode the agent can create and control its own tabs. In Manual mode, you explicitly attach tabs with **+ Attach Current Tab**. You can also lock URLs, block new tabs, or enable read-only mode. **Q: Does it work with multiple windows?** A: Yes. All tabs across all Chrome windows are visible. @@ -433,3 +491,5 @@ CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay st ```bash jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c ``` + +For incident/debug playbooks, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). diff --git a/README.md b/README.md index 294fdb0..488e651 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **You're giving an AI your real Chrome — your logins, cookies, and sessions. That takes conviction.** BrowserForce is built for people who use the best models and don't look back. Security is built in: lock URLs, block navigation, read-only mode, auto-cleanup — you stay in control. -**Fully autonomous browser control.** No manual tab clicking. Your agent browses as you, even from WhatsApp. Other tools make you click each tab, spawn a fresh Chrome, or only work with one AI client. BrowserForce connects to **your running browser** and auto-attaches to all tabs. One Chrome extension, full Playwright API, completely hands-off. +**Autonomous when you want it, controlled when you need it.** Your agent can run hands-off in Auto mode, or you can switch to Manual mode and explicitly attach only the tabs you trust. BrowserForce connects to **your running browser** with one Chrome extension and full Playwright API support. Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP-compatible agent. @@ -16,10 +16,10 @@ Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP- |---|---|---|---|---|---| | Browser | Spawns new Chrome | Separate profile | Your Chrome | Your Chrome | **Your Chrome** | | Login state | Fresh | Fresh (isolated) | Yours | Yours | **Yours** | -| Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **All tabs, automatic** | +| Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **Auto mode + manual attached tabs** | | Autonomous | Yes | Yes | No (manual click) | No (manual click) | **Yes (fully autonomous)** | | Context method | Screenshots (100KB+) | Screenshots + snapshots | A11y snapshots (5-20KB) | Screenshots (100KB+) | **A11y snapshots (5-20KB)** | -| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **3 tools: `execute`, `screenshot_with_labels`, `reset`** | +| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **2 tools: `execute`, `reset`** | | Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** | | Playwright API | Partial | No | Full | No | **Full** | @@ -108,10 +108,10 @@ browserforce serve If your agent browses to the page and responds with the title, you're all set. -
-MCP setup for OpenClaw, Claude, Codex, Cursor, and Antigravity +**MCP setup (advanced):** -#### OpenClaw (MCP adapter) +
+OpenClaw (MCP adapter) Add to `~/.openclaw/openclaw.json`: @@ -137,7 +137,10 @@ Add to `~/.openclaw/openclaw.json`: } ``` -#### Claude Desktop +
+ +
+Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -152,7 +155,10 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` -#### Claude Code +
+ +
+Claude Code Add to `~/.claude/mcp.json`: @@ -167,7 +173,10 @@ Add to `~/.claude/mcp.json`: } ``` -#### Codex +
+ +
+Codex Add to `~/.codex/config.toml`: @@ -177,7 +186,10 @@ command = "npx" args = ["-y", "browserforce@latest", "mcp"] ``` -#### Cursor +
+ +
+Cursor Add to `~/.cursor/mcp.json`: @@ -192,7 +204,10 @@ Add to `~/.cursor/mcp.json`: } ``` -#### Antigravity +
+ +
+Antigravity In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. Add the same `mcpServers` entry: @@ -208,14 +223,14 @@ Add the same `mcpServers` entry: } ``` +
+ If MCP startup fails with `connection closed: initialize response`: 1. Ensure args include `"mcp"` (without it, BrowserForce prints help and exits). 2. If running from a local clone, install deps first: `pnpm install`. 3. Validate the launch command manually: `npx -y browserforce@latest mcp` -
- ### CLI ```bash @@ -368,6 +383,8 @@ await cleanHTML('body', { showDiffSinceLastCall: false }); await pageMarkdown({ showDiffSinceLastCall: true }); ``` +Need role-based, real workflows? See [Actionable Use Cases](docs/USE_CASES.md). + ## Examples Get started with simple prompts. The AI generates code and does the work. @@ -519,9 +536,9 @@ Get started with simple prompts. The AI generates code and does the work. The **relay server** runs on your machine (localhost only). It translates between the agent's CDP commands and the extension's debugger bridge. -The **Chrome extension** lives in your browser. It attaches Chrome's built-in debugger to your tabs and forwards commands — exactly like DevTools does. +The **Chrome extension** lives in your browser. It attaches Chrome's built-in debugger to permitted tabs and forwards commands — exactly like DevTools does. -When the agent connects, it immediately sees all your open tabs as controllable Playwright pages. No clicking, no manual attachment. +In **Auto mode**, the agent can create and control tabs it opens. In **Manual mode**, you decide access by clicking **+ Attach Current Tab**. ## You Stay in Control @@ -537,6 +554,16 @@ Click the extension icon to configure restrictions. Your browser, your rules: | **Auto-close** | Automatically close agent-created tabs after 5-60 minutes | | **Custom instructions** | Pass text instructions to the agent (e.g. "don't click any buy buttons") | +### Controlled Tab Workflows + +- **Manually attach a tab:** Open the tab you want, click the extension popup, then click **+ Attach Current Tab**. +- **Restrict to one controlled tab:** Use **Manual mode**, attach one tab, and enable **No new tabs**. +- **Allow multiple controlled tabs:** Stay in **Manual mode** and attach each tab you want the agent to access. +- **Restriction modes:** Use **Lock URL** (no navigation), **No new tabs**, and **Read-only** (observe only) together or separately. +- **Auto-cleanup:** Use **Auto-detach** for inactive attached tabs and **Auto-close** for agent-created tabs. + +For step-by-step setups, see the [Controlled Tabs Playbook](GUIDE.md#controlled-tabs-playbook). + ## Security | Layer | Control | @@ -593,4 +620,6 @@ CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay st jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c ``` +For practical debugging and operations flows, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). + > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md new file mode 100644 index 0000000..8b8bbf7 --- /dev/null +++ b/docs/USE_CASES.md @@ -0,0 +1,326 @@ +# BrowserForce Use Cases (Actionable) + +This page is for real-world execution, not theory. Each section includes: + +- What role this is for +- What you are trying to achieve +- Which BrowserForce switches/helpers to use +- A copy-paste example +- What success looks like + +## Quick Switch Guide + +| Switch / Helper | Use it when | Typical outcome | +|---|---|---| +| `snapshot({ showDiffSinceLastCall: true })` | You are in a multi-step flow and want only changes | Faster loops, lower token usage, less noise | +| `snapshot({ showDiffSinceLastCall: false })` | You need full context right now | Full tree and refs for reliable decisions | +| `cleanHTML(selector, { showDiffSinceLastCall: true })` | You monitor DOM changes over time | Detect only meaningful structural changes | +| `cleanHTML(selector, { showDiffSinceLastCall: false })` | You need full HTML snapshot for parsing | Complete cleaned HTML for extraction | +| `pageMarkdown({ showDiffSinceLastCall: true })` | You monitor long-form content/pages | Alert only on content changes | +| `pageMarkdown({ search: /.../ })` | You need targeted text checks | Focused findings with context lines | +| `refToLocator({ ref: 'eN' })` | You got a ref from `snapshot()` and need a stable locator | Reliable interaction without brittle selectors | +| `getCDPSession({ page })` | You need low-level CDP commands in relay environment | Raw CDP access with relay-safe session creation | + +## Feature-by-Feature Use Cases (High Impact First) + +This section maps each newly added capability to practical scenarios by user type. + +### 1) `snapshot({ showDiffSinceLastCall })` (Most Impactful) + +**Why this is high impact:** It cuts repeated context noise in long flows and makes automation loops faster. + +- **OpenClaw user scenario:** Checkout flow monitoring from chat + - Run a full baseline once, then diff mode on each step. + - You see only changed controls/messages after each action. +- **Developer scenario:** Flaky UI reproduction loop + - Keep one stable script: `observe -> act -> observe diff`. + - Faster diagnosis when UI mutates between attempts. +- **Other scenario (Ops / Monitoring):** Status page drift detection + - Poll snapshot diff on dashboards. + - Alert only when visible state changes, not every poll. + +**Example execute pattern:** + +```javascript +await snapshot({ showDiffSinceLastCall: false }); // baseline once +// ... perform one action +return await snapshot({ showDiffSinceLastCall: true }); // concise change output +``` + +### 2) `refToLocator({ ref })` + +**Why this is high impact:** It converts snapshot refs into actionable selectors without brittle locator guessing. + +- **OpenClaw user scenario:** “Click the third approve button” from messaging app + - Agent inspects snapshot refs and resolves exact target with `refToLocator`. +- **Developer scenario:** Remove flaky `nth()` selectors in tests + - Replace deep CSS chains with snapshot-ref resolution per step. +- **Other scenario (Support):** Guided incident triage + - Agent can target the exact control visible in the current UI state. + +**Example execute pattern:** + +```javascript +await snapshot({ showDiffSinceLastCall: false }); +const locator = refToLocator({ ref: 'e3' }); +if (!locator) throw new Error('ref e3 not available'); +await state.page.locator(locator).click(); +``` + +### 3) `getCDPSession({ page })` + +**Why this is high impact:** It gives relay-safe low-level browser access for cases Playwright APIs do not cover cleanly. + +- **OpenClaw user scenario:** Advanced site diagnostics on authenticated pages + - Run protocol-level checks while still using real logged-in Chrome sessions. +- **Developer scenario:** Deep debugging in relay environments + - Enable CDP domains (`Network`, `Runtime`, `Performance`) safely. +- **Other scenario (QA):** Protocol verification in test workflows + - Validate low-level page/runtime conditions before/after critical actions. + +**Example execute pattern:** + +```javascript +const cdp = await getCDPSession({ page: state.page }); +await cdp.send('Network.enable'); +return await cdp.send('Runtime.evaluate', { expression: 'document.readyState' }); +``` + +### 4) Tactical Execute Playbook (Prompt Guidance) + +**Why this is high impact:** Better default agent behavior reduces dead-end runs on real websites. + +- **OpenClaw user scenario:** Cookie/consent/login blockers handled automatically + - Agent is guided to clear blockers before continuing. +- **Developer scenario:** Correct extraction tool choice per task + - Guidance for `snapshot vs cleanHTML vs pageMarkdown` reduces wrong-tool usage. +- **Other scenario (QA / Incident):** Faster root-cause loops + - “Combine snapshot + logs” guidance standardizes debugging flow. + +**Example prompt-to-agent outcomes:** + +- More reliable form/task completion on consent-heavy sites. +- Fewer retries caused by stale locators after page updates. +- Better extraction quality on article/news pages using `pageMarkdown`. + +### 5) Prompt/Test Regression Guards (Team Safety) + +**Why this is high impact:** Prevents silent drift between documented helper surface and runtime behavior. + +- **OpenClaw user scenario:** Stable agent behavior across updates + - Key guidance phrases remain enforced by tests. +- **Developer scenario:** Safer refactors of MCP prompt/runtime + - Failing tests catch missing helper mentions or diff contract changes. +- **Other scenario (Maintainers):** Predictable release quality + - Prompt contracts and helper exposure stay synchronized. + +**Operational check:** + +```bash +node --test mcp/test/mcp-tools.test.js +node --test mcp/test/exec-engine-plugins.test.js +``` + +## OpenClaw User (High Impact) + +### 1) Fast Checkout / Form Completion With Less Noise + +**Goal:** Complete long forms without re-reading the whole page every step. + +**Use:** +- `snapshot({ showDiffSinceLastCall: true })` +- `refToLocator({ ref })` + +**Example execute flow:** + +```javascript +await snapshot({ showDiffSinceLastCall: false }); // baseline full view +// ... fill step 1 +const delta = await snapshot({ showDiffSinceLastCall: true }); +return delta; +``` + +**Success looks like:** You only see what changed after each action, and fewer wrong clicks happen. + +### 2) Watch Your Competitor Pricing Page + +**Goal:** Detect only meaningful pricing-card changes. + +**Use:** +- `cleanHTML('.pricing', { showDiffSinceLastCall: true })` + +**Example execute flow:** + +```javascript +const first = await cleanHTML('.pricing', { showDiffSinceLastCall: true }); +const second = await cleanHTML('.pricing', { showDiffSinceLastCall: true }); +return { firstPreview: first.slice(0, 300), secondPreview: second.slice(0, 300) }; +``` + +**Success looks like:** Second run returns either a compact diff or no-change guidance instead of full repeated markup. + +### 3) Track Policy/Terms Changes On Services You Use + +**Goal:** Be notified when legal/terms wording changes. + +**Use:** +- `pageMarkdown({ showDiffSinceLastCall: true })` + +**Example execute flow:** + +```javascript +await state.page.goto('https://example.com/terms'); +await waitForPageLoad(); +const baseline = await pageMarkdown({ showDiffSinceLastCall: true }); +const next = await pageMarkdown({ showDiffSinceLastCall: true }); +return { baselineLen: baseline.length, next }; +``` + +**Success looks like:** You get concise change output only when terms changed. + +## Developer (High Impact) + +### 1) Debug “Action Sent But Nothing Happened” + +**Goal:** Find where command flow failed. + +**Use:** +- CDP JSONL log (`~/.browserforce/cdp.jsonl`) + +**Run:** + +```bash +jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c +``` + +**Success looks like:** You can confirm whether the command reached extension and whether response/event returned to Playwright. + +### 2) Reproduce Flaky Interaction Deterministically + +**Goal:** Replace brittle selectors and stale refs. + +**Use:** +- `snapshot({ showDiffSinceLastCall: false })` +- `refToLocator({ ref })` + +**Example execute flow:** + +```javascript +const snap = await snapshot({ showDiffSinceLastCall: false }); +const locator = refToLocator({ ref: 'e3' }); +if (!locator) throw new Error('ref e3 not available'); +await state.page.locator(locator).click(); +return await snapshot({ showDiffSinceLastCall: true }); +``` + +**Success looks like:** Fewer flaky failures from stale `nth()`/deep CSS paths. + +### 3) Raw CDP Verification In Relay Context + +**Goal:** Inspect browser/network behavior beyond normal locator APIs. + +**Use:** +- `getCDPSession({ page })` + +**Example execute flow:** + +```javascript +const cdp = await getCDPSession({ page: state.page }); +await cdp.send('Network.enable'); +const result = await cdp.send('Runtime.evaluate', { expression: 'document.readyState' }); +return result; +``` + +**Success looks like:** You can run low-level checks without breaking relay compatibility. + +## QA / Automation Engineer + +### 1) Regression Diff Between Test Steps + +**Goal:** Catch unexpected UI changes early. + +**Use:** +- `snapshot({ showDiffSinceLastCall: true })` + +**Example:** Run snapshot diff after each core step (`login -> cart -> checkout -> confirmation`) and fail test if unexpected controls appear/disappear. + +**Success looks like:** Smaller, reviewable diffs in CI logs. + +### 2) Validate Article/Release Notes Updates + +**Goal:** Verify content releases actually changed required sections. + +**Use:** +- `pageMarkdown({ search: /feature-x|deprecation|breaking/i })` + +**Example execute flow:** + +```javascript +await state.page.goto('https://example.com/changelog'); +await waitForPageLoad(); +return await pageMarkdown({ search: /feature-x|deprecation|breaking/i }); +``` + +**Success looks like:** You immediately see whether required terms exist in published content. + +## Support / Incident Response + +### 1) Triaging User Reports Quickly + +**Goal:** Determine whether issue is UI, extension, or relay routing. + +**Use:** +- `snapshot({ showDiffSinceLastCall: false })` +- `getLogs({ count: 30 })` +- `~/.browserforce/cdp.jsonl` + +**Flow:** +1. Capture full snapshot. +2. Capture console logs. +3. Check CDP direction flow in JSONL. + +**Success looks like:** Clear fault domain in minutes, not guesswork. + +### 2) Verify Page-Load Deadlocks + +**Goal:** Confirm whether page is stuck vs automation issue. + +**Use:** +- `waitForPageLoad({ timeout: ... })` +- `snapshot({ showDiffSinceLastCall: true })` + +**Success looks like:** You can prove if the page state is unchanged over time and isolate blocker overlays quickly. + +## Compliance / Risk + +### 1) Continuous Monitoring Of Disclosures + +**Goal:** Alert on modifications in legal disclosures/policy text. + +**Use:** +- `cleanHTML('main', { showDiffSinceLastCall: true })` +- `pageMarkdown({ showDiffSinceLastCall: true })` + +**Success looks like:** Only meaningful textual/structural changes trigger review tickets. + +### 2) Local Audit Trail For Automation + +**Goal:** Keep evidence of what automation asked and what browser returned. + +**Use:** +- `~/.browserforce/cdp.jsonl` + +**Run:** + +```bash +tail -n 200 ~/.browserforce/cdp.jsonl +``` + +**Success looks like:** Actionable timeline for audits and postmortems. + +## Rollout Pattern For Teams + +1. Start with one workflow in `diff` mode (`showDiffSinceLastCall: true`). +2. Keep one “escape hatch” call in full mode (`showDiffSinceLastCall: false`) for debugging. +3. Add JSONL checks to incident runbooks. +4. Standardize around `snapshot -> refToLocator -> action -> snapshot diff`. From 5dd7022776963cbbdd93f5ba01e3fd8b3599bf10 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 20:29:18 +0530 Subject: [PATCH 048/192] docs: expand controlled-tab guidance and persona use cases --- GUIDE.md | 62 ++++++++- README.md | 59 ++++++--- docs/USE_CASES.md | 326 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 docs/USE_CASES.md diff --git a/GUIDE.md b/GUIDE.md index d831550..bcae7bc 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -215,6 +215,62 @@ for (const page of pages) { } ``` +## Controlled Tabs Playbook + +Use this section when you want strict control over what the agent can touch. + +### 1) Manually Attach A Tab + +1. Open the exact tab you want the agent to use. +2. Click the BrowserForce extension icon. +3. In the popup, click **+ Attach Current Tab**. +4. Confirm it appears under **Controlled Tabs**. + +This is the fastest way to grant access to an already logged-in page without exposing other tabs. + +### 2) Single-Tab Locked Workflow + +For high-safety tasks (admin pages, billing pages, production dashboards): + +1. Set **Mode** to `Manual`. +2. Attach only one tab using **+ Attach Current Tab**. +3. Enable **No new tabs**. +4. Optionally enable **Lock URL** and **Read-only** depending on the task. + +Result: the agent is constrained to one attached tab and cannot open additional tabs. + +### 3) Multi-Tab Controlled Workflow + +If the task needs a few trusted tabs: + +1. Keep **Mode** on `Manual`. +2. Switch to each required tab and click **+ Attach Current Tab**. +3. Keep **No new tabs** on if you want to block any extra tab creation. + +Result: the agent can work only across the tabs you explicitly attached. + +### 4) Restriction Modes (How To Combine Them) + +- **Lock URL**: blocks navigation away from the current page (reload is still possible). +- **No new tabs**: blocks agent-driven tab creation. +- **Read-only**: blocks interaction methods (click/type/edit); useful for inspection-only runs. + +Common presets: + +- **Audit preset**: `Manual + No new tabs + Read-only` +- **Form testing preset**: `Manual + No new tabs` (leave Read-only off) +- **Pinned page preset**: `Manual + Lock URL + No new tabs` + +### 5) Auto-Cleanup After Use + +- **Auto-detach inactive tabs**: detaches tabs after 5-60 minutes of inactivity. +- **Auto-close agent tabs**: closes tabs created by the agent after 5-60 minutes. + +Recommended: + +- Use `10-15 min` auto-detach for normal sessions. +- Use auto-close when running broad exploration tasks that open many tabs. + ## CLI Once installed globally (`npm install -g browserforce`), the CLI is available: @@ -261,6 +317,8 @@ await cleanHTML('body', { showDiffSinceLastCall: false }); await pageMarkdown({ showDiffSinceLastCall: true }); ``` +Need concrete persona-based workflows? See [Actionable Use Cases](docs/USE_CASES.md). + The `execute` tool gives the agent full Playwright access — it can navigate, click, type, screenshot, read accessibility trees, and run JavaScript in the page context. All within your real browser session. ## Examples @@ -413,7 +471,7 @@ A: Any AI that supports MCP (OpenClaw, Claude Desktop, Claude Code) or any tool A: Chrome aggressively kills MV3 extensions after 30 seconds of inactivity. The relay sends keepalive pings every 5 seconds to prevent this. If the extension does restart, it auto-reconnects. **Q: Can I control which tabs the AI accesses?** -A: Yes. Click the extension icon to switch between Auto mode (agent sees all tabs) and Manual mode (you select which tabs). You can also lock URLs, block new tabs, or enable read-only mode. +A: Yes. In Auto mode the agent can create and control its own tabs. In Manual mode, you explicitly attach tabs with **+ Attach Current Tab**. You can also lock URLs, block new tabs, or enable read-only mode. **Q: Does it work with multiple windows?** A: Yes. All tabs across all Chrome windows are visible. @@ -433,3 +491,5 @@ CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay st ```bash jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c ``` + +For incident/debug playbooks, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). diff --git a/README.md b/README.md index 294fdb0..488e651 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ **You're giving an AI your real Chrome — your logins, cookies, and sessions. That takes conviction.** BrowserForce is built for people who use the best models and don't look back. Security is built in: lock URLs, block navigation, read-only mode, auto-cleanup — you stay in control. -**Fully autonomous browser control.** No manual tab clicking. Your agent browses as you, even from WhatsApp. Other tools make you click each tab, spawn a fresh Chrome, or only work with one AI client. BrowserForce connects to **your running browser** and auto-attaches to all tabs. One Chrome extension, full Playwright API, completely hands-off. +**Autonomous when you want it, controlled when you need it.** Your agent can run hands-off in Auto mode, or you can switch to Manual mode and explicitly attach only the tabs you trust. BrowserForce connects to **your running browser** with one Chrome extension and full Playwright API support. Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP-compatible agent. @@ -16,10 +16,10 @@ Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP- |---|---|---|---|---|---| | Browser | Spawns new Chrome | Separate profile | Your Chrome | Your Chrome | **Your Chrome** | | Login state | Fresh | Fresh (isolated) | Yours | Yours | **Yours** | -| Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **All tabs, automatic** | +| Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **Auto mode + manual attached tabs** | | Autonomous | Yes | Yes | No (manual click) | No (manual click) | **Yes (fully autonomous)** | | Context method | Screenshots (100KB+) | Screenshots + snapshots | A11y snapshots (5-20KB) | Screenshots (100KB+) | **A11y snapshots (5-20KB)** | -| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **3 tools: `execute`, `screenshot_with_labels`, `reset`** | +| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **2 tools: `execute`, `reset`** | | Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** | | Playwright API | Partial | No | Full | No | **Full** | @@ -108,10 +108,10 @@ browserforce serve If your agent browses to the page and responds with the title, you're all set. -
-MCP setup for OpenClaw, Claude, Codex, Cursor, and Antigravity +**MCP setup (advanced):** -#### OpenClaw (MCP adapter) +
+OpenClaw (MCP adapter) Add to `~/.openclaw/openclaw.json`: @@ -137,7 +137,10 @@ Add to `~/.openclaw/openclaw.json`: } ``` -#### Claude Desktop +
+ +
+Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -152,7 +155,10 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` -#### Claude Code +
+ +
+Claude Code Add to `~/.claude/mcp.json`: @@ -167,7 +173,10 @@ Add to `~/.claude/mcp.json`: } ``` -#### Codex +
+ +
+Codex Add to `~/.codex/config.toml`: @@ -177,7 +186,10 @@ command = "npx" args = ["-y", "browserforce@latest", "mcp"] ``` -#### Cursor +
+ +
+Cursor Add to `~/.cursor/mcp.json`: @@ -192,7 +204,10 @@ Add to `~/.cursor/mcp.json`: } ``` -#### Antigravity +
+ +
+Antigravity In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. Add the same `mcpServers` entry: @@ -208,14 +223,14 @@ Add the same `mcpServers` entry: } ``` +
+ If MCP startup fails with `connection closed: initialize response`: 1. Ensure args include `"mcp"` (without it, BrowserForce prints help and exits). 2. If running from a local clone, install deps first: `pnpm install`. 3. Validate the launch command manually: `npx -y browserforce@latest mcp` -
- ### CLI ```bash @@ -368,6 +383,8 @@ await cleanHTML('body', { showDiffSinceLastCall: false }); await pageMarkdown({ showDiffSinceLastCall: true }); ``` +Need role-based, real workflows? See [Actionable Use Cases](docs/USE_CASES.md). + ## Examples Get started with simple prompts. The AI generates code and does the work. @@ -519,9 +536,9 @@ Get started with simple prompts. The AI generates code and does the work. The **relay server** runs on your machine (localhost only). It translates between the agent's CDP commands and the extension's debugger bridge. -The **Chrome extension** lives in your browser. It attaches Chrome's built-in debugger to your tabs and forwards commands — exactly like DevTools does. +The **Chrome extension** lives in your browser. It attaches Chrome's built-in debugger to permitted tabs and forwards commands — exactly like DevTools does. -When the agent connects, it immediately sees all your open tabs as controllable Playwright pages. No clicking, no manual attachment. +In **Auto mode**, the agent can create and control tabs it opens. In **Manual mode**, you decide access by clicking **+ Attach Current Tab**. ## You Stay in Control @@ -537,6 +554,16 @@ Click the extension icon to configure restrictions. Your browser, your rules: | **Auto-close** | Automatically close agent-created tabs after 5-60 minutes | | **Custom instructions** | Pass text instructions to the agent (e.g. "don't click any buy buttons") | +### Controlled Tab Workflows + +- **Manually attach a tab:** Open the tab you want, click the extension popup, then click **+ Attach Current Tab**. +- **Restrict to one controlled tab:** Use **Manual mode**, attach one tab, and enable **No new tabs**. +- **Allow multiple controlled tabs:** Stay in **Manual mode** and attach each tab you want the agent to access. +- **Restriction modes:** Use **Lock URL** (no navigation), **No new tabs**, and **Read-only** (observe only) together or separately. +- **Auto-cleanup:** Use **Auto-detach** for inactive attached tabs and **Auto-close** for agent-created tabs. + +For step-by-step setups, see the [Controlled Tabs Playbook](GUIDE.md#controlled-tabs-playbook). + ## Security | Layer | Control | @@ -593,4 +620,6 @@ CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay st jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c ``` +For practical debugging and operations flows, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). + > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. diff --git a/docs/USE_CASES.md b/docs/USE_CASES.md new file mode 100644 index 0000000..8b8bbf7 --- /dev/null +++ b/docs/USE_CASES.md @@ -0,0 +1,326 @@ +# BrowserForce Use Cases (Actionable) + +This page is for real-world execution, not theory. Each section includes: + +- What role this is for +- What you are trying to achieve +- Which BrowserForce switches/helpers to use +- A copy-paste example +- What success looks like + +## Quick Switch Guide + +| Switch / Helper | Use it when | Typical outcome | +|---|---|---| +| `snapshot({ showDiffSinceLastCall: true })` | You are in a multi-step flow and want only changes | Faster loops, lower token usage, less noise | +| `snapshot({ showDiffSinceLastCall: false })` | You need full context right now | Full tree and refs for reliable decisions | +| `cleanHTML(selector, { showDiffSinceLastCall: true })` | You monitor DOM changes over time | Detect only meaningful structural changes | +| `cleanHTML(selector, { showDiffSinceLastCall: false })` | You need full HTML snapshot for parsing | Complete cleaned HTML for extraction | +| `pageMarkdown({ showDiffSinceLastCall: true })` | You monitor long-form content/pages | Alert only on content changes | +| `pageMarkdown({ search: /.../ })` | You need targeted text checks | Focused findings with context lines | +| `refToLocator({ ref: 'eN' })` | You got a ref from `snapshot()` and need a stable locator | Reliable interaction without brittle selectors | +| `getCDPSession({ page })` | You need low-level CDP commands in relay environment | Raw CDP access with relay-safe session creation | + +## Feature-by-Feature Use Cases (High Impact First) + +This section maps each newly added capability to practical scenarios by user type. + +### 1) `snapshot({ showDiffSinceLastCall })` (Most Impactful) + +**Why this is high impact:** It cuts repeated context noise in long flows and makes automation loops faster. + +- **OpenClaw user scenario:** Checkout flow monitoring from chat + - Run a full baseline once, then diff mode on each step. + - You see only changed controls/messages after each action. +- **Developer scenario:** Flaky UI reproduction loop + - Keep one stable script: `observe -> act -> observe diff`. + - Faster diagnosis when UI mutates between attempts. +- **Other scenario (Ops / Monitoring):** Status page drift detection + - Poll snapshot diff on dashboards. + - Alert only when visible state changes, not every poll. + +**Example execute pattern:** + +```javascript +await snapshot({ showDiffSinceLastCall: false }); // baseline once +// ... perform one action +return await snapshot({ showDiffSinceLastCall: true }); // concise change output +``` + +### 2) `refToLocator({ ref })` + +**Why this is high impact:** It converts snapshot refs into actionable selectors without brittle locator guessing. + +- **OpenClaw user scenario:** “Click the third approve button” from messaging app + - Agent inspects snapshot refs and resolves exact target with `refToLocator`. +- **Developer scenario:** Remove flaky `nth()` selectors in tests + - Replace deep CSS chains with snapshot-ref resolution per step. +- **Other scenario (Support):** Guided incident triage + - Agent can target the exact control visible in the current UI state. + +**Example execute pattern:** + +```javascript +await snapshot({ showDiffSinceLastCall: false }); +const locator = refToLocator({ ref: 'e3' }); +if (!locator) throw new Error('ref e3 not available'); +await state.page.locator(locator).click(); +``` + +### 3) `getCDPSession({ page })` + +**Why this is high impact:** It gives relay-safe low-level browser access for cases Playwright APIs do not cover cleanly. + +- **OpenClaw user scenario:** Advanced site diagnostics on authenticated pages + - Run protocol-level checks while still using real logged-in Chrome sessions. +- **Developer scenario:** Deep debugging in relay environments + - Enable CDP domains (`Network`, `Runtime`, `Performance`) safely. +- **Other scenario (QA):** Protocol verification in test workflows + - Validate low-level page/runtime conditions before/after critical actions. + +**Example execute pattern:** + +```javascript +const cdp = await getCDPSession({ page: state.page }); +await cdp.send('Network.enable'); +return await cdp.send('Runtime.evaluate', { expression: 'document.readyState' }); +``` + +### 4) Tactical Execute Playbook (Prompt Guidance) + +**Why this is high impact:** Better default agent behavior reduces dead-end runs on real websites. + +- **OpenClaw user scenario:** Cookie/consent/login blockers handled automatically + - Agent is guided to clear blockers before continuing. +- **Developer scenario:** Correct extraction tool choice per task + - Guidance for `snapshot vs cleanHTML vs pageMarkdown` reduces wrong-tool usage. +- **Other scenario (QA / Incident):** Faster root-cause loops + - “Combine snapshot + logs” guidance standardizes debugging flow. + +**Example prompt-to-agent outcomes:** + +- More reliable form/task completion on consent-heavy sites. +- Fewer retries caused by stale locators after page updates. +- Better extraction quality on article/news pages using `pageMarkdown`. + +### 5) Prompt/Test Regression Guards (Team Safety) + +**Why this is high impact:** Prevents silent drift between documented helper surface and runtime behavior. + +- **OpenClaw user scenario:** Stable agent behavior across updates + - Key guidance phrases remain enforced by tests. +- **Developer scenario:** Safer refactors of MCP prompt/runtime + - Failing tests catch missing helper mentions or diff contract changes. +- **Other scenario (Maintainers):** Predictable release quality + - Prompt contracts and helper exposure stay synchronized. + +**Operational check:** + +```bash +node --test mcp/test/mcp-tools.test.js +node --test mcp/test/exec-engine-plugins.test.js +``` + +## OpenClaw User (High Impact) + +### 1) Fast Checkout / Form Completion With Less Noise + +**Goal:** Complete long forms without re-reading the whole page every step. + +**Use:** +- `snapshot({ showDiffSinceLastCall: true })` +- `refToLocator({ ref })` + +**Example execute flow:** + +```javascript +await snapshot({ showDiffSinceLastCall: false }); // baseline full view +// ... fill step 1 +const delta = await snapshot({ showDiffSinceLastCall: true }); +return delta; +``` + +**Success looks like:** You only see what changed after each action, and fewer wrong clicks happen. + +### 2) Watch Your Competitor Pricing Page + +**Goal:** Detect only meaningful pricing-card changes. + +**Use:** +- `cleanHTML('.pricing', { showDiffSinceLastCall: true })` + +**Example execute flow:** + +```javascript +const first = await cleanHTML('.pricing', { showDiffSinceLastCall: true }); +const second = await cleanHTML('.pricing', { showDiffSinceLastCall: true }); +return { firstPreview: first.slice(0, 300), secondPreview: second.slice(0, 300) }; +``` + +**Success looks like:** Second run returns either a compact diff or no-change guidance instead of full repeated markup. + +### 3) Track Policy/Terms Changes On Services You Use + +**Goal:** Be notified when legal/terms wording changes. + +**Use:** +- `pageMarkdown({ showDiffSinceLastCall: true })` + +**Example execute flow:** + +```javascript +await state.page.goto('https://example.com/terms'); +await waitForPageLoad(); +const baseline = await pageMarkdown({ showDiffSinceLastCall: true }); +const next = await pageMarkdown({ showDiffSinceLastCall: true }); +return { baselineLen: baseline.length, next }; +``` + +**Success looks like:** You get concise change output only when terms changed. + +## Developer (High Impact) + +### 1) Debug “Action Sent But Nothing Happened” + +**Goal:** Find where command flow failed. + +**Use:** +- CDP JSONL log (`~/.browserforce/cdp.jsonl`) + +**Run:** + +```bash +jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c +``` + +**Success looks like:** You can confirm whether the command reached extension and whether response/event returned to Playwright. + +### 2) Reproduce Flaky Interaction Deterministically + +**Goal:** Replace brittle selectors and stale refs. + +**Use:** +- `snapshot({ showDiffSinceLastCall: false })` +- `refToLocator({ ref })` + +**Example execute flow:** + +```javascript +const snap = await snapshot({ showDiffSinceLastCall: false }); +const locator = refToLocator({ ref: 'e3' }); +if (!locator) throw new Error('ref e3 not available'); +await state.page.locator(locator).click(); +return await snapshot({ showDiffSinceLastCall: true }); +``` + +**Success looks like:** Fewer flaky failures from stale `nth()`/deep CSS paths. + +### 3) Raw CDP Verification In Relay Context + +**Goal:** Inspect browser/network behavior beyond normal locator APIs. + +**Use:** +- `getCDPSession({ page })` + +**Example execute flow:** + +```javascript +const cdp = await getCDPSession({ page: state.page }); +await cdp.send('Network.enable'); +const result = await cdp.send('Runtime.evaluate', { expression: 'document.readyState' }); +return result; +``` + +**Success looks like:** You can run low-level checks without breaking relay compatibility. + +## QA / Automation Engineer + +### 1) Regression Diff Between Test Steps + +**Goal:** Catch unexpected UI changes early. + +**Use:** +- `snapshot({ showDiffSinceLastCall: true })` + +**Example:** Run snapshot diff after each core step (`login -> cart -> checkout -> confirmation`) and fail test if unexpected controls appear/disappear. + +**Success looks like:** Smaller, reviewable diffs in CI logs. + +### 2) Validate Article/Release Notes Updates + +**Goal:** Verify content releases actually changed required sections. + +**Use:** +- `pageMarkdown({ search: /feature-x|deprecation|breaking/i })` + +**Example execute flow:** + +```javascript +await state.page.goto('https://example.com/changelog'); +await waitForPageLoad(); +return await pageMarkdown({ search: /feature-x|deprecation|breaking/i }); +``` + +**Success looks like:** You immediately see whether required terms exist in published content. + +## Support / Incident Response + +### 1) Triaging User Reports Quickly + +**Goal:** Determine whether issue is UI, extension, or relay routing. + +**Use:** +- `snapshot({ showDiffSinceLastCall: false })` +- `getLogs({ count: 30 })` +- `~/.browserforce/cdp.jsonl` + +**Flow:** +1. Capture full snapshot. +2. Capture console logs. +3. Check CDP direction flow in JSONL. + +**Success looks like:** Clear fault domain in minutes, not guesswork. + +### 2) Verify Page-Load Deadlocks + +**Goal:** Confirm whether page is stuck vs automation issue. + +**Use:** +- `waitForPageLoad({ timeout: ... })` +- `snapshot({ showDiffSinceLastCall: true })` + +**Success looks like:** You can prove if the page state is unchanged over time and isolate blocker overlays quickly. + +## Compliance / Risk + +### 1) Continuous Monitoring Of Disclosures + +**Goal:** Alert on modifications in legal disclosures/policy text. + +**Use:** +- `cleanHTML('main', { showDiffSinceLastCall: true })` +- `pageMarkdown({ showDiffSinceLastCall: true })` + +**Success looks like:** Only meaningful textual/structural changes trigger review tickets. + +### 2) Local Audit Trail For Automation + +**Goal:** Keep evidence of what automation asked and what browser returned. + +**Use:** +- `~/.browserforce/cdp.jsonl` + +**Run:** + +```bash +tail -n 200 ~/.browserforce/cdp.jsonl +``` + +**Success looks like:** Actionable timeline for audits and postmortems. + +## Rollout Pattern For Teams + +1. Start with one workflow in `diff` mode (`showDiffSinceLastCall: true`). +2. Keep one “escape hatch” call in full mode (`showDiffSinceLastCall: false`) for debugging. +3. Add JSONL checks to incident runbooks. +4. Standardize around `snapshot -> refToLocator -> action -> snapshot diff`. From 3040db1749da06e2ac0e25d91ff88c098b554ade Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 20:38:54 +0530 Subject: [PATCH 049/192] docs: update positioning to parallel ai agents in your chrome --- GUIDE.md | 2 ++ README.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/GUIDE.md b/GUIDE.md index bcae7bc..7dd06bd 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,5 +1,7 @@ # BrowserForce — User Guide +**BrowserForce // Parallel AI Agents in "your" Chrome!** + ## What is this? BrowserForce gives AI agents — like [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP-compatible tool — access to **your real Chrome browser**. The one you're already logged into. No headless browser, no fake profiles. The AI sees your actual tabs and can interact with any website using your existing sessions. diff --git a/README.md b/README.md index 488e651..0df7634 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# BrowserForce // +# BrowserForce // Parallel AI Agents in "your" Chrome! > "a lion doesn't concern itself with token counting" — [@steipete](https://x.com/steipete), creator of [OpenClaw](https://github.com/openclaw/openclaw) > From f2cd8da3577877f9727149c3e7b65db4fdbb12d0 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 20:59:22 +0530 Subject: [PATCH 050/192] feat(relay): add client arbitration config defaults --- relay/src/index.js | 10 ++++++++++ relay/test/relay-server.test.js | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/relay/src/index.js b/relay/src/index.js index 5326072..c2f2cf1 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -16,6 +16,8 @@ const BF_DIR = path.join(os.homedir(), '.browserforce'); const TOKEN_FILE = path.join(BF_DIR, 'auth-token'); const CDP_URL_FILE = path.join(BF_DIR, 'cdp-url'); const BF_PLUGINS_DIR = path.join(BF_DIR, 'plugins'); +const CLIENT_MODE_SINGLE = 'single-active'; +const CLIENT_MODE_MULTI = 'multi-client'; // ─── Logging ───────────────────────────────────────────────────────────────── @@ -48,6 +50,11 @@ function writeCdpUrlFile(cdpUrl) { } } +function getClientMode() { + const mode = (process.env.BF_CLIENT_MODE || CLIENT_MODE_SINGLE).trim(); + return mode === CLIENT_MODE_MULTI ? CLIENT_MODE_MULTI : CLIENT_MODE_SINGLE; +} + // ─── RelayServer ───────────────────────────────────────────────────────────── const DEFAULT_BROWSER_CONTEXT_ID = 'bf-default-context'; @@ -130,6 +137,9 @@ class RelayServer { this.port = port; this.pluginsDir = pluginsDir; this.authToken = getOrCreateAuthToken(); + this.clientMode = getClientMode(); + this.activeClient = null; // { id, ws, connectedAt, lastSeenAt } + this.clientSeq = 0; // Extension connection (single slot) this.ext = null; diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 3b36b2a..bc8a02c 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -101,6 +101,12 @@ describe('Token Persistence', () => { const tmpDir = path.join(os.tmpdir(), `bf-test-${crypto.randomBytes(4).toString('hex')}`); const origBfDir = BF_DIR; + it('defaults to single-active client mode', () => { + delete process.env.BF_CLIENT_MODE; + const relay = new RelayServer(getRandomPort()); + assert.equal(relay.clientMode, 'single-active'); + }); + it('creates auth token file on first run', () => { // RelayServer reads token from the global BF_DIR. // We just verify the token is a non-empty string. From b6fce07b695060960bd119d1a43014dfed318a4d Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:07:55 +0530 Subject: [PATCH 051/192] feat(relay): enforce single active cdp client mode --- relay/src/index.js | 26 ++++++++++++++++++++++++-- relay/test/relay-server.test.js | 22 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/relay/src/index.js b/relay/src/index.js index c2f2cf1..472fd63 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -182,7 +182,7 @@ class RelayServer { server.on('upgrade', (req, socket, head) => this._handleUpgrade(req, socket, head)); this.extWss.on('connection', (ws) => this._onExtConnect(ws)); - this.cdpWss.on('connection', (ws) => this._onCdpConnect(ws)); + this.cdpWss.on('connection', (ws, req) => this._onCdpConnect(ws, req)); this.server = server; @@ -419,6 +419,16 @@ class RelayServer { socket.destroy(); return; } + if (this.clientMode === CLIENT_MODE_SINGLE) { + if (this.activeClient && this.activeClient.ws.readyState === WebSocket.OPEN) { + const body = JSON.stringify({ error: 'Another CDP client is already connected' }); + socket.write( + `HTTP/1.1 409 Conflict\r\nContent-Type: application/json\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}` + ); + socket.destroy(); + return; + } + } this.cdpWss.handleUpgrade(req, socket, head, (ws) => { this.cdpWss.emit('connection', ws, req); }); @@ -644,11 +654,20 @@ class RelayServer { // ─── CDP Client Connection ────────────────────────────────────────────── - _onCdpConnect(ws) { + _onCdpConnect(ws, req) { + const clientId = `bf-cdp-${++this.clientSeq}`; + ws._bfClientId = clientId; + if (this.clientMode === CLIENT_MODE_SINGLE) { + const now = Date.now(); + this.activeClient = { id: clientId, ws, connectedAt: now, lastSeenAt: now }; + } log('[relay] CDP client connected'); this.clients.add(ws); ws.on('message', (data) => { + if (this.clientMode === CLIENT_MODE_SINGLE && this.activeClient?.id === clientId) { + this.activeClient.lastSeenAt = Date.now(); + } try { const msg = JSON.parse(data.toString()); this._handleCdpClientMessage(ws, msg); @@ -660,6 +679,9 @@ class RelayServer { ws.on('close', () => { log('[relay] CDP client disconnected'); this.clients.delete(ws); + if (this.activeClient?.id === clientId) { + this.activeClient = null; + } }); ws.on('error', (err) => { diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index bc8a02c..ab21dac 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -439,6 +439,28 @@ describe('WebSocket Security', () => { ws.close(); }); + it('rejects second /cdp client in single-active mode', async () => { + const prevMode = process.env.BF_CLIENT_MODE; + process.env.BF_CLIENT_MODE = 'single-active'; + let c1; + let c2; + try { + c1 = await connectWs(`ws://127.0.0.1:${port}/cdp?token=${relay.authToken}`); + await assert.rejects( + (async () => { + c2 = await connectWs(`ws://127.0.0.1:${port}/cdp?token=${relay.authToken}`); + c2.close(); + })(), + /409|Unexpected/ + ); + } finally { + if (c1 && c1.readyState === WebSocket.OPEN) c1.close(); + if (c2 && c2.readyState === WebSocket.OPEN) c2.close(); + if (prevMode === undefined) delete process.env.BF_CLIENT_MODE; + else process.env.BF_CLIENT_MODE = prevMode; + } + }); + it('rejects second extension connection (single slot)', async () => { const ws1 = await connectWs(`ws://127.0.0.1:${port}/extension`, { headers: { Origin: 'chrome-extension://first' }, From b7b5093d036246908b997cf1510c43935b800fec Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:14:56 +0530 Subject: [PATCH 052/192] test(relay): make single-active contention test deterministic --- relay/test/relay-server.test.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index ab21dac..d232244 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -442,13 +442,16 @@ describe('WebSocket Security', () => { it('rejects second /cdp client in single-active mode', async () => { const prevMode = process.env.BF_CLIENT_MODE; process.env.BF_CLIENT_MODE = 'single-active'; + const singleRelay = new RelayServer(getRandomPort()); + await singleRelay.start({ writeCdpUrl: false }); let c1; let c2; try { - c1 = await connectWs(`ws://127.0.0.1:${port}/cdp?token=${relay.authToken}`); + assert.equal(singleRelay.clientMode, 'single-active'); + c1 = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); await assert.rejects( (async () => { - c2 = await connectWs(`ws://127.0.0.1:${port}/cdp?token=${relay.authToken}`); + c2 = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); c2.close(); })(), /409|Unexpected/ @@ -456,6 +459,7 @@ describe('WebSocket Security', () => { } finally { if (c1 && c1.readyState === WebSocket.OPEN) c1.close(); if (c2 && c2.readyState === WebSocket.OPEN) c2.close(); + singleRelay.stop(); if (prevMode === undefined) delete process.env.BF_CLIENT_MODE; else process.env.BF_CLIENT_MODE = prevMode; } From 5ab3d522d63bf0572bfb143cba447d74566477e7 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:26:50 +0530 Subject: [PATCH 053/192] feat(relay): release active slot on disconnect and expose slot status --- relay/src/index.js | 14 ++++- relay/test/relay-server.test.js | 97 +++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/relay/src/index.js b/relay/src/index.js index 472fd63..d2c8611 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -233,6 +233,18 @@ class RelayServer { return; } + if (url.pathname === '/client-slot') { + const activeWsOpen = this.activeClient?.ws?.readyState === WebSocket.OPEN; + const busy = this.clientMode === CLIENT_MODE_SINGLE && activeWsOpen; + res.end(JSON.stringify({ + mode: this.clientMode, + busy, + activeClientId: busy ? this.activeClient.id : null, + connectedAt: busy ? this.activeClient.connectedAt : null, + })); + return; + } + if (url.pathname === '/json/version') { res.end(JSON.stringify({ Browser: 'BrowserForce/1.0', @@ -679,7 +691,7 @@ class RelayServer { ws.on('close', () => { log('[relay] CDP client disconnected'); this.clients.delete(ws); - if (this.activeClient?.id === clientId) { + if (this.activeClient?.ws === ws) { this.activeClient = null; } }); diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index d232244..2da74b4 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -465,6 +465,103 @@ describe('WebSocket Security', () => { } }); + it('allows standby client after active client disconnects', async () => { + const prevMode = process.env.BF_CLIENT_MODE; + process.env.BF_CLIENT_MODE = 'single-active'; + const singleRelay = new RelayServer(getRandomPort()); + await singleRelay.start({ writeCdpUrl: false }); + + let activeClient; + let standbyClient; + let rejectedClient; + try { + activeClient = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); + const slotWhileActive = await httpGet(`http://127.0.0.1:${singleRelay.port}/client-slot`); + assert.equal(slotWhileActive.status, 200); + assert.equal(slotWhileActive.body.busy, true); + + await assert.rejects( + (async () => { + rejectedClient = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); + rejectedClient.close(); + })(), + /409|Unexpected/ + ); + + const activeClosed = new Promise((resolve) => activeClient.once('close', resolve)); + activeClient.close(); + await activeClosed; + + await waitForCondition(() => singleRelay.activeClient === null, { + description: 'active client slot release', + }); + + const slotAfterDisconnect = await httpGet(`http://127.0.0.1:${singleRelay.port}/client-slot`); + assert.equal(slotAfterDisconnect.status, 200); + assert.equal(slotAfterDisconnect.body.busy, false); + + standbyClient = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); + assert.equal(standbyClient.readyState, WebSocket.OPEN); + } finally { + if (activeClient && activeClient.readyState === WebSocket.OPEN) activeClient.close(); + if (standbyClient && standbyClient.readyState === WebSocket.OPEN) standbyClient.close(); + if (rejectedClient && rejectedClient.readyState === WebSocket.OPEN) rejectedClient.close(); + singleRelay.stop(); + if (prevMode === undefined) delete process.env.BF_CLIENT_MODE; + else process.env.BF_CLIENT_MODE = prevMode; + } + }); + + it('GET /client-slot returns mode and active status', async () => { + const prevMode = process.env.BF_CLIENT_MODE; + process.env.BF_CLIENT_MODE = 'single-active'; + const singleRelay = new RelayServer(getRandomPort()); + await singleRelay.start({ writeCdpUrl: false }); + + let activeClient; + try { + const before = await httpGet(`http://127.0.0.1:${singleRelay.port}/client-slot`); + assert.equal(before.status, 200); + assert.deepEqual(before.body, { + mode: 'single-active', + busy: false, + activeClientId: null, + connectedAt: null, + }); + + activeClient = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); + + const during = await httpGet(`http://127.0.0.1:${singleRelay.port}/client-slot`); + assert.equal(during.status, 200); + assert.equal(during.body.mode, 'single-active'); + assert.equal(during.body.busy, true); + assert.equal(typeof during.body.activeClientId, 'string'); + assert.equal(typeof during.body.connectedAt, 'number'); + + const activeClosed = new Promise((resolve) => activeClient.once('close', resolve)); + activeClient.close(); + await activeClosed; + + await waitForCondition(() => singleRelay.activeClient === null, { + description: 'active client slot release', + }); + + const after = await httpGet(`http://127.0.0.1:${singleRelay.port}/client-slot`); + assert.equal(after.status, 200); + assert.deepEqual(after.body, { + mode: 'single-active', + busy: false, + activeClientId: null, + connectedAt: null, + }); + } finally { + if (activeClient && activeClient.readyState === WebSocket.OPEN) activeClient.close(); + singleRelay.stop(); + if (prevMode === undefined) delete process.env.BF_CLIENT_MODE; + else process.env.BF_CLIENT_MODE = prevMode; + } + }); + it('rejects second extension connection (single slot)', async () => { const ws1 = await connectWs(`ws://127.0.0.1:${port}/extension`, { headers: { Origin: 'chrome-extension://first' }, From a9cd31e3ff0f22059b10b4aac4930e205e7b2ba1 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:34:45 +0530 Subject: [PATCH 054/192] feat(relay): preserve multi-client fallback mode --- relay/test/relay-server.test.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 2da74b4..ef3c581 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -465,6 +465,27 @@ describe('WebSocket Security', () => { } }); + it('allows multiple /cdp clients when BF_CLIENT_MODE=multi-client', async () => { + const prevMode = process.env.BF_CLIENT_MODE; + process.env.BF_CLIENT_MODE = 'multi-client'; + const multiRelay = new RelayServer(getRandomPort()); + await multiRelay.start({ writeCdpUrl: false }); + let c1; + let c2; + try { + c1 = await connectWs(`ws://127.0.0.1:${multiRelay.port}/cdp?token=${multiRelay.authToken}`); + c2 = await connectWs(`ws://127.0.0.1:${multiRelay.port}/cdp?token=${multiRelay.authToken}`); + assert.equal(c1.readyState, WebSocket.OPEN); + assert.equal(c2.readyState, WebSocket.OPEN); + } finally { + if (c1 && c1.readyState === WebSocket.OPEN) c1.close(); + if (c2 && c2.readyState === WebSocket.OPEN) c2.close(); + multiRelay.stop(); + if (prevMode === undefined) delete process.env.BF_CLIENT_MODE; + else process.env.BF_CLIENT_MODE = prevMode; + } + }); + it('allows standby client after active client disconnects', async () => { const prevMode = process.env.BF_CLIENT_MODE; process.env.BF_CLIENT_MODE = 'single-active'; From d1793491c8e9d3036f751bcd24b9add961174144 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:39:59 +0530 Subject: [PATCH 055/192] docs(mcp): add parallel-first tab swarm policy and real-world examples --- GUIDE.md | 83 +++++++++++ README.md | 277 ++++++++++++++++++++++++------------- mcp/src/index.js | 22 ++- mcp/test/mcp-tools.test.js | 28 ++++ 4 files changed, 313 insertions(+), 97 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 7dd06bd..990d484 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -323,6 +323,89 @@ Need concrete persona-based workflows? See [Actionable Use Cases](docs/USE_CASES The `execute` tool gives the agent full Playwright access — it can navigate, click, type, screenshot, read accessibility trees, and run JavaScript in the page context. All within your real browser session. +### BrowserForce Tab Swarms // Parallel Tabs Processing + +Use this for read-only count/list/extraction tasks where each target is independent (different pages, dates, or items). + +- Start parallel-first with `Promise.all` and a concurrency cap (`3-8`, usually start at `5`). +- If you hit `429`, anti-bot pages, or repeated timeout failures, automatically retry with reduced concurrency. +- If reduced concurrency still fails, fall back to sequential processing. +- Return telemetry on every swarm run: `peakConcurrentTasks`, `wallClockMs`, `sumTaskDurationsMs`, `failures`, `retries`. + +Example execute pattern: + +```javascript +const items = state.items ?? []; +const startedAt = Date.now(); +let peakConcurrentTasks = 0; +let sumTaskDurationsMs = 0; +let failures = 0; +let retries = 0; + +async function runTask(item, page) { + const t0 = Date.now(); + try { + await page.goto(item.url); + await waitForPageLoad({ timeout: 15000 }); + const value = await page.locator(item.selector).first().textContent(); + return { ok: true, item, value }; + } catch (error) { + const msg = String(error?.message || error); + const retryable = /429|timeout|captcha|challenge|blocked/i.test(msg); + return { ok: false, item, retryable, error: msg }; + } finally { + sumTaskDurationsMs += Date.now() - t0; + } +} + +async function runWithCap(targetItems, cap) { + const results = []; + for (let i = 0; i < targetItems.length; i += cap) { + const batch = targetItems.slice(i, i + cap); + peakConcurrentTasks = Math.max(peakConcurrentTasks, batch.length); + const tabs = await Promise.all(batch.map(() => context.newPage())); + const batchResults = await Promise.all(batch.map((item, idx) => runTask(item, tabs[idx]))); + await Promise.all(tabs.map((p) => p.close().catch(() => {}))); + results.push(...batchResults); + } + return results; +} + +let results = await runWithCap(items, 5); +let retryable = results.filter((r) => !r.ok && r.retryable).map((r) => r.item); + +if (retryable.length) { + retries += 1; + const retried = await runWithCap(retryable, 2); // reduced concurrency fallback + const settled = new Map(results.filter((r) => r.ok).map((r) => [r.item.url, r])); + for (const r of retried) settled.set(r.item.url, r); + results = [...settled.values()]; + retryable = results.filter((r) => !r.ok && r.retryable).map((r) => r.item); +} + +if (retryable.length) { + retries += 1; + for (const item of retryable) { + const tab = await context.newPage(); + const r = await runTask(item, tab); // sequential fallback + await tab.close().catch(() => {}); + results.push(r); + } +} + +failures = results.filter((r) => !r.ok).length; +return { + results, + telemetry: { + peakConcurrentTasks, + wallClockMs: Date.now() - startedAt, + sumTaskDurationsMs, + failures, + retries, + }, +}; +``` + ## Examples These prompts show how 10x users work with BrowserForce. The AI generates the code and handles the work — you just describe what you need. diff --git a/README.md b/README.md index 0df7634..a9a81ae 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,6 @@ -# BrowserForce // Parallel AI Agents in "your" Chrome! +# BrowserForce // Parallel AI Agents in "your" Browser! + +Give AI agents controlled access to the browser you already use. > "a lion doesn't concern itself with token counting" — [@steipete](https://x.com/steipete), creator of [OpenClaw](https://github.com/openclaw/openclaw) > @@ -12,16 +14,18 @@ Works with [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP- ## Comparison -| | Playwright MCP | OpenClaw Browser | Playwriter | Claude Extension | BrowserForce | -|---|---|---|---|---|---| -| Browser | Spawns new Chrome | Separate profile | Your Chrome | Your Chrome | **Your Chrome** | -| Login state | Fresh | Fresh (isolated) | Yours | Yours | **Yours** | -| Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **Auto mode + manual attached tabs** | -| Autonomous | Yes | Yes | No (manual click) | No (manual click) | **Yes (fully autonomous)** | -| Context method | Screenshots (100KB+) | Screenshots + snapshots | A11y snapshots (5-20KB) | Screenshots (100KB+) | **A11y snapshots (5-20KB)** | -| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **2 tools: `execute`, `reset`** | -| Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** | -| Playwright API | Partial | No | Full | No | **Full** | + +| | Playwright MCP | OpenClaw Browser | Playwriter | Claude Extension | BrowserForce | +| -------------- | -------------------- | ----------------------- | ----------------------- | -------------------- | ------------------------------------ | +| Browser | Spawns new Chrome | Separate profile | Your Chrome | Your Chrome | **Your Chrome** | +| Login state | Fresh | Fresh (isolated) | Yours | Yours | **Yours** | +| Tab access | N/A (new browser) | Managed by agent | Click each tab | Click each tab | **Auto mode + manual attached tabs** | +| Autonomous | Yes | Yes | No (manual click) | No (manual click) | **Yes (fully autonomous)** | +| Context method | Screenshots (100KB+) | Screenshots + snapshots | A11y snapshots (5-20KB) | Screenshots (100KB+) | **A11y snapshots (5-20KB)** | +| Tools | Many dedicated | 1 `browser` tool | 1 `execute` tool | Built-in | **2 tools: `execute`, `reset`** | +| Agent support | Any MCP client | OpenClaw only | Any MCP client | Claude only | **Any MCP client** | +| Playwright API | Partial | No | Full | No | **Full** | + ## Your Credentials Stay Yours @@ -30,6 +34,7 @@ Every other approach asks you to hand over something: an API key, an OAuth token **Why?** Because you're already logged in. BrowserForce talks to your running Chrome — it doesn't extract credentials, store cookies, or replay tokens. The browser handles auth exactly as it always has. Your agent inherits your sessions the same way a new Chrome tab does. What you never need to provide: + - No passwords - No API keys - No OAuth tokens @@ -61,8 +66,8 @@ pnpm install 2. Open `chrome://extensions/` in Chrome 3. Enable **Developer mode** (top-right toggle) 4. Click **Load unpacked** → a file picker opens - - **macOS**: press `Cmd+Shift+G`, paste the path from step 1, press Enter - - **Windows/Linux**: paste the path directly into the address bar of the dialog + - **macOS**: press `Cmd+Shift+G`, paste the path from step 1, press Enter + - **Windows/Linux**: paste the path directly into the address bar of the dialog ❗ After every BrowserForce update, re-run `browserforce install-extension`, then reload the extension in `chrome://extensions/` (click the ↺ icon next to BrowserForce). @@ -104,14 +109,13 @@ browserforce serve **Verify it works** — send this to your agent: -> Go to https://x.com and give me top tweets +> Go to [https://x.com](https://x.com) and give me top tweets If your agent browses to the page and responds with the title, you're all set. **MCP setup (advanced):** -
-OpenClaw (MCP adapter) +**OpenClaw (MCP adapter)** Add to `~/.openclaw/openclaw.json`: @@ -137,10 +141,9 @@ Add to `~/.openclaw/openclaw.json`: } ``` -
-
-Claude Desktop + +**Claude Desktop** Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -155,10 +158,9 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` -
-
-Claude Code + +**Claude Code** Add to `~/.claude/mcp.json`: @@ -173,10 +175,9 @@ Add to `~/.claude/mcp.json`: } ``` -
-
-Codex + +**Codex** Add to `~/.codex/config.toml`: @@ -186,10 +187,9 @@ command = "npx" args = ["-y", "browserforce@latest", "mcp"] ``` -
-
-Cursor + +**Cursor** Add to `~/.cursor/mcp.json`: @@ -204,10 +204,9 @@ Add to `~/.cursor/mcp.json`: } ``` -
-
-Antigravity + +**Antigravity** In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. Add the same `mcpServers` entry: @@ -223,7 +222,7 @@ Add the same `mcpServers` entry: } ``` -
+ If MCP startup fails with `connection closed: initialize response`: @@ -268,10 +267,12 @@ That's it. Restart MCP (or Claude Desktop) and `highlight()` is available in eve ### Official plugins -| Plugin | What it adds | Install | -|--------|-------------|---------| + +| Plugin | What it adds | Install | +| ----------- | ---------------------------------------------------------------------------------------------- | --------------------------------------- | | `highlight` | `highlight(selector, color?)` — outlines matching elements; `clearHighlights()` — removes them | `browserforce plugin install highlight` | + ### Use an installed plugin After installing `highlight`, your agent can call it directly: @@ -367,10 +368,12 @@ state.results = await page.evaluate(() => document.title); ### MCP Tools -| Tool | Description | -|------|-------------| + +| Tool | Description | +| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `execute` | Run Playwright JavaScript in your real Chrome. Access `page`, `context`, `state`, `snapshot()`, `waitForPageLoad()`, `getLogs()`, `screenshotWithAccessibilityLabels()`, `cleanHTML()`, `pageMarkdown()`, and Node.js globals. | -| `reset` | Reconnect to the relay and clear state. Use when the connection drops. | +| `reset` | Reconnect to the relay and clear state. Use when the connection drops. | + ### Diff-Aware Helpers @@ -383,133 +386,204 @@ await cleanHTML('body', { showDiffSinceLastCall: false }); await pageMarkdown({ showDiffSinceLastCall: true }); ``` +### BrowserForce Tab Swarms // Parallel Tabs Processing + +BrowserForce uses a parallel-first policy for independent extraction jobs, so agents finish list/count/scrape tasks faster with bounded risk. + +- Rule: For count/list/extraction across independent pages, dates, or items, run parallel tabs first using `Promise.all` with a concurrency cap (`3-8`, typically start at `5`). +- Fallback: If the site starts rate-limiting (`429`), anti-bot challenges appear, or timeouts repeat, automatically retry with reduced concurrency and then sequential as a final fallback. +- Safety: This swarm exception is for read-only bulk extraction only; no user-tab mutation (checkout/purchase/send/delete/settings changes) during swarm runs. +- Required telemetry return: `peakConcurrentTasks`, `wallClockMs`, `sumTaskDurationsMs`, `failures`, `retries`. + Need role-based, real workflows? See [Actionable Use Cases](docs/USE_CASES.md). ## Examples Get started with simple prompts. The AI generates code and does the work. -
-Example 1: Read page content (X.com search) +**Example 1: Read page content (X.com search)** **Prompt to AI:** + > Go to x.com/search and search for "browserforce". Show me the top 5 tweets you find. **What the AI does:** Navigates to X, searches the term, extracts top tweets, returns them to you. **Use case:** Quick research, trend tracking, social listening. -
-
-Example 2: Interact with a form (GitHub search) + +**Example 2: Interact with a form (GitHub search)** **Prompt to AI:** + > Go to GitHub and search for "ai agents". Show me the top 3 repositories and their star counts. **What the AI does:** Fills GitHub search, waits for results, extracts repo names + stars, returns them. **Use case:** Finding libraries, competitive research, project discovery. -
+ ### Multi-Tab Workflows -
-Example 3: Search → Extract → Return +**Example 3: Search → Extract → Return** **Prompt to AI:** + > Search ProductHunt for "AI tools" and give me the top 5 products with their taglines and upvote counts. **What the AI does:** Navigates ProductHunt, searches, extracts product info, returns structured data. **Use case:** Market research, finding tools, competitive analysis. -
-
-Example 4: Open result in new tab, process there + +**Example 4: Open result in new tab, process there** **Prompt to AI:** + > Find the #1 product from your last ProductHunt search, click into it, and read the full description. Tell me what it does. **What the AI does:** Opens the product page from previous results, reads the description, summarizes it. **Use case:** Deep-dive research, understanding competitors, due diligence. -
-
-Example 5: Debugging workflow (inspect + verify) + +**Example 5: Debugging workflow (inspect + verify)** **Prompt to AI:** + > Go to my staging site at staging.myapp.com/checkout and take a labeled screenshot. Tell me if the "Complete Purchase" button is visible and what's around it. **What the AI does:** Navigates, takes screenshot with interactive labels, analyzes button state and layout. **Use case:** Visual debugging, QA checks, spotting broken elements. -
-
-Example 6: Test form with data + +**Example 6: Test form with data** **Prompt to AI:** -> Sign up for Substack using the email test.user@example.com. Tell me if the signup completes successfully. + +> Sign up for Substack using the email [test.user@example.com](mailto:test.user@example.com). Tell me if the signup completes successfully. **What the AI does:** Fills the form, submits, waits for confirmation, reports success/failure. **Use case:** Testing sign-up flows, QA automation, form validation. -
-
-Example 7: Content pipeline (search → extract → compare) + +**Example 7: Content pipeline (search → extract → compare)** **Prompt to AI:** + > Search for "AI regulation" on both X.com and LinkedIn. Give me the top 5 trending posts from each and tell me which topics overlap. **What the AI does:** Searches both platforms, extracts posts, compares content, returns analysis. **Use case:** Multi-source research, trend analysis, market sentiment. -
-
-Example 8: Data extraction → CSV pipeline + +**Example 8: Data extraction → CSV pipeline** **Prompt to AI:** + > Go to Hacker News and extract the top 10 stories with their titles and vote counts. Format as CSV so I can import into a spreadsheet. **What the AI does:** Navigates HN, extracts story data, formats as CSV, returns it ready to paste. **Use case:** Data workflows, trend tracking, content curation. -
-
-Example 9: A/B testing across variants + +**Example 9: A/B testing across variants** **Prompt to AI:** + > Visit myapp.com/?variant=red and myapp.com/?variant=blue. Compare the two designs and tell me which button color is more prominent and what other differences exist. **What the AI does:** Opens both variants, compares layouts/colors/text, reports visual differences. **Use case:** Design QA, A/B testing, variant comparison. -
-
-Example 10: Monitor + alert workflow + +**Example 10: Monitor + alert workflow** **Prompt to AI:** + > Check our status page at status.myapp.com every few minutes. Tell me the current status of the API and database. Alert me if anything changes from green to red. **What the AI does:** Monitors status page, reads indicators, alerts on degradation. **Use case:** Uptime monitoring, incident detection, SLA tracking. -
+ + +### Parallel Tab Swarms: Real-World Use Cases + +**Example 11: Retail price swarm (SKU × store matrix)** + +**Prompt to AI:** + +> For these 25 SKUs, check Amazon, Walmart, Target, and Best Buy in parallel tabs. Return the best price, in-stock status, and fastest delivery ETA per SKU. + +**What the AI does:** Runs independent `(sku, store)` checks in capped parallel tab batches, retries with reduced concurrency on `429`/timeouts, then falls back sequentially if needed. + +**Use case:** Pricing intelligence, buy-box monitoring, merchandising ops. + + + +**Example 12: Travel fare grid (date × route sweep)** + +**Prompt to AI:** + +> For SFO → JFK, scan the next 14 Fridays and Sundays across Google Flights, Kayak, and Expedia. Return the cheapest refundable option for each date. + +**What the AI does:** Opens independent `(date, site)` tasks in parallel, extracts fare + refundability, and returns a normalized comparison table. + +**Use case:** Travel operations, procurement, rapid itinerary optimization. + + + +**Example 13: Competitor launch radar (company × source)** + +**Prompt to AI:** + +> Track the last 7 days of updates for these 30 competitors across release notes, changelogs, docs, and blog posts. Group findings by feature category. + +**What the AI does:** Parallelizes `(company, source)` extraction, deduplicates announcements, and returns a launch digest with links. + +**Use case:** Product strategy, PM intelligence, competitive monitoring. + + + +**Example 14: Lead qualification swarm (account × signal source)** + +**Prompt to AI:** + +> For this account list, check careers pages, LinkedIn jobs, pricing pages, and press/news for expansion signals. Score each account and rank top opportunities. + +**What the AI does:** Executes independent account-source checks in parallel tabs, extracts signal evidence, and returns ranked lead scores with rationale. + +**Use case:** Sales research, outbound prioritization, RevOps signal mining. + + + +**Example 15: Security exposure triage (domain × surface)** + +**Prompt to AI:** + +> For these domains, inspect login pages, robots.txt, status pages, public docs, and likely staging links. Flag suspicious exposures with evidence links. + +**What the AI does:** Runs read-only `(domain, surface)` checks in a swarm, retries degraded paths safely, and returns a risk-prioritized findings report. + +**Use case:** Security reviews, surface mapping, pre-audit triage. + + **More examples** and detailed walkthrough available in the [User Guide](GUIDE.md#examples). @@ -544,16 +618,18 @@ In **Auto mode**, the agent can create and control tabs it opens. In **Manual mo Click the extension icon to configure restrictions. Your browser, your rules: -| Setting | What it does | -|---------|-------------| -| **Auto / Manual mode** | Let the agent create tabs freely, or hand-pick which tabs it can access | -| **Lock URL** | Prevent the agent from navigating away from the current page | -| **No new tabs** | Block the agent from opening new tabs | -| **Read-only** | Observe only — no clicks, no typing, no interactions | -| **Auto-detach** | Automatically detach inactive tabs after 5-60 minutes | -| **Auto-close** | Automatically close agent-created tabs after 5-60 minutes | + +| Setting | What it does | +| ----------------------- | ------------------------------------------------------------------------ | +| **Auto / Manual mode** | Let the agent create tabs freely, or hand-pick which tabs it can access | +| **Lock URL** | Prevent the agent from navigating away from the current page | +| **No new tabs** | Block the agent from opening new tabs | +| **Read-only** | Observe only — no clicks, no typing, no interactions | +| **Auto-detach** | Automatically detach inactive tabs after 5-60 minutes | +| **Auto-close** | Automatically close agent-created tabs after 5-60 minutes | | **Custom instructions** | Pass text instructions to the agent (e.g. "don't click any buy buttons") | + ### Controlled Tab Workflows - **Manually attach a tab:** Open the tab you want, click the extension popup, then click **+ Attach Current Tab**. @@ -566,19 +642,22 @@ For step-by-step setups, see the [Controlled Tabs Playbook](GUIDE.md#controlled- ## Security -| Layer | Control | -|-------|---------| -| **Network** | Relay binds to `127.0.0.1` only — never exposed to the internet | -| **Auth** | Random token required for every CDP connection | -| **Origin** | Extension only accepts connections from its own Chrome origin | -| **Visibility** | Chrome shows "controlled by automated test software" on active tabs | + +| Layer | Control | +| ---------------- | ----------------------------------------------------------------------- | +| **Network** | Relay binds to `127.0.0.1` only — never exposed to the internet | +| **Auth** | Random token required for every CDP connection | +| **Origin** | Extension only accepts connections from its own Chrome origin | +| **Visibility** | Chrome shows "controlled by automated test software" on active tabs | | **Restrictions** | Lock URLs, block navigation, read-only mode — enforced at the CDP level | + Everything runs on your machine. The auth token is stored at `~/.browserforce/auth-token` with owner-only permissions. ## Configuration **Custom relay port:** + ```bash RELAY_PORT=19333 browserforce serve ``` @@ -586,6 +665,7 @@ RELAY_PORT=19333 browserforce serve **Extension relay URL:** Click the extension icon → change the URL → Save. Default: `ws://127.0.0.1:19222/extension` **Override CDP URL for MCP:** + ```json { "env": { @@ -596,23 +676,27 @@ RELAY_PORT=19333 browserforce serve ## API -| Endpoint | Description | -|----------|-------------| -| `GET /` | Health check (extension status, target count) | -| `GET /json/version` | CDP discovery | -| `GET /json/list` | List attached targets | -| `ws://.../extension` | Chrome extension WebSocket | -| `ws://.../cdp?token=...` | Agent CDP connection | + +| Endpoint | Description | +| ------------------------ | --------------------------------------------- | +| `GET /` | Health check (extension status, target count) | +| `GET /json/version` | CDP discovery | +| `GET /json/list` | List attached targets | +| `ws://.../extension` | Chrome extension WebSocket | +| `ws://.../cdp?token=...` | Agent CDP connection | + ## Troubleshooting -| Problem | Fix | -|---------|-----| -| Extension stays gray | Is the relay running? Check `http://127.0.0.1:19222/` | -| "Another debugger attached" | Close DevTools for that tab | -| Agent sees 0 pages | Open at least one regular webpage (not `chrome://`) | -| Extension keeps reconnecting | Normal — MV3 kills idle workers; it auto-recovers | -| Port in use | `lsof -ti:19222 \| xargs kill -9` | + +| Problem | Fix | +| ---------------------------- | ----------------------------------------------------- | +| Extension stays gray | Is the relay running? Check `http://127.0.0.1:19222/` | +| "Another debugger attached" | Close DevTools for that tab | +| Agent sees 0 pages | Open at least one regular webpage (not `chrome://`) | +| Extension keeps reconnecting | Normal — MV3 kills idle workers; it auto-recovers | +| Port in use | `lsof -ti:19222 | xargs kill -9` | + CDP traffic is logged to `~/.browserforce/cdp.jsonl` (recreated on each relay start). Summarize traffic by direction + method: @@ -623,3 +707,4 @@ jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp. For practical debugging and operations flows, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. + diff --git a/mcp/src/index.js b/mcp/src/index.js index b3bc12d..e523484 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -180,6 +180,7 @@ After every action, verify its result before proceeding: Never chain multiple actions blindly. If you click a button, verify it worked before clicking the next. Each execute call should do ONE meaningful action and return verification. +Exception: Multi-step is allowed for read-only bulk extraction when actions are independent and no user-tab mutation occurs. When navigating: await state.page.goto(url); @@ -294,6 +295,24 @@ snapshot vs cleanHTML vs pageMarkdown: 3) Use pageMarkdown() for article/blog/news pages where nav/ads should be removed. 4) Use screenshotWithAccessibilityLabels() only when layout/visual evidence is required. +═══ BROWSERFORCE TAB SWARMS // PARALLEL TABS PROCESSING ═══ + +Parallel-first policy for independent extraction: + 1) For count/list/extraction across independent pages, dates, or items, start with parallel tabs first. + 2) Use Promise.all with a concurrency cap (typically 3-8; start at 5 unless site limits are known). + 3) Keep swarm runs read-only and isolated to agent-created tabs (no checkout/purchase/send/delete/profile changes). + 4) If you hit 429, anti-bot challenges, or repeated timeouts, automatically retry with reduced concurrency. + 5) If reduced concurrency still fails, retry sequentially. + +Always return telemetry for swarm runs: + { + peakConcurrentTasks, + wallClockMs, + sumTaskDurationsMs, + failures, + retries + } + ═══ DEBUGGING WORKFLOW ═══ Combine snapshot + logs: @@ -375,7 +394,8 @@ When you need the full tree instead of diff output: ✗ Don't chain actions without verifying — observe after each action ✗ Don't use page.waitForTimeout() — use waitForPageLoad() or waitFor() ✗ Don't forget to return a value — every call should return verification -✗ Don't write complex multi-step scripts — split into separate execute calls +✗ Don't write complex multi-step scripts by default — split into separate execute calls +✓ Exception: Multi-step is allowed for read-only bulk extraction when actions are independent and no user-tab mutation occurs ✗ Don't use page variable directly — use state.page after first call setup ═══ ERROR RECOVERY ═══ diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index fcefc4b..da8f2c0 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -148,6 +148,34 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('Downloads'), 'should include download pattern'); }); + it('execute prompt includes parallel-first swarm policy and telemetry contract', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + const promptStart = source.indexOf('const EXECUTE_PROMPT'); + const promptEnd = source.indexOf("server.tool(\n 'execute'"); + const promptBlock = source.slice(promptStart, promptEnd); + + assert.ok( + promptBlock.includes('BROWSERFORCE TAB SWARMS // PARALLEL TABS PROCESSING'), + 'should include tab swarm policy section' + ); + assert.ok( + promptBlock.includes('Promise.all with a concurrency cap'), + 'should include parallel-first concurrency guidance' + ); + assert.ok( + promptBlock.includes('Multi-step is allowed for read-only bulk extraction'), + 'should include explicit anti-pattern exception for read-only bulk extraction' + ); + assert.ok(promptBlock.includes('peakConcurrentTasks'), 'should require peakConcurrentTasks telemetry'); + assert.ok(promptBlock.includes('wallClockMs'), 'should require wallClockMs telemetry'); + assert.ok(promptBlock.includes('sumTaskDurationsMs'), 'should require sumTaskDurationsMs telemetry'); + assert.ok(promptBlock.includes('failures'), 'should require failures telemetry'); + assert.ok(promptBlock.includes('retries'), 'should require retries telemetry'); + }); + it('execute tool has code and optional timeout params', () => { const source = readFileSync( join(import.meta.url.replace('file://', ''), '../../src/index.js'), From 1915a008ae9b6226e810cd453c0ebe623437d33d Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:42:10 +0530 Subject: [PATCH 056/192] docs(readme): collapse advanced MCP setup into details block --- README.md | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index a9a81ae..bf71c1b 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ browserforce serve If your agent browses to the page and responds with the title, you're all set. -**MCP setup (advanced):** +
+MCP setup (advanced) **OpenClaw (MCP adapter)** @@ -141,8 +142,6 @@ Add to `~/.openclaw/openclaw.json`: } ``` - - **Claude Desktop** Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -158,8 +157,6 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` - - **Claude Code** Add to `~/.claude/mcp.json`: @@ -175,8 +172,6 @@ Add to `~/.claude/mcp.json`: } ``` - - **Codex** Add to `~/.codex/config.toml`: @@ -187,8 +182,6 @@ command = "npx" args = ["-y", "browserforce@latest", "mcp"] ``` - - **Cursor** Add to `~/.cursor/mcp.json`: @@ -204,8 +197,6 @@ Add to `~/.cursor/mcp.json`: } ``` - - **Antigravity** In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. @@ -222,14 +213,14 @@ Add the same `mcpServers` entry: } ``` - - If MCP startup fails with `connection closed: initialize response`: 1. Ensure args include `"mcp"` (without it, BrowserForce prints help and exits). 2. If running from a local clone, install deps first: `pnpm install`. 3. Validate the launch command manually: `npx -y browserforce@latest mcp` +
+ ### CLI ```bash @@ -707,4 +698,3 @@ jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp. For practical debugging and operations flows, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. - From edf2bb0b6afc49473f27c56bccdb88150dcbf2de Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:42:11 +0530 Subject: [PATCH 057/192] feat(mcp): retry when relay slot is busy instead of failing immediately --- mcp/src/exec-engine.js | 37 +++++++++++++++++++++++++++++++++++++ mcp/src/index.js | 29 +++++++++++++++++++++++++++-- mcp/test/mcp-tools.test.js | 10 ++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index c4f2e86..a2848c6 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -45,6 +45,43 @@ export function getRelayHttpUrl() { } } +export function isCdpBusyError(err) { + const message = String(err?.message || '').toLowerCase(); + return ( + message.includes('409') || + message.includes('slot busy') || + message.includes('slot is busy') || + message.includes('busy') || + message.includes('already connected') || + message.includes('already in use') || + message.includes('another cdp client') + ); +} + +export async function waitForFreeClientSlot({ timeoutMs = 30000, baseUrl } = {}) { + const start = Date.now(); + const resolvedBaseUrl = String(baseUrl || getRelayHttpUrl()).replace(/\/+$/, ''); + const slotUrl = `${resolvedBaseUrl}/client-slot`; + + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(slotUrl, { signal: AbortSignal.timeout(2000) }); + if (res.ok) { + const data = await res.json(); + if (data && data.busy === false) return true; + } + } catch { /* keep polling until timeout */ } + + const elapsed = Date.now() - start; + const remaining = timeoutMs - elapsed; + if (remaining <= 0) break; + const jitteredDelayMs = 200 + Math.floor(Math.random() * 200); + await new Promise((r) => globalThis.setTimeout(r, Math.min(jitteredDelayMs, remaining))); + } + + return false; +} + // ─── Auto-start relay ─────────────────────────────────────────────────────── function getRelayPort() { diff --git a/mcp/src/index.js b/mcp/src/index.js index b3bc12d..c5ce9ab 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -7,7 +7,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { chromium } from 'playwright-core'; import { - getCdpUrl, ensureRelay, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, + getCdpUrl, getRelayHttpUrl, ensureRelay, isCdpBusyError, waitForFreeClientSlot, + CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, } from './exec-engine.js'; import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js'; import { checkForUpdate } from './update-check.js'; @@ -63,12 +64,36 @@ function ensureAllPagesCapture() { // ─── Browser Connection ────────────────────────────────────────────────────── let browser = null; +const CONNECT_RETRY_TIMEOUT_MS = 30000; async function ensureBrowser() { if (browser?.isConnected()) return; await ensureRelay(); const cdpUrl = getCdpUrl(); - browser = await chromium.connectOverCDP(cdpUrl); + const baseUrl = getRelayHttpUrl(); + const deadline = Date.now() + CONNECT_RETRY_TIMEOUT_MS; + let lastBusyError = null; + + while (!browser && Date.now() < deadline) { + try { + browser = await chromium.connectOverCDP(cdpUrl); + } catch (err) { + if (!isCdpBusyError(err)) throw err; + lastBusyError = err; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const slotFreed = await waitForFreeClientSlot({ + timeoutMs: remainingMs, + baseUrl, + }); + if (!slotFreed) break; + } + } + + if (!browser) { + throw lastBusyError || new Error('Failed to connect to CDP relay'); + } + browser.on('disconnected', () => { browser = null; contextListenerAttached = false; diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index fcefc4b..32a5ad8 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -544,3 +544,13 @@ describe('smartWaitForPageLoad', () => { assert.equal(expectedShape.timedOut, false); }); }); + +// ─── CDP Busy Helpers ─────────────────────────────────────────────────────── + +describe('CDP Busy Helpers', () => { + it('detects relay slot contention errors', async () => { + const { isCdpBusyError } = await import('../src/exec-engine.js'); + assert.equal(isCdpBusyError(new Error('Unexpected server response: 409')), true); + assert.equal(isCdpBusyError(new Error('ECONNREFUSED')), false); + }); +}); From c2d8011bb44cb986b943867f80c2107f45a761c3 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:47:47 +0530 Subject: [PATCH 058/192] test(mcp): cover busy retry path for CDP connection --- mcp/src/exec-engine.js | 26 ++++++++++++++++++ mcp/src/index.js | 31 +++++----------------- mcp/test/mcp-tools.test.js | 54 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 24 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index a2848c6..b593861 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -82,6 +82,32 @@ export async function waitForFreeClientSlot({ timeoutMs = 30000, baseUrl } = {}) return false; } +export async function connectOverCdpWithBusyRetry({ + connect, + cdpUrl, + baseUrl = getRelayHttpUrl(), + timeoutMs = 30000, + waitForFreeSlot = waitForFreeClientSlot, +} = {}) { + const deadline = Date.now() + timeoutMs; + let lastBusyError = null; + + while (Date.now() < deadline) { + try { + return await connect(cdpUrl); + } catch (err) { + if (!isCdpBusyError(err)) throw err; + lastBusyError = err; + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const slotFreed = await waitForFreeSlot({ timeoutMs: remainingMs, baseUrl }); + if (!slotFreed) break; + } + } + + throw lastBusyError || new Error('Failed to connect to CDP relay'); +} + // ─── Auto-start relay ─────────────────────────────────────────────────────── function getRelayPort() { diff --git a/mcp/src/index.js b/mcp/src/index.js index c5ce9ab..8bd7fb1 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -7,7 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { z } from 'zod'; import { chromium } from 'playwright-core'; import { - getCdpUrl, getRelayHttpUrl, ensureRelay, isCdpBusyError, waitForFreeClientSlot, + getCdpUrl, getRelayHttpUrl, ensureRelay, connectOverCdpWithBusyRetry, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, } from './exec-engine.js'; import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js'; @@ -70,29 +70,12 @@ async function ensureBrowser() { if (browser?.isConnected()) return; await ensureRelay(); const cdpUrl = getCdpUrl(); - const baseUrl = getRelayHttpUrl(); - const deadline = Date.now() + CONNECT_RETRY_TIMEOUT_MS; - let lastBusyError = null; - - while (!browser && Date.now() < deadline) { - try { - browser = await chromium.connectOverCDP(cdpUrl); - } catch (err) { - if (!isCdpBusyError(err)) throw err; - lastBusyError = err; - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) break; - const slotFreed = await waitForFreeClientSlot({ - timeoutMs: remainingMs, - baseUrl, - }); - if (!slotFreed) break; - } - } - - if (!browser) { - throw lastBusyError || new Error('Failed to connect to CDP relay'); - } + browser = await connectOverCdpWithBusyRetry({ + connect: (url) => chromium.connectOverCDP(url), + cdpUrl, + baseUrl: getRelayHttpUrl(), + timeoutMs: CONNECT_RETRY_TIMEOUT_MS, + }); browser.on('disconnected', () => { browser = null; diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 32a5ad8..ddf64e6 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -553,4 +553,58 @@ describe('CDP Busy Helpers', () => { assert.equal(isCdpBusyError(new Error('Unexpected server response: 409')), true); assert.equal(isCdpBusyError(new Error('ECONNREFUSED')), false); }); + + it('retries busy connect and succeeds after slot is free', async () => { + const { connectOverCdpWithBusyRetry } = await import('../src/exec-engine.js'); + + let connectCalls = 0; + const expectedBrowser = { connected: true }; + const connect = async () => { + connectCalls += 1; + if (connectCalls === 1) { + throw new Error('Unexpected server response: 409'); + } + return expectedBrowser; + }; + + let waitCalls = 0; + const waitForFreeSlot = async () => { + waitCalls += 1; + return true; + }; + + const browser = await connectOverCdpWithBusyRetry({ + connect, + cdpUrl: 'ws://127.0.0.1:19222/cdp?token=test', + baseUrl: 'http://127.0.0.1:19222', + timeoutMs: 5000, + waitForFreeSlot, + }); + + assert.equal(browser, expectedBrowser); + assert.equal(connectCalls, 2); + assert.equal(waitCalls, 1); + }); + + it('does not retry non-busy connect errors', async () => { + const { connectOverCdpWithBusyRetry } = await import('../src/exec-engine.js'); + + let waitCalls = 0; + const error = new Error('ECONNREFUSED'); + + await assert.rejects( + () => connectOverCdpWithBusyRetry({ + connect: async () => { throw error; }, + cdpUrl: 'ws://127.0.0.1:19222/cdp?token=test', + timeoutMs: 5000, + waitForFreeSlot: async () => { + waitCalls += 1; + return true; + }, + }), + /ECONNREFUSED/ + ); + + assert.equal(waitCalls, 0); + }); }); From 1d90a9830674d1d35a868d2d9d17a1d362cb89f6 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:49:09 +0530 Subject: [PATCH 059/192] Revert "docs(readme): collapse advanced MCP setup into details block" This reverts commit 1915a008ae9b6226e810cd453c0ebe623437d33d. --- README.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index bf71c1b..a9a81ae 100644 --- a/README.md +++ b/README.md @@ -113,8 +113,7 @@ browserforce serve If your agent browses to the page and responds with the title, you're all set. -
-MCP setup (advanced) +**MCP setup (advanced):** **OpenClaw (MCP adapter)** @@ -142,6 +141,8 @@ Add to `~/.openclaw/openclaw.json`: } ``` + + **Claude Desktop** Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -157,6 +158,8 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` + + **Claude Code** Add to `~/.claude/mcp.json`: @@ -172,6 +175,8 @@ Add to `~/.claude/mcp.json`: } ``` + + **Codex** Add to `~/.codex/config.toml`: @@ -182,6 +187,8 @@ command = "npx" args = ["-y", "browserforce@latest", "mcp"] ``` + + **Cursor** Add to `~/.cursor/mcp.json`: @@ -197,6 +204,8 @@ Add to `~/.cursor/mcp.json`: } ``` + + **Antigravity** In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. @@ -213,14 +222,14 @@ Add the same `mcpServers` entry: } ``` + + If MCP startup fails with `connection closed: initialize response`: 1. Ensure args include `"mcp"` (without it, BrowserForce prints help and exits). 2. If running from a local clone, install deps first: `pnpm install`. 3. Validate the launch command manually: `npx -y browserforce@latest mcp` -
- ### CLI ```bash @@ -698,3 +707,4 @@ jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp. For practical debugging and operations flows, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. + From 7d902cf0c492edcb77d8039d7495ee1e2332c60f Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:49:41 +0530 Subject: [PATCH 060/192] docs(readme): make advanced MCP providers individually collapsible --- README.md | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index a9a81ae..a9416ff 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,8 @@ If your agent browses to the page and responds with the title, you're all set. **MCP setup (advanced):** -**OpenClaw (MCP adapter)** +
+OpenClaw (MCP adapter) Add to `~/.openclaw/openclaw.json`: @@ -141,9 +142,10 @@ Add to `~/.openclaw/openclaw.json`: } ``` +
- -**Claude Desktop** +
+Claude Desktop Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: @@ -158,9 +160,10 @@ Add to `~/Library/Application Support/Claude/claude_desktop_config.json`: } ``` +
- -**Claude Code** +
+Claude Code Add to `~/.claude/mcp.json`: @@ -175,9 +178,10 @@ Add to `~/.claude/mcp.json`: } ``` +
- -**Codex** +
+Codex Add to `~/.codex/config.toml`: @@ -187,9 +191,10 @@ command = "npx" args = ["-y", "browserforce@latest", "mcp"] ``` +
- -**Cursor** +
+Cursor Add to `~/.cursor/mcp.json`: @@ -204,9 +209,10 @@ Add to `~/.cursor/mcp.json`: } ``` +
- -**Antigravity** +
+Antigravity In Antigravity: Agent panel -> `...` -> `Manage MCP Servers` -> `View raw config`. Add the same `mcpServers` entry: @@ -222,6 +228,8 @@ Add the same `mcpServers` entry: } ``` +
+ If MCP startup fails with `connection closed: initialize response`: @@ -707,4 +715,3 @@ jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp. For practical debugging and operations flows, see [Actionable Use Cases](docs/USE_CASES.md#developer-high-impact). > **Want the full walkthrough?** Read the [User Guide](https://github.com/ivalsaraj/browserforce/blob/main/GUIDE.md) for a plain-English explanation of what this does and how to get started. - From 00652e23f17348ae6a4010e6d0e24b5115e5778f Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:51:49 +0530 Subject: [PATCH 061/192] docs: add single-active arbitration mode and fallback behavior --- AGENTS.md | 18 ++++++++++++++++++ README.md | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 2a6a8c3..326958e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,6 +157,18 @@ When a user clicks "Cancel" on Chrome's automation infobar, Chrome detaches the `RelayServer.start()` accepts `{ writeCdpUrl: false }` to prevent test instances from clobbering `~/.browserforce/cdp-url`. **All test `relay.start()` calls must pass `{ writeCdpUrl: false }`** or the production cdp-url file gets overwritten with random test ports. +### Client Arbitration: BF_CLIENT_MODE + +`BF_CLIENT_MODE` controls agent-side CDP arbitration: +- `single-active` (default): only one active `/cdp` client connection at a time. +- `multi-client`: fallback mode that allows concurrent `/cdp` clients. + +In `single-active`, contention returns HTTP `409 Conflict` for additional `/cdp` connects while the slot is busy. Slot state is exposed at `GET /client-slot` (`mode`, `busy`, `activeClientId`, `connectedAt`). + +### MCP Standby Polling + +MCP handles `409`/busy connect errors by entering standby and polling `GET /client-slot` with short jittered intervals (~200-400ms), then reconnecting when `busy: false` (up to a 30s connect timeout). + ## Security Rules - Relay binds to `127.0.0.1` ONLY. Never `0.0.0.0`. @@ -165,6 +177,12 @@ When a user clicks "Cancel" on Chrome's automation infobar, Chrome detaches the - Token file permissions: `0o600` (owner read/write only). - Single extension slot. Second extension connection gets HTTP 409. +## Operational Non-Goals + +- No new dependencies for client arbitration or standby behavior. +- No per-tab ownership model; arbitration is one relay-level client slot. +- No extension protocol changes for this feature area. + ## Development Workflow ### Commands diff --git a/README.md b/README.md index 0df7634..fa77b02 100644 --- a/README.md +++ b/README.md @@ -594,11 +594,31 @@ RELAY_PORT=19333 browserforce serve } ``` +**Client arbitration mode (`BF_CLIENT_MODE`):** + +```bash +# default: one active /cdp client at a time +BF_CLIENT_MODE=single-active browserforce serve + +# fallback: allow concurrent /cdp clients +BF_CLIENT_MODE=multi-client browserforce serve +``` + +In `single-active` mode, the relay enforces one active client slot. A second `/cdp` connection receives HTTP `409 Conflict` (busy). In `multi-client` mode, slot arbitration is disabled. + +**MCP standby polling (single-active mode):** if MCP sees a busy/`409` connect error, it enters standby and polls `GET /client-slot` until `busy: false` (about every 200-400ms, up to 30s), then retries connect. + +**Operational non-goals:** +- No new dependencies for arbitration or standby logic. +- No per-tab ownership complexity; arbitration is process-level client-slot control. +- No extension protocol changes (no new extension↔relay message types). + ## API | Endpoint | Description | |----------|-------------| | `GET /` | Health check (extension status, target count) | +| `GET /client-slot` | Client-slot state: `{ mode, busy, activeClientId, connectedAt }` | | `GET /json/version` | CDP discovery | | `GET /json/list` | List attached targets | | `ws://.../extension` | Chrome extension WebSocket | From 11da90cf783001ac8d4d48e3d42ca07991ceacbf Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 24 Feb 2026 21:56:34 +0530 Subject: [PATCH 062/192] docs: dedupe non-goals and point README to AGENTS --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index fa77b02..0ffd10f 100644 --- a/README.md +++ b/README.md @@ -608,10 +608,7 @@ In `single-active` mode, the relay enforces one active client slot. A second `/c **MCP standby polling (single-active mode):** if MCP sees a busy/`409` connect error, it enters standby and polls `GET /client-slot` until `busy: false` (about every 200-400ms, up to 30s), then retries connect. -**Operational non-goals:** -- No new dependencies for arbitration or standby logic. -- No per-tab ownership complexity; arbitration is process-level client-slot control. -- No extension protocol changes (no new extension↔relay message types). +**Operational non-goals:** canonical list is maintained in [AGENTS.md](AGENTS.md#operational-non-goals). ## API From a87579df1f94cd9785896897390e40eb26c631dc Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 25 Feb 2026 00:01:55 +0530 Subject: [PATCH 063/192] docs(guide): revise user guide to focus on advanced workflows and controlled tab management --- GUIDE.md | 571 +++++++++++------------------------------------------- README.md | 2 +- 2 files changed, 112 insertions(+), 461 deletions(-) diff --git a/GUIDE.md b/GUIDE.md index 990d484..26563ea 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1,345 +1,95 @@ -# BrowserForce — User Guide +# BrowserForce - Advanced Guide -**BrowserForce // Parallel AI Agents in "your" Chrome!** +This guide is an extension of README, not a duplicate. -## What is this? +Use README for onboarding and baseline usage: +- Install and extension setup: [README Setup](README.md#setup) +- Agent connection and MCP snippets: [README Connect Your Agent](README.md#connect-your-agent) +- CLI commands: [README CLI](README.md#cli) +- Core examples: [README Examples](README.md#examples) +- Security model: [README Security](README.md#security) -BrowserForce gives AI agents — like [OpenClaw](https://github.com/openclaw/openclaw), Claude, or any MCP-compatible tool — access to **your real Chrome browser**. The one you're already logged into. No headless browser, no fake profiles. The AI sees your actual tabs and can interact with any website using your existing sessions. - -**Example:** You tell your agent "go to my Gmail and summarize my latest emails" — and it actually opens your Gmail (already logged in), reads the page, and gives you a summary. No passwords, no login flows. - -## What can it do? - -### Browse the web as you - -| Capability | What it means | -|------------|---------------| -| **See your tabs** | AI sees all your open Chrome tabs instantly | -| **Navigate** | Open any URL in your real browser (with your cookies) | -| **Open new tabs** | Create tabs that inherit all your sessions | -| **Close tabs** | Clean up when done | - -### Interact with pages - -| Capability | What it means | -|------------|---------------| -| **Click** | Click buttons, links, menus — anything | -| **Type** | Type text into any input, search box, or contenteditable field | -| **Fill forms** | Fill input fields (clears existing value first) | -| **Press keys** | Enter, Tab, Escape, Ctrl+C, any key combo | -| **Scroll** | Scroll pages or specific elements | -| **Hover** | Trigger hover menus and tooltips | -| **Select dropdowns** | Pick options from ` +
+ + +
+ +
+ + +
+
diff --git a/extension/popup.js b/extension/popup.js index f6bc646..be61438 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -21,6 +21,8 @@ const autoTimerEl = document.getElementById('bf-auto-timer'); const attachBtn = document.getElementById('bf-attach-tab'); const openLogsBtn = document.getElementById('bf-open-logs'); const modeSelect = document.getElementById('bf-mode'); +const executionModeSelect = document.getElementById('bf-execution-mode'); +const parallelVisibilitySelect = document.getElementById('bf-parallel-visibility'); const lockUrlCb = document.getElementById('bf-lock-url'); const noNewTabsCb = document.getElementById('bf-no-new-tabs'); const readOnlyCb = document.getElementById('bf-read-only'); @@ -44,6 +46,7 @@ document.querySelectorAll('.tab-btn').forEach((btn) => { const SETTINGS_KEYS = [ 'relayUrl', 'autoDetachMinutes', 'autoCloseMinutes', 'mode', 'lockUrl', 'noNewTabs', 'readOnly', 'userInstructions', + 'executionMode', 'parallelVisibilityMode', ]; chrome.storage.local.get(SETTINGS_KEYS, (s) => { @@ -51,6 +54,8 @@ chrome.storage.local.get(SETTINGS_KEYS, (s) => { autoDetachSelect.value = String(s.autoDetachMinutes || 0); autoCloseSelect.value = String(s.autoCloseMinutes || 0); modeSelect.value = s.mode || 'auto'; + executionModeSelect.value = s.executionMode || 'parallel'; + parallelVisibilitySelect.value = s.parallelVisibilityMode || 'foreground-tab'; lockUrlCb.checked = !!s.lockUrl; noNewTabsCb.checked = !!s.noNewTabs; readOnlyCb.checked = !!s.readOnly; @@ -72,6 +77,14 @@ modeSelect.addEventListener('change', () => { chrome.storage.local.set({ mode: modeSelect.value }); }); +executionModeSelect.addEventListener('change', () => { + chrome.storage.local.set({ executionMode: executionModeSelect.value }); +}); + +parallelVisibilitySelect.addEventListener('change', () => { + chrome.storage.local.set({ parallelVisibilityMode: parallelVisibilitySelect.value }); +}); + autoDetachSelect.addEventListener('change', () => { chrome.storage.local.set({ autoDetachMinutes: Number(autoDetachSelect.value) }); }); From f5a27ff326f7db85a10f24c5c8d97b3f0134e127 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 25 Feb 2026 22:50:00 +0530 Subject: [PATCH 076/192] feat(extension): enforce visible parallel modes for agent-created tabs --- extension/background.js | 46 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 3 deletions(-) diff --git a/extension/background.js b/extension/background.js index a3f0cbc..721236d 100644 --- a/extension/background.js +++ b/extension/background.js @@ -189,6 +189,8 @@ async function executeCommand(msg) { }); }); }); + case 'getAgentPreferences': + return getAgentExecutionSettings(); default: throw new Error(`Unknown command: ${msg.method}`); } @@ -196,6 +198,31 @@ async function executeCommand(msg) { // ─── Tab Operations ────────────────────────────────────────────────────────── +async function getAgentExecutionSettings() { + const s = await chrome.storage.local.get(['executionMode', 'parallelVisibilityMode']); + const executionMode = s.executionMode === 'sequential' ? 'sequential' : 'parallel'; + const parallelVisibilityMode = + s.parallelVisibilityMode === 'rotate-visible' + ? 'rotate-visible' + : 'foreground-tab'; + + return { executionMode, parallelVisibilityMode }; +} + +async function getCurrentWindowId() { + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs[0] && typeof tabs[0].windowId === 'number') { + return tabs[0].windowId; + } + + const win = await chrome.windows.getLastFocused(); + if (win && typeof win.id === 'number') { + return win.id; + } + + return undefined; +} + async function listTabs() { const tabs = await chrome.tabs.query({}); return { @@ -294,10 +321,23 @@ async function createTab(params) { throw new Error(`BLOCKED: ${msg}`); } - const tab = await chrome.tabs.create({ + const agentSettings = await getAgentExecutionSettings(); + const windowId = await getCurrentWindowId(); + const createOptions = { url: params.url || 'about:blank', - active: false, - }); + // Keep agent-created tabs visible; do not spawn separate windows. + active: true, + }; + if (typeof windowId === 'number') { + createOptions.windowId = windowId; + } + + // rotate-visible remains normalized to visible tab creation in current window. + if (agentSettings.parallelVisibilityMode === 'rotate-visible') { + createOptions.active = true; + } + + const tab = await chrome.tabs.create(createOptions); // Brief delay for Chrome to finalize tab creation await sleep(200); From 47d83758055e886fe74761f5f90153f171aebf5f Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 25 Feb 2026 22:52:35 +0530 Subject: [PATCH 077/192] feat(relay): add agent-preferences endpoint backed by extension settings --- relay/src/index.js | 25 ++++++++++ relay/test/relay-server.test.js | 87 +++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/relay/src/index.js b/relay/src/index.js index 978f83d..b198eb9 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -75,6 +75,10 @@ function getClientMode() { // ─── RelayServer ───────────────────────────────────────────────────────────── const DEFAULT_BROWSER_CONTEXT_ID = 'bf-default-context'; +const DEFAULT_AGENT_PREFERENCES = Object.freeze({ + executionMode: 'parallel', + parallelVisibilityMode: 'foreground-tab', +}); // Commands Playwright sends automatically to every page during initialization. // We intercept these on unattached tabs and return synthetic responses so @@ -149,6 +153,13 @@ function syntheticInitResponse(method, target) { } } +function normalizeAgentPreferences(raw) { + const executionMode = raw?.executionMode === 'sequential' ? 'sequential' : 'parallel'; + // Keep relay behavior locked to visible tabs in the current window. + const parallelVisibilityMode = 'foreground-tab'; + return { executionMode, parallelVisibilityMode }; +} + class RelayServer { constructor(port = DEFAULT_PORT, pluginsDir = BF_PLUGINS_DIR) { this.port = port; @@ -330,6 +341,20 @@ class RelayServer { return; } + if (url.pathname === '/agent-preferences') { + if (!this.ext) { + res.end(JSON.stringify(DEFAULT_AGENT_PREFERENCES)); + return; + } + try { + const preferences = await this._sendToExt('getAgentPreferences'); + res.end(JSON.stringify(normalizeAgentPreferences(preferences))); + } catch { + res.end(JSON.stringify(DEFAULT_AGENT_PREFERENCES)); + } + return; + } + if (url.pathname === '/logs/status' && req.method === 'GET') { if (!this._requireExtensionOrigin(req, res)) return; res.end(JSON.stringify(this._logsStatus())); diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 860b4ca..18c0e55 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -1673,6 +1673,93 @@ describe('GET /restrictions endpoint', () => { }); }); +// ─── GET /agent-preferences Endpoint ──────────────────────────────────────── + +describe('GET /agent-preferences endpoint', () => { + let relay; + let port; + + before(async () => { + port = getRandomPort(); + relay = new RelayServer(port); + relay.start({ writeCdpUrl: false }); + await sleep(200); + }); + + after(() => { + relay.stop(); + }); + + it('returns defaults when no extension is connected', async () => { + const { status, body } = await httpGet(`http://127.0.0.1:${port}/agent-preferences`); + assert.equal(status, 200); + assert.deepEqual(body, { + executionMode: 'parallel', + parallelVisibilityMode: 'foreground-tab', + }); + }); + + it('forwards getAgentPreferences to extension and returns its response', async () => { + const ext = await connectWs(`ws://127.0.0.1:${port}/extension`, { + headers: { Origin: 'chrome-extension://test' }, + }); + + const extPreferences = { + executionMode: 'sequential', + parallelVisibilityMode: 'foreground-tab', + }; + + ext.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.method === 'ping') { ext.send(JSON.stringify({ method: 'pong' })); return; } + if (msg.id !== undefined && msg.method === 'getAgentPreferences') { + ext.send(JSON.stringify({ id: msg.id, result: extPreferences })); + } + }); + + await sleep(50); + + const { status, body } = await httpGet(`http://127.0.0.1:${port}/agent-preferences`); + assert.equal(status, 200); + assert.deepEqual(body, extPreferences); + + ext.close(); + await sleep(100); + }); + + it('normalizes rotate-visible to foreground-tab', async () => { + const ext = await connectWs(`ws://127.0.0.1:${port}/extension`, { + headers: { Origin: 'chrome-extension://test' }, + }); + + ext.on('message', (data) => { + const msg = JSON.parse(data.toString()); + if (msg.method === 'ping') { ext.send(JSON.stringify({ method: 'pong' })); return; } + if (msg.id !== undefined && msg.method === 'getAgentPreferences') { + ext.send(JSON.stringify({ + id: msg.id, + result: { + executionMode: 'parallel', + parallelVisibilityMode: 'rotate-visible', + }, + })); + } + }); + + await sleep(50); + + const { status, body } = await httpGet(`http://127.0.0.1:${port}/agent-preferences`); + assert.equal(status, 200); + assert.deepEqual(body, { + executionMode: 'parallel', + parallelVisibilityMode: 'foreground-tab', + }); + + ext.close(); + await sleep(100); + }); +}); + // ─── manualTabAttached Handler ─────────────────────────────────────────────── describe('manualTabAttached handler', () => { From ba1a4b07033f7584ae4b3d83f6d491398b1bbff8 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 25 Feb 2026 22:54:27 +0530 Subject: [PATCH 078/192] feat(mcp): cache agent execution preferences per session and expose in execute context --- mcp/src/exec-engine.js | 15 +++++++++++- mcp/src/index.js | 40 ++++++++++++++++++++++++++++++- mcp/test/mcp-tools.test.js | 48 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 2 deletions(-) diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index b593861..a18c3c7 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -470,7 +470,14 @@ export class CodeExecutionTimeoutError extends Error { // buildExecContext takes userState and optional console helpers as params // instead of referencing module-level singletons. -export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = {}, pluginHelpers = {}) { +export function buildExecContext( + defaultPage, + ctx, + userState, + consoleHelpers = {}, + pluginHelpers = {}, + agentPreferences = {}, +) { const { consoleLogs, setupConsoleCapture } = consoleHelpers; const lastSnapshots = userState.__lastSnapshots || (userState.__lastSnapshots = new WeakMap()); const lastRefToLocator = userState.__lastRefToLocator || (userState.__lastRefToLocator = new WeakMap()); @@ -573,6 +580,11 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { const pageMarkdown = (opts) => getPageMarkdown(activePage(), opts); + const browserforceSettings = { + executionMode: agentPreferences?.executionMode === 'sequential' ? 'sequential' : 'parallel', + parallelVisibilityMode: 'foreground-tab', + }; + // Wrap plugin helpers to auto-inject (page, ctx, state) as first three args const wrappedPluginHelpers = {}; for (const [name, fn] of Object.entries(pluginHelpers)) { @@ -585,6 +597,7 @@ export function buildExecContext(defaultPage, ctx, userState, consoleHelpers = { return { ...wrappedPluginHelpers, // plugin helpers spread first — built-ins always win + browserforceSettings, page: defaultPage, context: ctx, state: userState, snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, getCDPSession, screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, diff --git a/mcp/src/index.js b/mcp/src/index.js index 89cc9d6..fe5ad80 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -181,6 +181,39 @@ function getPages() { // ─── Persistent State ──────────────────────────────────────────────────────── let userState = {}; +const DEFAULT_AGENT_PREFERENCES = Object.freeze({ + executionMode: 'parallel', + parallelVisibilityMode: 'foreground-tab', +}); +let cachedAgentPreferences = null; + +function normalizeAgentPreferences(raw) { + const executionMode = raw?.executionMode === 'sequential' ? 'sequential' : 'parallel'; + // Keep behavior locked to visible tabs in the current window. + const parallelVisibilityMode = 'foreground-tab'; + return { executionMode, parallelVisibilityMode }; +} + +async function getAgentPreferencesForSession() { + if (cachedAgentPreferences) { + return cachedAgentPreferences; + } + + try { + const response = await fetch(`${getRelayHttpUrl()}/agent-preferences`, { + signal: AbortSignal.timeout(2000), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const raw = await response.json(); + cachedAgentPreferences = normalizeAgentPreferences(raw); + return cachedAgentPreferences; + } catch { + cachedAgentPreferences = { ...DEFAULT_AGENT_PREFERENCES }; + return cachedAgentPreferences; + } +} // ─── Plugin State ──────────────────────────────────────────────────────────── @@ -211,6 +244,8 @@ Variables: page Default page (first tab in context — shared, avoid navigating it) context Browser context — access all pages via context.pages() state Persistent object across calls (cleared on reset). Store your working page here. + browserforceSettings Session defaults loaded once per MCP session (refresh on reset). + Keys: executionMode, parallelVisibilityMode. Helpers: snapshot({ selector?, search?, showDiffSinceLastCall? }) Accessibility tree as text. 10-100x cheaper than screenshots. @@ -378,6 +413,7 @@ snapshot vs cleanHTML vs pageMarkdown: ═══ BROWSERFORCE TAB SWARMS // PARALLEL TABS PROCESSING ═══ Parallel-first policy for independent extraction: + Read browserforceSettings.executionMode before choosing swarm strategy. Settings are session defaults. 1) For count/list/extraction across independent pages, dates, or items, start with parallel tabs first. 2) Use Promise.all with a concurrency cap (typically 3-8; start at 5 unless site limits are known). 3) Keep swarm runs read-only and isolated to agent-created tabs (no checkout/purchase/send/delete/profile changes). @@ -521,6 +557,7 @@ function registerExecuteTool(skillAppendix = '') { async ({ code, timeout = 30000 }) => { await ensureBrowser(); ensureAllPagesCapture(); + const agentPreferences = await getAgentPreferencesForSession(); const ctx = getContext(); const pages = ctx.pages(); const page = pages[0] || null; @@ -528,7 +565,7 @@ function registerExecuteTool(skillAppendix = '') { if (page) setupConsoleCapture(page); const execCtx = buildExecContext(page, ctx, userState, { consoleLogs, setupConsoleCapture, - }, pluginHelpers); + }, pluginHelpers, agentPreferences); try { const result = await runCode(code, execCtx, timeout); const formatted = formatResult(result); @@ -561,6 +598,7 @@ server.tool( } browser = null; userState = {}; + cachedAgentPreferences = null; contextListenerAttached = false; consoleLogs.clear(); try { diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index 4c30a0a..9408a87 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -232,6 +232,54 @@ describe('Tool Definitions', () => { 'snapshot diff mode should only run for full-page snapshots with no search' ); }); + + it('execute context includes browserforceSettings', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/exec-engine.js'), + 'utf8' + ); + + assert.ok( + source.includes('browserforceSettings'), + 'exec context should expose browserforceSettings in the sandbox scope' + ); + assert.ok( + source.includes('executionMode') && source.includes('parallelVisibilityMode'), + 'browserforceSettings should include executionMode and parallelVisibilityMode' + ); + }); + + it('MCP preferences fetch is cached once per session', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + assert.ok(source.includes('cachedAgentPreferences'), 'should track cached agent preferences'); + assert.ok( + source.includes('if (cachedAgentPreferences)'), + 'should return cached preferences without refetching' + ); + assert.ok( + source.includes('/agent-preferences'), + 'should fetch preferences from relay /agent-preferences endpoint' + ); + }); + + it('reset clears cached preferences', () => { + const source = readFileSync( + join(import.meta.url.replace('file://', ''), '../../src/index.js'), + 'utf8' + ); + + const resetIdx = source.indexOf("'reset'"); + assert.ok(resetIdx !== -1, 'reset tool should exist'); + const resetBlock = source.slice(resetIdx, resetIdx + 2500); + assert.ok( + resetBlock.includes('cachedAgentPreferences = null'), + 'reset should clear cached agent preferences' + ); + }); }); // ─── MCP Response Format ───────────────────────────────────────────────────── From 2c73052a455922b45ea19a34fea61b8062783286 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 25 Feb 2026 22:55:10 +0530 Subject: [PATCH 079/192] docs: document execution mode and visible parallel tab settings --- GUIDE.md | 21 +++++++++++++++++++++ README.md | 12 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/GUIDE.md b/GUIDE.md index f87bf45..1e3c59b 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -59,6 +59,27 @@ Result: the agent can operate only in your approved set. Use both in long-running sessions to limit drift and memory growth. +## Execution Strategy Settings + +Popup settings include: + +- `executionMode`: `parallel` or `sequential` +- `parallelVisibilityMode`: `foreground-tab` or `rotate-visible` + +Current behavior lock: + +- Agent-created tabs stay visible in the current window (`foreground-tab` behavior). +- No new windows are created for parallel workers. +- `rotate-visible` is treated as `foreground-tab` in this release. + +MCP reads these preferences once per session and caches them. If you change popup settings mid-session, call `reset` so new execute calls pick up updated values. + +### Operational examples + +- Visible parallel in current window: `executionMode=parallel`, `parallelVisibilityMode=foreground-tab` +- Sequential low-detection run: `executionMode=sequential` +- Rotate-visible demo toggle: `parallelVisibilityMode=rotate-visible` (currently normalized to `foreground-tab`) + ## BrowserForce Tab Swarms // Parallel Tabs Processing This is the operating policy for independent read-only extraction at scale. diff --git a/README.md b/README.md index d57cdfd..2b2ef55 100644 --- a/README.md +++ b/README.md @@ -717,6 +717,8 @@ Click the extension icon to configure restrictions. Your browser, your rules: | Setting | What it does | | ----------------------- | ------------------------------------------------------------------------ | | **Auto / Manual mode** | Let the agent create tabs freely, or hand-pick which tabs it can access | +| **Execution mode** | `parallel` for independent work, `sequential` for one-at-a-time workflows | +| **Parallel visibility** | `foreground-tab` keeps new tabs visible in the current window | | **Lock URL** | Prevent the agent from navigating away from the current page | | **No new tabs** | Block the agent from opening new tabs | | **Read-only** | Observe only — no clicks, no typing, no interactions | @@ -724,6 +726,16 @@ Click the extension icon to configure restrictions. Your browser, your rules: | **Auto-close** | Automatically close agent-created tabs after 5-60 minutes | | **Custom instructions** | Pass text instructions to the agent (e.g. "don't click any buy buttons") | +`parallelVisibilityMode` is currently enforced as `foreground-tab` (visible tabs in the active window, no new windows). If `rotate-visible` is selected, BrowserForce normalizes to `foreground-tab` in this release. + +### Execution Strategy Preferences + +- **Visible parallel with current-window tabs (`foreground-tab`)**: New agent tabs open visibly in your current Chrome window and stay there. +- **Sequential mode (`executionMode = sequential`)**: Useful for lower-noise, step-by-step workflows on sensitive sites. +- **Rotate-visible demo mode (`rotate-visible`)**: Temporarily normalized to `foreground-tab` while the visibility lock is enforced. + +MCP reads `executionMode` and `parallelVisibilityMode` once per MCP session and caches them. If you change popup settings mid-session, call `reset` to refresh settings for new execute calls. + ### Controlled Tab Workflows From 9ed95471ec190e35314469459b786f1350744287 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Thu, 26 Feb 2026 00:18:15 +0530 Subject: [PATCH 080/192] chore: bump version to 1.0.15 in package.json --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 43eb39f..9d46e82 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browserforce", - "version": "1.0.14", + "version": "1.0.15", "type": "module", "description": "Give AI agents your real Chrome browser with progressive examples: simple reads, form interactions, multi-tab workflows, and state persistence. Search X and GitHub, extract ProductHunt data, test forms, compare A/B variants, monitor status pages. Works with OpenClaw, Claude, and any MCP agent.", "homepage": "https://github.com/ivalsaraj/browserforce", From abe8abfcd63d44dcc12a97c85e5b4582709e38f7 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Thu, 26 Feb 2026 17:05:31 +0530 Subject: [PATCH 081/192] extension: show MCP client count and auto-mode border --- extension/background.js | 41 +++++++++++++++++++++++++++++++++++++++-- extension/popup.css | 20 ++++++++++++++++++++ extension/popup.html | 9 ++++++--- extension/popup.js | 15 +++++++++++++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/extension/background.js b/extension/background.js index 721236d..9f1e1c7 100644 --- a/extension/background.js +++ b/extension/background.js @@ -4,12 +4,14 @@ const RELAY_URL_DEFAULT = 'ws://127.0.0.1:19222/extension'; const RECONNECT_DELAY_MS = 3000; const CDP_VERSION = '1.3'; +const RELAY_HTTP_DEFAULT = 'http://127.0.0.1:19222'; // ─── State ─────────────────────────────────────────────────────────────────── let ws = null; let connectionState = 'disconnected'; // disconnected | connecting | connected let maintainLoopActive = false; +let currentRelayUrl = RELAY_URL_DEFAULT; /** @type {Map} */ const attachedTabs = new Map(); @@ -35,6 +37,7 @@ let restrictionExplained = false; (async function init() { const stored = await chrome.storage.local.get(['relayUrl']); const relayUrl = stored.relayUrl || RELAY_URL_DEFAULT; + currentRelayUrl = relayUrl; // Register debugger listeners once (persists across reconnections) chrome.debugger.onEvent.addListener(onDebuggerEvent); @@ -610,6 +613,10 @@ async function checkInactiveTabs() { } chrome.storage.onChanged.addListener(async (changes) => { + if (changes.relayUrl) { + currentRelayUrl = changes.relayUrl.newValue || RELAY_URL_DEFAULT; + } + if (changes.autoDetachMinutes || changes.autoCloseMinutes) { const settings = await chrome.storage.local.get(['autoDetachMinutes', 'autoCloseMinutes']); const anyEnabled = (settings.autoDetachMinutes || 0) > 0 || (settings.autoCloseMinutes || 0) > 0; @@ -725,6 +732,29 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function relayWsToHttpBase(wsUrl) { + try { + const parsed = new URL(wsUrl || RELAY_URL_DEFAULT); + const protocol = parsed.protocol === 'wss:' ? 'https:' : 'http:'; + return `${protocol}//${parsed.host}`; + } catch { + return RELAY_HTTP_DEFAULT; + } +} + +async function getMcpClientCount() { + if (connectionState !== 'connected') return 0; + const base = relayWsToHttpBase(currentRelayUrl); + try { + const response = await fetch(`${base}/client-slot`, { method: 'GET', cache: 'no-store' }); + if (!response.ok) return 0; + const data = await response.json(); + return Number.isFinite(data?.clients) ? data.clients : 0; + } catch { + return 0; + } +} + // ─── Popup Message Handler ─────────────────────────────────────────────────── chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { @@ -741,7 +771,7 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { // Compute seconds until next auto-action (detach or close) let nextAutoActionSecs = null; - chrome.storage.local.get(['autoDetachMinutes', 'autoCloseMinutes'], (settings) => { + chrome.storage.local.get(['autoDetachMinutes', 'autoCloseMinutes', 'mode'], async (settings) => { const detachMs = (settings.autoDetachMinutes || 0) * 60_000; const closeMs = (settings.autoCloseMinutes || 0) * 60_000; if ((detachMs || closeMs) && tabLastActivity.size > 0) { @@ -757,7 +787,14 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { nextAutoActionSecs = Math.max(0, Math.ceil(earliest / 1000)); } } - sendResponse({ connectionState, tabs, nextAutoActionSecs }); + const mcpClientCount = await getMcpClientCount(); + sendResponse({ + connectionState, + tabs, + nextAutoActionSecs, + mode: settings.mode || 'auto', + mcpClientCount, + }); }); return true; // async sendResponse } diff --git a/extension/popup.css b/extension/popup.css index 512f6f3..d2e3b28 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -16,6 +16,11 @@ body { padding: 16px; } +.bf-popup.auto-mode { + border: 2px dotted #d32f2f; + border-radius: 10px; +} + header { display: flex; align-items: center; @@ -23,6 +28,12 @@ header { margin-bottom: 12px; } +.header-right { + display: flex; + align-items: center; + gap: 8px; +} + h1 { font-size: 14px; font-weight: 600; @@ -47,6 +58,15 @@ h1 { .status.connecting .dot { background: #ff9800; animation: pulse 1s infinite; } .status.disconnected .dot { background: #9e9e9e; } +.mcp-count { + font-size: 11px; + font-weight: 600; + color: #4a4a4a; + background: #f1f1f1; + border-radius: 10px; + padding: 2px 8px; +} + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } diff --git a/extension/popup.html b/extension/popup.html index e6e5c64..3deedb5 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -9,9 +9,12 @@

BrowserForce

-
- - Disconnected +
+
+ + Disconnected +
+ MCP 0
diff --git a/extension/popup.js b/extension/popup.js index be61438..8ba9ff1 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -13,6 +13,8 @@ const RESTRICTION_LINES = { const statusEl = document.getElementById('bf-status'); const statusTextEl = document.getElementById('bf-status-text'); +const mcpClientsEl = document.getElementById('bf-mcp-clients'); +const popupEl = document.querySelector('.bf-popup'); const relayUrlInput = document.getElementById('bf-relay-url'); const saveUrlBtn = document.getElementById('bf-save-url'); const tabCountEl = document.getElementById('bf-tab-count'); @@ -60,6 +62,7 @@ chrome.storage.local.get(SETTINGS_KEYS, (s) => { noNewTabsCb.checked = !!s.noNewTabs; readOnlyCb.checked = !!s.readOnly; instructionsEl.value = s.userInstructions || ''; + setAutoModeBorder(s.mode || 'auto'); }); // --- Save Handlers --- @@ -75,6 +78,7 @@ saveUrlBtn.addEventListener('click', () => { modeSelect.addEventListener('change', () => { chrome.storage.local.set({ mode: modeSelect.value }); + setAutoModeBorder(modeSelect.value); }); executionModeSelect.addEventListener('change', () => { @@ -178,6 +182,8 @@ function refreshStatus() { setStatus(response.connectionState, response.connectionState); setTabs(response.tabs || []); setAutoTimer(response.nextAutoActionSecs); + setMcpClientCount(response.mcpClientCount); + setAutoModeBorder(response.mode || modeSelect.value || 'auto'); }); } @@ -228,6 +234,15 @@ function setAutoTimer(secs) { autoTimerEl.textContent = `${m}:${String(s).padStart(2, '0')}`; } +function setMcpClientCount(count) { + const safeCount = Number.isFinite(count) ? count : 0; + mcpClientsEl.textContent = `MCP ${safeCount}`; +} + +function setAutoModeBorder(mode) { + popupEl.classList.toggle('auto-mode', mode === 'auto'); +} + function escapeHtml(str) { const div = document.createElement('div'); div.textContent = str; From e137a9091d1e11048ea6bdb892998764642ea652 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Thu, 26 Feb 2026 17:05:39 +0530 Subject: [PATCH 082/192] relay: include client count in client-slot status --- relay/src/index.js | 1 + relay/test/relay-server.test.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/relay/src/index.js b/relay/src/index.js index b198eb9..33179d4 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -301,6 +301,7 @@ class RelayServer { busy, activeClientId: busy ? this.activeClient.id : null, connectedAt: busy ? this.activeClient.connectedAt : null, + clients: this.clients.size, })); return; } diff --git a/relay/test/relay-server.test.js b/relay/test/relay-server.test.js index 18c0e55..c225871 100644 --- a/relay/test/relay-server.test.js +++ b/relay/test/relay-server.test.js @@ -708,6 +708,7 @@ describe('WebSocket Security', () => { busy: false, activeClientId: null, connectedAt: null, + clients: 0, }); activeClient = await connectWs(`ws://127.0.0.1:${singleRelay.port}/cdp?token=${singleRelay.authToken}`); @@ -718,6 +719,7 @@ describe('WebSocket Security', () => { assert.equal(during.body.busy, true); assert.equal(typeof during.body.activeClientId, 'string'); assert.equal(typeof during.body.connectedAt, 'number'); + assert.equal(during.body.clients, 1); const activeClosed = new Promise((resolve) => activeClient.once('close', resolve)); activeClient.close(); @@ -734,6 +736,7 @@ describe('WebSocket Security', () => { busy: false, activeClientId: null, connectedAt: null, + clients: 0, }); } finally { if (activeClient && activeClient.readyState === WebSocket.OPEN) activeClient.close(); From c4eb34786188057deca7105e4a0ef13b6866b753 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Thu, 26 Feb 2026 17:12:54 +0530 Subject: [PATCH 083/192] feat: add openclaw setup primitives and autostart installers --- mcp/src/openclaw-setup.js | 334 ++++++++++++++++++++ mcp/test/openclaw-setup.test.js | 530 ++++++++++++++++++++++++++++++++ 2 files changed, 864 insertions(+) create mode 100644 mcp/src/openclaw-setup.js create mode 100644 mcp/test/openclaw-setup.test.js diff --git a/mcp/src/openclaw-setup.js b/mcp/src/openclaw-setup.js new file mode 100644 index 0000000..156e921 --- /dev/null +++ b/mcp/src/openclaw-setup.js @@ -0,0 +1,334 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const RELAY_PORT = 19222; +const DARWIN_LAUNCH_AGENT_LABEL = 'ai.browserforce.relay'; +const LINUX_SYSTEMD_USER_SERVICE = 'browserforce-relay.service'; +const WIN32_TASK_NAME = 'BrowserForceRelay'; + +function shellQuote(value) { + return `'${String(value).replace(/'/g, `'\\''`)}'`; +} + +function posixPathJoin(left, right) { + return `${String(left).replace(/\/+$/, '')}/${String(right).replace(/^\/+/, '')}`; +} + +function windowsCommandArg(value) { + return String(value) + .replace(/"/g, '""') + .replace(/%/g, '%%') + .replace(/[&<>|^]/g, '^$&'); +} + +function windowsTaskQuotedArg(value) { + return `"${windowsCommandArg(value)}"`; +} + +function windowsTaskEscapeForTr(value) { + return String(value).replace(/"/g, '""'); +} + +function xmlEscape(value) { + return String(value).replace(/[&<>"']/g, (ch) => { + if (ch === '&') return '&'; + if (ch === '<') return '<'; + if (ch === '>') return '>'; + if (ch === '"') return '"'; + return '''; + }); +} + +export function renderLaunchAgentPlist({ label, nodePath, binScriptPath }) { + const escapedLabel = xmlEscape(label); + const escapedNodePath = xmlEscape(nodePath); + const escapedBinScriptPath = xmlEscape(binScriptPath); + + return [ + '', + '', + '', + '', + ' Label', + ` ${escapedLabel}`, + ' ProgramArguments', + ' ', + ` ${escapedNodePath}`, + ` ${escapedBinScriptPath}`, + ' serve', + ' ', + ' RunAtLoad', + ' ', + ' KeepAlive', + ' ', + '', + '', + '', + ].join('\n'); +} + +export function renderSystemdUserService({ nodePath, binScriptPath }) { + return [ + '[Unit]', + 'Description=BrowserForce Relay', + 'After=network.target', + '', + '[Service]', + 'Type=simple', + `ExecStart="${nodePath}" "${binScriptPath}" serve`, + 'Restart=always', + 'RestartSec=2', + '', + '[Install]', + 'WantedBy=default.target', + '', + ].join('\n'); +} + +export function buildAutostartSpec({ platform, homeDir, nodePath, binScriptPath }) { + const activePlatform = platform || process.platform; + + if (activePlatform === 'darwin') { + const plistPath = posixPathJoin( + posixPathJoin(homeDir, 'Library/LaunchAgents'), + `${DARWIN_LAUNCH_AGENT_LABEL}.plist`, + ); + const programArguments = [nodePath, binScriptPath, 'serve']; + const plist = renderLaunchAgentPlist({ + label: DARWIN_LAUNCH_AGENT_LABEL, + nodePath, + binScriptPath, + }); + + return { + platform: activePlatform, + filesToWrite: [ + { + path: plistPath, + content: plist, + }, + ], + commands: [ + `launchctl unload ${shellQuote(plistPath)} >/dev/null 2>&1 || true`, + `launchctl load -w ${shellQuote(plistPath)}`, + ], + summary: `Install launchd agent ${DARWIN_LAUNCH_AGENT_LABEL}`, + launchAgent: { + label: DARWIN_LAUNCH_AGENT_LABEL, + plistPath, + programArguments, + }, + }; + } + + if (activePlatform === 'linux') { + const servicePath = posixPathJoin( + posixPathJoin(homeDir, '.config/systemd/user'), + LINUX_SYSTEMD_USER_SERVICE, + ); + const serviceContents = renderSystemdUserService({ nodePath, binScriptPath }); + + return { + platform: activePlatform, + filesToWrite: [ + { + path: servicePath, + content: serviceContents, + }, + ], + commands: [ + 'systemctl --user daemon-reload', + `systemctl --user enable --now ${LINUX_SYSTEMD_USER_SERVICE}`, + ], + summary: `Install systemd user service ${LINUX_SYSTEMD_USER_SERVICE}`, + systemd: { + serviceName: LINUX_SYSTEMD_USER_SERVICE, + servicePath, + }, + }; + } + + if (activePlatform === 'win32') { + const commandToRun = `${windowsTaskQuotedArg(nodePath)} ${windowsTaskQuotedArg(binScriptPath)} serve`; + const createCommand = `schtasks /Create /F /TN "${WIN32_TASK_NAME}" /SC ONLOGON /TR "${windowsTaskEscapeForTr(commandToRun)}"`; + + return { + platform: activePlatform, + filesToWrite: [], + commands: [createCommand], + summary: `Install scheduled task ${WIN32_TASK_NAME}`, + scheduledTask: { + taskName: WIN32_TASK_NAME, + createCommand, + commandToRun, + }, + }; + } + + throw new Error(`Unsupported platform: ${activePlatform}`); +} + +export function buildBrowserforceMcpServerEntry({ platform = process.platform } = {}) { + if (platform === 'win32') { + const command = [ + `if (-not (netstat -ano | Select-String ':${RELAY_PORT}\\s+.*LISTENING')) {`, + "Start-Process -WindowStyle Hidden -FilePath 'npx' -ArgumentList '-y','browserforce@latest','serve'", + '}', + '& npx -y browserforce@latest mcp', + ].join(' '); + + return { + name: 'browserforce', + transport: 'stdio', + command: 'powershell', + args: ['-NoProfile', '-NonInteractive', '-Command', command], + }; + } + + const command = [ + `if ! lsof -tiTCP:${RELAY_PORT} -sTCP:LISTEN >/dev/null 2>&1; then`, + 'npx -y browserforce@latest serve >/dev/null 2>&1 &', + 'fi;', + 'exec npx -y browserforce@latest mcp', + ].join(' '); + + return { + name: 'browserforce', + transport: 'stdio', + command: 'sh', + args: ['-lc', command], + }; +} + +function asObject(value) { + return value && typeof value === 'object' && !Array.isArray(value) ? value : {}; +} + +function ensureMcpAdapterOnce(allowList) { + const values = Array.isArray(allowList) ? allowList : []; + const filtered = values.filter((value) => value !== 'mcp-adapter'); + return [...filtered, 'mcp-adapter']; +} + +function mergeServers(existingServers, { platform = process.platform } = {}) { + const values = Array.isArray(existingServers) ? existingServers : []; + const browserforceEntry = buildBrowserforceMcpServerEntry({ platform }); + const merged = []; + let inserted = false; + + for (const value of values) { + const isBrowserforce = + value && + typeof value === 'object' && + !Array.isArray(value) && + value.name === 'browserforce'; + + if (!isBrowserforce) { + merged.push(value); + continue; + } + + if (!inserted) { + merged.push(browserforceEntry); + inserted = true; + } + } + + if (!inserted) { + merged.push(browserforceEntry); + } + + return merged; +} + +export function mergeOpenClawConfig(existingConfig, { platform = process.platform } = {}) { + const root = asObject(existingConfig); + + const plugins = asObject(root.plugins); + const entries = asObject(plugins.entries); + const mcpAdapter = asObject(entries['mcp-adapter']); + const mcpAdapterConfig = asObject(mcpAdapter.config); + + const tools = asObject(root.tools); + const sandbox = asObject(tools.sandbox); + const sandboxTools = asObject(sandbox.tools); + + return { + ...root, + plugins: { + ...plugins, + entries: { + ...entries, + 'mcp-adapter': { + ...mcpAdapter, + enabled: true, + config: { + ...mcpAdapterConfig, + servers: mergeServers(mcpAdapterConfig.servers, { platform }), + }, + }, + }, + }, + tools: { + ...tools, + sandbox: { + ...sandbox, + tools: { + ...sandboxTools, + allow: ensureMcpAdapterOnce(sandboxTools.allow), + }, + }, + }, + }; +} + +export function formatJsonStable(obj) { + return `${JSON.stringify(obj, null, 2)}\n`; +} + +function defaultExecFn(command) { + const result = spawnSync(command, { + shell: true, + stdio: 'inherit', + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error(`Command failed with exit code ${result.status}: ${command}`); + } + + if (result.status === null) { + throw new Error(`Command terminated unexpectedly: ${command}`); + } +} + +export async function applyAutostart(spec, { dryRun = false, execFn = defaultExecFn, fsApi = fs } = {}) { + const filesToWrite = Array.isArray(spec?.filesToWrite) ? spec.filesToWrite : []; + const commands = Array.isArray(spec?.commands) ? spec.commands : []; + const report = { + wroteFiles: filesToWrite.map((file) => file.path), + ranCommands: [], + skippedCommands: dryRun ? [...commands] : [], + }; + + if (dryRun) { + return report; + } + + for (const file of filesToWrite) { + const parentDir = path.dirname(file.path); + await fsApi.mkdir(parentDir, { recursive: true }); + await fsApi.writeFile(file.path, file.content, 'utf8'); + } + + for (const command of commands) { + await execFn(command); + report.ranCommands.push(command); + } + + return report; +} diff --git a/mcp/test/openclaw-setup.test.js b/mcp/test/openclaw-setup.test.js new file mode 100644 index 0000000..a7801af --- /dev/null +++ b/mcp/test/openclaw-setup.test.js @@ -0,0 +1,530 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +import { + applyAutostart, + buildBrowserforceMcpServerEntry, + buildAutostartSpec, + formatJsonStable, + mergeOpenClawConfig, + renderLaunchAgentPlist, + renderSystemdUserService, +} from '../src/openclaw-setup.js'; + +function quoteShellArg(value) { + const stringValue = String(value); + if (process.platform === 'win32') { + return `"${stringValue.replace(/"/g, '""')}"`; + } + return `'${stringValue.replace(/'/g, `'\\''`)}'`; +} + +function buildNodeEvalCommand(source) { + return `${quoteShellArg(process.execPath)} -e ${quoteShellArg(source)}`; +} + +test('buildBrowserforceMcpServerEntry returns stdio sh wrapper with relay autostart on POSIX', () => { + const entry = buildBrowserforceMcpServerEntry({ platform: 'linux' }); + + assert.equal(entry.transport, 'stdio'); + assert.equal(entry.command, 'sh'); + assert.equal(entry.args[0], '-lc'); + assert.match(entry.args[1], /if ! lsof -tiTCP:19222 -sTCP:LISTEN/); + assert.match(entry.args[1], /npx -y browserforce@latest serve/); + assert.match(entry.args[1], /exec npx -y browserforce@latest mcp/); +}); + +test('buildBrowserforceMcpServerEntry returns win32-safe powershell wrapper', () => { + const entry = buildBrowserforceMcpServerEntry({ platform: 'win32' }); + + assert.equal(entry.transport, 'stdio'); + assert.equal(entry.command, 'powershell'); + assert.deepEqual(entry.args.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command']); + assert.match(entry.args[3], /netstat -ano/); + assert.match(entry.args[3], /browserforce@latest','serve/); + assert.match(entry.args[3], /& npx -y browserforce@latest mcp/); +}); + +test('mergeOpenClawConfig adds and enables plugins.entries["mcp-adapter"]', () => { + const merged = mergeOpenClawConfig({ + plugins: { + entries: { + 'mcp-adapter': { + enabled: false, + }, + }, + }, + }); + + assert.equal(merged.plugins.entries['mcp-adapter'].enabled, true); +}); + +test('mergeOpenClawConfig preserves unrelated keys', () => { + const existing = { + ui: { + theme: 'light', + }, + plugins: { + entries: { + other: { + enabled: false, + config: { foo: 1 }, + }, + }, + }, + tools: { + sandbox: { + tools: { + allow: ['shell'], + deny: ['network'], + }, + }, + }, + }; + + const merged = mergeOpenClawConfig(existing); + + assert.equal(merged.ui.theme, 'light'); + assert.deepEqual(merged.plugins.entries.other, existing.plugins.entries.other); + assert.deepEqual(merged.tools.sandbox.tools.deny, ['network']); +}); + +test('mergeOpenClawConfig preserves existing non-browserforce servers', () => { + const existing = { + plugins: { + entries: { + 'mcp-adapter': { + enabled: false, + config: { + timeoutMs: 1000, + servers: [ + { + name: 'custom', + transport: 'stdio', + command: 'node', + args: ['custom-mcp.js'], + }, + ], + }, + }, + }, + }, + }; + + const merged = mergeOpenClawConfig(existing); + const servers = merged.plugins.entries['mcp-adapter'].config.servers; + + assert.equal(merged.plugins.entries['mcp-adapter'].config.timeoutMs, 1000); + assert.deepEqual(servers.find((server) => server.name === 'custom'), existing.plugins.entries['mcp-adapter'].config.servers[0]); + assert.equal(servers.filter((server) => server.name === 'browserforce').length, 1); +}); + +test('mergeOpenClawConfig updates browserforce server entry once without duplicates', () => { + const existing = { + plugins: { + entries: { + 'mcp-adapter': { + config: { + servers: [ + { name: 'custom', transport: 'stdio', command: 'node', args: ['custom.js'] }, + { name: 'browserforce', transport: 'stdio', command: 'node', args: ['old-browserforce.js'] }, + { name: 'browserforce', transport: 'stdio', command: 'node', args: ['stale-browserforce.js'] }, + ], + }, + }, + }, + }, + }; + + const merged = mergeOpenClawConfig(existing); + const servers = merged.plugins.entries['mcp-adapter'].config.servers; + const browserforceServers = servers.filter((server) => server.name === 'browserforce'); + + assert.equal(browserforceServers.length, 1); + assert.deepEqual(browserforceServers[0], buildBrowserforceMcpServerEntry()); + assert.deepEqual(servers.find((server) => server.name === 'custom'), existing.plugins.entries['mcp-adapter'].config.servers[0]); +}); + +test('mergeOpenClawConfig is idempotent', () => { + const first = mergeOpenClawConfig({ + plugins: { entries: {} }, + tools: { sandbox: { tools: { allow: ['shell'] } } }, + }); + const second = mergeOpenClawConfig(first); + + assert.deepEqual(second, first); +}); + +test('mergeOpenClawConfig writes win32 browserforce server entry without sh', () => { + const merged = mergeOpenClawConfig( + { + plugins: { + entries: { + 'mcp-adapter': { + config: { + servers: [], + }, + }, + }, + }, + }, + { platform: 'win32' }, + ); + const server = merged.plugins.entries['mcp-adapter'].config.servers.find((value) => value.name === 'browserforce'); + + assert.equal(server.command, 'powershell'); + assert.deepEqual(server.args.slice(0, 3), ['-NoProfile', '-NonInteractive', '-Command']); +}); + +test('formatJsonStable uses 2-space indentation and trailing newline', () => { + const out = formatJsonStable({ a: 1, nested: { b: true } }); + assert.equal(out, '{\n "a": 1,\n "nested": {\n "b": true\n }\n}\n'); +}); + +test('mergeOpenClawConfig ensures tools.sandbox.tools.allow includes mcp-adapter once', () => { + const merged = mergeOpenClawConfig({ + tools: { + sandbox: { + tools: { + allow: ['shell', 'mcp-adapter', 'mcp-adapter'], + }, + }, + }, + }); + + const allow = merged.tools.sandbox.tools.allow; + assert.equal(allow.includes('mcp-adapter'), true); + assert.equal(allow.filter((item) => item === 'mcp-adapter').length, 1); + assert.equal(allow.includes('shell'), true); +}); + +test('buildAutostartSpec returns darwin launch agent spec', () => { + const spec = buildAutostartSpec({ + platform: 'darwin', + homeDir: '/Users/alex', + nodePath: '/usr/local/bin/node', + binScriptPath: '/Users/alex/.npm/_npx/browserforce/bin.js', + }); + + assert.equal(spec.platform, 'darwin'); + assert.equal(Array.isArray(spec.filesToWrite), true); + assert.equal(spec.filesToWrite.length, 1); + assert.equal(spec.filesToWrite[0].path, '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist'); + assert.match(spec.filesToWrite[0].content, /Label<\/key>\n\s*ai\.browserforce\.relay<\/string>/); + assert.equal(Array.isArray(spec.commands), true); + assert.deepEqual(spec.commands, [ + "launchctl unload '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist' >/dev/null 2>&1 || true", + "launchctl load -w '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist'", + ]); + assert.equal(typeof spec.summary, 'string'); + assert.notEqual(spec.summary.trim(), ''); + assert.equal(spec.launchAgent.label, 'ai.browserforce.relay'); + assert.equal(spec.launchAgent.plistPath, '/Users/alex/Library/LaunchAgents/ai.browserforce.relay.plist'); + assert.match(spec.launchAgent.programArguments.join(' '), /\/usr\/local\/bin\/node .*\/bin\.js serve/); +}); + +test('buildAutostartSpec returns linux systemd user service spec', () => { + const spec = buildAutostartSpec({ + platform: 'linux', + homeDir: '/home/alex', + nodePath: '/usr/bin/node', + binScriptPath: '/home/alex/.npm/_npx/browserforce/bin.js', + }); + + assert.equal(spec.platform, 'linux'); + assert.equal(Array.isArray(spec.filesToWrite), true); + assert.equal(spec.filesToWrite.length, 1); + assert.equal(spec.filesToWrite[0].path, '/home/alex/.config/systemd/user/browserforce-relay.service'); + assert.match(spec.filesToWrite[0].content, /ExecStart="\/usr\/bin\/node" "\/home\/alex\/\.npm\/_npx\/browserforce\/bin\.js" serve/); + assert.equal(Array.isArray(spec.commands), true); + assert.deepEqual(spec.commands, [ + 'systemctl --user daemon-reload', + 'systemctl --user enable --now browserforce-relay.service', + ]); + assert.equal(typeof spec.summary, 'string'); + assert.notEqual(spec.summary.trim(), ''); + assert.equal(spec.systemd.servicePath, '/home/alex/.config/systemd/user/browserforce-relay.service'); + assert.equal(spec.commands.some((command) => command === 'systemctl --user enable --now browserforce-relay.service'), true); +}); + +test('buildAutostartSpec returns win32 scheduled task spec', () => { + const spec = buildAutostartSpec({ + platform: 'win32', + homeDir: 'C:\\Users\\alex', + nodePath: 'C:\\Program Files\\nodejs\\node.exe', + binScriptPath: 'C:\\Users\\alex\\AppData\\Roaming\\npm\\node_modules\\browserforce\\bin.js', + }); + + assert.equal(spec.platform, 'win32'); + assert.equal(Array.isArray(spec.filesToWrite), true); + assert.deepEqual(spec.filesToWrite, []); + assert.equal(Array.isArray(spec.commands), true); + assert.equal(spec.commands.length, 1); + assert.match(spec.commands[0], /schtasks\s+\/Create/); + assert.equal(typeof spec.summary, 'string'); + assert.notEqual(spec.summary.trim(), ''); + assert.equal(spec.scheduledTask.taskName, 'BrowserForceRelay'); + assert.match(spec.scheduledTask.createCommand, /schtasks\s+\/Create/); + assert.match(spec.scheduledTask.createCommand, /\/SC\s+ONLOGON/); +}); + +test('buildAutostartSpec win32 escapes cmd metacharacters and quotes in /TR payload', () => { + const spec = buildAutostartSpec({ + platform: 'win32', + homeDir: 'C:\\Users\\alex', + nodePath: 'C:\\Program Files\\Tools & Stuff\\100%\\node.exe', + binScriptPath: 'C:\\Users\\alex\\AppData\\Roaming\\npm\\b&f%\\bin"odd".js', + }); + + assert.equal(spec.platform, 'win32'); + assert.match(spec.scheduledTask.commandToRun, /"\S[\s\S]*"\s+"\S[\s\S]*"\s+serve$/); + assert.match(spec.scheduledTask.commandToRun, /\^&/); + assert.match(spec.scheduledTask.commandToRun, /%%/); + assert.match(spec.scheduledTask.commandToRun, /""odd""/); + assert.match(spec.scheduledTask.createCommand, /\/TR\s+"/); + assert.match(spec.scheduledTask.createCommand, /\^&/); + assert.match(spec.scheduledTask.createCommand, /%%/); +}); + +test('buildAutostartSpec throws for unsupported platform', () => { + assert.throws( + () => + buildAutostartSpec({ + platform: 'freebsd', + homeDir: '/home/alex', + nodePath: '/usr/bin/node', + binScriptPath: '/home/alex/bin/browserforce', + }), + /Unsupported platform: freebsd/, + ); +}); + +test('renderLaunchAgentPlist includes expected label, args, and run-at-load keys', () => { + const output = renderLaunchAgentPlist({ + label: 'ai.browserforce.relay', + nodePath: '/usr/local/bin/node', + binScriptPath: '/Users/alex/.npm/_npx/browserforce/bin.js', + }); + + assert.match(output, /Label<\/key>\n\s*ai\.browserforce\.relay<\/string>/); + assert.match(output, /ProgramArguments<\/key>\n\s*\n\s*\/usr\/local\/bin\/node<\/string>\n\s*\/Users\/alex\/\.npm\/_npx\/browserforce\/bin\.js<\/string>\n\s*serve<\/string>\n\s*<\/array>/); + assert.match(output, /RunAtLoad<\/key>\n\s*/); +}); + +test('renderLaunchAgentPlist escapes XML entities in interpolated values', () => { + const output = renderLaunchAgentPlist({ + label: 'relay &
+ diff --git a/extension/popup.js b/extension/popup.js index 8ba9ff1..76d9905 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -21,6 +21,7 @@ const tabCountEl = document.getElementById('bf-tab-count'); const tabsListEl = document.getElementById('bf-tabs-list'); const autoTimerEl = document.getElementById('bf-auto-timer'); const attachBtn = document.getElementById('bf-attach-tab'); +const openAgentBtn = document.getElementById('bf-open-agent'); const openLogsBtn = document.getElementById('bf-open-logs'); const modeSelect = document.getElementById('bf-mode'); const executionModeSelect = document.getElementById('bf-execution-mode'); @@ -67,12 +68,41 @@ chrome.storage.local.get(SETTINGS_KEYS, (s) => { // --- Save Handlers --- +function setSaveUrlFeedback(label, disabled) { + saveUrlBtn.textContent = label; + saveUrlBtn.disabled = !!disabled; +} + saveUrlBtn.addEventListener('click', () => { const url = relayUrlInput.value.trim(); if (!url) return; - chrome.storage.local.set({ relayUrl: url }, () => { - saveUrlBtn.textContent = 'Saved'; - setTimeout(() => { saveUrlBtn.textContent = 'Save'; }, 1200); + setSaveUrlFeedback('Connecting...', true); + setStatus('connecting', 'connecting'); + + chrome.runtime.sendMessage({ type: 'updateRelayUrl', relayUrl: url }, (response) => { + if (chrome.runtime.lastError || !response) { + setSaveUrlFeedback('Connection failed', false); + setStatus('disconnected', 'connection failed'); + setTimeout(() => setSaveUrlFeedback('Save', false), 1800); + return; + } + + if (response.error) { + setSaveUrlFeedback('Connection failed', false); + setStatus('disconnected', response.error); + setTimeout(() => { + setSaveUrlFeedback('Save', false); + refreshStatus(); + }, 1800); + return; + } + + setSaveUrlFeedback('Connected', false); + setStatus(response.connectionState || 'connected', response.connectionState || 'connected'); + setTimeout(() => { + setSaveUrlFeedback('Save', false); + refreshStatus(); + }, 1200); }); }); @@ -165,6 +195,16 @@ attachBtn.addEventListener('click', () => { }); }); +openAgentBtn.addEventListener('click', async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + await chrome.sidePanel.open({ windowId: tab?.windowId }); + } catch { + openAgentBtn.textContent = 'Failed to open'; + setTimeout(() => { openAgentBtn.textContent = 'Open BrowserForce Agent'; }, 1500); + } +}); + openLogsBtn.addEventListener('click', () => { chrome.runtime.openOptionsPage(); }); diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js new file mode 100644 index 0000000..3739bc3 --- /dev/null +++ b/test/agent/agent-panel-contract.test.js @@ -0,0 +1,21 @@ +import fs from 'node:fs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const html = fs.readFileSync('extension/agent-panel.html', 'utf8'); + +test('agent panel has inline model and session selectors with popovers', () => { + assert.match(html, /id="bf-model-trigger"/); + assert.match(html, /id="bf-session-trigger"/); + assert.match(html, /id="bf-new-session"/); + assert.match(html, /aria-label="New Session"/); + assert.match(html, /id="bf-model-panel"/); + assert.match(html, /id="bf-session-panel"/); + assert.match(html, /id="bf-model-list"/); + assert.match(html, /id="bf-switch-session-list"/); +}); + +test('agent panel no longer renders title or persistent session sidebar', () => { + assert.doesNotMatch(html, /

/); +}); diff --git a/test/agent/extension-manifest.test.js b/test/agent/extension-manifest.test.js new file mode 100644 index 0000000..9061d2c --- /dev/null +++ b/test/agent/extension-manifest.test.js @@ -0,0 +1,10 @@ +import fs from 'node:fs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const manifest = JSON.parse(fs.readFileSync('extension/manifest.json', 'utf8')); + +test('manifest includes sidePanel permission and default_path', () => { + assert.ok(manifest.permissions.includes('sidePanel')); + assert.equal(manifest.side_panel.default_path, 'agent-panel.html'); +}); diff --git a/test/agent/popup-contract.test.js b/test/agent/popup-contract.test.js new file mode 100644 index 0000000..3995a17 --- /dev/null +++ b/test/agent/popup-contract.test.js @@ -0,0 +1,9 @@ +import fs from 'node:fs'; +import test from 'node:test'; +import assert from 'node:assert/strict'; + +const html = fs.readFileSync('extension/popup.html', 'utf8'); + +test('popup includes Open BrowserForce Agent button', () => { + assert.match(html, /Open BrowserForce Agent/); +}); From dac4c3eea09820ac3fd3020e796f2501e82a314a Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 12:24:30 +0530 Subject: [PATCH 115/192] test(sidepanel): cover session selection and message hydration reducers --- test/agent/session-ui-state.test.js | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 test/agent/session-ui-state.test.js diff --git a/test/agent/session-ui-state.test.js b/test/agent/session-ui-state.test.js new file mode 100644 index 0000000..d0ebd3f --- /dev/null +++ b/test/agent/session-ui-state.test.js @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { reduceState } from '../../extension/agent-panel-state.js'; + +test('selectSession replaces active transcript with selected session messages', () => { + const state = { + activeSessionId: 's1', + sessions: [], + runs: {}, + messagesBySession: { + s1: [{ role: 'user', text: 'one' }], + s2: [{ role: 'assistant', text: 'two' }], + }, + }; + + const next = reduceState(state, { type: 'session.selected', sessionId: 's2' }); + assert.equal(next.activeSessionId, 's2'); + assert.equal(next.messagesBySession.s2[0].text, 'two'); +}); + +test('messages.loaded hydrates transcript for the selected session', () => { + const state = { + activeSessionId: 's1', + sessions: [], + runs: {}, + messagesBySession: {}, + }; + + const next = reduceState(state, { + type: 'messages.loaded', + sessionId: 's1', + messages: [{ role: 'assistant', text: 'hello' }], + }); + + assert.equal(next.messagesBySession.s1[0].text, 'hello'); +}); From 0bad0450a06cb5c866d9b3c35fb43f4cc9611e84 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 12:24:46 +0530 Subject: [PATCH 116/192] chore: include agent package artifacts and document sidepanel daemon workflow --- README.md | 27 +++++++++++++++++++++++++++ package.json | 5 +++-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index b870760..1d9890f 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,9 @@ browserforce -e "" # Run Playwright JavaScript (one-shot) browserforce plugin list # List installed plugins browserforce plugin install # Install a plugin from the registry browserforce plugin remove # Remove an installed plugin +browserforce agent start # Start local BrowserForce Agent daemon (chatd) +browserforce agent status # Show daemon PID/port + /health +browserforce agent stop # Stop daemon and clear lockfile browserforce setup openclaw [--dry-run] [--json] [--no-autostart] # Configure OpenClaw + optional autostart browserforce update # Update to the latest version browserforce install-extension # Copy extension to ~/.browserforce/extension/ @@ -383,6 +386,30 @@ Setup flags: `--dry-run` (preview), `--no-autostart` (skip OS login daemon/servi Each `-e` command is one-shot — state does not persist between calls. For persistent state, use the MCP server. +### BrowserForce Agent Side Panel + +BrowserForce now includes a side-panel chat UI in the Chrome extension for resumable local sessions. + +- Open popup -> `Open BrowserForce Agent` to open the side panel. +- Use the session list to switch between chats; transcripts hydrate per selected `sessionId`. +- Session identity is explicit and persisted; there is no fixed/hardcoded chat session ID. +- Streaming uses `fetch` + `ReadableStream` for SSE, not `EventSource`, so the panel can send `Authorization: Bearer ...` headers. + +Daemon lifecycle: + +```bash +browserforce agent start +browserforce agent status +browserforce agent stop +``` + +Port/auth bootstrap: + +- `agent start` picks a loopback port. If `BF_CHATD_PORT` is set and free, it is used. +- If that port is unavailable, BrowserForce falls back to the first free port in `19280-19320`. +- The daemon writes `~/.browserforce/chatd-url.json` (`{ port, token }`, mode `0600`). +- Side-panel JS reads relay URL from extension storage, calls relay `GET /chatd-url` (extension-origin gated), then connects directly to chatd with Bearer auth. + ## Deep Dive Sections diff --git a/package.json b/package.json index 1af8494..1efaefd 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "relay/package.json", "mcp/src/", "mcp/package.json", - "skills/" + "skills/", + "agent/" ], "dependencies": { "@modelcontextprotocol/sdk": "^1.12.1", @@ -49,7 +50,7 @@ "relay:dev": "lsof -ti tcp:19222 | xargs kill -9 2>/dev/null; sleep 0.3; node --watch relay/src/index.js", "mcp": "node mcp/src/index.js", "postinstall": "node scripts/postinstall-openclaw.mjs", - "test": "node --test relay/test/relay-server.test.js && node --test mcp/test/mcp-tools.test.js && node --test mcp/test/plugin-loader.test.js && node --test mcp/test/plugin-installer.test.js && node --test mcp/test/exec-engine-plugins.test.js && node --test mcp/test/mcp-plugin-integration.test.js && node --test test/cli.test.js && node --test test/postinstall.test.js", + "test": "node --test relay/test/relay-server.test.js && node --test mcp/test/mcp-tools.test.js && node --test mcp/test/plugin-loader.test.js && node --test mcp/test/plugin-installer.test.js && node --test mcp/test/exec-engine-plugins.test.js && node --test mcp/test/mcp-plugin-integration.test.js && node --test test/agent/port-resolver.test.js && node --test test/agent/session-store.test.js && node --test test/agent/codex-runner.test.js && node --test test/agent/chatd-api.test.js && node --test test/agent/extension-manifest.test.js && node --test test/agent/popup-contract.test.js && node --test test/agent/relay-url-reconnect-contract.test.js && node --test test/agent/agent-panel-contract.test.js && node --test test/agent/agent-panel-send-contract.test.js && node --test test/agent/session-ui-state.test.js && node --test test/agent/sse-events.test.js && node --test test/agent/auth.test.js && node --test test/agent/agent-panel-runtime.test.js && node --test test/agent/cli-agent.test.js && node --test test/cli.test.js && node --test test/postinstall.test.js", "test:relay": "node --test relay/test/relay-server.test.js", "test:mcp": "node --test mcp/test/mcp-tools.test.js && node --test mcp/test/plugin-loader.test.js && node --test mcp/test/plugin-installer.test.js && node --test mcp/test/exec-engine-plugins.test.js && node --test mcp/test/mcp-plugin-integration.test.js" } From 33aa805b6edc786b3078dd3f9f5c1b344494e450 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 13:02:43 +0530 Subject: [PATCH 117/192] fix(sidepanel): avoid premature finalization and submit on Enter --- agent/src/codex-runner.js | 4 ++-- extension/agent-panel.js | 7 +++++++ test/agent/agent-panel-send-contract.test.js | 7 +++++++ test/agent/codex-runner.test.js | 6 +++--- 4 files changed, 19 insertions(+), 5 deletions(-) diff --git a/agent/src/codex-runner.js b/agent/src/codex-runner.js index 96d6088..073f550 100644 --- a/agent/src/codex-runner.js +++ b/agent/src/codex-runner.js @@ -44,10 +44,10 @@ export function normalizeCodexLine({ runId, sessionId, line }) { const itemType = parsed.item?.type || ''; if (itemType === 'agent_message') { return envelope({ - event: 'chat.final', + event: 'chat.delta', runId, sessionId, - payload: { text: String(parsed.item?.text || '') }, + payload: { delta: String(parsed.item?.text || '') }, }); } if (itemType === 'reasoning') { diff --git a/extension/agent-panel.js b/extension/agent-panel.js index b6c6d34..c48fe65 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -567,6 +567,13 @@ chatFormEl.addEventListener('submit', async (event) => { } }); +chatInputEl.addEventListener('keydown', (event) => { + if (event.key !== 'Enter' || event.shiftKey) return; + if (event.isComposing) return; + event.preventDefault(); + chatFormEl.requestSubmit(); +}); + newSessionBtn.addEventListener('click', () => { createSession() .then(() => setPopover('none')) diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 48b172f..faf3ddd 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -23,3 +23,10 @@ test('sidepanel auto-attaches current tab and sends browserContext with runs', ( assert.match(js, /const browserContext = await getActiveTabContext\(\);/); assert.match(js, /JSON\.stringify\(\{\s*sessionId,\s*message:\s*text,\s*browserContext\s*\}\)/); }); + +test('enter key submits composer and shift+enter keeps newline', () => { + assert.match(js, /chatInputEl\.addEventListener\('keydown'/); + assert.match(js, /if\s*\(\s*event\.key\s*!==\s*'Enter'\s*\|\|\s*event\.shiftKey\s*\)\s*return;/); + assert.match(js, /event\.preventDefault\(\);/); + assert.match(js, /chatFormEl\.requestSubmit\(\);/); +}); diff --git a/test/agent/codex-runner.test.js b/test/agent/codex-runner.test.js index d0ce6ad..6319aa9 100644 --- a/test/agent/codex-runner.test.js +++ b/test/agent/codex-runner.test.js @@ -24,14 +24,14 @@ test('maps final line to chat.final event', () => { assert.equal(evt.payload.text, 'done'); }); -test('maps codex item.completed agent_message to chat.final', () => { +test('maps codex item.completed agent_message to chat.delta (not premature final)', () => { const evt = normalizeCodexLine({ runId: 'r1', sessionId: 's1', line: '{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}', }); - assert.equal(evt.event, 'chat.final'); - assert.equal(evt.payload.text, 'hello'); + assert.equal(evt.event, 'chat.delta'); + assert.equal(evt.payload.delta, 'hello'); }); test('buildCodexExecArgs includes --model when session model is set', () => { From 53370b64d29586e3c31202171b164c46d9664e0c Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 13:04:17 +0530 Subject: [PATCH 118/192] style(extension): apply warm neutral palette across panel and popup --- extension/agent-panel.css | 98 ++++++++++++++++--------- extension/options.css | 80 +++++++++++++------- extension/popup.css | 149 +++++++++++++++++++++++--------------- 3 files changed, 207 insertions(+), 120 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 095ccb9..26f23e2 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -1,14 +1,28 @@ :root { color-scheme: light; - --panel-bg: linear-gradient(180deg, #f8f9fc 0%, #eef2f8 100%); - --card-bg: #ffffff; - --line: #d7ddea; - --text: #1f2430; - --muted: #5f6878; - --accent: #2f7bf6; - --menu-bg: rgba(24, 28, 36, 0.94); - --menu-line: rgba(255, 255, 255, 0.12); - --menu-text: #f4f6fb; + --bf-crail: #C15F3C; + --bf-cloudy: #B1ADA1; + --bf-pampas: #F4F3EE; + --bf-white: #FFFFFF; + + --panel-bg: linear-gradient(180deg, var(--bf-pampas) 0%, #ECE6DB 100%); + --card-bg: var(--bf-white); + --line: #D8D3C9; + --line-soft: #E9E4DA; + --text: #3D3028; + --muted: #756F63; + --text-subtle: #8C857A; + --accent: var(--bf-crail); + --accent-hover: #B05535; + --accent-press: #9F4D30; + --accent-soft: #E9D3CB; + --accent-soft-text: #7A3D27; + --status-ok: var(--bf-crail); + --status-error: #8A3D24; + --status-error-strong: #B25334; + --menu-bg: rgba(51, 41, 34, 0.94); + --menu-line: rgba(255, 255, 255, 0.16); + --menu-text: var(--bf-pampas); } * { @@ -33,7 +47,7 @@ body { .agent-header { padding: 10px 12px 8px; border-bottom: 1px solid var(--line); - background: linear-gradient(180deg, #fff, #f9fbff); + background: linear-gradient(180deg, var(--bf-white), var(--bf-pampas)); } .header-row { @@ -47,8 +61,8 @@ body { min-height: 30px; border: 1px solid var(--line); border-radius: 999px; - background: #fff; - color: #2b3242; + background: var(--card-bg); + color: var(--text); font-size: 12px; text-align: left; padding: 0 10px; @@ -62,8 +76,8 @@ body { min-height: 30px; border-radius: 8px; border: 1px solid var(--line); - background: #fff; - color: #2d3342; + background: var(--card-bg); + color: var(--text); font-size: 20px; line-height: 1; padding: 0; @@ -80,15 +94,15 @@ body { .status-icon { font-size: 10px; - color: #22a06b; + color: var(--status-ok); } .status.error { - color: #8f2836; + color: var(--status-error); } .status.error .status-icon { - color: #d14357; + color: var(--status-error-strong); } .chat { @@ -124,19 +138,19 @@ body { } .message.user { - background: #10131a; - color: #f3f6fc; - border: 1px solid #0c1018; + background: var(--accent-press); + color: var(--bf-white); + border: 1px solid var(--accent-hover); border-radius: 16px; padding: 10px 14px; max-width: min(85%, 520px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18); + box-shadow: 0 4px 12px rgba(97, 53, 37, 0.28); } .message.assistant { background: transparent; border: 0; - color: #1f2430; + color: var(--text); padding: 0; max-width: 100%; } @@ -151,20 +165,20 @@ body { .run-steps-trigger { all: unset; cursor: pointer; - color: #646f84; + color: var(--text-subtle); font-size: 13px; font-weight: 500; } .run-steps-trigger:hover { - color: #2f7bf6; + color: var(--accent); } .run-steps-list { list-style: none; margin: 0; padding: 0 0 0 10px; - border-left: 1px solid #d8dfed; + border-left: 1px solid var(--line-soft); display: grid; gap: 8px; } @@ -173,7 +187,7 @@ body { display: flex; align-items: flex-start; gap: 8px; - color: #4e586d; + color: var(--muted); } .run-step-label { @@ -185,7 +199,7 @@ body { height: 16px; margin-top: 2px; flex: 0 0 16px; - color: #97a2b8; + color: var(--text-subtle); position: relative; } @@ -307,11 +321,11 @@ body { } .run-step.done .run-step-icon { - color: #1f9d63; + color: var(--status-ok); } .run-step.failed .run-step-icon { - color: #d14357; + color: var(--status-error-strong); } .composer { @@ -319,7 +333,7 @@ body { padding: 10px; display: grid; gap: 8px; - background: #fff; + background: var(--card-bg); } .composer textarea { @@ -331,6 +345,8 @@ body { border: 1px solid var(--line); border-radius: 8px; font: inherit; + color: var(--text); + background: var(--card-bg); } .composer-actions { @@ -343,20 +359,32 @@ button { border: 0; border-radius: 8px; background: var(--accent); - color: #fff; + color: var(--bf-white); padding: 8px 12px; cursor: pointer; } +button:hover { + background: var(--accent-hover); +} + +button:active { + background: var(--accent-press); +} + button.secondary { - background: #e7edf8; - color: #1c3f7e; + background: var(--accent-soft); + color: var(--accent-soft-text); +} + +button.secondary:hover { + background: #E2C8BE; } .menu-backdrop { position: absolute; inset: 0; - background: rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.14); z-index: 20; } @@ -368,7 +396,7 @@ button.secondary { border-radius: 14px; background: var(--menu-bg); border: 1px solid var(--menu-line); - box-shadow: 0 18px 36px rgba(0, 0, 0, 0.28); + box-shadow: 0 18px 36px rgba(66, 49, 39, 0.32); backdrop-filter: blur(14px); z-index: 21; max-height: min(360px, calc(100vh - 70px)); diff --git a/extension/options.css b/extension/options.css index b0a3721..1adfc46 100644 --- a/extension/options.css +++ b/extension/options.css @@ -1,3 +1,27 @@ +:root { + --bf-crail: #C15F3C; + --bf-cloudy: #B1ADA1; + --bf-pampas: #F4F3EE; + --bf-white: #FFFFFF; + + --bf-page-bg: var(--bf-pampas); + --bf-surface: var(--bf-white); + --bf-text: #3D3028; + --bf-text-muted: #756F63; + --bf-text-subtle: #8C857A; + --bf-border: #D8D3C9; + --bf-border-soft: #E9E4DA; + --bf-accent: var(--bf-crail); + --bf-accent-hover: #B05535; + --bf-ghost-bg-hover: #ECE6DB; + --bf-row-hover: #EEE8DD; + --bf-row-active: #E6DFD3; + --bf-table-head-bg: #F0EBE1; + --bf-error-bg: #F4E5E0; + --bf-error-border: #E3B9AA; + --bf-error-text: #8A3D24; +} + * { box-sizing: border-box; } @@ -5,8 +29,8 @@ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: #f5f7fb; - color: #1c2333; + background: var(--bf-page-bg); + color: var(--bf-text); } .layout { @@ -33,13 +57,13 @@ h2 { font-size: 14px; text-transform: uppercase; letter-spacing: 0.04em; - color: #4c5770; + color: var(--bf-text-muted); } .subtitle { margin: 6px 0 0; font-size: 13px; - color: #5f6d8a; + color: var(--bf-text-subtle); } .controls { @@ -48,10 +72,10 @@ h2 { } button { - border: 1px solid #2f6dff; + border: 1px solid var(--bf-accent); border-radius: 8px; - background: #2f6dff; - color: #fff; + background: var(--bf-accent); + color: var(--bf-white); height: 36px; padding: 0 14px; font-size: 13px; @@ -59,17 +83,17 @@ button { } button:hover { - background: #1f5cff; + background: var(--bf-accent-hover); } button.ghost { - border-color: #c5ccda; - background: #fff; - color: #27314a; + border-color: var(--bf-border); + background: var(--bf-surface); + color: var(--bf-text); } button.ghost:hover { - background: #f2f4f9; + background: var(--bf-ghost-bg-hover); } .cards { @@ -80,10 +104,10 @@ button.ghost:hover { } .card { - border: 1px solid #d7ddea; + border: 1px solid var(--bf-border); border-radius: 10px; padding: 12px; - background: #fff; + background: var(--bf-surface); min-height: 90px; } @@ -108,18 +132,18 @@ button.ghost:hover { display: flex; gap: 6px; padding: 10px 12px; - border: 1px solid #d7ddea; + border: 1px solid var(--bf-border); border-radius: 10px; - background: #fff; + background: var(--bf-surface); margin-bottom: 12px; font-size: 13px; } .error { margin: 0 0 12px; - border: 1px solid #f2bcc1; - background: #fff5f6; - color: #8a1d2f; + border: 1px solid var(--bf-error-border); + background: var(--bf-error-bg); + color: var(--bf-error-text); padding: 10px 12px; border-radius: 8px; font-size: 13px; @@ -127,9 +151,9 @@ button.ghost:hover { .logs-panel, .details-panel { - border: 1px solid #d7ddea; + border: 1px solid var(--bf-border); border-radius: 10px; - background: #fff; + background: var(--bf-surface); margin-bottom: 12px; } @@ -138,7 +162,7 @@ button.ghost:hover { align-items: center; justify-content: space-between; padding: 12px; - border-bottom: 1px solid #e6ebf5; + border-bottom: 1px solid var(--bf-border-soft); } .logs-header h2 { @@ -160,16 +184,16 @@ td { font-size: 12px; text-align: left; padding: 8px 10px; - border-bottom: 1px solid #edf1f8; + border-bottom: 1px solid var(--bf-border-soft); vertical-align: top; } th { position: sticky; top: 0; - background: #f8faff; + background: var(--bf-table-head-bg); z-index: 1; - color: #4c5770; + color: var(--bf-text-muted); font-weight: 600; } @@ -178,15 +202,15 @@ tr.clickable { } tr.clickable:hover { - background: #f7f9ff; + background: var(--bf-row-hover); } tr.active { - background: #eef3ff; + background: var(--bf-row-active); } .empty { - color: #74809b; + color: var(--bf-text-subtle); text-align: center; } diff --git a/extension/popup.css b/extension/popup.css index f26a11b..62bcc57 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -1,3 +1,32 @@ +:root { + --bf-crail: #C15F3C; + --bf-cloudy: #B1ADA1; + --bf-pampas: #F4F3EE; + --bf-white: #FFFFFF; + + --bf-bg: var(--bf-white); + --bf-surface: var(--bf-white); + --bf-surface-soft: var(--bf-pampas); + --bf-text: #3D3028; + --bf-text-muted: #756F63; + --bf-text-subtle: #8C857A; + --bf-border: #D8D3C9; + --bf-border-soft: #E9E4DA; + --bf-border-strong: #C7C0B3; + --bf-accent: var(--bf-crail); + --bf-accent-hover: #B05535; + --bf-accent-press: #9F4D30; + --bf-status-connected: var(--bf-crail); + --bf-status-connecting: var(--bf-cloudy); + --bf-status-disconnected: #CFCBBF; + --bf-danger-bg: #F4E5E0; + --bf-danger-fg: #8A3D24; + --bf-danger-bg-press: #EED7D0; + --bf-surface-soft-hover: #EDE7DC; + --bf-surface-soft-press: #E4DED3; + --bf-surface-press: #ECE6DB; +} + * { margin: 0; padding: 0; @@ -7,8 +36,8 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 13px; - color: #1a1a1a; - background: #fff; + color: var(--bf-text); + background: var(--bf-bg); } .bf-popup { @@ -17,7 +46,7 @@ body { } .bf-popup.auto-mode { - border: 2px dotted #d32f2f; + border: 2px dotted var(--bf-accent); border-radius: 10px; } @@ -51,20 +80,21 @@ h1 { width: 8px; height: 8px; border-radius: 50%; - background: #9e9e9e; + background: var(--bf-status-disconnected); } -.status.connected .dot { background: #4caf50; } -.status.connecting .dot { background: #ff9800; animation: pulse 1s infinite; } -.status.disconnected .dot { background: #9e9e9e; } +.status.connected .dot { background: var(--bf-status-connected); } +.status.connecting .dot { background: var(--bf-status-connecting); animation: pulse 1s infinite; } +.status.disconnected .dot { background: var(--bf-status-disconnected); } .mcp-count { font-size: 11px; font-weight: 600; - color: #4a4a4a; - background: #f1f1f1; + color: var(--bf-text); + background: var(--bf-surface-soft); border-radius: 10px; padding: 2px 8px; + border: 1px solid var(--bf-border-soft); } @keyframes pulse { @@ -75,7 +105,7 @@ h1 { /* Tab Navigation */ .tab-nav { display: flex; - border-bottom: 2px solid #eee; + border-bottom: 2px solid var(--bf-border-soft); margin-bottom: 14px; gap: 0; } @@ -87,16 +117,16 @@ h1 { background: none; font-size: 12px; font-weight: 600; - color: #999; + color: var(--bf-text-subtle); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; border-radius: 0; } -.tab-btn:hover { color: #666; background: none; } +.tab-btn:hover { color: var(--bf-text-muted); background: none; } .tab-btn:active { background: none; } -.tab-btn.active { color: #4caf50; border-bottom-color: #4caf50; } +.tab-btn.active { color: var(--bf-accent); border-bottom-color: var(--bf-accent); } .tab-panel { display: none; } .tab-panel.active { display: block; } @@ -114,16 +144,17 @@ h1 { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; - color: #666; + color: var(--bf-text-muted); margin-bottom: 6px; } .badge { - background: #e0e0e0; - color: #555; + background: var(--bf-surface-soft); + color: var(--bf-text-muted); font-size: 10px; padding: 1px 6px; border-radius: 10px; + border: 1px solid var(--bf-border-soft); } .input-row { @@ -134,48 +165,50 @@ h1 { input[type="text"] { flex: 1; padding: 6px 10px; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 6px; font-size: 12px; outline: none; + color: var(--bf-text); + background: var(--bf-surface); } input[type="text"]:focus { - border-color: #4caf50; + border-color: var(--bf-accent); } button { padding: 6px 14px; border: none; border-radius: 6px; - background: #4caf50; - color: #fff; + background: var(--bf-accent); + color: var(--bf-white); font-size: 12px; font-weight: 500; cursor: pointer; } -button:hover { background: #43a047; } -button:active { background: #388e3c; } +button:hover { background: var(--bf-accent-hover); } +button:active { background: var(--bf-accent-press); } /* Tabs list */ .tabs-list { max-height: 200px; overflow-y: auto; - border: 1px solid #eee; + border: 1px solid var(--bf-border-soft); border-radius: 6px; } .tabs-list .empty { padding: 12px; text-align: center; - color: #999; + color: var(--bf-text-subtle); font-size: 12px; } .tab-item { padding: 8px 10px; - border-bottom: 1px solid #f5f5f5; + border-bottom: 1px solid var(--bf-border-soft); overflow: hidden; } @@ -204,7 +237,7 @@ button:active { background: #388e3c; } border: none; border-radius: 4px; background: transparent; - color: #999; + color: var(--bf-text-subtle); font-size: 14px; line-height: 20px; text-align: center; @@ -212,17 +245,17 @@ button:active { background: #388e3c; } } .detach-btn:hover { - background: #fee; - color: #d32f2f; + background: var(--bf-danger-bg); + color: var(--bf-danger-fg); } .detach-btn:active { - background: #fdd; + background: var(--bf-danger-bg-press); } .tab-item .tab-url { font-size: 11px; - color: #888; + color: var(--bf-text-subtle); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -234,7 +267,7 @@ button:active { background: #388e3c; } font-size: 11px; font-weight: 500; font-variant-numeric: tabular-nums; - color: #999; + color: var(--bf-text-subtle); text-transform: none; letter-spacing: normal; } @@ -245,54 +278,54 @@ button:active { background: #388e3c; } .attach-btn { width: 100%; padding: 10px; - background: #f5f5f5; - color: #333; + background: var(--bf-surface-soft); + color: var(--bf-text); font-size: 12px; font-weight: 500; - border: 1px dashed #ccc; + border: 1px dashed var(--bf-border-strong); border-radius: 6px; cursor: pointer; } -.attach-btn:hover { background: #eee; border-color: #aaa; } -.attach-btn:active { background: #e0e0e0; } +.attach-btn:hover { background: var(--bf-surface-soft-hover); border-color: var(--bf-cloudy); } +.attach-btn:active { background: var(--bf-surface-soft-press); } .agent-btn { width: 100%; margin-top: 8px; margin-bottom: 8px; padding: 10px; - background: #2f7bf6; - color: #fff; + background: var(--bf-accent); + color: var(--bf-white); border-radius: 6px; } -.agent-btn:hover { background: #1d66d9; } -.agent-btn:active { background: #1757b8; } +.agent-btn:hover { background: var(--bf-accent-hover); } +.agent-btn:active { background: var(--bf-accent-press); } .logs-btn { width: 100%; margin-top: 8px; padding: 9px; - background: #fff; - color: #333; + background: var(--bf-surface); + color: var(--bf-text); font-size: 12px; font-weight: 500; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 6px; } .logs-btn:hover { - background: #f7f7f7; + background: var(--bf-surface-soft); } .logs-btn:active { - background: #efefef; + background: var(--bf-surface-press); } /* Settings groups */ .settings-group { - border: 1px solid #eee; + border: 1px solid var(--bf-border-soft); border-radius: 6px; padding: 8px 10px; } @@ -305,14 +338,14 @@ button:active { background: #388e3c; } } .setting-row + .setting-row { - border-top: 1px solid #f5f5f5; + border-top: 1px solid var(--bf-border-soft); padding-top: 8px; margin-top: 4px; } .setting-label { font-size: 12px; - color: #333; + color: var(--bf-text); } /* Checkbox rows */ @@ -326,28 +359,28 @@ button:active { background: #388e3c; } font-weight: normal; text-transform: none; letter-spacing: normal; - color: #333; + color: var(--bf-text); } .checkbox-row + .checkbox-row { - border-top: 1px solid #f5f5f5; + border-top: 1px solid var(--bf-border-soft); } .checkbox-row input[type="checkbox"] { width: 14px; height: 14px; - accent-color: #4caf50; + accent-color: var(--bf-accent); cursor: pointer; } /* Select + textarea */ select { padding: 4px 8px; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 4px; font-size: 12px; - background: #fff; - color: #333; + background: var(--bf-surface); + color: var(--bf-text); cursor: pointer; outline: none; } @@ -359,21 +392,23 @@ select.full-width { } select:focus { - border-color: #4caf50; + border-color: var(--bf-accent); } textarea { width: 100%; padding: 8px 10px; - border: 1px solid #ddd; + border: 1px solid var(--bf-border); border-radius: 6px; font-size: 12px; font-family: inherit; resize: vertical; outline: none; min-height: 60px; + color: var(--bf-text); + background: var(--bf-surface); } textarea:focus { - border-color: #4caf50; + border-color: var(--bf-accent); } From 585d879a8b8fc16d29480ce8cb2c07808ad6de0c Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 13:11:48 +0530 Subject: [PATCH 119/192] Update extension background and styles --- AGENTS.md | 12 ++++++++++++ extension/background.js | 14 ++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9ba081d..fedbba4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -169,6 +169,16 @@ In `single-active`, contention returns HTTP `409 Conflict` for additional `/cdp` MCP handles `409`/busy connect errors by entering standby and polling `GET /client-slot` with short jittered intervals (~200-400ms), then reconnecting when `busy: false` (up to a 30s connect timeout). +### BrowserForce Agent Session Identity (No Fixed ID) + +For side-panel chat UX, **never hardcode or assume a fixed `sessionId`**. + +- Sessions are user-selectable conversation threads (ChatGPT/Atlas style). +- The UI must list prior sessions and let the user resume any session. +- New chats must create a new generated session ID (UUID/ULID), then persist metadata + transcript. +- Streaming channels (`/events`) must be scoped by explicit selected `sessionId`. +- Do not infer continuity from "current Codex turn/session" alone; BrowserForce Agent keeps its own session store. + ## Security Rules - Relay binds to `127.0.0.1` ONLY. Never `0.0.0.0`. @@ -247,3 +257,5 @@ Run with: `node --test relay/test/relay-server.test.js` and `node --test mcp/tes 5. **Relay port collision**: Default port 19222. If tests fail with EADDRINUSE, kill stale processes: `lsof -ti:19222 | xargs kill -9`. 6. **Test writeCdpUrl**: Never call `relay.start()` in tests without `{ writeCdpUrl: false }` — it overwrites the production cdp-url file. + +7. **No fixed chat session IDs**: BrowserForce Agent chat must always use explicit user-selected/generated session IDs and persisted session history. Never bind side-panel chat to a single hardcoded ID. diff --git a/extension/background.js b/extension/background.js index adf8d50..162364c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -5,6 +5,12 @@ const RELAY_URL_DEFAULT = 'ws://127.0.0.1:19222/extension'; const RECONNECT_DELAY_MS = 3000; const CDP_VERSION = '1.3'; const RELAY_HTTP_DEFAULT = 'http://127.0.0.1:19222'; +const TAB_GROUP_COLOR = 'orange'; +const BADGE_COLORS = { + connected: '#C15F3C', + connecting: '#B1ADA1', + disconnected: '#B1ADA1', +}; // ─── State ─────────────────────────────────────────────────────────────────── @@ -732,7 +738,7 @@ async function syncTabGroup() { // Always ensure group title/color are correct if (groupId !== undefined) { - await chrome.tabGroups.update(groupId, { title: 'browserforce', color: 'cyan' }); + await chrome.tabGroups.update(groupId, { title: 'browserforce', color: TAB_GROUP_COLOR }); } } catch (e) { console.warn('[bf] syncTabGroup error:', e.message); @@ -756,13 +762,13 @@ function updateBadge() { if (connectionState === 'connected') { chrome.action.setBadgeText({ text: count > 0 ? String(count) : 'ON' }); - chrome.action.setBadgeBackgroundColor({ color: '#4CAF50' }); + chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.connected }); } else if (connectionState === 'connecting') { chrome.action.setBadgeText({ text: '...' }); - chrome.action.setBadgeBackgroundColor({ color: '#FF9800' }); + chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.connecting }); } else { chrome.action.setBadgeText({ text: '' }); - chrome.action.setBadgeBackgroundColor({ color: '#9E9E9E' }); + chrome.action.setBadgeBackgroundColor({ color: BADGE_COLORS.disconnected }); } } From f0fb5477d6febc408eb4a779b0bbc525c02fec86 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 14:00:54 +0530 Subject: [PATCH 120/192] Simplify AGENTS local include setup --- .gitignore | 3 ++- AGENTS.md | 9 ++------- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index 74c7dfb..943e95b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ node_modules/ pnpm-debug.log* .worktrees/ docs/plans/* -.superset \ No newline at end of file +.superset +AGENTS.local.md diff --git a/AGENTS.md b/AGENTS.md index fedbba4..5b44d22 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,8 @@ # BrowserForce — Agent Guidelines -## Playwriter Reference +## Local Private Overrides -**Before writing any new code, always check how [playwriter](../playwriter) solves the same problem.** Playwriter is the reference implementation for a browser extension + CDP relay + MCP server stack. It lives at `~/Documents/projects/playwriter`. - -Rules: -- **Don't reinvent what playwriter already solved.** Read the relevant playwriter source file first. -- **Only add code for new requirements or problems playwriter hasn't already solved.** -- Reference files: `playwriter/src/cdp-relay.ts`, `playwriter/src/executor.ts`, `playwriter/src/mcp.ts`, `playwriter/src/relay-client.ts` +@AGENTS.local.md ## Project Overview From 0b655965354609619b990ef3195e07b125e647ab Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 14:16:59 +0530 Subject: [PATCH 121/192] feat(sidepanel): show tab attach banner and auto-refresh on tab switch --- agent/src/chatd.js | 2 + extension/agent-panel.css | 30 ++++ extension/agent-panel.html | 4 + extension/agent-panel.js | 138 ++++++++++++++++++- test/agent/agent-panel-contract.test.js | 3 + test/agent/agent-panel-send-contract.test.js | 3 + test/agent/chatd-api.test.js | 3 +- 7 files changed, 175 insertions(+), 8 deletions(-) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index cb52d94..cd1164f 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -257,6 +257,8 @@ function buildRunPrompt({ message, browserContext }) { if (browserContext.tabId != null) lines.push(`- Active tab id: ${browserContext.tabId}`); if (browserContext.title) lines.push(`- Active tab title: ${browserContext.title}`); if (browserContext.url) lines.push(`- Active tab URL: ${browserContext.url}`); + lines.push('Inspect the active page and answer directly when the user asks about what is on this tab.'); + lines.push('Do not ask for permission to inspect the active page.'); lines.push('Assume the user is referring to this active tab unless they explicitly say otherwise.'); lines.push('If the request is ambiguous or you are not sure, ask the user a clarifying question before acting.'); lines.push(''); diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 26f23e2..231c2f7 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -105,6 +105,36 @@ body { color: var(--status-error-strong); } +.tab-attach { + margin-top: 8px; + min-height: 28px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 8px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + background: var(--card-bg); + color: var(--muted); + font-size: 12px; +} + +.tab-attach-btn { + border: 1px solid var(--line); + background: var(--accent-soft); + color: var(--accent-soft-text); + border-radius: 8px; + padding: 4px 8px; + font-size: 12px; + line-height: 1.2; +} + +.tab-attach-btn:disabled { + opacity: 0.6; + cursor: default; +} + .chat { min-height: 0; display: grid; diff --git a/extension/agent-panel.html b/extension/agent-panel.html index b53408e..70b90ae 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -18,6 +18,10 @@ Starting...

+
diff --git a/extension/agent-panel.js b/extension/agent-panel.js index c48fe65..69bca4e 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -35,6 +35,11 @@ const chatFormEl = document.getElementById('bf-chat-form'); const chatInputEl = document.getElementById('bf-chat-input'); const stopRunBtn = document.getElementById('bf-stop-run'); const sendBtn = chatFormEl.querySelector('button[type="submit"]'); +const tabAttachBannerEl = document.getElementById('bf-tab-attach-banner'); +const tabAttachTextEl = document.getElementById('bf-tab-attach-text'); +const attachCurrentTabBtn = document.getElementById('bf-attach-current-tab'); +let tabAttachRefreshTimer = null; +let tabAttachRefreshToken = 0; function setStatus(kind, text) { statusTextEl.textContent = text; @@ -48,6 +53,20 @@ function setComposerEnabled(enabled) { sendBtn.disabled = !enabled; } +function setTabAttachBannerState({ + hidden = true, + text = 'Current tab is not connected', + canAttach = false, + busy = false, +} = {}) { + if (!tabAttachBannerEl || !tabAttachTextEl || !attachCurrentTabBtn) return; + tabAttachBannerEl.classList.toggle('hidden', !!hidden); + if (hidden) return; + tabAttachTextEl.textContent = text; + attachCurrentTabBtn.disabled = busy || !canAttach; + attachCurrentTabBtn.textContent = busy ? 'Attaching...' : 'Attach current tab'; +} + function dispatch(action) { state.value = reduceState(state.value, action); render(); @@ -288,8 +307,99 @@ async function ensureCurrentTabAttached() { if (response?.error && !isIgnoredAttachError(response.error)) { console.warn('[bf-agent] attachCurrentTab failed:', response.error); } + return response || null; } catch { // best-effort only + return null; + } +} + +function isTabAttachableUrl(url) { + const value = String(url || '').trim(); + if (!value) return false; + return !( + value.startsWith('chrome://') + || value.startsWith('chrome-extension://') + || value.startsWith('edge://') + || value.startsWith('devtools://') + ); +} + +async function getCurrentTabAttachmentState() { + if (!chrome?.tabs?.query) return { hidden: true }; + let tab = null; + try { + [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + } catch { + return { hidden: true }; + } + if (!tab || typeof tab.id !== 'number') return { hidden: true }; + if (!isTabAttachableUrl(tab.url)) { + return { + hidden: false, + text: 'This tab cannot be attached', + canAttach: false, + }; + } + + try { + const status = await runtimeMessage({ type: 'getStatus' }); + if (status?.connectionState && status.connectionState !== 'connected') { + return { + hidden: false, + text: 'Relay disconnected', + canAttach: false, + }; + } + + const attachedTabs = Array.isArray(status?.tabs) ? status.tabs : []; + const attached = attachedTabs.some((item) => Number(item?.tabId) === tab.id); + if (attached) return { hidden: true }; + return { + hidden: false, + text: 'Current tab is not connected', + canAttach: true, + }; + } catch { + return { + hidden: false, + text: 'Unable to check tab connection', + canAttach: false, + }; + } +} + +async function refreshTabAttachBanner() { + const token = ++tabAttachRefreshToken; + const next = await getCurrentTabAttachmentState(); + if (token !== tabAttachRefreshToken) return; + setTabAttachBannerState(next); +} + +function scheduleTabAttachRefresh(delayMs = 0) { + if (tabAttachRefreshTimer) clearTimeout(tabAttachRefreshTimer); + tabAttachRefreshTimer = setTimeout(() => { + refreshTabAttachBanner().catch(() => {}); + }, delayMs); +} + +function bindTabAttachWatchers() { + if (chrome?.tabs?.onActivated?.addListener) { + chrome.tabs.onActivated.addListener(() => { + scheduleTabAttachRefresh(40); + }); + } + if (chrome?.tabs?.onUpdated?.addListener) { + chrome.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => { + if (!tab?.active) return; + if (!('status' in changeInfo) && !('url' in changeInfo) && !('title' in changeInfo)) return; + scheduleTabAttachRefresh(80); + }); + } + if (chrome?.windows?.onFocusChanged?.addListener) { + chrome.windows.onFocusChanged.addListener(() => { + scheduleTabAttachRefresh(80); + }); } } @@ -300,13 +410,7 @@ async function getActiveTabContext() { if (!tab || typeof tab.id !== 'number') return null; const title = String(tab.title || '').trim().slice(0, 180); const url = String(tab.url || '').trim(); - if ( - !url - || url.startsWith('chrome://') - || url.startsWith('chrome-extension://') - || url.startsWith('edge://') - || url.startsWith('devtools://') - ) { + if (!isTabAttachableUrl(url)) { return { tabId: tab.id, title, url: null }; } return { tabId: tab.id, title, url: url.slice(0, 500) }; @@ -528,6 +632,7 @@ async function sendMessage(text) { dispatch({ type: 'messages.loaded', sessionId, messages: [...existing, { role: 'user', text }] }); await ensureCurrentTabAttached(); + scheduleTabAttachRefresh(0); const browserContext = await getActiveTabContext(); const res = await api('/v1/runs', { @@ -574,6 +679,22 @@ chatInputEl.addEventListener('keydown', (event) => { chatFormEl.requestSubmit(); }); +if (attachCurrentTabBtn) { + attachCurrentTabBtn.addEventListener('click', async () => { + setTabAttachBannerState({ + hidden: false, + text: tabAttachTextEl?.textContent || 'Current tab is not connected', + canAttach: false, + busy: true, + }); + const response = await ensureCurrentTabAttached(); + if (response?.error && !isIgnoredAttachError(response.error)) { + setStatus('error', response.error || 'Unable to attach current tab'); + } + scheduleTabAttachRefresh(0); + }); +} + newSessionBtn.addEventListener('click', () => { createSession() .then(() => setPopover('none')) @@ -601,6 +722,7 @@ popoverBackdropEl.addEventListener('click', () => { setStatus('info', 'Connecting...'); await loadAuth(); await ensureCurrentTabAttached(); + bindTabAttachWatchers(); try { await loadModelPresets(); } catch { @@ -613,9 +735,11 @@ popoverBackdropEl.addEventListener('click', () => { await selectSession(state.value.activeSessionId); } setComposerEnabled(true); + scheduleTabAttachRefresh(0); setStatus('ready', 'Ready'); } catch { setComposerEnabled(false); + setTabAttachBannerState({ hidden: true }); setStatus('error', 'Daemon unavailable'); } })(); diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index 3739bc3..9b63e6a 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -13,6 +13,9 @@ test('agent panel has inline model and session selectors with popovers', () => { assert.match(html, /id="bf-session-panel"/); assert.match(html, /id="bf-model-list"/); assert.match(html, /id="bf-switch-session-list"/); + assert.match(html, /id="bf-tab-attach-banner"/); + assert.match(html, /id="bf-tab-attach-text"/); + assert.match(html, /id="bf-attach-current-tab"/); }); test('agent panel no longer renders title or persistent session sidebar', () => { diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index faf3ddd..7a0d415 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -19,6 +19,9 @@ test('submit handler preserves draft on send failure', () => { test('sidepanel auto-attaches current tab and sends browserContext with runs', () => { assert.match(js, /async function ensureCurrentTabAttached\(\)/); assert.match(js, /runtimeMessage\(\{\s*type:\s*'attachCurrentTab'\s*\}\)/); + assert.match(js, /runtimeMessage\(\{\s*type:\s*'getStatus'\s*\}\)/); + assert.match(js, /chrome\.tabs\.onActivated\.addListener/); + assert.match(js, /attachCurrentTabBtn\.addEventListener\('click'/); assert.match(js, /await ensureCurrentTabAttached\(\);/); assert.match(js, /const browserContext = await getActiveTabContext\(\);/); assert.match(js, /JSON\.stringify\(\{\s*sessionId,\s*message:\s*text,\s*browserContext\s*\}\)/); diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index 704d6d0..7237f2a 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -298,7 +298,8 @@ test('POST /v1/runs includes active tab context in runExecutor prompt', async () const prompt = seenRuns.at(-1)?.message || ''; assert.match(prompt, /Active tab title: Pricing/); assert.match(prompt, /Active tab URL: https:\/\/example\.com\/pricing/); - assert.match(prompt, /If the request is ambiguous/i); + assert.match(prompt, /inspect the active page and answer directly/i); + assert.match(prompt, /do not ask for permission to inspect/i); assert.match(prompt, /User request:\s*summarize this page/i); } finally { await daemon.stop(); From 820ae31b155a14050808b9f845b9bcc033f8f9d5 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 15:10:45 +0530 Subject: [PATCH 122/192] Redesign agent sidepanel UI to new style system --- extension/agent-panel.css | 743 ++++++++++++++++++++++++++----------- extension/agent-panel.html | 67 +++- extension/agent-panel.js | 186 ++++++++-- 3 files changed, 739 insertions(+), 257 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 231c2f7..fac41d9 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -1,108 +1,181 @@ :root { color-scheme: light; - --bf-crail: #C15F3C; - --bf-cloudy: #B1ADA1; - --bf-pampas: #F4F3EE; - --bf-white: #FFFFFF; - - --panel-bg: linear-gradient(180deg, var(--bf-pampas) 0%, #ECE6DB 100%); - --card-bg: var(--bf-white); - --line: #D8D3C9; - --line-soft: #E9E4DA; - --text: #3D3028; - --muted: #756F63; - --text-subtle: #8C857A; - --accent: var(--bf-crail); - --accent-hover: #B05535; - --accent-press: #9F4D30; - --accent-soft: #E9D3CB; - --accent-soft-text: #7A3D27; - --status-ok: var(--bf-crail); - --status-error: #8A3D24; - --status-error-strong: #B25334; - --menu-bg: rgba(51, 41, 34, 0.94); - --menu-line: rgba(255, 255, 255, 0.16); - --menu-text: var(--bf-pampas); + --crail: #C15F3C; + --crail-dark: #A34E30; + --crail-press: #8F4228; + --crail-soft: #F0DDD6; + --pampas: #F4F3EE; + --sand: #EAE6DE; + --linen: #F9F7F4; + --header: #1E2926; + --header-border: #2D3B35; + --text: #2E2419; + --text-muted: #6B6358; + --text-subtle: #9B9189; + --line: #DDD8CF; + --line-soft: #EDE9E2; + --ok: #3D8A5E; + --error: #B25334; + --menu-bg: rgba(26, 36, 33, 0.97); + --menu-line: rgba(255, 255, 255, 0.1); } * { box-sizing: border-box; + margin: 0; + padding: 0; } body { - margin: 0; - font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + background: var(--pampas); + min-height: 100vh; color: var(--text); - background: var(--panel-bg); } .agent-shell { height: 100vh; - min-height: 0; - display: grid; - grid-template-rows: auto 1fr; + display: flex; + flex-direction: column; + overflow: hidden; position: relative; + background: var(--pampas); } .agent-header { - padding: 10px 12px 8px; - border-bottom: 1px solid var(--line); - background: linear-gradient(180deg, var(--bf-white), var(--bf-pampas)); + flex-shrink: 0; + background: var(--header); + border-bottom: 1px solid var(--header-border); +} + +.title-bar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px 10px; +} + +.brand { + display: flex; + align-items: center; + gap: 8px; +} + +.brand-icon { + width: 24px; + height: 24px; + border-radius: 6px; + background: var(--crail); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 12px; + flex-shrink: 0; +} + +.brand-name { + font-size: 13.5px; + font-weight: 600; + color: #fff; + letter-spacing: -0.02em; } -.header-row { +.controls { display: flex; align-items: center; gap: 8px; + padding: 0 14px 12px; } -.selector-pill { +.pill-btn { flex: 1; - min-height: 30px; - border: 1px solid var(--line); + min-width: 0; + height: 32px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 0 12px; border-radius: 999px; - background: var(--card-bg); - color: var(--text); + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.78); font-size: 12px; - text-align: left; - padding: 0 10px; - white-space: nowrap; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.pill-btn:hover { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.pill-btn span { overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } -.icon-btn { - min-width: 32px; - min-height: 30px; - border-radius: 8px; - border: 1px solid var(--line); - background: var(--card-bg); - color: var(--text); - font-size: 20px; - line-height: 1; - padding: 0; +.pill-btn svg { + width: 12px; + height: 12px; + opacity: 0.55; + flex-shrink: 0; } -.status { - margin: 8px 0 0; - font-size: 12px; - color: var(--muted); +.round-btn { + width: 32px; + height: 32px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + cursor: pointer; display: flex; align-items: center; - gap: 6px; + justify-content: center; + color: rgba(255, 255, 255, 0.78); + transition: background 0.15s, color 0.15s; + flex-shrink: 0; } -.status-icon { - font-size: 10px; - color: var(--status-ok); +.round-btn:hover { + background: rgba(255, 255, 255, 0.16); + color: #fff; +} + +.round-btn svg { + width: 14px; + height: 14px; +} + +.status-circle { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.14); + display: flex; + align-items: center; + justify-content: center; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--ok); } -.status.error { - color: var(--status-error); +.status-circle.error .status-dot { + background: var(--error); } -.status.error .status-icon { - color: var(--status-error-strong); +.status-circle.thinking .status-dot { + background: #FBBF24; + animation: pulse 1.2s ease-in-out infinite; } .tab-attach { @@ -137,98 +210,202 @@ body { .chat { min-height: 0; - display: grid; - grid-template-rows: 1fr auto; + flex: 1; + display: flex; + flex-direction: column; } .transcript { - overflow: auto; - padding: 14px; + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 18px; + scroll-behavior: smooth; +} + +.transcript::-webkit-scrollbar { + width: 4px; +} + +.transcript::-webkit-scrollbar-thumb { + background: var(--line); + border-radius: 4px; } -.message-row { +.empty-state { display: flex; flex-direction: column; - margin-bottom: 10px; - gap: 6px; + align-items: center; + justify-content: center; + flex: 1; + gap: 12px; + text-align: center; + padding: 40px 20px; +} + +.empty-icon { + width: 40px; + height: 40px; + border-radius: 12px; + background: var(--crail); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 700; + font-size: 18px; +} + +.empty-title { + font-size: 14px; + font-weight: 500; + color: var(--text); +} + +.empty-sub { + font-size: 12px; + color: var(--text-subtle); + line-height: 1.5; +} + +.message { + display: flex; + flex-direction: column; + gap: 4px; } -.message-row.user { +.message.user { align-items: flex-end; } -.message-row.assistant { +.message.assistant { align-items: flex-start; } -.message { - font-size: 13px; +.msg-meta { + display: flex; + align-items: center; + gap: 6px; + padding: 0 4px; +} + +.msg-author { + font-size: 10px; + font-weight: 600; + color: var(--text-subtle); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.msg-content-wrap { + max-width: 96%; +} + +.bubble-user { + background: var(--crail); + color: #fff; + font-size: 13.5px; + line-height: 1.55; + border-radius: 16px; + border-top-right-radius: 4px; + padding: 10px 16px; + max-width: 82%; + box-shadow: 0 2px 8px rgba(193, 95, 60, 0.25); white-space: pre-wrap; - line-height: 1.45; } -.message.user { - background: var(--accent-press); - color: var(--bf-white); - border: 1px solid var(--accent-hover); +.bubble-assistant { + background: #fff; + border: 1px solid var(--line-soft); border-radius: 16px; - padding: 10px 14px; - max-width: min(85%, 520px); - box-shadow: 0 4px 12px rgba(97, 53, 37, 0.28); + border-top-left-radius: 4px; + padding: 12px 16px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); } -.message.assistant { - background: transparent; - border: 0; +.bubble-assistant p { + font-size: 13.5px; + line-height: 1.6; color: var(--text); - padding: 0; - max-width: 100%; + white-space: pre-wrap; +} + +.bubble-assistant code { + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 11.5px; + background: var(--sand); + color: var(--crail-dark); + padding: 2px 6px; + border-radius: 5px; + border: 1px solid var(--line); } .run-steps-summary { - display: inline-flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; + margin-bottom: 6px; } -.run-steps-trigger { - all: unset; +.steps-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + background: transparent; + border: 0; cursor: pointer; + font-size: 12px; color: var(--text-subtle); - font-size: 13px; - font-weight: 500; + padding: 0; + transition: color 0.15s; } -.run-steps-trigger:hover { - color: var(--accent); +.steps-toggle:hover { + color: var(--text-muted); } -.run-steps-list { +.steps-toggle svg { + width: 12px; + height: 12px; + transition: transform 0.2s; +} + +.steps-toggle.open svg { + transform: rotate(90deg); +} + +.steps-list { list-style: none; - margin: 0; - padding: 0 0 0 10px; - border-left: 1px solid var(--line-soft); - display: grid; + display: none; + margin: 8px 0 8px 2px; + padding-left: 12px; + border-left: 1.5px solid var(--line); +} + +.steps-list.open { + display: flex; + flex-direction: column; gap: 8px; } -.run-step { +.step-item { display: flex; align-items: flex-start; gap: 8px; - color: var(--muted); } -.run-step-label { +.step-label { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; white-space: pre-wrap; } .run-step-icon { - width: 16px; - height: 16px; - margin-top: 2px; - flex: 0 0 16px; + width: 13px; + height: 13px; + flex-shrink: 0; + margin-top: 1px; color: var(--text-subtle); position: relative; } @@ -240,63 +417,63 @@ body { } .run-step-icon.icon-reasoning::before { - top: 4px; - left: 4px; - width: 8px; - height: 8px; + top: 2px; + left: 2px; + width: 9px; + height: 9px; border-radius: 999px; background: currentColor; } .run-step-icon.icon-tool::before { - top: 2px; - left: 2px; - width: 12px; - height: 12px; + top: 1px; + left: 1px; + width: 10px; + height: 10px; border: 1.5px solid currentColor; border-radius: 3px; } .run-step-icon.icon-view::before { - top: 5px; - left: 1px; - width: 14px; - height: 8px; + top: 4px; + left: 0; + width: 12px; + height: 6px; border: 1.5px solid currentColor; - border-radius: 10px; + border-radius: 8px; } .run-step-icon.icon-view::after { - top: 7px; - left: 6px; - width: 4px; - height: 4px; + top: 6px; + left: 5px; + width: 2px; + height: 2px; border: 1.5px solid currentColor; border-radius: 999px; } .run-step-icon.icon-camera::before { - top: 5px; - left: 1px; - width: 14px; - height: 9px; + top: 3px; + left: 0; + width: 12px; + height: 7px; border: 1.5px solid currentColor; - border-radius: 3px; + border-radius: 2px; } .run-step-icon.icon-camera::after { top: 1px; left: 4px; - width: 6px; - height: 4px; + width: 4px; + height: 2px; border: 1.5px solid currentColor; border-bottom: 0; border-radius: 2px 2px 0 0; } .run-step-icon.icon-plan::before { - top: 3px; - left: 3px; + top: 2px; + left: 2px; width: 2px; height: 2px; border-radius: 999px; @@ -305,9 +482,9 @@ body { } .run-step-icon.icon-plan::after { - top: 3px; - left: 7px; - width: 7px; + top: 2px; + left: 6px; + width: 5px; height: 2px; border-radius: 2px; background: currentColor; @@ -315,18 +492,18 @@ body { } .run-step-icon.icon-done::before { - top: 1px; - left: 1px; - width: 12px; - height: 12px; + top: 0; + left: 0; + width: 11px; + height: 11px; border: 1.5px solid currentColor; border-radius: 999px; } .run-step-icon.icon-done::after { - top: 6px; - left: 5px; - width: 6px; + top: 5px; + left: 3px; + width: 5px; height: 3px; border-left: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; @@ -334,139 +511,281 @@ body { } .run-step-icon.icon-failed::before { - top: 1px; - left: 1px; - width: 12px; - height: 12px; + top: 0; + left: 0; + width: 11px; + height: 11px; border: 1.5px solid currentColor; border-radius: 999px; } .run-step-icon.icon-failed::after { - top: 7px; - left: 4px; - width: 8px; + top: 6px; + left: 2px; + width: 7px; height: 1.5px; background: currentColor; } -.run-step.done .run-step-icon { - color: var(--status-ok); +.step-item.done .run-step-icon { + color: var(--ok); } -.run-step.failed .run-step-icon { - color: var(--status-error-strong); +.step-item.failed .run-step-icon { + color: var(--error); } -.composer { - border-top: 1px solid var(--line); - padding: 10px; - display: grid; +.thinking-bubble { + background: #fff; + border: 1px solid var(--line-soft); + border-radius: 16px; + border-top-left-radius: 4px; + padding: 12px 16px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + display: flex; + align-items: center; gap: 8px; - background: var(--card-bg); } -.composer textarea { - width: 100%; - min-height: 72px; - max-height: 160px; - resize: vertical; - padding: 10px; +.thinking-bubble span { + font-size: 13px; + color: var(--text-muted); +} + +.spinner { + width: 14px; + height: 14px; + border: 2px solid var(--crail-soft); + border-top-color: var(--crail); + border-radius: 50%; + flex-shrink: 0; + animation: spin 0.7s linear infinite; +} + +.composer-wrap { + flex-shrink: 0; + background: #fff; + border-top: 1px solid var(--line); + padding: 10px 12px; +} + +.composer-box { + display: flex; + align-items: flex-end; + gap: 6px; border: 1px solid var(--line); - border-radius: 8px; - font: inherit; + border-radius: 12px; + background: var(--linen); + padding: 8px 8px 8px 12px; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.composer-box:focus-within { + border-color: var(--crail); + box-shadow: 0 0 0 3px rgba(193, 95, 60, 0.12); +} + +.composer-textarea { + flex: 1; + resize: none; + background: transparent; + border: 0; + outline: none; + font-size: 13.5px; + font-family: inherit; color: var(--text); - background: var(--card-bg); + line-height: 1.55; + min-height: 22px; + max-height: 160px; + overflow-y: auto; +} + +.composer-textarea::placeholder { + color: var(--text-subtle); } .composer-actions { display: flex; - justify-content: flex-end; - gap: 8px; + align-items: center; + gap: 4px; + flex-shrink: 0; } -button { - border: 0; +.btn-stop, +.btn-send { + width: 32px; + height: 32px; border-radius: 8px; - background: var(--accent); - color: var(--bf-white); - padding: 8px 12px; + border: 0; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s, opacity 0.15s, color 0.15s; +} + +.btn-stop { + background: transparent; + cursor: not-allowed; + color: var(--text-subtle); + opacity: 0.3; +} + +.btn-stop.active { cursor: pointer; + opacity: 1; + color: #EF4444; } -button:hover { - background: var(--accent-hover); +.btn-stop.active:hover { + background: #FEF2F2; } -button:active { - background: var(--accent-press); +.btn-stop svg { + width: 15px; + height: 15px; } -button.secondary { - background: var(--accent-soft); - color: var(--accent-soft-text); +.btn-send { + background: var(--crail); + color: #fff; + cursor: pointer; + box-shadow: 0 2px 6px rgba(193, 95, 60, 0.3); } -button.secondary:hover { - background: #E2C8BE; +.btn-send:hover:not(:disabled) { + background: var(--crail-dark); } -.menu-backdrop { +.btn-send:active:not(:disabled) { + background: var(--crail-press); +} + +.btn-send:disabled { + opacity: 0.35; + cursor: not-allowed; + box-shadow: none; +} + +.btn-send svg { + width: 13px; + height: 13px; +} + +.popover-backdrop { position: absolute; inset: 0; - background: rgba(0, 0, 0, 0.14); z-index: 20; + display: block; } .popover-panel { position: absolute; - top: 52px; + top: 56px; left: 12px; right: 12px; - border-radius: 14px; + z-index: 30; + border-radius: 16px; background: var(--menu-bg); - border: 1px solid var(--menu-line); - box-shadow: 0 18px 36px rgba(66, 49, 39, 0.32); - backdrop-filter: blur(14px); - z-index: 21; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + backdrop-filter: blur(16px); + overflow: hidden; max-height: min(360px, calc(100vh - 70px)); - overflow: auto; +} + +.popover-label { + font-size: 10px; + font-weight: 600; + color: rgba(255, 255, 255, 0.35); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 10px 14px 4px; } .popover-list { list-style: none; - margin: 0; - padding: 8px; + padding: 4px 8px 8px; + max-height: 260px; + overflow-y: auto; +} + +.popover-list::-webkit-scrollbar { + width: 4px; +} + +.popover-list::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; } -.popover-list button { +.popover-item { + display: flex; + align-items: center; + justify-content: space-between; width: 100%; - text-align: left; + padding: 9px 12px; + border-radius: 10px; border: 0; background: transparent; - color: var(--menu-text); - border-radius: 10px; - padding: 10px 12px; - margin: 1px 0; - font-size: 14px; + color: rgba(255, 255, 255, 0.72); + font-size: 13px; + cursor: pointer; + text-align: left; + transition: background 0.12s, color 0.12s; } -.popover-list button.active, -.popover-list button:hover { - background: rgba(255, 255, 255, 0.11); +.popover-item:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; } -.popover-list .hint { - font-size: 12px; - color: rgba(255, 255, 255, 0.66); - margin-top: 4px; +.popover-item.active { + background: rgba(255, 255, 255, 0.14); + color: #fff; + font-weight: 500; } -.popover-list .empty-item { +.popover-item.active::after { + content: '✓'; + color: var(--crail); + font-weight: 700; +} + +.popover-item.custom-item { + color: rgba(255, 255, 255, 0.45); +} + +.empty-item { color: rgba(255, 255, 255, 0.75); padding: 10px 12px; + font-size: 13px; } .hidden { display: none; } + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +@keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.4; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/extension/agent-panel.html b/extension/agent-panel.html index 70b90ae..55dbcf8 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -9,15 +9,39 @@
-
- - - +
+
+ + BrowserForce +
+
+ +
+ + + + + + +
+ + Starting... +
-

- - Starting... -

diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 69bca4e..07681f1 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -17,13 +17,19 @@ const state = { eventLoopToken: 0, sessionSelectionToken: 0, popover: 'none', + status: { + kind: 'info', + text: 'Starting...', + }, }; const statusEl = document.getElementById('bf-agent-status'); const statusIconEl = document.getElementById('bf-agent-status-icon'); const statusTextEl = document.getElementById('bf-agent-status-text'); const modelTriggerBtn = document.getElementById('bf-model-trigger'); +const modelLabelEl = document.getElementById('bf-model-label'); const sessionTriggerBtn = document.getElementById('bf-session-trigger'); +const sessionLabelEl = document.getElementById('bf-session-label'); const newSessionBtn = document.getElementById('bf-new-session'); const popoverBackdropEl = document.getElementById('bf-popover-backdrop'); const modelPanelEl = document.getElementById('bf-model-panel'); @@ -41,16 +47,67 @@ const attachCurrentTabBtn = document.getElementById('bf-attach-current-tab'); let tabAttachRefreshTimer = null; let tabAttachRefreshToken = 0; +function getActiveSession() { + return state.value.sessions.find((item) => item.sessionId === state.value.activeSessionId) || null; +} + +function getActiveMessages() { + return state.value.messagesBySession[state.value.activeSessionId] || []; +} + +function getActiveRun() { + const sessionId = state.value.activeSessionId; + if (!sessionId) return null; + const runId = getSessionRunId(state.currentRunBySession, sessionId); + if (!runId) return null; + return state.value.runs[runId] || null; +} + +function isActiveRunInProgress() { + const run = getActiveRun(); + return !!(run && !run.done); +} + +function autoResizeInput() { + chatInputEl.style.height = 'auto'; + chatInputEl.style.height = `${Math.min(chatInputEl.scrollHeight, 160)}px`; +} + +function syncComposerState() { + const enabled = !chatInputEl.disabled; + const hasText = chatInputEl.value.trim().length > 0; + const runInProgress = isActiveRunInProgress(); + + stopRunBtn.disabled = !enabled || !runInProgress; + stopRunBtn.classList.toggle('active', enabled && runInProgress); + sendBtn.disabled = !enabled || runInProgress || !hasText; +} + +function syncStatusIndicator() { + const runInProgress = isActiveRunInProgress(); + const hasError = state.status.kind === 'error'; + const text = hasError + ? state.status.text + : runInProgress + ? 'Thinking...' + : state.status.text; + + statusEl.classList.toggle('error', hasError); + statusEl.classList.toggle('thinking', runInProgress && !hasError); + statusEl.title = text || 'Ready'; + statusTextEl.textContent = text || ''; + statusIconEl.textContent = ''; +} + function setStatus(kind, text) { - statusTextEl.textContent = text; - statusEl.classList.toggle('error', kind === 'error'); - statusIconEl.textContent = kind === 'error' ? '!' : '●'; + state.status = { kind, text }; + syncStatusIndicator(); } function setComposerEnabled(enabled) { chatInputEl.disabled = !enabled; - stopRunBtn.disabled = !enabled; - sendBtn.disabled = !enabled; + autoResizeInput(); + syncComposerState(); } function setTabAttachBannerState({ @@ -83,22 +140,26 @@ function dispatchEvent(evt) { render(); } -function getActiveSession() { - return state.value.sessions.find((item) => item.sessionId === state.value.activeSessionId) || null; -} - -function getActiveMessages() { - return state.value.messagesBySession[state.value.activeSessionId] || []; -} - function formatModelLabel(model) { return model && String(model).trim() ? model : 'Default'; } function renderSelectors() { const activeSession = getActiveSession(); - modelTriggerBtn.textContent = `Model: ${formatModelLabel(activeSession?.model)}`; - sessionTriggerBtn.textContent = activeSession?.title || 'Session'; + const modelLabel = `Model: ${formatModelLabel(activeSession?.model)}`; + const sessionLabel = activeSession?.title || 'Session'; + + if (modelLabelEl) { + modelLabelEl.textContent = modelLabel; + } else { + modelTriggerBtn.textContent = modelLabel; + } + + if (sessionLabelEl) { + sessionLabelEl.textContent = sessionLabel; + } else { + sessionTriggerBtn.textContent = sessionLabel; + } } function renderModelList() { @@ -107,9 +168,9 @@ function renderModelList() { const rows = state.modelPresets.map((preset) => { const active = (preset.value || null) === activeModel ? 'active' : ''; - return `
  • `; + return `
  • `; }); - rows.push('
  • '); + rows.push('
  • '); modelListEl.innerHTML = rows.join(''); @@ -156,7 +217,7 @@ function renderSessions() { const active = session.sessionId === state.value.activeSessionId ? 'active' : ''; const title = session.title || session.sessionId; const suffix = (titleCounts.get(title) || 0) > 1 ? ` · ${session.sessionId.slice(0, 8)}` : ''; - return `
  • `; + return `
  • `; }) .join(''); @@ -185,22 +246,31 @@ function renderRunSteps(runId, run) { if (!runId || !run || !Array.isArray(run.steps) || run.steps.length === 0) return ''; const count = run.steps.length; const expanded = isRunStepsExpanded(runId); - const summary = ``; - if (!expanded) { - return `
    ${summary}
    `; - } const items = run.steps .map((step) => { - const kind = step?.kind || 'reasoning'; const status = step?.status || 'running'; const label = step?.label || 'Step'; const icon = classifyRunStepIcon(step); - return `
  • ${escapeHtml(label)}
  • `; + return `
  • ${escapeHtml(label)}
  • `; }) .join(''); - return `
    ${summary}
      ${items}
    `; + return ` +
    + +
      ${items}
    +
    + `; +} + +function renderContent(value) { + return escapeHtml(value).replace(/`([^`]+)`/g, '$1'); } function bindTranscriptHandlers() { @@ -220,21 +290,65 @@ function renderTranscript() { const chunks = messages.map((msg) => { const role = msg.role || 'assistant'; if (role === 'user') { - return `
    ${escapeHtml(msg.text || '')}
    `; + return ` +
    +
    You
    +
    ${escapeHtml(msg.text || '')}
    +
    + `; } const messageRun = msg.runId ? state.value.runs[msg.runId] : null; - return `
    ${renderRunSteps(msg.runId, messageRun)}
    ${escapeHtml(msg.text || '')}
    `; + return ` +
    +
    BrowserForce
    +
    + ${renderRunSteps(msg.runId, messageRun)} +

    ${renderContent(msg.text || '')}

    +
    +
    + `; }); if (run && !run.done) { - const liveText = run.text ? `
    ${escapeHtml(run.text || '')}
    ` : ''; - chunks.push(`
    ${renderRunSteps(sessionRunId, run)}${liveText}
    `); + if (run.text && run.text.trim()) { + chunks.push(` +
    +
    BrowserForce
    +
    + ${renderRunSteps(sessionRunId, run)} +

    ${renderContent(run.text)}

    +
    +
    + `); + } else { + chunks.push(` +
    +
    BrowserForce
    +
    Thinking...
    +
    + `); + } + } + + if (!chunks.length) { + transcriptEl.innerHTML = ` +
    +
    B
    +
    +

    Start a conversation

    +

    Ask BrowserForce to inspect your active tab or run a browser task.

    +
    +
    + `; + } else { + transcriptEl.innerHTML = chunks.join(''); } - transcriptEl.innerHTML = chunks.join('') || '
    No messages yet.
    '; bindTranscriptHandlers(); transcriptEl.scrollTop = transcriptEl.scrollHeight; + syncStatusIndicator(); + syncComposerState(); } function setPopover(popover) { @@ -648,6 +762,7 @@ async function sendMessage(text) { if (body.runId) { state.currentRunBySession = assignSessionRunId(state.currentRunBySession, sessionId, body.runId); } + render(); } async function stopRun() { @@ -666,8 +781,11 @@ chatFormEl.addEventListener('submit', async (event) => { try { await sendMessage(text); chatInputEl.value = ''; + autoResizeInput(); + syncComposerState(); } catch (error) { chatInputEl.value = text; + syncComposerState(); setStatus('error', error?.message || 'Failed to send message'); } }); @@ -676,6 +794,7 @@ chatInputEl.addEventListener('keydown', (event) => { if (event.key !== 'Enter' || event.shiftKey) return; if (event.isComposing) return; event.preventDefault(); + if (sendBtn.disabled) return; chatFormEl.requestSubmit(); }); @@ -695,6 +814,11 @@ if (attachCurrentTabBtn) { }); } +chatInputEl.addEventListener('input', () => { + autoResizeInput(); + syncComposerState(); +}); + newSessionBtn.addEventListener('click', () => { createSession() .then(() => setPopover('none')) @@ -719,6 +843,7 @@ popoverBackdropEl.addEventListener('click', () => { (async function init() { try { + setComposerEnabled(false); setStatus('info', 'Connecting...'); await loadAuth(); await ensureCurrentTabAttached(); @@ -737,6 +862,7 @@ popoverBackdropEl.addEventListener('click', () => { setComposerEnabled(true); scheduleTabAttachRefresh(0); setStatus('ready', 'Ready'); + render(); } catch { setComposerEnabled(false); setTabAttachBannerState({ hidden: true }); From d7fc71b308f199dda82de6bd1f24080b13d8061b Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 15:25:34 +0530 Subject: [PATCH 123/192] Improve sidepanel session UX and prevent message overflow --- extension/agent-panel.css | 149 +++++++++++---- extension/agent-panel.html | 7 - extension/agent-panel.js | 189 ++++++++++++++++++- test/agent/agent-panel-contract.test.js | 10 + test/agent/agent-panel-send-contract.test.js | 23 +++ 5 files changed, 326 insertions(+), 52 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index fac41d9..ffac37e 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -48,45 +48,11 @@ body { border-bottom: 1px solid var(--header-border); } -.title-bar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 14px 10px; -} - -.brand { - display: flex; - align-items: center; - gap: 8px; -} - -.brand-icon { - width: 24px; - height: 24px; - border-radius: 6px; - background: var(--crail); - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-weight: 700; - font-size: 12px; - flex-shrink: 0; -} - -.brand-name { - font-size: 13.5px; - font-weight: 600; - color: #fff; - letter-spacing: -0.02em; -} - .controls { display: flex; align-items: center; gap: 8px; - padding: 0 14px 12px; + padding: 12px 14px; } .pill-btn { @@ -219,6 +185,7 @@ body { flex: 1; min-height: 0; overflow-y: auto; + overflow-x: hidden; padding: 16px; display: flex; flex-direction: column; @@ -302,6 +269,7 @@ body { .msg-content-wrap { max-width: 96%; + min-width: 0; } .bubble-user { @@ -315,6 +283,8 @@ body { max-width: 82%; box-shadow: 0 2px 8px rgba(193, 95, 60, 0.25); white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .bubble-assistant { @@ -324,6 +294,8 @@ body { border-top-left-radius: 4px; padding: 12px 16px; box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + max-width: 100%; + overflow-wrap: anywhere; } .bubble-assistant p { @@ -331,6 +303,8 @@ body { line-height: 1.6; color: var(--text); white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .bubble-assistant code { @@ -341,6 +315,9 @@ body { padding: 2px 6px; border-radius: 5px; border: 1px solid var(--line); + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; } .run-steps-summary { @@ -755,6 +732,108 @@ body { color: rgba(255, 255, 255, 0.45); } +.session-row { + display: flex; + align-items: center; + gap: 6px; + margin: 1px 0; +} + +.session-item { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; +} + +.session-item.active::after { + content: none; +} + +.session-main { + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.session-meta { + font-size: 10px; + color: rgba(255, 255, 255, 0.52); +} + +.session-edit-btn { + width: 28px; + height: 28px; + flex-shrink: 0; + border-radius: 8px; + border: 0; + background: transparent; + color: rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} + +.session-edit-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: #fff; +} + +.session-edit-btn svg { + width: 13px; + height: 13px; +} + +.session-edit-form { + width: 100%; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 6px; +} + +.session-edit-form input { + flex: 1; + min-width: 0; + height: 30px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.24); + background: rgba(255, 255, 255, 0.08); + color: #fff; + padding: 0 10px; + font-size: 12px; + outline: none; +} + +.session-edit-form input::placeholder { + color: rgba(255, 255, 255, 0.45); +} + +.session-edit-save, +.session-edit-cancel { + height: 30px; + border-radius: 8px; + border: 0; + padding: 0 10px; + font-size: 11px; + cursor: pointer; +} + +.session-edit-save { + background: var(--crail); + color: #fff; +} + +.session-edit-cancel { + background: rgba(255, 255, 255, 0.14); + color: rgba(255, 255, 255, 0.86); +} + .empty-item { color: rgba(255, 255, 255, 0.75); padding: 10px 12px; diff --git a/extension/agent-panel.html b/extension/agent-panel.html index 55dbcf8..9e20c0f 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -9,13 +9,6 @@
    -
    -
    - - BrowserForce -
    -
    -
    `; + const displayName = formatSessionDisplayName(session); + const timestamp = formatSessionTimestamp(session); + const shortId = formatShortSessionId(session.sessionId); + const editing = session.sessionId === state.editingSessionId; + const draftTitle = Object.prototype.hasOwnProperty.call(state.sessionTitleDrafts, session.sessionId) + ? state.sessionTitleDrafts[session.sessionId] + : (isDefaultSessionTitle(session.title) ? '' : String(session.title || '').trim()); + + if (editing) { + return ` +
  • +
    + + + +
    +
  • + `; + } + + return ` +
  • + + +
  • + `; }) .join(''); @@ -227,6 +298,48 @@ function renderSessions() { setPopover('none'); }); }); + + switchSessionListEl.querySelectorAll('button[data-session-edit-btn]').forEach((button) => { + button.addEventListener('click', () => { + beginSessionEdit(button.getAttribute('data-session-edit-btn') || ''); + }); + }); + + switchSessionListEl.querySelectorAll('form[data-session-edit-form]').forEach((form) => { + form.addEventListener('submit', async (event) => { + event.preventDefault(); + const sessionId = form.getAttribute('data-session-edit-form') || ''; + const input = form.querySelector('input[data-session-edit-input]'); + const title = input?.value || ''; + try { + await updateSessionTitle(sessionId, title); + } catch (error) { + setStatus('error', error?.message || 'Unable to rename session'); + } + }); + }); + + switchSessionListEl.querySelectorAll('button[data-session-edit-cancel]').forEach((button) => { + button.addEventListener('click', () => { + cancelSessionEdit(button.getAttribute('data-session-edit-cancel') || ''); + }); + }); + + switchSessionListEl.querySelectorAll('input[data-session-edit-input]').forEach((input) => { + input.addEventListener('input', () => { + const sessionId = input.getAttribute('data-session-edit-input') || ''; + state.sessionTitleDrafts = { + ...(state.sessionTitleDrafts || {}), + [sessionId]: input.value, + }; + }); + input.addEventListener('keydown', (event) => { + if (event.key !== 'Escape') return; + event.preventDefault(); + const sessionId = input.getAttribute('data-session-edit-input') || ''; + cancelSessionEdit(sessionId); + }); + }); } function isRunStepsExpanded(runId) { @@ -655,6 +768,62 @@ async function createSession() { await selectSession(created.sessionId); } +function beginSessionEdit(sessionId) { + if (!sessionId) return; + const session = state.value.sessions.find((item) => item.sessionId === sessionId); + if (!session) return; + + const current = isDefaultSessionTitle(session.title) ? '' : String(session.title || '').trim(); + state.editingSessionId = sessionId; + state.sessionTitleDrafts = { + ...(state.sessionTitleDrafts || {}), + [sessionId]: current, + }; + renderSessions(); + + window.requestAnimationFrame(() => { + const input = switchSessionListEl.querySelector(`input[data-session-edit-input="${sessionId}"]`); + if (!input) return; + input.focus(); + input.select(); + }); +} + +function cancelSessionEdit(sessionId) { + if (!sessionId) return; + state.editingSessionId = null; + const nextDrafts = { ...(state.sessionTitleDrafts || {}) }; + delete nextDrafts[sessionId]; + state.sessionTitleDrafts = nextDrafts; + renderSessions(); +} + +async function updateSessionTitle(sessionId, rawTitle) { + const title = String(rawTitle || '').trim(); + if (!sessionId) return; + if (!title) { + throw new Error('Session name cannot be empty'); + } + + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ title: title }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Unable to rename session'); + } + + state.editingSessionId = null; + const nextDrafts = { ...(state.sessionTitleDrafts || {}) }; + delete nextDrafts[sessionId]; + state.sessionTitleDrafts = nextDrafts; + + const activeSessionId = state.value.activeSessionId || sessionId; + await loadSessions(activeSessionId); + setStatus('ready', 'Ready'); +} + async function updateActiveSessionModel(model) { const sessionId = state.value.activeSessionId; if (!sessionId) return; diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index 9b63e6a..cfadc94 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -3,6 +3,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; const html = fs.readFileSync('extension/agent-panel.html', 'utf8'); +const css = fs.readFileSync('extension/agent-panel.css', 'utf8'); test('agent panel has inline model and session selectors with popovers', () => { assert.match(html, /id="bf-model-trigger"/); @@ -22,3 +23,12 @@ test('agent panel no longer renders title or persistent session sidebar', () => assert.doesNotMatch(html, /

    /); }); + +test('agent panel does not render a BrowserForce heading bar', () => { + assert.doesNotMatch(html, /class="brand-name"/); +}); + +test('agent panel keeps horizontal overflow contained in transcript cards', () => { + assert.match(css, /\.transcript[\s\S]*overflow-x:\s*hidden/); + assert.match(css, /\.bubble-assistant code[\s\S]*overflow-wrap:\s*anywhere/); +}); diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 7a0d415..d108862 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -33,3 +33,26 @@ test('enter key submits composer and shift+enter keeps newline', () => { assert.match(js, /event\.preventDefault\(\);/); assert.match(js, /chatFormEl\.requestSubmit\(\);/); }); + +test('session labels fall back to session id when title is default', () => { + assert.match(js, /function isDefaultSessionTitle\(title\)/); + assert.match(js, /new session/); + assert.match(js, /new chat/); + assert.match(js, /function formatSessionDisplayName\(session\)/); + assert.match(js, /session\.sessionId/); +}); + +test('session popover supports inline rename and saves via session patch endpoint', () => { + assert.match(js, /data-session-edit-btn/); + assert.match(js, /data-session-edit-form/); + assert.match(js, /async function updateSessionTitle/); + assert.match(js, /\/v1\/sessions\/\$\{encodeURIComponent\(sessionId\)\}/); + assert.match(js, /method:\s*'PATCH'/); + assert.match(js, /JSON\.stringify\(\{\s*title\s*:\s*title/); +}); + +test('session popover renders per-session timestamp metadata', () => { + assert.match(js, /function formatSessionTimestamp/); + assert.match(js, /updatedAt|createdAt/); + assert.match(js, /toLocaleString/); +}); From e9998340810265065fdb00f919eb0a0e03851afd Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 15:43:49 +0530 Subject: [PATCH 124/192] Persist run steps for session transcript rehydration --- agent/src/chatd.js | 167 +++++++++++++++++++++++++++- agent/src/session-store.js | 42 ++++++- extension/agent-panel-state.js | 99 ++++++++++++++++- test/agent/chatd-api.test.js | 66 +++++++++++ test/agent/session-store.test.js | 16 +++ test/agent/session-ui-state.test.js | 25 +++++ 6 files changed, 411 insertions(+), 4 deletions(-) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index cd1164f..80a3838 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -248,6 +248,156 @@ function normalizeBrowserContext(raw) { return { tabId, title, url }; } +function firstString(values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return ''; +} + +function trimStepLabel(label) { + const text = String(label || '').trim(); + if (!text) return ''; + return text.length > 160 ? `${text.slice(0, 157)}...` : text; +} + +function pushRunStep(run, step) { + if (!run) return; + const steps = Array.isArray(run.steps) ? run.steps : []; + const normalized = { + kind: String(step?.kind || '').trim() || 'reasoning', + status: String(step?.status || '').trim() || 'running', + label: trimStepLabel(step?.label), + }; + if (!normalized.label) return; + const last = steps[steps.length - 1]; + if (last && last.label === normalized.label && last.kind === normalized.kind && last.status === normalized.status) { + return; + } + steps.push(normalized); + if (steps.length > 100) steps.shift(); + run.steps = steps; +} + +function stepLabelForToolEvent(evt) { + const payload = evt?.payload || {}; + if (evt.event === 'tool.started') { + return firstString([ + payload.title, + payload.name, + payload.tool, + payload.toolName, + payload.command, + ]) || 'Tool call started'; + } + if (evt.event === 'tool.final') { + return firstString([ + payload.title, + payload.name, + payload.tool, + payload.toolName, + payload.command, + ]) || 'Tool call completed'; + } + if (evt.event === 'tool.delta') { + return firstString([ + payload.text, + payload.message, + payload.delta, + payload.command, + payload.name, + payload.tool, + payload.toolName, + payload.type === 'reasoning' ? 'Reasoning' : '', + ]) || 'Working...'; + } + return ''; +} + +function humanizeToken(value) { + const normalized = String(value || '') + .trim() + .replace(/[_./-]+/g, ' ') + .replace(/\s+/g, ' '); + if (!normalized) return ''; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function stepStatusForRunEvent(evt) { + const payload = evt?.payload || {}; + const type = String(payload.type || '').toLowerCase(); + if (/error|failed|aborted/.test(type)) return 'failed'; + if (/completed|final|done|finished|succeeded|success|end/.test(type)) return 'done'; + return 'running'; +} + +function stepKindForRunEvent(evt) { + const payload = evt?.payload || {}; + const itemType = String(payload?.item?.type || '').toLowerCase(); + const eventType = String(payload?.type || '').toLowerCase(); + if (/reason/.test(itemType) || /reason/.test(eventType)) return 'reasoning'; + return 'tool'; +} + +function stepLabelForRunEvent(evt) { + const payload = evt?.payload || {}; + const item = payload?.item && typeof payload.item === 'object' ? payload.item : {}; + return firstString([ + payload.title, + payload.message, + payload.text, + payload.status, + item.summary, + item.text, + item.message, + item.title, + item.name, + item.tool, + item.command, + item.type ? humanizeToken(item.type) : '', + payload.type ? humanizeToken(payload.type) : '', + ]) || 'Working...'; +} + +function trackRunStep(run, evt) { + if (!run || !evt?.event) return; + + if (evt.event === 'tool.started' || evt.event === 'tool.delta' || evt.event === 'tool.final') { + pushRunStep(run, { + kind: evt.event === 'tool.delta' ? 'reasoning' : 'tool', + status: evt.event === 'tool.final' ? 'done' : 'running', + label: stepLabelForToolEvent(evt), + }); + return; + } + + if (evt.event === 'run.event') { + pushRunStep(run, { + kind: stepKindForRunEvent(evt), + status: stepStatusForRunEvent(evt), + label: stepLabelForRunEvent(evt), + }); + return; + } + + if (evt.event === 'run.error') { + pushRunStep(run, { + kind: 'status', + status: 'failed', + label: `Failed: ${evt.payload?.error || 'Unknown error'}`, + }); + return; + } + + if (evt.event === 'run.aborted') { + pushRunStep(run, { + kind: 'status', + status: 'aborted', + label: 'Stopped', + }); + } +} + function buildRunPrompt({ message, browserContext }) { if (!browserContext) return message; @@ -260,7 +410,10 @@ function buildRunPrompt({ message, browserContext }) { lines.push('Inspect the active page and answer directly when the user asks about what is on this tab.'); lines.push('Do not ask for permission to inspect the active page.'); lines.push('Assume the user is referring to this active tab unless they explicitly say otherwise.'); - lines.push('If the request is ambiguous or you are not sure, ask the user a clarifying question before acting.'); + lines.push('When the user asks what you can see, asks about this page/tab, or requests a summary of the current page, inspect the active page and answer directly.'); + lines.push('Use BrowserForce browser tools to read the current page content before replying in these cases.'); + lines.push('Do not ask for permission to inspect, and do not say you only have tab metadata.'); + lines.push('If the request is still ambiguous after inspecting, ask one focused clarifying question.'); lines.push(''); lines.push(`User request: ${message}`); return lines.join('\n'); @@ -358,7 +511,14 @@ export async function startChatd(opts = {}) { if (!run || run.status !== 'running' || run.finalSent) return; run.finalSent = true; run.status = 'done'; - await appendMessage({ sessionId: run.sessionId, role: 'assistant', text: finalText, storageRoot }); + await appendMessage({ + sessionId: run.sessionId, + role: 'assistant', + text: finalText, + runId: run.runId, + steps: run.steps, + storageRoot, + }); broadcast(buildEvent({ event: 'chat.final', runId: run.runId, sessionId: run.sessionId, payload: { text: finalText } })); runs.delete(run.runId); } @@ -555,6 +715,7 @@ export async function startChatd(opts = {}) { status: 'running', abort: null, assistantBuffer: '', + steps: [], finalSent: false, queue: Promise.resolve(), }; @@ -593,6 +754,7 @@ export async function startChatd(opts = {}) { } if (evt.event === 'run.error') { + trackRunStep(active, evt); failRun(active, evt.payload?.error || 'Run failed'); return; } @@ -601,6 +763,7 @@ export async function startChatd(opts = {}) { return; } + trackRunStep(active, evt); broadcast(buildEvent({ event: evt.event, runId, sessionId, payload: evt.payload })); }); }, diff --git a/agent/src/session-store.js b/agent/src/session-store.js index 8713a93..c061015 100644 --- a/agent/src/session-store.js +++ b/agent/src/session-store.js @@ -6,6 +6,7 @@ import { randomUUID } from 'node:crypto'; const DEFAULT_STORAGE_ROOT = join(homedir(), '.browserforce', 'agent', 'sessions'); const INDEX_FILE = 'index.json'; const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; +const RUN_ID_RE = /^[A-Za-z0-9_-]{1,256}$/; const MODEL_ID_RE = /^[A-Za-z0-9._:/-]{1,128}$/; const indexWriteQueues = new Map(); @@ -35,6 +36,37 @@ function assertValidSessionId(sessionId, fnName) { } } +function normalizeRunId(runId) { + if (runId == null) return null; + const normalized = String(runId).trim(); + if (!normalized) return null; + if (!RUN_ID_RE.test(normalized)) { + throw new Error('appendMessage requires a safe runId'); + } + return normalized; +} + +function normalizeStep(step) { + if (!step || typeof step !== 'object') return null; + const label = String(step.label || '').trim(); + if (!label) return null; + const kind = String(step.kind || '').trim() || 'reasoning'; + const status = String(step.status || '').trim() || 'running'; + return { + kind, + status, + label: label.length > 160 ? `${label.slice(0, 157)}...` : label, + }; +} + +function normalizeSteps(steps) { + if (!Array.isArray(steps)) return []; + return steps + .map(normalizeStep) + .filter(Boolean) + .slice(-100); +} + async function ensureStorageRoot(storageRoot) { await fs.mkdir(storageRoot, { recursive: true }); } @@ -169,7 +201,7 @@ export async function updateSession({ sessionId, patch = {}, storageRoot } = {}) }); } -export async function appendMessage({ sessionId, role, text, storageRoot } = {}) { +export async function appendMessage({ sessionId, role, text, runId, steps, storageRoot } = {}) { assertValidSessionId(sessionId, 'appendMessage'); if (!role) throw new Error('appendMessage requires role'); if (typeof text !== 'string') throw new Error('appendMessage requires text'); @@ -185,6 +217,14 @@ export async function appendMessage({ sessionId, role, text, storageRoot } = {}) text, createdAt: now, }; + const safeRunId = normalizeRunId(runId); + if (safeRunId) { + entry.runId = safeRunId; + } + const normalizedSteps = normalizeSteps(steps); + if (normalizedSteps.length > 0) { + entry.steps = normalizedSteps; + } const logPath = messageLogPath(root, sessionId); await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8'); diff --git a/extension/agent-panel-state.js b/extension/agent-panel-state.js index eab2998..9b665a8 100644 --- a/extension/agent-panel-state.js +++ b/extension/agent-panel-state.js @@ -70,6 +70,51 @@ function stepLabelForToolEvent(evt) { return ''; } +function humanizeToken(value) { + const normalized = String(value || '') + .trim() + .replace(/[_./-]+/g, ' ') + .replace(/\s+/g, ' '); + if (!normalized) return ''; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function stepStatusForRunEvent(evt) { + const payload = evt?.payload || {}; + const type = String(payload.type || '').toLowerCase(); + if (/error|failed|aborted/.test(type)) return 'failed'; + if (/completed|final|done|finished|succeeded|success|end/.test(type)) return 'done'; + return 'running'; +} + +function stepKindForRunEvent(evt) { + const payload = evt?.payload || {}; + const itemType = String(payload?.item?.type || '').toLowerCase(); + const eventType = String(payload?.type || '').toLowerCase(); + if (/reason/.test(itemType) || /reason/.test(eventType)) return 'reasoning'; + return 'tool'; +} + +function stepLabelForRunEvent(evt) { + const payload = evt?.payload || {}; + const item = payload?.item && typeof payload.item === 'object' ? payload.item : {}; + return firstString([ + payload.title, + payload.message, + payload.text, + payload.status, + item.summary, + item.text, + item.message, + item.title, + item.name, + item.tool, + item.command, + item.type ? humanizeToken(item.type) : '', + payload.type ? humanizeToken(payload.type) : '', + ]) || 'Working...'; +} + function upsertRun(state, runId, patch) { return { ...state.runs, @@ -80,6 +125,37 @@ function upsertRun(state, runId, patch) { }; } +function normalizeStoredStep(step) { + if (!step || typeof step !== 'object') return null; + const label = trimStepLabel(step.label); + if (!label) return null; + return { + kind: step.kind || 'reasoning', + status: step.status || 'running', + label, + }; +} + +function hydrateRunsFromMessages(messages, sessionId, currentRuns) { + const hydrated = {}; + for (const message of messages) { + const runId = typeof message?.runId === 'string' ? message.runId.trim() : ''; + if (!runId) continue; + const steps = Array.isArray(message?.steps) + ? message.steps.map(normalizeStoredStep).filter(Boolean) + : []; + hydrated[runId] = { + ...(currentRuns?.[runId] || { runId, text: '', done: false, steps: [] }), + runId, + sessionId, + text: typeof message?.text === 'string' ? message.text : (currentRuns?.[runId]?.text || ''), + done: true, + steps: steps.length > 0 ? steps : (currentRuns?.[runId]?.steps || []), + }; + } + return hydrated; +} + export function reduceState(state = initialState, action = {}) { if (action.type === 'session.list.loaded') { const sessions = Array.isArray(action.sessions) ? action.sessions : []; @@ -102,11 +178,17 @@ export function reduceState(state = initialState, action = {}) { } if (action.type === 'messages.loaded') { + const messages = Array.isArray(action.messages) ? action.messages : []; + const hydratedRuns = hydrateRunsFromMessages(messages, action.sessionId, state.runs); return { ...state, messagesBySession: { ...state.messagesBySession, - [action.sessionId]: Array.isArray(action.messages) ? action.messages : [], + [action.sessionId]: messages, + }, + runs: { + ...state.runs, + ...hydratedRuns, }, }; } @@ -220,5 +302,20 @@ export function applyEvent(state = initialState, evt = {}) { }; } + if (evt.event === 'run.event') { + const run = state.runs[evt.runId] || { text: '', done: false, steps: [] }; + const status = stepStatusForRunEvent(evt); + const kind = stepKindForRunEvent(evt); + const label = stepLabelForRunEvent(evt); + return { + ...state, + runs: upsertRun(state, evt.runId, { + sessionId: evt.sessionId, + done: false, + steps: pushStep(run, { kind, status, label }), + }), + }; + } + return state; } diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index 7237f2a..9d07b96 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -211,6 +211,72 @@ test('POST /v1/runs uses injected run executor and persists assistant output', a } }); +test('POST /v1/runs persists run steps so reopened sessions can render them', async () => { + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + runExecutor: ({ runId, sessionId, onEvent, onExit }) => { + setTimeout(() => { + onEvent({ event: 'tool.started', runId, sessionId, payload: { tool: 'snapshot' } }); + }, 5); + setTimeout(() => { + onEvent({ + event: 'tool.delta', + runId, + sessionId, + payload: { type: 'reasoning', text: 'Inspecting active tab' }, + }); + }, 10); + setTimeout(() => { + onEvent({ event: 'tool.final', runId, sessionId, payload: { tool: 'snapshot' } }); + }, 15); + setTimeout(() => { + onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'done' } }); + }, 20); + setTimeout(() => onExit({ code: 0 }), 25); + return { abort() {} }; + }, + }); + + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Steps' }), + }).then((res) => res.json()); + + const runRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'hi' }), + }); + assert.equal(runRes.status, 202); + const runBody = await runRes.json(); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + const messagesBody = await fetch( + `${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}/messages`, + { headers: { authorization: `Bearer ${daemon.token}` } }, + ).then((res) => res.json()); + const assistant = (messagesBody.messages || []).at(-1); + + assert.equal(assistant?.role, 'assistant'); + assert.equal(assistant?.runId, runBody.runId); + assert.equal(Array.isArray(assistant?.steps), true); + assert.equal(assistant.steps.length >= 1, true); + assert.equal(assistant.steps.some((step) => /Inspecting active tab/.test(step?.label || '')), true); + } finally { + await daemon.stop(); + } +}); + test('runExecutor synchronous failure does not leak abortable run', async () => { const storageRoot = mkdtempSync(join(tmpdir(), 'bf-chatd-run-fail-')); let attemptedRunId = null; diff --git a/test/agent/session-store.test.js b/test/agent/session-store.test.js index 1816e8d..f2cae2b 100644 --- a/test/agent/session-store.test.js +++ b/test/agent/session-store.test.js @@ -34,6 +34,22 @@ test('messages are stored and loaded by sessionId', async () => { assert.equal(rows.at(-1).text, 'hello'); }); +test('messages preserve optional run metadata used for transcript rehydration', async () => { + const { sessionId } = await createSession({ title: 'Runs', storageRoot }); + await appendMessage({ + sessionId, + role: 'assistant', + text: 'done', + runId: 'run_123', + steps: [{ kind: 'tool', status: 'done', label: 'Snapshot page' }], + storageRoot, + }); + const rows = await readMessages({ sessionId, limit: 20, storageRoot }); + const last = rows.at(-1); + assert.equal(last.runId, 'run_123'); + assert.deepEqual(last.steps, [{ kind: 'tool', status: 'done', label: 'Snapshot page' }]); +}); + test('rejects unsafe session ids', async () => { await assert.rejects( appendMessage({ sessionId: '../escape', role: 'user', text: 'x', storageRoot }), diff --git a/test/agent/session-ui-state.test.js b/test/agent/session-ui-state.test.js index d0ebd3f..e7701f5 100644 --- a/test/agent/session-ui-state.test.js +++ b/test/agent/session-ui-state.test.js @@ -34,3 +34,28 @@ test('messages.loaded hydrates transcript for the selected session', () => { assert.equal(next.messagesBySession.s1[0].text, 'hello'); }); + +test('messages.loaded hydrates stored run metadata for reopened sessions', () => { + const state = { + activeSessionId: 's1', + sessions: [], + runs: {}, + messagesBySession: {}, + }; + + const next = reduceState(state, { + type: 'messages.loaded', + sessionId: 's1', + messages: [{ + role: 'assistant', + text: 'Done', + runId: 'run_1', + steps: [{ kind: 'tool', status: 'done', label: 'Snapshot page' }], + }], + }); + + assert.equal(next.runs.run_1?.done, true); + assert.equal(next.runs.run_1?.sessionId, 's1'); + assert.equal(next.runs.run_1?.steps?.length, 1); + assert.equal(next.runs.run_1?.steps?.[0]?.label, 'Snapshot page'); +}); From 4a27ec04f6cdf47fd9fe10af1be92801a4c0d4e8 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:06:36 +0530 Subject: [PATCH 125/192] test(agent): add codex exec/resume fixtures and contracts --- scripts/capture-codex-jsonl.sh | 41 ++++++++++++++++ test/fixtures/codex/events/README.md | 49 +++++++++++++++++++ test/fixtures/codex/events/exec-sample.jsonl | 5 ++ .../codex/events/failed-resume-exit-code.txt | 1 + .../codex/events/failed-resume-sample.jsonl | 5 ++ .../codex/events/failed-resume-stderr.txt | 1 + .../fixtures/codex/events/resume-sample.jsonl | 4 ++ 7 files changed, 106 insertions(+) create mode 100755 scripts/capture-codex-jsonl.sh create mode 100644 test/fixtures/codex/events/README.md create mode 100644 test/fixtures/codex/events/exec-sample.jsonl create mode 100644 test/fixtures/codex/events/failed-resume-exit-code.txt create mode 100644 test/fixtures/codex/events/failed-resume-sample.jsonl create mode 100644 test/fixtures/codex/events/failed-resume-stderr.txt create mode 100644 test/fixtures/codex/events/resume-sample.jsonl diff --git a/scripts/capture-codex-jsonl.sh b/scripts/capture-codex-jsonl.sh new file mode 100755 index 0000000..fb77e7c --- /dev/null +++ b/scripts/capture-codex-jsonl.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUT_DIR="${1:-test/fixtures/codex/events}" +PROMPT="${2:-Reply with one short sentence and stop.}" + +mkdir -p "$OUT_DIR" + +codex exec --json "$PROMPT" > "$OUT_DIR/exec-sample.jsonl" + +SESSION_ID="$(node - "$OUT_DIR/exec-sample.jsonl" <<'NODE' +const fs = require('fs'); +const file = process.argv[2]; +const lines = fs.readFileSync(file, 'utf8').split(/\n+/).filter(Boolean); +for (const line of lines) { + let parsed; + try { + parsed = JSON.parse(line); + } catch { + continue; + } + if (parsed && parsed.type === 'thread.started' && typeof parsed.thread_id === 'string' && parsed.thread_id.trim()) { + process.stdout.write(parsed.thread_id.trim()); + process.exit(0); + } +} +process.exit(1); +NODE +)" + +codex exec resume "$SESSION_ID" --json "$PROMPT" > "$OUT_DIR/resume-sample.jsonl" + +INVALID_SESSION_ID="00000000-0000-0000-0000-000000000000" +set +e +codex exec resume "$INVALID_SESSION_ID" --json "$PROMPT" > "$OUT_DIR/failed-resume-sample.jsonl" 2> "$OUT_DIR/failed-resume-stderr.txt" +EXIT_CODE=$? +set -e + +printf '%s\n' "$EXIT_CODE" > "$OUT_DIR/failed-resume-exit-code.txt" + +echo "Captured fixtures in $OUT_DIR" diff --git a/test/fixtures/codex/events/README.md b/test/fixtures/codex/events/README.md new file mode 100644 index 0000000..6f52147 --- /dev/null +++ b/test/fixtures/codex/events/README.md @@ -0,0 +1,49 @@ +# Codex JSONL Fixture Contracts + +Captured with `scripts/capture-codex-jsonl.sh` on 2026-03-03 using `codex-cli 0.106.0`. + +## Provider Session ID Extraction + +Use only this path: + +- Event: `type === "thread.started"` +- Field: `thread_id` + +Normalized contract: + +- `run.provider_session.payload.provider = "codex"` +- `run.provider_session.payload.sessionId = ` + +## Usage Telemetry Extraction + +Use only this path in current fixtures: + +- Event: `type === "turn.completed"` +- Field object: `usage` +- Fields: `usage.input_tokens`, `usage.cached_input_tokens`, `usage.output_tokens` + +Normalization contract: + +- `inputTokens = usage.input_tokens` +- `cachedInputTokens = usage.cached_input_tokens` +- `outputTokens = usage.output_tokens` +- `totalTokens = inputTokens + outputTokens` +- `modelContextWindow = null` (not emitted in these fixtures) + +## Failed Resume Signature + +Fixture command: + +- `codex exec resume "00000000-0000-0000-0000-000000000000" --json "..."` + +Observed behavior in this environment: + +- Exit code is zero (`failed-resume-exit-code.txt` contains `0`) +- No failure JSON event is emitted +- A normal `thread.started` event appears, but with a **new** `thread_id` (fresh session) +- `stderr` may contain shell snapshot warnings; this text is non-deterministic and is **not** used as a retry signature + +Implication for retry logic: + +- Invalid/stale resume IDs are currently soft-fallbacked by Codex itself to a fresh thread +- `isResumeSessionInvalidFailure(...)` should only trigger on explicit hard-failure signatures (none observed in these fixtures) diff --git a/test/fixtures/codex/events/exec-sample.jsonl b/test/fixtures/codex/events/exec-sample.jsonl new file mode 100644 index 0000000..ef58c7a --- /dev/null +++ b/test/fixtures/codex/events/exec-sample.jsonl @@ -0,0 +1,5 @@ +{"type":"thread.started","thread_id":"019cb36d-0554-7a91-aee0-17a840d36372"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Confirming single-sentence response**"}} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Understood, I will stop after this sentence."}} +{"type":"turn.completed","usage":{"input_tokens":15166,"cached_input_tokens":3456,"output_tokens":289}} diff --git a/test/fixtures/codex/events/failed-resume-exit-code.txt b/test/fixtures/codex/events/failed-resume-exit-code.txt new file mode 100644 index 0000000..573541a --- /dev/null +++ b/test/fixtures/codex/events/failed-resume-exit-code.txt @@ -0,0 +1 @@ +0 diff --git a/test/fixtures/codex/events/failed-resume-sample.jsonl b/test/fixtures/codex/events/failed-resume-sample.jsonl new file mode 100644 index 0000000..de9989c --- /dev/null +++ b/test/fixtures/codex/events/failed-resume-sample.jsonl @@ -0,0 +1,5 @@ +{"type":"thread.started","thread_id":"019cb36d-3da8-75c1-a959-d6db6519091d"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"reasoning","text":"**Confirming response approach**"}} +{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"This is the only sentence in my reply."}} +{"type":"turn.completed","usage":{"input_tokens":15166,"cached_input_tokens":15104,"output_tokens":201}} diff --git a/test/fixtures/codex/events/failed-resume-stderr.txt b/test/fixtures/codex/events/failed-resume-stderr.txt new file mode 100644 index 0000000..9cc5f58 --- /dev/null +++ b/test/fixtures/codex/events/failed-resume-stderr.txt @@ -0,0 +1 @@ +2026-03-03T11:20:07.699206Z WARN codex_core::shell_snapshot: Failed to delete shell snapshot at "/Users/valsaraj/.codex/shell_snapshots/019cb36d-3da8-75c1-a959-d6db6519091d.tmp-1772536806825338000": Os { code: 2, kind: NotFound, message: "No such file or directory" } diff --git a/test/fixtures/codex/events/resume-sample.jsonl b/test/fixtures/codex/events/resume-sample.jsonl new file mode 100644 index 0000000..23f78e3 --- /dev/null +++ b/test/fixtures/codex/events/resume-sample.jsonl @@ -0,0 +1,4 @@ +{"type":"thread.started","thread_id":"019cb36d-0554-7a91-aee0-17a840d36372"} +{"type":"turn.started"} +{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"I will stop after this short sentence."}} +{"type":"turn.completed","usage":{"input_tokens":30635,"cached_input_tokens":18816,"output_tokens":301}} From d32285371e91989a7551c53cd8a5c0cb4ed78794 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:06:41 +0530 Subject: [PATCH 126/192] feat(agent): persist codex provider session and usage metadata --- agent/src/session-store.js | 86 ++++++++++++++++++++++++++++++++ test/agent/session-store.test.js | 28 +++++++++++ 2 files changed, 114 insertions(+) diff --git a/agent/src/session-store.js b/agent/src/session-store.js index c061015..6ff7759 100644 --- a/agent/src/session-store.js +++ b/agent/src/session-store.js @@ -10,6 +10,10 @@ const RUN_ID_RE = /^[A-Za-z0-9_-]{1,256}$/; const MODEL_ID_RE = /^[A-Za-z0-9._:/-]{1,128}$/; const indexWriteQueues = new Map(); +function isObject(value) { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + function resolveStorageRoot(storageRoot) { return storageRoot || DEFAULT_STORAGE_ROOT; } @@ -124,6 +128,83 @@ function normalizeModel(model) { return trimmed; } +function normalizeUsageNumber(value, fieldName) { + if (value == null) return null; + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + throw new Error(`providerState.codex.latestUsage.${fieldName} must be a non-negative number`); + } + return Math.round(parsed); +} + +function normalizeLatestUsage(latestUsage) { + if (latestUsage == null) return null; + if (!isObject(latestUsage)) { + throw new Error('providerState.codex.latestUsage must be an object'); + } + + const fields = [ + 'modelContextWindow', + 'totalTokens', + 'inputTokens', + 'cachedInputTokens', + 'outputTokens', + 'reasoningOutputTokens', + ]; + + const normalized = {}; + for (const field of fields) { + if (!Object.prototype.hasOwnProperty.call(latestUsage, field)) continue; + const value = normalizeUsageNumber(latestUsage[field], field); + if (value != null) normalized[field] = value; + } + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function normalizeCodexProviderState(patchCodex, currentCodex) { + if (patchCodex == null) return null; + if (!isObject(patchCodex)) { + throw new Error('providerState.codex must be an object'); + } + + const normalized = isObject(currentCodex) ? { ...currentCodex } : {}; + + if (Object.prototype.hasOwnProperty.call(patchCodex, 'sessionId')) { + if (patchCodex.sessionId == null || String(patchCodex.sessionId).trim() === '') { + delete normalized.sessionId; + } else { + const sessionId = String(patchCodex.sessionId).trim(); + if (!isValidSessionId(sessionId)) { + throw new Error('providerState.codex.sessionId must be a safe session id'); + } + normalized.sessionId = sessionId; + } + } + + if (Object.prototype.hasOwnProperty.call(patchCodex, 'latestUsage')) { + const latestUsage = normalizeLatestUsage(patchCodex.latestUsage); + if (latestUsage == null) delete normalized.latestUsage; + else normalized.latestUsage = latestUsage; + } + + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function normalizeProviderState(providerStatePatch, currentProviderState) { + if (!isObject(providerStatePatch)) { + throw new Error('providerState must be an object'); + } + const normalized = isObject(currentProviderState) ? { ...currentProviderState } : {}; + + if (Object.prototype.hasOwnProperty.call(providerStatePatch, 'codex')) { + const codex = normalizeCodexProviderState(providerStatePatch.codex, normalized.codex); + if (codex == null) delete normalized.codex; + else normalized.codex = codex; + } + + return Object.keys(normalized).length > 0 ? normalized : null; +} + function sortSessionsNewestFirst(a, b) { const aTs = Date.parse(a.updatedAt || a.createdAt || 0); const bTs = Date.parse(b.updatedAt || b.createdAt || 0); @@ -194,6 +275,11 @@ export async function updateSession({ sessionId, patch = {}, storageRoot } = {}) if (Object.prototype.hasOwnProperty.call(patch, 'model')) { next.model = normalizeModel(patch.model); } + if (Object.prototype.hasOwnProperty.call(patch, 'providerState')) { + const providerState = normalizeProviderState(patch.providerState, current.providerState); + if (providerState == null) delete next.providerState; + else next.providerState = providerState; + } next.updatedAt = now; sessions[idx] = next; await writeIndex(root, sessions); diff --git a/test/agent/session-store.test.js b/test/agent/session-store.test.js index f2cae2b..a134e65 100644 --- a/test/agent/session-store.test.js +++ b/test/agent/session-store.test.js @@ -86,6 +86,34 @@ test('updateSession persists per-session model and title', async () => { assert.equal(row?.model, 'gpt-5'); }); +test('updateSession persists codex provider session mapping', async () => { + const created = await createSession({ title: 'Continuity', storageRoot }); + const updated = await updateSession({ + sessionId: created.sessionId, + patch: { + providerState: { + codex: { + sessionId: '019caa6f-8c63-7c81-a542-3dbcf922d065', + latestUsage: { + modelContextWindow: 258400, + totalTokens: 128125, + cachedInputTokens: 126592, + }, + }, + }, + }, + storageRoot, + }); + + assert.equal(updated?.providerState?.codex?.sessionId, '019caa6f-8c63-7c81-a542-3dbcf922d065'); + assert.equal(updated?.providerState?.codex?.latestUsage?.modelContextWindow, 258400); + + const rows = await listSessions({ limit: 10, storageRoot }); + const row = rows.find((item) => item.sessionId === created.sessionId); + assert.equal(row?.providerState?.codex?.sessionId, '019caa6f-8c63-7c81-a542-3dbcf922d065'); + assert.equal(row?.providerState?.codex?.latestUsage?.totalTokens, 128125); +}); + test('listSessions fails fast on corrupted index metadata', async () => { writeFileSync(join(storageRoot, 'index.json'), '{this-is-not-json\n', 'utf8'); await assert.rejects( From b45be4338a28d31df5b9cac4421b1ca3e15236ad Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:06:43 +0530 Subject: [PATCH 127/192] feat(agent): support codex resume args and usage event normalization --- agent/src/codex-runner.js | 81 +++++++++++++++++++++++++++++++-- test/agent/codex-runner.test.js | 62 +++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) diff --git a/agent/src/codex-runner.js b/agent/src/codex-runner.js index 073f550..776f039 100644 --- a/agent/src/codex-runner.js +++ b/agent/src/codex-runner.js @@ -20,6 +20,34 @@ function safeParse(line) { } } +function toCount(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.round(parsed); +} + +function toUsagePayload(source = {}) { + const inputTokens = toCount(source.input_tokens ?? source.inputTokens); + const cachedInputTokens = toCount(source.cached_input_tokens ?? source.cachedInputTokens); + const outputTokens = toCount(source.output_tokens ?? source.outputTokens); + const reasoningOutputTokens = toCount(source.reasoning_output_tokens ?? source.reasoningOutputTokens); + const explicitTotalTokens = toCount(source.total_tokens ?? source.totalTokens); + const modelContextWindow = toCount(source.model_context_window ?? source.modelContextWindow); + + const totalTokens = explicitTotalTokens != null + ? explicitTotalTokens + : ((inputTokens != null || outputTokens != null) ? (inputTokens || 0) + (outputTokens || 0) : null); + + return { + modelContextWindow, + totalTokens, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + }; +} + export function normalizeCodexLine({ runId, sessionId, line }) { const parsed = safeParse(line); if (!parsed || typeof parsed !== 'object') { @@ -28,6 +56,43 @@ export function normalizeCodexLine({ runId, sessionId, line }) { const type = String(parsed.type || '').toLowerCase(); + if (type === 'thread.started') { + const providerSessionId = String(parsed.thread_id || '').trim(); + if (providerSessionId) { + return envelope({ + event: 'run.provider_session', + runId, + sessionId, + payload: { provider: 'codex', sessionId: providerSessionId }, + }); + } + } + + if (type === 'turn.completed' && parsed.usage && typeof parsed.usage === 'object') { + return envelope({ + event: 'run.usage', + runId, + sessionId, + payload: toUsagePayload(parsed.usage), + }); + } + + if (type === 'token_count' && parsed.info && typeof parsed.info === 'object') { + const usage = parsed.info.total_token_usage && typeof parsed.info.total_token_usage === 'object' + ? parsed.info.total_token_usage + : {}; + return envelope({ + event: 'run.usage', + runId, + sessionId, + payload: toUsagePayload({ + ...usage, + model_context_window: parsed.info.model_context_window, + reasoning_output_tokens: parsed.info.reasoning_output_tokens, + }), + }); + } + if (type === 'delta' || type === 'text_delta') { return envelope({ event: 'chat.delta', runId, sessionId, payload: { delta: String(parsed.text || '') } }); } @@ -92,9 +157,12 @@ export function normalizeCodexLine({ runId, sessionId, line }) { return envelope({ event: 'run.event', runId, sessionId, payload: parsed }); } -export function buildCodexExecArgs({ prompt, model, args } = {}) { +export function buildCodexExecArgs({ prompt, model, args, resumeSessionId } = {}) { if (Array.isArray(args) && args.length > 0) return args; - const resolved = ['exec', '--json']; + const resumeId = typeof resumeSessionId === 'string' ? resumeSessionId.trim() : ''; + const resolved = resumeId + ? ['exec', 'resume', resumeId, '--json'] + : ['exec', '--json']; if (typeof model === 'string' && model.trim()) { resolved.push('--model', model.trim()); } @@ -113,9 +181,10 @@ export function startCodexRun({ command, args, model, + resumeSessionId, } = {}) { const cmd = command || process.env.BF_CHATD_CODEX_COMMAND || 'codex'; - const argv = buildCodexExecArgs({ prompt, model, args }); + const argv = buildCodexExecArgs({ prompt, model, args, resumeSessionId }); const child = spawn(cmd, argv, { cwd, @@ -123,6 +192,8 @@ export function startCodexRun({ stdio: ['ignore', 'pipe', 'pipe'], }); + const stderrChunks = []; + const stdoutLines = readline.createInterface({ input: child.stdout }); stdoutLines.on('line', (line) => { try { @@ -136,6 +207,8 @@ export function startCodexRun({ const stderrLines = readline.createInterface({ input: child.stderr }); stderrLines.on('line', (line) => { if (!line) return; + stderrChunks.push(String(line)); + if (stderrChunks.length > 200) stderrChunks.shift(); onEvent?.(envelope({ event: 'tool.delta', runId, @@ -149,7 +222,7 @@ export function startCodexRun({ }); child.on('close', (code, signal) => { - onExit?.({ code, signal }); + onExit?.({ code, signal, stderr: stderrChunks.join('\n') }); }); return { diff --git a/test/agent/codex-runner.test.js b/test/agent/codex-runner.test.js index 6319aa9..b6f2205 100644 --- a/test/agent/codex-runner.test.js +++ b/test/agent/codex-runner.test.js @@ -39,6 +39,23 @@ test('buildCodexExecArgs includes --model when session model is set', () => { assert.deepEqual(args, ['exec', '--json', '--model', 'gpt-5', 'hi']); }); +test('buildCodexExecArgs emits resume invocation when codex session id is provided', () => { + const args = buildCodexExecArgs({ + prompt: 'hi', + model: 'gpt-5', + resumeSessionId: '019caa6f-8c63-7c81-a542-3dbcf922d065', + }); + assert.deepEqual(args, [ + 'exec', + 'resume', + '019caa6f-8c63-7c81-a542-3dbcf922d065', + '--json', + '--model', + 'gpt-5', + 'hi', + ]); +}); + test('buildCodexExecArgs omits --model when model is empty', () => { const args = buildCodexExecArgs({ prompt: 'hi', model: '' }); assert.deepEqual(args, ['exec', '--json', 'hi']); @@ -55,3 +72,48 @@ test('maps transient codex error line to non-fatal tool event', () => { assert.equal(evt.payload.level, 'warning'); assert.match(evt.payload.message, /Reconnecting/); }); + +test('maps codex turn.completed usage into run.usage event', () => { + const line = JSON.stringify({ + type: 'turn.completed', + usage: { + input_tokens: 1000, + cached_input_tokens: 700, + output_tokens: 120, + }, + }); + const evt = normalizeCodexLine({ runId: 'r1', sessionId: 's1', line }); + assert.equal(evt.event, 'run.usage'); + assert.equal(evt.payload.modelContextWindow, null); + assert.equal(evt.payload.totalTokens, 1120); + assert.equal(evt.payload.inputTokens, 1000); + assert.equal(evt.payload.cachedInputTokens, 700); + assert.equal(evt.payload.outputTokens, 120); +}); + +test('maps codex token_count into run.usage event', () => { + const line = JSON.stringify({ + type: 'token_count', + info: { + total_token_usage: { input_tokens: 1000, cached_input_tokens: 700, output_tokens: 120, total_tokens: 1120 }, + model_context_window: 258400, + reasoning_output_tokens: 14, + }, + }); + const evt = normalizeCodexLine({ runId: 'r1', sessionId: 's1', line }); + assert.equal(evt.event, 'run.usage'); + assert.equal(evt.payload.modelContextWindow, 258400); + assert.equal(evt.payload.totalTokens, 1120); + assert.equal(evt.payload.reasoningOutputTokens, 14); +}); + +test('maps codex thread.started provider session id event to run.provider_session', () => { + const line = JSON.stringify({ + type: 'thread.started', + thread_id: '019caa6f-8c63-7c81-a542-3dbcf922d065', + }); + const evt = normalizeCodexLine({ runId: 'r1', sessionId: 's1', line }); + assert.equal(evt.event, 'run.provider_session'); + assert.equal(evt.payload.provider, 'codex'); + assert.equal(evt.payload.sessionId, '019caa6f-8c63-7c81-a542-3dbcf922d065'); +}); From 4d6f5f046cdbe63f72e891197a47b382db156772 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:06:49 +0530 Subject: [PATCH 128/192] feat(agent): resume codex sessions with usage persistence and metadata endpoint --- agent/src/chatd.js | 123 +++++++++++++- test/agent/chatd-api.test.js | 303 +++++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+), 5 deletions(-) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index 80a3838..480138b 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -248,6 +248,45 @@ function normalizeBrowserContext(raw) { return { tabId, title, url }; } +function normalizeUsageNumber(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.round(parsed); +} + +function normalizeUsagePayload(payload) { + if (!payload || typeof payload !== 'object') return null; + const modelContextWindow = normalizeUsageNumber(payload.modelContextWindow); + const totalTokens = normalizeUsageNumber(payload.totalTokens); + const inputTokens = normalizeUsageNumber(payload.inputTokens); + const cachedInputTokens = normalizeUsageNumber(payload.cachedInputTokens); + const outputTokens = normalizeUsageNumber(payload.outputTokens); + const reasoningOutputTokens = normalizeUsageNumber(payload.reasoningOutputTokens); + + const normalized = { + modelContextWindow, + totalTokens, + inputTokens, + cachedInputTokens, + outputTokens, + reasoningOutputTokens, + }; + + for (const [key, value] of Object.entries(normalized)) { + if (value == null) delete normalized[key]; + } + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function isResumeSessionInvalidFailure({ code, error, stderr } = {}) { + if (!Number.isInteger(code) || code === 0) return false; + const text = `${String(error || '')}\n${String(stderr || '')}`.toLowerCase(); + return ( + /resume|session|thread/.test(text) + && /not found|unknown|invalid|no such|does not exist/.test(text) + ); +} + function firstString(values) { for (const value of values) { if (typeof value === 'string' && value.trim()) return value.trim(); @@ -454,11 +493,12 @@ async function clearChatdUrlFile({ writeChatdUrl = true, urlPath = CHATD_URL_PAT } function createDefaultRunExecutor({ codexCwd } = {}) { - return ({ runId, sessionId, message, model, onEvent, onExit, onError }) => startCodexRun({ + return ({ runId, sessionId, message, model, resumeSessionId, onEvent, onExit, onError }) => startCodexRun({ runId, sessionId, prompt: message, model, + resumeSessionId, cwd: codexCwd, onEvent, onExit, @@ -596,6 +636,21 @@ export async function startChatd(opts = {}) { } const sessionMatch = url.pathname.match(/^\/v1\/sessions\/([^/]+)$/); + if (sessionMatch && req.method === 'GET') { + const decodedSessionId = safeDecodeComponent(sessionMatch[1]); + if (!decodedSessionId || !isValidSessionId(decodedSessionId)) { + json(res, 400, { error: 'Invalid sessionId' }); + return; + } + const session = await getSession({ sessionId: decodedSessionId, storageRoot }); + if (!session) { + json(res, 404, { error: 'Session not found' }); + return; + } + json(res, 200, session); + return; + } + if (sessionMatch && req.method === 'PATCH') { const decodedSessionId = safeDecodeComponent(sessionMatch[1]); if (!decodedSessionId || !isValidSessionId(decodedSessionId)) { @@ -718,6 +773,11 @@ export async function startChatd(opts = {}) { steps: [], finalSent: false, queue: Promise.resolve(), + lastError: null, + resumeRetryAttempted: false, + resumeSessionId: isValidSessionId(session?.providerState?.codex?.sessionId || '') + ? session.providerState.codex.sessionId + : null, }; const enqueue = (fn) => { @@ -728,11 +788,12 @@ export async function startChatd(opts = {}) { await appendMessage({ sessionId, role: 'user', text: message, storageRoot }); runs.set(runId, run); - const handle = runExecutor({ + const startAttempt = (resumeSessionId) => runExecutor({ runId, sessionId, message: promptMessage, model: session.model || null, + resumeSessionId, onEvent: (evt) => { enqueue(async () => { const active = runs.get(runId); @@ -753,9 +814,43 @@ export async function startChatd(opts = {}) { return; } + if (evt.event === 'run.provider_session') { + const provider = String(evt.payload?.provider || '').trim().toLowerCase(); + const providerSessionId = String(evt.payload?.sessionId || '').trim(); + if (provider === 'codex' && isValidSessionId(providerSessionId)) { + await updateSession({ + sessionId, + patch: { + providerState: { codex: { sessionId: providerSessionId } }, + }, + storageRoot, + }); + } + broadcast(buildEvent({ event: 'run.provider_session', runId, sessionId, payload: evt.payload })); + return; + } + + if (evt.event === 'run.usage') { + const usage = normalizeUsagePayload(evt.payload); + if (usage) { + await updateSession({ + sessionId, + patch: { + providerState: { codex: { latestUsage: usage } }, + }, + storageRoot, + }); + broadcast(buildEvent({ event: 'run.usage', runId, sessionId, payload: usage })); + } + return; + } + if (evt.event === 'run.error') { trackRunStep(active, evt); - failRun(active, evt.payload?.error || 'Run failed'); + active.lastError = evt.payload?.error || 'Run failed'; + if (!active.resumeSessionId || active.resumeRetryAttempted) { + failRun(active, active.lastError); + } return; } @@ -767,13 +862,30 @@ export async function startChatd(opts = {}) { broadcast(buildEvent({ event: evt.event, runId, sessionId, payload: evt.payload })); }); }, - onExit: ({ code, signal }) => { + onExit: ({ code, signal, stderr }) => { enqueue(async () => { const active = runs.get(runId); if (!active || active.status !== 'running') return; if (signal === 'SIGTERM' || active.status === 'aborted') return; + if ( + active.resumeSessionId + && !active.resumeRetryAttempted + && isResumeSessionInvalidFailure({ code, error: active.lastError, stderr }) + ) { + active.resumeRetryAttempted = true; + active.resumeSessionId = null; + active.lastError = null; + try { + const retryHandle = startAttempt(null); + active.abort = retryHandle?.abort || null; + } catch (error) { + failRun(active, error?.message || 'Failed to retry codex run'); + } + return; + } + if (active.assistantBuffer) { await finalizeRun(active, active.assistantBuffer); return; @@ -784,7 +896,7 @@ export async function startChatd(opts = {}) { return; } - failRun(active, `codex exited with code ${code ?? 'unknown'}`); + failRun(active, active.lastError || `codex exited with code ${code ?? 'unknown'}`); }); }, onError: (error) => { @@ -795,6 +907,7 @@ export async function startChatd(opts = {}) { }, }); + const handle = startAttempt(run.resumeSessionId); run.abort = handle?.abort || null; broadcast(buildEvent({ event: 'run.started', diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index 9d07b96..b0804dc 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -371,3 +371,306 @@ test('POST /v1/runs includes active tab context in runExecutor prompt', async () await daemon.stop(); } }); + +test('POST /v1/runs reuses codex provider session id on second turn', async () => { + const observed = []; + const providerSessionId = '019caa6f-8c63-7c81-a542-3dbcf922d065'; + + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + runExecutor: ({ runId, sessionId, resumeSessionId, onEvent, onExit }) => { + observed.push({ runId, sessionId, resumeSessionId: resumeSessionId || null }); + setTimeout(() => { + onEvent({ + event: 'run.provider_session', + runId, + sessionId, + payload: { provider: 'codex', sessionId: providerSessionId }, + }); + }, 5); + setTimeout(() => { + onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'ok' } }); + }, 10); + setTimeout(() => onExit({ code: 0 }), 15); + return { abort() {} }; + }, + }); + + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Continuity' }), + }).then((res) => res.json()); + + const runOneRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'first' }), + }); + assert.equal(runOneRes.status, 202); + await new Promise((resolve) => setTimeout(resolve, 60)); + + const runTwoRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'second' }), + }); + assert.equal(runTwoRes.status, 202); + await new Promise((resolve) => setTimeout(resolve, 60)); + + assert.equal(observed.length >= 2, true); + assert.equal(observed[0].resumeSessionId, null); + assert.equal(observed[1].resumeSessionId, providerSessionId); + } finally { + await daemon.stop(); + } +}); + +test('stale resume failures retry once as fresh run when failure signature matches', async () => { + const observed = []; + const staleProviderSessionId = '019caa6f-8c63-7c81-a542-3dbcf922d065'; + const recoveredProviderSessionId = '019caa6f-8c63-7c81-a542-3dbcf922d999'; + + let callCount = 0; + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + runExecutor: ({ runId, sessionId, resumeSessionId, onEvent, onExit }) => { + callCount += 1; + observed.push({ callCount, runId, sessionId, resumeSessionId: resumeSessionId || null }); + + if (callCount === 1) { + setTimeout(() => { + onEvent({ + event: 'run.provider_session', + runId, + sessionId, + payload: { provider: 'codex', sessionId: staleProviderSessionId }, + }); + }, 5); + setTimeout(() => onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'seeded' } }), 10); + setTimeout(() => onExit({ code: 0 }), 15); + return { abort() {} }; + } + + if (callCount === 2) { + setTimeout(() => onEvent({ event: 'run.error', runId, sessionId, payload: { error: 'Resume session not found' } }), 5); + setTimeout(() => onExit({ code: 1, stderr: 'session not found' }), 10); + return { abort() {} }; + } + + setTimeout(() => { + onEvent({ + event: 'run.provider_session', + runId, + sessionId, + payload: { provider: 'codex', sessionId: recoveredProviderSessionId }, + }); + }, 5); + setTimeout(() => onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'recovered' } }), 10); + setTimeout(() => onExit({ code: 0 }), 15); + return { abort() {} }; + }, + }); + + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Retry' }), + }).then((res) => res.json()); + + const seedRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'seed' }), + }); + assert.equal(seedRes.status, 202); + await new Promise((resolve) => setTimeout(resolve, 70)); + + const retryRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'retry' }), + }); + assert.equal(retryRes.status, 202); + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.equal(observed.length, 3); + assert.equal(observed[1].resumeSessionId, staleProviderSessionId); + assert.equal(observed[2].resumeSessionId, null); + + const sessionRes = await fetch(`${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}`, { + headers: { authorization: `Bearer ${daemon.token}` }, + }); + assert.equal(sessionRes.status, 200); + const sessionBody = await sessionRes.json(); + assert.equal(sessionBody.providerState?.codex?.sessionId, recoveredProviderSessionId); + } finally { + await daemon.stop(); + } +}); + +test('non-resume failures do not clear codex provider session mapping', async () => { + const observed = []; + const providerSessionId = '019caa6f-8c63-7c81-a542-3dbcf922d065'; + let callCount = 0; + + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + runExecutor: ({ runId, sessionId, resumeSessionId, onEvent, onExit }) => { + callCount += 1; + observed.push({ callCount, runId, sessionId, resumeSessionId: resumeSessionId || null }); + + if (callCount === 1) { + setTimeout(() => { + onEvent({ + event: 'run.provider_session', + runId, + sessionId, + payload: { provider: 'codex', sessionId: providerSessionId }, + }); + }, 5); + setTimeout(() => onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'seeded' } }), 10); + setTimeout(() => onExit({ code: 0 }), 15); + return { abort() {} }; + } + + setTimeout(() => onEvent({ event: 'run.error', runId, sessionId, payload: { error: 'tool crashed' } }), 5); + setTimeout(() => onExit({ code: 1, stderr: 'tool crashed' }), 10); + return { abort() {} }; + }, + }); + + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Preserve mapping' }), + }).then((res) => res.json()); + + await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'seed' }), + }); + await new Promise((resolve) => setTimeout(resolve, 70)); + + const failed = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'fail' }), + }); + assert.equal(failed.status, 202); + await new Promise((resolve) => setTimeout(resolve, 80)); + + assert.equal(observed.length, 2); + assert.equal(observed[1].resumeSessionId, providerSessionId); + + const sessionRes = await fetch(`${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}`, { + headers: { authorization: `Bearer ${daemon.token}` }, + }); + assert.equal(sessionRes.status, 200); + const sessionBody = await sessionRes.json(); + assert.equal(sessionBody.providerState?.codex?.sessionId, providerSessionId); + } finally { + await daemon.stop(); + } +}); + +test('GET /v1/sessions/:id exposes providerState metadata for side-panel hydration', async () => { + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + runExecutor: ({ runId, sessionId, onEvent, onExit }) => { + setTimeout(() => { + onEvent({ + event: 'run.provider_session', + runId, + sessionId, + payload: { provider: 'codex', sessionId: '019caa6f-8c63-7c81-a542-3dbcf922d065' }, + }); + }, 5); + setTimeout(() => { + onEvent({ + event: 'run.usage', + runId, + sessionId, + payload: { + modelContextWindow: 258400, + totalTokens: 1120, + inputTokens: 1000, + cachedInputTokens: 700, + outputTokens: 120, + }, + }); + }, 10); + setTimeout(() => onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'done' } }), 15); + setTimeout(() => onExit({ code: 0 }), 20); + return { abort() {} }; + }, + }); + + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Metadata' }), + }).then((res) => res.json()); + + const runRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'collect usage' }), + }); + assert.equal(runRes.status, 202); + await new Promise((resolve) => setTimeout(resolve, 90)); + + const sessionRes = await fetch(`${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}`, { + headers: { authorization: `Bearer ${daemon.token}` }, + }); + assert.equal(sessionRes.status, 200); + const sessionBody = await sessionRes.json(); + assert.equal(sessionBody.sessionId, created.sessionId); + assert.equal(sessionBody.providerState?.codex?.sessionId, '019caa6f-8c63-7c81-a542-3dbcf922d065'); + assert.equal(sessionBody.providerState?.codex?.latestUsage?.modelContextWindow, 258400); + } finally { + await daemon.stop(); + } +}); From 58d78436c528f3a373f7605b03572469a22f6eec Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:06:55 +0530 Subject: [PATCH 129/192] feat(sidepanel): render codex context usage telemetry with session hydration --- extension/agent-panel-runtime.js | 35 ++++++++++++ extension/agent-panel-state.js | 53 ++++++++++++++++++ extension/agent-panel.css | 56 ++++++++++++++++++++ extension/agent-panel.html | 2 + extension/agent-panel.js | 42 +++++++++++++-- test/agent/agent-panel-contract.test.js | 1 + test/agent/agent-panel-runtime.test.js | 31 +++++++++++ test/agent/agent-panel-send-contract.test.js | 13 +++++ test/agent/session-ui-state.test.js | 29 ++++++++++ test/agent/sse-events.test.js | 53 ++++++++++++++++++ 10 files changed, 311 insertions(+), 4 deletions(-) diff --git a/extension/agent-panel-runtime.js b/extension/agent-panel-runtime.js index 63fe634..0cb7eef 100644 --- a/extension/agent-panel-runtime.js +++ b/extension/agent-panel-runtime.js @@ -27,6 +27,27 @@ export function shouldApplySessionSelection({ requestToken, latestRequestToken, ); } +function escapeHtml(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +export function renderInlineContent(value) { + return escapeHtml(value) + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*([^*]+)\*\*/g, '$1'); +} + +export function getLatestInFlightStepIndex(run = {}) { + const steps = Array.isArray(run?.steps) ? run.steps : []; + if (!steps.length || run?.done) return -1; + return steps.length - 1; +} + export function classifyRunStepIcon(step = {}) { const status = String(step.status || '').toLowerCase(); if (status === 'failed') return 'failed'; @@ -43,3 +64,17 @@ export function classifyRunStepIcon(step = {}) { if (kind === 'tool') return 'tool'; return 'reasoning'; } + +function normalizeUsageValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) return null; + return Math.round(parsed); +} + +export function formatContextUsage({ totalTokens, modelContextWindow } = {}) { + const total = normalizeUsageValue(totalTokens); + const windowSize = normalizeUsageValue(modelContextWindow); + if (total == null || windowSize == null) return null; + const percent = ((total / windowSize) * 100).toFixed(1); + return `${total.toLocaleString()} / ${windowSize.toLocaleString()} (${percent}%)`; +} diff --git a/extension/agent-panel-state.js b/extension/agent-panel-state.js index 9b665a8..13a6671 100644 --- a/extension/agent-panel-state.js +++ b/extension/agent-panel-state.js @@ -3,6 +3,7 @@ export const initialState = { activeSessionId: null, messagesBySession: {}, runs: {}, + latestUsageBySession: {}, }; function firstString(values) { @@ -125,6 +126,28 @@ function upsertRun(state, runId, patch) { }; } +function normalizeUsageValue(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return null; + return Math.round(parsed); +} + +function normalizeUsagePayload(payload) { + if (!payload || typeof payload !== 'object') return null; + const normalized = { + modelContextWindow: normalizeUsageValue(payload.modelContextWindow), + totalTokens: normalizeUsageValue(payload.totalTokens), + inputTokens: normalizeUsageValue(payload.inputTokens), + cachedInputTokens: normalizeUsageValue(payload.cachedInputTokens), + outputTokens: normalizeUsageValue(payload.outputTokens), + reasoningOutputTokens: normalizeUsageValue(payload.reasoningOutputTokens), + }; + for (const [key, value] of Object.entries(normalized)) { + if (value == null) delete normalized[key]; + } + return Object.keys(normalized).length > 0 ? normalized : null; +} + function normalizeStoredStep(step) { if (!step || typeof step !== 'object') return null; const label = trimStepLabel(step.label); @@ -193,6 +216,18 @@ export function reduceState(state = initialState, action = {}) { }; } + if (action.type === 'session.metadata.loaded') { + const usage = normalizeUsagePayload(action.session?.providerState?.codex?.latestUsage); + if (!usage || !action.sessionId) return state; + return { + ...state, + latestUsageBySession: { + ...(state.latestUsageBySession || {}), + [action.sessionId]: usage, + }, + }; + } + return state; } @@ -317,5 +352,23 @@ export function applyEvent(state = initialState, evt = {}) { }; } + if (evt.event === 'run.usage') { + const usage = normalizeUsagePayload(evt.payload); + if (!usage) return state; + const run = state.runs[evt.runId] || { text: '', done: false, steps: [] }; + return { + ...state, + runs: upsertRun(state, evt.runId, { + sessionId: evt.sessionId, + done: run.done || false, + usage, + }), + latestUsageBySession: { + ...(state.latestUsageBySession || {}), + [evt.sessionId]: usage, + }, + }; + } + return state; } diff --git a/extension/agent-panel.css b/extension/agent-panel.css index ffac37e..8bc6444 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -53,6 +53,7 @@ body { align-items: center; gap: 8px; padding: 12px 14px; + flex-wrap: wrap; } .pill-btn { @@ -128,6 +129,22 @@ body { justify-content: center; } +.context-usage-chip { + min-width: 0; + max-width: 100%; + padding: 0 10px; + height: 24px; + border-radius: 999px; + border: 1px solid rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.78); + font-size: 11px; + line-height: 22px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .status-dot { width: 8px; height: 8px; @@ -378,6 +395,21 @@ body { white-space: pre-wrap; } +.step-label strong { + font-weight: 600; + color: var(--text); +} + +.step-label code { + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 11px; + background: var(--sand); + color: var(--crail-dark); + padding: 1px 5px; + border-radius: 4px; + border: 1px solid var(--line); +} + .run-step-icon { width: 13px; height: 13px; @@ -387,6 +419,18 @@ body { position: relative; } +.step-item.latest .step-label { + color: var(--text); +} + +.step-item.latest .run-step-icon { + color: var(--crail); +} + +.step-item.pulse .run-step-icon { + animation: step-pulse 1.2s ease-in-out infinite; +} + .run-step-icon::before, .run-step-icon::after { content: ''; @@ -868,3 +912,15 @@ body { transform: rotate(360deg); } } + +@keyframes step-pulse { + 0%, + 100% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.15); + opacity: 0.65; + } +} diff --git a/extension/agent-panel.html b/extension/agent-panel.html index 9e20c0f..7af63d6 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -30,6 +30,8 @@ +
    Context: unavailable
    +
    Starting... diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 55c6d76..cb7760a 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -3,7 +3,10 @@ import { assignSessionRunId, classifyRunStepIcon, clearSessionRunId, + formatContextUsage, + getLatestInFlightStepIndex, getSessionRunId, + renderInlineContent, shouldApplySessionSelection, } from './agent-panel-runtime.js'; @@ -28,6 +31,7 @@ const state = { const statusEl = document.getElementById('bf-agent-status'); const statusIconEl = document.getElementById('bf-agent-status-icon'); const statusTextEl = document.getElementById('bf-agent-status-text'); +const contextUsageEl = document.getElementById('bf-context-usage'); const modelTriggerBtn = document.getElementById('bf-model-trigger'); const modelLabelEl = document.getElementById('bf-model-label'); const sessionTriggerBtn = document.getElementById('bf-session-trigger'); @@ -101,6 +105,15 @@ function syncStatusIndicator() { statusIconEl.textContent = ''; } +function renderContextUsageChip() { + if (!contextUsageEl) return; + const sessionId = state.value.activeSessionId; + const usage = sessionId ? state.value.latestUsageBySession?.[sessionId] : null; + const formatted = formatContextUsage(usage || {}); + contextUsageEl.textContent = formatted ? `Context: ${formatted}` : 'Context: unavailable'; + contextUsageEl.title = contextUsageEl.textContent; +} + function setStatus(kind, text) { state.status = { kind, text }; syncStatusIndicator(); @@ -359,13 +372,19 @@ function renderRunSteps(runId, run) { if (!runId || !run || !Array.isArray(run.steps) || run.steps.length === 0) return ''; const count = run.steps.length; const expanded = isRunStepsExpanded(runId); + const latestStepIndex = getLatestInFlightStepIndex(run); const items = run.steps - .map((step) => { + .map((step, index) => { const status = step?.status || 'running'; const label = step?.label || 'Step'; const icon = classifyRunStepIcon(step); - return `
  • ${escapeHtml(label)}
  • `; + const isLatest = index === latestStepIndex; + const shouldPulse = isLatest && status === 'running'; + const classes = ['step-item', escapeHtml(status)]; + if (isLatest) classes.push('latest'); + if (shouldPulse) classes.push('pulse'); + return `
  • ${renderInlineContent(label)}
  • `; }) .join(''); @@ -383,7 +402,7 @@ function renderRunSteps(runId, run) { } function renderContent(value) { - return escapeHtml(value).replace(/`([^`]+)`/g, '$1'); + return renderInlineContent(value); } function bindTranscriptHandlers() { @@ -438,7 +457,10 @@ function renderTranscript() { chunks.push(`
    BrowserForce
    -
    Thinking...
    +
    + ${renderRunSteps(sessionRunId, run)} +
    Thinking...
    +
    `); } @@ -483,6 +505,7 @@ function renderPopovers() { function render() { renderSelectors(); + renderContextUsageChip(); renderModelList(); renderSessions(); renderTranscript(); @@ -741,11 +764,22 @@ async function loadMessages(sessionId) { dispatch({ type: 'messages.loaded', sessionId, messages: body.messages || [] }); } +async function loadSessionMetadata(sessionId) { + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'GET', + headers: {}, + }); + await ensureOk(res, 'Failed to load session metadata'); + const session = await readJsonOrEmpty(res); + dispatch({ type: 'session.metadata.loaded', sessionId, session }); +} + async function selectSession(sessionId) { state.sessionSelectionToken += 1; const selectionToken = state.sessionSelectionToken; dispatch({ type: 'session.selected', sessionId }); await loadMessages(sessionId); + await loadSessionMetadata(sessionId); if (!shouldApplySessionSelection({ requestToken: selectionToken, latestRequestToken: state.sessionSelectionToken, diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index cfadc94..0c7115d 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -17,6 +17,7 @@ test('agent panel has inline model and session selectors with popovers', () => { assert.match(html, /id="bf-tab-attach-banner"/); assert.match(html, /id="bf-tab-attach-text"/); assert.match(html, /id="bf-attach-current-tab"/); + assert.match(html, /id="bf-context-usage"/); }); test('agent panel no longer renders title or persistent session sidebar', () => { diff --git a/test/agent/agent-panel-runtime.test.js b/test/agent/agent-panel-runtime.test.js index 22fb8f5..7a613cf 100644 --- a/test/agent/agent-panel-runtime.test.js +++ b/test/agent/agent-panel-runtime.test.js @@ -4,7 +4,10 @@ import { assignSessionRunId, classifyRunStepIcon, clearSessionRunId, + formatContextUsage, + getLatestInFlightStepIndex, getSessionRunId, + renderInlineContent, shouldApplySessionSelection, } from '../../extension/agent-panel-runtime.js'; @@ -48,3 +51,31 @@ test('classifies step icons from reasoning/tool labels', () => { assert.equal(classifyRunStepIcon({ kind: 'status', status: 'done', label: 'Done' }), 'done'); assert.equal(classifyRunStepIcon({ kind: 'status', status: 'failed', label: 'Failed' }), 'failed'); }); + +test('renders safe inline markdown for bold and code spans', () => { + assert.equal(renderInlineContent('**Inspect active tab**'), 'Inspect active tab'); + assert.equal(renderInlineContent('Use `snapshot()` now'), 'Use snapshot() now'); + assert.equal( + renderInlineContent('****'), + '<script>alert(1)</script>', + ); +}); + +test('tracks latest step index for active runs only', () => { + assert.equal(getLatestInFlightStepIndex({ done: false, steps: [{}, {}, {}] }), 2); + assert.equal(getLatestInFlightStepIndex({ done: true, steps: [{}, {}] }), -1); + assert.equal(getLatestInFlightStepIndex({ done: false, steps: [] }), -1); +}); + +test('formats context usage with percentage when context window is present', () => { + assert.equal( + formatContextUsage({ totalTokens: 12345, modelContextWindow: 258400 }), + '12,345 / 258,400 (4.8%)', + ); +}); + +test('returns null for context usage formatting when values are incomplete', () => { + assert.equal(formatContextUsage({ totalTokens: 12345 }), null); + assert.equal(formatContextUsage({ modelContextWindow: 258400 }), null); + assert.equal(formatContextUsage({ totalTokens: 0, modelContextWindow: 258400 }), null); +}); diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index d108862..1443eaf 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -56,3 +56,16 @@ test('session popover renders per-session timestamp metadata', () => { assert.match(js, /updatedAt|createdAt/); assert.match(js, /toLocaleString/); }); + +test('in-flight thinking state keeps run steps visible above the thinking bubble', () => { + assert.match(js, /if \(run && !run\.done\)/); + assert.match(js, /renderRunSteps\(sessionRunId, run\)/); + assert.match(js, /class="thinking-bubble"/); +}); + +test('status row renders context usage from latestUsageBySession with fallback', () => { + assert.match(js, /function renderContextUsageChip\(\)/); + assert.match(js, /latestUsageBySession/); + assert.match(js, /Context:\s*unavailable/); + assert.match(js, /formatted \? `Context: \$\{formatted\}` : 'Context: unavailable'/); +}); diff --git a/test/agent/session-ui-state.test.js b/test/agent/session-ui-state.test.js index e7701f5..4f96798 100644 --- a/test/agent/session-ui-state.test.js +++ b/test/agent/session-ui-state.test.js @@ -59,3 +59,32 @@ test('messages.loaded hydrates stored run metadata for reopened sessions', () => assert.equal(next.runs.run_1?.steps?.length, 1); assert.equal(next.runs.run_1?.steps?.[0]?.label, 'Snapshot page'); }); + +test('session.metadata.loaded hydrates persisted codex usage for reopened session', () => { + const state = { + activeSessionId: 's1', + sessions: [], + runs: {}, + messagesBySession: {}, + latestUsageBySession: {}, + }; + + const next = reduceState(state, { + type: 'session.metadata.loaded', + sessionId: 's1', + session: { + sessionId: 's1', + providerState: { + codex: { + latestUsage: { + modelContextWindow: 258400, + totalTokens: 1120, + }, + }, + }, + }, + }); + + assert.equal(next.latestUsageBySession.s1.modelContextWindow, 258400); + assert.equal(next.latestUsageBySession.s1.totalTokens, 1120); +}); diff --git a/test/agent/sse-events.test.js b/test/agent/sse-events.test.js index 1e91059..cff54f6 100644 --- a/test/agent/sse-events.test.js +++ b/test/agent/sse-events.test.js @@ -7,6 +7,7 @@ const baseState = { activeSessionId: null, messagesBySession: {}, runs: {}, + latestUsageBySession: {}, }; test('chat.delta appends to in-flight run text', () => { @@ -54,3 +55,55 @@ test('run.error appends a final failed step', () => { assert.equal(last.status, 'failed'); assert.match(last.label, /boom/); }); + +test('run.event is converted into a visible in-flight step', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { + event: 'run.event', + runId: 'r1', + sessionId: 's1', + payload: { + type: 'item.started', + item: { + type: 'reasoning', + summary: 'Planning skill invocation', + }, + }, + }); + const last = s2.runs.r1.steps.at(-1); + assert.equal(last.status, 'running'); + assert.equal(last.kind, 'reasoning'); + assert.match(last.label, /Planning skill invocation/); +}); + +test('run.usage stores normalized usage for run and session', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { + event: 'run.usage', + runId: 'r1', + sessionId: 's1', + payload: { + totalTokens: 1120, + modelContextWindow: 258400, + cachedInputTokens: 700, + }, + }); + + assert.equal(s2.runs.r1.usage.totalTokens, 1120); + assert.equal(s2.latestUsageBySession.s1.modelContextWindow, 258400); + assert.equal(s2.latestUsageBySession.s1.cachedInputTokens, 700); +}); + +test('run.usage accepts missing context window without crashing', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { + event: 'run.usage', + runId: 'r1', + sessionId: 's1', + payload: { + totalTokens: 1120, + }, + }); + assert.equal(s2.latestUsageBySession.s1.totalTokens, 1120); + assert.equal(Object.prototype.hasOwnProperty.call(s2.latestUsageBySession.s1, 'modelContextWindow'), false); +}); From 2c1417d63ca61efbcd1afb80462e9f4d7dba225c Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:07:12 +0530 Subject: [PATCH 130/192] docs(agent): document codex session continuity and context telemetry --- AGENTS.md | 10 ++++++++++ README.md | 7 +++++++ 2 files changed, 17 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 5b44d22..540bf0c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,6 +174,16 @@ For side-panel chat UX, **never hardcode or assume a fixed `sessionId`**. - Streaming channels (`/events`) must be scoped by explicit selected `sessionId`. - Do not infer continuity from "current Codex turn/session" alone; BrowserForce Agent keeps its own session store. +### Codex Provider Session Continuity + Usage Telemetry + +For side-panel chat continuity, BrowserForce session metadata stores Codex provider state: + +- Persist Codex thread identity at `providerState.codex.sessionId`. +- On each new run, pass that mapping as `resumeSessionId` so runner can invoke `codex exec resume --json`. +- Persist latest context/token telemetry at `providerState.codex.latestUsage`. +- Emit and consume `run.usage` and `run.provider_session` events. +- Side-panel hydrates usage from `GET /v1/sessions/:sessionId` and shows `Context: unavailable` when telemetry is missing. + ## Security Rules - Relay binds to `127.0.0.1` ONLY. Never `0.0.0.0`. diff --git a/README.md b/README.md index 1d9890f..033da4c 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,14 @@ BrowserForce now includes a side-panel chat UI in the Chrome extension for resum - Open popup -> `Open BrowserForce Agent` to open the side panel. - Use the session list to switch between chats; transcripts hydrate per selected `sessionId`. - Session identity is explicit and persisted; there is no fixed/hardcoded chat session ID. +- BrowserForce session metadata persists Codex continuity state at `providerState.codex.sessionId`. + - New runs use `codex exec resume --json` when this mapping exists. + - If resume fails with an explicit invalid-session signature, chatd retries once as a fresh run. - Streaming uses `fetch` + `ReadableStream` for SSE, not `EventSource`, so the panel can send `Authorization: Bearer ...` headers. +- Side-panel status includes a context usage chip: + - Live updates from `run.usage` SSE events when available. + - Hydrates from `GET /v1/sessions/:sessionId` via `providerState.codex.latestUsage`. + - Falls back to `Context: unavailable` when telemetry is absent. Daemon lifecycle: From 6d142f627f923e6d8ce6cff08acfdfb2f5865817 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:19:33 +0530 Subject: [PATCH 131/192] sidepanel: render run timeline inline with ordered tool/text events --- extension/agent-panel-state.js | 184 +++++++++++++++---- extension/agent-panel.css | 104 ++++------- extension/agent-panel.js | 133 +++++++------- test/agent/agent-panel-send-contract.test.js | 11 +- test/agent/session-ui-state.test.js | 29 +++ test/agent/sse-events.test.js | 30 +++ 6 files changed, 320 insertions(+), 171 deletions(-) diff --git a/extension/agent-panel-state.js b/extension/agent-panel-state.js index 13a6671..1087c24 100644 --- a/extension/agent-panel-state.js +++ b/extension/agent-panel-state.js @@ -19,14 +19,21 @@ function trimStepLabel(label) { return text.length > 160 ? `${text.slice(0, 157)}...` : text; } -function pushStep(run, step) { - const steps = Array.isArray(run?.steps) ? run.steps.slice() : []; - const normalized = { +function normalizeStep(step) { + if (!step || typeof step !== 'object') return null; + const label = trimStepLabel(step.label); + if (!label) return null; + return { kind: step.kind || 'reasoning', status: step.status || 'running', - label: trimStepLabel(step.label), + label, }; - if (!normalized.label) return steps; +} + +function pushStep(run, step) { + const steps = Array.isArray(run?.steps) ? run.steps.slice() : []; + const normalized = normalizeStep(step); + if (!normalized || !normalized.label) return steps; const last = steps[steps.length - 1]; if (last && last.label === normalized.label && last.kind === normalized.kind && last.status === normalized.status) { return steps; @@ -36,6 +43,92 @@ function pushStep(run, step) { return steps; } +function pushTimelineEntry(run, entry) { + const timeline = Array.isArray(run?.timeline) ? run.timeline.slice() : []; + if (!entry || typeof entry !== 'object') return timeline; + + if (entry.type === 'text') { + const text = typeof entry.text === 'string' ? entry.text : ''; + if (!text) return timeline; + const last = timeline[timeline.length - 1]; + if (last?.type === 'text') { + last.text = `${last.text || ''}${text}`; + } else { + timeline.push({ type: 'text', text }); + } + } else if (entry.type === 'step') { + const normalized = normalizeStep(entry); + if (!normalized) return timeline; + const candidate = { type: 'step', ...normalized }; + const last = timeline[timeline.length - 1]; + if ( + last + && last.type === 'step' + && last.label === candidate.label + && last.kind === candidate.kind + && last.status === candidate.status + ) { + return timeline; + } + timeline.push(candidate); + } + + if (timeline.length > 200) timeline.shift(); + return timeline; +} + +function normalizeStoredTimelineEntry(entry) { + if (!entry || typeof entry !== 'object') return null; + if (entry.type === 'text') { + const text = typeof entry.text === 'string' ? entry.text : ''; + if (!text) return null; + return { type: 'text', text }; + } + const step = normalizeStep(entry); + if (!step) return null; + return { type: 'step', ...step }; +} + +function fallbackTimelineFromMessage({ steps, text }) { + const timeline = []; + for (const step of steps) { + timeline.push({ type: 'step', ...step }); + } + if (typeof text === 'string' && text) { + timeline.push({ type: 'text', text }); + } + return timeline; +} + +function hasTimelineText(timeline) { + return Array.isArray(timeline) && timeline.some((entry) => entry?.type === 'text' && entry.text); +} + +function applyFinalTextToTimeline(run, finalText) { + let timeline = Array.isArray(run?.timeline) ? run.timeline.slice() : []; + const currentText = String(run?.text || ''); + const resolved = String(finalText || ''); + if (!resolved) return timeline; + + if (!timeline.length || !hasTimelineText(timeline)) { + timeline = pushTimelineEntry({ timeline }, { type: 'text', text: resolved }); + return timeline; + } + + if (resolved === currentText) return timeline; + + if (currentText && resolved.startsWith(currentText)) { + const suffix = resolved.slice(currentText.length); + if (suffix) { + timeline = pushTimelineEntry({ timeline }, { type: 'text', text: suffix }); + } + return timeline; + } + + timeline = pushTimelineEntry({ timeline }, { type: 'text', text: resolved }); + return timeline; +} + function stepLabelForToolEvent(evt) { const payload = evt?.payload || {}; if (evt.event === 'tool.started') { @@ -149,14 +242,7 @@ function normalizeUsagePayload(payload) { } function normalizeStoredStep(step) { - if (!step || typeof step !== 'object') return null; - const label = trimStepLabel(step.label); - if (!label) return null; - return { - kind: step.kind || 'reasoning', - status: step.status || 'running', - label, - }; + return normalizeStep(step); } function hydrateRunsFromMessages(messages, sessionId, currentRuns) { @@ -167,13 +253,23 @@ function hydrateRunsFromMessages(messages, sessionId, currentRuns) { const steps = Array.isArray(message?.steps) ? message.steps.map(normalizeStoredStep).filter(Boolean) : []; + const timeline = Array.isArray(message?.timeline) + ? message.timeline.map(normalizeStoredTimelineEntry).filter(Boolean) + : []; + const resolvedText = typeof message?.text === 'string' ? message.text : (currentRuns?.[runId]?.text || ''); hydrated[runId] = { ...(currentRuns?.[runId] || { runId, text: '', done: false, steps: [] }), runId, sessionId, - text: typeof message?.text === 'string' ? message.text : (currentRuns?.[runId]?.text || ''), + text: resolvedText, done: true, steps: steps.length > 0 ? steps : (currentRuns?.[runId]?.steps || []), + timeline: timeline.length > 0 + ? timeline + : fallbackTimelineFromMessage({ + steps: steps.length > 0 ? steps : (currentRuns?.[runId]?.steps || []), + text: resolvedText, + }), }; } return hydrated; @@ -243,30 +339,39 @@ export function applyEvent(state = initialState, evt = {}) { done: false, error: null, steps: [], + timeline: [], }), }; } if (evt.event === 'chat.delta') { - const run = state.runs[evt.runId] || { text: '', done: false }; + const run = state.runs[evt.runId] || { text: '', done: false, steps: [], timeline: [] }; const delta = evt.payload?.delta || ''; return { ...state, runs: upsertRun(state, evt.runId, { sessionId: evt.sessionId, text: `${run.text || ''}${delta}`, + timeline: pushTimelineEntry(run, { type: 'text', text: delta }), }), }; } if (evt.event === 'chat.final') { - const finalText = evt.payload?.text || state.runs[evt.runId]?.text || ''; + const run = state.runs[evt.runId] || { text: '', done: false, steps: [], timeline: [] }; + const finalText = evt.payload?.text || run.text || ''; + const timeline = applyFinalTextToTimeline(run, finalText); const currentMessages = state.messagesBySession[evt.sessionId] || []; const hasStoredFinal = currentMessages.some( (message) => message.runId === evt.runId && message.role === 'assistant', ); - const nextMessages = (!hasStoredFinal && finalText) - ? [...currentMessages, { role: 'assistant', text: finalText, runId: evt.runId }] + const nextMessages = (!hasStoredFinal && (finalText || timeline.length > 0)) + ? [...currentMessages, { + role: 'assistant', + text: finalText, + runId: evt.runId, + timeline, + }] : currentMessages; return { @@ -279,47 +384,52 @@ export function applyEvent(state = initialState, evt = {}) { sessionId: evt.sessionId, text: finalText, done: true, + timeline, }), }; } if (evt.event === 'run.error') { - const run = state.runs[evt.runId] || { steps: [] }; + const run = state.runs[evt.runId] || { steps: [], timeline: [] }; const error = evt.payload?.error || 'Unknown error'; + const step = { + kind: 'status', + status: 'failed', + label: `Failed: ${error}`, + }; return { ...state, runs: upsertRun(state, evt.runId, { sessionId: evt.sessionId, done: true, error, - steps: pushStep(run, { - kind: 'status', - status: 'failed', - label: `Failed: ${error}`, - }), + steps: pushStep(run, step), + timeline: pushTimelineEntry(run, { type: 'step', ...step }), }), }; } if (evt.event === 'run.aborted') { - const run = state.runs[evt.runId] || { steps: [] }; + const run = state.runs[evt.runId] || { steps: [], timeline: [] }; + const step = { + kind: 'status', + status: 'aborted', + label: 'Stopped', + }; return { ...state, runs: upsertRun(state, evt.runId, { sessionId: evt.sessionId, done: true, aborted: true, - steps: pushStep(run, { - kind: 'status', - status: 'aborted', - label: 'Stopped', - }), + steps: pushStep(run, step), + timeline: pushTimelineEntry(run, { type: 'step', ...step }), }), }; } if (evt.event === 'tool.started' || evt.event === 'tool.delta' || evt.event === 'tool.final') { - const run = state.runs[evt.runId] || { text: '', done: false, steps: [] }; + const run = state.runs[evt.runId] || { text: '', done: false, steps: [], timeline: [] }; const status = evt.event === 'tool.final' ? 'done' : 'running'; @@ -327,27 +437,31 @@ export function applyEvent(state = initialState, evt = {}) { ? 'reasoning' : 'tool'; const label = stepLabelForToolEvent(evt); + const step = { kind, status, label }; return { ...state, runs: upsertRun(state, evt.runId, { sessionId: evt.sessionId, done: false, - steps: pushStep(run, { kind, status, label }), + steps: pushStep(run, step), + timeline: pushTimelineEntry(run, { type: 'step', ...step }), }), }; } if (evt.event === 'run.event') { - const run = state.runs[evt.runId] || { text: '', done: false, steps: [] }; + const run = state.runs[evt.runId] || { text: '', done: false, steps: [], timeline: [] }; const status = stepStatusForRunEvent(evt); const kind = stepKindForRunEvent(evt); const label = stepLabelForRunEvent(evt); + const step = { kind, status, label }; return { ...state, runs: upsertRun(state, evt.runId, { sessionId: evt.sessionId, done: false, - steps: pushStep(run, { kind, status, label }), + steps: pushStep(run, step), + timeline: pushTimelineEntry(run, { type: 'step', ...step }), }), }; } @@ -355,7 +469,7 @@ export function applyEvent(state = initialState, evt = {}) { if (evt.event === 'run.usage') { const usage = normalizeUsagePayload(evt.payload); if (!usage) return state; - const run = state.runs[evt.runId] || { text: '', done: false, steps: [] }; + const run = state.runs[evt.runId] || { text: '', done: false, steps: [], timeline: [] }; return { ...state, runs: upsertRun(state, evt.runId, { diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 8bc6444..25a5f8c 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -337,49 +337,15 @@ body { word-break: break-word; } -.run-steps-summary { - margin-bottom: 6px; -} - -.steps-toggle { - display: inline-flex; - align-items: center; - gap: 6px; - background: transparent; - border: 0; - cursor: pointer; - font-size: 12px; - color: var(--text-subtle); - padding: 0; - transition: color 0.15s; -} - -.steps-toggle:hover { - color: var(--text-muted); -} - -.steps-toggle svg { - width: 12px; - height: 12px; - transition: transform 0.2s; -} - -.steps-toggle.open svg { - transform: rotate(90deg); -} - -.steps-list { - list-style: none; - display: none; - margin: 8px 0 8px 2px; - padding-left: 12px; - border-left: 1.5px solid var(--line); -} - -.steps-list.open { +.run-timeline { display: flex; flex-direction: column; gap: 8px; + margin-bottom: 6px; +} + +.timeline-step { + padding-left: 2px; } .step-item { @@ -411,10 +377,13 @@ body { } .run-step-icon { - width: 13px; - height: 13px; + width: 14px; + height: 14px; + display: inline-flex; + align-items: center; + justify-content: center; flex-shrink: 0; - margin-top: 1px; + margin-top: 2px; color: var(--text-subtle); position: relative; } @@ -435,20 +404,21 @@ body { .run-step-icon::after { content: ''; position: absolute; + box-sizing: border-box; } .run-step-icon.icon-reasoning::before { - top: 2px; - left: 2px; - width: 9px; - height: 9px; + top: 3px; + left: 3px; + width: 8px; + height: 8px; border-radius: 999px; background: currentColor; } .run-step-icon.icon-tool::before { - top: 1px; - left: 1px; + top: 2px; + left: 2px; width: 10px; height: 10px; border: 1.5px solid currentColor; @@ -457,7 +427,7 @@ body { .run-step-icon.icon-view::before { top: 4px; - left: 0; + left: 1px; width: 12px; height: 6px; border: 1.5px solid currentColor; @@ -466,7 +436,7 @@ body { .run-step-icon.icon-view::after { top: 6px; - left: 5px; + left: 6px; width: 2px; height: 2px; border: 1.5px solid currentColor; @@ -475,7 +445,7 @@ body { .run-step-icon.icon-camera::before { top: 3px; - left: 0; + left: 1px; width: 12px; height: 7px; border: 1.5px solid currentColor; @@ -484,7 +454,7 @@ body { .run-step-icon.icon-camera::after { top: 1px; - left: 4px; + left: 5px; width: 4px; height: 2px; border: 1.5px solid currentColor; @@ -494,7 +464,7 @@ body { .run-step-icon.icon-plan::before { top: 2px; - left: 2px; + left: 3px; width: 2px; height: 2px; border-radius: 999px; @@ -504,7 +474,7 @@ body { .run-step-icon.icon-plan::after { top: 2px; - left: 6px; + left: 7px; width: 5px; height: 2px; border-radius: 2px; @@ -513,17 +483,17 @@ body { } .run-step-icon.icon-done::before { - top: 0; - left: 0; - width: 11px; - height: 11px; + top: 1px; + left: 1px; + width: 12px; + height: 12px; border: 1.5px solid currentColor; border-radius: 999px; } .run-step-icon.icon-done::after { - top: 5px; - left: 3px; + top: 6px; + left: 4px; width: 5px; height: 3px; border-left: 1.5px solid currentColor; @@ -532,17 +502,17 @@ body { } .run-step-icon.icon-failed::before { - top: 0; - left: 0; - width: 11px; - height: 11px; + top: 1px; + left: 1px; + width: 12px; + height: 12px; border: 1.5px solid currentColor; border-radius: 999px; } .run-step-icon.icon-failed::after { - top: 6px; - left: 2px; + top: 7px; + left: 3px; width: 7px; height: 1.5px; background: currentColor; diff --git a/extension/agent-panel.js b/extension/agent-panel.js index cb7760a..8aea36d 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -4,7 +4,6 @@ import { classifyRunStepIcon, clearSessionRunId, formatContextUsage, - getLatestInFlightStepIndex, getSessionRunId, renderInlineContent, shouldApplySessionSelection, @@ -17,7 +16,6 @@ const state = { currentRunBySession: {}, editingSessionId: null, sessionTitleDrafts: {}, - expandedRunSteps: {}, eventController: null, eventLoopToken: 0, sessionSelectionToken: 0, @@ -355,48 +353,62 @@ function renderSessions() { }); } -function isRunStepsExpanded(runId) { - return !!state.expandedRunSteps?.[runId]; -} +function normalizeRunTimeline(run, fallbackText = '') { + if (!run) return []; + if (Array.isArray(run.timeline) && run.timeline.length > 0) { + return run.timeline.filter((entry) => { + if (!entry || typeof entry !== 'object') return false; + if (entry.type === 'text') return typeof entry.text === 'string' && entry.text.length > 0; + if (entry.type === 'step') return typeof entry.label === 'string' && entry.label.trim().length > 0; + return false; + }); + } -function toggleRunSteps(runId) { - if (!runId) return; - state.expandedRunSteps = { - ...(state.expandedRunSteps || {}), - [runId]: !isRunStepsExpanded(runId), - }; - renderTranscript(); + const steps = Array.isArray(run.steps) ? run.steps : []; + const timeline = steps.map((step) => ({ + type: 'step', + kind: step?.kind || 'reasoning', + status: step?.status || 'running', + label: step?.label || '', + })); + + const text = typeof fallbackText === 'string' && fallbackText + ? fallbackText + : (typeof run.text === 'string' ? run.text : ''); + if (text) timeline.push({ type: 'text', text }); + return timeline; } -function renderRunSteps(runId, run) { - if (!runId || !run || !Array.isArray(run.steps) || run.steps.length === 0) return ''; - const count = run.steps.length; - const expanded = isRunStepsExpanded(runId); - const latestStepIndex = getLatestInFlightStepIndex(run); - - const items = run.steps - .map((step, index) => { - const status = step?.status || 'running'; - const label = step?.label || 'Step'; - const icon = classifyRunStepIcon(step); - const isLatest = index === latestStepIndex; - const shouldPulse = isLatest && status === 'running'; - const classes = ['step-item', escapeHtml(status)]; - if (isLatest) classes.push('latest'); - if (shouldPulse) classes.push('pulse'); - return `
  • ${renderInlineContent(label)}
  • `; - }) - .join(''); +function getLatestInFlightTimelineStepIndex(run, timeline) { + if (!run || run.done) return -1; + for (let index = timeline.length - 1; index >= 0; index -= 1) { + const entry = timeline[index]; + if (entry?.type !== 'step') continue; + const status = String(entry.status || 'running').toLowerCase(); + if (status === 'running') return index; + } + return -1; +} +function renderRunTimeline(run, fallbackText = '') { + const timeline = normalizeRunTimeline(run, fallbackText); + if (!timeline.length) return ''; + const latestStepIndex = getLatestInFlightTimelineStepIndex(run, timeline); return ` -
    - -
      ${items}
    +
    + ${timeline.map((entry, index) => { + if (entry.type === 'text') { + return `

    ${renderContent(entry.text || '')}

    `; + } + const status = entry?.status || 'running'; + const icon = classifyRunStepIcon(entry); + const isLatest = index === latestStepIndex; + const shouldPulse = isLatest && status === 'running'; + const classes = ['step-item', 'timeline-step', escapeHtml(status)]; + if (isLatest) classes.push('latest'); + if (shouldPulse) classes.push('pulse'); + return `
    ${renderInlineContent(entry.label || 'Step')}
    `; + }).join('')}
    `; } @@ -406,11 +418,7 @@ function renderContent(value) { } function bindTranscriptHandlers() { - transcriptEl.querySelectorAll('button[data-run-steps-toggle]').forEach((button) => { - button.addEventListener('click', () => { - toggleRunSteps(button.getAttribute('data-run-steps-toggle')); - }); - }); + // Transcript rows are static render output; no delegated actions required. } function renderTranscript() { @@ -431,39 +439,30 @@ function renderTranscript() { } const messageRun = msg.runId ? state.value.runs[msg.runId] : null; + const timelineHtml = renderRunTimeline(messageRun, msg.text || ''); + const fallbackHtml = `

    ${renderContent(msg.text || '')}

    `; return `
    BrowserForce
    - ${renderRunSteps(msg.runId, messageRun)} -

    ${renderContent(msg.text || '')}

    + ${timelineHtml || fallbackHtml}
    `; }); if (run && !run.done) { - if (run.text && run.text.trim()) { - chunks.push(` -
    -
    BrowserForce
    -
    - ${renderRunSteps(sessionRunId, run)} -

    ${renderContent(run.text)}

    -
    -
    - `); - } else { - chunks.push(` -
    -
    BrowserForce
    -
    - ${renderRunSteps(sessionRunId, run)} -
    Thinking...
    -
    -
    - `); - } + const timelineHtml = renderRunTimeline(run, run.text || ''); + const shouldShowThinking = !(run.text && run.text.trim()); + chunks.push(` +
    +
    BrowserForce
    +
    + ${timelineHtml} + ${shouldShowThinking ? '
    Thinking...
    ' : ''} +
    +
    + `); } if (!chunks.length) { diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 1443eaf..50d9370 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -57,12 +57,19 @@ test('session popover renders per-session timestamp metadata', () => { assert.match(js, /toLocaleString/); }); -test('in-flight thinking state keeps run steps visible above the thinking bubble', () => { +test('in-flight thinking state keeps inline timeline visible above the thinking bubble', () => { assert.match(js, /if \(run && !run\.done\)/); - assert.match(js, /renderRunSteps\(sessionRunId, run\)/); + assert.match(js, /function renderRunTimeline\(run, fallbackText = ''\)/); + assert.match(js, /renderRunTimeline\(run, run\.text \|\| ''\)/); assert.match(js, /class="thinking-bubble"/); }); +test('assistant transcript prefers ordered run timeline over grouped run steps', () => { + assert.match(js, /function normalizeRunTimeline\(run, fallbackText = ''\)/); + assert.match(js, /if \(Array\.isArray\(run\.timeline\) && run\.timeline\.length > 0\)/); + assert.match(js, /const timelineHtml = renderRunTimeline\(messageRun, msg\.text \|\| ''\)/); +}); + test('status row renders context usage from latestUsageBySession with fallback', () => { assert.match(js, /function renderContextUsageChip\(\)/); assert.match(js, /latestUsageBySession/); diff --git a/test/agent/session-ui-state.test.js b/test/agent/session-ui-state.test.js index 4f96798..c491fe0 100644 --- a/test/agent/session-ui-state.test.js +++ b/test/agent/session-ui-state.test.js @@ -60,6 +60,35 @@ test('messages.loaded hydrates stored run metadata for reopened sessions', () => assert.equal(next.runs.run_1?.steps?.[0]?.label, 'Snapshot page'); }); +test('messages.loaded hydrates stored timeline entries for reopened sessions', () => { + const state = { + activeSessionId: 's1', + sessions: [], + runs: {}, + messagesBySession: {}, + }; + + const next = reduceState(state, { + type: 'messages.loaded', + sessionId: 's1', + messages: [{ + role: 'assistant', + text: 'Done', + runId: 'run_2', + timeline: [ + { type: 'step', kind: 'tool', status: 'done', label: 'execute' }, + { type: 'text', text: 'Done' }, + ], + }], + }); + + assert.equal(next.runs.run_2?.done, true); + assert.equal(Array.isArray(next.runs.run_2?.timeline), true); + assert.equal(next.runs.run_2?.timeline?.length, 2); + assert.equal(next.runs.run_2?.timeline?.[0]?.type, 'step'); + assert.equal(next.runs.run_2?.timeline?.[1]?.type, 'text'); +}); + test('session.metadata.loaded hydrates persisted codex usage for reopened session', () => { const state = { activeSessionId: 's1', diff --git a/test/agent/sse-events.test.js b/test/agent/sse-events.test.js index cff54f6..3e1c116 100644 --- a/test/agent/sse-events.test.js +++ b/test/agent/sse-events.test.js @@ -48,6 +48,36 @@ test('tool and reasoning events are tracked as steps', () => { assert.match(s4.runs.r1.steps[1].label, /Planning/); }); +test('chat and tool events preserve inline timeline order', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { event: 'chat.delta', runId: 'r1', sessionId: 's1', payload: { delta: 'First chunk. ' } }); + const s3 = applyEvent(s2, { event: 'tool.started', runId: 'r1', sessionId: 's1', payload: { tool: 'execute' } }); + const s4 = applyEvent(s3, { event: 'chat.delta', runId: 'r1', sessionId: 's1', payload: { delta: 'Second chunk.' } }); + const timeline = s4.runs.r1.timeline || []; + + assert.deepEqual( + timeline.map((item) => item.type), + ['text', 'step', 'text'], + ); + assert.equal(timeline[0]?.text, 'First chunk. '); + assert.match(timeline[1]?.label || '', /execute/i); + assert.equal(timeline[2]?.text, 'Second chunk.'); +}); + +test('chat.final stores timeline with assistant transcript message', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { event: 'chat.delta', runId: 'r1', sessionId: 's1', payload: { delta: 'Done.' } }); + const s3 = applyEvent(s2, { event: 'tool.started', runId: 'r1', sessionId: 's1', payload: { tool: 'execute' } }); + const s4 = applyEvent(s3, { event: 'chat.final', runId: 'r1', sessionId: 's1', payload: { text: 'Done.' } }); + const message = s4.messagesBySession.s1.at(-1); + + assert.equal(message?.role, 'assistant'); + assert.equal(Array.isArray(message?.timeline), true); + assert.equal(message.timeline.length >= 2, true); + assert.equal(message.timeline.some((item) => item.type === 'step'), true); + assert.equal(message.timeline.some((item) => item.type === 'text'), true); +}); + test('run.error appends a final failed step', () => { const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); const s2 = applyEvent(s1, { event: 'run.error', runId: 'r1', sessionId: 's1', payload: { error: 'boom' } }); From cfc8dae4c743cd9712f83c82c1662ab122c29241 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:19:45 +0530 Subject: [PATCH 132/192] agent: persist ordered run timeline with chat/tool events --- agent/src/chatd.js | 105 ++++++++++++++++++++++++++----- agent/src/session-store.js | 43 ++++++++++++- test/agent/chatd-api.test.js | 3 + test/agent/session-store.test.js | 8 +++ 4 files changed, 144 insertions(+), 15 deletions(-) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index 480138b..2245ac4 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -300,15 +300,22 @@ function trimStepLabel(label) { return text.length > 160 ? `${text.slice(0, 157)}...` : text; } -function pushRunStep(run, step) { - if (!run) return; - const steps = Array.isArray(run.steps) ? run.steps : []; - const normalized = { +function normalizeRunStep(step) { + if (!step || typeof step !== 'object') return null; + const label = trimStepLabel(step.label); + if (!label) return null; + return { kind: String(step?.kind || '').trim() || 'reasoning', status: String(step?.status || '').trim() || 'running', - label: trimStepLabel(step?.label), + label, }; - if (!normalized.label) return; +} + +function pushRunStep(run, step) { + if (!run) return; + const steps = Array.isArray(run.steps) ? run.steps : []; + const normalized = normalizeRunStep(step); + if (!normalized || !normalized.label) return; const last = steps[steps.length - 1]; if (last && last.label === normalized.label && last.kind === normalized.kind && last.status === normalized.status) { return; @@ -318,6 +325,64 @@ function pushRunStep(run, step) { run.steps = steps; } +function pushRunTimelineEntry(run, entry) { + if (!run || !entry || typeof entry !== 'object') return; + const timeline = Array.isArray(run.timeline) ? run.timeline : []; + if (entry.type === 'text') { + const text = typeof entry.text === 'string' ? entry.text : ''; + if (!text) return; + const last = timeline[timeline.length - 1]; + if (last?.type === 'text') { + last.text = `${last.text || ''}${text}`; + } else { + timeline.push({ type: 'text', text }); + } + } else if (entry.type === 'step') { + const normalized = normalizeRunStep(entry); + if (!normalized) return; + const next = { type: 'step', ...normalized }; + const last = timeline[timeline.length - 1]; + if ( + last + && last.type === 'step' + && last.label === next.label + && last.kind === next.kind + && last.status === next.status + ) { + return; + } + timeline.push(next); + } else { + return; + } + if (timeline.length > 200) timeline.shift(); + run.timeline = timeline; +} + +function runTimelineHasText(run) { + return Array.isArray(run?.timeline) && run.timeline.some((entry) => entry?.type === 'text' && entry.text); +} + +function syncFinalTextToRunTimeline(run, finalText) { + if (!run) return; + const text = String(finalText || ''); + if (!text) return; + const assistantBuffer = String(run.assistantBuffer || ''); + + if (!runTimelineHasText(run)) { + pushRunTimelineEntry(run, { type: 'text', text }); + return; + } + if (assistantBuffer && text.startsWith(assistantBuffer)) { + const suffix = text.slice(assistantBuffer.length); + if (suffix) pushRunTimelineEntry(run, { type: 'text', text: suffix }); + return; + } + if (text !== assistantBuffer) { + pushRunTimelineEntry(run, { type: 'text', text }); + } +} + function stepLabelForToolEvent(evt) { const payload = evt?.payload || {}; if (evt.event === 'tool.started') { @@ -402,38 +467,46 @@ function trackRunStep(run, evt) { if (!run || !evt?.event) return; if (evt.event === 'tool.started' || evt.event === 'tool.delta' || evt.event === 'tool.final') { - pushRunStep(run, { + const step = { kind: evt.event === 'tool.delta' ? 'reasoning' : 'tool', status: evt.event === 'tool.final' ? 'done' : 'running', label: stepLabelForToolEvent(evt), - }); + }; + pushRunStep(run, step); + pushRunTimelineEntry(run, { type: 'step', ...step }); return; } if (evt.event === 'run.event') { - pushRunStep(run, { + const step = { kind: stepKindForRunEvent(evt), status: stepStatusForRunEvent(evt), label: stepLabelForRunEvent(evt), - }); + }; + pushRunStep(run, step); + pushRunTimelineEntry(run, { type: 'step', ...step }); return; } if (evt.event === 'run.error') { - pushRunStep(run, { + const step = { kind: 'status', status: 'failed', label: `Failed: ${evt.payload?.error || 'Unknown error'}`, - }); + }; + pushRunStep(run, step); + pushRunTimelineEntry(run, { type: 'step', ...step }); return; } if (evt.event === 'run.aborted') { - pushRunStep(run, { + const step = { kind: 'status', status: 'aborted', label: 'Stopped', - }); + }; + pushRunStep(run, step); + pushRunTimelineEntry(run, { type: 'step', ...step }); } } @@ -551,12 +624,14 @@ export async function startChatd(opts = {}) { if (!run || run.status !== 'running' || run.finalSent) return; run.finalSent = true; run.status = 'done'; + syncFinalTextToRunTimeline(run, finalText); await appendMessage({ sessionId: run.sessionId, role: 'assistant', text: finalText, runId: run.runId, steps: run.steps, + timeline: run.timeline, storageRoot, }); broadcast(buildEvent({ event: 'chat.final', runId: run.runId, sessionId: run.sessionId, payload: { text: finalText } })); @@ -771,6 +846,7 @@ export async function startChatd(opts = {}) { abort: null, assistantBuffer: '', steps: [], + timeline: [], finalSent: false, queue: Promise.resolve(), lastError: null, @@ -803,6 +879,7 @@ export async function startChatd(opts = {}) { const delta = evt.payload?.delta || ''; if (delta) { active.assistantBuffer += delta; + pushRunTimelineEntry(active, { type: 'text', text: delta }); broadcast(buildEvent({ event: 'chat.delta', runId, sessionId, payload: { delta } })); } return; diff --git a/agent/src/session-store.js b/agent/src/session-store.js index 6ff7759..a624f4b 100644 --- a/agent/src/session-store.js +++ b/agent/src/session-store.js @@ -71,6 +71,43 @@ function normalizeSteps(steps) { .slice(-100); } +function normalizeTimelineEntry(entry) { + if (!entry || typeof entry !== 'object') return null; + if (entry.type === 'text') { + const text = typeof entry.text === 'string' ? entry.text : ''; + if (!text) return null; + return { type: 'text', text }; + } + const step = normalizeStep(entry); + if (!step) return null; + return { type: 'step', ...step }; +} + +function normalizeTimeline(timeline) { + if (!Array.isArray(timeline)) return []; + const entries = []; + for (const item of timeline.slice(-200)) { + const normalized = normalizeTimelineEntry(item); + if (!normalized) continue; + const last = entries[entries.length - 1]; + if (normalized.type === 'text' && last?.type === 'text') { + last.text = `${last.text || ''}${normalized.text || ''}`; + continue; + } + if ( + normalized.type === 'step' + && last?.type === 'step' + && last.label === normalized.label + && last.kind === normalized.kind + && last.status === normalized.status + ) { + continue; + } + entries.push(normalized); + } + return entries.slice(-200); +} + async function ensureStorageRoot(storageRoot) { await fs.mkdir(storageRoot, { recursive: true }); } @@ -287,7 +324,7 @@ export async function updateSession({ sessionId, patch = {}, storageRoot } = {}) }); } -export async function appendMessage({ sessionId, role, text, runId, steps, storageRoot } = {}) { +export async function appendMessage({ sessionId, role, text, runId, steps, timeline, storageRoot } = {}) { assertValidSessionId(sessionId, 'appendMessage'); if (!role) throw new Error('appendMessage requires role'); if (typeof text !== 'string') throw new Error('appendMessage requires text'); @@ -311,6 +348,10 @@ export async function appendMessage({ sessionId, role, text, runId, steps, stora if (normalizedSteps.length > 0) { entry.steps = normalizedSteps; } + const normalizedTimeline = normalizeTimeline(timeline); + if (normalizedTimeline.length > 0) { + entry.timeline = normalizedTimeline; + } const logPath = messageLogPath(root, sessionId); await fs.appendFile(logPath, `${JSON.stringify(entry)}\n`, 'utf8'); diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index b0804dc..3507b3d 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -272,6 +272,9 @@ test('POST /v1/runs persists run steps so reopened sessions can render them', as assert.equal(Array.isArray(assistant?.steps), true); assert.equal(assistant.steps.length >= 1, true); assert.equal(assistant.steps.some((step) => /Inspecting active tab/.test(step?.label || '')), true); + assert.equal(Array.isArray(assistant?.timeline), true); + assert.equal(assistant.timeline.some((item) => item?.type === 'step'), true); + assert.equal(assistant.timeline.some((item) => item?.type === 'text' && /done/i.test(item?.text || '')), true); } finally { await daemon.stop(); } diff --git a/test/agent/session-store.test.js b/test/agent/session-store.test.js index a134e65..162a95f 100644 --- a/test/agent/session-store.test.js +++ b/test/agent/session-store.test.js @@ -42,12 +42,20 @@ test('messages preserve optional run metadata used for transcript rehydration', text: 'done', runId: 'run_123', steps: [{ kind: 'tool', status: 'done', label: 'Snapshot page' }], + timeline: [ + { type: 'step', kind: 'tool', status: 'done', label: 'Snapshot page' }, + { type: 'text', text: 'done' }, + ], storageRoot, }); const rows = await readMessages({ sessionId, limit: 20, storageRoot }); const last = rows.at(-1); assert.equal(last.runId, 'run_123'); assert.deepEqual(last.steps, [{ kind: 'tool', status: 'done', label: 'Snapshot page' }]); + assert.deepEqual(last.timeline, [ + { type: 'step', kind: 'tool', status: 'done', label: 'Snapshot page' }, + { type: 'text', text: 'done' }, + ]); }); test('rejects unsafe session ids', async () => { From 764f7ada8721b42af69716b90ccde000e2ea6e8d Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:47:46 +0530 Subject: [PATCH 133/192] fix(sidepanel): show context usage only when available below composer --- extension/agent-panel.css | 30 +++++++++----------- extension/agent-panel.html | 3 +- extension/agent-panel.js | 8 +++++- test/agent/agent-panel-contract.test.js | 4 +++ test/agent/agent-panel-send-contract.test.js | 7 +++-- 5 files changed, 29 insertions(+), 23 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 25a5f8c..48051dd 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -53,7 +53,6 @@ body { align-items: center; gap: 8px; padding: 12px 14px; - flex-wrap: wrap; } .pill-btn { @@ -129,22 +128,6 @@ body { justify-content: center; } -.context-usage-chip { - min-width: 0; - max-width: 100%; - padding: 0 10px; - height: 24px; - border-radius: 999px; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(255, 255, 255, 0.08); - color: rgba(255, 255, 255, 0.78); - font-size: 11px; - line-height: 22px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - .status-dot { width: 8px; height: 8px; @@ -558,6 +541,9 @@ body { background: #fff; border-top: 1px solid var(--line); padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 6px; } .composer-box { @@ -602,6 +588,16 @@ body { flex-shrink: 0; } +.context-usage-note { + font-size: 10px; + line-height: 1.35; + color: var(--text-subtle); + padding: 0 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .btn-stop, .btn-send { width: 32px; diff --git a/extension/agent-panel.html b/extension/agent-panel.html index 7af63d6..e2355a5 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -30,8 +30,6 @@ -
    Context: unavailable
    -
    Starting... @@ -62,6 +60,7 @@
    +

    diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 8aea36d..5b2ca0a 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -108,7 +108,13 @@ function renderContextUsageChip() { const sessionId = state.value.activeSessionId; const usage = sessionId ? state.value.latestUsageBySession?.[sessionId] : null; const formatted = formatContextUsage(usage || {}); - contextUsageEl.textContent = formatted ? `Context: ${formatted}` : 'Context: unavailable'; + contextUsageEl.classList.toggle('hidden', !formatted); + if (!formatted) { + contextUsageEl.textContent = ''; + contextUsageEl.removeAttribute('title'); + return; + } + contextUsageEl.textContent = `Context: ${formatted}`; contextUsageEl.title = contextUsageEl.textContent; } diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index 0c7115d..2eef4ca 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -18,6 +18,10 @@ test('agent panel has inline model and session selectors with popovers', () => { assert.match(html, /id="bf-tab-attach-text"/); assert.match(html, /id="bf-attach-current-tab"/); assert.match(html, /id="bf-context-usage"/); + assert.match( + html, + /id="bf-chat-form"[\s\S]*class="composer-box"[\s\S]*<\/div>\s*
    { diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 50d9370..7860f76 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -70,9 +70,10 @@ test('assistant transcript prefers ordered run timeline over grouped run steps', assert.match(js, /const timelineHtml = renderRunTimeline\(messageRun, msg\.text \|\| ''\)/); }); -test('status row renders context usage from latestUsageBySession with fallback', () => { +test('context usage renderer hides element when unavailable and only shows formatted values', () => { assert.match(js, /function renderContextUsageChip\(\)/); assert.match(js, /latestUsageBySession/); - assert.match(js, /Context:\s*unavailable/); - assert.match(js, /formatted \? `Context: \$\{formatted\}` : 'Context: unavailable'/); + assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !formatted\)/); + assert.match(js, /contextUsageEl\.textContent = `Context: \$\{formatted\}`/); + assert.doesNotMatch(js, /Context:\s*unavailable/); }); From e49e899b6376b1c8559eafe83db96e0577c87503 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:48:22 +0530 Subject: [PATCH 134/192] docs(dev): add contributor guide for relay ports and streaming debug workflows --- README.md | 2 + docs/DEVELOPMENT.md | 143 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 docs/DEVELOPMENT.md diff --git a/README.md b/README.md index 033da4c..9948bc9 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,8 @@ After loading, the extension icon appears in your toolbar (gray = disconnected). The relay auto-starts when you run any command or connect via MCP — no manual step needed. Extension icon turns green once connected. +Contributor/dev workflows (alternate ports, stream debugging, MCP wiring) are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md). + To run the relay manually (optional): ```bash diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..fa941fe --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,143 @@ +# BrowserForce Development Guide + +This guide is for contributors who need a fast local dev/debug loop. + +## Quickstart + +1. Install deps: + +```bash +pnpm install +``` + +2. Run relay and MCP from this repo: + +```bash +pnpm relay +pnpm mcp +``` + +3. Load extension from this repo in Chrome (`chrome://extensions` -> Load unpacked -> `extension/`). + +4. In popup, ensure Relay URL is: + +```text +ws://127.0.0.1:19222/extension +``` + +## Run on a Different Relay Port (Local Debug Hack) + +Use this when another BrowserForce instance is already running or you want isolated debugging. + +1. Start relay on a non-default port: + +```bash +RELAY_PORT=19333 pnpm relay +``` + +2. In extension popup, set Relay URL to: + +```text +ws://127.0.0.1:19333/extension +``` + +3. Make MCP use the same relay port. + +If your MCP client is configured with `npx browserforce@latest mcp`, inject `RELAY_PORT=19333` in the MCP command. + +Example shape: + +```json +{ + "command": "env", + "args": ["RELAY_PORT=19333", "npx", "-y", "browserforce@latest", "mcp"] +} +``` + +Fallback (if you cannot pass `RELAY_PORT` in MCP config): set `BF_CDP_URL` to the exact ws URL from `~/.browserforce/cdp-url`. + +## Debug Side-Panel Streaming Events + +The side-panel receives SSE from chatd (`/v1/events`). You can inspect the same stream directly. + +1. Start agent daemon: + +```bash +browserforce agent start +``` + +2. Load auth data: + +```bash +PORT=$(jq -r '.port' ~/.browserforce/chatd-url.json) +TOKEN=$(jq -r '.token' ~/.browserforce/chatd-url.json) +BASE="http://127.0.0.1:$PORT" +``` + +3. Create a test session: + +```bash +SESSION_ID=$(curl -sS "$BASE/v1/sessions" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"title":"debug-stream"}' | jq -r '.sessionId') +echo "$SESSION_ID" +``` + +4. Terminal A: watch stream: + +```bash +curl -N -sS "$BASE/v1/events?sessionId=$SESSION_ID" \ + -H "Authorization: Bearer $TOKEN" \ +| awk '/^data: /{sub(/^data: /,""); print}' \ +| jq -c '{event, runId, sessionId, payload}' +``` + +5. Terminal B: trigger a run: + +```bash +curl -sS "$BASE/v1/runs" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"sessionId\":\"$SESSION_ID\",\"message\":\"say hello and stop\"}" | jq +``` + +Useful filters: + +```bash +# only assistant deltas +... | jq -c 'select(.event=="chat.delta")' + +# continuity + telemetry +... | jq -c 'select(.event=="run.provider_session" or .event=="run.usage")' +``` + +## Debug CDP Traffic + +Relay writes CDP traffic to: + +```text +~/.browserforce/cdp.jsonl +``` + +Tail live: + +```bash +tail -f ~/.browserforce/cdp.jsonl | jq -c '{ts, direction, method: (.message.method // "response")}' +``` + +Method summary: + +```bash +jq -r '.direction + "\t" + (.message.method // "response")' ~/.browserforce/cdp.jsonl | uniq -c +``` + +## Test Commands (Common While Developing) + +```bash +pnpm test +node --test test/agent/chatd-api.test.js +node --test test/agent/codex-runner.test.js +node --test test/agent/session-store.test.js +node --test test/agent/agent-panel-contract.test.js test/agent/agent-panel-send-contract.test.js +``` From 6b0cd93e39b7b9392f3e2bfb7289f3cd879034d9 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:52:06 +0530 Subject: [PATCH 135/192] fix(sidepanel): open panel first and attach tab asynchronously --- extension/agent-panel.js | 31 ++++++++++++++++---- test/agent/agent-panel-send-contract.test.js | 17 +++++++++-- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 5b2ca0a..06358f6 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -14,6 +14,8 @@ const state = { auth: null, modelPresets: [{ value: null, label: 'Default' }], currentRunBySession: {}, + initialTabAttachInFlight: false, + initialTabAttachStarted: false, editingSessionId: null, sessionTitleDrafts: {}, eventController: null, @@ -108,14 +110,17 @@ function renderContextUsageChip() { const sessionId = state.value.activeSessionId; const usage = sessionId ? state.value.latestUsageBySession?.[sessionId] : null; const formatted = formatContextUsage(usage || {}); - contextUsageEl.classList.toggle('hidden', !formatted); - if (!formatted) { + const note = state.initialTabAttachInFlight + ? 'Attaching active tab...' + : (formatted ? `Context: ${formatted}` : ''); + contextUsageEl.classList.toggle('hidden', !note); + if (!note) { contextUsageEl.textContent = ''; contextUsageEl.removeAttribute('title'); return; } - contextUsageEl.textContent = `Context: ${formatted}`; - contextUsageEl.title = contextUsageEl.textContent; + contextUsageEl.textContent = note; + contextUsageEl.title = note; } function setStatus(kind, text) { @@ -658,6 +663,21 @@ function bindTabAttachWatchers() { } } +function startInitialTabAttach() { + if (state.initialTabAttachStarted) return; + state.initialTabAttachStarted = true; + state.initialTabAttachInFlight = true; + renderContextUsageChip(); + ensureCurrentTabAttached() + .catch(() => { + // best-effort only + }) + .finally(() => { + state.initialTabAttachInFlight = false; + renderContextUsageChip(); + }); +} + async function getActiveTabContext() { if (!chrome?.tabs?.query) return null; try { @@ -1053,8 +1073,9 @@ popoverBackdropEl.addEventListener('click', () => { try { setComposerEnabled(false); setStatus('info', 'Connecting...'); + render(); + startInitialTabAttach(); await loadAuth(); - await ensureCurrentTabAttached(); bindTabAttachWatchers(); try { await loadModelPresets(); diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 7860f76..f796abd 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -73,7 +73,20 @@ test('assistant transcript prefers ordered run timeline over grouped run steps', test('context usage renderer hides element when unavailable and only shows formatted values', () => { assert.match(js, /function renderContextUsageChip\(\)/); assert.match(js, /latestUsageBySession/); - assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !formatted\)/); - assert.match(js, /contextUsageEl\.textContent = `Context: \$\{formatted\}`/); + assert.match(js, /const note = state\.initialTabAttachInFlight[\s\S]*formatted[\s\S]*Context: \$\{formatted\}/); + assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !note\)/); + assert.match(js, /contextUsageEl\.textContent = note/); assert.doesNotMatch(js, /Context:\s*unavailable/); }); + +test('init opens smoothly by starting tab attach asynchronously', () => { + assert.match(js, /function startInitialTabAttach\(\)/); + assert.match(js, /\(async function init\(\)[\s\S]*startInitialTabAttach\(\);/); + assert.doesNotMatch(js, /\(async function init\(\)[\s\S]*await ensureCurrentTabAttached\(\);/); +}); + +test('bottom note can show async attach status and still hides when no note is available', () => { + assert.match(js, /initialTabAttachInFlight:\s*false/); + assert.match(js, /state\.initialTabAttachInFlight\s*\?\s*'Attaching active tab\.\.\.'/); + assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !note\)/); +}); From 755d85f2b5dd906404aa5ee6c5d8c261880cb357 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 17:57:26 +0530 Subject: [PATCH 136/192] fix(sidepanel): defer first tab attach until after initial paint --- extension/agent-panel.js | 20 ++++++++++++-------- test/agent/agent-panel-send-contract.test.js | 7 +++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 06358f6..8ecd4dd 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -668,14 +668,18 @@ function startInitialTabAttach() { state.initialTabAttachStarted = true; state.initialTabAttachInFlight = true; renderContextUsageChip(); - ensureCurrentTabAttached() - .catch(() => { - // best-effort only - }) - .finally(() => { - state.initialTabAttachInFlight = false; - renderContextUsageChip(); - }); + window.requestAnimationFrame(() => { + window.setTimeout(() => { + ensureCurrentTabAttached() + .catch(() => { + // best-effort only + }) + .finally(() => { + state.initialTabAttachInFlight = false; + renderContextUsageChip(); + }); + }, 0); + }); } async function getActiveTabContext() { diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index f796abd..6601e56 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -90,3 +90,10 @@ test('bottom note can show async attach status and still hides when no note is a assert.match(js, /state\.initialTabAttachInFlight\s*\?\s*'Attaching active tab\.\.\.'/); assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !note\)/); }); + +test('initial tab attach is deferred until after first paint', () => { + const fnMatch = js.match(/function startInitialTabAttach\(\)[\s\S]*?\n}\n\nasync function getActiveTabContext/); + assert.ok(fnMatch, 'startInitialTabAttach function block should be present'); + const fnBlock = fnMatch[0]; + assert.match(fnBlock, /window\.requestAnimationFrame\(\(\)\s*=>\s*\{/); +}); From ae4196b0c1602a38cb53a33dcce8d8803ec315f9 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 18:00:32 +0530 Subject: [PATCH 137/192] fix(sidepanel): delay initial tab attach by 2s --- extension/agent-panel.js | 22 +++++++++----------- test/agent/agent-panel-send-contract.test.js | 5 +++-- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 8ecd4dd..84232e7 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -668,18 +668,16 @@ function startInitialTabAttach() { state.initialTabAttachStarted = true; state.initialTabAttachInFlight = true; renderContextUsageChip(); - window.requestAnimationFrame(() => { - window.setTimeout(() => { - ensureCurrentTabAttached() - .catch(() => { - // best-effort only - }) - .finally(() => { - state.initialTabAttachInFlight = false; - renderContextUsageChip(); - }); - }, 0); - }); + window.setTimeout(() => { + ensureCurrentTabAttached() + .catch(() => { + // best-effort only + }) + .finally(() => { + state.initialTabAttachInFlight = false; + renderContextUsageChip(); + }); + }, 2000); } async function getActiveTabContext() { diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 6601e56..ed2622c 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -91,9 +91,10 @@ test('bottom note can show async attach status and still hides when no note is a assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !note\)/); }); -test('initial tab attach is deferred until after first paint', () => { +test('initial tab attach waits 2 seconds before attaching', () => { const fnMatch = js.match(/function startInitialTabAttach\(\)[\s\S]*?\n}\n\nasync function getActiveTabContext/); assert.ok(fnMatch, 'startInitialTabAttach function block should be present'); const fnBlock = fnMatch[0]; - assert.match(fnBlock, /window\.requestAnimationFrame\(\(\)\s*=>\s*\{/); + assert.match(fnBlock, /window\.setTimeout\(\(\)\s*=>\s*\{/); + assert.match(fnBlock, /},\s*2000\)/); }); From e147203a9876a705b66244f76dd162a6f4b4f639 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 18:06:12 +0530 Subject: [PATCH 138/192] fix(sidepanel): preserve partial assistant output when run is stopped --- extension/agent-panel-state.js | 25 +++++++++++++++++++++++-- test/agent/sse-events.test.js | 14 ++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/extension/agent-panel-state.js b/extension/agent-panel-state.js index 1087c24..48e7bbd 100644 --- a/extension/agent-panel-state.js +++ b/extension/agent-panel-state.js @@ -410,20 +410,41 @@ export function applyEvent(state = initialState, evt = {}) { } if (evt.event === 'run.aborted') { - const run = state.runs[evt.runId] || { steps: [], timeline: [] }; + const run = state.runs[evt.runId] || { text: '', steps: [], timeline: [] }; const step = { kind: 'status', status: 'aborted', label: 'Stopped', }; + const timeline = pushTimelineEntry(run, { type: 'step', ...step }); + const hasContentBeforeStop = Boolean( + (typeof run.text === 'string' && run.text) + || (Array.isArray(run.timeline) && run.timeline.length > 0), + ); + const currentMessages = state.messagesBySession[evt.sessionId] || []; + const hasStoredFinal = currentMessages.some( + (message) => message.runId === evt.runId && message.role === 'assistant', + ); + const nextMessages = (!hasStoredFinal && hasContentBeforeStop) + ? [...currentMessages, { + role: 'assistant', + text: run.text || '', + runId: evt.runId, + timeline, + }] + : currentMessages; return { ...state, + messagesBySession: { + ...state.messagesBySession, + [evt.sessionId]: nextMessages, + }, runs: upsertRun(state, evt.runId, { sessionId: evt.sessionId, done: true, aborted: true, steps: pushStep(run, step), - timeline: pushTimelineEntry(run, { type: 'step', ...step }), + timeline, }), }; } diff --git a/test/agent/sse-events.test.js b/test/agent/sse-events.test.js index 3e1c116..3244f14 100644 --- a/test/agent/sse-events.test.js +++ b/test/agent/sse-events.test.js @@ -31,6 +31,20 @@ test('run.aborted marks run terminal', () => { assert.equal(next.runs.r1.aborted, true); }); +test('run.aborted preserves partial assistant output in transcript history', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { event: 'chat.delta', runId: 'r1', sessionId: 's1', payload: { delta: 'Partial answer' } }); + const s3 = applyEvent(s2, { event: 'run.aborted', runId: 'r1', sessionId: 's1', payload: {} }); + const message = s3.messagesBySession.s1?.at(-1); + + assert.equal(message?.role, 'assistant'); + assert.equal(message?.runId, 'r1'); + assert.equal(message?.text, 'Partial answer'); + assert.equal(Array.isArray(message?.timeline), true); + assert.equal(message.timeline.some((item) => item.type === 'text'), true); + assert.equal(message.timeline.some((item) => item.type === 'step' && item.status === 'aborted'), true); +}); + test('tool and reasoning events are tracked as steps', () => { const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); const s2 = applyEvent(s1, { event: 'tool.started', runId: 'r1', sessionId: 's1', payload: { tool: 'fetch' } }); From 4dc0dd5f4d56976da9088cd162fd8d0a8d1575fc Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 18:07:40 +0530 Subject: [PATCH 139/192] fix(chatd): persist partial assistant output when run is aborted --- agent/src/chatd.js | 22 +++++++++++++ test/agent/chatd-api.test.js | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index 2245ac4..a2290ff 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -650,6 +650,27 @@ export async function startChatd(opts = {}) { runs.delete(run.runId); } + async function persistAbortedRun(run) { + if (!run) return; + trackRunStep(run, { event: 'run.aborted', payload: {} }); + const partialText = String(run.assistantBuffer || ''); + syncFinalTextToRunTimeline(run, partialText); + const hasContent = Boolean( + partialText + || (Array.isArray(run.timeline) && run.timeline.length > 0), + ); + if (!hasContent) return; + await appendMessage({ + sessionId: run.sessionId, + role: 'assistant', + text: partialText, + runId: run.runId, + steps: run.steps, + timeline: run.timeline, + storageRoot, + }); + } + const server = http.createServer(async (req, res) => { try { const base = `http://${req.headers.host || '127.0.0.1'}`; @@ -1015,6 +1036,7 @@ export async function startChatd(opts = {}) { } run.status = 'aborted'; + await persistAbortedRun(run); run.abort?.(); runs.delete(decodedRunId); broadcast(buildEvent({ event: 'run.aborted', runId: decodedRunId, sessionId: run.sessionId, payload: {} })); diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index 3507b3d..3398e5c 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -280,6 +280,66 @@ test('POST /v1/runs persists run steps so reopened sessions can render them', as } }); +test('POST /v1/runs abort persists partial assistant output for session reloads', async () => { + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + runExecutor: ({ runId, sessionId, onEvent }) => { + setTimeout(() => { + onEvent({ event: 'chat.delta', runId, sessionId, payload: { delta: 'Partial answer' } }); + }, 10); + setTimeout(() => { + onEvent({ event: 'tool.started', runId, sessionId, payload: { tool: 'snapshot' } }); + }, 15); + return { abort() {} }; + }, + }); + + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Abort persistence' }), + }).then((res) => res.json()); + + const runRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'start and stop' }), + }); + assert.equal(runRes.status, 202); + const runBody = await runRes.json(); + + await new Promise((resolve) => setTimeout(resolve, 60)); + + const abortRes = await fetch(`${daemon.baseUrl}/v1/runs/${encodeURIComponent(runBody.runId)}/abort`, { + method: 'DELETE', + headers: { authorization: `Bearer ${daemon.token}` }, + }); + assert.equal(abortRes.status, 200); + + const messagesBody = await fetch( + `${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}/messages`, + { headers: { authorization: `Bearer ${daemon.token}` } }, + ).then((res) => res.json()); + const assistant = (messagesBody.messages || []).at(-1); + + assert.equal(assistant?.role, 'assistant'); + assert.equal(assistant?.runId, runBody.runId); + assert.equal(assistant?.text, 'Partial answer'); + assert.equal(Array.isArray(assistant?.timeline), true); + assert.equal(assistant.timeline.some((item) => item?.type === 'step' && item?.status === 'aborted'), true); + } finally { + await daemon.stop(); + } +}); + test('runExecutor synchronous failure does not leak abortable run', async () => { const storageRoot = mkdtempSync(join(tmpdir(), 'bf-chatd-run-fail-')); let attemptedRunId = null; From 7d69d82c8463ea491cec6382d9c179ff3b0b4935 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 18:11:32 +0530 Subject: [PATCH 140/192] feat(sidepanel): collapse tool-call steps with expandable details --- extension/agent-panel-state.js | 94 +++++++++++++++++++- extension/agent-panel.css | 55 ++++++++++++ extension/agent-panel.js | 58 +++++++++++- test/agent/agent-panel-send-contract.test.js | 6 ++ test/agent/sse-events.test.js | 24 +++++ 5 files changed, 230 insertions(+), 7 deletions(-) diff --git a/extension/agent-panel-state.js b/extension/agent-panel-state.js index 48e7bbd..7cd3fe7 100644 --- a/extension/agent-panel-state.js +++ b/extension/agent-panel-state.js @@ -19,14 +19,57 @@ function trimStepLabel(label) { return text.length > 160 ? `${text.slice(0, 157)}...` : text; } +function normalizeStepDetails(details, label = '') { + const lines = []; + const pushLine = (value) => { + const line = String(value || '') + .split('\n') + .map((part) => part.trim()) + .filter(Boolean); + for (const rawPart of line) { + const part = rawPart.replace(/^[-*]\s+/, '').trim(); + if (!part) continue; + if (part === label) continue; + if (lines.includes(part)) continue; + lines.push(part.length > 220 ? `${part.slice(0, 217)}...` : part); + if (lines.length >= 8) return; + } + }; + const visit = (value) => { + if (value == null) return; + if (Array.isArray(value)) { + for (const item of value) { + if (lines.length >= 8) return; + visit(item); + } + return; + } + if (typeof value === 'object') { + visit(value.text); + visit(value.message); + visit(value.output); + visit(value.command); + visit(value.path); + visit(value.query); + visit(value.pattern); + return; + } + pushLine(value); + }; + visit(details); + return lines; +} + function normalizeStep(step) { if (!step || typeof step !== 'object') return null; const label = trimStepLabel(step.label); if (!label) return null; + const details = normalizeStepDetails(step.details, label); return { kind: step.kind || 'reasoning', status: step.status || 'running', label, + ...(details.length > 0 ? { details } : {}), }; } @@ -35,7 +78,13 @@ function pushStep(run, step) { const normalized = normalizeStep(step); if (!normalized || !normalized.label) return steps; const last = steps[steps.length - 1]; - if (last && last.label === normalized.label && last.kind === normalized.kind && last.status === normalized.status) { + if ( + last + && last.label === normalized.label + && last.kind === normalized.kind + && last.status === normalized.status + && JSON.stringify(last.details || []) === JSON.stringify(normalized.details || []) + ) { return steps; } steps.push(normalized); @@ -67,6 +116,7 @@ function pushTimelineEntry(run, entry) { && last.label === candidate.label && last.kind === candidate.kind && last.status === candidate.status + && JSON.stringify(last.details || []) === JSON.stringify(candidate.details || []) ) { return timeline; } @@ -164,6 +214,24 @@ function stepLabelForToolEvent(evt) { return ''; } +function stepDetailsForToolEvent(evt, label) { + const payload = evt?.payload || {}; + return normalizeStepDetails([ + payload.details, + payload.text, + payload.message, + payload.delta, + payload.command, + payload.path, + payload.query, + payload.pattern, + payload.args, + payload.paths, + payload.items, + payload.item, + ], label); +} + function humanizeToken(value) { const normalized = String(value || '') .trim() @@ -209,6 +277,24 @@ function stepLabelForRunEvent(evt) { ]) || 'Working...'; } +function stepDetailsForRunEvent(evt, label) { + const payload = evt?.payload || {}; + return normalizeStepDetails([ + payload.details, + payload.text, + payload.message, + payload.delta, + payload.command, + payload.path, + payload.query, + payload.pattern, + payload.args, + payload.paths, + payload.items, + payload.item, + ], label); +} + function upsertRun(state, runId, patch) { return { ...state.runs, @@ -458,7 +544,8 @@ export function applyEvent(state = initialState, evt = {}) { ? 'reasoning' : 'tool'; const label = stepLabelForToolEvent(evt); - const step = { kind, status, label }; + const details = stepDetailsForToolEvent(evt, label); + const step = { kind, status, label, ...(details.length > 0 ? { details } : {}) }; return { ...state, runs: upsertRun(state, evt.runId, { @@ -475,7 +562,8 @@ export function applyEvent(state = initialState, evt = {}) { const status = stepStatusForRunEvent(evt); const kind = stepKindForRunEvent(evt); const label = stepLabelForRunEvent(evt); - const step = { kind, status, label }; + const details = stepDetailsForRunEvent(evt, label); + const step = { kind, status, label, ...(details.length > 0 ? { details } : {}) }; return { ...state, runs: upsertRun(state, evt.runId, { diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 48051dd..fa82ecd 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -337,6 +337,61 @@ body { gap: 8px; } +.step-body { + min-width: 0; + flex: 1; +} + +.step-toggle { + width: 100%; + border: 0; + background: transparent; + padding: 0; + color: inherit; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + text-align: left; +} + +.step-item.collapsible .step-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.step-caret::before { + content: '›'; + display: inline-block; + font-size: 14px; + color: var(--text-subtle); + transition: transform 0.15s ease; +} + +.step-item.collapsible.expanded .step-caret::before { + transform: rotate(90deg); +} + +.step-details { + list-style: none; + margin: 6px 0 0; + padding: 0 0 0 4px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.step-details li { + font-size: 11.5px; + color: var(--text-muted); + line-height: 1.4; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + .step-label { font-size: 11.5px; color: var(--text-muted); diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 84232e7..eedce97 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -14,6 +14,8 @@ const state = { auth: null, modelPresets: [{ value: null, label: 'Default' }], currentRunBySession: {}, + expandedTimelineEntries: {}, + transcriptHandlersBound: false, initialTabAttachInFlight: false, initialTabAttachStarted: false, editingSessionId: null, @@ -405,6 +407,13 @@ function renderRunTimeline(run, fallbackText = '') { const timeline = normalizeRunTimeline(run, fallbackText); if (!timeline.length) return ''; const latestStepIndex = getLatestInFlightTimelineStepIndex(run, timeline); + const getTimelineEntryKey = (entry, index) => { + const runId = String(run?.runId || 'run'); + const kind = String(entry?.kind || ''); + const status = String(entry?.status || ''); + const label = String(entry?.label || ''); + return `${runId}:${index}:${kind}:${status}:${label}`; + }; return `
    ${timeline.map((entry, index) => { @@ -415,10 +424,33 @@ function renderRunTimeline(run, fallbackText = '') { const icon = classifyRunStepIcon(entry); const isLatest = index === latestStepIndex; const shouldPulse = isLatest && status === 'running'; + const details = Array.isArray(entry?.details) ? entry.details.filter(Boolean) : []; + const isCollapsible = details.length > 0; const classes = ['step-item', 'timeline-step', escapeHtml(status)]; if (isLatest) classes.push('latest'); if (shouldPulse) classes.push('pulse'); - return `
    ${renderInlineContent(entry.label || 'Step')}
    `; + if (!isCollapsible) { + return `
    ${renderInlineContent(entry.label || 'Step')}
    `; + } + classes.push('collapsible'); + const key = getTimelineEntryKey(entry, index); + const expanded = !!state.expandedTimelineEntries[key]; + if (expanded) classes.push('expanded'); + const detailsHtml = details + .map((line) => `
  • ${renderInlineContent(line)}
  • `) + .join(''); + return ` +
    + +
    + + ${expanded ? `
      ${detailsHtml}
    ` : ''} +
    +
    + `; }).join('')}
    `; @@ -429,10 +461,24 @@ function renderContent(value) { } function bindTranscriptHandlers() { - // Transcript rows are static render output; no delegated actions required. + if (state.transcriptHandlersBound) return; + transcriptEl.addEventListener('click', (event) => { + const toggleBtn = event.target.closest('button[data-step-key]'); + if (!toggleBtn || !transcriptEl.contains(toggleBtn)) return; + const stepKey = toggleBtn.getAttribute('data-step-key'); + if (!stepKey) return; + const nextExpanded = !state.expandedTimelineEntries[stepKey]; + state.expandedTimelineEntries = { + ...state.expandedTimelineEntries, + [stepKey]: nextExpanded, + }; + const scrollTop = transcriptEl.scrollTop; + renderTranscript({ preserveScrollTop: scrollTop }); + }); + state.transcriptHandlersBound = true; } -function renderTranscript() { +function renderTranscript({ preserveScrollTop = null } = {}) { const messages = getActiveMessages(); const sessionId = state.value.activeSessionId; const sessionRunId = getSessionRunId(state.currentRunBySession, sessionId); @@ -491,7 +537,11 @@ function renderTranscript() { } bindTranscriptHandlers(); - transcriptEl.scrollTop = transcriptEl.scrollHeight; + if (Number.isFinite(preserveScrollTop)) { + transcriptEl.scrollTop = preserveScrollTop; + } else { + transcriptEl.scrollTop = transcriptEl.scrollHeight; + } syncStatusIndicator(); syncComposerState(); } diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index ed2622c..ee367ef 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -98,3 +98,9 @@ test('initial tab attach waits 2 seconds before attaching', () => { assert.match(fnBlock, /window\.setTimeout\(\(\)\s*=>\s*\{/); assert.match(fnBlock, /},\s*2000\)/); }); + +test('tool-call timeline entries render collapsed toggle rows with click-to-expand details', () => { + assert.match(js, /data-step-key=/); + assert.match(js, /class="step-details"/); + assert.match(js, /closest\('button\[data-step-key\]'\)/); +}); diff --git a/test/agent/sse-events.test.js b/test/agent/sse-events.test.js index 3244f14..5661388 100644 --- a/test/agent/sse-events.test.js +++ b/test/agent/sse-events.test.js @@ -120,6 +120,30 @@ test('run.event is converted into a visible in-flight step', () => { assert.match(last.label, /Planning skill invocation/); }); +test('run.event captures detail lines for collapsible tool-call rendering', () => { + const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); + const s2 = applyEvent(s1, { + event: 'run.event', + runId: 'r1', + sessionId: 's1', + payload: { + item: { + summary: 'Explored 2 files, 1 search', + text: 'Read chatd.js\nSearched for run.aborted\nRead sse-events.test.js', + }, + }, + }); + const lastStep = s2.runs.r1.steps.at(-1); + const lastTimeline = s2.runs.r1.timeline.at(-1); + assert.equal(lastStep?.label, 'Explored 2 files, 1 search'); + assert.deepEqual(lastStep?.details, [ + 'Read chatd.js', + 'Searched for run.aborted', + 'Read sse-events.test.js', + ]); + assert.deepEqual(lastTimeline?.details, lastStep?.details); +}); + test('run.usage stores normalized usage for run and session', () => { const s1 = applyEvent(baseState, { event: 'run.started', runId: 'r1', sessionId: 's1', payload: {} }); const s2 = applyEvent(s1, { From 807f8060ec4f7b4ce02f884f58afa5f3f38463c1 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Tue, 3 Mar 2026 18:19:57 +0530 Subject: [PATCH 141/192] sidepanel: restyle composer shell and simplify controls --- extension/agent-panel.css | 124 ++++++++++++++++-------- extension/agent-panel.html | 8 +- test/agent/agent-panel-contract.test.js | 9 ++ 3 files changed, 96 insertions(+), 45 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index fa82ecd..563f723 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -593,28 +593,53 @@ body { .composer-wrap { flex-shrink: 0; - background: #fff; - border-top: 1px solid var(--line); - padding: 10px 12px; + background: linear-gradient(180deg, rgba(18, 20, 25, 0) 0%, rgba(18, 20, 25, 0.9) 36%, rgba(18, 20, 25, 0.96) 100%); + border-top: 0; + padding: 12px; display: flex; flex-direction: column; gap: 6px; } .composer-box { - display: flex; - align-items: flex-end; - gap: 6px; - border: 1px solid var(--line); - border-radius: 12px; - background: var(--linen); - padding: 8px 8px 8px 12px; - transition: border-color 0.15s, box-shadow 0.15s; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 32px; + background: linear-gradient(180deg, #33363A 0%, #2A2D30 100%); + padding: 10px 12px; + min-height: 56px; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); + transition: border-color 0.16s, box-shadow 0.16s, min-height 0.16s, padding 0.16s; } .composer-box:focus-within { - border-color: var(--crail); - box-shadow: 0 0 0 3px rgba(193, 95, 60, 0.12); + border-color: rgba(255, 255, 255, 0.22); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.14), + 0 16px 34px rgba(0, 0, 0, 0.4); +} + +.composer-box.is-multiline { + align-items: stretch; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 138px; + padding-top: 14px; + padding-bottom: 12px; +} + +.composer-box.is-multiline .composer-textarea { + grid-column: 1 / -1; + grid-row: 1; + margin: 0 2px 2px; +} + +.composer-box.is-multiline .composer-actions { + grid-column: 2; + grid-row: 2; + align-self: end; } .composer-textarea { @@ -623,30 +648,40 @@ body { background: transparent; border: 0; outline: none; - font-size: 13.5px; + font-size: 15px; font-family: inherit; - color: var(--text); - line-height: 1.55; + color: rgba(255, 255, 255, 0.95); + line-height: 1.38; min-height: 22px; max-height: 160px; overflow-y: auto; + padding: 2px 0; } .composer-textarea::placeholder { - color: var(--text-subtle); + color: rgba(255, 255, 255, 0.56); +} + +.composer-textarea::-webkit-scrollbar { + width: 4px; +} + +.composer-textarea::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 999px; } .composer-actions { display: flex; - align-items: center; - gap: 4px; + align-items: flex-end; + gap: 6px; flex-shrink: 0; } .context-usage-note { font-size: 10px; line-height: 1.35; - color: var(--text-subtle); + color: rgba(255, 255, 255, 0.42); padding: 0 4px; white-space: nowrap; overflow: hidden; @@ -655,62 +690,69 @@ body { .btn-stop, .btn-send { - width: 32px; - height: 32px; - border-radius: 8px; + width: 44px; + height: 44px; + border-radius: 999px; border: 0; display: flex; align-items: center; justify-content: center; - transition: background 0.15s, opacity 0.15s, color 0.15s; + transition: background 0.15s, opacity 0.15s, color 0.15s, transform 0.15s; } .btn-stop { - background: transparent; - cursor: not-allowed; - color: var(--text-subtle); - opacity: 0.3; + background: rgba(255, 90, 90, 0.14); + cursor: pointer; + color: #FF5A5A; + opacity: 1; } .btn-stop.active { - cursor: pointer; - opacity: 1; - color: #EF4444; + background: rgba(255, 90, 90, 0.14); } .btn-stop.active:hover { - background: #FEF2F2; + background: rgba(255, 90, 90, 0.2); +} + +.btn-stop:disabled { + opacity: 0.5; + cursor: not-allowed; } .btn-stop svg { - width: 15px; - height: 15px; + width: 20px; + height: 20px; } .btn-send { - background: var(--crail); + background: #2B78F6; color: #fff; cursor: pointer; - box-shadow: 0 2px 6px rgba(193, 95, 60, 0.3); + box-shadow: 0 8px 18px rgba(43, 120, 246, 0.45); } .btn-send:hover:not(:disabled) { - background: var(--crail-dark); + background: #2269D9; + transform: translateY(-1px); } .btn-send:active:not(:disabled) { - background: var(--crail-press); + background: #1A5CC7; + transform: translateY(0); } .btn-send:disabled { - opacity: 0.35; + background: rgba(255, 255, 255, 0.18); + color: rgba(255, 255, 255, 0.62); + opacity: 1; cursor: not-allowed; box-shadow: none; } .btn-send svg { - width: 13px; - height: 13px; + width: 18px; + height: 18px; } .popover-backdrop { diff --git a/extension/agent-panel.html b/extension/agent-panel.html index e2355a5..53a2344 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -45,10 +45,10 @@
    - -
    -
    + +
    diff --git a/extension/popup.js b/extension/popup.js index 76d9905..1bb23c4 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -14,7 +14,7 @@ const RESTRICTION_LINES = { const statusEl = document.getElementById('bf-status'); const statusTextEl = document.getElementById('bf-status-text'); const mcpClientsEl = document.getElementById('bf-mcp-clients'); -const popupEl = document.querySelector('.bf-popup'); +const autoModeNoteEl = document.getElementById('bf-auto-mode-note'); const relayUrlInput = document.getElementById('bf-relay-url'); const saveUrlBtn = document.getElementById('bf-save-url'); const tabCountEl = document.getElementById('bf-tab-count'); @@ -63,7 +63,7 @@ chrome.storage.local.get(SETTINGS_KEYS, (s) => { noNewTabsCb.checked = !!s.noNewTabs; readOnlyCb.checked = !!s.readOnly; instructionsEl.value = s.userInstructions || ''; - setAutoModeBorder(s.mode || 'auto'); + setAutoModeState(s.mode || 'auto'); }); // --- Save Handlers --- @@ -108,7 +108,7 @@ saveUrlBtn.addEventListener('click', () => { modeSelect.addEventListener('change', () => { chrome.storage.local.set({ mode: modeSelect.value }); - setAutoModeBorder(modeSelect.value); + setAutoModeState(modeSelect.value); }); executionModeSelect.addEventListener('change', () => { @@ -199,6 +199,7 @@ openAgentBtn.addEventListener('click', async () => { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); await chrome.sidePanel.open({ windowId: tab?.windowId }); + window.close(); } catch { openAgentBtn.textContent = 'Failed to open'; setTimeout(() => { openAgentBtn.textContent = 'Open BrowserForce Agent'; }, 1500); @@ -223,7 +224,7 @@ function refreshStatus() { setTabs(response.tabs || []); setAutoTimer(response.nextAutoActionSecs); setMcpClientCount(response.mcpClientCount); - setAutoModeBorder(response.mode || modeSelect.value || 'auto'); + setAutoModeState(response.mode || modeSelect.value || 'auto'); }); } @@ -279,8 +280,9 @@ function setMcpClientCount(count) { mcpClientsEl.textContent = `MCP ${safeCount}`; } -function setAutoModeBorder(mode) { - popupEl.classList.toggle('auto-mode', mode === 'auto'); +function setAutoModeState(mode) { + if (!autoModeNoteEl) return; + autoModeNoteEl.hidden = mode !== 'auto'; } function escapeHtml(str) { diff --git a/test/agent/popup-contract.test.js b/test/agent/popup-contract.test.js index 7530b38..dd5e075 100644 --- a/test/agent/popup-contract.test.js +++ b/test/agent/popup-contract.test.js @@ -4,6 +4,8 @@ import assert from 'node:assert/strict'; const html = fs.readFileSync('extension/popup.html', 'utf8'); const optionsJs = fs.readFileSync('extension/options.js', 'utf8'); +const popupJs = fs.readFileSync('extension/popup.js', 'utf8'); +const popupCss = fs.readFileSync('extension/popup.css', 'utf8'); test('popup includes Open BrowserForce Agent button', () => { assert.match(html, /Open BrowserForce Agent/); @@ -13,3 +15,15 @@ test('logs viewer requests include extension identity header', () => { assert.match(optionsJs, /chrome\?\.runtime\?\.id/); assert.match(optionsJs, /'x-browserforce-extension-id'/); }); + +test('open agent action opens side panel and closes popup', () => { + assert.match(popupJs, /chrome\.sidePanel\.open\(/); + assert.match(popupJs, /window\.close\(\)/); +}); + +test('auto mode uses bottom note instead of dotted popup border', () => { + assert.match(html, /id="bf-auto-mode-note"/); + assert.match(html, /Auto mode is on\. The agent can automatically create tabs\./); + assert.match(popupCss, /\.auto-mode-note\s*\{/); + assert.equal(/\.bf-popup\.auto-mode\s*\{[\s\S]*dotted/.test(popupCss), false); +}); From a600deeb67b3574640de35963a1ba24b9cdfa332 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 16:00:59 +0530 Subject: [PATCH 159/192] popup: make auto-mode note single-line with NOTE prefix and full-width bottom bars --- extension/popup.css | 32 +++++++++++++++++++++++++++---- extension/popup.html | 2 +- test/agent/popup-contract.test.js | 6 +++++- 3 files changed, 34 insertions(+), 6 deletions(-) diff --git a/extension/popup.css b/extension/popup.css index 33eb84f..582cbe3 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -409,10 +409,34 @@ textarea:focus { } .auto-mode-note { - margin-top: 10px; - padding-top: 8px; - border-top: 1px solid var(--bf-border-soft); + margin: 10px -16px -16px; + padding: 8px 16px 12px; font-size: 11px; color: var(--bf-text-subtle); - line-height: 1.35; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + position: relative; + background: var(--bf-surface-soft); +} + +.auto-mode-note::before, +.auto-mode-note::after { + content: ''; + position: absolute; + left: 0; + right: 0; +} + +.auto-mode-note::before { + bottom: 2px; + height: 1px; + background: var(--bf-danger-fg); +} + +.auto-mode-note::after { + bottom: 0; + height: 2px; + background: var(--bf-accent); } diff --git a/extension/popup.html b/extension/popup.html index c10f1d9..bd02cc3 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -124,7 +124,7 @@

    BrowserForce

    - + diff --git a/test/agent/popup-contract.test.js b/test/agent/popup-contract.test.js index dd5e075..21376f8 100644 --- a/test/agent/popup-contract.test.js +++ b/test/agent/popup-contract.test.js @@ -23,7 +23,11 @@ test('open agent action opens side panel and closes popup', () => { test('auto mode uses bottom note instead of dotted popup border', () => { assert.match(html, /id="bf-auto-mode-note"/); - assert.match(html, /Auto mode is on\. The agent can automatically create tabs\./); + assert.match(html, /NOTE:\s*Auto mode is on\./); assert.match(popupCss, /\.auto-mode-note\s*\{/); + assert.match(popupCss, /white-space:\s*nowrap/); + assert.match(popupCss, /margin:\s*10px\s+-16px\s+-16px/); + assert.match(popupCss, /\.auto-mode-note::before[\s\S]*background:\s*var\(--bf-danger-fg\)/); + assert.match(popupCss, /\.auto-mode-note::after[\s\S]*background:\s*var\(--bf-accent\)/); assert.equal(/\.bf-popup\.auto-mode\s*\{[\s\S]*dotted/.test(popupCss), false); }); From a935a80484353a6ca8d6a850a5738376b49a416e Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 16:04:30 +0530 Subject: [PATCH 160/192] popup: constrain auto-mode note text width with inner ellipsis --- extension/popup.css | 10 ++++++++-- extension/popup.html | 4 +++- test/agent/popup-contract.test.js | 5 ++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/extension/popup.css b/extension/popup.css index 582cbe3..ad09547 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -43,6 +43,7 @@ body { .bf-popup { width: 320px; padding: 16px; + overflow-x: hidden; } header { @@ -414,11 +415,16 @@ textarea:focus { font-size: 11px; color: var(--bf-text-subtle); line-height: 1.2; + position: relative; + background: var(--bf-surface-soft); +} + +.auto-mode-note-text { + display: block; + max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - position: relative; - background: var(--bf-surface-soft); } .auto-mode-note::before, diff --git a/extension/popup.html b/extension/popup.html index bd02cc3..93dddad 100644 --- a/extension/popup.html +++ b/extension/popup.html @@ -124,7 +124,9 @@

    BrowserForce

    - + diff --git a/test/agent/popup-contract.test.js b/test/agent/popup-contract.test.js index 21376f8..5bd9711 100644 --- a/test/agent/popup-contract.test.js +++ b/test/agent/popup-contract.test.js @@ -23,9 +23,12 @@ test('open agent action opens side panel and closes popup', () => { test('auto mode uses bottom note instead of dotted popup border', () => { assert.match(html, /id="bf-auto-mode-note"/); + assert.match(html, /class="auto-mode-note-text"/); assert.match(html, /NOTE:\s*Auto mode is on\./); assert.match(popupCss, /\.auto-mode-note\s*\{/); - assert.match(popupCss, /white-space:\s*nowrap/); + assert.match(popupCss, /\.auto-mode-note-text\s*\{/); + assert.match(popupCss, /\.auto-mode-note-text[\s\S]*max-width:\s*100%/); + assert.match(popupCss, /\.auto-mode-note-text[\s\S]*white-space:\s*nowrap/); assert.match(popupCss, /margin:\s*10px\s+-16px\s+-16px/); assert.match(popupCss, /\.auto-mode-note::before[\s\S]*background:\s*var\(--bf-danger-fg\)/); assert.match(popupCss, /\.auto-mode-note::after[\s\S]*background:\s*var\(--bf-accent\)/); From 9a1a3a52e57a184b5e2b2d6d8437d0f96da36597 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 16:07:26 +0530 Subject: [PATCH 161/192] popup: fit auto-mode note text to one line without ellipsis --- extension/popup.css | 6 +++--- extension/popup.js | 34 ++++++++++++++++++++++++++++++- test/agent/popup-contract.test.js | 6 +++++- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/extension/popup.css b/extension/popup.css index ad09547..ab57010 100644 --- a/extension/popup.css +++ b/extension/popup.css @@ -421,10 +421,10 @@ textarea:focus { .auto-mode-note-text { display: block; - max-width: 100%; + width: 100%; + font-size: 11px; + line-height: 1.2; white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; } .auto-mode-note::before, diff --git a/extension/popup.js b/extension/popup.js index 1bb23c4..a0d4bee 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -15,6 +15,7 @@ const statusEl = document.getElementById('bf-status'); const statusTextEl = document.getElementById('bf-status-text'); const mcpClientsEl = document.getElementById('bf-mcp-clients'); const autoModeNoteEl = document.getElementById('bf-auto-mode-note'); +const autoModeNoteTextEl = autoModeNoteEl?.querySelector('.auto-mode-note-text') || null; const relayUrlInput = document.getElementById('bf-relay-url'); const saveUrlBtn = document.getElementById('bf-save-url'); const tabCountEl = document.getElementById('bf-tab-count'); @@ -280,9 +281,37 @@ function setMcpClientCount(count) { mcpClientsEl.textContent = `MCP ${safeCount}`; } +function fitAutoModeNoteText() { + if (!autoModeNoteEl || !autoModeNoteTextEl || autoModeNoteEl.hidden) return; + const maxSizePx = 11; + const minSizePx = 8; + let size = maxSizePx; + autoModeNoteTextEl.style.fontSize = `${size}px`; + autoModeNoteTextEl.style.letterSpacing = ''; + + let safety = 0; + while ( + size > minSizePx + && autoModeNoteTextEl.scrollWidth > autoModeNoteTextEl.clientWidth + && safety < 24 + ) { + size -= 0.25; + autoModeNoteTextEl.style.fontSize = `${size}px`; + safety += 1; + } + + if (autoModeNoteTextEl.scrollWidth > autoModeNoteTextEl.clientWidth) { + autoModeNoteTextEl.style.letterSpacing = '-0.02em'; + } +} + function setAutoModeState(mode) { if (!autoModeNoteEl) return; - autoModeNoteEl.hidden = mode !== 'auto'; + const showAutoModeNote = mode === 'auto'; + autoModeNoteEl.hidden = !showAutoModeNote; + if (showAutoModeNote) { + window.requestAnimationFrame(fitAutoModeNoteText); + } } function escapeHtml(str) { @@ -293,3 +322,6 @@ function escapeHtml(str) { refreshStatus(); setInterval(refreshStatus, 1000); +window.addEventListener('resize', () => { + window.requestAnimationFrame(fitAutoModeNoteText); +}); diff --git a/test/agent/popup-contract.test.js b/test/agent/popup-contract.test.js index 5bd9711..72cd6f5 100644 --- a/test/agent/popup-contract.test.js +++ b/test/agent/popup-contract.test.js @@ -27,8 +27,12 @@ test('auto mode uses bottom note instead of dotted popup border', () => { assert.match(html, /NOTE:\s*Auto mode is on\./); assert.match(popupCss, /\.auto-mode-note\s*\{/); assert.match(popupCss, /\.auto-mode-note-text\s*\{/); - assert.match(popupCss, /\.auto-mode-note-text[\s\S]*max-width:\s*100%/); + assert.match(popupCss, /\.auto-mode-note-text[\s\S]*width:\s*100%/); assert.match(popupCss, /\.auto-mode-note-text[\s\S]*white-space:\s*nowrap/); + assert.equal(/\.auto-mode-note-text[\s\S]*text-overflow:\s*ellipsis/.test(popupCss), false); + assert.match(popupJs, /function\s+fitAutoModeNoteText\(/); + assert.match(popupJs, /scrollWidth\s*>\s*autoModeNoteTextEl\.clientWidth/); + assert.match(popupJs, /requestAnimationFrame\(fitAutoModeNoteText\)/); assert.match(popupCss, /margin:\s*10px\s+-16px\s+-16px/); assert.match(popupCss, /\.auto-mode-note::before[\s\S]*background:\s*var\(--bf-danger-fg\)/); assert.match(popupCss, /\.auto-mode-note::after[\s\S]*background:\s*var\(--bf-accent\)/); From d2f2b1321a05c9badbf390383bd62c9be8cf25d8 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 16:41:01 +0530 Subject: [PATCH 162/192] agent: label BrowserForce execute/reset tool steps --- agent/src/chatd.js | 101 +++++++++++++++++++++++++++------ extension/agent-panel-state.js | 101 +++++++++++++++++++++++++++------ test/agent/chatd-api.test.js | 1 + test/agent/sse-events.test.js | 3 +- 4 files changed, 169 insertions(+), 37 deletions(-) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index 3696876..2cfdea4 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -294,6 +294,63 @@ function firstString(values) { return ''; } +function isBrowserForceExecutePayload(payload = {}) { + const name = String(firstString([ + payload.name, + payload.toolName, + payload.tool, + ]) || '').trim().toLowerCase(); + + if (name === 'browserforce:execute' || name === 'mcp__browserforce__execute') return true; + if (name !== 'execute') return false; + + const args = payload?.args && typeof payload.args === 'object' ? payload.args : null; + if (args && typeof args.code === 'string') return true; + if (typeof payload.code === 'string') return true; + + const rawArgs = String(firstString([payload.arguments, payload.input]) || '').trim(); + return /"code"\s*:/.test(rawArgs); +} + +function isBrowserForceResetPayload(payload = {}) { + const name = String(firstString([ + payload.name, + payload.toolName, + payload.tool, + ]) || '').trim().toLowerCase(); + + if (name === 'browserforce:reset' || name === 'mcp__browserforce__reset') return true; + if (name !== 'reset') return false; + + const args = payload?.args && typeof payload.args === 'object' ? payload.args : null; + if (args && Object.keys(args).length > 0) return false; + + const rawArgs = String(firstString([payload.arguments, payload.input]) || '').trim(); + return !rawArgs || rawArgs === '{}' || rawArgs === 'null'; +} + +function normalizeToolLabel(label, payload = {}) { + const raw = String(label || '').trim(); + if (!raw) return ''; + const normalized = raw.toLowerCase(); + + if ( + isBrowserForceExecutePayload(payload) + && (normalized === 'execute' || normalized === 'mcp__browserforce__execute' || normalized === 'browserforce:execute') + ) { + return 'BrowserForce:execute'; + } + + if ( + isBrowserForceResetPayload(payload) + && (normalized === 'reset' || normalized === 'mcp__browserforce__reset' || normalized === 'browserforce:reset') + ) { + return 'BrowserForce:reset'; + } + + return raw; +} + const SHELL_LC_WRAPPER_RE = /^(?:\/usr\/bin\/env\s+)?(?:\/bin\/)?(?:zsh|bash|sh)\s+-lc\s+([\s\S]+)$/i; function unwrapShellLcCommand(value) { @@ -578,26 +635,21 @@ function syncFinalTextToRunTimeline(run, finalText) { function stepLabelForToolEvent(evt) { const payload = evt?.payload || {}; + const toolLabel = normalizeToolLabel(firstString([ + payload.command, + payload.title, + payload.name, + payload.tool, + payload.toolName, + ]), payload); if (evt.event === 'tool.started') { - return firstString([ - payload.command, - payload.title, - payload.name, - payload.tool, - payload.toolName, - ]) || 'Tool call started'; + return toolLabel || 'Tool call started'; } if (evt.event === 'tool.final') { - return firstString([ - payload.command, - payload.title, - payload.name, - payload.tool, - payload.toolName, - ]) || 'Tool call completed'; + return toolLabel || 'Tool call completed'; } if (evt.event === 'tool.delta') { - return firstString([ + return normalizeToolLabel(firstString([ payload.text, payload.message, payload.delta, @@ -606,7 +658,7 @@ function stepLabelForToolEvent(evt) { payload.tool, payload.toolName, payload.type === 'reasoning' ? 'Reasoning' : '', - ]) || 'Working...'; + ]), payload) || 'Working...'; } return ''; } @@ -675,7 +727,7 @@ function stepKindForRunEvent(evt) { function stepLabelForRunEvent(evt) { const payload = evt?.payload || {}; const item = payload?.item && typeof payload.item === 'object' ? payload.item : {}; - return firstString([ + const label = firstString([ payload.title, payload.message, payload.text, @@ -689,7 +741,20 @@ function stepLabelForRunEvent(evt) { item.command, item.type ? humanizeToken(item.type) : '', payload.type ? humanizeToken(payload.type) : '', - ]) || 'Working...'; + ]); + + const normalized = normalizeToolLabel(label, { + ...payload, + ...item, + name: firstString([item.name, payload.name]), + toolName: firstString([item.toolName, payload.toolName]), + tool: firstString([item.tool, payload.tool]), + args: item.args || payload.args, + arguments: firstString([item.arguments, payload.arguments]), + input: item.input || payload.input, + code: firstString([item.code, payload.code]), + }); + return normalized || 'Working...'; } function stepKeyForRunEvent(evt) { diff --git a/extension/agent-panel-state.js b/extension/agent-panel-state.js index c13c0f5..50d376e 100644 --- a/extension/agent-panel-state.js +++ b/extension/agent-panel-state.js @@ -13,6 +13,63 @@ function firstString(values) { return ''; } +function isBrowserForceExecutePayload(payload = {}) { + const name = String(firstString([ + payload.name, + payload.toolName, + payload.tool, + ]) || '').trim().toLowerCase(); + + if (name === 'browserforce:execute' || name === 'mcp__browserforce__execute') return true; + if (name !== 'execute') return false; + + const args = payload?.args && typeof payload.args === 'object' ? payload.args : null; + if (args && typeof args.code === 'string') return true; + if (typeof payload.code === 'string') return true; + + const rawArgs = String(firstString([payload.arguments, payload.input]) || '').trim(); + return /"code"\s*:/.test(rawArgs); +} + +function isBrowserForceResetPayload(payload = {}) { + const name = String(firstString([ + payload.name, + payload.toolName, + payload.tool, + ]) || '').trim().toLowerCase(); + + if (name === 'browserforce:reset' || name === 'mcp__browserforce__reset') return true; + if (name !== 'reset') return false; + + const args = payload?.args && typeof payload.args === 'object' ? payload.args : null; + if (args && Object.keys(args).length > 0) return false; + + const rawArgs = String(firstString([payload.arguments, payload.input]) || '').trim(); + return !rawArgs || rawArgs === '{}' || rawArgs === 'null'; +} + +function normalizeToolLabel(label, payload = {}) { + const raw = String(label || '').trim(); + if (!raw) return ''; + const normalized = raw.toLowerCase(); + + if ( + isBrowserForceExecutePayload(payload) + && (normalized === 'execute' || normalized === 'mcp__browserforce__execute' || normalized === 'browserforce:execute') + ) { + return 'BrowserForce:execute'; + } + + if ( + isBrowserForceResetPayload(payload) + && (normalized === 'reset' || normalized === 'mcp__browserforce__reset' || normalized === 'browserforce:reset') + ) { + return 'BrowserForce:reset'; + } + + return raw; +} + const SHELL_LC_WRAPPER_RE = /^(?:\/usr\/bin\/env\s+)?(?:\/bin\/)?(?:zsh|bash|sh)\s+-lc\s+([\s\S]+)$/i; function unwrapShellLcCommand(value) { @@ -335,26 +392,21 @@ function applyFinalTextToTimeline(run, finalText) { function stepLabelForToolEvent(evt) { const payload = evt?.payload || {}; + const toolLabel = normalizeToolLabel(firstString([ + payload.command, + payload.title, + payload.name, + payload.tool, + payload.toolName, + ]), payload); if (evt.event === 'tool.started') { - return firstString([ - payload.command, - payload.title, - payload.name, - payload.tool, - payload.toolName, - ]) || 'Tool call started'; + return toolLabel || 'Tool call started'; } if (evt.event === 'tool.final') { - return firstString([ - payload.command, - payload.title, - payload.name, - payload.tool, - payload.toolName, - ]) || 'Tool call completed'; + return toolLabel || 'Tool call completed'; } if (evt.event === 'tool.delta') { - return firstString([ + return normalizeToolLabel(firstString([ payload.text, payload.message, payload.delta, @@ -363,7 +415,7 @@ function stepLabelForToolEvent(evt) { payload.tool, payload.toolName, payload.type === 'reasoning' ? 'Reasoning' : '', - ]) || 'Working...'; + ]), payload) || 'Working...'; } return ''; } @@ -432,7 +484,7 @@ function stepKindForRunEvent(evt) { function stepLabelForRunEvent(evt) { const payload = evt?.payload || {}; const item = payload?.item && typeof payload.item === 'object' ? payload.item : {}; - return firstString([ + const label = firstString([ payload.title, payload.message, payload.text, @@ -446,7 +498,20 @@ function stepLabelForRunEvent(evt) { item.command, item.type ? humanizeToken(item.type) : '', payload.type ? humanizeToken(payload.type) : '', - ]) || 'Working...'; + ]); + + const normalized = normalizeToolLabel(label, { + ...payload, + ...item, + name: firstString([item.name, payload.name]), + toolName: firstString([item.toolName, payload.toolName]), + tool: firstString([item.tool, payload.tool]), + args: item.args || payload.args, + arguments: firstString([item.arguments, payload.arguments]), + input: item.input || payload.input, + code: firstString([item.code, payload.code]), + }); + return normalized || 'Working...'; } function stepKeyForRunEvent(evt) { diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index 27d56cc..7221b1b 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -338,6 +338,7 @@ test('POST /v1/runs persists execute tool details for collapsible timeline rows' const assistant = (messagesBody.messages || []).at(-1); const executeStep = (assistant?.timeline || []).find((item) => item?.type === 'step' && /execute/i.test(item?.label || '')); + assert.equal(executeStep?.label, 'BrowserForce:execute'); assert.equal(Array.isArray(executeStep?.details), true); assert.equal(executeStep.details.some((line) => /snapshot/.test(line)), true); } finally { diff --git a/test/agent/sse-events.test.js b/test/agent/sse-events.test.js index e2596aa..1e52e2b 100644 --- a/test/agent/sse-events.test.js +++ b/test/agent/sse-events.test.js @@ -183,6 +183,7 @@ test('execute tool step captures code details for collapsible rendering', () => }); const step = s2.runs.r1.steps.find((item) => /execute/i.test(item?.label || '')); + assert.equal(step?.label, 'BrowserForce:execute'); assert.deepEqual(step?.details, [ 'const rows = await snapshot();', 'return rows;', @@ -293,7 +294,7 @@ test('run.event extracts execute code from nested item input for collapsible det }); const step = (s2.runs.r1.steps || []).find((item) => item?.key === 'tool:item_2'); - assert.equal(step?.label, 'execute'); + assert.equal(step?.label, 'BrowserForce:execute'); assert.deepEqual(step?.details, [ 'const rows = await snapshot();', 'return rows;', From 8924417cac62abc78a47f66ae0da976855d06c45 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 16:41:05 +0530 Subject: [PATCH 163/192] google-sheets: add summary-first helper and workflow guidance --- README.md | 2 +- docs/google-sheets-issues.md | 6 + plugins/official/google-sheets/SKILL.md | 40 ++++-- plugins/official/google-sheets/index.js | 156 ++++++++++++++++++------ 4 files changed, 152 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 9948bc9..7e5539d 100644 --- a/README.md +++ b/README.md @@ -445,7 +445,7 @@ That's it. Restart MCP (or Claude Desktop) and `highlight()` is available in eve | Plugin | What it adds | Install | | ----------- | ---------------------------------------------------------------------------------------------- | --------------------------------------- | | `highlight` | `highlight(selector, color?)` — outlines matching elements; `clearHighlights()` — removes them | `browserforce plugin install highlight` | -| `google-sheets` | `gsReadContiguousRows()`; `gsFormatBulletsInRange()`; `gsSplitBulletsInRange()`; `gsRebalanceBoldInRange()`; `gsLogIssue()` | `browserforce plugin install google-sheets` | +| `google-sheets` | `gsSummarizeSheet()`; `gsReadContiguousRows()`; `gsFormatBulletsInRange()`; `gsSplitBulletsInRange()`; `gsRebalanceBoldInRange()`; `gsLogIssue()` | `browserforce plugin install google-sheets` | | `openclaw` | OpenClaw-specific BrowserForce tab policy (skill text only, no helper functions) | Auto-installed by `browserforce setup openclaw` | diff --git a/docs/google-sheets-issues.md b/docs/google-sheets-issues.md index 64dcf12..1a0325c 100644 --- a/docs/google-sheets-issues.md +++ b/docs/google-sheets-issues.md @@ -35,3 +35,9 @@ Use this format for each new entry: - Root cause: Feature work started before surveying existing Claude and MCP Google Sheets solutions. - Fix: Added a mandatory pre-build lookup step against official docs + known MCP repositories. - Rule: Before expanding Sheets automation behavior, check official support and existing MCP implementations. + +## 2026-03-04 — [SUMMARY] Export Drift During Simple Read Requests +- Symptom: Agent attempted gviz/CSV export and extra-tab fetch flows when the user only asked for a page summary. +- Root cause: Skill guidance did not enforce a summary-first path for Google Sheets and lacked anti-export guardrails. +- Fix: Added `gsSummarizeSheet()` helper plus strict skill rules to summarize directly from active-sheet helpers first. +- Rule: For "summarize/read this sheet" requests, use helper-driven page reads and answer directly before any export path. diff --git a/plugins/official/google-sheets/SKILL.md b/plugins/official/google-sheets/SKILL.md index e96f2c9..82a0fca 100644 --- a/plugins/official/google-sheets/SKILL.md +++ b/plugins/official/google-sheets/SKILL.md @@ -1,46 +1,64 @@ ## google-sheets plugin -Use Google Sheets helpers when work involves reading or structuring sheet content reliably without guesswork. +Use Google Sheets helpers when work involves reading, summarizing, or structuring sheet content from the active page without guesswork. + +Tool naming note: +- The same browser tool may appear as `execute` or `BrowserForce:execute`. +- Treat both labels as the same BrowserForce execution path. Available helpers: - `gsGetMeta()` → current spreadsheet id + gid + title + URL - `gsGotoCell(cellRef)` → jump to a cell using the Sheets name box - `gsReadCell(cellRef, options?)` → read cell text through the in-cell editor - `gsReadContiguousRows(options?)` → detect used rows without hard-scanning arbitrary ranges +- `gsSummarizeSheet(options?)` → one-call summary payload (sheet meta + scan stats + preview rows) - `gsSplitBulletsInRange(rangeRef, options?)` → replace in-cell bullet separators with real new lines - `gsRebalanceBoldInRange(rangeRef, options?)` → sparse bolding (default: max 1 bold segment per line) - `gsFormatBulletsInRange(rangeRef, options?)` → split bullets + rebalance bold in one pass - `gsLogIssue(summary, details?, options?)` → append a JSONL issue entry - `gsIssueLogPath()` → return default issue log path +## Summary-First Workflow (Default) + +When the user says "summarize this page/sheet", "read this sheet", or equivalent: +- Use `gsSummarizeSheet()` first. +- Answer directly from returned `preview` rows. +- Include `scannedRows`, `usedRowCount`, and `stopReason` in the summary. +- Ask a focused follow-up only when `usedRowCount === 0` or the user asks for a wider range. + ## Reliability Rules - Never hardcode long row scans (`1..80`, `1..200`) when structure is contiguous. - Use `gsReadContiguousRows({ columns: ['A','B'], startRow: 1, maxRows: 30, emptyStreakStop: 2 })`. - Always report `scannedRows`, `usedRowCount`, and `stopReason` when summarizing extraction. +- For summary requests, prefer `gsSummarizeSheet()` over ad-hoc DOM probing loops. - Prefer `gsFormatBulletsInRange()` for multi-cell content cleanup tasks. - Use `dryRun: true` first for formatting helpers when changing many cells. - Log every process failure or unexpected behavior with `gsLogIssue(...)`. -## Example: Read Guidelines Table +## Guardrails (Google Sheets) + +- Do not switch to `/export`, `/gviz`, CSV downloads, or out-of-page fetch flows unless the user explicitly asks for export data. +- Do not open extra tabs for summary-only requests. +- Do not infer cell content from toolbar/status text when table rows are available via helpers. + +## Example: One-Shot Summary ```js -const meta = await gsGetMeta(); -const result = await gsReadContiguousRows({ - columns: ['A', 'B'], +const result = await gsSummarizeSheet({ startRow: 1, maxRows: 30, - emptyStreakStop: 2 + previewRows: 8 }); return { - sheet: meta, + sheet: result.sheet, scan: { - scannedRows: result.scannedRows, - usedRowCount: result.usedRowCount, - stopReason: result.stopReason + scannedRows: result.scan.scannedRows, + usedRowCount: result.scan.usedRowCount, + stopReason: result.scan.stopReason }, - rows: result.rows + preview: result.preview }; ``` diff --git a/plugins/official/google-sheets/index.js b/plugins/official/google-sheets/index.js index 750173a..1ede7cd 100644 --- a/plugins/official/google-sheets/index.js +++ b/plugins/official/google-sheets/index.js @@ -421,6 +421,96 @@ function hasData(cells) { return Object.values(cells).some((value) => Boolean(String(value || '').trim())); } +async function scanContiguousRows(page, options = {}) { + const columns = normalizeColumns(options.columns || ['A', 'B']); + const startRow = Number.isInteger(options.startRow) && options.startRow > 0 ? options.startRow : 1; + const maxRows = Number.isInteger(options.maxRows) && options.maxRows > 0 + ? options.maxRows + : DEFAULT_SCAN_MAX_ROWS; + const emptyStreakStop = Number.isInteger(options.emptyStreakStop) && options.emptyStreakStop > 0 + ? options.emptyStreakStop + : DEFAULT_EMPTY_STREAK_STOP; + + const rows = []; + let scannedRows = 0; + let seenData = false; + let emptyStreak = 0; + let stopReason = 'max_rows_reached'; + + for (let i = 0; i < maxRows; i += 1) { + const row = startRow + i; + const cells = await readRow(page, row, columns, options); + scannedRows += 1; + + if (hasData(cells)) { + rows.push({ row, cells }); + seenData = true; + emptyStreak = 0; + continue; + } + + if (seenData) { + emptyStreak += 1; + if (emptyStreak >= emptyStreakStop) { + stopReason = 'empty_streak_stop'; + break; + } + } + } + + return { + rows, + scannedRows, + usedRowCount: rows.length, + stopReason, + config: { columns, startRow, maxRows, emptyStreakStop }, + }; +} + +async function inferColumnsFromHeaderRow(page, options = {}) { + const startRow = Number.isInteger(options.startRow) && options.startRow > 0 ? options.startRow : 1; + const maxColumns = Number.isInteger(options.maxColumns) && options.maxColumns > 0 + ? options.maxColumns + : 8; + const emptyColumnStreakStop = Number.isInteger(options.emptyColumnStreakStop) && options.emptyColumnStreakStop > 0 + ? options.emptyColumnStreakStop + : 1; + const fallbackColumnsCount = Number.isInteger(options.fallbackColumnsCount) && options.fallbackColumnsCount > 0 + ? options.fallbackColumnsCount + : 2; + const startColumn = normalizeColumns([options.startColumn || 'A'])[0]; + const startColIdx = columnToIndex(startColumn); + + const columns = []; + let seenData = false; + let emptyStreak = 0; + + for (let i = 0; i < maxColumns; i += 1) { + const col = indexToColumn(startColIdx + i); + const { value } = await readCell(page, `${col}${startRow}`, options); + const nonEmpty = Boolean(String(value || '').trim()); + if (nonEmpty) { + columns.push(col); + seenData = true; + emptyStreak = 0; + continue; + } + if (seenData) { + emptyStreak += 1; + if (emptyStreak >= emptyColumnStreakStop) break; + } + } + + if (columns.length > 0) return columns; + + const fallback = []; + const count = Math.min(Math.max(fallbackColumnsCount, 1), maxColumns); + for (let i = 0; i < count; i += 1) { + fallback.push(indexToColumn(startColIdx + i)); + } + return fallback; +} + export default { name: 'google-sheets', description: 'Google Sheets helpers for reliable row scanning, cell reads, and issue logging', @@ -447,49 +537,35 @@ export default { gsReadContiguousRows: async (page, ctx, state, options = {}) => { assertGoogleSheet(page, 'gsReadContiguousRows'); + return scanContiguousRows(page, options); + }, - const columns = normalizeColumns(options.columns || ['A', 'B']); - const startRow = Number.isInteger(options.startRow) && options.startRow > 0 ? options.startRow : 1; - const maxRows = Number.isInteger(options.maxRows) && options.maxRows > 0 - ? options.maxRows - : DEFAULT_SCAN_MAX_ROWS; - const emptyStreakStop = Number.isInteger(options.emptyStreakStop) && options.emptyStreakStop > 0 - ? options.emptyStreakStop - : DEFAULT_EMPTY_STREAK_STOP; - - const rows = []; - let scannedRows = 0; - let seenData = false; - let emptyStreak = 0; - let stopReason = 'max_rows_reached'; - - for (let i = 0; i < maxRows; i += 1) { - const row = startRow + i; - const cells = await readRow(page, row, columns, options); - scannedRows += 1; - - if (hasData(cells)) { - rows.push({ row, cells }); - seenData = true; - emptyStreak = 0; - continue; - } - - if (seenData) { - emptyStreak += 1; - if (emptyStreak >= emptyStreakStop) { - stopReason = 'empty_streak_stop'; - break; - } - } - } + gsSummarizeSheet: async (page, ctx, state, options = {}) => { + assertGoogleSheet(page, 'gsSummarizeSheet'); + const title = await page.title(); + const sheet = { ...parseSheetMeta(page.url()), title }; + const includeRows = options.includeRows === true; + const previewRows = Number.isInteger(options.previewRows) && options.previewRows > 0 ? options.previewRows : 8; + const columns = options.columns + ? normalizeColumns(options.columns) + : await inferColumnsFromHeaderRow(page, options); + const scanResult = await scanContiguousRows(page, { ...options, columns }); + const preview = scanResult.rows.slice(0, previewRows).map((entry) => ({ row: entry.row, cells: entry.cells })); + const firstDataRow = scanResult.rows[0] || null; + const headerCandidate = scanResult.rows.find((entry) => entry.row === scanResult.config.startRow) || null; return { - rows, - scannedRows, - usedRowCount: rows.length, - stopReason, - config: { columns, startRow, maxRows, emptyStreakStop }, + sheet, + columns, + scan: { + scannedRows: scanResult.scannedRows, + usedRowCount: scanResult.usedRowCount, + stopReason: scanResult.stopReason, + }, + firstDataRow: firstDataRow ? { row: firstDataRow.row, cells: firstDataRow.cells } : null, + headerCandidate: headerCandidate ? { row: headerCandidate.row, cells: headerCandidate.cells } : null, + preview, + ...(includeRows ? { rows: scanResult.rows } : {}), }; }, From a223e0d95c9085faf755099ac7b822c56fdd2ff0 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 20:11:40 +0530 Subject: [PATCH 164/192] agent-panel: animate active reasoning titles with shimmer and enter transition --- extension/agent-panel.css | 68 +++++++++++++++++++++++++ extension/agent-panel.js | 31 ++++++++++- test/agent/agent-panel-contract.test.js | 14 +++++ 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index a7074d7..4d1e19b 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -399,6 +399,45 @@ body { white-space: pre-wrap; } +.step-label.title-label { + font-size: 13px; + font-weight: 600; + color: var(--text); + line-height: 1.3; + letter-spacing: 0.01em; + transition: opacity 0.18s ease, transform 0.18s ease; +} + +.step-item:not(.latest) .step-label.title-label { + opacity: 0.84; +} + +.step-label.title-label.shimmer-text { + color: transparent; + -webkit-text-fill-color: transparent; + background-image: linear-gradient( + 95deg, + rgba(61, 48, 40, 0.45) 0%, + rgba(61, 48, 40, 0.45) 35%, + rgba(193, 95, 60, 0.96) 50%, + rgba(61, 48, 40, 0.45) 65%, + rgba(61, 48, 40, 0.45) 100% + ); + background-size: 220% 100%; + background-position: 110% 0; + -webkit-background-clip: text; + background-clip: text; + animation: reasoning-shimmer 2.3s ease-in-out infinite; +} + +.step-label.title-label.title-transition-in { + animation: reasoning-title-in 210ms ease-out; +} + +.step-label.title-label.shimmer-text.title-transition-in { + animation: reasoning-title-in 210ms ease-out, reasoning-shimmer 2.3s ease-in-out 150ms infinite; +} + .step-label strong { font-weight: 600; color: var(--text); @@ -989,3 +1028,32 @@ body { opacity: 0.65; } } + +@keyframes reasoning-shimmer { + 0% { + background-position: 110% 0; + } + 100% { + background-position: -110% 0; + } +} + +@keyframes reasoning-title-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .step-item.pulse .run-step-icon, + .step-label.title-label.shimmer-text, + .step-label.title-label.title-transition-in, + .step-label.title-label.shimmer-text.title-transition-in { + animation: none; + } +} diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 89c9401..30cb247 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -15,6 +15,7 @@ const state = { modelPresets: [{ value: null, label: 'Default' }], currentRunBySession: {}, expandedTimelineEntries: {}, + latestReasoningTitleByRun: {}, transcriptHandlersBound: false, initialTabAttachInFlight: false, initialTabAttachStarted: false, @@ -430,6 +431,21 @@ function getLatestInFlightTimelineStepIndex(run, timeline) { return -1; } +function shouldAnimateLatestReasoningTitle({ run, entry, isLatest, isRunningReasoning }) { + if (!isLatest || !isRunningReasoning) return false; + const runId = String(run?.runId || '').trim(); + if (!runId) return false; + const signature = `${String(entry?.key || '').trim()}::${String(entry?.label || '').trim()}`; + if (!signature || signature === '::') return false; + const previous = state.latestReasoningTitleByRun[runId]; + if (previous === signature) return false; + state.latestReasoningTitleByRun = { + ...state.latestReasoningTitleByRun, + [runId]: signature, + }; + return true; +} + function renderRunTimeline(run, fallbackText = '') { const timeline = normalizeRunTimeline(run, fallbackText); if (!timeline.length) return ''; @@ -450,16 +466,27 @@ function renderRunTimeline(run, fallbackText = '') { return `

    ${renderContent(entry.text || '')}

    `; } const status = entry?.status || 'running'; + const normalizedStatus = String(status || '').toLowerCase(); const icon = classifyRunStepIcon(entry); const isLatest = index === latestStepIndex; const shouldPulse = isLatest && status === 'running'; + const isReasoningTitle = String(entry?.kind || '').toLowerCase() === 'reasoning'; + const isRunningReasoning = isReasoningTitle && normalizedStatus === 'running'; + const labelClasses = ['step-label']; + if (isReasoningTitle) labelClasses.push('title-label'); + if (isRunningReasoning && isLatest) { + labelClasses.push('shimmer-text'); + if (shouldAnimateLatestReasoningTitle({ run, entry, isLatest, isRunningReasoning })) { + labelClasses.push('title-transition-in'); + } + } const details = Array.isArray(entry?.details) ? entry.details.filter(Boolean) : []; const isCollapsible = details.length > 0; const classes = ['step-item', 'timeline-step', escapeHtml(status)]; if (isLatest) classes.push('latest'); if (shouldPulse) classes.push('pulse'); if (!isCollapsible) { - return `
    ${renderInlineContent(entry.label || 'Step')}
    `; + return `
    ${renderInlineContent(entry.label || 'Step')}
    `; } classes.push('collapsible'); const key = getTimelineEntryKey(entry, index); @@ -473,7 +500,7 @@ function renderRunTimeline(run, fallbackText = '') {
    ${expanded ? `
      ${detailsHtml}
    ` : ''} diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index f0d8a64..45a65f2 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -4,6 +4,7 @@ import assert from 'node:assert/strict'; const html = fs.readFileSync('extension/agent-panel.html', 'utf8'); const css = fs.readFileSync('extension/agent-panel.css', 'utf8'); +const panelJs = fs.readFileSync('extension/agent-panel.js', 'utf8'); test('agent panel has inline model and session selectors with popovers', () => { assert.match(html, /id="bf-model-trigger"/); @@ -61,3 +62,16 @@ test('agent panel composer matches compact/expanded shell structure', () => { test('composer action buttons respect hidden attribute for send/stop swapping', () => { assert.match(css, /\.composer-actions button\[hidden\][\s\S]*display:\s*none/); }); + +test('reasoning title rows use shimmer and enter transition treatment', () => { + assert.match(panelJs, /shouldAnimateLatestReasoningTitle/); + assert.match(panelJs, /title-label/); + assert.match(panelJs, /shimmer-text/); + assert.match(panelJs, /title-transition-in/); + assert.match(css, /\.step-label\.title-label/); + assert.match(css, /\.step-label\.title-label\.shimmer-text/); + assert.match(css, /\.step-label\.title-label\.title-transition-in/); + assert.match(css, /@keyframes reasoning-shimmer/); + assert.match(css, /@keyframes reasoning-title-in/); + assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/); +}); From 7dde1fa0a20b7872ef308474ce56c2b5e4a9fd80 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 21:45:44 +0530 Subject: [PATCH 165/192] test: cover sheets cache invalidation helpers --- mcp/src/plugin-loader.js | 283 +++++++++++++++++++++++- mcp/test/exec-engine-plugins.test.js | 316 +++++++++++++++++++++++++++ 2 files changed, 593 insertions(+), 6 deletions(-) diff --git a/mcp/src/plugin-loader.js b/mcp/src/plugin-loader.js index 69e417d..130029d 100644 --- a/mcp/src/plugin-loader.js +++ b/mcp/src/plugin-loader.js @@ -5,6 +5,208 @@ import { homedir } from 'node:os'; export const PLUGINS_DIR = join(homedir(), '.browserforce', 'plugins'); +function stripWrappingQuotes(value) { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === '\'' && last === '\'')) { + return value.slice(1, -1); + } + } + return value; +} + +const CANONICAL_SKILL_META_KEYS = new Set([ + 'name', + 'description', + 'helpers', + 'tools', + 'when_to_use', +]); +const CANONICAL_SKILL_LIST_KEYS = new Set([ + 'helpers', + 'tools', + 'when_to_use', +]); + +function parseBlockScalarValue(lines, style) { + if (style === '|') { + return lines.join('\n').trimEnd(); + } + + // Minimal folded-scalar support for `>`: fold newlines into spaces. + return lines + .map((line) => line.trim()) + .join(' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function normalizeListItem(value) { + return stripWrappingQuotes(String(value || '').trim()); +} + +function parseInlineList(rawValue) { + const trimmed = String(rawValue || '').trim(); + if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { + return null; + } + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) { + return parsed.map(normalizeListItem).filter(Boolean); + } + } catch { /* fall back to scalar parsing */ } + return null; +} + +function normalizeMetaValue(key, value) { + const normalizedValue = typeof value === 'string' ? value.trim() : value; + if (!CANONICAL_SKILL_LIST_KEYS.has(key)) { + return normalizedValue; + } + + const inline = parseInlineList(normalizedValue); + if (inline) return inline; + if (typeof normalizedValue !== 'string') return []; + if (!normalizedValue) return []; + if (normalizedValue.includes(',')) { + return normalizedValue.split(',').map(normalizeListItem).filter(Boolean); + } + return [normalizeListItem(normalizedValue)].filter(Boolean); +} + +function parseSkillFrontmatter(rawSkill = '') { + const skillText = typeof rawSkill === 'string' ? rawSkill : ''; + + try { + if (!skillText.startsWith('---')) { + return { meta: {}, body: skillText }; + } + + const match = skillText.match(/^---\s*\r?\n([\s\S]*?)\r?\n---\s*(?:\r?\n)?([\s\S]*)$/); + if (!match) { + return { meta: {}, body: skillText }; + } + + const [, rawMeta, rawBody] = match; + const meta = {}; + const lines = rawMeta.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + + const keyMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)$/); + if (!keyMatch) continue; + + const key = keyMatch[1].trim().toLowerCase(); + const rawValue = keyMatch[2].trim(); + + if (rawValue === '|' || rawValue === '>') { + const blockLines = []; + let j = i + 1; + for (; j < lines.length; j++) { + const blockLine = lines[j]; + if (blockLine.trim() === '') { + blockLines.push(''); + continue; + } + if (!/^\s+/.test(blockLine)) { + break; + } + blockLines.push(blockLine.replace(/^\s+/, '')); + } + + i = j - 1; + if (!CANONICAL_SKILL_META_KEYS.has(key)) continue; + meta[key] = normalizeMetaValue(key, parseBlockScalarValue(blockLines, rawValue)); + continue; + } + + if (rawValue === '' && CANONICAL_SKILL_LIST_KEYS.has(key)) { + const listItems = []; + let j = i + 1; + for (; j < lines.length; j++) { + const listLine = lines[j]; + if (!listLine.trim()) continue; + if (!/^\s+/.test(listLine)) break; + const listMatch = listLine.match(/^\s*-\s+(.+)$/); + if (!listMatch) break; + listItems.push(normalizeListItem(listMatch[1])); + } + i = j - 1; + meta[key] = listItems.filter(Boolean); + continue; + } + + if (!CANONICAL_SKILL_META_KEYS.has(key)) continue; + meta[key] = normalizeMetaValue(key, stripWrappingQuotes(rawValue)); + } + + return { meta, body: rawBody }; + } catch { + return { meta: {}, body: skillText }; + } +} + +function normalizeMarkdownHeading(heading) { + return heading + .toLowerCase() + .trim() + .replace(/^[\d.)\s-]+/, '') + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractSkillSections(skillBody = '') { + const normalizedBody = typeof skillBody === 'string' ? skillBody.replace(/\r\n/g, '\n') : ''; + const sections = {}; + const headingEntries = []; + const lines = normalizedBody.split('\n'); + let offset = 0; + let activeFence = null; + + for (const line of lines) { + const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/); + if (fenceMatch) { + const fence = fenceMatch[1]; + if (!activeFence) { + activeFence = { char: fence[0], len: fence.length }; + } else if (fence[0] === activeFence.char && fence.length >= activeFence.len) { + activeFence = null; + } + } else if (!activeFence) { + const headingMatch = line.match(/^\s*##\s+(.+?)\s*$/); + if (headingMatch) { + headingEntries.push({ + headingText: String(headingMatch[1] || '').trim(), + lineStart: offset, + contentStart: offset + line.length + 1, + }); + } + } + offset += line.length + 1; + } + + if (headingEntries.length === 0) return sections; + + for (let i = 0; i < headingEntries.length; i++) { + const { headingText, contentStart } = headingEntries[i]; + const key = normalizeMarkdownHeading(headingText); + if (!key) continue; + const contentEnd = i + 1 < headingEntries.length ? headingEntries[i + 1].lineStart : normalizedBody.length; + const safeContentStart = Math.min(contentStart, normalizedBody.length); + const sectionBody = normalizedBody.slice(safeContentStart, contentEnd).trim(); + if (sectionBody) { + sections[key] = sectionBody; + } + } + + return sections; +} + /** * Scan pluginsDir for subfolders with index.js. Loads each as an ESM module. * @param {string} [pluginsDir] @@ -47,8 +249,15 @@ export async function loadPlugins(pluginsDir = PLUGINS_DIR) { try { skill = await readFile(join(pluginDir, 'SKILL.md'), 'utf8'); } catch { /* SKILL.md is optional */ } + const { meta: skillMeta, body: skillBody } = parseSkillFrontmatter(skill); - plugins.push({ ...plugin, _skill: skill, _dir: pluginDir }); + plugins.push({ + ...plugin, + _skill: skill, + _skillMeta: skillMeta, + _skillBody: skillBody, + _dir: pluginDir, + }); process.stderr.write(`[bf-plugins] Loaded plugin: ${plugin.name}\n`); } @@ -75,11 +284,73 @@ export function buildPluginHelpers(plugins) { /** * Build the SKILL.md appendix to append to the execute tool prompt. - * Only includes plugins that have non-empty SKILL.md content. + * Includes plugins that provide either non-empty SKILL.md content or parsed + * frontmatter metadata. */ export function buildPluginSkillAppendix(plugins) { - const sections = plugins - .filter(p => p._skill && p._skill.trim()) - .map(p => `\n\n═══ PLUGIN: ${p.name} ═══\n\n${p._skill.trim()}`); - return sections.join(''); + const lines = []; + lines.push('\n\n═══ PLUGINS (METADATA-ONLY) ═══'); + lines.push('Use pluginCatalog() for plugin metadata, then pluginHelp(name, section?) for details on demand.'); + + let included = 0; + for (const plugin of plugins) { + const skillBody = typeof plugin._skillBody === 'string' ? plugin._skillBody : plugin._skill; + const hasSkill = typeof skillBody === 'string' && skillBody.trim().length > 0; + const meta = plugin._skillMeta && typeof plugin._skillMeta === 'object' ? plugin._skillMeta : {}; + const hasMeta = Object.keys(meta).length > 0; + if (!hasSkill && !hasMeta) continue; + included += 1; + + const helperNames = Object.keys(plugin.helpers || {}); + const description = String(meta.description || '').trim() || 'No description provided'; + lines.push(''); + lines.push(`PLUGIN: ${plugin.name}`); + lines.push(`description: ${description}`); + lines.push(`helpers: ${helperNames.length ? helperNames.join(', ') : '(none)'}`); + } + + if (included === 0) { + lines.push('No plugin skills currently advertise metadata.'); + } + + return lines.join('\n'); +} + +export function buildPluginSkillRuntime(plugins) { + const catalog = []; + const byName = {}; + + for (const plugin of plugins) { + const normalizedName = String(plugin.name).toLowerCase(); + if (Object.prototype.hasOwnProperty.call(byName, normalizedName)) { + process.stderr.write( + `[bf-plugins] Duplicate plugin skill name after normalization: "${plugin.name}" conflicts with "${byName[normalizedName].name}" (key "${normalizedName}"). Keeping first.\n` + ); + continue; + } + + const helperNames = Object.keys(plugin.helpers || {}); + const meta = plugin._skillMeta && typeof plugin._skillMeta === 'object' ? plugin._skillMeta : {}; + const skillBody = (typeof plugin._skillBody === 'string' ? plugin._skillBody : plugin._skill || '').trim(); + const description = String(meta.description || '').trim() || ''; + const sections = extractSkillSections(skillBody); + const sectionNames = Object.keys(sections); + + catalog.push({ + name: plugin.name, + description: description || 'No description provided', + helpers: helperNames, + sections: sectionNames, + }); + + byName[normalizedName] = { + name: plugin.name, + description, + text: skillBody, + sections, + helpers: helperNames, + }; + } + + return { catalog, byName }; } diff --git a/mcp/test/exec-engine-plugins.test.js b/mcp/test/exec-engine-plugins.test.js index 71c3659..50fe705 100644 --- a/mcp/test/exec-engine-plugins.test.js +++ b/mcp/test/exec-engine-plugins.test.js @@ -80,6 +80,60 @@ function createPageMarkdownPage(content = 'Markdown content line', options = {}) }; } +function createGoogleSheetsMockPage(cellValues = {}) { + let activeRef = 'A1'; + let editorReadCount = 0; + + const page = { + isClosed: () => false, + url: () => 'https://docs.google.com/spreadsheets/d/test-sheet-id/edit#gid=1', + title: async () => 'Mock Sheet', + locator: (selector) => { + assert.equal(selector, '#t-name-box'); + return { + click: async () => {}, + fill: async (value) => { + activeRef = String(value || '').toUpperCase(); + }, + }; + }, + keyboard: { + press: async () => {}, + }, + waitForTimeout: async () => {}, + evaluate: async (fn, arg) => { + const source = String(fn); + if (arg && typeof arg === 'object' && typeof arg.textValue === 'string') { + cellValues[activeRef] = arg.textValue; + return { after: arg.textValue, lineCount: arg.textValue.split('\n').length }; + } + if (source.includes('createTreeWalker(editor, NodeFilter.SHOW_TEXT)')) { + const text = Object.prototype.hasOwnProperty.call(cellValues, activeRef) + ? String(cellValues[activeRef]) + : ''; + return { + text, + baseStyle: '', + boldRanges: [], + lineCount: text.split('\n').length, + }; + } + if (source.includes('#waffle-rich-text-editor')) { + editorReadCount += 1; + return Object.prototype.hasOwnProperty.call(cellValues, activeRef) + ? String(cellValues[activeRef]) + : ''; + } + throw new Error('Unexpected evaluate call in google-sheets mock'); + }, + }; + + return { + page, + getEditorReadCount: () => editorReadCount, + }; +} + test('plugin helpers are available in execute scope', async () => { const pluginHelpers = { myHelper: async (page, ctx, state, arg) => `result:${arg}`, @@ -90,6 +144,33 @@ test('plugin helpers are available in execute scope', async () => { assert.equal(result, 'result:hello'); }); +test('pluginCatalog and pluginHelp built-ins are available in execute scope', async () => { + const pluginSkillRuntime = { + catalog: [{ + name: 'tagger', + description: 'Tags elements quickly', + helpers: ['tagger'], + sections: ['examples'], + }], + byName: { + tagger: { + text: 'Use tagger() to tag.', + sections: { examples: '- tagger("hero")' }, + }, + }, + }; + + const ctx = buildExecContext(mockPage, mockCtx, {}, {}, {}, {}, {}, pluginSkillRuntime); + const catalog = await runCode('return pluginCatalog()', ctx, 5000); + assert.deepEqual(catalog, pluginSkillRuntime.catalog); + + const defaultHelp = await runCode('return pluginHelp("tagger")', ctx, 5000); + assert.equal(defaultHelp, 'Use tagger() to tag.'); + + const sectionHelp = await runCode('return pluginHelp("tagger", "examples")', ctx, 5000); + assert.equal(sectionHelp, '- tagger("hero")'); +}); + test('built-in helpers always win over plugin helpers with same name', async () => { const pluginHelpers = { snapshot: async () => 'fake-snapshot-string', // attempt to override @@ -102,6 +183,34 @@ test('built-in helpers always win over plugin helpers with same name', async () assert.notEqual(result, 'fake-snapshot-string'); }); +test('plugin helpers cannot override pluginCatalog/pluginHelp built-ins', async () => { + const pluginHelpers = { + pluginCatalog: async () => ['evil'], + pluginHelp: async () => 'evil-help', + }; + const pluginSkillRuntime = { + catalog: [{ name: 'safe', helpers: [], sections: [] }], + byName: { safe: { text: 'safe-help', sections: {} } }, + }; + + const ctx = buildExecContext( + mockPage, + mockCtx, + {}, + {}, + pluginHelpers, + {}, + {}, + pluginSkillRuntime, + ); + + const catalog = await runCode('return pluginCatalog()', ctx, 5000); + assert.deepEqual(catalog, pluginSkillRuntime.catalog); + + const help = await runCode('return pluginHelp("safe")', ctx, 5000); + assert.equal(help, 'safe-help'); +}); + test('plugin helper receives null page gracefully when no page open', async () => { const pluginHelpers = { safeHelper: async (page, ctx, state) => page === null ? 'no-page' : 'has-page', @@ -113,6 +222,213 @@ test('plugin helper receives null page gracefully when no page open', async () = assert.equal(result, 'no-page'); }); +test('gsSummarizeSheet reuses cached rows on repeated calls with same options', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + }); + const state = {}; + const options = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + const first = await summarize(page, null, state, options); + const readsAfterFirst = getEditorReadCount(); + assert.equal(first.scan.usedRowCount, 2); + assert.ok(readsAfterFirst > 0); + + const second = await summarize(page, null, state, options); + const readsAfterSecond = getEditorReadCount(); + assert.equal(second.scan.usedRowCount, 2); + assert.equal(readsAfterSecond, readsAfterFirst); +}); + +test('gsSummarizeSheet forceRefresh bypasses cache', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + }); + const state = {}; + const options = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, options); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, { ...options, forceRefresh: true }); + const readsAfterForceRefresh = getEditorReadCount(); + assert.ok(readsAfterForceRefresh > readsAfterFirst); +}); + +test('gsSummarizeSheet useCache false bypasses cache reads and writes', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + }); + const state = {}; + const options = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + useCache: false, + }; + + await summarize(page, null, state, options); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, options); + const readsAfterSecond = getEditorReadCount(); + assert.ok(readsAfterSecond > readsAfterFirst); +}); + +test('gsSplitBulletsInRange invalidates gsSummarizeSheet cache after real write', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const splitBullets = googleSheetsPlugin.helpers.gsSplitBulletsInRange; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + D2: 'Alpha - Beta', + }); + const state = {}; + const summarizeOptions = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, summarizeOptions); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterSecond = getEditorReadCount(); + assert.equal(readsAfterSecond, readsAfterFirst); + + const splitResult = await splitBullets(page, null, state, 'D2:D2', { + verify: false, + dryRun: false, + }); + assert.equal(splitResult.changed, 1); + + const readsAfterWrite = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterThird = getEditorReadCount(); + assert.ok(readsAfterThird > readsAfterWrite); +}); + +test('gsRebalanceBoldInRange invalidates gsSummarizeSheet cache after real write', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const rebalanceBold = googleSheetsPlugin.helpers.gsRebalanceBoldInRange; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + D2: 'Alpha Beta', + }); + const state = {}; + const summarizeOptions = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, summarizeOptions); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterSecond = getEditorReadCount(); + assert.equal(readsAfterSecond, readsAfterFirst); + + const rebalanceResult = await rebalanceBold(page, null, state, 'D2:D2', { + verify: false, + dryRun: false, + preferredPhrases: ['Alpha'], + }); + assert.equal(rebalanceResult.changed, 1); + + const readsAfterWrite = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterThird = getEditorReadCount(); + assert.ok(readsAfterThird > readsAfterWrite); +}); + +test('gsFormatBulletsInRange invalidates gsSummarizeSheet cache after real write', async () => { + const { default: googleSheetsPlugin } = await import('../../plugins/official/google-sheets/index.js'); + const summarize = googleSheetsPlugin.helpers.gsSummarizeSheet; + const formatBullets = googleSheetsPlugin.helpers.gsFormatBulletsInRange; + const { page, getEditorReadCount } = createGoogleSheetsMockPage({ + A1: 'Level', + B1: 'Expectation', + A2: 'Junior', + B2: 'Owns scoped tasks', + A3: '', + B3: '', + D2: 'Alpha - Beta', + }); + const state = {}; + const summarizeOptions = { + columns: ['A', 'B'], + startRow: 1, + maxRows: 6, + emptyStreakStop: 1, + previewRows: 2, + }; + + await summarize(page, null, state, summarizeOptions); + const readsAfterFirst = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterSecond = getEditorReadCount(); + assert.equal(readsAfterSecond, readsAfterFirst); + + const formatResult = await formatBullets(page, null, state, 'D2:D2', { + verify: false, + dryRun: false, + }); + assert.equal(formatResult.changed, 1); + + const readsAfterWrite = getEditorReadCount(); + await summarize(page, null, state, summarizeOptions); + const readsAfterThird = getEditorReadCount(); + assert.ok(readsAfterThird > readsAfterWrite); +}); + test('buildExecContext exposes screenshot and content helpers in execute scope', () => { const ctx = buildExecContext(mockPage, mockCtx, {}, {}, {}); assert.equal(typeof ctx.screenshotWithAccessibilityLabels, 'function'); From 99935a31e5bee8f8f37c68d83c27feb529d1ba88 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 21:59:56 +0530 Subject: [PATCH 166/192] agent-panel: show explicit relay/agent startup failure states - map /chatd-url bootstrap failures to specific startup issue codes (relay unreachable, extension disconnected, agent not running) - render a visible empty-state error card in the sidebar with actionable guidance and command hints - keep status-dot text in sync with mapped startup state while preserving existing ready/thinking flow - add contract tests for startup error mapping and error-state UI styling --- extension/agent-panel.css | 28 +++++ extension/agent-panel.js | 102 +++++++++++++++++-- test/agent/agent-panel-contract.test.js | 9 ++ test/agent/agent-panel-send-contract.test.js | 17 ++++ 4 files changed, 150 insertions(+), 6 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 4d1e19b..808bca6 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -213,6 +213,12 @@ body { padding: 40px 20px; } +.empty-state.error-state { + align-items: flex-start; + text-align: left; + gap: 10px; +} + .empty-icon { width: 40px; height: 40px; @@ -226,6 +232,10 @@ body { font-size: 18px; } +.empty-icon.error { + background: var(--error); +} + .empty-title { font-size: 14px; font-weight: 500; @@ -238,6 +248,24 @@ body { line-height: 1.5; } +.empty-command { + margin-top: 8px; +} + +.empty-command code { + display: inline-block; + background: var(--linen); + border: 1px solid var(--line); + border-radius: 8px; + color: var(--crail-dark); + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + font-size: 11px; + padding: 4px 8px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; +} + .message { display: flex; flex-direction: column; diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 30cb247..066498b 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -25,6 +25,7 @@ const state = { eventLoopToken: 0, sessionSelectionToken: 0, popover: 'none', + startupIssue: null, status: { kind: 'info', text: 'Starting...', @@ -157,6 +158,44 @@ function setStatus(kind, text) { syncStatusIndicator(); } +function normalizeStartupError(code = '', fallbackMessage = 'Unable to connect to BrowserForce Agent') { + const normalized = String(code || '').trim().toLowerCase(); + if (normalized === 'agent_not_running') { + return { + code: 'agent_not_running', + statusText: 'Agent not running', + title: 'BrowserForce Agent is not running', + detail: 'Relay is reachable, but the local agent daemon (chatd) is offline.', + command: 'browserforce agent start', + }; + } + if (normalized === 'extension_not_connected') { + return { + code: 'extension_not_connected', + statusText: 'Extension not connected', + title: 'Extension is not connected to relay', + detail: 'Open the BrowserForce extension popup and reconnect it to the relay.', + command: null, + }; + } + if (normalized === 'relay_unreachable') { + return { + code: 'relay_unreachable', + statusText: 'Relay unreachable', + title: 'Relay is not reachable', + detail: 'Start relay first, then retry opening this side panel.', + command: 'browserforce serve', + }; + } + return { + code: 'unknown', + statusText: 'Connection failed', + title: 'Unable to connect to BrowserForce Agent', + detail: fallbackMessage || 'Check relay and agent daemon status, then try again.', + command: null, + }; +} + function setComposerEnabled(enabled) { chatInputEl.disabled = !enabled; autoResizeInput(); @@ -579,6 +618,31 @@ function renderTranscript({ preserveScrollTop = null } = {}) { } if (!chunks.length) { + const startupIssue = state.startupIssue; + if (startupIssue) { + const commandHtml = startupIssue.command + ? `

    ${escapeHtml(startupIssue.command)}

    ` + : ''; + transcriptEl.innerHTML = ` +
    +
    !
    +
    +

    ${escapeHtml(startupIssue.title || 'Unable to connect')}

    +

    ${escapeHtml(startupIssue.detail || '')}

    + ${commandHtml} +
    +
    + `; + bindTranscriptHandlers(); + if (Number.isFinite(preserveScrollTop)) { + transcriptEl.scrollTop = preserveScrollTop; + } else { + transcriptEl.scrollTop = transcriptEl.scrollHeight; + } + syncStatusIndicator(); + syncComposerState(); + return; + } transcriptEl.innerHTML = `
    B
    @@ -814,10 +878,33 @@ async function getRelayHttpUrl() { async function loadAuth() { const relayHttpUrl = await getRelayHttpUrl(); const extensionId = chrome?.runtime?.id; - const res = await fetch(`${relayHttpUrl}/chatd-url`, { - headers: extensionId ? { 'x-browserforce-extension-id': extensionId } : {}, - }); - if (!res.ok) throw new Error('daemon_unavailable'); + let res; + try { + res = await fetch(`${relayHttpUrl}/chatd-url`, { + headers: extensionId ? { 'x-browserforce-extension-id': extensionId } : {}, + }); + } catch { + const error = new Error('relay_unreachable'); + error.code = 'relay_unreachable'; + throw error; + } + if (!res.ok) { + const body = await readJsonOrEmpty(res); + const relayError = String(body?.error || '').toLowerCase(); + if (res.status === 404 && relayError.includes('chatd not running')) { + const error = new Error('agent_not_running'); + error.code = 'agent_not_running'; + throw error; + } + if (res.status === 503 && relayError.includes('extension not connected')) { + const error = new Error('extension_not_connected'); + error.code = 'extension_not_connected'; + throw error; + } + const error = new Error(body?.error || `chatd-url failed (${res.status})`); + error.code = 'daemon_unavailable'; + throw error; + } const body = await res.json(); state.auth = { baseUrl: `http://127.0.0.1:${body.port}`, @@ -1188,6 +1275,7 @@ popoverBackdropEl.addEventListener('click', () => { (async function init() { try { + state.startupIssue = null; setComposerEnabled(false); setStatus('info', 'Connecting...'); render(); @@ -1209,9 +1297,11 @@ popoverBackdropEl.addEventListener('click', () => { scheduleTabAttachRefresh(0); setStatus('ready', 'Ready'); render(); - } catch { + } catch (error) { + state.startupIssue = normalizeStartupError(error?.code, error?.message); setComposerEnabled(false); setTabAttachBannerState({ hidden: true }); - setStatus('error', 'Daemon unavailable'); + setStatus('error', state.startupIssue.statusText || 'Daemon unavailable'); + render(); } })(); diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index 45a65f2..4dd1518 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -75,3 +75,12 @@ test('reasoning title rows use shimmer and enter transition treatment', () => { assert.match(css, /@keyframes reasoning-title-in/); assert.match(css, /@media\s*\(prefers-reduced-motion:\s*reduce\)/); }); + +test('agent panel includes visible startup error empty-state treatment', () => { + assert.match(panelJs, /state\.startupIssue = null/); + assert.match(panelJs, /class="empty-state error-state"/); + assert.match(panelJs, /empty-command/); + assert.match(css, /\.empty-state\.error-state/); + assert.match(css, /\.empty-icon\.error/); + assert.match(css, /\.empty-command code/); +}); diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 2c062db..3ad524f 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -130,3 +130,20 @@ test('stale run pointer is reconciled from loaded messages so stop does not stay assert.match(js, /state\.currentRunBySession = clearSessionRunId\(state\.currentRunBySession, sessionId, runId\)/); assert.match(js, /async function loadMessages\(sessionId\)[\s\S]*reconcileSessionRunState\(sessionId\)/); }); + +test('init maps relay/chatd boot failures into explicit startup issues', () => { + assert.match(js, /function normalizeStartupError\(code = '', fallbackMessage = 'Unable to connect to BrowserForce Agent'\)/); + assert.match(js, /agent_not_running/); + assert.match(js, /extension_not_connected/); + assert.match(js, /relay_unreachable/); + assert.match(js, /browserforce agent start/); + assert.match(js, /browserforce serve/); + assert.match(js, /state\.startupIssue = normalizeStartupError\(error\?\.code, error\?\.message\)/); +}); + +test('chatd-url auth bootstrap reports specific failure codes before generic daemon unavailable', () => { + assert.match(js, /async function loadAuth\(\)/); + assert.match(js, /if \(res\.status === 404 && relayError\.includes\('chatd not running'\)\)/); + assert.match(js, /if \(res\.status === 503 && relayError\.includes\('extension not connected'\)\)/); + assert.match(js, /error\.code = 'daemon_unavailable'/); +}); From 5a3aea461a988e243e5a95609ee6d3f50d2406c1 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 22:18:05 +0530 Subject: [PATCH 167/192] agent-panel: add collapsed execute helper tree preview - infer likely plugin/helper function calls from BrowserForce:execute detail lines - render a tree-like branch preview under collapsed execute rows while keeping expanded raw details unchanged - add lightweight branch styling and status-aware colors for running/done execute states - extend panel contract tests for helper preview hooks and tree CSS --- extension/agent-panel.css | 49 +++++++++- extension/agent-panel.js | 98 +++++++++++++++++++- test/agent/agent-panel-contract.test.js | 7 ++ test/agent/agent-panel-send-contract.test.js | 9 ++ 4 files changed, 160 insertions(+), 3 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 808bca6..d21c972 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -377,11 +377,15 @@ body { padding: 0; color: inherit; cursor: pointer; + display: block; + text-align: left; +} + +.step-toggle-main { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; - text-align: left; } .step-item.collapsible .step-label { @@ -390,6 +394,49 @@ body { text-overflow: ellipsis; } +.step-branch-preview { + list-style: none; + margin: 6px 0 0; + padding: 0 0 0 12px; + border-left: 1px solid var(--line); + display: flex; + flex-direction: column; + gap: 4px; +} + +.step-branch-node { + position: relative; + padding-left: 10px; +} + +.step-branch-node::before { + content: ''; + position: absolute; + left: 0; + top: 7px; + width: 8px; + border-top: 1px solid var(--line); +} + +.step-branch-call { + display: block; + font-size: 11px; + line-height: 1.35; + color: var(--text-muted); + font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.step-branch-preview.done .step-branch-call { + color: var(--ok); +} + +.step-item.latest .step-branch-preview.running .step-branch-call { + color: var(--crail-dark); +} + .step-caret::before { content: '›'; display: inline-block; diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 066498b..2600482 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -459,6 +459,96 @@ function normalizeRunTimeline(run, fallbackText = '') { return timeline; } +const EXECUTE_HELPER_EXCLUDE_CALLS = new Set([ + 'if', + 'for', + 'while', + 'switch', + 'catch', + 'snapshot', + 'reftolocator', + 'waitforpageload', + 'getlogs', + 'clearlogs', + 'screenshotwithaccessibilitylabels', + 'cleanhtml', + 'pagemarkdown', + 'getcdpsession', + 'plugincatalog', + 'pluginhelp', + 'fetch', + 'settimeout', + 'cleartimeout', + 'promise', + 'array', + 'object', + 'number', + 'string', + 'boolean', + 'date', + 'math', + 'json', + 'parseint', + 'parsefloat', + 'isnan', + 'isfinite', + 'encodeuri', + 'decodeuri', +]); + +function isBrowserForceExecuteStep(entry) { + const label = String(entry?.label || '').trim().toLowerCase(); + return ( + label === 'browserforce:execute' + || label === 'browserforce execute' + || label === 'mcp__browserforce__execute' + || label === 'execute' + ); +} + +function extractExecuteHelperCalls(details) { + if (!Array.isArray(details) || details.length === 0) return []; + const helperCalls = []; + const seen = new Set(); + const callPattern = /(^|[^.\w$])([A-Za-z_$][\w$]{2,})\s*\(/g; + + for (const line of details) { + const text = String(line || ''); + if (!text) continue; + callPattern.lastIndex = 0; + for (const match of text.matchAll(callPattern)) { + const callName = String(match[2] || '').trim(); + if (!callName) continue; + const normalized = callName.toLowerCase(); + if (EXECUTE_HELPER_EXCLUDE_CALLS.has(normalized)) continue; + if (seen.has(normalized)) continue; + seen.add(normalized); + helperCalls.push(callName); + if (helperCalls.length >= 3) return helperCalls; + } + } + + return helperCalls; +} + +function renderExecuteHelperTreePreview(entry, expanded) { + if (expanded) return ''; + if (!isBrowserForceExecuteStep(entry)) return ''; + const details = Array.isArray(entry?.details) ? entry.details : []; + const helperCalls = extractExecuteHelperCalls(details); + if (!helperCalls.length) return ''; + const status = String(entry?.status || '').toLowerCase() === 'done' ? 'done' : 'running'; + return ` +
      + ${helperCalls.map((callName) => ` +
    • + ${escapeHtml(callName)}() +
    • + `).join('')} +
    + `; +} + function getLatestInFlightTimelineStepIndex(run, timeline) { if (!run || run.done) return -1; for (let index = timeline.length - 1; index >= 0; index -= 1) { @@ -531,6 +621,7 @@ function renderRunTimeline(run, fallbackText = '') { const key = getTimelineEntryKey(entry, index); const expanded = !!state.expandedTimelineEntries[key]; if (expanded) classes.push('expanded'); + const helperTreePreviewHtml = renderExecuteHelperTreePreview(entry, expanded); const detailsHtml = details .map((line) => `
  • ${renderInlineContent(line)}
  • `) .join(''); @@ -539,8 +630,11 @@ function renderRunTimeline(run, fallbackText = '') {
    ${expanded ? `
      ${detailsHtml}
    ` : ''}
    diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index 4dd1518..fbb9bca 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -84,3 +84,10 @@ test('agent panel includes visible startup error empty-state treatment', () => { assert.match(css, /\.empty-icon\.error/); assert.match(css, /\.empty-command code/); }); + +test('collapsed execute helper preview has tree-like branch styling', () => { + assert.match(css, /\.step-branch-preview/); + assert.match(css, /\.step-branch-node/); + assert.match(css, /\.step-branch-node::before/); + assert.match(css, /\.step-branch-call/); +}); diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 3ad524f..90feeeb 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -147,3 +147,12 @@ test('chatd-url auth bootstrap reports specific failure codes before generic dae assert.match(js, /if \(res\.status === 503 && relayError\.includes\('extension not connected'\)\)/); assert.match(js, /error\.code = 'daemon_unavailable'/); }); + +test('collapsed BrowserForce execute rows infer helper calls and render branch preview', () => { + assert.match(js, /function extractExecuteHelperCalls\(/); + assert.match(js, /function renderExecuteHelperTreePreview\(/); + assert.match(js, /isBrowserForceExecuteStep/); + assert.match(js, /step-branch-preview/); + assert.match(js, /class="step-branch-node"/); + assert.match(js, /class="step-branch-call"/); +}); From c637698d284523a5bfc5b414b3dee668e65d9a69 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 22:41:17 +0530 Subject: [PATCH 168/192] agent-panel: prioritize top attach-progress state during auto-connect - move initial auto-attach progress from bottom context note into top tab-attach banner - suppress "Current tab is not connected" prompt while initial attach is still in flight - keep context usage note focused on usage-only text and update contract tests --- extension/agent-panel.js | 20 +++++++++++++++++--- test/agent/agent-panel-send-contract.test.js | 13 ++++++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 2600482..67a7722 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -140,9 +140,7 @@ function renderContextUsageChip() { const sessionId = state.value.activeSessionId; const usage = sessionId ? state.value.latestUsageBySession?.[sessionId] : null; const formatted = formatContextUsage(usage || {}); - const note = state.initialTabAttachInFlight - ? 'Attaching active tab...' - : (formatted ? `Context: ${formatted}` : ''); + const note = formatted ? `Context: ${formatted}` : ''; contextUsageEl.classList.toggle('hidden', !note); if (!note) { contextUsageEl.textContent = ''; @@ -217,6 +215,16 @@ function setTabAttachBannerState({ attachCurrentTabBtn.textContent = busy ? 'Attaching...' : 'Attach current tab'; } +function getTabAttachInProgressState() { + if (!state.initialTabAttachInFlight) return null; + return { + hidden: false, + text: 'Currently attaching active tab...', + canAttach: false, + busy: true, + }; +} + function dispatch(action) { state.value = reduceState(state.value, action); render(); @@ -895,6 +903,11 @@ async function getCurrentTabAttachmentState() { async function refreshTabAttachBanner() { const token = ++tabAttachRefreshToken; + const inProgressState = getTabAttachInProgressState(); + if (inProgressState) { + setTabAttachBannerState(inProgressState); + return; + } const next = await getCurrentTabAttachmentState(); if (token !== tabAttachRefreshToken) return; setTabAttachBannerState(next); @@ -931,6 +944,7 @@ function startInitialTabAttach() { if (state.initialTabAttachStarted) return; state.initialTabAttachStarted = true; state.initialTabAttachInFlight = true; + setTabAttachBannerState(getTabAttachInProgressState() || undefined); renderContextUsageChip(); window.setTimeout(() => { ensureCurrentTabAttached() diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index 90feeeb..ef01071 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -73,7 +73,7 @@ test('assistant transcript prefers ordered run timeline over grouped run steps', test('context usage renderer hides element when unavailable and only shows formatted values', () => { assert.match(js, /function renderContextUsageChip\(\)/); assert.match(js, /latestUsageBySession/); - assert.match(js, /const note = state\.initialTabAttachInFlight[\s\S]*formatted[\s\S]*Context: \$\{formatted\}/); + assert.match(js, /const note = formatted \? `Context: \$\{formatted\}` : '';/); assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !note\)/); assert.match(js, /contextUsageEl\.textContent = note/); assert.doesNotMatch(js, /Context:\s*unavailable/); @@ -85,10 +85,13 @@ test('init opens smoothly by starting tab attach asynchronously', () => { assert.doesNotMatch(js, /\(async function init\(\)[\s\S]*await ensureCurrentTabAttached\(\);/); }); -test('bottom note can show async attach status and still hides when no note is available', () => { - assert.match(js, /initialTabAttachInFlight:\s*false/); - assert.match(js, /state\.initialTabAttachInFlight\s*\?\s*'Attaching active tab\.\.\.'/); - assert.match(js, /contextUsageEl\.classList\.toggle\('hidden', !note\)/); +test('tab-attach banner shows progress during initial auto-attach and suppresses not-connected state', () => { + assert.match(js, /function getTabAttachInProgressState\(\)/); + assert.match(js, /text:\s*'Currently attaching active tab\.\.\.'/); + assert.match(js, /busy:\s*true/); + assert.match(js, /async function refreshTabAttachBanner\(\)[\s\S]*getTabAttachInProgressState\(\)/); + assert.match(js, /setTabAttachBannerState\(inProgressState\);/); + assert.match(js, /function startInitialTabAttach\(\)[\s\S]*setTabAttachBannerState\(getTabAttachInProgressState\(\) \|\| undefined\);/); }); test('initial tab attach waits 2 seconds before attaching', () => { From 7671b3a8e0b102e98a217e67000dbaf8440cb5a5 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 22:51:39 +0530 Subject: [PATCH 169/192] docs(plugins): document internal/private plugin workflow - add internal plugin workflow guidance for ~/.browserforce/plugins/ - clarify no .gitignore change is needed for home-directory plugins - recommend private Git repos for versioning internal plugins - document local-dev -> plugins/community promotion path after validation --- docs/BUILDING_PLUGINS.md | 117 +++++++++++++++++++++++++++++---------- docs/PLUGINS.md | 43 ++++++++++---- 2 files changed, 121 insertions(+), 39 deletions(-) diff --git a/docs/BUILDING_PLUGINS.md b/docs/BUILDING_PLUGINS.md index 34e6928..5a0fa11 100644 --- a/docs/BUILDING_PLUGINS.md +++ b/docs/BUILDING_PLUGINS.md @@ -1,11 +1,42 @@ # Building BrowserForce Plugins -Adding a plugin extends BrowserForce for yourself or the whole community. Personal plugins stay in `~/.browserforce/plugins/` and are never shared unless you choose to. Public plugins get reviewed and merged into the repo, appearing in the plugin directory for anyone to install. +Adding a plugin extends BrowserForce for yourself or the whole community. Personal plugins stay in `~/.browserforce/plugins//` and are never shared unless you choose to. Public plugins get reviewed and merged into the repo, appearing in the plugin directory for anyone to install. + +Repo plugin layout is: +- Official: `plugins/official//SKILL.md` +- Community: `plugins/community//SKILL.md` + +No migration to `plugin/skills//` is required. This guide walks through everything: building, testing, and submitting a plugin. --- +## Internal Plugins (Private Workflow) + +If your plugin is for internal QA or company-only automation, keep it local: + +- Create it at `~/.browserforce/plugins//`. +- Keep plugin folders flat under `~/.browserforce/plugins/` (the loader scans one level deep). +- Do not place internal plugins in this repo unless you intend to publish them. + +Because `~/.browserforce/plugins` is outside this repository, you do not need to change this repo's `.gitignore` for internal plugins. + +If you want version history, track internal plugins in a private Git repository: + +- Option 1: Run `git init` directly inside `~/.browserforce/plugins//` and push to a private remote. +- Option 2: Keep plugins in a separate private repo directory and copy/sync them into `~/.browserforce/plugins//` when developing. + +Recommended development flow: + +1. Build and iterate in `~/.browserforce/plugins//` for fast local testing. +2. Validate behavior end to end until checks pass. +3. Promote the plugin to the BrowserForce repo at `plugins/community//` only when it is ready to publish. + +This keeps internal plugins private while still making them reproducible for your team. + +--- + ## 1. Build Your First Plugin ### Step 1 — Create the folder @@ -18,7 +49,7 @@ touch ~/.browserforce/plugins/highlight/SKILL.md ### Step 2 — Write the export -Start with just `name` and one helper. Here is a complete `highlight.js` plugin that visually highlights any element on the page: +Start with just `name` and one helper. Here is a complete `highlight` plugin that visually highlights any element on the page: ```js // ~/.browserforce/plugins/highlight/index.js @@ -108,11 +139,11 @@ await highlight(page, '.price', '#f0f', 0); // permanent magenta on price ### Step 5 — Write a SKILL.md companion -See [Section 4](#4-the-skillmd-companion) for what to include. +See [Section 3](#3-the-skillmd-companion) for what to include. ### Step 6 — Submit as a PR (optional) -See [Section 8](#8-submitting-a-plugin-pr-checklist) for the full checklist. +See [Section 7](#7-submitting-a-plugin-pr-checklist) for the full checklist. --- @@ -210,39 +241,69 @@ export default { ## 3. The SKILL.md Companion -Every plugin should ship a `SKILL.md` alongside the `.js` file. This file is read by the AI agent at startup. It tells the agent when to use the plugin, when not to, and how to call it correctly. Without it, the agent has no context for the plugin's capabilities. +Every plugin should ship a `SKILL.md` alongside `index.js`. BrowserForce now uses a metadata-first prompt model: + +- Default prompt includes plugin metadata only. +- Agents call `pluginCatalog()` to discover plugins and helpers. +- Agents call `pluginHelp(name, section?)` when full or sectioned detail is needed. -**Required sections:** +### Metadata source of truth + +For plugin prompt metadata, `SKILL.md` frontmatter is the source of truth. Do not treat `index.js` fields as metadata authority for prompt docs. + +### Frontmatter contract + +Frontmatter is expected at the top of `SKILL.md`: ```markdown -# highlight plugin +--- +name: highlight +description: Visual outlining helpers for matching elements. +when_to_use: ["Debugging selectors", "Previewing click targets"] +helpers: ["highlight", "clearHighlights"] +tools: [] +--- +``` -Use `highlight(page, selector, color, duration)` / `clearHighlights(page)` when you need to: -- Visually mark an element for debugging or demonstration -- Show a user which element the agent is about to interact with -- Annotate a screenshot for reporting +Supported canonical keys: -## When NOT to use this -- Don't highlight before taking a screenshot if you need the original unmodified view -- Don't leave permanent highlights (duration: 0) unless intentional — they persist across agent turns +| Key | Status | Type | Notes | +| --- | --- | --- | --- | +| `name` | Required | string | Plugin metadata name shown in catalog. | +| `description` | Required | string | Short summary shown in metadata-only prompt and `pluginCatalog()`. | +| `helpers` | Optional | JSON array string | Helper names this plugin exposes. | +| `tools` | Optional | JSON array string | MCP tool names this plugin exposes. | +| `when_to_use` | Optional | JSON array string or block scalar | Guidance for agent selection behavior. | -## Parameters -- `selector` — any valid CSS selector -- `color` — any CSS color value: `'#f90'`, `'red'`, `'rgba(255,0,0,0.3)'` -- `duration` — milliseconds to hold the highlight; `0` = permanent until `clearHighlights()` +Notes: +- Unknown frontmatter keys are ignored. +- Keep arrays as JSON (`["item-a", "item-b"]`) for reliable parsing. +- Block scalars (`|` or `>`) are supported for multiline text fields. -## Example -\`\`\`js -// Highlight the submit button in orange for 3 seconds -const { found } = await highlight(page, 'button[type="submit"]', '#f90', 3000); -if (!found) return 'Submit button not found on this page'; -\`\`\` +### SKILL body guidance (on-demand help) + +The markdown body after frontmatter powers `pluginHelp(...)`. Keep it structured with `##` sections so section lookup is useful. -## Common mistakes -- Calling `highlight` on a selector that matches zero elements — always check `result.found` -- Forgetting to `clearHighlights()` before capturing a clean screenshot +Recommended sections: + +```markdown +## when to use +## when not to use +## parameters +## examples +## common mistakes ``` +### Legacy SKILL migration (no frontmatter) + +Legacy `SKILL.md` files without frontmatter still load, but they only provide on-demand body help and no structured metadata. + +Migration steps: +1. Add a top `--- ... ---` frontmatter block with `name` and `description`. +2. Add optional canonical keys (`helpers`, `tools`, `when_to_use`) as needed. +3. Keep existing markdown body content below frontmatter. +4. Restart MCP after install/update to refresh loaded metadata and help text. + --- ## 4. Rules — What's Not Allowed @@ -433,7 +494,7 @@ const { found } = await highlight(page, 'h1', '#f90'); ## Full Plugin Shape Reference ```js -// ~/.browserforce/plugins/my-plugin.js +// ~/.browserforce/plugins/my-plugin/index.js export default { // Required. Unique across all plugins. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 0a0c362..bd3f754 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -2,12 +2,12 @@ Extend BrowserForce with local JS files — no framework, no build step, no registry. -Plugins live in `~/.browserforce/plugins/`. Each file exports a plain object. The MCP server loads them at startup and merges their helpers, tools, and hooks into the runtime. +Plugins live in `~/.browserforce/plugins//`. Each plugin folder exports a plain object from `index.js`. The MCP server loads plugins at startup and merges their helpers, tools, and hooks into the runtime. **Minimal plugin — 10 lines:** ```js -// ~/.browserforce/plugins/hello.js +// ~/.browserforce/plugins/hello/index.js export default { name: 'hello', helpers: { @@ -25,12 +25,30 @@ After installing, `greet(page)` is available as a global inside every `execute() ## How to Install a Plugin -1. Drop a `.js` file in `~/.browserforce/plugins/` -2. Restart the MCP server +1. Drop a plugin folder at `~/.browserforce/plugins//` with at least `index.js` +2. Restart the MCP server after every plugin install or update 3. Done — helpers are injected, tools are registered No config changes. No manifest edits. The directory is auto-scanned on startup. +### Internal and private plugins + +For company-internal plugins, use local folders under `~/.browserforce/plugins//`. + +- No `.gitignore` update is needed in this repo (that directory is outside repo git tracking). +- Keep plugin folders one level deep (for example `~/.browserforce/plugins/ufe-qa/`). +- If you need collaboration/versioning, track the plugin in a private Git repo and push there instead of the public BrowserForce repo. +- Recommended flow: develop and test locally in `~/.browserforce/plugins//`, then move to `plugins/community//` in BrowserForce when all checks pass and you want to publish. + +### Prompt behavior (metadata-first) + +`SKILL.md` is no longer fully appended to the default `execute()` prompt. BrowserForce provides metadata first, then on-demand help: + +- `pluginCatalog()` returns installed plugin metadata (`name`, `description`, `helpers`, `sections`) +- `pluginHelp(name, section?)` returns full `SKILL.md` text or just one section when requested + +Use `pluginCatalog()` before calling `pluginHelp(...)`; do not fetch every plugin's full help by default. + --- ## For Developers @@ -335,7 +353,7 @@ A single JSON file at `plugins/registry.json` in the repo is the source of truth | `audience` | `"developer"`, `"headless"`, or both | | `capabilities` | Which plugin surfaces it uses: `helpers`, `tools`, `hooks`, `setup` | | `file` | Path to `index.js` in the repo — fetched on install | -| `skill` | Path to `SKILL.md` — fetched on install, injected into AI context | +| `skill` | Path to `SKILL.md` — fetched on install; metadata is exposed by default, full text via `pluginHelp(...)` | --- @@ -354,7 +372,7 @@ Chrome extensions have no filesystem access. The relay runs at `127.0.0.1:19222` ``` Extension UI - │ POST /plugins/install { name: "network" } + │ POST /v1/plugins/install { name: "network" } ▼ Relay (127.0.0.1:19222) │ fetches index.js + SKILL.md from GitHub @@ -368,9 +386,11 @@ Relay (127.0.0.1:19222) | Method | Path | Action | | -------- | ------------------ | -------------------------------------------- | -| `GET` | `/plugins` | List installed plugins + their metadata | -| `POST` | `/plugins/install` | Download plugin from registry, write to disk | -| `DELETE` | `/plugins/:name` | Remove plugin file from disk | +| `GET` | `/v1/plugins` | List installed plugins + their metadata | +| `POST` | `/v1/plugins/install` | Download plugin from registry, write to disk | +| `DELETE` | `/v1/plugins/:name` | Remove plugin file from disk | + +Legacy non-versioned paths (`/plugins*`) remain accepted for backward compatibility. Plugins take effect on next MCP server restart (the extension shows a restart prompt). @@ -398,7 +418,7 @@ browserforce plugin remove network browserforce plugin status ``` -`plugin install` fetches the JS directly from GitHub's raw content URL and writes it to `~/.browserforce/plugins/`. Same outcome as the extension UI, different path. +`plugin install` fetches `index.js` (and `SKILL.md` when available) from GitHub and writes them to `~/.browserforce/plugins//`. Same outcome as the extension UI, different path. --- @@ -427,6 +447,7 @@ plugins/ ``` Official plugins are maintained by the BrowserForce team. Community plugins are reviewed for safety (no `eval`, no network calls to external servers, no credential exfiltration) before merge. +This layout is current and supported: `plugins/official//SKILL.md` and `plugins/community//SKILL.md`. No migration to `plugin/skills//` is required. --- @@ -436,7 +457,7 @@ Plugins are arbitrary JS running in Node.js — they have full filesystem and ne - **Official plugins**: reviewed and maintained by BrowserForce - **Community plugins**: reviewed before merge (same bar as official) -- **Local plugins**: `~/.browserforce/plugins/*.js` — user's own files, not from the registry, fully trusted +- **Local plugins**: `~/.browserforce/plugins//` — user's own plugin folders, not from the registry, fully trusted The relay install endpoint only fetches from the known GitHub repo URL — no arbitrary URLs. The extension UI only shows registry plugins. Users who want to run untrusted code drop files manually into the plugins folder. From 20bd89077446bc8c35c10e7ab7722ee8755cb203 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 22:37:05 +0530 Subject: [PATCH 170/192] agent: add per-session reasoning effort support - persist reasoningEffort in session metadata with strict low/medium/high/xhigh validation - resolve run effort from session value, then config/environment defaults, with medium fallback - pass effort to codex exec via -c model_reasoning_effort=... in runner args - expose configured defaultReasoningEffort in /v1/models for panel hydration - extend agent tests for session-store, codex-runner, and chatd API coverage --- agent/src/chatd.js | 50 +++++++++++++++++-- agent/src/codex-runner.js | 18 ++++++- agent/src/session-store.js | 21 +++++++- test/agent/chatd-api.test.js | 82 +++++++++++++++++++++++++++++++- test/agent/codex-runner.test.js | 5 ++ test/agent/session-store.test.js | 14 +++++- 6 files changed, 181 insertions(+), 9 deletions(-) diff --git a/agent/src/chatd.js b/agent/src/chatd.js index 2cfdea4..a67b965 100644 --- a/agent/src/chatd.js +++ b/agent/src/chatd.js @@ -14,6 +14,7 @@ import { createSession, getSession, isValidModelId, + isValidReasoningEffort, isValidSessionId, listSessions, readMessages, @@ -24,6 +25,7 @@ const BF_DIR = join(homedir(), '.browserforce'); const CHATD_URL_PATH = join(BF_DIR, 'chatd-url.json'); const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml'); const MODEL_LIST_TIMEOUT_MS = 5000; +const DEFAULT_REASONING_EFFORT = 'medium'; function parseTopLevelTomlString(raw, key) { const lines = String(raw || '').split(/\r?\n/); @@ -55,6 +57,20 @@ async function resolveConfiguredModel() { return null; } +async function resolveConfiguredReasoningEffort() { + const envEffort = String(process.env.BF_CHATD_DEFAULT_REASONING_EFFORT || '').trim().toLowerCase(); + if (envEffort && isValidReasoningEffort(envEffort)) return envEffort; + + try { + const raw = await fs.readFile(CODEX_CONFIG_PATH, 'utf8'); + const effort = String(parseTopLevelTomlString(raw, 'model_reasoning_effort') || '').trim().toLowerCase(); + if (effort && isValidReasoningEffort(effort)) return effort; + } catch { + // no local codex config is fine + } + return DEFAULT_REASONING_EFFORT; +} + function dedupeModelRows(rows) { const seen = new Set(); const out = [{ value: null, label: 'Default' }]; @@ -214,6 +230,16 @@ async function listModelPresets({ storageRoot, modelFetcher } = {}) { return dedupeModelRows([...liveRows, ...configuredRow, ...sessionRows]); } +function resolveEffectiveReasoningEffort(sessionReasoningEffort, fallbackReasoningEffort = DEFAULT_REASONING_EFFORT) { + const sessionValue = String(sessionReasoningEffort || '').trim().toLowerCase(); + if (sessionValue && isValidReasoningEffort(sessionValue)) return sessionValue; + + const fallbackValue = String(fallbackReasoningEffort || '').trim().toLowerCase(); + if (fallbackValue && isValidReasoningEffort(fallbackValue)) return fallbackValue; + + return DEFAULT_REASONING_EFFORT; +} + function nowIso() { return new Date().toISOString(); } @@ -923,11 +949,12 @@ async function clearChatdUrlFile({ writeChatdUrl = true, urlPath = CHATD_URL_PAT } function createDefaultRunExecutor({ codexCwd } = {}) { - return ({ runId, sessionId, message, model, resumeSessionId, onEvent, onExit, onError }) => startCodexRun({ + return ({ runId, sessionId, message, model, reasoningEffort, resumeSessionId, onEvent, onExit, onError }) => startCodexRun({ runId, sessionId, prompt: message, model, + reasoningEffort, resumeSessionId, cwd: codexCwd, onEvent, @@ -949,6 +976,10 @@ export async function startChatd(opts = {}) { command: opts.codexCommand || process.env.BF_CHATD_CODEX_COMMAND || 'codex', timeoutMs: Number(process.env.BF_CHATD_MODEL_LIST_TIMEOUT_MS || MODEL_LIST_TIMEOUT_MS), })); + const configuredReasoningEffort = resolveEffectiveReasoningEffort( + opts.defaultReasoningEffort, + await resolveConfiguredReasoningEffort(), + ); let desiredPort = Number.isFinite(opts.port) ? Number(opts.port) : Number(process.env.BF_CHATD_PORT || 0); if (!Number.isInteger(desiredPort) || desiredPort < 0) desiredPort = 0; @@ -1063,7 +1094,7 @@ export async function startChatd(opts = {}) { if (url.pathname === '/v1/models' && req.method === 'GET') { const models = await listModelPresets({ storageRoot, modelFetcher }); - json(res, 200, { models }); + json(res, 200, { models, defaultReasoningEffort: configuredReasoningEffort }); return; } @@ -1079,6 +1110,7 @@ export async function startChatd(opts = {}) { const session = await createSession({ title: body.title || 'New chat', model: body.model ?? null, + reasoningEffort: body.reasoningEffort ?? null, storageRoot, }); json(res, 201, session); @@ -1125,6 +1157,7 @@ export async function startChatd(opts = {}) { patch: { ...(Object.prototype.hasOwnProperty.call(body, 'title') ? { title: body.title } : {}), ...(Object.prototype.hasOwnProperty.call(body, 'model') ? { model: body.model } : {}), + ...(Object.prototype.hasOwnProperty.call(body, 'reasoningEffort') ? { reasoningEffort: body.reasoningEffort } : {}), }, storageRoot, }); @@ -1215,6 +1248,10 @@ export async function startChatd(opts = {}) { } const browserContext = normalizeBrowserContext(body?.browserContext); const promptMessage = buildRunPrompt({ message, browserContext }); + const runReasoningEffort = resolveEffectiveReasoningEffort( + session.reasoningEffort, + configuredReasoningEffort, + ); const runId = randomBytes(12).toString('base64url'); const run = { @@ -1232,6 +1269,7 @@ export async function startChatd(opts = {}) { resumeSessionId: isValidSessionId(session?.providerState?.codex?.sessionId || '') ? session.providerState.codex.sessionId : null, + reasoningEffort: runReasoningEffort, }; const enqueue = (fn) => { @@ -1247,6 +1285,7 @@ export async function startChatd(opts = {}) { sessionId, message: promptMessage, model: session.model || null, + reasoningEffort: runReasoningEffort, resumeSessionId, onEvent: (evt) => { enqueue(async () => { @@ -1377,7 +1416,12 @@ export async function startChatd(opts = {}) { event: 'run.started', runId, sessionId, - payload: { message, model: session.model || null, browserContext }, + payload: { + message, + model: session.model || null, + reasoningEffort: runReasoningEffort, + browserContext, + }, })); json(res, 202, { ok: true, runId, sessionId }); } catch (error) { diff --git a/agent/src/codex-runner.js b/agent/src/codex-runner.js index 828fb61..3b57efe 100644 --- a/agent/src/codex-runner.js +++ b/agent/src/codex-runner.js @@ -465,7 +465,16 @@ export function normalizeCodexLine({ runId, sessionId, line }) { return envelope({ event: 'run.event', runId, sessionId, payload: parsed }); } -export function buildCodexExecArgs({ prompt, model, args, resumeSessionId } = {}) { +function normalizeReasoningEffort(reasoningEffort) { + const normalized = String(reasoningEffort || '').trim().toLowerCase(); + if (!normalized) return null; + if (normalized === 'low' || normalized === 'medium' || normalized === 'high' || normalized === 'xhigh') { + return normalized; + } + return null; +} + +export function buildCodexExecArgs({ prompt, model, reasoningEffort, args, resumeSessionId } = {}) { if (Array.isArray(args) && args.length > 0) return args; const resumeId = typeof resumeSessionId === 'string' ? resumeSessionId.trim() : ''; const resolved = resumeId @@ -474,6 +483,10 @@ export function buildCodexExecArgs({ prompt, model, args, resumeSessionId } = {} if (typeof model === 'string' && model.trim()) { resolved.push('--model', model.trim()); } + const normalizedReasoningEffort = normalizeReasoningEffort(reasoningEffort); + if (normalizedReasoningEffort) { + resolved.push('-c', `model_reasoning_effort="${normalizedReasoningEffort}"`); + } resolved.push(prompt || ''); return resolved; } @@ -489,10 +502,11 @@ export function startCodexRun({ command, args, model, + reasoningEffort, resumeSessionId, } = {}) { const cmd = command || process.env.BF_CHATD_CODEX_COMMAND || 'codex'; - const argv = buildCodexExecArgs({ prompt, model, args, resumeSessionId }); + const argv = buildCodexExecArgs({ prompt, model, reasoningEffort, args, resumeSessionId }); const child = spawn(cmd, argv, { cwd, diff --git a/agent/src/session-store.js b/agent/src/session-store.js index eb58851..12194dd 100644 --- a/agent/src/session-store.js +++ b/agent/src/session-store.js @@ -8,6 +8,7 @@ const INDEX_FILE = 'index.json'; const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,128}$/; const RUN_ID_RE = /^[A-Za-z0-9_-]{1,256}$/; const MODEL_ID_RE = /^[A-Za-z0-9._:/-]{1,128}$/; +const REASONING_EFFORT_VALUES = new Set(['low', 'medium', 'high', 'xhigh']); const indexWriteQueues = new Map(); function isObject(value) { @@ -34,6 +35,10 @@ export function isValidModelId(model) { return typeof model === 'string' && MODEL_ID_RE.test(model); } +export function isValidReasoningEffort(value) { + return typeof value === 'string' && REASONING_EFFORT_VALUES.has(value.trim().toLowerCase()); +} + function assertValidSessionId(sessionId, fnName) { if (!isValidSessionId(sessionId)) { throw new Error(`${fnName} requires a safe sessionId`); @@ -252,6 +257,16 @@ function normalizeModel(model) { return trimmed; } +function normalizeReasoningEffort(reasoningEffort) { + if (reasoningEffort == null) return null; + const trimmed = String(reasoningEffort).trim().toLowerCase(); + if (!trimmed) return null; + if (!isValidReasoningEffort(trimmed)) { + throw new Error('reasoningEffort must be one of: low, medium, high, xhigh'); + } + return trimmed; +} + function normalizeUsageNumber(value, fieldName) { if (value == null) return null; const parsed = Number(value); @@ -335,7 +350,7 @@ function sortSessionsNewestFirst(a, b) { return bTs - aTs; } -export async function createSession({ title = 'New chat', model = null, storageRoot } = {}) { +export async function createSession({ title = 'New chat', model = null, reasoningEffort = null, storageRoot } = {}) { const root = resolveStorageRoot(storageRoot); await ensureStorageRoot(root); @@ -345,6 +360,7 @@ export async function createSession({ title = 'New chat', model = null, storageR sessionId, title, model: normalizeModel(model), + reasoningEffort: normalizeReasoningEffort(reasoningEffort), createdAt: now, updatedAt: now, }; @@ -399,6 +415,9 @@ export async function updateSession({ sessionId, patch = {}, storageRoot } = {}) if (Object.prototype.hasOwnProperty.call(patch, 'model')) { next.model = normalizeModel(patch.model); } + if (Object.prototype.hasOwnProperty.call(patch, 'reasoningEffort')) { + next.reasoningEffort = normalizeReasoningEffort(patch.reasoningEffort); + } if (Object.prototype.hasOwnProperty.call(patch, 'providerState')) { const providerState = normalizeProviderState(patch.providerState, current.providerState); if (providerState == null) delete next.providerState; diff --git a/test/agent/chatd-api.test.js b/test/agent/chatd-api.test.js index 7221b1b..eff31e1 100644 --- a/test/agent/chatd-api.test.js +++ b/test/agent/chatd-api.test.js @@ -155,8 +155,9 @@ test('POST /v1/runs uses injected run executor and persists assistant output', a const daemon = await startChatd({ port: 0, writeChatdUrl: false, - runExecutor: ({ runId, sessionId, model, onEvent, onExit }) => { - seenRuns.push({ runId, sessionId, model }); + defaultReasoningEffort: 'medium', + runExecutor: ({ runId, sessionId, model, reasoningEffort, onEvent, onExit }) => { + seenRuns.push({ runId, sessionId, model, reasoningEffort }); setTimeout(() => { onEvent({ event: 'chat.delta', runId, sessionId, payload: { delta: 'hel' } }); }, 10); @@ -199,6 +200,7 @@ test('POST /v1/runs uses injected run executor and persists assistant output', a await new Promise((resolve) => setTimeout(resolve, 60)); assert.equal(seenRuns.at(-1)?.model, 'gpt-5'); + assert.equal(seenRuns.at(-1)?.reasoningEffort, 'medium'); const messagesBody = await fetch( `${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}/messages`, @@ -211,6 +213,82 @@ test('POST /v1/runs uses injected run executor and persists assistant output', a } }); +test('POST /v1/runs uses per-session reasoning effort when configured', async () => { + const seenRuns = []; + const daemon = await startChatd({ + port: 0, + writeChatdUrl: false, + defaultReasoningEffort: 'medium', + runExecutor: ({ runId, sessionId, reasoningEffort, onEvent, onExit }) => { + seenRuns.push({ runId, sessionId, reasoningEffort }); + setTimeout(() => onEvent({ event: 'chat.final', runId, sessionId, payload: { text: 'ok' } }), 10); + setTimeout(() => onExit({ code: 0 }), 15); + return { abort() {} }; + }, + }); + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Effort' }), + }).then((res) => res.json()); + + const patched = await fetch(`${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ reasoningEffort: 'high' }), + }); + assert.equal(patched.status, 200); + + const runRes = await fetch(`${daemon.baseUrl}/v1/runs`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ sessionId: created.sessionId, message: 'hi' }), + }); + assert.equal(runRes.status, 202); + + await new Promise((resolve) => setTimeout(resolve, 60)); + assert.equal(seenRuns.at(-1)?.reasoningEffort, 'high'); + } finally { + await daemon.stop(); + } +}); + +test('PATCH /v1/sessions rejects invalid reasoning effort values', async () => { + const daemon = await startChatd({ port: 0, writeChatdUrl: false }); + try { + const created = await fetchWithRetry(`${daemon.baseUrl}/v1/sessions`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ title: 'Invalid effort' }), + }).then((res) => res.json()); + + const patched = await fetch(`${daemon.baseUrl}/v1/sessions/${encodeURIComponent(created.sessionId)}`, { + method: 'PATCH', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${daemon.token}`, + }, + body: JSON.stringify({ reasoningEffort: 'turbo' }), + }); + assert.equal(patched.status, 400); + } finally { + await daemon.stop(); + } +}); + test('POST /v1/runs persists run steps so reopened sessions can render them', async () => { const daemon = await startChatd({ port: 0, diff --git a/test/agent/codex-runner.test.js b/test/agent/codex-runner.test.js index 3ec4166..15280a4 100644 --- a/test/agent/codex-runner.test.js +++ b/test/agent/codex-runner.test.js @@ -44,6 +44,11 @@ test('buildCodexExecArgs includes --model when session model is set', () => { assert.deepEqual(args, ['exec', '--json', '--model', 'gpt-5', 'hi']); }); +test('buildCodexExecArgs includes reasoning effort override when set', () => { + const args = buildCodexExecArgs({ prompt: 'hi', reasoningEffort: 'medium' }); + assert.deepEqual(args, ['exec', '--json', '-c', 'model_reasoning_effort="medium"', 'hi']); +}); + test('buildCodexExecArgs emits resume invocation when codex session id is provided', () => { const args = buildCodexExecArgs({ prompt: 'hi', diff --git a/test/agent/session-store.test.js b/test/agent/session-store.test.js index e17fccf..39d3df5 100644 --- a/test/agent/session-store.test.js +++ b/test/agent/session-store.test.js @@ -104,17 +104,29 @@ test('updateSession persists per-session model and title', async () => { const created = await createSession({ title: 'Before', storageRoot }); const updated = await updateSession({ sessionId: created.sessionId, - patch: { title: 'After', model: 'gpt-5' }, + patch: { title: 'After', model: 'gpt-5', reasoningEffort: 'high' }, storageRoot, }); assert.equal(updated?.title, 'After'); assert.equal(updated?.model, 'gpt-5'); + assert.equal(updated?.reasoningEffort, 'high'); const rows = await listSessions({ limit: 10, storageRoot }); const row = rows.find((item) => item.sessionId === created.sessionId); assert.equal(row?.title, 'After'); assert.equal(row?.model, 'gpt-5'); + assert.equal(row?.reasoningEffort, 'high'); +}); + +test('updateSession supports clearing reasoning effort back to config default', async () => { + const created = await createSession({ title: 'Before', storageRoot }); + const updated = await updateSession({ + sessionId: created.sessionId, + patch: { reasoningEffort: null }, + storageRoot, + }); + assert.equal(updated?.reasoningEffort, null); }); test('updateSession persists codex provider session mapping', async () => { From 5ad5718131742f8b00df6fdc2dae80f6c389b15c Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 22:37:16 +0530 Subject: [PATCH 171/192] panel: add thinking selector to model popup - add a Thinking Level list in the existing model popover with Default/Low/Medium/High/Extra High choices - map Default to config-derived defaultReasoningEffort and persist per-session overrides through PATCH /v1/sessions - hydrate panel state from /v1/models defaultReasoningEffort and keep medium as local fallback - extend panel contract coverage for the new thinking list surface --- extension/agent-panel.html | 2 + extension/agent-panel.js | 181 +++++++++++++++++++++--- test/agent/agent-panel-contract.test.js | 7 + 3 files changed, 167 insertions(+), 23 deletions(-) diff --git a/extension/agent-panel.html b/extension/agent-panel.html index 53a2344..13d2f1a 100644 --- a/extension/agent-panel.html +++ b/extension/agent-panel.html @@ -69,6 +69,8 @@
    `; @@ -921,6 +1006,8 @@ function scheduleTabAttachRefresh(delayMs = 0) { } function bindTabAttachWatchers() { + if (state.tabAttachWatchersBound) return; + state.tabAttachWatchersBound = true; if (chrome?.tabs?.onActivated?.addListener) { chrome.tabs.onActivated.addListener(() => { scheduleTabAttachRefresh(40); @@ -983,6 +1070,13 @@ async function getRelayHttpUrl() { return 'http://127.0.0.1:19222'; } +async function refreshExtensionConnection() { + const stored = await chrome.storage.local.get(['relayUrl']); + const relayUrl = stored.relayUrl || 'ws://127.0.0.1:19222/extension'; + const response = await runtimeMessage({ type: 'updateRelayUrl', relayUrl }); + if (response?.error) throw new Error(response.error); +} + async function loadAuth() { const relayHttpUrl = await getRelayHttpUrl(); const extensionId = chrome?.runtime?.id; @@ -1081,6 +1175,7 @@ async function loadModelPresets() { await ensureOk(res, 'Failed to load models'); const body = await readJsonOrEmpty(res); state.modelPresets = normalizeModelRows(body.models); + state.defaultReasoningEffort = normalizeReasoningEffort(body.defaultReasoningEffort) || 'medium'; } async function loadMessages(sessionId) { @@ -1209,6 +1304,24 @@ async function updateActiveSessionModel(model) { setStatus('ready', 'Ready'); } +async function updateActiveSessionReasoningEffort(reasoningEffort) { + const sessionId = state.value.activeSessionId; + if (!sessionId) return; + + const res = await api(`/v1/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + body: JSON.stringify({ reasoningEffort }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error || 'Unable to update thinking level'); + } + + await loadSessions(sessionId); + setPopover('none'); + setStatus('ready', 'Ready'); +} + async function consumeEventStream(body, loopToken) { if (!body) return; const reader = body.getReader(); @@ -1311,6 +1424,49 @@ async function stopRun() { }); } +async function initializePanel() { + state.startupIssue = null; + setComposerEnabled(false); + setStatus('info', 'Connecting...'); + render(); + startInitialTabAttach(); + await loadAuth(); + bindTabAttachWatchers(); + try { + await loadModelPresets(); + } catch { + state.modelPresets = [{ value: null, label: 'Default' }]; + state.defaultReasoningEffort = 'medium'; + } + await loadSessions(); + if (!state.value.activeSessionId) { + await createSession(); + } else { + await selectSession(state.value.activeSessionId); + } + setComposerEnabled(true); + scheduleTabAttachRefresh(0); + setStatus('ready', 'Ready'); + render(); +} + +async function retryStartup({ refreshConnection = false } = {}) { + try { + setStatus('info', refreshConnection ? 'Refreshing connection...' : 'Retrying...'); + render(); + if (refreshConnection) { + await refreshExtensionConnection(); + } + await initializePanel(); + } catch (error) { + state.startupIssue = normalizeStartupError(error?.code, error?.message); + setComposerEnabled(false); + setTabAttachBannerState({ hidden: true }); + setStatus('error', state.startupIssue.statusText || 'Daemon unavailable'); + render(); + } +} + chatFormEl.addEventListener('submit', async (event) => { event.preventDefault(); const text = chatInputEl.value; @@ -1383,28 +1539,7 @@ popoverBackdropEl.addEventListener('click', () => { (async function init() { try { - state.startupIssue = null; - setComposerEnabled(false); - setStatus('info', 'Connecting...'); - render(); - startInitialTabAttach(); - await loadAuth(); - bindTabAttachWatchers(); - try { - await loadModelPresets(); - } catch { - state.modelPresets = [{ value: null, label: 'Default' }]; - } - await loadSessions(); - if (!state.value.activeSessionId) { - await createSession(); - } else { - await selectSession(state.value.activeSessionId); - } - setComposerEnabled(true); - scheduleTabAttachRefresh(0); - setStatus('ready', 'Ready'); - render(); + await initializePanel(); } catch (error) { state.startupIssue = normalizeStartupError(error?.code, error?.message); setComposerEnabled(false); diff --git a/test/agent/agent-panel-contract.test.js b/test/agent/agent-panel-contract.test.js index fbb9bca..e923306 100644 --- a/test/agent/agent-panel-contract.test.js +++ b/test/agent/agent-panel-contract.test.js @@ -14,6 +14,7 @@ test('agent panel has inline model and session selectors with popovers', () => { assert.match(html, /id="bf-model-panel"/); assert.match(html, /id="bf-session-panel"/); assert.match(html, /id="bf-model-list"/); + assert.match(html, /id="bf-thinking-list"/); assert.match(html, /id="bf-switch-session-list"/); assert.match(html, /id="bf-tab-attach-banner"/); assert.match(html, /id="bf-tab-attach-text"/); @@ -91,3 +92,9 @@ test('collapsed execute helper preview has tree-like branch styling', () => { assert.match(css, /\.step-branch-node::before/); assert.match(css, /\.step-branch-call/); }); + +test('startup error card action buttons have dedicated styling hooks', () => { + assert.match(css, /\.empty-actions/); + assert.match(css, /\.empty-action-btn/); + assert.match(css, /\.empty-action-btn\.secondary/); +}); From e18c59088509f7ec71233b1b7e69111f0fc107fa Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 22:37:18 +0530 Subject: [PATCH 172/192] agent-panel: add startup retry and refresh-connection actions - add action buttons to startup error card: Retry and Refresh connection - wire transcript action handling to retry panel bootstrap and trigger extension relay reconnect via updateRelayUrl - refactor bootstrap flow into initializePanel/retryStartup and guard duplicate tab watcher bindings - add contract coverage for startup action hooks and styles --- extension/agent-panel.css | 35 ++++++++++++++++++++ test/agent/agent-panel-send-contract.test.js | 18 ++++++++-- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/extension/agent-panel.css b/extension/agent-panel.css index d21c972..0ab0a28 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -266,6 +266,41 @@ body { word-break: break-word; } +.empty-actions { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.empty-action-btn { + height: 28px; + border-radius: 8px; + border: 1px solid var(--crail); + background: var(--crail); + color: #fff; + padding: 0 10px; + font-size: 11px; + line-height: 1; + cursor: pointer; +} + +.empty-action-btn.secondary { + background: var(--linen); + border-color: var(--line); + color: var(--crail-dark); +} + +.empty-action-btn:hover { + background: var(--crail-dark); + border-color: var(--crail-dark); +} + +.empty-action-btn.secondary:hover { + background: var(--sand); + border-color: var(--line); +} + .message { display: flex; flex-direction: column; diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index ef01071..a79b92c 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -81,8 +81,11 @@ test('context usage renderer hides element when unavailable and only shows forma test('init opens smoothly by starting tab attach asynchronously', () => { assert.match(js, /function startInitialTabAttach\(\)/); - assert.match(js, /\(async function init\(\)[\s\S]*startInitialTabAttach\(\);/); - assert.doesNotMatch(js, /\(async function init\(\)[\s\S]*await ensureCurrentTabAttached\(\);/); + assert.match(js, /async function initializePanel\(\)[\s\S]*startInitialTabAttach\(\);/); + const initMatch = js.match(/\(async function init\(\)[\s\S]*?\n}\)\(\);/); + assert.ok(initMatch, 'init block should be present'); + const initBlock = initMatch[0]; + assert.doesNotMatch(initBlock, /await ensureCurrentTabAttached\(\);/); }); test('tab-attach banner shows progress during initial auto-attach and suppresses not-connected state', () => { @@ -159,3 +162,14 @@ test('collapsed BrowserForce execute rows infer helper calls and render branch p assert.match(js, /class="step-branch-node"/); assert.match(js, /class="step-branch-call"/); }); + +test('startup error card supports retry and refresh connection actions', () => { + assert.match(js, /function refreshExtensionConnection\(/); + assert.match(js, /function retryStartup\(/); + assert.match(js, /data-startup-action=/); + assert.match(js, /key:\s*'retry'/); + assert.match(js, /key:\s*'refresh-connection'/); + assert.match(js, /msgAction === 'retry'/); + assert.match(js, /msgAction === 'refresh-connection'/); + assert.match(js, /runtimeMessage\(\{\s*type:\s*'updateRelayUrl'/); +}); From 4deb317eb136c1fda9293a4243f2581db650f2f8 Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 23:05:27 +0530 Subject: [PATCH 173/192] feat(plugins): metadata-first skill loading and v1 plugin API --- README.frontpage.md | 19 +- README.md | 21 +- bin.js | 6 +- mcp/src/exec-engine.js | 80 +++++++ mcp/src/index.js | 18 +- mcp/test/mcp-plugin-integration.test.js | 49 ++++- mcp/test/mcp-tools.test.js | 3 + mcp/test/plugin-loader.test.js | 271 +++++++++++++++++++++++- plugins/official/google-sheets/SKILL.md | 9 + plugins/official/google-sheets/index.js | 210 +++++++++++++++--- plugins/official/highlight/SKILL.md | 8 + plugins/official/openclaw/SKILL.md | 8 + relay/src/index.js | 8 +- 13 files changed, 650 insertions(+), 60 deletions(-) diff --git a/README.frontpage.md b/README.frontpage.md index f11e4f5..0c67331 100644 --- a/README.frontpage.md +++ b/README.frontpage.md @@ -410,7 +410,14 @@ Plugins add custom helpers directly into the `execute` tool scope. Install once browserforce plugin install highlight ``` -That's it. Restart MCP (or Claude Desktop) and `highlight()` is available in every `execute` call. +That's it. Restart MCP (or Claude Desktop) after every plugin install or update, then `highlight()` is available in every `execute` call. + +### Prompt behavior (metadata-first) + +Plugin `SKILL.md` content is no longer fully inlined into the default `execute` prompt. BrowserForce now exposes plugin metadata first (name, description, helpers), then loads details on demand: + +- Call `pluginCatalog()` to discover installed plugins, helper names, and available sections. +- Call `pluginHelp(name, section?)` only when you need plugin-specific instructions. ### Official plugins @@ -442,7 +449,13 @@ browserforce plugin list # See what's installed browserforce plugin remove highlight # Uninstall ``` -Plugins are stored at `~/.browserforce/plugins/`. Each one is a folder with an `index.js`. +Plugins are stored at `~/.browserforce/plugins//`. Each plugin folder contains an `index.js` and can include a `SKILL.md`. + +Repo layout remains: +- Official plugins: `plugins/official//SKILL.md` +- Community plugins: `plugins/community//SKILL.md` + +No migration to `plugin/skills//` is required. ### Write your own @@ -463,7 +476,7 @@ export default { Drop it in `~/.browserforce/plugins/my-plugin/`, restart MCP, and call `await scrollToBottom()` or `await countLinks()` from any `execute` call. -Add a `SKILL.md` file alongside `index.js` and its content is automatically appended to the `execute` tool's description — so your agent knows the helpers exist without you having to explain them every time. +Add a `SKILL.md` file alongside `index.js` to publish plugin metadata and help text. The default prompt includes only metadata; fetch full or sectioned guidance on demand with `pluginHelp('my-plugin')` or `pluginHelp('my-plugin', 'examples')`. ### Any Playwright Script diff --git a/README.md b/README.md index 7e5539d..de5b348 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ flowchart LR MCP --> RELAY["Relay (`127.0.0.1:19222`)"] RELAY --> EXT["Chrome Extension (MV3)"] EXT --> CHROME["User's Real Chrome Session"] - SETUP["`browserforce setup openclaw`"] --> PLUGIN["Auto-install `openclaw` plugin\n(SKILL appended to execute prompt)"] + SETUP["`browserforce setup openclaw`"] --> PLUGIN["Auto-install `openclaw` plugin\n(metadata shown in prompt, details via pluginHelp())"] PLUGIN --> MCP ``` @@ -437,7 +437,14 @@ Plugins add custom helpers directly into the `execute` tool scope. Install once browserforce plugin install highlight ``` -That's it. Restart MCP (or Claude Desktop) and `highlight()` is available in every `execute` call. +That's it. Restart MCP (or Claude Desktop) after every plugin install or update, then `highlight()` is available in every `execute` call. + +### Prompt behavior (metadata-first) + +Plugin `SKILL.md` content is no longer fully inlined into the default `execute` prompt. BrowserForce now exposes plugin metadata first (name, description, helpers), then loads details on demand: + +- Call `pluginCatalog()` to discover installed plugins, helper names, and available sections. +- Call `pluginHelp(name, section?)` only when you need plugin-specific instructions. ### Official plugins @@ -471,7 +478,13 @@ browserforce plugin list # See what's installed browserforce plugin remove highlight # Uninstall ``` -Plugins are stored at `~/.browserforce/plugins/`. Each one is a folder with an `index.js`. +Plugins are stored at `~/.browserforce/plugins//`. Each plugin folder contains an `index.js` and can include a `SKILL.md`. + +Repo layout remains: +- Official plugins: `plugins/official//SKILL.md` +- Community plugins: `plugins/community//SKILL.md` + +No migration to `plugin/skills//` is required. ### Write your own @@ -492,7 +505,7 @@ export default { Drop it in `~/.browserforce/plugins/my-plugin/`, restart MCP, and call `await scrollToBottom()` or `await countLinks()` from any `execute` call. -Add a `SKILL.md` file alongside `index.js` and its content is automatically appended to the `execute` tool's description — so your agent knows the helpers exist without you having to explain them every time. +Add a `SKILL.md` file alongside `index.js` to publish plugin metadata and help text. The default prompt includes only metadata; fetch full or sectioned guidance on demand with `pluginHelp('my-plugin')` or `pluginHelp('my-plugin', 'examples')`. ### Any Playwright Script diff --git a/bin.js b/bin.js index 773980c..4f99e26 100644 --- a/bin.js +++ b/bin.js @@ -289,7 +289,7 @@ async function cmdPlugin() { try { authToken = readFileSync(tokenFile, 'utf8').trim(); } catch { /* no token file */ } if (sub === 'list') { - const data = await httpGet(`${baseUrl}/plugins`); + const data = await httpGet(`${baseUrl}/v1/plugins`); if (values.json) { output(data, true); } else { @@ -306,7 +306,7 @@ async function cmdPlugin() { if (sub === 'install') { const name = positionals[2]; if (!name) { console.error('Usage: browserforce plugin install '); process.exit(1); } - const { status, body } = await httpFetch('POST', `${baseUrl}/plugins/install`, { name }, authToken); + const { status, body } = await httpFetch('POST', `${baseUrl}/v1/plugins/install`, { name }, authToken); if (status >= 400) { console.error(`Error: ${body.error || JSON.stringify(body)}`); process.exit(1); @@ -318,7 +318,7 @@ async function cmdPlugin() { if (sub === 'remove') { const name = positionals[2]; if (!name) { console.error('Usage: browserforce plugin remove '); process.exit(1); } - const { status, body } = await httpFetch('DELETE', `${baseUrl}/plugins/${encodeURIComponent(name)}`, null, authToken); + const { status, body } = await httpFetch('DELETE', `${baseUrl}/v1/plugins/${encodeURIComponent(name)}`, null, authToken); if (status >= 400) { console.error(`Error: ${body.error || JSON.stringify(body)}`); process.exit(1); diff --git a/mcp/src/exec-engine.js b/mcp/src/exec-engine.js index 2169faa..e538aca 100644 --- a/mcp/src/exec-engine.js +++ b/mcp/src/exec-engine.js @@ -554,6 +554,7 @@ export function buildExecContext( pluginHelpers = {}, agentPreferences = {}, runtimeRestrictions = {}, + pluginSkillRuntime = {}, ) { const { consoleLogs, setupConsoleCapture } = consoleHelpers; const lastSnapshots = userState.__lastSnapshots || (userState.__lastSnapshots = new WeakMap()); @@ -669,9 +670,87 @@ export function buildExecContext( instructions: typeof runtimeRestrictions?.instructions === 'string' ? runtimeRestrictions.instructions : '', }; + const pluginCatalog = () => { + const catalog = Array.isArray(pluginSkillRuntime?.catalog) ? pluginSkillRuntime.catalog : []; + return catalog.map((entry) => ({ + ...entry, + helpers: Array.isArray(entry?.helpers) ? [...entry.helpers] : [], + sections: Array.isArray(entry?.sections) ? [...entry.sections] : [], + })); + }; + + const pluginHelp = (name, section) => { + const requestedName = String(name || '').trim().toLowerCase(); + if (!requestedName) { + throw new Error('pluginHelp(name, section?) requires a plugin name'); + } + + const lookup = pluginSkillRuntime?.byName && typeof pluginSkillRuntime.byName === 'object' + ? pluginSkillRuntime.byName + : {}; + const plugin = lookup[requestedName]; + if (!plugin) { + const available = pluginCatalog().map((entry) => entry.name).join(', ') || '(none)'; + throw new Error(`Unknown plugin "${name}". Available plugins: ${available}`); + } + + if (section === undefined || section === null || String(section).trim() === '') { + if (plugin.text && plugin.text.trim()) return plugin.text; + if (plugin.description && plugin.description.trim()) { + return `${plugin.name}: ${plugin.description.trim()}`; + } + return `${plugin.name} has no SKILL.md help text.`; + } + + const normalizedSection = String(section) + .toLowerCase() + .trim() + .replace(/^[\d.)\s-]+/, '') + .replace(/[^\p{L}\p{N}\s-]/gu, '') + .replace(/\s+/g, ' ') + .trim(); + const sections = plugin.sections && typeof plugin.sections === 'object' ? plugin.sections : {}; + if (sections[normalizedSection]) return sections[normalizedSection]; + const availableSections = Object.keys(sections).join(', ') || '(none)'; + throw new Error( + `Unknown section "${section}" for plugin "${plugin.name}". Available sections: ${availableSections}` + ); + }; + + const reservedContextNames = new Set([ + 'browserforceSettings', + 'browserforceRestrictions', + 'page', + 'context', + 'state', + 'snapshot', + 'refToLocator', + 'waitForPageLoad', + 'getLogs', + 'clearLogs', + 'getCDPSession', + 'screenshotWithAccessibilityLabels', + 'cleanHTML', + 'pageMarkdown', + 'pluginCatalog', + 'pluginHelp', + 'fetch', + 'URL', + 'URLSearchParams', + 'Buffer', + 'setTimeout', + 'clearTimeout', + 'TextEncoder', + 'TextDecoder', + ]); + // Wrap plugin helpers to auto-inject (page, ctx, state) as first three args const wrappedPluginHelpers = {}; for (const [name, fn] of Object.entries(pluginHelpers)) { + if (reservedContextNames.has(name)) { + process.stderr.write(`[bf-plugins] Ignoring helper "${name}" because it conflicts with a built-in\n`); + continue; + } wrappedPluginHelpers[name] = (...args) => { let pg = null; try { pg = activePage(); } catch { /* no active page */ } @@ -686,6 +765,7 @@ export function buildExecContext( page: defaultPage, context: ctx, state: userState, snapshot, refToLocator, waitForPageLoad, getLogs, clearLogs, getCDPSession, screenshotWithAccessibilityLabels, cleanHTML, pageMarkdown, + pluginCatalog, pluginHelp, fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder, }; diff --git a/mcp/src/index.js b/mcp/src/index.js index 541796c..802dd77 100644 --- a/mcp/src/index.js +++ b/mcp/src/index.js @@ -11,7 +11,12 @@ import { ensureRelay, connectOverCdpWithBusyRetry, CodeExecutionTimeoutError, buildExecContext, runCode, formatResult, } from './exec-engine.js'; -import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from './plugin-loader.js'; +import { + loadPlugins, + buildPluginHelpers, + buildPluginSkillAppendix, + buildPluginSkillRuntime, +} from './plugin-loader.js'; import { checkForUpdate } from './update-check.js'; // ─── Console Log Capture ───────────────────────────────────────────────────── @@ -261,6 +266,7 @@ async function getBrowserforceRestrictionsForSession() { let plugins = []; let pluginHelpers = {}; +let pluginSkillRuntime = { catalog: [], byName: {} }; // ─── Update State ──────────────────────────────────────────────────────────── // Checked once at startup; notice injected into first execute response only. @@ -309,9 +315,16 @@ Helpers: Falls back to raw body text for non-article pages. getCDPSession({ page }) Create a relay-safe raw CDP session for a page. Use this instead of page.context().newCDPSession(page). + pluginCatalog() Returns installed plugin metadata (metadata-first discovery). + pluginHelp(name, section?) Returns on-demand SKILL help for one plugin from in-memory cache. Globals: fetch, URL, URLSearchParams, Buffer, setTimeout, clearTimeout, TextEncoder, TextDecoder +Plugin workflow (metadata-first): + 1) Call pluginCatalog() to discover plugin names, helper names, and available sections. + 2) Call pluginHelp(name, section?) only when you need plugin-specific instructions. + 3) Avoid calling pluginHelp blindly for every plugin. + ═══ FIRST CALL — PAGE SETUP ═══ IMPORTANT: Do NOT navigate the user's existing tabs. Always create or reuse a dedicated tab. @@ -491,7 +504,7 @@ function registerExecuteTool(skillAppendix = '') { if (page) setupConsoleCapture(page); const execCtx = buildExecContext(page, ctx, userState, { consoleLogs, setupConsoleCapture, - }, pluginHelpers, agentPreferences, browserforceRestrictions); + }, pluginHelpers, agentPreferences, browserforceRestrictions, pluginSkillRuntime); try { const result = await runCode(code, execCtx, timeout); const formatted = formatResult(result); @@ -550,6 +563,7 @@ async function initPlugins() { try { plugins = await loadPlugins(); pluginHelpers = buildPluginHelpers(plugins); + pluginSkillRuntime = buildPluginSkillRuntime(plugins); if (plugins.length > 0) { process.stderr.write(`[bf-mcp] Loaded ${plugins.length} plugin(s): ${plugins.map(p => p.name).join(', ')}\n`); } diff --git a/mcp/test/mcp-plugin-integration.test.js b/mcp/test/mcp-plugin-integration.test.js index ec128c6..1567fd3 100644 --- a/mcp/test/mcp-plugin-integration.test.js +++ b/mcp/test/mcp-plugin-integration.test.js @@ -6,7 +6,12 @@ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { buildExecContext, runCode } from '../src/exec-engine.js'; -import { loadPlugins, buildPluginHelpers, buildPluginSkillAppendix } from '../src/plugin-loader.js'; +import { + loadPlugins, + buildPluginHelpers, + buildPluginSkillAppendix, + buildPluginSkillRuntime, +} from '../src/plugin-loader.js'; test('plugin helper is callable in execute scope after loadPlugins', async () => { const dir = await mkdtemp(join(tmpdir(), 'bf-mcp-test-')); @@ -33,18 +38,52 @@ test('plugin helper is callable in execute scope after loadPlugins', async () => await rm(dir, { recursive: true }); }); -test('plugin SKILL.md content is included in plugin appendix', async () => { +test('plugin appendix is metadata-only and runtime help remains available', async () => { const dir = await mkdtemp(join(tmpdir(), 'bf-mcp-test-')); const pluginDir = join(dir, 'tagger'); await mkdir(pluginDir); - await writeFile(join(pluginDir, 'index.js'), `export default { name: 'tagger', helpers: {} };`); - await writeFile(join(pluginDir, 'SKILL.md'), 'Use tagger() to tag elements.'); + await writeFile(join(pluginDir, 'index.js'), ` + export default { + name: 'tagger', + helpers: { tagger: () => 'ok' }, + }; + `); + await writeFile(join(pluginDir, 'SKILL.md'), `--- +name: tagger +description: Tags elements with labels. +--- +Use tagger() to tag elements. + +## examples +- tagger('hero')`); const plugins = await loadPlugins(dir); const appendix = buildPluginSkillAppendix(plugins); + const pluginSkillRuntime = buildPluginSkillRuntime(plugins); + const mockPage = { isClosed: () => false, url: () => 'about:blank', title: async () => '' }; + + const ctx = buildExecContext( + mockPage, + { pages: () => [mockPage] }, + {}, + {}, + buildPluginHelpers(plugins), + {}, + {}, + pluginSkillRuntime, + ); assert.ok(appendix.includes('PLUGIN: tagger')); - assert.ok(appendix.includes('Use tagger() to tag elements.')); + assert.ok(appendix.includes('Tags elements with labels.')); + assert.ok(!appendix.includes('Use tagger() to tag elements.')); + + const catalog = await runCode('return pluginCatalog()', ctx, 5000); + assert.equal(Array.isArray(catalog), true); + assert.equal(catalog[0].name, 'tagger'); + assert.equal(catalog[0].description, 'Tags elements with labels.'); + + const help = await runCode('return pluginHelp("tagger", "examples")', ctx, 5000); + assert.ok(help.includes("tagger('hero')")); await rm(dir, { recursive: true }); }); diff --git a/mcp/test/mcp-tools.test.js b/mcp/test/mcp-tools.test.js index cd0b543..c3cb82e 100644 --- a/mcp/test/mcp-tools.test.js +++ b/mcp/test/mcp-tools.test.js @@ -123,6 +123,9 @@ describe('Tool Definitions', () => { assert.ok(promptBlock.includes('getCDPSession({ page })'), 'should mention relay-safe getCDPSession helper usage'); assert.ok(promptBlock.includes('cleanHTML'), 'should mention cleanHTML helper'); assert.ok(promptBlock.includes('pageMarkdown'), 'should mention pageMarkdown helper'); + assert.ok(promptBlock.includes('pluginCatalog()'), 'should mention pluginCatalog built-in helper'); + assert.ok(promptBlock.includes('pluginHelp(name, section?)'), 'should mention pluginHelp built-in helper'); + assert.ok(promptBlock.includes('metadata-first'), 'should guide plugin usage as metadata-first'); assert.ok(promptBlock.includes('newPage'), 'should mention creating new tabs'); // Anti-patterns section assert.ok(promptBlock.includes('ANTI-PATTERN') || promptBlock.includes('Don\'t') || promptBlock.includes('✗'), 'should include anti-patterns'); diff --git a/mcp/test/plugin-loader.test.js b/mcp/test/plugin-loader.test.js index 6ed6666..1166ea8 100644 --- a/mcp/test/plugin-loader.test.js +++ b/mcp/test/plugin-loader.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; test('loadPlugins returns empty array when dir does not exist', async () => { const { loadPlugins } = await import('../src/plugin-loader.js'); @@ -17,7 +18,14 @@ test('loadPlugins loads a valid plugin folder', async () => { await writeFile(join(pluginDir, 'index.js'), `export default { name: 'hello', helpers: { greet: async (page) => 'hi' } };` ); - await writeFile(join(pluginDir, 'SKILL.md'), '# hello\nUse greet() to say hi.'); + const skillSource = `--- +name: hello-skill +description: Friendly hello helper +tags: greeting,starter +--- +# hello +Use greet() to say hi.`; + await writeFile(join(pluginDir, 'SKILL.md'), skillSource); const { loadPlugins } = await import('../src/plugin-loader.js'); const plugins = await loadPlugins(dir); @@ -25,7 +33,136 @@ test('loadPlugins loads a valid plugin folder', async () => { assert.equal(plugins.length, 1); assert.equal(plugins[0].name, 'hello'); assert.equal(typeof plugins[0].helpers.greet, 'function'); - assert.equal(plugins[0]._skill, '# hello\nUse greet() to say hi.'); + assert.equal(plugins[0]._skill, skillSource); + assert.deepEqual(plugins[0]._skillMeta, { + name: 'hello-skill', + description: 'Friendly hello helper', + }); + assert.equal(plugins[0]._skillBody, '# hello\nUse greet() to say hi.'); + + await rm(dir, { recursive: true }); +}); + +test('loadPlugins ignores unknown SKILL frontmatter keys', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bf-test-')); + const pluginDir = join(dir, 'meta-keys'); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, 'index.js'), + `export default { name: 'meta-keys', helpers: { noop: async () => null } };` + ); + await writeFile( + join(pluginDir, 'SKILL.md'), + `--- +name: meta-keys +description: plugin metadata +helpers: noop +unknown: should-be-ignored +tags: also-ignored +--- +# Meta Keys +Details` + ); + + const { loadPlugins } = await import('../src/plugin-loader.js'); + const plugins = await loadPlugins(dir); + + assert.equal(plugins.length, 1); + assert.deepEqual(plugins[0]._skillMeta, { + name: 'meta-keys', + description: 'plugin metadata', + helpers: ['noop'], + }); + + await rm(dir, { recursive: true }); +}); + +test('loadPlugins preserves description text after first colon', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bf-test-')); + const pluginDir = join(dir, 'desc-colons'); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, 'index.js'), + `export default { name: 'desc-colons', helpers: { noop: async () => null } };` + ); + await writeFile( + join(pluginDir, 'SKILL.md'), + `--- +name: desc-colons +description: A: B: C +--- +# Desc Colons +Details` + ); + + const { loadPlugins } = await import('../src/plugin-loader.js'); + const plugins = await loadPlugins(dir); + + assert.equal(plugins.length, 1); + assert.equal(plugins[0]._skillMeta.description, 'A: B: C'); + + await rm(dir, { recursive: true }); +}); + +test('loadPlugins parses YAML list frontmatter values for canonical list keys', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bf-test-')); + const pluginDir = join(dir, 'yaml-lists'); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, 'index.js'), + `export default { name: 'yaml-lists', helpers: { alpha: async () => null } };` + ); + await writeFile( + join(pluginDir, 'SKILL.md'), + `--- +name: yaml-lists +description: Uses YAML list values +helpers: + - alpha + - beta +tools: + - read_sheet +when_to_use: + - First scenario + - Second scenario +--- +# YAML Lists +Body` + ); + + const { loadPlugins } = await import('../src/plugin-loader.js'); + const plugins = await loadPlugins(dir); + + assert.equal(plugins.length, 1); + assert.deepEqual(plugins[0]._skillMeta.helpers, ['alpha', 'beta']); + assert.deepEqual(plugins[0]._skillMeta.tools, ['read_sheet']); + assert.deepEqual(plugins[0]._skillMeta.when_to_use, ['First scenario', 'Second scenario']); + + await rm(dir, { recursive: true }); +}); + +test('loadPlugins tolerates malformed frontmatter without crashing', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bf-test-')); + const pluginDir = join(dir, 'malformed'); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, 'index.js'), + `export default { name: 'malformed', helpers: { noop: async () => null } };` + ); + const malformedSkill = `--- +name malformed +description missing colon +# no closing fence +# Malformed +Still loads`; + await writeFile(join(pluginDir, 'SKILL.md'), malformedSkill); + + const { loadPlugins } = await import('../src/plugin-loader.js'); + const plugins = await loadPlugins(dir); + + assert.equal(plugins.length, 1); + assert.deepEqual(plugins[0]._skillMeta, {}); + assert.equal(plugins[0]._skillBody, malformedSkill); await rm(dir, { recursive: true }); }); @@ -63,13 +200,135 @@ test('buildPluginHelpers merges helpers from multiple plugins', async () => { test('buildPluginSkillAppendix skips plugins with empty skill', async () => { const { buildPluginSkillAppendix } = await import('../src/plugin-loader.js'); const plugins = [ - { name: 'a', _skill: 'Use foo() for X.' }, - { name: 'b', _skill: '' }, - { name: 'c', _skill: 'Use bar() for Y.' }, + { + name: 'a', + helpers: { foo: () => 'x' }, + _skillMeta: { description: 'Helper for X' }, + _skillBody: 'Detailed instructions for X', + }, + { name: 'b', helpers: { noop: () => null }, _skillMeta: {}, _skillBody: '' }, + { + name: 'c', + helpers: { bar: () => 'y' }, + _skillMeta: { description: 'Helper for Y' }, + _skillBody: 'Detailed instructions for Y', + }, ]; const appendix = buildPluginSkillAppendix(plugins); + + assert.ok(appendix.includes('pluginCatalog()')); + assert.ok(appendix.includes('pluginHelp(name, section?)')); assert.ok(appendix.includes('PLUGIN: a')); - assert.ok(appendix.includes('Use foo() for X.')); + assert.ok(appendix.includes('Helper for X')); + assert.ok(appendix.includes('foo')); assert.ok(appendix.includes('PLUGIN: c')); + assert.ok(appendix.includes('Helper for Y')); assert.ok(!appendix.includes('PLUGIN: b')); + assert.ok(!appendix.includes('Detailed instructions for X')); + assert.ok(!appendix.includes('Detailed instructions for Y')); +}); + +test('loadPlugins parses block scalar frontmatter values for canonical keys', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bf-test-')); + const pluginDir = join(dir, 'block-scalars'); + await mkdir(pluginDir); + await writeFile( + join(pluginDir, 'index.js'), + `export default { name: 'block-scalars', helpers: { noop: async () => null } };` + ); + await writeFile( + join(pluginDir, 'SKILL.md'), + `--- +name: block-scalars +description: | + First line. + Second line. +when_to_use: > + Use this helper + when pages are ready. +--- +# Block Scalars +Body` + ); + + const { loadPlugins } = await import('../src/plugin-loader.js'); + const plugins = await loadPlugins(dir); + + assert.equal(plugins.length, 1); + assert.equal(plugins[0]._skillMeta.description, 'First line.\nSecond line.'); + assert.deepEqual(plugins[0]._skillMeta.when_to_use, ['Use this helper when pages are ready.']); + + await rm(dir, { recursive: true }); +}); + +test('buildPluginSkillRuntime ignores section headings inside fenced code blocks', async () => { + const { buildPluginSkillRuntime } = await import('../src/plugin-loader.js'); + const runtime = buildPluginSkillRuntime([ + { + name: 'fences', + helpers: {}, + _skillMeta: {}, + _skillBody: `Intro + +## usage +Visible section text. + +\`\`\`md +## not-a-section +\`\`\` + +## examples +Real examples section.`, + }, + ]); + + const usage = runtime.byName.fences.sections.usage; + assert.ok(usage.includes('Visible section text.')); + assert.ok(usage.includes('## not-a-section')); + assert.deepEqual(Object.keys(runtime.byName.fences.sections), ['usage', 'examples']); +}); + +test('buildPluginSkillRuntime keeps first plugin for duplicate normalized names and warns', async () => { + const { buildPluginSkillRuntime } = await import('../src/plugin-loader.js'); + + const originalStderrWrite = process.stderr.write; + let stderr = ''; + process.stderr.write = function patchedWrite(chunk, ...args) { + stderr += String(chunk); + const maybeCallback = args[args.length - 1]; + if (typeof maybeCallback === 'function') maybeCallback(); + return true; + }; + + try { + const runtime = buildPluginSkillRuntime([ + { name: 'Dupe', helpers: {}, _skillMeta: {}, _skillBody: '## one\nfirst' }, + { name: 'dupe', helpers: {}, _skillMeta: {}, _skillBody: '## one\nsecond' }, + ]); + + assert.equal(runtime.catalog.length, 1); + assert.equal(runtime.catalog[0].name, 'Dupe'); + assert.equal(runtime.byName.dupe.name, 'Dupe'); + assert.equal(runtime.byName.dupe.sections.one, 'first'); + assert.match(stderr, /Duplicate plugin skill name/i); + assert.match(stderr, /Keeping first/i); + } finally { + process.stderr.write = originalStderrWrite; + } +}); + +test('loadPlugins parses metadata shape from official google-sheets SKILL fixture', async () => { + const officialPluginsDir = fileURLToPath(new URL('../../plugins/official', import.meta.url)); + const { loadPlugins } = await import('../src/plugin-loader.js'); + const plugins = await loadPlugins(officialPluginsDir); + const googleSheets = plugins.find((plugin) => plugin.name === 'google-sheets'); + + assert.ok(googleSheets); + assert.equal(googleSheets._skillMeta.name, 'google-sheets'); + assert.equal(typeof googleSheets._skillMeta.description, 'string'); + assert.equal(Array.isArray(googleSheets._skillMeta.when_to_use), true); + assert.equal(Array.isArray(googleSheets._skillMeta.helpers), true); + assert.equal(Array.isArray(googleSheets._skillMeta.tools), true); + assert.ok(googleSheets._skillMeta.when_to_use.length > 0); + assert.ok(googleSheets._skillMeta.helpers.length > 0); }); diff --git a/plugins/official/google-sheets/SKILL.md b/plugins/official/google-sheets/SKILL.md index 82a0fca..3bae8d5 100644 --- a/plugins/official/google-sheets/SKILL.md +++ b/plugins/official/google-sheets/SKILL.md @@ -1,3 +1,11 @@ +--- +name: google-sheets +description: Google Sheets helpers for reading, summarizing, formatting, and issue logging in the active sheet. +when_to_use: ["Summarizing an active Google Sheet quickly", "Reading specific cells or contiguous used rows", "Applying bullet splitting and sparse bold formatting across ranges", "Logging extraction or formatting failures for follow-up"] +helpers: ["gsGetMeta", "gsGotoCell", "gsReadCell", "gsReadContiguousRows", "gsSummarizeSheet", "gsSplitBulletsInRange", "gsRebalanceBoldInRange", "gsFormatBulletsInRange", "gsLogIssue", "gsIssueLogPath"] +tools: [] +--- + ## google-sheets plugin Use Google Sheets helpers when work involves reading, summarizing, or structuring sheet content from the active page without guesswork. @@ -32,6 +40,7 @@ When the user says "summarize this page/sheet", "read this sheet", or equivalent - Use `gsReadContiguousRows({ columns: ['A','B'], startRow: 1, maxRows: 30, emptyStreakStop: 2 })`. - Always report `scannedRows`, `usedRowCount`, and `stopReason` when summarizing extraction. - For summary requests, prefer `gsSummarizeSheet()` over ad-hoc DOM probing loops. +- `gsSummarizeSheet()` reuses a recent in-session scan by default; set `forceRefresh: true` when the user asks for a guaranteed fresh pull. - Prefer `gsFormatBulletsInRange()` for multi-cell content cleanup tasks. - Use `dryRun: true` first for formatting helpers when changing many cells. - Log every process failure or unexpected behavior with `gsLogIssue(...)`. diff --git a/plugins/official/google-sheets/index.js b/plugins/official/google-sheets/index.js index 1ede7cd..fa60bb6 100644 --- a/plugins/official/google-sheets/index.js +++ b/plugins/official/google-sheets/index.js @@ -5,6 +5,9 @@ import { homedir } from 'node:os'; const DEFAULT_SCAN_MAX_ROWS = 30; const DEFAULT_EMPTY_STREAK_STOP = 2; const DEFAULT_EDITOR_WAIT_MS = 35; +const DEFAULT_SUMMARY_CACHE_TTL_MS = 5 * 60 * 1000; +const SUMMARY_CACHE_MAX_ENTRIES = 24; +const SUMMARY_CACHE_STATE_KEY = '__gsSummaryCache'; const DEFAULT_LOG_PATH = join(homedir(), '.browserforce', 'logs', 'google-sheets-issues.jsonl'); const SHEETS_URL_RE = /^https:\/\/docs\.google\.com\/spreadsheets\//; @@ -511,6 +514,124 @@ async function inferColumnsFromHeaderRow(page, options = {}) { return fallback; } +function getSummaryScanConfig(options = {}, explicitColumns = null) { + const startRow = Number.isInteger(options.startRow) && options.startRow > 0 ? options.startRow : 1; + const maxRows = Number.isInteger(options.maxRows) && options.maxRows > 0 + ? options.maxRows + : DEFAULT_SCAN_MAX_ROWS; + const emptyStreakStop = Number.isInteger(options.emptyStreakStop) && options.emptyStreakStop > 0 + ? options.emptyStreakStop + : DEFAULT_EMPTY_STREAK_STOP; + const trim = options.trim !== false; + + if (explicitColumns) { + return { + mode: 'explicit', + columns: explicitColumns, + startRow, + maxRows, + emptyStreakStop, + trim, + }; + } + + const maxColumns = Number.isInteger(options.maxColumns) && options.maxColumns > 0 + ? options.maxColumns + : 8; + const emptyColumnStreakStop = Number.isInteger(options.emptyColumnStreakStop) && options.emptyColumnStreakStop > 0 + ? options.emptyColumnStreakStop + : 1; + const fallbackColumnsCount = Number.isInteger(options.fallbackColumnsCount) && options.fallbackColumnsCount > 0 + ? options.fallbackColumnsCount + : 2; + const startColumn = normalizeColumns([options.startColumn || 'A'])[0]; + + return { + mode: 'auto', + startRow, + maxRows, + emptyStreakStop, + trim, + startColumn, + maxColumns, + emptyColumnStreakStop, + fallbackColumnsCount, + }; +} + +function buildSummaryCacheKey(sheetMeta, options = {}, explicitColumns = null) { + const identity = { + spreadsheetId: sheetMeta?.spreadsheetId || null, + gid: sheetMeta?.gid || null, + }; + const config = getSummaryScanConfig(options, explicitColumns); + return JSON.stringify({ identity, config }); +} + +function getSummaryCacheMap(state) { + if (!state || typeof state !== 'object') return null; + if (!(state[SUMMARY_CACHE_STATE_KEY] instanceof Map)) { + state[SUMMARY_CACHE_STATE_KEY] = new Map(); + } + return state[SUMMARY_CACHE_STATE_KEY]; +} + +function readSummaryCacheEntry(state, cacheKey, ttlMs) { + const cache = getSummaryCacheMap(state); + if (!cache) return null; + + const entry = cache.get(cacheKey); + if (!entry) return null; + + const ageMs = Date.now() - entry.cachedAt; + if (ttlMs >= 0 && ageMs > ttlMs) { + cache.delete(cacheKey); + return null; + } + + return entry; +} + +function writeSummaryCacheEntry(state, cacheKey, value) { + const cache = getSummaryCacheMap(state); + if (!cache) return; + + cache.delete(cacheKey); + cache.set(cacheKey, { cachedAt: Date.now(), ...value }); + + while (cache.size > SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + cache.delete(oldestKey); + } +} + +function clearSummaryCache(state) { + const cache = getSummaryCacheMap(state); + if (cache) cache.clear(); +} + +function buildSummaryResult(sheet, columns, scanResult, options = {}) { + const includeRows = options.includeRows === true; + const previewRows = Number.isInteger(options.previewRows) && options.previewRows > 0 ? options.previewRows : 8; + const preview = scanResult.rows.slice(0, previewRows).map((entry) => ({ row: entry.row, cells: entry.cells })); + const firstDataRow = scanResult.rows[0] || null; + const headerCandidate = scanResult.rows.find((entry) => entry.row === scanResult.config.startRow) || null; + + return { + sheet, + columns, + scan: { + scannedRows: scanResult.scannedRows, + usedRowCount: scanResult.usedRowCount, + stopReason: scanResult.stopReason, + }, + firstDataRow: firstDataRow ? { row: firstDataRow.row, cells: firstDataRow.cells } : null, + headerCandidate: headerCandidate ? { row: headerCandidate.row, cells: headerCandidate.cells } : null, + preview, + ...(includeRows ? { rows: scanResult.rows } : {}), + }; +} + export default { name: 'google-sheets', description: 'Google Sheets helpers for reliable row scanning, cell reads, and issue logging', @@ -543,30 +664,30 @@ export default { gsSummarizeSheet: async (page, ctx, state, options = {}) => { assertGoogleSheet(page, 'gsSummarizeSheet'); const title = await page.title(); - const sheet = { ...parseSheetMeta(page.url()), title }; - const includeRows = options.includeRows === true; - const previewRows = Number.isInteger(options.previewRows) && options.previewRows > 0 ? options.previewRows : 8; - const columns = options.columns - ? normalizeColumns(options.columns) - : await inferColumnsFromHeaderRow(page, options); + const sheetMeta = parseSheetMeta(page.url()); + const sheet = { ...sheetMeta, title }; + const explicitColumns = options.columns ? normalizeColumns(options.columns) : null; + const forceRefresh = options.forceRefresh === true; + const useCache = options.useCache !== false; + const cacheTtlMs = Number.isInteger(options.cacheTtlMs) && options.cacheTtlMs >= 0 + ? options.cacheTtlMs + : DEFAULT_SUMMARY_CACHE_TTL_MS; + const cacheKey = buildSummaryCacheKey(sheetMeta, options, explicitColumns); + + if (useCache && !forceRefresh) { + const cached = readSummaryCacheEntry(state, cacheKey, cacheTtlMs); + if (cached) { + return buildSummaryResult(sheet, cached.columns, cached.scanResult, options); + } + } + + const columns = explicitColumns || await inferColumnsFromHeaderRow(page, options); const scanResult = await scanContiguousRows(page, { ...options, columns }); - const preview = scanResult.rows.slice(0, previewRows).map((entry) => ({ row: entry.row, cells: entry.cells })); - const firstDataRow = scanResult.rows[0] || null; - const headerCandidate = scanResult.rows.find((entry) => entry.row === scanResult.config.startRow) || null; + if (useCache) { + writeSummaryCacheEntry(state, cacheKey, { columns, scanResult }); + } - return { - sheet, - columns, - scan: { - scannedRows: scanResult.scannedRows, - usedRowCount: scanResult.usedRowCount, - stopReason: scanResult.stopReason, - }, - firstDataRow: firstDataRow ? { row: firstDataRow.row, cells: firstDataRow.cells } : null, - headerCandidate: headerCandidate ? { row: headerCandidate.row, cells: headerCandidate.cells } : null, - preview, - ...(includeRows ? { rows: scanResult.rows } : {}), - }; + return buildSummaryResult(sheet, columns, scanResult, options); }, gsLogIssue: async (page, ctx, state, summary, details = {}, options = {}) => { @@ -654,13 +775,20 @@ export default { } } + const changedCount = results.filter((r) => r.changed).length; + const unchangedCount = results.filter((r) => r.status === 'unchanged').length; + const okCount = results.filter((r) => r.status === 'ok' || r.status === 'dry_run').length; + const failedCount = results.filter((r) => r.status === 'error' || r.status === 'verify_failed').length; + + if (!dryRun && changedCount > 0) clearSummaryCache(state); + return { rangeRef: String(rangeRef), total: results.length, - changed: results.filter((r) => r.changed).length, - unchanged: results.filter((r) => r.status === 'unchanged').length, - ok: results.filter((r) => r.status === 'ok' || r.status === 'dry_run').length, - failed: results.filter((r) => r.status === 'error' || r.status === 'verify_failed').length, + changed: changedCount, + unchanged: unchangedCount, + ok: okCount, + failed: failedCount, results, }; }, @@ -743,13 +871,20 @@ export default { } } + const changedCount = results.filter((r) => r.changed).length; + const unchangedCount = results.filter((r) => r.status === 'unchanged').length; + const okCount = results.filter((r) => r.status === 'ok' || r.status === 'dry_run').length; + const failedCount = results.filter((r) => r.status === 'error' || r.status === 'verify_failed').length; + + if (!dryRun && changedCount > 0) clearSummaryCache(state); + return { rangeRef: String(rangeRef), total: results.length, - changed: results.filter((r) => r.changed).length, - unchanged: results.filter((r) => r.status === 'unchanged').length, - ok: results.filter((r) => r.status === 'ok' || r.status === 'dry_run').length, - failed: results.filter((r) => r.status === 'error' || r.status === 'verify_failed').length, + changed: changedCount, + unchanged: unchangedCount, + ok: okCount, + failed: failedCount, results, }; }, @@ -843,13 +978,20 @@ export default { } } + const changedCount = results.filter((r) => r.changed).length; + const unchangedCount = results.filter((r) => r.status === 'unchanged').length; + const okCount = results.filter((r) => r.status === 'ok' || r.status === 'dry_run').length; + const failedCount = results.filter((r) => r.status === 'error' || r.status === 'verify_failed').length; + + if (!dryRun && changedCount > 0) clearSummaryCache(state); + return { rangeRef: String(rangeRef), total: results.length, - changed: results.filter((r) => r.changed).length, - unchanged: results.filter((r) => r.status === 'unchanged').length, - ok: results.filter((r) => r.status === 'ok' || r.status === 'dry_run').length, - failed: results.filter((r) => r.status === 'error' || r.status === 'verify_failed').length, + changed: changedCount, + unchanged: unchangedCount, + ok: okCount, + failed: failedCount, results, }; }, diff --git a/plugins/official/highlight/SKILL.md b/plugins/official/highlight/SKILL.md index 6578142..4ae7633 100644 --- a/plugins/official/highlight/SKILL.md +++ b/plugins/official/highlight/SKILL.md @@ -1,3 +1,11 @@ +--- +name: highlight +description: Visual outlining helpers that highlight matching elements and clear applied outlines. +when_to_use: ["Visually identifying matched elements before interaction", "Debugging selectors on complex pages", "Clearing temporary visual outlines after inspection"] +helpers: ["highlight", "clearHighlights"] +tools: [] +--- + ## highlight(selector, color?) Visually highlight matching elements with a colored outline. Default color: red. Returns the number of elements highlighted. diff --git a/plugins/official/openclaw/SKILL.md b/plugins/official/openclaw/SKILL.md index 4069ff9..57855cd 100644 --- a/plugins/official/openclaw/SKILL.md +++ b/plugins/official/openclaw/SKILL.md @@ -1,3 +1,11 @@ +--- +name: openclaw +description: BrowserForce tab-attachment policy notes for OpenClaw operating constraints. +when_to_use: ["Applying OpenClaw tab policy in BrowserForce sessions", "Deciding whether to auto-create a dedicated tab", "Reporting actionable attach/share blockers to the user"] +helpers: [] +tools: [] +--- + ## BrowserForce tab policy (OpenClaw) - Do not ask the user to click Attach/Share by default. diff --git a/relay/src/index.js b/relay/src/index.js index 1335f1f..2545d22 100644 --- a/relay/src/index.js +++ b/relay/src/index.js @@ -409,7 +409,8 @@ class RelayServer { // ─── Plugin Routes ─────────────────────────────────────────────────────── - if (url.pathname === '/plugins' && req.method === 'GET') { + const isPluginsListPath = url.pathname === '/plugins' || url.pathname === '/v1/plugins'; + if (isPluginsListPath && req.method === 'GET') { try { const entries = fs.existsSync(this.pluginsDir) ? fs.readdirSync(this.pluginsDir, { withFileTypes: true }) @@ -424,7 +425,8 @@ class RelayServer { return; } - if (url.pathname === '/plugins/install' && req.method === 'POST') { + const isPluginsInstallPath = url.pathname === '/plugins/install' || url.pathname === '/v1/plugins/install'; + if (isPluginsInstallPath && req.method === 'POST') { if (!this._requireAuth(req, res)) return; let body = ''; req.on('data', chunk => { body += chunk; }); @@ -447,7 +449,7 @@ class RelayServer { return; } - const deleteMatch = url.pathname.match(/^\/plugins\/([a-z0-9_-]+)$/); + const deleteMatch = url.pathname.match(/^\/(?:v1\/)?plugins\/([a-z0-9_-]+)$/); if (deleteMatch && req.method === 'DELETE') { if (!this._requireAuth(req, res)) return; const name = deleteMatch[1]; From 81d51ba4ba15249ada75520ec29aa99728b55e2a Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 23:33:37 +0530 Subject: [PATCH 174/192] agent-panel: start fresh session when opened from popup - persist a one-shot open-agent request in popup before opening side panel - consume/watch that request in agent panel to create a new conversation on open - keep existing behavior for normal tab switching while panel remains open - add contract tests for popup signal and panel fresh-session handling --- extension/agent-panel.js | 75 +++++++++++++++++++- extension/popup.js | 9 +++ test/agent/agent-panel-send-contract.test.js | 16 +++++ test/agent/popup-contract.test.js | 4 ++ 4 files changed, 103 insertions(+), 1 deletion(-) diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 209f761..66b59d1 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -16,6 +16,8 @@ const REASONING_PRESETS = [ { value: 'high', label: 'High' }, { value: 'xhigh', label: 'Extra High' }, ]; +const BROWSERFORCE_AGENT_OPEN_REQUEST_KEY = 'browserforceAgentOpenRequest'; +const BROWSERFORCE_AGENT_OPEN_REQUEST_MAX_AGE_MS = 60_000; const state = { value: initialState, @@ -27,6 +29,9 @@ const state = { latestReasoningTitleByRun: {}, transcriptHandlersBound: false, tabAttachWatchersBound: false, + agentOpenRequestWatcherBound: false, + lastHandledAgentOpenRequestId: null, + pendingAgentOpenRequest: null, initialTabAttachInFlight: false, initialTabAttachStarted: false, editingSessionId: null, @@ -885,6 +890,65 @@ function escapeHtml(value) { return div.innerHTML; } +function normalizeAgentOpenRequest(raw) { + if (!raw || typeof raw !== 'object') return null; + const requestId = String(raw.requestId || '').trim(); + const requestedAt = Number(raw.requestedAt); + if (!requestId || !Number.isFinite(requestedAt)) return null; + if ((Date.now() - requestedAt) > BROWSERFORCE_AGENT_OPEN_REQUEST_MAX_AGE_MS) return null; + return { + requestId, + requestedAt, + source: String(raw.source || '').trim() || null, + }; +} + +async function consumePendingAgentOpenRequest() { + if (!chrome?.storage?.local?.get || !chrome?.storage?.local?.remove) return null; + try { + const stored = await chrome.storage.local.get([BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]); + const request = normalizeAgentOpenRequest(stored?.[BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]); + if (!request) return null; + await chrome.storage.local.remove(BROWSERFORCE_AGENT_OPEN_REQUEST_KEY); + state.lastHandledAgentOpenRequestId = request.requestId; + return request; + } catch { + return null; + } +} + +async function startFreshSessionFromOpenRequest(rawRequest) { + const request = normalizeAgentOpenRequest(rawRequest); + if (!request) return; + if (state.lastHandledAgentOpenRequestId === request.requestId) return; + state.lastHandledAgentOpenRequestId = request.requestId; + if (!state.auth) { + state.pendingAgentOpenRequest = request; + return; + } + try { + await chrome.storage.local.remove(BROWSERFORCE_AGENT_OPEN_REQUEST_KEY); + } catch { + // best-effort cleanup + } + state.pendingAgentOpenRequest = null; + await createSession(); +} + +function bindAgentOpenRequestWatcher() { + if (state.agentOpenRequestWatcherBound) return; + if (!chrome?.storage?.onChanged?.addListener) return; + state.agentOpenRequestWatcherBound = true; + chrome.storage.onChanged.addListener((changes, areaName) => { + if (areaName !== 'local') return; + const change = changes?.[BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]; + if (!change?.newValue) return; + startFreshSessionFromOpenRequest(change.newValue).catch((error) => { + setStatus('error', error?.message || 'Unable to start a new conversation'); + }); + }); +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -1429,6 +1493,15 @@ async function initializePanel() { setComposerEnabled(false); setStatus('info', 'Connecting...'); render(); + bindAgentOpenRequestWatcher(); + const openRequest = await consumePendingAgentOpenRequest(); + let shouldStartFreshSession = !!openRequest; + if (shouldStartFreshSession) { + state.pendingAgentOpenRequest = null; + } else if (state.pendingAgentOpenRequest) { + shouldStartFreshSession = true; + state.pendingAgentOpenRequest = null; + } startInitialTabAttach(); await loadAuth(); bindTabAttachWatchers(); @@ -1439,7 +1512,7 @@ async function initializePanel() { state.defaultReasoningEffort = 'medium'; } await loadSessions(); - if (!state.value.activeSessionId) { + if (shouldStartFreshSession || !state.value.activeSessionId) { await createSession(); } else { await selectSession(state.value.activeSessionId); diff --git a/extension/popup.js b/extension/popup.js index a0d4bee..70ba4ed 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -1,6 +1,7 @@ // BrowserForce — Popup UI const RELAY_URL_DEFAULT = 'ws://127.0.0.1:19222/extension'; +const BROWSERFORCE_AGENT_OPEN_REQUEST_KEY = 'browserforceAgentOpenRequest'; // Auto-generated instruction lines per restriction const RESTRICTION_LINES = { @@ -199,6 +200,14 @@ attachBtn.addEventListener('click', () => { openAgentBtn.addEventListener('click', async () => { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + await chrome.storage.local.set({ + [BROWSERFORCE_AGENT_OPEN_REQUEST_KEY]: { + requestId: (globalThis.crypto?.randomUUID?.() || `bf-open-${Date.now()}`), + requestedAt: Date.now(), + source: 'popup-open-agent', + tabId: Number.isFinite(tab?.id) ? Number(tab.id) : null, + }, + }); await chrome.sidePanel.open({ windowId: tab?.windowId }); window.close(); } catch { diff --git a/test/agent/agent-panel-send-contract.test.js b/test/agent/agent-panel-send-contract.test.js index a79b92c..8fdec08 100644 --- a/test/agent/agent-panel-send-contract.test.js +++ b/test/agent/agent-panel-send-contract.test.js @@ -88,6 +88,22 @@ test('init opens smoothly by starting tab attach asynchronously', () => { assert.doesNotMatch(initBlock, /await ensureCurrentTabAttached\(\);/); }); +test('popup open-agent request can force a fresh session on panel init', () => { + assert.match(js, /BROWSERFORCE_AGENT_OPEN_REQUEST_KEY/); + assert.match(js, /function normalizeAgentOpenRequest\(/); + assert.match(js, /async function consumePendingAgentOpenRequest\(/); + assert.match(js, /async function initializePanel\(\)[\s\S]*consumePendingAgentOpenRequest\(\)/); + assert.match(js, /if \(shouldStartFreshSession \|\| !state\.value\.activeSessionId\)\s*\{\s*await createSession\(\);/); +}); + +test('panel watches open-agent request changes and starts a fresh session when already open', () => { + assert.match(js, /function bindAgentOpenRequestWatcher\(/); + assert.match(js, /chrome\.storage\.onChanged\.addListener/); + assert.match(js, /changes\?\.\[BROWSERFORCE_AGENT_OPEN_REQUEST_KEY\]/); + assert.match(js, /startFreshSessionFromOpenRequest\(change\.newValue\)/); + assert.match(js, /if \(!state\.auth\)\s*\{[\s\S]*state\.pendingAgentOpenRequest = request;/); +}); + test('tab-attach banner shows progress during initial auto-attach and suppresses not-connected state', () => { assert.match(js, /function getTabAttachInProgressState\(\)/); assert.match(js, /text:\s*'Currently attaching active tab\.\.\.'/); diff --git a/test/agent/popup-contract.test.js b/test/agent/popup-contract.test.js index 72cd6f5..f83a5e1 100644 --- a/test/agent/popup-contract.test.js +++ b/test/agent/popup-contract.test.js @@ -17,6 +17,10 @@ test('logs viewer requests include extension identity header', () => { }); test('open agent action opens side panel and closes popup', () => { + assert.match(popupJs, /BROWSERFORCE_AGENT_OPEN_REQUEST_KEY/); + assert.match(popupJs, /chrome\.storage\.local\.set\(/); + assert.match(popupJs, /source:\s*'popup-open-agent'/); + assert.match(popupJs, /await chrome\.storage\.local\.set\([\s\S]*await chrome\.sidePanel\.open\(/); assert.match(popupJs, /chrome\.sidePanel\.open\(/); assert.match(popupJs, /window\.close\(\)/); }); From 3f0529e14ff4278a83f363377b63f8f4f99bf5ef Mon Sep 17 00:00:00 2001 From: Valsaraj Date: Wed, 4 Mar 2026 23:51:34 +0530 Subject: [PATCH 175/192] agent-panel: render full markdown in assistant chat - add block-level markdown renderer for assistant messages (headings, lists, task lists, blockquotes, code fences, tables, hr) - keep inline markdown rendering for timeline labels/details with safe links/images and emphasis - switch assistant bubble message rendering to markdown block output - add markdown styling for md-content blocks and expand runtime/contract coverage --- extension/agent-panel-runtime.js | 359 ++++++++++++++++++- extension/agent-panel.css | 167 +++++++++ extension/agent-panel.js | 7 +- test/agent/agent-panel-runtime.test.js | 68 ++++ test/agent/agent-panel-send-contract.test.js | 6 + 5 files changed, 601 insertions(+), 6 deletions(-) diff --git a/extension/agent-panel-runtime.js b/extension/agent-panel-runtime.js index 0cb7eef..8faa89a 100644 --- a/extension/agent-panel-runtime.js +++ b/extension/agent-panel-runtime.js @@ -36,10 +36,363 @@ function escapeHtml(value) { .replace(/'/g, '''); } +function escapeRegex(value) { + return String(value || '').replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function matchFencedBlockStart(line) { + const match = String(line || '').match(/^\s*(`{3,}|~{3,})\s*([\w+-]+)?\s*$/); + if (!match) return null; + return { + fence: match[1], + language: String(match[2] || '').trim().toLowerCase(), + }; +} + +function isFencedBlockClose(line, fence) { + if (!fence) return false; + const fenceChar = fence[0]; + const fenceLength = fence.length; + const matcher = new RegExp(`^\\s*${escapeRegex(fenceChar)}{${fenceLength},}\\s*$`); + return matcher.test(String(line || '')); +} + +function isMarkdownHeading(line) { + return /^\s{0,3}#{1,6}\s+\S/.test(String(line || '')); +} + +function isMarkdownHorizontalRule(line) { + return /^\s{0,3}(?:\*{3,}|-{3,}|_{3,})\s*$/.test(String(line || '')); +} + +function isMarkdownBlockquote(line) { + return /^\s{0,3}>\s?/.test(String(line || '')); +} + +function matchMarkdownListItem(line) { + const match = String(line || '').match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/); + if (!match) return null; + const marker = match[2]; + return { + indent: match[1].length, + ordered: /\d+\./.test(marker), + content: String(match[3] || '').trim(), + }; +} + +function isMarkdownTableSeparator(line) { + const normalized = String(line || '').trim(); + if (!normalized.includes('|')) return false; + return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/.test(normalized); +} + +function splitMarkdownTableRow(line) { + const raw = String(line || '').trim(); + if (!raw) return []; + const startTrimmed = raw.startsWith('|') ? raw.slice(1) : raw; + const value = startTrimmed.endsWith('|') ? startTrimmed.slice(0, -1) : startTrimmed; + return value.split('|').map((cell) => cell.trim()); +} + +function parseMarkdownTableAlignments(separatorLine) { + const cells = splitMarkdownTableRow(separatorLine); + return cells.map((cell) => { + const value = String(cell || '').trim(); + const left = value.startsWith(':'); + const right = value.endsWith(':'); + if (left && right) return 'center'; + if (right) return 'right'; + if (left) return 'left'; + return ''; + }); +} + +function looksLikeImageUrl(url) { + const value = String(url || '').trim(); + if (!value) return false; + if (/^data:image\//i.test(value)) return true; + const cleaned = value.split('#')[0].split('?')[0]; + return /\.(png|jpe?g|gif|webp|bmp|svg|avif)$/i.test(cleaned); +} + +function normalizeRenderableUrl(url) { + const value = String(url || '').trim(); + if (!value) return ''; + + if ( + /^\/(?:tmp|private|var|Users|home|Volumes)\//.test(value) + || /^\/var\/folders\//.test(value) + ) { + return `file://${value}`; + } + + return value; +} + +function isSafeRenderableUrl(url) { + const value = String(url || '').trim(); + if (!value) return false; + return ( + /^https?:\/\//i.test(value) + || /^file:\/\//i.test(value) + || /^blob:/i.test(value) + || /^data:image\//i.test(value) + || /^(?:\.{1,2}\/)/.test(value) + || /^\/(?!\/)/.test(value) + ); +} + +function createMarkdownTokenStore() { + const tokens = []; + return { + put(html) { + const key = `__BF_INLINE_TOKEN_${tokens.length}__`; + tokens.push({ key, html }); + return key; + }, + apply(text) { + let output = text; + for (const token of tokens) { + output = output.replaceAll(token.key, token.html); + } + return output; + }, + }; +} + export function renderInlineContent(value) { - return escapeHtml(value) - .replace(/`([^`]+)`/g, '$1') - .replace(/\*\*([^*]+)\*\*/g, '$1'); + const store = createMarkdownTokenStore(); + const source = String(value ?? ''); + + const withCodeTokens = source.replace(/`([^`\n]+)`/g, (_match, codeRaw) => ( + store.put(`${escapeHtml(codeRaw)}`) + )); + + const withImageAndLinks = withCodeTokens.replace(/(!)?\[([^\]]*)\]\(([^)]+)\)/g, (match, imageMark, labelRaw, urlRaw) => { + const normalizedUrl = normalizeRenderableUrl(urlRaw); + if (!isSafeRenderableUrl(normalizedUrl)) return match; + + const href = escapeHtml(normalizedUrl); + if (imageMark || looksLikeImageUrl(urlRaw)) { + const altText = String(labelRaw || '').trim() || 'Screenshot'; + const alt = escapeHtml(altText); + return store.put( + `
    ${alt}`, + ); + } + + const label = escapeHtml(String(labelRaw || '').trim() || normalizedUrl); + return store.put(`${label}`); + }); + + const withAutolinks = withImageAndLinks.replace(/(^|[\s(>])((https?:\/\/[^\s<]+))/g, (match, prefix, urlRaw) => { + const normalizedUrl = normalizeRenderableUrl(urlRaw); + if (!isSafeRenderableUrl(normalizedUrl)) return match; + const href = escapeHtml(normalizedUrl); + const label = escapeHtml(urlRaw); + return `${prefix}${store.put(`${label}`)}`; + }); + + const escaped = escapeHtml(withAutolinks); + const withEmphasis = escaped + .replace(/\*\*([^*]+)\*\*/g, '$1') + .replace(/~~([^~]+)~~/g, '$1') + .replace(/(^|[^\*])\*([^*\n]+)\*(?!\*)/g, '$1$2'); + + return store.apply(withEmphasis); +} + +function isMarkdownBlockStarter(line, nextLine = '') { + if (!String(line || '').trim()) return true; + if (matchFencedBlockStart(line)) return true; + if (isMarkdownHeading(line)) return true; + if (isMarkdownHorizontalRule(line)) return true; + if (isMarkdownBlockquote(line)) return true; + if (matchMarkdownListItem(line)) return true; + if (String(line || '').includes('|') && isMarkdownTableSeparator(nextLine)) return true; + return false; +} + +function renderMarkdownListBlock(lines, startIndex) { + let index = startIndex; + let html = ''; + let currentType = null; + let items = []; + + const flush = () => { + if (!items.length || !currentType) return; + const tag = currentType === 'ol' ? 'ol' : 'ul'; + const itemsHtml = items.map((item) => { + const task = String(item || '').match(/^\[( |x|X)\]\s+([\s\S]+)$/); + if (!task) { + return `
  • ${renderInlineContent(item).replace(/\n/g, '
    ')}
  • `; + } + const checked = String(task[1]).toLowerCase() === 'x'; + return ` +
  • + + ${renderInlineContent(task[2]).replace(/\n/g, '
    ')}
    +
  • + `; + }).join(''); + html += `<${tag} class="md-list">${itemsHtml}`; + items = []; + }; + + while (index < lines.length) { + const line = lines[index]; + if (!String(line || '').trim()) break; + const item = matchMarkdownListItem(line); + if (item) { + const nextType = item.ordered ? 'ol' : 'ul'; + if (currentType && currentType !== nextType) { + flush(); + } + currentType = nextType; + let itemText = item.content; + index += 1; + while (index < lines.length) { + const continuation = lines[index]; + if (!String(continuation || '').trim()) break; + if (matchMarkdownListItem(continuation)) break; + if (isMarkdownBlockStarter(continuation, lines[index + 1])) break; + if (/^\s{2,}\S/.test(continuation)) { + itemText += `\n${continuation.trim()}`; + index += 1; + continue; + } + break; + } + items.push(itemText); + continue; + } + break; + } + + flush(); + return { html, nextIndex: index }; +} + +function renderMarkdownTableBlock(lines, startIndex) { + const headerCells = splitMarkdownTableRow(lines[startIndex]); + const alignments = parseMarkdownTableAlignments(lines[startIndex + 1]); + let index = startIndex + 2; + const bodyRows = []; + + while (index < lines.length) { + const line = String(lines[index] || ''); + if (!line.trim() || !line.includes('|')) break; + bodyRows.push(splitMarkdownTableRow(line)); + index += 1; + } + + const headHtml = `${headerCells.map((cell, cellIndex) => { + const align = alignments[cellIndex] ? ` style="text-align:${alignments[cellIndex]};"` : ''; + return `${renderInlineContent(cell)}`; + }).join('')}`; + + const bodyHtml = bodyRows.map((row) => ( + `${row.map((cell, cellIndex) => { + const align = alignments[cellIndex] ? ` style="text-align:${alignments[cellIndex]};"` : ''; + return `${renderInlineContent(cell)}`; + }).join('')}` + )).join(''); + + return { + html: `${headHtml}${bodyRows.length ? `${bodyHtml}` : ''}
    `, + nextIndex: index, + }; +} + +function renderMarkdownBlocks(source) { + const normalized = String(source ?? '').replace(/\r\n?/g, '\n'); + const lines = normalized.split('\n'); + const chunks = []; + let index = 0; + + while (index < lines.length) { + const line = String(lines[index] || ''); + const nextLine = String(lines[index + 1] || ''); + if (!line.trim()) { + index += 1; + continue; + } + + const fenced = matchFencedBlockStart(line); + if (fenced) { + const codeLines = []; + index += 1; + while (index < lines.length && !isFencedBlockClose(lines[index], fenced.fence)) { + codeLines.push(lines[index]); + index += 1; + } + if (index < lines.length) index += 1; + const language = /^[a-z0-9_-]{1,32}$/i.test(fenced.language) ? fenced.language : ''; + const className = language ? ` class="language-${escapeHtml(language)}"` : ''; + chunks.push(`
    ${escapeHtml(codeLines.join('\n'))}
    `); + continue; + } + + const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/); + if (heading) { + const level = Math.min(6, heading[1].length); + chunks.push(`${renderInlineContent(heading[2])}`); + index += 1; + continue; + } + + if (isMarkdownHorizontalRule(line)) { + chunks.push('
    '); + index += 1; + continue; + } + + if (isMarkdownBlockquote(line)) { + const quoteLines = []; + while (index < lines.length && isMarkdownBlockquote(lines[index])) { + quoteLines.push(String(lines[index] || '').replace(/^\s{0,3}>\s?/, '')); + index += 1; + } + const quoteInner = renderMarkdownBlocks(quoteLines.join('\n')); + chunks.push(`
    ${quoteInner || '

    '}
    `); + continue; + } + + if (matchMarkdownListItem(line)) { + const list = renderMarkdownListBlock(lines, index); + if (list.html) chunks.push(list.html); + index = list.nextIndex; + continue; + } + + if (line.includes('|') && isMarkdownTableSeparator(nextLine)) { + const table = renderMarkdownTableBlock(lines, index); + chunks.push(table.html); + index = table.nextIndex; + continue; + } + + const paragraphLines = [line]; + index += 1; + while (index < lines.length) { + const candidate = String(lines[index] || ''); + const candidateNext = String(lines[index + 1] || ''); + if (!candidate.trim()) break; + if (isMarkdownBlockStarter(candidate, candidateNext)) break; + paragraphLines.push(candidate); + index += 1; + } + const paragraphText = paragraphLines.join('\n'); + chunks.push(`

    ${renderInlineContent(paragraphText).replace(/\n/g, '
    ')}

    `); + } + + return chunks.join(''); +} + +export function renderMarkdownContent(value) { + const html = renderMarkdownBlocks(value); + if (!html) return ''; + return `
    ${html}
    `; } export function getLatestInFlightStepIndex(run = {}) { diff --git a/extension/agent-panel.css b/extension/agent-panel.css index 0ab0a28..54b7a11 100644 --- a/extension/agent-panel.css +++ b/extension/agent-panel.css @@ -370,6 +370,144 @@ body { word-break: break-word; } +.bubble-assistant .md-content { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bubble-assistant .md-content p { + margin: 0; +} + +.bubble-assistant .md-content h1, +.bubble-assistant .md-content h2, +.bubble-assistant .md-content h3, +.bubble-assistant .md-content h4, +.bubble-assistant .md-content h5, +.bubble-assistant .md-content h6 { + color: var(--text); + line-height: 1.28; + margin: 0; +} + +.bubble-assistant .md-content .md-h1 { font-size: 18px; font-weight: 700; } +.bubble-assistant .md-content .md-h2 { font-size: 16px; font-weight: 700; } +.bubble-assistant .md-content .md-h3 { font-size: 14px; font-weight: 650; } +.bubble-assistant .md-content .md-h4, +.bubble-assistant .md-content .md-h5, +.bubble-assistant .md-content .md-h6 { font-size: 13px; font-weight: 650; } + +.bubble-assistant .md-content .md-list { + margin: 0; + padding-left: 18px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.bubble-assistant .md-content .md-list li { + font-size: 13.5px; + line-height: 1.55; +} + +.bubble-assistant .md-content .md-task-item { + list-style: none; + margin-left: -18px; + display: flex; + align-items: flex-start; + gap: 8px; +} + +.bubble-assistant .md-content .md-task-box { + width: 13px; + height: 13px; + margin-top: 3px; + border-radius: 4px; + border: 1px solid var(--line); + background: #fff; + flex-shrink: 0; +} + +.bubble-assistant .md-content .md-task-box.checked { + border-color: var(--ok); + background: var(--ok); + position: relative; +} + +.bubble-assistant .md-content .md-task-box.checked::after { + content: ''; + position: absolute; + left: 3px; + top: 1px; + width: 4px; + height: 7px; + border-right: 2px solid #fff; + border-bottom: 2px solid #fff; + transform: rotate(40deg); +} + +.bubble-assistant .md-content .md-blockquote { + margin: 0; + padding: 6px 10px; + border-left: 3px solid var(--line); + background: var(--linen); + border-radius: 0 8px 8px 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.bubble-assistant .md-content .md-pre { + margin: 0; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--line); + background: #f6f4ef; + overflow-x: auto; +} + +.bubble-assistant .md-content .md-pre code { + background: transparent; + border: 0; + border-radius: 0; + padding: 0; + font-size: 11.5px; + line-height: 1.55; + color: var(--text); + white-space: pre; +} + +.bubble-assistant .md-content .md-table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + border: 1px solid var(--line); + border-radius: 8px; + overflow: hidden; + font-size: 12px; +} + +.bubble-assistant .md-content .md-table th, +.bubble-assistant .md-content .md-table td { + border: 1px solid var(--line); + padding: 6px 8px; + vertical-align: top; + overflow-wrap: anywhere; + word-break: break-word; +} + +.bubble-assistant .md-content .md-table th { + background: var(--linen); + font-weight: 600; +} + +.bubble-assistant .md-content .md-hr { + border: 0; + border-top: 1px solid var(--line); + margin: 2px 0; +} + .bubble-assistant code { font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; font-size: 11.5px; @@ -383,6 +521,35 @@ body { word-break: break-word; } +.inline-link { + color: var(--crail-dark); + text-decoration: underline; + text-underline-offset: 2px; +} + +.inline-link:hover { + color: var(--crail-press); +} + +.inline-image-link { + display: block; + width: 100%; + margin-top: 6px; + border-radius: 10px; + overflow: hidden; + border: 1px solid var(--line); + background: #fff; +} + +.inline-image { + display: block; + width: 100%; + height: auto; + max-height: 280px; + object-fit: contain; + background: var(--linen); +} + .run-timeline { display: flex; flex-direction: column; diff --git a/extension/agent-panel.js b/extension/agent-panel.js index 66b59d1..1cb0f72 100644 --- a/extension/agent-panel.js +++ b/extension/agent-panel.js @@ -5,6 +5,7 @@ import { clearSessionRunId, formatContextUsage, getSessionRunId, + renderMarkdownContent, renderInlineContent, shouldApplySessionSelection, } from './agent-panel-runtime.js'; @@ -662,7 +663,7 @@ function renderRunTimeline(run, fallbackText = '') {
    ${timeline.map((entry, index) => { if (entry.type === 'text') { - return `

    ${renderContent(entry.text || '')}

    `; + return `
    ${renderContent(entry.text || '')}
    `; } const status = entry?.status || 'running'; const normalizedStatus = String(status || '').toLowerCase(); @@ -716,7 +717,7 @@ function renderRunTimeline(run, fallbackText = '') { } function renderContent(value) { - return renderInlineContent(value); + return renderMarkdownContent(value); } function bindTranscriptHandlers() { @@ -769,7 +770,7 @@ function renderTranscript({ preserveScrollTop = null } = {}) { const messageRun = msg.runId ? state.value.runs[msg.runId] : null; const timelineHtml = renderRunTimeline(messageRun, msg.text || ''); - const fallbackHtml = `

    ${renderContent(msg.text || '')}

    `; + const fallbackHtml = `
    ${renderContent(msg.text || '')}
    `; return `
    BrowserForce
    diff --git a/test/agent/agent-panel-runtime.test.js b/test/agent/agent-panel-runtime.test.js index 7a613cf..346b789 100644 --- a/test/agent/agent-panel-runtime.test.js +++ b/test/agent/agent-panel-runtime.test.js @@ -7,6 +7,7 @@ import { formatContextUsage, getLatestInFlightStepIndex, getSessionRunId, + renderMarkdownContent, renderInlineContent, shouldApplySessionSelection, } from '../../extension/agent-panel-runtime.js'; @@ -61,6 +62,73 @@ test('renders safe inline markdown for bold and code spans', () => { ); }); +test('renders screenshot markdown links as image previews', () => { + const rendered = renderInlineContent('- Screenshot saved: [shopify-direct-1772647808095.png](/tmp/shopify-direct-1772647808095.png)'); + assert.match(rendered, /Screenshot saved:/); + assert.match(rendered, /class="inline-image-link"/); + assert.match(rendered, /class="inline-image"/); + assert.match(rendered, /src="file:\/\/\/tmp\/shopify-direct-1772647808095\.png"/); +}); + +test('renders non-image markdown links as clickable anchors', () => { + const rendered = renderInlineContent('Open [BrowserForce](https://github.com/ivalsaraj/browserforce)'); + assert.match(rendered, /class="inline-link"/); + assert.match(rendered, /href="https:\/\/github\.com\/ivalsaraj\/browserforce"/); +}); + +test('does not render unsafe markdown link protocols as HTML anchors', () => { + const rendered = renderInlineContent('[bad](javascript:alert(1))'); + assert.equal(rendered, '[bad](javascript:alert(1))'); +}); + +test('renders markdown blocks for headings, emphasis, list, quote, and hr', () => { + const rendered = renderMarkdownContent([ + '# Heading', + '', + 'Paragraph with *italic*, **bold**, and ~~strike~~.', + '', + '- Item one', + '- [x] Done task', + '', + '> quoted line', + '', + '---', + ].join('\n')); + assert.match(rendered, /class="md-content"/); + assert.match(rendered, /class="md-h1"/); + assert.match(rendered, /italic<\/em>/); + assert.match(rendered, /bold<\/strong>/); + assert.match(rendered, /strike<\/del>/); + assert.match(rendered, /class="md-list"/); + assert.match(rendered, /class="md-task-item"/); + assert.match(rendered, /class="md-blockquote"/); + assert.match(rendered, /class="md-hr"/); +}); + +test('renders fenced code blocks and table markdown', () => { + const rendered = renderMarkdownContent([ + '```js', + 'const ok = true;', + '```', + '', + '| Name | Value |', + '| :--- | ---: |', + '| foo | 42 |', + ].join('\n')); + assert.match(rendered, /class="md-pre"/); + assert.match(rendered, /language-js/); + assert.match(rendered, /const ok = true;/); + assert.match(rendered, /class="md-table"/); + assert.match(rendered, /Name<\/th>/); + assert.match(rendered, /Value<\/th>/); +}); + +test('escapes raw html inside markdown blocks', () => { + const rendered = renderMarkdownContent('Text '); + assert.match(rendered, /<script>alert\(1\)<\/script>/); + assert.doesNotMatch(rendered, /