aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 21:59:30 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 21:59:30 +0530
commita1df2d3e0998e99176a4bfe3cffb83b9e6a2377e (patch)
treef84522e4f08fd6adeaf4954e09fc6e43a59d02f7
parentc9d1605b1e3b8dbe88cd3674a9256aa0465f3e6d (diff)
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
-rw-r--r--CHANGELOG.md18
-rw-r--r--package.json5
-rw-r--r--skills/pi-browser-harness/SKILL.md21
-rw-r--r--src/client.ts24
-rw-r--r--src/domains/files.ts10
-rw-r--r--src/domains/keyboard.ts1
-rw-r--r--src/domains/navigate.ts1
-rw-r--r--src/domains/network.ts2
-rw-r--r--src/domains/tabs.ts42
9 files changed, 107 insertions, 17 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ffc11e7..10c4571 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,24 @@
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
diff --git a/package.json b/package.json
index 9605952..1b19280 100644
--- a/package.json
+++ b/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",
@@ -62,6 +62,9 @@
"publishConfig": {
"access": "public"
},
+ "optionalDependencies": {
+ "sharp": "^0.33.0"
+ },
"dependencies": {
"ws": "^8.20.0"
}
diff --git a/skills/pi-browser-harness/SKILL.md b/skills/pi-browser-harness/SKILL.md
index 59c236f..81a233c 100644
--- a/skills/pi-browser-harness/SKILL.md
+++ b/skills/pi-browser-harness/SKILL.md
@@ -130,7 +130,18 @@ The script is written to disk, so it's auditable and re-runnable.
**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 @@ The script is written to disk, so it's auditable and re-runnable.
**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 @@ browser_execute_js("!!document.querySelector('.loaded-content')")
- **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
diff --git a/src/client.ts b/src/client.ts
index 51dfa9f..1bf7ccc 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -108,12 +108,26 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
}
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>> => {
diff --git a/src/domains/files.ts b/src/domains/files.ts
index f0cb6b0..91f8761 100644
--- a/src/domains/files.ts
+++ b/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";
@@ -136,6 +136,14 @@ export const downloadTool = defineBrowserTool({
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()) {
return err({ kind: "io_error", message: `Not a directory: ${args.downloadPath}` });
diff --git a/src/domains/keyboard.ts b/src/domains/keyboard.ts
index d953b76..c39814e 100644
--- a/src/domains/keyboard.ts
+++ b/src/domains/keyboard.ts
@@ -91,6 +91,7 @@ export const dispatchKeyTool = defineBrowserTool({
"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'.",
diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts
index 5a59629..6016122 100644
--- a/src/domains/navigate.ts
+++ b/src/domains/navigate.ts
@@ -22,6 +22,7 @@ export const navigateTool = defineBrowserTool({
"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),
diff --git a/src/domains/network.ts b/src/domains/network.ts
index 312f39a..1377486 100644
--- a/src/domains/network.ts
+++ b/src/domains/network.ts
@@ -61,7 +61,7 @@ export const getNetworkLogTool = defineBrowserTool({
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.",
diff --git a/src/domains/tabs.ts b/src/domains/tabs.ts
index c9440fe..d2cd646 100644
--- a/src/domains/tabs.ts
+++ b/src/domains/tabs.ts
@@ -12,14 +12,15 @@ export const listTabsTool = defineBrowserTool({
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 @@ export const switchTabTool = defineBrowserTool({
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 },
+ });
},
});