passalong 1.4.0 → 1.6.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
@@ -2,13 +2,20 @@
2
2
  // so anything an agent can do through MCP a human can do from the terminal and vice versa.
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { randomBytes } from "node:crypto";
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ readdirSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ } from "node:fs";
6
14
  import { hostname } from "node:os";
7
15
  import { basename, join } from "node:path";
8
16
  import * as api from "./api.js";
9
- import { context } from "./capture.js";
17
+ import { context, openPullRequest } from "./capture.js";
10
18
  import { refusal, runChecks } from "./checks.js";
11
- import * as runner from "./runner.js";
12
19
  import * as store from "./store.js";
13
20
  import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
14
21
 
@@ -38,6 +45,11 @@ export async function publish(markdown, { cwd = process.cwd(), to, parent } = {}
38
45
  author: c.author,
39
46
  source_context: c.branch && c.branch !== "HEAD" ? `${c.name}@${c.branch}` : c.name,
40
47
  });
48
+ // Written inside a repo and not saying which repo it is for: it is for this one. Only `new
49
+ // "<text>"` used to fill it in, so work written from a file was for no repo — no agent there was
50
+ // offered it, and it never reached its project (2026-11-07). A file that names one, or names none
51
+ // with an empty `target_context:`, is taken at its word.
52
+ if (!Object.hasOwn(work.meta, "target_context") && c.repo) work.meta.target_context = c.repo;
41
53
  const target = parseTarget(to);
42
54
  if (target.team) work.meta.team = target.team;
43
55
  if (target.to) work.meta.to = target.to;
@@ -219,12 +231,11 @@ function needsSync(what) {
219
231
 
220
232
  /**
221
233
  * Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
222
- * several. `null` when there is nothing to do. `alone` asks only for work a person marked safe to
223
- * run unattended (the runner).
234
+ * several. `null` when there is nothing to do.
224
235
  */
225
- export async function nextWork({ cwd = process.cwd(), any = false, alone = false } = {}) {
236
+ export async function nextWork({ cwd = process.cwd(), any = false } = {}) {
226
237
  needsSync("taking work");
227
- const { task } = await api.nextTask({ ...agent(cwd), any, ...(alone ? { alone: true } : {}) });
238
+ const { task } = await api.nextTask({ ...agent(cwd), any });
228
239
  return task || null;
229
240
  }
230
241
 
@@ -263,7 +274,7 @@ function rememberFence(id, fence, cwd) {
263
274
  writeFileSync(fenceFile(cwd), `${JSON.stringify(held, null, 2)}\n`);
264
275
  }
265
276
 
266
- /** A check a runner executed: the command it ran, and what the process returned. See checks.js. */
277
+ /** A check that was actually run: the command, and what the process returned. See checks.js. */
267
278
  const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
268
279
 
269
280
  /**
@@ -327,7 +338,7 @@ export async function handIn(
327
338
  ) {
328
339
  needsSync("handing work in");
329
340
  // A check that names a command is run here, before anything is sent. Checks that already carry
330
- // an exit came from the MCP server's own runner and are left alone, so nothing runs twice.
341
+ // an exit were already run by the MCP server, and are not run again, so nothing runs twice.
331
342
  const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
332
343
  if (pending.length) {
333
344
  const done = runChecks(pending, { cwd });
@@ -341,6 +352,9 @@ export async function handIn(
341
352
  // whose command was actually run is exempt from the length rule — its exit code is the evidence.
342
353
  if (checks.length) checks.forEach((c) => (verified(c) ? null : requireEvidence(c?.ran)));
343
354
  else requireEvidence(evidence);
355
+ // Where the change is, for the person reviewing it: the agent's link, or this branch's open pull
356
+ // request when it gave none.
357
+ if (!pr) pr = openPullRequest(cwd);
344
358
  const fence = fenceFor(id, cwd);
345
359
  return api.handIn(id, {
346
360
  agent: agent(cwd).agent,
@@ -379,11 +393,9 @@ export async function assign(ref, to) {
379
393
  }
380
394
 
381
395
  /**
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.
396
+ * Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
385
397
  */
386
- export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}) {
398
+ export async function pass(id, why, { cwd = process.cwd() } = {}) {
387
399
  needsSync("passing work");
388
400
  if (!String(why || "").trim())
389
401
  throw new PassalongError("say why you are passing it, so whoever is next knows");
@@ -392,7 +404,6 @@ export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}
392
404
  agent: agent(cwd).agent,
393
405
  why,
394
406
  ...(fence === undefined ? {} : { fence }),
395
- ...(runner ? { runner: true } : {}),
396
407
  });
397
408
  }
398
409
 
@@ -420,7 +431,6 @@ export function needsWhy(t) {
420
431
  if (!t.mine) return "";
421
432
  if (t.state === "draft") return "in Draft: read it and mark it Ready";
422
433
  if (t.state === "stalled") return "its agent went quiet";
423
- if (t.state === "review") return "the runner finished it: ready for your review";
424
434
  if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
425
435
  if (/^BLOCKED:/i.test(t.claim?.note || ""))
426
436
  return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
@@ -490,6 +500,111 @@ export async function now({ cwd = process.cwd() } = {}) {
490
500
  };
491
501
  }
492
502
 
503
+ /**
504
+ * What someone wrote on work this worktree's agent holds that the agent has not been told, for the
505
+ * hook that runs after each of Claude Code's tool calls (hooks.js). Asking is a `progress` with no
506
+ * note: it renews the hold, hands over the unread replies and marks them read (claims.deliver in
507
+ * apps/api), so a reply reaches the agent at its next step instead of its next progress call.
508
+ *
509
+ * At most once every `every` ms per worktree, checked against a file's age before any network, so
510
+ * a session making tool calls back to back asks the server a few times a minute, not on every call.
511
+ * Read, never minted, like now(): a worktree that never took anything is never asked about.
512
+ */
513
+ export async function liveReplies({
514
+ cwd = process.cwd(),
515
+ every = 15_000,
516
+ at = Date.now(),
517
+ session = "",
518
+ } = {}) {
519
+ if (!api.loggedIn()) return [];
520
+ const who = knownAgent(cwd);
521
+ if (!who.agent) return [];
522
+ // This session is working here: what the session-end hook reads to leave other sessions' work.
523
+ if (session) markSession(cwd, session, at);
524
+ const stamp = join(context(cwd).root || cwd, ".passalong", "replies-checked");
525
+ try {
526
+ if (at - statSync(stamp).mtimeMs < every) return [];
527
+ } catch {}
528
+ try {
529
+ writeFileSync(stamp, `${new Date(at).toISOString()}\n`);
530
+ } catch {
531
+ return [];
532
+ }
533
+ const { working } = await api.working();
534
+ // Work with replies waiting, and work whose hold ends soon: an agent busy on a long run that posts
535
+ // no progress read as gone quiet after thirty minutes while it worked (2026-11-07). Not a hold
536
+ // waiting on an answer that has none yet: it runs for a day, and a renewal would cut it short.
537
+ const soon = new Date(at + RENEW_WITHIN_MS).toISOString();
538
+ const waiting = working.filter(
539
+ (x) =>
540
+ x.agent === who.agent &&
541
+ x.by?.you &&
542
+ (x.replies > 0 || (!x.asking && String(x.lease_until || "") < soon)),
543
+ );
544
+ const found = [];
545
+ for (const h of waiting) {
546
+ const fence = fenceFor(h.id, cwd);
547
+ const r = await api
548
+ .progress(h.id, { agent: who.agent, ...(fence === undefined ? {} : { fence }) })
549
+ .catch(() => null);
550
+ if (r?.replies?.length) found.push({ id: h.id, title: h.title, replies: r.replies });
551
+ }
552
+ return found;
553
+ }
554
+
555
+ /** A hold is renewed by the tool hook once it has less than this left. Holds last 30 minutes. */
556
+ export const RENEW_WITHIN_MS = 20 * 60 * 1000;
557
+
558
+ /** How recently another session must have worked here for the session-end hook to leave its work. */
559
+ const SESSION_FRESH_MS = 10 * 60 * 1000;
560
+
561
+ const sessionsDir = (cwd) => join(context(cwd).root || cwd, ".passalong", "sessions");
562
+ const sessionFile = (cwd, session) =>
563
+ join(
564
+ sessionsDir(cwd),
565
+ String(session)
566
+ .replace(/[^A-Za-z0-9_-]/g, "")
567
+ .slice(0, 80),
568
+ );
569
+
570
+ function markSession(cwd, session, at) {
571
+ try {
572
+ mkdirSync(sessionsDir(cwd), { recursive: true });
573
+ writeFileSync(sessionFile(cwd, session), `${new Date(at).toISOString()}\n`);
574
+ } catch {}
575
+ }
576
+
577
+ /** Whether a session other than `session` worked in this worktree in the last few minutes. */
578
+ export function otherSessions(cwd, session, at = Date.now()) {
579
+ const mine = sessionFile(cwd, session);
580
+ try {
581
+ return readdirSync(sessionsDir(cwd)).some((f) => {
582
+ const path = join(sessionsDir(cwd), f);
583
+ return path !== mine && at - statSync(path).mtimeMs < SESSION_FRESH_MS;
584
+ });
585
+ } catch {
586
+ return false;
587
+ }
588
+ }
589
+
590
+ /**
591
+ * A session ended: what it still holds goes back to the queue, with the reason (hooks.js
592
+ * endDecision says what is left alone and why). Returns the ids given back.
593
+ */
594
+ export async function endSession({ cwd = process.cwd(), input = {} } = {}) {
595
+ const { endDecision, ENDED } = await import("./hooks.js");
596
+ const session = String(input.session_id || "");
597
+ const { held } = await now({ cwd });
598
+ const ids = endDecision({ held, input, others: session ? otherSessions(cwd, session) : false });
599
+ const gone = [];
600
+ for (const id of ids) {
601
+ const r = await pass(id, ENDED, { cwd }).catch(() => null);
602
+ if (r) gone.push(id);
603
+ }
604
+ if (session) rmSync(sessionFile(cwd, session), { force: true });
605
+ return gone;
606
+ }
607
+
493
608
  /** Finished work back to To do, with why. */
494
609
  export async function reopen(id, why) {
495
610
  needsSync("reopening work");
@@ -738,8 +853,6 @@ export function workPrompt(t) {
738
853
  *
739
854
  * An agent that exits without finishing stops the loop, rather than piling up half-done work. It
740
855
  * stays held here, for a person to see on the board and resume (take it) or release.
741
- *
742
- * The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
743
856
  */
744
857
  export async function work({
745
858
  cwd = process.cwd(),
@@ -774,428 +887,6 @@ export async function work({
774
887
  }
775
888
  }
776
889
 
777
- /**
778
- * The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
779
- * a git worktree of its own beside its checkout, on its own branch, by an agent that finishes with
780
- * a pull request. Up to `atOnce` at a time, with no count a day; a run that ends without the work
781
- * done passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
782
- *
783
- * When the agent's usage runs out (runner.usageOut) its work is not given back: it stays held here
784
- * with its worktree, the runner takes nothing new, and once the usage is back (when the agent said,
785
- * or every `USAGE_RETRY_MS`) it carries on where it stopped.
786
- *
787
- * Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
788
- * one with an `origin` remote, so one terminal does what six used to. The folder is checked before
789
- * anything is said (`onStart`), so a runner that cannot run says why instead of what it will do.
790
- *
791
- * Work is asked for before a worktree is made: the agent's id is minted first and written into the
792
- * worktree once there is work for it, so a runner serving many repos does not make and remove a
793
- * worktree in each of them every minute. What the agent does is read as it goes (runner.describe)
794
- * and sent to the hub every few seconds as steps, and to `onStep` for the terminal.
795
- */
796
- export async function watch({
797
- cwd = process.cwd(),
798
- agent: command,
799
- every = 60,
800
- atOnce = runner.LIMITS.atOnce,
801
- minutes = runner.LIMITS.minutes,
802
- cli,
803
- git = runner.git,
804
- runAgent = runner.runAgent,
805
- now = () => Date.now(),
806
- countFile = join(store.HOME, "runner.json"),
807
- machine = runner.machineName,
808
- onStart = () => {},
809
- onTask = () => {},
810
- onStep = () => {},
811
- onIdle = () => {},
812
- onPaused = () => {},
813
- onFinished = () => {},
814
- onPassed = () => {},
815
- onWaiting = () => {},
816
- onResumed = () => {},
817
- onTrouble = () => {},
818
- report = api.runnerStatus,
819
- postSteps = api.workSteps,
820
- stepEvery = 5,
821
- signals = false,
822
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
823
- } = {}) {
824
- needsSync("running work");
825
- const inside = context(cwd).root;
826
- const { found, skipped } = runner.checkoutsIn(cwd, { git });
827
- if (inside && !found.length)
828
- throw new PassalongError(
829
- "this checkout has no `origin` remote: the runner pushes each change to a branch there",
830
- );
831
- if (!found.length)
832
- throw new PassalongError(
833
- `there's no git checkout in ${cwd} or the folders under it` +
834
- (skipped.length ? ` with an \`origin\` remote (${skipped.length} without one)` : "") +
835
- ". Start the runner inside a repo, or in the folder that holds your repos.",
836
- );
837
- const checkouts = found.map(({ root, remote }) => {
838
- const runs = runner.runsDirOf(root);
839
- mkdirSync(runs, { recursive: true });
840
- return { root, runs, repo: context(root).repo || remote, forge: runner.forgeOf(remote) };
841
- });
842
- const repos = checkouts.map((k) => k.repo);
843
- const name = machine();
844
- onStart({ repos, skipped });
845
-
846
- // What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
847
- // nobody has to look at this terminal to know. A failed report never stops a run.
848
- const onNow = new Map();
849
- const say = (stopped = false) =>
850
- Promise.resolve(
851
- report({
852
- host: name,
853
- repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
854
- repos,
855
- running: [...onNow].map(([id, title]) => ({ id, title })),
856
- runs_today: runner.runsToday(countFile, runner.dayOf(now())),
857
- per_day: 0,
858
- at_once: atOnce,
859
- stopped,
860
- }),
861
- ).catch(() => {});
862
- // Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
863
- // terminal; what they held stays held, and the sweep puts it back once it goes quiet.
864
- if (signals)
865
- process.once("SIGINT", async () => {
866
- await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
867
- process.exit(130);
868
- });
869
- /**
870
- * The agent's command for one run: `--agent` as given, else Claude Code with what the server's
871
- * brief allows it (runner-brief.ts on the server), or this package's own list without one.
872
- */
873
- const agentRun = (brief) =>
874
- command
875
- ? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
876
- : runner.claudeCommand({
877
- cli,
878
- ...(brief?.permissions?.allow ? { permissions: brief.permissions } : {}),
879
- });
880
-
881
- /**
882
- * Make the worktree a taken piece of work runs in, as the agent whose id took it. Work sent back
883
- * from review starts where its last run left off: on its pushed branch, not from scratch, which
884
- * would also leave the agent unable to push over its own earlier commits.
885
- */
886
- function worktreeFor(k, dir, id, workId) {
887
- try {
888
- git(["-C", k.root, "fetch", "--quiet", "origin"]);
889
- } catch {}
890
- let base = "HEAD";
891
- try {
892
- base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
893
- ? "origin/HEAD"
894
- : base;
895
- } catch {}
896
- try {
897
- const earlier = `origin/${runner.branchOf(workId)}`;
898
- if (git(["-C", k.root, "rev-parse", "--verify", "--quiet", earlier])) base = earlier;
899
- } catch {}
900
- git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
901
- writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
902
- }
903
-
904
- /**
905
- * One slot: a piece of work taken for one of the repos, its worktree made and its agent started.
906
- * `null` when nothing waits in any of them. The run itself is handed back unawaited, so several
907
- * go at once. Repos are asked in turn, starting after the last one that had work.
908
- */
909
- let turn = 0;
910
- async function startOne(n) {
911
- let t = null;
912
- let k = null;
913
- let dir = "";
914
- let id = "";
915
- for (let i = 0; i < checkouts.length && !t; i++) {
916
- k = checkouts[(turn + i) % checkouts.length];
917
- dir = join(k.runs, `run-${now()}-${n}`);
918
- id = randomBytes(12).toString("hex");
919
- try {
920
- const got = await api.nextTask({
921
- agent: id,
922
- host: hostname(),
923
- repo: k.repo,
924
- worktree: dir,
925
- alone: true,
926
- // So the server's brief ends the run the way this checkout's host takes changes.
927
- forge: k.forge,
928
- });
929
- // The brief rides with the work: what to tell its agent, from the server (1.4.0's).
930
- t = got?.task ? { ...got.task, brief: got.runner || null } : null;
931
- } catch (err) {
932
- onTrouble(err);
933
- }
934
- if (t) turn = (turn + i + 1) % checkouts.length;
935
- }
936
- if (!t || !k) return null;
937
- // Its branch, or a fresh one beside it when that one is still checked out in the worktree of
938
- // an earlier run that didn't finish (kept for its author), as when a person ticks it again.
939
- let branch = runner.branchOf(t.id);
940
- try {
941
- worktreeFor(k, dir, id, t.id);
942
- try {
943
- git(["-C", dir, "switch", "--quiet", "-C", branch]);
944
- } catch {
945
- branch = `${branch}-${now().toString(36)}`;
946
- git(["-C", dir, "switch", "--quiet", "-c", branch]);
947
- }
948
- } catch (err) {
949
- // Taken and then stuck before any agent started: never left held with nobody on it.
950
- const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
951
- await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
952
- onTrouble(e, t),
953
- );
954
- onPassed(t, { why, dir, log: "" });
955
- return { run: Promise.resolve() };
956
- }
957
- runner.countRun(countFile, runner.dayOf(now()));
958
- onNow.set(t.id, t.title || t.id);
959
- onTask(t, { dir, branch, repo: k.repo });
960
- say();
961
- return {
962
- run: finish(t, k, dir, branch).finally(() => {
963
- // A paused run is still on: the hub keeps showing it, with its last step saying why.
964
- if (!paused.has(t.id)) onNow.delete(t.id);
965
- say();
966
- }),
967
- };
968
- }
969
-
970
- async function finish(t, k, dir, branch, { resumed = false } = {}) {
971
- const log = join(k.runs, `${t.id}.log`);
972
- // Steps go to the hub every few seconds rather than one request each: an agent can take a
973
- // dozen in a second. Whatever is left goes when the run ends.
974
- const pending = [];
975
- const flush = async () => {
976
- if (!pending.length) return;
977
- await postSteps(t.id, pending.splice(0)).catch(() => {});
978
- };
979
- const step = (text) => {
980
- pending.push({ at: new Date(now()).toISOString(), text });
981
- onStep(t, text);
982
- };
983
- step(
984
- resumed === "usage"
985
- ? `Carrying on now that Claude's usage is back, on ${name}`
986
- : resumed
987
- ? `Carrying on after your answer, on ${name}`
988
- : `Started on ${name} in ${k.repo}, on branch ${branch}`,
989
- );
990
- const timer = setInterval(flush, stepEvery * 1000);
991
- // The server's words when it sent them, with this run's branch filled in; else this package's.
992
- const told = (said) => (typeof said === "string" ? said.replaceAll("{branch}", branch) : "");
993
- const prompt =
994
- resumed === "usage"
995
- ? told(t.brief?.paused) || runner.pausedPrompt(t, branch, { forge: k.forge })
996
- : resumed
997
- ? told(t.brief?.resume) || runner.resumePrompt(t, branch, { forge: k.forge })
998
- : told(t.brief?.start) || runner.runnerPrompt(t, branch, { forge: k.forge });
999
- const ran = await runAgent({
1000
- ...agentRun(t.brief),
1001
- prompt,
1002
- ...(t.brief?.usage ? { usage: t.brief.usage } : {}),
1003
- cwd: dir,
1004
- log,
1005
- minutes,
1006
- env: { ...process.env, PASSALONG_TASK: t.id },
1007
- onStep: step,
1008
- }).finally(() => clearInterval(timer));
1009
- if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
1010
- let held = null;
1011
- try {
1012
- held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
1013
- } catch {}
1014
- // Handed in: Done, or (from 1.4.0's server on) waiting for a person to approve it or send it
1015
- // back. Either way this run is over and its branch is pushed.
1016
- const handedIn = held?.state === "done" || held?.state === "review";
1017
- // Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
1018
- // it back again would be refused, and it is no longer this runner's to describe.
1019
- if (!handedIn && !held?.claim) {
1020
- step("It was no longer held here when the agent stopped, so the runner left it as it is");
1021
- await flush();
1022
- onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
1023
- return;
1024
- }
1025
- // The last step says how the run ended, while the work is still held here: once it is passed
1026
- // back, its steps are no longer this runner's to write.
1027
- // Its usage ran out: not given up. It stays held here, and carries on once the usage is back.
1028
- if (ran.usage && held.state !== "done" && !held.claim?.asking) {
1029
- pauseUntil = Math.max(pauseUntil, ran.usage.until || now() + runner.USAGE_RETRY_MS);
1030
- const when = ran.usage.until ? `at ${runner.clockOf(pauseUntil)}` : "when it's back";
1031
- step(`Paused: Claude's usage ran out. It carries on here ${when}, with nothing lost.`);
1032
- await flush();
1033
- paused.set(t.id, { t, k, dir, branch });
1034
- onPaused(t, { until: ran.usage.until ? pauseUntil : 0 });
1035
- return;
1036
- }
1037
- if (!handedIn && !held.claim?.asking)
1038
- step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
1039
- if (held.state === "review")
1040
- step(
1041
- "Handed in. It's ready for your review: approve it, or send it back with what to change",
1042
- );
1043
- if (held.claim?.asking) step("Waiting for your answer, then it carries on");
1044
- await flush();
1045
- if (handedIn) {
1046
- try {
1047
- git(["-C", k.root, "worktree", "remove", "--force", dir]);
1048
- } catch {}
1049
- onFinished(t, { branch, log });
1050
- return;
1051
- }
1052
- // It stopped to ask, and the question is waiting on a person: not given up. The work stays held
1053
- // here with its worktree, and the run picks up again once they answer (resumeAnswered).
1054
- if (held?.claim?.asking) {
1055
- waiting.set(t.id, { t, k, dir, branch });
1056
- onWaiting(t, { question: held.claim.asking, dir });
1057
- return;
1058
- }
1059
- const why = runner.passBack({
1060
- minutes,
1061
- ...ran,
1062
- note: held?.claim?.note || "",
1063
- said: ran.said || runner.lastLine(log),
1064
- branch,
1065
- machine: name,
1066
- });
1067
- try {
1068
- await pass(t.id, why, { cwd: dir, runner: true });
1069
- onPassed(t, { why, dir, log });
1070
- } catch (err) {
1071
- onTrouble(err, t);
1072
- }
1073
- }
1074
-
1075
- const running = new Set();
1076
- /** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
1077
- const waiting = new Map();
1078
- /** Runs stopped because the agent's usage ran out, the same way: they carry on at `pauseUntil`. */
1079
- const paused = new Map();
1080
- let pauseUntil = 0;
1081
- /** Start an agent again where it stopped: the same worktree, branch and claim. */
1082
- function resume(t, k, dir, branch, { afterPause = false } = {}) {
1083
- runner.countRun(countFile, runner.dayOf(now()));
1084
- onNow.set(t.id, t.title || t.id);
1085
- onResumed(t, { dir, branch, afterPause });
1086
- say();
1087
- const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
1088
- .catch((err) => onTrouble(err, t))
1089
- .finally(() => {
1090
- if (!paused.has(t.id)) onNow.delete(t.id);
1091
- running.delete(run);
1092
- say();
1093
- });
1094
- running.add(run);
1095
- }
1096
-
1097
- /**
1098
- * Questions answered since the last look start their run again, while there is a slot and the
1099
- * day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
1100
- */
1101
- async function resumeAnswered() {
1102
- if (!waiting.size) return;
1103
- let rows = [];
1104
- try {
1105
- rows = (await api.tasks()).tasks;
1106
- } catch (err) {
1107
- return onTrouble(err);
1108
- }
1109
- for (const [id, w] of [...waiting]) {
1110
- const row = rows.find((x) => x.id === id);
1111
- if (row?.state === "done") {
1112
- waiting.delete(id);
1113
- try {
1114
- git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
1115
- } catch {}
1116
- onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
1117
- continue;
1118
- }
1119
- if (!row?.claim) {
1120
- waiting.delete(id);
1121
- onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
1122
- continue;
1123
- }
1124
- if (row.claim.asking) continue;
1125
- if (running.size >= atOnce || paused.size) return;
1126
- waiting.delete(id);
1127
- resume(w.t, w.k, w.dir, w.branch);
1128
- }
1129
- }
1130
-
1131
- // A runner started again finds what its earlier runs left held: their worktrees are still beside
1132
- // their checkouts. One waiting on a question waits on; any other (paused when the usage ran out,
1133
- // or stopped with the runner) carries on straight away.
1134
- try {
1135
- for (const row of (await api.tasks()).tasks) {
1136
- const leaf = basename(row.claim?.worktree || "");
1137
- if (!leaf.startsWith("run-") || row.state === "done") continue;
1138
- const k = checkouts.find((x) => existsSync(join(x.runs, leaf)));
1139
- if (!k) continue;
1140
- const dir = join(k.runs, leaf);
1141
- let branch = runner.branchOf(row.id);
1142
- try {
1143
- branch = git(["-C", dir, "branch", "--show-current"]) || branch;
1144
- } catch {}
1145
- (row.claim?.asking ? waiting : paused).set(row.id, {
1146
- t: { id: row.id, title: row.title, markdown: "" },
1147
- k,
1148
- dir,
1149
- branch,
1150
- });
1151
- }
1152
- } catch {}
1153
-
1154
- /**
1155
- * While the agent's usage is out: keep each paused run's hold alive (an empty report of steps
1156
- * renews it, so the hub doesn't call it gone quiet), and once the time comes, carry them on. If
1157
- * the usage is still out they pause again at once, which costs nothing.
1158
- */
1159
- async function carryOnPaused() {
1160
- if (!paused.size) return;
1161
- if (now() < pauseUntil) {
1162
- for (const id of paused.keys()) await postSteps(id, []).catch(() => {});
1163
- return;
1164
- }
1165
- pauseUntil = 0;
1166
- for (const [id, p] of [...paused]) {
1167
- if (running.size >= atOnce) break;
1168
- paused.delete(id);
1169
- resume(p.t, p.k, p.dir, p.branch, { afterPause: true });
1170
- }
1171
- }
1172
-
1173
- let n = 0;
1174
- for (;;) {
1175
- await carryOnPaused();
1176
- await resumeAnswered();
1177
- // Nothing new while the usage is out: it would only start and stop at once.
1178
- while (running.size < atOnce && !paused.size && now() >= pauseUntil) {
1179
- let slot;
1180
- try {
1181
- slot = await startOne(++n);
1182
- } catch (err) {
1183
- onTrouble(err);
1184
- break;
1185
- }
1186
- if (!slot) {
1187
- if (!running.size) onIdle(repos);
1188
- break;
1189
- }
1190
- const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
1191
- running.add(run);
1192
- }
1193
- // Wait for a run to end or for the next look, whichever is first.
1194
- await say();
1195
- await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
1196
- }
1197
- }
1198
-
1199
890
  /**
1200
891
  * Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
1201
892
  * moment a planner has written several and you have read them all. Only your own: sending work on