cascade-ai 0.79.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -1849,7 +1849,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1849
1849
  var init_constants = __esm({
1850
1850
  "src/constants.ts"() {
1851
1851
  init_pricing();
1852
- CASCADE_VERSION = "0.79.0";
1852
+ CASCADE_VERSION = "0.81.0";
1853
1853
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1854
1854
  CASCADE_DB_FILE = ".cascade/memory.db";
1855
1855
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -31444,8 +31444,95 @@ function requiredFieldFor(kind, input) {
31444
31444
  }
31445
31445
  }
31446
31446
 
31447
+ // src/tools/ask-user.ts
31448
+ var MAX_QUESTIONS = 5;
31449
+ var MAX_OPTIONS = 6;
31450
+ var AskUserTool = class extends BaseTool {
31451
+ name = "ask_user";
31452
+ 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.";
31453
+ inputSchema = {
31454
+ type: "object",
31455
+ properties: {
31456
+ questions: {
31457
+ type: "array",
31458
+ minItems: 1,
31459
+ maxItems: MAX_QUESTIONS,
31460
+ description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
31461
+ items: {
31462
+ type: "object",
31463
+ properties: {
31464
+ prompt: { type: "string", description: "The question, as you would say it to them." },
31465
+ kind: {
31466
+ type: "string",
31467
+ enum: ["choice", "multi", "text"],
31468
+ description: "choice = pick one, multi = pick any that apply, text = free-form answer."
31469
+ },
31470
+ options: {
31471
+ type: "array",
31472
+ items: { type: "string" },
31473
+ description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
31474
+ }
31475
+ },
31476
+ required: ["prompt", "kind"]
31477
+ }
31478
+ }
31479
+ },
31480
+ required: ["questions"]
31481
+ };
31482
+ ask;
31483
+ constructor(ask) {
31484
+ super();
31485
+ this.ask = ask;
31486
+ }
31487
+ // Asking a question changes nothing, so it needs no approval gate: a prompt
31488
+ // asking permission to show a prompt is a tax on the behaviour we want.
31489
+ isDangerous() {
31490
+ return false;
31491
+ }
31492
+ async execute(input, options) {
31493
+ const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
31494
+ const questions = [];
31495
+ for (const [i, q] of raw.slice(0, MAX_QUESTIONS).entries()) {
31496
+ const item = q ?? {};
31497
+ const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
31498
+ if (!prompt) continue;
31499
+ const kind = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
31500
+ const options_ = Array.isArray(item["options"]) ? item["options"].filter((o) => typeof o === "string" && o.trim() !== "").slice(0, MAX_OPTIONS) : [];
31501
+ const resolved = kind !== "text" && options_.length < 2 ? "text" : kind;
31502
+ questions.push({
31503
+ // Positional, and assigned HERE. Letting the model supply ids invites
31504
+ // two questions sharing one, and the second answer would overwrite the
31505
+ // first with nothing to say it had.
31506
+ id: `q${i + 1}`,
31507
+ prompt,
31508
+ kind: resolved,
31509
+ ...resolved === "text" ? {} : { options: options_ }
31510
+ });
31511
+ }
31512
+ if (questions.length === 0) {
31513
+ return "Error: ask_user needs at least one question with a prompt.";
31514
+ }
31515
+ const result = await this.ask(questions, options.signal);
31516
+ if (result.outcome !== "answered" || result.answers.length === 0) {
31517
+ 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";
31518
+ return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
31519
+ }
31520
+ const byId = new Map(result.answers.map((a) => [a.id, a.value]));
31521
+ const lines = questions.map((q) => {
31522
+ const value = byId.get(q.id);
31523
+ const said = Array.isArray(value) ? value.join(", ") : value;
31524
+ return `${q.prompt}
31525
+ \u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
31526
+ });
31527
+ return `They answered:
31528
+
31529
+ ${lines.join("\n\n")}`;
31530
+ }
31531
+ };
31532
+
31447
31533
  // src/core/cascade.ts
31448
31534
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
31535
+ var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
31449
31536
  function buildContextualPrompt(prompt, history = []) {
31450
31537
  const recent = history.slice(-6);
31451
31538
  if (recent.length === 0) return prompt;
@@ -31724,6 +31811,112 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31724
31811
  // timeout. Same class of bug as the twelve-failover dead-model race: a wave
31725
31812
  // is concurrent, so per-run singletons are never safe inside it.
31726
31813
  pendingEscalations = /* @__PURE__ */ new Map();
31814
+ /**
31815
+ * Questionnaires waiting on a person, keyed by request id.
31816
+ *
31817
+ * Exactly the shape `pendingEscalations` uses, and for the same reason: the
31818
+ * answer, the timeout and the abort all race, and one settle path guarded by
31819
+ * the map delete is what makes the first of them win.
31820
+ */
31821
+ pendingClarifications = /* @__PURE__ */ new Map();
31822
+ /**
31823
+ * Put a gate's opening question to its listeners, and NOTICE when one fails.
31824
+ *
31825
+ * `emit` cannot do this job. It calls listeners synchronously and discards
31826
+ * what they return, so an `async` listener — the ordinary shape for anything
31827
+ * that has to reach a socket or a UI — does not fail in a way `emit` reports:
31828
+ * it returns a REJECTED PROMISE that nobody is holding. The `try` around the
31829
+ * emit sees nothing, the gate is never told delivery failed, and it parks for
31830
+ * its full timeout while Node raises an unhandled rejection that can take the
31831
+ * process down.
31832
+ *
31833
+ * `onFailed` is called for either shape, and it is safe to call more than
31834
+ * once: both gates route it to `settle`, which is guarded by a map delete, so
31835
+ * the first failure wins and the rest are no-ops.
31836
+ *
31837
+ * PER LISTENER, not around the loop. `emit` stops dispatching at the first
31838
+ * throw, so one broken host used to cost a working one its notification —
31839
+ * the same reason `announceLiveView` delivers its two listeners separately.
31840
+ * A synchronous failure settles the gate once the whole snapshot has been
31841
+ * delivered; an asynchronous one settles it when the rejection arrives, which
31842
+ * is still far better than the window-long park it replaces.
31843
+ *
31844
+ * SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
31845
+ * settles the gate, and settling emits the gate's CLOSURE — so failing
31846
+ * mid-dispatch announced "this request is over" and then went on handing the
31847
+ * OPENING event to the listeners after it. A host later in the list would
31848
+ * draw a form whose only closure notification had already gone past, and it
31849
+ * would stand there forever. Deliver to everyone, then decide.
31850
+ *
31851
+ * `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
31852
+ * `emit` was doing, and `once` is the part that bites: `listeners()` hands
31853
+ * back the UNWRAPPED callback, so calling it never runs the wrapper that
31854
+ * deregisters it. A host that asked to be told once would be told every
31855
+ * time — and told through a transport it had set up for one gate only.
31856
+ * `rawListeners()` returns the wrapper, which removes the registration and
31857
+ * passes the underlying listener's return value straight through, so the
31858
+ * promise below is still the one being observed. Verified against Node.
31859
+ *
31860
+ * `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
31861
+ * invokes listeners with the emitter as the receiver, so
31862
+ * `function () { this.resolveClarification(...) }` is a legitimate host — and
31863
+ * calling it bare gave it `this === undefined`, which throws, which this
31864
+ * would then have read as the host failing to receive the question and
31865
+ * settled the gate unanswered. A working listener turned into a delivery
31866
+ * failure by the thing delivering it.
31867
+ */
31868
+ deliverGate(event, payload, onFailed) {
31869
+ let delivered = false;
31870
+ let threw = false;
31871
+ const pending = [];
31872
+ for (const listener of this.rawListeners(event)) {
31873
+ try {
31874
+ const out = listener.call(this, payload);
31875
+ if (out && typeof out.then === "function") {
31876
+ pending.push(Promise.resolve(out).then(() => true, () => false));
31877
+ } else {
31878
+ delivered = true;
31879
+ }
31880
+ } catch {
31881
+ threw = true;
31882
+ }
31883
+ }
31884
+ if (delivered) return;
31885
+ if (pending.length === 0) {
31886
+ if (threw) onFailed();
31887
+ return;
31888
+ }
31889
+ void Promise.all(pending).then((results) => {
31890
+ if (!results.some(Boolean)) onFailed();
31891
+ });
31892
+ }
31893
+ /**
31894
+ * Tell listeners something that is over, and let none of them make it worse.
31895
+ *
31896
+ * The closing half of the gate family — `clarification:closed`,
31897
+ * `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
31898
+ * was already wrapped in a `try` for the synchronous case, and each had the
31899
+ * same hole `deliverGate` had before last round: `emit` discards what a
31900
+ * listener returns, so an `async` host fails by returning a rejected promise
31901
+ * nobody is holding and Node raises an unhandled rejection.
31902
+ *
31903
+ * There is nothing to settle here — the gate is already closed, which is the
31904
+ * whole message — so unlike `deliverGate` this reports to no one. It exists
31905
+ * so that the four informational emits cannot take the process down, and so
31906
+ * the rule is written once rather than four times.
31907
+ */
31908
+ announceGateClosed(event, payload) {
31909
+ for (const listener of this.rawListeners(event)) {
31910
+ try {
31911
+ const out = listener.call(this, payload);
31912
+ if (out && typeof out.then === "function") {
31913
+ void Promise.resolve(out).catch(() => {
31914
+ });
31915
+ }
31916
+ } catch {
31917
+ }
31918
+ }
31919
+ }
31727
31920
  async requestEscalationDecision(ctx, taskId, signal) {
31728
31921
  if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
31729
31922
  if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
@@ -31736,23 +31929,28 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31736
31929
  if (timeout) clearTimeout(timeout);
31737
31930
  signal?.removeEventListener("abort", onAbort);
31738
31931
  resolve2(decision);
31932
+ this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
31739
31933
  };
31740
31934
  const onAbort = () => settle({ action: "skip", automatic: true });
31741
31935
  signal?.addEventListener("abort", onAbort, { once: true });
31742
31936
  timeout = setTimeout(() => {
31743
- this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31744
31937
  settle({ action: "timeout" });
31938
+ this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31745
31939
  }, ESCALATION_DECISION_TIMEOUT_MS);
31746
31940
  this.pendingEscalations.set(requestId, settle);
31747
- this.emit("escalation:decision-required", {
31748
- taskId,
31749
- requestId,
31750
- sectionId: ctx.sectionId,
31751
- sectionTitle: ctx.sectionTitle,
31752
- issues: ctx.issues,
31753
- summary: ctx.summary,
31754
- timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31755
- });
31941
+ this.deliverGate(
31942
+ "escalation:decision-required",
31943
+ {
31944
+ taskId,
31945
+ requestId,
31946
+ sectionId: ctx.sectionId,
31947
+ sectionTitle: ctx.sectionTitle,
31948
+ issues: ctx.issues,
31949
+ summary: ctx.summary,
31950
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31951
+ },
31952
+ () => settle({ action: "skip", automatic: true })
31953
+ );
31756
31954
  });
31757
31955
  }
31758
31956
  /**
@@ -31789,6 +31987,73 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31789
31987
  releasePendingEscalations(action = "skip") {
31790
31988
  for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
31791
31989
  }
31990
+ // ── Clarification: asking instead of assuming ───────────────────────
31991
+ // The same gate shape as the escalation above, and for the same reasons. The
31992
+ // difference that matters: an unanswered escalation SKIPS a section, while an
31993
+ // unanswered question just means the model proceeds on its own reading — so
31994
+ // every non-answer here is benign, and none of them may park the run.
31995
+ /**
31996
+ * Put a questionnaire to whoever is watching, if anybody is.
31997
+ *
31998
+ * Returns `no-listener` rather than waiting when there is nothing attached to
31999
+ * answer — a scheduled run, an API caller with no socket, an autonomous run
32000
+ * that has already said it wants no prompts. That check is the whole reason
32001
+ * this can be offered to the model at all: a tool that might hang a headless
32002
+ * run forever is a tool that cannot be registered.
32003
+ */
32004
+ async askUser(questions, signal) {
32005
+ const none = (outcome) => ({ outcome, answers: [] });
32006
+ if (this.unattended) return none("no-listener");
32007
+ if (this.config.autonomy === "auto") return none("no-listener");
32008
+ if (this.listenerCount("clarification:required") === 0) return none("no-listener");
32009
+ if (signal?.aborted) return none("aborted");
32010
+ const requestId = crypto.randomUUID();
32011
+ return await new Promise((resolve2) => {
32012
+ let timeout;
32013
+ const settle = (result) => {
32014
+ if (!this.pendingClarifications.delete(requestId)) return;
32015
+ if (timeout) clearTimeout(timeout);
32016
+ signal?.removeEventListener("abort", onAbort);
32017
+ resolve2(result);
32018
+ this.announceGateClosed("clarification:closed", { requestId });
32019
+ };
32020
+ const onAbort = () => settle(none("aborted"));
32021
+ signal?.addEventListener("abort", onAbort, { once: true });
32022
+ timeout = setTimeout(() => {
32023
+ settle(none("timeout"));
32024
+ this.announceGateClosed("clarification:timeout", { requestId });
32025
+ }, CLARIFICATION_TIMEOUT_MS);
32026
+ this.pendingClarifications.set(requestId, settle);
32027
+ this.deliverGate(
32028
+ "clarification:required",
32029
+ { requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
32030
+ () => settle(none("no-listener"))
32031
+ );
32032
+ });
32033
+ }
32034
+ /**
32035
+ * Answer a questionnaire from a REPL / dashboard / cloud listener.
32036
+ *
32037
+ * `requestId` comes back on the `clarification:required` event and is what
32038
+ * makes an answer land on the question that asked. Optional for a host with
32039
+ * only one in flight, matching `resolveEscalation`: without it the oldest
32040
+ * outstanding request is answered, which is correct whenever there is one.
32041
+ */
32042
+ resolveClarification(answers, requestId) {
32043
+ const result = { outcome: "answered", answers };
32044
+ if (requestId) {
32045
+ this.pendingClarifications.get(requestId)?.(result);
32046
+ return;
32047
+ }
32048
+ const oldest = this.pendingClarifications.keys().next();
32049
+ if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
32050
+ }
32051
+ /** Release every parked questionnaire — run teardown, so none outlive the run. */
32052
+ releasePendingClarifications() {
32053
+ for (const settle of Array.from(this.pendingClarifications.values())) {
32054
+ settle({ outcome: "aborted", answers: [] });
32055
+ }
32056
+ }
31792
32057
  /**
31793
32058
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
31794
32059
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -31813,12 +32078,14 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
31813
32078
  const timeout = setTimeout(() => {
31814
32079
  if (this.pendingContextApproval) {
31815
32080
  this.pendingContextApproval = void 0;
32081
+ this.announceGateClosed("context:approval-closed", {});
31816
32082
  resolve2(true);
31817
32083
  }
31818
32084
  }, 12e4);
31819
32085
  this.pendingContextApproval = (approved) => {
31820
32086
  clearTimeout(timeout);
31821
32087
  this.pendingContextApproval = void 0;
32088
+ this.announceGateClosed("context:approval-closed", {});
31822
32089
  resolve2(approved);
31823
32090
  };
31824
32091
  this.emit("context:approval-required", info);
@@ -32009,6 +32276,34 @@ ${input}` }],
32009
32276
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
32010
32277
  this.toolRegistry.register(new BrowserControlTool(controller, release));
32011
32278
  }
32279
+ /**
32280
+ * Declare that this host can put a questionnaire to a person and answer it.
32281
+ *
32282
+ * Explicit, and NOT done at construction, because registering a tool is a
32283
+ * promise the host has to keep. A host that registers `ask_user` without
32284
+ * listening for `clarification:required` gives the model a tool that always
32285
+ * answers "there is nobody watching this run" — while a person sits at the
32286
+ * terminal looking at it. That is worse than not offering the tool at all:
32287
+ * it costs a round trip and then tells the model something false.
32288
+ *
32289
+ * Only `cloud/server` bridges the protocol today. The CLI REPL and the
32290
+ * dashboard do not, so they do not call this, and their models are not shown
32291
+ * a capability nobody can serve. Wiring either of them is a matter of
32292
+ * listening for the event and calling `resolveClarification`, then calling
32293
+ * this.
32294
+ *
32295
+ * Still gated on the same conditions as the call itself, and `askUser`
32296
+ * re-checks them: `setUnattended` can land after this, so the tool can be
32297
+ * registered for a run that later turns out to have nobody on it. Then it
32298
+ * answers `no-listener` and the model proceeds on its own reading, which is
32299
+ * the same defence-in-depth `BrowserControlTool.revoke` provides.
32300
+ */
32301
+ enableClarification() {
32302
+ if (this.unattended) return;
32303
+ if (this.config.autonomy === "auto") return;
32304
+ if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
32305
+ this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
32306
+ }
32012
32307
  registerMediaTools(workspacePath) {
32013
32308
  const denied = new Set(this.config.tools?.disabledTools ?? []);
32014
32309
  const permitted = (name) => !denied.has(name);
@@ -32954,6 +33249,10 @@ ${finalOutput}` : ""
32954
33249
  this.releasePendingEscalations();
32955
33250
  } catch {
32956
33251
  }
33252
+ try {
33253
+ this.releasePendingClarifications();
33254
+ } catch {
33255
+ }
32957
33256
  this.router.restoreTierModels();
32958
33257
  this.router.setRunSignal(void 0);
32959
33258
  if (this.taskAnalyzer) {