feat(extension): attach the resident runtime on stagehand.init - #2803
feat(extension): attach the resident runtime on stagehand.init#2803miguelg719 wants to merge 5 commits into
Conversation
|
There was a problem hiding this comment.
6 issues found across 22 files
Confidence score: 2/5
packages/extension/tests/resident-runtime.test.tslocks inResidentRuntimeLifecycleexposing a URL-bearing browser-session failure, which can leak transport or session details to users; assert a typed, sanitized error instead.packages/extension/understudy/cdp.tspreserves arbitrary transport exception text in CDP send rejection paths, exposing internal failure details; use a dedicated typed CDP error with a safe public message.packages/extension/runtime.tsraises the generation-guard failure as a genericError, weakening the typed-error contract and making consistent sanitization harder; replace it with the appropriate dedicated error class.packages/extension/service-worker-lifecycle/resident-browser-proxy.tshas the same exposure risk when nested version-fetch text reachesstagehand.init, so sanitize that public error too.- The test updates leave regressions under-checked:
packages/extension/tests/resident-browser-proxy.test.tsno longer asserts the configured timeout duration, andpackages/extension/tests/runtime-descriptor.test.tsuses{}matching that does not provetimingsis empty; restore exact assertions.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/extension/understudy/cdp.ts">
<violation number="1" location="packages/extension/understudy/cdp.ts:220">
P2: Custom agent: **Exception and error message sanitization**
These new CDP rejection paths expose generic errors and, in the send catches, preserve arbitrary transport exception text. Use a dedicated typed CDP error with a fixed sanitized message for disconnected and transport-send failures.</violation>
</file>
<file name="packages/extension/tests/resident-runtime.test.ts">
<violation number="1" location="packages/extension/tests/resident-runtime.test.ts:334">
P1: Custom agent: **Exception and error message sanitization**
When the browser-session factory fails with a URL-bearing message, `ResidentRuntimeLifecycle` rethrows the generic error, and this test locks in exposing `private.internal` and its query secret. Wrap terminal bootstrap failures in a typed sanitized error before propagating them, while retaining only safe failure metadata in the marker.</violation>
</file>
<file name="packages/extension/runtime.ts">
<violation number="1" location="packages/extension/runtime.ts:428">
P2: Custom agent: **Exception and error message sanitization**
The new generation-guard failure is raised as a generic `Error`, which violates the requirement that user-raised failures use individually typed error classes. Define a dedicated sanitized supersession error in `errors.ts` and throw it from both replacement checks and `assertBrowserSessionCurrent`.</violation>
</file>
<file name="packages/extension/tests/resident-browser-proxy.test.ts">
<violation number="1" location="packages/extension/tests/resident-browser-proxy.test.ts:143">
P2: The timeout test no longer verifies the configured duration in the failure message, so regressions that lose or change `timed out after 1ms` will pass. Keep the duration assertion alongside the new error-code assertion.</violation>
</file>
<file name="packages/extension/tests/runtime-descriptor.test.ts">
<violation number="1" location="packages/extension/tests/runtime-descriptor.test.ts:24">
P2: The `toMatchObject` matcher does not verify that `timings` is empty, because `{}` matches every nested object. Assert `scope.__stagehand_runtime?.timings` with `toStrictEqual({})` so unexpected initialization timings are caught.
(Based on your team's feedback about adding focused regression tests for new behavior.)</violation>
</file>
<file name="packages/extension/service-worker-lifecycle/resident-browser-proxy.ts">
<violation number="1" location="packages/extension/service-worker-lifecycle/resident-browser-proxy.ts:57">
P2: When the version fetch rejects, this interpolates the nested fetch message into the error returned by `stagehand.init`. The RPC layer forwards that message unchanged, exposing transport details and making the public error unstable; keep a fixed proxy-unavailable message and retain diagnostics only internally.
(Based on your team's feedback about sanitizing CDP discovery failures.)</violation>
</file>
Architecture diagram
sequenceDiagram
participant SDK as Stagehand SDK (TS)
participant Router as RPC Router (Service Worker)
participant Life as ResidentRuntimeLifecycle
participant RT as StagehandRuntime
participant Proxy as Resident Browser Proxy
participant Session as Browser Session (CDP)
Note over SDK,Session: stagehand.init() Flow
SDK->>Router: stagehand.init(params)
alt NEW: Resident Mode (no browserCdpUrl provided)
Router->>Life: NEW: initialize(params)
Life->>Life: Set state to "connecting"
Life->>Proxy: resolveResidentBrowserWebSocketUrl()
Proxy-->>Life: Return WebSocket URL
Life->>RT: NEW: replaceBrowserConnection(options)
RT->>Session: NEW: BrowserContext.create({ restrictToWebTargets: true, ... })
Session-->>RT: Connection established
RT->>RT: NEW: restoreInitializedBrowserSession()
Note right of RT: Replays init scripts, headers, and viewports
RT-->>Life: Ready
Life->>Life: Set state to "ready"
else CHANGED: Custom CDP (browserCdpUrl provided)
Router->>Life: NEW: initializeWithBrowserCdpUrl(params)
Life->>RT: replaceBrowserConnection({ cdpUrl })
RT->>Session: BrowserContext.create(defaults)
end
RT-->>Router: Init result
Router-->>SDK: 200 OK
Note over SDK,Session: NEW: RPC Guard & Reconnect Resilience
SDK->>Router: context.pages()
Router->>Router: Check if method is SESSION_INDEPENDENT
opt CHANGED: Session Disconnected / Reconnecting
Router->>RT: NEW: waitForBrowserSession(timeout: 10s)
RT->>Life: NEW: pendingBrowserSessionRecovery()
alt Reconnect Success
Life-->>RT: Session ready
RT-->>Router: Resume RPC
else Reconnect Failure / Timeout
RT-->>Router: Throw BrowserSessionUnavailableError
Router-->>SDK: Error: STAGEHAND_BROWSER_SESSION_UNAVAILABLE
end
end
Router->>RT: contextPages()
RT->>Session: CDP: Target.getTargets
Session-->>RT: list of targets
RT-->>Router: pages metadata
Router-->>SDK: result
Note over Life,Session: Resident Reconnect Loop
Session-xRT: CHANGED: Connection dropped
RT->>Life: Trigger onDisconnected
Life->>Life: NEW: Schedule reconnect [100ms, 250ms, ..., 2000ms]
loop Reconnect Strategy
Life->>Proxy: resolveResidentBrowserWebSocketUrl()
alt Proxy Error (e.g., 503)
Proxy-->>Life: RESIDENT_PROXY_NOT_READY
Life->>Life: Wait for next delay slot
else Proxy OK
Life->>RT: replaceBrowserConnection()
RT->>Session: Re-establish CDP
RT->>RT: restoreInitializedBrowserSession()
end
end
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| }); | ||
| const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions()); | ||
|
|
||
| await expect(lifecycle.bootstrap()).rejects.toThrow("private.internal"); |
There was a problem hiding this comment.
P1: Custom agent: Exception and error message sanitization
When the browser-session factory fails with a URL-bearing message, ResidentRuntimeLifecycle rethrows the generic error, and this test locks in exposing private.internal and its query secret. Wrap terminal bootstrap failures in a typed sanitized error before propagating them, while retaining only safe failure metadata in the marker.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/resident-runtime.test.ts, line 334:
<comment>When the browser-session factory fails with a URL-bearing message, `ResidentRuntimeLifecycle` rethrows the generic error, and this test locks in exposing `private.internal` and its query secret. Wrap terminal bootstrap failures in a typed sanitized error before propagating them, while retaining only safe failure metadata in the marker.</comment>
<file context>
@@ -0,0 +1,981 @@
+ });
+ const lifecycle = new ResidentRuntimeLifecycle(runtime, residentOptions());
+
+ await expect(lifecycle.bootstrap()).rejects.toThrow("private.internal");
+ expect(lifecycle.marker).toMatchObject({
+ state: "failed",
</file context>
| params: JSON.stringify(params ?? {}), | ||
| }); | ||
| if (!this.transport.connected) { | ||
| return Promise.reject(new Error(`CDP connection is closed (method=${method})`)); |
There was a problem hiding this comment.
P2: Custom agent: Exception and error message sanitization
These new CDP rejection paths expose generic errors and, in the send catches, preserve arbitrary transport exception text. Use a dedicated typed CDP error with a fixed sanitized message for disconnected and transport-send failures.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/understudy/cdp.ts, line 220:
<comment>These new CDP rejection paths expose generic errors and, in the send catches, preserve arbitrary transport exception text. Use a dedicated typed CDP error with a fixed sanitized message for disconnected and transport-send failures.</comment>
<file context>
@@ -216,6 +216,9 @@ export class CdpConnection implements CDPSessionLike {
params: JSON.stringify(params ?? {}),
});
+ if (!this.transport.connected) {
+ return Promise.reject(new Error(`CDP connection is closed (method=${method})`));
+ }
const p = new Promise<unknown>((resolve, reject) => {
</file context>
| bootstrapLogger, | ||
| ); | ||
| if (generation !== this.browserSessionGeneration) { | ||
| throw new Error("Stagehand browser session bootstrap was superseded"); |
There was a problem hiding this comment.
P2: Custom agent: Exception and error message sanitization
The new generation-guard failure is raised as a generic Error, which violates the requirement that user-raised failures use individually typed error classes. Define a dedicated sanitized supersession error in errors.ts and throw it from both replacement checks and assertBrowserSessionCurrent.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/runtime.ts, line 428:
<comment>The new generation-guard failure is raised as a generic `Error`, which violates the requirement that user-raised failures use individually typed error classes. Define a dedicated sanitized supersession error in `errors.ts` and throw it from both replacement checks and `assertBrowserSessionCurrent`.</comment>
<file context>
@@ -313,27 +349,92 @@ export class StagehandRuntime {
- bootstrapLogger,
- );
+ if (generation !== this.browserSessionGeneration) {
+ throw new Error("Stagehand browser session bootstrap was superseded");
+ }
+ browserSession = await this.adapters.browserSessionFactory(cdpUrl, this.logger, options);
</file context>
| await expect( | ||
| resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch, timeoutMs: 1 }), | ||
| ).rejects.toThrow("timed out after 1ms"); | ||
| ).rejects.toMatchObject({ code: "RESIDENT_PROXY_UNAVAILABLE" }); |
There was a problem hiding this comment.
P2: The timeout test no longer verifies the configured duration in the failure message, so regressions that lose or change timed out after 1ms will pass. Keep the duration assertion alongside the new error-code assertion.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/resident-browser-proxy.test.ts, line 143:
<comment>The timeout test no longer verifies the configured duration in the failure message, so regressions that lose or change `timed out after 1ms` will pass. Keep the duration assertion alongside the new error-code assertion.</comment>
<file context>
@@ -131,7 +140,60 @@ describe("resident browser proxy resolver", () => {
await expect(
resolveResidentBrowserWebSocketUrl("http://127.0.0.1:9333", { fetch, timeoutMs: 1 }),
- ).rejects.toThrow("timed out after 1ms");
+ ).rejects.toMatchObject({ code: "RESIDENT_PROXY_UNAVAILABLE" });
+ });
+
</file context>
| ).rejects.toMatchObject({ code: "RESIDENT_PROXY_UNAVAILABLE" }); | |
| ).rejects.toMatchObject({ | |
| code: "RESIDENT_PROXY_UNAVAILABLE", | |
| message: expect.stringContaining("timed out after 1ms"), | |
| }); |
| expect(scope.__stagehand_runtime).toMatchObject({ | ||
| state: "unconfigured", | ||
| connected: false, | ||
| timings: {}, | ||
| }); |
There was a problem hiding this comment.
P2: The toMatchObject matcher does not verify that timings is empty, because {} matches every nested object. Assert scope.__stagehand_runtime?.timings with toStrictEqual({}) so unexpected initialization timings are caught.
(Based on your team's feedback about adding focused regression tests for new behavior.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/tests/runtime-descriptor.test.ts, line 24:
<comment>The `toMatchObject` matcher does not verify that `timings` is empty, because `{}` matches every nested object. Assert `scope.__stagehand_runtime?.timings` with `toStrictEqual({})` so unexpected initialization timings are caught.
(Based on your team's feedback about adding focused regression tests for new behavior.) </comment>
<file context>
@@ -12,15 +12,19 @@ describe("runtime descriptor", () => {
- },
+ serverInfo: { name: "stagehand", version: extensionPackageJson.version },
+ });
+ expect(scope.__stagehand_runtime).toMatchObject({
+ state: "unconfigured",
+ connected: false,
</file context>
| expect(scope.__stagehand_runtime).toMatchObject({ | |
| state: "unconfigured", | |
| connected: false, | |
| timings: {}, | |
| }); | |
| expect(scope.__stagehand_runtime).toMatchObject({ | |
| state: "unconfigured", | |
| connected: false, | |
| }); | |
| expect(scope.__stagehand_runtime?.timings).toStrictEqual({}); |
| const underlying = error instanceof Error ? error.message : String(error); | ||
| throw new ResidentBrowserProxyError( | ||
| "RESIDENT_PROXY_UNAVAILABLE", | ||
| `RESIDENT_PROXY_UNAVAILABLE: Resident browser proxy at ${proxyUrl.origin} is unavailable; the Browserbase session may not have enabled the Stagehand runtime or the proxy is not listening. Ensure browserSettings.extensions includes "stagehand". (${underlying})`, | ||
| { cause: error }, | ||
| ); |
There was a problem hiding this comment.
P2: When the version fetch rejects, this interpolates the nested fetch message into the error returned by stagehand.init. The RPC layer forwards that message unchanged, exposing transport details and making the public error unstable; keep a fixed proxy-unavailable message and retain diagnostics only internally.
(Based on your team's feedback about sanitizing CDP discovery failures.)
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/extension/service-worker-lifecycle/resident-browser-proxy.ts, line 57:
<comment>When the version fetch rejects, this interpolates the nested fetch message into the error returned by `stagehand.init`. The RPC layer forwards that message unchanged, exposing transport details and making the public error unstable; keep a fixed proxy-unavailable message and retain diagnostics only internally.
(Based on your team's feedback about sanitizing CDP discovery failures.) </comment>
<file context>
@@ -28,11 +47,48 @@ export async function resolveResidentBrowserWebSocketUrl(
+ signal: abortController.signal,
+ });
+ } catch (error) {
+ const underlying = error instanceof Error ? error.message : String(error);
+ throw new ResidentBrowserProxyError(
+ "RESIDENT_PROXY_UNAVAILABLE",
</file context>
| const underlying = error instanceof Error ? error.message : String(error); | |
| throw new ResidentBrowserProxyError( | |
| "RESIDENT_PROXY_UNAVAILABLE", | |
| `RESIDENT_PROXY_UNAVAILABLE: Resident browser proxy at ${proxyUrl.origin} is unavailable; the Browserbase session may not have enabled the Stagehand runtime or the proxy is not listening. Ensure browserSettings.extensions includes "stagehand". (${underlying})`, | |
| { cause: error }, | |
| ); | |
| throw new ResidentBrowserProxyError( | |
| "RESIDENT_PROXY_UNAVAILABLE", | |
| "RESIDENT_PROXY_UNAVAILABLE: Resident browser proxy is unavailable; the Browserbase session may not have enabled the Stagehand runtime or the proxy is not listening. Ensure browserSettings.extensions includes \"stagehand\".", | |
| { cause: error }, | |
| ); |
…ach-aware Port of #2465 (feat/resident-gateway-runtime) onto main, redesigned for main's #2752 SDK reattach semantics. Lifecycle (service-worker-lifecycle/resident-runtime.ts): - A configured worker stays idle until the first URL-less stagehand.init, then resolves the loopback browser proxy, bootstraps the restricted BrowserContext (restrictToWebTargets, deferPageInstrumentation, ensureInitialPage:false), restores initialized instrumentation, waits for the RPC receiver and only then publishes `ready`. Socket loss publishes `reconnecting` and retries within a bounded budget (100/250/500ms). - initialize() no longer rejects an initialized runtime: ready+connected refreshes config via runtime.initialize; reconnecting/failed awaits a bootstrap (which replays init scripts, headers, domain policy, viewport and page CDP event subscriptions) before reattaching; a failed runtime gets a fresh attempt (post-budget recovery). Concurrent init still fails with "already in progress" via a lifecycle-level guard. - initializeWithBrowserCdpUrl on an initialized runtime logs and ignores the URL instead of replacing the session; a legacy (custom CDP) session reattaches with or without browserCdpUrl. - Reconnect generations reset the operation queue so a stalled stale restore cannot starve the next attempt; `closed` is published even if session teardown throws. Runtime (runtime.ts): - Factory/replaceBrowserConnection take an options object { bootstrapLogger?, lifecycle? } instead of a positional bootstrapLogger. - replaceBrowserConnection is generation-guarded ("superseded") and keeps main's page-event-subscription disposal and responseHandles.clear(). - Restore bookkeeping for context/page init scripts, headers, domain policy and viewport; vanished pages are pruned (pageClose, registry refresh, restore). Page CDP event subscriptions are snapshotted across replaces (merged, so a superseded reconnect cannot drop them), honored by page.off while pending, and re-subscribed on restore. Worker wiring: RPCRouter gains closeContext and passes the request logger to initializeStagehand; stagehand.close/context.close tear down through the lifecycle; the worker marker is the lifecycle marker (state, connected, timings, failure). Local/custom CDP sessions pass none of the resident BrowserContext options, so that path is unchanged. SDK: negotiateRuntimeCompatibility reads protocolVersion/serverInfo from a loose envelope so the strict RuntimeDescriptor still validates beside the operational marker fields.
…ions A stagehand.init that arrived during a reconnect window awaited one generation's bootstrap promise. If that attempt stalled (socket dropped mid-restore and a post-close CDP send never settled), the timer-driven next generation reconnected and published `ready`, but the init stayed parked on the stale promise, so `initializationInFlight` was never cleared and every later stagehand.init rejected with "already in progress" forever. - Add a per-generation supersession signal; all generation bumps (bootstrap timer-clear, reconnect timer, disableResidentBootstrap, close) go through bumpOperationGeneration(), which resolves the previous signal. - initializeResident races the current bootstrap against supersession and, when superseded, follows the new generation's bootstrap until it reaches ready, fails, or the lifecycle is closed/disabled (which reject). Only the final runtime.initialize is serialized on the operation queue; the wait itself cannot sit on the tail because a stalled op would block it. - Test: init awaiting a stalled stale reconnect resolves on the new generation and a subsequent init succeeds.
… budget exhaustion
Step C2 on top of the resident gateway runtime (same branch).
Queue-wedge root cause (understudy):
- BrowserWebSocketTransport.send throws when the socket is not OPEN instead
of letting the browser silently drop the frame.
- CdpConnection.send/_sendViaSession reject immediately when the transport is
not connected and turn synchronous transport throws into rejections; no
inflight entry is left behind. A pre-resume dispatch waiter settles with the
outcome of its send (rejects when the command never reached the wire), so
the context's Promise.all over dispatch promises cannot hang.
Lifecycle (service-worker-lifecycle/resident-runtime.ts):
- Every awaited bootstrap step (resolve URL, replaceBrowserConnection,
restore, RPC receiver) is raced against the run's own disconnect signal and
the generation's supersession, so a stale run can never hold the queue.
- bootstrap() always opens a new generation for a fresh run (and every bump
resets the operation tail); hooks of an abandoned run cannot publish into or
schedule reconnects for its replacement. A superseded run rejects rather
than resolving with a non-ready marker.
- stagehand.init rides out the reconnect budget: a failed attempt with a retry
pending makes the init follow the next generation instead of rejecting; only
budget exhaustion/disable/close reject it. Default delays are now
100/250/500/1000/2000ms. A later init on a failed (budget-exhausted)
runtime re-arms a fresh budget.
- Failure phase: retries of the first connection report `connecting`;
anything after the lifecycle held a socket reports `reconnecting`; the
budget-exhausted diagnostic is preserved. marker.failure gains an optional
`code` from the structured proxy error.
Runtime (runtime.ts): restoreInitializedBrowserSession re-checks that the
session it is restoring is still the active one after every await, so a
stale restore cannot instrument the next generation's pages. Page CDP event
subscriptions whose page vanished across a reconnect are dropped with a
warning (no wire-level invalidation event: the page itself is gone).
Proxy discovery (resident-browser-proxy.ts): ResidentBrowserProxyError with
codes RESIDENT_PROXY_UNAVAILABLE (fetch rejected/aborted),
RESIDENT_PROXY_NOT_READY (503 or {"error":"stagehand_not_enabled"|
"browser_unavailable"}), RESIDENT_PROXY_FORBIDDEN (403); other HTTP
failures stay plain errors.
Tests: CDP send-guard/dispatch-waiter/WebSocket-guard tests with a fake
transport that drops mid-operation; lifecycle tests for hang-then-drop
during restore (budget exhausted, no timer to rescue it), first-init retry
within budget, first-init rejection with the last attempt's error and code,
init during the reconnect window following a failed retry, fresh budget
after exhaustion, close during a stalled bootstrap with a pending init,
stale restore isolation, dropped-subscription warning, and the structured
proxy error mapping.
91d03b8 to
1c1d6dd
Compare
Summary
Stack C/5:
-a-transport→-b-target-safety→feat/resident-main-c-gateway-runtime(this PR) →-d-artifacts→-e-browserbase-opt-in. Supersedes #2465; redesigned formain(#2752 reattach, #2588bootstrapLogger, #2581 page CDP subscriptions).stagehand.initwithoutbrowserCdpUrlnow attaches the resident runtime: resolve the 9224 proxy (stack A) → connect →BrowserContext.create({ restrictToWebTargets, deferPageInstrumentation, ensureInitialPage: false })(stack B) → initialize. Local/custombrowserCdpUrldisables resident bootstrap (unchanged behavior).ResidentRuntimeLifecycle:idle → connecting → bootstrapping → ready → reconnecting/failed/closed; generation guards; reconnect delays[100, 250, 500, 1000, 2000]ms consumed by the init-triggered bootstrap before it rejects; recovery on the nextinitafter budget exhaustion; reconnect failures report phasereconnecting.stagehand.initon an initialized runtime refreshes config (ready) or waits for the in-flight restore (reconnecting/failed) instead of rejecting;browserCdpUrlon an already-initialized runtime is ignored with a log.{ bootstrapLogger?, lifecycle? }; generation-guardedreplaceBrowserConnectionkeepsdisposeAllPageEventSubscriptions()andresponseHandles.clear(); page-scoped state and page CDP subscriptions are replayed after a resident reconnect.BrowserWebSocketTransport.sendthrows on non-OPEN sockets andCdpConnectionrejects sends when disconnected (previously silently dropped → permanent lifecycle wedge); per-generation operation queue; a pendinginitfollows reconnect generations.replaceBrowserConnectionnulls the session until the new one is up, so an RPC in that window failed with "loopback CDP is not configured".RPCRouter.routenow awaits an in-flight replacement (10 s budget;stagehand.init/close,stagehand.metrics,context.closeexempt) and raisesSTAGEHAND_BROWSER_SESSION_UNAVAILABLEon timeout. Non-resident flows unaffected.RESIDENT_PROXY_UNAVAILABLE/RESIDENT_PROXY_NOT_READY/RESIDENT_PROXY_FORBIDDEN, keyed on pid2's JSON error bodies.Open product decision (not changed here):
stagehand.close()is runtime-wide, so a reattached client closing takes the browser away from the first client.Validation
pnpm check; extension tests (414, incl. lifecycle tests with controllable session/transport fakes andbrowser-session-wait.test.ts); protocol unit + real-Chrome browser tests; sdk-ts unit — green. Devbox E2E: init ≈ 35–40 ms after session create through 9224, SIGTERM drain survives, reattach 0/36 failures.Summary by cubic
Attaches the resident runtime on
stagehand.initwhenbrowserCdpUrlis absent and the worker is configured, improving resilience and reattach behavior; custom-CDP flows are unchanged. Hardens CDP transport and unwedges init/RPCs across reconnects by following generation replacements.stagehand.initwithoutbrowserCdpUrlresolves the loopback proxy, creates a restrictedBrowserContext, restores instrumentation, and publishesready; a secondstagehand.initrefreshes config or waits duringreconnectinginstead of rejecting. CustombrowserCdpUrldisables resident bootstrap; providing it after initialization is logged and ignored. Public/unconfigured builds do not auto-bootstrap. Pending inits now follow reconnect generations and recover after budget exhaustion on the next init.[100, 250, 500, 1000, 2000]ms; failure phase reportsconnectingorreconnecting; structured proxy errors (RESIDENT_PROXY_UNAVAILABLE|NOT_READY|FORBIDDEN) are surfaced with codes.stagehand.init,stagehand.close,stagehand.metrics, andcontext.close; times out withBrowserSessionUnavailableError(STAGEHAND_BROWSER_SESSION_UNAVAILABLE).BrowserWebSocketTransport.sendthrows on non-open sockets;CdpConnectionrejects sends when disconnected and converts synchronous transport errors into rejections, preventing lifecycle wedges.state,connected, andtimings; the SDK reads a loose envelope while validating the strict runtime descriptor.stagehand.close()remains runtime-wide.Migration
StagehandControlleroptioninitializenow receives(params, logger).RPCRouteroptioncloseContextis available; session-independent methods are enforced.StagehandBrowserSessionFactoryandreplaceBrowserConnectionnow take{ bootstrapLogger?, lifecycle? }.BrowserSessionUnavailableErrorandResidentBrowserProxyErrorcodes.BrowserWebSocketTransport.sendandCdpConnection.sendto throw/reject when not connected.Written for commit 1c1d6dd. Summary will update on new commits.