@stringsync/splates 0.0.54 → 0.0.55

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/index.js CHANGED
@@ -11,10 +11,44 @@ import {
11
11
  import { assets } from "@stringsync/splates-server/server";
12
12
  import { NodeFs } from "webappwiz/system";
13
13
 
14
+ // handoff.ts
15
+ import { existsSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ var devMounted = { value: false };
18
+ var CHECKOUT_BIN = join("bin", "splates");
19
+ var CHECKOUT_ENTRY = join("packages", "splates", "checkout.ts");
20
+ function checkoutAbove(cwd) {
21
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
22
+ if (existsSync(join(dir, CHECKOUT_BIN)) && existsSync(join(dir, CHECKOUT_ENTRY))) {
23
+ return dir;
24
+ }
25
+ if (dirname(dir) === dir) {
26
+ return null;
27
+ }
28
+ }
29
+ }
30
+ async function handOffDev(argv, cwd) {
31
+ if (devMounted.value || argv[0] !== "dev") {
32
+ return null;
33
+ }
34
+ const checkout = checkoutAbove(cwd);
35
+ if (checkout === null) {
36
+ console.error("splates dev is only in a splates checkout, and this directory is in none: cd into one and run its bin/splates.");
37
+ return 1;
38
+ }
39
+ const proc = Bun.spawn([join(checkout, CHECKOUT_BIN), ...argv], {
40
+ cwd,
41
+ stdin: "inherit",
42
+ stdout: "inherit",
43
+ stderr: "inherit"
44
+ });
45
+ return await proc.exited;
46
+ }
47
+
14
48
  // splates.ts
15
49
  import { agents } from "@stringsync/splates-server/agents";
16
50
  import { attach } from "@stringsync/splates-server/artifacts";
17
- import { commaList as commaList6 } from "@stringsync/splates-server/comma-list";
51
+ import { commaList as commaList5 } from "@stringsync/splates-server/comma-list";
18
52
  import { refuseGroomer as refuseGroomer4 } from "@stringsync/splates-server/conversations";
19
53
  import { exits, fail as fail9 } from "@stringsync/splates-server/exit";
20
54
  import { DEFAULT_COUNT, log as showLog } from "@stringsync/splates-server/log";
@@ -52,12 +86,12 @@ import {
52
86
  untilAfter as untilAfter3,
53
87
  wait
54
88
  } from "@stringsync/splates-server/tasks";
55
- import { cli as cli15 } from "webappwiz/cmd";
89
+ import { cli as cli16 } from "webappwiz/cmd";
56
90
  import { Duration as Duration3 } from "webappwiz/time";
57
- import { z as z16 } from "zod";
91
+ import { z as z17 } from "zod";
58
92
 
59
93
  // artifact.ts
60
- import { basename, resolve } from "node:path";
94
+ import { basename, resolve as resolve2 } from "node:path";
61
95
  import {
62
96
  addArtifact,
63
97
  listArtifacts,
@@ -186,7 +220,7 @@ splatesArtifact.command("add").use(repository({ needs: "own" })).description("ke
186
220
  }
187
221
  const id = await conversationOf(ctx.service, opts.conversation);
188
222
  const files = await Promise.all(paths.map(async (given) => {
189
- const path = resolve(ctx.ps.cwd(), given);
223
+ const path = resolve2(ctx.ps.cwd(), given);
190
224
  const bytes = await ctx.fs.readBytes(path).catch(() => null);
191
225
  if (bytes === null) {
192
226
  fail2("usage", `cannot read ${path}: nothing was changed`, {
@@ -222,14 +256,15 @@ import { cli as cli2 } from "webappwiz/cmd";
222
256
  import { z as z3 } from "zod";
223
257
  var DEV = {
224
258
  default: "",
225
- description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
259
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV. With none answering, this machine's own budget file is read and changed"
226
260
  };
227
261
  var JSON_OPTION = { default: false, description: "emit JSON" };
228
262
  var LIMIT = z3.enum(["hour", "day", "month"]);
263
+ var SET_LIMIT = z3.enum(["hour", "day", "month", "runners"]);
229
264
  var LIMIT_ARG = {
230
265
  description: "hour, the rate cap on what made workers running at once cost an hour; day; or month"
231
266
  };
232
- var splatesBudget = cli2("budget").description("what the workers a `workerProvider` makes may cost, in dollars: a rate cap per hour on those running at once ($0.25/h by default), and limits on a day ($5) and a month ($75). Past the rate cap, or once a day's or month's limit is spent, no worker is made and the queue says why; reaching the day's or month's stops made workers, whose agents stop at their checkpoint. Each warns at 80%, 90% and 100% by Web Push and on the page's header. Spend is an estimate at each type's list price. Agents' own API spend is not counted here");
267
+ var splatesBudget = cli2("budget").description("what the workers a `workerProvider` makes may cost, in dollars: a rate cap per hour on those running at once ($1/h by default), and limits on a day ($15) and a month ($200). Past the rate cap, or once a day's or month's limit is spent, no worker is made and the queue says why; reaching the day's or month's stops made workers, whose agents stop at their checkpoint. Each warns at 80%, 90% and 100% by Web Push and on the page's header. No more than 16 runners are up at once by default, those the server made and `splates run` made together, counted from the cloud's own list; past that the queue waits for one to free. Spend is an estimate at each type's list price. Agents' own API spend is not counted here");
233
268
  splatesBudget.command("show").description("show each limit: what made workers spent in its window, of how much, and what is left").option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => showBudget({
234
269
  dev: opts.dev,
235
270
  json: opts.json,
@@ -238,8 +273,10 @@ splatesBudget.command("show").description("show each limit: what made workers sp
238
273
  ps: ctx.ps,
239
274
  send: ctx.fetch
240
275
  }));
241
- splatesBudget.command("set").description("set a limit, in dollars (dollars an hour for hour), then show the budget").arg("limit", LIMIT, LIMIT_ARG).arg("dollars", z3.string(), {
242
- description: "like 5 or 0.25, or `default` to give it back to its default"
276
+ splatesBudget.command("set").description("set a limit, in dollars (dollars an hour for hour, a count for runners), then show the budget").arg("limit", SET_LIMIT, {
277
+ description: `${LIMIT_ARG.description}; or runners, the most runners up at once in the account, whoever made them (16 by default)`
278
+ }).arg("dollars", z3.string(), {
279
+ description: "like 5 or 0.25 (a whole number for runners, like 16), or `default` to give it back to its default"
243
280
  }).option("dev", z3.string(), DEV).option("json", z3.boolean(), JSON_OPTION).action((opts, ctx) => setBudget({
244
281
  limit: opts.limit,
245
282
  dollars: opts.dollars,
@@ -296,7 +333,7 @@ splatesCreds.command("request").use(repository2({ needs: "own" })).description("
296
333
  default: false,
297
334
  description: "keep it for every project on this device, as for a person's own account, rather than this project alone"
298
335
  }).option("provider", z4.optional(z4.enum(PROVIDERS)), {
299
- description: "who keeps it: `system`, the machine's own store, or `ssm`, AWS Parameter Store, where that is offered"
336
+ description: "who keeps it: `system`, the machine's own store"
300
337
  }).option("replace", z4.boolean(), {
301
338
  default: false,
302
339
  description: "ask again although one is kept, since the kept one does not work; --purpose and --instructions left off are those of the last request"
@@ -670,262 +707,256 @@ splatesQueue.command("move").use(repository6({ needs: "tasks" })).description("m
670
707
  return queueMove(ctx, opts.id, opts.position);
671
708
  });
672
709
 
673
- // server.ts
674
- import {
675
- BoxShell,
676
- CoordinatorBox as CoordinatorBox2,
677
- coordinatorMoved,
678
- deployKey,
679
- machineMove,
680
- neededCreds
681
- } from "@stringsync/splates-server/coordinator";
710
+ // replica.ts
711
+ import { dirname as dirname2 } from "node:path";
712
+ import { loadConfig as loadConfig2 } from "@stringsync/splates-server/config";
682
713
  import { SYSTEM_STORES as SYSTEM_STORES2 } from "@stringsync/splates-server/creds";
714
+ import { fail as fail5 } from "@stringsync/splates-server/exit";
715
+ import { Projects } from "@stringsync/splates-server/projects";
716
+ import {
717
+ FORMAT,
718
+ ProjectHome,
719
+ repository as repository7
720
+ } from "@stringsync/splates-server/records";
721
+ import {
722
+ openReplica,
723
+ REPLICA_ACCESS_KEY,
724
+ REPLICA_SECRET_KEY,
725
+ Replicator,
726
+ restoreFromReplica,
727
+ statusLines
728
+ } from "@stringsync/splates-server/replica";
729
+ import { cli as cli8 } from "webappwiz/cmd";
730
+ import { Credentials } from "webappwiz/creds";
731
+ import { z as z9 } from "zod";
732
+ var splatesReplica = cli8("replica").description("the copy of this project's records `splates server` keeps a few seconds behind every change, in the bucket or folder `replica` names in splates.config.ts");
733
+ splatesReplica.command("status").use(repository7({ needs: "read" })).description("print where the replica is, when it last held every change, how many changes wait to reach it and since when, and why it is failing if it is; as of the server's last batch, so it reads the same with the server stopped").option("json", z9.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
734
+ const status = await Replicator.recorded(ctx.storage);
735
+ if (opts.json) {
736
+ ctx.log.info(JSON.stringify(status));
737
+ return;
738
+ }
739
+ ctx.log.info(statusLines(status, ctx.config.replica !== null, Date.now()).join(`
740
+ `));
741
+ });
742
+ splatesReplica.command("restore").description("with `splates server` stopped, rebuild this project's records from the replica `replica` names in splates.config.ts, adopting its origin so the server started on them replicates on into it: how a server moves to another machine or comes back after its box is lost. Stops when the project already has records here, unless --force leaves them aside as state.db.moved. The roster and Auto pick rules are not in the replica; set them up again. A bucket's REPLICA_ACCESS_KEY and REPLICA_SECRET_KEY must be filled on this machine first").option("force", z9.boolean(), {
743
+ default: false,
744
+ description: "restore over the records here, leaving them aside"
745
+ }).action(async (opts, ctx) => {
746
+ const root = await mainTree(ctx);
747
+ const projects = Projects.ofMachine(ctx);
748
+ const home = new ProjectHome(root, { ...ctx, projects });
749
+ const stores = ctx.secrets ?? SYSTEM_STORES2;
750
+ const credentials = new Credentials([
751
+ await stores.project(root),
752
+ stores.device
753
+ ]);
754
+ const config = await loadConfig2(root, { fs: ctx.fs });
755
+ if (config.replica === null) {
756
+ fail5("usage", "no replica: name one as `replica` in splates.config.ts first");
757
+ }
758
+ const replica = openReplica(config.replica, {
759
+ root,
760
+ fs: ctx.fs,
761
+ creds: {
762
+ credentials,
763
+ request: async () => {
764
+ throw new Error(`fill ${REPLICA_ACCESS_KEY} and ${REPLICA_SECRET_KEY} with \`wiz creds\` first`);
765
+ }
766
+ }
767
+ });
768
+ try {
769
+ const done = await restoreFromReplica(replica, {
770
+ target: home,
771
+ fs: ctx.fs,
772
+ format: FORMAT,
773
+ force: opts.force
774
+ });
775
+ ctx.log.info([
776
+ `restored ${done.values} record(s) and ${done.logs} log(s) from ${replica.name}, last caught up on ${done.host}`,
777
+ ...done.replaced === null ? [] : [`the records here went aside as ${done.replaced}`],
778
+ `project: ${root} (register its checkout with \`splates init\` if it is new on this machine)`,
779
+ "start `splates server` to serve it and replicate on"
780
+ ].join(`
781
+ `));
782
+ } catch (error) {
783
+ fail5("usage", error instanceof Error ? error.message : String(error));
784
+ }
785
+ });
786
+ splatesReplica.command("retire").use(repository7({ needs: "own" })).description("after the server moved elsewhere (stop it and check `splates replica status` says caught up first), leave this project's records aside as state.db.moved, so a splates run here finds no stale records and says where the server went").arg("to", z9.string(), {
787
+ description: "where the server went, as `droplet.example.com`"
788
+ }).action(async (opts, ctx) => {
789
+ const home = new ProjectHome(ctx.git.root, ctx);
790
+ ctx.dispose();
791
+ const aside = await home.moveAside(opts.to);
792
+ ctx.log.info(`records left aside as ${aside}; the server is at ${opts.to}`);
793
+ });
794
+ async function mainTree(ctx) {
795
+ const { exitCode, stdout } = await ctx.ps.spawnCapture([
796
+ "git",
797
+ "-C",
798
+ ctx.ps.cwd(),
799
+ "rev-parse",
800
+ "--path-format=absolute",
801
+ "--git-common-dir"
802
+ ]);
803
+ if (exitCode !== 0) {
804
+ fail5("usage", `not a git repository: ${ctx.ps.cwd()}`);
805
+ }
806
+ return dirname2(stdout.trim());
807
+ }
808
+
809
+ // runners.ts
810
+ import { join as join2 } from "node:path";
811
+ import { loadConfig as loadConfig3 } from "@stringsync/splates-server/config";
683
812
  import { fail as fail6 } from "@stringsync/splates-server/exit";
813
+ import { splatesHome as splatesHome2 } from "@stringsync/splates-server/models";
684
814
  import {
685
- DEFAULT_PORT,
686
- RUNNING,
687
- serveDev
815
+ REAPER_FILE,
816
+ withRunners
688
817
  } from "@stringsync/splates-server/server";
689
- import { LOCAL_SLOTS } from "@stringsync/splates-server/workers";
818
+ import {
819
+ DIRECT_IDLE,
820
+ NoRunner,
821
+ parseTimeout,
822
+ runnerServe,
823
+ sizeLabel
824
+ } from "@stringsync/splates-server/workers";
690
825
  import { cli as cli9 } from "webappwiz/cmd";
826
+ import { sleep } from "webappwiz/time";
691
827
  import { z as z10 } from "zod";
692
-
693
- // infra.ts
694
- import { resolve as resolve2 } from "node:path";
695
- import { commaList as commaList4 } from "@stringsync/splates-server/comma-list";
696
- import {
697
- ACCESS_ENVIRONMENT,
698
- accessEnvironmentExists,
699
- CoordinatorBox,
700
- pulumiOrg,
701
- writeInfraConfig
702
- } from "@stringsync/splates-server/coordinator";
703
- import { fail as fail5 } from "@stringsync/splates-server/exit";
704
- import { infraConfigure } from "@stringsync/splates-server/infra/configure";
705
- import { secretsEnvironmentUrl } from "@stringsync/splates-server/infra/secrets";
706
- import { Projects } from "@stringsync/splates-server/projects";
707
- import { repository as repository7 } from "@stringsync/splates-server/records";
708
- import { savedTunnelOf } from "@stringsync/splates-server/server";
709
- import { FsStorage as FsStorage2 } from "@stringsync/splates-server/storage";
710
- import { cli as cli8 } from "webappwiz/cmd";
711
- import { z as z9 } from "zod";
712
- var splatesInfra = cli8("infra").description("the Pulumi project that makes everything the server's box needs in AWS, Cloudflare and GitHub, which `splates server deploy` then installs splates on");
713
- splatesInfra.command("init").use(repository7({ needs: "own" })).description("write a Pulumi project in <dir>, its program a dozen lines using the components of @stringsync/splates-server/infra, and the stack's config from the flags; run again, it writes only what is not so already and keeps values you changed. It fills the stack's config from what splates keeps (the projects, the saved tunnel, the Cloudflare ids). The secrets never pass through splates: it makes the Pulumi ESC environment splates/secrets with a placeholder for each (the Cloudflare token, a GitHub token per owner, the Anthropic key), which the stack imports and the user replaces on the Pulumi site; `splates setup` has a step for each saying how to get it. When the Pulumi ESC environment `coordinator-access` makes (splates/aws) is there, the stack imports it. It records the directory in ~/.splates/coordinator/infra.json, where `splates server deploy` finds the stack. Its runtime is bun, which runs the program's TypeScript itself. Commit the project but not Pulumi.<stack>.yaml, which holds your own accounts and ids and which the .gitignore it writes keeps out of git; init writes it again from what splates keeps. Then `bun add @stringsync/splates-server @pulumi/pulumi @pulumi/aws @pulumi/awsx @pulumi/cloudflare @pulumi/github @pulumi/tls @pulumi/random` there and `pulumi up`").arg("dir", z9.string(), { description: "where the Pulumi project goes" }).option("stack", z9.string(), { default: "prod", description: "the stack" }).option("host", z9.string(), {
714
- default: "",
715
- description: "the public host name, as in splates.example.com; the saved tunnel's by default"
716
- }).option("email", z9.string(), {
828
+ var splatesRunners = cli9("runners").description(`the runners \`splates run\` makes for itself in the project's cloud (\`cloud\` in splates.config.ts) when \`splates server\` has none for it: each takes as many commands at once as fit in its CPUs and memory, another is made when none has room, within \`splates budget\`, and each is deleted once idle ${DIRECT_IDLE.mins} minutes`);
829
+ splatesRunners.command("list").description("list them: name, size and price, the address Fly's proxy reaches them at, and busy, idle, or the machine's state").option("json", z10.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
830
+ const runners = await opened(ctx, (each) => each.list());
831
+ if (opts.json) {
832
+ ctx.log.info(JSON.stringify(runners));
833
+ return;
834
+ }
835
+ ctx.log.info(runners.length === 0 ? "no runner is up" : runners.map(runnerLine).join(`
836
+ `));
837
+ });
838
+ splatesRunners.command("reap").description(`delete each runner idle ${DIRECT_IDLE.mins} minutes or more, every idle one once the budget's day or month is spent, and any that never booted; with --watch, again every so often for as long as any is up, which \`splates run\` leaves running behind it, one on this machine at a time, so none is left running unused`).option("watch", z10.string(), {
717
839
  default: "",
718
- description: "the email address Access lets in; the tunnel's Access application's by default"
719
- }).option("region", z9.string(), {
720
- default: "us-east-1",
721
- description: "the AWS region"
722
- }).option("projects", z9.string(), {
840
+ description: "how long to wait before each look, as 10m or 90s, looking again for as long as any runner is up"
841
+ }).option("idle", z10.string(), {
723
842
  default: "",
724
- description: "comma-separated slug=owner/repo, each a project the box pushes trunk of; every project registered here with a GitHub origin by default"
843
+ description: `how long a runner must have stood idle, as 10m or 0s; ${DIRECT_IDLE.mins}m by default`
725
844
  }).action(async (opts, ctx) => {
726
- const listed = commaList4(opts.projects).map((entry) => {
727
- const [slug, repository] = entry.split("=");
728
- if (!slug || !repository?.includes("/")) {
729
- fail5("usage", `--projects wants slug=owner/repo, not ${entry}`, {
730
- entry
731
- });
732
- }
733
- return { slug, repository };
734
- });
735
- const org = await pulumiOrg(ctx.ps);
736
- const home = new CoordinatorBox({ ps: ctx.ps, fs: ctx.fs }).home;
737
- const { written, todo } = await infraConfigure({
738
- dir: opts.dir,
739
- stack: opts.stack,
740
- host: opts.host,
741
- email: opts.email,
742
- region: opts.region,
743
- projects: listed.length === 0 ? null : listed,
744
- registered: async () => (await Projects.in(new FsStorage2(home, ctx.fs), ctx.fs).list()).filter((project) => !project.gone),
745
- creds: ctx.creds,
746
- saved: savedTunnelOf(ctx),
747
- ...org !== null && await accessEnvironmentExists(ctx.ps, org) ? { environment: ACCESS_ENVIRONMENT } : {},
748
- fs: ctx.fs,
749
- ps: ctx.ps,
750
- log: ctx.log,
751
- fetch: ctx.fetch
752
- });
753
- await writeInfraConfig(ctx.fs, home, {
754
- dir: resolve2(opts.dir),
755
- stack: opts.stack
756
- });
757
- ctx.log.info(written.length === 0 ? "already so: nothing written" : `wrote ${written.join(", ")}`);
758
- ctx.log.info(`Pulumi.${opts.stack}.yaml stays out of git: it holds your own accounts and ids, and init writes it again from what splates keeps`);
759
- for (const command of todo) {
760
- ctx.log.info(`still to run in ${opts.dir}: ${command}`);
845
+ const idle = opts.idle === "" ? DIRECT_IDLE : lengthOf(opts.idle);
846
+ if (opts.watch === "") {
847
+ const reaped = await opened(ctx, (each) => each.reap(idle));
848
+ ctx.log.info(reaped.length === 0 ? "nothing to reap" : reaped.map((line) => `deleted ${line}`).join(`
849
+ `));
850
+ return;
851
+ }
852
+ if (!await watching(ctx)) {
853
+ return;
761
854
  }
762
- if (org !== null) {
763
- ctx.log.info(`paste each secret over its placeholder at ${secretsEnvironmentUrl(org)}: \`splates setup\` has a step for each saying how to get it`);
855
+ const every = lengthOf(opts.watch);
856
+ for (;; ) {
857
+ await sleep(every);
858
+ const left = await opened(ctx, async (each) => {
859
+ await each.reap(idle);
860
+ return (await each.list()).length;
861
+ });
862
+ if (left === 0) {
863
+ return;
864
+ }
764
865
  }
765
866
  });
867
+ async function watching(ctx) {
868
+ const file = reaperFile(ctx);
869
+ const held = Number((await ctx.fs.read(file).catch(() => "")).trim());
870
+ if (Number.isInteger(held) && held > 0 && held !== process.pid && ctx.ps.alive(held)) {
871
+ return false;
872
+ }
873
+ await ctx.fs.write(file, String(process.pid));
874
+ return true;
875
+ }
876
+ async function opened(ctx, use) {
877
+ const { stdout, exitCode } = await ctx.ps.spawnCapture([
878
+ "git",
879
+ "rev-parse",
880
+ "--show-toplevel"
881
+ ]);
882
+ if (exitCode !== 0) {
883
+ fail6("usage", "run it in a splates project", {});
884
+ }
885
+ const { cloud } = await loadConfig3(stdout.trim(), { fs: ctx.fs });
886
+ if (cloud === null) {
887
+ fail6("no_runner", "this project names no `cloud` in splates.config.ts, so `splates run` makes no runners", {});
888
+ }
889
+ try {
890
+ return await withRunners(ctx, cloud, use);
891
+ } catch (error) {
892
+ if (error instanceof NoRunner) {
893
+ fail6("no_runner", error.message, {});
894
+ }
895
+ throw error;
896
+ }
897
+ }
898
+ function lengthOf(text) {
899
+ return parseTimeout(text) ?? fail6("usage", `${text} is no length of time: name one as 10m or 90s`, {});
900
+ }
901
+ function runnerLine(runner) {
902
+ const size = runner.size === null ? runner.type : sizeLabel(runner.size, ...runner.dollarsPerHour === null ? [] : [runner.dollarsPerHour]);
903
+ return `${runner.id} ${size} ${runner.address ?? "no address yet"} ${runner.state}`;
904
+ }
905
+ function reaperFile(ctx) {
906
+ return join2(splatesHome2(ctx.ps), REAPER_FILE);
907
+ }
908
+ splatesRunners.command("serve").description("serve only job channels at /api/jobs over authenticated WebSockets; for the runner image, with SPLATES_RUNNER_SECRET from its hosting environment").option("port", z10.coerce.number().int().min(0).max(65535), {
909
+ default: 8080,
910
+ description: "HTTP port behind the hosting proxy"
911
+ }).action(({ port }, { ps }) => runnerServe({ port, ps }));
766
912
 
767
913
  // server.ts
768
- var splatesServer = cli9("server").description("the splates server: run it on this machine, or deploy it to an always-on box in your own AWS account, which the Pulumi project `splates server infra init` writes makes, reached by SSH alone").fallback("run");
769
- splatesServer.command("run").description("serve every splates project on this machine as a web page, from wherever it is run: the page picks the project it shows, adds one by its path, running `splates init` there, and shows its queued and started tasks, where queued tasks can be added, updated, reordered or removed; it brings each project's records forward as it opens it. A server that crashes starts again after 2s, then 4s, 8s and 16s, and is left down after five crashes in a row that each came within a minute of its start").option("port", z10.coerce.number(), {
914
+ import {
915
+ DEFAULT_PORT,
916
+ serveDev
917
+ } from "@stringsync/splates-server/server";
918
+ import { cli as cli10 } from "webappwiz/cmd";
919
+ import { z as z11 } from "zod";
920
+ var splatesServer = cli10("server").description("the splates server: run it on this machine").fallback("run");
921
+ splatesServer.command("run").description("serve every splates project on this machine as a web page, from wherever it is run: the page picks the project it shows, adds one by its path, running `splates init` there, and shows its queued and started tasks, where queued tasks can be added, updated, reordered or removed; it brings each project's records forward as it opens it. A server that crashes starts again after 2s, then 4s, 8s and 16s, and is left down after five crashes in a row that each came within a minute of its start").option("port", z11.coerce.number(), {
770
922
  default: DEFAULT_PORT,
771
923
  description: "port to listen on, or the next open one above it"
772
- }).option("allow-hosts", z10.string(), {
924
+ }).option("allow-hosts", z11.string(), {
773
925
  default: "",
774
926
  description: "comma-separated host names the page may also be reached by, like a tunnel's; put Cloudflare Access in front of them with --access-team and --access-aud"
775
- }).option("access-team", z10.string(), {
927
+ }).option("access-team", z11.string(), {
776
928
  default: "",
777
929
  description: "the Cloudflare Access team in front of --allow-hosts, as in <team>.cloudflareaccess.com; with --access-aud, those hosts answer only to a verified Access token, and may then use conversations and terminals"
778
- }).option("access-aud", z10.string(), {
930
+ }).option("access-aud", z11.string(), {
779
931
  default: "",
780
932
  description: "the AUD tag of that Access application"
781
- }).option("tunnel", z10.string(), {
933
+ }).option("tunnel", z11.string(), {
782
934
  default: "",
783
935
  description: "the public host name of a tunnel to run, with cloudflared or the tunnel provider named by splates.config.ts in the repository it is run in, if any, which is allowed as a host too; refused without --access-team and --access-aud, so a tunnel never exposes the page unauthenticated. With none of the tunnel and Access flags, the tunnel `splates tunnel setup` saved runs, if any"
784
- }).option("tunnel-name", z10.string(), {
936
+ }).option("tunnel-name", z11.string(), {
785
937
  default: "",
786
938
  description: "the named tunnel cloudflared runs for --tunnel, by default the one its own config names; not with a tunnel provider of splates.config.ts"
787
- }).option("ports-host", z10.string(), {
939
+ }).option("ports-host", z11.string(), {
788
940
  default: "",
789
941
  description: "the wildcard base host to serve task ports under, saved for this machine so a bare `splates server` serves it too: each port a task's processes listen on opens at https://<task>-<port>.<base>, to whoever Cloudflare Access lets in, and is refused without Access; routing *.<base> here, its DNS and TLS, are yours. `none` forgets it, leaving each task port forwarded to a port on this machine's loopback alone"
790
- }).option("worker-slots", z10.coerce.number().int().min(1), {
791
- default: LOCAL_SLOTS,
792
- description: "how many agents that other agents started (with `splates add --agent`, or a lane's orchestrator handing out tasks) this machine's worker runs at once; past them, each new or resumed one waits in the queue, first come, first served, which `splates worker queue` shows and reorders. An agent you start runs at once on any worker up, taking no slot. Read as the server starts its worker, which a restart from the page leaves running"
793
- }).option("keepalive", z10.boolean(), {
942
+ }).option("keepalive", z11.boolean(), {
794
943
  default: false,
795
944
  description: "keep a machine left to serve awake while the server runs: on macOS with `caffeinate`, on power or idle, though closing a laptop's lid still sleeps it; on Linux with a systemd-inhibit lock on sleep and idle, which the server serves on without, saying so, where systemd is missing or refuses it. Refused on other platforms"
796
- }).option("slot", z10.boolean(), {
945
+ }).option("slot", z11.boolean(), {
797
946
  default: false,
798
947
  description: "serve this checkout's splates at https://<word>.<zone>, a slot of its own on the zone `splates tunnel setup` uses, behind the same Access policy, so several run side by side and a phone opens each by name: a task's worktree keeps its word, as fox, until the task merges or is removed, which stops its server; run again, it stops the tree's last server first. Refused in a splates repository's main tree, which every agent shares. A slot keeps its own projects in ~/.splates/slots/<word>, apart from yours, starting with a throwaway project there; not with the tunnel and Access flags"
799
948
  }).action((opts, ctx) => serveDev(ctx, opts));
800
- splatesServer.mount("infra", splatesInfra);
801
- splatesServer.command("deploy").description("deploy the server to the box its Pulumi stack made (`splates server infra init`, then `pulumi up`), over SSH, with splates of this release installed for the user splates. The SSH key and the host key come from the stack and the instance's console. It checks what the deploy needs, brings the box's splates to this release, and exits `blocked`, naming each, until the box's Parameter Store holds the creds the stack should have put there; then gives each project a clone there with its deploy key from Parameter Store. Then it asks every agent of this machine's `splates server` to checkpoint and stops it, carries the server's records (no creds: the box reads its own through its instance role) to the box, starts `splates server` there for good under systemd, and joins this machine to it as a worker, kept up by a LaunchAgent, where each task carries on in its worktree here. This machine's records go aside, kept, and its `splates` reaches the box from then on. Run again once the server runs on the box, it brings the box's splates to this release and restarts it there").option("dry-run", z10.boolean(), {
802
- default: false,
803
- description: "stop once the box is ready, before this machine's server stops or anything here changes; once the server runs on the box, change nothing"
804
- }).option("dev", z10.string(), {
805
- default: `http://localhost:${DEFAULT_PORT}`,
806
- description: "this machine's `splates server`, which the deploy stops"
807
- }).action(async (opts, ctx) => {
808
- const moved = await machineMove({
809
- ps: ctx.ps,
810
- fs: ctx.fs,
811
- log: ctx.log,
812
- version: RUNNING,
813
- dev: opts.dev,
814
- stores: ctx.secrets ?? SYSTEM_STORES2,
815
- ...ctx.fetch === undefined ? {} : { fetch: ctx.fetch }
816
- }).run({ dryRun: opts["dry-run"] });
817
- if (moved.status === "blocked") {
818
- fail6("blocked", [
819
- "The box's Parameter Store lacks these. Set each (the Pulumi stack makes them: `pulumi up`), then deploy again:",
820
- ...moved.needs.map((name) => ` ${name}`)
821
- ].join(`
822
- `), { needs: moved.needs });
823
- }
824
- if (moved.status === "deployed") {
825
- ctx.log.info(`the server runs on the box at ${moved.ipv4}: deploy again without --dry-run to bring it to ${moved.version}`);
826
- return;
827
- }
828
- if (moved.status === "updated") {
829
- ctx.log.info(`the server on the box at ${moved.ipv4} runs ${moved.version} now`);
830
- return;
831
- }
832
- if (moved.status === "ready") {
833
- ctx.log.info(`the box is ready to serve ${moved.projects.join(", ")} through ${moved.host}: deploy again without --dry-run`);
834
- return;
835
- }
836
- ctx.log.info([
837
- `the server runs on the box: https://${moved.host}`,
838
- `this machine is its worker ${ctx.ps.hostname}, kept up by ${moved.worker}`,
839
- `its own records are kept aside in ${moved.aside} and each project's .git/splates.moved`
840
- ].join(`
841
- `));
842
- });
843
- splatesServer.command("status").description("print where the server runs, here or deployed to its box, and the box as its cloud has it").option("json", z10.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
844
- const deployed = await coordinatorMoved({
845
- fs: ctx.fs,
846
- ps: ctx.ps,
847
- home: box(ctx).home
848
- });
849
- const standing = await box(ctx).status();
850
- if (opts.json) {
851
- ctx.log.info(JSON.stringify({ deployed, box: standing }));
852
- return;
853
- }
854
- ctx.log.info([
855
- `runs: ${deployed ? "on the box" : "on this machine"}`,
856
- ...standing === null ? [
857
- "box: none: `splates server infra init` writes the Pulumi project, `pulumi up` there makes it"
858
- ] : [
859
- `address: ${standing.ipv4}`,
860
- `instance: ${standing.instanceId} (role ${standing.roleName})`,
861
- `host: ${standing.host}`,
862
- "ssh: splates server ssh"
863
- ]
864
- ].join(`
865
- `));
866
- });
867
- splatesServer.command("ssh").description("open a root shell on the box over SSH, with the key its Pulumi stack made, or run the command given there").rest("command", z10.string(), {
868
- description: "what to run on the box; by default a shell"
869
- }).passThroughOptions().action(async (opts, ctx) => {
870
- const standing = await box(ctx).status();
871
- if (standing === null) {
872
- fail6("not_found", "no box: `splates server infra init` writes the Pulumi project, `pulumi up` there makes it");
873
- }
874
- const { exitCode } = await ctx.ps.spawn(shell(ctx, standing).login(opts.command));
875
- ctx.ps.exit(exitCode);
876
- });
877
- splatesServer.command("destroy").description("unmake the box and everything its Pulumi stack made (its role, snapshots, creds, tunnel and deploy keys), lifting the box's termination protection first: anything only on the box is lost. The stack and its config stay, so the setup's Make the box step, or `splates server deploy`, makes it again. Without --yes, says what it would destroy").option("yes", z10.boolean(), {
878
- default: false,
879
- description: "destroy without asking"
880
- }).action(async (opts, ctx) => {
881
- const infra = await box(ctx).infra();
882
- if (infra === null) {
883
- fail6("not_found", "no infra project here: nothing to destroy, since `splates server infra init` has not written one");
884
- }
885
- const standing = await box(ctx).status();
886
- const what = standing === null ? `what the stack in ${infra.dir} made` : `the box at ${standing.ipv4} (${standing.instanceId}) and everything the stack in ${infra.dir} made`;
887
- if (!opts.yes) {
888
- fail6("usage", `this destroys ${what}, and anything only on the box is lost: run again with --yes`, { dir: infra.dir, box: standing });
889
- }
890
- ctx.log.info(`Destroying ${what}, a few minutes…`);
891
- await box(ctx).destroy();
892
- ctx.log.info("Destroyed. The stack and its config stay for making it again.");
893
- });
894
- splatesServer.command("needs").description("kept for `splates server deploy`, which runs it on the box: print the name of each cred this box's store lacks of those the server needs, one per line").rest("roots", z10.string(), {
895
- description: "each project's clone on this machine"
896
- }).action(async (opts, ctx) => {
897
- const missing = await neededCreds(ctx.secrets ?? SYSTEM_STORES2, opts.roots);
898
- for (const name of missing) {
899
- ctx.log.info(name);
900
- }
901
- });
902
- splatesServer.command("deploy-key").description("kept for `splates server deploy`, which runs it on the box: print the deploy key of the project whose clone is <root>").arg("root", z10.string(), {
903
- description: "the project's clone on this machine"
904
- }).action(async (opts, ctx) => {
905
- const key = await deployKey(ctx.secrets ?? SYSTEM_STORES2, opts.root);
906
- if (key === undefined) {
907
- fail6("not_found", `no deploy key kept for ${opts.root}`);
908
- }
909
- ctx.log.info(key);
910
- });
911
- function box(ctx) {
912
- return new CoordinatorBox2({ ps: ctx.ps, fs: ctx.fs });
913
- }
914
- function shell(ctx, standing) {
915
- return new BoxShell({
916
- ps: ctx.ps,
917
- ipv4: standing.ipv4,
918
- key: standing.key,
919
- knownHosts: `${box(ctx).home}/coordinator/known_hosts`
920
- });
921
- }
922
949
 
923
950
  // setup.ts
924
- import { dirname } from "node:path";
951
+ import { dirname as dirname3 } from "node:path";
925
952
  import { SYSTEM_STORES as SYSTEM_STORES3 } from "@stringsync/splates-server/creds";
926
953
  import { fail as fail7 } from "@stringsync/splates-server/exit";
927
- import { splatesHome as splatesHome2 } from "@stringsync/splates-server/models";
954
+ import { splatesHome as splatesHome3 } from "@stringsync/splates-server/models";
928
955
  import { Projects as Projects2 } from "@stringsync/splates-server/projects";
956
+ import {
957
+ openRepository
958
+ } from "@stringsync/splates-server/records";
959
+ import { Replicator as Replicator2 } from "@stringsync/splates-server/replica";
929
960
  import {
930
961
  SETUP_AREAS,
931
962
  Setup,
@@ -990,7 +1021,7 @@ async function setupOf(deps) {
990
1021
  return new Setup({
991
1022
  fs,
992
1023
  ps,
993
- home: splatesHome2(ps),
1024
+ home: splatesHome3(ps),
994
1025
  device: secrets.device,
995
1026
  fetch,
996
1027
  workers: deps.ps.env("SPLATES_DEV") ? () => fetchWorkers({ dev: "", log: deps.log, ps: deps.ps }) : undefined,
@@ -1002,9 +1033,20 @@ async function setupOf(deps) {
1002
1033
  ps: deps.ps
1003
1034
  })
1004
1035
  } : undefined,
1005
- project: root === null ? undefined : setupProjectAt(root, { fs, ps })
1036
+ project: root === null ? undefined : setupProjectAt(root, { fs, ps }, {
1037
+ replica: () => withRecords(deps, root, ({ storage }) => Replicator2.recorded(storage)),
1038
+ kept: (name) => withRecords(deps, root, async ({ creds }) => await creds.credentials.get(name) !== undefined)
1039
+ })
1006
1040
  }, SETUP_AREAS);
1007
1041
  }
1042
+ async function withRecords(deps, root, read) {
1043
+ const records = await openRepository(deps, { at: root });
1044
+ try {
1045
+ return await read(records);
1046
+ } finally {
1047
+ records.dispose();
1048
+ }
1049
+ }
1008
1050
  async function repoRoot(ps) {
1009
1051
  const { exitCode, stdout } = await ps.spawnCapture([
1010
1052
  "git",
@@ -1014,7 +1056,7 @@ async function repoRoot(ps) {
1014
1056
  "--path-format=absolute",
1015
1057
  "--git-common-dir"
1016
1058
  ]);
1017
- return exitCode === 0 ? dirname(stdout.trim()) : null;
1059
+ return exitCode === 0 ? dirname3(stdout.trim()) : null;
1018
1060
  }
1019
1061
  var MARKS = {
1020
1062
  done: color2.green("✓"),
@@ -1091,10 +1133,10 @@ function printUndo(log, plan) {
1091
1133
 
1092
1134
  // slots.ts
1093
1135
  import { Slots } from "@stringsync/splates-server/ports";
1094
- import { cli as cli10 } from "webappwiz/cmd";
1095
- import { z as z11 } from "zod";
1096
- var splatesSlots = cli10("slots").description("the words `splates server --slot` hands out on this machine, each held by the tree that claimed it: a task's worktree holds its word until the task merges or is removed, which stops its server and gives the word back");
1097
- splatesSlots.command("list").description("print each claimed word, the tree holding it, its server's pid while one runs, and whether it is orphaned: its tree gone, or a splates repository's main tree, which every agent shares").option("json", z11.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
1136
+ import { cli as cli11 } from "webappwiz/cmd";
1137
+ import { z as z12 } from "zod";
1138
+ var splatesSlots = cli11("slots").description("the words `splates server --slot` hands out on this machine, each held by the tree that claimed it: a task's worktree holds its word until the task merges or is removed, which stops its server and gives the word back");
1139
+ splatesSlots.command("list").description("print each claimed word, the tree holding it, its server's pid while one runs, and whether it is orphaned: its tree gone, or a splates repository's main tree, which every agent shares").option("json", z12.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
1098
1140
  const held = await Slots.ofMachine(ctx).status();
1099
1141
  if (opts.json) {
1100
1142
  ctx.log.info(JSON.stringify(held.map(({ slot, tree, pid, orphaned }) => ({
@@ -1137,7 +1179,7 @@ import {
1137
1179
  tasksNamed as tasksNamed2,
1138
1180
  taskUpdate
1139
1181
  } from "@stringsync/splates-server/board";
1140
- import { commaList as commaList5 } from "@stringsync/splates-server/comma-list";
1182
+ import { commaList as commaList4 } from "@stringsync/splates-server/comma-list";
1141
1183
  import { refuseGroomer as refuseGroomer3 } from "@stringsync/splates-server/conversations";
1142
1184
  import { fail as fail8 } from "@stringsync/splates-server/exit";
1143
1185
  import { repository as repository8 } from "@stringsync/splates-server/records";
@@ -1146,10 +1188,10 @@ import {
1146
1188
  taskWait,
1147
1189
  untilAfter as untilAfter2
1148
1190
  } from "@stringsync/splates-server/tasks";
1149
- import { cli as cli11 } from "webappwiz/cmd";
1191
+ import { cli as cli12 } from "webappwiz/cmd";
1150
1192
  import { Duration as Duration2 } from "webappwiz/time";
1151
- import { z as z12 } from "zod";
1152
- var splatesTask = cli11("task").description("the numbered tasks: defer work for later instead of growing the task you are in, and `splates add <slug> --task <n>` starts one; `merge` recommends the next one when a task lands. The person puts tasks in `splates queue`, for workers to start");
1193
+ import { z as z13 } from "zod";
1194
+ var splatesTask = cli12("task").description("the numbered tasks: defer work for later instead of growing the task you are in, and `splates add <slug> --task <n>` starts one; `merge` recommends the next one when a task lands. The person puts tasks in `splates queue`, for workers to start");
1153
1195
  async function projectFrom(ctx) {
1154
1196
  if (ctx.elsewhere === null) {
1155
1197
  return;
@@ -1157,31 +1199,29 @@ async function projectFrom(ctx) {
1157
1199
  return (await ctx.projects.list()).find((each) => each.root === ctx.git.root)?.slug;
1158
1200
  }
1159
1201
  function adding(name, description, ready) {
1160
- splatesTask.command(name).use(repository8({ needs: "queue" })).description(description).arg("subject", z12.string(), {
1202
+ splatesTask.command(name).use(repository8({ needs: "queue" })).description(description).arg("subject", z13.string(), {
1161
1203
  description: "what is left to do, in a line"
1162
- }).arg("text", z12.string(), {
1204
+ }).arg("text", z13.string(), {
1163
1205
  default: "",
1164
1206
  description: "whatever more there is to say about it, in markdown"
1165
- }).option("slug", z12.string(), {
1207
+ }).option("slug", z13.string(), {
1166
1208
  description: "a short name for the task that takes it (lowercase-with-dashes), naming its branch, worktree and conversation: a few words for what it changes, not the subject cut short"
1167
1209
  }).option("position", position, {
1168
1210
  description: "where it goes in the list, 1 at the top, pushing those from there down (default: the bottom)"
1169
- }).option("file", z12.string(), {
1170
- default: "",
1171
- description: "files to attach, comma separated: copied under .git/splates/tasks/<n>/"
1172
- }).option("blocked-by", z12.string(), {
1173
- default: "",
1174
- description: "numbers of the tasks blocking it, comma separated: each has to merge before this one starts"
1175
- }).option("lane", z12.string(), {
1211
+ }).repeated("file", z13.string(), {
1212
+ description: "files to attach, comma separated or the flag repeated: copied under .git/splates/tasks/<n>/"
1213
+ }).repeated("blocked-by", z13.string(), {
1214
+ description: "numbers of the tasks blocking it, comma separated or the flag repeated: each has to merge before this one starts"
1215
+ }).option("lane", z13.string(), {
1176
1216
  default: "",
1177
1217
  description: "the lane it goes in, at the bottom: a lane's number, `backlog` for the permanent catch-all, or `new` for one of its own (default: none, untriaged)"
1178
- }).option("agent", z12.string(), {
1218
+ }).option("agent", z13.string(), {
1179
1219
  default: "",
1180
1220
  description: "the agent on the roster (`splates agents`) that should work it, which Start on the `splates server` board offers first"
1181
- }).option("model", z12.string(), {
1221
+ }).option("model", z13.string(), {
1182
1222
  default: "",
1183
1223
  description: "with --agent: the model, by its id or name on the roster"
1184
- }).option("effort", z12.string(), {
1224
+ }).option("effort", z13.string(), {
1185
1225
  default: "",
1186
1226
  description: "with --agent: the thinking level, one the agent offers (default: the agent's own)"
1187
1227
  }).action(async (opts, ctx) => {
@@ -1199,8 +1239,8 @@ function adding(name, description, ready) {
1199
1239
  slug: opts.slug,
1200
1240
  text: opts.text,
1201
1241
  position: opts.position,
1202
- files: commaList5(opts.file),
1203
- blockedBy: taskIds(opts["blocked-by"]),
1242
+ files: commaList4(opts.file.join(",")),
1243
+ blockedBy: taskIds(opts["blocked-by"].join(",")),
1204
1244
  lane: laneIn(opts.lane) ?? null,
1205
1245
  assignee,
1206
1246
  origin,
@@ -1211,10 +1251,10 @@ function adding(name, description, ready) {
1211
1251
  }
1212
1252
  adding("add", "queue a task to do later and move on, groomed, so it goes in ready: you checked no task already asks it, and linked what blocks it; run from a worktree, it records the task it came up in", true);
1213
1253
  adding("defer", "queue a task as it is, rough, in the backlog for whoever grooms it: to check for one that already asks it, link what blocks it, and mark it ready; run from a worktree, it records the task it came up in", false);
1214
- splatesTask.command("list").use(repository8({ needs: "read" })).description("every queued task, top of the list first, with the task it came from and the started one that took it up").option("json", z12.boolean(), { default: false, description: "emit JSON" }).option("open", z12.boolean(), {
1254
+ splatesTask.command("list").use(repository8({ needs: "read" })).description("every queued task, top of the list first, with the task it came from and the started one that took it up").option("json", z13.boolean(), { default: false, description: "emit JSON" }).option("open", z13.boolean(), {
1215
1255
  default: false,
1216
1256
  description: "only tasks no started one has taken up: the ones free to pick up"
1217
- }).option("ready", z12.boolean(), {
1257
+ }).option("ready", z13.boolean(), {
1218
1258
  default: false,
1219
1259
  description: "only tasks free to start now: none has taken them, and nothing blocking them is still open"
1220
1260
  }).action(async (opts, ctx) => taskList(ctx, {
@@ -1223,53 +1263,49 @@ splatesTask.command("list").use(repository8({ needs: "read" })).description("eve
1223
1263
  ready: opts.ready,
1224
1264
  project: await projectFrom(ctx)
1225
1265
  }));
1226
- splatesTask.command("show").use(repository8({ needs: "read" })).description("one task in full: where it stands, its files, and its detail").arg("id", z12.coerce.number().int().positive(), { description: "task number" }).option("json", z12.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => taskShow(ctx, opts.id, {
1266
+ splatesTask.command("show").use(repository8({ needs: "read" })).description("one task in full: where it stands, its files, and its detail").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).option("json", z13.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => taskShow(ctx, opts.id, {
1227
1267
  json: opts.json,
1228
1268
  project: await projectFrom(ctx),
1229
1269
  others: (await ctx.projects.list()).filter((project) => project.root !== ctx.git.root).map((project) => project.slug)
1230
1270
  }));
1231
- splatesTask.command("update").use(repository8({ needs: "tasks" })).description("say what a queued task is in other words, move it up or down the list, or attach and drop files; it keeps its number").arg("id", z12.coerce.number().int().positive(), { description: "task number" }).arg("text", z12.string(), {
1271
+ splatesTask.command("update").use(repository8({ needs: "tasks" })).description("say what a queued task is in other words, move it up or down the list, or attach and drop files; it keeps its number").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).arg("text", z13.string(), {
1232
1272
  default: "",
1233
1273
  description: "whatever more there is to say about it, in markdown; leave out to keep the detail"
1234
- }).option("subject", z12.string(), {
1274
+ }).option("subject", z13.string(), {
1235
1275
  default: "",
1236
1276
  description: "what is left to do, in a line; leave out to keep it"
1237
- }).option("slug", z12.string(), {
1277
+ }).option("slug", z13.string(), {
1238
1278
  default: "",
1239
1279
  description: "a new name for the task that takes it (lowercase-with-dashes); leave out to keep it"
1240
1280
  }).option("position", position, {
1241
1281
  description: "where to move it in the list, 1 at the top; the others close up around it"
1242
- }).option("file", z12.string(), {
1243
- default: "",
1244
- description: "files to attach, comma separated"
1245
- }).option("remove-file", z12.string(), {
1246
- default: "",
1247
- description: "attached files to drop, comma separated, by path or stored name"
1248
- }).option("blocked-by", z12.string(), {
1249
- default: "",
1250
- description: "numbers of more tasks blocking it, comma separated: each has to merge before this one starts; refused when that makes a loop"
1251
- }).option("remove-blocked-by", z12.string(), {
1252
- default: "",
1253
- description: "numbers of tasks no longer blocking it, comma separated"
1254
- }).option("lane", z12.string(), {
1282
+ }).repeated("file", z13.string(), {
1283
+ description: "files to attach, comma separated or the flag repeated"
1284
+ }).repeated("remove-file", z13.string(), {
1285
+ description: "attached files to drop, comma separated or the flag repeated, by path or stored name"
1286
+ }).repeated("blocked-by", z13.string(), {
1287
+ description: "numbers of more tasks blocking it, comma separated or the flag repeated: each has to merge before this one starts; refused when that makes a loop"
1288
+ }).repeated("remove-blocked-by", z13.string(), {
1289
+ description: "numbers of tasks no longer blocking it, comma separated or the flag repeated"
1290
+ }).option("lane", z13.string(), {
1255
1291
  default: "",
1256
1292
  description: "move it to a lane, at the bottom: a lane's number, `backlog` for the permanent catch-all, `new` for one of its own, or `none` for untriaged"
1257
- }).option("agent", z12.string(), {
1293
+ }).option("agent", z13.string(), {
1258
1294
  default: "",
1259
1295
  description: "the agent on the roster (`splates agents`) that should work it, which Start on the `splates server` board offers first, or `none`"
1260
- }).option("model", z12.string(), {
1296
+ }).option("model", z13.string(), {
1261
1297
  default: "",
1262
1298
  description: "with --agent: the model, by its id or name on the roster"
1263
- }).option("effort", z12.string(), {
1299
+ }).option("effort", z13.string(), {
1264
1300
  default: "",
1265
1301
  description: "with --agent: the thinking level, one the agent offers (default: the agent's own)"
1266
- }).option("ready", z12.boolean(), {
1302
+ }).option("ready", z13.boolean(), {
1267
1303
  default: false,
1268
1304
  description: "mark it groomed, moving it from the backlog to ready: no task already asks it, and what blocks it is linked"
1269
- }).option("manual", z12.boolean(), {
1305
+ }).option("manual", z13.boolean(), {
1270
1306
  default: false,
1271
1307
  description: "queue it by hand only: a queued lane passes over it, and it starts once you `splates queue add` it"
1272
- }).option("auto", z12.boolean(), {
1308
+ }).option("auto", z13.boolean(), {
1273
1309
  default: false,
1274
1310
  description: "let a queued lane queue it again, undoing --manual"
1275
1311
  }).action(async (opts, ctx) => {
@@ -1283,21 +1319,21 @@ splatesTask.command("update").use(repository8({ needs: "tasks" })).description("
1283
1319
  slug: opts.slug,
1284
1320
  text: opts.text,
1285
1321
  position: opts.position,
1286
- files: commaList5(opts.file),
1287
- removeFiles: commaList5(opts["remove-file"]),
1288
- blockedBy: taskIds(opts["blocked-by"]),
1289
- removeBlockedBy: taskIds(opts["remove-blocked-by"]),
1322
+ files: commaList4(opts.file.join(",")),
1323
+ removeFiles: commaList4(opts["remove-file"].join(",")),
1324
+ blockedBy: taskIds(opts["blocked-by"].join(",")),
1325
+ removeBlockedBy: taskIds(opts["remove-blocked-by"].join(",")),
1290
1326
  lane: laneIn(opts.lane),
1291
1327
  assignee,
1292
1328
  ready: opts.ready,
1293
1329
  manual: opts.manual ? true : opts.auto ? false : undefined
1294
1330
  }));
1295
1331
  });
1296
- splatesTask.command("remove").use(repository8({ needs: "tasks" })).description("drop a queued task that was done some other way or no longer applies").arg("id", z12.coerce.number().int().positive(), { description: "task number" }).action(async (opts, ctx) => {
1332
+ splatesTask.command("remove").use(repository8({ needs: "tasks" })).description("drop a queued task that was done some other way or no longer applies").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).action(async (opts, ctx) => {
1297
1333
  await refuseGroomer3(ctx.service, "task remove", await ctx.tasks.find(opts.id));
1298
1334
  await ctx.journal.record("task remove", null, () => taskRemove(ctx, opts.id), (removed) => ({ tasks: tasksNamed2([removed]) }));
1299
1335
  });
1300
- splatesTask.command("wait").use(repository8({ needs: "read" })).description("block until a task leaves the queue, which it does when the task that took it merges: for a task blocked by one another lane is on; ends early when that task waits on a person to answer its questions, and gives up with `timeout`").arg("id", z12.coerce.number().int().positive(), { description: "task number" }).option("timeout-secs", z12.coerce.number(), {
1336
+ splatesTask.command("wait").use(repository8({ needs: "read" })).description("block until a task leaves the queue, which it does when the task that took it merges: for a task blocked by one another lane is on; ends early when that task waits on a person to answer its questions, and gives up with `timeout`").arg("id", z13.coerce.number().int().positive(), { description: "task number" }).option("timeout-secs", z13.coerce.number(), {
1301
1337
  default: DEFAULT_TIMEOUT2.secs,
1302
1338
  description: "how long to wait before giving up"
1303
1339
  }).action(async (opts, ctx) => {
@@ -1312,36 +1348,36 @@ splatesTask.command("wait").use(repository8({ needs: "read" })).description("blo
1312
1348
 
1313
1349
  // tunnel.ts
1314
1350
  import { repository as repository9 } from "@stringsync/splates-server/records";
1315
- import { savedTunnelOf as savedTunnelOf2 } from "@stringsync/splates-server/server";
1351
+ import { savedTunnelOf } from "@stringsync/splates-server/server";
1316
1352
  import {
1317
1353
  API_TOKEN,
1318
1354
  tunnelRemove,
1319
1355
  tunnelSetup
1320
1356
  } from "@stringsync/splates-server/tunnel";
1321
- import { cli as cli12 } from "webappwiz/cmd";
1322
- import { z as z13 } from "zod";
1323
- var splatesTunnel = cli12("tunnel").description("reach `splates server` from elsewhere: a Cloudflare tunnel with Cloudflare Access in front, set up once per machine");
1324
- splatesTunnel.command("setup").use(repository9({ needs: "own" })).description(`make a Cloudflare tunnel to \`splates server\`, the DNS record routing <host> to it, and an Access application letting in only --email, through the API with the token kept as the cred ${API_TOKEN}, then save them for this machine, so a bare \`splates server\` comes up tunneled; run again, it changes only what is not so already. Without that token it requests it on the Creds page of \`splates server\`, with how to make it, and exits \`requested\`: \`splates creds wait ${API_TOKEN}\`, then run it again`).arg("host", z13.string(), {
1357
+ import { cli as cli13 } from "webappwiz/cmd";
1358
+ import { z as z14 } from "zod";
1359
+ var splatesTunnel = cli13("tunnel").description("reach `splates server` from elsewhere: a Cloudflare tunnel with Cloudflare Access in front, set up once per machine");
1360
+ splatesTunnel.command("setup").use(repository9({ needs: "own" })).description(`make a Cloudflare tunnel to \`splates server\`, the DNS record routing <host> to it, and an Access application letting in only --email, through the API with the token kept as the cred ${API_TOKEN}, then save them for this machine, so a bare \`splates server\` comes up tunneled; run again, it changes only what is not so already. Without that token it requests it on the Creds page of \`splates server\`, with how to make it, and exits \`requested\`: \`splates creds wait ${API_TOKEN}\`, then run it again`).arg("host", z14.string(), {
1325
1361
  description: "the public host name, in a zone of the account, as in splates.example.com"
1326
- }).option("email", z13.string(), {
1362
+ }).option("email", z14.string(), {
1327
1363
  default: "",
1328
1364
  description: "comma-separated email addresses Access lets in"
1329
- }).option("name", z13.string(), {
1365
+ }).option("name", z14.string(), {
1330
1366
  default: "splates",
1331
1367
  description: "what the tunnel, the Access application and its policy are called"
1332
- }).option("team", z13.string(), {
1368
+ }).option("team", z14.string(), {
1333
1369
  default: "",
1334
1370
  description: "the Zero Trust team to make, as in <team>.cloudflareaccess.com, when the account has none yet"
1335
1371
  }).action(async (opts, ctx) => ctx.journal.record("tunnel setup", null, async () => tunnelSetup({
1336
1372
  ...opts,
1337
1373
  creds: ctx.creds,
1338
1374
  task: await here(ctx),
1339
- saved: savedTunnelOf2(ctx),
1375
+ saved: savedTunnelOf(ctx),
1340
1376
  log: ctx.log,
1341
1377
  fetch: ctx.fetch
1342
1378
  })));
1343
- splatesTunnel.command("status").description("print the tunnel `splates tunnel setup` saved for this machine, which a bare `splates server` runs, and whether its token is in the system's secret store").option("json", z13.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
1344
- const saved = savedTunnelOf2(ctx);
1379
+ splatesTunnel.command("status").description("print the tunnel `splates tunnel setup` saved for this machine, which a bare `splates server` runs, and whether its token is in the system's secret store").option("json", z14.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => {
1380
+ const saved = savedTunnelOf(ctx);
1345
1381
  const record = await saved.read();
1346
1382
  const token = record === null ? false : await saved.token() !== undefined;
1347
1383
  if (opts.json) {
@@ -1362,7 +1398,7 @@ splatesTunnel.command("status").description("print the tunnel `splates tunnel se
1362
1398
  `));
1363
1399
  });
1364
1400
  splatesTunnel.command("remove").use(repository9({ needs: "own" })).description(`delete what \`splates tunnel setup\` and \`splates server --slot\` made in Cloudflare by what they saved: each tunnel, the DNS record routing to it, the Access applications and their policy, then forget the saved files and the tunnels' tokens; the API token kept as ${API_TOKEN} stays. Run again after a failure, it finishes what is left`).action(async (_opts, ctx) => ctx.journal.record("tunnel remove", null, async () => tunnelRemove({
1365
- saved: savedTunnelOf2(ctx),
1401
+ saved: savedTunnelOf(ctx),
1366
1402
  slots: slotTunnelsOf(ctx),
1367
1403
  creds: ctx.creds,
1368
1404
  task: await here(ctx),
@@ -1372,7 +1408,7 @@ splatesTunnel.command("remove").use(repository9({ needs: "own" })).description(`
1372
1408
  })));
1373
1409
 
1374
1410
  // worker.ts
1375
- import { splatesHome as splatesHome3 } from "@stringsync/splates-server/models";
1411
+ import { splatesHome as splatesHome4 } from "@stringsync/splates-server/models";
1376
1412
  import {
1377
1413
  addWorker,
1378
1414
  drainWorker,
@@ -1381,25 +1417,24 @@ import {
1381
1417
  removeWorker,
1382
1418
  setWorkerSlots,
1383
1419
  WORKER_JOIN,
1384
- wakeWorker,
1385
1420
  workerJoin,
1386
1421
  workerRecipe,
1387
1422
  workerRun
1388
1423
  } from "@stringsync/splates-server/workers";
1389
- import { cli as cli14 } from "webappwiz/cmd";
1424
+ import { cli as cli15 } from "webappwiz/cmd";
1390
1425
  import { CompositeLogger } from "webappwiz/log";
1391
- import { z as z15 } from "zod";
1426
+ import { z as z16 } from "zod";
1392
1427
 
1393
1428
  // waiting.ts
1394
1429
  import { listQueue, moveQueued } from "@stringsync/splates-server/workers";
1395
- import { cli as cli13 } from "webappwiz/cmd";
1396
- import { z as z14 } from "zod";
1430
+ import { cli as cli14 } from "webappwiz/cmd";
1431
+ import { z as z15 } from "zod";
1397
1432
  var DEV2 = {
1398
1433
  default: "",
1399
1434
  description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
1400
1435
  };
1401
- var splatesWaiting = cli13("queue").description("the agents waiting for a slot on a worker of the running `splates server`: each one another agent started (with `splates add --agent`, or a lane's orchestrator handing out tasks), started or resumed while every worker it can run on is full, waits here, first come, first served, and starts once a slot frees, as when an agent left idle 30 minutes stops; none is ever failed for waiting. An agent you start waits only while no worker it can run on is up, as with this machine's own removed or none on the platform its project requires");
1402
- splatesWaiting.command("list").description("list the agents waiting for a slot, first in line first: position, project, task, conversation, how long each has waited and why (no free slot, no worker up, or a platform no worker runs, as `needs macOS`)").option("dev", z14.string(), DEV2).option("json", z14.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listQueue({
1436
+ var splatesWaiting = cli14("queue").description("the agents waiting for a slot on a worker of the running `splates server`: each one another agent started (with `splates add --agent`, or a lane's orchestrator handing out tasks), started or resumed while every worker it can run on is full, waits here, first come, first served, and starts once a slot frees, as when an agent left idle 30 minutes stops; none is ever failed for waiting. An agent you start waits only while no worker it can run on is up, as with this machine's own removed or none on the platform its project requires");
1437
+ splatesWaiting.command("list").description("list the agents waiting for a slot, first in line first: position, project, task, conversation, how long each has waited and why (no free slot, no worker up, or a platform no worker runs, as `needs macOS`)").option("dev", z15.string(), DEV2).option("json", z15.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listQueue({
1403
1438
  dev: opts.dev,
1404
1439
  json: opts.json,
1405
1440
  log: ctx.log,
@@ -1407,11 +1442,11 @@ splatesWaiting.command("list").description("list the agents waiting for a slot,
1407
1442
  ps: ctx.ps,
1408
1443
  send: ctx.fetch
1409
1444
  }));
1410
- splatesWaiting.command("move").description("move an agent waiting for a slot to another place in line, from 1 for the next to start, then list the queue").arg("which", z14.string(), {
1445
+ splatesWaiting.command("move").description("move an agent waiting for a slot to another place in line, from 1 for the next to start, then list the queue").arg("which", z15.string(), {
1411
1446
  description: "the task it works, or its conversation's id"
1412
- }).arg("position", z14.coerce.number().int(), {
1447
+ }).arg("position", z15.coerce.number().int(), {
1413
1448
  description: "where in line to put it, from 1"
1414
- }).option("dev", z14.string(), DEV2).option("json", z14.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => moveQueued({
1449
+ }).option("dev", z15.string(), DEV2).option("json", z15.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => moveQueued({
1415
1450
  which: opts.which,
1416
1451
  position: opts.position,
1417
1452
  dev: opts.dev,
@@ -1423,29 +1458,29 @@ splatesWaiting.command("move").description("move an agent waiting for a slot to
1423
1458
  }));
1424
1459
 
1425
1460
  // worker.ts
1426
- var splatesWorker = cli14("worker").description("the machines that run tasks for `splates server`: each dials out to it and runs what it is given, agents, terminals and the git and process reads around them, on its own machine");
1427
- splatesWorker.command("run").description("run a worker on this machine until stopped: it dials `splates server`, by default the one on this machine through a socket in ~/.splates only you may open, and dials again whenever the link drops, while what it runs carries on; it stops reading a process's output while the server is away too long, so nothing is lost. Once its agents have moved off it, a server on another build has it restart on that build, through `bunx @stringsync/splates@<build>`, as it does when it refuses this worker for another protocol; one that says no build has it exit `outdated`").option("server", z15.string(), {
1461
+ var splatesWorker = cli15("worker").description("the machines that run tasks for `splates server`: each dials out to it and runs what it is given, agents, terminals and the git and process reads around them, on its own machine");
1462
+ splatesWorker.command("run").description("run a worker on this machine until stopped: it dials `splates server`, by default the one on this machine through a socket in ~/.splates only you may open, and dials again whenever the link drops, while what it runs carries on; it stops reading a process's output while the server is away too long, so nothing is lost. Once its agents have moved off it, a server on another build has it restart on that build, through `bunx @stringsync/splates@<build>`, as it does when it refuses this worker for another protocol; one that says no build has it exit `outdated`").option("server", z16.string(), {
1428
1463
  default: "",
1429
1464
  description: "the `splates server` to dial: its URL, whose /api/workers it opens, or unix:<path>; by default this machine's"
1430
- }).option("coordinator", z15.string(), {
1465
+ }).option("coordinator", z16.string(), {
1431
1466
  default: "",
1432
1467
  description: "the old name for --server, still read for workers installed before it, and passed to a build older than it"
1433
- }).option("slots", z15.coerce.number().int().min(0), {
1468
+ }).option("slots", z16.coerce.number().int().min(0), {
1434
1469
  default: 0,
1435
1470
  description: "how many tasks this worker takes at once; by default one for each core and 4 GB of memory it has, whichever is fewer, as a VM such as WSL2 sees them"
1436
- }).option("id", z15.string(), {
1471
+ }).option("id", z16.string(), {
1437
1472
  default: "",
1438
1473
  description: "what the server knows this worker by, kept across restarts so it picks up where it was; by default the id it joined as, kept beside its key, else the machine's host name"
1439
- }).option("join", z15.string(), {
1474
+ }).option("join", z16.string(), {
1440
1475
  default: "",
1441
1476
  description: "a join token from `splates worker add` on the server, spent to join it before dialing; the key it answers is kept in this machine's system store, or read from SPLATES_WORKER_KEY on one without"
1442
- }).option("ephemeral", z15.boolean(), {
1477
+ }).option("ephemeral", z16.boolean(), {
1443
1478
  default: false,
1444
1479
  description: "keep the key --join answers in memory alone, for a machine destroyed rather than restarted, as one a worker provider makes"
1445
- }).option("key-file", z15.boolean(), {
1480
+ }).option("key-file", z16.boolean(), {
1446
1481
  default: false,
1447
1482
  description: "keep the key --join answers in ~/.splates/worker.env, which only this user may read, rather than the system store, for a machine whose store is locked or missing at boot, as WSL's is with no one signed in; read it from there on each run after"
1448
- }).option("quiet", z15.boolean(), {
1483
+ }).option("quiet", z16.boolean(), {
1449
1484
  default: false,
1450
1485
  description: "say only what went wrong, not each time it joins or loses its server, as the worker `splates server` starts beside itself does; all of it still goes to worker.log in ~/.splates, the one before it kept as worker.log.1"
1451
1486
  }).action((opts, ctx) => workerRun({
@@ -1459,19 +1494,19 @@ splatesWorker.command("run").description("run a worker on this machine until sto
1459
1494
  ps: ctx.ps,
1460
1495
  fs: ctx.fs
1461
1496
  }));
1462
- splatesWorker.command("join").description("spend a join token from `splates worker add` and keep the key it answers, as `splates worker run --join` does, then exit, for an installer that leaves running the worker to a service").option("server", z15.string(), {
1497
+ splatesWorker.command("join").description("spend a join token from `splates worker add` and keep the key it answers, as `splates worker run --join` does, then exit, for an installer that leaves running the worker to a service").option("server", z16.string(), {
1463
1498
  default: "",
1464
1499
  description: "the `splates server` to join: its URL"
1465
- }).option("coordinator", z15.string(), {
1500
+ }).option("coordinator", z16.string(), {
1466
1501
  default: "",
1467
1502
  description: "the old name for --server"
1468
- }).option("join", z15.string(), {
1503
+ }).option("join", z16.string(), {
1469
1504
  default: "",
1470
1505
  description: "the join token `splates worker add` printed there; by default read from SPLATES_WORKER_JOIN, which keeps it out of the process list other users of the machine can read"
1471
- }).option("id", z15.string(), {
1506
+ }).option("id", z16.string(), {
1472
1507
  default: "",
1473
1508
  description: "what the server knows this worker by, kept beside its key so a change of host name leaves it the same; by default the machine's host name"
1474
- }).option("key-file", z15.boolean(), {
1509
+ }).option("key-file", z16.boolean(), {
1475
1510
  default: false,
1476
1511
  description: "keep the key in ~/.splates/worker.env, which only this user may read, rather than the system store, for `splates worker run --key-file` to read"
1477
1512
  }).action((opts, ctx) => workerJoin({
@@ -1484,10 +1519,10 @@ splatesWorker.command("join").description("spend a join token from `splates work
1484
1519
  fs: ctx.fs
1485
1520
  }));
1486
1521
  splatesWorker.command("recipe").description("print the shell script, run as root on a fresh Debian or Ubuntu machine, that installs what a Linux worker needs: bun, git, the shared browser and this build of splates, for the user `splates`. It is what a provider's snapshot is built from, and what the Windows installer runs in WSL").action((_opts, ctx) => workerRecipe({ log: ctx.log }));
1487
- splatesWorker.command("list").description("list the workers the running `splates server` has: kind (local over its socket, registered otherwise), platform, build, protocol, slots used and total, health with when it was last heard, tasks and $/hour, then any it refused for speaking another protocol").option("dev", z15.string(), {
1522
+ splatesWorker.command("list").description("list the workers the running `splates server` has: kind (local over its socket, registered otherwise), platform, build, protocol, slots used and total, health with when it was last heard, tasks and $/hour, then any it refused for speaking another protocol").option("dev", z16.string(), {
1488
1523
  default: "",
1489
1524
  description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
1490
- }).option("json", z15.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listWorkers({
1525
+ }).option("json", z16.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => listWorkers({
1491
1526
  dev: opts.dev,
1492
1527
  json: opts.json,
1493
1528
  log: ctx.log,
@@ -1499,16 +1534,16 @@ var dev = {
1499
1534
  default: "",
1500
1535
  description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV"
1501
1536
  };
1502
- splatesWorker.command("add").description("add a worker to the running `splates server`: prints a join token, single use and good for 15 minutes, for `splates worker run --join <token>` on the machine to join, which gets a key of its own that `splates worker remove` revokes; with --local N, makes this machine's own worker run N agents at once and take new ones again, adding it back if it was removed; with --provider, has the `workerProvider` in splates.config.ts make one, booted from the snapshot splates builds for it, which joins on its own and whose machine `splates worker remove` destroys").option("local", z15.coerce.number().int().min(0), {
1537
+ splatesWorker.command("add").description("add a worker to the running `splates server`: prints a join token, single use and good for 15 minutes, for `splates worker run --join <token>` on the machine to join, which gets a key of its own that `splates worker remove` revokes; with --local N, makes this machine's own worker run N agents at once and take new ones again, adding it back if it was removed; with --provider, has the `workerProvider` in splates.config.ts make one, booted from the snapshot splates builds for it, which joins on its own and whose machine `splates worker remove` destroys").option("local", z16.coerce.number().int().min(0), {
1503
1538
  default: 0,
1504
1539
  description: "this machine's own worker, with this many slots"
1505
- }).option("provider", z15.boolean(), {
1540
+ }).option("provider", z16.boolean(), {
1506
1541
  default: false,
1507
1542
  description: "have the server's worker provider make the worker, of its cheapest type unless --type says"
1508
- }).option("type", z15.string(), {
1543
+ }).option("type", z16.string(), {
1509
1544
  default: "",
1510
1545
  description: "the worker provider's type to make, as it names it; implies --provider"
1511
- }).option("dev", z15.string(), dev).action((opts, ctx) => addWorker({
1546
+ }).option("dev", z16.string(), dev).action((opts, ctx) => addWorker({
1512
1547
  local: opts.local,
1513
1548
  provider: opts.provider,
1514
1549
  type: opts.type,
@@ -1518,12 +1553,12 @@ splatesWorker.command("add").description("add a worker to the running `splates s
1518
1553
  ps: ctx.ps,
1519
1554
  send: ctx.fetch
1520
1555
  }));
1521
- splatesWorker.command("drain").description("take no new agents on a worker, letting those on it run to their end, but for one asking you something in a task, which saves its task and lets its slot go, its question kept for you to answer and carry it on elsewhere; --undo takes new ones again").arg("id", z15.string(), {
1556
+ splatesWorker.command("drain").description("take no new agents on a worker, letting those on it run to their end, but for one asking you something in a task, which saves its task and lets its slot go, its question kept for you to answer and carry it on elsewhere; --undo takes new ones again").arg("id", z16.string(), {
1522
1557
  description: "the worker, as `splates worker list` names it"
1523
- }).option("undo", z15.boolean(), {
1558
+ }).option("undo", z16.boolean(), {
1524
1559
  default: false,
1525
1560
  description: "take new agents again"
1526
- }).option("dev", z15.string(), dev).action((opts, ctx) => drainWorker({
1561
+ }).option("dev", z16.string(), dev).action((opts, ctx) => drainWorker({
1527
1562
  id: opts.id,
1528
1563
  undo: opts.undo,
1529
1564
  dev: opts.dev,
@@ -1532,21 +1567,11 @@ splatesWorker.command("drain").description("take no new agents on a worker, lett
1532
1567
  ps: ctx.ps,
1533
1568
  send: ctx.fetch
1534
1569
  }));
1535
- splatesWorker.command("wake").description("wake a worker asleep at home, a Windows PC that joined in WSL2: a worker awake on its network sends the Wake-on-LAN packet. Placement wakes one by itself when agents queue, before making a worker").arg("id", z15.string(), {
1536
- description: "the worker, as `splates worker list` names it"
1537
- }).option("dev", z15.string(), dev).action((opts, ctx) => wakeWorker({
1538
- id: opts.id,
1539
- dev: opts.dev,
1540
- log: ctx.log,
1541
- fs: ctx.fs,
1542
- ps: ctx.ps,
1543
- send: ctx.fetch
1544
- }));
1545
- splatesWorker.command("slots").description("set how many agents a worker runs at once, from now on, over what it started with").arg("id", z15.string(), {
1570
+ splatesWorker.command("slots").description("set how many agents a worker runs at once, from now on, over what it started with").arg("id", z16.string(), {
1546
1571
  description: "the worker, as `splates worker list` names it"
1547
- }).arg("slots", z15.coerce.number().int().min(1), {
1572
+ }).arg("slots", z16.coerce.number().int().min(1), {
1548
1573
  description: "how many at once"
1549
- }).option("dev", z15.string(), dev).action((opts, ctx) => setWorkerSlots({
1574
+ }).option("dev", z16.string(), dev).action((opts, ctx) => setWorkerSlots({
1550
1575
  id: opts.id,
1551
1576
  slots: opts.slots,
1552
1577
  dev: opts.dev,
@@ -1555,12 +1580,12 @@ splatesWorker.command("slots").description("set how many agents a worker runs at
1555
1580
  ps: ctx.ps,
1556
1581
  send: ctx.fetch
1557
1582
  }));
1558
- splatesWorker.command("remove").description("remove a worker, revoking its key: one with agents on it drains and is removed once they end; --force ends them now, frees their slots, and places each again on another worker, to carry on its task from the checkpoint it last pushed. This machine's own worker, `local`, stops taking agents, its agents running to their end, and leaves this machine only coordinating until `splates worker add --local <slots>`; it takes no --force").arg("id", z15.string(), {
1583
+ splatesWorker.command("remove").description("remove a worker, revoking its key: one with agents on it drains and is removed once they end; --force ends them now, frees their slots, and places each again on another worker, to carry on its task from the checkpoint it last pushed. This machine's own worker, `local`, stops taking agents, its agents running to their end, and leaves this machine only coordinating until `splates worker add --local <slots>`; it takes no --force").arg("id", z16.string(), {
1559
1584
  description: "the worker, as `splates worker list` names it"
1560
- }).option("force", z15.boolean(), {
1585
+ }).option("force", z16.boolean(), {
1561
1586
  default: false,
1562
1587
  description: "end the agents on it now rather than wait for them"
1563
- }).option("dev", z15.string(), dev).action((opts, ctx) => removeWorker({
1588
+ }).option("dev", z16.string(), dev).action((opts, ctx) => removeWorker({
1564
1589
  id: opts.id,
1565
1590
  force: opts.force,
1566
1591
  dev: opts.dev,
@@ -1571,7 +1596,7 @@ splatesWorker.command("remove").description("remove a worker, revoking its key:
1571
1596
  }));
1572
1597
  splatesWorker.mount("queue", splatesWaiting);
1573
1598
  function quietLog(ctx) {
1574
- const file = new FileLogger(ctx.fs, `${splatesHome3(ctx.ps)}/worker.log`);
1599
+ const file = new FileLogger(ctx.fs, `${splatesHome4(ctx.ps)}/worker.log`);
1575
1600
  return new CompositeLogger([
1576
1601
  { info: () => {}, error: (...said) => ctx.log.error(...said) },
1577
1602
  file
@@ -1592,7 +1617,7 @@ function projectArgs(argv) {
1592
1617
  }
1593
1618
  return { argv };
1594
1619
  }
1595
- var splates = cli15("splates").use(exits());
1620
+ var splates = cli16("splates").use(exits());
1596
1621
  splates.command("notes").use(repository10({ needs: "read" })).description("print each project note's frontmatter description, one per line; notesDir in splates.config.ts defaults to notes; an absent directory prints nothing").action((_opts, ctx) => notes(ctx));
1597
1622
  splates.command("init").use(repository10({ creates: true, needs: "own" })).description("make this repository a splates project: set up its records under .git/splates when it has none, install the splates skill of this release with `bunx skills add`, and register it in ~/.splates/projects.json, so `splates server` serves it beside every other; run again, it changes only what is not so already").action((_opts, ctx) => init(ctx));
1598
1623
  splates.command("migrate").description(`move this repository's records under .git/splates to this splates's format, as \`splates server\` does as it starts: backed up first, the steps run with ${MIGRATION_LIMIT.human()} to finish, and the backup put back if one fails or runs over, with why kept in .git/splates-migration.json; it tries again past a failure kept there, which the server and every other command leave alone. Run it only while no older \`splates server\` serves the repository, which would refuse the records once moved; \`splates update\` restarts one first`).action(async (_opts, ctx) => {
@@ -1613,39 +1638,51 @@ splates.command("update").use(recordsFirst()).use(repository10({ needs: "own" })
1613
1638
  ctx.ps.exit(code);
1614
1639
  }
1615
1640
  });
1616
- splates.command("heavy").description("run a heavy command (a full test run, an install) in one of this machine's heavy-job slots, waiting for one to come free first, so agents testing at once queue rather than run out of memory; `SPLATES_HEAVY_JOBS` sets how many run at once, 2 by default").rest("command", z16.string(), {
1641
+ splates.command("run").description("run a command that needs a machine of its own (a full test run, an install) on a runner, in a checkout of this worktree as it stands, uncommitted edits included, its output streamed back and its exit code yours: one that has joined `splates server` when it has one, else, in a project whose splates.config.ts names a `cloud`, one `splates run` makes there itself, server or no server (`splates runners`), refusing with `no_runner` rather than run here when none can be had, as without the cloud's token or past `splates budget`. A project that names no cloud runs it here, in one of this machine's heavy-job slots, waiting for one to come free first, so agents testing at once queue rather than run out of memory; `SPLATES_HEAVY_JOBS` sets how many run at once, 1 by default. Run on a runner, it changes nothing here: files it writes stay there").option("cpus", z17.coerce.number().positive().optional(), {
1642
+ description: "the CPUs it asks of a runner, which takes as many commands at once as fit; the project's `job` in splates.config.ts, else 4, by default"
1643
+ }).option("memory", z17.string(), {
1644
+ default: "",
1645
+ description: "the memory it asks of a runner, as 8GB or 512MB; the project's `job` in splates.config.ts, else 8GB, by default"
1646
+ }).option("timeout", z17.string(), {
1647
+ default: "",
1648
+ description: "how long it may run, as 30m, 90s or 2h, before it is killed and exits 124, so a hung command holds no runner or slot; the project's `job.timeout` in splates.config.ts, else 30m, by default"
1649
+ }).rest("command", z17.string(), {
1617
1650
  description: "the command and its arguments, after `--`"
1618
1651
  }).action(async (opts, ctx) => {
1619
- const code = await runHeavy(ctx, opts.command);
1652
+ const code = await runHeavy(ctx, opts.command, undefined, {
1653
+ ...opts.cpus === undefined ? {} : { cpus: opts.cpus },
1654
+ memory: opts.memory,
1655
+ timeout: opts.timeout
1656
+ });
1620
1657
  if (code !== 0) {
1621
1658
  ctx.ps.exit(code);
1622
1659
  }
1623
1660
  });
1624
1661
  splates.command("forget").use(repository10({ needs: "own" })).description("take this repository off ~/.splates/projects.json, so `splates server` no longer offers it; its records stay, and `splates init` brings it back").action((_opts, ctx) => forget(ctx));
1625
- splates.command("add").use(repository10({ needs: "tasks" })).description("start a task: a new one, or with --task one from the queue; create branch task/<slug>, a worktree at ../<repo>-splates.noindex/<slug> and a state record").arg("slug", z16.string(), {
1662
+ splates.command("add").use(repository10({ needs: "tasks" })).description("start a task: a new one, or with --task one from the queue; create branch task/<slug>, a worktree at ../<repo>-splates.noindex/<slug> and a state record").arg("slug", z17.string(), {
1626
1663
  description: "the task's name, for its branch and worktree (lowercase-with-dashes)"
1627
- }).option("base", z16.string(), {
1664
+ }).option("base", z17.string(), {
1628
1665
  default: "",
1629
1666
  description: "branch this task starts from and merges onto (default: trunk); `task/<other>` stacks this task on that one and lands the work in its worktree"
1630
- }).option("task", z16.string(), {
1667
+ }).option("task", z17.string(), {
1631
1668
  default: "",
1632
1669
  description: "start this queued task by number: its words become the plan's Goal, merging removes it, and removing this task puts it back where it was"
1633
- }).option("goal", z16.string(), {
1670
+ }).option("goal", z17.string(), {
1634
1671
  default: "",
1635
1672
  description: "one or two lines on what done means, written under `## Goal` in the task's SPLATES.md; required unless --task seeds the Goal"
1636
- }).option("agent", z16.string(), {
1673
+ }).option("agent", z17.string(), {
1637
1674
  default: "",
1638
1675
  description: "how `splates server`'s queue hands the task to an agent on the roster (`splates agents`), which it starts in the worktree; refused from an agent, which starts no agents and queues work with `splates task add` instead"
1639
- }).option("model", z16.string(), {
1676
+ }).option("model", z17.string(), {
1640
1677
  default: "",
1641
1678
  description: "with --agent: the model it works on, by its id or name on the roster (default: the agent's own)"
1642
- }).option("effort", z16.string(), {
1679
+ }).option("effort", z17.string(), {
1643
1680
  default: "",
1644
1681
  description: "with --agent: the thinking level it works at, one the agent offers (default: the agent's own)"
1645
- }).option("prompt", z16.string(), {
1682
+ }).option("prompt", z17.string(), {
1646
1683
  default: "",
1647
1684
  description: "with --agent: what to tell the agent beyond claiming the task"
1648
- }).option("held", z16.string(), {
1685
+ }).option("held", z17.string(), {
1649
1686
  default: "",
1650
1687
  description: "with --agent: the conversation `splates server`'s queue held for the queued task, which takes the task rather than a new one"
1651
1688
  }).action(async (opts, ctx) => {
@@ -1690,108 +1727,109 @@ splates.command("add").use(repository10({ needs: "tasks" })).description("start
1690
1727
  await serveProject(ctx);
1691
1728
  }
1692
1729
  });
1693
- splates.command("claim").use(repository10({ needs: "own" })).description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; refuses while another agent holds the lease, but takes a stale one silently, so `splates show` first if the tree may not be abandoned").arg("task", z16.string(), { description: "task name" }).action(async (opts, ctx) => {
1730
+ splates.command("claim").use(repository10({ needs: "own" })).description("resume an existing task: take ownership of its worktree and print its path, status and any half-finished rebase; refuses while another agent holds the lease, but takes a stale one silently, so `splates show` first if the tree may not be abandoned").arg("task", z17.string(), { description: "task name" }).action(async (opts, ctx) => {
1694
1731
  await ctx.journal.record("claim", opts.task, () => claim(ctx, opts.task), (claimed) => claimed);
1695
1732
  await follow(ctx, opts.task);
1696
1733
  });
1697
- splates.command("merge").use(repository10({ needs: "own" })).description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").option("message", z16.string(), {
1734
+ splates.command("merge").use(repository10({ needs: "own" })).description("land this worktree's branch on its base (trunk unless created with --base): rebase onto it, run tests on the rebased code, fast-forward it in whichever worktree has it checked out, then discard the worktree, branch and record (linear history, never a merge commit, no flag to skip tests); requires committed work, refusing a dirty worktree").option("message", z17.string(), {
1698
1735
  default: "",
1699
1736
  description: "squash the task's commits into one with this message before landing, saying what the base gains in at least 5 characters"
1700
1737
  }).action(async (opts, ctx) => {
1701
1738
  await refuseGroomer4(ctx.service, "merge");
1702
1739
  const task = await here(ctx);
1703
1740
  const facts = {};
1704
- await ctx.journal.record("merge", task, () => merge(ctx, ctx.ps.cwd(), facts, { message: opts.message }), facts);
1741
+ await ctx.journal.record("merge", task, () => merge({ ...ctx, send: ctx.fetch }, ctx.ps.cwd(), facts, {
1742
+ message: opts.message
1743
+ }), facts);
1705
1744
  if (task !== null) {
1706
1745
  await addFactTasks(ctx, task).catch((error) => ctx.log.error(`could not queue tasks for what the merge found: ${error}`));
1707
1746
  await follow(ctx, null, { from: task });
1708
1747
  }
1709
1748
  });
1710
- splates.command("remove").use(repository10({ needs: "tasks" })).description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", z16.string(), { description: "task name" }).option("force", z16.boolean(), {
1749
+ splates.command("remove").use(repository10({ needs: "tasks" })).description("discard a task: worktree, branch and state file; cheap and encouraged, since redoing a task against current trunk often beats a hard rebase").arg("task", z17.string(), { description: "task name" }).option("force", z17.boolean(), {
1711
1750
  default: false,
1712
1751
  description: "discard even when another agent holds the lease"
1713
- }).option("reason", z16.optional(z16.enum(REMOVE_REASONS)), {
1752
+ }).option("reason", z17.optional(z17.enum(REMOVE_REASONS)), {
1714
1753
  description: "why, for the Stats page: nothing (nothing to change), moot (no longer needed), superseded (done some other way), redo (better redone against current trunk), blocked (set aside until what it waits on lands, so back on the list)"
1715
1754
  }).action(async (opts, ctx) => {
1716
1755
  await ctx.journal.record("remove", opts.task, () => remove(ctx, opts.task, { force: opts.force, reason: opts.reason }), ({ released }) => ({ why: opts.reason ?? null, released }));
1717
1756
  await follow(ctx, null, { from: opts.task });
1718
1757
  });
1719
- splates.command("list").use(repository10({ needs: "read" })).description("list every started task: its number, name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age; `splates task list` lists the queue").option("json", z16.boolean(), { default: false, description: "emit JSON" }).option("files", z16.boolean(), {
1758
+ splates.command("list").use(repository10({ needs: "read" })).description("list every started task: its number, name, status, lease (held: an agent is on it now; stale: gone quiet, normal for a task mid-edit; none), commits ahead of trunk, age; `splates task list` lists the queue").option("json", z17.boolean(), { default: false, description: "emit JSON" }).option("files", z17.boolean(), {
1720
1759
  default: false,
1721
1760
  description: "add each task's Goal and changed files (committed or not), to check for overlap before starting"
1722
1761
  }).action((opts, ctx) => list(ctx, { json: opts.json, files: opts.files }));
1723
- splates.command("show").use(repository10({ needs: "read" })).description("read one task without touching it: everything `list` shows for it, plus the SPLATES.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree; for a task no longer live, the newest finished one of that name: how it ended, its base, the commit it landed as, its conversation and the SPLATES.md it ended on").arg("task", z16.string(), { description: "task name" }).option("json", z16.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
1724
- splates.command("wait").use(repository10({ needs: "read" })).description("block until a task stops moving: waiting on a person to answer its questions, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", z16.string(), { description: "task name" }).option("timeout-secs", z16.coerce.number(), {
1762
+ splates.command("show").use(repository10({ needs: "read" })).description("read one task without touching it: everything `list` shows for it, plus the SPLATES.md its agent left at the worktree root; takes no lease, so it cannot knock that agent off its own tree; for a task no longer live, the newest finished one of that name: how it ended, its base, the commit it landed as, its conversation and the SPLATES.md it ended on").arg("task", z17.string(), { description: "task name" }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => show(ctx, opts.task, { json: opts.json }));
1763
+ splates.command("wait").use(repository10({ needs: "read" })).description("block until a task stops moving: waiting on a person to answer its questions, or gone (merged or removed), or broken; takes no lease, and gives up with `timeout` rather than waiting forever").arg("task", z17.string(), { description: "task name" }).option("timeout-secs", z17.coerce.number(), {
1725
1764
  default: DEFAULT_TIMEOUT3.secs,
1726
1765
  description: "how long to wait before giving up"
1727
1766
  }).action((opts, ctx) => {
1728
1767
  const timeout = Duration3.secs(opts["timeout-secs"]);
1729
1768
  return ctx.journal.waiting("wait", opts.task, { until: untilAfter3(timeout) }, () => wait(ctx, opts.task, { timeout }));
1730
1769
  });
1731
- splates.command("log").use(repository10({ needs: "read" })).description("show what has been done here recently: one line per add, claim, merge, remove, retry, queued task and more, with how it ended; outlives the tasks themselves").option("count", z16.coerce.number(), {
1770
+ splates.command("log").use(repository10({ needs: "read" })).description("show what has been done here recently: one line per add, claim, merge, remove, retry, queued task and more, with how it ended; outlives the tasks themselves").option("count", z17.coerce.number(), {
1732
1771
  default: DEFAULT_COUNT,
1733
1772
  description: "how many entries to show"
1734
- }).option("json", z16.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
1773
+ }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => showLog(ctx, { count: opts.count, json: opts.json }));
1735
1774
  splates.mount("server", splatesServer);
1736
- splates.command("ports").description("list the ports tasks serve, found by the running `splates server` as an editor finds ports to forward: each port a process in a task's worktree, or under its agent, listens on, with its link through the ports host `splates server --ports-host` saved, behind Cloudflare Access, and its link on this machine, which on a worker whose server runs elsewhere only that worker's own ports have; give the user both").arg("task", z16.string(), {
1775
+ splates.command("ports").description("list the ports tasks serve, found by the running `splates server` as an editor finds ports to forward: each port a process in a task's worktree, or under its agent, listens on, with its link through the ports host `splates server --ports-host` saved, behind Cloudflare Access, and its link on this machine; give the user both").arg("task", z17.string(), {
1737
1776
  default: "",
1738
1777
  description: "only this task's ports; every task's by default"
1739
- }).option("dev", z16.string(), {
1778
+ }).option("dev", z17.string(), {
1740
1779
  default: "",
1741
- description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV, else the coordinator this machine's worker relays to, else this machine's on its default port"
1742
- }).option("json", z16.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => portsLs({
1780
+ description: "the `splates server` to ask, a URL such as http://localhost:4310; by default the one an agent runs under, from SPLATES_DEV, else this machine's on its default port"
1781
+ }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).action((opts, ctx) => portsLs({
1743
1782
  task: opts.task,
1744
1783
  dev: opts.dev,
1745
1784
  json: opts.json,
1746
1785
  log: ctx.log,
1747
- fs: ctx.fs,
1748
1786
  ps: ctx.ps,
1749
1787
  send: ctx.fetch
1750
1788
  }));
1751
- splates.command("agents").description("print the roster of agents splates can start, kept for every project in ~/.splates/agents.json: each agent's launch command and models, with what each model is good at and what it costs, and the thinking levels `--effort` takes, as the agent offered them when last started, for choosing whom to delegate to; writes the defaults (Claude, Codex) there first when there is none, and `splates server` starts conversations with those it lists").option("json", z16.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => agents({
1789
+ splates.command("agents").description("print the roster of agents splates can start, kept for every project in ~/.splates/agents.json: each agent's launch command and models, with what each model is good at and what it costs, and the thinking levels `--effort` takes, as the agent offered them when last started, for choosing whom to delegate to; writes the defaults (Claude, Codex) there first when there is none, and `splates server` starts conversations with those it lists").option("json", z17.boolean(), { default: false, description: "emit JSON" }).action(async (opts, ctx) => agents({
1752
1790
  roster: rosterOf2(ctx),
1753
1791
  offered: await offeredOf(ctx).all(),
1754
1792
  log: ctx.log
1755
1793
  }, { json: opts.json }));
1756
- splates.command("setup").description("set splates up on this machine, one step at a time: with no step, each area's steps, checked from real state, and the one to do next; with a step, why it matters, the one thing the user does and what the agent does. Skips are kept in ~/.splates/setup.json, so a skipped step stops asking").arg("step", z16.string(), {
1794
+ splates.command("setup").description("set splates up on this machine, one step at a time: with no step, each area's steps, checked from real state, and the one to do next; with a step, why it matters, the one thing the user does and what the agent does. Skips are kept in ~/.splates/setup.json, so a skipped step stops asking").arg("step", z17.string(), {
1757
1795
  default: "",
1758
1796
  description: "a step's id; omit for every area's progress"
1759
- }).option("json", z16.boolean(), { default: false, description: "emit JSON" }).option("skip", z16.boolean(), {
1797
+ }).option("json", z17.boolean(), { default: false, description: "emit JSON" }).option("skip", z17.boolean(), {
1760
1798
  default: false,
1761
1799
  description: "with a step: stop asking for it"
1762
- }).option("unskip", z16.boolean(), {
1800
+ }).option("unskip", z17.boolean(), {
1763
1801
  default: false,
1764
1802
  description: "with a step: ask for it again"
1765
- }).option("undo", z16.boolean(), {
1803
+ }).option("undo", z17.boolean(), {
1766
1804
  default: false,
1767
1805
  description: "with an area's id in place of a step: print what undoing it would remove, the creds it forgets and keeps, and what only the user can remove; the Setup page's Undo does it, after the user confirms"
1768
1806
  }).action((opts, ctx) => setup(ctx, opts));
1769
- splates.command("path").use(repository10({ needs: "read" })).description("print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease").arg("task", z16.string(), {
1807
+ splates.command("path").use(repository10({ needs: "read" })).description("print a task's worktree path, or the main tree with no task; names another agent's tree without taking its lease").arg("task", z17.string(), {
1770
1808
  default: "",
1771
1809
  description: "task name; omit for the main tree"
1772
1810
  }).action((opts, ctx) => path(ctx, opts.task || undefined));
1773
1811
  splates.command("checkpoint").use(repository10({ needs: "own" })).description("save this task to the central remote (`remote` in splates.config.ts) before a stop: commit what is uncommitted as a `WIP: checkpoint` commit, to squash before merging, and push the branch as `splates/<task>`, so a worker that dies loses nothing; with no remote configured, leaves the tree as it is").action(async (_opts, ctx) => {
1774
1812
  await checkpoint(ctx, ctx.ps.cwd());
1775
1813
  });
1776
- splates.command("attach").use(repository10({ needs: "own" })).description("store screenshots and recordings for this task's questions to show: prints the path each was stored at, under .git/splates, which the `splates server` page shows inline; from a conversation (its $SPLATES_CONVERSATION, or --conversation) they stay with it past the task, otherwise they go when the task merges or is removed; those the task's final SPLATES.md shows are copied to keep with its finished record. A task holds at most attachmentCapacity bytes; with no files, lists what it holds. A file the user will come back for on its own is an artifact instead (`splates artifact add`)").arg("files", z16.string(), {
1814
+ splates.command("attach").use(repository10({ needs: "own" })).description("store screenshots and recordings for this task's questions to show: prints the path each was stored at, under .git/splates, which the `splates server` page shows inline; from a conversation (its $SPLATES_CONVERSATION, or --conversation) they stay with it past the task, otherwise they go when the task merges or is removed; those the task's final SPLATES.md shows are copied to keep with its finished record. A task holds at most attachmentCapacity bytes; with no files, lists what it holds. A file the user will come back for on its own is an artifact instead (`splates artifact add`)").arg("files", z17.string(), {
1777
1815
  default: "",
1778
1816
  description: "files to store, comma separated"
1779
- }).option("task", z16.string(), {
1817
+ }).option("task", z17.string(), {
1780
1818
  default: "",
1781
1819
  description: "task name, when run outside its worktree"
1782
- }).option("remove", z16.string(), {
1820
+ }).option("remove", z17.string(), {
1783
1821
  default: "",
1784
1822
  description: "files it holds to drop first, comma separated, by path or stored name"
1785
- }).option("conversation", z16.string(), {
1823
+ }).option("conversation", z17.string(), {
1786
1824
  default: "",
1787
1825
  description: "conversation to keep the files with, when $SPLATES_CONVERSATION is not set"
1788
- }).action(async (opts, ctx) => ctx.journal.record("attach", opts.task || await here(ctx), () => attach(ctx, commaList6(opts.files), ctx.ps.cwd(), {
1826
+ }).action(async (opts, ctx) => ctx.journal.record("attach", opts.task || await here(ctx), () => attach(ctx, commaList5(opts.files), ctx.ps.cwd(), {
1789
1827
  task: opts.task || undefined,
1790
- remove: commaList6(opts.remove),
1828
+ remove: commaList5(opts.remove),
1791
1829
  conversation: opts.conversation || undefined
1792
- }), (stored) => ({ stored, removed: commaList6(opts.remove) })));
1793
- splates.command("retry").use(repository10({ needs: "own" })).description("give a task that spent its merge attempts another mergeRetryCount of them; the way out of `budget_exhausted` that is not remove and redo, for a person to run once they have seen the tree").arg("task", z16.string(), { description: "task name" }).action((opts, ctx) => recordRetry(ctx, opts.task));
1794
- splates.mount("artifact", splatesArtifact).mount("task", splatesTask).mount("lane", splatesLane).mount("projects", splatesProjects).mount("creds", splatesCreds).mount("tunnel", splatesTunnel).mount("slots", splatesSlots).mount("worker", splatesWorker).mount("queue", splatesQueue).mount("hook", splatesHook).mount("budget", splatesBudget);
1830
+ }), (stored) => ({ stored, removed: commaList5(opts.remove) })));
1831
+ splates.command("retry").use(repository10({ needs: "own" })).description("give a task that spent its merge attempts another mergeRetryCount of them; the way out of `budget_exhausted` that is not remove and redo, for a person to run once they have seen the tree").arg("task", z17.string(), { description: "task name" }).action((opts, ctx) => recordRetry(ctx, opts.task));
1832
+ splates.mount("artifact", splatesArtifact).mount("task", splatesTask).mount("lane", splatesLane).mount("projects", splatesProjects).mount("creds", splatesCreds).mount("tunnel", splatesTunnel).mount("slots", splatesSlots).mount("worker", splatesWorker).mount("runners", splatesRunners).mount("queue", splatesQueue).mount("hook", splatesHook).mount("budget", splatesBudget).mount("replica", splatesReplica);
1795
1833
 
1796
1834
  // index.ts
1797
1835
  if (basename2(process.argv[1] ?? "") === LEGACY) {
@@ -1799,4 +1837,8 @@ if (basename2(process.argv[1] ?? "") === LEGACY) {
1799
1837
  }
1800
1838
  var ps = systemPs();
1801
1839
  var { project, argv } = projectArgs(ps.args);
1840
+ var handedOff = await handOffDev(argv, ps.cwd());
1841
+ if (handedOff !== null) {
1842
+ process.exit(handedOff);
1843
+ }
1802
1844
  await splates.run({ fs: new NodeFs, ps, assets, project }, argv);