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.
@@ -312562,7 +312562,7 @@ function reconcilePrice(model, live) {
312562
312562
  }
312563
312563
 
312564
312564
  // src/constants.ts
312565
- var CASCADE_VERSION = "0.80.0";
312565
+ var CASCADE_VERSION = "0.81.0";
312566
312566
  var CASCADE_CONFIG_DIR = ".cascade";
312567
312567
  var CASCADE_MD_FILE = "CASCADE.md";
312568
312568
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -398034,8 +398034,95 @@ function requiredFieldFor(kind2, input) {
398034
398034
  }
398035
398035
  }
398036
398036
 
398037
+ // src/tools/ask-user.ts
398038
+ var MAX_QUESTIONS = 5;
398039
+ var MAX_OPTIONS = 6;
398040
+ var AskUserTool = class extends BaseTool {
398041
+ name = "ask_user";
398042
+ description = "Ask the person a short questionnaire when their request is genuinely ambiguous, instead of guessing. Use it when two readings of the request would produce materially different work \u2014 which account, which date range, whether to include drafts \u2014 and NOT for anything you can settle yourself or from the conversation. Ask everything you need in ONE call: several questions together read as a form, the same questions one per turn read as an interrogation. You may get no answer (nobody is watching, or they did not reply); if so, proceed on your best reading and say which assumption you made.";
398043
+ inputSchema = {
398044
+ type: "object",
398045
+ properties: {
398046
+ questions: {
398047
+ type: "array",
398048
+ minItems: 1,
398049
+ maxItems: MAX_QUESTIONS,
398050
+ description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
398051
+ items: {
398052
+ type: "object",
398053
+ properties: {
398054
+ prompt: { type: "string", description: "The question, as you would say it to them." },
398055
+ kind: {
398056
+ type: "string",
398057
+ enum: ["choice", "multi", "text"],
398058
+ description: "choice = pick one, multi = pick any that apply, text = free-form answer."
398059
+ },
398060
+ options: {
398061
+ type: "array",
398062
+ items: { type: "string" },
398063
+ description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
398064
+ }
398065
+ },
398066
+ required: ["prompt", "kind"]
398067
+ }
398068
+ }
398069
+ },
398070
+ required: ["questions"]
398071
+ };
398072
+ ask;
398073
+ constructor(ask) {
398074
+ super();
398075
+ this.ask = ask;
398076
+ }
398077
+ // Asking a question changes nothing, so it needs no approval gate: a prompt
398078
+ // asking permission to show a prompt is a tax on the behaviour we want.
398079
+ isDangerous() {
398080
+ return false;
398081
+ }
398082
+ async execute(input, options) {
398083
+ const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
398084
+ const questions = [];
398085
+ for (const [i4, q4] of raw.slice(0, MAX_QUESTIONS).entries()) {
398086
+ const item = q4 ?? {};
398087
+ const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
398088
+ if (!prompt) continue;
398089
+ const kind2 = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
398090
+ const options_ = Array.isArray(item["options"]) ? item["options"].filter((o3) => typeof o3 === "string" && o3.trim() !== "").slice(0, MAX_OPTIONS) : [];
398091
+ const resolved = kind2 !== "text" && options_.length < 2 ? "text" : kind2;
398092
+ questions.push({
398093
+ // Positional, and assigned HERE. Letting the model supply ids invites
398094
+ // two questions sharing one, and the second answer would overwrite the
398095
+ // first with nothing to say it had.
398096
+ id: `q${i4 + 1}`,
398097
+ prompt,
398098
+ kind: resolved,
398099
+ ...resolved === "text" ? {} : { options: options_ }
398100
+ });
398101
+ }
398102
+ if (questions.length === 0) {
398103
+ return "Error: ask_user needs at least one question with a prompt.";
398104
+ }
398105
+ const result = await this.ask(questions, options.signal);
398106
+ if (result.outcome !== "answered" || result.answers.length === 0) {
398107
+ const why = result.outcome === "no-listener" ? "There is nobody watching this run to answer" : result.outcome === "aborted" ? "The run was stopped while the question was open" : result.outcome === "answered" ? "They saw the questions and chose not to answer" : "They did not answer in time";
398108
+ return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
398109
+ }
398110
+ const byId = new Map(result.answers.map((a2) => [a2.id, a2.value]));
398111
+ const lines = questions.map((q4) => {
398112
+ const value = byId.get(q4.id);
398113
+ const said = Array.isArray(value) ? value.join(", ") : value;
398114
+ return `${q4.prompt}
398115
+ \u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
398116
+ });
398117
+ return `They answered:
398118
+
398119
+ ${lines.join("\n\n")}`;
398120
+ }
398121
+ };
398122
+
398037
398123
  // src/core/cascade.ts
398038
398124
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
398125
+ var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
398039
398126
  function buildContextualPrompt(prompt, history = []) {
398040
398127
  const recent = history.slice(-6);
398041
398128
  if (recent.length === 0) return prompt;
@@ -398314,6 +398401,112 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
398314
398401
  // timeout. Same class of bug as the twelve-failover dead-model race: a wave
398315
398402
  // is concurrent, so per-run singletons are never safe inside it.
398316
398403
  pendingEscalations = /* @__PURE__ */ new Map();
398404
+ /**
398405
+ * Questionnaires waiting on a person, keyed by request id.
398406
+ *
398407
+ * Exactly the shape `pendingEscalations` uses, and for the same reason: the
398408
+ * answer, the timeout and the abort all race, and one settle path guarded by
398409
+ * the map delete is what makes the first of them win.
398410
+ */
398411
+ pendingClarifications = /* @__PURE__ */ new Map();
398412
+ /**
398413
+ * Put a gate's opening question to its listeners, and NOTICE when one fails.
398414
+ *
398415
+ * `emit` cannot do this job. It calls listeners synchronously and discards
398416
+ * what they return, so an `async` listener — the ordinary shape for anything
398417
+ * that has to reach a socket or a UI — does not fail in a way `emit` reports:
398418
+ * it returns a REJECTED PROMISE that nobody is holding. The `try` around the
398419
+ * emit sees nothing, the gate is never told delivery failed, and it parks for
398420
+ * its full timeout while Node raises an unhandled rejection that can take the
398421
+ * process down.
398422
+ *
398423
+ * `onFailed` is called for either shape, and it is safe to call more than
398424
+ * once: both gates route it to `settle`, which is guarded by a map delete, so
398425
+ * the first failure wins and the rest are no-ops.
398426
+ *
398427
+ * PER LISTENER, not around the loop. `emit` stops dispatching at the first
398428
+ * throw, so one broken host used to cost a working one its notification —
398429
+ * the same reason `announceLiveView` delivers its two listeners separately.
398430
+ * A synchronous failure settles the gate once the whole snapshot has been
398431
+ * delivered; an asynchronous one settles it when the rejection arrives, which
398432
+ * is still far better than the window-long park it replaces.
398433
+ *
398434
+ * SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
398435
+ * settles the gate, and settling emits the gate's CLOSURE — so failing
398436
+ * mid-dispatch announced "this request is over" and then went on handing the
398437
+ * OPENING event to the listeners after it. A host later in the list would
398438
+ * draw a form whose only closure notification had already gone past, and it
398439
+ * would stand there forever. Deliver to everyone, then decide.
398440
+ *
398441
+ * `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
398442
+ * `emit` was doing, and `once` is the part that bites: `listeners()` hands
398443
+ * back the UNWRAPPED callback, so calling it never runs the wrapper that
398444
+ * deregisters it. A host that asked to be told once would be told every
398445
+ * time — and told through a transport it had set up for one gate only.
398446
+ * `rawListeners()` returns the wrapper, which removes the registration and
398447
+ * passes the underlying listener's return value straight through, so the
398448
+ * promise below is still the one being observed. Verified against Node.
398449
+ *
398450
+ * `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
398451
+ * invokes listeners with the emitter as the receiver, so
398452
+ * `function () { this.resolveClarification(...) }` is a legitimate host — and
398453
+ * calling it bare gave it `this === undefined`, which throws, which this
398454
+ * would then have read as the host failing to receive the question and
398455
+ * settled the gate unanswered. A working listener turned into a delivery
398456
+ * failure by the thing delivering it.
398457
+ */
398458
+ deliverGate(event, payload, onFailed) {
398459
+ let delivered = false;
398460
+ let threw = false;
398461
+ const pending = [];
398462
+ for (const listener of this.rawListeners(event)) {
398463
+ try {
398464
+ const out = listener.call(this, payload);
398465
+ if (out && typeof out.then === "function") {
398466
+ pending.push(Promise.resolve(out).then(() => true, () => false));
398467
+ } else {
398468
+ delivered = true;
398469
+ }
398470
+ } catch {
398471
+ threw = true;
398472
+ }
398473
+ }
398474
+ if (delivered) return;
398475
+ if (pending.length === 0) {
398476
+ if (threw) onFailed();
398477
+ return;
398478
+ }
398479
+ void Promise.all(pending).then((results) => {
398480
+ if (!results.some(Boolean)) onFailed();
398481
+ });
398482
+ }
398483
+ /**
398484
+ * Tell listeners something that is over, and let none of them make it worse.
398485
+ *
398486
+ * The closing half of the gate family — `clarification:closed`,
398487
+ * `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
398488
+ * was already wrapped in a `try` for the synchronous case, and each had the
398489
+ * same hole `deliverGate` had before last round: `emit` discards what a
398490
+ * listener returns, so an `async` host fails by returning a rejected promise
398491
+ * nobody is holding and Node raises an unhandled rejection.
398492
+ *
398493
+ * There is nothing to settle here — the gate is already closed, which is the
398494
+ * whole message — so unlike `deliverGate` this reports to no one. It exists
398495
+ * so that the four informational emits cannot take the process down, and so
398496
+ * the rule is written once rather than four times.
398497
+ */
398498
+ announceGateClosed(event, payload) {
398499
+ for (const listener of this.rawListeners(event)) {
398500
+ try {
398501
+ const out = listener.call(this, payload);
398502
+ if (out && typeof out.then === "function") {
398503
+ void Promise.resolve(out).catch(() => {
398504
+ });
398505
+ }
398506
+ } catch {
398507
+ }
398508
+ }
398509
+ }
398317
398510
  async requestEscalationDecision(ctx, taskId, signal) {
398318
398511
  if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
398319
398512
  if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
@@ -398326,23 +398519,28 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
398326
398519
  if (timeout) clearTimeout(timeout);
398327
398520
  signal?.removeEventListener("abort", onAbort);
398328
398521
  resolve2(decision);
398522
+ this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
398329
398523
  };
398330
398524
  const onAbort = () => settle({ action: "skip", automatic: true });
398331
398525
  signal?.addEventListener("abort", onAbort, { once: true });
398332
398526
  timeout = setTimeout(() => {
398333
- this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
398334
398527
  settle({ action: "timeout" });
398528
+ this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
398335
398529
  }, ESCALATION_DECISION_TIMEOUT_MS);
398336
398530
  this.pendingEscalations.set(requestId, settle);
398337
- this.emit("escalation:decision-required", {
398338
- taskId,
398339
- requestId,
398340
- sectionId: ctx.sectionId,
398341
- sectionTitle: ctx.sectionTitle,
398342
- issues: ctx.issues,
398343
- summary: ctx.summary,
398344
- timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
398345
- });
398531
+ this.deliverGate(
398532
+ "escalation:decision-required",
398533
+ {
398534
+ taskId,
398535
+ requestId,
398536
+ sectionId: ctx.sectionId,
398537
+ sectionTitle: ctx.sectionTitle,
398538
+ issues: ctx.issues,
398539
+ summary: ctx.summary,
398540
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
398541
+ },
398542
+ () => settle({ action: "skip", automatic: true })
398543
+ );
398346
398544
  });
398347
398545
  }
398348
398546
  /**
@@ -398379,6 +398577,73 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
398379
398577
  releasePendingEscalations(action = "skip") {
398380
398578
  for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
398381
398579
  }
398580
+ // ── Clarification: asking instead of assuming ───────────────────────
398581
+ // The same gate shape as the escalation above, and for the same reasons. The
398582
+ // difference that matters: an unanswered escalation SKIPS a section, while an
398583
+ // unanswered question just means the model proceeds on its own reading — so
398584
+ // every non-answer here is benign, and none of them may park the run.
398585
+ /**
398586
+ * Put a questionnaire to whoever is watching, if anybody is.
398587
+ *
398588
+ * Returns `no-listener` rather than waiting when there is nothing attached to
398589
+ * answer — a scheduled run, an API caller with no socket, an autonomous run
398590
+ * that has already said it wants no prompts. That check is the whole reason
398591
+ * this can be offered to the model at all: a tool that might hang a headless
398592
+ * run forever is a tool that cannot be registered.
398593
+ */
398594
+ async askUser(questions, signal) {
398595
+ const none = (outcome) => ({ outcome, answers: [] });
398596
+ if (this.unattended) return none("no-listener");
398597
+ if (this.config.autonomy === "auto") return none("no-listener");
398598
+ if (this.listenerCount("clarification:required") === 0) return none("no-listener");
398599
+ if (signal?.aborted) return none("aborted");
398600
+ const requestId = crypto2.randomUUID();
398601
+ return await new Promise((resolve2) => {
398602
+ let timeout;
398603
+ const settle = (result) => {
398604
+ if (!this.pendingClarifications.delete(requestId)) return;
398605
+ if (timeout) clearTimeout(timeout);
398606
+ signal?.removeEventListener("abort", onAbort);
398607
+ resolve2(result);
398608
+ this.announceGateClosed("clarification:closed", { requestId });
398609
+ };
398610
+ const onAbort = () => settle(none("aborted"));
398611
+ signal?.addEventListener("abort", onAbort, { once: true });
398612
+ timeout = setTimeout(() => {
398613
+ settle(none("timeout"));
398614
+ this.announceGateClosed("clarification:timeout", { requestId });
398615
+ }, CLARIFICATION_TIMEOUT_MS);
398616
+ this.pendingClarifications.set(requestId, settle);
398617
+ this.deliverGate(
398618
+ "clarification:required",
398619
+ { requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
398620
+ () => settle(none("no-listener"))
398621
+ );
398622
+ });
398623
+ }
398624
+ /**
398625
+ * Answer a questionnaire from a REPL / dashboard / cloud listener.
398626
+ *
398627
+ * `requestId` comes back on the `clarification:required` event and is what
398628
+ * makes an answer land on the question that asked. Optional for a host with
398629
+ * only one in flight, matching `resolveEscalation`: without it the oldest
398630
+ * outstanding request is answered, which is correct whenever there is one.
398631
+ */
398632
+ resolveClarification(answers, requestId) {
398633
+ const result = { outcome: "answered", answers };
398634
+ if (requestId) {
398635
+ this.pendingClarifications.get(requestId)?.(result);
398636
+ return;
398637
+ }
398638
+ const oldest = this.pendingClarifications.keys().next();
398639
+ if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
398640
+ }
398641
+ /** Release every parked questionnaire — run teardown, so none outlive the run. */
398642
+ releasePendingClarifications() {
398643
+ for (const settle of Array.from(this.pendingClarifications.values())) {
398644
+ settle({ outcome: "aborted", answers: [] });
398645
+ }
398646
+ }
398382
398647
  /**
398383
398648
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
398384
398649
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -398403,12 +398668,14 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
398403
398668
  const timeout = setTimeout(() => {
398404
398669
  if (this.pendingContextApproval) {
398405
398670
  this.pendingContextApproval = void 0;
398671
+ this.announceGateClosed("context:approval-closed", {});
398406
398672
  resolve2(true);
398407
398673
  }
398408
398674
  }, 12e4);
398409
398675
  this.pendingContextApproval = (approved) => {
398410
398676
  clearTimeout(timeout);
398411
398677
  this.pendingContextApproval = void 0;
398678
+ this.announceGateClosed("context:approval-closed", {});
398412
398679
  resolve2(approved);
398413
398680
  };
398414
398681
  this.emit("context:approval-required", info);
@@ -398599,6 +398866,34 @@ ${input}` }],
398599
398866
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
398600
398867
  this.toolRegistry.register(new BrowserControlTool(controller, release));
398601
398868
  }
398869
+ /**
398870
+ * Declare that this host can put a questionnaire to a person and answer it.
398871
+ *
398872
+ * Explicit, and NOT done at construction, because registering a tool is a
398873
+ * promise the host has to keep. A host that registers `ask_user` without
398874
+ * listening for `clarification:required` gives the model a tool that always
398875
+ * answers "there is nobody watching this run" — while a person sits at the
398876
+ * terminal looking at it. That is worse than not offering the tool at all:
398877
+ * it costs a round trip and then tells the model something false.
398878
+ *
398879
+ * Only `cloud/server` bridges the protocol today. The CLI REPL and the
398880
+ * dashboard do not, so they do not call this, and their models are not shown
398881
+ * a capability nobody can serve. Wiring either of them is a matter of
398882
+ * listening for the event and calling `resolveClarification`, then calling
398883
+ * this.
398884
+ *
398885
+ * Still gated on the same conditions as the call itself, and `askUser`
398886
+ * re-checks them: `setUnattended` can land after this, so the tool can be
398887
+ * registered for a run that later turns out to have nobody on it. Then it
398888
+ * answers `no-listener` and the model proceeds on its own reading, which is
398889
+ * the same defence-in-depth `BrowserControlTool.revoke` provides.
398890
+ */
398891
+ enableClarification() {
398892
+ if (this.unattended) return;
398893
+ if (this.config.autonomy === "auto") return;
398894
+ if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
398895
+ this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
398896
+ }
398602
398897
  registerMediaTools(workspacePath) {
398603
398898
  const denied = new Set(this.config.tools?.disabledTools ?? []);
398604
398899
  const permitted = (name) => !denied.has(name);
@@ -399544,6 +399839,10 @@ ${finalOutput}` : ""
399544
399839
  this.releasePendingEscalations();
399545
399840
  } catch {
399546
399841
  }
399842
+ try {
399843
+ this.releasePendingClarifications();
399844
+ } catch {
399845
+ }
399547
399846
  this.router.restoreTierModels();
399548
399847
  this.router.setRunSignal(void 0);
399549
399848
  if (this.taskAnalyzer) {
@@ -403402,6 +403701,13 @@ var RESTORE_FRAME_MS = 2e3;
403402
403701
  var MAX_HUMAN_INPUT_QUEUE = 64;
403403
403702
  var MAX_CAPTURE_TRANSITION_QUEUE = 16;
403404
403703
  var MAX_WATCH_LIFECYCLE_QUEUE = 16;
403704
+ function notify(deliver) {
403705
+ try {
403706
+ void Promise.resolve(deliver()).catch(() => {
403707
+ });
403708
+ } catch {
403709
+ }
403710
+ }
403405
403711
  var RemoteBrowserController = class {
403406
403712
  provider;
403407
403713
  maxSessions;
@@ -403427,6 +403733,36 @@ var RemoteBrowserController = class {
403427
403733
  controlAnnounced = /* @__PURE__ */ new Map();
403428
403734
  /** An embedder that wants every run's live view, told which run each is. */
403429
403735
  onLiveViewAll;
403736
+ /** Told when a provider session could not be handed back. See `disposeRun`. */
403737
+ onReleaseFailed;
403738
+ /**
403739
+ * Tell the observer a session leaked, without letting it become the leak.
403740
+ *
403741
+ * Guarded, because this runs inside a `.catch` on the release: an observer
403742
+ * that throws — a logging or telemetry sink that is momentarily unavailable
403743
+ * is the ordinary case — would turn its own failure into a fresh rejection
403744
+ * from `disposeRun`, so `endRun` would reject before `forgetRun` and the
403745
+ * fire-and-forget `stopRun` path would raise an unhandled rejection.
403746
+ *
403747
+ * That is precisely the guarantee this whole change claims to preserve: a
403748
+ * release that fails must never fail the run. Reporting it cannot be the
403749
+ * thing that breaks it.
403750
+ *
403751
+ * TWO WAYS TO FAIL, not one. The `try` catches a synchronous throw; an
403752
+ * `async` sink — which is what a telemetry or logging callback usually is —
403753
+ * fails by returning a REJECTED PROMISE, which no `try` around a call that is
403754
+ * never awaited can see. Node then raises an unhandled rejection and can take
403755
+ * the process down: the observer would not merely have failed to report the
403756
+ * leak, it would have killed the run, which is the one outcome this function
403757
+ * exists to prevent, reached through the door the guard did not cover.
403758
+ *
403759
+ * Not awaited, though. The caller is a `.catch` on the release and is not
403760
+ * going to wait for a telemetry round trip; attaching a handler is what makes
403761
+ * the rejection observed, which is the whole requirement.
403762
+ */
403763
+ reportReleaseFailure(runId, sessionId, err, expiresInMs) {
403764
+ notify(() => this.onReleaseFailed?.(runId, sessionId, err, expiresInMs));
403765
+ }
403430
403766
  runs = /* @__PURE__ */ new Map();
403431
403767
  /**
403432
403768
  * Runs that have a pool slot but no browser yet.
@@ -403485,6 +403821,7 @@ var RemoteBrowserController = class {
403485
403821
  const asked = Math.max(1, options.maxSessions ?? 1);
403486
403822
  this.maxSessions = options.provider.isolatesSessions ? asked : 1;
403487
403823
  this.onLiveViewAll = options.onLiveView;
403824
+ this.onReleaseFailed = options.onReleaseFailed;
403488
403825
  this.ready = options.ready;
403489
403826
  }
403490
403827
  /**
@@ -403569,7 +403906,7 @@ var RemoteBrowserController = class {
403569
403906
  const key = `${state.human}:${state.capturing}:${state.confirmed}`;
403570
403907
  if (!force && this.controlAnnounced.get(runId) === key) return;
403571
403908
  this.controlAnnounced.set(runId, key);
403572
- listener(state);
403909
+ notify(() => listener(state));
403573
403910
  }
403574
403911
  /**
403575
403912
  * Tell a run's listener what its browser situation is.
@@ -403580,9 +403917,32 @@ var RemoteBrowserController = class {
403580
403917
  * apart — and would drop the Stop control for the first as if it were the
403581
403918
  * second.
403582
403919
  */
403920
+ /*
403921
+ * Guarded HERE rather than at the call sites, which is the point.
403922
+ *
403923
+ * This is the fifth place in this changeset where a notification could take
403924
+ * down the thing it was notifying about — after the release observer,
403925
+ * `clarification:closed`, `clarification:timeout` and `escalation:timeout`.
403926
+ * The first four were fixed one door at a time, which is how a fifth door
403927
+ * came to exist: the announcement added on the reuse path propagated out of
403928
+ * `open()`, `act()` caught it as an action failure, and every later action
403929
+ * against a perfectly healthy browser was skipped.
403930
+ *
403931
+ * Putting the guard at the source closes all four call sites at once and
403932
+ * leaves no door for a sixth. The worst of them is not the reported one:
403933
+ * `disposeRun` announces `active: false` as its FIRST statement, so a
403934
+ * throwing listener aborted teardown before the screencast was stopped, the
403935
+ * context closed, or the session handed back — leaking the provider session
403936
+ * and never reaching `reportReleaseFailure`. The leak report would have been
403937
+ * skipped by exactly the failure it exists to report.
403938
+ *
403939
+ * Two separate guards, not one around both: these are different listeners —
403940
+ * the per-run panel and the global observer — and one failing must not cost
403941
+ * the other its notification.
403942
+ */
403583
403943
  announceLiveView(runId, liveViewUrl, active) {
403584
- this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
403585
- this.onLiveViewAll?.(runId, liveViewUrl);
403944
+ notify(() => this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} }));
403945
+ notify(() => this.onLiveViewAll?.(runId, liveViewUrl));
403586
403946
  }
403587
403947
  /**
403588
403948
  * Hand back a session the run turned out not to want.
@@ -403793,7 +404153,8 @@ var RemoteBrowserController = class {
403793
404153
  ping?.();
403794
404154
  const deliver = held.onFrame;
403795
404155
  if (deliver) {
403796
- deliver({ data: e3.data, width, height, generation: held.watchGen ?? 0 });
404156
+ const frame = { data: e3.data, width, height, generation: held.watchGen ?? 0 };
404157
+ notify(() => deliver(frame));
403797
404158
  }
403798
404159
  }
403799
404160
  void cdp.send("Page.screencastFrameAck", { sessionId: e3?.sessionId }).catch(() => {
@@ -404322,8 +404683,7 @@ var RemoteBrowserController = class {
404322
404683
  });
404323
404684
  await held.browser.close().catch(() => {
404324
404685
  });
404325
- await this.provider.endSession(held.session.id).catch(() => {
404326
- });
404686
+ await this.provider.endSession(held.session.id).catch((err) => this.reportReleaseFailure(runId, held.session.id, err, held.session.expiresInMs));
404327
404687
  }
404328
404688
  /**
404329
404689
  * Run `disposeRun`, with the run id counted in `closing` until it settles.
@@ -404482,11 +404842,17 @@ var RemoteBrowserController = class {
404482
404842
  if (this.ready === settling) this.ready = void 0;
404483
404843
  }
404484
404844
  const existing = this.runs.get(runId);
404485
- if (existing && !existing.page.isClosed()) return existing;
404845
+ if (existing && !existing.page.isClosed()) {
404846
+ this.announceLiveView(runId, existing.session.liveViewUrl, true);
404847
+ return existing;
404848
+ }
404486
404849
  if (existing && existing.page.isClosed()) {
404487
404850
  const fallback = existing.page.context().pages().filter((p3) => !p3.isClosed()).at(-1);
404488
404851
  if (fallback) {
404489
404852
  await this.adoptPage(runId, existing, fallback);
404853
+ if (this.runs.get(runId) === existing && !existing.abort.signal.aborted) {
404854
+ this.announceLiveView(runId, existing.session.liveViewUrl, true);
404855
+ }
404490
404856
  return existing;
404491
404857
  }
404492
404858
  }
@@ -404535,8 +404901,7 @@ var RemoteBrowserController = class {
404535
404901
  });
404536
404902
  await browser2?.close().catch(() => {
404537
404903
  });
404538
- await this.provider.endSession(session.id).catch(() => {
404539
- });
404904
+ await this.provider.endSession(session.id).catch((releaseErr) => this.reportReleaseFailure(runId, session.id, releaseErr, session.expiresInMs));
404540
404905
  throw err;
404541
404906
  }
404542
404907
  this.announceLiveView(runId, session.liveViewUrl, true);
@@ -404746,6 +405111,139 @@ function isCdpEndpoint(url2) {
404746
405111
 
404747
405112
  // src/browser/remote/steel.ts
404748
405113
  var DEFAULT_BASE = "https://api.steel.dev";
405114
+ function isHostedSteel(url2) {
405115
+ if (!url2) return true;
405116
+ try {
405117
+ return hostKey(new URL(url2).hostname) === hostKey(new URL(DEFAULT_BASE).hostname);
405118
+ } catch {
405119
+ return false;
405120
+ }
405121
+ }
405122
+ function isUsableSteelBase(url2) {
405123
+ try {
405124
+ const u2 = new URL(url2);
405125
+ if (u2.protocol !== "http:" && u2.protocol !== "https:") return false;
405126
+ if (u2.username !== "" || u2.password !== "") return false;
405127
+ const built = new URL(withoutTrailingSlashes(url2) + PROBE_PATH);
405128
+ if (FETCH_BLOCKED_PORTS.has(built.port)) return false;
405129
+ if (built.search !== "" || built.hash !== "") return false;
405130
+ if (!built.pathname.endsWith(PROBE_PATH)) return false;
405131
+ if (isHostedSteel(url2)) {
405132
+ if (built.pathname !== PROBE_PATH) return false;
405133
+ if (u2.protocol !== "https:") return false;
405134
+ if (built.port !== "") return false;
405135
+ }
405136
+ return true;
405137
+ } catch {
405138
+ return false;
405139
+ }
405140
+ }
405141
+ function isUsableSteelKey(apiKey) {
405142
+ for (const ch of apiKey) {
405143
+ const c4 = ch.codePointAt(0) ?? 0;
405144
+ if (c4 === 9) continue;
405145
+ if (c4 >= 32 && c4 <= 126) continue;
405146
+ if (c4 >= 128 && c4 <= 255) continue;
405147
+ return false;
405148
+ }
405149
+ return true;
405150
+ }
405151
+ var API_KEY_HEADER = "steel-api-key";
405152
+ var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
405153
+ "1",
405154
+ "7",
405155
+ "9",
405156
+ "11",
405157
+ "13",
405158
+ "15",
405159
+ "17",
405160
+ "19",
405161
+ "20",
405162
+ "21",
405163
+ "22",
405164
+ "23",
405165
+ "25",
405166
+ "37",
405167
+ "42",
405168
+ "43",
405169
+ "53",
405170
+ "69",
405171
+ "77",
405172
+ "79",
405173
+ "87",
405174
+ "95",
405175
+ "101",
405176
+ "102",
405177
+ "103",
405178
+ "104",
405179
+ "109",
405180
+ "110",
405181
+ "111",
405182
+ "113",
405183
+ "115",
405184
+ "117",
405185
+ "119",
405186
+ "123",
405187
+ "135",
405188
+ "137",
405189
+ "139",
405190
+ "143",
405191
+ "161",
405192
+ "179",
405193
+ "389",
405194
+ "427",
405195
+ "465",
405196
+ "512",
405197
+ "513",
405198
+ "514",
405199
+ "515",
405200
+ "526",
405201
+ "530",
405202
+ "531",
405203
+ "532",
405204
+ "540",
405205
+ "548",
405206
+ "554",
405207
+ "556",
405208
+ "563",
405209
+ "587",
405210
+ "601",
405211
+ "636",
405212
+ "989",
405213
+ "990",
405214
+ "993",
405215
+ "995",
405216
+ "1719",
405217
+ "1720",
405218
+ "1723",
405219
+ "2049",
405220
+ "3659",
405221
+ "4045",
405222
+ "4190",
405223
+ "5060",
405224
+ "5061",
405225
+ "6000",
405226
+ "6566",
405227
+ "6665",
405228
+ "6666",
405229
+ "6667",
405230
+ "6668",
405231
+ "6669",
405232
+ "6679",
405233
+ "6697",
405234
+ "10080"
405235
+ ]);
405236
+ var PROBE_PATH = "/v1/sessions";
405237
+ function withoutTrailingSlashes(value) {
405238
+ let out = value;
405239
+ while (out.endsWith("/")) out = out.slice(0, -1);
405240
+ return out;
405241
+ }
405242
+ function hostKey(hostname2) {
405243
+ let host = hostname2.toLowerCase();
405244
+ while (host.endsWith(".")) host = host.slice(0, -1);
405245
+ return host;
405246
+ }
404749
405247
  var CREATE_TIMEOUT_MS = 6e4;
404750
405248
  var SteelProvider = class {
404751
405249
  name = "Steel";
@@ -404755,9 +405253,7 @@ var SteelProvider = class {
404755
405253
  base;
404756
405254
  apiKey;
404757
405255
  constructor(opts = {}) {
404758
- let base = opts.url || DEFAULT_BASE;
404759
- while (base.endsWith("/")) base = base.slice(0, -1);
404760
- this.base = base;
405256
+ this.base = withoutTrailingSlashes(opts.url || DEFAULT_BASE);
404761
405257
  this.apiKey = opts.apiKey;
404762
405258
  }
404763
405259
  async createSession(signal) {
@@ -404768,14 +405264,15 @@ var SteelProvider = class {
404768
405264
  return {
404769
405265
  id: details.id,
404770
405266
  cdpUrl: details.websocketUrl,
404771
- ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
405267
+ ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {},
405268
+ // Carried through because it explains an otherwise mysterious death. A
405269
+ // session nobody released dies at exactly this number, and without it a
405270
+ // leak looks identical to an orderly shutdown.
405271
+ ...typeof details.timeout === "number" ? { expiresInMs: details.timeout } : {}
404772
405272
  };
404773
405273
  }
404774
405274
  async endSession(id) {
404775
- try {
404776
- await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
404777
- } catch {
404778
- }
405275
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
404779
405276
  }
404780
405277
  async call(method, path32, signal, body) {
404781
405278
  const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
@@ -404786,7 +405283,7 @@ var SteelProvider = class {
404786
405283
  "content-type": "application/json",
404787
405284
  // Sent only when configured — the self-hosted API takes no key, and an
404788
405285
  // empty header is worse than none.
404789
- ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
405286
+ ...this.apiKey ? { [API_KEY_HEADER]: this.apiKey } : {}
404790
405287
  },
404791
405288
  body: JSON.stringify(body),
404792
405289
  signal: composed
@@ -405881,6 +406378,7 @@ object-assign/index.js:
405881
406378
 
405882
406379
  exports.AZURE_BASE_MODELS = AZURE_BASE_MODELS;
405883
406380
  exports.AZURE_BASE_URL_TEMPLATE = AZURE_BASE_URL_TEMPLATE;
406381
+ exports.AskUserTool = AskUserTool;
405884
406382
  exports.AuditLogger = AuditLogger;
405885
406383
  exports.BrowserControlTool = BrowserControlTool;
405886
406384
  exports.BrowserLease = BrowserLease;
@@ -406012,7 +406510,10 @@ exports.inferAzureBaseModel = inferAzureBaseModel;
406012
406510
  exports.inlineRuns = inlineRuns;
406013
406511
  exports.isCdpEndpoint = isCdpEndpoint;
406014
406512
  exports.isDocumentFormat = isDocumentFormat;
406513
+ exports.isHostedSteel = isHostedSteel;
406015
406514
  exports.isMcpToolName = isMcpToolName;
406515
+ exports.isUsableSteelBase = isUsableSteelBase;
406516
+ exports.isUsableSteelKey = isUsableSteelKey;
406016
406517
  exports.matchImageLine = matchImageLine;
406017
406518
  exports.mcpServerPrefix = mcpServerPrefix;
406018
406519
  exports.mcpToolName = mcpToolName;