pi-browser-harness 0.10.0 → 0.10.2

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,20 @@
2
2
 
3
3
  All notable changes to pi-browser-harness will be documented in this file.
4
4
 
5
+ ## 0.10.2 — 2026-07-26
6
+
7
+ ### Fixed
8
+
9
+ - **Page info no longer fails on a missing `documentElement`.** The page-info probe read `document.documentElement.scrollWidth`/`scrollHeight` directly, which throws when `documentElement` is absent — leaving every caller with an evaluation error instead of a page snapshot. The probe now falls back to zeroed scroll dimensions and returns a usable `PageInfo`.
10
+
11
+ ## 0.10.1 — 2026-07-26
12
+
13
+ ### Fixed
14
+
15
+ - **Chrome process leak on profile seed failure.** When a profile is pinned and `seedProfileWindow` spawns Chrome via `openProfileWindow`, the spawned child is kept as `detached: true` + `unref()`. If Chromium's ProcessSingleton fails to delegate (user-data-dir mismatch, multiple browsers, Chrome busy/crashed), the spawned process starts a full second browser instance. The sentinel never appears, the seed times out, but the spawned Chrome process was never killed — accumulating zombie browser instances that consume RAM. The fix stores the child process reference, exposes a `kill()` on the returned handle, and calls it on seed timeout.
16
+ - **Unnecessary Chrome re-spawns on every `start()`.** `profileContextId` was unconditionally cleared on every `start()`, forcing a new profile-window launch via ProcessSingleton even when Chrome hadn't restarted and the context was still valid. Now the context is cleared only when the browser UUID in the WebSocket URL changes — which Chrome re-mints on every launch.
17
+ - **`setProfilePin` no longer clears context when the pin hasn't changed.** Loading the same pin from disk on `session_start` was clearing `profileContextId`, forcing yet another Chrome spawn on the next `start()`.
18
+
5
19
  ## 0.10.0 — 2026-07-25
6
20
 
7
21
  ### Added
package/README.md CHANGED
@@ -50,9 +50,9 @@ The first time you connect, you'll be asked which browser profile the agent shou
50
50
 
51
51
  ```
52
52
  Select the browser profile the agent should use
53
- vertexcover.io (aman@vertexcover.io)
54
- ✓ Aman (personal@gmail.com)
55
- Harness (harness@gmail.com)
53
+ Work (work@example.com)
54
+ ✓ Personal (personal@example.com)
55
+ Testing (test@example.com)
56
56
  — Clear selection (use whichever window is focused) —
57
57
  ```
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-browser-harness",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",
package/src/client.ts CHANGED
@@ -170,11 +170,12 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
170
170
  }
171
171
  const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 });
172
172
  if (!connected.success) return connected;
173
- if (!remote) {
174
- remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" };
173
+
174
+ const newBrowserId: string = wsUrl.split("/").pop() ?? "unknown";
175
+ if (!remote || remote.browserId !== newBrowserId) {
176
+ profileContextId = undefined;
175
177
  }
176
- // A fresh browser run invalidates any context id resolved earlier.
177
- profileContextId = undefined;
178
+ remote = { cdpUrl: wsUrl, browserId: newBrowserId };
178
179
  const seeded = await seedIfPinned();
179
180
  if (!seeded.success) {
180
181
  await transport.close();
@@ -255,7 +256,7 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
255
256
  const currentTid = session.current()?.targetId;
256
257
  const cached = currentTid ? pageCaches.get(currentTid) : undefined;
257
258
  if (cached && !dirty && Date.now() - cached.at < PAGE_INFO_TTL_MS) return ok(cached.info);
258
- const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`;
259
+ const expr = safeJs`JSON.stringify((function(){var e=document.documentElement||{scrollWidth:0,scrollHeight:0};return {url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:e.scrollWidth,ph:e.scrollHeight};})())`;
259
260
  const raw = await evaluateJs(expr);
260
261
  if (!raw.success) return raw;
261
262
  if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string"));
@@ -397,9 +398,13 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient =
397
398
  userDataDir: () => userDataDir,
398
399
  profilePin: () => profilePin,
399
400
  setProfilePin: (pin) => {
401
+ const changed = pin === null
402
+ ? profilePin !== null
403
+ : profilePin === null || profilePin.userDataDir !== pin.userDataDir || profilePin.profileDir !== pin.profileDir;
400
404
  profilePin = pin;
401
- // The old profile's context no longer describes where tabs should go.
402
- profileContextId = undefined;
405
+ if (changed) {
406
+ profileContextId = undefined;
407
+ }
403
408
  },
404
409
  profileContextId: () => profileContextId,
405
410
  setProfileContextId: (contextId) => {
@@ -93,7 +93,7 @@ export const seedProfileWindow = async (
93
93
  return err(cdpError("discovery_failed", `${launched.error} — ${manualFallbackHint(pin)}`));
94
94
  }
95
95
 
96
- const { sentinelUrl, cleanup } = launched.data;
96
+ const { sentinelUrl, cleanup, kill } = launched.data;
97
97
  const deadline = Date.now() + SEED_DEADLINE_MS;
98
98
  let seed: PageTarget | undefined;
99
99
  while (Date.now() < deadline && !seed) {
@@ -102,6 +102,7 @@ export const seedProfileWindow = async (
102
102
  }
103
103
 
104
104
  if (!seed) {
105
+ kill();
105
106
  await cleanup();
106
107
  return err(cdpError(
107
108
  "timeout",
@@ -15,7 +15,7 @@
15
15
  * identification correct when the user opens tabs at the same moment.
16
16
  */
17
17
 
18
- import { spawn } from "node:child_process";
18
+ import { spawn, type ChildProcess } from "node:child_process";
19
19
  import { randomUUID } from "node:crypto";
20
20
  import { rm, writeFile } from "node:fs/promises";
21
21
  import { tmpdir } from "node:os";
@@ -42,6 +42,7 @@ export type ProfileWindowHandle = {
42
42
  readonly sentinelUrl: string;
43
43
  /** Remove the sentinel file. Safe to call more than once. */
44
44
  readonly cleanup: () => Promise<void>;
45
+ readonly kill: () => void;
45
46
  };
46
47
 
47
48
  const SENTINEL_HTML = (token: string): string =>
@@ -68,10 +69,11 @@ export const openProfileWindow = async (
68
69
  if (req.explicitUserDataDir) args.push(`--user-data-dir=${req.explicitUserDataDir}`);
69
70
  args.push(`--profile-directory=${req.profileDir}`, "--new-window", sentinelUrl);
70
71
 
72
+ let child: ChildProcess | null = null;
71
73
  try {
72
74
  // No shell: arguments are passed as an array, so profile names and paths
73
75
  // containing spaces survive intact on every platform.
74
- const child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
76
+ child = spawn(req.exePath, args, { detached: true, stdio: "ignore" });
75
77
  child.unref();
76
78
  // The launcher process exits as soon as the running browser takes over the
77
79
  // command line; an error here means the binary itself could not be started.
@@ -86,5 +88,11 @@ export const openProfileWindow = async (
86
88
  cleanup: async () => {
87
89
  await rm(file, { force: true }).catch(() => {});
88
90
  },
91
+ kill: () => {
92
+ if (child) {
93
+ try { child.kill("SIGTERM"); } catch {}
94
+ child = null;
95
+ }
96
+ },
89
97
  });
90
98
  };