cascade-ai 0.51.0 → 0.52.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.
@@ -224247,7 +224247,7 @@ function reconcilePrice(model, live) {
224247
224247
  }
224248
224248
 
224249
224249
  // src/constants.ts
224250
- var CASCADE_VERSION = "0.51.0";
224250
+ var CASCADE_VERSION = "0.52.0";
224251
224251
  var CASCADE_CONFIG_DIR = ".cascade";
224252
224252
  var CASCADE_MD_FILE = "CASCADE.md";
224253
224253
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -257153,17 +257153,13 @@ var CAPABILITIES = [
257153
257153
  },
257154
257154
  // ── Video ──
257155
257155
  {
257156
- // Listed so `describe()` can tell the truth about what exists, and so the
257157
- // planner is told video is out of reach rather than left to invent a tool.
257158
- // Veo is a long-running operation: submit, poll, then fetch bytes from a
257159
- // signed URL. That is a different execution shape from every other entry
257160
- // here and is not wired up — claiming otherwise would produce a tool that
257161
- // fails at call time instead of an honest "not yet".
257156
+ // Long-running: submit returns an operation name, the video lands minutes
257157
+ // later, and the bytes come from a short-lived signed URL. generate.ts owns
257158
+ // that polling loop; the registry only records the shape via `api`.
257162
257159
  modality: "video",
257163
257160
  provider: "gemini",
257164
257161
  modelId: "veo-3.1-generate-001",
257165
- api: "gemini-predict",
257166
- unsupported: "Veo runs as a long-running operation (submit \u2192 poll \u2192 fetch). Not wired up yet."
257162
+ api: "gemini-predict-lro"
257167
257163
  }
257168
257164
  ];
257169
257165
  function capabilityCost(cap) {
@@ -257476,6 +257472,75 @@ async function generateSpeech(cap, cfg, req, signal) {
257476
257472
  };
257477
257473
  }, cap.modelId);
257478
257474
  }
257475
+ var VIDEO_POLL_INTERVAL_MS = 1e4;
257476
+ var VIDEO_TIMEOUT_MS = 8 * 6e4;
257477
+ function sleep4(ms2, signal) {
257478
+ return new Promise((resolve2, reject) => {
257479
+ if (signal?.aborted) return reject(new Error("Cancelled."));
257480
+ const timer = setTimeout(() => {
257481
+ signal?.removeEventListener("abort", onAbort);
257482
+ resolve2();
257483
+ }, ms2);
257484
+ function onAbort() {
257485
+ clearTimeout(timer);
257486
+ reject(new Error("Cancelled."));
257487
+ }
257488
+ signal?.addEventListener("abort", onAbort, { once: true });
257489
+ });
257490
+ }
257491
+ async function generateVideo(cap, cfg, req, signal, onProgress) {
257492
+ return callProvider(async () => {
257493
+ if (cap.api !== "gemini-predict-lro") throw new Error(`${cap.modelId} is not a video generator.`);
257494
+ const base = baseUrlFor(cfg, "https://generativelanguage.googleapis.com/v1beta");
257495
+ const key = cfg.apiKey ?? "";
257496
+ const headers = { "x-goog-api-key": key };
257497
+ const submit = await postJson(
257498
+ `${base}/models/${encodeURIComponent(cap.modelId)}:predictLongRunning`,
257499
+ {
257500
+ instances: [{ prompt: req.prompt }],
257501
+ parameters: {
257502
+ aspectRatio: req.aspectRatio ?? "16:9",
257503
+ ...req.seconds ? { durationSeconds: req.seconds } : {}
257504
+ }
257505
+ },
257506
+ headers,
257507
+ signal
257508
+ );
257509
+ const started = await submit.json();
257510
+ if (!started.name) throw new Error("Video request was accepted but returned no operation to poll.");
257511
+ onProgress?.(`Video generation started (${cap.modelId}). This usually takes 1-3 minutes.`);
257512
+ const deadline = Date.now() + VIDEO_TIMEOUT_MS;
257513
+ let operation = {};
257514
+ while (Date.now() < deadline) {
257515
+ await sleep4(VIDEO_POLL_INTERVAL_MS, signal);
257516
+ const poll = await fetch(`${base}/${started.name}`, { headers, signal });
257517
+ if (!poll.ok) {
257518
+ const text = await poll.text().catch(() => "");
257519
+ throw Object.assign(new Error(text || poll.statusText), { status: poll.status });
257520
+ }
257521
+ operation = await poll.json();
257522
+ if (operation.done) break;
257523
+ onProgress?.("Still rendering\u2026");
257524
+ }
257525
+ if (!operation.done) {
257526
+ throw new Error(
257527
+ `Video was still rendering after ${Math.round(VIDEO_TIMEOUT_MS / 6e4)} minutes. It may still complete on the provider side, but Cascade stopped waiting.`
257528
+ );
257529
+ }
257530
+ if (operation.error?.message) throw new Error(operation.error.message);
257531
+ const uri = operation.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
257532
+ if (!uri) throw new Error("Video finished but the response contained no video.");
257533
+ const media = await fetch(uri, { headers, signal });
257534
+ if (!media.ok) throw new Error(`Video finished but could not be downloaded (HTTP ${media.status}).`);
257535
+ return {
257536
+ data: Buffer.from(await media.arrayBuffer()),
257537
+ mimeType: "video/mp4",
257538
+ filename: stamp("mp4"),
257539
+ modelId: cap.modelId,
257540
+ provider: cap.provider
257541
+ };
257542
+ }, cap.modelId);
257543
+ }
257479
257544
  async function transcribeAudio(cap, cfg, req, signal) {
257480
257545
  return callProvider(async () => {
257481
257546
  if (cap.api !== "openai-transcriptions") throw new Error(`${cap.modelId} is not a transcription model.`);
@@ -257600,6 +257665,58 @@ var GenerateSpeechTool = class extends BaseTool {
257600
257665
  Model: ${asset.modelId} \u2014 ${r4.selected.reason}`;
257601
257666
  }
257602
257667
  };
257668
+ var DEFAULT_VIDEO_SECONDS = 5;
257669
+ var MAX_VIDEO_SECONDS = 8;
257670
+ var GenerateVideoTool = class extends BaseTool {
257671
+ constructor(deps) {
257672
+ super();
257673
+ this.deps = deps;
257674
+ }
257675
+ deps;
257676
+ name = "generate_video";
257677
+ // The price is in the description on purpose. Video is ~1000x the cost of an
257678
+ // image per call, it is billed per SECOND, and the model choosing the
257679
+ // duration is the one deciding the bill — so the number has to be where the
257680
+ // model reads before calling, not only in the result afterwards.
257681
+ description = "Generate a short video clip from a text description. EXPENSIVE and slow: billed per second of output (about $0.40/second, so a 5-second clip costs roughly $2) and takes 1-3 minutes. Only use when the user explicitly asks for a video, and prefer the shortest duration that satisfies the request.";
257682
+ inputSchema = {
257683
+ type: "object",
257684
+ properties: {
257685
+ prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
257686
+ seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
257687
+ aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
257688
+ model: { type: "string", description: "Optional: force a specific video model." }
257689
+ },
257690
+ required: ["prompt"]
257691
+ };
257692
+ async execute(input, options) {
257693
+ const prompt = String(input["prompt"] ?? "").trim();
257694
+ if (!prompt) return 'Provide a "prompt" describing the video.';
257695
+ const r4 = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
257696
+ if (!r4.ok) return r4.error;
257697
+ const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
257698
+ const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
257699
+ const asset = await generateVideo(
257700
+ r4.selected.capability,
257701
+ r4.cfg,
257702
+ {
257703
+ prompt,
257704
+ seconds,
257705
+ ...input["aspectRatio"] ? { aspectRatio: String(input["aspectRatio"]) } : {}
257706
+ },
257707
+ options.signal,
257708
+ // Progress goes to the run log so a two-minute wait doesn't look like a
257709
+ // hang — the single most likely reason someone kills a working render.
257710
+ (note) => options.onProgress?.(note)
257711
+ );
257712
+ const location = await this.deps.sink(asset);
257713
+ const unit = r4.selected.cost;
257714
+ const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
257715
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
257716
+ return `Video generated and saved to ${location}.
257717
+ Model: ${asset.modelId}.${estimate}${clamped}`;
257718
+ }
257719
+ };
257603
257720
  var TranscribeAudioTool = class extends BaseTool {
257604
257721
  constructor(deps, readFile) {
257605
257722
  super();
@@ -257650,6 +257767,7 @@ function buildMediaTools(deps, readFile) {
257650
257767
  const modalities = new Set(deps.registry.availableModalities());
257651
257768
  if (modalities.has("image")) tools.push(new GenerateImageTool(deps));
257652
257769
  if (modalities.has("speech")) tools.push(new GenerateSpeechTool(deps));
257770
+ if (modalities.has("video")) tools.push(new GenerateVideoTool(deps));
257653
257771
  if (modalities.has("transcription") && readFile) tools.push(new TranscribeAudioTool(deps, readFile));
257654
257772
  return tools;
257655
257773
  }
@@ -300272,7 +300390,14 @@ var ToolsConfigSchema = external_exports.object({
300272
300390
  * built-in tool (shell/file/git/…) from existing at all, rather than just
300273
300391
  * gating it behind approval. Omitted = full default set (unchanged).
300274
300392
  */
300275
- enabledTools: external_exports.array(external_exports.string()).optional()
300393
+ enabledTools: external_exports.array(external_exports.string()).optional(),
300394
+ /**
300395
+ * Tools to remove even when they would otherwise be registered. Unlike
300396
+ * `enabledTools` (an allowlist over the built-in set), this also reaches
300397
+ * tools registered outside it — MCP tools and media generation — which is
300398
+ * the only way to turn those off.
300399
+ */
300400
+ disabledTools: external_exports.array(external_exports.string()).optional()
300276
300401
  });
300277
300402
  var HookDefinitionSchema = external_exports.object({
300278
300403
  name: external_exports.string().optional(),
@@ -302821,6 +302946,7 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
302821
302946
  router;
302822
302947
  toolRegistry;
302823
302948
  multimodal;
302949
+ mediaSink;
302824
302950
  mcpClient;
302825
302951
  codeIndex;
302826
302952
  config;
@@ -303140,14 +303266,27 @@ ${input}` }],
303140
303266
  * server stores a file row) constructs the tools itself via buildMediaTools
303141
303267
  * with its own sink.
303142
303268
  */
303269
+ /**
303270
+ * Replace where generated media is written.
303271
+ *
303272
+ * The default sink writes into the workspace, which is what a desktop or CLI
303273
+ * run wants. A host with different storage — the cloud server keeps a file
303274
+ * row plus bytes under the tenant's directory — supplies its own here, and
303275
+ * the media tools are rebuilt around it. Re-registering by name replaces the
303276
+ * existing tools rather than duplicating them.
303277
+ */
303278
+ setMediaSink(sink) {
303279
+ this.mediaSink = sink;
303280
+ this.registerMediaTools(this.workspacePath);
303281
+ }
303143
303282
  registerMediaTools(workspacePath) {
303144
- const allowlist = this.config.tools?.enabledTools;
303145
- const permitted = (name) => !allowlist || allowlist.includes(name);
303283
+ const denied = new Set(this.config.tools?.disabledTools ?? []);
303284
+ const permitted = (name) => !denied.has(name);
303146
303285
  const tools = buildMediaTools(
303147
303286
  {
303148
303287
  registry: this.multimodal,
303149
303288
  lookupProvider: (provider) => (this.config.providers ?? []).find((p3) => p3.type === provider),
303150
- sink: async (asset) => {
303289
+ sink: this.mediaSink ?? (async (asset) => {
303151
303290
  const fsp = await import('fs/promises');
303152
303291
  const nodePath = await import('path');
303153
303292
  const dir = nodePath.join(workspacePath, "generated");
@@ -303155,7 +303294,7 @@ ${input}` }],
303155
303294
  const dest = nodePath.join(dir, asset.filename);
303156
303295
  await fsp.writeFile(dest, asset.data);
303157
303296
  return nodePath.relative(workspacePath, dest);
303158
- }
303297
+ })
303159
303298
  },
303160
303299
  async (p3) => {
303161
303300
  const fsp = await import('fs/promises');
@@ -307953,7 +308092,7 @@ var CloudClient = class _CloudClient {
307953
308092
  const deadline = Date.now() + start.expiresIn * 1e3;
307954
308093
  while (Date.now() < deadline) {
307955
308094
  if (opts.signal?.aborted) throw new Error("Sign-in cancelled.");
307956
- await sleep4(interval * 1e3, opts.signal);
308095
+ await sleep5(interval * 1e3, opts.signal);
307957
308096
  const r4 = await this.pollDevice(start.deviceCode);
307958
308097
  if (r4 === "slow_down") {
307959
308098
  interval += 5;
@@ -308164,7 +308303,7 @@ var CloudClient = class _CloudClient {
308164
308303
  this.store.clear();
308165
308304
  }
308166
308305
  };
308167
- function sleep4(ms2, signal) {
308306
+ function sleep5(ms2, signal) {
308168
308307
  return new Promise((resolve2, reject) => {
308169
308308
  const t4 = setTimeout(resolve2, ms2);
308170
308309
  signal?.addEventListener("abort", () => {
package/dist/index.cjs CHANGED
@@ -1891,7 +1891,7 @@ function reconcilePrice(model, live) {
1891
1891
  }
1892
1892
 
1893
1893
  // src/constants.ts
1894
- var CASCADE_VERSION = "0.51.0";
1894
+ var CASCADE_VERSION = "0.52.0";
1895
1895
  var CASCADE_CONFIG_DIR = ".cascade";
1896
1896
  var CASCADE_MD_FILE = "CASCADE.md";
1897
1897
  var CASCADE_IGNORE_FILE = ".cascadeignore";
@@ -8894,17 +8894,13 @@ var CAPABILITIES = [
8894
8894
  },
8895
8895
  // ── Video ──
8896
8896
  {
8897
- // Listed so `describe()` can tell the truth about what exists, and so the
8898
- // planner is told video is out of reach rather than left to invent a tool.
8899
- // Veo is a long-running operation: submit, poll, then fetch bytes from a
8900
- // signed URL. That is a different execution shape from every other entry
8901
- // here and is not wired up — claiming otherwise would produce a tool that
8902
- // fails at call time instead of an honest "not yet".
8897
+ // Long-running: submit returns an operation name, the video lands minutes
8898
+ // later, and the bytes come from a short-lived signed URL. generate.ts owns
8899
+ // that polling loop; the registry only records the shape via `api`.
8903
8900
  modality: "video",
8904
8901
  provider: "gemini",
8905
8902
  modelId: "veo-3.1-generate-001",
8906
- api: "gemini-predict",
8907
- unsupported: "Veo runs as a long-running operation (submit \u2192 poll \u2192 fetch). Not wired up yet."
8903
+ api: "gemini-predict-lro"
8908
8904
  }
8909
8905
  ];
8910
8906
  function capabilityCost(cap) {
@@ -9217,6 +9213,75 @@ async function generateSpeech(cap, cfg, req, signal) {
9217
9213
  };
9218
9214
  }, cap.modelId);
9219
9215
  }
9216
+ var VIDEO_POLL_INTERVAL_MS = 1e4;
9217
+ var VIDEO_TIMEOUT_MS = 8 * 6e4;
9218
+ function sleep(ms, signal) {
9219
+ return new Promise((resolve2, reject) => {
9220
+ if (signal?.aborted) return reject(new Error("Cancelled."));
9221
+ const timer = setTimeout(() => {
9222
+ signal?.removeEventListener("abort", onAbort);
9223
+ resolve2();
9224
+ }, ms);
9225
+ function onAbort() {
9226
+ clearTimeout(timer);
9227
+ reject(new Error("Cancelled."));
9228
+ }
9229
+ signal?.addEventListener("abort", onAbort, { once: true });
9230
+ });
9231
+ }
9232
+ async function generateVideo(cap, cfg, req, signal, onProgress) {
9233
+ return callProvider(async () => {
9234
+ if (cap.api !== "gemini-predict-lro") throw new Error(`${cap.modelId} is not a video generator.`);
9235
+ const base = baseUrlFor(cfg, "https://generativelanguage.googleapis.com/v1beta");
9236
+ const key = cfg.apiKey ?? "";
9237
+ const headers = { "x-goog-api-key": key };
9238
+ const submit = await postJson(
9239
+ `${base}/models/${encodeURIComponent(cap.modelId)}:predictLongRunning`,
9240
+ {
9241
+ instances: [{ prompt: req.prompt }],
9242
+ parameters: {
9243
+ aspectRatio: req.aspectRatio ?? "16:9",
9244
+ ...req.seconds ? { durationSeconds: req.seconds } : {}
9245
+ }
9246
+ },
9247
+ headers,
9248
+ signal
9249
+ );
9250
+ const started = await submit.json();
9251
+ if (!started.name) throw new Error("Video request was accepted but returned no operation to poll.");
9252
+ onProgress?.(`Video generation started (${cap.modelId}). This usually takes 1-3 minutes.`);
9253
+ const deadline = Date.now() + VIDEO_TIMEOUT_MS;
9254
+ let operation = {};
9255
+ while (Date.now() < deadline) {
9256
+ await sleep(VIDEO_POLL_INTERVAL_MS, signal);
9257
+ const poll = await fetch(`${base}/${started.name}`, { headers, signal });
9258
+ if (!poll.ok) {
9259
+ const text = await poll.text().catch(() => "");
9260
+ throw Object.assign(new Error(text || poll.statusText), { status: poll.status });
9261
+ }
9262
+ operation = await poll.json();
9263
+ if (operation.done) break;
9264
+ onProgress?.("Still rendering\u2026");
9265
+ }
9266
+ if (!operation.done) {
9267
+ throw new Error(
9268
+ `Video was still rendering after ${Math.round(VIDEO_TIMEOUT_MS / 6e4)} minutes. It may still complete on the provider side, but Cascade stopped waiting.`
9269
+ );
9270
+ }
9271
+ if (operation.error?.message) throw new Error(operation.error.message);
9272
+ const uri = operation.response?.generateVideoResponse?.generatedSamples?.[0]?.video?.uri;
9273
+ if (!uri) throw new Error("Video finished but the response contained no video.");
9274
+ const media = await fetch(uri, { headers, signal });
9275
+ if (!media.ok) throw new Error(`Video finished but could not be downloaded (HTTP ${media.status}).`);
9276
+ return {
9277
+ data: Buffer.from(await media.arrayBuffer()),
9278
+ mimeType: "video/mp4",
9279
+ filename: stamp("mp4"),
9280
+ modelId: cap.modelId,
9281
+ provider: cap.provider
9282
+ };
9283
+ }, cap.modelId);
9284
+ }
9220
9285
  async function transcribeAudio(cap, cfg, req, signal) {
9221
9286
  return callProvider(async () => {
9222
9287
  if (cap.api !== "openai-transcriptions") throw new Error(`${cap.modelId} is not a transcription model.`);
@@ -9341,6 +9406,58 @@ var GenerateSpeechTool = class extends BaseTool {
9341
9406
  Model: ${asset.modelId} \u2014 ${r.selected.reason}`;
9342
9407
  }
9343
9408
  };
9409
+ var DEFAULT_VIDEO_SECONDS = 5;
9410
+ var MAX_VIDEO_SECONDS = 8;
9411
+ var GenerateVideoTool = class extends BaseTool {
9412
+ constructor(deps) {
9413
+ super();
9414
+ this.deps = deps;
9415
+ }
9416
+ deps;
9417
+ name = "generate_video";
9418
+ // The price is in the description on purpose. Video is ~1000x the cost of an
9419
+ // image per call, it is billed per SECOND, and the model choosing the
9420
+ // duration is the one deciding the bill — so the number has to be where the
9421
+ // model reads before calling, not only in the result afterwards.
9422
+ description = "Generate a short video clip from a text description. EXPENSIVE and slow: billed per second of output (about $0.40/second, so a 5-second clip costs roughly $2) and takes 1-3 minutes. Only use when the user explicitly asks for a video, and prefer the shortest duration that satisfies the request.";
9423
+ inputSchema = {
9424
+ type: "object",
9425
+ properties: {
9426
+ prompt: { type: "string", description: "What the video should show: subject, action, camera movement, style." },
9427
+ seconds: { type: "number", description: `Clip length, 1-${MAX_VIDEO_SECONDS} seconds (default ${DEFAULT_VIDEO_SECONDS}). Billed per second \u2014 keep it short.` },
9428
+ aspectRatio: { type: "string", description: 'Frame shape: "16:9" (default) or "9:16" for vertical.' },
9429
+ model: { type: "string", description: "Optional: force a specific video model." }
9430
+ },
9431
+ required: ["prompt"]
9432
+ };
9433
+ async execute(input, options) {
9434
+ const prompt = String(input["prompt"] ?? "").trim();
9435
+ if (!prompt) return 'Provide a "prompt" describing the video.';
9436
+ const r = resolve(this.deps, "video", input["model"] ? String(input["model"]) : void 0);
9437
+ if (!r.ok) return r.error;
9438
+ const asked = typeof input["seconds"] === "number" ? input["seconds"] : DEFAULT_VIDEO_SECONDS;
9439
+ const seconds = Math.max(1, Math.min(MAX_VIDEO_SECONDS, Math.round(asked)));
9440
+ const asset = await generateVideo(
9441
+ r.selected.capability,
9442
+ r.cfg,
9443
+ {
9444
+ prompt,
9445
+ seconds,
9446
+ ...input["aspectRatio"] ? { aspectRatio: String(input["aspectRatio"]) } : {}
9447
+ },
9448
+ options.signal,
9449
+ // Progress goes to the run log so a two-minute wait doesn't look like a
9450
+ // hang — the single most likely reason someone kills a working render.
9451
+ (note) => options.onProgress?.(note)
9452
+ );
9453
+ const location = await this.deps.sink(asset);
9454
+ const unit = r.selected.cost;
9455
+ const estimate = unit ? ` Estimated cost: $${(unit.amount * seconds).toFixed(2)} (${seconds}s x ${unit.label}).` : "";
9456
+ const clamped = seconds !== Math.round(asked) ? ` Duration was clamped to ${seconds}s (max ${MAX_VIDEO_SECONDS}).` : "";
9457
+ return `Video generated and saved to ${location}.
9458
+ Model: ${asset.modelId}.${estimate}${clamped}`;
9459
+ }
9460
+ };
9344
9461
  var TranscribeAudioTool = class extends BaseTool {
9345
9462
  constructor(deps, readFile) {
9346
9463
  super();
@@ -9391,6 +9508,7 @@ function buildMediaTools(deps, readFile) {
9391
9508
  const modalities = new Set(deps.registry.availableModalities());
9392
9509
  if (modalities.has("image")) tools.push(new GenerateImageTool(deps));
9393
9510
  if (modalities.has("speech")) tools.push(new GenerateSpeechTool(deps));
9511
+ if (modalities.has("video")) tools.push(new GenerateVideoTool(deps));
9394
9512
  if (modalities.has("transcription") && readFile) tools.push(new TranscribeAudioTool(deps, readFile));
9395
9513
  return tools;
9396
9514
  }
@@ -11719,7 +11837,14 @@ var ToolsConfigSchema = zod.z.object({
11719
11837
  * built-in tool (shell/file/git/…) from existing at all, rather than just
11720
11838
  * gating it behind approval. Omitted = full default set (unchanged).
11721
11839
  */
11722
- enabledTools: zod.z.array(zod.z.string()).optional()
11840
+ enabledTools: zod.z.array(zod.z.string()).optional(),
11841
+ /**
11842
+ * Tools to remove even when they would otherwise be registered. Unlike
11843
+ * `enabledTools` (an allowlist over the built-in set), this also reaches
11844
+ * tools registered outside it — MCP tools and media generation — which is
11845
+ * the only way to turn those off.
11846
+ */
11847
+ disabledTools: zod.z.array(zod.z.string()).optional()
11723
11848
  });
11724
11849
  var HookDefinitionSchema = zod.z.object({
11725
11850
  name: zod.z.string().optional(),
@@ -14258,6 +14383,7 @@ var Cascade = class _Cascade extends EventEmitter__default.default {
14258
14383
  router;
14259
14384
  toolRegistry;
14260
14385
  multimodal;
14386
+ mediaSink;
14261
14387
  mcpClient;
14262
14388
  codeIndex;
14263
14389
  config;
@@ -14577,14 +14703,27 @@ ${input}` }],
14577
14703
  * server stores a file row) constructs the tools itself via buildMediaTools
14578
14704
  * with its own sink.
14579
14705
  */
14706
+ /**
14707
+ * Replace where generated media is written.
14708
+ *
14709
+ * The default sink writes into the workspace, which is what a desktop or CLI
14710
+ * run wants. A host with different storage — the cloud server keeps a file
14711
+ * row plus bytes under the tenant's directory — supplies its own here, and
14712
+ * the media tools are rebuilt around it. Re-registering by name replaces the
14713
+ * existing tools rather than duplicating them.
14714
+ */
14715
+ setMediaSink(sink) {
14716
+ this.mediaSink = sink;
14717
+ this.registerMediaTools(this.workspacePath);
14718
+ }
14580
14719
  registerMediaTools(workspacePath) {
14581
- const allowlist = this.config.tools?.enabledTools;
14582
- const permitted = (name) => !allowlist || allowlist.includes(name);
14720
+ const denied = new Set(this.config.tools?.disabledTools ?? []);
14721
+ const permitted = (name) => !denied.has(name);
14583
14722
  const tools = buildMediaTools(
14584
14723
  {
14585
14724
  registry: this.multimodal,
14586
14725
  lookupProvider: (provider) => (this.config.providers ?? []).find((p) => p.type === provider),
14587
- sink: async (asset) => {
14726
+ sink: this.mediaSink ?? (async (asset) => {
14588
14727
  const fsp = await import('fs/promises');
14589
14728
  const nodePath = await import('path');
14590
14729
  const dir = nodePath.join(workspacePath, "generated");
@@ -14592,7 +14731,7 @@ ${input}` }],
14592
14731
  const dest = nodePath.join(dir, asset.filename);
14593
14732
  await fsp.writeFile(dest, asset.data);
14594
14733
  return nodePath.relative(workspacePath, dest);
14595
- }
14734
+ })
14596
14735
  },
14597
14736
  async (p) => {
14598
14737
  const fsp = await import('fs/promises');
@@ -18606,7 +18745,7 @@ var CloudClient = class _CloudClient {
18606
18745
  const deadline = Date.now() + start.expiresIn * 1e3;
18607
18746
  while (Date.now() < deadline) {
18608
18747
  if (opts.signal?.aborted) throw new Error("Sign-in cancelled.");
18609
- await sleep(interval * 1e3, opts.signal);
18748
+ await sleep2(interval * 1e3, opts.signal);
18610
18749
  const r = await this.pollDevice(start.deviceCode);
18611
18750
  if (r === "slow_down") {
18612
18751
  interval += 5;
@@ -18817,7 +18956,7 @@ var CloudClient = class _CloudClient {
18817
18956
  this.store.clear();
18818
18957
  }
18819
18958
  };
18820
- function sleep(ms, signal) {
18959
+ function sleep2(ms, signal) {
18821
18960
  return new Promise((resolve2, reject) => {
18822
18961
  const t = setTimeout(resolve2, ms);
18823
18962
  signal?.addEventListener("abort", () => {