cascade-ai 0.79.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.js CHANGED
@@ -11623,7 +11623,7 @@ function reconcilePrice(model, live) {
11623
11623
  }
11624
11624
 
11625
11625
  // src/constants.ts
11626
- var CASCADE_VERSION = "0.79.0";
11626
+ var CASCADE_VERSION = "0.80.0";
11627
11627
  var CASCADE_CONFIG_DIR = ".cascade";
11628
11628
  var CASCADE_MD_FILE = "CASCADE.md";
11629
11629
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -34977,11 +34977,13 @@ async function discoverMcpTools(servers) {
34977
34977
  }
34978
34978
 
34979
34979
  // src/browser/lease.ts
34980
+ var HUMAN_ACTOR = "__human__";
34981
+ var HUMAN_IDLE_MS = 12e4;
34980
34982
  var LEASE_DEADLOCK_CEILING_MS = 3e5;
34981
34983
  var QUEUE_WAIT_MS = 18e4;
34982
34984
  var MAX_QUEUED = 8;
34983
34985
  var ACTION_HANDOFF_MS = 2e3;
34984
- var ACTION_HANDOFF_POLL_MS = 25;
34986
+ var HANDBACK_RETRY_MS = 25;
34985
34987
  var BrowserLease = class {
34986
34988
  actor = null;
34987
34989
  session = null;
@@ -35000,6 +35002,51 @@ var BrowserLease = class {
35000
35002
  * took the slot may release it.
35001
35003
  */
35002
35004
  action = null;
35005
+ /** Whether the current action slot represents activity by its holder. */
35006
+ actionCountsAsHumanActivity = false;
35007
+ /**
35008
+ * The human action that has already consumed its one idle-deadline grace.
35009
+ * A single wedged CDP request must not look like fresh activity every two
35010
+ * minutes forever. The first deadline may yield to an event that began before
35011
+ * it; a second deadline for that SAME event becomes a pending lapse instead.
35012
+ */
35013
+ idleGraceAction = null;
35014
+ /** Release the human hold as soon as this overlong action finally unwinds. */
35015
+ idleLapseAfterAction = null;
35016
+ /**
35017
+ * Everyone waiting for that slot, IN THE ORDER THEY ASKED.
35018
+ *
35019
+ * A queue rather than a poll, and the ordering is the whole reason for it.
35020
+ * `acquireActionSlot` used to spin on a 25ms timer, which decides nothing
35021
+ * about who goes first: each waiter checks on its own phase, so the one that
35022
+ * asked SECOND can find the slot free a full poll ahead of the one that asked
35023
+ * first. The person types one event per keypress — `{ kind: 'text', text }`
35024
+ * carries a single character — so that reordering is not a theoretical
35025
+ * unfairness, it is "passowrd" appearing in the box.
35026
+ *
35027
+ * The slot is handed straight from `endAction` to the head of this line while
35028
+ * still synchronous, so nothing can slip in between the two, and a waiter
35029
+ * that reaches the front never has to be told the slot is already taken.
35030
+ */
35031
+ actionQueue = [];
35032
+ /** Lapses a human hold nobody is using. See `HUMAN_IDLE_MS`. */
35033
+ humanTimer = null;
35034
+ /**
35035
+ * The person ASKED for the hold to end and it could not end yet.
35036
+ *
35037
+ * Kept as a request rather than only a retry timer, because the retry shares
35038
+ * `humanTimer` with the idle deadline: an authorised event settling after the
35039
+ * request would otherwise call `touch()`, re-arm that timer for a full idle
35040
+ * interval, and quietly cancel a handback the person had already asked for.
35041
+ * "Give it back" is not undone by activity that was already under way when
35042
+ * they said it.
35043
+ *
35044
+ * Explicit requests ONLY. An idle expiry must never set this: a deadline that
35045
+ * lands on the person's own in-flight event is evidence they were THERE, and
35046
+ * recording it here made `touch()` bail afterwards, so the hold dropped the
35047
+ * instant a fresh interaction finished. See `lapse()`.
35048
+ */
35049
+ handBackWanted = false;
35003
35050
  isRevoked;
35004
35051
  onChange;
35005
35052
  constructor(options = {}) {
@@ -35010,6 +35057,20 @@ var BrowserLease = class {
35010
35057
  get holderActor() {
35011
35058
  return this.actor;
35012
35059
  }
35060
+ /** Whether a person, rather than the agent, is driving the page right now. */
35061
+ get heldByHuman() {
35062
+ return this.actor === HUMAN_ACTOR;
35063
+ }
35064
+ /**
35065
+ * Whether THIS actor is the one holding the browser right now.
35066
+ *
35067
+ * For asking again after a wait. `acquire` is re-entrant for the holder, so
35068
+ * "I was granted the lease" is a fact about the moment it was granted and
35069
+ * says nothing about the moment the caller finally gets to act.
35070
+ */
35071
+ heldBy(actorId) {
35072
+ return this.actor === actorId;
35073
+ }
35013
35074
  get holderSession() {
35014
35075
  return this.session;
35015
35076
  }
@@ -35036,6 +35097,7 @@ var BrowserLease = class {
35036
35097
  * the same worker coming back for its next step must not queue behind itself.
35037
35098
  */
35038
35099
  async acquire(actorId, sessionId, signal) {
35100
+ if (this.actor === HUMAN_ACTOR && actorId !== HUMAN_ACTOR) return "human";
35039
35101
  if (this.actor === null || this.actor === actorId) {
35040
35102
  this.actor = actorId;
35041
35103
  this.session = sessionId;
@@ -35076,6 +35138,105 @@ var BrowserLease = class {
35076
35138
  if (this.action) return;
35077
35139
  if (this.actor === actorId) this.release();
35078
35140
  }
35141
+ /**
35142
+ * Give the browser to the person watching it.
35143
+ *
35144
+ * Callers must have waited the in-flight action out first — see
35145
+ * `acquireActionSlot`. This does not interrupt one, because it cannot: a
35146
+ * Playwright call already inside the page finishes whatever it started, and
35147
+ * taking the lease off it would only mean the person and a still-running
35148
+ * `fill` were both touching the same form. Waiting is what makes "you have
35149
+ * control" true when the UI says it.
35150
+ *
35151
+ * Every queued worker is turned away rather than left in line, for the same
35152
+ * reason `acquire` refuses: they were planned against a page the person is
35153
+ * about to change.
35154
+ */
35155
+ takeOver(sessionId) {
35156
+ for (let i = this.waiting.length - 1; i >= 0; i--) this.waiting[i].settle("human");
35157
+ this.clearCeiling();
35158
+ this.actor = HUMAN_ACTOR;
35159
+ this.session = sessionId;
35160
+ this.touch();
35161
+ this.onChange();
35162
+ }
35163
+ /**
35164
+ * The person is still there — anything they did counts.
35165
+ *
35166
+ * Called per authorised input rather than on a heartbeat, so the hold is
35167
+ * extended by USE and not merely by a tab being open. A left-open panel on a
35168
+ * forgotten laptop stops holding a billed session two minutes later.
35169
+ */
35170
+ touch() {
35171
+ if (this.actor !== HUMAN_ACTOR) return;
35172
+ if (this.handBackWanted) return;
35173
+ this.armIdle();
35174
+ }
35175
+ /** (Re)start the idle interval. The only place the deadline is set. */
35176
+ armIdle() {
35177
+ this.clearHumanIdle();
35178
+ this.humanTimer = setTimeout(() => {
35179
+ this.lapse();
35180
+ }, HUMAN_IDLE_MS);
35181
+ this.humanTimer.unref?.();
35182
+ }
35183
+ /**
35184
+ * The idle deadline came round.
35185
+ *
35186
+ * Both ways a hold ends obey the same rule — never while one of the person's
35187
+ * own events is still landing — but they resolve that clash in OPPOSITE
35188
+ * directions, and routing the lapse through `handBack()` collapsed them into
35189
+ * one. An event in flight BEGAN before this deadline, which makes it evidence
35190
+ * the person was there; deferring the lapse and then releasing the moment
35191
+ * that event lands ends the hold immediately after a fresh interaction, which
35192
+ * is the precise opposite of what an idle deadline measures. So an
35193
+ * interrupted lapse yields: the interval starts again, and the next silence
35194
+ * decides.
35195
+ *
35196
+ * `handBack()` keeps the deferral instead, because "Give it back" is a
35197
+ * decision and activity already under way when it was made does not withdraw
35198
+ * it. Same rule, different answer, which is why they are different methods.
35199
+ */
35200
+ lapse() {
35201
+ if (this.actor !== HUMAN_ACTOR) return;
35202
+ if (this.action && this.actionCountsAsHumanActivity) {
35203
+ if (this.idleGraceAction !== this.action) {
35204
+ this.idleGraceAction = this.action;
35205
+ this.armIdle();
35206
+ } else {
35207
+ this.idleLapseAfterAction = this.action;
35208
+ this.clearHumanIdle();
35209
+ }
35210
+ return;
35211
+ }
35212
+ this.release();
35213
+ }
35214
+ /**
35215
+ * The person hands the browser back, or their hold lapses.
35216
+ *
35217
+ * Answers whether there was a human hold to end, so a caller can tell a real
35218
+ * handback from a duplicate click or a lapse that already happened.
35219
+ */
35220
+ handBack() {
35221
+ if (this.actor !== HUMAN_ACTOR) return false;
35222
+ if (this.action) {
35223
+ this.handBackWanted = true;
35224
+ this.clearHumanIdle();
35225
+ this.humanTimer = setTimeout(() => {
35226
+ this.handBack();
35227
+ }, HANDBACK_RETRY_MS);
35228
+ this.humanTimer.unref?.();
35229
+ return false;
35230
+ }
35231
+ this.release();
35232
+ return true;
35233
+ }
35234
+ clearHumanIdle() {
35235
+ if (this.humanTimer) {
35236
+ clearTimeout(this.humanTimer);
35237
+ this.humanTimer = null;
35238
+ }
35239
+ }
35079
35240
  /** Drop every queued waiter belonging to a run, and the lease if it holds it. */
35080
35241
  dropRun(sessionId) {
35081
35242
  for (let i = this.waiting.length - 1; i >= 0; i--) {
@@ -35091,6 +35252,10 @@ var BrowserLease = class {
35091
35252
  /** Hand the browser to the next actor in line, if any. */
35092
35253
  release() {
35093
35254
  this.clearCeiling();
35255
+ this.clearHumanIdle();
35256
+ this.handBackWanted = false;
35257
+ this.idleGraceAction = null;
35258
+ this.idleLapseAfterAction = null;
35094
35259
  this.actor = null;
35095
35260
  this.session = null;
35096
35261
  let next = this.waiting.shift();
@@ -35119,33 +35284,111 @@ var BrowserLease = class {
35119
35284
  this.ceilingTimer = null;
35120
35285
  }
35121
35286
  }
35122
- /** Take the per-action slot, or null when one is already running. */
35123
- beginAction() {
35287
+ /**
35288
+ * Take the per-action slot, or null when one is already running.
35289
+ *
35290
+ * PRIVATE, so the queue is the only way in. A caller that could take the slot
35291
+ * without joining the line would be exactly the jump-the-queue the ordering
35292
+ * is there to prevent.
35293
+ */
35294
+ beginAction(countsAsHumanActivity = true) {
35124
35295
  if (this.action) return null;
35125
35296
  const token = /* @__PURE__ */ Symbol("browser-action");
35126
35297
  this.action = token;
35298
+ this.actionCountsAsHumanActivity = countsAsHumanActivity;
35127
35299
  return token;
35128
35300
  }
35129
- /** Release the slot, but only for the action that took it. */
35301
+ /**
35302
+ * Release the slot, but only for the action that took it.
35303
+ *
35304
+ * Hands it to the head of the line rather than merely clearing it, and does
35305
+ * so SYNCHRONOUSLY: the waiter's `beginAction` runs before this call returns,
35306
+ * so there is no window in which the slot is free and somebody who asked
35307
+ * later can take it.
35308
+ */
35130
35309
  endAction(token) {
35131
35310
  if (this.action !== token) return false;
35311
+ const lapseNow = this.actor === HUMAN_ACTOR && this.idleLapseAfterAction === token;
35132
35312
  this.action = null;
35313
+ this.actionCountsAsHumanActivity = false;
35314
+ if (this.idleGraceAction === token) this.idleGraceAction = null;
35315
+ if (this.idleLapseAfterAction === token) this.idleLapseAfterAction = null;
35316
+ if (lapseNow) this.release();
35317
+ this.actionQueue.shift()?.();
35133
35318
  return true;
35134
35319
  }
35135
35320
  /**
35136
- * Wait, briefly, for an outgoing action to unwind.
35321
+ * Take the slot only if it is free RIGHT NOW, never joining the line.
35322
+ *
35323
+ * For input that is a SAMPLE rather than a decision — pointer movement. A
35324
+ * click or a keystroke is a thing the person meant to do and has to happen,
35325
+ * so it waits its turn; a pointer position is one of many on the way
35326
+ * somewhere, and one that cannot be applied now is worth less than the one
35327
+ * behind it. Queueing them is actively harmful: on an endpoint slower than
35328
+ * the client's sampling interval the backlog grows, and the click behind it
35329
+ * can age out against `acquireActionSlot`'s bound and be refused — movement
35330
+ * costing the person the action they actually intended.
35331
+ *
35332
+ * The queue length is checked as well as the slot, so a sample cannot step
35333
+ * over a click that is already waiting.
35334
+ */
35335
+ tryActionSlot() {
35336
+ if (this.action || this.actionQueue.length > 0) return null;
35337
+ return this.beginAction();
35338
+ }
35339
+ /**
35340
+ * Take the per-action slot, waiting in line for it if somebody has it.
35137
35341
  *
35138
35342
  * A release can hand the browser on while the previous holder's abort is
35139
35343
  * still propagating through an awaited navigation. That is a handoff, not a
35140
- * conflict, so it is waited out rather than refused — but bounded, because a
35141
- * wedged action must not pin the queue.
35142
- */
35143
- async awaitActionSlot() {
35144
- for (let waited = 0; waited < ACTION_HANDOFF_MS; waited += ACTION_HANDOFF_POLL_MS) {
35145
- if (!this.action) return true;
35146
- await new Promise((r) => setTimeout(r, ACTION_HANDOFF_POLL_MS));
35147
- }
35148
- return this.action === null;
35344
+ * conflict, so it is normally bounded: a wedged action must not pin an agent
35345
+ * or a takeover forever. Human discrete input is the exception and passes
35346
+ * `null`: once a click, key or committed text has been accepted into this
35347
+ * ordered queue, expiring it merely because earlier accepted input took more
35348
+ * than two seconds silently truncates what the person typed.
35349
+ *
35350
+ * Waiting and TAKING are one step, which they deliberately were not before:
35351
+ * the wait used to answer "the slot looks free" and leave the caller to call
35352
+ * `beginAction` afterwards. Two callers woken by the same release both saw it
35353
+ * free, and the loser was told "another action is already running" — a
35354
+ * refusal for having asked first. Granting it here means whoever reaches the
35355
+ * front of the line gets it, and everybody else is still waiting rather than
35356
+ * refused.
35357
+ *
35358
+ * `null` as a RESULT means a bounded wait ran out OR a caller-supplied queue
35359
+ * cap refused a new waiter. `null` as `withinMs` means an ACCEPTED operation
35360
+ * is intentionally unbounded by time and will leave the queue only when its
35361
+ * turn arrives; it does not require accepting infinitely many operations.
35362
+ * `countsAsHumanActivity=false` is for
35363
+ * lifecycle barriers: they still serialize, but an idle deadline must not
35364
+ * mistake framework cleanup for evidence that the person is present.
35365
+ */
35366
+ async acquireActionSlot(withinMs = ACTION_HANDOFF_MS, countsAsHumanActivity = true, maxQueued = null) {
35367
+ if (!this.action && this.actionQueue.length === 0) {
35368
+ return this.beginAction(countsAsHumanActivity);
35369
+ }
35370
+ if (maxQueued !== null && this.actionQueue.length >= maxQueued) return null;
35371
+ return await new Promise((resolve2) => {
35372
+ let done = false;
35373
+ let timer;
35374
+ const wake = () => {
35375
+ if (done) return;
35376
+ done = true;
35377
+ if (timer) clearTimeout(timer);
35378
+ resolve2(this.beginAction(countsAsHumanActivity));
35379
+ };
35380
+ if (withinMs !== null) {
35381
+ timer = setTimeout(() => {
35382
+ if (done) return;
35383
+ done = true;
35384
+ const i = this.actionQueue.indexOf(wake);
35385
+ if (i >= 0) this.actionQueue.splice(i, 1);
35386
+ resolve2(null);
35387
+ }, withinMs);
35388
+ timer.unref?.();
35389
+ }
35390
+ this.actionQueue.push(wake);
35391
+ });
35149
35392
  }
35150
35393
  /**
35151
35394
  * Queue one actor, resolving when it gets the browser or is turned away.
@@ -35180,7 +35423,28 @@ var BrowserLease = class {
35180
35423
  };
35181
35424
 
35182
35425
  // src/browser/remote/controller.ts
35426
+ var KEYS = {
35427
+ Enter: { code: 13, text: "\r" },
35428
+ Tab: { code: 9 },
35429
+ Backspace: { code: 8 },
35430
+ Delete: { code: 46 },
35431
+ Escape: { code: 27 },
35432
+ ArrowUp: { code: 38 },
35433
+ ArrowDown: { code: 40 },
35434
+ ArrowLeft: { code: 37 },
35435
+ ArrowRight: { code: 39 },
35436
+ Home: { code: 36 },
35437
+ End: { code: 35 },
35438
+ PageUp: { code: 33 },
35439
+ PageDown: { code: 34 }
35440
+ };
35441
+ var BUTTONS = { left: 1, middle: 4, right: 2 };
35442
+ var SCREENCAST = { format: "jpeg", quality: 60, maxWidth: 1280, maxHeight: 800 };
35183
35443
  var ACTION_TIMEOUT_MS = 3e4;
35444
+ var RESTORE_FRAME_MS = 2e3;
35445
+ var MAX_HUMAN_INPUT_QUEUE = 64;
35446
+ var MAX_CAPTURE_TRANSITION_QUEUE = 16;
35447
+ var MAX_WATCH_LIFECYCLE_QUEUE = 16;
35184
35448
  var RemoteBrowserController = class {
35185
35449
  provider;
35186
35450
  maxSessions;
@@ -35193,6 +35457,17 @@ var RemoteBrowserController = class {
35193
35457
  * bearer capability for a browser somebody else is driving.
35194
35458
  */
35195
35459
  liveViewListeners = /* @__PURE__ */ new Map();
35460
+ /**
35461
+ * Told when a run's browser changes hands, or stops being pictured.
35462
+ *
35463
+ * Needed because a takeover can end without anybody asking. The hold lapses
35464
+ * on its own after `HUMAN_IDLE_MS`, and a client left believing it still has
35465
+ * control would keep sending input into a lease it no longer holds — every
35466
+ * event refused, with nothing on screen saying why.
35467
+ */
35468
+ controlListeners = /* @__PURE__ */ new Map();
35469
+ /** What each run's listener was last told, so unchanged states stay quiet. */
35470
+ controlAnnounced = /* @__PURE__ */ new Map();
35196
35471
  /** An embedder that wants every run's live view, told which run each is. */
35197
35472
  onLiveViewAll;
35198
35473
  runs = /* @__PURE__ */ new Map();
@@ -35202,7 +35477,7 @@ var RemoteBrowserController = class {
35202
35477
  * Counted alongside `runs` against the cap. Without it the limit was a
35203
35478
  * check-then-act across four awaits, and simultaneous first uses both won.
35204
35479
  */
35205
- opening = /* @__PURE__ */ new Set();
35480
+ opening = /* @__PURE__ */ new Map();
35206
35481
  /**
35207
35482
  * A run's stop signal, created when its slot is reserved.
35208
35483
  *
@@ -35235,7 +35510,7 @@ var RemoteBrowserController = class {
35235
35510
  * own teardown settles, so a later, genuine re-open of the same run id
35236
35511
  * never collides with one still in flight.
35237
35512
  */
35238
- closing = /* @__PURE__ */ new Set();
35513
+ closing = /* @__PURE__ */ new Map();
35239
35514
  /** Runs the user has stopped, by run id — same meaning as on the desktop. */
35240
35515
  revoked = /* @__PURE__ */ new Set();
35241
35516
  /**
@@ -35253,11 +35528,38 @@ var RemoteBrowserController = class {
35253
35528
  const asked = Math.max(1, options.maxSessions ?? 1);
35254
35529
  this.maxSessions = options.provider.isolatesSessions ? asked : 1;
35255
35530
  this.onLiveViewAll = options.onLiveView;
35531
+ this.ready = options.ready;
35256
35532
  }
35533
+ /**
35534
+ * The predecessor's teardown, until it has been waited for once.
35535
+ *
35536
+ * Cleared as soon as it settles so the wait is not re-entered on every open
35537
+ * — and so the promise itself is not retained for the life of the process.
35538
+ */
35539
+ ready;
35540
+ /**
35541
+ * This controller has been retired and will never open anything again.
35542
+ *
35543
+ * Stronger than "its runs were ended", and it has to be. A caller parked on
35544
+ * the `ready` gate above is invisible to `dispose()` — it holds no run and
35545
+ * has reserved no slot, because it has not got that far — so a SECOND
35546
+ * settings change, arriving while the first retirement is still settling,
35547
+ * found nothing to wait for and let the newest controller start immediately.
35548
+ * The parked caller then woke up on a controller nobody holds a reference to
35549
+ * and allocated a session against the superseded configuration, with the
35550
+ * newest controller free to allocate its own.
35551
+ *
35552
+ * Set synchronously at the top of `dispose`, so anything already waiting is
35553
+ * excluded the moment retirement begins rather than when it finishes.
35554
+ */
35555
+ retired = false;
35257
35556
  leaseFor(runId) {
35258
35557
  let lease = this.leases.get(runId);
35259
35558
  if (!lease) {
35260
- lease = new BrowserLease({ isRevoked: (id) => this.revoked.has(id) });
35559
+ lease = new BrowserLease({
35560
+ isRevoked: (id) => this.revoked.has(id),
35561
+ onChange: () => this.announceControl(runId)
35562
+ });
35261
35563
  lease.setLifecycleReleaseWired(true);
35262
35564
  this.leases.set(runId, lease);
35263
35565
  }
@@ -35274,6 +35576,44 @@ var RemoteBrowserController = class {
35274
35576
  offLiveViewFor(runKey) {
35275
35577
  this.liveViewListeners.delete(runKey);
35276
35578
  }
35579
+ onControlFor(runKey, listener) {
35580
+ this.controlListeners.set(runKey, listener);
35581
+ }
35582
+ offControlFor(runKey) {
35583
+ this.controlListeners.delete(runKey);
35584
+ this.controlAnnounced.delete(runKey);
35585
+ }
35586
+ /**
35587
+ * Say who has the browser, but only when the answer has changed.
35588
+ *
35589
+ * The lease reports every ownership change, and during ordinary work that is
35590
+ * one per action as workers take it and give it back. None of those are
35591
+ * interesting to a viewer — the agent had it before and has it now — and
35592
+ * emitting them would put a socket message on every click the agent makes.
35593
+ */
35594
+ announceControl(runId, force = false) {
35595
+ const listener = this.controlListeners.get(runId);
35596
+ if (!listener) return;
35597
+ const held = this.runs.get(runId);
35598
+ const state = {
35599
+ human: this.leases.get(runId)?.heldByHuman === true,
35600
+ capturing: held?.capturing === true,
35601
+ // A receipt vouches for a VIEW, so it stops meaning anything the moment
35602
+ // there is no view. `stopWatching` clears the session without bumping the
35603
+ // watch — deliberately, since replacing the consumer is what a new watch
35604
+ // is about — so the receipt stayed matched across the whole detach and
35605
+ // re-attach, which `beginWatching` performs on every reconnect and every
35606
+ // return to a conversation. Announced `confirmed` through that window,
35607
+ // the client mounted and FOCUSED the blind keyboard surface over a page
35608
+ // no session was serving: keystrokes meant for the composer went to a
35609
+ // remote page that could only refuse them.
35610
+ confirmed: held?.screencast !== void 0 && held.watchGen !== void 0 && held.seenGen === held.watchGen
35611
+ };
35612
+ const key = `${state.human}:${state.capturing}:${state.confirmed}`;
35613
+ if (!force && this.controlAnnounced.get(runId) === key) return;
35614
+ this.controlAnnounced.set(runId, key);
35615
+ listener(state);
35616
+ }
35277
35617
  /**
35278
35618
  * Tell a run's listener what its browser situation is.
35279
35619
  *
@@ -35294,22 +35634,681 @@ var RemoteBrowserController = class {
35294
35634
  * allocated and billed until the whole run ended, for a run that has just
35295
35635
  * been stopped.
35296
35636
  */
35297
- async releaseIfIdle(runId) {
35637
+ async releaseIfIdle(runId, releaseSlot) {
35298
35638
  const held = this.runs.get(runId);
35299
- if (!held) return;
35639
+ if (!held) {
35640
+ releaseSlot?.();
35641
+ return;
35642
+ }
35300
35643
  this.runs.delete(runId);
35301
- this.closing.add(runId);
35302
- await this.teardown(runId, held);
35644
+ const teardown = this.teardown(runId, held);
35645
+ releaseSlot?.();
35646
+ await teardown;
35303
35647
  }
35304
35648
  /** Release every run's session. For when the deployment's config changes. */
35305
35649
  async dispose() {
35306
- const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening]);
35650
+ this.retired = true;
35651
+ const opening = [...this.opening.values()];
35652
+ const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening.keys()]);
35307
35653
  await Promise.all([...ids].map((runId) => this.endRun(runId)));
35654
+ await Promise.all(opening.map((open) => open.catch(() => {
35655
+ })));
35656
+ await Promise.all([...this.closing.values()].map((done) => done.catch(() => {
35657
+ })));
35308
35658
  }
35309
35659
  /** A worker finished; it will never ask for the browser again. */
35310
35660
  actorEnded(actorId) {
35311
35661
  for (const lease of this.leases.values()) lease.actorEnded(actorId);
35312
35662
  }
35663
+ /**
35664
+ * Start streaming this run's page to whoever is watching it.
35665
+ *
35666
+ * Nothing streams until someone asks. That is the whole reason this is a
35667
+ * method rather than something `open()` does: an unwatched run would
35668
+ * otherwise pay to encode frames nobody sees, on every run, forever — and on
35669
+ * a metered provider it would pay for the bandwidth too.
35670
+ *
35671
+ * Frames are ACKED after they are handed on, never before. Chrome sends the
35672
+ * next frame only once the previous is acknowledged, so this bounds it to one
35673
+ * outstanding frame against OUR OWN handling. It does not reach further than
35674
+ * that, and an earlier version of this comment claimed it did: `onFrame` ends
35675
+ * at a socket emit, which hands the JPEG to a transport rather than to a
35676
+ * viewer, so the ack says nothing about what the person actually received.
35677
+ * Bounding the rest of the path is the transport's job — see `emitFrame`,
35678
+ * where frames go out lossy so a slow connection drops pictures instead of
35679
+ * banking stale ones. Dropping the ack stops the stream dead, which is why it
35680
+ * is best-effort but unconditional.
35681
+ *
35682
+ * Returns false when there is nothing to watch — the run has no browser, or
35683
+ * it is already being watched. Callers use that to avoid promising a viewer
35684
+ * a stream that will never arrive.
35685
+ */
35686
+ async startWatching(runId, onFrame) {
35687
+ const held = this.runs.get(runId);
35688
+ if (!held) return false;
35689
+ return await this.queueWatch(held, async () => {
35690
+ held.onFrame = onFrame;
35691
+ held.watchGen = (held.watchGen ?? 0) + 1;
35692
+ if (held.screencast) {
35693
+ this.announceControl(runId);
35694
+ return true;
35695
+ }
35696
+ const attached = await this.attachScreencast(runId, held, onFrame);
35697
+ if (!attached && held.onFrame === onFrame) held.onFrame = void 0;
35698
+ return attached !== null;
35699
+ });
35700
+ }
35701
+ /**
35702
+ * Run one watch or unwatch after every one already asked for.
35703
+ *
35704
+ * The chain is kept non-rejecting on purpose: a failed attach must not poison
35705
+ * every later request for this run, which is what a bare `.then` chain would
35706
+ * do. The caller still sees its own failure, because that is the promise
35707
+ * returned rather than the one stored.
35708
+ */
35709
+ queueWatch(held, op) {
35710
+ const depth = held.watchQueueDepth ?? 0;
35711
+ if (held.watchCollapsedPromise || depth >= MAX_WATCH_LIFECYCLE_QUEUE) {
35712
+ held.watchCollapsed = op;
35713
+ if (!held.watchCollapsedPromise) {
35714
+ const drain = async () => {
35715
+ let result;
35716
+ while (held.watchCollapsed) {
35717
+ const latest = held.watchCollapsed;
35718
+ held.watchCollapsed = void 0;
35719
+ try {
35720
+ result = await latest();
35721
+ } catch {
35722
+ }
35723
+ }
35724
+ return result;
35725
+ };
35726
+ const tail = (held.watchQueue ?? Promise.resolve()).then(drain, drain);
35727
+ let tracked;
35728
+ tracked = tail.finally(() => {
35729
+ if (held.watchCollapsedPromise === tracked) {
35730
+ held.watchCollapsedPromise = void 0;
35731
+ held.watchCollapsed = void 0;
35732
+ }
35733
+ });
35734
+ held.watchCollapsedPromise = tracked;
35735
+ held.watchQueue = tracked.catch(() => {
35736
+ });
35737
+ }
35738
+ return held.watchCollapsedPromise;
35739
+ }
35740
+ held.watchQueueDepth = depth + 1;
35741
+ const mine = (held.watchQueue ?? Promise.resolve()).then(op, op);
35742
+ const counted = mine.finally(() => {
35743
+ held.watchQueueDepth = Math.max(0, (held.watchQueueDepth ?? 1) - 1);
35744
+ });
35745
+ held.watchQueue = counted.catch(() => {
35746
+ });
35747
+ return counted;
35748
+ }
35749
+ /**
35750
+ * Attach at most one physical CDP screencast session at a time.
35751
+ *
35752
+ * `startWatching()` already serializes watcher lifecycle through `watchQueue`,
35753
+ * but the agent's detached-hidden restore cannot join that queue: it holds the
35754
+ * action slot, while an earlier unwatch may be waiting for exactly that slot.
35755
+ * Sharing only the physical attach closes the two-session race without making
35756
+ * watcher teardown and agent mutation wait on each other in a cycle.
35757
+ *
35758
+ * A real watcher claims `onFrame` immediately, before it waits for an attach
35759
+ * already started by the agent. The lower-level attach never writes the sink,
35760
+ * so finishing an older temporary attempt cannot overwrite a newer viewer.
35761
+ */
35762
+ async attachScreencast(runId, held, onFrame, popupDetachGen) {
35763
+ const consumerIntent = onFrame && popupDetachGen !== void 0 ? popupDetachGen : void 0;
35764
+ if (onFrame) held.onFrame = onFrame;
35765
+ if (held.screencast && held.screencastPage === held.page) return held.screencast;
35766
+ const targetPage = held.page;
35767
+ const existing = held.attaching;
35768
+ if (existing) {
35769
+ const existingPage = held.attachingPage;
35770
+ await existing.catch(() => null);
35771
+ if (held.page !== targetPage || existingPage !== targetPage) {
35772
+ return this.attachScreencast(runId, held, onFrame, popupDetachGen);
35773
+ }
35774
+ const cdp = held.screencastPage === targetPage ? held.screencast ?? null : null;
35775
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
35776
+ if (cdp && onFrame) held.onFrame = onFrame;
35777
+ return cdp;
35778
+ }
35779
+ if (held.screencast && held.screencastPage !== targetPage) {
35780
+ const stale = held.screencast;
35781
+ held.screencast = void 0;
35782
+ held.screencastPage = void 0;
35783
+ await stale.send("Page.stopScreencast").catch(() => {
35784
+ });
35785
+ await stale.detach().catch(() => {
35786
+ });
35787
+ }
35788
+ const attempt = this.attachScreencastFresh(runId, held, targetPage, consumerIntent);
35789
+ held.attaching = attempt;
35790
+ held.attachingPage = targetPage;
35791
+ try {
35792
+ const cdp = await attempt;
35793
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
35794
+ if (cdp && onFrame) held.onFrame = onFrame;
35795
+ return cdp;
35796
+ } finally {
35797
+ if (held.attaching === attempt) {
35798
+ held.attaching = void 0;
35799
+ held.attachingPage = void 0;
35800
+ }
35801
+ }
35802
+ }
35803
+ /** Open one new CDP session and start the stream when it is safe to do so. */
35804
+ async attachScreencastFresh(runId, held, targetPage, consumerIntent) {
35805
+ const safetyGate = held.hiddenByHolder === true || held.visibilityUnconfirmed === true;
35806
+ let cdp;
35807
+ try {
35808
+ cdp = await targetPage.context().newCDPSession(targetPage);
35809
+ } catch {
35810
+ if (!safetyGate) {
35811
+ held.visibilityUnconfirmed = false;
35812
+ held.capturing = true;
35813
+ this.announceControl(runId);
35814
+ }
35815
+ return null;
35816
+ }
35817
+ if (held.page !== targetPage) {
35818
+ await cdp.detach().catch(() => {
35819
+ });
35820
+ return null;
35821
+ }
35822
+ cdp.on("Page.screencastFrame", ((e) => {
35823
+ if (held.page === targetPage && typeof e?.data === "string") {
35824
+ const width = e.metadata?.deviceWidth ?? 0;
35825
+ const height = e.metadata?.deviceHeight ?? 0;
35826
+ held.metricsStale = true;
35827
+ if (held.visibilityUnconfirmed) {
35828
+ held.visibilityUnconfirmed = false;
35829
+ if (held.capturing !== true) {
35830
+ held.capturing = true;
35831
+ this.announceControl(runId);
35832
+ }
35833
+ }
35834
+ const ping = held.framePing;
35835
+ held.framePing = void 0;
35836
+ ping?.();
35837
+ const deliver = held.onFrame;
35838
+ if (deliver) {
35839
+ deliver({ data: e.data, width, height, generation: held.watchGen ?? 0 });
35840
+ }
35841
+ }
35842
+ void cdp.send("Page.screencastFrameAck", { sessionId: e?.sessionId }).catch(() => {
35843
+ });
35844
+ }));
35845
+ const paused = held.hiddenByHolder === true;
35846
+ if (!paused) {
35847
+ try {
35848
+ await cdp.send("Page.startScreencast", { ...SCREENCAST });
35849
+ } catch {
35850
+ if (!safetyGate) {
35851
+ held.visibilityUnconfirmed = false;
35852
+ held.capturing = true;
35853
+ this.announceControl(runId);
35854
+ } else {
35855
+ held.capturing = false;
35856
+ this.announceControl(runId);
35857
+ }
35858
+ await cdp.detach().catch(() => {
35859
+ });
35860
+ return null;
35861
+ }
35862
+ }
35863
+ if (held.page !== targetPage) {
35864
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
35865
+ });
35866
+ await cdp.detach().catch(() => {
35867
+ });
35868
+ return null;
35869
+ }
35870
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) {
35871
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
35872
+ });
35873
+ await cdp.detach().catch(() => {
35874
+ });
35875
+ return null;
35876
+ }
35877
+ held.screencast = cdp;
35878
+ held.screencastPage = targetPage;
35879
+ held.capturing = !paused;
35880
+ held.hiddenByHolder = paused;
35881
+ this.announceControl(runId);
35882
+ return cdp;
35883
+ }
35884
+ /** Nobody is watching any more: stop paying to render frames. */
35885
+ async stopWatching(runId) {
35886
+ const held = this.runs.get(runId);
35887
+ if (!held) return;
35888
+ held.watchDetachGen = (held.watchDetachGen ?? 0) + 1;
35889
+ held.onFrame = void 0;
35890
+ await this.queueWatch(held, async () => {
35891
+ const cdp = held.screencast;
35892
+ held.onFrame = void 0;
35893
+ if (!cdp) return;
35894
+ held.screencast = void 0;
35895
+ held.screencastPage = void 0;
35896
+ held.capturing = false;
35897
+ this.announceControl(runId);
35898
+ const lease = this.leases.get(runId);
35899
+ const token = lease ? await lease.acquireActionSlot(null, false) : null;
35900
+ if (lease && !token) return;
35901
+ try {
35902
+ await cdp.send("Page.stopScreencast").catch(() => {
35903
+ });
35904
+ await cdp.detach().catch(() => {
35905
+ });
35906
+ } finally {
35907
+ if (token && lease) lease.endAction(token);
35908
+ }
35909
+ });
35910
+ }
35911
+ /**
35912
+ * Suspend or resume the picture without giving up control.
35913
+ *
35914
+ * The credential case, and the only reason this is separate from watching. A
35915
+ * person who has taken the page over to sign in is about to type a password
35916
+ * into a field whose contents are echoed as dots — but a password manager's
35917
+ * dropdown, a "show password" toggle, a one-time code in plain text and the
35918
+ * confirmation page afterwards are not, and all of them would otherwise be
35919
+ * encoded into frames and sent across the network.
35920
+ *
35921
+ * What this does NOT do is hide anything from the agent: it stops the
35922
+ * picture, not the page. Nothing about a suspended capture would stop a
35923
+ * `extract_text` from reading the same screen — it is the lease that stops
35924
+ * that, by refusing the agent for as long as the person holds it.
35925
+ *
35926
+ * Answers whether frames are flowing afterwards, which is what a UI needs to
35927
+ * show and is not always what was asked for: a run with nobody watching has
35928
+ * no session to suspend.
35929
+ */
35930
+ async setCapture(runId, on) {
35931
+ const held = this.runs.get(runId);
35932
+ const cdp = held?.screencast;
35933
+ const watch = held?.watchGen;
35934
+ if (!held || !cdp) return false;
35935
+ const lease = this.leaseFor(runId);
35936
+ if (!on && !lease.heldByHuman) return held.capturing === true;
35937
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
35938
+ return held.capturing === true;
35939
+ }
35940
+ if (held.capturing === on) {
35941
+ if (lease.heldByHuman) lease.touch();
35942
+ return on;
35943
+ }
35944
+ const token = await lease.acquireActionSlot(null, true, MAX_CAPTURE_TRANSITION_QUEUE);
35945
+ if (!token) return held.capturing === true;
35946
+ try {
35947
+ if (!on && !lease.heldByHuman) return held.capturing === true;
35948
+ if (!on && (held.screencast !== cdp || held.watchGen !== watch)) {
35949
+ return held.capturing === true;
35950
+ }
35951
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
35952
+ return held.capturing === true;
35953
+ }
35954
+ const target = on ? held.screencast : cdp;
35955
+ if (!target) {
35956
+ if (on) {
35957
+ held.hiddenByHolder = false;
35958
+ if (lease.heldByHuman) lease.touch();
35959
+ }
35960
+ return held.capturing === true;
35961
+ }
35962
+ const now = await this.applyCapture(runId, held, target, on);
35963
+ if (lease.heldByHuman) lease.touch();
35964
+ return now;
35965
+ } finally {
35966
+ lease.endAction(token);
35967
+ }
35968
+ }
35969
+ /**
35970
+ * The capture transition itself, with no lease bookkeeping.
35971
+ *
35972
+ * Separate because the agent's restore in `act()` already holds the action
35973
+ * slot — taking it again there would deadlock against itself, and the whole
35974
+ * point of that call site is that it happens INSIDE the agent's turn.
35975
+ *
35976
+ * `confirmWithFrame` is used only by the agent's visibility gate. There, a
35977
+ * successful CDP command is not enough: the externally visible state remains
35978
+ * paused until a frame arrives, and the frame handler is the only code that
35979
+ * promotes it to capturing. This makes a restore timeout sticky rather than
35980
+ * a one-action refusal.
35981
+ */
35982
+ async applyCapture(runId, held, cdp, on, confirmWithFrame = false) {
35983
+ if (held.capturing === on && !(on && confirmWithFrame && held.visibilityUnconfirmed)) return on;
35984
+ const wasUnconfirmed = held.visibilityUnconfirmed === true;
35985
+ const wasCapturing = held.capturing === true;
35986
+ if (on) held.visibilityUnconfirmed = true;
35987
+ try {
35988
+ if (on) await cdp.send("Page.startScreencast", { ...SCREENCAST });
35989
+ else await cdp.send("Page.stopScreencast");
35990
+ if (on) {
35991
+ if (!confirmWithFrame) held.capturing = true;
35992
+ } else {
35993
+ held.visibilityUnconfirmed = false;
35994
+ held.capturing = false;
35995
+ }
35996
+ held.hiddenByHolder = !on;
35997
+ this.announceControl(runId);
35998
+ } catch {
35999
+ if (on) {
36000
+ held.visibilityUnconfirmed = wasUnconfirmed;
36001
+ held.capturing = wasCapturing;
36002
+ this.announceControl(runId);
36003
+ }
36004
+ return held.capturing === true && !held.visibilityUnconfirmed;
36005
+ }
36006
+ return on;
36007
+ }
36008
+ /**
36009
+ * Wait, briefly, for Chrome to produce a frame.
36010
+ *
36011
+ * `Page.startScreencast` resolves when the command is accepted, which is not
36012
+ * when a picture exists. The agent's visibility gate — "the page must be
36013
+ * VISIBLE before anything changes it" — was therefore satisfied by a request
36014
+ * rather than by a repaint, and the agent could click while the panel was
36015
+ * still showing the page from before it was hidden, or nothing at all.
36016
+ *
36017
+ * A frame rather than a client receipt, deliberately. A receipt would prove
36018
+ * somebody SAW it, which is stronger — and would make the agent's progress
36019
+ * depend on a viewer that may not exist: this path runs after a handback or a
36020
+ * lapse, when there may be nobody watching at all, and an agent that stalls
36021
+ * because a tab was closed is a worse failure than the one being fixed. What
36022
+ * this does guarantee is ours to guarantee: the stream is producing again.
36023
+ *
36024
+ * Bounded, and the bound rests on the property rounds 14 and 15 already rely
36025
+ * on — a screencast that has just been started emits its first frame at once,
36026
+ * whatever the page is doing. If one does not arrive, the picture genuinely
36027
+ * has not come back, and the caller refuses on the same trade it already
36028
+ * makes when the restore itself fails.
36029
+ */
36030
+ nextFrame(held, withinMs = RESTORE_FRAME_MS) {
36031
+ return new Promise((resolve2) => {
36032
+ const timer = setTimeout(() => {
36033
+ if (held.framePing === ping) held.framePing = void 0;
36034
+ resolve2(false);
36035
+ }, withinMs);
36036
+ timer.unref?.();
36037
+ const ping = () => {
36038
+ clearTimeout(timer);
36039
+ resolve2(true);
36040
+ };
36041
+ held.framePing = ping;
36042
+ });
36043
+ }
36044
+ /** Whether a person is driving this run's browser right now. */
36045
+ humanHolds(runId) {
36046
+ return this.leases.get(runId)?.heldByHuman === true;
36047
+ }
36048
+ /**
36049
+ * Hand this run's browser to the person watching it.
36050
+ *
36051
+ * The wait is the substance. An agent action already inside Playwright cannot
36052
+ * be interrupted — `click` waits for its element to become actionable and
36053
+ * then clicks, whatever anyone decided in the meantime — so a takeover that
36054
+ * returned immediately would put the person on a page an action was still
36055
+ * about to change. Bounded by the action ceiling, because that is the longest
36056
+ * an action can legitimately take; past it something is wedged, and saying so
36057
+ * is better than a spinner that never resolves.
36058
+ *
36059
+ * Idempotent for someone who already holds it: a second click of Take control
36060
+ * just says they are still there.
36061
+ */
36062
+ async takeOver(runId) {
36063
+ if (this.revoked.has(runId)) {
36064
+ return { ok: false, detail: "Browser control was stopped for this run." };
36065
+ }
36066
+ const requested = this.runs.get(runId);
36067
+ if (!requested) {
36068
+ return { ok: false, detail: "This run has no browser open." };
36069
+ }
36070
+ const requestedCdp = requested.screencast;
36071
+ const requestedWatch = requested.watchGen;
36072
+ if (!requestedCdp || requestedWatch === void 0 || !requested.onFrame) {
36073
+ return { ok: false, detail: "This browser is not being watched right now." };
36074
+ }
36075
+ const lease = this.leaseFor(runId);
36076
+ if (lease.heldByHuman) {
36077
+ lease.touch();
36078
+ this.announceControl(runId, true);
36079
+ return { ok: true };
36080
+ }
36081
+ const token = await lease.acquireActionSlot(ACTION_TIMEOUT_MS);
36082
+ if (!token) {
36083
+ return {
36084
+ ok: false,
36085
+ detail: "The agent is still finishing an action on this page. Try again in a moment."
36086
+ };
36087
+ }
36088
+ try {
36089
+ const held = this.runs.get(runId);
36090
+ if (this.revoked.has(runId)) {
36091
+ return { ok: false, detail: "Browser control was stopped for this run." };
36092
+ }
36093
+ if (!held || held.page.isClosed()) {
36094
+ return { ok: false, detail: "That browser closed while you were waiting for it." };
36095
+ }
36096
+ if (held.screencast !== requestedCdp || held.watchGen !== requestedWatch || !held.onFrame) {
36097
+ return { ok: false, detail: "The browser view changed while you were waiting. Take control again from the current view." };
36098
+ }
36099
+ lease.takeOver(runId);
36100
+ } finally {
36101
+ lease.endAction(token);
36102
+ }
36103
+ return { ok: true };
36104
+ }
36105
+ /** The person is done. Answers whether they were holding it at all. */
36106
+ /**
36107
+ * The viewer confirms it received a picture of this run's page.
36108
+ *
36109
+ * Reliable, one per watch, and the only thing that opens Hide. Checked
36110
+ * against the CURRENT watch: a receipt that names an older one is from a
36111
+ * viewer that has since been replaced, and vouching for its successor is the
36112
+ * reconnect hole this counter exists to close.
36113
+ */
36114
+ frameSeen(runId, generation) {
36115
+ const held = this.runs.get(runId);
36116
+ if (!held) return;
36117
+ if (typeof generation !== "number" || !Number.isFinite(generation)) return;
36118
+ if (held.watchGen === void 0 || generation !== held.watchGen) return;
36119
+ if (held.seenGen === generation) return;
36120
+ held.seenGen = generation;
36121
+ this.announceControl(runId);
36122
+ }
36123
+ handBack(runId) {
36124
+ return this.leases.get(runId)?.handBack() === true;
36125
+ }
36126
+ /**
36127
+ * One thing the person did to the page.
36128
+ *
36129
+ * Authorised per event, not per session. A takeover is not a channel that
36130
+ * stays open once opened: between two clicks the run can be stopped, the hold
36131
+ * can lapse, the page can close, and each of those has to stop the NEXT
36132
+ * event rather than be noticed at the end. So every event re-checks that this
36133
+ * run is live and that the lease is still the person's, and every accepted
36134
+ * event says so by extending the hold.
36135
+ */
36136
+ async input(runId, event) {
36137
+ const held = this.runs.get(runId);
36138
+ const cdp = held?.screencast;
36139
+ const watch = held?.watchGen;
36140
+ if (!held || held.page.isClosed()) return { ok: false, detail: "This run has no browser open." };
36141
+ const page2 = held.page;
36142
+ const pageGeneration = held.generation;
36143
+ if (!cdp) return { ok: false, detail: "Nothing is streaming this browser, so it cannot be driven." };
36144
+ const lease = this.leaseFor(runId);
36145
+ if (!lease.heldByHuman) {
36146
+ return { ok: false, detail: "You do not have control of this browser. Take control first." };
36147
+ }
36148
+ if (held.capturing !== true && (held.watchGen === void 0 || held.seenGen !== held.watchGen)) {
36149
+ return { ok: false, detail: "Show the page before typing into it \u2014 this view has not seen it yet." };
36150
+ }
36151
+ const sampled = event.kind === "move";
36152
+ const token = sampled ? lease.tryActionSlot() : await lease.acquireActionSlot(null, true, MAX_HUMAN_INPUT_QUEUE);
36153
+ if (!token) {
36154
+ if (sampled) return { ok: true };
36155
+ return { ok: false, detail: "Too many browser inputs are already waiting. Let the page catch up, then try again." };
36156
+ }
36157
+ try {
36158
+ if (!lease.heldByHuman) {
36159
+ return { ok: false, detail: "Your control of this browser ended while that was waiting." };
36160
+ }
36161
+ if (held.page !== page2 || held.generation !== pageGeneration) {
36162
+ return { ok: false, detail: "The page changed while that input was waiting, so it did not land." };
36163
+ }
36164
+ if (held.screencast !== cdp || held.watchGen !== watch) {
36165
+ return { ok: false, detail: "The view you were driving is gone, so that did not land." };
36166
+ }
36167
+ const pagesBefore = new Set(held.page.context().pages());
36168
+ await this.dispatch(cdp, held, event);
36169
+ await this.followOpenedPage(runId, held, pagesBefore);
36170
+ } catch (err) {
36171
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
36172
+ } finally {
36173
+ lease.endAction(token);
36174
+ }
36175
+ lease.touch();
36176
+ return { ok: true };
36177
+ }
36178
+ /**
36179
+ * The page's own coordinate space, measured rather than inferred.
36180
+ *
36181
+ * Cached, because a click should not cost a round trip, and invalidated by
36182
+ * the frame metadata changing shape — which arrives free with every frame. A
36183
+ * failed measurement falls back to the last good one rather than to a guess:
36184
+ * a stale viewport is off by a resize, an invented one is off by anything.
36185
+ */
36186
+ async pageViewport(held, cdp) {
36187
+ if (held.viewport && !held.metricsStale) return held.viewport;
36188
+ try {
36189
+ const metrics = await cdp.send("Page.getLayoutMetrics");
36190
+ const width = metrics?.cssVisualViewport?.clientWidth;
36191
+ const height = metrics?.cssVisualViewport?.clientHeight;
36192
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
36193
+ held.viewport = { width, height };
36194
+ held.metricsStale = false;
36195
+ return held.viewport;
36196
+ }
36197
+ } catch {
36198
+ }
36199
+ return null;
36200
+ }
36201
+ /**
36202
+ * Turn one authorised event into CDP calls. Coordinates arrive normalised.
36203
+ *
36204
+ * A switch with a default that REFUSES, rather than a chain of ifs ending in
36205
+ * the click branch. This is a mutation boundary reached from a socket, so the
36206
+ * question is not what a well-formed event does but what a malformed one
36207
+ * does: the chain answered "clicks", which meant a version-skewed client
36208
+ * sending `{ kind: 'drag', x, y }` pressed the mouse on a real page, and a
36209
+ * kind with no coordinates at all clamped to 0 and clicked the top-left
36210
+ * corner. Unknown input has to fail closed.
36211
+ *
36212
+ * The `never` in the default is the other half: adding a member to
36213
+ * `BrowserInput` without handling it here stops compiling, so the next kind
36214
+ * cannot silently inherit whatever the last branch happened to be.
36215
+ */
36216
+ async dispatch(cdp, held, event) {
36217
+ switch (event.kind) {
36218
+ case "text": {
36219
+ if (typeof event.text !== "string") {
36220
+ throw new Error("That is not text that can be typed into the page.");
36221
+ }
36222
+ const text = event.text.slice(0, 4e3);
36223
+ if (!text) return;
36224
+ await cdp.send("Input.insertText", { text });
36225
+ return;
36226
+ }
36227
+ case "key": {
36228
+ const modifiers = event.modifiers;
36229
+ if (modifiers !== void 0) {
36230
+ if (!Array.isArray(modifiers) || modifiers.some((m) => m !== "Control" && m !== "Meta" && m !== "Shift") || new Set(modifiers).size !== modifiers.length) {
36231
+ throw new Error("That editing shortcut has invalid modifiers.");
36232
+ }
36233
+ const primary = modifiers.filter((m) => m === "Control" || m === "Meta");
36234
+ const shifted = modifiers.includes("Shift");
36235
+ if (primary.length !== 1) {
36236
+ throw new Error("That editing shortcut cannot be sent to the page.");
36237
+ }
36238
+ const key = typeof event.key === "string" ? event.key : "";
36239
+ const lower = key.toLowerCase();
36240
+ 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;
36241
+ if (!shortcut) {
36242
+ throw new Error(`That editing shortcut is not supported for the remote page: ${key}`);
36243
+ }
36244
+ const modifierBits = (primary[0] === "Control" ? 2 : 4) | (shifted ? 8 : 0);
36245
+ const base2 = {
36246
+ key: shortcut.key,
36247
+ code: shortcut.code,
36248
+ windowsVirtualKeyCode: shortcut.vk,
36249
+ nativeVirtualKeyCode: shortcut.vk,
36250
+ modifiers: modifierBits
36251
+ };
36252
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "rawKeyDown" });
36253
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "keyUp" });
36254
+ return;
36255
+ }
36256
+ const spec = typeof event.key === "string" && Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : void 0;
36257
+ if (!spec) throw new Error(`That key cannot be sent to the page: ${String(event.key)}`);
36258
+ const base = {
36259
+ key: event.key,
36260
+ code: event.key,
36261
+ windowsVirtualKeyCode: spec.code,
36262
+ nativeVirtualKeyCode: spec.code,
36263
+ ...spec.text ? { text: spec.text } : {}
36264
+ };
36265
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: spec.text ? "keyDown" : "rawKeyDown" });
36266
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" });
36267
+ return;
36268
+ }
36269
+ case "move":
36270
+ case "scroll":
36271
+ case "click": {
36272
+ const view = await this.pageViewport(held, cdp);
36273
+ if (!view) throw new Error("The page has not been measured yet, so a click cannot be placed on it.");
36274
+ const x = coordinate(event.x, "horizontal position") * view.width;
36275
+ const y = coordinate(event.y, "vertical position") * view.height;
36276
+ if (event.kind === "move") {
36277
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
36278
+ return;
36279
+ }
36280
+ if (event.kind === "scroll") {
36281
+ await cdp.send("Input.dispatchMouseEvent", {
36282
+ type: "mouseWheel",
36283
+ x,
36284
+ y,
36285
+ button: "none",
36286
+ buttons: 0,
36287
+ deltaX: 0,
36288
+ deltaY: scrollDelta(event.deltaY)
36289
+ });
36290
+ return;
36291
+ }
36292
+ if (event.button !== void 0 && !Object.hasOwn(BUTTONS, event.button)) {
36293
+ throw new Error(`That is not a mouse button: ${String(event.button)}`);
36294
+ }
36295
+ const button = event.button ?? "left";
36296
+ const buttons = BUTTONS[button];
36297
+ if (event.clicks !== void 0 && (typeof event.clicks !== "number" || !Number.isFinite(event.clicks))) {
36298
+ throw new Error("That is not a number of clicks.");
36299
+ }
36300
+ const clickCount = Math.min(Math.max(Math.trunc(event.clicks ?? 1), 1), 3);
36301
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
36302
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, buttons, clickCount });
36303
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, buttons: 0, clickCount });
36304
+ return;
36305
+ }
36306
+ default: {
36307
+ const unknown = event;
36308
+ throw new Error(`That is not something that can be done to the page: ${unknown?.kind ?? "an event with no kind"}`);
36309
+ }
36310
+ }
36311
+ }
35313
36312
  /** The user stopped this run. Refuses further actions and clears its queue. */
35314
36313
  stopRun(runId) {
35315
36314
  this.revoked.add(runId);
@@ -35318,7 +36317,6 @@ var RemoteBrowserController = class {
35318
36317
  const held = this.runs.get(runId);
35319
36318
  if (!held) return;
35320
36319
  this.runs.delete(runId);
35321
- this.closing.add(runId);
35322
36320
  void this.teardown(runId, held);
35323
36321
  }
35324
36322
  /** A run ended: release its browser rather than pay for an idle session. */
@@ -35331,7 +36329,6 @@ var RemoteBrowserController = class {
35331
36329
  return;
35332
36330
  }
35333
36331
  this.runs.delete(runId);
35334
- this.closing.add(runId);
35335
36332
  await this.teardown(runId, held);
35336
36333
  this.forgetRun(runId);
35337
36334
  }
@@ -35341,6 +36338,7 @@ var RemoteBrowserController = class {
35341
36338
  this.leases.delete(runId);
35342
36339
  this.aborts.delete(runId);
35343
36340
  this.liveViewListeners.delete(runId);
36341
+ this.offControlFor(runId);
35344
36342
  }
35345
36343
  /**
35346
36344
  * Let go of one run's browser.
@@ -35352,6 +36350,17 @@ var RemoteBrowserController = class {
35352
36350
  async disposeRun(runId, held) {
35353
36351
  held.abort.abort();
35354
36352
  this.announceLiveView(runId, void 0, false);
36353
+ await held.watchQueue?.catch(() => {
36354
+ });
36355
+ const watching = held.screencast;
36356
+ held.screencast = void 0;
36357
+ held.screencastPage = void 0;
36358
+ if (watching) {
36359
+ await watching.send("Page.stopScreencast").catch(() => {
36360
+ });
36361
+ await watching.detach().catch(() => {
36362
+ });
36363
+ }
35355
36364
  await held.ownedContext?.close().catch(() => {
35356
36365
  });
35357
36366
  await held.browser.close().catch(() => {
@@ -35367,12 +36376,12 @@ var RemoteBrowserController = class {
35367
36376
  * guarantees the removal happens, success or failure, so a disposal that
35368
36377
  * throws cannot leave the slot counted against the pool forever.
35369
36378
  */
35370
- async teardown(runId, held) {
35371
- try {
35372
- await this.disposeRun(runId, held);
35373
- } finally {
36379
+ teardown(runId, held) {
36380
+ const done = this.disposeRun(runId, held).finally(() => {
35374
36381
  this.closing.delete(runId);
35375
- }
36382
+ });
36383
+ this.closing.set(runId, done);
36384
+ return done;
35376
36385
  }
35377
36386
  async act(action, context) {
35378
36387
  const runId = context.sessionId;
@@ -35386,7 +36395,9 @@ var RemoteBrowserController = class {
35386
36395
  if (context.signal?.aborted) {
35387
36396
  return { ok: false, detail: "The run was cancelled." };
35388
36397
  }
35389
- const planned = this.runs.get(runId)?.generation ?? 0;
36398
+ const plannedRun = this.runs.get(runId);
36399
+ const planned = plannedRun?.generation ?? 0;
36400
+ const recoveringClosedPage = plannedRun?.page.isClosed() === true;
35390
36401
  const lease_ = this.leaseFor(runId);
35391
36402
  const lease = await lease_.acquire(actor, runId, context.signal);
35392
36403
  if (lease !== "granted") {
@@ -35400,28 +36411,96 @@ var RemoteBrowserController = class {
35400
36411
  lease_.releaseIfHeldBy(actor);
35401
36412
  return { ok: false, detail: "The run was cancelled." };
35402
36413
  }
35403
- if (!await lease_.awaitActionSlot()) {
36414
+ const token = await lease_.acquireActionSlot();
36415
+ if (!token) {
35404
36416
  lease_.releaseIfHeldBy(actor);
35405
36417
  return { ok: false, detail: "Another browser action is still finishing. Try again." };
35406
36418
  }
35407
- const token = lease_.beginAction();
35408
- if (!token) {
35409
- return { ok: false, detail: "Another browser action is already running. Wait for it to finish." };
36419
+ if (!lease_.heldBy(actor)) {
36420
+ lease_.endAction(token);
36421
+ return { ok: false, detail: refusal(lease_.heldByHuman ? "human" : "off") };
35410
36422
  }
36423
+ let temporaryCapture;
35411
36424
  try {
35412
36425
  const held = await this.open(runId, context.signal);
35413
36426
  if (this.revoked.has(runId)) {
35414
- await this.releaseIfIdle(runId);
36427
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
35415
36428
  return { ok: false, detail: "The user stopped browser control for this run." };
35416
36429
  }
35417
36430
  if (context.signal?.aborted || held.abort.signal.aborted) {
35418
- await this.releaseIfIdle(runId);
36431
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
35419
36432
  return { ok: false, detail: "The run was cancelled." };
35420
36433
  }
35421
- return await this.perform(held, action, planned);
36434
+ if ((held.hiddenByHolder === true || held.visibilityUnconfirmed === true) && !held.screencast) {
36435
+ const watchGen = held.watchGen;
36436
+ let cdp = null;
36437
+ try {
36438
+ cdp = await this.attachScreencast(runId, held);
36439
+ } catch {
36440
+ }
36441
+ if (!cdp) {
36442
+ return {
36443
+ ok: false,
36444
+ detail: "The page could not be shown again, so nothing was done to it."
36445
+ };
36446
+ }
36447
+ if (held.onFrame === void 0 && held.watchGen === watchGen) {
36448
+ temporaryCapture = { held, cdp, watchGen };
36449
+ }
36450
+ }
36451
+ if (held.screencast && (held.capturing === false || held.visibilityUnconfirmed === true)) {
36452
+ const cdp = held.screencast;
36453
+ if (held.capturing === true) {
36454
+ await cdp.send("Page.stopScreencast").catch(() => {
36455
+ });
36456
+ held.capturing = false;
36457
+ this.announceControl(runId);
36458
+ }
36459
+ const drawn = this.nextFrame(held);
36460
+ if (!await this.applyCapture(runId, held, cdp, true, true)) {
36461
+ return {
36462
+ ok: false,
36463
+ detail: "The page could not be shown again, so nothing was done to it."
36464
+ };
36465
+ }
36466
+ if (!await drawn) {
36467
+ held.visibilityUnconfirmed = true;
36468
+ held.capturing = false;
36469
+ await cdp.send("Page.stopScreencast").catch(() => {
36470
+ });
36471
+ this.announceControl(runId);
36472
+ return {
36473
+ ok: false,
36474
+ detail: "The page did not come back on screen, so nothing was done to it."
36475
+ };
36476
+ }
36477
+ if (this.revoked.has(runId)) {
36478
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36479
+ return { ok: false, detail: "The user stopped browser control for this run." };
36480
+ }
36481
+ if (context.signal?.aborted || held.abort.signal.aborted) {
36482
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36483
+ return { ok: false, detail: "The run was cancelled." };
36484
+ }
36485
+ }
36486
+ return await this.perform(runId, held, action, recoveringClosedPage ? held.generation : planned);
35422
36487
  } catch (err) {
35423
36488
  return { ok: false, detail: err instanceof Error ? err.message : String(err) };
35424
36489
  } finally {
36490
+ if (temporaryCapture) {
36491
+ const { held, cdp, watchGen } = temporaryCapture;
36492
+ if (held.screencast === cdp && held.onFrame === void 0 && held.watchGen === watchGen && held.hiddenByHolder === false && held.visibilityUnconfirmed === false && held.capturing === true) {
36493
+ held.onFrame = void 0;
36494
+ held.screencast = void 0;
36495
+ held.screencastPage = void 0;
36496
+ held.capturing = true;
36497
+ this.announceControl(runId);
36498
+ await cdp.send("Page.stopScreencast").catch(() => {
36499
+ });
36500
+ await cdp.detach().catch(() => {
36501
+ });
36502
+ }
36503
+ }
35425
36504
  lease_.endAction(token);
35426
36505
  if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
35427
36506
  }
@@ -35437,22 +36516,40 @@ var RemoteBrowserController = class {
35437
36516
  * it out changed no test. Two copies of a cap invite them to disagree.
35438
36517
  */
35439
36518
  async open(runId, signal) {
36519
+ if (this.ready) {
36520
+ const settling = this.ready;
36521
+ try {
36522
+ await settling;
36523
+ } catch {
36524
+ }
36525
+ if (this.ready === settling) this.ready = void 0;
36526
+ }
35440
36527
  const existing = this.runs.get(runId);
35441
36528
  if (existing && !existing.page.isClosed()) return existing;
36529
+ if (existing && existing.page.isClosed()) {
36530
+ const fallback = existing.page.context().pages().filter((p) => !p.isClosed()).at(-1);
36531
+ if (fallback) {
36532
+ await this.adoptPage(runId, existing, fallback);
36533
+ return existing;
36534
+ }
36535
+ }
35442
36536
  if (existing) {
35443
36537
  this.runs.delete(runId);
35444
- this.closing.add(runId);
35445
36538
  await this.teardown(runId, existing);
35446
36539
  }
36540
+ if (this.retired) {
36541
+ throw new Error("The browser settings changed while this action was waiting. Try again.");
36542
+ }
35447
36543
  if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
35448
36544
  throw new Error(
35449
36545
  `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.`
35450
36546
  );
35451
36547
  }
35452
- this.opening.add(runId);
35453
36548
  if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
36549
+ const opening = this.openReserved(runId, signal);
36550
+ this.opening.set(runId, opening);
35454
36551
  try {
35455
- return await this.openReserved(runId, signal);
36552
+ return await opening;
35456
36553
  } finally {
35457
36554
  this.opening.delete(runId);
35458
36555
  }
@@ -35463,9 +36560,9 @@ var RemoteBrowserController = class {
35463
36560
  const abort = this.aborts.get(runId)?.signal;
35464
36561
  const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
35465
36562
  const session = await this.provider.createSession(creationSignal);
35466
- this.announceLiveView(runId, session.liveViewUrl, true);
35467
36563
  let browser;
35468
36564
  let owned;
36565
+ let held;
35469
36566
  try {
35470
36567
  browser = await playwright.chromium.connectOverCDP(session.cdpUrl);
35471
36568
  owned = this.provider.isolatesSessions ? void 0 : await browser.newContext();
@@ -35473,15 +36570,10 @@ var RemoteBrowserController = class {
35473
36570
  const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
35474
36571
  const abort2 = this.aborts.get(runId);
35475
36572
  if (!abort2) throw new Error("The run ended while its browser was still opening.");
35476
- const held = { session, browser, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
35477
- page2.on("framenavigated", ((frame) => {
35478
- if (frame !== page2.mainFrame()) return;
35479
- held.generation += 1;
35480
- }));
36573
+ held = { session, browser, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
36574
+ this.trackPageNavigation(runId, held, page2);
35481
36575
  this.runs.set(runId, held);
35482
- return held;
35483
36576
  } catch (err) {
35484
- this.announceLiveView(runId, void 0, false);
35485
36577
  await owned?.close().catch(() => {
35486
36578
  });
35487
36579
  await browser?.close().catch(() => {
@@ -35490,8 +36582,74 @@ var RemoteBrowserController = class {
35490
36582
  });
35491
36583
  throw err;
35492
36584
  }
36585
+ this.announceLiveView(runId, session.liveViewUrl, true);
36586
+ return held;
36587
+ }
36588
+ /** Count main-frame navigation only while this page is the run's active page. */
36589
+ trackPageNavigation(runId, held, page2) {
36590
+ page2.on("framenavigated", ((frame) => {
36591
+ if (held.page !== page2 || frame !== page2.mainFrame()) return;
36592
+ held.generation += 1;
36593
+ if (held.onFrame && held.watchGen !== void 0) {
36594
+ held.watchGen += 1;
36595
+ this.announceControl(runId);
36596
+ }
36597
+ }));
36598
+ }
36599
+ /**
36600
+ * Follow a page opened by the action that just finished.
36601
+ *
36602
+ * A popup/new tab is part of the same user gesture (OAuth is the common
36603
+ * example). Keeping `held.page` and the screencast on its opener makes the
36604
+ * new page both invisible and impossible to drive. This runs while the SAME
36605
+ * action slot is still held, after the opening click/key has fully landed, so
36606
+ * no later human/agent event can slip between detecting the popup and moving
36607
+ * the view to it.
36608
+ */
36609
+ async followOpenedPage(runId, held, before) {
36610
+ if (this.runs.get(runId) !== held || held.abort.signal.aborted) return;
36611
+ const candidates = held.page.context().pages().filter((p) => !p.isClosed() && !before.has(p));
36612
+ const next = candidates.at(-1);
36613
+ if (!next || next === held.page) return;
36614
+ await this.adoptPage(runId, held, next);
36615
+ }
36616
+ /**
36617
+ * Make one live page the run's controlled/viewed page. Used in both
36618
+ * directions: into a popup opened by an action, and back to another surviving
36619
+ * page when that popup closes itself after OAuth/login completion.
36620
+ */
36621
+ async adoptPage(runId, held, next) {
36622
+ if (next === held.page || next.isClosed()) return;
36623
+ held.page = next;
36624
+ held.generation += 1;
36625
+ held.viewport = void 0;
36626
+ held.metricsStale = true;
36627
+ this.trackPageNavigation(runId, held, next);
36628
+ const deliver = held.onFrame;
36629
+ if (deliver) {
36630
+ held.watchGen = (held.watchGen ?? 0) + 1;
36631
+ held.visibilityUnconfirmed = true;
36632
+ held.capturing = false;
36633
+ this.announceControl(runId);
36634
+ await this.attachScreencast(runId, held, deliver, held.watchDetachGen ?? 0);
36635
+ return;
36636
+ }
36637
+ const pending = held.attaching;
36638
+ if (pending) await pending.catch(() => null);
36639
+ if (held.screencast && held.screencastPage !== next) {
36640
+ const stale = held.screencast;
36641
+ held.screencast = void 0;
36642
+ held.screencastPage = void 0;
36643
+ await stale.send("Page.stopScreencast").catch(() => {
36644
+ });
36645
+ await stale.detach().catch(() => {
36646
+ });
36647
+ }
36648
+ held.visibilityUnconfirmed = false;
36649
+ held.capturing = held.hiddenByHolder !== true;
36650
+ this.announceControl(runId);
35493
36651
  }
35494
- async perform(held, action, planned) {
36652
+ async perform(runId, held, action, planned) {
35495
36653
  const { page: page2 } = held;
35496
36654
  const stoppable = async (work) => {
35497
36655
  if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
@@ -35513,10 +36671,10 @@ var RemoteBrowserController = class {
35513
36671
  }
35514
36672
  };
35515
36673
  const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
35516
- const where = async () => ({
35517
- url: page2.url(),
35518
- title: await page2.title().catch(() => "")
35519
- });
36674
+ const where = async () => {
36675
+ const current = held.page;
36676
+ return { url: current.url(), title: await current.title().catch(() => "") };
36677
+ };
35520
36678
  if (action.kind !== "navigate" && held.generation !== planned) {
35521
36679
  return {
35522
36680
  ok: false,
@@ -35533,15 +36691,20 @@ var RemoteBrowserController = class {
35533
36691
  await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
35534
36692
  return { ok: true, detail: `Opened ${target}`, ...await where() };
35535
36693
  }
35536
- case "click":
36694
+ case "click": {
36695
+ const pagesBefore = new Set(page2.context().pages());
35537
36696
  await stoppable(page2.click(action.selector, { timeout }));
36697
+ await this.followOpenedPage(runId, held, pagesBefore);
35538
36698
  return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
36699
+ }
35539
36700
  case "fill":
35540
36701
  await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
35541
36702
  return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
35542
36703
  case "press": {
36704
+ const pagesBefore = new Set(page2.context().pages());
35543
36705
  if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
35544
36706
  else await stoppable(page2.keyboard.press(action.key));
36707
+ await this.followOpenedPage(runId, held, pagesBefore);
35545
36708
  return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
35546
36709
  }
35547
36710
  case "wait_for":
@@ -35557,10 +36720,25 @@ var RemoteBrowserController = class {
35557
36720
  }
35558
36721
  };
35559
36722
  function refusal(result) {
36723
+ if (result === "human") {
36724
+ return "The user has taken control of this browser. Stop using it and tell them what you were about to do.";
36725
+ }
35560
36726
  if (result === "off") return "Browser control was turned off while this action waited for the browser.";
35561
36727
  if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
35562
36728
  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.";
35563
36729
  }
36730
+ function coordinate(n, what) {
36731
+ if (typeof n !== "number" || !Number.isFinite(n)) {
36732
+ throw new Error(`That ${what} is not a position on the page.`);
36733
+ }
36734
+ return Math.min(Math.max(n, 0), 1);
36735
+ }
36736
+ function scrollDelta(n) {
36737
+ if (typeof n !== "number" || !Number.isFinite(n)) {
36738
+ throw new Error("That scroll has no distance to travel.");
36739
+ }
36740
+ return Math.min(Math.max(n, -2e3), 2e3);
36741
+ }
35564
36742
  async function loadPlaywright() {
35565
36743
  try {
35566
36744
  return await import('playwright');