stagehand 4.3.1a0.dev1658__tar.gz → 4.4.0a0.dev1660__tar.gz

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.
Files changed (39) hide show
  1. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/PKG-INFO +1 -1
  2. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/pyproject.toml +1 -1
  3. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/manifest.json +2 -2
  4. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/service-worker.js +157 -8
  5. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_generated/protocol_version.py +1 -1
  6. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browser.py +191 -18
  7. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/cdp_client.py +100 -2
  8. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/stagehand.py +1 -0
  9. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/README.md +0 -0
  10. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/__init__.py +0 -0
  11. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/blank.html +0 -0
  12. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/content-script.js +0 -0
  13. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/offscreen/service-worker-heartbeat.html +0 -0
  14. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/offscreen/service-worker-heartbeat.js +0 -0
  15. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/wake-service-worker.html +0 -0
  16. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_extension/wake-service-worker.js +0 -0
  17. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_generated/__init__.py +0 -0
  18. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_generated/input_types.py +0 -0
  19. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_generated/models.py +0 -0
  20. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_sdk_identity.py +0 -0
  21. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/_validation.py +0 -0
  22. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browser_clipboard.py +0 -0
  23. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browser_context.py +0 -0
  24. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browserbase_connection.py +0 -0
  25. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browserbase_rpc.py +0 -0
  26. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browserbase_services.py +0 -0
  27. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browserbase_session.py +0 -0
  28. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/browserbase_transport.py +0 -0
  29. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/client_models.py +0 -0
  30. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/client_types.py +0 -0
  31. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/extension_assets.py +0 -0
  32. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/file_upload.py +0 -0
  33. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/locator.py +0 -0
  34. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/page.py +0 -0
  35. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/py.typed +0 -0
  36. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/response.py +0 -0
  37. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/rpc_client.py +0 -0
  38. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/timeouts.py +0 -0
  39. {stagehand-4.3.1a0.dev1658 → stagehand-4.4.0a0.dev1660}/src/stagehand/webmcp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stagehand
3
- Version: 4.3.1a0.dev1658
3
+ Version: 4.4.0a0.dev1660
4
4
  Summary: Browserbase's SDK for building browser agents
5
5
  Keywords: ai,agent,ai-agent,browser,browser-agent,browser-automation,computer-use,headless,playwright,cdp,llm,automation,web-scraping,testing
6
6
  Author: Browserbase
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "stagehand"
3
- version = "4.3.1a0.dev1658"
3
+ version = "4.4.0a0.dev1660"
4
4
  description = "Browserbase's SDK for building browser agents"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Stagehand Runtime",
4
- "minimum_chrome_version": "116",
4
+ "minimum_chrome_version": "137",
5
5
  "permissions": [
6
6
  "debugger",
7
7
  "offscreen",
@@ -31,5 +31,5 @@
31
31
  }
32
32
  ],
33
33
  "options_page": "wake-service-worker.html",
34
- "version": "1.2.1"
34
+ "version": "1.3.0"
35
35
  }
@@ -9206,7 +9206,7 @@ function isRecord$1(value) {
9206
9206
  }
9207
9207
  var package_default$1 = {
9208
9208
  name: "@browserbasehq/stagehand-protocol",
9209
- version: "3.0.1-alpha-7d90812785c3ffea93b080982568fca07f952b1f",
9209
+ version: "3.1.1-alpha-d59afde37e995b165a41f754a12a446f11d471a2",
9210
9210
  "private": true,
9211
9211
  type: "module",
9212
9212
  exports: {
@@ -13213,6 +13213,7 @@ var RPCClient = class {
13213
13213
  this.nextRequestId = 1;
13214
13214
  this.pending = /* @__PURE__ */ new Map();
13215
13215
  this.closed = false;
13216
+ this.clientConnected = true;
13216
13217
  this.runtime.onmessage = (message, runtimeAttachments) => this.receive(message, runtimeAttachments);
13217
13218
  this.runtime.onclose = (reason) => this.close(reason);
13218
13219
  this.runtime.onerror = (error) => this.close(error);
@@ -13220,6 +13221,7 @@ var RPCClient = class {
13220
13221
  async send(method, params, options = {}) {
13221
13222
  options.signal?.throwIfAborted();
13222
13223
  if (this.closed) throw new Error("RPC client is closed");
13224
+ if (!this.clientConnected) throw new Error("Stagehand client disconnected");
13223
13225
  const parentContext = context.active();
13224
13226
  const span = this.router.runtime.tracing.tracer.startSpan(method.name, {
13225
13227
  kind: SpanKind.CLIENT,
@@ -13293,6 +13295,14 @@ var RPCClient = class {
13293
13295
  this.runtime.onerror = void 0;
13294
13296
  this.runtime.close();
13295
13297
  }
13298
+ /** Fail reverse RPCs whose client disappeared, allowing instance disposal to drain. */
13299
+ releaseClient() {
13300
+ this.clientConnected = false;
13301
+ for (const [id] of this.pending) this.rejectPending(id, /* @__PURE__ */ new Error("Stagehand client disconnected"));
13302
+ }
13303
+ acceptClient() {
13304
+ this.clientConnected = true;
13305
+ }
13296
13306
  waitForResponse(id, method, cleanup = () => {}) {
13297
13307
  return new Promise((resolve, reject) => {
13298
13308
  this.pending.set(id, {
@@ -56384,7 +56394,7 @@ var WebTracerProvider = class extends BasicTracerProvider {
56384
56394
  };
56385
56395
  var package_default = {
56386
56396
  name: "@browserbasehq/stagehand-extension",
56387
- version: "1.2.1-alpha-7d90812785c3ffea93b080982568fca07f952b1f",
56397
+ version: "1.3.0-alpha-d59afde37e995b165a41f754a12a446f11d471a2",
56388
56398
  "private": true,
56389
56399
  type: "module",
56390
56400
  scripts: {
@@ -60379,6 +60389,7 @@ var StagehandRuntime = class {
60379
60389
  this.state = createStore()(() => StagehandRuntimeStateSchema.parse({ status: "idle" }));
60380
60390
  this.pagesById = /* @__PURE__ */ new Map();
60381
60391
  this.pageEventSubscriptions = /* @__PURE__ */ new Map();
60392
+ this.browserConnectionGeneration = 0;
60382
60393
  this.initializationInProgress = false;
60383
60394
  this.lifecycleTail = Promise.resolve();
60384
60395
  this.stagehandInstanceClosing = false;
@@ -60387,6 +60398,7 @@ var StagehandRuntime = class {
60387
60398
  }
60388
60399
  async replaceBrowserConnection(params, bootstrapLogger) {
60389
60400
  const { cdpUrl } = params;
60401
+ const generation = ++this.browserConnectionGeneration;
60390
60402
  const previousSession = this.browserSession;
60391
60403
  this.browserSession = void 0;
60392
60404
  this.browserSessionCdpUrl = void 0;
@@ -60395,14 +60407,35 @@ var StagehandRuntime = class {
60395
60407
  this.responseHandles.clear();
60396
60408
  await previousSession?.close();
60397
60409
  try {
60398
- this.browserSession = await this.adapters.browserSessionFactory(cdpUrl, this.logger, bootstrapLogger);
60410
+ const session = await this.adapters.browserSessionFactory(cdpUrl, this.logger, bootstrapLogger);
60411
+ if (generation !== this.browserConnectionGeneration) {
60412
+ await session.close();
60413
+ throw new Error("Stagehand browser connection was dropped while connecting");
60414
+ }
60415
+ this.browserSession = session;
60399
60416
  this.browserSessionCdpUrl = cdpUrl;
60400
60417
  } catch (error) {
60401
- await this.browserSession?.close();
60402
- this.browserSession = void 0;
60418
+ if (generation === this.browserConnectionGeneration) {
60419
+ await this.browserSession?.close();
60420
+ this.browserSession = void 0;
60421
+ }
60403
60422
  throw error;
60404
60423
  }
60405
60424
  }
60425
+ /**
60426
+ * Closes the runtime's own browser connection immediately, outside the lifecycle
60427
+ * queue: a lost client must not wait behind lifecycle work that needs that client.
60428
+ */
60429
+ async dropBrowserConnection() {
60430
+ this.browserConnectionGeneration += 1;
60431
+ const session = this.browserSession;
60432
+ this.browserSession = void 0;
60433
+ this.browserSessionCdpUrl = void 0;
60434
+ await session?.close();
60435
+ }
60436
+ async ensureBrowserConnection(cdpUrl) {
60437
+ if (!this.browserSession?.connected || this.browserSessionCdpUrl !== cdpUrl) await this.replaceBrowserConnection({ cdpUrl });
60438
+ }
60406
60439
  async initialize(params, logger = this.logger) {
60407
60440
  if (this.initializationInProgress) throw new Error("Stagehand initialization is already in progress");
60408
60441
  this.initializationInProgress = true;
@@ -61394,6 +61427,96 @@ function getDomainPolicyDecision(url, policy) {
61394
61427
  };
61395
61428
  return { action: "continue" };
61396
61429
  }
61430
+ var RequestSchema = discriminatedUnion("action", [strictObject({
61431
+ action: literal("attach"),
61432
+ targetId: string().min(1),
61433
+ cdpUrl: string().min(1),
61434
+ closeBrowserOnDisconnect: boolean()
61435
+ }), strictObject({
61436
+ action: literal("detach"),
61437
+ targetId: string().min(1)
61438
+ })]);
61439
+ /** Browser-side ownership of one SDK attachment. No heartbeat or expiry timer. */
61440
+ var ClientLifecycle = class {
61441
+ constructor(options) {
61442
+ this.options = options;
61443
+ this.tail = Promise.resolve();
61444
+ }
61445
+ handle(input) {
61446
+ const request = RequestSchema.parse(input);
61447
+ return this.enqueue(async () => {
61448
+ if (request.action === "detach") {
61449
+ const attachment = this.attachment;
61450
+ if (attachment?.targetId !== request.targetId) {
61451
+ if (this.detachedTargetId === request.targetId) return;
61452
+ throw new Error("Stagehand client attachment is no longer active");
61453
+ }
61454
+ attachment.closeBrowserOnDisconnect = false;
61455
+ this.options.rejectClientCallbacks();
61456
+ await this.options.closeConnection();
61457
+ this.removeAttachment();
61458
+ this.detachedTargetId = request.targetId;
61459
+ this.options.disposeClient().catch((error) => this.options.onError(error));
61460
+ return;
61461
+ }
61462
+ if (this.attachment) {
61463
+ const previous = this.attachment;
61464
+ if ((await previous.connection.getTargets()).some((target) => target.targetId === previous.targetId)) throw new Error("A Stagehand client is already connected");
61465
+ await this.lost(previous);
61466
+ }
61467
+ const connection = await this.options.connection(request.cdpUrl);
61468
+ const attachment = {
61469
+ ...request,
61470
+ connection,
61471
+ onDestroyed: ({ targetId }) => {
61472
+ if (targetId !== request.targetId) return;
61473
+ this.enqueue(() => this.lost(attachment)).catch((error) => this.options.onError(error));
61474
+ }
61475
+ };
61476
+ this.attachment = attachment;
61477
+ connection.on("Target.targetDestroyed", attachment.onDestroyed);
61478
+ try {
61479
+ const target = (await connection.getTargets()).find((target) => target.targetId === request.targetId);
61480
+ if (!target) {
61481
+ await this.lost(attachment);
61482
+ throw new Error("Stagehand client disconnected during registration");
61483
+ }
61484
+ if (target.url !== "about:blank#stagehand-client-connection") throw new Error("Invalid Stagehand client connection target");
61485
+ } catch (error) {
61486
+ if (this.attachment === attachment) this.removeAttachment();
61487
+ throw error;
61488
+ }
61489
+ });
61490
+ }
61491
+ async lost(attachment) {
61492
+ if (this.attachment !== attachment) return;
61493
+ this.removeAttachment();
61494
+ if (attachment.closeBrowserOnDisconnect) {
61495
+ try {
61496
+ await attachment.connection.send("Browser.close");
61497
+ } catch (error) {
61498
+ if (attachment.connection.connected) throw error;
61499
+ }
61500
+ return;
61501
+ }
61502
+ this.options.rejectClientCallbacks();
61503
+ try {
61504
+ await this.options.closeConnection();
61505
+ } finally {
61506
+ this.options.disposeClient().catch((error) => this.options.onError(error));
61507
+ }
61508
+ }
61509
+ removeAttachment() {
61510
+ const attachment = this.attachment;
61511
+ this.attachment = void 0;
61512
+ attachment?.connection.off("Target.targetDestroyed", attachment.onDestroyed);
61513
+ }
61514
+ enqueue(work) {
61515
+ const result = this.tail.then(work, work);
61516
+ this.tail = result.catch(() => void 0);
61517
+ return result;
61518
+ }
61519
+ };
61397
61520
  //#endregion
61398
61521
  //#region understudy/context.ts
61399
61522
  function isMissingTargetError(error) {
@@ -61414,12 +61537,13 @@ function hasInjectableDOM(url) {
61414
61537
  return false;
61415
61538
  }
61416
61539
  function isNonWebTarget(info) {
61540
+ if (info.url === "about:blank#stagehand-client-connection") return true;
61417
61541
  if (info.type === "page") return false;
61418
61542
  return info.type !== "iframe" || !hasInjectableDOM(info.url);
61419
61543
  }
61420
61544
  function isTopLevelPage(info) {
61421
61545
  const ti = info;
61422
- return info.type === "page" && ti.subtype !== "iframe";
61546
+ return info.type === "page" && ti.subtype !== "iframe" && info.url !== "about:blank#stagehand-client-connection";
61423
61547
  }
61424
61548
  var DEFAULT_ACTIVE_PAGE_TIMEOUT_MS = 3e3;
61425
61549
  /**
@@ -62210,7 +62334,7 @@ var STAGEHAND_RUNTIME_VERSION = package_default.version;
62210
62334
  function startStagehandServiceWorker(scope = globalThis, runtime) {
62211
62335
  const chromeRuntimeClient = new ChromeRuntimeClient(scope, STAGEHAND_SEND_TO_HOST_BINDING);
62212
62336
  let rpcClient;
62213
- rpcClient = new RPCClient(chromeRuntimeClient, new RPCRouter(runtime ?? createStagehandRuntime({
62337
+ const activeRuntime = runtime ?? createStagehandRuntime({
62214
62338
  browserSessionFactory: async (cdpUrl, logger, bootstrapLogger) => {
62215
62339
  const locatorRuntimeResponse = await fetch(chrome.runtime.getURL("content-script.js"));
62216
62340
  if (!locatorRuntimeResponse.ok) throw new Error(`Failed to load Stagehand locator runtime: ${locatorRuntimeResponse.status}`);
@@ -62246,7 +62370,32 @@ function startStagehandServiceWorker(scope = globalThis, runtime) {
62246
62370
  if (!rpcClient) throw new Error("Stagehand RPC client is not connected");
62247
62371
  return await rpcClient.send(StagehandMethods.llmGenerate, params);
62248
62372
  }
62249
- })));
62373
+ });
62374
+ rpcClient = new RPCClient(chromeRuntimeClient, new RPCRouter(activeRuntime));
62375
+ const lifecycle = new ClientLifecycle({
62376
+ connection: async (cdpUrl) => {
62377
+ await activeRuntime.ensureBrowserConnection(cdpUrl);
62378
+ const session = activeRuntime.browserSession;
62379
+ if (!(session instanceof BrowserContext)) throw new Error("Stagehand client lifecycle requires a CDP browser connection");
62380
+ return session.conn;
62381
+ },
62382
+ rejectClientCallbacks: () => rpcClient?.releaseClient(),
62383
+ closeConnection: () => activeRuntime.dropBrowserConnection(),
62384
+ disposeClient: () => activeRuntime.disposeStagehandInstance(),
62385
+ onError: (error) => {
62386
+ console.error("Stagehand client lifecycle cleanup failed", error);
62387
+ }
62388
+ });
62389
+ scope.__stagehandClientLifecycle = async (input) => {
62390
+ const action = typeof input === "object" && input !== null && "action" in input ? input.action : void 0;
62391
+ try {
62392
+ await lifecycle.handle(input);
62393
+ } catch (error) {
62394
+ if (action === "detach") rpcClient?.acceptClient();
62395
+ throw error;
62396
+ }
62397
+ if (action === "attach") rpcClient?.acceptClient();
62398
+ };
62250
62399
  scope.__stagehand_runtime = {
62251
62400
  protocolVersion: STAGEHAND_PROTOCOL_VERSION,
62252
62401
  serverInfo: {
@@ -1,4 +1,4 @@
1
1
  """Generated from packages/protocol/package.json. Do not edit."""
2
2
 
3
3
  PROTOCOL_SEMVER_PATTERN = '^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$'
4
- STAGEHAND_PROTOCOL_VERSION = '3.0.1-alpha-7d90812785c3ffea93b080982568fca07f952b1f'
4
+ STAGEHAND_PROTOCOL_VERSION = '3.1.1-alpha-d59afde37e995b165a41f754a12a446f11d471a2'
@@ -107,26 +107,54 @@ class ResolvedBrowserSource:
107
107
  return
108
108
  if self._close_task is None:
109
109
  self._close_task = asyncio.create_task(_invoke_close(self._close_callback))
110
- await asyncio.shield(self._close_task)
110
+ task = self._close_task
111
+ try:
112
+ await asyncio.shield(task)
113
+ except BaseException:
114
+ # A failed close (e.g. a transient Browserbase release error) can be retried.
115
+ if (
116
+ self._close_task is task
117
+ and task.done()
118
+ and not task.cancelled()
119
+ and task.exception() is not None
120
+ ):
121
+ self._close_task = None
122
+ raise
111
123
 
112
124
 
113
125
  async def _invoke_close(callback: Callable[[], Awaitable[None]]) -> None:
114
126
  await callback()
115
127
 
116
128
 
129
+ async def _noop() -> None:
130
+ return None
131
+
132
+
117
133
  @dataclass(frozen=True)
118
134
  class _ChromeProfile:
119
135
  path: Path
120
136
  remove: bool
121
137
 
122
138
 
139
+ _BROWSER_CLOSE_TIMEOUT_SECONDS = 5.0
140
+ _DISCONNECT_STAGEHAND_TIMEOUT_SECONDS = 10.0
141
+ _BROWSER_DISCONNECTED_MESSAGE = "The browser is disconnected; connect() again to close it"
142
+
143
+
123
144
  class StagehandBrowser:
124
145
  __slots__ = (
125
146
  "_attachment",
126
147
  "_claimed",
148
+ "_close_stagehand",
127
149
  "_close_callback",
150
+ "_disconnect_callback",
128
151
  "_invalidate_callback",
129
- "_terminal_task",
152
+ "_connected",
153
+ "_close_without_connection",
154
+ "_cdp_url",
155
+ "_state",
156
+ "_disconnect_task",
157
+ "_close_task",
130
158
  "_context",
131
159
  "_origin",
132
160
  "_provider",
@@ -141,7 +169,11 @@ class StagehandBrowser:
141
169
  close: Callable[[], Awaitable[None]],
142
170
  *,
143
171
  invalidate: Callable[[], Awaitable[None]] | None = None,
172
+ disconnect: Callable[[], Awaitable[None]] | None = None,
173
+ connected: Callable[[], bool] | None = None,
174
+ close_without_connection: Callable[[], Awaitable[None]] | None = None,
144
175
  session_id: str | None = None,
176
+ cdp_url: str | None = None,
145
177
  _token: object | None = None,
146
178
  ) -> None:
147
179
  if _token is not _BROWSER_TOKEN:
@@ -151,9 +183,18 @@ class StagehandBrowser:
151
183
  self._attachment = attachment
152
184
  self._close_callback = close
153
185
  self._invalidate_callback = invalidate or close
186
+ self._disconnect_callback = disconnect or _noop
187
+ self._connected = connected or (lambda: True)
188
+ # What close() does after an unexpectedly lost connection.
189
+ self._close_without_connection = close_without_connection or _noop
190
+ self._cdp_url = cdp_url
154
191
  self._claimed = False
155
- self._terminal_task: asyncio.Task[None] | None = None
192
+ self._state: Literal["open", "disconnected", "closed"] = "open"
193
+ self._disconnect_task: asyncio.Task[None] | None = None
194
+ self._close_task: asyncio.Task[None] | None = None
156
195
  self._context: BrowserContext | None = None
196
+ # Closes the attached Stagehand instance; disconnect() runs it before the connection.
197
+ self._close_stagehand: Callable[[], Awaitable[None]] | None = None
157
198
  self._session_id = session_id
158
199
 
159
200
  @property
@@ -169,9 +210,24 @@ class StagehandBrowser:
169
210
  """Browserbase session id backing this browser; None for local browsers."""
170
211
  return self._session_id
171
212
 
213
+ @property
214
+ def cdp_url(self) -> str | None:
215
+ """CDP URL to reconnect with local_browser.connect(); None for Browserbase browsers."""
216
+ return self._cdp_url
217
+
172
218
  @property
173
219
  def closed(self) -> bool:
174
- return self._terminal_task is not None
220
+ """True once ``close()`` or ``disconnect()`` was called."""
221
+ return self._state != "open"
222
+
223
+ @property
224
+ def connected(self) -> bool:
225
+ """False once the handle is closed or disconnected, or its connection was lost.
226
+
227
+ After a lost connection ``close()`` can still have work to do, such as releasing a
228
+ Browserbase session.
229
+ """
230
+ return self._state == "open" and self._connected()
175
231
 
176
232
  @property
177
233
  def context(self) -> BrowserContext:
@@ -183,18 +239,81 @@ class StagehandBrowser:
183
239
  return self._context
184
240
 
185
241
  def close(self) -> Awaitable[None]:
186
- if self._terminal_task is None:
187
- self._terminal_task = asyncio.create_task(
188
- _run_browser_terminal_callback(self._close_callback),
242
+ """Close the browser.
243
+
244
+ A local browser receives ``Browser.close`` (a launched one is also terminated if
245
+ it keeps running, and its temporary profile removed unless preserved), and a
246
+ Browserbase session is released. After ``disconnect()`` it raises: ``connect()``
247
+ again to close the browser. After an unexpectedly lost connection it still
248
+ releases a Browserbase session and does nothing for a local browser. Repeated
249
+ ``close()`` calls do nothing.
250
+ """
251
+ if self._close_task is None:
252
+ if self._state == "disconnected":
253
+ raise RuntimeError(_BROWSER_DISCONNECTED_MESSAGE)
254
+ self._close_task = asyncio.create_task(
255
+ _run_browser_terminal_callback(self._close_callback)
256
+ if self._connected()
257
+ else self._close_without_connection_retryable(),
189
258
  name="stagehand-browser-close",
190
259
  )
191
- return asyncio.shield(self._terminal_task)
260
+ self._state = "closed"
261
+ return asyncio.shield(self._close_task)
262
+
263
+ async def _close_without_connection_retryable(self) -> None:
264
+ try:
265
+ await self._close_without_connection()
266
+ except BaseException:
267
+ # Releasing over the Browserbase API can fail transiently: allow a retry.
268
+ self._close_task = None
269
+ raise
270
+
271
+ def disconnect(self) -> Awaitable[None]:
272
+ """Close the attached Stagehand instance, then Stagehand's connection.
273
+
274
+ The attached instance closes first, including the extension's own connection to
275
+ the browser. The browser keeps running: Stagehand never closes it or releases its
276
+ Browserbase session here. Browserbase still ends a session without
277
+ ``keep_alive`` when its CDP connection closes, so only ``keep_alive`` sessions
278
+ survive a disconnect. The handle is finished afterwards: ``connect()`` again for
279
+ a new one. Repeated ``disconnect()`` calls, or ``disconnect()`` after
280
+ ``close()``, do nothing.
281
+ """
282
+ if self._state == "closed" and self._close_task is not None:
283
+ return asyncio.shield(self._close_task)
284
+ if self._disconnect_task is None:
285
+ self._state = "disconnected"
286
+ self._disconnect_task = asyncio.create_task(
287
+ self._disconnect_after_closing_stagehand(self._close_stagehand),
288
+ name="stagehand-browser-disconnect",
289
+ )
290
+ return asyncio.shield(self._disconnect_task)
291
+
292
+ async def _disconnect_after_closing_stagehand(
293
+ self, close_stagehand: Callable[[], Awaitable[None]] | None
294
+ ) -> None:
295
+ try:
296
+ try:
297
+ if close_stagehand is not None:
298
+ # stagehand.close() waits for in-flight work without a deadline;
299
+ # disconnect() stops waiting and lets go anyway, which also ends that work.
300
+ await _close_stagehand_within(
301
+ close_stagehand, _DISCONNECT_STAGEHAND_TIMEOUT_SECONDS
302
+ )
303
+ finally:
304
+ await self._disconnect_callback()
305
+ except BaseException:
306
+ # Still connected means nothing was let go: keep the handle usable for a retry.
307
+ if self._connected():
308
+ self._state = "open"
309
+ self._disconnect_task = None
310
+ raise
192
311
 
193
312
 
194
313
  def _claim_browser(browser: object) -> _ClaimedBrowser:
195
314
  if not isinstance(browser, StagehandBrowser):
196
315
  raise TypeError("browser must be created by local_browser or browserbase")
197
- if browser.closed:
316
+ if not browser.connected:
198
317
  raise RuntimeError("Cannot attach Stagehand to a closed browser")
199
318
  if browser._claimed:
200
319
  raise RuntimeError("This browser is already attached to a Stagehand instance")
@@ -211,12 +330,15 @@ def _release_browser(browser: StagehandBrowser) -> None:
211
330
  def _invalidate_browser(browser: StagehandBrowser) -> Awaitable[None]:
212
331
  if not isinstance(browser, StagehandBrowser):
213
332
  raise TypeError("browser must be created by local_browser or browserbase")
214
- if browser._terminal_task is None:
215
- browser._terminal_task = asyncio.create_task(
216
- _run_browser_terminal_callback(browser._invalidate_callback),
217
- name="stagehand-browser-invalidate",
218
- )
219
- return asyncio.shield(browser._terminal_task)
333
+ existing = browser._close_task or browser._disconnect_task
334
+ if existing is not None:
335
+ return asyncio.shield(existing)
336
+ browser._state = "closed"
337
+ browser._close_task = asyncio.create_task(
338
+ _run_browser_terminal_callback(browser._invalidate_callback),
339
+ name="stagehand-browser-invalidate",
340
+ )
341
+ return asyncio.shield(browser._close_task)
220
342
 
221
343
 
222
344
  async def _run_browser_terminal_callback(
@@ -225,19 +347,35 @@ async def _run_browser_terminal_callback(
225
347
  await callback()
226
348
 
227
349
 
350
+ async def _close_stagehand_within(
351
+ close_stagehand: Callable[[], Awaitable[None]], timeout: float
352
+ ) -> None:
353
+ deadline = asyncio.timeout(timeout)
354
+ try:
355
+ async with deadline:
356
+ await close_stagehand()
357
+ except TimeoutError:
358
+ # Only this deadline is expected; a TimeoutError from Stagehand itself is a failure.
359
+ if not deadline.expired():
360
+ raise
361
+
362
+
228
363
  def _attach_browser_context(
229
364
  browser: StagehandBrowser,
230
365
  context: BrowserContext,
366
+ close_stagehand: Callable[[], Awaitable[None]] | None = None,
231
367
  ) -> None:
232
368
  if not browser._claimed:
233
369
  raise RuntimeError("Cannot attach a browser context before Stagehand claims the browser")
234
370
  if browser._context is not None:
235
371
  raise RuntimeError("This browser already has a Stagehand context")
236
372
  browser._context = context
373
+ browser._close_stagehand = close_stagehand
237
374
 
238
375
 
239
376
  def _detach_browser_context(browser: StagehandBrowser) -> None:
240
377
  browser._context = None
378
+ browser._close_stagehand = None
241
379
 
242
380
 
243
381
  def _browser_session_metadata(
@@ -325,6 +463,10 @@ async def _connect_browser(
325
463
  extension_id=extension_id,
326
464
  preloaded_extension=preloaded_extension,
327
465
  service_worker_url_includes="service-worker.js",
466
+ # Only a local browser Stagehand launched without keep_alive is shut down when
467
+ # this client disappears; Browserbase applies its own keep_alive policy.
468
+ close_browser_on_disconnect=provider == "local" and owns_source,
469
+ extension_cdp_url=worker_init_metadata.browser_cdp_url,
328
470
  )
329
471
  if after_connect is not None:
330
472
  await after_connect(cdp_client)
@@ -354,6 +496,13 @@ async def _connect_browser(
354
496
 
355
497
  connected_client = cdp_client
356
498
 
499
+ async def disconnect() -> None:
500
+ # Detaching first disarms the extension's watchdog; without that acknowledgement
501
+ # the socket stays open, since closing it would read as a lost client and could
502
+ # shut the browser down.
503
+ await connected_client.detach_client()
504
+ await connected_client.close()
505
+
357
506
  async def invalidate() -> None:
358
507
  try:
359
508
  await connected_client.close()
@@ -364,12 +513,28 @@ async def _connect_browser(
364
513
  async def close() -> None:
365
514
  errors: list[BaseException] = []
366
515
  try:
367
- if provider == "local" and origin == "connected":
516
+ if provider == "local":
368
517
  try:
369
- await connected_client.send_command("Browser.close")
518
+ # Bounded so an unresponsive Chrome cannot keep a launched one from
519
+ # being terminated.
520
+ async with asyncio.timeout(_BROWSER_CLOSE_TIMEOUT_SECONDS):
521
+ await connected_client.send_command("Browser.close")
370
522
  except CDPConnectionClosedError:
371
523
  pass
372
- else:
524
+ except TimeoutError:
525
+ # A launched browser is terminated below; a connected one cannot be.
526
+ if origin == "connected":
527
+ raise RuntimeError(
528
+ "Browser.close did not respond within "
529
+ f"{_BROWSER_CLOSE_TIMEOUT_SECONDS} seconds"
530
+ ) from None
531
+ except BaseException as error:
532
+ errors.append(error)
533
+ try:
534
+ # A launched local browser is also terminated if Browser.close failed or
535
+ # left it running, and its profile removed unless preserved; a Browserbase
536
+ # session is released.
537
+ if provider == "browserbase" or origin == "launched":
373
538
  await source.close()
374
539
  except BaseException as error:
375
540
  errors.append(error)
@@ -391,6 +556,14 @@ async def _connect_browser(
391
556
  ),
392
557
  close,
393
558
  invalidate=invalidate,
559
+ # Never closes the browser or releases its session. Browserbase still ends a
560
+ # session without keep_alive once this connection closes.
561
+ disconnect=disconnect,
562
+ connected=lambda: not connected_client.closed,
563
+ # After an unexpected loss, a Browserbase session can still be released over its
564
+ # API; a local browser is left alone.
565
+ close_without_connection=source.close if provider == "browserbase" else None,
566
+ cdp_url=source.cdp_url if provider == "local" else None,
394
567
  session_id=(
395
568
  worker_init_metadata.browser.session_id if worker_init_metadata.browser else None
396
569
  ),
@@ -15,7 +15,7 @@ from urllib.request import HTTPRedirectHandler, Request, build_opener, urlopen
15
15
 
16
16
  from typing_extensions import override
17
17
  from websockets.asyncio.client import connect as WebSocketConnect
18
- from websockets.exceptions import InvalidStatus
18
+ from websockets.exceptions import ConnectionClosed, InvalidStatus
19
19
 
20
20
  from stagehand._generated.protocol_version import (
21
21
  PROTOCOL_SEMVER_PATTERN,
@@ -239,6 +239,9 @@ class CDPConnectionClosedError(RuntimeError):
239
239
  super().__init__("CDP connection closed")
240
240
 
241
241
 
242
+ _CLIENT_TARGET_URL = "about:blank#stagehand-client-connection"
243
+
244
+
242
245
  class CDPClient:
243
246
  def __init__(
244
247
  self,
@@ -253,6 +256,9 @@ class CDPClient:
253
256
  self._session_id: str | None = None
254
257
  self._service_worker: ServiceWorkerInfo | None = None
255
258
  self._closed = False
259
+ self._client_target_id: str | None = None
260
+ # Whether the extension shuts the browser down if this client is lost.
261
+ self._client_close_browser_on_loss = False
256
262
  self._background_tasks: set[asyncio.Task[dict[str, object]]] = set()
257
263
  self._reader = asyncio.create_task(self._read(), name="stagehand-cdp-reader")
258
264
 
@@ -267,6 +273,8 @@ class CDPClient:
267
273
  preloaded_extension: bool = False,
268
274
  service_worker_url_includes: str | None = None,
269
275
  headers: Mapping[str, str] | None = None,
276
+ close_browser_on_disconnect: bool | None = None,
277
+ extension_cdp_url: str | None = None,
270
278
  ) -> CDPClient:
271
279
  """Connect without an independent lifecycle deadline.
272
280
 
@@ -338,17 +346,104 @@ class CDPClient:
338
346
  session_id=session_id,
339
347
  )
340
348
  await client._wait_for_runtime_receiver(session_id)
349
+ if close_browser_on_disconnect is not None:
350
+ await client._register_client(
351
+ close_browser_on_disconnect,
352
+ extension_cdp_url or web_socket_debugger_url,
353
+ )
341
354
  return client
342
355
  except BaseException:
343
356
  await asyncio.shield(client.close())
344
357
  raise
345
358
 
359
+ @property
360
+ def closed(self) -> bool:
361
+ return self._closed
362
+
346
363
  @property
347
364
  def service_worker(self) -> ServiceWorkerInfo:
348
365
  if self._service_worker is None:
349
366
  raise RuntimeError("Stagehand service worker is not attached")
350
367
  return self._service_worker
351
368
 
369
+ async def _register_client(self, close_browser_on_disconnect: bool, cdp_url: str) -> None:
370
+ marker = await self.send_command(
371
+ "Target.createTarget",
372
+ {"url": _CLIENT_TARGET_URL, "hidden": True, "background": True},
373
+ )
374
+ target_id = _required_string(marker, "targetId", "Target.createTarget")
375
+ # Chrome accepts unknown parameters: a build without hidden targets opens an
376
+ # ordinary tab that outlives this connection and would never signal its loss.
377
+ info = await self.send_command("Target.getTargetInfo", {"targetId": target_id})
378
+ target_info = info.get("targetInfo")
379
+ if not isinstance(target_info, dict) or target_info.get("type") != "other":
380
+ await self.send_command("Target.closeTarget", {"targetId": target_id})
381
+ raise RuntimeError(
382
+ "Stagehand requires Chrome 137 or later, which supports hidden targets"
383
+ )
384
+ if not await self._client_lifecycle_command({
385
+ "action": "attach",
386
+ "targetId": target_id,
387
+ "cdpUrl": cdp_url,
388
+ "closeBrowserOnDisconnect": close_browser_on_disconnect,
389
+ }):
390
+ # The protocol handshake already rejects older extensions; this one is
391
+ # inconsistent.
392
+ await self.send_command("Target.closeTarget", {"targetId": target_id})
393
+ raise RuntimeError(
394
+ "The Stagehand extension in this browser does not support the client "
395
+ "watchdog; update the extension"
396
+ )
397
+ self._client_target_id = target_id
398
+ self._client_close_browser_on_loss = close_browser_on_disconnect
399
+
400
+ async def detach_client(self) -> None:
401
+ """Acknowledge intentional detach before the socket/hidden target disappears."""
402
+ if self._client_target_id is None or self._closed:
403
+ return
404
+ try:
405
+ async with asyncio.timeout(10):
406
+ acknowledged = await self._client_lifecycle_command({
407
+ "action": "detach",
408
+ "targetId": self._client_target_id,
409
+ })
410
+ except CDPConnectionClosedError:
411
+ # Browserbase ends a keep_alive=False session once the extension closes its own
412
+ # connection, which closes this one before the acknowledgement arrives: the
413
+ # detach happened. A failure or timeout with this connection open still raises,
414
+ # and so does a lost connection when the extension shuts the browser down on
415
+ # loss: it may not have received the detach.
416
+ if self._client_close_browser_on_loss:
417
+ raise
418
+ acknowledged = True
419
+ if not acknowledged:
420
+ raise RuntimeError("The Stagehand extension did not acknowledge the detach")
421
+ self._client_target_id = None
422
+
423
+ async def _client_lifecycle_command(self, params: dict[str, object]) -> bool:
424
+ """Return False when the extension has no client lifecycle support."""
425
+ expression = (
426
+ "(async () => { "
427
+ 'if (typeof globalThis.__stagehandClientLifecycle !== "function") return false; '
428
+ f"await globalThis.__stagehandClientLifecycle({json.dumps(params)}); "
429
+ "return true; })()"
430
+ )
431
+ result = await self.send_command(
432
+ "Runtime.evaluate",
433
+ {"expression": expression, "awaitPromise": True, "returnByValue": True},
434
+ session_id=self._session_id,
435
+ )
436
+ value = result.get("result")
437
+ if (
438
+ result.get("exceptionDetails")
439
+ or not isinstance(value, dict)
440
+ or not isinstance(value.get("value"), bool)
441
+ ):
442
+ raise RuntimeError(
443
+ f"Stagehand client lifecycle failed: {result.get('exceptionDetails')}"
444
+ )
445
+ return bool(value["value"])
446
+
352
447
  async def send(
353
448
  self,
354
449
  message: dict[str, object],
@@ -415,7 +510,10 @@ class CDPClient:
415
510
  message["sessionId"] = session_id
416
511
 
417
512
  try:
418
- await self._socket.send(json.dumps(message, separators=(",", ":")))
513
+ try:
514
+ await self._socket.send(json.dumps(message, separators=(",", ":")))
515
+ except ConnectionClosed as error:
516
+ raise CDPConnectionClosedError() from error
419
517
  result = await response
420
518
  except BaseException:
421
519
  if not response.done():
@@ -296,6 +296,7 @@ class Stagehand:
296
296
  _attach_browser_context(
297
297
  self._browser_handle,
298
298
  BrowserContext(rpc_client, self._browser_handle.close),
299
+ self.close,
299
300
  )
300
301
  self._initialized = True
301
302