cascade-ai 0.5.1 → 0.9.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -6,20 +6,21 @@ var glob = require('glob');
6
6
  var Anthropic = require('@anthropic-ai/sdk');
7
7
  var OpenAI = require('openai');
8
8
  var genai = require('@google/genai');
9
- var axios2 = require('axios');
10
- var fs3 = require('fs/promises');
11
- var path16 = require('path');
9
+ var fs4 = require('fs/promises');
10
+ var path18 = require('path');
11
+ var os4 = require('os');
12
12
  var ignoreFactory = require('ignore');
13
13
  var child_process = require('child_process');
14
14
  var util = require('util');
15
- var fs15 = require('fs');
15
+ var fs17 = require('fs');
16
16
  var simpleGit = require('simple-git');
17
17
  var PDFDocument = require('pdfkit');
18
+ var dns = require('dns/promises');
19
+ var net = require('net');
18
20
  var index_js = require('@modelcontextprotocol/sdk/client/index.js');
19
21
  var stdio_js = require('@modelcontextprotocol/sdk/client/stdio.js');
20
22
  var zod = require('zod');
21
- var os3 = require('os');
22
- var vm = require('vm');
23
+ var worker_threads = require('worker_threads');
23
24
  var Database = require('better-sqlite3');
24
25
  var http = require('http');
25
26
  var url = require('url');
@@ -56,13 +57,14 @@ var EventEmitter__default = /*#__PURE__*/_interopDefault(EventEmitter);
56
57
  var crypto__default = /*#__PURE__*/_interopDefault(crypto);
57
58
  var Anthropic__default = /*#__PURE__*/_interopDefault(Anthropic);
58
59
  var OpenAI__default = /*#__PURE__*/_interopDefault(OpenAI);
59
- var axios2__default = /*#__PURE__*/_interopDefault(axios2);
60
- var fs3__default = /*#__PURE__*/_interopDefault(fs3);
61
- var path16__default = /*#__PURE__*/_interopDefault(path16);
60
+ var fs4__default = /*#__PURE__*/_interopDefault(fs4);
61
+ var path18__default = /*#__PURE__*/_interopDefault(path18);
62
+ var os4__default = /*#__PURE__*/_interopDefault(os4);
62
63
  var ignoreFactory__namespace = /*#__PURE__*/_interopNamespace(ignoreFactory);
63
- var fs15__default = /*#__PURE__*/_interopDefault(fs15);
64
+ var fs17__default = /*#__PURE__*/_interopDefault(fs17);
64
65
  var PDFDocument__default = /*#__PURE__*/_interopDefault(PDFDocument);
65
- var os3__default = /*#__PURE__*/_interopDefault(os3);
66
+ var dns__default = /*#__PURE__*/_interopDefault(dns);
67
+ var net__default = /*#__PURE__*/_interopDefault(net);
66
68
  var Database__default = /*#__PURE__*/_interopDefault(Database);
67
69
  var express__default = /*#__PURE__*/_interopDefault(express);
68
70
  var rateLimit__default = /*#__PURE__*/_interopDefault(rateLimit);
@@ -111,13 +113,13 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
111
113
  var keytar_default;
112
114
  var init_keytar = __esm({
113
115
  "node_modules/keytar/build/Release/keytar.node"() {
114
- keytar_default = "./keytar-F4YAPN53.node";
116
+ keytar_default = "./keytar-VMICNFEJ.node";
115
117
  }
116
118
  });
117
119
 
118
- // node-file:F:\Softwares\Github Softwares\Cascade-AI\node_modules\keytar\build\Release\keytar.node
120
+ // node-file:/home/runner/work/Cascade-AI/Cascade-AI/node_modules/keytar/build/Release/keytar.node
119
121
  var require_keytar = __commonJS({
120
- "node-file:F:\\Softwares\\Github Softwares\\Cascade-AI\\node_modules\\keytar\\build\\Release\\keytar.node"(exports$1, module) {
122
+ "node-file:/home/runner/work/Cascade-AI/Cascade-AI/node_modules/keytar/build/Release/keytar.node"(exports, module) {
121
123
  init_keytar();
122
124
  try {
123
125
  module.exports = __require(keytar_default);
@@ -128,7 +130,7 @@ var require_keytar = __commonJS({
128
130
 
129
131
  // node_modules/keytar/lib/keytar.js
130
132
  var require_keytar2 = __commonJS({
131
- "node_modules/keytar/lib/keytar.js"(exports$1, module) {
133
+ "node_modules/keytar/lib/keytar.js"(exports, module) {
132
134
  var keytar = require_keytar();
133
135
  function checkRequired(val, name) {
134
136
  if (!val || val.length <= 0) {
@@ -165,7 +167,7 @@ var require_keytar2 = __commonJS({
165
167
  });
166
168
 
167
169
  // src/constants.ts
168
- var CASCADE_VERSION = "0.5.1";
170
+ var CASCADE_VERSION = "0.9.6";
169
171
  var CASCADE_CONFIG_DIR = ".cascade";
170
172
  var CASCADE_MD_FILE = "CASCADE.md";
171
173
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -333,7 +335,7 @@ var MODELS = {
333
335
  isLocal: false
334
336
  },
335
337
  "gemini-2.5-pro": {
336
- id: "gemini-2.5-pro-preview-05-06",
338
+ id: "gemini-2.5-pro",
337
339
  name: "Gemini 2.5 Pro",
338
340
  provider: "gemini",
339
341
  contextWindow: 1e6,
@@ -345,7 +347,7 @@ var MODELS = {
345
347
  isLocal: false
346
348
  },
347
349
  "gemini-2.5-flash": {
348
- id: "gemini-2.5-flash-preview-04-17",
350
+ id: "gemini-2.5-flash",
349
351
  name: "Gemini 2.5 Flash",
350
352
  provider: "gemini",
351
353
  contextWindow: 1e6,
@@ -410,6 +412,9 @@ var MODELS = {
410
412
  minSizeB: 7
411
413
  }
412
414
  };
415
+ for (const _m of Object.values(MODELS)) {
416
+ if (_m.supportsToolUse === void 0) _m.supportsToolUse = !_m.isLocal;
417
+ }
413
418
  var T1_MODEL_PRIORITY = [
414
419
  "claude-opus-4",
415
420
  "claude-sonnet-4",
@@ -469,12 +474,15 @@ var TOOL_NAMES = {
469
474
  PDF_CREATE: "pdf_create",
470
475
  RUN_CODE: "run_code",
471
476
  PEER_MESSAGE: "peer_message",
472
- WEB_SEARCH: "web_search"
477
+ WEB_SEARCH: "web_search",
478
+ REQUEST_WORKERS: "request_workers"
473
479
  };
474
480
  var DEFAULT_APPROVAL_REQUIRED = [
475
481
  TOOL_NAMES.SHELL,
476
482
  TOOL_NAMES.FILE_DELETE,
477
483
  TOOL_NAMES.FILE_WRITE,
484
+ TOOL_NAMES.FILE_EDIT,
485
+ TOOL_NAMES.GIT,
478
486
  TOOL_NAMES.BROWSER,
479
487
  TOOL_NAMES.GITHUB,
480
488
  "pdf_create",
@@ -521,9 +529,16 @@ var AnthropicProvider = class extends BaseProvider {
521
529
  client;
522
530
  constructor(config, model) {
523
531
  super(config, model);
524
- this.client = new Anthropic__default.default({
525
- apiKey: config.apiKey
526
- });
532
+ if (config.authToken) {
533
+ this.client = new Anthropic__default.default({
534
+ authToken: config.authToken,
535
+ defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" }
536
+ });
537
+ } else {
538
+ this.client = new Anthropic__default.default({
539
+ apiKey: config.apiKey
540
+ });
541
+ }
527
542
  }
528
543
  async generate(options) {
529
544
  const chunks = [];
@@ -546,7 +561,7 @@ var AnthropicProvider = class extends BaseProvider {
546
561
  system: options.systemPrompt,
547
562
  messages,
548
563
  tools: tools?.length ? tools : void 0
549
- });
564
+ }, { signal: options.signal });
550
565
  let isThinking = false;
551
566
  for await (const event of stream) {
552
567
  if (event.type === "content_block_delta") {
@@ -633,7 +648,7 @@ var AnthropicProvider = class extends BaseProvider {
633
648
  }
634
649
  async isAvailable() {
635
650
  try {
636
- return !!this.config.apiKey;
651
+ return !!(this.config.apiKey || this.config.authToken);
637
652
  } catch {
638
653
  return false;
639
654
  }
@@ -734,7 +749,7 @@ var OpenAIProvider = class extends BaseProvider {
734
749
  };
735
750
  let stream;
736
751
  try {
737
- stream = await this.client.chat.completions.create(params);
752
+ stream = await this.client.chat.completions.create(params, { signal: options.signal });
738
753
  } catch (err) {
739
754
  if (err.message && err.message.includes("max_completion_tokens")) {
740
755
  const fallbackParams = { ...params };
@@ -743,7 +758,7 @@ var OpenAIProvider = class extends BaseProvider {
743
758
  if (this.model.id.includes("o1") || this.model.id.includes("o3")) {
744
759
  fallbackParams.temperature = 1;
745
760
  }
746
- stream = await this.client.chat.completions.create(fallbackParams);
761
+ stream = await this.client.chat.completions.create(fallbackParams, { signal: options.signal });
747
762
  } else {
748
763
  throw err;
749
764
  }
@@ -957,7 +972,8 @@ var GeminiProvider = class extends BaseProvider {
957
972
  { category: genai.HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: genai.HarmBlockThreshold.BLOCK_NONE },
958
973
  { category: genai.HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: genai.HarmBlockThreshold.BLOCK_NONE }
959
974
  ],
960
- tools: options.tools?.length ? [{ functionDeclarations: options.tools.map(this.convertTool) }] : void 0
975
+ tools: options.tools?.length ? [{ functionDeclarations: options.tools.map(this.convertTool) }] : void 0,
976
+ abortSignal: options.signal
961
977
  }
962
978
  });
963
979
  let fullContent = "";
@@ -1159,6 +1175,8 @@ var GeminiProvider = class extends BaseProvider {
1159
1175
  };
1160
1176
  }
1161
1177
  };
1178
+
1179
+ // src/providers/ollama.ts
1162
1180
  var TOOL_CAPABLE_FAMILIES = [
1163
1181
  "llama3.1",
1164
1182
  "llama3.2",
@@ -1195,9 +1213,10 @@ var OllamaProvider = class extends BaseProvider {
1195
1213
  parameters: t.inputSchema
1196
1214
  }
1197
1215
  }));
1198
- const response = await axios2__default.default.post(
1199
- `${this.baseUrl}/api/chat`,
1200
- {
1216
+ const response = await fetch(`${this.baseUrl}/api/chat`, {
1217
+ method: "POST",
1218
+ headers: { "Content-Type": "application/json" },
1219
+ body: JSON.stringify({
1201
1220
  model: this.model.id,
1202
1221
  messages,
1203
1222
  stream: true,
@@ -1206,61 +1225,43 @@ var OllamaProvider = class extends BaseProvider {
1206
1225
  num_predict: options.maxTokens ?? this.model.maxOutputTokens,
1207
1226
  temperature: options.temperature ?? 0.7
1208
1227
  }
1209
- },
1210
- { responseType: "stream" }
1211
- );
1228
+ }),
1229
+ signal: options.signal
1230
+ });
1231
+ if (!response.ok || !response.body) {
1232
+ throw new Error(`Ollama chat request failed: ${response.status} ${response.statusText}`);
1233
+ }
1212
1234
  let fullContent = "";
1213
1235
  let inputTokens = 0;
1214
1236
  let outputTokens = 0;
1215
1237
  const pendingToolCalls = [];
1216
- await new Promise((resolve, reject) => {
1217
- let buffer = "";
1218
- response.data.on("data", (chunk) => {
1219
- buffer += chunk.toString();
1220
- const lines = buffer.split("\n");
1221
- buffer = lines.pop() ?? "";
1222
- for (const line of lines) {
1223
- if (!line.trim()) continue;
1224
- try {
1225
- const parsed = JSON.parse(line);
1226
- if (parsed.message?.content) {
1227
- fullContent += parsed.message.content;
1228
- onChunk({ text: parsed.message.content, finishReason: null });
1229
- }
1230
- if (parsed.message?.tool_calls?.length) {
1231
- pendingToolCalls.push(...parsed.message.tool_calls);
1232
- }
1233
- if (parsed.done) {
1234
- inputTokens = parsed.prompt_eval_count ?? 0;
1235
- outputTokens = parsed.eval_count ?? 0;
1236
- }
1237
- } catch {
1238
- }
1238
+ const handleLine = (line) => {
1239
+ if (!line.trim()) return;
1240
+ try {
1241
+ const parsed = JSON.parse(line);
1242
+ if (parsed.message?.content) {
1243
+ fullContent += parsed.message.content;
1244
+ onChunk({ text: parsed.message.content, finishReason: null });
1239
1245
  }
1240
- });
1241
- response.data.on("end", () => {
1242
- const tail = buffer.trim();
1243
- if (tail) {
1244
- try {
1245
- const parsed = JSON.parse(tail);
1246
- if (parsed.message?.content) {
1247
- fullContent += parsed.message.content;
1248
- onChunk({ text: parsed.message.content, finishReason: null });
1249
- }
1250
- if (parsed.message?.tool_calls?.length) {
1251
- pendingToolCalls.push(...parsed.message.tool_calls);
1252
- }
1253
- if (parsed.done) {
1254
- inputTokens = parsed.prompt_eval_count ?? inputTokens;
1255
- outputTokens = parsed.eval_count ?? outputTokens;
1256
- }
1257
- } catch {
1258
- }
1246
+ if (parsed.message?.tool_calls?.length) {
1247
+ pendingToolCalls.push(...parsed.message.tool_calls);
1259
1248
  }
1260
- resolve();
1261
- });
1262
- response.data.on("error", reject);
1263
- });
1249
+ if (parsed.done) {
1250
+ inputTokens = parsed.prompt_eval_count ?? inputTokens;
1251
+ outputTokens = parsed.eval_count ?? outputTokens;
1252
+ }
1253
+ } catch {
1254
+ }
1255
+ };
1256
+ let buffer = "";
1257
+ const decoder = new TextDecoder();
1258
+ for await (const chunk of response.body) {
1259
+ buffer += decoder.decode(chunk, { stream: true });
1260
+ const lines = buffer.split("\n");
1261
+ buffer = lines.pop() ?? "";
1262
+ for (const line of lines) handleLine(line);
1263
+ }
1264
+ handleLine(buffer);
1264
1265
  const toolCalls = pendingToolCalls.map((tc, i) => {
1265
1266
  let input;
1266
1267
  if (typeof tc.function.arguments === "string") {
@@ -1292,9 +1293,11 @@ var OllamaProvider = class extends BaseProvider {
1292
1293
  }
1293
1294
  async listModels() {
1294
1295
  try {
1295
- const response = await axios2__default.default.get(`${this.baseUrl}/api/tags`);
1296
+ const response = await fetch(`${this.baseUrl}/api/tags`);
1297
+ if (!response.ok) return [];
1298
+ const data = await response.json();
1296
1299
  const supportedKeywords = ["llama3", "llama2", "gemma", "mistral", "mixtral", "qwen", "phi3", "codellama", "deepseek", "llava", "starcoder", "stable-code", "nomic-embed"];
1297
- return response.data.models.filter((m) => {
1300
+ return data.models.filter((m) => {
1298
1301
  const name = m.name.toLowerCase();
1299
1302
  return supportedKeywords.some((k) => name.includes(k));
1300
1303
  }).map((m) => ({
@@ -1316,11 +1319,15 @@ var OllamaProvider = class extends BaseProvider {
1316
1319
  }
1317
1320
  }
1318
1321
  async isAvailable() {
1322
+ const ac = new AbortController();
1323
+ const timer = setTimeout(() => ac.abort(), 2e3);
1319
1324
  try {
1320
- await axios2__default.default.get(`${this.baseUrl}/api/tags`, { timeout: 2e3 });
1321
- return true;
1325
+ const response = await fetch(`${this.baseUrl}/api/tags`, { signal: ac.signal });
1326
+ return response.ok;
1322
1327
  } catch {
1323
1328
  return false;
1329
+ } finally {
1330
+ clearTimeout(timer);
1324
1331
  }
1325
1332
  }
1326
1333
  convertMessages(messages, systemPrompt) {
@@ -1423,6 +1430,19 @@ var ModelSelector = class {
1423
1430
  addDynamicModel(model) {
1424
1431
  this.availableModels.set(model.id, model);
1425
1432
  }
1433
+ /**
1434
+ * Permanently drop a model from the available set for this session. Used by
1435
+ * the router's 404 / "model not found" self-heal so a dead id is never
1436
+ * selected again after it fails once.
1437
+ */
1438
+ removeModel(id) {
1439
+ this.availableModels.delete(id);
1440
+ }
1441
+ /** Look up an available model by exact id (post-discovery/pricing lookups). */
1442
+ getModelById(id) {
1443
+ const m = this.availableModels.get(id);
1444
+ return m && this.availableProviders.has(m.provider) ? m : void 0;
1445
+ }
1426
1446
  getAvailableModelsForProvider(provider) {
1427
1447
  const models = /* @__PURE__ */ new Map();
1428
1448
  for (const model of this.availableModels.values()) {
@@ -1439,6 +1459,7 @@ var ModelSelector = class {
1439
1459
  model = this.resolveDynamicModel(overrideModelId);
1440
1460
  }
1441
1461
  if (model && this.availableProviders.has(model.provider)) return model;
1462
+ return null;
1442
1463
  }
1443
1464
  if (requireVision) {
1444
1465
  return this.selectVisionModel();
@@ -1501,6 +1522,14 @@ var ModelSelector = class {
1501
1522
  candidates.push(model);
1502
1523
  }
1503
1524
  }
1525
+ const localOnly = this.availableProviders.size > 0 && Array.from(this.availableProviders).every((p) => p === "ollama");
1526
+ if (localOnly) {
1527
+ for (const model of this.availableModels.values()) {
1528
+ if (model.isLocal && this.availableProviders.has(model.provider) && !candidates.some((c) => c.id === model.id)) {
1529
+ candidates.push(model);
1530
+ }
1531
+ }
1532
+ }
1504
1533
  return candidates;
1505
1534
  }
1506
1535
  isProviderAvailable(provider) {
@@ -1905,6 +1934,267 @@ var ModelProfiler = class {
1905
1934
  }
1906
1935
  };
1907
1936
 
1937
+ // src/core/router/savings.ts
1938
+ var NO_SAVINGS = { savedUsd: 0, savedPct: 0, counterfactualUsd: 0 };
1939
+ function computeDelegationSavings(stats, t1Model) {
1940
+ if (!t1Model) return NO_SAVINGS;
1941
+ let counterfactualUsd = 0;
1942
+ const tiers = /* @__PURE__ */ new Set([
1943
+ ...Object.keys(stats.inputTokensByTier),
1944
+ ...Object.keys(stats.outputTokensByTier)
1945
+ ]);
1946
+ for (const tier of tiers) {
1947
+ counterfactualUsd += calculateCost(
1948
+ stats.inputTokensByTier[tier] ?? 0,
1949
+ stats.outputTokensByTier[tier] ?? 0,
1950
+ t1Model
1951
+ );
1952
+ }
1953
+ const savedUsd = counterfactualUsd - stats.totalCostUsd;
1954
+ if (!(savedUsd > 0) || counterfactualUsd <= 0) {
1955
+ return { ...NO_SAVINGS, counterfactualUsd: Math.max(0, counterfactualUsd) };
1956
+ }
1957
+ return {
1958
+ savedUsd,
1959
+ savedPct: Math.round(savedUsd / counterfactualUsd * 1e3) / 10,
1960
+ counterfactualUsd
1961
+ };
1962
+ }
1963
+ var DEFAULT_SNAPSHOT_URL = "https://raw.githubusercontent.com/Varun-SV/Cascade-AI/main/src/core/router/benchmark-data.json";
1964
+ var OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models";
1965
+ var FETCH_TIMEOUT_MS = 8e3;
1966
+ var DEFAULT_CACHE_FILE = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, "benchmarks-cache.json");
1967
+ function normalizeModelId(id) {
1968
+ let s = id.toLowerCase();
1969
+ const slash = s.lastIndexOf("/");
1970
+ if (slash !== -1) s = s.slice(slash + 1);
1971
+ s = s.replace(/-preview(?:-\d{2}-\d{2})?$/, "");
1972
+ s = s.replace(/-\d{8}$/, "");
1973
+ s = s.replace(/[:@].*$/, "");
1974
+ return s;
1975
+ }
1976
+ var LiveDataProvider = class {
1977
+ snapshot = null;
1978
+ prices = /* @__PURE__ */ new Map();
1979
+ source = "bundled";
1980
+ fetchedAt = 0;
1981
+ loaded = false;
1982
+ refreshing = null;
1983
+ opts;
1984
+ constructor(opts = {}) {
1985
+ this.opts = {
1986
+ live: opts.live ?? true,
1987
+ pricingLive: opts.pricingLive ?? true,
1988
+ refreshHours: opts.refreshHours ?? 24,
1989
+ cacheFile: opts.cacheFile ?? DEFAULT_CACHE_FILE,
1990
+ sourceUrl: opts.sourceUrl
1991
+ };
1992
+ }
1993
+ /** Load cached data from disk (cheap, no network). Safe to call repeatedly. */
1994
+ async load() {
1995
+ if (this.loaded) return;
1996
+ this.loaded = true;
1997
+ try {
1998
+ const raw = await fs4__default.default.readFile(this.opts.cacheFile, "utf-8");
1999
+ const cache = JSON.parse(raw);
2000
+ if (cache.snapshot?.families) {
2001
+ this.snapshot = cache.snapshot;
2002
+ this.source = "cache";
2003
+ }
2004
+ if (cache.prices) {
2005
+ for (const [id, p] of Object.entries(cache.prices)) this.prices.set(id, p);
2006
+ }
2007
+ this.fetchedAt = cache.fetchedAt ?? 0;
2008
+ } catch {
2009
+ }
2010
+ }
2011
+ /**
2012
+ * Refresh from the network if the cache is older than the TTL. Coalesces
2013
+ * concurrent callers and never throws — failures keep last-known-good data.
2014
+ */
2015
+ async refresh(force = false) {
2016
+ if (this.refreshing) return this.refreshing;
2017
+ this.refreshing = this.doRefresh(force).finally(() => {
2018
+ this.refreshing = null;
2019
+ });
2020
+ return this.refreshing;
2021
+ }
2022
+ async doRefresh(force) {
2023
+ await this.load();
2024
+ const ttlMs = this.opts.refreshHours * 36e5;
2025
+ const fresh = ttlMs > 0 && Date.now() - this.fetchedAt < ttlMs;
2026
+ if (!force && fresh && this.source !== "bundled") return;
2027
+ const [snap, prices] = await Promise.all([
2028
+ this.opts.live ? this.fetchSnapshot() : Promise.resolve(null),
2029
+ this.opts.pricingLive ? this.fetchPrices() : Promise.resolve(null)
2030
+ ]);
2031
+ let changed = false;
2032
+ if (snap) {
2033
+ this.snapshot = snap;
2034
+ this.source = "live";
2035
+ changed = true;
2036
+ }
2037
+ if (prices && prices.size > 0) {
2038
+ this.prices = prices;
2039
+ changed = true;
2040
+ }
2041
+ if (changed) {
2042
+ this.fetchedAt = Date.now();
2043
+ await this.saveCache();
2044
+ }
2045
+ }
2046
+ async fetchSnapshot() {
2047
+ const url = this.opts.sourceUrl ?? DEFAULT_SNAPSHOT_URL;
2048
+ try {
2049
+ const resp = await withTimeout(fetch(url), FETCH_TIMEOUT_MS, "benchmark fetch timed out");
2050
+ if (!resp.ok) return null;
2051
+ const data = await resp.json();
2052
+ if (!data || typeof data !== "object" || !data.families || typeof data.families !== "object") {
2053
+ return null;
2054
+ }
2055
+ return data;
2056
+ } catch {
2057
+ return null;
2058
+ }
2059
+ }
2060
+ async fetchPrices() {
2061
+ try {
2062
+ const resp = await withTimeout(fetch(OPENROUTER_MODELS_URL), FETCH_TIMEOUT_MS, "pricing fetch timed out");
2063
+ if (!resp.ok) return null;
2064
+ const data = await resp.json();
2065
+ if (!Array.isArray(data?.data)) return null;
2066
+ const out = /* @__PURE__ */ new Map();
2067
+ for (const m of data.data) {
2068
+ if (!m?.id || !m.pricing) continue;
2069
+ const input = Number(m.pricing.prompt) * 1e3;
2070
+ const output = Number(m.pricing.completion) * 1e3;
2071
+ if (!Number.isFinite(input) || !Number.isFinite(output)) continue;
2072
+ out.set(normalizeModelId(m.id), { input, output });
2073
+ }
2074
+ return out;
2075
+ } catch {
2076
+ return null;
2077
+ }
2078
+ }
2079
+ async saveCache() {
2080
+ try {
2081
+ await fs4__default.default.mkdir(path18__default.default.dirname(this.opts.cacheFile), { recursive: true });
2082
+ const cache = {
2083
+ fetchedAt: this.fetchedAt,
2084
+ snapshot: this.snapshot ?? void 0,
2085
+ prices: Object.fromEntries(this.prices)
2086
+ };
2087
+ await fs4__default.default.writeFile(this.opts.cacheFile, JSON.stringify(cache, null, 2), "utf-8");
2088
+ } catch {
2089
+ }
2090
+ }
2091
+ /** Quality profile for a model family, or null when we have no live/cached data. */
2092
+ getQualityProfile(family) {
2093
+ return this.snapshot?.families?.[family] ?? null;
2094
+ }
2095
+ /** Current per-1k price for a model id, or null when unknown. */
2096
+ getLivePrice(modelId) {
2097
+ return this.prices.get(normalizeModelId(modelId)) ?? null;
2098
+ }
2099
+ /**
2100
+ * Returns a price-corrected copy of each model when live pricing is known,
2101
+ * leaving the original untouched (so the shared catalog is never mutated).
2102
+ */
2103
+ applyLivePricing(models) {
2104
+ return models.map((m) => {
2105
+ const p = this.getLivePrice(m.id);
2106
+ if (!p) return m;
2107
+ return { ...m, inputCostPer1kTokens: p.input, outputCostPer1kTokens: p.output };
2108
+ });
2109
+ }
2110
+ /** Where the active quality data came from — for /why and `cascade models`. */
2111
+ getDataSource() {
2112
+ return this.source;
2113
+ }
2114
+ getGeneratedAt() {
2115
+ return this.snapshot?.generatedAt ?? null;
2116
+ }
2117
+ hasLivePricing() {
2118
+ return this.prices.size > 0;
2119
+ }
2120
+ };
2121
+
2122
+ // src/core/router/benchmarks.ts
2123
+ var liveProvider = null;
2124
+ function setBenchmarkLiveProvider(provider) {
2125
+ liveProvider = provider;
2126
+ }
2127
+ var FAMILY_BENCHMARKS = {
2128
+ // Anthropic — strongest at coding and agentic tool-use.
2129
+ "claude-opus": { code: 95, analysis: 92, creative: 90, data: 88 },
2130
+ "claude-sonnet": { code: 93, analysis: 88, creative: 87, data: 85 },
2131
+ "claude-haiku": { code: 80, analysis: 75, creative: 76, data: 72 },
2132
+ // OpenAI — strong all-round, particularly creative/writing.
2133
+ "gpt-4.1": { code: 90, analysis: 89, creative: 91, data: 87 },
2134
+ "gpt-4.1-mini": { code: 82, analysis: 80, creative: 83, data: 79 },
2135
+ "gpt-4.1-nano": { code: 70, analysis: 68, creative: 72, data: 66 },
2136
+ "gpt-4o": { code: 86, analysis: 85, creative: 90, data: 84 },
2137
+ "gpt-4o-mini": { code: 76, analysis: 74, creative: 80, data: 72 },
2138
+ // Google — strongest at analysis/data and long-context.
2139
+ "gemini-2.5-pro": { code: 90, analysis: 93, creative: 86, data: 92 },
2140
+ "gemini-2.5-flash": { code: 82, analysis: 83, creative: 80, data: 82 },
2141
+ "gemini-1.5-pro": { code: 82, analysis: 84, creative: 82, data: 85 },
2142
+ "gemini-2.0-flash": { code: 79, analysis: 80, creative: 79, data: 80 },
2143
+ "gemini-flash-lite": { code: 68, analysis: 68, creative: 70, data: 68 },
2144
+ // Local (Ollama) — lower absolute scores; the ordering is what matters when a
2145
+ // tier is restricted to local-only models.
2146
+ "deepseek": { code: 80, analysis: 72, creative: 68, data: 74 },
2147
+ "qwen": { code: 78, analysis: 73, creative: 72, data: 74 },
2148
+ "codellama": { code: 76, analysis: 60, creative: 55, data: 60 },
2149
+ "llama-70b": { code: 74, analysis: 72, creative: 73, data: 70 },
2150
+ "mistral": { code: 62, analysis: 64, creative: 66, data: 60 },
2151
+ "gemma": { code: 58, analysis: 60, creative: 62, data: 57 },
2152
+ "llama-small": { code: 55, analysis: 56, creative: 60, data: 54 }
2153
+ };
2154
+ var FAMILY_MATCHERS = [
2155
+ [/opus/i, "claude-opus"],
2156
+ [/sonnet/i, "claude-sonnet"],
2157
+ [/haiku/i, "claude-haiku"],
2158
+ [/gpt-?4\.1-nano/i, "gpt-4.1-nano"],
2159
+ [/gpt-?4\.1-mini/i, "gpt-4.1-mini"],
2160
+ [/gpt-?4\.1/i, "gpt-4.1"],
2161
+ [/gpt-?4o-mini/i, "gpt-4o-mini"],
2162
+ [/gpt-?4o/i, "gpt-4o"],
2163
+ [/gemini-?2\.5-pro/i, "gemini-2.5-pro"],
2164
+ [/gemini-?2\.5-flash/i, "gemini-2.5-flash"],
2165
+ [/gemini-?1\.5-pro/i, "gemini-1.5-pro"],
2166
+ [/gemini-?2\.0-flash-lite/i, "gemini-flash-lite"],
2167
+ [/gemini-?2\.0-flash/i, "gemini-2.0-flash"],
2168
+ [/codellama|code-llama|starcoder|stable-code/i, "codellama"],
2169
+ [/deepseek/i, "deepseek"],
2170
+ [/qwen/i, "qwen"],
2171
+ [/llama.?3.*70b|llama3:70b|llama-3-70b/i, "llama-70b"],
2172
+ [/llama/i, "llama-small"],
2173
+ [/mistral|mixtral/i, "mistral"],
2174
+ [/gemma/i, "gemma"]
2175
+ ];
2176
+ function resolveFamily(model) {
2177
+ const hay = `${model.id} ${model.name}`;
2178
+ for (const [re, fam] of FAMILY_MATCHERS) {
2179
+ if (re.test(hay)) return fam;
2180
+ }
2181
+ return null;
2182
+ }
2183
+ function benchmarkScore01(model, taskType) {
2184
+ const fam = resolveFamily(model);
2185
+ if (!fam) return 0.5;
2186
+ const profile = liveProvider?.getQualityProfile(fam) ?? FAMILY_BENCHMARKS[fam];
2187
+ if (!profile) return 0.5;
2188
+ let score;
2189
+ if (taskType === "mixed") {
2190
+ const vals = Object.values(profile).filter((v) => typeof v === "number");
2191
+ score = vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : 50;
2192
+ } else {
2193
+ score = profile[taskType] ?? 50;
2194
+ }
2195
+ return Math.max(0, Math.min(1, score / 100));
2196
+ }
2197
+
1908
2198
  // src/core/router/index.ts
1909
2199
  var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1910
2200
  selector;
@@ -1923,6 +2213,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1923
2213
  tierModels = /* @__PURE__ */ new Map();
1924
2214
  config;
1925
2215
  sessionCostUsd = 0;
2216
+ // Per-run accounting for the hard per-task cap. Reset by beginRun() at the
2217
+ // start of every `cascade run`, independent of the session-wide budget.
2218
+ runTokens = 0;
2219
+ runCostUsd = 0;
2220
+ runBudgetExceeded = false;
2221
+ runBudgetExceededReason;
1926
2222
  /**
1927
2223
  * Budget state machine — guards against two concurrent `generate()` calls
1928
2224
  * each firing the warning or both slipping past the hard cap. All
@@ -1933,6 +2229,12 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1933
2229
  budgetExceededReason;
1934
2230
  tpmLimiter;
1935
2231
  localQueue;
2232
+ taskAnalyzer;
2233
+ liveData;
2234
+ /** Snapshot of configured/default tier models, taken before Cascade Auto overrides them. */
2235
+ originalTierModels;
2236
+ /** The current run's abort signal — injected into every provider call so a cancel aborts in-flight requests. */
2237
+ runSignal;
1936
2238
  /** Thrown when the configured budget is exceeded. */
1937
2239
  static BudgetExceededError = class extends Error {
1938
2240
  constructor(msg) {
@@ -1959,10 +2261,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1959
2261
  if (!override) continue;
1960
2262
  const model = this.selector.selectForTier(tier, override);
1961
2263
  if (!model) {
1962
- throw new Error(`Configured model "${override}" for ${tier} could not be loaded. Check provider availability and exact model name.`);
1963
- }
1964
- if (model.id !== override && `${model.provider}:${model.id}` !== override) {
1965
- throw new Error(`Configured model "${override}" for ${tier} resolved to "${model.id}". Use the exact provider model ID or prefix the provider (e.g. gemini:${override}).`);
2264
+ const knownProviders = ["anthropic", "openai", "gemini", "azure", "openai-compatible", "ollama"];
2265
+ const hasProviderPrefix = override.includes(":") && knownProviders.some((p) => override.startsWith(p + ":"));
2266
+ if (hasProviderPrefix) {
2267
+ const provider = override.split(":")[0];
2268
+ throw new Error(
2269
+ `Configured model "${override}" for ${tier} cannot be used: provider '${provider}' is not available or unreachable. Check that the provider is running and accessible.`
2270
+ );
2271
+ }
2272
+ throw new Error(
2273
+ `Configured model "${override}" for ${tier} could not be loaded. Check provider availability and exact model name.`
2274
+ );
1966
2275
  }
1967
2276
  this.tierModels.set(tier, model);
1968
2277
  this.ensureProvider(model, config.providers);
@@ -1987,19 +2296,93 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1987
2296
  profiler.profileAll(allModels).catch(() => {
1988
2297
  });
1989
2298
  }
2299
+ /**
2300
+ * Cascade Auto live data: discover/validate real model ids from each cloud
2301
+ * provider, then fetch current public quality scores + per-token prices and
2302
+ * apply the prices to the available-model set. Best-effort and safe to run in
2303
+ * the background — any failure leaves the bundled catalog/benchmarks in effect.
2304
+ */
2305
+ async refreshLiveData() {
2306
+ const benchCfg = this.config.benchmarks ?? {};
2307
+ if (!this.liveData) {
2308
+ this.liveData = new LiveDataProvider({
2309
+ live: benchCfg.live,
2310
+ pricingLive: benchCfg.pricingLive,
2311
+ refreshHours: benchCfg.refreshHours,
2312
+ sourceUrl: benchCfg.sourceUrl
2313
+ });
2314
+ setBenchmarkLiveProvider(this.liveData);
2315
+ }
2316
+ await this.discoverProviderModels();
2317
+ await this.liveData.refresh().catch(() => {
2318
+ });
2319
+ this.applyLivePricing();
2320
+ }
2321
+ /** Returns the live-data provider once refreshLiveData has run (UX/insight). */
2322
+ getLiveData() {
2323
+ return this.liveData;
2324
+ }
2325
+ /**
2326
+ * Query each available cloud provider's live model list and register the
2327
+ * results. Confirms catalog ids still exist and surfaces newly released
2328
+ * models without a package upgrade. Mirrors discoverOllamaModels.
2329
+ */
2330
+ async discoverProviderModels() {
2331
+ const cloud = ["anthropic", "openai", "gemini", "azure", "openai-compatible"];
2332
+ const tasks = cloud.map(async (type) => {
2333
+ if (!this.selector.isProviderAvailable(type)) return;
2334
+ const seed = this.getAnyModelForProvider(type);
2335
+ if (!seed) return;
2336
+ const cfg = this.config.providers.find((p) => p.type === type) ?? { type };
2337
+ try {
2338
+ const provider = this.createProvider(cfg, seed);
2339
+ if (typeof provider.listModels !== "function") return;
2340
+ const models = await provider.listModels();
2341
+ for (const m of models) this.selector.addDynamicModel(m);
2342
+ } catch {
2343
+ }
2344
+ });
2345
+ await Promise.allSettled(tasks);
2346
+ }
2347
+ /**
2348
+ * Replace available models with live-priced copies and refresh the already
2349
+ * resolved tier models so shared-tier cost accounting uses current prices.
2350
+ */
2351
+ applyLivePricing() {
2352
+ if (!this.liveData?.hasLivePricing()) return;
2353
+ const updated = this.liveData.applyLivePricing(this.selector.getAllAvailableModels());
2354
+ for (const m of updated) this.selector.addDynamicModel(m);
2355
+ for (const tier of ["T1", "T2", "T3"]) {
2356
+ const cur = this.tierModels.get(tier);
2357
+ if (!cur) continue;
2358
+ const fresh = this.selector.getModelById(cur.id);
2359
+ if (fresh) this.tierModels.set(tier, fresh);
2360
+ }
2361
+ }
1990
2362
  async generate(tier, options, onChunk, requireVision = false) {
1991
2363
  if (this.budgetState === "exceeded") {
1992
2364
  throw new _CascadeRouter.BudgetExceededError(
1993
2365
  this.budgetExceededReason ?? "Session budget exceeded."
1994
2366
  );
1995
2367
  }
2368
+ if (this.runBudgetExceeded) {
2369
+ throw new _CascadeRouter.BudgetExceededError(
2370
+ this.runBudgetExceededReason ?? "Per-task budget exceeded."
2371
+ );
2372
+ }
1996
2373
  const limits = this.config?.tierLimits;
1997
2374
  const tierKey = tier.toLowerCase();
1998
2375
  const tierMaxTokens = limits?.[`${tierKey}MaxTokens`];
1999
2376
  if (tierMaxTokens && (!options.maxTokens || options.maxTokens > tierMaxTokens)) {
2000
2377
  options = { ...options, maxTokens: tierMaxTokens };
2001
2378
  }
2002
- const model = requireVision ? this.selector.selectVisionModel() : this.tierModels.get(tier);
2379
+ if (this.runSignal && !options.signal) {
2380
+ options = { ...options, signal: this.runSignal };
2381
+ }
2382
+ if (options.model && !requireVision) {
2383
+ this.ensureProvider(options.model, this.config.providers);
2384
+ }
2385
+ const model = requireVision ? this.selector.selectVisionModel() : options.model ?? this.tierModels.get(tier);
2003
2386
  if (!model) throw new Error(`No model available for tier ${tier}`);
2004
2387
  const provider = this.getProvider(model);
2005
2388
  if (!provider) throw new Error(`No provider for model ${model.id}`);
@@ -2028,16 +2411,33 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2028
2411
  `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`
2029
2412
  );
2030
2413
  } else if (useStream && onChunk) {
2414
+ const cloudTimeoutMs = this.config.cloudInferenceTimeoutMs ?? 12e4;
2031
2415
  try {
2032
- result = await provider.generateStream(options, (chunk) => {
2033
- const text = typeof chunk?.text === "string" ? chunk.text : "";
2034
- if (text) onChunk({ ...chunk, text });
2035
- });
2036
- } catch {
2037
- result = await provider.generate(options);
2416
+ result = await withTimeout(
2417
+ provider.generateStream(options, (chunk) => {
2418
+ const text = typeof chunk?.text === "string" ? chunk.text : "";
2419
+ if (text) onChunk({ ...chunk, text });
2420
+ }),
2421
+ cloudTimeoutMs,
2422
+ `Model ${model.id} stream timed out after ${cloudTimeoutMs}ms`
2423
+ );
2424
+ } catch (streamErr) {
2425
+ if (streamErr instanceof Error && streamErr.name === "AbortError" || this.runSignal?.aborted || options.signal?.aborted) {
2426
+ throw streamErr;
2427
+ }
2428
+ result = await withTimeout(
2429
+ provider.generate(options),
2430
+ cloudTimeoutMs,
2431
+ `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`
2432
+ );
2038
2433
  }
2039
2434
  } else {
2040
- result = await provider.generate(options);
2435
+ const cloudTimeoutMs = this.config.cloudInferenceTimeoutMs ?? 12e4;
2436
+ result = await withTimeout(
2437
+ provider.generate(options),
2438
+ cloudTimeoutMs,
2439
+ `Model ${model.id} inference timed out after ${cloudTimeoutMs}ms`
2440
+ );
2041
2441
  }
2042
2442
  const correctedCost = calculateCost(
2043
2443
  result.usage.inputTokens,
@@ -2058,6 +2458,9 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2058
2458
  this.failover.recordSuccess(model.provider);
2059
2459
  return result;
2060
2460
  } catch (err) {
2461
+ if (err instanceof Error && err.name === "AbortError" || this.runSignal?.aborted || options.signal?.aborted) {
2462
+ throw new CascadeCancelledError("Run cancelled");
2463
+ }
2061
2464
  const errMsg = err instanceof Error ? err.message : String(err);
2062
2465
  if (this.isRateLimitError(errMsg)) {
2063
2466
  this.failover.recordFailure(model.provider, "rate_limit");
@@ -2065,11 +2468,35 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2065
2468
  if (fallback) {
2066
2469
  this.tierModels.set(tier, fallback);
2067
2470
  this.ensureProvider(fallback, this.config.providers);
2471
+ this.emit("failover", {
2472
+ tier,
2473
+ from: `${model.provider}:${model.id}`,
2474
+ to: `${fallback.provider}:${fallback.id}`,
2475
+ reason: "rate limit"
2476
+ });
2068
2477
  releaseLocalSlot?.();
2069
2478
  releaseLocalSlot = void 0;
2070
2479
  return this.generate(tier, options, onChunk, requireVision);
2071
2480
  }
2072
2481
  }
2482
+ if (isModelNotFoundError(errMsg)) {
2483
+ this.selector.removeModel(model.id);
2484
+ const next = this.selector.selectForTier(tier);
2485
+ if (next && next.id !== model.id) {
2486
+ this.tierModels.set(tier, next);
2487
+ this.ensureProvider(next, this.config.providers);
2488
+ this.emit("failover", {
2489
+ tier,
2490
+ from: `${model.provider}:${model.id}`,
2491
+ to: `${next.provider}:${next.id}`,
2492
+ reason: "model not found"
2493
+ });
2494
+ releaseLocalSlot?.();
2495
+ releaseLocalSlot = void 0;
2496
+ const retryOpts = options.model && options.model.id === model.id ? { ...options, model: void 0 } : options;
2497
+ return this.generate(tier, retryOpts, onChunk, requireVision);
2498
+ }
2499
+ }
2073
2500
  throw err;
2074
2501
  } finally {
2075
2502
  releaseLocalSlot?.();
@@ -2078,18 +2505,74 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2078
2505
  getModelForTier(tier) {
2079
2506
  return this.tierModels.get(tier);
2080
2507
  }
2508
+ /** Reflection settings for workers (config.reflection). Off unless enabled. */
2509
+ getReflectionConfig() {
2510
+ const r = this.config?.reflection;
2511
+ return { enabled: r?.enabled === true, maxRounds: r?.maxRounds ?? 1 };
2512
+ }
2513
+ /** T3→T2 reinforcement settings (config.reinforcements). Off unless enabled. */
2514
+ getReinforcementsConfig() {
2515
+ const r = this.config?.reinforcements;
2516
+ return { enabled: r?.enabled === true, maxPerSection: r?.maxPerSection ?? 4 };
2517
+ }
2518
+ /**
2519
+ * Resolved T3 wave execution mode. 'auto' becomes 'sequential' when the T3
2520
+ * tier resolves to a LOCAL model (the single-GPU queue serializes anyway, so
2521
+ * running them in parallel just thrashes it), and 'parallel' for cloud.
2522
+ */
2523
+ getT3ExecutionMode() {
2524
+ const mode = this.config?.t3Execution ?? "auto";
2525
+ if (mode === "parallel" || mode === "sequential") return mode;
2526
+ return this.tierModels.get("T3")?.isLocal ? "sequential" : "parallel";
2527
+ }
2081
2528
  /**
2082
2529
  * Cascade Auto: temporarily override the model for a tier.
2083
2530
  * Used by TaskAnalyzer to inject task-optimal models before execution.
2084
2531
  * The override is valid for the current task only — restored by restoreTierModels().
2085
2532
  */
2086
2533
  overrideTierModel(tier, model) {
2534
+ if (!this.originalTierModels) {
2535
+ this.originalTierModels = new Map(this.tierModels);
2536
+ }
2087
2537
  this.tierModels.set(tier, model);
2088
2538
  this.ensureProvider(model, this.config.providers);
2089
2539
  }
2540
+ /**
2541
+ * Restore tier models to the configured/default baseline captured before the
2542
+ * first Cascade Auto override. Called at the end of each run so `/why`, the
2543
+ * status bar, and the next run reflect the configured models, not stale picks.
2544
+ */
2545
+ restoreTierModels() {
2546
+ if (this.originalTierModels) {
2547
+ this.tierModels = new Map(this.originalTierModels);
2548
+ this.originalTierModels = void 0;
2549
+ }
2550
+ }
2551
+ /** Set (or clear) the current run's abort signal for instant cancellation. */
2552
+ setRunSignal(signal) {
2553
+ this.runSignal = signal;
2554
+ }
2090
2555
  getSelector() {
2091
2556
  return this.selector;
2092
2557
  }
2558
+ /** Wire the Cascade Auto task analyzer used for per-subtask model routing. */
2559
+ setTaskAnalyzer(analyzer) {
2560
+ this.taskAnalyzer = analyzer;
2561
+ }
2562
+ /**
2563
+ * Cascade Auto per-subtask routing: pick the benchmark-best model for a
2564
+ * specific subtask's text, scoped to the tier's eligible candidates. Returns
2565
+ * null when Cascade Auto is off (callers then use the shared tier model).
2566
+ * Pure heuristic — no extra LLM call.
2567
+ */
2568
+ async selectModelForSubtask(tier, text) {
2569
+ if (!this.config?.cascadeAuto || !this.taskAnalyzer || !text.trim()) return null;
2570
+ try {
2571
+ return await this.taskAnalyzer.selectModel(text, tier, this.selector);
2572
+ } catch {
2573
+ return null;
2574
+ }
2575
+ }
2093
2576
  getStats() {
2094
2577
  return {
2095
2578
  totalTokens: this.stats.totalTokens,
@@ -2102,6 +2585,14 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2102
2585
  outputTokensByTier: { ...this.stats.outputTokensByTier }
2103
2586
  };
2104
2587
  }
2588
+ /**
2589
+ * What did delegation save? Compares actual spend against the
2590
+ * counterfactual of every call running on the T1 model. This is the
2591
+ * number only a tiered hierarchy can show.
2592
+ */
2593
+ getDelegationSavings() {
2594
+ return computeDelegationSavings(this.stats, this.tierModels.get("T1"));
2595
+ }
2105
2596
  /**
2106
2597
  * Returns a human-readable cost summary broken down by tier.
2107
2598
  * Example: { T1: "$0.0120 (2 calls, 1500 tokens)", T2: "$0.0043 (6 calls, 4200 tokens)", ... }
@@ -2160,6 +2651,11 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2160
2651
  * Sets (or clears) a runtime session budget cap (USD).
2161
2652
  * Pass null to remove the cap.
2162
2653
  */
2654
+ /** Raise/set the per-task token cap at runtime (used by /continue resume). */
2655
+ setMaxTokensPerRun(maxTokens) {
2656
+ if (!this.config) return;
2657
+ this.config = { ...this.config, budget: { ...this.config.budget, maxTokensPerRun: maxTokens } };
2658
+ }
2163
2659
  setSessionBudget(usd) {
2164
2660
  if (!this.config) return;
2165
2661
  if (!this.config.budget) {
@@ -2262,7 +2758,39 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2262
2758
  this.stats.tokensByTier[tier] = (this.stats.tokensByTier[tier] ?? 0) + usage.totalTokens;
2263
2759
  this.stats.inputTokensByTier[tier] = (this.stats.inputTokensByTier[tier] ?? 0) + usage.inputTokens;
2264
2760
  this.stats.outputTokensByTier[tier] = (this.stats.outputTokensByTier[tier] ?? 0) + usage.outputTokens;
2761
+ this.runTokens += usage.totalTokens;
2762
+ this.runCostUsd += usage.estimatedCostUsd;
2265
2763
  this.updateBudgetState();
2764
+ this.enforceRunBudget();
2765
+ }
2766
+ /**
2767
+ * Resets per-run accounting at the start of each `cascade run`. Session
2768
+ * totals and a session-wide budget halt are deliberately preserved; only the
2769
+ * per-task ceiling is cleared so the next task starts with a fresh allowance.
2770
+ */
2771
+ beginRun() {
2772
+ this.runTokens = 0;
2773
+ this.runCostUsd = 0;
2774
+ this.runBudgetExceeded = false;
2775
+ this.runBudgetExceededReason = void 0;
2776
+ }
2777
+ /**
2778
+ * Enforce the hard per-task ceiling. Once tripped, the flag makes every
2779
+ * subsequent (and concurrent) generate() call in this run fail fast.
2780
+ */
2781
+ enforceRunBudget() {
2782
+ if (this.runBudgetExceeded) return;
2783
+ const budget = this.config?.budget;
2784
+ const maxTokens = budget?.maxTokensPerRun;
2785
+ const maxCost = budget?.maxCostPerRunUsd;
2786
+ const overTokens = maxTokens != null && this.runTokens >= maxTokens;
2787
+ const overCost = maxCost != null && this.runCostUsd >= maxCost;
2788
+ if (!overTokens && !overCost) return;
2789
+ const reason = overTokens ? `Per-task token cap of ${maxTokens.toLocaleString()} reached (used ${this.runTokens.toLocaleString()}). Stopping this run to avoid runaway cost \u2014 raise budget.maxTokensPerRun for larger jobs.` : `Per-task cost cap of $${maxCost.toFixed(4)} reached (spent $${this.runCostUsd.toFixed(4)}). Stopping this run to avoid runaway cost.`;
2790
+ this.runBudgetExceeded = true;
2791
+ this.runBudgetExceededReason = reason;
2792
+ this.emit("budget:exceeded", { reason, spentUsd: this.sessionCostUsd });
2793
+ throw new _CascadeRouter.BudgetExceededError(reason);
2266
2794
  }
2267
2795
  /**
2268
2796
  * Single point of truth for budget state transitions. Called after each
@@ -2312,6 +2840,9 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
2312
2840
  return /rate.?limit|429|too.?many.?requests|quota/i.test(msg);
2313
2841
  }
2314
2842
  };
2843
+ function isModelNotFoundError(msg) {
2844
+ return /not[_\s]?found|404|does not exist|no such model|unknown model|invalid model|model_not_found|not supported for generatecontent|is not supported for/i.test(msg);
2845
+ }
2315
2846
  var BaseTier = class extends EventEmitter__default.default {
2316
2847
  id;
2317
2848
  role;
@@ -2594,60 +3125,95 @@ var AuditLogger = class {
2594
3125
 
2595
3126
  // src/tools/text-tool-parser.ts
2596
3127
  var TOOL_CALL_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
2597
- var JSON_BLOCK_RE = /```json\s*([\s\S]*?)\s*```/g;
2598
- var FUNCTION_OBJ_RE = /\{\s*"function"\s*:\s*\{[^}]*"name"\s*:[^}]*\}\s*\}/g;
3128
+ var JSON_BLOCK_RE = /```(?:json|tool_call|tool)?\s*([\s\S]*?)```/g;
2599
3129
  function parseTextToolCalls(text) {
2600
- const results = tryXmlBlocks(text);
2601
- if (results.length > 0) return results;
2602
- const jsonBlockResults = tryJsonCodeBlocks(text);
2603
- if (jsonBlockResults.length > 0) return jsonBlockResults;
2604
- return tryFunctionCallObjects(text);
3130
+ const xml = collect(text, TOOL_CALL_RE);
3131
+ if (xml.length > 0) return xml;
3132
+ const fenced = collect(text, JSON_BLOCK_RE);
3133
+ if (fenced.length > 0) return fenced;
3134
+ return tryBareObjects(text);
2605
3135
  }
2606
- function tryXmlBlocks(text) {
3136
+ function collect(text, re) {
2607
3137
  const results = [];
2608
3138
  let match;
2609
- TOOL_CALL_RE.lastIndex = 0;
2610
- while ((match = TOOL_CALL_RE.exec(text)) !== null) {
2611
- try {
2612
- const raw = JSON.parse(match[1]);
2613
- if (typeof raw.name !== "string") continue;
2614
- const input = typeof raw.input === "object" && raw.input !== null ? raw.input : {};
2615
- results.push({ name: raw.name, input });
2616
- } catch {
2617
- }
3139
+ re.lastIndex = 0;
3140
+ while ((match = re.exec(text)) !== null) {
3141
+ const body = (match[1] ?? "").trim();
3142
+ const parsed = parseJsonLoose(body);
3143
+ const call = coerceCall(parsed);
3144
+ if (call) results.push(call);
2618
3145
  }
2619
3146
  return results;
2620
3147
  }
2621
- function tryJsonCodeBlocks(text) {
3148
+ function tryBareObjects(text) {
2622
3149
  const results = [];
2623
- let match;
2624
- JSON_BLOCK_RE.lastIndex = 0;
2625
- while ((match = JSON_BLOCK_RE.exec(text)) !== null) {
2626
- try {
2627
- const raw = JSON.parse(match[1]);
2628
- if (typeof raw.name !== "string") continue;
2629
- const input = typeof raw.input === "object" && raw.input !== null ? raw.input : {};
2630
- results.push({ name: raw.name, input });
2631
- } catch {
3150
+ for (let i = 0; i < text.length; i++) {
3151
+ if (text[i] !== "{") continue;
3152
+ let depth = 0;
3153
+ let inStr = false;
3154
+ let esc = false;
3155
+ let end = -1;
3156
+ for (let j = i; j < text.length; j++) {
3157
+ const c = text[j];
3158
+ if (esc) {
3159
+ esc = false;
3160
+ continue;
3161
+ }
3162
+ if (c === "\\") {
3163
+ esc = true;
3164
+ continue;
3165
+ }
3166
+ if (c === '"') {
3167
+ inStr = !inStr;
3168
+ continue;
3169
+ }
3170
+ if (inStr) continue;
3171
+ if (c === "{") depth++;
3172
+ else if (c === "}") {
3173
+ depth--;
3174
+ if (depth === 0) {
3175
+ end = j;
3176
+ break;
3177
+ }
3178
+ }
2632
3179
  }
3180
+ if (end === -1) break;
3181
+ const candidate = text.slice(i, end + 1);
3182
+ if (/['"]name['"]\s*:/.test(candidate) && /['"](?:input|arguments)['"]\s*:/.test(candidate)) {
3183
+ const call = coerceCall(parseJsonLoose(candidate));
3184
+ if (call) results.push(call);
3185
+ }
3186
+ i = end;
2633
3187
  }
2634
3188
  return results;
2635
3189
  }
2636
- function tryFunctionCallObjects(text) {
2637
- const results = [];
2638
- let match;
2639
- FUNCTION_OBJ_RE.lastIndex = 0;
2640
- while ((match = FUNCTION_OBJ_RE.exec(text)) !== null) {
3190
+ function parseJsonLoose(raw) {
3191
+ if (!raw) return null;
3192
+ try {
3193
+ return JSON.parse(raw);
3194
+ } catch {
2641
3195
  try {
2642
- const raw = JSON.parse(match[0]);
2643
- const fn = raw.function;
2644
- if (!fn || typeof fn.name !== "string") continue;
2645
- const input = typeof fn.arguments === "object" && fn.arguments !== null ? fn.arguments : {};
2646
- results.push({ name: fn.name, input });
3196
+ return JSON.parse(raw.replace(/'/g, '"'));
2647
3197
  } catch {
3198
+ return null;
2648
3199
  }
2649
3200
  }
2650
- return results;
3201
+ }
3202
+ function coerceCall(raw) {
3203
+ if (!raw || typeof raw !== "object") return null;
3204
+ const obj = raw;
3205
+ const fn = obj.function && typeof obj.function === "object" ? obj.function : null;
3206
+ const name = typeof obj.name === "string" ? obj.name : fn && typeof fn.name === "string" ? fn.name : null;
3207
+ if (!name) return null;
3208
+ const rawInput = obj.input ?? obj.arguments ?? (fn ? fn.input ?? fn.arguments : void 0);
3209
+ let input = {};
3210
+ if (rawInput && typeof rawInput === "object") {
3211
+ input = rawInput;
3212
+ } else if (typeof rawInput === "string") {
3213
+ const parsed = parseJsonLoose(rawInput);
3214
+ if (parsed && typeof parsed === "object") input = parsed;
3215
+ }
3216
+ return { name, input };
2651
3217
  }
2652
3218
  function toToolCall(parsed, index) {
2653
3219
  return {
@@ -2658,32 +3224,59 @@ function toToolCall(parsed, index) {
2658
3224
  }
2659
3225
  function buildTextToolSystemPrompt(tools) {
2660
3226
  const toolDefs = tools.map((t) => {
2661
- const props = t.inputSchema?.properties ?? {};
2662
- const paramLines = Object.entries(props).map(([k, v]) => ` "${k}": "<${v.description ?? k}>"`);
2663
- return `\u2022 ${t.name}: ${t.description}
2664
- Input: {${paramLines.length ? "\n" + paramLines.join(",\n") + "\n " : ""}}`;
3227
+ const schema = t.inputSchema ?? {};
3228
+ const props = schema.properties && typeof schema.properties === "object" ? schema.properties : {};
3229
+ const required = Array.isArray(schema.required) ? schema.required : [];
3230
+ const paramLines = Object.entries(props).map(([k, v]) => {
3231
+ const type = typeof v.type === "string" ? v.type : "any";
3232
+ const desc = typeof v.description === "string" ? v.description : k;
3233
+ const req = required.includes(k) ? " [required]" : "";
3234
+ const enumVals = Array.isArray(v.enum) ? ` (one of: ${v.enum.map((e) => JSON.stringify(e)).join(", ")})` : "";
3235
+ return ` - ${k} (${type})${req}: ${desc}${enumVals}`;
3236
+ });
3237
+ return `\u2022 ${t.name} \u2014 ${t.description}${paramLines.length ? "\n" + paramLines.join("\n") : "\n (no parameters)"}`;
2665
3238
  }).join("\n");
2666
3239
  return `
2667
3240
  TOOL USE INSTRUCTIONS:
2668
- You do not have native tool-use capability. To call a tool, write a <tool_call> block:
3241
+ You do not have native tool-use capability. To call a tool, output a single <tool_call> block containing JSON with the tool name and its input arguments:
2669
3242
 
2670
3243
  <tool_call>
2671
- {"name": "<tool_name>", "input": {<parameters>}}
3244
+ {"name": "<tool_name>", "input": { ...arguments... }}
2672
3245
  </tool_call>
2673
3246
 
3247
+ Rules:
3248
+ - Use exactly the parameter names shown below and include every [required] parameter.
3249
+ - For parameters that list "one of", use one of those values verbatim.
3250
+ - Emit valid JSON with double quotes. Call only ONE tool at a time, then wait for the result.
3251
+
2674
3252
  Available tools:
2675
3253
  ${toolDefs}
2676
3254
 
2677
3255
  EXAMPLE \u2014 calling the "shell" tool to list files:
2678
3256
  <tool_call>
2679
- {"name": "shell", "input": {"command": "ls -la /workspace"}}
3257
+ {"name": "shell", "input": {"command": "ls -la"}}
2680
3258
  </tool_call>
2681
3259
 
2682
- You will then receive a user message with the result, then continue your work.
2683
- Only call one tool at a time. When you have enough information, provide your final answer.`;
3260
+ When you have enough information, stop calling tools and write your final answer.`;
2684
3261
  }
2685
3262
 
2686
3263
  // src/core/tiers/t3-worker.ts
3264
+ var CriticalToolError = class extends Error {
3265
+ constructor(message, toolName) {
3266
+ super(message);
3267
+ this.toolName = toolName;
3268
+ this.name = "CriticalToolError";
3269
+ }
3270
+ toolName;
3271
+ };
3272
+ var WorkerStallError = class extends Error {
3273
+ constructor(message, partialOutput) {
3274
+ super(message);
3275
+ this.partialOutput = partialOutput;
3276
+ this.name = "WorkerStallError";
3277
+ }
3278
+ partialOutput;
3279
+ };
2687
3280
  var T3_SYSTEM_PROMPT = `You are a T3 Worker agent in the Cascade AI system. Your job is to execute a specific subtask completely and accurately.
2688
3281
 
2689
3282
  Rules:
@@ -2705,6 +3298,10 @@ var T3Worker = class extends BaseTier {
2705
3298
  store;
2706
3299
  audit;
2707
3300
  tools = [];
3301
+ /** 0 = top-level worker (may request reinforcements); 1 = a spawned reinforcement (may not). */
3302
+ reinforcementDepth = 0;
3303
+ /** Sibling-worker requests this worker made via request_workers (T3→T2). */
3304
+ pendingReinforcements = [];
2708
3305
  /** @deprecated — kept only as fallback when no escalator is attached */
2709
3306
  sessionApprovals = /* @__PURE__ */ new Map();
2710
3307
  peerBus;
@@ -2717,10 +3314,22 @@ var T3Worker = class extends BaseTier {
2717
3314
  this.log(`Peer message from ${msg.fromId}: ${msg.type}`);
2718
3315
  this.receivePeerSync(msg.fromId, msg.payload);
2719
3316
  });
3317
+ this.peerBus.on("broadcast", (msg) => {
3318
+ const payload = msg?.payload;
3319
+ if (payload?.type === "TOOL_CREATED" && payload.spec && this.toolCreator) {
3320
+ this.toolCreator.registerSpec(payload.spec);
3321
+ this.tools = this.toolRegistry.getToolDefinitions();
3322
+ this.log(`Registered peer tool "${payload.spec.name}" from broadcast.`);
3323
+ }
3324
+ });
2720
3325
  }
2721
3326
  setPermissionEscalator(escalator) {
2722
3327
  this.permissionEscalator = escalator;
2723
3328
  }
3329
+ /** Marks this worker as a spawned reinforcement (depth 1 — cannot request more). */
3330
+ markAsReinforcement() {
3331
+ this.reinforcementDepth = 1;
3332
+ }
2724
3333
  setToolCreator(creator) {
2725
3334
  this.toolCreator = creator;
2726
3335
  }
@@ -2741,6 +3350,31 @@ var T3Worker = class extends BaseTier {
2741
3350
  this.setLabel(assignment.subtaskTitle);
2742
3351
  this.setStatus("ACTIVE");
2743
3352
  this.tools = this.toolRegistry.getToolDefinitions();
3353
+ if (this.reinforcementDepth === 0 && this.router.getReinforcementsConfig?.()?.enabled) {
3354
+ this.tools = [...this.tools, {
3355
+ name: "request_workers",
3356
+ description: "Ask your manager to spawn additional sibling workers for sub-problems you discover are too large or parallelizable to finish alone. Use sparingly \u2014 only when the work genuinely needs to fan out.",
3357
+ inputSchema: {
3358
+ type: "object",
3359
+ properties: {
3360
+ subtasks: {
3361
+ type: "array",
3362
+ description: "New sibling subtasks for your manager to spawn.",
3363
+ items: {
3364
+ type: "object",
3365
+ properties: {
3366
+ title: { type: "string" },
3367
+ description: { type: "string" },
3368
+ expectedOutput: { type: "string" }
3369
+ },
3370
+ required: ["title", "description"]
3371
+ }
3372
+ }
3373
+ },
3374
+ required: ["subtasks"]
3375
+ }
3376
+ }];
3377
+ }
2744
3378
  if (assignment.dependsOn?.length && this.peerBus) {
2745
3379
  this.sendStatusUpdate({
2746
3380
  progressPct: 0,
@@ -2841,12 +3475,31 @@ Now execute your subtask using this context where relevant.`
2841
3475
  return this.buildResult("ESCALATED", output, { checksRun, passed, failed }, issues, correctionAttempts);
2842
3476
  }
2843
3477
  }
3478
+ const reflectCfg = this.router.getReflectionConfig?.() ?? { enabled: false, maxRounds: 1 };
3479
+ if (reflectCfg.enabled) {
3480
+ this.sendStatusUpdate({ progressPct: 85, currentAction: "Reflecting on output", status: "IN_PROGRESS" });
3481
+ output = await this.reflectAndImprove(assignment, output, reflectCfg.maxRounds);
3482
+ }
2844
3483
  this.setStatus("COMPLETED", output);
2845
3484
  this.sendStatusUpdate({ progressPct: 100, currentAction: "Subtask complete", status: "IN_PROGRESS", output });
2846
3485
  this.peerBus?.publish(this.id, assignment.subtaskId, output, "COMPLETED");
2847
3486
  return this.buildResult("COMPLETED", output, { checksRun, passed, failed }, issues, correctionAttempts);
2848
3487
  } catch (err) {
2849
3488
  const errMsg = err instanceof Error ? err.message : String(err);
3489
+ if (err instanceof WorkerStallError) {
3490
+ issues.push(`Stalled: ${errMsg}`);
3491
+ const finalOutput2 = err.partialOutput || output || errMsg;
3492
+ this.setStatus("FAILED", finalOutput2);
3493
+ this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput2, "FAILED");
3494
+ return this.buildResult("ESCALATED", finalOutput2, { checksRun, passed, failed }, issues, correctionAttempts);
3495
+ }
3496
+ if (err instanceof CriticalToolError) {
3497
+ issues.push(`[CRITICAL_TOOL_ERROR] ${err.toolName}: ${errMsg}`);
3498
+ const finalOutput2 = output || `Tool "${err.toolName}" failed unrecoverably: ${errMsg}`;
3499
+ this.setStatus("FAILED", finalOutput2);
3500
+ this.peerBus?.publish(this.id, assignment.subtaskId, finalOutput2, "FAILED");
3501
+ return this.buildResult("ESCALATED", finalOutput2, { checksRun, passed, failed }, issues, correctionAttempts);
3502
+ }
2850
3503
  issues.push(`Execution error: ${errMsg}`);
2851
3504
  const finalOutput = output || errMsg;
2852
3505
  this.setStatus("FAILED", finalOutput);
@@ -2884,8 +3537,17 @@ Now execute your subtask using this context where relevant.`
2884
3537
  const MAX_ITERATIONS = 15;
2885
3538
  const requiresArtifact = this.requiresArtifact();
2886
3539
  tools = [...tools];
2887
- const t3Model = this.router.getModelForTier("T3");
2888
- const useTextTools = t3Model?.supportsToolUse === false && tools.length > 0;
3540
+ let subtaskModel;
3541
+ try {
3542
+ const subtaskText = `${this.assignment?.subtaskTitle ?? ""} ${this.assignment?.description ?? ""} ${this.assignment?.expectedOutput ?? ""}`;
3543
+ subtaskModel = await this.router.selectModelForSubtask("T3", subtaskText) ?? void 0;
3544
+ if (subtaskModel) {
3545
+ this.log(`Cascade Auto: routing this subtask to ${subtaskModel.provider}:${subtaskModel.id}`);
3546
+ }
3547
+ } catch {
3548
+ }
3549
+ const effectiveModel = subtaskModel ?? this.router.getModelForTier("T3");
3550
+ const useTextTools = effectiveModel?.supportsToolUse === false && tools.length > 0;
2889
3551
  const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
2890
3552
  while (iterations < MAX_ITERATIONS) {
2891
3553
  iterations++;
@@ -2897,7 +3559,8 @@ Now execute your subtask using this context where relevant.`
2897
3559
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2898
3560
  // Don't pass tools array when model can't use them natively
2899
3561
  tools: useTextTools ? void 0 : tools.length ? tools : void 0,
2900
- maxTokens: 4096
3562
+ maxTokens: 4096,
3563
+ ...subtaskModel ? { model: subtaskModel } : {}
2901
3564
  };
2902
3565
  const result = await this.router.generate(
2903
3566
  "T3",
@@ -2921,10 +3584,17 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2921
3584
  }
2922
3585
  stalledArtifactIterations += 1;
2923
3586
  if (stalledArtifactIterations >= 2) {
3587
+ const partial = result.content || "";
2924
3588
  if (stalledArtifactIterations === 2) {
2925
- throw new Error(`Worker stalled waiting for artifact creation. Requesting dynamic tool generation from T2 Manager for: ${this.assignment?.subtaskTitle ?? "unknown task"}`);
3589
+ throw new WorkerStallError(
3590
+ `Worker stalled waiting for artifact creation. Requesting dynamic tool generation from T2 Manager for: ${this.assignment?.subtaskTitle ?? "unknown task"}`,
3591
+ partial
3592
+ );
2926
3593
  }
2927
- throw new Error("Artifact-producing task stalled without creating or verifying the required files");
3594
+ throw new WorkerStallError(
3595
+ "Artifact-producing task stalled without creating or verifying the required files",
3596
+ partial
3597
+ );
2928
3598
  }
2929
3599
  await this.context.addMessage({
2930
3600
  role: "user",
@@ -2961,7 +3631,41 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2961
3631
  toolCalls: allToolCalls
2962
3632
  };
2963
3633
  }
3634
+ /**
3635
+ * Lightweight argument check against the tool's JSON Schema: required fields
3636
+ * present and enum values in range. Not a full validator — just the two
3637
+ * failure modes weak models hit most. Returns an error message, or null if OK.
3638
+ */
3639
+ validateToolInput(tc) {
3640
+ const def = this.tools.find((t) => t.name === tc.name);
3641
+ const schema = def?.inputSchema;
3642
+ if (!schema) return null;
3643
+ const required = Array.isArray(schema.required) ? schema.required : [];
3644
+ const missing = required.filter((k) => tc.input[k] === void 0 || tc.input[k] === null || tc.input[k] === "");
3645
+ if (missing.length) {
3646
+ return `Tool error: missing required parameter(s) for "${tc.name}": ${missing.join(", ")}. Expected: ${JSON.stringify(schema)}. Supply them and call the tool again.`;
3647
+ }
3648
+ if (schema.properties) {
3649
+ for (const [k, prop] of Object.entries(schema.properties)) {
3650
+ const allowed = Array.isArray(prop?.enum) ? prop.enum : null;
3651
+ if (allowed && tc.input[k] !== void 0 && !allowed.includes(tc.input[k])) {
3652
+ return `Tool error: invalid value for "${k}" in "${tc.name}": ${JSON.stringify(tc.input[k])}. Must be one of ${JSON.stringify(allowed)}.`;
3653
+ }
3654
+ }
3655
+ }
3656
+ return null;
3657
+ }
2964
3658
  async executeTool(tc) {
3659
+ if (tc.name === "request_workers") {
3660
+ const msg = this.recordReinforcements(tc.input);
3661
+ this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, output: msg, durationMs: 0 });
3662
+ return msg;
3663
+ }
3664
+ const validationError = this.validateToolInput(tc);
3665
+ if (validationError) {
3666
+ this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, error: validationError, durationMs: 0 });
3667
+ return validationError;
3668
+ }
2965
3669
  const needsApproval = this.toolRegistry.requiresApproval(tc.name);
2966
3670
  if (needsApproval) {
2967
3671
  if (this.permissionEscalator) {
@@ -2982,7 +3686,14 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2982
3686
  const wasApproved = this.sessionApprovals.get(tc.name);
2983
3687
  if (!wasApproved) return `Tool ${tc.name} was denied by user.`;
2984
3688
  } else {
3689
+ const LEGACY_APPROVAL_TIMEOUT_MS = 6e5;
2985
3690
  const legacyDecision = await new Promise((resolve) => {
3691
+ const eventName = `tool:approval-response:${this.id}-${tc.id}`;
3692
+ const timer = setTimeout(() => {
3693
+ this.removeAllListeners(eventName);
3694
+ resolve({ approved: false });
3695
+ }, LEGACY_APPROVAL_TIMEOUT_MS);
3696
+ timer.unref?.();
2986
3697
  this.emit("tool:approval-request", {
2987
3698
  id: `${this.id}-${tc.id}`,
2988
3699
  tierId: this.id,
@@ -2991,7 +3702,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2991
3702
  description: `T3 (${this.assignment?.subtaskTitle}) wants to run "${tc.name}"`,
2992
3703
  isDangerous: this.toolRegistry.isDangerous(tc.name)
2993
3704
  });
2994
- this.once(`tool:approval-response:${this.id}-${tc.id}`, resolve);
3705
+ this.once(eventName, (d) => {
3706
+ clearTimeout(timer);
3707
+ resolve(d);
3708
+ });
2995
3709
  });
2996
3710
  if (legacyDecision.always) this.sessionApprovals.set(tc.name, legacyDecision.approved);
2997
3711
  if (!legacyDecision.approved) return `Tool ${tc.name} was denied by user.`;
@@ -3010,8 +3724,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
3010
3724
  tierId: this.id,
3011
3725
  sessionId: this.taskId,
3012
3726
  requireApproval: false,
3013
- saveSnapshot: async (path17, content) => {
3014
- this.store?.addFileSnapshot(this.taskId, path17, content);
3727
+ saveSnapshot: async (path19, content) => {
3728
+ this.store?.addFileSnapshot(this.taskId, path19, content);
3015
3729
  },
3016
3730
  sendPeerSync: (to, syncType, content) => {
3017
3731
  this.peerBus?.send(this.id, to, syncType, this.assignment?.subtaskId ?? "", content);
@@ -3035,7 +3749,10 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
3035
3749
  const durationMs = Date.now() - toolStartMs;
3036
3750
  const errMsg = err instanceof Error ? err.message : String(err);
3037
3751
  this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, error: errMsg, durationMs });
3038
- return `Tool error: ${errMsg}`;
3752
+ if (/\b(429|rate.?limit|authentication|api.?key|forbidden|401|403)\b/i.test(errMsg)) {
3753
+ throw new CriticalToolError(errMsg, tc.name);
3754
+ }
3755
+ return await this.adaptiveFallback(tc, `Tool error: ${errMsg}`);
3039
3756
  }
3040
3757
  }
3041
3758
  /**
@@ -3113,6 +3830,11 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
3113
3830
  */
3114
3831
  async coordinateFileIntents(assignment) {
3115
3832
  if (!this.peerBus) return;
3833
+ const haystack = `${assignment.description}
3834
+ ${assignment.expectedOutput}`;
3835
+ if (!/\b(create|write|save|generate|produce|output|edit|update|modify|append|overwrite|rewrite)\b/i.test(haystack)) {
3836
+ return;
3837
+ }
3116
3838
  const plannedFiles = this.extractArtifactPaths(assignment);
3117
3839
  if (!plannedFiles.length) return;
3118
3840
  this.peerBus.broadcast(this.id, {
@@ -3123,16 +3845,22 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
3123
3845
  await new Promise((r) => setTimeout(r, 500));
3124
3846
  const sortedFiles = [...plannedFiles].sort();
3125
3847
  for (const filePath of sortedFiles) {
3126
- if (this.peerBus.isFileLocked(filePath)) {
3127
- this.log(`[T3] Waiting for file lock: ${filePath}`);
3128
- this.sendStatusUpdate({
3129
- progressPct: 5,
3130
- currentAction: `Waiting for peer to finish editing: ${filePath}`,
3131
- status: "IN_PROGRESS"
3848
+ try {
3849
+ if (this.peerBus.isFileLocked(filePath)) {
3850
+ this.log(`[T3] Waiting for file lock: ${filePath}`);
3851
+ this.sendStatusUpdate({
3852
+ progressPct: 5,
3853
+ currentAction: `Waiting for peer to finish editing: ${filePath}`,
3854
+ status: "IN_PROGRESS"
3855
+ });
3856
+ await this.peerBus.waitForFileRelease(filePath, 1e4).catch(() => {
3857
+ });
3858
+ }
3859
+ await this.peerBus.lockFile(this.id, filePath, 1e4).catch(() => {
3132
3860
  });
3133
- await this.peerBus.waitForFileRelease(filePath);
3861
+ } catch (err) {
3862
+ this.log(`[T3] Lock coordination skipped for ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
3134
3863
  }
3135
- await this.peerBus.lockFile(this.id, filePath);
3136
3864
  }
3137
3865
  const origPublish = this.peerBus.publish.bind(this.peerBus);
3138
3866
  const bus = this.peerBus;
@@ -3163,13 +3891,13 @@ ${assignment.expectedOutput}`;
3163
3891
  const artifactPaths = this.extractArtifactPaths(assignment);
3164
3892
  if (!artifactPaths.length) return { ok: true, issues: [] };
3165
3893
  const issues = [];
3166
- const { exec: exec3 } = await import('child_process');
3894
+ const { exec: exec2 } = await import('child_process');
3167
3895
  const { promisify: promisify4 } = await import('util');
3168
- const execAsync2 = promisify4(exec3);
3896
+ const execAsync2 = promisify4(exec2);
3169
3897
  for (const artifactPath of artifactPaths) {
3170
- const absolutePath = path16__default.default.resolve(process.cwd(), artifactPath);
3898
+ const absolutePath = path18__default.default.resolve(process.cwd(), artifactPath);
3171
3899
  try {
3172
- const stat = await fs3__default.default.stat(absolutePath);
3900
+ const stat = await fs4__default.default.stat(absolutePath);
3173
3901
  if (!stat.isFile()) {
3174
3902
  issues.push(`Expected artifact is not a file: ${artifactPath}`);
3175
3903
  continue;
@@ -3179,7 +3907,7 @@ ${assignment.expectedOutput}`;
3179
3907
  continue;
3180
3908
  }
3181
3909
  if (!/\.pdf$/i.test(artifactPath)) {
3182
- const content = await fs3__default.default.readFile(absolutePath, "utf-8");
3910
+ const content = await fs4__default.default.readFile(absolutePath, "utf-8");
3183
3911
  if (!content.trim()) {
3184
3912
  issues.push(`Artifact content is empty: ${artifactPath}`);
3185
3913
  continue;
@@ -3188,7 +3916,7 @@ ${assignment.expectedOutput}`;
3188
3916
  issues.push(`PDF artifact looks too small to be valid: ${artifactPath}`);
3189
3917
  continue;
3190
3918
  }
3191
- const ext = path16__default.default.extname(absolutePath).toLowerCase();
3919
+ const ext = path18__default.default.extname(absolutePath).toLowerCase();
3192
3920
  try {
3193
3921
  if (ext === ".ts" || ext === ".tsx") {
3194
3922
  await execAsync2(`npx tsc --noEmit ${absolutePath}`, { timeout: 1e4 });
@@ -3210,6 +3938,61 @@ ${stdout}`);
3210
3938
  }
3211
3939
  return { ok: issues.length === 0, issues };
3212
3940
  }
3941
+ /**
3942
+ * Reflection / self-critique: critique the output against the broader GOAL
3943
+ * (not just the subtask spec the self-test checks) and revise once if it falls
3944
+ * short. Two cheap calls per round — a JSON verdict, then a rewrite only if
3945
+ * needed. Best-effort: any parse/error just keeps the current output.
3946
+ */
3947
+ async reflectAndImprove(assignment, output, maxRounds) {
3948
+ const sys = this.systemPromptOverride + (this.hierarchyContext ? `
3949
+
3950
+ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "");
3951
+ let current = output;
3952
+ for (let round = 0; round < Math.max(1, maxRounds); round++) {
3953
+ try {
3954
+ const verdict = await this.router.generate("T3", {
3955
+ messages: [{
3956
+ role: "user",
3957
+ content: `Does this output FULLY achieve the goal \u2014 not just the literal task, but the intent behind it?
3958
+
3959
+ Goal / expected: ${assignment.expectedOutput}
3960
+ Subtask: ${assignment.description}
3961
+
3962
+ Output:
3963
+ ${current}
3964
+
3965
+ Reply with ONLY JSON: {"sufficient": true|false, "notes": "what is weak or missing if not sufficient"}`
3966
+ }],
3967
+ systemPrompt: sys,
3968
+ maxTokens: 400
3969
+ });
3970
+ const parsed = JSON.parse(/\{[\s\S]*\}/.exec(verdict.content)?.[0] ?? "{}");
3971
+ if (parsed.sufficient !== false) break;
3972
+ const improved = await this.router.generate("T3", {
3973
+ messages: [{
3974
+ role: "user",
3975
+ content: `Improve the following so it fully achieves the goal. Address specifically: ${parsed.notes ?? "gaps vs the goal"}.
3976
+ Output ONLY the improved result \u2014 no preamble, no commentary.
3977
+
3978
+ Goal / expected: ${assignment.expectedOutput}
3979
+
3980
+ Current output:
3981
+ ${current}`
3982
+ }],
3983
+ systemPrompt: sys,
3984
+ maxTokens: 4096
3985
+ });
3986
+ const next = (improved.content ?? "").trim();
3987
+ if (!next) break;
3988
+ current = next;
3989
+ this.log("Reflection: revised output for better goal alignment.");
3990
+ } catch {
3991
+ break;
3992
+ }
3993
+ }
3994
+ return current;
3995
+ }
3213
3996
  async selfTest(assignment, output) {
3214
3997
  const prompt = `Self-test this output against the assignment requirements.
3215
3998
 
@@ -3284,6 +4067,35 @@ ${assignment.constraints.map((c) => `- ${c}`).join("\n")}
3284
4067
 
3285
4068
  Begin execution now.`;
3286
4069
  }
4070
+ /**
4071
+ * Records a request_workers call (T3→T2 reinforcement). Capped at
4072
+ * maxPerSection; reinforcement workers (depth 1) cannot request more.
4073
+ */
4074
+ recordReinforcements(input) {
4075
+ if (this.reinforcementDepth !== 0) {
4076
+ return "request_workers is unavailable to reinforcement workers \u2014 complete your assigned subtask.";
4077
+ }
4078
+ const max = this.router.getReinforcementsConfig?.()?.maxPerSection ?? 4;
4079
+ const raw = Array.isArray(input.subtasks) ? input.subtasks : [];
4080
+ let added = 0;
4081
+ for (const s of raw) {
4082
+ if (this.pendingReinforcements.length >= max) break;
4083
+ const o = s;
4084
+ if (typeof o?.title !== "string" || typeof o?.description !== "string") continue;
4085
+ this.pendingReinforcements.push({
4086
+ subtaskId: `reinf-${this.id}-${this.pendingReinforcements.length + 1}`,
4087
+ subtaskTitle: o.title,
4088
+ description: o.description,
4089
+ expectedOutput: typeof o.expectedOutput === "string" ? o.expectedOutput : o.title,
4090
+ constraints: [],
4091
+ peerT3Ids: [],
4092
+ parentT2: this.parentId ?? "root",
4093
+ dependsOn: []
4094
+ });
4095
+ added++;
4096
+ }
4097
+ return added > 0 ? `Requested ${added} reinforcement worker(s) from your manager; they will run in parallel. Focus on your own part \u2014 do not redo their work.` : "No valid reinforcement subtasks (each needs a title and description), or the per-section limit was reached.";
4098
+ }
3287
4099
  buildResult(status, output, testResults, issues, correctionAttempts) {
3288
4100
  return {
3289
4101
  subtaskId: this.assignment?.subtaskId ?? "",
@@ -3292,7 +4104,8 @@ Begin execution now.`;
3292
4104
  testResults,
3293
4105
  issues,
3294
4106
  peerSyncsUsed: this.peerSyncBuffer.map((m) => m.fromId),
3295
- correctionAttempts
4107
+ correctionAttempts,
4108
+ reinforcements: this.pendingReinforcements.length ? this.pendingReinforcements : void 0
3296
4109
  };
3297
4110
  }
3298
4111
  isFileOperation(toolName) {
@@ -3311,6 +4124,17 @@ var PeerBus = class extends EventEmitter__default.default {
3311
4124
  /** Called when any peer message or broadcast is sent — used for dashboard visibility. */
3312
4125
  onPeerMessage;
3313
4126
  sessionId = "";
4127
+ /** Surface coordination traffic (locks, barriers) to the visibility hook. */
4128
+ emitCoordination(fromId, text) {
4129
+ this.onPeerMessage?.({
4130
+ fromId,
4131
+ toId: void 0,
4132
+ syncType: "COORDINATION",
4133
+ payload: text,
4134
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
4135
+ sessionId: this.sessionId
4136
+ });
4137
+ }
3314
4138
  register(peerId) {
3315
4139
  this.members.add(peerId);
3316
4140
  }
@@ -3447,8 +4271,10 @@ var PeerBus = class extends EventEmitter__default.default {
3447
4271
  const existing = this.fileLocks.get(filePath);
3448
4272
  if (!existing) {
3449
4273
  this.fileLocks.set(filePath, { holderId: tierId, lockedAt: (/* @__PURE__ */ new Date()).toISOString(), waiters: [] });
4274
+ this.emitCoordination(tierId, `\u{1F512} locked ${filePath}`);
3450
4275
  return;
3451
4276
  }
4277
+ this.emitCoordination(tierId, `\u23F3 waiting for ${filePath} (held by ${existing.holderId})`);
3452
4278
  return new Promise((resolve, reject) => {
3453
4279
  const timer = setTimeout(() => {
3454
4280
  reject(new Error(`File lock timeout for ${filePath} (held by ${existing.holderId})`));
@@ -3456,6 +4282,7 @@ var PeerBus = class extends EventEmitter__default.default {
3456
4282
  existing.waiters.push(() => {
3457
4283
  clearTimeout(timer);
3458
4284
  this.fileLocks.set(filePath, { holderId: tierId, lockedAt: (/* @__PURE__ */ new Date()).toISOString(), waiters: [] });
4285
+ this.emitCoordination(tierId, `\u{1F512} locked ${filePath}`);
3459
4286
  resolve();
3460
4287
  });
3461
4288
  });
@@ -3466,6 +4293,7 @@ var PeerBus = class extends EventEmitter__default.default {
3466
4293
  releaseFile(tierId, filePath) {
3467
4294
  const lock = this.fileLocks.get(filePath);
3468
4295
  if (!lock || lock.holderId !== tierId) return;
4296
+ this.emitCoordination(tierId, `\u{1F513} released ${filePath}`);
3469
4297
  const nextWaiter = lock.waiters.shift();
3470
4298
  if (nextWaiter) {
3471
4299
  nextWaiter();
@@ -3545,6 +4373,7 @@ var PeerBus = class extends EventEmitter__default.default {
3545
4373
  }
3546
4374
  const bar = this.barriers.get(barrierName);
3547
4375
  bar.arrived.add(peerId);
4376
+ this.emitCoordination(peerId, `\u22A8 barrier "${barrierName}" (${bar.arrived.size}/${bar.total})`);
3548
4377
  if (bar.arrived.size >= bar.total) {
3549
4378
  this.emit(`barrier:${barrierName}`);
3550
4379
  return;
@@ -3577,6 +4406,7 @@ var T2Manager = class extends BaseTier {
3577
4406
  router;
3578
4407
  toolRegistry;
3579
4408
  assignment;
4409
+ sectionModel;
3580
4410
  t3Workers = /* @__PURE__ */ new Map();
3581
4411
  escalations = [];
3582
4412
  peerSyncBuffer = [];
@@ -3586,6 +4416,8 @@ var T2Manager = class extends BaseTier {
3586
4416
  t2PeerBus;
3587
4417
  permissionEscalator;
3588
4418
  toolCreator;
4419
+ /** Optional boardroom gate (Moderate / root-T2 runs) — pauses after decomposition. */
4420
+ planApprovalCallback;
3589
4421
  /** AbortController for the current T3 wave — aborted on cancel-and-respawn */
3590
4422
  waveAbortController = null;
3591
4423
  setPeerBus(bus) {
@@ -3623,6 +4455,10 @@ var T2Manager = class extends BaseTier {
3623
4455
  setToolCreator(creator) {
3624
4456
  this.toolCreator = creator;
3625
4457
  }
4458
+ /** Boardroom gate for Moderate (root-T2) runs: pause after decomposition. */
4459
+ setPlanApprovalCallback(cb) {
4460
+ this.planApprovalCallback = cb;
4461
+ }
3626
4462
  /**
3627
4463
  * Phase 1 of T2 peer discussion: broadcast this section's plan so sibling T2s
3628
4464
  * and T1 can detect overlaps and coordinate execution order.
@@ -3676,9 +4512,39 @@ var T2Manager = class extends BaseTier {
3676
4512
  status: "IN_PROGRESS"
3677
4513
  });
3678
4514
  this.log(`T2 managing section: ${assignment.sectionTitle}`);
4515
+ this.sectionModel = void 0;
4516
+ try {
4517
+ const sectionText = `${assignment.sectionTitle} ${assignment.description} ${assignment.expectedOutput}`;
4518
+ this.sectionModel = await this.router.selectModelForSubtask("T2", sectionText) ?? void 0;
4519
+ if (this.sectionModel) {
4520
+ this.log(`Cascade Auto: routing this section to ${this.sectionModel.provider}:${this.sectionModel.id}`);
4521
+ }
4522
+ } catch {
4523
+ }
3679
4524
  try {
3680
4525
  this.throwIfCancelled();
3681
- const subtasks = assignment.t3Subtasks.length > 0 ? assignment.t3Subtasks : await this.decomposeSection(assignment);
4526
+ let subtasks = assignment.t3Subtasks.length > 0 ? assignment.t3Subtasks : await this.decomposeSection(assignment);
4527
+ if (this.planApprovalCallback) {
4528
+ const decision = await this.planApprovalCallback(subtasks, assignment.sectionTitle);
4529
+ if (!decision.approved) {
4530
+ const output = "Plan rejected \u2014 nothing was executed.";
4531
+ this.setStatus("COMPLETED", output);
4532
+ this.sendStatusUpdate({ progressPct: 100, currentAction: "Plan rejected by user", status: "IN_PROGRESS", output });
4533
+ return { sectionId: assignment.sectionId, sectionTitle: assignment.sectionTitle, status: "COMPLETED", t3Results: [], sectionSummary: output, issues: [] };
4534
+ }
4535
+ if (decision.keepSubtaskIds?.length) {
4536
+ const keep = new Set(decision.keepSubtaskIds);
4537
+ subtasks = subtasks.filter((s) => keep.has(s.subtaskId));
4538
+ }
4539
+ if (decision.note?.trim()) {
4540
+ subtasks = await this.decomposeSection({
4541
+ ...assignment,
4542
+ description: `${assignment.description}
4543
+
4544
+ Guidance (must be followed): ${decision.note}`
4545
+ });
4546
+ }
4547
+ }
3682
4548
  this.sendStatusUpdate({
3683
4549
  progressPct: 20,
3684
4550
  currentAction: `Dispatching ${subtasks.length} T3 workers`,
@@ -3752,7 +4618,8 @@ Return ONLY the JSON array.`;
3752
4618
  systemPrompt: this.systemPromptOverride + T2_SYSTEM_PROMPT + (this.hierarchyContext ? `
3753
4619
 
3754
4620
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3755
- maxTokens: 2e3
4621
+ maxTokens: 2e3,
4622
+ ...this.sectionModel ? { model: this.sectionModel } : {}
3756
4623
  });
3757
4624
  try {
3758
4625
  const jsonMatch = /\[[\s\S]*\]/.exec(result.content);
@@ -3856,6 +4723,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3856
4723
  let remaining = new Set(sanitizedAssignments.map((a) => a.subtaskId));
3857
4724
  let wave = 0;
3858
4725
  let respawnBudget = 1;
4726
+ const reinforceCfg = this.router.getReinforcementsConfig?.() ?? { enabled: false, maxPerSection: 4 };
4727
+ let reinforcementsAdded = 0;
3859
4728
  while (remaining.size > 0) {
3860
4729
  const runnableIds = [...remaining].filter((id) => (inDegree.get(id) ?? 0) === 0);
3861
4730
  if (runnableIds.length === 0) {
@@ -3880,15 +4749,27 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3880
4749
  const waveSignal = AbortSignal.any(
3881
4750
  [this.signal, this.waveAbortController.signal].filter(Boolean)
3882
4751
  );
3883
- const waveResults = await Promise.allSettled(
3884
- runnableIds.map(async (id) => {
3885
- const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3886
- const worker = workerMap.get(id);
3887
- const result = await worker.execute(assignment, taskId, waveSignal);
3888
- resultMap.set(id, result);
3889
- return result;
3890
- })
3891
- );
4752
+ const runOne = async (id) => {
4753
+ const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
4754
+ const worker = workerMap.get(id);
4755
+ const result = await worker.execute(assignment, taskId, waveSignal);
4756
+ resultMap.set(id, result);
4757
+ return result;
4758
+ };
4759
+ let waveResults;
4760
+ if (this.router.getT3ExecutionMode?.() === "sequential") {
4761
+ this.log(`Wave ${wave}: running ${runnableIds.length} subtask(s) sequentially (local tier)`);
4762
+ waveResults = [];
4763
+ for (const id of runnableIds) {
4764
+ try {
4765
+ waveResults.push({ status: "fulfilled", value: await runOne(id) });
4766
+ } catch (reason) {
4767
+ waveResults.push({ status: "rejected", reason });
4768
+ }
4769
+ }
4770
+ } else {
4771
+ waveResults = await Promise.allSettled(runnableIds.map(runOne));
4772
+ }
3892
4773
  const escalatedToolIdx = respawnBudget > 0 ? waveResults.findIndex(
3893
4774
  (r) => r.status === "fulfilled" && r.value.status === "ESCALATED" && r.value.issues.some((iss) => iss.includes("dynamic tool generation"))
3894
4775
  ) : -1;
@@ -3916,6 +4797,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3916
4797
  [SYSTEM]: Dynamic tool "${toolName}" is now available \u2014 use it to complete your task.`;
3917
4798
  }
3918
4799
  }
4800
+ const spec = this.toolCreator.getSpec(toolName);
4801
+ if (spec) this.t3PeerBus.broadcast(this.id, { type: "TOOL_CREATED", spec });
3919
4802
  }
3920
4803
  for (const id of runnableIds) {
3921
4804
  this.t3PeerBus.clearOutput(id);
@@ -3961,6 +4844,35 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3961
4844
  inDegree.set(dependent, Math.max(0, (inDegree.get(dependent) ?? 0) - 1));
3962
4845
  }
3963
4846
  }
4847
+ if (reinforceCfg.enabled && reinforcementsAdded < reinforceCfg.maxPerSection) {
4848
+ let addedThisWave = 0;
4849
+ for (const id of runnableIds) {
4850
+ for (const req of resultMap.get(id)?.reinforcements ?? []) {
4851
+ if (reinforcementsAdded >= reinforceCfg.maxPerSection) break;
4852
+ reinforcementsAdded++;
4853
+ addedThisWave++;
4854
+ const assignment = {
4855
+ ...req,
4856
+ subtaskId: `reinf-${this.id}-${reinforcementsAdded}`,
4857
+ dependsOn: [],
4858
+ peerT3Ids: []
4859
+ };
4860
+ sanitizedAssignments.push(assignment);
4861
+ adj.set(assignment.subtaskId, /* @__PURE__ */ new Set());
4862
+ inDegree.set(assignment.subtaskId, 0);
4863
+ remaining.add(assignment.subtaskId);
4864
+ const fresh = this.buildWorkerMap([assignment], taskId);
4865
+ for (const [k, v] of fresh) {
4866
+ v.markAsReinforcement();
4867
+ workerMap.set(k, v);
4868
+ }
4869
+ this.log(`Reinforcement: spawned worker "${assignment.subtaskTitle}" (requested by ${id})`);
4870
+ }
4871
+ }
4872
+ if (addedThisWave > 0) {
4873
+ this.sendStatusUpdate({ progressPct: 55, currentAction: `Added ${addedThisWave} reinforcement worker(s)`, status: "IN_PROGRESS" });
4874
+ }
4875
+ }
3964
4876
  }
3965
4877
  return [...resultMap.values()];
3966
4878
  }
@@ -4070,7 +4982,8 @@ NEW OUTPUTS TO INTEGRATE:
4070
4982
  systemPrompt: this.systemPromptOverride + "You are a T2 Manager. Summarize the work of your T3 workers succinctly." + (this.hierarchyContext ? `
4071
4983
 
4072
4984
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4073
- maxTokens: 500
4985
+ maxTokens: 500,
4986
+ ...this.sectionModel ? { model: this.sectionModel } : {}
4074
4987
  });
4075
4988
  currentSummary = result.content;
4076
4989
  } catch (err) {
@@ -4119,7 +5032,8 @@ Reply with exactly one word: YES, NO, or UNSURE.`;
4119
5032
 
4120
5033
  HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
4121
5034
  maxTokens: 10,
4122
- temperature: 0
5035
+ temperature: 0,
5036
+ ...this.sectionModel ? { model: this.sectionModel } : {}
4123
5037
  });
4124
5038
  const answer = result.content.trim().toUpperCase();
4125
5039
  if (answer.includes("YES")) {
@@ -4227,6 +5141,7 @@ var T1Administrator = class extends BaseTier {
4227
5141
  taskGoal = "";
4228
5142
  peerMessageCallback;
4229
5143
  peerMessageSessionId = "";
5144
+ planApprovalCallback;
4230
5145
  constructor(router, toolRegistry, config) {
4231
5146
  super("T1", "T1");
4232
5147
  this.router = router;
@@ -4253,6 +5168,17 @@ var T1Administrator = class extends BaseTier {
4253
5168
  this.t2PeerBus.onPeerMessage = cb;
4254
5169
  this.t2PeerBus.sessionId = sessionId;
4255
5170
  }
5171
+ /**
5172
+ * Install a "boardroom" gate: called with T1's plan BEFORE any T2 manager
5173
+ * spawns. When unset, plans proceed immediately (headless/SDK unchanged).
5174
+ */
5175
+ setPlanApprovalCallback(cb) {
5176
+ this.planApprovalCallback = cb;
5177
+ }
5178
+ /** Decompose a prompt into a plan WITHOUT executing it (powers /plan preview). */
5179
+ async previewPlan(prompt) {
5180
+ return this.decomposeTask(prompt);
5181
+ }
4256
5182
  async execute(userPrompt, images, systemContext, signal) {
4257
5183
  this.signal = signal;
4258
5184
  this.taskId = crypto.randomUUID();
@@ -4271,29 +5197,72 @@ var T1Administrator = class extends BaseTier {
4271
5197
  enrichedPrompt = await this.analyzeImages(userPrompt, images);
4272
5198
  }
4273
5199
  this.throwIfCancelled();
4274
- const plan = await this.decomposeTask(enrichedPrompt, systemContext);
4275
- this.sendStatusUpdate({
4276
- progressPct: 10,
4277
- currentAction: `Plan ready: ${plan.complexity} \u2192 ${plan.sections.length} sections`,
4278
- status: "IN_PROGRESS"
4279
- });
4280
- this.emit("plan", { taskId: this.taskId, plan });
4281
- this.throwIfCancelled();
5200
+ let plan = await this.decomposeTask(enrichedPrompt, systemContext);
5201
+ this.sendStatusUpdate({
5202
+ progressPct: 10,
5203
+ currentAction: `Plan ready: ${plan.complexity} \u2192 ${plan.sections.length} sections`,
5204
+ status: "IN_PROGRESS"
5205
+ });
5206
+ this.emit("plan", { taskId: this.taskId, plan });
5207
+ if (this.planApprovalCallback) {
5208
+ const maxRounds = this.config.planReview?.maxRevisionRounds ?? 5;
5209
+ const reviewer = this.config.planReview?.autoReviewer === true;
5210
+ let round = 0;
5211
+ for (; ; ) {
5212
+ const critique = reviewer ? await this.reviewPlan(plan, enrichedPrompt) ?? void 0 : void 0;
5213
+ this.sendStatusUpdate({
5214
+ progressPct: 10,
5215
+ currentAction: "Boardroom: waiting for plan approval",
5216
+ status: "IN_PROGRESS"
5217
+ });
5218
+ const decision = await this.planApprovalCallback(plan, { critique });
5219
+ if (!decision.approved) {
5220
+ const output2 = "Plan rejected in the boardroom \u2014 nothing was executed. Rephrase the request or adjust the plan with a new prompt.";
5221
+ this.setStatus("COMPLETED", output2);
5222
+ this.sendStatusUpdate({ progressPct: 100, currentAction: "Plan rejected by user", status: "IN_PROGRESS", output: output2 });
5223
+ return { output: output2, t2Results: [], taskId: this.taskId, complexity: plan.complexity };
5224
+ }
5225
+ if (decision.editedPlan?.sections?.length) {
5226
+ plan = decision.editedPlan;
5227
+ try {
5228
+ this.validatePlan(plan);
5229
+ } catch {
5230
+ }
5231
+ this.emit("plan", { taskId: this.taskId, plan });
5232
+ }
5233
+ if (decision.note?.trim() && round < maxRounds) {
5234
+ round++;
5235
+ this.log(`Boardroom note \u2014 re-planning (round ${round}/${maxRounds}): ${decision.note}`);
5236
+ plan = await this.decomposeTask(
5237
+ `${enrichedPrompt}
5238
+
5239
+ Board guidance (must be followed in the plan): ${decision.note}`,
5240
+ systemContext
5241
+ );
5242
+ this.emit("plan", { taskId: this.taskId, plan });
5243
+ continue;
5244
+ }
5245
+ break;
5246
+ }
5247
+ }
5248
+ this.throwIfCancelled();
4282
5249
  let allT2Results = await this.dispatchT2Managers(plan.sections);
4283
5250
  let pass = 1;
4284
- const MAX_REPLAN_PASSES = 2;
4285
- while (pass <= MAX_REPLAN_PASSES) {
5251
+ const maxReplanPasses = this.config.maxReplanPasses ?? 2;
5252
+ const okCount = (rs) => rs.filter((r) => r.status === "COMPLETED" || r.status === "PARTIAL").length;
5253
+ while (pass <= maxReplanPasses) {
4286
5254
  const reviewResult = await this.reviewT2Outputs(enrichedPrompt, plan, allT2Results);
4287
5255
  if (reviewResult.approved) {
4288
5256
  this.log("T1 Review passed.");
4289
5257
  break;
4290
5258
  }
4291
- this.log(`T1 Review rejected outputs. Replanning (Pass ${pass}). Reason: ${reviewResult.reason}`);
5259
+ this.log(`T1 Review rejected outputs. Replanning (Pass ${pass}/${maxReplanPasses}). Reason: ${reviewResult.reason}`);
4292
5260
  this.sendStatusUpdate({
4293
5261
  progressPct: 80 + pass * 5,
4294
5262
  currentAction: `Review failed: ${reviewResult.reason}. Replanning...`,
4295
5263
  status: "IN_PROGRESS"
4296
5264
  });
5265
+ const okBefore = okCount(allT2Results);
4297
5266
  const correctionPlan = await this.decomposeTask(`The previous execution plan failed to fully satisfy the original goal or encountered errors.
4298
5267
  Review reason: ${reviewResult.reason}
4299
5268
 
@@ -4302,6 +5271,10 @@ Original goal: ${enrichedPrompt}
4302
5271
  Create a CORRECTION PLAN that contains only the new sections needed to fix the issues. Do not repeat successful sections.`);
4303
5272
  const correctionResults = await this.dispatchT2Managers(correctionPlan.sections);
4304
5273
  allT2Results = [...allT2Results, ...correctionResults];
5274
+ if (okCount(allT2Results) <= okBefore) {
5275
+ this.log("T1 Review: corrective pass made no net progress \u2014 stopping early with the best partial result.");
5276
+ break;
5277
+ }
4305
5278
  pass++;
4306
5279
  }
4307
5280
  this.sendStatusUpdate({
@@ -4370,6 +5343,34 @@ If no, reply with "REJECTED: [Detailed reason explaining exactly what is missing
4370
5343
 
4371
5344
  [Image context: ${result.content}]`;
4372
5345
  }
5346
+ /**
5347
+ * Automated reviewer pass: a single T1 critique of the plan before the user
5348
+ * sees it (planReview.autoReviewer). Best-effort — returns null on any error
5349
+ * so it never blocks the approval gate.
5350
+ */
5351
+ async reviewPlan(plan, goal) {
5352
+ try {
5353
+ const sections = plan.sections.map((s, i) => `${i + 1}. ${s.sectionTitle} \u2014 ${s.description} (${s.t3Subtasks?.length ?? 0} subtasks${s.dependsOn?.length ? `, depends on: ${s.dependsOn.join(", ")}` : ""})`).join("\n");
5354
+ const prompt = `You are a senior engineer reviewing an execution plan BEFORE it runs.
5355
+
5356
+ GOAL:
5357
+ ${goal}
5358
+
5359
+ PLAN (${plan.complexity}, ${plan.sections.length} sections):
5360
+ ${sections}
5361
+
5362
+ In 3-5 terse bullets, flag the most important RISKS, GAPS, or over-/under-decomposition the operator should weigh before approving. If the plan is sound, say so in one line. Output plain-text bullets only - no preamble.`;
5363
+ const result = await this.router.generate("T1", {
5364
+ messages: [{ role: "user", content: prompt }],
5365
+ systemPrompt: "You are a concise, critical plan reviewer. Be specific and brief.",
5366
+ maxTokens: 400
5367
+ });
5368
+ const text = (result.content ?? "").trim();
5369
+ return text.length ? text : null;
5370
+ } catch {
5371
+ return null;
5372
+ }
5373
+ }
4373
5374
  async decomposeTask(prompt, systemContext) {
4374
5375
  const contextSection = systemContext ? `
4375
5376
  Project context:
@@ -4669,7 +5670,14 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
4669
5670
  async compileFinalOutput(originalPrompt, plan, t2Results) {
4670
5671
  const completedSections = t2Results.filter((r) => r.status !== "FAILED");
4671
5672
  if (!completedSections.length) {
4672
- return "Task failed \u2014 all sections encountered errors. Please check the escalation log.";
5673
+ const allIssues = t2Results.flatMap((r) => r.t3Results.flatMap((t) => t.issues));
5674
+ const critical = allIssues.find((i) => i.includes("[CRITICAL_TOOL_ERROR]"));
5675
+ const stalled = allIssues.find((i) => /^Stalled:/.test(i));
5676
+ const topReason = critical ?? stalled ?? allIssues[0] ?? "no specific reason recorded";
5677
+ const sectionWord = t2Results.length === 1 ? "section" : "sections";
5678
+ return `Task failed \u2014 ${topReason}
5679
+
5680
+ All ${t2Results.length} ${sectionWord} encountered errors. Run \`/logs\` for details.`;
4673
5681
  }
4674
5682
  const sectionsText = completedSections.map((r) => `**${r.sectionTitle}**
4675
5683
  ${r.sectionSummary}
@@ -4789,7 +5797,7 @@ var ShellTool = class extends BaseTool {
4789
5797
  const timeout = input["timeout"] ?? 3e4;
4790
5798
  this.validateCommand(command);
4791
5799
  try {
4792
- const { stdout, stderr } = await execAsync(command, { cwd, timeout });
5800
+ const { stdout, stderr } = await execAsync(command, { cwd, timeout, windowsHide: true });
4793
5801
  const out = [stdout, stderr].filter(Boolean).join("\n").trim();
4794
5802
  return out || "(no output)";
4795
5803
  } catch (err) {
@@ -4803,11 +5811,14 @@ ${[e.stdout, e.stderr].filter(Boolean).join("\n").trim()}`;
4803
5811
  }
4804
5812
  validateCommand(command) {
4805
5813
  const builtinDangerous = [
4806
- /rm\s+-rf\s+\//,
4807
- />\s*\/dev\/sda/,
4808
- /mkfs\./,
4809
- /dd\s+if=.*of=\/dev\//,
4810
- /chmod\s+777\s+\//
5814
+ /\brm\s+(?:-\w+\s+)*-\w*[rf]\w*[rf]\w*\s+(?:\/|~|\$HOME)(?:\s|$)/,
5815
+ // rm -rf / , rm -fr ~
5816
+ />\s*\/dev\/[sh]d[a-z]/,
5817
+ /\bmkfs[.\s]/,
5818
+ /\bdd\s+.*\bof=\/dev\/[sh]d[a-z]/,
5819
+ /\bchmod\s+(?:-\w+\s+)*-?R?\s*777\s+\//,
5820
+ /:\(\)\s*\{\s*:\s*\|\s*:?\s*&\s*\}\s*;/
5821
+ // fork bomb :(){ :|:& };:
4811
5822
  ];
4812
5823
  for (const pattern of builtinDangerous) {
4813
5824
  if (pattern.test(command)) {
@@ -4846,16 +5857,16 @@ function resolveInWorkspace(workspaceRoot, input) {
4846
5857
  if (typeof input !== "string" || input.length === 0) {
4847
5858
  throw new WorkspaceSandboxError(String(input), workspaceRoot);
4848
5859
  }
4849
- const root = path16__default.default.resolve(workspaceRoot);
4850
- const abs = path16__default.default.isAbsolute(input) ? path16__default.default.resolve(input) : path16__default.default.resolve(root, input);
4851
- const rel = path16__default.default.relative(root, abs);
4852
- if (rel === "" || rel === ".") ; else if (rel.startsWith("..") || path16__default.default.isAbsolute(rel)) {
5860
+ const root = path18__default.default.resolve(workspaceRoot);
5861
+ const abs = path18__default.default.isAbsolute(input) ? path18__default.default.resolve(input) : path18__default.default.resolve(root, input);
5862
+ const rel = path18__default.default.relative(root, abs);
5863
+ if (rel === "" || rel === ".") ; else if (rel.startsWith("..") || path18__default.default.isAbsolute(rel)) {
4853
5864
  throw new WorkspaceSandboxError(input, root);
4854
5865
  }
4855
5866
  try {
4856
- const real = fs15__default.default.realpathSync(abs);
4857
- const realRel = path16__default.default.relative(root, real);
4858
- if (realRel !== "" && realRel !== "." && (realRel.startsWith("..") || path16__default.default.isAbsolute(realRel))) {
5867
+ const real = fs17__default.default.realpathSync(abs);
5868
+ const realRel = path18__default.default.relative(root, real);
5869
+ if (realRel !== "" && realRel !== "." && (realRel.startsWith("..") || path18__default.default.isAbsolute(realRel))) {
4859
5870
  throw new WorkspaceSandboxError(input, root);
4860
5871
  }
4861
5872
  } catch (e) {
@@ -4882,7 +5893,7 @@ var FileReadTool = class extends BaseTool {
4882
5893
  const absPath = resolveInWorkspace(this.workspaceRoot, filePath);
4883
5894
  const offset = input["offset"] ?? 1;
4884
5895
  const limit = input["limit"];
4885
- const content = await fs3__default.default.readFile(absPath, "utf-8");
5896
+ const content = await fs4__default.default.readFile(absPath, "utf-8");
4886
5897
  const lines = content.split("\n");
4887
5898
  const start = Math.max(0, offset - 1);
4888
5899
  const end = limit ? start + limit : lines.length;
@@ -4911,13 +5922,13 @@ var FileWriteTool = class extends BaseTool {
4911
5922
  const content = input["content"];
4912
5923
  if (options.saveSnapshot) {
4913
5924
  try {
4914
- const oldContent = await fs3__default.default.readFile(absPath, "utf-8");
5925
+ const oldContent = await fs4__default.default.readFile(absPath, "utf-8");
4915
5926
  await options.saveSnapshot(absPath, oldContent);
4916
5927
  } catch {
4917
5928
  }
4918
5929
  }
4919
- await fs3__default.default.mkdir(path16__default.default.dirname(absPath), { recursive: true });
4920
- await fs3__default.default.writeFile(absPath, content, "utf-8");
5930
+ await fs4__default.default.mkdir(path18__default.default.dirname(absPath), { recursive: true });
5931
+ await fs4__default.default.writeFile(absPath, content, "utf-8");
4921
5932
  return `Written ${content.length} characters to ${filePath}`;
4922
5933
  }
4923
5934
  };
@@ -4943,7 +5954,7 @@ var FileEditTool = class extends BaseTool {
4943
5954
  const oldString = input["old_string"];
4944
5955
  const newString = input["new_string"];
4945
5956
  const replaceAll = input["replace_all"] ?? false;
4946
- const rawContent = await fs3__default.default.readFile(absPath, "utf-8");
5957
+ const rawContent = await fs4__default.default.readFile(absPath, "utf-8");
4947
5958
  if (options.saveSnapshot) {
4948
5959
  await options.saveSnapshot(absPath, rawContent);
4949
5960
  }
@@ -4955,7 +5966,7 @@ var FileEditTool = class extends BaseTool {
4955
5966
  );
4956
5967
  }
4957
5968
  const updated = replaceAll ? content.split(normalizedOld).join(newString) : content.replace(normalizedOld, newString);
4958
- await fs3__default.default.writeFile(absPath, updated, "utf-8");
5969
+ await fs4__default.default.writeFile(absPath, updated, "utf-8");
4959
5970
  const count = replaceAll ? content.split(normalizedOld).length - 1 : 1;
4960
5971
  return `Replaced ${count} occurrence(s) in ${filePath}`;
4961
5972
  }
@@ -4978,12 +5989,12 @@ var FileDeleteTool = class extends BaseTool {
4978
5989
  const absPath = resolveInWorkspace(this.workspaceRoot, filePath);
4979
5990
  if (options.saveSnapshot) {
4980
5991
  try {
4981
- const oldContent = await fs3__default.default.readFile(absPath, "utf-8");
5992
+ const oldContent = await fs4__default.default.readFile(absPath, "utf-8");
4982
5993
  await options.saveSnapshot(absPath, oldContent);
4983
5994
  } catch {
4984
5995
  }
4985
5996
  }
4986
- await fs3__default.default.rm(absPath, { recursive: false });
5997
+ await fs4__default.default.rm(absPath, { recursive: false });
4987
5998
  return `Deleted ${filePath}`;
4988
5999
  }
4989
6000
  };
@@ -5000,7 +6011,7 @@ var FileListTool = class extends BaseTool {
5000
6011
  async execute(input, _options) {
5001
6012
  const inputPath = input["path"] || ".";
5002
6013
  const absPath = resolveInWorkspace(this.workspaceRoot, inputPath);
5003
- const entries = await fs3__default.default.readdir(absPath, { withFileTypes: true });
6014
+ const entries = await fs4__default.default.readdir(absPath, { withFileTypes: true });
5004
6015
  return entries.map((e) => `${e.isDirectory() ? "[DIR] " : " "}${e.name}`).join("\n") || "(empty directory)";
5005
6016
  }
5006
6017
  };
@@ -5093,6 +6104,8 @@ var GitTool = class extends BaseTool {
5093
6104
  return lines.join("\n") || "Working tree clean";
5094
6105
  }
5095
6106
  };
6107
+
6108
+ // src/tools/github.ts
5096
6109
  var GitHubTool = class extends BaseTool {
5097
6110
  name = "github";
5098
6111
  description = "Interact with GitHub or GitLab: create PRs, list issues, comment on issues.";
@@ -5117,6 +6130,34 @@ var GitHubTool = class extends BaseTool {
5117
6130
  isDangerous() {
5118
6131
  return true;
5119
6132
  }
6133
+ // ── fetch helpers (replace axios) ──────────────
6134
+ async request(url, init) {
6135
+ const res = await fetch(url, init);
6136
+ const text = await res.text();
6137
+ let data;
6138
+ try {
6139
+ data = text ? JSON.parse(text) : void 0;
6140
+ } catch {
6141
+ data = text;
6142
+ }
6143
+ if (!res.ok) {
6144
+ const err = new Error(`HTTP ${res.status}`);
6145
+ err.status = res.status;
6146
+ err.data = data;
6147
+ throw err;
6148
+ }
6149
+ return data;
6150
+ }
6151
+ apiGet(url, headers) {
6152
+ return this.request(url, { headers });
6153
+ }
6154
+ apiPost(url, body, headers) {
6155
+ return this.request(url, {
6156
+ method: "POST",
6157
+ headers: { ...headers, "Content-Type": "application/json" },
6158
+ body: JSON.stringify(body)
6159
+ });
6160
+ }
5120
6161
  async execute(input, _options) {
5121
6162
  const platform = input["platform"] ?? "github";
5122
6163
  const operation = input["operation"];
@@ -5139,10 +6180,10 @@ var GitHubTool = class extends BaseTool {
5139
6180
  }
5140
6181
  return await this.executeGitLab(operation, repo, token, input);
5141
6182
  } catch (err) {
5142
- const axiosErr = err;
5143
- if (axiosErr?.response?.status) {
5144
- const status = axiosErr.response.status;
5145
- const msg = axiosErr.response.data?.message ?? "";
6183
+ const httpErr = err;
6184
+ if (httpErr?.status) {
6185
+ const status = httpErr.status;
6186
+ const msg = httpErr.data?.message ?? "";
5146
6187
  switch (status) {
5147
6188
  case 401:
5148
6189
  return `Authentication failed: Your ${platform} token is invalid or expired. Check your token and try again.`;
@@ -5155,10 +6196,10 @@ var GitHubTool = class extends BaseTool {
5155
6196
  case 429:
5156
6197
  return `Rate limited by ${platform}. Please wait a moment before trying again.`;
5157
6198
  default:
5158
- return `${platform} API error (${status}): ${msg || (axiosErr.message ?? "Unknown error")}`;
6199
+ return `${platform} API error (${status}): ${msg || (httpErr.message ?? "Unknown error")}`;
5159
6200
  }
5160
6201
  }
5161
- return `${platform} request failed: ${axiosErr.message ?? String(err)}`;
6202
+ return `${platform} request failed: ${httpErr.message ?? String(err)}`;
5162
6203
  }
5163
6204
  }
5164
6205
  async executeGitHub(operation, repo, token, input) {
@@ -5169,35 +6210,35 @@ var GitHubTool = class extends BaseTool {
5169
6210
  const base = `https://api.github.com/repos/${repo}`;
5170
6211
  switch (operation) {
5171
6212
  case "list_issues": {
5172
- const response = await axios2__default.default.get(`${base}/issues`, { headers });
5173
- return response.data.map((i) => `#${i.number} [${i.state}] ${i.title}`).join("\n");
6213
+ const data = await this.apiGet(`${base}/issues`, headers);
6214
+ return data.map((i) => `#${i.number} [${i.state}] ${i.title}`).join("\n");
5174
6215
  }
5175
6216
  case "list_prs": {
5176
- const response = await axios2__default.default.get(`${base}/pulls`, { headers });
5177
- return response.data.map((p) => `#${p.number} [${p.state}] ${p.title} (${p.head.ref} \u2192 ${p.base.ref})`).join("\n");
6217
+ const data = await this.apiGet(`${base}/pulls`, headers);
6218
+ return data.map((p) => `#${p.number} [${p.state}] ${p.title} (${p.head.ref} \u2192 ${p.base.ref})`).join("\n");
5178
6219
  }
5179
6220
  case "create_pr": {
5180
- const response = await axios2__default.default.post(`${base}/pulls`, {
6221
+ const data = await this.apiPost(`${base}/pulls`, {
5181
6222
  title: input["title"],
5182
6223
  body: input["body"] ?? "",
5183
6224
  head: input["head"],
5184
6225
  base: input["base"] ?? "main"
5185
- }, { headers });
5186
- return `Created PR #${response.data.number}: ${response.data.html_url}`;
6226
+ }, headers);
6227
+ return `Created PR #${data.number}: ${data.html_url}`;
5187
6228
  }
5188
6229
  case "comment_issue": {
5189
6230
  const num = input["issue_number"];
5190
- await axios2__default.default.post(`${base}/issues/${num}/comments`, { body: input["body"] }, { headers });
6231
+ await this.apiPost(`${base}/issues/${num}/comments`, { body: input["body"] }, headers);
5191
6232
  return `Comment added to #${num}`;
5192
6233
  }
5193
6234
  case "get_pr": {
5194
6235
  const num = input["issue_number"];
5195
- const response = await axios2__default.default.get(`${base}/pulls/${num}`, { headers });
5196
- return `PR #${num}: ${response.data.title}
5197
- State: ${response.data.state}
5198
- ${response.data.html_url}
6236
+ const data = await this.apiGet(`${base}/pulls/${num}`, headers);
6237
+ return `PR #${num}: ${data.title}
6238
+ State: ${data.state}
6239
+ ${data.html_url}
5199
6240
 
5200
- ${response.data.body}`;
6241
+ ${data.body}`;
5201
6242
  }
5202
6243
  default:
5203
6244
  throw new Error(`Unknown GitHub operation: ${operation}`);
@@ -5209,35 +6250,35 @@ ${response.data.body}`;
5209
6250
  const base = `https://gitlab.com/api/v4/projects/${encodedRepo}`;
5210
6251
  switch (operation) {
5211
6252
  case "list_issues": {
5212
- const response = await axios2__default.default.get(`${base}/issues`, { headers });
5213
- return response.data.map((i) => `#${i.iid} [${i.state}] ${i.title}`).join("\n");
6253
+ const data = await this.apiGet(`${base}/issues`, headers);
6254
+ return data.map((i) => `#${i.iid} [${i.state}] ${i.title}`).join("\n");
5214
6255
  }
5215
6256
  case "create_pr": {
5216
- const response = await axios2__default.default.post(`${base}/merge_requests`, {
6257
+ const data = await this.apiPost(`${base}/merge_requests`, {
5217
6258
  title: input["title"],
5218
6259
  description: input["body"] ?? "",
5219
6260
  source_branch: input["head"],
5220
6261
  target_branch: input["base"] ?? "main"
5221
- }, { headers });
5222
- return `Created MR !${response.data.iid}: ${response.data.web_url}`;
6262
+ }, headers);
6263
+ return `Created MR !${data.iid}: ${data.web_url}`;
5223
6264
  }
5224
6265
  case "list_prs": {
5225
- const response = await axios2__default.default.get(`${base}/merge_requests`, { headers });
5226
- return response.data.map((p) => `!${p.iid} [${p.state}] ${p.title} (${p.source_branch} \u2192 ${p.target_branch})`).join("\n");
6266
+ const data = await this.apiGet(`${base}/merge_requests`, headers);
6267
+ return data.map((p) => `!${p.iid} [${p.state}] ${p.title} (${p.source_branch} \u2192 ${p.target_branch})`).join("\n");
5227
6268
  }
5228
6269
  case "comment_issue": {
5229
6270
  const num = input["issue_number"];
5230
- await axios2__default.default.post(`${base}/issues/${num}/notes`, { body: input["body"] }, { headers });
6271
+ await this.apiPost(`${base}/issues/${num}/notes`, { body: input["body"] }, headers);
5231
6272
  return `Comment added to #${num}`;
5232
6273
  }
5233
6274
  case "get_pr": {
5234
6275
  const num = input["issue_number"];
5235
- const response = await axios2__default.default.get(`${base}/merge_requests/${num}`, { headers });
5236
- return `MR !${num}: ${response.data.title}
5237
- State: ${response.data.state}
5238
- ${response.data.web_url}
6276
+ const data = await this.apiGet(`${base}/merge_requests/${num}`, headers);
6277
+ return `MR !${num}: ${data.title}
6278
+ State: ${data.state}
6279
+ ${data.web_url}
5239
6280
 
5240
- ${response.data.description}`;
6281
+ ${data.description}`;
5241
6282
  }
5242
6283
  default:
5243
6284
  throw new Error(`GitLab operation not supported: ${operation}`);
@@ -5383,8 +6424,8 @@ var ImageAnalyzeTool = class extends BaseTool {
5383
6424
  }
5384
6425
  };
5385
6426
  async function fileToImageAttachment(filePath) {
5386
- const data = await fs3__default.default.readFile(filePath);
5387
- const ext = path16__default.default.extname(filePath).toLowerCase();
6427
+ const data = await fs4__default.default.readFile(filePath);
6428
+ const ext = path18__default.default.extname(filePath).toLowerCase();
5388
6429
  const mimeMap = {
5389
6430
  ".jpg": "image/jpeg",
5390
6431
  ".jpeg": "image/jpeg",
@@ -5418,14 +6459,14 @@ var PDFCreateTool = class extends BaseTool {
5418
6459
  const filePath = input["path"];
5419
6460
  const content = input["content"];
5420
6461
  const title = input["title"];
5421
- const dir = path16__default.default.dirname(filePath);
5422
- if (!fs15__default.default.existsSync(dir)) {
5423
- fs15__default.default.mkdirSync(dir, { recursive: true });
6462
+ const dir = path18__default.default.dirname(filePath);
6463
+ if (!fs17__default.default.existsSync(dir)) {
6464
+ fs17__default.default.mkdirSync(dir, { recursive: true });
5424
6465
  }
5425
6466
  return new Promise((resolve, reject) => {
5426
6467
  try {
5427
6468
  const doc = new PDFDocument__default.default({ margin: 50 });
5428
- const stream = fs15__default.default.createWriteStream(filePath);
6469
+ const stream = fs17__default.default.createWriteStream(filePath);
5429
6470
  doc.pipe(stream);
5430
6471
  if (title) {
5431
6472
  doc.info["Title"] = title;
@@ -5503,24 +6544,22 @@ var CodeInterpreterTool = class extends BaseTool {
5503
6544
  }
5504
6545
  cmdPrefix = NODE_CMD;
5505
6546
  }
5506
- const tmpDir = path16__default.default.join(process.cwd(), ".cascade", "tmp");
5507
- if (!fs15__default.default.existsSync(tmpDir)) {
5508
- fs15__default.default.mkdirSync(tmpDir, { recursive: true });
6547
+ const tmpDir = path18__default.default.join(this.workspaceRoot, ".cascade", "tmp");
6548
+ if (!fs17__default.default.existsSync(tmpDir)) {
6549
+ fs17__default.default.mkdirSync(tmpDir, { recursive: true });
5509
6550
  }
5510
6551
  const extension = language === "python" ? "py" : "js";
5511
6552
  const fileName = `intp_${crypto.randomUUID().slice(0, 8)}.${extension}`;
5512
- const filePath = path16__default.default.join(tmpDir, fileName);
5513
- fs15__default.default.writeFileSync(filePath, code, "utf-8");
5514
- const quotedPath = `"${filePath}"`;
5515
- const quotedArgs = args.map((a) => `"${a}"`).join(" ");
5516
- const fullCmd = `${cmdPrefix} ${quotedPath}${quotedArgs ? " " + quotedArgs : ""}`;
6553
+ const filePath = path18__default.default.join(tmpDir, fileName);
6554
+ fs17__default.default.writeFileSync(filePath, code, "utf-8");
6555
+ const execArgs = [filePath, ...args];
5517
6556
  return new Promise((resolve) => {
5518
6557
  const startMs = Date.now();
5519
- child_process.exec(fullCmd, { cwd: process.cwd(), timeout: 3e4 }, (error, stdout, stderr) => {
6558
+ child_process.execFile(cmdPrefix, execArgs, { cwd: this.workspaceRoot, timeout: 3e4 }, (error, stdout, stderr) => {
5520
6559
  const duration = Date.now() - startMs;
5521
6560
  try {
5522
- if (fs15__default.default.existsSync(filePath)) {
5523
- fs15__default.default.unlinkSync(filePath);
6561
+ if (fs17__default.default.existsSync(filePath)) {
6562
+ fs17__default.default.unlinkSync(filePath);
5524
6563
  }
5525
6564
  } catch (cleanupErr) {
5526
6565
  console.error(`Failed to cleanup interpreter script ${filePath}:`, cleanupErr);
@@ -5799,7 +6838,7 @@ var GlobTool = class extends BaseTool {
5799
6838
  };
5800
6839
  async execute(input, _options) {
5801
6840
  const pattern = input["pattern"];
5802
- const searchPath = input["path"] ? path16__default.default.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
6841
+ const searchPath = input["path"] ? path18__default.default.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
5803
6842
  const matches = await glob.glob(pattern, {
5804
6843
  cwd: searchPath,
5805
6844
  ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
@@ -5812,7 +6851,7 @@ var GlobTool = class extends BaseTool {
5812
6851
  const withMtime = await Promise.all(
5813
6852
  matches.map(async (rel) => {
5814
6853
  try {
5815
- const stat = await fs3__default.default.stat(path16__default.default.join(searchPath, rel));
6854
+ const stat = await fs4__default.default.stat(path18__default.default.join(searchPath, rel));
5816
6855
  return { rel, mtime: stat.mtimeMs };
5817
6856
  } catch {
5818
6857
  return { rel, mtime: 0 };
@@ -5861,7 +6900,7 @@ var GrepTool = class extends BaseTool {
5861
6900
  };
5862
6901
  async execute(input, _options) {
5863
6902
  const pattern = input["pattern"];
5864
- const searchPath = input["path"] ? path16__default.default.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
6903
+ const searchPath = input["path"] ? path18__default.default.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
5865
6904
  const globPattern = input["glob"];
5866
6905
  const outputMode = input["output_mode"] ?? "content";
5867
6906
  const context = input["context"] ?? 0;
@@ -5915,15 +6954,15 @@ var GrepTool = class extends BaseTool {
5915
6954
  nodir: true
5916
6955
  });
5917
6956
  } catch {
5918
- files = [path16__default.default.relative(searchPath, searchPath) || "."];
6957
+ files = [path18__default.default.relative(searchPath, searchPath) || "."];
5919
6958
  }
5920
6959
  const results = [];
5921
6960
  let totalCount = 0;
5922
6961
  for (const rel of files) {
5923
- const abs = path16__default.default.join(searchPath, rel);
6962
+ const abs = path18__default.default.join(searchPath, rel);
5924
6963
  let content;
5925
6964
  try {
5926
- content = await fs3__default.default.readFile(abs, "utf-8");
6965
+ content = await fs4__default.default.readFile(abs, "utf-8");
5927
6966
  } catch {
5928
6967
  continue;
5929
6968
  }
@@ -5961,6 +7000,92 @@ Total: ${totalCount} matches`);
5961
7000
  return results.join("\n");
5962
7001
  }
5963
7002
  };
7003
+ var SsrfBlockedError = class extends Error {
7004
+ constructor(message) {
7005
+ super(message);
7006
+ this.name = "SsrfBlockedError";
7007
+ }
7008
+ };
7009
+ var ALLOWED_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
7010
+ var MAX_REDIRECTS = 5;
7011
+ function allowLocal() {
7012
+ return process.env["CASCADE_ALLOW_LOCAL_FETCH"] === "1";
7013
+ }
7014
+ function isPrivateAddress(ip) {
7015
+ const type = net__default.default.isIP(ip);
7016
+ if (type === 4) return isPrivateIPv4(ip);
7017
+ if (type === 6) return isPrivateIPv6(ip);
7018
+ return true;
7019
+ }
7020
+ function isPrivateIPv4(ip) {
7021
+ const parts = ip.split(".").map((p) => Number(p));
7022
+ if (parts.length !== 4 || parts.some((p) => Number.isNaN(p) || p < 0 || p > 255)) return true;
7023
+ const [a, b] = parts;
7024
+ if (a === 0) return true;
7025
+ if (a === 10) return true;
7026
+ if (a === 127) return true;
7027
+ if (a === 169 && b === 254) return true;
7028
+ if (a === 172 && b >= 16 && b <= 31) return true;
7029
+ if (a === 192 && b === 168) return true;
7030
+ if (a === 100 && b >= 64 && b <= 127) return true;
7031
+ if (a >= 224) return true;
7032
+ return false;
7033
+ }
7034
+ function isPrivateIPv6(ip) {
7035
+ const lower = ip.toLowerCase().replace(/^\[|\]$/g, "");
7036
+ if (lower === "::1" || lower === "::") return true;
7037
+ if (lower.startsWith("fe80")) return true;
7038
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
7039
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(lower);
7040
+ if (mapped) return isPrivateIPv4(mapped[1]);
7041
+ return false;
7042
+ }
7043
+ async function assertPublicUrl(rawUrl) {
7044
+ let url;
7045
+ try {
7046
+ url = new URL(rawUrl);
7047
+ } catch {
7048
+ throw new SsrfBlockedError(`Invalid URL: ${rawUrl}`);
7049
+ }
7050
+ if (!ALLOWED_PROTOCOLS.has(url.protocol)) {
7051
+ throw new SsrfBlockedError(`Blocked URL scheme "${url.protocol}" \u2014 only http and https are allowed.`);
7052
+ }
7053
+ if (allowLocal()) return url;
7054
+ const host = url.hostname.replace(/^\[|\]$/g, "");
7055
+ if (net__default.default.isIP(host)) {
7056
+ if (isPrivateAddress(host)) {
7057
+ throw new SsrfBlockedError(`Blocked request to non-public address ${host}.`);
7058
+ }
7059
+ return url;
7060
+ }
7061
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) {
7062
+ throw new SsrfBlockedError(`Blocked request to local hostname "${host}".`);
7063
+ }
7064
+ let addresses;
7065
+ try {
7066
+ const records = await dns__default.default.lookup(host, { all: true });
7067
+ addresses = records.map((r) => r.address);
7068
+ } catch {
7069
+ throw new SsrfBlockedError(`Could not resolve host "${host}".`);
7070
+ }
7071
+ if (addresses.length === 0 || addresses.some((addr) => isPrivateAddress(addr))) {
7072
+ throw new SsrfBlockedError(`Blocked request to "${host}" \u2014 resolves to a non-public address.`);
7073
+ }
7074
+ return url;
7075
+ }
7076
+ async function safeFetch(rawUrl, init = {}) {
7077
+ let currentUrl = (await assertPublicUrl(rawUrl)).toString();
7078
+ for (let i = 0; i <= MAX_REDIRECTS; i++) {
7079
+ const resp = await fetch(currentUrl, { ...init, redirect: "manual" });
7080
+ if (resp.status < 300 || resp.status >= 400) return resp;
7081
+ const location = resp.headers.get("location");
7082
+ if (!location) return resp;
7083
+ const next = new URL(location, currentUrl);
7084
+ await assertPublicUrl(next.toString());
7085
+ currentUrl = next.toString();
7086
+ }
7087
+ throw new SsrfBlockedError(`Too many redirects (>${MAX_REDIRECTS}).`);
7088
+ }
5964
7089
 
5965
7090
  // src/tools/web-fetch.ts
5966
7091
  var MAX_CHARS = 5e4;
@@ -5994,15 +7119,17 @@ var WebFetchTool = class extends BaseTool {
5994
7119
  const url = input["url"];
5995
7120
  let resp;
5996
7121
  try {
5997
- resp = await fetch(url, {
7122
+ resp = await safeFetch(url, {
5998
7123
  headers: {
5999
7124
  "User-Agent": "Cascade-AI/1.0 WebFetchTool",
6000
7125
  Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5"
6001
7126
  },
6002
- signal: AbortSignal.timeout(TIMEOUT_MS),
6003
- redirect: "follow"
7127
+ signal: AbortSignal.timeout(TIMEOUT_MS)
6004
7128
  });
6005
7129
  } catch (err) {
7130
+ if (err instanceof SsrfBlockedError) {
7131
+ return `Refused to fetch ${url}: ${err.message}`;
7132
+ }
6006
7133
  return `Failed to fetch ${url}: ${err instanceof Error ? err.message : String(err)}`;
6007
7134
  }
6008
7135
  if (!resp.ok) {
@@ -6194,10 +7321,10 @@ var ToolRegistry = class extends EventEmitter__default.default {
6194
7321
  }
6195
7322
  isIgnored(filePath) {
6196
7323
  if (!filePath) return false;
6197
- const abs = path16__default.default.resolve(this.workspaceRoot, filePath);
6198
- const rel = path16__default.default.relative(this.workspaceRoot, abs);
6199
- if (!rel || rel.startsWith("..") || path16__default.default.isAbsolute(rel)) return true;
6200
- const posixRel = rel.split(path16__default.default.sep).join("/");
7324
+ const abs = path18__default.default.resolve(this.workspaceRoot, filePath);
7325
+ const rel = path18__default.default.relative(this.workspaceRoot, abs);
7326
+ if (!rel || rel.startsWith("..") || path18__default.default.isAbsolute(rel)) return true;
7327
+ const posixRel = rel.split(path18__default.default.sep).join("/");
6201
7328
  return this.ignoreMatcher.ignores(posixRel);
6202
7329
  }
6203
7330
  };
@@ -6221,9 +7348,11 @@ var McpClient = class _McpClient {
6221
7348
  tools = /* @__PURE__ */ new Map();
6222
7349
  trustedServers;
6223
7350
  approvalCallback;
7351
+ onWarn;
6224
7352
  constructor(options = {}) {
6225
7353
  this.trustedServers = new Set(options.trustedServers ?? []);
6226
7354
  this.approvalCallback = options.approvalCallback;
7355
+ this.onWarn = options.onWarn ?? ((message) => console.warn(message));
6227
7356
  }
6228
7357
  async connect(server) {
6229
7358
  if (!this.trustedServers.has(server.name)) {
@@ -6252,7 +7381,7 @@ var McpClient = class _McpClient {
6252
7381
  for (const tool of toolsResult.tools) {
6253
7382
  for (const existing of this.tools.values()) {
6254
7383
  if (existing.name === tool.name && existing.serverName !== server.name) {
6255
- console.warn(
7384
+ this.onWarn(
6256
7385
  `[mcp] Tool "${tool.name}" is exposed by both "${existing.serverName}" and "${server.name}". Cascade disambiguates internally via mcp::<server>::<tool>.`
6257
7386
  );
6258
7387
  break;
@@ -6332,6 +7461,19 @@ var PermissionEscalator = class extends EventEmitter__default.default {
6332
7461
  t1Evaluator;
6333
7462
  /** Pending user-decision resolvers keyed by request ID */
6334
7463
  pendingUserDecisions = /* @__PURE__ */ new Map();
7464
+ /** ms to wait for a user approval decision before denying for safety. */
7465
+ approvalTimeoutMs;
7466
+ /** Autonomous mode (autonomy: 'auto'): non-dangerous tools auto-approve. */
7467
+ autonomous;
7468
+ constructor(approvalTimeoutMs = 6e5, autonomous = false) {
7469
+ super();
7470
+ this.approvalTimeoutMs = approvalTimeoutMs;
7471
+ this.autonomous = autonomous;
7472
+ }
7473
+ /** Toggle autonomous auto-approval at runtime (e.g. from /auto). */
7474
+ setAutonomous(on) {
7475
+ this.autonomous = on;
7476
+ }
6335
7477
  setT2Evaluator(evaluator) {
6336
7478
  this.t2Evaluator = evaluator;
6337
7479
  }
@@ -6344,7 +7486,7 @@ var PermissionEscalator = class extends EventEmitter__default.default {
6344
7486
  */
6345
7487
  async requestPermission(req) {
6346
7488
  const cacheKey = `${req.parentT2Id}:${req.toolName}`;
6347
- if (this.sessionCache.has(cacheKey)) {
7489
+ if (!req.forceReprompt && this.sessionCache.has(cacheKey)) {
6348
7490
  return {
6349
7491
  requestId: req.id,
6350
7492
  approved: this.sessionCache.get(cacheKey),
@@ -6364,6 +7506,15 @@ var PermissionEscalator = class extends EventEmitter__default.default {
6364
7506
  this.sessionCache.set(cacheKey, true);
6365
7507
  return decision;
6366
7508
  }
7509
+ if (this.autonomous && !req.isDangerous) {
7510
+ return {
7511
+ requestId: req.id,
7512
+ approved: true,
7513
+ always: false,
7514
+ decidedBy: "T1",
7515
+ reasoning: "Autonomous mode \u2014 non-dangerous tool auto-approved"
7516
+ };
7517
+ }
6367
7518
  if (this.t2Evaluator) {
6368
7519
  try {
6369
7520
  const t2Decision = await this.t2Evaluator(req);
@@ -6404,13 +7555,28 @@ var PermissionEscalator = class extends EventEmitter__default.default {
6404
7555
  }
6405
7556
  waitForUserDecision(req) {
6406
7557
  return new Promise((resolve) => {
7558
+ let timer;
6407
7559
  const wrappedResolver = (decision) => {
7560
+ if (timer) clearTimeout(timer);
6408
7561
  if (decision.always) {
6409
7562
  this.sessionCache.set(`${req.parentT2Id}:${req.toolName}`, decision.approved);
6410
7563
  }
6411
7564
  resolve(decision);
6412
7565
  };
6413
7566
  this.pendingUserDecisions.set(req.id, wrappedResolver);
7567
+ if (this.approvalTimeoutMs > 0 && Number.isFinite(this.approvalTimeoutMs)) {
7568
+ timer = setTimeout(() => {
7569
+ if (this.pendingUserDecisions.delete(req.id)) {
7570
+ resolve({
7571
+ requestId: req.id,
7572
+ approved: false,
7573
+ decidedBy: "USER",
7574
+ reasoning: `Approval timed out after ${this.approvalTimeoutMs}ms \u2014 denied for safety`
7575
+ });
7576
+ }
7577
+ }, this.approvalTimeoutMs);
7578
+ timer.unref?.();
7579
+ }
6414
7580
  this.emit("permission:user-required", req);
6415
7581
  });
6416
7582
  }
@@ -6428,11 +7594,14 @@ var PermissionEscalator = class extends EventEmitter__default.default {
6428
7594
  };
6429
7595
  var ProviderConfigSchema = zod.z.object({
6430
7596
  type: zod.z.enum(["anthropic", "openai", "gemini", "azure", "openai-compatible", "ollama"]),
7597
+ label: zod.z.string().optional(),
6431
7598
  apiKey: zod.z.string().optional(),
6432
7599
  baseUrl: zod.z.string().url().optional(),
6433
7600
  deploymentName: zod.z.string().optional(),
6434
7601
  apiVersion: zod.z.string().optional(),
6435
- model: zod.z.string().optional()
7602
+ model: zod.z.string().optional(),
7603
+ authToken: zod.z.string().optional(),
7604
+ credentialSource: zod.z.string().optional()
6436
7605
  });
6437
7606
  var ModelOverridesSchema = zod.z.object({
6438
7607
  t1: zod.z.string().optional(),
@@ -6462,10 +7631,12 @@ var ToolsConfigSchema = zod.z.object({
6462
7631
  requireApprovalFor: zod.z.array(zod.z.string()).default([]),
6463
7632
  browserEnabled: zod.z.boolean().default(false),
6464
7633
  mcpServers: zod.z.array(McpServerConfigSchema).optional(),
7634
+ mcpTrusted: zod.z.array(zod.z.string()).optional(),
6465
7635
  /** Web search backends — at least one should be configured for best results */
6466
7636
  webSearch: WebSearchConfigSchema.optional()
6467
7637
  });
6468
7638
  var HookDefinitionSchema = zod.z.object({
7639
+ name: zod.z.string().optional(),
6469
7640
  command: zod.z.string(),
6470
7641
  tools: zod.z.array(zod.z.string()).optional(),
6471
7642
  timeout: zod.z.number().optional()
@@ -6478,6 +7649,13 @@ var HooksConfigSchema = zod.z.object({
6478
7649
  });
6479
7650
  var DashboardConfigSchema = zod.z.object({
6480
7651
  port: zod.z.number().default(4891),
7652
+ /**
7653
+ * Interface to bind the dashboard HTTP/WebSocket server to. Defaults to
7654
+ * loopback so the dashboard — which exposes /api/run (arbitrary task
7655
+ * execution) and config mutation — is never reachable from the network
7656
+ * unless the operator explicitly opts in (e.g. "0.0.0.0" for team mode).
7657
+ */
7658
+ host: zod.z.string().default("127.0.0.1"),
6481
7659
  auth: zod.z.boolean().default(true),
6482
7660
  teamMode: zod.z.enum(["single", "multi"]).default("single"),
6483
7661
  secret: zod.z.string().optional()
@@ -6500,6 +7678,15 @@ var TierLimitsSchema = zod.z.object({
6500
7678
  var BudgetConfigSchema = zod.z.object({
6501
7679
  dailyBudgetUsd: zod.z.number().optional(),
6502
7680
  sessionBudgetUsd: zod.z.number().optional(),
7681
+ /**
7682
+ * Hard per-task token ceiling. A single `cascade run` is stopped once its
7683
+ * combined token usage crosses this, so a mis-routed trivial task can never
7684
+ * fan out into a runaway multi-agent burn. Resets every run. Raise it for
7685
+ * genuinely large jobs. Defaults to 200k.
7686
+ */
7687
+ maxTokensPerRun: zod.z.number().int().positive().default(2e5),
7688
+ /** Optional hard per-task cost ceiling (USD). Unset = only the token cap applies. */
7689
+ maxCostPerRunUsd: zod.z.number().positive().optional(),
6503
7690
  warnAtPct: zod.z.number().default(80)
6504
7691
  });
6505
7692
  var WorkspaceConfigSchema = zod.z.object({
@@ -6528,6 +7715,32 @@ var CascadeConfigSchema = zod.z.object({
6528
7715
  * Heuristic-first with AI inference fallback (adds ~0–500ms per task).
6529
7716
  */
6530
7717
  cascadeAuto: zod.z.boolean().default(false),
7718
+ /**
7719
+ * Cascade Auto trade-off bias when picking a model for a task:
7720
+ * - 'balanced' (default): quality × cost-efficiency — cheap models win
7721
+ * trivial tasks, strong models win hard ones.
7722
+ * - 'quality': pick the highest-benchmark model; cost only breaks ties.
7723
+ * - 'cost': pick the cheapest model that clears a per-task quality floor.
7724
+ */
7725
+ autoBias: zod.z.enum(["balanced", "quality", "cost"]).default("balanced"),
7726
+ /**
7727
+ * Public-benchmark data source for Cascade Auto. All fields have safe
7728
+ * defaults so zero config "just works" — live data is fetched in the
7729
+ * background and the bundled snapshot is used until it arrives (or offline).
7730
+ */
7731
+ benchmarks: zod.z.object({
7732
+ /** Fetch current quality scores from a public source. Default: true. */
7733
+ live: zod.z.boolean().default(true),
7734
+ /** How long a fetched snapshot stays fresh before re-fetching (hours). */
7735
+ refreshHours: zod.z.number().min(0).default(24),
7736
+ /**
7737
+ * Override the quality-benchmark source URL (must return the snapshot
7738
+ * JSON shape). When unset, the maintained GitHub-raw snapshot is used.
7739
+ */
7740
+ sourceUrl: zod.z.string().url().optional(),
7741
+ /** Fetch current per-token prices from OpenRouter (free, no key). */
7742
+ pricingLive: zod.z.boolean().default(true)
7743
+ }).default({}),
6531
7744
  /**
6532
7745
  * Runtime Tool Creation: when true, T3 workers can generate and register new tools
6533
7746
  * at runtime via the ToolCreator when no existing tool can handle a required operation.
@@ -6535,6 +7748,13 @@ var CascadeConfigSchema = zod.z.object({
6535
7748
  * HTTP calls from generated tools require approval.
6536
7749
  */
6537
7750
  enableToolCreation: zod.z.boolean().default(true),
7751
+ /**
7752
+ * Persist runtime-generated tools to .cascade/dynamic-tools.json and reload them
7753
+ * on startup for cross-run dedup. Reloaded (and peer-received) tools are always
7754
+ * treated as UNTRUSTED — their dangerous actions re-escalate. Set false to disable
7755
+ * persistence entirely.
7756
+ */
7757
+ persistDynamicTools: zod.z.boolean().default(true),
6538
7758
  /**
6539
7759
  * External plugin paths or npm package names to load at startup.
6540
7760
  * Each entry must export a default ToolPlugin object.
@@ -6551,7 +7771,89 @@ var CascadeConfigSchema = zod.z.object({
6551
7771
  * Timeout in milliseconds for a single local model inference call.
6552
7772
  * Local models can take minutes for large parameter counts. Default: 5 minutes.
6553
7773
  */
6554
- localInferenceTimeoutMs: zod.z.number().int().min(1e3).default(3e5)
7774
+ localInferenceTimeoutMs: zod.z.number().int().min(1e3).default(3e5),
7775
+ /**
7776
+ * Timeout (ms) for a single cloud LLM call (streaming or not). Guards against
7777
+ * a stalled provider stream hanging the whole run with no output. On timeout
7778
+ * the call errors and the worker escalates. Default: 2 minutes.
7779
+ */
7780
+ cloudInferenceTimeoutMs: zod.z.number().int().min(1e3).default(12e4),
7781
+ /**
7782
+ * Timeout (ms) for a tool-approval decision. If no decision arrives in time the
7783
+ * request is DENIED (never auto-approved) so the run continues rather than
7784
+ * hanging on an unanswered prompt. Default: 10 minutes.
7785
+ */
7786
+ approvalTimeoutMs: zod.z.number().int().min(1e3).default(6e5),
7787
+ /**
7788
+ * Boardroom plan approval: pause after the plan is produced so the user can
7789
+ * review the org chart (sections, workers, estimated cost) before any worker
7790
+ * spawns. Scope:
7791
+ * 'never' — never pause (default; no behavior change).
7792
+ * 'complex' — pause Complex runs only ('always' is kept as an alias).
7793
+ * 'all' — pause Moderate and Complex runs.
7794
+ * Headless/SDK consumers without a listener auto-approve, so pausing is safe
7795
+ * outside the TUI.
7796
+ */
7797
+ planApproval: zod.z.enum(["never", "complex", "all", "always"]).default("never"),
7798
+ /**
7799
+ * Plan-review behaviour for the boardroom gate:
7800
+ * autoReviewer — a reviewer model critiques the plan (gaps/risks/cost)
7801
+ * before you see it, and the critique is shown in the dialog.
7802
+ * editable — allow editing the plan (drop sections) in the dialog.
7803
+ * maxRevisionRounds — how many steering-note → re-plan → re-ask rounds the
7804
+ * boardroom allows before proceeding with the last plan.
7805
+ */
7806
+ planReview: zod.z.object({
7807
+ autoReviewer: zod.z.boolean().default(false),
7808
+ editable: zod.z.boolean().default(true),
7809
+ maxRevisionRounds: zod.z.number().int().min(1).max(20).default(5)
7810
+ }).default({}),
7811
+ /**
7812
+ * Autonomy level. 'manual' (default): plan + tool approvals prompt as usual.
7813
+ * 'auto': hands-off — the plan gate auto-approves and the escalator
7814
+ * auto-approves NON-dangerous tools, while dangerous tools still escalate and
7815
+ * budget caps remain the hard stop. Toggle at runtime with /auto.
7816
+ */
7817
+ autonomy: zod.z.enum(["manual", "auto"]).default("manual"),
7818
+ /**
7819
+ * Max corrective re-plan passes T1's reviewer runs before returning the best
7820
+ * partial result. The run also stops early when a pass makes no net progress.
7821
+ */
7822
+ maxReplanPasses: zod.z.number().int().min(0).max(10).default(2),
7823
+ /**
7824
+ * Reflection / self-critique. When enabled, after a worker's pass/fail self-test
7825
+ * succeeds it runs a goal-alignment critique and revises once if the output is
7826
+ * weak against the broader goal (not just the subtask spec). Off by default — it
7827
+ * adds an LLM call per worker.
7828
+ */
7829
+ reflection: zod.z.object({
7830
+ enabled: zod.z.boolean().default(false),
7831
+ maxRounds: zod.z.number().int().min(1).max(3).default(1)
7832
+ }).default({}),
7833
+ /**
7834
+ * T3 worker execution within a dependency wave:
7835
+ * 'auto' (default) — sequential when the T3 tier is a LOCAL model (a single
7836
+ * GPU serializes anyway, so parallel just thrashes the queue), parallel for
7837
+ * cloud models.
7838
+ * 'parallel' / 'sequential' — force it.
7839
+ */
7840
+ t3Execution: zod.z.enum(["auto", "parallel", "sequential"]).default("auto"),
7841
+ /**
7842
+ * T3→T2 reinforcement: when enabled, a worker that discovers its subtask should
7843
+ * fan out can call the `request_workers` tool to have its T2 manager spawn
7844
+ * sibling workers for the new pieces (no 4th tier; bounded). Off by default.
7845
+ */
7846
+ reinforcements: zod.z.object({
7847
+ enabled: zod.z.boolean().default(false),
7848
+ maxPerSection: zod.z.number().int().min(1).max(20).default(4)
7849
+ }).default({}),
7850
+ /**
7851
+ * Render the TUI in the terminal's alternate screen buffer (like vim).
7852
+ * Flicker-proof and restores the shell on exit, but native scrollback is
7853
+ * unavailable — history scrolls in-app with PgUp/PgDn. Also enabled per
7854
+ * session with the --alt-screen flag. Default: off.
7855
+ */
7856
+ altScreen: zod.z.boolean().default(false)
6555
7857
  });
6556
7858
 
6557
7859
  // src/config/validate.ts
@@ -6689,14 +7991,20 @@ var TASK_TYPE_TAGS = {
6689
7991
  };
6690
7992
  var TaskAnalyzer = class {
6691
7993
  tracker;
7994
+ bias;
6692
7995
  lastProfile = null;
6693
7996
  lastSelectedModels = /* @__PURE__ */ new Map();
6694
- constructor(tracker) {
7997
+ constructor(tracker, bias = "balanced") {
6695
7998
  this.tracker = tracker;
7999
+ this.bias = bias;
6696
8000
  }
6697
8001
  setTracker(tracker) {
6698
8002
  this.tracker = tracker;
6699
8003
  }
8004
+ /** Change the cost/quality bias at runtime (e.g. when config reloads). */
8005
+ setBias(bias) {
8006
+ this.bias = bias;
8007
+ }
6700
8008
  /** Returns the TaskProfile from the most recent analyze() call — used for outcome recording. */
6701
8009
  getLastProfile() {
6702
8010
  return this.lastProfile;
@@ -6756,7 +8064,16 @@ var TaskAnalyzer = class {
6756
8064
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
6757
8065
  const costEff = this.costEfficiency(model, profile.complexity);
6758
8066
  const match = this.taskMatchScore(model, profile);
6759
- return perf * costEff * match;
8067
+ const benchmark = 0.3 + 0.7 * benchmarkScore01(model, profile.type);
8068
+ switch (this.bias) {
8069
+ case "quality":
8070
+ return perf * match * benchmark ** 2 * (0.85 + 0.15 * costEff);
8071
+ case "cost":
8072
+ return perf * match * costEff ** 1.5 * Math.sqrt(benchmark);
8073
+ case "balanced":
8074
+ default:
8075
+ return perf * costEff * match * benchmark;
8076
+ }
6760
8077
  }
6761
8078
  costEfficiency(model, complexity) {
6762
8079
  if (this.tracker) return this.tracker.costEfficiencyScore(model, complexity);
@@ -6776,7 +8093,7 @@ var TaskAnalyzer = class {
6776
8093
  analysisCache.clear();
6777
8094
  }
6778
8095
  };
6779
- var DEFAULT_STATS_FILE = path16__default.default.join(os3__default.default.homedir(), ".cascade", "model-perf.json");
8096
+ var DEFAULT_STATS_FILE = path18__default.default.join(os4__default.default.homedir(), ".cascade", "model-perf.json");
6780
8097
  var ModelPerformanceTracker = class {
6781
8098
  stats = /* @__PURE__ */ new Map();
6782
8099
  statsFile;
@@ -6788,7 +8105,7 @@ var ModelPerformanceTracker = class {
6788
8105
  if (this.loaded) return;
6789
8106
  this.loaded = true;
6790
8107
  try {
6791
- const raw = await fs3__default.default.readFile(this.statsFile, "utf-8");
8108
+ const raw = await fs4__default.default.readFile(this.statsFile, "utf-8");
6792
8109
  const parsed = JSON.parse(raw);
6793
8110
  for (const [key, stat] of Object.entries(parsed)) {
6794
8111
  this.stats.set(key, stat);
@@ -6798,10 +8115,10 @@ var ModelPerformanceTracker = class {
6798
8115
  }
6799
8116
  async save() {
6800
8117
  try {
6801
- await fs3__default.default.mkdir(path16__default.default.dirname(this.statsFile), { recursive: true });
8118
+ await fs4__default.default.mkdir(path18__default.default.dirname(this.statsFile), { recursive: true });
6802
8119
  const obj = {};
6803
8120
  for (const [key, stat] of this.stats) obj[key] = stat;
6804
- await fs3__default.default.writeFile(this.statsFile, JSON.stringify(obj, null, 2), "utf-8");
8121
+ await fs4__default.default.writeFile(this.statsFile, JSON.stringify(obj, null, 2), "utf-8");
6805
8122
  } catch {
6806
8123
  }
6807
8124
  }
@@ -6849,6 +8166,96 @@ var ModelPerformanceTracker = class {
6849
8166
  return Math.max(0.1, 1 - normalised * complexityWeight);
6850
8167
  }
6851
8168
  };
8169
+ var DYNAMIC_TOOLS_FILE = "dynamic-tools.json";
8170
+ function normalizeToolSchema(schema) {
8171
+ if (schema && schema["type"] === "object" && typeof schema["properties"] === "object") {
8172
+ return schema;
8173
+ }
8174
+ const properties = schema && typeof schema === "object" ? schema : {};
8175
+ return {
8176
+ type: "object",
8177
+ properties,
8178
+ required: Object.keys(properties)
8179
+ };
8180
+ }
8181
+ function capabilityKey(text) {
8182
+ return Array.from(
8183
+ new Set((text.toLowerCase().match(/[a-z0-9]+/g) ?? []).filter((w) => w.length > 2))
8184
+ ).sort().join(" ");
8185
+ }
8186
+ var DYNAMIC_TOOL_TIMEOUT_MS = 15e3;
8187
+ var DYNAMIC_FETCH_MAX = 1e6;
8188
+ var HARNESS_SRC = `
8189
+ const { parentPort, workerData } = require('node:worker_threads');
8190
+ const { executeCode, input } = workerData;
8191
+ let nextId = 0;
8192
+ const pending = new Map();
8193
+ function bridge(kind, payload) {
8194
+ return new Promise((resolve, reject) => {
8195
+ const id = nextId++;
8196
+ pending.set(id, { resolve, reject });
8197
+ parentPort.postMessage(Object.assign({ kind, id }, payload));
8198
+ });
8199
+ }
8200
+ parentPort.on('message', (msg) => {
8201
+ const p = pending.get(msg.id);
8202
+ if (!p) return;
8203
+ pending.delete(msg.id);
8204
+ if (msg.error !== undefined) p.reject(new Error(msg.error));
8205
+ else p.resolve(msg.value);
8206
+ });
8207
+ const callTool = (name, toolInput) => bridge('callTool', { name: name, input: toolInput });
8208
+ const fetch = async (url, init) => {
8209
+ const safeInit = init && typeof init === 'object'
8210
+ ? { method: init.method, headers: init.headers, body: typeof init.body === 'string' ? init.body : undefined }
8211
+ : undefined;
8212
+ const r = await bridge('fetch', { url: url, init: safeInit });
8213
+ return {
8214
+ ok: r.ok, status: r.status, statusText: r.statusText,
8215
+ headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? r.contentType : null) },
8216
+ text: async () => r.body,
8217
+ json: async () => JSON.parse(r.body),
8218
+ };
8219
+ };
8220
+ (async () => {
8221
+ const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
8222
+ const fn = new AsyncFunction('input', 'callTool', 'fetch', 'console', executeCode);
8223
+ return await fn(input, callTool, fetch, { log() {}, error() {} });
8224
+ })()
8225
+ .then((r) => parentPort.postMessage({ kind: 'result', value: String(r == null ? '' : r) }))
8226
+ .catch((e) => parentPort.postMessage({ kind: 'result', value: 'Tool error: ' + (e && e.message ? e.message : String(e)) }));
8227
+ `;
8228
+ function isExecutableToolCode(code) {
8229
+ try {
8230
+ const AsyncFunction = Object.getPrototypeOf(async function() {
8231
+ }).constructor;
8232
+ new AsyncFunction("input", "callTool", "fetch", "console", code);
8233
+ return true;
8234
+ } catch {
8235
+ return false;
8236
+ }
8237
+ }
8238
+ async function bridgeFetch(url, init) {
8239
+ try {
8240
+ const i = init && typeof init === "object" ? init : {};
8241
+ const resp = await safeFetch(url, {
8242
+ method: typeof i["method"] === "string" ? i["method"] : void 0,
8243
+ headers: i["headers"],
8244
+ body: typeof i["body"] === "string" ? i["body"] : void 0
8245
+ });
8246
+ const contentType = resp.headers.get("content-type") ?? "";
8247
+ let body = "";
8248
+ try {
8249
+ body = await resp.text();
8250
+ } catch {
8251
+ body = "";
8252
+ }
8253
+ if (body.length > DYNAMIC_FETCH_MAX) body = body.slice(0, DYNAMIC_FETCH_MAX);
8254
+ return { ok: resp.ok, status: resp.status, statusText: resp.statusText, contentType, body };
8255
+ } catch (err) {
8256
+ return { __error: err instanceof Error ? err.message : String(err) };
8257
+ }
8258
+ }
6852
8259
  var DynamicTool = class extends BaseTool {
6853
8260
  name;
6854
8261
  description;
@@ -6856,8 +8263,12 @@ var DynamicTool = class extends BaseTool {
6856
8263
  executeCode;
6857
8264
  _isDangerous;
6858
8265
  registry;
6859
- escalator;
6860
- constructor(spec, registry, escalator) {
8266
+ /** Resolve the CURRENT escalator at call time — covers tools registered before
8267
+ * the per-run escalator was wired (persisted at init, received from a peer). */
8268
+ getEscalator;
8269
+ /** Untrusted = loaded from disk / a peer; its dangerous calls always re-prompt. */
8270
+ trusted;
8271
+ constructor(spec, registry, getEscalator, trusted) {
6861
8272
  super();
6862
8273
  this.name = spec.name;
6863
8274
  this.description = spec.description;
@@ -6865,32 +8276,35 @@ var DynamicTool = class extends BaseTool {
6865
8276
  this.executeCode = spec.executeCode;
6866
8277
  this._isDangerous = spec.isDangerous;
6867
8278
  this.registry = registry;
6868
- this.escalator = escalator;
8279
+ this.getEscalator = getEscalator;
8280
+ this.trusted = trusted;
6869
8281
  }
6870
8282
  isDangerous() {
6871
8283
  return this._isDangerous;
6872
8284
  }
6873
8285
  async execute(input, options) {
6874
8286
  const registry = this.registry;
6875
- const escalator = this.escalator;
6876
8287
  const callTool = async (toolName, toolInput) => {
6877
8288
  if (!registry.hasTool(toolName)) return `Tool not found: ${toolName}`;
6878
8289
  if (registry.isDangerous(toolName)) {
6879
- if (escalator) {
6880
- const req = {
6881
- id: `dynamic-${this.name}-${toolName}-${Date.now()}`,
6882
- requestedBy: `dynamic_tool:${this.name}`,
6883
- parentT2Id: options.tierId,
6884
- toolName,
6885
- input: toolInput,
6886
- isDangerous: true,
6887
- subtaskContext: `Dynamic tool "${this.name}" requesting access to "${toolName}"`,
6888
- sectionContext: `Dynamic tool "${this.name}"`
6889
- };
6890
- const decision = await escalator.requestPermission(req);
6891
- if (!decision.approved) {
6892
- return `Permission denied for ${toolName} (decided by ${decision.decidedBy}).`;
6893
- }
8290
+ const escalator = this.getEscalator();
8291
+ if (!escalator) {
8292
+ return `Permission denied for "${toolName}": dynamic tool "${this.name}" has no approver available (default-deny).`;
8293
+ }
8294
+ const req = {
8295
+ id: `dynamic-${this.name}-${toolName}-${Date.now()}`,
8296
+ requestedBy: `dynamic_tool:${this.name}`,
8297
+ parentT2Id: options.tierId,
8298
+ toolName,
8299
+ input: toolInput,
8300
+ isDangerous: true,
8301
+ subtaskContext: `Dynamic tool "${this.name}" (${this.trusted ? "trusted" : "UNTRUSTED"}) requesting access to "${toolName}"`,
8302
+ sectionContext: `Dynamic tool "${this.name}"`,
8303
+ forceReprompt: !this.trusted
8304
+ };
8305
+ const decision = await escalator.requestPermission(req);
8306
+ if (!decision.approved) {
8307
+ return `Permission denied for ${toolName} (decided by ${decision.decidedBy}).`;
6894
8308
  }
6895
8309
  }
6896
8310
  try {
@@ -6900,41 +8314,52 @@ var DynamicTool = class extends BaseTool {
6900
8314
  return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
6901
8315
  }
6902
8316
  };
6903
- const sandbox = {
6904
- input,
6905
- fetch: globalThis.fetch,
6906
- callTool,
6907
- JSON,
6908
- Math,
6909
- Date,
6910
- console: { log: () => {
6911
- }, error: () => {
6912
- } },
6913
- setTimeout,
6914
- clearTimeout,
6915
- Promise,
6916
- Error,
6917
- String,
6918
- Number,
6919
- Boolean,
6920
- Array,
6921
- Object,
6922
- result: void 0
6923
- };
6924
- const context = vm.createContext(sandbox);
6925
- const wrapped = `(async () => { ${this.executeCode} })().then(r => { result = String(r ?? ''); }).catch(e => { result = 'Tool error: ' + e.message; });`;
6926
- try {
6927
- const promise = vm.runInContext(wrapped, context, {
6928
- timeout: 15e3,
6929
- breakOnSigint: true,
6930
- filename: `dynamic_tool_${this.name}.js`,
6931
- displayErrors: true
8317
+ return this.runInWorker(input, callTool);
8318
+ }
8319
+ /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
8320
+ runInWorker(input, callTool) {
8321
+ const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
8322
+ return new Promise((resolve) => {
8323
+ let settled = false;
8324
+ const worker = new worker_threads.Worker(HARNESS_SRC, {
8325
+ eval: true,
8326
+ workerData: { executeCode: this.executeCode, input },
8327
+ resourceLimits: { maxOldGenerationSizeMb: 128 }
6932
8328
  });
6933
- await promise;
6934
- return sandbox["result"] ?? "";
6935
- } catch (err) {
6936
- return `Dynamic tool error: ${err instanceof Error ? err.message : String(err)}`;
6937
- }
8329
+ const finish = (value) => {
8330
+ if (settled) return;
8331
+ settled = true;
8332
+ clearTimeout(timer);
8333
+ void worker.terminate();
8334
+ resolve(value);
8335
+ };
8336
+ const timer = setTimeout(
8337
+ () => finish(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`),
8338
+ timeoutMs
8339
+ );
8340
+ timer.unref?.();
8341
+ worker.on("message", (msg) => {
8342
+ if (msg?.kind === "result") {
8343
+ finish(typeof msg.value === "string" ? msg.value : String(msg.value ?? ""));
8344
+ } else if (msg?.kind === "callTool") {
8345
+ void (async () => {
8346
+ const value = await callTool(String(msg.name), msg.input ?? {});
8347
+ if (!settled) worker.postMessage({ id: msg.id, value });
8348
+ })();
8349
+ } else if (msg?.kind === "fetch") {
8350
+ void (async () => {
8351
+ const r = await bridgeFetch(String(msg.url), msg.init);
8352
+ if (settled) return;
8353
+ if ("__error" in r) worker.postMessage({ id: msg.id, error: r.__error });
8354
+ else worker.postMessage({ id: msg.id, value: r });
8355
+ })();
8356
+ }
8357
+ });
8358
+ worker.on("error", (err) => finish(`Dynamic tool error: ${err instanceof Error ? err.message : String(err)}`));
8359
+ worker.on("exit", (code) => {
8360
+ if (code !== 0) finish(`Dynamic tool "${this.name}" exited unexpectedly (code ${code}).`);
8361
+ });
8362
+ });
6938
8363
  }
6939
8364
  };
6940
8365
  var TOOL_CREATOR_PROMPT = `You are a tool-generation assistant for the Cascade AI system.
@@ -6967,52 +8392,153 @@ var ToolCreator = class {
6967
8392
  router;
6968
8393
  registry;
6969
8394
  escalator;
6970
- createdTools = /* @__PURE__ */ new Set();
6971
- constructor(router, registry) {
8395
+ workspacePath;
8396
+ /** When false, persisted tools are neither loaded nor written. */
8397
+ persistEnabled;
8398
+ logger;
8399
+ /** name → spec, for persistence, broadcast, and re-registration. */
8400
+ specs = /* @__PURE__ */ new Map();
8401
+ /** capability fingerprint → tool name, so the same need isn't re-generated. */
8402
+ capabilityIndex = /* @__PURE__ */ new Map();
8403
+ constructor(router, registry, workspacePath, persistEnabled = true) {
6972
8404
  this.router = router;
6973
8405
  this.registry = registry;
8406
+ this.workspacePath = workspacePath;
8407
+ this.persistEnabled = persistEnabled;
6974
8408
  }
6975
8409
  setPermissionEscalator(escalator) {
6976
8410
  this.escalator = escalator;
6977
8411
  }
8412
+ /** Route diagnostics through the host (Cascade) so they survive the Ink TUI. */
8413
+ setLogger(fn) {
8414
+ this.logger = fn;
8415
+ }
8416
+ /** Returns the stored spec for a created tool (for peer broadcast). */
8417
+ getSpec(name) {
8418
+ return this.specs.get(name);
8419
+ }
8420
+ log(msg) {
8421
+ if (this.logger) this.logger(msg);
8422
+ }
6978
8423
  /**
6979
8424
  * Generate a new tool from a description and register it with the ToolRegistry.
6980
- * The generated tool has access to all registered cascade tools via callTool().
6981
- * Returns the tool name if successful, null if generation failed.
8425
+ * Returns the tool name on success, or null on failure (with a logged reason —
8426
+ * failures are no longer swallowed silently). Reuses an existing tool when the
8427
+ * same capability has already been created (dedup) so peers/runs don't
8428
+ * regenerate identical tools.
6982
8429
  */
6983
8430
  async createTool(description, context) {
8431
+ const key = capabilityKey(`${description} ${context}`);
8432
+ const existing = this.capabilityIndex.get(key);
8433
+ if (existing && this.registry.hasTool(existing)) {
8434
+ this.log(`[tool-creator] Reusing existing tool "${existing}" for: ${description.slice(0, 80)}`);
8435
+ return existing;
8436
+ }
6984
8437
  const prompt = `${TOOL_CREATOR_PROMPT}
6985
8438
 
6986
8439
  Task context: ${context.slice(0, 200)}
6987
8440
  Required capability: ${description.slice(0, 300)}`;
8441
+ let spec = null;
8442
+ for (let attempt = 1; attempt <= 2 && !spec; attempt++) {
8443
+ try {
8444
+ const result = await this.router.generate("T3", {
8445
+ messages: [{ role: "user", content: prompt }],
8446
+ maxTokens: 800
8447
+ });
8448
+ const jsonMatch = /\{[\s\S]*\}/.exec(result.content);
8449
+ if (!jsonMatch) {
8450
+ this.log(`[tool-creator] Attempt ${attempt}: model returned no JSON object.`);
8451
+ continue;
8452
+ }
8453
+ const parsed = JSON.parse(jsonMatch[0]);
8454
+ if (!parsed.name || !parsed.description || !parsed.executeCode || !parsed.inputSchema) {
8455
+ this.log(`[tool-creator] Attempt ${attempt}: spec missing required fields (name/description/executeCode/inputSchema).`);
8456
+ continue;
8457
+ }
8458
+ spec = parsed;
8459
+ } catch (err) {
8460
+ this.log(`[tool-creator] Attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`);
8461
+ }
8462
+ }
8463
+ if (!spec) {
8464
+ this.log(`[tool-creator] Could not generate a tool for: ${description.slice(0, 80)}`);
8465
+ return null;
8466
+ }
8467
+ spec.inputSchema = normalizeToolSchema(spec.inputSchema);
8468
+ if (this.specs.has(spec.name) || this.registry.hasTool(spec.name)) {
8469
+ spec.name = `${spec.name}_${Date.now() % 1e4}`;
8470
+ }
8471
+ if (!isExecutableToolCode(spec.executeCode)) {
8472
+ this.log(`[tool-creator] Generated code for "${spec.name}" has a syntax error \u2014 discarded.`);
8473
+ return null;
8474
+ }
8475
+ this.registerSpec(spec, true);
8476
+ this.capabilityIndex.set(key, spec.name);
8477
+ this.log(`[tool-creator] Created tool "${spec.name}".`);
8478
+ void this.persist();
8479
+ return spec.name;
8480
+ }
8481
+ /**
8482
+ * Register a spec (from createTool, disk, or a peer) into the registry.
8483
+ * Idempotent — a name already present is skipped. `trusted` is set by the
8484
+ * caller and never inherited from disk: createTool passes true; persisted and
8485
+ * peer-broadcast specs pass false, so their dangerous actions always re-escalate.
8486
+ * The DynamicTool resolves the escalator lazily (`() => this.escalator`) so a
8487
+ * later setPermissionEscalator covers tools registered before the run wired it.
8488
+ */
8489
+ registerSpec(spec, trusted = false) {
8490
+ spec.trusted = trusted;
8491
+ if (this.registry.hasTool(spec.name)) {
8492
+ this.specs.set(spec.name, spec);
8493
+ return;
8494
+ }
8495
+ const tool = new DynamicTool(spec, this.registry, () => this.escalator, trusted);
8496
+ this.registry.register(tool);
8497
+ this.specs.set(spec.name, spec);
8498
+ this.capabilityIndex.set(capabilityKey(`${spec.description}`), spec.name);
8499
+ }
8500
+ /** Load tools persisted by previous runs and register them — as UNTRUSTED, and
8501
+ * only after re-validating each spec (its source could have been tampered with
8502
+ * or authored during a prior prompt-injected run). Untrusted tools re-escalate
8503
+ * any dangerous action, so a silently-reloaded tool can't act without approval. */
8504
+ async loadPersistedTools() {
8505
+ if (!this.workspacePath || !this.persistEnabled) return;
8506
+ const file = path18__default.default.join(this.workspacePath, ".cascade", DYNAMIC_TOOLS_FILE);
6988
8507
  try {
6989
- const result = await this.router.generate("T3", {
6990
- messages: [{ role: "user", content: prompt }],
6991
- maxTokens: 800
6992
- });
6993
- const jsonMatch = /\{[\s\S]*\}/.exec(result.content);
6994
- if (!jsonMatch) return null;
6995
- const spec = JSON.parse(jsonMatch[0]);
6996
- if (!spec.name || !spec.description || !spec.executeCode || !spec.inputSchema) return null;
6997
- if (this.createdTools.has(spec.name) || this.registry.hasTool(spec.name)) {
6998
- spec.name = `${spec.name}_${Date.now() % 1e4}`;
8508
+ const raw = await fs4__default.default.readFile(file, "utf-8");
8509
+ const specs = JSON.parse(raw);
8510
+ if (!Array.isArray(specs)) return;
8511
+ let loaded = 0;
8512
+ let skipped = 0;
8513
+ for (const spec of specs) {
8514
+ if (!(spec?.name && spec.description && spec.executeCode && spec.inputSchema) || !isExecutableToolCode(spec.executeCode)) {
8515
+ skipped++;
8516
+ continue;
8517
+ }
8518
+ spec.inputSchema = normalizeToolSchema(spec.inputSchema);
8519
+ this.registerSpec(spec, false);
8520
+ loaded++;
6999
8521
  }
7000
- try {
7001
- new Function("input", "fetch", "callTool", spec.executeCode);
7002
- } catch {
7003
- return null;
8522
+ if (loaded || skipped) {
8523
+ this.log(`[tool-creator] Loaded ${loaded} persisted tool(s) as untrusted${skipped ? `, skipped ${skipped} invalid` : ""}.`);
7004
8524
  }
7005
- const tool = new DynamicTool(spec, this.registry, this.escalator);
7006
- this.registry.register(tool);
7007
- this.createdTools.add(spec.name);
7008
- return spec.name;
7009
8525
  } catch {
7010
- return null;
8526
+ }
8527
+ }
8528
+ async persist() {
8529
+ if (!this.workspacePath || !this.persistEnabled) return;
8530
+ const dir = path18__default.default.join(this.workspacePath, ".cascade");
8531
+ const file = path18__default.default.join(dir, DYNAMIC_TOOLS_FILE);
8532
+ try {
8533
+ await fs4__default.default.mkdir(dir, { recursive: true });
8534
+ await fs4__default.default.writeFile(file, JSON.stringify(Array.from(this.specs.values()), null, 2), "utf-8");
8535
+ } catch (err) {
8536
+ this.log(`[tool-creator] Failed to persist tools: ${err instanceof Error ? err.message : String(err)}`);
7011
8537
  }
7012
8538
  }
7013
8539
  /** Returns the names of all tools created in this session. */
7014
8540
  getCreatedTools() {
7015
- return Array.from(this.createdTools);
8541
+ return Array.from(this.specs.keys());
7016
8542
  }
7017
8543
  };
7018
8544
 
@@ -7022,7 +8548,11 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7022
8548
  toolRegistry;
7023
8549
  mcpClient;
7024
8550
  config;
8551
+ /** Orchestration decisions for the CURRENT run — cleared on each run(). */
8552
+ decisionLog = [];
7025
8553
  initialized = false;
8554
+ /** Last task that stopped at the budget cap — powers /continue (resumeRun). */
8555
+ lastInterruptedRun;
7026
8556
  initPromise;
7027
8557
  store;
7028
8558
  audit;
@@ -7030,15 +8560,23 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7030
8560
  taskAnalyzer;
7031
8561
  perfTracker;
7032
8562
  toolCreator;
8563
+ workspacePath;
7033
8564
  constructor(config, workspacePath, store) {
7034
8565
  super();
7035
8566
  this.config = validateConfig(config);
8567
+ this.workspacePath = workspacePath;
7036
8568
  this.store = store;
7037
8569
  this.router = new CascadeRouter();
7038
8570
  this.mcpClient = new McpClient({
7039
8571
  trustedServers: this.config.tools.mcpTrusted,
7040
8572
  approvalCallback: async (server) => {
7041
8573
  return await this.requestMcpApproval(server);
8574
+ },
8575
+ // Route warnings through the event stream when anyone is listening —
8576
+ // a raw console write while the TUI is live corrupts Ink's frame.
8577
+ onWarn: (message) => {
8578
+ if (this.listenerCount("log") > 0) this.emit("log", { level: "warn", message });
8579
+ else console.warn(message);
7042
8580
  }
7043
8581
  });
7044
8582
  this.toolRegistry = new ToolRegistry(this.config.tools, workspacePath);
@@ -7048,11 +8586,15 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7048
8586
  if (this.config.cascadeAuto === true) {
7049
8587
  this.perfTracker = new ModelPerformanceTracker();
7050
8588
  void this.perfTracker.load();
7051
- this.taskAnalyzer = new TaskAnalyzer(this.perfTracker);
8589
+ this.taskAnalyzer = new TaskAnalyzer(this.perfTracker, this.config.autoBias ?? "balanced");
8590
+ this.router.setTaskAnalyzer(this.taskAnalyzer);
7052
8591
  }
7053
8592
  const cfg = this.config;
7054
8593
  if (cfg["enableToolCreation"] === true) {
7055
- this.toolCreator = new ToolCreator(this.router, this.toolRegistry);
8594
+ this.toolCreator = new ToolCreator(this.router, this.toolRegistry, this.workspacePath, cfg["persistDynamicTools"] !== false);
8595
+ this.toolCreator.setLogger((m) => {
8596
+ if (this.listenerCount("log") > 0) this.emit("log", { level: "info", message: m });
8597
+ });
7056
8598
  }
7057
8599
  }
7058
8600
  setStore(store) {
@@ -7083,6 +8625,17 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7083
8625
  this.emit("mcp:approval-required", { server });
7084
8626
  });
7085
8627
  }
8628
+ recordDecision(kind, detail) {
8629
+ this.decisionLog.push({ at: (/* @__PURE__ */ new Date()).toISOString(), kind, detail });
8630
+ }
8631
+ /**
8632
+ * The orchestration decision trail for the most recent run: complexity
8633
+ * verdict (and why), which model served each tier, failovers, and
8634
+ * escalations. Powers the /why command.
8635
+ */
8636
+ getDecisionLog() {
8637
+ return [...this.decisionLog];
8638
+ }
7086
8639
  /** Resolve a pending MCP server approval from a REPL / dashboard listener. */
7087
8640
  resolveMcpApproval(serverName, approved) {
7088
8641
  const resolver = this.pendingMcpApprovals.get(serverName);
@@ -7091,6 +8644,125 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7091
8644
  resolver(approved);
7092
8645
  }
7093
8646
  }
8647
+ // ── Boardroom plan approval ─────────────────────────────────────────
8648
+ // Same gate pattern as MCP approvals, with the opposite default: plans
8649
+ // are work the user asked for, so no listener (SDK/headless) or a
8650
+ // timeout means PROCEED, not reject.
8651
+ pendingPlanApproval;
8652
+ async requestPlanApproval(plan, taskId, critique, summary) {
8653
+ if (this.config.autonomy === "auto") {
8654
+ return { approved: true };
8655
+ }
8656
+ if (this.listenerCount("plan:approval-required") === 0) {
8657
+ return { approved: true };
8658
+ }
8659
+ const t2Count = plan.sections.length;
8660
+ const t3Count = plan.sections.reduce((sum, s) => sum + (s.t3Subtasks?.length ?? 0), 0);
8661
+ return await new Promise((resolve) => {
8662
+ const timeout = setTimeout(() => {
8663
+ if (this.pendingPlanApproval) {
8664
+ this.pendingPlanApproval = void 0;
8665
+ resolve({ approved: true });
8666
+ }
8667
+ }, 12e4);
8668
+ this.pendingPlanApproval = (decision) => {
8669
+ clearTimeout(timeout);
8670
+ this.pendingPlanApproval = void 0;
8671
+ resolve(decision);
8672
+ };
8673
+ this.emit("plan:approval-required", {
8674
+ taskId,
8675
+ plan,
8676
+ t2Count,
8677
+ t3Count,
8678
+ estCostUsd: this.estimatePlanCost(plan),
8679
+ critique,
8680
+ summary
8681
+ });
8682
+ });
8683
+ }
8684
+ /**
8685
+ * Resolve a pending boardroom plan approval from a REPL / dashboard listener.
8686
+ * An optional `note` re-plans and re-asks; an optional `editedPlan` is applied
8687
+ * directly (no re-decompose).
8688
+ */
8689
+ resolvePlanApproval(approved, note, editedPlan) {
8690
+ this.pendingPlanApproval?.({ approved, note, editedPlan });
8691
+ }
8692
+ /**
8693
+ * Autonomy control (used by the /auto command). 'auto' makes the next run
8694
+ * hands-off: the plan gate auto-approves and non-dangerous tools auto-approve,
8695
+ * while dangerous tools still escalate and budget caps remain the hard stop.
8696
+ */
8697
+ setAutonomy(mode) {
8698
+ this.config = { ...this.config, autonomy: mode };
8699
+ }
8700
+ getAutonomy() {
8701
+ return this.config.autonomy === "auto" ? "auto" : "manual";
8702
+ }
8703
+ /**
8704
+ * Preview T1's decomposition for a prompt WITHOUT executing it (powers /plan).
8705
+ * Idempotent init guard, so it works before the first run.
8706
+ */
8707
+ async previewPlan(prompt) {
8708
+ await this.init();
8709
+ const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
8710
+ if (this.store) t1.setStore(this.store);
8711
+ return t1.previewPlan(prompt);
8712
+ }
8713
+ /** True when a task stopped at the budget cap and can be resumed via /continue. */
8714
+ hasResumableRun() {
8715
+ return this.lastInterruptedRun != null;
8716
+ }
8717
+ /**
8718
+ * Raise the per-run token budget for a resume and return the continuation
8719
+ * prompt (or null when nothing is resumable). Consumes the interrupted-run
8720
+ * state. The REPL submits the returned prompt through its normal flow so the
8721
+ * resumed run renders like any other; `resumeRun` wraps this for SDK callers.
8722
+ */
8723
+ prepareResume(opts = {}) {
8724
+ const last = this.lastInterruptedRun;
8725
+ if (!last) return null;
8726
+ this.lastInterruptedRun = void 0;
8727
+ const raised = opts.maxTokens ?? Math.round((this.config.budget?.maxTokensPerRun ?? 2e5) * 2);
8728
+ this.config = { ...this.config, budget: { ...this.config.budget, maxTokensPerRun: raised } };
8729
+ this.router.setMaxTokensPerRun(raised);
8730
+ return `Continue and FINISH this task. A previous attempt was interrupted before completion; any files already created are on disk \u2014 build on them, do NOT recreate them. Complete only the remaining work.
8731
+
8732
+ Original task: ${last.prompt}` + (last.partialOutput ? `
8733
+
8734
+ Partial result so far:
8735
+ ${last.partialOutput}` : "");
8736
+ }
8737
+ /**
8738
+ * Resume the last budget-capped task with a raised budget (SDK/headless).
8739
+ * Returns null when there is nothing to resume.
8740
+ */
8741
+ async resumeRun(opts = {}) {
8742
+ const prompt = this.prepareResume(opts);
8743
+ if (!prompt) return null;
8744
+ return this.run({ prompt });
8745
+ }
8746
+ /**
8747
+ * Rough pre-execution cost estimate for a plan: ~3 T2 calls per section
8748
+ * plus ~4 T3 calls per subtask at typical token volumes. A ballpark for
8749
+ * the approval dialog, not an invoice — always label it "est."
8750
+ */
8751
+ estimatePlanCost(plan) {
8752
+ const T2_CALLS_PER_SECTION = 3;
8753
+ const T3_CALLS_PER_SUBTASK = 4;
8754
+ const IN_TOKENS = 1500;
8755
+ const OUT_TOKENS = 700;
8756
+ const t2Model = this.router.getTierModel("T2");
8757
+ const t3Model = this.router.getTierModel("T3");
8758
+ let est = 0;
8759
+ for (const section of plan.sections) {
8760
+ if (t2Model) est += T2_CALLS_PER_SECTION * calculateCost(IN_TOKENS, OUT_TOKENS, t2Model);
8761
+ const subtasks = section.t3Subtasks?.length ?? 1;
8762
+ if (t3Model) est += subtasks * T3_CALLS_PER_SUBTASK * calculateCost(IN_TOKENS, OUT_TOKENS, t3Model);
8763
+ }
8764
+ return est;
8765
+ }
7094
8766
  async init() {
7095
8767
  if (this.initialized) return;
7096
8768
  if (this.initPromise) return this.initPromise;
@@ -7099,6 +8771,9 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7099
8771
  this.router.on("budget:warning", (payload) => {
7100
8772
  this.emit("budget:warning", payload);
7101
8773
  });
8774
+ this.router.on("failover", (e) => {
8775
+ this.recordDecision("failover", `${e.tier} ${e.from} \u2192 ${e.to} (${e.reason})`);
8776
+ });
7102
8777
  this.router.on("budget:exceeded", (payload) => {
7103
8778
  this.emit("budget:exceeded", payload);
7104
8779
  for (const [name, resolver] of this.pendingMcpApprovals) {
@@ -7136,7 +8811,12 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7136
8811
  this.router.profileModels(this.store).catch(() => {
7137
8812
  });
7138
8813
  }
8814
+ if (this.config.cascadeAuto) {
8815
+ this.router.refreshLiveData().catch(() => {
8816
+ });
8817
+ }
7139
8818
  this.initOptionalFeatures();
8819
+ if (this.toolCreator) await this.toolCreator.loadPersistedTools();
7140
8820
  this.initialized = true;
7141
8821
  })();
7142
8822
  try {
@@ -7162,6 +8842,20 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7162
8842
  const wordCount = prompt.trim().split(/\s+/).length;
7163
8843
  return wordCount <= 12 && LOW_COMPLEXITY.some((re) => re.test(prompt.trim()));
7164
8844
  }
8845
+ /**
8846
+ * Read-only inquiries about existing content ("read / review / explain /
8847
+ * summarize / analyze this file or codebase and tell me …") are single-agent
8848
+ * work — one worker with file/grep tools answers directly, no T1→T2→T3 fan-out.
8849
+ * They must NOT ask to create, build, implement, refactor, or save an artifact;
8850
+ * those stay on the heavier classifier path. This keeps trivial "what does this
8851
+ * do?" requests from being mis-routed into a multi-agent, multi-thousand-token run.
8852
+ */
8853
+ looksLikeReadOnlyInquiry(prompt) {
8854
+ const p = prompt.trim();
8855
+ const inquiry = /\b(?:read|review|explain|describe|summari[sz]e|analy[sz]e|assess|evaluate|inspect|examine|explore|go through|look at|tell me about|what (?:is|are|does|do)|is it|understand|novelty|novel idea)\b/i.test(p);
8856
+ const producesArtifact = /\b(?:create|build|implement|generate|write|refactor|rewrite|add|fix|deploy|install|migrate|scaffold|set up|save (?:a|the)|report|\.(?:pdf|md|txt|json|csv|py|js|ts|tsx|jsx|html|docx?))\b/i.test(p);
8857
+ return inquiry && !producesArtifact;
8858
+ }
7165
8859
  // Cache glob scan results per workspace path to avoid repeated I/O.
7166
8860
  static globCache = /* @__PURE__ */ new Map();
7167
8861
  async countWorkspaceFiles(workspacePath) {
@@ -7181,9 +8875,22 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
7181
8875
  }
7182
8876
  }
7183
8877
  async determineComplexity(prompt, workspacePath, conversationHistory = []) {
7184
- if (this.isCasualGreeting(prompt)) return "Simple";
7185
- if (this.looksLikeSimpleArtifactTask(prompt)) return "Simple";
7186
- if (this.looksLikeConversational(prompt)) return "Simple";
8878
+ if (this.isCasualGreeting(prompt)) {
8879
+ this.recordDecision("complexity", "Simple \u2014 heuristic: casual greeting (no classifier call)");
8880
+ return "Simple";
8881
+ }
8882
+ if (this.looksLikeSimpleArtifactTask(prompt)) {
8883
+ this.recordDecision("complexity", "Simple \u2014 heuristic: single-file artifact task (no classifier call)");
8884
+ return "Simple";
8885
+ }
8886
+ if (this.looksLikeConversational(prompt)) {
8887
+ this.recordDecision("complexity", "Simple \u2014 heuristic: short conversational message (no classifier call)");
8888
+ return "Simple";
8889
+ }
8890
+ if (this.looksLikeReadOnlyInquiry(prompt)) {
8891
+ this.recordDecision("complexity", "Simple \u2014 heuristic: read-only inquiry over existing content (single agent, no classifier call)");
8892
+ return "Simple";
8893
+ }
7187
8894
  let workspaceContext = "";
7188
8895
  try {
7189
8896
  const count = await this.countWorkspaceFiles(workspacePath);
@@ -7203,10 +8910,12 @@ Classification:
7203
8910
  Important rules:
7204
8911
  - Treat short follow-ups like "proceed", "continue", "do it", "yes" as referring to the recent context.
7205
8912
  - If the earlier context is complex, keep the inherited complexity unless the user clearly narrows scope.
8913
+ - Reading, explaining, summarizing, or analyzing existing files/code and answering a question \u2014 WITHOUT creating files or implementing changes \u2014 is "Simple" (single agent), never "Complex".
7206
8914
  - If the task asks for a simple single-file artifact like hello.txt, it is usually Moderate.
7207
8915
  - If the task asks for a saved report, PDF, implementation, or deeper verification workflow, it is at least Moderate and often Complex.
7208
8916
 
7209
- Respond with exactly one word: Simple, Moderate, or Complex.`;
8917
+ Respond with the verdict word first, then a dash and a short reason (under 12 words).
8918
+ Format: <Simple|Moderate|Complex> \u2014 <reason>`;
7210
8919
  const recentHistory = conversationHistory.slice(-6);
7211
8920
  const contextBlock = recentHistory.map((message, index) => {
7212
8921
  const content = typeof message.content === "string" ? message.content : message.content.map((block) => block.type === "text" ? block.text : "[non-text]").join(" ");
@@ -7221,26 +8930,36 @@ ${prompt}` : prompt;
7221
8930
  const result = await this.router.generate("T1", {
7222
8931
  messages: [{ role: "user", content: routedPrompt }],
7223
8932
  systemPrompt: sysPrompt,
7224
- maxTokens: 8,
8933
+ maxTokens: 40,
7225
8934
  temperature: 0
7226
8935
  });
7227
- const content = result.content.trim().toLowerCase();
7228
- if (content.includes("simple")) return "Simple";
7229
- if (content.includes("moderate")) return "Moderate";
7230
- return "Complex";
8936
+ const content = result.content.trim();
8937
+ const firstWord = (content.split(/[\s—–-]+/)[0] ?? "").toLowerCase();
8938
+ const reason = content.replace(/^\S+\s*[—–-]*\s*/, "").trim();
8939
+ const verdict = firstWord.includes("simple") ? "Simple" : firstWord.includes("moderate") ? "Moderate" : "Complex";
8940
+ this.recordDecision("complexity", `${verdict} \u2014 classifier: ${reason || "no reason given"}`);
8941
+ return verdict;
7231
8942
  } catch {
7232
8943
  const followUpPrompt = /^(proceed|continue|go ahead|do it|yes|yep|ok|okay|carry on)$/i.test(prompt.trim());
7233
- if (followUpPrompt && recentHistory.length > 0) return "Complex";
7234
- return "Complex";
8944
+ if (followUpPrompt && recentHistory.length > 0) {
8945
+ this.recordDecision("complexity", "Complex \u2014 classifier unavailable; short follow-up inherits prior context");
8946
+ return "Complex";
8947
+ }
8948
+ this.recordDecision("complexity", "Moderate \u2014 classifier unavailable; defaulting to the mid-cost route");
8949
+ return "Moderate";
7235
8950
  }
7236
8951
  }
7237
8952
  async run(options) {
7238
8953
  await this.init();
8954
+ this.router.beginRun();
8955
+ this.router.setRunSignal(options.signal);
7239
8956
  const startMs = Date.now();
7240
8957
  const taskId = crypto.randomUUID();
7241
- const escalator = new PermissionEscalator();
8958
+ this.decisionLog = [];
8959
+ const escalator = new PermissionEscalator(this.config.approvalTimeoutMs ?? 6e5, this.config.autonomy === "auto");
7242
8960
  escalator.on("permission:user-required", async (req) => {
7243
8961
  this.emit("permission:user-required", req);
8962
+ this.recordDecision("escalation", `"${req.toolName}" by ${req.requestedBy} \u2014 T2 and T1 both unsure, escalated to user`);
7244
8963
  const enrichedRequest = {
7245
8964
  id: req.id,
7246
8965
  tierId: req.requestedBy,
@@ -7277,16 +8996,32 @@ ${prompt}` : prompt;
7277
8996
  toolCreationEnabled: this.config["enableToolCreation"] === true
7278
8997
  });
7279
8998
  this.emit("tier:root", { role: complexity === "Simple" ? "T3" : complexity === "Moderate" ? "T2" : "T1" });
8999
+ const tiersInPlay = complexity === "Simple" ? ["T3"] : complexity === "Moderate" ? ["T2", "T3"] : ["T1", "T2", "T3"];
7280
9000
  if (this.taskAnalyzer) {
7281
- const tiers = complexity === "Simple" ? ["T3"] : complexity === "Moderate" ? ["T2", "T3"] : ["T1", "T2", "T3"];
7282
- await Promise.all(tiers.map(async (tier) => {
9001
+ await Promise.all(tiersInPlay.map(async (tier) => {
9002
+ const tierKey = tier.toLowerCase();
9003
+ if (this.config.models?.[tierKey]) return;
7283
9004
  try {
7284
9005
  const model = await this.taskAnalyzer.selectModel(options.prompt, tier, this.router.getSelector());
7285
- if (model) this.router.overrideTierModel(tier, model);
9006
+ if (model) {
9007
+ this.router.overrideTierModel(tier, model);
9008
+ const taskType = this.taskAnalyzer.getLastProfile()?.type ?? "mixed";
9009
+ const bench = Math.round(benchmarkScore01(model, taskType) * 100);
9010
+ const price = model.inputCostPer1kTokens === 0 && model.outputCostPer1kTokens === 0 ? "free" : `$${model.outputCostPer1kTokens.toFixed(4)}/1K out`;
9011
+ const dataSrc = this.router.getLiveData()?.getDataSource() ?? "bundled";
9012
+ this.recordDecision(
9013
+ "model",
9014
+ `${tier} \u2192 ${model.provider}:${model.id} \u2014 Cascade Auto: best value for ${taskType} (bench ${bench}/100, ${price}, data: ${dataSrc})`
9015
+ );
9016
+ }
7286
9017
  } catch {
7287
9018
  }
7288
9019
  }));
7289
9020
  }
9021
+ this.recordDecision("model", tiersInPlay.map((tier) => {
9022
+ const m = this.router.getTierModel(tier);
9023
+ return m ? `${tier} ${m.provider}:${m.id}${m.isLocal ? " \u2302local" : ""}` : `${tier} (none)`;
9024
+ }).join(" \xB7 "));
7290
9025
  const toolCreator = this.toolCreator;
7291
9026
  if (toolCreator) toolCreator.setPermissionEscalator(escalator);
7292
9027
  let finalOutput = "";
@@ -7368,6 +9103,25 @@ ${prompt}` : prompt;
7368
9103
  if (toolCreator) t2.setToolCreator(toolCreator);
7369
9104
  t2.setPeerMessageCallback((e) => this.emit("peer:message", e), options.sessionId ?? "");
7370
9105
  bindTierEvents(t2);
9106
+ if (this.config.planApproval === "all") {
9107
+ t2.setPlanApprovalCallback(async (subtasks) => {
9108
+ const pseudoPlan = {
9109
+ complexity: "Moderate",
9110
+ reasoning: "",
9111
+ sections: subtasks.map((st) => ({
9112
+ sectionId: st.subtaskId,
9113
+ sectionTitle: st.subtaskTitle,
9114
+ description: st.description,
9115
+ t3Subtasks: []
9116
+ }))
9117
+ };
9118
+ const n = subtasks.length;
9119
+ const summary = `${n} worker${n !== 1 ? "s" : ""} \xB7 1 root manager \xB7 est. $${this.estimatePlanCost(pseudoPlan).toFixed(4)}`;
9120
+ const decision = await this.requestPlanApproval(pseudoPlan, taskId, void 0, summary);
9121
+ const keepSubtaskIds = decision.editedPlan?.sections?.map((s) => s.sectionId).filter((id) => Boolean(id));
9122
+ return { approved: decision.approved, note: decision.note, keepSubtaskIds };
9123
+ });
9124
+ }
7371
9125
  const assignment = {
7372
9126
  sectionId: taskId,
7373
9127
  sectionTitle: "Direct Task",
@@ -7399,17 +9153,33 @@ ${prompt}` : prompt;
7399
9153
  t1.setPeerMessageCallback((e) => this.emit("peer:message", e), options.sessionId ?? "");
7400
9154
  bindTierEvents(t1);
7401
9155
  t1.on("plan", (e) => this.emit("plan", e));
9156
+ if (this.config.planApproval != null && this.config.planApproval !== "never") {
9157
+ t1.setPlanApprovalCallback(async (plan, meta) => {
9158
+ const decision = await this.requestPlanApproval(plan, taskId, meta?.critique);
9159
+ this.recordDecision("escalation", decision.approved ? `Boardroom: plan approved (${plan.sections.length} sections)${decision.note ? " with a steering note" : ""}${decision.editedPlan ? " (edited)" : ""}` : "Boardroom: plan rejected \u2014 run stopped before any T2 spawned");
9160
+ return decision;
9161
+ });
9162
+ }
7402
9163
  const result = await t1.execute(options.prompt, options.images, void 0, options.signal);
7403
9164
  finalOutput = result.output;
7404
9165
  t2Results = result.t2Results;
7405
9166
  }
7406
9167
  } catch (err) {
7407
- if (err instanceof CascadeCancelledError) {
9168
+ if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
7408
9169
  this.emit("run:cancelled", {
9170
+ taskId,
9171
+ reason: err instanceof Error ? err.message : "Task cancelled",
9172
+ partialOutput: finalOutput || ""
9173
+ });
9174
+ runError = null;
9175
+ } else if (err instanceof Error && err.name === "BudgetExceededError") {
9176
+ this.emit("run:budget-exceeded", {
7409
9177
  taskId,
7410
9178
  reason: err.message,
7411
9179
  partialOutput: finalOutput || ""
7412
9180
  });
9181
+ this.lastInterruptedRun = { prompt: options.prompt, partialOutput: finalOutput || "", taskId };
9182
+ if (!finalOutput) finalOutput = `\u26A0 Stopped to avoid runaway cost: ${err.message}`;
7413
9183
  runError = null;
7414
9184
  } else {
7415
9185
  runError = err;
@@ -7420,6 +9190,8 @@ ${prompt}` : prompt;
7420
9190
  escalator.cancelAllPending();
7421
9191
  } catch {
7422
9192
  }
9193
+ this.router.restoreTierModels();
9194
+ this.router.setRunSignal(void 0);
7423
9195
  if (this.taskAnalyzer) {
7424
9196
  try {
7425
9197
  const stats2 = this.router.getStats();
@@ -7527,7 +9299,7 @@ var Keystore = class {
7527
9299
  const creds = await this.keytar.findCredentials(KEYTAR_SERVICE);
7528
9300
  this.cache = Object.fromEntries(creds.map((c) => [c.account, c.password]));
7529
9301
  this.backend = "keytar";
7530
- if (password && fs15__default.default.existsSync(this.storePath)) {
9302
+ if (password && fs17__default.default.existsSync(this.storePath)) {
7531
9303
  try {
7532
9304
  const fileEntries = this.decryptFile(password);
7533
9305
  for (const [k, v] of Object.entries(fileEntries)) {
@@ -7546,7 +9318,7 @@ var Keystore = class {
7546
9318
  "Keystore unlock requires a password because the OS keychain (keytar) is not available on this system."
7547
9319
  );
7548
9320
  }
7549
- if (!fs15__default.default.existsSync(this.storePath)) {
9321
+ if (!fs17__default.default.existsSync(this.storePath)) {
7550
9322
  const salt = crypto__default.default.randomBytes(SALT_LEN);
7551
9323
  this.masterKey = this.deriveKey(password, salt);
7552
9324
  this.writeWithSalt({}, salt);
@@ -7560,7 +9332,7 @@ var Keystore = class {
7560
9332
  }
7561
9333
  /** Synchronous legacy unlock kept for AES-only environments. */
7562
9334
  unlockSync(password) {
7563
- if (!fs15__default.default.existsSync(this.storePath)) {
9335
+ if (!fs17__default.default.existsSync(this.storePath)) {
7564
9336
  const salt = crypto__default.default.randomBytes(SALT_LEN);
7565
9337
  this.masterKey = this.deriveKey(password, salt);
7566
9338
  this.writeWithSalt({}, salt);
@@ -7618,7 +9390,7 @@ var Keystore = class {
7618
9390
  }
7619
9391
  }
7620
9392
  decryptFile(password, knownSalt) {
7621
- if (!fs15__default.default.existsSync(this.storePath)) return {};
9393
+ if (!fs17__default.default.existsSync(this.storePath)) return {};
7622
9394
  try {
7623
9395
  const { salt, ciphertext, iv, tag } = this.readRaw();
7624
9396
  const useSalt = knownSalt ?? salt;
@@ -7640,8 +9412,8 @@ var Keystore = class {
7640
9412
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
7641
9413
  const tag = cipher.getAuthTag();
7642
9414
  const out = Buffer.concat([raw.salt, iv, tag, ciphertext]);
7643
- fs15__default.default.mkdirSync(path16__default.default.dirname(this.storePath), { recursive: true });
7644
- fs15__default.default.writeFileSync(this.storePath, out, { mode: 384 });
9415
+ fs17__default.default.mkdirSync(path18__default.default.dirname(this.storePath), { recursive: true });
9416
+ fs17__default.default.writeFileSync(this.storePath, out, { mode: 384 });
7645
9417
  }
7646
9418
  writeWithSalt(data, salt) {
7647
9419
  if (!this.masterKey) throw new Error("writeWithSalt called before masterKey was set");
@@ -7651,11 +9423,11 @@ var Keystore = class {
7651
9423
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
7652
9424
  const tag = cipher.getAuthTag();
7653
9425
  const out = Buffer.concat([salt, iv, tag, ciphertext]);
7654
- fs15__default.default.mkdirSync(path16__default.default.dirname(this.storePath), { recursive: true });
7655
- fs15__default.default.writeFileSync(this.storePath, out, { mode: 384 });
9426
+ fs17__default.default.mkdirSync(path18__default.default.dirname(this.storePath), { recursive: true });
9427
+ fs17__default.default.writeFileSync(this.storePath, out, { mode: 384 });
7656
9428
  }
7657
9429
  readRaw() {
7658
- const buf = fs15__default.default.readFileSync(this.storePath);
9430
+ const buf = fs17__default.default.readFileSync(this.storePath);
7659
9431
  let offset = 0;
7660
9432
  const salt = buf.subarray(offset, offset + SALT_LEN);
7661
9433
  offset += SALT_LEN;
@@ -7688,9 +9460,9 @@ var CascadeIgnore = class {
7688
9460
  ]);
7689
9461
  }
7690
9462
  async load(workspacePath) {
7691
- const filePath = path16__default.default.join(workspacePath, ".cascadeignore");
9463
+ const filePath = path18__default.default.join(workspacePath, ".cascadeignore");
7692
9464
  try {
7693
- const content = await fs3__default.default.readFile(filePath, "utf-8");
9465
+ const content = await fs4__default.default.readFile(filePath, "utf-8");
7694
9466
  const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
7695
9467
  this.ig.add(lines);
7696
9468
  this.loaded = true;
@@ -7699,7 +9471,7 @@ var CascadeIgnore = class {
7699
9471
  }
7700
9472
  isIgnored(filePath, workspacePath) {
7701
9473
  try {
7702
- const relative = workspacePath ? path16__default.default.relative(workspacePath, filePath) : filePath;
9474
+ const relative = workspacePath ? path18__default.default.relative(workspacePath, filePath) : filePath;
7703
9475
  return this.ig.ignores(relative);
7704
9476
  } catch {
7705
9477
  return false;
@@ -7710,9 +9482,9 @@ var CascadeIgnore = class {
7710
9482
  }
7711
9483
  };
7712
9484
  async function loadCascadeMd(workspacePath) {
7713
- const filePath = path16__default.default.join(workspacePath, "CASCADE.md");
9485
+ const filePath = path18__default.default.join(workspacePath, "CASCADE.md");
7714
9486
  try {
7715
- const raw = await fs3__default.default.readFile(filePath, "utf-8");
9487
+ const raw = await fs4__default.default.readFile(filePath, "utf-8");
7716
9488
  return parseCascadeMd(raw);
7717
9489
  } catch {
7718
9490
  return null;
@@ -7741,7 +9513,7 @@ ${raw.trim()}`;
7741
9513
  var MemoryStore = class _MemoryStore {
7742
9514
  db;
7743
9515
  constructor(dbPath) {
7744
- fs15__default.default.mkdirSync(path16__default.default.dirname(dbPath), { recursive: true });
9516
+ fs17__default.default.mkdirSync(path18__default.default.dirname(dbPath), { recursive: true });
7745
9517
  try {
7746
9518
  this.db = new Database__default.default(dbPath, { timeout: 5e3 });
7747
9519
  this.db.pragma("journal_mode = WAL");
@@ -8499,15 +10271,15 @@ var ConfigManager = class {
8499
10271
  globalDir;
8500
10272
  constructor(workspacePath = process.cwd()) {
8501
10273
  this.workspacePath = workspacePath;
8502
- this.globalDir = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR);
10274
+ this.globalDir = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR);
8503
10275
  }
8504
10276
  async load() {
8505
10277
  this.config = await this.loadConfig();
8506
10278
  this.ignore = new CascadeIgnore();
8507
10279
  await this.ignore.load(this.workspacePath);
8508
10280
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
8509
- this.keystore = new Keystore(path16__default.default.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
8510
- this.store = new MemoryStore(path16__default.default.join(this.workspacePath, CASCADE_DB_FILE));
10281
+ this.keystore = new Keystore(path18__default.default.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
10282
+ this.store = new MemoryStore(path18__default.default.join(this.workspacePath, CASCADE_DB_FILE));
8511
10283
  await this.injectEnvKeys();
8512
10284
  await this.ensureDefaultIdentity();
8513
10285
  }
@@ -8530,9 +10302,9 @@ var ConfigManager = class {
8530
10302
  return this.workspacePath;
8531
10303
  }
8532
10304
  async save() {
8533
- const configPath = path16__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
8534
- await fs3__default.default.mkdir(path16__default.default.dirname(configPath), { recursive: true });
8535
- await fs3__default.default.writeFile(configPath, JSON.stringify(this.config, null, 2), "utf-8");
10305
+ const configPath = path18__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
10306
+ await fs4__default.default.mkdir(path18__default.default.dirname(configPath), { recursive: true });
10307
+ await fs4__default.default.writeFile(configPath, JSON.stringify(this.config, null, 2), "utf-8");
8536
10308
  }
8537
10309
  async updateConfig(updates) {
8538
10310
  this.config = validateConfig({ ...this.config, ...updates });
@@ -8555,9 +10327,9 @@ var ConfigManager = class {
8555
10327
  return configProvider?.apiKey;
8556
10328
  }
8557
10329
  async loadConfig() {
8558
- const configPath = path16__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
10330
+ const configPath = path18__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
8559
10331
  try {
8560
- const raw = await fs3__default.default.readFile(configPath, "utf-8");
10332
+ const raw = await fs4__default.default.readFile(configPath, "utf-8");
8561
10333
  return validateConfig(JSON.parse(raw));
8562
10334
  } catch (err) {
8563
10335
  if (err.code === "ENOENT") {
@@ -8636,12 +10408,13 @@ async function streamCascade(prompt, onToken, options = {}) {
8636
10408
  }
8637
10409
  });
8638
10410
  }
10411
+ var JWT_ALGORITHM = "HS256";
8639
10412
  function createToken(user, secret) {
8640
- return jwt__default.default.sign(user, secret, { expiresIn: "24h" });
10413
+ return jwt__default.default.sign(user, secret, { expiresIn: "24h", algorithm: JWT_ALGORITHM });
8641
10414
  }
8642
10415
  function verifyToken(token, secret) {
8643
10416
  try {
8644
- return jwt__default.default.verify(token, secret);
10417
+ return jwt__default.default.verify(token, secret, { algorithms: [JWT_ALGORITHM] });
8645
10418
  } catch {
8646
10419
  return null;
8647
10420
  }
@@ -8772,7 +10545,7 @@ var DashboardSocket = class {
8772
10545
  this.io.close();
8773
10546
  }
8774
10547
  };
8775
- var __dirname$1 = path16__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
10548
+ var __dirname$1 = path18__default.default.dirname(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href))));
8776
10549
  var DashboardServer = class {
8777
10550
  app;
8778
10551
  httpServer;
@@ -8783,12 +10556,14 @@ var DashboardServer = class {
8783
10556
  globalStore = null;
8784
10557
  broadcastTimer = null;
8785
10558
  port;
10559
+ host;
8786
10560
  workspacePath;
8787
10561
  constructor(config, store, workspacePath = process.cwd()) {
8788
10562
  this.config = config;
8789
10563
  this.store = store;
8790
10564
  this.workspacePath = workspacePath;
8791
10565
  this.port = config.dashboard.port ?? DEFAULT_DASHBOARD_PORT;
10566
+ this.host = config.dashboard.host ?? "127.0.0.1";
8792
10567
  this.dashboardSecret = this.resolveDashboardSecret();
8793
10568
  this.app = express__default.default();
8794
10569
  this.httpServer = http.createServer(this.app);
@@ -8801,10 +10576,19 @@ var DashboardServer = class {
8801
10576
  this.setupRoutes();
8802
10577
  }
8803
10578
  async start() {
10579
+ const isLoopback = this.host === "127.0.0.1" || this.host === "::1" || this.host === "localhost";
10580
+ if (!isLoopback) {
10581
+ console.warn(
10582
+ `\u26A0 Dashboard is binding to ${this.host}:${this.port} \u2014 reachable from the network. It exposes task execution (/api/run) and config endpoints. Ensure dashboard.auth is enabled and CASCADE_DASHBOARD_PASSWORD is set.`
10583
+ );
10584
+ if (!this.config.dashboard.auth) {
10585
+ console.warn("\u26A0 Dashboard auth is DISABLED while bound to a non-loopback interface \u2014 this allows unauthenticated remote task execution.");
10586
+ }
10587
+ }
8804
10588
  await new Promise((resolve, reject) => {
8805
10589
  const onError = (err) => reject(err);
8806
10590
  this.httpServer.once("error", onError);
8807
- this.httpServer.listen(this.port, () => {
10591
+ this.httpServer.listen(this.port, this.host, () => {
8808
10592
  this.httpServer.off("error", onError);
8809
10593
  resolve();
8810
10594
  });
@@ -8838,15 +10622,15 @@ var DashboardServer = class {
8838
10622
  resolveDashboardSecret() {
8839
10623
  const fromConfig = this.config.dashboard.secret ?? process.env["CASCADE_DASHBOARD_SECRET"];
8840
10624
  if (fromConfig) return fromConfig;
8841
- const secretPath = path16__default.default.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
10625
+ const secretPath = path18__default.default.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
8842
10626
  try {
8843
- if (fs15__default.default.existsSync(secretPath)) {
8844
- const existing = fs15__default.default.readFileSync(secretPath, "utf-8").trim();
10627
+ if (fs17__default.default.existsSync(secretPath)) {
10628
+ const existing = fs17__default.default.readFileSync(secretPath, "utf-8").trim();
8845
10629
  if (existing.length >= 16) return existing;
8846
10630
  }
8847
10631
  const generated = crypto.randomUUID();
8848
- fs15__default.default.mkdirSync(path16__default.default.dirname(secretPath), { recursive: true });
8849
- fs15__default.default.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
10632
+ fs17__default.default.mkdirSync(path18__default.default.dirname(secretPath), { recursive: true });
10633
+ fs17__default.default.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
8850
10634
  if (this.config.dashboard.auth) {
8851
10635
  console.warn(
8852
10636
  `Dashboard auth enabled with no secret configured; persisted a generated secret to ${secretPath}. Set CASCADE_DASHBOARD_SECRET or config.dashboard.secret to override.`
@@ -8873,7 +10657,7 @@ var DashboardServer = class {
8873
10657
  // ── Setup ─────────────────────────────────────
8874
10658
  getGlobalStore() {
8875
10659
  if (!this.globalStore) {
8876
- const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10660
+ const globalDbPath = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8877
10661
  this.globalStore = new MemoryStore(globalDbPath);
8878
10662
  }
8879
10663
  return this.globalStore;
@@ -8934,12 +10718,12 @@ var DashboardServer = class {
8934
10718
  }
8935
10719
  }
8936
10720
  watchRuntimeChanges() {
8937
- const workspaceDbPath = path16__default.default.join(this.workspacePath, CASCADE_DB_FILE);
8938
- const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10721
+ const workspaceDbPath = path18__default.default.join(this.workspacePath, CASCADE_DB_FILE);
10722
+ const globalDbPath = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8939
10723
  const watchPaths = [workspaceDbPath, globalDbPath].filter((p, index, arr) => arr.indexOf(p) === index);
8940
10724
  for (const watchPath of watchPaths) {
8941
- if (!fs15__default.default.existsSync(watchPath)) continue;
8942
- fs15__default.default.watchFile(watchPath, { interval: 3e3 }, () => {
10725
+ if (!fs17__default.default.existsSync(watchPath)) continue;
10726
+ fs17__default.default.watchFile(watchPath, { interval: 3e3 }, () => {
8943
10727
  this.throttledBroadcast(watchPath === globalDbPath ? "global" : "workspace");
8944
10728
  });
8945
10729
  }
@@ -9069,7 +10853,7 @@ var DashboardServer = class {
9069
10853
  const sessionId = req.params.id;
9070
10854
  this.store.deleteSession(sessionId);
9071
10855
  this.store.deleteRuntimeSession(sessionId);
9072
- const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10856
+ const globalDbPath = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9073
10857
  const globalStore = new MemoryStore(globalDbPath);
9074
10858
  try {
9075
10859
  globalStore.deleteRuntimeSession(sessionId);
@@ -9083,7 +10867,7 @@ var DashboardServer = class {
9083
10867
  });
9084
10868
  this.app.delete("/api/sessions", auth, (req, res) => {
9085
10869
  const body = req.body;
9086
- const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10870
+ const globalDbPath = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9087
10871
  if (body?.ids && Array.isArray(body.ids) && body.ids.length > 0) {
9088
10872
  const globalStore = new MemoryStore(globalDbPath);
9089
10873
  try {
@@ -9106,7 +10890,7 @@ var DashboardServer = class {
9106
10890
  });
9107
10891
  this.app.delete("/api/runtime", auth, (_req, res) => {
9108
10892
  this.store.deleteAllRuntimeNodes();
9109
- const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10893
+ const globalDbPath = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9110
10894
  const globalStore = new MemoryStore(globalDbPath);
9111
10895
  try {
9112
10896
  globalStore.deleteAllRuntimeNodes();
@@ -9179,12 +10963,12 @@ var DashboardServer = class {
9179
10963
  if (body["tierLimits"]) this.config.tierLimits = { ...this.config.tierLimits, ...body["tierLimits"] };
9180
10964
  if (body["budget"]) this.config.budget = { ...this.config.budget, ...body["budget"] };
9181
10965
  try {
9182
- const configPath = path16__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
9183
- const existing = fs15__default.default.existsSync(configPath) ? JSON.parse(fs15__default.default.readFileSync(configPath, "utf-8")) : {};
10966
+ const configPath = path18__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
10967
+ const existing = fs17__default.default.existsSync(configPath) ? JSON.parse(fs17__default.default.readFileSync(configPath, "utf-8")) : {};
9184
10968
  const updated = { ...existing, tierLimits: this.config.tierLimits, budget: this.config.budget };
9185
10969
  const tmp = configPath + ".tmp";
9186
- fs15__default.default.writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
9187
- fs15__default.default.renameSync(tmp, configPath);
10970
+ fs17__default.default.writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
10971
+ fs17__default.default.renameSync(tmp, configPath);
9188
10972
  res.json({ ok: true });
9189
10973
  } catch (err) {
9190
10974
  res.status(500).json({ error: `Failed to save config: ${err instanceof Error ? err.message : String(err)}` });
@@ -9212,7 +10996,7 @@ var DashboardServer = class {
9212
10996
  this.app.get("/api/runtime", auth, (req, res) => {
9213
10997
  const scope = req.query["scope"] ?? "workspace";
9214
10998
  if (scope === "global") {
9215
- const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
10999
+ const globalDbPath = path18__default.default.join(os4__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9216
11000
  const globalStore = new MemoryStore(globalDbPath);
9217
11001
  try {
9218
11002
  res.json({
@@ -9285,13 +11069,13 @@ var DashboardServer = class {
9285
11069
  }))
9286
11070
  });
9287
11071
  });
9288
- const prodPath = path16__default.default.resolve(__dirname$1, "../web/dist");
9289
- const devPath = path16__default.default.resolve(__dirname$1, "../../web/dist");
9290
- const webDistPath = fs15__default.default.existsSync(prodPath) ? prodPath : devPath;
9291
- if (fs15__default.default.existsSync(webDistPath)) {
11072
+ const prodPath = path18__default.default.resolve(__dirname$1, "../web/dist");
11073
+ const devPath = path18__default.default.resolve(__dirname$1, "../../web/dist");
11074
+ const webDistPath = fs17__default.default.existsSync(prodPath) ? prodPath : devPath;
11075
+ if (fs17__default.default.existsSync(webDistPath)) {
9292
11076
  this.app.use(express__default.default.static(webDistPath));
9293
11077
  this.app.get("*", (_req, res) => {
9294
- res.sendFile(path16__default.default.join(webDistPath, "index.html"));
11078
+ res.sendFile(path18__default.default.join(webDistPath, "index.html"));
9295
11079
  });
9296
11080
  } else {
9297
11081
  this.app.get("/", (_req, res) => {