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.js CHANGED
@@ -11623,7 +11623,7 @@ function reconcilePrice(model, live) {
11623
11623
  }
11624
11624
 
11625
11625
  // src/constants.ts
11626
- var CASCADE_VERSION = "0.80.0";
11626
+ var CASCADE_VERSION = "0.81.0";
11627
11627
  var CASCADE_CONFIG_DIR = ".cascade";
11628
11628
  var CASCADE_MD_FILE = "CASCADE.md";
11629
11629
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -30861,8 +30861,95 @@ function requiredFieldFor(kind, input) {
30861
30861
  }
30862
30862
  }
30863
30863
 
30864
+ // src/tools/ask-user.ts
30865
+ var MAX_QUESTIONS = 5;
30866
+ var MAX_OPTIONS = 6;
30867
+ var AskUserTool = class extends BaseTool {
30868
+ name = "ask_user";
30869
+ 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.";
30870
+ inputSchema = {
30871
+ type: "object",
30872
+ properties: {
30873
+ questions: {
30874
+ type: "array",
30875
+ minItems: 1,
30876
+ maxItems: MAX_QUESTIONS,
30877
+ description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
30878
+ items: {
30879
+ type: "object",
30880
+ properties: {
30881
+ prompt: { type: "string", description: "The question, as you would say it to them." },
30882
+ kind: {
30883
+ type: "string",
30884
+ enum: ["choice", "multi", "text"],
30885
+ description: "choice = pick one, multi = pick any that apply, text = free-form answer."
30886
+ },
30887
+ options: {
30888
+ type: "array",
30889
+ items: { type: "string" },
30890
+ description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
30891
+ }
30892
+ },
30893
+ required: ["prompt", "kind"]
30894
+ }
30895
+ }
30896
+ },
30897
+ required: ["questions"]
30898
+ };
30899
+ ask;
30900
+ constructor(ask) {
30901
+ super();
30902
+ this.ask = ask;
30903
+ }
30904
+ // Asking a question changes nothing, so it needs no approval gate: a prompt
30905
+ // asking permission to show a prompt is a tax on the behaviour we want.
30906
+ isDangerous() {
30907
+ return false;
30908
+ }
30909
+ async execute(input, options) {
30910
+ const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
30911
+ const questions = [];
30912
+ for (const [i, q] of raw.slice(0, MAX_QUESTIONS).entries()) {
30913
+ const item = q ?? {};
30914
+ const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
30915
+ if (!prompt) continue;
30916
+ const kind = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
30917
+ const options_ = Array.isArray(item["options"]) ? item["options"].filter((o) => typeof o === "string" && o.trim() !== "").slice(0, MAX_OPTIONS) : [];
30918
+ const resolved = kind !== "text" && options_.length < 2 ? "text" : kind;
30919
+ questions.push({
30920
+ // Positional, and assigned HERE. Letting the model supply ids invites
30921
+ // two questions sharing one, and the second answer would overwrite the
30922
+ // first with nothing to say it had.
30923
+ id: `q${i + 1}`,
30924
+ prompt,
30925
+ kind: resolved,
30926
+ ...resolved === "text" ? {} : { options: options_ }
30927
+ });
30928
+ }
30929
+ if (questions.length === 0) {
30930
+ return "Error: ask_user needs at least one question with a prompt.";
30931
+ }
30932
+ const result = await this.ask(questions, options.signal);
30933
+ if (result.outcome !== "answered" || result.answers.length === 0) {
30934
+ 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";
30935
+ return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
30936
+ }
30937
+ const byId = new Map(result.answers.map((a) => [a.id, a.value]));
30938
+ const lines = questions.map((q) => {
30939
+ const value = byId.get(q.id);
30940
+ const said = Array.isArray(value) ? value.join(", ") : value;
30941
+ return `${q.prompt}
30942
+ \u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
30943
+ });
30944
+ return `They answered:
30945
+
30946
+ ${lines.join("\n\n")}`;
30947
+ }
30948
+ };
30949
+
30864
30950
  // src/core/cascade.ts
30865
30951
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
30952
+ var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
30866
30953
  function buildContextualPrompt(prompt, history = []) {
30867
30954
  const recent = history.slice(-6);
30868
30955
  if (recent.length === 0) return prompt;
@@ -31141,6 +31228,112 @@ var Cascade = class _Cascade extends EventEmitter {
31141
31228
  // timeout. Same class of bug as the twelve-failover dead-model race: a wave
31142
31229
  // is concurrent, so per-run singletons are never safe inside it.
31143
31230
  pendingEscalations = /* @__PURE__ */ new Map();
31231
+ /**
31232
+ * Questionnaires waiting on a person, keyed by request id.
31233
+ *
31234
+ * Exactly the shape `pendingEscalations` uses, and for the same reason: the
31235
+ * answer, the timeout and the abort all race, and one settle path guarded by
31236
+ * the map delete is what makes the first of them win.
31237
+ */
31238
+ pendingClarifications = /* @__PURE__ */ new Map();
31239
+ /**
31240
+ * Put a gate's opening question to its listeners, and NOTICE when one fails.
31241
+ *
31242
+ * `emit` cannot do this job. It calls listeners synchronously and discards
31243
+ * what they return, so an `async` listener — the ordinary shape for anything
31244
+ * that has to reach a socket or a UI — does not fail in a way `emit` reports:
31245
+ * it returns a REJECTED PROMISE that nobody is holding. The `try` around the
31246
+ * emit sees nothing, the gate is never told delivery failed, and it parks for
31247
+ * its full timeout while Node raises an unhandled rejection that can take the
31248
+ * process down.
31249
+ *
31250
+ * `onFailed` is called for either shape, and it is safe to call more than
31251
+ * once: both gates route it to `settle`, which is guarded by a map delete, so
31252
+ * the first failure wins and the rest are no-ops.
31253
+ *
31254
+ * PER LISTENER, not around the loop. `emit` stops dispatching at the first
31255
+ * throw, so one broken host used to cost a working one its notification —
31256
+ * the same reason `announceLiveView` delivers its two listeners separately.
31257
+ * A synchronous failure settles the gate once the whole snapshot has been
31258
+ * delivered; an asynchronous one settles it when the rejection arrives, which
31259
+ * is still far better than the window-long park it replaces.
31260
+ *
31261
+ * SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
31262
+ * settles the gate, and settling emits the gate's CLOSURE — so failing
31263
+ * mid-dispatch announced "this request is over" and then went on handing the
31264
+ * OPENING event to the listeners after it. A host later in the list would
31265
+ * draw a form whose only closure notification had already gone past, and it
31266
+ * would stand there forever. Deliver to everyone, then decide.
31267
+ *
31268
+ * `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
31269
+ * `emit` was doing, and `once` is the part that bites: `listeners()` hands
31270
+ * back the UNWRAPPED callback, so calling it never runs the wrapper that
31271
+ * deregisters it. A host that asked to be told once would be told every
31272
+ * time — and told through a transport it had set up for one gate only.
31273
+ * `rawListeners()` returns the wrapper, which removes the registration and
31274
+ * passes the underlying listener's return value straight through, so the
31275
+ * promise below is still the one being observed. Verified against Node.
31276
+ *
31277
+ * `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
31278
+ * invokes listeners with the emitter as the receiver, so
31279
+ * `function () { this.resolveClarification(...) }` is a legitimate host — and
31280
+ * calling it bare gave it `this === undefined`, which throws, which this
31281
+ * would then have read as the host failing to receive the question and
31282
+ * settled the gate unanswered. A working listener turned into a delivery
31283
+ * failure by the thing delivering it.
31284
+ */
31285
+ deliverGate(event, payload, onFailed) {
31286
+ let delivered = false;
31287
+ let threw = false;
31288
+ const pending = [];
31289
+ for (const listener of this.rawListeners(event)) {
31290
+ try {
31291
+ const out = listener.call(this, payload);
31292
+ if (out && typeof out.then === "function") {
31293
+ pending.push(Promise.resolve(out).then(() => true, () => false));
31294
+ } else {
31295
+ delivered = true;
31296
+ }
31297
+ } catch {
31298
+ threw = true;
31299
+ }
31300
+ }
31301
+ if (delivered) return;
31302
+ if (pending.length === 0) {
31303
+ if (threw) onFailed();
31304
+ return;
31305
+ }
31306
+ void Promise.all(pending).then((results) => {
31307
+ if (!results.some(Boolean)) onFailed();
31308
+ });
31309
+ }
31310
+ /**
31311
+ * Tell listeners something that is over, and let none of them make it worse.
31312
+ *
31313
+ * The closing half of the gate family — `clarification:closed`,
31314
+ * `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
31315
+ * was already wrapped in a `try` for the synchronous case, and each had the
31316
+ * same hole `deliverGate` had before last round: `emit` discards what a
31317
+ * listener returns, so an `async` host fails by returning a rejected promise
31318
+ * nobody is holding and Node raises an unhandled rejection.
31319
+ *
31320
+ * There is nothing to settle here — the gate is already closed, which is the
31321
+ * whole message — so unlike `deliverGate` this reports to no one. It exists
31322
+ * so that the four informational emits cannot take the process down, and so
31323
+ * the rule is written once rather than four times.
31324
+ */
31325
+ announceGateClosed(event, payload) {
31326
+ for (const listener of this.rawListeners(event)) {
31327
+ try {
31328
+ const out = listener.call(this, payload);
31329
+ if (out && typeof out.then === "function") {
31330
+ void Promise.resolve(out).catch(() => {
31331
+ });
31332
+ }
31333
+ } catch {
31334
+ }
31335
+ }
31336
+ }
31144
31337
  async requestEscalationDecision(ctx, taskId, signal) {
31145
31338
  if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
31146
31339
  if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
@@ -31153,23 +31346,28 @@ var Cascade = class _Cascade extends EventEmitter {
31153
31346
  if (timeout) clearTimeout(timeout);
31154
31347
  signal?.removeEventListener("abort", onAbort);
31155
31348
  resolve2(decision);
31349
+ this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
31156
31350
  };
31157
31351
  const onAbort = () => settle({ action: "skip", automatic: true });
31158
31352
  signal?.addEventListener("abort", onAbort, { once: true });
31159
31353
  timeout = setTimeout(() => {
31160
- this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31161
31354
  settle({ action: "timeout" });
31355
+ this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31162
31356
  }, ESCALATION_DECISION_TIMEOUT_MS);
31163
31357
  this.pendingEscalations.set(requestId, settle);
31164
- this.emit("escalation:decision-required", {
31165
- taskId,
31166
- requestId,
31167
- sectionId: ctx.sectionId,
31168
- sectionTitle: ctx.sectionTitle,
31169
- issues: ctx.issues,
31170
- summary: ctx.summary,
31171
- timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31172
- });
31358
+ this.deliverGate(
31359
+ "escalation:decision-required",
31360
+ {
31361
+ taskId,
31362
+ requestId,
31363
+ sectionId: ctx.sectionId,
31364
+ sectionTitle: ctx.sectionTitle,
31365
+ issues: ctx.issues,
31366
+ summary: ctx.summary,
31367
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31368
+ },
31369
+ () => settle({ action: "skip", automatic: true })
31370
+ );
31173
31371
  });
31174
31372
  }
31175
31373
  /**
@@ -31206,6 +31404,73 @@ var Cascade = class _Cascade extends EventEmitter {
31206
31404
  releasePendingEscalations(action = "skip") {
31207
31405
  for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
31208
31406
  }
31407
+ // ── Clarification: asking instead of assuming ───────────────────────
31408
+ // The same gate shape as the escalation above, and for the same reasons. The
31409
+ // difference that matters: an unanswered escalation SKIPS a section, while an
31410
+ // unanswered question just means the model proceeds on its own reading — so
31411
+ // every non-answer here is benign, and none of them may park the run.
31412
+ /**
31413
+ * Put a questionnaire to whoever is watching, if anybody is.
31414
+ *
31415
+ * Returns `no-listener` rather than waiting when there is nothing attached to
31416
+ * answer — a scheduled run, an API caller with no socket, an autonomous run
31417
+ * that has already said it wants no prompts. That check is the whole reason
31418
+ * this can be offered to the model at all: a tool that might hang a headless
31419
+ * run forever is a tool that cannot be registered.
31420
+ */
31421
+ async askUser(questions, signal) {
31422
+ const none = (outcome) => ({ outcome, answers: [] });
31423
+ if (this.unattended) return none("no-listener");
31424
+ if (this.config.autonomy === "auto") return none("no-listener");
31425
+ if (this.listenerCount("clarification:required") === 0) return none("no-listener");
31426
+ if (signal?.aborted) return none("aborted");
31427
+ const requestId = randomUUID();
31428
+ return await new Promise((resolve2) => {
31429
+ let timeout;
31430
+ const settle = (result) => {
31431
+ if (!this.pendingClarifications.delete(requestId)) return;
31432
+ if (timeout) clearTimeout(timeout);
31433
+ signal?.removeEventListener("abort", onAbort);
31434
+ resolve2(result);
31435
+ this.announceGateClosed("clarification:closed", { requestId });
31436
+ };
31437
+ const onAbort = () => settle(none("aborted"));
31438
+ signal?.addEventListener("abort", onAbort, { once: true });
31439
+ timeout = setTimeout(() => {
31440
+ settle(none("timeout"));
31441
+ this.announceGateClosed("clarification:timeout", { requestId });
31442
+ }, CLARIFICATION_TIMEOUT_MS);
31443
+ this.pendingClarifications.set(requestId, settle);
31444
+ this.deliverGate(
31445
+ "clarification:required",
31446
+ { requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
31447
+ () => settle(none("no-listener"))
31448
+ );
31449
+ });
31450
+ }
31451
+ /**
31452
+ * Answer a questionnaire from a REPL / dashboard / cloud listener.
31453
+ *
31454
+ * `requestId` comes back on the `clarification:required` event and is what
31455
+ * makes an answer land on the question that asked. Optional for a host with
31456
+ * only one in flight, matching `resolveEscalation`: without it the oldest
31457
+ * outstanding request is answered, which is correct whenever there is one.
31458
+ */
31459
+ resolveClarification(answers, requestId) {
31460
+ const result = { outcome: "answered", answers };
31461
+ if (requestId) {
31462
+ this.pendingClarifications.get(requestId)?.(result);
31463
+ return;
31464
+ }
31465
+ const oldest = this.pendingClarifications.keys().next();
31466
+ if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
31467
+ }
31468
+ /** Release every parked questionnaire — run teardown, so none outlive the run. */
31469
+ releasePendingClarifications() {
31470
+ for (const settle of Array.from(this.pendingClarifications.values())) {
31471
+ settle({ outcome: "aborted", answers: [] });
31472
+ }
31473
+ }
31209
31474
  /**
31210
31475
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
31211
31476
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -31230,12 +31495,14 @@ var Cascade = class _Cascade extends EventEmitter {
31230
31495
  const timeout = setTimeout(() => {
31231
31496
  if (this.pendingContextApproval) {
31232
31497
  this.pendingContextApproval = void 0;
31498
+ this.announceGateClosed("context:approval-closed", {});
31233
31499
  resolve2(true);
31234
31500
  }
31235
31501
  }, 12e4);
31236
31502
  this.pendingContextApproval = (approved) => {
31237
31503
  clearTimeout(timeout);
31238
31504
  this.pendingContextApproval = void 0;
31505
+ this.announceGateClosed("context:approval-closed", {});
31239
31506
  resolve2(approved);
31240
31507
  };
31241
31508
  this.emit("context:approval-required", info);
@@ -31426,6 +31693,34 @@ ${input}` }],
31426
31693
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31427
31694
  this.toolRegistry.register(new BrowserControlTool(controller, release));
31428
31695
  }
31696
+ /**
31697
+ * Declare that this host can put a questionnaire to a person and answer it.
31698
+ *
31699
+ * Explicit, and NOT done at construction, because registering a tool is a
31700
+ * promise the host has to keep. A host that registers `ask_user` without
31701
+ * listening for `clarification:required` gives the model a tool that always
31702
+ * answers "there is nobody watching this run" — while a person sits at the
31703
+ * terminal looking at it. That is worse than not offering the tool at all:
31704
+ * it costs a round trip and then tells the model something false.
31705
+ *
31706
+ * Only `cloud/server` bridges the protocol today. The CLI REPL and the
31707
+ * dashboard do not, so they do not call this, and their models are not shown
31708
+ * a capability nobody can serve. Wiring either of them is a matter of
31709
+ * listening for the event and calling `resolveClarification`, then calling
31710
+ * this.
31711
+ *
31712
+ * Still gated on the same conditions as the call itself, and `askUser`
31713
+ * re-checks them: `setUnattended` can land after this, so the tool can be
31714
+ * registered for a run that later turns out to have nobody on it. Then it
31715
+ * answers `no-listener` and the model proceeds on its own reading, which is
31716
+ * the same defence-in-depth `BrowserControlTool.revoke` provides.
31717
+ */
31718
+ enableClarification() {
31719
+ if (this.unattended) return;
31720
+ if (this.config.autonomy === "auto") return;
31721
+ if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
31722
+ this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
31723
+ }
31429
31724
  registerMediaTools(workspacePath) {
31430
31725
  const denied = new Set(this.config.tools?.disabledTools ?? []);
31431
31726
  const permitted = (name) => !denied.has(name);
@@ -32371,6 +32666,10 @@ ${finalOutput}` : ""
32371
32666
  this.releasePendingEscalations();
32372
32667
  } catch {
32373
32668
  }
32669
+ try {
32670
+ this.releasePendingClarifications();
32671
+ } catch {
32672
+ }
32374
32673
  this.router.restoreTierModels();
32375
32674
  this.router.setRunSignal(void 0);
32376
32675
  if (this.taskAnalyzer) {
@@ -35445,6 +35744,13 @@ var RESTORE_FRAME_MS = 2e3;
35445
35744
  var MAX_HUMAN_INPUT_QUEUE = 64;
35446
35745
  var MAX_CAPTURE_TRANSITION_QUEUE = 16;
35447
35746
  var MAX_WATCH_LIFECYCLE_QUEUE = 16;
35747
+ function notify(deliver) {
35748
+ try {
35749
+ void Promise.resolve(deliver()).catch(() => {
35750
+ });
35751
+ } catch {
35752
+ }
35753
+ }
35448
35754
  var RemoteBrowserController = class {
35449
35755
  provider;
35450
35756
  maxSessions;
@@ -35470,6 +35776,36 @@ var RemoteBrowserController = class {
35470
35776
  controlAnnounced = /* @__PURE__ */ new Map();
35471
35777
  /** An embedder that wants every run's live view, told which run each is. */
35472
35778
  onLiveViewAll;
35779
+ /** Told when a provider session could not be handed back. See `disposeRun`. */
35780
+ onReleaseFailed;
35781
+ /**
35782
+ * Tell the observer a session leaked, without letting it become the leak.
35783
+ *
35784
+ * Guarded, because this runs inside a `.catch` on the release: an observer
35785
+ * that throws — a logging or telemetry sink that is momentarily unavailable
35786
+ * is the ordinary case — would turn its own failure into a fresh rejection
35787
+ * from `disposeRun`, so `endRun` would reject before `forgetRun` and the
35788
+ * fire-and-forget `stopRun` path would raise an unhandled rejection.
35789
+ *
35790
+ * That is precisely the guarantee this whole change claims to preserve: a
35791
+ * release that fails must never fail the run. Reporting it cannot be the
35792
+ * thing that breaks it.
35793
+ *
35794
+ * TWO WAYS TO FAIL, not one. The `try` catches a synchronous throw; an
35795
+ * `async` sink — which is what a telemetry or logging callback usually is —
35796
+ * fails by returning a REJECTED PROMISE, which no `try` around a call that is
35797
+ * never awaited can see. Node then raises an unhandled rejection and can take
35798
+ * the process down: the observer would not merely have failed to report the
35799
+ * leak, it would have killed the run, which is the one outcome this function
35800
+ * exists to prevent, reached through the door the guard did not cover.
35801
+ *
35802
+ * Not awaited, though. The caller is a `.catch` on the release and is not
35803
+ * going to wait for a telemetry round trip; attaching a handler is what makes
35804
+ * the rejection observed, which is the whole requirement.
35805
+ */
35806
+ reportReleaseFailure(runId, sessionId, err, expiresInMs) {
35807
+ notify(() => this.onReleaseFailed?.(runId, sessionId, err, expiresInMs));
35808
+ }
35473
35809
  runs = /* @__PURE__ */ new Map();
35474
35810
  /**
35475
35811
  * Runs that have a pool slot but no browser yet.
@@ -35528,6 +35864,7 @@ var RemoteBrowserController = class {
35528
35864
  const asked = Math.max(1, options.maxSessions ?? 1);
35529
35865
  this.maxSessions = options.provider.isolatesSessions ? asked : 1;
35530
35866
  this.onLiveViewAll = options.onLiveView;
35867
+ this.onReleaseFailed = options.onReleaseFailed;
35531
35868
  this.ready = options.ready;
35532
35869
  }
35533
35870
  /**
@@ -35612,7 +35949,7 @@ var RemoteBrowserController = class {
35612
35949
  const key = `${state.human}:${state.capturing}:${state.confirmed}`;
35613
35950
  if (!force && this.controlAnnounced.get(runId) === key) return;
35614
35951
  this.controlAnnounced.set(runId, key);
35615
- listener(state);
35952
+ notify(() => listener(state));
35616
35953
  }
35617
35954
  /**
35618
35955
  * Tell a run's listener what its browser situation is.
@@ -35623,9 +35960,32 @@ var RemoteBrowserController = class {
35623
35960
  * apart — and would drop the Stop control for the first as if it were the
35624
35961
  * second.
35625
35962
  */
35963
+ /*
35964
+ * Guarded HERE rather than at the call sites, which is the point.
35965
+ *
35966
+ * This is the fifth place in this changeset where a notification could take
35967
+ * down the thing it was notifying about — after the release observer,
35968
+ * `clarification:closed`, `clarification:timeout` and `escalation:timeout`.
35969
+ * The first four were fixed one door at a time, which is how a fifth door
35970
+ * came to exist: the announcement added on the reuse path propagated out of
35971
+ * `open()`, `act()` caught it as an action failure, and every later action
35972
+ * against a perfectly healthy browser was skipped.
35973
+ *
35974
+ * Putting the guard at the source closes all four call sites at once and
35975
+ * leaves no door for a sixth. The worst of them is not the reported one:
35976
+ * `disposeRun` announces `active: false` as its FIRST statement, so a
35977
+ * throwing listener aborted teardown before the screencast was stopped, the
35978
+ * context closed, or the session handed back — leaking the provider session
35979
+ * and never reaching `reportReleaseFailure`. The leak report would have been
35980
+ * skipped by exactly the failure it exists to report.
35981
+ *
35982
+ * Two separate guards, not one around both: these are different listeners —
35983
+ * the per-run panel and the global observer — and one failing must not cost
35984
+ * the other its notification.
35985
+ */
35626
35986
  announceLiveView(runId, liveViewUrl, active) {
35627
- this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} });
35628
- this.onLiveViewAll?.(runId, liveViewUrl);
35987
+ notify(() => this.liveViewListeners.get(runId)?.({ active, ...liveViewUrl ? { liveViewUrl } : {} }));
35988
+ notify(() => this.onLiveViewAll?.(runId, liveViewUrl));
35629
35989
  }
35630
35990
  /**
35631
35991
  * Hand back a session the run turned out not to want.
@@ -35836,7 +36196,8 @@ var RemoteBrowserController = class {
35836
36196
  ping?.();
35837
36197
  const deliver = held.onFrame;
35838
36198
  if (deliver) {
35839
- deliver({ data: e.data, width, height, generation: held.watchGen ?? 0 });
36199
+ const frame = { data: e.data, width, height, generation: held.watchGen ?? 0 };
36200
+ notify(() => deliver(frame));
35840
36201
  }
35841
36202
  }
35842
36203
  void cdp.send("Page.screencastFrameAck", { sessionId: e?.sessionId }).catch(() => {
@@ -36365,8 +36726,7 @@ var RemoteBrowserController = class {
36365
36726
  });
36366
36727
  await held.browser.close().catch(() => {
36367
36728
  });
36368
- await this.provider.endSession(held.session.id).catch(() => {
36369
- });
36729
+ await this.provider.endSession(held.session.id).catch((err) => this.reportReleaseFailure(runId, held.session.id, err, held.session.expiresInMs));
36370
36730
  }
36371
36731
  /**
36372
36732
  * Run `disposeRun`, with the run id counted in `closing` until it settles.
@@ -36525,11 +36885,17 @@ var RemoteBrowserController = class {
36525
36885
  if (this.ready === settling) this.ready = void 0;
36526
36886
  }
36527
36887
  const existing = this.runs.get(runId);
36528
- if (existing && !existing.page.isClosed()) return existing;
36888
+ if (existing && !existing.page.isClosed()) {
36889
+ this.announceLiveView(runId, existing.session.liveViewUrl, true);
36890
+ return existing;
36891
+ }
36529
36892
  if (existing && existing.page.isClosed()) {
36530
36893
  const fallback = existing.page.context().pages().filter((p) => !p.isClosed()).at(-1);
36531
36894
  if (fallback) {
36532
36895
  await this.adoptPage(runId, existing, fallback);
36896
+ if (this.runs.get(runId) === existing && !existing.abort.signal.aborted) {
36897
+ this.announceLiveView(runId, existing.session.liveViewUrl, true);
36898
+ }
36533
36899
  return existing;
36534
36900
  }
36535
36901
  }
@@ -36578,8 +36944,7 @@ var RemoteBrowserController = class {
36578
36944
  });
36579
36945
  await browser?.close().catch(() => {
36580
36946
  });
36581
- await this.provider.endSession(session.id).catch(() => {
36582
- });
36947
+ await this.provider.endSession(session.id).catch((releaseErr) => this.reportReleaseFailure(runId, session.id, releaseErr, session.expiresInMs));
36583
36948
  throw err;
36584
36949
  }
36585
36950
  this.announceLiveView(runId, session.liveViewUrl, true);
@@ -36789,6 +37154,139 @@ function isCdpEndpoint(url) {
36789
37154
 
36790
37155
  // src/browser/remote/steel.ts
36791
37156
  var DEFAULT_BASE = "https://api.steel.dev";
37157
+ function isHostedSteel(url) {
37158
+ if (!url) return true;
37159
+ try {
37160
+ return hostKey(new URL(url).hostname) === hostKey(new URL(DEFAULT_BASE).hostname);
37161
+ } catch {
37162
+ return false;
37163
+ }
37164
+ }
37165
+ function isUsableSteelBase(url) {
37166
+ try {
37167
+ const u = new URL(url);
37168
+ if (u.protocol !== "http:" && u.protocol !== "https:") return false;
37169
+ if (u.username !== "" || u.password !== "") return false;
37170
+ const built = new URL(withoutTrailingSlashes(url) + PROBE_PATH);
37171
+ if (FETCH_BLOCKED_PORTS.has(built.port)) return false;
37172
+ if (built.search !== "" || built.hash !== "") return false;
37173
+ if (!built.pathname.endsWith(PROBE_PATH)) return false;
37174
+ if (isHostedSteel(url)) {
37175
+ if (built.pathname !== PROBE_PATH) return false;
37176
+ if (u.protocol !== "https:") return false;
37177
+ if (built.port !== "") return false;
37178
+ }
37179
+ return true;
37180
+ } catch {
37181
+ return false;
37182
+ }
37183
+ }
37184
+ function isUsableSteelKey(apiKey) {
37185
+ for (const ch of apiKey) {
37186
+ const c = ch.codePointAt(0) ?? 0;
37187
+ if (c === 9) continue;
37188
+ if (c >= 32 && c <= 126) continue;
37189
+ if (c >= 128 && c <= 255) continue;
37190
+ return false;
37191
+ }
37192
+ return true;
37193
+ }
37194
+ var API_KEY_HEADER = "steel-api-key";
37195
+ var FETCH_BLOCKED_PORTS = /* @__PURE__ */ new Set([
37196
+ "1",
37197
+ "7",
37198
+ "9",
37199
+ "11",
37200
+ "13",
37201
+ "15",
37202
+ "17",
37203
+ "19",
37204
+ "20",
37205
+ "21",
37206
+ "22",
37207
+ "23",
37208
+ "25",
37209
+ "37",
37210
+ "42",
37211
+ "43",
37212
+ "53",
37213
+ "69",
37214
+ "77",
37215
+ "79",
37216
+ "87",
37217
+ "95",
37218
+ "101",
37219
+ "102",
37220
+ "103",
37221
+ "104",
37222
+ "109",
37223
+ "110",
37224
+ "111",
37225
+ "113",
37226
+ "115",
37227
+ "117",
37228
+ "119",
37229
+ "123",
37230
+ "135",
37231
+ "137",
37232
+ "139",
37233
+ "143",
37234
+ "161",
37235
+ "179",
37236
+ "389",
37237
+ "427",
37238
+ "465",
37239
+ "512",
37240
+ "513",
37241
+ "514",
37242
+ "515",
37243
+ "526",
37244
+ "530",
37245
+ "531",
37246
+ "532",
37247
+ "540",
37248
+ "548",
37249
+ "554",
37250
+ "556",
37251
+ "563",
37252
+ "587",
37253
+ "601",
37254
+ "636",
37255
+ "989",
37256
+ "990",
37257
+ "993",
37258
+ "995",
37259
+ "1719",
37260
+ "1720",
37261
+ "1723",
37262
+ "2049",
37263
+ "3659",
37264
+ "4045",
37265
+ "4190",
37266
+ "5060",
37267
+ "5061",
37268
+ "6000",
37269
+ "6566",
37270
+ "6665",
37271
+ "6666",
37272
+ "6667",
37273
+ "6668",
37274
+ "6669",
37275
+ "6679",
37276
+ "6697",
37277
+ "10080"
37278
+ ]);
37279
+ var PROBE_PATH = "/v1/sessions";
37280
+ function withoutTrailingSlashes(value) {
37281
+ let out = value;
37282
+ while (out.endsWith("/")) out = out.slice(0, -1);
37283
+ return out;
37284
+ }
37285
+ function hostKey(hostname) {
37286
+ let host = hostname.toLowerCase();
37287
+ while (host.endsWith(".")) host = host.slice(0, -1);
37288
+ return host;
37289
+ }
36792
37290
  var CREATE_TIMEOUT_MS = 6e4;
36793
37291
  var SteelProvider = class {
36794
37292
  name = "Steel";
@@ -36798,9 +37296,7 @@ var SteelProvider = class {
36798
37296
  base;
36799
37297
  apiKey;
36800
37298
  constructor(opts = {}) {
36801
- let base = opts.url || DEFAULT_BASE;
36802
- while (base.endsWith("/")) base = base.slice(0, -1);
36803
- this.base = base;
37299
+ this.base = withoutTrailingSlashes(opts.url || DEFAULT_BASE);
36804
37300
  this.apiKey = opts.apiKey;
36805
37301
  }
36806
37302
  async createSession(signal) {
@@ -36811,14 +37307,15 @@ var SteelProvider = class {
36811
37307
  return {
36812
37308
  id: details.id,
36813
37309
  cdpUrl: details.websocketUrl,
36814
- ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {}
37310
+ ...details.debugUrl ? { liveViewUrl: details.debugUrl } : {},
37311
+ // Carried through because it explains an otherwise mysterious death. A
37312
+ // session nobody released dies at exactly this number, and without it a
37313
+ // leak looks identical to an orderly shutdown.
37314
+ ...typeof details.timeout === "number" ? { expiresInMs: details.timeout } : {}
36815
37315
  };
36816
37316
  }
36817
37317
  async endSession(id) {
36818
- try {
36819
- await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
36820
- } catch {
36821
- }
37318
+ await this.call("POST", `/v1/sessions/${encodeURIComponent(id)}/release`, void 0, {});
36822
37319
  }
36823
37320
  async call(method, path30, signal, body) {
36824
37321
  const timeout = AbortSignal.timeout(CREATE_TIMEOUT_MS);
@@ -36829,7 +37326,7 @@ var SteelProvider = class {
36829
37326
  "content-type": "application/json",
36830
37327
  // Sent only when configured — the self-hosted API takes no key, and an
36831
37328
  // empty header is worse than none.
36832
- ...this.apiKey ? { "steel-api-key": this.apiKey } : {}
37329
+ ...this.apiKey ? { [API_KEY_HEADER]: this.apiKey } : {}
36833
37330
  },
36834
37331
  body: JSON.stringify(body),
36835
37332
  signal: composed
@@ -37493,6 +37990,6 @@ function applySyncBundle(bundle, config, cleanup) {
37493
37990
  return next;
37494
37991
  }
37495
37992
 
37496
- export { AZURE_BASE_MODELS, AZURE_BASE_URL_TEMPLATE, AuditLogger, BrowserControlTool, BrowserLease, CASCADE_AUDIT_FILE, CASCADE_CONFIG_DIR, CASCADE_CONFIG_FILE, CASCADE_DASHBOARD_SECRET_FILE, CASCADE_DB_FILE, CASCADE_IGNORE_FILE, CASCADE_KEYSTORE_FILE, CASCADE_MD_FILE, CASCADE_VERSION, CHARS_PER_TOKEN2 as CHARS_PER_TOKEN, COMPLEXITY_T2_COUNT, Cascade, CascadeCancelledError, CascadeConfigSchema, CascadeIgnore, CascadeRouter, CascadeToolError, CloudClient, CodeSearchTool, ConfigManager, CurrentPageTool, DEFAULT_API_PORT, DEFAULT_APPROVAL_REQUIRED, DEFAULT_AUTO_SUMMARIZE_AT, DEFAULT_CLOUD_URL, DEFAULT_CONTEXT_LIMIT, DEFAULT_DASHBOARD_PORT, DEFAULT_MAX_SESSION_MESSAGES, DEFAULT_RETENTION_DAYS, DEFAULT_THEME, DOCUMENT_MIME, DashboardServer, DependencyScheduler, FileMcpOAuthStore, GLOBAL_CONFIG_DIR, GLOBAL_CREDENTIALS_FILE, GLOBAL_DB_FILE, GLOBAL_KEYSTORE_FILE, GLOBAL_RUNTIME_DB_FILE, GenerateDocumentTool, GenericCdpProvider, GraphRetriever, GraphSearchTool, HooksRunner, Keystore, LLMReranker, LM_STUDIO_BASE_URL, MAX_PAGE_TEXT_CHARS, MCP_TOOL_PREFIX, MODELS, McpClient, McpOAuthProvider, MemoryStore, OLLAMA_BASE_URL, OpenAICompatibleEmbedder, PROVIDER_DISPLAY_NAMES, RESUME_SCHEMA_VERSION, RemoteBrowserController, ResumeStore, Retriever, SqliteVectorStore, SteelProvider, T1Administrator, T1_MODEL_PRIORITY, T2Manager, T2_MODEL_PRIORITY, T3Worker, T3_MODEL_PRIORITY, THEME_NAMES, TOOL_NAMES, TaskGraphValidationError, TaskScheduler, Telemetry, ToolRegistry, VISION_MODEL_PRIORITY, WorkspaceIndex, applyEndpointEdit, applyProviderApiKey, applyProviderCredential, applySettingsCredentials, applySettingsPayload, applySyncBundle, azureModelForDeployment, beginMcpOAuth, buildDocumentTools, buildManifest, buildSessionTranscript, bytesToBase64, cagCharBudget, chartKind, chartToTableRows, chatCompleterFromProviders, chunkCode, chunkText2 as chunkText, commitSettings, compileSectionGraph, compileSubtaskGraph, compileTaskGraph, completeMcpOAuth, connectMcpWithLoopbackOAuth, createCascade, credentialDispositionForEdit, credentialEndpointIdentity, decryptJSON as decryptSyncBlob, diffManifest, disambiguateMcpServerNames, discoverMcpAuthServer, discoverMcpTools, distillSessionFacts, embedderFromProviders, encryptJSON as encryptSyncBlob, endpointFromSettingsPayload, evaluateAcceptance, explainRefusal, extractCharts, failures, fileExt, fileOAuthProvider, gatherSyncBundle, hasDefaultEndpoint, hasProviderCredential, hasUsableProvider, hashContent, heuristicCodeChunker, inferAzureBaseModel, inlineRuns, isCdpEndpoint, isDocumentFormat, isMcpToolName, matchImageLine, mcpServerPrefix, mcpToolName, nodeHttpFetch, normalizeEndpoint, parseBlocks, parseChartSpec, parseDelimited, parseRankOrder, parseSlide, planRetrieval, preferIpv4Host, priorAzureRow, reciprocalRankFusion, refreshMcpToken, removeMcpServerDenials, renderDocument, renderDocx, renderPptx, renderXlsx, runCascade, sameAzureEndpoint, sameCredentialEndpoint, sameEndpoint, sessionWorthRemembering, settingsSnapshot, sniffImage, splitSlides, streamCascade, stripInline, summarizeCompleted, undecided, uniqueMcpServerName };
37993
+ export { AZURE_BASE_MODELS, AZURE_BASE_URL_TEMPLATE, AskUserTool, AuditLogger, BrowserControlTool, BrowserLease, CASCADE_AUDIT_FILE, CASCADE_CONFIG_DIR, CASCADE_CONFIG_FILE, CASCADE_DASHBOARD_SECRET_FILE, CASCADE_DB_FILE, CASCADE_IGNORE_FILE, CASCADE_KEYSTORE_FILE, CASCADE_MD_FILE, CASCADE_VERSION, CHARS_PER_TOKEN2 as CHARS_PER_TOKEN, COMPLEXITY_T2_COUNT, Cascade, CascadeCancelledError, CascadeConfigSchema, CascadeIgnore, CascadeRouter, CascadeToolError, CloudClient, CodeSearchTool, ConfigManager, CurrentPageTool, DEFAULT_API_PORT, DEFAULT_APPROVAL_REQUIRED, DEFAULT_AUTO_SUMMARIZE_AT, DEFAULT_CLOUD_URL, DEFAULT_CONTEXT_LIMIT, DEFAULT_DASHBOARD_PORT, DEFAULT_MAX_SESSION_MESSAGES, DEFAULT_RETENTION_DAYS, DEFAULT_THEME, DOCUMENT_MIME, DashboardServer, DependencyScheduler, FileMcpOAuthStore, GLOBAL_CONFIG_DIR, GLOBAL_CREDENTIALS_FILE, GLOBAL_DB_FILE, GLOBAL_KEYSTORE_FILE, GLOBAL_RUNTIME_DB_FILE, GenerateDocumentTool, GenericCdpProvider, GraphRetriever, GraphSearchTool, HooksRunner, Keystore, LLMReranker, LM_STUDIO_BASE_URL, MAX_PAGE_TEXT_CHARS, MCP_TOOL_PREFIX, MODELS, McpClient, McpOAuthProvider, MemoryStore, OLLAMA_BASE_URL, OpenAICompatibleEmbedder, PROVIDER_DISPLAY_NAMES, RESUME_SCHEMA_VERSION, RemoteBrowserController, ResumeStore, Retriever, SqliteVectorStore, SteelProvider, T1Administrator, T1_MODEL_PRIORITY, T2Manager, T2_MODEL_PRIORITY, T3Worker, T3_MODEL_PRIORITY, THEME_NAMES, TOOL_NAMES, TaskGraphValidationError, TaskScheduler, Telemetry, ToolRegistry, VISION_MODEL_PRIORITY, WorkspaceIndex, applyEndpointEdit, applyProviderApiKey, applyProviderCredential, applySettingsCredentials, applySettingsPayload, applySyncBundle, azureModelForDeployment, beginMcpOAuth, buildDocumentTools, buildManifest, buildSessionTranscript, bytesToBase64, cagCharBudget, chartKind, chartToTableRows, chatCompleterFromProviders, chunkCode, chunkText2 as chunkText, commitSettings, compileSectionGraph, compileSubtaskGraph, compileTaskGraph, completeMcpOAuth, connectMcpWithLoopbackOAuth, createCascade, credentialDispositionForEdit, credentialEndpointIdentity, decryptJSON as decryptSyncBlob, diffManifest, disambiguateMcpServerNames, discoverMcpAuthServer, discoverMcpTools, distillSessionFacts, embedderFromProviders, encryptJSON as encryptSyncBlob, endpointFromSettingsPayload, evaluateAcceptance, explainRefusal, extractCharts, failures, fileExt, fileOAuthProvider, gatherSyncBundle, hasDefaultEndpoint, hasProviderCredential, hasUsableProvider, hashContent, heuristicCodeChunker, inferAzureBaseModel, inlineRuns, isCdpEndpoint, isDocumentFormat, isHostedSteel, isMcpToolName, isUsableSteelBase, isUsableSteelKey, matchImageLine, mcpServerPrefix, mcpToolName, nodeHttpFetch, normalizeEndpoint, parseBlocks, parseChartSpec, parseDelimited, parseRankOrder, parseSlide, planRetrieval, preferIpv4Host, priorAzureRow, reciprocalRankFusion, refreshMcpToken, removeMcpServerDenials, renderDocument, renderDocx, renderPptx, renderXlsx, runCascade, sameAzureEndpoint, sameCredentialEndpoint, sameEndpoint, sessionWorthRemembering, settingsSnapshot, sniffImage, splitSlides, streamCascade, stripInline, summarizeCompleted, undecided, uniqueMcpServerName };
37497
37994
  //# sourceMappingURL=index.js.map
37498
37995
  //# sourceMappingURL=index.js.map