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.
@@ -312562,7 +312562,7 @@ function reconcilePrice(model, live) {
312562
312562
  }
312563
312563
 
312564
312564
  // src/constants.ts
312565
- var CASCADE_VERSION = "0.79.0";
312565
+ var CASCADE_VERSION = "0.80.0";
312566
312566
  var CASCADE_CONFIG_DIR = ".cascade";
312567
312567
  var CASCADE_MD_FILE = "CASCADE.md";
312568
312568
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -402934,11 +402934,13 @@ async function discoverMcpTools(servers) {
402934
402934
  }
402935
402935
 
402936
402936
  // src/browser/lease.ts
402937
+ var HUMAN_ACTOR = "__human__";
402938
+ var HUMAN_IDLE_MS = 12e4;
402937
402939
  var LEASE_DEADLOCK_CEILING_MS = 3e5;
402938
402940
  var QUEUE_WAIT_MS = 18e4;
402939
402941
  var MAX_QUEUED = 8;
402940
402942
  var ACTION_HANDOFF_MS = 2e3;
402941
- var ACTION_HANDOFF_POLL_MS = 25;
402943
+ var HANDBACK_RETRY_MS = 25;
402942
402944
  var BrowserLease = class {
402943
402945
  actor = null;
402944
402946
  session = null;
@@ -402957,6 +402959,51 @@ var BrowserLease = class {
402957
402959
  * took the slot may release it.
402958
402960
  */
402959
402961
  action = null;
402962
+ /** Whether the current action slot represents activity by its holder. */
402963
+ actionCountsAsHumanActivity = false;
402964
+ /**
402965
+ * The human action that has already consumed its one idle-deadline grace.
402966
+ * A single wedged CDP request must not look like fresh activity every two
402967
+ * minutes forever. The first deadline may yield to an event that began before
402968
+ * it; a second deadline for that SAME event becomes a pending lapse instead.
402969
+ */
402970
+ idleGraceAction = null;
402971
+ /** Release the human hold as soon as this overlong action finally unwinds. */
402972
+ idleLapseAfterAction = null;
402973
+ /**
402974
+ * Everyone waiting for that slot, IN THE ORDER THEY ASKED.
402975
+ *
402976
+ * A queue rather than a poll, and the ordering is the whole reason for it.
402977
+ * `acquireActionSlot` used to spin on a 25ms timer, which decides nothing
402978
+ * about who goes first: each waiter checks on its own phase, so the one that
402979
+ * asked SECOND can find the slot free a full poll ahead of the one that asked
402980
+ * first. The person types one event per keypress — `{ kind: 'text', text }`
402981
+ * carries a single character — so that reordering is not a theoretical
402982
+ * unfairness, it is "passowrd" appearing in the box.
402983
+ *
402984
+ * The slot is handed straight from `endAction` to the head of this line while
402985
+ * still synchronous, so nothing can slip in between the two, and a waiter
402986
+ * that reaches the front never has to be told the slot is already taken.
402987
+ */
402988
+ actionQueue = [];
402989
+ /** Lapses a human hold nobody is using. See `HUMAN_IDLE_MS`. */
402990
+ humanTimer = null;
402991
+ /**
402992
+ * The person ASKED for the hold to end and it could not end yet.
402993
+ *
402994
+ * Kept as a request rather than only a retry timer, because the retry shares
402995
+ * `humanTimer` with the idle deadline: an authorised event settling after the
402996
+ * request would otherwise call `touch()`, re-arm that timer for a full idle
402997
+ * interval, and quietly cancel a handback the person had already asked for.
402998
+ * "Give it back" is not undone by activity that was already under way when
402999
+ * they said it.
403000
+ *
403001
+ * Explicit requests ONLY. An idle expiry must never set this: a deadline that
403002
+ * lands on the person's own in-flight event is evidence they were THERE, and
403003
+ * recording it here made `touch()` bail afterwards, so the hold dropped the
403004
+ * instant a fresh interaction finished. See `lapse()`.
403005
+ */
403006
+ handBackWanted = false;
402960
403007
  isRevoked;
402961
403008
  onChange;
402962
403009
  constructor(options = {}) {
@@ -402967,6 +403014,20 @@ var BrowserLease = class {
402967
403014
  get holderActor() {
402968
403015
  return this.actor;
402969
403016
  }
403017
+ /** Whether a person, rather than the agent, is driving the page right now. */
403018
+ get heldByHuman() {
403019
+ return this.actor === HUMAN_ACTOR;
403020
+ }
403021
+ /**
403022
+ * Whether THIS actor is the one holding the browser right now.
403023
+ *
403024
+ * For asking again after a wait. `acquire` is re-entrant for the holder, so
403025
+ * "I was granted the lease" is a fact about the moment it was granted and
403026
+ * says nothing about the moment the caller finally gets to act.
403027
+ */
403028
+ heldBy(actorId) {
403029
+ return this.actor === actorId;
403030
+ }
402970
403031
  get holderSession() {
402971
403032
  return this.session;
402972
403033
  }
@@ -402993,6 +403054,7 @@ var BrowserLease = class {
402993
403054
  * the same worker coming back for its next step must not queue behind itself.
402994
403055
  */
402995
403056
  async acquire(actorId, sessionId, signal) {
403057
+ if (this.actor === HUMAN_ACTOR && actorId !== HUMAN_ACTOR) return "human";
402996
403058
  if (this.actor === null || this.actor === actorId) {
402997
403059
  this.actor = actorId;
402998
403060
  this.session = sessionId;
@@ -403033,6 +403095,105 @@ var BrowserLease = class {
403033
403095
  if (this.action) return;
403034
403096
  if (this.actor === actorId) this.release();
403035
403097
  }
403098
+ /**
403099
+ * Give the browser to the person watching it.
403100
+ *
403101
+ * Callers must have waited the in-flight action out first — see
403102
+ * `acquireActionSlot`. This does not interrupt one, because it cannot: a
403103
+ * Playwright call already inside the page finishes whatever it started, and
403104
+ * taking the lease off it would only mean the person and a still-running
403105
+ * `fill` were both touching the same form. Waiting is what makes "you have
403106
+ * control" true when the UI says it.
403107
+ *
403108
+ * Every queued worker is turned away rather than left in line, for the same
403109
+ * reason `acquire` refuses: they were planned against a page the person is
403110
+ * about to change.
403111
+ */
403112
+ takeOver(sessionId) {
403113
+ for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) this.waiting[i4].settle("human");
403114
+ this.clearCeiling();
403115
+ this.actor = HUMAN_ACTOR;
403116
+ this.session = sessionId;
403117
+ this.touch();
403118
+ this.onChange();
403119
+ }
403120
+ /**
403121
+ * The person is still there — anything they did counts.
403122
+ *
403123
+ * Called per authorised input rather than on a heartbeat, so the hold is
403124
+ * extended by USE and not merely by a tab being open. A left-open panel on a
403125
+ * forgotten laptop stops holding a billed session two minutes later.
403126
+ */
403127
+ touch() {
403128
+ if (this.actor !== HUMAN_ACTOR) return;
403129
+ if (this.handBackWanted) return;
403130
+ this.armIdle();
403131
+ }
403132
+ /** (Re)start the idle interval. The only place the deadline is set. */
403133
+ armIdle() {
403134
+ this.clearHumanIdle();
403135
+ this.humanTimer = setTimeout(() => {
403136
+ this.lapse();
403137
+ }, HUMAN_IDLE_MS);
403138
+ this.humanTimer.unref?.();
403139
+ }
403140
+ /**
403141
+ * The idle deadline came round.
403142
+ *
403143
+ * Both ways a hold ends obey the same rule — never while one of the person's
403144
+ * own events is still landing — but they resolve that clash in OPPOSITE
403145
+ * directions, and routing the lapse through `handBack()` collapsed them into
403146
+ * one. An event in flight BEGAN before this deadline, which makes it evidence
403147
+ * the person was there; deferring the lapse and then releasing the moment
403148
+ * that event lands ends the hold immediately after a fresh interaction, which
403149
+ * is the precise opposite of what an idle deadline measures. So an
403150
+ * interrupted lapse yields: the interval starts again, and the next silence
403151
+ * decides.
403152
+ *
403153
+ * `handBack()` keeps the deferral instead, because "Give it back" is a
403154
+ * decision and activity already under way when it was made does not withdraw
403155
+ * it. Same rule, different answer, which is why they are different methods.
403156
+ */
403157
+ lapse() {
403158
+ if (this.actor !== HUMAN_ACTOR) return;
403159
+ if (this.action && this.actionCountsAsHumanActivity) {
403160
+ if (this.idleGraceAction !== this.action) {
403161
+ this.idleGraceAction = this.action;
403162
+ this.armIdle();
403163
+ } else {
403164
+ this.idleLapseAfterAction = this.action;
403165
+ this.clearHumanIdle();
403166
+ }
403167
+ return;
403168
+ }
403169
+ this.release();
403170
+ }
403171
+ /**
403172
+ * The person hands the browser back, or their hold lapses.
403173
+ *
403174
+ * Answers whether there was a human hold to end, so a caller can tell a real
403175
+ * handback from a duplicate click or a lapse that already happened.
403176
+ */
403177
+ handBack() {
403178
+ if (this.actor !== HUMAN_ACTOR) return false;
403179
+ if (this.action) {
403180
+ this.handBackWanted = true;
403181
+ this.clearHumanIdle();
403182
+ this.humanTimer = setTimeout(() => {
403183
+ this.handBack();
403184
+ }, HANDBACK_RETRY_MS);
403185
+ this.humanTimer.unref?.();
403186
+ return false;
403187
+ }
403188
+ this.release();
403189
+ return true;
403190
+ }
403191
+ clearHumanIdle() {
403192
+ if (this.humanTimer) {
403193
+ clearTimeout(this.humanTimer);
403194
+ this.humanTimer = null;
403195
+ }
403196
+ }
403036
403197
  /** Drop every queued waiter belonging to a run, and the lease if it holds it. */
403037
403198
  dropRun(sessionId) {
403038
403199
  for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) {
@@ -403048,6 +403209,10 @@ var BrowserLease = class {
403048
403209
  /** Hand the browser to the next actor in line, if any. */
403049
403210
  release() {
403050
403211
  this.clearCeiling();
403212
+ this.clearHumanIdle();
403213
+ this.handBackWanted = false;
403214
+ this.idleGraceAction = null;
403215
+ this.idleLapseAfterAction = null;
403051
403216
  this.actor = null;
403052
403217
  this.session = null;
403053
403218
  let next = this.waiting.shift();
@@ -403076,33 +403241,111 @@ var BrowserLease = class {
403076
403241
  this.ceilingTimer = null;
403077
403242
  }
403078
403243
  }
403079
- /** Take the per-action slot, or null when one is already running. */
403080
- beginAction() {
403244
+ /**
403245
+ * Take the per-action slot, or null when one is already running.
403246
+ *
403247
+ * PRIVATE, so the queue is the only way in. A caller that could take the slot
403248
+ * without joining the line would be exactly the jump-the-queue the ordering
403249
+ * is there to prevent.
403250
+ */
403251
+ beginAction(countsAsHumanActivity = true) {
403081
403252
  if (this.action) return null;
403082
403253
  const token = /* @__PURE__ */ Symbol("browser-action");
403083
403254
  this.action = token;
403255
+ this.actionCountsAsHumanActivity = countsAsHumanActivity;
403084
403256
  return token;
403085
403257
  }
403086
- /** Release the slot, but only for the action that took it. */
403258
+ /**
403259
+ * Release the slot, but only for the action that took it.
403260
+ *
403261
+ * Hands it to the head of the line rather than merely clearing it, and does
403262
+ * so SYNCHRONOUSLY: the waiter's `beginAction` runs before this call returns,
403263
+ * so there is no window in which the slot is free and somebody who asked
403264
+ * later can take it.
403265
+ */
403087
403266
  endAction(token) {
403088
403267
  if (this.action !== token) return false;
403268
+ const lapseNow = this.actor === HUMAN_ACTOR && this.idleLapseAfterAction === token;
403089
403269
  this.action = null;
403270
+ this.actionCountsAsHumanActivity = false;
403271
+ if (this.idleGraceAction === token) this.idleGraceAction = null;
403272
+ if (this.idleLapseAfterAction === token) this.idleLapseAfterAction = null;
403273
+ if (lapseNow) this.release();
403274
+ this.actionQueue.shift()?.();
403090
403275
  return true;
403091
403276
  }
403092
403277
  /**
403093
- * Wait, briefly, for an outgoing action to unwind.
403278
+ * Take the slot only if it is free RIGHT NOW, never joining the line.
403279
+ *
403280
+ * For input that is a SAMPLE rather than a decision — pointer movement. A
403281
+ * click or a keystroke is a thing the person meant to do and has to happen,
403282
+ * so it waits its turn; a pointer position is one of many on the way
403283
+ * somewhere, and one that cannot be applied now is worth less than the one
403284
+ * behind it. Queueing them is actively harmful: on an endpoint slower than
403285
+ * the client's sampling interval the backlog grows, and the click behind it
403286
+ * can age out against `acquireActionSlot`'s bound and be refused — movement
403287
+ * costing the person the action they actually intended.
403288
+ *
403289
+ * The queue length is checked as well as the slot, so a sample cannot step
403290
+ * over a click that is already waiting.
403291
+ */
403292
+ tryActionSlot() {
403293
+ if (this.action || this.actionQueue.length > 0) return null;
403294
+ return this.beginAction();
403295
+ }
403296
+ /**
403297
+ * Take the per-action slot, waiting in line for it if somebody has it.
403094
403298
  *
403095
403299
  * A release can hand the browser on while the previous holder's abort is
403096
403300
  * still propagating through an awaited navigation. That is a handoff, not a
403097
- * conflict, so it is waited out rather than refused — but bounded, because a
403098
- * wedged action must not pin the queue.
403301
+ * conflict, so it is normally bounded: a wedged action must not pin an agent
403302
+ * or a takeover forever. Human discrete input is the exception and passes
403303
+ * `null`: once a click, key or committed text has been accepted into this
403304
+ * ordered queue, expiring it merely because earlier accepted input took more
403305
+ * than two seconds silently truncates what the person typed.
403306
+ *
403307
+ * Waiting and TAKING are one step, which they deliberately were not before:
403308
+ * the wait used to answer "the slot looks free" and leave the caller to call
403309
+ * `beginAction` afterwards. Two callers woken by the same release both saw it
403310
+ * free, and the loser was told "another action is already running" — a
403311
+ * refusal for having asked first. Granting it here means whoever reaches the
403312
+ * front of the line gets it, and everybody else is still waiting rather than
403313
+ * refused.
403314
+ *
403315
+ * `null` as a RESULT means a bounded wait ran out OR a caller-supplied queue
403316
+ * cap refused a new waiter. `null` as `withinMs` means an ACCEPTED operation
403317
+ * is intentionally unbounded by time and will leave the queue only when its
403318
+ * turn arrives; it does not require accepting infinitely many operations.
403319
+ * `countsAsHumanActivity=false` is for
403320
+ * lifecycle barriers: they still serialize, but an idle deadline must not
403321
+ * mistake framework cleanup for evidence that the person is present.
403099
403322
  */
403100
- async awaitActionSlot() {
403101
- for (let waited = 0; waited < ACTION_HANDOFF_MS; waited += ACTION_HANDOFF_POLL_MS) {
403102
- if (!this.action) return true;
403103
- await new Promise((r4) => setTimeout(r4, ACTION_HANDOFF_POLL_MS));
403323
+ async acquireActionSlot(withinMs = ACTION_HANDOFF_MS, countsAsHumanActivity = true, maxQueued = null) {
403324
+ if (!this.action && this.actionQueue.length === 0) {
403325
+ return this.beginAction(countsAsHumanActivity);
403104
403326
  }
403105
- return this.action === null;
403327
+ if (maxQueued !== null && this.actionQueue.length >= maxQueued) return null;
403328
+ return await new Promise((resolve2) => {
403329
+ let done = false;
403330
+ let timer;
403331
+ const wake = () => {
403332
+ if (done) return;
403333
+ done = true;
403334
+ if (timer) clearTimeout(timer);
403335
+ resolve2(this.beginAction(countsAsHumanActivity));
403336
+ };
403337
+ if (withinMs !== null) {
403338
+ timer = setTimeout(() => {
403339
+ if (done) return;
403340
+ done = true;
403341
+ const i4 = this.actionQueue.indexOf(wake);
403342
+ if (i4 >= 0) this.actionQueue.splice(i4, 1);
403343
+ resolve2(null);
403344
+ }, withinMs);
403345
+ timer.unref?.();
403346
+ }
403347
+ this.actionQueue.push(wake);
403348
+ });
403106
403349
  }
403107
403350
  /**
403108
403351
  * Queue one actor, resolving when it gets the browser or is turned away.
@@ -403137,7 +403380,28 @@ var BrowserLease = class {
403137
403380
  };
403138
403381
 
403139
403382
  // src/browser/remote/controller.ts
403383
+ var KEYS = {
403384
+ Enter: { code: 13, text: "\r" },
403385
+ Tab: { code: 9 },
403386
+ Backspace: { code: 8 },
403387
+ Delete: { code: 46 },
403388
+ Escape: { code: 27 },
403389
+ ArrowUp: { code: 38 },
403390
+ ArrowDown: { code: 40 },
403391
+ ArrowLeft: { code: 37 },
403392
+ ArrowRight: { code: 39 },
403393
+ Home: { code: 36 },
403394
+ End: { code: 35 },
403395
+ PageUp: { code: 33 },
403396
+ PageDown: { code: 34 }
403397
+ };
403398
+ var BUTTONS = { left: 1, middle: 4, right: 2 };
403399
+ var SCREENCAST = { format: "jpeg", quality: 60, maxWidth: 1280, maxHeight: 800 };
403140
403400
  var ACTION_TIMEOUT_MS = 3e4;
403401
+ var RESTORE_FRAME_MS = 2e3;
403402
+ var MAX_HUMAN_INPUT_QUEUE = 64;
403403
+ var MAX_CAPTURE_TRANSITION_QUEUE = 16;
403404
+ var MAX_WATCH_LIFECYCLE_QUEUE = 16;
403141
403405
  var RemoteBrowserController = class {
403142
403406
  provider;
403143
403407
  maxSessions;
@@ -403150,6 +403414,17 @@ var RemoteBrowserController = class {
403150
403414
  * bearer capability for a browser somebody else is driving.
403151
403415
  */
403152
403416
  liveViewListeners = /* @__PURE__ */ new Map();
403417
+ /**
403418
+ * Told when a run's browser changes hands, or stops being pictured.
403419
+ *
403420
+ * Needed because a takeover can end without anybody asking. The hold lapses
403421
+ * on its own after `HUMAN_IDLE_MS`, and a client left believing it still has
403422
+ * control would keep sending input into a lease it no longer holds — every
403423
+ * event refused, with nothing on screen saying why.
403424
+ */
403425
+ controlListeners = /* @__PURE__ */ new Map();
403426
+ /** What each run's listener was last told, so unchanged states stay quiet. */
403427
+ controlAnnounced = /* @__PURE__ */ new Map();
403153
403428
  /** An embedder that wants every run's live view, told which run each is. */
403154
403429
  onLiveViewAll;
403155
403430
  runs = /* @__PURE__ */ new Map();
@@ -403159,7 +403434,7 @@ var RemoteBrowserController = class {
403159
403434
  * Counted alongside `runs` against the cap. Without it the limit was a
403160
403435
  * check-then-act across four awaits, and simultaneous first uses both won.
403161
403436
  */
403162
- opening = /* @__PURE__ */ new Set();
403437
+ opening = /* @__PURE__ */ new Map();
403163
403438
  /**
403164
403439
  * A run's stop signal, created when its slot is reserved.
403165
403440
  *
@@ -403192,7 +403467,7 @@ var RemoteBrowserController = class {
403192
403467
  * own teardown settles, so a later, genuine re-open of the same run id
403193
403468
  * never collides with one still in flight.
403194
403469
  */
403195
- closing = /* @__PURE__ */ new Set();
403470
+ closing = /* @__PURE__ */ new Map();
403196
403471
  /** Runs the user has stopped, by run id — same meaning as on the desktop. */
403197
403472
  revoked = /* @__PURE__ */ new Set();
403198
403473
  /**
@@ -403210,11 +403485,38 @@ var RemoteBrowserController = class {
403210
403485
  const asked = Math.max(1, options.maxSessions ?? 1);
403211
403486
  this.maxSessions = options.provider.isolatesSessions ? asked : 1;
403212
403487
  this.onLiveViewAll = options.onLiveView;
403488
+ this.ready = options.ready;
403213
403489
  }
403490
+ /**
403491
+ * The predecessor's teardown, until it has been waited for once.
403492
+ *
403493
+ * Cleared as soon as it settles so the wait is not re-entered on every open
403494
+ * — and so the promise itself is not retained for the life of the process.
403495
+ */
403496
+ ready;
403497
+ /**
403498
+ * This controller has been retired and will never open anything again.
403499
+ *
403500
+ * Stronger than "its runs were ended", and it has to be. A caller parked on
403501
+ * the `ready` gate above is invisible to `dispose()` — it holds no run and
403502
+ * has reserved no slot, because it has not got that far — so a SECOND
403503
+ * settings change, arriving while the first retirement is still settling,
403504
+ * found nothing to wait for and let the newest controller start immediately.
403505
+ * The parked caller then woke up on a controller nobody holds a reference to
403506
+ * and allocated a session against the superseded configuration, with the
403507
+ * newest controller free to allocate its own.
403508
+ *
403509
+ * Set synchronously at the top of `dispose`, so anything already waiting is
403510
+ * excluded the moment retirement begins rather than when it finishes.
403511
+ */
403512
+ retired = false;
403214
403513
  leaseFor(runId) {
403215
403514
  let lease = this.leases.get(runId);
403216
403515
  if (!lease) {
403217
- lease = new BrowserLease({ isRevoked: (id) => this.revoked.has(id) });
403516
+ lease = new BrowserLease({
403517
+ isRevoked: (id) => this.revoked.has(id),
403518
+ onChange: () => this.announceControl(runId)
403519
+ });
403218
403520
  lease.setLifecycleReleaseWired(true);
403219
403521
  this.leases.set(runId, lease);
403220
403522
  }
@@ -403231,6 +403533,44 @@ var RemoteBrowserController = class {
403231
403533
  offLiveViewFor(runKey) {
403232
403534
  this.liveViewListeners.delete(runKey);
403233
403535
  }
403536
+ onControlFor(runKey, listener) {
403537
+ this.controlListeners.set(runKey, listener);
403538
+ }
403539
+ offControlFor(runKey) {
403540
+ this.controlListeners.delete(runKey);
403541
+ this.controlAnnounced.delete(runKey);
403542
+ }
403543
+ /**
403544
+ * Say who has the browser, but only when the answer has changed.
403545
+ *
403546
+ * The lease reports every ownership change, and during ordinary work that is
403547
+ * one per action as workers take it and give it back. None of those are
403548
+ * interesting to a viewer — the agent had it before and has it now — and
403549
+ * emitting them would put a socket message on every click the agent makes.
403550
+ */
403551
+ announceControl(runId, force = false) {
403552
+ const listener = this.controlListeners.get(runId);
403553
+ if (!listener) return;
403554
+ const held = this.runs.get(runId);
403555
+ const state = {
403556
+ human: this.leases.get(runId)?.heldByHuman === true,
403557
+ capturing: held?.capturing === true,
403558
+ // A receipt vouches for a VIEW, so it stops meaning anything the moment
403559
+ // there is no view. `stopWatching` clears the session without bumping the
403560
+ // watch — deliberately, since replacing the consumer is what a new watch
403561
+ // is about — so the receipt stayed matched across the whole detach and
403562
+ // re-attach, which `beginWatching` performs on every reconnect and every
403563
+ // return to a conversation. Announced `confirmed` through that window,
403564
+ // the client mounted and FOCUSED the blind keyboard surface over a page
403565
+ // no session was serving: keystrokes meant for the composer went to a
403566
+ // remote page that could only refuse them.
403567
+ confirmed: held?.screencast !== void 0 && held.watchGen !== void 0 && held.seenGen === held.watchGen
403568
+ };
403569
+ const key = `${state.human}:${state.capturing}:${state.confirmed}`;
403570
+ if (!force && this.controlAnnounced.get(runId) === key) return;
403571
+ this.controlAnnounced.set(runId, key);
403572
+ listener(state);
403573
+ }
403234
403574
  /**
403235
403575
  * Tell a run's listener what its browser situation is.
403236
403576
  *
@@ -403251,22 +403591,681 @@ var RemoteBrowserController = class {
403251
403591
  * allocated and billed until the whole run ended, for a run that has just
403252
403592
  * been stopped.
403253
403593
  */
403254
- async releaseIfIdle(runId) {
403594
+ async releaseIfIdle(runId, releaseSlot) {
403255
403595
  const held = this.runs.get(runId);
403256
- if (!held) return;
403596
+ if (!held) {
403597
+ releaseSlot?.();
403598
+ return;
403599
+ }
403257
403600
  this.runs.delete(runId);
403258
- this.closing.add(runId);
403259
- await this.teardown(runId, held);
403601
+ const teardown = this.teardown(runId, held);
403602
+ releaseSlot?.();
403603
+ await teardown;
403260
403604
  }
403261
403605
  /** Release every run's session. For when the deployment's config changes. */
403262
403606
  async dispose() {
403263
- const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening]);
403607
+ this.retired = true;
403608
+ const opening = [...this.opening.values()];
403609
+ const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening.keys()]);
403264
403610
  await Promise.all([...ids].map((runId) => this.endRun(runId)));
403611
+ await Promise.all(opening.map((open2) => open2.catch(() => {
403612
+ })));
403613
+ await Promise.all([...this.closing.values()].map((done) => done.catch(() => {
403614
+ })));
403265
403615
  }
403266
403616
  /** A worker finished; it will never ask for the browser again. */
403267
403617
  actorEnded(actorId) {
403268
403618
  for (const lease of this.leases.values()) lease.actorEnded(actorId);
403269
403619
  }
403620
+ /**
403621
+ * Start streaming this run's page to whoever is watching it.
403622
+ *
403623
+ * Nothing streams until someone asks. That is the whole reason this is a
403624
+ * method rather than something `open()` does: an unwatched run would
403625
+ * otherwise pay to encode frames nobody sees, on every run, forever — and on
403626
+ * a metered provider it would pay for the bandwidth too.
403627
+ *
403628
+ * Frames are ACKED after they are handed on, never before. Chrome sends the
403629
+ * next frame only once the previous is acknowledged, so this bounds it to one
403630
+ * outstanding frame against OUR OWN handling. It does not reach further than
403631
+ * that, and an earlier version of this comment claimed it did: `onFrame` ends
403632
+ * at a socket emit, which hands the JPEG to a transport rather than to a
403633
+ * viewer, so the ack says nothing about what the person actually received.
403634
+ * Bounding the rest of the path is the transport's job — see `emitFrame`,
403635
+ * where frames go out lossy so a slow connection drops pictures instead of
403636
+ * banking stale ones. Dropping the ack stops the stream dead, which is why it
403637
+ * is best-effort but unconditional.
403638
+ *
403639
+ * Returns false when there is nothing to watch — the run has no browser, or
403640
+ * it is already being watched. Callers use that to avoid promising a viewer
403641
+ * a stream that will never arrive.
403642
+ */
403643
+ async startWatching(runId, onFrame) {
403644
+ const held = this.runs.get(runId);
403645
+ if (!held) return false;
403646
+ return await this.queueWatch(held, async () => {
403647
+ held.onFrame = onFrame;
403648
+ held.watchGen = (held.watchGen ?? 0) + 1;
403649
+ if (held.screencast) {
403650
+ this.announceControl(runId);
403651
+ return true;
403652
+ }
403653
+ const attached = await this.attachScreencast(runId, held, onFrame);
403654
+ if (!attached && held.onFrame === onFrame) held.onFrame = void 0;
403655
+ return attached !== null;
403656
+ });
403657
+ }
403658
+ /**
403659
+ * Run one watch or unwatch after every one already asked for.
403660
+ *
403661
+ * The chain is kept non-rejecting on purpose: a failed attach must not poison
403662
+ * every later request for this run, which is what a bare `.then` chain would
403663
+ * do. The caller still sees its own failure, because that is the promise
403664
+ * returned rather than the one stored.
403665
+ */
403666
+ queueWatch(held, op) {
403667
+ const depth = held.watchQueueDepth ?? 0;
403668
+ if (held.watchCollapsedPromise || depth >= MAX_WATCH_LIFECYCLE_QUEUE) {
403669
+ held.watchCollapsed = op;
403670
+ if (!held.watchCollapsedPromise) {
403671
+ const drain = async () => {
403672
+ let result;
403673
+ while (held.watchCollapsed) {
403674
+ const latest = held.watchCollapsed;
403675
+ held.watchCollapsed = void 0;
403676
+ try {
403677
+ result = await latest();
403678
+ } catch {
403679
+ }
403680
+ }
403681
+ return result;
403682
+ };
403683
+ const tail = (held.watchQueue ?? Promise.resolve()).then(drain, drain);
403684
+ let tracked;
403685
+ tracked = tail.finally(() => {
403686
+ if (held.watchCollapsedPromise === tracked) {
403687
+ held.watchCollapsedPromise = void 0;
403688
+ held.watchCollapsed = void 0;
403689
+ }
403690
+ });
403691
+ held.watchCollapsedPromise = tracked;
403692
+ held.watchQueue = tracked.catch(() => {
403693
+ });
403694
+ }
403695
+ return held.watchCollapsedPromise;
403696
+ }
403697
+ held.watchQueueDepth = depth + 1;
403698
+ const mine = (held.watchQueue ?? Promise.resolve()).then(op, op);
403699
+ const counted = mine.finally(() => {
403700
+ held.watchQueueDepth = Math.max(0, (held.watchQueueDepth ?? 1) - 1);
403701
+ });
403702
+ held.watchQueue = counted.catch(() => {
403703
+ });
403704
+ return counted;
403705
+ }
403706
+ /**
403707
+ * Attach at most one physical CDP screencast session at a time.
403708
+ *
403709
+ * `startWatching()` already serializes watcher lifecycle through `watchQueue`,
403710
+ * but the agent's detached-hidden restore cannot join that queue: it holds the
403711
+ * action slot, while an earlier unwatch may be waiting for exactly that slot.
403712
+ * Sharing only the physical attach closes the two-session race without making
403713
+ * watcher teardown and agent mutation wait on each other in a cycle.
403714
+ *
403715
+ * A real watcher claims `onFrame` immediately, before it waits for an attach
403716
+ * already started by the agent. The lower-level attach never writes the sink,
403717
+ * so finishing an older temporary attempt cannot overwrite a newer viewer.
403718
+ */
403719
+ async attachScreencast(runId, held, onFrame, popupDetachGen) {
403720
+ const consumerIntent = onFrame && popupDetachGen !== void 0 ? popupDetachGen : void 0;
403721
+ if (onFrame) held.onFrame = onFrame;
403722
+ if (held.screencast && held.screencastPage === held.page) return held.screencast;
403723
+ const targetPage = held.page;
403724
+ const existing = held.attaching;
403725
+ if (existing) {
403726
+ const existingPage = held.attachingPage;
403727
+ await existing.catch(() => null);
403728
+ if (held.page !== targetPage || existingPage !== targetPage) {
403729
+ return this.attachScreencast(runId, held, onFrame, popupDetachGen);
403730
+ }
403731
+ const cdp = held.screencastPage === targetPage ? held.screencast ?? null : null;
403732
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
403733
+ if (cdp && onFrame) held.onFrame = onFrame;
403734
+ return cdp;
403735
+ }
403736
+ if (held.screencast && held.screencastPage !== targetPage) {
403737
+ const stale = held.screencast;
403738
+ held.screencast = void 0;
403739
+ held.screencastPage = void 0;
403740
+ await stale.send("Page.stopScreencast").catch(() => {
403741
+ });
403742
+ await stale.detach().catch(() => {
403743
+ });
403744
+ }
403745
+ const attempt = this.attachScreencastFresh(runId, held, targetPage, consumerIntent);
403746
+ held.attaching = attempt;
403747
+ held.attachingPage = targetPage;
403748
+ try {
403749
+ const cdp = await attempt;
403750
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
403751
+ if (cdp && onFrame) held.onFrame = onFrame;
403752
+ return cdp;
403753
+ } finally {
403754
+ if (held.attaching === attempt) {
403755
+ held.attaching = void 0;
403756
+ held.attachingPage = void 0;
403757
+ }
403758
+ }
403759
+ }
403760
+ /** Open one new CDP session and start the stream when it is safe to do so. */
403761
+ async attachScreencastFresh(runId, held, targetPage, consumerIntent) {
403762
+ const safetyGate = held.hiddenByHolder === true || held.visibilityUnconfirmed === true;
403763
+ let cdp;
403764
+ try {
403765
+ cdp = await targetPage.context().newCDPSession(targetPage);
403766
+ } catch {
403767
+ if (!safetyGate) {
403768
+ held.visibilityUnconfirmed = false;
403769
+ held.capturing = true;
403770
+ this.announceControl(runId);
403771
+ }
403772
+ return null;
403773
+ }
403774
+ if (held.page !== targetPage) {
403775
+ await cdp.detach().catch(() => {
403776
+ });
403777
+ return null;
403778
+ }
403779
+ cdp.on("Page.screencastFrame", ((e3) => {
403780
+ if (held.page === targetPage && typeof e3?.data === "string") {
403781
+ const width = e3.metadata?.deviceWidth ?? 0;
403782
+ const height = e3.metadata?.deviceHeight ?? 0;
403783
+ held.metricsStale = true;
403784
+ if (held.visibilityUnconfirmed) {
403785
+ held.visibilityUnconfirmed = false;
403786
+ if (held.capturing !== true) {
403787
+ held.capturing = true;
403788
+ this.announceControl(runId);
403789
+ }
403790
+ }
403791
+ const ping = held.framePing;
403792
+ held.framePing = void 0;
403793
+ ping?.();
403794
+ const deliver = held.onFrame;
403795
+ if (deliver) {
403796
+ deliver({ data: e3.data, width, height, generation: held.watchGen ?? 0 });
403797
+ }
403798
+ }
403799
+ void cdp.send("Page.screencastFrameAck", { sessionId: e3?.sessionId }).catch(() => {
403800
+ });
403801
+ }));
403802
+ const paused = held.hiddenByHolder === true;
403803
+ if (!paused) {
403804
+ try {
403805
+ await cdp.send("Page.startScreencast", { ...SCREENCAST });
403806
+ } catch {
403807
+ if (!safetyGate) {
403808
+ held.visibilityUnconfirmed = false;
403809
+ held.capturing = true;
403810
+ this.announceControl(runId);
403811
+ } else {
403812
+ held.capturing = false;
403813
+ this.announceControl(runId);
403814
+ }
403815
+ await cdp.detach().catch(() => {
403816
+ });
403817
+ return null;
403818
+ }
403819
+ }
403820
+ if (held.page !== targetPage) {
403821
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
403822
+ });
403823
+ await cdp.detach().catch(() => {
403824
+ });
403825
+ return null;
403826
+ }
403827
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) {
403828
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
403829
+ });
403830
+ await cdp.detach().catch(() => {
403831
+ });
403832
+ return null;
403833
+ }
403834
+ held.screencast = cdp;
403835
+ held.screencastPage = targetPage;
403836
+ held.capturing = !paused;
403837
+ held.hiddenByHolder = paused;
403838
+ this.announceControl(runId);
403839
+ return cdp;
403840
+ }
403841
+ /** Nobody is watching any more: stop paying to render frames. */
403842
+ async stopWatching(runId) {
403843
+ const held = this.runs.get(runId);
403844
+ if (!held) return;
403845
+ held.watchDetachGen = (held.watchDetachGen ?? 0) + 1;
403846
+ held.onFrame = void 0;
403847
+ await this.queueWatch(held, async () => {
403848
+ const cdp = held.screencast;
403849
+ held.onFrame = void 0;
403850
+ if (!cdp) return;
403851
+ held.screencast = void 0;
403852
+ held.screencastPage = void 0;
403853
+ held.capturing = false;
403854
+ this.announceControl(runId);
403855
+ const lease = this.leases.get(runId);
403856
+ const token = lease ? await lease.acquireActionSlot(null, false) : null;
403857
+ if (lease && !token) return;
403858
+ try {
403859
+ await cdp.send("Page.stopScreencast").catch(() => {
403860
+ });
403861
+ await cdp.detach().catch(() => {
403862
+ });
403863
+ } finally {
403864
+ if (token && lease) lease.endAction(token);
403865
+ }
403866
+ });
403867
+ }
403868
+ /**
403869
+ * Suspend or resume the picture without giving up control.
403870
+ *
403871
+ * The credential case, and the only reason this is separate from watching. A
403872
+ * person who has taken the page over to sign in is about to type a password
403873
+ * into a field whose contents are echoed as dots — but a password manager's
403874
+ * dropdown, a "show password" toggle, a one-time code in plain text and the
403875
+ * confirmation page afterwards are not, and all of them would otherwise be
403876
+ * encoded into frames and sent across the network.
403877
+ *
403878
+ * What this does NOT do is hide anything from the agent: it stops the
403879
+ * picture, not the page. Nothing about a suspended capture would stop a
403880
+ * `extract_text` from reading the same screen — it is the lease that stops
403881
+ * that, by refusing the agent for as long as the person holds it.
403882
+ *
403883
+ * Answers whether frames are flowing afterwards, which is what a UI needs to
403884
+ * show and is not always what was asked for: a run with nobody watching has
403885
+ * no session to suspend.
403886
+ */
403887
+ async setCapture(runId, on) {
403888
+ const held = this.runs.get(runId);
403889
+ const cdp = held?.screencast;
403890
+ const watch = held?.watchGen;
403891
+ if (!held || !cdp) return false;
403892
+ const lease = this.leaseFor(runId);
403893
+ if (!on && !lease.heldByHuman) return held.capturing === true;
403894
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
403895
+ return held.capturing === true;
403896
+ }
403897
+ if (held.capturing === on) {
403898
+ if (lease.heldByHuman) lease.touch();
403899
+ return on;
403900
+ }
403901
+ const token = await lease.acquireActionSlot(null, true, MAX_CAPTURE_TRANSITION_QUEUE);
403902
+ if (!token) return held.capturing === true;
403903
+ try {
403904
+ if (!on && !lease.heldByHuman) return held.capturing === true;
403905
+ if (!on && (held.screencast !== cdp || held.watchGen !== watch)) {
403906
+ return held.capturing === true;
403907
+ }
403908
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
403909
+ return held.capturing === true;
403910
+ }
403911
+ const target = on ? held.screencast : cdp;
403912
+ if (!target) {
403913
+ if (on) {
403914
+ held.hiddenByHolder = false;
403915
+ if (lease.heldByHuman) lease.touch();
403916
+ }
403917
+ return held.capturing === true;
403918
+ }
403919
+ const now = await this.applyCapture(runId, held, target, on);
403920
+ if (lease.heldByHuman) lease.touch();
403921
+ return now;
403922
+ } finally {
403923
+ lease.endAction(token);
403924
+ }
403925
+ }
403926
+ /**
403927
+ * The capture transition itself, with no lease bookkeeping.
403928
+ *
403929
+ * Separate because the agent's restore in `act()` already holds the action
403930
+ * slot — taking it again there would deadlock against itself, and the whole
403931
+ * point of that call site is that it happens INSIDE the agent's turn.
403932
+ *
403933
+ * `confirmWithFrame` is used only by the agent's visibility gate. There, a
403934
+ * successful CDP command is not enough: the externally visible state remains
403935
+ * paused until a frame arrives, and the frame handler is the only code that
403936
+ * promotes it to capturing. This makes a restore timeout sticky rather than
403937
+ * a one-action refusal.
403938
+ */
403939
+ async applyCapture(runId, held, cdp, on, confirmWithFrame = false) {
403940
+ if (held.capturing === on && !(on && confirmWithFrame && held.visibilityUnconfirmed)) return on;
403941
+ const wasUnconfirmed = held.visibilityUnconfirmed === true;
403942
+ const wasCapturing = held.capturing === true;
403943
+ if (on) held.visibilityUnconfirmed = true;
403944
+ try {
403945
+ if (on) await cdp.send("Page.startScreencast", { ...SCREENCAST });
403946
+ else await cdp.send("Page.stopScreencast");
403947
+ if (on) {
403948
+ if (!confirmWithFrame) held.capturing = true;
403949
+ } else {
403950
+ held.visibilityUnconfirmed = false;
403951
+ held.capturing = false;
403952
+ }
403953
+ held.hiddenByHolder = !on;
403954
+ this.announceControl(runId);
403955
+ } catch {
403956
+ if (on) {
403957
+ held.visibilityUnconfirmed = wasUnconfirmed;
403958
+ held.capturing = wasCapturing;
403959
+ this.announceControl(runId);
403960
+ }
403961
+ return held.capturing === true && !held.visibilityUnconfirmed;
403962
+ }
403963
+ return on;
403964
+ }
403965
+ /**
403966
+ * Wait, briefly, for Chrome to produce a frame.
403967
+ *
403968
+ * `Page.startScreencast` resolves when the command is accepted, which is not
403969
+ * when a picture exists. The agent's visibility gate — "the page must be
403970
+ * VISIBLE before anything changes it" — was therefore satisfied by a request
403971
+ * rather than by a repaint, and the agent could click while the panel was
403972
+ * still showing the page from before it was hidden, or nothing at all.
403973
+ *
403974
+ * A frame rather than a client receipt, deliberately. A receipt would prove
403975
+ * somebody SAW it, which is stronger — and would make the agent's progress
403976
+ * depend on a viewer that may not exist: this path runs after a handback or a
403977
+ * lapse, when there may be nobody watching at all, and an agent that stalls
403978
+ * because a tab was closed is a worse failure than the one being fixed. What
403979
+ * this does guarantee is ours to guarantee: the stream is producing again.
403980
+ *
403981
+ * Bounded, and the bound rests on the property rounds 14 and 15 already rely
403982
+ * on — a screencast that has just been started emits its first frame at once,
403983
+ * whatever the page is doing. If one does not arrive, the picture genuinely
403984
+ * has not come back, and the caller refuses on the same trade it already
403985
+ * makes when the restore itself fails.
403986
+ */
403987
+ nextFrame(held, withinMs = RESTORE_FRAME_MS) {
403988
+ return new Promise((resolve2) => {
403989
+ const timer = setTimeout(() => {
403990
+ if (held.framePing === ping) held.framePing = void 0;
403991
+ resolve2(false);
403992
+ }, withinMs);
403993
+ timer.unref?.();
403994
+ const ping = () => {
403995
+ clearTimeout(timer);
403996
+ resolve2(true);
403997
+ };
403998
+ held.framePing = ping;
403999
+ });
404000
+ }
404001
+ /** Whether a person is driving this run's browser right now. */
404002
+ humanHolds(runId) {
404003
+ return this.leases.get(runId)?.heldByHuman === true;
404004
+ }
404005
+ /**
404006
+ * Hand this run's browser to the person watching it.
404007
+ *
404008
+ * The wait is the substance. An agent action already inside Playwright cannot
404009
+ * be interrupted — `click` waits for its element to become actionable and
404010
+ * then clicks, whatever anyone decided in the meantime — so a takeover that
404011
+ * returned immediately would put the person on a page an action was still
404012
+ * about to change. Bounded by the action ceiling, because that is the longest
404013
+ * an action can legitimately take; past it something is wedged, and saying so
404014
+ * is better than a spinner that never resolves.
404015
+ *
404016
+ * Idempotent for someone who already holds it: a second click of Take control
404017
+ * just says they are still there.
404018
+ */
404019
+ async takeOver(runId) {
404020
+ if (this.revoked.has(runId)) {
404021
+ return { ok: false, detail: "Browser control was stopped for this run." };
404022
+ }
404023
+ const requested = this.runs.get(runId);
404024
+ if (!requested) {
404025
+ return { ok: false, detail: "This run has no browser open." };
404026
+ }
404027
+ const requestedCdp = requested.screencast;
404028
+ const requestedWatch = requested.watchGen;
404029
+ if (!requestedCdp || requestedWatch === void 0 || !requested.onFrame) {
404030
+ return { ok: false, detail: "This browser is not being watched right now." };
404031
+ }
404032
+ const lease = this.leaseFor(runId);
404033
+ if (lease.heldByHuman) {
404034
+ lease.touch();
404035
+ this.announceControl(runId, true);
404036
+ return { ok: true };
404037
+ }
404038
+ const token = await lease.acquireActionSlot(ACTION_TIMEOUT_MS);
404039
+ if (!token) {
404040
+ return {
404041
+ ok: false,
404042
+ detail: "The agent is still finishing an action on this page. Try again in a moment."
404043
+ };
404044
+ }
404045
+ try {
404046
+ const held = this.runs.get(runId);
404047
+ if (this.revoked.has(runId)) {
404048
+ return { ok: false, detail: "Browser control was stopped for this run." };
404049
+ }
404050
+ if (!held || held.page.isClosed()) {
404051
+ return { ok: false, detail: "That browser closed while you were waiting for it." };
404052
+ }
404053
+ if (held.screencast !== requestedCdp || held.watchGen !== requestedWatch || !held.onFrame) {
404054
+ return { ok: false, detail: "The browser view changed while you were waiting. Take control again from the current view." };
404055
+ }
404056
+ lease.takeOver(runId);
404057
+ } finally {
404058
+ lease.endAction(token);
404059
+ }
404060
+ return { ok: true };
404061
+ }
404062
+ /** The person is done. Answers whether they were holding it at all. */
404063
+ /**
404064
+ * The viewer confirms it received a picture of this run's page.
404065
+ *
404066
+ * Reliable, one per watch, and the only thing that opens Hide. Checked
404067
+ * against the CURRENT watch: a receipt that names an older one is from a
404068
+ * viewer that has since been replaced, and vouching for its successor is the
404069
+ * reconnect hole this counter exists to close.
404070
+ */
404071
+ frameSeen(runId, generation) {
404072
+ const held = this.runs.get(runId);
404073
+ if (!held) return;
404074
+ if (typeof generation !== "number" || !Number.isFinite(generation)) return;
404075
+ if (held.watchGen === void 0 || generation !== held.watchGen) return;
404076
+ if (held.seenGen === generation) return;
404077
+ held.seenGen = generation;
404078
+ this.announceControl(runId);
404079
+ }
404080
+ handBack(runId) {
404081
+ return this.leases.get(runId)?.handBack() === true;
404082
+ }
404083
+ /**
404084
+ * One thing the person did to the page.
404085
+ *
404086
+ * Authorised per event, not per session. A takeover is not a channel that
404087
+ * stays open once opened: between two clicks the run can be stopped, the hold
404088
+ * can lapse, the page can close, and each of those has to stop the NEXT
404089
+ * event rather than be noticed at the end. So every event re-checks that this
404090
+ * run is live and that the lease is still the person's, and every accepted
404091
+ * event says so by extending the hold.
404092
+ */
404093
+ async input(runId, event) {
404094
+ const held = this.runs.get(runId);
404095
+ const cdp = held?.screencast;
404096
+ const watch = held?.watchGen;
404097
+ if (!held || held.page.isClosed()) return { ok: false, detail: "This run has no browser open." };
404098
+ const page2 = held.page;
404099
+ const pageGeneration = held.generation;
404100
+ if (!cdp) return { ok: false, detail: "Nothing is streaming this browser, so it cannot be driven." };
404101
+ const lease = this.leaseFor(runId);
404102
+ if (!lease.heldByHuman) {
404103
+ return { ok: false, detail: "You do not have control of this browser. Take control first." };
404104
+ }
404105
+ if (held.capturing !== true && (held.watchGen === void 0 || held.seenGen !== held.watchGen)) {
404106
+ return { ok: false, detail: "Show the page before typing into it \u2014 this view has not seen it yet." };
404107
+ }
404108
+ const sampled = event.kind === "move";
404109
+ const token = sampled ? lease.tryActionSlot() : await lease.acquireActionSlot(null, true, MAX_HUMAN_INPUT_QUEUE);
404110
+ if (!token) {
404111
+ if (sampled) return { ok: true };
404112
+ return { ok: false, detail: "Too many browser inputs are already waiting. Let the page catch up, then try again." };
404113
+ }
404114
+ try {
404115
+ if (!lease.heldByHuman) {
404116
+ return { ok: false, detail: "Your control of this browser ended while that was waiting." };
404117
+ }
404118
+ if (held.page !== page2 || held.generation !== pageGeneration) {
404119
+ return { ok: false, detail: "The page changed while that input was waiting, so it did not land." };
404120
+ }
404121
+ if (held.screencast !== cdp || held.watchGen !== watch) {
404122
+ return { ok: false, detail: "The view you were driving is gone, so that did not land." };
404123
+ }
404124
+ const pagesBefore = new Set(held.page.context().pages());
404125
+ await this.dispatch(cdp, held, event);
404126
+ await this.followOpenedPage(runId, held, pagesBefore);
404127
+ } catch (err) {
404128
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
404129
+ } finally {
404130
+ lease.endAction(token);
404131
+ }
404132
+ lease.touch();
404133
+ return { ok: true };
404134
+ }
404135
+ /**
404136
+ * The page's own coordinate space, measured rather than inferred.
404137
+ *
404138
+ * Cached, because a click should not cost a round trip, and invalidated by
404139
+ * the frame metadata changing shape — which arrives free with every frame. A
404140
+ * failed measurement falls back to the last good one rather than to a guess:
404141
+ * a stale viewport is off by a resize, an invented one is off by anything.
404142
+ */
404143
+ async pageViewport(held, cdp) {
404144
+ if (held.viewport && !held.metricsStale) return held.viewport;
404145
+ try {
404146
+ const metrics = await cdp.send("Page.getLayoutMetrics");
404147
+ const width = metrics?.cssVisualViewport?.clientWidth;
404148
+ const height = metrics?.cssVisualViewport?.clientHeight;
404149
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
404150
+ held.viewport = { width, height };
404151
+ held.metricsStale = false;
404152
+ return held.viewport;
404153
+ }
404154
+ } catch {
404155
+ }
404156
+ return null;
404157
+ }
404158
+ /**
404159
+ * Turn one authorised event into CDP calls. Coordinates arrive normalised.
404160
+ *
404161
+ * A switch with a default that REFUSES, rather than a chain of ifs ending in
404162
+ * the click branch. This is a mutation boundary reached from a socket, so the
404163
+ * question is not what a well-formed event does but what a malformed one
404164
+ * does: the chain answered "clicks", which meant a version-skewed client
404165
+ * sending `{ kind: 'drag', x, y }` pressed the mouse on a real page, and a
404166
+ * kind with no coordinates at all clamped to 0 and clicked the top-left
404167
+ * corner. Unknown input has to fail closed.
404168
+ *
404169
+ * The `never` in the default is the other half: adding a member to
404170
+ * `BrowserInput` without handling it here stops compiling, so the next kind
404171
+ * cannot silently inherit whatever the last branch happened to be.
404172
+ */
404173
+ async dispatch(cdp, held, event) {
404174
+ switch (event.kind) {
404175
+ case "text": {
404176
+ if (typeof event.text !== "string") {
404177
+ throw new Error("That is not text that can be typed into the page.");
404178
+ }
404179
+ const text = event.text.slice(0, 4e3);
404180
+ if (!text) return;
404181
+ await cdp.send("Input.insertText", { text });
404182
+ return;
404183
+ }
404184
+ case "key": {
404185
+ const modifiers = event.modifiers;
404186
+ if (modifiers !== void 0) {
404187
+ if (!Array.isArray(modifiers) || modifiers.some((m3) => m3 !== "Control" && m3 !== "Meta" && m3 !== "Shift") || new Set(modifiers).size !== modifiers.length) {
404188
+ throw new Error("That editing shortcut has invalid modifiers.");
404189
+ }
404190
+ const primary = modifiers.filter((m3) => m3 === "Control" || m3 === "Meta");
404191
+ const shifted = modifiers.includes("Shift");
404192
+ if (primary.length !== 1) {
404193
+ throw new Error("That editing shortcut cannot be sent to the page.");
404194
+ }
404195
+ const key = typeof event.key === "string" ? event.key : "";
404196
+ const lower2 = key.toLowerCase();
404197
+ const shortcut = lower2 === "a" && !shifted ? { key: "a", code: "KeyA", vk: 65 } : lower2 === "z" ? { key: "z", code: "KeyZ", vk: 90 } : lower2 === "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;
404198
+ if (!shortcut) {
404199
+ throw new Error(`That editing shortcut is not supported for the remote page: ${key}`);
404200
+ }
404201
+ const modifierBits = (primary[0] === "Control" ? 2 : 4) | (shifted ? 8 : 0);
404202
+ const base2 = {
404203
+ key: shortcut.key,
404204
+ code: shortcut.code,
404205
+ windowsVirtualKeyCode: shortcut.vk,
404206
+ nativeVirtualKeyCode: shortcut.vk,
404207
+ modifiers: modifierBits
404208
+ };
404209
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "rawKeyDown" });
404210
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "keyUp" });
404211
+ return;
404212
+ }
404213
+ const spec = typeof event.key === "string" && Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : void 0;
404214
+ if (!spec) throw new Error(`That key cannot be sent to the page: ${String(event.key)}`);
404215
+ const base = {
404216
+ key: event.key,
404217
+ code: event.key,
404218
+ windowsVirtualKeyCode: spec.code,
404219
+ nativeVirtualKeyCode: spec.code,
404220
+ ...spec.text ? { text: spec.text } : {}
404221
+ };
404222
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: spec.text ? "keyDown" : "rawKeyDown" });
404223
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" });
404224
+ return;
404225
+ }
404226
+ case "move":
404227
+ case "scroll":
404228
+ case "click": {
404229
+ const view = await this.pageViewport(held, cdp);
404230
+ if (!view) throw new Error("The page has not been measured yet, so a click cannot be placed on it.");
404231
+ const x5 = coordinate(event.x, "horizontal position") * view.width;
404232
+ const y3 = coordinate(event.y, "vertical position") * view.height;
404233
+ if (event.kind === "move") {
404234
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: x5, y: y3, button: "none", buttons: 0 });
404235
+ return;
404236
+ }
404237
+ if (event.kind === "scroll") {
404238
+ await cdp.send("Input.dispatchMouseEvent", {
404239
+ type: "mouseWheel",
404240
+ x: x5,
404241
+ y: y3,
404242
+ button: "none",
404243
+ buttons: 0,
404244
+ deltaX: 0,
404245
+ deltaY: scrollDelta(event.deltaY)
404246
+ });
404247
+ return;
404248
+ }
404249
+ if (event.button !== void 0 && !Object.hasOwn(BUTTONS, event.button)) {
404250
+ throw new Error(`That is not a mouse button: ${String(event.button)}`);
404251
+ }
404252
+ const button = event.button ?? "left";
404253
+ const buttons = BUTTONS[button];
404254
+ if (event.clicks !== void 0 && (typeof event.clicks !== "number" || !Number.isFinite(event.clicks))) {
404255
+ throw new Error("That is not a number of clicks.");
404256
+ }
404257
+ const clickCount = Math.min(Math.max(Math.trunc(event.clicks ?? 1), 1), 3);
404258
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: x5, y: y3, button: "none", buttons: 0 });
404259
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: x5, y: y3, button, buttons, clickCount });
404260
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: x5, y: y3, button, buttons: 0, clickCount });
404261
+ return;
404262
+ }
404263
+ default: {
404264
+ const unknown2 = event;
404265
+ throw new Error(`That is not something that can be done to the page: ${unknown2?.kind ?? "an event with no kind"}`);
404266
+ }
404267
+ }
404268
+ }
403270
404269
  /** The user stopped this run. Refuses further actions and clears its queue. */
403271
404270
  stopRun(runId) {
403272
404271
  this.revoked.add(runId);
@@ -403275,7 +404274,6 @@ var RemoteBrowserController = class {
403275
404274
  const held = this.runs.get(runId);
403276
404275
  if (!held) return;
403277
404276
  this.runs.delete(runId);
403278
- this.closing.add(runId);
403279
404277
  void this.teardown(runId, held);
403280
404278
  }
403281
404279
  /** A run ended: release its browser rather than pay for an idle session. */
@@ -403288,7 +404286,6 @@ var RemoteBrowserController = class {
403288
404286
  return;
403289
404287
  }
403290
404288
  this.runs.delete(runId);
403291
- this.closing.add(runId);
403292
404289
  await this.teardown(runId, held);
403293
404290
  this.forgetRun(runId);
403294
404291
  }
@@ -403298,6 +404295,7 @@ var RemoteBrowserController = class {
403298
404295
  this.leases.delete(runId);
403299
404296
  this.aborts.delete(runId);
403300
404297
  this.liveViewListeners.delete(runId);
404298
+ this.offControlFor(runId);
403301
404299
  }
403302
404300
  /**
403303
404301
  * Let go of one run's browser.
@@ -403309,6 +404307,17 @@ var RemoteBrowserController = class {
403309
404307
  async disposeRun(runId, held) {
403310
404308
  held.abort.abort();
403311
404309
  this.announceLiveView(runId, void 0, false);
404310
+ await held.watchQueue?.catch(() => {
404311
+ });
404312
+ const watching = held.screencast;
404313
+ held.screencast = void 0;
404314
+ held.screencastPage = void 0;
404315
+ if (watching) {
404316
+ await watching.send("Page.stopScreencast").catch(() => {
404317
+ });
404318
+ await watching.detach().catch(() => {
404319
+ });
404320
+ }
403312
404321
  await held.ownedContext?.close().catch(() => {
403313
404322
  });
403314
404323
  await held.browser.close().catch(() => {
@@ -403324,12 +404333,12 @@ var RemoteBrowserController = class {
403324
404333
  * guarantees the removal happens, success or failure, so a disposal that
403325
404334
  * throws cannot leave the slot counted against the pool forever.
403326
404335
  */
403327
- async teardown(runId, held) {
403328
- try {
403329
- await this.disposeRun(runId, held);
403330
- } finally {
404336
+ teardown(runId, held) {
404337
+ const done = this.disposeRun(runId, held).finally(() => {
403331
404338
  this.closing.delete(runId);
403332
- }
404339
+ });
404340
+ this.closing.set(runId, done);
404341
+ return done;
403333
404342
  }
403334
404343
  async act(action, context) {
403335
404344
  const runId = context.sessionId;
@@ -403343,7 +404352,9 @@ var RemoteBrowserController = class {
403343
404352
  if (context.signal?.aborted) {
403344
404353
  return { ok: false, detail: "The run was cancelled." };
403345
404354
  }
403346
- const planned = this.runs.get(runId)?.generation ?? 0;
404355
+ const plannedRun = this.runs.get(runId);
404356
+ const planned = plannedRun?.generation ?? 0;
404357
+ const recoveringClosedPage = plannedRun?.page.isClosed() === true;
403347
404358
  const lease_ = this.leaseFor(runId);
403348
404359
  const lease = await lease_.acquire(actor, runId, context.signal);
403349
404360
  if (lease !== "granted") {
@@ -403357,28 +404368,96 @@ var RemoteBrowserController = class {
403357
404368
  lease_.releaseIfHeldBy(actor);
403358
404369
  return { ok: false, detail: "The run was cancelled." };
403359
404370
  }
403360
- if (!await lease_.awaitActionSlot()) {
404371
+ const token = await lease_.acquireActionSlot();
404372
+ if (!token) {
403361
404373
  lease_.releaseIfHeldBy(actor);
403362
404374
  return { ok: false, detail: "Another browser action is still finishing. Try again." };
403363
404375
  }
403364
- const token = lease_.beginAction();
403365
- if (!token) {
403366
- return { ok: false, detail: "Another browser action is already running. Wait for it to finish." };
404376
+ if (!lease_.heldBy(actor)) {
404377
+ lease_.endAction(token);
404378
+ return { ok: false, detail: refusal(lease_.heldByHuman ? "human" : "off") };
403367
404379
  }
404380
+ let temporaryCapture;
403368
404381
  try {
403369
404382
  const held = await this.open(runId, context.signal);
403370
404383
  if (this.revoked.has(runId)) {
403371
- await this.releaseIfIdle(runId);
404384
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
403372
404385
  return { ok: false, detail: "The user stopped browser control for this run." };
403373
404386
  }
403374
404387
  if (context.signal?.aborted || held.abort.signal.aborted) {
403375
- await this.releaseIfIdle(runId);
404388
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
403376
404389
  return { ok: false, detail: "The run was cancelled." };
403377
404390
  }
403378
- return await this.perform(held, action, planned);
404391
+ if ((held.hiddenByHolder === true || held.visibilityUnconfirmed === true) && !held.screencast) {
404392
+ const watchGen = held.watchGen;
404393
+ let cdp = null;
404394
+ try {
404395
+ cdp = await this.attachScreencast(runId, held);
404396
+ } catch {
404397
+ }
404398
+ if (!cdp) {
404399
+ return {
404400
+ ok: false,
404401
+ detail: "The page could not be shown again, so nothing was done to it."
404402
+ };
404403
+ }
404404
+ if (held.onFrame === void 0 && held.watchGen === watchGen) {
404405
+ temporaryCapture = { held, cdp, watchGen };
404406
+ }
404407
+ }
404408
+ if (held.screencast && (held.capturing === false || held.visibilityUnconfirmed === true)) {
404409
+ const cdp = held.screencast;
404410
+ if (held.capturing === true) {
404411
+ await cdp.send("Page.stopScreencast").catch(() => {
404412
+ });
404413
+ held.capturing = false;
404414
+ this.announceControl(runId);
404415
+ }
404416
+ const drawn = this.nextFrame(held);
404417
+ if (!await this.applyCapture(runId, held, cdp, true, true)) {
404418
+ return {
404419
+ ok: false,
404420
+ detail: "The page could not be shown again, so nothing was done to it."
404421
+ };
404422
+ }
404423
+ if (!await drawn) {
404424
+ held.visibilityUnconfirmed = true;
404425
+ held.capturing = false;
404426
+ await cdp.send("Page.stopScreencast").catch(() => {
404427
+ });
404428
+ this.announceControl(runId);
404429
+ return {
404430
+ ok: false,
404431
+ detail: "The page did not come back on screen, so nothing was done to it."
404432
+ };
404433
+ }
404434
+ if (this.revoked.has(runId)) {
404435
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
404436
+ return { ok: false, detail: "The user stopped browser control for this run." };
404437
+ }
404438
+ if (context.signal?.aborted || held.abort.signal.aborted) {
404439
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
404440
+ return { ok: false, detail: "The run was cancelled." };
404441
+ }
404442
+ }
404443
+ return await this.perform(runId, held, action, recoveringClosedPage ? held.generation : planned);
403379
404444
  } catch (err) {
403380
404445
  return { ok: false, detail: err instanceof Error ? err.message : String(err) };
403381
404446
  } finally {
404447
+ if (temporaryCapture) {
404448
+ const { held, cdp, watchGen } = temporaryCapture;
404449
+ if (held.screencast === cdp && held.onFrame === void 0 && held.watchGen === watchGen && held.hiddenByHolder === false && held.visibilityUnconfirmed === false && held.capturing === true) {
404450
+ held.onFrame = void 0;
404451
+ held.screencast = void 0;
404452
+ held.screencastPage = void 0;
404453
+ held.capturing = true;
404454
+ this.announceControl(runId);
404455
+ await cdp.send("Page.stopScreencast").catch(() => {
404456
+ });
404457
+ await cdp.detach().catch(() => {
404458
+ });
404459
+ }
404460
+ }
403382
404461
  lease_.endAction(token);
403383
404462
  if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
403384
404463
  }
@@ -403394,22 +404473,40 @@ var RemoteBrowserController = class {
403394
404473
  * it out changed no test. Two copies of a cap invite them to disagree.
403395
404474
  */
403396
404475
  async open(runId, signal) {
404476
+ if (this.ready) {
404477
+ const settling = this.ready;
404478
+ try {
404479
+ await settling;
404480
+ } catch {
404481
+ }
404482
+ if (this.ready === settling) this.ready = void 0;
404483
+ }
403397
404484
  const existing = this.runs.get(runId);
403398
404485
  if (existing && !existing.page.isClosed()) return existing;
404486
+ if (existing && existing.page.isClosed()) {
404487
+ const fallback = existing.page.context().pages().filter((p3) => !p3.isClosed()).at(-1);
404488
+ if (fallback) {
404489
+ await this.adoptPage(runId, existing, fallback);
404490
+ return existing;
404491
+ }
404492
+ }
403399
404493
  if (existing) {
403400
404494
  this.runs.delete(runId);
403401
- this.closing.add(runId);
403402
404495
  await this.teardown(runId, existing);
403403
404496
  }
404497
+ if (this.retired) {
404498
+ throw new Error("The browser settings changed while this action was waiting. Try again.");
404499
+ }
403404
404500
  if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
403405
404501
  throw new Error(
403406
404502
  `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.`
403407
404503
  );
403408
404504
  }
403409
- this.opening.add(runId);
403410
404505
  if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
404506
+ const opening = this.openReserved(runId, signal);
404507
+ this.opening.set(runId, opening);
403411
404508
  try {
403412
- return await this.openReserved(runId, signal);
404509
+ return await opening;
403413
404510
  } finally {
403414
404511
  this.opening.delete(runId);
403415
404512
  }
@@ -403420,9 +404517,9 @@ var RemoteBrowserController = class {
403420
404517
  const abort = this.aborts.get(runId)?.signal;
403421
404518
  const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
403422
404519
  const session = await this.provider.createSession(creationSignal);
403423
- this.announceLiveView(runId, session.liveViewUrl, true);
403424
404520
  let browser2;
403425
404521
  let owned;
404522
+ let held;
403426
404523
  try {
403427
404524
  browser2 = await playwright.chromium.connectOverCDP(session.cdpUrl);
403428
404525
  owned = this.provider.isolatesSessions ? void 0 : await browser2.newContext();
@@ -403430,15 +404527,10 @@ var RemoteBrowserController = class {
403430
404527
  const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
403431
404528
  const abort2 = this.aborts.get(runId);
403432
404529
  if (!abort2) throw new Error("The run ended while its browser was still opening.");
403433
- const held = { session, browser: browser2, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
403434
- page2.on("framenavigated", ((frame) => {
403435
- if (frame !== page2.mainFrame()) return;
403436
- held.generation += 1;
403437
- }));
404530
+ held = { session, browser: browser2, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
404531
+ this.trackPageNavigation(runId, held, page2);
403438
404532
  this.runs.set(runId, held);
403439
- return held;
403440
404533
  } catch (err) {
403441
- this.announceLiveView(runId, void 0, false);
403442
404534
  await owned?.close().catch(() => {
403443
404535
  });
403444
404536
  await browser2?.close().catch(() => {
@@ -403447,8 +404539,74 @@ var RemoteBrowserController = class {
403447
404539
  });
403448
404540
  throw err;
403449
404541
  }
404542
+ this.announceLiveView(runId, session.liveViewUrl, true);
404543
+ return held;
403450
404544
  }
403451
- async perform(held, action, planned) {
404545
+ /** Count main-frame navigation only while this page is the run's active page. */
404546
+ trackPageNavigation(runId, held, page2) {
404547
+ page2.on("framenavigated", ((frame) => {
404548
+ if (held.page !== page2 || frame !== page2.mainFrame()) return;
404549
+ held.generation += 1;
404550
+ if (held.onFrame && held.watchGen !== void 0) {
404551
+ held.watchGen += 1;
404552
+ this.announceControl(runId);
404553
+ }
404554
+ }));
404555
+ }
404556
+ /**
404557
+ * Follow a page opened by the action that just finished.
404558
+ *
404559
+ * A popup/new tab is part of the same user gesture (OAuth is the common
404560
+ * example). Keeping `held.page` and the screencast on its opener makes the
404561
+ * new page both invisible and impossible to drive. This runs while the SAME
404562
+ * action slot is still held, after the opening click/key has fully landed, so
404563
+ * no later human/agent event can slip between detecting the popup and moving
404564
+ * the view to it.
404565
+ */
404566
+ async followOpenedPage(runId, held, before) {
404567
+ if (this.runs.get(runId) !== held || held.abort.signal.aborted) return;
404568
+ const candidates = held.page.context().pages().filter((p3) => !p3.isClosed() && !before.has(p3));
404569
+ const next = candidates.at(-1);
404570
+ if (!next || next === held.page) return;
404571
+ await this.adoptPage(runId, held, next);
404572
+ }
404573
+ /**
404574
+ * Make one live page the run's controlled/viewed page. Used in both
404575
+ * directions: into a popup opened by an action, and back to another surviving
404576
+ * page when that popup closes itself after OAuth/login completion.
404577
+ */
404578
+ async adoptPage(runId, held, next) {
404579
+ if (next === held.page || next.isClosed()) return;
404580
+ held.page = next;
404581
+ held.generation += 1;
404582
+ held.viewport = void 0;
404583
+ held.metricsStale = true;
404584
+ this.trackPageNavigation(runId, held, next);
404585
+ const deliver = held.onFrame;
404586
+ if (deliver) {
404587
+ held.watchGen = (held.watchGen ?? 0) + 1;
404588
+ held.visibilityUnconfirmed = true;
404589
+ held.capturing = false;
404590
+ this.announceControl(runId);
404591
+ await this.attachScreencast(runId, held, deliver, held.watchDetachGen ?? 0);
404592
+ return;
404593
+ }
404594
+ const pending = held.attaching;
404595
+ if (pending) await pending.catch(() => null);
404596
+ if (held.screencast && held.screencastPage !== next) {
404597
+ const stale = held.screencast;
404598
+ held.screencast = void 0;
404599
+ held.screencastPage = void 0;
404600
+ await stale.send("Page.stopScreencast").catch(() => {
404601
+ });
404602
+ await stale.detach().catch(() => {
404603
+ });
404604
+ }
404605
+ held.visibilityUnconfirmed = false;
404606
+ held.capturing = held.hiddenByHolder !== true;
404607
+ this.announceControl(runId);
404608
+ }
404609
+ async perform(runId, held, action, planned) {
403452
404610
  const { page: page2 } = held;
403453
404611
  const stoppable = async (work) => {
403454
404612
  if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
@@ -403470,10 +404628,10 @@ var RemoteBrowserController = class {
403470
404628
  }
403471
404629
  };
403472
404630
  const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
403473
- const where = async () => ({
403474
- url: page2.url(),
403475
- title: await page2.title().catch(() => "")
403476
- });
404631
+ const where = async () => {
404632
+ const current = held.page;
404633
+ return { url: current.url(), title: await current.title().catch(() => "") };
404634
+ };
403477
404635
  if (action.kind !== "navigate" && held.generation !== planned) {
403478
404636
  return {
403479
404637
  ok: false,
@@ -403490,15 +404648,20 @@ var RemoteBrowserController = class {
403490
404648
  await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
403491
404649
  return { ok: true, detail: `Opened ${target}`, ...await where() };
403492
404650
  }
403493
- case "click":
404651
+ case "click": {
404652
+ const pagesBefore = new Set(page2.context().pages());
403494
404653
  await stoppable(page2.click(action.selector, { timeout }));
404654
+ await this.followOpenedPage(runId, held, pagesBefore);
403495
404655
  return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
404656
+ }
403496
404657
  case "fill":
403497
404658
  await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
403498
404659
  return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
403499
404660
  case "press": {
404661
+ const pagesBefore = new Set(page2.context().pages());
403500
404662
  if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
403501
404663
  else await stoppable(page2.keyboard.press(action.key));
404664
+ await this.followOpenedPage(runId, held, pagesBefore);
403502
404665
  return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
403503
404666
  }
403504
404667
  case "wait_for":
@@ -403514,10 +404677,25 @@ var RemoteBrowserController = class {
403514
404677
  }
403515
404678
  };
403516
404679
  function refusal(result) {
404680
+ if (result === "human") {
404681
+ return "The user has taken control of this browser. Stop using it and tell them what you were about to do.";
404682
+ }
403517
404683
  if (result === "off") return "Browser control was turned off while this action waited for the browser.";
403518
404684
  if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
403519
404685
  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.";
403520
404686
  }
404687
+ function coordinate(n8, what) {
404688
+ if (typeof n8 !== "number" || !Number.isFinite(n8)) {
404689
+ throw new Error(`That ${what} is not a position on the page.`);
404690
+ }
404691
+ return Math.min(Math.max(n8, 0), 1);
404692
+ }
404693
+ function scrollDelta(n8) {
404694
+ if (typeof n8 !== "number" || !Number.isFinite(n8)) {
404695
+ throw new Error("That scroll has no distance to travel.");
404696
+ }
404697
+ return Math.min(Math.max(n8, -2e3), 2e3);
404698
+ }
403521
404699
  async function loadPlaywright() {
403522
404700
  try {
403523
404701
  return await import('playwright');