cascade-ai 0.49.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.cjs CHANGED
@@ -1793,7 +1793,7 @@ var CASCADE_VERSION, CASCADE_CONFIG_FILE, CASCADE_DB_FILE, CASCADE_DASHBOARD_SEC
1793
1793
  var init_constants = __esm({
1794
1794
  "src/constants.ts"() {
1795
1795
  init_pricing();
1796
- CASCADE_VERSION = "0.49.0";
1796
+ CASCADE_VERSION = "0.51.0";
1797
1797
  CASCADE_CONFIG_FILE = ".cascade/config.json";
1798
1798
  CASCADE_DB_FILE = ".cascade/memory.db";
1799
1799
  CASCADE_DASHBOARD_SECRET_FILE = ".cascade/dashboard-secret";
@@ -2311,49 +2311,40 @@ function isChatModel(id, methods) {
2311
2311
  }
2312
2312
  return !NON_CHAT.test(id);
2313
2313
  }
2314
- var NON_CHAT_PATTERNS, NON_CHAT;
2314
+ var MODALITY_PATTERNS, NON_CHAT;
2315
2315
  var init_model_filter = __esm({
2316
2316
  "src/providers/model-filter.ts"() {
2317
- NON_CHAT_PATTERNS = [
2318
- "embed",
2317
+ MODALITY_PATTERNS = [
2318
+ ["embedding", ["embed"]],
2319
2319
  // text-embedding-3, nomic-embed, mxbai-embed, snowflake-arctic-embed
2320
- "(?:^|[-_/])tts(?:[-_/]|$)",
2321
- "text-to-speech",
2322
- "speech",
2323
- // text-to-speech
2324
- "whisper",
2325
- "transcrib",
2326
- "(?:^|[-_/])stt(?:[-_/]|$)",
2327
- // speech-to-text
2328
- "dall[-]?e",
2329
- "imagen",
2330
- "image-generation",
2331
- "gpt-image",
2332
- "(?:^|[-_/])image(?:[-_/]|$)",
2333
- "stable-diffusion",
2334
- "sdxl",
2335
- "flux",
2336
- // image gen
2337
- "moderation",
2338
- "(?:^|[-_/])guard(?:[-_/]|$)",
2339
- // moderation / safety
2340
- "rerank",
2341
- "(?:^|[-_/])audio(?:[-_/]|$)",
2342
- "native-audio",
2343
- "realtime",
2344
- // audio / realtime
2345
- "veo",
2346
- "sora",
2347
- "(?:^|[-_/])video(?:[-_/]|$)",
2348
- // video
2349
- "(?:^|[-_/])aqa(?:[-_/]|$)",
2350
- // attributed QA
2351
- "(?:^|[-_/])live(?:[-_/]|$)",
2352
- // live-audio variants
2353
- "(?:^|[-_/])(?:davinci|babbage)(?:[-_/]|$)"
2354
- // legacy completion-only base models
2320
+ ["transcription", ["whisper", "transcrib", "(?:^|[-_/])stt(?:[-_/]|$)"]],
2321
+ ["speech", ["(?:^|[-_/])tts(?:[-_/]|$)", "text-to-speech", "speech"]],
2322
+ ["video", ["veo", "sora", "(?:^|[-_/])video(?:[-_/]|$)"]],
2323
+ ["image", [
2324
+ "dall[-]?e",
2325
+ "imagen",
2326
+ "image-generation",
2327
+ "gpt-image",
2328
+ "(?:^|[-_/])image(?:[-_/]|$)",
2329
+ "stable-diffusion",
2330
+ "sdxl",
2331
+ "flux"
2332
+ ]],
2333
+ ["moderation", ["moderation", "(?:^|[-_/])guard(?:[-_/]|$)"]],
2334
+ ["rerank", ["rerank"]],
2335
+ ["realtime", [
2336
+ "(?:^|[-_/])audio(?:[-_/]|$)",
2337
+ "native-audio",
2338
+ "realtime",
2339
+ "(?:^|[-_/])live(?:[-_/]|$)",
2340
+ // live-audio variants
2341
+ "(?:^|[-_/])aqa(?:[-_/]|$)"
2342
+ // attributed QA
2343
+ ]],
2344
+ ["legacy-completion", ["(?:^|[-_/])(?:davinci|babbage)(?:[-_/]|$)"]]
2355
2345
  ];
2356
- NON_CHAT = new RegExp(NON_CHAT_PATTERNS.join("|"), "i");
2346
+ MODALITY_PATTERNS.map(([m, pats]) => [m, new RegExp(pats.join("|"), "i")]);
2347
+ NON_CHAT = new RegExp(MODALITY_PATTERNS.flatMap(([, p]) => p).join("|"), "i");
2357
2348
  }
2358
2349
  });
2359
2350
 
@@ -2921,6 +2912,118 @@ var init_azure = __esm({
2921
2912
  }
2922
2913
  });
2923
2914
 
2915
+ // src/providers/gemini-schema.ts
2916
+ function isPlainObject(v) {
2917
+ return typeof v === "object" && v !== null && !Array.isArray(v);
2918
+ }
2919
+ function normalizeType(value) {
2920
+ if (typeof value === "string") {
2921
+ return ALLOWED_TYPES.has(value) ? { type: value } : {};
2922
+ }
2923
+ if (Array.isArray(value)) {
2924
+ const types = value.filter((t) => typeof t === "string");
2925
+ const nullable = types.includes("null");
2926
+ const concrete = types.find((t) => t !== "null" && ALLOWED_TYPES.has(t));
2927
+ return {
2928
+ ...concrete ? { type: concrete } : {},
2929
+ ...nullable ? { nullable: true } : {}
2930
+ };
2931
+ }
2932
+ return {};
2933
+ }
2934
+ function normalizeEnum(value) {
2935
+ if (!Array.isArray(value) || value.length === 0) return void 0;
2936
+ return value.map((v) => typeof v === "string" ? v : JSON.stringify(v));
2937
+ }
2938
+ function sanitizeGeminiSchema(schema) {
2939
+ if (!isPlainObject(schema)) return void 0;
2940
+ const out = {};
2941
+ for (const [key, value] of Object.entries(schema)) {
2942
+ if (!ALLOWED_KEYS.has(key)) continue;
2943
+ switch (key) {
2944
+ case "type": {
2945
+ Object.assign(out, normalizeType(value));
2946
+ break;
2947
+ }
2948
+ case "enum": {
2949
+ const e = normalizeEnum(value);
2950
+ if (e) out["enum"] = e;
2951
+ break;
2952
+ }
2953
+ case "properties": {
2954
+ if (!isPlainObject(value)) break;
2955
+ const props = {};
2956
+ for (const [propName, propSchema] of Object.entries(value)) {
2957
+ const cleaned = sanitizeGeminiSchema(propSchema);
2958
+ props[propName] = cleaned && Object.keys(cleaned).length ? cleaned : { type: "string" };
2959
+ }
2960
+ out["properties"] = props;
2961
+ break;
2962
+ }
2963
+ case "items": {
2964
+ const cleaned = sanitizeGeminiSchema(value);
2965
+ if (cleaned) out["items"] = cleaned;
2966
+ break;
2967
+ }
2968
+ case "anyOf": {
2969
+ if (!Array.isArray(value)) break;
2970
+ const variants = value.map((v) => sanitizeGeminiSchema(v)).filter((v) => !!v && Object.keys(v).length > 0);
2971
+ if (variants.length) out["anyOf"] = variants;
2972
+ break;
2973
+ }
2974
+ case "required": {
2975
+ if (Array.isArray(value)) {
2976
+ out["required"] = value.filter((r) => typeof r === "string");
2977
+ }
2978
+ break;
2979
+ }
2980
+ default:
2981
+ out[key] = value;
2982
+ }
2983
+ }
2984
+ if (!out["type"] && out["properties"]) out["type"] = "object";
2985
+ if (!out["type"] && out["items"]) out["type"] = "array";
2986
+ return out;
2987
+ }
2988
+ function toGeminiParameters(inputSchema) {
2989
+ const cleaned = sanitizeGeminiSchema(inputSchema);
2990
+ if (!cleaned) return void 0;
2991
+ const props = cleaned["properties"];
2992
+ if (isPlainObject(props) && Object.keys(props).length === 0) return void 0;
2993
+ if (Object.keys(cleaned).length === 0) return void 0;
2994
+ return cleaned;
2995
+ }
2996
+ var ALLOWED_KEYS, ALLOWED_TYPES;
2997
+ var init_gemini_schema = __esm({
2998
+ "src/providers/gemini-schema.ts"() {
2999
+ ALLOWED_KEYS = /* @__PURE__ */ new Set([
3000
+ "type",
3001
+ "format",
3002
+ "title",
3003
+ "description",
3004
+ "nullable",
3005
+ "enum",
3006
+ "items",
3007
+ "properties",
3008
+ "required",
3009
+ "propertyOrdering",
3010
+ "minItems",
3011
+ "maxItems",
3012
+ "minProperties",
3013
+ "maxProperties",
3014
+ "minimum",
3015
+ "maximum",
3016
+ "minLength",
3017
+ "maxLength",
3018
+ "pattern",
3019
+ "example",
3020
+ "default",
3021
+ "anyOf"
3022
+ ]);
3023
+ ALLOWED_TYPES = /* @__PURE__ */ new Set(["string", "number", "integer", "boolean", "array", "object"]);
3024
+ }
3025
+ });
3026
+
2924
3027
  // src/providers/gemini.ts
2925
3028
  var gemini_exports = {};
2926
3029
  __export(gemini_exports, {
@@ -2933,6 +3036,7 @@ var init_gemini = __esm({
2933
3036
  init_base();
2934
3037
  init_pricing();
2935
3038
  init_model_filter();
3039
+ init_gemini_schema();
2936
3040
  GeminiProvider = class extends BaseProvider {
2937
3041
  client;
2938
3042
  constructor(config, model) {
@@ -3153,7 +3257,12 @@ var init_gemini = __esm({
3153
3257
  return {
3154
3258
  name: tool.name,
3155
3259
  description: tool.description,
3156
- parameters: tool.inputSchema
3260
+ // Convert, never cast. Gemini's parameters field is an OpenAPI subset
3261
+ // that 400s on unknown keys, and MCP servers ship schemas carrying their
3262
+ // own extensions (GitHub's adds `x-mcp-header` to every header-bound
3263
+ // property). Casting sent those through verbatim and every request failed
3264
+ // before the model saw it. See providers/gemini-schema.ts.
3265
+ parameters: toGeminiParameters(tool.inputSchema)
3157
3266
  };
3158
3267
  }
3159
3268
  };
@@ -3178,7 +3287,7 @@ async function nodeHttpFetch(input, init = {}, redirectCount = 0) {
3178
3287
  headers["accept-encoding"] = "gzip, deflate, br";
3179
3288
  }
3180
3289
  const body = init.body == null ? void 0 : typeof init.body === "string" ? init.body : Buffer.from(init.body);
3181
- return new Promise((resolve, reject) => {
3290
+ return new Promise((resolve2, reject) => {
3182
3291
  const req = lib.request(
3183
3292
  {
3184
3293
  hostname: u.hostname,
@@ -3195,7 +3304,7 @@ async function nodeHttpFetch(input, init = {}, redirectCount = 0) {
3195
3304
  const nextUrl = new URL(location, u).href;
3196
3305
  const downgrade = status === 303 || (status === 301 || status === 302) && method !== "GET" && method !== "HEAD";
3197
3306
  const nextInit = downgrade ? { ...init, method: "GET", body: void 0 } : init;
3198
- resolve(nodeHttpFetch(nextUrl, nextInit, redirectCount + 1));
3307
+ resolve2(nodeHttpFetch(nextUrl, nextInit, redirectCount + 1));
3199
3308
  return;
3200
3309
  }
3201
3310
  const encoding = (res.headers["content-encoding"] ?? "").toLowerCase();
@@ -3210,7 +3319,7 @@ async function nodeHttpFetch(input, init = {}, redirectCount = 0) {
3210
3319
  if (Array.isArray(v)) respHeaders.set(k, v.join(", "));
3211
3320
  else if (typeof v === "string") respHeaders.set(k, v);
3212
3321
  }
3213
- resolve(new Response(stream$1, {
3322
+ resolve2(new Response(stream$1, {
3214
3323
  status,
3215
3324
  statusText: res.statusMessage ?? "",
3216
3325
  headers: respHeaders
@@ -5009,6 +5118,14 @@ var BudgetConfigSchema = zod.z.object({
5009
5118
  maxTokensPerRun: zod.z.number().int().positive().default(2e5),
5010
5119
  /** Optional hard per-task cost ceiling (USD). Unset = only the token cap applies. */
5011
5120
  maxCostPerRunUsd: zod.z.number().positive().optional(),
5121
+ /**
5122
+ * Consecutive systemic failures against ONE model before the run stops
5123
+ * launching work that would fail the same way (dead key, wrong model id,
5124
+ * exhausted quota). Lower reacts sooner and wastes less; higher absorbs a
5125
+ * longer transient before giving up. Only failures that will certainly repeat
5126
+ * are counted — see core/run-breaker.ts.
5127
+ */
5128
+ failureThreshold: zod.z.number().int().min(1).max(20).default(3),
5012
5129
  warnAtPct: zod.z.number().default(80)
5013
5130
  });
5014
5131
  var WorkspaceConfigSchema = zod.z.object({
@@ -6061,7 +6178,7 @@ var TpmLimiter = class {
6061
6178
  }
6062
6179
  const deficit = want - bucket.available;
6063
6180
  const waitMs = Math.max(50, Math.ceil(deficit / bucket.tokensPerMinute * 6e4));
6064
- await new Promise((resolve) => setTimeout(resolve, waitMs));
6181
+ await new Promise((resolve2) => setTimeout(resolve2, waitMs));
6065
6182
  }
6066
6183
  }
6067
6184
  /**
@@ -6124,14 +6241,14 @@ var LocalRequestQueue = class {
6124
6241
  this.active++;
6125
6242
  return this.makeRelease();
6126
6243
  }
6127
- return new Promise((resolve, reject) => {
6244
+ return new Promise((resolve2, reject) => {
6128
6245
  let settled = false;
6129
6246
  let timer;
6130
6247
  const resolver = (release) => {
6131
6248
  if (settled) return;
6132
6249
  settled = true;
6133
6250
  if (timer !== void 0) clearTimeout(timer);
6134
- resolve(release);
6251
+ resolve2(release);
6135
6252
  };
6136
6253
  if (timeoutMs !== void 0 && timeoutMs > 0) {
6137
6254
  timer = setTimeout(() => {
@@ -6758,6 +6875,7 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
6758
6875
  tpmLimiter;
6759
6876
  localQueue;
6760
6877
  taskAnalyzer;
6878
+ pendingFeedbackSource;
6761
6879
  worldStateDB;
6762
6880
  privacyPaths;
6763
6881
  guidanceQueue;
@@ -7212,8 +7330,18 @@ var CascadeRouter = class _CascadeRouter extends EventEmitter__default.default {
7212
7330
  return windows.length ? Math.min(...windows) : void 0;
7213
7331
  }
7214
7332
  /** Wire the Cascade Auto task analyzer used for per-subtask model routing. */
7333
+ /**
7334
+ * Feed per-model thumbs counts into Auto routing. Forwarded to the analyzer
7335
+ * because that is where the public benchmark score is combined; the prior
7336
+ * adjusts that number rather than sitting anywhere else in the pipeline.
7337
+ */
7338
+ setFeedbackSource(source) {
7339
+ this.taskAnalyzer?.setFeedbackSource(source);
7340
+ this.pendingFeedbackSource = source;
7341
+ }
7215
7342
  setTaskAnalyzer(analyzer) {
7216
7343
  this.taskAnalyzer = analyzer;
7344
+ if (this.pendingFeedbackSource) analyzer.setFeedbackSource(this.pendingFeedbackSource);
7217
7345
  }
7218
7346
  setWorldStateDB(db) {
7219
7347
  this.worldStateDB = db;
@@ -7668,6 +7796,14 @@ var BaseTier = class extends EventEmitter__default.default {
7668
7796
  this.emit("tier:status", event);
7669
7797
  }
7670
7798
  /** Record the model serving this tier; future status events carry it. */
7799
+ /**
7800
+ * The model actually serving this tier ("provider:id"), once one has been
7801
+ * chosen. The run breaker reads it to attribute a failure to a model rather
7802
+ * than to the subtask that happened to hit it.
7803
+ */
7804
+ getServingModel() {
7805
+ return this.servingModel;
7806
+ }
7671
7807
  setServingModel(model) {
7672
7808
  this.servingModel = model || void 0;
7673
7809
  }
@@ -8300,8 +8436,11 @@ Now execute your subtask using this context where relevant.`
8300
8436
  const result = await this.runAgentLoop(systemPrompt, this.tools);
8301
8437
  output = result.output;
8302
8438
  toolCalls = result.toolCalls;
8303
- this.sendStatusUpdate({ progressPct: 65, currentAction: "Verifying required artifacts", status: "IN_PROGRESS" });
8304
- const artifactCheck = await this.verifyArtifacts(assignment);
8439
+ const mustProduceArtifact = this.requiresArtifact();
8440
+ if (mustProduceArtifact) {
8441
+ this.sendStatusUpdate({ progressPct: 65, currentAction: "Verifying required artifacts", status: "IN_PROGRESS" });
8442
+ }
8443
+ const artifactCheck = mustProduceArtifact ? await this.verifyArtifacts(assignment) : { ok: true, issues: [] };
8305
8444
  if (!artifactCheck.ok) {
8306
8445
  correctionAttempts = 1;
8307
8446
  issues.push(...artifactCheck.issues);
@@ -8581,11 +8720,11 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
8581
8720
  if (!wasApproved) return `Tool ${tc.name} was denied by user.`;
8582
8721
  } else {
8583
8722
  const LEGACY_APPROVAL_TIMEOUT_MS = 6e5;
8584
- const legacyDecision = await new Promise((resolve) => {
8723
+ const legacyDecision = await new Promise((resolve2) => {
8585
8724
  const eventName = `tool:approval-response:${this.id}-${tc.id}`;
8586
8725
  const timer = setTimeout(() => {
8587
8726
  this.removeAllListeners(eventName);
8588
- resolve({ approved: false });
8727
+ resolve2({ approved: false });
8589
8728
  }, LEGACY_APPROVAL_TIMEOUT_MS);
8590
8729
  timer.unref?.();
8591
8730
  this.emit("tool:approval-request", {
@@ -8598,7 +8737,7 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
8598
8737
  });
8599
8738
  this.once(eventName, (d) => {
8600
8739
  clearTimeout(timer);
8601
- resolve(d);
8740
+ resolve2(d);
8602
8741
  });
8603
8742
  });
8604
8743
  if (legacyDecision.always) this.sessionApprovals.set(tc.name, legacyDecision.approved);
@@ -8618,6 +8757,9 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
8618
8757
  tierId: this.id,
8619
8758
  sessionId: this.taskId,
8620
8759
  requireApproval: false,
8760
+ // Media generation can run for a minute; without this a cancelled run
8761
+ // still pays for an image nobody will see.
8762
+ ...this.signal ? { signal: this.signal } : {},
8621
8763
  saveSnapshot: async (path37, content) => {
8622
8764
  this.store?.addFileSnapshot(this.taskId, path37, content);
8623
8765
  },
@@ -8665,7 +8807,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
8665
8807
  const result = await this.toolRegistry.execute(altTool, tc.input, {
8666
8808
  tierId: this.id,
8667
8809
  sessionId: this.taskId,
8668
- requireApproval: false
8810
+ requireApproval: false,
8811
+ ...this.signal ? { signal: this.signal } : {}
8669
8812
  });
8670
8813
  const str2 = typeof result === "string" ? result : JSON.stringify(result);
8671
8814
  if (!str2.startsWith("Tool error:") && !str2.startsWith("Error:")) {
@@ -8687,7 +8830,8 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : "") + textToolSuffix,
8687
8830
  const result = await this.toolRegistry.execute(newToolName, tc.input, {
8688
8831
  tierId: this.id,
8689
8832
  sessionId: this.taskId,
8690
- requireApproval: false
8833
+ requireApproval: false,
8834
+ ...this.signal ? { signal: this.signal } : {}
8691
8835
  });
8692
8836
  const str2 = typeof result === "string" ? result : JSON.stringify(result);
8693
8837
  if (!str2.startsWith("Tool error:")) return `[Synthesized ${newToolName}]: ${str2}`;
@@ -9105,7 +9249,7 @@ var PeerBus = class extends EventEmitter__default.default {
9105
9249
  this.outputs.set(subtaskId, entry);
9106
9250
  this.emit("output:ready", entry);
9107
9251
  const waiting = this.waiters.get(subtaskId) ?? [];
9108
- for (const resolve of waiting) resolve(entry);
9252
+ for (const resolve2 of waiting) resolve2(entry);
9109
9253
  this.waiters.delete(subtaskId);
9110
9254
  }
9111
9255
  /**
@@ -9136,10 +9280,10 @@ var PeerBus = class extends EventEmitter__default.default {
9136
9280
  waitFor(subtaskId, timeoutMs = 12e4) {
9137
9281
  const existing = this.outputs.get(subtaskId);
9138
9282
  if (existing && !this.retryPending.has(subtaskId)) return Promise.resolve(existing);
9139
- return new Promise((resolve, reject) => {
9283
+ return new Promise((resolve2, reject) => {
9140
9284
  const resolver = (output) => {
9141
9285
  clearTimeout(timer);
9142
- resolve(output);
9286
+ resolve2(output);
9143
9287
  };
9144
9288
  const timer = setTimeout(() => {
9145
9289
  const waiting = this.waiters.get(subtaskId);
@@ -9193,10 +9337,10 @@ var PeerBus = class extends EventEmitter__default.default {
9193
9337
  */
9194
9338
  collect(timeoutMs) {
9195
9339
  const collected = [...this.broadcastLog];
9196
- return new Promise((resolve) => {
9340
+ return new Promise((resolve2) => {
9197
9341
  const timer = setTimeout(() => {
9198
9342
  off();
9199
- resolve(collected);
9343
+ resolve2(collected);
9200
9344
  }, timeoutMs);
9201
9345
  const handler = (msg) => {
9202
9346
  collected.push({ fromId: msg.fromId, payload: msg.payload, timestamp: msg.timestamp });
@@ -9210,7 +9354,7 @@ var PeerBus = class extends EventEmitter__default.default {
9210
9354
  const broadcasters = new Set(collected.map((m) => m.fromId));
9211
9355
  if (broadcasters.size >= this.members.size) {
9212
9356
  off();
9213
- resolve(collected);
9357
+ resolve2(collected);
9214
9358
  }
9215
9359
  };
9216
9360
  this.on("broadcast", checkComplete);
@@ -9228,7 +9372,7 @@ var PeerBus = class extends EventEmitter__default.default {
9228
9372
  return;
9229
9373
  }
9230
9374
  this.emitCoordination(tierId, `\u23F3 waiting for ${filePath} (held by ${existing.holderId})`);
9231
- return new Promise((resolve, reject) => {
9375
+ return new Promise((resolve2, reject) => {
9232
9376
  const timer = setTimeout(() => {
9233
9377
  reject(new Error(`File lock timeout for ${filePath} (held by ${existing.holderId})`));
9234
9378
  }, timeoutMs);
@@ -9236,7 +9380,7 @@ var PeerBus = class extends EventEmitter__default.default {
9236
9380
  clearTimeout(timer);
9237
9381
  this.fileLocks.set(filePath, { holderId: tierId, lockedAt: (/* @__PURE__ */ new Date()).toISOString(), waiters: [] });
9238
9382
  this.emitCoordination(tierId, `\u{1F512} locked ${filePath}`);
9239
- resolve();
9383
+ resolve2();
9240
9384
  });
9241
9385
  });
9242
9386
  }
@@ -9261,11 +9405,11 @@ var PeerBus = class extends EventEmitter__default.default {
9261
9405
  */
9262
9406
  waitForFileRelease(filePath, timeoutMs = 3e4) {
9263
9407
  if (!this.fileLocks.has(filePath)) return Promise.resolve();
9264
- return new Promise((resolve, reject) => {
9408
+ return new Promise((resolve2, reject) => {
9265
9409
  const timer = setTimeout(() => reject(new Error(`Timeout waiting for file release: ${filePath}`)), timeoutMs);
9266
9410
  this.once(`file:released:${filePath}`, () => {
9267
9411
  clearTimeout(timer);
9268
- resolve();
9412
+ resolve2();
9269
9413
  });
9270
9414
  });
9271
9415
  }
@@ -9331,13 +9475,13 @@ var PeerBus = class extends EventEmitter__default.default {
9331
9475
  this.emit(`barrier:${barrierName}`);
9332
9476
  return;
9333
9477
  }
9334
- return new Promise((resolve, reject) => {
9478
+ return new Promise((resolve2, reject) => {
9335
9479
  const timer = setTimeout(() => {
9336
9480
  reject(new Error(`Barrier timeout: ${barrierName} (${bar.arrived.size}/${bar.total} arrived)`));
9337
9481
  }, 12e4);
9338
9482
  this.once(`barrier:${barrierName}`, () => {
9339
9483
  clearTimeout(timer);
9340
- resolve();
9484
+ resolve2();
9341
9485
  });
9342
9486
  });
9343
9487
  }
@@ -9404,6 +9548,7 @@ var T2Manager = class extends BaseTier {
9404
9548
  escalations = [];
9405
9549
  peerSyncBuffer = [];
9406
9550
  store;
9551
+ runBreaker;
9407
9552
  t3PeerBus = new PeerBus();
9408
9553
  // ← T3↔T3 bus (local to this T2)
9409
9554
  t2PeerBus;
@@ -9434,6 +9579,15 @@ var T2Manager = class extends BaseTier {
9434
9579
  this.router = router;
9435
9580
  this.toolRegistry = toolRegistry;
9436
9581
  }
9582
+ /**
9583
+ * Share the run-wide circuit breaker. Every T2 in a run gets the SAME
9584
+ * instance: three sections each failing once against a dead model is the
9585
+ * same fact as one section failing three times, and a per-section breaker
9586
+ * would never see it.
9587
+ */
9588
+ setRunBreaker(breaker) {
9589
+ this.runBreaker = breaker;
9590
+ }
9437
9591
  setStore(store) {
9438
9592
  this.store = store;
9439
9593
  }
@@ -9557,7 +9711,8 @@ Guidance (must be followed): ${decision.note}`
9557
9711
  const overallStatus = this.determineStatus(t3Results);
9558
9712
  const isOk = overallStatus === "COMPLETED" || overallStatus === "PARTIAL";
9559
9713
  this.setStatus(isOk ? "COMPLETED" : "FAILED", summary);
9560
- this.sendStatusUpdate({ progressPct: 100, currentAction: "Section complete", status: "IN_PROGRESS", output: summary });
9714
+ const sectionAction = overallStatus === "COMPLETED" ? "Section complete" : overallStatus === "PARTIAL" ? "Section partially complete \u2014 some subtasks failed" : overallStatus === "ESCALATED" ? "Section escalated \u2014 needs a decision" : "Section failed \u2014 no subtask completed";
9715
+ this.sendStatusUpdate({ progressPct: 100, currentAction: sectionAction, status: "IN_PROGRESS", output: summary });
9561
9716
  const result = {
9562
9717
  sectionId: assignment.sectionId,
9563
9718
  sectionTitle: assignment.sectionTitle,
@@ -9727,6 +9882,24 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
9727
9882
  const reinforceCfg = this.router.getReinforcementsConfig?.() ?? { enabled: false, maxPerSection: 4 };
9728
9883
  let reinforcementsAdded = 0;
9729
9884
  while (remaining.size > 0) {
9885
+ if (this.runBreaker?.isOpen()) {
9886
+ const why = this.runBreaker.skipMessage();
9887
+ this.log(`Run breaker open \u2014 skipping ${remaining.size} remaining subtask(s)`);
9888
+ for (const id of remaining) {
9889
+ this.t3PeerBus.publish(this.id, id, why, "FAILED");
9890
+ resultMap.set(id, {
9891
+ subtaskId: id,
9892
+ status: "FAILED",
9893
+ output: why,
9894
+ testResults: { checksRun: [], passed: [], failed: [] },
9895
+ issues: [why],
9896
+ peerSyncsUsed: [],
9897
+ correctionAttempts: 0
9898
+ });
9899
+ }
9900
+ remaining.clear();
9901
+ break;
9902
+ }
9730
9903
  const runnableIds = [...remaining].filter((id) => (inDegree.get(id) ?? 0) === 0);
9731
9904
  if (runnableIds.length === 0) {
9732
9905
  const fallbackId = [...remaining].sort(
@@ -9829,8 +10002,32 @@ HIERARCHY CONTEXT: ${this.hierarchyContext}` : ""),
9829
10002
  remaining.delete(id);
9830
10003
  const r = waveResults[i];
9831
10004
  if (r.status === "rejected") {
9832
- this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
9833
10005
  const assignment = sanitizedAssignments.find((a) => a.subtaskId === id);
10006
+ const failedModel = workerMap.get(id)?.getServingModel();
10007
+ const tripped = this.runBreaker?.record(r.reason, failedModel);
10008
+ if (tripped) {
10009
+ this.log(`Run breaker opened: ${tripped.failures} consecutive ${tripped.kind} failures on ${tripped.modelId} \u2014 stopping`);
10010
+ this.emit("run:breaker", tripped);
10011
+ }
10012
+ if (this.runBreaker?.isOpen()) {
10013
+ const why = this.runBreaker.skipMessage();
10014
+ this.log(`T3 worker ${id} failed and the run breaker is open \u2014 not retrying`);
10015
+ this.t3PeerBus.publish(this.id, id, why, "FAILED");
10016
+ resultMap.set(id, {
10017
+ subtaskId: id,
10018
+ status: "FAILED",
10019
+ output: why,
10020
+ testResults: { checksRun: [], passed: [], failed: [] },
10021
+ issues: [why],
10022
+ peerSyncsUsed: [],
10023
+ correctionAttempts: 0
10024
+ });
10025
+ for (const dependent of adj.get(id) ?? []) {
10026
+ inDegree.set(dependent, Math.max(0, (inDegree.get(dependent) ?? 0) - 1));
10027
+ }
10028
+ continue;
10029
+ }
10030
+ this.log(`T3 worker ${id} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)} \u2014 retrying once`);
9834
10031
  try {
9835
10032
  const retried = await this.retryT3(assignment, taskId);
9836
10033
  resultMap.set(id, retried);
@@ -10165,6 +10362,7 @@ var T1Administrator = class extends BaseTier {
10165
10362
  t2Managers = /* @__PURE__ */ new Map();
10166
10363
  escalations = [];
10167
10364
  store;
10365
+ runBreaker;
10168
10366
  t2PeerBus = new PeerBus();
10169
10367
  permissionEscalator;
10170
10368
  toolCreator;
@@ -10179,6 +10377,10 @@ var T1Administrator = class extends BaseTier {
10179
10377
  this.toolRegistry = toolRegistry;
10180
10378
  this.config = config;
10181
10379
  }
10380
+ /** Share the run-wide circuit breaker with every section manager. */
10381
+ setRunBreaker(breaker) {
10382
+ this.runBreaker = breaker;
10383
+ }
10182
10384
  setStore(store) {
10183
10385
  this.store = store;
10184
10386
  }
@@ -10541,6 +10743,7 @@ SPEC RULES \u2014 each subtask is a self-contained spec slice (workers execute f
10541
10743
  };
10542
10744
  const managers = sections.map((section) => {
10543
10745
  const manager = new T2Manager(this.router, this.toolRegistry, this.id);
10746
+ if (this.runBreaker) manager.setRunBreaker(this.runBreaker);
10544
10747
  manager.setHierarchyContext(`You are a T2 Manager for the section "${section.sectionTitle}". You are part of a COMPLEX task overseen by T1 Administrator.`);
10545
10748
  if (this.store) {
10546
10749
  manager.setStore(this.store);
@@ -10829,8 +11032,156 @@ Reply with exactly one word: YES, NO, or UNSURE.
10829
11032
  // src/core/cascade.ts
10830
11033
  init_cost();
10831
11034
 
10832
- // src/tools/registry.ts
10833
- init_constants();
11035
+ // src/core/multimodal/registry.ts
11036
+ init_pricing();
11037
+ var CAPABILITIES = [
11038
+ // ── Image ──
11039
+ {
11040
+ modality: "image",
11041
+ provider: "openai",
11042
+ modelId: "dall-e-3",
11043
+ api: "openai-images"
11044
+ },
11045
+ {
11046
+ modality: "image",
11047
+ provider: "gemini",
11048
+ modelId: "gemini-3-pro-image",
11049
+ api: "gemini-predict"
11050
+ },
11051
+ // ── Speech (text → audio) ──
11052
+ {
11053
+ modality: "speech",
11054
+ provider: "openai",
11055
+ modelId: "tts-1",
11056
+ api: "openai-speech"
11057
+ },
11058
+ {
11059
+ // The only quality claim in this table that needs no leaderboard: OpenAI
11060
+ // ships these as the same voice at two fidelities and charges double for
11061
+ // this one. That is the vendor's own ordering, not an inference.
11062
+ modality: "speech",
11063
+ provider: "openai",
11064
+ modelId: "tts-1-hd",
11065
+ api: "openai-speech",
11066
+ quality: 70,
11067
+ qualityBasis: "OpenAI's own hd variant of tts-1, priced 2x"
11068
+ },
11069
+ // ── Transcription (audio → text) ──
11070
+ {
11071
+ modality: "transcription",
11072
+ provider: "openai",
11073
+ modelId: "whisper-1",
11074
+ api: "openai-transcriptions"
11075
+ },
11076
+ // ── Video ──
11077
+ {
11078
+ // Listed so `describe()` can tell the truth about what exists, and so the
11079
+ // planner is told video is out of reach rather than left to invent a tool.
11080
+ // Veo is a long-running operation: submit, poll, then fetch bytes from a
11081
+ // signed URL. That is a different execution shape from every other entry
11082
+ // here and is not wired up — claiming otherwise would produce a tool that
11083
+ // fails at call time instead of an honest "not yet".
11084
+ modality: "video",
11085
+ provider: "gemini",
11086
+ modelId: "veo-3.1-generate-001",
11087
+ api: "gemini-predict",
11088
+ unsupported: "Veo runs as a long-running operation (submit \u2192 poll \u2192 fetch). Not wired up yet."
11089
+ }
11090
+ ];
11091
+ function capabilityCost(cap) {
11092
+ const match = findPricing(cap.modelId, cap.provider, {
11093
+ fallbackProviders: cap.provider === "gemini" ? ["vertex"] : []
11094
+ });
11095
+ const rate = match?.entry.rates?.[0];
11096
+ if (!rate) return null;
11097
+ const unitLabel = {
11098
+ per_image: "per image",
11099
+ per_second: "per second of video",
11100
+ per_character: "per character",
11101
+ per_audio_minute: "per audio minute"
11102
+ };
11103
+ const unit = unitLabel[rate.unit] ?? rate.unit;
11104
+ const amount = rate.amount;
11105
+ const shown = amount < 1e-3 ? amount.toExponential(2) : amount.toFixed(4);
11106
+ return { label: `$${shown} ${unit}`, amount, unit };
11107
+ }
11108
+ var MultimodalRegistry = class {
11109
+ /** Provider types the user has actually configured a key for. */
11110
+ available;
11111
+ constructor(availableProviders) {
11112
+ this.available = new Set(availableProviders);
11113
+ }
11114
+ /** Every capability whose provider is configured AND which Cascade can call. */
11115
+ usable(modality) {
11116
+ return CAPABILITIES.filter(
11117
+ (c) => !c.unsupported && this.available.has(c.provider) && (!modality || c.modality === modality)
11118
+ );
11119
+ }
11120
+ /** Modalities this account can actually generate right now. */
11121
+ availableModalities() {
11122
+ return [...new Set(this.usable().map((c) => c.modality))];
11123
+ }
11124
+ /**
11125
+ * Pick a model for a modality.
11126
+ *
11127
+ * Ranked models beat unranked ones, then higher quality, then lower unit
11128
+ * price. An explicit `preferModel` wins outright when it is usable — the user
11129
+ * naming a model is a stronger signal than any prior.
11130
+ */
11131
+ select(modality, opts = {}) {
11132
+ const pool = this.usable(modality);
11133
+ if (!pool.length) return null;
11134
+ if (opts.preferModel) {
11135
+ const wanted = pool.find((c) => c.modelId === opts.preferModel);
11136
+ if (wanted) {
11137
+ return { capability: wanted, cost: capabilityCost(wanted), reason: `Requested explicitly: ${wanted.modelId}.` };
11138
+ }
11139
+ }
11140
+ const scored = pool.map((c) => ({ c, cost: capabilityCost(c) })).sort((a, b) => {
11141
+ const qa = a.c.quality ?? -1;
11142
+ const qb = b.c.quality ?? -1;
11143
+ if (qa !== qb) return qb - qa;
11144
+ const pa = a.cost?.amount ?? Number.POSITIVE_INFINITY;
11145
+ const pb = b.cost?.amount ?? Number.POSITIVE_INFINITY;
11146
+ return pa - pb;
11147
+ });
11148
+ const best = scored[0];
11149
+ const ranked = best.c.quality !== void 0;
11150
+ const reason = ranked ? `${best.c.modelId} (${best.c.provider}) \u2014 ranked highest for ${modality}: ${best.c.qualityBasis}.` + (best.cost ? ` ${best.cost.label}.` : "") : `${best.c.modelId} (${best.c.provider}) \u2014 cheapest usable ${modality} model` + (best.cost ? ` at ${best.cost.label}` : "") + ". No published quality ranking is applied for this modality, so this is a cost choice, not a quality one.";
11151
+ return { capability: best.c, cost: best.cost, reason };
11152
+ }
11153
+ /**
11154
+ * A plain-language account of what this account can and cannot generate —
11155
+ * including the things it cannot, and why. The planner sees this, so being
11156
+ * candid here is what stops it inventing a `generate_music` call.
11157
+ */
11158
+ describe() {
11159
+ const lines = [];
11160
+ const usable = this.usable();
11161
+ if (usable.length) {
11162
+ lines.push("Generation models available on your configured providers:");
11163
+ for (const modality of ["image", "speech", "transcription", "video"]) {
11164
+ const forModality = usable.filter((c) => c.modality === modality);
11165
+ if (!forModality.length) continue;
11166
+ const shown = forModality.map((c) => {
11167
+ const cost = capabilityCost(c);
11168
+ return `${c.modelId}${cost ? ` (${cost.label})` : ""}`;
11169
+ });
11170
+ lines.push(` ${modality}: ${shown.join(", ")}`);
11171
+ }
11172
+ } else {
11173
+ lines.push("No generation models are available \u2014 none of your configured providers offers one.");
11174
+ }
11175
+ const blocked = CAPABILITIES.filter((c) => c.unsupported && this.available.has(c.provider));
11176
+ for (const c of blocked) {
11177
+ lines.push(` ${c.modality}: ${c.modelId} exists on your account but is not callable. ${c.unsupported}`);
11178
+ }
11179
+ lines.push(
11180
+ " music: no supported provider exposes a music-generation API, so Cascade cannot generate music."
11181
+ );
11182
+ return lines.join("\n");
11183
+ }
11184
+ };
10834
11185
 
10835
11186
  // src/tools/base.ts
10836
11187
  var BaseTool = class {
@@ -10850,7 +11201,449 @@ var BaseTool = class {
10850
11201
  }
10851
11202
  };
10852
11203
 
10853
- // src/tools/shell.ts
11204
+ // src/core/router/provider-errors.ts
11205
+ var SYSTEMIC = /* @__PURE__ */ new Set([
11206
+ "rate_limit",
11207
+ "quota_exhausted",
11208
+ "auth",
11209
+ "model_unavailable"
11210
+ ]);
11211
+ function isSystemicKind(kind) {
11212
+ return SYSTEMIC.has(kind);
11213
+ }
11214
+ function statusOf(err) {
11215
+ if (!err || typeof err !== "object") return void 0;
11216
+ const e = err;
11217
+ for (const key of ["status", "statusCode", "code"]) {
11218
+ const v = e[key];
11219
+ if (typeof v === "number" && v >= 100 && v < 600) return v;
11220
+ }
11221
+ const resp = e["response"];
11222
+ if (resp && typeof resp["status"] === "number") return resp["status"];
11223
+ const inner = e["error"];
11224
+ if (inner && typeof inner["code"] === "number") return inner["code"];
11225
+ return void 0;
11226
+ }
11227
+ function messageOf(err) {
11228
+ if (err instanceof Error) return err.message;
11229
+ if (typeof err === "string") return err;
11230
+ if (err && typeof err === "object") {
11231
+ const e = err;
11232
+ if (typeof e["message"] === "string") return e["message"];
11233
+ const inner = e["error"];
11234
+ if (inner && typeof inner["message"] === "string") return inner["message"];
11235
+ try {
11236
+ return JSON.stringify(err);
11237
+ } catch {
11238
+ }
11239
+ }
11240
+ return String(err);
11241
+ }
11242
+ function classifyProviderError(err) {
11243
+ const raw = messageOf(err).trim();
11244
+ const status = statusOf(err);
11245
+ const m = raw.toLowerCase();
11246
+ const kind = (() => {
11247
+ if (status === 429) {
11248
+ return /quota|billing|credit|exceeded your current quota|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
11249
+ }
11250
+ if (status === 401 || status === 403) return "auth";
11251
+ if (status === 404) return "model_unavailable";
11252
+ if (/rate limit|too many requests|resource_exhausted/.test(m)) {
11253
+ return /quota|billing|credit|insufficient/.test(m) ? "quota_exhausted" : "rate_limit";
11254
+ }
11255
+ if (/quota|billing|insufficient_quota|insufficient credit|payment required/.test(m)) return "quota_exhausted";
11256
+ if (/api key|unauthorized|authentication|permission denied|invalid[_ ]api/.test(m)) return "auth";
11257
+ if (/model .*(not found|does not exist|unavailable)|no such model|unknown model|not supported for/.test(m)) {
11258
+ return "model_unavailable";
11259
+ }
11260
+ if (/context length|too many tokens|maximum context|prompt is too long|input too long/.test(m)) return "context_length";
11261
+ if (/safety|content filter|blocked|recitation|prohibited/.test(m)) return "content_filter";
11262
+ if (/econnreset|etimedout|enotfound|socket hang up|network|fetch failed|aborted/.test(m)) return "network";
11263
+ return "unknown";
11264
+ })();
11265
+ return { kind, systemic: isSystemicKind(kind), status, raw };
11266
+ }
11267
+ function describeProviderError(c, modelId) {
11268
+ const on = modelId ? ` on ${modelId}` : "";
11269
+ const detail = c.raw ? ` Provider said: ${c.raw}` : "";
11270
+ switch (c.kind) {
11271
+ case "rate_limit":
11272
+ return `Rate limit hit${on}. The provider is throttling requests \u2014 lower the number of parallel workers, or use a model with a higher requests-per-minute allowance.${detail}`;
11273
+ case "quota_exhausted":
11274
+ return `Quota or billing limit reached${on}. This will not recover on its own \u2014 check the provider's billing page, or switch this tier to a different provider.${detail}`;
11275
+ case "auth":
11276
+ return `Authentication failed${on}. The API key is missing, expired, or lacks access to this model \u2014 re-check the key in Settings > Providers.${detail}`;
11277
+ case "model_unavailable":
11278
+ return `Model unavailable${on}. The id may be wrong, retired, or not enabled for this API key \u2014 pick a different model for this tier.${detail}`;
11279
+ case "context_length":
11280
+ return `The prompt was too long${on}. Reduce the attached context, or use a model with a larger context window for this tier.${detail}`;
11281
+ case "content_filter":
11282
+ return `The provider's safety filter refused this request${on}.${detail}`;
11283
+ case "network":
11284
+ return `Network error reaching the provider${on}. This is usually transient.${detail}`;
11285
+ default:
11286
+ return `The model call failed${on}.${detail}`;
11287
+ }
11288
+ }
11289
+
11290
+ // src/core/multimodal/generate.ts
11291
+ var DEFAULT_TIMEOUT_MS = 12e4;
11292
+ async function callProvider(fn, modelId) {
11293
+ try {
11294
+ return await fn();
11295
+ } catch (err) {
11296
+ const classified = classifyProviderError(err);
11297
+ throw new Error(describeProviderError(classified, modelId));
11298
+ }
11299
+ }
11300
+ async function postJson(url, body, headers, signal) {
11301
+ const ac = new AbortController();
11302
+ const timer = setTimeout(() => ac.abort(), DEFAULT_TIMEOUT_MS);
11303
+ const onAbort = () => ac.abort();
11304
+ signal?.addEventListener("abort", onAbort);
11305
+ try {
11306
+ const res = await fetch(url, {
11307
+ method: "POST",
11308
+ headers: { "Content-Type": "application/json", ...headers },
11309
+ body: JSON.stringify(body),
11310
+ signal: ac.signal
11311
+ });
11312
+ if (!res.ok) {
11313
+ const text = await res.text().catch(() => "");
11314
+ throw Object.assign(new Error(text || res.statusText), { status: res.status });
11315
+ }
11316
+ return res;
11317
+ } finally {
11318
+ clearTimeout(timer);
11319
+ signal?.removeEventListener("abort", onAbort);
11320
+ }
11321
+ }
11322
+ function baseUrlFor(cfg, fallback) {
11323
+ return (cfg.baseUrl ?? fallback).replace(/\/+$/, "");
11324
+ }
11325
+ function stamp(ext) {
11326
+ return `cascade-${Date.now()}.${ext}`;
11327
+ }
11328
+ async function generateImage(cap, cfg, req, signal) {
11329
+ return callProvider(async () => {
11330
+ if (cap.api === "openai-images") {
11331
+ const url = `${baseUrlFor(cfg, "https://api.openai.com/v1")}/images/generations`;
11332
+ const res = await postJson(url, {
11333
+ model: cap.modelId,
11334
+ prompt: req.prompt,
11335
+ size: req.size ?? "1024x1024",
11336
+ n: 1,
11337
+ // b64 rather than a URL: see the file header — provider URLs expire.
11338
+ response_format: "b64_json"
11339
+ }, { Authorization: `Bearer ${cfg.apiKey ?? ""}` }, signal);
11340
+ const body = await res.json();
11341
+ const first = body.data?.[0];
11342
+ if (!first?.b64_json) throw new Error("Image response contained no image data.");
11343
+ return {
11344
+ data: Buffer.from(first.b64_json, "base64"),
11345
+ mimeType: "image/png",
11346
+ filename: stamp("png"),
11347
+ modelId: cap.modelId,
11348
+ provider: cap.provider,
11349
+ ...first.revised_prompt ? { revisedPrompt: first.revised_prompt } : {}
11350
+ };
11351
+ }
11352
+ if (cap.api === "gemini-predict") {
11353
+ const base = baseUrlFor(cfg, "https://generativelanguage.googleapis.com/v1beta");
11354
+ const url = `${base}/models/${encodeURIComponent(cap.modelId)}:predict`;
11355
+ const res = await postJson(url, {
11356
+ instances: [{ prompt: req.prompt }],
11357
+ parameters: { sampleCount: 1 }
11358
+ }, { "x-goog-api-key": cfg.apiKey ?? "" }, signal);
11359
+ const body = await res.json();
11360
+ const first = body.predictions?.[0];
11361
+ if (!first?.bytesBase64Encoded) throw new Error("Image response contained no image data.");
11362
+ const mimeType = first.mimeType ?? "image/png";
11363
+ return {
11364
+ data: Buffer.from(first.bytesBase64Encoded, "base64"),
11365
+ mimeType,
11366
+ filename: stamp(mimeType.includes("jpeg") ? "jpg" : "png"),
11367
+ modelId: cap.modelId,
11368
+ provider: cap.provider
11369
+ };
11370
+ }
11371
+ throw new Error(`${cap.modelId} is not an image generator.`);
11372
+ }, cap.modelId);
11373
+ }
11374
+ async function generateSpeech(cap, cfg, req, signal) {
11375
+ return callProvider(async () => {
11376
+ if (cap.api !== "openai-speech") throw new Error(`${cap.modelId} is not a speech model.`);
11377
+ const format = req.format ?? "mp3";
11378
+ const url = `${baseUrlFor(cfg, "https://api.openai.com/v1")}/audio/speech`;
11379
+ const res = await postJson(url, {
11380
+ model: cap.modelId,
11381
+ input: req.text,
11382
+ voice: req.voice ?? "alloy",
11383
+ response_format: format
11384
+ }, { Authorization: `Bearer ${cfg.apiKey ?? ""}` }, signal);
11385
+ const mime = {
11386
+ mp3: "audio/mpeg",
11387
+ opus: "audio/opus",
11388
+ aac: "audio/aac",
11389
+ flac: "audio/flac",
11390
+ wav: "audio/wav"
11391
+ };
11392
+ return {
11393
+ data: Buffer.from(await res.arrayBuffer()),
11394
+ mimeType: mime[format] ?? "application/octet-stream",
11395
+ filename: stamp(format),
11396
+ modelId: cap.modelId,
11397
+ provider: cap.provider
11398
+ };
11399
+ }, cap.modelId);
11400
+ }
11401
+ async function transcribeAudio(cap, cfg, req, signal) {
11402
+ return callProvider(async () => {
11403
+ if (cap.api !== "openai-transcriptions") throw new Error(`${cap.modelId} is not a transcription model.`);
11404
+ const url = `${baseUrlFor(cfg, "https://api.openai.com/v1")}/audio/transcriptions`;
11405
+ const form = new FormData();
11406
+ form.append("model", cap.modelId);
11407
+ if (req.language) form.append("language", req.language);
11408
+ form.append("file", new Blob([new Uint8Array(req.audio)]), req.filename);
11409
+ const ac = new AbortController();
11410
+ const timer = setTimeout(() => ac.abort(), DEFAULT_TIMEOUT_MS);
11411
+ const onAbort = () => ac.abort();
11412
+ signal?.addEventListener("abort", onAbort);
11413
+ try {
11414
+ const res = await fetch(url, {
11415
+ method: "POST",
11416
+ headers: { Authorization: `Bearer ${cfg.apiKey ?? ""}` },
11417
+ body: form,
11418
+ signal: ac.signal
11419
+ });
11420
+ if (!res.ok) {
11421
+ const text = await res.text().catch(() => "");
11422
+ throw Object.assign(new Error(text || res.statusText), { status: res.status });
11423
+ }
11424
+ const body = await res.json();
11425
+ return { text: body.text ?? "", modelId: cap.modelId };
11426
+ } finally {
11427
+ clearTimeout(timer);
11428
+ signal?.removeEventListener("abort", onAbort);
11429
+ }
11430
+ }, cap.modelId);
11431
+ }
11432
+
11433
+ // src/tools/generate-media.ts
11434
+ function resolve(deps, modality, preferModel) {
11435
+ const selected = deps.registry.select(modality, preferModel ? { preferModel } : {});
11436
+ if (!selected) {
11437
+ return { ok: false, error: `No ${modality} model is available on your configured providers.` };
11438
+ }
11439
+ const cfg = deps.lookupProvider(selected.capability.provider);
11440
+ if (!cfg) {
11441
+ return {
11442
+ ok: false,
11443
+ error: `${selected.capability.modelId} needs the ${selected.capability.provider} provider, which is not configured.`
11444
+ };
11445
+ }
11446
+ return { ok: true, selected, cfg };
11447
+ }
11448
+ var GenerateImageTool = class extends BaseTool {
11449
+ constructor(deps) {
11450
+ super();
11451
+ this.deps = deps;
11452
+ }
11453
+ deps;
11454
+ name = "generate_image";
11455
+ description = "Generate an image from a text description using the best available image model. Returns where the image was saved. Use when the user asks for a picture, illustration, diagram render, logo or any visual asset.";
11456
+ inputSchema = {
11457
+ type: "object",
11458
+ properties: {
11459
+ prompt: { type: "string", description: "What the image should show. Be specific about subject, style and composition." },
11460
+ size: { type: "string", description: 'Pixel size, e.g. "1024x1024" (default), "1792x1024", "1024x1792".' },
11461
+ model: { type: "string", description: "Optional: force a specific image model instead of the automatic choice." }
11462
+ },
11463
+ required: ["prompt"]
11464
+ };
11465
+ async execute(input, options) {
11466
+ const prompt = String(input["prompt"] ?? "").trim();
11467
+ if (!prompt) return 'Provide a "prompt" describing the image.';
11468
+ const r = resolve(this.deps, "image", input["model"] ? String(input["model"]) : void 0);
11469
+ if (!r.ok) return r.error;
11470
+ const size = input["size"] ? String(input["size"]) : void 0;
11471
+ const asset = await generateImage(
11472
+ r.selected.capability,
11473
+ r.cfg,
11474
+ { prompt, ...size ? { size } : {} },
11475
+ options.signal
11476
+ );
11477
+ const location = await this.deps.sink(asset);
11478
+ return [
11479
+ `Image generated and saved to ${location}.`,
11480
+ `Model: ${asset.modelId} \u2014 ${r.selected.reason}`,
11481
+ // DALL·E rewrites the prompt before drawing; the model needs to know what
11482
+ // was actually rendered, not what it asked for, or its description of the
11483
+ // result will not match the image.
11484
+ asset.revisedPrompt ? `The provider rewrote the prompt to: ${asset.revisedPrompt}` : ""
11485
+ ].filter(Boolean).join("\n");
11486
+ }
11487
+ };
11488
+ var GenerateSpeechTool = class extends BaseTool {
11489
+ constructor(deps) {
11490
+ super();
11491
+ this.deps = deps;
11492
+ }
11493
+ deps;
11494
+ name = "generate_speech";
11495
+ description = "Turn text into spoken audio using the best available text-to-speech model. Returns where the audio was saved. Use when the user asks for narration, a voiceover or an audio version of some text.";
11496
+ inputSchema = {
11497
+ type: "object",
11498
+ properties: {
11499
+ text: { type: "string", description: "The text to speak." },
11500
+ voice: { type: "string", description: "Voice id: alloy, echo, fable, onyx, nova, shimmer. Default alloy." },
11501
+ format: { type: "string", description: "Audio format: mp3 (default), opus, aac, flac, wav." },
11502
+ model: { type: "string", description: "Optional: force a specific speech model." }
11503
+ },
11504
+ required: ["text"]
11505
+ };
11506
+ async execute(input, options) {
11507
+ const text = String(input["text"] ?? "").trim();
11508
+ if (!text) return 'Provide "text" to speak.';
11509
+ const r = resolve(this.deps, "speech", input["model"] ? String(input["model"]) : void 0);
11510
+ if (!r.ok) return r.error;
11511
+ const fmt = String(input["format"] ?? "mp3");
11512
+ const allowed = ["mp3", "opus", "aac", "flac", "wav"];
11513
+ const format = allowed.includes(fmt) ? fmt : "mp3";
11514
+ const asset = await generateSpeech(
11515
+ r.selected.capability,
11516
+ r.cfg,
11517
+ { text, voice: input["voice"] ? String(input["voice"]) : void 0, format },
11518
+ options.signal
11519
+ );
11520
+ const location = await this.deps.sink(asset);
11521
+ return `Speech generated and saved to ${location}.
11522
+ Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
11523
+ }
11524
+ };
11525
+ var TranscribeAudioTool = class extends BaseTool {
11526
+ constructor(deps, readFile) {
11527
+ super();
11528
+ this.deps = deps;
11529
+ this.readFile = readFile;
11530
+ }
11531
+ deps;
11532
+ readFile;
11533
+ name = "transcribe_audio";
11534
+ description = "Transcribe a local audio file to text using the best available speech-to-text model. Use when the user supplies audio and wants its contents in writing.";
11535
+ inputSchema = {
11536
+ type: "object",
11537
+ properties: {
11538
+ path: { type: "string", description: "Path to the audio file to transcribe." },
11539
+ language: { type: "string", description: 'Optional ISO-639-1 language hint, e.g. "en", improves accuracy.' },
11540
+ model: { type: "string", description: "Optional: force a specific transcription model." }
11541
+ },
11542
+ required: ["path"]
11543
+ };
11544
+ async execute(input, options) {
11545
+ const filePath = String(input["path"] ?? "").trim();
11546
+ if (!filePath) return 'Provide a "path" to the audio file.';
11547
+ const r = resolve(this.deps, "transcription", input["model"] ? String(input["model"]) : void 0);
11548
+ if (!r.ok) return r.error;
11549
+ let audio;
11550
+ try {
11551
+ audio = await this.readFile(filePath);
11552
+ } catch {
11553
+ return `Could not read the audio file at ${filePath}.`;
11554
+ }
11555
+ const { text, modelId } = await transcribeAudio(
11556
+ r.selected.capability,
11557
+ r.cfg,
11558
+ {
11559
+ audio,
11560
+ filename: filePath.split(/[\\/]/).pop() || "audio",
11561
+ ...input["language"] ? { language: String(input["language"]) } : {}
11562
+ },
11563
+ options.signal
11564
+ );
11565
+ return text ? `Transcript (${modelId}):
11566
+
11567
+ ${text}` : `${modelId} returned an empty transcript \u2014 the file may be silent or in an unsupported format.`;
11568
+ }
11569
+ };
11570
+ function buildMediaTools(deps, readFile) {
11571
+ const tools = [];
11572
+ const modalities = new Set(deps.registry.availableModalities());
11573
+ if (modalities.has("image")) tools.push(new GenerateImageTool(deps));
11574
+ if (modalities.has("speech")) tools.push(new GenerateSpeechTool(deps));
11575
+ if (modalities.has("transcription") && readFile) tools.push(new TranscribeAudioTool(deps, readFile));
11576
+ return tools;
11577
+ }
11578
+
11579
+ // src/core/run-breaker.ts
11580
+ var DEFAULT_FAILURE_THRESHOLD = 3;
11581
+ var RunBreaker = class {
11582
+ threshold;
11583
+ /** modelId → consecutive systemic failures of the SAME kind. */
11584
+ counts = /* @__PURE__ */ new Map();
11585
+ trip = null;
11586
+ constructor(threshold = DEFAULT_FAILURE_THRESHOLD) {
11587
+ this.threshold = Math.max(1, threshold);
11588
+ }
11589
+ /**
11590
+ * Record a failed model call. Returns the trip if this failure is the one
11591
+ * that opened the breaker, otherwise null.
11592
+ *
11593
+ * A non-systemic failure RESETS the model's counter: it is evidence the model
11594
+ * is reachable and answering, which is exactly what a systemic streak claims
11595
+ * it is not.
11596
+ */
11597
+ record(err, modelId) {
11598
+ if (this.trip) return null;
11599
+ const id = modelId?.trim() || "unknown-model";
11600
+ const c = classifyProviderError(err);
11601
+ if (!c.systemic) {
11602
+ this.counts.delete(id);
11603
+ return null;
11604
+ }
11605
+ const prev = this.counts.get(id);
11606
+ const n = prev && prev.kind === c.kind ? prev.n + 1 : 1;
11607
+ this.counts.set(id, { kind: c.kind, n, last: c });
11608
+ if (n >= this.threshold) {
11609
+ this.trip = {
11610
+ modelId: id,
11611
+ kind: c.kind,
11612
+ failures: n,
11613
+ message: describeProviderError(c, id)
11614
+ };
11615
+ return this.trip;
11616
+ }
11617
+ return null;
11618
+ }
11619
+ /** True once the run should stop launching work that would fail identically. */
11620
+ isOpen() {
11621
+ return this.trip !== null;
11622
+ }
11623
+ /** Why the breaker opened, or null while it is closed. */
11624
+ reason() {
11625
+ return this.trip;
11626
+ }
11627
+ /**
11628
+ * One line for a worker/section that never ran because the breaker was
11629
+ * already open. Says the work was skipped deliberately — not that it failed
11630
+ * on its own merits, which is what an empty FAILED status would imply.
11631
+ */
11632
+ skipMessage() {
11633
+ const t = this.trip;
11634
+ if (!t) return "Skipped.";
11635
+ return `Skipped \u2014 the run stopped after ${t.failures} consecutive failures on ${t.modelId}. ${t.message}`;
11636
+ }
11637
+ /** Test/diagnostic view of the per-model streaks. */
11638
+ streaks() {
11639
+ const out = {};
11640
+ for (const [k, v] of this.counts) out[k] = { kind: v.kind, n: v.n };
11641
+ return out;
11642
+ }
11643
+ };
11644
+
11645
+ // src/tools/registry.ts
11646
+ init_constants();
10854
11647
  var execAsync = util.promisify(child_process.exec);
10855
11648
  var ShellTool = class extends BaseTool {
10856
11649
  name = "shell";
@@ -11546,7 +12339,7 @@ var PDFCreateTool = class extends BaseTool {
11546
12339
  if (!fs29__default.default.existsSync(dir)) {
11547
12340
  fs29__default.default.mkdirSync(dir, { recursive: true });
11548
12341
  }
11549
- return new Promise((resolve, reject) => {
12342
+ return new Promise((resolve2, reject) => {
11550
12343
  try {
11551
12344
  const doc = new PDFDocument__default.default({ margin: 50 });
11552
12345
  const stream = fs29__default.default.createWriteStream(filePath);
@@ -11564,7 +12357,7 @@ var PDFCreateTool = class extends BaseTool {
11564
12357
  });
11565
12358
  doc.end();
11566
12359
  stream.on("finish", () => {
11567
- resolve(`Successfully created PDF at ${filePath} (${content.length} characters)`);
12360
+ resolve2(`Successfully created PDF at ${filePath} (${content.length} characters)`);
11568
12361
  });
11569
12362
  stream.on("error", (err) => {
11570
12363
  reject(new Error(`Failed to write PDF: ${err.message}`));
@@ -11636,7 +12429,7 @@ var CodeInterpreterTool = class extends BaseTool {
11636
12429
  const filePath = path32__default.default.join(tmpDir, fileName);
11637
12430
  fs29__default.default.writeFileSync(filePath, code, "utf-8");
11638
12431
  const execArgs = [filePath, ...args];
11639
- return new Promise((resolve) => {
12432
+ return new Promise((resolve2) => {
11640
12433
  const startMs = Date.now();
11641
12434
  child_process.execFile(cmdPrefix, execArgs, { cwd: this.workspaceRoot, timeout: 3e4 }, (error, stdout, stderr) => {
11642
12435
  const duration = Date.now() - startMs;
@@ -11650,17 +12443,17 @@ var CodeInterpreterTool = class extends BaseTool {
11650
12443
  if (error) {
11651
12444
  const timedOut = error.killed && duration >= 3e4;
11652
12445
  if (timedOut) {
11653
- resolve(`Execution timed out after 30s. Consider breaking the task into smaller pieces.
12446
+ resolve2(`Execution timed out after 30s. Consider breaking the task into smaller pieces.
11654
12447
  Partial stdout: ${stdout}
11655
12448
  Stderr: ${stderr}`);
11656
12449
  } else {
11657
- resolve(`Execution failed (${duration}ms):
12450
+ resolve2(`Execution failed (${duration}ms):
11658
12451
  Error: ${error.message}
11659
12452
  Stderr: ${stderr}
11660
12453
  Stdout: ${stdout}`);
11661
12454
  }
11662
12455
  } else {
11663
- resolve(`Execution successful (${duration}ms):
12456
+ resolve2(`Execution successful (${duration}ms):
11664
12457
  Stdout: ${stdout}
11665
12458
  Stderr: ${stderr}`);
11666
12459
  }
@@ -12314,7 +13107,7 @@ var ToolRegistry = class extends EventEmitter__default.default {
12314
13107
  */
12315
13108
  waitForTool(toolName, timeoutMs = 6e4) {
12316
13109
  if (this.tools.has(toolName)) return Promise.resolve();
12317
- return new Promise((resolve, reject) => {
13110
+ return new Promise((resolve2, reject) => {
12318
13111
  const timer = setTimeout(() => {
12319
13112
  this.off("tool:added", handler);
12320
13113
  reject(new Error(`Timeout waiting for tool: ${toolName}`));
@@ -12323,7 +13116,7 @@ var ToolRegistry = class extends EventEmitter__default.default {
12323
13116
  if (name === toolName) {
12324
13117
  clearTimeout(timer);
12325
13118
  this.off("tool:added", handler);
12326
- resolve();
13119
+ resolve2();
12327
13120
  }
12328
13121
  };
12329
13122
  this.on("tool:added", handler);
@@ -12976,20 +13769,20 @@ var PermissionEscalator = class extends EventEmitter__default.default {
12976
13769
  resolver(decision);
12977
13770
  }
12978
13771
  waitForUserDecision(req) {
12979
- return new Promise((resolve) => {
13772
+ return new Promise((resolve2) => {
12980
13773
  let timer;
12981
13774
  const wrappedResolver = (decision) => {
12982
13775
  if (timer) clearTimeout(timer);
12983
13776
  if (decision.always) {
12984
13777
  this.taskWideCache.set(req.toolName, decision.approved);
12985
13778
  }
12986
- resolve(decision);
13779
+ resolve2(decision);
12987
13780
  };
12988
13781
  this.pendingUserDecisions.set(req.id, wrappedResolver);
12989
13782
  if (this.approvalTimeoutMs > 0 && Number.isFinite(this.approvalTimeoutMs)) {
12990
13783
  timer = setTimeout(() => {
12991
13784
  if (this.pendingUserDecisions.delete(req.id)) {
12992
- resolve({
13785
+ resolve2({
12993
13786
  requestId: req.id,
12994
13787
  approved: false,
12995
13788
  decidedBy: "USER",
@@ -13057,6 +13850,23 @@ var noopTelemetry = {
13057
13850
  }
13058
13851
  };
13059
13852
 
13853
+ // src/core/router/feedback-prior.ts
13854
+ var MAX_FEEDBACK_ADJUSTMENT = 0.05;
13855
+ var PRIOR_STRENGTH = 10;
13856
+ function feedbackAdjustment(counts) {
13857
+ const good = Math.max(0, Math.floor(counts.good));
13858
+ const bad = Math.max(0, Math.floor(counts.bad));
13859
+ const n = good + bad;
13860
+ if (n === 0) return 0;
13861
+ const half = PRIOR_STRENGTH / 2;
13862
+ const shrunk = (good + half) / (n + PRIOR_STRENGTH);
13863
+ return (shrunk - 0.5) * 2 * MAX_FEEDBACK_ADJUSTMENT;
13864
+ }
13865
+ function applyFeedback(score01, counts) {
13866
+ const adjusted = score01 + feedbackAdjustment(counts);
13867
+ return Math.max(0, Math.min(1, adjusted));
13868
+ }
13869
+
13060
13870
  // src/core/router/task-analyzer.ts
13061
13871
  init_pricing();
13062
13872
  var CODE_SIGNALS = [
@@ -13132,6 +13942,8 @@ var TASK_TYPE_TAGS = {
13132
13942
  };
13133
13943
  var TaskAnalyzer = class {
13134
13944
  tracker;
13945
+ /** Per-model thumbs counts, when the host collects them. */
13946
+ feedback;
13135
13947
  bias;
13136
13948
  lastProfile = null;
13137
13949
  lastSelectedModels = /* @__PURE__ */ new Map();
@@ -13139,6 +13951,14 @@ var TaskAnalyzer = class {
13139
13951
  this.tracker = tracker;
13140
13952
  this.bias = bias;
13141
13953
  }
13954
+ /**
13955
+ * Supply per-model rating counts. Optional by design: a host that collects no
13956
+ * feedback (the CLI, a fresh install) scores exactly as it did before, since
13957
+ * feedbackAdjustment returns 0 for an empty record.
13958
+ */
13959
+ setFeedbackSource(source) {
13960
+ this.feedback = source;
13961
+ }
13142
13962
  setTracker(tracker) {
13143
13963
  this.tracker = tracker;
13144
13964
  }
@@ -13222,7 +14042,10 @@ var TaskAnalyzer = class {
13222
14042
  const perf = this.tracker?.performanceScore(model.id, profile.type) ?? 0.5;
13223
14043
  const costEff = this.costEfficiency(model, profile.complexity);
13224
14044
  const match = this.taskMatchScore(model, profile);
13225
- const benchmark = 0.3 + 0.7 * benchmarkScore01(model, profile.type);
14045
+ const rated = this.feedback?.(model.id);
14046
+ const publicScore = benchmarkScore01(model, profile.type);
14047
+ const adjusted = rated ? applyFeedback(publicScore, rated) : publicScore;
14048
+ const benchmark = 0.3 + 0.7 * adjusted;
13226
14049
  switch (this.bias) {
13227
14050
  case "quality":
13228
14051
  return perf * match * benchmark ** 2 * (0.85 + 0.15 * costEff);
@@ -13627,10 +14450,10 @@ var DynamicTool = class extends BaseTool {
13627
14450
  `;
13628
14451
  const script = await isolate.compileScript(bootstrap);
13629
14452
  const runPromise = script.run(context, { timeout: timeoutMs, promise: true }).then((v) => String(v ?? ""));
13630
- const timeoutPromise = new Promise((resolve) => {
14453
+ const timeoutPromise = new Promise((resolve2) => {
13631
14454
  const t = setTimeout(() => {
13632
14455
  dispose();
13633
- resolve(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
14456
+ resolve2(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`);
13634
14457
  }, timeoutMs + 500);
13635
14458
  t.unref?.();
13636
14459
  });
@@ -13648,7 +14471,7 @@ var DynamicTool = class extends BaseTool {
13648
14471
  /** Spawn the worker, service its callTool/fetch bridge, enforce the kill timeout. */
13649
14472
  runInWorker(input, callTool) {
13650
14473
  const timeoutMs = Math.max(200, Number(process.env["CASCADE_DYNAMIC_TOOL_TIMEOUT_MS"]) || DYNAMIC_TOOL_TIMEOUT_MS);
13651
- return new Promise((resolve) => {
14474
+ return new Promise((resolve2) => {
13652
14475
  let settled = false;
13653
14476
  const worker = new worker_threads.Worker(HARNESS_SRC, {
13654
14477
  eval: true,
@@ -13660,7 +14483,7 @@ var DynamicTool = class extends BaseTool {
13660
14483
  settled = true;
13661
14484
  clearTimeout(timer);
13662
14485
  void worker.terminate();
13663
- resolve(value);
14486
+ resolve2(value);
13664
14487
  };
13665
14488
  const timer = setTimeout(
13666
14489
  () => finish(`Dynamic tool "${this.name}" timed out after ${timeoutMs}ms and was terminated.`),
@@ -15167,6 +15990,7 @@ ${prompt}`;
15167
15990
  var Cascade = class _Cascade extends EventEmitter__default.default {
15168
15991
  router;
15169
15992
  toolRegistry;
15993
+ multimodal;
15170
15994
  mcpClient;
15171
15995
  codeIndex;
15172
15996
  config;
@@ -15205,6 +16029,10 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
15205
16029
  }
15206
16030
  });
15207
16031
  this.toolRegistry = new ToolRegistry(this.config.tools, workspacePath);
16032
+ this.multimodal = new MultimodalRegistry(
16033
+ (this.config.providers ?? []).map((p) => p.type)
16034
+ );
16035
+ this.registerMediaTools(workspacePath);
15208
16036
  this.telemetry = config.telemetry?.enabled ? new Telemetry(config.telemetry, config.telemetry.distinctId ?? "anonymous") : noopTelemetry;
15209
16037
  this.worldStateDB = new WorldStateDB(this.workspacePath, this.config.workspace?.debugWorldState ?? false);
15210
16038
  this.router.setWorldStateDB(this.worldStateDB);
@@ -15258,14 +16086,14 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
15258
16086
  if (this.listenerCount("mcp:approval-required") === 0) {
15259
16087
  return false;
15260
16088
  }
15261
- return await new Promise((resolve) => {
15262
- this.pendingMcpApprovals.set(server.name, resolve);
16089
+ return await new Promise((resolve2) => {
16090
+ this.pendingMcpApprovals.set(server.name, resolve2);
15263
16091
  const timeout = setTimeout(() => {
15264
- if (this.pendingMcpApprovals.delete(server.name)) resolve(false);
16092
+ if (this.pendingMcpApprovals.delete(server.name)) resolve2(false);
15265
16093
  }, 3e4);
15266
16094
  const wrap = (approved) => {
15267
16095
  clearTimeout(timeout);
15268
- resolve(approved);
16096
+ resolve2(approved);
15269
16097
  };
15270
16098
  this.pendingMcpApprovals.set(server.name, wrap);
15271
16099
  this.emit("mcp:approval-required", { server });
@@ -15339,17 +16167,17 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
15339
16167
  }
15340
16168
  const t2Count = plan.sections.length;
15341
16169
  const t3Count = plan.sections.reduce((sum, s) => sum + (s.t3Subtasks?.length ?? 0), 0);
15342
- return await new Promise((resolve) => {
16170
+ return await new Promise((resolve2) => {
15343
16171
  const timeout = setTimeout(() => {
15344
16172
  if (this.pendingPlanApproval) {
15345
16173
  this.pendingPlanApproval = void 0;
15346
- resolve({ approved: true });
16174
+ resolve2({ approved: true });
15347
16175
  }
15348
16176
  }, 12e4);
15349
16177
  this.pendingPlanApproval = (decision) => {
15350
16178
  clearTimeout(timeout);
15351
16179
  this.pendingPlanApproval = void 0;
15352
- resolve(decision);
16180
+ resolve2(decision);
15353
16181
  };
15354
16182
  this.emit("plan:approval-required", {
15355
16183
  taskId,
@@ -15382,17 +16210,17 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
15382
16210
  }
15383
16211
  async requestContextApproval(info) {
15384
16212
  if (this.listenerCount("context:approval-required") === 0) return true;
15385
- return await new Promise((resolve) => {
16213
+ return await new Promise((resolve2) => {
15386
16214
  const timeout = setTimeout(() => {
15387
16215
  if (this.pendingContextApproval) {
15388
16216
  this.pendingContextApproval = void 0;
15389
- resolve(true);
16217
+ resolve2(true);
15390
16218
  }
15391
16219
  }, 12e4);
15392
16220
  this.pendingContextApproval = (approved) => {
15393
16221
  clearTimeout(timeout);
15394
16222
  this.pendingContextApproval = void 0;
15395
- resolve(approved);
16223
+ resolve2(approved);
15396
16224
  };
15397
16225
  this.emit("context:approval-required", info);
15398
16226
  });
@@ -15472,6 +16300,55 @@ ${input}` }],
15472
16300
  if (this.store) t1.setStore(this.store);
15473
16301
  return t1.previewPlan(prompt);
15474
16302
  }
16303
+ /**
16304
+ * Register image / speech / transcription tools for the modalities this
16305
+ * config can actually serve.
16306
+ *
16307
+ * The sink writes bytes into the workspace, because that is what a desktop or
16308
+ * CLI run wants and it keeps a generated asset indistinguishable from any
16309
+ * other file the run produced. A host with different storage (the cloud
16310
+ * server stores a file row) constructs the tools itself via buildMediaTools
16311
+ * with its own sink.
16312
+ */
16313
+ registerMediaTools(workspacePath) {
16314
+ const allowlist = this.config.tools?.enabledTools;
16315
+ const permitted = (name) => !allowlist || allowlist.includes(name);
16316
+ const tools = buildMediaTools(
16317
+ {
16318
+ registry: this.multimodal,
16319
+ lookupProvider: (provider) => (this.config.providers ?? []).find((p) => p.type === provider),
16320
+ sink: async (asset) => {
16321
+ const fsp = await import('fs/promises');
16322
+ const nodePath = await import('path');
16323
+ const dir = nodePath.join(workspacePath, "generated");
16324
+ await fsp.mkdir(dir, { recursive: true });
16325
+ const dest = nodePath.join(dir, asset.filename);
16326
+ await fsp.writeFile(dest, asset.data);
16327
+ return nodePath.relative(workspacePath, dest);
16328
+ }
16329
+ },
16330
+ async (p) => {
16331
+ const fsp = await import('fs/promises');
16332
+ return fsp.readFile(p);
16333
+ }
16334
+ );
16335
+ for (const tool of tools) {
16336
+ if (!permitted(tool.name)) continue;
16337
+ tool.setWorkspaceRoot(workspacePath);
16338
+ this.toolRegistry.register(tool);
16339
+ }
16340
+ }
16341
+ /**
16342
+ * Supply per-model thumbs-up/down counts so Auto routing can weigh how models
16343
+ * have actually performed for this user, on top of public benchmarks.
16344
+ *
16345
+ * Optional everywhere: with no source, or an empty record, scoring is
16346
+ * byte-identical to before. The adjustment is capped and sample-size shrunk —
16347
+ * see core/router/feedback-prior.ts.
16348
+ */
16349
+ setFeedbackSource(source) {
16350
+ this.router.setFeedbackSource(source);
16351
+ }
15475
16352
  /** True when a task stopped at the budget cap and can be resumed via /continue. */
15476
16353
  hasResumableRun() {
15477
16354
  return this.lastInterruptedRun != null;
@@ -15961,6 +16838,7 @@ ${prompt}` : prompt;
15961
16838
  if (toolCreator) toolCreator.setPermissionEscalator(escalator);
15962
16839
  const rootPrompt = buildContextualPrompt(options.prompt, options.conversationHistory);
15963
16840
  let finalOutput = "";
16841
+ const runBreaker = new RunBreaker(this.config.budget?.failureThreshold);
15964
16842
  let t2Results = [];
15965
16843
  let runError = null;
15966
16844
  let identityPrompt = "";
@@ -16029,6 +16907,7 @@ ${prompt}` : prompt;
16029
16907
  this.emit("tier:status", { tierId: "t3-root", status: "COMPLETED", role: "T3" });
16030
16908
  } else if (complexity === "Moderate") {
16031
16909
  const t2 = new T2Manager(this.router, this.toolRegistry, "root");
16910
+ t2.setRunBreaker(runBreaker);
16032
16911
  t2.setPresenter(true);
16033
16912
  t2.setHierarchyContext("You are the ROOT Manager for this task. There is no T1 Administrator involved in this run. You are responsible for decomposing the task and managing T3 workers directly.");
16034
16913
  if (identityPrompt) {
@@ -16089,6 +16968,7 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
16089
16968
  }
16090
16969
  } else {
16091
16970
  const t1 = new T1Administrator(this.router, this.toolRegistry, this.config);
16971
+ t1.setRunBreaker(runBreaker);
16092
16972
  t1.setPresenter(true);
16093
16973
  t1.setHierarchyContext("You are the top-level Administrator. You are responsible for the overall plan and supervising multiple T2 Managers.");
16094
16974
  if (identityPrompt) {
@@ -16113,6 +16993,20 @@ _(incomplete: ${reasons.join("; ")})_` : partial;
16113
16993
  finalOutput = result.output;
16114
16994
  t2Results = result.t2Results;
16115
16995
  }
16996
+ const trip = runBreaker.reason();
16997
+ if (trip) {
16998
+ finalOutput = [
16999
+ `\u26A0 **Run stopped early \u2014 ${trip.modelId} was failing every request.**`,
17000
+ "",
17001
+ trip.message,
17002
+ "",
17003
+ `Cascade stopped after ${trip.failures} consecutive failures rather than retrying every remaining subtask against the same model, which would have cost more and produced the same result.`,
17004
+ finalOutput ? `
17005
+ ---
17006
+
17007
+ ${finalOutput}` : ""
17008
+ ].join("\n").trimEnd();
17009
+ }
16116
17010
  } catch (err) {
16117
17011
  if (err instanceof CascadeCancelledError || err instanceof Error && err.name === "AbortError" || options.signal?.aborted) {
16118
17012
  this.emit("run:cancelled", {
@@ -16647,8 +17541,8 @@ var CloudClient = class _CloudClient {
16647
17541
  }
16648
17542
  };
16649
17543
  function sleep(ms, signal) {
16650
- return new Promise((resolve, reject) => {
16651
- const t = setTimeout(resolve, ms);
17544
+ return new Promise((resolve2, reject) => {
17545
+ const t = setTimeout(resolve2, ms);
16652
17546
  signal?.addEventListener("abort", () => {
16653
17547
  clearTimeout(t);
16654
17548
  reject(new Error("aborted"));
@@ -18440,8 +19334,8 @@ function Repl({ config, workspacePath, themeName, initialPrompt, identityName, a
18440
19334
  onExit: () => exit(),
18441
19335
  onThemeChange: (name) => setTheme(getTheme(name)),
18442
19336
  onExport: async (fmt) => {
18443
- const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
18444
- const exportPath = path32__default.default.join(workspacePath, `cascade-export-${stamp}.${fmt === "json" ? "json" : "md"}`);
19337
+ const stamp2 = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
19338
+ const exportPath = path32__default.default.join(workspacePath, `cascade-export-${stamp2}.${fmt === "json" ? "json" : "md"}`);
18445
19339
  if (fmt === "json") {
18446
19340
  await fs10__default.default.writeFile(exportPath, JSON.stringify({ sessionId: sessionIdRef.current, messages: state.messages }, null, 2), "utf-8");
18447
19341
  } else {
@@ -18873,8 +19767,8 @@ ${lastUser.content}`;
18873
19767
  conversationHistory: toConversationHistory(state.messages),
18874
19768
  approvalCallback: async (req) => {
18875
19769
  dispatch({ type: "SET_APPROVAL", request: req });
18876
- return new Promise((resolve) => {
18877
- approvalResolverRef.current = resolve;
19770
+ return new Promise((resolve2) => {
19771
+ approvalResolverRef.current = resolve2;
18878
19772
  });
18879
19773
  }
18880
19774
  });
@@ -20140,13 +21034,13 @@ function SetupWizard({ workspacePath, onComplete }) {
20140
21034
  }
20141
21035
  async function runSetupWizard(workspacePath) {
20142
21036
  printTelemetryBanner();
20143
- return new Promise((resolve) => {
21037
+ return new Promise((resolve2) => {
20144
21038
  const { unmount } = ink.render(
20145
21039
  React2__default.default.createElement(SetupWizard, {
20146
21040
  workspacePath,
20147
21041
  onComplete: (config) => {
20148
21042
  unmount();
20149
- resolve(config);
21043
+ resolve2(config);
20150
21044
  }
20151
21045
  }),
20152
21046
  { exitOnCtrlC: true }
@@ -20614,7 +21508,7 @@ function notSignedIn() {
20614
21508
  process.exitCode = 1;
20615
21509
  }
20616
21510
  function promptHidden(question) {
20617
- return new Promise((resolve) => {
21511
+ return new Promise((resolve2) => {
20618
21512
  const rl = readline__default.default.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
20619
21513
  let masked = false;
20620
21514
  rl._writeToOutput = (s) => {
@@ -20623,7 +21517,7 @@ function promptHidden(question) {
20623
21517
  rl.question(question, (answer) => {
20624
21518
  rl.close();
20625
21519
  process.stdout.write("\n");
20626
- resolve(answer.trim());
21520
+ resolve2(answer.trim());
20627
21521
  });
20628
21522
  masked = true;
20629
21523
  });
@@ -21550,12 +22444,12 @@ var DashboardServer = class {
21550
22444
  console.warn("\u26A0 Dashboard auth is DISABLED while bound to a non-loopback interface \u2014 this allows unauthenticated remote task execution.");
21551
22445
  }
21552
22446
  }
21553
- await new Promise((resolve, reject) => {
22447
+ await new Promise((resolve2, reject) => {
21554
22448
  const onError = (err) => reject(err);
21555
22449
  this.httpServer.once("error", onError);
21556
22450
  this.httpServer.listen(this.port, this.host, () => {
21557
22451
  this.httpServer.off("error", onError);
21558
- resolve();
22452
+ resolve2();
21559
22453
  });
21560
22454
  });
21561
22455
  try {
@@ -21579,8 +22473,8 @@ var DashboardServer = class {
21579
22473
  } catch {
21580
22474
  }
21581
22475
  this.globalStore = null;
21582
- await new Promise((resolve, reject) => {
21583
- this.httpServer.close((err) => err ? reject(err) : resolve());
22476
+ await new Promise((resolve2, reject) => {
22477
+ this.httpServer.close((err) => err ? reject(err) : resolve2());
21584
22478
  });
21585
22479
  }
21586
22480
  getSocket() {
@@ -21777,8 +22671,8 @@ var DashboardServer = class {
21777
22671
  * timeout denies it.
21778
22672
  */
21779
22673
  makeApprovalCallback(sessionId) {
21780
- return (request) => new Promise((resolve) => {
21781
- this.pendingApprovals.set(request.id, { resolve, sessionId });
22674
+ return (request) => new Promise((resolve2) => {
22675
+ this.pendingApprovals.set(request.id, { resolve: resolve2, sessionId });
21782
22676
  });
21783
22677
  }
21784
22678
  /**