cascade-ai 0.79.0 → 0.81.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 +310 -11
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +310 -11
- package/dist/cli.js.map +1 -1
- package/dist/desktop-core.cjs +1758 -79
- package/dist/index.cjs +1760 -81
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +832 -14
- package/dist/index.d.ts +832 -14
- package/dist/index.js +1757 -82
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/desktop-core.cjs
CHANGED
|
@@ -312562,7 +312562,7 @@ function reconcilePrice(model, live) {
|
|
|
312562
312562
|
}
|
|
312563
312563
|
|
|
312564
312564
|
// src/constants.ts
|
|
312565
|
-
var CASCADE_VERSION = "0.
|
|
312565
|
+
var CASCADE_VERSION = "0.81.0";
|
|
312566
312566
|
var CASCADE_CONFIG_DIR = ".cascade";
|
|
312567
312567
|
var CASCADE_MD_FILE = "CASCADE.md";
|
|
312568
312568
|
var CASCADE_IGNORE_FILE = ".cascadeignore";
|
|
@@ -398034,8 +398034,95 @@ function requiredFieldFor(kind2, input) {
|
|
|
398034
398034
|
}
|
|
398035
398035
|
}
|
|
398036
398036
|
|
|
398037
|
+
// src/tools/ask-user.ts
|
|
398038
|
+
var MAX_QUESTIONS = 5;
|
|
398039
|
+
var MAX_OPTIONS = 6;
|
|
398040
|
+
var AskUserTool = class extends BaseTool {
|
|
398041
|
+
name = "ask_user";
|
|
398042
|
+
description = "Ask the person a short questionnaire when their request is genuinely ambiguous, instead of guessing. Use it when two readings of the request would produce materially different work \u2014 which account, which date range, whether to include drafts \u2014 and NOT for anything you can settle yourself or from the conversation. Ask everything you need in ONE call: several questions together read as a form, the same questions one per turn read as an interrogation. You may get no answer (nobody is watching, or they did not reply); if so, proceed on your best reading and say which assumption you made.";
|
|
398043
|
+
inputSchema = {
|
|
398044
|
+
type: "object",
|
|
398045
|
+
properties: {
|
|
398046
|
+
questions: {
|
|
398047
|
+
type: "array",
|
|
398048
|
+
minItems: 1,
|
|
398049
|
+
maxItems: MAX_QUESTIONS,
|
|
398050
|
+
description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
|
|
398051
|
+
items: {
|
|
398052
|
+
type: "object",
|
|
398053
|
+
properties: {
|
|
398054
|
+
prompt: { type: "string", description: "The question, as you would say it to them." },
|
|
398055
|
+
kind: {
|
|
398056
|
+
type: "string",
|
|
398057
|
+
enum: ["choice", "multi", "text"],
|
|
398058
|
+
description: "choice = pick one, multi = pick any that apply, text = free-form answer."
|
|
398059
|
+
},
|
|
398060
|
+
options: {
|
|
398061
|
+
type: "array",
|
|
398062
|
+
items: { type: "string" },
|
|
398063
|
+
description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
|
|
398064
|
+
}
|
|
398065
|
+
},
|
|
398066
|
+
required: ["prompt", "kind"]
|
|
398067
|
+
}
|
|
398068
|
+
}
|
|
398069
|
+
},
|
|
398070
|
+
required: ["questions"]
|
|
398071
|
+
};
|
|
398072
|
+
ask;
|
|
398073
|
+
constructor(ask) {
|
|
398074
|
+
super();
|
|
398075
|
+
this.ask = ask;
|
|
398076
|
+
}
|
|
398077
|
+
// Asking a question changes nothing, so it needs no approval gate: a prompt
|
|
398078
|
+
// asking permission to show a prompt is a tax on the behaviour we want.
|
|
398079
|
+
isDangerous() {
|
|
398080
|
+
return false;
|
|
398081
|
+
}
|
|
398082
|
+
async execute(input, options) {
|
|
398083
|
+
const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
|
|
398084
|
+
const questions = [];
|
|
398085
|
+
for (const [i4, q4] of raw.slice(0, MAX_QUESTIONS).entries()) {
|
|
398086
|
+
const item = q4 ?? {};
|
|
398087
|
+
const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
|
|
398088
|
+
if (!prompt) continue;
|
|
398089
|
+
const kind2 = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
|
|
398090
|
+
const options_ = Array.isArray(item["options"]) ? item["options"].filter((o3) => typeof o3 === "string" && o3.trim() !== "").slice(0, MAX_OPTIONS) : [];
|
|
398091
|
+
const resolved = kind2 !== "text" && options_.length < 2 ? "text" : kind2;
|
|
398092
|
+
questions.push({
|
|
398093
|
+
// Positional, and assigned HERE. Letting the model supply ids invites
|
|
398094
|
+
// two questions sharing one, and the second answer would overwrite the
|
|
398095
|
+
// first with nothing to say it had.
|
|
398096
|
+
id: `q${i4 + 1}`,
|
|
398097
|
+
prompt,
|
|
398098
|
+
kind: resolved,
|
|
398099
|
+
...resolved === "text" ? {} : { options: options_ }
|
|
398100
|
+
});
|
|
398101
|
+
}
|
|
398102
|
+
if (questions.length === 0) {
|
|
398103
|
+
return "Error: ask_user needs at least one question with a prompt.";
|
|
398104
|
+
}
|
|
398105
|
+
const result = await this.ask(questions, options.signal);
|
|
398106
|
+
if (result.outcome !== "answered" || result.answers.length === 0) {
|
|
398107
|
+
const why = result.outcome === "no-listener" ? "There is nobody watching this run to answer" : result.outcome === "aborted" ? "The run was stopped while the question was open" : result.outcome === "answered" ? "They saw the questions and chose not to answer" : "They did not answer in time";
|
|
398108
|
+
return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
|
|
398109
|
+
}
|
|
398110
|
+
const byId = new Map(result.answers.map((a2) => [a2.id, a2.value]));
|
|
398111
|
+
const lines = questions.map((q4) => {
|
|
398112
|
+
const value = byId.get(q4.id);
|
|
398113
|
+
const said = Array.isArray(value) ? value.join(", ") : value;
|
|
398114
|
+
return `${q4.prompt}
|
|
398115
|
+
\u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
|
|
398116
|
+
});
|
|
398117
|
+
return `They answered:
|
|
398118
|
+
|
|
398119
|
+
${lines.join("\n\n")}`;
|
|
398120
|
+
}
|
|
398121
|
+
};
|
|
398122
|
+
|
|
398037
398123
|
// src/core/cascade.ts
|
|
398038
398124
|
var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
|
|
398125
|
+
var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
|
|
398039
398126
|
function buildContextualPrompt(prompt, history = []) {
|
|
398040
398127
|
const recent = history.slice(-6);
|
|
398041
398128
|
if (recent.length === 0) return prompt;
|
|
@@ -398314,6 +398401,112 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
398314
398401
|
// timeout. Same class of bug as the twelve-failover dead-model race: a wave
|
|
398315
398402
|
// is concurrent, so per-run singletons are never safe inside it.
|
|
398316
398403
|
pendingEscalations = /* @__PURE__ */ new Map();
|
|
398404
|
+
/**
|
|
398405
|
+
* Questionnaires waiting on a person, keyed by request id.
|
|
398406
|
+
*
|
|
398407
|
+
* Exactly the shape `pendingEscalations` uses, and for the same reason: the
|
|
398408
|
+
* answer, the timeout and the abort all race, and one settle path guarded by
|
|
398409
|
+
* the map delete is what makes the first of them win.
|
|
398410
|
+
*/
|
|
398411
|
+
pendingClarifications = /* @__PURE__ */ new Map();
|
|
398412
|
+
/**
|
|
398413
|
+
* Put a gate's opening question to its listeners, and NOTICE when one fails.
|
|
398414
|
+
*
|
|
398415
|
+
* `emit` cannot do this job. It calls listeners synchronously and discards
|
|
398416
|
+
* what they return, so an `async` listener — the ordinary shape for anything
|
|
398417
|
+
* that has to reach a socket or a UI — does not fail in a way `emit` reports:
|
|
398418
|
+
* it returns a REJECTED PROMISE that nobody is holding. The `try` around the
|
|
398419
|
+
* emit sees nothing, the gate is never told delivery failed, and it parks for
|
|
398420
|
+
* its full timeout while Node raises an unhandled rejection that can take the
|
|
398421
|
+
* process down.
|
|
398422
|
+
*
|
|
398423
|
+
* `onFailed` is called for either shape, and it is safe to call more than
|
|
398424
|
+
* once: both gates route it to `settle`, which is guarded by a map delete, so
|
|
398425
|
+
* the first failure wins and the rest are no-ops.
|
|
398426
|
+
*
|
|
398427
|
+
* PER LISTENER, not around the loop. `emit` stops dispatching at the first
|
|
398428
|
+
* throw, so one broken host used to cost a working one its notification —
|
|
398429
|
+
* the same reason `announceLiveView` delivers its two listeners separately.
|
|
398430
|
+
* A synchronous failure settles the gate once the whole snapshot has been
|
|
398431
|
+
* delivered; an asynchronous one settles it when the rejection arrives, which
|
|
398432
|
+
* is still far better than the window-long park it replaces.
|
|
398433
|
+
*
|
|
398434
|
+
* SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
|
|
398435
|
+
* settles the gate, and settling emits the gate's CLOSURE — so failing
|
|
398436
|
+
* mid-dispatch announced "this request is over" and then went on handing the
|
|
398437
|
+
* OPENING event to the listeners after it. A host later in the list would
|
|
398438
|
+
* draw a form whose only closure notification had already gone past, and it
|
|
398439
|
+
* would stand there forever. Deliver to everyone, then decide.
|
|
398440
|
+
*
|
|
398441
|
+
* `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
|
|
398442
|
+
* `emit` was doing, and `once` is the part that bites: `listeners()` hands
|
|
398443
|
+
* back the UNWRAPPED callback, so calling it never runs the wrapper that
|
|
398444
|
+
* deregisters it. A host that asked to be told once would be told every
|
|
398445
|
+
* time — and told through a transport it had set up for one gate only.
|
|
398446
|
+
* `rawListeners()` returns the wrapper, which removes the registration and
|
|
398447
|
+
* passes the underlying listener's return value straight through, so the
|
|
398448
|
+
* promise below is still the one being observed. Verified against Node.
|
|
398449
|
+
*
|
|
398450
|
+
* `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
|
|
398451
|
+
* invokes listeners with the emitter as the receiver, so
|
|
398452
|
+
* `function () { this.resolveClarification(...) }` is a legitimate host — and
|
|
398453
|
+
* calling it bare gave it `this === undefined`, which throws, which this
|
|
398454
|
+
* would then have read as the host failing to receive the question and
|
|
398455
|
+
* settled the gate unanswered. A working listener turned into a delivery
|
|
398456
|
+
* failure by the thing delivering it.
|
|
398457
|
+
*/
|
|
398458
|
+
deliverGate(event, payload, onFailed) {
|
|
398459
|
+
let delivered = false;
|
|
398460
|
+
let threw = false;
|
|
398461
|
+
const pending = [];
|
|
398462
|
+
for (const listener of this.rawListeners(event)) {
|
|
398463
|
+
try {
|
|
398464
|
+
const out = listener.call(this, payload);
|
|
398465
|
+
if (out && typeof out.then === "function") {
|
|
398466
|
+
pending.push(Promise.resolve(out).then(() => true, () => false));
|
|
398467
|
+
} else {
|
|
398468
|
+
delivered = true;
|
|
398469
|
+
}
|
|
398470
|
+
} catch {
|
|
398471
|
+
threw = true;
|
|
398472
|
+
}
|
|
398473
|
+
}
|
|
398474
|
+
if (delivered) return;
|
|
398475
|
+
if (pending.length === 0) {
|
|
398476
|
+
if (threw) onFailed();
|
|
398477
|
+
return;
|
|
398478
|
+
}
|
|
398479
|
+
void Promise.all(pending).then((results) => {
|
|
398480
|
+
if (!results.some(Boolean)) onFailed();
|
|
398481
|
+
});
|
|
398482
|
+
}
|
|
398483
|
+
/**
|
|
398484
|
+
* Tell listeners something that is over, and let none of them make it worse.
|
|
398485
|
+
*
|
|
398486
|
+
* The closing half of the gate family — `clarification:closed`,
|
|
398487
|
+
* `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
|
|
398488
|
+
* was already wrapped in a `try` for the synchronous case, and each had the
|
|
398489
|
+
* same hole `deliverGate` had before last round: `emit` discards what a
|
|
398490
|
+
* listener returns, so an `async` host fails by returning a rejected promise
|
|
398491
|
+
* nobody is holding and Node raises an unhandled rejection.
|
|
398492
|
+
*
|
|
398493
|
+
* There is nothing to settle here — the gate is already closed, which is the
|
|
398494
|
+
* whole message — so unlike `deliverGate` this reports to no one. It exists
|
|
398495
|
+
* so that the four informational emits cannot take the process down, and so
|
|
398496
|
+
* the rule is written once rather than four times.
|
|
398497
|
+
*/
|
|
398498
|
+
announceGateClosed(event, payload) {
|
|
398499
|
+
for (const listener of this.rawListeners(event)) {
|
|
398500
|
+
try {
|
|
398501
|
+
const out = listener.call(this, payload);
|
|
398502
|
+
if (out && typeof out.then === "function") {
|
|
398503
|
+
void Promise.resolve(out).catch(() => {
|
|
398504
|
+
});
|
|
398505
|
+
}
|
|
398506
|
+
} catch {
|
|
398507
|
+
}
|
|
398508
|
+
}
|
|
398509
|
+
}
|
|
398317
398510
|
async requestEscalationDecision(ctx, taskId, signal) {
|
|
398318
398511
|
if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
|
|
398319
398512
|
if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
|
|
@@ -398326,23 +398519,28 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
398326
398519
|
if (timeout) clearTimeout(timeout);
|
|
398327
398520
|
signal?.removeEventListener("abort", onAbort);
|
|
398328
398521
|
resolve2(decision);
|
|
398522
|
+
this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
|
|
398329
398523
|
};
|
|
398330
398524
|
const onAbort = () => settle({ action: "skip", automatic: true });
|
|
398331
398525
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
398332
398526
|
timeout = setTimeout(() => {
|
|
398333
|
-
this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
|
|
398334
398527
|
settle({ action: "timeout" });
|
|
398528
|
+
this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
|
|
398335
398529
|
}, ESCALATION_DECISION_TIMEOUT_MS);
|
|
398336
398530
|
this.pendingEscalations.set(requestId, settle);
|
|
398337
|
-
this.
|
|
398338
|
-
|
|
398339
|
-
|
|
398340
|
-
|
|
398341
|
-
|
|
398342
|
-
|
|
398343
|
-
|
|
398344
|
-
|
|
398345
|
-
|
|
398531
|
+
this.deliverGate(
|
|
398532
|
+
"escalation:decision-required",
|
|
398533
|
+
{
|
|
398534
|
+
taskId,
|
|
398535
|
+
requestId,
|
|
398536
|
+
sectionId: ctx.sectionId,
|
|
398537
|
+
sectionTitle: ctx.sectionTitle,
|
|
398538
|
+
issues: ctx.issues,
|
|
398539
|
+
summary: ctx.summary,
|
|
398540
|
+
timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
|
|
398541
|
+
},
|
|
398542
|
+
() => settle({ action: "skip", automatic: true })
|
|
398543
|
+
);
|
|
398346
398544
|
});
|
|
398347
398545
|
}
|
|
398348
398546
|
/**
|
|
@@ -398379,6 +398577,73 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
398379
398577
|
releasePendingEscalations(action = "skip") {
|
|
398380
398578
|
for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
|
|
398381
398579
|
}
|
|
398580
|
+
// ── Clarification: asking instead of assuming ───────────────────────
|
|
398581
|
+
// The same gate shape as the escalation above, and for the same reasons. The
|
|
398582
|
+
// difference that matters: an unanswered escalation SKIPS a section, while an
|
|
398583
|
+
// unanswered question just means the model proceeds on its own reading — so
|
|
398584
|
+
// every non-answer here is benign, and none of them may park the run.
|
|
398585
|
+
/**
|
|
398586
|
+
* Put a questionnaire to whoever is watching, if anybody is.
|
|
398587
|
+
*
|
|
398588
|
+
* Returns `no-listener` rather than waiting when there is nothing attached to
|
|
398589
|
+
* answer — a scheduled run, an API caller with no socket, an autonomous run
|
|
398590
|
+
* that has already said it wants no prompts. That check is the whole reason
|
|
398591
|
+
* this can be offered to the model at all: a tool that might hang a headless
|
|
398592
|
+
* run forever is a tool that cannot be registered.
|
|
398593
|
+
*/
|
|
398594
|
+
async askUser(questions, signal) {
|
|
398595
|
+
const none = (outcome) => ({ outcome, answers: [] });
|
|
398596
|
+
if (this.unattended) return none("no-listener");
|
|
398597
|
+
if (this.config.autonomy === "auto") return none("no-listener");
|
|
398598
|
+
if (this.listenerCount("clarification:required") === 0) return none("no-listener");
|
|
398599
|
+
if (signal?.aborted) return none("aborted");
|
|
398600
|
+
const requestId = crypto2.randomUUID();
|
|
398601
|
+
return await new Promise((resolve2) => {
|
|
398602
|
+
let timeout;
|
|
398603
|
+
const settle = (result) => {
|
|
398604
|
+
if (!this.pendingClarifications.delete(requestId)) return;
|
|
398605
|
+
if (timeout) clearTimeout(timeout);
|
|
398606
|
+
signal?.removeEventListener("abort", onAbort);
|
|
398607
|
+
resolve2(result);
|
|
398608
|
+
this.announceGateClosed("clarification:closed", { requestId });
|
|
398609
|
+
};
|
|
398610
|
+
const onAbort = () => settle(none("aborted"));
|
|
398611
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
398612
|
+
timeout = setTimeout(() => {
|
|
398613
|
+
settle(none("timeout"));
|
|
398614
|
+
this.announceGateClosed("clarification:timeout", { requestId });
|
|
398615
|
+
}, CLARIFICATION_TIMEOUT_MS);
|
|
398616
|
+
this.pendingClarifications.set(requestId, settle);
|
|
398617
|
+
this.deliverGate(
|
|
398618
|
+
"clarification:required",
|
|
398619
|
+
{ requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
|
|
398620
|
+
() => settle(none("no-listener"))
|
|
398621
|
+
);
|
|
398622
|
+
});
|
|
398623
|
+
}
|
|
398624
|
+
/**
|
|
398625
|
+
* Answer a questionnaire from a REPL / dashboard / cloud listener.
|
|
398626
|
+
*
|
|
398627
|
+
* `requestId` comes back on the `clarification:required` event and is what
|
|
398628
|
+
* makes an answer land on the question that asked. Optional for a host with
|
|
398629
|
+
* only one in flight, matching `resolveEscalation`: without it the oldest
|
|
398630
|
+
* outstanding request is answered, which is correct whenever there is one.
|
|
398631
|
+
*/
|
|
398632
|
+
resolveClarification(answers, requestId) {
|
|
398633
|
+
const result = { outcome: "answered", answers };
|
|
398634
|
+
if (requestId) {
|
|
398635
|
+
this.pendingClarifications.get(requestId)?.(result);
|
|
398636
|
+
return;
|
|
398637
|
+
}
|
|
398638
|
+
const oldest = this.pendingClarifications.keys().next();
|
|
398639
|
+
if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
|
|
398640
|
+
}
|
|
398641
|
+
/** Release every parked questionnaire — run teardown, so none outlive the run. */
|
|
398642
|
+
releasePendingClarifications() {
|
|
398643
|
+
for (const settle of Array.from(this.pendingClarifications.values())) {
|
|
398644
|
+
settle({ outcome: "aborted", answers: [] });
|
|
398645
|
+
}
|
|
398646
|
+
}
|
|
398382
398647
|
/**
|
|
398383
398648
|
* Resolve a pending boardroom plan approval from a REPL / dashboard listener.
|
|
398384
398649
|
* An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
|
|
@@ -398403,12 +398668,14 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
398403
398668
|
const timeout = setTimeout(() => {
|
|
398404
398669
|
if (this.pendingContextApproval) {
|
|
398405
398670
|
this.pendingContextApproval = void 0;
|
|
398671
|
+
this.announceGateClosed("context:approval-closed", {});
|
|
398406
398672
|
resolve2(true);
|
|
398407
398673
|
}
|
|
398408
398674
|
}, 12e4);
|
|
398409
398675
|
this.pendingContextApproval = (approved) => {
|
|
398410
398676
|
clearTimeout(timeout);
|
|
398411
398677
|
this.pendingContextApproval = void 0;
|
|
398678
|
+
this.announceGateClosed("context:approval-closed", {});
|
|
398412
398679
|
resolve2(approved);
|
|
398413
398680
|
};
|
|
398414
398681
|
this.emit("context:approval-required", info);
|
|
@@ -398599,6 +398866,34 @@ ${input}` }],
|
|
|
398599
398866
|
if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
|
|
398600
398867
|
this.toolRegistry.register(new BrowserControlTool(controller, release));
|
|
398601
398868
|
}
|
|
398869
|
+
/**
|
|
398870
|
+
* Declare that this host can put a questionnaire to a person and answer it.
|
|
398871
|
+
*
|
|
398872
|
+
* Explicit, and NOT done at construction, because registering a tool is a
|
|
398873
|
+
* promise the host has to keep. A host that registers `ask_user` without
|
|
398874
|
+
* listening for `clarification:required` gives the model a tool that always
|
|
398875
|
+
* answers "there is nobody watching this run" — while a person sits at the
|
|
398876
|
+
* terminal looking at it. That is worse than not offering the tool at all:
|
|
398877
|
+
* it costs a round trip and then tells the model something false.
|
|
398878
|
+
*
|
|
398879
|
+
* Only `cloud/server` bridges the protocol today. The CLI REPL and the
|
|
398880
|
+
* dashboard do not, so they do not call this, and their models are not shown
|
|
398881
|
+
* a capability nobody can serve. Wiring either of them is a matter of
|
|
398882
|
+
* listening for the event and calling `resolveClarification`, then calling
|
|
398883
|
+
* this.
|
|
398884
|
+
*
|
|
398885
|
+
* Still gated on the same conditions as the call itself, and `askUser`
|
|
398886
|
+
* re-checks them: `setUnattended` can land after this, so the tool can be
|
|
398887
|
+
* registered for a run that later turns out to have nobody on it. Then it
|
|
398888
|
+
* answers `no-listener` and the model proceeds on its own reading, which is
|
|
398889
|
+
* the same defence-in-depth `BrowserControlTool.revoke` provides.
|
|
398890
|
+
*/
|
|
398891
|
+
enableClarification() {
|
|
398892
|
+
if (this.unattended) return;
|
|
398893
|
+
if (this.config.autonomy === "auto") return;
|
|
398894
|
+
if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
|
|
398895
|
+
this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
|
|
398896
|
+
}
|
|
398602
398897
|
registerMediaTools(workspacePath) {
|
|
398603
398898
|
const denied = new Set(this.config.tools?.disabledTools ?? []);
|
|
398604
398899
|
const permitted = (name) => !denied.has(name);
|
|
@@ -399544,6 +399839,10 @@ ${finalOutput}` : ""
|
|
|
399544
399839
|
this.releasePendingEscalations();
|
|
399545
399840
|
} catch {
|
|
399546
399841
|
}
|
|
399842
|
+
try {
|
|
399843
|
+
this.releasePendingClarifications();
|
|
399844
|
+
} catch {
|
|
399845
|
+
}
|
|
399547
399846
|
this.router.restoreTierModels();
|
|
399548
399847
|
this.router.setRunSignal(void 0);
|
|
399549
399848
|
if (this.taskAnalyzer) {
|
|
@@ -402934,11 +403233,13 @@ async function discoverMcpTools(servers) {
|
|
|
402934
403233
|
}
|
|
402935
403234
|
|
|
402936
403235
|
// src/browser/lease.ts
|
|
403236
|
+
var HUMAN_ACTOR = "__human__";
|
|
403237
|
+
var HUMAN_IDLE_MS = 12e4;
|
|
402937
403238
|
var LEASE_DEADLOCK_CEILING_MS = 3e5;
|
|
402938
403239
|
var QUEUE_WAIT_MS = 18e4;
|
|
402939
403240
|
var MAX_QUEUED = 8;
|
|
402940
403241
|
var ACTION_HANDOFF_MS = 2e3;
|
|
402941
|
-
var
|
|
403242
|
+
var HANDBACK_RETRY_MS = 25;
|
|
402942
403243
|
var BrowserLease = class {
|
|
402943
403244
|
actor = null;
|
|
402944
403245
|
session = null;
|
|
@@ -402957,6 +403258,51 @@ var BrowserLease = class {
|
|
|
402957
403258
|
* took the slot may release it.
|
|
402958
403259
|
*/
|
|
402959
403260
|
action = null;
|
|
403261
|
+
/** Whether the current action slot represents activity by its holder. */
|
|
403262
|
+
actionCountsAsHumanActivity = false;
|
|
403263
|
+
/**
|
|
403264
|
+
* The human action that has already consumed its one idle-deadline grace.
|
|
403265
|
+
* A single wedged CDP request must not look like fresh activity every two
|
|
403266
|
+
* minutes forever. The first deadline may yield to an event that began before
|
|
403267
|
+
* it; a second deadline for that SAME event becomes a pending lapse instead.
|
|
403268
|
+
*/
|
|
403269
|
+
idleGraceAction = null;
|
|
403270
|
+
/** Release the human hold as soon as this overlong action finally unwinds. */
|
|
403271
|
+
idleLapseAfterAction = null;
|
|
403272
|
+
/**
|
|
403273
|
+
* Everyone waiting for that slot, IN THE ORDER THEY ASKED.
|
|
403274
|
+
*
|
|
403275
|
+
* A queue rather than a poll, and the ordering is the whole reason for it.
|
|
403276
|
+
* `acquireActionSlot` used to spin on a 25ms timer, which decides nothing
|
|
403277
|
+
* about who goes first: each waiter checks on its own phase, so the one that
|
|
403278
|
+
* asked SECOND can find the slot free a full poll ahead of the one that asked
|
|
403279
|
+
* first. The person types one event per keypress — `{ kind: 'text', text }`
|
|
403280
|
+
* carries a single character — so that reordering is not a theoretical
|
|
403281
|
+
* unfairness, it is "passowrd" appearing in the box.
|
|
403282
|
+
*
|
|
403283
|
+
* The slot is handed straight from `endAction` to the head of this line while
|
|
403284
|
+
* still synchronous, so nothing can slip in between the two, and a waiter
|
|
403285
|
+
* that reaches the front never has to be told the slot is already taken.
|
|
403286
|
+
*/
|
|
403287
|
+
actionQueue = [];
|
|
403288
|
+
/** Lapses a human hold nobody is using. See `HUMAN_IDLE_MS`. */
|
|
403289
|
+
humanTimer = null;
|
|
403290
|
+
/**
|
|
403291
|
+
* The person ASKED for the hold to end and it could not end yet.
|
|
403292
|
+
*
|
|
403293
|
+
* Kept as a request rather than only a retry timer, because the retry shares
|
|
403294
|
+
* `humanTimer` with the idle deadline: an authorised event settling after the
|
|
403295
|
+
* request would otherwise call `touch()`, re-arm that timer for a full idle
|
|
403296
|
+
* interval, and quietly cancel a handback the person had already asked for.
|
|
403297
|
+
* "Give it back" is not undone by activity that was already under way when
|
|
403298
|
+
* they said it.
|
|
403299
|
+
*
|
|
403300
|
+
* Explicit requests ONLY. An idle expiry must never set this: a deadline that
|
|
403301
|
+
* lands on the person's own in-flight event is evidence they were THERE, and
|
|
403302
|
+
* recording it here made `touch()` bail afterwards, so the hold dropped the
|
|
403303
|
+
* instant a fresh interaction finished. See `lapse()`.
|
|
403304
|
+
*/
|
|
403305
|
+
handBackWanted = false;
|
|
402960
403306
|
isRevoked;
|
|
402961
403307
|
onChange;
|
|
402962
403308
|
constructor(options = {}) {
|
|
@@ -402967,6 +403313,20 @@ var BrowserLease = class {
|
|
|
402967
403313
|
get holderActor() {
|
|
402968
403314
|
return this.actor;
|
|
402969
403315
|
}
|
|
403316
|
+
/** Whether a person, rather than the agent, is driving the page right now. */
|
|
403317
|
+
get heldByHuman() {
|
|
403318
|
+
return this.actor === HUMAN_ACTOR;
|
|
403319
|
+
}
|
|
403320
|
+
/**
|
|
403321
|
+
* Whether THIS actor is the one holding the browser right now.
|
|
403322
|
+
*
|
|
403323
|
+
* For asking again after a wait. `acquire` is re-entrant for the holder, so
|
|
403324
|
+
* "I was granted the lease" is a fact about the moment it was granted and
|
|
403325
|
+
* says nothing about the moment the caller finally gets to act.
|
|
403326
|
+
*/
|
|
403327
|
+
heldBy(actorId) {
|
|
403328
|
+
return this.actor === actorId;
|
|
403329
|
+
}
|
|
402970
403330
|
get holderSession() {
|
|
402971
403331
|
return this.session;
|
|
402972
403332
|
}
|
|
@@ -402993,6 +403353,7 @@ var BrowserLease = class {
|
|
|
402993
403353
|
* the same worker coming back for its next step must not queue behind itself.
|
|
402994
403354
|
*/
|
|
402995
403355
|
async acquire(actorId, sessionId, signal) {
|
|
403356
|
+
if (this.actor === HUMAN_ACTOR && actorId !== HUMAN_ACTOR) return "human";
|
|
402996
403357
|
if (this.actor === null || this.actor === actorId) {
|
|
402997
403358
|
this.actor = actorId;
|
|
402998
403359
|
this.session = sessionId;
|
|
@@ -403033,6 +403394,105 @@ var BrowserLease = class {
|
|
|
403033
403394
|
if (this.action) return;
|
|
403034
403395
|
if (this.actor === actorId) this.release();
|
|
403035
403396
|
}
|
|
403397
|
+
/**
|
|
403398
|
+
* Give the browser to the person watching it.
|
|
403399
|
+
*
|
|
403400
|
+
* Callers must have waited the in-flight action out first — see
|
|
403401
|
+
* `acquireActionSlot`. This does not interrupt one, because it cannot: a
|
|
403402
|
+
* Playwright call already inside the page finishes whatever it started, and
|
|
403403
|
+
* taking the lease off it would only mean the person and a still-running
|
|
403404
|
+
* `fill` were both touching the same form. Waiting is what makes "you have
|
|
403405
|
+
* control" true when the UI says it.
|
|
403406
|
+
*
|
|
403407
|
+
* Every queued worker is turned away rather than left in line, for the same
|
|
403408
|
+
* reason `acquire` refuses: they were planned against a page the person is
|
|
403409
|
+
* about to change.
|
|
403410
|
+
*/
|
|
403411
|
+
takeOver(sessionId) {
|
|
403412
|
+
for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) this.waiting[i4].settle("human");
|
|
403413
|
+
this.clearCeiling();
|
|
403414
|
+
this.actor = HUMAN_ACTOR;
|
|
403415
|
+
this.session = sessionId;
|
|
403416
|
+
this.touch();
|
|
403417
|
+
this.onChange();
|
|
403418
|
+
}
|
|
403419
|
+
/**
|
|
403420
|
+
* The person is still there — anything they did counts.
|
|
403421
|
+
*
|
|
403422
|
+
* Called per authorised input rather than on a heartbeat, so the hold is
|
|
403423
|
+
* extended by USE and not merely by a tab being open. A left-open panel on a
|
|
403424
|
+
* forgotten laptop stops holding a billed session two minutes later.
|
|
403425
|
+
*/
|
|
403426
|
+
touch() {
|
|
403427
|
+
if (this.actor !== HUMAN_ACTOR) return;
|
|
403428
|
+
if (this.handBackWanted) return;
|
|
403429
|
+
this.armIdle();
|
|
403430
|
+
}
|
|
403431
|
+
/** (Re)start the idle interval. The only place the deadline is set. */
|
|
403432
|
+
armIdle() {
|
|
403433
|
+
this.clearHumanIdle();
|
|
403434
|
+
this.humanTimer = setTimeout(() => {
|
|
403435
|
+
this.lapse();
|
|
403436
|
+
}, HUMAN_IDLE_MS);
|
|
403437
|
+
this.humanTimer.unref?.();
|
|
403438
|
+
}
|
|
403439
|
+
/**
|
|
403440
|
+
* The idle deadline came round.
|
|
403441
|
+
*
|
|
403442
|
+
* Both ways a hold ends obey the same rule — never while one of the person's
|
|
403443
|
+
* own events is still landing — but they resolve that clash in OPPOSITE
|
|
403444
|
+
* directions, and routing the lapse through `handBack()` collapsed them into
|
|
403445
|
+
* one. An event in flight BEGAN before this deadline, which makes it evidence
|
|
403446
|
+
* the person was there; deferring the lapse and then releasing the moment
|
|
403447
|
+
* that event lands ends the hold immediately after a fresh interaction, which
|
|
403448
|
+
* is the precise opposite of what an idle deadline measures. So an
|
|
403449
|
+
* interrupted lapse yields: the interval starts again, and the next silence
|
|
403450
|
+
* decides.
|
|
403451
|
+
*
|
|
403452
|
+
* `handBack()` keeps the deferral instead, because "Give it back" is a
|
|
403453
|
+
* decision and activity already under way when it was made does not withdraw
|
|
403454
|
+
* it. Same rule, different answer, which is why they are different methods.
|
|
403455
|
+
*/
|
|
403456
|
+
lapse() {
|
|
403457
|
+
if (this.actor !== HUMAN_ACTOR) return;
|
|
403458
|
+
if (this.action && this.actionCountsAsHumanActivity) {
|
|
403459
|
+
if (this.idleGraceAction !== this.action) {
|
|
403460
|
+
this.idleGraceAction = this.action;
|
|
403461
|
+
this.armIdle();
|
|
403462
|
+
} else {
|
|
403463
|
+
this.idleLapseAfterAction = this.action;
|
|
403464
|
+
this.clearHumanIdle();
|
|
403465
|
+
}
|
|
403466
|
+
return;
|
|
403467
|
+
}
|
|
403468
|
+
this.release();
|
|
403469
|
+
}
|
|
403470
|
+
/**
|
|
403471
|
+
* The person hands the browser back, or their hold lapses.
|
|
403472
|
+
*
|
|
403473
|
+
* Answers whether there was a human hold to end, so a caller can tell a real
|
|
403474
|
+
* handback from a duplicate click or a lapse that already happened.
|
|
403475
|
+
*/
|
|
403476
|
+
handBack() {
|
|
403477
|
+
if (this.actor !== HUMAN_ACTOR) return false;
|
|
403478
|
+
if (this.action) {
|
|
403479
|
+
this.handBackWanted = true;
|
|
403480
|
+
this.clearHumanIdle();
|
|
403481
|
+
this.humanTimer = setTimeout(() => {
|
|
403482
|
+
this.handBack();
|
|
403483
|
+
}, HANDBACK_RETRY_MS);
|
|
403484
|
+
this.humanTimer.unref?.();
|
|
403485
|
+
return false;
|
|
403486
|
+
}
|
|
403487
|
+
this.release();
|
|
403488
|
+
return true;
|
|
403489
|
+
}
|
|
403490
|
+
clearHumanIdle() {
|
|
403491
|
+
if (this.humanTimer) {
|
|
403492
|
+
clearTimeout(this.humanTimer);
|
|
403493
|
+
this.humanTimer = null;
|
|
403494
|
+
}
|
|
403495
|
+
}
|
|
403036
403496
|
/** Drop every queued waiter belonging to a run, and the lease if it holds it. */
|
|
403037
403497
|
dropRun(sessionId) {
|
|
403038
403498
|
for (let i4 = this.waiting.length - 1; i4 >= 0; i4--) {
|
|
@@ -403048,6 +403508,10 @@ var BrowserLease = class {
|
|
|
403048
403508
|
/** Hand the browser to the next actor in line, if any. */
|
|
403049
403509
|
release() {
|
|
403050
403510
|
this.clearCeiling();
|
|
403511
|
+
this.clearHumanIdle();
|
|
403512
|
+
this.handBackWanted = false;
|
|
403513
|
+
this.idleGraceAction = null;
|
|
403514
|
+
this.idleLapseAfterAction = null;
|
|
403051
403515
|
this.actor = null;
|
|
403052
403516
|
this.session = null;
|
|
403053
403517
|
let next = this.waiting.shift();
|
|
@@ -403076,33 +403540,111 @@ var BrowserLease = class {
|
|
|
403076
403540
|
this.ceilingTimer = null;
|
|
403077
403541
|
}
|
|
403078
403542
|
}
|
|
403079
|
-
/**
|
|
403080
|
-
|
|
403543
|
+
/**
|
|
403544
|
+
* Take the per-action slot, or null when one is already running.
|
|
403545
|
+
*
|
|
403546
|
+
* PRIVATE, so the queue is the only way in. A caller that could take the slot
|
|
403547
|
+
* without joining the line would be exactly the jump-the-queue the ordering
|
|
403548
|
+
* is there to prevent.
|
|
403549
|
+
*/
|
|
403550
|
+
beginAction(countsAsHumanActivity = true) {
|
|
403081
403551
|
if (this.action) return null;
|
|
403082
403552
|
const token = /* @__PURE__ */ Symbol("browser-action");
|
|
403083
403553
|
this.action = token;
|
|
403554
|
+
this.actionCountsAsHumanActivity = countsAsHumanActivity;
|
|
403084
403555
|
return token;
|
|
403085
403556
|
}
|
|
403086
|
-
/**
|
|
403557
|
+
/**
|
|
403558
|
+
* Release the slot, but only for the action that took it.
|
|
403559
|
+
*
|
|
403560
|
+
* Hands it to the head of the line rather than merely clearing it, and does
|
|
403561
|
+
* so SYNCHRONOUSLY: the waiter's `beginAction` runs before this call returns,
|
|
403562
|
+
* so there is no window in which the slot is free and somebody who asked
|
|
403563
|
+
* later can take it.
|
|
403564
|
+
*/
|
|
403087
403565
|
endAction(token) {
|
|
403088
403566
|
if (this.action !== token) return false;
|
|
403567
|
+
const lapseNow = this.actor === HUMAN_ACTOR && this.idleLapseAfterAction === token;
|
|
403089
403568
|
this.action = null;
|
|
403569
|
+
this.actionCountsAsHumanActivity = false;
|
|
403570
|
+
if (this.idleGraceAction === token) this.idleGraceAction = null;
|
|
403571
|
+
if (this.idleLapseAfterAction === token) this.idleLapseAfterAction = null;
|
|
403572
|
+
if (lapseNow) this.release();
|
|
403573
|
+
this.actionQueue.shift()?.();
|
|
403090
403574
|
return true;
|
|
403091
403575
|
}
|
|
403092
403576
|
/**
|
|
403093
|
-
*
|
|
403577
|
+
* Take the slot only if it is free RIGHT NOW, never joining the line.
|
|
403578
|
+
*
|
|
403579
|
+
* For input that is a SAMPLE rather than a decision — pointer movement. A
|
|
403580
|
+
* click or a keystroke is a thing the person meant to do and has to happen,
|
|
403581
|
+
* so it waits its turn; a pointer position is one of many on the way
|
|
403582
|
+
* somewhere, and one that cannot be applied now is worth less than the one
|
|
403583
|
+
* behind it. Queueing them is actively harmful: on an endpoint slower than
|
|
403584
|
+
* the client's sampling interval the backlog grows, and the click behind it
|
|
403585
|
+
* can age out against `acquireActionSlot`'s bound and be refused — movement
|
|
403586
|
+
* costing the person the action they actually intended.
|
|
403587
|
+
*
|
|
403588
|
+
* The queue length is checked as well as the slot, so a sample cannot step
|
|
403589
|
+
* over a click that is already waiting.
|
|
403590
|
+
*/
|
|
403591
|
+
tryActionSlot() {
|
|
403592
|
+
if (this.action || this.actionQueue.length > 0) return null;
|
|
403593
|
+
return this.beginAction();
|
|
403594
|
+
}
|
|
403595
|
+
/**
|
|
403596
|
+
* Take the per-action slot, waiting in line for it if somebody has it.
|
|
403094
403597
|
*
|
|
403095
403598
|
* A release can hand the browser on while the previous holder's abort is
|
|
403096
403599
|
* still propagating through an awaited navigation. That is a handoff, not a
|
|
403097
|
-
* conflict, so it is
|
|
403098
|
-
*
|
|
403600
|
+
* conflict, so it is normally bounded: a wedged action must not pin an agent
|
|
403601
|
+
* or a takeover forever. Human discrete input is the exception and passes
|
|
403602
|
+
* `null`: once a click, key or committed text has been accepted into this
|
|
403603
|
+
* ordered queue, expiring it merely because earlier accepted input took more
|
|
403604
|
+
* than two seconds silently truncates what the person typed.
|
|
403605
|
+
*
|
|
403606
|
+
* Waiting and TAKING are one step, which they deliberately were not before:
|
|
403607
|
+
* the wait used to answer "the slot looks free" and leave the caller to call
|
|
403608
|
+
* `beginAction` afterwards. Two callers woken by the same release both saw it
|
|
403609
|
+
* free, and the loser was told "another action is already running" — a
|
|
403610
|
+
* refusal for having asked first. Granting it here means whoever reaches the
|
|
403611
|
+
* front of the line gets it, and everybody else is still waiting rather than
|
|
403612
|
+
* refused.
|
|
403613
|
+
*
|
|
403614
|
+
* `null` as a RESULT means a bounded wait ran out OR a caller-supplied queue
|
|
403615
|
+
* cap refused a new waiter. `null` as `withinMs` means an ACCEPTED operation
|
|
403616
|
+
* is intentionally unbounded by time and will leave the queue only when its
|
|
403617
|
+
* turn arrives; it does not require accepting infinitely many operations.
|
|
403618
|
+
* `countsAsHumanActivity=false` is for
|
|
403619
|
+
* lifecycle barriers: they still serialize, but an idle deadline must not
|
|
403620
|
+
* mistake framework cleanup for evidence that the person is present.
|
|
403099
403621
|
*/
|
|
403100
|
-
async
|
|
403101
|
-
|
|
403102
|
-
|
|
403103
|
-
await new Promise((r4) => setTimeout(r4, ACTION_HANDOFF_POLL_MS));
|
|
403622
|
+
async acquireActionSlot(withinMs = ACTION_HANDOFF_MS, countsAsHumanActivity = true, maxQueued = null) {
|
|
403623
|
+
if (!this.action && this.actionQueue.length === 0) {
|
|
403624
|
+
return this.beginAction(countsAsHumanActivity);
|
|
403104
403625
|
}
|
|
403105
|
-
|
|
403626
|
+
if (maxQueued !== null && this.actionQueue.length >= maxQueued) return null;
|
|
403627
|
+
return await new Promise((resolve2) => {
|
|
403628
|
+
let done = false;
|
|
403629
|
+
let timer;
|
|
403630
|
+
const wake = () => {
|
|
403631
|
+
if (done) return;
|
|
403632
|
+
done = true;
|
|
403633
|
+
if (timer) clearTimeout(timer);
|
|
403634
|
+
resolve2(this.beginAction(countsAsHumanActivity));
|
|
403635
|
+
};
|
|
403636
|
+
if (withinMs !== null) {
|
|
403637
|
+
timer = setTimeout(() => {
|
|
403638
|
+
if (done) return;
|
|
403639
|
+
done = true;
|
|
403640
|
+
const i4 = this.actionQueue.indexOf(wake);
|
|
403641
|
+
if (i4 >= 0) this.actionQueue.splice(i4, 1);
|
|
403642
|
+
resolve2(null);
|
|
403643
|
+
}, withinMs);
|
|
403644
|
+
timer.unref?.();
|
|
403645
|
+
}
|
|
403646
|
+
this.actionQueue.push(wake);
|
|
403647
|
+
});
|
|
403106
403648
|
}
|
|
403107
403649
|
/**
|
|
403108
403650
|
* Queue one actor, resolving when it gets the browser or is turned away.
|
|
@@ -403137,7 +403679,35 @@ var BrowserLease = class {
|
|
|
403137
403679
|
};
|
|
403138
403680
|
|
|
403139
403681
|
// src/browser/remote/controller.ts
|
|
403682
|
+
var KEYS = {
|
|
403683
|
+
Enter: { code: 13, text: "\r" },
|
|
403684
|
+
Tab: { code: 9 },
|
|
403685
|
+
Backspace: { code: 8 },
|
|
403686
|
+
Delete: { code: 46 },
|
|
403687
|
+
Escape: { code: 27 },
|
|
403688
|
+
ArrowUp: { code: 38 },
|
|
403689
|
+
ArrowDown: { code: 40 },
|
|
403690
|
+
ArrowLeft: { code: 37 },
|
|
403691
|
+
ArrowRight: { code: 39 },
|
|
403692
|
+
Home: { code: 36 },
|
|
403693
|
+
End: { code: 35 },
|
|
403694
|
+
PageUp: { code: 33 },
|
|
403695
|
+
PageDown: { code: 34 }
|
|
403696
|
+
};
|
|
403697
|
+
var BUTTONS = { left: 1, middle: 4, right: 2 };
|
|
403698
|
+
var SCREENCAST = { format: "jpeg", quality: 60, maxWidth: 1280, maxHeight: 800 };
|
|
403140
403699
|
var ACTION_TIMEOUT_MS = 3e4;
|
|
403700
|
+
var RESTORE_FRAME_MS = 2e3;
|
|
403701
|
+
var MAX_HUMAN_INPUT_QUEUE = 64;
|
|
403702
|
+
var MAX_CAPTURE_TRANSITION_QUEUE = 16;
|
|
403703
|
+
var MAX_WATCH_LIFECYCLE_QUEUE = 16;
|
|
403704
|
+
function notify(deliver) {
|
|
403705
|
+
try {
|
|
403706
|
+
void Promise.resolve(deliver()).catch(() => {
|
|
403707
|
+
});
|
|
403708
|
+
} catch {
|
|
403709
|
+
}
|
|
403710
|
+
}
|
|
403141
403711
|
var RemoteBrowserController = class {
|
|
403142
403712
|
provider;
|
|
403143
403713
|
maxSessions;
|
|
@@ -403150,8 +403720,49 @@ var RemoteBrowserController = class {
|
|
|
403150
403720
|
* bearer capability for a browser somebody else is driving.
|
|
403151
403721
|
*/
|
|
403152
403722
|
liveViewListeners = /* @__PURE__ */ new Map();
|
|
403723
|
+
/**
|
|
403724
|
+
* Told when a run's browser changes hands, or stops being pictured.
|
|
403725
|
+
*
|
|
403726
|
+
* Needed because a takeover can end without anybody asking. The hold lapses
|
|
403727
|
+
* on its own after `HUMAN_IDLE_MS`, and a client left believing it still has
|
|
403728
|
+
* control would keep sending input into a lease it no longer holds — every
|
|
403729
|
+
* event refused, with nothing on screen saying why.
|
|
403730
|
+
*/
|
|
403731
|
+
controlListeners = /* @__PURE__ */ new Map();
|
|
403732
|
+
/** What each run's listener was last told, so unchanged states stay quiet. */
|
|
403733
|
+
controlAnnounced = /* @__PURE__ */ new Map();
|
|
403153
403734
|
/** An embedder that wants every run's live view, told which run each is. */
|
|
403154
403735
|
onLiveViewAll;
|
|
403736
|
+
/** Told when a provider session could not be handed back. See `disposeRun`. */
|
|
403737
|
+
onReleaseFailed;
|
|
403738
|
+
/**
|
|
403739
|
+
* Tell the observer a session leaked, without letting it become the leak.
|
|
403740
|
+
*
|
|
403741
|
+
* Guarded, because this runs inside a `.catch` on the release: an observer
|
|
403742
|
+
* that throws — a logging or telemetry sink that is momentarily unavailable
|
|
403743
|
+
* is the ordinary case — would turn its own failure into a fresh rejection
|
|
403744
|
+
* from `disposeRun`, so `endRun` would reject before `forgetRun` and the
|
|
403745
|
+
* fire-and-forget `stopRun` path would raise an unhandled rejection.
|
|
403746
|
+
*
|
|
403747
|
+
* That is precisely the guarantee this whole change claims to preserve: a
|
|
403748
|
+
* release that fails must never fail the run. Reporting it cannot be the
|
|
403749
|
+
* thing that breaks it.
|
|
403750
|
+
*
|
|
403751
|
+
* TWO WAYS TO FAIL, not one. The `try` catches a synchronous throw; an
|
|
403752
|
+
* `async` sink — which is what a telemetry or logging callback usually is —
|
|
403753
|
+
* fails by returning a REJECTED PROMISE, which no `try` around a call that is
|
|
403754
|
+
* never awaited can see. Node then raises an unhandled rejection and can take
|
|
403755
|
+
* the process down: the observer would not merely have failed to report the
|
|
403756
|
+
* leak, it would have killed the run, which is the one outcome this function
|
|
403757
|
+
* exists to prevent, reached through the door the guard did not cover.
|
|
403758
|
+
*
|
|
403759
|
+
* Not awaited, though. The caller is a `.catch` on the release and is not
|
|
403760
|
+
* going to wait for a telemetry round trip; attaching a handler is what makes
|
|
403761
|
+
* the rejection observed, which is the whole requirement.
|
|
403762
|
+
*/
|
|
403763
|
+
reportReleaseFailure(runId, sessionId, err, expiresInMs) {
|
|
403764
|
+
notify(() => this.onReleaseFailed?.(runId, sessionId, err, expiresInMs));
|
|
403765
|
+
}
|
|
403155
403766
|
runs = /* @__PURE__ */ new Map();
|
|
403156
403767
|
/**
|
|
403157
403768
|
* Runs that have a pool slot but no browser yet.
|
|
@@ -403159,7 +403770,7 @@ var RemoteBrowserController = class {
|
|
|
403159
403770
|
* Counted alongside `runs` against the cap. Without it the limit was a
|
|
403160
403771
|
* check-then-act across four awaits, and simultaneous first uses both won.
|
|
403161
403772
|
*/
|
|
403162
|
-
opening = /* @__PURE__ */ new
|
|
403773
|
+
opening = /* @__PURE__ */ new Map();
|
|
403163
403774
|
/**
|
|
403164
403775
|
* A run's stop signal, created when its slot is reserved.
|
|
403165
403776
|
*
|
|
@@ -403192,7 +403803,7 @@ var RemoteBrowserController = class {
|
|
|
403192
403803
|
* own teardown settles, so a later, genuine re-open of the same run id
|
|
403193
403804
|
* never collides with one still in flight.
|
|
403194
403805
|
*/
|
|
403195
|
-
closing = /* @__PURE__ */ new
|
|
403806
|
+
closing = /* @__PURE__ */ new Map();
|
|
403196
403807
|
/** Runs the user has stopped, by run id — same meaning as on the desktop. */
|
|
403197
403808
|
revoked = /* @__PURE__ */ new Set();
|
|
403198
403809
|
/**
|
|
@@ -403210,11 +403821,39 @@ var RemoteBrowserController = class {
|
|
|
403210
403821
|
const asked = Math.max(1, options.maxSessions ?? 1);
|
|
403211
403822
|
this.maxSessions = options.provider.isolatesSessions ? asked : 1;
|
|
403212
403823
|
this.onLiveViewAll = options.onLiveView;
|
|
403824
|
+
this.onReleaseFailed = options.onReleaseFailed;
|
|
403825
|
+
this.ready = options.ready;
|
|
403213
403826
|
}
|
|
403827
|
+
/**
|
|
403828
|
+
* The predecessor's teardown, until it has been waited for once.
|
|
403829
|
+
*
|
|
403830
|
+
* Cleared as soon as it settles so the wait is not re-entered on every open
|
|
403831
|
+
* — and so the promise itself is not retained for the life of the process.
|
|
403832
|
+
*/
|
|
403833
|
+
ready;
|
|
403834
|
+
/**
|
|
403835
|
+
* This controller has been retired and will never open anything again.
|
|
403836
|
+
*
|
|
403837
|
+
* Stronger than "its runs were ended", and it has to be. A caller parked on
|
|
403838
|
+
* the `ready` gate above is invisible to `dispose()` — it holds no run and
|
|
403839
|
+
* has reserved no slot, because it has not got that far — so a SECOND
|
|
403840
|
+
* settings change, arriving while the first retirement is still settling,
|
|
403841
|
+
* found nothing to wait for and let the newest controller start immediately.
|
|
403842
|
+
* The parked caller then woke up on a controller nobody holds a reference to
|
|
403843
|
+
* and allocated a session against the superseded configuration, with the
|
|
403844
|
+
* newest controller free to allocate its own.
|
|
403845
|
+
*
|
|
403846
|
+
* Set synchronously at the top of `dispose`, so anything already waiting is
|
|
403847
|
+
* excluded the moment retirement begins rather than when it finishes.
|
|
403848
|
+
*/
|
|
403849
|
+
retired = false;
|
|
403214
403850
|
leaseFor(runId) {
|
|
403215
403851
|
let lease = this.leases.get(runId);
|
|
403216
403852
|
if (!lease) {
|
|
403217
|
-
lease = new BrowserLease({
|
|
403853
|
+
lease = new BrowserLease({
|
|
403854
|
+
isRevoked: (id) => this.revoked.has(id),
|
|
403855
|
+
onChange: () => this.announceControl(runId)
|
|
403856
|
+
});
|
|
403218
403857
|
lease.setLifecycleReleaseWired(true);
|
|
403219
403858
|
this.leases.set(runId, lease);
|
|
403220
403859
|
}
|
|
@@ -403231,6 +403870,44 @@ var RemoteBrowserController = class {
|
|
|
403231
403870
|
offLiveViewFor(runKey) {
|
|
403232
403871
|
this.liveViewListeners.delete(runKey);
|
|
403233
403872
|
}
|
|
403873
|
+
onControlFor(runKey, listener) {
|
|
403874
|
+
this.controlListeners.set(runKey, listener);
|
|
403875
|
+
}
|
|
403876
|
+
offControlFor(runKey) {
|
|
403877
|
+
this.controlListeners.delete(runKey);
|
|
403878
|
+
this.controlAnnounced.delete(runKey);
|
|
403879
|
+
}
|
|
403880
|
+
/**
|
|
403881
|
+
* Say who has the browser, but only when the answer has changed.
|
|
403882
|
+
*
|
|
403883
|
+
* The lease reports every ownership change, and during ordinary work that is
|
|
403884
|
+
* one per action as workers take it and give it back. None of those are
|
|
403885
|
+
* interesting to a viewer — the agent had it before and has it now — and
|
|
403886
|
+
* emitting them would put a socket message on every click the agent makes.
|
|
403887
|
+
*/
|
|
403888
|
+
announceControl(runId, force = false) {
|
|
403889
|
+
const listener = this.controlListeners.get(runId);
|
|
403890
|
+
if (!listener) return;
|
|
403891
|
+
const held = this.runs.get(runId);
|
|
403892
|
+
const state = {
|
|
403893
|
+
human: this.leases.get(runId)?.heldByHuman === true,
|
|
403894
|
+
capturing: held?.capturing === true,
|
|
403895
|
+
// A receipt vouches for a VIEW, so it stops meaning anything the moment
|
|
403896
|
+
// there is no view. `stopWatching` clears the session without bumping the
|
|
403897
|
+
// watch — deliberately, since replacing the consumer is what a new watch
|
|
403898
|
+
// is about — so the receipt stayed matched across the whole detach and
|
|
403899
|
+
// re-attach, which `beginWatching` performs on every reconnect and every
|
|
403900
|
+
// return to a conversation. Announced `confirmed` through that window,
|
|
403901
|
+
// the client mounted and FOCUSED the blind keyboard surface over a page
|
|
403902
|
+
// no session was serving: keystrokes meant for the composer went to a
|
|
403903
|
+
// remote page that could only refuse them.
|
|
403904
|
+
confirmed: held?.screencast !== void 0 && held.watchGen !== void 0 && held.seenGen === held.watchGen
|
|
403905
|
+
};
|
|
403906
|
+
const key = `${state.human}:${state.capturing}:${state.confirmed}`;
|
|
403907
|
+
if (!force && this.controlAnnounced.get(runId) === key) return;
|
|
403908
|
+
this.controlAnnounced.set(runId, key);
|
|
403909
|
+
notify(() => listener(state));
|
|
403910
|
+
}
|
|
403234
403911
|
/**
|
|
403235
403912
|
* Tell a run's listener what its browser situation is.
|
|
403236
403913
|
*
|
|
@@ -403240,9 +403917,32 @@ var RemoteBrowserController = class {
|
|
|
403240
403917
|
* apart — and would drop the Stop control for the first as if it were the
|
|
403241
403918
|
* second.
|
|
403242
403919
|
*/
|
|
403920
|
+
/*
|
|
403921
|
+
* Guarded HERE rather than at the call sites, which is the point.
|
|
403922
|
+
*
|
|
403923
|
+
* This is the fifth place in this changeset where a notification could take
|
|
403924
|
+
* down the thing it was notifying about — after the release observer,
|
|
403925
|
+
* `clarification:closed`, `clarification:timeout` and `escalation:timeout`.
|
|
403926
|
+
* The first four were fixed one door at a time, which is how a fifth door
|
|
403927
|
+
* came to exist: the announcement added on the reuse path propagated out of
|
|
403928
|
+
* `open()`, `act()` caught it as an action failure, and every later action
|
|
403929
|
+
* against a perfectly healthy browser was skipped.
|
|
403930
|
+
*
|
|
403931
|
+
* Putting the guard at the source closes all four call sites at once and
|
|
403932
|
+
* leaves no door for a sixth. The worst of them is not the reported one:
|
|
403933
|
+
* `disposeRun` announces `active: false` as its FIRST statement, so a
|
|
403934
|
+
* throwing listener aborted teardown before the screencast was stopped, the
|
|
403935
|
+
* context closed, or the session handed back — leaking the provider session
|
|
403936
|
+
* and never reaching `reportReleaseFailure`. The leak report would have been
|
|
403937
|
+
* skipped by exactly the failure it exists to report.
|
|
403938
|
+
*
|
|
403939
|
+
* Two separate guards, not one around both: these are different listeners —
|
|
403940
|
+
* the per-run panel and the global observer — and one failing must not cost
|
|
403941
|
+
* the other its notification.
|
|
403942
|
+
*/
|
|
403243
403943
|
announceLiveView(runId, liveViewUrl, active) {
|
|
403244
|
-
this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
|
|
403245
|
-
this.onLiveViewAll?.(runId, liveViewUrl);
|
|
403944
|
+
notify(() => this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} }));
|
|
403945
|
+
notify(() => this.onLiveViewAll?.(runId, liveViewUrl));
|
|
403246
403946
|
}
|
|
403247
403947
|
/**
|
|
403248
403948
|
* Hand back a session the run turned out not to want.
|
|
@@ -403251,22 +403951,682 @@ var RemoteBrowserController = class {
|
|
|
403251
403951
|
* allocated and billed until the whole run ended, for a run that has just
|
|
403252
403952
|
* been stopped.
|
|
403253
403953
|
*/
|
|
403254
|
-
async releaseIfIdle(runId) {
|
|
403954
|
+
async releaseIfIdle(runId, releaseSlot) {
|
|
403255
403955
|
const held = this.runs.get(runId);
|
|
403256
|
-
if (!held)
|
|
403956
|
+
if (!held) {
|
|
403957
|
+
releaseSlot?.();
|
|
403958
|
+
return;
|
|
403959
|
+
}
|
|
403257
403960
|
this.runs.delete(runId);
|
|
403258
|
-
this.
|
|
403259
|
-
|
|
403961
|
+
const teardown = this.teardown(runId, held);
|
|
403962
|
+
releaseSlot?.();
|
|
403963
|
+
await teardown;
|
|
403260
403964
|
}
|
|
403261
403965
|
/** Release every run's session. For when the deployment's config changes. */
|
|
403262
403966
|
async dispose() {
|
|
403263
|
-
|
|
403967
|
+
this.retired = true;
|
|
403968
|
+
const opening = [...this.opening.values()];
|
|
403969
|
+
const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening.keys()]);
|
|
403264
403970
|
await Promise.all([...ids].map((runId) => this.endRun(runId)));
|
|
403971
|
+
await Promise.all(opening.map((open2) => open2.catch(() => {
|
|
403972
|
+
})));
|
|
403973
|
+
await Promise.all([...this.closing.values()].map((done) => done.catch(() => {
|
|
403974
|
+
})));
|
|
403265
403975
|
}
|
|
403266
403976
|
/** A worker finished; it will never ask for the browser again. */
|
|
403267
403977
|
actorEnded(actorId) {
|
|
403268
403978
|
for (const lease of this.leases.values()) lease.actorEnded(actorId);
|
|
403269
403979
|
}
|
|
403980
|
+
/**
|
|
403981
|
+
* Start streaming this run's page to whoever is watching it.
|
|
403982
|
+
*
|
|
403983
|
+
* Nothing streams until someone asks. That is the whole reason this is a
|
|
403984
|
+
* method rather than something `open()` does: an unwatched run would
|
|
403985
|
+
* otherwise pay to encode frames nobody sees, on every run, forever — and on
|
|
403986
|
+
* a metered provider it would pay for the bandwidth too.
|
|
403987
|
+
*
|
|
403988
|
+
* Frames are ACKED after they are handed on, never before. Chrome sends the
|
|
403989
|
+
* next frame only once the previous is acknowledged, so this bounds it to one
|
|
403990
|
+
* outstanding frame against OUR OWN handling. It does not reach further than
|
|
403991
|
+
* that, and an earlier version of this comment claimed it did: `onFrame` ends
|
|
403992
|
+
* at a socket emit, which hands the JPEG to a transport rather than to a
|
|
403993
|
+
* viewer, so the ack says nothing about what the person actually received.
|
|
403994
|
+
* Bounding the rest of the path is the transport's job — see `emitFrame`,
|
|
403995
|
+
* where frames go out lossy so a slow connection drops pictures instead of
|
|
403996
|
+
* banking stale ones. Dropping the ack stops the stream dead, which is why it
|
|
403997
|
+
* is best-effort but unconditional.
|
|
403998
|
+
*
|
|
403999
|
+
* Returns false when there is nothing to watch — the run has no browser, or
|
|
404000
|
+
* it is already being watched. Callers use that to avoid promising a viewer
|
|
404001
|
+
* a stream that will never arrive.
|
|
404002
|
+
*/
|
|
404003
|
+
async startWatching(runId, onFrame) {
|
|
404004
|
+
const held = this.runs.get(runId);
|
|
404005
|
+
if (!held) return false;
|
|
404006
|
+
return await this.queueWatch(held, async () => {
|
|
404007
|
+
held.onFrame = onFrame;
|
|
404008
|
+
held.watchGen = (held.watchGen ?? 0) + 1;
|
|
404009
|
+
if (held.screencast) {
|
|
404010
|
+
this.announceControl(runId);
|
|
404011
|
+
return true;
|
|
404012
|
+
}
|
|
404013
|
+
const attached = await this.attachScreencast(runId, held, onFrame);
|
|
404014
|
+
if (!attached && held.onFrame === onFrame) held.onFrame = void 0;
|
|
404015
|
+
return attached !== null;
|
|
404016
|
+
});
|
|
404017
|
+
}
|
|
404018
|
+
/**
|
|
404019
|
+
* Run one watch or unwatch after every one already asked for.
|
|
404020
|
+
*
|
|
404021
|
+
* The chain is kept non-rejecting on purpose: a failed attach must not poison
|
|
404022
|
+
* every later request for this run, which is what a bare `.then` chain would
|
|
404023
|
+
* do. The caller still sees its own failure, because that is the promise
|
|
404024
|
+
* returned rather than the one stored.
|
|
404025
|
+
*/
|
|
404026
|
+
queueWatch(held, op) {
|
|
404027
|
+
const depth = held.watchQueueDepth ?? 0;
|
|
404028
|
+
if (held.watchCollapsedPromise || depth >= MAX_WATCH_LIFECYCLE_QUEUE) {
|
|
404029
|
+
held.watchCollapsed = op;
|
|
404030
|
+
if (!held.watchCollapsedPromise) {
|
|
404031
|
+
const drain = async () => {
|
|
404032
|
+
let result;
|
|
404033
|
+
while (held.watchCollapsed) {
|
|
404034
|
+
const latest = held.watchCollapsed;
|
|
404035
|
+
held.watchCollapsed = void 0;
|
|
404036
|
+
try {
|
|
404037
|
+
result = await latest();
|
|
404038
|
+
} catch {
|
|
404039
|
+
}
|
|
404040
|
+
}
|
|
404041
|
+
return result;
|
|
404042
|
+
};
|
|
404043
|
+
const tail = (held.watchQueue ?? Promise.resolve()).then(drain, drain);
|
|
404044
|
+
let tracked;
|
|
404045
|
+
tracked = tail.finally(() => {
|
|
404046
|
+
if (held.watchCollapsedPromise === tracked) {
|
|
404047
|
+
held.watchCollapsedPromise = void 0;
|
|
404048
|
+
held.watchCollapsed = void 0;
|
|
404049
|
+
}
|
|
404050
|
+
});
|
|
404051
|
+
held.watchCollapsedPromise = tracked;
|
|
404052
|
+
held.watchQueue = tracked.catch(() => {
|
|
404053
|
+
});
|
|
404054
|
+
}
|
|
404055
|
+
return held.watchCollapsedPromise;
|
|
404056
|
+
}
|
|
404057
|
+
held.watchQueueDepth = depth + 1;
|
|
404058
|
+
const mine = (held.watchQueue ?? Promise.resolve()).then(op, op);
|
|
404059
|
+
const counted = mine.finally(() => {
|
|
404060
|
+
held.watchQueueDepth = Math.max(0, (held.watchQueueDepth ?? 1) - 1);
|
|
404061
|
+
});
|
|
404062
|
+
held.watchQueue = counted.catch(() => {
|
|
404063
|
+
});
|
|
404064
|
+
return counted;
|
|
404065
|
+
}
|
|
404066
|
+
/**
|
|
404067
|
+
* Attach at most one physical CDP screencast session at a time.
|
|
404068
|
+
*
|
|
404069
|
+
* `startWatching()` already serializes watcher lifecycle through `watchQueue`,
|
|
404070
|
+
* but the agent's detached-hidden restore cannot join that queue: it holds the
|
|
404071
|
+
* action slot, while an earlier unwatch may be waiting for exactly that slot.
|
|
404072
|
+
* Sharing only the physical attach closes the two-session race without making
|
|
404073
|
+
* watcher teardown and agent mutation wait on each other in a cycle.
|
|
404074
|
+
*
|
|
404075
|
+
* A real watcher claims `onFrame` immediately, before it waits for an attach
|
|
404076
|
+
* already started by the agent. The lower-level attach never writes the sink,
|
|
404077
|
+
* so finishing an older temporary attempt cannot overwrite a newer viewer.
|
|
404078
|
+
*/
|
|
404079
|
+
async attachScreencast(runId, held, onFrame, popupDetachGen) {
|
|
404080
|
+
const consumerIntent = onFrame && popupDetachGen !== void 0 ? popupDetachGen : void 0;
|
|
404081
|
+
if (onFrame) held.onFrame = onFrame;
|
|
404082
|
+
if (held.screencast && held.screencastPage === held.page) return held.screencast;
|
|
404083
|
+
const targetPage = held.page;
|
|
404084
|
+
const existing = held.attaching;
|
|
404085
|
+
if (existing) {
|
|
404086
|
+
const existingPage = held.attachingPage;
|
|
404087
|
+
await existing.catch(() => null);
|
|
404088
|
+
if (held.page !== targetPage || existingPage !== targetPage) {
|
|
404089
|
+
return this.attachScreencast(runId, held, onFrame, popupDetachGen);
|
|
404090
|
+
}
|
|
404091
|
+
const cdp = held.screencastPage === targetPage ? held.screencast ?? null : null;
|
|
404092
|
+
if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
|
|
404093
|
+
if (cdp && onFrame) held.onFrame = onFrame;
|
|
404094
|
+
return cdp;
|
|
404095
|
+
}
|
|
404096
|
+
if (held.screencast && held.screencastPage !== targetPage) {
|
|
404097
|
+
const stale = held.screencast;
|
|
404098
|
+
held.screencast = void 0;
|
|
404099
|
+
held.screencastPage = void 0;
|
|
404100
|
+
await stale.send("Page.stopScreencast").catch(() => {
|
|
404101
|
+
});
|
|
404102
|
+
await stale.detach().catch(() => {
|
|
404103
|
+
});
|
|
404104
|
+
}
|
|
404105
|
+
const attempt = this.attachScreencastFresh(runId, held, targetPage, consumerIntent);
|
|
404106
|
+
held.attaching = attempt;
|
|
404107
|
+
held.attachingPage = targetPage;
|
|
404108
|
+
try {
|
|
404109
|
+
const cdp = await attempt;
|
|
404110
|
+
if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
|
|
404111
|
+
if (cdp && onFrame) held.onFrame = onFrame;
|
|
404112
|
+
return cdp;
|
|
404113
|
+
} finally {
|
|
404114
|
+
if (held.attaching === attempt) {
|
|
404115
|
+
held.attaching = void 0;
|
|
404116
|
+
held.attachingPage = void 0;
|
|
404117
|
+
}
|
|
404118
|
+
}
|
|
404119
|
+
}
|
|
404120
|
+
/** Open one new CDP session and start the stream when it is safe to do so. */
|
|
404121
|
+
async attachScreencastFresh(runId, held, targetPage, consumerIntent) {
|
|
404122
|
+
const safetyGate = held.hiddenByHolder === true || held.visibilityUnconfirmed === true;
|
|
404123
|
+
let cdp;
|
|
404124
|
+
try {
|
|
404125
|
+
cdp = await targetPage.context().newCDPSession(targetPage);
|
|
404126
|
+
} catch {
|
|
404127
|
+
if (!safetyGate) {
|
|
404128
|
+
held.visibilityUnconfirmed = false;
|
|
404129
|
+
held.capturing = true;
|
|
404130
|
+
this.announceControl(runId);
|
|
404131
|
+
}
|
|
404132
|
+
return null;
|
|
404133
|
+
}
|
|
404134
|
+
if (held.page !== targetPage) {
|
|
404135
|
+
await cdp.detach().catch(() => {
|
|
404136
|
+
});
|
|
404137
|
+
return null;
|
|
404138
|
+
}
|
|
404139
|
+
cdp.on("Page.screencastFrame", ((e3) => {
|
|
404140
|
+
if (held.page === targetPage && typeof e3?.data === "string") {
|
|
404141
|
+
const width = e3.metadata?.deviceWidth ?? 0;
|
|
404142
|
+
const height = e3.metadata?.deviceHeight ?? 0;
|
|
404143
|
+
held.metricsStale = true;
|
|
404144
|
+
if (held.visibilityUnconfirmed) {
|
|
404145
|
+
held.visibilityUnconfirmed = false;
|
|
404146
|
+
if (held.capturing !== true) {
|
|
404147
|
+
held.capturing = true;
|
|
404148
|
+
this.announceControl(runId);
|
|
404149
|
+
}
|
|
404150
|
+
}
|
|
404151
|
+
const ping = held.framePing;
|
|
404152
|
+
held.framePing = void 0;
|
|
404153
|
+
ping?.();
|
|
404154
|
+
const deliver = held.onFrame;
|
|
404155
|
+
if (deliver) {
|
|
404156
|
+
const frame = { data: e3.data, width, height, generation: held.watchGen ?? 0 };
|
|
404157
|
+
notify(() => deliver(frame));
|
|
404158
|
+
}
|
|
404159
|
+
}
|
|
404160
|
+
void cdp.send("Page.screencastFrameAck", { sessionId: e3?.sessionId }).catch(() => {
|
|
404161
|
+
});
|
|
404162
|
+
}));
|
|
404163
|
+
const paused = held.hiddenByHolder === true;
|
|
404164
|
+
if (!paused) {
|
|
404165
|
+
try {
|
|
404166
|
+
await cdp.send("Page.startScreencast", { ...SCREENCAST });
|
|
404167
|
+
} catch {
|
|
404168
|
+
if (!safetyGate) {
|
|
404169
|
+
held.visibilityUnconfirmed = false;
|
|
404170
|
+
held.capturing = true;
|
|
404171
|
+
this.announceControl(runId);
|
|
404172
|
+
} else {
|
|
404173
|
+
held.capturing = false;
|
|
404174
|
+
this.announceControl(runId);
|
|
404175
|
+
}
|
|
404176
|
+
await cdp.detach().catch(() => {
|
|
404177
|
+
});
|
|
404178
|
+
return null;
|
|
404179
|
+
}
|
|
404180
|
+
}
|
|
404181
|
+
if (held.page !== targetPage) {
|
|
404182
|
+
if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
|
|
404183
|
+
});
|
|
404184
|
+
await cdp.detach().catch(() => {
|
|
404185
|
+
});
|
|
404186
|
+
return null;
|
|
404187
|
+
}
|
|
404188
|
+
if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) {
|
|
404189
|
+
if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
|
|
404190
|
+
});
|
|
404191
|
+
await cdp.detach().catch(() => {
|
|
404192
|
+
});
|
|
404193
|
+
return null;
|
|
404194
|
+
}
|
|
404195
|
+
held.screencast = cdp;
|
|
404196
|
+
held.screencastPage = targetPage;
|
|
404197
|
+
held.capturing = !paused;
|
|
404198
|
+
held.hiddenByHolder = paused;
|
|
404199
|
+
this.announceControl(runId);
|
|
404200
|
+
return cdp;
|
|
404201
|
+
}
|
|
404202
|
+
/** Nobody is watching any more: stop paying to render frames. */
|
|
404203
|
+
async stopWatching(runId) {
|
|
404204
|
+
const held = this.runs.get(runId);
|
|
404205
|
+
if (!held) return;
|
|
404206
|
+
held.watchDetachGen = (held.watchDetachGen ?? 0) + 1;
|
|
404207
|
+
held.onFrame = void 0;
|
|
404208
|
+
await this.queueWatch(held, async () => {
|
|
404209
|
+
const cdp = held.screencast;
|
|
404210
|
+
held.onFrame = void 0;
|
|
404211
|
+
if (!cdp) return;
|
|
404212
|
+
held.screencast = void 0;
|
|
404213
|
+
held.screencastPage = void 0;
|
|
404214
|
+
held.capturing = false;
|
|
404215
|
+
this.announceControl(runId);
|
|
404216
|
+
const lease = this.leases.get(runId);
|
|
404217
|
+
const token = lease ? await lease.acquireActionSlot(null, false) : null;
|
|
404218
|
+
if (lease && !token) return;
|
|
404219
|
+
try {
|
|
404220
|
+
await cdp.send("Page.stopScreencast").catch(() => {
|
|
404221
|
+
});
|
|
404222
|
+
await cdp.detach().catch(() => {
|
|
404223
|
+
});
|
|
404224
|
+
} finally {
|
|
404225
|
+
if (token && lease) lease.endAction(token);
|
|
404226
|
+
}
|
|
404227
|
+
});
|
|
404228
|
+
}
|
|
404229
|
+
/**
|
|
404230
|
+
* Suspend or resume the picture without giving up control.
|
|
404231
|
+
*
|
|
404232
|
+
* The credential case, and the only reason this is separate from watching. A
|
|
404233
|
+
* person who has taken the page over to sign in is about to type a password
|
|
404234
|
+
* into a field whose contents are echoed as dots — but a password manager's
|
|
404235
|
+
* dropdown, a "show password" toggle, a one-time code in plain text and the
|
|
404236
|
+
* confirmation page afterwards are not, and all of them would otherwise be
|
|
404237
|
+
* encoded into frames and sent across the network.
|
|
404238
|
+
*
|
|
404239
|
+
* What this does NOT do is hide anything from the agent: it stops the
|
|
404240
|
+
* picture, not the page. Nothing about a suspended capture would stop a
|
|
404241
|
+
* `extract_text` from reading the same screen — it is the lease that stops
|
|
404242
|
+
* that, by refusing the agent for as long as the person holds it.
|
|
404243
|
+
*
|
|
404244
|
+
* Answers whether frames are flowing afterwards, which is what a UI needs to
|
|
404245
|
+
* show and is not always what was asked for: a run with nobody watching has
|
|
404246
|
+
* no session to suspend.
|
|
404247
|
+
*/
|
|
404248
|
+
async setCapture(runId, on) {
|
|
404249
|
+
const held = this.runs.get(runId);
|
|
404250
|
+
const cdp = held?.screencast;
|
|
404251
|
+
const watch = held?.watchGen;
|
|
404252
|
+
if (!held || !cdp) return false;
|
|
404253
|
+
const lease = this.leaseFor(runId);
|
|
404254
|
+
if (!on && !lease.heldByHuman) return held.capturing === true;
|
|
404255
|
+
if (!on && (watch === void 0 || held.seenGen !== watch)) {
|
|
404256
|
+
return held.capturing === true;
|
|
404257
|
+
}
|
|
404258
|
+
if (held.capturing === on) {
|
|
404259
|
+
if (lease.heldByHuman) lease.touch();
|
|
404260
|
+
return on;
|
|
404261
|
+
}
|
|
404262
|
+
const token = await lease.acquireActionSlot(null, true, MAX_CAPTURE_TRANSITION_QUEUE);
|
|
404263
|
+
if (!token) return held.capturing === true;
|
|
404264
|
+
try {
|
|
404265
|
+
if (!on && !lease.heldByHuman) return held.capturing === true;
|
|
404266
|
+
if (!on && (held.screencast !== cdp || held.watchGen !== watch)) {
|
|
404267
|
+
return held.capturing === true;
|
|
404268
|
+
}
|
|
404269
|
+
if (!on && (watch === void 0 || held.seenGen !== watch)) {
|
|
404270
|
+
return held.capturing === true;
|
|
404271
|
+
}
|
|
404272
|
+
const target = on ? held.screencast : cdp;
|
|
404273
|
+
if (!target) {
|
|
404274
|
+
if (on) {
|
|
404275
|
+
held.hiddenByHolder = false;
|
|
404276
|
+
if (lease.heldByHuman) lease.touch();
|
|
404277
|
+
}
|
|
404278
|
+
return held.capturing === true;
|
|
404279
|
+
}
|
|
404280
|
+
const now = await this.applyCapture(runId, held, target, on);
|
|
404281
|
+
if (lease.heldByHuman) lease.touch();
|
|
404282
|
+
return now;
|
|
404283
|
+
} finally {
|
|
404284
|
+
lease.endAction(token);
|
|
404285
|
+
}
|
|
404286
|
+
}
|
|
404287
|
+
/**
|
|
404288
|
+
* The capture transition itself, with no lease bookkeeping.
|
|
404289
|
+
*
|
|
404290
|
+
* Separate because the agent's restore in `act()` already holds the action
|
|
404291
|
+
* slot — taking it again there would deadlock against itself, and the whole
|
|
404292
|
+
* point of that call site is that it happens INSIDE the agent's turn.
|
|
404293
|
+
*
|
|
404294
|
+
* `confirmWithFrame` is used only by the agent's visibility gate. There, a
|
|
404295
|
+
* successful CDP command is not enough: the externally visible state remains
|
|
404296
|
+
* paused until a frame arrives, and the frame handler is the only code that
|
|
404297
|
+
* promotes it to capturing. This makes a restore timeout sticky rather than
|
|
404298
|
+
* a one-action refusal.
|
|
404299
|
+
*/
|
|
404300
|
+
async applyCapture(runId, held, cdp, on, confirmWithFrame = false) {
|
|
404301
|
+
if (held.capturing === on && !(on && confirmWithFrame && held.visibilityUnconfirmed)) return on;
|
|
404302
|
+
const wasUnconfirmed = held.visibilityUnconfirmed === true;
|
|
404303
|
+
const wasCapturing = held.capturing === true;
|
|
404304
|
+
if (on) held.visibilityUnconfirmed = true;
|
|
404305
|
+
try {
|
|
404306
|
+
if (on) await cdp.send("Page.startScreencast", { ...SCREENCAST });
|
|
404307
|
+
else await cdp.send("Page.stopScreencast");
|
|
404308
|
+
if (on) {
|
|
404309
|
+
if (!confirmWithFrame) held.capturing = true;
|
|
404310
|
+
} else {
|
|
404311
|
+
held.visibilityUnconfirmed = false;
|
|
404312
|
+
held.capturing = false;
|
|
404313
|
+
}
|
|
404314
|
+
held.hiddenByHolder = !on;
|
|
404315
|
+
this.announceControl(runId);
|
|
404316
|
+
} catch {
|
|
404317
|
+
if (on) {
|
|
404318
|
+
held.visibilityUnconfirmed = wasUnconfirmed;
|
|
404319
|
+
held.capturing = wasCapturing;
|
|
404320
|
+
this.announceControl(runId);
|
|
404321
|
+
}
|
|
404322
|
+
return held.capturing === true && !held.visibilityUnconfirmed;
|
|
404323
|
+
}
|
|
404324
|
+
return on;
|
|
404325
|
+
}
|
|
404326
|
+
/**
|
|
404327
|
+
* Wait, briefly, for Chrome to produce a frame.
|
|
404328
|
+
*
|
|
404329
|
+
* `Page.startScreencast` resolves when the command is accepted, which is not
|
|
404330
|
+
* when a picture exists. The agent's visibility gate — "the page must be
|
|
404331
|
+
* VISIBLE before anything changes it" — was therefore satisfied by a request
|
|
404332
|
+
* rather than by a repaint, and the agent could click while the panel was
|
|
404333
|
+
* still showing the page from before it was hidden, or nothing at all.
|
|
404334
|
+
*
|
|
404335
|
+
* A frame rather than a client receipt, deliberately. A receipt would prove
|
|
404336
|
+
* somebody SAW it, which is stronger — and would make the agent's progress
|
|
404337
|
+
* depend on a viewer that may not exist: this path runs after a handback or a
|
|
404338
|
+
* lapse, when there may be nobody watching at all, and an agent that stalls
|
|
404339
|
+
* because a tab was closed is a worse failure than the one being fixed. What
|
|
404340
|
+
* this does guarantee is ours to guarantee: the stream is producing again.
|
|
404341
|
+
*
|
|
404342
|
+
* Bounded, and the bound rests on the property rounds 14 and 15 already rely
|
|
404343
|
+
* on — a screencast that has just been started emits its first frame at once,
|
|
404344
|
+
* whatever the page is doing. If one does not arrive, the picture genuinely
|
|
404345
|
+
* has not come back, and the caller refuses on the same trade it already
|
|
404346
|
+
* makes when the restore itself fails.
|
|
404347
|
+
*/
|
|
404348
|
+
nextFrame(held, withinMs = RESTORE_FRAME_MS) {
|
|
404349
|
+
return new Promise((resolve2) => {
|
|
404350
|
+
const timer = setTimeout(() => {
|
|
404351
|
+
if (held.framePing === ping) held.framePing = void 0;
|
|
404352
|
+
resolve2(false);
|
|
404353
|
+
}, withinMs);
|
|
404354
|
+
timer.unref?.();
|
|
404355
|
+
const ping = () => {
|
|
404356
|
+
clearTimeout(timer);
|
|
404357
|
+
resolve2(true);
|
|
404358
|
+
};
|
|
404359
|
+
held.framePing = ping;
|
|
404360
|
+
});
|
|
404361
|
+
}
|
|
404362
|
+
/** Whether a person is driving this run's browser right now. */
|
|
404363
|
+
humanHolds(runId) {
|
|
404364
|
+
return this.leases.get(runId)?.heldByHuman === true;
|
|
404365
|
+
}
|
|
404366
|
+
/**
|
|
404367
|
+
* Hand this run's browser to the person watching it.
|
|
404368
|
+
*
|
|
404369
|
+
* The wait is the substance. An agent action already inside Playwright cannot
|
|
404370
|
+
* be interrupted — `click` waits for its element to become actionable and
|
|
404371
|
+
* then clicks, whatever anyone decided in the meantime — so a takeover that
|
|
404372
|
+
* returned immediately would put the person on a page an action was still
|
|
404373
|
+
* about to change. Bounded by the action ceiling, because that is the longest
|
|
404374
|
+
* an action can legitimately take; past it something is wedged, and saying so
|
|
404375
|
+
* is better than a spinner that never resolves.
|
|
404376
|
+
*
|
|
404377
|
+
* Idempotent for someone who already holds it: a second click of Take control
|
|
404378
|
+
* just says they are still there.
|
|
404379
|
+
*/
|
|
404380
|
+
async takeOver(runId) {
|
|
404381
|
+
if (this.revoked.has(runId)) {
|
|
404382
|
+
return { ok: false, detail: "Browser control was stopped for this run." };
|
|
404383
|
+
}
|
|
404384
|
+
const requested = this.runs.get(runId);
|
|
404385
|
+
if (!requested) {
|
|
404386
|
+
return { ok: false, detail: "This run has no browser open." };
|
|
404387
|
+
}
|
|
404388
|
+
const requestedCdp = requested.screencast;
|
|
404389
|
+
const requestedWatch = requested.watchGen;
|
|
404390
|
+
if (!requestedCdp || requestedWatch === void 0 || !requested.onFrame) {
|
|
404391
|
+
return { ok: false, detail: "This browser is not being watched right now." };
|
|
404392
|
+
}
|
|
404393
|
+
const lease = this.leaseFor(runId);
|
|
404394
|
+
if (lease.heldByHuman) {
|
|
404395
|
+
lease.touch();
|
|
404396
|
+
this.announceControl(runId, true);
|
|
404397
|
+
return { ok: true };
|
|
404398
|
+
}
|
|
404399
|
+
const token = await lease.acquireActionSlot(ACTION_TIMEOUT_MS);
|
|
404400
|
+
if (!token) {
|
|
404401
|
+
return {
|
|
404402
|
+
ok: false,
|
|
404403
|
+
detail: "The agent is still finishing an action on this page. Try again in a moment."
|
|
404404
|
+
};
|
|
404405
|
+
}
|
|
404406
|
+
try {
|
|
404407
|
+
const held = this.runs.get(runId);
|
|
404408
|
+
if (this.revoked.has(runId)) {
|
|
404409
|
+
return { ok: false, detail: "Browser control was stopped for this run." };
|
|
404410
|
+
}
|
|
404411
|
+
if (!held || held.page.isClosed()) {
|
|
404412
|
+
return { ok: false, detail: "That browser closed while you were waiting for it." };
|
|
404413
|
+
}
|
|
404414
|
+
if (held.screencast !== requestedCdp || held.watchGen !== requestedWatch || !held.onFrame) {
|
|
404415
|
+
return { ok: false, detail: "The browser view changed while you were waiting. Take control again from the current view." };
|
|
404416
|
+
}
|
|
404417
|
+
lease.takeOver(runId);
|
|
404418
|
+
} finally {
|
|
404419
|
+
lease.endAction(token);
|
|
404420
|
+
}
|
|
404421
|
+
return { ok: true };
|
|
404422
|
+
}
|
|
404423
|
+
/** The person is done. Answers whether they were holding it at all. */
|
|
404424
|
+
/**
|
|
404425
|
+
* The viewer confirms it received a picture of this run's page.
|
|
404426
|
+
*
|
|
404427
|
+
* Reliable, one per watch, and the only thing that opens Hide. Checked
|
|
404428
|
+
* against the CURRENT watch: a receipt that names an older one is from a
|
|
404429
|
+
* viewer that has since been replaced, and vouching for its successor is the
|
|
404430
|
+
* reconnect hole this counter exists to close.
|
|
404431
|
+
*/
|
|
404432
|
+
frameSeen(runId, generation) {
|
|
404433
|
+
const held = this.runs.get(runId);
|
|
404434
|
+
if (!held) return;
|
|
404435
|
+
if (typeof generation !== "number" || !Number.isFinite(generation)) return;
|
|
404436
|
+
if (held.watchGen === void 0 || generation !== held.watchGen) return;
|
|
404437
|
+
if (held.seenGen === generation) return;
|
|
404438
|
+
held.seenGen = generation;
|
|
404439
|
+
this.announceControl(runId);
|
|
404440
|
+
}
|
|
404441
|
+
handBack(runId) {
|
|
404442
|
+
return this.leases.get(runId)?.handBack() === true;
|
|
404443
|
+
}
|
|
404444
|
+
/**
|
|
404445
|
+
* One thing the person did to the page.
|
|
404446
|
+
*
|
|
404447
|
+
* Authorised per event, not per session. A takeover is not a channel that
|
|
404448
|
+
* stays open once opened: between two clicks the run can be stopped, the hold
|
|
404449
|
+
* can lapse, the page can close, and each of those has to stop the NEXT
|
|
404450
|
+
* event rather than be noticed at the end. So every event re-checks that this
|
|
404451
|
+
* run is live and that the lease is still the person's, and every accepted
|
|
404452
|
+
* event says so by extending the hold.
|
|
404453
|
+
*/
|
|
404454
|
+
async input(runId, event) {
|
|
404455
|
+
const held = this.runs.get(runId);
|
|
404456
|
+
const cdp = held?.screencast;
|
|
404457
|
+
const watch = held?.watchGen;
|
|
404458
|
+
if (!held || held.page.isClosed()) return { ok: false, detail: "This run has no browser open." };
|
|
404459
|
+
const page2 = held.page;
|
|
404460
|
+
const pageGeneration = held.generation;
|
|
404461
|
+
if (!cdp) return { ok: false, detail: "Nothing is streaming this browser, so it cannot be driven." };
|
|
404462
|
+
const lease = this.leaseFor(runId);
|
|
404463
|
+
if (!lease.heldByHuman) {
|
|
404464
|
+
return { ok: false, detail: "You do not have control of this browser. Take control first." };
|
|
404465
|
+
}
|
|
404466
|
+
if (held.capturing !== true && (held.watchGen === void 0 || held.seenGen !== held.watchGen)) {
|
|
404467
|
+
return { ok: false, detail: "Show the page before typing into it \u2014 this view has not seen it yet." };
|
|
404468
|
+
}
|
|
404469
|
+
const sampled = event.kind === "move";
|
|
404470
|
+
const token = sampled ? lease.tryActionSlot() : await lease.acquireActionSlot(null, true, MAX_HUMAN_INPUT_QUEUE);
|
|
404471
|
+
if (!token) {
|
|
404472
|
+
if (sampled) return { ok: true };
|
|
404473
|
+
return { ok: false, detail: "Too many browser inputs are already waiting. Let the page catch up, then try again." };
|
|
404474
|
+
}
|
|
404475
|
+
try {
|
|
404476
|
+
if (!lease.heldByHuman) {
|
|
404477
|
+
return { ok: false, detail: "Your control of this browser ended while that was waiting." };
|
|
404478
|
+
}
|
|
404479
|
+
if (held.page !== page2 || held.generation !== pageGeneration) {
|
|
404480
|
+
return { ok: false, detail: "The page changed while that input was waiting, so it did not land." };
|
|
404481
|
+
}
|
|
404482
|
+
if (held.screencast !== cdp || held.watchGen !== watch) {
|
|
404483
|
+
return { ok: false, detail: "The view you were driving is gone, so that did not land." };
|
|
404484
|
+
}
|
|
404485
|
+
const pagesBefore = new Set(held.page.context().pages());
|
|
404486
|
+
await this.dispatch(cdp, held, event);
|
|
404487
|
+
await this.followOpenedPage(runId, held, pagesBefore);
|
|
404488
|
+
} catch (err) {
|
|
404489
|
+
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
404490
|
+
} finally {
|
|
404491
|
+
lease.endAction(token);
|
|
404492
|
+
}
|
|
404493
|
+
lease.touch();
|
|
404494
|
+
return { ok: true };
|
|
404495
|
+
}
|
|
404496
|
+
/**
|
|
404497
|
+
* The page's own coordinate space, measured rather than inferred.
|
|
404498
|
+
*
|
|
404499
|
+
* Cached, because a click should not cost a round trip, and invalidated by
|
|
404500
|
+
* the frame metadata changing shape — which arrives free with every frame. A
|
|
404501
|
+
* failed measurement falls back to the last good one rather than to a guess:
|
|
404502
|
+
* a stale viewport is off by a resize, an invented one is off by anything.
|
|
404503
|
+
*/
|
|
404504
|
+
async pageViewport(held, cdp) {
|
|
404505
|
+
if (held.viewport && !held.metricsStale) return held.viewport;
|
|
404506
|
+
try {
|
|
404507
|
+
const metrics = await cdp.send("Page.getLayoutMetrics");
|
|
404508
|
+
const width = metrics?.cssVisualViewport?.clientWidth;
|
|
404509
|
+
const height = metrics?.cssVisualViewport?.clientHeight;
|
|
404510
|
+
if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
|
|
404511
|
+
held.viewport = { width, height };
|
|
404512
|
+
held.metricsStale = false;
|
|
404513
|
+
return held.viewport;
|
|
404514
|
+
}
|
|
404515
|
+
} catch {
|
|
404516
|
+
}
|
|
404517
|
+
return null;
|
|
404518
|
+
}
|
|
404519
|
+
/**
|
|
404520
|
+
* Turn one authorised event into CDP calls. Coordinates arrive normalised.
|
|
404521
|
+
*
|
|
404522
|
+
* A switch with a default that REFUSES, rather than a chain of ifs ending in
|
|
404523
|
+
* the click branch. This is a mutation boundary reached from a socket, so the
|
|
404524
|
+
* question is not what a well-formed event does but what a malformed one
|
|
404525
|
+
* does: the chain answered "clicks", which meant a version-skewed client
|
|
404526
|
+
* sending `{ kind: 'drag', x, y }` pressed the mouse on a real page, and a
|
|
404527
|
+
* kind with no coordinates at all clamped to 0 and clicked the top-left
|
|
404528
|
+
* corner. Unknown input has to fail closed.
|
|
404529
|
+
*
|
|
404530
|
+
* The `never` in the default is the other half: adding a member to
|
|
404531
|
+
* `BrowserInput` without handling it here stops compiling, so the next kind
|
|
404532
|
+
* cannot silently inherit whatever the last branch happened to be.
|
|
404533
|
+
*/
|
|
404534
|
+
async dispatch(cdp, held, event) {
|
|
404535
|
+
switch (event.kind) {
|
|
404536
|
+
case "text": {
|
|
404537
|
+
if (typeof event.text !== "string") {
|
|
404538
|
+
throw new Error("That is not text that can be typed into the page.");
|
|
404539
|
+
}
|
|
404540
|
+
const text = event.text.slice(0, 4e3);
|
|
404541
|
+
if (!text) return;
|
|
404542
|
+
await cdp.send("Input.insertText", { text });
|
|
404543
|
+
return;
|
|
404544
|
+
}
|
|
404545
|
+
case "key": {
|
|
404546
|
+
const modifiers = event.modifiers;
|
|
404547
|
+
if (modifiers !== void 0) {
|
|
404548
|
+
if (!Array.isArray(modifiers) || modifiers.some((m3) => m3 !== "Control" && m3 !== "Meta" && m3 !== "Shift") || new Set(modifiers).size !== modifiers.length) {
|
|
404549
|
+
throw new Error("That editing shortcut has invalid modifiers.");
|
|
404550
|
+
}
|
|
404551
|
+
const primary = modifiers.filter((m3) => m3 === "Control" || m3 === "Meta");
|
|
404552
|
+
const shifted = modifiers.includes("Shift");
|
|
404553
|
+
if (primary.length !== 1) {
|
|
404554
|
+
throw new Error("That editing shortcut cannot be sent to the page.");
|
|
404555
|
+
}
|
|
404556
|
+
const key = typeof event.key === "string" ? event.key : "";
|
|
404557
|
+
const lower2 = key.toLowerCase();
|
|
404558
|
+
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;
|
|
404559
|
+
if (!shortcut) {
|
|
404560
|
+
throw new Error(`That editing shortcut is not supported for the remote page: ${key}`);
|
|
404561
|
+
}
|
|
404562
|
+
const modifierBits = (primary[0] === "Control" ? 2 : 4) | (shifted ? 8 : 0);
|
|
404563
|
+
const base2 = {
|
|
404564
|
+
key: shortcut.key,
|
|
404565
|
+
code: shortcut.code,
|
|
404566
|
+
windowsVirtualKeyCode: shortcut.vk,
|
|
404567
|
+
nativeVirtualKeyCode: shortcut.vk,
|
|
404568
|
+
modifiers: modifierBits
|
|
404569
|
+
};
|
|
404570
|
+
await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "rawKeyDown" });
|
|
404571
|
+
await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "keyUp" });
|
|
404572
|
+
return;
|
|
404573
|
+
}
|
|
404574
|
+
const spec = typeof event.key === "string" && Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : void 0;
|
|
404575
|
+
if (!spec) throw new Error(`That key cannot be sent to the page: ${String(event.key)}`);
|
|
404576
|
+
const base = {
|
|
404577
|
+
key: event.key,
|
|
404578
|
+
code: event.key,
|
|
404579
|
+
windowsVirtualKeyCode: spec.code,
|
|
404580
|
+
nativeVirtualKeyCode: spec.code,
|
|
404581
|
+
...spec.text ? { text: spec.text } : {}
|
|
404582
|
+
};
|
|
404583
|
+
await cdp.send("Input.dispatchKeyEvent", { ...base, type: spec.text ? "keyDown" : "rawKeyDown" });
|
|
404584
|
+
await cdp.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" });
|
|
404585
|
+
return;
|
|
404586
|
+
}
|
|
404587
|
+
case "move":
|
|
404588
|
+
case "scroll":
|
|
404589
|
+
case "click": {
|
|
404590
|
+
const view = await this.pageViewport(held, cdp);
|
|
404591
|
+
if (!view) throw new Error("The page has not been measured yet, so a click cannot be placed on it.");
|
|
404592
|
+
const x5 = coordinate(event.x, "horizontal position") * view.width;
|
|
404593
|
+
const y3 = coordinate(event.y, "vertical position") * view.height;
|
|
404594
|
+
if (event.kind === "move") {
|
|
404595
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: x5, y: y3, button: "none", buttons: 0 });
|
|
404596
|
+
return;
|
|
404597
|
+
}
|
|
404598
|
+
if (event.kind === "scroll") {
|
|
404599
|
+
await cdp.send("Input.dispatchMouseEvent", {
|
|
404600
|
+
type: "mouseWheel",
|
|
404601
|
+
x: x5,
|
|
404602
|
+
y: y3,
|
|
404603
|
+
button: "none",
|
|
404604
|
+
buttons: 0,
|
|
404605
|
+
deltaX: 0,
|
|
404606
|
+
deltaY: scrollDelta(event.deltaY)
|
|
404607
|
+
});
|
|
404608
|
+
return;
|
|
404609
|
+
}
|
|
404610
|
+
if (event.button !== void 0 && !Object.hasOwn(BUTTONS, event.button)) {
|
|
404611
|
+
throw new Error(`That is not a mouse button: ${String(event.button)}`);
|
|
404612
|
+
}
|
|
404613
|
+
const button = event.button ?? "left";
|
|
404614
|
+
const buttons = BUTTONS[button];
|
|
404615
|
+
if (event.clicks !== void 0 && (typeof event.clicks !== "number" || !Number.isFinite(event.clicks))) {
|
|
404616
|
+
throw new Error("That is not a number of clicks.");
|
|
404617
|
+
}
|
|
404618
|
+
const clickCount = Math.min(Math.max(Math.trunc(event.clicks ?? 1), 1), 3);
|
|
404619
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: x5, y: y3, button: "none", buttons: 0 });
|
|
404620
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x: x5, y: y3, button, buttons, clickCount });
|
|
404621
|
+
await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: x5, y: y3, button, buttons: 0, clickCount });
|
|
404622
|
+
return;
|
|
404623
|
+
}
|
|
404624
|
+
default: {
|
|
404625
|
+
const unknown2 = event;
|
|
404626
|
+
throw new Error(`That is not something that can be done to the page: ${unknown2?.kind ?? "an event with no kind"}`);
|
|
404627
|
+
}
|
|
404628
|
+
}
|
|
404629
|
+
}
|
|
403270
404630
|
/** The user stopped this run. Refuses further actions and clears its queue. */
|
|
403271
404631
|
stopRun(runId) {
|
|
403272
404632
|
this.revoked.add(runId);
|
|
@@ -403275,7 +404635,6 @@ var RemoteBrowserController = class {
|
|
|
403275
404635
|
const held = this.runs.get(runId);
|
|
403276
404636
|
if (!held) return;
|
|
403277
404637
|
this.runs.delete(runId);
|
|
403278
|
-
this.closing.add(runId);
|
|
403279
404638
|
void this.teardown(runId, held);
|
|
403280
404639
|
}
|
|
403281
404640
|
/** A run ended: release its browser rather than pay for an idle session. */
|
|
@@ -403288,7 +404647,6 @@ var RemoteBrowserController = class {
|
|
|
403288
404647
|
return;
|
|
403289
404648
|
}
|
|
403290
404649
|
this.runs.delete(runId);
|
|
403291
|
-
this.closing.add(runId);
|
|
403292
404650
|
await this.teardown(runId, held);
|
|
403293
404651
|
this.forgetRun(runId);
|
|
403294
404652
|
}
|
|
@@ -403298,6 +404656,7 @@ var RemoteBrowserController = class {
|
|
|
403298
404656
|
this.leases.delete(runId);
|
|
403299
404657
|
this.aborts.delete(runId);
|
|
403300
404658
|
this.liveViewListeners.delete(runId);
|
|
404659
|
+
this.offControlFor(runId);
|
|
403301
404660
|
}
|
|
403302
404661
|
/**
|
|
403303
404662
|
* Let go of one run's browser.
|
|
@@ -403309,12 +404668,22 @@ var RemoteBrowserController = class {
|
|
|
403309
404668
|
async disposeRun(runId, held) {
|
|
403310
404669
|
held.abort.abort();
|
|
403311
404670
|
this.announceLiveView(runId, void 0, false);
|
|
404671
|
+
await held.watchQueue?.catch(() => {
|
|
404672
|
+
});
|
|
404673
|
+
const watching = held.screencast;
|
|
404674
|
+
held.screencast = void 0;
|
|
404675
|
+
held.screencastPage = void 0;
|
|
404676
|
+
if (watching) {
|
|
404677
|
+
await watching.send("Page.stopScreencast").catch(() => {
|
|
404678
|
+
});
|
|
404679
|
+
await watching.detach().catch(() => {
|
|
404680
|
+
});
|
|
404681
|
+
}
|
|
403312
404682
|
await held.ownedContext?.close().catch(() => {
|
|
403313
404683
|
});
|
|
403314
404684
|
await held.browser.close().catch(() => {
|
|
403315
404685
|
});
|
|
403316
|
-
await this.provider.endSession(held.session.id).catch(() =>
|
|
403317
|
-
});
|
|
404686
|
+
await this.provider.endSession(held.session.id).catch((err) => this.reportReleaseFailure(runId, held.session.id, err, held.session.expiresInMs));
|
|
403318
404687
|
}
|
|
403319
404688
|
/**
|
|
403320
404689
|
* Run `disposeRun`, with the run id counted in `closing` until it settles.
|
|
@@ -403324,12 +404693,12 @@ var RemoteBrowserController = class {
|
|
|
403324
404693
|
* guarantees the removal happens, success or failure, so a disposal that
|
|
403325
404694
|
* throws cannot leave the slot counted against the pool forever.
|
|
403326
404695
|
*/
|
|
403327
|
-
|
|
403328
|
-
|
|
403329
|
-
await this.disposeRun(runId, held);
|
|
403330
|
-
} finally {
|
|
404696
|
+
teardown(runId, held) {
|
|
404697
|
+
const done = this.disposeRun(runId, held).finally(() => {
|
|
403331
404698
|
this.closing.delete(runId);
|
|
403332
|
-
}
|
|
404699
|
+
});
|
|
404700
|
+
this.closing.set(runId, done);
|
|
404701
|
+
return done;
|
|
403333
404702
|
}
|
|
403334
404703
|
async act(action, context) {
|
|
403335
404704
|
const runId = context.sessionId;
|
|
@@ -403343,7 +404712,9 @@ var RemoteBrowserController = class {
|
|
|
403343
404712
|
if (context.signal?.aborted) {
|
|
403344
404713
|
return { ok: false, detail: "The run was cancelled." };
|
|
403345
404714
|
}
|
|
403346
|
-
const
|
|
404715
|
+
const plannedRun = this.runs.get(runId);
|
|
404716
|
+
const planned = plannedRun?.generation ?? 0;
|
|
404717
|
+
const recoveringClosedPage = plannedRun?.page.isClosed() === true;
|
|
403347
404718
|
const lease_ = this.leaseFor(runId);
|
|
403348
404719
|
const lease = await lease_.acquire(actor, runId, context.signal);
|
|
403349
404720
|
if (lease !== "granted") {
|
|
@@ -403357,28 +404728,96 @@ var RemoteBrowserController = class {
|
|
|
403357
404728
|
lease_.releaseIfHeldBy(actor);
|
|
403358
404729
|
return { ok: false, detail: "The run was cancelled." };
|
|
403359
404730
|
}
|
|
403360
|
-
|
|
404731
|
+
const token = await lease_.acquireActionSlot();
|
|
404732
|
+
if (!token) {
|
|
403361
404733
|
lease_.releaseIfHeldBy(actor);
|
|
403362
404734
|
return { ok: false, detail: "Another browser action is still finishing. Try again." };
|
|
403363
404735
|
}
|
|
403364
|
-
|
|
403365
|
-
|
|
403366
|
-
return { ok: false, detail:
|
|
404736
|
+
if (!lease_.heldBy(actor)) {
|
|
404737
|
+
lease_.endAction(token);
|
|
404738
|
+
return { ok: false, detail: refusal(lease_.heldByHuman ? "human" : "off") };
|
|
403367
404739
|
}
|
|
404740
|
+
let temporaryCapture;
|
|
403368
404741
|
try {
|
|
403369
404742
|
const held = await this.open(runId, context.signal);
|
|
403370
404743
|
if (this.revoked.has(runId)) {
|
|
403371
|
-
await this.releaseIfIdle(runId);
|
|
404744
|
+
await this.releaseIfIdle(runId, () => lease_.endAction(token));
|
|
403372
404745
|
return { ok: false, detail: "The user stopped browser control for this run." };
|
|
403373
404746
|
}
|
|
403374
404747
|
if (context.signal?.aborted || held.abort.signal.aborted) {
|
|
403375
|
-
await this.releaseIfIdle(runId);
|
|
404748
|
+
await this.releaseIfIdle(runId, () => lease_.endAction(token));
|
|
403376
404749
|
return { ok: false, detail: "The run was cancelled." };
|
|
403377
404750
|
}
|
|
403378
|
-
|
|
404751
|
+
if ((held.hiddenByHolder === true || held.visibilityUnconfirmed === true) && !held.screencast) {
|
|
404752
|
+
const watchGen = held.watchGen;
|
|
404753
|
+
let cdp = null;
|
|
404754
|
+
try {
|
|
404755
|
+
cdp = await this.attachScreencast(runId, held);
|
|
404756
|
+
} catch {
|
|
404757
|
+
}
|
|
404758
|
+
if (!cdp) {
|
|
404759
|
+
return {
|
|
404760
|
+
ok: false,
|
|
404761
|
+
detail: "The page could not be shown again, so nothing was done to it."
|
|
404762
|
+
};
|
|
404763
|
+
}
|
|
404764
|
+
if (held.onFrame === void 0 && held.watchGen === watchGen) {
|
|
404765
|
+
temporaryCapture = { held, cdp, watchGen };
|
|
404766
|
+
}
|
|
404767
|
+
}
|
|
404768
|
+
if (held.screencast && (held.capturing === false || held.visibilityUnconfirmed === true)) {
|
|
404769
|
+
const cdp = held.screencast;
|
|
404770
|
+
if (held.capturing === true) {
|
|
404771
|
+
await cdp.send("Page.stopScreencast").catch(() => {
|
|
404772
|
+
});
|
|
404773
|
+
held.capturing = false;
|
|
404774
|
+
this.announceControl(runId);
|
|
404775
|
+
}
|
|
404776
|
+
const drawn = this.nextFrame(held);
|
|
404777
|
+
if (!await this.applyCapture(runId, held, cdp, true, true)) {
|
|
404778
|
+
return {
|
|
404779
|
+
ok: false,
|
|
404780
|
+
detail: "The page could not be shown again, so nothing was done to it."
|
|
404781
|
+
};
|
|
404782
|
+
}
|
|
404783
|
+
if (!await drawn) {
|
|
404784
|
+
held.visibilityUnconfirmed = true;
|
|
404785
|
+
held.capturing = false;
|
|
404786
|
+
await cdp.send("Page.stopScreencast").catch(() => {
|
|
404787
|
+
});
|
|
404788
|
+
this.announceControl(runId);
|
|
404789
|
+
return {
|
|
404790
|
+
ok: false,
|
|
404791
|
+
detail: "The page did not come back on screen, so nothing was done to it."
|
|
404792
|
+
};
|
|
404793
|
+
}
|
|
404794
|
+
if (this.revoked.has(runId)) {
|
|
404795
|
+
await this.releaseIfIdle(runId, () => lease_.endAction(token));
|
|
404796
|
+
return { ok: false, detail: "The user stopped browser control for this run." };
|
|
404797
|
+
}
|
|
404798
|
+
if (context.signal?.aborted || held.abort.signal.aborted) {
|
|
404799
|
+
await this.releaseIfIdle(runId, () => lease_.endAction(token));
|
|
404800
|
+
return { ok: false, detail: "The run was cancelled." };
|
|
404801
|
+
}
|
|
404802
|
+
}
|
|
404803
|
+
return await this.perform(runId, held, action, recoveringClosedPage ? held.generation : planned);
|
|
403379
404804
|
} catch (err) {
|
|
403380
404805
|
return { ok: false, detail: err instanceof Error ? err.message : String(err) };
|
|
403381
404806
|
} finally {
|
|
404807
|
+
if (temporaryCapture) {
|
|
404808
|
+
const { held, cdp, watchGen } = temporaryCapture;
|
|
404809
|
+
if (held.screencast === cdp && held.onFrame === void 0 && held.watchGen === watchGen && held.hiddenByHolder === false && held.visibilityUnconfirmed === false && held.capturing === true) {
|
|
404810
|
+
held.onFrame = void 0;
|
|
404811
|
+
held.screencast = void 0;
|
|
404812
|
+
held.screencastPage = void 0;
|
|
404813
|
+
held.capturing = true;
|
|
404814
|
+
this.announceControl(runId);
|
|
404815
|
+
await cdp.send("Page.stopScreencast").catch(() => {
|
|
404816
|
+
});
|
|
404817
|
+
await cdp.detach().catch(() => {
|
|
404818
|
+
});
|
|
404819
|
+
}
|
|
404820
|
+
}
|
|
403382
404821
|
lease_.endAction(token);
|
|
403383
404822
|
if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
|
|
403384
404823
|
}
|
|
@@ -403394,22 +404833,46 @@ var RemoteBrowserController = class {
|
|
|
403394
404833
|
* it out changed no test. Two copies of a cap invite them to disagree.
|
|
403395
404834
|
*/
|
|
403396
404835
|
async open(runId, signal) {
|
|
404836
|
+
if (this.ready) {
|
|
404837
|
+
const settling = this.ready;
|
|
404838
|
+
try {
|
|
404839
|
+
await settling;
|
|
404840
|
+
} catch {
|
|
404841
|
+
}
|
|
404842
|
+
if (this.ready === settling) this.ready = void 0;
|
|
404843
|
+
}
|
|
403397
404844
|
const existing = this.runs.get(runId);
|
|
403398
|
-
if (existing && !existing.page.isClosed())
|
|
404845
|
+
if (existing && !existing.page.isClosed()) {
|
|
404846
|
+
this.announceLiveView(runId, existing.session.liveViewUrl, true);
|
|
404847
|
+
return existing;
|
|
404848
|
+
}
|
|
404849
|
+
if (existing && existing.page.isClosed()) {
|
|
404850
|
+
const fallback = existing.page.context().pages().filter((p3) => !p3.isClosed()).at(-1);
|
|
404851
|
+
if (fallback) {
|
|
404852
|
+
await this.adoptPage(runId, existing, fallback);
|
|
404853
|
+
if (this.runs.get(runId) === existing && !existing.abort.signal.aborted) {
|
|
404854
|
+
this.announceLiveView(runId, existing.session.liveViewUrl, true);
|
|
404855
|
+
}
|
|
404856
|
+
return existing;
|
|
404857
|
+
}
|
|
404858
|
+
}
|
|
403399
404859
|
if (existing) {
|
|
403400
404860
|
this.runs.delete(runId);
|
|
403401
|
-
this.closing.add(runId);
|
|
403402
404861
|
await this.teardown(runId, existing);
|
|
403403
404862
|
}
|
|
404863
|
+
if (this.retired) {
|
|
404864
|
+
throw new Error("The browser settings changed while this action was waiting. Try again.");
|
|
404865
|
+
}
|
|
403404
404866
|
if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
|
|
403405
404867
|
throw new Error(
|
|
403406
404868
|
`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
404869
|
);
|
|
403408
404870
|
}
|
|
403409
|
-
this.opening.add(runId);
|
|
403410
404871
|
if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
|
|
404872
|
+
const opening = this.openReserved(runId, signal);
|
|
404873
|
+
this.opening.set(runId, opening);
|
|
403411
404874
|
try {
|
|
403412
|
-
return await
|
|
404875
|
+
return await opening;
|
|
403413
404876
|
} finally {
|
|
403414
404877
|
this.opening.delete(runId);
|
|
403415
404878
|
}
|
|
@@ -403420,9 +404883,9 @@ var RemoteBrowserController = class {
|
|
|
403420
404883
|
const abort = this.aborts.get(runId)?.signal;
|
|
403421
404884
|
const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
|
|
403422
404885
|
const session = await this.provider.createSession(creationSignal);
|
|
403423
|
-
this.announceLiveView(runId, session.liveViewUrl, true);
|
|
403424
404886
|
let browser2;
|
|
403425
404887
|
let owned;
|
|
404888
|
+
let held;
|
|
403426
404889
|
try {
|
|
403427
404890
|
browser2 = await playwright.chromium.connectOverCDP(session.cdpUrl);
|
|
403428
404891
|
owned = this.provider.isolatesSessions ? void 0 : await browser2.newContext();
|
|
@@ -403430,25 +404893,85 @@ var RemoteBrowserController = class {
|
|
|
403430
404893
|
const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
|
|
403431
404894
|
const abort2 = this.aborts.get(runId);
|
|
403432
404895
|
if (!abort2) throw new Error("The run ended while its browser was still opening.");
|
|
403433
|
-
|
|
403434
|
-
|
|
403435
|
-
if (frame !== page2.mainFrame()) return;
|
|
403436
|
-
held.generation += 1;
|
|
403437
|
-
}));
|
|
404896
|
+
held = { session, browser: browser2, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
|
|
404897
|
+
this.trackPageNavigation(runId, held, page2);
|
|
403438
404898
|
this.runs.set(runId, held);
|
|
403439
|
-
return held;
|
|
403440
404899
|
} catch (err) {
|
|
403441
|
-
this.announceLiveView(runId, void 0, false);
|
|
403442
404900
|
await owned?.close().catch(() => {
|
|
403443
404901
|
});
|
|
403444
404902
|
await browser2?.close().catch(() => {
|
|
403445
404903
|
});
|
|
403446
|
-
await this.provider.endSession(session.id).catch(() =>
|
|
403447
|
-
});
|
|
404904
|
+
await this.provider.endSession(session.id).catch((releaseErr) => this.reportReleaseFailure(runId, session.id, releaseErr, session.expiresInMs));
|
|
403448
404905
|
throw err;
|
|
403449
404906
|
}
|
|
404907
|
+
this.announceLiveView(runId, session.liveViewUrl, true);
|
|
404908
|
+
return held;
|
|
404909
|
+
}
|
|
404910
|
+
/** Count main-frame navigation only while this page is the run's active page. */
|
|
404911
|
+
trackPageNavigation(runId, held, page2) {
|
|
404912
|
+
page2.on("framenavigated", ((frame) => {
|
|
404913
|
+
if (held.page !== page2 || frame !== page2.mainFrame()) return;
|
|
404914
|
+
held.generation += 1;
|
|
404915
|
+
if (held.onFrame && held.watchGen !== void 0) {
|
|
404916
|
+
held.watchGen += 1;
|
|
404917
|
+
this.announceControl(runId);
|
|
404918
|
+
}
|
|
404919
|
+
}));
|
|
404920
|
+
}
|
|
404921
|
+
/**
|
|
404922
|
+
* Follow a page opened by the action that just finished.
|
|
404923
|
+
*
|
|
404924
|
+
* A popup/new tab is part of the same user gesture (OAuth is the common
|
|
404925
|
+
* example). Keeping `held.page` and the screencast on its opener makes the
|
|
404926
|
+
* new page both invisible and impossible to drive. This runs while the SAME
|
|
404927
|
+
* action slot is still held, after the opening click/key has fully landed, so
|
|
404928
|
+
* no later human/agent event can slip between detecting the popup and moving
|
|
404929
|
+
* the view to it.
|
|
404930
|
+
*/
|
|
404931
|
+
async followOpenedPage(runId, held, before) {
|
|
404932
|
+
if (this.runs.get(runId) !== held || held.abort.signal.aborted) return;
|
|
404933
|
+
const candidates = held.page.context().pages().filter((p3) => !p3.isClosed() && !before.has(p3));
|
|
404934
|
+
const next = candidates.at(-1);
|
|
404935
|
+
if (!next || next === held.page) return;
|
|
404936
|
+
await this.adoptPage(runId, held, next);
|
|
404937
|
+
}
|
|
404938
|
+
/**
|
|
404939
|
+
* Make one live page the run's controlled/viewed page. Used in both
|
|
404940
|
+
* directions: into a popup opened by an action, and back to another surviving
|
|
404941
|
+
* page when that popup closes itself after OAuth/login completion.
|
|
404942
|
+
*/
|
|
404943
|
+
async adoptPage(runId, held, next) {
|
|
404944
|
+
if (next === held.page || next.isClosed()) return;
|
|
404945
|
+
held.page = next;
|
|
404946
|
+
held.generation += 1;
|
|
404947
|
+
held.viewport = void 0;
|
|
404948
|
+
held.metricsStale = true;
|
|
404949
|
+
this.trackPageNavigation(runId, held, next);
|
|
404950
|
+
const deliver = held.onFrame;
|
|
404951
|
+
if (deliver) {
|
|
404952
|
+
held.watchGen = (held.watchGen ?? 0) + 1;
|
|
404953
|
+
held.visibilityUnconfirmed = true;
|
|
404954
|
+
held.capturing = false;
|
|
404955
|
+
this.announceControl(runId);
|
|
404956
|
+
await this.attachScreencast(runId, held, deliver, held.watchDetachGen ?? 0);
|
|
404957
|
+
return;
|
|
404958
|
+
}
|
|
404959
|
+
const pending = held.attaching;
|
|
404960
|
+
if (pending) await pending.catch(() => null);
|
|
404961
|
+
if (held.screencast && held.screencastPage !== next) {
|
|
404962
|
+
const stale = held.screencast;
|
|
404963
|
+
held.screencast = void 0;
|
|
404964
|
+
held.screencastPage = void 0;
|
|
404965
|
+
await stale.send("Page.stopScreencast").catch(() => {
|
|
404966
|
+
});
|
|
404967
|
+
await stale.detach().catch(() => {
|
|
404968
|
+
});
|
|
404969
|
+
}
|
|
404970
|
+
held.visibilityUnconfirmed = false;
|
|
404971
|
+
held.capturing = held.hiddenByHolder !== true;
|
|
404972
|
+
this.announceControl(runId);
|
|
403450
404973
|
}
|
|
403451
|
-
async perform(held, action, planned) {
|
|
404974
|
+
async perform(runId, held, action, planned) {
|
|
403452
404975
|
const { page: page2 } = held;
|
|
403453
404976
|
const stoppable = async (work) => {
|
|
403454
404977
|
if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
|
|
@@ -403470,10 +404993,10 @@ var RemoteBrowserController = class {
|
|
|
403470
404993
|
}
|
|
403471
404994
|
};
|
|
403472
404995
|
const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
|
|
403473
|
-
const where = async () =>
|
|
403474
|
-
|
|
403475
|
-
title: await
|
|
403476
|
-
}
|
|
404996
|
+
const where = async () => {
|
|
404997
|
+
const current = held.page;
|
|
404998
|
+
return { url: current.url(), title: await current.title().catch(() => "") };
|
|
404999
|
+
};
|
|
403477
405000
|
if (action.kind !== "navigate" && held.generation !== planned) {
|
|
403478
405001
|
return {
|
|
403479
405002
|
ok: false,
|
|
@@ -403490,15 +405013,20 @@ var RemoteBrowserController = class {
|
|
|
403490
405013
|
await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
|
|
403491
405014
|
return { ok: true, detail: `Opened ${target}`, ...await where() };
|
|
403492
405015
|
}
|
|
403493
|
-
case "click":
|
|
405016
|
+
case "click": {
|
|
405017
|
+
const pagesBefore = new Set(page2.context().pages());
|
|
403494
405018
|
await stoppable(page2.click(action.selector, { timeout }));
|
|
405019
|
+
await this.followOpenedPage(runId, held, pagesBefore);
|
|
403495
405020
|
return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
|
|
405021
|
+
}
|
|
403496
405022
|
case "fill":
|
|
403497
405023
|
await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
|
|
403498
405024
|
return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
|
|
403499
405025
|
case "press": {
|
|
405026
|
+
const pagesBefore = new Set(page2.context().pages());
|
|
403500
405027
|
if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
|
|
403501
405028
|
else await stoppable(page2.keyboard.press(action.key));
|
|
405029
|
+
await this.followOpenedPage(runId, held, pagesBefore);
|
|
403502
405030
|
return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
|
|
403503
405031
|
}
|
|
403504
405032
|
case "wait_for":
|
|
@@ -403514,10 +405042,25 @@ var RemoteBrowserController = class {
|
|
|
403514
405042
|
}
|
|
403515
405043
|
};
|
|
403516
405044
|
function refusal(result) {
|
|
405045
|
+
if (result === "human") {
|
|
405046
|
+
return "The user has taken control of this browser. Stop using it and tell them what you were about to do.";
|
|
405047
|
+
}
|
|
403517
405048
|
if (result === "off") return "Browser control was turned off while this action waited for the browser.";
|
|
403518
405049
|
if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
|
|
403519
405050
|
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
405051
|
}
|
|
405052
|
+
function coordinate(n8, what) {
|
|
405053
|
+
if (typeof n8 !== "number" || !Number.isFinite(n8)) {
|
|
405054
|
+
throw new Error(`That ${what} is not a position on the page.`);
|
|
405055
|
+
}
|
|
405056
|
+
return Math.min(Math.max(n8, 0), 1);
|
|
405057
|
+
}
|
|
405058
|
+
function scrollDelta(n8) {
|
|
405059
|
+
if (typeof n8 !== "number" || !Number.isFinite(n8)) {
|
|
405060
|
+
throw new Error("That scroll has no distance to travel.");
|
|
405061
|
+
}
|
|
405062
|
+
return Math.min(Math.max(n8, -2e3), 2e3);
|
|
405063
|
+
}
|
|
403521
405064
|
async function loadPlaywright() {
|
|
403522
405065
|
try {
|
|
403523
405066
|
return await import('playwright');
|
|
@@ -403568,6 +405111,139 @@ function isCdpEndpoint(url2) {
|
|
|
403568
405111
|
|
|
403569
405112
|
// src/browser/remote/steel.ts
|
|
403570
405113
|
var DEFAULT_BASE = "https://api.steel.dev";
|
|
405114
|
+
function isHostedSteel(url2) {
|
|
405115
|
+
if (!url2) return true;
|
|
405116
|
+
try {
|
|
405117
|
+
return hostKey(new URL(url2).hostname) === hostKey(new URL(DEFAULT_BASE).hostname);
|
|
405118
|
+
} catch {
|
|
405119
|
+
return false;
|
|
405120
|
+
}
|
|
405121
|
+
}
|
|
405122
|
+
function isUsableSteelBase(url2) {
|
|
405123
|
+
try {
|
|
405124
|
+
const u2 = new URL(url2);
|
|
405125
|
+
if (u2.protocol !== "http:" && u2.protocol !== "https:") return false;
|
|
405126
|
+
if (u2.username !== "" || u2.password !== "") return false;
|
|
405127
|
+
const built = new URL(withoutTrailingSlashes(url2) + PROBE_PATH);
|
|
405128
|
+
if (FETCH_BLOCKED_PORTS.has(built.port)) return false;
|
|
405129
|
+
if (built.search !== "" || built.hash !== "") return false;
|
|
405130
|
+
if (!built.pathname.endsWith(PROBE_PATH)) return false;
|
|
405131
|
+
if (isHostedSteel(url2)) {
|
|
405132
|
+
if (built.pathname !== PROBE_PATH) return false;
|
|
405133
|
+
if (u2.protocol !== "https:") return false;
|
|
405134
|
+
if (built.port !== "") return false;
|
|
405135
|
+
}
|
|
405136
|
+
return true;
|
|
405137
|
+
} catch {
|
|
405138
|
+
return false;
|
|
405139
|
+
}
|
|
405140
|
+
}
|
|
405141
|
+
function isUsableSteelKey(apiKey) {
|
|
405142
|
+
for (const ch of apiKey) {
|
|
405143
|
+
const c4 = ch.codePointAt(0) ?? 0;
|
|
405144
|
+
if (c4 === 9) continue;
|
|
405145
|
+
if (c4 >= 32 && c4 <= 126) continue;
|
|
405146
|
+
if (c4 >= 128 && c4 <= 255) continue;
|
|
405147
|
+
return false;
|
|
405148
|
+
}
|
|
405149
|
+
return true;
|
|
405150
|
+
}
|
|
405151
|
+
var API_KEY_HEADER = "steel-api-key";
|
|
405152
|
+
var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
|
|
405153
|
+
"1",
|
|
405154
|
+
"7",
|
|
405155
|
+
"9",
|
|
405156
|
+
"11",
|
|
405157
|
+
"13",
|
|
405158
|
+
"15",
|
|
405159
|
+
"17",
|
|
405160
|
+
"19",
|
|
405161
|
+
"20",
|
|
405162
|
+
"21",
|
|
405163
|
+
"22",
|
|
405164
|
+
"23",
|
|
405165
|
+
"25",
|
|
405166
|
+
"37",
|
|
405167
|
+
"42",
|
|
405168
|
+
"43",
|
|
405169
|
+
"53",
|
|
405170
|
+
"69",
|
|
405171
|
+
"77",
|
|
405172
|
+
"79",
|
|
405173
|
+
"87",
|
|
405174
|
+
"95",
|
|
405175
|
+
"101",
|
|
405176
|
+
"102",
|
|
405177
|
+
"103",
|
|
405178
|
+
"104",
|
|
405179
|
+
"109",
|
|
405180
|
+
"110",
|
|
405181
|
+
"111",
|
|
405182
|
+
"113",
|
|
405183
|
+
"115",
|
|
405184
|
+
"117",
|
|
405185
|
+
"119",
|
|
405186
|
+
"123",
|
|
405187
|
+
"135",
|
|
405188
|
+
"137",
|
|
405189
|
+
"139",
|
|
405190
|
+
"143",
|
|
405191
|
+
"161",
|
|
405192
|
+
"179",
|
|
405193
|
+
"389",
|
|
405194
|
+
"427",
|
|
405195
|
+
"465",
|
|
405196
|
+
"512",
|
|
405197
|
+
"513",
|
|
405198
|
+
"514",
|
|
405199
|
+
"515",
|
|
405200
|
+
"526",
|
|
405201
|
+
"530",
|
|
405202
|
+
"531",
|
|
405203
|
+
"532",
|
|
405204
|
+
"540",
|
|
405205
|
+
"548",
|
|
405206
|
+
"554",
|
|
405207
|
+
"556",
|
|
405208
|
+
"563",
|
|
405209
|
+
"587",
|
|
405210
|
+
"601",
|
|
405211
|
+
"636",
|
|
405212
|
+
"989",
|
|
405213
|
+
"990",
|
|
405214
|
+
"993",
|
|
405215
|
+
"995",
|
|
405216
|
+
"1719",
|
|
405217
|
+
"1720",
|
|
405218
|
+
"1723",
|
|
405219
|
+
"2049",
|
|
405220
|
+
"3659",
|
|
405221
|
+
"4045",
|
|
405222
|
+
"4190",
|
|
405223
|
+
"5060",
|
|
405224
|
+
"5061",
|
|
405225
|
+
"6000",
|
|
405226
|
+
"6566",
|
|
405227
|
+
"6665",
|
|
405228
|
+
"6666",
|
|
405229
|
+
"6667",
|
|
405230
|
+
"6668",
|
|
405231
|
+
"6669",
|
|
405232
|
+
"6679",
|
|
405233
|
+
"6697",
|
|
405234
|
+
"10080"
|
|
405235
|
+
]);
|
|
405236
|
+
var PROBE_PATH = "/v1/sessions";
|
|
405237
|
+
function withoutTrailingSlashes(value) {
|
|
405238
|
+
let out = value;
|
|
405239
|
+
while (out.endsWith("/")) out = out.slice(0, -1);
|
|
405240
|
+
return out;
|
|
405241
|
+
}
|
|
405242
|
+
function hostKey(hostname2) {
|
|
405243
|
+
let host = hostname2.toLowerCase();
|
|
405244
|
+
while (host.endsWith(".")) host = host.slice(0, -1);
|
|
405245
|
+
return host;
|
|
405246
|
+
}
|
|
403571
405247
|
var CREATE_TIMEOUT_MS = 6e4;
|
|
403572
405248
|
var SteelProvider = class {
|
|
403573
405249
|
name = "Steel";
|
|
@@ -403577,9 +405253,7 @@ var SteelProvider = class {
|
|
|
403577
405253
|
base;
|
|
403578
405254
|
apiKey;
|
|
403579
405255
|
constructor(opts = {}) {
|
|
403580
|
-
|
|
403581
|
-
while (base.endsWith("/")) base = base.slice(0, -1);
|
|
403582
|
-
this.base = base;
|
|
405256
|
+
this.base = withoutTrailingSlashes(opts.url || DEFAULT_BASE);
|
|
403583
405257
|
this.apiKey = opts.apiKey;
|
|
403584
405258
|
}
|
|
403585
405259
|
async createSession(signal) {
|
|
@@ -403590,14 +405264,15 @@ var SteelProvider = class {
|
|
|
403590
405264
|
return {
|
|
403591
405265
|
id: details.id,
|
|
403592
405266
|
cdpUrl: details.websocketUrl,
|
|
403593
|
-
...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
|
|
405267
|
+
...details.debugUrl ? { liveViewUrl: details.debugUrl } : {},
|
|
405268
|
+
// Carried through because it explains an otherwise mysterious death. A
|
|
405269
|
+
// session nobody released dies at exactly this number, and without it a
|
|
405270
|
+
// leak looks identical to an orderly shutdown.
|
|
405271
|
+
...typeof details.timeout === "number" ? { expiresInMs: details.timeout } : {}
|
|
403594
405272
|
};
|
|
403595
405273
|
}
|
|
403596
405274
|
async endSession(id) {
|
|
403597
|
-
|
|
403598
|
-
await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
|
|
403599
|
-
} catch {
|
|
403600
|
-
}
|
|
405275
|
+
await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
|
|
403601
405276
|
}
|
|
403602
405277
|
async call(method, path32, signal, body) {
|
|
403603
405278
|
const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
|
|
@@ -403608,7 +405283,7 @@ var SteelProvider = class {
|
|
|
403608
405283
|
"content-type": "application/json",
|
|
403609
405284
|
// Sent only when configured — the self-hosted API takes no key, and an
|
|
403610
405285
|
// empty header is worse than none.
|
|
403611
|
-
...this.apiKey ? {
|
|
405286
|
+
...this.apiKey ? { [API_KEY_HEADER]: this.apiKey } : {}
|
|
403612
405287
|
},
|
|
403613
405288
|
body: JSON.stringify(body),
|
|
403614
405289
|
signal: composed
|
|
@@ -404703,6 +406378,7 @@ object-assign/index.js:
|
|
|
404703
406378
|
|
|
404704
406379
|
exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
|
|
404705
406380
|
exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
|
|
406381
|
+
exports.AskUserTool = AskUserTool;
|
|
404706
406382
|
exports.AuditLogger = AuditLogger;
|
|
404707
406383
|
exports.BrowserControlTool = BrowserControlTool;
|
|
404708
406384
|
exports.BrowserLease = BrowserLease;
|
|
@@ -404834,7 +406510,10 @@ exports.inferAzureBaseModel = inferAzureBaseModel;
|
|
|
404834
406510
|
exports.inlineRuns = inlineRuns;
|
|
404835
406511
|
exports.isCdpEndpoint = isCdpEndpoint;
|
|
404836
406512
|
exports.isDocumentFormat = isDocumentFormat;
|
|
406513
|
+
exports.isHostedSteel = isHostedSteel;
|
|
404837
406514
|
exports.isMcpToolName = isMcpToolName;
|
|
406515
|
+
exports.isUsableSteelBase = isUsableSteelBase;
|
|
406516
|
+
exports.isUsableSteelKey = isUsableSteelKey;
|
|
404838
406517
|
exports.matchImageLine = matchImageLine;
|
|
404839
406518
|
exports.mcpServerPrefix = mcpServerPrefix;
|
|
404840
406519
|
exports.mcpToolName = mcpToolName;
|