cascade-ai 0.54.0 → 0.55.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1897,7 +1897,7 @@ function reconcilePrice(model, live) {
1897
1897
  }
1898
1898
 
1899
1899
  // src/constants.ts
1900
- var CASCADE_VERSION = "0.54.0";
1900
+ var CASCADE_VERSION = "0.55.0";
1901
1901
  var CASCADE_CONFIG_DIR = ".cascade";
1902
1902
  var CASCADE_MD_FILE = "CASCADE.md";
1903
1903
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -4835,6 +4835,23 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
4835
4835
  */
4836
4836
  budgetState = "ok";
4837
4837
  budgetExceededReason;
4838
+ /**
4839
+ * Whether the run/session budget is permanently tripped, and why.
4840
+ *
4841
+ * A tier or worker that catches `BudgetExceededError` internally (T3Worker
4842
+ * does, so one dead-end subtask doesn't crash the whole run) absorbs the
4843
+ * exception into a normal FAILED/PARTIAL result — the tier returns as if
4844
+ * nothing unusual happened. That silently skips `Cascade.run()`'s dedicated
4845
+ * budget-exceeded handling (the `run:budget-exceeded` event and
4846
+ * `lastInterruptedRun` for `/continue`). This lets a caller check the
4847
+ * router's own state directly after a tier returns, regardless of how that
4848
+ * tier chose to report the error internally.
4849
+ */
4850
+ budgetExceededInfo() {
4851
+ if (this.runBudgetExceeded) return { reason: this.runBudgetExceededReason ?? "Per-task budget exceeded." };
4852
+ if (this.budgetState === "exceeded") return { reason: this.budgetExceededReason ?? "Session budget exceeded." };
4853
+ return null;
4854
+ }
4838
4855
  tpmLimiter;
4839
4856
  localQueue;
4840
4857
  taskAnalyzer;
@@ -6492,6 +6509,13 @@ Now execute your subtask using this context where relevant.`
6492
6509
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput2, "FAILED");
6493
6510
  return this.buildResult("ESCALATED", finalOutput2, { checksRun, passed, failed }, issues, correctionAttempts);
6494
6511
  }
6512
+ if (err instanceof Error && err.name === "BudgetExceededError") {
6513
+ issues.push(errMsg);
6514
+ const stopped = output || errMsg;
6515
+ this.setStatus("FAILED", stopped);
6516
+ this.peerBus?.publish(this.id, assignment.subtaskId, stopped, "FAILED");
6517
+ return this.buildResult("FAILED", stopped, { checksRun, passed, failed }, issues, correctionAttempts);
6518
+ }
6495
6519
  issues.push(`Execution error: ${errMsg}`);
6496
6520
  const finalOutput = output || errMsg;
6497
6521
  this.setStatus("FAILED", finalOutput);
@@ -7513,6 +7537,26 @@ var RedactionLayer = class {
7513
7537
  }
7514
7538
  };
7515
7539
 
7540
+ // src/core/tiers/escalation-policy.ts
7541
+ function sectionNeedsDecision(results) {
7542
+ return results.some((r) => r.status === "ESCALATED");
7543
+ }
7544
+ function settledEscalationStatus(results) {
7545
+ const hasWork = results.some((r) => r.status === "COMPLETED" || !!r.output);
7546
+ return hasWork ? "PARTIAL" : "FAILED";
7547
+ }
7548
+ function composeRootSectionOutput(t2Result, completedOutputs) {
7549
+ let output = `${t2Result.sectionSummary}
7550
+
7551
+ ${completedOutputs.join("\n\n")}`;
7552
+ if (t2Result.status === "FAILED" && t2Result.issues?.length) {
7553
+ output += `
7554
+
7555
+ _(incomplete: ${t2Result.issues.join("; ")})_`;
7556
+ }
7557
+ return output;
7558
+ }
7559
+
7516
7560
  // src/core/tiers/t2-manager.ts
7517
7561
  function buildT2SystemPrompt(hasPeerMessage) {
7518
7562
  return [
@@ -7538,6 +7582,19 @@ var T2Manager = class extends BaseTier {
7538
7582
  t2PeerBus;
7539
7583
  permissionEscalator;
7540
7584
  toolCreator;
7585
+ /**
7586
+ * Asked when a section ends ESCALATED — i.e. a worker hit something it could
7587
+ * not decide alone. Without this the escalation was a dead end: the status
7588
+ * said "needs a decision" and no decision was ever requested, so an MCP run
7589
+ * that legitimately needed input just stopped there.
7590
+ */
7591
+ escalationCallback;
7592
+ /**
7593
+ * The one allowed retry has been spent. Retrying is bounded because an
7594
+ * escalation loop that can re-ask forever burns a budget on a question that
7595
+ * is not resolving; a second escalation is terminal instead.
7596
+ */
7597
+ escalationRetryUsed = false;
7541
7598
  /** Optional boardroom gate (Moderate / root-T2 runs) — pauses after decomposition. */
7542
7599
  planApprovalCallback;
7543
7600
  /** AbortController for the current T3 wave — aborted on cancel-and-respawn */
@@ -7586,6 +7643,10 @@ var T2Manager = class extends BaseTier {
7586
7643
  setToolCreator(creator) {
7587
7644
  this.toolCreator = creator;
7588
7645
  }
7646
+ /** Ask the user what to do when a section escalates. */
7647
+ setEscalationCallback(cb) {
7648
+ this.escalationCallback = cb;
7649
+ }
7589
7650
  /** Boardroom gate for Moderate (root-T2) runs: pause after decomposition. */
7590
7651
  setPlanApprovalCallback(cb) {
7591
7652
  this.planApprovalCallback = cb;
@@ -7690,9 +7751,57 @@ Guidance (must be followed): ${decision.note}`
7690
7751
  currentAction: "Aggregating T3 results",
7691
7752
  status: "IN_PROGRESS"
7692
7753
  });
7693
- const summary = await this.aggregateResults(assignment, t3Results);
7754
+ let summary = await this.aggregateResults(assignment, t3Results);
7694
7755
  const issues = t3Results.filter((r) => r.status !== "COMPLETED").flatMap((r) => r.issues);
7695
- const overallStatus = this.determineStatus(t3Results);
7756
+ let overallStatus = this.determineStatus(t3Results);
7757
+ let userSkipped = false;
7758
+ const hasEscalated = sectionNeedsDecision(t3Results);
7759
+ const settleEscalated = async (reason) => {
7760
+ if (reason) issues.push(reason);
7761
+ summary = await this.aggregateResults(assignment, t3Results, { includeEscalated: true });
7762
+ overallStatus = settledEscalationStatus(t3Results);
7763
+ };
7764
+ if (hasEscalated && this.escalationCallback && this.escalationRetryUsed) {
7765
+ await settleEscalated("Escalated again after the retry \u2014 no further attempts were made.");
7766
+ } else if (hasEscalated && !this.escalationCallback) {
7767
+ await settleEscalated();
7768
+ } else if (hasEscalated && this.escalationCallback) {
7769
+ this.sendStatusUpdate({
7770
+ progressPct: 95,
7771
+ currentAction: "Escalated \u2014 waiting for your decision",
7772
+ status: "ESCALATING"
7773
+ });
7774
+ const decision = await this.escalationCallback({
7775
+ sectionId: assignment.sectionId,
7776
+ sectionTitle: assignment.sectionTitle,
7777
+ issues,
7778
+ summary
7779
+ });
7780
+ this.log(`Escalation decision for "${assignment.sectionTitle}": ${decision.action}`);
7781
+ if (decision.action === "skip") {
7782
+ summary = await this.aggregateResults(assignment, t3Results, { includeEscalated: true });
7783
+ overallStatus = settledEscalationStatus(t3Results);
7784
+ userSkipped = decision.automatic !== true;
7785
+ } else if (decision.action === "retry" || decision.action === "guidance") {
7786
+ this.escalationRetryUsed = true;
7787
+ const guided = decision.action === "guidance" && decision.note?.trim() ? {
7788
+ ...assignment,
7789
+ description: `${assignment.description}
7790
+
7791
+ Guidance (must be followed): ${decision.note.trim()}`,
7792
+ // Drop T1's preplanned subtasks so the retry re-decomposes with
7793
+ // the guidance in hand. The workers execute from their subtask
7794
+ // slices, not from this description — leaving the slices in
7795
+ // place gave "Retry with guidance" byte-identical worker
7796
+ // prompts to "Retry as-is", making the note do nothing at all.
7797
+ t3Subtasks: []
7798
+ } : assignment;
7799
+ return await this.execute(guided, taskId, signal);
7800
+ } else {
7801
+ issues.push("Escalated, but no decision was received in time.");
7802
+ overallStatus = "FAILED";
7803
+ }
7804
+ }
7696
7805
  const isOk = overallStatus === "COMPLETED" || overallStatus === "PARTIAL";
7697
7806
  this.setStatus(isOk ? "COMPLETED" : "FAILED", summary);
7698
7807
  const sectionAction = overallStatus === "COMPLETED" ? "Section complete" : overallStatus === "PARTIAL" ? "Section partially complete \u2014 some subtasks failed" : overallStatus === "ESCALATED" ? "Section escalated \u2014 needs a decision" : "Section failed \u2014 no subtask completed";
@@ -7703,7 +7812,8 @@ Guidance (must be followed): ${decision.note}`
7703
7812
  status: overallStatus,
7704
7813
  t3Results,
7705
7814
  sectionSummary: summary,
7706
- issues
7815
+ issues,
7816
+ ...userSkipped ? { userSkipped: true } : {}
7707
7817
  };
7708
7818
  this.publishSectionOutput(result);
7709
7819
  return result;
@@ -8137,8 +8247,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
8137
8247
  result.status === "COMPLETED" ? "COMPLETED" : "FAILED"
8138
8248
  );
8139
8249
  }
8140
- async aggregateResults(assignment, results) {
8141
- const completed = results.filter((r) => r.status === "COMPLETED");
8250
+ async aggregateResults(assignment, results, opts = {}) {
8251
+ const completed = results.filter(
8252
+ (r) => r.status === "COMPLETED" || opts.includeEscalated && r.status === "ESCALATED" && r.output
8253
+ );
8142
8254
  if (!completed.length) return `Section ${assignment.sectionTitle} failed \u2014 no T3 workers completed.`;
8143
8255
  const peerOutputs = this.peerSyncBuffer.filter((p) => p.content?.type === "T2_SECTION_OUTPUT").map((p) => `[Peer ${p.fromId} Output]: ${p.content.output}`).join("\n\n");
8144
8256
  const peerContext = peerOutputs ? `
@@ -8344,6 +8456,7 @@ var T1Administrator = class extends BaseTier {
8344
8456
  escalations = [];
8345
8457
  store;
8346
8458
  runBreaker;
8459
+ escalationCallback;
8347
8460
  t2PeerBus = new PeerBus();
8348
8461
  permissionEscalator;
8349
8462
  toolCreator;
@@ -8358,6 +8471,10 @@ var T1Administrator = class extends BaseTier {
8358
8471
  this.toolRegistry = toolRegistry;
8359
8472
  this.config = config;
8360
8473
  }
8474
+ /** Ask the user what to do when any section escalates. */
8475
+ setEscalationCallback(cb) {
8476
+ this.escalationCallback = cb;
8477
+ }
8361
8478
  /** Share the run-wide circuit breaker with every section manager. */
8362
8479
  setRunBreaker(breaker) {
8363
8480
  this.runBreaker = breaker;
@@ -8517,8 +8634,11 @@ Create a CORRECTION PLAN that contains only the new sections needed to fix the i
8517
8634
  reason: `Some T2 managers failed entirely: ${failedSections.map((s) => s.sectionTitle).join(", ")}. Errors: ${failedSections.flatMap((s) => s.issues).join("; ")}`
8518
8635
  };
8519
8636
  }
8520
- const sectionsText = t2Results.map((r) => `**${r.sectionTitle}**
8521
- ${r.sectionSummary}`).join("\n\n");
8637
+ const sectionsText = t2Results.map((r) => {
8638
+ const skipNote = r.userSkipped ? "\n[The user was asked to retry or skip this section and explicitly chose to SKIP it. This is an intentional decision, not a failure \u2014 do not cite it as a reason to reject.]" : "";
8639
+ return `**${r.sectionTitle}**
8640
+ ${r.sectionSummary}${skipNote}`;
8641
+ }).join("\n\n");
8522
8642
  const prompt = `You are a strict QA Reviewer for the Cascade AI system.
8523
8643
  Review the following execution outputs against the original user prompt.
8524
8644
 
@@ -8528,6 +8648,7 @@ T2 Manager Summaries:
8528
8648
  ${sectionsText}
8529
8649
 
8530
8650
  Does the current state of the workspace and the outputs fully satisfy the user's request?
8651
+ A section marked as user-skipped above is accepted as-is by the user's own choice \u2014 treat it as satisfied, not as a gap.
8531
8652
  If yes, reply with exactly: "APPROVED".
8532
8653
  If no, reply with "REJECTED: [Detailed reason explaining exactly what is missing or incorrect]".`;
8533
8654
  try {
@@ -8725,6 +8846,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
8725
8846
  const managers = sections.map((section) => {
8726
8847
  const manager = new T2Manager(this.router, this.toolRegistry, this.id);
8727
8848
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
8849
+ if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
8728
8850
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
8729
8851
  if (this.store) {
8730
8852
  manager.setStore(this.store);
@@ -8934,7 +9056,10 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
8934
9056
  async compileFinalOutput(originalPrompt, plan, t2Results) {
8935
9057
  const completedSections = t2Results.filter((r) => r.status !== "FAILED");
8936
9058
  if (!completedSections.length) {
8937
- const allIssues = t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues));
9059
+ const allIssues = [
9060
+ ...t2Results.flatMap((r) => r.issues ?? []),
9061
+ ...t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues))
9062
+ ].filter(Boolean);
8938
9063
  const critical = allIssues.find((i) => i.includes("[CRITICAL_TOOL_ERROR]"));
8939
9064
  const stalled = allIssues.find((i) => /^Stalled:/.test(i));
8940
9065
  const topReason = critical ?? stalled ?? allIssues[0] ?? "no specific reason recorded";
@@ -9743,6 +9868,102 @@ var RunBreaker = class {
9743
9868
  return out;
9744
9869
  }
9745
9870
  };
9871
+ var MCP_TOOL_PREFIX = "mcp__";
9872
+ var MAX_SEGMENT_LENGTH = 24;
9873
+ var SEGMENT_HASH_LENGTH = 8;
9874
+ function boundedSegment(raw) {
9875
+ const clean = sanitizeToolNameSegment(raw);
9876
+ if (clean.length <= MAX_SEGMENT_LENGTH) return clean;
9877
+ const hash = crypto4.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, SEGMENT_HASH_LENGTH);
9878
+ return `${clean.slice(0, MAX_SEGMENT_LENGTH - SEGMENT_HASH_LENGTH - 1)}_${hash}`;
9879
+ }
9880
+ function isStableSegment(raw) {
9881
+ return sanitizeToolNameSegment(raw) === raw && raw.length <= MAX_SEGMENT_LENGTH;
9882
+ }
9883
+ function sanitizeToolNameSegment(segment) {
9884
+ const cleaned = segment.replace(/[^a-zA-Z0-9-]+/g, "_");
9885
+ let start = 0;
9886
+ let end = cleaned.length;
9887
+ while (start < end && cleaned[start] === "_") start++;
9888
+ while (end > start && cleaned[end - 1] === "_") end--;
9889
+ return cleaned.slice(start, end) || "_";
9890
+ }
9891
+ function mcpToolName(serverName, toolName) {
9892
+ return `${MCP_TOOL_PREFIX}${boundedSegment(serverName)}__${boundedSegment(toolName)}`;
9893
+ }
9894
+ function isMcpToolName(name) {
9895
+ return name.startsWith(MCP_TOOL_PREFIX);
9896
+ }
9897
+ function uniqueMcpServerName(desired, existingNames) {
9898
+ const taken = new Set(existingNames.map(mcpServerPrefix));
9899
+ if (!taken.has(mcpServerPrefix(desired))) return desired;
9900
+ for (let n = 2; n < 1e3; n++) {
9901
+ const candidate = `${desired} (${n})`;
9902
+ if (!taken.has(mcpServerPrefix(candidate))) return candidate;
9903
+ }
9904
+ return `${desired} (${crypto4.randomUUID().slice(0, 8)})`;
9905
+ }
9906
+ function disambiguateMcpServerNames(servers) {
9907
+ const seenNames = [];
9908
+ const renames = [];
9909
+ const result = servers.map((s) => {
9910
+ const name = uniqueMcpServerName(s.name, seenNames);
9911
+ seenNames.push(name);
9912
+ if (name === s.name) return s;
9913
+ renames.push({ from: s.name, to: name });
9914
+ return { ...s, name };
9915
+ });
9916
+ return { servers: renames.length ? result : servers, renames };
9917
+ }
9918
+ function assignMcpToolNames(tools) {
9919
+ const names = new Array(tools.length);
9920
+ const bases = tools.map((t) => mcpToolName(t.server, t.tool));
9921
+ const groups = /* @__PURE__ */ new Map();
9922
+ bases.forEach((base, i) => {
9923
+ const g = groups.get(base);
9924
+ if (g) g.push(i);
9925
+ else groups.set(base, [i]);
9926
+ });
9927
+ const used = new Set(bases);
9928
+ for (const [base, idx] of groups) {
9929
+ if (idx.length === 1) {
9930
+ names[idx[0]] = base;
9931
+ continue;
9932
+ }
9933
+ const raw = (i) => `${tools[i].server}::${tools[i].tool}`;
9934
+ const isClean = (i) => isStableSegment(tools[i].server) && isStableSegment(tools[i].tool);
9935
+ const ordered = [...idx].sort((a, b) => {
9936
+ const la = isClean(a) ? 0 : 1;
9937
+ const lb = isClean(b) ? 0 : 1;
9938
+ if (la !== lb) return la - lb;
9939
+ const ra = raw(a);
9940
+ const rb = raw(b);
9941
+ return ra < rb ? -1 : ra > rb ? 1 : 0;
9942
+ });
9943
+ names[ordered[0]] = base;
9944
+ let n = 2;
9945
+ for (let rank3 = 1; rank3 < ordered.length; rank3++) {
9946
+ let candidate = `${base}_${n}`;
9947
+ while (used.has(candidate)) {
9948
+ n++;
9949
+ candidate = `${base}_${n}`;
9950
+ }
9951
+ names[ordered[rank3]] = candidate;
9952
+ used.add(candidate);
9953
+ n++;
9954
+ }
9955
+ }
9956
+ return names;
9957
+ }
9958
+ function mcpServerPrefix(serverName) {
9959
+ return `${MCP_TOOL_PREFIX}${boundedSegment(serverName)}__`;
9960
+ }
9961
+ function removeMcpServerDenials(disabledTools, serverName) {
9962
+ if (!disabledTools?.length) return disabledTools;
9963
+ const prefix = mcpServerPrefix(serverName);
9964
+ const kept = disabledTools.filter((t) => !t.startsWith(prefix));
9965
+ return kept.length === disabledTools.length ? disabledTools : kept;
9966
+ }
9746
9967
  var execAsync = util.promisify(child_process.exec);
9747
9968
  var ShellTool = class extends BaseTool {
9748
9969
  name = "shell";
@@ -11158,20 +11379,6 @@ ${text}`;
11158
11379
  }
11159
11380
  };
11160
11381
 
11161
- // src/tools/tool-name.ts
11162
- var MCP_TOOL_PREFIX = "mcp__";
11163
- function sanitizeToolNameSegment(segment) {
11164
- const cleaned = segment.replace(/[^a-zA-Z0-9-]+/g, "_");
11165
- let start = 0;
11166
- let end = cleaned.length;
11167
- while (start < end && cleaned[start] === "_") start++;
11168
- while (end > start && cleaned[end - 1] === "_") end--;
11169
- return cleaned.slice(start, end) || "_";
11170
- }
11171
- function mcpToolName(serverName, toolName) {
11172
- return `${MCP_TOOL_PREFIX}${sanitizeToolNameSegment(serverName)}__${sanitizeToolNameSegment(toolName)}`;
11173
- }
11174
-
11175
11382
  // src/tools/mcp.ts
11176
11383
  var McpToolWrapper = class extends BaseTool {
11177
11384
  name;
@@ -11180,12 +11387,12 @@ var McpToolWrapper = class extends BaseTool {
11180
11387
  mcpClient;
11181
11388
  serverName;
11182
11389
  toolName;
11183
- constructor(mcpClient, serverName, toolName, description, inputSchema) {
11390
+ constructor(mcpClient, serverName, toolName, description, inputSchema, registeredName) {
11184
11391
  super();
11185
11392
  this.mcpClient = mcpClient;
11186
11393
  this.serverName = serverName;
11187
11394
  this.toolName = toolName;
11188
- this.name = mcpToolName(serverName, toolName);
11395
+ this.name = registeredName ?? mcpToolName(serverName, toolName);
11189
11396
  this.description = `[MCP:${serverName}] ${description}`;
11190
11397
  this.inputSchema = inputSchema;
11191
11398
  }
@@ -11256,22 +11463,35 @@ var ToolRegistry = class extends EventEmitter__default.default {
11256
11463
  getRegisteredPlugins() {
11257
11464
  return Array.from(this.plugins.keys());
11258
11465
  }
11259
- /** Registers all tools from an MCP client */
11260
- registerMcpTools(mcpClient) {
11261
- const definitions = mcpClient.getToolDefinitions();
11262
- for (const def of definitions) {
11263
- const [, serverName, toolName] = def.name.split("::");
11264
- if (!serverName || !toolName) continue;
11466
+ /**
11467
+ * Registers all tools from an MCP client.
11468
+ *
11469
+ * `isAllowed` receives the FINAL registered name (`mcp__server__tool`) — the
11470
+ * same string the user sees and the same one that reaches the provider — so
11471
+ * a per-tool opt-out in Settings can be expressed as a plain name list.
11472
+ * Filtering here rather than at call time means a deselected tool is genuinely
11473
+ * absent from the definitions handed to the model, so it is never proposed,
11474
+ * never counted against the tool budget, and never has to be refused.
11475
+ */
11476
+ registerMcpTools(mcpClient, isAllowed) {
11477
+ const parsed = mcpClient.getToolDefinitions().flatMap((def) => {
11478
+ const [, server, tool] = def.name.split("::");
11479
+ return server && tool ? [{ def, server, tool }] : [];
11480
+ });
11481
+ const names = assignMcpToolNames(parsed);
11482
+ parsed.forEach(({ def, server, tool }, i) => {
11265
11483
  const wrapper = new McpToolWrapper(
11266
11484
  mcpClient,
11267
- serverName,
11268
- toolName,
11269
- def.description.replace(`[MCP:${serverName}] `, ""),
11270
- def.inputSchema
11485
+ server,
11486
+ tool,
11487
+ def.description.replace(`[MCP:${server}] `, ""),
11488
+ def.inputSchema,
11489
+ names[i]
11271
11490
  );
11491
+ if (isAllowed && !isAllowed(wrapper.name)) return;
11272
11492
  wrapper.setWorkspaceRoot(this.workspaceRoot);
11273
11493
  this.register(wrapper);
11274
- }
11494
+ });
11275
11495
  }
11276
11496
  setIgnoredPaths(patterns) {
11277
11497
  this.ignoreMatcher = ignore().add(patterns);
@@ -11608,6 +11828,17 @@ function fileOAuthProvider(storePath) {
11608
11828
  }
11609
11829
  });
11610
11830
  }
11831
+ var oauthStoreLocks = /* @__PURE__ */ new Map();
11832
+ function withOAuthStoreLock(storePath, fn) {
11833
+ const prev = oauthStoreLocks.get(storePath) ?? Promise.resolve();
11834
+ const run = prev.then(fn, fn);
11835
+ const tail = run.then(() => void 0, () => void 0);
11836
+ oauthStoreLocks.set(storePath, tail);
11837
+ void tail.then(() => {
11838
+ if (oauthStoreLocks.get(storePath) === tail) oauthStoreLocks.delete(storePath);
11839
+ });
11840
+ return run;
11841
+ }
11611
11842
  function beginMcpOAuth(provider, serverUrl) {
11612
11843
  return auth_js.auth(provider, { serverUrl });
11613
11844
  }
@@ -14546,7 +14777,50 @@ var GuidanceQueue = class {
14546
14777
  }
14547
14778
  };
14548
14779
 
14780
+ // src/tools/current-page.ts
14781
+ var MAX_PAGE_TEXT_CHARS = 4e4;
14782
+ var CurrentPageTool = class extends BaseTool {
14783
+ name = "read_current_page";
14784
+ description = 'Read the web page the user currently has open in the built-in browser \u2014 its URL, title, and visible text. Use this whenever the user refers to "this page", "what I am looking at", or asks about something on screen. Prefer it over web_fetch for the open page: it sees content behind a login and content rendered by JavaScript, which web_fetch cannot.';
14785
+ inputSchema = {
14786
+ type: "object",
14787
+ properties: {},
14788
+ required: []
14789
+ };
14790
+ provider;
14791
+ constructor(provider) {
14792
+ super();
14793
+ this.provider = provider;
14794
+ }
14795
+ // Reading what is already on the user's screen changes nothing and reaches
14796
+ // nothing they have not already opened themselves.
14797
+ isDangerous() {
14798
+ return false;
14799
+ }
14800
+ async execute(_input, _options) {
14801
+ let page2;
14802
+ try {
14803
+ page2 = await this.provider();
14804
+ } catch (err) {
14805
+ return `Error: could not read the open page \u2014 ${err instanceof Error ? err.message : String(err)}`;
14806
+ }
14807
+ if (!page2) {
14808
+ return "No page is open in the built-in browser. Ask the user to open one, or use web_fetch for a public URL.";
14809
+ }
14810
+ const text = page2.text.length > MAX_PAGE_TEXT_CHARS ? `${page2.text.slice(0, MAX_PAGE_TEXT_CHARS)}
14811
+
14812
+ [truncated \u2014 the page is longer than ${MAX_PAGE_TEXT_CHARS} characters]` : page2.text;
14813
+ return [
14814
+ `URL: ${page2.url}`,
14815
+ `Title: ${page2.title}`,
14816
+ "",
14817
+ text.trim() || "[the page rendered no readable text]"
14818
+ ].join("\n");
14819
+ }
14820
+ };
14821
+
14549
14822
  // src/core/cascade.ts
14823
+ var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
14550
14824
  function buildContextualPrompt(prompt, history = []) {
14551
14825
  const recent = history.slice(-6);
14552
14826
  if (recent.length === 0) return prompt;
@@ -14769,6 +15043,94 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
14769
15043
  });
14770
15044
  });
14771
15045
  }
15046
+ // ── Escalation decisions ────────────────────────────────────────────
15047
+ //
15048
+ // A section that ends ESCALATED used to be a dead end: the Cockpit showed
15049
+ // "Section escalated — needs a decision" and no decision was ever requested,
15050
+ // so the run stopped there having spent a full orchestration. This is the
15051
+ // same round-trip shape as plan approval, with one deliberate difference —
15052
+ // an unanswered escalation FAILS the section instead of proceeding.
15053
+ //
15054
+ // That direction is the safe one here. Plan approval defaults to "yes" on
15055
+ // timeout because the plan is already the model's considered proposal;
15056
+ // an escalation exists precisely BECAUSE the worker was not confident, so
15057
+ // acting unattended is the option most likely to be wrong. And in cloud a
15058
+ // waiting run holds server resources, so hanging is not free either.
15059
+ // Keyed by request id, NOT a single slot. A Complex run dispatches each wave
15060
+ // of ready sections with `Promise.all` (t1-administrator.ts), so two sections
15061
+ // can be waiting on a decision at the same moment. A single field made that
15062
+ // case fail hard rather than merely confusingly: the second escalation
15063
+ // overwrote the first's resolver, the user's answer resolved only the second,
15064
+ // and then the FIRST section's timer found the slot already empty and
15065
+ // returned without resolving anything — parking the run forever, past its own
15066
+ // timeout. Same class of bug as the twelve-failover dead-model race: a wave
15067
+ // is concurrent, so per-run singletons are never safe inside it.
15068
+ pendingEscalations = /* @__PURE__ */ new Map();
15069
+ async requestEscalationDecision(ctx, taskId, signal) {
15070
+ if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
15071
+ if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
15072
+ if (signal?.aborted) return { action: "skip", automatic: true };
15073
+ const requestId = crypto4.randomUUID();
15074
+ return await new Promise((resolve2) => {
15075
+ let timeout;
15076
+ const settle = (decision) => {
15077
+ if (!this.pendingEscalations.delete(requestId)) return;
15078
+ if (timeout) clearTimeout(timeout);
15079
+ signal?.removeEventListener("abort", onAbort);
15080
+ resolve2(decision);
15081
+ };
15082
+ const onAbort = () => settle({ action: "skip", automatic: true });
15083
+ signal?.addEventListener("abort", onAbort, { once: true });
15084
+ timeout = setTimeout(() => {
15085
+ this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
15086
+ settle({ action: "timeout" });
15087
+ }, ESCALATION_DECISION_TIMEOUT_MS);
15088
+ this.pendingEscalations.set(requestId, settle);
15089
+ this.emit("escalation:decision-required", {
15090
+ taskId,
15091
+ requestId,
15092
+ sectionId: ctx.sectionId,
15093
+ sectionTitle: ctx.sectionTitle,
15094
+ issues: ctx.issues,
15095
+ summary: ctx.summary,
15096
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
15097
+ });
15098
+ });
15099
+ }
15100
+ /**
15101
+ * Resolve a pending escalation from a REPL / dashboard / cloud listener.
15102
+ *
15103
+ * `requestId` comes back from the `escalation:decision-required` event and is
15104
+ * what makes an answer land on the section that asked. It is optional so a
15105
+ * host with only one escalation in flight (and any caller that predates the
15106
+ * id) still works: without it the oldest outstanding request is answered,
15107
+ * which is correct whenever there is exactly one.
15108
+ *
15109
+ * `automatic` defaults to false — a call into this method is normally a real
15110
+ * person answering the prompt. Pass `true` from a host that is itself
15111
+ * settling the gate on nobody's behalf (a REST caller who never had a
15112
+ * chance to connect, a session halt with no per-section review behind it),
15113
+ * so T2 doesn't set `userSkipped` for a decision no one actually reviewed —
15114
+ * see EscalationDecision.automatic.
15115
+ */
15116
+ resolveEscalation(action, note, requestId, automatic) {
15117
+ const decision = { action, ...note ? { note } : {}, ...automatic ? { automatic: true } : {} };
15118
+ if (requestId) {
15119
+ this.pendingEscalations.get(requestId)?.(decision);
15120
+ return;
15121
+ }
15122
+ const oldest = this.pendingEscalations.keys().next();
15123
+ if (!oldest.done) this.pendingEscalations.get(oldest.value)?.(decision);
15124
+ }
15125
+ /**
15126
+ * Release every parked escalation — run teardown, so none outlive the run.
15127
+ * `automatic: true`: teardown resolving a section nobody answered is the
15128
+ * same system-decided case as the early-return skips above, not a person
15129
+ * reviewing and accepting that section's output.
15130
+ */
15131
+ releasePendingEscalations(action = "skip") {
15132
+ for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
15133
+ }
14772
15134
  /**
14773
15135
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
14774
15136
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -14902,6 +15264,20 @@ ${input}` }],
14902
15264
  this.mediaSink = sink;
14903
15265
  this.registerMediaTools(this.workspacePath);
14904
15266
  }
15267
+ /**
15268
+ * Let the agent read the page the user has open in the host's browser.
15269
+ *
15270
+ * Host-supplied because only a host WITH a browser can answer it — so the
15271
+ * tool is simply absent in the CLI and in hosted runs, rather than present
15272
+ * and always failing. Registered outside `enabledTools` for the same reason
15273
+ * media tools are: that allowlist is a blast-radius control for tools that
15274
+ * touch the machine, and reading a page the user already opened touches
15275
+ * nothing. `disabledTools` still removes it.
15276
+ */
15277
+ setCurrentPageProvider(provider) {
15278
+ if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
15279
+ this.toolRegistry.register(new CurrentPageTool(provider));
15280
+ }
14905
15281
  registerMediaTools(workspacePath) {
14906
15282
  const denied = new Set(this.config.tools?.disabledTools ?? []);
14907
15283
  const permitted = (name) => !denied.has(name);
@@ -15034,8 +15410,12 @@ ${last.partialOutput}` : "");
15034
15410
  for (const server of this.config.tools.mcpServers) {
15035
15411
  try {
15036
15412
  const authProvider = server.oauthStore ? fileOAuthProvider(server.oauthStore) : void 0;
15037
- await this.mcpClient.connect(server, authProvider ? { authProvider } : {});
15038
- this.toolRegistry.registerMcpTools(this.mcpClient);
15413
+ const connect = () => this.mcpClient.connect(server, authProvider ? { authProvider } : {});
15414
+ await (server.oauthStore ? withOAuthStoreLock(server.oauthStore, connect) : connect());
15415
+ this.toolRegistry.registerMcpTools(
15416
+ this.mcpClient,
15417
+ (name) => !(this.config.tools?.disabledTools ?? []).includes(name)
15418
+ );
15039
15419
  } catch (err) {
15040
15420
  console.error(`Failed to connect to MCP server "${server.name}":`, err);
15041
15421
  }
@@ -15500,6 +15880,7 @@ ${prompt}` : prompt;
15500
15880
  } else if (complexity === "Moderate") {
15501
15881
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
15502
15882
  t2.setRunBreaker(runBreaker);
15883
+ t2.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
15503
15884
  t2.setPresenter(true);
15504
15885
  t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
15505
15886
  if (identityPrompt) {
@@ -15544,11 +15925,17 @@ ${prompt}` : prompt;
15544
15925
  t2Results = [t2Result];
15545
15926
  const completed = t2Result.t3Results.filter((r) => r.status === "COMPLETED");
15546
15927
  if (completed.length > 0) {
15547
- finalOutput = t2Result.sectionSummary + "\n\n" + completed.map((r) => r.output).join("\n\n");
15928
+ finalOutput = composeRootSectionOutput(
15929
+ t2Result,
15930
+ completed.map((r) => typeof r.output === "string" ? r.output : JSON.stringify(r.output))
15931
+ );
15548
15932
  } else {
15549
15933
  const partial = t2Result.t3Results.map((r) => typeof r.output === "string" ? r.output : "").filter((o) => o.trim()).sort((a, b) => b.length - a.length)[0];
15550
15934
  const reasons = Array.from(
15551
- new Set(t2Result.t3Results.flatMap((r) => r.issues ?? []).filter(Boolean))
15935
+ new Set([
15936
+ ...t2Result.issues ?? [],
15937
+ ...t2Result.t3Results.flatMap((r) => r.issues ?? [])
15938
+ ].filter(Boolean))
15552
15939
  );
15553
15940
  if (partial) {
15554
15941
  finalOutput = reasons.length ? `${partial}
@@ -15561,6 +15948,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
15561
15948
  } else {
15562
15949
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
15563
15950
  t1.setRunBreaker(runBreaker);
15951
+ t1.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
15564
15952
  t1.setPresenter(true);
15565
15953
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
15566
15954
  if (identityPrompt) {
@@ -15585,6 +15973,8 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
15585
15973
  finalOutput = result.output;
15586
15974
  t2Results = result.t2Results;
15587
15975
  }
15976
+ const budgetInfo = this.router.budgetExceededInfo();
15977
+ if (budgetInfo) throw new CascadeRouter.BudgetExceededError(budgetInfo.reason);
15588
15978
  const trip = runBreaker.reason();
15589
15979
  if (trip) {
15590
15980
  finalOutput = [
@@ -15625,6 +16015,10 @@ ${finalOutput}` : ""
15625
16015
  escalator.cancelAllPending();
15626
16016
  } catch {
15627
16017
  }
16018
+ try {
16019
+ this.releasePendingEscalations();
16020
+ } catch {
16021
+ }
15628
16022
  this.router.restoreTierModels();
15629
16023
  this.router.setRunSignal(void 0);
15630
16024
  if (this.taskAnalyzer) {
@@ -16941,6 +17335,13 @@ var ConfigManager = class {
16941
17335
  }
16942
17336
  async load() {
16943
17337
  this.config = await this.loadConfig();
17338
+ const servers = this.config.tools?.mcpServers;
17339
+ const { servers: disambiguated, renames } = servers?.length ? disambiguateMcpServerNames(servers) : { servers, renames: [] };
17340
+ const mcpNamesChanged = renames.length > 0;
17341
+ if (mcpNamesChanged && this.config.tools) {
17342
+ this.config.tools.mcpServers = disambiguated;
17343
+ this.renameMcpServerReferences(renames);
17344
+ }
16944
17345
  this.ignore = new CascadeIgnore();
16945
17346
  await this.ignore.load(this.workspacePath);
16946
17347
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
@@ -16949,6 +17350,7 @@ var ConfigManager = class {
16949
17350
  await this.injectEnvKeys();
16950
17351
  this.config.providers = mergeGlobalCredentials(this.config.providers, loadGlobalCredentials(this.globalDir));
16951
17352
  await this.ensureDefaultIdentity();
17353
+ if (mcpNamesChanged) await this.save();
16952
17354
  }
16953
17355
  getConfig() {
16954
17356
  return this.config;
@@ -17032,6 +17434,66 @@ var ConfigManager = class {
17032
17434
  this.config.providers.push({ type: "ollama" });
17033
17435
  }
17034
17436
  }
17437
+ /**
17438
+ * Follow a disambiguation rename into `tools.mcpTrusted`.
17439
+ *
17440
+ * `mcpTrusted` is matched by EXACT name (`McpClient.connect()`). Two shapes
17441
+ * of rename reach here, and they need OPPOSITE treatment:
17442
+ *
17443
+ * - Distinct raw names that only collide via sanitizing (`foo bar` /
17444
+ * `foo@bar`): each had its OWN trust entry, and the old string now belongs
17445
+ * to nothing — the server that used it moved away, so the entry has to
17446
+ * move with it. A plain rewrite is correct here.
17447
+ * - Literally identical names (a hand-edited or duplicated config: two rows
17448
+ * both named `foo`): there is only ONE trust entry for both, since
17449
+ * `mcpTrusted` itself is deduplicated. `this.config.tools.mcpServers` has
17450
+ * already been updated to the disambiguated list by the time this runs
17451
+ * (see `load()`), so the untouched survivor is STILL named `foo` — a plain
17452
+ * rewrite would move the one entry entirely onto the renamed row and
17453
+ * leave the survivor untrusted. The entry must be kept for the survivor
17454
+ * AND granted to the renamed identity, not moved.
17455
+ *
17456
+ * Renames are grouped by `from` before either treatment is applied, and
17457
+ * each group is settled in one step. Two DIFFERENT rows can start out with
17458
+ * the exact same raw name and BOTH get renamed away (e.g. three servers
17459
+ * named `foo bar`, `foo@bar`, `foo@bar` — the two `foo@bar` rows collide
17460
+ * with `foo bar` and with each other, so both are renamed to distinct
17461
+ * identities while `foo bar` survives untouched). Processing renames one at
17462
+ * a time in that shape drops coverage: the first rename's REPLACE branch
17463
+ * would erase `from` from `trusted` entirely, so the guard on the second
17464
+ * rename with the same `from` sees it already gone and silently skips —
17465
+ * granting trust to only one of the two renamed identities. Grouping first
17466
+ * means every renamed identity that shares a `from` is added or substituted
17467
+ * together, in the one pass that still sees the original entry.
17468
+ *
17469
+ * `tools.disabledTools` deliberately gets no equivalent treatment. It's
17470
+ * matched by sanitized PREFIX, and a prefix collision is exactly what made
17471
+ * an existing entry ambiguous between the two servers in the first place —
17472
+ * there is no way to tell, from the stored string alone, which of the two
17473
+ * a denial was meant for. Leaving those entries untouched resolves that
17474
+ * ambiguity for free: the survivor keeps the original (now unique) prefix,
17475
+ * so an old entry keeps applying to it, while the renamed server starts
17476
+ * clean under its new prefix. Rewriting the entry to follow the rename
17477
+ * would do the opposite — move a denial that already worked for the
17478
+ * survivor onto a server it may never have meant to cover.
17479
+ */
17480
+ renameMcpServerReferences(renames) {
17481
+ const tools = this.config.tools;
17482
+ if (!tools?.mcpTrusted?.length || !renames.length) return;
17483
+ const currentNames = new Set((tools.mcpServers ?? []).map((s) => s.name));
17484
+ const byFrom = /* @__PURE__ */ new Map();
17485
+ for (const { from, to } of renames) {
17486
+ const tos = byFrom.get(from);
17487
+ if (tos) tos.push(to);
17488
+ else byFrom.set(from, [to]);
17489
+ }
17490
+ let trusted = tools.mcpTrusted;
17491
+ for (const [from, tos] of byFrom) {
17492
+ if (!trusted.includes(from)) continue;
17493
+ trusted = currentNames.has(from) ? [...trusted, ...tos] : [...trusted.filter((n) => n !== from), ...tos];
17494
+ }
17495
+ tools.mcpTrusted = Array.from(new Set(trusted));
17496
+ }
17035
17497
  async ensureDefaultIdentity() {
17036
17498
  const existing = this.store.getDefaultIdentity();
17037
17499
  if (existing) return;
@@ -17154,6 +17616,14 @@ var DashboardSocket = class {
17154
17616
  this.secret = options.secret;
17155
17617
  this.setupHandlers();
17156
17618
  }
17619
+ /** How many sockets are currently in a room — 0 means nobody can answer. */
17620
+ roomSize(room) {
17621
+ return this.io.sockets.adapter.rooms.get(room)?.size ?? 0;
17622
+ }
17623
+ /** Is this exact connection still live? A reconnect issues a NEW socket id. */
17624
+ hasSocket(socketId) {
17625
+ return this.io.sockets.sockets.has(socketId);
17626
+ }
17157
17627
  broadcastToRoom(room, event, data) {
17158
17628
  this.io.to(room).emit(event, data);
17159
17629
  }
@@ -17225,6 +17695,22 @@ var DashboardSocket = class {
17225
17695
  });
17226
17696
  });
17227
17697
  }
17698
+ /**
17699
+ * A client subscribed to a session's room.
17700
+ *
17701
+ * The host uses this to replay a gate the run is already parked on. Socket.IO
17702
+ * does not buffer room emissions, so a prompt broadcast while the renderer was
17703
+ * reconnecting is simply gone; without a replay the section waits out its full
17704
+ * timeout with the user sitting in front of a screen that never asked.
17705
+ */
17706
+ onJoinSession(callback) {
17707
+ this.io.on("connection", (socket) => {
17708
+ socket.on("join:session", (payload) => {
17709
+ const { sessionId } = normalizeSessionSubscriptionPayload(payload);
17710
+ if (sessionId) callback(sessionId, socket.id);
17711
+ });
17712
+ });
17713
+ }
17228
17714
  onSessionRate(callback) {
17229
17715
  this.io.on("connection", (socket) => {
17230
17716
  socket.on("session:rate", (payload) => {
@@ -17262,6 +17748,31 @@ var DashboardSocket = class {
17262
17748
  });
17263
17749
  });
17264
17750
  }
17751
+ /**
17752
+ * A section escalated and the run is parked waiting for an answer. The
17753
+ * desktop shows the escalation modal on `escalation:decision-required` and
17754
+ * answers here.
17755
+ *
17756
+ * Unlike a plan decision, silence is NOT taken as consent — the SDK fails
17757
+ * the section on timeout — so this handler only ever forwards a real choice.
17758
+ */
17759
+ onEscalationDecision(callback) {
17760
+ this.io.on("connection", (socket) => {
17761
+ socket.on("escalation:decide", (payload) => {
17762
+ const action = payload?.action;
17763
+ if (typeof payload?.sessionId !== "string") return;
17764
+ if (action !== "retry" && action !== "skip" && action !== "guidance") return;
17765
+ callback({
17766
+ sessionId: payload.sessionId,
17767
+ // Identifies WHICH parked section this answers — sections in a wave
17768
+ // run concurrently, so more than one can be waiting.
17769
+ requestId: typeof payload.requestId === "string" ? payload.requestId : void 0,
17770
+ action,
17771
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0
17772
+ });
17773
+ });
17774
+ });
17775
+ }
17265
17776
  onSessionSteer(callback) {
17266
17777
  this.io.on("connection", (socket) => {
17267
17778
  socket.on("session:steer", (payload) => {
@@ -17420,6 +17931,7 @@ function aggregateCostStats(sessions, opts = {}) {
17420
17931
 
17421
17932
  // src/dashboard/server.ts
17422
17933
  var __dirname$1 = path24__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
17934
+ var ORPHANED_ESCALATION_GRACE_MS = 3e4;
17423
17935
  var DashboardServer = class {
17424
17936
  app;
17425
17937
  httpServer;
@@ -17430,6 +17942,13 @@ var DashboardServer = class {
17430
17942
  globalStore = null;
17431
17943
  broadcastTimer = null;
17432
17944
  activeSessions = /* @__PURE__ */ new Map();
17945
+ /**
17946
+ * Supplied by the desktop shell so a run can read the page the user has
17947
+ * open in the built-in browser. Absent in the CLI, where there is no
17948
+ * browser — so the tool is simply not registered rather than present and
17949
+ * always failing.
17950
+ */
17951
+ currentPageProvider;
17433
17952
  activeControllers = /* @__PURE__ */ new Map();
17434
17953
  /**
17435
17954
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -17444,6 +17963,17 @@ var DashboardServer = class {
17444
17963
  * map is how that answer reaches the run that's blocked on it.
17445
17964
  */
17446
17965
  pendingApprovals = /* @__PURE__ */ new Map();
17966
+ /**
17967
+ * The escalation prompt a session is currently parked on, kept so it can be
17968
+ * delivered to a client that was not reachable when it was raised.
17969
+ *
17970
+ * Socket.IO buffers nothing: a prompt emitted while the renderer is mid
17971
+ * reconnect is gone, and the section then waits out its full five minutes
17972
+ * with the user in front of a screen that never asked them anything. Holding
17973
+ * the payload lets `join:session` replay it, and lets a run with no reachable
17974
+ * client be settled instead of parked (see ORPHANED_ESCALATION_GRACE_MS).
17975
+ */
17976
+ pendingEscalations = /* @__PURE__ */ new Map();
17447
17977
  /**
17448
17978
  * The orchestration decision trail ("why") of each session's most recent
17449
17979
  * run — captured when the run ends so the desktop's Why panel can show it
@@ -17521,20 +18051,29 @@ var DashboardServer = class {
17521
18051
  if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
17522
18052
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
17523
18053
  this.activeSessions.set(sessionId, cascade);
18054
+ if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
17524
18055
  cascade.on("stream:token", (e) => {
17525
- this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
18056
+ this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
17526
18057
  });
17527
18058
  cascade.on("tier:status", (e) => {
17528
- this.socket.emitToSocket(socketId, "tier:status", { sessionId, ...e });
18059
+ this.emitToSessionClients(socketId, sessionId, "tier:status", { sessionId, ...e });
17529
18060
  });
17530
18061
  cascade.on("permission:user-required", (e) => {
17531
- this.socket.emitToSocket(socketId, "permission:user-required", { sessionId, ...e });
18062
+ this.emitToSessionClients(socketId, sessionId, "permission:user-required", { sessionId, ...e });
17532
18063
  });
17533
18064
  cascade.on("peer:message", (e) => {
17534
18065
  this.socket.emitPeerMessage(e);
17535
18066
  });
17536
18067
  cascade.on("plan:approval-required", (e) => {
17537
- this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
18068
+ this.emitToSessionClients(socketId, sessionId, "plan:approval-required", { sessionId, ...e });
18069
+ });
18070
+ cascade.on("escalation:decision-required", (e) => {
18071
+ this.raiseEscalation(sessionId, socketId, e);
18072
+ });
18073
+ cascade.on("escalation:timeout", (e) => {
18074
+ const ev = e;
18075
+ if (ev.requestId) this.clearPendingEscalation(ev.requestId);
18076
+ this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
17538
18077
  });
17539
18078
  try {
17540
18079
  const result = await cascade.run({
@@ -17545,7 +18084,7 @@ var DashboardServer = class {
17545
18084
  this.recordSessionTask(sessionId, result.taskId);
17546
18085
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
17547
18086
  this.captureWhy(sessionId, cascade, result);
17548
- this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
18087
+ this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
17549
18088
  this.socket.broadcast("cost:update", {
17550
18089
  sessionId,
17551
18090
  totalTokens: result.usage.totalTokens,
@@ -17558,7 +18097,7 @@ var DashboardServer = class {
17558
18097
  } catch (err) {
17559
18098
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
17560
18099
  this.captureWhy(sessionId, cascade);
17561
- this.socket.emitToSocket(socketId, "session:error", {
18100
+ this.emitToSessionClients(socketId, sessionId, "session:error", {
17562
18101
  sessionId,
17563
18102
  error: err instanceof Error ? err.message : String(err)
17564
18103
  });
@@ -17566,6 +18105,7 @@ var DashboardServer = class {
17566
18105
  this.activeSessions.delete(sessionId);
17567
18106
  this.activeControllers.delete(sessionId);
17568
18107
  this.denyPendingApprovals(sessionId);
18108
+ this.clearSessionEscalations(sessionId);
17569
18109
  }
17570
18110
  });
17571
18111
  this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
@@ -17575,10 +18115,20 @@ var DashboardServer = class {
17575
18115
  editedPlan
17576
18116
  );
17577
18117
  });
18118
+ this.socket.onEscalationDecision(({ sessionId, requestId, action, note }) => {
18119
+ if (requestId) this.clearPendingEscalation(requestId);
18120
+ else this.clearOldestSessionEscalation(sessionId);
18121
+ this.activeSessions.get(sessionId)?.resolveEscalation(action, note, requestId);
18122
+ });
18123
+ this.socket.onJoinSession((sessionId, socketId) => {
18124
+ this.replayEscalations(sessionId, socketId);
18125
+ });
17578
18126
  this.socket.onSessionHalt((sessionId) => {
17579
18127
  this.activeControllers.get(sessionId)?.abort();
17580
18128
  this.denyPendingApprovals(sessionId);
17581
18129
  this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
18130
+ this.clearSessionEscalations(sessionId);
18131
+ this.activeSessions.get(sessionId)?.resolveEscalation("skip", void 0, void 0, true);
17582
18132
  });
17583
18133
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
17584
18134
  const pending = this.pendingApprovals.get(requestId);
@@ -17593,6 +18143,10 @@ var DashboardServer = class {
17593
18143
  }
17594
18144
  });
17595
18145
  }
18146
+ /** Wire the host's browser view in (desktop only). */
18147
+ setCurrentPageProvider(provider) {
18148
+ this.currentPageProvider = provider;
18149
+ }
17596
18150
  async start() {
17597
18151
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
17598
18152
  if (!isLoopback) {
@@ -17881,6 +18435,116 @@ var DashboardServer = class {
17881
18435
  this.activeSessions.delete(sessionId);
17882
18436
  }
17883
18437
  }
18438
+ /**
18439
+ * Is anyone able to answer for this session right now?
18440
+ *
18441
+ * The originating socket first — that connection is definitely watching this
18442
+ * run — then the session room, which is where a client lands after a
18443
+ * reconnect issues it a new socket id.
18444
+ */
18445
+ hasResponder(sessionId, socketId) {
18446
+ if (socketId && this.socket.hasSocket(socketId)) return true;
18447
+ return this.socket.roomSize(`session:${sessionId}`) > 0;
18448
+ }
18449
+ /**
18450
+ * Send a run event to whoever is watching this session.
18451
+ *
18452
+ * A captured socket id goes stale the moment Socket.IO reconnects — the
18453
+ * client is back, but under a new id, and `emitToSocket` on the old one is a
18454
+ * silent no-op. Falling back to the session room keeps the run addressable
18455
+ * across a reconnect; emitting to one OR the other (never both) keeps a
18456
+ * client that is both the originator and a room member from seeing every
18457
+ * token twice.
18458
+ */
18459
+ emitToSessionClients(socketId, sessionId, event, data) {
18460
+ if (socketId && this.socket.hasSocket(socketId)) this.socket.emitToSocket(socketId, event, data);
18461
+ else this.socket.broadcastToRoom(`session:${sessionId}`, event, data);
18462
+ }
18463
+ /**
18464
+ * Raise an escalation prompt, and make sure it can still be answered.
18465
+ *
18466
+ * Held in `pendingEscalations` — keyed by requestId, because a Complex wave
18467
+ * dispatches sections concurrently and two can be parked at once — so a
18468
+ * client that joins late (or rejoins after a reconnect) gets every waiting
18469
+ * prompt replayed. Keying by session would drop the first of a pair: its
18470
+ * grace timer would be cleared by the second, leaving it parked for the full
18471
+ * timeout with nobody able to answer it. If nobody is reachable at all, the gate is
18472
+ * settled as `skip` after a short grace period rather than parked for the
18473
+ * full timeout: that matches the SDK's policy when no listener exists, and
18474
+ * five minutes of a held run is a real cost for a REST caller who has already
18475
+ * hung up. The grace period exists so an ordinary two-second reconnect is not
18476
+ * mistaken for an absent user.
18477
+ */
18478
+ raiseEscalation(sessionId, socketId, event) {
18479
+ const requestId = typeof event.requestId === "string" && event.requestId ? event.requestId : crypto4.randomUUID();
18480
+ const payload = { sessionId, ...event };
18481
+ const entry = { payload, sessionId, requestId, raisedAt: Date.now() };
18482
+ this.pendingEscalations.set(requestId, entry);
18483
+ if (this.hasResponder(sessionId, socketId)) {
18484
+ this.emitToSessionClients(socketId, sessionId, "escalation:decision-required", payload);
18485
+ return;
18486
+ }
18487
+ entry.graceTimer = setTimeout(() => {
18488
+ if (this.pendingEscalations.get(requestId) !== entry) return;
18489
+ if (this.hasResponder(sessionId, socketId)) return;
18490
+ this.pendingEscalations.delete(requestId);
18491
+ this.activeSessions.get(sessionId)?.resolveEscalation("skip", "No client was connected to answer.", requestId, true);
18492
+ }, ORPHANED_ESCALATION_GRACE_MS);
18493
+ }
18494
+ /**
18495
+ * Replay EVERY parked prompt to a client that just subscribed to the session.
18496
+ *
18497
+ * Plural because a Complex run dispatches sections concurrently, so a wave can
18498
+ * leave more than one waiting at the same time — the same reason the SDK keys
18499
+ * its gates and the clients queue theirs.
18500
+ *
18501
+ * `timeoutMs` is recomputed as the time actually LEFT, because the SDK's timer
18502
+ * has been running since the prompt was raised. Sent verbatim, a client
18503
+ * reconnecting four minutes in would show a fresh five-minute countdown and
18504
+ * then watch the section fail with four of those minutes still on the clock.
18505
+ * A remaining duration is used rather than an absolute deadline so the two
18506
+ * sides never have to agree on a wall clock.
18507
+ */
18508
+ replayEscalations(sessionId, socketId) {
18509
+ for (const entry of this.pendingEscalations.values()) {
18510
+ if (entry.sessionId !== sessionId) continue;
18511
+ if (entry.graceTimer) {
18512
+ clearTimeout(entry.graceTimer);
18513
+ entry.graceTimer = void 0;
18514
+ }
18515
+ const originalTimeout = typeof entry.payload.timeoutMs === "number" ? entry.payload.timeoutMs : 0;
18516
+ const remaining = Math.max(0, originalTimeout - (Date.now() - entry.raisedAt));
18517
+ this.socket.emitToSocket(socketId, "escalation:decision-required", { ...entry.payload, timeoutMs: remaining });
18518
+ }
18519
+ }
18520
+ /** Forget ONE parked prompt — the one that was answered or timed out. */
18521
+ clearPendingEscalation(requestId) {
18522
+ const entry = this.pendingEscalations.get(requestId);
18523
+ if (!entry) return;
18524
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
18525
+ this.pendingEscalations.delete(requestId);
18526
+ }
18527
+ /**
18528
+ * Forget the session's OLDEST prompt — for a client that answered without a
18529
+ * requestId, which is exactly the gate `resolveEscalation` will settle.
18530
+ * Insertion order makes `values()` oldest-first.
18531
+ */
18532
+ clearOldestSessionEscalation(sessionId) {
18533
+ for (const [id, entry] of this.pendingEscalations) {
18534
+ if (entry.sessionId !== sessionId) continue;
18535
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
18536
+ this.pendingEscalations.delete(id);
18537
+ return;
18538
+ }
18539
+ }
18540
+ /** Forget every prompt a session was parked on (run ended, or halted). */
18541
+ clearSessionEscalations(sessionId) {
18542
+ for (const [id, entry] of this.pendingEscalations) {
18543
+ if (entry.sessionId !== sessionId) continue;
18544
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
18545
+ this.pendingEscalations.delete(id);
18546
+ }
18547
+ }
17884
18548
  /** Deny + clear any approvals still pending for a session (run end / abort). */
17885
18549
  denyPendingApprovals(sessionId) {
17886
18550
  for (const [id, pending] of this.pendingApprovals) {
@@ -18381,6 +19045,7 @@ ${prompt}`;
18381
19045
  void (async () => {
18382
19046
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
18383
19047
  this.activeSessions.set(sessionId, cascade);
19048
+ if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
18384
19049
  cascade.on("stream:token", (e) => {
18385
19050
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
18386
19051
  });
@@ -18396,6 +19061,14 @@ ${prompt}`;
18396
19061
  cascade.on("plan:approval-required", (e) => {
18397
19062
  this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
18398
19063
  });
19064
+ cascade.on("escalation:decision-required", (e) => {
19065
+ this.raiseEscalation(sessionId, void 0, e);
19066
+ });
19067
+ cascade.on("escalation:timeout", (e) => {
19068
+ const ev = e;
19069
+ if (ev.requestId) this.clearPendingEscalation(ev.requestId);
19070
+ this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
19071
+ });
18399
19072
  try {
18400
19073
  const result = await cascade.run({
18401
19074
  prompt: runPrompt,
@@ -18423,6 +19096,7 @@ ${prompt}`;
18423
19096
  } finally {
18424
19097
  this.activeSessions.delete(sessionId);
18425
19098
  this.denyPendingApprovals(sessionId);
19099
+ this.clearSessionEscalations(sessionId);
18426
19100
  }
18427
19101
  })();
18428
19102
  });
@@ -18755,6 +19429,41 @@ var HooksRunner = class {
18755
19429
  }
18756
19430
  };
18757
19431
 
19432
+ // src/mcp/discover.ts
19433
+ async function discoverMcpTools(servers) {
19434
+ const results = [];
19435
+ for (const server of servers) {
19436
+ const client = new McpClient({ trustedServers: [server.name] });
19437
+ try {
19438
+ const authProvider = server.oauthStore ? fileOAuthProvider(server.oauthStore) : void 0;
19439
+ const connect = () => client.connect(server, authProvider ? { authProvider } : {});
19440
+ await (server.oauthStore ? withOAuthStoreLock(server.oauthStore, connect) : connect());
19441
+ const parsed = client.getToolDefinitions().flatMap((def) => {
19442
+ const [, serverName, toolName] = def.name.split("::");
19443
+ return serverName && toolName ? [{ def, server: serverName, tool: toolName }] : [];
19444
+ });
19445
+ const names = assignMcpToolNames(parsed);
19446
+ const tools = parsed.map(({ def, server: s, tool }, i) => ({
19447
+ server: s,
19448
+ tool,
19449
+ name: names[i],
19450
+ description: def.description.replace(`[MCP:${s}] `, "")
19451
+ }));
19452
+ results.push({ server: server.name, tools });
19453
+ } catch (err) {
19454
+ results.push({
19455
+ server: server.name,
19456
+ tools: [],
19457
+ error: err instanceof Error ? err.message : String(err)
19458
+ });
19459
+ } finally {
19460
+ await client.disconnectAll().catch(() => {
19461
+ });
19462
+ }
19463
+ }
19464
+ return results;
19465
+ }
19466
+
18758
19467
  // src/retrieval/chunk.ts
18759
19468
  var DEFAULTS2 = { targetChars: 2e3, overlapChars: 200, minChars: 80 };
18760
19469
  function splitLongBlock(block, max) {
@@ -19293,11 +20002,55 @@ function mergeByKey(local, incoming, key) {
19293
20002
  for (const i of incoming) map.set(key(i), i);
19294
20003
  return [...map.values()];
19295
20004
  }
20005
+ function uniqueServerName(desired, takenPrefixes) {
20006
+ if (!takenPrefixes.has(mcpServerPrefix(desired))) return desired;
20007
+ let n = 2;
20008
+ while (takenPrefixes.has(mcpServerPrefix(`${desired} ${n}`))) n++;
20009
+ return `${desired} ${n}`;
20010
+ }
20011
+ function mergeMcpServers(local, incoming) {
20012
+ const rewrite = /* @__PURE__ */ new Map();
20013
+ if (!incoming?.length) return { servers: local, synced: [], rewrite };
20014
+ const byName = /* @__PURE__ */ new Map();
20015
+ for (const l of local ?? []) byName.set(l.name, l);
20016
+ const prefixes = new Set([...byName.values()].map((m) => mcpServerPrefix(m.name)));
20017
+ const synced = [];
20018
+ for (const inc of incoming) {
20019
+ if (byName.has(inc.name)) {
20020
+ byName.set(inc.name, inc);
20021
+ synced.push(inc);
20022
+ continue;
20023
+ }
20024
+ const name = uniqueServerName(inc.name, prefixes);
20025
+ if (name !== inc.name) rewrite.set(mcpServerPrefix(inc.name), mcpServerPrefix(name));
20026
+ const server = name === inc.name ? inc : { ...inc, name };
20027
+ byName.set(name, server);
20028
+ synced.push(server);
20029
+ prefixes.add(mcpServerPrefix(name));
20030
+ }
20031
+ return { servers: [...byName.values()], synced, rewrite };
20032
+ }
20033
+ function rewriteDisabledTools(tools, rewrite) {
20034
+ if (!tools?.length || rewrite.size === 0) return tools;
20035
+ return tools.map((t) => {
20036
+ for (const [from, to] of rewrite) if (t.startsWith(from)) return to + t.slice(from.length);
20037
+ return t;
20038
+ });
20039
+ }
20040
+ function mergeDisabledTools(local, incoming, syncedServers) {
20041
+ const prefixes = (syncedServers ?? []).map((m) => mcpServerPrefix(m.name));
20042
+ const covered = (tool) => prefixes.some((p) => tool.startsWith(p));
20043
+ const kept = (local ?? []).filter((t) => !covered(t));
20044
+ const merged = [.../* @__PURE__ */ new Set([...kept, ...incoming ?? []])];
20045
+ return merged.length ? merged : void 0;
20046
+ }
19296
20047
  function gatherSyncBundle(config) {
19297
- const bundle = { v: 1 };
20048
+ const bundle = { v: 2 };
19298
20049
  if (config.providers?.length) bundle.providers = config.providers;
19299
20050
  if (config.tools?.webSearch) bundle.webSearch = config.tools.webSearch;
19300
20051
  if (config.tools?.mcpServers?.length) bundle.mcpServers = config.tools.mcpServers;
20052
+ const syncedDisabledTools = config.tools?.disabledTools?.filter(isMcpToolName);
20053
+ if (syncedDisabledTools?.length) bundle.disabledTools = syncedDisabledTools;
19301
20054
  if (config.models) bundle.models = config.models;
19302
20055
  if (config.budget) bundle.budget = config.budget;
19303
20056
  const prefs = {};
@@ -19312,7 +20065,16 @@ function applySyncBundle(bundle, config) {
19312
20065
  const next = { ...config, tools: { ...config.tools } };
19313
20066
  if (bundle.providers) next.providers = mergeByKey(config.providers, bundle.providers, providerSig) ?? [];
19314
20067
  if (bundle.webSearch) next.tools.webSearch = { ...config.tools?.webSearch, ...bundle.webSearch };
19315
- if (bundle.mcpServers) next.tools.mcpServers = mergeByKey(config.tools?.mcpServers, bundle.mcpServers, (m) => m.name);
20068
+ const mcp = mergeMcpServers(config.tools?.mcpServers, bundle.mcpServers);
20069
+ if (bundle.mcpServers) next.tools.mcpServers = mcp.servers;
20070
+ const carriesSelections = bundle.v >= 2 || bundle.disabledTools !== void 0;
20071
+ if (carriesSelections && (bundle.mcpServers || bundle.disabledTools)) {
20072
+ next.tools.disabledTools = mergeDisabledTools(
20073
+ config.tools?.disabledTools,
20074
+ rewriteDisabledTools(bundle.disabledTools, mcp.rewrite),
20075
+ bundle.mcpServers ? mcp.synced : void 0
20076
+ );
20077
+ }
19316
20078
  if (bundle.models) next.models = { ...config.models, ...bundle.models };
19317
20079
  if (bundle.budget) next.budget = { ...config.budget, ...bundle.budget };
19318
20080
  if (bundle.prefs) {
@@ -19347,6 +20109,7 @@ exports.CascadeToolError = CascadeToolError;
19347
20109
  exports.CloudClient = CloudClient;
19348
20110
  exports.CodeSearchTool = CodeSearchTool;
19349
20111
  exports.ConfigManager = ConfigManager;
20112
+ exports.CurrentPageTool = CurrentPageTool;
19350
20113
  exports.DEFAULT_API_PORT = DEFAULT_API_PORT;
19351
20114
  exports.DEFAULT_APPROVAL_REQUIRED = DEFAULT_APPROVAL_REQUIRED;
19352
20115
  exports.DEFAULT_AUTO_SUMMARIZE_AT = DEFAULT_AUTO_SUMMARIZE_AT;
@@ -19369,6 +20132,8 @@ exports.HooksRunner = HooksRunner;
19369
20132
  exports.Keystore = Keystore;
19370
20133
  exports.LLMReranker = LLMReranker;
19371
20134
  exports.LM_STUDIO_BASE_URL = LM_STUDIO_BASE_URL;
20135
+ exports.MAX_PAGE_TEXT_CHARS = MAX_PAGE_TEXT_CHARS;
20136
+ exports.MCP_TOOL_PREFIX = MCP_TOOL_PREFIX;
19372
20137
  exports.MODELS = MODELS;
19373
20138
  exports.McpClient = McpClient;
19374
20139
  exports.McpOAuthProvider = McpOAuthProvider;
@@ -19405,7 +20170,9 @@ exports.connectMcpWithLoopbackOAuth = connectMcpWithLoopbackOAuth;
19405
20170
  exports.createCascade = createCascade;
19406
20171
  exports.decryptSyncBlob = decryptJSON;
19407
20172
  exports.diffManifest = diffManifest;
20173
+ exports.disambiguateMcpServerNames = disambiguateMcpServerNames;
19408
20174
  exports.discoverMcpAuthServer = discoverMcpAuthServer;
20175
+ exports.discoverMcpTools = discoverMcpTools;
19409
20176
  exports.distillSessionFacts = distillSessionFacts;
19410
20177
  exports.embedderFromProviders = embedderFromProviders;
19411
20178
  exports.encryptSyncBlob = encryptJSON;
@@ -19414,14 +20181,19 @@ exports.gatherSyncBundle = gatherSyncBundle;
19414
20181
  exports.hashContent = hashContent;
19415
20182
  exports.heuristicCodeChunker = heuristicCodeChunker;
19416
20183
  exports.inferAzureBaseModel = inferAzureBaseModel;
20184
+ exports.isMcpToolName = isMcpToolName;
20185
+ exports.mcpServerPrefix = mcpServerPrefix;
20186
+ exports.mcpToolName = mcpToolName;
19417
20187
  exports.nodeHttpFetch = nodeHttpFetch;
19418
20188
  exports.parseRankOrder = parseRankOrder;
19419
20189
  exports.planRetrieval = planRetrieval;
19420
20190
  exports.preferIpv4Host = preferIpv4Host;
19421
20191
  exports.reciprocalRankFusion = reciprocalRankFusion;
19422
20192
  exports.refreshMcpToken = refreshMcpToken;
20193
+ exports.removeMcpServerDenials = removeMcpServerDenials;
19423
20194
  exports.runCascade = runCascade;
19424
20195
  exports.sessionWorthRemembering = sessionWorthRemembering;
19425
20196
  exports.streamCascade = streamCascade;
20197
+ exports.uniqueMcpServerName = uniqueMcpServerName;
19426
20198
  //# sourceMappingURL=index.cjs.map
19427
20199
  //# sourceMappingURL=index.cjs.map