pi-browser-harness 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.12.0 — 2026-10-19
6
+
7
+ ### Changed
8
+
9
+ - **Browser guidance no longer repeats tool declarations.** The browser prompt now contains only cross-tool coordination, available before and after connection, and tool guidelines omit usage and argument details already present in descriptions and schemas. Tool names, schemas, handlers, and safety warnings are unchanged.
10
+
11
+ ### Added
12
+
13
+ - **Per-tab extra HTTP request headers.** `browser_set_headers` applies headers to subsequent document, subresource, fetch, and XHR requests on the current owned tab through CDP, while `browser_clear_headers` removes them. Header values are not echoed in tool output, and guidance requires clearing them before switching tabs or navigating to another origin.
14
+
15
+ ### Fixed
16
+
17
+ - **`pi -p` exits after using browser tools.** The client's socket to the daemon is kept open across sessions by design, and in non-interactive mode it was the last handle keeping Node alive, so a print-mode run that had connected to the browser printed its answer and then hung until killed. The socket no longer holds the event loop; in-flight requests keep their own timers, so nothing awaited is affected. (#39)
18
+ - **`browser_download` no longer redirects the user's own downloads for good.** `Browser.setDownloadBehavior` was sent without a `browserContextId`, which Chrome applies to its default context — the one the user's own tabs are in — and the override lives on the browser connection, which the daemon keeps open across pi sessions. Every later download in that Chrome, the user's own included, went to the tool's temp directory, and nothing undid it when the session ended. The tool now takes `restore: true` to reset the behavior, and a clean session shutdown restores the default whenever this session's override is still in force (a killed session cannot, so call `restore: true` as soon as you are done).
19
+ - **Browser state is no longer appended to the session on every callback.** Every ownership change, every `session_tree` event (even one that merely restored the entry already on the branch) and every shutdown appended a `browser-harness-state` entry, so the append-only session file and Pi Web's `+N custom` chain grew without bound. A snapshot is now written only when it differs from the last one persisted or restored; tab order does not count as a difference. (#26)
20
+ - **The daemon starts from a pi-managed install without `typebox`.** pi installs packages with dev and peer dependencies omitted and maps host-provided packages such as `typebox` only inside the extension host, but the daemon is a separate Node process that resolved `typebox` through `src/cdp/discovery.ts` and died with `Cannot find module 'typebox'` before binding its socket, so `/browser-setup` reported `Could not start the browser daemon`. The daemon's `/json/version` parsing no longer uses `typebox`, and a test walks the daemon's import graph to keep every reachable package a runtime dependency. (#34)
21
+ - **The daemon starts from a Bun-compiled pi binary.** Inside `pi.exe` (and any other `bun build --compile` host), `createRequire().resolve("tsx/cli")` is rooted at the binary's embedded virtual filesystem and cannot see the extension's real `node_modules`, so `browser_setup` failed instantly with `Could not start the browser daemon`. The resolver result is now checked on disk and, when it fails, the tsx CLI is found by walking up the `node_modules` chain the way Node does. Under Bun the daemon is launched with `node` from `PATH` instead of `process.execPath` (which is the pi binary itself); `PI_BROWSER_NODE` overrides the executable. (#27)
22
+
23
+ ## 0.11.1 — 2026-08-12
24
+
25
+ ### Fixed
26
+
27
+ - **Chrome remote-debugging consent is no longer retriggered indefinitely in the background.** A cancelled, failed, or disconnected CDP connection now remains disconnected until the next explicit browser request, which makes one on-demand connection attempt instead of running an unbounded retry loop that can repeatedly interrupt normal browsing.
28
+ - **`browser_run_script` accepts paths on Windows again.** The allowed-directory check compared against a hardcoded `/` separator, so every backslash path — including files inside `os.tmpdir()`, which the rejection message itself listed as allowed — was refused. Containment is now decided by `path.relative`, which carries each platform's own separator and case rules.
29
+ - **The daemon starts from a production install.** `tsx` is needed to run the daemon but was declared only as a dev dependency, so `browser_setup` reported `Could not start the browser daemon` under `npm omit=dev`. It is now a runtime dependency, and the daemon is launched via the CLI entry that Node's resolver finds — a fixed `node_modules/.bin` path does not exist in a hoisted install, which is how pi lays extensions out.
30
+
5
31
  ## 0.11.0 — 2026-08-02
6
32
 
7
33
  ### Fixed
package/README.md CHANGED
@@ -161,13 +161,19 @@ Both run in isolated tabs and never disturb your current page.
161
161
  |---|---|
162
162
  | `browser_network_requests` | Requests captured on the current tab since attach. Filter by URL, method, status, resource type, or recency; optional response bodies. |
163
163
  | `browser_http_get` | Plain GET outside the browser. Much faster than navigate-then-read for JSON APIs — but no JS rendering. |
164
+ | `browser_set_headers` | Set extra HTTP request headers on the current owned tab. Values are not echoed in output. |
165
+ | `browser_clear_headers` | Clear extra HTTP request headers from the current owned tab. |
166
+
167
+ Extra headers persist for subsequent document, subresource, fetch, and XHR requests until cleared or
168
+ the tab closes. Create or select an owned tab before setting them, and clear them before switching
169
+ tabs or navigating the tab to another origin.
164
170
 
165
171
  ### Files and viewport
166
172
 
167
173
  | Tool | Purpose |
168
174
  |---|---|
169
175
  | `browser_upload_file` | Set files on a file input, with a DataTransfer fallback for stubborn pages. |
170
- | `browser_download` | Set the download directory and suppress the save-as prompt. |
176
+ | `browser_download` | Set the download directory and suppress the save-as prompt, or restore the default. Applies to the user's own downloads until restored. |
171
177
  | `browser_print_to_pdf` | Print the page to PDF via `Page.printToPDF`. |
172
178
  | `browser_viewport_resize` | Override viewport size and device pixel ratio for responsive checks. |
173
179
 
@@ -316,6 +322,7 @@ re-runnable.
316
322
  | `BH_DEBUG_CLICKS` | Same as `--browser-debug-clicks`. |
317
323
  | `BU_CDP_PORTS` | Extra ports to probe during CDP discovery, beyond 9222. |
318
324
  | `BH_SCRIPT_DIR` | Additional directory `browser_run_script` will accept paths from. |
325
+ | `PI_BROWSER_NODE` | Node executable used to run the daemon. Defaults to the one running pi, or `node` from `PATH` when pi is a Bun-compiled binary. |
319
326
 
320
327
  ---
321
328
 
@@ -346,6 +353,14 @@ A long-lived daemon owns the CDP connection, so it outlives individual pi sessio
346
353
  you only see Chrome's "Allow Remote Debugging" prompt once. `browser_run_script` executes in the
347
354
  harness process with direct daemon and Node access.
348
355
 
356
+ ### Prompt guidance
357
+
358
+ Tool descriptions and parameter schemas own per-tool usage. Tool guidelines add only decisions
359
+ and caveats not already covered there. The browser prompt carries cross-tool rules:
360
+ tab isolation, concurrency, checking mutations, and stale-ref recovery. These rules are available
361
+ before and after connection. Longer recipes remain in this README and the bundled skill
362
+ rather than being copied into the browser prompt.
363
+
349
364
  ---
350
365
 
351
366
  ## Troubleshooting
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Browser control extension for pi — navigate, click, type, screenshot, and extract data from real Chrome via CDP",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -52,7 +52,6 @@
52
52
  "@mariozechner/pi-coding-agent": "*",
53
53
  "@mariozechner/pi-tui": "*",
54
54
  "@types/ws": "^8.18.1",
55
- "tsx": "^4.22.4",
56
55
  "typebox": "*",
57
56
  "typescript": "^5.0.0"
58
57
  },
@@ -70,6 +69,7 @@
70
69
  "sharp": "^0.33.0"
71
70
  },
72
71
  "dependencies": {
72
+ "tsx": "^4.22.4",
73
73
  "ws": "^8.20.0"
74
74
  }
75
75
  }
@@ -21,6 +21,9 @@ What do you need to know?
21
21
  ├─ Network behavior on the current page?
22
22
  │ → browser_network_requests
23
23
  │
24
+ ├─ A page requires custom request headers?
25
+ │ → create/select owned tab → browser_set_headers → navigate/interact → browser_clear_headers
26
+ │
24
27
  ├─ Find pages on the web about a topic?
25
28
  │ → browser_web_search (ranked SERP — links only; follow up with browser_read_page)
26
29
  │
@@ -73,6 +76,14 @@ it by opening tabs elsewhere — a different profile means different accounts.
73
76
 
74
77
  You're attached to the user's real Chrome — never launch your own. If auth is required, stop and ask the user. If `browser_page_info` returns a dialog, handle it first with `browser_handle_dialog`.
75
78
 
79
+ ## Downloads land in the user's context
80
+
81
+ `browser_download` calls `Browser.setDownloadBehavior` with no `browserContextId`, which Chrome applies to its **default browser context** — the same one the user's own tabs are in. The override redirects the user's manual downloads too, and it lives on the browser connection the daemon keeps open, so it outlives the tab and the pi session. Symptom the user reports: *"I download things in Chrome and nothing appears in Downloads."*
82
+
83
+ A clean session shutdown restores the default; a killed session does not. Call `browser_download({ restore: true })` as soon as you are done with a download path, and if a user reports the symptom, call it first.
84
+
85
+ `browser_run_script` is the same hazard: a script that sets download behavior and then throws partway leaves Chrome mutated. Reset it in a `finally`, not after the last line.
86
+
76
87
  ## Diagnosing a "nothing happened" moment
77
88
 
78
89
  When an action runs but the page didn't change, capture `browser_console`'s `nextCursor` *before* the action, take the action, then call `browser_console({ sinceSeq: <cursor> })` after — this isolates what your action caused from what was already there. Pair with `browser_network_requests({ sinceMs: 5000 })` to see if an API call fired and failed. The console buffer is page-scoped: it clears on tab switch, capacity 500.
@@ -97,3 +108,21 @@ When a workflow repeats 3+ times or needs Node.js APIs, write a script to disk a
97
108
  **Don't:**
98
109
  - Use scripts for one-off actions — call `browser_*` tools directly.
99
110
  - Call `browser_*` tools from inside a script — sequence them as separate tool calls outside.
111
+
112
+ ## Troubleshooting: "Could not start the browser daemon"
113
+
114
+ `browser_setup` spawns the daemon as `node <tsx cli> <pkg>/src/daemon/index.ts` with `stdio: "ignore"` (tsx is resolved from the package directory upward, so in pi's hoisted install it sits beside the package rather than inside it). A crash before the socket binds is completely silent and all you see is `Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock.`
115
+
116
+ Run it in the foreground to see the real error (it prints whether it reached Chrome):
117
+
118
+ cd <pkg> && node "$(node -p "require.resolve('tsx/cli')")" src/daemon/index.ts
119
+
120
+ Known cause (package 0.11.1): `Error: Cannot find module 'typebox'` at `src/cdp/discovery.ts`. The package declares `typebox` in `peerDependencies` and `devDependencies`, and pi installs packages with dev and peer dependencies omitted, so the tree has no physical copy. It still resolves inside the pi extension host, which maps `typebox` to pi's bundled copy, but not in the spawned daemon process, which resolves modules from the package directory upward. The daemon dies, no socket appears, and every browser tool reports `not_connected`.
121
+
122
+ Fix it without touching the manifest, which must keep `typebox` out of `dependencies` (pi warns on it and `npm run check` rejects it):
123
+
124
+ cd <pkg> && npm install --no-save typebox --no-audit --no-fund
125
+
126
+ or restore the full tree with `npm ci`, which installs the `devDependencies` copy. Then confirm with `node -e "console.log(require.resolve('typebox'))"` inside the package directory, and start the daemon with `NODE_PATH` unset to prove no environment workaround is in play. A fresh clone or update reinstalls the tree, so the missing copy can come back; the manifest itself is correct upstream.
127
+
128
+ Do not work around it with `npm install typebox --save` (that writes `typebox` into `dependencies`, tripping pi's extension warning and the manifest check), a global `NODE_PATH`, or a hand-made symlink: the spawn inherits the extension host environment, so a shell-level export may not reach it, and the underlying problem is an incomplete install, not an undeclared dependency.
@@ -228,6 +228,10 @@ export const COMMANDS = {
228
228
  Type.Object({ requestId: Type.String() }),
229
229
  Type.Object({ body: Type.String(), base64Encoded: Type.Optional(Type.Boolean()) }),
230
230
  ),
231
+ "Network.setExtraHTTPHeaders": cmd(
232
+ Type.Object({ headers: Type.Record(Type.String(), Type.String()) }),
233
+ Empty,
234
+ ),
231
235
  "Emulation.setDeviceMetricsOverride": cmd(Type.Object({}, { additionalProperties: true }), Empty),
232
236
  "Accessibility.getFullAXTree": cmd(
233
237
  Type.Object({ depth: Type.Optional(Type.Number()) }, { additionalProperties: true }),
@@ -2,23 +2,27 @@ import { readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { connect as netConnect } from "node:net";
4
4
  import { request as httpRequest } from "node:http";
5
- import { Type } from "typebox";
6
- import { Compile } from "typebox/compile";
7
- import { parseJson } from "../schemas/parse";
8
5
  import { type Result, err, ok } from "../util/result";
9
6
  import { userDataDirCandidates } from "../profile/paths";
10
7
  import { type CdpError, cdpError } from "./errors";
11
- import { errnoCode } from "../util/guards";
8
+ import { asString, errnoCode, isRecord } from "../util/guards";
12
9
 
13
10
  const PORT_PROBE_DEADLINE_MS = 30_000;
14
11
  const PORT_PROBE_INTERVAL_MS = 1_000;
15
12
 
16
- const VersionResponse = Type.Object(
17
- { webSocketDebuggerUrl: Type.Optional(Type.String()) },
18
- { additionalProperties: true },
19
- );
20
-
21
- const versionValidator = Compile(VersionResponse);
13
+ // Hand-rolled on purpose: this file runs inside the daemon, a separate Node process that pi does not
14
+ // supply host-provided packages (typebox) to. Everything the daemon imports must be a runtime
15
+ // dependency or a builtin; test/daemon/entry-imports-test.ts enforces that.
16
+ export const parseVersionResponse = (raw: string): string | null => {
17
+ let parsed: unknown;
18
+ try {
19
+ parsed = JSON.parse(raw);
20
+ } catch {
21
+ return null;
22
+ }
23
+ if (!isRecord(parsed)) return null;
24
+ return asString(parsed["webSocketDebuggerUrl"]) ?? null;
25
+ };
22
26
 
23
27
  export type CdpEndpoint = {
24
28
  readonly wsUrl: string;
@@ -72,8 +76,7 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
72
76
  const chunks: Buffer[] = [];
73
77
  res.on("data", (c: Buffer) => chunks.push(c));
74
78
  res.on("end", () => {
75
- const parsed = parseJson(Buffer.concat(chunks).toString("utf8"), versionValidator);
76
- resolve(parsed.success ? parsed.data.webSocketDebuggerUrl ?? null : null);
79
+ resolve(parseVersionResponse(Buffer.concat(chunks).toString("utf8")));
77
80
  });
78
81
  },
79
82
  );