focalapi-cli 0.3.1 → 0.4.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-08-18
4
+
5
+ - Added `task status --wait [--download]`: built-in bounded polling with elapsed-time reporting and an optional auto-download, so agents never need to write their own poller (the audited agent session's custom script crashed on the Windows `.cmd` shim and stalled blind).
6
+ - Added `task list [--status] [--action] [--limit] [--offset]` backed by `GET /v1/tasks`, so a caller that lost a submission output can reconcile recent tasks instead of resubmitting and double-charging.
7
+ - Added `--idempotency-key` to `gen video` and async `gen image`: same-key retries replay the original task server-side without a second charge. Keys are auto-generated when omitted, breadcrumbed on stderr (`idempotency_key=...`), and the server's `idempotent_replay` marker is surfaced in both JSON output and stderr.
8
+ - Aligned `task download --json` output with `gen image` by exposing `files[]` alongside the legacy `file`.
9
+ - Added a stderr warning for double-encoded reference URLs (`%25XX`) before submission — the top cause of 403 `invalid_reference_url` from presigned URLs.
10
+
3
11
  ## 0.3.1 - 2026-08-18
4
12
 
5
13
  - Added `--duration` as an alias of `--seconds` on `gen video` so the API contract field name works directly; conflicting values are rejected locally.
package/dist/cli.js CHANGED
@@ -176,7 +176,7 @@ function displayWidth(s) {
176
176
  }
177
177
 
178
178
  // src/lib/version.ts
179
- var VERSION = true ? "0.3.1" : "0.0.0-dev";
179
+ var VERSION = true ? "0.4.0" : "0.0.0-dev";
180
180
 
181
181
  // src/commands/auth.ts
182
182
  import { createInterface } from "readline/promises";
@@ -916,6 +916,7 @@ import { mkdir as mkdir2, writeFile } from "fs/promises";
916
916
  import { join as join3, resolve as resolve2 } from "path";
917
917
  import { pipeline as pipeline2 } from "stream/promises";
918
918
  import { Readable as Readable2 } from "stream";
919
+ import { randomUUID } from "crypto";
919
920
 
920
921
  // src/lib/model-capabilities.ts
921
922
  var GEMINI_IMAGE_MAX_SEED = 9007199254740991;
@@ -1526,6 +1527,19 @@ function extractProgress(body) {
1526
1527
  }
1527
1528
  return void 0;
1528
1529
  }
1530
+ function extractCreatedAt(raw) {
1531
+ if (!raw || typeof raw !== "object") return void 0;
1532
+ const obj = raw;
1533
+ const candidates = [obj.created_at, obj.data?.created_at];
1534
+ for (const candidate of candidates) {
1535
+ if (typeof candidate === "number" && candidate > 0) return candidate;
1536
+ if (typeof candidate === "string") {
1537
+ const parsed = Number.parseInt(candidate, 10);
1538
+ if (Number.isFinite(parsed) && parsed > 0) return parsed;
1539
+ }
1540
+ }
1541
+ return void 0;
1542
+ }
1529
1543
  async function cancelTask(baseUrl, apiKey, taskId) {
1530
1544
  try {
1531
1545
  await request({
@@ -1588,9 +1602,24 @@ async function fetchTask(baseUrl, apiKey, taskId) {
1588
1602
  status: normalizeTaskStatus(rawStatus),
1589
1603
  rawStatus,
1590
1604
  progress: extractProgress(raw),
1605
+ createdAt: extractCreatedAt(raw),
1591
1606
  raw
1592
1607
  };
1593
1608
  }
1609
+ async function listTasks(baseUrl, apiKey, opts) {
1610
+ const raw = await request({
1611
+ baseUrl,
1612
+ path: "/v1/tasks",
1613
+ apiKey,
1614
+ query: {
1615
+ status: opts?.status,
1616
+ action: opts?.action,
1617
+ limit: opts?.limit,
1618
+ offset: opts?.offset
1619
+ }
1620
+ });
1621
+ return raw.data ?? [];
1622
+ }
1594
1623
  async function pollTask(baseUrl, apiKey, taskId, opts) {
1595
1624
  const intervalMs = opts?.intervalMs ?? 5e3;
1596
1625
  const timeoutMs = opts?.timeoutMs ?? 30 * 6e4;
@@ -1615,7 +1644,7 @@ async function pollTask(baseUrl, apiKey, taskId, opts) {
1615
1644
  }
1616
1645
  if (Date.now() > deadline) {
1617
1646
  throw new ApiError("timeout", `\u4EFB\u52A1 ${taskId} \u7B49\u5F85\u8D85\u65F6\uFF08${Math.round(timeoutMs / 6e4)} \u5206\u949F\uFF09`, {
1618
- hint: `\u53EF\u7A0D\u540E\u8FD0\u884C focalapi task status ${taskId} \u67E5\u770B\uFF0C\u6216 focalapi task download ${taskId} \u7EED\u53D6\u4EA7\u7269\u3002`
1647
+ hint: `\u53EF\u8FD0\u884C focalapi task status ${taskId} --wait \u7EE7\u7EED\u7B49\u5F85\uFF0C\u6216 focalapi task status ${taskId} \u67E5\u770B\u5F53\u524D\u72B6\u6001\u3002`
1619
1648
  });
1620
1649
  }
1621
1650
  await new Promise((r) => setTimeout(r, intervalMs));
@@ -1653,6 +1682,20 @@ async function downloadTaskContent(baseUrl, apiKey, taskId, outDir, filenameBase
1653
1682
  var MAX_IMAGE_N = 128;
1654
1683
  var MAX_TASK_DURATION_SECONDS = 3600;
1655
1684
  var DEFAULT_OUT_DIR = "focalapi-out";
1685
+ function resolveIdempotencyKey(provided) {
1686
+ const key = provided?.trim() || randomUUID();
1687
+ if (!/^[\x21-\x7e]{8,128}$/.test(key)) {
1688
+ throw new ApiError("invalid_request", "--idempotency-key \u5FC5\u987B\u662F 8\u2013128 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF08\u6536\u5230\uFF1A" + provided + "\uFF09");
1689
+ }
1690
+ return key;
1691
+ }
1692
+ function warnDoubleEncodedReferenceURLs(label, urls) {
1693
+ for (const url of urls) {
1694
+ if (url && /%25[0-9a-f]{2}/i.test(url)) {
1695
+ info(`\u8B66\u544A\uFF1A${label} \u7684 URL \u7591\u4F3C\u88AB\u4E8C\u6B21\u7F16\u7801\uFF08\u5305\u542B %25XX\uFF09\uFF1A${url.slice(0, 100)} \u2014\u2014 \u9884\u7B7E\u540D URL \u5FC5\u987B\u539F\u6837\u4F20\u9012\uFF0C\u5426\u5219\u4E0A\u6E38\u4F1A\u8FD4\u56DE 403 invalid_reference_url`);
1696
+ }
1697
+ }
1698
+ }
1656
1699
  function clampInt(value, min, max, name) {
1657
1700
  if (!Number.isInteger(value) || value < min || value > max) {
1658
1701
  throw new ApiError("invalid_request", `${name} \u5FC5\u987B\u662F ${min}\u2013${max} \u7684\u6574\u6570\uFF08\u6536\u5230\uFF1A${value}\uFF09`);
@@ -1769,7 +1812,7 @@ function extractGeminiImageItems(response) {
1769
1812
  }
1770
1813
  function registerGen(program) {
1771
1814
  const gen = program.command("gen").description("\u56FE\u50CF / \u89C6\u9891\u751F\u6210");
1772
- gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9").option("--resolution <resolution>", "\u6A21\u578B\u539F\u751F\u8F93\u51FA\u6863\u4F4D\uFF0C\u5982 1k\u30012k").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--quality <quality>", "\u56FE\u50CF\u8D28\u91CF\u6863\u4F4D\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--background <background>", "\u80CC\u666F\u6A21\u5F0F\uFF08\u4EC5 gpt-image-2 \u652F\u6301 auto/opaque\uFF09").option("--negative-prompt <text>", "\u8D1F\u9762\u63D0\u793A\u8BCD\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--creativity <level>", "\u63D0\u793A\u8BCD\u6269\u5C55\u5F3A\u5EA6\uFF0C\u5982 raw\u3001low\u3001medium\u3001high").option("--prompt-extend <boolean>", "\u662F\u5426\u6269\u5C55\u63D0\u793A\u8BCD\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "prompt-extend")).option("--style-references <json>", "Krea image_style_references JSON \u6570\u7EC4").option("--moodboards <json>", "Krea moodboards JSON \u6570\u7EC4").option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--output-format <format>", "\u8F93\u51FA\u683C\u5F0F\uFF08\u4EC5 Seedream\uFF1Apng \u6216 jpeg\uFF09").option("--optimize-prompt <mode>", "\u63D0\u793A\u8BCD\u4F18\u5316\uFF08\u4EC5 Seedream\uFF1Aauto\u3001enabled \u6216 disabled\uFF09").option("--image <url...>", "\u53C2\u8003\u56FE\u6216\u7F16\u8F91\u56FE URL\uFF0C\u53EF\u591A\u4E2A").option("--mask <url>", "\u7F16\u8F91 mask URL\uFF08gpt-image-2 \u9700\u8981\u5355\u5F20\u53C2\u8003\u56FE\uFF09").option("--response-format <format>", "\u56FE\u50CF\u54CD\u5E94\u683C\u5F0F\uFF1Aurl \u6216 b64_json").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u56FE\u50CF\u751F\u6210\u5B8C\u6210").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
1815
+ gen.command("image").description("\u751F\u6210\u56FE\u50CF\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u56FE\u50CF\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--size <size>", "\u5C3A\u5BF8\uFF0C\u5982 1024x1024").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9").option("--resolution <resolution>", "\u6A21\u578B\u539F\u751F\u8F93\u51FA\u6863\u4F4D\uFF0C\u5982 1k\u30012k").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--quality <quality>", "\u56FE\u50CF\u8D28\u91CF\u6863\u4F4D\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--background <background>", "\u80CC\u666F\u6A21\u5F0F\uFF08\u4EC5 gpt-image-2 \u652F\u6301 auto/opaque\uFF09").option("--negative-prompt <text>", "\u8D1F\u9762\u63D0\u793A\u8BCD\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09").option("--creativity <level>", "\u63D0\u793A\u8BCD\u6269\u5C55\u5F3A\u5EA6\uFF0C\u5982 raw\u3001low\u3001medium\u3001high").option("--prompt-extend <boolean>", "\u662F\u5426\u6269\u5C55\u63D0\u793A\u8BCD\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "prompt-extend")).option("--style-references <json>", "Krea image_style_references JSON \u6570\u7EC4").option("--moodboards <json>", "Krea moodboards JSON \u6570\u7EC4").option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--output-format <format>", "\u8F93\u51FA\u683C\u5F0F\uFF08\u4EC5 Seedream\uFF1Apng \u6216 jpeg\uFF09").option("--optimize-prompt <mode>", "\u63D0\u793A\u8BCD\u4F18\u5316\uFF08\u4EC5 Seedream\uFF1Aauto\u3001enabled \u6216 disabled\uFF09").option("--image <url...>", "\u53C2\u8003\u56FE\u6216\u7F16\u8F91\u56FE URL\uFF0C\u53EF\u591A\u4E2A").option("--mask <url>", "\u7F16\u8F91 mask URL\uFF08gpt-image-2 \u9700\u8981\u5355\u5F20\u53C2\u8003\u56FE\uFF09").option("--response-format <format>", "\u56FE\u50CF\u54CD\u5E94\u683C\u5F0F\uFF1Aurl \u6216 b64_json").option("--n <count>", "\u5F20\u6570\uFF081\u2013128\uFF09", (v) => Number.parseInt(v, 10), 1).option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u56FE\u50CF\u751F\u6210\u5B8C\u6210").option("--idempotency-key <key>", "\u5E42\u7B49\u952E\uFF088\u2013128 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09\u3002\u540C\u4E00 key \u7684\u91CD\u590D\u63D0\u4EA4\u62FF\u56DE\u539F\u4EFB\u52A1\u800C\u4E0D\u91CD\u590D\u8BA1\u8D39\uFF1B\u7701\u7565\u65F6\u81EA\u52A8\u751F\u6210\u3002\u91CD\u8BD5\u4E0D\u786E\u5B9A\u7684\u63D0\u4EA4\u65F6\u52A1\u5FC5\u590D\u7528\u539F key").option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).action(async (promptParts, opts, cmd) => {
1773
1816
  const g = cmd.optsWithGlobals();
1774
1817
  const auth = resolveAuth(g);
1775
1818
  const model = opts.model ?? (await resolveCreativeModel(auth, "image")).model.id;
@@ -1818,12 +1861,17 @@ function registerGen(program) {
1818
1861
  if (opts.image) body.image = opts.image;
1819
1862
  if (opts.mask) body.mask = opts.mask;
1820
1863
  if (opts.responseFormat) body.response_format = opts.responseFormat;
1864
+ const idempotencyKey = opts.wait === false ? resolveIdempotencyKey(opts.idempotencyKey) : void 0;
1865
+ warnDoubleEncodedReferenceURLs("--image", opts.image ?? []);
1821
1866
  const res = await withProgress(opts.wait === false ? "\u6B63\u5728\u63D0\u4EA4\u56FE\u50CF\u4EFB\u52A1" : "\u6B63\u5728\u751F\u6210\u56FE\u50CF", () => request({
1822
1867
  baseUrl: auth.baseUrl,
1823
1868
  path: "/v1/images/generations",
1824
1869
  apiKey: auth.apiKey,
1825
1870
  body,
1826
- headers: opts.wait === false ? { Prefer: "respond-async" } : void 0,
1871
+ headers: {
1872
+ ...opts.wait === false ? { Prefer: "respond-async" } : {},
1873
+ ...idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}
1874
+ },
1827
1875
  timeoutMs: 6e5
1828
1876
  }));
1829
1877
  if (opts.wait === false) {
@@ -1832,8 +1880,9 @@ function registerGen(program) {
1832
1880
  throw new ApiError("bad_response", "\u5F02\u6B65\u56FE\u50CF\u4EFB\u52A1\u54CD\u5E94\u4E2D\u672A\u627E\u5230 task_id", { body: res });
1833
1881
  }
1834
1882
  info(`task_id=${taskId}`);
1883
+ if (idempotencyKey) info(`idempotency_key=${idempotencyKey}`);
1835
1884
  if (g.json) {
1836
- printJson({ model, task_id: taskId, status: res.status ?? "queued", submitted: true, next_command: `focalapi task status ${taskId} --json` });
1885
+ printJson({ model, task_id: taskId, status: res.status ?? "queued", submitted: true, ...res.idempotent_replay ? { idempotent_replay: true } : {}, next_command: `focalapi task status ${taskId} --json` });
1837
1886
  } else {
1838
1887
  process.stdout.write(taskId + "\n");
1839
1888
  info(`\u4EFB\u52A1\u5DF2\u63D0\u4EA4\u3002\u67E5\u8BE2\uFF1Afocalapi task status ${taskId}`);
@@ -1958,7 +2007,7 @@ function registerGen(program) {
1958
2007
  if (res.id) info(`\u4EA4\u4E92 ID\uFF1A${res.id}`);
1959
2008
  }
1960
2009
  });
1961
- gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF1B--duration \u4E3A\u540C\u4E49\u522B\u540D\u3002\u7CBE\u786E\u8303\u56F4\u8FD0\u884C focalapi models get <model> \u67E5\u770B", (v) => Number.parseInt(v, 10)).option("--duration <n>", "\u65F6\u957F\u79D2\u6570\uFF08--seconds \u7684\u522B\u540D\uFF0C\u4E0E API \u5951\u7EA6\u5B57\u6BB5\u540C\u540D\uFF09", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--resolution <resolution>", "\u539F\u751F\u8F93\u51FA\u5206\u8FA8\u7387\uFF0C\u5982 480p\u3001720p\u30011080p\u30014k").option("--ratio <ratio>", "\u539F\u751F\u5BBD\u9AD8\u6BD4\uFF0C\u5982 16:9\u30019:16\u3001adaptive").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9\u30019:16\u3001auto").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--fps <n>", "\u8F93\u51FA\u5E27\u7387\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-tolerance <n>", "\u5B89\u5168\u5BB9\u5FCD\u5EA6\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--image <url...>", "\u53C2\u8003\u56FE URL\uFF08Grok \u89C6\u9891\u4E3A reference-to-video \u6A21\u5F0F\uFF0C\u53EF\u591A\u4E2A\uFF09").option("--first-frame <url>", "\u56FE\u751F\u89C6\u9891\u9996\u5E27\u56FE URL\uFF08image-to-video \u6A21\u5F0F\uFF1B\u4E0E --image \u4E92\u65A5\uFF09").option("--generate-audio <boolean>", "\u662F\u5426\u751F\u6210\u97F3\u9891\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "generate-audio")).option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--service-tier <tier>", "\u670D\u52A1\u5C42\u7EA7\uFF08Seedance 2.0 \u9ED8\u8BA4 default\uFF09").option("--priority <n>", "\u4EFB\u52A1\u4F18\u5148\u7EA7\uFF08\u4EC5 Seedance 2.0 \u7CFB\u5217\uFF09", (v) => Number.parseInt(v, 10)).option("--callback-url <url>", "\u4EFB\u52A1\u5B8C\u6210\u56DE\u8C03 URL").option("--return-last-frame <boolean>", "\u662F\u5426\u8FD4\u56DE\u6700\u540E\u4E00\u5E27\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "return-last-frame")).option("--execution-expires-after <seconds>", "\u4EFB\u52A1\u8FC7\u671F\u79D2\u6570\uFF083600\u2013259200\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-identifier <identifier>", "Seedance \u5B89\u5168\u6807\u8BC6\u7B26\uFF081\u201364 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--content <json>", "Ark-compatible content JSON array; overrides prompt/image facade fields").action(
2010
+ gen.command("video").description("\u751F\u6210\u89C6\u9891\uFF08\u7701\u7565 --model \u65F6\u81EA\u52A8\u9009\u62E9\u5F53\u524D\u53EF\u7528\u9ED8\u8BA4\u6A21\u578B\uFF09").argument("<prompt...>", "\u63D0\u793A\u8BCD").option("-m, --model <model>", "\u89C6\u9891\u6A21\u578B ID\uFF1B\u7701\u7565\u65F6\u7531 focalapi \u81EA\u52A8\u9009\u62E9").option("--seconds <n>", "\u65F6\u957F\u79D2\u6570\uFF1B--duration \u4E3A\u540C\u4E49\u522B\u540D\u3002\u7CBE\u786E\u8303\u56F4\u8FD0\u884C focalapi models get <model> \u67E5\u770B", (v) => Number.parseInt(v, 10)).option("--duration <n>", "\u65F6\u957F\u79D2\u6570\uFF08--seconds \u7684\u522B\u540D\uFF0C\u4E0E API \u5951\u7EA6\u5B57\u6BB5\u540C\u540D\uFF09", (v) => Number.parseInt(v, 10)).option("--size <size>", "\u5206\u8FA8\u7387\uFF0C\u5982 1280x720").option("--resolution <resolution>", "\u539F\u751F\u8F93\u51FA\u5206\u8FA8\u7387\uFF0C\u5982 480p\u3001720p\u30011080p\u30014k").option("--ratio <ratio>", "\u539F\u751F\u5BBD\u9AD8\u6BD4\uFF0C\u5982 16:9\u30019:16\u3001adaptive").option("--aspect-ratio <ratio>", "\u6A21\u578B\u539F\u751F\u753B\u9762\u6BD4\u4F8B\uFF0C\u5982 16:9\u30019:16\u3001auto").option("--seed <n>", "\u6A21\u578B\u968F\u673A\u79CD\u5B50\uFF08\u975E\u8D1F\u6574\u6570\uFF09", (v) => Number.parseInt(v, 10)).option("--fps <n>", "\u8F93\u51FA\u5E27\u7387\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-tolerance <n>", "\u5B89\u5168\u5BB9\u5FCD\u5EA6\uFF08\u4EC5\u652F\u6301\u8BE5\u53C2\u6570\u7684\u6A21\u578B\u751F\u6548\uFF09", (v) => Number.parseInt(v, 10)).option("--image <url...>", "\u53C2\u8003\u56FE URL\uFF08Grok \u89C6\u9891\u4E3A reference-to-video \u6A21\u5F0F\uFF0C\u53EF\u591A\u4E2A\uFF09").option("--first-frame <url>", "\u56FE\u751F\u89C6\u9891\u9996\u5E27\u56FE URL\uFF08image-to-video \u6A21\u5F0F\uFF1B\u4E0E --image \u4E92\u65A5\uFF09").option("--generate-audio <boolean>", "\u662F\u5426\u751F\u6210\u97F3\u9891\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "generate-audio")).option("--watermark <boolean>", "\u662F\u5426\u6DFB\u52A0\u6C34\u5370\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "watermark")).option("--service-tier <tier>", "\u670D\u52A1\u5C42\u7EA7\uFF08Seedance 2.0 \u9ED8\u8BA4 default\uFF09").option("--priority <n>", "\u4EFB\u52A1\u4F18\u5148\u7EA7\uFF08\u4EC5 Seedance 2.0 \u7CFB\u5217\uFF09", (v) => Number.parseInt(v, 10)).option("--callback-url <url>", "\u4EFB\u52A1\u5B8C\u6210\u56DE\u8C03 URL").option("--return-last-frame <boolean>", "\u662F\u5426\u8FD4\u56DE\u6700\u540E\u4E00\u5E27\uFF08\u53EA\u63A5\u53D7 true \u6216 false\uFF09", (v) => parseBooleanOption(v, "return-last-frame")).option("--execution-expires-after <seconds>", "\u4EFB\u52A1\u8FC7\u671F\u79D2\u6570\uFF083600\u2013259200\uFF09", (v) => Number.parseInt(v, 10)).option("--safety-identifier <identifier>", "Seedance \u5B89\u5168\u6807\u8BC6\u7B26\uFF081\u201364 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09").option("--no-wait", "\u63D0\u4EA4\u540E\u7ACB\u5373\u8FD4\u56DE task_id\uFF0C\u4E0D\u7B49\u5F85\u5B8C\u6210").option("--idempotency-key <key>", "\u5E42\u7B49\u952E\uFF088\u2013128 \u4E2A\u53EF\u6253\u5370 ASCII \u5B57\u7B26\uFF09\u3002\u540C\u4E00 key \u7684\u91CD\u590D\u63D0\u4EA4\u62FF\u56DE\u539F\u4EFB\u52A1\u800C\u4E0D\u91CD\u590D\u8BA1\u8D39\uFF1B\u7701\u7565\u65F6\u81EA\u52A8\u751F\u6210\u3002\u91CD\u8BD5\u4E0D\u786E\u5B9A\u7684\u63D0\u4EA4\u65F6\u52A1\u5FC5\u590D\u7528\u539F key").option("--poll-interval <ms>", "\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--timeout <minutes>", "\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("-o, --out <dir>", "\u8F93\u51FA\u76EE\u5F55", DEFAULT_OUT_DIR).option("--content <json>", "Ark-compatible content JSON array; overrides prompt/image facade fields").action(
1962
2011
  async (promptParts, opts, cmd) => {
1963
2012
  const g = cmd.optsWithGlobals();
1964
2013
  const auth = resolveAuth(g);
@@ -2010,11 +2059,14 @@ function registerGen(program) {
2010
2059
  watermark: opts.watermark
2011
2060
  });
2012
2061
  if (Object.keys(metadata).length > 0) body.metadata = metadata;
2062
+ const idempotencyKey = resolveIdempotencyKey(opts.idempotencyKey);
2063
+ warnDoubleEncodedReferenceURLs("--image/--first-frame", [...opts.image ?? [], opts.firstFrame]);
2013
2064
  const created = await withProgress("\u6B63\u5728\u63D0\u4EA4\u89C6\u9891\u4EFB\u52A1", () => request({
2014
2065
  baseUrl: auth.baseUrl,
2015
2066
  path: "/v1/video/generations",
2016
2067
  apiKey: auth.apiKey,
2017
2068
  body,
2069
+ headers: { "Idempotency-Key": idempotencyKey },
2018
2070
  timeoutMs: 12e4
2019
2071
  }));
2020
2072
  const taskId = extractTaskId(created);
@@ -2023,8 +2075,10 @@ function registerGen(program) {
2023
2075
  }
2024
2076
  if (opts.wait === false) {
2025
2077
  info(`task_id=${taskId}`);
2078
+ info(`idempotency_key=${idempotencyKey}`);
2079
+ if (created.idempotent_replay) info("\u5DF2\u56DE\u653E\u539F\u4EFB\u52A1\uFF08idempotent_replay\uFF0C\u672A\u91CD\u590D\u8BA1\u8D39\uFF09");
2026
2080
  if (g.json) {
2027
- printJson({ model, task_id: taskId, submitted: true, next_command: `focalapi task status ${taskId} --json` });
2081
+ printJson({ model, task_id: taskId, submitted: true, ...created.idempotent_replay ? { idempotent_replay: true } : {}, next_command: `focalapi task status ${taskId} --json` });
2028
2082
  } else {
2029
2083
  process.stdout.write(taskId + "\n");
2030
2084
  info(`\u4EFB\u52A1\u5DF2\u63D0\u4EA4\u3002\u7EED\u53D6\uFF1Afocalapi task status ${taskId} / focalapi task download ${taskId}\uFF1B\u6392\u961F\u4E2D\u53EF\u53D6\u6D88\uFF1Afocalapi task cancel ${taskId}`);
@@ -2052,42 +2106,102 @@ function registerGen(program) {
2052
2106
  }
2053
2107
 
2054
2108
  // src/commands/task.ts
2055
- var TASK_ID_HINT = "task_id \u533A\u5206\u5927\u5C0F\u5199\u4E14\u6DF7\u6709\u5C0F\u5199 l / \u6570\u5B57 1 / \u5927\u5199 I\uFF08O \u4E0E 0 \u540C\u7406\uFF09\uFF0C\u5FC5\u987B\u9010\u5B57\u590D\u5236\u3002\u82E5\u63D0\u4EA4\u65F6\u7684\u8F93\u51FA\u5DF2\u4E22\u5931\uFF0C\u5148\u56DE\u67E5\u5F53\u65F6\u7684 stderr \u9762\u5305\u5C51\uFF08task_id=...\uFF09\uFF0C\u4E0D\u8981\u76F2\u76EE\u91CD\u65B0\u63D0\u4EA4\u2014\u2014\u91CD\u590D\u63D0\u4EA4\u4F1A\u91CD\u590D\u6263\u8D39\u3002";
2109
+ var TASK_ID_HINT = "task_id \u533A\u5206\u5927\u5C0F\u5199\u4E14\u6DF7\u6709\u5C0F\u5199 l / \u6570\u5B57 1 / \u5927\u5199 I\uFF08O \u4E0E 0 \u540C\u7406\uFF09\uFF0C\u5FC5\u987B\u9010\u5B57\u590D\u5236\u3002\u82E5\u63D0\u4EA4\u65F6\u7684\u8F93\u51FA\u5DF2\u4E22\u5931\uFF0C\u5148\u8FD0\u884C focalapi task list \u627E\u56DE\u6700\u8FD1\u7684\u4EFB\u52A1\uFF0C\u4E0D\u8981\u76F2\u76EE\u91CD\u65B0\u63D0\u4EA4\u2014\u2014\u91CD\u590D\u63D0\u4EA4\u4F1A\u91CD\u590D\u6263\u8D39\u3002";
2056
2110
  function withTaskIdHint(err) {
2057
2111
  if (err instanceof ApiError && err.status === 404) {
2058
2112
  return new ApiError(err.code, err.message, { status: err.status, hint: TASK_ID_HINT, body: err.body, upstreamCode: err.upstreamCode, requestId: err.requestId });
2059
2113
  }
2060
2114
  return err;
2061
2115
  }
2116
+ function formatElapsed(seconds) {
2117
+ if (!seconds || seconds < 0) return "-";
2118
+ if (seconds < 90) return `${seconds} \u79D2`;
2119
+ return `${Math.floor(seconds / 60)} \u5206 ${seconds % 60} \u79D2`;
2120
+ }
2121
+ function taskAgeSeconds(createdAt) {
2122
+ if (!createdAt) return void 0;
2123
+ return Math.max(0, Math.floor(Date.now() / 1e3 - createdAt));
2124
+ }
2062
2125
  function registerTask(program) {
2063
2126
  const task = program.command("task").description("\u4EFB\u52A1\u67E5\u8BE2\u3001\u53D6\u6D88\u4E0E\u4EA7\u7269\u4E0B\u8F7D\uFF08\u89C6\u9891\u7B49\u4EFB\u52A1\u5236\u80FD\u529B\uFF09");
2064
- task.command("status").description("\u67E5\u8BE2\u4EFB\u52A1\u72B6\u6001").argument("<task_id>", "\u4EFB\u52A1 ID").action(async (taskId, _opts, cmd) => {
2127
+ task.command("status").description("\u67E5\u8BE2\u4EFB\u52A1\u72B6\u6001\uFF1B--wait \u5185\u7F6E\u8F6E\u8BE2\u7B49\u5F85\u7EC8\u6001\uFF0C\u65E0\u9700\u81EA\u5199\u8F6E\u8BE2\u811A\u672C").argument("<task_id>", "\u4EFB\u52A1 ID").option("--wait", "\u7B49\u5F85\u4EFB\u52A1\u5230\u8FBE\u7EC8\u6001\uFF08\u6210\u529F / \u5931\u8D25 / \u53D6\u6D88\uFF09\u540E\u518D\u8FD4\u56DE").option("--timeout <minutes>", "--wait \u7684\u6700\u957F\u7B49\u5F85\u5206\u949F", (v) => Number.parseInt(v, 10), 30).option("--poll-interval <ms>", "--wait \u7684\u8F6E\u8BE2\u95F4\u9694\u6BEB\u79D2", (v) => Number.parseInt(v, 10), 5e3).option("--download", "--wait \u6210\u529F\u540E\u81EA\u52A8\u4E0B\u8F7D\u4EA7\u7269\uFF08\u7B49\u4EF7\u4E8E\u63A5\u7740\u6267\u884C task download\uFF09").option("-o, --out <dir>", "--download \u7684\u8F93\u51FA\u76EE\u5F55", "focalapi-out").action(async (taskId, opts, cmd) => {
2065
2128
  const g = cmd.optsWithGlobals();
2066
2129
  const auth = resolveAuth(g);
2067
2130
  try {
2068
- const info_ = await fetchTask(auth.baseUrl, auth.apiKey, taskId);
2131
+ let info_ = await fetchTask(auth.baseUrl, auth.apiKey, taskId);
2132
+ if (opts.wait && info_.status !== "success" && info_.status !== "failed" && info_.status !== "cancelled") {
2133
+ info_ = await pollTask(auth.baseUrl, auth.apiKey, taskId, {
2134
+ intervalMs: opts.pollInterval,
2135
+ timeoutMs: opts.timeout * 6e4,
2136
+ onUpdate: (t) => {
2137
+ if (!g.json) {
2138
+ const elapsed2 = formatElapsed(taskAgeSeconds(t.createdAt));
2139
+ info(` \u72B6\u6001\uFF1A${t.rawStatus || t.status}${t.progress !== void 0 ? `\uFF08${t.progress}%\uFF09` : ""}\uFF0C\u5DF2\u8017\u65F6 ${elapsed2}`);
2140
+ }
2141
+ }
2142
+ });
2143
+ }
2144
+ const elapsed = taskAgeSeconds(info_.createdAt);
2145
+ let file;
2146
+ if (opts.download && info_.status === "success") {
2147
+ file = await downloadTaskContent(auth.baseUrl, auth.apiKey, taskId, opts.out);
2148
+ }
2069
2149
  if (g.json) {
2070
- printJson({ task_id: taskId, status: info_.status, raw_status: info_.rawStatus, progress: info_.progress, raw: info_.raw });
2150
+ printJson({
2151
+ task_id: taskId,
2152
+ status: info_.status,
2153
+ raw_status: info_.rawStatus,
2154
+ progress: info_.progress,
2155
+ ...elapsed !== void 0 ? { elapsed_seconds: elapsed } : {},
2156
+ ...file ? { file } : {},
2157
+ raw: info_.raw
2158
+ });
2071
2159
  } else {
2072
2160
  printTable(
2073
2161
  ["\u5B57\u6BB5", "\u503C"],
2074
2162
  [
2075
2163
  ["\u4EFB\u52A1 ID", taskId],
2076
2164
  ["\u72B6\u6001", `${info_.status}${info_.rawStatus && info_.rawStatus !== info_.status ? `\uFF08\u4E0A\u6E38\uFF1A${info_.rawStatus}\uFF09` : ""}`],
2077
- ["\u8FDB\u5EA6", info_.progress !== void 0 ? `${info_.progress}%` : "-"]
2165
+ ["\u8FDB\u5EA6", info_.progress !== void 0 ? `${info_.progress}%` : "-"],
2166
+ ["\u5DF2\u8017\u65F6", formatElapsed(elapsed)]
2078
2167
  ]
2079
2168
  );
2080
- if (info_.status === "success") {
2169
+ if (file) {
2170
+ info(`\u2713 ${file}`);
2171
+ } else if (info_.status === "success") {
2081
2172
  info(`\u4EA7\u7269\u4E0B\u8F7D\uFF1Afocalapi task download ${taskId}`);
2082
2173
  }
2083
2174
  if (info_.status === "pending" || info_.status === "running") {
2084
- info(`\u5982\u9700\u505C\u6B62\u6392\u961F\u4E2D\u7684\u4EFB\u52A1\uFF1Afocalapi task cancel ${taskId}`);
2175
+ info(`\u7B49\u5F85\u5B8C\u6210\uFF1Afocalapi task status ${taskId} --wait\uFF1B\u6392\u961F\u4E2D\u53EF\u53D6\u6D88\uFF1Afocalapi task cancel ${taskId}`);
2085
2176
  }
2086
2177
  }
2087
2178
  } catch (err) {
2088
2179
  throw withTaskIdHint(err);
2089
2180
  }
2090
2181
  });
2182
+ task.command("list").description("\u5217\u51FA\u5F53\u524D Key \u7684\u8FD1\u671F\u4EFB\u52A1\uFF08\u63D0\u4EA4\u8F93\u51FA\u4E22\u5931\u65F6\u7528\u5B83\u627E\u56DE task_id\uFF09").option("--status <status>", "\u6309\u72B6\u6001\u8FC7\u6EE4\uFF1Aqueued\u3001in_progress\u3001completed\u3001failed\u3001cancelled").option("--action <action>", "\u6309\u4EFB\u52A1\u7C7B\u578B\u8FC7\u6EE4\uFF0C\u5982 generate\u3001image_generation").option("--limit <n>", "\u6BCF\u9875\u6570\u91CF\uFF081\u2013100\uFF09", (v) => Number.parseInt(v, 10), 20).option("--offset <n>", "\u504F\u79FB\u91CF", (v) => Number.parseInt(v, 10), 0).action(async (opts, cmd) => {
2183
+ const g = cmd.optsWithGlobals();
2184
+ const auth = resolveAuth(g);
2185
+ const items = await listTasks(auth.baseUrl, auth.apiKey, opts);
2186
+ if (g.json) {
2187
+ printJson({ object: "list", data: items });
2188
+ } else if (items.length === 0) {
2189
+ info("\u5F53\u524D Key \u6682\u65E0\u4EFB\u52A1\u8BB0\u5F55\u3002");
2190
+ } else {
2191
+ printTable(
2192
+ ["\u4EFB\u52A1 ID", "\u6A21\u578B", "\u72B6\u6001", "\u8FDB\u5EA6", "\u5DF2\u8017\u65F6", "\u989D\u5EA6"],
2193
+ items.map((item) => [
2194
+ item.task_id,
2195
+ item.model ?? "-",
2196
+ item.status ?? "-",
2197
+ item.progress !== void 0 ? `${item.progress}%` : "-",
2198
+ formatElapsed(item.created_at ? Math.max(0, Math.floor(Date.now() / 1e3 - item.created_at)) : void 0),
2199
+ item.quota !== void 0 ? String(item.quota) : "-"
2200
+ ])
2201
+ );
2202
+ info("\u7EED\u53D6\uFF1Afocalapi task status <task_id> --wait");
2203
+ }
2204
+ });
2091
2205
  task.command("cancel").description("\u53D6\u6D88\u6392\u961F\u4E2D\u7684\u4EFB\u52A1\uFF08\u8FD0\u884C\u4E2D\u7684\u4EFB\u52A1\u4E0D\u53EF\u53D6\u6D88\uFF1B\u53D6\u6D88\u540E\u8D39\u7528\u81EA\u52A8\u9000\u8FD8\uFF09").argument("<task_id>", "\u4EFB\u52A1 ID").action(async (taskId, _opts, cmd) => {
2092
2206
  const g = cmd.optsWithGlobals();
2093
2207
  const auth = resolveAuth(g);
@@ -2103,7 +2217,7 @@ function registerTask(program) {
2103
2217
  const auth = resolveAuth(g);
2104
2218
  const filePath = await downloadTaskContent(auth.baseUrl, auth.apiKey, taskId, opts.out);
2105
2219
  if (g.json) {
2106
- printJson({ task_id: taskId, file: filePath });
2220
+ printJson({ task_id: taskId, file: filePath, files: [filePath] });
2107
2221
  } else {
2108
2222
  info(`\u2713 ${filePath}`);
2109
2223
  }
@@ -2210,7 +2324,7 @@ function registerUsage(program) {
2210
2324
  }
2211
2325
 
2212
2326
  // src/commands/connect.ts
2213
- import { createHash, randomUUID } from "crypto";
2327
+ import { createHash, randomUUID as randomUUID2 } from "crypto";
2214
2328
  import {
2215
2329
  cpSync,
2216
2330
  existsSync as existsSync2,
@@ -2369,7 +2483,7 @@ function installTo(skillsDir, agents, skills, srcDir) {
2369
2483
  }
2370
2484
  mkdirSync2(skillsDir, { recursive: true });
2371
2485
  const oldManifest = readManifest(skillsDir);
2372
- const transactionRoot = join4(skillsDir, `.focalapi-install-${randomUUID()}`);
2486
+ const transactionRoot = join4(skillsDir, `.focalapi-install-${randomUUID2()}`);
2373
2487
  const stageRoot = join4(transactionRoot, "stage");
2374
2488
  const backupRoot = join4(transactionRoot, "backup");
2375
2489
  mkdirSync2(stageRoot, { recursive: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "focalapi-cli",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "让任意 AI Agent 直接调用 focalapi 创作模型的命令行工具",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,8 +26,8 @@ focalapi gen image "<user prompt>" -o ./focalapi-out --json
26
26
  focalapi gen video "<user prompt>" --no-wait -o ./focalapi-out --json
27
27
 
28
28
  # Continue an asynchronous video task.
29
- focalapi task status <task-id> --json
30
- focalapi task download <task-id> -o ./focalapi-out --json
29
+ focalapi task status <task-id> --wait --json # built-in polling; never write your own poll script
30
+ focalapi task list --json # recover recent task IDs after a lost output
31
31
  focalapi task cancel <task-id> --json # queued tasks only; cancelled tasks are refunded
32
32
  ```
33
33
 
@@ -38,7 +38,7 @@ focalapi task cancel <task-id> --json # queued tasks only; cancelled tasks are
38
38
  | Generate or edit images; create from reference images | `focalapi gen image` | focalapi-gen |
39
39
  | Generate video; animate images or reference media | `focalapi gen video` | focalapi-gen |
40
40
  | Select, compare, or inspect model parameters | `focalapi models resolve/get/search` | focalapi-models |
41
- | Inspect progress or failures; cancel queued tasks; download results | `focalapi task status/cancel/download` | focalapi-task |
41
+ | Inspect progress or failures; wait, list, cancel queued tasks; download results | `focalapi task status/wait/list/cancel/download` | focalapi-task |
42
42
  | Resolve key, sign-in, or 401 issues | `focalapi auth status/login` | focalapi-auth |
43
43
  | Inspect quota, usage, or service failures | `focalapi usage/doctor` | focalapi-usage |
44
44
  | Provide text assistance explicitly requested by the user | `focalapi chat` | focalapi-chat |
@@ -13,8 +13,10 @@ metadata:
13
13
  After a generation command returns `task_id`, prefer the response's `next_command`:
14
14
 
15
15
  ```bash
16
- focalapi task status <task-id> --json
17
- focalapi task download <task-id> -o ./focalapi-out --json
16
+ focalapi task status <task-id> --json # one-shot status check
17
+ focalapi task status <task-id> --wait # built-in polling until a terminal state — do not write your own poller
18
+ focalapi task status <task-id> --wait --download -o ./focalapi-out --json
19
+ focalapi task list --status in_progress --json # reconcile recent tasks after losing a submission output
18
20
  focalapi task cancel <task-id> --json
19
21
  ```
20
22
 
@@ -24,7 +26,9 @@ focalapi task cancel <task-id> --json
24
26
  - `cancelled`: the task was stopped; a cancelled queued task is refunded. Do not download or resubmit unless the user asks for a new attempt.
25
27
  - `unknown`: preserve the raw response and run `focalapi doctor --json`; never fabricate a success state.
26
28
 
27
- Task IDs are case-sensitive and mix `l`/`1`/`I` and `O`/`0`. Copy them exactly from the submission output — stdout JSON or the stderr `task_id=` breadcrumb — instead of retyping.
29
+ Task IDs are case-sensitive and mix `l`/`1`/`I` and `O`/`0`. Copy them exactly from the submission output — stdout JSON or the stderr `task_id=` breadcrumb — instead of retyping. If a submission output is lost entirely, run `task list` to recover recent task IDs before considering any resubmission.
30
+
31
+ Generation commands print the submission's `idempotency_key=` on stderr. When retrying a submission whose outcome is uncertain (timeout, crash, lost output), reuse that same key via `--idempotency-key` so the retry replays the original task instead of double-charging; a fresh key means a deliberate new generation.
28
32
 
29
33
  `task cancel` only works while a task is still queued (`pending`). A 409 `task_already_running` means generation already started and cannot be stopped — keep tracking with `task status`. Cancellation failures return explicit codes (`task_already_finished`, `task_cancel_failed`); follow `error.hint` instead of retrying blindly.
30
34