privateer-agent 0.15.3 → 0.15.5

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.
@@ -38,11 +38,14 @@
38
38
  // stitch → score → send.
39
39
 
40
40
  import { Type } from "typebox";
41
- import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync } from "node:fs";
41
+ import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync } from "node:fs";
42
+ import { createDecipheriv, randomBytes } from "node:crypto";
42
43
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
43
44
  import { tmpdir } from "node:os";
45
+ import { Agent } from "undici";
44
46
  import { apiRequest } from "../auth/privateer.ts";
45
47
  import { effectiveMediaModel } from "../config/mediaModels.ts";
48
+ import { globalDir } from "../config/paths.ts";
46
49
 
47
50
  /** Tool names these definitions register, for allow-list construction. */
48
51
  export const MEDIA_TOOL_NAMES = [
@@ -167,6 +170,51 @@ interface AccountFailure {
167
170
  status?: number;
168
171
  }
169
172
 
173
+ // Pi installs a process-wide undici dispatcher whose headersTimeout/bodyTimeout is
174
+ // its HTTP idle timeout — 5 minutes by default. That is right for a model stream and
175
+ // wrong here: an image, speech or music POST answers only once the provider has
176
+ // finished, and a slow model (gpt-image with input images, a long track) can take
177
+ // longer than that to send its first byte. undici then aborts with a HeadersTimeout
178
+ // that fetch flattens to the bare "fetch failed", and an agent driving Privateer
179
+ // (Claude Code, Hermes, OpenClaw…) saw a 5-6 minute hang followed by a message that
180
+ // gave it nothing to act on — while the generation it had paid for finished anyway.
181
+ // Media calls get their own dispatcher with a ceiling sized to the slowest
182
+ // synchronous generation; the tool's AbortSignal still cancels it sooner.
183
+ export const MEDIA_HTTP_TIMEOUT_MS = 20 * 60_000;
184
+ let _mediaDispatcher: Agent | null = null;
185
+ function mediaDispatcher(): Agent {
186
+ return (_mediaDispatcher ??= new Agent({
187
+ headersTimeout: MEDIA_HTTP_TIMEOUT_MS,
188
+ bodyTimeout: MEDIA_HTTP_TIMEOUT_MS,
189
+ connect: { autoSelectFamilyAttemptTimeout: 2_000 },
190
+ }));
191
+ }
192
+
193
+ /**
194
+ * Say WHY a request got no HTTP response. fetch reports every transport failure as
195
+ * "fetch failed" and keeps the real reason on `cause` (an undici error with a `code`,
196
+ * or a Node errno); printing only the message threw that away, so a caller could not
197
+ * tell a timeout from a dead network from DNS. The timeout case also warns about
198
+ * billing: the server keeps working after we stop listening, so blindly re-sending
199
+ * the same prompt can pay for the same generation twice.
200
+ */
201
+ export function describeTransportFailure(e: unknown): string {
202
+ if (e instanceof Error && e.name === "AbortError") return "the request was cancelled";
203
+ const top = e instanceof Error ? e.message : String(e);
204
+ const cause = (e as { cause?: unknown })?.cause;
205
+ const code = String((cause as { code?: unknown })?.code ?? (e as { code?: unknown })?.code ?? "");
206
+ const causeMsg = cause instanceof Error ? cause.message : "";
207
+ if (/^UND_ERR_(HEADERS|BODY)_TIMEOUT$/.test(code)) {
208
+ return (
209
+ `Privateer did not answer within ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 60_000)} minutes (${code}). ` +
210
+ "The generation may still have finished and been billed on the server, so check the output path " +
211
+ "before retrying, and retry once rather than in a loop"
212
+ );
213
+ }
214
+ const detail = [code, causeMsg].filter(Boolean).join(": ");
215
+ return `could not reach Privateer: ${top}${detail && !top.includes(detail) ? ` (${detail})` : ""}`;
216
+ }
217
+
170
218
  /**
171
219
  * Call the account API and return the parsed payload, or a message written for the
172
220
  * model to read. Errors are surfaced rather than swallowed — an agent that quietly
@@ -175,11 +223,14 @@ interface AccountFailure {
175
223
  */
176
224
  async function callAccount<T>(
177
225
  path: string,
178
- init: { method: "GET" | "POST"; body?: unknown; signal?: AbortSignal },
226
+ init: { method: "GET" | "POST" | "DELETE"; body?: unknown; signal?: AbortSignal },
179
227
  ): Promise<({ ok: true } & { data: T }) | AccountFailure> {
180
228
  let res: Response;
181
229
  try {
182
230
  res = await apiRequest(path, {
231
+ // npm undici's Agent vs the undici-types Dispatcher in RequestInit: same runtime
232
+ // contract (verified against both Node's bundled fetch and Pi's undici 8).
233
+ ...({ dispatcher: mediaDispatcher() } as object),
183
234
  method: init.method,
184
235
  ...(init.body === undefined
185
236
  ? {}
@@ -187,9 +238,18 @@ async function callAccount<T>(
187
238
  ...(init.signal ? { signal: init.signal } : {}),
188
239
  });
189
240
  } catch (e) {
190
- return { ok: false, message: `could not reach Privateer: ${e instanceof Error ? e.message : String(e)}` };
241
+ return { ok: false, message: describeTransportFailure(e) };
191
242
  }
243
+ return interpretAccountResponse<T>(res);
244
+ }
192
245
 
246
+ /**
247
+ * Turn an account-API response into the payload or a message for the model. Split
248
+ * out of callAccount because a deferred job's result arrives SEALED inside a poll,
249
+ * and once opened it must be judged exactly as the same response would have been
250
+ * had it come back directly — same ZDR wording, same 402, same 503 advice.
251
+ */
252
+ async function interpretAccountResponse<T>(res: Response): Promise<({ ok: true } & { data: T }) | AccountFailure> {
193
253
  if (res.ok || res.status === 202) {
194
254
  try {
195
255
  return { ok: true, data: (await res.json()) as T };
@@ -261,6 +321,191 @@ async function callAccount<T>(
261
321
  return { ok: false, status: res.status, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
262
322
  }
263
323
 
324
+ // ── Deferred jobs (image, speech, music, sfx) ────────────────────────────────
325
+ //
326
+ // These four used to be ONE blocking POST that answered only when the provider had
327
+ // finished. A slow generation outlived the idle timeouts between us and the server,
328
+ // the connection died, and the result — already billed — was gone. Now each is
329
+ // submit → job id → poll, like video: POST /api/agent/media/jobs runs the original
330
+ // route server-side and parks the response SEALED under a key only this machine
331
+ // holds (`sealKey`, sent with the submit and dropped by the server). The key is
332
+ // kept on disk beside the job id, so a later process — the same agent after a
333
+ // timeout, or another session entirely — can collect it with `resumeJobId`.
334
+ //
335
+ // A server older than the jobs route answers 404, and the call falls back to the
336
+ // old blocking request, so an agent released before the server deploys still works.
337
+
338
+ export type DeferredKind = "image" | "speech" | "music" | "sfx";
339
+ const DEFERRED_SYNC_PATH: Record<DeferredKind, string> = {
340
+ image: "/api/agent/media/images",
341
+ speech: "/api/audio/speech",
342
+ music: "/api/audio/music",
343
+ sfx: "/api/audio/sfx",
344
+ };
345
+ const DEFERRED_TOOL: Record<DeferredKind, string> = {
346
+ image: "generate_image",
347
+ speech: "generate_speech",
348
+ music: "generate_music",
349
+ sfx: "generate_sfx",
350
+ };
351
+ const DEFERRED_POLL_MS = 3_000;
352
+ // The server holds a result for an hour; a key older than that opens nothing.
353
+ const DEFERRED_KEY_TTL_MS = 2 * 60 * 60_000;
354
+
355
+ function deferredKeyDir(): string {
356
+ return join(globalDir(), "media-jobs");
357
+ }
358
+
359
+ function saveDeferredKey(jobId: string, kind: DeferredKind, key: Buffer): void {
360
+ const dir = deferredKeyDir();
361
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
362
+ // Prune expired keys while we are here — nothing else ever reads them.
363
+ try {
364
+ for (const f of readdirSync(dir)) {
365
+ const p = join(dir, f);
366
+ try {
367
+ const { createdAt } = JSON.parse(readFileSync(p, "utf8")) as { createdAt?: number };
368
+ if (!createdAt || Date.now() - createdAt > DEFERRED_KEY_TTL_MS) unlinkSync(p);
369
+ } catch { /* unreadable — leave it */ }
370
+ }
371
+ } catch { /* best effort */ }
372
+ writeFileSync(join(dir, `${jobId}.json`), JSON.stringify({ kind, key: key.toString("base64"), createdAt: Date.now() }), { mode: 0o600 });
373
+ }
374
+
375
+ function loadDeferredKey(jobId: string): { kind: DeferredKind; key: Buffer } | null {
376
+ if (!/^amj_[0-9a-f]+$/.test(jobId)) return null;
377
+ try {
378
+ const raw = JSON.parse(readFileSync(join(deferredKeyDir(), `${jobId}.json`), "utf8")) as { kind: DeferredKind; key: string };
379
+ return { kind: raw.kind, key: Buffer.from(raw.key, "base64") };
380
+ } catch {
381
+ return null;
382
+ }
383
+ }
384
+
385
+ function forgetDeferredKey(jobId: string): void {
386
+ try { unlinkSync(join(deferredKeyDir(), `${jobId}.json`)); } catch { /* already gone */ }
387
+ }
388
+
389
+ /** Open the server's iv(12) | tag(16) | ciphertext. Throws on a wrong key or tampering. */
390
+ export function openSealedResult(key: Buffer, sealedB64: string): { status: number; body: string } {
391
+ const buf = Buffer.from(sealedB64, "base64");
392
+ const d = createDecipheriv("aes-256-gcm", key, buf.subarray(0, 12));
393
+ d.setAuthTag(buf.subarray(12, 28));
394
+ return JSON.parse(Buffer.concat([d.update(buf.subarray(28)), d.final()]).toString("utf8"));
395
+ }
396
+
397
+ export function deferredResumeHint(kind: DeferredKind, jobId: string): string {
398
+ return `Collect it with ${DEFERRED_TOOL[kind]} { resumeJobId: "${jobId}", path: <same path> } — that waits on this same job and bills nothing further.`;
399
+ }
400
+
401
+ interface DeferredStatus {
402
+ status?: string;
403
+ kind?: string;
404
+ httpStatus?: number;
405
+ sealed?: string;
406
+ message?: string;
407
+ }
408
+
409
+ type DeferredResult<T> = ({ ok: true; data: T }) | AccountFailure | { ok: "started"; jobId: string };
410
+
411
+ /**
412
+ * Run a generation as a deferred job and return what the blocking call would have.
413
+ * `resumeJobId` skips the submit and only waits; `wait: false` returns as soon as
414
+ * the job is accepted, so a caller can start several and collect them later.
415
+ */
416
+ async function callDeferred<T>(
417
+ kind: DeferredKind,
418
+ body: unknown,
419
+ opts: { signal?: AbortSignal; resumeJobId?: string; wait?: boolean },
420
+ ): Promise<DeferredResult<T>> {
421
+ let jobId = String(opts.resumeJobId ?? "").trim();
422
+ let key: Buffer;
423
+ if (jobId) {
424
+ const held = loadDeferredKey(jobId);
425
+ if (!held) {
426
+ return { ok: false, message: `no key for job ${jobId} on this machine — it was started elsewhere, already collected, or is over two hours old. Its result cannot be opened here.` };
427
+ }
428
+ if (held.kind !== kind) {
429
+ return { ok: false, message: `job ${jobId} is a ${held.kind} job; collect it with ${DEFERRED_TOOL[held.kind]}.` };
430
+ }
431
+ key = held.key;
432
+ } else {
433
+ key = randomBytes(32);
434
+ const submitted = await callAccount<{ jobId?: string }>("/api/agent/media/jobs", {
435
+ method: "POST",
436
+ signal: opts.signal,
437
+ body: { kind, sealKey: key.toString("base64"), request: body },
438
+ });
439
+ if (!submitted.ok) {
440
+ // A server from before the jobs route: do it the old, blocking way.
441
+ if (submitted.status === 404 || submitted.status === 405) {
442
+ return callAccount<T>(DEFERRED_SYNC_PATH[kind], { method: "POST", body, signal: opts.signal });
443
+ }
444
+ return submitted;
445
+ }
446
+ jobId = String(submitted.data.jobId ?? "");
447
+ if (!jobId) return { ok: false, message: "Privateer accepted the job but returned no job id" };
448
+ saveDeferredKey(jobId, kind, key);
449
+ if (opts.wait === false) return { ok: "started", jobId };
450
+ }
451
+
452
+ const hint = deferredResumeHint(kind, jobId);
453
+ const deadline = Date.now() + MEDIA_HTTP_TIMEOUT_MS;
454
+ let failures = 0;
455
+ while (Date.now() < deadline) {
456
+ if (opts.signal?.aborted) {
457
+ return { ok: false, message: `stopped waiting — job ${jobId} keeps running on the server. ${hint}` };
458
+ }
459
+ const poll = await callAccount<DeferredStatus>(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "GET", signal: opts.signal });
460
+ if (!poll.ok) {
461
+ if (spritePollWorthRetrying(poll.status) && ++failures < SPRITE_POLL_FAILURE_BUDGET) {
462
+ await sleep(DEFERRED_POLL_MS * 2, opts.signal);
463
+ continue;
464
+ }
465
+ if (poll.status === 404) forgetDeferredKey(jobId);
466
+ return { ok: false, status: poll.status, message: `job ${jobId} could not be collected: ${poll.message}${poll.status === 404 ? "" : `. ${hint}`}` };
467
+ }
468
+ failures = 0;
469
+ const status = String(poll.data.status ?? "");
470
+ if (status === "failed") {
471
+ forgetDeferredKey(jobId);
472
+ return { ok: false, message: poll.data.message ?? `job ${jobId} failed on the server` };
473
+ }
474
+ if (status === "done" && poll.data.sealed) {
475
+ let opened: { status: number; body: string };
476
+ try {
477
+ opened = openSealedResult(key, poll.data.sealed);
478
+ } catch {
479
+ return { ok: false, message: `job ${jobId}'s result could not be opened with this machine's key` };
480
+ }
481
+ forgetDeferredKey(jobId);
482
+ // Ack so the server drops the ciphertext now rather than at the TTL.
483
+ void callAccount(`/api/agent/media/jobs/${encodeURIComponent(jobId)}`, { method: "DELETE" }).catch(() => {});
484
+ return interpretAccountResponse<T>(new Response(opened.body, { status: opened.status, headers: { "Content-Type": "application/json" } }));
485
+ }
486
+ await sleep(DEFERRED_POLL_MS, opts.signal);
487
+ }
488
+ return { ok: false, message: `job ${jobId} is still running after ${Math.round(MEDIA_HTTP_TIMEOUT_MS / 60_000)} minutes. ${hint}` };
489
+ }
490
+
491
+ /** Shared parameters every deferred tool takes. */
492
+ const deferredParams = {
493
+ resumeJobId: Type.Optional(Type.String({
494
+ description:
495
+ "Collect a job started earlier (with wait:false, or one that timed out) instead of starting a new one. " +
496
+ "Nothing is generated and nothing is billed: it only waits and saves. Other generation fields are ignored; `path` still says where to save.",
497
+ })),
498
+ wait: Type.Optional(Type.Boolean({
499
+ description:
500
+ "Default true. Set false to start the generation and return its job id immediately, so you can start " +
501
+ "several at once and collect each later with resumeJobId. The job runs on Privateer's servers either way.",
502
+ })),
503
+ };
504
+
505
+ function startedText(kind: DeferredKind, jobId: string): string {
506
+ return `Started ${kind} job ${jobId}; it is generating on Privateer's servers (results are held for up to an hour, sealed to this machine). ${deferredResumeHint(kind, jobId)}`;
507
+ }
508
+
264
509
  // ── Images ───────────────────────────────────────────────────────────────────
265
510
 
266
511
  interface ImageResponse {
@@ -276,7 +521,7 @@ export const generateImageToolDefinition = {
276
521
  "images already on disk — to EDIT or COMPOSE instead: one input means 'change this picture per the " +
277
522
  "prompt', several means 'combine these into one'. Billed to the user's Privateer account and " +
278
523
  "subject to its privacy settings; the prompt and any input images pass through Privateer's servers " +
279
- "in plaintext, but nothing is stored there — the only copy is the file you name. Use the resulting " +
524
+ "in plaintext. The finished result is held on the server for up to an hour, encrypted to a key only this machine holds, until it is collected — the only readable copy is the file you name. Use the resulting " +
280
525
  "path as the first frame of generate_video, or as slideshow material for video_compose.",
281
526
  parameters: Type.Object({
282
527
  prompt: Type.String({ description: "What the image should show. Be specific about subject, style, lighting and framing." }),
@@ -292,40 +537,39 @@ export const generateImageToolDefinition = {
292
537
  aspectRatio: Type.Optional(Type.String({ description: "Aspect ratio, e.g. '16:9', '9:16', '1:1'. Defaults to the model's own." })),
293
538
  size: Type.Optional(Type.String({ description: "Explicit pixel size if the model supports one, e.g. '1024x1024'." })),
294
539
  model: Type.Optional(Type.String({ description: "Override the image model (e.g. 'google/gemini-3.1-flash-image'). Leave unset to use the one the user chose with /image-model, or the account default." })),
540
+ ...deferredParams,
295
541
  }),
296
542
  async execute(
297
543
  _toolCallId: string,
298
- params: { prompt: string; path: string; images?: string[]; count?: number; aspectRatio?: string; size?: string; model?: string },
544
+ params: { prompt: string; path: string; images?: string[]; count?: number; aspectRatio?: string; size?: string; model?: string; resumeJobId?: string; wait?: boolean },
299
545
  signal?: AbortSignal,
300
546
  _onUpdate?: unknown,
301
547
  ctx?: { cwd?: string },
302
548
  ) {
303
549
  const cwd = ctx?.cwd ?? process.cwd();
304
550
  const prompt = String(params.prompt ?? "").trim();
305
- if (!prompt) return text("Error: prompt is required.");
551
+ const resuming = Boolean(String(params.resumeJobId ?? "").trim());
552
+ if (!prompt && !resuming) return text("Error: prompt is required.");
306
553
  if (!params.path) return text("Error: path is required — say where to save the image.");
307
554
 
308
555
  let inputs: { data: string; mimeType: string }[];
309
556
  try {
310
- inputs = (params.images ?? []).map((p) => readInputImage(cwd, p));
557
+ inputs = resuming ? [] : (params.images ?? []).map((p) => readInputImage(cwd, p));
311
558
  } catch (e) {
312
559
  return text(`Error: ${e instanceof Error ? e.message : String(e)}`);
313
560
  }
314
561
 
315
562
  const count = Math.min(Math.max(1, Math.round(params.count ?? 1)), 4);
316
563
  const model = effectiveMediaModel("image", params.model);
317
- const r = await callAccount<ImageResponse>("/api/agent/media/images", {
318
- method: "POST",
319
- signal,
320
- body: {
321
- prompt,
322
- n: count,
323
- ...(inputs.length ? { images: inputs } : {}),
324
- ...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
325
- ...(params.size ? { imageSize: params.size } : {}),
326
- ...(model ? { model } : {}),
327
- },
328
- });
564
+ const r = await callDeferred<ImageResponse>("image", {
565
+ prompt,
566
+ n: count,
567
+ ...(inputs.length ? { images: inputs } : {}),
568
+ ...(params.aspectRatio ? { aspectRatio: params.aspectRatio } : {}),
569
+ ...(params.size ? { imageSize: params.size } : {}),
570
+ ...(model ? { model } : {}),
571
+ }, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
572
+ if (r.ok === "started") return text(startedText("image", r.jobId));
329
573
  if (!r.ok) return text(`Image generation failed: ${r.message}`);
330
574
 
331
575
  const images = r.data.images ?? [];
@@ -344,7 +588,7 @@ export const generateImageToolDefinition = {
344
588
  }
345
589
  if (written.length === 0) return text("Image generation returned no usable image data.");
346
590
 
347
- const verb = inputs.length ? (inputs.length === 1 ? "Edited" : "Composed") : "Generated";
591
+ const verb = resuming ? "Collected" : inputs.length ? (inputs.length === 1 ? "Edited" : "Composed") : "Generated";
348
592
  return text(`${verb} ${written.length} image${written.length === 1 ? "" : "s"} with ${r.data.model ?? "the account image model"}:\n${written.map((w) => ` ${w}`).join("\n")}`);
349
593
  },
350
594
  };
@@ -782,28 +1026,26 @@ export const generateSpeechToolDefinition = {
782
1026
  "refused with VOICE_UNSUPPORTED rather than silently served on a different voice.",
783
1027
  })),
784
1028
  model: Type.Optional(Type.String({ description: "Override the account's text-to-speech model." })),
1029
+ ...deferredParams,
785
1030
  }),
786
1031
  async execute(
787
1032
  _toolCallId: string,
788
- params: { text: string; path: string; voice?: string; model?: string },
1033
+ params: { text: string; path: string; voice?: string; model?: string; resumeJobId?: string; wait?: boolean },
789
1034
  signal?: AbortSignal,
790
1035
  _onUpdate?: unknown,
791
1036
  ctx?: { cwd?: string },
792
1037
  ) {
793
1038
  const cwd = ctx?.cwd ?? process.cwd();
794
1039
  const body = String(params.text ?? "").trim();
795
- if (!body) return text("Error: text is required.");
1040
+ if (!body && !String(params.resumeJobId ?? "").trim()) return text("Error: text is required.");
796
1041
  if (!params.path) return text("Error: path is required — say where to save the audio.");
797
1042
 
798
- const r = await callAccount<AudioResponse>("/api/audio/speech", {
799
- method: "POST",
800
- signal,
801
- body: {
802
- text: body,
803
- ...(params.voice ? { voice: params.voice } : {}),
804
- ...(params.model ? { ttsModelId: params.model } : {}),
805
- },
806
- });
1043
+ const r = await callDeferred<AudioResponse>("speech", {
1044
+ text: body,
1045
+ ...(params.voice ? { voice: params.voice } : {}),
1046
+ ...(params.model ? { ttsModelId: params.model } : {}),
1047
+ }, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
1048
+ if (r.ok === "started") return text(startedText("speech", r.jobId));
807
1049
  if (!r.ok) return text(`Speech generation failed: ${r.message}`);
808
1050
  if (!r.data.audioBase64) return text("Speech generation returned no audio.");
809
1051
 
@@ -834,24 +1076,26 @@ export const generateMusicToolDefinition = {
834
1076
  prompt: Type.String({ description: "The music to generate: genre, mood, instrumentation, tempo. Keep it about the music, not about the user." }),
835
1077
  path: Type.String({ description: "Where to write the audio, relative to cwd or absolute (e.g. 'audio/score.mp3')." }),
836
1078
  model: Type.Optional(Type.String({ description: "Override the account's music model." })),
1079
+ ...deferredParams,
837
1080
  }),
838
1081
  async execute(
839
1082
  _toolCallId: string,
840
- params: { prompt: string; path: string; model?: string },
1083
+ params: { prompt: string; path: string; model?: string; resumeJobId?: string; wait?: boolean },
841
1084
  signal?: AbortSignal,
842
1085
  _onUpdate?: unknown,
843
1086
  ctx?: { cwd?: string },
844
1087
  ) {
845
1088
  const cwd = ctx?.cwd ?? process.cwd();
846
1089
  const prompt = String(params.prompt ?? "").trim();
847
- if (!prompt) return text("Error: prompt is required.");
1090
+ if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
848
1091
  if (!params.path) return text("Error: path is required — say where to save the audio.");
849
1092
 
850
- const r = await callAccount<AudioResponse>("/api/audio/music", {
851
- method: "POST",
852
- signal,
853
- body: { prompt, ...(params.model ? { musicModelId: params.model } : {}) },
854
- });
1093
+ const r = await callDeferred<AudioResponse>(
1094
+ "music",
1095
+ { prompt, ...(params.model ? { musicModelId: params.model } : {}) },
1096
+ { signal, resumeJobId: params.resumeJobId, wait: params.wait },
1097
+ );
1098
+ if (r.ok === "started") return text(startedText("music", r.jobId));
855
1099
  if (!r.ok) return text(`Music generation failed: ${r.message}`);
856
1100
  if (!r.data.audioBase64) return text("Music generation returned no audio.");
857
1101
 
@@ -907,17 +1151,18 @@ export const generateSfxToolDefinition = {
907
1151
  "foley in the catalog; the default is a cheaper model tuned for single sounds.",
908
1152
  }),
909
1153
  ),
1154
+ ...deferredParams,
910
1155
  }),
911
1156
  async execute(
912
1157
  _toolCallId: string,
913
- params: { prompt: string; path: string; seconds?: number; model?: string },
1158
+ params: { prompt: string; path: string; seconds?: number; model?: string; resumeJobId?: string; wait?: boolean },
914
1159
  signal?: AbortSignal,
915
1160
  _onUpdate?: unknown,
916
1161
  ctx?: { cwd?: string },
917
1162
  ) {
918
1163
  const cwd = ctx?.cwd ?? process.cwd();
919
1164
  const prompt = String(params.prompt ?? "").trim();
920
- if (!prompt) return text("Error: prompt is required.");
1165
+ if (!prompt && !String(params.resumeJobId ?? "").trim()) return text("Error: prompt is required.");
921
1166
  if (!params.path) return text("Error: path is required — say where to save the audio.");
922
1167
 
923
1168
  let seconds: number | undefined;
@@ -935,15 +1180,12 @@ export const generateSfxToolDefinition = {
935
1180
  seconds = Math.round(seconds);
936
1181
  }
937
1182
 
938
- const r = await callAccount<AudioResponse>("/api/audio/sfx", {
939
- method: "POST",
940
- signal,
941
- body: {
942
- prompt,
943
- ...(seconds != null ? { duration: seconds } : {}),
944
- ...(params.model ? { sfxModelId: params.model } : {}),
945
- },
946
- });
1183
+ const r = await callDeferred<AudioResponse>("sfx", {
1184
+ prompt,
1185
+ ...(seconds != null ? { duration: seconds } : {}),
1186
+ ...(params.model ? { sfxModelId: params.model } : {}),
1187
+ }, { signal, resumeJobId: params.resumeJobId, wait: params.wait });
1188
+ if (r.ok === "started") return text(startedText("sfx", r.jobId));
947
1189
  if (!r.ok) return text(`Sound-effect generation failed: ${r.message}`);
948
1190
  if (!r.data.audioBase64) return text("Sound-effect generation returned no audio.");
949
1191