@velven/cli 0.4.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.
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.4.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,8 +74,9 @@ 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
- return answerOf(api, path, body === undefined ? undefined : { type: "application/json", data: JSON.stringify(body) }, method);
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
80
  }
81
81
  async function putBytes(url, bytes, type, tooLarge) {
82
82
  let response;
@@ -91,30 +91,44 @@ async function putBytes(url, bytes, type, tooLarge) {
91
91
  if (!response.ok)
92
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
93
  }
94
- async function answerOf(api, path, body, method) {
94
+ async function answerOf(api, path, body, method, retry) {
95
95
  const headers = requestHeaders(api, "application/json");
96
96
  if (body !== undefined)
97
97
  headers["content-type"] = body.type;
98
- let response;
99
- try {
100
- response = await fetch(`${api.base}${path}`, { method, headers, body: body?.data });
101
- } catch (e) {
102
- throw new CliError(`Could not reach Velven at ${api.base} (${causeOf(e)}).`, EXIT.failed, "network");
103
- }
104
- const text = await response.text();
105
- let parsed = null;
106
- if (text) {
98
+ for (let attempt = 0;; attempt++) {
99
+ const again = retry !== undefined && attempt === 0;
100
+ let response;
101
+ let text;
107
102
  try {
108
- parsed = JSON.parse(text);
109
- } catch {
110
- 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");
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;
111
127
  }
128
+ if (parsed === null)
129
+ throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
130
+ return parsed;
112
131
  }
113
- if (!response.ok)
114
- throw errorFromResponse(response.status, parsed, path, api.creds, response.headers.get("retry-after"));
115
- if (parsed === null)
116
- throw new CliError(`Velven answered ${path} with something other than JSON.`, EXIT.failed, "bad_response");
117
- return parsed;
118
132
  }
119
133
  function seg(value) {
120
134
  return encodeURIComponent(String(value));
@@ -132,7 +146,8 @@ async function stream(api, path, options, onEvent) {
132
146
  async function connect() {
133
147
  let got = false;
134
148
  const connectionKeys = new Set;
135
- 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;
136
151
  const headers = requestHeaders(api, "text/event-stream");
137
152
  if (lastId !== null)
138
153
  headers["last-event-id"] = lastId;
@@ -335,10 +350,9 @@ async function login(ctx) {
335
350
  ctx.out("Opened it in your browser. Waiting for you to approve...");
336
351
  else
337
352
  ctx.out("Open that link in a browser. Waiting for you to approve...");
338
- let interval = Math.max(1, started.interval || 5);
339
- const deadline = Date.now() + (started.expires_in || 900) * 1000;
353
+ const deadline = Date.now() + started.expires_in * 1000;
340
354
  while (Date.now() < deadline) {
341
- await ctx.sleep(interval * 1000);
355
+ await ctx.sleep(started.interval * 1000);
342
356
  try {
343
357
  const token = await call(api, "POST", "/api/agent/token", { device_code: started.device_code });
344
358
  await saveToken(ctx, base, { token: token.access_token, handle: token.handle });
@@ -351,10 +365,6 @@ async function login(ctx) {
351
365
  throw e;
352
366
  if (e.code === "authorization_pending")
353
367
  continue;
354
- if (e.code === "slow_down") {
355
- interval += 5;
356
- continue;
357
- }
358
368
  if (e.code === "access_denied")
359
369
  throw new CliError("The sign-in was denied. Nothing was saved.", EXIT.auth, "access_denied");
360
370
  if (e.code === "expired_token" || e.code === "invalid_grant")
@@ -488,12 +498,20 @@ function nodeContext() {
488
498
  }
489
499
 
490
500
  // src/create.ts
501
+ import { randomUUID } from "node:crypto";
491
502
  import { readFile as readFile3 } from "node:fs/promises";
492
503
  import { resolve } from "node:path";
493
504
 
494
505
  // src/create-state.ts
495
506
  import { chmod as chmod2, mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
496
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
+ }
497
515
  function createStatePath(ctx) {
498
516
  return join2(ctx.home, ".config", "velven", "create.json");
499
517
  }
@@ -513,9 +531,11 @@ async function readCreateFile(ctx) {
513
531
  }
514
532
  async function readCreateState(ctx, api) {
515
533
  const state = (await readCreateFile(ctx)).apis[api] ?? {};
534
+ const question = questionOf(state.question);
516
535
  return {
517
536
  ...typeof state.project === "string" && state.project ? { project: state.project } : {},
518
- ...Number.isInteger(state.turn) ? { turn: state.turn } : {}
537
+ ...Number.isInteger(state.turn) ? { turn: state.turn } : {},
538
+ ...question ? { question } : {}
519
539
  };
520
540
  }
521
541
  async function saveCreateState(ctx, api, patch) {
@@ -523,6 +543,8 @@ async function saveCreateState(ctx, api, patch) {
523
543
  const next = { ...file.apis[api], ...patch };
524
544
  if (next.turn === undefined)
525
545
  delete next.turn;
546
+ if (next.question === undefined)
547
+ delete next.question;
526
548
  file.apis[api] = next;
527
549
  const path = createStatePath(ctx);
528
550
  await mkdir2(dirname2(path), { recursive: true, mode: 448 });
@@ -531,13 +553,21 @@ async function saveCreateState(ctx, api, patch) {
531
553
  await chmod2(path, 384);
532
554
  }
533
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
+
534
565
  // src/create-timeline.ts
535
566
  var BAR_WIDTH = 60;
536
567
  var FOLD_OVER = 20;
537
568
  var LABEL_MAX = 44;
538
569
  var CHAIN_SLACK_MS = 50;
539
570
  var PHASE_ORDER = ["routing", "workspace", "planning", "coding", "building", "playtesting", "reviewing", "fixing", "saving", "previewing", "replying", "settle"];
540
- var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
541
571
  function seconds(ms) {
542
572
  return `${(ms / 1000).toFixed(1)} s`;
543
573
  }
@@ -741,7 +771,7 @@ function renderTimeline(turn, spans, options = {}) {
741
771
  return [`Turn ${turn.id}, a ${kind}, ${turn.status}: no timeline.${why}`];
742
772
  }
743
773
  const spanEnd = Math.max(...spans.map(endOf));
744
- const total = Math.max(spanEnd, turn.timing?.total_ms ?? 0);
774
+ const total = Math.max(spanEnd, turn.timing.total_ms ?? 0);
745
775
  const critical = criticalPath(spans);
746
776
  const { roots, children } = treeOf(spans);
747
777
  const rows = [];
@@ -784,7 +814,7 @@ function renderTimeline(turn, spans, options = {}) {
784
814
  visit(span, 0);
785
815
  const cut = (label) => label.length > LABEL_MAX ? `${label.slice(0, LABEL_MAX - 3)}...` : label;
786
816
  const width = Math.max(...rows.map((r) => cut(r.label).length), 4);
787
- const turnTotal = turn.timing?.total_ms ?? spanEnd;
817
+ const turnTotal = turn.timing.total_ms ?? spanEnd;
788
818
  const lines = [
789
819
  `Turn ${turn.id}, a ${kind}, ${turn.status}: ${duration(turnTotal)}, ${plural(spans.length, "span")}${firsts(turn.timing)}. * marks the critical path.`,
790
820
  `${"span".padEnd(width)} ${"0 s".padEnd(BAR_WIDTH / 2 + 1)}${seconds(total).padStart(BAR_WIDTH / 2 + 1)} ${"start".padStart(8)} ${"time".padStart(8)}`
@@ -815,9 +845,7 @@ function turnKind(turn, spans) {
815
845
  return `${turn.effort} ${work === "fast" || work === "full" ? `${work} ` : ""}${what}`;
816
846
  }
817
847
  function firsts(timing) {
818
- if (!timing || timing.first_dev_ms === undefined && timing.first_write_ms === undefined && timing.first_preview_ms === undefined)
819
- return "";
820
- const say = (ms) => typeof ms === "number" ? duration(ms) : "-";
848
+ const say = (ms) => ms === null ? "-" : duration(ms);
821
849
  return `; live link ${say(timing.first_dev_ms)}, first change ${say(timing.first_write_ms)}, first playable ${say(timing.first_preview_ms)}`;
822
850
  }
823
851
  var isEngineRun = (s) => s.kind === "step" && s.name === "engine_programmer";
@@ -850,11 +878,7 @@ function engineSummary(spans) {
850
878
  if (!run)
851
879
  return null;
852
880
  const a = run.attrs ?? {};
853
- const kids = spans.filter((s) => s.parent === run.id);
854
- const steps = num(a.steps) ?? kids.filter((s) => s.kind === "model").length;
855
- const tools = num(a.tools) ?? kids.filter((s) => s.kind === "tool").length;
856
- const images = num(a.images) ?? kids.filter((s) => s.kind === "tool" && s.name === "make_image").length;
857
- 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")];
858
882
  if (a.cut === true)
859
883
  figures.push("cut short");
860
884
  return `in ${duration(run.ms)}: ${figures.join(", ")}`;
@@ -878,19 +902,19 @@ function renderStats(groups, options) {
878
902
  const over = `the last ${plural(options.days, "day")}${options.origin ? `, from ${options.origin}` : ""}`;
879
903
  if (groups.length === 0)
880
904
  return [`No turns finished in ${over}.`];
881
- const per = options.microsPerCredit ?? null;
882
- 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));
883
907
  const cents = (micros) => micros === null ? "-" : (micros / 1e4).toFixed(1);
884
908
  const lines = [`Turns created in ${over}: p50 and p90 over the done turns.`];
885
909
  for (const g of groups) {
886
910
  lines.push("", `${groupTitle(g)}: ${plural(g.turns, "done turn")}, ${g.failed} failed`);
887
- const reacted = REACTION_WORDS.filter(([key]) => (g.reactions?.[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
911
+ const reacted = REACTION_WORDS.filter(([key]) => (g.reactions[key] ?? 0) > 0).map(([key, words]) => `${words} ${g.reactions[key]}`);
888
912
  if (reacted.length)
889
913
  lines.push(` the next message: ${reacted.join(", ")}`);
890
914
  const figures = [
891
915
  ["figure", "p50", "p90", "turns"],
892
916
  ["first live link", sec(g.first_dev_ms.p50), sec(g.first_dev_ms.p90), ""],
893
- ["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), ""],
894
918
  ["first playable", sec(g.first_preview_ms.p50), sec(g.first_preview_ms.p90), ""],
895
919
  ["whole turn", sec(g.total_ms.p50), sec(g.total_ms.p90), ""]
896
920
  ];
@@ -937,7 +961,9 @@ Usage:
937
961
  velven create send <text...> Send the project a message: prints the estimate, asks, then follows the turn.
938
962
  Velven picks how much to do (a quick change, a full build with a playtest,
939
963
  a plan; on a new game a question or two, then a short plan to shape) unless
940
- you pass --effort or a plan flag. Sent while a turn runs, it waits its turn
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)
941
967
  --project <id> A project other than the current one
942
968
  --effort quick|standard|deep How much the turn may do, instead of Velven's pick. The project keeps it
943
969
  for a later message that passes only a plan flag; a message with neither
@@ -947,15 +973,32 @@ Usage:
947
973
  a new standard or deep, off for quick)
948
974
  --build Build it: no more questions, Velven fills in what the plan leaves open
949
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
950
979
  --ref <file|ref-n> A reference picture (PNG, JPEG or WebP, up to 10 MB) sent with the message, or
951
980
  one the project keeps by its id (ref-3); up to 4
952
981
  --yes, -y Do not ask before sending
953
982
  --detach Send it and return at once; follow it later with watch
954
983
  velven create watch [turn] Follow a turn to its end (default: the last one)
955
984
  --from <n> Start from this event
956
- 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
957
999
  velven create list Your projects (* is the current one)
958
- 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)
959
1002
  --timeline The newest turn's timeline instead: a waterfall of every step, model and
960
1003
  tool call, the critical path marked (* at the line's end)
961
1004
  --turn <id> With --timeline: this turn instead of the project's newest
@@ -967,8 +1010,10 @@ Usage:
967
1010
  velven create refs rm <ref-n> Delete a picture, sent or not; the messages that carried it keep its number
968
1011
  velven create plan The project's short plan: what the game is, how it plays, its look
969
1012
  --project <id> A project other than the current one
970
- velven create queue The messages waiting behind the running turn, in the order they run
1013
+ velven create queue The messages waiting on the project, in the order they run
971
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)
972
1017
  velven create queue rm <id> Remove a waiting message
973
1018
  velven create credits Your credits and the latest entries
974
1019
  velven create usage What each of your turns held and used, newest first
@@ -978,14 +1023,14 @@ Usage:
978
1023
  --days <n> Turns created in the last n days, 1 to 90 (default 7)
979
1024
  --origin <origin> Only turns sent from web, api, cli, mcp or benchmark
980
1025
 
981
- --json: new, send, list, show, plan, queue, refs, credits, usage and stats print one JSON object (send prints the finished turn,
982
- show --timeline the timeline); watch prints one event per line.
983
- 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).
984
1029
 
985
1030
  Exit codes:
986
1031
  0 done (also a turn that stopped at its credit cap or time limit) 1 failed, cancelled or no longer followed
987
- 2 bad command 3 needs sign-in 4 refused (no credits, closed, busy, too many turns, effort unavailable,
988
- a picture refused, 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`;
989
1034
  var CREATE_FLAGS = {
990
1035
  engine: "string",
991
1036
  genre: "string",
@@ -994,10 +1039,13 @@ var CREATE_FLAGS = {
994
1039
  plan: "boolean",
995
1040
  "no-plan": "boolean",
996
1041
  build: "boolean",
1042
+ answer: "string",
1043
+ queue: "boolean",
997
1044
  ref: "strings",
998
1045
  yes: "boolean",
999
1046
  detach: "boolean",
1000
1047
  from: "string",
1048
+ "until-idle": "boolean",
1001
1049
  json: "boolean",
1002
1050
  timeline: "boolean",
1003
1051
  turn: "string",
@@ -1008,14 +1056,17 @@ var CREATE_FLAGS = {
1008
1056
  };
1009
1057
  var SUBCOMMANDS = {
1010
1058
  new: ["engine", "genre", "json"],
1011
- send: ["project", "effort", "plan", "no-plan", "build", "ref", "yes", "detach", "json"],
1012
- watch: ["from", "json"],
1059
+ send: ["project", "effort", "plan", "no-plan", "build", "answer", "queue", "ref", "yes", "detach", "json"],
1060
+ watch: ["from", "until-idle", "json"],
1013
1061
  cancel: [],
1062
+ resume: ["project", "json"],
1063
+ versions: ["project", "json"],
1064
+ restore: ["project", "json"],
1014
1065
  list: ["json"],
1015
1066
  show: ["json", "timeline", "turn", "all"],
1016
1067
  refs: ["project", "json"],
1017
1068
  plan: ["project", "json"],
1018
- queue: ["project", "json"],
1069
+ queue: ["project", "build", "json"],
1019
1070
  credits: ["json"],
1020
1071
  usage: ["project", "before", "json"],
1021
1072
  stats: ["days", "origin", "json"]
@@ -1025,6 +1076,14 @@ var REFS_PER_MESSAGE = 4;
1025
1076
  var REFS_PER_PROJECT = 16;
1026
1077
  var ORIGINS = ["web", "api", "cli", "mcp", "benchmark"];
1027
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
+ };
1028
1087
  async function create(ctx, parsed) {
1029
1088
  const [sub, ...args] = parsed.positionals;
1030
1089
  if (!sub) {
@@ -1048,9 +1107,15 @@ async function create(ctx, parsed) {
1048
1107
  case "send":
1049
1108
  return send(ctx, api, args, parsed, json);
1050
1109
  case "watch":
1051
- 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);
1052
1111
  case "cancel":
1053
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);
1054
1119
  case "list":
1055
1120
  return list(ctx, api, json);
1056
1121
  case "show":
@@ -1069,7 +1134,7 @@ async function create(ctx, parsed) {
1069
1134
  case "plan":
1070
1135
  return showPlan(ctx, api, args, stringFlag(parsed, "project"), json);
1071
1136
  case "queue":
1072
- return queue(ctx, api, args, stringFlag(parsed, "project"), json);
1137
+ return queue(ctx, api, args, stringFlag(parsed, "project"), parsed.flags.build === true, json);
1073
1138
  default:
1074
1139
  return credits(ctx, api, json);
1075
1140
  }
@@ -1097,8 +1162,9 @@ async function newProject(ctx, api, title, engine, genre, json) {
1097
1162
  return EXIT.ok;
1098
1163
  }
1099
1164
  async function send(ctx, api, words, parsed, json) {
1100
- const text = words.join(" ").trim();
1101
- if (!text)
1165
+ let text = words.join(" ").trim();
1166
+ const answerFlag = stringFlag(parsed, "answer");
1167
+ if (!text && answerFlag === undefined)
1102
1168
  throw new CliError('Say what to build or change, like velven create send "make the fox jump higher".', EXIT.usage, "usage");
1103
1169
  const effort = stringFlag(parsed, "effort");
1104
1170
  if (effort !== undefined && !EFFORTS.includes(effort)) {
@@ -1112,8 +1178,22 @@ async function send(ctx, api, words, parsed, json) {
1112
1178
  const refs = stringsFlag(parsed, "ref");
1113
1179
  if (refs.length > REFS_PER_MESSAGE)
1114
1180
  throw new CliError(`A message takes up to ${REFS_PER_MESSAGE} pictures; ${refs.length} were given with --ref.`, EXIT.usage, "usage");
1115
- const pictures = await Promise.all(refs.map((ref) => REF_ID.test(ref) ? ref : readPicture(ctx, ref)));
1116
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)));
1117
1197
  const say = json ? () => {} : (line) => ctx.out(line);
1118
1198
  const query = new URLSearchParams;
1119
1199
  if (routed)
@@ -1124,41 +1204,49 @@ async function send(ctx, api, words, parsed, json) {
1124
1204
  query.set("effort", effort);
1125
1205
  if (plan !== undefined)
1126
1206
  query.set("plan", String(plan));
1127
- query.set("queue", "true");
1128
1207
  const search = query.toString();
1129
1208
  const { estimate } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/estimate${search ? `?${search}` : ""}`);
1130
- if (routed) {
1131
- const what = estimate.shaping === true ? "a question or a short plan" : estimate.first_build === true ? "a first build (its pictures are made before the first preview)" : "a small change";
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";
1132
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.`);
1133
1212
  } else {
1134
- 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`;
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`;
1135
1214
  say(`A ${kind}: about ${estimate.credits.typical} credits, at most ${estimate.credits.cap}. You have ${estimate.available} available.`);
1136
1215
  }
1137
1216
  if (!estimate.enough) {
1138
1217
  throw new CliError(estimate.reason ?? "Velven cannot start this turn now.", EXIT.refused, "not_enough");
1139
1218
  }
1140
1219
  if (ctx.interactive && !json && parsed.flags.yes !== true) {
1141
- const answer = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
1142
- if (answer === "n" || answer === "no") {
1220
+ const reply = (await ctx.ask("Send it? (Y/n) ")).trim().toLowerCase();
1221
+ if (reply === "n" || reply === "no") {
1143
1222
  ctx.out("Nothing was sent.");
1144
1223
  return EXIT.failed;
1145
1224
  }
1146
1225
  }
1147
- const body = { text, queue: true };
1226
+ const body = { text, client_id: randomUUID() };
1227
+ if (parsed.flags.queue === true)
1228
+ body.delivery = "queue";
1148
1229
  if (effort !== undefined)
1149
1230
  body.effort = effort;
1150
1231
  if (plan !== undefined)
1151
1232
  body.plan = plan;
1152
1233
  if (parsed.flags.build === true)
1153
1234
  body.intent = "build";
1235
+ if (answer)
1236
+ body.answer = answer;
1154
1237
  if (pictures.length) {
1155
1238
  const ids = [];
1156
1239
  for (const picture of pictures)
1157
1240
  ids.push(typeof picture === "string" ? picture : await uploadPicture(api, project, picture));
1158
1241
  body.references = ids;
1159
- say(`Attached ${plural2(ids.length, "picture")}: ${ids.join(", ")}.`);
1160
- }
1161
- const sent = await call(api, "POST", `${ROOT}/projects/${seg(project)}/messages`, body);
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);
1162
1250
  let turn;
1163
1251
  let stream2;
1164
1252
  if ("queued" in sent) {
@@ -1167,9 +1255,9 @@ async function send(ctx, api, words, parsed, json) {
1167
1255
  throw new CliError(queued.refused ?? "Velven could not start this message.", EXIT.refused, "refused");
1168
1256
  if (queued.state === "waiting" || queued.turn === null) {
1169
1257
  if (json)
1170
- ctx.out(JSON.stringify({ ok: true, queued }));
1258
+ ctx.out(JSON.stringify({ ok: true, queued, why: sent.why, ...sent.again ? { again: true } : {} }));
1171
1259
  else
1172
- say(queuedLine(queued));
1260
+ say(`${already}${queuedLine(queued, sent.why)}`);
1173
1261
  return EXIT.ok;
1174
1262
  }
1175
1263
  turn = { id: queued.turn, ...queued.capped === true ? { queued: true } : {} };
@@ -1179,19 +1267,143 @@ async function send(ctx, api, words, parsed, json) {
1179
1267
  }
1180
1268
  await saveCreateState(ctx, api.base, { project, turn: turn.id });
1181
1269
  if (turn.queued)
1182
- 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.`);
1183
1271
  else
1184
- say(`Turn ${turn.id} is on its way.`);
1272
+ say(`${already}Turn ${turn.id} is on its way.`);
1185
1273
  if (parsed.flags.detach === true) {
1186
1274
  if (json)
1187
- ctx.out(JSON.stringify({ ok: true, turn }));
1275
+ ctx.out(JSON.stringify({ ok: true, turn, ...sent.again ? { again: true } : {} }));
1188
1276
  else
1189
1277
  say(`Follow it: velven create watch ${turn.id}; stop it: velven create cancel ${turn.id}`);
1190
1278
  return EXIT.ok;
1191
1279
  }
1192
- const path = stream2?.startsWith("/") ? stream2 : `${ROOT}/turns/${seg(turn.id)}/stream`;
1280
+ const path = stream2 ?? `${ROOT}/turns/${seg(turn.id)}/stream`;
1193
1281
  return follow(ctx, api, turn.id, path, undefined, json ? "final" : "text", project);
1194
1282
  }
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
+ }
1195
1407
  var REF_ID = /^ref-[1-9][0-9]{0,2}$/;
1196
1408
  async function uploadPicture(api, project, picture) {
1197
1409
  const { upload } = await call(api, "POST", `${ROOT}/projects/${seg(project)}/references/uploads`);
@@ -1222,21 +1434,27 @@ async function readPicture(ctx, file) {
1222
1434
  return { file, bytes, type: PICTURE_TYPES[ext] ?? "application/octet-stream" };
1223
1435
  }
1224
1436
  async function turnProject(ctx, api, turn) {
1225
- const { project: current } = await readCreateState(ctx, api.base);
1226
1437
  try {
1227
1438
  const { turn: view } = await call(api, "GET", `${ROOT}/turns/${seg(turn)}`);
1228
- return view.project ?? current ?? null;
1439
+ return view.project;
1229
1440
  } catch {
1230
- return current ?? null;
1441
+ return (await readCreateState(ctx, api.base)).project ?? null;
1231
1442
  }
1232
1443
  }
1233
- async function watch(ctx, api, arg, fromFlag, json) {
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");
1234
1448
  let from;
1235
1449
  if (fromFlag !== undefined) {
1236
1450
  if (!/^\d+$/.test(fromFlag))
1237
1451
  throw new CliError(`--from is an event number, like 12, not "${fromFlag}".`, EXIT.usage, "usage");
1238
1452
  from = Number(fromFlag);
1239
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");
1240
1458
  const turn = await turnId(ctx, api.base, arg, "watch");
1241
1459
  const named = json ? null : await turnProject(ctx, api, turn);
1242
1460
  let code;
@@ -1250,13 +1468,311 @@ async function watch(ctx, api, arg, fromFlag, json) {
1250
1468
  await saveCreateState(ctx, api.base, { turn });
1251
1469
  return code;
1252
1470
  }
1471
+ async function watchProject(ctx, api, project, after, untilIdle, json) {
1472
+ const controller = new AbortController;
1473
+ ctx.untilStopped().then(() => controller.abort());
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;
1479
+ let final;
1480
+ try {
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
+ }
1253
1769
  async function follow(ctx, api, turn, path, from, mode, project) {
1254
1770
  const controller = new AbortController;
1255
1771
  ctx.untilStopped().then(() => controller.abort());
1256
- const said = { held: null, engine: null, project, asked: false, next: [] };
1772
+ const said = { held: null, engine: null, project, asked: false, next: [], base: api.base, question: null, work: null };
1257
1773
  let final;
1258
1774
  try {
1259
- final = await stream(api, path, { from, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
1775
+ final = await stream(api, path, { ...from !== undefined ? { start: { from } } : {}, signal: controller.signal, sleep: ctx.sleep }, (event2) => {
1260
1776
  if (mode === "events")
1261
1777
  ctx.out(JSON.stringify(event2));
1262
1778
  else if (mode === "text")
@@ -1273,6 +1789,7 @@ async function follow(ctx, api, turn, path, from, mode, project) {
1273
1789
  } finally {
1274
1790
  controller.abort();
1275
1791
  release(ctx, said);
1792
+ await keepQuestion(ctx, api.base, turn, project, said.question);
1276
1793
  }
1277
1794
  if (final === null) {
1278
1795
  const message = `The turn goes on. Watch it: velven create watch ${turn}; stop it: velven create cancel ${turn}`;
@@ -1302,7 +1819,6 @@ async function follow(ctx, api, turn, path, from, mode, project) {
1302
1819
  }
1303
1820
  return code;
1304
1821
  }
1305
- var plural2 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
1306
1822
  function capital(text) {
1307
1823
  return text.charAt(0).toUpperCase() + text.slice(1);
1308
1824
  }
@@ -1314,8 +1830,8 @@ function lasting(at, expires) {
1314
1830
  if (!Number.isFinite(minutes) || minutes <= 0)
1315
1831
  return "";
1316
1832
  if (minutes < 90)
1317
- return plural2(minutes, "minute");
1318
- return plural2(Math.round(minutes / 60), "hour");
1833
+ return plural(minutes, "minute");
1834
+ return plural(Math.round(minutes / 60), "hour");
1319
1835
  }
1320
1836
  function jobDone(ctx, label, figures, summary) {
1321
1837
  if (figures)
@@ -1345,21 +1861,26 @@ function render(ctx, event, said) {
1345
1861
  release(ctx, said);
1346
1862
  switch (event.type) {
1347
1863
  case "turn.started":
1348
- if (event.routed === true)
1349
- ctx.err(`Started: Velven is choosing the work, ${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.`);
1350
1866
  else
1351
- ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held.`);
1867
+ ctx.err(`Started: ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held.`);
1352
1868
  return;
1353
1869
  case "route":
1870
+ said.work = event.work;
1354
1871
  ctx.out(event.said);
1355
- ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural2(event.credits_held, "credit")} held: ${event.reason})`);
1872
+ ctx.err(` (${event.work}, ${event.effort}${event.plan ? ", plan mode" : ""}, ${plural(event.credits_held, "credit")} held: ${event.reason})`);
1356
1873
  return;
1357
1874
  case "ask": {
1358
1875
  ctx.out(`${event.question} (question ${event.index} of ${event.of})`);
1359
1876
  for (const [i, choice] of event.choices.entries())
1360
1877
  ctx.out(` ${i + 1}. ${choice}`);
1361
1878
  const send2 = sendCommand(said);
1362
- ctx.out(`Answer: ${send2} "${event.choices[0]}", or in your own words.`);
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.`);
1363
1884
  if (event.picture || event.topic === "style")
1364
1885
  ctx.out(`Or show the look: ${send2} --ref <file> "like this".`);
1365
1886
  ctx.out(`Just build it: ${send2} --build "Just build it"`);
@@ -1371,7 +1892,8 @@ function render(ctx, event, said) {
1371
1892
  return;
1372
1893
  for (const line of planCard(event.plan))
1373
1894
  ctx.out(line);
1374
- ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
1895
+ if (said.work === "draft")
1896
+ ctx.out(`Build it: ${sendCommand(said)} --build "Build it"`);
1375
1897
  return;
1376
1898
  case "brief": {
1377
1899
  if (!event.done)
@@ -1411,8 +1933,9 @@ function render(ctx, event, said) {
1411
1933
  }
1412
1934
  case "tool":
1413
1935
  ctx.err(` ${event.name}: ${event.summary}${typeof event.ms === "number" ? ` (${duration(event.ms)})` : ""}`);
1414
- if (event.picture?.url)
1415
- ctx.out(`Painted ${event.picture.id} (${event.picture.width} by ${event.picture.height}) ${dim(ctx, event.picture.url)}`);
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
+ }
1416
1939
  return;
1417
1940
  case "text":
1418
1941
  ctx.out(event.text);
@@ -1431,10 +1954,10 @@ function render(ctx, event, said) {
1431
1954
  ctx.err("The live link is gone.");
1432
1955
  return;
1433
1956
  case "shot":
1434
- ctx.err(` screenshot ${event.label}: ${event.url}`);
1957
+ ctx.err(` screenshot ${event.label}: ${mediaLink(said.base, event.media)}`);
1435
1958
  return;
1436
1959
  case "clip":
1437
- ctx.out(`Clip: ${event.url}`);
1960
+ ctx.out(`Clip: ${mediaLink(said.base, event.media)}`);
1438
1961
  return;
1439
1962
  case "build":
1440
1963
  if (event.ok)
@@ -1446,10 +1969,10 @@ function render(ctx, event, said) {
1446
1969
  }
1447
1970
  return;
1448
1971
  case "playtest":
1449
- 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")}.`);
1450
1973
  return;
1451
1974
  case "review":
1452
- ctx.err(`Review: ${event.ok ? "no problems" : plural2(event.problems, "problem")}.`);
1975
+ ctx.err(`Review: ${event.ok ? "no problems" : plural(event.problems, "problem")}.`);
1453
1976
  return;
1454
1977
  case "commit":
1455
1978
  ctx.out(`Committed ${event.sha.slice(0, 7)}: ${event.message}`);
@@ -1464,11 +1987,14 @@ function render(ctx, event, said) {
1464
1987
  return;
1465
1988
  }
1466
1989
  case "cost":
1467
- 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.`);
1468
1991
  return;
1469
1992
  case "next":
1470
1993
  said.next = event.choices;
1471
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.");
1997
+ return;
1472
1998
  default:
1473
1999
  return;
1474
2000
  }
@@ -1494,20 +2020,22 @@ function finish(ctx, turn, event, said) {
1494
2020
  }
1495
2021
  if (event.preview)
1496
2022
  ctx.out(`Play it: ${event.preview.url}`);
1497
- const back = event.refunded > 0 ? `; ${plural2(event.refunded, "credit")} came back` : "";
2023
+ const back = event.refunded > 0 ? `; ${plural(event.refunded, "credit")} came back` : "";
1498
2024
  if (event.status === "done") {
1499
- 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}.`);
1500
2026
  if (event.stop_reason === "cap")
1501
2027
  ctx.out("It stopped at its credit cap. What it finished is saved: send another message to go on.");
1502
2028
  if (event.stop_reason === "time")
1503
2029
  ctx.out("It stopped at its time limit. What it finished is saved: send another message to go on.");
1504
2030
  } else if (event.status === "cancelled") {
1505
- ctx.err(`Turn ${turn} was cancelled: ${plural2(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
2031
+ ctx.err(`Turn ${turn} was cancelled: ${plural(event.credits_charged, "credit")} used${back}. What it finished is saved.`);
1506
2032
  } else {
1507
- ctx.err(`Turn ${turn} failed: ${event.error ?? "Velven could not finish it."} A failed turn costs nothing.`);
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.`);
1508
2034
  }
1509
2035
  if (reply?.includes(BUILD_AGAIN))
1510
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.`);
1511
2039
  if (event.next_turn) {
1512
2040
  const elsewhere = event.next_project && said.project && event.next_project !== said.project ? ` on project ${event.next_project}` : "";
1513
2041
  ctx.out(`Your next message${elsewhere} is running: velven create watch ${event.next_turn}`);
@@ -1517,6 +2045,9 @@ var BUILD_AGAIN = "Press Build it to try again.";
1517
2045
  function sendCommand(said) {
1518
2046
  return `velven create send${said.project ? ` --project ${said.project}` : ""}`;
1519
2047
  }
2048
+ function mediaLink(base, media) {
2049
+ return `${base}${ROOT}/media/${media.id}`;
2050
+ }
1520
2051
  function dim(ctx, text) {
1521
2052
  return ctx.interactive && !ctx.env.NO_COLOR ? `\x1B[2m${text}\x1B[22m` : text;
1522
2053
  }
@@ -1548,17 +2079,46 @@ function planCard(plan) {
1548
2079
  lines.push(plan.score ? "It keeps a score." : "No score.");
1549
2080
  return lines;
1550
2081
  }
1551
- function queuedLine(queued) {
1552
- const ahead = queued.position !== null && queued.position > 1 ? ` and ${plural2(queued.position - 1, "message")} ahead of it` : "";
1553
- return `Queued: it runs after the turn that is running${ahead}. velven create queue lists what waits.`;
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`;
1554
2102
  }
1555
2103
  async function cancel(ctx, api, arg) {
1556
2104
  const turn = await turnId(ctx, api.base, arg, "cancel");
1557
2105
  const answer = await call(api, "POST", `${ROOT}/turns/${seg(turn)}/cancel`, {});
1558
- if (answer.status === "cancelled")
2106
+ if (answer.status === "cancelled" && !answer.started)
1559
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.`);
1560
2110
  else
1561
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
+ }
1562
2122
  return EXIT.ok;
1563
2123
  }
1564
2124
  async function list(ctx, api, json) {
@@ -1590,11 +2150,14 @@ async function show(ctx, api, arg, json) {
1590
2150
  }
1591
2151
  ctx.out(`${project.title ?? "Untitled"} (${project.id})`);
1592
2152
  ctx.out(`Packs: ${packs(project)}`);
1593
- ctx.out(`Next turn: ${project.effort}, plan mode ${project.plan ? "on" : "off"}`);
1594
- 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"}`);
1595
2158
  const t = project.turn;
1596
2159
  if (t) {
1597
- 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`;
1598
2161
  const stop = t.stop_reason === "cap" ? ", stopped at its credit cap" : t.stop_reason === "time" ? ", stopped at its time limit" : "";
1599
2162
  ctx.out(`Newest turn: ${t.id}, ${t.status}${t.phase && t.status === "running" ? ` (${t.phase})` : ""}${charged}${stop}`);
1600
2163
  if (t.error)
@@ -1602,12 +2165,32 @@ async function show(ctx, api, arg, json) {
1602
2165
  } else {
1603
2166
  ctx.out("Newest turn: none yet");
1604
2167
  }
2168
+ if (summary)
2169
+ for (const line of summaryLines(summary))
2170
+ ctx.out(line);
1605
2171
  if (project.preview?.url)
1606
2172
  ctx.out(`Play it: ${project.preview.url}${project.preview.expires_at ? ` (the preview lasts until ${when(project.preview.expires_at)})` : ""}`);
1607
2173
  else
1608
2174
  ctx.out("Preview: none yet");
1609
2175
  return EXIT.ok;
1610
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
+ }
1611
2194
  async function showTimeline(ctx, api, arg, turnFlag, all, json) {
1612
2195
  let id;
1613
2196
  if (turnFlag !== undefined) {
@@ -1624,12 +2207,11 @@ async function showTimeline(ctx, api, arg, turnFlag, all, json) {
1624
2207
  id = view.turn.id;
1625
2208
  }
1626
2209
  const { turn } = await call(api, "GET", `${ROOT}/turns/${seg(id)}?timeline=1`);
1627
- const timeline = turn.timeline ?? { started_at: turn.started_at, spans: [] };
1628
2210
  if (json) {
1629
- ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline }));
2211
+ ctx.out(JSON.stringify({ ok: true, turn: turn.id, timeline: turn.timeline }));
1630
2212
  return EXIT.ok;
1631
2213
  }
1632
- for (const line of renderTimeline(turn, timeline.spans, { all }))
2214
+ for (const line of renderTimeline(turn, turn.timeline.spans, { all }))
1633
2215
  ctx.out(line);
1634
2216
  return EXIT.ok;
1635
2217
  }
@@ -1657,7 +2239,7 @@ async function stats(ctx, api, daysFlag, origin, json) {
1657
2239
  ctx.out(JSON.stringify({ ok: true, ...answer }));
1658
2240
  return EXIT.ok;
1659
2241
  }
1660
- 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 }))
1661
2243
  ctx.out(line);
1662
2244
  return EXIT.ok;
1663
2245
  }
@@ -1689,7 +2271,7 @@ async function referencePictures(ctx, api, args, projectFlag, json) {
1689
2271
  return EXIT.ok;
1690
2272
  }
1691
2273
  for (const r of references) {
1692
- const carried = r.messages !== undefined ? r.messages ? `on ${plural2(r.messages, "message")}` : "not sent yet" : r.attached ? "sent" : "not sent yet";
2274
+ const carried = r.messages ? `on ${plural(r.messages, "message")}` : "not sent yet";
1693
2275
  ctx.out(`${r.id.padEnd(7)} ${when(r.created_at).padEnd(20)} ${`${r.width} by ${r.height}`.padEnd(12)} ${carried}`);
1694
2276
  }
1695
2277
  ctx.out(`${references.length} of ${REFS_PER_PROJECT} kept. A picture no message carried goes a day after it was last uploaded.`);
@@ -1714,7 +2296,7 @@ async function usage(ctx, api, project, before, json) {
1714
2296
  return EXIT.ok;
1715
2297
  }
1716
2298
  for (const t of answer.turns) {
1717
- const used = t.credits.charged !== null ? `${plural2(t.credits.charged, "credit")} used` : `${plural2(t.credits.held, "credit")} held`;
2299
+ const used = t.credits.charged !== null ? `${plural(t.credits.charged, "credit")} used` : `${plural(t.credits.held, "credit")} held`;
1718
2300
  const kind = t.work ?? `${t.effort}${t.plan ? " plan" : ""}`;
1719
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());
1720
2302
  }
@@ -1742,42 +2324,164 @@ async function showPlan(ctx, api, args, projectFlag, json) {
1742
2324
  ctx.out(`Build it: velven create send${projectFlag ? ` --project ${id}` : ""} --build "Build it"`);
1743
2325
  return EXIT.ok;
1744
2326
  }
1745
- async function queue(ctx, api, args, projectFlag, json) {
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) {
1746
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");
1747
2336
  if (action === "rm") {
1748
- if (id === undefined || !/^\d+$/.test(id) || Number(id) === 0 || rest.length) {
1749
- throw new CliError("Name one waiting message by its number, like velven create queue rm 12.", EXIT.usage, "usage");
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");
1750
2369
  }
1751
2370
  const project2 = await currentProject(ctx, api.base, projectFlag);
1752
- await call(api, "DELETE", `${ROOT}/projects/${seg(project2)}/queue/${seg(id)}`);
2371
+ const view = await moveQueued(api, project2, n, Number(place));
2372
+ const moved = view.queued.find((q) => q.id === n);
1753
2373
  if (json)
1754
- ctx.out(JSON.stringify({ ok: true, removed: Number(id) }));
2374
+ ctx.out(JSON.stringify({ ok: true, ...view }));
1755
2375
  else
1756
- ctx.out(`Removed message ${id}: it will not run.`);
2376
+ ctx.out(`Moved message ${n}${moved?.position ? ` to ${ordinal(moved.position)} in line` : ""}.`);
1757
2377
  return EXIT.ok;
1758
2378
  }
1759
2379
  if (action !== undefined) {
1760
- throw new CliError(`Unknown command "create queue ${action}". List what waits with velven create queue, remove one with velven create queue rm 12.`, EXIT.usage, "usage");
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");
1761
2381
  }
1762
2382
  const project = await currentProject(ctx, api.base, projectFlag);
1763
- const { queued } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
2383
+ const { queued, paused } = await call(api, "GET", `${ROOT}/projects/${seg(project)}/queue`);
1764
2384
  if (json) {
1765
- ctx.out(JSON.stringify({ ok: true, queued }));
2385
+ ctx.out(JSON.stringify({ ok: true, queued, paused }));
1766
2386
  return EXIT.ok;
1767
2387
  }
1768
2388
  if (!queued.length) {
1769
2389
  ctx.out("Nothing is waiting on this project.");
1770
2390
  return EXIT.ok;
1771
2391
  }
2392
+ if (paused)
2393
+ ctx.out(pausedLine(paused));
1772
2394
  for (const q of queued) {
1773
2395
  const at = q.position !== null ? `${q.position}.` : q.state === "refused" ? "x" : "-";
1774
2396
  const words = q.text.length > 60 ? `${q.text.slice(0, 59)}…` : q.text;
1775
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)");
1776
2400
  if (q.state === "refused" && q.refused)
1777
2401
  ctx.out(` Not sent: ${q.refused}`);
1778
2402
  }
1779
- if (queued.some((q) => q.state === "waiting"))
1780
- ctx.out("They run in this order when the running turn ends. Remove one: velven create queue rm <number>");
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.");
1781
2485
  return EXIT.ok;
1782
2486
  }
1783
2487
  async function credits(ctx, api, json) {
@@ -1786,7 +2490,7 @@ async function credits(ctx, api, json) {
1786
2490
  ctx.out(JSON.stringify({ ok: true, credits: view }));
1787
2491
  return EXIT.ok;
1788
2492
  }
1789
- 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.`);
1790
2494
  for (const row of view.ledger.slice(0, 10)) {
1791
2495
  const amount = row.amount > 0 ? `+${row.amount}` : String(row.amount);
1792
2496
  ctx.out(` ${when(row.created_at).padEnd(20)} ${amount.padStart(6)} ${row.kind}${row.turn_id !== null ? ` (turn ${row.turn_id})` : ""}`);
@@ -1797,12 +2501,6 @@ function packs(project) {
1797
2501
  const engine = `${project.engine.id} ${project.engine.version}`;
1798
2502
  return project.genre ? `${engine}, ${project.genre.id} ${project.genre.version}` : engine;
1799
2503
  }
1800
- function when(iso) {
1801
- if (!iso)
1802
- return "";
1803
- const d = new Date(iso);
1804
- return Number.isNaN(d.getTime()) ? iso : d.toISOString().slice(0, 16).replace("T", " ") + " UTC";
1805
- }
1806
2504
  async function currentProject(ctx, base, given) {
1807
2505
  if (given)
1808
2506
  return given;
@@ -1825,7 +2523,7 @@ async function turnId(ctx, base, arg, verb) {
1825
2523
 
1826
2524
  // src/dev.ts
1827
2525
  import { createReadStream as createReadStream2 } from "node:fs";
1828
- import { open as open2, readdir as readdir3, readFile as readFile6, 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";
1829
2527
  import { createServer } from "node:http";
1830
2528
  import { extname, join as join5, relative as relative2, resolve as resolve2, sep } from "node:path";
1831
2529
  import { createBrotliDecompress, createGunzip } from "node:zlib";
@@ -2702,11 +3400,11 @@ function checkLimits(files, { maxBytes, maxFiles } = ACCOUNT_LIMITS) {
2702
3400
  throw new CliError(`The folder is ${formatSize(total)}; a version holds at most ${formatSize(maxBytes)}. The largest files: ${largest}.`, EXIT.refused, "too_large");
2703
3401
  }
2704
3402
  }
2705
- async function readHead(abs) {
3403
+ async function readStart(abs, length) {
2706
3404
  const handle = await open(abs, "r");
2707
3405
  try {
2708
- const head = new Uint8Array(HEAD_BYTES);
2709
- 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);
2710
3408
  return head.subarray(0, bytesRead);
2711
3409
  } finally {
2712
3410
  await handle.close();
@@ -2716,7 +3414,7 @@ var SCAN_WINDOW = 8 * MB;
2716
3414
  var SCAN_OVERLAP = 64 * 1024;
2717
3415
  async function fileFindings(file) {
2718
3416
  if (!scansContent(file.path) || isCompressed(file.path))
2719
- return refuseFindings(file.path, await readHead(file.abs));
3417
+ return refuseFindings(file.path, await readStart(file.abs, HEAD_BYTES));
2720
3418
  const encoding = extensionOf(file.path) === "wasm" ? "latin1" : "utf8";
2721
3419
  const handle = await open(file.abs, "r");
2722
3420
  try {
@@ -2761,7 +3459,7 @@ async function checkMedia(config, files) {
2761
3459
  continue;
2762
3460
  const path = mediaPath(raw);
2763
3461
  const file = files.find((f) => f.path === path);
2764
- const bytes = !file ? undefined : file.size <= MEDIA_RULES[kind].maxBytes ? new Uint8Array(await readFile5(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);
2765
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");
2766
3464
  if (problem)
2767
3465
  problems.push(problem);
@@ -2879,16 +3577,6 @@ function contentType(path, head) {
2879
3577
  }
2880
3578
  var WASM_HEAD_BYTES = 1024 * 1024;
2881
3579
  var ISOLATION_HEADER = { "Document-Isolation-Policy": "isolate-and-credentialless" };
2882
- async function readStart(abs, length) {
2883
- const handle = await open2(abs, "r");
2884
- try {
2885
- const head = new Uint8Array(length);
2886
- const { bytesRead } = await handle.read(head, 0, length, 0);
2887
- return head.subarray(0, bytesRead);
2888
- } finally {
2889
- await handle.close();
2890
- }
2891
- }
2892
3580
  function inflatedStart(abs, encoding) {
2893
3581
  return new Promise((resolveHead) => {
2894
3582
  const source = createReadStream2(abs);
@@ -3089,7 +3777,7 @@ import { stat as stat3 } from "node:fs/promises";
3089
3777
  import { resolve as resolve3 } from "node:path";
3090
3778
 
3091
3779
  // src/upload.ts
3092
- import { open as open3 } from "node:fs/promises";
3780
+ import { open as open2 } from "node:fs/promises";
3093
3781
  var PARALLEL = 6;
3094
3782
  var ATTEMPTS = 4;
3095
3783
  function slicesOf(files, pieceBytes = PIECE_BYTES) {
@@ -3108,7 +3796,7 @@ function slicesOf(files, pieceBytes = PIECE_BYTES) {
3108
3796
  return slices;
3109
3797
  }
3110
3798
  async function readSlice({ file, offset, length }) {
3111
- const handle = await open3(file.abs, "r");
3799
+ const handle = await open2(file.abs, "r");
3112
3800
  try {
3113
3801
  const body = Buffer.alloc(length);
3114
3802
  let read = 0;
@@ -3126,7 +3814,7 @@ async function readSlice({ file, offset, length }) {
3126
3814
  function retryable(status) {
3127
3815
  return status === null || status === 408 || status === 429 || status >= 500;
3128
3816
  }
3129
- async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes = PIECE_BYTES) {
3817
+ async function uploadMissing(ctx, base, targets, files, pieceBytes = PIECE_BYTES) {
3130
3818
  const slices = slicesOf(files, pieceBytes);
3131
3819
  let failure = null;
3132
3820
  await eachLimit(targets, PARALLEL, async (target) => {
@@ -3157,10 +3845,8 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
3157
3845
  });
3158
3846
  status = response.status;
3159
3847
  detail = (await response.text()).slice(0, 200);
3160
- if (response.ok) {
3161
- onProgress(slice.length);
3848
+ if (response.ok)
3162
3849
  return;
3163
- }
3164
3850
  } catch (e) {
3165
3851
  detail = e instanceof Error ? e.message : String(e);
3166
3852
  }
@@ -3175,7 +3861,6 @@ async function uploadMissing(ctx, base, targets, files, onProgress, pieceBytes =
3175
3861
  }
3176
3862
 
3177
3863
  // src/publish.ts
3178
- var plural3 = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
3179
3864
  var DONE = ["live", "refused", "unjudged", "replaced", "preview"];
3180
3865
  var POLL_MS = 3000;
3181
3866
  var WAIT_LIMIT_MS = 20 * 60 * 1000;
@@ -3268,7 +3953,7 @@ async function publish(ctx, options) {
3268
3953
  if (guest)
3269
3954
  say(`By publishing you agree to Velven's Terms: ${terms}`);
3270
3955
  const total = files.reduce((sum, f) => sum + f.size, 0);
3271
- say(`Publishing ${plural3(files.length, "file")} (${formatSize(total)}) from ${dir}`);
3956
+ say(`Publishing ${plural(files.length, "file")} (${formatSize(total)}) from ${dir}`);
3272
3957
  await hashAll(files);
3273
3958
  const body = {
3274
3959
  fields: listingFields(config),
@@ -3306,15 +3991,12 @@ async function publish(ctx, options) {
3306
3991
  const slices = slicesOf(files);
3307
3992
  const bytes = [...wanted].reduce((sum, sha) => sum + (slices.get(sha)?.length ?? 0), 0);
3308
3993
  if (sending) {
3309
- 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.`);
3310
3995
  } else
3311
3996
  say("Every file is already on Velven; nothing to upload.");
3312
- await uploadMissing(ctx, base, created.uploads, files, () => {});
3997
+ await uploadMissing(ctx, base, created.uploads, files);
3313
3998
  const versionPath = `/api/v1/hosting/versions/${seg(created.version.id)}`;
3314
- const finished = await call(api, "POST", `${versionPath}/finish`, {
3315
- preview: !options.prod,
3316
- ...guest && claim ? { claim } : {}
3317
- }).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"));
3318
4000
  const result = {
3319
4001
  ok: true,
3320
4002
  space: created.space,
@@ -3446,26 +4128,17 @@ async function signedIn(ctx) {
3446
4128
  return { base, creds: await requireToken(ctx, base) };
3447
4129
  }
3448
4130
  async function listVersions(api, slug) {
3449
- const answer = await call(api, "GET", `/api/v1/hosting/spaces/${seg(slug)}/versions`);
3450
- return Array.isArray(answer) ? answer : answer.versions;
3451
- }
3452
- function when2(iso) {
3453
- if (!iso)
3454
- return "";
3455
- const d = new Date(iso);
3456
- 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;
3457
4132
  }
3458
4133
  function printVersions(ctx, rows) {
3459
4134
  for (const v of rows) {
3460
- const live = v.live || v.status === "live" ? "*" : " ";
3461
- const size2 = v.files !== undefined ? `${v.files} files${v.size !== undefined ? `, ${formatSize(v.size)}` : ""}` : "";
3462
- 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)}`];
3463
4136
  ctx.out(cols.join(" ").trimEnd());
3464
4137
  if (v.reason && v.status !== "live")
3465
4138
  ctx.out(` ${v.reason}`);
3466
4139
  }
3467
4140
  }
3468
- async function versions(ctx, spaceFlag, json) {
4141
+ async function versions2(ctx, spaceFlag, json) {
3469
4142
  const api = await signedIn(ctx);
3470
4143
  const slug = await spaceSlug(ctx, spaceFlag);
3471
4144
  const rows = await listVersions(api, slug);
@@ -3506,7 +4179,7 @@ async function rollback(ctx, arg, spaceFlag) {
3506
4179
  number = versionNumber(await ctx.ask("Which version should go live? "));
3507
4180
  }
3508
4181
  const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/rollback`, { version: number });
3509
- 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}`);
3510
4183
  return EXIT.ok;
3511
4184
  }
3512
4185
  var RESET_CATEGORIES = ["scores", "saves", "achievements", "content", "rooms"];
@@ -3530,7 +4203,7 @@ async function reset(ctx, options) {
3530
4203
  if (player)
3531
4204
  body.player = player;
3532
4205
  const answer = await call(api, "POST", `/api/v1/hosting/spaces/${seg(slug)}/reset`, body);
3533
- 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}`);
3534
4207
  ctx.out(`Deleted ${what}${counts.length ? ` (${counts.join(", ")})` : ""}.`);
3535
4208
  return EXIT.ok;
3536
4209
  }
@@ -3603,7 +4276,7 @@ var COMMANDS = {
3603
4276
  },
3604
4277
  versions: {
3605
4278
  flags: { json: "boolean", space: "string" },
3606
- 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)
3607
4280
  },
3608
4281
  rollback: { flags: { space: "string" }, run: (ctx, p) => rollback(ctx, p.positionals[0], stringFlag(p, "space")) },
3609
4282
  reset: {