privateer-agent 0.12.39 → 0.12.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "privateer-agent",
3
- "version": "0.12.39",
3
+ "version": "0.12.40",
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",
@@ -5,9 +5,10 @@
5
5
  // only a CLEAN exit revoked it (session_shutdown → revokeLocalSessions). A terminal
6
6
  // that dies without running its shutdown hook — SIGKILL, a closed window, a crash,
7
7
  // `kill` — leaves its session row alive server-side for the rest of its ~24h TTL. Do
8
- // that a few times and the next spawn is refused with
9
- // `429 CHILD_SESSION_CAP: Too many active terminals for this device`, which takes the
10
- // whole account channel down until the rows age out.
8
+ // that a few times and the account accumulates a pile of live rows nobody owns. (There
9
+ // used to be a per-device cap that refused the next spawn outright, which took the whole
10
+ // account channel down until the rows aged out; it is gone, and this is what keeps the
11
+ // row count sane without one.)
11
12
  //
12
13
  // The fix is to reclaim an orphan instead of stacking another row on top of it. That
13
14
  // needs one bit the credential itself can't tell us: is the terminal that owns it
@@ -158,8 +158,9 @@ let _refreshInFlight: Promise<ChildSession> | null = null;
158
158
  //
159
159
  // That pairing only covers a CLEAN exit, though. A terminal killed without running its
160
160
  // shutdown hook leaves its row alive server-side for the full TTL, and the next launch
161
- // used to spawn another on top of it — enough repeats and the spawn is refused with
162
- // `429 CHILD_SESSION_CAP`. So every session is also recorded in a pid-keyed registry
161
+ // used to spawn another on top of it — so a few crashes left a pile of live rows nobody
162
+ // owned (which used to be refused outright by a per-device cap the server has since
163
+ // dropped). So every session is also recorded in a pid-keyed registry
163
164
  // (auth/accountSessions.ts) and acquireAccountCredential reclaims one whose owning
164
165
  // terminal is gone instead of spawning. Keep the registry in step with reality:
165
166
  // recordOwnedSession wherever a credential is minted or rotated, forgetOwnedSession
@@ -445,9 +446,8 @@ export async function runDeviceLogin(opts: {
445
446
  //
446
447
  // A 401 means the parent refresh token is gone — the machine login itself is dead, so
447
448
  // clear it and announce (the UI flips to signed-out). EVERY OTHER status used to be
448
- // reported as an expiry too, which actively misled: the common one is 429
449
- // `CHILD_SESSION_CAP` ("Too many active terminals for this device. Sign one out and
450
- // try again"), where /login is not the fix and the credentials are perfectly valid.
449
+ // reported as an expiry too, which actively misled: a rate limit or a server-side
450
+ // refusal is not something /login fixes, and the credentials are perfectly valid.
451
451
  // Pass the server's own message through so the user learns what to actually do.
452
452
  async function spawnFailure(res: Response): Promise<Error> {
453
453
  if (res.status === 401) {
@@ -225,9 +225,10 @@ const MEDIA_TITLES: Record<string, string> = {
225
225
  video_compose: "Compose video/audio locally",
226
226
  media_capabilities: "Read media capabilities",
227
227
  };
228
- // Not in MEDIA_TITLES: it is the same tool, told apart by its arguments rather
229
- // than its name (see `resuming` below).
228
+ // Not in MEDIA_TITLES: they are the same tools, told apart by their arguments
229
+ // rather than their names (see `resuming` below).
230
230
  const RESUME_VIDEO_TITLE = "Save a video already generated (nothing further is billed)";
231
+ const RESUME_SPRITE_TITLE = "Save a sprite animation already generated (nothing further is billed)";
231
232
 
232
233
  export function classifyToolCall(
233
234
  toolName: string,
@@ -481,14 +482,18 @@ export function classifyToolCall(
481
482
  // even when the output lands neatly in cwd.
482
483
  if (MEDIA_TOOLS.has(name)) {
483
484
  const compose = name === "video_compose";
484
- // A generate_video RESUME submits nothing and bills nothing — it goes back to
485
- // waiting on a job the account has already paid for and writes the file. So it
486
- // is an ordinary write, not a billed one: the title must not claim a charge
487
- // that isn't happening, and `alwaysAsk` must not make re-prompting the cheaper
488
- // path than re-generating. Getting that backwards is what teaches a model to
489
- // pay twice.
490
- const resuming = name === "generate_video" && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
491
- const mediaTitle = resuming ? RESUME_VIDEO_TITLE : MEDIA_TITLES[name];
485
+ // A generate_video or generate_sprite RESUME submits nothing and bills nothing —
486
+ // it goes back to waiting on a job the account has already paid for and writes the
487
+ // files. So it is an ordinary write, not a billed one: the title must not claim a
488
+ // charge that isn't happening, and `alwaysAsk` must not make re-prompting the
489
+ // cheaper path than re-generating. Getting that backwards is what teaches a model
490
+ // to pay twice — and it costs the most on a sprite, where the alternative to a
491
+ // resume is a whole second fan-out of up to five video generations.
492
+ const resumable = name === "generate_video" || name === "generate_sprite";
493
+ const resuming = resumable && typeof obj.resumeJobId === "string" && !!obj.resumeJobId.trim();
494
+ const mediaTitle = resuming
495
+ ? (name === "generate_sprite" ? RESUME_SPRITE_TITLE : RESUME_VIDEO_TITLE)
496
+ : MEDIA_TITLES[name];
492
497
  const inputs = [
493
498
  ...(Array.isArray(obj.inputs) ? (obj.inputs as unknown[]).map(str) : []),
494
499
  str(obj.input),
@@ -38,8 +38,9 @@
38
38
  // stitch → score → send.
39
39
 
40
40
  import { Type } from "typebox";
41
- import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
42
- import { dirname, extname, isAbsolute, resolve } from "node:path";
41
+ import { mkdirSync, readFileSync, writeFileSync, unlinkSync, existsSync, statSync } from "node:fs";
42
+ import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
43
+ import { tmpdir } from "node:os";
43
44
  import { apiRequest } from "../auth/privateer.ts";
44
45
 
45
46
  /** Tool names these definitions register, for allow-list construction. */
@@ -79,10 +80,14 @@ const VIDEO_POLL_INTERVAL_MS = 5_000;
79
80
  // id is reported on timeout so the caller can resume the poll rather than pay
80
81
  // for a second generation.
81
82
  const MESH_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_MESH_TIMEOUT_MS) || 10 * 60_000;
82
- // A sprite job renders one clip per BILLED facing, sequentially, so an eight-way
83
- // set waits on five video generations rather than one. The ceiling is
84
- // correspondingly generous; as with video, the job id is reported on timeout so
85
- // the caller can resume the poll rather than pay for another run.
83
+ // A sprite job renders one clip per BILLED facing, so an eight-way set waits on
84
+ // five video generations rather than one. They are SUBMITTED in one pass and
85
+ // render together at the provider (spriteApiHandler submits the whole fan-out
86
+ // before returning the 202), so the wait is roughly one clip's — what is serial
87
+ // is the stage BEFORE it, where the picture is turned to face each direction one
88
+ // edit at a time. The ceiling is correspondingly generous; as with video, the job
89
+ // id is reported on timeout and `resumeJobId` is what goes back to it, so a slow
90
+ // job is never a reason to pay for a second fan-out.
86
91
  const SPRITE_POLL_TIMEOUT_MS = Number(process.env.PRIVATEER_SPRITE_TIMEOUT_MS) || 25 * 60_000;
87
92
  const SPRITE_POLL_INTERVAL_MS = 6_000;
88
93
  const MESH_POLL_INTERVAL_MS = 5_000;
@@ -150,6 +155,15 @@ function readInputImage(cwd: string, p: string): { data: string; mimeType: strin
150
155
  interface AccountFailure {
151
156
  ok: false;
152
157
  message: string;
158
+ /**
159
+ * The HTTP status behind the failure, or undefined when the request never got
160
+ * an answer at all (DNS, reset, offline). A POLLER needs this and a one-shot
161
+ * caller does not: abandoning a paid job because one poll returned 502 throws
162
+ * the job away, while retrying a 404 or a 410 forever is just as wrong. The
163
+ * status is the only thing that separates the two, so it is carried rather
164
+ * than flattened into the message.
165
+ */
166
+ status?: number;
153
167
  }
154
168
 
155
169
  /**
@@ -197,31 +211,31 @@ async function callAccount<T>(
197
211
  // by the user (not by the model) — say exactly which switch to change and stop.
198
212
  if (code === "ZDR_MEDIA_BLOCKED") {
199
213
  return {
200
- ok: false,
214
+ ok: false, status: res.status,
201
215
  message:
202
216
  (serverMessage || "this account requires Zero Data Retention and the media model has no ZDR endpoint") +
203
217
  " — this is a privacy setting only the account owner can change (Settings → Privacy), so do not retry.",
204
218
  };
205
219
  }
206
220
  if (code === "ZDR_KEY_UNAVAILABLE") {
207
- return { ok: false, message: serverMessage || "no zero-retention provider key is available right now — try again later" };
221
+ return { ok: false, status: res.status, message: serverMessage || "no zero-retention provider key is available right now — try again later" };
208
222
  }
209
223
  if (/DAILY_CAP|LIMIT_REACHED/i.test(code) || res.status === 429) {
210
- return { ok: false, message: serverMessage || "the account's daily media allowance is used up — it resets tomorrow" };
224
+ return { ok: false, status: res.status, message: serverMessage || "the account's daily media allowance is used up — it resets tomorrow" };
211
225
  }
212
226
  if (res.status === 402 || /INSUFFICIENT|QUOTA|TOP_?UP/i.test(code)) {
213
- return { ok: false, message: serverMessage || "the account is out of credit for media generation — top up or upgrade to continue" };
227
+ return { ok: false, status: res.status, message: serverMessage || "the account is out of credit for media generation — top up or upgrade to continue" };
214
228
  }
215
229
  if (res.status === 401 || res.status === 403) {
216
230
  return {
217
- ok: false,
231
+ ok: false, status: res.status,
218
232
  message:
219
233
  serverMessage ||
220
234
  "this agent is not signed in to a Privateer account (or the plan doesn't include this), so it cannot generate media",
221
235
  };
222
236
  }
223
237
  if (res.status === 400 || res.status === 413) {
224
- return { ok: false, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };
238
+ return { ok: false, status: res.status, message: serverMessage || `Privateer rejected the request${code ? ` (${code})` : ""}` };
225
239
  }
226
240
  // 503/504 are OUR outage or a provider timing out, not a bad request: an unset
227
241
  // provider key, our own balance with that provider, or a slow job. Retrying the same
@@ -229,11 +243,11 @@ async function callAccount<T>(
229
243
  // good prompt in the belief it caused this.
230
244
  if (res.status === 503 || res.status === 504) {
231
245
  return {
232
- ok: false,
246
+ ok: false, status: res.status,
233
247
  message: `${serverMessage || "that media service is temporarily unavailable"} — this is on Privateer's side, not the prompt's; try again in a few minutes`,
234
248
  };
235
249
  }
236
- return { ok: false, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
250
+ return { ok: false, status: res.status, message: serverMessage || `media generation failed (HTTP ${res.status}${code ? ` ${code}` : ""})` };
237
251
  }
238
252
 
239
253
  // ── Images ───────────────────────────────────────────────────────────────────
@@ -1124,6 +1138,44 @@ interface SpriteStatusResponse {
1124
1138
  message?: string;
1125
1139
  }
1126
1140
 
1141
+ /**
1142
+ * Is `target` the directory `root` itself, or something inside it?
1143
+ *
1144
+ * Asked through `relative` rather than by comparing string prefixes, because
1145
+ * the obvious `target.startsWith(root + "/")` is WRONG for the one root whose
1146
+ * own spelling already ends in a separator: with root `/`, every entry in a
1147
+ * perfectly ordinary archive resolves to `/thing` and matches no prefix `//`,
1148
+ * so a bundle destined for the filesystem root was rejected entry-by-entry as
1149
+ * an escape attempt. That is not a hypothetical — an agent whose cwd is `/`
1150
+ * (an Electron app opened from the Finder, a daemon started by launchd) and a
1151
+ * `dir` of `.` lands exactly there, and the sprite bundle it had already paid
1152
+ * for was thrown away with a security error that named the wrong problem. The
1153
+ * prefix form is also separator-blind on Windows.
1154
+ */
1155
+ export function pathInside(root: string, target: string): boolean {
1156
+ const rel = relative(root, target);
1157
+ return rel === "" || (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep));
1158
+ }
1159
+
1160
+ /**
1161
+ * Turn one archive entry name into a path under `root`, or refuse it.
1162
+ *
1163
+ * Separators are normalised first so a `..\\..` written the Windows way is
1164
+ * judged as the traversal it is on every platform. The refusal names the
1165
+ * destination as well as the entry, because the entry name alone is the half
1166
+ * the reader already has: it is WHERE the archive was being unpacked to that
1167
+ * says whether the bundle or the caller's `dir` is the thing at fault.
1168
+ */
1169
+ function archiveEntryPath(name: string, root: string): string {
1170
+ const rel = name.split("\\").join("/");
1171
+ const traverses =
1172
+ rel.startsWith("/") || /^[A-Za-z]:/.test(rel) || rel.split("/").some((seg) => seg === "..");
1173
+ if (traverses || !pathInside(root, resolve(root, rel))) {
1174
+ throw new Error(`archive entry "${name}" escapes the destination directory ${root}`);
1175
+ }
1176
+ return rel;
1177
+ }
1178
+
1127
1179
  /**
1128
1180
  * Unpack the bundle into a directory.
1129
1181
  *
@@ -1132,10 +1184,10 @@ interface SpriteStatusResponse {
1132
1184
  * payload is already PNG, so deflating it twice buys nothing), which means every
1133
1185
  * entry is a header followed by its bytes verbatim.
1134
1186
  *
1135
- * ZIP-SLIP: entry names come off the wire, so each resolved path is checked to
1136
- * be inside the destination before anything is written. A `..` segment here
1137
- * would let a generated archive write anywhere the agent can reach, which on an
1138
- * unattended run is the user's whole machine.
1187
+ * ZIP-SLIP: entry names come off the wire, so each one is checked to be a plain
1188
+ * relative path landing inside the destination before anything is written. A
1189
+ * `..` segment here would let a generated archive write anywhere the agent can
1190
+ * reach, which on an unattended run is the user's whole machine.
1139
1191
  */
1140
1192
  export function extractStoredZip(zip: Buffer, destDir: string): string[] {
1141
1193
  const written: string[] = [];
@@ -1153,10 +1205,7 @@ export function extractStoredZip(zip: Buffer, destDir: string): string[] {
1153
1205
  if (method !== 0) throw new Error(`archive entry "${name}" is compressed; only stored entries are expected`);
1154
1206
  if (dataAt + size > zip.length) throw new Error(`archive entry "${name}" is truncated`);
1155
1207
 
1156
- const target = resolve(root, name);
1157
- if (target !== root && !target.startsWith(root + "/")) {
1158
- throw new Error(`archive entry "${name}" escapes the destination directory`);
1159
- }
1208
+ const target = resolve(root, archiveEntryPath(name, root));
1160
1209
  mkdirSync(dirname(target), { recursive: true });
1161
1210
  writeFileSync(target, zip.subarray(dataAt, dataAt + size));
1162
1211
  written.push(name);
@@ -1181,8 +1230,205 @@ export function extractStoredZip(zip: Buffer, destDir: string): string[] {
1181
1230
  export function guessResPath(cwd: string, dir: string): string | undefined {
1182
1231
  const target = resolve(abs(cwd, dir));
1183
1232
  const root = resolve(cwd);
1184
- if (target === root || !target.startsWith(root + "/")) return undefined;
1185
- return `res://${target.slice(root.length + 1).split("\\").join("/")}/`;
1233
+ if (target === root || !pathInside(root, target)) return undefined;
1234
+ return `res://${relative(root, target).split("\\").join("/")}/`;
1235
+ }
1236
+
1237
+ /**
1238
+ * The direction sets the server actually recognises, with what each one yields
1239
+ * and what it bills.
1240
+ *
1241
+ * Kept here as a table rather than trusted to the description, because the
1242
+ * server does not reject an unknown set — `parseSpec` falls back to 'one'. So
1243
+ * `directions: "4"`, `"four-way"` or `"down,left,right,up"` used to be accepted
1244
+ * all the way through, bill one clip, and hand back a single-facing sheet with
1245
+ * no error anywhere: the caller asked for a four-way walk cycle and got one
1246
+ * animation, which reads as the feature being broken rather than the argument
1247
+ * being wrong. The schema is a closed set now and this table is what the
1248
+ * unpacked result is checked against.
1249
+ */
1250
+ const SPRITE_DIRECTION_SETS = {
1251
+ one: { animations: 1, billed: 1 },
1252
+ four: { animations: 4, billed: 3 },
1253
+ eight: { animations: 8, billed: 5 },
1254
+ } as const;
1255
+ type SpriteDirections = keyof typeof SPRITE_DIRECTION_SETS;
1256
+
1257
+ /**
1258
+ * Whether a failed poll is worth another try.
1259
+ *
1260
+ * A poll that fails is NOT the job failing — the clips are still rendering at
1261
+ * the provider and are still being paid for. A transport error (no status at
1262
+ * all), a 429 or any 5xx is our side or the network having a bad moment, and the
1263
+ * next poll six seconds later will very likely work; abandoning the job on the
1264
+ * first one throws away up to five video generations the account has already
1265
+ * been charged for, and on this path the bundle is delivered ONCE and stored
1266
+ * nowhere, so there is nothing to go back for. A 404/410/401/402 is the
1267
+ * opposite: the job is gone, already delivered, or was never ours, and waiting
1268
+ * changes nothing.
1269
+ */
1270
+ export function spritePollWorthRetrying(status?: number): boolean {
1271
+ return status === undefined || status === 429 || status >= 500;
1272
+ }
1273
+
1274
+ // How many consecutive failed polls to ride out before giving up. Six seconds
1275
+ // apart, so this is a minute of Privateer being unreachable — long enough to
1276
+ // cover a deploy or a blip, short enough that a genuinely dead endpoint does not
1277
+ // hold an unattended run until the 25-minute ceiling.
1278
+ const SPRITE_POLL_FAILURE_BUDGET = 10;
1279
+
1280
+ /**
1281
+ * Put the delivered archive somewhere safe before unpacking it.
1282
+ *
1283
+ * Returns the path, or null if even this failed — in which case the caller is
1284
+ * out of options and has to say so. Deliberately the temp directory rather than
1285
+ * the destination: the destination is the thing that may be unwritable or wrong,
1286
+ * and a stray .zip inside a Godot project gets picked up by the import scan.
1287
+ */
1288
+ function stashBundle(bundle: Buffer, jobId: string): string | null {
1289
+ try {
1290
+ const path = join(tmpdir(), `privateer-sprite-${jobId.replace(/[^A-Za-z0-9_-]/g, "")}.zip`);
1291
+ writeFileSync(path, bundle);
1292
+ return path;
1293
+ } catch {
1294
+ return null;
1295
+ }
1296
+ }
1297
+
1298
+ /**
1299
+ * Wait on a submitted sprite job, unpack the bundle, and describe what landed.
1300
+ *
1301
+ * Split out of `execute` for the reason `awaitVideoJob` is: `resumeJobId` has to
1302
+ * poll EXACTLY as the original call did, and a resume that polled differently
1303
+ * would be the one path nobody exercises until someone's five-clip job is on the
1304
+ * line.
1305
+ *
1306
+ * `expectedAnimations` is the size of the direction set that was asked for, and
1307
+ * is null on a resume (where the request that chose it is gone). When it is
1308
+ * known it is checked against what actually came back — the server packs a sheet
1309
+ * out of whatever facings rendered and silently drops the rest, so a four-way
1310
+ * set whose "up" clip failed returns three animations, bills three, and says
1311
+ * nothing. That sheet is not the one the caller asked for and the .tres does not
1312
+ * contain the animation their GDScript will play.
1313
+ */
1314
+ async function awaitSpriteJob(
1315
+ jobId: string,
1316
+ cwd: string,
1317
+ dir: string,
1318
+ expectedAnimations: number | null,
1319
+ billed: number | null,
1320
+ signal?: AbortSignal,
1321
+ ) {
1322
+ const deadline = Date.now() + SPRITE_POLL_TIMEOUT_MS;
1323
+ // The clips are charged as they land, so an abandoned poll still costs money —
1324
+ // hence every exit below names the job id and says how to get back to it
1325
+ // without paying twice.
1326
+ const resumeHint =
1327
+ `Resume it with generate_sprite { resumeJobId: "${jobId}", dir: "${dir}" } — ` +
1328
+ "that waits on this same job and bills nothing further.";
1329
+ const cancelled = () =>
1330
+ text(
1331
+ `Sprite job ${jobId} was submitted but the wait was cancelled. Its ${billed ?? "queued"} clip(s) are still rendering and will still be billed. ` +
1332
+ resumeHint,
1333
+ );
1334
+
1335
+ let consecutiveFailures = 0;
1336
+
1337
+ for (;;) {
1338
+ if (signal?.aborted) return cancelled();
1339
+ await sleep(SPRITE_POLL_INTERVAL_MS, signal);
1340
+ if (signal?.aborted) return cancelled();
1341
+
1342
+ const poll = await callAccount<SpriteStatusResponse>(
1343
+ `/api/agent/media/sprites/${encodeURIComponent(jobId)}`,
1344
+ { method: "GET", signal },
1345
+ );
1346
+ if (!poll.ok) {
1347
+ if (!spritePollWorthRetrying(poll.status)) {
1348
+ return text(`Sprite job ${jobId} could not be polled: ${poll.message}`);
1349
+ }
1350
+ if (++consecutiveFailures >= SPRITE_POLL_FAILURE_BUDGET || Date.now() > deadline) {
1351
+ return text(
1352
+ `Sprite job ${jobId} could not be polled ${consecutiveFailures} times in a row: ${poll.message}. ` +
1353
+ `The clips are still rendering and are still billed. ${resumeHint}`,
1354
+ );
1355
+ }
1356
+ continue;
1357
+ }
1358
+ consecutiveFailures = 0;
1359
+
1360
+ const status = String(poll.data.status ?? "").toLowerCase();
1361
+ if (status === "failed") {
1362
+ return text(`Sprite generation failed: ${poll.data.error?.message ?? poll.data.message ?? "the provider reported a failure"}.`);
1363
+ }
1364
+ if (status === "completed") {
1365
+ if (!poll.data.zip_base64) {
1366
+ return text(`Sprite job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the files are missing.`);
1367
+ }
1368
+ const destination = abs(cwd, dir);
1369
+ const bundle = Buffer.from(poll.data.zip_base64, "base64");
1370
+ // Keep the bytes BEFORE touching them. This poll is the only delivery the
1371
+ // job will ever make — the server stores nothing for the agent path and
1372
+ // the next poll answers "already delivered" — so anything that throws
1373
+ // between here and the last writeFileSync used to destroy up to five
1374
+ // billed clips with no way back. The copy costs a few hundred kilobytes
1375
+ // in the temp directory and is removed the moment the unpack succeeds.
1376
+ const stash = stashBundle(bundle, jobId);
1377
+ let written: string[];
1378
+ try {
1379
+ written = extractStoredZip(bundle, destination);
1380
+ } catch (e) {
1381
+ const why = e instanceof Error ? e.message : String(e);
1382
+ return text(
1383
+ `Sprite job ${jobId} rendered but the bundle could not be unpacked into ${destination}: ${why}. ` +
1384
+ (stash
1385
+ ? `The archive itself was saved to ${stash} — unzip it there; the clips are already paid for and will not be delivered again.`
1386
+ : "The archive could not be saved either, so the clips are lost."),
1387
+ );
1388
+ }
1389
+ if (stash) {
1390
+ try {
1391
+ unlinkSync(stash);
1392
+ } catch {
1393
+ // Cleaning up a copy nobody needs is not worth failing a good unpack over.
1394
+ }
1395
+ }
1396
+
1397
+ const tres = written.find((f) => f.endsWith(".tres"));
1398
+ const anims = poll.data.animations ?? [];
1399
+ const mirrored = anims.filter((a) => a.origin === "mirrored").length;
1400
+ const sheet = poll.data.sheet;
1401
+ const missing = expectedAnimations != null ? expectedAnimations - anims.length : 0;
1402
+
1403
+ const lines = [
1404
+ `Generated sprite animation: ${written.length} files in ${destination}`,
1405
+ sheet ? `Sheet ${sheet.width}x${sheet.height}px, ${sheet.frame_width}x${sheet.frame_height} cells, ${sheet.columns}x${sheet.rows} grid.` : "",
1406
+ anims.length ? `Animations: ${anims.map((a) => a.name).join(", ")}${mirrored ? ` (${mirrored} mirrored, not billed)` : ""}.` : "",
1407
+ // Said out loud rather than left to be counted: a sheet short a facing is
1408
+ // not the sheet that was asked for, and the animation GDScript plays for
1409
+ // that direction is simply not in the resource.
1410
+ missing > 0
1411
+ ? `WARNING: ${missing} of ${expectedAnimations} facings did not render and are NOT in the sheet or the .tres — playing them will fail in Godot. ` +
1412
+ "The facings that did land were billed. Re-run to try for the missing ones."
1413
+ : "",
1414
+ tres ? `Set an AnimatedSprite2D's Sprite Frames to ${poll.data.res_path ?? "res://"}${tres.split("/").pop()}.` : "",
1415
+ "Set the sheet's texture Filter to Nearest in the Import dock, or the pixel art imports blurry.",
1416
+ // Surfaced rather than swallowed: the flat backdrop the clip was asked
1417
+ // for is a prompt the model can ignore, and when it does the key leaves
1418
+ // a rim. The caller can see it here instead of finding it in-game.
1419
+ poll.data.key_residue != null && poll.data.key_residue > 0.08
1420
+ ? `NOTE: the background did not key cleanly (residue ${poll.data.key_residue.toFixed(2)}) — the frames may have a fringe. Re-run, or clean them up before shipping.`
1421
+ : "",
1422
+ ].filter(Boolean);
1423
+ return text(lines.join("\n"));
1424
+ }
1425
+ if (Date.now() > deadline) {
1426
+ return text(
1427
+ `Sprite job ${jobId} is still ${status || "running"} after ${Math.round(SPRITE_POLL_TIMEOUT_MS / 60000)} minutes. ` +
1428
+ `It will still complete and still be billed; nothing was saved here. ${resumeHint}`,
1429
+ );
1430
+ }
1431
+ }
1186
1432
  }
1187
1433
 
1188
1434
  export const generateSpriteToolDefinition = {
@@ -1202,13 +1448,23 @@ export const generateSpriteToolDefinition = {
1202
1448
  "right-facing ones rather than rendered, which is why eight animations cost five clips and not " +
1203
1449
  "eight. Each clip is charged at the account's video rate, so an eight-way set is genuinely " +
1204
1450
  "expensive; say the total to the user before batching characters.\n" +
1205
- "TWO models are involved, which matters when one of them is down: an IMAGE model turns the " +
1206
- "picture to face each direction (only for `directions` 'four' and 'eight'), then a VIDEO model " +
1207
- "renders the motion per facing. `image_model` and `model` override them separately.\n" +
1208
- "It takes several minutes (the clips render sequentially) and this tool waits. Frame count, cell " +
1209
- "size and frame rate are chosen here and cost nothing extra. AVAILABILITY: this needs a video " +
1210
- "decoder on the Privateer API and some deployments do not have one — call media_capabilities and " +
1211
- "check `sprites.available` before spending, or you will get a clear refusal instead of a sheet. " +
1451
+ "TWO models are involved, which matters when one of them is down AND when you are quoting a " +
1452
+ "price: an IMAGE model turns the picture to face each direction (only for `directions` 'four' " +
1453
+ "and 'eight'), then a VIDEO model renders the motion per facing. `image_model` and `model` " +
1454
+ "override them separately. The turns are billed too, at the image rate — one still per billed " +
1455
+ "facing after the first, so 'four' draws two and 'eight' draws four. They are small beside a " +
1456
+ "clip, but a total that counts only clips is short; media_capabilities reports both numbers " +
1457
+ "per direction set (`billedClips` and `billedTurnStills`).\n" +
1458
+ "It takes several minutes — the picture is turned to face each direction one at a time before " +
1459
+ "anything is billed, then the clips render together — and this tool waits. Frame count, cell " +
1460
+ "size and frame rate are chosen here and cost nothing extra.\n" +
1461
+ "If a call comes back saying the job is still running, or that it could not be polled, DO NOT " +
1462
+ "call this again with the same image and prompt — that bills a whole second fan-out. Call it " +
1463
+ "with `resumeJobId` set to the job id it reported (and the same `dir`) to keep waiting on the " +
1464
+ "clips the account has already paid for.\n" +
1465
+ "AVAILABILITY: this needs a video decoder on the Privateer API and some deployments do not have " +
1466
+ "one — call media_capabilities and check `sprites.available` before spending, or you will get a " +
1467
+ "clear refusal instead of a sheet. " +
1212
1468
  "PRIVACY: video and image models have no zero-retention option, so this is gated the way 3D is — " +
1213
1469
  "a ZDR account must have enabled non-ZDR media.",
1214
1470
  parameters: Type.Object({
@@ -1231,6 +1487,12 @@ export const generateSpriteToolDefinition = {
1231
1487
  "baked into the .tres is derived from it, so running at your Godot project root means the " +
1232
1488
  "resource resolves with nothing to edit.",
1233
1489
  }),
1490
+ resumeJobId: Type.Optional(Type.String({
1491
+ description:
1492
+ "Resume waiting on a job already submitted (from a previous call that timed out or lost its " +
1493
+ "poll). Nothing is generated and nothing is billed: it only polls and unpacks. `image`, " +
1494
+ "`prompt` and every other setting are ignored — pass the same `dir`.",
1495
+ })),
1234
1496
  action: Type.Optional(
1235
1497
  Type.String({
1236
1498
  description:
@@ -1239,12 +1501,16 @@ export const generateSpriteToolDefinition = {
1239
1501
  }),
1240
1502
  ),
1241
1503
  directions: Type.Optional(
1242
- Type.String({
1243
- description:
1244
- "'one' (default, one animation, ONE clip billed), 'four' (down/right/up/left, THREE billed) " +
1245
- "or 'eight' (adds the diagonals, FIVE billed). Four is the usual choice for a top-down or " +
1246
- "2.5D character; eight only if the game actually turns that finely.",
1247
- }),
1504
+ Type.Union(
1505
+ [Type.Literal("one"), Type.Literal("four"), Type.Literal("eight")],
1506
+ {
1507
+ description:
1508
+ "'one' (default, one animation, ONE clip billed), 'four' (down/right/up/left, THREE billed) " +
1509
+ "or 'eight' (adds the diagonals, FIVE billed). Four is the usual choice for a top-down or " +
1510
+ "2.5D character; eight only if the game actually turns that finely. These three words are " +
1511
+ "the only accepted values — not '4', not 'four-way'.",
1512
+ },
1513
+ ),
1248
1514
  ),
1249
1515
  frames: Type.Optional(
1250
1516
  Type.Number({
@@ -1296,7 +1562,7 @@ export const generateSpriteToolDefinition = {
1296
1562
  async execute(
1297
1563
  _toolCallId: string,
1298
1564
  params: {
1299
- image: string; prompt: string; dir: string; action?: string; directions?: string;
1565
+ image: string; prompt: string; dir: string; resumeJobId?: string; action?: string; directions?: string;
1300
1566
  frames?: number; frame_size?: number; fps?: number; loop?: boolean;
1301
1567
  name?: string; res_path?: string; model?: string; image_model?: string;
1302
1568
  },
@@ -1305,9 +1571,36 @@ export const generateSpriteToolDefinition = {
1305
1571
  ctx?: { cwd?: string },
1306
1572
  ) {
1307
1573
  const cwd = ctx?.cwd ?? process.cwd();
1574
+ if (!params.dir) return text("Error: dir is required — say where to write the sheet and the .tres.");
1575
+
1576
+ // RESUME. Submit nothing, bill nothing — just go back to waiting on a fan-out
1577
+ // the account has already paid for. The direction set that chose the facing
1578
+ // count is gone with the original request, so the short-sheet check is off.
1579
+ const resumeJobId = String(params.resumeJobId ?? "").trim();
1580
+ if (resumeJobId) return awaitSpriteJob(resumeJobId, cwd, params.dir, null, null, signal);
1581
+
1308
1582
  if (!params.image) return text("Error: image is required — sprite generation derives every facing from one picture.");
1309
1583
  if (!params.prompt?.trim()) return text("Error: prompt is required — describe how the character moves.");
1310
- if (!params.dir) return text("Error: dir is required — say where to write the sheet and the .tres.");
1584
+
1585
+ // Checked rather than passed through: the server CLAMPS a number out of range
1586
+ // and falls back on an unknown direction set, both silently, so a typo comes
1587
+ // back as a sheet that is quietly not the one that was asked for — after the
1588
+ // clips are billed. Refusing costs nothing and names the fix.
1589
+ const directions = (params.directions ?? "one") as SpriteDirections;
1590
+ if (!Object.prototype.hasOwnProperty.call(SPRITE_DIRECTION_SETS, directions)) {
1591
+ return text(`Error: directions must be 'one', 'four' or 'eight' — got ${JSON.stringify(params.directions)}.`);
1592
+ }
1593
+ const ranges: [string, number | undefined, number, number][] = [
1594
+ ["frames", params.frames, 2, 24],
1595
+ ["frame_size", params.frame_size, 8, 512],
1596
+ ["fps", params.fps, 1, 120],
1597
+ ];
1598
+ for (const [label, value, min, max] of ranges) {
1599
+ if (value == null) continue;
1600
+ if (!Number.isFinite(value) || value < min || value > max) {
1601
+ return text(`Error: ${label} must be between ${min} and ${max} — got ${value}.`);
1602
+ }
1603
+ }
1311
1604
 
1312
1605
  let seed: { data: string; mimeType: string };
1313
1606
  try {
@@ -1324,7 +1617,7 @@ export const generateSpriteToolDefinition = {
1324
1617
  prompt: params.prompt,
1325
1618
  ...(params.action ? { action: params.action } : {}),
1326
1619
  ...(params.name ? { name: params.name } : {}),
1327
- ...(params.directions ? { directions: params.directions } : {}),
1620
+ directions,
1328
1621
  ...(params.frames != null ? { frames: params.frames } : {}),
1329
1622
  ...(params.frame_size != null ? { frame_size: params.frame_size } : {}),
1330
1623
  ...(params.fps != null ? { fps: params.fps } : {}),
@@ -1345,67 +1638,11 @@ export const generateSpriteToolDefinition = {
1345
1638
  const jobId = submitted.data.id;
1346
1639
  if (!jobId) return text("Sprite generation failed: Privateer did not return a job id.");
1347
1640
 
1348
- const billed = submitted.data.billed_facings;
1349
- const deadline = Date.now() + SPRITE_POLL_TIMEOUT_MS;
1350
- // The clips are charged as they land, so an abandoned poll still costs money —
1351
- // hence every exit below names the job id and says so plainly.
1352
- const cancelled = () =>
1353
- text(`Sprite job ${jobId} was submitted but the wait was cancelled. Its ${billed ?? "queued"} clip(s) are still rendering and will still be billed.`);
1354
-
1355
- for (;;) {
1356
- if (signal?.aborted) return cancelled();
1357
- await sleep(SPRITE_POLL_INTERVAL_MS, signal);
1358
- if (signal?.aborted) return cancelled();
1359
-
1360
- const poll = await callAccount<SpriteStatusResponse>(
1361
- `/api/agent/media/sprites/${encodeURIComponent(jobId)}`,
1362
- { method: "GET", signal },
1363
- );
1364
- if (!poll.ok) return text(`Sprite job ${jobId} could not be polled: ${poll.message}`);
1365
-
1366
- const status = String(poll.data.status ?? "").toLowerCase();
1367
- if (status === "failed") {
1368
- return text(`Sprite generation failed: ${poll.data.error?.message ?? poll.data.message ?? "the provider reported a failure"}.`);
1369
- }
1370
- if (status === "completed") {
1371
- if (!poll.data.zip_base64) {
1372
- return text(`Sprite job ${jobId} already delivered its bytes on an earlier poll; they were not saved. Generate again if the files are missing.`);
1373
- }
1374
- const destination = abs(cwd, params.dir);
1375
- let written: string[];
1376
- try {
1377
- written = extractStoredZip(Buffer.from(poll.data.zip_base64, "base64"), destination);
1378
- } catch (e) {
1379
- return text(`Sprite job ${jobId} rendered but the bundle could not be unpacked: ${e instanceof Error ? e.message : String(e)}`);
1380
- }
1381
-
1382
- const tres = written.find((f) => f.endsWith(".tres"));
1383
- const anims = poll.data.animations ?? [];
1384
- const mirrored = anims.filter((a) => a.origin === "mirrored").length;
1385
- const sheet = poll.data.sheet;
1386
-
1387
- const lines = [
1388
- `Generated sprite animation: ${written.length} files in ${destination}`,
1389
- sheet ? `Sheet ${sheet.width}x${sheet.height}px, ${sheet.frame_width}x${sheet.frame_height} cells, ${sheet.columns}x${sheet.rows} grid.` : "",
1390
- anims.length ? `Animations: ${anims.map((a) => a.name).join(", ")}${mirrored ? ` (${mirrored} mirrored, not billed)` : ""}.` : "",
1391
- tres ? `Set an AnimatedSprite2D's Sprite Frames to ${poll.data.res_path ?? "res://"}${tres.split("/").pop()}.` : "",
1392
- "Set the sheet's texture Filter to Nearest in the Import dock, or the pixel art imports blurry.",
1393
- // Surfaced rather than swallowed: the flat backdrop the clip was asked
1394
- // for is a prompt the model can ignore, and when it does the key leaves
1395
- // a rim. The caller can see it here instead of finding it in-game.
1396
- poll.data.key_residue != null && poll.data.key_residue > 0.08
1397
- ? `NOTE: the background did not key cleanly (residue ${poll.data.key_residue.toFixed(2)}) — the frames may have a fringe. Re-run, or clean them up before shipping.`
1398
- : "",
1399
- ].filter(Boolean);
1400
- return text(lines.join("\n"));
1401
- }
1402
- if (Date.now() > deadline) {
1403
- return text(
1404
- `Sprite job ${jobId} is still ${status || "running"} after ${Math.round(SPRITE_POLL_TIMEOUT_MS / 60000)} minutes. ` +
1405
- "It will still complete and still be billed; nothing was saved here.",
1406
- );
1407
- }
1408
- }
1641
+ // The server's own count where it gave one, the table's where it did not —
1642
+ // the point of the check is to notice a SHORT sheet, so guessing high would
1643
+ // invent a failure and guessing low would hide one.
1644
+ const expected = submitted.data.animations ?? SPRITE_DIRECTION_SETS[directions].animations;
1645
+ return awaitSpriteJob(jobId, cwd, params.dir, expected, submitted.data.billed_facings ?? null, signal);
1409
1646
  },
1410
1647
  };
1411
1648