@velven/cli 0.3.0 → 0.5.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +130 -4
  2. package/README.md +122 -22
  3. package/dist/velven.js +1254 -184
  4. package/package.json +1 -1
package/dist/velven.js CHANGED
@@ -23,14 +23,13 @@ class CliError extends Error {
23
23
  }
24
24
  }
25
25
  // package.json
26
- var version = "0.3.0";
26
+ var version = "0.5.0";
27
27
 
28
28
  // src/version.ts
29
29
  var VERSION = version;
30
30
 
31
31
  // src/api.ts
32
32
  var USER_AGENT = `velven-cli/${VERSION}`;
33
- var REFUSED_CODES = new Set(["not_owner", "too_large", "too_many_files", "not_found", "invalid_fields", "forbidden"]);
34
33
  function errorFromResponse(status, body, path, creds, retryAfter) {
35
34
  const b = body && typeof body === "object" ? body : {};
36
35
  const errorText = typeof b.error === "string" ? b.error : undefined;
@@ -59,7 +58,7 @@ function errorFromResponse(status, body, path, creds, retryAfter) {
59
58
  if (!body && status === 404) {
60
59
  return new CliError(`This Velven does not answer ${path}. Check VELVEN_API.`, EXIT.failed, "not_found");
61
60
  }
62
- if (REFUSED_CODES.has(code) || status >= 400) {
61
+ if (status >= 400) {
63
62
  return new CliError(sentence ?? `Velven refused the request (HTTP ${status}).`, EXIT.refused, code, fields);
64
63
  }
65
64
  return new CliError(sentence ?? `Unexpected answer from Velven (HTTP ${status}).`, EXIT.failed, code);
@@ -75,34 +74,61 @@ function requestHeaders(api, accept) {
75
74
  function causeOf(e) {
76
75
  return e instanceof Error && e.cause instanceof Error ? e.cause.message : e instanceof Error ? e.message : String(e);
77
76
  }
78
- async function call(api, method, path, body) {
79
- const headers = requestHeaders(api, "application/json");
80
- if (body !== undefined)
81
- headers["content-type"] = "application/json";
77
+ var RETRY_PAUSE_MS = 1000;
78
+ async function call(api, method, path, body, options = {}) {
79
+ return answerOf(api, path, body === undefined ? undefined : { type: "application/json", data: JSON.stringify(body) }, method, options.retryOnce?.sleep);
80
+ }
81
+ async function putBytes(url, bytes, type, tooLarge) {
82
82
  let response;
83
83
  try {
84
- response = await fetch(`${api.base}${path}`, {
85
- method,
86
- headers,
87
- body: body === undefined ? undefined : JSON.stringify(body)
88
- });
84
+ response = await fetch(url, { method: "PUT", headers: { "content-type": type, "user-agent": USER_AGENT }, body: bytes });
89
85
  } catch (e) {
90
- throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
86
+ throw new CliError(`Could not upload the picture (${causeOf(e)}).`, EXIT.failed, "network");
91
87
  }
92
- const text = await response.text();
93
- let parsed = null;
94
- if (text) {
88
+ await response.text().catch(() => "");
89
+ if (response.status === 413)
90
+ throw new CliError(tooLarge, EXIT.refused, "too_large");
91
+ if (!response.ok)
92
+ throw new CliError(`The picture's upload was refused (HTTP ${response.status}). Try again in a moment.`, response.status >= 500 ? EXIT.failed : EXIT.refused, `http_${response.status}`);
93
+ }
94
+ async function answerOf(api, path, body, method, retry) {
95
+ const headers = requestHeaders(api, "application/json");
96
+ if (body !== undefined)
97
+ headers["content-type"] = body.type;
98
+ for (let attempt = 0;; attempt++) {
99
+ const again = retry !== undefined && attempt === 0;
100
+ let response;
101
+ let text;
95
102
  try {
96
- parsed = JSON.parse(text);
97
- } catch {
98
- parsed = null;
103
+ response = await fetch(`${api.base}${path}`, { method, headers, body: body?.data });
104
+ text = await response.text();
105
+ } catch (e) {
106
+ if (again) {
107
+ await retry(RETRY_PAUSE_MS);
108
+ continue;
109
+ }
110
+ throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
99
111
  }
112
+ let parsed = null;
113
+ if (text) {
114
+ try {
115
+ parsed = JSON.parse(text);
116
+ } catch {
117
+ parsed = null;
118
+ }
119
+ }
120
+ if (!response.ok) {
121
+ const error = errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
122
+ if (again && response.status >= 500 && error.code !== "workflow_unavailable") {
123
+ await retry(RETRY_PAUSE_MS);
124
+ continue;
125
+ }
126
+ throw error;
127
+ }
128
+ if (parsed === null)
129
+ throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
130
+ return parsed;
100
131
  }
101
- if (!response.ok)
102
- throw errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
103
- if (parsed === null)
104
- throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
105
- return parsed;
106
132
  }
107
133
  function seg(value) {
108
134
  return encodeURIComponent(String(value));
@@ -120,7 +146,8 @@ async function stream(api, path, options, onEvent) {
120
146
  async function connect() {
121
147
  let got = false;
122
148
  const connectionKeys = new Set;
123
- const url = options.from !== undefined && lastId === null ? `${path}${path.includes("?") ? "&" : "?"}from=${options.from}` : path;
149
+ const start = options.start && lastId === null ? "from" in options.start ? `from=${options.start.from}` : `after=${options.start.after}` : null;
150
+ const url = start ? `${path}${path.includes("?") ? "&" : "?"}${start}` : path;
124
151
  const headers = requestHeaders(api, "text/event-stream");
125
152
  if (lastId !== null)
126
153
  headers["last-event-id"] = lastId;
@@ -323,10 +350,9 @@ async function login(ctx) {
323
350
  ctx.out("Opened it in your browser. Waiting for you to approve...");
324
351
  else
325
352
  ctx.out("Open that link in a browser. Waiting for you to approve...");
326
- let interval = Math.max(1, started.interval || 5);
327
- const deadline = Date.now() + (started.expires_in || 900) * 1000;
353
+ const deadline = Date.now() + started.expires_in * 1000;
328
354
  while (Date.now() < deadline) {
329
- await ctx.sleep(interval * 1000);
355
+ await ctx.sleep(started.interval * 1000);
330
356
  try {
331
357
  const token = await call(api, "POST", "/api/agent/token", { device_code: started.device_code });
332
358
  await saveToken(ctx, base, { token: token.access_token, handle: token.handle });
@@ -339,10 +365,6 @@ async function login(ctx) {
339
365
  throw e;
340
366
  if (e.code === "authorization_pending")
341
367
  continue;
342
- if (e.code === "slow_down") {
343
- interval += 5;
344
- continue;
345
- }
346
368
  if (e.code === "access_denied")
347
369
  throw new CliError("The sign-in was denied. Nothing was saved.", EXIT.auth, "access_denied");
348
370
  if (e.code === "expired_token" || e.code === "invalid_grant")
@@ -408,7 +430,11 @@ function parseArgs(argv, spec) {
408
430
  if (value === undefined || eq === -1 && value.startsWith("--")) {
409
431
  throw new CliError(`--${name} needs a value.`, EXIT.usage, "usage");
410
432
  }
411
- flags[name] = value;
433
+ if (kind === "strings") {
434
+ const given = flags[name];
435
+ flags[name] = [...Array.isArray(given) ? given : [], value];
436
+ } else
437
+ flags[name] = value;
412
438
  }
413
439
  return { positionals, flags };
414
440
  }
@@ -416,6 +442,10 @@ function stringFlag(parsed, name) {
416
442
  const value = parsed.flags[name];
417
443
  return typeof value === "string" ? value : undefined;
418
444
  }
445
+ function stringsFlag(parsed, name) {
446
+ const value = parsed.flags[name];
447
+ return Array.isArray(value) ? value : typeof value === "string" ? [value] : [];
448
+ }
419
449
 
420
450
  // src/context.ts
421
451
  import { spawn } from "node:child_process";
@@ -467,9 +497,21 @@ function nodeContext() {
467
497
  };
468
498
  }
469
499
 
500
+ // src/create.ts
501
+ import { randomUUID } from "node:crypto";
502
+ import { readFile as readFile3 } from "node:fs/promises";
503
+ import { resolve } from "node:path";
504
+
470
505
  // src/create-state.ts
471
506
  import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
472
507
  import { dirname as dirname2, join as join2 } from "node:path";
508
+ function questionOf(value) {
509
+ if (!value || typeof value !== "object")
510
+ return;
511
+ const q = value;
512
+ const choices = Array.isArray(q.choices) && q.choices.every((c) => typeof c === "string") ? q.choices : null;
513
+ return typeof q.project === "string" && typeof q.item === "string" && choices?.length ? { project: q.project, item: q.item, choices } : undefined;
514
+ }
473
515
  function createStatePath(ctx) {
474
516
  return join2(ctx.home, ".config", "velven", "create.json");
475
517
  }
@@ -489,9 +531,11 @@ async function readCreateFile(ctx) {
489
531
  }
490
532
  async function readCreateState(ctx, api) {
491
533
  const state = (await readCreateFile(ctx)).apis[api] ?? {};
534
+ const question = questionOf(state.question);
492
535
  return {
493
536
  ...typeof state.project === "string" && state.project ? { project: state.project } : {},
494
- ...Number.isInteger(state.turn) ? { turn: state.turn } : {}
537
+ ...Number.isInteger(state.turn) ? { turn: state.turn } : {},
538
+ ...question ? { question } : {}
495
539
  };
496
540
  }
497
541
  async function saveCreateState(ctx, api, patch) {
@@ -499,6 +543,8 @@ async function saveCreateState(ctx, api, patch) {
499
543
  const next = { ...file.apis[api], ...patch };
500
544
  if (next.turn === undefined)
501
545
  delete next.turn;
546
+ if (next.question === undefined)
547
+ delete next.question;
502
548
  file.apis[api] = next;
503
549
  const path = createStatePath(ctx);
504
550
  await mkdir2(dirname2(path), { recursive: true, mode: 448 });
@@ -507,13 +553,21 @@ async function saveCreateState(ctx, api, patch) {
507
553
  await chmod2(path, 384);
508
554
  }
509
555
 
556
+ // src/text.ts
557
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
558
+ function when(iso) {
559
+ if (!iso)
560
+ return "";
561
+ const d = new Date(iso);
562
+ return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
563
+ }
564
+
510
565
  // src/create-timeline.ts
511
566
  var BAR_WIDTH = 60;
512
567
  var FOLD_OVER = 20;
513
568
  var LABEL_MAX = 44;
514
569
  var CHAIN_SLACK_MS = 50;
515
- var PHASE_ORDER = ["workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
516
- var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
570
+ var PHASE_ORDER = ["routing", "workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
517
571
  function seconds(ms) {
518
572
  return `${(ms / 1000).toFixed(1)} s`;
519
573
  }
@@ -569,7 +623,11 @@ function spanFacts(span) {
569
623
  fps_median: (v) => `${v} fps`,
570
624
  memory_mb: (v) => `${v} MB memory`,
571
625
  clocks: (v) => Number(v) > 1 ? "across two clocks" : null,
572
- reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null
626
+ reasoning: (v) => Number(v) > 0 ? `reasoning ${count(Number(v))}` : null,
627
+ route: (v) => `routed ${v}`,
628
+ route_by: (v) => v === "fallback" ? "the router did not answer" : null,
629
+ downgraded: (v) => v === true ? "full was over the hold" : null,
630
+ route_reason: (v) => `"${v}"`
573
631
  };
574
632
  if (span.kind === "model") {
575
633
  const input = (num(a.input) ?? 0) + (num(a.cached) ?? 0);
@@ -707,13 +765,13 @@ function bar(row, total) {
707
765
  return `|${" ".repeat(a)}${"#".repeat(b - a)}${" ".repeat(BAR_WIDTH - b)}|`;
708
766
  }
709
767
  function renderTimeline(turn, spans, options = {}) {
710
- const kind = `${turn.effort} ${turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn"}`;
768
+ const kind = turnKind(turn, spans);
711
769
  if (spans.length === 0) {
712
770
  const why = turn.status === "queued" || turn.status === "running" ? " A turn's timeline is written when it ends." : "";
713
771
  return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
714
772
  }
715
773
  const spanEnd = Math.max(...spans.map(endOf));
716
- const total = Math.max(spanEnd, turn.timing?.total_ms ?? 0);
774
+ const total = Math.max(spanEnd, turn.timing.total_ms ?? 0);
717
775
  const critical = criticalPath(spans);
718
776
  const { roots, children } = treeOf(spans);
719
777
  const rows = [];
@@ -756,7 +814,7 @@ function renderTimeline(turn, spans, options = {}) {
756
814
  visit(span, 0);
757
815
  const cut = (label) => label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 3)}...` : label;
758
816
  const width = Math.max(...rows.map((r) => cut(r.label).length), 4);
759
- const turnTotal = turn.timing?.total_ms ?? spanEnd;
817
+ const turnTotal = turn.timing.total_ms ?? spanEnd;
760
818
  const lines = [
761
819
  `Turn ${turn.id}, a ${kind}, ${turn.status}: ${duration(turnTotal)}, ${plural(spans.length, "span")}${firsts(turn.timing)}. * marks the critical path.`,
762
820
  `${"span".padEnd(width)} ${"0 s".padEnd(BAR_WIDTH / 2 + 1)}${seconds(total).padStart(BAR_WIDTH / 2 + 1)} ${"start".padStart(8)} ${"time".padStart(8)}`
@@ -768,12 +826,26 @@ function renderTimeline(turn, spans, options = {}) {
768
826
  return lines;
769
827
  }
770
828
  function isFirstBuild(spans) {
771
- return spans.some((s) => s.name === "beginTurn" && s.attrs?.first_build === true);
829
+ return spans.some((s) => (s.name === "beginTurn" || s.name === "askRouter") && s.attrs?.first_build === true);
830
+ }
831
+ function workOf(spans) {
832
+ const interview = spans.find((s) => s.name === "askInterviewer" && typeof s.attrs?.interview === "string")?.attrs?.interview;
833
+ if (interview === "ask")
834
+ return "interview";
835
+ if (interview === "draft")
836
+ return "draft";
837
+ const route = spans.find((s) => s.name === "askRouter" && typeof s.attrs?.route === "string")?.attrs?.route;
838
+ return typeof route === "string" ? route : null;
839
+ }
840
+ function turnKind(turn, spans) {
841
+ const work = workOf(spans);
842
+ if (work === "interview" || work === "draft")
843
+ return `${turn.effort} ${work} turn`;
844
+ const what = turn.plan ? "plan turn" : isFirstBuild(spans) ? "first build" : "build turn";
845
+ return `${turn.effort} ${work === "fast" || work === "full" ? `${work} ` : ""}${what}`;
772
846
  }
773
847
  function firsts(timing) {
774
- if (!timing || timing.first_dev_ms === undefined && timing.first_write_ms === undefined && timing.first_preview_ms === undefined)
775
- return "";
776
- const say = (ms) => typeof ms === "number" ? duration(ms) : "-";
848
+ const say = (ms) => ms === null ? "-" : duration(ms);
777
849
  return `; live link ${say(timing.first_dev_ms)}, first change ${say(timing.first_write_ms)}, first playable ${say(timing.first_preview_ms)}`;
778
850
  }
779
851
  var isEngineRun = (s) => s.kind === "step" && s.name === "engine_programmer";
@@ -806,11 +878,7 @@ function engineSummary(spans) {
806
878
  if (!run)
807
879
  return null;
808
880
  const a = run.attrs ?? {};
809
- const kids = spans.filter((s) => s.parent === run.id);
810
- const steps = num(a.steps) ?? kids.filter((s) => s.kind === "model").length;
811
- const tools = num(a.tools) ?? kids.filter((s) => s.kind === "tool").length;
812
- const images = num(a.images) ?? kids.filter((s) => s.kind === "tool" && s.name === "make_image").length;
813
- const figures = [plural(steps, "model step"), plural(tools, "tool call"), plural(images, "image")];
881
+ const figures = [plural(num(a.steps) ?? 0, "model step"), plural(num(a.tools) ?? 0, "tool call"), plural(num(a.images) ?? 0, "image")];
814
882
  if (a.cut === true)
815
883
  figures.push("cut short");
816
884
  return `in ${duration(run.ms)}: ${figures.join(", ")}`;
@@ -824,23 +892,29 @@ function table(rows, indent) {
824
892
  var sec = (ms) => ms === null ? "-" : seconds(ms);
825
893
  var pair = (p, f) => `${f(p.p50)} / ${f(p.p90)}`;
826
894
  function groupTitle(g) {
895
+ if (g.work === "interview" || g.work === "draft")
896
+ return `${g.effort} ${g.work} turns, ${g.engine}`;
827
897
  const what = g.plan ? "plan turns" : g.first_build === true ? "first builds" : g.first_build === false ? "later builds" : "build turns";
828
- return `${g.effort} ${what}, ${g.engine}`;
898
+ const work = g.work === "plan" ? "routed " : g.work ? `${g.work} ` : "";
899
+ return `${g.effort} ${work}${what}, ${g.engine}`;
829
900
  }
830
901
  function renderStats(groups, options) {
831
902
  const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
832
903
  if (groups.length === 0)
833
904
  return [`No turns finished in ${over}.`];
834
- const per = options.microsPerCredit ?? null;
835
- const credits = (micros) => micros === null || !per ? "-" : String(Math.ceil(micros / per));
905
+ const per = options.microsPerCredit;
906
+ const credits = (micros) => micros === null || per === null ? "-" : String(Math.ceil(micros / per));
836
907
  const cents = (micros) => micros === null ? "-" : (micros / 1e4).toFixed(1);
837
908
  const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
838
909
  for (const g of groups) {
839
910
  lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
911
+ const reacted = REACTION_WORDS.filter(([key]) => (g.reactions[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
912
+ if (reacted.length)
913
+ lines.push(` the next message: ${reacted.join(", ")}`);
840
914
  const figures = [
841
915
  ["figure", "p50", "p90", "turns"],
842
916
  ["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
843
- ["first change", sec(g.first_write_ms?.p50 ?? null), sec(g.first_write_ms?.p90 ?? null), ""],
917
+ ["first change", sec(g.first_write_ms.p50), sec(g.first_write_ms.p90), ""],
844
918
  ["first playable", sec(g.first_preview_ms.p50), sec(g.first_preview_ms.p90), ""],
845
919
  ["whole turn", sec(g.total_ms.p50), sec(g.total_ms.p90), ""]
846
920
  ];
@@ -865,6 +939,12 @@ function renderStats(groups, options) {
865
939
  }
866
940
  return lines;
867
941
  }
942
+ var REACTION_WORDS = [
943
+ ["moved_on", "moved on"],
944
+ ["corrected", "corrected it"],
945
+ ["asked_checks", "asked for checks"],
946
+ ["none", "no reaction"]
947
+ ];
868
948
  function rank(phase) {
869
949
  const i = PHASE_ORDER.indexOf(phase);
870
950
  return i === -1 ? PHASE_ORDER.length : i;
@@ -875,38 +955,82 @@ var CREATE_HELP = `velven create: describe a game, and Velven's agents build it.
875
955
 
876
956
  Usage:
877
957
  velven create new [title...] Start a project; it becomes the current one
878
- --engine <id> The engine pack (default phaser)
879
- --genre <id> The genre pack (default arcade)
880
- velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn
958
+ --engine <id> The engine pack (default: Velven picks, 3D unless your idea is clearly 2D)
959
+ --genre <id> The genre pack (default none: an open game, Velven follows your idea;
960
+ --genre arcade holds it to arcade's rules)
961
+ velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn.
962
+ Velven picks how much to do (a quick change, a full build with a playtest,
963
+ a plan; on a new game a question or two, then a short plan to shape) unless
964
+ you pass --effort or a plan flag. Sent while a turn runs, it joins that turn
965
+ when the turn is writing code, else waits for the turn to end (with
966
+ --effort, a plan flag, --build, --ref or --answer it always waits)
881
967
  --project <id> A project other than the current one
882
- --effort quick|standard|deep How much the turn may do; kept for later messages until you change it
883
- --plan, --no-plan Plan mode: the turn updates the design document and changes no game files;
884
- kept until you change it (on by default for standard, off for quick)
968
+ --effort quick|standard|deep How much the turn may do, instead of Velven's pick. The project keeps it
969
+ for a later message that passes only a plan flag; a message with neither
970
+ flag is Velven's pick again
971
+ --plan, --no-plan Plan mode, instead of Velven's pick: the turn updates the design document
972
+ and changes no game files. Kept as --effort is (with --effort alone, on for
973
+ a new standard or deep, off for quick)
974
+ --build Build it: no more questions, Velven fills in what the plan leaves open
975
+ (Velven still picks how much)
976
+ --answer <n> Answer the open question with its choice n (its words are sent, unless
977
+ you give your own)
978
+ --queue Sent while a turn runs: wait for it to end instead of joining it
979
+ --ref <file|ref-n> A reference picture (PNG, JPEG or WebP, up to 10 MB) sent with the message, or
980
+ one the project keeps by its id (ref-3); up to 4
885
981
  --yes, -y Do not ask before sending
886
982
  --detach Send it and return at once; follow it later with watch
887
983
  velven create watch [turn] Follow a turn to its end (default: the last one)
888
984
  --from <n> Start from this event
889
- velven create cancel [turn] Stop a turn: what it finished is saved, unused credits come back
985
+ velven create watch <project> Follow a project's chat: its turns, messages, pictures, queue and versions
986
+ --from <n> Only what came after this event
987
+ --until-idle End once no turn runs, no preview is being rebuilt and no message waits
988
+ to start (one on a paused queue waits for resume)
989
+ velven create cancel [turn] Stop a turn: what it finished is saved, unused credits come back, and the
990
+ messages waiting on the project pause until you resume them
991
+ velven create resume Start the messages waiting on the project again after a failed or stopped
992
+ turn paused them
993
+ --project <id> A project other than the current one
994
+ velven create versions The project's versions, newest first: each saved turn, and 0, the template
995
+ --project <id> A project other than the current one
996
+ velven create restore <version> Go back (or forward) to a version: instant and free; Velven rebuilds its
997
+ preview when it expired
998
+ --project <id> A project other than the current one
890
999
  velven create list Your projects (* is the current one)
891
- velven create show [project] A project, its newest turn and its preview link (a fresh 24-hour link)
1000
+ velven create show [project] A project, what it is doing, its newest turn and its preview link (a fresh
1001
+ 24-hour link)
892
1002
  --timeline The newest turn's timeline instead: a waterfall of every step, model and
893
1003
  tool call, the critical path marked (* at the line's end)
894
1004
  --turn <id> With --timeline: this turn instead of the project's newest
895
1005
  --all With --timeline: list every tool call and screenshot (past 20 under one
896
1006
  step they fold into one line)
1007
+ velven create refs The project's reference pictures: each id, when it was added and how many
1008
+ messages carried it (a project keeps up to 16)
1009
+ --project <id> A project other than the current one
1010
+ velven create refs rm <ref-n> Delete a picture, sent or not; the messages that carried it keep its number
1011
+ velven create plan The project's short plan: what the game is, how it plays, its look
1012
+ --project <id> A project other than the current one
1013
+ velven create queue The messages waiting on the project, in the order they run
1014
+ --project <id> A project other than the current one
1015
+ velven create queue edit <id> <text...> Change a waiting message's words (--build: send it as Build it)
1016
+ velven create queue move <id> <place> Move a waiting message to a place in line (1 runs next)
1017
+ velven create queue rm <id> Remove a waiting message
897
1018
  velven create credits Your credits and the latest entries
1019
+ velven create usage What each of your turns held and used, newest first
1020
+ --project <id> Only this project's turns
1021
+ --before <turn> The page before this turn (the line at the end names it)
898
1022
  velven create stats Admins: p50 and p90 over recent turns, by effort, plan mode and engine
899
1023
  --days <n> Turns created in the last n days, 1 to 90 (default 7)
900
1024
  --origin <origin> Only turns sent from web, api, cli, mcp or benchmark
901
1025
 
902
- --json: new, send, list, show, credits and stats print one JSON object (send prints the finished turn,
903
- show --timeline the timeline); watch prints one event per line.
904
- Ctrl+C stops following a turn; the turn goes on.
1026
+ --json: new, send, list, show, plan, queue, refs, versions, restore, resume, credits, usage and stats print one
1027
+ JSON object (send prints the finished turn, show --timeline the timeline); watch prints one event per line.
1028
+ Ctrl+C stops following a turn; the turn goes on. On a project, it stops watching (exit 0).
905
1029
 
906
1030
  Exit codes:
907
1031
  0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
908
- 2 bad command 3 needs sign-in 4 refused (no credits, closed, busy, too many turns, effort unavailable,
909
- not found) 5 rate limited, try later`;
1032
+ 2 bad command 3 needs sign-in 4 refused (no credits, closed, a turn is open, too many turns, effort
1033
+ unavailable, a picture refused, not found) 5 rate limited, try later`;
910
1034
  var CREATE_FLAGS = {
911
1035
  engine: "string",
912
1036
  genre: "string",
@@ -914,29 +1038,52 @@ var CREATE_FLAGS = {
914
1038
  effort: "string",
915
1039
  plan: "boolean",
916
1040
  "no-plan": "boolean",
1041
+ build: "boolean",
1042
+ answer: "string",
1043
+ queue: "boolean",
1044
+ ref: "strings",
917
1045
  yes: "boolean",
918
1046
  detach: "boolean",
919
1047
  from: "string",
1048
+ "until-idle": "boolean",
920
1049
  json: "boolean",
921
1050
  timeline: "boolean",
922
1051
  turn: "string",
923
1052
  all: "boolean",
924
1053
  days: "string",
925
- origin: "string"
1054
+ origin: "string",
1055
+ before: "string"
926
1056
  };
927
1057
  var SUBCOMMANDS = {
928
1058
  new: ["engine", "genre", "json"],
929
- send: ["project", "effort", "plan", "no-plan", "yes", "detach", "json"],
930
- watch: ["from", "json"],
1059
+ send: ["project", "effort", "plan", "no-plan", "build", "answer", "queue", "ref", "yes", "detach", "json"],
1060
+ watch: ["from", "until-idle", "json"],
931
1061
  cancel: [],
1062
+ resume: ["project", "json"],
1063
+ versions: ["project", "json"],
1064
+ restore: ["project", "json"],
932
1065
  list: ["json"],
933
1066
  show: ["json", "timeline", "turn", "all"],
1067
+ refs: ["project", "json"],
1068
+ plan: ["project", "json"],
1069
+ queue: ["project", "build", "json"],
934
1070
  credits: ["json"],
1071
+ usage: ["project", "before", "json"],
935
1072
  stats: ["days", "origin", "json"]
936
1073
  };
937
1074
  var EFFORTS = ["quick", "standard", "deep"];
1075
+ var REFS_PER_MESSAGE = 4;
1076
+ var REFS_PER_PROJECT = 16;
938
1077
  var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
939
1078
  var ROOT = "/api/v1/create";
1079
+ var FAILURE_SENTENCES = {
1080
+ credits: "This turn could not start: not enough credits.",
1081
+ capacity: "Velven is at capacity; try again in a few minutes.",
1082
+ model: "The model service did not answer.",
1083
+ sandbox: "The workspace did not start.",
1084
+ build: "The build was refused, so no preview was made.",
1085
+ unknown: "Something went wrong on Velven's side."
1086
+ };
940
1087
  async function create(ctx, parsed) {
941
1088
  const [sub, ...args] = parsed.positionals;
942
1089
  if (!sub) {
@@ -960,9 +1107,15 @@ async function create(ctx, parsed) {
960
1107
  case "send":
961
1108
  return send(ctx, api, args, parsed, json);
962
1109
  case "watch":
963
- return watch(ctx, api, args[0], stringFlag(parsed, "from"), json);
1110
+ return watch(ctx, api, args, stringFlag(parsed, "from"), parsed.flags["until-idle"] === true, json);
964
1111
  case "cancel":
965
1112
  return cancel(ctx, api, args[0]);
1113
+ case "resume":
1114
+ return resume(ctx, api, args, stringFlag(parsed, "project"), json);
1115
+ case "versions":
1116
+ return versions(ctx, api, args, stringFlag(parsed, "project"), json);
1117
+ case "restore":
1118
+ return restore(ctx, api, args, stringFlag(parsed, "project"), json);
966
1119
  case "list":
967
1120
  return list(ctx, api, json);
968
1121
  case "show":
@@ -974,6 +1127,14 @@ async function create(ctx, parsed) {
974
1127
  return show(ctx, api, args[0], json);
975
1128
  case "stats":
976
1129
  return stats(ctx, api, stringFlag(parsed, "days"), stringFlag(parsed, "origin"), json);
1130
+ case "refs":
1131
+ return referencePictures(ctx, api, args, stringFlag(parsed, "project"), json);
1132
+ case "usage":
1133
+ return usage(ctx, api, stringFlag(parsed, "project"), stringFlag(parsed, "before"), json);
1134
+ case "plan":
1135
+ return showPlan(ctx, api, args, stringFlag(parsed, "project"), json);
1136
+ case "queue":
1137
+ return queue(ctx, api, args, stringFlag(parsed, "project"), parsed.flags.build === true, json);
977
1138
  default:
978
1139
  return credits(ctx, api, json);
979
1140
  }
@@ -993,12 +1154,17 @@ async function newProject(ctx, api, title, engine, genre, json) {
993
1154
  return EXIT.ok;
994
1155
  }
995
1156
  ctx.out(`Started ${project.title ?? "a project"} (${project.id}): ${packs(project)}. It is now the current project.`);
996
- ctx.out('Tell it what to build: velven create send "a one-button game where a fox jumps over logs"');
1157
+ if (!project.genre)
1158
+ ctx.out("An open game: Velven follows your idea. --genre arcade holds a project to arcade's rules.");
1159
+ if (!engine)
1160
+ ctx.out("Velven picks the engine from your idea: 3D unless it is clearly 2D.");
1161
+ ctx.out('Tell it what to build: velven create send "a cozy game where you grow a garden on a floating island"');
997
1162
  return EXIT.ok;
998
1163
  }
999
1164
  async function send(ctx, api, words, parsed, json) {
1000
- const text = words.join(" ").trim();
1001
- if (!text)
1165
+ let text = words.join(" ").trim();
1166
+ const answerFlag = stringFlag(parsed, "answer");
1167
+ if (!text && answerFlag === undefined)
1002
1168
  throw new CliError('Say what to build or change, like velven create send "make the fox jump higher".', EXIT.usage, "usage");
1003
1169
  const effort = stringFlag(parsed, "effort");
1004
1170
  if (effort !== undefined && !EFFORTS.includes(effort)) {
@@ -1008,60 +1174,292 @@ async function send(ctx, api, words, parsed, json) {
1008
1174
  throw new CliError("Pass --plan or --no-plan, not both.", EXIT.usage, "usage");
1009
1175
  }
1010
1176
  const plan = parsed.flags.plan === true ? true : parsed.flags["no-plan"] === true ? false : undefined;
1177
+ const routed = effort === undefined && plan === undefined;
1178
+ const refs = stringsFlag(parsed, "ref");
1179
+ if (refs.length > REFS_PER_MESSAGE)
1180
+ throw new CliError(`A message takes up to ${REFS_PER_MESSAGE} pictures; ${refs.length} were given with --ref.`, EXIT.usage, "usage");
1011
1181
  const project = await currentProject(ctx, api.base, stringFlag(parsed, "project"));
1182
+ let answer;
1183
+ if (answerFlag !== undefined) {
1184
+ const { question } = await readCreateState(ctx, api.base);
1185
+ if (!question || question.project !== project) {
1186
+ throw new CliError("There is no question to answer on this project. Send your answer in words instead.", EXIT.usage, "no_question");
1187
+ }
1188
+ const choice = Number(answerFlag);
1189
+ if (!/^\d+$/.test(answerFlag) || choice < 1 || choice > question.choices.length) {
1190
+ throw new CliError(`--answer is the number of a choice, 1 to ${question.choices.length}, not "${answerFlag}".`, EXIT.usage, "usage");
1191
+ }
1192
+ answer = { item: question.item, choice };
1193
+ if (!text)
1194
+ text = question.choices[choice - 1];
1195
+ }
1196
+ const pictures = await Promise.all(refs.map((ref) => REF_ID.test(ref) ? ref : readPicture(ctx, ref)));
1012
1197
  const say = json ? () => {} : (line) => ctx.out(line);
1013
1198
  const query = new URLSearchParams;
1199
+ if (routed)
1200
+ query.set("routed", "true");
1201
+ if (routed && parsed.flags.build === true)
1202
+ query.set("intent", "build");
1014
1203
  if (effort !== undefined)
1015
1204
  query.set("effort", effort);
1016
1205
  if (plan !== undefined)
1017
1206
  query.set("plan", String(plan));
1018
1207
  const search = query.toString();
1019
1208
  const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
1020
- const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build === true ? `${estimate.effort} first build (its pictures are made before the first preview)` : `${estimate.effort} build turn`;
1021
- say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
1209
+ if (estimate.open_turn) {} else if (routed) {
1210
+ const what = estimate.shaping ? "a question or a short plan" : estimate.first_build ? "a first build (its pictures are made before the first preview)" : "a small change";
1211
+ say(`Velven picks how much to do: about ${estimate.credits.typical} credits for ${what}, at most ${estimate.credits.cap} held. You have ${estimate.available} available.`);
1212
+ } else {
1213
+ const kind = estimate.plan ? `${estimate.effort} plan turn` : estimate.first_build ? `${estimate.effort} first build (its pictures are made before the first preview)` : `${estimate.effort} build turn`;
1214
+ say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
1215
+ }
1022
1216
  if (!estimate.enough) {
1023
1217
  throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
1024
1218
  }
1025
1219
  if (ctx.interactive && !json && parsed.flags.yes !== true) {
1026
- const answer = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
1027
- if (answer === "n" || answer === "no") {
1220
+ const reply = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
1221
+ if (reply === "n" || reply === "no") {
1028
1222
  ctx.out("Nothing was sent.");
1029
1223
  return EXIT.failed;
1030
1224
  }
1031
1225
  }
1032
- const body = { text };
1226
+ const body = { text, client_id: randomUUID() };
1227
+ if (parsed.flags.queue === true)
1228
+ body.delivery = "queue";
1033
1229
  if (effort !== undefined)
1034
1230
  body.effort = effort;
1035
1231
  if (plan !== undefined)
1036
1232
  body.plan = plan;
1037
- const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body);
1038
- const turn = sent.turn;
1233
+ if (parsed.flags.build === true)
1234
+ body.intent = "build";
1235
+ if (answer)
1236
+ body.answer = answer;
1237
+ if (pictures.length) {
1238
+ const ids = [];
1239
+ for (const picture of pictures)
1240
+ ids.push(typeof picture === "string" ? picture : await uploadPicture(api, project, picture));
1241
+ body.references = ids;
1242
+ say(`Attached ${plural(ids.length, "picture")}: ${ids.join(", ")}.`);
1243
+ }
1244
+ const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body, { retryOnce: { sleep: ctx.sleep } });
1245
+ if ((await readCreateState(ctx, api.base)).question?.project === project)
1246
+ await saveCreateState(ctx, api.base, { question: undefined });
1247
+ const already = sent.again ? "(already sent) " : "";
1248
+ if ("steer" in sent)
1249
+ return steered(ctx, api, project, sent, already, parsed.flags.detach === true, json);
1250
+ let turn;
1251
+ let stream2;
1252
+ if ("queued" in sent) {
1253
+ const queued = sent.queued;
1254
+ if (queued.state === "refused")
1255
+ throw new CliError(queued.refused ?? "Velven could not start this message.", EXIT.refused, "refused");
1256
+ if (queued.state === "waiting" || queued.turn === null) {
1257
+ if (json)
1258
+ ctx.out(JSON.stringify({ ok: true, queued, why: sent.why, ...sent.again ? { again: true } : {} }));
1259
+ else
1260
+ say(`${already}${queuedLine(queued, sent.why)}`);
1261
+ return EXIT.ok;
1262
+ }
1263
+ turn = { id: queued.turn, ...queued.capped === true ? { queued: true } : {} };
1264
+ } else {
1265
+ turn = sent.turn;
1266
+ stream2 = sent.stream;
1267
+ }
1039
1268
  await saveCreateState(ctx, api.base, { project, turn: turn.id });
1040
1269
  if (turn.queued)
1041
- say(`Turn ${turn.id} waits for room on Velven and starts on its own.`);
1270
+ say(`${already}Turn ${turn.id} waits for room on Velven and starts on its own.`);
1042
1271
  else
1043
- say(`Turn ${turn.id} is on its way.`);
1272
+ say(`${already}Turn ${turn.id} is on its way.`);
1044
1273
  if (parsed.flags.detach === true) {
1045
1274
  if (json)
1046
- ctx.out(JSON.stringify({ ok: true, turn }));
1275
+ ctx.out(JSON.stringify({ ok: true, turn, ...sent.again ? { again: true } : {} }));
1047
1276
  else
1048
1277
  say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
1049
1278
  return EXIT.ok;
1050
1279
  }
1051
- const path = sent.stream?.startsWith("/") ? sent.stream : `${ROOT}/turns/${seg(turn.id)}/stream`;
1052
- return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text");
1280
+ const path = stream2 ?? `${ROOT}/turns/${seg(turn.id)}/stream`;
1281
+ return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text", project);
1053
1282
  }
1054
- async function watch(ctx, api, arg, fromFlag, json) {
1283
+ async function steered(ctx, api, project, sent, already, detach, json) {
1284
+ const turn = sent.steer.turn;
1285
+ const say = json ? () => {} : (line) => ctx.out(line);
1286
+ if (sent.steer.state === "dropped") {
1287
+ if (json)
1288
+ ctx.out(JSON.stringify({ ok: true, message: sent.message, steer: sent.steer, again: true }));
1289
+ else
1290
+ say(`${already}${STEER_DROPPED}`);
1291
+ return EXIT.ok;
1292
+ }
1293
+ await saveCreateState(ctx, api.base, { project, turn });
1294
+ say(`${already}Sent to the running turn (${turn}).`);
1295
+ if (sent.steer.state === "taken")
1296
+ say(STEER_TAKEN);
1297
+ if (detach) {
1298
+ if (json)
1299
+ ctx.out(JSON.stringify({ ok: true, message: sent.message, steer: sent.steer, ...sent.again ? { again: true } : {} }));
1300
+ else
1301
+ say(`Follow it: velven create watch ${turn}; stop it: velven create cancel ${turn}`);
1302
+ return EXIT.ok;
1303
+ }
1304
+ const mine = `m${sent.message.id}`;
1305
+ let state = sent.steer.state;
1306
+ let queuedWhy;
1307
+ let started = null;
1308
+ let replied = false;
1309
+ let ended = null;
1310
+ const controller = new AbortController;
1311
+ ctx.untilStopped().then(() => controller.abort());
1312
+ let final;
1313
+ try {
1314
+ final = await stream(api, `${ROOT}/projects/${seg(project)}/stream`, { start: { after: sent.seq }, signal: controller.signal, sleep: ctx.sleep }, (raw) => {
1315
+ const event = raw;
1316
+ const items = event.type === "snapshot" ? event.items : event.type === "item" ? [event.item] : [];
1317
+ for (const item of items) {
1318
+ const summary = item.summary;
1319
+ if (summary.kind !== "message")
1320
+ continue;
1321
+ if (item.id === mine && summary.steer) {
1322
+ const paused = summary.steer.state === "queued" && summary.steer.why === "paused" && queuedWhy !== "paused";
1323
+ if (summary.steer.state !== state || paused) {
1324
+ state = summary.steer.state;
1325
+ queuedWhy = summary.steer.why;
1326
+ if (state === "taken")
1327
+ say(STEER_TAKEN);
1328
+ if (state === "queued")
1329
+ say(steerQueuedLine(queuedWhy));
1330
+ }
1331
+ if (summary.steer.started && started === null)
1332
+ started = summary.steer.started;
1333
+ }
1334
+ if (item.turn_id === turn && summary.role === "agent" && !replied) {
1335
+ replied = true;
1336
+ say(summary.text);
1337
+ }
1338
+ }
1339
+ const turns = event.type === "snapshot" ? event.turns : event.type === "turn" ? [event.turn] : [];
1340
+ for (const t of turns)
1341
+ if (t.id === turn && ENDED.has(t.status))
1342
+ ended = t;
1343
+ return event.type === "error" || ended !== null;
1344
+ });
1345
+ } catch (e) {
1346
+ if (e instanceof CliError && e.code === "stream_lost") {
1347
+ const lost = new CliError(`${e.message} The turn goes on: velven create watch ${turn}`, e.exitCode, e.code);
1348
+ if (!json)
1349
+ throw lost;
1350
+ ctx.out(JSON.stringify({ ok: false, error: lost.message, code: lost.code, turn: { id: turn } }));
1351
+ return lost.exitCode;
1352
+ }
1353
+ throw e;
1354
+ } finally {
1355
+ controller.abort();
1356
+ }
1357
+ if (final === null || final.type === "error") {
1358
+ const message = final === null ? `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}` : `${final.message} The turn may go on. Watch it: velven create watch ${turn}`;
1359
+ if (json)
1360
+ ctx.out(JSON.stringify({ ok: false, error: message, code: final === null ? "detached" : final.code, turn: { id: turn } }));
1361
+ else
1362
+ ctx.err(message);
1363
+ return EXIT.failed;
1364
+ }
1365
+ const end = ended;
1366
+ const code = end?.status === "done" ? EXIT.ok : EXIT.failed;
1367
+ if (json) {
1368
+ const read = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`).catch(() => null);
1369
+ ctx.out(JSON.stringify({ ok: code === EXIT.ok, message: sent.message, steer: { turn, state }, turn: read?.turn ?? end }));
1370
+ return code;
1371
+ }
1372
+ if (!replied) {
1373
+ const read = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`).catch(() => null);
1374
+ if (read?.turn.reply?.body)
1375
+ ctx.out(read.turn.reply.body);
1376
+ }
1377
+ if (end)
1378
+ for (const line of endLines(end))
1379
+ ctx.out(line);
1380
+ if (started !== null)
1381
+ ctx.out(`Your message is running: velven create watch ${started}`);
1382
+ return code;
1383
+ }
1384
+ var ENDED = new Set(["done", "cancelled", "failed"]);
1385
+ var STEER_TAKEN = "Taken into account.";
1386
+ var STEER_QUEUED = "Could not be taken this turn; queued as the next message.";
1387
+ var STEER_PAUSED = "Could not be taken this turn; queued, but the queue is paused after a failed or stopped turn. Start it: velven create resume";
1388
+ function steerQueuedLine(why) {
1389
+ return why === "paused" ? STEER_PAUSED : STEER_QUEUED;
1390
+ }
1391
+ var STEER_DROPPED = "Taken out of the queue; it will not run.";
1392
+ function endLines(turn) {
1393
+ if (turn.status === "done")
1394
+ return [`Turn ${turn.id} is done.`, ...stopLines(turn.stop_reason)];
1395
+ if (turn.status === "cancelled")
1396
+ return [`Turn ${turn.id} was cancelled. What it finished is saved.`];
1397
+ const sentence = turn.error ?? (turn.failure ? FAILURE_SENTENCES[turn.failure.class] : "Velven could not finish it.");
1398
+ return [`Turn ${turn.id} failed: ${sentence} A failed turn costs nothing.${turn.failure?.retryable ? " Try again." : ""}`];
1399
+ }
1400
+ function stopLines(stop) {
1401
+ if (stop === "cap")
1402
+ return ["It stopped at its credit cap. What it finished is saved: send another message to go on."];
1403
+ if (stop === "time")
1404
+ return ["It stopped at its time limit. What it finished is saved: send another message to go on."];
1405
+ return [];
1406
+ }
1407
+ var REF_ID = /^ref-[1-9][0-9]{0,2}$/;
1408
+ async function uploadPicture(api, project, picture) {
1409
+ const { upload } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references/uploads`);
1410
+ const tooLarge = `The picture ${picture.file} is larger than ${Math.floor(upload.max_bytes / (1024 * 1024))} MB. Send a smaller one.`;
1411
+ if (picture.bytes.length > upload.max_bytes)
1412
+ throw new CliError(tooLarge, EXIT.refused, "too_large");
1413
+ await putBytes(upload.url, picture.bytes, picture.type, tooLarge);
1414
+ try {
1415
+ const { reference } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references`, { upload: upload.id });
1416
+ return reference.id;
1417
+ } catch (e) {
1418
+ if (e instanceof CliError && e.code === "too_many_references") {
1419
+ const named = `velven create refs --project ${project}`;
1420
+ throw new CliError(`${e.message} See them: ${named}; remove one: velven create refs rm <ref-n> --project ${project}.`, e.exitCode, e.code);
1421
+ }
1422
+ throw e;
1423
+ }
1424
+ }
1425
+ var PICTURE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp" };
1426
+ async function readPicture(ctx, file) {
1427
+ let bytes;
1428
+ try {
1429
+ bytes = new Uint8Array(await readFile3(resolve(ctx.cwd, file)));
1430
+ } catch {
1431
+ throw new CliError(`Could not read the picture ${file}.`, EXIT.usage, "usage");
1432
+ }
1433
+ const ext = file.toLowerCase().split(".").pop() ?? "";
1434
+ return { file, bytes, type: PICTURE_TYPES[ext] ?? "application/octet-stream" };
1435
+ }
1436
+ async function turnProject(ctx, api, turn) {
1437
+ try {
1438
+ const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
1439
+ return view.project;
1440
+ } catch {
1441
+ return (await readCreateState(ctx, api.base)).project ?? null;
1442
+ }
1443
+ }
1444
+ async function watch(ctx, api, args, fromFlag, untilIdle, json) {
1445
+ const [arg, ...rest] = args;
1446
+ if (rest.length)
1447
+ throw new CliError("Name one turn or one project to watch, like velven create watch 12.", EXIT.usage, "usage");
1055
1448
  let from;
1056
1449
  if (fromFlag !== undefined) {
1057
1450
  if (!/^\d+$/.test(fromFlag))
1058
1451
  throw new CliError(`--from is an event number, like 12, not "${fromFlag}".`, EXIT.usage, "usage");
1059
1452
  from = Number(fromFlag);
1060
1453
  }
1454
+ if (arg !== undefined && !/^\d+$/.test(arg))
1455
+ return watchProject(ctx, api, arg, from, untilIdle, json);
1456
+ if (untilIdle)
1457
+ throw new CliError("--until-idle goes with a project, like velven create watch <project> --until-idle.", EXIT.usage, "usage");
1061
1458
  const turn = await turnId(ctx, api.base, arg, "watch");
1459
+ const named = json ? null : await turnProject(ctx, api, turn);
1062
1460
  let code;
1063
1461
  try {
1064
- code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text");
1462
+ code = await follow(ctx, api, turn, `${ROOT}/turns/${seg(turn)}/stream`, from, json ? "events" : "text", named);
1065
1463
  } catch (e) {
1066
1464
  if (e instanceof CliError && e.code === "stream_lost")
1067
1465
  await saveCreateState(ctx, api.base, { turn });
@@ -1070,13 +1468,311 @@ async function watch(ctx, api, arg, fromFlag, json) {
1070
1468
  await saveCreateState(ctx, api.base, { turn });
1071
1469
  return code;
1072
1470
  }
1073
- async function follow(ctx, api, turn, path, from, mode) {
1471
+ async function watchProject(ctx, api, project, after, untilIdle, json) {
1074
1472
  const controller = new AbortController;
1075
1473
  ctx.untilStopped().then(() => controller.abort());
1076
- const said = { held: null, engine: null };
1474
+ const kept = (await readCreateState(ctx, api.base)).question;
1475
+ const before = kept?.project === project ? kept : null;
1476
+ const chat = chatView(ctx, api.base, project, before);
1477
+ let idle = false;
1478
+ let synced = false;
1077
1479
  let final;
1078
1480
  try {
1079
- final = await stream(api, path, { from, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
1481
+ final = await stream(api, `${ROOT}/projects/${seg(project)}/stream`, { ...after !== undefined ? { start: { after } } : {}, signal: controller.signal, sleep: ctx.sleep }, (raw) => {
1482
+ const event = raw;
1483
+ if (json)
1484
+ ctx.out(JSON.stringify(raw));
1485
+ else
1486
+ chat.show(event);
1487
+ if (event.type === "summary") {
1488
+ const { open_turn, rebuilding, queue } = event.summary;
1489
+ idle = open_turn === null && rebuilding === null && (queue.waiting === 0 || queue.paused !== null);
1490
+ }
1491
+ if (event.type === "synchronized")
1492
+ synced = true;
1493
+ return event.type === "error" || untilIdle && synced && idle && (event.type === "synchronized" || event.type === "summary");
1494
+ });
1495
+ } catch (e) {
1496
+ if (e instanceof CliError && e.code === "stream_lost")
1497
+ throw new CliError(`${e.message} Watch it again: velven create watch ${project}`, e.exitCode, e.code);
1498
+ throw e;
1499
+ } finally {
1500
+ controller.abort();
1501
+ if (chat.question !== before)
1502
+ await saveCreateState(ctx, api.base, { question: chat.question ?? undefined });
1503
+ }
1504
+ if (final?.type === "error") {
1505
+ if (!json)
1506
+ ctx.err(final.message || "The project's stream answered with an error.");
1507
+ return EXIT.failed;
1508
+ }
1509
+ return EXIT.ok;
1510
+ }
1511
+ function chatView(ctx, base, project, kept) {
1512
+ const send2 = `velven create send --project ${project}`;
1513
+ const said = new Map;
1514
+ const turns = new Map;
1515
+ let headed = null;
1516
+ let queueSaid = "";
1517
+ let failureSaid = "";
1518
+ let synced = false;
1519
+ let olderSaid = false;
1520
+ const header = (turn, summary) => {
1521
+ if (turn === null || headed === turn) {
1522
+ headed = turn;
1523
+ return;
1524
+ }
1525
+ headed = turn;
1526
+ const marks = summary ? [summary.status, summary.version ? `version ${summary.version}` : null, summary.rolled_back ? "rolled back" : null].filter((m) => m !== null) : [];
1527
+ ctx.out(`Turn ${turn}${marks.length ? ` (${marks.join(", ")})` : ""}`);
1528
+ };
1529
+ const turnKey = (t) => `${t.status}|${t.phase ?? ""}`;
1530
+ const works = new Map;
1531
+ const item = (i, summaries) => {
1532
+ if (i.summary.kind === "route" && i.turn_id !== null)
1533
+ works.set(i.turn_id, i.summary.work);
1534
+ const lines = itemLines(i, base, send2, i.turn_id !== null && works.get(i.turn_id) === "draft");
1535
+ const known = said.get(i.id) ?? new Set;
1536
+ said.set(i.id, known);
1537
+ const fresh = [...lines.out.map((line) => ({ key: `out ${line}`, line, write: ctx.out })), ...lines.err.map((line) => ({ key: `err ${line}`, line, write: ctx.err }))].filter((l) => !known.has(l.key));
1538
+ const under = chatTurn(i);
1539
+ if (fresh.length)
1540
+ header(under, under !== null ? summaries.get(under) : undefined);
1541
+ for (const { key, line, write } of fresh) {
1542
+ known.add(key);
1543
+ write(line);
1544
+ }
1545
+ if (i.summary.kind === "question") {
1546
+ if (i.summary.state === "open")
1547
+ view.question = { project, item: i.id, choices: i.summary.choices };
1548
+ else if (view.question?.item === i.id)
1549
+ view.question = null;
1550
+ }
1551
+ };
1552
+ const view = {
1553
+ question: kept,
1554
+ show(event) {
1555
+ switch (event.type) {
1556
+ case "snapshot": {
1557
+ const summaries = new Map(event.turns.map((t) => [t.id, t]));
1558
+ for (const t of event.turns)
1559
+ turns.set(t.id, turnKey(t));
1560
+ if (event.older && !olderSaid)
1561
+ ctx.err("Earlier turns are not shown.");
1562
+ olderSaid ||= event.older !== null;
1563
+ for (const i of chatOrder(event.items))
1564
+ item(i, summaries);
1565
+ return;
1566
+ }
1567
+ case "item":
1568
+ item(event.item, new Map);
1569
+ return;
1570
+ case "turn": {
1571
+ const t = event.turn;
1572
+ const before = turns.get(t.id);
1573
+ if (before === turnKey(t))
1574
+ return;
1575
+ turns.set(t.id, turnKey(t));
1576
+ if (ENDED.has(t.status)) {
1577
+ if (before?.split("|")[0] !== t.status)
1578
+ for (const line of endLines(t))
1579
+ ctx.out(line);
1580
+ } else if (t.phase)
1581
+ ctx.err(`Turn ${t.id}: ${t.phase}`);
1582
+ return;
1583
+ }
1584
+ case "queue": {
1585
+ const waiting = event.queue.queued.filter((q) => q.state === "waiting").length;
1586
+ const line = event.queue.paused ? `${plural(waiting, "message")} waiting. ${pausedLine(event.queue.paused)}` : waiting ? `${plural(waiting, "message")} waiting.` : queueSaid ? "Nothing is waiting." : "";
1587
+ if (line && line !== queueSaid)
1588
+ ctx.out(line);
1589
+ queueSaid = line;
1590
+ return;
1591
+ }
1592
+ case "summary": {
1593
+ const line = failureLine(event.summary) ?? "";
1594
+ if (line && line !== failureSaid)
1595
+ ctx.out(line);
1596
+ failureSaid = line;
1597
+ return;
1598
+ }
1599
+ case "synchronized":
1600
+ if (!synced)
1601
+ ctx.out("Up to date.");
1602
+ synced = true;
1603
+ return;
1604
+ default:
1605
+ return;
1606
+ }
1607
+ }
1608
+ };
1609
+ return view;
1610
+ }
1611
+ function chatTurn(i) {
1612
+ return i.summary.kind === "message" && i.summary.steer ? i.summary.steer.turn : i.turn_id;
1613
+ }
1614
+ function chatOrder(items) {
1615
+ const last = new Map;
1616
+ const byId = new Map(items.map((i) => [i.id, i]));
1617
+ for (const i of items) {
1618
+ for (let up = i.parent ? byId.get(i.parent) : undefined;up; up = up.parent ? byId.get(up.parent) : undefined) {
1619
+ last.set(up.id, Math.max(last.get(up.id) ?? up.pos, i.pos));
1620
+ }
1621
+ }
1622
+ const at = (i) => i.summary.kind === "run" && i.status !== "running" ? Math.max(i.seq, last.get(i.id) ?? i.pos) + 0.5 : i.pos;
1623
+ return [...items].sort((a, b) => at(a) - at(b));
1624
+ }
1625
+ function itemLines(i, base, send2, draft) {
1626
+ const out = [];
1627
+ const err = [];
1628
+ const s = i.summary;
1629
+ const ended = i.status !== "running";
1630
+ switch (s.kind) {
1631
+ case "message":
1632
+ if (s.role === "agent") {
1633
+ out.push(s.text);
1634
+ break;
1635
+ }
1636
+ out.push(`> ${s.text}`);
1637
+ if (s.steer?.state === "sent")
1638
+ out.push(` Sent to the running turn (${s.steer.turn}).`);
1639
+ if (s.steer?.state === "taken")
1640
+ out.push(` ${STEER_TAKEN}`);
1641
+ if (s.steer?.state === "queued")
1642
+ out.push(` ${steerQueuedLine(s.steer.why)}`);
1643
+ if (s.steer?.state === "dropped")
1644
+ out.push(` ${STEER_DROPPED}`);
1645
+ if (s.steer?.started)
1646
+ out.push(` It is running as turn ${s.steer.started}.`);
1647
+ break;
1648
+ case "route":
1649
+ out.push(s.said);
1650
+ break;
1651
+ case "question":
1652
+ out.push(`${s.question} (question ${s.index} of ${s.of})`);
1653
+ for (const [n, choice] of s.choices.entries())
1654
+ out.push(` ${n + 1}. ${choice}`);
1655
+ if (s.state === "open")
1656
+ out.push(`Answer: ${send2} --answer 1 (a choice's number), or in your own words.`);
1657
+ if (s.state === "answered" && s.answered_choice !== null)
1658
+ out.push(` Answered: ${s.answered_choice}.`);
1659
+ break;
1660
+ case "plan":
1661
+ if (!ended)
1662
+ break;
1663
+ out.push(`Plan: ${s.title}${s.pitch ? `: ${s.pitch}` : ""}`);
1664
+ if (draft)
1665
+ out.push(`Build it: ${send2} --build "Build it"`);
1666
+ break;
1667
+ case "brief":
1668
+ if (i.status !== "done")
1669
+ break;
1670
+ out.push(`Designing ${s.title ?? "your game"}${s.pitch ? `: ${s.pitch}` : ""}`);
1671
+ if (s.style)
1672
+ out.push(` Style: ${s.style}`);
1673
+ if (s.palette.length)
1674
+ out.push(` Palette: ${s.palette.join(", ")}`);
1675
+ break;
1676
+ case "run": {
1677
+ const label = jobLabel(s.job);
1678
+ if (!ended)
1679
+ err.push(`${label} started${s.model ? ` (${s.model})` : ""}.`);
1680
+ else if (i.status === "done")
1681
+ err.push(`${label} done${s.summary ? `: ${s.summary}` : "."}`);
1682
+ else
1683
+ err.push(`${label} ${i.status}.`);
1684
+ break;
1685
+ }
1686
+ case "tool":
1687
+ if (!ended && !s.note)
1688
+ break;
1689
+ err.push(` ${s.name}: ${s.note ?? s.summary ?? ""}`.trimEnd());
1690
+ if (i.status === "failed")
1691
+ err.push(` ${s.name} failed${s.summary ? `: ${s.summary}` : "."}`);
1692
+ break;
1693
+ case "picture":
1694
+ if (s.placeholder)
1695
+ err.push(` A placeholder stands in for ${s.picture_id}.`);
1696
+ else if (s.media)
1697
+ out.push(`Painted ${s.picture_id} (${s.width} by ${s.height})${s.redraw ? " again" : ""} ${mediaLink(base, s.media)}`);
1698
+ break;
1699
+ case "files":
1700
+ if (s.changed.length)
1701
+ err.push(` changed: ${s.changed.map((c) => `${c.path} +${c.added} -${c.removed}`).join(", ")}${s.more > 0 ? `, and ${s.more} more` : ""}`);
1702
+ break;
1703
+ case "build":
1704
+ if (!ended)
1705
+ break;
1706
+ if (s.ok)
1707
+ err.push(`The build passed in ${(s.ms / 1000).toFixed(1)} s.`);
1708
+ else
1709
+ err.push("The build failed:", ...s.errors.map((e) => ` ${e}`));
1710
+ break;
1711
+ case "preview":
1712
+ if (i.status === "running") {
1713
+ if (s.rebuild)
1714
+ out.push("Rebuilding the preview (no credits).");
1715
+ } else if (i.status === "done") {
1716
+ out.push(`${s.rebuild ? "The preview is rebuilt" : s.purpose === "playtest" ? "A playtest preview is ready" : "A preview is ready"}: velven create show prints its link.`);
1717
+ } else if (i.status === "failed")
1718
+ out.push(`No preview was made${s.refused ? `: ${s.refused}` : "."}`);
1719
+ else
1720
+ err.push("The preview's rebuild stopped: the next turn makes its own.");
1721
+ break;
1722
+ case "playtest":
1723
+ if (!ended)
1724
+ break;
1725
+ err.push(`Playtest round ${s.round}: ${s.ok ? "every check passed" : plural(s.problems, "problem")}.`);
1726
+ if (s.judge_summary)
1727
+ err.push(` ${s.judge_summary}`);
1728
+ for (const shot of s.shots)
1729
+ err.push(` screenshot ${shot.label}: ${mediaLink(base, shot)}`);
1730
+ if (s.clip)
1731
+ out.push(`Clip: ${mediaLink(base, s.clip.media)}`);
1732
+ break;
1733
+ case "review":
1734
+ if (!ended)
1735
+ break;
1736
+ err.push(`Review: ${s.ok ? "no problems" : plural(s.problems, "problem")}.${s.summary ? ` ${s.summary}` : ""}`);
1737
+ break;
1738
+ case "commit":
1739
+ if (i.status === "done")
1740
+ out.push(`Committed ${s.sha.slice(0, 7)}: ${s.message}`);
1741
+ else if (ended)
1742
+ out.push(`Not saved (${s.sha.slice(0, 7)}): ${s.message}`);
1743
+ break;
1744
+ case "restore":
1745
+ out.push(restoreLine(s.version, s.commit, s.from_version));
1746
+ break;
1747
+ }
1748
+ return { out, err };
1749
+ }
1750
+ function restoreLine(version2, commit, from) {
1751
+ return `Went ${from !== null && version2 < from ? "back" : "forward"} to version ${version2} (commit ${commit.slice(0, 7)}).`;
1752
+ }
1753
+ function failureLine(summary) {
1754
+ const failure = summary.failure;
1755
+ if (!failure || failure.turn !== null && failure.turn !== summary.latest?.turn)
1756
+ return null;
1757
+ return `${failure.turn !== null ? `Turn ${failure.turn} failed: ` : ""}${FAILURE_SENTENCES[failure.class]}${failure.retryable ? " Try again." : ""}`;
1758
+ }
1759
+ var askedIn = (q) => Number(/^t(\d+)\./.exec(q.item)?.[1] ?? 0);
1760
+ async function keepQuestion(ctx, base, turn, project, asked) {
1761
+ const kept = (await readCreateState(ctx, base)).question;
1762
+ const same = kept !== undefined && kept.project === (asked?.project ?? project);
1763
+ if (asked) {
1764
+ if (!same || askedIn(kept) <= turn)
1765
+ await saveCreateState(ctx, base, { question: asked });
1766
+ } else if (same && askedIn(kept) < turn)
1767
+ await saveCreateState(ctx, base, { question: undefined });
1768
+ }
1769
+ async function follow(ctx, api, turn, path, from, mode, project) {
1770
+ const controller = new AbortController;
1771
+ ctx.untilStopped().then(() => controller.abort());
1772
+ const said = { held: null, engine: null, project, asked: false, next: [], base: api.base, question: null, work: null };
1773
+ let final;
1774
+ try {
1775
+ final = await stream(api, path, { ...from !== undefined ? { start: { from } } : {}, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
1080
1776
  if (mode === "events")
1081
1777
  ctx.out(JSON.stringify(event2));
1082
1778
  else if (mode === "text")
@@ -1093,6 +1789,7 @@ async function follow(ctx, api, turn, path, from, mode) {
1093
1789
  } finally {
1094
1790
  controller.abort();
1095
1791
  release(ctx, said);
1792
+ await keepQuestion(ctx, api.base, turn, project, said.question);
1096
1793
  }
1097
1794
  if (final === null) {
1098
1795
  const message = `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}`;
@@ -1118,11 +1815,10 @@ async function follow(ctx, api, turn, path, from, mode) {
1118
1815
  }
1119
1816
  ctx.out(JSON.stringify({ ok: code === EXIT.ok, ...error, turn: view }));
1120
1817
  } else if (mode === "text") {
1121
- finish(ctx, turn, event);
1818
+ finish(ctx, turn, event, said);
1122
1819
  }
1123
1820
  return code;
1124
1821
  }
1125
- var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1126
1822
  function capital(text) {
1127
1823
  return text.charAt(0).toUpperCase() + text.slice(1);
1128
1824
  }
@@ -1134,8 +1830,8 @@ function lasting(at, expires) {
1134
1830
  if (!Number.isFinite(minutes) || minutes <= 0)
1135
1831
  return "";
1136
1832
  if (minutes < 90)
1137
- return plural2(minutes, "minute");
1138
- return plural2(Math.round(minutes / 60), "hour");
1833
+ return plural(minutes, "minute");
1834
+ return plural(Math.round(minutes / 60), "hour");
1139
1835
  }
1140
1836
  function jobDone(ctx, label, figures, summary) {
1141
1837
  if (figures)
@@ -1165,12 +1861,57 @@ function render(ctx, event, said) {
1165
1861
  release(ctx, said);
1166
1862
  switch (event.type) {
1167
1863
  case "turn.started":
1168
- ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
1864
+ if (event.routed)
1865
+ ctx.err(`Started: Velven is choosing the work, ${plural(event.credits_held, "credit")} held.`);
1866
+ else
1867
+ ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held.`);
1868
+ return;
1869
+ case "route":
1870
+ said.work = event.work;
1871
+ ctx.out(event.said);
1872
+ ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held: ${event.reason})`);
1873
+ return;
1874
+ case "ask": {
1875
+ ctx.out(`${event.question} (question ${event.index} of ${event.of})`);
1876
+ for (const [i, choice] of event.choices.entries())
1877
+ ctx.out(` ${i + 1}. ${choice}`);
1878
+ const send2 = sendCommand(said);
1879
+ if (event.item_id && said.project) {
1880
+ said.question = { project: said.project, item: event.item_id, choices: event.choices };
1881
+ ctx.out(`Answer: ${send2} --answer 1 (a choice's number), or in your own words.`);
1882
+ } else
1883
+ ctx.out(`Answer: ${send2} "${event.choices[0]}", or in your own words.`);
1884
+ if (event.picture || event.topic === "style")
1885
+ ctx.out(`Or show the look: ${send2} --ref <file> "like this".`);
1886
+ ctx.out(`Just build it: ${send2} --build "Just build it"`);
1887
+ said.asked = true;
1169
1888
  return;
1889
+ }
1890
+ case "plan":
1891
+ if (!event.done)
1892
+ return;
1893
+ for (const line of planCard(event.plan))
1894
+ ctx.out(line);
1895
+ if (said.work === "draft")
1896
+ ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
1897
+ return;
1898
+ case "brief": {
1899
+ if (!event.done)
1900
+ return;
1901
+ if (event.title || event.pitch)
1902
+ ctx.out(`Designing ${event.title ?? "your game"}${event.pitch ? `: ${event.pitch}` : ""}`);
1903
+ if (event.style)
1904
+ ctx.out(` Style: ${event.style}`);
1905
+ if (event.palette.length)
1906
+ ctx.out(` Palette: ${event.palette.join(", ")}`);
1907
+ return;
1908
+ }
1170
1909
  case "turn.queued":
1171
1910
  ctx.err("Still waiting for room on Velven.");
1172
1911
  return;
1173
1912
  case "phase":
1913
+ if (event.progress)
1914
+ ctx.out(event.progress.label);
1174
1915
  ctx.err(`${capital(event.phase)}${event.detail ? `: ${event.detail}` : ""}`);
1175
1916
  return;
1176
1917
  case "job": {
@@ -1192,6 +1933,9 @@ function render(ctx, event, said) {
1192
1933
  }
1193
1934
  case "tool":
1194
1935
  ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
1936
+ if (event.picture?.media) {
1937
+ ctx.out(`Painted ${event.picture.id} (${event.picture.width} by ${event.picture.height}) ${dim(ctx, mediaLink(said.base, event.picture.media))}`);
1938
+ }
1195
1939
  return;
1196
1940
  case "text":
1197
1941
  ctx.out(event.text);
@@ -1210,10 +1954,10 @@ function render(ctx, event, said) {
1210
1954
  ctx.err("The live link is gone.");
1211
1955
  return;
1212
1956
  case "shot":
1213
- ctx.err(` screenshot ${event.label}: ${event.url}`);
1957
+ ctx.err(` screenshot ${event.label}: ${mediaLink(said.base, event.media)}`);
1214
1958
  return;
1215
1959
  case "clip":
1216
- ctx.out(`Clip: ${event.url}`);
1960
+ ctx.out(`Clip: ${mediaLink(said.base, event.media)}`);
1217
1961
  return;
1218
1962
  case "build":
1219
1963
  if (event.ok)
@@ -1225,10 +1969,10 @@ function render(ctx, event, said) {
1225
1969
  }
1226
1970
  return;
1227
1971
  case "playtest":
1228
- ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" : plural2(event.problems, "check problem")}.`);
1972
+ ctx.err(`Playtest round ${event.round}: ${event.ok ? "every check passed" : plural(event.problems, "check problem")}.`);
1229
1973
  return;
1230
1974
  case "review":
1231
- ctx.err(`Review: ${event.ok ? "no problems" : plural2(event.problems, "problem")}.`);
1975
+ ctx.err(`Review: ${event.ok ? "no problems" : plural(event.problems, "problem")}.`);
1232
1976
  return;
1233
1977
  case "commit":
1234
1978
  ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
@@ -1243,44 +1987,138 @@ function render(ctx, event, said) {
1243
1987
  return;
1244
1988
  }
1245
1989
  case "cost":
1246
- ctx.err(`${event.credits_used} of ${plural2(event.credits_held, "credit")} used so far.`);
1990
+ ctx.err(`${event.credits_used} of ${plural(event.credits_held, "credit")} used so far.`);
1991
+ return;
1992
+ case "next":
1993
+ said.next = event.choices;
1994
+ return;
1995
+ case "steer":
1996
+ ctx.out(event.state === "taken" ? "A message you sent while it ran was taken into account." : event.why === "paused" ? "A message you sent while it ran could not be taken this turn; it is queued, but the queue is paused. Start it: velven create resume" : "A message you sent while it ran could not be taken this turn; it is queued as the next message.");
1247
1997
  return;
1248
1998
  default:
1249
1999
  return;
1250
2000
  }
1251
2001
  }
1252
- function finish(ctx, turn, event) {
2002
+ function finish(ctx, turn, event, said) {
1253
2003
  if (event.type === "error") {
1254
2004
  const tail = event.code === "turn_stopped" || event.code === "not_found" ? " Check it again later: velven create show" : ` The turn may go on. Watch it: velven create watch ${turn}; check it: velven create show`;
1255
2005
  ctx.err(`${event.message || "The turn's stream answered with an error."}${tail}`);
1256
2006
  return;
1257
2007
  }
1258
- if (event.reply)
1259
- ctx.out(event.reply);
2008
+ const reply = event.reply && said.asked ? event.reply.split(`
2009
+
2010
+ `).slice(1).join(`
2011
+
2012
+ `) : event.reply;
2013
+ if (reply)
2014
+ ctx.out(reply);
2015
+ if (said.next.length) {
2016
+ ctx.out("Where it could go next:");
2017
+ for (const [i, choice] of said.next.entries())
2018
+ ctx.out(` ${i + 1}. ${choice}`);
2019
+ ctx.out(`Send one: velven create send${said.project ? ` --project ${said.project}` : ""} "${said.next[0]}"`);
2020
+ }
1260
2021
  if (event.preview)
1261
2022
  ctx.out(`Play it: ${event.preview.url}`);
1262
- const back = event.refunded > 0 ? `; ${plural2(event.refunded, "credit")} came back` : "";
2023
+ const back = event.refunded > 0 ? `; ${plural(event.refunded, "credit")} came back` : "";
1263
2024
  if (event.status === "done") {
1264
- ctx.out(`Turn ${turn} is done: ${plural2(event.credits_charged, "credit")} used${back}.`);
2025
+ ctx.out(`Turn ${turn} is done: ${plural(event.credits_charged, "credit")} used${back}.`);
1265
2026
  if (event.stop_reason === "cap")
1266
2027
  ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
1267
2028
  if (event.stop_reason === "time")
1268
2029
  ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
1269
- return;
2030
+ } else if (event.status === "cancelled") {
2031
+ ctx.err(`Turn ${turn} was cancelled: ${plural(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
2032
+ } else {
2033
+ ctx.err(`Turn ${turn} failed: ${event.error ?? (event.failure ? FAILURE_SENTENCES[event.failure.class] : "Velven could not finish it.")} A failed turn costs nothing.`);
1270
2034
  }
1271
- if (event.status === "cancelled") {
1272
- ctx.err(`Turn ${turn} was cancelled: ${plural2(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
1273
- return;
2035
+ if (reply?.includes(BUILD_AGAIN))
2036
+ ctx.out(`Try again: ${sendCommand(said)} --build "Build it"`);
2037
+ else if (event.status === "failed" && event.failure?.retryable)
2038
+ ctx.out(`Try again: ${sendCommand(said)} with the same words.`);
2039
+ if (event.next_turn) {
2040
+ const elsewhere = event.next_project && said.project && event.next_project !== said.project ? ` on project ${event.next_project}` : "";
2041
+ ctx.out(`Your next message${elsewhere} is running: velven create watch ${event.next_turn}`);
1274
2042
  }
1275
- ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
2043
+ }
2044
+ var BUILD_AGAIN = "Press Build it to try again.";
2045
+ function sendCommand(said) {
2046
+ return `velven create send${said.project ? ` --project ${said.project}` : ""}`;
2047
+ }
2048
+ function mediaLink(base, media) {
2049
+ return `${base}${ROOT}/media/${media.id}`;
2050
+ }
2051
+ function dim(ctx, text) {
2052
+ return ctx.interactive && !ctx.env.NO_COLOR ? `\x1B[2m${text}\x1B[22m` : text;
2053
+ }
2054
+ function planCard(plan) {
2055
+ const lines = [];
2056
+ if (plan.title)
2057
+ lines.push(plan.title);
2058
+ if (plan.pitch)
2059
+ lines.push(plan.pitch);
2060
+ if (plan.dimension)
2061
+ lines.push(`${plan.dimension === "3d" ? "3D" : "2D"}${plan.camera ? `, ${plan.camera}` : ""}`);
2062
+ if (plan.play?.length) {
2063
+ lines.push("How it plays:");
2064
+ for (const [i, beat] of plan.play.entries())
2065
+ lines.push(` ${i + 1}. ${beat}`);
2066
+ }
2067
+ if (plan.look)
2068
+ lines.push(`Look: ${plan.look}`);
2069
+ if (plan.palette?.length)
2070
+ lines.push(`Palette: ${plan.palette.join(", ")}`);
2071
+ if (plan.contents?.length) {
2072
+ lines.push("What is in it:");
2073
+ for (const thing of plan.contents)
2074
+ lines.push(` - ${thing}`);
2075
+ }
2076
+ if (plan.ending)
2077
+ lines.push(`How it ends: ${plan.ending}`);
2078
+ if (plan.score !== undefined)
2079
+ lines.push(plan.score ? "It keeps a score." : "No score.");
2080
+ return lines;
2081
+ }
2082
+ function queuedLine(queued, why) {
2083
+ const ahead = queued.position !== null && queued.position > 1 ? plural(queued.position - 1, "message") : null;
2084
+ const list = "velven create queue lists what waits.";
2085
+ switch (why) {
2086
+ case "paused":
2087
+ return `Queued, but the queue is paused after a failed or stopped turn. Start it: velven create resume`;
2088
+ case "wrapping_up":
2089
+ return `Queued: the turn is finishing, so it runs next${ahead ? ` after ${ahead} ahead of it` : ""}. ${list}`;
2090
+ case "limit":
2091
+ return `Queued: five messages already joined this turn, so it runs next${ahead ? ` after ${ahead} ahead of it` : ""}. ${list}`;
2092
+ case "phase":
2093
+ return `Queued: the turn is not writing code now, so it runs after the turn${ahead ? ` and ${ahead} ahead of it` : ""}. ${list}`;
2094
+ case "steer_off":
2095
+ return `Queued: it runs after the turn that is running${ahead ? ` and ${ahead} ahead of it` : ""}. ${list}`;
2096
+ default:
2097
+ return `Queued: it runs after ${ahead ? `${ahead} ahead of it` : "the message ahead of it"}. ${list}`;
2098
+ }
2099
+ }
2100
+ function pausedLine(paused) {
2101
+ return `Paused after a ${paused.why === "failed" ? "failed" : "stopped"} turn. Resume: velven create resume`;
1276
2102
  }
1277
2103
  async function cancel(ctx, api, arg) {
1278
2104
  const turn = await turnId(ctx, api.base, arg, "cancel");
1279
2105
  const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
1280
- if (answer.status === "cancelled")
2106
+ if (answer.status === "cancelled" && !answer.started)
1281
2107
  ctx.out(`Turn ${turn} is cancelled before it started. Its credits are back.`);
2108
+ else if (answer.status === "cancelled")
2109
+ ctx.out(`Turn ${turn} is stopped: ${plural(answer.credits_charged, "credit")} used. What it finished is saved.`);
1282
2110
  else
1283
2111
  ctx.out(`Turn ${turn} is stopping. What it finished is saved and unused credits come back. Follow it: velven create watch ${turn}`);
2112
+ try {
2113
+ const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
2114
+ const queue = await call(api, "GET", `${ROOT}/projects/${seg(view.project)}/queue`);
2115
+ if (queue.paused || answer.status === "cancelling" && queue.queued.some((q) => q.state === "waiting")) {
2116
+ ctx.out(`${answer.status === "cancelled" ? "Stopped" : "Stopping"}. The messages waiting on this project are paused: velven create resume starts them.`);
2117
+ }
2118
+ } catch (e) {
2119
+ if (!(e instanceof CliError))
2120
+ throw e;
2121
+ }
1284
2122
  return EXIT.ok;
1285
2123
  }
1286
2124
  async function list(ctx, api, json) {
@@ -1312,11 +2150,14 @@ async function show(ctx, api, arg, json) {
1312
2150
  }
1313
2151
  ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
1314
2152
  ctx.out(`Packs: ${packs(project)}`);
1315
- ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
1316
- ctx.out(`Latest commit: ${project.head ? project.head.slice(0, 7) : "none yet"}`);
2153
+ if (project.turn && !project.turn.routed)
2154
+ ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
2155
+ const summary = project.summary;
2156
+ const version2 = summary?.head?.version;
2157
+ ctx.out(`Latest commit: ${project.head ? `${project.head.slice(0, 7)}${version2 !== null && version2 !== undefined ? ` (version ${version2})` : ""}` : "none yet"}`);
1317
2158
  const t = project.turn;
1318
2159
  if (t) {
1319
- const charged = t.credits.charged !== null ? `, ${plural2(t.credits.charged, "credit")} used` : `, ${plural2(t.credits.held, "credit")} held`;
2160
+ const charged = t.credits.charged !== null ? `, ${plural(t.credits.charged, "credit")} used` : `, ${plural(t.credits.held, "credit")} held`;
1320
2161
  const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
1321
2162
  ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
1322
2163
  if (t.error)
@@ -1324,12 +2165,32 @@ async function show(ctx, api, arg, json) {
1324
2165
  } else {
1325
2166
  ctx.out("Newest turn: none yet");
1326
2167
  }
2168
+ if (summary)
2169
+ for (const line of summaryLines(summary))
2170
+ ctx.out(line);
1327
2171
  if (project.preview?.url)
1328
2172
  ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
1329
2173
  else
1330
2174
  ctx.out("Preview: none yet");
1331
2175
  return EXIT.ok;
1332
2176
  }
2177
+ function summaryLines(summary) {
2178
+ const lines = [];
2179
+ if (summary.open_turn)
2180
+ lines.push(`Running: ${summary.open_turn.phase ?? "starting"} (turn ${summary.open_turn.id})`);
2181
+ if (summary.rebuilding) {
2182
+ const version2 = summary.head?.version;
2183
+ lines.push(`A preview of ${version2 !== null && version2 !== undefined ? `version ${version2}` : "the current version"} is being rebuilt.`);
2184
+ }
2185
+ if (summary.queue.paused)
2186
+ lines.push(`${plural(summary.queue.waiting, "message")} waiting. ${pausedLine(summary.queue.paused)}`);
2187
+ else if (summary.queue.waiting > 0)
2188
+ lines.push(`${plural(summary.queue.waiting, "message")} waiting.`);
2189
+ const failure = failureLine(summary);
2190
+ if (failure)
2191
+ lines.push(failure);
2192
+ return lines;
2193
+ }
1333
2194
  async function showTimeline(ctx, api, arg, turnFlag, all, json) {
1334
2195
  let id;
1335
2196
  if (turnFlag !== undefined) {
@@ -1346,12 +2207,11 @@ async function showTimeline(ctx, api, arg, turnFlag, all, json) {
1346
2207
  id = view.turn.id;
1347
2208
  }
1348
2209
  const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
1349
- const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
1350
2210
  if (json) {
1351
- ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
2211
+ ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline: turn.timeline }));
1352
2212
  return EXIT.ok;
1353
2213
  }
1354
- for (const line of renderTimeline(turn, timeline.spans, { all }))
2214
+ for (const line of renderTimeline(turn, turn.timeline.spans, { all }))
1355
2215
  ctx.out(line);
1356
2216
  return EXIT.ok;
1357
2217
  }
@@ -1379,8 +2239,249 @@ async function stats(ctx, api, daysFlag, origin, json) {
1379
2239
  ctx.out(JSON.stringify({ ok: true, ...answer }));
1380
2240
  return EXIT.ok;
1381
2241
  }
1382
- for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit ?? null }))
2242
+ for (const line of renderStats(answer.stats, { days, origin, microsPerCredit: answer.micros_per_credit }))
2243
+ ctx.out(line);
2244
+ return EXIT.ok;
2245
+ }
2246
+ async function referencePictures(ctx, api, args, projectFlag, json) {
2247
+ const [action, id, ...rest] = args;
2248
+ if (action === "rm") {
2249
+ if (id === undefined || !REF_ID.test(id) || rest.length) {
2250
+ throw new CliError("Name one picture to remove by its id, like velven create refs rm ref-3.", EXIT.usage, "usage");
2251
+ }
2252
+ const project2 = await currentProject(ctx, api.base, projectFlag);
2253
+ await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/references/${seg(id)}`);
2254
+ if (json)
2255
+ ctx.out(JSON.stringify({ ok: true, deleted: id }));
2256
+ else
2257
+ ctx.out(`Removed ${id}. The messages that carried it keep its number, shown as removed.`);
2258
+ return EXIT.ok;
2259
+ }
2260
+ if (action !== undefined) {
2261
+ throw new CliError(`Unknown command "create refs ${action}". List the pictures with velven create refs, remove one with velven create refs rm ref-3.`, EXIT.usage, "usage");
2262
+ }
2263
+ const project = await currentProject(ctx, api.base, projectFlag);
2264
+ const { references } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/references`);
2265
+ if (json) {
2266
+ ctx.out(JSON.stringify({ ok: true, references }));
2267
+ return EXIT.ok;
2268
+ }
2269
+ if (!references.length) {
2270
+ ctx.out('No reference pictures yet. Attach one: velven create send --ref <picture> "like this".');
2271
+ return EXIT.ok;
2272
+ }
2273
+ for (const r of references) {
2274
+ const carried = r.messages ? `on ${plural(r.messages, "message")}` : "not sent yet";
2275
+ ctx.out(`${r.id.padEnd(7)} ${when(r.created_at).padEnd(20)} ${`${r.width} by ${r.height}`.padEnd(12)} ${carried}`);
2276
+ }
2277
+ ctx.out(`${references.length} of ${REFS_PER_PROJECT} kept. A picture no message carried goes a day after it was last uploaded.`);
2278
+ return EXIT.ok;
2279
+ }
2280
+ async function usage(ctx, api, project, before, json) {
2281
+ if (before !== undefined && (!/^\d+$/.test(before) || Number(before) === 0))
2282
+ throw new CliError(`--before is a turn number, like 12, not "${before}".`, EXIT.usage, "usage");
2283
+ const query = new URLSearchParams;
2284
+ if (project)
2285
+ query.set("project", project);
2286
+ if (before)
2287
+ query.set("before", before);
2288
+ const search = query.toString();
2289
+ const answer = await call(api, "GET", `${ROOT}/usage${search ? `?${search}` : ""}`);
2290
+ if (json) {
2291
+ ctx.out(JSON.stringify({ ok: true, ...answer }));
2292
+ return EXIT.ok;
2293
+ }
2294
+ if (!answer.turns.length) {
2295
+ ctx.out(before ? "No earlier turns." : "No turns yet.");
2296
+ return EXIT.ok;
2297
+ }
2298
+ for (const t of answer.turns) {
2299
+ const used = t.credits.charged !== null ? `${plural(t.credits.charged, "credit")} used` : `${plural(t.credits.held, "credit")} held`;
2300
+ const kind = t.work ?? `${t.effort}${t.plan ? " plan" : ""}`;
2301
+ ctx.out(`turn ${String(t.turn).padEnd(6)} ${when(t.created_at).padEnd(20)} ${(t.project.title ?? "Untitled").padEnd(24)} ${kind.padEnd(10)} ${t.status.padEnd(9)} ${used}`.trimEnd());
2302
+ }
2303
+ if (answer.next !== null)
2304
+ ctx.out(`More: velven create usage${project ? ` --project ${project}` : ""} --before ${answer.next}`);
2305
+ return EXIT.ok;
2306
+ }
2307
+ async function showPlan(ctx, api, args, projectFlag, json) {
2308
+ if (args.length)
2309
+ throw new CliError("velven create plan takes no words: name another project with --project <id>.", EXIT.usage, "usage");
2310
+ const id = await currentProject(ctx, api.base, projectFlag);
2311
+ const { project } = await call(api, "GET", `${ROOT}/projects/${seg(id)}`);
2312
+ const plan = project.game_plan ?? null;
2313
+ if (json) {
2314
+ ctx.out(JSON.stringify({ ok: true, plan }));
2315
+ return EXIT.ok;
2316
+ }
2317
+ if (!plan) {
2318
+ ctx.out('This project has no plan yet. Send your idea and Velven sketches one with you: velven create send "a cozy farming game".');
2319
+ return EXIT.ok;
2320
+ }
2321
+ for (const line of planCard(plan))
1383
2322
  ctx.out(line);
2323
+ if (!project.head)
2324
+ ctx.out(`Build it: velven create send${projectFlag ? ` --project ${id}` : ""} --build "Build it"`);
2325
+ return EXIT.ok;
2326
+ }
2327
+ function queuedId(id, usage2) {
2328
+ if (id === undefined || !/^\d+$/.test(id) || Number(id) === 0)
2329
+ throw new CliError(usage2, EXIT.usage, "usage");
2330
+ return Number(id);
2331
+ }
2332
+ async function queue(ctx, api, args, projectFlag, build, json) {
2333
+ const [action, id, ...rest] = args;
2334
+ if (build && action !== "edit")
2335
+ throw new CliError("--build goes with velven create queue edit.", EXIT.usage, "usage");
2336
+ if (action === "rm") {
2337
+ const n = queuedId(rest.length ? undefined : id, "Name one waiting message by its number, like velven create queue rm 12.");
2338
+ const project2 = await currentProject(ctx, api.base, projectFlag);
2339
+ await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/queue/${seg(n)}`);
2340
+ if (json)
2341
+ ctx.out(JSON.stringify({ ok: true, removed: n }));
2342
+ else
2343
+ ctx.out(`Removed message ${n}: it will not run.`);
2344
+ return EXIT.ok;
2345
+ }
2346
+ if (action === "edit") {
2347
+ const n = queuedId(id, 'Name the waiting message and its new words, like velven create queue edit 12 "add a pond".');
2348
+ const text = rest.join(" ").trim();
2349
+ if (!text && !build)
2350
+ throw new CliError(`Give the message's new words, like velven create queue edit 12 "add a pond", or --build.`, EXIT.usage, "usage");
2351
+ const project2 = await currentProject(ctx, api.base, projectFlag);
2352
+ const body = {};
2353
+ if (text)
2354
+ body.text = text;
2355
+ if (build)
2356
+ body.intent = "build";
2357
+ const { queued: queued2 } = await call(api, "PATCH", `${ROOT}/projects/${seg(project2)}/queue/${seg(n)}`, body);
2358
+ if (json)
2359
+ ctx.out(JSON.stringify({ ok: true, queued: queued2 }));
2360
+ else
2361
+ ctx.out(`Changed message ${n}${queued2.position !== null ? `, ${ordinal(queued2.position)} in line` : ""}.`);
2362
+ return EXIT.ok;
2363
+ }
2364
+ if (action === "move") {
2365
+ const n = queuedId(id, "Name the waiting message and its new place, like velven create queue move 12 1.");
2366
+ const [place, ...extra] = rest;
2367
+ if (place === undefined || !/^\d+$/.test(place) || Number(place) === 0 || extra.length) {
2368
+ throw new CliError(`The place is a number, 1 for the next to run, like velven create queue move ${n} 1.`, EXIT.usage, "usage");
2369
+ }
2370
+ const project2 = await currentProject(ctx, api.base, projectFlag);
2371
+ const view = await moveQueued(api, project2, n, Number(place));
2372
+ const moved = view.queued.find((q) => q.id === n);
2373
+ if (json)
2374
+ ctx.out(JSON.stringify({ ok: true, ...view }));
2375
+ else
2376
+ ctx.out(`Moved message ${n}${moved?.position ? ` to ${ordinal(moved.position)} in line` : ""}.`);
2377
+ return EXIT.ok;
2378
+ }
2379
+ if (action !== undefined) {
2380
+ throw new CliError(`Unknown command "create queue ${action}". List what waits with velven create queue; change one with queue edit, move one with queue move, remove one with queue rm.`, EXIT.usage, "usage");
2381
+ }
2382
+ const project = await currentProject(ctx, api.base, projectFlag);
2383
+ const { queued, paused } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
2384
+ if (json) {
2385
+ ctx.out(JSON.stringify({ ok: true, queued, paused }));
2386
+ return EXIT.ok;
2387
+ }
2388
+ if (!queued.length) {
2389
+ ctx.out("Nothing is waiting on this project.");
2390
+ return EXIT.ok;
2391
+ }
2392
+ if (paused)
2393
+ ctx.out(pausedLine(paused));
2394
+ for (const q of queued) {
2395
+ const at = q.position !== null ? `${q.position}.` : q.state === "refused" ? "x" : "-";
2396
+ const words = q.text.length > 60 ? `${q.text.slice(0, 59)}…` : q.text;
2397
+ ctx.out(`${at.padStart(3)} ${String(q.id).padEnd(6)} ${when(q.created_at).padEnd(20)} ${q.intent === "build" ? "[build] " : ""}${words}`);
2398
+ if (q.from_steer)
2399
+ ctx.out(" (from a message sent while a turn ran)");
2400
+ if (q.state === "refused" && q.refused)
2401
+ ctx.out(` Not sent: ${q.refused}`);
2402
+ }
2403
+ if (queued.some((q) => q.state === "waiting")) {
2404
+ ctx.out(`They run in this order ${paused ? "once you resume" : "when the running turn ends"}. Change one: velven create queue edit <number> <text>; move one: queue move <number> <place>; remove one: queue rm <number>`);
2405
+ }
2406
+ return EXIT.ok;
2407
+ }
2408
+ async function moveQueued(api, project, id, place) {
2409
+ for (let attempt = 0;; attempt++) {
2410
+ const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
2411
+ const waiting = queued.filter((q) => q.state === "waiting").sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
2412
+ if (!waiting.some((q) => q.id === id))
2413
+ throw new CliError(`Message ${id} is not waiting on this project: velven create queue lists what waits.`, EXIT.refused, "not_found");
2414
+ const order = waiting.map((q) => q.id).filter((q) => q !== id);
2415
+ order.splice(Math.min(place, waiting.length) - 1, 0, id);
2416
+ try {
2417
+ return await call(api, "PUT", `${ROOT}/projects/${seg(project)}/queue/order`, { order });
2418
+ } catch (e) {
2419
+ if (!(e instanceof CliError) || e.code !== "queue_mismatch" || attempt > 0)
2420
+ throw e;
2421
+ }
2422
+ }
2423
+ }
2424
+ function ordinal(n) {
2425
+ const tens = n % 100;
2426
+ const suffix = tens >= 11 && tens <= 13 ? "th" : ["th", "st", "nd", "rd"][n % 10] ?? "th";
2427
+ return `${n}${suffix}`;
2428
+ }
2429
+ async function resume(ctx, api, args, projectFlag, json) {
2430
+ if (args.length)
2431
+ throw new CliError("velven create resume takes no words: name another project with --project <id>.", EXIT.usage, "usage");
2432
+ const project = await currentProject(ctx, api.base, projectFlag);
2433
+ const answer = await call(api, "POST", `${ROOT}/projects/${seg(project)}/queue/resume`, {});
2434
+ if (answer.started !== null)
2435
+ await saveCreateState(ctx, api.base, { project, turn: answer.started });
2436
+ if (json) {
2437
+ ctx.out(JSON.stringify({ ok: true, ...answer }));
2438
+ return EXIT.ok;
2439
+ }
2440
+ const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
2441
+ const waiting = queued.filter((q) => q.state === "waiting").length + (answer.started !== null ? 1 : 0);
2442
+ const first = answer.started !== null ? ` The first starts now: velven create watch ${answer.started}` : waiting > 0 ? " They start when the running turn ends." : "";
2443
+ ctx.out(answer.resumed ? `Resumed: ${waiting} waiting.${first}` : `Nothing was paused.${first}`);
2444
+ return EXIT.ok;
2445
+ }
2446
+ async function versions(ctx, api, args, projectFlag, json) {
2447
+ if (args.length)
2448
+ throw new CliError("velven create versions takes no words: name another project with --project <id>.", EXIT.usage, "usage");
2449
+ const project = await currentProject(ctx, api.base, projectFlag);
2450
+ const { versions: rows } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/versions`);
2451
+ if (json) {
2452
+ ctx.out(JSON.stringify({ ok: true, versions: rows }));
2453
+ return EXIT.ok;
2454
+ }
2455
+ if (!rows.length) {
2456
+ ctx.out('No versions yet: a turn that saves the game makes one. velven create send "a fox that hops over logs"');
2457
+ return EXIT.ok;
2458
+ }
2459
+ for (const v of rows) {
2460
+ const label = v.version === 0 ? "0 template" : String(v.version);
2461
+ const words = v.version === 0 ? "" : v.message.length > 60 ? `${v.message.slice(0, 59)}…` : v.message;
2462
+ const marks = [v.head ? "head" : null, v.rolled_back ? "rolled back" : null, v.preview?.status === "ready" ? "preview" : null].filter((m) => m !== null);
2463
+ ctx.out(`${label.padStart(10)} ${v.commit.slice(0, 7)} ${words.padEnd(60)} ${marks.join(", ")}`.trimEnd());
2464
+ }
2465
+ ctx.out("Go back or forward to one: velven create restore <version>");
2466
+ return EXIT.ok;
2467
+ }
2468
+ async function restore(ctx, api, args, projectFlag, json) {
2469
+ const [arg, ...rest] = args;
2470
+ if (arg === undefined || !/^\d+$/.test(arg) || rest.length) {
2471
+ throw new CliError("Name the version to go to by its number, like velven create restore 3 (velven create versions lists them).", EXIT.usage, "usage");
2472
+ }
2473
+ const project = await currentProject(ctx, api.base, projectFlag);
2474
+ const answer = await call(api, "POST", `${ROOT}/projects/${seg(project)}/restore`, { version: Number(arg) });
2475
+ if (json) {
2476
+ ctx.out(JSON.stringify({ ok: true, ...answer }));
2477
+ return EXIT.ok;
2478
+ }
2479
+ if ("unchanged" in answer)
2480
+ ctx.out(`Already on version ${arg}.`);
2481
+ else
2482
+ ctx.out(restoreLine(answer.restored.version, answer.restored.commit, answer.restored.from_version));
2483
+ if (answer.rebuild)
2484
+ ctx.out("Its preview expired; Velven is rebuilding it (no credits). velven create show prints it when ready.");
1384
2485
  return EXIT.ok;
1385
2486
  }
1386
2487
  async function credits(ctx, api, json) {
@@ -1389,7 +2490,7 @@ async function credits(ctx, api, json) {
1389
2490
  ctx.out(JSON.stringify({ ok: true, credits: view }));
1390
2491
  return EXIT.ok;
1391
2492
  }
1392
- ctx.out(`${plural2(view.available, "credit")} available: ${view.daily_left} left of today's allowance, ${view.balance} in your balance, ${view.held} held by open turns.`);
2493
+ ctx.out(`${plural(view.available, "credit")} available: ${view.daily_left} left of today's allowance, ${view.balance} in your balance, ${view.held} held by open turns.`);
1393
2494
  for (const row of view.ledger.slice(0, 10)) {
1394
2495
  const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
1395
2496
  ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
@@ -1400,12 +2501,6 @@ function packs(project) {
1400
2501
  const engine = `${project.engine.id} ${project.engine.version}`;
1401
2502
  return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
1402
2503
  }
1403
- function when(iso) {
1404
- if (!iso)
1405
- return "";
1406
- const d = new Date(iso);
1407
- return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
1408
- }
1409
2504
  async function currentProject(ctx, base, given) {
1410
2505
  if (given)
1411
2506
  return given;
@@ -1428,9 +2523,9 @@ async function turnId(ctx, base, arg, verb) {
1428
2523
 
1429
2524
  // src/dev.ts
1430
2525
  import { createReadStream as createReadStream2 } from "node:fs";
1431
- import { open as open2, readdir as readdir3, readFile as readFile5, realpath as realpath2, stat as stat2 } from "node:fs/promises";
2526
+ import { readdir as readdir3, readFile as readFile6, realpath as realpath2, stat as stat2 } from "node:fs/promises";
1432
2527
  import { createServer } from "node:http";
1433
- import { extname, join as join5, relative as relative2, resolve, sep } from "node:path";
2528
+ import { extname, join as join5, relative as relative2, resolve as resolve2, sep } from "node:path";
1434
2529
  import { createBrotliDecompress, createGunzip } from "node:zlib";
1435
2530
 
1436
2531
  // ../scan-rules/src/each-limit.ts
@@ -1753,7 +2848,7 @@ function refusalSentence(findings) {
1753
2848
  // src/files.ts
1754
2849
  import { createHash } from "node:crypto";
1755
2850
  import { createReadStream } from "node:fs";
1756
- import { open, readdir as readdir2, readFile as readFile4, realpath, stat } from "node:fs/promises";
2851
+ import { open, readdir as readdir2, readFile as readFile5, realpath, stat } from "node:fs/promises";
1757
2852
  import { join as join4, relative } from "node:path";
1758
2853
 
1759
2854
  // src/ignore.ts
@@ -2058,7 +3153,7 @@ function mediaProblem(kind, path, size2, bytes, missing = "is not among the file
2058
3153
  }
2059
3154
 
2060
3155
  // src/project.ts
2061
- import { readdir, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
3156
+ import { readdir, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
2062
3157
  import { join as join3, win32 } from "node:path";
2063
3158
  var SPACE_TYPES = ["game", "world", "tool", "wonder"];
2064
3159
  var DEVICES = ["desktop", "mobile", "vr"];
@@ -2078,7 +3173,7 @@ async function readProject(dir) {
2078
3173
  const path = join3(dir, names.includes(PROJECT_FILE) ? PROJECT_FILE : names.find(isProjectName) ?? PROJECT_FILE);
2079
3174
  let text;
2080
3175
  try {
2081
- text = await readFile3(path, "utf8");
3176
+ text = await readFile4(path, "utf8");
2082
3177
  } catch {
2083
3178
  return { path, exists: false, data: {} };
2084
3179
  }
@@ -2243,7 +3338,7 @@ async function walk(root, skipped = () => {}) {
2243
3338
  const realRoot = await realpath(root);
2244
3339
  let rules = [];
2245
3340
  try {
2246
- rules = parseIgnore(await readFile4(join4(root, ".velvenignore"), "utf8"));
3341
+ rules = parseIgnore(await readFile5(join4(root, ".velvenignore"), "utf8"));
2247
3342
  } catch {
2248
3343
  rules = [];
2249
3344
  }
@@ -2305,11 +3400,11 @@ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
2305
3400
  throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
2306
3401
  }
2307
3402
  }
2308
- async function readHead(abs) {
3403
+ async function readStart(abs, length) {
2309
3404
  const handle = await open(abs, "r");
2310
3405
  try {
2311
- const head = new Uint8Array(HEAD_BYTES);
2312
- const { bytesRead } = await handle.read(head, 0, HEAD_BYTES, 0);
3406
+ const head = new Uint8Array(length);
3407
+ const { bytesRead } = await handle.read(head, 0, length, 0);
2313
3408
  return head.subarray(0, bytesRead);
2314
3409
  } finally {
2315
3410
  await handle.close();
@@ -2319,7 +3414,7 @@ var SCAN_WINDOW = 8 * MB;
2319
3414
  var SCAN_OVERLAP = 64 * 1024;
2320
3415
  async function fileFindings(file) {
2321
3416
  if (!scansContent(file.path) || isCompressed(file.path))
2322
- return refuseFindings(file.path, await readHead(file.abs));
3417
+ return refuseFindings(file.path, await readStart(file.abs, HEAD_BYTES));
2323
3418
  const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
2324
3419
  const handle = await open(file.abs, "r");
2325
3420
  try {
@@ -2364,7 +3459,7 @@ async function checkMedia(config, files) {
2364
3459
  continue;
2365
3460
  const path = mediaPath(raw);
2366
3461
  const file = files.find((f) => f.path === path);
2367
- const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile4(file.abs)) : await readHead(file.abs);
3462
+ const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(file.abs)) : await readStart(file.abs, HEAD_BYTES);
2368
3463
  const problem = mediaProblem(kind, path, file?.size, bytes, "is not among the files this publish sends: check the path, and that .velvenignore does not leave it out");
2369
3464
  if (problem)
2370
3465
  problems.push(problem);
@@ -2376,9 +3471,9 @@ async function checkMedia(config, files) {
2376
3471
  return out;
2377
3472
  }
2378
3473
  function hashFile(abs) {
2379
- return new Promise((resolve, reject) => {
3474
+ return new Promise((resolve2, reject) => {
2380
3475
  const hash = createHash("sha256");
2381
- createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve(hash.digest("hex")));
3476
+ createReadStream(abs).on("data", (chunk) => hash.update(chunk)).on("error", reject).on("end", () => resolve2(hash.digest("hex")));
2382
3477
  });
2383
3478
  }
2384
3479
  var HASH_CHUNK = 4 * MB;
@@ -2482,16 +3577,6 @@ function contentType(path, head) {
2482
3577
  }
2483
3578
  var WASM_HEAD_BYTES = 1024 * 1024;
2484
3579
  var ISOLATION_HEADER = { "Document-Isolation-Policy": "isolate-and-credentialless" };
2485
- async function readStart(abs, length) {
2486
- const handle = await open2(abs, "r");
2487
- try {
2488
- const head = new Uint8Array(length);
2489
- const { bytesRead } = await handle.read(head, 0, length, 0);
2490
- return head.subarray(0, bytesRead);
2491
- } finally {
2492
- await handle.close();
2493
- }
2494
- }
2495
3580
  function inflatedStart(abs, encoding) {
2496
3581
  return new Promise((resolveHead) => {
2497
3582
  const source = createReadStream2(abs);
@@ -2551,7 +3636,7 @@ function injectSdk(html, src) {
2551
3636
  return tag + html;
2552
3637
  }
2553
3638
  async function ignoreRules(root) {
2554
- return parseIgnore(await readFile5(join5(root, ".velvenignore"), "utf8").catch(() => ""));
3639
+ return parseIgnore(await readFile6(join5(root, ".velvenignore"), "utf8").catch(() => ""));
2555
3640
  }
2556
3641
  async function namedAsOnDisk(base, rel) {
2557
3642
  const parts = rel.split("/").filter(Boolean);
@@ -2567,7 +3652,7 @@ async function statExact(base, rel) {
2567
3652
  return info && await namedAsOnDisk(base, rel) ? info : null;
2568
3653
  }
2569
3654
  async function startDevServer(root, options) {
2570
- const base = resolve(root);
3655
+ const base = resolve2(root);
2571
3656
  const realBase = await realpath2(base);
2572
3657
  const server = createServer(async (req, res) => {
2573
3658
  const send2 = (status, body2, headers2 = {}) => {
@@ -2586,7 +3671,7 @@ async function startDevServer(root, options) {
2586
3671
  if (!parts.every(servableSegment))
2587
3672
  return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
2588
3673
  let rel = parts.join("/");
2589
- let file = resolve(base, rel);
3674
+ let file = resolve2(base, rel);
2590
3675
  if (file !== base && !file.startsWith(base + sep))
2591
3676
  return send2(404, "Not found");
2592
3677
  let info = await statExact(base, rel);
@@ -2615,7 +3700,7 @@ async function startDevServer(root, options) {
2615
3700
  if (!real || !servableRelative(relative2(realBase, real))) {
2616
3701
  return send2(404, "Not found", { "content-type": "text/plain; charset=utf-8" });
2617
3702
  }
2618
- let body = await readFile5(file);
3703
+ let body = await readFile6(file);
2619
3704
  const { type, encoding } = contentType(file, body);
2620
3705
  if (rel === options.entry && options.sdkSrc && !encoding)
2621
3706
  body = injectSdk(body.toString("utf8"), options.sdkSrc);
@@ -2638,7 +3723,7 @@ async function startDevServer(root, options) {
2638
3723
  };
2639
3724
  }
2640
3725
  async function dev(ctx, dirArg, portFlag) {
2641
- const dir = resolve(ctx.cwd, dirArg ?? ".");
3726
+ const dir = resolve2(ctx.cwd, dirArg ?? ".");
2642
3727
  const info = await stat2(dir).catch(() => null);
2643
3728
  if (!info?.isDirectory())
2644
3729
  throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
@@ -2689,10 +3774,10 @@ async function dev(ctx, dirArg, portFlag) {
2689
3774
 
2690
3775
  // src/publish.ts
2691
3776
  import { stat as stat3 } from "node:fs/promises";
2692
- import { resolve as resolve2 } from "node:path";
3777
+ import { resolve as resolve3 } from "node:path";
2693
3778
 
2694
3779
  // src/upload.ts
2695
- import { open as open3 } from "node:fs/promises";
3780
+ import { open as open2 } from "node:fs/promises";
2696
3781
  var PARALLEL = 6;
2697
3782
  var ATTEMPTS = 4;
2698
3783
  function slicesOf(files, pieceBytes = PIECE_BYTES) {
@@ -2711,7 +3796,7 @@ function slicesOf(files, pieceBytes = PIECE_BYTES) {
2711
3796
  return slices;
2712
3797
  }
2713
3798
  async function readSlice({ file, offset, length }) {
2714
- const handle = await open3(file.abs, "r");
3799
+ const handle = await open2(file.abs, "r");
2715
3800
  try {
2716
3801
  const body = Buffer.alloc(length);
2717
3802
  let read = 0;
@@ -2729,7 +3814,7 @@ async function readSlice({ file, offset, length }) {
2729
3814
  function retryable(status) {
2730
3815
  return status === null || status === 408 || status === 429 || status >= 500;
2731
3816
  }
2732
- async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes = PIECE_BYTES) {
3817
+ async function uploadMissing(ctx, base, targets, files, pieceBytes = PIECE_BYTES) {
2733
3818
  const slices = slicesOf(files, pieceBytes);
2734
3819
  let failure = null;
2735
3820
  await eachLimit(targets, PARALLEL, async (target) => {
@@ -2760,10 +3845,8 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
2760
3845
  });
2761
3846
  status = response.status;
2762
3847
  detail = (await response.text()).slice(0, 200);
2763
- if (response.ok) {
2764
- onProgress(slice.length);
3848
+ if (response.ok)
2765
3849
  return;
2766
- }
2767
3850
  } catch (e) {
2768
3851
  detail = e instanceof Error ? e.message : String(e);
2769
3852
  }
@@ -2778,7 +3861,6 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
2778
3861
  }
2779
3862
 
2780
3863
  // src/publish.ts
2781
- var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
2782
3864
  var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
2783
3865
  var POLL_MS = 3000;
2784
3866
  var WAIT_LIMIT_MS = 20 * 60 * 1000;
@@ -2796,7 +3878,7 @@ function fileEntry(f) {
2796
3878
  }
2797
3879
  var hashesOf = (f) => f.pieces ?? (f.sha256 ? [f.sha256] : []);
2798
3880
  async function folderOf(ctx, arg) {
2799
- const dir = resolve2(ctx.cwd, arg ?? ".");
3881
+ const dir = resolve3(ctx.cwd, arg ?? ".");
2800
3882
  const info = await stat3(dir).catch(() => null);
2801
3883
  if (!info)
2802
3884
  throw new CliError(`There is no folder at ${dir}.`, EXIT.usage, "usage");
@@ -2871,7 +3953,7 @@ async function publish(ctx, options) {
2871
3953
  if (guest)
2872
3954
  say(`By publishing you agree to Velven's Terms: ${terms}`);
2873
3955
  const total = files.reduce((sum, f) => sum + f.size, 0);
2874
- say(`Publishing ${plural3(files.length, "file")} (${formatSize(total)}) from ${dir}`);
3956
+ say(`Publishing ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
2875
3957
  await hashAll(files);
2876
3958
  const body = {
2877
3959
  fields: listingFields(config),
@@ -2909,15 +3991,12 @@ async function publish(ctx, options) {
2909
3991
  const slices = slicesOf(files);
2910
3992
  const bytes = [...wanted].reduce((sum, sha) => sum + (slices.get(sha)?.length ?? 0), 0);
2911
3993
  if (sending) {
2912
- say(`Uploading ${sending} of ${plural3(distinct.length, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
3994
+ say(`Uploading ${sending} of ${plural(distinct.length, "file")} (${formatSize(bytes)}); the rest are already on Velven.`);
2913
3995
  } else
2914
3996
  say("Every file is already on Velven; nothing to upload.");
2915
- await uploadMissing(ctx, base, created.uploads, files, () => {});
3997
+ await uploadMissing(ctx, base, created.uploads, files);
2916
3998
  const versionPath = `/api/v1/hosting/versions/${seg(created.version.id)}`;
2917
- const finished = await call(api, "POST", `${versionPath}/finish`, {
2918
- preview: !options.prod,
2919
- ...guest && claim ? { claim } : {}
2920
- }).catch(claimedMeanwhile(base, slug, created.version.id, "finish"));
3999
+ const finished = await call(api, "POST", `${versionPath}/finish`, { preview: !options.prod }).catch(claimedMeanwhile(base, slug, created.version.id, "finish"));
2921
4000
  const result = {
2922
4001
  ok: true,
2923
4002
  space: created.space,
@@ -3049,26 +4128,17 @@ async function signedIn(ctx) {
3049
4128
  return { base, creds: await requireToken(ctx, base) };
3050
4129
  }
3051
4130
  async function listVersions(api, slug) {
3052
- const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
3053
- return Array.isArray(answer) ? answer : answer.versions;
3054
- }
3055
- function when2(iso) {
3056
- if (!iso)
3057
- return "";
3058
- const d = new Date(iso);
3059
- return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
4131
+ return (await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`)).versions;
3060
4132
  }
3061
4133
  function printVersions(ctx, rows) {
3062
4134
  for (const v of rows) {
3063
- const live = v.live || v.status === "live" ? "*" : " ";
3064
- const size2 = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
3065
- const cols = [`${live} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when2(v.createdAt).padEnd(20), size2];
4135
+ const cols = [`${v.live ? "*" : " "} ${String(v.number).padStart(3)}`, v.status.padEnd(9), when(v.createdAt).padEnd(20), `${v.files} files, ${formatSize(v.size)}`];
3066
4136
  ctx.out(cols.join(" ").trimEnd());
3067
4137
  if (v.reason && v.status !== "live")
3068
4138
  ctx.out(` ${v.reason}`);
3069
4139
  }
3070
4140
  }
3071
- async function versions(ctx, spaceFlag, json) {
4141
+ async function versions2(ctx, spaceFlag, json) {
3072
4142
  const api = await signedIn(ctx);
3073
4143
  const slug = await spaceSlug(ctx, spaceFlag);
3074
4144
  const rows = await listVersions(api, slug);
@@ -3109,7 +4179,7 @@ async function rollback(ctx, arg, spaceFlag) {
3109
4179
  number = versionNumber(await ctx.ask("Which version should go live? "));
3110
4180
  }
3111
4181
  const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/rollback`, { version: number });
3112
- ctx.out(`Version ${answer.version?.number ?? number} of ${slug} is live again${answer.pageUrl ? `: ${answer.pageUrl}` : "."}`);
4182
+ ctx.out(`Version ${answer.version.number} of ${slug} is live again: ${answer.pageUrl}`);
3113
4183
  return EXIT.ok;
3114
4184
  }
3115
4185
  var RESET_CATEGORIES = ["scores", "saves", "achievements", "content", "rooms"];
@@ -3133,7 +4203,7 @@ async function reset(ctx, options) {
3133
4203
  if (player)
3134
4204
  body.player = player;
3135
4205
  const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/reset`, body);
3136
- const counts = answer.deleted ? Object.entries(answer.deleted).map(([k, n]) => `${n} ${k}`) : [];
4206
+ const counts = Object.entries(answer.deleted).map(([k, n]) => `${n} ${k}`);
3137
4207
  ctx.out(`Deleted ${what}${counts.length ? ` (${counts.join(", ")})` : ""}.`);
3138
4208
  return EXIT.ok;
3139
4209
  }
@@ -3206,7 +4276,7 @@ var COMMANDS = {
3206
4276
  },
3207
4277
  versions: {
3208
4278
  flags: { json: "boolean", space: "string" },
3209
- run: (ctx, p) => versions(ctx, stringFlag(p, "space"), p.flags.json === true)
4279
+ run: (ctx, p) => versions2(ctx, stringFlag(p, "space"), p.flags.json === true)
3210
4280
  },
3211
4281
  rollback: { flags: { space: "string" }, run: (ctx, p) => rollback(ctx, p.positionals[0], stringFlag(p, "space")) },
3212
4282
  reset: {