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/cli.cjs CHANGED
@@ -1799,7 +1799,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1799
1799
  var init_constants = __esm({
1800
1800
  "src/constants.ts"() {
1801
1801
  init_pricing();
1802
- CASCADE_VERSION = "0.54.0";
1802
+ CASCADE_VERSION = "0.55.0";
1803
1803
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1804
1804
  CASCADE_DB_FILE = ".cascade/memory.db";
1805
1805
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -5442,6 +5442,102 @@ function mergeGlobalCredentials(workspaceProviders, globalProviders) {
5442
5442
  }
5443
5443
  return merged;
5444
5444
  }
5445
+ var MCP_TOOL_PREFIX = "mcp__";
5446
+ var MAX_SEGMENT_LENGTH = 24;
5447
+ var SEGMENT_HASH_LENGTH = 8;
5448
+ function boundedSegment(raw) {
5449
+ const clean = sanitizeToolNameSegment(raw);
5450
+ if (clean.length <= MAX_SEGMENT_LENGTH) return clean;
5451
+ const hash = crypto.createHash("sha256").update(raw, "utf8").digest("hex").slice(0, SEGMENT_HASH_LENGTH);
5452
+ return `${clean.slice(0, MAX_SEGMENT_LENGTH - SEGMENT_HASH_LENGTH - 1)}_${hash}`;
5453
+ }
5454
+ function isStableSegment(raw) {
5455
+ return sanitizeToolNameSegment(raw) === raw && raw.length <= MAX_SEGMENT_LENGTH;
5456
+ }
5457
+ function sanitizeToolNameSegment(segment) {
5458
+ const cleaned = segment.replace(/[^a-zA-Z0-9-]+/g, "_");
5459
+ let start = 0;
5460
+ let end = cleaned.length;
5461
+ while (start < end && cleaned[start] === "_") start++;
5462
+ while (end > start && cleaned[end - 1] === "_") end--;
5463
+ return cleaned.slice(start, end) || "_";
5464
+ }
5465
+ function mcpToolName(serverName, toolName) {
5466
+ return `${MCP_TOOL_PREFIX}${boundedSegment(serverName)}__${boundedSegment(toolName)}`;
5467
+ }
5468
+ function isMcpToolName(name) {
5469
+ return name.startsWith(MCP_TOOL_PREFIX);
5470
+ }
5471
+ function uniqueMcpServerName(desired, existingNames) {
5472
+ const taken = new Set(existingNames.map(mcpServerPrefix));
5473
+ if (!taken.has(mcpServerPrefix(desired))) return desired;
5474
+ for (let n = 2; n < 1e3; n++) {
5475
+ const candidate = `${desired} (${n})`;
5476
+ if (!taken.has(mcpServerPrefix(candidate))) return candidate;
5477
+ }
5478
+ return `${desired} (${crypto.randomUUID().slice(0, 8)})`;
5479
+ }
5480
+ function disambiguateMcpServerNames(servers) {
5481
+ const seenNames = [];
5482
+ const renames = [];
5483
+ const result = servers.map((s) => {
5484
+ const name = uniqueMcpServerName(s.name, seenNames);
5485
+ seenNames.push(name);
5486
+ if (name === s.name) return s;
5487
+ renames.push({ from: s.name, to: name });
5488
+ return { ...s, name };
5489
+ });
5490
+ return { servers: renames.length ? result : servers, renames };
5491
+ }
5492
+ function assignMcpToolNames(tools) {
5493
+ const names = new Array(tools.length);
5494
+ const bases = tools.map((t) => mcpToolName(t.server, t.tool));
5495
+ const groups = /* @__PURE__ */ new Map();
5496
+ bases.forEach((base, i) => {
5497
+ const g = groups.get(base);
5498
+ if (g) g.push(i);
5499
+ else groups.set(base, [i]);
5500
+ });
5501
+ const used = new Set(bases);
5502
+ for (const [base, idx] of groups) {
5503
+ if (idx.length === 1) {
5504
+ names[idx[0]] = base;
5505
+ continue;
5506
+ }
5507
+ const raw = (i) => `${tools[i].server}::${tools[i].tool}`;
5508
+ const isClean = (i) => isStableSegment(tools[i].server) && isStableSegment(tools[i].tool);
5509
+ const ordered = [...idx].sort((a, b) => {
5510
+ const la = isClean(a) ? 0 : 1;
5511
+ const lb = isClean(b) ? 0 : 1;
5512
+ if (la !== lb) return la - lb;
5513
+ const ra = raw(a);
5514
+ const rb = raw(b);
5515
+ return ra < rb ? -1 : ra > rb ? 1 : 0;
5516
+ });
5517
+ names[ordered[0]] = base;
5518
+ let n = 2;
5519
+ for (let rank3 = 1; rank3 < ordered.length; rank3++) {
5520
+ let candidate = `${base}_${n}`;
5521
+ while (used.has(candidate)) {
5522
+ n++;
5523
+ candidate = `${base}_${n}`;
5524
+ }
5525
+ names[ordered[rank3]] = candidate;
5526
+ used.add(candidate);
5527
+ n++;
5528
+ }
5529
+ }
5530
+ return names;
5531
+ }
5532
+ function mcpServerPrefix(serverName) {
5533
+ return `${MCP_TOOL_PREFIX}${boundedSegment(serverName)}__`;
5534
+ }
5535
+ function removeMcpServerDenials(disabledTools, serverName) {
5536
+ if (!disabledTools?.length) return disabledTools;
5537
+ const prefix = mcpServerPrefix(serverName);
5538
+ const kept = disabledTools.filter((t) => !t.startsWith(prefix));
5539
+ return kept.length === disabledTools.length ? disabledTools : kept;
5540
+ }
5445
5541
 
5446
5542
  // src/config/index.ts
5447
5543
  init_constants();
@@ -5465,6 +5561,13 @@ var ConfigManager = class {
5465
5561
  }
5466
5562
  async load() {
5467
5563
  this.config = await this.loadConfig();
5564
+ const servers = this.config.tools?.mcpServers;
5565
+ const { servers: disambiguated, renames } = servers?.length ? disambiguateMcpServerNames(servers) : { servers, renames: [] };
5566
+ const mcpNamesChanged = renames.length > 0;
5567
+ if (mcpNamesChanged && this.config.tools) {
5568
+ this.config.tools.mcpServers = disambiguated;
5569
+ this.renameMcpServerReferences(renames);
5570
+ }
5468
5571
  this.ignore = new CascadeIgnore();
5469
5572
  await this.ignore.load(this.workspacePath);
5470
5573
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
@@ -5473,6 +5576,7 @@ var ConfigManager = class {
5473
5576
  await this.injectEnvKeys();
5474
5577
  this.config.providers = mergeGlobalCredentials(this.config.providers, loadGlobalCredentials(this.globalDir));
5475
5578
  await this.ensureDefaultIdentity();
5579
+ if (mcpNamesChanged) await this.save();
5476
5580
  }
5477
5581
  getConfig() {
5478
5582
  return this.config;
@@ -5556,6 +5660,66 @@ var ConfigManager = class {
5556
5660
  this.config.providers.push({ type: "ollama" });
5557
5661
  }
5558
5662
  }
5663
+ /**
5664
+ * Follow a disambiguation rename into `tools.mcpTrusted`.
5665
+ *
5666
+ * `mcpTrusted` is matched by EXACT name (`McpClient.connect()`). Two shapes
5667
+ * of rename reach here, and they need OPPOSITE treatment:
5668
+ *
5669
+ * - Distinct raw names that only collide via sanitizing (`foo bar` /
5670
+ * `foo@bar`): each had its OWN trust entry, and the old string now belongs
5671
+ * to nothing — the server that used it moved away, so the entry has to
5672
+ * move with it. A plain rewrite is correct here.
5673
+ * - Literally identical names (a hand-edited or duplicated config: two rows
5674
+ * both named `foo`): there is only ONE trust entry for both, since
5675
+ * `mcpTrusted` itself is deduplicated. `this.config.tools.mcpServers` has
5676
+ * already been updated to the disambiguated list by the time this runs
5677
+ * (see `load()`), so the untouched survivor is STILL named `foo` — a plain
5678
+ * rewrite would move the one entry entirely onto the renamed row and
5679
+ * leave the survivor untrusted. The entry must be kept for the survivor
5680
+ * AND granted to the renamed identity, not moved.
5681
+ *
5682
+ * Renames are grouped by `from` before either treatment is applied, and
5683
+ * each group is settled in one step. Two DIFFERENT rows can start out with
5684
+ * the exact same raw name and BOTH get renamed away (e.g. three servers
5685
+ * named `foo bar`, `foo@bar`, `foo@bar` — the two `foo@bar` rows collide
5686
+ * with `foo bar` and with each other, so both are renamed to distinct
5687
+ * identities while `foo bar` survives untouched). Processing renames one at
5688
+ * a time in that shape drops coverage: the first rename's REPLACE branch
5689
+ * would erase `from` from `trusted` entirely, so the guard on the second
5690
+ * rename with the same `from` sees it already gone and silently skips —
5691
+ * granting trust to only one of the two renamed identities. Grouping first
5692
+ * means every renamed identity that shares a `from` is added or substituted
5693
+ * together, in the one pass that still sees the original entry.
5694
+ *
5695
+ * `tools.disabledTools` deliberately gets no equivalent treatment. It's
5696
+ * matched by sanitized PREFIX, and a prefix collision is exactly what made
5697
+ * an existing entry ambiguous between the two servers in the first place —
5698
+ * there is no way to tell, from the stored string alone, which of the two
5699
+ * a denial was meant for. Leaving those entries untouched resolves that
5700
+ * ambiguity for free: the survivor keeps the original (now unique) prefix,
5701
+ * so an old entry keeps applying to it, while the renamed server starts
5702
+ * clean under its new prefix. Rewriting the entry to follow the rename
5703
+ * would do the opposite — move a denial that already worked for the
5704
+ * survivor onto a server it may never have meant to cover.
5705
+ */
5706
+ renameMcpServerReferences(renames) {
5707
+ const tools = this.config.tools;
5708
+ if (!tools?.mcpTrusted?.length || !renames.length) return;
5709
+ const currentNames = new Set((tools.mcpServers ?? []).map((s) => s.name));
5710
+ const byFrom = /* @__PURE__ */ new Map();
5711
+ for (const { from, to } of renames) {
5712
+ const tos = byFrom.get(from);
5713
+ if (tos) tos.push(to);
5714
+ else byFrom.set(from, [to]);
5715
+ }
5716
+ let trusted = tools.mcpTrusted;
5717
+ for (const [from, tos] of byFrom) {
5718
+ if (!trusted.includes(from)) continue;
5719
+ trusted = currentNames.has(from) ? [...trusted, ...tos] : [...trusted.filter((n) => n !== from), ...tos];
5720
+ }
5721
+ tools.mcpTrusted = Array.from(new Set(trusted));
5722
+ }
5559
5723
  async ensureDefaultIdentity() {
5560
5724
  const existing = this.store.getDefaultIdentity();
5561
5725
  if (existing) return;
@@ -5571,26 +5735,6 @@ var ConfigManager = class {
5571
5735
  }
5572
5736
  };
5573
5737
 
5574
- // src/tools/tool-name.ts
5575
- var MCP_TOOL_PREFIX = "mcp__";
5576
- function sanitizeToolNameSegment(segment) {
5577
- const cleaned = segment.replace(/[^a-zA-Z0-9-]+/g, "_");
5578
- let start = 0;
5579
- let end = cleaned.length;
5580
- while (start < end && cleaned[start] === "_") start++;
5581
- while (end > start && cleaned[end - 1] === "_") end--;
5582
- return cleaned.slice(start, end) || "_";
5583
- }
5584
- function mcpToolName(serverName, toolName) {
5585
- return `${MCP_TOOL_PREFIX}${sanitizeToolNameSegment(serverName)}__${sanitizeToolNameSegment(toolName)}`;
5586
- }
5587
- function isMcpToolName(name) {
5588
- return name.startsWith(MCP_TOOL_PREFIX);
5589
- }
5590
- function mcpServerPrefix(serverName) {
5591
- return `${MCP_TOOL_PREFIX}${sanitizeToolNameSegment(serverName)}__`;
5592
- }
5593
-
5594
5738
  // src/cli/utils/terminal-input.ts
5595
5739
  var PASTE_START = "\x1B[200~";
5596
5740
  var PASTE_END = "\x1B[201~";
@@ -7036,6 +7180,23 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7036
7180
  */
7037
7181
  budgetState = "ok";
7038
7182
  budgetExceededReason;
7183
+ /**
7184
+ * Whether the run/session budget is permanently tripped, and why.
7185
+ *
7186
+ * A tier or worker that catches `BudgetExceededError` internally (T3Worker
7187
+ * does, so one dead-end subtask doesn't crash the whole run) absorbs the
7188
+ * exception into a normal FAILED/PARTIAL result — the tier returns as if
7189
+ * nothing unusual happened. That silently skips `Cascade.run()`'s dedicated
7190
+ * budget-exceeded handling (the `run:budget-exceeded` event and
7191
+ * `lastInterruptedRun` for `/continue`). This lets a caller check the
7192
+ * router's own state directly after a tier returns, regardless of how that
7193
+ * tier chose to report the error internally.
7194
+ */
7195
+ budgetExceededInfo() {
7196
+ if (this.runBudgetExceeded) return { reason: this.runBudgetExceededReason ?? "Per-task budget exceeded." };
7197
+ if (this.budgetState === "exceeded") return { reason: this.budgetExceededReason ?? "Session budget exceeded." };
7198
+ return null;
7199
+ }
7039
7200
  tpmLimiter;
7040
7201
  localQueue;
7041
7202
  taskAnalyzer;
@@ -8694,6 +8855,13 @@ Now execute your subtask using this context where relevant.`
8694
8855
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput2, "FAILED");
8695
8856
  return this.buildResult("ESCALATED", finalOutput2, { checksRun, passed, failed }, issues, correctionAttempts);
8696
8857
  }
8858
+ if (err instanceof Error && err.name === "BudgetExceededError") {
8859
+ issues.push(errMsg);
8860
+ const stopped = output || errMsg;
8861
+ this.setStatus("FAILED", stopped);
8862
+ this.peerBus?.publish(this.id, assignment.subtaskId, stopped, "FAILED");
8863
+ return this.buildResult("FAILED", stopped, { checksRun, passed, failed }, issues, correctionAttempts);
8864
+ }
8697
8865
  issues.push(`Execution error: ${errMsg}`);
8698
8866
  const finalOutput = output || errMsg;
8699
8867
  this.setStatus("FAILED", finalOutput);
@@ -9715,6 +9883,26 @@ var RedactionLayer = class {
9715
9883
  }
9716
9884
  };
9717
9885
 
9886
+ // src/core/tiers/escalation-policy.ts
9887
+ function sectionNeedsDecision(results) {
9888
+ return results.some((r) => r.status === "ESCALATED");
9889
+ }
9890
+ function settledEscalationStatus(results) {
9891
+ const hasWork = results.some((r) => r.status === "COMPLETED" || !!r.output);
9892
+ return hasWork ? "PARTIAL" : "FAILED";
9893
+ }
9894
+ function composeRootSectionOutput(t2Result, completedOutputs) {
9895
+ let output = `${t2Result.sectionSummary}
9896
+
9897
+ ${completedOutputs.join("\n\n")}`;
9898
+ if (t2Result.status === "FAILED" && t2Result.issues?.length) {
9899
+ output += `
9900
+
9901
+ _(incomplete: ${t2Result.issues.join("; ")})_`;
9902
+ }
9903
+ return output;
9904
+ }
9905
+
9718
9906
  // src/core/tiers/t2-manager.ts
9719
9907
  function buildT2SystemPrompt(hasPeerMessage) {
9720
9908
  return [
@@ -9740,6 +9928,19 @@ var T2Manager = class extends BaseTier {
9740
9928
  t2PeerBus;
9741
9929
  permissionEscalator;
9742
9930
  toolCreator;
9931
+ /**
9932
+ * Asked when a section ends ESCALATED — i.e. a worker hit something it could
9933
+ * not decide alone. Without this the escalation was a dead end: the status
9934
+ * said "needs a decision" and no decision was ever requested, so an MCP run
9935
+ * that legitimately needed input just stopped there.
9936
+ */
9937
+ escalationCallback;
9938
+ /**
9939
+ * The one allowed retry has been spent. Retrying is bounded because an
9940
+ * escalation loop that can re-ask forever burns a budget on a question that
9941
+ * is not resolving; a second escalation is terminal instead.
9942
+ */
9943
+ escalationRetryUsed = false;
9743
9944
  /** Optional boardroom gate (Moderate / root-T2 runs) — pauses after decomposition. */
9744
9945
  planApprovalCallback;
9745
9946
  /** AbortController for the current T3 wave — aborted on cancel-and-respawn */
@@ -9788,6 +9989,10 @@ var T2Manager = class extends BaseTier {
9788
9989
  setToolCreator(creator) {
9789
9990
  this.toolCreator = creator;
9790
9991
  }
9992
+ /** Ask the user what to do when a section escalates. */
9993
+ setEscalationCallback(cb) {
9994
+ this.escalationCallback = cb;
9995
+ }
9791
9996
  /** Boardroom gate for Moderate (root-T2) runs: pause after decomposition. */
9792
9997
  setPlanApprovalCallback(cb) {
9793
9998
  this.planApprovalCallback = cb;
@@ -9892,9 +10097,57 @@ Guidance (must be followed): ${decision.note}`
9892
10097
  currentAction: "Aggregating T3 results",
9893
10098
  status: "IN_PROGRESS"
9894
10099
  });
9895
- const summary = await this.aggregateResults(assignment, t3Results);
10100
+ let summary = await this.aggregateResults(assignment, t3Results);
9896
10101
  const issues = t3Results.filter((r) => r.status !== "COMPLETED").flatMap((r) => r.issues);
9897
- const overallStatus = this.determineStatus(t3Results);
10102
+ let overallStatus = this.determineStatus(t3Results);
10103
+ let userSkipped = false;
10104
+ const hasEscalated = sectionNeedsDecision(t3Results);
10105
+ const settleEscalated = async (reason) => {
10106
+ if (reason) issues.push(reason);
10107
+ summary = await this.aggregateResults(assignment, t3Results, { includeEscalated: true });
10108
+ overallStatus = settledEscalationStatus(t3Results);
10109
+ };
10110
+ if (hasEscalated && this.escalationCallback && this.escalationRetryUsed) {
10111
+ await settleEscalated("Escalated again after the retry \u2014 no further attempts were made.");
10112
+ } else if (hasEscalated && !this.escalationCallback) {
10113
+ await settleEscalated();
10114
+ } else if (hasEscalated && this.escalationCallback) {
10115
+ this.sendStatusUpdate({
10116
+ progressPct: 95,
10117
+ currentAction: "Escalated \u2014 waiting for your decision",
10118
+ status: "ESCALATING"
10119
+ });
10120
+ const decision = await this.escalationCallback({
10121
+ sectionId: assignment.sectionId,
10122
+ sectionTitle: assignment.sectionTitle,
10123
+ issues,
10124
+ summary
10125
+ });
10126
+ this.log(`Escalation decision for "${assignment.sectionTitle}": ${decision.action}`);
10127
+ if (decision.action === "skip") {
10128
+ summary = await this.aggregateResults(assignment, t3Results, { includeEscalated: true });
10129
+ overallStatus = settledEscalationStatus(t3Results);
10130
+ userSkipped = decision.automatic !== true;
10131
+ } else if (decision.action === "retry" || decision.action === "guidance") {
10132
+ this.escalationRetryUsed = true;
10133
+ const guided = decision.action === "guidance" && decision.note?.trim() ? {
10134
+ ...assignment,
10135
+ description: `${assignment.description}
10136
+
10137
+ Guidance (must be followed): ${decision.note.trim()}`,
10138
+ // Drop T1's preplanned subtasks so the retry re-decomposes with
10139
+ // the guidance in hand. The workers execute from their subtask
10140
+ // slices, not from this description — leaving the slices in
10141
+ // place gave "Retry with guidance" byte-identical worker
10142
+ // prompts to "Retry as-is", making the note do nothing at all.
10143
+ t3Subtasks: []
10144
+ } : assignment;
10145
+ return await this.execute(guided, taskId, signal);
10146
+ } else {
10147
+ issues.push("Escalated, but no decision was received in time.");
10148
+ overallStatus = "FAILED";
10149
+ }
10150
+ }
9898
10151
  const isOk = overallStatus === "COMPLETED" || overallStatus === "PARTIAL";
9899
10152
  this.setStatus(isOk ? "COMPLETED" : "FAILED", summary);
9900
10153
  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";
@@ -9905,7 +10158,8 @@ Guidance (must be followed): ${decision.note}`
9905
10158
  status: overallStatus,
9906
10159
  t3Results,
9907
10160
  sectionSummary: summary,
9908
- issues
10161
+ issues,
10162
+ ...userSkipped ? { userSkipped: true } : {}
9909
10163
  };
9910
10164
  this.publishSectionOutput(result);
9911
10165
  return result;
@@ -10339,8 +10593,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
10339
10593
  result.status === "COMPLETED" ? "COMPLETED" : "FAILED"
10340
10594
  );
10341
10595
  }
10342
- async aggregateResults(assignment, results) {
10343
- const completed = results.filter((r) => r.status === "COMPLETED");
10596
+ async aggregateResults(assignment, results, opts = {}) {
10597
+ const completed = results.filter(
10598
+ (r) => r.status === "COMPLETED" || opts.includeEscalated && r.status === "ESCALATED" && r.output
10599
+ );
10344
10600
  if (!completed.length) return `Section ${assignment.sectionTitle} failed \u2014 no T3 workers completed.`;
10345
10601
  const peerOutputs = this.peerSyncBuffer.filter((p) => p.content?.type === "T2_SECTION_OUTPUT").map((p) => `[Peer ${p.fromId} Output]: ${p.content.output}`).join("\n\n");
10346
10602
  const peerContext = peerOutputs ? `
@@ -10549,6 +10805,7 @@ var T1Administrator = class extends BaseTier {
10549
10805
  escalations = [];
10550
10806
  store;
10551
10807
  runBreaker;
10808
+ escalationCallback;
10552
10809
  t2PeerBus = new PeerBus();
10553
10810
  permissionEscalator;
10554
10811
  toolCreator;
@@ -10563,6 +10820,10 @@ var T1Administrator = class extends BaseTier {
10563
10820
  this.toolRegistry = toolRegistry;
10564
10821
  this.config = config;
10565
10822
  }
10823
+ /** Ask the user what to do when any section escalates. */
10824
+ setEscalationCallback(cb) {
10825
+ this.escalationCallback = cb;
10826
+ }
10566
10827
  /** Share the run-wide circuit breaker with every section manager. */
10567
10828
  setRunBreaker(breaker) {
10568
10829
  this.runBreaker = breaker;
@@ -10722,8 +10983,11 @@ Create a CORRECTION PLAN that contains only the new sections needed to fix the i
10722
10983
  reason: `Some T2 managers failed entirely: ${failedSections.map((s) => s.sectionTitle).join(", ")}. Errors: ${failedSections.flatMap((s) => s.issues).join("; ")}`
10723
10984
  };
10724
10985
  }
10725
- const sectionsText = t2Results.map((r) => `**${r.sectionTitle}**
10726
- ${r.sectionSummary}`).join("\n\n");
10986
+ const sectionsText = t2Results.map((r) => {
10987
+ 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.]" : "";
10988
+ return `**${r.sectionTitle}**
10989
+ ${r.sectionSummary}${skipNote}`;
10990
+ }).join("\n\n");
10727
10991
  const prompt = `You are a strict QA Reviewer for the Cascade AI system.
10728
10992
  Review the following execution outputs against the original user prompt.
10729
10993
 
@@ -10733,6 +10997,7 @@ T2 Manager Summaries:
10733
10997
  ${sectionsText}
10734
10998
 
10735
10999
  Does the current state of the workspace and the outputs fully satisfy the user's request?
11000
+ 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.
10736
11001
  If yes, reply with exactly: "APPROVED".
10737
11002
  If no, reply with "REJECTED: [Detailed reason explaining exactly what is missing or incorrect]".`;
10738
11003
  try {
@@ -10930,6 +11195,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10930
11195
  const managers = sections.map((section) => {
10931
11196
  const manager = new T2Manager(this.router, this.toolRegistry, this.id);
10932
11197
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
11198
+ if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
10933
11199
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
10934
11200
  if (this.store) {
10935
11201
  manager.setStore(this.store);
@@ -11139,7 +11405,10 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
11139
11405
  async compileFinalOutput(originalPrompt, plan, t2Results) {
11140
11406
  const completedSections = t2Results.filter((r) => r.status !== "FAILED");
11141
11407
  if (!completedSections.length) {
11142
- const allIssues = t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues));
11408
+ const allIssues = [
11409
+ ...t2Results.flatMap((r) => r.issues ?? []),
11410
+ ...t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues))
11411
+ ].filter(Boolean);
11143
11412
  const critical = allIssues.find((i) => i.includes("[CRITICAL_TOOL_ERROR]"));
11144
11413
  const stalled = allIssues.find((i) => /^Stalled:/.test(i));
11145
11414
  const topReason = critical ?? stalled ?? allIssues[0] ?? "no specific reason recorded";
@@ -13378,12 +13647,12 @@ var McpToolWrapper = class extends BaseTool {
13378
13647
  mcpClient;
13379
13648
  serverName;
13380
13649
  toolName;
13381
- constructor(mcpClient, serverName, toolName, description, inputSchema) {
13650
+ constructor(mcpClient, serverName, toolName, description, inputSchema, registeredName) {
13382
13651
  super();
13383
13652
  this.mcpClient = mcpClient;
13384
13653
  this.serverName = serverName;
13385
13654
  this.toolName = toolName;
13386
- this.name = mcpToolName(serverName, toolName);
13655
+ this.name = registeredName ?? mcpToolName(serverName, toolName);
13387
13656
  this.description = `[MCP:${serverName}] ${description}`;
13388
13657
  this.inputSchema = inputSchema;
13389
13658
  }
@@ -13454,22 +13723,35 @@ var ToolRegistry = class extends EventEmitter__default.default {
13454
13723
  getRegisteredPlugins() {
13455
13724
  return Array.from(this.plugins.keys());
13456
13725
  }
13457
- /** Registers all tools from an MCP client */
13458
- registerMcpTools(mcpClient) {
13459
- const definitions = mcpClient.getToolDefinitions();
13460
- for (const def of definitions) {
13461
- const [, serverName, toolName] = def.name.split("::");
13462
- if (!serverName || !toolName) continue;
13726
+ /**
13727
+ * Registers all tools from an MCP client.
13728
+ *
13729
+ * `isAllowed` receives the FINAL registered name (`mcp__server__tool`) — the
13730
+ * same string the user sees and the same one that reaches the provider — so
13731
+ * a per-tool opt-out in Settings can be expressed as a plain name list.
13732
+ * Filtering here rather than at call time means a deselected tool is genuinely
13733
+ * absent from the definitions handed to the model, so it is never proposed,
13734
+ * never counted against the tool budget, and never has to be refused.
13735
+ */
13736
+ registerMcpTools(mcpClient, isAllowed) {
13737
+ const parsed = mcpClient.getToolDefinitions().flatMap((def) => {
13738
+ const [, server, tool] = def.name.split("::");
13739
+ return server && tool ? [{ def, server, tool }] : [];
13740
+ });
13741
+ const names = assignMcpToolNames(parsed);
13742
+ parsed.forEach(({ def, server, tool }, i) => {
13463
13743
  const wrapper = new McpToolWrapper(
13464
13744
  mcpClient,
13465
- serverName,
13466
- toolName,
13467
- def.description.replace(`[MCP:${serverName}] `, ""),
13468
- def.inputSchema
13745
+ server,
13746
+ tool,
13747
+ def.description.replace(`[MCP:${server}] `, ""),
13748
+ def.inputSchema,
13749
+ names[i]
13469
13750
  );
13751
+ if (isAllowed && !isAllowed(wrapper.name)) return;
13470
13752
  wrapper.setWorkspaceRoot(this.workspaceRoot);
13471
13753
  this.register(wrapper);
13472
- }
13754
+ });
13473
13755
  }
13474
13756
  setIgnoredPaths(patterns) {
13475
13757
  this.ignoreMatcher = ignore2().add(patterns);
@@ -13806,6 +14088,17 @@ function fileOAuthProvider(storePath) {
13806
14088
  }
13807
14089
  });
13808
14090
  }
14091
+ var oauthStoreLocks = /* @__PURE__ */ new Map();
14092
+ function withOAuthStoreLock(storePath, fn) {
14093
+ const prev = oauthStoreLocks.get(storePath) ?? Promise.resolve();
14094
+ const run = prev.then(fn, fn);
14095
+ const tail = run.then(() => void 0, () => void 0);
14096
+ oauthStoreLocks.set(storePath, tail);
14097
+ void tail.then(() => {
14098
+ if (oauthStoreLocks.get(storePath) === tail) oauthStoreLocks.delete(storePath);
14099
+ });
14100
+ return run;
14101
+ }
13809
14102
  async function connectMcpWithLoopbackOAuth(opts) {
13810
14103
  const listener = await startCallbackListener(opts);
13811
14104
  const transport = new streamableHttp_js.StreamableHTTPClientTransport(new URL(opts.serverUrl), {
@@ -16286,7 +16579,50 @@ var GuidanceQueue = class {
16286
16579
  }
16287
16580
  };
16288
16581
 
16582
+ // src/tools/current-page.ts
16583
+ var MAX_PAGE_TEXT_CHARS = 4e4;
16584
+ var CurrentPageTool = class extends BaseTool {
16585
+ name = "read_current_page";
16586
+ 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.';
16587
+ inputSchema = {
16588
+ type: "object",
16589
+ properties: {},
16590
+ required: []
16591
+ };
16592
+ provider;
16593
+ constructor(provider) {
16594
+ super();
16595
+ this.provider = provider;
16596
+ }
16597
+ // Reading what is already on the user's screen changes nothing and reaches
16598
+ // nothing they have not already opened themselves.
16599
+ isDangerous() {
16600
+ return false;
16601
+ }
16602
+ async execute(_input, _options) {
16603
+ let page2;
16604
+ try {
16605
+ page2 = await this.provider();
16606
+ } catch (err) {
16607
+ return `Error: could not read the open page \u2014 ${err instanceof Error ? err.message : String(err)}`;
16608
+ }
16609
+ if (!page2) {
16610
+ return "No page is open in the built-in browser. Ask the user to open one, or use web_fetch for a public URL.";
16611
+ }
16612
+ const text = page2.text.length > MAX_PAGE_TEXT_CHARS ? `${page2.text.slice(0, MAX_PAGE_TEXT_CHARS)}
16613
+
16614
+ [truncated \u2014 the page is longer than ${MAX_PAGE_TEXT_CHARS} characters]` : page2.text;
16615
+ return [
16616
+ `URL: ${page2.url}`,
16617
+ `Title: ${page2.title}`,
16618
+ "",
16619
+ text.trim() || "[the page rendered no readable text]"
16620
+ ].join("\n");
16621
+ }
16622
+ };
16623
+
16289
16624
  // src/core/cascade.ts
16625
+ var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
16290
16626
  function buildContextualPrompt(prompt, history = []) {
16291
16627
  const recent = history.slice(-6);
16292
16628
  if (recent.length === 0) return prompt;
@@ -16509,6 +16845,94 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
16509
16845
  });
16510
16846
  });
16511
16847
  }
16848
+ // ── Escalation decisions ────────────────────────────────────────────
16849
+ //
16850
+ // A section that ends ESCALATED used to be a dead end: the Cockpit showed
16851
+ // "Section escalated — needs a decision" and no decision was ever requested,
16852
+ // so the run stopped there having spent a full orchestration. This is the
16853
+ // same round-trip shape as plan approval, with one deliberate difference —
16854
+ // an unanswered escalation FAILS the section instead of proceeding.
16855
+ //
16856
+ // That direction is the safe one here. Plan approval defaults to "yes" on
16857
+ // timeout because the plan is already the model's considered proposal;
16858
+ // an escalation exists precisely BECAUSE the worker was not confident, so
16859
+ // acting unattended is the option most likely to be wrong. And in cloud a
16860
+ // waiting run holds server resources, so hanging is not free either.
16861
+ // Keyed by request id, NOT a single slot. A Complex run dispatches each wave
16862
+ // of ready sections with `Promise.all` (t1-administrator.ts), so two sections
16863
+ // can be waiting on a decision at the same moment. A single field made that
16864
+ // case fail hard rather than merely confusingly: the second escalation
16865
+ // overwrote the first's resolver, the user's answer resolved only the second,
16866
+ // and then the FIRST section's timer found the slot already empty and
16867
+ // returned without resolving anything — parking the run forever, past its own
16868
+ // timeout. Same class of bug as the twelve-failover dead-model race: a wave
16869
+ // is concurrent, so per-run singletons are never safe inside it.
16870
+ pendingEscalations = /* @__PURE__ */ new Map();
16871
+ async requestEscalationDecision(ctx, taskId, signal) {
16872
+ if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
16873
+ if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
16874
+ if (signal?.aborted) return { action: "skip", automatic: true };
16875
+ const requestId = crypto.randomUUID();
16876
+ return await new Promise((resolve2) => {
16877
+ let timeout;
16878
+ const settle = (decision) => {
16879
+ if (!this.pendingEscalations.delete(requestId)) return;
16880
+ if (timeout) clearTimeout(timeout);
16881
+ signal?.removeEventListener("abort", onAbort);
16882
+ resolve2(decision);
16883
+ };
16884
+ const onAbort = () => settle({ action: "skip", automatic: true });
16885
+ signal?.addEventListener("abort", onAbort, { once: true });
16886
+ timeout = setTimeout(() => {
16887
+ this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
16888
+ settle({ action: "timeout" });
16889
+ }, ESCALATION_DECISION_TIMEOUT_MS);
16890
+ this.pendingEscalations.set(requestId, settle);
16891
+ this.emit("escalation:decision-required", {
16892
+ taskId,
16893
+ requestId,
16894
+ sectionId: ctx.sectionId,
16895
+ sectionTitle: ctx.sectionTitle,
16896
+ issues: ctx.issues,
16897
+ summary: ctx.summary,
16898
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
16899
+ });
16900
+ });
16901
+ }
16902
+ /**
16903
+ * Resolve a pending escalation from a REPL / dashboard / cloud listener.
16904
+ *
16905
+ * `requestId` comes back from the `escalation:decision-required` event and is
16906
+ * what makes an answer land on the section that asked. It is optional so a
16907
+ * host with only one escalation in flight (and any caller that predates the
16908
+ * id) still works: without it the oldest outstanding request is answered,
16909
+ * which is correct whenever there is exactly one.
16910
+ *
16911
+ * `automatic` defaults to false — a call into this method is normally a real
16912
+ * person answering the prompt. Pass `true` from a host that is itself
16913
+ * settling the gate on nobody's behalf (a REST caller who never had a
16914
+ * chance to connect, a session halt with no per-section review behind it),
16915
+ * so T2 doesn't set `userSkipped` for a decision no one actually reviewed —
16916
+ * see EscalationDecision.automatic.
16917
+ */
16918
+ resolveEscalation(action, note, requestId, automatic) {
16919
+ const decision = { action, ...note ? { note } : {}, ...automatic ? { automatic: true } : {} };
16920
+ if (requestId) {
16921
+ this.pendingEscalations.get(requestId)?.(decision);
16922
+ return;
16923
+ }
16924
+ const oldest = this.pendingEscalations.keys().next();
16925
+ if (!oldest.done) this.pendingEscalations.get(oldest.value)?.(decision);
16926
+ }
16927
+ /**
16928
+ * Release every parked escalation — run teardown, so none outlive the run.
16929
+ * `automatic: true`: teardown resolving a section nobody answered is the
16930
+ * same system-decided case as the early-return skips above, not a person
16931
+ * reviewing and accepting that section's output.
16932
+ */
16933
+ releasePendingEscalations(action = "skip") {
16934
+ for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
16935
+ }
16512
16936
  /**
16513
16937
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
16514
16938
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -16642,6 +17066,20 @@ ${input}` }],
16642
17066
  this.mediaSink = sink;
16643
17067
  this.registerMediaTools(this.workspacePath);
16644
17068
  }
17069
+ /**
17070
+ * Let the agent read the page the user has open in the host's browser.
17071
+ *
17072
+ * Host-supplied because only a host WITH a browser can answer it — so the
17073
+ * tool is simply absent in the CLI and in hosted runs, rather than present
17074
+ * and always failing. Registered outside `enabledTools` for the same reason
17075
+ * media tools are: that allowlist is a blast-radius control for tools that
17076
+ * touch the machine, and reading a page the user already opened touches
17077
+ * nothing. `disabledTools` still removes it.
17078
+ */
17079
+ setCurrentPageProvider(provider) {
17080
+ if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
17081
+ this.toolRegistry.register(new CurrentPageTool(provider));
17082
+ }
16645
17083
  registerMediaTools(workspacePath) {
16646
17084
  const denied = new Set(this.config.tools?.disabledTools ?? []);
16647
17085
  const permitted = (name) => !denied.has(name);
@@ -16774,8 +17212,12 @@ ${last.partialOutput}` : "");
16774
17212
  for (const server of this.config.tools.mcpServers) {
16775
17213
  try {
16776
17214
  const authProvider = server.oauthStore ? fileOAuthProvider(server.oauthStore) : void 0;
16777
- await this.mcpClient.connect(server, authProvider ? { authProvider } : {});
16778
- this.toolRegistry.registerMcpTools(this.mcpClient);
17215
+ const connect = () => this.mcpClient.connect(server, authProvider ? { authProvider } : {});
17216
+ await (server.oauthStore ? withOAuthStoreLock(server.oauthStore, connect) : connect());
17217
+ this.toolRegistry.registerMcpTools(
17218
+ this.mcpClient,
17219
+ (name) => !(this.config.tools?.disabledTools ?? []).includes(name)
17220
+ );
16779
17221
  } catch (err) {
16780
17222
  console.error(`Failed to connect to MCP server "${server.name}":`, err);
16781
17223
  }
@@ -17240,6 +17682,7 @@ ${prompt}` : prompt;
17240
17682
  } else if (complexity === "Moderate") {
17241
17683
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
17242
17684
  t2.setRunBreaker(runBreaker);
17685
+ t2.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
17243
17686
  t2.setPresenter(true);
17244
17687
  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.");
17245
17688
  if (identityPrompt) {
@@ -17284,11 +17727,17 @@ ${prompt}` : prompt;
17284
17727
  t2Results = [t2Result];
17285
17728
  const completed = t2Result.t3Results.filter((r) => r.status === "COMPLETED");
17286
17729
  if (completed.length > 0) {
17287
- finalOutput = t2Result.sectionSummary + "\n\n" + completed.map((r) => r.output).join("\n\n");
17730
+ finalOutput = composeRootSectionOutput(
17731
+ t2Result,
17732
+ completed.map((r) => typeof r.output === "string" ? r.output : JSON.stringify(r.output))
17733
+ );
17288
17734
  } else {
17289
17735
  const partial = t2Result.t3Results.map((r) => typeof r.output === "string" ? r.output : "").filter((o) => o.trim()).sort((a, b) => b.length - a.length)[0];
17290
17736
  const reasons = Array.from(
17291
- new Set(t2Result.t3Results.flatMap((r) => r.issues ?? []).filter(Boolean))
17737
+ new Set([
17738
+ ...t2Result.issues ?? [],
17739
+ ...t2Result.t3Results.flatMap((r) => r.issues ?? [])
17740
+ ].filter(Boolean))
17292
17741
  );
17293
17742
  if (partial) {
17294
17743
  finalOutput = reasons.length ? `${partial}
@@ -17301,6 +17750,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
17301
17750
  } else {
17302
17751
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
17303
17752
  t1.setRunBreaker(runBreaker);
17753
+ t1.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
17304
17754
  t1.setPresenter(true);
17305
17755
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
17306
17756
  if (identityPrompt) {
@@ -17325,6 +17775,8 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
17325
17775
  finalOutput = result.output;
17326
17776
  t2Results = result.t2Results;
17327
17777
  }
17778
+ const budgetInfo = this.router.budgetExceededInfo();
17779
+ if (budgetInfo) throw new CascadeRouter.BudgetExceededError(budgetInfo.reason);
17328
17780
  const trip = runBreaker.reason();
17329
17781
  if (trip) {
17330
17782
  finalOutput = [
@@ -17365,6 +17817,10 @@ ${finalOutput}` : ""
17365
17817
  escalator.cancelAllPending();
17366
17818
  } catch {
17367
17819
  }
17820
+ try {
17821
+ this.releasePendingEscalations();
17822
+ } catch {
17823
+ }
17368
17824
  this.router.restoreTierModels();
17369
17825
  this.router.setRunSignal(void 0);
17370
17826
  if (this.taskAnalyzer) {
@@ -21791,11 +22247,55 @@ function mergeByKey(local, incoming, key) {
21791
22247
  for (const i of incoming) map.set(key(i), i);
21792
22248
  return [...map.values()];
21793
22249
  }
22250
+ function uniqueServerName(desired, takenPrefixes) {
22251
+ if (!takenPrefixes.has(mcpServerPrefix(desired))) return desired;
22252
+ let n = 2;
22253
+ while (takenPrefixes.has(mcpServerPrefix(`${desired} ${n}`))) n++;
22254
+ return `${desired} ${n}`;
22255
+ }
22256
+ function mergeMcpServers(local, incoming) {
22257
+ const rewrite = /* @__PURE__ */ new Map();
22258
+ if (!incoming?.length) return { servers: local, synced: [], rewrite };
22259
+ const byName = /* @__PURE__ */ new Map();
22260
+ for (const l of local ?? []) byName.set(l.name, l);
22261
+ const prefixes = new Set([...byName.values()].map((m) => mcpServerPrefix(m.name)));
22262
+ const synced = [];
22263
+ for (const inc of incoming) {
22264
+ if (byName.has(inc.name)) {
22265
+ byName.set(inc.name, inc);
22266
+ synced.push(inc);
22267
+ continue;
22268
+ }
22269
+ const name = uniqueServerName(inc.name, prefixes);
22270
+ if (name !== inc.name) rewrite.set(mcpServerPrefix(inc.name), mcpServerPrefix(name));
22271
+ const server = name === inc.name ? inc : { ...inc, name };
22272
+ byName.set(name, server);
22273
+ synced.push(server);
22274
+ prefixes.add(mcpServerPrefix(name));
22275
+ }
22276
+ return { servers: [...byName.values()], synced, rewrite };
22277
+ }
22278
+ function rewriteDisabledTools(tools, rewrite) {
22279
+ if (!tools?.length || rewrite.size === 0) return tools;
22280
+ return tools.map((t) => {
22281
+ for (const [from, to] of rewrite) if (t.startsWith(from)) return to + t.slice(from.length);
22282
+ return t;
22283
+ });
22284
+ }
22285
+ function mergeDisabledTools(local, incoming, syncedServers) {
22286
+ const prefixes = (syncedServers ?? []).map((m) => mcpServerPrefix(m.name));
22287
+ const covered = (tool) => prefixes.some((p) => tool.startsWith(p));
22288
+ const kept = (local ?? []).filter((t) => !covered(t));
22289
+ const merged = [.../* @__PURE__ */ new Set([...kept, ...incoming ?? []])];
22290
+ return merged.length ? merged : void 0;
22291
+ }
21794
22292
  function gatherSyncBundle(config) {
21795
- const bundle = { v: 1 };
22293
+ const bundle = { v: 2 };
21796
22294
  if (config.providers?.length) bundle.providers = config.providers;
21797
22295
  if (config.tools?.webSearch) bundle.webSearch = config.tools.webSearch;
21798
22296
  if (config.tools?.mcpServers?.length) bundle.mcpServers = config.tools.mcpServers;
22297
+ const syncedDisabledTools = config.tools?.disabledTools?.filter(isMcpToolName);
22298
+ if (syncedDisabledTools?.length) bundle.disabledTools = syncedDisabledTools;
21799
22299
  if (config.models) bundle.models = config.models;
21800
22300
  if (config.budget) bundle.budget = config.budget;
21801
22301
  const prefs = {};
@@ -21810,7 +22310,16 @@ function applySyncBundle(bundle, config) {
21810
22310
  const next = { ...config, tools: { ...config.tools } };
21811
22311
  if (bundle.providers) next.providers = mergeByKey(config.providers, bundle.providers, providerSig) ?? [];
21812
22312
  if (bundle.webSearch) next.tools.webSearch = { ...config.tools?.webSearch, ...bundle.webSearch };
21813
- if (bundle.mcpServers) next.tools.mcpServers = mergeByKey(config.tools?.mcpServers, bundle.mcpServers, (m) => m.name);
22313
+ const mcp2 = mergeMcpServers(config.tools?.mcpServers, bundle.mcpServers);
22314
+ if (bundle.mcpServers) next.tools.mcpServers = mcp2.servers;
22315
+ const carriesSelections = bundle.v >= 2 || bundle.disabledTools !== void 0;
22316
+ if (carriesSelections && (bundle.mcpServers || bundle.disabledTools)) {
22317
+ next.tools.disabledTools = mergeDisabledTools(
22318
+ config.tools?.disabledTools,
22319
+ rewriteDisabledTools(bundle.disabledTools, mcp2.rewrite),
22320
+ bundle.mcpServers ? mcp2.synced : void 0
22321
+ );
22322
+ }
21814
22323
  if (bundle.models) next.models = { ...config.models, ...bundle.models };
21815
22324
  if (bundle.budget) next.budget = { ...config.budget, ...bundle.budget };
21816
22325
  if (bundle.prefs) {
@@ -22151,12 +22660,19 @@ function defaultName(url) {
22151
22660
  }
22152
22661
  }
22153
22662
  async function mcpConnectCommand(url, opts = {}) {
22154
- const name = opts.name?.trim() || defaultName(url);
22155
- const store = new FileMcpOAuthStore(storePathFor(name));
22663
+ const desiredName = opts.name?.trim() || defaultName(url);
22156
22664
  console.log(chalk7__default.default.magenta("\n \u25C8 Connect an MCP server via OAuth"));
22157
22665
  console.log(chalk7__default.default.dim(` ${url}
22158
22666
  `));
22159
22667
  try {
22668
+ const cm = new ConfigManager(process.cwd());
22669
+ await cm.load();
22670
+ const config = cm.getConfig();
22671
+ config.tools = config.tools ?? {};
22672
+ const servers = config.tools.mcpServers ?? [];
22673
+ const existingIdx = servers.findIndex((s) => s.name === desiredName);
22674
+ const name = existingIdx >= 0 ? desiredName : uniqueMcpServerName(desiredName, servers.map((s) => s.name));
22675
+ const store = new FileMcpOAuthStore(storePathFor(name));
22160
22676
  await connectMcpWithLoopbackOAuth({
22161
22677
  serverUrl: url,
22162
22678
  store,
@@ -22166,14 +22682,8 @@ async function mcpConnectCommand(url, opts = {}) {
22166
22682
  openBrowser2(u);
22167
22683
  }
22168
22684
  });
22169
- const cm = new ConfigManager(process.cwd());
22170
- await cm.load();
22171
- const config = cm.getConfig();
22172
- config.tools = config.tools ?? {};
22173
- const servers = config.tools.mcpServers ?? [];
22174
22685
  const entry = { name, url, oauthStore: storePathFor(name) };
22175
- const idx = servers.findIndex((s) => s.name === name);
22176
- if (idx >= 0) servers[idx] = entry;
22686
+ if (existingIdx >= 0) servers[existingIdx] = entry;
22177
22687
  else servers.push(entry);
22178
22688
  config.tools.mcpServers = servers;
22179
22689
  config.tools.mcpTrusted = Array.from(/* @__PURE__ */ new Set([...config.tools.mcpTrusted ?? [], name]));
@@ -22218,6 +22728,7 @@ async function mcpRemoveCommand(name) {
22218
22728
  }
22219
22729
  config.tools.mcpServers = servers.filter((s) => s.name !== name);
22220
22730
  config.tools.mcpTrusted = (config.tools.mcpTrusted ?? []).filter((n) => n !== name);
22731
+ config.tools.disabledTools = removeMcpServerDenials(config.tools.disabledTools, name);
22221
22732
  if (match.oauthStore) {
22222
22733
  try {
22223
22734
  new FileMcpOAuthStore(match.oauthStore).clear();
@@ -22324,6 +22835,14 @@ var DashboardSocket = class {
22324
22835
  this.secret = options.secret;
22325
22836
  this.setupHandlers();
22326
22837
  }
22838
+ /** How many sockets are currently in a room — 0 means nobody can answer. */
22839
+ roomSize(room) {
22840
+ return this.io.sockets.adapter.rooms.get(room)?.size ?? 0;
22841
+ }
22842
+ /** Is this exact connection still live? A reconnect issues a NEW socket id. */
22843
+ hasSocket(socketId) {
22844
+ return this.io.sockets.sockets.has(socketId);
22845
+ }
22327
22846
  broadcastToRoom(room, event, data) {
22328
22847
  this.io.to(room).emit(event, data);
22329
22848
  }
@@ -22395,6 +22914,22 @@ var DashboardSocket = class {
22395
22914
  });
22396
22915
  });
22397
22916
  }
22917
+ /**
22918
+ * A client subscribed to a session's room.
22919
+ *
22920
+ * The host uses this to replay a gate the run is already parked on. Socket.IO
22921
+ * does not buffer room emissions, so a prompt broadcast while the renderer was
22922
+ * reconnecting is simply gone; without a replay the section waits out its full
22923
+ * timeout with the user sitting in front of a screen that never asked.
22924
+ */
22925
+ onJoinSession(callback) {
22926
+ this.io.on("connection", (socket) => {
22927
+ socket.on("join:session", (payload) => {
22928
+ const { sessionId } = normalizeSessionSubscriptionPayload(payload);
22929
+ if (sessionId) callback(sessionId, socket.id);
22930
+ });
22931
+ });
22932
+ }
22398
22933
  onSessionRate(callback) {
22399
22934
  this.io.on("connection", (socket) => {
22400
22935
  socket.on("session:rate", (payload) => {
@@ -22432,6 +22967,31 @@ var DashboardSocket = class {
22432
22967
  });
22433
22968
  });
22434
22969
  }
22970
+ /**
22971
+ * A section escalated and the run is parked waiting for an answer. The
22972
+ * desktop shows the escalation modal on `escalation:decision-required` and
22973
+ * answers here.
22974
+ *
22975
+ * Unlike a plan decision, silence is NOT taken as consent — the SDK fails
22976
+ * the section on timeout — so this handler only ever forwards a real choice.
22977
+ */
22978
+ onEscalationDecision(callback) {
22979
+ this.io.on("connection", (socket) => {
22980
+ socket.on("escalation:decide", (payload) => {
22981
+ const action = payload?.action;
22982
+ if (typeof payload?.sessionId !== "string") return;
22983
+ if (action !== "retry" && action !== "skip" && action !== "guidance") return;
22984
+ callback({
22985
+ sessionId: payload.sessionId,
22986
+ // Identifies WHICH parked section this answers — sections in a wave
22987
+ // run concurrently, so more than one can be waiting.
22988
+ requestId: typeof payload.requestId === "string" ? payload.requestId : void 0,
22989
+ action,
22990
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0
22991
+ });
22992
+ });
22993
+ });
22994
+ }
22435
22995
  onSessionSteer(callback) {
22436
22996
  this.io.on("connection", (socket) => {
22437
22997
  socket.on("session:steer", (payload) => {
@@ -22593,6 +23153,7 @@ function aggregateCostStats(sessions2, opts = {}) {
22593
23153
 
22594
23154
  // src/dashboard/server.ts
22595
23155
  var __dirname$1 = path32__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href))));
23156
+ var ORPHANED_ESCALATION_GRACE_MS = 3e4;
22596
23157
  var DashboardServer = class {
22597
23158
  app;
22598
23159
  httpServer;
@@ -22603,6 +23164,13 @@ var DashboardServer = class {
22603
23164
  globalStore = null;
22604
23165
  broadcastTimer = null;
22605
23166
  activeSessions = /* @__PURE__ */ new Map();
23167
+ /**
23168
+ * Supplied by the desktop shell so a run can read the page the user has
23169
+ * open in the built-in browser. Absent in the CLI, where there is no
23170
+ * browser — so the tool is simply not registered rather than present and
23171
+ * always failing.
23172
+ */
23173
+ currentPageProvider;
22606
23174
  activeControllers = /* @__PURE__ */ new Map();
22607
23175
  /**
22608
23176
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -22617,6 +23185,17 @@ var DashboardServer = class {
22617
23185
  * map is how that answer reaches the run that's blocked on it.
22618
23186
  */
22619
23187
  pendingApprovals = /* @__PURE__ */ new Map();
23188
+ /**
23189
+ * The escalation prompt a session is currently parked on, kept so it can be
23190
+ * delivered to a client that was not reachable when it was raised.
23191
+ *
23192
+ * Socket.IO buffers nothing: a prompt emitted while the renderer is mid
23193
+ * reconnect is gone, and the section then waits out its full five minutes
23194
+ * with the user in front of a screen that never asked them anything. Holding
23195
+ * the payload lets `join:session` replay it, and lets a run with no reachable
23196
+ * client be settled instead of parked (see ORPHANED_ESCALATION_GRACE_MS).
23197
+ */
23198
+ pendingEscalations = /* @__PURE__ */ new Map();
22620
23199
  /**
22621
23200
  * The orchestration decision trail ("why") of each session's most recent
22622
23201
  * run — captured when the run ends so the desktop's Why panel can show it
@@ -22694,20 +23273,29 @@ var DashboardServer = class {
22694
23273
  if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
22695
23274
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
22696
23275
  this.activeSessions.set(sessionId, cascade);
23276
+ if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
22697
23277
  cascade.on("stream:token", (e) => {
22698
- this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
23278
+ this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
22699
23279
  });
22700
23280
  cascade.on("tier:status", (e) => {
22701
- this.socket.emitToSocket(socketId, "tier:status", { sessionId, ...e });
23281
+ this.emitToSessionClients(socketId, sessionId, "tier:status", { sessionId, ...e });
22702
23282
  });
22703
23283
  cascade.on("permission:user-required", (e) => {
22704
- this.socket.emitToSocket(socketId, "permission:user-required", { sessionId, ...e });
23284
+ this.emitToSessionClients(socketId, sessionId, "permission:user-required", { sessionId, ...e });
22705
23285
  });
22706
23286
  cascade.on("peer:message", (e) => {
22707
23287
  this.socket.emitPeerMessage(e);
22708
23288
  });
22709
23289
  cascade.on("plan:approval-required", (e) => {
22710
- this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
23290
+ this.emitToSessionClients(socketId, sessionId, "plan:approval-required", { sessionId, ...e });
23291
+ });
23292
+ cascade.on("escalation:decision-required", (e) => {
23293
+ this.raiseEscalation(sessionId, socketId, e);
23294
+ });
23295
+ cascade.on("escalation:timeout", (e) => {
23296
+ const ev = e;
23297
+ if (ev.requestId) this.clearPendingEscalation(ev.requestId);
23298
+ this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
22711
23299
  });
22712
23300
  try {
22713
23301
  const result = await cascade.run({
@@ -22718,7 +23306,7 @@ var DashboardServer = class {
22718
23306
  this.recordSessionTask(sessionId, result.taskId);
22719
23307
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
22720
23308
  this.captureWhy(sessionId, cascade, result);
22721
- this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
23309
+ this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
22722
23310
  this.socket.broadcast("cost:update", {
22723
23311
  sessionId,
22724
23312
  totalTokens: result.usage.totalTokens,
@@ -22731,7 +23319,7 @@ var DashboardServer = class {
22731
23319
  } catch (err) {
22732
23320
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
22733
23321
  this.captureWhy(sessionId, cascade);
22734
- this.socket.emitToSocket(socketId, "session:error", {
23322
+ this.emitToSessionClients(socketId, sessionId, "session:error", {
22735
23323
  sessionId,
22736
23324
  error: err instanceof Error ? err.message : String(err)
22737
23325
  });
@@ -22739,6 +23327,7 @@ var DashboardServer = class {
22739
23327
  this.activeSessions.delete(sessionId);
22740
23328
  this.activeControllers.delete(sessionId);
22741
23329
  this.denyPendingApprovals(sessionId);
23330
+ this.clearSessionEscalations(sessionId);
22742
23331
  }
22743
23332
  });
22744
23333
  this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
@@ -22748,10 +23337,20 @@ var DashboardServer = class {
22748
23337
  editedPlan
22749
23338
  );
22750
23339
  });
23340
+ this.socket.onEscalationDecision(({ sessionId, requestId, action, note }) => {
23341
+ if (requestId) this.clearPendingEscalation(requestId);
23342
+ else this.clearOldestSessionEscalation(sessionId);
23343
+ this.activeSessions.get(sessionId)?.resolveEscalation(action, note, requestId);
23344
+ });
23345
+ this.socket.onJoinSession((sessionId, socketId) => {
23346
+ this.replayEscalations(sessionId, socketId);
23347
+ });
22751
23348
  this.socket.onSessionHalt((sessionId) => {
22752
23349
  this.activeControllers.get(sessionId)?.abort();
22753
23350
  this.denyPendingApprovals(sessionId);
22754
23351
  this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
23352
+ this.clearSessionEscalations(sessionId);
23353
+ this.activeSessions.get(sessionId)?.resolveEscalation("skip", void 0, void 0, true);
22755
23354
  });
22756
23355
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
22757
23356
  const pending = this.pendingApprovals.get(requestId);
@@ -22766,6 +23365,10 @@ var DashboardServer = class {
22766
23365
  }
22767
23366
  });
22768
23367
  }
23368
+ /** Wire the host's browser view in (desktop only). */
23369
+ setCurrentPageProvider(provider) {
23370
+ this.currentPageProvider = provider;
23371
+ }
22769
23372
  async start() {
22770
23373
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
22771
23374
  if (!isLoopback) {
@@ -23054,6 +23657,116 @@ var DashboardServer = class {
23054
23657
  this.activeSessions.delete(sessionId);
23055
23658
  }
23056
23659
  }
23660
+ /**
23661
+ * Is anyone able to answer for this session right now?
23662
+ *
23663
+ * The originating socket first — that connection is definitely watching this
23664
+ * run — then the session room, which is where a client lands after a
23665
+ * reconnect issues it a new socket id.
23666
+ */
23667
+ hasResponder(sessionId, socketId) {
23668
+ if (socketId && this.socket.hasSocket(socketId)) return true;
23669
+ return this.socket.roomSize(`session:${sessionId}`) > 0;
23670
+ }
23671
+ /**
23672
+ * Send a run event to whoever is watching this session.
23673
+ *
23674
+ * A captured socket id goes stale the moment Socket.IO reconnects — the
23675
+ * client is back, but under a new id, and `emitToSocket` on the old one is a
23676
+ * silent no-op. Falling back to the session room keeps the run addressable
23677
+ * across a reconnect; emitting to one OR the other (never both) keeps a
23678
+ * client that is both the originator and a room member from seeing every
23679
+ * token twice.
23680
+ */
23681
+ emitToSessionClients(socketId, sessionId, event, data) {
23682
+ if (socketId && this.socket.hasSocket(socketId)) this.socket.emitToSocket(socketId, event, data);
23683
+ else this.socket.broadcastToRoom(`session:${sessionId}`, event, data);
23684
+ }
23685
+ /**
23686
+ * Raise an escalation prompt, and make sure it can still be answered.
23687
+ *
23688
+ * Held in `pendingEscalations` — keyed by requestId, because a Complex wave
23689
+ * dispatches sections concurrently and two can be parked at once — so a
23690
+ * client that joins late (or rejoins after a reconnect) gets every waiting
23691
+ * prompt replayed. Keying by session would drop the first of a pair: its
23692
+ * grace timer would be cleared by the second, leaving it parked for the full
23693
+ * timeout with nobody able to answer it. If nobody is reachable at all, the gate is
23694
+ * settled as `skip` after a short grace period rather than parked for the
23695
+ * full timeout: that matches the SDK's policy when no listener exists, and
23696
+ * five minutes of a held run is a real cost for a REST caller who has already
23697
+ * hung up. The grace period exists so an ordinary two-second reconnect is not
23698
+ * mistaken for an absent user.
23699
+ */
23700
+ raiseEscalation(sessionId, socketId, event) {
23701
+ const requestId = typeof event.requestId === "string" && event.requestId ? event.requestId : crypto.randomUUID();
23702
+ const payload = { sessionId, ...event };
23703
+ const entry = { payload, sessionId, requestId, raisedAt: Date.now() };
23704
+ this.pendingEscalations.set(requestId, entry);
23705
+ if (this.hasResponder(sessionId, socketId)) {
23706
+ this.emitToSessionClients(socketId, sessionId, "escalation:decision-required", payload);
23707
+ return;
23708
+ }
23709
+ entry.graceTimer = setTimeout(() => {
23710
+ if (this.pendingEscalations.get(requestId) !== entry) return;
23711
+ if (this.hasResponder(sessionId, socketId)) return;
23712
+ this.pendingEscalations.delete(requestId);
23713
+ this.activeSessions.get(sessionId)?.resolveEscalation("skip", "No client was connected to answer.", requestId, true);
23714
+ }, ORPHANED_ESCALATION_GRACE_MS);
23715
+ }
23716
+ /**
23717
+ * Replay EVERY parked prompt to a client that just subscribed to the session.
23718
+ *
23719
+ * Plural because a Complex run dispatches sections concurrently, so a wave can
23720
+ * leave more than one waiting at the same time — the same reason the SDK keys
23721
+ * its gates and the clients queue theirs.
23722
+ *
23723
+ * `timeoutMs` is recomputed as the time actually LEFT, because the SDK's timer
23724
+ * has been running since the prompt was raised. Sent verbatim, a client
23725
+ * reconnecting four minutes in would show a fresh five-minute countdown and
23726
+ * then watch the section fail with four of those minutes still on the clock.
23727
+ * A remaining duration is used rather than an absolute deadline so the two
23728
+ * sides never have to agree on a wall clock.
23729
+ */
23730
+ replayEscalations(sessionId, socketId) {
23731
+ for (const entry of this.pendingEscalations.values()) {
23732
+ if (entry.sessionId !== sessionId) continue;
23733
+ if (entry.graceTimer) {
23734
+ clearTimeout(entry.graceTimer);
23735
+ entry.graceTimer = void 0;
23736
+ }
23737
+ const originalTimeout = typeof entry.payload.timeoutMs === "number" ? entry.payload.timeoutMs : 0;
23738
+ const remaining = Math.max(0, originalTimeout - (Date.now() - entry.raisedAt));
23739
+ this.socket.emitToSocket(socketId, "escalation:decision-required", { ...entry.payload, timeoutMs: remaining });
23740
+ }
23741
+ }
23742
+ /** Forget ONE parked prompt — the one that was answered or timed out. */
23743
+ clearPendingEscalation(requestId) {
23744
+ const entry = this.pendingEscalations.get(requestId);
23745
+ if (!entry) return;
23746
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
23747
+ this.pendingEscalations.delete(requestId);
23748
+ }
23749
+ /**
23750
+ * Forget the session's OLDEST prompt — for a client that answered without a
23751
+ * requestId, which is exactly the gate `resolveEscalation` will settle.
23752
+ * Insertion order makes `values()` oldest-first.
23753
+ */
23754
+ clearOldestSessionEscalation(sessionId) {
23755
+ for (const [id, entry] of this.pendingEscalations) {
23756
+ if (entry.sessionId !== sessionId) continue;
23757
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
23758
+ this.pendingEscalations.delete(id);
23759
+ return;
23760
+ }
23761
+ }
23762
+ /** Forget every prompt a session was parked on (run ended, or halted). */
23763
+ clearSessionEscalations(sessionId) {
23764
+ for (const [id, entry] of this.pendingEscalations) {
23765
+ if (entry.sessionId !== sessionId) continue;
23766
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
23767
+ this.pendingEscalations.delete(id);
23768
+ }
23769
+ }
23057
23770
  /** Deny + clear any approvals still pending for a session (run end / abort). */
23058
23771
  denyPendingApprovals(sessionId) {
23059
23772
  for (const [id, pending] of this.pendingApprovals) {
@@ -23554,6 +24267,7 @@ ${prompt}`;
23554
24267
  void (async () => {
23555
24268
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
23556
24269
  this.activeSessions.set(sessionId, cascade);
24270
+ if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
23557
24271
  cascade.on("stream:token", (e) => {
23558
24272
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
23559
24273
  });
@@ -23569,6 +24283,14 @@ ${prompt}`;
23569
24283
  cascade.on("plan:approval-required", (e) => {
23570
24284
  this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
23571
24285
  });
24286
+ cascade.on("escalation:decision-required", (e) => {
24287
+ this.raiseEscalation(sessionId, void 0, e);
24288
+ });
24289
+ cascade.on("escalation:timeout", (e) => {
24290
+ const ev = e;
24291
+ if (ev.requestId) this.clearPendingEscalation(ev.requestId);
24292
+ this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
24293
+ });
23572
24294
  try {
23573
24295
  const result = await cascade.run({
23574
24296
  prompt: runPrompt,
@@ -23596,6 +24318,7 @@ ${prompt}`;
23596
24318
  } finally {
23597
24319
  this.activeSessions.delete(sessionId);
23598
24320
  this.denyPendingApprovals(sessionId);
24321
+ this.clearSessionEscalations(sessionId);
23599
24322
  }
23600
24323
  })();
23601
24324
  });