cascade-ai 0.78.0 → 0.80.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/dist/index.cjs CHANGED
@@ -11669,7 +11669,7 @@ function reconcilePrice(model, live) {
11669
11669
  }
11670
11670
 
11671
11671
  // src/constants.ts
11672
- var CASCADE_VERSION = "0.78.0";
11672
+ var CASCADE_VERSION = "0.80.0";
11673
11673
  var CASCADE_CONFIG_DIR = ".cascade";
11674
11674
  var CASCADE_MD_FILE = "CASCADE.md";
11675
11675
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -16144,6 +16144,31 @@ var ToolsConfigSchema = zod.z.object({
16144
16144
  * stop control for the run that is acting.
16145
16145
  */
16146
16146
  agentBrowserControl: zod.z.boolean().default(false),
16147
+ /**
16148
+ * Where a HOSTED run gets a browser, since it has none of its own.
16149
+ *
16150
+ * The desktop drives the Chromium the user is signed into and can watch. A
16151
+ * server has neither, and running one inside the container is wrong twice
16152
+ * over — page JavaScript issues requests no URL check ever sees, from inside
16153
+ * the trust boundary, and a session costs 200-400 MB. So this points at a
16154
+ * browser somebody else runs.
16155
+ *
16156
+ * Absent by default: no provider, no capability. There is nothing to turn
16157
+ * off because nothing is registered until an endpoint is supplied.
16158
+ */
16159
+ remoteBrowser: zod.z.object({
16160
+ /** `cdp` for an endpoint you run; `steel` for a Steel deployment. */
16161
+ provider: zod.z.enum(["cdp", "steel"]).optional(),
16162
+ /** The websocket endpoint for `cdp`, or the API base for `steel`. */
16163
+ url: zod.z.string().optional(),
16164
+ apiKey: zod.z.string().optional(),
16165
+ /**
16166
+ * Concurrent sessions. One by default and deliberately so: every session
16167
+ * is billed, and a wave of workers each opening their own is a cost the
16168
+ * user did not choose. Raising it is a decision, not a default.
16169
+ */
16170
+ maxSessions: zod.z.number().int().min(1).max(16).default(1)
16171
+ }).optional(),
16147
16172
  mcpServers: zod.z.array(McpServerConfigSchema).optional(),
16148
16173
  mcpTrusted: zod.z.array(zod.z.string()).optional(),
16149
16174
  /** Web search backends — at least one should be configured for best results */
@@ -31412,8 +31437,38 @@ ${input}` }],
31412
31437
  * run — it is simply not there.
31413
31438
  */
31414
31439
  setBrowserController(controller, release) {
31440
+ this.registerBrowserControl(controller, release, this.config.tools?.agentBrowserControl === true);
31441
+ }
31442
+ /**
31443
+ * Wire a REMOTE browser — one the host connects to rather than one the user
31444
+ * is signed into.
31445
+ *
31446
+ * A separate entry point because the enablement is a different question, and
31447
+ * conflating them made the hosted capability dead on arrival: this went out
31448
+ * calling `setBrowserController`, which gates on `agentBrowserControl`, and
31449
+ * that flag means "drive the browser I am signed into on this machine". It
31450
+ * defaults to false and has no meaning on a server, so the hosted tool was
31451
+ * never registered at all — the feature looked shipped and did nothing.
31452
+ *
31453
+ * Configuring a provider IS the opt-in here. It takes a deliberate act with
31454
+ * an endpoint and usually a paid account, and the browser it reaches is a
31455
+ * throwaway signed into nothing — a smaller consent than handing over the
31456
+ * session the user is already authenticated in, and one the desktop flag
31457
+ * cannot stand in for.
31458
+ */
31459
+ setRemoteBrowserController(controller, release) {
31460
+ this.registerBrowserControl(controller, release, Boolean(this.config.tools?.remoteBrowser?.provider));
31461
+ }
31462
+ /**
31463
+ * The gates every surface shares, with only enablement differing.
31464
+ *
31465
+ * One place, because the two callers above are exactly the sibling-writer
31466
+ * shape that has already drifted twice in this feature: a rule added to one
31467
+ * and not the other is silent on the surface that missed it.
31468
+ */
31469
+ registerBrowserControl(controller, release, enabled) {
31415
31470
  if (this.unattended) return;
31416
- if (!this.config.tools?.agentBrowserControl) return;
31471
+ if (!enabled) return;
31417
31472
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31418
31473
  this.toolRegistry.register(new BrowserControlTool(controller, release));
31419
31474
  }
@@ -32758,6 +32813,20 @@ function applySettingsPayload(config, data) {
32758
32813
  if (data.webSearch.tavilyApiKey) next.tavilyApiKey = data.webSearch.tavilyApiKey;
32759
32814
  config.tools.webSearch = next;
32760
32815
  }
32816
+ if (data.remoteBrowser && typeof data.remoteBrowser === "object") {
32817
+ const prior = config.tools.remoteBrowser ?? {};
32818
+ const next = { ...prior };
32819
+ if (data.remoteBrowser.provider === "cdp" || data.remoteBrowser.provider === "steel") {
32820
+ next.provider = data.remoteBrowser.provider;
32821
+ }
32822
+ if (typeof data.remoteBrowser.url === "string") next.url = data.remoteBrowser.url.trim() || void 0;
32823
+ if (data.remoteBrowser.apiKey) next.apiKey = data.remoteBrowser.apiKey;
32824
+ const n = data.remoteBrowser.maxSessions;
32825
+ if (typeof n === "number" && Number.isFinite(n)) {
32826
+ next.maxSessions = Math.min(Math.max(Math.floor(n), 1), 16);
32827
+ }
32828
+ config.tools.remoteBrowser = next;
32829
+ }
32761
32830
  if (data.advanced && typeof data.advanced === "object") {
32762
32831
  const a = data.advanced;
32763
32832
  const num = (v, min, max) => {
@@ -32804,6 +32873,7 @@ function settingsSnapshot(config) {
32804
32873
  if (p?.type && p?.baseUrl && p.type !== "azure") endpoints[p.type] = p.baseUrl;
32805
32874
  }
32806
32875
  const ws = config.tools?.webSearch ?? {};
32876
+ const rb = config.tools?.remoteBrowser ?? {};
32807
32877
  return {
32808
32878
  models: config.models ?? {},
32809
32879
  budget: {
@@ -32833,6 +32903,15 @@ function settingsSnapshot(config) {
32833
32903
  hasBraveKey: typeof ws.braveApiKey === "string" && ws.braveApiKey.length > 0,
32834
32904
  hasTavilyKey: typeof ws.tavilyApiKey === "string" && ws.tavilyApiKey.length > 0
32835
32905
  },
32906
+ // `hasApiKey`, never the key. The panel needs to show whether one is set;
32907
+ // sending the value back would put a live credential in every settings
32908
+ // response, where it reaches any client that can read them.
32909
+ remoteBrowser: {
32910
+ provider: rb.provider,
32911
+ url: rb.url,
32912
+ hasApiKey: typeof rb.apiKey === "string" && rb.apiKey.length > 0,
32913
+ maxSessions: rb.maxSessions
32914
+ },
32836
32915
  advanced: {
32837
32916
  autonomy: config.autonomy,
32838
32917
  planApproval: config.planApproval,
@@ -34943,6 +35022,1872 @@ async function discoverMcpTools(servers) {
34943
35022
  return results;
34944
35023
  }
34945
35024
 
35025
+ // src/browser/lease.ts
35026
+ var HUMAN_ACTOR = "__human__";
35027
+ var HUMAN_IDLE_MS = 12e4;
35028
+ var LEASE_DEADLOCK_CEILING_MS = 3e5;
35029
+ var QUEUE_WAIT_MS = 18e4;
35030
+ var MAX_QUEUED = 8;
35031
+ var ACTION_HANDOFF_MS = 2e3;
35032
+ var HANDBACK_RETRY_MS = 25;
35033
+ var BrowserLease = class {
35034
+ actor = null;
35035
+ session = null;
35036
+ ceilingTimer = null;
35037
+ waiting = [];
35038
+ lifecycleWired = false;
35039
+ /**
35040
+ * The action holding the browser, if any.
35041
+ *
35042
+ * Needed alongside the lease for a reason the lease cannot cover: the lease
35043
+ * is RE-ENTRANT for its holder — that is what makes a sequence a sequence —
35044
+ * so nothing in it stops ONE actor running two actions at once. A retry
35045
+ * racing the call it retried is exactly that shape.
35046
+ *
35047
+ * A symbol because it identifies the individual call: only the action that
35048
+ * took the slot may release it.
35049
+ */
35050
+ action = null;
35051
+ /** Whether the current action slot represents activity by its holder. */
35052
+ actionCountsAsHumanActivity = false;
35053
+ /**
35054
+ * The human action that has already consumed its one idle-deadline grace.
35055
+ * A single wedged CDP request must not look like fresh activity every two
35056
+ * minutes forever. The first deadline may yield to an event that began before
35057
+ * it; a second deadline for that SAME event becomes a pending lapse instead.
35058
+ */
35059
+ idleGraceAction = null;
35060
+ /** Release the human hold as soon as this overlong action finally unwinds. */
35061
+ idleLapseAfterAction = null;
35062
+ /**
35063
+ * Everyone waiting for that slot, IN THE ORDER THEY ASKED.
35064
+ *
35065
+ * A queue rather than a poll, and the ordering is the whole reason for it.
35066
+ * `acquireActionSlot` used to spin on a 25ms timer, which decides nothing
35067
+ * about who goes first: each waiter checks on its own phase, so the one that
35068
+ * asked SECOND can find the slot free a full poll ahead of the one that asked
35069
+ * first. The person types one event per keypress — `{ kind: 'text', text }`
35070
+ * carries a single character — so that reordering is not a theoretical
35071
+ * unfairness, it is "passowrd" appearing in the box.
35072
+ *
35073
+ * The slot is handed straight from `endAction` to the head of this line while
35074
+ * still synchronous, so nothing can slip in between the two, and a waiter
35075
+ * that reaches the front never has to be told the slot is already taken.
35076
+ */
35077
+ actionQueue = [];
35078
+ /** Lapses a human hold nobody is using. See `HUMAN_IDLE_MS`. */
35079
+ humanTimer = null;
35080
+ /**
35081
+ * The person ASKED for the hold to end and it could not end yet.
35082
+ *
35083
+ * Kept as a request rather than only a retry timer, because the retry shares
35084
+ * `humanTimer` with the idle deadline: an authorised event settling after the
35085
+ * request would otherwise call `touch()`, re-arm that timer for a full idle
35086
+ * interval, and quietly cancel a handback the person had already asked for.
35087
+ * "Give it back" is not undone by activity that was already under way when
35088
+ * they said it.
35089
+ *
35090
+ * Explicit requests ONLY. An idle expiry must never set this: a deadline that
35091
+ * lands on the person's own in-flight event is evidence they were THERE, and
35092
+ * recording it here made `touch()` bail afterwards, so the hold dropped the
35093
+ * instant a fresh interaction finished. See `lapse()`.
35094
+ */
35095
+ handBackWanted = false;
35096
+ isRevoked;
35097
+ onChange;
35098
+ constructor(options = {}) {
35099
+ this.isRevoked = options.isRevoked ?? (() => false);
35100
+ this.onChange = options.onChange ?? (() => {
35101
+ });
35102
+ }
35103
+ get holderActor() {
35104
+ return this.actor;
35105
+ }
35106
+ /** Whether a person, rather than the agent, is driving the page right now. */
35107
+ get heldByHuman() {
35108
+ return this.actor === HUMAN_ACTOR;
35109
+ }
35110
+ /**
35111
+ * Whether THIS actor is the one holding the browser right now.
35112
+ *
35113
+ * For asking again after a wait. `acquire` is re-entrant for the holder, so
35114
+ * "I was granted the lease" is a fact about the moment it was granted and
35115
+ * says nothing about the moment the caller finally gets to act.
35116
+ */
35117
+ heldBy(actorId) {
35118
+ return this.actor === actorId;
35119
+ }
35120
+ get holderSession() {
35121
+ return this.session;
35122
+ }
35123
+ get queueDepth() {
35124
+ return this.waiting.length;
35125
+ }
35126
+ get actionInFlight() {
35127
+ return this.action !== null;
35128
+ }
35129
+ /**
35130
+ * Declare that this host reports worker terminal states.
35131
+ *
35132
+ * When it does, timers stop being ownership boundaries entirely. When it does
35133
+ * not — an embedder that wired a controller but not the release — the
35134
+ * fallbacks stay, because something has to break a deadlock.
35135
+ */
35136
+ setLifecycleReleaseWired(on) {
35137
+ this.lifecycleWired = on;
35138
+ }
35139
+ /**
35140
+ * Take the browser for this actor, waiting in line if someone else holds it.
35141
+ *
35142
+ * Re-entrant for the actor that already holds it: that is the sequence case —
35143
+ * the same worker coming back for its next step must not queue behind itself.
35144
+ */
35145
+ async acquire(actorId, sessionId, signal) {
35146
+ if (this.actor === HUMAN_ACTOR && actorId !== HUMAN_ACTOR) return "human";
35147
+ if (this.actor === null || this.actor === actorId) {
35148
+ this.actor = actorId;
35149
+ this.session = sessionId;
35150
+ this.armCeiling();
35151
+ return "granted";
35152
+ }
35153
+ if (this.waiting.length >= MAX_QUEUED) return "busy";
35154
+ return this.enqueue(actorId, sessionId, signal);
35155
+ }
35156
+ /**
35157
+ * One worker has finished its whole sequence, so its hold ends.
35158
+ *
35159
+ * THIS is the semantic release. It comes from the worker's own terminal path,
35160
+ * so it means "this actor will never ask again" — which a timer can never
35161
+ * mean about a worker that is merely thinking.
35162
+ *
35163
+ * Idempotent, and safe for an actor that never touched the browser.
35164
+ */
35165
+ actorEnded(actorId) {
35166
+ if (!actorId) return;
35167
+ for (let i = this.waiting.length - 1; i >= 0; i--) {
35168
+ const w = this.waiting[i];
35169
+ if (w.actorId === actorId) w.settle("cancelled");
35170
+ }
35171
+ this.releaseIfHeldBy(actorId);
35172
+ this.onChange();
35173
+ }
35174
+ /**
35175
+ * Give the browser up when about to return without using it.
35176
+ *
35177
+ * Two guards, both load-bearing. Never while an action is in flight, because
35178
+ * the lease is RE-ENTRANT: the action still running may be this same actor's
35179
+ * previous step, and taking the lease off it hands the page to a queued
35180
+ * worker mid-sequence. And only while still the holder, because by the time a
35181
+ * refusal path runs a Stop may already have handed the lease on.
35182
+ */
35183
+ releaseIfHeldBy(actorId) {
35184
+ if (this.action) return;
35185
+ if (this.actor === actorId) this.release();
35186
+ }
35187
+ /**
35188
+ * Give the browser to the person watching it.
35189
+ *
35190
+ * Callers must have waited the in-flight action out first — see
35191
+ * `acquireActionSlot`. This does not interrupt one, because it cannot: a
35192
+ * Playwright call already inside the page finishes whatever it started, and
35193
+ * taking the lease off it would only mean the person and a still-running
35194
+ * `fill` were both touching the same form. Waiting is what makes "you have
35195
+ * control" true when the UI says it.
35196
+ *
35197
+ * Every queued worker is turned away rather than left in line, for the same
35198
+ * reason `acquire` refuses: they were planned against a page the person is
35199
+ * about to change.
35200
+ */
35201
+ takeOver(sessionId) {
35202
+ for (let i = this.waiting.length - 1; i >= 0; i--) this.waiting[i].settle("human");
35203
+ this.clearCeiling();
35204
+ this.actor = HUMAN_ACTOR;
35205
+ this.session = sessionId;
35206
+ this.touch();
35207
+ this.onChange();
35208
+ }
35209
+ /**
35210
+ * The person is still there — anything they did counts.
35211
+ *
35212
+ * Called per authorised input rather than on a heartbeat, so the hold is
35213
+ * extended by USE and not merely by a tab being open. A left-open panel on a
35214
+ * forgotten laptop stops holding a billed session two minutes later.
35215
+ */
35216
+ touch() {
35217
+ if (this.actor !== HUMAN_ACTOR) return;
35218
+ if (this.handBackWanted) return;
35219
+ this.armIdle();
35220
+ }
35221
+ /** (Re)start the idle interval. The only place the deadline is set. */
35222
+ armIdle() {
35223
+ this.clearHumanIdle();
35224
+ this.humanTimer = setTimeout(() => {
35225
+ this.lapse();
35226
+ }, HUMAN_IDLE_MS);
35227
+ this.humanTimer.unref?.();
35228
+ }
35229
+ /**
35230
+ * The idle deadline came round.
35231
+ *
35232
+ * Both ways a hold ends obey the same rule — never while one of the person's
35233
+ * own events is still landing — but they resolve that clash in OPPOSITE
35234
+ * directions, and routing the lapse through `handBack()` collapsed them into
35235
+ * one. An event in flight BEGAN before this deadline, which makes it evidence
35236
+ * the person was there; deferring the lapse and then releasing the moment
35237
+ * that event lands ends the hold immediately after a fresh interaction, which
35238
+ * is the precise opposite of what an idle deadline measures. So an
35239
+ * interrupted lapse yields: the interval starts again, and the next silence
35240
+ * decides.
35241
+ *
35242
+ * `handBack()` keeps the deferral instead, because "Give it back" is a
35243
+ * decision and activity already under way when it was made does not withdraw
35244
+ * it. Same rule, different answer, which is why they are different methods.
35245
+ */
35246
+ lapse() {
35247
+ if (this.actor !== HUMAN_ACTOR) return;
35248
+ if (this.action && this.actionCountsAsHumanActivity) {
35249
+ if (this.idleGraceAction !== this.action) {
35250
+ this.idleGraceAction = this.action;
35251
+ this.armIdle();
35252
+ } else {
35253
+ this.idleLapseAfterAction = this.action;
35254
+ this.clearHumanIdle();
35255
+ }
35256
+ return;
35257
+ }
35258
+ this.release();
35259
+ }
35260
+ /**
35261
+ * The person hands the browser back, or their hold lapses.
35262
+ *
35263
+ * Answers whether there was a human hold to end, so a caller can tell a real
35264
+ * handback from a duplicate click or a lapse that already happened.
35265
+ */
35266
+ handBack() {
35267
+ if (this.actor !== HUMAN_ACTOR) return false;
35268
+ if (this.action) {
35269
+ this.handBackWanted = true;
35270
+ this.clearHumanIdle();
35271
+ this.humanTimer = setTimeout(() => {
35272
+ this.handBack();
35273
+ }, HANDBACK_RETRY_MS);
35274
+ this.humanTimer.unref?.();
35275
+ return false;
35276
+ }
35277
+ this.release();
35278
+ return true;
35279
+ }
35280
+ clearHumanIdle() {
35281
+ if (this.humanTimer) {
35282
+ clearTimeout(this.humanTimer);
35283
+ this.humanTimer = null;
35284
+ }
35285
+ }
35286
+ /** Drop every queued waiter belonging to a run, and the lease if it holds it. */
35287
+ dropRun(sessionId) {
35288
+ for (let i = this.waiting.length - 1; i >= 0; i--) {
35289
+ const w = this.waiting[i];
35290
+ if (w.sessionId === sessionId) w.settle("cancelled");
35291
+ }
35292
+ if (this.session === sessionId) this.release();
35293
+ }
35294
+ /** Turn everyone away — the capability itself has gone. */
35295
+ dropAll() {
35296
+ for (let i = this.waiting.length - 1; i >= 0; i--) this.waiting[i].settle("off");
35297
+ }
35298
+ /** Hand the browser to the next actor in line, if any. */
35299
+ release() {
35300
+ this.clearCeiling();
35301
+ this.clearHumanIdle();
35302
+ this.handBackWanted = false;
35303
+ this.idleGraceAction = null;
35304
+ this.idleLapseAfterAction = null;
35305
+ this.actor = null;
35306
+ this.session = null;
35307
+ let next = this.waiting.shift();
35308
+ while (next && this.isRevoked(next.sessionId)) {
35309
+ next.settle("cancelled");
35310
+ next = this.waiting.shift();
35311
+ }
35312
+ if (next) {
35313
+ this.actor = next.actorId;
35314
+ this.session = next.sessionId;
35315
+ this.armCeiling();
35316
+ next.settle("granted");
35317
+ }
35318
+ this.onChange();
35319
+ }
35320
+ /** Arm the deadlock ceiling. Not a release schedule — see the constant. */
35321
+ armCeiling() {
35322
+ this.clearCeiling();
35323
+ if (this.lifecycleWired) return;
35324
+ this.ceilingTimer = setTimeout(() => this.release(), LEASE_DEADLOCK_CEILING_MS);
35325
+ this.ceilingTimer.unref?.();
35326
+ }
35327
+ clearCeiling() {
35328
+ if (this.ceilingTimer) {
35329
+ clearTimeout(this.ceilingTimer);
35330
+ this.ceilingTimer = null;
35331
+ }
35332
+ }
35333
+ /**
35334
+ * Take the per-action slot, or null when one is already running.
35335
+ *
35336
+ * PRIVATE, so the queue is the only way in. A caller that could take the slot
35337
+ * without joining the line would be exactly the jump-the-queue the ordering
35338
+ * is there to prevent.
35339
+ */
35340
+ beginAction(countsAsHumanActivity = true) {
35341
+ if (this.action) return null;
35342
+ const token = /* @__PURE__ */ Symbol("browser-action");
35343
+ this.action = token;
35344
+ this.actionCountsAsHumanActivity = countsAsHumanActivity;
35345
+ return token;
35346
+ }
35347
+ /**
35348
+ * Release the slot, but only for the action that took it.
35349
+ *
35350
+ * Hands it to the head of the line rather than merely clearing it, and does
35351
+ * so SYNCHRONOUSLY: the waiter's `beginAction` runs before this call returns,
35352
+ * so there is no window in which the slot is free and somebody who asked
35353
+ * later can take it.
35354
+ */
35355
+ endAction(token) {
35356
+ if (this.action !== token) return false;
35357
+ const lapseNow = this.actor === HUMAN_ACTOR && this.idleLapseAfterAction === token;
35358
+ this.action = null;
35359
+ this.actionCountsAsHumanActivity = false;
35360
+ if (this.idleGraceAction === token) this.idleGraceAction = null;
35361
+ if (this.idleLapseAfterAction === token) this.idleLapseAfterAction = null;
35362
+ if (lapseNow) this.release();
35363
+ this.actionQueue.shift()?.();
35364
+ return true;
35365
+ }
35366
+ /**
35367
+ * Take the slot only if it is free RIGHT NOW, never joining the line.
35368
+ *
35369
+ * For input that is a SAMPLE rather than a decision — pointer movement. A
35370
+ * click or a keystroke is a thing the person meant to do and has to happen,
35371
+ * so it waits its turn; a pointer position is one of many on the way
35372
+ * somewhere, and one that cannot be applied now is worth less than the one
35373
+ * behind it. Queueing them is actively harmful: on an endpoint slower than
35374
+ * the client's sampling interval the backlog grows, and the click behind it
35375
+ * can age out against `acquireActionSlot`'s bound and be refused — movement
35376
+ * costing the person the action they actually intended.
35377
+ *
35378
+ * The queue length is checked as well as the slot, so a sample cannot step
35379
+ * over a click that is already waiting.
35380
+ */
35381
+ tryActionSlot() {
35382
+ if (this.action || this.actionQueue.length > 0) return null;
35383
+ return this.beginAction();
35384
+ }
35385
+ /**
35386
+ * Take the per-action slot, waiting in line for it if somebody has it.
35387
+ *
35388
+ * A release can hand the browser on while the previous holder's abort is
35389
+ * still propagating through an awaited navigation. That is a handoff, not a
35390
+ * conflict, so it is normally bounded: a wedged action must not pin an agent
35391
+ * or a takeover forever. Human discrete input is the exception and passes
35392
+ * `null`: once a click, key or committed text has been accepted into this
35393
+ * ordered queue, expiring it merely because earlier accepted input took more
35394
+ * than two seconds silently truncates what the person typed.
35395
+ *
35396
+ * Waiting and TAKING are one step, which they deliberately were not before:
35397
+ * the wait used to answer "the slot looks free" and leave the caller to call
35398
+ * `beginAction` afterwards. Two callers woken by the same release both saw it
35399
+ * free, and the loser was told "another action is already running" — a
35400
+ * refusal for having asked first. Granting it here means whoever reaches the
35401
+ * front of the line gets it, and everybody else is still waiting rather than
35402
+ * refused.
35403
+ *
35404
+ * `null` as a RESULT means a bounded wait ran out OR a caller-supplied queue
35405
+ * cap refused a new waiter. `null` as `withinMs` means an ACCEPTED operation
35406
+ * is intentionally unbounded by time and will leave the queue only when its
35407
+ * turn arrives; it does not require accepting infinitely many operations.
35408
+ * `countsAsHumanActivity=false` is for
35409
+ * lifecycle barriers: they still serialize, but an idle deadline must not
35410
+ * mistake framework cleanup for evidence that the person is present.
35411
+ */
35412
+ async acquireActionSlot(withinMs = ACTION_HANDOFF_MS, countsAsHumanActivity = true, maxQueued = null) {
35413
+ if (!this.action && this.actionQueue.length === 0) {
35414
+ return this.beginAction(countsAsHumanActivity);
35415
+ }
35416
+ if (maxQueued !== null && this.actionQueue.length >= maxQueued) return null;
35417
+ return await new Promise((resolve2) => {
35418
+ let done = false;
35419
+ let timer;
35420
+ const wake = () => {
35421
+ if (done) return;
35422
+ done = true;
35423
+ if (timer) clearTimeout(timer);
35424
+ resolve2(this.beginAction(countsAsHumanActivity));
35425
+ };
35426
+ if (withinMs !== null) {
35427
+ timer = setTimeout(() => {
35428
+ if (done) return;
35429
+ done = true;
35430
+ const i = this.actionQueue.indexOf(wake);
35431
+ if (i >= 0) this.actionQueue.splice(i, 1);
35432
+ resolve2(null);
35433
+ }, withinMs);
35434
+ timer.unref?.();
35435
+ }
35436
+ this.actionQueue.push(wake);
35437
+ });
35438
+ }
35439
+ /**
35440
+ * Queue one actor, resolving when it gets the browser or is turned away.
35441
+ *
35442
+ * `settle` is wrapped so it runs at most once and always takes the waiter out
35443
+ * of the line: the timeout, an abort and a handoff all race, and settling
35444
+ * twice would silently leave a dead entry holding a queue slot.
35445
+ */
35446
+ enqueue(actorId, sessionId, signal) {
35447
+ return new Promise((resolve2) => {
35448
+ let done = false;
35449
+ const waiter = {
35450
+ actorId,
35451
+ sessionId,
35452
+ settle: (result) => {
35453
+ if (done) return;
35454
+ done = true;
35455
+ clearTimeout(timer);
35456
+ signal?.removeEventListener("abort", giveUp);
35457
+ const i = this.waiting.indexOf(waiter);
35458
+ if (i >= 0) this.waiting.splice(i, 1);
35459
+ resolve2(result);
35460
+ }
35461
+ };
35462
+ const timer = this.lifecycleWired ? void 0 : setTimeout(() => waiter.settle("busy"), QUEUE_WAIT_MS);
35463
+ timer?.unref?.();
35464
+ const giveUp = () => waiter.settle("cancelled");
35465
+ signal?.addEventListener("abort", giveUp, { once: true });
35466
+ this.waiting.push(waiter);
35467
+ });
35468
+ }
35469
+ };
35470
+
35471
+ // src/browser/remote/controller.ts
35472
+ var KEYS = {
35473
+ Enter: { code: 13, text: "\r" },
35474
+ Tab: { code: 9 },
35475
+ Backspace: { code: 8 },
35476
+ Delete: { code: 46 },
35477
+ Escape: { code: 27 },
35478
+ ArrowUp: { code: 38 },
35479
+ ArrowDown: { code: 40 },
35480
+ ArrowLeft: { code: 37 },
35481
+ ArrowRight: { code: 39 },
35482
+ Home: { code: 36 },
35483
+ End: { code: 35 },
35484
+ PageUp: { code: 33 },
35485
+ PageDown: { code: 34 }
35486
+ };
35487
+ var BUTTONS = { left: 1, middle: 4, right: 2 };
35488
+ var SCREENCAST = { format: "jpeg", quality: 60, maxWidth: 1280, maxHeight: 800 };
35489
+ var ACTION_TIMEOUT_MS = 3e4;
35490
+ var RESTORE_FRAME_MS = 2e3;
35491
+ var MAX_HUMAN_INPUT_QUEUE = 64;
35492
+ var MAX_CAPTURE_TRANSITION_QUEUE = 16;
35493
+ var MAX_WATCH_LIFECYCLE_QUEUE = 16;
35494
+ var RemoteBrowserController = class {
35495
+ provider;
35496
+ maxSessions;
35497
+ /**
35498
+ * Live-view listeners, one per run.
35499
+ *
35500
+ * A single callback was fine while each run had its own controller. Now the
35501
+ * controller is shared across a deployment, so one callback would send run
35502
+ * A's live-view URL to whichever run registered last — and that URL is a
35503
+ * bearer capability for a browser somebody else is driving.
35504
+ */
35505
+ liveViewListeners = /* @__PURE__ */ new Map();
35506
+ /**
35507
+ * Told when a run's browser changes hands, or stops being pictured.
35508
+ *
35509
+ * Needed because a takeover can end without anybody asking. The hold lapses
35510
+ * on its own after `HUMAN_IDLE_MS`, and a client left believing it still has
35511
+ * control would keep sending input into a lease it no longer holds — every
35512
+ * event refused, with nothing on screen saying why.
35513
+ */
35514
+ controlListeners = /* @__PURE__ */ new Map();
35515
+ /** What each run's listener was last told, so unchanged states stay quiet. */
35516
+ controlAnnounced = /* @__PURE__ */ new Map();
35517
+ /** An embedder that wants every run's live view, told which run each is. */
35518
+ onLiveViewAll;
35519
+ runs = /* @__PURE__ */ new Map();
35520
+ /**
35521
+ * Runs that have a pool slot but no browser yet.
35522
+ *
35523
+ * Counted alongside `runs` against the cap. Without it the limit was a
35524
+ * check-then-act across four awaits, and simultaneous first uses both won.
35525
+ */
35526
+ opening = /* @__PURE__ */ new Map();
35527
+ /**
35528
+ * A run's stop signal, created when its slot is reserved.
35529
+ *
35530
+ * Before this, Stop looked only in `runs` — so during the first `open()`,
35531
+ * while the run existed only in `opening` and four awaits were in flight, it
35532
+ * found nothing and returned. The open then completed, built a FRESH
35533
+ * unaborted controller, and the click the user had stopped went ahead. The
35534
+ * signal has to exist before the browser does.
35535
+ */
35536
+ aborts = /* @__PURE__ */ new Map();
35537
+ /**
35538
+ * Runs whose provider session is being released right now.
35539
+ *
35540
+ * `stopRun`, `endRun`, `releaseIfIdle`, and the stale-page branch of `open`
35541
+ * all delete a run from `runs` synchronously and only THEN await
35542
+ * `disposeRun` settling — the browser disconnect, and `provider.endSession`,
35543
+ * which for Steel is a real HTTP `/release` call. Between that delete and
35544
+ * the call actually finishing, the admission check in `open()` — which
35545
+ * counts only `runs` and `opening` — saw the slot as free, so a second run
35546
+ * could pass admission and call `createSession` before the first run's
35547
+ * session was truly released. At `maxSessions: 1` that is two live, BILLED
35548
+ * provider sessions at once, exactly what the pool exists to prevent.
35549
+ * Counting this set alongside the other two closes the window.
35550
+ *
35551
+ * Keyed by run id, not a counter: every one of those four call sites checks
35552
+ * the run is still IN `runs` before deleting it and starting teardown, and
35553
+ * that delete is synchronous, so at most one teardown can ever be in flight
35554
+ * for a given run id at a time — a second caller finds the entry already
35555
+ * gone and never starts a competing one. The entry is removed only once its
35556
+ * own teardown settles, so a later, genuine re-open of the same run id
35557
+ * never collides with one still in flight.
35558
+ */
35559
+ closing = /* @__PURE__ */ new Map();
35560
+ /** Runs the user has stopped, by run id — same meaning as on the desktop. */
35561
+ revoked = /* @__PURE__ */ new Set();
35562
+ /**
35563
+ * One lease PER RUN, because there is one browser per run.
35564
+ *
35565
+ * The desktop has a single lease because it has a single browser. Here each
35566
+ * run gets its own session, so a global lease would make run B queue behind
35567
+ * run A for a browser it was never going to use — and with no queue timeout,
35568
+ * queue for as long as A lasts. What limits runs against each other is the
35569
+ * session POOL, not the lease.
35570
+ */
35571
+ leases = /* @__PURE__ */ new Map();
35572
+ constructor(options) {
35573
+ this.provider = options.provider;
35574
+ const asked = Math.max(1, options.maxSessions ?? 1);
35575
+ this.maxSessions = options.provider.isolatesSessions ? asked : 1;
35576
+ this.onLiveViewAll = options.onLiveView;
35577
+ this.ready = options.ready;
35578
+ }
35579
+ /**
35580
+ * The predecessor's teardown, until it has been waited for once.
35581
+ *
35582
+ * Cleared as soon as it settles so the wait is not re-entered on every open
35583
+ * — and so the promise itself is not retained for the life of the process.
35584
+ */
35585
+ ready;
35586
+ /**
35587
+ * This controller has been retired and will never open anything again.
35588
+ *
35589
+ * Stronger than "its runs were ended", and it has to be. A caller parked on
35590
+ * the `ready` gate above is invisible to `dispose()` — it holds no run and
35591
+ * has reserved no slot, because it has not got that far — so a SECOND
35592
+ * settings change, arriving while the first retirement is still settling,
35593
+ * found nothing to wait for and let the newest controller start immediately.
35594
+ * The parked caller then woke up on a controller nobody holds a reference to
35595
+ * and allocated a session against the superseded configuration, with the
35596
+ * newest controller free to allocate its own.
35597
+ *
35598
+ * Set synchronously at the top of `dispose`, so anything already waiting is
35599
+ * excluded the moment retirement begins rather than when it finishes.
35600
+ */
35601
+ retired = false;
35602
+ leaseFor(runId) {
35603
+ let lease = this.leases.get(runId);
35604
+ if (!lease) {
35605
+ lease = new BrowserLease({
35606
+ isRevoked: (id) => this.revoked.has(id),
35607
+ onChange: () => this.announceControl(runId)
35608
+ });
35609
+ lease.setLifecycleReleaseWired(true);
35610
+ this.leases.set(runId, lease);
35611
+ }
35612
+ return lease;
35613
+ }
35614
+ /** The controller to hand to `Cascade.setBrowserController`. */
35615
+ get controller() {
35616
+ return (action, context) => this.act(action, context);
35617
+ }
35618
+ /** Watch one run's browser. The URL never goes to any other run's listener. */
35619
+ onLiveViewFor(runKey, listener) {
35620
+ this.liveViewListeners.set(runKey, listener);
35621
+ }
35622
+ offLiveViewFor(runKey) {
35623
+ this.liveViewListeners.delete(runKey);
35624
+ }
35625
+ onControlFor(runKey, listener) {
35626
+ this.controlListeners.set(runKey, listener);
35627
+ }
35628
+ offControlFor(runKey) {
35629
+ this.controlListeners.delete(runKey);
35630
+ this.controlAnnounced.delete(runKey);
35631
+ }
35632
+ /**
35633
+ * Say who has the browser, but only when the answer has changed.
35634
+ *
35635
+ * The lease reports every ownership change, and during ordinary work that is
35636
+ * one per action as workers take it and give it back. None of those are
35637
+ * interesting to a viewer — the agent had it before and has it now — and
35638
+ * emitting them would put a socket message on every click the agent makes.
35639
+ */
35640
+ announceControl(runId, force = false) {
35641
+ const listener = this.controlListeners.get(runId);
35642
+ if (!listener) return;
35643
+ const held = this.runs.get(runId);
35644
+ const state = {
35645
+ human: this.leases.get(runId)?.heldByHuman === true,
35646
+ capturing: held?.capturing === true,
35647
+ // A receipt vouches for a VIEW, so it stops meaning anything the moment
35648
+ // there is no view. `stopWatching` clears the session without bumping the
35649
+ // watch — deliberately, since replacing the consumer is what a new watch
35650
+ // is about — so the receipt stayed matched across the whole detach and
35651
+ // re-attach, which `beginWatching` performs on every reconnect and every
35652
+ // return to a conversation. Announced `confirmed` through that window,
35653
+ // the client mounted and FOCUSED the blind keyboard surface over a page
35654
+ // no session was serving: keystrokes meant for the composer went to a
35655
+ // remote page that could only refuse them.
35656
+ confirmed: held?.screencast !== void 0 && held.watchGen !== void 0 && held.seenGen === held.watchGen
35657
+ };
35658
+ const key = `${state.human}:${state.capturing}:${state.confirmed}`;
35659
+ if (!force && this.controlAnnounced.get(runId) === key) return;
35660
+ this.controlAnnounced.set(runId, key);
35661
+ listener(state);
35662
+ }
35663
+ /**
35664
+ * Tell a run's listener what its browser situation is.
35665
+ *
35666
+ * `active` is separate from the URL and both are needed: a provider with no
35667
+ * live view is attached-but-unwatchable, and a finished run is not attached
35668
+ * at all. Both have no URL, so a listener given only the URL cannot tell them
35669
+ * apart — and would drop the Stop control for the first as if it were the
35670
+ * second.
35671
+ */
35672
+ announceLiveView(runId, liveViewUrl, active) {
35673
+ this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
35674
+ this.onLiveViewAll?.(runId, liveViewUrl);
35675
+ }
35676
+ /**
35677
+ * Hand back a session the run turned out not to want.
35678
+ *
35679
+ * A browser opened for an action that is then refused would otherwise stay
35680
+ * allocated and billed until the whole run ended, for a run that has just
35681
+ * been stopped.
35682
+ */
35683
+ async releaseIfIdle(runId, releaseSlot) {
35684
+ const held = this.runs.get(runId);
35685
+ if (!held) {
35686
+ releaseSlot?.();
35687
+ return;
35688
+ }
35689
+ this.runs.delete(runId);
35690
+ const teardown = this.teardown(runId, held);
35691
+ releaseSlot?.();
35692
+ await teardown;
35693
+ }
35694
+ /** Release every run's session. For when the deployment's config changes. */
35695
+ async dispose() {
35696
+ this.retired = true;
35697
+ const opening = [...this.opening.values()];
35698
+ const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening.keys()]);
35699
+ await Promise.all([...ids].map((runId) => this.endRun(runId)));
35700
+ await Promise.all(opening.map((open) => open.catch(() => {
35701
+ })));
35702
+ await Promise.all([...this.closing.values()].map((done) => done.catch(() => {
35703
+ })));
35704
+ }
35705
+ /** A worker finished; it will never ask for the browser again. */
35706
+ actorEnded(actorId) {
35707
+ for (const lease of this.leases.values()) lease.actorEnded(actorId);
35708
+ }
35709
+ /**
35710
+ * Start streaming this run's page to whoever is watching it.
35711
+ *
35712
+ * Nothing streams until someone asks. That is the whole reason this is a
35713
+ * method rather than something `open()` does: an unwatched run would
35714
+ * otherwise pay to encode frames nobody sees, on every run, forever — and on
35715
+ * a metered provider it would pay for the bandwidth too.
35716
+ *
35717
+ * Frames are ACKED after they are handed on, never before. Chrome sends the
35718
+ * next frame only once the previous is acknowledged, so this bounds it to one
35719
+ * outstanding frame against OUR OWN handling. It does not reach further than
35720
+ * that, and an earlier version of this comment claimed it did: `onFrame` ends
35721
+ * at a socket emit, which hands the JPEG to a transport rather than to a
35722
+ * viewer, so the ack says nothing about what the person actually received.
35723
+ * Bounding the rest of the path is the transport's job — see `emitFrame`,
35724
+ * where frames go out lossy so a slow connection drops pictures instead of
35725
+ * banking stale ones. Dropping the ack stops the stream dead, which is why it
35726
+ * is best-effort but unconditional.
35727
+ *
35728
+ * Returns false when there is nothing to watch — the run has no browser, or
35729
+ * it is already being watched. Callers use that to avoid promising a viewer
35730
+ * a stream that will never arrive.
35731
+ */
35732
+ async startWatching(runId, onFrame) {
35733
+ const held = this.runs.get(runId);
35734
+ if (!held) return false;
35735
+ return await this.queueWatch(held, async () => {
35736
+ held.onFrame = onFrame;
35737
+ held.watchGen = (held.watchGen ?? 0) + 1;
35738
+ if (held.screencast) {
35739
+ this.announceControl(runId);
35740
+ return true;
35741
+ }
35742
+ const attached = await this.attachScreencast(runId, held, onFrame);
35743
+ if (!attached && held.onFrame === onFrame) held.onFrame = void 0;
35744
+ return attached !== null;
35745
+ });
35746
+ }
35747
+ /**
35748
+ * Run one watch or unwatch after every one already asked for.
35749
+ *
35750
+ * The chain is kept non-rejecting on purpose: a failed attach must not poison
35751
+ * every later request for this run, which is what a bare `.then` chain would
35752
+ * do. The caller still sees its own failure, because that is the promise
35753
+ * returned rather than the one stored.
35754
+ */
35755
+ queueWatch(held, op) {
35756
+ const depth = held.watchQueueDepth ?? 0;
35757
+ if (held.watchCollapsedPromise || depth >= MAX_WATCH_LIFECYCLE_QUEUE) {
35758
+ held.watchCollapsed = op;
35759
+ if (!held.watchCollapsedPromise) {
35760
+ const drain = async () => {
35761
+ let result;
35762
+ while (held.watchCollapsed) {
35763
+ const latest = held.watchCollapsed;
35764
+ held.watchCollapsed = void 0;
35765
+ try {
35766
+ result = await latest();
35767
+ } catch {
35768
+ }
35769
+ }
35770
+ return result;
35771
+ };
35772
+ const tail = (held.watchQueue ?? Promise.resolve()).then(drain, drain);
35773
+ let tracked;
35774
+ tracked = tail.finally(() => {
35775
+ if (held.watchCollapsedPromise === tracked) {
35776
+ held.watchCollapsedPromise = void 0;
35777
+ held.watchCollapsed = void 0;
35778
+ }
35779
+ });
35780
+ held.watchCollapsedPromise = tracked;
35781
+ held.watchQueue = tracked.catch(() => {
35782
+ });
35783
+ }
35784
+ return held.watchCollapsedPromise;
35785
+ }
35786
+ held.watchQueueDepth = depth + 1;
35787
+ const mine = (held.watchQueue ?? Promise.resolve()).then(op, op);
35788
+ const counted = mine.finally(() => {
35789
+ held.watchQueueDepth = Math.max(0, (held.watchQueueDepth ?? 1) - 1);
35790
+ });
35791
+ held.watchQueue = counted.catch(() => {
35792
+ });
35793
+ return counted;
35794
+ }
35795
+ /**
35796
+ * Attach at most one physical CDP screencast session at a time.
35797
+ *
35798
+ * `startWatching()` already serializes watcher lifecycle through `watchQueue`,
35799
+ * but the agent's detached-hidden restore cannot join that queue: it holds the
35800
+ * action slot, while an earlier unwatch may be waiting for exactly that slot.
35801
+ * Sharing only the physical attach closes the two-session race without making
35802
+ * watcher teardown and agent mutation wait on each other in a cycle.
35803
+ *
35804
+ * A real watcher claims `onFrame` immediately, before it waits for an attach
35805
+ * already started by the agent. The lower-level attach never writes the sink,
35806
+ * so finishing an older temporary attempt cannot overwrite a newer viewer.
35807
+ */
35808
+ async attachScreencast(runId, held, onFrame, popupDetachGen) {
35809
+ const consumerIntent = onFrame && popupDetachGen !== void 0 ? popupDetachGen : void 0;
35810
+ if (onFrame) held.onFrame = onFrame;
35811
+ if (held.screencast && held.screencastPage === held.page) return held.screencast;
35812
+ const targetPage = held.page;
35813
+ const existing = held.attaching;
35814
+ if (existing) {
35815
+ const existingPage = held.attachingPage;
35816
+ await existing.catch(() => null);
35817
+ if (held.page !== targetPage || existingPage !== targetPage) {
35818
+ return this.attachScreencast(runId, held, onFrame, popupDetachGen);
35819
+ }
35820
+ const cdp = held.screencastPage === targetPage ? held.screencast ?? null : null;
35821
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
35822
+ if (cdp && onFrame) held.onFrame = onFrame;
35823
+ return cdp;
35824
+ }
35825
+ if (held.screencast && held.screencastPage !== targetPage) {
35826
+ const stale = held.screencast;
35827
+ held.screencast = void 0;
35828
+ held.screencastPage = void 0;
35829
+ await stale.send("Page.stopScreencast").catch(() => {
35830
+ });
35831
+ await stale.detach().catch(() => {
35832
+ });
35833
+ }
35834
+ const attempt = this.attachScreencastFresh(runId, held, targetPage, consumerIntent);
35835
+ held.attaching = attempt;
35836
+ held.attachingPage = targetPage;
35837
+ try {
35838
+ const cdp = await attempt;
35839
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
35840
+ if (cdp && onFrame) held.onFrame = onFrame;
35841
+ return cdp;
35842
+ } finally {
35843
+ if (held.attaching === attempt) {
35844
+ held.attaching = void 0;
35845
+ held.attachingPage = void 0;
35846
+ }
35847
+ }
35848
+ }
35849
+ /** Open one new CDP session and start the stream when it is safe to do so. */
35850
+ async attachScreencastFresh(runId, held, targetPage, consumerIntent) {
35851
+ const safetyGate = held.hiddenByHolder === true || held.visibilityUnconfirmed === true;
35852
+ let cdp;
35853
+ try {
35854
+ cdp = await targetPage.context().newCDPSession(targetPage);
35855
+ } catch {
35856
+ if (!safetyGate) {
35857
+ held.visibilityUnconfirmed = false;
35858
+ held.capturing = true;
35859
+ this.announceControl(runId);
35860
+ }
35861
+ return null;
35862
+ }
35863
+ if (held.page !== targetPage) {
35864
+ await cdp.detach().catch(() => {
35865
+ });
35866
+ return null;
35867
+ }
35868
+ cdp.on("Page.screencastFrame", ((e) => {
35869
+ if (held.page === targetPage && typeof e?.data === "string") {
35870
+ const width = e.metadata?.deviceWidth ?? 0;
35871
+ const height = e.metadata?.deviceHeight ?? 0;
35872
+ held.metricsStale = true;
35873
+ if (held.visibilityUnconfirmed) {
35874
+ held.visibilityUnconfirmed = false;
35875
+ if (held.capturing !== true) {
35876
+ held.capturing = true;
35877
+ this.announceControl(runId);
35878
+ }
35879
+ }
35880
+ const ping = held.framePing;
35881
+ held.framePing = void 0;
35882
+ ping?.();
35883
+ const deliver = held.onFrame;
35884
+ if (deliver) {
35885
+ deliver({ data: e.data, width, height, generation: held.watchGen ?? 0 });
35886
+ }
35887
+ }
35888
+ void cdp.send("Page.screencastFrameAck", { sessionId: e?.sessionId }).catch(() => {
35889
+ });
35890
+ }));
35891
+ const paused = held.hiddenByHolder === true;
35892
+ if (!paused) {
35893
+ try {
35894
+ await cdp.send("Page.startScreencast", { ...SCREENCAST });
35895
+ } catch {
35896
+ if (!safetyGate) {
35897
+ held.visibilityUnconfirmed = false;
35898
+ held.capturing = true;
35899
+ this.announceControl(runId);
35900
+ } else {
35901
+ held.capturing = false;
35902
+ this.announceControl(runId);
35903
+ }
35904
+ await cdp.detach().catch(() => {
35905
+ });
35906
+ return null;
35907
+ }
35908
+ }
35909
+ if (held.page !== targetPage) {
35910
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
35911
+ });
35912
+ await cdp.detach().catch(() => {
35913
+ });
35914
+ return null;
35915
+ }
35916
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) {
35917
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
35918
+ });
35919
+ await cdp.detach().catch(() => {
35920
+ });
35921
+ return null;
35922
+ }
35923
+ held.screencast = cdp;
35924
+ held.screencastPage = targetPage;
35925
+ held.capturing = !paused;
35926
+ held.hiddenByHolder = paused;
35927
+ this.announceControl(runId);
35928
+ return cdp;
35929
+ }
35930
+ /** Nobody is watching any more: stop paying to render frames. */
35931
+ async stopWatching(runId) {
35932
+ const held = this.runs.get(runId);
35933
+ if (!held) return;
35934
+ held.watchDetachGen = (held.watchDetachGen ?? 0) + 1;
35935
+ held.onFrame = void 0;
35936
+ await this.queueWatch(held, async () => {
35937
+ const cdp = held.screencast;
35938
+ held.onFrame = void 0;
35939
+ if (!cdp) return;
35940
+ held.screencast = void 0;
35941
+ held.screencastPage = void 0;
35942
+ held.capturing = false;
35943
+ this.announceControl(runId);
35944
+ const lease = this.leases.get(runId);
35945
+ const token = lease ? await lease.acquireActionSlot(null, false) : null;
35946
+ if (lease && !token) return;
35947
+ try {
35948
+ await cdp.send("Page.stopScreencast").catch(() => {
35949
+ });
35950
+ await cdp.detach().catch(() => {
35951
+ });
35952
+ } finally {
35953
+ if (token && lease) lease.endAction(token);
35954
+ }
35955
+ });
35956
+ }
35957
+ /**
35958
+ * Suspend or resume the picture without giving up control.
35959
+ *
35960
+ * The credential case, and the only reason this is separate from watching. A
35961
+ * person who has taken the page over to sign in is about to type a password
35962
+ * into a field whose contents are echoed as dots — but a password manager's
35963
+ * dropdown, a "show password" toggle, a one-time code in plain text and the
35964
+ * confirmation page afterwards are not, and all of them would otherwise be
35965
+ * encoded into frames and sent across the network.
35966
+ *
35967
+ * What this does NOT do is hide anything from the agent: it stops the
35968
+ * picture, not the page. Nothing about a suspended capture would stop a
35969
+ * `extract_text` from reading the same screen — it is the lease that stops
35970
+ * that, by refusing the agent for as long as the person holds it.
35971
+ *
35972
+ * Answers whether frames are flowing afterwards, which is what a UI needs to
35973
+ * show and is not always what was asked for: a run with nobody watching has
35974
+ * no session to suspend.
35975
+ */
35976
+ async setCapture(runId, on) {
35977
+ const held = this.runs.get(runId);
35978
+ const cdp = held?.screencast;
35979
+ const watch = held?.watchGen;
35980
+ if (!held || !cdp) return false;
35981
+ const lease = this.leaseFor(runId);
35982
+ if (!on && !lease.heldByHuman) return held.capturing === true;
35983
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
35984
+ return held.capturing === true;
35985
+ }
35986
+ if (held.capturing === on) {
35987
+ if (lease.heldByHuman) lease.touch();
35988
+ return on;
35989
+ }
35990
+ const token = await lease.acquireActionSlot(null, true, MAX_CAPTURE_TRANSITION_QUEUE);
35991
+ if (!token) return held.capturing === true;
35992
+ try {
35993
+ if (!on && !lease.heldByHuman) return held.capturing === true;
35994
+ if (!on && (held.screencast !== cdp || held.watchGen !== watch)) {
35995
+ return held.capturing === true;
35996
+ }
35997
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
35998
+ return held.capturing === true;
35999
+ }
36000
+ const target = on ? held.screencast : cdp;
36001
+ if (!target) {
36002
+ if (on) {
36003
+ held.hiddenByHolder = false;
36004
+ if (lease.heldByHuman) lease.touch();
36005
+ }
36006
+ return held.capturing === true;
36007
+ }
36008
+ const now = await this.applyCapture(runId, held, target, on);
36009
+ if (lease.heldByHuman) lease.touch();
36010
+ return now;
36011
+ } finally {
36012
+ lease.endAction(token);
36013
+ }
36014
+ }
36015
+ /**
36016
+ * The capture transition itself, with no lease bookkeeping.
36017
+ *
36018
+ * Separate because the agent's restore in `act()` already holds the action
36019
+ * slot — taking it again there would deadlock against itself, and the whole
36020
+ * point of that call site is that it happens INSIDE the agent's turn.
36021
+ *
36022
+ * `confirmWithFrame` is used only by the agent's visibility gate. There, a
36023
+ * successful CDP command is not enough: the externally visible state remains
36024
+ * paused until a frame arrives, and the frame handler is the only code that
36025
+ * promotes it to capturing. This makes a restore timeout sticky rather than
36026
+ * a one-action refusal.
36027
+ */
36028
+ async applyCapture(runId, held, cdp, on, confirmWithFrame = false) {
36029
+ if (held.capturing === on && !(on && confirmWithFrame && held.visibilityUnconfirmed)) return on;
36030
+ const wasUnconfirmed = held.visibilityUnconfirmed === true;
36031
+ const wasCapturing = held.capturing === true;
36032
+ if (on) held.visibilityUnconfirmed = true;
36033
+ try {
36034
+ if (on) await cdp.send("Page.startScreencast", { ...SCREENCAST });
36035
+ else await cdp.send("Page.stopScreencast");
36036
+ if (on) {
36037
+ if (!confirmWithFrame) held.capturing = true;
36038
+ } else {
36039
+ held.visibilityUnconfirmed = false;
36040
+ held.capturing = false;
36041
+ }
36042
+ held.hiddenByHolder = !on;
36043
+ this.announceControl(runId);
36044
+ } catch {
36045
+ if (on) {
36046
+ held.visibilityUnconfirmed = wasUnconfirmed;
36047
+ held.capturing = wasCapturing;
36048
+ this.announceControl(runId);
36049
+ }
36050
+ return held.capturing === true && !held.visibilityUnconfirmed;
36051
+ }
36052
+ return on;
36053
+ }
36054
+ /**
36055
+ * Wait, briefly, for Chrome to produce a frame.
36056
+ *
36057
+ * `Page.startScreencast` resolves when the command is accepted, which is not
36058
+ * when a picture exists. The agent's visibility gate — "the page must be
36059
+ * VISIBLE before anything changes it" — was therefore satisfied by a request
36060
+ * rather than by a repaint, and the agent could click while the panel was
36061
+ * still showing the page from before it was hidden, or nothing at all.
36062
+ *
36063
+ * A frame rather than a client receipt, deliberately. A receipt would prove
36064
+ * somebody SAW it, which is stronger — and would make the agent's progress
36065
+ * depend on a viewer that may not exist: this path runs after a handback or a
36066
+ * lapse, when there may be nobody watching at all, and an agent that stalls
36067
+ * because a tab was closed is a worse failure than the one being fixed. What
36068
+ * this does guarantee is ours to guarantee: the stream is producing again.
36069
+ *
36070
+ * Bounded, and the bound rests on the property rounds 14 and 15 already rely
36071
+ * on — a screencast that has just been started emits its first frame at once,
36072
+ * whatever the page is doing. If one does not arrive, the picture genuinely
36073
+ * has not come back, and the caller refuses on the same trade it already
36074
+ * makes when the restore itself fails.
36075
+ */
36076
+ nextFrame(held, withinMs = RESTORE_FRAME_MS) {
36077
+ return new Promise((resolve2) => {
36078
+ const timer = setTimeout(() => {
36079
+ if (held.framePing === ping) held.framePing = void 0;
36080
+ resolve2(false);
36081
+ }, withinMs);
36082
+ timer.unref?.();
36083
+ const ping = () => {
36084
+ clearTimeout(timer);
36085
+ resolve2(true);
36086
+ };
36087
+ held.framePing = ping;
36088
+ });
36089
+ }
36090
+ /** Whether a person is driving this run's browser right now. */
36091
+ humanHolds(runId) {
36092
+ return this.leases.get(runId)?.heldByHuman === true;
36093
+ }
36094
+ /**
36095
+ * Hand this run's browser to the person watching it.
36096
+ *
36097
+ * The wait is the substance. An agent action already inside Playwright cannot
36098
+ * be interrupted — `click` waits for its element to become actionable and
36099
+ * then clicks, whatever anyone decided in the meantime — so a takeover that
36100
+ * returned immediately would put the person on a page an action was still
36101
+ * about to change. Bounded by the action ceiling, because that is the longest
36102
+ * an action can legitimately take; past it something is wedged, and saying so
36103
+ * is better than a spinner that never resolves.
36104
+ *
36105
+ * Idempotent for someone who already holds it: a second click of Take control
36106
+ * just says they are still there.
36107
+ */
36108
+ async takeOver(runId) {
36109
+ if (this.revoked.has(runId)) {
36110
+ return { ok: false, detail: "Browser control was stopped for this run." };
36111
+ }
36112
+ const requested = this.runs.get(runId);
36113
+ if (!requested) {
36114
+ return { ok: false, detail: "This run has no browser open." };
36115
+ }
36116
+ const requestedCdp = requested.screencast;
36117
+ const requestedWatch = requested.watchGen;
36118
+ if (!requestedCdp || requestedWatch === void 0 || !requested.onFrame) {
36119
+ return { ok: false, detail: "This browser is not being watched right now." };
36120
+ }
36121
+ const lease = this.leaseFor(runId);
36122
+ if (lease.heldByHuman) {
36123
+ lease.touch();
36124
+ this.announceControl(runId, true);
36125
+ return { ok: true };
36126
+ }
36127
+ const token = await lease.acquireActionSlot(ACTION_TIMEOUT_MS);
36128
+ if (!token) {
36129
+ return {
36130
+ ok: false,
36131
+ detail: "The agent is still finishing an action on this page. Try again in a moment."
36132
+ };
36133
+ }
36134
+ try {
36135
+ const held = this.runs.get(runId);
36136
+ if (this.revoked.has(runId)) {
36137
+ return { ok: false, detail: "Browser control was stopped for this run." };
36138
+ }
36139
+ if (!held || held.page.isClosed()) {
36140
+ return { ok: false, detail: "That browser closed while you were waiting for it." };
36141
+ }
36142
+ if (held.screencast !== requestedCdp || held.watchGen !== requestedWatch || !held.onFrame) {
36143
+ return { ok: false, detail: "The browser view changed while you were waiting. Take control again from the current view." };
36144
+ }
36145
+ lease.takeOver(runId);
36146
+ } finally {
36147
+ lease.endAction(token);
36148
+ }
36149
+ return { ok: true };
36150
+ }
36151
+ /** The person is done. Answers whether they were holding it at all. */
36152
+ /**
36153
+ * The viewer confirms it received a picture of this run's page.
36154
+ *
36155
+ * Reliable, one per watch, and the only thing that opens Hide. Checked
36156
+ * against the CURRENT watch: a receipt that names an older one is from a
36157
+ * viewer that has since been replaced, and vouching for its successor is the
36158
+ * reconnect hole this counter exists to close.
36159
+ */
36160
+ frameSeen(runId, generation) {
36161
+ const held = this.runs.get(runId);
36162
+ if (!held) return;
36163
+ if (typeof generation !== "number" || !Number.isFinite(generation)) return;
36164
+ if (held.watchGen === void 0 || generation !== held.watchGen) return;
36165
+ if (held.seenGen === generation) return;
36166
+ held.seenGen = generation;
36167
+ this.announceControl(runId);
36168
+ }
36169
+ handBack(runId) {
36170
+ return this.leases.get(runId)?.handBack() === true;
36171
+ }
36172
+ /**
36173
+ * One thing the person did to the page.
36174
+ *
36175
+ * Authorised per event, not per session. A takeover is not a channel that
36176
+ * stays open once opened: between two clicks the run can be stopped, the hold
36177
+ * can lapse, the page can close, and each of those has to stop the NEXT
36178
+ * event rather than be noticed at the end. So every event re-checks that this
36179
+ * run is live and that the lease is still the person's, and every accepted
36180
+ * event says so by extending the hold.
36181
+ */
36182
+ async input(runId, event) {
36183
+ const held = this.runs.get(runId);
36184
+ const cdp = held?.screencast;
36185
+ const watch = held?.watchGen;
36186
+ if (!held || held.page.isClosed()) return { ok: false, detail: "This run has no browser open." };
36187
+ const page2 = held.page;
36188
+ const pageGeneration = held.generation;
36189
+ if (!cdp) return { ok: false, detail: "Nothing is streaming this browser, so it cannot be driven." };
36190
+ const lease = this.leaseFor(runId);
36191
+ if (!lease.heldByHuman) {
36192
+ return { ok: false, detail: "You do not have control of this browser. Take control first." };
36193
+ }
36194
+ if (held.capturing !== true && (held.watchGen === void 0 || held.seenGen !== held.watchGen)) {
36195
+ return { ok: false, detail: "Show the page before typing into it \u2014 this view has not seen it yet." };
36196
+ }
36197
+ const sampled = event.kind === "move";
36198
+ const token = sampled ? lease.tryActionSlot() : await lease.acquireActionSlot(null, true, MAX_HUMAN_INPUT_QUEUE);
36199
+ if (!token) {
36200
+ if (sampled) return { ok: true };
36201
+ return { ok: false, detail: "Too many browser inputs are already waiting. Let the page catch up, then try again." };
36202
+ }
36203
+ try {
36204
+ if (!lease.heldByHuman) {
36205
+ return { ok: false, detail: "Your control of this browser ended while that was waiting." };
36206
+ }
36207
+ if (held.page !== page2 || held.generation !== pageGeneration) {
36208
+ return { ok: false, detail: "The page changed while that input was waiting, so it did not land." };
36209
+ }
36210
+ if (held.screencast !== cdp || held.watchGen !== watch) {
36211
+ return { ok: false, detail: "The view you were driving is gone, so that did not land." };
36212
+ }
36213
+ const pagesBefore = new Set(held.page.context().pages());
36214
+ await this.dispatch(cdp, held, event);
36215
+ await this.followOpenedPage(runId, held, pagesBefore);
36216
+ } catch (err) {
36217
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
36218
+ } finally {
36219
+ lease.endAction(token);
36220
+ }
36221
+ lease.touch();
36222
+ return { ok: true };
36223
+ }
36224
+ /**
36225
+ * The page's own coordinate space, measured rather than inferred.
36226
+ *
36227
+ * Cached, because a click should not cost a round trip, and invalidated by
36228
+ * the frame metadata changing shape — which arrives free with every frame. A
36229
+ * failed measurement falls back to the last good one rather than to a guess:
36230
+ * a stale viewport is off by a resize, an invented one is off by anything.
36231
+ */
36232
+ async pageViewport(held, cdp) {
36233
+ if (held.viewport && !held.metricsStale) return held.viewport;
36234
+ try {
36235
+ const metrics = await cdp.send("Page.getLayoutMetrics");
36236
+ const width = metrics?.cssVisualViewport?.clientWidth;
36237
+ const height = metrics?.cssVisualViewport?.clientHeight;
36238
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
36239
+ held.viewport = { width, height };
36240
+ held.metricsStale = false;
36241
+ return held.viewport;
36242
+ }
36243
+ } catch {
36244
+ }
36245
+ return null;
36246
+ }
36247
+ /**
36248
+ * Turn one authorised event into CDP calls. Coordinates arrive normalised.
36249
+ *
36250
+ * A switch with a default that REFUSES, rather than a chain of ifs ending in
36251
+ * the click branch. This is a mutation boundary reached from a socket, so the
36252
+ * question is not what a well-formed event does but what a malformed one
36253
+ * does: the chain answered "clicks", which meant a version-skewed client
36254
+ * sending `{ kind: 'drag', x, y }` pressed the mouse on a real page, and a
36255
+ * kind with no coordinates at all clamped to 0 and clicked the top-left
36256
+ * corner. Unknown input has to fail closed.
36257
+ *
36258
+ * The `never` in the default is the other half: adding a member to
36259
+ * `BrowserInput` without handling it here stops compiling, so the next kind
36260
+ * cannot silently inherit whatever the last branch happened to be.
36261
+ */
36262
+ async dispatch(cdp, held, event) {
36263
+ switch (event.kind) {
36264
+ case "text": {
36265
+ if (typeof event.text !== "string") {
36266
+ throw new Error("That is not text that can be typed into the page.");
36267
+ }
36268
+ const text = event.text.slice(0, 4e3);
36269
+ if (!text) return;
36270
+ await cdp.send("Input.insertText", { text });
36271
+ return;
36272
+ }
36273
+ case "key": {
36274
+ const modifiers = event.modifiers;
36275
+ if (modifiers !== void 0) {
36276
+ if (!Array.isArray(modifiers) || modifiers.some((m) => m !== "Control" && m !== "Meta" && m !== "Shift") || new Set(modifiers).size !== modifiers.length) {
36277
+ throw new Error("That editing shortcut has invalid modifiers.");
36278
+ }
36279
+ const primary = modifiers.filter((m) => m === "Control" || m === "Meta");
36280
+ const shifted = modifiers.includes("Shift");
36281
+ if (primary.length !== 1) {
36282
+ throw new Error("That editing shortcut cannot be sent to the page.");
36283
+ }
36284
+ const key = typeof event.key === "string" ? event.key : "";
36285
+ const lower = key.toLowerCase();
36286
+ const shortcut = lower === "a" && !shifted ? { key: "a", code: "KeyA", vk: 65 } : lower === "z" ? { key: "z", code: "KeyZ", vk: 90 } : lower === "y" && !shifted ? { key: "y", code: "KeyY", vk: 89 } : key === "Backspace" && !shifted ? { key: "Backspace", code: "Backspace", vk: 8 } : key === "Delete" && !shifted ? { key: "Delete", code: "Delete", vk: 46 } : void 0;
36287
+ if (!shortcut) {
36288
+ throw new Error(`That editing shortcut is not supported for the remote page: ${key}`);
36289
+ }
36290
+ const modifierBits = (primary[0] === "Control" ? 2 : 4) | (shifted ? 8 : 0);
36291
+ const base2 = {
36292
+ key: shortcut.key,
36293
+ code: shortcut.code,
36294
+ windowsVirtualKeyCode: shortcut.vk,
36295
+ nativeVirtualKeyCode: shortcut.vk,
36296
+ modifiers: modifierBits
36297
+ };
36298
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "rawKeyDown" });
36299
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "keyUp" });
36300
+ return;
36301
+ }
36302
+ const spec = typeof event.key === "string" && Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : void 0;
36303
+ if (!spec) throw new Error(`That key cannot be sent to the page: ${String(event.key)}`);
36304
+ const base = {
36305
+ key: event.key,
36306
+ code: event.key,
36307
+ windowsVirtualKeyCode: spec.code,
36308
+ nativeVirtualKeyCode: spec.code,
36309
+ ...spec.text ? { text: spec.text } : {}
36310
+ };
36311
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: spec.text ? "keyDown" : "rawKeyDown" });
36312
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" });
36313
+ return;
36314
+ }
36315
+ case "move":
36316
+ case "scroll":
36317
+ case "click": {
36318
+ const view = await this.pageViewport(held, cdp);
36319
+ if (!view) throw new Error("The page has not been measured yet, so a click cannot be placed on it.");
36320
+ const x = coordinate(event.x, "horizontal position") * view.width;
36321
+ const y = coordinate(event.y, "vertical position") * view.height;
36322
+ if (event.kind === "move") {
36323
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
36324
+ return;
36325
+ }
36326
+ if (event.kind === "scroll") {
36327
+ await cdp.send("Input.dispatchMouseEvent", {
36328
+ type: "mouseWheel",
36329
+ x,
36330
+ y,
36331
+ button: "none",
36332
+ buttons: 0,
36333
+ deltaX: 0,
36334
+ deltaY: scrollDelta(event.deltaY)
36335
+ });
36336
+ return;
36337
+ }
36338
+ if (event.button !== void 0 && !Object.hasOwn(BUTTONS, event.button)) {
36339
+ throw new Error(`That is not a mouse button: ${String(event.button)}`);
36340
+ }
36341
+ const button = event.button ?? "left";
36342
+ const buttons = BUTTONS[button];
36343
+ if (event.clicks !== void 0 && (typeof event.clicks !== "number" || !Number.isFinite(event.clicks))) {
36344
+ throw new Error("That is not a number of clicks.");
36345
+ }
36346
+ const clickCount = Math.min(Math.max(Math.trunc(event.clicks ?? 1), 1), 3);
36347
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
36348
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, buttons, clickCount });
36349
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, buttons: 0, clickCount });
36350
+ return;
36351
+ }
36352
+ default: {
36353
+ const unknown = event;
36354
+ throw new Error(`That is not something that can be done to the page: ${unknown?.kind ?? "an event with no kind"}`);
36355
+ }
36356
+ }
36357
+ }
36358
+ /** The user stopped this run. Refuses further actions and clears its queue. */
36359
+ stopRun(runId) {
36360
+ this.revoked.add(runId);
36361
+ this.leases.get(runId)?.dropRun(runId);
36362
+ this.aborts.get(runId)?.abort();
36363
+ const held = this.runs.get(runId);
36364
+ if (!held) return;
36365
+ this.runs.delete(runId);
36366
+ void this.teardown(runId, held);
36367
+ }
36368
+ /** A run ended: release its browser rather than pay for an idle session. */
36369
+ async endRun(runId) {
36370
+ this.leases.get(runId)?.dropRun(runId);
36371
+ this.aborts.get(runId)?.abort();
36372
+ const held = this.runs.get(runId);
36373
+ if (!held) {
36374
+ this.forgetRun(runId);
36375
+ return;
36376
+ }
36377
+ this.runs.delete(runId);
36378
+ await this.teardown(runId, held);
36379
+ this.forgetRun(runId);
36380
+ }
36381
+ /** Forget a finished run entirely, so nothing accumulates per run. */
36382
+ forgetRun(runId) {
36383
+ this.revoked.delete(runId);
36384
+ this.leases.delete(runId);
36385
+ this.aborts.delete(runId);
36386
+ this.liveViewListeners.delete(runId);
36387
+ this.offControlFor(runId);
36388
+ }
36389
+ /**
36390
+ * Let go of one run's browser.
36391
+ *
36392
+ * Best-effort throughout: a run is already over by the time this runs, and a
36393
+ * provider that is briefly unreachable must not turn that into a failure the
36394
+ * user sees. The provider's own idle timeout collects anything left.
36395
+ */
36396
+ async disposeRun(runId, held) {
36397
+ held.abort.abort();
36398
+ this.announceLiveView(runId, void 0, false);
36399
+ await held.watchQueue?.catch(() => {
36400
+ });
36401
+ const watching = held.screencast;
36402
+ held.screencast = void 0;
36403
+ held.screencastPage = void 0;
36404
+ if (watching) {
36405
+ await watching.send("Page.stopScreencast").catch(() => {
36406
+ });
36407
+ await watching.detach().catch(() => {
36408
+ });
36409
+ }
36410
+ await held.ownedContext?.close().catch(() => {
36411
+ });
36412
+ await held.browser.close().catch(() => {
36413
+ });
36414
+ await this.provider.endSession(held.session.id).catch(() => {
36415
+ });
36416
+ }
36417
+ /**
36418
+ * Run `disposeRun`, with the run id counted in `closing` until it settles.
36419
+ *
36420
+ * The caller adds the id to `closing` itself, synchronously, in the same
36421
+ * breath as deleting the run from `runs` — see the field comment. This just
36422
+ * guarantees the removal happens, success or failure, so a disposal that
36423
+ * throws cannot leave the slot counted against the pool forever.
36424
+ */
36425
+ teardown(runId, held) {
36426
+ const done = this.disposeRun(runId, held).finally(() => {
36427
+ this.closing.delete(runId);
36428
+ });
36429
+ this.closing.set(runId, done);
36430
+ return done;
36431
+ }
36432
+ async act(action, context) {
36433
+ const runId = context.sessionId;
36434
+ if (!runId) {
36435
+ return { ok: false, detail: "This browser action arrived without a run identity, so it was not run." };
36436
+ }
36437
+ const actor = context.actorId || runId;
36438
+ if (this.revoked.has(runId)) {
36439
+ return { ok: false, detail: "The user stopped browser control for this run." };
36440
+ }
36441
+ if (context.signal?.aborted) {
36442
+ return { ok: false, detail: "The run was cancelled." };
36443
+ }
36444
+ const plannedRun = this.runs.get(runId);
36445
+ const planned = plannedRun?.generation ?? 0;
36446
+ const recoveringClosedPage = plannedRun?.page.isClosed() === true;
36447
+ const lease_ = this.leaseFor(runId);
36448
+ const lease = await lease_.acquire(actor, runId, context.signal);
36449
+ if (lease !== "granted") {
36450
+ return { ok: false, detail: refusal(lease) };
36451
+ }
36452
+ if (this.revoked.has(runId)) {
36453
+ lease_.releaseIfHeldBy(actor);
36454
+ return { ok: false, detail: "The user stopped browser control for this run." };
36455
+ }
36456
+ if (context.signal?.aborted) {
36457
+ lease_.releaseIfHeldBy(actor);
36458
+ return { ok: false, detail: "The run was cancelled." };
36459
+ }
36460
+ const token = await lease_.acquireActionSlot();
36461
+ if (!token) {
36462
+ lease_.releaseIfHeldBy(actor);
36463
+ return { ok: false, detail: "Another browser action is still finishing. Try again." };
36464
+ }
36465
+ if (!lease_.heldBy(actor)) {
36466
+ lease_.endAction(token);
36467
+ return { ok: false, detail: refusal(lease_.heldByHuman ? "human" : "off") };
36468
+ }
36469
+ let temporaryCapture;
36470
+ try {
36471
+ const held = await this.open(runId, context.signal);
36472
+ if (this.revoked.has(runId)) {
36473
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36474
+ return { ok: false, detail: "The user stopped browser control for this run." };
36475
+ }
36476
+ if (context.signal?.aborted || held.abort.signal.aborted) {
36477
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36478
+ return { ok: false, detail: "The run was cancelled." };
36479
+ }
36480
+ if ((held.hiddenByHolder === true || held.visibilityUnconfirmed === true) && !held.screencast) {
36481
+ const watchGen = held.watchGen;
36482
+ let cdp = null;
36483
+ try {
36484
+ cdp = await this.attachScreencast(runId, held);
36485
+ } catch {
36486
+ }
36487
+ if (!cdp) {
36488
+ return {
36489
+ ok: false,
36490
+ detail: "The page could not be shown again, so nothing was done to it."
36491
+ };
36492
+ }
36493
+ if (held.onFrame === void 0 && held.watchGen === watchGen) {
36494
+ temporaryCapture = { held, cdp, watchGen };
36495
+ }
36496
+ }
36497
+ if (held.screencast && (held.capturing === false || held.visibilityUnconfirmed === true)) {
36498
+ const cdp = held.screencast;
36499
+ if (held.capturing === true) {
36500
+ await cdp.send("Page.stopScreencast").catch(() => {
36501
+ });
36502
+ held.capturing = false;
36503
+ this.announceControl(runId);
36504
+ }
36505
+ const drawn = this.nextFrame(held);
36506
+ if (!await this.applyCapture(runId, held, cdp, true, true)) {
36507
+ return {
36508
+ ok: false,
36509
+ detail: "The page could not be shown again, so nothing was done to it."
36510
+ };
36511
+ }
36512
+ if (!await drawn) {
36513
+ held.visibilityUnconfirmed = true;
36514
+ held.capturing = false;
36515
+ await cdp.send("Page.stopScreencast").catch(() => {
36516
+ });
36517
+ this.announceControl(runId);
36518
+ return {
36519
+ ok: false,
36520
+ detail: "The page did not come back on screen, so nothing was done to it."
36521
+ };
36522
+ }
36523
+ if (this.revoked.has(runId)) {
36524
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36525
+ return { ok: false, detail: "The user stopped browser control for this run." };
36526
+ }
36527
+ if (context.signal?.aborted || held.abort.signal.aborted) {
36528
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36529
+ return { ok: false, detail: "The run was cancelled." };
36530
+ }
36531
+ }
36532
+ return await this.perform(runId, held, action, recoveringClosedPage ? held.generation : planned);
36533
+ } catch (err) {
36534
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
36535
+ } finally {
36536
+ if (temporaryCapture) {
36537
+ const { held, cdp, watchGen } = temporaryCapture;
36538
+ if (held.screencast === cdp && held.onFrame === void 0 && held.watchGen === watchGen && held.hiddenByHolder === false && held.visibilityUnconfirmed === false && held.capturing === true) {
36539
+ held.onFrame = void 0;
36540
+ held.screencast = void 0;
36541
+ held.screencastPage = void 0;
36542
+ held.capturing = true;
36543
+ this.announceControl(runId);
36544
+ await cdp.send("Page.stopScreencast").catch(() => {
36545
+ });
36546
+ await cdp.detach().catch(() => {
36547
+ });
36548
+ }
36549
+ }
36550
+ lease_.endAction(token);
36551
+ if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
36552
+ }
36553
+ }
36554
+ /**
36555
+ * The run's browser, opened on first use.
36556
+ *
36557
+ * The ONE place the session cap is enforced. An earlier version also checked
36558
+ * it before taking the lease, on the reasoning that a run which cannot get a
36559
+ * session would otherwise queue forever — true when every run shared one
36560
+ * lease, and false once each run got its own, because a second run no longer
36561
+ * queues behind the first at all. Its own revert-check proved it dead: taking
36562
+ * it out changed no test. Two copies of a cap invite them to disagree.
36563
+ */
36564
+ async open(runId, signal) {
36565
+ if (this.ready) {
36566
+ const settling = this.ready;
36567
+ try {
36568
+ await settling;
36569
+ } catch {
36570
+ }
36571
+ if (this.ready === settling) this.ready = void 0;
36572
+ }
36573
+ const existing = this.runs.get(runId);
36574
+ if (existing && !existing.page.isClosed()) return existing;
36575
+ if (existing && existing.page.isClosed()) {
36576
+ const fallback = existing.page.context().pages().filter((p) => !p.isClosed()).at(-1);
36577
+ if (fallback) {
36578
+ await this.adoptPage(runId, existing, fallback);
36579
+ return existing;
36580
+ }
36581
+ }
36582
+ if (existing) {
36583
+ this.runs.delete(runId);
36584
+ await this.teardown(runId, existing);
36585
+ }
36586
+ if (this.retired) {
36587
+ throw new Error("The browser settings changed while this action was waiting. Try again.");
36588
+ }
36589
+ if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
36590
+ throw new Error(
36591
+ `All ${this.maxSessions} browser session${this.maxSessions === 1 ? "" : "s"} are in use by other runs. Try again when one finishes, or raise the session limit in settings.`
36592
+ );
36593
+ }
36594
+ if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
36595
+ const opening = this.openReserved(runId, signal);
36596
+ this.opening.set(runId, opening);
36597
+ try {
36598
+ return await opening;
36599
+ } finally {
36600
+ this.opening.delete(runId);
36601
+ }
36602
+ }
36603
+ /** The part that may fail, with the pool slot already reserved. */
36604
+ async openReserved(runId, signal) {
36605
+ const playwright = await loadPlaywright();
36606
+ const abort = this.aborts.get(runId)?.signal;
36607
+ const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
36608
+ const session = await this.provider.createSession(creationSignal);
36609
+ let browser;
36610
+ let owned;
36611
+ let held;
36612
+ try {
36613
+ browser = await playwright.chromium.connectOverCDP(session.cdpUrl);
36614
+ owned = this.provider.isolatesSessions ? void 0 : await browser.newContext();
36615
+ const context = owned ?? browser.contexts()[0] ?? await browser.newContext();
36616
+ const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
36617
+ const abort2 = this.aborts.get(runId);
36618
+ if (!abort2) throw new Error("The run ended while its browser was still opening.");
36619
+ held = { session, browser, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
36620
+ this.trackPageNavigation(runId, held, page2);
36621
+ this.runs.set(runId, held);
36622
+ } catch (err) {
36623
+ await owned?.close().catch(() => {
36624
+ });
36625
+ await browser?.close().catch(() => {
36626
+ });
36627
+ await this.provider.endSession(session.id).catch(() => {
36628
+ });
36629
+ throw err;
36630
+ }
36631
+ this.announceLiveView(runId, session.liveViewUrl, true);
36632
+ return held;
36633
+ }
36634
+ /** Count main-frame navigation only while this page is the run's active page. */
36635
+ trackPageNavigation(runId, held, page2) {
36636
+ page2.on("framenavigated", ((frame) => {
36637
+ if (held.page !== page2 || frame !== page2.mainFrame()) return;
36638
+ held.generation += 1;
36639
+ if (held.onFrame && held.watchGen !== void 0) {
36640
+ held.watchGen += 1;
36641
+ this.announceControl(runId);
36642
+ }
36643
+ }));
36644
+ }
36645
+ /**
36646
+ * Follow a page opened by the action that just finished.
36647
+ *
36648
+ * A popup/new tab is part of the same user gesture (OAuth is the common
36649
+ * example). Keeping `held.page` and the screencast on its opener makes the
36650
+ * new page both invisible and impossible to drive. This runs while the SAME
36651
+ * action slot is still held, after the opening click/key has fully landed, so
36652
+ * no later human/agent event can slip between detecting the popup and moving
36653
+ * the view to it.
36654
+ */
36655
+ async followOpenedPage(runId, held, before) {
36656
+ if (this.runs.get(runId) !== held || held.abort.signal.aborted) return;
36657
+ const candidates = held.page.context().pages().filter((p) => !p.isClosed() && !before.has(p));
36658
+ const next = candidates.at(-1);
36659
+ if (!next || next === held.page) return;
36660
+ await this.adoptPage(runId, held, next);
36661
+ }
36662
+ /**
36663
+ * Make one live page the run's controlled/viewed page. Used in both
36664
+ * directions: into a popup opened by an action, and back to another surviving
36665
+ * page when that popup closes itself after OAuth/login completion.
36666
+ */
36667
+ async adoptPage(runId, held, next) {
36668
+ if (next === held.page || next.isClosed()) return;
36669
+ held.page = next;
36670
+ held.generation += 1;
36671
+ held.viewport = void 0;
36672
+ held.metricsStale = true;
36673
+ this.trackPageNavigation(runId, held, next);
36674
+ const deliver = held.onFrame;
36675
+ if (deliver) {
36676
+ held.watchGen = (held.watchGen ?? 0) + 1;
36677
+ held.visibilityUnconfirmed = true;
36678
+ held.capturing = false;
36679
+ this.announceControl(runId);
36680
+ await this.attachScreencast(runId, held, deliver, held.watchDetachGen ?? 0);
36681
+ return;
36682
+ }
36683
+ const pending = held.attaching;
36684
+ if (pending) await pending.catch(() => null);
36685
+ if (held.screencast && held.screencastPage !== next) {
36686
+ const stale = held.screencast;
36687
+ held.screencast = void 0;
36688
+ held.screencastPage = void 0;
36689
+ await stale.send("Page.stopScreencast").catch(() => {
36690
+ });
36691
+ await stale.detach().catch(() => {
36692
+ });
36693
+ }
36694
+ held.visibilityUnconfirmed = false;
36695
+ held.capturing = held.hiddenByHolder !== true;
36696
+ this.announceControl(runId);
36697
+ }
36698
+ async perform(runId, held, action, planned) {
36699
+ const { page: page2 } = held;
36700
+ const stoppable = async (work) => {
36701
+ if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
36702
+ let onAbort;
36703
+ try {
36704
+ return await Promise.race([
36705
+ work,
36706
+ new Promise((_, reject) => {
36707
+ onAbort = () => {
36708
+ void page2.close().catch(() => {
36709
+ });
36710
+ reject(new Error("The user stopped browser control for this run."));
36711
+ };
36712
+ held.abort.signal.addEventListener("abort", onAbort, { once: true });
36713
+ })
36714
+ ]);
36715
+ } finally {
36716
+ held.abort.signal.removeEventListener("abort", onAbort);
36717
+ }
36718
+ };
36719
+ const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
36720
+ const where = async () => {
36721
+ const current = held.page;
36722
+ return { url: current.url(), title: await current.title().catch(() => "") };
36723
+ };
36724
+ if (action.kind !== "navigate" && held.generation !== planned) {
36725
+ return {
36726
+ ok: false,
36727
+ detail: "The page changed while this action was waiting for the browser, so it was not run. Look at the page again before retrying.",
36728
+ ...await where()
36729
+ };
36730
+ }
36731
+ switch (action.kind) {
36732
+ case "navigate": {
36733
+ const target = action.url ?? "";
36734
+ if (!/^https?:\/\//i.test(target)) {
36735
+ return { ok: false, detail: "Only http and https addresses can be opened." };
36736
+ }
36737
+ await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
36738
+ return { ok: true, detail: `Opened ${target}`, ...await where() };
36739
+ }
36740
+ case "click": {
36741
+ const pagesBefore = new Set(page2.context().pages());
36742
+ await stoppable(page2.click(action.selector, { timeout }));
36743
+ await this.followOpenedPage(runId, held, pagesBefore);
36744
+ return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
36745
+ }
36746
+ case "fill":
36747
+ await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
36748
+ return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
36749
+ case "press": {
36750
+ const pagesBefore = new Set(page2.context().pages());
36751
+ if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
36752
+ else await stoppable(page2.keyboard.press(action.key));
36753
+ await this.followOpenedPage(runId, held, pagesBefore);
36754
+ return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
36755
+ }
36756
+ case "wait_for":
36757
+ await stoppable(page2.waitForSelector(action.selector, { timeout }));
36758
+ return { ok: true, detail: `${action.selector} appeared`, ...await where() };
36759
+ case "extract_text": {
36760
+ const text = await stoppable(page2.innerText(action.selector ?? "body", { timeout }));
36761
+ return { ok: true, detail: text.slice(0, 2e5), ...await where() };
36762
+ }
36763
+ default:
36764
+ return { ok: false, detail: `Unsupported action: ${String(action.kind)}` };
36765
+ }
36766
+ }
36767
+ };
36768
+ function refusal(result) {
36769
+ if (result === "human") {
36770
+ return "The user has taken control of this browser. Stop using it and tell them what you were about to do.";
36771
+ }
36772
+ if (result === "off") return "Browser control was turned off while this action waited for the browser.";
36773
+ if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
36774
+ return "The browser is in use by another part of this run and did not come free in time. Try again, or do something else first.";
36775
+ }
36776
+ function coordinate(n, what) {
36777
+ if (typeof n !== "number" || !Number.isFinite(n)) {
36778
+ throw new Error(`That ${what} is not a position on the page.`);
36779
+ }
36780
+ return Math.min(Math.max(n, 0), 1);
36781
+ }
36782
+ function scrollDelta(n) {
36783
+ if (typeof n !== "number" || !Number.isFinite(n)) {
36784
+ throw new Error("That scroll has no distance to travel.");
36785
+ }
36786
+ return Math.min(Math.max(n, -2e3), 2e3);
36787
+ }
36788
+ async function loadPlaywright() {
36789
+ try {
36790
+ return await import('playwright');
36791
+ } catch {
36792
+ throw new Error("Playwright is not installed. Run: npm install playwright (no browser download is needed \u2014 this connects to a remote browser).");
36793
+ }
36794
+ }
36795
+
36796
+ // src/browser/remote/generic-cdp.ts
36797
+ var GenericCdpProvider = class {
36798
+ name = "CDP endpoint";
36799
+ // The endpoint is a single browser. There is no session to allocate, so two
36800
+ // runs against it share a page — see the seam.
36801
+ isolatesSessions = false;
36802
+ endpoint;
36803
+ constructor(endpoint) {
36804
+ this.endpoint = endpoint;
36805
+ }
36806
+ /**
36807
+ * There is no session to create — the endpoint is the session.
36808
+ *
36809
+ * `id` is a constant rather than a generated one because there is nothing to
36810
+ * look up later: `endSession` has nothing to call. Inventing a unique id
36811
+ * would imply a lifecycle this provider does not have.
36812
+ */
36813
+ async createSession() {
36814
+ return { id: "cdp", cdpUrl: this.endpoint };
36815
+ }
36816
+ /**
36817
+ * Deliberately nothing.
36818
+ *
36819
+ * The endpoint belongs to whoever runs it and outlives any single run, so
36820
+ * "ending" it is not ours to do — closing it would take the browser away from
36821
+ * every other user of a shared endpoint. The driver still closes its own
36822
+ * connection and its pages; this is only about the browser's lifetime.
36823
+ */
36824
+ async endSession() {
36825
+ }
36826
+ };
36827
+ function isCdpEndpoint(url) {
36828
+ try {
36829
+ const u = new URL(url);
36830
+ return u.protocol === "ws:" || u.protocol === "wss:";
36831
+ } catch {
36832
+ return false;
36833
+ }
36834
+ }
36835
+
36836
+ // src/browser/remote/steel.ts
36837
+ var DEFAULT_BASE = "https://api.steel.dev";
36838
+ var CREATE_TIMEOUT_MS = 6e4;
36839
+ var SteelProvider = class {
36840
+ name = "Steel";
36841
+ // POST /v1/sessions allocates a browser per call, so concurrent runs are
36842
+ // genuinely separate.
36843
+ isolatesSessions = true;
36844
+ base;
36845
+ apiKey;
36846
+ constructor(opts = {}) {
36847
+ let base = opts.url || DEFAULT_BASE;
36848
+ while (base.endsWith("/")) base = base.slice(0, -1);
36849
+ this.base = base;
36850
+ this.apiKey = opts.apiKey;
36851
+ }
36852
+ async createSession(signal) {
36853
+ const details = await this.call("POST", "/v1/sessions", signal, {});
36854
+ if (!details.id || !details.websocketUrl) {
36855
+ throw new Error("Steel returned a session with no id or websocket URL.");
36856
+ }
36857
+ return {
36858
+ id: details.id,
36859
+ cdpUrl: details.websocketUrl,
36860
+ ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
36861
+ };
36862
+ }
36863
+ async endSession(id) {
36864
+ try {
36865
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
36866
+ } catch {
36867
+ }
36868
+ }
36869
+ async call(method, path30, signal, body) {
36870
+ const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
36871
+ const composed = signal ? AbortSignal.any([signal, timeout]) : timeout;
36872
+ const res = await fetch(`${this.base}${path30}`, {
36873
+ method,
36874
+ headers: {
36875
+ "content-type": "application/json",
36876
+ // Sent only when configured — the self-hosted API takes no key, and an
36877
+ // empty header is worse than none.
36878
+ ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
36879
+ },
36880
+ body: JSON.stringify(body),
36881
+ signal: composed
36882
+ });
36883
+ if (!res.ok) {
36884
+ const detail = await res.text().then((t) => t.slice(0, 300)).catch(() => "");
36885
+ throw new Error(`Steel ${method} ${path30} failed: ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
36886
+ }
36887
+ return await res.json();
36888
+ }
36889
+ };
36890
+
34946
36891
  // src/retrieval/chunk.ts
34947
36892
  var DEFAULTS2 = { targetChars: 2e3, overlapChars: 200, minChars: 80 };
34948
36893
  function splitLongBlock(block, max) {
@@ -35598,6 +37543,7 @@ exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
35598
37543
  exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
35599
37544
  exports.AuditLogger = AuditLogger;
35600
37545
  exports.BrowserControlTool = BrowserControlTool;
37546
+ exports.BrowserLease = BrowserLease;
35601
37547
  exports.CASCADE_AUDIT_FILE = CASCADE_AUDIT_FILE;
35602
37548
  exports.CASCADE_CONFIG_DIR = CASCADE_CONFIG_DIR;
35603
37549
  exports.CASCADE_CONFIG_FILE = CASCADE_CONFIG_FILE;
@@ -35638,6 +37584,7 @@ exports.GLOBAL_DB_FILE = GLOBAL_DB_FILE;
35638
37584
  exports.GLOBAL_KEYSTORE_FILE = GLOBAL_KEYSTORE_FILE;
35639
37585
  exports.GLOBAL_RUNTIME_DB_FILE = GLOBAL_RUNTIME_DB_FILE;
35640
37586
  exports.GenerateDocumentTool = GenerateDocumentTool;
37587
+ exports.GenericCdpProvider = GenericCdpProvider;
35641
37588
  exports.GraphRetriever = GraphRetriever;
35642
37589
  exports.GraphSearchTool = GraphSearchTool;
35643
37590
  exports.HooksRunner = HooksRunner;
@@ -35654,9 +37601,11 @@ exports.OLLAMA_BASE_URL = OLLAMA_BASE_URL;
35654
37601
  exports.OpenAICompatibleEmbedder = OpenAICompatibleEmbedder;
35655
37602
  exports.PROVIDER_DISPLAY_NAMES = PROVIDER_DISPLAY_NAMES;
35656
37603
  exports.RESUME_SCHEMA_VERSION = RESUME_SCHEMA_VERSION;
37604
+ exports.RemoteBrowserController = RemoteBrowserController;
35657
37605
  exports.ResumeStore = ResumeStore;
35658
37606
  exports.Retriever = Retriever;
35659
37607
  exports.SqliteVectorStore = SqliteVectorStore;
37608
+ exports.SteelProvider = SteelProvider;
35660
37609
  exports.T1Administrator = T1Administrator;
35661
37610
  exports.T1_MODEL_PRIORITY = T1_MODEL_PRIORITY;
35662
37611
  exports.T2Manager = T2Manager;
@@ -35721,6 +37670,7 @@ exports.hashContent = hashContent;
35721
37670
  exports.heuristicCodeChunker = heuristicCodeChunker;
35722
37671
  exports.inferAzureBaseModel = inferAzureBaseModel;
35723
37672
  exports.inlineRuns = inlineRuns;
37673
+ exports.isCdpEndpoint = isCdpEndpoint;
35724
37674
  exports.isDocumentFormat = isDocumentFormat;
35725
37675
  exports.isMcpToolName = isMcpToolName;
35726
37676
  exports.matchImageLine = matchImageLine;