cascade-ai 0.80.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.80.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) {
@@ -35491,6 +35790,13 @@ var RESTORE_FRAME_MS = 2e3;
35491
35790
  var MAX_HUMAN_INPUT_QUEUE = 64;
35492
35791
  var MAX_CAPTURE_TRANSITION_QUEUE = 16;
35493
35792
  var MAX_WATCH_LIFECYCLE_QUEUE = 16;
35793
+ function notify(deliver) {
35794
+ try {
35795
+ void Promise.resolve(deliver()).catch(() => {
35796
+ });
35797
+ } catch {
35798
+ }
35799
+ }
35494
35800
  var RemoteBrowserController = class {
35495
35801
  provider;
35496
35802
  maxSessions;
@@ -35516,6 +35822,36 @@ var RemoteBrowserController = class {
35516
35822
  controlAnnounced = /* @__PURE__ */ new Map();
35517
35823
  /** An embedder that wants every run's live view, told which run each is. */
35518
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
+ }
35519
35855
  runs = /* @__PURE__ */ new Map();
35520
35856
  /**
35521
35857
  * Runs that have a pool slot but no browser yet.
@@ -35574,6 +35910,7 @@ var RemoteBrowserController = class {
35574
35910
  const asked = Math.max(1, options.maxSessions ?? 1);
35575
35911
  this.maxSessions = options.provider.isolatesSessions ? asked : 1;
35576
35912
  this.onLiveViewAll = options.onLiveView;
35913
+ this.onReleaseFailed = options.onReleaseFailed;
35577
35914
  this.ready = options.ready;
35578
35915
  }
35579
35916
  /**
@@ -35658,7 +35995,7 @@ var RemoteBrowserController = class {
35658
35995
  const key = `${state.human}:${state.capturing}:${state.confirmed}`;
35659
35996
  if (!force && this.controlAnnounced.get(runId) === key) return;
35660
35997
  this.controlAnnounced.set(runId, key);
35661
- listener(state);
35998
+ notify(() => listener(state));
35662
35999
  }
35663
36000
  /**
35664
36001
  * Tell a run's listener what its browser situation is.
@@ -35669,9 +36006,32 @@ var RemoteBrowserController = class {
35669
36006
  * apart — and would drop the Stop control for the first as if it were the
35670
36007
  * second.
35671
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
+ */
35672
36032
  announceLiveView(runId, liveViewUrl, active) {
35673
- this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
35674
- this.onLiveViewAll?.(runId, liveViewUrl);
36033
+ notify(() => this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} }));
36034
+ notify(() => this.onLiveViewAll?.(runId, liveViewUrl));
35675
36035
  }
35676
36036
  /**
35677
36037
  * Hand back a session the run turned out not to want.
@@ -35882,7 +36242,8 @@ var RemoteBrowserController = class {
35882
36242
  ping?.();
35883
36243
  const deliver = held.onFrame;
35884
36244
  if (deliver) {
35885
- deliver({ data: e.data, width, height, generation: held.watchGen ?? 0 });
36245
+ const frame = { data: e.data, width, height, generation: held.watchGen ?? 0 };
36246
+ notify(() => deliver(frame));
35886
36247
  }
35887
36248
  }
35888
36249
  void cdp.send("Page.screencastFrameAck", { sessionId: e?.sessionId }).catch(() => {
@@ -36411,8 +36772,7 @@ var RemoteBrowserController = class {
36411
36772
  });
36412
36773
  await held.browser.close().catch(() => {
36413
36774
  });
36414
- await this.provider.endSession(held.session.id).catch(() => {
36415
- });
36775
+ await this.provider.endSession(held.session.id).catch((err) => this.reportReleaseFailure(runId, held.session.id, err, held.session.expiresInMs));
36416
36776
  }
36417
36777
  /**
36418
36778
  * Run `disposeRun`, with the run id counted in `closing` until it settles.
@@ -36571,11 +36931,17 @@ var RemoteBrowserController = class {
36571
36931
  if (this.ready === settling) this.ready = void 0;
36572
36932
  }
36573
36933
  const existing = this.runs.get(runId);
36574
- 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
+ }
36575
36938
  if (existing && existing.page.isClosed()) {
36576
36939
  const fallback = existing.page.context().pages().filter((p) => !p.isClosed()).at(-1);
36577
36940
  if (fallback) {
36578
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
+ }
36579
36945
  return existing;
36580
36946
  }
36581
36947
  }
@@ -36624,8 +36990,7 @@ var RemoteBrowserController = class {
36624
36990
  });
36625
36991
  await browser?.close().catch(() => {
36626
36992
  });
36627
- await this.provider.endSession(session.id).catch(() => {
36628
- });
36993
+ await this.provider.endSession(session.id).catch((releaseErr) => this.reportReleaseFailure(runId, session.id, releaseErr, session.expiresInMs));
36629
36994
  throw err;
36630
36995
  }
36631
36996
  this.announceLiveView(runId, session.liveViewUrl, true);
@@ -36835,6 +37200,139 @@ function isCdpEndpoint(url) {
36835
37200
 
36836
37201
  // src/browser/remote/steel.ts
36837
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
+ }
36838
37336
  var CREATE_TIMEOUT_MS = 6e4;
36839
37337
  var SteelProvider = class {
36840
37338
  name = "Steel";
@@ -36844,9 +37342,7 @@ var SteelProvider = class {
36844
37342
  base;
36845
37343
  apiKey;
36846
37344
  constructor(opts = {}) {
36847
- let base = opts.url || DEFAULT_BASE;
36848
- while (base.endsWith("/")) base = base.slice(0, -1);
36849
- this.base = base;
37345
+ this.base = withoutTrailingSlashes(opts.url || DEFAULT_BASE);
36850
37346
  this.apiKey = opts.apiKey;
36851
37347
  }
36852
37348
  async createSession(signal) {
@@ -36857,14 +37353,15 @@ var SteelProvider = class {
36857
37353
  return {
36858
37354
  id: details.id,
36859
37355
  cdpUrl: details.websocketUrl,
36860
- ...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 } : {}
36861
37361
  };
36862
37362
  }
36863
37363
  async endSession(id) {
36864
- try {
36865
- await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
36866
- } catch {
36867
- }
37364
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
36868
37365
  }
36869
37366
  async call(method, path30, signal, body) {
36870
37367
  const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
@@ -36875,7 +37372,7 @@ var SteelProvider = class {
36875
37372
  "content-type": "application/json",
36876
37373
  // Sent only when configured — the self-hosted API takes no key, and an
36877
37374
  // empty header is worse than none.
36878
- ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
37375
+ ...this.apiKey ? { [API_KEY_HEADER]: this.apiKey } : {}
36879
37376
  },
36880
37377
  body: JSON.stringify(body),
36881
37378
  signal: composed
@@ -37541,6 +38038,7 @@ function applySyncBundle(bundle, config, cleanup) {
37541
38038
 
37542
38039
  exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
37543
38040
  exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
38041
+ exports.AskUserTool = AskUserTool;
37544
38042
  exports.AuditLogger = AuditLogger;
37545
38043
  exports.BrowserControlTool = BrowserControlTool;
37546
38044
  exports.BrowserLease = BrowserLease;
@@ -37672,7 +38170,10 @@ exports.inferAzureBaseModel = inferAzureBaseModel;
37672
38170
  exports.inlineRuns = inlineRuns;
37673
38171
  exports.isCdpEndpoint = isCdpEndpoint;
37674
38172
  exports.isDocumentFormat = isDocumentFormat;
38173
+ exports.isHostedSteel = isHostedSteel;
37675
38174
  exports.isMcpToolName = isMcpToolName;
38175
+ exports.isUsableSteelBase = isUsableSteelBase;
38176
+ exports.isUsableSteelKey = isUsableSteelKey;
37676
38177
  exports.matchImageLine = matchImageLine;
37677
38178
  exports.mcpServerPrefix = mcpServerPrefix;
37678
38179
  exports.mcpToolName = mcpToolName;