From 859089dec9304aa100e0383ebc5f892aaf904835 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 24 Feb 2026 15:30:10 -0800 Subject: [PATCH 1/4] cancel job in separate step so lint isnt marked skipped when it fails --- .github/workflows/ci.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b1b4233d..a56ff62a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -191,9 +191,14 @@ jobs: - name: Run Lint run: pnpm exec turbo run lint - - name: Cancel workflow if lint fails - if: failure() - continue-on-error: true + cancel-after-lint-failure: + name: Cancel after lint failure + runs-on: ubuntu-latest + needs: [run-lint] + if: ${{ always() && needs.run-lint.result == 'failure' }} + continue-on-error: true + steps: + - name: Cancel workflow run env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From 7a4988688d62e9a60f8c5264edafb8c04d057402 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 24 Feb 2026 15:30:27 -0800 Subject: [PATCH 2/4] auto-create about:blank tab when connecting to browser with 0 pages --- packages/core/lib/v3/understudy/context.ts | 45 +++++++++++++++-- .../connect-to-existing-browser.spec.ts | 50 +++++++++++++++++++ 2 files changed, 90 insertions(+), 5 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index e544b75dd..800223842 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -75,6 +75,14 @@ const DEFAULT_FIRST_TOP_LEVEL_PAGE_TIMEOUT_MS = 5000; const CI_FIRST_TOP_LEVEL_PAGE_TIMEOUT_MS = 30000; const FIRST_TOP_LEVEL_PAGE_TIMEOUT_ENV = "STAGEHAND_FIRST_TOP_LEVEL_PAGE_TIMEOUT_MS"; +const WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION = + "waitForFirstTopLevelPage (no top-level Page)"; + +class WaitForFirstTopLevelPageTimeoutError extends TimeoutError { + constructor(timeoutMs: number) { + super(WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION, timeoutMs); + } +} function getFirstTopLevelPageTimeoutMs(): number { return ( @@ -168,7 +176,7 @@ export class V3Context { opts?.localBrowserLaunchOptions ?? null, ); await ctx.bootstrap(); - await ctx.waitForFirstTopLevelPage(getFirstTopLevelPageTimeoutMs()); + await ctx.ensureFirstTopLevelPage(getFirstTopLevelPageTimeoutMs()); return ctx; }; @@ -199,6 +207,36 @@ export class V3Context { return await connectTask(); } + private hasTopLevelPage(): boolean { + for (const [targetId, targetType] of this.typeByTarget) { + if (targetType === "page" && this.pagesByTarget.has(targetId)) { + return true; + } + } + return false; + } + + private async ensureFirstTopLevelPage(timeoutMs: number): Promise { + if (this.hasTopLevelPage()) return; + + try { + await this.waitForFirstTopLevelPage(timeoutMs); + return; + } catch (err) { + if (!(err instanceof WaitForFirstTopLevelPageTimeoutError)) { + throw err; + } + v3Logger({ + category: "ctx", + message: + "No top-level page found after connect; creating recovery page target", + level: 1, + }); + } + + await this.newPage("about:blank"); + } + /** * Wait until at least one top-level Page has been created and registered. * We poll internal maps that bootstrap/onAttachedToTarget populate. @@ -216,10 +254,7 @@ export class V3Context { } await new Promise((r) => setTimeout(r, 25)); } - throw new TimeoutError( - "waitForFirstTopLevelPage (no top-level Page)", - timeoutMs, - ); + throw new WaitForFirstTopLevelPageTimeoutError(timeoutMs); } private async waitForInitialTopLevelTargets( diff --git a/packages/core/tests/integration/connect-to-existing-browser.spec.ts b/packages/core/tests/integration/connect-to-existing-browser.spec.ts index d42250a40..37a143a5a 100644 --- a/packages/core/tests/integration/connect-to-existing-browser.spec.ts +++ b/packages/core/tests/integration/connect-to-existing-browser.spec.ts @@ -67,4 +67,54 @@ test.describe("connect to existing Browserbase session", () => { await closeV3(initialStagehand); } }); + + test("new Stagehand instance initializes when existing browser has zero pages", async () => { + const browserTarget = ( + process.env.STAGEHAND_BROWSER_TARGET ?? "local" + ).toLowerCase(); + const isLocal = browserTarget !== "browserbase"; + test.skip(!isLocal, "Requires STAGEHAND_BROWSER_TARGET=local"); + + const initialStagehand = new V3({ + ...v3DynamicTestConfig, + disableAPI: true, + env: "LOCAL", + }); + await initialStagehand.init(); + + let resumedStagehand: V3 | null = null; + + try { + const ctx = initialStagehand.context; + const pages = ctx.pages(); + for (const page of pages) { + await page.close(); + } + + await expect.poll(() => ctx.pages().length, { timeout: 15_000 }).toBe(0); + + const sessionUrl = initialStagehand.connectURL(); + resumedStagehand = new V3({ + env: "LOCAL", + verbose: 0, + disablePino: true, + disableAPI: true, + logger: v3DynamicTestConfig.logger, + localBrowserLaunchOptions: { + cdpUrl: sessionUrl, + }, + }); + + await resumedStagehand.init(); + + await expect + .poll(() => resumedStagehand!.context.pages().length, { + timeout: 15_000, + }) + .toBeGreaterThan(0); + } finally { + await closeV3(resumedStagehand); + await closeV3(initialStagehand); + } + }); }); From 8950c76b17f92cc3b5a3f308be5338c6989a4375 Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 24 Feb 2026 15:30:35 -0800 Subject: [PATCH 3/4] add changeset for patch version --- .changeset/real-cameras-grin.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/real-cameras-grin.md diff --git a/.changeset/real-cameras-grin.md b/.changeset/real-cameras-grin.md new file mode 100644 index 000000000..22a326e00 --- /dev/null +++ b/.changeset/real-cameras-grin.md @@ -0,0 +1,5 @@ +--- +"@browserbasehq/stagehand": patch +--- + +When connecting to a browser session that has zero open tabs, Stagehand now automatically creates an initial `about:blank` tab so the connection can continue. From 45ed6dc059d44f07c338dc8f36e72eb0702a2cfd Mon Sep 17 00:00:00 2001 From: Nick Sweeting Date: Tue, 24 Feb 2026 17:29:41 -0800 Subject: [PATCH 4/4] remove special error --- packages/core/lib/v3/understudy/context.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/packages/core/lib/v3/understudy/context.ts b/packages/core/lib/v3/understudy/context.ts index 800223842..ae210a1e0 100644 --- a/packages/core/lib/v3/understudy/context.ts +++ b/packages/core/lib/v3/understudy/context.ts @@ -78,12 +78,6 @@ const FIRST_TOP_LEVEL_PAGE_TIMEOUT_ENV = const WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION = "waitForFirstTopLevelPage (no top-level Page)"; -class WaitForFirstTopLevelPageTimeoutError extends TimeoutError { - constructor(timeoutMs: number) { - super(WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION, timeoutMs); - } -} - function getFirstTopLevelPageTimeoutMs(): number { return ( getEnvTimeoutMs(FIRST_TOP_LEVEL_PAGE_TIMEOUT_ENV) ?? @@ -223,13 +217,13 @@ export class V3Context { await this.waitForFirstTopLevelPage(timeoutMs); return; } catch (err) { - if (!(err instanceof WaitForFirstTopLevelPageTimeoutError)) { + if (!(err instanceof TimeoutError)) { throw err; } v3Logger({ category: "ctx", message: - "No top-level page found after connect; creating recovery page target", + "No open browser pages found after connect; creating an initial about:blank page", level: 1, }); } @@ -254,7 +248,7 @@ export class V3Context { } await new Promise((r) => setTimeout(r, 25)); } - throw new WaitForFirstTopLevelPageTimeoutError(timeoutMs); + throw new TimeoutError(WAIT_FOR_FIRST_TOP_LEVEL_PAGE_OPERATION, timeoutMs); } private async waitForInitialTopLevelTargets(