pi-browser-use 0.9.5 → 0.10.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/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # pi-browser-use
2
2
 
3
+ Native Pi extension **and Agent Plugins 1.0 / standalone MCP server**, sharing the same
4
+ curated browser runtime. See [portable installation and configuration](docs/agent-plugins.md).
5
+
3
6
  Opinionated browser-use for the [Pi coding agent](https://pi.dev), powered by [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) — not Playwright.
4
7
 
5
8
  ## Why this exists
@@ -159,7 +162,7 @@ On startup the default persistent profile is checked for accessibility. A root-o
159
162
 
160
163
  `npm run bench` times the tool stack headless over 5 iterations (fixture setup excluded, matching vercel-labs/agent-browser's scenario set). Baseline on Apple Silicon: navigate ~1ms, snapshot ~3ms, screenshot ~37ms, evaluate ~206ms, full agent-loop cycle ~422ms. Re-run on your hardware before quoting numbers.
161
164
 
162
- `npm run perf:audit` reports cold-import memory/time plus published package and dependency footprint; `npm test` enforces the stable regression budgets. See [Performance audit and budgets](docs/performance.md) for the M0 measurements, thresholds, and reproducible local/CI commands.
165
+ `npm run perf:audit` reports cold-import memory/time plus published package and dependency footprint; Code Foundry's dedicated performance job enforces the stable regression budgets with `npm run perf:check`. See [Performance audit and budgets](docs/performance.md) for the M0 measurements, thresholds, and reproducible local/CI commands.
163
166
 
164
167
  ## Bundled skills
165
168
 
@@ -1,7 +1,7 @@
1
1
  export type ArtifactKind = 'screenshot' | 'html';
2
2
  export declare function defaultArtifactDir(): string;
3
3
  /** Resolve the destination file: explicit path wins, otherwise a timestamped file in the default dir. */
4
- export declare function resolveArtifactTarget(kind: ArtifactKind, path?: unknown): string;
4
+ export declare function resolveArtifactTarget(kind: ArtifactKind, path?: unknown, directory?: string): string;
5
5
  /** Pick the first image payload out of MCP content, if any. */
6
6
  export declare function pickImageData(content: unknown): {
7
7
  data: string;
package/dist/artifacts.js CHANGED
@@ -4,10 +4,10 @@ export function defaultArtifactDir() {
4
4
  return join(homedir(), '.pi', 'browser-artifacts');
5
5
  }
6
6
  /** Resolve the destination file: explicit path wins, otherwise a timestamped file in the default dir. */
7
- export function resolveArtifactTarget(kind, path) {
7
+ export function resolveArtifactTarget(kind, path, directory = defaultArtifactDir()) {
8
8
  if (typeof path === 'string' && path.length > 0)
9
9
  return path;
10
- return join(defaultArtifactDir(), `page-${Date.now()}.${kind === 'html' ? 'html' : 'png'}`);
10
+ return join(directory, `page-${Date.now()}.${kind === 'html' ? 'html' : 'png'}`);
11
11
  }
12
12
  /** Pick the first image payload out of MCP content, if any. */
13
13
  export function pickImageData(content) {
@@ -28,6 +28,7 @@ export interface ChromeLaunchOptions {
28
28
  executablePath?: string;
29
29
  /** How long to wait for the DevTools endpoint. Default 15s. */
30
30
  readyTimeoutMs?: number;
31
+ signal?: AbortSignal;
31
32
  }
32
33
  export interface ChromeProcess {
33
34
  readonly pid: number | undefined;
@@ -68,6 +69,7 @@ export declare function buildChromeArgs(options: {
68
69
  export declare function waitForDevToolsEndpoint(port: number, options?: {
69
70
  timeoutMs?: number;
70
71
  fetchImpl?: typeof fetch;
72
+ signal?: AbortSignal;
71
73
  }): Promise<{
72
74
  browserUrl: string;
73
75
  webSocketDebuggerUrl: string;
@@ -102,5 +104,6 @@ export declare function launchSetupBrowser(options: {
102
104
  profileDirectory?: string;
103
105
  executablePath?: string;
104
106
  chromeArgs?: string[];
107
+ signal?: AbortSignal;
105
108
  }): Promise<number | null>;
106
109
  //# sourceMappingURL=chrome-launcher.d.ts.map
@@ -19,6 +19,7 @@
19
19
  import { execFileSync, spawn } from 'node:child_process';
20
20
  import { existsSync } from 'node:fs';
21
21
  import { createServer } from 'node:net';
22
+ import { setTimeout as delay } from 'node:timers/promises';
22
23
  /** Chrome/Chromium executable candidates by platform (stable first). */
23
24
  export function chromeExecutableCandidates() {
24
25
  if (process.platform === 'darwin') {
@@ -131,8 +132,11 @@ export async function waitForDevToolsEndpoint(port, options) {
131
132
  const deadline = Date.now() + timeoutMs;
132
133
  let lastError;
133
134
  while (Date.now() < deadline) {
135
+ options?.signal?.throwIfAborted();
134
136
  try {
135
- const response = await fetchImpl(`${browserUrl}/json/version`);
137
+ const timeout = AbortSignal.timeout(Math.max(1, Math.min(1000, deadline - Date.now())));
138
+ const signal = options?.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
139
+ const response = await fetchImpl(`${browserUrl}/json/version`, { signal });
136
140
  if (response.ok) {
137
141
  const info = (await response.json());
138
142
  return { browserUrl, webSocketDebuggerUrl: info.webSocketDebuggerUrl ?? '' };
@@ -140,9 +144,10 @@ export async function waitForDevToolsEndpoint(port, options) {
140
144
  lastError = new Error(`DevTools endpoint answered ${response.status}`);
141
145
  }
142
146
  catch (error) {
147
+ options?.signal?.throwIfAborted();
143
148
  lastError = error;
144
149
  }
145
- await new Promise((resolve) => setTimeout(resolve, 100));
150
+ await delay(100, undefined, { signal: options?.signal });
146
151
  }
147
152
  throw new Error(`Timed out waiting for Chrome DevTools on ${browserUrl} (${lastError instanceof Error ? lastError.message : String(lastError)})`);
148
153
  }
@@ -199,6 +204,7 @@ class OwnedChromeProcess {
199
204
  * (see `profile-lock.ts`) for `userDataDir` before calling.
200
205
  */
201
206
  export async function launchChrome(options) {
207
+ options.signal?.throwIfAborted();
202
208
  const executable = findChromeExecutable(options.executablePath);
203
209
  const port = options.port ?? (await allocateEphemeralPort());
204
210
  const args = buildChromeArgs({
@@ -218,7 +224,10 @@ export async function launchChrome(options) {
218
224
  throw new Error(`Chrome exited immediately (code ${child.exitCode}).`);
219
225
  }
220
226
  try {
221
- await waitForDevToolsEndpoint(port, { timeoutMs: options.readyTimeoutMs });
227
+ await waitForDevToolsEndpoint(port, {
228
+ timeoutMs: options.readyTimeoutMs,
229
+ signal: options.signal,
230
+ });
222
231
  }
223
232
  catch (error) {
224
233
  try {
@@ -284,6 +293,7 @@ export function buildFocusWindowScript(markerUrl) {
284
293
  * launched Chrome. Resolves when the user closes the window.
285
294
  */
286
295
  export async function launchSetupBrowser(options) {
296
+ options.signal?.throwIfAborted();
287
297
  const executable = findChromeExecutable(options.executablePath);
288
298
  const args = buildChromeArgs({
289
299
  userDataDir: options.userDataDir,
@@ -291,9 +301,32 @@ export async function launchSetupBrowser(options) {
291
301
  chromeArgs: options.chromeArgs,
292
302
  });
293
303
  const child = spawn(executable, args, { stdio: 'ignore', detached: false });
304
+ const browser = new OwnedChromeProcess(child, 0, '', options.userDataDir);
294
305
  return new Promise((resolve, reject) => {
295
- child.on('error', reject);
296
- child.on('exit', (code) => resolve(code));
306
+ const cleanup = () => options.signal?.removeEventListener('abort', abort);
307
+ const abort = () => {
308
+ void browser.shutdown().then(() => {
309
+ cleanup();
310
+ reject(options.signal?.reason ?? new Error('Browser setup aborted.'));
311
+ }, (error) => {
312
+ cleanup();
313
+ reject(error);
314
+ });
315
+ };
316
+ child.once('error', (error) => {
317
+ cleanup();
318
+ reject(error);
319
+ });
320
+ child.once('exit', (code) => {
321
+ cleanup();
322
+ if (options.signal?.aborted)
323
+ reject(options.signal.reason);
324
+ else
325
+ resolve(code);
326
+ });
327
+ options.signal?.addEventListener('abort', abort, { once: true });
328
+ if (options.signal?.aborted)
329
+ abort();
297
330
  });
298
331
  }
299
332
  //# sourceMappingURL=chrome-launcher.js.map
package/dist/client.js CHANGED
@@ -89,6 +89,8 @@ export class DevToolsClient {
89
89
  });
90
90
  const client = new Client({ name: 'pi-browser-use', version: '0.1.0' }, { capabilities: {} });
91
91
  this.client = client;
92
+ // MCP transport implements callback properties, not EventTarget.
93
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener
92
94
  transport.onerror = (error) => {
93
95
  if (generation !== this.generation)
94
96
  return;
@@ -96,9 +98,10 @@ export class DevToolsClient {
96
98
  console.error(`[pi-browser-use] chrome-devtools-mcp transport error (${transportErrorCode ?? error.name})`);
97
99
  void this.disconnectUnhealthyClient(generation);
98
100
  };
101
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener
99
102
  transport.onclose = () => this.markDisconnected(generation);
100
103
  try {
101
- await client.connect(transport, signal ? { signal, timeout: MCP_TIMEOUT_MS } : { timeout: MCP_TIMEOUT_MS });
104
+ await client.connect(transport, signal ? { signal, timeout: MCP_TIMEOUT_MS } : { timeout: MCP_TIMEOUT_MS, signal });
102
105
  if (generation !== this.generation)
103
106
  return;
104
107
  this.state = 'ready';
@@ -125,6 +128,8 @@ export class DevToolsClient {
125
128
  : undefined;
126
129
  const diagnostic = summarizeFailure(stderr, errorName, transportErrorCode ?? errorCode);
127
130
  console.error(`[pi-browser-use] browser connection failed: ${diagnostic}`);
131
+ // Raw upstream causes can contain credentials; retain only the sanitized diagnostic.
132
+ // oxlint-disable-next-line preserve-caught-error
128
133
  throw new Error(`Browser connection failed. ${diagnostic}`);
129
134
  }
130
135
  }
@@ -181,6 +186,7 @@ export class DevToolsClient {
181
186
  do {
182
187
  const result = await client.listTools(cursor ? { cursor } : undefined, {
183
188
  timeout: MCP_TIMEOUT_MS,
189
+ signal,
184
190
  });
185
191
  allTools.push(...result.tools.map((t) => ({
186
192
  name: t.name,
@@ -197,20 +203,23 @@ export class DevToolsClient {
197
203
  if (!client)
198
204
  throw new Error('Client not connected');
199
205
  try {
200
- return await client.callTool({ name, arguments: args }, undefined, { timeout: MCP_TIMEOUT_MS });
206
+ return await client.callTool({ name, arguments: args }, undefined, { timeout: MCP_TIMEOUT_MS, signal });
201
207
  }
202
208
  catch (error) {
203
209
  if (signal?.aborted)
204
210
  throw error;
205
211
  if (this.state !== 'ready' || this.client !== client) {
212
+ // oxlint-disable-next-line preserve-caught-error -- upstream causes may contain secrets
206
213
  throw new Error('Browser connection lost; retry the tool.');
207
214
  }
208
215
  const errorName = error instanceof Error ? error.name : 'UnknownError';
209
216
  console.error(`[pi-browser-use] upstream tool call failed (${errorName})`);
210
217
  // Existing mode fails most often on the consent gate: say so plainly.
211
218
  if (this.config.sessionMode === 'existing') {
219
+ // oxlint-disable-next-line preserve-caught-error -- upstream causes may contain secrets
212
220
  throw new Error('Browser tool call failed. If Chrome is showing an "Allow remote debugging?" prompt, click Allow and retry.');
213
221
  }
222
+ // oxlint-disable-next-line preserve-caught-error -- upstream causes may contain secrets
214
223
  throw new Error('Browser tool call failed.');
215
224
  }
216
225
  }
package/dist/config.d.ts CHANGED
@@ -62,11 +62,11 @@ export type BrowserMode = 'fresh' | 'persistent' | 'existing';
62
62
  * (headless unless headed is requested, so logins work without popups).
63
63
  * Attach fields never carry across modes.
64
64
  */
65
- export declare function resolveModeTarget(base: BrowserUseConfig, mode: BrowserMode, headed?: boolean): BrowserUseConfig;
65
+ export declare function resolveModeTarget(base: BrowserUseConfig, mode: BrowserMode, headed?: boolean, defaultProfileDir?: string): BrowserUseConfig;
66
66
  /** Expand a leading ~/ in user-supplied paths (env interpolation covers ${} only). */
67
67
  export declare function expandHome(path: string): string;
68
68
  /** Merge user config over fresh-headless defaults. */
69
- export declare function resolveConfig(config?: BrowserUseConfig): BrowserUseConfig;
69
+ export declare function resolveConfig(config?: BrowserUseConfig, defaultProfileDir?: string): BrowserUseConfig;
70
70
  /** Convert config into CLI flags for the chrome-devtools-mcp subprocess. */
71
71
  export declare function configToArgs(config: BrowserUseConfig): string[];
72
72
  //# sourceMappingURL=config.d.ts.map
package/dist/config.js CHANGED
@@ -31,10 +31,11 @@ const DEFAULTS = {
31
31
  * (headless unless headed is requested, so logins work without popups).
32
32
  * Attach fields never carry across modes.
33
33
  */
34
- export function resolveModeTarget(base, mode, headed = false) {
35
- const { browserUrl: _browserUrl, wsEndpoint: _wsEndpoint, autoConnect: _autoConnect, mode: _mode, headed: _headed, ...rest } = base;
34
+ export function resolveModeTarget(base, mode, headed = false, defaultProfileDir = DEFAULT_PROFILE_DIR) {
35
+ const { browserUrl: _browserUrl, wsEndpoint: _wsEndpoint, wsHeaders: _wsHeaders, autoConnect: _autoConnect, mode: _mode, headed: _headed, ...rest } = base;
36
36
  void _browserUrl;
37
37
  void _wsEndpoint;
38
+ void _wsHeaders;
38
39
  void _autoConnect;
39
40
  void _mode;
40
41
  void _headed;
@@ -44,11 +45,14 @@ export function resolveModeTarget(base, mode, headed = false) {
44
45
  return { ...freshRest, sessionMode: 'isolated', headless: !headed, isolated: true };
45
46
  }
46
47
  if (mode === 'existing') {
47
- // Attach to the user's running Chrome: drop Pi-owned launch fields so
48
- // MCP auto-connects instead of starting its own browser.
49
- const { userDataDir: _userDataDir, isolated: _isolated, ...existingRest } = rest;
48
+ // Attach to the user's running Chrome: drop launch-only fields so MCP
49
+ // auto-connects instead of trying to start or reconfigure its browser.
50
+ const { userDataDir: _userDataDir, isolated: _isolated, executablePath: _executablePath, chromeArgs: _chromeArgs, viewport: _viewport, ...existingRest } = rest;
50
51
  void _userDataDir;
51
52
  void _isolated;
53
+ void _executablePath;
54
+ void _chromeArgs;
55
+ void _viewport;
52
56
  // Existing attaches to the user's visible Chrome: always headed.
53
57
  return { ...existingRest, sessionMode: 'existing', headless: false, autoConnect: true };
54
58
  }
@@ -57,7 +61,7 @@ export function resolveModeTarget(base, mode, headed = false) {
57
61
  sessionMode: 'persistent',
58
62
  headless: !headed,
59
63
  isolated: false,
60
- userDataDir: base.userDataDir ?? DEFAULT_PROFILE_DIR,
64
+ userDataDir: base.userDataDir ?? defaultProfileDir,
61
65
  };
62
66
  }
63
67
  /** Expand a leading ~/ in user-supplied paths (env interpolation covers ${} only). */
@@ -65,7 +69,7 @@ export function expandHome(path) {
65
69
  return path === '~' ? homedir() : path.startsWith('~/') ? resolve(homedir(), path.slice(2)) : path;
66
70
  }
67
71
  /** Merge user config over fresh-headless defaults. */
68
- export function resolveConfig(config) {
72
+ export function resolveConfig(config, defaultProfileDir = DEFAULT_PROFILE_DIR) {
69
73
  const { mode, headed, ...rest } = config ?? {};
70
74
  const resolved = { ...DEFAULTS, ...rest };
71
75
  if (typeof resolved.userDataDir === 'string')
@@ -116,7 +120,7 @@ export function resolveConfig(config) {
116
120
  break;
117
121
  default:
118
122
  if (!resolved.userDataDir && !resolved.browserUrl && !resolved.wsEndpoint) {
119
- resolved.userDataDir = DEFAULT_PROFILE_DIR;
123
+ resolved.userDataDir = defaultProfileDir;
120
124
  }
121
125
  break;
122
126
  }
@@ -78,7 +78,9 @@ export function parseMcpPageList(result) {
78
78
  const title = (match[2] ?? '').trim();
79
79
  if (title)
80
80
  entry.title = title;
81
- const url = (match[3] ?? '').trim();
81
+ // chrome-devtools-mcp 1.8 emits `N: URL [selected]`; retain support
82
+ // for the older `N: title (URL)` form as well.
83
+ const url = (match[3] ?? (/^(https?:|about:|file:|data:)/.test(title) ? title : '')).trim();
82
84
  if (url)
83
85
  entry.url = url;
84
86
  entries.push(entry);
@@ -113,7 +115,7 @@ export async function openExistingPage(url, deps, options) {
113
115
  }
114
116
  catch (error) {
115
117
  throw new Error(`Pi extension did not open the tab (token ${token}). ` +
116
- `Is the extension installed and polling the tab bridge? (${error instanceof Error ? error.message : String(error)})`);
118
+ `Is the extension installed and polling the tab bridge? (${error instanceof Error ? error.message : String(error)})`, { cause: error });
117
119
  }
118
120
  let pageId;
119
121
  try {
package/dist/index.d.ts CHANGED
@@ -1,47 +1,20 @@
1
+ import { createRegistryVisionCaller } from './vision.js';
1
2
  export { configToArgs, resolveConfig } from './config.js';
2
- interface ModelRegistry {
3
- find(provider: string, modelId: string): unknown;
4
- getApiKeyAndHeaders(model: unknown): Promise<{
5
- ok: true;
6
- apiKey?: string;
7
- headers?: Record<string, string>;
8
- } | {
9
- ok: false;
10
- error: string;
11
- }>;
12
- }
13
- interface PiToolContext {
14
- modelRegistry?: ModelRegistry;
15
- }
3
+ type ModelRegistry = Parameters<typeof createRegistryVisionCaller>[1];
16
4
  interface Pi {
17
- registerTool(def: {
5
+ registerTool(definition: {
18
6
  name: string;
19
7
  label: string;
20
8
  description: string;
21
9
  parameters: unknown;
22
- execute: (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal, onUpdate?: unknown, ctx?: PiToolContext) => Promise<{
23
- content: Array<{
24
- type: string;
25
- text?: string;
26
- data?: string;
27
- mimeType?: string;
28
- }>;
29
- isError?: boolean;
30
- details?: undefined;
31
- }>;
10
+ execute: (toolCallId: string, params: Record<string, unknown>, signal?: AbortSignal, onUpdate?: unknown, context?: {
11
+ modelRegistry?: ModelRegistry;
12
+ }) => Promise<unknown>;
32
13
  }): void;
33
- on(event: string, handler: (event: unknown, ctx: {
14
+ on(event: string, handler: (event: unknown, context: {
34
15
  cwd: string;
35
16
  } & Record<string, unknown>) => Promise<void>): void;
36
17
  }
37
- /**
38
- * Pi extension entry point. On session_start spawns chrome-devtools-mcp,
39
- * discovers upstream tools, and registers each as browser_*. On
40
- * session_shutdown tears the subprocess down. Nothing runs persistently.
41
- *
42
- * Defaults are persistent headless (Pi-owned profile, no window, no consent
43
- * popups). Set mode "fresh" for an isolated clean room, or "existing" with
44
- * autoConnect/browserUrl to drive an already-running Chrome.
45
- */
18
+ /** Native Pi owns settings/trust and model credentials, not browser behavior. */
46
19
  export default function browserUseExtension(pi: Pi): void;
47
20
  //# sourceMappingURL=index.d.ts.map