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.js CHANGED
@@ -5,20 +5,20 @@ import Anthropic from '@anthropic-ai/sdk';
5
5
  import OpenAI, { AzureOpenAI } from 'openai';
6
6
  import { GoogleGenAI, HarmBlockThreshold, HarmCategory } from '@google/genai';
7
7
  import axios2 from 'axios';
8
- import fs2 from 'fs/promises';
9
- import path13 from 'path';
8
+ import fs3 from 'fs/promises';
9
+ import path16 from 'path';
10
10
  import * as ignoreFactory from 'ignore';
11
11
  import ignoreFactory__default from 'ignore';
12
12
  import { exec, execFile, execSync } from 'child_process';
13
13
  import { promisify } from 'util';
14
+ import fs15 from 'fs';
14
15
  import { simpleGit } from 'simple-git';
15
- import fs11 from 'fs';
16
16
  import PDFDocument from 'pdfkit';
17
17
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
18
18
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
19
19
  import { z } from 'zod';
20
+ import os3 from 'os';
20
21
  import { createContext, runInContext } from 'vm';
21
- import os2 from 'os';
22
22
  import Database from 'better-sqlite3';
23
23
  import { createServer } from 'http';
24
24
  import { fileURLToPath } from 'url';
@@ -124,7 +124,7 @@ var require_keytar2 = __commonJS({
124
124
  });
125
125
 
126
126
  // src/constants.ts
127
- var CASCADE_VERSION = "0.4.0";
127
+ var CASCADE_VERSION = "0.5.1";
128
128
  var CASCADE_CONFIG_DIR = ".cascade";
129
129
  var CASCADE_MD_FILE = "CASCADE.md";
130
130
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -1118,6 +1118,22 @@ var GeminiProvider = class extends BaseProvider {
1118
1118
  };
1119
1119
  }
1120
1120
  };
1121
+ var TOOL_CAPABLE_FAMILIES = [
1122
+ "llama3.1",
1123
+ "llama3.2",
1124
+ "llama3.3",
1125
+ "qwen2",
1126
+ "qwen2.5",
1127
+ "qwen3",
1128
+ "mistral-nemo",
1129
+ "mistral-small",
1130
+ "command-r",
1131
+ "firefunction"
1132
+ ];
1133
+ function isToolCapable(modelName) {
1134
+ const name = modelName.toLowerCase();
1135
+ return TOOL_CAPABLE_FAMILIES.some((family) => name.includes(family));
1136
+ }
1121
1137
  var OllamaProvider = class extends BaseProvider {
1122
1138
  baseUrl;
1123
1139
  constructor(config, model) {
@@ -1130,12 +1146,21 @@ var OllamaProvider = class extends BaseProvider {
1130
1146
  }
1131
1147
  async generateStream(options, onChunk) {
1132
1148
  const messages = this.convertMessages(options.messages, options.systemPrompt);
1149
+ const ollamaTools = options.tools?.map((t) => ({
1150
+ type: "function",
1151
+ function: {
1152
+ name: t.name,
1153
+ description: t.description,
1154
+ parameters: t.inputSchema
1155
+ }
1156
+ }));
1133
1157
  const response = await axios2.post(
1134
1158
  `${this.baseUrl}/api/chat`,
1135
1159
  {
1136
1160
  model: this.model.id,
1137
1161
  messages,
1138
1162
  stream: true,
1163
+ tools: ollamaTools?.length ? ollamaTools : void 0,
1139
1164
  options: {
1140
1165
  num_predict: options.maxTokens ?? this.model.maxOutputTokens,
1141
1166
  temperature: options.temperature ?? 0.7
@@ -1146,6 +1171,7 @@ var OllamaProvider = class extends BaseProvider {
1146
1171
  let fullContent = "";
1147
1172
  let inputTokens = 0;
1148
1173
  let outputTokens = 0;
1174
+ const pendingToolCalls = [];
1149
1175
  await new Promise((resolve, reject) => {
1150
1176
  let buffer = "";
1151
1177
  response.data.on("data", (chunk) => {
@@ -1160,6 +1186,9 @@ var OllamaProvider = class extends BaseProvider {
1160
1186
  fullContent += parsed.message.content;
1161
1187
  onChunk({ text: parsed.message.content, finishReason: null });
1162
1188
  }
1189
+ if (parsed.message?.tool_calls?.length) {
1190
+ pendingToolCalls.push(...parsed.message.tool_calls);
1191
+ }
1163
1192
  if (parsed.done) {
1164
1193
  inputTokens = parsed.prompt_eval_count ?? 0;
1165
1194
  outputTokens = parsed.eval_count ?? 0;
@@ -1177,6 +1206,9 @@ var OllamaProvider = class extends BaseProvider {
1177
1206
  fullContent += parsed.message.content;
1178
1207
  onChunk({ text: parsed.message.content, finishReason: null });
1179
1208
  }
1209
+ if (parsed.message?.tool_calls?.length) {
1210
+ pendingToolCalls.push(...parsed.message.tool_calls);
1211
+ }
1180
1212
  if (parsed.done) {
1181
1213
  inputTokens = parsed.prompt_eval_count ?? inputTokens;
1182
1214
  outputTokens = parsed.eval_count ?? outputTokens;
@@ -1188,11 +1220,30 @@ var OllamaProvider = class extends BaseProvider {
1188
1220
  });
1189
1221
  response.data.on("error", reject);
1190
1222
  });
1191
- onChunk({ text: "", finishReason: "stop" });
1223
+ const toolCalls = pendingToolCalls.map((tc, i) => {
1224
+ let input;
1225
+ if (typeof tc.function.arguments === "string") {
1226
+ try {
1227
+ input = JSON.parse(tc.function.arguments);
1228
+ } catch {
1229
+ input = { __rawArguments: tc.function.arguments };
1230
+ }
1231
+ } else {
1232
+ input = tc.function.arguments;
1233
+ }
1234
+ return {
1235
+ id: `ollama-tool-${Date.now()}-${i}`,
1236
+ name: tc.function.name,
1237
+ input
1238
+ };
1239
+ });
1240
+ const finishReason = toolCalls.length ? "tool_use" : "stop";
1241
+ onChunk({ text: "", finishReason });
1192
1242
  return {
1193
1243
  content: fullContent,
1194
1244
  usage: this.makeUsage(inputTokens, outputTokens),
1195
- finishReason: "stop"
1245
+ toolCalls: toolCalls.length ? toolCalls : void 0,
1246
+ finishReason
1196
1247
  };
1197
1248
  }
1198
1249
  async countTokens(text) {
@@ -1216,6 +1267,7 @@ var OllamaProvider = class extends BaseProvider {
1216
1267
  maxOutputTokens: 4e3,
1217
1268
  supportsStreaming: true,
1218
1269
  isLocal: true,
1270
+ supportsToolUse: isToolCapable(m.name),
1219
1271
  minSizeB: this.parseSizeB(m.details?.parameter_size)
1220
1272
  }));
1221
1273
  } catch {
@@ -1238,6 +1290,26 @@ var OllamaProvider = class extends BaseProvider {
1238
1290
  result.push({ role: "system", content: typeof m.content === "string" ? m.content : "" });
1239
1291
  continue;
1240
1292
  }
1293
+ if (m.role === "tool") {
1294
+ result.push({
1295
+ role: "tool",
1296
+ content: typeof m.content === "string" ? m.content : JSON.stringify(m.content)
1297
+ });
1298
+ continue;
1299
+ }
1300
+ if (m.role === "assistant" && m.toolCalls?.length) {
1301
+ result.push({
1302
+ role: "assistant",
1303
+ content: typeof m.content === "string" ? m.content : "",
1304
+ tool_calls: m.toolCalls.map((tc) => ({
1305
+ function: {
1306
+ name: tc.name,
1307
+ arguments: tc.input
1308
+ }
1309
+ }))
1310
+ });
1311
+ continue;
1312
+ }
1241
1313
  if (typeof m.content === "string") {
1242
1314
  result.push({ role: m.role, content: m.content });
1243
1315
  continue;
@@ -1370,6 +1442,26 @@ var ModelSelector = class {
1370
1442
  return T3_MODEL_PRIORITY;
1371
1443
  }
1372
1444
  }
1445
+ getAllAvailableModels() {
1446
+ return Array.from(this.availableModels.values()).filter(
1447
+ (m) => this.availableProviders.has(m.provider)
1448
+ );
1449
+ }
1450
+ /**
1451
+ * Returns all available models eligible for the given tier, ordered by the
1452
+ * tier's priority chain. Use this as the candidate set for scored selection.
1453
+ */
1454
+ getCandidatesForTier(tier) {
1455
+ const priority = this.getPriorityList(tier);
1456
+ const candidates = [];
1457
+ for (const key of priority) {
1458
+ const model = this.availableModels.get(key);
1459
+ if (model && this.availableProviders.has(model.provider)) {
1460
+ candidates.push(model);
1461
+ }
1462
+ }
1463
+ return candidates;
1464
+ }
1373
1465
  isProviderAvailable(provider) {
1374
1466
  return this.availableProviders.has(provider);
1375
1467
  }
@@ -1575,11 +1667,203 @@ var TpmLimiter = class {
1575
1667
  }
1576
1668
  };
1577
1669
 
1670
+ // src/core/router/local-queue.ts
1671
+ var LocalRequestQueue = class {
1672
+ maxConcurrent;
1673
+ active = 0;
1674
+ queue = [];
1675
+ constructor(maxConcurrent = 1) {
1676
+ this.maxConcurrent = Math.max(1, maxConcurrent);
1677
+ }
1678
+ /**
1679
+ * Acquire a queue slot. Returns a `release` function that MUST be called
1680
+ * when the inference call is done (even on error). Rejects if the slot
1681
+ * cannot be acquired within `timeoutMs`.
1682
+ */
1683
+ async acquire(timeoutMs) {
1684
+ if (this.active < this.maxConcurrent) {
1685
+ this.active++;
1686
+ return this.makeRelease();
1687
+ }
1688
+ return new Promise((resolve, reject) => {
1689
+ let settled = false;
1690
+ let timer;
1691
+ const resolver = (release) => {
1692
+ if (settled) return;
1693
+ settled = true;
1694
+ if (timer !== void 0) clearTimeout(timer);
1695
+ resolve(release);
1696
+ };
1697
+ if (timeoutMs !== void 0 && timeoutMs > 0) {
1698
+ timer = setTimeout(() => {
1699
+ if (settled) return;
1700
+ settled = true;
1701
+ const idx = this.queue.indexOf(resolver);
1702
+ if (idx !== -1) this.queue.splice(idx, 1);
1703
+ reject(new Error(
1704
+ `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.`
1705
+ ));
1706
+ }, timeoutMs);
1707
+ }
1708
+ this.queue.push(resolver);
1709
+ });
1710
+ }
1711
+ /** Number of in-flight requests. */
1712
+ get activeCount() {
1713
+ return this.active;
1714
+ }
1715
+ /** Number of requests waiting for a slot. */
1716
+ get queueDepth() {
1717
+ return this.queue.length;
1718
+ }
1719
+ makeRelease() {
1720
+ let called = false;
1721
+ return () => {
1722
+ if (called) return;
1723
+ called = true;
1724
+ this.active--;
1725
+ const next = this.queue.shift();
1726
+ if (next) {
1727
+ this.active++;
1728
+ next(this.makeRelease());
1729
+ }
1730
+ };
1731
+ }
1732
+ };
1733
+
1578
1734
  // src/utils/cost.ts
1579
1735
  function calculateCost(inputTokens, outputTokens, model) {
1580
1736
  return inputTokens / 1e3 * model.inputCostPer1kTokens + outputTokens / 1e3 * model.outputCostPer1kTokens;
1581
1737
  }
1582
1738
 
1739
+ // src/utils/retry.ts
1740
+ var CascadeCancelledError = class extends Error {
1741
+ constructor(reason) {
1742
+ super(reason ?? "Run was cancelled via AbortSignal");
1743
+ this.name = "CascadeCancelledError";
1744
+ }
1745
+ };
1746
+ var CascadeToolError = class extends Error {
1747
+ /** A friendly message to show the user / T3 */
1748
+ userMessage;
1749
+ /** Whether this error class is retryable by default */
1750
+ retryable;
1751
+ constructor(userMessage, cause, retryable = false) {
1752
+ const causeMsg = cause instanceof Error ? cause.message : String(cause);
1753
+ super(`${userMessage}: ${causeMsg}`);
1754
+ this.name = "CascadeToolError";
1755
+ this.userMessage = userMessage;
1756
+ this.retryable = retryable;
1757
+ }
1758
+ };
1759
+ async function withTimeout(promise, timeoutMs, errorMessage = "Operation timed out") {
1760
+ let timer;
1761
+ const timeoutPromise = new Promise((_, reject) => {
1762
+ timer = setTimeout(
1763
+ () => reject(new Error(errorMessage)),
1764
+ timeoutMs
1765
+ );
1766
+ });
1767
+ try {
1768
+ return await Promise.race([promise, timeoutPromise]);
1769
+ } finally {
1770
+ if (timer !== void 0) clearTimeout(timer);
1771
+ }
1772
+ }
1773
+
1774
+ // src/core/router/model-profiler.ts
1775
+ var SKIP_PATTERN = /embed|dall-e|whisper|tts|vision|instruct-vision|rerank/i;
1776
+ var SPECIALIZATION_KEYWORDS = {
1777
+ code: ["code", "coding", "programming", "developer", "software", "function", "debug", "typescript", "python", "javascript"],
1778
+ analysis: ["analysis", "analytical", "reasoning", "logic", "research", "evaluate", "assess", "explain"],
1779
+ creative: ["creative", "writing", "story", "poetry", "content", "blog", "essay", "narrative"],
1780
+ data: ["data", "sql", "statistics", "chart", "csv", "json", "excel", "spreadsheet", "math", "mathematical"],
1781
+ instruction: ["instruction", "instruction-following", "accurate", "precise", "factual"],
1782
+ multilingual: ["multilingual", "language", "translation", "linguistic"],
1783
+ long_context: ["long", "context", "document", "book", "summarize", "large"]
1784
+ };
1785
+ function extractSpecializations(description) {
1786
+ const lower = description.toLowerCase();
1787
+ const found = [];
1788
+ for (const [key, terms] of Object.entries(SPECIALIZATION_KEYWORDS)) {
1789
+ if (terms.some((t) => lower.includes(t))) {
1790
+ found.push(key);
1791
+ }
1792
+ }
1793
+ return found;
1794
+ }
1795
+ async function fetchOpenRouterModels() {
1796
+ try {
1797
+ const resp = await fetch("https://openrouter.ai/api/v1/models", {
1798
+ headers: { "User-Agent": "Cascade-AI/0.4.0" },
1799
+ signal: AbortSignal.timeout(8e3)
1800
+ });
1801
+ if (!resp.ok) return [];
1802
+ const data = await resp.json();
1803
+ return data.data ?? [];
1804
+ } catch {
1805
+ return [];
1806
+ }
1807
+ }
1808
+ async function queryModelDirectly(router, model) {
1809
+ try {
1810
+ const result = await router.generate("T3", {
1811
+ messages: [{
1812
+ role: "user",
1813
+ content: 'What are your top 3 task specializations? Reply with valid JSON only: {"specializations": ["<area1>", "<area2>", "<area3>"]}'
1814
+ }],
1815
+ maxTokens: 60
1816
+ });
1817
+ const match = /\{[\s\S]*?\}/.exec(result.content);
1818
+ if (!match) return [];
1819
+ const parsed = JSON.parse(match[0]);
1820
+ const specs = parsed.specializations;
1821
+ if (!Array.isArray(specs)) return [];
1822
+ return specs.filter((s) => typeof s === "string").slice(0, 5);
1823
+ } catch {
1824
+ return [];
1825
+ }
1826
+ }
1827
+ var ModelProfiler = class {
1828
+ store;
1829
+ router;
1830
+ constructor(store, router) {
1831
+ this.store = store;
1832
+ this.router = router;
1833
+ }
1834
+ /**
1835
+ * Profile all models that haven't been profiled yet.
1836
+ * Safe to call concurrently — SQLite upsert handles races.
1837
+ */
1838
+ async profileAll(models) {
1839
+ const alreadyProfiled = new Set(this.store.getProfiledModelIds());
1840
+ const toProfile = models.filter(
1841
+ (m) => !alreadyProfiled.has(m.id) && !SKIP_PATTERN.test(m.id) && !SKIP_PATTERN.test(m.name)
1842
+ );
1843
+ if (toProfile.length === 0) return;
1844
+ const openRouterModels = await fetchOpenRouterModels();
1845
+ const orByNormalizedId = /* @__PURE__ */ new Map();
1846
+ for (const m of openRouterModels) {
1847
+ orByNormalizedId.set(m.id.toLowerCase(), m);
1848
+ const short = m.id.split("/").pop();
1849
+ if (short) orByNormalizedId.set(short.toLowerCase(), m);
1850
+ }
1851
+ await Promise.allSettled(
1852
+ toProfile.map(async (model) => {
1853
+ let specializations = [];
1854
+ const orMatch = orByNormalizedId.get(model.id.toLowerCase()) ?? orByNormalizedId.get(model.id.split("/").pop()?.toLowerCase() ?? "");
1855
+ if (orMatch?.description) {
1856
+ specializations = extractSpecializations(orMatch.description);
1857
+ }
1858
+ if (specializations.length === 0 && this.router) {
1859
+ specializations = await queryModelDirectly(this.router);
1860
+ }
1861
+ this.store.saveModelProfile(model.id, model.provider, specializations);
1862
+ })
1863
+ );
1864
+ }
1865
+ };
1866
+
1583
1867
  // src/core/router/index.ts
1584
1868
  var CascadeRouter = class _CascadeRouter extends EventEmitter {
1585
1869
  selector;
@@ -1607,6 +1891,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
1607
1891
  budgetState = "ok";
1608
1892
  budgetExceededReason;
1609
1893
  tpmLimiter;
1894
+ localQueue;
1610
1895
  /** Thrown when the configured budget is exceeded. */
1611
1896
  static BudgetExceededError = class extends Error {
1612
1897
  constructor(msg) {
@@ -1623,6 +1908,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
1623
1908
  this.selector = new ModelSelector(availableProviders);
1624
1909
  this.failover = new FailoverManager(this.selector);
1625
1910
  this.tpmLimiter = new TpmLimiter(config.rateLimits?.providerTpm ?? {});
1911
+ this.localQueue = new LocalRequestQueue(config.localConcurrency ?? 1);
1626
1912
  const ollamaCfg = config.providers.find((p) => p.type === "ollama");
1627
1913
  if (availableProviders.has("ollama")) {
1628
1914
  await this.discoverOllamaModels(ollamaCfg);
@@ -1649,6 +1935,17 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
1649
1935
  }
1650
1936
  }
1651
1937
  }
1938
+ /**
1939
+ * Run model specialization profiling in the background.
1940
+ * Only profiles models that haven't been profiled yet (cache-first).
1941
+ * No-op if store is not provided.
1942
+ */
1943
+ async profileModels(store) {
1944
+ const allModels = this.selector.getAllAvailableModels();
1945
+ const profiler = new ModelProfiler(store, this);
1946
+ profiler.profileAll(allModels).catch(() => {
1947
+ });
1948
+ }
1652
1949
  async generate(tier, options, onChunk, requireVision = false) {
1653
1950
  if (this.budgetState === "exceeded") {
1654
1951
  throw new _CascadeRouter.BudgetExceededError(
@@ -1670,9 +1967,26 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
1670
1967
  await this.tpmLimiter.acquire(model.provider, estimatedTokens);
1671
1968
  }
1672
1969
  const useStream = Boolean(onChunk) && model.supportsStreaming && typeof provider.generateStream === "function";
1970
+ let releaseLocalSlot;
1971
+ if (model.isLocal) {
1972
+ const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
1973
+ const queueWaitMs = Math.round(inferenceTimeoutMs / 2);
1974
+ releaseLocalSlot = await this.localQueue.acquire(queueWaitMs);
1975
+ }
1673
1976
  try {
1674
1977
  let result;
1675
- if (useStream && onChunk) {
1978
+ if (model.isLocal) {
1979
+ const inferenceTimeoutMs = this.config.localInferenceTimeoutMs ?? 3e5;
1980
+ const inferencePromise = useStream && onChunk ? provider.generateStream(options, (chunk) => {
1981
+ const text = typeof chunk?.text === "string" ? chunk.text : "";
1982
+ if (text) onChunk({ ...chunk, text });
1983
+ }) : provider.generate(options);
1984
+ result = await withTimeout(
1985
+ inferencePromise,
1986
+ inferenceTimeoutMs,
1987
+ `Local model ${model.id} inference timed out after ${inferenceTimeoutMs}ms`
1988
+ );
1989
+ } else if (useStream && onChunk) {
1676
1990
  try {
1677
1991
  result = await provider.generateStream(options, (chunk) => {
1678
1992
  const text = typeof chunk?.text === "string" ? chunk.text : "";
@@ -1710,10 +2024,14 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
1710
2024
  if (fallback) {
1711
2025
  this.tierModels.set(tier, fallback);
1712
2026
  this.ensureProvider(fallback, this.config.providers);
2027
+ releaseLocalSlot?.();
2028
+ releaseLocalSlot = void 0;
1713
2029
  return this.generate(tier, options, onChunk, requireVision);
1714
2030
  }
1715
2031
  }
1716
2032
  throw err;
2033
+ } finally {
2034
+ releaseLocalSlot?.();
1717
2035
  }
1718
2036
  }
1719
2037
  getModelForTier(tier) {
@@ -1953,29 +2271,6 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter {
1953
2271
  return /rate.?limit|429|too.?many.?requests|quota/i.test(msg);
1954
2272
  }
1955
2273
  };
1956
-
1957
- // src/utils/retry.ts
1958
- var CascadeCancelledError = class extends Error {
1959
- constructor(reason) {
1960
- super(reason ?? "Run was cancelled via AbortSignal");
1961
- this.name = "CascadeCancelledError";
1962
- }
1963
- };
1964
- var CascadeToolError = class extends Error {
1965
- /** A friendly message to show the user / T3 */
1966
- userMessage;
1967
- /** Whether this error class is retryable by default */
1968
- retryable;
1969
- constructor(userMessage, cause, retryable = false) {
1970
- const causeMsg = cause instanceof Error ? cause.message : String(cause);
1971
- super(`${userMessage}: ${causeMsg}`);
1972
- this.name = "CascadeToolError";
1973
- this.userMessage = userMessage;
1974
- this.retryable = retryable;
1975
- }
1976
- };
1977
-
1978
- // src/core/tiers/base.ts
1979
2274
  var BaseTier = class extends EventEmitter {
1980
2275
  id;
1981
2276
  role;
@@ -2256,6 +2551,97 @@ var AuditLogger = class {
2256
2551
  }
2257
2552
  };
2258
2553
 
2554
+ // src/tools/text-tool-parser.ts
2555
+ var TOOL_CALL_RE = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
2556
+ var JSON_BLOCK_RE = /```json\s*([\s\S]*?)\s*```/g;
2557
+ var FUNCTION_OBJ_RE = /\{\s*"function"\s*:\s*\{[^}]*"name"\s*:[^}]*\}\s*\}/g;
2558
+ function parseTextToolCalls(text) {
2559
+ const results = tryXmlBlocks(text);
2560
+ if (results.length > 0) return results;
2561
+ const jsonBlockResults = tryJsonCodeBlocks(text);
2562
+ if (jsonBlockResults.length > 0) return jsonBlockResults;
2563
+ return tryFunctionCallObjects(text);
2564
+ }
2565
+ function tryXmlBlocks(text) {
2566
+ const results = [];
2567
+ let match;
2568
+ TOOL_CALL_RE.lastIndex = 0;
2569
+ while ((match = TOOL_CALL_RE.exec(text)) !== null) {
2570
+ try {
2571
+ const raw = JSON.parse(match[1]);
2572
+ if (typeof raw.name !== "string") continue;
2573
+ const input = typeof raw.input === "object" && raw.input !== null ? raw.input : {};
2574
+ results.push({ name: raw.name, input });
2575
+ } catch {
2576
+ }
2577
+ }
2578
+ return results;
2579
+ }
2580
+ function tryJsonCodeBlocks(text) {
2581
+ const results = [];
2582
+ let match;
2583
+ JSON_BLOCK_RE.lastIndex = 0;
2584
+ while ((match = JSON_BLOCK_RE.exec(text)) !== null) {
2585
+ try {
2586
+ const raw = JSON.parse(match[1]);
2587
+ if (typeof raw.name !== "string") continue;
2588
+ const input = typeof raw.input === "object" && raw.input !== null ? raw.input : {};
2589
+ results.push({ name: raw.name, input });
2590
+ } catch {
2591
+ }
2592
+ }
2593
+ return results;
2594
+ }
2595
+ function tryFunctionCallObjects(text) {
2596
+ const results = [];
2597
+ let match;
2598
+ FUNCTION_OBJ_RE.lastIndex = 0;
2599
+ while ((match = FUNCTION_OBJ_RE.exec(text)) !== null) {
2600
+ try {
2601
+ const raw = JSON.parse(match[0]);
2602
+ const fn = raw.function;
2603
+ if (!fn || typeof fn.name !== "string") continue;
2604
+ const input = typeof fn.arguments === "object" && fn.arguments !== null ? fn.arguments : {};
2605
+ results.push({ name: fn.name, input });
2606
+ } catch {
2607
+ }
2608
+ }
2609
+ return results;
2610
+ }
2611
+ function toToolCall(parsed, index) {
2612
+ return {
2613
+ id: `text-tool-${Date.now()}-${index}`,
2614
+ name: parsed.name,
2615
+ input: parsed.input
2616
+ };
2617
+ }
2618
+ function buildTextToolSystemPrompt(tools) {
2619
+ const toolDefs = tools.map((t) => {
2620
+ const props = t.inputSchema?.properties ?? {};
2621
+ const paramLines = Object.entries(props).map(([k, v]) => ` "${k}": "<${v.description ?? k}>"`);
2622
+ return `\u2022 ${t.name}: ${t.description}
2623
+ Input: {${paramLines.length ? "\n" + paramLines.join(",\n") + "\n " : ""}}`;
2624
+ }).join("\n");
2625
+ return `
2626
+ TOOL USE INSTRUCTIONS:
2627
+ You do not have native tool-use capability. To call a tool, write a <tool_call> block:
2628
+
2629
+ <tool_call>
2630
+ {"name": "<tool_name>", "input": {<parameters>}}
2631
+ </tool_call>
2632
+
2633
+ Available tools:
2634
+ ${toolDefs}
2635
+
2636
+ EXAMPLE \u2014 calling the "shell" tool to list files:
2637
+ <tool_call>
2638
+ {"name": "shell", "input": {"command": "ls -la /workspace"}}
2639
+ </tool_call>
2640
+
2641
+ You will then receive a user message with the result, then continue your work.
2642
+ Only call one tool at a time. When you have enough information, provide your final answer.`;
2643
+ }
2644
+
2259
2645
  // src/core/tiers/t3-worker.ts
2260
2646
  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.
2261
2647
 
@@ -2457,6 +2843,9 @@ Now execute your subtask using this context where relevant.`
2457
2843
  const MAX_ITERATIONS = 15;
2458
2844
  const requiresArtifact = this.requiresArtifact();
2459
2845
  tools = [...tools];
2846
+ const t3Model = this.router.getModelForTier("T3");
2847
+ const useTextTools = t3Model?.supportsToolUse === false && tools.length > 0;
2848
+ const textToolSuffix = useTextTools ? buildTextToolSystemPrompt(tools) : "";
2460
2849
  while (iterations < MAX_ITERATIONS) {
2461
2850
  iterations++;
2462
2851
  this.throwIfCancelled();
@@ -2464,8 +2853,9 @@ Now execute your subtask using this context where relevant.`
2464
2853
  messages: this.context.getMessages(),
2465
2854
  systemPrompt: this.systemPromptOverride + systemPrompt + (this.hierarchyContext ? `
2466
2855
 
2467
- HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2468
- tools: tools.length ? tools : void 0,
2856
+ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
2857
+ // Don't pass tools array when model can't use them natively
2858
+ tools: useTextTools ? void 0 : tools.length ? tools : void 0,
2469
2859
  maxTokens: 4096
2470
2860
  };
2471
2861
  const result = await this.router.generate(
@@ -2475,8 +2865,14 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2475
2865
  this.emit("stream:token", { tierId: this.id, text: chunk.text });
2476
2866
  }
2477
2867
  );
2478
- await this.context.addMessage({ role: "assistant", content: result.content, toolCalls: result.toolCalls });
2479
- if (!result.toolCalls?.length) {
2868
+ let effectiveToolCalls = result.toolCalls ?? [];
2869
+ if (useTextTools && effectiveToolCalls.length === 0) {
2870
+ const textCalls = parseTextToolCalls(result.content);
2871
+ effectiveToolCalls = textCalls.map((tc, i) => toToolCall(tc, i));
2872
+ }
2873
+ const effectiveResult = { ...result, toolCalls: effectiveToolCalls };
2874
+ await this.context.addMessage({ role: "assistant", content: result.content, toolCalls: effectiveToolCalls });
2875
+ if (!effectiveResult.toolCalls?.length) {
2480
2876
  if (requiresArtifact) {
2481
2877
  const artifactCheck = await this.verifyArtifacts(this.assignment);
2482
2878
  if (artifactCheck.ok) {
@@ -2498,7 +2894,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2498
2894
  return { output: result.content, toolCalls: allToolCalls };
2499
2895
  }
2500
2896
  stalledArtifactIterations = 0;
2501
- if (result.finishReason === "stop") {
2897
+ if (effectiveResult.finishReason === "stop" && effectiveResult.toolCalls.length === 0) {
2502
2898
  if (requiresArtifact) {
2503
2899
  const artifactCheck = await this.verifyArtifacts(this.assignment);
2504
2900
  if (artifactCheck.ok) {
@@ -2508,7 +2904,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2508
2904
  return { output: result.content, toolCalls: allToolCalls };
2509
2905
  }
2510
2906
  }
2511
- for (const tc of result.toolCalls) {
2907
+ for (const tc of effectiveResult.toolCalls) {
2512
2908
  allToolCalls.push(tc);
2513
2909
  const toolResult = await this.executeTool(tc);
2514
2910
  await this.context.addMessage({
@@ -2566,13 +2962,15 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2566
2962
  currentAction: `Using tool: ${tc.name}`,
2567
2963
  status: "IN_PROGRESS"
2568
2964
  });
2965
+ this.emit("tool:call", { id: tc.id, tierId: this.id, toolName: tc.name, input: tc.input });
2966
+ const toolStartMs = Date.now();
2569
2967
  try {
2570
2968
  const result = await this.toolRegistry.execute(tc.name, tc.input, {
2571
2969
  tierId: this.id,
2572
2970
  sessionId: this.taskId,
2573
2971
  requireApproval: false,
2574
- saveSnapshot: async (path14, content) => {
2575
- this.store?.addFileSnapshot(this.taskId, path14, content);
2972
+ saveSnapshot: async (path17, content) => {
2973
+ this.store?.addFileSnapshot(this.taskId, path17, content);
2576
2974
  },
2577
2975
  sendPeerSync: (to, syncType, content) => {
2578
2976
  this.peerBus?.send(this.id, to, syncType, this.assignment?.subtaskId ?? "", content);
@@ -2589,12 +2987,84 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
2589
2987
  this.audit.fileChange(this.id, tc.input["path"] ?? "unknown", tc.name);
2590
2988
  }
2591
2989
  }
2592
- this.emit("tool:result", { tierId: this.id, toolName: tc.name, result });
2990
+ const durationMs = Date.now() - toolStartMs;
2991
+ this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, output: typeof result === "string" ? result : JSON.stringify(result), durationMs });
2593
2992
  return typeof result === "string" ? result : JSON.stringify(result);
2594
2993
  } catch (err) {
2595
- return `Tool error: ${err instanceof Error ? err.message : String(err)}`;
2994
+ const durationMs = Date.now() - toolStartMs;
2995
+ const errMsg = err instanceof Error ? err.message : String(err);
2996
+ this.emit("tool:result", { id: tc.id, tierId: this.id, toolName: tc.name, error: errMsg, durationMs });
2997
+ return `Tool error: ${errMsg}`;
2596
2998
  }
2597
2999
  }
3000
+ /**
3001
+ * Adaptive fallback cascade — invoked when executeTool() fails.
3002
+ * Strategy order:
3003
+ * 1. Find a semantically similar registered tool and retry with same input
3004
+ * 2. Synthesize a new tool via ToolCreator (if available) and run it
3005
+ * 3. Return the original error so the agent loop can decide what to do next
3006
+ */
3007
+ async adaptiveFallback(tc, originalError) {
3008
+ const altTool = this.findAlternativeTool(tc.name);
3009
+ if (altTool) {
3010
+ this.log(`Adaptive fallback: trying alternative tool "${altTool}" for failed "${tc.name}"`);
3011
+ this.sendStatusUpdate({ progressPct: 50, currentAction: `Fallback: trying ${altTool}`, status: "IN_PROGRESS" });
3012
+ try {
3013
+ const result = await this.toolRegistry.execute(altTool, tc.input, {
3014
+ tierId: this.id,
3015
+ sessionId: this.taskId,
3016
+ requireApproval: false
3017
+ });
3018
+ const str = typeof result === "string" ? result : JSON.stringify(result);
3019
+ if (!str.startsWith("Tool error:") && !str.startsWith("Error:")) {
3020
+ return `[Fallback via ${altTool}]: ${str}`;
3021
+ }
3022
+ } catch {
3023
+ }
3024
+ }
3025
+ if (this.toolCreator) {
3026
+ this.log(`Adaptive fallback: requesting dynamic tool synthesis for "${tc.name}"`);
3027
+ this.sendStatusUpdate({ progressPct: 55, currentAction: `Synthesizing fallback tool for: ${tc.name}`, status: "IN_PROGRESS" });
3028
+ try {
3029
+ const newToolName = await this.toolCreator.createTool(
3030
+ `Replacement for "${tc.name}" \u2014 original failed with: ${originalError.slice(0, 150)}`,
3031
+ this.assignment?.subtaskTitle ?? tc.name
3032
+ );
3033
+ if (newToolName) {
3034
+ this.log(`Adaptive fallback: synthesized "${newToolName}", retrying`);
3035
+ const result = await this.toolRegistry.execute(newToolName, tc.input, {
3036
+ tierId: this.id,
3037
+ sessionId: this.taskId,
3038
+ requireApproval: false
3039
+ });
3040
+ const str = typeof result === "string" ? result : JSON.stringify(result);
3041
+ if (!str.startsWith("Tool error:")) return `[Synthesized ${newToolName}]: ${str}`;
3042
+ }
3043
+ } catch {
3044
+ }
3045
+ }
3046
+ return originalError;
3047
+ }
3048
+ /**
3049
+ * Find a registered tool whose name/description semantically overlaps with
3050
+ * the failing tool. Returns the best candidate name, or null if none found.
3051
+ */
3052
+ findAlternativeTool(failedToolName) {
3053
+ const failedKeywords = failedToolName.toLowerCase().split(/[_\-\s]+/);
3054
+ const allTools = this.toolRegistry.getToolDefinitions();
3055
+ let bestScore = 0;
3056
+ let bestName = null;
3057
+ for (const tool of allTools) {
3058
+ if (tool.name === failedToolName) continue;
3059
+ const toolWords = tool.name.toLowerCase().split(/[_\-\s]+/);
3060
+ const score = failedKeywords.filter((k) => toolWords.includes(k)).length;
3061
+ if (score > bestScore && score >= 1) {
3062
+ bestScore = score;
3063
+ bestName = tool.name;
3064
+ }
3065
+ }
3066
+ return bestName;
3067
+ }
2598
3068
  /**
2599
3069
  * Announce which files this T3 plans to edit, then acquire locks on them
2600
3070
  * before competing siblings can claim them. T3s working on different files
@@ -2653,12 +3123,12 @@ ${assignment.expectedOutput}`;
2653
3123
  if (!artifactPaths.length) return { ok: true, issues: [] };
2654
3124
  const issues = [];
2655
3125
  const { exec: exec3 } = await import('child_process');
2656
- const { promisify: promisify3 } = await import('util');
2657
- const execAsync2 = promisify3(exec3);
3126
+ const { promisify: promisify4 } = await import('util');
3127
+ const execAsync2 = promisify4(exec3);
2658
3128
  for (const artifactPath of artifactPaths) {
2659
- const absolutePath = path13.resolve(process.cwd(), artifactPath);
3129
+ const absolutePath = path16.resolve(process.cwd(), artifactPath);
2660
3130
  try {
2661
- const stat = await fs2.stat(absolutePath);
3131
+ const stat = await fs3.stat(absolutePath);
2662
3132
  if (!stat.isFile()) {
2663
3133
  issues.push(`Expected artifact is not a file: ${artifactPath}`);
2664
3134
  continue;
@@ -2668,7 +3138,7 @@ ${assignment.expectedOutput}`;
2668
3138
  continue;
2669
3139
  }
2670
3140
  if (!/\.pdf$/i.test(artifactPath)) {
2671
- const content = await fs2.readFile(absolutePath, "utf-8");
3141
+ const content = await fs3.readFile(absolutePath, "utf-8");
2672
3142
  if (!content.trim()) {
2673
3143
  issues.push(`Artifact content is empty: ${artifactPath}`);
2674
3144
  continue;
@@ -2677,7 +3147,7 @@ ${assignment.expectedOutput}`;
2677
3147
  issues.push(`PDF artifact looks too small to be valid: ${artifactPath}`);
2678
3148
  continue;
2679
3149
  }
2680
- const ext = path13.extname(absolutePath).toLowerCase();
3150
+ const ext = path16.extname(absolutePath).toLowerCase();
2681
3151
  try {
2682
3152
  if (ext === ".ts" || ext === ".tsx") {
2683
3153
  await execAsync2(`npx tsc --noEmit ${absolutePath}`, { timeout: 1e4 });
@@ -2795,6 +3265,11 @@ var PeerBus = class extends EventEmitter {
2795
3265
  barriers = /* @__PURE__ */ new Map();
2796
3266
  broadcastLog = [];
2797
3267
  fileLocks = /* @__PURE__ */ new Map();
3268
+ /** subtaskIds whose T3 is being retried by T2 — dependents should re-wait rather than fail fast */
3269
+ retryPending = /* @__PURE__ */ new Set();
3270
+ /** Called when any peer message or broadcast is sent — used for dashboard visibility. */
3271
+ onPeerMessage;
3272
+ sessionId = "";
2798
3273
  register(peerId) {
2799
3274
  this.members.add(peerId);
2800
3275
  }
@@ -2816,11 +3291,33 @@ var PeerBus = class extends EventEmitter {
2816
3291
  this.waiters.delete(subtaskId);
2817
3292
  }
2818
3293
  /**
2819
- * Wait for a specific subtask's output resolves immediately if already available
3294
+ * Mark a subtask as retry-pending so dependents re-wait instead of failing fast
3295
+ * when they see an ESCALATED status.
3296
+ */
3297
+ markRetryPending(subtaskId) {
3298
+ this.retryPending.add(subtaskId);
3299
+ this.outputs.delete(subtaskId);
3300
+ }
3301
+ /** Called by T2 after retry resolves (success or final failure). */
3302
+ clearRetryPending(subtaskId) {
3303
+ this.retryPending.delete(subtaskId);
3304
+ }
3305
+ /** Remove a single output entry so a respawned worker can republish without clearing prior-wave outputs. */
3306
+ clearOutput(subtaskId) {
3307
+ this.outputs.delete(subtaskId);
3308
+ this.waiters.delete(subtaskId);
3309
+ this.retryPending.delete(subtaskId);
3310
+ }
3311
+ isRetryPending(subtaskId) {
3312
+ return this.retryPending.has(subtaskId);
3313
+ }
3314
+ /**
3315
+ * Wait for a specific subtask's output — resolves immediately if already available.
3316
+ * If the output is ESCALATED but a retry is pending, waits for the retry result.
2820
3317
  */
2821
3318
  waitFor(subtaskId, timeoutMs = 12e4) {
2822
3319
  const existing = this.outputs.get(subtaskId);
2823
- if (existing) return Promise.resolve(existing);
3320
+ if (existing && !this.retryPending.has(subtaskId)) return Promise.resolve(existing);
2824
3321
  return new Promise((resolve, reject) => {
2825
3322
  const resolver = (output) => {
2826
3323
  clearTimeout(timer);
@@ -2851,6 +3348,7 @@ var PeerBus = class extends EventEmitter {
2851
3348
  * Also logs to broadcastLog so collect() can retrieve recent broadcasts.
2852
3349
  */
2853
3350
  broadcast(fromId, payload) {
3351
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2854
3352
  const msg = {
2855
3353
  fromId,
2856
3354
  toId: "*",
@@ -2858,10 +3356,18 @@ var PeerBus = class extends EventEmitter {
2858
3356
  subtaskId: "",
2859
3357
  syncType: "SHARE_OUTPUT",
2860
3358
  payload,
2861
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3359
+ timestamp
2862
3360
  };
2863
- this.broadcastLog.push({ fromId, payload, timestamp: msg.timestamp });
3361
+ this.broadcastLog.push({ fromId, payload, timestamp });
2864
3362
  this.emit("broadcast", msg);
3363
+ this.onPeerMessage?.({
3364
+ fromId,
3365
+ toId: void 0,
3366
+ syncType: "SHARE_OUTPUT",
3367
+ payload: typeof payload === "string" ? payload : JSON.stringify(payload),
3368
+ timestamp,
3369
+ sessionId: this.sessionId
3370
+ });
2865
3371
  }
2866
3372
  /**
2867
3373
  * Collect all broadcast messages received within a time window.
@@ -2947,6 +3453,16 @@ var PeerBus = class extends EventEmitter {
2947
3453
  isFileLocked(filePath) {
2948
3454
  return this.fileLocks.has(filePath);
2949
3455
  }
3456
+ /**
3457
+ * Reset all runtime output/waiter state for a fresh T3 respawn wave.
3458
+ * Preserves member registrations and barrier definitions.
3459
+ */
3460
+ reset() {
3461
+ this.outputs.clear();
3462
+ this.waiters.clear();
3463
+ this.retryPending.clear();
3464
+ this.broadcastLog = [];
3465
+ }
2950
3466
  /**
2951
3467
  * Clear broadcast log — call between phases to avoid stale announcements.
2952
3468
  */
@@ -2957,6 +3473,7 @@ var PeerBus = class extends EventEmitter {
2957
3473
  * Send a targeted message to a specific peer
2958
3474
  */
2959
3475
  send(fromId, toId, syncType, subtaskId, payload) {
3476
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
2960
3477
  const msg = {
2961
3478
  fromId,
2962
3479
  toId,
@@ -2964,10 +3481,18 @@ var PeerBus = class extends EventEmitter {
2964
3481
  subtaskId,
2965
3482
  syncType,
2966
3483
  payload,
2967
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
3484
+ timestamp
2968
3485
  };
2969
3486
  this.emit(`message:${toId}`, msg);
2970
3487
  this.emit("message", msg);
3488
+ this.onPeerMessage?.({
3489
+ fromId,
3490
+ toId,
3491
+ syncType: syncType ?? "SHARE_OUTPUT",
3492
+ payload: typeof payload === "string" ? payload : JSON.stringify(payload),
3493
+ timestamp,
3494
+ sessionId: this.sessionId
3495
+ });
2971
3496
  }
2972
3497
  /**
2973
3498
  * Barrier — wait until N peers have all reached this point
@@ -3020,6 +3545,8 @@ var T2Manager = class extends BaseTier {
3020
3545
  t2PeerBus;
3021
3546
  permissionEscalator;
3022
3547
  toolCreator;
3548
+ /** AbortController for the current T3 wave — aborted on cancel-and-respawn */
3549
+ waveAbortController = null;
3023
3550
  setPeerBus(bus) {
3024
3551
  this.t2PeerBus = bus;
3025
3552
  this.t2PeerBus.register(this.id);
@@ -3028,6 +3555,14 @@ var T2Manager = class extends BaseTier {
3028
3555
  this.receivePeerSync(msg.fromId, msg.payload);
3029
3556
  });
3030
3557
  }
3558
+ setPeerMessageCallback(cb, sessionId) {
3559
+ this.t3PeerBus.onPeerMessage = cb;
3560
+ this.t3PeerBus.sessionId = sessionId;
3561
+ if (this.t2PeerBus) {
3562
+ this.t2PeerBus.onPeerMessage = cb;
3563
+ this.t2PeerBus.sessionId = sessionId;
3564
+ }
3565
+ }
3031
3566
  constructor(router, toolRegistry, parentId) {
3032
3567
  super("T2", void 0, parentId);
3033
3568
  this.router = router;
@@ -3195,6 +3730,26 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3195
3730
  }];
3196
3731
  }
3197
3732
  }
3733
+ buildWorkerMap(assignments, taskId) {
3734
+ const workerMap = /* @__PURE__ */ new Map();
3735
+ for (const a of assignments) {
3736
+ const worker = new T3Worker(this.router, this.toolRegistry, this.id);
3737
+ if (this.store) worker.setStore(this.store, taskId);
3738
+ worker.setPeerBus(this.t3PeerBus);
3739
+ if (this.permissionEscalator) worker.setPermissionEscalator(this.permissionEscalator);
3740
+ if (this.toolCreator) worker.setToolCreator(this.toolCreator);
3741
+ workerMap.set(a.subtaskId, worker);
3742
+ this.t3Workers.set(a.subtaskId, worker);
3743
+ worker.on("stream:token", (e) => this.emit("stream:token", e));
3744
+ worker.on("log", (e) => this.emit("log", e));
3745
+ worker.on("tier:status", (e) => this.emit("tier:status", e));
3746
+ worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
3747
+ ...e,
3748
+ __cascadeResponder: (decision) => worker.emit(`tool:approval-response:${e.id}`, decision)
3749
+ }));
3750
+ }
3751
+ return workerMap;
3752
+ }
3198
3753
  async executeSubtasks(subtasks, taskId) {
3199
3754
  const assignments = subtasks.map((s) => ({
3200
3755
  ...s,
@@ -3221,6 +3776,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3221
3776
  worker.on("stream:token", (e) => this.emit("stream:token", e));
3222
3777
  worker.on("log", (e) => this.emit("log", e));
3223
3778
  worker.on("tier:status", (e) => this.emit("tier:status", e));
3779
+ worker.on("tool:call", (e) => this.emit("tool:call", e));
3780
+ worker.on("tool:result", (e) => this.emit("tool:result", e));
3224
3781
  worker.on("tool:approval-request", (e) => this.emit("tool:approval-request", {
3225
3782
  ...e,
3226
3783
  __cascadeResponder: (decision) => worker.emit(`tool:approval-response:${e.id}`, decision)
@@ -3257,6 +3814,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3257
3814
  const sanitizedAssignments = this.breakCycles(assignments, adj, inDegree);
3258
3815
  let remaining = new Set(sanitizedAssignments.map((a) => a.subtaskId));
3259
3816
  let wave = 0;
3817
+ let respawnBudget = 1;
3260
3818
  while (remaining.size > 0) {
3261
3819
  const runnableIds = [...remaining].filter((id) => (inDegree.get(id) ?? 0) === 0);
3262
3820
  if (runnableIds.length === 0) {
@@ -3277,15 +3835,62 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3277
3835
  status: "IN_PROGRESS"
3278
3836
  });
3279
3837
  this.throwIfCancelled();
3838
+ this.waveAbortController = new AbortController();
3839
+ const waveSignal = AbortSignal.any(
3840
+ [this.signal, this.waveAbortController.signal].filter(Boolean)
3841
+ );
3280
3842
  const waveResults = await Promise.allSettled(
3281
3843
  runnableIds.map(async (id) => {
3282
3844
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3283
3845
  const worker = workerMap.get(id);
3284
- const result = await worker.execute(assignment, taskId, this.signal);
3846
+ const result = await worker.execute(assignment, taskId, waveSignal);
3285
3847
  resultMap.set(id, result);
3286
3848
  return result;
3287
3849
  })
3288
3850
  );
3851
+ const escalatedToolIdx = respawnBudget > 0 ? waveResults.findIndex(
3852
+ (r) => r.status === "fulfilled" && r.value.status === "ESCALATED" && r.value.issues.some((iss) => iss.includes("dynamic tool generation"))
3853
+ ) : -1;
3854
+ if (escalatedToolIdx !== -1 && this.toolCreator) {
3855
+ respawnBudget--;
3856
+ this.waveAbortController.abort();
3857
+ const escalatedId = runnableIds[escalatedToolIdx];
3858
+ const escalatedAssignment = sanitizedAssignments.find((a) => a.subtaskId === escalatedId);
3859
+ this.log(`Wave ${wave}: tool escalation detected \u2014 synthesizing tool then respawning all ${runnableIds.length} worker(s)`);
3860
+ this.sendStatusUpdate({
3861
+ progressPct: 50,
3862
+ currentAction: `Synthesizing dynamic tool for: ${escalatedAssignment.subtaskTitle}`,
3863
+ status: "IN_PROGRESS"
3864
+ });
3865
+ const toolName = await this.toolCreator.createTool(
3866
+ `Help complete: ${escalatedAssignment.subtaskTitle}`,
3867
+ escalatedAssignment.description
3868
+ );
3869
+ if (toolName) {
3870
+ this.log(`Tool "${toolName}" created \u2014 respawning wave ${wave} workers`);
3871
+ for (const a of sanitizedAssignments) {
3872
+ if (runnableIds.includes(a.subtaskId)) {
3873
+ a.description += `
3874
+
3875
+ [SYSTEM]: Dynamic tool "${toolName}" is now available \u2014 use it to complete your task.`;
3876
+ }
3877
+ }
3878
+ }
3879
+ for (const id of runnableIds) {
3880
+ this.t3PeerBus.clearOutput(id);
3881
+ }
3882
+ const freshMap = this.buildWorkerMap(
3883
+ sanitizedAssignments.filter((a) => runnableIds.includes(a.subtaskId)),
3884
+ taskId
3885
+ );
3886
+ for (const [k, v] of freshMap) workerMap.set(k, v);
3887
+ for (const id of runnableIds) {
3888
+ remaining.add(id);
3889
+ inDegree.set(id, 0);
3890
+ }
3891
+ wave--;
3892
+ continue;
3893
+ }
3289
3894
  for (let i = 0; i < runnableIds.length; i++) {
3290
3895
  const id = runnableIds[i];
3291
3896
  remaining.delete(id);
@@ -3293,61 +3898,22 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
3293
3898
  if (r.status === "rejected") {
3294
3899
  this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
3295
3900
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3296
- const retried = await this.retryT3(assignment, taskId);
3297
- resultMap.set(id, retried);
3298
- } else if (r.status === "fulfilled" && r.value.status === "ESCALATED" && r.value.issues.some((i2) => i2.includes("dynamic tool generation"))) {
3299
- const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
3300
- if (this.toolCreator) {
3301
- this.log(`T3 escalated for tool. T2 spawning Tool-Builder T3 for: ${assignment.subtaskTitle}`);
3302
- this.sendStatusUpdate({
3303
- progressPct: 50,
3304
- currentAction: `Spawning Tool-Builder T3 for: ${assignment.subtaskTitle}`,
3305
- status: "IN_PROGRESS"
3901
+ try {
3902
+ const retried = await this.retryT3(assignment, taskId);
3903
+ resultMap.set(id, retried);
3904
+ } catch (retryErr) {
3905
+ const msg = retryErr instanceof Error ? retryErr.message : String(retryErr);
3906
+ this.log(`T3 retry for ${id} threw before publishing \u2014 unblocking dependents with FAILED`);
3907
+ this.t3PeerBus.publish(this.id, id, `Retry failed: ${msg}`, "FAILED");
3908
+ resultMap.set(id, {
3909
+ subtaskId: id,
3910
+ status: "FAILED",
3911
+ output: `Retry threw: ${msg}`,
3912
+ testResults: { checksRun: [], passed: [], failed: [] },
3913
+ issues: [msg],
3914
+ peerSyncsUsed: [],
3915
+ correctionAttempts: 1
3306
3916
  });
3307
- const toolName = await this.toolCreator.createTool(
3308
- `Help complete: ${assignment.subtaskTitle}`,
3309
- assignment.description
3310
- );
3311
- if (toolName) {
3312
- this.log(`T2 verifying new tool: ${toolName}`);
3313
- this.sendStatusUpdate({
3314
- progressPct: 60,
3315
- currentAction: `T2 Verifying new tool: ${toolName}`,
3316
- status: "IN_PROGRESS"
3317
- });
3318
- try {
3319
- const verifyResult = await this.router.generate("T2", {
3320
- 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".` }],
3321
- systemPrompt: this.systemPromptOverride + "You are T2 Manager verifying a dynamic tool.",
3322
- maxTokens: 50
3323
- });
3324
- if (!verifyResult.content.toUpperCase().includes("REJECTED")) {
3325
- this.log(`T2 verification passed for ${toolName}. Restarting original T3.`);
3326
- const retried = await this.retryT3({
3327
- ...assignment,
3328
- description: `${assignment.description}
3329
-
3330
- [SYSTEM NOTIFICATION]: A new dynamic tool "${toolName}" has been built and verified for you. Use it to complete your task.`
3331
- }, taskId);
3332
- resultMap.set(id, retried);
3333
- } else {
3334
- this.log(`T2 rejected the dynamic tool: ${toolName}`);
3335
- resultMap.set(id, r.value);
3336
- }
3337
- } catch {
3338
- const retried = await this.retryT3({
3339
- ...assignment,
3340
- description: `${assignment.description}
3341
-
3342
- [SYSTEM NOTIFICATION]: A new dynamic tool "${toolName}" has been built for you. Use it to complete your task.`
3343
- }, taskId);
3344
- resultMap.set(id, retried);
3345
- }
3346
- } else {
3347
- resultMap.set(id, r.value);
3348
- }
3349
- } else {
3350
- resultMap.set(id, r.value);
3351
3917
  }
3352
3918
  }
3353
3919
  for (const dependent of adj.get(id) ?? []) {
@@ -3618,6 +4184,8 @@ var T1Administrator = class extends BaseTier {
3618
4184
  toolCreator;
3619
4185
  /** Stored overall task goal — used when evaluating escalated permissions */
3620
4186
  taskGoal = "";
4187
+ peerMessageCallback;
4188
+ peerMessageSessionId = "";
3621
4189
  constructor(router, toolRegistry, config) {
3622
4190
  super("T1", "T1");
3623
4191
  this.router = router;
@@ -3638,6 +4206,12 @@ var T1Administrator = class extends BaseTier {
3638
4206
  setToolCreator(creator) {
3639
4207
  this.toolCreator = creator;
3640
4208
  }
4209
+ setPeerMessageCallback(cb, sessionId) {
4210
+ this.peerMessageCallback = cb;
4211
+ this.peerMessageSessionId = sessionId;
4212
+ this.t2PeerBus.onPeerMessage = cb;
4213
+ this.t2PeerBus.sessionId = sessionId;
4214
+ }
3641
4215
  async execute(userPrompt, images, systemContext, signal) {
3642
4216
  this.signal = signal;
3643
4217
  this.taskId = randomUUID();
@@ -3857,6 +4431,9 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
3857
4431
  manager.setStore(this.store);
3858
4432
  }
3859
4433
  manager.setPeerBus(this.t2PeerBus);
4434
+ if (this.peerMessageCallback) {
4435
+ manager.setPeerMessageCallback(this.peerMessageCallback, this.peerMessageSessionId);
4436
+ }
3860
4437
  if (this.permissionEscalator) {
3861
4438
  manager.setPermissionEscalator(this.permissionEscalator);
3862
4439
  }
@@ -3867,6 +4444,8 @@ Leave dependsOn empty for sections that can run immediately in parallel.`;
3867
4444
  bind(manager, "stream:token", (e) => this.emit("stream:token", e));
3868
4445
  bind(manager, "log", (e) => this.emit("log", e));
3869
4446
  bind(manager, "tier:status", (e) => this.emit("tier:status", e));
4447
+ bind(manager, "tool:call", (e) => this.emit("tool:call", e));
4448
+ bind(manager, "tool:result", (e) => this.emit("tool:result", e));
3870
4449
  bind(manager, "tool:approval-request", (e) => this.emit("tool:approval-request", e));
3871
4450
  bind(manager, "message", (msg) => {
3872
4451
  if (msg.type === "PEER_SYNC") {
@@ -4226,13 +4805,21 @@ function resolveInWorkspace(workspaceRoot, input) {
4226
4805
  if (typeof input !== "string" || input.length === 0) {
4227
4806
  throw new WorkspaceSandboxError(String(input), workspaceRoot);
4228
4807
  }
4229
- const root = path13.resolve(workspaceRoot);
4230
- const abs = path13.isAbsolute(input) ? path13.resolve(input) : path13.resolve(root, input);
4231
- const rel = path13.relative(root, abs);
4232
- if (rel === "" || rel === ".") return abs;
4233
- if (rel.startsWith("..") || path13.isAbsolute(rel)) {
4808
+ const root = path16.resolve(workspaceRoot);
4809
+ const abs = path16.isAbsolute(input) ? path16.resolve(input) : path16.resolve(root, input);
4810
+ const rel = path16.relative(root, abs);
4811
+ if (rel === "" || rel === ".") ; else if (rel.startsWith("..") || path16.isAbsolute(rel)) {
4234
4812
  throw new WorkspaceSandboxError(input, root);
4235
4813
  }
4814
+ try {
4815
+ const real = fs15.realpathSync(abs);
4816
+ const realRel = path16.relative(root, real);
4817
+ if (realRel !== "" && realRel !== "." && (realRel.startsWith("..") || path16.isAbsolute(realRel))) {
4818
+ throw new WorkspaceSandboxError(input, root);
4819
+ }
4820
+ } catch (e) {
4821
+ if (e instanceof WorkspaceSandboxError) throw e;
4822
+ }
4236
4823
  return abs;
4237
4824
  }
4238
4825
 
@@ -4254,7 +4841,7 @@ var FileReadTool = class extends BaseTool {
4254
4841
  const absPath = resolveInWorkspace(this.workspaceRoot, filePath);
4255
4842
  const offset = input["offset"] ?? 1;
4256
4843
  const limit = input["limit"];
4257
- const content = await fs2.readFile(absPath, "utf-8");
4844
+ const content = await fs3.readFile(absPath, "utf-8");
4258
4845
  const lines = content.split("\n");
4259
4846
  const start = Math.max(0, offset - 1);
4260
4847
  const end = limit ? start + limit : lines.length;
@@ -4283,13 +4870,13 @@ var FileWriteTool = class extends BaseTool {
4283
4870
  const content = input["content"];
4284
4871
  if (options.saveSnapshot) {
4285
4872
  try {
4286
- const oldContent = await fs2.readFile(absPath, "utf-8");
4873
+ const oldContent = await fs3.readFile(absPath, "utf-8");
4287
4874
  await options.saveSnapshot(absPath, oldContent);
4288
4875
  } catch {
4289
4876
  }
4290
4877
  }
4291
- await fs2.mkdir(path13.dirname(absPath), { recursive: true });
4292
- await fs2.writeFile(absPath, content, "utf-8");
4878
+ await fs3.mkdir(path16.dirname(absPath), { recursive: true });
4879
+ await fs3.writeFile(absPath, content, "utf-8");
4293
4880
  return `Written ${content.length} characters to ${filePath}`;
4294
4881
  }
4295
4882
  };
@@ -4315,7 +4902,7 @@ var FileEditTool = class extends BaseTool {
4315
4902
  const oldString = input["old_string"];
4316
4903
  const newString = input["new_string"];
4317
4904
  const replaceAll = input["replace_all"] ?? false;
4318
- const rawContent = await fs2.readFile(absPath, "utf-8");
4905
+ const rawContent = await fs3.readFile(absPath, "utf-8");
4319
4906
  if (options.saveSnapshot) {
4320
4907
  await options.saveSnapshot(absPath, rawContent);
4321
4908
  }
@@ -4327,7 +4914,7 @@ var FileEditTool = class extends BaseTool {
4327
4914
  );
4328
4915
  }
4329
4916
  const updated = replaceAll ? content.split(normalizedOld).join(newString) : content.replace(normalizedOld, newString);
4330
- await fs2.writeFile(absPath, updated, "utf-8");
4917
+ await fs3.writeFile(absPath, updated, "utf-8");
4331
4918
  const count = replaceAll ? content.split(normalizedOld).length - 1 : 1;
4332
4919
  return `Replaced ${count} occurrence(s) in ${filePath}`;
4333
4920
  }
@@ -4350,12 +4937,12 @@ var FileDeleteTool = class extends BaseTool {
4350
4937
  const absPath = resolveInWorkspace(this.workspaceRoot, filePath);
4351
4938
  if (options.saveSnapshot) {
4352
4939
  try {
4353
- const oldContent = await fs2.readFile(absPath, "utf-8");
4940
+ const oldContent = await fs3.readFile(absPath, "utf-8");
4354
4941
  await options.saveSnapshot(absPath, oldContent);
4355
4942
  } catch {
4356
4943
  }
4357
4944
  }
4358
- await fs2.rm(absPath, { recursive: false });
4945
+ await fs3.rm(absPath, { recursive: false });
4359
4946
  return `Deleted ${filePath}`;
4360
4947
  }
4361
4948
  };
@@ -4372,7 +4959,7 @@ var FileListTool = class extends BaseTool {
4372
4959
  async execute(input, _options) {
4373
4960
  const inputPath = input["path"] || ".";
4374
4961
  const absPath = resolveInWorkspace(this.workspaceRoot, inputPath);
4375
- const entries = await fs2.readdir(absPath, { withFileTypes: true });
4962
+ const entries = await fs3.readdir(absPath, { withFileTypes: true });
4376
4963
  return entries.map((e) => `${e.isDirectory() ? "[DIR] " : " "}${e.name}`).join("\n") || "(empty directory)";
4377
4964
  }
4378
4965
  };
@@ -4755,8 +5342,8 @@ var ImageAnalyzeTool = class extends BaseTool {
4755
5342
  }
4756
5343
  };
4757
5344
  async function fileToImageAttachment(filePath) {
4758
- const data = await fs2.readFile(filePath);
4759
- const ext = path13.extname(filePath).toLowerCase();
5345
+ const data = await fs3.readFile(filePath);
5346
+ const ext = path16.extname(filePath).toLowerCase();
4760
5347
  const mimeMap = {
4761
5348
  ".jpg": "image/jpeg",
4762
5349
  ".jpeg": "image/jpeg",
@@ -4790,14 +5377,14 @@ var PDFCreateTool = class extends BaseTool {
4790
5377
  const filePath = input["path"];
4791
5378
  const content = input["content"];
4792
5379
  const title = input["title"];
4793
- const dir = path13.dirname(filePath);
4794
- if (!fs11.existsSync(dir)) {
4795
- fs11.mkdirSync(dir, { recursive: true });
5380
+ const dir = path16.dirname(filePath);
5381
+ if (!fs15.existsSync(dir)) {
5382
+ fs15.mkdirSync(dir, { recursive: true });
4796
5383
  }
4797
5384
  return new Promise((resolve, reject) => {
4798
5385
  try {
4799
5386
  const doc = new PDFDocument({ margin: 50 });
4800
- const stream = fs11.createWriteStream(filePath);
5387
+ const stream = fs15.createWriteStream(filePath);
4801
5388
  doc.pipe(stream);
4802
5389
  if (title) {
4803
5390
  doc.info["Title"] = title;
@@ -4875,14 +5462,14 @@ var CodeInterpreterTool = class extends BaseTool {
4875
5462
  }
4876
5463
  cmdPrefix = NODE_CMD;
4877
5464
  }
4878
- const tmpDir = path13.join(process.cwd(), ".cascade", "tmp");
4879
- if (!fs11.existsSync(tmpDir)) {
4880
- fs11.mkdirSync(tmpDir, { recursive: true });
5465
+ const tmpDir = path16.join(process.cwd(), ".cascade", "tmp");
5466
+ if (!fs15.existsSync(tmpDir)) {
5467
+ fs15.mkdirSync(tmpDir, { recursive: true });
4881
5468
  }
4882
5469
  const extension = language === "python" ? "py" : "js";
4883
5470
  const fileName = `intp_${randomUUID().slice(0, 8)}.${extension}`;
4884
- const filePath = path13.join(tmpDir, fileName);
4885
- fs11.writeFileSync(filePath, code, "utf-8");
5471
+ const filePath = path16.join(tmpDir, fileName);
5472
+ fs15.writeFileSync(filePath, code, "utf-8");
4886
5473
  const quotedPath = `"${filePath}"`;
4887
5474
  const quotedArgs = args.map((a) => `"${a}"`).join(" ");
4888
5475
  const fullCmd = `${cmdPrefix} ${quotedPath}${quotedArgs ? " " + quotedArgs : ""}`;
@@ -4891,8 +5478,8 @@ var CodeInterpreterTool = class extends BaseTool {
4891
5478
  exec(fullCmd, { cwd: process.cwd(), timeout: 3e4 }, (error, stdout, stderr) => {
4892
5479
  const duration = Date.now() - startMs;
4893
5480
  try {
4894
- if (fs11.existsSync(filePath)) {
4895
- fs11.unlinkSync(filePath);
5481
+ if (fs15.existsSync(filePath)) {
5482
+ fs15.unlinkSync(filePath);
4896
5483
  }
4897
5484
  } catch (cleanupErr) {
4898
5485
  console.error(`Failed to cleanup interpreter script ${filePath}:`, cleanupErr);
@@ -5152,6 +5739,253 @@ var WebSearchTool = class extends BaseTool {
5152
5739
  return lines.join("\n");
5153
5740
  }
5154
5741
  };
5742
+ var GlobTool = class extends BaseTool {
5743
+ name = "glob";
5744
+ description = "Fast file pattern matching. Returns file paths matching a glob pattern, sorted by modification time. Use this to find files by name patterns.";
5745
+ inputSchema = {
5746
+ type: "object",
5747
+ properties: {
5748
+ pattern: {
5749
+ type: "string",
5750
+ description: 'Glob pattern to match files against, e.g. "**/*.ts", "src/**/*.tsx"'
5751
+ },
5752
+ path: {
5753
+ type: "string",
5754
+ description: "Directory to search in. Defaults to the workspace root."
5755
+ }
5756
+ },
5757
+ required: ["pattern"]
5758
+ };
5759
+ async execute(input, _options) {
5760
+ const pattern = input["pattern"];
5761
+ const searchPath = input["path"] ? path16.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
5762
+ const matches = await glob(pattern, {
5763
+ cwd: searchPath,
5764
+ ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
5765
+ nodir: true,
5766
+ dot: false
5767
+ });
5768
+ if (matches.length === 0) {
5769
+ return `No files matched pattern: ${pattern}`;
5770
+ }
5771
+ const withMtime = await Promise.all(
5772
+ matches.map(async (rel) => {
5773
+ try {
5774
+ const stat = await fs3.stat(path16.join(searchPath, rel));
5775
+ return { rel, mtime: stat.mtimeMs };
5776
+ } catch {
5777
+ return { rel, mtime: 0 };
5778
+ }
5779
+ })
5780
+ );
5781
+ withMtime.sort((a, b) => b.mtime - a.mtime);
5782
+ const lines = withMtime.map((f) => f.rel);
5783
+ return lines.join("\n");
5784
+ }
5785
+ };
5786
+ var execFileAsync = promisify(execFile);
5787
+ var GrepTool = class extends BaseTool {
5788
+ name = "grep";
5789
+ 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.";
5790
+ inputSchema = {
5791
+ type: "object",
5792
+ properties: {
5793
+ pattern: {
5794
+ type: "string",
5795
+ description: "Regular expression pattern to search for in file contents"
5796
+ },
5797
+ path: {
5798
+ type: "string",
5799
+ description: "File or directory to search in. Defaults to workspace root."
5800
+ },
5801
+ glob: {
5802
+ type: "string",
5803
+ description: 'Glob pattern to filter files, e.g. "*.ts", "**/*.tsx"'
5804
+ },
5805
+ output_mode: {
5806
+ type: "string",
5807
+ enum: ["content", "files_with_matches", "count"],
5808
+ description: '"content" shows matching lines (default), "files_with_matches" shows file paths only, "count" shows match counts'
5809
+ },
5810
+ context: {
5811
+ type: "number",
5812
+ description: "Lines of context around each match (content mode only). Default: 0."
5813
+ },
5814
+ case_insensitive: {
5815
+ type: "boolean",
5816
+ description: "Case-insensitive search. Default: false."
5817
+ }
5818
+ },
5819
+ required: ["pattern"]
5820
+ };
5821
+ async execute(input, _options) {
5822
+ const pattern = input["pattern"];
5823
+ const searchPath = input["path"] ? path16.resolve(this.workspaceRoot, input["path"]) : this.workspaceRoot;
5824
+ const globPattern = input["glob"];
5825
+ const outputMode = input["output_mode"] ?? "content";
5826
+ const context = input["context"] ?? 0;
5827
+ const caseInsensitive = input["case_insensitive"] ?? false;
5828
+ try {
5829
+ const result = await this.runRipgrep(
5830
+ pattern,
5831
+ searchPath,
5832
+ globPattern,
5833
+ outputMode,
5834
+ context,
5835
+ caseInsensitive
5836
+ );
5837
+ return result;
5838
+ } catch {
5839
+ }
5840
+ return this.nodeScan(pattern, searchPath, globPattern, outputMode, context, caseInsensitive);
5841
+ }
5842
+ async runRipgrep(pattern, searchPath, globPattern, outputMode, context, caseInsensitive) {
5843
+ const args = ["--no-heading"];
5844
+ if (caseInsensitive) args.push("-i");
5845
+ if (outputMode === "files_with_matches") args.push("-l");
5846
+ else if (outputMode === "count") args.push("-c");
5847
+ else {
5848
+ args.push("-n");
5849
+ if (context > 0) args.push(`-C${context}`);
5850
+ }
5851
+ if (globPattern) args.push("--glob", globPattern);
5852
+ args.push("--", pattern, searchPath);
5853
+ const { stdout } = await execFileAsync("rg", args, {
5854
+ timeout: 15e3,
5855
+ maxBuffer: 2 * 1024 * 1024
5856
+ });
5857
+ const trimmed = stdout.trim();
5858
+ return trimmed || `No matches found for: ${pattern}`;
5859
+ }
5860
+ async nodeScan(pattern, searchPath, globPattern, outputMode, context, caseInsensitive) {
5861
+ const flags = caseInsensitive ? "gi" : "g";
5862
+ let regex;
5863
+ try {
5864
+ regex = new RegExp(pattern, flags);
5865
+ } catch {
5866
+ return `Invalid regex pattern: ${pattern}`;
5867
+ }
5868
+ const fileGlob = globPattern ?? "**/*";
5869
+ let files;
5870
+ try {
5871
+ files = await glob(fileGlob, {
5872
+ cwd: searchPath,
5873
+ ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
5874
+ nodir: true
5875
+ });
5876
+ } catch {
5877
+ files = [path16.relative(searchPath, searchPath) || "."];
5878
+ }
5879
+ const results = [];
5880
+ let totalCount = 0;
5881
+ for (const rel of files) {
5882
+ const abs = path16.join(searchPath, rel);
5883
+ let content;
5884
+ try {
5885
+ content = await fs3.readFile(abs, "utf-8");
5886
+ } catch {
5887
+ continue;
5888
+ }
5889
+ const lines = content.split("\n");
5890
+ const matchingLines = [];
5891
+ for (let i = 0; i < lines.length; i++) {
5892
+ if (regex.test(lines[i])) matchingLines.push(i);
5893
+ regex.lastIndex = 0;
5894
+ }
5895
+ if (matchingLines.length === 0) continue;
5896
+ totalCount += matchingLines.length;
5897
+ if (outputMode === "files_with_matches") {
5898
+ results.push(rel);
5899
+ } else if (outputMode === "count") {
5900
+ results.push(`${rel}: ${matchingLines.length}`);
5901
+ } else {
5902
+ const shown = /* @__PURE__ */ new Set();
5903
+ for (const lineIdx of matchingLines) {
5904
+ const start = Math.max(0, lineIdx - context);
5905
+ const end = Math.min(lines.length - 1, lineIdx + context);
5906
+ for (let i = start; i <= end; i++) shown.add(i);
5907
+ }
5908
+ const sortedIdxs = [...shown].sort((a, b) => a - b);
5909
+ for (const i of sortedIdxs) {
5910
+ const marker = matchingLines.includes(i) ? ":" : "-";
5911
+ results.push(`${rel}:${i + 1}${marker}${lines[i]}`);
5912
+ }
5913
+ }
5914
+ }
5915
+ if (results.length === 0) return `No matches found for: ${pattern}`;
5916
+ if (outputMode === "count") {
5917
+ results.push(`
5918
+ Total: ${totalCount} matches`);
5919
+ }
5920
+ return results.join("\n");
5921
+ }
5922
+ };
5923
+
5924
+ // src/tools/web-fetch.ts
5925
+ var MAX_CHARS = 5e4;
5926
+ var TIMEOUT_MS = 15e3;
5927
+ function stripHtml(html) {
5928
+ let text = html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<noscript[\s\S]*?<\/noscript>/gi, "");
5929
+ 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, " ");
5930
+ text = text.replace(/<[^>]+>/g, "");
5931
+ 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)));
5932
+ text = text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0).join("\n");
5933
+ return text;
5934
+ }
5935
+ var WebFetchTool = class extends BaseTool {
5936
+ name = "web_fetch";
5937
+ 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.";
5938
+ inputSchema = {
5939
+ type: "object",
5940
+ properties: {
5941
+ url: {
5942
+ type: "string",
5943
+ description: "The URL to fetch"
5944
+ },
5945
+ prompt: {
5946
+ type: "string",
5947
+ description: "Optional hint for what information to extract from the page (not used for filtering, just context)"
5948
+ }
5949
+ },
5950
+ required: ["url"]
5951
+ };
5952
+ async execute(input, _options) {
5953
+ const url = input["url"];
5954
+ let resp;
5955
+ try {
5956
+ resp = await fetch(url, {
5957
+ headers: {
5958
+ "User-Agent": "Cascade-AI/1.0 WebFetchTool",
5959
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.5"
5960
+ },
5961
+ signal: AbortSignal.timeout(TIMEOUT_MS),
5962
+ redirect: "follow"
5963
+ });
5964
+ } catch (err) {
5965
+ return `Failed to fetch ${url}: ${err instanceof Error ? err.message : String(err)}`;
5966
+ }
5967
+ if (!resp.ok) {
5968
+ return `HTTP ${resp.status} ${resp.statusText} from ${url}`;
5969
+ }
5970
+ const contentType = resp.headers.get("content-type") ?? "";
5971
+ let text;
5972
+ try {
5973
+ const raw = await resp.text();
5974
+ text = contentType.includes("html") ? stripHtml(raw) : raw;
5975
+ } catch (err) {
5976
+ return `Failed to read response body: ${err instanceof Error ? err.message : String(err)}`;
5977
+ }
5978
+ if (text.length > MAX_CHARS) {
5979
+ text = text.slice(0, MAX_CHARS) + `
5980
+
5981
+ [Content truncated at ${MAX_CHARS} characters]`;
5982
+ }
5983
+ return `URL: ${url}
5984
+ Content-Type: ${contentType}
5985
+
5986
+ ${text}`;
5987
+ }
5988
+ };
5155
5989
 
5156
5990
  // src/tools/mcp.ts
5157
5991
  var McpToolWrapper = class extends BaseTool {
@@ -5177,7 +6011,7 @@ var McpToolWrapper = class extends BaseTool {
5177
6011
 
5178
6012
  // src/tools/registry.ts
5179
6013
  var ignore = ignoreFactory__default.default ?? ignoreFactory__default;
5180
- var ToolRegistry = class {
6014
+ var ToolRegistry = class extends EventEmitter {
5181
6015
  tools = /* @__PURE__ */ new Map();
5182
6016
  config;
5183
6017
  ignoreMatcher = ignore();
@@ -5185,12 +6019,36 @@ var ToolRegistry = class {
5185
6019
  /** Loaded plugins, keyed by plugin name */
5186
6020
  plugins = /* @__PURE__ */ new Map();
5187
6021
  constructor(config, workspaceRoot = process.cwd()) {
6022
+ super();
5188
6023
  this.config = config;
5189
6024
  this.workspaceRoot = workspaceRoot;
5190
6025
  this.registerDefaults();
5191
6026
  }
5192
6027
  register(tool) {
5193
6028
  this.tools.set(tool.name, tool);
6029
+ this.emit("tool:added", tool.name);
6030
+ }
6031
+ /**
6032
+ * Wait until a named tool is registered, resolving immediately if it already exists.
6033
+ * T3 workers can call this after encountering a missing-tool error to resume
6034
+ * automatically once T2 synthesizes the tool.
6035
+ */
6036
+ waitForTool(toolName, timeoutMs = 6e4) {
6037
+ if (this.tools.has(toolName)) return Promise.resolve();
6038
+ return new Promise((resolve, reject) => {
6039
+ const timer = setTimeout(() => {
6040
+ this.off("tool:added", handler);
6041
+ reject(new Error(`Timeout waiting for tool: ${toolName}`));
6042
+ }, timeoutMs);
6043
+ const handler = (name) => {
6044
+ if (name === toolName) {
6045
+ clearTimeout(timer);
6046
+ this.off("tool:added", handler);
6047
+ resolve();
6048
+ }
6049
+ };
6050
+ this.on("tool:added", handler);
6051
+ });
5194
6052
  }
5195
6053
  /**
5196
6054
  * Register a ToolPlugin, loading all its tools into the registry.
@@ -5275,7 +6133,10 @@ var ToolRegistry = class {
5275
6133
  new PDFCreateTool(),
5276
6134
  new CodeInterpreterTool(),
5277
6135
  new PeerCommunicationTool(),
5278
- new WebSearchTool(this.config.webSearch)
6136
+ new WebSearchTool(this.config.webSearch),
6137
+ new GlobTool(),
6138
+ new GrepTool(),
6139
+ new WebFetchTool()
5279
6140
  ];
5280
6141
  for (const tool of tools) {
5281
6142
  tool.setWorkspaceRoot(this.workspaceRoot);
@@ -5292,10 +6153,10 @@ var ToolRegistry = class {
5292
6153
  }
5293
6154
  isIgnored(filePath) {
5294
6155
  if (!filePath) return false;
5295
- const abs = path13.resolve(this.workspaceRoot, filePath);
5296
- const rel = path13.relative(this.workspaceRoot, abs);
5297
- if (!rel || rel.startsWith("..") || path13.isAbsolute(rel)) return true;
5298
- const posixRel = rel.split(path13.sep).join("/");
6156
+ const abs = path16.resolve(this.workspaceRoot, filePath);
6157
+ const rel = path16.relative(this.workspaceRoot, abs);
6158
+ if (!rel || rel.startsWith("..") || path16.isAbsolute(rel)) return true;
6159
+ const posixRel = rel.split(path16.sep).join("/");
5299
6160
  return this.ignoreMatcher.ignores(posixRel);
5300
6161
  }
5301
6162
  };
@@ -5632,7 +6493,24 @@ var CascadeConfigSchema = z.object({
5632
6493
  * Generated tools are session-scoped and sandboxed in node:vm.
5633
6494
  * HTTP calls from generated tools require approval.
5634
6495
  */
5635
- enableToolCreation: z.boolean().default(false)
6496
+ enableToolCreation: z.boolean().default(true),
6497
+ /**
6498
+ * External plugin paths or npm package names to load at startup.
6499
+ * Each entry must export a default ToolPlugin object.
6500
+ * Example: ["./plugins/my-tool.js", "cascade-plugin-slack"]
6501
+ */
6502
+ plugins: z.array(z.string()).default([]),
6503
+ /**
6504
+ * Maximum number of concurrent inference requests to any local model provider
6505
+ * (e.g. Ollama). Defaults to 1 to prevent GPU memory pressure when multiple
6506
+ * T3 workers run in parallel on a single-GPU machine.
6507
+ */
6508
+ localConcurrency: z.number().int().min(1).default(1),
6509
+ /**
6510
+ * Timeout in milliseconds for a single local model inference call.
6511
+ * Local models can take minutes for large parameter counts. Default: 5 minutes.
6512
+ */
6513
+ localInferenceTimeoutMs: z.number().int().min(1e3).default(3e5)
5636
6514
  });
5637
6515
 
5638
6516
  // src/config/validate.ts
@@ -5760,139 +6638,237 @@ function heuristicAnalyze(prompt) {
5760
6638
  const estimatedTokens = wordCount * 5;
5761
6639
  return { type, complexity, requiresReasoning, requiresVision, estimatedTokens, confidence };
5762
6640
  }
5763
- function selectModelFromProfile(profile, tier, selector) {
5764
- if (profile.requiresVision) {
5765
- return selector.selectVisionModel();
5766
- }
5767
- if (tier === "T1") {
5768
- if (profile.complexity >= 4) {
5769
- return selector.selectForTier("T1");
5770
- } else {
5771
- return selector.selectForTier("T2");
5772
- }
5773
- }
5774
- if (tier === "T2") {
5775
- if (profile.type === "code" || profile.type === "data") {
5776
- return selector.selectForTier("T2");
5777
- } else if (profile.complexity <= 2) {
5778
- return selector.selectForTier("T3");
5779
- }
5780
- return selector.selectForTier("T2");
5781
- }
5782
- if (tier === "T3") {
5783
- if (profile.complexity >= 4 || profile.requiresReasoning) {
5784
- return selector.selectForTier("T2");
5785
- } else if (profile.type === "creative") {
5786
- return selector.selectForTier("T2");
5787
- } else {
5788
- return selector.selectForTier("T3");
5789
- }
5790
- }
5791
- return selector.selectForTier(tier);
5792
- }
5793
6641
  var analysisCache = /* @__PURE__ */ new Map();
6642
+ var TASK_TYPE_TAGS = {
6643
+ code: ["code", "instruction"],
6644
+ analysis: ["analysis", "instruction"],
6645
+ creative: ["creative", "multilingual"],
6646
+ data: ["data", "code"],
6647
+ mixed: []
6648
+ };
5794
6649
  var TaskAnalyzer = class {
5795
- router;
5796
- constructor(router) {
5797
- this.router = router;
6650
+ tracker;
6651
+ lastProfile = null;
6652
+ lastSelectedModels = /* @__PURE__ */ new Map();
6653
+ constructor(tracker) {
6654
+ this.tracker = tracker;
6655
+ }
6656
+ setTracker(tracker) {
6657
+ this.tracker = tracker;
6658
+ }
6659
+ /** Returns the TaskProfile from the most recent analyze() call — used for outcome recording. */
6660
+ getLastProfile() {
6661
+ return this.lastProfile;
5798
6662
  }
5799
6663
  /**
5800
- * Analyze a prompt and return a TaskProfile.
5801
- * Uses heuristics first; falls back to AI inference if confidence is low.
6664
+ * Analyze a prompt and return a TaskProfile using pure heuristics.
6665
+ * Low confidence prompts fall back to a conservative mixed/moderate profile.
5802
6666
  */
5803
6667
  async analyze(prompt) {
5804
6668
  const cacheKey = prompt.slice(0, 200);
5805
6669
  const cached = analysisCache.get(cacheKey);
5806
- if (cached) return cached;
5807
- const heuristic = heuristicAnalyze(prompt);
5808
- if (heuristic.confidence < 0.7 && this.router) {
5809
- try {
5810
- const aiProfile = await this.aiInference(prompt);
5811
- const merged = {
5812
- type: aiProfile.type,
5813
- complexity: aiProfile.complexity,
5814
- requiresReasoning: aiProfile.requiresReasoning,
5815
- requiresVision: heuristic.requiresVision || aiProfile.requiresVision,
5816
- estimatedTokens: heuristic.estimatedTokens,
5817
- confidence: 0.9
5818
- // AI-backed
5819
- };
5820
- analysisCache.set(cacheKey, merged);
5821
- return merged;
5822
- } catch {
5823
- }
6670
+ if (cached) {
6671
+ this.lastProfile = cached;
6672
+ return cached;
5824
6673
  }
5825
- analysisCache.set(cacheKey, heuristic);
5826
- return heuristic;
6674
+ const profile = heuristicAnalyze(prompt);
6675
+ analysisCache.set(cacheKey, profile);
6676
+ this.lastProfile = profile;
6677
+ return profile;
5827
6678
  }
5828
6679
  /**
5829
- * Select the optimal model for a given tier based on task analysis.
6680
+ * Select the optimal model for a given tier.
6681
+ * Scores tier-eligible models using cost efficiency + historical performance.
6682
+ * Falls back to the priority-list default when no candidates have history.
5830
6683
  */
5831
6684
  async selectModel(prompt, tier, selector) {
5832
6685
  const profile = await this.analyze(prompt);
5833
- return selectModelFromProfile(profile, tier, selector);
6686
+ if (profile.requiresVision) {
6687
+ return selector.selectVisionModel();
6688
+ }
6689
+ const candidates = selector.getCandidatesForTier(tier);
6690
+ if (candidates.length === 0) return selector.selectForTier(tier);
6691
+ const scored = candidates.map((m) => ({
6692
+ model: m,
6693
+ score: this.scoreModel(m, profile)
6694
+ }));
6695
+ scored.sort((a, b) => b.score - a.score);
6696
+ const best = scored[0]?.model ?? selector.selectForTier(tier);
6697
+ if (best) this.lastSelectedModels.set(tier, best);
6698
+ return best;
5834
6699
  }
5835
- async aiInference(prompt) {
5836
- if (!this.router) throw new Error("No router for AI inference");
5837
- const inferencePrompt = `Analyze this task and return ONLY a JSON object \u2014 no other text.
5838
-
5839
- Task: "${prompt.slice(0, 300)}"
5840
-
5841
- Return: { "type": "code"|"analysis"|"creative"|"data"|"mixed", "complexity": 1-5, "requiresReasoning": true|false, "requiresVision": true|false }
5842
-
5843
- Where complexity: 1=trivial, 2=simple, 3=moderate, 4=complex, 5=research-grade.`;
5844
- const result = await this.router.generate("T3", {
5845
- messages: [{ role: "user", content: inferencePrompt }],
5846
- maxTokens: 80
5847
- });
5848
- const jsonMatch = /\{[\s\S]*?\}/.exec(result.content);
5849
- if (!jsonMatch) throw new Error("No JSON in AI inference response");
5850
- const parsed = JSON.parse(jsonMatch[0]);
5851
- const validTypes = ["code", "analysis", "creative", "data", "mixed"];
5852
- const type = validTypes.includes(parsed.type) ? parsed.type : "mixed";
5853
- const complexity = Math.max(1, Math.min(5, Math.round(parsed.complexity)));
5854
- return {
5855
- type,
5856
- complexity,
5857
- requiresReasoning: Boolean(parsed.requiresReasoning),
5858
- requiresVision: Boolean(parsed.requiresVision),
5859
- estimatedTokens: 0,
5860
- confidence: 0.9
5861
- };
6700
+ /**
6701
+ * Record the outcome of a completed run across all tiers that were selected
6702
+ * during this session and persist stats to disk.
6703
+ */
6704
+ recordRunOutcome(outcome, costByTier) {
6705
+ if (!this.tracker || !this.lastProfile) return;
6706
+ const taskType = this.lastProfile.type;
6707
+ for (const [tier, model] of this.lastSelectedModels) {
6708
+ const cost = costByTier[tier] ?? 0;
6709
+ this.tracker.record(model.id, taskType, outcome, 0, cost);
6710
+ }
6711
+ this.lastSelectedModels.clear();
6712
+ void this.tracker.save();
6713
+ }
6714
+ scoreModel(model, profile) {
6715
+ const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
6716
+ const costEff = this.costEfficiency(model, profile.complexity);
6717
+ const match = this.taskMatchScore(model, profile);
6718
+ return perf * costEff * match;
6719
+ }
6720
+ costEfficiency(model, complexity) {
6721
+ if (this.tracker) return this.tracker.costEfficiencyScore(model, complexity);
6722
+ const blended = model.inputCostPer1kTokens + model.outputCostPer1kTokens * 2;
6723
+ const normalised = Math.min(1, blended / 0.05);
6724
+ const complexityWeight = (6 - complexity) / 5;
6725
+ return Math.max(0.1, 1 - normalised * complexityWeight);
6726
+ }
6727
+ taskMatchScore(model, profile) {
6728
+ const expected = TASK_TYPE_TAGS[profile.type];
6729
+ if (!model.specializations?.length || expected.length === 0) return 1;
6730
+ const matches = expected.filter((tag) => model.specializations.includes(tag)).length;
6731
+ return matches > 0 ? 1 + matches / expected.length * 0.3 : 0.8;
5862
6732
  }
5863
6733
  /** Clear the analysis cache (call between sessions). */
5864
6734
  static clearCache() {
5865
6735
  analysisCache.clear();
5866
6736
  }
5867
6737
  };
6738
+ var DEFAULT_STATS_FILE = path16.join(os3.homedir(), ".cascade", "model-perf.json");
6739
+ var ModelPerformanceTracker = class {
6740
+ stats = /* @__PURE__ */ new Map();
6741
+ statsFile;
6742
+ loaded = false;
6743
+ constructor(statsFile = DEFAULT_STATS_FILE) {
6744
+ this.statsFile = statsFile;
6745
+ }
6746
+ async load() {
6747
+ if (this.loaded) return;
6748
+ this.loaded = true;
6749
+ try {
6750
+ const raw = await fs3.readFile(this.statsFile, "utf-8");
6751
+ const parsed = JSON.parse(raw);
6752
+ for (const [key, stat] of Object.entries(parsed)) {
6753
+ this.stats.set(key, stat);
6754
+ }
6755
+ } catch {
6756
+ }
6757
+ }
6758
+ async save() {
6759
+ try {
6760
+ await fs3.mkdir(path16.dirname(this.statsFile), { recursive: true });
6761
+ const obj = {};
6762
+ for (const [key, stat] of this.stats) obj[key] = stat;
6763
+ await fs3.writeFile(this.statsFile, JSON.stringify(obj, null, 2), "utf-8");
6764
+ } catch {
6765
+ }
6766
+ }
6767
+ record(modelId, taskType, outcome, retries = 0, costUsd = 0) {
6768
+ const key = `${modelId}:${taskType}`;
6769
+ const s = this.stats.get(key) ?? {
6770
+ successCount: 0,
6771
+ failureCount: 0,
6772
+ totalRetries: 0,
6773
+ totalCostUsd: 0,
6774
+ sampleCount: 0
6775
+ };
6776
+ this.stats.set(key, {
6777
+ successCount: s.successCount + (outcome === "success" ? 1 : 0),
6778
+ failureCount: s.failureCount + (outcome === "failure" ? 1 : 0),
6779
+ totalRetries: s.totalRetries + retries,
6780
+ totalCostUsd: s.totalCostUsd + costUsd,
6781
+ sampleCount: s.sampleCount + 1
6782
+ });
6783
+ }
6784
+ /**
6785
+ * Returns 0.05–1.0; defaults to 0.5 (neutral prior) when no history exists.
6786
+ * High retry counts penalise the score.
6787
+ */
6788
+ performanceScore(modelId, taskType) {
6789
+ const key = `${modelId}:${taskType}`;
6790
+ const s = this.stats.get(key);
6791
+ if (!s || s.sampleCount === 0) return 0.5;
6792
+ const successRate = s.successCount / s.sampleCount;
6793
+ const avgRetries = s.totalRetries / s.sampleCount;
6794
+ const retryPenalty = Math.min(0.4, avgRetries / 3);
6795
+ return Math.max(0.05, successRate * (1 - retryPenalty));
6796
+ }
6797
+ /**
6798
+ * Returns 0.1–1.0. Cheaper models score higher, with the penalty scaled
6799
+ * down for complex tasks (where capability matters more than cost).
6800
+ *
6801
+ * blended cost = input + 2 × output (output tokens are typically pricier).
6802
+ * normalised over $0.05 blended as the "expensive" ceiling.
6803
+ */
6804
+ costEfficiencyScore(model, complexity) {
6805
+ const blended = model.inputCostPer1kTokens + model.outputCostPer1kTokens * 2;
6806
+ const normalised = Math.min(1, blended / 0.05);
6807
+ const complexityWeight = (6 - complexity) / 5;
6808
+ return Math.max(0.1, 1 - normalised * complexityWeight);
6809
+ }
6810
+ };
5868
6811
  var DynamicTool = class extends BaseTool {
5869
6812
  name;
5870
6813
  description;
5871
6814
  inputSchema;
5872
6815
  executeCode;
5873
6816
  _isDangerous;
5874
- constructor(spec) {
6817
+ registry;
6818
+ escalator;
6819
+ constructor(spec, registry, escalator) {
5875
6820
  super();
5876
6821
  this.name = spec.name;
5877
6822
  this.description = spec.description;
5878
6823
  this.inputSchema = spec.inputSchema;
5879
6824
  this.executeCode = spec.executeCode;
5880
6825
  this._isDangerous = spec.isDangerous;
6826
+ this.registry = registry;
6827
+ this.escalator = escalator;
5881
6828
  }
5882
6829
  isDangerous() {
5883
6830
  return this._isDangerous;
5884
6831
  }
5885
- async execute(input, _options) {
6832
+ async execute(input, options) {
6833
+ const registry = this.registry;
6834
+ const escalator = this.escalator;
6835
+ const callTool = async (toolName, toolInput) => {
6836
+ if (!registry.hasTool(toolName)) return `Tool not found: ${toolName}`;
6837
+ if (registry.isDangerous(toolName)) {
6838
+ if (escalator) {
6839
+ const req = {
6840
+ id: `dynamic-${this.name}-${toolName}-${Date.now()}`,
6841
+ requestedBy: `dynamic_tool:${this.name}`,
6842
+ parentT2Id: options.tierId,
6843
+ toolName,
6844
+ input: toolInput,
6845
+ isDangerous: true,
6846
+ subtaskContext: `Dynamic tool "${this.name}" requesting access to "${toolName}"`,
6847
+ sectionContext: `Dynamic tool "${this.name}"`
6848
+ };
6849
+ const decision = await escalator.requestPermission(req);
6850
+ if (!decision.approved) {
6851
+ return `Permission denied for ${toolName} (decided by ${decision.decidedBy}).`;
6852
+ }
6853
+ }
6854
+ }
6855
+ try {
6856
+ const result = await registry.execute(toolName, toolInput, options);
6857
+ return typeof result === "string" ? result : JSON.stringify(result);
6858
+ } catch (err) {
6859
+ return `Error calling ${toolName}: ${err instanceof Error ? err.message : String(err)}`;
6860
+ }
6861
+ };
5886
6862
  const sandbox = {
5887
6863
  input,
5888
6864
  fetch: globalThis.fetch,
6865
+ callTool,
5889
6866
  JSON,
5890
6867
  Math,
5891
6868
  Date,
5892
6869
  console: { log: () => {
5893
6870
  }, error: () => {
5894
6871
  } },
5895
- // Silenced
5896
6872
  setTimeout,
5897
6873
  clearTimeout,
5898
6874
  Promise,
@@ -5925,29 +6901,42 @@ Generate a minimal, safe JavaScript tool function for the described operation.
5925
6901
 
5926
6902
  Rules:
5927
6903
  - Return ONLY a JSON object with these fields: name, description, inputSchema, executeCode, isDangerous
5928
- - executeCode is a self-contained JavaScript function body that:
5929
- - Receives: input (object), fetch (if HTTP needed)
6904
+ - executeCode is a self-contained JavaScript async function body that:
6905
+ - Receives: input (object), fetch (for HTTP), callTool(toolName, input) (to call any registered cascade tool)
5930
6906
  - Returns: a string result
5931
- - Uses no require(), no fs, no process \u2014 only fetch, JSON, Math, Date, String, Number, Array, Object
6907
+ - For file operations, prefer: await callTool('file_read', { path: input.path })
6908
+ - For shell commands, prefer: await callTool('shell', { command: 'ls -la' })
6909
+ - For pure computation / HTTP: use fetch or built-ins (JSON, Math, Date, String, Number, Array, Object)
5932
6910
  - Must complete in under 15 seconds
5933
- - isDangerous should be true only if the tool makes write operations or external HTTP calls
6911
+ - isDangerous: true if the tool calls dangerous cascade tools (shell, file_write, file_delete, git) or makes HTTP calls that write data
5934
6912
  - name must be snake_case, start with "dynamic_", max 40 chars
5935
6913
  - description must be \u2264 120 chars
5936
6914
 
5937
- Example executeCode for an HTTP tool:
5938
- "const res = await fetch(input.url); const text = await res.text(); return text.slice(0, 2000);"
6915
+ Example for a file-summary tool:
6916
+ {
6917
+ "name": "dynamic_summarize_file",
6918
+ "description": "Read a file and return a one-paragraph summary",
6919
+ "inputSchema": { "path": { "type": "string", "description": "File path to summarize" } },
6920
+ "executeCode": "const content = await callTool('file_read', { path: input.path }); return content.slice(0, 500);",
6921
+ "isDangerous": false
6922
+ }
5939
6923
 
5940
6924
  Return ONLY valid JSON \u2014 no other text.`;
5941
6925
  var ToolCreator = class {
5942
6926
  router;
5943
6927
  registry;
6928
+ escalator;
5944
6929
  createdTools = /* @__PURE__ */ new Set();
5945
6930
  constructor(router, registry) {
5946
6931
  this.router = router;
5947
6932
  this.registry = registry;
5948
6933
  }
6934
+ setPermissionEscalator(escalator) {
6935
+ this.escalator = escalator;
6936
+ }
5949
6937
  /**
5950
6938
  * Generate a new tool from a description and register it with the ToolRegistry.
6939
+ * The generated tool has access to all registered cascade tools via callTool().
5951
6940
  * Returns the tool name if successful, null if generation failed.
5952
6941
  */
5953
6942
  async createTool(description, context) {
@@ -5958,26 +6947,21 @@ Required capability: ${description.slice(0, 300)}`;
5958
6947
  try {
5959
6948
  const result = await this.router.generate("T3", {
5960
6949
  messages: [{ role: "user", content: prompt }],
5961
- maxTokens: 600
6950
+ maxTokens: 800
5962
6951
  });
5963
6952
  const jsonMatch = /\{[\s\S]*\}/.exec(result.content);
5964
- if (!jsonMatch) {
5965
- return null;
5966
- }
6953
+ if (!jsonMatch) return null;
5967
6954
  const spec = JSON.parse(jsonMatch[0]);
5968
- if (!spec.name || !spec.description || !spec.executeCode || !spec.inputSchema) {
5969
- return null;
5970
- }
6955
+ if (!spec.name || !spec.description || !spec.executeCode || !spec.inputSchema) return null;
5971
6956
  if (this.createdTools.has(spec.name) || this.registry.hasTool(spec.name)) {
5972
6957
  spec.name = `${spec.name}_${Date.now() % 1e4}`;
5973
6958
  }
5974
6959
  try {
5975
- createContext({ input: {}, fetch: globalThis.fetch });
5976
- new Function("input", "fetch", spec.executeCode);
5977
- } catch (err) {
6960
+ new Function("input", "fetch", "callTool", spec.executeCode);
6961
+ } catch {
5978
6962
  return null;
5979
6963
  }
5980
- const tool = new DynamicTool(spec);
6964
+ const tool = new DynamicTool(spec, this.registry, this.escalator);
5981
6965
  this.registry.register(tool);
5982
6966
  this.createdTools.add(spec.name);
5983
6967
  return spec.name;
@@ -5985,16 +6969,14 @@ Required capability: ${description.slice(0, 300)}`;
5985
6969
  return null;
5986
6970
  }
5987
6971
  }
5988
- /**
5989
- * Returns the names of all tools created in this session.
5990
- */
6972
+ /** Returns the names of all tools created in this session. */
5991
6973
  getCreatedTools() {
5992
6974
  return Array.from(this.createdTools);
5993
6975
  }
5994
6976
  };
5995
6977
 
5996
6978
  // src/core/cascade.ts
5997
- var Cascade = class extends EventEmitter {
6979
+ var Cascade = class _Cascade extends EventEmitter {
5998
6980
  router;
5999
6981
  toolRegistry;
6000
6982
  mcpClient;
@@ -6005,6 +6987,7 @@ var Cascade = class extends EventEmitter {
6005
6987
  audit;
6006
6988
  telemetry;
6007
6989
  taskAnalyzer;
6990
+ perfTracker;
6008
6991
  toolCreator;
6009
6992
  constructor(config, workspacePath, store) {
6010
6993
  super();
@@ -6021,10 +7004,12 @@ var Cascade = class extends EventEmitter {
6021
7004
  this.telemetry = config.telemetry?.enabled ? new Telemetry(config.telemetry, config.telemetry.distinctId ?? "anonymous") : noopTelemetry;
6022
7005
  }
6023
7006
  initOptionalFeatures() {
6024
- const cfg = this.config;
6025
- if (cfg["cascadeAuto"] === true) {
6026
- this.taskAnalyzer = new TaskAnalyzer(this.router);
7007
+ if (this.config.cascadeAuto === true) {
7008
+ this.perfTracker = new ModelPerformanceTracker();
7009
+ void this.perfTracker.load();
7010
+ this.taskAnalyzer = new TaskAnalyzer(this.perfTracker);
6027
7011
  }
7012
+ const cfg = this.config;
6028
7013
  if (cfg["enableToolCreation"] === true) {
6029
7014
  this.toolCreator = new ToolCreator(this.router, this.toolRegistry);
6030
7015
  }
@@ -6090,6 +7075,26 @@ var Cascade = class extends EventEmitter {
6090
7075
  }
6091
7076
  }
6092
7077
  }
7078
+ const pluginPaths = this.config["plugins"];
7079
+ if (pluginPaths?.length) {
7080
+ for (const pluginPath of pluginPaths) {
7081
+ try {
7082
+ const mod = await import(pluginPath);
7083
+ const plugin = mod.default ?? mod;
7084
+ if (plugin && Array.isArray(plugin.tools)) {
7085
+ this.toolRegistry.registerPlugin(plugin);
7086
+ } else {
7087
+ console.warn(`[cascade] Plugin "${pluginPath}" does not export a valid ToolPlugin.`);
7088
+ }
7089
+ } catch (err) {
7090
+ console.warn(`[cascade] Failed to load plugin "${pluginPath}":`, err);
7091
+ }
7092
+ }
7093
+ }
7094
+ if (this.config.cascadeAuto && this.store) {
7095
+ this.router.profileModels(this.store).catch(() => {
7096
+ });
7097
+ }
6093
7098
  this.initOptionalFeatures();
6094
7099
  this.initialized = true;
6095
7100
  })();
@@ -6107,21 +7112,41 @@ var Cascade = class extends EventEmitter {
6107
7112
  looksLikeSimpleArtifactTask(prompt) {
6108
7113
  return /create .*\.(txt|md|json|csv)\b/i.test(prompt) && !/(research|compare|thorough|pdf|report|analy[sz]e|architecture|multi-agent)/i.test(prompt);
6109
7114
  }
6110
- async determineComplexity(prompt, workspacePath, conversationHistory = []) {
6111
- if (this.isCasualGreeting(prompt)) {
6112
- return "Simple";
6113
- }
6114
- if (this.looksLikeSimpleArtifactTask(prompt)) {
6115
- return "Simple";
6116
- }
6117
- let workspaceContext = "";
7115
+ looksLikeConversational(prompt) {
7116
+ const LOW_COMPLEXITY = [
7117
+ /^(?:hi|hello|hey|thanks|thank you|ok|okay|yes|no|sure|got it|sounds good)\b/i,
7118
+ /^(?:what is|what are|list|show me|tell me|who is|where is|when is|how do i)\b/i,
7119
+ /\b(?:simple|quick|brief|small|single|one-line|typo|rename)\b/i
7120
+ ];
7121
+ const wordCount = prompt.trim().split(/\s+/).length;
7122
+ return wordCount <= 12 && LOW_COMPLEXITY.some((re) => re.test(prompt.trim()));
7123
+ }
7124
+ // Cache glob scan results per workspace path to avoid repeated I/O.
7125
+ static globCache = /* @__PURE__ */ new Map();
7126
+ async countWorkspaceFiles(workspacePath) {
7127
+ const now = Date.now();
7128
+ const cached = _Cascade.globCache.get(workspacePath);
7129
+ if (cached && cached.expiresAt > now) return cached.count;
6118
7130
  try {
6119
7131
  const files = await glob("**/*.*", {
6120
7132
  cwd: workspacePath,
6121
7133
  ignore: ["node_modules/**", ".git/**", "dist/**", "build/**"],
6122
7134
  nodir: true
6123
7135
  });
6124
- workspaceContext = `Workspace Scout: Found ~${files.length} source files in the project.`;
7136
+ _Cascade.globCache.set(workspacePath, { count: files.length, expiresAt: now + 3e4 });
7137
+ return files.length;
7138
+ } catch {
7139
+ return 0;
7140
+ }
7141
+ }
7142
+ async determineComplexity(prompt, workspacePath, conversationHistory = []) {
7143
+ if (this.isCasualGreeting(prompt)) return "Simple";
7144
+ if (this.looksLikeSimpleArtifactTask(prompt)) return "Simple";
7145
+ if (this.looksLikeConversational(prompt)) return "Simple";
7146
+ let workspaceContext = "";
7147
+ try {
7148
+ const count = await this.countWorkspaceFiles(workspacePath);
7149
+ workspaceContext = `Workspace Scout: Found ~${count} source files in the project.`;
6125
7150
  } catch {
6126
7151
  workspaceContext = "Workspace Scout: Could not scan workspace.";
6127
7152
  }
@@ -6207,7 +7232,7 @@ ${prompt}` : prompt;
6207
7232
  this.telemetry.capture("cascade:session_start", {
6208
7233
  complexity,
6209
7234
  providerCount: this.config.providers.length,
6210
- cascadeAutoEnabled: this.config["cascadeAuto"] === true,
7235
+ cascadeAutoEnabled: this.config.cascadeAuto === true,
6211
7236
  toolCreationEnabled: this.config["enableToolCreation"] === true
6212
7237
  });
6213
7238
  this.emit("tier:root", { role: complexity === "Simple" ? "T3" : complexity === "Moderate" ? "T2" : "T1" });
@@ -6222,6 +7247,7 @@ ${prompt}` : prompt;
6222
7247
  }));
6223
7248
  }
6224
7249
  const toolCreator = this.toolCreator;
7250
+ if (toolCreator) toolCreator.setPermissionEscalator(escalator);
6225
7251
  let finalOutput = "";
6226
7252
  let t2Results = [];
6227
7253
  let runError = null;
@@ -6243,6 +7269,8 @@ ${prompt}` : prompt;
6243
7269
  });
6244
7270
  tier.on("log", (e) => this.emit("log", e));
6245
7271
  tier.on("tier:status", (e) => this.emit("tier:status", e));
7272
+ tier.on("tool:call", (e) => this.emit("tool:call", e));
7273
+ tier.on("tool:result", (e) => this.emit("tool:result", e));
6246
7274
  tier.on("tool:approval-request", async (request) => {
6247
7275
  this.emit("tool:approval-request", request);
6248
7276
  let decision = { approved: false };
@@ -6297,6 +7325,7 @@ ${prompt}` : prompt;
6297
7325
  }
6298
7326
  t2.setPermissionEscalator(escalator);
6299
7327
  if (toolCreator) t2.setToolCreator(toolCreator);
7328
+ t2.setPeerMessageCallback((e) => this.emit("peer:message", e), options.sessionId ?? "");
6300
7329
  bindTierEvents(t2);
6301
7330
  const assignment = {
6302
7331
  sectionId: taskId,
@@ -6326,6 +7355,7 @@ ${prompt}` : prompt;
6326
7355
  }
6327
7356
  t1.setPermissionEscalator(escalator);
6328
7357
  if (toolCreator) t1.setToolCreator(toolCreator);
7358
+ t1.setPeerMessageCallback((e) => this.emit("peer:message", e), options.sessionId ?? "");
6329
7359
  bindTierEvents(t1);
6330
7360
  t1.on("plan", (e) => this.emit("plan", e));
6331
7361
  const result = await t1.execute(options.prompt, options.images, void 0, options.signal);
@@ -6349,6 +7379,13 @@ ${prompt}` : prompt;
6349
7379
  escalator.cancelAllPending();
6350
7380
  } catch {
6351
7381
  }
7382
+ if (this.taskAnalyzer) {
7383
+ try {
7384
+ const stats2 = this.router.getStats();
7385
+ this.taskAnalyzer.recordRunOutcome(runError ? "failure" : "success", stats2.costByTier);
7386
+ } catch {
7387
+ }
7388
+ }
6352
7389
  try {
6353
7390
  const stats2 = this.router.getStats();
6354
7391
  const durationMs2 = Date.now() - startMs;
@@ -6449,7 +7486,7 @@ var Keystore = class {
6449
7486
  const creds = await this.keytar.findCredentials(KEYTAR_SERVICE);
6450
7487
  this.cache = Object.fromEntries(creds.map((c) => [c.account, c.password]));
6451
7488
  this.backend = "keytar";
6452
- if (password && fs11.existsSync(this.storePath)) {
7489
+ if (password && fs15.existsSync(this.storePath)) {
6453
7490
  try {
6454
7491
  const fileEntries = this.decryptFile(password);
6455
7492
  for (const [k, v] of Object.entries(fileEntries)) {
@@ -6468,7 +7505,7 @@ var Keystore = class {
6468
7505
  "Keystore unlock requires a password because the OS keychain (keytar) is not available on this system."
6469
7506
  );
6470
7507
  }
6471
- if (!fs11.existsSync(this.storePath)) {
7508
+ if (!fs15.existsSync(this.storePath)) {
6472
7509
  const salt = crypto.randomBytes(SALT_LEN);
6473
7510
  this.masterKey = this.deriveKey(password, salt);
6474
7511
  this.writeWithSalt({}, salt);
@@ -6482,7 +7519,7 @@ var Keystore = class {
6482
7519
  }
6483
7520
  /** Synchronous legacy unlock kept for AES-only environments. */
6484
7521
  unlockSync(password) {
6485
- if (!fs11.existsSync(this.storePath)) {
7522
+ if (!fs15.existsSync(this.storePath)) {
6486
7523
  const salt = crypto.randomBytes(SALT_LEN);
6487
7524
  this.masterKey = this.deriveKey(password, salt);
6488
7525
  this.writeWithSalt({}, salt);
@@ -6540,7 +7577,7 @@ var Keystore = class {
6540
7577
  }
6541
7578
  }
6542
7579
  decryptFile(password, knownSalt) {
6543
- if (!fs11.existsSync(this.storePath)) return {};
7580
+ if (!fs15.existsSync(this.storePath)) return {};
6544
7581
  try {
6545
7582
  const { salt, ciphertext, iv, tag } = this.readRaw();
6546
7583
  const useSalt = knownSalt ?? salt;
@@ -6562,8 +7599,8 @@ var Keystore = class {
6562
7599
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
6563
7600
  const tag = cipher.getAuthTag();
6564
7601
  const out = Buffer.concat([raw.salt, iv, tag, ciphertext]);
6565
- fs11.mkdirSync(path13.dirname(this.storePath), { recursive: true });
6566
- fs11.writeFileSync(this.storePath, out, { mode: 384 });
7602
+ fs15.mkdirSync(path16.dirname(this.storePath), { recursive: true });
7603
+ fs15.writeFileSync(this.storePath, out, { mode: 384 });
6567
7604
  }
6568
7605
  writeWithSalt(data, salt) {
6569
7606
  if (!this.masterKey) throw new Error("writeWithSalt called before masterKey was set");
@@ -6573,11 +7610,11 @@ var Keystore = class {
6573
7610
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
6574
7611
  const tag = cipher.getAuthTag();
6575
7612
  const out = Buffer.concat([salt, iv, tag, ciphertext]);
6576
- fs11.mkdirSync(path13.dirname(this.storePath), { recursive: true });
6577
- fs11.writeFileSync(this.storePath, out, { mode: 384 });
7613
+ fs15.mkdirSync(path16.dirname(this.storePath), { recursive: true });
7614
+ fs15.writeFileSync(this.storePath, out, { mode: 384 });
6578
7615
  }
6579
7616
  readRaw() {
6580
- const buf = fs11.readFileSync(this.storePath);
7617
+ const buf = fs15.readFileSync(this.storePath);
6581
7618
  let offset = 0;
6582
7619
  const salt = buf.subarray(offset, offset + SALT_LEN);
6583
7620
  offset += SALT_LEN;
@@ -6610,9 +7647,9 @@ var CascadeIgnore = class {
6610
7647
  ]);
6611
7648
  }
6612
7649
  async load(workspacePath) {
6613
- const filePath = path13.join(workspacePath, ".cascadeignore");
7650
+ const filePath = path16.join(workspacePath, ".cascadeignore");
6614
7651
  try {
6615
- const content = await fs2.readFile(filePath, "utf-8");
7652
+ const content = await fs3.readFile(filePath, "utf-8");
6616
7653
  const lines = content.split("\n").filter((l) => l.trim() && !l.startsWith("#"));
6617
7654
  this.ig.add(lines);
6618
7655
  this.loaded = true;
@@ -6621,7 +7658,7 @@ var CascadeIgnore = class {
6621
7658
  }
6622
7659
  isIgnored(filePath, workspacePath) {
6623
7660
  try {
6624
- const relative = workspacePath ? path13.relative(workspacePath, filePath) : filePath;
7661
+ const relative = workspacePath ? path16.relative(workspacePath, filePath) : filePath;
6625
7662
  return this.ig.ignores(relative);
6626
7663
  } catch {
6627
7664
  return false;
@@ -6632,9 +7669,9 @@ var CascadeIgnore = class {
6632
7669
  }
6633
7670
  };
6634
7671
  async function loadCascadeMd(workspacePath) {
6635
- const filePath = path13.join(workspacePath, "CASCADE.md");
7672
+ const filePath = path16.join(workspacePath, "CASCADE.md");
6636
7673
  try {
6637
- const raw = await fs2.readFile(filePath, "utf-8");
7674
+ const raw = await fs3.readFile(filePath, "utf-8");
6638
7675
  return parseCascadeMd(raw);
6639
7676
  } catch {
6640
7677
  return null;
@@ -6663,7 +7700,7 @@ ${raw.trim()}`;
6663
7700
  var MemoryStore = class _MemoryStore {
6664
7701
  db;
6665
7702
  constructor(dbPath) {
6666
- fs11.mkdirSync(path13.dirname(dbPath), { recursive: true });
7703
+ fs15.mkdirSync(path16.dirname(dbPath), { recursive: true });
6667
7704
  try {
6668
7705
  this.db = new Database(dbPath, { timeout: 5e3 });
6669
7706
  this.db.pragma("journal_mode = WAL");
@@ -7146,6 +8183,27 @@ Original error: ${err.message}`
7146
8183
  if (!row.oldest) return Infinity;
7147
8184
  return Date.now() - new Date(row.oldest).getTime();
7148
8185
  }
8186
+ saveModelProfile(modelId, provider, specializations) {
8187
+ const cacheKey = `${provider}:${modelId}`;
8188
+ const existing = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(cacheKey);
8189
+ 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 };
8190
+ meta.specializations = specializations;
8191
+ this.db.prepare(`
8192
+ INSERT INTO model_cache (id, provider, model_id, name, metadata, updated_at)
8193
+ VALUES (?, ?, ?, ?, ?, ?)
8194
+ ON CONFLICT(id) DO UPDATE SET metadata = excluded.metadata, updated_at = excluded.updated_at
8195
+ `).run(cacheKey, provider, modelId, meta.name ?? modelId, JSON.stringify(meta), (/* @__PURE__ */ new Date()).toISOString());
8196
+ }
8197
+ getModelProfile(modelId, provider) {
8198
+ const row = this.db.prepare("SELECT metadata FROM model_cache WHERE id = ?").get(`${provider}:${modelId}`);
8199
+ return row ? JSON.parse(row.metadata) : void 0;
8200
+ }
8201
+ getProfiledModelIds() {
8202
+ const rows = this.db.prepare(
8203
+ "SELECT model_id FROM model_cache WHERE json_extract(metadata, '$.specializations') IS NOT NULL"
8204
+ ).all();
8205
+ return rows.map((r) => r.model_id);
8206
+ }
7149
8207
  // ── Tool Result Cache (in-memory, TTL-based) ──────────────────────────
7150
8208
  // Avoids redundant calls for read-only tools within a short window.
7151
8209
  // Not persisted to DB — cleared on process restart.
@@ -7400,15 +8458,15 @@ var ConfigManager = class {
7400
8458
  globalDir;
7401
8459
  constructor(workspacePath = process.cwd()) {
7402
8460
  this.workspacePath = workspacePath;
7403
- this.globalDir = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR);
8461
+ this.globalDir = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR);
7404
8462
  }
7405
8463
  async load() {
7406
8464
  this.config = await this.loadConfig();
7407
8465
  this.ignore = new CascadeIgnore();
7408
8466
  await this.ignore.load(this.workspacePath);
7409
8467
  this.cascadeMd = await loadCascadeMd(this.workspacePath);
7410
- this.keystore = new Keystore(path13.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
7411
- this.store = new MemoryStore(path13.join(this.workspacePath, CASCADE_DB_FILE));
8468
+ this.keystore = new Keystore(path16.join(this.globalDir, GLOBAL_KEYSTORE_FILE));
8469
+ this.store = new MemoryStore(path16.join(this.workspacePath, CASCADE_DB_FILE));
7412
8470
  await this.injectEnvKeys();
7413
8471
  await this.ensureDefaultIdentity();
7414
8472
  }
@@ -7431,9 +8489,9 @@ var ConfigManager = class {
7431
8489
  return this.workspacePath;
7432
8490
  }
7433
8491
  async save() {
7434
- const configPath = path13.join(this.workspacePath, CASCADE_CONFIG_FILE);
7435
- await fs2.mkdir(path13.dirname(configPath), { recursive: true });
7436
- await fs2.writeFile(configPath, JSON.stringify(this.config, null, 2), "utf-8");
8492
+ const configPath = path16.join(this.workspacePath, CASCADE_CONFIG_FILE);
8493
+ await fs3.mkdir(path16.dirname(configPath), { recursive: true });
8494
+ await fs3.writeFile(configPath, JSON.stringify(this.config, null, 2), "utf-8");
7437
8495
  }
7438
8496
  async updateConfig(updates) {
7439
8497
  this.config = validateConfig({ ...this.config, ...updates });
@@ -7456,9 +8514,9 @@ var ConfigManager = class {
7456
8514
  return configProvider?.apiKey;
7457
8515
  }
7458
8516
  async loadConfig() {
7459
- const configPath = path13.join(this.workspacePath, CASCADE_CONFIG_FILE);
8517
+ const configPath = path16.join(this.workspacePath, CASCADE_CONFIG_FILE);
7460
8518
  try {
7461
- const raw = await fs2.readFile(configPath, "utf-8");
8519
+ const raw = await fs3.readFile(configPath, "utf-8");
7462
8520
  return validateConfig(JSON.parse(raw));
7463
8521
  } catch (err) {
7464
8522
  if (err.code === "ENOENT") {
@@ -7622,6 +8680,9 @@ var DashboardSocket = class {
7622
8680
  emitStreamToken(tierId, text, sessionId) {
7623
8681
  this.io.to(`session:${sessionId}`).emit("stream:token", { tierId, text, sessionId });
7624
8682
  }
8683
+ emitPeerMessage(event) {
8684
+ this.io.to(`session:${event.sessionId}`).emit("peer:message", event);
8685
+ }
7625
8686
  emitApprovalRequest(request) {
7626
8687
  this.io.emit("permission:user-required", request);
7627
8688
  }
@@ -7664,16 +8725,13 @@ var DashboardSocket = class {
7664
8725
  const { sessionId } = normalizeSessionSubscriptionPayload(payload);
7665
8726
  socket.leave(`session:${sessionId}`);
7666
8727
  });
7667
- socket.on("join:tenant", (tenantId) => {
7668
- socket.join(`tenant:${tenantId}`);
7669
- });
7670
8728
  });
7671
8729
  }
7672
8730
  close() {
7673
8731
  this.io.close();
7674
8732
  }
7675
8733
  };
7676
- var __dirname$1 = path13.dirname(fileURLToPath(import.meta.url));
8734
+ var __dirname$1 = path16.dirname(fileURLToPath(import.meta.url));
7677
8735
  var DashboardServer = class {
7678
8736
  app;
7679
8737
  httpServer;
@@ -7739,15 +8797,15 @@ var DashboardServer = class {
7739
8797
  resolveDashboardSecret() {
7740
8798
  const fromConfig = this.config.dashboard.secret ?? process.env["CASCADE_DASHBOARD_SECRET"];
7741
8799
  if (fromConfig) return fromConfig;
7742
- const secretPath = path13.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
8800
+ const secretPath = path16.join(this.workspacePath, CASCADE_DASHBOARD_SECRET_FILE);
7743
8801
  try {
7744
- if (fs11.existsSync(secretPath)) {
7745
- const existing = fs11.readFileSync(secretPath, "utf-8").trim();
8802
+ if (fs15.existsSync(secretPath)) {
8803
+ const existing = fs15.readFileSync(secretPath, "utf-8").trim();
7746
8804
  if (existing.length >= 16) return existing;
7747
8805
  }
7748
8806
  const generated = randomUUID();
7749
- fs11.mkdirSync(path13.dirname(secretPath), { recursive: true });
7750
- fs11.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
8807
+ fs15.mkdirSync(path16.dirname(secretPath), { recursive: true });
8808
+ fs15.writeFileSync(secretPath, generated, { encoding: "utf-8", mode: 384 });
7751
8809
  if (this.config.dashboard.auth) {
7752
8810
  console.warn(
7753
8811
  `Dashboard auth enabled with no secret configured; persisted a generated secret to ${secretPath}. Set CASCADE_DASHBOARD_SECRET or config.dashboard.secret to override.`
@@ -7774,7 +8832,7 @@ var DashboardServer = class {
7774
8832
  // ── Setup ─────────────────────────────────────
7775
8833
  getGlobalStore() {
7776
8834
  if (!this.globalStore) {
7777
- const globalDbPath = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8835
+ const globalDbPath = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7778
8836
  this.globalStore = new MemoryStore(globalDbPath);
7779
8837
  }
7780
8838
  return this.globalStore;
@@ -7835,12 +8893,12 @@ var DashboardServer = class {
7835
8893
  }
7836
8894
  }
7837
8895
  watchRuntimeChanges() {
7838
- const workspaceDbPath = path13.join(this.workspacePath, CASCADE_DB_FILE);
7839
- const globalDbPath = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8896
+ const workspaceDbPath = path16.join(this.workspacePath, CASCADE_DB_FILE);
8897
+ const globalDbPath = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7840
8898
  const watchPaths = [workspaceDbPath, globalDbPath].filter((p, index, arr) => arr.indexOf(p) === index);
7841
8899
  for (const watchPath of watchPaths) {
7842
- if (!fs11.existsSync(watchPath)) continue;
7843
- fs11.watchFile(watchPath, { interval: 3e3 }, () => {
8900
+ if (!fs15.existsSync(watchPath)) continue;
8901
+ fs15.watchFile(watchPath, { interval: 3e3 }, () => {
7844
8902
  this.throttledBroadcast(watchPath === globalDbPath ? "global" : "workspace");
7845
8903
  });
7846
8904
  }
@@ -7871,6 +8929,21 @@ var DashboardServer = class {
7871
8929
  legacyHeaders: false,
7872
8930
  message: { error: "Too many login attempts. Try again in 15 minutes." }
7873
8931
  });
8932
+ const apiLimiter = rateLimit({
8933
+ windowMs: 60 * 1e3,
8934
+ limit: 60,
8935
+ standardHeaders: "draft-7",
8936
+ legacyHeaders: false,
8937
+ message: { error: "Too many requests. Slow down." }
8938
+ });
8939
+ this.app.use("/api", apiLimiter);
8940
+ const mutationLimiter = rateLimit({
8941
+ windowMs: 60 * 1e3,
8942
+ limit: 10,
8943
+ standardHeaders: "draft-7",
8944
+ legacyHeaders: false,
8945
+ message: { error: "Too many requests on this endpoint." }
8946
+ });
7874
8947
  this.app.post("/api/auth/login", loginLimiter, (req, res) => {
7875
8948
  const { username, password } = req.body ?? {};
7876
8949
  if (!authRequired) {
@@ -7906,22 +8979,33 @@ var DashboardServer = class {
7906
8979
  res.status(401).json({ error: "Invalid credentials" });
7907
8980
  }
7908
8981
  });
7909
- this.app.post("/api/force-halt", auth, (req, res) => {
7910
- const { sessionId, nodeId } = req.body;
8982
+ this.app.post("/api/force-halt", auth, mutationLimiter, (req, res) => {
8983
+ const body = req.body;
8984
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
8985
+ const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
7911
8986
  const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
7912
8987
  this.socket.broadcast("session:halt", payload);
7913
8988
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:halt", payload);
7914
8989
  res.json({ success: true, ...payload });
7915
8990
  });
7916
- this.app.post("/api/approve", auth, (req, res) => {
7917
- const { nodeId, sessionId } = req.body;
8991
+ this.app.post("/api/approve", auth, mutationLimiter, (req, res) => {
8992
+ const body = req.body;
8993
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
8994
+ const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
7918
8995
  const payload = { sessionId, nodeId, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
7919
8996
  this.socket.broadcast("session:approve", payload);
7920
8997
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:approve", payload);
7921
8998
  res.json({ success: true, ...payload });
7922
8999
  });
7923
- this.app.post("/api/inject", auth, (req, res) => {
7924
- const { message, sessionId, nodeId } = req.body;
9000
+ this.app.post("/api/inject", auth, mutationLimiter, (req, res) => {
9001
+ const body = req.body;
9002
+ const message = typeof body["message"] === "string" ? body["message"] : void 0;
9003
+ const sessionId = typeof body["sessionId"] === "string" ? body["sessionId"] : void 0;
9004
+ const nodeId = typeof body["nodeId"] === "string" ? body["nodeId"] : void 0;
9005
+ if (!message) {
9006
+ res.status(400).json({ error: "message is required and must be a string" });
9007
+ return;
9008
+ }
7925
9009
  const payload = { sessionId, nodeId, message, requestedAt: (/* @__PURE__ */ new Date()).toISOString() };
7926
9010
  this.socket.broadcast("session:message-injected", payload);
7927
9011
  if (sessionId) this.socket.broadcastToRoom(`session:${sessionId}`, "session:message-injected", payload);
@@ -7944,7 +9028,7 @@ var DashboardServer = class {
7944
9028
  const sessionId = req.params.id;
7945
9029
  this.store.deleteSession(sessionId);
7946
9030
  this.store.deleteRuntimeSession(sessionId);
7947
- const globalDbPath = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9031
+ const globalDbPath = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7948
9032
  const globalStore = new MemoryStore(globalDbPath);
7949
9033
  try {
7950
9034
  globalStore.deleteRuntimeSession(sessionId);
@@ -7958,7 +9042,7 @@ var DashboardServer = class {
7958
9042
  });
7959
9043
  this.app.delete("/api/sessions", auth, (req, res) => {
7960
9044
  const body = req.body;
7961
- const globalDbPath = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9045
+ const globalDbPath = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7962
9046
  if (body?.ids && Array.isArray(body.ids) && body.ids.length > 0) {
7963
9047
  const globalStore = new MemoryStore(globalDbPath);
7964
9048
  try {
@@ -7981,7 +9065,7 @@ var DashboardServer = class {
7981
9065
  });
7982
9066
  this.app.delete("/api/runtime", auth, (_req, res) => {
7983
9067
  this.store.deleteAllRuntimeNodes();
7984
- const globalDbPath = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9068
+ const globalDbPath = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
7985
9069
  const globalStore = new MemoryStore(globalDbPath);
7986
9070
  try {
7987
9071
  globalStore.deleteAllRuntimeNodes();
@@ -8043,16 +9127,26 @@ var DashboardServer = class {
8043
9127
  });
8044
9128
  this.app.put("/api/config", auth, async (req, res) => {
8045
9129
  const body = req.body;
8046
- if (body.tierLimits) this.config.tierLimits = { ...this.config.tierLimits, ...body.tierLimits };
8047
- if (body.budget) this.config.budget = { ...this.config.budget, ...body.budget };
9130
+ if (body["tierLimits"] !== void 0 && (typeof body["tierLimits"] !== "object" || Array.isArray(body["tierLimits"]))) {
9131
+ res.status(400).json({ error: "tierLimits must be an object" });
9132
+ return;
9133
+ }
9134
+ if (body["budget"] !== void 0 && (typeof body["budget"] !== "object" || Array.isArray(body["budget"]))) {
9135
+ res.status(400).json({ error: "budget must be an object" });
9136
+ return;
9137
+ }
9138
+ if (body["tierLimits"]) this.config.tierLimits = { ...this.config.tierLimits, ...body["tierLimits"] };
9139
+ if (body["budget"]) this.config.budget = { ...this.config.budget, ...body["budget"] };
8048
9140
  try {
8049
- const configPath = path13.join(this.workspacePath, CASCADE_CONFIG_FILE);
8050
- const existing = fs11.existsSync(configPath) ? JSON.parse(fs11.readFileSync(configPath, "utf-8")) : {};
9141
+ const configPath = path16.join(this.workspacePath, CASCADE_CONFIG_FILE);
9142
+ const existing = fs15.existsSync(configPath) ? JSON.parse(fs15.readFileSync(configPath, "utf-8")) : {};
8051
9143
  const updated = { ...existing, tierLimits: this.config.tierLimits, budget: this.config.budget };
8052
- fs11.writeFileSync(configPath, JSON.stringify(updated, null, 2), "utf-8");
9144
+ const tmp = configPath + ".tmp";
9145
+ fs15.writeFileSync(tmp, JSON.stringify(updated, null, 2), "utf-8");
9146
+ fs15.renameSync(tmp, configPath);
8053
9147
  res.json({ ok: true });
8054
9148
  } catch (err) {
8055
- res.status(500).json({ error: `Failed to save config: ${String(err)}` });
9149
+ res.status(500).json({ error: `Failed to save config: ${err instanceof Error ? err.message : String(err)}` });
8056
9150
  }
8057
9151
  });
8058
9152
  this.app.get("/api/runtime/logs/:sessionId", auth, (req, res) => {
@@ -8077,7 +9171,7 @@ var DashboardServer = class {
8077
9171
  this.app.get("/api/runtime", auth, (req, res) => {
8078
9172
  const scope = req.query["scope"] ?? "workspace";
8079
9173
  if (scope === "global") {
8080
- const globalDbPath = path13.join(os2.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
9174
+ const globalDbPath = path16.join(os3.homedir(), GLOBAL_CONFIG_DIR, GLOBAL_RUNTIME_DB_FILE);
8081
9175
  const globalStore = new MemoryStore(globalDbPath);
8082
9176
  try {
8083
9177
  res.json({
@@ -8098,7 +9192,7 @@ var DashboardServer = class {
8098
9192
  logs: this.store.listRuntimeNodeLogs(void 0, void 0, 500)
8099
9193
  });
8100
9194
  });
8101
- this.app.post("/api/run", auth, (req, res) => {
9195
+ this.app.post("/api/run", auth, mutationLimiter, (req, res) => {
8102
9196
  const body = req.body;
8103
9197
  if (!body.prompt || typeof body.prompt !== "string") {
8104
9198
  res.status(400).json({ error: "prompt is required" });
@@ -8119,12 +9213,15 @@ var DashboardServer = class {
8119
9213
  cascade.on("permission:user-required", (e) => {
8120
9214
  this.socket.broadcastToRoom(`session:${sessionId}`, "permission:user-required", { sessionId, ...e });
8121
9215
  });
9216
+ cascade.on("peer:message", (e) => {
9217
+ this.socket.emitPeerMessage(e);
9218
+ });
8122
9219
  try {
8123
9220
  const result = await cascade.run({ prompt: body.prompt, identityId: body.identityId });
8124
9221
  this.socket.broadcast("cost:update", {
8125
9222
  sessionId,
8126
- tokens: result.usage.totalTokens,
8127
- costUsd: result.usage.estimatedCostUsd
9223
+ totalTokens: result.usage.totalTokens,
9224
+ totalCostUsd: result.usage.estimatedCostUsd
8128
9225
  });
8129
9226
  this.socket.broadcastToRoom(`session:${sessionId}`, "session:complete", { sessionId, result });
8130
9227
  this.throttledBroadcast("workspace");
@@ -8147,13 +9244,13 @@ var DashboardServer = class {
8147
9244
  }))
8148
9245
  });
8149
9246
  });
8150
- const prodPath = path13.resolve(__dirname$1, "../web/dist");
8151
- const devPath = path13.resolve(__dirname$1, "../../web/dist");
8152
- const webDistPath = fs11.existsSync(prodPath) ? prodPath : devPath;
8153
- if (fs11.existsSync(webDistPath)) {
9247
+ const prodPath = path16.resolve(__dirname$1, "../web/dist");
9248
+ const devPath = path16.resolve(__dirname$1, "../../web/dist");
9249
+ const webDistPath = fs15.existsSync(prodPath) ? prodPath : devPath;
9250
+ if (fs15.existsSync(webDistPath)) {
8154
9251
  this.app.use(express.static(webDistPath));
8155
9252
  this.app.get("*", (_req, res) => {
8156
- res.sendFile(path13.join(webDistPath, "index.html"));
9253
+ res.sendFile(path16.join(webDistPath, "index.html"));
8157
9254
  });
8158
9255
  } else {
8159
9256
  this.app.get("/", (_req, res) => {
@@ -8230,7 +9327,7 @@ var TaskScheduler = class {
8230
9327
  return cron.validate(expression);
8231
9328
  }
8232
9329
  };
8233
- var execFileAsync = promisify(execFile);
9330
+ var execFileAsync2 = promisify(execFile);
8234
9331
  var SAFE_ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
8235
9332
  function sanitizeEnvValue(v) {
8236
9333
  const raw = typeof v === "string" ? v : JSON.stringify(v);
@@ -8269,7 +9366,7 @@ var HooksRunner = class {
8269
9366
  const isWin = process.platform === "win32";
8270
9367
  const shell = isWin ? "cmd.exe" : "/bin/sh";
8271
9368
  const shellArgs = isWin ? ["/d", "/s", "/c", hook.command] : ["-c", hook.command];
8272
- const { stdout } = await execFileAsync(shell, shellArgs, {
9369
+ const { stdout } = await execFileAsync2(shell, shellArgs, {
8273
9370
  timeout: hook.timeout ?? 1e4,
8274
9371
  env: { ...process.env, ...envVars },
8275
9372
  windowsHide: true