cascade-ai 0.54.0 → 0.56.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 +793 -64
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +793 -64
- package/dist/cli.js.map +1 -1
- package/dist/desktop-core.cjs +833 -55
- package/dist/index.cjs +827 -49
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +435 -4
- package/dist/index.d.ts +435 -4
- package/dist/index.js +818 -50
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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.
|
|
1802
|
+
CASCADE_VERSION = "0.56.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
|
-
|
|
10100
|
+
let summary = await this.aggregateResults(assignment, t3Results);
|
|
9896
10101
|
const issues = t3Results.filter((r) => r.status !== "COMPLETED").flatMap((r) => r.issues);
|
|
9897
|
-
|
|
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;
|
|
@@ -9964,7 +10218,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
|
9964
10218
|
const jsonMatch = /\[[\s\S]*\]/.exec(result.content);
|
|
9965
10219
|
if (!jsonMatch) throw new Error("No JSON array found");
|
|
9966
10220
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
9967
|
-
return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle }));
|
|
10221
|
+
return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle, constraints: a.constraints ?? [] }));
|
|
9968
10222
|
} catch {
|
|
9969
10223
|
return [{
|
|
9970
10224
|
subtaskId: crypto.randomUUID(),
|
|
@@ -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(
|
|
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) =>
|
|
10726
|
-
|
|
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 {
|
|
@@ -10914,6 +11179,12 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
|
|
|
10914
11179
|
if (!plan.sections || !Array.isArray(plan.sections) || plan.sections.length === 0) {
|
|
10915
11180
|
throw new Error("Invalid plan: no sections");
|
|
10916
11181
|
}
|
|
11182
|
+
for (const section of plan.sections) {
|
|
11183
|
+
section.constraints ??= [];
|
|
11184
|
+
for (const subtask of section.t3Subtasks ?? []) {
|
|
11185
|
+
subtask.constraints ??= [];
|
|
11186
|
+
}
|
|
11187
|
+
}
|
|
10917
11188
|
const [min, max] = COMPLEXITY_T2_COUNT[plan.complexity] ?? [1, 8];
|
|
10918
11189
|
if (plan.sections.length < min) ;
|
|
10919
11190
|
}
|
|
@@ -10930,6 +11201,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
|
|
|
10930
11201
|
const managers = sections.map((section) => {
|
|
10931
11202
|
const manager = new T2Manager(this.router, this.toolRegistry, this.id);
|
|
10932
11203
|
if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
|
|
11204
|
+
if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
|
|
10933
11205
|
manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
|
|
10934
11206
|
if (this.store) {
|
|
10935
11207
|
manager.setStore(this.store);
|
|
@@ -11139,7 +11411,10 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
|
|
|
11139
11411
|
async compileFinalOutput(originalPrompt, plan, t2Results) {
|
|
11140
11412
|
const completedSections = t2Results.filter((r) => r.status !== "FAILED");
|
|
11141
11413
|
if (!completedSections.length) {
|
|
11142
|
-
const allIssues =
|
|
11414
|
+
const allIssues = [
|
|
11415
|
+
...t2Results.flatMap((r) => r.issues ?? []),
|
|
11416
|
+
...t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues))
|
|
11417
|
+
].filter(Boolean);
|
|
11143
11418
|
const critical = allIssues.find((i) => i.includes("[CRITICAL_TOOL_ERROR]"));
|
|
11144
11419
|
const stalled = allIssues.find((i) => /^Stalled:/.test(i));
|
|
11145
11420
|
const topReason = critical ?? stalled ?? allIssues[0] ?? "no specific reason recorded";
|
|
@@ -13378,12 +13653,12 @@ var McpToolWrapper = class extends BaseTool {
|
|
|
13378
13653
|
mcpClient;
|
|
13379
13654
|
serverName;
|
|
13380
13655
|
toolName;
|
|
13381
|
-
constructor(mcpClient, serverName, toolName, description, inputSchema) {
|
|
13656
|
+
constructor(mcpClient, serverName, toolName, description, inputSchema, registeredName) {
|
|
13382
13657
|
super();
|
|
13383
13658
|
this.mcpClient = mcpClient;
|
|
13384
13659
|
this.serverName = serverName;
|
|
13385
13660
|
this.toolName = toolName;
|
|
13386
|
-
this.name = mcpToolName(serverName, toolName);
|
|
13661
|
+
this.name = registeredName ?? mcpToolName(serverName, toolName);
|
|
13387
13662
|
this.description = `[MCP:${serverName}] ${description}`;
|
|
13388
13663
|
this.inputSchema = inputSchema;
|
|
13389
13664
|
}
|
|
@@ -13454,22 +13729,35 @@ var ToolRegistry = class extends EventEmitter__default.default {
|
|
|
13454
13729
|
getRegisteredPlugins() {
|
|
13455
13730
|
return Array.from(this.plugins.keys());
|
|
13456
13731
|
}
|
|
13457
|
-
/**
|
|
13458
|
-
|
|
13459
|
-
|
|
13460
|
-
|
|
13461
|
-
|
|
13462
|
-
|
|
13732
|
+
/**
|
|
13733
|
+
* Registers all tools from an MCP client.
|
|
13734
|
+
*
|
|
13735
|
+
* `isAllowed` receives the FINAL registered name (`mcp__server__tool`) — the
|
|
13736
|
+
* same string the user sees and the same one that reaches the provider — so
|
|
13737
|
+
* a per-tool opt-out in Settings can be expressed as a plain name list.
|
|
13738
|
+
* Filtering here rather than at call time means a deselected tool is genuinely
|
|
13739
|
+
* absent from the definitions handed to the model, so it is never proposed,
|
|
13740
|
+
* never counted against the tool budget, and never has to be refused.
|
|
13741
|
+
*/
|
|
13742
|
+
registerMcpTools(mcpClient, isAllowed) {
|
|
13743
|
+
const parsed = mcpClient.getToolDefinitions().flatMap((def) => {
|
|
13744
|
+
const [, server, tool] = def.name.split("::");
|
|
13745
|
+
return server && tool ? [{ def, server, tool }] : [];
|
|
13746
|
+
});
|
|
13747
|
+
const names = assignMcpToolNames(parsed);
|
|
13748
|
+
parsed.forEach(({ def, server, tool }, i) => {
|
|
13463
13749
|
const wrapper = new McpToolWrapper(
|
|
13464
13750
|
mcpClient,
|
|
13465
|
-
|
|
13466
|
-
|
|
13467
|
-
def.description.replace(`[MCP:${
|
|
13468
|
-
def.inputSchema
|
|
13751
|
+
server,
|
|
13752
|
+
tool,
|
|
13753
|
+
def.description.replace(`[MCP:${server}] `, ""),
|
|
13754
|
+
def.inputSchema,
|
|
13755
|
+
names[i]
|
|
13469
13756
|
);
|
|
13757
|
+
if (isAllowed && !isAllowed(wrapper.name)) return;
|
|
13470
13758
|
wrapper.setWorkspaceRoot(this.workspaceRoot);
|
|
13471
13759
|
this.register(wrapper);
|
|
13472
|
-
}
|
|
13760
|
+
});
|
|
13473
13761
|
}
|
|
13474
13762
|
setIgnoredPaths(patterns) {
|
|
13475
13763
|
this.ignoreMatcher = ignore2().add(patterns);
|
|
@@ -13806,6 +14094,17 @@ function fileOAuthProvider(storePath) {
|
|
|
13806
14094
|
}
|
|
13807
14095
|
});
|
|
13808
14096
|
}
|
|
14097
|
+
var oauthStoreLocks = /* @__PURE__ */ new Map();
|
|
14098
|
+
function withOAuthStoreLock(storePath, fn) {
|
|
14099
|
+
const prev = oauthStoreLocks.get(storePath) ?? Promise.resolve();
|
|
14100
|
+
const run = prev.then(fn, fn);
|
|
14101
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
14102
|
+
oauthStoreLocks.set(storePath, tail);
|
|
14103
|
+
void tail.then(() => {
|
|
14104
|
+
if (oauthStoreLocks.get(storePath) === tail) oauthStoreLocks.delete(storePath);
|
|
14105
|
+
});
|
|
14106
|
+
return run;
|
|
14107
|
+
}
|
|
13809
14108
|
async function connectMcpWithLoopbackOAuth(opts) {
|
|
13810
14109
|
const listener = await startCallbackListener(opts);
|
|
13811
14110
|
const transport = new streamableHttp_js.StreamableHTTPClientTransport(new URL(opts.serverUrl), {
|
|
@@ -16286,7 +16585,50 @@ var GuidanceQueue = class {
|
|
|
16286
16585
|
}
|
|
16287
16586
|
};
|
|
16288
16587
|
|
|
16588
|
+
// src/tools/current-page.ts
|
|
16589
|
+
var MAX_PAGE_TEXT_CHARS = 4e4;
|
|
16590
|
+
var CurrentPageTool = class extends BaseTool {
|
|
16591
|
+
name = "read_current_page";
|
|
16592
|
+
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.';
|
|
16593
|
+
inputSchema = {
|
|
16594
|
+
type: "object",
|
|
16595
|
+
properties: {},
|
|
16596
|
+
required: []
|
|
16597
|
+
};
|
|
16598
|
+
provider;
|
|
16599
|
+
constructor(provider) {
|
|
16600
|
+
super();
|
|
16601
|
+
this.provider = provider;
|
|
16602
|
+
}
|
|
16603
|
+
// Reading what is already on the user's screen changes nothing and reaches
|
|
16604
|
+
// nothing they have not already opened themselves.
|
|
16605
|
+
isDangerous() {
|
|
16606
|
+
return false;
|
|
16607
|
+
}
|
|
16608
|
+
async execute(_input, _options) {
|
|
16609
|
+
let page2;
|
|
16610
|
+
try {
|
|
16611
|
+
page2 = await this.provider();
|
|
16612
|
+
} catch (err) {
|
|
16613
|
+
return `Error: could not read the open page \u2014 ${err instanceof Error ? err.message : String(err)}`;
|
|
16614
|
+
}
|
|
16615
|
+
if (!page2) {
|
|
16616
|
+
return "No page is open in the built-in browser. Ask the user to open one, or use web_fetch for a public URL.";
|
|
16617
|
+
}
|
|
16618
|
+
const text = page2.text.length > MAX_PAGE_TEXT_CHARS ? `${page2.text.slice(0, MAX_PAGE_TEXT_CHARS)}
|
|
16619
|
+
|
|
16620
|
+
[truncated \u2014 the page is longer than ${MAX_PAGE_TEXT_CHARS} characters]` : page2.text;
|
|
16621
|
+
return [
|
|
16622
|
+
`URL: ${page2.url}`,
|
|
16623
|
+
`Title: ${page2.title}`,
|
|
16624
|
+
"",
|
|
16625
|
+
text.trim() || "[the page rendered no readable text]"
|
|
16626
|
+
].join("\n");
|
|
16627
|
+
}
|
|
16628
|
+
};
|
|
16629
|
+
|
|
16289
16630
|
// src/core/cascade.ts
|
|
16631
|
+
var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
|
|
16290
16632
|
function buildContextualPrompt(prompt, history = []) {
|
|
16291
16633
|
const recent = history.slice(-6);
|
|
16292
16634
|
if (recent.length === 0) return prompt;
|
|
@@ -16509,6 +16851,94 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
|
|
|
16509
16851
|
});
|
|
16510
16852
|
});
|
|
16511
16853
|
}
|
|
16854
|
+
// ── Escalation decisions ────────────────────────────────────────────
|
|
16855
|
+
//
|
|
16856
|
+
// A section that ends ESCALATED used to be a dead end: the Cockpit showed
|
|
16857
|
+
// "Section escalated — needs a decision" and no decision was ever requested,
|
|
16858
|
+
// so the run stopped there having spent a full orchestration. This is the
|
|
16859
|
+
// same round-trip shape as plan approval, with one deliberate difference —
|
|
16860
|
+
// an unanswered escalation FAILS the section instead of proceeding.
|
|
16861
|
+
//
|
|
16862
|
+
// That direction is the safe one here. Plan approval defaults to "yes" on
|
|
16863
|
+
// timeout because the plan is already the model's considered proposal;
|
|
16864
|
+
// an escalation exists precisely BECAUSE the worker was not confident, so
|
|
16865
|
+
// acting unattended is the option most likely to be wrong. And in cloud a
|
|
16866
|
+
// waiting run holds server resources, so hanging is not free either.
|
|
16867
|
+
// Keyed by request id, NOT a single slot. A Complex run dispatches each wave
|
|
16868
|
+
// of ready sections with `Promise.all` (t1-administrator.ts), so two sections
|
|
16869
|
+
// can be waiting on a decision at the same moment. A single field made that
|
|
16870
|
+
// case fail hard rather than merely confusingly: the second escalation
|
|
16871
|
+
// overwrote the first's resolver, the user's answer resolved only the second,
|
|
16872
|
+
// and then the FIRST section's timer found the slot already empty and
|
|
16873
|
+
// returned without resolving anything — parking the run forever, past its own
|
|
16874
|
+
// timeout. Same class of bug as the twelve-failover dead-model race: a wave
|
|
16875
|
+
// is concurrent, so per-run singletons are never safe inside it.
|
|
16876
|
+
pendingEscalations = /* @__PURE__ */ new Map();
|
|
16877
|
+
async requestEscalationDecision(ctx, taskId, signal) {
|
|
16878
|
+
if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
|
|
16879
|
+
if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
|
|
16880
|
+
if (signal?.aborted) return { action: "skip", automatic: true };
|
|
16881
|
+
const requestId = crypto.randomUUID();
|
|
16882
|
+
return await new Promise((resolve2) => {
|
|
16883
|
+
let timeout;
|
|
16884
|
+
const settle = (decision) => {
|
|
16885
|
+
if (!this.pendingEscalations.delete(requestId)) return;
|
|
16886
|
+
if (timeout) clearTimeout(timeout);
|
|
16887
|
+
signal?.removeEventListener("abort", onAbort);
|
|
16888
|
+
resolve2(decision);
|
|
16889
|
+
};
|
|
16890
|
+
const onAbort = () => settle({ action: "skip", automatic: true });
|
|
16891
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
16892
|
+
timeout = setTimeout(() => {
|
|
16893
|
+
this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
|
|
16894
|
+
settle({ action: "timeout" });
|
|
16895
|
+
}, ESCALATION_DECISION_TIMEOUT_MS);
|
|
16896
|
+
this.pendingEscalations.set(requestId, settle);
|
|
16897
|
+
this.emit("escalation:decision-required", {
|
|
16898
|
+
taskId,
|
|
16899
|
+
requestId,
|
|
16900
|
+
sectionId: ctx.sectionId,
|
|
16901
|
+
sectionTitle: ctx.sectionTitle,
|
|
16902
|
+
issues: ctx.issues,
|
|
16903
|
+
summary: ctx.summary,
|
|
16904
|
+
timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
|
|
16905
|
+
});
|
|
16906
|
+
});
|
|
16907
|
+
}
|
|
16908
|
+
/**
|
|
16909
|
+
* Resolve a pending escalation from a REPL / dashboard / cloud listener.
|
|
16910
|
+
*
|
|
16911
|
+
* `requestId` comes back from the `escalation:decision-required` event and is
|
|
16912
|
+
* what makes an answer land on the section that asked. It is optional so a
|
|
16913
|
+
* host with only one escalation in flight (and any caller that predates the
|
|
16914
|
+
* id) still works: without it the oldest outstanding request is answered,
|
|
16915
|
+
* which is correct whenever there is exactly one.
|
|
16916
|
+
*
|
|
16917
|
+
* `automatic` defaults to false — a call into this method is normally a real
|
|
16918
|
+
* person answering the prompt. Pass `true` from a host that is itself
|
|
16919
|
+
* settling the gate on nobody's behalf (a REST caller who never had a
|
|
16920
|
+
* chance to connect, a session halt with no per-section review behind it),
|
|
16921
|
+
* so T2 doesn't set `userSkipped` for a decision no one actually reviewed —
|
|
16922
|
+
* see EscalationDecision.automatic.
|
|
16923
|
+
*/
|
|
16924
|
+
resolveEscalation(action, note, requestId, automatic) {
|
|
16925
|
+
const decision = { action, ...note ? { note } : {}, ...automatic ? { automatic: true } : {} };
|
|
16926
|
+
if (requestId) {
|
|
16927
|
+
this.pendingEscalations.get(requestId)?.(decision);
|
|
16928
|
+
return;
|
|
16929
|
+
}
|
|
16930
|
+
const oldest = this.pendingEscalations.keys().next();
|
|
16931
|
+
if (!oldest.done) this.pendingEscalations.get(oldest.value)?.(decision);
|
|
16932
|
+
}
|
|
16933
|
+
/**
|
|
16934
|
+
* Release every parked escalation — run teardown, so none outlive the run.
|
|
16935
|
+
* `automatic: true`: teardown resolving a section nobody answered is the
|
|
16936
|
+
* same system-decided case as the early-return skips above, not a person
|
|
16937
|
+
* reviewing and accepting that section's output.
|
|
16938
|
+
*/
|
|
16939
|
+
releasePendingEscalations(action = "skip") {
|
|
16940
|
+
for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
|
|
16941
|
+
}
|
|
16512
16942
|
/**
|
|
16513
16943
|
* Resolve a pending boardroom plan approval from a REPL / dashboard listener.
|
|
16514
16944
|
* An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
|
|
@@ -16642,6 +17072,20 @@ ${input}` }],
|
|
|
16642
17072
|
this.mediaSink = sink;
|
|
16643
17073
|
this.registerMediaTools(this.workspacePath);
|
|
16644
17074
|
}
|
|
17075
|
+
/**
|
|
17076
|
+
* Let the agent read the page the user has open in the host's browser.
|
|
17077
|
+
*
|
|
17078
|
+
* Host-supplied because only a host WITH a browser can answer it — so the
|
|
17079
|
+
* tool is simply absent in the CLI and in hosted runs, rather than present
|
|
17080
|
+
* and always failing. Registered outside `enabledTools` for the same reason
|
|
17081
|
+
* media tools are: that allowlist is a blast-radius control for tools that
|
|
17082
|
+
* touch the machine, and reading a page the user already opened touches
|
|
17083
|
+
* nothing. `disabledTools` still removes it.
|
|
17084
|
+
*/
|
|
17085
|
+
setCurrentPageProvider(provider) {
|
|
17086
|
+
if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
|
|
17087
|
+
this.toolRegistry.register(new CurrentPageTool(provider));
|
|
17088
|
+
}
|
|
16645
17089
|
registerMediaTools(workspacePath) {
|
|
16646
17090
|
const denied = new Set(this.config.tools?.disabledTools ?? []);
|
|
16647
17091
|
const permitted = (name) => !denied.has(name);
|
|
@@ -16774,8 +17218,12 @@ ${last.partialOutput}` : "");
|
|
|
16774
17218
|
for (const server of this.config.tools.mcpServers) {
|
|
16775
17219
|
try {
|
|
16776
17220
|
const authProvider = server.oauthStore ? fileOAuthProvider(server.oauthStore) : void 0;
|
|
16777
|
-
|
|
16778
|
-
|
|
17221
|
+
const connect = () => this.mcpClient.connect(server, authProvider ? { authProvider } : {});
|
|
17222
|
+
await (server.oauthStore ? withOAuthStoreLock(server.oauthStore, connect) : connect());
|
|
17223
|
+
this.toolRegistry.registerMcpTools(
|
|
17224
|
+
this.mcpClient,
|
|
17225
|
+
(name) => !(this.config.tools?.disabledTools ?? []).includes(name)
|
|
17226
|
+
);
|
|
16779
17227
|
} catch (err) {
|
|
16780
17228
|
console.error(`Failed to connect to MCP server "${server.name}":`, err);
|
|
16781
17229
|
}
|
|
@@ -17240,6 +17688,7 @@ ${prompt}` : prompt;
|
|
|
17240
17688
|
} else if (complexity === "Moderate") {
|
|
17241
17689
|
const t2 = new T2Manager(this.router, this.toolRegistry, "root");
|
|
17242
17690
|
t2.setRunBreaker(runBreaker);
|
|
17691
|
+
t2.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
|
|
17243
17692
|
t2.setPresenter(true);
|
|
17244
17693
|
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
17694
|
if (identityPrompt) {
|
|
@@ -17284,11 +17733,17 @@ ${prompt}` : prompt;
|
|
|
17284
17733
|
t2Results = [t2Result];
|
|
17285
17734
|
const completed = t2Result.t3Results.filter((r) => r.status === "COMPLETED");
|
|
17286
17735
|
if (completed.length > 0) {
|
|
17287
|
-
finalOutput =
|
|
17736
|
+
finalOutput = composeRootSectionOutput(
|
|
17737
|
+
t2Result,
|
|
17738
|
+
completed.map((r) => typeof r.output === "string" ? r.output : JSON.stringify(r.output))
|
|
17739
|
+
);
|
|
17288
17740
|
} else {
|
|
17289
17741
|
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
17742
|
const reasons = Array.from(
|
|
17291
|
-
new Set(
|
|
17743
|
+
new Set([
|
|
17744
|
+
...t2Result.issues ?? [],
|
|
17745
|
+
...t2Result.t3Results.flatMap((r) => r.issues ?? [])
|
|
17746
|
+
].filter(Boolean))
|
|
17292
17747
|
);
|
|
17293
17748
|
if (partial) {
|
|
17294
17749
|
finalOutput = reasons.length ? `${partial}
|
|
@@ -17301,6 +17756,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
|
|
|
17301
17756
|
} else {
|
|
17302
17757
|
const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
|
|
17303
17758
|
t1.setRunBreaker(runBreaker);
|
|
17759
|
+
t1.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
|
|
17304
17760
|
t1.setPresenter(true);
|
|
17305
17761
|
t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
|
|
17306
17762
|
if (identityPrompt) {
|
|
@@ -17325,6 +17781,8 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
|
|
|
17325
17781
|
finalOutput = result.output;
|
|
17326
17782
|
t2Results = result.t2Results;
|
|
17327
17783
|
}
|
|
17784
|
+
const budgetInfo = this.router.budgetExceededInfo();
|
|
17785
|
+
if (budgetInfo) throw new CascadeRouter.BudgetExceededError(budgetInfo.reason);
|
|
17328
17786
|
const trip = runBreaker.reason();
|
|
17329
17787
|
if (trip) {
|
|
17330
17788
|
finalOutput = [
|
|
@@ -17365,6 +17823,10 @@ ${finalOutput}` : ""
|
|
|
17365
17823
|
escalator.cancelAllPending();
|
|
17366
17824
|
} catch {
|
|
17367
17825
|
}
|
|
17826
|
+
try {
|
|
17827
|
+
this.releasePendingEscalations();
|
|
17828
|
+
} catch {
|
|
17829
|
+
}
|
|
17368
17830
|
this.router.restoreTierModels();
|
|
17369
17831
|
this.router.setRunSignal(void 0);
|
|
17370
17832
|
if (this.taskAnalyzer) {
|
|
@@ -21791,11 +22253,55 @@ function mergeByKey(local, incoming, key) {
|
|
|
21791
22253
|
for (const i of incoming) map.set(key(i), i);
|
|
21792
22254
|
return [...map.values()];
|
|
21793
22255
|
}
|
|
22256
|
+
function uniqueServerName(desired, takenPrefixes) {
|
|
22257
|
+
if (!takenPrefixes.has(mcpServerPrefix(desired))) return desired;
|
|
22258
|
+
let n = 2;
|
|
22259
|
+
while (takenPrefixes.has(mcpServerPrefix(`${desired} ${n}`))) n++;
|
|
22260
|
+
return `${desired} ${n}`;
|
|
22261
|
+
}
|
|
22262
|
+
function mergeMcpServers(local, incoming) {
|
|
22263
|
+
const rewrite = /* @__PURE__ */ new Map();
|
|
22264
|
+
if (!incoming?.length) return { servers: local, synced: [], rewrite };
|
|
22265
|
+
const byName = /* @__PURE__ */ new Map();
|
|
22266
|
+
for (const l of local ?? []) byName.set(l.name, l);
|
|
22267
|
+
const prefixes = new Set([...byName.values()].map((m) => mcpServerPrefix(m.name)));
|
|
22268
|
+
const synced = [];
|
|
22269
|
+
for (const inc of incoming) {
|
|
22270
|
+
if (byName.has(inc.name)) {
|
|
22271
|
+
byName.set(inc.name, inc);
|
|
22272
|
+
synced.push(inc);
|
|
22273
|
+
continue;
|
|
22274
|
+
}
|
|
22275
|
+
const name = uniqueServerName(inc.name, prefixes);
|
|
22276
|
+
if (name !== inc.name) rewrite.set(mcpServerPrefix(inc.name), mcpServerPrefix(name));
|
|
22277
|
+
const server = name === inc.name ? inc : { ...inc, name };
|
|
22278
|
+
byName.set(name, server);
|
|
22279
|
+
synced.push(server);
|
|
22280
|
+
prefixes.add(mcpServerPrefix(name));
|
|
22281
|
+
}
|
|
22282
|
+
return { servers: [...byName.values()], synced, rewrite };
|
|
22283
|
+
}
|
|
22284
|
+
function rewriteDisabledTools(tools, rewrite) {
|
|
22285
|
+
if (!tools?.length || rewrite.size === 0) return tools;
|
|
22286
|
+
return tools.map((t) => {
|
|
22287
|
+
for (const [from, to] of rewrite) if (t.startsWith(from)) return to + t.slice(from.length);
|
|
22288
|
+
return t;
|
|
22289
|
+
});
|
|
22290
|
+
}
|
|
22291
|
+
function mergeDisabledTools(local, incoming, syncedServers) {
|
|
22292
|
+
const prefixes = (syncedServers ?? []).map((m) => mcpServerPrefix(m.name));
|
|
22293
|
+
const covered = (tool) => prefixes.some((p) => tool.startsWith(p));
|
|
22294
|
+
const kept = (local ?? []).filter((t) => !covered(t));
|
|
22295
|
+
const merged = [.../* @__PURE__ */ new Set([...kept, ...incoming ?? []])];
|
|
22296
|
+
return merged.length ? merged : void 0;
|
|
22297
|
+
}
|
|
21794
22298
|
function gatherSyncBundle(config) {
|
|
21795
|
-
const bundle = { v:
|
|
22299
|
+
const bundle = { v: 2 };
|
|
21796
22300
|
if (config.providers?.length) bundle.providers = config.providers;
|
|
21797
22301
|
if (config.tools?.webSearch) bundle.webSearch = config.tools.webSearch;
|
|
21798
22302
|
if (config.tools?.mcpServers?.length) bundle.mcpServers = config.tools.mcpServers;
|
|
22303
|
+
const syncedDisabledTools = config.tools?.disabledTools?.filter(isMcpToolName);
|
|
22304
|
+
if (syncedDisabledTools?.length) bundle.disabledTools = syncedDisabledTools;
|
|
21799
22305
|
if (config.models) bundle.models = config.models;
|
|
21800
22306
|
if (config.budget) bundle.budget = config.budget;
|
|
21801
22307
|
const prefs = {};
|
|
@@ -21810,7 +22316,16 @@ function applySyncBundle(bundle, config) {
|
|
|
21810
22316
|
const next = { ...config, tools: { ...config.tools } };
|
|
21811
22317
|
if (bundle.providers) next.providers = mergeByKey(config.providers, bundle.providers, providerSig) ?? [];
|
|
21812
22318
|
if (bundle.webSearch) next.tools.webSearch = { ...config.tools?.webSearch, ...bundle.webSearch };
|
|
21813
|
-
|
|
22319
|
+
const mcp2 = mergeMcpServers(config.tools?.mcpServers, bundle.mcpServers);
|
|
22320
|
+
if (bundle.mcpServers) next.tools.mcpServers = mcp2.servers;
|
|
22321
|
+
const carriesSelections = bundle.v >= 2 || bundle.disabledTools !== void 0;
|
|
22322
|
+
if (carriesSelections && (bundle.mcpServers || bundle.disabledTools)) {
|
|
22323
|
+
next.tools.disabledTools = mergeDisabledTools(
|
|
22324
|
+
config.tools?.disabledTools,
|
|
22325
|
+
rewriteDisabledTools(bundle.disabledTools, mcp2.rewrite),
|
|
22326
|
+
bundle.mcpServers ? mcp2.synced : void 0
|
|
22327
|
+
);
|
|
22328
|
+
}
|
|
21814
22329
|
if (bundle.models) next.models = { ...config.models, ...bundle.models };
|
|
21815
22330
|
if (bundle.budget) next.budget = { ...config.budget, ...bundle.budget };
|
|
21816
22331
|
if (bundle.prefs) {
|
|
@@ -22151,12 +22666,19 @@ function defaultName(url) {
|
|
|
22151
22666
|
}
|
|
22152
22667
|
}
|
|
22153
22668
|
async function mcpConnectCommand(url, opts = {}) {
|
|
22154
|
-
const
|
|
22155
|
-
const store = new FileMcpOAuthStore(storePathFor(name));
|
|
22669
|
+
const desiredName = opts.name?.trim() || defaultName(url);
|
|
22156
22670
|
console.log(chalk7__default.default.magenta("\n \u25C8 Connect an MCP server via OAuth"));
|
|
22157
22671
|
console.log(chalk7__default.default.dim(` ${url}
|
|
22158
22672
|
`));
|
|
22159
22673
|
try {
|
|
22674
|
+
const cm = new ConfigManager(process.cwd());
|
|
22675
|
+
await cm.load();
|
|
22676
|
+
const config = cm.getConfig();
|
|
22677
|
+
config.tools = config.tools ?? {};
|
|
22678
|
+
const servers = config.tools.mcpServers ?? [];
|
|
22679
|
+
const existingIdx = servers.findIndex((s) => s.name === desiredName);
|
|
22680
|
+
const name = existingIdx >= 0 ? desiredName : uniqueMcpServerName(desiredName, servers.map((s) => s.name));
|
|
22681
|
+
const store = new FileMcpOAuthStore(storePathFor(name));
|
|
22160
22682
|
await connectMcpWithLoopbackOAuth({
|
|
22161
22683
|
serverUrl: url,
|
|
22162
22684
|
store,
|
|
@@ -22166,14 +22688,8 @@ async function mcpConnectCommand(url, opts = {}) {
|
|
|
22166
22688
|
openBrowser2(u);
|
|
22167
22689
|
}
|
|
22168
22690
|
});
|
|
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
22691
|
const entry = { name, url, oauthStore: storePathFor(name) };
|
|
22175
|
-
|
|
22176
|
-
if (idx >= 0) servers[idx] = entry;
|
|
22692
|
+
if (existingIdx >= 0) servers[existingIdx] = entry;
|
|
22177
22693
|
else servers.push(entry);
|
|
22178
22694
|
config.tools.mcpServers = servers;
|
|
22179
22695
|
config.tools.mcpTrusted = Array.from(/* @__PURE__ */ new Set([...config.tools.mcpTrusted ?? [], name]));
|
|
@@ -22218,6 +22734,7 @@ async function mcpRemoveCommand(name) {
|
|
|
22218
22734
|
}
|
|
22219
22735
|
config.tools.mcpServers = servers.filter((s) => s.name !== name);
|
|
22220
22736
|
config.tools.mcpTrusted = (config.tools.mcpTrusted ?? []).filter((n) => n !== name);
|
|
22737
|
+
config.tools.disabledTools = removeMcpServerDenials(config.tools.disabledTools, name);
|
|
22221
22738
|
if (match.oauthStore) {
|
|
22222
22739
|
try {
|
|
22223
22740
|
new FileMcpOAuthStore(match.oauthStore).clear();
|
|
@@ -22324,6 +22841,14 @@ var DashboardSocket = class {
|
|
|
22324
22841
|
this.secret = options.secret;
|
|
22325
22842
|
this.setupHandlers();
|
|
22326
22843
|
}
|
|
22844
|
+
/** How many sockets are currently in a room — 0 means nobody can answer. */
|
|
22845
|
+
roomSize(room) {
|
|
22846
|
+
return this.io.sockets.adapter.rooms.get(room)?.size ?? 0;
|
|
22847
|
+
}
|
|
22848
|
+
/** Is this exact connection still live? A reconnect issues a NEW socket id. */
|
|
22849
|
+
hasSocket(socketId) {
|
|
22850
|
+
return this.io.sockets.sockets.has(socketId);
|
|
22851
|
+
}
|
|
22327
22852
|
broadcastToRoom(room, event, data) {
|
|
22328
22853
|
this.io.to(room).emit(event, data);
|
|
22329
22854
|
}
|
|
@@ -22395,6 +22920,22 @@ var DashboardSocket = class {
|
|
|
22395
22920
|
});
|
|
22396
22921
|
});
|
|
22397
22922
|
}
|
|
22923
|
+
/**
|
|
22924
|
+
* A client subscribed to a session's room.
|
|
22925
|
+
*
|
|
22926
|
+
* The host uses this to replay a gate the run is already parked on. Socket.IO
|
|
22927
|
+
* does not buffer room emissions, so a prompt broadcast while the renderer was
|
|
22928
|
+
* reconnecting is simply gone; without a replay the section waits out its full
|
|
22929
|
+
* timeout with the user sitting in front of a screen that never asked.
|
|
22930
|
+
*/
|
|
22931
|
+
onJoinSession(callback) {
|
|
22932
|
+
this.io.on("connection", (socket) => {
|
|
22933
|
+
socket.on("join:session", (payload) => {
|
|
22934
|
+
const { sessionId } = normalizeSessionSubscriptionPayload(payload);
|
|
22935
|
+
if (sessionId) callback(sessionId, socket.id);
|
|
22936
|
+
});
|
|
22937
|
+
});
|
|
22938
|
+
}
|
|
22398
22939
|
onSessionRate(callback) {
|
|
22399
22940
|
this.io.on("connection", (socket) => {
|
|
22400
22941
|
socket.on("session:rate", (payload) => {
|
|
@@ -22432,6 +22973,31 @@ var DashboardSocket = class {
|
|
|
22432
22973
|
});
|
|
22433
22974
|
});
|
|
22434
22975
|
}
|
|
22976
|
+
/**
|
|
22977
|
+
* A section escalated and the run is parked waiting for an answer. The
|
|
22978
|
+
* desktop shows the escalation modal on `escalation:decision-required` and
|
|
22979
|
+
* answers here.
|
|
22980
|
+
*
|
|
22981
|
+
* Unlike a plan decision, silence is NOT taken as consent — the SDK fails
|
|
22982
|
+
* the section on timeout — so this handler only ever forwards a real choice.
|
|
22983
|
+
*/
|
|
22984
|
+
onEscalationDecision(callback) {
|
|
22985
|
+
this.io.on("connection", (socket) => {
|
|
22986
|
+
socket.on("escalation:decide", (payload) => {
|
|
22987
|
+
const action = payload?.action;
|
|
22988
|
+
if (typeof payload?.sessionId !== "string") return;
|
|
22989
|
+
if (action !== "retry" && action !== "skip" && action !== "guidance") return;
|
|
22990
|
+
callback({
|
|
22991
|
+
sessionId: payload.sessionId,
|
|
22992
|
+
// Identifies WHICH parked section this answers — sections in a wave
|
|
22993
|
+
// run concurrently, so more than one can be waiting.
|
|
22994
|
+
requestId: typeof payload.requestId === "string" ? payload.requestId : void 0,
|
|
22995
|
+
action,
|
|
22996
|
+
note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0
|
|
22997
|
+
});
|
|
22998
|
+
});
|
|
22999
|
+
});
|
|
23000
|
+
}
|
|
22435
23001
|
onSessionSteer(callback) {
|
|
22436
23002
|
this.io.on("connection", (socket) => {
|
|
22437
23003
|
socket.on("session:steer", (payload) => {
|
|
@@ -22593,6 +23159,7 @@ function aggregateCostStats(sessions2, opts = {}) {
|
|
|
22593
23159
|
|
|
22594
23160
|
// src/dashboard/server.ts
|
|
22595
23161
|
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))));
|
|
23162
|
+
var ORPHANED_ESCALATION_GRACE_MS = 3e4;
|
|
22596
23163
|
var DashboardServer = class {
|
|
22597
23164
|
app;
|
|
22598
23165
|
httpServer;
|
|
@@ -22603,6 +23170,13 @@ var DashboardServer = class {
|
|
|
22603
23170
|
globalStore = null;
|
|
22604
23171
|
broadcastTimer = null;
|
|
22605
23172
|
activeSessions = /* @__PURE__ */ new Map();
|
|
23173
|
+
/**
|
|
23174
|
+
* Supplied by the desktop shell so a run can read the page the user has
|
|
23175
|
+
* open in the built-in browser. Absent in the CLI, where there is no
|
|
23176
|
+
* browser — so the tool is simply not registered rather than present and
|
|
23177
|
+
* always failing.
|
|
23178
|
+
*/
|
|
23179
|
+
currentPageProvider;
|
|
22606
23180
|
activeControllers = /* @__PURE__ */ new Map();
|
|
22607
23181
|
/**
|
|
22608
23182
|
* Run taskIds per chat session — file snapshots are keyed by the run's
|
|
@@ -22617,6 +23191,17 @@ var DashboardServer = class {
|
|
|
22617
23191
|
* map is how that answer reaches the run that's blocked on it.
|
|
22618
23192
|
*/
|
|
22619
23193
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
23194
|
+
/**
|
|
23195
|
+
* The escalation prompt a session is currently parked on, kept so it can be
|
|
23196
|
+
* delivered to a client that was not reachable when it was raised.
|
|
23197
|
+
*
|
|
23198
|
+
* Socket.IO buffers nothing: a prompt emitted while the renderer is mid
|
|
23199
|
+
* reconnect is gone, and the section then waits out its full five minutes
|
|
23200
|
+
* with the user in front of a screen that never asked them anything. Holding
|
|
23201
|
+
* the payload lets `join:session` replay it, and lets a run with no reachable
|
|
23202
|
+
* client be settled instead of parked (see ORPHANED_ESCALATION_GRACE_MS).
|
|
23203
|
+
*/
|
|
23204
|
+
pendingEscalations = /* @__PURE__ */ new Map();
|
|
22620
23205
|
/**
|
|
22621
23206
|
* The orchestration decision trail ("why") of each session's most recent
|
|
22622
23207
|
* run — captured when the run ends so the desktop's Why panel can show it
|
|
@@ -22694,20 +23279,29 @@ var DashboardServer = class {
|
|
|
22694
23279
|
if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
|
|
22695
23280
|
const cascade = new Cascade(cfg, this.workspacePath, this.store);
|
|
22696
23281
|
this.activeSessions.set(sessionId, cascade);
|
|
23282
|
+
if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
|
|
22697
23283
|
cascade.on("stream:token", (e) => {
|
|
22698
|
-
this.
|
|
23284
|
+
this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
|
|
22699
23285
|
});
|
|
22700
23286
|
cascade.on("tier:status", (e) => {
|
|
22701
|
-
this.
|
|
23287
|
+
this.emitToSessionClients(socketId, sessionId, "tier:status", { sessionId, ...e });
|
|
22702
23288
|
});
|
|
22703
23289
|
cascade.on("permission:user-required", (e) => {
|
|
22704
|
-
this.
|
|
23290
|
+
this.emitToSessionClients(socketId, sessionId, "permission:user-required", { sessionId, ...e });
|
|
22705
23291
|
});
|
|
22706
23292
|
cascade.on("peer:message", (e) => {
|
|
22707
23293
|
this.socket.emitPeerMessage(e);
|
|
22708
23294
|
});
|
|
22709
23295
|
cascade.on("plan:approval-required", (e) => {
|
|
22710
|
-
this.
|
|
23296
|
+
this.emitToSessionClients(socketId, sessionId, "plan:approval-required", { sessionId, ...e });
|
|
23297
|
+
});
|
|
23298
|
+
cascade.on("escalation:decision-required", (e) => {
|
|
23299
|
+
this.raiseEscalation(sessionId, socketId, e);
|
|
23300
|
+
});
|
|
23301
|
+
cascade.on("escalation:timeout", (e) => {
|
|
23302
|
+
const ev = e;
|
|
23303
|
+
if (ev.requestId) this.clearPendingEscalation(ev.requestId);
|
|
23304
|
+
this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
|
|
22711
23305
|
});
|
|
22712
23306
|
try {
|
|
22713
23307
|
const result = await cascade.run({
|
|
@@ -22718,7 +23312,7 @@ var DashboardServer = class {
|
|
|
22718
23312
|
this.recordSessionTask(sessionId, result.taskId);
|
|
22719
23313
|
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
|
|
22720
23314
|
this.captureWhy(sessionId, cascade, result);
|
|
22721
|
-
this.
|
|
23315
|
+
this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
|
|
22722
23316
|
this.socket.broadcast("cost:update", {
|
|
22723
23317
|
sessionId,
|
|
22724
23318
|
totalTokens: result.usage.totalTokens,
|
|
@@ -22731,7 +23325,7 @@ var DashboardServer = class {
|
|
|
22731
23325
|
} catch (err) {
|
|
22732
23326
|
this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
|
|
22733
23327
|
this.captureWhy(sessionId, cascade);
|
|
22734
|
-
this.
|
|
23328
|
+
this.emitToSessionClients(socketId, sessionId, "session:error", {
|
|
22735
23329
|
sessionId,
|
|
22736
23330
|
error: err instanceof Error ? err.message : String(err)
|
|
22737
23331
|
});
|
|
@@ -22739,6 +23333,7 @@ var DashboardServer = class {
|
|
|
22739
23333
|
this.activeSessions.delete(sessionId);
|
|
22740
23334
|
this.activeControllers.delete(sessionId);
|
|
22741
23335
|
this.denyPendingApprovals(sessionId);
|
|
23336
|
+
this.clearSessionEscalations(sessionId);
|
|
22742
23337
|
}
|
|
22743
23338
|
});
|
|
22744
23339
|
this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
|
|
@@ -22748,10 +23343,20 @@ var DashboardServer = class {
|
|
|
22748
23343
|
editedPlan
|
|
22749
23344
|
);
|
|
22750
23345
|
});
|
|
23346
|
+
this.socket.onEscalationDecision(({ sessionId, requestId, action, note }) => {
|
|
23347
|
+
if (requestId) this.clearPendingEscalation(requestId);
|
|
23348
|
+
else this.clearOldestSessionEscalation(sessionId);
|
|
23349
|
+
this.activeSessions.get(sessionId)?.resolveEscalation(action, note, requestId);
|
|
23350
|
+
});
|
|
23351
|
+
this.socket.onJoinSession((sessionId, socketId) => {
|
|
23352
|
+
this.replayEscalations(sessionId, socketId);
|
|
23353
|
+
});
|
|
22751
23354
|
this.socket.onSessionHalt((sessionId) => {
|
|
22752
23355
|
this.activeControllers.get(sessionId)?.abort();
|
|
22753
23356
|
this.denyPendingApprovals(sessionId);
|
|
22754
23357
|
this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
|
|
23358
|
+
this.clearSessionEscalations(sessionId);
|
|
23359
|
+
this.activeSessions.get(sessionId)?.resolveEscalation("skip", void 0, void 0, true);
|
|
22755
23360
|
});
|
|
22756
23361
|
this.socket.onApprovalResponse(({ requestId, approved, always }) => {
|
|
22757
23362
|
const pending = this.pendingApprovals.get(requestId);
|
|
@@ -22766,6 +23371,10 @@ var DashboardServer = class {
|
|
|
22766
23371
|
}
|
|
22767
23372
|
});
|
|
22768
23373
|
}
|
|
23374
|
+
/** Wire the host's browser view in (desktop only). */
|
|
23375
|
+
setCurrentPageProvider(provider) {
|
|
23376
|
+
this.currentPageProvider = provider;
|
|
23377
|
+
}
|
|
22769
23378
|
async start() {
|
|
22770
23379
|
const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
|
|
22771
23380
|
if (!isLoopback) {
|
|
@@ -23054,6 +23663,116 @@ var DashboardServer = class {
|
|
|
23054
23663
|
this.activeSessions.delete(sessionId);
|
|
23055
23664
|
}
|
|
23056
23665
|
}
|
|
23666
|
+
/**
|
|
23667
|
+
* Is anyone able to answer for this session right now?
|
|
23668
|
+
*
|
|
23669
|
+
* The originating socket first — that connection is definitely watching this
|
|
23670
|
+
* run — then the session room, which is where a client lands after a
|
|
23671
|
+
* reconnect issues it a new socket id.
|
|
23672
|
+
*/
|
|
23673
|
+
hasResponder(sessionId, socketId) {
|
|
23674
|
+
if (socketId && this.socket.hasSocket(socketId)) return true;
|
|
23675
|
+
return this.socket.roomSize(`session:${sessionId}`) > 0;
|
|
23676
|
+
}
|
|
23677
|
+
/**
|
|
23678
|
+
* Send a run event to whoever is watching this session.
|
|
23679
|
+
*
|
|
23680
|
+
* A captured socket id goes stale the moment Socket.IO reconnects — the
|
|
23681
|
+
* client is back, but under a new id, and `emitToSocket` on the old one is a
|
|
23682
|
+
* silent no-op. Falling back to the session room keeps the run addressable
|
|
23683
|
+
* across a reconnect; emitting to one OR the other (never both) keeps a
|
|
23684
|
+
* client that is both the originator and a room member from seeing every
|
|
23685
|
+
* token twice.
|
|
23686
|
+
*/
|
|
23687
|
+
emitToSessionClients(socketId, sessionId, event, data) {
|
|
23688
|
+
if (socketId && this.socket.hasSocket(socketId)) this.socket.emitToSocket(socketId, event, data);
|
|
23689
|
+
else this.socket.broadcastToRoom(`session:${sessionId}`, event, data);
|
|
23690
|
+
}
|
|
23691
|
+
/**
|
|
23692
|
+
* Raise an escalation prompt, and make sure it can still be answered.
|
|
23693
|
+
*
|
|
23694
|
+
* Held in `pendingEscalations` — keyed by requestId, because a Complex wave
|
|
23695
|
+
* dispatches sections concurrently and two can be parked at once — so a
|
|
23696
|
+
* client that joins late (or rejoins after a reconnect) gets every waiting
|
|
23697
|
+
* prompt replayed. Keying by session would drop the first of a pair: its
|
|
23698
|
+
* grace timer would be cleared by the second, leaving it parked for the full
|
|
23699
|
+
* timeout with nobody able to answer it. If nobody is reachable at all, the gate is
|
|
23700
|
+
* settled as `skip` after a short grace period rather than parked for the
|
|
23701
|
+
* full timeout: that matches the SDK's policy when no listener exists, and
|
|
23702
|
+
* five minutes of a held run is a real cost for a REST caller who has already
|
|
23703
|
+
* hung up. The grace period exists so an ordinary two-second reconnect is not
|
|
23704
|
+
* mistaken for an absent user.
|
|
23705
|
+
*/
|
|
23706
|
+
raiseEscalation(sessionId, socketId, event) {
|
|
23707
|
+
const requestId = typeof event.requestId === "string" && event.requestId ? event.requestId : crypto.randomUUID();
|
|
23708
|
+
const payload = { sessionId, ...event };
|
|
23709
|
+
const entry = { payload, sessionId, requestId, raisedAt: Date.now() };
|
|
23710
|
+
this.pendingEscalations.set(requestId, entry);
|
|
23711
|
+
if (this.hasResponder(sessionId, socketId)) {
|
|
23712
|
+
this.emitToSessionClients(socketId, sessionId, "escalation:decision-required", payload);
|
|
23713
|
+
return;
|
|
23714
|
+
}
|
|
23715
|
+
entry.graceTimer = setTimeout(() => {
|
|
23716
|
+
if (this.pendingEscalations.get(requestId) !== entry) return;
|
|
23717
|
+
if (this.hasResponder(sessionId, socketId)) return;
|
|
23718
|
+
this.pendingEscalations.delete(requestId);
|
|
23719
|
+
this.activeSessions.get(sessionId)?.resolveEscalation("skip", "No client was connected to answer.", requestId, true);
|
|
23720
|
+
}, ORPHANED_ESCALATION_GRACE_MS);
|
|
23721
|
+
}
|
|
23722
|
+
/**
|
|
23723
|
+
* Replay EVERY parked prompt to a client that just subscribed to the session.
|
|
23724
|
+
*
|
|
23725
|
+
* Plural because a Complex run dispatches sections concurrently, so a wave can
|
|
23726
|
+
* leave more than one waiting at the same time — the same reason the SDK keys
|
|
23727
|
+
* its gates and the clients queue theirs.
|
|
23728
|
+
*
|
|
23729
|
+
* `timeoutMs` is recomputed as the time actually LEFT, because the SDK's timer
|
|
23730
|
+
* has been running since the prompt was raised. Sent verbatim, a client
|
|
23731
|
+
* reconnecting four minutes in would show a fresh five-minute countdown and
|
|
23732
|
+
* then watch the section fail with four of those minutes still on the clock.
|
|
23733
|
+
* A remaining duration is used rather than an absolute deadline so the two
|
|
23734
|
+
* sides never have to agree on a wall clock.
|
|
23735
|
+
*/
|
|
23736
|
+
replayEscalations(sessionId, socketId) {
|
|
23737
|
+
for (const entry of this.pendingEscalations.values()) {
|
|
23738
|
+
if (entry.sessionId !== sessionId) continue;
|
|
23739
|
+
if (entry.graceTimer) {
|
|
23740
|
+
clearTimeout(entry.graceTimer);
|
|
23741
|
+
entry.graceTimer = void 0;
|
|
23742
|
+
}
|
|
23743
|
+
const originalTimeout = typeof entry.payload.timeoutMs === "number" ? entry.payload.timeoutMs : 0;
|
|
23744
|
+
const remaining = Math.max(0, originalTimeout - (Date.now() - entry.raisedAt));
|
|
23745
|
+
this.socket.emitToSocket(socketId, "escalation:decision-required", { ...entry.payload, timeoutMs: remaining });
|
|
23746
|
+
}
|
|
23747
|
+
}
|
|
23748
|
+
/** Forget ONE parked prompt — the one that was answered or timed out. */
|
|
23749
|
+
clearPendingEscalation(requestId) {
|
|
23750
|
+
const entry = this.pendingEscalations.get(requestId);
|
|
23751
|
+
if (!entry) return;
|
|
23752
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
23753
|
+
this.pendingEscalations.delete(requestId);
|
|
23754
|
+
}
|
|
23755
|
+
/**
|
|
23756
|
+
* Forget the session's OLDEST prompt — for a client that answered without a
|
|
23757
|
+
* requestId, which is exactly the gate `resolveEscalation` will settle.
|
|
23758
|
+
* Insertion order makes `values()` oldest-first.
|
|
23759
|
+
*/
|
|
23760
|
+
clearOldestSessionEscalation(sessionId) {
|
|
23761
|
+
for (const [id, entry] of this.pendingEscalations) {
|
|
23762
|
+
if (entry.sessionId !== sessionId) continue;
|
|
23763
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
23764
|
+
this.pendingEscalations.delete(id);
|
|
23765
|
+
return;
|
|
23766
|
+
}
|
|
23767
|
+
}
|
|
23768
|
+
/** Forget every prompt a session was parked on (run ended, or halted). */
|
|
23769
|
+
clearSessionEscalations(sessionId) {
|
|
23770
|
+
for (const [id, entry] of this.pendingEscalations) {
|
|
23771
|
+
if (entry.sessionId !== sessionId) continue;
|
|
23772
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
23773
|
+
this.pendingEscalations.delete(id);
|
|
23774
|
+
}
|
|
23775
|
+
}
|
|
23057
23776
|
/** Deny + clear any approvals still pending for a session (run end / abort). */
|
|
23058
23777
|
denyPendingApprovals(sessionId) {
|
|
23059
23778
|
for (const [id, pending] of this.pendingApprovals) {
|
|
@@ -23554,6 +24273,7 @@ ${prompt}`;
|
|
|
23554
24273
|
void (async () => {
|
|
23555
24274
|
const cascade = new Cascade(this.config, this.workspacePath, this.store);
|
|
23556
24275
|
this.activeSessions.set(sessionId, cascade);
|
|
24276
|
+
if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
|
|
23557
24277
|
cascade.on("stream:token", (e) => {
|
|
23558
24278
|
this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
|
|
23559
24279
|
});
|
|
@@ -23569,6 +24289,14 @@ ${prompt}`;
|
|
|
23569
24289
|
cascade.on("plan:approval-required", (e) => {
|
|
23570
24290
|
this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
|
|
23571
24291
|
});
|
|
24292
|
+
cascade.on("escalation:decision-required", (e) => {
|
|
24293
|
+
this.raiseEscalation(sessionId, void 0, e);
|
|
24294
|
+
});
|
|
24295
|
+
cascade.on("escalation:timeout", (e) => {
|
|
24296
|
+
const ev = e;
|
|
24297
|
+
if (ev.requestId) this.clearPendingEscalation(ev.requestId);
|
|
24298
|
+
this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
|
|
24299
|
+
});
|
|
23572
24300
|
try {
|
|
23573
24301
|
const result = await cascade.run({
|
|
23574
24302
|
prompt: runPrompt,
|
|
@@ -23596,6 +24324,7 @@ ${prompt}`;
|
|
|
23596
24324
|
} finally {
|
|
23597
24325
|
this.activeSessions.delete(sessionId);
|
|
23598
24326
|
this.denyPendingApprovals(sessionId);
|
|
24327
|
+
this.clearSessionEscalations(sessionId);
|
|
23599
24328
|
}
|
|
23600
24329
|
})();
|
|
23601
24330
|
});
|