privateer-agent 0.15.3 → 0.15.4

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.
@@ -63,6 +63,41 @@ function piBusy(): boolean {
63
63
  try { return ctxRef?.isIdle?.() === false; } catch { return false; }
64
64
  }
65
65
 
66
+ // Messages the app sent mid-turn that Pi has queued as steers but the model has not
67
+ // read yet. Pi only reads a steer between steps, and a turn that ends on an error or
68
+ // a Stop leaves it in the queue — the app showed "Added to the running turn" and then
69
+ // nothing, so a driver had no way to tell a read message from a lost one. Each entry
70
+ // is cleared (and the app told) when Pi puts it in the transcript.
71
+ let unreadSteers: string[] = [];
72
+ let stopRequested = false;
73
+ const userText = (m: any): string =>
74
+ typeof m?.content === "string"
75
+ ? m.content
76
+ : Array.isArray(m?.content) ? m.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("\n") : "";
77
+
78
+ // After a run ends, wait for Pi to settle (it may start another run to drain the
79
+ // queue itself), then say so if an app message is still sitting unread.
80
+ function reportStrandedSteers(): void {
81
+ const stopped = stopRequested;
82
+ stopRequested = false;
83
+ if (!unreadSteers.length) return;
84
+ const startedAt = Date.now();
85
+ const check = () => {
86
+ if (!unreadSteers.length) return;
87
+ if (piBusy()) {
88
+ if (Date.now() - startedAt < 10_000) setTimeout(check, 250).unref?.();
89
+ return; // still running: the next step or agent_end reads/reports it
90
+ }
91
+ const n = unreadSteers.length;
92
+ const what = n === 1 ? "Your last message" : `Your last ${n} messages`;
93
+ relay?.sendNotice(
94
+ `${what} arrived after the agent's final step${stopped ? " (you stopped the turn)" : ""}, so it wasn't read. ` +
95
+ "It will go to the agent with your next prompt.",
96
+ );
97
+ };
98
+ setTimeout(check, 250).unref?.();
99
+ }
100
+
66
101
  let piRef: any = null;
67
102
  let relay: any = null;
68
103
  // Pi-extension manager for the app's extensions screen. Built lazily on first use
@@ -381,7 +416,10 @@ const bridge = new RemoteBridge({
381
416
  relay?.sendNotice(`Couldn't attach: ${mentions.skipped.join(", ")} (must be a file inside ${cwd})`);
382
417
  }
383
418
  const body = mentions.text + note;
384
- if (piBusy()) relay?.sendNotice("Added to the running turn — the agent reads it before its next step.");
419
+ if (piBusy()) {
420
+ unreadSteers.push(body);
421
+ relay?.sendNotice("Added to the running turn — the agent reads it before its next step.");
422
+ }
385
423
  // A message sent mid-turn STEERS it, as in the REPL and the desktop session.
386
424
  // This used to be refused with "busy — a turn is already running" behind a flag
387
425
  // that only agent_end cleared — but the extension API's sendUserMessage is
@@ -402,7 +440,17 @@ const bridge = new RemoteBridge({
402
440
  }
403
441
  });
404
442
  },
405
- onInterrupt: () => {}, // Pi owns interrupt; best-effort no-op
443
+ // The app's Stop. This was a no-op, so Stop only closed the turn in the app while the
444
+ // agent kept working — and the next message from the app landed as a steer on a turn
445
+ // the driver believed was over.
446
+ onInterrupt: () => {
447
+ try {
448
+ if (ctxRef?.isIdle?.() === false) {
449
+ stopRequested = true;
450
+ ctxRef.abort();
451
+ }
452
+ } catch { /* a replaced session's context throws — nothing to stop */ }
453
+ },
406
454
  // The app asked to end remote access from its side — stop the relay locally too so
407
455
  // the terminal doesn't keep reconnecting, and clear the green indicator.
408
456
  onTerminate: () => disableRemote(),
@@ -667,6 +715,8 @@ export default function privateerControl(pi: any): void {
667
715
  // remote access was already on when the session (re)started.
668
716
  if (ctx?.ui) uiRef = ctx.ui;
669
717
  ctxRef = ctx;
718
+ unreadSteers = []; // a new session has a new queue
719
+ stopRequested = false;
670
720
  // Capture the model registry + launch model so the app's /model picker has this
671
721
  // machine's real catalog and the banner shows the current spec from the start.
672
722
  if (ctx?.modelRegistry) modelReg = ctx.modelRegistry;
@@ -796,6 +846,14 @@ export default function privateerControl(pi: any): void {
796
846
  });
797
847
  pi.on("message_start", (ev: any) => {
798
848
  if (headlessRun && ev?.message?.role === "assistant") watchdog.arm();
849
+ // A queued app message entering the transcript — the model reads it this step.
850
+ // Match by text; an input handler (the privacy filter) may have rewritten it, in
851
+ // which case the oldest unread one is the one Pi just delivered (FIFO).
852
+ if (ev?.message?.role === "user" && unreadSteers.length) {
853
+ const i = unreadSteers.indexOf(userText(ev.message));
854
+ unreadSteers.splice(i >= 0 ? i : 0, 1);
855
+ relay?.sendNotice("The agent read your message.");
856
+ }
799
857
  });
800
858
  pi.on("message_update", () => {
801
859
  if (headlessRun) watchdog.arm();
@@ -840,6 +898,7 @@ export default function privateerControl(pi: any): void {
840
898
  pi.on("agent_end", (ev: any) => {
841
899
  fwd(ev);
842
900
  bridge.settleTurn();
901
+ reportStrandedSteers();
843
902
  });
844
903
 
845
904
  pi.registerCommand?.("mode", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.15.3",
3
+ "version": "0.15.4",
4
4
  "description": "Privacy-first terminal coding agent — bring your own model across 20 providers (Anthropic, OpenAI, OpenRouter, Google, local Ollama…). Safe-by-default permissions, MCP, sub-agents, workflows, and verifiable TEE inference. Built on the Pi toolkit.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$comment": "The banner's What's New list, newest first: the two to four things a returning user should notice, not a changelog. `reviewedFor` is the release this list was last looked at for. A release can't ship until it equals package.json's version (scripts/check-whats-new.mjs, run by `npm test` and by release.yml before anything builds), so every release, patches included, is a deliberate look at this list. If nothing new is worth a line, bumping `reviewedFor` alone is the answer. `since` is the release an entry arrived in. `cmd` is shown in the accent colour.",
3
- "reviewedFor": "0.15.3",
3
+ "reviewedFor": "0.15.4",
4
4
  "items": [
5
+ { "text": "Slow images, speech and music run as background jobs — a dropped connection no longer loses a paid result", "since": "0.15.4" },
5
6
  { "text": "Driving from the app: a message sent mid-turn steers it, and questions survive a dropped link", "since": "0.15.1" },
6
7
  { "text": "MCP on Pi's built-in support — sign OAuth connectors back in with", "cmd": "/mcp", "since": "0.15.0" },
7
- { "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" },
8
- { "text": "API keys can expire and carry their own credit limit — set them in the app", "since": "0.14.6" }
8
+ { "text": "Sub-agents are back: hand work to bounded parallel agents", "since": "0.15.0" }
9
9
  ]
10
10
  }
@@ -234,6 +234,14 @@ const MEDIA_TITLES: Record<string, string> = {
234
234
  // rather than their names (see `resuming` below).
235
235
  const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
236
236
  const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
237
+ const RESUME_TITLES: Record<string, string> = {
238
+ generate_video: RESUME_VIDEO_TITLE,
239
+ generate_sprite: RESUME_SPRITE_TITLE,
240
+ generate_image: "Save an image already generated (nothing further is billed)",
241
+ generate_speech: "Save speech already generated (nothing further is billed)",
242
+ generate_music: "Save music already generated (nothing further is billed)",
243
+ generate_sfx: "Save a sound effect already generated (nothing further is billed)",
244
+ };
237
245
 
238
246
  // Pi's MCP resource tools (core/mcp-servers.js). They list and read resources from
239
247
  // every connected server — network reads from connectors the user configured.
@@ -533,11 +541,10 @@ export function classifyToolCall(
533
541
  // cheaper path than re-generating. Getting that backwards is what teaches a model
534
542
  // to pay twice — and it costs the most on a sprite, where the alternative to a
535
543
  // resume is a whole second fan-out of up to five video generations.
536
- const resumable = name === "generate_video" || name === "generate_sprite";
544
+ // Image, speech, music and sfx became deferred jobs too, so the same holds for them.
545
+ const resumable = RESUME_TITLES[name] !== undefined;
537
546
  const resuming = resumable && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
538
- const mediaTitle = resuming
539
- ? (name === "generate_sprite" ? RESUME_SPRITE_TITLE : RESUME_VIDEO_TITLE)
540
- : MEDIA_TITLES[name];
547
+ const mediaTitle = resuming ? RESUME_TITLES[name] : MEDIA_TITLES[name];
541
548
  const inputs = [
542
549
  ...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
543
550
  str(obj.input),
@@ -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