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