all repos — sz-chrome-remote-cdp @ a1df2d3e0998e99176a4bfe3cffb83b9e6a2377e

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

release: v0.3.1 — audit fixes for list_tabs IDs, switch_tab prefix matching, download auto-mkdir, session recovery, docs updates

Bug fixes:
- list_tabs: show full 32-char targetIds (was truncated to 8 chars)
- switch_tab: prefix matching fallback for hex prefixes >= 8 chars
- download: auto-create directory with mkdir -p
- ensureAlive: Runtime.evaluate probe to recover from crashed page targets

Docs:
- dispatch_key: clarify DOM-only event behavior
- navigate: warn about Google anti-bot limitation
- network_log: update deprecated promptSnippet
- SKILL.md: document actual daemon API (evaluateJs, pageInfo, session.call)
- SKILL.md: add troubleshooting for sharp/maxDim and Google navigation
- sharp: add to optionalDependencies for screenshot maxDim auto-resize
amankumarsingh77 amankumarsingh7702@gmal.com
Sat, 02 May 2026 21:59:30 +0530
commit

a1df2d3e0998e99176a4bfe3cffb83b9e6a2377e

parent

c9d1605b1e3b8dbe88cd3674a9256aa0465f3e6d

M CHANGELOG.md → CHANGELOG.md

@@ -2,6 +2,24 @@ # Changelog

All notable changes to pi-browser-harness will be documented in this file. +## 0.3.1 — 2026-05-02 + +### Bug fixes + +- **`browser_list_tabs`** now shows full 32-character targetIds instead of truncated `BE9DD1DC…` prefixes. The `browser_list_tabs` → `browser_switch_tab` round-trip is repaired. +- **`browser_switch_tab`** now supports prefix matching: pass a unique hex prefix (≥8 chars) and it resolves to the full targetId automatically. Ambiguous prefixes return a clear error listing all matching tabs. +- **`browser_download`** auto-creates the download directory with `mkdir -p` if it doesn't exist. Previously it required a pre-existing writable directory. +- **`ensureAlive()`** now probes the page session with `Runtime.evaluate("1")` after the transport health check. If the page target has crashed (e.g. localhost server died), it reattaches automatically instead of returning a cryptic `session_not_found` error on the next tool call. + +### Docs & metadata + +- **`browser_dispatch_key`** prompt guidelines now explicitly note it dispatches a synthetic DOM `KeyboardEvent` and does NOT type text. Point users to `browser_type` / `browser_press_key` for actual text input. +- **`browser_navigate`** prompt guidelines now warn that Google and strict-anti-bot sites may reject CDP navigation. `browser_http_get` is the recommended workaround. +- **`browser_get_network_log`** prompt snippet updated with explicit workaround (`browser_execute_js` with `PerformanceObserver`). +- **`sharp`** added to `optionalDependencies` so `npm install` attempts it (enables `browser_screenshot` `maxDim` auto-resize). +- **`SKILL.md`** script bindings section updated to document the actual daemon API: `daemon.evaluateJs()`, `daemon.pageInfo()`, `daemon.listTabs()`, `daemon.session().call()`. Example script updated to match. +- **`SKILL.md`** troubleshooting section now covers `sharp`/`maxDim` and Google anti-bot navigation. + ## 0.3.0 — 2026-05-02 ### Internal rewrite
M package.json → package.json

@@ -1,6 +1,6 @@

{ "name": "pi-browser-harness", - "version": "0.3.0", + "version": "0.3.1", "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP", "keywords": [ "pi-package",

@@ -61,6 +61,9 @@ "prepublishOnly": "tsc --noEmit"

}, "publishConfig": { "access": "public" + }, + "optionalDependencies": { + "sharp": "^0.33.0" }, "dependencies": { "ws": "^8.20.0"
M skills/pi-browser-harness/SKILL.md → skills/pi-browser-harness/SKILL.md

@@ -130,7 +130,18 @@ - Don't try to call browser_* tools from within a script — sequence them as separate tool calls.

**Script bindings:** - `params` — the arguments passed to browser_run_script -- `daemon` — browser daemon (daemon.cdp(), daemon.evaluateJS(), daemon.getPageInfo(), etc.) +- `daemon` — browser daemon with these methods: + - `daemon.evaluateJs(expression)` — run JS in the current page + - `daemon.pageInfo()` — get current page info or dialog + - `daemon.listTabs()` — returns `{ success, data: [{ targetId, url, title }] }` + - `daemon.switchTab(targetId)` — switch to a tab by full targetId + - `daemon.newTab(url?)` — open and switch to a new tab + - `daemon.current()` — returns `{ targetId, url?, title? }` + - `daemon.session(targetId)` — returns a session object for raw CDP: + - `session.call(method, params?)` — CDP command on the page target + - `session.callOnTarget(method, params, sessionId)` — CDP on a specific session + - `session.callBrowser(method, params?)` — CDP on the browser target + - `session.takeDialog()` — get/dismiss pending dialog - `require` — Node.js require() (use 'fs', 'path', 'crypto', etc.) - `signal` — AbortSignal for cancellation - `onUpdate` — progress callback: onUpdate({ content: [{ type: 'text', text: '...' }] })

@@ -140,9 +151,9 @@

**Example — a script that extracts structured data:** ```javascript // /tmp/extract-products.js -const info = await daemon.getPageInfo(); -if ("dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message); -const data = await daemon.evaluateJS(` +const info = await daemon.pageInfo(); +if (info && "dialog" in info) throw new Error("Dialog is blocking: " + info.dialog.message); +const data = await daemon.evaluateJs(` JSON.stringify(Array.from(document.querySelectorAll('${params.rowSelector}')).map(el => ({ title: el.querySelector('${params.titleSelector}')?.textContent?.trim(), price: el.querySelector('${params.priceSelector}')?.textContent?.trim(),

@@ -196,6 +207,8 @@ - **"DevToolsActivePort not found"**: Open chrome://inspect/#remote-debugging, tick checkbox, click Allow

- **Stale session**: Run `/browser-reload-daemon` to restart the daemon - **Dialog blocking**: Run browser_page_info to detect it, then browser_handle_dialog - **Status check**: Run `/browser-status` to see daemon health and current page +- **Screenshot maxDim not working**: Install sharp (`npm install sharp`) for auto-resize support +- **Google navigation fails**: Google's anti-bot detection may reject CDP navigation; use `browser_http_get` to fetch search results instead ## Architecture
M src/client.ts → src/client.ts

@@ -108,12 +108,26 @@ return start();

} if (Date.now() - lastHealth < HEALTH_TTL_MS) return ok(undefined); const probe = await transport.request("Target.getTargets", {}, { sessionId: null, timeoutMs: 2_000 }); - if (probe.success) { - lastHealth = Date.now(); - return ok(undefined); + if (!probe.success) { + await stop(); + return start(); } - await stop(); - return start(); + lastHealth = Date.now(); + // Verify the page session is still responsive (handles the case where the + // browser transport is alive but the page target crashed, e.g. localhost died). + const jsProbe = await session.call("Runtime.evaluate", { + expression: "1", returnByValue: true, + }, { timeoutMs: 2_000 }); + if (!jsProbe.success && jsProbe.error.kind === "session_not_found") { + const reattached = await session.attachFirstPage(); + if (reattached.success) { + pageCache = null; + return ok(undefined); + } + await stop(); + return start(); + } + return ok(undefined); }; const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => {
M src/domains/files.ts → src/domains/files.ts

@@ -1,4 +1,4 @@

-import { access, constants, stat, readFile, writeFile } from "node:fs/promises"; +import { mkdir, access, constants, stat, readFile, writeFile } from "node:fs/promises"; import { basename } from "node:path"; import { Type } from "typebox"; import type { BrowserClient } from "../client";

@@ -135,6 +135,14 @@ "Affects all subsequent downloads on this browser.",

], parameters: DownloadArgs, async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { + try { + await mkdir(args.downloadPath, { recursive: true }); + } catch (e) { + return err({ + kind: "io_error", + message: `Cannot create download directory: ${args.downloadPath} (${e instanceof Error ? e.message : String(e)})`, + }); + } try { const s = await stat(args.downloadPath); if (!s.isDirectory()) {
M src/domains/keyboard.ts → src/domains/keyboard.ts

@@ -91,6 +91,7 @@ description:

"Dispatch a DOM KeyboardEvent on a specific element via JS injection. Use for React/Vue components that listen to synthetic events more reliably than CDP input.", promptSnippet: "Dispatch a DOM KeyboardEvent on a specific element", promptGuidelines: [ + "Dispatches a synthetic DOM KeyboardEvent — for React/Vue synthetic event listeners. Does NOT insert text into inputs (use browser_type or browser_press_key for actual typing).", "Try browser_press_key first; only use browser_dispatch_key when the page ignores raw CDP key events.", "The selector must match exactly one or more elements; zero matches is reported as an error.", "eventType defaults to 'keydown'.",
M src/domains/navigate.ts → src/domains/navigate.ts

@@ -22,6 +22,7 @@ promptGuidelines: [

"Use browser_navigate to go to URLs.", "Use browser_wait_for_load after browser_navigate to wait for the page to finish loading.", "For extracting data from a page you already navigated to, use browser_execute_js or browser_http_get (faster for APIs).", + "Note: Google and some sites with strict anti-bot detection may reject CDP navigation. Use browser_http_get for search results.", ], parameters: NavigateArgs, renderCall: (a) => new Text(`🌐 Navigate to ${a.url}`, 0, 0),
M src/domains/network.ts → src/domains/network.ts

@@ -61,7 +61,7 @@ name: "browser_get_network_log",

label: "Browser Get Network Log", description: "Read buffered Network.* CDP events. NOTE: in v0.3 the event stream is consumed internally by the session manager; this tool is a placeholder. Use browser_execute_js with PerformanceObserver as a workaround.", - promptSnippet: "Get buffered network events (DEPRECATED in v0.3 — see description)", + promptSnippet: "Get buffered network events (DEPRECATED in v0.3 — use browser_execute_js with PerformanceObserver instead)", promptGuidelines: [ "v0.3 routes CDP events through an internal AsyncIterable; a synchronous drain API isn't exposed yet.", "For now, use browser_execute_js with PerformanceObserver, performance.getEntries(), or fetch interceptors.",
M src/domains/tabs.ts → src/domains/tabs.ts

@@ -12,14 +12,15 @@ label: "Browser List Tabs",

description: "List all open browser tabs (page targets).", promptSnippet: "List browser tabs", promptGuidelines: [ - "Use to find a targetId for browser_switch_tab.", + "Use to find a full targetId for browser_switch_tab.", "Internal tabs (chrome://) included by default; pass includeInternal=false to exclude them.", + "Each tab shows its full 32-char hex targetId — use the exact value with browser_switch_tab.", ], parameters: ListTabsArgs, async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { const r = await client.listTabs(args.includeInternal ?? true); if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); - const lines = r.data.map((t, i) => ` [${i}] ${t.targetId.slice(0, 8)}… ${t.url}\n ${t.title}`); + const lines = r.data.map((t, i) => ` [${i}] ${t.targetId} ${t.url}\n ${t.title}`); return ok({ text: `Tabs (${r.data.length}):\n${lines.join("\n")}`, details: { tabs: r.data },

@@ -56,12 +57,43 @@ name: "browser_switch_tab",

label: "Browser Switch Tab", description: "Switch to and attach to a different tab by targetId.", promptSnippet: "Switch tabs", - promptGuidelines: ["Get a targetId via browser_list_tabs first."], + promptGuidelines: [ + "Get a targetId via browser_list_tabs first.", + "Accepts exact targetId or a unique prefix of at least 8 hex characters.", + ], parameters: SwitchTabArgs, async handler(args, { client }): Promise<Result<ToolOk, ToolErr>> { - const r = await client.switchTab(args.targetId); + // Try exact match first + let r = await client.switchTab(args.targetId); + if (r.success) { + return ok({ text: `Switched to ${args.targetId}`, details: { targetId: args.targetId } }); + } + // If exact match failed and input looks like a short prefix (≥8 hex chars), try prefix resolution + const isHexPrefix = /^[0-9A-Fa-f]{8,}$/.test(args.targetId); + if (!isHexPrefix) { + return err({ kind: "cdp_error", message: r.error.message }); + } + const tabs = await client.listTabs(true); + if (!tabs.success) return err({ kind: "cdp_error", message: r.error.message }); + const matches = tabs.data.filter((t) => t.targetId.startsWith(args.targetId)); + if (matches.length === 0) { + return err({ kind: "cdp_error", message: `No tab found with prefix "${args.targetId}"` }); + } + if (matches.length > 1) { + const ids = matches.map((t) => t.targetId).join(", "); + return err({ + kind: "invalid_state", + message: `Ambiguous prefix "${args.targetId}" matches ${matches.length} tabs: ${ids}`, + details: { matches: matches.map((t) => ({ targetId: t.targetId, url: t.url })) }, + }); + } + const resolved = matches[0]!.targetId; + r = await client.switchTab(resolved); if (!r.success) return err({ kind: "cdp_error", message: r.error.message }); - return ok({ text: `Switched to ${args.targetId}`, details: { targetId: args.targetId } }); + return ok({ + text: `Switched to ${resolved} (resolved from prefix "${args.targetId}")`, + details: { targetId: resolved, resolvedPrefix: args.targetId }, + }); }, });