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/index.cjs CHANGED
@@ -11669,7 +11669,7 @@ function reconcilePrice(model, live) {
11669
11669
  }
11670
11670
 
11671
11671
  // src/constants.ts
11672
- var CASCADE_VERSION = "0.79.0";
11672
+ var CASCADE_VERSION = "0.81.0";
11673
11673
  var CASCADE_CONFIG_DIR = ".cascade";
11674
11674
  var CASCADE_MD_FILE = "CASCADE.md";
11675
11675
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -30907,8 +30907,95 @@ function requiredFieldFor(kind, input) {
30907
30907
  }
30908
30908
  }
30909
30909
 
30910
+ // src/tools/ask-user.ts
30911
+ var MAX_QUESTIONS = 5;
30912
+ var MAX_OPTIONS = 6;
30913
+ var AskUserTool = class extends BaseTool {
30914
+ name = "ask_user";
30915
+ 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.";
30916
+ inputSchema = {
30917
+ type: "object",
30918
+ properties: {
30919
+ questions: {
30920
+ type: "array",
30921
+ minItems: 1,
30922
+ maxItems: MAX_QUESTIONS,
30923
+ description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
30924
+ items: {
30925
+ type: "object",
30926
+ properties: {
30927
+ prompt: { type: "string", description: "The question, as you would say it to them." },
30928
+ kind: {
30929
+ type: "string",
30930
+ enum: ["choice", "multi", "text"],
30931
+ description: "choice = pick one, multi = pick any that apply, text = free-form answer."
30932
+ },
30933
+ options: {
30934
+ type: "array",
30935
+ items: { type: "string" },
30936
+ description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
30937
+ }
30938
+ },
30939
+ required: ["prompt", "kind"]
30940
+ }
30941
+ }
30942
+ },
30943
+ required: ["questions"]
30944
+ };
30945
+ ask;
30946
+ constructor(ask) {
30947
+ super();
30948
+ this.ask = ask;
30949
+ }
30950
+ // Asking a question changes nothing, so it needs no approval gate: a prompt
30951
+ // asking permission to show a prompt is a tax on the behaviour we want.
30952
+ isDangerous() {
30953
+ return false;
30954
+ }
30955
+ async execute(input, options) {
30956
+ const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
30957
+ const questions = [];
30958
+ for (const [i, q] of raw.slice(0, MAX_QUESTIONS).entries()) {
30959
+ const item = q ?? {};
30960
+ const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
30961
+ if (!prompt) continue;
30962
+ const kind = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
30963
+ const options_ = Array.isArray(item["options"]) ? item["options"].filter((o) => typeof o === "string" && o.trim() !== "").slice(0, MAX_OPTIONS) : [];
30964
+ const resolved = kind !== "text" && options_.length < 2 ? "text" : kind;
30965
+ questions.push({
30966
+ // Positional, and assigned HERE. Letting the model supply ids invites
30967
+ // two questions sharing one, and the second answer would overwrite the
30968
+ // first with nothing to say it had.
30969
+ id: `q${i + 1}`,
30970
+ prompt,
30971
+ kind: resolved,
30972
+ ...resolved === "text" ? {} : { options: options_ }
30973
+ });
30974
+ }
30975
+ if (questions.length === 0) {
30976
+ return "Error: ask_user needs at least one question with a prompt.";
30977
+ }
30978
+ const result = await this.ask(questions, options.signal);
30979
+ if (result.outcome !== "answered" || result.answers.length === 0) {
30980
+ 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";
30981
+ return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
30982
+ }
30983
+ const byId = new Map(result.answers.map((a) => [a.id, a.value]));
30984
+ const lines = questions.map((q) => {
30985
+ const value = byId.get(q.id);
30986
+ const said = Array.isArray(value) ? value.join(", ") : value;
30987
+ return `${q.prompt}
30988
+ \u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
30989
+ });
30990
+ return `They answered:
30991
+
30992
+ ${lines.join("\n\n")}`;
30993
+ }
30994
+ };
30995
+
30910
30996
  // src/core/cascade.ts
30911
30997
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
30998
+ var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
30912
30999
  function buildContextualPrompt(prompt, history = []) {
30913
31000
  const recent = history.slice(-6);
30914
31001
  if (recent.length === 0) return prompt;
@@ -31187,6 +31274,112 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31187
31274
  // timeout. Same class of bug as the twelve-failover dead-model race: a wave
31188
31275
  // is concurrent, so per-run singletons are never safe inside it.
31189
31276
  pendingEscalations = /* @__PURE__ */ new Map();
31277
+ /**
31278
+ * Questionnaires waiting on a person, keyed by request id.
31279
+ *
31280
+ * Exactly the shape `pendingEscalations` uses, and for the same reason: the
31281
+ * answer, the timeout and the abort all race, and one settle path guarded by
31282
+ * the map delete is what makes the first of them win.
31283
+ */
31284
+ pendingClarifications = /* @__PURE__ */ new Map();
31285
+ /**
31286
+ * Put a gate's opening question to its listeners, and NOTICE when one fails.
31287
+ *
31288
+ * `emit` cannot do this job. It calls listeners synchronously and discards
31289
+ * what they return, so an `async` listener — the ordinary shape for anything
31290
+ * that has to reach a socket or a UI — does not fail in a way `emit` reports:
31291
+ * it returns a REJECTED PROMISE that nobody is holding. The `try` around the
31292
+ * emit sees nothing, the gate is never told delivery failed, and it parks for
31293
+ * its full timeout while Node raises an unhandled rejection that can take the
31294
+ * process down.
31295
+ *
31296
+ * `onFailed` is called for either shape, and it is safe to call more than
31297
+ * once: both gates route it to `settle`, which is guarded by a map delete, so
31298
+ * the first failure wins and the rest are no-ops.
31299
+ *
31300
+ * PER LISTENER, not around the loop. `emit` stops dispatching at the first
31301
+ * throw, so one broken host used to cost a working one its notification —
31302
+ * the same reason `announceLiveView` delivers its two listeners separately.
31303
+ * A synchronous failure settles the gate once the whole snapshot has been
31304
+ * delivered; an asynchronous one settles it when the rejection arrives, which
31305
+ * is still far better than the window-long park it replaces.
31306
+ *
31307
+ * SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
31308
+ * settles the gate, and settling emits the gate's CLOSURE — so failing
31309
+ * mid-dispatch announced "this request is over" and then went on handing the
31310
+ * OPENING event to the listeners after it. A host later in the list would
31311
+ * draw a form whose only closure notification had already gone past, and it
31312
+ * would stand there forever. Deliver to everyone, then decide.
31313
+ *
31314
+ * `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
31315
+ * `emit` was doing, and `once` is the part that bites: `listeners()` hands
31316
+ * back the UNWRAPPED callback, so calling it never runs the wrapper that
31317
+ * deregisters it. A host that asked to be told once would be told every
31318
+ * time — and told through a transport it had set up for one gate only.
31319
+ * `rawListeners()` returns the wrapper, which removes the registration and
31320
+ * passes the underlying listener's return value straight through, so the
31321
+ * promise below is still the one being observed. Verified against Node.
31322
+ *
31323
+ * `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
31324
+ * invokes listeners with the emitter as the receiver, so
31325
+ * `function () { this.resolveClarification(...) }` is a legitimate host — and
31326
+ * calling it bare gave it `this === undefined`, which throws, which this
31327
+ * would then have read as the host failing to receive the question and
31328
+ * settled the gate unanswered. A working listener turned into a delivery
31329
+ * failure by the thing delivering it.
31330
+ */
31331
+ deliverGate(event, payload, onFailed) {
31332
+ let delivered = false;
31333
+ let threw = false;
31334
+ const pending = [];
31335
+ for (const listener of this.rawListeners(event)) {
31336
+ try {
31337
+ const out = listener.call(this, payload);
31338
+ if (out && typeof out.then === "function") {
31339
+ pending.push(Promise.resolve(out).then(() => true, () => false));
31340
+ } else {
31341
+ delivered = true;
31342
+ }
31343
+ } catch {
31344
+ threw = true;
31345
+ }
31346
+ }
31347
+ if (delivered) return;
31348
+ if (pending.length === 0) {
31349
+ if (threw) onFailed();
31350
+ return;
31351
+ }
31352
+ void Promise.all(pending).then((results) => {
31353
+ if (!results.some(Boolean)) onFailed();
31354
+ });
31355
+ }
31356
+ /**
31357
+ * Tell listeners something that is over, and let none of them make it worse.
31358
+ *
31359
+ * The closing half of the gate family — `clarification:closed`,
31360
+ * `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
31361
+ * was already wrapped in a `try` for the synchronous case, and each had the
31362
+ * same hole `deliverGate` had before last round: `emit` discards what a
31363
+ * listener returns, so an `async` host fails by returning a rejected promise
31364
+ * nobody is holding and Node raises an unhandled rejection.
31365
+ *
31366
+ * There is nothing to settle here — the gate is already closed, which is the
31367
+ * whole message — so unlike `deliverGate` this reports to no one. It exists
31368
+ * so that the four informational emits cannot take the process down, and so
31369
+ * the rule is written once rather than four times.
31370
+ */
31371
+ announceGateClosed(event, payload) {
31372
+ for (const listener of this.rawListeners(event)) {
31373
+ try {
31374
+ const out = listener.call(this, payload);
31375
+ if (out && typeof out.then === "function") {
31376
+ void Promise.resolve(out).catch(() => {
31377
+ });
31378
+ }
31379
+ } catch {
31380
+ }
31381
+ }
31382
+ }
31190
31383
  async requestEscalationDecision(ctx, taskId, signal) {
31191
31384
  if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
31192
31385
  if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
@@ -31199,23 +31392,28 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31199
31392
  if (timeout) clearTimeout(timeout);
31200
31393
  signal?.removeEventListener("abort", onAbort);
31201
31394
  resolve2(decision);
31395
+ this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
31202
31396
  };
31203
31397
  const onAbort = () => settle({ action: "skip", automatic: true });
31204
31398
  signal?.addEventListener("abort", onAbort, { once: true });
31205
31399
  timeout = setTimeout(() => {
31206
- this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31207
31400
  settle({ action: "timeout" });
31401
+ this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31208
31402
  }, ESCALATION_DECISION_TIMEOUT_MS);
31209
31403
  this.pendingEscalations.set(requestId, settle);
31210
- this.emit("escalation:decision-required", {
31211
- taskId,
31212
- requestId,
31213
- sectionId: ctx.sectionId,
31214
- sectionTitle: ctx.sectionTitle,
31215
- issues: ctx.issues,
31216
- summary: ctx.summary,
31217
- timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31218
- });
31404
+ this.deliverGate(
31405
+ "escalation:decision-required",
31406
+ {
31407
+ taskId,
31408
+ requestId,
31409
+ sectionId: ctx.sectionId,
31410
+ sectionTitle: ctx.sectionTitle,
31411
+ issues: ctx.issues,
31412
+ summary: ctx.summary,
31413
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31414
+ },
31415
+ () => settle({ action: "skip", automatic: true })
31416
+ );
31219
31417
  });
31220
31418
  }
31221
31419
  /**
@@ -31252,6 +31450,73 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31252
31450
  releasePendingEscalations(action = "skip") {
31253
31451
  for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
31254
31452
  }
31453
+ // ── Clarification: asking instead of assuming ───────────────────────
31454
+ // The same gate shape as the escalation above, and for the same reasons. The
31455
+ // difference that matters: an unanswered escalation SKIPS a section, while an
31456
+ // unanswered question just means the model proceeds on its own reading — so
31457
+ // every non-answer here is benign, and none of them may park the run.
31458
+ /**
31459
+ * Put a questionnaire to whoever is watching, if anybody is.
31460
+ *
31461
+ * Returns `no-listener` rather than waiting when there is nothing attached to
31462
+ * answer — a scheduled run, an API caller with no socket, an autonomous run
31463
+ * that has already said it wants no prompts. That check is the whole reason
31464
+ * this can be offered to the model at all: a tool that might hang a headless
31465
+ * run forever is a tool that cannot be registered.
31466
+ */
31467
+ async askUser(questions, signal) {
31468
+ const none = (outcome) => ({ outcome, answers: [] });
31469
+ if (this.unattended) return none("no-listener");
31470
+ if (this.config.autonomy === "auto") return none("no-listener");
31471
+ if (this.listenerCount("clarification:required") === 0) return none("no-listener");
31472
+ if (signal?.aborted) return none("aborted");
31473
+ const requestId = crypto.randomUUID();
31474
+ return await new Promise((resolve2) => {
31475
+ let timeout;
31476
+ const settle = (result) => {
31477
+ if (!this.pendingClarifications.delete(requestId)) return;
31478
+ if (timeout) clearTimeout(timeout);
31479
+ signal?.removeEventListener("abort", onAbort);
31480
+ resolve2(result);
31481
+ this.announceGateClosed("clarification:closed", { requestId });
31482
+ };
31483
+ const onAbort = () => settle(none("aborted"));
31484
+ signal?.addEventListener("abort", onAbort, { once: true });
31485
+ timeout = setTimeout(() => {
31486
+ settle(none("timeout"));
31487
+ this.announceGateClosed("clarification:timeout", { requestId });
31488
+ }, CLARIFICATION_TIMEOUT_MS);
31489
+ this.pendingClarifications.set(requestId, settle);
31490
+ this.deliverGate(
31491
+ "clarification:required",
31492
+ { requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
31493
+ () => settle(none("no-listener"))
31494
+ );
31495
+ });
31496
+ }
31497
+ /**
31498
+ * Answer a questionnaire from a REPL / dashboard / cloud listener.
31499
+ *
31500
+ * `requestId` comes back on the `clarification:required` event and is what
31501
+ * makes an answer land on the question that asked. Optional for a host with
31502
+ * only one in flight, matching `resolveEscalation`: without it the oldest
31503
+ * outstanding request is answered, which is correct whenever there is one.
31504
+ */
31505
+ resolveClarification(answers, requestId) {
31506
+ const result = { outcome: "answered", answers };
31507
+ if (requestId) {
31508
+ this.pendingClarifications.get(requestId)?.(result);
31509
+ return;
31510
+ }
31511
+ const oldest = this.pendingClarifications.keys().next();
31512
+ if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
31513
+ }
31514
+ /** Release every parked questionnaire — run teardown, so none outlive the run. */
31515
+ releasePendingClarifications() {
31516
+ for (const settle of Array.from(this.pendingClarifications.values())) {
31517
+ settle({ outcome: "aborted", answers: [] });
31518
+ }
31519
+ }
31255
31520
  /**
31256
31521
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
31257
31522
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -31276,12 +31541,14 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31276
31541
  const timeout = setTimeout(() => {
31277
31542
  if (this.pendingContextApproval) {
31278
31543
  this.pendingContextApproval = void 0;
31544
+ this.announceGateClosed("context:approval-closed", {});
31279
31545
  resolve2(true);
31280
31546
  }
31281
31547
  }, 12e4);
31282
31548
  this.pendingContextApproval = (approved) => {
31283
31549
  clearTimeout(timeout);
31284
31550
  this.pendingContextApproval = void 0;
31551
+ this.announceGateClosed("context:approval-closed", {});
31285
31552
  resolve2(approved);
31286
31553
  };
31287
31554
  this.emit("context:approval-required", info);
@@ -31472,6 +31739,34 @@ ${input}` }],
31472
31739
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31473
31740
  this.toolRegistry.register(new BrowserControlTool(controller, release));
31474
31741
  }
31742
+ /**
31743
+ * Declare that this host can put a questionnaire to a person and answer it.
31744
+ *
31745
+ * Explicit, and NOT done at construction, because registering a tool is a
31746
+ * promise the host has to keep. A host that registers `ask_user` without
31747
+ * listening for `clarification:required` gives the model a tool that always
31748
+ * answers "there is nobody watching this run" — while a person sits at the
31749
+ * terminal looking at it. That is worse than not offering the tool at all:
31750
+ * it costs a round trip and then tells the model something false.
31751
+ *
31752
+ * Only `cloud/server` bridges the protocol today. The CLI REPL and the
31753
+ * dashboard do not, so they do not call this, and their models are not shown
31754
+ * a capability nobody can serve. Wiring either of them is a matter of
31755
+ * listening for the event and calling `resolveClarification`, then calling
31756
+ * this.
31757
+ *
31758
+ * Still gated on the same conditions as the call itself, and `askUser`
31759
+ * re-checks them: `setUnattended` can land after this, so the tool can be
31760
+ * registered for a run that later turns out to have nobody on it. Then it
31761
+ * answers `no-listener` and the model proceeds on its own reading, which is
31762
+ * the same defence-in-depth `BrowserControlTool.revoke` provides.
31763
+ */
31764
+ enableClarification() {
31765
+ if (this.unattended) return;
31766
+ if (this.config.autonomy === "auto") return;
31767
+ if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
31768
+ this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
31769
+ }
31475
31770
  registerMediaTools(workspacePath) {
31476
31771
  const denied = new Set(this.config.tools?.disabledTools ?? []);
31477
31772
  const permitted = (name) => !denied.has(name);
@@ -32417,6 +32712,10 @@ ${finalOutput}` : ""
32417
32712
  this.releasePendingEscalations();
32418
32713
  } catch {
32419
32714
  }
32715
+ try {
32716
+ this.releasePendingClarifications();
32717
+ } catch {
32718
+ }
32420
32719
  this.router.restoreTierModels();
32421
32720
  this.router.setRunSignal(void 0);
32422
32721
  if (this.taskAnalyzer) {
@@ -35023,11 +35322,13 @@ async function discoverMcpTools(servers) {
35023
35322
  }
35024
35323
 
35025
35324
  // src/browser/lease.ts
35325
+ var HUMAN_ACTOR = "__human__";
35326
+ var HUMAN_IDLE_MS = 12e4;
35026
35327
  var LEASE_DEADLOCK_CEILING_MS = 3e5;
35027
35328
  var QUEUE_WAIT_MS = 18e4;
35028
35329
  var MAX_QUEUED = 8;
35029
35330
  var ACTION_HANDOFF_MS = 2e3;
35030
- var ACTION_HANDOFF_POLL_MS = 25;
35331
+ var HANDBACK_RETRY_MS = 25;
35031
35332
  var BrowserLease = class {
35032
35333
  actor = null;
35033
35334
  session = null;
@@ -35046,6 +35347,51 @@ var BrowserLease = class {
35046
35347
  * took the slot may release it.
35047
35348
  */
35048
35349
  action = null;
35350
+ /** Whether the current action slot represents activity by its holder. */
35351
+ actionCountsAsHumanActivity = false;
35352
+ /**
35353
+ * The human action that has already consumed its one idle-deadline grace.
35354
+ * A single wedged CDP request must not look like fresh activity every two
35355
+ * minutes forever. The first deadline may yield to an event that began before
35356
+ * it; a second deadline for that SAME event becomes a pending lapse instead.
35357
+ */
35358
+ idleGraceAction = null;
35359
+ /** Release the human hold as soon as this overlong action finally unwinds. */
35360
+ idleLapseAfterAction = null;
35361
+ /**
35362
+ * Everyone waiting for that slot, IN THE ORDER THEY ASKED.
35363
+ *
35364
+ * A queue rather than a poll, and the ordering is the whole reason for it.
35365
+ * `acquireActionSlot` used to spin on a 25ms timer, which decides nothing
35366
+ * about who goes first: each waiter checks on its own phase, so the one that
35367
+ * asked SECOND can find the slot free a full poll ahead of the one that asked
35368
+ * first. The person types one event per keypress — `{ kind: 'text', text }`
35369
+ * carries a single character — so that reordering is not a theoretical
35370
+ * unfairness, it is "passowrd" appearing in the box.
35371
+ *
35372
+ * The slot is handed straight from `endAction` to the head of this line while
35373
+ * still synchronous, so nothing can slip in between the two, and a waiter
35374
+ * that reaches the front never has to be told the slot is already taken.
35375
+ */
35376
+ actionQueue = [];
35377
+ /** Lapses a human hold nobody is using. See `HUMAN_IDLE_MS`. */
35378
+ humanTimer = null;
35379
+ /**
35380
+ * The person ASKED for the hold to end and it could not end yet.
35381
+ *
35382
+ * Kept as a request rather than only a retry timer, because the retry shares
35383
+ * `humanTimer` with the idle deadline: an authorised event settling after the
35384
+ * request would otherwise call `touch()`, re-arm that timer for a full idle
35385
+ * interval, and quietly cancel a handback the person had already asked for.
35386
+ * "Give it back" is not undone by activity that was already under way when
35387
+ * they said it.
35388
+ *
35389
+ * Explicit requests ONLY. An idle expiry must never set this: a deadline that
35390
+ * lands on the person's own in-flight event is evidence they were THERE, and
35391
+ * recording it here made `touch()` bail afterwards, so the hold dropped the
35392
+ * instant a fresh interaction finished. See `lapse()`.
35393
+ */
35394
+ handBackWanted = false;
35049
35395
  isRevoked;
35050
35396
  onChange;
35051
35397
  constructor(options = {}) {
@@ -35056,6 +35402,20 @@ var BrowserLease = class {
35056
35402
  get holderActor() {
35057
35403
  return this.actor;
35058
35404
  }
35405
+ /** Whether a person, rather than the agent, is driving the page right now. */
35406
+ get heldByHuman() {
35407
+ return this.actor === HUMAN_ACTOR;
35408
+ }
35409
+ /**
35410
+ * Whether THIS actor is the one holding the browser right now.
35411
+ *
35412
+ * For asking again after a wait. `acquire` is re-entrant for the holder, so
35413
+ * "I was granted the lease" is a fact about the moment it was granted and
35414
+ * says nothing about the moment the caller finally gets to act.
35415
+ */
35416
+ heldBy(actorId) {
35417
+ return this.actor === actorId;
35418
+ }
35059
35419
  get holderSession() {
35060
35420
  return this.session;
35061
35421
  }
@@ -35082,6 +35442,7 @@ var BrowserLease = class {
35082
35442
  * the same worker coming back for its next step must not queue behind itself.
35083
35443
  */
35084
35444
  async acquire(actorId, sessionId, signal) {
35445
+ if (this.actor === HUMAN_ACTOR && actorId !== HUMAN_ACTOR) return "human";
35085
35446
  if (this.actor === null || this.actor === actorId) {
35086
35447
  this.actor = actorId;
35087
35448
  this.session = sessionId;
@@ -35122,6 +35483,105 @@ var BrowserLease = class {
35122
35483
  if (this.action) return;
35123
35484
  if (this.actor === actorId) this.release();
35124
35485
  }
35486
+ /**
35487
+ * Give the browser to the person watching it.
35488
+ *
35489
+ * Callers must have waited the in-flight action out first — see
35490
+ * `acquireActionSlot`. This does not interrupt one, because it cannot: a
35491
+ * Playwright call already inside the page finishes whatever it started, and
35492
+ * taking the lease off it would only mean the person and a still-running
35493
+ * `fill` were both touching the same form. Waiting is what makes "you have
35494
+ * control" true when the UI says it.
35495
+ *
35496
+ * Every queued worker is turned away rather than left in line, for the same
35497
+ * reason `acquire` refuses: they were planned against a page the person is
35498
+ * about to change.
35499
+ */
35500
+ takeOver(sessionId) {
35501
+ for (let i = this.waiting.length - 1; i >= 0; i--) this.waiting[i].settle("human");
35502
+ this.clearCeiling();
35503
+ this.actor = HUMAN_ACTOR;
35504
+ this.session = sessionId;
35505
+ this.touch();
35506
+ this.onChange();
35507
+ }
35508
+ /**
35509
+ * The person is still there — anything they did counts.
35510
+ *
35511
+ * Called per authorised input rather than on a heartbeat, so the hold is
35512
+ * extended by USE and not merely by a tab being open. A left-open panel on a
35513
+ * forgotten laptop stops holding a billed session two minutes later.
35514
+ */
35515
+ touch() {
35516
+ if (this.actor !== HUMAN_ACTOR) return;
35517
+ if (this.handBackWanted) return;
35518
+ this.armIdle();
35519
+ }
35520
+ /** (Re)start the idle interval. The only place the deadline is set. */
35521
+ armIdle() {
35522
+ this.clearHumanIdle();
35523
+ this.humanTimer = setTimeout(() => {
35524
+ this.lapse();
35525
+ }, HUMAN_IDLE_MS);
35526
+ this.humanTimer.unref?.();
35527
+ }
35528
+ /**
35529
+ * The idle deadline came round.
35530
+ *
35531
+ * Both ways a hold ends obey the same rule — never while one of the person's
35532
+ * own events is still landing — but they resolve that clash in OPPOSITE
35533
+ * directions, and routing the lapse through `handBack()` collapsed them into
35534
+ * one. An event in flight BEGAN before this deadline, which makes it evidence
35535
+ * the person was there; deferring the lapse and then releasing the moment
35536
+ * that event lands ends the hold immediately after a fresh interaction, which
35537
+ * is the precise opposite of what an idle deadline measures. So an
35538
+ * interrupted lapse yields: the interval starts again, and the next silence
35539
+ * decides.
35540
+ *
35541
+ * `handBack()` keeps the deferral instead, because "Give it back" is a
35542
+ * decision and activity already under way when it was made does not withdraw
35543
+ * it. Same rule, different answer, which is why they are different methods.
35544
+ */
35545
+ lapse() {
35546
+ if (this.actor !== HUMAN_ACTOR) return;
35547
+ if (this.action && this.actionCountsAsHumanActivity) {
35548
+ if (this.idleGraceAction !== this.action) {
35549
+ this.idleGraceAction = this.action;
35550
+ this.armIdle();
35551
+ } else {
35552
+ this.idleLapseAfterAction = this.action;
35553
+ this.clearHumanIdle();
35554
+ }
35555
+ return;
35556
+ }
35557
+ this.release();
35558
+ }
35559
+ /**
35560
+ * The person hands the browser back, or their hold lapses.
35561
+ *
35562
+ * Answers whether there was a human hold to end, so a caller can tell a real
35563
+ * handback from a duplicate click or a lapse that already happened.
35564
+ */
35565
+ handBack() {
35566
+ if (this.actor !== HUMAN_ACTOR) return false;
35567
+ if (this.action) {
35568
+ this.handBackWanted = true;
35569
+ this.clearHumanIdle();
35570
+ this.humanTimer = setTimeout(() => {
35571
+ this.handBack();
35572
+ }, HANDBACK_RETRY_MS);
35573
+ this.humanTimer.unref?.();
35574
+ return false;
35575
+ }
35576
+ this.release();
35577
+ return true;
35578
+ }
35579
+ clearHumanIdle() {
35580
+ if (this.humanTimer) {
35581
+ clearTimeout(this.humanTimer);
35582
+ this.humanTimer = null;
35583
+ }
35584
+ }
35125
35585
  /** Drop every queued waiter belonging to a run, and the lease if it holds it. */
35126
35586
  dropRun(sessionId) {
35127
35587
  for (let i = this.waiting.length - 1; i >= 0; i--) {
@@ -35137,6 +35597,10 @@ var BrowserLease = class {
35137
35597
  /** Hand the browser to the next actor in line, if any. */
35138
35598
  release() {
35139
35599
  this.clearCeiling();
35600
+ this.clearHumanIdle();
35601
+ this.handBackWanted = false;
35602
+ this.idleGraceAction = null;
35603
+ this.idleLapseAfterAction = null;
35140
35604
  this.actor = null;
35141
35605
  this.session = null;
35142
35606
  let next = this.waiting.shift();
@@ -35165,33 +35629,111 @@ var BrowserLease = class {
35165
35629
  this.ceilingTimer = null;
35166
35630
  }
35167
35631
  }
35168
- /** Take the per-action slot, or null when one is already running. */
35169
- beginAction() {
35632
+ /**
35633
+ * Take the per-action slot, or null when one is already running.
35634
+ *
35635
+ * PRIVATE, so the queue is the only way in. A caller that could take the slot
35636
+ * without joining the line would be exactly the jump-the-queue the ordering
35637
+ * is there to prevent.
35638
+ */
35639
+ beginAction(countsAsHumanActivity = true) {
35170
35640
  if (this.action) return null;
35171
35641
  const token = /* @__PURE__ */ Symbol("browser-action");
35172
35642
  this.action = token;
35643
+ this.actionCountsAsHumanActivity = countsAsHumanActivity;
35173
35644
  return token;
35174
35645
  }
35175
- /** Release the slot, but only for the action that took it. */
35646
+ /**
35647
+ * Release the slot, but only for the action that took it.
35648
+ *
35649
+ * Hands it to the head of the line rather than merely clearing it, and does
35650
+ * so SYNCHRONOUSLY: the waiter's `beginAction` runs before this call returns,
35651
+ * so there is no window in which the slot is free and somebody who asked
35652
+ * later can take it.
35653
+ */
35176
35654
  endAction(token) {
35177
35655
  if (this.action !== token) return false;
35656
+ const lapseNow = this.actor === HUMAN_ACTOR && this.idleLapseAfterAction === token;
35178
35657
  this.action = null;
35658
+ this.actionCountsAsHumanActivity = false;
35659
+ if (this.idleGraceAction === token) this.idleGraceAction = null;
35660
+ if (this.idleLapseAfterAction === token) this.idleLapseAfterAction = null;
35661
+ if (lapseNow) this.release();
35662
+ this.actionQueue.shift()?.();
35179
35663
  return true;
35180
35664
  }
35181
35665
  /**
35182
- * Wait, briefly, for an outgoing action to unwind.
35666
+ * Take the slot only if it is free RIGHT NOW, never joining the line.
35667
+ *
35668
+ * For input that is a SAMPLE rather than a decision — pointer movement. A
35669
+ * click or a keystroke is a thing the person meant to do and has to happen,
35670
+ * so it waits its turn; a pointer position is one of many on the way
35671
+ * somewhere, and one that cannot be applied now is worth less than the one
35672
+ * behind it. Queueing them is actively harmful: on an endpoint slower than
35673
+ * the client's sampling interval the backlog grows, and the click behind it
35674
+ * can age out against `acquireActionSlot`'s bound and be refused — movement
35675
+ * costing the person the action they actually intended.
35676
+ *
35677
+ * The queue length is checked as well as the slot, so a sample cannot step
35678
+ * over a click that is already waiting.
35679
+ */
35680
+ tryActionSlot() {
35681
+ if (this.action || this.actionQueue.length > 0) return null;
35682
+ return this.beginAction();
35683
+ }
35684
+ /**
35685
+ * Take the per-action slot, waiting in line for it if somebody has it.
35183
35686
  *
35184
35687
  * A release can hand the browser on while the previous holder's abort is
35185
35688
  * still propagating through an awaited navigation. That is a handoff, not a
35186
- * conflict, so it is waited out rather than refused — but bounded, because a
35187
- * wedged action must not pin the queue.
35188
- */
35189
- async awaitActionSlot() {
35190
- for (let waited = 0; waited < ACTION_HANDOFF_MS; waited += ACTION_HANDOFF_POLL_MS) {
35191
- if (!this.action) return true;
35192
- await new Promise((r) => setTimeout(r, ACTION_HANDOFF_POLL_MS));
35193
- }
35194
- return this.action === null;
35689
+ * conflict, so it is normally bounded: a wedged action must not pin an agent
35690
+ * or a takeover forever. Human discrete input is the exception and passes
35691
+ * `null`: once a click, key or committed text has been accepted into this
35692
+ * ordered queue, expiring it merely because earlier accepted input took more
35693
+ * than two seconds silently truncates what the person typed.
35694
+ *
35695
+ * Waiting and TAKING are one step, which they deliberately were not before:
35696
+ * the wait used to answer "the slot looks free" and leave the caller to call
35697
+ * `beginAction` afterwards. Two callers woken by the same release both saw it
35698
+ * free, and the loser was told "another action is already running" — a
35699
+ * refusal for having asked first. Granting it here means whoever reaches the
35700
+ * front of the line gets it, and everybody else is still waiting rather than
35701
+ * refused.
35702
+ *
35703
+ * `null` as a RESULT means a bounded wait ran out OR a caller-supplied queue
35704
+ * cap refused a new waiter. `null` as `withinMs` means an ACCEPTED operation
35705
+ * is intentionally unbounded by time and will leave the queue only when its
35706
+ * turn arrives; it does not require accepting infinitely many operations.
35707
+ * `countsAsHumanActivity=false` is for
35708
+ * lifecycle barriers: they still serialize, but an idle deadline must not
35709
+ * mistake framework cleanup for evidence that the person is present.
35710
+ */
35711
+ async acquireActionSlot(withinMs = ACTION_HANDOFF_MS, countsAsHumanActivity = true, maxQueued = null) {
35712
+ if (!this.action && this.actionQueue.length === 0) {
35713
+ return this.beginAction(countsAsHumanActivity);
35714
+ }
35715
+ if (maxQueued !== null && this.actionQueue.length >= maxQueued) return null;
35716
+ return await new Promise((resolve2) => {
35717
+ let done = false;
35718
+ let timer;
35719
+ const wake = () => {
35720
+ if (done) return;
35721
+ done = true;
35722
+ if (timer) clearTimeout(timer);
35723
+ resolve2(this.beginAction(countsAsHumanActivity));
35724
+ };
35725
+ if (withinMs !== null) {
35726
+ timer = setTimeout(() => {
35727
+ if (done) return;
35728
+ done = true;
35729
+ const i = this.actionQueue.indexOf(wake);
35730
+ if (i >= 0) this.actionQueue.splice(i, 1);
35731
+ resolve2(null);
35732
+ }, withinMs);
35733
+ timer.unref?.();
35734
+ }
35735
+ this.actionQueue.push(wake);
35736
+ });
35195
35737
  }
35196
35738
  /**
35197
35739
  * Queue one actor, resolving when it gets the browser or is turned away.
@@ -35226,7 +35768,35 @@ var BrowserLease = class {
35226
35768
  };
35227
35769
 
35228
35770
  // src/browser/remote/controller.ts
35771
+ var KEYS = {
35772
+ Enter: { code: 13, text: "\r" },
35773
+ Tab: { code: 9 },
35774
+ Backspace: { code: 8 },
35775
+ Delete: { code: 46 },
35776
+ Escape: { code: 27 },
35777
+ ArrowUp: { code: 38 },
35778
+ ArrowDown: { code: 40 },
35779
+ ArrowLeft: { code: 37 },
35780
+ ArrowRight: { code: 39 },
35781
+ Home: { code: 36 },
35782
+ End: { code: 35 },
35783
+ PageUp: { code: 33 },
35784
+ PageDown: { code: 34 }
35785
+ };
35786
+ var BUTTONS = { left: 1, middle: 4, right: 2 };
35787
+ var SCREENCAST = { format: "jpeg", quality: 60, maxWidth: 1280, maxHeight: 800 };
35229
35788
  var ACTION_TIMEOUT_MS = 3e4;
35789
+ var RESTORE_FRAME_MS = 2e3;
35790
+ var MAX_HUMAN_INPUT_QUEUE = 64;
35791
+ var MAX_CAPTURE_TRANSITION_QUEUE = 16;
35792
+ var MAX_WATCH_LIFECYCLE_QUEUE = 16;
35793
+ function notify(deliver) {
35794
+ try {
35795
+ void Promise.resolve(deliver()).catch(() => {
35796
+ });
35797
+ } catch {
35798
+ }
35799
+ }
35230
35800
  var RemoteBrowserController = class {
35231
35801
  provider;
35232
35802
  maxSessions;
@@ -35239,8 +35809,49 @@ var RemoteBrowserController = class {
35239
35809
  * bearer capability for a browser somebody else is driving.
35240
35810
  */
35241
35811
  liveViewListeners = /* @__PURE__ */ new Map();
35812
+ /**
35813
+ * Told when a run's browser changes hands, or stops being pictured.
35814
+ *
35815
+ * Needed because a takeover can end without anybody asking. The hold lapses
35816
+ * on its own after `HUMAN_IDLE_MS`, and a client left believing it still has
35817
+ * control would keep sending input into a lease it no longer holds — every
35818
+ * event refused, with nothing on screen saying why.
35819
+ */
35820
+ controlListeners = /* @__PURE__ */ new Map();
35821
+ /** What each run's listener was last told, so unchanged states stay quiet. */
35822
+ controlAnnounced = /* @__PURE__ */ new Map();
35242
35823
  /** An embedder that wants every run's live view, told which run each is. */
35243
35824
  onLiveViewAll;
35825
+ /** Told when a provider session could not be handed back. See `disposeRun`. */
35826
+ onReleaseFailed;
35827
+ /**
35828
+ * Tell the observer a session leaked, without letting it become the leak.
35829
+ *
35830
+ * Guarded, because this runs inside a `.catch` on the release: an observer
35831
+ * that throws — a logging or telemetry sink that is momentarily unavailable
35832
+ * is the ordinary case — would turn its own failure into a fresh rejection
35833
+ * from `disposeRun`, so `endRun` would reject before `forgetRun` and the
35834
+ * fire-and-forget `stopRun` path would raise an unhandled rejection.
35835
+ *
35836
+ * That is precisely the guarantee this whole change claims to preserve: a
35837
+ * release that fails must never fail the run. Reporting it cannot be the
35838
+ * thing that breaks it.
35839
+ *
35840
+ * TWO WAYS TO FAIL, not one. The `try` catches a synchronous throw; an
35841
+ * `async` sink — which is what a telemetry or logging callback usually is —
35842
+ * fails by returning a REJECTED PROMISE, which no `try` around a call that is
35843
+ * never awaited can see. Node then raises an unhandled rejection and can take
35844
+ * the process down: the observer would not merely have failed to report the
35845
+ * leak, it would have killed the run, which is the one outcome this function
35846
+ * exists to prevent, reached through the door the guard did not cover.
35847
+ *
35848
+ * Not awaited, though. The caller is a `.catch` on the release and is not
35849
+ * going to wait for a telemetry round trip; attaching a handler is what makes
35850
+ * the rejection observed, which is the whole requirement.
35851
+ */
35852
+ reportReleaseFailure(runId, sessionId, err, expiresInMs) {
35853
+ notify(() => this.onReleaseFailed?.(runId, sessionId, err, expiresInMs));
35854
+ }
35244
35855
  runs = /* @__PURE__ */ new Map();
35245
35856
  /**
35246
35857
  * Runs that have a pool slot but no browser yet.
@@ -35248,7 +35859,7 @@ var RemoteBrowserController = class {
35248
35859
  * Counted alongside `runs` against the cap. Without it the limit was a
35249
35860
  * check-then-act across four awaits, and simultaneous first uses both won.
35250
35861
  */
35251
- opening = /* @__PURE__ */ new Set();
35862
+ opening = /* @__PURE__ */ new Map();
35252
35863
  /**
35253
35864
  * A run's stop signal, created when its slot is reserved.
35254
35865
  *
@@ -35281,7 +35892,7 @@ var RemoteBrowserController = class {
35281
35892
  * own teardown settles, so a later, genuine re-open of the same run id
35282
35893
  * never collides with one still in flight.
35283
35894
  */
35284
- closing = /* @__PURE__ */ new Set();
35895
+ closing = /* @__PURE__ */ new Map();
35285
35896
  /** Runs the user has stopped, by run id — same meaning as on the desktop. */
35286
35897
  revoked = /* @__PURE__ */ new Set();
35287
35898
  /**
@@ -35299,11 +35910,39 @@ var RemoteBrowserController = class {
35299
35910
  const asked = Math.max(1, options.maxSessions ?? 1);
35300
35911
  this.maxSessions = options.provider.isolatesSessions ? asked : 1;
35301
35912
  this.onLiveViewAll = options.onLiveView;
35913
+ this.onReleaseFailed = options.onReleaseFailed;
35914
+ this.ready = options.ready;
35302
35915
  }
35916
+ /**
35917
+ * The predecessor's teardown, until it has been waited for once.
35918
+ *
35919
+ * Cleared as soon as it settles so the wait is not re-entered on every open
35920
+ * — and so the promise itself is not retained for the life of the process.
35921
+ */
35922
+ ready;
35923
+ /**
35924
+ * This controller has been retired and will never open anything again.
35925
+ *
35926
+ * Stronger than "its runs were ended", and it has to be. A caller parked on
35927
+ * the `ready` gate above is invisible to `dispose()` — it holds no run and
35928
+ * has reserved no slot, because it has not got that far — so a SECOND
35929
+ * settings change, arriving while the first retirement is still settling,
35930
+ * found nothing to wait for and let the newest controller start immediately.
35931
+ * The parked caller then woke up on a controller nobody holds a reference to
35932
+ * and allocated a session against the superseded configuration, with the
35933
+ * newest controller free to allocate its own.
35934
+ *
35935
+ * Set synchronously at the top of `dispose`, so anything already waiting is
35936
+ * excluded the moment retirement begins rather than when it finishes.
35937
+ */
35938
+ retired = false;
35303
35939
  leaseFor(runId) {
35304
35940
  let lease = this.leases.get(runId);
35305
35941
  if (!lease) {
35306
- lease = new BrowserLease({ isRevoked: (id) => this.revoked.has(id) });
35942
+ lease = new BrowserLease({
35943
+ isRevoked: (id) => this.revoked.has(id),
35944
+ onChange: () => this.announceControl(runId)
35945
+ });
35307
35946
  lease.setLifecycleReleaseWired(true);
35308
35947
  this.leases.set(runId, lease);
35309
35948
  }
@@ -35320,6 +35959,44 @@ var RemoteBrowserController = class {
35320
35959
  offLiveViewFor(runKey) {
35321
35960
  this.liveViewListeners.delete(runKey);
35322
35961
  }
35962
+ onControlFor(runKey, listener) {
35963
+ this.controlListeners.set(runKey, listener);
35964
+ }
35965
+ offControlFor(runKey) {
35966
+ this.controlListeners.delete(runKey);
35967
+ this.controlAnnounced.delete(runKey);
35968
+ }
35969
+ /**
35970
+ * Say who has the browser, but only when the answer has changed.
35971
+ *
35972
+ * The lease reports every ownership change, and during ordinary work that is
35973
+ * one per action as workers take it and give it back. None of those are
35974
+ * interesting to a viewer — the agent had it before and has it now — and
35975
+ * emitting them would put a socket message on every click the agent makes.
35976
+ */
35977
+ announceControl(runId, force = false) {
35978
+ const listener = this.controlListeners.get(runId);
35979
+ if (!listener) return;
35980
+ const held = this.runs.get(runId);
35981
+ const state = {
35982
+ human: this.leases.get(runId)?.heldByHuman === true,
35983
+ capturing: held?.capturing === true,
35984
+ // A receipt vouches for a VIEW, so it stops meaning anything the moment
35985
+ // there is no view. `stopWatching` clears the session without bumping the
35986
+ // watch — deliberately, since replacing the consumer is what a new watch
35987
+ // is about — so the receipt stayed matched across the whole detach and
35988
+ // re-attach, which `beginWatching` performs on every reconnect and every
35989
+ // return to a conversation. Announced `confirmed` through that window,
35990
+ // the client mounted and FOCUSED the blind keyboard surface over a page
35991
+ // no session was serving: keystrokes meant for the composer went to a
35992
+ // remote page that could only refuse them.
35993
+ confirmed: held?.screencast !== void 0 && held.watchGen !== void 0 && held.seenGen === held.watchGen
35994
+ };
35995
+ const key = `${state.human}:${state.capturing}:${state.confirmed}`;
35996
+ if (!force && this.controlAnnounced.get(runId) === key) return;
35997
+ this.controlAnnounced.set(runId, key);
35998
+ notify(() => listener(state));
35999
+ }
35323
36000
  /**
35324
36001
  * Tell a run's listener what its browser situation is.
35325
36002
  *
@@ -35329,9 +36006,32 @@ var RemoteBrowserController = class {
35329
36006
  * apart — and would drop the Stop control for the first as if it were the
35330
36007
  * second.
35331
36008
  */
36009
+ /*
36010
+ * Guarded HERE rather than at the call sites, which is the point.
36011
+ *
36012
+ * This is the fifth place in this changeset where a notification could take
36013
+ * down the thing it was notifying about — after the release observer,
36014
+ * `clarification:closed`, `clarification:timeout` and `escalation:timeout`.
36015
+ * The first four were fixed one door at a time, which is how a fifth door
36016
+ * came to exist: the announcement added on the reuse path propagated out of
36017
+ * `open()`, `act()` caught it as an action failure, and every later action
36018
+ * against a perfectly healthy browser was skipped.
36019
+ *
36020
+ * Putting the guard at the source closes all four call sites at once and
36021
+ * leaves no door for a sixth. The worst of them is not the reported one:
36022
+ * `disposeRun` announces `active: false` as its FIRST statement, so a
36023
+ * throwing listener aborted teardown before the screencast was stopped, the
36024
+ * context closed, or the session handed back — leaking the provider session
36025
+ * and never reaching `reportReleaseFailure`. The leak report would have been
36026
+ * skipped by exactly the failure it exists to report.
36027
+ *
36028
+ * Two separate guards, not one around both: these are different listeners —
36029
+ * the per-run panel and the global observer — and one failing must not cost
36030
+ * the other its notification.
36031
+ */
35332
36032
  announceLiveView(runId, liveViewUrl, active) {
35333
- this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
35334
- this.onLiveViewAll?.(runId, liveViewUrl);
36033
+ notify(() => this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} }));
36034
+ notify(() => this.onLiveViewAll?.(runId, liveViewUrl));
35335
36035
  }
35336
36036
  /**
35337
36037
  * Hand back a session the run turned out not to want.
@@ -35340,22 +36040,682 @@ var RemoteBrowserController = class {
35340
36040
  * allocated and billed until the whole run ended, for a run that has just
35341
36041
  * been stopped.
35342
36042
  */
35343
- async releaseIfIdle(runId) {
36043
+ async releaseIfIdle(runId, releaseSlot) {
35344
36044
  const held = this.runs.get(runId);
35345
- if (!held) return;
36045
+ if (!held) {
36046
+ releaseSlot?.();
36047
+ return;
36048
+ }
35346
36049
  this.runs.delete(runId);
35347
- this.closing.add(runId);
35348
- await this.teardown(runId, held);
36050
+ const teardown = this.teardown(runId, held);
36051
+ releaseSlot?.();
36052
+ await teardown;
35349
36053
  }
35350
36054
  /** Release every run's session. For when the deployment's config changes. */
35351
36055
  async dispose() {
35352
- const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening]);
36056
+ this.retired = true;
36057
+ const opening = [...this.opening.values()];
36058
+ const ids = /* @__PURE__ */ new Set([...this.runs.keys(), ...this.opening.keys()]);
35353
36059
  await Promise.all([...ids].map((runId) => this.endRun(runId)));
36060
+ await Promise.all(opening.map((open) => open.catch(() => {
36061
+ })));
36062
+ await Promise.all([...this.closing.values()].map((done) => done.catch(() => {
36063
+ })));
35354
36064
  }
35355
36065
  /** A worker finished; it will never ask for the browser again. */
35356
36066
  actorEnded(actorId) {
35357
36067
  for (const lease of this.leases.values()) lease.actorEnded(actorId);
35358
36068
  }
36069
+ /**
36070
+ * Start streaming this run's page to whoever is watching it.
36071
+ *
36072
+ * Nothing streams until someone asks. That is the whole reason this is a
36073
+ * method rather than something `open()` does: an unwatched run would
36074
+ * otherwise pay to encode frames nobody sees, on every run, forever — and on
36075
+ * a metered provider it would pay for the bandwidth too.
36076
+ *
36077
+ * Frames are ACKED after they are handed on, never before. Chrome sends the
36078
+ * next frame only once the previous is acknowledged, so this bounds it to one
36079
+ * outstanding frame against OUR OWN handling. It does not reach further than
36080
+ * that, and an earlier version of this comment claimed it did: `onFrame` ends
36081
+ * at a socket emit, which hands the JPEG to a transport rather than to a
36082
+ * viewer, so the ack says nothing about what the person actually received.
36083
+ * Bounding the rest of the path is the transport's job — see `emitFrame`,
36084
+ * where frames go out lossy so a slow connection drops pictures instead of
36085
+ * banking stale ones. Dropping the ack stops the stream dead, which is why it
36086
+ * is best-effort but unconditional.
36087
+ *
36088
+ * Returns false when there is nothing to watch — the run has no browser, or
36089
+ * it is already being watched. Callers use that to avoid promising a viewer
36090
+ * a stream that will never arrive.
36091
+ */
36092
+ async startWatching(runId, onFrame) {
36093
+ const held = this.runs.get(runId);
36094
+ if (!held) return false;
36095
+ return await this.queueWatch(held, async () => {
36096
+ held.onFrame = onFrame;
36097
+ held.watchGen = (held.watchGen ?? 0) + 1;
36098
+ if (held.screencast) {
36099
+ this.announceControl(runId);
36100
+ return true;
36101
+ }
36102
+ const attached = await this.attachScreencast(runId, held, onFrame);
36103
+ if (!attached && held.onFrame === onFrame) held.onFrame = void 0;
36104
+ return attached !== null;
36105
+ });
36106
+ }
36107
+ /**
36108
+ * Run one watch or unwatch after every one already asked for.
36109
+ *
36110
+ * The chain is kept non-rejecting on purpose: a failed attach must not poison
36111
+ * every later request for this run, which is what a bare `.then` chain would
36112
+ * do. The caller still sees its own failure, because that is the promise
36113
+ * returned rather than the one stored.
36114
+ */
36115
+ queueWatch(held, op) {
36116
+ const depth = held.watchQueueDepth ?? 0;
36117
+ if (held.watchCollapsedPromise || depth >= MAX_WATCH_LIFECYCLE_QUEUE) {
36118
+ held.watchCollapsed = op;
36119
+ if (!held.watchCollapsedPromise) {
36120
+ const drain = async () => {
36121
+ let result;
36122
+ while (held.watchCollapsed) {
36123
+ const latest = held.watchCollapsed;
36124
+ held.watchCollapsed = void 0;
36125
+ try {
36126
+ result = await latest();
36127
+ } catch {
36128
+ }
36129
+ }
36130
+ return result;
36131
+ };
36132
+ const tail = (held.watchQueue ?? Promise.resolve()).then(drain, drain);
36133
+ let tracked;
36134
+ tracked = tail.finally(() => {
36135
+ if (held.watchCollapsedPromise === tracked) {
36136
+ held.watchCollapsedPromise = void 0;
36137
+ held.watchCollapsed = void 0;
36138
+ }
36139
+ });
36140
+ held.watchCollapsedPromise = tracked;
36141
+ held.watchQueue = tracked.catch(() => {
36142
+ });
36143
+ }
36144
+ return held.watchCollapsedPromise;
36145
+ }
36146
+ held.watchQueueDepth = depth + 1;
36147
+ const mine = (held.watchQueue ?? Promise.resolve()).then(op, op);
36148
+ const counted = mine.finally(() => {
36149
+ held.watchQueueDepth = Math.max(0, (held.watchQueueDepth ?? 1) - 1);
36150
+ });
36151
+ held.watchQueue = counted.catch(() => {
36152
+ });
36153
+ return counted;
36154
+ }
36155
+ /**
36156
+ * Attach at most one physical CDP screencast session at a time.
36157
+ *
36158
+ * `startWatching()` already serializes watcher lifecycle through `watchQueue`,
36159
+ * but the agent's detached-hidden restore cannot join that queue: it holds the
36160
+ * action slot, while an earlier unwatch may be waiting for exactly that slot.
36161
+ * Sharing only the physical attach closes the two-session race without making
36162
+ * watcher teardown and agent mutation wait on each other in a cycle.
36163
+ *
36164
+ * A real watcher claims `onFrame` immediately, before it waits for an attach
36165
+ * already started by the agent. The lower-level attach never writes the sink,
36166
+ * so finishing an older temporary attempt cannot overwrite a newer viewer.
36167
+ */
36168
+ async attachScreencast(runId, held, onFrame, popupDetachGen) {
36169
+ const consumerIntent = onFrame && popupDetachGen !== void 0 ? popupDetachGen : void 0;
36170
+ if (onFrame) held.onFrame = onFrame;
36171
+ if (held.screencast && held.screencastPage === held.page) return held.screencast;
36172
+ const targetPage = held.page;
36173
+ const existing = held.attaching;
36174
+ if (existing) {
36175
+ const existingPage = held.attachingPage;
36176
+ await existing.catch(() => null);
36177
+ if (held.page !== targetPage || existingPage !== targetPage) {
36178
+ return this.attachScreencast(runId, held, onFrame, popupDetachGen);
36179
+ }
36180
+ const cdp = held.screencastPage === targetPage ? held.screencast ?? null : null;
36181
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
36182
+ if (cdp && onFrame) held.onFrame = onFrame;
36183
+ return cdp;
36184
+ }
36185
+ if (held.screencast && held.screencastPage !== targetPage) {
36186
+ const stale = held.screencast;
36187
+ held.screencast = void 0;
36188
+ held.screencastPage = void 0;
36189
+ await stale.send("Page.stopScreencast").catch(() => {
36190
+ });
36191
+ await stale.detach().catch(() => {
36192
+ });
36193
+ }
36194
+ const attempt = this.attachScreencastFresh(runId, held, targetPage, consumerIntent);
36195
+ held.attaching = attempt;
36196
+ held.attachingPage = targetPage;
36197
+ try {
36198
+ const cdp = await attempt;
36199
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) return null;
36200
+ if (cdp && onFrame) held.onFrame = onFrame;
36201
+ return cdp;
36202
+ } finally {
36203
+ if (held.attaching === attempt) {
36204
+ held.attaching = void 0;
36205
+ held.attachingPage = void 0;
36206
+ }
36207
+ }
36208
+ }
36209
+ /** Open one new CDP session and start the stream when it is safe to do so. */
36210
+ async attachScreencastFresh(runId, held, targetPage, consumerIntent) {
36211
+ const safetyGate = held.hiddenByHolder === true || held.visibilityUnconfirmed === true;
36212
+ let cdp;
36213
+ try {
36214
+ cdp = await targetPage.context().newCDPSession(targetPage);
36215
+ } catch {
36216
+ if (!safetyGate) {
36217
+ held.visibilityUnconfirmed = false;
36218
+ held.capturing = true;
36219
+ this.announceControl(runId);
36220
+ }
36221
+ return null;
36222
+ }
36223
+ if (held.page !== targetPage) {
36224
+ await cdp.detach().catch(() => {
36225
+ });
36226
+ return null;
36227
+ }
36228
+ cdp.on("Page.screencastFrame", ((e) => {
36229
+ if (held.page === targetPage && typeof e?.data === "string") {
36230
+ const width = e.metadata?.deviceWidth ?? 0;
36231
+ const height = e.metadata?.deviceHeight ?? 0;
36232
+ held.metricsStale = true;
36233
+ if (held.visibilityUnconfirmed) {
36234
+ held.visibilityUnconfirmed = false;
36235
+ if (held.capturing !== true) {
36236
+ held.capturing = true;
36237
+ this.announceControl(runId);
36238
+ }
36239
+ }
36240
+ const ping = held.framePing;
36241
+ held.framePing = void 0;
36242
+ ping?.();
36243
+ const deliver = held.onFrame;
36244
+ if (deliver) {
36245
+ const frame = { data: e.data, width, height, generation: held.watchGen ?? 0 };
36246
+ notify(() => deliver(frame));
36247
+ }
36248
+ }
36249
+ void cdp.send("Page.screencastFrameAck", { sessionId: e?.sessionId }).catch(() => {
36250
+ });
36251
+ }));
36252
+ const paused = held.hiddenByHolder === true;
36253
+ if (!paused) {
36254
+ try {
36255
+ await cdp.send("Page.startScreencast", { ...SCREENCAST });
36256
+ } catch {
36257
+ if (!safetyGate) {
36258
+ held.visibilityUnconfirmed = false;
36259
+ held.capturing = true;
36260
+ this.announceControl(runId);
36261
+ } else {
36262
+ held.capturing = false;
36263
+ this.announceControl(runId);
36264
+ }
36265
+ await cdp.detach().catch(() => {
36266
+ });
36267
+ return null;
36268
+ }
36269
+ }
36270
+ if (held.page !== targetPage) {
36271
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
36272
+ });
36273
+ await cdp.detach().catch(() => {
36274
+ });
36275
+ return null;
36276
+ }
36277
+ if (consumerIntent !== void 0 && (held.watchDetachGen ?? 0) !== consumerIntent) {
36278
+ if (!paused) await cdp.send("Page.stopScreencast").catch(() => {
36279
+ });
36280
+ await cdp.detach().catch(() => {
36281
+ });
36282
+ return null;
36283
+ }
36284
+ held.screencast = cdp;
36285
+ held.screencastPage = targetPage;
36286
+ held.capturing = !paused;
36287
+ held.hiddenByHolder = paused;
36288
+ this.announceControl(runId);
36289
+ return cdp;
36290
+ }
36291
+ /** Nobody is watching any more: stop paying to render frames. */
36292
+ async stopWatching(runId) {
36293
+ const held = this.runs.get(runId);
36294
+ if (!held) return;
36295
+ held.watchDetachGen = (held.watchDetachGen ?? 0) + 1;
36296
+ held.onFrame = void 0;
36297
+ await this.queueWatch(held, async () => {
36298
+ const cdp = held.screencast;
36299
+ held.onFrame = void 0;
36300
+ if (!cdp) return;
36301
+ held.screencast = void 0;
36302
+ held.screencastPage = void 0;
36303
+ held.capturing = false;
36304
+ this.announceControl(runId);
36305
+ const lease = this.leases.get(runId);
36306
+ const token = lease ? await lease.acquireActionSlot(null, false) : null;
36307
+ if (lease && !token) return;
36308
+ try {
36309
+ await cdp.send("Page.stopScreencast").catch(() => {
36310
+ });
36311
+ await cdp.detach().catch(() => {
36312
+ });
36313
+ } finally {
36314
+ if (token && lease) lease.endAction(token);
36315
+ }
36316
+ });
36317
+ }
36318
+ /**
36319
+ * Suspend or resume the picture without giving up control.
36320
+ *
36321
+ * The credential case, and the only reason this is separate from watching. A
36322
+ * person who has taken the page over to sign in is about to type a password
36323
+ * into a field whose contents are echoed as dots — but a password manager's
36324
+ * dropdown, a "show password" toggle, a one-time code in plain text and the
36325
+ * confirmation page afterwards are not, and all of them would otherwise be
36326
+ * encoded into frames and sent across the network.
36327
+ *
36328
+ * What this does NOT do is hide anything from the agent: it stops the
36329
+ * picture, not the page. Nothing about a suspended capture would stop a
36330
+ * `extract_text` from reading the same screen — it is the lease that stops
36331
+ * that, by refusing the agent for as long as the person holds it.
36332
+ *
36333
+ * Answers whether frames are flowing afterwards, which is what a UI needs to
36334
+ * show and is not always what was asked for: a run with nobody watching has
36335
+ * no session to suspend.
36336
+ */
36337
+ async setCapture(runId, on) {
36338
+ const held = this.runs.get(runId);
36339
+ const cdp = held?.screencast;
36340
+ const watch = held?.watchGen;
36341
+ if (!held || !cdp) return false;
36342
+ const lease = this.leaseFor(runId);
36343
+ if (!on && !lease.heldByHuman) return held.capturing === true;
36344
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
36345
+ return held.capturing === true;
36346
+ }
36347
+ if (held.capturing === on) {
36348
+ if (lease.heldByHuman) lease.touch();
36349
+ return on;
36350
+ }
36351
+ const token = await lease.acquireActionSlot(null, true, MAX_CAPTURE_TRANSITION_QUEUE);
36352
+ if (!token) return held.capturing === true;
36353
+ try {
36354
+ if (!on && !lease.heldByHuman) return held.capturing === true;
36355
+ if (!on && (held.screencast !== cdp || held.watchGen !== watch)) {
36356
+ return held.capturing === true;
36357
+ }
36358
+ if (!on && (watch === void 0 || held.seenGen !== watch)) {
36359
+ return held.capturing === true;
36360
+ }
36361
+ const target = on ? held.screencast : cdp;
36362
+ if (!target) {
36363
+ if (on) {
36364
+ held.hiddenByHolder = false;
36365
+ if (lease.heldByHuman) lease.touch();
36366
+ }
36367
+ return held.capturing === true;
36368
+ }
36369
+ const now = await this.applyCapture(runId, held, target, on);
36370
+ if (lease.heldByHuman) lease.touch();
36371
+ return now;
36372
+ } finally {
36373
+ lease.endAction(token);
36374
+ }
36375
+ }
36376
+ /**
36377
+ * The capture transition itself, with no lease bookkeeping.
36378
+ *
36379
+ * Separate because the agent's restore in `act()` already holds the action
36380
+ * slot — taking it again there would deadlock against itself, and the whole
36381
+ * point of that call site is that it happens INSIDE the agent's turn.
36382
+ *
36383
+ * `confirmWithFrame` is used only by the agent's visibility gate. There, a
36384
+ * successful CDP command is not enough: the externally visible state remains
36385
+ * paused until a frame arrives, and the frame handler is the only code that
36386
+ * promotes it to capturing. This makes a restore timeout sticky rather than
36387
+ * a one-action refusal.
36388
+ */
36389
+ async applyCapture(runId, held, cdp, on, confirmWithFrame = false) {
36390
+ if (held.capturing === on && !(on && confirmWithFrame && held.visibilityUnconfirmed)) return on;
36391
+ const wasUnconfirmed = held.visibilityUnconfirmed === true;
36392
+ const wasCapturing = held.capturing === true;
36393
+ if (on) held.visibilityUnconfirmed = true;
36394
+ try {
36395
+ if (on) await cdp.send("Page.startScreencast", { ...SCREENCAST });
36396
+ else await cdp.send("Page.stopScreencast");
36397
+ if (on) {
36398
+ if (!confirmWithFrame) held.capturing = true;
36399
+ } else {
36400
+ held.visibilityUnconfirmed = false;
36401
+ held.capturing = false;
36402
+ }
36403
+ held.hiddenByHolder = !on;
36404
+ this.announceControl(runId);
36405
+ } catch {
36406
+ if (on) {
36407
+ held.visibilityUnconfirmed = wasUnconfirmed;
36408
+ held.capturing = wasCapturing;
36409
+ this.announceControl(runId);
36410
+ }
36411
+ return held.capturing === true && !held.visibilityUnconfirmed;
36412
+ }
36413
+ return on;
36414
+ }
36415
+ /**
36416
+ * Wait, briefly, for Chrome to produce a frame.
36417
+ *
36418
+ * `Page.startScreencast` resolves when the command is accepted, which is not
36419
+ * when a picture exists. The agent's visibility gate — "the page must be
36420
+ * VISIBLE before anything changes it" — was therefore satisfied by a request
36421
+ * rather than by a repaint, and the agent could click while the panel was
36422
+ * still showing the page from before it was hidden, or nothing at all.
36423
+ *
36424
+ * A frame rather than a client receipt, deliberately. A receipt would prove
36425
+ * somebody SAW it, which is stronger — and would make the agent's progress
36426
+ * depend on a viewer that may not exist: this path runs after a handback or a
36427
+ * lapse, when there may be nobody watching at all, and an agent that stalls
36428
+ * because a tab was closed is a worse failure than the one being fixed. What
36429
+ * this does guarantee is ours to guarantee: the stream is producing again.
36430
+ *
36431
+ * Bounded, and the bound rests on the property rounds 14 and 15 already rely
36432
+ * on — a screencast that has just been started emits its first frame at once,
36433
+ * whatever the page is doing. If one does not arrive, the picture genuinely
36434
+ * has not come back, and the caller refuses on the same trade it already
36435
+ * makes when the restore itself fails.
36436
+ */
36437
+ nextFrame(held, withinMs = RESTORE_FRAME_MS) {
36438
+ return new Promise((resolve2) => {
36439
+ const timer = setTimeout(() => {
36440
+ if (held.framePing === ping) held.framePing = void 0;
36441
+ resolve2(false);
36442
+ }, withinMs);
36443
+ timer.unref?.();
36444
+ const ping = () => {
36445
+ clearTimeout(timer);
36446
+ resolve2(true);
36447
+ };
36448
+ held.framePing = ping;
36449
+ });
36450
+ }
36451
+ /** Whether a person is driving this run's browser right now. */
36452
+ humanHolds(runId) {
36453
+ return this.leases.get(runId)?.heldByHuman === true;
36454
+ }
36455
+ /**
36456
+ * Hand this run's browser to the person watching it.
36457
+ *
36458
+ * The wait is the substance. An agent action already inside Playwright cannot
36459
+ * be interrupted — `click` waits for its element to become actionable and
36460
+ * then clicks, whatever anyone decided in the meantime — so a takeover that
36461
+ * returned immediately would put the person on a page an action was still
36462
+ * about to change. Bounded by the action ceiling, because that is the longest
36463
+ * an action can legitimately take; past it something is wedged, and saying so
36464
+ * is better than a spinner that never resolves.
36465
+ *
36466
+ * Idempotent for someone who already holds it: a second click of Take control
36467
+ * just says they are still there.
36468
+ */
36469
+ async takeOver(runId) {
36470
+ if (this.revoked.has(runId)) {
36471
+ return { ok: false, detail: "Browser control was stopped for this run." };
36472
+ }
36473
+ const requested = this.runs.get(runId);
36474
+ if (!requested) {
36475
+ return { ok: false, detail: "This run has no browser open." };
36476
+ }
36477
+ const requestedCdp = requested.screencast;
36478
+ const requestedWatch = requested.watchGen;
36479
+ if (!requestedCdp || requestedWatch === void 0 || !requested.onFrame) {
36480
+ return { ok: false, detail: "This browser is not being watched right now." };
36481
+ }
36482
+ const lease = this.leaseFor(runId);
36483
+ if (lease.heldByHuman) {
36484
+ lease.touch();
36485
+ this.announceControl(runId, true);
36486
+ return { ok: true };
36487
+ }
36488
+ const token = await lease.acquireActionSlot(ACTION_TIMEOUT_MS);
36489
+ if (!token) {
36490
+ return {
36491
+ ok: false,
36492
+ detail: "The agent is still finishing an action on this page. Try again in a moment."
36493
+ };
36494
+ }
36495
+ try {
36496
+ const held = this.runs.get(runId);
36497
+ if (this.revoked.has(runId)) {
36498
+ return { ok: false, detail: "Browser control was stopped for this run." };
36499
+ }
36500
+ if (!held || held.page.isClosed()) {
36501
+ return { ok: false, detail: "That browser closed while you were waiting for it." };
36502
+ }
36503
+ if (held.screencast !== requestedCdp || held.watchGen !== requestedWatch || !held.onFrame) {
36504
+ return { ok: false, detail: "The browser view changed while you were waiting. Take control again from the current view." };
36505
+ }
36506
+ lease.takeOver(runId);
36507
+ } finally {
36508
+ lease.endAction(token);
36509
+ }
36510
+ return { ok: true };
36511
+ }
36512
+ /** The person is done. Answers whether they were holding it at all. */
36513
+ /**
36514
+ * The viewer confirms it received a picture of this run's page.
36515
+ *
36516
+ * Reliable, one per watch, and the only thing that opens Hide. Checked
36517
+ * against the CURRENT watch: a receipt that names an older one is from a
36518
+ * viewer that has since been replaced, and vouching for its successor is the
36519
+ * reconnect hole this counter exists to close.
36520
+ */
36521
+ frameSeen(runId, generation) {
36522
+ const held = this.runs.get(runId);
36523
+ if (!held) return;
36524
+ if (typeof generation !== "number" || !Number.isFinite(generation)) return;
36525
+ if (held.watchGen === void 0 || generation !== held.watchGen) return;
36526
+ if (held.seenGen === generation) return;
36527
+ held.seenGen = generation;
36528
+ this.announceControl(runId);
36529
+ }
36530
+ handBack(runId) {
36531
+ return this.leases.get(runId)?.handBack() === true;
36532
+ }
36533
+ /**
36534
+ * One thing the person did to the page.
36535
+ *
36536
+ * Authorised per event, not per session. A takeover is not a channel that
36537
+ * stays open once opened: between two clicks the run can be stopped, the hold
36538
+ * can lapse, the page can close, and each of those has to stop the NEXT
36539
+ * event rather than be noticed at the end. So every event re-checks that this
36540
+ * run is live and that the lease is still the person's, and every accepted
36541
+ * event says so by extending the hold.
36542
+ */
36543
+ async input(runId, event) {
36544
+ const held = this.runs.get(runId);
36545
+ const cdp = held?.screencast;
36546
+ const watch = held?.watchGen;
36547
+ if (!held || held.page.isClosed()) return { ok: false, detail: "This run has no browser open." };
36548
+ const page2 = held.page;
36549
+ const pageGeneration = held.generation;
36550
+ if (!cdp) return { ok: false, detail: "Nothing is streaming this browser, so it cannot be driven." };
36551
+ const lease = this.leaseFor(runId);
36552
+ if (!lease.heldByHuman) {
36553
+ return { ok: false, detail: "You do not have control of this browser. Take control first." };
36554
+ }
36555
+ if (held.capturing !== true && (held.watchGen === void 0 || held.seenGen !== held.watchGen)) {
36556
+ return { ok: false, detail: "Show the page before typing into it \u2014 this view has not seen it yet." };
36557
+ }
36558
+ const sampled = event.kind === "move";
36559
+ const token = sampled ? lease.tryActionSlot() : await lease.acquireActionSlot(null, true, MAX_HUMAN_INPUT_QUEUE);
36560
+ if (!token) {
36561
+ if (sampled) return { ok: true };
36562
+ return { ok: false, detail: "Too many browser inputs are already waiting. Let the page catch up, then try again." };
36563
+ }
36564
+ try {
36565
+ if (!lease.heldByHuman) {
36566
+ return { ok: false, detail: "Your control of this browser ended while that was waiting." };
36567
+ }
36568
+ if (held.page !== page2 || held.generation !== pageGeneration) {
36569
+ return { ok: false, detail: "The page changed while that input was waiting, so it did not land." };
36570
+ }
36571
+ if (held.screencast !== cdp || held.watchGen !== watch) {
36572
+ return { ok: false, detail: "The view you were driving is gone, so that did not land." };
36573
+ }
36574
+ const pagesBefore = new Set(held.page.context().pages());
36575
+ await this.dispatch(cdp, held, event);
36576
+ await this.followOpenedPage(runId, held, pagesBefore);
36577
+ } catch (err) {
36578
+ return { ok: false, detail: err instanceof Error ? err.message : String(err) };
36579
+ } finally {
36580
+ lease.endAction(token);
36581
+ }
36582
+ lease.touch();
36583
+ return { ok: true };
36584
+ }
36585
+ /**
36586
+ * The page's own coordinate space, measured rather than inferred.
36587
+ *
36588
+ * Cached, because a click should not cost a round trip, and invalidated by
36589
+ * the frame metadata changing shape — which arrives free with every frame. A
36590
+ * failed measurement falls back to the last good one rather than to a guess:
36591
+ * a stale viewport is off by a resize, an invented one is off by anything.
36592
+ */
36593
+ async pageViewport(held, cdp) {
36594
+ if (held.viewport && !held.metricsStale) return held.viewport;
36595
+ try {
36596
+ const metrics = await cdp.send("Page.getLayoutMetrics");
36597
+ const width = metrics?.cssVisualViewport?.clientWidth;
36598
+ const height = metrics?.cssVisualViewport?.clientHeight;
36599
+ if (typeof width === "number" && typeof height === "number" && width > 0 && height > 0) {
36600
+ held.viewport = { width, height };
36601
+ held.metricsStale = false;
36602
+ return held.viewport;
36603
+ }
36604
+ } catch {
36605
+ }
36606
+ return null;
36607
+ }
36608
+ /**
36609
+ * Turn one authorised event into CDP calls. Coordinates arrive normalised.
36610
+ *
36611
+ * A switch with a default that REFUSES, rather than a chain of ifs ending in
36612
+ * the click branch. This is a mutation boundary reached from a socket, so the
36613
+ * question is not what a well-formed event does but what a malformed one
36614
+ * does: the chain answered "clicks", which meant a version-skewed client
36615
+ * sending `{ kind: 'drag', x, y }` pressed the mouse on a real page, and a
36616
+ * kind with no coordinates at all clamped to 0 and clicked the top-left
36617
+ * corner. Unknown input has to fail closed.
36618
+ *
36619
+ * The `never` in the default is the other half: adding a member to
36620
+ * `BrowserInput` without handling it here stops compiling, so the next kind
36621
+ * cannot silently inherit whatever the last branch happened to be.
36622
+ */
36623
+ async dispatch(cdp, held, event) {
36624
+ switch (event.kind) {
36625
+ case "text": {
36626
+ if (typeof event.text !== "string") {
36627
+ throw new Error("That is not text that can be typed into the page.");
36628
+ }
36629
+ const text = event.text.slice(0, 4e3);
36630
+ if (!text) return;
36631
+ await cdp.send("Input.insertText", { text });
36632
+ return;
36633
+ }
36634
+ case "key": {
36635
+ const modifiers = event.modifiers;
36636
+ if (modifiers !== void 0) {
36637
+ if (!Array.isArray(modifiers) || modifiers.some((m) => m !== "Control" && m !== "Meta" && m !== "Shift") || new Set(modifiers).size !== modifiers.length) {
36638
+ throw new Error("That editing shortcut has invalid modifiers.");
36639
+ }
36640
+ const primary = modifiers.filter((m) => m === "Control" || m === "Meta");
36641
+ const shifted = modifiers.includes("Shift");
36642
+ if (primary.length !== 1) {
36643
+ throw new Error("That editing shortcut cannot be sent to the page.");
36644
+ }
36645
+ const key = typeof event.key === "string" ? event.key : "";
36646
+ const lower = key.toLowerCase();
36647
+ const shortcut = lower === "a" && !shifted ? { key: "a", code: "KeyA", vk: 65 } : lower === "z" ? { key: "z", code: "KeyZ", vk: 90 } : lower === "y" && !shifted ? { key: "y", code: "KeyY", vk: 89 } : key === "Backspace" && !shifted ? { key: "Backspace", code: "Backspace", vk: 8 } : key === "Delete" && !shifted ? { key: "Delete", code: "Delete", vk: 46 } : void 0;
36648
+ if (!shortcut) {
36649
+ throw new Error(`That editing shortcut is not supported for the remote page: ${key}`);
36650
+ }
36651
+ const modifierBits = (primary[0] === "Control" ? 2 : 4) | (shifted ? 8 : 0);
36652
+ const base2 = {
36653
+ key: shortcut.key,
36654
+ code: shortcut.code,
36655
+ windowsVirtualKeyCode: shortcut.vk,
36656
+ nativeVirtualKeyCode: shortcut.vk,
36657
+ modifiers: modifierBits
36658
+ };
36659
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "rawKeyDown" });
36660
+ await cdp.send("Input.dispatchKeyEvent", { ...base2, type: "keyUp" });
36661
+ return;
36662
+ }
36663
+ const spec = typeof event.key === "string" && Object.hasOwn(KEYS, event.key) ? KEYS[event.key] : void 0;
36664
+ if (!spec) throw new Error(`That key cannot be sent to the page: ${String(event.key)}`);
36665
+ const base = {
36666
+ key: event.key,
36667
+ code: event.key,
36668
+ windowsVirtualKeyCode: spec.code,
36669
+ nativeVirtualKeyCode: spec.code,
36670
+ ...spec.text ? { text: spec.text } : {}
36671
+ };
36672
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: spec.text ? "keyDown" : "rawKeyDown" });
36673
+ await cdp.send("Input.dispatchKeyEvent", { ...base, type: "keyUp" });
36674
+ return;
36675
+ }
36676
+ case "move":
36677
+ case "scroll":
36678
+ case "click": {
36679
+ const view = await this.pageViewport(held, cdp);
36680
+ if (!view) throw new Error("The page has not been measured yet, so a click cannot be placed on it.");
36681
+ const x = coordinate(event.x, "horizontal position") * view.width;
36682
+ const y = coordinate(event.y, "vertical position") * view.height;
36683
+ if (event.kind === "move") {
36684
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
36685
+ return;
36686
+ }
36687
+ if (event.kind === "scroll") {
36688
+ await cdp.send("Input.dispatchMouseEvent", {
36689
+ type: "mouseWheel",
36690
+ x,
36691
+ y,
36692
+ button: "none",
36693
+ buttons: 0,
36694
+ deltaX: 0,
36695
+ deltaY: scrollDelta(event.deltaY)
36696
+ });
36697
+ return;
36698
+ }
36699
+ if (event.button !== void 0 && !Object.hasOwn(BUTTONS, event.button)) {
36700
+ throw new Error(`That is not a mouse button: ${String(event.button)}`);
36701
+ }
36702
+ const button = event.button ?? "left";
36703
+ const buttons = BUTTONS[button];
36704
+ if (event.clicks !== void 0 && (typeof event.clicks !== "number" || !Number.isFinite(event.clicks))) {
36705
+ throw new Error("That is not a number of clicks.");
36706
+ }
36707
+ const clickCount = Math.min(Math.max(Math.trunc(event.clicks ?? 1), 1), 3);
36708
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "none", buttons: 0 });
36709
+ await cdp.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, buttons, clickCount });
36710
+ await cdp.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, buttons: 0, clickCount });
36711
+ return;
36712
+ }
36713
+ default: {
36714
+ const unknown = event;
36715
+ throw new Error(`That is not something that can be done to the page: ${unknown?.kind ?? "an event with no kind"}`);
36716
+ }
36717
+ }
36718
+ }
35359
36719
  /** The user stopped this run. Refuses further actions and clears its queue. */
35360
36720
  stopRun(runId) {
35361
36721
  this.revoked.add(runId);
@@ -35364,7 +36724,6 @@ var RemoteBrowserController = class {
35364
36724
  const held = this.runs.get(runId);
35365
36725
  if (!held) return;
35366
36726
  this.runs.delete(runId);
35367
- this.closing.add(runId);
35368
36727
  void this.teardown(runId, held);
35369
36728
  }
35370
36729
  /** A run ended: release its browser rather than pay for an idle session. */
@@ -35377,7 +36736,6 @@ var RemoteBrowserController = class {
35377
36736
  return;
35378
36737
  }
35379
36738
  this.runs.delete(runId);
35380
- this.closing.add(runId);
35381
36739
  await this.teardown(runId, held);
35382
36740
  this.forgetRun(runId);
35383
36741
  }
@@ -35387,6 +36745,7 @@ var RemoteBrowserController = class {
35387
36745
  this.leases.delete(runId);
35388
36746
  this.aborts.delete(runId);
35389
36747
  this.liveViewListeners.delete(runId);
36748
+ this.offControlFor(runId);
35390
36749
  }
35391
36750
  /**
35392
36751
  * Let go of one run's browser.
@@ -35398,12 +36757,22 @@ var RemoteBrowserController = class {
35398
36757
  async disposeRun(runId, held) {
35399
36758
  held.abort.abort();
35400
36759
  this.announceLiveView(runId, void 0, false);
36760
+ await held.watchQueue?.catch(() => {
36761
+ });
36762
+ const watching = held.screencast;
36763
+ held.screencast = void 0;
36764
+ held.screencastPage = void 0;
36765
+ if (watching) {
36766
+ await watching.send("Page.stopScreencast").catch(() => {
36767
+ });
36768
+ await watching.detach().catch(() => {
36769
+ });
36770
+ }
35401
36771
  await held.ownedContext?.close().catch(() => {
35402
36772
  });
35403
36773
  await held.browser.close().catch(() => {
35404
36774
  });
35405
- await this.provider.endSession(held.session.id).catch(() => {
35406
- });
36775
+ await this.provider.endSession(held.session.id).catch((err) => this.reportReleaseFailure(runId, held.session.id, err, held.session.expiresInMs));
35407
36776
  }
35408
36777
  /**
35409
36778
  * Run `disposeRun`, with the run id counted in `closing` until it settles.
@@ -35413,12 +36782,12 @@ var RemoteBrowserController = class {
35413
36782
  * guarantees the removal happens, success or failure, so a disposal that
35414
36783
  * throws cannot leave the slot counted against the pool forever.
35415
36784
  */
35416
- async teardown(runId, held) {
35417
- try {
35418
- await this.disposeRun(runId, held);
35419
- } finally {
36785
+ teardown(runId, held) {
36786
+ const done = this.disposeRun(runId, held).finally(() => {
35420
36787
  this.closing.delete(runId);
35421
- }
36788
+ });
36789
+ this.closing.set(runId, done);
36790
+ return done;
35422
36791
  }
35423
36792
  async act(action, context) {
35424
36793
  const runId = context.sessionId;
@@ -35432,7 +36801,9 @@ var RemoteBrowserController = class {
35432
36801
  if (context.signal?.aborted) {
35433
36802
  return { ok: false, detail: "The run was cancelled." };
35434
36803
  }
35435
- const planned = this.runs.get(runId)?.generation ?? 0;
36804
+ const plannedRun = this.runs.get(runId);
36805
+ const planned = plannedRun?.generation ?? 0;
36806
+ const recoveringClosedPage = plannedRun?.page.isClosed() === true;
35436
36807
  const lease_ = this.leaseFor(runId);
35437
36808
  const lease = await lease_.acquire(actor, runId, context.signal);
35438
36809
  if (lease !== "granted") {
@@ -35446,28 +36817,96 @@ var RemoteBrowserController = class {
35446
36817
  lease_.releaseIfHeldBy(actor);
35447
36818
  return { ok: false, detail: "The run was cancelled." };
35448
36819
  }
35449
- if (!await lease_.awaitActionSlot()) {
36820
+ const token = await lease_.acquireActionSlot();
36821
+ if (!token) {
35450
36822
  lease_.releaseIfHeldBy(actor);
35451
36823
  return { ok: false, detail: "Another browser action is still finishing. Try again." };
35452
36824
  }
35453
- const token = lease_.beginAction();
35454
- if (!token) {
35455
- return { ok: false, detail: "Another browser action is already running. Wait for it to finish." };
36825
+ if (!lease_.heldBy(actor)) {
36826
+ lease_.endAction(token);
36827
+ return { ok: false, detail: refusal(lease_.heldByHuman ? "human" : "off") };
35456
36828
  }
36829
+ let temporaryCapture;
35457
36830
  try {
35458
36831
  const held = await this.open(runId, context.signal);
35459
36832
  if (this.revoked.has(runId)) {
35460
- await this.releaseIfIdle(runId);
36833
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
35461
36834
  return { ok: false, detail: "The user stopped browser control for this run." };
35462
36835
  }
35463
36836
  if (context.signal?.aborted || held.abort.signal.aborted) {
35464
- await this.releaseIfIdle(runId);
36837
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
35465
36838
  return { ok: false, detail: "The run was cancelled." };
35466
36839
  }
35467
- return await this.perform(held, action, planned);
36840
+ if ((held.hiddenByHolder === true || held.visibilityUnconfirmed === true) && !held.screencast) {
36841
+ const watchGen = held.watchGen;
36842
+ let cdp = null;
36843
+ try {
36844
+ cdp = await this.attachScreencast(runId, held);
36845
+ } catch {
36846
+ }
36847
+ if (!cdp) {
36848
+ return {
36849
+ ok: false,
36850
+ detail: "The page could not be shown again, so nothing was done to it."
36851
+ };
36852
+ }
36853
+ if (held.onFrame === void 0 && held.watchGen === watchGen) {
36854
+ temporaryCapture = { held, cdp, watchGen };
36855
+ }
36856
+ }
36857
+ if (held.screencast && (held.capturing === false || held.visibilityUnconfirmed === true)) {
36858
+ const cdp = held.screencast;
36859
+ if (held.capturing === true) {
36860
+ await cdp.send("Page.stopScreencast").catch(() => {
36861
+ });
36862
+ held.capturing = false;
36863
+ this.announceControl(runId);
36864
+ }
36865
+ const drawn = this.nextFrame(held);
36866
+ if (!await this.applyCapture(runId, held, cdp, true, true)) {
36867
+ return {
36868
+ ok: false,
36869
+ detail: "The page could not be shown again, so nothing was done to it."
36870
+ };
36871
+ }
36872
+ if (!await drawn) {
36873
+ held.visibilityUnconfirmed = true;
36874
+ held.capturing = false;
36875
+ await cdp.send("Page.stopScreencast").catch(() => {
36876
+ });
36877
+ this.announceControl(runId);
36878
+ return {
36879
+ ok: false,
36880
+ detail: "The page did not come back on screen, so nothing was done to it."
36881
+ };
36882
+ }
36883
+ if (this.revoked.has(runId)) {
36884
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36885
+ return { ok: false, detail: "The user stopped browser control for this run." };
36886
+ }
36887
+ if (context.signal?.aborted || held.abort.signal.aborted) {
36888
+ await this.releaseIfIdle(runId, () => lease_.endAction(token));
36889
+ return { ok: false, detail: "The run was cancelled." };
36890
+ }
36891
+ }
36892
+ return await this.perform(runId, held, action, recoveringClosedPage ? held.generation : planned);
35468
36893
  } catch (err) {
35469
36894
  return { ok: false, detail: err instanceof Error ? err.message : String(err) };
35470
36895
  } finally {
36896
+ if (temporaryCapture) {
36897
+ const { held, cdp, watchGen } = temporaryCapture;
36898
+ if (held.screencast === cdp && held.onFrame === void 0 && held.watchGen === watchGen && held.hiddenByHolder === false && held.visibilityUnconfirmed === false && held.capturing === true) {
36899
+ held.onFrame = void 0;
36900
+ held.screencast = void 0;
36901
+ held.screencastPage = void 0;
36902
+ held.capturing = true;
36903
+ this.announceControl(runId);
36904
+ await cdp.send("Page.stopScreencast").catch(() => {
36905
+ });
36906
+ await cdp.detach().catch(() => {
36907
+ });
36908
+ }
36909
+ }
35471
36910
  lease_.endAction(token);
35472
36911
  if (context.signal?.aborted || this.revoked.has(runId)) lease_.releaseIfHeldBy(actor);
35473
36912
  }
@@ -35483,22 +36922,46 @@ var RemoteBrowserController = class {
35483
36922
  * it out changed no test. Two copies of a cap invite them to disagree.
35484
36923
  */
35485
36924
  async open(runId, signal) {
36925
+ if (this.ready) {
36926
+ const settling = this.ready;
36927
+ try {
36928
+ await settling;
36929
+ } catch {
36930
+ }
36931
+ if (this.ready === settling) this.ready = void 0;
36932
+ }
35486
36933
  const existing = this.runs.get(runId);
35487
- if (existing && !existing.page.isClosed()) return existing;
36934
+ if (existing && !existing.page.isClosed()) {
36935
+ this.announceLiveView(runId, existing.session.liveViewUrl, true);
36936
+ return existing;
36937
+ }
36938
+ if (existing && existing.page.isClosed()) {
36939
+ const fallback = existing.page.context().pages().filter((p) => !p.isClosed()).at(-1);
36940
+ if (fallback) {
36941
+ await this.adoptPage(runId, existing, fallback);
36942
+ if (this.runs.get(runId) === existing && !existing.abort.signal.aborted) {
36943
+ this.announceLiveView(runId, existing.session.liveViewUrl, true);
36944
+ }
36945
+ return existing;
36946
+ }
36947
+ }
35488
36948
  if (existing) {
35489
36949
  this.runs.delete(runId);
35490
- this.closing.add(runId);
35491
36950
  await this.teardown(runId, existing);
35492
36951
  }
36952
+ if (this.retired) {
36953
+ throw new Error("The browser settings changed while this action was waiting. Try again.");
36954
+ }
35493
36955
  if (this.runs.size + this.opening.size + this.closing.size >= this.maxSessions) {
35494
36956
  throw new Error(
35495
36957
  `All ${this.maxSessions} browser session${this.maxSessions === 1 ? "" : "s"} are in use by other runs. Try again when one finishes, or raise the session limit in settings.`
35496
36958
  );
35497
36959
  }
35498
- this.opening.add(runId);
35499
36960
  if (!this.aborts.has(runId)) this.aborts.set(runId, new AbortController());
36961
+ const opening = this.openReserved(runId, signal);
36962
+ this.opening.set(runId, opening);
35500
36963
  try {
35501
- return await this.openReserved(runId, signal);
36964
+ return await opening;
35502
36965
  } finally {
35503
36966
  this.opening.delete(runId);
35504
36967
  }
@@ -35509,9 +36972,9 @@ var RemoteBrowserController = class {
35509
36972
  const abort = this.aborts.get(runId)?.signal;
35510
36973
  const creationSignal = signal && abort ? AbortSignal.any([signal, abort]) : signal ?? abort;
35511
36974
  const session = await this.provider.createSession(creationSignal);
35512
- this.announceLiveView(runId, session.liveViewUrl, true);
35513
36975
  let browser;
35514
36976
  let owned;
36977
+ let held;
35515
36978
  try {
35516
36979
  browser = await playwright.chromium.connectOverCDP(session.cdpUrl);
35517
36980
  owned = this.provider.isolatesSessions ? void 0 : await browser.newContext();
@@ -35519,25 +36982,85 @@ var RemoteBrowserController = class {
35519
36982
  const page2 = owned ? await owned.newPage() : context.pages()[0] ?? await context.newPage();
35520
36983
  const abort2 = this.aborts.get(runId);
35521
36984
  if (!abort2) throw new Error("The run ended while its browser was still opening.");
35522
- const held = { session, browser, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
35523
- page2.on("framenavigated", ((frame) => {
35524
- if (frame !== page2.mainFrame()) return;
35525
- held.generation += 1;
35526
- }));
36985
+ held = { session, browser, page: page2, generation: 0, abort: abort2, ...owned ? { ownedContext: owned } : {} };
36986
+ this.trackPageNavigation(runId, held, page2);
35527
36987
  this.runs.set(runId, held);
35528
- return held;
35529
36988
  } catch (err) {
35530
- this.announceLiveView(runId, void 0, false);
35531
36989
  await owned?.close().catch(() => {
35532
36990
  });
35533
36991
  await browser?.close().catch(() => {
35534
36992
  });
35535
- await this.provider.endSession(session.id).catch(() => {
35536
- });
36993
+ await this.provider.endSession(session.id).catch((releaseErr) => this.reportReleaseFailure(runId, session.id, releaseErr, session.expiresInMs));
35537
36994
  throw err;
35538
36995
  }
36996
+ this.announceLiveView(runId, session.liveViewUrl, true);
36997
+ return held;
36998
+ }
36999
+ /** Count main-frame navigation only while this page is the run's active page. */
37000
+ trackPageNavigation(runId, held, page2) {
37001
+ page2.on("framenavigated", ((frame) => {
37002
+ if (held.page !== page2 || frame !== page2.mainFrame()) return;
37003
+ held.generation += 1;
37004
+ if (held.onFrame && held.watchGen !== void 0) {
37005
+ held.watchGen += 1;
37006
+ this.announceControl(runId);
37007
+ }
37008
+ }));
35539
37009
  }
35540
- async perform(held, action, planned) {
37010
+ /**
37011
+ * Follow a page opened by the action that just finished.
37012
+ *
37013
+ * A popup/new tab is part of the same user gesture (OAuth is the common
37014
+ * example). Keeping `held.page` and the screencast on its opener makes the
37015
+ * new page both invisible and impossible to drive. This runs while the SAME
37016
+ * action slot is still held, after the opening click/key has fully landed, so
37017
+ * no later human/agent event can slip between detecting the popup and moving
37018
+ * the view to it.
37019
+ */
37020
+ async followOpenedPage(runId, held, before) {
37021
+ if (this.runs.get(runId) !== held || held.abort.signal.aborted) return;
37022
+ const candidates = held.page.context().pages().filter((p) => !p.isClosed() && !before.has(p));
37023
+ const next = candidates.at(-1);
37024
+ if (!next || next === held.page) return;
37025
+ await this.adoptPage(runId, held, next);
37026
+ }
37027
+ /**
37028
+ * Make one live page the run's controlled/viewed page. Used in both
37029
+ * directions: into a popup opened by an action, and back to another surviving
37030
+ * page when that popup closes itself after OAuth/login completion.
37031
+ */
37032
+ async adoptPage(runId, held, next) {
37033
+ if (next === held.page || next.isClosed()) return;
37034
+ held.page = next;
37035
+ held.generation += 1;
37036
+ held.viewport = void 0;
37037
+ held.metricsStale = true;
37038
+ this.trackPageNavigation(runId, held, next);
37039
+ const deliver = held.onFrame;
37040
+ if (deliver) {
37041
+ held.watchGen = (held.watchGen ?? 0) + 1;
37042
+ held.visibilityUnconfirmed = true;
37043
+ held.capturing = false;
37044
+ this.announceControl(runId);
37045
+ await this.attachScreencast(runId, held, deliver, held.watchDetachGen ?? 0);
37046
+ return;
37047
+ }
37048
+ const pending = held.attaching;
37049
+ if (pending) await pending.catch(() => null);
37050
+ if (held.screencast && held.screencastPage !== next) {
37051
+ const stale = held.screencast;
37052
+ held.screencast = void 0;
37053
+ held.screencastPage = void 0;
37054
+ await stale.send("Page.stopScreencast").catch(() => {
37055
+ });
37056
+ await stale.detach().catch(() => {
37057
+ });
37058
+ }
37059
+ held.visibilityUnconfirmed = false;
37060
+ held.capturing = held.hiddenByHolder !== true;
37061
+ this.announceControl(runId);
37062
+ }
37063
+ async perform(runId, held, action, planned) {
35541
37064
  const { page: page2 } = held;
35542
37065
  const stoppable = async (work) => {
35543
37066
  if (held.abort.signal.aborted) throw new Error("The user stopped browser control for this run.");
@@ -35559,10 +37082,10 @@ var RemoteBrowserController = class {
35559
37082
  }
35560
37083
  };
35561
37084
  const timeout = Math.min(action.timeoutMs ?? 1e4, ACTION_TIMEOUT_MS);
35562
- const where = async () => ({
35563
- url: page2.url(),
35564
- title: await page2.title().catch(() => "")
35565
- });
37085
+ const where = async () => {
37086
+ const current = held.page;
37087
+ return { url: current.url(), title: await current.title().catch(() => "") };
37088
+ };
35566
37089
  if (action.kind !== "navigate" && held.generation !== planned) {
35567
37090
  return {
35568
37091
  ok: false,
@@ -35579,15 +37102,20 @@ var RemoteBrowserController = class {
35579
37102
  await stoppable(page2.goto(target, { timeout: ACTION_TIMEOUT_MS, waitUntil: "domcontentloaded" }));
35580
37103
  return { ok: true, detail: `Opened ${target}`, ...await where() };
35581
37104
  }
35582
- case "click":
37105
+ case "click": {
37106
+ const pagesBefore = new Set(page2.context().pages());
35583
37107
  await stoppable(page2.click(action.selector, { timeout }));
37108
+ await this.followOpenedPage(runId, held, pagesBefore);
35584
37109
  return { ok: true, detail: `Clicked ${action.selector}`, ...await where() };
37110
+ }
35585
37111
  case "fill":
35586
37112
  await stoppable(page2.fill(action.selector, action.value ?? "", { timeout }));
35587
37113
  return { ok: true, detail: `Filled ${action.selector}`, ...await where() };
35588
37114
  case "press": {
37115
+ const pagesBefore = new Set(page2.context().pages());
35589
37116
  if (action.selector) await stoppable(page2.press(action.selector, action.key, { timeout }));
35590
37117
  else await stoppable(page2.keyboard.press(action.key));
37118
+ await this.followOpenedPage(runId, held, pagesBefore);
35591
37119
  return { ok: true, detail: `Pressed ${action.key}`, ...await where() };
35592
37120
  }
35593
37121
  case "wait_for":
@@ -35603,10 +37131,25 @@ var RemoteBrowserController = class {
35603
37131
  }
35604
37132
  };
35605
37133
  function refusal(result) {
37134
+ if (result === "human") {
37135
+ return "The user has taken control of this browser. Stop using it and tell them what you were about to do.";
37136
+ }
35606
37137
  if (result === "off") return "Browser control was turned off while this action waited for the browser.";
35607
37138
  if (result === "cancelled") return "The run was cancelled while waiting for the browser.";
35608
37139
  return "The browser is in use by another part of this run and did not come free in time. Try again, or do something else first.";
35609
37140
  }
37141
+ function coordinate(n, what) {
37142
+ if (typeof n !== "number" || !Number.isFinite(n)) {
37143
+ throw new Error(`That ${what} is not a position on the page.`);
37144
+ }
37145
+ return Math.min(Math.max(n, 0), 1);
37146
+ }
37147
+ function scrollDelta(n) {
37148
+ if (typeof n !== "number" || !Number.isFinite(n)) {
37149
+ throw new Error("That scroll has no distance to travel.");
37150
+ }
37151
+ return Math.min(Math.max(n, -2e3), 2e3);
37152
+ }
35610
37153
  async function loadPlaywright() {
35611
37154
  try {
35612
37155
  return await import('playwright');
@@ -35657,6 +37200,139 @@ function isCdpEndpoint(url) {
35657
37200
 
35658
37201
  // src/browser/remote/steel.ts
35659
37202
  var DEFAULT_BASE = "https://api.steel.dev";
37203
+ function isHostedSteel(url) {
37204
+ if (!url) return true;
37205
+ try {
37206
+ return hostKey(new URL(url).hostname) === hostKey(new URL(DEFAULT_BASE).hostname);
37207
+ } catch {
37208
+ return false;
37209
+ }
37210
+ }
37211
+ function isUsableSteelBase(url) {
37212
+ try {
37213
+ const u = new URL(url);
37214
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
37215
+ if (u.username !== "" || u.password !== "") return false;
37216
+ const built = new URL(withoutTrailingSlashes(url) + PROBE_PATH);
37217
+ if (FETCH_BLOCKED_PORTS.has(built.port)) return false;
37218
+ if (built.search !== "" || built.hash !== "") return false;
37219
+ if (!built.pathname.endsWith(PROBE_PATH)) return false;
37220
+ if (isHostedSteel(url)) {
37221
+ if (built.pathname !== PROBE_PATH) return false;
37222
+ if (u.protocol !== "https:") return false;
37223
+ if (built.port !== "") return false;
37224
+ }
37225
+ return true;
37226
+ } catch {
37227
+ return false;
37228
+ }
37229
+ }
37230
+ function isUsableSteelKey(apiKey) {
37231
+ for (const ch of apiKey) {
37232
+ const c = ch.codePointAt(0) ?? 0;
37233
+ if (c === 9) continue;
37234
+ if (c >= 32 && c <= 126) continue;
37235
+ if (c >= 128 && c <= 255) continue;
37236
+ return false;
37237
+ }
37238
+ return true;
37239
+ }
37240
+ var API_KEY_HEADER = "steel-api-key";
37241
+ var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
37242
+ "1",
37243
+ "7",
37244
+ "9",
37245
+ "11",
37246
+ "13",
37247
+ "15",
37248
+ "17",
37249
+ "19",
37250
+ "20",
37251
+ "21",
37252
+ "22",
37253
+ "23",
37254
+ "25",
37255
+ "37",
37256
+ "42",
37257
+ "43",
37258
+ "53",
37259
+ "69",
37260
+ "77",
37261
+ "79",
37262
+ "87",
37263
+ "95",
37264
+ "101",
37265
+ "102",
37266
+ "103",
37267
+ "104",
37268
+ "109",
37269
+ "110",
37270
+ "111",
37271
+ "113",
37272
+ "115",
37273
+ "117",
37274
+ "119",
37275
+ "123",
37276
+ "135",
37277
+ "137",
37278
+ "139",
37279
+ "143",
37280
+ "161",
37281
+ "179",
37282
+ "389",
37283
+ "427",
37284
+ "465",
37285
+ "512",
37286
+ "513",
37287
+ "514",
37288
+ "515",
37289
+ "526",
37290
+ "530",
37291
+ "531",
37292
+ "532",
37293
+ "540",
37294
+ "548",
37295
+ "554",
37296
+ "556",
37297
+ "563",
37298
+ "587",
37299
+ "601",
37300
+ "636",
37301
+ "989",
37302
+ "990",
37303
+ "993",
37304
+ "995",
37305
+ "1719",
37306
+ "1720",
37307
+ "1723",
37308
+ "2049",
37309
+ "3659",
37310
+ "4045",
37311
+ "4190",
37312
+ "5060",
37313
+ "5061",
37314
+ "6000",
37315
+ "6566",
37316
+ "6665",
37317
+ "6666",
37318
+ "6667",
37319
+ "6668",
37320
+ "6669",
37321
+ "6679",
37322
+ "6697",
37323
+ "10080"
37324
+ ]);
37325
+ var PROBE_PATH = "/v1/sessions";
37326
+ function withoutTrailingSlashes(value) {
37327
+ let out = value;
37328
+ while (out.endsWith("/")) out = out.slice(0, -1);
37329
+ return out;
37330
+ }
37331
+ function hostKey(hostname) {
37332
+ let host = hostname.toLowerCase();
37333
+ while (host.endsWith(".")) host = host.slice(0, -1);
37334
+ return host;
37335
+ }
35660
37336
  var CREATE_TIMEOUT_MS = 6e4;
35661
37337
  var SteelProvider = class {
35662
37338
  name = "Steel";
@@ -35666,9 +37342,7 @@ var SteelProvider = class {
35666
37342
  base;
35667
37343
  apiKey;
35668
37344
  constructor(opts = {}) {
35669
- let base = opts.url || DEFAULT_BASE;
35670
- while (base.endsWith("/")) base = base.slice(0, -1);
35671
- this.base = base;
37345
+ this.base = withoutTrailingSlashes(opts.url || DEFAULT_BASE);
35672
37346
  this.apiKey = opts.apiKey;
35673
37347
  }
35674
37348
  async createSession(signal) {
@@ -35679,14 +37353,15 @@ var SteelProvider = class {
35679
37353
  return {
35680
37354
  id: details.id,
35681
37355
  cdpUrl: details.websocketUrl,
35682
- ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
37356
+ ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {},
37357
+ // Carried through because it explains an otherwise mysterious death. A
37358
+ // session nobody released dies at exactly this number, and without it a
37359
+ // leak looks identical to an orderly shutdown.
37360
+ ...typeof details.timeout === "number" ? { expiresInMs: details.timeout } : {}
35683
37361
  };
35684
37362
  }
35685
37363
  async endSession(id) {
35686
- try {
35687
- await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
35688
- } catch {
35689
- }
37364
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
35690
37365
  }
35691
37366
  async call(method, path30, signal, body) {
35692
37367
  const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
@@ -35697,7 +37372,7 @@ var SteelProvider = class {
35697
37372
  "content-type": "application/json",
35698
37373
  // Sent only when configured — the self-hosted API takes no key, and an
35699
37374
  // empty header is worse than none.
35700
- ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
37375
+ ...this.apiKey ? { [API_KEY_HEADER]: this.apiKey } : {}
35701
37376
  },
35702
37377
  body: JSON.stringify(body),
35703
37378
  signal: composed
@@ -36363,6 +38038,7 @@ function applySyncBundle(bundle, config, cleanup) {
36363
38038
 
36364
38039
  exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
36365
38040
  exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
38041
+ exports.AskUserTool = AskUserTool;
36366
38042
  exports.AuditLogger = AuditLogger;
36367
38043
  exports.BrowserControlTool = BrowserControlTool;
36368
38044
  exports.BrowserLease = BrowserLease;
@@ -36494,7 +38170,10 @@ exports.inferAzureBaseModel = inferAzureBaseModel;
36494
38170
  exports.inlineRuns = inlineRuns;
36495
38171
  exports.isCdpEndpoint = isCdpEndpoint;
36496
38172
  exports.isDocumentFormat = isDocumentFormat;
38173
+ exports.isHostedSteel = isHostedSteel;
36497
38174
  exports.isMcpToolName = isMcpToolName;
38175
+ exports.isUsableSteelBase = isUsableSteelBase;
38176
+ exports.isUsableSteelKey = isUsableSteelKey;
36498
38177
  exports.matchImageLine = matchImageLine;
36499
38178
  exports.mcpServerPrefix = mcpServerPrefix;
36500
38179
  exports.mcpToolName = mcpToolName;