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.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.
|
|
1749
|
+
CASCADE_VERSION = "0.56.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
|
-
|
|
10047
|
+
let summary = await this.aggregateResults(assignment, t3Results);
|
|
9843
10048
|
const issues = t3Results.filter((r) => r.status !== "COMPLETED").flatMap((r) => r.issues);
|
|
9844
|
-
|
|
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;
|
|
@@ -9911,7 +10165,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
|
|
|
9911
10165
|
const jsonMatch = /\[[\s\S]*\]/.exec(result.content);
|
|
9912
10166
|
if (!jsonMatch) throw new Error("No JSON array found");
|
|
9913
10167
|
const parsed = JSON.parse(jsonMatch[0]);
|
|
9914
|
-
return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle }));
|
|
10168
|
+
return parsed.map((a) => ({ ...a, sectionTitle: assignment.sectionTitle, constraints: a.constraints ?? [] }));
|
|
9915
10169
|
} catch {
|
|
9916
10170
|
return [{
|
|
9917
10171
|
subtaskId: randomUUID(),
|
|
@@ -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(
|
|
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) =>
|
|
10673
|
-
|
|
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 {
|
|
@@ -10861,6 +11126,12 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
|
|
|
10861
11126
|
if (!plan.sections || !Array.isArray(plan.sections) || plan.sections.length === 0) {
|
|
10862
11127
|
throw new Error("Invalid plan: no sections");
|
|
10863
11128
|
}
|
|
11129
|
+
for (const section of plan.sections) {
|
|
11130
|
+
section.constraints ??= [];
|
|
11131
|
+
for (const subtask of section.t3Subtasks ?? []) {
|
|
11132
|
+
subtask.constraints ??= [];
|
|
11133
|
+
}
|
|
11134
|
+
}
|
|
10864
11135
|
const [min, max] = COMPLEXITY_T2_COUNT[plan.complexity] ?? [1, 8];
|
|
10865
11136
|
if (plan.sections.length < min) ;
|
|
10866
11137
|
}
|
|
@@ -10877,6 +11148,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
|
|
|
10877
11148
|
const managers = sections.map((section) => {
|
|
10878
11149
|
const manager = new T2Manager(this.router, this.toolRegistry, this.id);
|
|
10879
11150
|
if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
|
|
11151
|
+
if (this.escalationCallback) manager.setEscalationCallback(this.escalationCallback);
|
|
10880
11152
|
manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
|
|
10881
11153
|
if (this.store) {
|
|
10882
11154
|
manager.setStore(this.store);
|
|
@@ -11086,7 +11358,10 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
|
|
|
11086
11358
|
async compileFinalOutput(originalPrompt, plan, t2Results) {
|
|
11087
11359
|
const completedSections = t2Results.filter((r) => r.status !== "FAILED");
|
|
11088
11360
|
if (!completedSections.length) {
|
|
11089
|
-
const allIssues =
|
|
11361
|
+
const allIssues = [
|
|
11362
|
+
...t2Results.flatMap((r) => r.issues ?? []),
|
|
11363
|
+
...t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues))
|
|
11364
|
+
].filter(Boolean);
|
|
11090
11365
|
const critical = allIssues.find((i) => i.includes("[CRITICAL_TOOL_ERROR]"));
|
|
11091
11366
|
const stalled = allIssues.find((i) => /^Stalled:/.test(i));
|
|
11092
11367
|
const topReason = critical ?? stalled ?? allIssues[0] ?? "no specific reason recorded";
|
|
@@ -13325,12 +13600,12 @@ var McpToolWrapper = class extends BaseTool {
|
|
|
13325
13600
|
mcpClient;
|
|
13326
13601
|
serverName;
|
|
13327
13602
|
toolName;
|
|
13328
|
-
constructor(mcpClient, serverName, toolName, description, inputSchema) {
|
|
13603
|
+
constructor(mcpClient, serverName, toolName, description, inputSchema, registeredName) {
|
|
13329
13604
|
super();
|
|
13330
13605
|
this.mcpClient = mcpClient;
|
|
13331
13606
|
this.serverName = serverName;
|
|
13332
13607
|
this.toolName = toolName;
|
|
13333
|
-
this.name = mcpToolName(serverName, toolName);
|
|
13608
|
+
this.name = registeredName ?? mcpToolName(serverName, toolName);
|
|
13334
13609
|
this.description = `[MCP:${serverName}] ${description}`;
|
|
13335
13610
|
this.inputSchema = inputSchema;
|
|
13336
13611
|
}
|
|
@@ -13401,22 +13676,35 @@ var ToolRegistry = class extends EventEmitter {
|
|
|
13401
13676
|
getRegisteredPlugins() {
|
|
13402
13677
|
return Array.from(this.plugins.keys());
|
|
13403
13678
|
}
|
|
13404
|
-
/**
|
|
13405
|
-
|
|
13406
|
-
|
|
13407
|
-
|
|
13408
|
-
|
|
13409
|
-
|
|
13679
|
+
/**
|
|
13680
|
+
* Registers all tools from an MCP client.
|
|
13681
|
+
*
|
|
13682
|
+
* `isAllowed` receives the FINAL registered name (`mcp__server__tool`) — the
|
|
13683
|
+
* same string the user sees and the same one that reaches the provider — so
|
|
13684
|
+
* a per-tool opt-out in Settings can be expressed as a plain name list.
|
|
13685
|
+
* Filtering here rather than at call time means a deselected tool is genuinely
|
|
13686
|
+
* absent from the definitions handed to the model, so it is never proposed,
|
|
13687
|
+
* never counted against the tool budget, and never has to be refused.
|
|
13688
|
+
*/
|
|
13689
|
+
registerMcpTools(mcpClient, isAllowed) {
|
|
13690
|
+
const parsed = mcpClient.getToolDefinitions().flatMap((def) => {
|
|
13691
|
+
const [, server, tool] = def.name.split("::");
|
|
13692
|
+
return server && tool ? [{ def, server, tool }] : [];
|
|
13693
|
+
});
|
|
13694
|
+
const names = assignMcpToolNames(parsed);
|
|
13695
|
+
parsed.forEach(({ def, server, tool }, i) => {
|
|
13410
13696
|
const wrapper = new McpToolWrapper(
|
|
13411
13697
|
mcpClient,
|
|
13412
|
-
|
|
13413
|
-
|
|
13414
|
-
def.description.replace(`[MCP:${
|
|
13415
|
-
def.inputSchema
|
|
13698
|
+
server,
|
|
13699
|
+
tool,
|
|
13700
|
+
def.description.replace(`[MCP:${server}] `, ""),
|
|
13701
|
+
def.inputSchema,
|
|
13702
|
+
names[i]
|
|
13416
13703
|
);
|
|
13704
|
+
if (isAllowed && !isAllowed(wrapper.name)) return;
|
|
13417
13705
|
wrapper.setWorkspaceRoot(this.workspaceRoot);
|
|
13418
13706
|
this.register(wrapper);
|
|
13419
|
-
}
|
|
13707
|
+
});
|
|
13420
13708
|
}
|
|
13421
13709
|
setIgnoredPaths(patterns) {
|
|
13422
13710
|
this.ignoreMatcher = ignore2().add(patterns);
|
|
@@ -13753,6 +14041,17 @@ function fileOAuthProvider(storePath) {
|
|
|
13753
14041
|
}
|
|
13754
14042
|
});
|
|
13755
14043
|
}
|
|
14044
|
+
var oauthStoreLocks = /* @__PURE__ */ new Map();
|
|
14045
|
+
function withOAuthStoreLock(storePath, fn) {
|
|
14046
|
+
const prev = oauthStoreLocks.get(storePath) ?? Promise.resolve();
|
|
14047
|
+
const run = prev.then(fn, fn);
|
|
14048
|
+
const tail = run.then(() => void 0, () => void 0);
|
|
14049
|
+
oauthStoreLocks.set(storePath, tail);
|
|
14050
|
+
void tail.then(() => {
|
|
14051
|
+
if (oauthStoreLocks.get(storePath) === tail) oauthStoreLocks.delete(storePath);
|
|
14052
|
+
});
|
|
14053
|
+
return run;
|
|
14054
|
+
}
|
|
13756
14055
|
async function connectMcpWithLoopbackOAuth(opts) {
|
|
13757
14056
|
const listener = await startCallbackListener(opts);
|
|
13758
14057
|
const transport = new StreamableHTTPClientTransport(new URL(opts.serverUrl), {
|
|
@@ -16233,7 +16532,50 @@ var GuidanceQueue = class {
|
|
|
16233
16532
|
}
|
|
16234
16533
|
};
|
|
16235
16534
|
|
|
16535
|
+
// src/tools/current-page.ts
|
|
16536
|
+
var MAX_PAGE_TEXT_CHARS = 4e4;
|
|
16537
|
+
var CurrentPageTool = class extends BaseTool {
|
|
16538
|
+
name = "read_current_page";
|
|
16539
|
+
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.';
|
|
16540
|
+
inputSchema = {
|
|
16541
|
+
type: "object",
|
|
16542
|
+
properties: {},
|
|
16543
|
+
required: []
|
|
16544
|
+
};
|
|
16545
|
+
provider;
|
|
16546
|
+
constructor(provider) {
|
|
16547
|
+
super();
|
|
16548
|
+
this.provider = provider;
|
|
16549
|
+
}
|
|
16550
|
+
// Reading what is already on the user's screen changes nothing and reaches
|
|
16551
|
+
// nothing they have not already opened themselves.
|
|
16552
|
+
isDangerous() {
|
|
16553
|
+
return false;
|
|
16554
|
+
}
|
|
16555
|
+
async execute(_input, _options) {
|
|
16556
|
+
let page2;
|
|
16557
|
+
try {
|
|
16558
|
+
page2 = await this.provider();
|
|
16559
|
+
} catch (err) {
|
|
16560
|
+
return `Error: could not read the open page \u2014 ${err instanceof Error ? err.message : String(err)}`;
|
|
16561
|
+
}
|
|
16562
|
+
if (!page2) {
|
|
16563
|
+
return "No page is open in the built-in browser. Ask the user to open one, or use web_fetch for a public URL.";
|
|
16564
|
+
}
|
|
16565
|
+
const text = page2.text.length > MAX_PAGE_TEXT_CHARS ? `${page2.text.slice(0, MAX_PAGE_TEXT_CHARS)}
|
|
16566
|
+
|
|
16567
|
+
[truncated \u2014 the page is longer than ${MAX_PAGE_TEXT_CHARS} characters]` : page2.text;
|
|
16568
|
+
return [
|
|
16569
|
+
`URL: ${page2.url}`,
|
|
16570
|
+
`Title: ${page2.title}`,
|
|
16571
|
+
"",
|
|
16572
|
+
text.trim() || "[the page rendered no readable text]"
|
|
16573
|
+
].join("\n");
|
|
16574
|
+
}
|
|
16575
|
+
};
|
|
16576
|
+
|
|
16236
16577
|
// src/core/cascade.ts
|
|
16578
|
+
var ESCALATION_DECISION_TIMEOUT_MS = 5 * 6e4;
|
|
16237
16579
|
function buildContextualPrompt(prompt, history = []) {
|
|
16238
16580
|
const recent = history.slice(-6);
|
|
16239
16581
|
if (recent.length === 0) return prompt;
|
|
@@ -16456,6 +16798,94 @@ var Cascade = class _Cascade extends EventEmitter {
|
|
|
16456
16798
|
});
|
|
16457
16799
|
});
|
|
16458
16800
|
}
|
|
16801
|
+
// ── Escalation decisions ────────────────────────────────────────────
|
|
16802
|
+
//
|
|
16803
|
+
// A section that ends ESCALATED used to be a dead end: the Cockpit showed
|
|
16804
|
+
// "Section escalated — needs a decision" and no decision was ever requested,
|
|
16805
|
+
// so the run stopped there having spent a full orchestration. This is the
|
|
16806
|
+
// same round-trip shape as plan approval, with one deliberate difference —
|
|
16807
|
+
// an unanswered escalation FAILS the section instead of proceeding.
|
|
16808
|
+
//
|
|
16809
|
+
// That direction is the safe one here. Plan approval defaults to "yes" on
|
|
16810
|
+
// timeout because the plan is already the model's considered proposal;
|
|
16811
|
+
// an escalation exists precisely BECAUSE the worker was not confident, so
|
|
16812
|
+
// acting unattended is the option most likely to be wrong. And in cloud a
|
|
16813
|
+
// waiting run holds server resources, so hanging is not free either.
|
|
16814
|
+
// Keyed by request id, NOT a single slot. A Complex run dispatches each wave
|
|
16815
|
+
// of ready sections with `Promise.all` (t1-administrator.ts), so two sections
|
|
16816
|
+
// can be waiting on a decision at the same moment. A single field made that
|
|
16817
|
+
// case fail hard rather than merely confusingly: the second escalation
|
|
16818
|
+
// overwrote the first's resolver, the user's answer resolved only the second,
|
|
16819
|
+
// and then the FIRST section's timer found the slot already empty and
|
|
16820
|
+
// returned without resolving anything — parking the run forever, past its own
|
|
16821
|
+
// timeout. Same class of bug as the twelve-failover dead-model race: a wave
|
|
16822
|
+
// is concurrent, so per-run singletons are never safe inside it.
|
|
16823
|
+
pendingEscalations = /* @__PURE__ */ new Map();
|
|
16824
|
+
async requestEscalationDecision(ctx, taskId, signal) {
|
|
16825
|
+
if (this.config.autonomy === "auto") return { action: "skip", automatic: true };
|
|
16826
|
+
if (this.listenerCount("escalation:decision-required") === 0) return { action: "skip", automatic: true };
|
|
16827
|
+
if (signal?.aborted) return { action: "skip", automatic: true };
|
|
16828
|
+
const requestId = randomUUID();
|
|
16829
|
+
return await new Promise((resolve2) => {
|
|
16830
|
+
let timeout;
|
|
16831
|
+
const settle = (decision) => {
|
|
16832
|
+
if (!this.pendingEscalations.delete(requestId)) return;
|
|
16833
|
+
if (timeout) clearTimeout(timeout);
|
|
16834
|
+
signal?.removeEventListener("abort", onAbort);
|
|
16835
|
+
resolve2(decision);
|
|
16836
|
+
};
|
|
16837
|
+
const onAbort = () => settle({ action: "skip", automatic: true });
|
|
16838
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
16839
|
+
timeout = setTimeout(() => {
|
|
16840
|
+
this.emit("escalation:timeout", { taskId, requestId, sectionId: ctx.sectionId });
|
|
16841
|
+
settle({ action: "timeout" });
|
|
16842
|
+
}, ESCALATION_DECISION_TIMEOUT_MS);
|
|
16843
|
+
this.pendingEscalations.set(requestId, settle);
|
|
16844
|
+
this.emit("escalation:decision-required", {
|
|
16845
|
+
taskId,
|
|
16846
|
+
requestId,
|
|
16847
|
+
sectionId: ctx.sectionId,
|
|
16848
|
+
sectionTitle: ctx.sectionTitle,
|
|
16849
|
+
issues: ctx.issues,
|
|
16850
|
+
summary: ctx.summary,
|
|
16851
|
+
timeoutMs: ESCALATION_DECISION_TIMEOUT_MS
|
|
16852
|
+
});
|
|
16853
|
+
});
|
|
16854
|
+
}
|
|
16855
|
+
/**
|
|
16856
|
+
* Resolve a pending escalation from a REPL / dashboard / cloud listener.
|
|
16857
|
+
*
|
|
16858
|
+
* `requestId` comes back from the `escalation:decision-required` event and is
|
|
16859
|
+
* what makes an answer land on the section that asked. It is optional so a
|
|
16860
|
+
* host with only one escalation in flight (and any caller that predates the
|
|
16861
|
+
* id) still works: without it the oldest outstanding request is answered,
|
|
16862
|
+
* which is correct whenever there is exactly one.
|
|
16863
|
+
*
|
|
16864
|
+
* `automatic` defaults to false — a call into this method is normally a real
|
|
16865
|
+
* person answering the prompt. Pass `true` from a host that is itself
|
|
16866
|
+
* settling the gate on nobody's behalf (a REST caller who never had a
|
|
16867
|
+
* chance to connect, a session halt with no per-section review behind it),
|
|
16868
|
+
* so T2 doesn't set `userSkipped` for a decision no one actually reviewed —
|
|
16869
|
+
* see EscalationDecision.automatic.
|
|
16870
|
+
*/
|
|
16871
|
+
resolveEscalation(action, note, requestId, automatic) {
|
|
16872
|
+
const decision = { action, ...note ? { note } : {}, ...automatic ? { automatic: true } : {} };
|
|
16873
|
+
if (requestId) {
|
|
16874
|
+
this.pendingEscalations.get(requestId)?.(decision);
|
|
16875
|
+
return;
|
|
16876
|
+
}
|
|
16877
|
+
const oldest = this.pendingEscalations.keys().next();
|
|
16878
|
+
if (!oldest.done) this.pendingEscalations.get(oldest.value)?.(decision);
|
|
16879
|
+
}
|
|
16880
|
+
/**
|
|
16881
|
+
* Release every parked escalation — run teardown, so none outlive the run.
|
|
16882
|
+
* `automatic: true`: teardown resolving a section nobody answered is the
|
|
16883
|
+
* same system-decided case as the early-return skips above, not a person
|
|
16884
|
+
* reviewing and accepting that section's output.
|
|
16885
|
+
*/
|
|
16886
|
+
releasePendingEscalations(action = "skip") {
|
|
16887
|
+
for (const settle of Array.from(this.pendingEscalations.values())) settle({ action, automatic: true });
|
|
16888
|
+
}
|
|
16459
16889
|
/**
|
|
16460
16890
|
* Resolve a pending boardroom plan approval from a REPL / dashboard listener.
|
|
16461
16891
|
* An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
|
|
@@ -16589,6 +17019,20 @@ ${input}` }],
|
|
|
16589
17019
|
this.mediaSink = sink;
|
|
16590
17020
|
this.registerMediaTools(this.workspacePath);
|
|
16591
17021
|
}
|
|
17022
|
+
/**
|
|
17023
|
+
* Let the agent read the page the user has open in the host's browser.
|
|
17024
|
+
*
|
|
17025
|
+
* Host-supplied because only a host WITH a browser can answer it — so the
|
|
17026
|
+
* tool is simply absent in the CLI and in hosted runs, rather than present
|
|
17027
|
+
* and always failing. Registered outside `enabledTools` for the same reason
|
|
17028
|
+
* media tools are: that allowlist is a blast-radius control for tools that
|
|
17029
|
+
* touch the machine, and reading a page the user already opened touches
|
|
17030
|
+
* nothing. `disabledTools` still removes it.
|
|
17031
|
+
*/
|
|
17032
|
+
setCurrentPageProvider(provider) {
|
|
17033
|
+
if ((this.config.tools?.disabledTools ?? []).includes("read_current_page")) return;
|
|
17034
|
+
this.toolRegistry.register(new CurrentPageTool(provider));
|
|
17035
|
+
}
|
|
16592
17036
|
registerMediaTools(workspacePath) {
|
|
16593
17037
|
const denied = new Set(this.config.tools?.disabledTools ?? []);
|
|
16594
17038
|
const permitted = (name) => !denied.has(name);
|
|
@@ -16721,8 +17165,12 @@ ${last.partialOutput}` : "");
|
|
|
16721
17165
|
for (const server of this.config.tools.mcpServers) {
|
|
16722
17166
|
try {
|
|
16723
17167
|
const authProvider = server.oauthStore ? fileOAuthProvider(server.oauthStore) : void 0;
|
|
16724
|
-
|
|
16725
|
-
|
|
17168
|
+
const connect = () => this.mcpClient.connect(server, authProvider ? { authProvider } : {});
|
|
17169
|
+
await (server.oauthStore ? withOAuthStoreLock(server.oauthStore, connect) : connect());
|
|
17170
|
+
this.toolRegistry.registerMcpTools(
|
|
17171
|
+
this.mcpClient,
|
|
17172
|
+
(name) => !(this.config.tools?.disabledTools ?? []).includes(name)
|
|
17173
|
+
);
|
|
16726
17174
|
} catch (err) {
|
|
16727
17175
|
console.error(`Failed to connect to MCP server "${server.name}":`, err);
|
|
16728
17176
|
}
|
|
@@ -17187,6 +17635,7 @@ ${prompt}` : prompt;
|
|
|
17187
17635
|
} else if (complexity === "Moderate") {
|
|
17188
17636
|
const t2 = new T2Manager(this.router, this.toolRegistry, "root");
|
|
17189
17637
|
t2.setRunBreaker(runBreaker);
|
|
17638
|
+
t2.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
|
|
17190
17639
|
t2.setPresenter(true);
|
|
17191
17640
|
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
17641
|
if (identityPrompt) {
|
|
@@ -17231,11 +17680,17 @@ ${prompt}` : prompt;
|
|
|
17231
17680
|
t2Results = [t2Result];
|
|
17232
17681
|
const completed = t2Result.t3Results.filter((r) => r.status === "COMPLETED");
|
|
17233
17682
|
if (completed.length > 0) {
|
|
17234
|
-
finalOutput =
|
|
17683
|
+
finalOutput = composeRootSectionOutput(
|
|
17684
|
+
t2Result,
|
|
17685
|
+
completed.map((r) => typeof r.output === "string" ? r.output : JSON.stringify(r.output))
|
|
17686
|
+
);
|
|
17235
17687
|
} else {
|
|
17236
17688
|
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
17689
|
const reasons = Array.from(
|
|
17238
|
-
new Set(
|
|
17690
|
+
new Set([
|
|
17691
|
+
...t2Result.issues ?? [],
|
|
17692
|
+
...t2Result.t3Results.flatMap((r) => r.issues ?? [])
|
|
17693
|
+
].filter(Boolean))
|
|
17239
17694
|
);
|
|
17240
17695
|
if (partial) {
|
|
17241
17696
|
finalOutput = reasons.length ? `${partial}
|
|
@@ -17248,6 +17703,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
|
|
|
17248
17703
|
} else {
|
|
17249
17704
|
const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
|
|
17250
17705
|
t1.setRunBreaker(runBreaker);
|
|
17706
|
+
t1.setEscalationCallback((ctx) => this.requestEscalationDecision(ctx, taskId, options.signal));
|
|
17251
17707
|
t1.setPresenter(true);
|
|
17252
17708
|
t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
|
|
17253
17709
|
if (identityPrompt) {
|
|
@@ -17272,6 +17728,8 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
|
|
|
17272
17728
|
finalOutput = result.output;
|
|
17273
17729
|
t2Results = result.t2Results;
|
|
17274
17730
|
}
|
|
17731
|
+
const budgetInfo = this.router.budgetExceededInfo();
|
|
17732
|
+
if (budgetInfo) throw new CascadeRouter.BudgetExceededError(budgetInfo.reason);
|
|
17275
17733
|
const trip = runBreaker.reason();
|
|
17276
17734
|
if (trip) {
|
|
17277
17735
|
finalOutput = [
|
|
@@ -17312,6 +17770,10 @@ ${finalOutput}` : ""
|
|
|
17312
17770
|
escalator.cancelAllPending();
|
|
17313
17771
|
} catch {
|
|
17314
17772
|
}
|
|
17773
|
+
try {
|
|
17774
|
+
this.releasePendingEscalations();
|
|
17775
|
+
} catch {
|
|
17776
|
+
}
|
|
17315
17777
|
this.router.restoreTierModels();
|
|
17316
17778
|
this.router.setRunSignal(void 0);
|
|
17317
17779
|
if (this.taskAnalyzer) {
|
|
@@ -21738,11 +22200,55 @@ function mergeByKey(local, incoming, key) {
|
|
|
21738
22200
|
for (const i of incoming) map.set(key(i), i);
|
|
21739
22201
|
return [...map.values()];
|
|
21740
22202
|
}
|
|
22203
|
+
function uniqueServerName(desired, takenPrefixes) {
|
|
22204
|
+
if (!takenPrefixes.has(mcpServerPrefix(desired))) return desired;
|
|
22205
|
+
let n = 2;
|
|
22206
|
+
while (takenPrefixes.has(mcpServerPrefix(`${desired} ${n}`))) n++;
|
|
22207
|
+
return `${desired} ${n}`;
|
|
22208
|
+
}
|
|
22209
|
+
function mergeMcpServers(local, incoming) {
|
|
22210
|
+
const rewrite = /* @__PURE__ */ new Map();
|
|
22211
|
+
if (!incoming?.length) return { servers: local, synced: [], rewrite };
|
|
22212
|
+
const byName = /* @__PURE__ */ new Map();
|
|
22213
|
+
for (const l of local ?? []) byName.set(l.name, l);
|
|
22214
|
+
const prefixes = new Set([...byName.values()].map((m) => mcpServerPrefix(m.name)));
|
|
22215
|
+
const synced = [];
|
|
22216
|
+
for (const inc of incoming) {
|
|
22217
|
+
if (byName.has(inc.name)) {
|
|
22218
|
+
byName.set(inc.name, inc);
|
|
22219
|
+
synced.push(inc);
|
|
22220
|
+
continue;
|
|
22221
|
+
}
|
|
22222
|
+
const name = uniqueServerName(inc.name, prefixes);
|
|
22223
|
+
if (name !== inc.name) rewrite.set(mcpServerPrefix(inc.name), mcpServerPrefix(name));
|
|
22224
|
+
const server = name === inc.name ? inc : { ...inc, name };
|
|
22225
|
+
byName.set(name, server);
|
|
22226
|
+
synced.push(server);
|
|
22227
|
+
prefixes.add(mcpServerPrefix(name));
|
|
22228
|
+
}
|
|
22229
|
+
return { servers: [...byName.values()], synced, rewrite };
|
|
22230
|
+
}
|
|
22231
|
+
function rewriteDisabledTools(tools, rewrite) {
|
|
22232
|
+
if (!tools?.length || rewrite.size === 0) return tools;
|
|
22233
|
+
return tools.map((t) => {
|
|
22234
|
+
for (const [from, to] of rewrite) if (t.startsWith(from)) return to + t.slice(from.length);
|
|
22235
|
+
return t;
|
|
22236
|
+
});
|
|
22237
|
+
}
|
|
22238
|
+
function mergeDisabledTools(local, incoming, syncedServers) {
|
|
22239
|
+
const prefixes = (syncedServers ?? []).map((m) => mcpServerPrefix(m.name));
|
|
22240
|
+
const covered = (tool) => prefixes.some((p) => tool.startsWith(p));
|
|
22241
|
+
const kept = (local ?? []).filter((t) => !covered(t));
|
|
22242
|
+
const merged = [.../* @__PURE__ */ new Set([...kept, ...incoming ?? []])];
|
|
22243
|
+
return merged.length ? merged : void 0;
|
|
22244
|
+
}
|
|
21741
22245
|
function gatherSyncBundle(config) {
|
|
21742
|
-
const bundle = { v:
|
|
22246
|
+
const bundle = { v: 2 };
|
|
21743
22247
|
if (config.providers?.length) bundle.providers = config.providers;
|
|
21744
22248
|
if (config.tools?.webSearch) bundle.webSearch = config.tools.webSearch;
|
|
21745
22249
|
if (config.tools?.mcpServers?.length) bundle.mcpServers = config.tools.mcpServers;
|
|
22250
|
+
const syncedDisabledTools = config.tools?.disabledTools?.filter(isMcpToolName);
|
|
22251
|
+
if (syncedDisabledTools?.length) bundle.disabledTools = syncedDisabledTools;
|
|
21746
22252
|
if (config.models) bundle.models = config.models;
|
|
21747
22253
|
if (config.budget) bundle.budget = config.budget;
|
|
21748
22254
|
const prefs = {};
|
|
@@ -21757,7 +22263,16 @@ function applySyncBundle(bundle, config) {
|
|
|
21757
22263
|
const next = { ...config, tools: { ...config.tools } };
|
|
21758
22264
|
if (bundle.providers) next.providers = mergeByKey(config.providers, bundle.providers, providerSig) ?? [];
|
|
21759
22265
|
if (bundle.webSearch) next.tools.webSearch = { ...config.tools?.webSearch, ...bundle.webSearch };
|
|
21760
|
-
|
|
22266
|
+
const mcp2 = mergeMcpServers(config.tools?.mcpServers, bundle.mcpServers);
|
|
22267
|
+
if (bundle.mcpServers) next.tools.mcpServers = mcp2.servers;
|
|
22268
|
+
const carriesSelections = bundle.v >= 2 || bundle.disabledTools !== void 0;
|
|
22269
|
+
if (carriesSelections && (bundle.mcpServers || bundle.disabledTools)) {
|
|
22270
|
+
next.tools.disabledTools = mergeDisabledTools(
|
|
22271
|
+
config.tools?.disabledTools,
|
|
22272
|
+
rewriteDisabledTools(bundle.disabledTools, mcp2.rewrite),
|
|
22273
|
+
bundle.mcpServers ? mcp2.synced : void 0
|
|
22274
|
+
);
|
|
22275
|
+
}
|
|
21761
22276
|
if (bundle.models) next.models = { ...config.models, ...bundle.models };
|
|
21762
22277
|
if (bundle.budget) next.budget = { ...config.budget, ...bundle.budget };
|
|
21763
22278
|
if (bundle.prefs) {
|
|
@@ -22098,12 +22613,19 @@ function defaultName(url) {
|
|
|
22098
22613
|
}
|
|
22099
22614
|
}
|
|
22100
22615
|
async function mcpConnectCommand(url, opts = {}) {
|
|
22101
|
-
const
|
|
22102
|
-
const store = new FileMcpOAuthStore(storePathFor(name));
|
|
22616
|
+
const desiredName = opts.name?.trim() || defaultName(url);
|
|
22103
22617
|
console.log(chalk7.magenta("\n \u25C8 Connect an MCP server via OAuth"));
|
|
22104
22618
|
console.log(chalk7.dim(` ${url}
|
|
22105
22619
|
`));
|
|
22106
22620
|
try {
|
|
22621
|
+
const cm = new ConfigManager(process.cwd());
|
|
22622
|
+
await cm.load();
|
|
22623
|
+
const config = cm.getConfig();
|
|
22624
|
+
config.tools = config.tools ?? {};
|
|
22625
|
+
const servers = config.tools.mcpServers ?? [];
|
|
22626
|
+
const existingIdx = servers.findIndex((s) => s.name === desiredName);
|
|
22627
|
+
const name = existingIdx >= 0 ? desiredName : uniqueMcpServerName(desiredName, servers.map((s) => s.name));
|
|
22628
|
+
const store = new FileMcpOAuthStore(storePathFor(name));
|
|
22107
22629
|
await connectMcpWithLoopbackOAuth({
|
|
22108
22630
|
serverUrl: url,
|
|
22109
22631
|
store,
|
|
@@ -22113,14 +22635,8 @@ async function mcpConnectCommand(url, opts = {}) {
|
|
|
22113
22635
|
openBrowser2(u);
|
|
22114
22636
|
}
|
|
22115
22637
|
});
|
|
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
22638
|
const entry = { name, url, oauthStore: storePathFor(name) };
|
|
22122
|
-
|
|
22123
|
-
if (idx >= 0) servers[idx] = entry;
|
|
22639
|
+
if (existingIdx >= 0) servers[existingIdx] = entry;
|
|
22124
22640
|
else servers.push(entry);
|
|
22125
22641
|
config.tools.mcpServers = servers;
|
|
22126
22642
|
config.tools.mcpTrusted = Array.from(/* @__PURE__ */ new Set([...config.tools.mcpTrusted ?? [], name]));
|
|
@@ -22165,6 +22681,7 @@ async function mcpRemoveCommand(name) {
|
|
|
22165
22681
|
}
|
|
22166
22682
|
config.tools.mcpServers = servers.filter((s) => s.name !== name);
|
|
22167
22683
|
config.tools.mcpTrusted = (config.tools.mcpTrusted ?? []).filter((n) => n !== name);
|
|
22684
|
+
config.tools.disabledTools = removeMcpServerDenials(config.tools.disabledTools, name);
|
|
22168
22685
|
if (match.oauthStore) {
|
|
22169
22686
|
try {
|
|
22170
22687
|
new FileMcpOAuthStore(match.oauthStore).clear();
|
|
@@ -22271,6 +22788,14 @@ var DashboardSocket = class {
|
|
|
22271
22788
|
this.secret = options.secret;
|
|
22272
22789
|
this.setupHandlers();
|
|
22273
22790
|
}
|
|
22791
|
+
/** How many sockets are currently in a room — 0 means nobody can answer. */
|
|
22792
|
+
roomSize(room) {
|
|
22793
|
+
return this.io.sockets.adapter.rooms.get(room)?.size ?? 0;
|
|
22794
|
+
}
|
|
22795
|
+
/** Is this exact connection still live? A reconnect issues a NEW socket id. */
|
|
22796
|
+
hasSocket(socketId) {
|
|
22797
|
+
return this.io.sockets.sockets.has(socketId);
|
|
22798
|
+
}
|
|
22274
22799
|
broadcastToRoom(room, event, data) {
|
|
22275
22800
|
this.io.to(room).emit(event, data);
|
|
22276
22801
|
}
|
|
@@ -22342,6 +22867,22 @@ var DashboardSocket = class {
|
|
|
22342
22867
|
});
|
|
22343
22868
|
});
|
|
22344
22869
|
}
|
|
22870
|
+
/**
|
|
22871
|
+
* A client subscribed to a session's room.
|
|
22872
|
+
*
|
|
22873
|
+
* The host uses this to replay a gate the run is already parked on. Socket.IO
|
|
22874
|
+
* does not buffer room emissions, so a prompt broadcast while the renderer was
|
|
22875
|
+
* reconnecting is simply gone; without a replay the section waits out its full
|
|
22876
|
+
* timeout with the user sitting in front of a screen that never asked.
|
|
22877
|
+
*/
|
|
22878
|
+
onJoinSession(callback) {
|
|
22879
|
+
this.io.on("connection", (socket) => {
|
|
22880
|
+
socket.on("join:session", (payload) => {
|
|
22881
|
+
const { sessionId } = normalizeSessionSubscriptionPayload(payload);
|
|
22882
|
+
if (sessionId) callback(sessionId, socket.id);
|
|
22883
|
+
});
|
|
22884
|
+
});
|
|
22885
|
+
}
|
|
22345
22886
|
onSessionRate(callback) {
|
|
22346
22887
|
this.io.on("connection", (socket) => {
|
|
22347
22888
|
socket.on("session:rate", (payload) => {
|
|
@@ -22379,6 +22920,31 @@ var DashboardSocket = class {
|
|
|
22379
22920
|
});
|
|
22380
22921
|
});
|
|
22381
22922
|
}
|
|
22923
|
+
/**
|
|
22924
|
+
* A section escalated and the run is parked waiting for an answer. The
|
|
22925
|
+
* desktop shows the escalation modal on `escalation:decision-required` and
|
|
22926
|
+
* answers here.
|
|
22927
|
+
*
|
|
22928
|
+
* Unlike a plan decision, silence is NOT taken as consent — the SDK fails
|
|
22929
|
+
* the section on timeout — so this handler only ever forwards a real choice.
|
|
22930
|
+
*/
|
|
22931
|
+
onEscalationDecision(callback) {
|
|
22932
|
+
this.io.on("connection", (socket) => {
|
|
22933
|
+
socket.on("escalation:decide", (payload) => {
|
|
22934
|
+
const action = payload?.action;
|
|
22935
|
+
if (typeof payload?.sessionId !== "string") return;
|
|
22936
|
+
if (action !== "retry" && action !== "skip" && action !== "guidance") return;
|
|
22937
|
+
callback({
|
|
22938
|
+
sessionId: payload.sessionId,
|
|
22939
|
+
// Identifies WHICH parked section this answers — sections in a wave
|
|
22940
|
+
// run concurrently, so more than one can be waiting.
|
|
22941
|
+
requestId: typeof payload.requestId === "string" ? payload.requestId : void 0,
|
|
22942
|
+
action,
|
|
22943
|
+
note: typeof payload.note === "string" && payload.note.trim() ? payload.note.trim() : void 0
|
|
22944
|
+
});
|
|
22945
|
+
});
|
|
22946
|
+
});
|
|
22947
|
+
}
|
|
22382
22948
|
onSessionSteer(callback) {
|
|
22383
22949
|
this.io.on("connection", (socket) => {
|
|
22384
22950
|
socket.on("session:steer", (payload) => {
|
|
@@ -22540,6 +23106,7 @@ function aggregateCostStats(sessions2, opts = {}) {
|
|
|
22540
23106
|
|
|
22541
23107
|
// src/dashboard/server.ts
|
|
22542
23108
|
var __dirname$1 = path32.dirname(fileURLToPath(import.meta.url));
|
|
23109
|
+
var ORPHANED_ESCALATION_GRACE_MS = 3e4;
|
|
22543
23110
|
var DashboardServer = class {
|
|
22544
23111
|
app;
|
|
22545
23112
|
httpServer;
|
|
@@ -22550,6 +23117,13 @@ var DashboardServer = class {
|
|
|
22550
23117
|
globalStore = null;
|
|
22551
23118
|
broadcastTimer = null;
|
|
22552
23119
|
activeSessions = /* @__PURE__ */ new Map();
|
|
23120
|
+
/**
|
|
23121
|
+
* Supplied by the desktop shell so a run can read the page the user has
|
|
23122
|
+
* open in the built-in browser. Absent in the CLI, where there is no
|
|
23123
|
+
* browser — so the tool is simply not registered rather than present and
|
|
23124
|
+
* always failing.
|
|
23125
|
+
*/
|
|
23126
|
+
currentPageProvider;
|
|
22553
23127
|
activeControllers = /* @__PURE__ */ new Map();
|
|
22554
23128
|
/**
|
|
22555
23129
|
* Run taskIds per chat session — file snapshots are keyed by the run's
|
|
@@ -22564,6 +23138,17 @@ var DashboardServer = class {
|
|
|
22564
23138
|
* map is how that answer reaches the run that's blocked on it.
|
|
22565
23139
|
*/
|
|
22566
23140
|
pendingApprovals = /* @__PURE__ */ new Map();
|
|
23141
|
+
/**
|
|
23142
|
+
* The escalation prompt a session is currently parked on, kept so it can be
|
|
23143
|
+
* delivered to a client that was not reachable when it was raised.
|
|
23144
|
+
*
|
|
23145
|
+
* Socket.IO buffers nothing: a prompt emitted while the renderer is mid
|
|
23146
|
+
* reconnect is gone, and the section then waits out its full five minutes
|
|
23147
|
+
* with the user in front of a screen that never asked them anything. Holding
|
|
23148
|
+
* the payload lets `join:session` replay it, and lets a run with no reachable
|
|
23149
|
+
* client be settled instead of parked (see ORPHANED_ESCALATION_GRACE_MS).
|
|
23150
|
+
*/
|
|
23151
|
+
pendingEscalations = /* @__PURE__ */ new Map();
|
|
22567
23152
|
/**
|
|
22568
23153
|
* The orchestration decision trail ("why") of each session's most recent
|
|
22569
23154
|
* run — captured when the run ends so the desktop's Why panel can show it
|
|
@@ -22641,20 +23226,29 @@ var DashboardServer = class {
|
|
|
22641
23226
|
if (forceTier) cfg = { ...cfg, routing: { ...cfg.routing, forceTier } };
|
|
22642
23227
|
const cascade = new Cascade(cfg, this.workspacePath, this.store);
|
|
22643
23228
|
this.activeSessions.set(sessionId, cascade);
|
|
23229
|
+
if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
|
|
22644
23230
|
cascade.on("stream:token", (e) => {
|
|
22645
|
-
this.
|
|
23231
|
+
this.emitToSessionClients(socketId, sessionId, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
|
|
22646
23232
|
});
|
|
22647
23233
|
cascade.on("tier:status", (e) => {
|
|
22648
|
-
this.
|
|
23234
|
+
this.emitToSessionClients(socketId, sessionId, "tier:status", { sessionId, ...e });
|
|
22649
23235
|
});
|
|
22650
23236
|
cascade.on("permission:user-required", (e) => {
|
|
22651
|
-
this.
|
|
23237
|
+
this.emitToSessionClients(socketId, sessionId, "permission:user-required", { sessionId, ...e });
|
|
22652
23238
|
});
|
|
22653
23239
|
cascade.on("peer:message", (e) => {
|
|
22654
23240
|
this.socket.emitPeerMessage(e);
|
|
22655
23241
|
});
|
|
22656
23242
|
cascade.on("plan:approval-required", (e) => {
|
|
22657
|
-
this.
|
|
23243
|
+
this.emitToSessionClients(socketId, sessionId, "plan:approval-required", { sessionId, ...e });
|
|
23244
|
+
});
|
|
23245
|
+
cascade.on("escalation:decision-required", (e) => {
|
|
23246
|
+
this.raiseEscalation(sessionId, socketId, e);
|
|
23247
|
+
});
|
|
23248
|
+
cascade.on("escalation:timeout", (e) => {
|
|
23249
|
+
const ev = e;
|
|
23250
|
+
if (ev.requestId) this.clearPendingEscalation(ev.requestId);
|
|
23251
|
+
this.emitToSessionClients(socketId, sessionId, "escalation:timeout", { sessionId, ...e });
|
|
22658
23252
|
});
|
|
22659
23253
|
try {
|
|
22660
23254
|
const result = await cascade.run({
|
|
@@ -22665,7 +23259,7 @@ var DashboardServer = class {
|
|
|
22665
23259
|
this.recordSessionTask(sessionId, result.taskId);
|
|
22666
23260
|
this.persistRunEnd(sessionId, title, prompt, result.output, "COMPLETED", result);
|
|
22667
23261
|
this.captureWhy(sessionId, cascade, result);
|
|
22668
|
-
this.
|
|
23262
|
+
this.emitToSessionClients(socketId, sessionId, "session:complete", { sessionId, result });
|
|
22669
23263
|
this.socket.broadcast("cost:update", {
|
|
22670
23264
|
sessionId,
|
|
22671
23265
|
totalTokens: result.usage.totalTokens,
|
|
@@ -22678,7 +23272,7 @@ var DashboardServer = class {
|
|
|
22678
23272
|
} catch (err) {
|
|
22679
23273
|
this.persistRunEnd(sessionId, title, prompt, void 0, "FAILED");
|
|
22680
23274
|
this.captureWhy(sessionId, cascade);
|
|
22681
|
-
this.
|
|
23275
|
+
this.emitToSessionClients(socketId, sessionId, "session:error", {
|
|
22682
23276
|
sessionId,
|
|
22683
23277
|
error: err instanceof Error ? err.message : String(err)
|
|
22684
23278
|
});
|
|
@@ -22686,6 +23280,7 @@ var DashboardServer = class {
|
|
|
22686
23280
|
this.activeSessions.delete(sessionId);
|
|
22687
23281
|
this.activeControllers.delete(sessionId);
|
|
22688
23282
|
this.denyPendingApprovals(sessionId);
|
|
23283
|
+
this.clearSessionEscalations(sessionId);
|
|
22689
23284
|
}
|
|
22690
23285
|
});
|
|
22691
23286
|
this.socket.onPlanDecision(({ sessionId, approved, note, editedPlan }) => {
|
|
@@ -22695,10 +23290,20 @@ var DashboardServer = class {
|
|
|
22695
23290
|
editedPlan
|
|
22696
23291
|
);
|
|
22697
23292
|
});
|
|
23293
|
+
this.socket.onEscalationDecision(({ sessionId, requestId, action, note }) => {
|
|
23294
|
+
if (requestId) this.clearPendingEscalation(requestId);
|
|
23295
|
+
else this.clearOldestSessionEscalation(sessionId);
|
|
23296
|
+
this.activeSessions.get(sessionId)?.resolveEscalation(action, note, requestId);
|
|
23297
|
+
});
|
|
23298
|
+
this.socket.onJoinSession((sessionId, socketId) => {
|
|
23299
|
+
this.replayEscalations(sessionId, socketId);
|
|
23300
|
+
});
|
|
22698
23301
|
this.socket.onSessionHalt((sessionId) => {
|
|
22699
23302
|
this.activeControllers.get(sessionId)?.abort();
|
|
22700
23303
|
this.denyPendingApprovals(sessionId);
|
|
22701
23304
|
this.activeSessions.get(sessionId)?.resolvePlanApproval(false);
|
|
23305
|
+
this.clearSessionEscalations(sessionId);
|
|
23306
|
+
this.activeSessions.get(sessionId)?.resolveEscalation("skip", void 0, void 0, true);
|
|
22702
23307
|
});
|
|
22703
23308
|
this.socket.onApprovalResponse(({ requestId, approved, always }) => {
|
|
22704
23309
|
const pending = this.pendingApprovals.get(requestId);
|
|
@@ -22713,6 +23318,10 @@ var DashboardServer = class {
|
|
|
22713
23318
|
}
|
|
22714
23319
|
});
|
|
22715
23320
|
}
|
|
23321
|
+
/** Wire the host's browser view in (desktop only). */
|
|
23322
|
+
setCurrentPageProvider(provider) {
|
|
23323
|
+
this.currentPageProvider = provider;
|
|
23324
|
+
}
|
|
22716
23325
|
async start() {
|
|
22717
23326
|
const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
|
|
22718
23327
|
if (!isLoopback) {
|
|
@@ -23001,6 +23610,116 @@ var DashboardServer = class {
|
|
|
23001
23610
|
this.activeSessions.delete(sessionId);
|
|
23002
23611
|
}
|
|
23003
23612
|
}
|
|
23613
|
+
/**
|
|
23614
|
+
* Is anyone able to answer for this session right now?
|
|
23615
|
+
*
|
|
23616
|
+
* The originating socket first — that connection is definitely watching this
|
|
23617
|
+
* run — then the session room, which is where a client lands after a
|
|
23618
|
+
* reconnect issues it a new socket id.
|
|
23619
|
+
*/
|
|
23620
|
+
hasResponder(sessionId, socketId) {
|
|
23621
|
+
if (socketId && this.socket.hasSocket(socketId)) return true;
|
|
23622
|
+
return this.socket.roomSize(`session:${sessionId}`) > 0;
|
|
23623
|
+
}
|
|
23624
|
+
/**
|
|
23625
|
+
* Send a run event to whoever is watching this session.
|
|
23626
|
+
*
|
|
23627
|
+
* A captured socket id goes stale the moment Socket.IO reconnects — the
|
|
23628
|
+
* client is back, but under a new id, and `emitToSocket` on the old one is a
|
|
23629
|
+
* silent no-op. Falling back to the session room keeps the run addressable
|
|
23630
|
+
* across a reconnect; emitting to one OR the other (never both) keeps a
|
|
23631
|
+
* client that is both the originator and a room member from seeing every
|
|
23632
|
+
* token twice.
|
|
23633
|
+
*/
|
|
23634
|
+
emitToSessionClients(socketId, sessionId, event, data) {
|
|
23635
|
+
if (socketId && this.socket.hasSocket(socketId)) this.socket.emitToSocket(socketId, event, data);
|
|
23636
|
+
else this.socket.broadcastToRoom(`session:${sessionId}`, event, data);
|
|
23637
|
+
}
|
|
23638
|
+
/**
|
|
23639
|
+
* Raise an escalation prompt, and make sure it can still be answered.
|
|
23640
|
+
*
|
|
23641
|
+
* Held in `pendingEscalations` — keyed by requestId, because a Complex wave
|
|
23642
|
+
* dispatches sections concurrently and two can be parked at once — so a
|
|
23643
|
+
* client that joins late (or rejoins after a reconnect) gets every waiting
|
|
23644
|
+
* prompt replayed. Keying by session would drop the first of a pair: its
|
|
23645
|
+
* grace timer would be cleared by the second, leaving it parked for the full
|
|
23646
|
+
* timeout with nobody able to answer it. If nobody is reachable at all, the gate is
|
|
23647
|
+
* settled as `skip` after a short grace period rather than parked for the
|
|
23648
|
+
* full timeout: that matches the SDK's policy when no listener exists, and
|
|
23649
|
+
* five minutes of a held run is a real cost for a REST caller who has already
|
|
23650
|
+
* hung up. The grace period exists so an ordinary two-second reconnect is not
|
|
23651
|
+
* mistaken for an absent user.
|
|
23652
|
+
*/
|
|
23653
|
+
raiseEscalation(sessionId, socketId, event) {
|
|
23654
|
+
const requestId = typeof event.requestId === "string" && event.requestId ? event.requestId : randomUUID();
|
|
23655
|
+
const payload = { sessionId, ...event };
|
|
23656
|
+
const entry = { payload, sessionId, requestId, raisedAt: Date.now() };
|
|
23657
|
+
this.pendingEscalations.set(requestId, entry);
|
|
23658
|
+
if (this.hasResponder(sessionId, socketId)) {
|
|
23659
|
+
this.emitToSessionClients(socketId, sessionId, "escalation:decision-required", payload);
|
|
23660
|
+
return;
|
|
23661
|
+
}
|
|
23662
|
+
entry.graceTimer = setTimeout(() => {
|
|
23663
|
+
if (this.pendingEscalations.get(requestId) !== entry) return;
|
|
23664
|
+
if (this.hasResponder(sessionId, socketId)) return;
|
|
23665
|
+
this.pendingEscalations.delete(requestId);
|
|
23666
|
+
this.activeSessions.get(sessionId)?.resolveEscalation("skip", "No client was connected to answer.", requestId, true);
|
|
23667
|
+
}, ORPHANED_ESCALATION_GRACE_MS);
|
|
23668
|
+
}
|
|
23669
|
+
/**
|
|
23670
|
+
* Replay EVERY parked prompt to a client that just subscribed to the session.
|
|
23671
|
+
*
|
|
23672
|
+
* Plural because a Complex run dispatches sections concurrently, so a wave can
|
|
23673
|
+
* leave more than one waiting at the same time — the same reason the SDK keys
|
|
23674
|
+
* its gates and the clients queue theirs.
|
|
23675
|
+
*
|
|
23676
|
+
* `timeoutMs` is recomputed as the time actually LEFT, because the SDK's timer
|
|
23677
|
+
* has been running since the prompt was raised. Sent verbatim, a client
|
|
23678
|
+
* reconnecting four minutes in would show a fresh five-minute countdown and
|
|
23679
|
+
* then watch the section fail with four of those minutes still on the clock.
|
|
23680
|
+
* A remaining duration is used rather than an absolute deadline so the two
|
|
23681
|
+
* sides never have to agree on a wall clock.
|
|
23682
|
+
*/
|
|
23683
|
+
replayEscalations(sessionId, socketId) {
|
|
23684
|
+
for (const entry of this.pendingEscalations.values()) {
|
|
23685
|
+
if (entry.sessionId !== sessionId) continue;
|
|
23686
|
+
if (entry.graceTimer) {
|
|
23687
|
+
clearTimeout(entry.graceTimer);
|
|
23688
|
+
entry.graceTimer = void 0;
|
|
23689
|
+
}
|
|
23690
|
+
const originalTimeout = typeof entry.payload.timeoutMs === "number" ? entry.payload.timeoutMs : 0;
|
|
23691
|
+
const remaining = Math.max(0, originalTimeout - (Date.now() - entry.raisedAt));
|
|
23692
|
+
this.socket.emitToSocket(socketId, "escalation:decision-required", { ...entry.payload, timeoutMs: remaining });
|
|
23693
|
+
}
|
|
23694
|
+
}
|
|
23695
|
+
/** Forget ONE parked prompt — the one that was answered or timed out. */
|
|
23696
|
+
clearPendingEscalation(requestId) {
|
|
23697
|
+
const entry = this.pendingEscalations.get(requestId);
|
|
23698
|
+
if (!entry) return;
|
|
23699
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
23700
|
+
this.pendingEscalations.delete(requestId);
|
|
23701
|
+
}
|
|
23702
|
+
/**
|
|
23703
|
+
* Forget the session's OLDEST prompt — for a client that answered without a
|
|
23704
|
+
* requestId, which is exactly the gate `resolveEscalation` will settle.
|
|
23705
|
+
* Insertion order makes `values()` oldest-first.
|
|
23706
|
+
*/
|
|
23707
|
+
clearOldestSessionEscalation(sessionId) {
|
|
23708
|
+
for (const [id, entry] of this.pendingEscalations) {
|
|
23709
|
+
if (entry.sessionId !== sessionId) continue;
|
|
23710
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
23711
|
+
this.pendingEscalations.delete(id);
|
|
23712
|
+
return;
|
|
23713
|
+
}
|
|
23714
|
+
}
|
|
23715
|
+
/** Forget every prompt a session was parked on (run ended, or halted). */
|
|
23716
|
+
clearSessionEscalations(sessionId) {
|
|
23717
|
+
for (const [id, entry] of this.pendingEscalations) {
|
|
23718
|
+
if (entry.sessionId !== sessionId) continue;
|
|
23719
|
+
if (entry.graceTimer) clearTimeout(entry.graceTimer);
|
|
23720
|
+
this.pendingEscalations.delete(id);
|
|
23721
|
+
}
|
|
23722
|
+
}
|
|
23004
23723
|
/** Deny + clear any approvals still pending for a session (run end / abort). */
|
|
23005
23724
|
denyPendingApprovals(sessionId) {
|
|
23006
23725
|
for (const [id, pending] of this.pendingApprovals) {
|
|
@@ -23501,6 +24220,7 @@ ${prompt}`;
|
|
|
23501
24220
|
void (async () => {
|
|
23502
24221
|
const cascade = new Cascade(this.config, this.workspacePath, this.store);
|
|
23503
24222
|
this.activeSessions.set(sessionId, cascade);
|
|
24223
|
+
if (this.currentPageProvider) cascade.setCurrentPageProvider(this.currentPageProvider);
|
|
23504
24224
|
cascade.on("stream:token", (e) => {
|
|
23505
24225
|
this.socket.broadcastToRoom(`session:${sessionId}`, "stream:token", { sessionId, tierId: e.tierId, text: e.text, primary: e.primary });
|
|
23506
24226
|
});
|
|
@@ -23516,6 +24236,14 @@ ${prompt}`;
|
|
|
23516
24236
|
cascade.on("plan:approval-required", (e) => {
|
|
23517
24237
|
this.socket.broadcastToRoom(`session:${sessionId}`, "plan:approval-required", { sessionId, ...e });
|
|
23518
24238
|
});
|
|
24239
|
+
cascade.on("escalation:decision-required", (e) => {
|
|
24240
|
+
this.raiseEscalation(sessionId, void 0, e);
|
|
24241
|
+
});
|
|
24242
|
+
cascade.on("escalation:timeout", (e) => {
|
|
24243
|
+
const ev = e;
|
|
24244
|
+
if (ev.requestId) this.clearPendingEscalation(ev.requestId);
|
|
24245
|
+
this.socket.broadcastToRoom(`session:${sessionId}`, "escalation:timeout", { sessionId, ...e });
|
|
24246
|
+
});
|
|
23519
24247
|
try {
|
|
23520
24248
|
const result = await cascade.run({
|
|
23521
24249
|
prompt: runPrompt,
|
|
@@ -23543,6 +24271,7 @@ ${prompt}`;
|
|
|
23543
24271
|
} finally {
|
|
23544
24272
|
this.activeSessions.delete(sessionId);
|
|
23545
24273
|
this.denyPendingApprovals(sessionId);
|
|
24274
|
+
this.clearSessionEscalations(sessionId);
|
|
23546
24275
|
}
|
|
23547
24276
|
})();
|
|
23548
24277
|
});
|