dreamlayer 0.3.0 → 0.4.0-beta.2

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/README.md CHANGED
@@ -21,6 +21,7 @@ dreamlayer cutout <image> [--out file.png] # background removal
21
21
  dreamlayer upscale <image> [--out file.png] # 2x
22
22
  dreamlayer answer <conversation-id> <text> [--image file.png]
23
23
  dreamlayer status <execution-id>
24
+ dreamlayer download <execution-id> --out file.png
24
25
  dreamlayer balance # spends nothing
25
26
  dreamlayer capabilities # spends nothing
26
27
  ```
@@ -77,28 +78,28 @@ dreamlayer balance --json
77
78
 
78
79
  ## Retries are safe if you reuse the key
79
80
 
80
- An idempotency key is generated per run. After an uncertain response, pass the same one
81
- back and the original result replays instead of paying twice:
81
+ Save a key before starting text generation. After an uncertain response, reuse that key
82
+ and the identical prompt/options, or check the existing execution first:
82
83
 
83
84
  ```bash
84
85
  dreamlayer generate "a fox logo" --idempotency-key fox-001
85
86
  ```
86
87
 
87
- ## A question is not a failure
88
+ ## Continue a question from another client
88
89
 
89
- An edit-shaped prompt with no image exits 6 and asks for one:
90
+ The CLI's `generate`, `edit`, `cutout`, `upscale`, and `sprite` commands select their
91
+ operation explicitly. `generate "remove the background"` therefore remains a
92
+ text-to-image request; use `cutout image.png` to remove a background.
93
+
94
+ A conversational request made through the API or MCP can instead ask for missing
95
+ input. Continue that conversation from the CLI using its saved conversation ID:
90
96
 
91
97
  ```bash
92
- dreamlayer generate "remove the background"
93
- # Which image should I use? Upload or attach one, then respond.
94
- # dreamlayer answer <id> "your answer" --image <file>
98
+ dreamlayer answer <conversation-id> "Use this image" --image reference.png
95
99
  ```
96
100
 
97
- Answer it with the image attached. Words alone are refused, because the question is
98
- asking for a picture, not a clarification.
99
-
100
- Naming an operation avoids the round trip entirely, which is why `cutout`, `upscale`,
101
- `edit`, and `generate` all do.
101
+ An answer that needs an image must attach one. Exit code 6 means the returned
102
+ conversation needs input; it does not mean a generation failed.
102
103
 
103
104
  ## Requirements
104
105
 
@@ -107,3 +108,62 @@ Node.js 22.12 or later.
107
108
  ## License
108
109
 
109
110
  MIT. See LICENSE and NOTICE.
111
+
112
+ ## Sprite-sheet beta
113
+
114
+ Sprite requests accept exactly one of `options.animation_prompt` (1–4000 characters) or an `options.action` preset (`walk`, `run`, `idle`). Custom prompts can describe characters, creatures, objects, effects or 360° turntables. `animation_mode` is `loop` or `once`; presets default to loop, custom prompts to once. For a turntable, request a stationary camera and rotating subject. Broad requests do not guarantee correct motion, unseen details or successful effect transparency.
115
+
116
+ Request integer `frame_count` 7–100 (default 12) and `frame_size` 32, 64, 128, 256, 512, 720 or 1080 (default 512). These are square export canvases; a larger export does not guarantee additional detail. Aspect ratio and shared alignment are preserved with transparent padding. A bundle contains transparent PNG frames, sheet, atlas, preview and import instructions, including each frame's playback duration. Choose a repeating loop or a one-time action with a beginning and ending. If the requested number of distinct frames cannot be delivered, the job fails and held credits are returned. Translucent effects can lose detail or fail; small exports are not automatically pixel art.
117
+
118
+ Pricing is unchanged across sizes: frames 1–14 cost $0.14 each; additional frames $0.07 each. One credit is $0.17. Round the complete order upward once to a tenth of a credit. Check `sprite_pricing` in capabilities and approve the quote with `max_credits`. Credits are held during processing, settled after complete delivery and restored on failure/timeout. There is no customer cancellation. Keep the execution ID to resume status. Custom requests need the matching broad-animation server release; older servers reject them. New live generation quality, 100-frame duration and actual cost remain unverified.
119
+
120
+ ```sh
121
+ dreamlayer sprite character.png --action walk --frames 12 --max-credits 9.9 --out walk.zip
122
+ dreamlayer status EXECUTION_ID
123
+ ```
124
+
125
+ Set `--max-credits` to the amount you approve after checking the current price. The CLI reconnects to existing work if an event stream closes.
126
+
127
+ For affordability, compare the complete rounded quote in **credits** with `available`. One tenth of a credit is $0.017. Promotional and purchased amounts are displayed rounded down separately, so their displayed sum can be 0.1 credit below `available`; stored fractions are preserved. Compare against the combined total, not that sum. The order charge rounds only once, never per frame or per tier.
128
+
129
+ ## Automating recovery
130
+
131
+ Commands never prompt. `--json` sends results to stdout and structured errors to stderr,
132
+ including usage and transport errors. Exit 0 from `status` means the read succeeded;
133
+ inspect its `status` field to learn whether the execution completed.
134
+
135
+ After a lost download, recover the existing execution without generation:
136
+
137
+ ```sh
138
+ dreamlayer status EXECUTION_ID --json
139
+ dreamlayer download EXECUTION_ID --out recovered.png --json
140
+ ```
141
+
142
+ All output commands refuse to overwrite existing files, directories, or symlinks.
143
+ Paid commands check `--out` before uploads or `/v1/execute`: an existing destination
144
+ returns `output_exists` (exit 1) without submitting or charging a new job. A missing or
145
+ unwritable parent returns `output_unavailable` (exit 1). Re-running a batch with the
146
+ same output paths therefore stops on completed files before paid work. Choose a new
147
+ path only for intentionally new work. The final write is still exclusive: if another
148
+ process creates the destination during generation, use the saved execution ID to
149
+ recover the completed output with `download`.
150
+
151
+ `download` also refuses to overwrite a file. Use `.zip` for sprite results. Generation errors
152
+ in JSON include the available execution ID and idempotency key for recovery. Treat them
153
+ as private identifiers. For file-based commands, rerunning uploads a new asset: the same
154
+ local file is not an identical API request. Prefer `status` and `download`, or the
155
+ [execution recovery guide](https://docs.dreamlayer.io/agent-api/jobs-and-events).
156
+
157
+ [API overview](https://docs.dreamlayer.io/agent-api) ·
158
+ [CLI guide](https://docs.dreamlayer.io/cli) ·
159
+ [MCP setup](https://docs.dreamlayer.io/mcp/index)
160
+
161
+ Local client failures are separate from API generation failures. `local_output_failed`
162
+ (exit 1) means the completed output could not be written; fix the destination and run
163
+ `download` with the saved execution ID. `download_failed` or `output_not_ready` (exit 5)
164
+ also require recovery of existing work, not a new generation. `retryable: true` means
165
+ retry the indicated recovery action, never blindly repeat a paid command.
166
+ `local_input_failed` (exit 1) means no readable input was supplied; missing credentials
167
+ use `authentication_failed` (exit 2). A cancelled run uses `execution_cancelled`, exit 4,
168
+ and a JSON error on stderr. Unknown client failures use `client_error`; they do not
169
+ prove that the server-side generation failed.
package/dist/cli.js CHANGED
@@ -1,24 +1,10 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * DreamLayer CLI.
4
- *
5
- * Generate and edit images from a terminal, over local files. The API is the same one
6
- * the MCP server and the web app use, and it spends from the same credit balance.
7
- *
8
- * Exit codes are meaningful, so this composes in a script:
9
- * 0 success
10
- * 1 usage error
11
- * 2 authentication or account problem (401, 403)
12
- * 3 out of credits (402)
13
- * 4 the request was rejected (409, 422)
14
- * 5 temporary, worth retrying (429, 5xx)
15
- * 6 the run ended asking a question instead of producing an image
16
- */
2
+ import { spriteCreditPrice } from "./client.js";
17
3
  import { randomUUID } from "node:crypto";
18
- import { openAsBlob, readFileSync } from "node:fs";
19
- import { stat, writeFile } from "node:fs/promises";
4
+ import { constants, openAsBlob, readFileSync } from "node:fs";
5
+ import { access, lstat, stat, writeFile } from "node:fs/promises";
20
6
  import path from "node:path";
21
- import { ApiError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
7
+ import { ApiError, InputValidationError, RecoveryRequiredError, KNOWN_OPERATIONS, ManagedClient, StreamIdleError, UploadTimeoutError, terminalExecutionError, } from "./client.js";
22
8
  import { Progress, consume } from "./render.js";
23
9
  const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
24
10
  const USAGE = `dreamlayer - generate and edit images from your terminal
@@ -28,30 +14,103 @@ USAGE
28
14
  dreamlayer edit <image> <prompt> [--out <file>]
29
15
  dreamlayer cutout <image> [--out <file>]
30
16
  dreamlayer upscale <image> [--out <file>]
17
+ dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
31
18
  dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
19
+ dreamlayer download <execution-id> --out <file>
32
20
  dreamlayer status <execution-id>
33
21
  dreamlayer balance
34
22
  dreamlayer capabilities
35
23
 
36
24
  OPTIONS
37
- --out <file> Where to write the image. Default: dreamlayer-<n>.png
25
+ --out <file> New output file; never overwrites. Default: dreamlayer-<n>.png
26
+ Existing destinations are refused before paid submission.
38
27
  --image <file> Attach an image when answering a question that asks for one
39
28
  --aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
40
- --json Machine-readable output on stdout
29
+ --action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
30
+ --animation-prompt <text> Custom animation; cannot combine with --action
31
+ --animation-mode <loop|once> Default: loop for presets, once for custom
32
+ --frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
33
+ --frames <n> Frame count: integer 7–100, default 12
34
+ --max-credits <n> Maximum approved charge for the sprite job
35
+ --json JSON results on stdout; JSON errors on stderr
41
36
  --quiet No progress on stderr
42
37
  --idempotency-key <key> Reuse to retry safely after an uncertain response
43
38
 
39
+ EXIT CODES
40
+ 0 success, 1 usage/local error, 2 authentication/access, 3 credits/quota,
41
+ 4 permanent API failure, 5 temporary failure, 6 input required
42
+
43
+ AUTOMATION
44
+ Commands never prompt. Save a unique --idempotency-key before paid work.
45
+ After uncertainty, use status then download; do not start a replacement job.
46
+ CLI guide: https://docs.dreamlayer.io/cli
47
+
44
48
  ENVIRONMENT
45
49
  DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
46
50
  DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
47
51
 
48
- Every finished image costs one credit. A new account starts at zero.
52
+ Image operations cost one credit. Sprite pricing is listed in capabilities. A new account starts at zero.
49
53
  `;
50
54
  class UsageError extends Error {
51
55
  }
56
+ class CommandError extends Error {
57
+ reason;
58
+ exitCode;
59
+ retryable;
60
+ guidance;
61
+ constructor(reason, message, exitCode, retryable = false, guidance) {
62
+ super(message);
63
+ this.reason = reason;
64
+ this.exitCode = exitCode;
65
+ this.retryable = retryable;
66
+ this.guidance = guidance;
67
+ }
68
+ }
69
+ async function preflightOutput(target) {
70
+ let exists = false;
71
+ try {
72
+ await lstat(target);
73
+ exists = true;
74
+ }
75
+ catch (error) {
76
+ if (error.code !== "ENOENT") {
77
+ throw new CommandError("output_unavailable", "The output destination could not be checked. No generation was submitted.", 1);
78
+ }
79
+ }
80
+ if (exists) {
81
+ throw new CommandError("output_exists", "The output destination already exists. Refusing to overwrite; no generation was submitted.", 1, false, "Use the existing output or choose a new --out path for intentionally new work.");
82
+ }
83
+ try {
84
+ const parent = path.dirname(path.resolve(target));
85
+ if (!(await stat(parent)).isDirectory())
86
+ throw new Error("not a directory");
87
+ await access(parent, constants.W_OK | constants.X_OK);
88
+ }
89
+ catch {
90
+ throw new CommandError("output_unavailable", "The output parent must be an existing writable directory. No generation was submitted.", 1);
91
+ }
92
+ }
93
+ async function saveOutput(target, bytes) {
94
+ try {
95
+ await writeFile(target, bytes, { flag: "wx" });
96
+ }
97
+ catch {
98
+ throw new CommandError("local_output_failed", "The output could not be saved locally. Generation has already completed.", 1, true, "Fix the destination or choose a new path, then use dreamlayer download with the saved execution_id. Do not generate again.");
99
+ }
100
+ }
101
+ async function downloadOutput(api, url) {
102
+ try {
103
+ return await api.download(url);
104
+ }
105
+ catch {
106
+ throw new CommandError("download_failed", "The completed output could not be downloaded.", 5, true, "Retry dreamlayer download with the saved execution_id. Do not generate again.");
107
+ }
108
+ }
52
109
  function parseOptions(argv) {
53
110
  const positional = [];
54
111
  const options = {
112
+ maxCredits: 1,
113
+ frameCount: 12,
55
114
  out: null,
56
115
  image: null,
57
116
  aspect: "1:1",
@@ -71,6 +130,42 @@ function parseOptions(argv) {
71
130
  throw new UsageError("--out needs a file path");
72
131
  options.out = value;
73
132
  }
133
+ else if (token === "--action") {
134
+ const value = argv[++i];
135
+ if (value !== "walk" && value !== "run" && value !== "idle")
136
+ throw new UsageError("--action must be walk, run, or idle");
137
+ options.action = value;
138
+ }
139
+ else if (token === "--animation-prompt") {
140
+ const value = argv[++i];
141
+ if (!value?.trim() || [...value].length > 4000)
142
+ throw new UsageError("--animation-prompt needs 1–4000 characters");
143
+ options.animationPrompt = value;
144
+ }
145
+ else if (token === "--animation-mode") {
146
+ const value = argv[++i];
147
+ if (value !== "loop" && value !== "once")
148
+ throw new UsageError("--animation-mode must be loop or once");
149
+ options.animationMode = value;
150
+ }
151
+ else if (token === "--frame-size") {
152
+ const value = Number(argv[++i]);
153
+ if (![32, 64, 128, 256, 512, 720, 1080].includes(value))
154
+ throw new UsageError("--frame-size must be 32, 64, 128, 256, 512, 720 or 1080");
155
+ options.frameSize = value;
156
+ }
157
+ else if (token === "--frames") {
158
+ const value = Number(argv[++i]);
159
+ if (!Number.isInteger(value) || value < 7 || value > 100)
160
+ throw new UsageError("--frames must be an integer from 7 to 100");
161
+ options.frameCount = value;
162
+ }
163
+ else if (token === "--max-credits") {
164
+ const value = Number(argv[++i]);
165
+ if (!Number.isFinite(value) || value < 0.1 || value > 100)
166
+ throw new UsageError("--max-credits must be 0.1 to 100");
167
+ options.maxCredits = value;
168
+ }
74
169
  else if (token === "--image") {
75
170
  const value = argv[++i];
76
171
  if (!value)
@@ -101,14 +196,11 @@ function parseOptions(argv) {
101
196
  function client() {
102
197
  const key = (process.env.DREAMLAYER_API_KEY ?? "").trim();
103
198
  if (!key) {
104
- throw new UsageError("DREAMLAYER_API_KEY is not set.\n" +
105
- " export DREAMLAYER_API_KEY=dlr_live_...\n" +
106
- " Get a key at https://platform.dreamlayer.io");
199
+ throw new CommandError("authentication_failed", "DREAMLAYER_API_KEY is not set. Get a key at https://platform.dreamlayer.io", 2);
107
200
  }
108
201
  return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
109
202
  }
110
203
  const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
111
- /** Upload a local file; the server owns RAW, EXIF, alpha, and resize normalization. */
112
204
  async function upload(api, file) {
113
205
  const resolved = path.resolve(file);
114
206
  let fileStat;
@@ -116,30 +208,38 @@ async function upload(api, file) {
116
208
  fileStat = await stat(resolved);
117
209
  }
118
210
  catch {
119
- throw new UsageError(`cannot read ${file}`);
211
+ throw new CommandError("local_input_failed", "The local input file could not be read.", 1);
120
212
  }
121
213
  if (fileStat.size > MAX_SOURCE_BYTES) {
122
214
  throw new UsageError(`${file} is ${Math.round(fileStat.size / 1024 / 1024)} MB; the limit is 200 MB`);
123
215
  }
124
- const asset = await api.uploadInput(await openAsBlob(resolved), path.basename(resolved));
216
+ let blob;
217
+ try {
218
+ blob = await openAsBlob(resolved);
219
+ }
220
+ catch {
221
+ throw new CommandError("local_input_failed", "The local input file could not be read.", 1);
222
+ }
223
+ const asset = await api.uploadInput(blob, path.basename(resolved));
125
224
  return asset.input_asset_id;
126
225
  }
127
226
  function defaultOut() {
128
227
  return `dreamlayer-${Date.now()}.png`;
129
228
  }
229
+ let recovery = {};
130
230
  async function run(api, input, options) {
131
231
  const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
132
232
  const idempotencyKey = options.idempotencyKey ?? randomUUID();
133
- const outcome = await consume(api.execute(input, { idempotencyKey }), progress);
233
+ recovery = { idempotency_key: idempotencyKey };
234
+ const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
235
+ recovery.execution_id = outcome.execution_id;
134
236
  if (outcome.question) {
135
237
  progress.stop();
136
238
  if (options.json) {
137
- process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
239
+ process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`);
138
240
  }
139
241
  else {
140
242
  process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`);
141
- // The server's needs_input question always asks for an image, so point at the
142
- // flag that can supply one rather than the bare form that will 422.
143
243
  const wantsImage = /image/i.test(outcome.question.text);
144
244
  process.stderr.write(`Answer it with:\n dreamlayer answer ${outcome.conversation_id} "your answer"` +
145
245
  `${wantsImage ? " --image <file>" : ""}\n`);
@@ -153,22 +253,21 @@ async function run(api, input, options) {
153
253
  if (terminal)
154
254
  throw terminal;
155
255
  }
156
- if (options.json)
157
- process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
158
- else
159
- process.stderr.write(`Run ended as ${outcome.status}. No credit was settled.\n`);
160
- return 5;
256
+ if (outcome.status === "cancelled")
257
+ throw new CommandError("execution_cancelled", "The execution was cancelled.", 4);
258
+ if (outcome.status === "failed")
259
+ throw new CommandError("generation_failed", "The execution failed. Read canonical state for details.", 4);
260
+ throw new RecoveryRequiredError("The execution has no completed output yet. Read its saved state.");
161
261
  }
162
262
  progress.set("Downloading");
163
- const bytes = await api.download(outcome.asset.download_url);
164
- const target = options.out ?? defaultOut();
165
- await writeFile(target, bytes);
263
+ const bytes = await downloadOutput(api, outcome.asset.download_url);
264
+ const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
265
+ await saveOutput(target, bytes);
166
266
  progress.stop();
167
267
  if (options.json) {
168
- process.stdout.write(`${JSON.stringify({ ...outcome, file: path.resolve(target) }, null, 2)}\n`);
268
+ process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey, file: path.resolve(target) }, null, 2)}\n`);
169
269
  }
170
270
  else {
171
- // The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file.
172
271
  process.stdout.write(`${target}\n`);
173
272
  }
174
273
  return 0;
@@ -178,13 +277,6 @@ async function imageCommand(operation, prompt, file, options) {
178
277
  const inputAssetId = await upload(api, file);
179
278
  return run(api, { prompt, operation, input_asset_id: inputAssetId }, options);
180
279
  }
181
- /**
182
- * Point a user at the job they may have paid for.
183
- *
184
- * Without this, a timed-out upscale left nothing to go on: no id, no command, and no
185
- * key-authenticated way to check a balance. "It might have charged you, good luck" is
186
- * not an acceptable end state for a paid call.
187
- */
188
280
  function recoveryHint(error) {
189
281
  const id = error !== null && typeof error === "object"
190
282
  ? error.partialOutcome
@@ -192,25 +284,6 @@ function recoveryHint(error) {
192
284
  : null;
193
285
  return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
194
286
  }
195
- /**
196
- * Say so when this build and the server disagree about what exists.
197
- *
198
- * The MCP package solves this by asking the server at startup and shaping its tool
199
- * schema from the answer. The CLI cannot: `ManagedOperation` is a compile-time union and
200
- * `cutout` / `upscale` are compile-time commands, so deriving the list at runtime would
201
- * buy consistency by giving up type safety at every call site.
202
- *
203
- * So it reports instead of adapting, and it does so HERE because `capabilities` is free,
204
- * spends nothing, and is the command people are told to run first. Both directions are
205
- * worth naming:
206
- *
207
- * - the server offers something this build cannot reach -> the user is missing a
208
- * feature they are paying for and would never know
209
- * - this build names something the server will not run -> the failure that shipped on
210
- * 2026-08-21, where a call looked like a client bug rather than a version skew
211
- *
212
- * stderr, never stdout: `dreamlayer capabilities` is piped into jq.
213
- */
214
287
  function warnIfOperationsDrifted(capabilities) {
215
288
  const listed = capabilities.operations;
216
289
  if (!Array.isArray(listed) || listed.some((o) => typeof o !== "string"))
@@ -218,7 +291,7 @@ function warnIfOperationsDrifted(capabilities) {
218
291
  const server = new Set(listed);
219
292
  const mine = new Set(KNOWN_OPERATIONS);
220
293
  const serverOnly = [...server].filter((o) => !mine.has(o));
221
- const clientOnly = [...mine].filter((o) => !server.has(o));
294
+ const clientOnly = [...mine].filter((o) => !server.has(o) && o !== "sprite_sheet");
222
295
  if (serverOnly.length === 0 && clientOnly.length === 0)
223
296
  return;
224
297
  process.stderr.write("\nThis CLI and the server disagree about the operation list.\n");
@@ -244,12 +317,37 @@ async function main(argv) {
244
317
  process.stdout.write(USAGE);
245
318
  return command ? 0 : 1;
246
319
  }
320
+ if (rest.includes("--help") || rest.includes("-h")) {
321
+ process.stdout.write(USAGE);
322
+ return 0;
323
+ }
247
324
  if (command === "--version" || command === "-v") {
248
325
  process.stdout.write(`${PACKAGE_VERSION}\n`);
249
326
  return 0;
250
327
  }
251
328
  const { positional, options } = parseOptions(rest);
329
+ if (["generate", "edit", "cutout", "upscale", "sprite", "answer"].includes(command)) {
330
+ options.out ??= command === "sprite" ? `dreamlayer-${Date.now()}.zip` : defaultOut();
331
+ await preflightOutput(options.out);
332
+ }
252
333
  switch (command) {
334
+ case "sprite": {
335
+ if (options.action && options.animationPrompt)
336
+ throw new UsageError("Use either --action or --animation-prompt, not both");
337
+ const file = positional[0];
338
+ if (!file)
339
+ throw new UsageError("sprite needs a reference image");
340
+ const api = client();
341
+ const caps = await api.getCapabilities();
342
+ if (!Array.isArray(caps.operations) || !caps.operations.includes("sprite_sheet"))
343
+ throw new UsageError("sprite beta access is not enabled for this account");
344
+ if (!caps.sprite_pricing)
345
+ throw new UsageError("The server does not support configurable sprite pricing yet");
346
+ const price = spriteCreditPrice(options.frameCount);
347
+ if (options.maxCredits < price)
348
+ throw new UsageError(`Sprite jobs require ${price} credits. Set --max-credits to approve that amount.`);
349
+ return run(api, { operation: "sprite_sheet", input_asset_id: await upload(api, file), options: { ...(options.animationPrompt ? { animation_prompt: options.animationPrompt } : { action: options.action ?? "walk" }), ...(options.animationMode ? { animation_mode: options.animationMode } : {}), ...(options.frameSize ? { frame_size: options.frameSize } : {}), frame_count: options.frameCount }, max_credits: options.maxCredits }, options);
350
+ }
253
351
  case "generate": {
254
352
  const prompt = positional[0];
255
353
  if (!prompt)
@@ -279,10 +377,6 @@ async function main(argv) {
279
377
  if (!conversationId || !text)
280
378
  throw new UsageError("answer needs a conversation id and text");
281
379
  const api = client();
282
- // A question that asks for an image cannot be answered with words alone: the
283
- // server refuses a reply with no asset when the pending question required one.
284
- // Without --image this command reached a clean 422 and the documented path
285
- // dead-ended, telling the user to attach an image and offering no way to do it.
286
380
  const input = options.image ? await upload(api, options.image) : undefined;
287
381
  return run(api, {
288
382
  respond: text,
@@ -290,6 +384,21 @@ async function main(argv) {
290
384
  ...(input ? { input_asset_id: input } : {}),
291
385
  }, options);
292
386
  }
387
+ case "download": {
388
+ const executionId = positional[0];
389
+ if (!executionId || positional.length !== 1 || !options.out)
390
+ throw new UsageError("download needs one execution id and --out <file>");
391
+ const api = client();
392
+ recovery = { execution_id: executionId };
393
+ const execution = await api.getExecution(executionId);
394
+ const assets = execution.image_job?.finished_assets;
395
+ if (execution.status !== "completed" || !Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
396
+ throw new CommandError("output_not_ready", "The execution has no single finished asset. Check status before downloading.", 5, true, "Read the saved execution; do not generate again.");
397
+ const bytes = await downloadOutput(api, assets[0].download_url);
398
+ await saveOutput(options.out, bytes);
399
+ process.stdout.write(options.json ? `${JSON.stringify({ execution_id: executionId, file: path.resolve(options.out), bytes: bytes.length })}\n` : `${options.out}\n`);
400
+ return 0;
401
+ }
293
402
  case "status": {
294
403
  const executionId = positional[0];
295
404
  if (!executionId)
@@ -309,6 +418,9 @@ async function main(argv) {
309
418
  process.stdout.write(`${balance.available} credits available ` +
310
419
  `(${balance.promotional} promotional, ${balance.purchased} purchased)\n`);
311
420
  }
421
+ if (!options.json && Math.round(balance.available * 10) > Math.round(balance.promotional * 10) + Math.round(balance.purchased * 10)) {
422
+ process.stdout.write("Use the available total for affordability. Funding balances are rounded down separately; stored fractions are preserved.\n");
423
+ }
312
424
  return 0;
313
425
  }
314
426
  case "capabilities": {
@@ -326,6 +438,39 @@ main(process.argv.slice(2))
326
438
  process.exitCode = code;
327
439
  })
328
440
  .catch((error) => {
441
+ const known = error instanceof CommandError ? error : error instanceof InputValidationError
442
+ ? new CommandError("invalid_request", error.message, 1)
443
+ : error instanceof RecoveryRequiredError
444
+ ? new CommandError("temporarily_unavailable", "Execution state is uncertain. Read saved state before retrying.", 5, true, "Use status and download for the saved execution. If no ID was received, replay identical inputs with the original idempotency key.") : null;
445
+ if (known) {
446
+ const partial = error.partialOutcome;
447
+ const identity = { ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) };
448
+ const envelope = { error: { code: "CLIENT_ERROR", reason: known.reason, message: known.message, retryable: known.retryable, request_id: null, guidance: known.guidance, ...identity } };
449
+ if (process.argv.slice(2).includes("--json"))
450
+ process.stderr.write(`${JSON.stringify(envelope)}\n`);
451
+ else {
452
+ process.stderr.write(`${known.message}\n${known.guidance ?? ""}\n`);
453
+ if (identity.execution_id)
454
+ process.stderr.write(`Execution: ${identity.execution_id}\n dreamlayer status ${identity.execution_id}\n dreamlayer download ${identity.execution_id} --out <new-file>\n`);
455
+ if (identity.idempotency_key)
456
+ process.stderr.write(`Idempotency key: ${identity.idempotency_key}\n`);
457
+ }
458
+ process.exitCode = known.exitCode;
459
+ return;
460
+ }
461
+ if (process.argv.slice(2).includes("--json")) {
462
+ const partial = error?.partialOutcome;
463
+ const temporary = error instanceof StreamIdleError || error instanceof UploadTimeoutError;
464
+ const envelope = error instanceof ApiError ? error.toPublicEnvelope() : {
465
+ error: { code: error instanceof UsageError ? "VALIDATION_FAILED" : temporary ? "SERVICE_UNAVAILABLE" : "INTERNAL_ERROR",
466
+ reason: error instanceof UsageError ? "invalid_request" : temporary ? "temporarily_unavailable" : "client_error",
467
+ message: error instanceof UsageError ? "Check command arguments and local input or output files; use --help." : temporary ? "The request timed out. Check the saved execution before retrying." : "The command could not complete. Check saved execution state and local output access.",
468
+ retryable: temporary, request_id: null },
469
+ };
470
+ process.stderr.write(`${JSON.stringify({ error: { ...envelope.error, ...recovery, ...(partial?.execution_id ? { execution_id: partial.execution_id } : {}) } })}\n`);
471
+ process.exitCode = error instanceof ApiError ? exitCodeFor(error) : temporary ? 5 : 1;
472
+ return;
473
+ }
329
474
  if (error instanceof UsageError) {
330
475
  process.stderr.write(`${error.message}\n`);
331
476
  process.exitCode = 1;
@@ -346,11 +491,6 @@ main(process.argv.slice(2))
346
491
  process.exitCode = exitCodeFor(error);
347
492
  return;
348
493
  }
349
- // A stream that went silent is retryable, and it is the failure MOST likely to
350
- // have been charged for: the server may have finished the job we stopped listening
351
- // to. It reached this generic branch as a bare DOMException, so it exited 1 with no
352
- // guidance, and the --idempotency-key advice that exists precisely to prevent
353
- // double payment never printed on the one case that needs it.
354
494
  if (error instanceof StreamIdleError) {
355
495
  process.stderr.write(`${error.message}\n`);
356
496
  process.stderr.write("Temporary. Retry with --idempotency-key to avoid paying twice.\n");
package/dist/client.d.ts CHANGED
@@ -1,49 +1,10 @@
1
- /**
2
- * Hosted client for the DreamLayer Agent API.
3
- *
4
- * Deliberately a COPY of the same file in dreamlayer-mcp rather than a shared package.
5
- * The alternative makes every capability three releases in strict order (client, then
6
- * CLI, then MCP) instead of one release per repo. For a client over eight endpoints
7
- * that trade is not worth the friction.
8
- *
9
- * Lifted from the DreamLayer runtime's TypeScript client, which is retired. The
10
- * validation, error sanitisation, and origin hardening are kept verbatim because they
11
- * were already correct; what changed is that the SSE reader is now wired to the hosted
12
- * client instead of the dead local-proxy class, and that timeouts and an explicit
13
- * redirect policy were added, which the original lacked.
14
- */
15
1
  export type ManagedEventName = "started" | "thinking" | "progress" | "job" | "question" | "asset" | "done";
16
2
  export type ManagedEvent = {
17
3
  id: string | null;
18
4
  event: ManagedEventName;
19
5
  data: Record<string, unknown>;
20
6
  };
21
- /**
22
- * Every operation the Agent API can execute.
23
- *
24
- * REQUIRES the gateway build that added `operation` to ExecuteRequest. Against an older
25
- * deployment this field is rejected with 422 extra_forbidden, because the request model
26
- * is closed. That is a sequencing constraint, not a reason to drop it: naming the
27
- * operation is what stops a cutout or an upscale being re-read from the prompt and
28
- * coming back as a clarifying question instead of an image.
29
- *
30
- * SATISFIED 2026-08-21. prodbeta176 carries the field in the gateway AND the dispatch
31
- * in the workflow engine, which had been split across two releases: the gateway
32
- * accepted `operation` from prodbeta174 while the half that acts on it was still on
33
- * prodbeta172, so naming an operation returned 200 and was then inferred from prose
34
- * anyway. Confirmed against the deployment, not the source: the live /openapi.json
35
- * advertises exactly these four in both ExecuteRequest and ImageJobCreate.
36
- */
37
- export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale"];
38
- /**
39
- * Derived from the array above, not written twice.
40
- *
41
- * The first version of this declared the union by hand and pinned an array to it with
42
- * `satisfies`. That catches a WRONG entry and not a MISSING one, because a shorter array
43
- * still satisfies a wider union, so the exact drift this file exists to detect could
44
- * slip through the check meant to prevent it. Deriving the type makes the array the
45
- * single definition and the question unaskable.
46
- */
7
+ export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale", "sprite_sheet"];
47
8
  export type ManagedOperation = (typeof KNOWN_OPERATIONS)[number];
48
9
  export type ManagedExecuteInput = {
49
10
  prompt?: string;
@@ -51,9 +12,22 @@ export type ManagedExecuteInput = {
51
12
  conversation_id?: string;
52
13
  input_asset_id?: string;
53
14
  aspect_ratio?: string;
54
- /** Requires the gateway build that added it. See ManagedOperation. */
55
15
  operation?: ManagedOperation;
16
+ options?: {
17
+ action?: "walk" | "run" | "idle";
18
+ animation_prompt?: string;
19
+ animation_mode?: "loop" | "once";
20
+ frame_count?: number;
21
+ frame_size?: 32 | 64 | 128 | 256 | 512 | 720 | 1080;
22
+ };
23
+ max_credits?: number;
56
24
  };
25
+ export declare class InputValidationError extends Error {
26
+ }
27
+ export declare class RecoveryRequiredError extends Error {
28
+ }
29
+ export declare function spriteCreditPrice(frameCount: number): number;
30
+ export declare function validateSpriteInput(input: ManagedExecuteInput): void;
57
31
  export type ManagedInputAsset = {
58
32
  input_asset_id: string;
59
33
  width: number;
@@ -66,7 +40,7 @@ export type ManagedExecution = {
66
40
  status: string;
67
41
  image_job: Record<string, unknown> | null;
68
42
  };
69
- export declare const PUBLIC_ERROR_REASONS: readonly ["invalid_request", "authentication_failed", "access_denied", "resource_not_found", "insufficient_credits", "conflict", "too_many_active_jobs", "rate_limited", "quota_exceeded", "content_refused", "temporarily_unavailable", "generation_failed"];
43
+ export declare const PUBLIC_ERROR_REASONS: readonly ["invalid_request", "authentication_failed", "access_denied", "resource_not_found", "insufficient_credits", "conflict", "too_many_active_jobs", "rate_limited", "quota_exceeded", "content_refused", "temporarily_unavailable", "generation_failed", "insufficient_frames"];
70
44
  export type PublicErrorReason = (typeof PUBLIC_ERROR_REASONS)[number];
71
45
  export declare class ApiError extends Error {
72
46
  readonly status: number;
@@ -74,12 +48,8 @@ export declare class ApiError extends Error {
74
48
  readonly requestId: string | null;
75
49
  readonly code: string;
76
50
  readonly reason: PublicErrorReason;
77
- constructor(status: number, surface?: string, detail?: string | null,
78
- /** Server-assigned id for this failure. The only handle support can search on. */
79
- requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
80
- /** Whether retrying with the same idempotency key is worth doing. */
51
+ constructor(status: number, surface?: string, detail?: string | null, requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
81
52
  get retryable(): boolean;
82
- /** The same stable fields exposed by REST and MCP, with no private response text. */
83
53
  toPublicEnvelope(): Record<string, unknown>;
84
54
  }
85
55
  export type ManagedBalance = {
@@ -88,14 +58,6 @@ export type ManagedBalance = {
88
58
  available: number;
89
59
  credit_usd: "0.17";
90
60
  };
91
- /**
92
- * A stream that went silent, as distinct from a slow one.
93
- *
94
- * Thrown as a real error type because the CLI's exit codes and its retry advice are
95
- * driven off the error, and a bare DOMException from AbortSignal fell through to the
96
- * generic handler: exit 1 with no guidance, on the one failure most likely to have been
97
- * charged for. See ManagedApiError.retryable.
98
- */
99
61
  export declare class StreamIdleError extends Error {
100
62
  readonly idleMs: number;
101
63
  constructor();
@@ -105,32 +67,19 @@ export declare class UploadTimeoutError extends Error {
105
67
  }
106
68
  export declare function uploadTimeoutMs(bytes: number): number;
107
69
  export declare function managedBalance(value: unknown): ManagedBalance;
108
- /** Convert a terminal job failure into the same safe contract used by HTTP errors. */
109
70
  export declare function terminalExecutionError(execution: ManagedExecution): ApiError | null;
110
- /**
111
- * Validate one sanitized event against the published contract.
112
- *
113
- * Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
114
- * schema is that a field appearing where none is documented means something changed
115
- * server-side that a client should not silently consume.
116
- */
117
71
  export declare function managedEvent(event: string, id: string | null, value: unknown): ManagedEvent;
118
72
  export declare class ManagedClient {
119
73
  private readonly apiKey;
120
74
  private readonly baseUrl;
121
75
  private capabilitiesPromise;
122
76
  constructor(apiKey: string, baseUrl?: string);
123
- /**
124
- * Run or continue an execution, yielding each validated event as it arrives.
125
- *
126
- * Streams rather than buffers. The Python server this replaces collected events into
127
- * a list and threw the whole list away on overflow, taking the execution ID with it,
128
- * so a caller could not even resume what it had already paid for.
129
- */
130
77
  execute(input: ManagedExecuteInput, options: {
131
78
  idempotencyKey: string;
132
79
  }): AsyncGenerator<ManagedEvent>;
133
- /** Resume a stream after a drop. Pass the last event id you actually processed. */
80
+ follow(input: ManagedExecuteInput, options: {
81
+ idempotencyKey: string;
82
+ }): AsyncGenerator<ManagedEvent>;
134
83
  events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
135
84
  getCapabilities(): Promise<Record<string, unknown>>;
136
85
  getExecution(executionId: string): Promise<ManagedExecution>;
@@ -139,11 +88,6 @@ export declare class ManagedClient {
139
88
  listConversations(): Promise<Array<Record<string, unknown>>>;
140
89
  deleteConversation(conversationId: string): Promise<void>;
141
90
  uploadInput(file: Blob, filename?: string): Promise<ManagedInputAsset>;
142
- /**
143
- * Fetch a finished asset. Follows redirects on purpose: large images are served
144
- * straight from storage rather than proxied, so a client that refuses redirects
145
- * receives the redirect instead of the image.
146
- */
147
91
  download(url: string): Promise<Uint8Array>;
148
92
  private parse;
149
93
  private fetchStream;
package/dist/client.js CHANGED
@@ -1,39 +1,38 @@
1
- /**
2
- * Hosted client for the DreamLayer Agent API.
3
- *
4
- * Deliberately a COPY of the same file in dreamlayer-mcp rather than a shared package.
5
- * The alternative makes every capability three releases in strict order (client, then
6
- * CLI, then MCP) instead of one release per repo. For a client over eight endpoints
7
- * that trade is not worth the friction.
8
- *
9
- * Lifted from the DreamLayer runtime's TypeScript client, which is retired. The
10
- * validation, error sanitisation, and origin hardening are kept verbatim because they
11
- * were already correct; what changed is that the SSE reader is now wired to the hosted
12
- * client instead of the dead local-proxy class, and that timeouts and an explicit
13
- * redirect policy were added, which the original lacked.
14
- */
15
- /**
16
- * Every operation the Agent API can execute.
17
- *
18
- * REQUIRES the gateway build that added `operation` to ExecuteRequest. Against an older
19
- * deployment this field is rejected with 422 extra_forbidden, because the request model
20
- * is closed. That is a sequencing constraint, not a reason to drop it: naming the
21
- * operation is what stops a cutout or an upscale being re-read from the prompt and
22
- * coming back as a clarifying question instead of an image.
23
- *
24
- * SATISFIED 2026-08-21. prodbeta176 carries the field in the gateway AND the dispatch
25
- * in the workflow engine, which had been split across two releases: the gateway
26
- * accepted `operation` from prodbeta174 while the half that acts on it was still on
27
- * prodbeta172, so naming an operation returned 200 and was then inferred from prose
28
- * anyway. Confirmed against the deployment, not the source: the live /openapi.json
29
- * advertises exactly these four in both ExecuteRequest and ImageJobCreate.
30
- */
31
1
  export const KNOWN_OPERATIONS = [
32
2
  "text_to_image",
33
3
  "image_to_image",
34
4
  "background_remove",
35
5
  "upscale",
6
+ "sprite_sheet",
36
7
  ];
8
+ export class InputValidationError extends Error {
9
+ }
10
+ export class RecoveryRequiredError extends Error {
11
+ }
12
+ export function spriteCreditPrice(frameCount) {
13
+ if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100)
14
+ throw new InputValidationError("frame_count must be an integer from 7 to 100");
15
+ const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
16
+ return Math.ceil(cents * 10 / 17) / 10;
17
+ }
18
+ export function validateSpriteInput(input) {
19
+ if (input.operation !== "sprite_sheet")
20
+ return;
21
+ const options = input.options;
22
+ if (!options || (options.action === undefined) === (options.animation_prompt === undefined))
23
+ throw new InputValidationError("Sprite requests require exactly one of options.action or options.animation_prompt");
24
+ if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action))
25
+ throw new InputValidationError("Invalid sprite preset");
26
+ if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000))
27
+ throw new InputValidationError("animation_prompt must contain 1–4000 characters");
28
+ if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode))
29
+ throw new InputValidationError("animation_mode must be loop or once");
30
+ const price = spriteCreditPrice(options.frame_count ?? 12);
31
+ if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size))
32
+ throw new InputValidationError("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
33
+ if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100)
34
+ throw new InputValidationError(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
35
+ }
37
36
  const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
38
37
  const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
39
38
  const LEGACY_INPUT_SUFFIXES = new Set([
@@ -54,6 +53,7 @@ export const PUBLIC_ERROR_REASONS = [
54
53
  "content_refused",
55
54
  "temporarily_unavailable",
56
55
  "generation_failed",
56
+ "insufficient_frames",
57
57
  ];
58
58
  const PUBLIC_ERROR_SPECS = {
59
59
  invalid_request: { message: "The request could not be validated.", retryable: false },
@@ -82,6 +82,7 @@ const PUBLIC_ERROR_SPECS = {
82
82
  message: "The service is temporarily unavailable. Please try again.",
83
83
  retryable: true,
84
84
  },
85
+ insufficient_frames: { message: "Not enough distinct animation frames. Try a lower frame count.", retryable: false },
85
86
  generation_failed: { message: "Image generation failed.", retryable: false },
86
87
  };
87
88
  const PUBLIC_ERROR_REASON_SET = new Set(PUBLIC_ERROR_REASONS);
@@ -123,6 +124,7 @@ function defaultCode(reason) {
123
124
  content_refused: "CONTENT_REFUSED",
124
125
  temporarily_unavailable: "SERVICE_UNAVAILABLE",
125
126
  generation_failed: "INTERNAL_ERROR",
127
+ insufficient_frames: "INSUFFICIENT_FRAMES",
126
128
  };
127
129
  return codes[reason];
128
130
  }
@@ -140,6 +142,7 @@ function statusForReason(reason) {
140
142
  content_refused: 422,
141
143
  temporarily_unavailable: 503,
142
144
  generation_failed: 500,
145
+ insufficient_frames: 422,
143
146
  };
144
147
  return statuses[reason];
145
148
  }
@@ -153,9 +156,7 @@ export class ApiError extends Error {
153
156
  requestId;
154
157
  code;
155
158
  reason;
156
- constructor(status, surface = "DreamLayer Agent API", detail = null,
157
- /** Server-assigned id for this failure. The only handle support can search on. */
158
- requestId = null, code = null, reason = reasonForStatus(status)) {
159
+ constructor(status, surface = "DreamLayer Agent API", detail = null, requestId = null, code = null, reason = reasonForStatus(status)) {
159
160
  const safeDetail = detail ?? PUBLIC_ERROR_SPECS[reason].message;
160
161
  super(`${safeDetail || `${surface} request failed (${status})`}${requestId ? ` (request ${requestId})` : ""}`);
161
162
  this.status = status;
@@ -165,11 +166,9 @@ export class ApiError extends Error {
165
166
  this.code = code ?? defaultCode(reason);
166
167
  this.reason = reason;
167
168
  }
168
- /** Whether retrying with the same idempotency key is worth doing. */
169
169
  get retryable() {
170
170
  return PUBLIC_ERROR_SPECS[this.reason].retryable;
171
171
  }
172
- /** The same stable fields exposed by REST and MCP, with no private response text. */
173
172
  toPublicEnvelope() {
174
173
  return {
175
174
  error: {
@@ -182,14 +181,6 @@ export class ApiError extends Error {
182
181
  };
183
182
  }
184
183
  }
185
- /**
186
- * A stream that went silent, as distinct from a slow one.
187
- *
188
- * Thrown as a real error type because the CLI's exit codes and its retry advice are
189
- * driven off the error, and a bare DOMException from AbortSignal fell through to the
190
- * generic handler: exit 1 with no guidance, on the one failure most likely to have been
191
- * charged for. See ManagedApiError.retryable.
192
- */
193
184
  export class StreamIdleError extends Error {
194
185
  idleMs = STREAM_IDLE_TIMEOUT_MS;
195
186
  constructor() {
@@ -205,9 +196,6 @@ export class UploadTimeoutError extends Error {
205
196
  }
206
197
  }
207
198
  const ERROR_BODY_LIMIT = 16 * 1024;
208
- /**
209
- * A plain request: send, get a body back. Bounded work, so a total cap is right.
210
- */
211
199
  const REQUEST_TIMEOUT_MS = 130_000;
212
200
  const UPLOAD_MIN_BYTES_PER_SECOND = 256 * 1024;
213
201
  const UPLOAD_MAX_TIMEOUT_MS = 15 * 60_000;
@@ -218,29 +206,7 @@ export function uploadTimeoutMs(bytes) {
218
206
  }
219
207
  return Math.min(Math.max(REQUEST_TIMEOUT_MS, 60_000 + Math.ceil(bytes / UPLOAD_MIN_BYTES_PER_SECOND) * 1000), UPLOAD_MAX_TIMEOUT_MS);
220
208
  }
221
- /**
222
- * A STREAM is different, and conflating the two shipped a broken `upscale`.
223
- *
224
- * AbortSignal.timeout() caps TOTAL duration. An upscale of a 2048px image takes about
225
- * 150s server-side, so a 130s total cap aborted every single one: a command that failed
226
- * 100% of the time on a normal input, while the server had done the work and charged
227
- * for it.
228
- *
229
- * Raising the number would fix upscale and break again on the next slower operation.
230
- * The right question is not "how long may a job take" (unknowable, and it is the
231
- * server's business) but "how long may we hear NOTHING before the connection is dead".
232
- * The server sends `: keepalive` comments precisely so a client can tell those apart;
233
- * a total-duration timeout throws that information away.
234
- *
235
- * So: idle timeout, reset on every byte received.
236
- */
237
209
  const STREAM_IDLE_DEFAULT_MS = 90_000;
238
- /**
239
- * Overridable, within bounds. Two honest reasons rather than one: a test cannot wait
240
- * 90 seconds to prove a timeout fires, and a user on a genuinely bad link may need
241
- * longer. Clamped so a typo cannot disable the guard entirely or set it to zero, and
242
- * an unparseable value falls back rather than becoming NaN, which would abort instantly.
243
- */
244
210
  const STREAM_IDLE_TIMEOUT_MS = (() => {
245
211
  const raw = Number(process.env.DREAMLAYER_STREAM_IDLE_MS);
246
212
  if (!Number.isFinite(raw) || raw <= 0)
@@ -284,16 +250,12 @@ async function apiError(response, surface = "DreamLayer Agent API") {
284
250
  const raw = await boundedErrorBody(response);
285
251
  const parsed = raw ? JSON.parse(raw) : null;
286
252
  if (isRecord(parsed) && isRecord(parsed.error)) {
287
- // Treat only the closed reason/code/id fields as data. The local message table
288
- // deliberately ignores arbitrary remote detail so a private upstream response
289
- // cannot leak through a client even if a server regression serializes it.
290
253
  reason = publicReason(parsed.error.reason) ?? reason;
291
254
  code = publicIdentifier(parsed.error.code);
292
255
  requestId = publicRequestId(parsed.error.request_id) ?? requestId;
293
256
  }
294
257
  }
295
258
  catch {
296
- // A malformed or oversized response still becomes a closed, status-derived error.
297
259
  }
298
260
  return new ApiError(response.status, surface, null, requestId, code, reason);
299
261
  }
@@ -318,14 +280,17 @@ export function managedBalance(value) {
318
280
  throw new Error("Invalid DreamLayer balance response");
319
281
  }
320
282
  for (const field of ["promotional", "purchased", "available"]) {
321
- if (!Number.isSafeInteger(value[field]) || Number(value[field]) < 0) {
283
+ if (typeof value[field] !== "number" || !Number.isFinite(value[field]) || Number(value[field]) < 0 || Number(value[field]) > Number.MAX_SAFE_INTEGER / 10) {
322
284
  throw new Error("Invalid DreamLayer balance response");
323
285
  }
324
286
  }
325
287
  if (value.credit_usd !== "0.17" ||
326
- Number(value.available) !== Number(value.promotional) + Number(value.purchased)) {
288
+ Math.round(Number(value.available) * 10) < Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) ||
289
+ Math.round(Number(value.available) * 10) > Math.round(Number(value.promotional) * 10) + Math.round(Number(value.purchased) * 10) + 1) {
327
290
  throw new Error("Invalid DreamLayer balance response");
328
291
  }
292
+ if ([value.promotional, value.purchased, value.available].some(v => Math.abs(Number(v) * 10 - Math.round(Number(v) * 10)) > 1e-7))
293
+ throw new Error("Invalid DreamLayer balance response");
329
294
  return {
330
295
  promotional: Number(value.promotional),
331
296
  purchased: Number(value.purchased),
@@ -333,7 +298,6 @@ export function managedBalance(value) {
333
298
  credit_usd: "0.17",
334
299
  };
335
300
  }
336
- /** Convert a terminal job failure into the same safe contract used by HTTP errors. */
337
301
  export function terminalExecutionError(execution) {
338
302
  if (!isRecord(execution.image_job) || !isRecord(execution.image_job.sanitized_error)) {
339
303
  return execution.status === "failed" ? new ApiError(500) : null;
@@ -342,13 +306,6 @@ export function terminalExecutionError(execution) {
342
306
  const reason = publicReason(error.reason) ?? "generation_failed";
343
307
  return new ApiError(statusForReason(reason), "DreamLayer execution", null, publicRequestId(error.request_id), publicIdentifier(error.code), reason);
344
308
  }
345
- /**
346
- * Validate one sanitized event against the published contract.
347
- *
348
- * Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
349
- * schema is that a field appearing where none is documented means something changed
350
- * server-side that a client should not silently consume.
351
- */
352
309
  export function managedEvent(event, id, value) {
353
310
  if (!MANAGED_EVENT_NAMES.has(event) || !isRecord(value)) {
354
311
  throw new Error("Invalid DreamLayer managed event");
@@ -417,15 +374,12 @@ export function managedEvent(event, id, value) {
417
374
  }
418
375
  return { id, event: event, data };
419
376
  }
420
- /** Parse a server-sent-event body into blocks. Handles multi-line data and comments. */
421
377
  async function* readEventStream(body, onBytes) {
422
378
  const reader = body.getReader();
423
379
  const decoder = new TextDecoder();
424
380
  let buffer = "";
425
381
  for (;;) {
426
382
  const { value, done } = await reader.read();
427
- // Any byte at all, including a `: keepalive` comment that parses to no event,
428
- // proves the connection is alive. That is the signal the idle timer needs.
429
383
  if (!done)
430
384
  onBytes?.();
431
385
  buffer += decoder.decode(value, { stream: !done });
@@ -454,18 +408,6 @@ async function* readEventStream(body, onBytes) {
454
408
  return;
455
409
  }
456
410
  }
457
- /**
458
- * Hosts this client will send a bearer key to.
459
- *
460
- * In August a build moved the endpoint default from api.dreamlayer.io to the bare
461
- * marketing apex, and every request carried Authorization there for two days. The
462
- * origin passed every cleanliness check below, because those check the SHAPE of a URL
463
- * and never which host it names. An allowlist is the only thing that catches a host
464
- * swap, which is why the gateway now has a pinned-origin test and why this mirrors it.
465
- *
466
- * DREAMLAYER_API_URL still works for a genuinely different deployment: set
467
- * DREAMLAYER_ALLOW_ANY_HOST=1 alongside it and accept that you are vouching for the host.
468
- */
469
411
  const ALLOWED_HOSTS = new Set(["api.dreamlayer.io"]);
470
412
  function isLoopback(hostname) {
471
413
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
@@ -510,14 +452,8 @@ export class ManagedClient {
510
452
  throw new Error("DREAMLAYER_API_KEY is required");
511
453
  this.baseUrl = managedOrigin(baseUrl);
512
454
  }
513
- /**
514
- * Run or continue an execution, yielding each validated event as it arrives.
515
- *
516
- * Streams rather than buffers. The Python server this replaces collected events into
517
- * a list and threw the whole list away on overflow, taking the execution ID with it,
518
- * so a caller could not even resume what it had already paid for.
519
- */
520
455
  async *execute(input, options) {
456
+ validateSpriteInput(input);
521
457
  const stream = await this.fetchStream("/v1/execute", {
522
458
  method: "POST",
523
459
  headers: {
@@ -529,7 +465,53 @@ export class ManagedClient {
529
465
  });
530
466
  yield* this.parse(stream);
531
467
  }
532
- /** Resume a stream after a drop. Pass the last event id you actually processed. */
468
+ async *follow(input, options) {
469
+ let executionId;
470
+ let cursor;
471
+ let stream = this.execute(input, options);
472
+ const deadline = Date.now() + 16 * 60_000;
473
+ let failures = 0;
474
+ while (Date.now() < deadline) {
475
+ try {
476
+ for await (const event of stream) {
477
+ if (event.event === "started")
478
+ executionId = String(event.data.execution_id);
479
+ if (event.id)
480
+ cursor = event.id;
481
+ yield event;
482
+ if (event.event === "done")
483
+ return;
484
+ }
485
+ failures = 0;
486
+ }
487
+ catch (error) {
488
+ if (error instanceof StreamIdleError)
489
+ throw error;
490
+ if (error instanceof InputValidationError || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)))
491
+ throw error;
492
+ if (!executionId && error instanceof ApiError)
493
+ throw error;
494
+ if (!executionId || ++failures > 5)
495
+ throw new RecoveryRequiredError("Execution state is uncertain. Read saved state before retrying.");
496
+ }
497
+ if (!executionId)
498
+ throw new RecoveryRequiredError("Execution stream ended before an identifier was received; reuse your idempotency key.");
499
+ const state = await this.getExecution(executionId);
500
+ if (["completed", "failed", "cancelled"].includes(state.status)) {
501
+ if (state.status === "completed") {
502
+ const assets = state.image_job?.finished_assets;
503
+ if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
504
+ throw new RecoveryRequiredError(`Execution ${executionId} has no downloadable asset yet.`);
505
+ yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url });
506
+ }
507
+ yield managedEvent("done", null, { status: state.status });
508
+ return;
509
+ }
510
+ await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures)));
511
+ stream = this.events(executionId, cursor);
512
+ }
513
+ throw new RecoveryRequiredError(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
514
+ }
533
515
  async *events(executionId, lastEventId) {
534
516
  const headers = { Accept: "text/event-stream" };
535
517
  if (lastEventId)
@@ -539,8 +521,6 @@ export class ManagedClient {
539
521
  }
540
522
  async getCapabilities() {
541
523
  this.capabilitiesPromise ??= this.request("/v1/capabilities").catch((error) => {
542
- // Cache a successful contract for the process, but never pin a transient
543
- // capabilities failure as a permanent result.
544
524
  this.capabilitiesPromise = null;
545
525
  throw error;
546
526
  });
@@ -624,17 +604,7 @@ export class ManagedClient {
624
604
  body.append("file", file, filename);
625
605
  return this.request("/v1/input-assets", { method: "POST", body });
626
606
  }
627
- /**
628
- * Fetch a finished asset. Follows redirects on purpose: large images are served
629
- * straight from storage rather than proxied, so a client that refuses redirects
630
- * receives the redirect instead of the image.
631
- */
632
607
  async download(url) {
633
- // Only attach the key when the URL is OUR origin. download_url arrives in the event
634
- // stream and is validated as text, so a wrong or hostile value would otherwise walk
635
- // off with a live credential on the very first request. Node strips Authorization
636
- // across a cross-origin redirect, so the hop to signed storage stays safe either way,
637
- // and storage URLs are pre-signed and need no header from us.
638
608
  const sameOrigin = (() => {
639
609
  try {
640
610
  return new URL(url).origin === this.baseUrl;
@@ -664,9 +634,6 @@ export class ManagedClient {
664
634
  }
665
635
  }
666
636
  finally {
667
- // Also runs when the consumer breaks out of the loop early, which the CLI does
668
- // as soon as it sees a terminal event. Without this the timer keeps the process
669
- // alive for another idle period.
670
637
  finish();
671
638
  }
672
639
  }
@@ -674,9 +641,6 @@ export class ManagedClient {
674
641
  const headers = new Headers(init.headers);
675
642
  headers.set("Authorization", `Bearer ${this.apiKey}`);
676
643
  headers.set("DreamLayer-Version", "1");
677
- // One controller for the whole stream, armed on an IDLE clock that every received
678
- // byte pushes forward. The signal has to outlive the fetch() call: aborting only
679
- // the handshake would leave a stalled body hanging forever.
680
644
  const controller = new AbortController();
681
645
  let timer;
682
646
  const keepAlive = () => {
package/dist/render.d.ts CHANGED
@@ -1,11 +1,3 @@
1
- /**
2
- * Terminal rendering of an execution.
3
- *
4
- * Two modes, decided by whether stdout is a TTY and whether --json was passed. A CLI
5
- * that only prints prose cannot be piped into anything, and one that only prints JSON
6
- * is miserable to watch. Progress goes to stderr so `dreamlayer generate ... | jq`
7
- * works without the spinner corrupting the pipe.
8
- */
9
1
  import type { ManagedEvent } from "./client.js";
10
2
  export type Outcome = {
11
3
  execution_id: string | null;
package/dist/render.js CHANGED
@@ -34,7 +34,6 @@ export class Progress {
34
34
  process.stderr.write(`${final}\n`);
35
35
  }
36
36
  }
37
- /** Human wording for each event. `thinking` carries no text by contract. */
38
37
  function describe(event) {
39
38
  switch (event.event) {
40
39
  case "started":
@@ -97,13 +96,6 @@ export async function consume(stream, progress, onEvent) {
97
96
  }
98
97
  }
99
98
  catch (error) {
100
- // `started` arrives within seconds and carries the execution id. When the stream
101
- // later dies, that id is the only way a user can find a job they may already have
102
- // paid for, and it was being discarded along with the exception.
103
- //
104
- // Attached to the error rather than wrapped in a new one: the top-level handler
105
- // branches on `instanceof ApiError`, and a wrapper would silently defeat that
106
- // while looking tidier.
107
99
  if (error !== null && typeof error === "object") {
108
100
  error.partialOutcome = outcome;
109
101
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dreamlayer",
3
- "version": "0.3.0",
3
+ "version": "0.4.0-beta.2",
4
4
  "description": "Generate and edit images from your terminal, over local files, with one API key.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,11 +31,16 @@
31
31
  "cli",
32
32
  "dreamlayer",
33
33
  "ai",
34
- "image-editing"
34
+ "image-editing",
35
+ "sprite-sheet",
36
+ "automation"
35
37
  ],
36
38
  "repository": {
37
39
  "type": "git",
38
40
  "url": "git+https://github.com/TheDesignFounder/dreamlayer-cli.git"
39
41
  },
40
- "homepage": "https://docs.dreamlayer.io/cli"
42
+ "homepage": "https://docs.dreamlayer.io/cli",
43
+ "publishConfig": {
44
+ "tag": "beta"
45
+ }
41
46
  }