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.js CHANGED
@@ -1746,7 +1746,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1746
1746
  var init_constants = __esm({
1747
1747
  "src/constants.ts"() {
1748
1748
  init_pricing();
1749
- CASCADE_VERSION = "0.54.0";
1749
+ CASCADE_VERSION = "0.55.0";
1750
1750
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1751
1751
  CASCADE_DB_FILE = ".cascade/memory.db";
1752
1752
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -5389,6 +5389,102 @@ function mergeGlobalCredentials(workspaceProviders, globalProviders) {
5389
5389
  }
5390
5390
  return merged;
5391
5391
  }
5392
+ var MCP_TOOL_PREFIX = "mcp__";
5393
+ var MAX_SEGMENT_LENGTH = 24;
5394
+ var SEGMENT_HASH_LENGTH = 8;
5395
+ function boundedSegment(raw) {
5396
+ const clean = sanitizeToolNameSegment(raw);
5397
+ if (clean.length <= MAX_SEGMENT_LENGTH) return clean;
5398
+ const hash = createHash("sha256").update(raw, "utf8").digest("hex").slice(0, SEGMENT_HASH_LENGTH);
5399
+ return `${clean.slice(0, MAX_SEGMENT_LENGTH - SEGMENT_HASH_LENGTH - 1)}_${hash}`;
5400
+ }
5401
+ function isStableSegment(raw) {
5402
+ return sanitizeToolNameSegment(raw) === raw && raw.length <= MAX_SEGMENT_LENGTH;
5403
+ }
5404
+ function sanitizeToolNameSegment(segment) {
5405
+ const cleaned = segment.replace(/[^a-zA-Z0-9-]+/g, "_");
5406
+ let start = 0;
5407
+ let end = cleaned.length;
5408
+ while (start < end && cleaned[start] === "_") start++;
5409
+ while (end > start && cleaned[end - 1] === "_") end--;
5410
+ return cleaned.slice(start, end) || "_";
5411
+ }
5412
+ function mcpToolName(serverName, toolName) {
5413
+ return `${MCP_TOOL_PREFIX}${boundedSegment(serverName)}__${boundedSegment(toolName)}`;
5414
+ }
5415
+ function isMcpToolName(name) {
5416
+ return name.startsWith(MCP_TOOL_PREFIX);
5417
+ }
5418
+ function uniqueMcpServerName(desired, existingNames) {
5419
+ const taken = new Set(existingNames.map(mcpServerPrefix));
5420
+ if (!taken.has(mcpServerPrefix(desired))) return desired;
5421
+ for (let n = 2; n < 1e3; n++) {
5422
+ const candidate = `${desired} (${n})`;
5423
+ if (!taken.has(mcpServerPrefix(candidate))) return candidate;
5424
+ }
5425
+ return `${desired} (${randomUUID().slice(0, 8)})`;
5426
+ }
5427
+ function disambiguateMcpServerNames(servers) {
5428
+ const seenNames = [];
5429
+ const renames = [];
5430
+ const result = servers.map((s) => {
5431
+ const name = uniqueMcpServerName(s.name, seenNames);
5432
+ seenNames.push(name);
5433
+ if (name === s.name) return s;
5434
+ renames.push({ from: s.name, to: name });
5435
+ return { ...s, name };
5436
+ });
5437
+ return { servers: renames.length ? result : servers, renames };
5438
+ }
5439
+ function assignMcpToolNames(tools) {
5440
+ const names = new Array(tools.length);
5441
+ const bases = tools.map((t) => mcpToolName(t.server, t.tool));
5442
+ const groups = /* @__PURE__ */ new Map();
5443
+ bases.forEach((base, i) => {
5444
+ const g = groups.get(base);
5445
+ if (g) g.push(i);
5446
+ else groups.set(base, [i]);
5447
+ });
5448
+ const used = new Set(bases);
5449
+ for (const [base, idx] of groups) {
5450
+ if (idx.length === 1) {
5451
+ names[idx[0]] = base;
5452
+ continue;
5453
+ }
5454
+ const raw = (i) => `${tools[i].server}::${tools[i].tool}`;
5455
+ const isClean = (i) => isStableSegment(tools[i].server) && isStableSegment(tools[i].tool);
5456
+ const ordered = [...idx].sort((a, b) => {
5457
+ const la = isClean(a) ? 0 : 1;
5458
+ const lb = isClean(b) ? 0 : 1;
5459
+ if (la !== lb) return la - lb;
5460
+ const ra = raw(a);
5461
+ const rb = raw(b);
5462
+ return ra < rb ? -1 : ra > rb ? 1 : 0;
5463
+ });
5464
+ names[ordered[0]] = base;
5465
+ let n = 2;
5466
+ for (let rank3 = 1; rank3 < ordered.length; rank3++) {
5467
+ let candidate = `${base}_${n}`;
5468
+ while (used.has(candidate)) {
5469
+ n++;
5470
+ candidate = `${base}_${n}`;
5471
+ }
5472
+ names[ordered[rank3]] = candidate;
5473
+ used.add(candidate);
5474
+ n++;
5475
+ }
5476
+ }
5477
+ return names;
5478
+ }
5479
+ function mcpServerPrefix(serverName) {
5480
+ return `${MCP_TOOL_PREFIX}${boundedSegment(serverName)}__`;
5481
+ }
5482
+ function removeMcpServerDenials(disabledTools, serverName) {
5483
+ if (!disabledTools?.length) return disabledTools;
5484
+ const prefix = mcpServerPrefix(serverName);
5485
+ const kept = disabledTools.filter((t) => !t.startsWith(prefix));
5486
+ return kept.length === disabledTools.length ? disabledTools : kept;
5487
+ }
5392
5488
 
5393
5489
  // src/config/index.ts
5394
5490
  init_constants();
@@ -5412,6 +5508,13 @@ var ConfigManager = class {
5412
5508
  }
5413
5509
  async load() {
5414
5510
  this.config = await this.loadConfig();
5511
+ const servers = this.config.tools?.mcpServers;
5512
+ const { servers: disambiguated, renames } = servers?.length ? disambiguateMcpServerNames(servers) : { servers, renames: [] };
5513
+ const mcpNamesChanged = renames.length > 0;
5514
+ if (mcpNamesChanged && this.config.tools) {
5515
+ this.config.tools.mcpServers = disambiguated;
5516
+ this.renameMcpServerReferences(renames);
5517
+ }
5415
5518
  this.ignore = new CascadeIgnore();
5416
5519
  await this.ignore.load(this.workspacePath);
5417
5520
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
@@ -5420,6 +5523,7 @@ var ConfigManager = class {
5420
5523
  await this.injectEnvKeys();
5421
5524
  this.config.providers = mergeGlobalCredentials(this.config.providers, loadGlobalCredentials(this.globalDir));
5422
5525
  await this.ensureDefaultIdentity();
5526
+ if (mcpNamesChanged) await this.save();
5423
5527
  }
5424
5528
  getConfig() {
5425
5529
  return this.config;
@@ -5503,6 +5607,66 @@ var ConfigManager = class {
5503
5607
  this.config.providers.push({ type: "ollama" });
5504
5608
  }
5505
5609
  }
5610
+ /**
5611
+ * Follow a disambiguation rename into `tools.mcpTrusted`.
5612
+ *
5613
+ * `mcpTrusted` is matched by EXACT name (`McpClient.connect()`). Two shapes
5614
+ * of rename reach here, and they need OPPOSITE treatment:
5615
+ *
5616
+ * - Distinct raw names that only collide via sanitizing (`foo bar` /
5617
+ * `foo@bar`): each had its OWN trust entry, and the old string now belongs
5618
+ * to nothing — the server that used it moved away, so the entry has to
5619
+ * move with it. A plain rewrite is correct here.
5620
+ * - Literally identical names (a hand-edited or duplicated config: two rows
5621
+ * both named `foo`): there is only ONE trust entry for both, since
5622
+ * `mcpTrusted` itself is deduplicated. `this.config.tools.mcpServers` has
5623
+ * already been updated to the disambiguated list by the time this runs
5624
+ * (see `load()`), so the untouched survivor is STILL named `foo` — a plain
5625
+ * rewrite would move the one entry entirely onto the renamed row and
5626
+ * leave the survivor untrusted. The entry must be kept for the survivor
5627
+ * AND granted to the renamed identity, not moved.
5628
+ *
5629
+ * Renames are grouped by `from` before either treatment is applied, and
5630
+ * each group is settled in one step. Two DIFFERENT rows can start out with
5631
+ * the exact same raw name and BOTH get renamed away (e.g. three servers
5632
+ * named `foo bar`, `foo@bar`, `foo@bar` — the two `foo@bar` rows collide
5633
+ * with `foo bar` and with each other, so both are renamed to distinct
5634
+ * identities while `foo bar` survives untouched). Processing renames one at
5635
+ * a time in that shape drops coverage: the first rename's REPLACE branch
5636
+ * would erase `from` from `trusted` entirely, so the guard on the second
5637
+ * rename with the same `from` sees it already gone and silently skips —
5638
+ * granting trust to only one of the two renamed identities. Grouping first
5639
+ * means every renamed identity that shares a `from` is added or substituted
5640
+ * together, in the one pass that still sees the original entry.
5641
+ *
5642
+ * `tools.disabledTools` deliberately gets no equivalent treatment. It's
5643
+ * matched by sanitized PREFIX, and a prefix collision is exactly what made
5644
+ * an existing entry ambiguous between the two servers in the first place —
5645
+ * there is no way to tell, from the stored string alone, which of the two
5646
+ * a denial was meant for. Leaving those entries untouched resolves that
5647
+ * ambiguity for free: the survivor keeps the original (now unique) prefix,
5648
+ * so an old entry keeps applying to it, while the renamed server starts
5649
+ * clean under its new prefix. Rewriting the entry to follow the rename
5650
+ * would do the opposite — move a denial that already worked for the
5651
+ * survivor onto a server it may never have meant to cover.
5652
+ */
5653
+ renameMcpServerReferences(renames) {
5654
+ const tools = this.config.tools;
5655
+ if (!tools?.mcpTrusted?.length || !renames.length) return;
5656
+ const currentNames = new Set((tools.mcpServers ?? []).map((s) => s.name));
5657
+ const byFrom = /* @__PURE__ */ new Map();
5658
+ for (const { from, to } of renames) {
5659
+ const tos = byFrom.get(from);
5660
+ if (tos) tos.push(to);
5661
+ else byFrom.set(from, [to]);
5662
+ }
5663
+ let trusted = tools.mcpTrusted;
5664
+ for (const [from, tos] of byFrom) {
5665
+ if (!trusted.includes(from)) continue;
5666
+ trusted = currentNames.has(from) ? [...trusted, ...tos] : [...trusted.filter((n) => n !== from), ...tos];
5667
+ }
5668
+ tools.mcpTrusted = Array.from(new Set(trusted));
5669
+ }
5506
5670
  async ensureDefaultIdentity() {
5507
5671
  const existing = this.store.getDefaultIdentity();
5508
5672
  if (existing) return;
@@ -5518,26 +5682,6 @@ var ConfigManager = class {
5518
5682
  }
5519
5683
  };
5520
5684
 
5521
- // src/tools/tool-name.ts
5522
- var MCP_TOOL_PREFIX = "mcp__";
5523
- function sanitizeToolNameSegment(segment) {
5524
- const cleaned = segment.replace(/[^a-zA-Z0-9-]+/g, "_");
5525
- let start = 0;
5526
- let end = cleaned.length;
5527
- while (start < end && cleaned[start] === "_") start++;
5528
- while (end > start && cleaned[end - 1] === "_") end--;
5529
- return cleaned.slice(start, end) || "_";
5530
- }
5531
- function mcpToolName(serverName, toolName) {
5532
- return `${MCP_TOOL_PREFIX}${sanitizeToolNameSegment(serverName)}__${sanitizeToolNameSegment(toolName)}`;
5533
- }
5534
- function isMcpToolName(name) {
5535
- return name.startsWith(MCP_TOOL_PREFIX);
5536
- }
5537
- function mcpServerPrefix(serverName) {
5538
- return `${MCP_TOOL_PREFIX}${sanitizeToolNameSegment(serverName)}__`;
5539
- }
5540
-
5541
5685
  // src/cli/utils/terminal-input.ts
5542
5686
  var PASTE_START = "\x1B[200~";
5543
5687
  var PASTE_END = "\x1B[201~";
@@ -6983,6 +7127,23 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
6983
7127
  */
6984
7128
  budgetState = "ok";
6985
7129
  budgetExceededReason;
7130
+ /**
7131
+ * Whether the run/session budget is permanently tripped, and why.
7132
+ *
7133
+ * A tier or worker that catches `BudgetExceededError` internally (T3Worker
7134
+ * does, so one dead-end subtask doesn't crash the whole run) absorbs the
7135
+ * exception into a normal FAILED/PARTIAL result — the tier returns as if
7136
+ * nothing unusual happened. That silently skips `Cascade.run()`'s dedicated
7137
+ * budget-exceeded handling (the `run:budget-exceeded` event and
7138
+ * `lastInterruptedRun` for `/continue`). This lets a caller check the
7139
+ * router's own state directly after a tier returns, regardless of how that
7140
+ * tier chose to report the error internally.
7141
+ */
7142
+ budgetExceededInfo() {
7143
+ if (this.runBudgetExceeded) return { reason: this.runBudgetExceededReason ?? "Per-task budget exceeded." };
7144
+ if (this.budgetState === "exceeded") return { reason: this.budgetExceededReason ?? "Session budget exceeded." };
7145
+ return null;
7146
+ }
6986
7147
  tpmLimiter;
6987
7148
  localQueue;
6988
7149
  taskAnalyzer;
@@ -8641,6 +8802,13 @@ Now execute your subtask using this context where relevant.`
8641
8802
  this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput2, "FAILED");
8642
8803
  return this.buildResult("ESCALATED", finalOutput2, { checksRun, passed, failed }, issues, correctionAttempts);
8643
8804
  }
8805
+ if (err instanceof Error && err.name === "BudgetExceededError") {
8806
+ issues.push(errMsg);
8807
+ const stopped = output || errMsg;
8808
+ this.setStatus("FAILED", stopped);
8809
+ this.peerBus?.publish(this.id, assignment.subtaskId, stopped, "FAILED");
8810
+ return this.buildResult("FAILED", stopped, { checksRun, passed, failed }, issues, correctionAttempts);
8811
+ }
8644
8812
  issues.push(`Execution error: ${errMsg}`);
8645
8813
  const finalOutput = output || errMsg;
8646
8814
  this.setStatus("FAILED", finalOutput);
@@ -9662,6 +9830,26 @@ var RedactionLayer = class {
9662
9830
  }
9663
9831
  };
9664
9832
 
9833
+ // src/core/tiers/escalation-policy.ts
9834
+ function sectionNeedsDecision(results) {
9835
+ return results.some((r) => r.status === "ESCALATED");
9836
+ }
9837
+ function settledEscalationStatus(results) {
9838
+ const hasWork = results.some((r) => r.status === "COMPLETED" || !!r.output);
9839
+ return hasWork ? "PARTIAL" : "FAILED";
9840
+ }
9841
+ function composeRootSectionOutput(t2Result, completedOutputs) {
9842
+ let output = `${t2Result.sectionSummary}
9843
+
9844
+ ${completedOutputs.join("\n\n")}`;
9845
+ if (t2Result.status === "FAILED" && t2Result.issues?.length) {
9846
+ output += `
9847
+
9848
+ _(incomplete: ${t2Result.issues.join("; ")})_`;
9849
+ }
9850
+ return output;
9851
+ }
9852
+
9665
9853
  // src/core/tiers/t2-manager.ts
9666
9854
  function buildT2SystemPrompt(hasPeerMessage) {
9667
9855
  return [
@@ -9687,6 +9875,19 @@ var T2Manager = class extends BaseTier {
9687
9875
  t2PeerBus;
9688
9876
  permissionEscalator;
9689
9877
  toolCreator;
9878
+ /**
9879
+ * Asked when a section ends ESCALATED — i.e. a worker hit something it could
9880
+ * not decide alone. Without this the escalation was a dead end: the status
9881
+ * said "needs a decision" and no decision was ever requested, so an MCP run
9882
+ * that legitimately needed input just stopped there.
9883
+ */
9884
+ escalationCallback;
9885
+ /**
9886
+ * The one allowed retry has been spent. Retrying is bounded because an
9887
+ * escalation loop that can re-ask forever burns a budget on a question that
9888
+ * is not resolving; a second escalation is terminal instead.
9889
+ */
9890
+ escalationRetryUsed = false;
9690
9891
  /** Optional boardroom gate (Moderate / root-T2 runs) — pauses after decomposition. */
9691
9892
  planApprovalCallback;
9692
9893
  /** AbortController for the current T3 wave — aborted on cancel-and-respawn */
@@ -9735,6 +9936,10 @@ var T2Manager = class extends BaseTier {
9735
9936
  setToolCreator(creator) {
9736
9937
  this.toolCreator = creator;
9737
9938
  }
9939
+ /** Ask the user what to do when a section escalates. */
9940
+ setEscalationCallback(cb) {
9941
+ this.escalationCallback = cb;
9942
+ }
9738
9943
  /** Boardroom gate for Moderate (root-T2) runs: pause after decomposition. */
9739
9944
  setPlanApprovalCallback(cb) {
9740
9945
  this.planApprovalCallback = cb;
@@ -9839,9 +10044,57 @@ Guidance (must be followed): ${decision.note}`
9839
10044
  currentAction: "Aggregating T3 results",
9840
10045
  status: "IN_PROGRESS"
9841
10046
  });
9842
- const summary = await this.aggregateResults(assignment, t3Results);
10047
+ let summary = await this.aggregateResults(assignment, t3Results);
9843
10048
  const issues = t3Results.filter((r) => r.status !== "COMPLETED").flatMap((r) => r.issues);
9844
- const overallStatus = this.determineStatus(t3Results);
10049
+ let overallStatus = this.determineStatus(t3Results);
10050
+ let userSkipped = false;
10051
+ const hasEscalated = sectionNeedsDecision(t3Results);
10052
+ const settleEscalated = async (reason) => {
10053
+ if (reason) issues.push(reason);
10054
+ summary = await this.aggregateResults(assignment, t3Results, { includeEscalated: true });
10055
+ overallStatus = settledEscalationStatus(t3Results);
10056
+ };
10057
+ if (hasEscalated && this.escalationCallback && this.escalationRetryUsed) {
10058
+ await settleEscalated("Escalated again after the retry \u2014 no further attempts were made.");
10059
+ } else if (hasEscalated && !this.escalationCallback) {
10060
+ await settleEscalated();
10061
+ } else if (hasEscalated && this.escalationCallback) {
10062
+ this.sendStatusUpdate({
10063
+ progressPct: 95,
10064
+ currentAction: "Escalated \u2014 waiting for your decision",
10065
+ status: "ESCALATING"
10066
+ });
10067
+ const decision = await this.escalationCallback({
10068
+ sectionId: assignment.sectionId,
10069
+ sectionTitle: assignment.sectionTitle,
10070
+ issues,
10071
+ summary
10072
+ });
10073
+ this.log(`Escalation decision for "${assignment.sectionTitle}": ${decision.action}`);
10074
+ if (decision.action === "skip") {
10075
+ summary = await this.aggregateResults(assignment, t3Results, { includeEscalated: true });
10076
+ overallStatus = settledEscalationStatus(t3Results);
10077
+ userSkipped = decision.automatic !== true;
10078
+ } else if (decision.action === "retry" || decision.action === "guidance") {
10079
+ this.escalationRetryUsed = true;
10080
+ const guided = decision.action === "guidance" && decision.note?.trim() ? {
10081
+ ...assignment,
10082
+ description: `${assignment.description}
10083
+
10084
+ Guidance (must be followed): ${decision.note.trim()}`,
10085
+ // Drop T1's preplanned subtasks so the retry re-decomposes with
10086
+ // the guidance in hand. The workers execute from their subtask
10087
+ // slices, not from this description — leaving the slices in
10088
+ // place gave "Retry with guidance" byte-identical worker
10089
+ // prompts to "Retry as-is", making the note do nothing at all.
10090
+ t3Subtasks: []
10091
+ } : assignment;
10092
+ return await this.execute(guided, taskId, signal);
10093
+ } else {
10094
+ issues.push("Escalated, but no decision was received in time.");
10095
+ overallStatus = "FAILED";
10096
+ }
10097
+ }
9845
10098
  const isOk = overallStatus === "COMPLETED" || overallStatus === "PARTIAL";
9846
10099
  this.setStatus(isOk ? "COMPLETED" : "FAILED", summary);
9847
10100
  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";
@@ -9852,7 +10105,8 @@ Guidance (must be followed): ${decision.note}`
9852
10105
  status: overallStatus,
9853
10106
  t3Results,
9854
10107
  sectionSummary: summary,
9855
- issues
10108
+ issues,
10109
+ ...userSkipped ? { userSkipped: true } : {}
9856
10110
  };
9857
10111
  this.publishSectionOutput(result);
9858
10112
  return result;
@@ -10286,8 +10540,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
10286
10540
  result.status === "COMPLETED" ? "COMPLETED" : "FAILED"
10287
10541
  );
10288
10542
  }
10289
- async aggregateResults(assignment, results) {
10290
- const completed = results.filter((r) => r.status === "COMPLETED");
10543
+ async aggregateResults(assignment, results, opts = {}) {
10544
+ const completed = results.filter(
10545
+ (r) => r.status === "COMPLETED" || opts.includeEscalated && r.status === "ESCALATED" && r.output
10546
+ );
10291
10547
  if (!completed.length) return `Section ${assignment.sectionTitle} failed \u2014 no T3 workers completed.`;
10292
10548
  const peerOutputs = this.peerSyncBuffer.filter((p) => p.content?.type === "T2_SECTION_OUTPUT").map((p) => `[Peer ${p.fromId} Output]: ${p.content.output}`).join("\n\n");
10293
10549
  const peerContext = peerOutputs ? `
@@ -10496,6 +10752,7 @@ var T1Administrator = class extends BaseTier {
10496
10752
  escalations = [];
10497
10753
  store;
10498
10754
  runBreaker;
10755
+ escalationCallback;
10499
10756
  t2PeerBus = new PeerBus();
10500
10757
  permissionEscalator;
10501
10758
  toolCreator;
@@ -10510,6 +10767,10 @@ var T1Administrator = class extends BaseTier {
10510
10767
  this.toolRegistry = toolRegistry;
10511
10768
  this.config = config;
10512
10769
  }
10770
+ /** Ask the user what to do when any section escalates. */
10771
+ setEscalationCallback(cb) {
10772
+ this.escalationCallback = cb;
10773
+ }
10513
10774
  /** Share the run-wide circuit breaker with every section manager. */
10514
10775
  setRunBreaker(breaker) {
10515
10776
  this.runBreaker = breaker;
@@ -10669,8 +10930,11 @@ Create a CORRECTION PLAN that contains only the new sections needed to fix the i
10669
10930
  reason: `Some T2 managers failed entirely: ${failedSections.map((s) => s.sectionTitle).join(", ")}. Errors: ${failedSections.flatMap((s) => s.issues).join("; ")}`
10670
10931
  };
10671
10932
  }
10672
- const sectionsText = t2Results.map((r) => `**${r.sectionTitle}**
10673
- ${r.sectionSummary}`).join("\n\n");
10933
+ const sectionsText = t2Results.map((r) => {
10934
+ 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.]" : "";
10935
+ return `**${r.sectionTitle}**
10936
+ ${r.sectionSummary}${skipNote}`;
10937
+ }).join("\n\n");
10674
10938
  const prompt = `You are a strict QA Reviewer for the Cascade AI system.
10675
10939
  Review the following execution outputs against the original user prompt.
10676
10940
 
@@ -10680,6 +10944,7 @@ T2 Manager Summaries:
10680
10944
  ${sectionsText}
10681
10945
 
10682
10946
  Does the current state of the workspace and the outputs fully satisfy the user's request?
10947
+ 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.
10683
10948
  If yes, reply with exactly: "APPROVED".
10684
10949
  If no, reply with "REJECTED: [Detailed reason explaining exactly what is missing or incorrect]".`;
10685
10950
  try {
@@ -10877,6 +11142,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10877
11142
  const managers = sections.map((section) => {
10878
11143
  const manager = new T2Manager(this.router, this.toolRegistry, this.id);
10879
11144
  if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
11145
+ if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
10880
11146
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
10881
11147
  if (this.store) {
10882
11148
  manager.setStore(this.store);
@@ -11086,7 +11352,10 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
11086
11352
  async compileFinalOutput(originalPrompt, plan, t2Results) {
11087
11353
  const completedSections = t2Results.filter((r) => r.status !== "FAILED");
11088
11354
  if (!completedSections.length) {
11089
- const allIssues = t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues));
11355
+ const allIssues = [
11356
+ ...t2Results.flatMap((r) => r.issues ?? []),
11357
+ ...t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues))
11358
+ ].filter(Boolean);
11090
11359
  const critical = allIssues.find((i) => i.includes("[CRITICAL_TOOL_ERROR]"));
11091
11360
  const stalled = allIssues.find((i) => /^Stalled:/.test(i));
11092
11361
  const topReason = critical ?? stalled ?? allIssues[0] ?? "no specific reason recorded";
@@ -13325,12 +13594,12 @@ var McpToolWrapper = class extends BaseTool {
13325
13594
  mcpClient;
13326
13595
  serverName;
13327
13596
  toolName;
13328
- constructor(mcpClient, serverName, toolName, description, inputSchema) {
13597
+ constructor(mcpClient, serverName, toolName, description, inputSchema, registeredName) {
13329
13598
  super();
13330
13599
  this.mcpClient = mcpClient;
13331
13600
  this.serverName = serverName;
13332
13601
  this.toolName = toolName;
13333
- this.name = mcpToolName(serverName, toolName);
13602
+ this.name = registeredName ?? mcpToolName(serverName, toolName);
13334
13603
  this.description = `[MCP:${serverName}] ${description}`;
13335
13604
  this.inputSchema = inputSchema;
13336
13605
  }
@@ -13401,22 +13670,35 @@ var ToolRegistry = class extends EventEmitter {
13401
13670
  getRegisteredPlugins() {
13402
13671
  return Array.from(this.plugins.keys());
13403
13672
  }
13404
- /** Registers all tools from an MCP client */
13405
- registerMcpTools(mcpClient) {
13406
- const definitions = mcpClient.getToolDefinitions();
13407
- for (const def of definitions) {
13408
- const [, serverName, toolName] = def.name.split("::");
13409
- if (!serverName || !toolName) continue;
13673
+ /**
13674
+ * Registers all tools from an MCP client.
13675
+ *
13676
+ * `isAllowed` receives the FINAL registered name (`mcp__server__tool`) — the
13677
+ * same string the user sees and the same one that reaches the provider — so
13678
+ * a per-tool opt-out in Settings can be expressed as a plain name list.
13679
+ * Filtering here rather than at call time means a deselected tool is genuinely
13680
+ * absent from the definitions handed to the model, so it is never proposed,
13681
+ * never counted against the tool budget, and never has to be refused.
13682
+ */
13683
+ registerMcpTools(mcpClient, isAllowed) {
13684
+ const parsed = mcpClient.getToolDefinitions().flatMap((def) => {
13685
+ const [, server, tool] = def.name.split("::");
13686
+ return server && tool ? [{ def, server, tool }] : [];
13687
+ });
13688
+ const names = assignMcpToolNames(parsed);
13689
+ parsed.forEach(({ def, server, tool }, i) => {
13410
13690
  const wrapper = new McpToolWrapper(
13411
13691
  mcpClient,
13412
- serverName,
13413
- toolName,
13414
- def.description.replace(`[MCP:${serverName}] `, ""),
13415
- def.inputSchema
13692
+ server,
13693
+ tool,
13694
+ def.description.replace(`[MCP:${server}] `, ""),
13695
+ def.inputSchema,
13696
+ names[i]
13416
13697
  );
13698
+ if (isAllowed && !isAllowed(wrapper.name)) return;
13417
13699
  wrapper.setWorkspaceRoot(this.workspaceRoot);
13418
13700
  this.register(wrapper);
13419
- }
13701
+ });
13420
13702
  }
13421
13703
  setIgnoredPaths(patterns) {
13422
13704
  this.ignoreMatcher = ignore2().add(patterns);
@@ -13753,6 +14035,17 @@ function fileOAuthProvider(storePath) {
13753
14035
  }
13754
14036
  });
13755
14037
  }
14038
+ var oauthStoreLocks = /* @__PURE__ */ new Map();
14039
+ function withOAuthStoreLock(storePath, fn) {
14040
+ const prev = oauthStoreLocks.get(storePath) ?? Promise.resolve();
14041
+ const run = prev.then(fn, fn);
14042
+ const tail = run.then(() => void 0, () => void 0);
14043
+ oauthStoreLocks.set(storePath, tail);
14044
+ void tail.then(() => {
14045
+ if (oauthStoreLocks.get(storePath) === tail) oauthStoreLocks.delete(storePath);
14046
+ });
14047
+ return run;
14048
+ }
13756
14049
  async function connectMcpWithLoopbackOAuth(opts) {
13757
14050
  const listener = await startCallbackListener(opts);
13758
14051
  const transport = new StreamableHTTPClientTransport(new URL(opts.serverUrl), {
@@ -16233,7 +16526,50 @@ var GuidanceQueue = class {
16233
16526
  }
16234
16527
  };
16235
16528
 
16529
+ // src/tools/current-page.ts
16530
+ var MAX_PAGE_TEXT_CHARS = 4e4;
16531
+ var CurrentPageTool = class extends BaseTool {
16532
+ name = "read_current_page";
16533
+ 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.';
16534
+ inputSchema = {
16535
+ type: "object",
16536
+ properties: {},
16537
+ required: []
16538
+ };
16539
+ provider;
16540
+ constructor(provider) {
16541
+ super();
16542
+ this.provider = provider;
16543
+ }
16544
+ // Reading what is already on the user's screen changes nothing and reaches
16545
+ // nothing they have not already opened themselves.
16546
+ isDangerous() {
16547
+ return false;
16548
+ }
16549
+ async execute(_input, _options) {
16550
+ let page2;
16551
+ try {
16552
+ page2 = await this.provider();
16553
+ } catch (err) {
16554
+ return `Error: could not read the open page \u2014 ${err instanceof Error ? err.message : String(err)}`;
16555
+ }
16556
+ if (!page2) {
16557
+ return "No page is open in the built-in browser. Ask the user to open one, or use web_fetch for a public URL.";
16558
+ }
16559
+ const text = page2.text.length > MAX_PAGE_TEXT_CHARS ? `${page2.text.slice(0, MAX_PAGE_TEXT_CHARS)}
16560
+
16561
+ [truncated \u2014 the page is longer than ${MAX_PAGE_TEXT_CHARS} characters]` : page2.text;
16562
+ return [
16563
+ `URL: ${page2.url}`,
16564
+ `Title: ${page2.title}`,
16565
+ "",
16566
+ text.trim() || "[the page rendered no readable text]"
16567
+ ].join("\n");
16568
+ }
16569
+ };
16570
+
16236
16571
  // src/core/cascade.ts
16572
+ var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
16237
16573
  function buildContextualPrompt(prompt, history = []) {
16238
16574
  const recent = history.slice(-6);
16239
16575
  if (recent.length === 0) return prompt;
@@ -16456,6 +16792,94 @@ var Cascade = class _Cascade extends EventEmitter {
16456
16792
  });
16457
16793
  });
16458
16794
  }
16795
+ // ── Escalation decisions ────────────────────────────────────────────
16796
+ //
16797
+ // A section that ends ESCALATED used to be a dead end: the Cockpit showed
16798
+ // "Section escalated — needs a decision" and no decision was ever requested,
16799
+ // so the run stopped there having spent a full orchestration. This is the
16800
+ // same round-trip shape as plan approval, with one deliberate difference —
16801
+ // an unanswered escalation FAILS the section instead of proceeding.
16802
+ //
16803
+ // That direction is the safe one here. Plan approval defaults to "yes" on
16804
+ // timeout because the plan is already the model's considered proposal;
16805
+ // an escalation exists precisely BECAUSE the worker was not confident, so
16806
+ // acting unattended is the option most likely to be wrong. And in cloud a
16807
+ // waiting run holds server resources, so hanging is not free either.
16808
+ // Keyed by request id, NOT a single slot. A Complex run dispatches each wave
16809
+ // of ready sections with `Promise.all` (t1-administrator.ts), so two sections
16810
+ // can be waiting on a decision at the same moment. A single field made that
16811
+ // case fail hard rather than merely confusingly: the second escalation
16812
+ // overwrote the first's resolver, the user's answer resolved only the second,
16813
+ // and then the FIRST section's timer found the slot already empty and
16814
+ // returned without resolving anything — parking the run forever, past its own
16815
+ // timeout. Same class of bug as the twelve-failover dead-model race: a wave
16816
+ // is concurrent, so per-run singletons are never safe inside it.
16817
+ pendingEscalations = /* @__PURE__ */ new Map();
16818
+ async requestEscalationDecision(ctx, taskId, signal) {
16819
+ if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
16820
+ if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
16821
+ if (signal?.aborted) return { action: "skip", automatic: true };
16822
+ const requestId = randomUUID();
16823
+ return await new Promise((resolve2) => {
16824
+ let timeout;
16825
+ const settle = (decision) => {
16826
+ if (!this.pendingEscalations.delete(requestId)) return;
16827
+ if (timeout) clearTimeout(timeout);
16828
+ signal?.removeEventListener("abort", onAbort);
16829
+ resolve2(decision);
16830
+ };
16831
+ const onAbort = () => settle({ action: "skip", automatic: true });
16832
+ signal?.addEventListener("abort", onAbort, { once: true });
16833
+ timeout = setTimeout(() => {
16834
+ this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
16835
+ settle({ action: "timeout" });
16836
+ }, ESCALATION_DECISION_TIMEOUT_MS);
16837
+ this.pendingEscalations.set(requestId, settle);
16838
+ this.emit("escalation:decision-required", {
16839
+ taskId,
16840
+ requestId,
16841
+ sectionId: ctx.sectionId,
16842
+ sectionTitle: ctx.sectionTitle,
16843
+ issues: ctx.issues,
16844
+ summary: ctx.summary,
16845
+ timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
16846
+ });
16847
+ });
16848
+ }
16849
+ /**
16850
+ * Resolve a pending escalation from a REPL / dashboard / cloud listener.
16851
+ *
16852
+ * `requestId` comes back from the `escalation:decision-required` event and is
16853
+ * what makes an answer land on the section that asked. It is optional so a
16854
+ * host with only one escalation in flight (and any caller that predates the
16855
+ * id) still works: without it the oldest outstanding request is answered,
16856
+ * which is correct whenever there is exactly one.
16857
+ *
16858
+ * `automatic` defaults to false — a call into this method is normally a real
16859
+ * person answering the prompt. Pass `true` from a host that is itself
16860
+ * settling the gate on nobody's behalf (a REST caller who never had a
16861
+ * chance to connect, a session halt with no per-section review behind it),
16862
+ * so T2 doesn't set `userSkipped` for a decision no one actually reviewed —
16863
+ * see EscalationDecision.automatic.
16864
+ */
16865
+ resolveEscalation(action, note, requestId, automatic) {
16866
+ const decision = { action, ...note ? { note } : {}, ...automatic ? { automatic: true } : {} };
16867
+ if (requestId) {
16868
+ this.pendingEscalations.get(requestId)?.(decision);
16869
+ return;
16870
+ }
16871
+ const oldest = this.pendingEscalations.keys().next();
16872
+ if (!oldest.done) this.pendingEscalations.get(oldest.value)?.(decision);
16873
+ }
16874
+ /**
16875
+ * Release every parked escalation — run teardown, so none outlive the run.
16876
+ * `automatic: true`: teardown resolving a section nobody answered is the
16877
+ * same system-decided case as the early-return skips above, not a person
16878
+ * reviewing and accepting that section's output.
16879
+ */
16880
+ releasePendingEscalations(action = "skip") {
16881
+ for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
16882
+ }
16459
16883
  /**
16460
16884
  * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
16461
16885
  * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
@@ -16589,6 +17013,20 @@ ${input}` }],
16589
17013
  this.mediaSink = sink;
16590
17014
  this.registerMediaTools(this.workspacePath);
16591
17015
  }
17016
+ /**
17017
+ * Let the agent read the page the user has open in the host's browser.
17018
+ *
17019
+ * Host-supplied because only a host WITH a browser can answer it — so the
17020
+ * tool is simply absent in the CLI and in hosted runs, rather than present
17021
+ * and always failing. Registered outside `enabledTools` for the same reason
17022
+ * media tools are: that allowlist is a blast-radius control for tools that
17023
+ * touch the machine, and reading a page the user already opened touches
17024
+ * nothing. `disabledTools` still removes it.
17025
+ */
17026
+ setCurrentPageProvider(provider) {
17027
+ if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
17028
+ this.toolRegistry.register(new CurrentPageTool(provider));
17029
+ }
16592
17030
  registerMediaTools(workspacePath) {
16593
17031
  const denied = new Set(this.config.tools?.disabledTools ?? []);
16594
17032
  const permitted = (name) => !denied.has(name);
@@ -16721,8 +17159,12 @@ ${last.partialOutput}` : "");
16721
17159
  for (const server of this.config.tools.mcpServers) {
16722
17160
  try {
16723
17161
  const authProvider = server.oauthStore ? fileOAuthProvider(server.oauthStore) : void 0;
16724
- await this.mcpClient.connect(server, authProvider ? { authProvider } : {});
16725
- this.toolRegistry.registerMcpTools(this.mcpClient);
17162
+ const connect = () => this.mcpClient.connect(server, authProvider ? { authProvider } : {});
17163
+ await (server.oauthStore ? withOAuthStoreLock(server.oauthStore, connect) : connect());
17164
+ this.toolRegistry.registerMcpTools(
17165
+ this.mcpClient,
17166
+ (name) => !(this.config.tools?.disabledTools ?? []).includes(name)
17167
+ );
16726
17168
  } catch (err) {
16727
17169
  console.error(`Failed to connect to MCP server "${server.name}":`, err);
16728
17170
  }
@@ -17187,6 +17629,7 @@ ${prompt}` : prompt;
17187
17629
  } else if (complexity === "Moderate") {
17188
17630
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
17189
17631
  t2.setRunBreaker(runBreaker);
17632
+ t2.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
17190
17633
  t2.setPresenter(true);
17191
17634
  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.");
17192
17635
  if (identityPrompt) {
@@ -17231,11 +17674,17 @@ ${prompt}` : prompt;
17231
17674
  t2Results = [t2Result];
17232
17675
  const completed = t2Result.t3Results.filter((r) => r.status === "COMPLETED");
17233
17676
  if (completed.length > 0) {
17234
- finalOutput = t2Result.sectionSummary + "\n\n" + completed.map((r) => r.output).join("\n\n");
17677
+ finalOutput = composeRootSectionOutput(
17678
+ t2Result,
17679
+ completed.map((r) => typeof r.output === "string" ? r.output : JSON.stringify(r.output))
17680
+ );
17235
17681
  } else {
17236
17682
  const partial = t2Result.t3Results.map((r) => typeof r.output === "string" ? r.output : "").filter((o) => o.trim()).sort((a, b) => b.length - a.length)[0];
17237
17683
  const reasons = Array.from(
17238
- new Set(t2Result.t3Results.flatMap((r) => r.issues ?? []).filter(Boolean))
17684
+ new Set([
17685
+ ...t2Result.issues ?? [],
17686
+ ...t2Result.t3Results.flatMap((r) => r.issues ?? [])
17687
+ ].filter(Boolean))
17239
17688
  );
17240
17689
  if (partial) {
17241
17690
  finalOutput = reasons.length ? `${partial}
@@ -17248,6 +17697,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
17248
17697
  } else {
17249
17698
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
17250
17699
  t1.setRunBreaker(runBreaker);
17700
+ t1.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
17251
17701
  t1.setPresenter(true);
17252
17702
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
17253
17703
  if (identityPrompt) {
@@ -17272,6 +17722,8 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
17272
17722
  finalOutput = result.output;
17273
17723
  t2Results = result.t2Results;
17274
17724
  }
17725
+ const budgetInfo = this.router.budgetExceededInfo();
17726
+ if (budgetInfo) throw new CascadeRouter.BudgetExceededError(budgetInfo.reason);
17275
17727
  const trip = runBreaker.reason();
17276
17728
  if (trip) {
17277
17729
  finalOutput = [
@@ -17312,6 +17764,10 @@ ${finalOutput}` : ""
17312
17764
  escalator.cancelAllPending();
17313
17765
  } catch {
17314
17766
  }
17767
+ try {
17768
+ this.releasePendingEscalations();
17769
+ } catch {
17770
+ }
17315
17771
  this.router.restoreTierModels();
17316
17772
  this.router.setRunSignal(void 0);
17317
17773
  if (this.taskAnalyzer) {
@@ -21738,11 +22194,55 @@ function mergeByKey(local, incoming, key) {
21738
22194
  for (const i of incoming) map.set(key(i), i);
21739
22195
  return [...map.values()];
21740
22196
  }
22197
+ function uniqueServerName(desired, takenPrefixes) {
22198
+ if (!takenPrefixes.has(mcpServerPrefix(desired))) return desired;
22199
+ let n = 2;
22200
+ while (takenPrefixes.has(mcpServerPrefix(`${desired} ${n}`))) n++;
22201
+ return `${desired} ${n}`;
22202
+ }
22203
+ function mergeMcpServers(local, incoming) {
22204
+ const rewrite = /* @__PURE__ */ new Map();
22205
+ if (!incoming?.length) return { servers: local, synced: [], rewrite };
22206
+ const byName = /* @__PURE__ */ new Map();
22207
+ for (const l of local ?? []) byName.set(l.name, l);
22208
+ const prefixes = new Set([...byName.values()].map((m) => mcpServerPrefix(m.name)));
22209
+ const synced = [];
22210
+ for (const inc of incoming) {
22211
+ if (byName.has(inc.name)) {
22212
+ byName.set(inc.name, inc);
22213
+ synced.push(inc);
22214
+ continue;
22215
+ }
22216
+ const name = uniqueServerName(inc.name, prefixes);
22217
+ if (name !== inc.name) rewrite.set(mcpServerPrefix(inc.name), mcpServerPrefix(name));
22218
+ const server = name === inc.name ? inc : { ...inc, name };
22219
+ byName.set(name, server);
22220
+ synced.push(server);
22221
+ prefixes.add(mcpServerPrefix(name));
22222
+ }
22223
+ return { servers: [...byName.values()], synced, rewrite };
22224
+ }
22225
+ function rewriteDisabledTools(tools, rewrite) {
22226
+ if (!tools?.length || rewrite.size === 0) return tools;
22227
+ return tools.map((t) => {
22228
+ for (const [from, to] of rewrite) if (t.startsWith(from)) return to + t.slice(from.length);
22229
+ return t;
22230
+ });
22231
+ }
22232
+ function mergeDisabledTools(local, incoming, syncedServers) {
22233
+ const prefixes = (syncedServers ?? []).map((m) => mcpServerPrefix(m.name));
22234
+ const covered = (tool) => prefixes.some((p) => tool.startsWith(p));
22235
+ const kept = (local ?? []).filter((t) => !covered(t));
22236
+ const merged = [.../* @__PURE__ */ new Set([...kept, ...incoming ?? []])];
22237
+ return merged.length ? merged : void 0;
22238
+ }
21741
22239
  function gatherSyncBundle(config) {
21742
- const bundle = { v: 1 };
22240
+ const bundle = { v: 2 };
21743
22241
  if (config.providers?.length) bundle.providers = config.providers;
21744
22242
  if (config.tools?.webSearch) bundle.webSearch = config.tools.webSearch;
21745
22243
  if (config.tools?.mcpServers?.length) bundle.mcpServers = config.tools.mcpServers;
22244
+ const syncedDisabledTools = config.tools?.disabledTools?.filter(isMcpToolName);
22245
+ if (syncedDisabledTools?.length) bundle.disabledTools = syncedDisabledTools;
21746
22246
  if (config.models) bundle.models = config.models;
21747
22247
  if (config.budget) bundle.budget = config.budget;
21748
22248
  const prefs = {};
@@ -21757,7 +22257,16 @@ function applySyncBundle(bundle, config) {
21757
22257
  const next = { ...config, tools: { ...config.tools } };
21758
22258
  if (bundle.providers) next.providers = mergeByKey(config.providers, bundle.providers, providerSig) ?? [];
21759
22259
  if (bundle.webSearch) next.tools.webSearch = { ...config.tools?.webSearch, ...bundle.webSearch };
21760
- if (bundle.mcpServers) next.tools.mcpServers = mergeByKey(config.tools?.mcpServers, bundle.mcpServers, (m) => m.name);
22260
+ const mcp2 = mergeMcpServers(config.tools?.mcpServers, bundle.mcpServers);
22261
+ if (bundle.mcpServers) next.tools.mcpServers = mcp2.servers;
22262
+ const carriesSelections = bundle.v >= 2 || bundle.disabledTools !== void 0;
22263
+ if (carriesSelections && (bundle.mcpServers || bundle.disabledTools)) {
22264
+ next.tools.disabledTools = mergeDisabledTools(
22265
+ config.tools?.disabledTools,
22266
+ rewriteDisabledTools(bundle.disabledTools, mcp2.rewrite),
22267
+ bundle.mcpServers ? mcp2.synced : void 0
22268
+ );
22269
+ }
21761
22270
  if (bundle.models) next.models = { ...config.models, ...bundle.models };
21762
22271
  if (bundle.budget) next.budget = { ...config.budget, ...bundle.budget };
21763
22272
  if (bundle.prefs) {
@@ -22098,12 +22607,19 @@ function defaultName(url) {
22098
22607
  }
22099
22608
  }
22100
22609
  async function mcpConnectCommand(url, opts = {}) {
22101
- const name = opts.name?.trim() || defaultName(url);
22102
- const store = new FileMcpOAuthStore(storePathFor(name));
22610
+ const desiredName = opts.name?.trim() || defaultName(url);
22103
22611
  console.log(chalk7.magenta("\n \u25C8 Connect an MCP server via OAuth"));
22104
22612
  console.log(chalk7.dim(` ${url}
22105
22613
  `));
22106
22614
  try {
22615
+ const cm = new ConfigManager(process.cwd());
22616
+ await cm.load();
22617
+ const config = cm.getConfig();
22618
+ config.tools = config.tools ?? {};
22619
+ const servers = config.tools.mcpServers ?? [];
22620
+ const existingIdx = servers.findIndex((s) => s.name === desiredName);
22621
+ const name = existingIdx >= 0 ? desiredName : uniqueMcpServerName(desiredName, servers.map((s) => s.name));
22622
+ const store = new FileMcpOAuthStore(storePathFor(name));
22107
22623
  await connectMcpWithLoopbackOAuth({
22108
22624
  serverUrl: url,
22109
22625
  store,
@@ -22113,14 +22629,8 @@ async function mcpConnectCommand(url, opts = {}) {
22113
22629
  openBrowser2(u);
22114
22630
  }
22115
22631
  });
22116
- const cm = new ConfigManager(process.cwd());
22117
- await cm.load();
22118
- const config = cm.getConfig();
22119
- config.tools = config.tools ?? {};
22120
- const servers = config.tools.mcpServers ?? [];
22121
22632
  const entry = { name, url, oauthStore: storePathFor(name) };
22122
- const idx = servers.findIndex((s) => s.name === name);
22123
- if (idx >= 0) servers[idx] = entry;
22633
+ if (existingIdx >= 0) servers[existingIdx] = entry;
22124
22634
  else servers.push(entry);
22125
22635
  config.tools.mcpServers = servers;
22126
22636
  config.tools.mcpTrusted = Array.from(/* @__PURE__ */ new Set([...config.tools.mcpTrusted ?? [], name]));
@@ -22165,6 +22675,7 @@ async function mcpRemoveCommand(name) {
22165
22675
  }
22166
22676
  config.tools.mcpServers = servers.filter((s) => s.name !== name);
22167
22677
  config.tools.mcpTrusted = (config.tools.mcpTrusted ?? []).filter((n) => n !== name);
22678
+ config.tools.disabledTools = removeMcpServerDenials(config.tools.disabledTools, name);
22168
22679
  if (match.oauthStore) {
22169
22680
  try {
22170
22681
  new FileMcpOAuthStore(match.oauthStore).clear();
@@ -22271,6 +22782,14 @@ var DashboardSocket = class {
22271
22782
  this.secret = options.secret;
22272
22783
  this.setupHandlers();
22273
22784
  }
22785
+ /** How many sockets are currently in a room — 0 means nobody can answer. */
22786
+ roomSize(room) {
22787
+ return this.io.sockets.adapter.rooms.get(room)?.size ?? 0;
22788
+ }
22789
+ /** Is this exact connection still live? A reconnect issues a NEW socket id. */
22790
+ hasSocket(socketId) {
22791
+ return this.io.sockets.sockets.has(socketId);
22792
+ }
22274
22793
  broadcastToRoom(room, event, data) {
22275
22794
  this.io.to(room).emit(event, data);
22276
22795
  }
@@ -22342,6 +22861,22 @@ var DashboardSocket = class {
22342
22861
  });
22343
22862
  });
22344
22863
  }
22864
+ /**
22865
+ * A client subscribed to a session's room.
22866
+ *
22867
+ * The host uses this to replay a gate the run is already parked on. Socket.IO
22868
+ * does not buffer room emissions, so a prompt broadcast while the renderer was
22869
+ * reconnecting is simply gone; without a replay the section waits out its full
22870
+ * timeout with the user sitting in front of a screen that never asked.
22871
+ */
22872
+ onJoinSession(callback) {
22873
+ this.io.on("connection", (socket) => {
22874
+ socket.on("join:session", (payload) => {
22875
+ const { sessionId } = normalizeSessionSubscriptionPayload(payload);
22876
+ if (sessionId) callback(sessionId, socket.id);
22877
+ });
22878
+ });
22879
+ }
22345
22880
  onSessionRate(callback) {
22346
22881
  this.io.on("connection", (socket) => {
22347
22882
  socket.on("session:rate", (payload) => {
@@ -22379,6 +22914,31 @@ var DashboardSocket = class {
22379
22914
  });
22380
22915
  });
22381
22916
  }
22917
+ /**
22918
+ * A section escalated and the run is parked waiting for an answer. The
22919
+ * desktop shows the escalation modal on `escalation:decision-required` and
22920
+ * answers here.
22921
+ *
22922
+ * Unlike a plan decision, silence is NOT taken as consent — the SDK fails
22923
+ * the section on timeout — so this handler only ever forwards a real choice.
22924
+ */
22925
+ onEscalationDecision(callback) {
22926
+ this.io.on("connection", (socket) => {
22927
+ socket.on("escalation:decide", (payload) => {
22928
+ const action = payload?.action;
22929
+ if (typeof payload?.sessionId !== "string") return;
22930
+ if (action !== "retry" && action !== "skip" && action !== "guidance") return;
22931
+ callback({
22932
+ sessionId: payload.sessionId,
22933
+ // Identifies WHICH parked section this answers — sections in a wave
22934
+ // run concurrently, so more than one can be waiting.
22935
+ requestId: typeof payload.requestId === "string" ? payload.requestId : void 0,
22936
+ action,
22937
+ note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0
22938
+ });
22939
+ });
22940
+ });
22941
+ }
22382
22942
  onSessionSteer(callback) {
22383
22943
  this.io.on("connection", (socket) => {
22384
22944
  socket.on("session:steer", (payload) => {
@@ -22540,6 +23100,7 @@ function aggregateCostStats(sessions2, opts = {}) {
22540
23100
 
22541
23101
  // src/dashboard/server.ts
22542
23102
  var __dirname$1 = path32.dirname(fileURLToPath(import.meta.url));
23103
+ var ORPHANED_ESCALATION_GRACE_MS = 3e4;
22543
23104
  var DashboardServer = class {
22544
23105
  app;
22545
23106
  httpServer;
@@ -22550,6 +23111,13 @@ var DashboardServer = class {
22550
23111
  globalStore = null;
22551
23112
  broadcastTimer = null;
22552
23113
  activeSessions = /* @__PURE__ */ new Map();
23114
+ /**
23115
+ * Supplied by the desktop shell so a run can read the page the user has
23116
+ * open in the built-in browser. Absent in the CLI, where there is no
23117
+ * browser — so the tool is simply not registered rather than present and
23118
+ * always failing.
23119
+ */
23120
+ currentPageProvider;
22553
23121
  activeControllers = /* @__PURE__ */ new Map();
22554
23122
  /**
22555
23123
  * Run taskIds per chat session — file snapshots are keyed by the run's
@@ -22564,6 +23132,17 @@ var DashboardServer = class {
22564
23132
  * map is how that answer reaches the run that's blocked on it.
22565
23133
  */
22566
23134
  pendingApprovals = /* @__PURE__ */ new Map();
23135
+ /**
23136
+ * The escalation prompt a session is currently parked on, kept so it can be
23137
+ * delivered to a client that was not reachable when it was raised.
23138
+ *
23139
+ * Socket.IO buffers nothing: a prompt emitted while the renderer is mid
23140
+ * reconnect is gone, and the section then waits out its full five minutes
23141
+ * with the user in front of a screen that never asked them anything. Holding
23142
+ * the payload lets `join:session` replay it, and lets a run with no reachable
23143
+ * client be settled instead of parked (see ORPHANED_ESCALATION_GRACE_MS).
23144
+ */
23145
+ pendingEscalations = /* @__PURE__ */ new Map();
22567
23146
  /**
22568
23147
  * The orchestration decision trail ("why") of each session's most recent
22569
23148
  * run — captured when the run ends so the desktop's Why panel can show it
@@ -22641,20 +23220,29 @@ var DashboardServer = class {
22641
23220
  if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
22642
23221
  const cascade = new Cascade(cfg, this.workspacePath, this.store);
22643
23222
  this.activeSessions.set(sessionId, cascade);
23223
+ if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
22644
23224
  cascade.on("stream:token", (e) => {
22645
- this.socket.emitToSocket(socketId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
23225
+ this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
22646
23226
  });
22647
23227
  cascade.on("tier:status", (e) => {
22648
- this.socket.emitToSocket(socketId, "tier:status", { sessionId, ...e });
23228
+ this.emitToSessionClients(socketId, sessionId, "tier:status", { sessionId, ...e });
22649
23229
  });
22650
23230
  cascade.on("permission:user-required", (e) => {
22651
- this.socket.emitToSocket(socketId, "permission:user-required", { sessionId, ...e });
23231
+ this.emitToSessionClients(socketId, sessionId, "permission:user-required", { sessionId, ...e });
22652
23232
  });
22653
23233
  cascade.on("peer:message", (e) => {
22654
23234
  this.socket.emitPeerMessage(e);
22655
23235
  });
22656
23236
  cascade.on("plan:approval-required", (e) => {
22657
- this.socket.emitToSocket(socketId, "plan:approval-required", { sessionId, ...e });
23237
+ this.emitToSessionClients(socketId, sessionId, "plan:approval-required", { sessionId, ...e });
23238
+ });
23239
+ cascade.on("escalation:decision-required", (e) => {
23240
+ this.raiseEscalation(sessionId, socketId, e);
23241
+ });
23242
+ cascade.on("escalation:timeout", (e) => {
23243
+ const ev = e;
23244
+ if (ev.requestId) this.clearPendingEscalation(ev.requestId);
23245
+ this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
22658
23246
  });
22659
23247
  try {
22660
23248
  const result = await cascade.run({
@@ -22665,7 +23253,7 @@ var DashboardServer = class {
22665
23253
  this.recordSessionTask(sessionId, result.taskId);
22666
23254
  this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
22667
23255
  this.captureWhy(sessionId, cascade, result);
22668
- this.socket.emitToSocket(socketId, "session:complete", { sessionId, result });
23256
+ this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
22669
23257
  this.socket.broadcast("cost:update", {
22670
23258
  sessionId,
22671
23259
  totalTokens: result.usage.totalTokens,
@@ -22678,7 +23266,7 @@ var DashboardServer = class {
22678
23266
  } catch (err) {
22679
23267
  this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
22680
23268
  this.captureWhy(sessionId, cascade);
22681
- this.socket.emitToSocket(socketId, "session:error", {
23269
+ this.emitToSessionClients(socketId, sessionId, "session:error", {
22682
23270
  sessionId,
22683
23271
  error: err instanceof Error ? err.message : String(err)
22684
23272
  });
@@ -22686,6 +23274,7 @@ var DashboardServer = class {
22686
23274
  this.activeSessions.delete(sessionId);
22687
23275
  this.activeControllers.delete(sessionId);
22688
23276
  this.denyPendingApprovals(sessionId);
23277
+ this.clearSessionEscalations(sessionId);
22689
23278
  }
22690
23279
  });
22691
23280
  this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
@@ -22695,10 +23284,20 @@ var DashboardServer = class {
22695
23284
  editedPlan
22696
23285
  );
22697
23286
  });
23287
+ this.socket.onEscalationDecision(({ sessionId, requestId, action, note }) => {
23288
+ if (requestId) this.clearPendingEscalation(requestId);
23289
+ else this.clearOldestSessionEscalation(sessionId);
23290
+ this.activeSessions.get(sessionId)?.resolveEscalation(action, note, requestId);
23291
+ });
23292
+ this.socket.onJoinSession((sessionId, socketId) => {
23293
+ this.replayEscalations(sessionId, socketId);
23294
+ });
22698
23295
  this.socket.onSessionHalt((sessionId) => {
22699
23296
  this.activeControllers.get(sessionId)?.abort();
22700
23297
  this.denyPendingApprovals(sessionId);
22701
23298
  this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
23299
+ this.clearSessionEscalations(sessionId);
23300
+ this.activeSessions.get(sessionId)?.resolveEscalation("skip", void 0, void 0, true);
22702
23301
  });
22703
23302
  this.socket.onApprovalResponse(({ requestId, approved, always }) => {
22704
23303
  const pending = this.pendingApprovals.get(requestId);
@@ -22713,6 +23312,10 @@ var DashboardServer = class {
22713
23312
  }
22714
23313
  });
22715
23314
  }
23315
+ /** Wire the host's browser view in (desktop only). */
23316
+ setCurrentPageProvider(provider) {
23317
+ this.currentPageProvider = provider;
23318
+ }
22716
23319
  async start() {
22717
23320
  const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
22718
23321
  if (!isLoopback) {
@@ -23001,6 +23604,116 @@ var DashboardServer = class {
23001
23604
  this.activeSessions.delete(sessionId);
23002
23605
  }
23003
23606
  }
23607
+ /**
23608
+ * Is anyone able to answer for this session right now?
23609
+ *
23610
+ * The originating socket first — that connection is definitely watching this
23611
+ * run — then the session room, which is where a client lands after a
23612
+ * reconnect issues it a new socket id.
23613
+ */
23614
+ hasResponder(sessionId, socketId) {
23615
+ if (socketId && this.socket.hasSocket(socketId)) return true;
23616
+ return this.socket.roomSize(`session:${sessionId}`) > 0;
23617
+ }
23618
+ /**
23619
+ * Send a run event to whoever is watching this session.
23620
+ *
23621
+ * A captured socket id goes stale the moment Socket.IO reconnects — the
23622
+ * client is back, but under a new id, and `emitToSocket` on the old one is a
23623
+ * silent no-op. Falling back to the session room keeps the run addressable
23624
+ * across a reconnect; emitting to one OR the other (never both) keeps a
23625
+ * client that is both the originator and a room member from seeing every
23626
+ * token twice.
23627
+ */
23628
+ emitToSessionClients(socketId, sessionId, event, data) {
23629
+ if (socketId && this.socket.hasSocket(socketId)) this.socket.emitToSocket(socketId, event, data);
23630
+ else this.socket.broadcastToRoom(`session:${sessionId}`, event, data);
23631
+ }
23632
+ /**
23633
+ * Raise an escalation prompt, and make sure it can still be answered.
23634
+ *
23635
+ * Held in `pendingEscalations` — keyed by requestId, because a Complex wave
23636
+ * dispatches sections concurrently and two can be parked at once — so a
23637
+ * client that joins late (or rejoins after a reconnect) gets every waiting
23638
+ * prompt replayed. Keying by session would drop the first of a pair: its
23639
+ * grace timer would be cleared by the second, leaving it parked for the full
23640
+ * timeout with nobody able to answer it. If nobody is reachable at all, the gate is
23641
+ * settled as `skip` after a short grace period rather than parked for the
23642
+ * full timeout: that matches the SDK's policy when no listener exists, and
23643
+ * five minutes of a held run is a real cost for a REST caller who has already
23644
+ * hung up. The grace period exists so an ordinary two-second reconnect is not
23645
+ * mistaken for an absent user.
23646
+ */
23647
+ raiseEscalation(sessionId, socketId, event) {
23648
+ const requestId = typeof event.requestId === "string" && event.requestId ? event.requestId : randomUUID();
23649
+ const payload = { sessionId, ...event };
23650
+ const entry = { payload, sessionId, requestId, raisedAt: Date.now() };
23651
+ this.pendingEscalations.set(requestId, entry);
23652
+ if (this.hasResponder(sessionId, socketId)) {
23653
+ this.emitToSessionClients(socketId, sessionId, "escalation:decision-required", payload);
23654
+ return;
23655
+ }
23656
+ entry.graceTimer = setTimeout(() => {
23657
+ if (this.pendingEscalations.get(requestId) !== entry) return;
23658
+ if (this.hasResponder(sessionId, socketId)) return;
23659
+ this.pendingEscalations.delete(requestId);
23660
+ this.activeSessions.get(sessionId)?.resolveEscalation("skip", "No client was connected to answer.", requestId, true);
23661
+ }, ORPHANED_ESCALATION_GRACE_MS);
23662
+ }
23663
+ /**
23664
+ * Replay EVERY parked prompt to a client that just subscribed to the session.
23665
+ *
23666
+ * Plural because a Complex run dispatches sections concurrently, so a wave can
23667
+ * leave more than one waiting at the same time — the same reason the SDK keys
23668
+ * its gates and the clients queue theirs.
23669
+ *
23670
+ * `timeoutMs` is recomputed as the time actually LEFT, because the SDK's timer
23671
+ * has been running since the prompt was raised. Sent verbatim, a client
23672
+ * reconnecting four minutes in would show a fresh five-minute countdown and
23673
+ * then watch the section fail with four of those minutes still on the clock.
23674
+ * A remaining duration is used rather than an absolute deadline so the two
23675
+ * sides never have to agree on a wall clock.
23676
+ */
23677
+ replayEscalations(sessionId, socketId) {
23678
+ for (const entry of this.pendingEscalations.values()) {
23679
+ if (entry.sessionId !== sessionId) continue;
23680
+ if (entry.graceTimer) {
23681
+ clearTimeout(entry.graceTimer);
23682
+ entry.graceTimer = void 0;
23683
+ }
23684
+ const originalTimeout = typeof entry.payload.timeoutMs === "number" ? entry.payload.timeoutMs : 0;
23685
+ const remaining = Math.max(0, originalTimeout - (Date.now() - entry.raisedAt));
23686
+ this.socket.emitToSocket(socketId, "escalation:decision-required", { ...entry.payload, timeoutMs: remaining });
23687
+ }
23688
+ }
23689
+ /** Forget ONE parked prompt — the one that was answered or timed out. */
23690
+ clearPendingEscalation(requestId) {
23691
+ const entry = this.pendingEscalations.get(requestId);
23692
+ if (!entry) return;
23693
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
23694
+ this.pendingEscalations.delete(requestId);
23695
+ }
23696
+ /**
23697
+ * Forget the session's OLDEST prompt — for a client that answered without a
23698
+ * requestId, which is exactly the gate `resolveEscalation` will settle.
23699
+ * Insertion order makes `values()` oldest-first.
23700
+ */
23701
+ clearOldestSessionEscalation(sessionId) {
23702
+ for (const [id, entry] of this.pendingEscalations) {
23703
+ if (entry.sessionId !== sessionId) continue;
23704
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
23705
+ this.pendingEscalations.delete(id);
23706
+ return;
23707
+ }
23708
+ }
23709
+ /** Forget every prompt a session was parked on (run ended, or halted). */
23710
+ clearSessionEscalations(sessionId) {
23711
+ for (const [id, entry] of this.pendingEscalations) {
23712
+ if (entry.sessionId !== sessionId) continue;
23713
+ if (entry.graceTimer) clearTimeout(entry.graceTimer);
23714
+ this.pendingEscalations.delete(id);
23715
+ }
23716
+ }
23004
23717
  /** Deny + clear any approvals still pending for a session (run end / abort). */
23005
23718
  denyPendingApprovals(sessionId) {
23006
23719
  for (const [id, pending] of this.pendingApprovals) {
@@ -23501,6 +24214,7 @@ ${prompt}`;
23501
24214
  void (async () => {
23502
24215
  const cascade = new Cascade(this.config, this.workspacePath, this.store);
23503
24216
  this.activeSessions.set(sessionId, cascade);
24217
+ if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
23504
24218
  cascade.on("stream:token", (e) => {
23505
24219
  this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
23506
24220
  });
@@ -23516,6 +24230,14 @@ ${prompt}`;
23516
24230
  cascade.on("plan:approval-required", (e) => {
23517
24231
  this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
23518
24232
  });
24233
+ cascade.on("escalation:decision-required", (e) => {
24234
+ this.raiseEscalation(sessionId, void 0, e);
24235
+ });
24236
+ cascade.on("escalation:timeout", (e) => {
24237
+ const ev = e;
24238
+ if (ev.requestId) this.clearPendingEscalation(ev.requestId);
24239
+ this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
24240
+ });
23519
24241
  try {
23520
24242
  const result = await cascade.run({
23521
24243
  prompt: runPrompt,
@@ -23543,6 +24265,7 @@ ${prompt}`;
23543
24265
  } finally {
23544
24266
  this.activeSessions.delete(sessionId);
23545
24267
  this.denyPendingApprovals(sessionId);
24268
+ this.clearSessionEscalations(sessionId);
23546
24269
  }
23547
24270
  })();
23548
24271
  });