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/cli.js CHANGED
@@ -1796,7 +1796,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1796
1796
  var init_constants = __esm({
1797
1797
  "src/constants.ts"() {
1798
1798
  init_pricing();
1799
- CASCADE_VERSION = "0.80.0";
1799
+ CASCADE_VERSION = "0.81.0";
1800
1800
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1801
1801
  CASCADE_DB_FILE = ".cascade/memory.db";
1802
1802
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -31391,8 +31391,95 @@ function requiredFieldFor(kind, input) {
31391
31391
  }
31392
31392
  }
31393
31393
 
31394
+ // src/tools/ask-user.ts
31395
+ var MAX_QUESTIONS = 5;
31396
+ var MAX_OPTIONS = 6;
31397
+ var AskUserTool = class extends BaseTool {
31398
+ name = "ask_user";
31399
+ 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.";
31400
+ inputSchema = {
31401
+ type: "object",
31402
+ properties: {
31403
+ questions: {
31404
+ type: "array",
31405
+ minItems: 1,
31406
+ maxItems: MAX_QUESTIONS,
31407
+ description: `The questions to ask together (at most ${MAX_QUESTIONS}).`,
31408
+ items: {
31409
+ type: "object",
31410
+ properties: {
31411
+ prompt: { type: "string", description: "The question, as you would say it to them." },
31412
+ kind: {
31413
+ type: "string",
31414
+ enum: ["choice", "multi", "text"],
31415
+ description: "choice = pick one, multi = pick any that apply, text = free-form answer."
31416
+ },
31417
+ options: {
31418
+ type: "array",
31419
+ items: { type: "string" },
31420
+ description: `The answers to offer, for choice and multi (at most ${MAX_OPTIONS}). Omit for text.`
31421
+ }
31422
+ },
31423
+ required: ["prompt", "kind"]
31424
+ }
31425
+ }
31426
+ },
31427
+ required: ["questions"]
31428
+ };
31429
+ ask;
31430
+ constructor(ask) {
31431
+ super();
31432
+ this.ask = ask;
31433
+ }
31434
+ // Asking a question changes nothing, so it needs no approval gate: a prompt
31435
+ // asking permission to show a prompt is a tax on the behaviour we want.
31436
+ isDangerous() {
31437
+ return false;
31438
+ }
31439
+ async execute(input, options) {
31440
+ const raw = Array.isArray(input["questions"]) ? input["questions"] : [];
31441
+ const questions = [];
31442
+ for (const [i, q] of raw.slice(0, MAX_QUESTIONS).entries()) {
31443
+ const item = q ?? {};
31444
+ const prompt = typeof item["prompt"] === "string" ? item["prompt"].trim() : "";
31445
+ if (!prompt) continue;
31446
+ const kind = item["kind"] === "multi" || item["kind"] === "text" ? item["kind"] : "choice";
31447
+ const options_ = Array.isArray(item["options"]) ? item["options"].filter((o) => typeof o === "string" && o.trim() !== "").slice(0, MAX_OPTIONS) : [];
31448
+ const resolved = kind !== "text" && options_.length < 2 ? "text" : kind;
31449
+ questions.push({
31450
+ // Positional, and assigned HERE. Letting the model supply ids invites
31451
+ // two questions sharing one, and the second answer would overwrite the
31452
+ // first with nothing to say it had.
31453
+ id: `q${i + 1}`,
31454
+ prompt,
31455
+ kind: resolved,
31456
+ ...resolved === "text" ? {} : { options: options_ }
31457
+ });
31458
+ }
31459
+ if (questions.length === 0) {
31460
+ return "Error: ask_user needs at least one question with a prompt.";
31461
+ }
31462
+ const result = await this.ask(questions, options.signal);
31463
+ if (result.outcome !== "answered" || result.answers.length === 0) {
31464
+ 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";
31465
+ return `${why}. Proceed on your best reading of the request, and say plainly which assumption you made.`;
31466
+ }
31467
+ const byId = new Map(result.answers.map((a) => [a.id, a.value]));
31468
+ const lines = questions.map((q) => {
31469
+ const value = byId.get(q.id);
31470
+ const said = Array.isArray(value) ? value.join(", ") : value;
31471
+ return `${q.prompt}
31472
+ \u2192 ${said && said.length > 0 ? said : "(no answer given)"}`;
31473
+ });
31474
+ return `They answered:
31475
+
31476
+ ${lines.join("\n\n")}`;
31477
+ }
31478
+ };
31479
+
31394
31480
  // src/core/cascade.ts
31395
31481
  var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
31482
+ var CLARIFICATION_TIMEOUT_MS = 2 * 6e4;
31396
31483
  function buildContextualPrompt(prompt, history = []) {
31397
31484
  const recent = history.slice(-6);
31398
31485
  if (recent.length === 0) return prompt;
@@ -31671,6 +31758,112 @@ var Cascade = class _Cascade extends EventEmitter {
31671
31758
  // timeout. Same class of bug as the twelve-failover dead-model race: a wave
31672
31759
  // is concurrent, so per-run singletons are never safe inside it.
31673
31760
  pendingEscalations = /* @__PURE__ */ new Map();
31761
+ /**
31762
+ * Questionnaires waiting on a person, keyed by request id.
31763
+ *
31764
+ * Exactly the shape `pendingEscalations` uses, and for the same reason: the
31765
+ * answer, the timeout and the abort all race, and one settle path guarded by
31766
+ * the map delete is what makes the first of them win.
31767
+ */
31768
+ pendingClarifications = /* @__PURE__ */ new Map();
31769
+ /**
31770
+ * Put a gate's opening question to its listeners, and NOTICE when one fails.
31771
+ *
31772
+ * `emit` cannot do this job. It calls listeners synchronously and discards
31773
+ * what they return, so an `async` listener — the ordinary shape for anything
31774
+ * that has to reach a socket or a UI — does not fail in a way `emit` reports:
31775
+ * it returns a REJECTED PROMISE that nobody is holding. The `try` around the
31776
+ * emit sees nothing, the gate is never told delivery failed, and it parks for
31777
+ * its full timeout while Node raises an unhandled rejection that can take the
31778
+ * process down.
31779
+ *
31780
+ * `onFailed` is called for either shape, and it is safe to call more than
31781
+ * once: both gates route it to `settle`, which is guarded by a map delete, so
31782
+ * the first failure wins and the rest are no-ops.
31783
+ *
31784
+ * PER LISTENER, not around the loop. `emit` stops dispatching at the first
31785
+ * throw, so one broken host used to cost a working one its notification —
31786
+ * the same reason `announceLiveView` delivers its two listeners separately.
31787
+ * A synchronous failure settles the gate once the whole snapshot has been
31788
+ * delivered; an asynchronous one settles it when the rejection arrives, which
31789
+ * is still far better than the window-long park it replaces.
31790
+ *
31791
+ * SETTLED AFTER THE LOOP, not inside it, and that is not tidiness. `onFailed`
31792
+ * settles the gate, and settling emits the gate's CLOSURE — so failing
31793
+ * mid-dispatch announced "this request is over" and then went on handing the
31794
+ * OPENING event to the listeners after it. A host later in the list would
31795
+ * draw a form whose only closure notification had already gone past, and it
31796
+ * would stand there forever. Deliver to everyone, then decide.
31797
+ *
31798
+ * `rawListeners`, NOT `listeners`. Bypassing `emit` means taking on what
31799
+ * `emit` was doing, and `once` is the part that bites: `listeners()` hands
31800
+ * back the UNWRAPPED callback, so calling it never runs the wrapper that
31801
+ * deregisters it. A host that asked to be told once would be told every
31802
+ * time — and told through a transport it had set up for one gate only.
31803
+ * `rawListeners()` returns the wrapper, which removes the registration and
31804
+ * passes the underlying listener's return value straight through, so the
31805
+ * promise below is still the one being observed. Verified against Node.
31806
+ *
31807
+ * `.call(this, …)`, which is the third thing `emit` was doing. EventEmitter
31808
+ * invokes listeners with the emitter as the receiver, so
31809
+ * `function () { this.resolveClarification(...) }` is a legitimate host — and
31810
+ * calling it bare gave it `this === undefined`, which throws, which this
31811
+ * would then have read as the host failing to receive the question and
31812
+ * settled the gate unanswered. A working listener turned into a delivery
31813
+ * failure by the thing delivering it.
31814
+ */
31815
+ deliverGate(event, payload, onFailed) {
31816
+ let delivered = false;
31817
+ let threw = false;
31818
+ const pending = [];
31819
+ for (const listener of this.rawListeners(event)) {
31820
+ try {
31821
+ const out = listener.call(this, payload);
31822
+ if (out && typeof out.then === "function") {
31823
+ pending.push(Promise.resolve(out).then(() => true, () => false));
31824
+ } else {
31825
+ delivered = true;
31826
+ }
31827
+ } catch {
31828
+ threw = true;
31829
+ }
31830
+ }
31831
+ if (delivered) return;
31832
+ if (pending.length === 0) {
31833
+ if (threw) onFailed();
31834
+ return;
31835
+ }
31836
+ void Promise.all(pending).then((results) => {
31837
+ if (!results.some(Boolean)) onFailed();
31838
+ });
31839
+ }
31840
+ /**
31841
+ * Tell listeners something that is over, and let none of them make it worse.
31842
+ *
31843
+ * The closing half of the gate family — `clarification:closed`,
31844
+ * `clarification:timeout`, `escalation:closed`, `escalation:timeout`. Each
31845
+ * was already wrapped in a `try` for the synchronous case, and each had the
31846
+ * same hole `deliverGate` had before last round: `emit` discards what a
31847
+ * listener returns, so an `async` host fails by returning a rejected promise
31848
+ * nobody is holding and Node raises an unhandled rejection.
31849
+ *
31850
+ * There is nothing to settle here — the gate is already closed, which is the
31851
+ * whole message — so unlike `deliverGate` this reports to no one. It exists
31852
+ * so that the four informational emits cannot take the process down, and so
31853
+ * the rule is written once rather than four times.
31854
+ */
31855
+ announceGateClosed(event, payload) {
31856
+ for (const listener of this.rawListeners(event)) {
31857
+ try {
31858
+ const out = listener.call(this, payload);
31859
+ if (out && typeof out.then === "function") {
31860
+ void Promise.resolve(out).catch(() => {
31861
+ });
31862
+ }
31863
+ } catch {
31864
+ }
31865
+ }
31866
+ }
31674
31867
  async requestEscalationDecision(ctx, taskId, signal) {
31675
31868
  if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
31676
31869
  if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
@@ -31683,23 +31876,28 @@ var Cascade = class _Cascade extends EventEmitter {
31683
31876
  if (timeout) clearTimeout(timeout);
31684
31877
  signal?.removeEventListener("abort", onAbort);
31685
31878
  resolve2(decision);
31879
+ this.announceGateClosed("escalation:closed", { taskId, requestId, sectionId: ctx.sectionId });
31686
31880
  };
31687
31881
  const onAbort = () => settle({ action: "skip", automatic: true });
31688
31882
  signal?.addEventListener("abort", onAbort, { once: true });
31689
31883
  timeout = setTimeout(() => {
31690
- this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31691
31884
  settle({ action: "timeout" });
31885
+ this.announceGateClosed("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
31692
31886
  }, ESCALATION_DECISION_TIMEOUT_MS);
31693
31887
  this.pendingEscalations.set(requestId, settle);
31694
- this.emit("escalation:decision-required", {
31695
- taskId,
31696
- requestId,
31697
- sectionId: ctx.sectionId,
31698
- sectionTitle: ctx.sectionTitle,
31699
- issues: ctx.issues,
31700
- summary: ctx.summary,
31701
- timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31702
- });
31888
+ this.deliverGate(
31889
+ "escalation:decision-required",
31890
+ {
31891
+ taskId,
31892
+ requestId,
31893
+ sectionId: ctx.sectionId,
31894
+ sectionTitle: ctx.sectionTitle,
31895
+ issues: ctx.issues,
31896
+ summary: ctx.summary,
31897
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
31898
+ },
31899
+ () => settle({ action: "skip", automatic: true })
31900
+ );
31703
31901
  });
31704
31902
  }
31705
31903
  /**
@@ -31736,6 +31934,73 @@ var Cascade = class _Cascade extends EventEmitter {
31736
31934
  releasePendingEscalations(action = "skip") {
31737
31935
  for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
31738
31936
  }
31937
+ // ── Clarification: asking instead of assuming ───────────────────────
31938
+ // The same gate shape as the escalation above, and for the same reasons. The
31939
+ // difference that matters: an unanswered escalation SKIPS a section, while an
31940
+ // unanswered question just means the model proceeds on its own reading — so
31941
+ // every non-answer here is benign, and none of them may park the run.
31942
+ /**
31943
+ * Put a questionnaire to whoever is watching, if anybody is.
31944
+ *
31945
+ * Returns `no-listener` rather than waiting when there is nothing attached to
31946
+ * answer — a scheduled run, an API caller with no socket, an autonomous run
31947
+ * that has already said it wants no prompts. That check is the whole reason
31948
+ * this can be offered to the model at all: a tool that might hang a headless
31949
+ * run forever is a tool that cannot be registered.
31950
+ */
31951
+ async askUser(questions, signal) {
31952
+ const none = (outcome) => ({ outcome, answers: [] });
31953
+ if (this.unattended) return none("no-listener");
31954
+ if (this.config.autonomy === "auto") return none("no-listener");
31955
+ if (this.listenerCount("clarification:required") === 0) return none("no-listener");
31956
+ if (signal?.aborted) return none("aborted");
31957
+ const requestId = randomUUID();
31958
+ return await new Promise((resolve2) => {
31959
+ let timeout;
31960
+ const settle = (result) => {
31961
+ if (!this.pendingClarifications.delete(requestId)) return;
31962
+ if (timeout) clearTimeout(timeout);
31963
+ signal?.removeEventListener("abort", onAbort);
31964
+ resolve2(result);
31965
+ this.announceGateClosed("clarification:closed", { requestId });
31966
+ };
31967
+ const onAbort = () => settle(none("aborted"));
31968
+ signal?.addEventListener("abort", onAbort, { once: true });
31969
+ timeout = setTimeout(() => {
31970
+ settle(none("timeout"));
31971
+ this.announceGateClosed("clarification:timeout", { requestId });
31972
+ }, CLARIFICATION_TIMEOUT_MS);
31973
+ this.pendingClarifications.set(requestId, settle);
31974
+ this.deliverGate(
31975
+ "clarification:required",
31976
+ { requestId, questions, timeoutMs: CLARIFICATION_TIMEOUT_MS },
31977
+ () => settle(none("no-listener"))
31978
+ );
31979
+ });
31980
+ }
31981
+ /**
31982
+ * Answer a questionnaire from a REPL / dashboard / cloud listener.
31983
+ *
31984
+ * `requestId` comes back on the `clarification:required` event and is what
31985
+ * makes an answer land on the question that asked. Optional for a host with
31986
+ * only one in flight, matching `resolveEscalation`: without it the oldest
31987
+ * outstanding request is answered, which is correct whenever there is one.
31988
+ */
31989
+ resolveClarification(answers, requestId) {
31990
+ const result = { outcome: "answered", answers };
31991
+ if (requestId) {
31992
+ this.pendingClarifications.get(requestId)?.(result);
31993
+ return;
31994
+ }
31995
+ const oldest = this.pendingClarifications.keys().next();
31996
+ if (!oldest.done) this.pendingClarifications.get(oldest.value)?.(result);
31997
+ }
31998
+ /** Release every parked questionnaire — run teardown, so none outlive the run. */
31999
+ releasePendingClarifications() {
32000
+ for (const settle of Array.from(this.pendingClarifications.values())) {
32001
+ settle({ outcome: "aborted", answers: [] });
32002
+ }
32003
+ }
31739
32004
  /**
31740
32005
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
31741
32006
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -31760,12 +32025,14 @@ var Cascade = class _Cascade extends EventEmitter {
31760
32025
  const timeout = setTimeout(() => {
31761
32026
  if (this.pendingContextApproval) {
31762
32027
  this.pendingContextApproval = void 0;
32028
+ this.announceGateClosed("context:approval-closed", {});
31763
32029
  resolve2(true);
31764
32030
  }
31765
32031
  }, 12e4);
31766
32032
  this.pendingContextApproval = (approved) => {
31767
32033
  clearTimeout(timeout);
31768
32034
  this.pendingContextApproval = void 0;
32035
+ this.announceGateClosed("context:approval-closed", {});
31769
32036
  resolve2(approved);
31770
32037
  };
31771
32038
  this.emit("context:approval-required", info);
@@ -31956,6 +32223,34 @@ ${input}` }],
31956
32223
  if ((this.config.tools?.disabledTools ?? []).includes("browser_control")) return;
31957
32224
  this.toolRegistry.register(new BrowserControlTool(controller, release));
31958
32225
  }
32226
+ /**
32227
+ * Declare that this host can put a questionnaire to a person and answer it.
32228
+ *
32229
+ * Explicit, and NOT done at construction, because registering a tool is a
32230
+ * promise the host has to keep. A host that registers `ask_user` without
32231
+ * listening for `clarification:required` gives the model a tool that always
32232
+ * answers "there is nobody watching this run" — while a person sits at the
32233
+ * terminal looking at it. That is worse than not offering the tool at all:
32234
+ * it costs a round trip and then tells the model something false.
32235
+ *
32236
+ * Only `cloud/server` bridges the protocol today. The CLI REPL and the
32237
+ * dashboard do not, so they do not call this, and their models are not shown
32238
+ * a capability nobody can serve. Wiring either of them is a matter of
32239
+ * listening for the event and calling `resolveClarification`, then calling
32240
+ * this.
32241
+ *
32242
+ * Still gated on the same conditions as the call itself, and `askUser`
32243
+ * re-checks them: `setUnattended` can land after this, so the tool can be
32244
+ * registered for a run that later turns out to have nobody on it. Then it
32245
+ * answers `no-listener` and the model proceeds on its own reading, which is
32246
+ * the same defence-in-depth `BrowserControlTool.revoke` provides.
32247
+ */
32248
+ enableClarification() {
32249
+ if (this.unattended) return;
32250
+ if (this.config.autonomy === "auto") return;
32251
+ if ((this.config.tools?.disabledTools ?? []).includes("ask_user")) return;
32252
+ this.toolRegistry.register(new AskUserTool((questions, signal) => this.askUser(questions, signal)));
32253
+ }
31959
32254
  registerMediaTools(workspacePath) {
31960
32255
  const denied = new Set(this.config.tools?.disabledTools ?? []);
31961
32256
  const permitted = (name) => !denied.has(name);
@@ -32901,6 +33196,10 @@ ${finalOutput}` : ""
32901
33196
  this.releasePendingEscalations();
32902
33197
  } catch {
32903
33198
  }
33199
+ try {
33200
+ this.releasePendingClarifications();
33201
+ } catch {
33202
+ }
32904
33203
  this.router.restoreTierModels();
32905
33204
  this.router.setRunSignal(void 0);
32906
33205
  if (this.taskAnalyzer) {