passalong 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/passalong.js CHANGED
@@ -8,6 +8,7 @@ import { basename, join } from "node:path";
8
8
  import * as api from "./api.js";
9
9
  import { context } from "./capture.js";
10
10
  import { refusal, runChecks } from "./checks.js";
11
+ import * as runner from "./runner.js";
11
12
  import * as store from "./store.js";
12
13
  import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
13
14
 
@@ -48,11 +49,11 @@ export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}
48
49
  // below is what refuses work that names itself.
49
50
  if (parent) work.meta.parent = await resolveId(String(parent).trim());
50
51
  if (work.meta.to && !work.meta.team)
51
- throw new PassalongError("`to:` needs a team — address it as team/@handle");
52
+ throw new PassalongError("`to:` needs a team: address it as team/@handle");
52
53
  const errors = [
53
54
  ...unheldFields(markdown).map(
54
55
  (k) =>
55
- `frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped — move it into the body (a fenced yaml block keeps it as written)`,
56
+ `frontmatter "${k}" holds more than a string or a list of strings, and it would be dropped: move it into the body (a fenced yaml block keeps it as written)`,
56
57
  ),
57
58
  ...validate(work),
58
59
  ];
@@ -212,7 +213,7 @@ function knownAgent(cwd = process.cwd()) {
212
213
  function needsSync(what) {
213
214
  if (!api.loggedIn())
214
215
  throw new PassalongError(
215
- `${what} needs sync — work lives on the server (run \`passalong login\`)`,
216
+ `${what} needs sync: work lives on the server (run \`passalong login\`)`,
216
217
  );
217
218
  }
218
219
 
@@ -273,7 +274,7 @@ function requireEvidence(evidence) {
273
274
  const said = String(evidence || "").trim();
274
275
  if (said.length >= 16) return;
275
276
  throw new PassalongError(
276
- "send `evidence`: what you ran and what came back — the command and the lines that decided " +
277
+ "send `evidence`: what you ran and what came back, with the command and the lines that decided " +
277
278
  "it, a test summary, a link to the change, or a screenshot url. " +
278
279
  `"${said || "nothing"}" is a claim, not evidence.`,
279
280
  );
@@ -377,8 +378,12 @@ export async function assign(ref, to) {
377
378
  return api.assign(id, target);
378
379
  }
379
380
 
380
- /** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
381
- export async function pass(id, why, { cwd = process.cwd() } = {}) {
381
+ /**
382
+ * Not this agent's to do: what it held is open again, and `why` goes to whoever is next. `runner`
383
+ * says the runner is giving it back, unfinished: the server then clears its Safe to run alone tick,
384
+ * so it waits for a person rather than going straight back to an unattended agent.
385
+ */
386
+ export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}) {
382
387
  needsSync("passing work");
383
388
  if (!String(why || "").trim())
384
389
  throw new PassalongError("say why you are passing it, so whoever is next knows");
@@ -387,6 +392,7 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
387
392
  agent: agent(cwd).agent,
388
393
  why,
389
394
  ...(fence === undefined ? {} : { fence }),
395
+ ...(runner ? { runner: true } : {}),
390
396
  });
391
397
  }
392
398
 
@@ -395,7 +401,7 @@ export async function pass(id, why, { cwd = process.cwd() } = {}) {
395
401
  * and not `progress`, which says carry on: the answer to this one is "stop, and take it again when
396
402
  * they have replied", and the reply comes back with that `take`.
397
403
  */
398
- export async function ask(id, question, { cwd = process.cwd() } = {}) {
404
+ export async function ask(id, question, { cwd = process.cwd(), choices } = {}) {
399
405
  needsSync("asking");
400
406
  if (!String(question || "").trim())
401
407
  throw new PassalongError("say what you need to know, so the person can answer it");
@@ -404,6 +410,8 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
404
410
  agent: agent(cwd).agent,
405
411
  question,
406
412
  ...(fence === undefined ? {} : { fence }),
413
+ // Options the person picks from with a tap in the hub; the pick comes back as the reply.
414
+ ...(Array.isArray(choices) && choices.length ? { choices } : {}),
407
415
  });
408
416
  }
409
417
 
@@ -517,7 +525,7 @@ export async function attachBytes(data, { name = "", type = "image/png" } = {})
517
525
  needsSync("adding a file");
518
526
  const allowed = new Set(Object.values(SHOT_TYPES));
519
527
  if (!allowed.has(type))
520
- throw new PassalongError(`pictures must be ${[...allowed].join(", ")} — ${type} is not one`);
528
+ throw new PassalongError(`pictures must be ${[...allowed].join(", ")}: ${type} is not one`);
521
529
  // A data: URL is what several browser tools hand back, so it is accepted rather than made the
522
530
  // caller's problem to strip.
523
531
  const bytes = Buffer.from(String(data).replace(/^data:[^,]*,/, ""), "base64");
@@ -525,7 +533,7 @@ export async function attachBytes(data, { name = "", type = "image/png" } = {})
525
533
  // The server's own ceiling, checked here so a large paste fails before it is sent.
526
534
  if (bytes.length > 5 * 1024 * 1024)
527
535
  throw new PassalongError(
528
- `that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB — write it to a file and pass its path`,
536
+ `that image is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 5MB: write it to a file and pass its path`,
529
537
  );
530
538
  const label = name || "screenshot";
531
539
  const shot = await api.uploadShot(bytes, type, label);
@@ -544,7 +552,7 @@ export async function attach(file, { name = "" } = {}) {
544
552
  const type = SHOT_TYPES[ext];
545
553
  if (!type)
546
554
  throw new PassalongError(
547
- `pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")} — ${ext || file} is not one`,
555
+ `pictures must be ${[...new Set(Object.values(SHOT_TYPES))].join(", ")}: ${ext || file} is not one`,
548
556
  );
549
557
  const shot = await api.uploadShot(readFileSync(file), type, name || basename(file));
550
558
  return { ...shot, markdown: `![${name || basename(file)}](${shot.url})` };
@@ -578,7 +586,7 @@ export async function attachFile(file, { name = "" } = {}) {
578
586
  if (!bytes.length) throw new PassalongError(`${file} is empty`);
579
587
  if (bytes.length > FILE_MAX)
580
588
  throw new PassalongError(
581
- `${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB — send a link instead`,
589
+ `${file} is ${Math.round(bytes.length / 1024 / 1024)}MB and the limit is 10MB: send a link instead`,
582
590
  );
583
591
  const ext = file.includes(".") ? file.slice(file.lastIndexOf(".")).toLowerCase() : "";
584
592
  const label = (name || basename(file)).replace(/[[\]()]/g, "");
@@ -612,7 +620,7 @@ export async function reply(ref, text, { files = [] } = {}) {
612
620
  * work. Neither is a second document handed to one person. Mirrors AFTER in apps/api/src/mcp-http.ts.
613
621
  */
614
622
  export const AFTER =
615
- "<!-- passalong: what you learn doing this — how something works, a decision and why — goes " +
623
+ "<!-- passalong: what you learn doing this (how something works, a decision and why) goes " +
616
624
  "into the project's documents (find_projects, read_document, save_document), not into a new " +
617
625
  "piece of work. Work somebody else should now do is create_work with `parent` set to this id. " +
618
626
  "What you did and how you checked it goes on hand_in. -->";
@@ -625,6 +633,35 @@ export const AFTER =
625
633
  * worth failing the call over: the work itself is what was asked for. Mirrors parentOf() in
626
634
  * apps/api/src/mcp-http.ts.
627
635
  */
636
+ /**
637
+ * What to know before starting, as the server hands it over on take: each project the work is filed
638
+ * in, its README and the names of its other documents — or "" when it is in none. It sits in front
639
+ * of the work, after the work it came out of. Mirrors projectsNote in apps/api/src/mcp-http.ts.
640
+ */
641
+ export function projectsNote(projects) {
642
+ if (!Array.isArray(projects) || !projects.length) return "";
643
+ const blocks = projects.map((p) => {
644
+ const name = p.title || p.id;
645
+ const where = `${name} (${p.id})${p.repo ? `, built from ${p.repo}` : ""}`;
646
+ const about = p.description ? ` ${p.description}` : "";
647
+ const lead = p.readme
648
+ ? `WHAT TO KNOW FIRST: this work is filed in the project ${where}.${about} Its README is ` +
649
+ "below: read it before you start."
650
+ : `WHAT TO KNOW FIRST: this work is filed in the project ${where}.${about} It has no README yet.`;
651
+ const readme = p.readme
652
+ ? `\n\n--- README of ${name}: document ${p.readme.id} ---\n${p.readme.body.trimEnd()}\n` +
653
+ (p.readme.cut ? `…it goes on: read_document ${p.readme.id} for the rest.\n` : "")
654
+ : "\n";
655
+ const docs = p.documents?.length
656
+ ? "Its other documents: open the ones that bear on this with read_document: " +
657
+ `${p.documents.map((d) => `"${d.name}" (${d.id})`).join(", ")}` +
658
+ `${p.more ? `, and ${p.more} more (find_projects id ${p.id})` : ""}.\n`
659
+ : "";
660
+ return `${lead}${readme}${docs}`;
661
+ });
662
+ return `${blocks.join("\n")}\n`;
663
+ }
664
+
628
665
  export async function parentWork(meta = {}, { parent = api.parent, loggedIn = api.loggedIn } = {}) {
629
666
  if (!meta.id || !meta.parent || !loggedIn()) return "";
630
667
  try {
@@ -633,7 +670,7 @@ export async function parentWork(meta = {}, { parent = api.parent, loggedIn = ap
633
670
  const who = work.by?.name || (work.by?.handle ? `@${work.by.handle}` : "");
634
671
  const state = work.state === "held" && who ? `held by ${who}` : work.state || "open";
635
672
  return (
636
- `THIS CAME OUT OF ${work.id}: ${work.title || "untitled"} — ${state}. It is part of that ` +
673
+ `THIS CAME OUT OF ${work.id}: ${work.title || "untitled"} (${state}). It is part of that ` +
637
674
  "work, which is below; read it first. If this depends on " +
638
675
  `${work.id} being done and it is not, say so rather than starting.\n\n` +
639
676
  `--- the work it came out of: ${work.id} ---\n${String(work.markdown ?? "").trimEnd()}\n\n`
@@ -674,17 +711,17 @@ export function workPrompt(t) {
674
711
  t.markdown.trim(),
675
712
  "",
676
713
  "Do it in this repo: read it, work out what done means, and get there. Call the passalong MCP",
677
- `tool progress with id ${t.id} and a one-line status at each milestone — 30 minutes of`,
714
+ `tool progress with id ${t.id} and a one-line status at each milestone; 30 minutes of`,
678
715
  "silence stalls it. When it is done, commit what you changed as one commit whose message",
679
- `starts with "${t.id}: " — several pieces of work share this worktree, and a person reads each`,
716
+ `starts with "${t.id}: ": several pieces of work share this worktree, and a person reads each`,
680
717
  `one's change on its own. Then call hand_in with id ${t.id}, \`pr\` set to that commit's hash,`,
681
718
  "`note`: one plain sentence saying what you did, and `evidence`: what you ran and what came",
682
- "back — the commands and the lines that decided it, the test summary, the commit. Keep that",
719
+ "back, with the commands and the lines that decided it, the test summary, the commit. Keep that",
683
720
  "as you go; at the end you would be writing it from memory. What you learned about the",
684
721
  "project goes into its documents (save_document). If a call says you no longer hold it, stop.",
685
722
  "",
686
723
  "Nobody reads what you print: this session runs unattended. If something only a person can",
687
- "do stands between you and done — a permission, a secret, a decision — call",
724
+ "do stands between you and done (a permission, a secret, a decision), call",
688
725
  `progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you`,
689
726
  "need, leave it unfinished and uncommitted, and stop. The board shows that note.",
690
727
  ].join("\n");
@@ -701,34 +738,20 @@ export function workPrompt(t) {
701
738
  * An agent that exits without finishing stops the loop, rather than piling up half-done work. It
702
739
  * stays held here, for a person to see on the board and resume (take it) or release.
703
740
  *
704
- * `watch` is the runner (docs/LOOP.md §1a): left running on an always-on machine with the repo
705
- * checked out, it takes only work a person marked safe to run alone, waits `every` seconds when
706
- * there is none, and keeps going past work its agent did not finish — that stays held here, the
707
- * sweep puts it back in the queue once it goes quiet, and a second time a person is told it is
708
- * stuck. Nobody is watching, so nothing stops for a person.
741
+ * The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
709
742
  */
710
743
  export async function work({
711
744
  cwd = process.cwd(),
712
745
  agent = process.env.PASSALONG_AGENT || "claude -p",
713
746
  any = false,
714
747
  once = false,
715
- watch = false,
716
- every = 60,
717
748
  onTask = () => {},
718
- onIdle = () => {},
719
- onUnfinished = () => {},
720
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
721
749
  } = {}) {
722
750
  needsSync("working the queue");
723
751
  const [command, ...args] = agent.trim().split(/\s+/);
724
752
  const finished = [];
725
753
  for (;;) {
726
- const t = await nextWork({ cwd, any, alone: watch });
727
- if (!t && watch) {
728
- onIdle();
729
- await sleep(Math.max(10, every) * 1000);
730
- continue;
731
- }
754
+ const t = await nextWork({ cwd, any });
732
755
  if (!t) return { finished, stopped: "empty" };
733
756
  onTask(t);
734
757
  const run = spawnSync(command, [...args, workPrompt(t)], {
@@ -737,10 +760,6 @@ export async function work({
737
760
  env: { ...process.env, PASSALONG_TASK: t.id },
738
761
  });
739
762
  const state = (await api.tasks()).tasks.find((x) => x.id === t.id)?.state;
740
- if (state !== "done" && watch) {
741
- onUnfinished(t, state, run.status ?? run.error?.message);
742
- continue;
743
- }
744
763
  if (state !== "done")
745
764
  return {
746
765
  finished,
@@ -754,6 +773,279 @@ export async function work({
754
773
  }
755
774
  }
756
775
 
776
+ /**
777
+ * The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
778
+ * a git worktree of its own beside this checkout, on its own branch, by an agent that finishes with
779
+ * a pull request. Up to `atOnce` at a time and `perDay` a day; a run that ends without the work done
780
+ * passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
781
+ *
782
+ * A worktree is made before asking the queue, because taking work is done by a worktree's agent and
783
+ * the agent that takes it must be the one that does it. When the queue has nothing, the worktree
784
+ * goes again.
785
+ */
786
+ export async function watch({
787
+ cwd = process.cwd(),
788
+ agent: command,
789
+ every = 60,
790
+ atOnce = runner.LIMITS.atOnce,
791
+ perDay = runner.LIMITS.perDay,
792
+ minutes = runner.LIMITS.minutes,
793
+ cli,
794
+ git = runner.git,
795
+ runAgent = runner.runAgent,
796
+ now = () => Date.now(),
797
+ countFile = join(store.HOME, "runner.json"),
798
+ onTask = () => {},
799
+ onIdle = () => {},
800
+ onCapped = () => {},
801
+ onFinished = () => {},
802
+ onPassed = () => {},
803
+ onWaiting = () => {},
804
+ onResumed = () => {},
805
+ onTrouble = () => {},
806
+ report = api.runnerStatus,
807
+ signals = false,
808
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
809
+ } = {}) {
810
+ needsSync("running work");
811
+ const { root, repo } = context(cwd);
812
+ if (!root) throw new PassalongError("run the runner inside a git checkout of the repo");
813
+ let remote = "";
814
+ try {
815
+ remote = git(["-C", root, "remote", "get-url", "origin"]);
816
+ } catch {}
817
+ if (!remote)
818
+ throw new PassalongError(
819
+ "this checkout has no `origin` remote: the runner pushes each change to a branch there",
820
+ );
821
+ const runs = runner.runsDirOf(root);
822
+ mkdirSync(runs, { recursive: true });
823
+
824
+ // What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
825
+ // nobody has to look at this terminal to know. A failed report never stops a run.
826
+ const onNow = new Map();
827
+ const say = (stopped = false) =>
828
+ Promise.resolve(
829
+ report({
830
+ host: hostname(),
831
+ repo,
832
+ running: [...onNow].map(([id, title]) => ({ id, title })),
833
+ runs_today: runner.runsToday(countFile, runner.dayOf(now())),
834
+ per_day: perDay,
835
+ at_once: atOnce,
836
+ stopped,
837
+ }),
838
+ ).catch(() => {});
839
+ // Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
840
+ // terminal; what they held stays held, and the sweep puts it back once it goes quiet.
841
+ if (signals)
842
+ process.once("SIGINT", async () => {
843
+ await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
844
+ process.exit(130);
845
+ });
846
+ const agentRun = command
847
+ ? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
848
+ : runner.claudeCommand({ cli });
849
+
850
+ /**
851
+ * One slot: a worktree and a piece of work taken for it, with its agent started. `null` when
852
+ * nothing waits. The run itself is handed back unawaited, so several go at once.
853
+ */
854
+ async function startOne(n) {
855
+ try {
856
+ git(["-C", root, "fetch", "--quiet", "origin"]);
857
+ } catch {}
858
+ let base = "HEAD";
859
+ try {
860
+ base = git(["-C", root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
861
+ ? "origin/HEAD"
862
+ : base;
863
+ } catch {}
864
+ const dir = join(runs, `run-${now()}-${n}`);
865
+ git(["-C", root, "worktree", "add", "--detach", "--quiet", dir, base]);
866
+ let t = null;
867
+ try {
868
+ t = await nextWork({ cwd: dir, alone: true });
869
+ } catch (err) {
870
+ onTrouble(err);
871
+ }
872
+ if (!t) {
873
+ git(["-C", root, "worktree", "remove", "--force", dir]);
874
+ return null;
875
+ }
876
+ // Its branch, or a fresh one beside it when that one is still checked out in the worktree of
877
+ // an earlier run that didn't finish (kept for its author), as when a person ticks it again.
878
+ let branch = runner.branchOf(t.id);
879
+ try {
880
+ try {
881
+ git(["-C", dir, "switch", "--quiet", "-C", branch]);
882
+ } catch {
883
+ branch = `${branch}-${now().toString(36)}`;
884
+ git(["-C", dir, "switch", "--quiet", "-c", branch]);
885
+ }
886
+ } catch (err) {
887
+ // Taken and then stuck before any agent started: never left held with nobody on it.
888
+ const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
889
+ await pass(t.id, why, { cwd: dir, runner: true }).catch((e) => onTrouble(e, t));
890
+ onPassed(t, { why, dir, log: "" });
891
+ return { run: Promise.resolve() };
892
+ }
893
+ runner.countRun(countFile, runner.dayOf(now()));
894
+ onNow.set(t.id, t.title || t.id);
895
+ onTask(t, { dir, branch });
896
+ say();
897
+ return {
898
+ run: finish(t, dir, branch).finally(() => {
899
+ onNow.delete(t.id);
900
+ say();
901
+ }),
902
+ };
903
+ }
904
+
905
+ async function finish(t, dir, branch, { resumed = false } = {}) {
906
+ const log = join(runs, `${t.id}.log`);
907
+ const ran = await runAgent({
908
+ ...agentRun,
909
+ prompt: resumed ? runner.resumePrompt(t, branch) : runner.runnerPrompt(t, branch),
910
+ cwd: dir,
911
+ log,
912
+ minutes,
913
+ env: { ...process.env, PASSALONG_TASK: t.id },
914
+ });
915
+ let held = null;
916
+ try {
917
+ held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
918
+ } catch {}
919
+ if (held?.state === "done") {
920
+ try {
921
+ git(["-C", root, "worktree", "remove", "--force", dir]);
922
+ } catch {}
923
+ onFinished(t, { branch, log });
924
+ return;
925
+ }
926
+ // It stopped to ask, and the question is waiting on a person: not given up. The work stays held
927
+ // here with its worktree, and the run picks up again once they answer (resumeAnswered).
928
+ if (held?.claim?.asking) {
929
+ waiting.set(t.id, { t, dir, branch });
930
+ onWaiting(t, { question: held.claim.asking, dir });
931
+ return;
932
+ }
933
+ const why = runner.passBack({
934
+ minutes,
935
+ ...ran,
936
+ note: held?.claim?.note || "",
937
+ said: runner.lastLine(log),
938
+ dir,
939
+ branch,
940
+ });
941
+ try {
942
+ await pass(t.id, why, { cwd: dir, runner: true });
943
+ onPassed(t, { why, dir, log });
944
+ } catch (err) {
945
+ onTrouble(err, t);
946
+ }
947
+ }
948
+
949
+ const running = new Set();
950
+ /** Runs stopped on a question, by work id: their worktree and branch, kept to resume in. */
951
+ const waiting = new Map();
952
+ /** Start an agent again where it stopped: the same worktree, branch and claim. */
953
+ function resume(t, dir, branch) {
954
+ runner.countRun(countFile, runner.dayOf(now()));
955
+ onNow.set(t.id, t.title || t.id);
956
+ onResumed(t, { dir, branch });
957
+ say();
958
+ const run = finish(t, dir, branch, { resumed: true })
959
+ .catch((err) => onTrouble(err, t))
960
+ .finally(() => {
961
+ onNow.delete(t.id);
962
+ running.delete(run);
963
+ say();
964
+ });
965
+ running.add(run);
966
+ }
967
+
968
+ /**
969
+ * Questions answered since the last look start their run again, while there is a slot and the
970
+ * day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
971
+ */
972
+ async function resumeAnswered() {
973
+ if (!waiting.size) return;
974
+ let rows = [];
975
+ try {
976
+ rows = (await api.tasks()).tasks;
977
+ } catch (err) {
978
+ return onTrouble(err);
979
+ }
980
+ for (const [id, w] of [...waiting]) {
981
+ const row = rows.find((x) => x.id === id);
982
+ if (row?.state === "done") {
983
+ waiting.delete(id);
984
+ try {
985
+ git(["-C", root, "worktree", "remove", "--force", w.dir]);
986
+ } catch {}
987
+ onFinished(w.t, { branch: w.branch, log: join(runs, `${id}.log`) });
988
+ continue;
989
+ }
990
+ if (!row?.claim) {
991
+ waiting.delete(id);
992
+ onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
993
+ continue;
994
+ }
995
+ if (row.claim.asking) continue;
996
+ if (running.size >= atOnce) return;
997
+ if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
998
+ waiting.delete(id);
999
+ resume(w.t, w.dir, w.branch);
1000
+ }
1001
+ }
1002
+
1003
+ // A runner started again finds the questions its earlier runs left waiting: their worktrees are
1004
+ // still beside the checkout, and the work is still held by them.
1005
+ try {
1006
+ for (const row of (await api.tasks()).tasks) {
1007
+ const leaf = row.claim?.worktree || "";
1008
+ const dir = leaf.startsWith("run-") ? join(runs, leaf) : "";
1009
+ if (!dir || !existsSync(dir) || row.state === "done") continue;
1010
+ let branch = runner.branchOf(row.id);
1011
+ try {
1012
+ branch = git(["-C", dir, "branch", "--show-current"]) || branch;
1013
+ } catch {}
1014
+ waiting.set(row.id, { t: { id: row.id, title: row.title, markdown: "" }, dir, branch });
1015
+ }
1016
+ } catch {}
1017
+
1018
+ let n = 0;
1019
+ let cappedOn = "";
1020
+ for (;;) {
1021
+ await resumeAnswered();
1022
+ while (running.size < atOnce) {
1023
+ const day = runner.dayOf(now());
1024
+ if (runner.runsToday(countFile, day) >= perDay) {
1025
+ if (cappedOn !== day) onCapped(perDay);
1026
+ cappedOn = day;
1027
+ break;
1028
+ }
1029
+ let slot;
1030
+ try {
1031
+ slot = await startOne(++n);
1032
+ } catch (err) {
1033
+ onTrouble(err);
1034
+ break;
1035
+ }
1036
+ if (!slot) {
1037
+ if (!running.size) onIdle(repo);
1038
+ break;
1039
+ }
1040
+ const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
1041
+ running.add(run);
1042
+ }
1043
+ // Wait for a run to end or for the next look, whichever is first.
1044
+ await say();
1045
+ await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
1046
+ }
1047
+ }
1048
+
757
1049
  /**
758
1050
  * Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
759
1051
  * moment a planner has written several and you have read them all. Only your own: sending work on