browse 0.8.4 → 0.8.5

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.
@@ -1,14 +1,41 @@
1
1
  import { Stagehand } from "@browserbasehq/stagehand";
2
2
  import { emptyRefMaps, resolveSelector as resolveCachedSelector, } from "./commands/selectors.js";
3
3
  import { executeDriverCommand } from "./commands/registry.js";
4
+ import { DriverError } from "./errors.js";
4
5
  import { discoverLocalCdp } from "./local-cdp-discovery.js";
5
6
  import { NetworkCapture } from "./network-capture.js";
6
7
  import { getRemote } from "./remote-binding.js";
7
8
  const INIT_FAILURE_RETRY_MS = 5_000;
9
+ const INIT_FAILURE_RETRY_MAX_MS = 60_000;
10
+ // chrome-launcher reports "no Chrome on this machine" with these codes (its
11
+ // LaunchErrorCodes const enum, which can't be imported directly: const enums
12
+ // are erased at build time and isolatedModules forbids cross-module access).
13
+ const CHROME_NOT_FOUND_ERROR_CODES = new Set([
14
+ "ERR_LAUNCHER_NOT_INSTALLED",
15
+ "ERR_LAUNCHER_PATH_NOT_SET",
16
+ ]);
17
+ /**
18
+ * Exponential backoff for cached init failures: 5s, 10s, 20s, ... capped at
19
+ * 1 minute. Prevents agents stuck in retry loops from hammering init while
20
+ * still allowing a quick retry after the first failure.
21
+ */
22
+ export function initFailureBackoffMs(consecutiveFailures) {
23
+ const attempt = Math.max(1, consecutiveFailures);
24
+ return Math.min(INIT_FAILURE_RETRY_MS * 2 ** (attempt - 1), INIT_FAILURE_RETRY_MAX_MS);
25
+ }
26
+ export function isChromeNotFoundError(error) {
27
+ const code = error?.code;
28
+ if (typeof code === "string" && CHROME_NOT_FOUND_ERROR_CODES.has(code)) {
29
+ return true;
30
+ }
31
+ return (error instanceof Error &&
32
+ error.message.includes("No Chrome installations found"));
33
+ }
8
34
  export class DriverSessionManager {
9
35
  session;
10
36
  target;
11
37
  network;
38
+ consecutiveInitFailures = 0;
12
39
  context = null;
13
40
  initFailure = null;
14
41
  initPromise = null;
@@ -79,6 +106,7 @@ export class DriverSessionManager {
79
106
  this.stagehand = null;
80
107
  this.context = null;
81
108
  this.initFailure = null;
109
+ this.consecutiveInitFailures = 0;
82
110
  await this.network.disable().catch(() => undefined);
83
111
  if (stagehand) {
84
112
  await stagehand.close().catch(() => undefined);
@@ -128,23 +156,23 @@ export class DriverSessionManager {
128
156
  if (!page) {
129
157
  throw new Error(`Target ${target.targetId} was not found in the attached browser.`);
130
158
  }
131
- this.context.setActivePage(page);
159
+ this.activateIfNeeded(page);
132
160
  this.selectedTargetId = page.targetId();
133
161
  return page;
134
162
  }
135
163
  const existingPage = this.activePageIfPresent() ?? this.context.pages()[0];
136
164
  if (existingPage) {
137
- this.context.setActivePage(existingPage);
165
+ this.activateIfNeeded(existingPage);
138
166
  this.selectedTargetId = existingPage.targetId();
139
167
  return existingPage;
140
168
  }
141
169
  if (options.createIfMissing) {
142
170
  const page = await this.context.newPage();
143
- this.context.setActivePage(page);
171
+ this.activateIfNeeded(page);
144
172
  this.selectedTargetId = page.targetId();
145
173
  return page;
146
174
  }
147
- throw new Error(`No active page in session "${this.session}". Run browse open <url> --session ${this.session} or browse tab new <url> --session ${this.session}.`);
175
+ throw new DriverError(`No active page in session "${this.session}". Run browse open <url> --session ${this.session} or browse tab new <url> --session ${this.session}.`, { code: "no_active_page" });
148
176
  }
149
177
  activePageIfPresent() {
150
178
  try {
@@ -154,6 +182,21 @@ export class DriverSessionManager {
154
182
  return undefined;
155
183
  }
156
184
  }
185
+ /**
186
+ * Mark `page` active only when it isn't already the active page.
187
+ *
188
+ * `setActivePage` ends in a CDP `Target.activateTarget`, which on macOS
189
+ * raises the whole Chrome app to the OS foreground and steals keyboard focus.
190
+ * The daemon resolves the active page on every subcommand, so calling this
191
+ * unconditionally yanks focus away from the user's editor/terminal on each
192
+ * command in headed local mode. Skipping the redundant re-activation keeps a
193
+ * headed session usable alongside a coding agent.
194
+ */
195
+ activateIfNeeded(page) {
196
+ if (page !== this.activePageIfPresent()) {
197
+ this.context?.setActivePage(page);
198
+ }
199
+ }
157
200
  async ensureInitialized() {
158
201
  if (this.stagehand && this.context)
159
202
  return;
@@ -168,19 +211,32 @@ export class DriverSessionManager {
168
211
  this.initPromise = this.initialize()
169
212
  .then(() => {
170
213
  this.initFailure = null;
214
+ this.consecutiveInitFailures = 0;
171
215
  })
172
- .catch((error) => {
216
+ .catch(async (error) => {
217
+ this.consecutiveInitFailures += 1;
218
+ const failure = await this.markRepeatedInitFailure(error);
173
219
  this.initFailure = {
174
- error,
175
- retryAt: Date.now() + INIT_FAILURE_RETRY_MS,
220
+ error: failure,
221
+ retryAt: Date.now() + initFailureBackoffMs(this.consecutiveInitFailures),
176
222
  };
177
- throw error;
223
+ throw failure;
178
224
  })
179
225
  .finally(() => {
180
226
  this.initPromise = null;
181
227
  });
182
228
  return this.initPromise;
183
229
  }
230
+ async markRepeatedInitFailure(error) {
231
+ if (this.consecutiveInitFailures < 3 || !(error instanceof Error)) {
232
+ return error;
233
+ }
234
+ const hint = (await getRemote()).driverInitHints().repeatedInitFailure;
235
+ if (!error.message.includes(hint)) {
236
+ error.message += hint;
237
+ }
238
+ return error;
239
+ }
184
240
  async initialize() {
185
241
  const resolvedTarget = await this.resolveTarget();
186
242
  const options = await this.stagehandOptions(resolvedTarget);
@@ -190,7 +246,7 @@ export class DriverSessionManager {
190
246
  }
191
247
  catch (error) {
192
248
  await stagehand.close().catch(() => undefined);
193
- throw error;
249
+ throw await describeInitError(error, resolvedTarget);
194
250
  }
195
251
  this.stagehand = stagehand;
196
252
  this.context = stagehand.context;
@@ -213,6 +269,10 @@ export class DriverSessionManager {
213
269
  disablePino: true,
214
270
  env: "LOCAL",
215
271
  localBrowserLaunchOptions: {
272
+ ...(target.chromeArgs?.length ? { args: target.chromeArgs } : {}),
273
+ ...(target.ignoreDefaultArgs !== undefined
274
+ ? { ignoreDefaultArgs: target.ignoreDefaultArgs }
275
+ : {}),
216
276
  headless: target.headless,
217
277
  },
218
278
  verbose: 0,
@@ -239,3 +299,24 @@ async function safeTitle(page) {
239
299
  return "";
240
300
  }
241
301
  }
302
+ /**
303
+ * Turn raw `stagehand.init()` failures into typed, actionable errors. Remote
304
+ * failures are classified by the remote capability (401/403/etc.); a missing
305
+ * local Chrome gets install/escape-hatch guidance. Anything else is rethrown
306
+ * unchanged.
307
+ */
308
+ async function describeInitError(error, target) {
309
+ if (error instanceof DriverError)
310
+ return error;
311
+ if (target.kind === "remote") {
312
+ const { code, httpStatus, message } = (await getRemote()).classifyRemoteInitError(error);
313
+ return new DriverError(message, { cause: error, code, httpStatus });
314
+ }
315
+ if (target.kind === "managed-local" && isChromeNotFoundError(error)) {
316
+ return new DriverError((await getRemote()).driverInitHints().chromeNotFound, {
317
+ cause: error,
318
+ code: "no_chrome_found",
319
+ });
320
+ }
321
+ return error;
322
+ }
@@ -5,6 +5,7 @@ import { homedir } from "node:os";
5
5
  import { delimiter, dirname, join } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { fail } from "../errors.js";
8
+ import { setRunTelemetryCompletion } from "../run-telemetry.js";
8
9
  import { defaultSkillsApiBaseUrl, isRecord, responseDetail } from "./shared.js";
9
10
  const defaultBlobBaseUrl = "https://gh0lfhlmyzhg6tww.public.blob.vercel-storage.com";
10
11
  const generatedSkillSuffixPattern = /-[A-Za-z0-9]{6}$/;
@@ -46,14 +47,18 @@ export function isBlobSkillId(skillId) {
46
47
  return generatedSkillSuffixPattern.test(skillId.task);
47
48
  }
48
49
  export async function installSkill(rawSkillId) {
49
- const skillId = parseSkillId(rawSkillId);
50
+ const skill = parseSkillId(rawSkillId);
51
+ // Attach only the validated, catalog-public id to telemetry — never the raw
52
+ // argument, which can contain arbitrary user input. Setting it right after
53
+ // parsing covers success and every downstream failure path.
54
+ setRunTelemetryCompletion({ skillId: skill.id });
50
55
  const npxPath = await findExecutable("npx");
51
56
  if (!npxPath) {
52
57
  fail("`npx` is not installed. Install Node.js from https://nodejs.org, then rerun `browse skills add`.", 1, { resultCode: "npx_missing" });
53
58
  }
54
- const files = await fetchSkillFiles(skillId);
59
+ const files = await fetchSkillFiles(skill);
55
60
  if (files.status === "found") {
56
- const result = await downloadBlobSkill(skillId, files.files);
61
+ const result = await downloadBlobSkill(skill, files.files);
57
62
  process.stdout.write(`Downloaded ${result.fileCount} skill file${result.fileCount === 1 ? "" : "s"} to ${result.installPath}\n`);
58
63
  await runSkillsInstall(npxPath, [
59
64
  "--yes",
@@ -64,7 +69,7 @@ export async function installSkill(rawSkillId) {
64
69
  return;
65
70
  }
66
71
  if (files.status === "not_found") {
67
- fail(`Skill "${skillId.id}" not found in the catalog. Run \`browse skills find ${skillId.domain}\` to discover available skills, or \`browse skills list\` to browse them.`, 1, { resultCode: "skill_not_found" });
72
+ fail(`Skill "${skill.id}" not found in the catalog. Run \`browse skills find ${skill.domain}\` to discover available skills, or \`browse skills list\` to browse them.`, 1, { resultCode: "skill_not_found" });
68
73
  }
69
74
  // Suffix-shaped (generated) ids, or a temporarily unavailable catalog, may
70
75
  // still resolve through the browse.sh GitHub repo.
@@ -74,10 +79,11 @@ export async function installSkill(rawSkillId) {
74
79
  "add",
75
80
  "browserbase/browse.sh",
76
81
  "--skill",
77
- skillId.id,
82
+ skill.id,
78
83
  ]);
79
84
  }
80
85
  export async function installBundledCliSkill() {
86
+ setRunTelemetryCompletion({ skillId: "bundled/browse" });
81
87
  const npxPath = await findExecutable("npx");
82
88
  if (!npxPath) {
83
89
  fail("`npx` is not installed. Install Node.js from https://nodejs.org, then rerun `browse skills install`.", 1, { resultCode: "npx_missing" });
@@ -52,6 +52,25 @@ export async function captureCommandCompleted(cliVersion, error) {
52
52
  completionCapture,
53
53
  ]);
54
54
  }
55
+ /**
56
+ * Captures a `cli.command_not_found` event. Privacy: only the sanitized
57
+ * attempted command id and the computed suggestion are sent — never raw argv,
58
+ * which can contain URLs, selectors, or secrets.
59
+ */
60
+ export async function captureCommandNotFound(cliVersion, attemptedCommand, suggestedCommand) {
61
+ await getCliTelemetry(cliVersion)
62
+ .capture("cli.command_not_found", {
63
+ attempted_command: attemptedCommand
64
+ ? resolveCommandPath(attemptedCommand)
65
+ : null,
66
+ suggested_command: suggestedCommand
67
+ ? resolveCommandPath(suggestedCommand)
68
+ : null,
69
+ })
70
+ .catch(() => {
71
+ // Best-effort telemetry should never affect CLI behavior.
72
+ });
73
+ }
55
74
  function createCliTelemetry(options) {
56
75
  const env = options.env ?? process.env;
57
76
  const transport = resolveTransportConfig(env);
@@ -147,6 +166,7 @@ function commandCompletedProperties(invocation, completion) {
147
166
  request_had_http_response: failureTelemetry?.requestHadHttpResponse ??
148
167
  runTelemetry.requestHadHttpResponse ??
149
168
  null,
169
+ skill_id: runTelemetry.skillId ?? null,
150
170
  };
151
171
  }
152
172
  function fallbackResultCode(success, errorType) {