cascade-ai 0.4.0 → 0.5.1

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
@@ -7,19 +7,19 @@ var Anthropic = require('@anthropic-ai/sdk');
7
7
  var OpenAI = require('openai');
8
8
  var genai = require('@google/genai');
9
9
  var axios2 = require('axios');
10
- var fs2 = require('fs/promises');
11
- var path13 = require('path');
10
+ var fs3 = require('fs/promises');
11
+ var path16 = require('path');
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
16
  var simpleGit = require('simple-git');
16
- var fs11 = require('fs');
17
17
  var PDFDocument = require('pdfkit');
18
18
  var index_js = require('@modelcontextprotocol/sdk/client/index.js');
19
19
  var stdio_js = require('@modelcontextprotocol/sdk/client/stdio.js');
20
20
  var zod = require('zod');
21
+ var os3 = require('os');
21
22
  var vm = require('vm');
22
- var os2 = require('os');
23
23
  var Database = require('better-sqlite3');
24
24
  var http = require('http');
25
25
  var url = require('url');
@@ -57,12 +57,12 @@ var crypto__default = /*#__PURE__*/_interopDefault(crypto);
57
57
  var Anthropic__default = /*#__PURE__*/_interopDefault(Anthropic);
58
58
  var OpenAI__default = /*#__PURE__*/_interopDefault(OpenAI);
59
59
  var axios2__default = /*#__PURE__*/_interopDefault(axios2);
60
- var fs2__default = /*#__PURE__*/_interopDefault(fs2);
61
- var path13__default = /*#__PURE__*/_interopDefault(path13);
60
+ var fs3__default = /*#__PURE__*/_interopDefault(fs3);
61
+ var path16__default = /*#__PURE__*/_interopDefault(path16);
62
62
  var ignoreFactory__namespace = /*#__PURE__*/_interopNamespace(ignoreFactory);
63
- var fs11__default = /*#__PURE__*/_interopDefault(fs11);
63
+ var fs15__default = /*#__PURE__*/_interopDefault(fs15);
64
64
  var PDFDocument__default = /*#__PURE__*/_interopDefault(PDFDocument);
65
- var os2__default = /*#__PURE__*/_interopDefault(os2);
65
+ var os3__default = /*#__PURE__*/_interopDefault(os3);
66
66
  var Database__default = /*#__PURE__*/_interopDefault(Database);
67
67
  var express__default = /*#__PURE__*/_interopDefault(express);
68
68
  var rateLimit__default = /*#__PURE__*/_interopDefault(rateLimit);
@@ -165,7 +165,7 @@ var require_keytar2 = __commonJS({
165
165
  });
166
166
 
167
167
  // src/constants.ts
168
- var CASCADE_VERSION = "0.4.0";
168
+ var CASCADE_VERSION = "0.5.1";
169
169
  var CASCADE_CONFIG_DIR = ".cascade";
170
170
  var CASCADE_MD_FILE = "CASCADE.md";
171
171
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -1159,6 +1159,22 @@ var GeminiProvider = class extends BaseProvider {
1159
1159
  };
1160
1160
  }
1161
1161
  };
1162
+ var TOOL_CAPABLE_FAMILIES = [
1163
+ "llama3.1",
1164
+ "llama3.2",
1165
+ "llama3.3",
1166
+ "qwen2",
1167
+ "qwen2.5",
1168
+ "qwen3",
1169
+ "mistral-nemo",
1170
+ "mistral-small",
1171
+ "command-r",
1172
+ "firefunction"
1173
+ ];
1174
+ function isToolCapable(modelName) {
1175
+ const name = modelName.toLowerCase();
1176
+ return TOOL_CAPABLE_FAMILIES.some((family) => name.includes(family));
1177
+ }
1162
1178
  var OllamaProvider = class extends BaseProvider {
1163
1179
  baseUrl;
1164
1180
  constructor(config, model) {
@@ -1171,12 +1187,21 @@ var OllamaProvider = class extends BaseProvider {
1171
1187
  }
1172
1188
  async generateStream(options, onChunk) {
1173
1189
  const messages = this.convertMessages(options.messages, options.systemPrompt);
1190
+ const ollamaTools = options.tools?.map((t) => ({
1191
+ type: "function",
1192
+ function: {
1193
+ name: t.name,
1194
+ description: t.description,
1195
+ parameters: t.inputSchema
1196
+ }
1197
+ }));
1174
1198
  const response = await axios2__default.default.post(
1175
1199
  `${this.baseUrl}/api/chat`,
1176
1200
  {
1177
1201
  model: this.model.id,
1178
1202
  messages,
1179
1203
  stream: true,
1204
+ tools: ollamaTools?.length ? ollamaTools : void 0,
1180
1205
  options: {
1181
1206
  num_predict: options.maxTokens ?? this.model.maxOutputTokens,
1182
1207
  temperature: options.temperature ?? 0.7
@@ -1187,6 +1212,7 @@ var OllamaProvider = class extends BaseProvider {
1187
1212
  let fullContent = "";
1188
1213
  let inputTokens = 0;
1189
1214
  let outputTokens = 0;
1215
+ const pendingToolCalls = [];
1190
1216
  await new Promise((resolve, reject) => {
1191
1217
  let buffer = "";
1192
1218
  response.data.on("data", (chunk) => {
@@ -1201,6 +1227,9 @@ var OllamaProvider = class extends BaseProvider {
1201
1227
  fullContent += parsed.message.content;
1202
1228
  onChunk({ text: parsed.message.content, finishReason: null });
1203
1229
  }
1230
+ if (parsed.message?.tool_calls?.length) {
1231
+ pendingToolCalls.push(...parsed.message.tool_calls);
1232
+ }
1204
1233
  if (parsed.done) {
1205
1234
  inputTokens = parsed.prompt_eval_count ?? 0;
1206
1235
  outputTokens = parsed.eval_count ?? 0;
@@ -1218,6 +1247,9 @@ var OllamaProvider = class extends BaseProvider {
1218
1247
  fullContent += parsed.message.content;
1219
1248
  onChunk({ text: parsed.message.content, finishReason: null });
1220
1249
  }
1250
+ if (parsed.message?.tool_calls?.length) {
1251
+ pendingToolCalls.push(...parsed.message.tool_calls);
1252
+ }
1221
1253
  if (parsed.done) {
1222
1254
  inputTokens = parsed.prompt_eval_count ?? inputTokens;
1223
1255
  outputTokens = parsed.eval_count ?? outputTokens;
@@ -1229,11 +1261,30 @@ var OllamaProvider = class extends BaseProvider {
1229
1261
  });
1230
1262
  response.data.on("error", reject);
1231
1263
  });
1232
- onChunk({ text: "", finishReason: "stop" });
1264
+ const toolCalls = pendingToolCalls.map((tc, i) => {
1265
+ let input;
1266
+ if (typeof tc.function.arguments === "string") {
1267
+ try {
1268
+ input = JSON.parse(tc.function.arguments);
1269
+ } catch {
1270
+ input = { __rawArguments: tc.function.arguments };
1271
+ }
1272
+ } else {
1273
+ input = tc.function.arguments;
1274
+ }
1275
+ return {
1276
+ id: `ollama-tool-${Date.now()}-${i}`,
1277
+ name: tc.function.name,
1278
+ input
1279
+ };
1280
+ });
1281
+ const finishReason = toolCalls.length ? "tool_use" : "stop";
1282
+ onChunk({ text: "", finishReason });
1233
1283
  return {
1234
1284
  content: fullContent,
1235
1285
  usage: this.makeUsage(inputTokens, outputTokens),
1236
- finishReason: "stop"
1286
+ toolCalls: toolCalls.length ? toolCalls : void 0,
1287
+ finishReason
1237
1288
  };
1238
1289
  }
1239
1290
  async countTokens(text) {
@@ -1257,6 +1308,7 @@ var OllamaProvider = class extends BaseProvider {
1257
1308
  maxOutputTokens: 4e3,
1258
1309
  supportsStreaming: true,
1259
1310
  isLocal: true,
1311
+ supportsToolUse: isToolCapable(m.name),
1260
1312
  minSizeB: this.parseSizeB(m.details?.parameter_size)
1261
1313
  }));
1262
1314
  } catch {
@@ -1279,6 +1331,26 @@ var OllamaProvider = class extends BaseProvider {
1279
1331
  result.push({ role: "system", content: typeof m.content === "string" ? m.content : "" });
1280
1332
  continue;
1281
1333
  }
1334
+ if (m.role === "tool") {
1335
+ result.push({
1336
+ role: "tool",
1337
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content)
1338
+ });
1339
+ continue;
1340
+ }
1341
+ if (m.role === "assistant" && m.toolCalls?.length) {
1342
+ result.push({
1343
+ role: "assistant",
1344
+ content: typeof m.content === "string" ? m.content : "",
1345
+ tool_calls: m.toolCalls.map((tc) => ({
1346
+ function: {
1347
+ name: tc.name,
1348
+ arguments: tc.input
1349
+ }
1350
+ }))
1351
+ });
1352
+ continue;
1353
+ }
1282
1354
  if (typeof m.content === "string") {
1283
1355
  result.push({ role: m.role, content: m.content });
1284
1356
  continue;
@@ -1411,6 +1483,26 @@ var ModelSelector = class {
1411
1483
  return T3_MODEL_PRIORITY;
1412
1484
  }
1413
1485
  }
1486
+ getAllAvailableModels() {
1487
+ return Array.from(this.availableModels.values()).filter(
1488
+ (m) => this.availableProviders.has(m.provider)
1489
+ );
1490
+ }
1491
+ /**
1492
+ * Returns all available models eligible for the given tier, ordered by the
1493
+ * tier's priority chain. Use this as the candidate set for scored selection.
1494
+ */
1495
+ getCandidatesForTier(tier) {
1496
+ const priority = this.getPriorityList(tier);
1497
+ const candidates = [];
1498
+ for (const key of priority) {
1499
+ const model = this.availableModels.get(key);
1500
+ if (model && this.availableProviders.has(model.provider)) {
1501
+ candidates.push(model);
1502
+ }
1503
+ }
1504
+ return candidates;
1505
+ }
1414
1506
  isProviderAvailable(provider) {
1415
1507
  return this.availableProviders.has(provider);
1416
1508
  }
@@ -1616,11 +1708,203 @@ var TpmLimiter = class {
1616
1708
  }
1617
1709
  };
1618
1710
 
1711
+ // src/core/router/local-queue.ts
1712
+ var LocalRequestQueue = class {
1713
+ maxConcurrent;
1714
+ active = 0;
1715
+ queue = [];
1716
+ constructor(maxConcurrent = 1) {
1717
+ this.maxConcurrent = Math.max(1, maxConcurrent);
1718
+ }
1719
+ /**
1720
+ * Acquire a queue slot. Returns a `release` function that MUST be called
1721
+ * when the inference call is done (even on error). Rejects if the slot
1722
+ * cannot be acquired within `timeoutMs`.
1723
+ */
1724
+ async acquire(timeoutMs) {
1725
+ if (this.active < this.maxConcurrent) {
1726
+ this.active++;
1727
+ return this.makeRelease();
1728
+ }
1729
+ return new Promise((resolve, reject) => {
1730
+ let settled = false;
1731
+ let timer;
1732
+ const resolver = (release) => {
1733
+ if (settled) return;
1734
+ settled = true;
1735
+ if (timer !== void 0) clearTimeout(timer);
1736
+ resolve(release);
1737
+ };
1738
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
1739
+ timer = setTimeout(() => {
1740
+ if (settled) return;
1741
+ settled = true;
1742
+ const idx = this.queue.indexOf(resolver);
1743
+ if (idx !== -1) this.queue.splice(idx, 1);
1744
+ reject(new Error(
1745
+ `Local model queue: timed out waiting for a free slot after ${timeoutMs}ms. Active: ${this.active}, Queued: ${this.queue.length}. Consider increasing localConcurrency or localInferenceTimeoutMs in your config.`
1746
+ ));
1747
+ }, timeoutMs);
1748
+ }
1749
+ this.queue.push(resolver);
1750
+ });
1751
+ }
1752
+ /** Number of in-flight requests. */
1753
+ get activeCount() {
1754
+ return this.active;
1755
+ }
1756
+ /** Number of requests waiting for a slot. */
1757
+ get queueDepth() {
1758
+ return this.queue.length;
1759
+ }
1760
+ makeRelease() {
1761
+ let called = false;
1762
+ return () => {
1763
+ if (called) return;
1764
+ called = true;
1765
+ this.active--;
1766
+ const next = this.queue.shift();
1767
+ if (next) {
1768
+ this.active++;
1769
+ next(this.makeRelease());
1770
+ }
1771
+ };
1772
+ }
1773
+ };
1774
+
1619
1775
  // src/utils/cost.ts
1620
1776
  function calculateCost(inputTokens, outputTokens, model) {
1621
1777
  return inputTokens / 1e3 * model.inputCostPer1kTokens + outputTokens / 1e3 * model.outputCostPer1kTokens;
1622
1778
  }
1623
1779
 
1780
+ // src/utils/retry.ts
1781
+ var CascadeCancelledError = class extends Error {
1782
+ constructor(reason) {
1783
+ super(reason ?? "Run was cancelled via AbortSignal");
1784
+ this.name = "CascadeCancelledError";
1785
+ }
1786
+ };
1787
+ var CascadeToolError = class extends Error {
1788
+ /** A friendly message to show the user / T3 */
1789
+ userMessage;
1790
+ /** Whether this error class is retryable by default */
1791
+ retryable;
1792
+ constructor(userMessage, cause, retryable = false) {
1793
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
1794
+ super(`${userMessage}: ${causeMsg}`);
1795
+ this.name = "CascadeToolError";
1796
+ this.userMessage = userMessage;
1797
+ this.retryable = retryable;
1798
+ }
1799
+ };
1800
+ async function withTimeout(promise, timeoutMs, errorMessage = "Operation timed out") {
1801
+ let timer;
1802
+ const timeoutPromise = new Promise((_, reject) => {
1803
+ timer = setTimeout(
1804
+ () => reject(new Error(errorMessage)),
1805
+ timeoutMs
1806
+ );
1807
+ });
1808
+ try {
1809
+ return await Promise.race([promise, timeoutPromise]);
1810
+ } finally {
1811
+ if (timer !== void 0) clearTimeout(timer);
1812
+ }
1813
+ }
1814
+
1815
+ // src/core/router/model-profiler.ts
1816
+ var SKIP_PATTERN = /embed|dall-e|whisper|tts|vision|instruct-vision|rerank/i;
1817
+ var SPECIALIZATION_KEYWORDS = {
1818
+ code: ["code", "coding", "programming", "developer", "software", "function", "debug", "typescript", "python", "javascript"],
1819
+ analysis: ["analysis", "analytical", "reasoning", "logic", "research", "evaluate", "assess", "explain"],
1820
+ creative: ["creative", "writing", "story", "poetry", "content", "blog", "essay", "narrative"],
1821
+ data: ["data", "sql", "statistics", "chart", "csv", "json", "excel", "spreadsheet", "math", "mathematical"],
1822
+ instruction: ["instruction", "instruction-following", "accurate", "precise", "factual"],
1823
+ multilingual: ["multilingual", "language", "translation", "linguistic"],
1824
+ long_context: ["long", "context", "document", "book", "summarize", "large"]
1825
+ };
1826
+ function extractSpecializations(description) {
1827
+ const lower = description.toLowerCase();
1828
+ const found = [];
1829
+ for (const [key, terms] of Object.entries(SPECIALIZATION_KEYWORDS)) {
1830
+ if (terms.some((t) => lower.includes(t))) {
1831
+ found.push(key);
1832
+ }
1833
+ }
1834
+ return found;
1835
+ }
1836
+ async function fetchOpenRouterModels() {
1837
+ try {
1838
+ const resp = await fetch("https://openrouter.ai/api/v1/models", {
1839
+ headers: { "User-Agent": "Cascade-AI/0.4.0" },
1840
+ signal: AbortSignal.timeout(8e3)
1841
+ });
1842
+ if (!resp.ok) return [];
1843
+ const data = await resp.json();
1844
+ return data.data ?? [];
1845
+ } catch {
1846
+ return [];
1847
+ }
1848
+ }
1849
+ async function queryModelDirectly(router, model) {
1850
+ try {
1851
+ const result = await router.generate("T3", {
1852
+ messages: [{
1853
+ role: "user",
1854
+ content: 'What are your top 3 task specializations? Reply with valid JSON only: {"specializations": ["<area1>", "<area2>", "<area3>"]}'
1855
+ }],
1856
+ maxTokens: 60
1857
+ });
1858
+ const match = /\{[\s\S]*?\}/.exec(result.content);
1859
+ if (!match) return [];
1860
+ const parsed = JSON.parse(match[0]);
1861
+ const specs = parsed.specializations;
1862
+ if (!Array.isArray(specs)) return [];
1863
+ return specs.filter((s) => typeof s === "string").slice(0, 5);
1864
+ } catch {
1865
+ return [];
1866
+ }
1867
+ }
1868
+ var ModelProfiler = class {
1869
+ store;
1870
+ router;
1871
+ constructor(store, router) {
1872
+ this.store = store;
1873
+ this.router = router;
1874
+ }
1875
+ /**
1876
+ * Profile all models that haven't been profiled yet.
1877
+ * Safe to call concurrently — SQLite upsert handles races.
1878
+ */
1879
+ async profileAll(models) {
1880
+ const alreadyProfiled = new Set(this.store.getProfiledModelIds());
1881
+ const toProfile = models.filter(
1882
+ (m) => !alreadyProfiled.has(m.id) && !SKIP_PATTERN.test(m.id) && !SKIP_PATTERN.test(m.name)
1883
+ );
1884
+ if (toProfile.length === 0) return;
1885
+ const openRouterModels = await fetchOpenRouterModels();
1886
+ const orByNormalizedId = /* @__PURE__ */ new Map();
1887
+ for (const m of openRouterModels) {
1888
+ orByNormalizedId.set(m.id.toLowerCase(), m);
1889
+ const short = m.id.split("/").pop();
1890
+ if (short) orByNormalizedId.set(short.toLowerCase(), m);
1891
+ }
1892
+ await Promise.allSettled(
1893
+ toProfile.map(async (model) => {
1894
+ let specializations = [];
1895
+ const orMatch = orByNormalizedId.get(model.id.toLowerCase()) ?? orByNormalizedId.get(model.id.split("/").pop()?.toLowerCase() ?? "");
1896
+ if (orMatch?.description) {
1897
+ specializations = extractSpecializations(orMatch.description);
1898
+ }
1899
+ if (specializations.length === 0 && this.router) {
1900
+ specializations = await queryModelDirectly(this.router);
1901
+ }
1902
+ this.store.saveModelProfile(model.id, model.provider, specializations);
1903
+ })
1904
+ );
1905
+ }
1906
+ };
1907
+
1624
1908
  // src/core/router/index.ts
1625
1909
  var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1626
1910
  selector;
@@ -1648,6 +1932,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1648
1932
  budgetState = "ok";
1649
1933
  budgetExceededReason;
1650
1934
  tpmLimiter;
1935
+ localQueue;
1651
1936
  /** Thrown when the configured budget is exceeded. */
1652
1937
  static BudgetExceededError = class extends Error {
1653
1938
  constructor(msg) {
@@ -1664,6 +1949,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1664
1949
  this.selector = new ModelSelector(availableProviders);
1665
1950
  this.failover = new FailoverManager(this.selector);
1666
1951
  this.tpmLimiter = new TpmLimiter(config.rateLimits?.providerTpm ?? {});
1952
+ this.localQueue = new LocalRequestQueue(config.localConcurrency ?? 1);
1667
1953
  const ollamaCfg = config.providers.find((p) => p.type === "ollama");
1668
1954
  if (availableProviders.has("ollama")) {
1669
1955
  await this.discoverOllamaModels(ollamaCfg);
@@ -1690,6 +1976,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1690
1976
  }
1691
1977
  }
1692
1978
  }
1979
+ /**
1980
+ * Run model specialization profiling in the background.
1981
+ * Only profiles models that haven't been profiled yet (cache-first).
1982
+ * No-op if store is not provided.
1983
+ */
1984
+ async profileModels(store) {
1985
+ const allModels = this.selector.getAllAvailableModels();
1986
+ const profiler = new ModelProfiler(store, this);
1987
+ profiler.profileAll(allModels).catch(() => {
1988
+ });
1989
+ }
1693
1990
  async generate(tier, options, onChunk, requireVision = false) {
1694
1991
  if (this.budgetState === "exceeded") {
1695
1992
  throw new _CascadeRouter.BudgetExceededError(
@@ -1711,9 +2008,26 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1711
2008
  await this.tpmLimiter.acquire(model.provider, estimatedTokens);
1712
2009
  }
1713
2010
  const useStream = Boolean(onChunk) && model.supportsStreaming && typeof provider.generateStream === "function";
2011
+ let releaseLocalSlot;
2012
+ if (model.isLocal) {
2013
+ const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
2014
+ const queueWaitMs = Math.round(inferenceTimeoutMs / 2);
2015
+ releaseLocalSlot = await this.localQueue.acquire(queueWaitMs);
2016
+ }
1714
2017
  try {
1715
2018
  let result;
1716
- if (useStream && onChunk) {
2019
+ if (model.isLocal) {
2020
+ const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
2021
+ const inferencePromise = useStream && onChunk ? provider.generateStream(options, (chunk) => {
2022
+ const text = typeof chunk?.text === "string" ? chunk.text : "";
2023
+ if (text) onChunk({ ...chunk, text });
2024
+ }) : provider.generate(options);
2025
+ result = await withTimeout(
2026
+ inferencePromise,
2027
+ inferenceTimeoutMs,
2028
+ `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`
2029
+ );
2030
+ } else if (useStream && onChunk) {
1717
2031
  try {
1718
2032
  result = await provider.generateStream(options, (chunk) => {
1719
2033
  const text = typeof chunk?.text === "string" ? chunk.text : "";
@@ -1751,10 +2065,14 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1751
2065
  if (fallback) {
1752
2066
  this.tierModels.set(tier, fallback);
1753
2067
  this.ensureProvider(fallback, this.config.providers);
2068
+ releaseLocalSlot?.();
2069
+ releaseLocalSlot = void 0;
1754
2070
  return this.generate(tier, options, onChunk, requireVision);
1755
2071
  }
1756
2072
  }
1757
2073
  throw err;
2074
+ } finally {
2075
+ releaseLocalSlot?.();
1758
2076
  }
1759
2077
  }
1760
2078
  getModelForTier(tier) {
@@ -1994,29 +2312,6 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
1994
2312
  return /rate.?limit|429|too.?many.?requests|quota/i.test(msg);
1995
2313
  }
1996
2314
  };
1997
-
1998
- // src/utils/retry.ts
1999
- var CascadeCancelledError = class extends Error {
2000
- constructor(reason) {
2001
- super(reason ?? "Run was cancelled via AbortSignal");
2002
- this.name = "CascadeCancelledError";
2003
- }
2004
- };
2005
- var CascadeToolError = class extends Error {
2006
- /** A friendly message to show the user / T3 */
2007
- userMessage;
2008
- /** Whether this error class is retryable by default */
2009
- retryable;
2010
- constructor(userMessage, cause, retryable = false) {
2011
- const causeMsg = cause instanceof Error ? cause.message : String(cause);
2012
- super(`${userMessage}: ${causeMsg}`);
2013
- this.name = "CascadeToolError";
2014
- this.userMessage = userMessage;
2015
- this.retryable = retryable;
2016
- }
2017
- };
2018
-
2019
- // src/core/tiers/base.ts
2020
2315
  var BaseTier = class extends EventEmitter__default.default {
2021
2316
  id;
2022
2317
  role;
@@ -2297,6 +2592,97 @@ var AuditLogger = class {
2297
2592
  }
2298
2593
  };
2299
2594
 
2595
+ // src/tools/text-tool-parser.ts
2596
+ 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;
2599
+ 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);
2605
+ }
2606
+ function tryXmlBlocks(text) {
2607
+ const results = [];
2608
+ 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
+ }
2618
+ }
2619
+ return results;
2620
+ }
2621
+ function tryJsonCodeBlocks(text) {
2622
+ 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 {
2632
+ }
2633
+ }
2634
+ return results;
2635
+ }
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) {
2641
+ 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 });
2647
+ } catch {
2648
+ }
2649
+ }
2650
+ return results;
2651
+ }
2652
+ function toToolCall(parsed, index) {
2653
+ return {
2654
+ id: `text-tool-${Date.now()}-${index}`,
2655
+ name: parsed.name,
2656
+ input: parsed.input
2657
+ };
2658
+ }
2659
+ function buildTextToolSystemPrompt(tools) {
2660
+ 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 " : ""}}`;
2665
+ }).join("\n");
2666
+ return `
2667
+ TOOL USE INSTRUCTIONS:
2668
+ You do not have native tool-use capability. To call a tool, write a <tool_call> block:
2669
+
2670
+ <tool_call>
2671
+ {"name": "<tool_name>", "input": {<parameters>}}
2672
+ </tool_call>
2673
+
2674
+ Available tools:
2675
+ ${toolDefs}
2676
+
2677
+ EXAMPLE \u2014 calling the "shell" tool to list files:
2678
+ <tool_call>
2679
+ {"name": "shell", "input": {"command": "ls -la /workspace"}}
2680
+ </tool_call>
2681
+
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.`;
2684
+ }
2685
+
2300
2686
  // src/core/tiers/t3-worker.ts
2301
2687
  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.
2302
2688
 
@@ -2498,6 +2884,9 @@ Now execute your subtask using this context where relevant.`
2498
2884
  const MAX_ITERATIONS = 15;
2499
2885
  const requiresArtifact = this.requiresArtifact();
2500
2886
  tools = [...tools];
2887
+ const t3Model = this.router.getModelForTier("T3");
2888
+ const useTextTools = t3Model?.supportsToolUse === false && tools.length > 0;
2889
+ const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
2501
2890
  while (iterations < MAX_ITERATIONS) {
2502
2891
  iterations++;
2503
2892
  this.throwIfCancelled();
@@ -2505,8 +2894,9 @@ Now execute your subtask using this context where relevant.`
2505
2894
  messages: this.context.getMessages(),
2506
2895
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
2507
2896
 
2508
- HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2509
- tools: tools.length ? tools : void 0,
2897
+ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2898
+ // Don't pass tools array when model can't use them natively
2899
+ tools: useTextTools ? void 0 : tools.length ? tools : void 0,
2510
2900
  maxTokens: 4096
2511
2901
  };
2512
2902
  const result = await this.router.generate(
@@ -2516,8 +2906,14 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2516
2906
  this.emit("stream:token", { tierId: this.id, text: chunk.text });
2517
2907
  }
2518
2908
  );
2519
- await this.context.addMessage({ role: "assistant", content: result.content, toolCalls: result.toolCalls });
2520
- if (!result.toolCalls?.length) {
2909
+ let effectiveToolCalls = result.toolCalls ?? [];
2910
+ if (useTextTools && effectiveToolCalls.length === 0) {
2911
+ const textCalls = parseTextToolCalls(result.content);
2912
+ effectiveToolCalls = textCalls.map((tc, i) => toToolCall(tc, i));
2913
+ }
2914
+ const effectiveResult = { ...result, toolCalls: effectiveToolCalls };
2915
+ await this.context.addMessage({ role: "assistant", content: result.content, toolCalls: effectiveToolCalls });
2916
+ if (!effectiveResult.toolCalls?.length) {
2521
2917
  if (requiresArtifact) {
2522
2918
  const artifactCheck = await this.verifyArtifacts(this.assignment);
2523
2919
  if (artifactCheck.ok) {
@@ -2539,7 +2935,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2539
2935
  return { output: result.content, toolCalls: allToolCalls };
2540
2936
  }
2541
2937
  stalledArtifactIterations = 0;
2542
- if (result.finishReason === "stop") {
2938
+ if (effectiveResult.finishReason === "stop" && effectiveResult.toolCalls.length === 0) {
2543
2939
  if (requiresArtifact) {
2544
2940
  const artifactCheck = await this.verifyArtifacts(this.assignment);
2545
2941
  if (artifactCheck.ok) {
@@ -2549,7 +2945,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2549
2945
  return { output: result.content, toolCalls: allToolCalls };
2550
2946
  }
2551
2947
  }
2552
- for (const tc of result.toolCalls) {
2948
+ for (const tc of effectiveResult.toolCalls) {
2553
2949
  allToolCalls.push(tc);
2554
2950
  const toolResult = await this.executeTool(tc);
2555
2951
  await this.context.addMessage({
@@ -2607,13 +3003,15 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2607
3003
  currentAction: `Using tool: ${tc.name}`,
2608
3004
  status: "IN_PROGRESS"
2609
3005
  });
3006
+ this.emit("tool:call", { id: tc.id, tierId: this.id, toolName: tc.name, input: tc.input });
3007
+ const toolStartMs = Date.now();
2610
3008
  try {
2611
3009
  const result = await this.toolRegistry.execute(tc.name, tc.input, {
2612
3010
  tierId: this.id,
2613
3011
  sessionId: this.taskId,
2614
3012
  requireApproval: false,
2615
- saveSnapshot: async (path14, content) => {
2616
- this.store?.addFileSnapshot(this.taskId, path14, content);
3013
+ saveSnapshot: async (path17, content) => {
3014
+ this.store?.addFileSnapshot(this.taskId, path17, content);
2617
3015
  },
2618
3016
  sendPeerSync: (to, syncType, content) => {
2619
3017
  this.peerBus?.send(this.id, to, syncType, this.assignment?.subtaskId ?? "", content);
@@ -2630,12 +3028,84 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2630
3028
  this.audit.fileChange(this.id, tc.input["path"] ?? "unknown", tc.name);
2631
3029
  }
2632
3030
  }
2633
- this.emit("tool:result", { tierId: this.id, toolName: tc.name, result });
3031
+ const durationMs = Date.now() - toolStartMs;
3032
+ this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, output: typeof result === "string" ? result : JSON.stringify(result), durationMs });
2634
3033
  return typeof result === "string" ? result : JSON.stringify(result);
2635
3034
  } catch (err) {
2636
- return `Tool error: ${err instanceof Error ? err.message : String(err)}`;
3035
+ const durationMs = Date.now() - toolStartMs;
3036
+ const errMsg = err instanceof Error ? err.message : String(err);
3037
+ this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, error: errMsg, durationMs });
3038
+ return `Tool error: ${errMsg}`;
2637
3039
  }
2638
3040
  }
3041
+ /**
3042
+ * Adaptive fallback cascade — invoked when executeTool() fails.
3043
+ * Strategy order:
3044
+ * 1. Find a semantically similar registered tool and retry with same input
3045
+ * 2. Synthesize a new tool via ToolCreator (if available) and run it
3046
+ * 3. Return the original error so the agent loop can decide what to do next
3047
+ */
3048
+ async adaptiveFallback(tc, originalError) {
3049
+ const altTool = this.findAlternativeTool(tc.name);
3050
+ if (altTool) {
3051
+ this.log(`Adaptive fallback: trying alternative tool "${altTool}" for failed "${tc.name}"`);
3052
+ this.sendStatusUpdate({ progressPct: 50, currentAction: `Fallback: trying ${altTool}`, status: "IN_PROGRESS" });
3053
+ try {
3054
+ const result = await this.toolRegistry.execute(altTool, tc.input, {
3055
+ tierId: this.id,
3056
+ sessionId: this.taskId,
3057
+ requireApproval: false
3058
+ });
3059
+ const str = typeof result === "string" ? result : JSON.stringify(result);
3060
+ if (!str.startsWith("Tool error:") && !str.startsWith("Error:")) {
3061
+ return `[Fallback via ${altTool}]: ${str}`;
3062
+ }
3063
+ } catch {
3064
+ }
3065
+ }
3066
+ if (this.toolCreator) {
3067
+ this.log(`Adaptive fallback: requesting dynamic tool synthesis for "${tc.name}"`);
3068
+ this.sendStatusUpdate({ progressPct: 55, currentAction: `Synthesizing fallback tool for: ${tc.name}`, status: "IN_PROGRESS" });
3069
+ try {
3070
+ const newToolName = await this.toolCreator.createTool(
3071
+ `Replacement for "${tc.name}" \u2014 original failed with: ${originalError.slice(0, 150)}`,
3072
+ this.assignment?.subtaskTitle ?? tc.name
3073
+ );
3074
+ if (newToolName) {
3075
+ this.log(`Adaptive fallback: synthesized "${newToolName}", retrying`);
3076
+ const result = await this.toolRegistry.execute(newToolName, tc.input, {
3077
+ tierId: this.id,
3078
+ sessionId: this.taskId,
3079
+ requireApproval: false
3080
+ });
3081
+ const str = typeof result === "string" ? result : JSON.stringify(result);
3082
+ if (!str.startsWith("Tool error:")) return `[Synthesized ${newToolName}]: ${str}`;
3083
+ }
3084
+ } catch {
3085
+ }
3086
+ }
3087
+ return originalError;
3088
+ }
3089
+ /**
3090
+ * Find a registered tool whose name/description semantically overlaps with
3091
+ * the failing tool. Returns the best candidate name, or null if none found.
3092
+ */
3093
+ findAlternativeTool(failedToolName) {
3094
+ const failedKeywords = failedToolName.toLowerCase().split(/[_\-\s]+/);
3095
+ const allTools = this.toolRegistry.getToolDefinitions();
3096
+ let bestScore = 0;
3097
+ let bestName = null;
3098
+ for (const tool of allTools) {
3099
+ if (tool.name === failedToolName) continue;
3100
+ const toolWords = tool.name.toLowerCase().split(/[_\-\s]+/);
3101
+ const score = failedKeywords.filter((k) => toolWords.includes(k)).length;
3102
+ if (score > bestScore && score >= 1) {
3103
+ bestScore = score;
3104
+ bestName = tool.name;
3105
+ }
3106
+ }
3107
+ return bestName;
3108
+ }
2639
3109
  /**
2640
3110
  * Announce which files this T3 plans to edit, then acquire locks on them
2641
3111
  * before competing siblings can claim them. T3s working on different files
@@ -2694,12 +3164,12 @@ ${assignment.expectedOutput}`;
2694
3164
  if (!artifactPaths.length) return { ok: true, issues: [] };
2695
3165
  const issues = [];
2696
3166
  const { exec: exec3 } = await import('child_process');
2697
- const { promisify: promisify3 } = await import('util');
2698
- const execAsync2 = promisify3(exec3);
3167
+ const { promisify: promisify4 } = await import('util');
3168
+ const execAsync2 = promisify4(exec3);
2699
3169
  for (const artifactPath of artifactPaths) {
2700
- const absolutePath = path13__default.default.resolve(process.cwd(), artifactPath);
3170
+ const absolutePath = path16__default.default.resolve(process.cwd(), artifactPath);
2701
3171
  try {
2702
- const stat = await fs2__default.default.stat(absolutePath);
3172
+ const stat = await fs3__default.default.stat(absolutePath);
2703
3173
  if (!stat.isFile()) {
2704
3174
  issues.push(`Expected artifact is not a file: ${artifactPath}`);
2705
3175
  continue;
@@ -2709,7 +3179,7 @@ ${assignment.expectedOutput}`;
2709
3179
  continue;
2710
3180
  }
2711
3181
  if (!/\.pdf$/i.test(artifactPath)) {
2712
- const content = await fs2__default.default.readFile(absolutePath, "utf-8");
3182
+ const content = await fs3__default.default.readFile(absolutePath, "utf-8");
2713
3183
  if (!content.trim()) {
2714
3184
  issues.push(`Artifact content is empty: ${artifactPath}`);
2715
3185
  continue;
@@ -2718,7 +3188,7 @@ ${assignment.expectedOutput}`;
2718
3188
  issues.push(`PDF artifact looks too small to be valid: ${artifactPath}`);
2719
3189
  continue;
2720
3190
  }
2721
- const ext = path13__default.default.extname(absolutePath).toLowerCase();
3191
+ const ext = path16__default.default.extname(absolutePath).toLowerCase();
2722
3192
  try {
2723
3193
  if (ext === ".ts" || ext === ".tsx") {
2724
3194
  await execAsync2(`npx tsc --noEmit ${absolutePath}`, { timeout: 1e4 });
@@ -2836,6 +3306,11 @@ var PeerBus = class extends EventEmitter__default.default {
2836
3306
  barriers = /* @__PURE__ */ new Map();
2837
3307
  broadcastLog = [];
2838
3308
  fileLocks = /* @__PURE__ */ new Map();
3309
+ /** subtaskIds whose T3 is being retried by T2 — dependents should re-wait rather than fail fast */
3310
+ retryPending = /* @__PURE__ */ new Set();
3311
+ /** Called when any peer message or broadcast is sent — used for dashboard visibility. */
3312
+ onPeerMessage;
3313
+ sessionId = "";
2839
3314
  register(peerId) {
2840
3315
  this.members.add(peerId);
2841
3316
  }
@@ -2857,11 +3332,33 @@ var PeerBus = class extends EventEmitter__default.default {
2857
3332
  this.waiters.delete(subtaskId);
2858
3333
  }
2859
3334
  /**
2860
- * Wait for a specific subtask's output resolves immediately if already available
3335
+ * Mark a subtask as retry-pending so dependents re-wait instead of failing fast
3336
+ * when they see an ESCALATED status.
3337
+ */
3338
+ markRetryPending(subtaskId) {
3339
+ this.retryPending.add(subtaskId);
3340
+ this.outputs.delete(subtaskId);
3341
+ }
3342
+ /** Called by T2 after retry resolves (success or final failure). */
3343
+ clearRetryPending(subtaskId) {
3344
+ this.retryPending.delete(subtaskId);
3345
+ }
3346
+ /** Remove a single output entry so a respawned worker can republish without clearing prior-wave outputs. */
3347
+ clearOutput(subtaskId) {
3348
+ this.outputs.delete(subtaskId);
3349
+ this.waiters.delete(subtaskId);
3350
+ this.retryPending.delete(subtaskId);
3351
+ }
3352
+ isRetryPending(subtaskId) {
3353
+ return this.retryPending.has(subtaskId);
3354
+ }
3355
+ /**
3356
+ * Wait for a specific subtask's output — resolves immediately if already available.
3357
+ * If the output is ESCALATED but a retry is pending, waits for the retry result.
2861
3358
  */
2862
3359
  waitFor(subtaskId, timeoutMs = 12e4) {
2863
3360
  const existing = this.outputs.get(subtaskId);
2864
- if (existing) return Promise.resolve(existing);
3361
+ if (existing && !this.retryPending.has(subtaskId)) return Promise.resolve(existing);
2865
3362
  return new Promise((resolve, reject) => {
2866
3363
  const resolver = (output) => {
2867
3364
  clearTimeout(timer);
@@ -2892,6 +3389,7 @@ var PeerBus = class extends EventEmitter__default.default {
2892
3389
  * Also logs to broadcastLog so collect() can retrieve recent broadcasts.
2893
3390
  */
2894
3391
  broadcast(fromId, payload) {
3392
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2895
3393
  const msg = {
2896
3394
  fromId,
2897
3395
  toId: "*",
@@ -2899,10 +3397,18 @@ var PeerBus = class extends EventEmitter__default.default {
2899
3397
  subtaskId: "",
2900
3398
  syncType: "SHARE_OUTPUT",
2901
3399
  payload,
2902
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3400
+ timestamp
2903
3401
  };
2904
- this.broadcastLog.push({ fromId, payload, timestamp: msg.timestamp });
3402
+ this.broadcastLog.push({ fromId, payload, timestamp });
2905
3403
  this.emit("broadcast", msg);
3404
+ this.onPeerMessage?.({
3405
+ fromId,
3406
+ toId: void 0,
3407
+ syncType: "SHARE_OUTPUT",
3408
+ payload: typeof payload === "string" ? payload : JSON.stringify(payload),
3409
+ timestamp,
3410
+ sessionId: this.sessionId
3411
+ });
2906
3412
  }
2907
3413
  /**
2908
3414
  * Collect all broadcast messages received within a time window.
@@ -2988,6 +3494,16 @@ var PeerBus = class extends EventEmitter__default.default {
2988
3494
  isFileLocked(filePath) {
2989
3495
  return this.fileLocks.has(filePath);
2990
3496
  }
3497
+ /**
3498
+ * Reset all runtime output/waiter state for a fresh T3 respawn wave.
3499
+ * Preserves member registrations and barrier definitions.
3500
+ */
3501
+ reset() {
3502
+ this.outputs.clear();
3503
+ this.waiters.clear();
3504
+ this.retryPending.clear();
3505
+ this.broadcastLog = [];
3506
+ }
2991
3507
  /**
2992
3508
  * Clear broadcast log — call between phases to avoid stale announcements.
2993
3509
  */
@@ -2998,6 +3514,7 @@ var PeerBus = class extends EventEmitter__default.default {
2998
3514
  * Send a targeted message to a specific peer
2999
3515
  */
3000
3516
  send(fromId, toId, syncType, subtaskId, payload) {
3517
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
3001
3518
  const msg = {
3002
3519
  fromId,
3003
3520
  toId,
@@ -3005,10 +3522,18 @@ var PeerBus = class extends EventEmitter__default.default {
3005
3522
  subtaskId,
3006
3523
  syncType,
3007
3524
  payload,
3008
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3525
+ timestamp
3009
3526
  };
3010
3527
  this.emit(`message:${toId}`, msg);
3011
3528
  this.emit("message", msg);
3529
+ this.onPeerMessage?.({
3530
+ fromId,
3531
+ toId,
3532
+ syncType: syncType ?? "SHARE_OUTPUT",
3533
+ payload: typeof payload === "string" ? payload : JSON.stringify(payload),
3534
+ timestamp,
3535
+ sessionId: this.sessionId
3536
+ });
3012
3537
  }
3013
3538
  /**
3014
3539
  * Barrier — wait until N peers have all reached this point
@@ -3061,6 +3586,8 @@ var T2Manager = class extends BaseTier {
3061
3586
  t2PeerBus;
3062
3587
  permissionEscalator;
3063
3588
  toolCreator;
3589
+ /** AbortController for the current T3 wave — aborted on cancel-and-respawn */
3590
+ waveAbortController = null;
3064
3591
  setPeerBus(bus) {
3065
3592
  this.t2PeerBus = bus;
3066
3593
  this.t2PeerBus.register(this.id);
@@ -3069,6 +3596,14 @@ var T2Manager = class extends BaseTier {
3069
3596
  this.receivePeerSync(msg.fromId, msg.payload);
3070
3597
  });
3071
3598
  }
3599
+ setPeerMessageCallback(cb, sessionId) {
3600
+ this.t3PeerBus.onPeerMessage = cb;
3601
+ this.t3PeerBus.sessionId = sessionId;
3602
+ if (this.t2PeerBus) {
3603
+ this.t2PeerBus.onPeerMessage = cb;
3604
+ this.t2PeerBus.sessionId = sessionId;
3605
+ }
3606
+ }
3072
3607
  constructor(router, toolRegistry, parentId) {
3073
3608
  super("T2", void 0, parentId);
3074
3609
  this.router = router;
@@ -3236,6 +3771,26 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3236
3771
  }];
3237
3772
  }
3238
3773
  }
3774
+ buildWorkerMap(assignments, taskId) {
3775
+ const workerMap = /* @__PURE__ */ new Map();
3776
+ for (const a of assignments) {
3777
+ const worker = new T3Worker(this.router, this.toolRegistry, this.id);
3778
+ if (this.store) worker.setStore(this.store, taskId);
3779
+ worker.setPeerBus(this.t3PeerBus);
3780
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
3781
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
3782
+ workerMap.set(a.subtaskId, worker);
3783
+ this.t3Workers.set(a.subtaskId, worker);
3784
+ worker.on("stream:token", (e) => this.emit("stream:token", e));
3785
+ worker.on("log", (e) => this.emit("log", e));
3786
+ worker.on("tier:status", (e) => this.emit("tier:status", e));
3787
+ worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
3788
+ ...e,
3789
+ __cascadeResponder: (decision) => worker.emit(`tool:approval-response:${e.id}`, decision)
3790
+ }));
3791
+ }
3792
+ return workerMap;
3793
+ }
3239
3794
  async executeSubtasks(subtasks, taskId) {
3240
3795
  const assignments = subtasks.map((s) => ({
3241
3796
  ...s,
@@ -3262,6 +3817,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3262
3817
  worker.on("stream:token", (e) => this.emit("stream:token", e));
3263
3818
  worker.on("log", (e) => this.emit("log", e));
3264
3819
  worker.on("tier:status", (e) => this.emit("tier:status", e));
3820
+ worker.on("tool:call", (e) => this.emit("tool:call", e));
3821
+ worker.on("tool:result", (e) => this.emit("tool:result", e));
3265
3822
  worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
3266
3823
  ...e,
3267
3824
  __cascadeResponder: (decision) => worker.emit(`tool:approval-response:${e.id}`, decision)
@@ -3298,6 +3855,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3298
3855
  const sanitizedAssignments = this.breakCycles(assignments, adj, inDegree);
3299
3856
  let remaining = new Set(sanitizedAssignments.map((a) => a.subtaskId));
3300
3857
  let wave = 0;
3858
+ let respawnBudget = 1;
3301
3859
  while (remaining.size > 0) {
3302
3860
  const runnableIds = [...remaining].filter((id) => (inDegree.get(id) ?? 0) === 0);
3303
3861
  if (runnableIds.length === 0) {
@@ -3318,15 +3876,62 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3318
3876
  status: "IN_PROGRESS"
3319
3877
  });
3320
3878
  this.throwIfCancelled();
3879
+ this.waveAbortController = new AbortController();
3880
+ const waveSignal = AbortSignal.any(
3881
+ [this.signal, this.waveAbortController.signal].filter(Boolean)
3882
+ );
3321
3883
  const waveResults = await Promise.allSettled(
3322
3884
  runnableIds.map(async (id) => {
3323
3885
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3324
3886
  const worker = workerMap.get(id);
3325
- const result = await worker.execute(assignment, taskId, this.signal);
3887
+ const result = await worker.execute(assignment, taskId, waveSignal);
3326
3888
  resultMap.set(id, result);
3327
3889
  return result;
3328
3890
  })
3329
3891
  );
3892
+ const escalatedToolIdx = respawnBudget > 0 ? waveResults.findIndex(
3893
+ (r) => r.status === "fulfilled" && r.value.status === "ESCALATED" && r.value.issues.some((iss) => iss.includes("dynamic tool generation"))
3894
+ ) : -1;
3895
+ if (escalatedToolIdx !== -1 && this.toolCreator) {
3896
+ respawnBudget--;
3897
+ this.waveAbortController.abort();
3898
+ const escalatedId = runnableIds[escalatedToolIdx];
3899
+ const escalatedAssignment = sanitizedAssignments.find((a) => a.subtaskId === escalatedId);
3900
+ this.log(`Wave ${wave}: tool escalation detected \u2014 synthesizing tool then respawning all ${runnableIds.length} worker(s)`);
3901
+ this.sendStatusUpdate({
3902
+ progressPct: 50,
3903
+ currentAction: `Synthesizing dynamic tool for: ${escalatedAssignment.subtaskTitle}`,
3904
+ status: "IN_PROGRESS"
3905
+ });
3906
+ const toolName = await this.toolCreator.createTool(
3907
+ `Help complete: ${escalatedAssignment.subtaskTitle}`,
3908
+ escalatedAssignment.description
3909
+ );
3910
+ if (toolName) {
3911
+ this.log(`Tool "${toolName}" created \u2014 respawning wave ${wave} workers`);
3912
+ for (const a of sanitizedAssignments) {
3913
+ if (runnableIds.includes(a.subtaskId)) {
3914
+ a.description += `
3915
+
3916
+ [SYSTEM]: Dynamic tool "${toolName}" is now available \u2014 use it to complete your task.`;
3917
+ }
3918
+ }
3919
+ }
3920
+ for (const id of runnableIds) {
3921
+ this.t3PeerBus.clearOutput(id);
3922
+ }
3923
+ const freshMap = this.buildWorkerMap(
3924
+ sanitizedAssignments.filter((a) => runnableIds.includes(a.subtaskId)),
3925
+ taskId
3926
+ );
3927
+ for (const [k, v] of freshMap) workerMap.set(k, v);
3928
+ for (const id of runnableIds) {
3929
+ remaining.add(id);
3930
+ inDegree.set(id, 0);
3931
+ }
3932
+ wave--;
3933
+ continue;
3934
+ }
3330
3935
  for (let i = 0; i < runnableIds.length; i++) {
3331
3936
  const id = runnableIds[i];
3332
3937
  remaining.delete(id);
@@ -3334,61 +3939,22 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3334
3939
  if (r.status === "rejected") {
3335
3940
  this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
3336
3941
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3337
- const retried = await this.retryT3(assignment, taskId);
3338
- resultMap.set(id, retried);
3339
- } else if (r.status === "fulfilled" && r.value.status === "ESCALATED" && r.value.issues.some((i2) => i2.includes("dynamic tool generation"))) {
3340
- const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3341
- if (this.toolCreator) {
3342
- this.log(`T3 escalated for tool. T2 spawning Tool-Builder T3 for: ${assignment.subtaskTitle}`);
3343
- this.sendStatusUpdate({
3344
- progressPct: 50,
3345
- currentAction: `Spawning Tool-Builder T3 for: ${assignment.subtaskTitle}`,
3346
- status: "IN_PROGRESS"
3942
+ try {
3943
+ const retried = await this.retryT3(assignment, taskId);
3944
+ resultMap.set(id, retried);
3945
+ } catch (retryErr) {
3946
+ const msg = retryErr instanceof Error ? retryErr.message : String(retryErr);
3947
+ this.log(`T3 retry for ${id} threw before publishing \u2014 unblocking dependents with FAILED`);
3948
+ this.t3PeerBus.publish(this.id, id, `Retry failed: ${msg}`, "FAILED");
3949
+ resultMap.set(id, {
3950
+ subtaskId: id,
3951
+ status: "FAILED",
3952
+ output: `Retry threw: ${msg}`,
3953
+ testResults: { checksRun: [], passed: [], failed: [] },
3954
+ issues: [msg],
3955
+ peerSyncsUsed: [],
3956
+ correctionAttempts: 1
3347
3957
  });
3348
- const toolName = await this.toolCreator.createTool(
3349
- `Help complete: ${assignment.subtaskTitle}`,
3350
- assignment.description
3351
- );
3352
- if (toolName) {
3353
- this.log(`T2 verifying new tool: ${toolName}`);
3354
- this.sendStatusUpdate({
3355
- progressPct: 60,
3356
- currentAction: `T2 Verifying new tool: ${toolName}`,
3357
- status: "IN_PROGRESS"
3358
- });
3359
- try {
3360
- const verifyResult = await this.router.generate("T2", {
3361
- messages: [{ role: "user", content: `A new tool named "${toolName}" was just created dynamically to help with: ${assignment.description}. Based on its name and purpose, does this seem like a valid addition? Reply "VERIFIED" or "REJECTED".` }],
3362
- systemPrompt: this.systemPromptOverride + "You are T2 Manager verifying a dynamic tool.",
3363
- maxTokens: 50
3364
- });
3365
- if (!verifyResult.content.toUpperCase().includes("REJECTED")) {
3366
- this.log(`T2 verification passed for ${toolName}. Restarting original T3.`);
3367
- const retried = await this.retryT3({
3368
- ...assignment,
3369
- description: `${assignment.description}
3370
-
3371
- [SYSTEM NOTIFICATION]: A new dynamic tool "${toolName}" has been built and verified for you. Use it to complete your task.`
3372
- }, taskId);
3373
- resultMap.set(id, retried);
3374
- } else {
3375
- this.log(`T2 rejected the dynamic tool: ${toolName}`);
3376
- resultMap.set(id, r.value);
3377
- }
3378
- } catch {
3379
- const retried = await this.retryT3({
3380
- ...assignment,
3381
- description: `${assignment.description}
3382
-
3383
- [SYSTEM NOTIFICATION]: A new dynamic tool "${toolName}" has been built for you. Use it to complete your task.`
3384
- }, taskId);
3385
- resultMap.set(id, retried);
3386
- }
3387
- } else {
3388
- resultMap.set(id, r.value);
3389
- }
3390
- } else {
3391
- resultMap.set(id, r.value);
3392
3958
  }
3393
3959
  }
3394
3960
  for (const dependent of adj.get(id) ?? []) {
@@ -3659,6 +4225,8 @@ var T1Administrator = class extends BaseTier {
3659
4225
  toolCreator;
3660
4226
  /** Stored overall task goal — used when evaluating escalated permissions */
3661
4227
  taskGoal = "";
4228
+ peerMessageCallback;
4229
+ peerMessageSessionId = "";
3662
4230
  constructor(router, toolRegistry, config) {
3663
4231
  super("T1", "T1");
3664
4232
  this.router = router;
@@ -3679,6 +4247,12 @@ var T1Administrator = class extends BaseTier {
3679
4247
  setToolCreator(creator) {
3680
4248
  this.toolCreator = creator;
3681
4249
  }
4250
+ setPeerMessageCallback(cb, sessionId) {
4251
+ this.peerMessageCallback = cb;
4252
+ this.peerMessageSessionId = sessionId;
4253
+ this.t2PeerBus.onPeerMessage = cb;
4254
+ this.t2PeerBus.sessionId = sessionId;
4255
+ }
3682
4256
  async execute(userPrompt, images, systemContext, signal) {
3683
4257
  this.signal = signal;
3684
4258
  this.taskId = crypto.randomUUID();
@@ -3898,6 +4472,9 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
3898
4472
  manager.setStore(this.store);
3899
4473
  }
3900
4474
  manager.setPeerBus(this.t2PeerBus);
4475
+ if (this.peerMessageCallback) {
4476
+ manager.setPeerMessageCallback(this.peerMessageCallback, this.peerMessageSessionId);
4477
+ }
3901
4478
  if (this.permissionEscalator) {
3902
4479
  manager.setPermissionEscalator(this.permissionEscalator);
3903
4480
  }
@@ -3908,6 +4485,8 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
3908
4485
  bind(manager, "stream:token", (e) => this.emit("stream:token", e));
3909
4486
  bind(manager, "log", (e) => this.emit("log", e));
3910
4487
  bind(manager, "tier:status", (e) => this.emit("tier:status", e));
4488
+ bind(manager, "tool:call", (e) => this.emit("tool:call", e));
4489
+ bind(manager, "tool:result", (e) => this.emit("tool:result", e));
3911
4490
  bind(manager, "tool:approval-request", (e) => this.emit("tool:approval-request", e));
3912
4491
  bind(manager, "message", (msg) => {
3913
4492
  if (msg.type === "PEER_SYNC") {
@@ -4267,13 +4846,21 @@ function resolveInWorkspace(workspaceRoot, input) {
4267
4846
  if (typeof input !== "string" || input.length === 0) {
4268
4847
  throw new WorkspaceSandboxError(String(input), workspaceRoot);
4269
4848
  }
4270
- const root = path13__default.default.resolve(workspaceRoot);
4271
- const abs = path13__default.default.isAbsolute(input) ? path13__default.default.resolve(input) : path13__default.default.resolve(root, input);
4272
- const rel = path13__default.default.relative(root, abs);
4273
- if (rel === "" || rel === ".") return abs;
4274
- if (rel.startsWith("..") || path13__default.default.isAbsolute(rel)) {
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)) {
4275
4853
  throw new WorkspaceSandboxError(input, root);
4276
4854
  }
4855
+ 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))) {
4859
+ throw new WorkspaceSandboxError(input, root);
4860
+ }
4861
+ } catch (e) {
4862
+ if (e instanceof WorkspaceSandboxError) throw e;
4863
+ }
4277
4864
  return abs;
4278
4865
  }
4279
4866
 
@@ -4295,7 +4882,7 @@ var FileReadTool = class extends BaseTool {
4295
4882
  const absPath = resolveInWorkspace(this.workspaceRoot, filePath);
4296
4883
  const offset = input["offset"] ?? 1;
4297
4884
  const limit = input["limit"];
4298
- const content = await fs2__default.default.readFile(absPath, "utf-8");
4885
+ const content = await fs3__default.default.readFile(absPath, "utf-8");
4299
4886
  const lines = content.split("\n");
4300
4887
  const start = Math.max(0, offset - 1);
4301
4888
  const end = limit ? start + limit : lines.length;
@@ -4324,13 +4911,13 @@ var FileWriteTool = class extends BaseTool {
4324
4911
  const content = input["content"];
4325
4912
  if (options.saveSnapshot) {
4326
4913
  try {
4327
- const oldContent = await fs2__default.default.readFile(absPath, "utf-8");
4914
+ const oldContent = await fs3__default.default.readFile(absPath, "utf-8");
4328
4915
  await options.saveSnapshot(absPath, oldContent);
4329
4916
  } catch {
4330
4917
  }
4331
4918
  }
4332
- await fs2__default.default.mkdir(path13__default.default.dirname(absPath), { recursive: true });
4333
- await fs2__default.default.writeFile(absPath, content, "utf-8");
4919
+ await fs3__default.default.mkdir(path16__default.default.dirname(absPath), { recursive: true });
4920
+ await fs3__default.default.writeFile(absPath, content, "utf-8");
4334
4921
  return `Written ${content.length} characters to ${filePath}`;
4335
4922
  }
4336
4923
  };
@@ -4356,7 +4943,7 @@ var FileEditTool = class extends BaseTool {
4356
4943
  const oldString = input["old_string"];
4357
4944
  const newString = input["new_string"];
4358
4945
  const replaceAll = input["replace_all"] ?? false;
4359
- const rawContent = await fs2__default.default.readFile(absPath, "utf-8");
4946
+ const rawContent = await fs3__default.default.readFile(absPath, "utf-8");
4360
4947
  if (options.saveSnapshot) {
4361
4948
  await options.saveSnapshot(absPath, rawContent);
4362
4949
  }
@@ -4368,7 +4955,7 @@ var FileEditTool = class extends BaseTool {
4368
4955
  );
4369
4956
  }
4370
4957
  const updated = replaceAll ? content.split(normalizedOld).join(newString) : content.replace(normalizedOld, newString);
4371
- await fs2__default.default.writeFile(absPath, updated, "utf-8");
4958
+ await fs3__default.default.writeFile(absPath, updated, "utf-8");
4372
4959
  const count = replaceAll ? content.split(normalizedOld).length - 1 : 1;
4373
4960
  return `Replaced ${count} occurrence(s) in ${filePath}`;
4374
4961
  }
@@ -4391,12 +4978,12 @@ var FileDeleteTool = class extends BaseTool {
4391
4978
  const absPath = resolveInWorkspace(this.workspaceRoot, filePath);
4392
4979
  if (options.saveSnapshot) {
4393
4980
  try {
4394
- const oldContent = await fs2__default.default.readFile(absPath, "utf-8");
4981
+ const oldContent = await fs3__default.default.readFile(absPath, "utf-8");
4395
4982
  await options.saveSnapshot(absPath, oldContent);
4396
4983
  } catch {
4397
4984
  }
4398
4985
  }
4399
- await fs2__default.default.rm(absPath, { recursive: false });
4986
+ await fs3__default.default.rm(absPath, { recursive: false });
4400
4987
  return `Deleted ${filePath}`;
4401
4988
  }
4402
4989
  };
@@ -4413,7 +5000,7 @@ var FileListTool = class extends BaseTool {
4413
5000
  async execute(input, _options) {
4414
5001
  const inputPath = input["path"] || ".";
4415
5002
  const absPath = resolveInWorkspace(this.workspaceRoot, inputPath);
4416
- const entries = await fs2__default.default.readdir(absPath, { withFileTypes: true });
5003
+ const entries = await fs3__default.default.readdir(absPath, { withFileTypes: true });
4417
5004
  return entries.map((e) => `${e.isDirectory() ? "[DIR] " : " "}${e.name}`).join("\n") || "(empty directory)";
4418
5005
  }
4419
5006
  };
@@ -4796,8 +5383,8 @@ var ImageAnalyzeTool = class extends BaseTool {
4796
5383
  }
4797
5384
  };
4798
5385
  async function fileToImageAttachment(filePath) {
4799
- const data = await fs2__default.default.readFile(filePath);
4800
- const ext = path13__default.default.extname(filePath).toLowerCase();
5386
+ const data = await fs3__default.default.readFile(filePath);
5387
+ const ext = path16__default.default.extname(filePath).toLowerCase();
4801
5388
  const mimeMap = {
4802
5389
  ".jpg": "image/jpeg",
4803
5390
  ".jpeg": "image/jpeg",
@@ -4831,14 +5418,14 @@ var PDFCreateTool = class extends BaseTool {
4831
5418
  const filePath = input["path"];
4832
5419
  const content = input["content"];
4833
5420
  const title = input["title"];
4834
- const dir = path13__default.default.dirname(filePath);
4835
- if (!fs11__default.default.existsSync(dir)) {
4836
- fs11__default.default.mkdirSync(dir, { recursive: true });
5421
+ const dir = path16__default.default.dirname(filePath);
5422
+ if (!fs15__default.default.existsSync(dir)) {
5423
+ fs15__default.default.mkdirSync(dir, { recursive: true });
4837
5424
  }
4838
5425
  return new Promise((resolve, reject) => {
4839
5426
  try {
4840
5427
  const doc = new PDFDocument__default.default({ margin: 50 });
4841
- const stream = fs11__default.default.createWriteStream(filePath);
5428
+ const stream = fs15__default.default.createWriteStream(filePath);
4842
5429
  doc.pipe(stream);
4843
5430
  if (title) {
4844
5431
  doc.info["Title"] = title;
@@ -4916,14 +5503,14 @@ var CodeInterpreterTool = class extends BaseTool {
4916
5503
  }
4917
5504
  cmdPrefix = NODE_CMD;
4918
5505
  }
4919
- const tmpDir = path13__default.default.join(process.cwd(), ".cascade", "tmp");
4920
- if (!fs11__default.default.existsSync(tmpDir)) {
4921
- fs11__default.default.mkdirSync(tmpDir, { recursive: true });
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 });
4922
5509
  }
4923
5510
  const extension = language === "python" ? "py" : "js";
4924
5511
  const fileName = `intp_${crypto.randomUUID().slice(0, 8)}.${extension}`;
4925
- const filePath = path13__default.default.join(tmpDir, fileName);
4926
- fs11__default.default.writeFileSync(filePath, code, "utf-8");
5512
+ const filePath = path16__default.default.join(tmpDir, fileName);
5513
+ fs15__default.default.writeFileSync(filePath, code, "utf-8");
4927
5514
  const quotedPath = `"${filePath}"`;
4928
5515
  const quotedArgs = args.map((a) => `"${a}"`).join(" ");
4929
5516
  const fullCmd = `${cmdPrefix} ${quotedPath}${quotedArgs ? " " + quotedArgs : ""}`;
@@ -4932,8 +5519,8 @@ var CodeInterpreterTool = class extends BaseTool {
4932
5519
  child_process.exec(fullCmd, { cwd: process.cwd(), timeout: 3e4 }, (error, stdout, stderr) => {
4933
5520
  const duration = Date.now() - startMs;
4934
5521
  try {
4935
- if (fs11__default.default.existsSync(filePath)) {
4936
- fs11__default.default.unlinkSync(filePath);
5522
+ if (fs15__default.default.existsSync(filePath)) {
5523
+ fs15__default.default.unlinkSync(filePath);
4937
5524
  }
4938
5525
  } catch (cleanupErr) {
4939
5526
  console.error(`Failed to cleanup interpreter script ${filePath}:`, cleanupErr);
@@ -5193,6 +5780,253 @@ var WebSearchTool = class extends BaseTool {
5193
5780
  return lines.join("\n");
5194
5781
  }
5195
5782
  };
5783
+ var GlobTool = class extends BaseTool {
5784
+ name = "glob";
5785
+ description = "Fast file pattern matching. Returns file paths matching a glob pattern, sorted by modification time. Use this to find files by name patterns.";
5786
+ inputSchema = {
5787
+ type: "object",
5788
+ properties: {
5789
+ pattern: {
5790
+ type: "string",
5791
+ description: 'Glob pattern to match files against, e.g. "**/*.ts", "src/**/*.tsx"'
5792
+ },
5793
+ path: {
5794
+ type: "string",
5795
+ description: "Directory to search in. Defaults to the workspace root."
5796
+ }
5797
+ },
5798
+ required: ["pattern"]
5799
+ };
5800
+ async execute(input, _options) {
5801
+ const pattern = input["pattern"];
5802
+ const searchPath = input["path"] ? path16__default.default.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
5803
+ const matches = await glob.glob(pattern, {
5804
+ cwd: searchPath,
5805
+ ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
5806
+ nodir: true,
5807
+ dot: false
5808
+ });
5809
+ if (matches.length === 0) {
5810
+ return `No files matched pattern: ${pattern}`;
5811
+ }
5812
+ const withMtime = await Promise.all(
5813
+ matches.map(async (rel) => {
5814
+ try {
5815
+ const stat = await fs3__default.default.stat(path16__default.default.join(searchPath, rel));
5816
+ return { rel, mtime: stat.mtimeMs };
5817
+ } catch {
5818
+ return { rel, mtime: 0 };
5819
+ }
5820
+ })
5821
+ );
5822
+ withMtime.sort((a, b) => b.mtime - a.mtime);
5823
+ const lines = withMtime.map((f) => f.rel);
5824
+ return lines.join("\n");
5825
+ }
5826
+ };
5827
+ var execFileAsync = util.promisify(child_process.execFile);
5828
+ var GrepTool = class extends BaseTool {
5829
+ name = "grep";
5830
+ description = "Search file contents using a regex pattern. Returns matching lines with file paths and line numbers. Tries ripgrep (rg) first, falls back to Node.js regex scan.";
5831
+ inputSchema = {
5832
+ type: "object",
5833
+ properties: {
5834
+ pattern: {
5835
+ type: "string",
5836
+ description: "Regular expression pattern to search for in file contents"
5837
+ },
5838
+ path: {
5839
+ type: "string",
5840
+ description: "File or directory to search in. Defaults to workspace root."
5841
+ },
5842
+ glob: {
5843
+ type: "string",
5844
+ description: 'Glob pattern to filter files, e.g. "*.ts", "**/*.tsx"'
5845
+ },
5846
+ output_mode: {
5847
+ type: "string",
5848
+ enum: ["content", "files_with_matches", "count"],
5849
+ description: '"content" shows matching lines (default), "files_with_matches" shows file paths only, "count" shows match counts'
5850
+ },
5851
+ context: {
5852
+ type: "number",
5853
+ description: "Lines of context around each match (content mode only). Default: 0."
5854
+ },
5855
+ case_insensitive: {
5856
+ type: "boolean",
5857
+ description: "Case-insensitive search. Default: false."
5858
+ }
5859
+ },
5860
+ required: ["pattern"]
5861
+ };
5862
+ async execute(input, _options) {
5863
+ const pattern = input["pattern"];
5864
+ const searchPath = input["path"] ? path16__default.default.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
5865
+ const globPattern = input["glob"];
5866
+ const outputMode = input["output_mode"] ?? "content";
5867
+ const context = input["context"] ?? 0;
5868
+ const caseInsensitive = input["case_insensitive"] ?? false;
5869
+ try {
5870
+ const result = await this.runRipgrep(
5871
+ pattern,
5872
+ searchPath,
5873
+ globPattern,
5874
+ outputMode,
5875
+ context,
5876
+ caseInsensitive
5877
+ );
5878
+ return result;
5879
+ } catch {
5880
+ }
5881
+ return this.nodeScan(pattern, searchPath, globPattern, outputMode, context, caseInsensitive);
5882
+ }
5883
+ async runRipgrep(pattern, searchPath, globPattern, outputMode, context, caseInsensitive) {
5884
+ const args = ["--no-heading"];
5885
+ if (caseInsensitive) args.push("-i");
5886
+ if (outputMode === "files_with_matches") args.push("-l");
5887
+ else if (outputMode === "count") args.push("-c");
5888
+ else {
5889
+ args.push("-n");
5890
+ if (context > 0) args.push(`-C${context}`);
5891
+ }
5892
+ if (globPattern) args.push("--glob", globPattern);
5893
+ args.push("--", pattern, searchPath);
5894
+ const { stdout } = await execFileAsync("rg", args, {
5895
+ timeout: 15e3,
5896
+ maxBuffer: 2 * 1024 * 1024
5897
+ });
5898
+ const trimmed = stdout.trim();
5899
+ return trimmed || `No matches found for: ${pattern}`;
5900
+ }
5901
+ async nodeScan(pattern, searchPath, globPattern, outputMode, context, caseInsensitive) {
5902
+ const flags = caseInsensitive ? "gi" : "g";
5903
+ let regex;
5904
+ try {
5905
+ regex = new RegExp(pattern, flags);
5906
+ } catch {
5907
+ return `Invalid regex pattern: ${pattern}`;
5908
+ }
5909
+ const fileGlob = globPattern ?? "**/*";
5910
+ let files;
5911
+ try {
5912
+ files = await glob.glob(fileGlob, {
5913
+ cwd: searchPath,
5914
+ ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
5915
+ nodir: true
5916
+ });
5917
+ } catch {
5918
+ files = [path16__default.default.relative(searchPath, searchPath) || "."];
5919
+ }
5920
+ const results = [];
5921
+ let totalCount = 0;
5922
+ for (const rel of files) {
5923
+ const abs = path16__default.default.join(searchPath, rel);
5924
+ let content;
5925
+ try {
5926
+ content = await fs3__default.default.readFile(abs, "utf-8");
5927
+ } catch {
5928
+ continue;
5929
+ }
5930
+ const lines = content.split("\n");
5931
+ const matchingLines = [];
5932
+ for (let i = 0; i < lines.length; i++) {
5933
+ if (regex.test(lines[i])) matchingLines.push(i);
5934
+ regex.lastIndex = 0;
5935
+ }
5936
+ if (matchingLines.length === 0) continue;
5937
+ totalCount += matchingLines.length;
5938
+ if (outputMode === "files_with_matches") {
5939
+ results.push(rel);
5940
+ } else if (outputMode === "count") {
5941
+ results.push(`${rel}: ${matchingLines.length}`);
5942
+ } else {
5943
+ const shown = /* @__PURE__ */ new Set();
5944
+ for (const lineIdx of matchingLines) {
5945
+ const start = Math.max(0, lineIdx - context);
5946
+ const end = Math.min(lines.length - 1, lineIdx + context);
5947
+ for (let i = start; i <= end; i++) shown.add(i);
5948
+ }
5949
+ const sortedIdxs = [...shown].sort((a, b) => a - b);
5950
+ for (const i of sortedIdxs) {
5951
+ const marker = matchingLines.includes(i) ? ":" : "-";
5952
+ results.push(`${rel}:${i + 1}${marker}${lines[i]}`);
5953
+ }
5954
+ }
5955
+ }
5956
+ if (results.length === 0) return `No matches found for: ${pattern}`;
5957
+ if (outputMode === "count") {
5958
+ results.push(`
5959
+ Total: ${totalCount} matches`);
5960
+ }
5961
+ return results.join("\n");
5962
+ }
5963
+ };
5964
+
5965
+ // src/tools/web-fetch.ts
5966
+ var MAX_CHARS = 5e4;
5967
+ var TIMEOUT_MS = 15e3;
5968
+ function stripHtml(html) {
5969
+ let text = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
5970
+ text = text.replace(/<br\s*\/?>/gi, "\n").replace(/<\/p>/gi, "\n").replace(/<\/div>/gi, "\n").replace(/<\/h[1-6]>/gi, "\n").replace(/<\/li>/gi, "\n").replace(/<\/tr>/gi, "\n").replace(/<\/td>/gi, " ");
5971
+ text = text.replace(/<[^>]+>/g, "");
5972
+ text = text.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&nbsp;/g, " ").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
5973
+ text = text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).join("\n");
5974
+ return text;
5975
+ }
5976
+ var WebFetchTool = class extends BaseTool {
5977
+ name = "web_fetch";
5978
+ description = "Fetch a URL and return its content as plain text (HTML stripped). Use for reading documentation, web pages, or any URL. Limit: 50,000 characters.";
5979
+ inputSchema = {
5980
+ type: "object",
5981
+ properties: {
5982
+ url: {
5983
+ type: "string",
5984
+ description: "The URL to fetch"
5985
+ },
5986
+ prompt: {
5987
+ type: "string",
5988
+ description: "Optional hint for what information to extract from the page (not used for filtering, just context)"
5989
+ }
5990
+ },
5991
+ required: ["url"]
5992
+ };
5993
+ async execute(input, _options) {
5994
+ const url = input["url"];
5995
+ let resp;
5996
+ try {
5997
+ resp = await fetch(url, {
5998
+ headers: {
5999
+ "User-Agent": "Cascade-AI/1.0 WebFetchTool",
6000
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5"
6001
+ },
6002
+ signal: AbortSignal.timeout(TIMEOUT_MS),
6003
+ redirect: "follow"
6004
+ });
6005
+ } catch (err) {
6006
+ return `Failed to fetch ${url}: ${err instanceof Error ? err.message : String(err)}`;
6007
+ }
6008
+ if (!resp.ok) {
6009
+ return `HTTP ${resp.status} ${resp.statusText} from ${url}`;
6010
+ }
6011
+ const contentType = resp.headers.get("content-type") ?? "";
6012
+ let text;
6013
+ try {
6014
+ const raw = await resp.text();
6015
+ text = contentType.includes("html") ? stripHtml(raw) : raw;
6016
+ } catch (err) {
6017
+ return `Failed to read response body: ${err instanceof Error ? err.message : String(err)}`;
6018
+ }
6019
+ if (text.length > MAX_CHARS) {
6020
+ text = text.slice(0, MAX_CHARS) + `
6021
+
6022
+ [Content truncated at ${MAX_CHARS} characters]`;
6023
+ }
6024
+ return `URL: ${url}
6025
+ Content-Type: ${contentType}
6026
+
6027
+ ${text}`;
6028
+ }
6029
+ };
5196
6030
 
5197
6031
  // src/tools/mcp.ts
5198
6032
  var McpToolWrapper = class extends BaseTool {
@@ -5218,7 +6052,7 @@ var McpToolWrapper = class extends BaseTool {
5218
6052
 
5219
6053
  // src/tools/registry.ts
5220
6054
  var ignore = ignoreFactory__namespace.default.default ?? ignoreFactory__namespace.default;
5221
- var ToolRegistry = class {
6055
+ var ToolRegistry = class extends EventEmitter__default.default {
5222
6056
  tools = /* @__PURE__ */ new Map();
5223
6057
  config;
5224
6058
  ignoreMatcher = ignore();
@@ -5226,12 +6060,36 @@ var ToolRegistry = class {
5226
6060
  /** Loaded plugins, keyed by plugin name */
5227
6061
  plugins = /* @__PURE__ */ new Map();
5228
6062
  constructor(config, workspaceRoot = process.cwd()) {
6063
+ super();
5229
6064
  this.config = config;
5230
6065
  this.workspaceRoot = workspaceRoot;
5231
6066
  this.registerDefaults();
5232
6067
  }
5233
6068
  register(tool) {
5234
6069
  this.tools.set(tool.name, tool);
6070
+ this.emit("tool:added", tool.name);
6071
+ }
6072
+ /**
6073
+ * Wait until a named tool is registered, resolving immediately if it already exists.
6074
+ * T3 workers can call this after encountering a missing-tool error to resume
6075
+ * automatically once T2 synthesizes the tool.
6076
+ */
6077
+ waitForTool(toolName, timeoutMs = 6e4) {
6078
+ if (this.tools.has(toolName)) return Promise.resolve();
6079
+ return new Promise((resolve, reject) => {
6080
+ const timer = setTimeout(() => {
6081
+ this.off("tool:added", handler);
6082
+ reject(new Error(`Timeout waiting for tool: ${toolName}`));
6083
+ }, timeoutMs);
6084
+ const handler = (name) => {
6085
+ if (name === toolName) {
6086
+ clearTimeout(timer);
6087
+ this.off("tool:added", handler);
6088
+ resolve();
6089
+ }
6090
+ };
6091
+ this.on("tool:added", handler);
6092
+ });
5235
6093
  }
5236
6094
  /**
5237
6095
  * Register a ToolPlugin, loading all its tools into the registry.
@@ -5316,7 +6174,10 @@ var ToolRegistry = class {
5316
6174
  new PDFCreateTool(),
5317
6175
  new CodeInterpreterTool(),
5318
6176
  new PeerCommunicationTool(),
5319
- new WebSearchTool(this.config.webSearch)
6177
+ new WebSearchTool(this.config.webSearch),
6178
+ new GlobTool(),
6179
+ new GrepTool(),
6180
+ new WebFetchTool()
5320
6181
  ];
5321
6182
  for (const tool of tools) {
5322
6183
  tool.setWorkspaceRoot(this.workspaceRoot);
@@ -5333,10 +6194,10 @@ var ToolRegistry = class {
5333
6194
  }
5334
6195
  isIgnored(filePath) {
5335
6196
  if (!filePath) return false;
5336
- const abs = path13__default.default.resolve(this.workspaceRoot, filePath);
5337
- const rel = path13__default.default.relative(this.workspaceRoot, abs);
5338
- if (!rel || rel.startsWith("..") || path13__default.default.isAbsolute(rel)) return true;
5339
- const posixRel = rel.split(path13__default.default.sep).join("/");
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("/");
5340
6201
  return this.ignoreMatcher.ignores(posixRel);
5341
6202
  }
5342
6203
  };
@@ -5673,7 +6534,24 @@ var CascadeConfigSchema = zod.z.object({
5673
6534
  * Generated tools are session-scoped and sandboxed in node:vm.
5674
6535
  * HTTP calls from generated tools require approval.
5675
6536
  */
5676
- enableToolCreation: zod.z.boolean().default(false)
6537
+ enableToolCreation: zod.z.boolean().default(true),
6538
+ /**
6539
+ * External plugin paths or npm package names to load at startup.
6540
+ * Each entry must export a default ToolPlugin object.
6541
+ * Example: ["./plugins/my-tool.js", "cascade-plugin-slack"]
6542
+ */
6543
+ plugins: zod.z.array(zod.z.string()).default([]),
6544
+ /**
6545
+ * Maximum number of concurrent inference requests to any local model provider
6546
+ * (e.g. Ollama). Defaults to 1 to prevent GPU memory pressure when multiple
6547
+ * T3 workers run in parallel on a single-GPU machine.
6548
+ */
6549
+ localConcurrency: zod.z.number().int().min(1).default(1),
6550
+ /**
6551
+ * Timeout in milliseconds for a single local model inference call.
6552
+ * Local models can take minutes for large parameter counts. Default: 5 minutes.
6553
+ */
6554
+ localInferenceTimeoutMs: zod.z.number().int().min(1e3).default(3e5)
5677
6555
  });
5678
6556
 
5679
6557
  // src/config/validate.ts
@@ -5801,139 +6679,237 @@ function heuristicAnalyze(prompt) {
5801
6679
  const estimatedTokens = wordCount * 5;
5802
6680
  return { type, complexity, requiresReasoning, requiresVision, estimatedTokens, confidence };
5803
6681
  }
5804
- function selectModelFromProfile(profile, tier, selector) {
5805
- if (profile.requiresVision) {
5806
- return selector.selectVisionModel();
5807
- }
5808
- if (tier === "T1") {
5809
- if (profile.complexity >= 4) {
5810
- return selector.selectForTier("T1");
5811
- } else {
5812
- return selector.selectForTier("T2");
5813
- }
5814
- }
5815
- if (tier === "T2") {
5816
- if (profile.type === "code" || profile.type === "data") {
5817
- return selector.selectForTier("T2");
5818
- } else if (profile.complexity <= 2) {
5819
- return selector.selectForTier("T3");
5820
- }
5821
- return selector.selectForTier("T2");
5822
- }
5823
- if (tier === "T3") {
5824
- if (profile.complexity >= 4 || profile.requiresReasoning) {
5825
- return selector.selectForTier("T2");
5826
- } else if (profile.type === "creative") {
5827
- return selector.selectForTier("T2");
5828
- } else {
5829
- return selector.selectForTier("T3");
5830
- }
5831
- }
5832
- return selector.selectForTier(tier);
5833
- }
5834
6682
  var analysisCache = /* @__PURE__ */ new Map();
6683
+ var TASK_TYPE_TAGS = {
6684
+ code: ["code", "instruction"],
6685
+ analysis: ["analysis", "instruction"],
6686
+ creative: ["creative", "multilingual"],
6687
+ data: ["data", "code"],
6688
+ mixed: []
6689
+ };
5835
6690
  var TaskAnalyzer = class {
5836
- router;
5837
- constructor(router) {
5838
- this.router = router;
6691
+ tracker;
6692
+ lastProfile = null;
6693
+ lastSelectedModels = /* @__PURE__ */ new Map();
6694
+ constructor(tracker) {
6695
+ this.tracker = tracker;
6696
+ }
6697
+ setTracker(tracker) {
6698
+ this.tracker = tracker;
6699
+ }
6700
+ /** Returns the TaskProfile from the most recent analyze() call — used for outcome recording. */
6701
+ getLastProfile() {
6702
+ return this.lastProfile;
5839
6703
  }
5840
6704
  /**
5841
- * Analyze a prompt and return a TaskProfile.
5842
- * Uses heuristics first; falls back to AI inference if confidence is low.
6705
+ * Analyze a prompt and return a TaskProfile using pure heuristics.
6706
+ * Low confidence prompts fall back to a conservative mixed/moderate profile.
5843
6707
  */
5844
6708
  async analyze(prompt) {
5845
6709
  const cacheKey = prompt.slice(0, 200);
5846
6710
  const cached = analysisCache.get(cacheKey);
5847
- if (cached) return cached;
5848
- const heuristic = heuristicAnalyze(prompt);
5849
- if (heuristic.confidence < 0.7 && this.router) {
5850
- try {
5851
- const aiProfile = await this.aiInference(prompt);
5852
- const merged = {
5853
- type: aiProfile.type,
5854
- complexity: aiProfile.complexity,
5855
- requiresReasoning: aiProfile.requiresReasoning,
5856
- requiresVision: heuristic.requiresVision || aiProfile.requiresVision,
5857
- estimatedTokens: heuristic.estimatedTokens,
5858
- confidence: 0.9
5859
- // AI-backed
5860
- };
5861
- analysisCache.set(cacheKey, merged);
5862
- return merged;
5863
- } catch {
5864
- }
6711
+ if (cached) {
6712
+ this.lastProfile = cached;
6713
+ return cached;
5865
6714
  }
5866
- analysisCache.set(cacheKey, heuristic);
5867
- return heuristic;
6715
+ const profile = heuristicAnalyze(prompt);
6716
+ analysisCache.set(cacheKey, profile);
6717
+ this.lastProfile = profile;
6718
+ return profile;
5868
6719
  }
5869
6720
  /**
5870
- * Select the optimal model for a given tier based on task analysis.
6721
+ * Select the optimal model for a given tier.
6722
+ * Scores tier-eligible models using cost efficiency + historical performance.
6723
+ * Falls back to the priority-list default when no candidates have history.
5871
6724
  */
5872
6725
  async selectModel(prompt, tier, selector) {
5873
6726
  const profile = await this.analyze(prompt);
5874
- return selectModelFromProfile(profile, tier, selector);
6727
+ if (profile.requiresVision) {
6728
+ return selector.selectVisionModel();
6729
+ }
6730
+ const candidates = selector.getCandidatesForTier(tier);
6731
+ if (candidates.length === 0) return selector.selectForTier(tier);
6732
+ const scored = candidates.map((m) => ({
6733
+ model: m,
6734
+ score: this.scoreModel(m, profile)
6735
+ }));
6736
+ scored.sort((a, b) => b.score - a.score);
6737
+ const best = scored[0]?.model ?? selector.selectForTier(tier);
6738
+ if (best) this.lastSelectedModels.set(tier, best);
6739
+ return best;
5875
6740
  }
5876
- async aiInference(prompt) {
5877
- if (!this.router) throw new Error("No router for AI inference");
5878
- const inferencePrompt = `Analyze this task and return ONLY a JSON object \u2014 no other text.
5879
-
5880
- Task: "${prompt.slice(0, 300)}"
5881
-
5882
- Return: { "type": "code"|"analysis"|"creative"|"data"|"mixed", "complexity": 1-5, "requiresReasoning": true|false, "requiresVision": true|false }
5883
-
5884
- Where complexity: 1=trivial, 2=simple, 3=moderate, 4=complex, 5=research-grade.`;
5885
- const result = await this.router.generate("T3", {
5886
- messages: [{ role: "user", content: inferencePrompt }],
5887
- maxTokens: 80
5888
- });
5889
- const jsonMatch = /\{[\s\S]*?\}/.exec(result.content);
5890
- if (!jsonMatch) throw new Error("No JSON in AI inference response");
5891
- const parsed = JSON.parse(jsonMatch[0]);
5892
- const validTypes = ["code", "analysis", "creative", "data", "mixed"];
5893
- const type = validTypes.includes(parsed.type) ? parsed.type : "mixed";
5894
- const complexity = Math.max(1, Math.min(5, Math.round(parsed.complexity)));
5895
- return {
5896
- type,
5897
- complexity,
5898
- requiresReasoning: Boolean(parsed.requiresReasoning),
5899
- requiresVision: Boolean(parsed.requiresVision),
5900
- estimatedTokens: 0,
5901
- confidence: 0.9
5902
- };
6741
+ /**
6742
+ * Record the outcome of a completed run across all tiers that were selected
6743
+ * during this session and persist stats to disk.
6744
+ */
6745
+ recordRunOutcome(outcome, costByTier) {
6746
+ if (!this.tracker || !this.lastProfile) return;
6747
+ const taskType = this.lastProfile.type;
6748
+ for (const [tier, model] of this.lastSelectedModels) {
6749
+ const cost = costByTier[tier] ?? 0;
6750
+ this.tracker.record(model.id, taskType, outcome, 0, cost);
6751
+ }
6752
+ this.lastSelectedModels.clear();
6753
+ void this.tracker.save();
6754
+ }
6755
+ scoreModel(model, profile) {
6756
+ const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
6757
+ const costEff = this.costEfficiency(model, profile.complexity);
6758
+ const match = this.taskMatchScore(model, profile);
6759
+ return perf * costEff * match;
6760
+ }
6761
+ costEfficiency(model, complexity) {
6762
+ if (this.tracker) return this.tracker.costEfficiencyScore(model, complexity);
6763
+ const blended = model.inputCostPer1kTokens + model.outputCostPer1kTokens * 2;
6764
+ const normalised = Math.min(1, blended / 0.05);
6765
+ const complexityWeight = (6 - complexity) / 5;
6766
+ return Math.max(0.1, 1 - normalised * complexityWeight);
6767
+ }
6768
+ taskMatchScore(model, profile) {
6769
+ const expected = TASK_TYPE_TAGS[profile.type];
6770
+ if (!model.specializations?.length || expected.length === 0) return 1;
6771
+ const matches = expected.filter((tag) => model.specializations.includes(tag)).length;
6772
+ return matches > 0 ? 1 + matches / expected.length * 0.3 : 0.8;
5903
6773
  }
5904
6774
  /** Clear the analysis cache (call between sessions). */
5905
6775
  static clearCache() {
5906
6776
  analysisCache.clear();
5907
6777
  }
5908
6778
  };
6779
+ var DEFAULT_STATS_FILE = path16__default.default.join(os3__default.default.homedir(), ".cascade", "model-perf.json");
6780
+ var ModelPerformanceTracker = class {
6781
+ stats = /* @__PURE__ */ new Map();
6782
+ statsFile;
6783
+ loaded = false;
6784
+ constructor(statsFile = DEFAULT_STATS_FILE) {
6785
+ this.statsFile = statsFile;
6786
+ }
6787
+ async load() {
6788
+ if (this.loaded) return;
6789
+ this.loaded = true;
6790
+ try {
6791
+ const raw = await fs3__default.default.readFile(this.statsFile, "utf-8");
6792
+ const parsed = JSON.parse(raw);
6793
+ for (const [key, stat] of Object.entries(parsed)) {
6794
+ this.stats.set(key, stat);
6795
+ }
6796
+ } catch {
6797
+ }
6798
+ }
6799
+ async save() {
6800
+ try {
6801
+ await fs3__default.default.mkdir(path16__default.default.dirname(this.statsFile), { recursive: true });
6802
+ const obj = {};
6803
+ 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");
6805
+ } catch {
6806
+ }
6807
+ }
6808
+ record(modelId, taskType, outcome, retries = 0, costUsd = 0) {
6809
+ const key = `${modelId}:${taskType}`;
6810
+ const s = this.stats.get(key) ?? {
6811
+ successCount: 0,
6812
+ failureCount: 0,
6813
+ totalRetries: 0,
6814
+ totalCostUsd: 0,
6815
+ sampleCount: 0
6816
+ };
6817
+ this.stats.set(key, {
6818
+ successCount: s.successCount + (outcome === "success" ? 1 : 0),
6819
+ failureCount: s.failureCount + (outcome === "failure" ? 1 : 0),
6820
+ totalRetries: s.totalRetries + retries,
6821
+ totalCostUsd: s.totalCostUsd + costUsd,
6822
+ sampleCount: s.sampleCount + 1
6823
+ });
6824
+ }
6825
+ /**
6826
+ * Returns 0.05–1.0; defaults to 0.5 (neutral prior) when no history exists.
6827
+ * High retry counts penalise the score.
6828
+ */
6829
+ performanceScore(modelId, taskType) {
6830
+ const key = `${modelId}:${taskType}`;
6831
+ const s = this.stats.get(key);
6832
+ if (!s || s.sampleCount === 0) return 0.5;
6833
+ const successRate = s.successCount / s.sampleCount;
6834
+ const avgRetries = s.totalRetries / s.sampleCount;
6835
+ const retryPenalty = Math.min(0.4, avgRetries / 3);
6836
+ return Math.max(0.05, successRate * (1 - retryPenalty));
6837
+ }
6838
+ /**
6839
+ * Returns 0.1–1.0. Cheaper models score higher, with the penalty scaled
6840
+ * down for complex tasks (where capability matters more than cost).
6841
+ *
6842
+ * blended cost = input + 2 × output (output tokens are typically pricier).
6843
+ * normalised over $0.05 blended as the "expensive" ceiling.
6844
+ */
6845
+ costEfficiencyScore(model, complexity) {
6846
+ const blended = model.inputCostPer1kTokens + model.outputCostPer1kTokens * 2;
6847
+ const normalised = Math.min(1, blended / 0.05);
6848
+ const complexityWeight = (6 - complexity) / 5;
6849
+ return Math.max(0.1, 1 - normalised * complexityWeight);
6850
+ }
6851
+ };
5909
6852
  var DynamicTool = class extends BaseTool {
5910
6853
  name;
5911
6854
  description;
5912
6855
  inputSchema;
5913
6856
  executeCode;
5914
6857
  _isDangerous;
5915
- constructor(spec) {
6858
+ registry;
6859
+ escalator;
6860
+ constructor(spec, registry, escalator) {
5916
6861
  super();
5917
6862
  this.name = spec.name;
5918
6863
  this.description = spec.description;
5919
6864
  this.inputSchema = spec.inputSchema;
5920
6865
  this.executeCode = spec.executeCode;
5921
6866
  this._isDangerous = spec.isDangerous;
6867
+ this.registry = registry;
6868
+ this.escalator = escalator;
5922
6869
  }
5923
6870
  isDangerous() {
5924
6871
  return this._isDangerous;
5925
6872
  }
5926
- async execute(input, _options) {
6873
+ async execute(input, options) {
6874
+ const registry = this.registry;
6875
+ const escalator = this.escalator;
6876
+ const callTool = async (toolName, toolInput) => {
6877
+ if (!registry.hasTool(toolName)) return `Tool not found: ${toolName}`;
6878
+ 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
+ }
6894
+ }
6895
+ }
6896
+ try {
6897
+ const result = await registry.execute(toolName, toolInput, options);
6898
+ return typeof result === "string" ? result : JSON.stringify(result);
6899
+ } catch (err) {
6900
+ return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
6901
+ }
6902
+ };
5927
6903
  const sandbox = {
5928
6904
  input,
5929
6905
  fetch: globalThis.fetch,
6906
+ callTool,
5930
6907
  JSON,
5931
6908
  Math,
5932
6909
  Date,
5933
6910
  console: { log: () => {
5934
6911
  }, error: () => {
5935
6912
  } },
5936
- // Silenced
5937
6913
  setTimeout,
5938
6914
  clearTimeout,
5939
6915
  Promise,
@@ -5966,29 +6942,42 @@ Generate a minimal, safe JavaScript tool function for the described operation.
5966
6942
 
5967
6943
  Rules:
5968
6944
  - Return ONLY a JSON object with these fields: name, description, inputSchema, executeCode, isDangerous
5969
- - executeCode is a self-contained JavaScript function body that:
5970
- - Receives: input (object), fetch (if HTTP needed)
6945
+ - executeCode is a self-contained JavaScript async function body that:
6946
+ - Receives: input (object), fetch (for HTTP), callTool(toolName, input) (to call any registered cascade tool)
5971
6947
  - Returns: a string result
5972
- - Uses no require(), no fs, no process \u2014 only fetch, JSON, Math, Date, String, Number, Array, Object
6948
+ - For file operations, prefer: await callTool('file_read', { path: input.path })
6949
+ - For shell commands, prefer: await callTool('shell', { command: 'ls -la' })
6950
+ - For pure computation / HTTP: use fetch or built-ins (JSON, Math, Date, String, Number, Array, Object)
5973
6951
  - Must complete in under 15 seconds
5974
- - isDangerous should be true only if the tool makes write operations or external HTTP calls
6952
+ - isDangerous: true if the tool calls dangerous cascade tools (shell, file_write, file_delete, git) or makes HTTP calls that write data
5975
6953
  - name must be snake_case, start with "dynamic_", max 40 chars
5976
6954
  - description must be \u2264 120 chars
5977
6955
 
5978
- Example executeCode for an HTTP tool:
5979
- "const res = await fetch(input.url); const text = await res.text(); return text.slice(0, 2000);"
6956
+ Example for a file-summary tool:
6957
+ {
6958
+ "name": "dynamic_summarize_file",
6959
+ "description": "Read a file and return a one-paragraph summary",
6960
+ "inputSchema": { "path": { "type": "string", "description": "File path to summarize" } },
6961
+ "executeCode": "const content = await callTool('file_read', { path: input.path }); return content.slice(0, 500);",
6962
+ "isDangerous": false
6963
+ }
5980
6964
 
5981
6965
  Return ONLY valid JSON \u2014 no other text.`;
5982
6966
  var ToolCreator = class {
5983
6967
  router;
5984
6968
  registry;
6969
+ escalator;
5985
6970
  createdTools = /* @__PURE__ */ new Set();
5986
6971
  constructor(router, registry) {
5987
6972
  this.router = router;
5988
6973
  this.registry = registry;
5989
6974
  }
6975
+ setPermissionEscalator(escalator) {
6976
+ this.escalator = escalator;
6977
+ }
5990
6978
  /**
5991
6979
  * 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().
5992
6981
  * Returns the tool name if successful, null if generation failed.
5993
6982
  */
5994
6983
  async createTool(description, context) {
@@ -5999,26 +6988,21 @@ Required capability: ${description.slice(0, 300)}`;
5999
6988
  try {
6000
6989
  const result = await this.router.generate("T3", {
6001
6990
  messages: [{ role: "user", content: prompt }],
6002
- maxTokens: 600
6991
+ maxTokens: 800
6003
6992
  });
6004
6993
  const jsonMatch = /\{[\s\S]*\}/.exec(result.content);
6005
- if (!jsonMatch) {
6006
- return null;
6007
- }
6994
+ if (!jsonMatch) return null;
6008
6995
  const spec = JSON.parse(jsonMatch[0]);
6009
- if (!spec.name || !spec.description || !spec.executeCode || !spec.inputSchema) {
6010
- return null;
6011
- }
6996
+ if (!spec.name || !spec.description || !spec.executeCode || !spec.inputSchema) return null;
6012
6997
  if (this.createdTools.has(spec.name) || this.registry.hasTool(spec.name)) {
6013
6998
  spec.name = `${spec.name}_${Date.now() % 1e4}`;
6014
6999
  }
6015
7000
  try {
6016
- vm.createContext({ input: {}, fetch: globalThis.fetch });
6017
- new Function("input", "fetch", spec.executeCode);
6018
- } catch (err) {
7001
+ new Function("input", "fetch", "callTool", spec.executeCode);
7002
+ } catch {
6019
7003
  return null;
6020
7004
  }
6021
- const tool = new DynamicTool(spec);
7005
+ const tool = new DynamicTool(spec, this.registry, this.escalator);
6022
7006
  this.registry.register(tool);
6023
7007
  this.createdTools.add(spec.name);
6024
7008
  return spec.name;
@@ -6026,16 +7010,14 @@ Required capability: ${description.slice(0, 300)}`;
6026
7010
  return null;
6027
7011
  }
6028
7012
  }
6029
- /**
6030
- * Returns the names of all tools created in this session.
6031
- */
7013
+ /** Returns the names of all tools created in this session. */
6032
7014
  getCreatedTools() {
6033
7015
  return Array.from(this.createdTools);
6034
7016
  }
6035
7017
  };
6036
7018
 
6037
7019
  // src/core/cascade.ts
6038
- var Cascade = class extends EventEmitter__default.default {
7020
+ var Cascade = class _Cascade extends EventEmitter__default.default {
6039
7021
  router;
6040
7022
  toolRegistry;
6041
7023
  mcpClient;
@@ -6046,6 +7028,7 @@ var Cascade = class extends EventEmitter__default.default {
6046
7028
  audit;
6047
7029
  telemetry;
6048
7030
  taskAnalyzer;
7031
+ perfTracker;
6049
7032
  toolCreator;
6050
7033
  constructor(config, workspacePath, store) {
6051
7034
  super();
@@ -6062,10 +7045,12 @@ var Cascade = class extends EventEmitter__default.default {
6062
7045
  this.telemetry = config.telemetry?.enabled ? new Telemetry(config.telemetry, config.telemetry.distinctId ?? "anonymous") : noopTelemetry;
6063
7046
  }
6064
7047
  initOptionalFeatures() {
6065
- const cfg = this.config;
6066
- if (cfg["cascadeAuto"] === true) {
6067
- this.taskAnalyzer = new TaskAnalyzer(this.router);
7048
+ if (this.config.cascadeAuto === true) {
7049
+ this.perfTracker = new ModelPerformanceTracker();
7050
+ void this.perfTracker.load();
7051
+ this.taskAnalyzer = new TaskAnalyzer(this.perfTracker);
6068
7052
  }
7053
+ const cfg = this.config;
6069
7054
  if (cfg["enableToolCreation"] === true) {
6070
7055
  this.toolCreator = new ToolCreator(this.router, this.toolRegistry);
6071
7056
  }
@@ -6131,6 +7116,26 @@ var Cascade = class extends EventEmitter__default.default {
6131
7116
  }
6132
7117
  }
6133
7118
  }
7119
+ const pluginPaths = this.config["plugins"];
7120
+ if (pluginPaths?.length) {
7121
+ for (const pluginPath of pluginPaths) {
7122
+ try {
7123
+ const mod = await import(pluginPath);
7124
+ const plugin = mod.default ?? mod;
7125
+ if (plugin && Array.isArray(plugin.tools)) {
7126
+ this.toolRegistry.registerPlugin(plugin);
7127
+ } else {
7128
+ console.warn(`[cascade] Plugin "${pluginPath}" does not export a valid ToolPlugin.`);
7129
+ }
7130
+ } catch (err) {
7131
+ console.warn(`[cascade] Failed to load plugin "${pluginPath}":`, err);
7132
+ }
7133
+ }
7134
+ }
7135
+ if (this.config.cascadeAuto && this.store) {
7136
+ this.router.profileModels(this.store).catch(() => {
7137
+ });
7138
+ }
6134
7139
  this.initOptionalFeatures();
6135
7140
  this.initialized = true;
6136
7141
  })();
@@ -6148,21 +7153,41 @@ var Cascade = class extends EventEmitter__default.default {
6148
7153
  looksLikeSimpleArtifactTask(prompt) {
6149
7154
  return /create .*\.(txt|md|json|csv)\b/i.test(prompt) && !/(research|compare|thorough|pdf|report|analy[sz]e|architecture|multi-agent)/i.test(prompt);
6150
7155
  }
6151
- async determineComplexity(prompt, workspacePath, conversationHistory = []) {
6152
- if (this.isCasualGreeting(prompt)) {
6153
- return "Simple";
6154
- }
6155
- if (this.looksLikeSimpleArtifactTask(prompt)) {
6156
- return "Simple";
6157
- }
6158
- let workspaceContext = "";
7156
+ looksLikeConversational(prompt) {
7157
+ const LOW_COMPLEXITY = [
7158
+ /^(?:hi|hello|hey|thanks|thank you|ok|okay|yes|no|sure|got it|sounds good)\b/i,
7159
+ /^(?:what is|what are|list|show me|tell me|who is|where is|when is|how do i)\b/i,
7160
+ /\b(?:simple|quick|brief|small|single|one-line|typo|rename)\b/i
7161
+ ];
7162
+ const wordCount = prompt.trim().split(/\s+/).length;
7163
+ return wordCount <= 12 && LOW_COMPLEXITY.some((re) => re.test(prompt.trim()));
7164
+ }
7165
+ // Cache glob scan results per workspace path to avoid repeated I/O.
7166
+ static globCache = /* @__PURE__ */ new Map();
7167
+ async countWorkspaceFiles(workspacePath) {
7168
+ const now = Date.now();
7169
+ const cached = _Cascade.globCache.get(workspacePath);
7170
+ if (cached && cached.expiresAt > now) return cached.count;
6159
7171
  try {
6160
7172
  const files = await glob.glob("**/*.*", {
6161
7173
  cwd: workspacePath,
6162
7174
  ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
6163
7175
  nodir: true
6164
7176
  });
6165
- workspaceContext = `Workspace Scout: Found ~${files.length} source files in the project.`;
7177
+ _Cascade.globCache.set(workspacePath, { count: files.length, expiresAt: now + 3e4 });
7178
+ return files.length;
7179
+ } catch {
7180
+ return 0;
7181
+ }
7182
+ }
7183
+ 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";
7187
+ let workspaceContext = "";
7188
+ try {
7189
+ const count = await this.countWorkspaceFiles(workspacePath);
7190
+ workspaceContext = `Workspace Scout: Found ~${count} source files in the project.`;
6166
7191
  } catch {
6167
7192
  workspaceContext = "Workspace Scout: Could not scan workspace.";
6168
7193
  }
@@ -6248,7 +7273,7 @@ ${prompt}` : prompt;
6248
7273
  this.telemetry.capture("cascade:session_start", {
6249
7274
  complexity,
6250
7275
  providerCount: this.config.providers.length,
6251
- cascadeAutoEnabled: this.config["cascadeAuto"] === true,
7276
+ cascadeAutoEnabled: this.config.cascadeAuto === true,
6252
7277
  toolCreationEnabled: this.config["enableToolCreation"] === true
6253
7278
  });
6254
7279
  this.emit("tier:root", { role: complexity === "Simple" ? "T3" : complexity === "Moderate" ? "T2" : "T1" });
@@ -6263,6 +7288,7 @@ ${prompt}` : prompt;
6263
7288
  }));
6264
7289
  }
6265
7290
  const toolCreator = this.toolCreator;
7291
+ if (toolCreator) toolCreator.setPermissionEscalator(escalator);
6266
7292
  let finalOutput = "";
6267
7293
  let t2Results = [];
6268
7294
  let runError = null;
@@ -6284,6 +7310,8 @@ ${prompt}` : prompt;
6284
7310
  });
6285
7311
  tier.on("log", (e) => this.emit("log", e));
6286
7312
  tier.on("tier:status", (e) => this.emit("tier:status", e));
7313
+ tier.on("tool:call", (e) => this.emit("tool:call", e));
7314
+ tier.on("tool:result", (e) => this.emit("tool:result", e));
6287
7315
  tier.on("tool:approval-request", async (request) => {
6288
7316
  this.emit("tool:approval-request", request);
6289
7317
  let decision = { approved: false };
@@ -6338,6 +7366,7 @@ ${prompt}` : prompt;
6338
7366
  }
6339
7367
  t2.setPermissionEscalator(escalator);
6340
7368
  if (toolCreator) t2.setToolCreator(toolCreator);
7369
+ t2.setPeerMessageCallback((e) => this.emit("peer:message", e), options.sessionId ?? "");
6341
7370
  bindTierEvents(t2);
6342
7371
  const assignment = {
6343
7372
  sectionId: taskId,
@@ -6367,6 +7396,7 @@ ${prompt}` : prompt;
6367
7396
  }
6368
7397
  t1.setPermissionEscalator(escalator);
6369
7398
  if (toolCreator) t1.setToolCreator(toolCreator);
7399
+ t1.setPeerMessageCallback((e) => this.emit("peer:message", e), options.sessionId ?? "");
6370
7400
  bindTierEvents(t1);
6371
7401
  t1.on("plan", (e) => this.emit("plan", e));
6372
7402
  const result = await t1.execute(options.prompt, options.images, void 0, options.signal);
@@ -6390,6 +7420,13 @@ ${prompt}` : prompt;
6390
7420
  escalator.cancelAllPending();
6391
7421
  } catch {
6392
7422
  }
7423
+ if (this.taskAnalyzer) {
7424
+ try {
7425
+ const stats2 = this.router.getStats();
7426
+ this.taskAnalyzer.recordRunOutcome(runError ? "failure" : "success", stats2.costByTier);
7427
+ } catch {
7428
+ }
7429
+ }
6393
7430
  try {
6394
7431
  const stats2 = this.router.getStats();
6395
7432
  const durationMs2 = Date.now() - startMs;
@@ -6490,7 +7527,7 @@ var Keystore = class {
6490
7527
  const creds = await this.keytar.findCredentials(KEYTAR_SERVICE);
6491
7528
  this.cache = Object.fromEntries(creds.map((c) => [c.account, c.password]));
6492
7529
  this.backend = "keytar";
6493
- if (password && fs11__default.default.existsSync(this.storePath)) {
7530
+ if (password && fs15__default.default.existsSync(this.storePath)) {
6494
7531
  try {
6495
7532
  const fileEntries = this.decryptFile(password);
6496
7533
  for (const [k, v] of Object.entries(fileEntries)) {
@@ -6509,7 +7546,7 @@ var Keystore = class {
6509
7546
  "Keystore unlock requires a password because the OS keychain (keytar) is not available on this system."
6510
7547
  );
6511
7548
  }
6512
- if (!fs11__default.default.existsSync(this.storePath)) {
7549
+ if (!fs15__default.default.existsSync(this.storePath)) {
6513
7550
  const salt = crypto__default.default.randomBytes(SALT_LEN);
6514
7551
  this.masterKey = this.deriveKey(password, salt);
6515
7552
  this.writeWithSalt({}, salt);
@@ -6523,7 +7560,7 @@ var Keystore = class {
6523
7560
  }
6524
7561
  /** Synchronous legacy unlock kept for AES-only environments. */
6525
7562
  unlockSync(password) {
6526
- if (!fs11__default.default.existsSync(this.storePath)) {
7563
+ if (!fs15__default.default.existsSync(this.storePath)) {
6527
7564
  const salt = crypto__default.default.randomBytes(SALT_LEN);
6528
7565
  this.masterKey = this.deriveKey(password, salt);
6529
7566
  this.writeWithSalt({}, salt);
@@ -6581,7 +7618,7 @@ var Keystore = class {
6581
7618
  }
6582
7619
  }
6583
7620
  decryptFile(password, knownSalt) {
6584
- if (!fs11__default.default.existsSync(this.storePath)) return {};
7621
+ if (!fs15__default.default.existsSync(this.storePath)) return {};
6585
7622
  try {
6586
7623
  const { salt, ciphertext, iv, tag } = this.readRaw();
6587
7624
  const useSalt = knownSalt ?? salt;
@@ -6603,8 +7640,8 @@ var Keystore = class {
6603
7640
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
6604
7641
  const tag = cipher.getAuthTag();
6605
7642
  const out = Buffer.concat([raw.salt, iv, tag, ciphertext]);
6606
- fs11__default.default.mkdirSync(path13__default.default.dirname(this.storePath), { recursive: true });
6607
- fs11__default.default.writeFileSync(this.storePath, out, { mode: 384 });
7643
+ fs15__default.default.mkdirSync(path16__default.default.dirname(this.storePath), { recursive: true });
7644
+ fs15__default.default.writeFileSync(this.storePath, out, { mode: 384 });
6608
7645
  }
6609
7646
  writeWithSalt(data, salt) {
6610
7647
  if (!this.masterKey) throw new Error("writeWithSalt called before masterKey was set");
@@ -6614,11 +7651,11 @@ var Keystore = class {
6614
7651
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
6615
7652
  const tag = cipher.getAuthTag();
6616
7653
  const out = Buffer.concat([salt, iv, tag, ciphertext]);
6617
- fs11__default.default.mkdirSync(path13__default.default.dirname(this.storePath), { recursive: true });
6618
- fs11__default.default.writeFileSync(this.storePath, out, { mode: 384 });
7654
+ fs15__default.default.mkdirSync(path16__default.default.dirname(this.storePath), { recursive: true });
7655
+ fs15__default.default.writeFileSync(this.storePath, out, { mode: 384 });
6619
7656
  }
6620
7657
  readRaw() {
6621
- const buf = fs11__default.default.readFileSync(this.storePath);
7658
+ const buf = fs15__default.default.readFileSync(this.storePath);
6622
7659
  let offset = 0;
6623
7660
  const salt = buf.subarray(offset, offset + SALT_LEN);
6624
7661
  offset += SALT_LEN;
@@ -6651,9 +7688,9 @@ var CascadeIgnore = class {
6651
7688
  ]);
6652
7689
  }
6653
7690
  async load(workspacePath) {
6654
- const filePath = path13__default.default.join(workspacePath, ".cascadeignore");
7691
+ const filePath = path16__default.default.join(workspacePath, ".cascadeignore");
6655
7692
  try {
6656
- const content = await fs2__default.default.readFile(filePath, "utf-8");
7693
+ const content = await fs3__default.default.readFile(filePath, "utf-8");
6657
7694
  const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
6658
7695
  this.ig.add(lines);
6659
7696
  this.loaded = true;
@@ -6662,7 +7699,7 @@ var CascadeIgnore = class {
6662
7699
  }
6663
7700
  isIgnored(filePath, workspacePath) {
6664
7701
  try {
6665
- const relative = workspacePath ? path13__default.default.relative(workspacePath, filePath) : filePath;
7702
+ const relative = workspacePath ? path16__default.default.relative(workspacePath, filePath) : filePath;
6666
7703
  return this.ig.ignores(relative);
6667
7704
  } catch {
6668
7705
  return false;
@@ -6673,9 +7710,9 @@ var CascadeIgnore = class {
6673
7710
  }
6674
7711
  };
6675
7712
  async function loadCascadeMd(workspacePath) {
6676
- const filePath = path13__default.default.join(workspacePath, "CASCADE.md");
7713
+ const filePath = path16__default.default.join(workspacePath, "CASCADE.md");
6677
7714
  try {
6678
- const raw = await fs2__default.default.readFile(filePath, "utf-8");
7715
+ const raw = await fs3__default.default.readFile(filePath, "utf-8");
6679
7716
  return parseCascadeMd(raw);
6680
7717
  } catch {
6681
7718
  return null;
@@ -6704,7 +7741,7 @@ ${raw.trim()}`;
6704
7741
  var MemoryStore = class _MemoryStore {
6705
7742
  db;
6706
7743
  constructor(dbPath) {
6707
- fs11__default.default.mkdirSync(path13__default.default.dirname(dbPath), { recursive: true });
7744
+ fs15__default.default.mkdirSync(path16__default.default.dirname(dbPath), { recursive: true });
6708
7745
  try {
6709
7746
  this.db = new Database__default.default(dbPath, { timeout: 5e3 });
6710
7747
  this.db.pragma("journal_mode = WAL");
@@ -7187,6 +8224,27 @@ Original error: ${err.message}`
7187
8224
  if (!row.oldest) return Infinity;
7188
8225
  return Date.now() - new Date(row.oldest).getTime();
7189
8226
  }
8227
+ saveModelProfile(modelId, provider, specializations) {
8228
+ const cacheKey = `${provider}:${modelId}`;
8229
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
8230
+ const meta = existing ? JSON.parse(existing.metadata) : { id: modelId, provider, name: modelId, contextWindow: 0, isVisionCapable: false, inputCostPer1kTokens: 0, outputCostPer1kTokens: 0, maxOutputTokens: 0, supportsStreaming: false, isLocal: false };
8231
+ meta.specializations = specializations;
8232
+ this.db.prepare(`
8233
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
8234
+ VALUES (?, ?, ?, ?, ?, ?)
8235
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
8236
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
8237
+ }
8238
+ getModelProfile(modelId, provider) {
8239
+ const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
8240
+ return row ? JSON.parse(row.metadata) : void 0;
8241
+ }
8242
+ getProfiledModelIds() {
8243
+ const rows = this.db.prepare(
8244
+ "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
8245
+ ).all();
8246
+ return rows.map((r) => r.model_id);
8247
+ }
7190
8248
  // ── Tool Result Cache (in-memory, TTL-based) ──────────────────────────
7191
8249
  // Avoids redundant calls for read-only tools within a short window.
7192
8250
  // Not persisted to DB — cleared on process restart.
@@ -7441,15 +8499,15 @@ var ConfigManager = class {
7441
8499
  globalDir;
7442
8500
  constructor(workspacePath = process.cwd()) {
7443
8501
  this.workspacePath = workspacePath;
7444
- this.globalDir = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR);
8502
+ this.globalDir = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR);
7445
8503
  }
7446
8504
  async load() {
7447
8505
  this.config = await this.loadConfig();
7448
8506
  this.ignore = new CascadeIgnore();
7449
8507
  await this.ignore.load(this.workspacePath);
7450
8508
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
7451
- this.keystore = new Keystore(path13__default.default.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
7452
- this.store = new MemoryStore(path13__default.default.join(this.workspacePath, CASCADE_DB_FILE));
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));
7453
8511
  await this.injectEnvKeys();
7454
8512
  await this.ensureDefaultIdentity();
7455
8513
  }
@@ -7472,9 +8530,9 @@ var ConfigManager = class {
7472
8530
  return this.workspacePath;
7473
8531
  }
7474
8532
  async save() {
7475
- const configPath = path13__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
7476
- await fs2__default.default.mkdir(path13__default.default.dirname(configPath), { recursive: true });
7477
- await fs2__default.default.writeFile(configPath, JSON.stringify(this.config, null, 2), "utf-8");
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");
7478
8536
  }
7479
8537
  async updateConfig(updates) {
7480
8538
  this.config = validateConfig({ ...this.config, ...updates });
@@ -7497,9 +8555,9 @@ var ConfigManager = class {
7497
8555
  return configProvider?.apiKey;
7498
8556
  }
7499
8557
  async loadConfig() {
7500
- const configPath = path13__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
8558
+ const configPath = path16__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
7501
8559
  try {
7502
- const raw = await fs2__default.default.readFile(configPath, "utf-8");
8560
+ const raw = await fs3__default.default.readFile(configPath, "utf-8");
7503
8561
  return validateConfig(JSON.parse(raw));
7504
8562
  } catch (err) {
7505
8563
  if (err.code === "ENOENT") {
@@ -7663,6 +8721,9 @@ var DashboardSocket = class {
7663
8721
  emitStreamToken(tierId, text, sessionId) {
7664
8722
  this.io.to(`session:${sessionId}`).emit("stream:token", { tierId, text, sessionId });
7665
8723
  }
8724
+ emitPeerMessage(event) {
8725
+ this.io.to(`session:${event.sessionId}`).emit("peer:message", event);
8726
+ }
7666
8727
  emitApprovalRequest(request) {
7667
8728
  this.io.emit("permission:user-required", request);
7668
8729
  }
@@ -7705,16 +8766,13 @@ var DashboardSocket = class {
7705
8766
  const { sessionId } = normalizeSessionSubscriptionPayload(payload);
7706
8767
  socket.leave(`session:${sessionId}`);
7707
8768
  });
7708
- socket.on("join:tenant", (tenantId) => {
7709
- socket.join(`tenant:${tenantId}`);
7710
- });
7711
8769
  });
7712
8770
  }
7713
8771
  close() {
7714
8772
  this.io.close();
7715
8773
  }
7716
8774
  };
7717
- var __dirname$1 = path13__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))));
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))));
7718
8776
  var DashboardServer = class {
7719
8777
  app;
7720
8778
  httpServer;
@@ -7780,15 +8838,15 @@ var DashboardServer = class {
7780
8838
  resolveDashboardSecret() {
7781
8839
  const fromConfig = this.config.dashboard.secret ?? process.env["CASCADE_DASHBOARD_SECRET"];
7782
8840
  if (fromConfig) return fromConfig;
7783
- const secretPath = path13__default.default.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
8841
+ const secretPath = path16__default.default.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
7784
8842
  try {
7785
- if (fs11__default.default.existsSync(secretPath)) {
7786
- const existing = fs11__default.default.readFileSync(secretPath, "utf-8").trim();
8843
+ if (fs15__default.default.existsSync(secretPath)) {
8844
+ const existing = fs15__default.default.readFileSync(secretPath, "utf-8").trim();
7787
8845
  if (existing.length >= 16) return existing;
7788
8846
  }
7789
8847
  const generated = crypto.randomUUID();
7790
- fs11__default.default.mkdirSync(path13__default.default.dirname(secretPath), { recursive: true });
7791
- fs11__default.default.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
8848
+ fs15__default.default.mkdirSync(path16__default.default.dirname(secretPath), { recursive: true });
8849
+ fs15__default.default.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
7792
8850
  if (this.config.dashboard.auth) {
7793
8851
  console.warn(
7794
8852
  `Dashboard auth enabled with no secret configured; persisted a generated secret to ${secretPath}. Set CASCADE_DASHBOARD_SECRET or config.dashboard.secret to override.`
@@ -7815,7 +8873,7 @@ var DashboardServer = class {
7815
8873
  // ── Setup ─────────────────────────────────────
7816
8874
  getGlobalStore() {
7817
8875
  if (!this.globalStore) {
7818
- const globalDbPath = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8876
+ const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7819
8877
  this.globalStore = new MemoryStore(globalDbPath);
7820
8878
  }
7821
8879
  return this.globalStore;
@@ -7876,12 +8934,12 @@ var DashboardServer = class {
7876
8934
  }
7877
8935
  }
7878
8936
  watchRuntimeChanges() {
7879
- const workspaceDbPath = path13__default.default.join(this.workspacePath, CASCADE_DB_FILE);
7880
- const globalDbPath = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
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);
7881
8939
  const watchPaths = [workspaceDbPath, globalDbPath].filter((p, index, arr) => arr.indexOf(p) === index);
7882
8940
  for (const watchPath of watchPaths) {
7883
- if (!fs11__default.default.existsSync(watchPath)) continue;
7884
- fs11__default.default.watchFile(watchPath, { interval: 3e3 }, () => {
8941
+ if (!fs15__default.default.existsSync(watchPath)) continue;
8942
+ fs15__default.default.watchFile(watchPath, { interval: 3e3 }, () => {
7885
8943
  this.throttledBroadcast(watchPath === globalDbPath ? "global" : "workspace");
7886
8944
  });
7887
8945
  }
@@ -7912,6 +8970,21 @@ var DashboardServer = class {
7912
8970
  legacyHeaders: false,
7913
8971
  message: { error: "Too many login attempts. Try again in 15 minutes." }
7914
8972
  });
8973
+ const apiLimiter = rateLimit__default.default({
8974
+ windowMs: 60 * 1e3,
8975
+ limit: 60,
8976
+ standardHeaders: "draft-7",
8977
+ legacyHeaders: false,
8978
+ message: { error: "Too many requests. Slow down." }
8979
+ });
8980
+ this.app.use("/api", apiLimiter);
8981
+ const mutationLimiter = rateLimit__default.default({
8982
+ windowMs: 60 * 1e3,
8983
+ limit: 10,
8984
+ standardHeaders: "draft-7",
8985
+ legacyHeaders: false,
8986
+ message: { error: "Too many requests on this endpoint." }
8987
+ });
7915
8988
  this.app.post("/api/auth/login", loginLimiter, (req, res) => {
7916
8989
  const { username, password } = req.body ?? {};
7917
8990
  if (!authRequired) {
@@ -7947,22 +9020,33 @@ var DashboardServer = class {
7947
9020
  res.status(401).json({ error: "Invalid credentials" });
7948
9021
  }
7949
9022
  });
7950
- this.app.post("/api/force-halt", auth, (req, res) => {
7951
- const { sessionId, nodeId } = req.body;
9023
+ this.app.post("/api/force-halt", auth, mutationLimiter, (req, res) => {
9024
+ const body = req.body;
9025
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
9026
+ const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
7952
9027
  const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
7953
9028
  this.socket.broadcast("session:halt", payload);
7954
9029
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:halt", payload);
7955
9030
  res.json({ success: true, ...payload });
7956
9031
  });
7957
- this.app.post("/api/approve", auth, (req, res) => {
7958
- const { nodeId, sessionId } = req.body;
9032
+ this.app.post("/api/approve", auth, mutationLimiter, (req, res) => {
9033
+ const body = req.body;
9034
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
9035
+ const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
7959
9036
  const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
7960
9037
  this.socket.broadcast("session:approve", payload);
7961
9038
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:approve", payload);
7962
9039
  res.json({ success: true, ...payload });
7963
9040
  });
7964
- this.app.post("/api/inject", auth, (req, res) => {
7965
- const { message, sessionId, nodeId } = req.body;
9041
+ this.app.post("/api/inject", auth, mutationLimiter, (req, res) => {
9042
+ const body = req.body;
9043
+ const message = typeof body["message"] === "string" ? body["message"] : void 0;
9044
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
9045
+ const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
9046
+ if (!message) {
9047
+ res.status(400).json({ error: "message is required and must be a string" });
9048
+ return;
9049
+ }
7966
9050
  const payload = { sessionId, nodeId, message, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
7967
9051
  this.socket.broadcast("session:message-injected", payload);
7968
9052
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:message-injected", payload);
@@ -7985,7 +9069,7 @@ var DashboardServer = class {
7985
9069
  const sessionId = req.params.id;
7986
9070
  this.store.deleteSession(sessionId);
7987
9071
  this.store.deleteRuntimeSession(sessionId);
7988
- const globalDbPath = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9072
+ const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7989
9073
  const globalStore = new MemoryStore(globalDbPath);
7990
9074
  try {
7991
9075
  globalStore.deleteRuntimeSession(sessionId);
@@ -7999,7 +9083,7 @@ var DashboardServer = class {
7999
9083
  });
8000
9084
  this.app.delete("/api/sessions", auth, (req, res) => {
8001
9085
  const body = req.body;
8002
- const globalDbPath = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9086
+ const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8003
9087
  if (body?.ids && Array.isArray(body.ids) && body.ids.length > 0) {
8004
9088
  const globalStore = new MemoryStore(globalDbPath);
8005
9089
  try {
@@ -8022,7 +9106,7 @@ var DashboardServer = class {
8022
9106
  });
8023
9107
  this.app.delete("/api/runtime", auth, (_req, res) => {
8024
9108
  this.store.deleteAllRuntimeNodes();
8025
- const globalDbPath = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9109
+ const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8026
9110
  const globalStore = new MemoryStore(globalDbPath);
8027
9111
  try {
8028
9112
  globalStore.deleteAllRuntimeNodes();
@@ -8084,16 +9168,26 @@ var DashboardServer = class {
8084
9168
  });
8085
9169
  this.app.put("/api/config", auth, async (req, res) => {
8086
9170
  const body = req.body;
8087
- if (body.tierLimits) this.config.tierLimits = { ...this.config.tierLimits, ...body.tierLimits };
8088
- if (body.budget) this.config.budget = { ...this.config.budget, ...body.budget };
9171
+ if (body["tierLimits"] !== void 0 && (typeof body["tierLimits"] !== "object" || Array.isArray(body["tierLimits"]))) {
9172
+ res.status(400).json({ error: "tierLimits must be an object" });
9173
+ return;
9174
+ }
9175
+ if (body["budget"] !== void 0 && (typeof body["budget"] !== "object" || Array.isArray(body["budget"]))) {
9176
+ res.status(400).json({ error: "budget must be an object" });
9177
+ return;
9178
+ }
9179
+ if (body["tierLimits"]) this.config.tierLimits = { ...this.config.tierLimits, ...body["tierLimits"] };
9180
+ if (body["budget"]) this.config.budget = { ...this.config.budget, ...body["budget"] };
8089
9181
  try {
8090
- const configPath = path13__default.default.join(this.workspacePath, CASCADE_CONFIG_FILE);
8091
- const existing = fs11__default.default.existsSync(configPath) ? JSON.parse(fs11__default.default.readFileSync(configPath, "utf-8")) : {};
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")) : {};
8092
9184
  const updated = { ...existing, tierLimits: this.config.tierLimits, budget: this.config.budget };
8093
- fs11__default.default.writeFileSync(configPath, JSON.stringify(updated, null, 2), "utf-8");
9185
+ const tmp = configPath + ".tmp";
9186
+ fs15__default.default.writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
9187
+ fs15__default.default.renameSync(tmp, configPath);
8094
9188
  res.json({ ok: true });
8095
9189
  } catch (err) {
8096
- res.status(500).json({ error: `Failed to save config: ${String(err)}` });
9190
+ res.status(500).json({ error: `Failed to save config: ${err instanceof Error ? err.message : String(err)}` });
8097
9191
  }
8098
9192
  });
8099
9193
  this.app.get("/api/runtime/logs/:sessionId", auth, (req, res) => {
@@ -8118,7 +9212,7 @@ var DashboardServer = class {
8118
9212
  this.app.get("/api/runtime", auth, (req, res) => {
8119
9213
  const scope = req.query["scope"] ?? "workspace";
8120
9214
  if (scope === "global") {
8121
- const globalDbPath = path13__default.default.join(os2__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9215
+ const globalDbPath = path16__default.default.join(os3__default.default.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8122
9216
  const globalStore = new MemoryStore(globalDbPath);
8123
9217
  try {
8124
9218
  res.json({
@@ -8139,7 +9233,7 @@ var DashboardServer = class {
8139
9233
  logs: this.store.listRuntimeNodeLogs(void 0, void 0, 500)
8140
9234
  });
8141
9235
  });
8142
- this.app.post("/api/run", auth, (req, res) => {
9236
+ this.app.post("/api/run", auth, mutationLimiter, (req, res) => {
8143
9237
  const body = req.body;
8144
9238
  if (!body.prompt || typeof body.prompt !== "string") {
8145
9239
  res.status(400).json({ error: "prompt is required" });
@@ -8160,12 +9254,15 @@ var DashboardServer = class {
8160
9254
  cascade.on("permission:user-required", (e) => {
8161
9255
  this.socket.broadcastToRoom(`session:${sessionId}`, "permission:user-required", { sessionId, ...e });
8162
9256
  });
9257
+ cascade.on("peer:message", (e) => {
9258
+ this.socket.emitPeerMessage(e);
9259
+ });
8163
9260
  try {
8164
9261
  const result = await cascade.run({ prompt: body.prompt, identityId: body.identityId });
8165
9262
  this.socket.broadcast("cost:update", {
8166
9263
  sessionId,
8167
- tokens: result.usage.totalTokens,
8168
- costUsd: result.usage.estimatedCostUsd
9264
+ totalTokens: result.usage.totalTokens,
9265
+ totalCostUsd: result.usage.estimatedCostUsd
8169
9266
  });
8170
9267
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:complete", { sessionId, result });
8171
9268
  this.throttledBroadcast("workspace");
@@ -8188,13 +9285,13 @@ var DashboardServer = class {
8188
9285
  }))
8189
9286
  });
8190
9287
  });
8191
- const prodPath = path13__default.default.resolve(__dirname$1, "../web/dist");
8192
- const devPath = path13__default.default.resolve(__dirname$1, "../../web/dist");
8193
- const webDistPath = fs11__default.default.existsSync(prodPath) ? prodPath : devPath;
8194
- if (fs11__default.default.existsSync(webDistPath)) {
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)) {
8195
9292
  this.app.use(express__default.default.static(webDistPath));
8196
9293
  this.app.get("*", (_req, res) => {
8197
- res.sendFile(path13__default.default.join(webDistPath, "index.html"));
9294
+ res.sendFile(path16__default.default.join(webDistPath, "index.html"));
8198
9295
  });
8199
9296
  } else {
8200
9297
  this.app.get("/", (_req, res) => {
@@ -8271,7 +9368,7 @@ var TaskScheduler = class {
8271
9368
  return cron__default.default.validate(expression);
8272
9369
  }
8273
9370
  };
8274
- var execFileAsync = util.promisify(child_process.execFile);
9371
+ var execFileAsync2 = util.promisify(child_process.execFile);
8275
9372
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
8276
9373
  function sanitizeEnvValue(v) {
8277
9374
  const raw = typeof v === "string" ? v : JSON.stringify(v);
@@ -8310,7 +9407,7 @@ var HooksRunner = class {
8310
9407
  const isWin = process.platform === "win32";
8311
9408
  const shell = isWin ? "cmd.exe" : "/bin/sh";
8312
9409
  const shellArgs = isWin ? ["/d", "/s", "/c", hook.command] : ["-c", hook.command];
8313
- const { stdout } = await execFileAsync(shell, shellArgs, {
9410
+ const { stdout } = await execFileAsync2(shell, shellArgs, {
8314
9411
  timeout: hook.timeout ?? 1e4,
8315
9412
  env: { ...process.env, ...envVars },
8316
9413
  windowsHide: true