dreamlayer 0.4.0-beta.1 → 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
 
@@ -124,3 +125,45 @@ dreamlayer status EXECUTION_ID
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.
125
126
 
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,25 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { spriteCreditPrice } from "./client.js";
3
- /**
4
- * DreamLayer CLI.
5
- *
6
- * Generate and edit images from a terminal, over local files. The API is the same one
7
- * the MCP server and the web app use, and it spends from the same credit balance.
8
- *
9
- * Exit codes are meaningful, so this composes in a script:
10
- * 0 success
11
- * 1 usage error
12
- * 2 authentication or account problem (401, 403)
13
- * 3 out of credits (402)
14
- * 4 the request was rejected (409, 422)
15
- * 5 temporary, worth retrying (429, 5xx)
16
- * 6 the run ended asking a question instead of producing an image
17
- */
18
3
  import { randomUUID } from "node:crypto";
19
- import { openAsBlob, readFileSync } from "node:fs";
20
- 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";
21
6
  import path from "node:path";
22
- 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";
23
8
  import { Progress, consume } from "./render.js";
24
9
  const PACKAGE_VERSION = String(JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version);
25
10
  const USAGE = `dreamlayer - generate and edit images from your terminal
@@ -31,12 +16,14 @@ USAGE
31
16
  dreamlayer upscale <image> [--out <file>]
32
17
  dreamlayer sprite <image> --action <walk|run|idle> [--frames <7–100>] --max-credits <n> [--out <zip>]
33
18
  dreamlayer answer <conversation-id> <text> [--image <file>] [--out <file>]
19
+ dreamlayer download <execution-id> --out <file>
34
20
  dreamlayer status <execution-id>
35
21
  dreamlayer balance
36
22
  dreamlayer capabilities
37
23
 
38
24
  OPTIONS
39
- --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.
40
27
  --image <file> Attach an image when answering a question that asks for one
41
28
  --aspect <ratio> 1:1, 16:9, 9:16, 4:3, 3:4. Default 1:1
42
29
  --action <name> Sprite preset: walk, run, idle (walk if no custom prompt)
@@ -45,10 +32,19 @@ OPTIONS
45
32
  --frame-size <px> Square export: 32, 64, 128, 256, 512 (default), 720, 1080
46
33
  --frames <n> Frame count: integer 7–100, default 12
47
34
  --max-credits <n> Maximum approved charge for the sprite job
48
- --json Machine-readable output on stdout
35
+ --json JSON results on stdout; JSON errors on stderr
49
36
  --quiet No progress on stderr
50
37
  --idempotency-key <key> Reuse to retry safely after an uncertain response
51
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
+
52
48
  ENVIRONMENT
53
49
  DREAMLAYER_API_KEY Required. Get one at https://platform.dreamlayer.io
54
50
  DREAMLAYER_API_URL Override the endpoint. Default https://api.dreamlayer.io
@@ -57,6 +53,59 @@ Image operations cost one credit. Sprite pricing is listed in capabilities. A ne
57
53
  `;
58
54
  class UsageError extends Error {
59
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
+ }
60
109
  function parseOptions(argv) {
61
110
  const positional = [];
62
111
  const options = {
@@ -147,14 +196,11 @@ function parseOptions(argv) {
147
196
  function client() {
148
197
  const key = (process.env.DREAMLAYER_API_KEY ?? "").trim();
149
198
  if (!key) {
150
- throw new UsageError("DREAMLAYER_API_KEY is not set.\n" +
151
- " export DREAMLAYER_API_KEY=dlr_live_...\n" +
152
- " 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);
153
200
  }
154
201
  return new ManagedClient(key, (process.env.DREAMLAYER_API_URL ?? "https://api.dreamlayer.io").trim());
155
202
  }
156
203
  const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
157
- /** Upload a local file; the server owns RAW, EXIF, alpha, and resize normalization. */
158
204
  async function upload(api, file) {
159
205
  const resolved = path.resolve(file);
160
206
  let fileStat;
@@ -162,30 +208,38 @@ async function upload(api, file) {
162
208
  fileStat = await stat(resolved);
163
209
  }
164
210
  catch {
165
- throw new UsageError(`cannot read ${file}`);
211
+ throw new CommandError("local_input_failed", "The local input file could not be read.", 1);
166
212
  }
167
213
  if (fileStat.size > MAX_SOURCE_BYTES) {
168
214
  throw new UsageError(`${file} is ${Math.round(fileStat.size / 1024 / 1024)} MB; the limit is 200 MB`);
169
215
  }
170
- 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));
171
224
  return asset.input_asset_id;
172
225
  }
173
226
  function defaultOut() {
174
227
  return `dreamlayer-${Date.now()}.png`;
175
228
  }
229
+ let recovery = {};
176
230
  async function run(api, input, options) {
177
231
  const progress = new Progress(!options.quiet && process.stderr.isTTY === true);
178
232
  const idempotencyKey = options.idempotencyKey ?? randomUUID();
233
+ recovery = { idempotency_key: idempotencyKey };
179
234
  const outcome = await consume(api.follow(input, { idempotencyKey }), progress);
235
+ recovery.execution_id = outcome.execution_id;
180
236
  if (outcome.question) {
181
237
  progress.stop();
182
238
  if (options.json) {
183
- process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
239
+ process.stdout.write(`${JSON.stringify({ ...outcome, idempotency_key: idempotencyKey }, null, 2)}\n`);
184
240
  }
185
241
  else {
186
242
  process.stderr.write(`\nDreamLayer needs one more thing:\n ${outcome.question.text}\n\n`);
187
- // The server's needs_input question always asks for an image, so point at the
188
- // flag that can supply one rather than the bare form that will 422.
189
243
  const wantsImage = /image/i.test(outcome.question.text);
190
244
  process.stderr.write(`Answer it with:\n dreamlayer answer ${outcome.conversation_id} "your answer"` +
191
245
  `${wantsImage ? " --image <file>" : ""}\n`);
@@ -199,22 +253,21 @@ async function run(api, input, options) {
199
253
  if (terminal)
200
254
  throw terminal;
201
255
  }
202
- if (options.json)
203
- process.stdout.write(`${JSON.stringify(outcome, null, 2)}\n`);
204
- else
205
- process.stderr.write(`Run ended as ${outcome.status}. No credit was settled.\n`);
206
- 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.");
207
261
  }
208
262
  progress.set("Downloading");
209
- const bytes = await api.download(outcome.asset.download_url);
263
+ const bytes = await downloadOutput(api, outcome.asset.download_url);
210
264
  const target = options.out ?? (input.operation === "sprite_sheet" ? `dreamlayer-${Date.now()}.zip` : defaultOut());
211
- await writeFile(target, bytes);
265
+ await saveOutput(target, bytes);
212
266
  progress.stop();
213
267
  if (options.json) {
214
- 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`);
215
269
  }
216
270
  else {
217
- // The path on stdout and nothing else, so `$(dreamlayer generate ...)` is the file.
218
271
  process.stdout.write(`${target}\n`);
219
272
  }
220
273
  return 0;
@@ -224,13 +277,6 @@ async function imageCommand(operation, prompt, file, options) {
224
277
  const inputAssetId = await upload(api, file);
225
278
  return run(api, { prompt, operation, input_asset_id: inputAssetId }, options);
226
279
  }
227
- /**
228
- * Point a user at the job they may have paid for.
229
- *
230
- * Without this, a timed-out upscale left nothing to go on: no id, no command, and no
231
- * key-authenticated way to check a balance. "It might have charged you, good luck" is
232
- * not an acceptable end state for a paid call.
233
- */
234
280
  function recoveryHint(error) {
235
281
  const id = error !== null && typeof error === "object"
236
282
  ? error.partialOutcome
@@ -238,25 +284,6 @@ function recoveryHint(error) {
238
284
  : null;
239
285
  return id ? `The job may still be running. Check it with:\n dreamlayer status ${id}\n` : "";
240
286
  }
241
- /**
242
- * Say so when this build and the server disagree about what exists.
243
- *
244
- * The MCP package solves this by asking the server at startup and shaping its tool
245
- * schema from the answer. The CLI cannot: `ManagedOperation` is a compile-time union and
246
- * `cutout` / `upscale` are compile-time commands, so deriving the list at runtime would
247
- * buy consistency by giving up type safety at every call site.
248
- *
249
- * So it reports instead of adapting, and it does so HERE because `capabilities` is free,
250
- * spends nothing, and is the command people are told to run first. Both directions are
251
- * worth naming:
252
- *
253
- * - the server offers something this build cannot reach -> the user is missing a
254
- * feature they are paying for and would never know
255
- * - this build names something the server will not run -> the failure that shipped on
256
- * 2026-08-21, where a call looked like a client bug rather than a version skew
257
- *
258
- * stderr, never stdout: `dreamlayer capabilities` is piped into jq.
259
- */
260
287
  function warnIfOperationsDrifted(capabilities) {
261
288
  const listed = capabilities.operations;
262
289
  if (!Array.isArray(listed) || listed.some((o) => typeof o !== "string"))
@@ -290,11 +317,19 @@ async function main(argv) {
290
317
  process.stdout.write(USAGE);
291
318
  return command ? 0 : 1;
292
319
  }
320
+ if (rest.includes("--help") || rest.includes("-h")) {
321
+ process.stdout.write(USAGE);
322
+ return 0;
323
+ }
293
324
  if (command === "--version" || command === "-v") {
294
325
  process.stdout.write(`${PACKAGE_VERSION}\n`);
295
326
  return 0;
296
327
  }
297
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
+ }
298
333
  switch (command) {
299
334
  case "sprite": {
300
335
  if (options.action && options.animationPrompt)
@@ -342,10 +377,6 @@ async function main(argv) {
342
377
  if (!conversationId || !text)
343
378
  throw new UsageError("answer needs a conversation id and text");
344
379
  const api = client();
345
- // A question that asks for an image cannot be answered with words alone: the
346
- // server refuses a reply with no asset when the pending question required one.
347
- // Without --image this command reached a clean 422 and the documented path
348
- // dead-ended, telling the user to attach an image and offering no way to do it.
349
380
  const input = options.image ? await upload(api, options.image) : undefined;
350
381
  return run(api, {
351
382
  respond: text,
@@ -353,6 +384,21 @@ async function main(argv) {
353
384
  ...(input ? { input_asset_id: input } : {}),
354
385
  }, options);
355
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
+ }
356
402
  case "status": {
357
403
  const executionId = positional[0];
358
404
  if (!executionId)
@@ -392,6 +438,39 @@ main(process.argv.slice(2))
392
438
  process.exitCode = code;
393
439
  })
394
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
+ }
395
474
  if (error instanceof UsageError) {
396
475
  process.stderr.write(`${error.message}\n`);
397
476
  process.exitCode = 1;
@@ -412,11 +491,6 @@ main(process.argv.slice(2))
412
491
  process.exitCode = exitCodeFor(error);
413
492
  return;
414
493
  }
415
- // A stream that went silent is retryable, and it is the failure MOST likely to
416
- // have been charged for: the server may have finished the job we stopped listening
417
- // to. It reached this generic branch as a bare DOMException, so it exited 1 with no
418
- // guidance, and the --idempotency-key advice that exists precisely to prevent
419
- // double payment never printed on the one case that needs it.
420
494
  if (error instanceof StreamIdleError) {
421
495
  process.stderr.write(`${error.message}\n`);
422
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
7
  export declare const KNOWN_OPERATIONS: readonly ["text_to_image", "image_to_image", "background_remove", "upscale", "sprite_sheet"];
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
- */
47
8
  export type ManagedOperation = (typeof KNOWN_OPERATIONS)[number];
48
9
  export type ManagedExecuteInput = {
49
10
  prompt?: string;
@@ -51,7 +12,6 @@ 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;
56
16
  options?: {
57
17
  action?: "walk" | "run" | "idle";
@@ -62,6 +22,10 @@ export type ManagedExecuteInput = {
62
22
  };
63
23
  max_credits?: number;
64
24
  };
25
+ export declare class InputValidationError extends Error {
26
+ }
27
+ export declare class RecoveryRequiredError extends Error {
28
+ }
65
29
  export declare function spriteCreditPrice(frameCount: number): number;
66
30
  export declare function validateSpriteInput(input: ManagedExecuteInput): void;
67
31
  export type ManagedInputAsset = {
@@ -84,12 +48,8 @@ export declare class ApiError extends Error {
84
48
  readonly requestId: string | null;
85
49
  readonly code: string;
86
50
  readonly reason: PublicErrorReason;
87
- constructor(status: number, surface?: string, detail?: string | null,
88
- /** Server-assigned id for this failure. The only handle support can search on. */
89
- requestId?: string | null, code?: string | null, reason?: PublicErrorReason);
90
- /** 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);
91
52
  get retryable(): boolean;
92
- /** The same stable fields exposed by REST and MCP, with no private response text. */
93
53
  toPublicEnvelope(): Record<string, unknown>;
94
54
  }
95
55
  export type ManagedBalance = {
@@ -98,14 +58,6 @@ export type ManagedBalance = {
98
58
  available: number;
99
59
  credit_usd: "0.17";
100
60
  };
101
- /**
102
- * A stream that went silent, as distinct from a slow one.
103
- *
104
- * Thrown as a real error type because the CLI's exit codes and its retry advice are
105
- * driven off the error, and a bare DOMException from AbortSignal fell through to the
106
- * generic handler: exit 1 with no guidance, on the one failure most likely to have been
107
- * charged for. See ManagedApiError.retryable.
108
- */
109
61
  export declare class StreamIdleError extends Error {
110
62
  readonly idleMs: number;
111
63
  constructor();
@@ -115,36 +67,19 @@ export declare class UploadTimeoutError extends Error {
115
67
  }
116
68
  export declare function uploadTimeoutMs(bytes: number): number;
117
69
  export declare function managedBalance(value: unknown): ManagedBalance;
118
- /** Convert a terminal job failure into the same safe contract used by HTTP errors. */
119
70
  export declare function terminalExecutionError(execution: ManagedExecution): ApiError | null;
120
- /**
121
- * Validate one sanitized event against the published contract.
122
- *
123
- * Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
124
- * schema is that a field appearing where none is documented means something changed
125
- * server-side that a client should not silently consume.
126
- */
127
71
  export declare function managedEvent(event: string, id: string | null, value: unknown): ManagedEvent;
128
72
  export declare class ManagedClient {
129
73
  private readonly apiKey;
130
74
  private readonly baseUrl;
131
75
  private capabilitiesPromise;
132
76
  constructor(apiKey: string, baseUrl?: string);
133
- /**
134
- * Run or continue an execution, yielding each validated event as it arrives.
135
- *
136
- * Streams rather than buffers. The Python server this replaces collected events into
137
- * a list and threw the whole list away on overflow, taking the execution ID with it,
138
- * so a caller could not even resume what it had already paid for.
139
- */
140
77
  execute(input: ManagedExecuteInput, options: {
141
78
  idempotencyKey: string;
142
79
  }): AsyncGenerator<ManagedEvent>;
143
- /** Follow a durable job across finite streams without submitting it twice. */
144
80
  follow(input: ManagedExecuteInput, options: {
145
81
  idempotencyKey: string;
146
82
  }): AsyncGenerator<ManagedEvent>;
147
- /** Resume a stream after a drop. Pass the last event id you actually processed. */
148
83
  events(executionId: string, lastEventId?: string): AsyncGenerator<ManagedEvent>;
149
84
  getCapabilities(): Promise<Record<string, unknown>>;
150
85
  getExecution(executionId: string): Promise<ManagedExecution>;
@@ -153,11 +88,6 @@ export declare class ManagedClient {
153
88
  listConversations(): Promise<Array<Record<string, unknown>>>;
154
89
  deleteConversation(conversationId: string): Promise<void>;
155
90
  uploadInput(file: Blob, filename?: string): Promise<ManagedInputAsset>;
156
- /**
157
- * Fetch a finished asset. Follows redirects on purpose: large images are served
158
- * straight from storage rather than proxied, so a client that refuses redirects
159
- * receives the redirect instead of the image.
160
- */
161
91
  download(url: string): Promise<Uint8Array>;
162
92
  private parse;
163
93
  private fetchStream;
package/dist/client.js CHANGED
@@ -1,33 +1,3 @@
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",
@@ -35,9 +5,13 @@ export const KNOWN_OPERATIONS = [
35
5
  "upscale",
36
6
  "sprite_sheet",
37
7
  ];
8
+ export class InputValidationError extends Error {
9
+ }
10
+ export class RecoveryRequiredError extends Error {
11
+ }
38
12
  export function spriteCreditPrice(frameCount) {
39
13
  if (!Number.isInteger(frameCount) || frameCount < 7 || frameCount > 100)
40
- throw new Error("frame_count must be an integer from 7 to 100");
14
+ throw new InputValidationError("frame_count must be an integer from 7 to 100");
41
15
  const cents = 14 * Math.min(frameCount, 14) + 7 * Math.max(frameCount - 14, 0);
42
16
  return Math.ceil(cents * 10 / 17) / 10;
43
17
  }
@@ -46,18 +20,18 @@ export function validateSpriteInput(input) {
46
20
  return;
47
21
  const options = input.options;
48
22
  if (!options || (options.action === undefined) === (options.animation_prompt === undefined))
49
- throw new Error("Sprite requests require exactly one of options.action or options.animation_prompt");
23
+ throw new InputValidationError("Sprite requests require exactly one of options.action or options.animation_prompt");
50
24
  if (options.action !== undefined && !["walk", "run", "idle"].includes(options.action))
51
- throw new Error("Invalid sprite preset");
25
+ throw new InputValidationError("Invalid sprite preset");
52
26
  if (options.animation_prompt !== undefined && (typeof options.animation_prompt !== "string" || !options.animation_prompt.trim() || [...options.animation_prompt].length > 4000))
53
- throw new Error("animation_prompt must contain 1–4000 characters");
27
+ throw new InputValidationError("animation_prompt must contain 1–4000 characters");
54
28
  if (options.animation_mode !== undefined && !["loop", "once"].includes(options.animation_mode))
55
- throw new Error("animation_mode must be loop or once");
29
+ throw new InputValidationError("animation_mode must be loop or once");
56
30
  const price = spriteCreditPrice(options.frame_count ?? 12);
57
31
  if (options.frame_size !== undefined && ![32, 64, 128, 256, 512, 720, 1080].includes(options.frame_size))
58
- throw new Error("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
32
+ throw new InputValidationError("frame_size must be 32, 64, 128, 256, 512, 720 or 1080");
59
33
  if (typeof input.max_credits !== "number" || !Number.isFinite(input.max_credits) || input.max_credits < price || input.max_credits > 100)
60
- throw new Error(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
34
+ throw new InputValidationError(`This sprite request requires ${price} credits. Supply a sufficient max_credits limit.`);
61
35
  }
62
36
  const DIRECT_INPUT_BYTES = 20 * 1024 * 1024;
63
37
  const RASTER_INPUT_SUFFIXES = new Set([".png", ".jpg", ".jpeg", ".webp"]);
@@ -182,9 +156,7 @@ export class ApiError extends Error {
182
156
  requestId;
183
157
  code;
184
158
  reason;
185
- constructor(status, surface = "DreamLayer Agent API", detail = null,
186
- /** Server-assigned id for this failure. The only handle support can search on. */
187
- requestId = null, code = null, reason = reasonForStatus(status)) {
159
+ constructor(status, surface = "DreamLayer Agent API", detail = null, requestId = null, code = null, reason = reasonForStatus(status)) {
188
160
  const safeDetail = detail ?? PUBLIC_ERROR_SPECS[reason].message;
189
161
  super(`${safeDetail || `${surface} request failed (${status})`}${requestId ? ` (request ${requestId})` : ""}`);
190
162
  this.status = status;
@@ -194,11 +166,9 @@ export class ApiError extends Error {
194
166
  this.code = code ?? defaultCode(reason);
195
167
  this.reason = reason;
196
168
  }
197
- /** Whether retrying with the same idempotency key is worth doing. */
198
169
  get retryable() {
199
170
  return PUBLIC_ERROR_SPECS[this.reason].retryable;
200
171
  }
201
- /** The same stable fields exposed by REST and MCP, with no private response text. */
202
172
  toPublicEnvelope() {
203
173
  return {
204
174
  error: {
@@ -211,14 +181,6 @@ export class ApiError extends Error {
211
181
  };
212
182
  }
213
183
  }
214
- /**
215
- * A stream that went silent, as distinct from a slow one.
216
- *
217
- * Thrown as a real error type because the CLI's exit codes and its retry advice are
218
- * driven off the error, and a bare DOMException from AbortSignal fell through to the
219
- * generic handler: exit 1 with no guidance, on the one failure most likely to have been
220
- * charged for. See ManagedApiError.retryable.
221
- */
222
184
  export class StreamIdleError extends Error {
223
185
  idleMs = STREAM_IDLE_TIMEOUT_MS;
224
186
  constructor() {
@@ -234,9 +196,6 @@ export class UploadTimeoutError extends Error {
234
196
  }
235
197
  }
236
198
  const ERROR_BODY_LIMIT = 16 * 1024;
237
- /**
238
- * A plain request: send, get a body back. Bounded work, so a total cap is right.
239
- */
240
199
  const REQUEST_TIMEOUT_MS = 130_000;
241
200
  const UPLOAD_MIN_BYTES_PER_SECOND = 256 * 1024;
242
201
  const UPLOAD_MAX_TIMEOUT_MS = 15 * 60_000;
@@ -247,29 +206,7 @@ export function uploadTimeoutMs(bytes) {
247
206
  }
248
207
  return Math.min(Math.max(REQUEST_TIMEOUT_MS, 60_000 + Math.ceil(bytes / UPLOAD_MIN_BYTES_PER_SECOND) * 1000), UPLOAD_MAX_TIMEOUT_MS);
249
208
  }
250
- /**
251
- * A STREAM is different, and conflating the two shipped a broken `upscale`.
252
- *
253
- * AbortSignal.timeout() caps TOTAL duration. An upscale of a 2048px image takes about
254
- * 150s server-side, so a 130s total cap aborted every single one: a command that failed
255
- * 100% of the time on a normal input, while the server had done the work and charged
256
- * for it.
257
- *
258
- * Raising the number would fix upscale and break again on the next slower operation.
259
- * The right question is not "how long may a job take" (unknowable, and it is the
260
- * server's business) but "how long may we hear NOTHING before the connection is dead".
261
- * The server sends `: keepalive` comments precisely so a client can tell those apart;
262
- * a total-duration timeout throws that information away.
263
- *
264
- * So: idle timeout, reset on every byte received.
265
- */
266
209
  const STREAM_IDLE_DEFAULT_MS = 90_000;
267
- /**
268
- * Overridable, within bounds. Two honest reasons rather than one: a test cannot wait
269
- * 90 seconds to prove a timeout fires, and a user on a genuinely bad link may need
270
- * longer. Clamped so a typo cannot disable the guard entirely or set it to zero, and
271
- * an unparseable value falls back rather than becoming NaN, which would abort instantly.
272
- */
273
210
  const STREAM_IDLE_TIMEOUT_MS = (() => {
274
211
  const raw = Number(process.env.DREAMLAYER_STREAM_IDLE_MS);
275
212
  if (!Number.isFinite(raw) || raw <= 0)
@@ -313,16 +250,12 @@ async function apiError(response, surface = "DreamLayer Agent API") {
313
250
  const raw = await boundedErrorBody(response);
314
251
  const parsed = raw ? JSON.parse(raw) : null;
315
252
  if (isRecord(parsed) && isRecord(parsed.error)) {
316
- // Treat only the closed reason/code/id fields as data. The local message table
317
- // deliberately ignores arbitrary remote detail so a private upstream response
318
- // cannot leak through a client even if a server regression serializes it.
319
253
  reason = publicReason(parsed.error.reason) ?? reason;
320
254
  code = publicIdentifier(parsed.error.code);
321
255
  requestId = publicRequestId(parsed.error.request_id) ?? requestId;
322
256
  }
323
257
  }
324
258
  catch {
325
- // A malformed or oversized response still becomes a closed, status-derived error.
326
259
  }
327
260
  return new ApiError(response.status, surface, null, requestId, code, reason);
328
261
  }
@@ -365,7 +298,6 @@ export function managedBalance(value) {
365
298
  credit_usd: "0.17",
366
299
  };
367
300
  }
368
- /** Convert a terminal job failure into the same safe contract used by HTTP errors. */
369
301
  export function terminalExecutionError(execution) {
370
302
  if (!isRecord(execution.image_job) || !isRecord(execution.image_job.sanitized_error)) {
371
303
  return execution.status === "failed" ? new ApiError(500) : null;
@@ -374,13 +306,6 @@ export function terminalExecutionError(execution) {
374
306
  const reason = publicReason(error.reason) ?? "generation_failed";
375
307
  return new ApiError(statusForReason(reason), "DreamLayer execution", null, publicRequestId(error.request_id), publicIdentifier(error.code), reason);
376
308
  }
377
- /**
378
- * Validate one sanitized event against the published contract.
379
- *
380
- * Deliberately strict, including rejecting UNKNOWN fields: the point of the closed
381
- * schema is that a field appearing where none is documented means something changed
382
- * server-side that a client should not silently consume.
383
- */
384
309
  export function managedEvent(event, id, value) {
385
310
  if (!MANAGED_EVENT_NAMES.has(event) || !isRecord(value)) {
386
311
  throw new Error("Invalid DreamLayer managed event");
@@ -449,15 +374,12 @@ export function managedEvent(event, id, value) {
449
374
  }
450
375
  return { id, event: event, data };
451
376
  }
452
- /** Parse a server-sent-event body into blocks. Handles multi-line data and comments. */
453
377
  async function* readEventStream(body, onBytes) {
454
378
  const reader = body.getReader();
455
379
  const decoder = new TextDecoder();
456
380
  let buffer = "";
457
381
  for (;;) {
458
382
  const { value, done } = await reader.read();
459
- // Any byte at all, including a `: keepalive` comment that parses to no event,
460
- // proves the connection is alive. That is the signal the idle timer needs.
461
383
  if (!done)
462
384
  onBytes?.();
463
385
  buffer += decoder.decode(value, { stream: !done });
@@ -486,18 +408,6 @@ async function* readEventStream(body, onBytes) {
486
408
  return;
487
409
  }
488
410
  }
489
- /**
490
- * Hosts this client will send a bearer key to.
491
- *
492
- * In August a build moved the endpoint default from api.dreamlayer.io to the bare
493
- * marketing apex, and every request carried Authorization there for two days. The
494
- * origin passed every cleanliness check below, because those check the SHAPE of a URL
495
- * and never which host it names. An allowlist is the only thing that catches a host
496
- * swap, which is why the gateway now has a pinned-origin test and why this mirrors it.
497
- *
498
- * DREAMLAYER_API_URL still works for a genuinely different deployment: set
499
- * DREAMLAYER_ALLOW_ANY_HOST=1 alongside it and accept that you are vouching for the host.
500
- */
501
411
  const ALLOWED_HOSTS = new Set(["api.dreamlayer.io"]);
502
412
  function isLoopback(hostname) {
503
413
  return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
@@ -542,13 +452,6 @@ export class ManagedClient {
542
452
  throw new Error("DREAMLAYER_API_KEY is required");
543
453
  this.baseUrl = managedOrigin(baseUrl);
544
454
  }
545
- /**
546
- * Run or continue an execution, yielding each validated event as it arrives.
547
- *
548
- * Streams rather than buffers. The Python server this replaces collected events into
549
- * a list and threw the whole list away on overflow, taking the execution ID with it,
550
- * so a caller could not even resume what it had already paid for.
551
- */
552
455
  async *execute(input, options) {
553
456
  validateSpriteInput(input);
554
457
  const stream = await this.fetchStream("/v1/execute", {
@@ -562,7 +465,6 @@ export class ManagedClient {
562
465
  });
563
466
  yield* this.parse(stream);
564
467
  }
565
- /** Follow a durable job across finite streams without submitting it twice. */
566
468
  async *follow(input, options) {
567
469
  let executionId;
568
470
  let cursor;
@@ -585,17 +487,21 @@ export class ManagedClient {
585
487
  catch (error) {
586
488
  if (error instanceof StreamIdleError)
587
489
  throw error;
588
- if (!executionId || (error instanceof ApiError && ![429, 500, 502, 503, 504].includes(error.status)) || ++failures > 5)
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)
589
493
  throw error;
494
+ if (!executionId || ++failures > 5)
495
+ throw new RecoveryRequiredError("Execution state is uncertain. Read saved state before retrying.");
590
496
  }
591
497
  if (!executionId)
592
- throw new Error("Execution stream ended before an identifier was received; reuse your idempotency key.");
498
+ throw new RecoveryRequiredError("Execution stream ended before an identifier was received; reuse your idempotency key.");
593
499
  const state = await this.getExecution(executionId);
594
500
  if (["completed", "failed", "cancelled"].includes(state.status)) {
595
501
  if (state.status === "completed") {
596
502
  const assets = state.image_job?.finished_assets;
597
503
  if (!Array.isArray(assets) || assets.length !== 1 || typeof assets[0]?.download_url !== "string")
598
- throw new Error(`Execution ${executionId} has no downloadable asset yet.`);
504
+ throw new RecoveryRequiredError(`Execution ${executionId} has no downloadable asset yet.`);
599
505
  yield managedEvent("asset", null, { asset_id: assets[0].asset_id, download_url: assets[0].download_url });
600
506
  }
601
507
  yield managedEvent("done", null, { status: state.status });
@@ -604,9 +510,8 @@ export class ManagedClient {
604
510
  await new Promise((resolve) => setTimeout(resolve, Math.min(5000, 500 * 2 ** failures)));
605
511
  stream = this.events(executionId, cursor);
606
512
  }
607
- throw new Error(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
513
+ throw new RecoveryRequiredError(`Execution ${executionId ?? "unknown"} is still active. Use status to resume; the job has not been cancelled.`);
608
514
  }
609
- /** Resume a stream after a drop. Pass the last event id you actually processed. */
610
515
  async *events(executionId, lastEventId) {
611
516
  const headers = { Accept: "text/event-stream" };
612
517
  if (lastEventId)
@@ -616,8 +521,6 @@ export class ManagedClient {
616
521
  }
617
522
  async getCapabilities() {
618
523
  this.capabilitiesPromise ??= this.request("/v1/capabilities").catch((error) => {
619
- // Cache a successful contract for the process, but never pin a transient
620
- // capabilities failure as a permanent result.
621
524
  this.capabilitiesPromise = null;
622
525
  throw error;
623
526
  });
@@ -701,17 +604,7 @@ export class ManagedClient {
701
604
  body.append("file", file, filename);
702
605
  return this.request("/v1/input-assets", { method: "POST", body });
703
606
  }
704
- /**
705
- * Fetch a finished asset. Follows redirects on purpose: large images are served
706
- * straight from storage rather than proxied, so a client that refuses redirects
707
- * receives the redirect instead of the image.
708
- */
709
607
  async download(url) {
710
- // Only attach the key when the URL is OUR origin. download_url arrives in the event
711
- // stream and is validated as text, so a wrong or hostile value would otherwise walk
712
- // off with a live credential on the very first request. Node strips Authorization
713
- // across a cross-origin redirect, so the hop to signed storage stays safe either way,
714
- // and storage URLs are pre-signed and need no header from us.
715
608
  const sameOrigin = (() => {
716
609
  try {
717
610
  return new URL(url).origin === this.baseUrl;
@@ -741,9 +634,6 @@ export class ManagedClient {
741
634
  }
742
635
  }
743
636
  finally {
744
- // Also runs when the consumer breaks out of the loop early, which the CLI does
745
- // as soon as it sees a terminal event. Without this the timer keeps the process
746
- // alive for another idle period.
747
637
  finish();
748
638
  }
749
639
  }
@@ -751,9 +641,6 @@ export class ManagedClient {
751
641
  const headers = new Headers(init.headers);
752
642
  headers.set("Authorization", `Bearer ${this.apiKey}`);
753
643
  headers.set("DreamLayer-Version", "1");
754
- // One controller for the whole stream, armed on an IDLE clock that every received
755
- // byte pushes forward. The signal has to outlive the fetch() call: aborting only
756
- // the handshake would leave a stalled body hanging forever.
757
644
  const controller = new AbortController();
758
645
  let timer;
759
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.4.0-beta.1",
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
  }