passalong 1.1.0 → 1.3.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/README.md CHANGED
@@ -51,8 +51,10 @@ passalong archive <id> off the board and out of the free tier's count
51
51
  passalong rm <id> delete it
52
52
  passalong attach <file> upload a picture or file and print the markdown that points at it
53
53
  passalong work [--once] [--agent CMD] run an agent on each piece of ready work here, in turn
54
- passalong work --watch the runner: work a person marked safe to run alone, up to 3 at once
55
- and 10 a day, each in its own worktree, finished as a pull request
54
+ passalong work --watch the runner, in a repo or a folder of repos: work a person marked safe
55
+ to run alone, up to 3 at once, each in its own worktree, finished
56
+ as a pull request; each agent's steps show in the hub. When Claude's
57
+ usage runs out it pauses, and carries on once it is back
56
58
  passalong now what this worktree holds and what is waiting
57
59
  passalong hub open the hub in the browser
58
60
  passalong export [dir] every piece of work as plain markdown
package/bin/passalong CHANGED
@@ -22,8 +22,8 @@
22
22
  // passalong rm <id> delete it
23
23
  // passalong attach <file> upload a picture or a file and print the markdown that points at it
24
24
  // passalong work [--once] [--agent CMD] run an agent on each piece of ready work here, one after another
25
- // passalong work --watch [--every SECS] the runner: keep taking work marked safe to run alone,
26
- // up to 3 at once and 10 a day, each in its own worktree and branch, finished as a pull request
25
+ // passalong work --watch [--every SECS] the runner, in a repo or a folder of repos: keep taking work marked safe to run alone,
26
+ // up to 3 at once, each in its own worktree and branch, finished as a pull request
27
27
  // passalong hub open the hub in the browser
28
28
  // passalong export [dir] dump every piece of work as plain markdown (default ./passalong-export)
29
29
  // passalong admin create <email> make an account that runs Passalong (owner only)
@@ -257,32 +257,55 @@ async function statusLine() {
257
257
  */
258
258
  async function runner(f) {
259
259
  const every = Number(f.every) || 60;
260
- console.error(
261
- "Runner: taking work marked safe to run alone, up to 3 at once and 10 a day, each finished as a " +
262
- "pull request. The hub's Work board shows what it is on. Stop it with Ctrl-C: agents running " +
263
- "then stop too, and what they held goes back to the queue once it goes quiet.",
264
- );
265
260
  let idle = false;
261
+ /** "a, b and 4 more": the repos a runner serves, short enough for one line. */
262
+ const some = (repos) =>
263
+ repos.length <= 3 ? repos.join(", ") : `${repos.slice(0, 3).join(", ")} and ${repos.length - 3} more`;
266
264
  await passalong
267
265
  .watch({
268
266
  agent: typeof f.agent === "string" ? f.agent : undefined,
269
267
  every,
270
268
  cli: fileURLToPath(import.meta.url),
271
269
  signals: true,
272
- onTask: (t, { dir, branch }) => {
270
+ // Said only once the folder checked out: a runner that cannot run says why, not what it will do.
271
+ onStart: ({ repos, skipped }) => {
272
+ console.error(
273
+ `Runner for ${repos.length === 1 ? repos[0] : `${repos.length} repos: ${some(repos)}`}.\n` +
274
+ "It takes work marked safe to run alone, up to 3 at once, and finishes each " +
275
+ "as a pull request. You can watch each agent here and on the work's page in the hub. " +
276
+ "Stop it with Ctrl-C: agents running then stop too.",
277
+ );
278
+ if (skipped.length)
279
+ console.error(`Skipped ${skipped.length} checkout${skipped.length === 1 ? "" : "s"} with no origin remote: ${some(skipped)}`);
280
+ },
281
+ onTask: (t, { dir, branch, repo }) => {
273
282
  idle = false;
274
- console.error(`\n→ ${t.id} ${t.title}\n on ${branch} in ${dir}`);
283
+ console.error(`\n→ ${t.id} ${t.title}\n ${repo}, on ${branch} in ${dir}`);
275
284
  },
276
- onIdle: (repo) => {
277
- if (!idle) console.error(`waiting for work marked safe to run alone for ${repo || "this repo"}…`);
285
+ onStep: (t, text) => console.error(` ${t.id} · ${text}`),
286
+ onIdle: (repos) => {
287
+ if (!idle)
288
+ console.error(
289
+ `waiting for work marked safe to run alone in ${some(repos)}. ` +
290
+ "Work with no repo needs one picked on its page in the hub.",
291
+ );
278
292
  idle = true;
279
293
  },
280
- onCapped: (n) =>
281
- console.error(`${n} runs today: that's the day's limit. Picking up again tomorrow.`),
294
+ onPaused: (t, { until }) =>
295
+ console.error(
296
+ `‖ ${t.id} paused: Claude's usage ran out. ` +
297
+ (until
298
+ ? `It carries on at ${new Date(until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
299
+ : "It tries again every 15 minutes") +
300
+ ", and takes nothing new until then.",
301
+ ),
282
302
  onFinished: (t, { branch, log }) => console.error(`✓ ${t.id} finished on ${branch} (log: ${log})`),
283
303
  onWaiting: (t, { question }) =>
284
304
  console.error(`? ${t.id} is waiting on you: "${question.split("\n")[0]}" (answer it in the hub)`),
285
- onResumed: (t) => console.error(`\n→ ${t.id} ${t.title} (answered: carrying on)`),
305
+ onResumed: (t, { afterPause }) =>
306
+ console.error(
307
+ `\n→ ${t.id} ${t.title} (${afterPause ? "carrying on where it stopped" : "answered: carrying on"})`,
308
+ ),
286
309
  onPassed: (t, { dir, log }) =>
287
310
  console.error(`✗ ${t.id} went back to its author, unfinished. Its worktree: ${dir} (log: ${log})`),
288
311
  onTrouble: (err, t) => console.error(`! ${t ? `${t.id}: ` : ""}${err?.message || err}`),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Work, projects, documents and files for a team and its agents: take work, hand it in with evidence, and keep what the team knows in its projects.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
package/src/api.js CHANGED
@@ -359,6 +359,9 @@ export const tasks = () => call("/v1/tasks");
359
359
  export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
360
360
  /** The runner saying how it is, for the hub: what it is on, today's count, or that it stopped. */
361
361
  export const runnerStatus = (body) => call("/v1/runner", { method: "PUT", body });
362
+ /** What a runner's agent did since the last report, a line each, for the hub to show. */
363
+ export const workSteps = (id, steps) =>
364
+ call(`/v1/work/${encodeURIComponent(id)}/steps`, { method: "POST", body: { steps } });
362
365
  export const reopen = (id, why) =>
363
366
  call(`/v1/work/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
364
367
  /** Take work back from whoever holds it. */
package/src/passalong.js CHANGED
@@ -775,51 +775,72 @@ export async function work({
775
775
 
776
776
  /**
777
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.
778
+ * a git worktree of its own beside its checkout, on its own branch, by an agent that finishes with
779
+ * a pull request. Up to `atOnce` at a time, with no count a day; a run that ends without the work
780
+ * done passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
781
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.
782
+ * When the agent's usage runs out (runner.usageOut) its work is not given back: it stays held here
783
+ * with its worktree, the runner takes nothing new, and once the usage is back (when the agent said,
784
+ * or every `USAGE_RETRY_MS`) it carries on where it stopped.
785
+ *
786
+ * Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
787
+ * one with an `origin` remote, so one terminal does what six used to. The folder is checked before
788
+ * anything is said (`onStart`), so a runner that cannot run says why instead of what it will do.
789
+ *
790
+ * Work is asked for before a worktree is made: the agent's id is minted first and written into the
791
+ * worktree once there is work for it, so a runner serving many repos does not make and remove a
792
+ * worktree in each of them every minute. What the agent does is read as it goes (runner.describe)
793
+ * and sent to the hub every few seconds as steps, and to `onStep` for the terminal.
785
794
  */
786
795
  export async function watch({
787
796
  cwd = process.cwd(),
788
797
  agent: command,
789
798
  every = 60,
790
799
  atOnce = runner.LIMITS.atOnce,
791
- perDay = runner.LIMITS.perDay,
792
800
  minutes = runner.LIMITS.minutes,
793
801
  cli,
794
802
  git = runner.git,
795
803
  runAgent = runner.runAgent,
796
804
  now = () => Date.now(),
797
805
  countFile = join(store.HOME, "runner.json"),
806
+ machine = runner.machineName,
807
+ onStart = () => {},
798
808
  onTask = () => {},
809
+ onStep = () => {},
799
810
  onIdle = () => {},
800
- onCapped = () => {},
811
+ onPaused = () => {},
801
812
  onFinished = () => {},
802
813
  onPassed = () => {},
803
814
  onWaiting = () => {},
804
815
  onResumed = () => {},
805
816
  onTrouble = () => {},
806
817
  report = api.runnerStatus,
818
+ postSteps = api.workSteps,
819
+ stepEvery = 5,
807
820
  signals = false,
808
821
  sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
809
822
  } = {}) {
810
823
  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)
824
+ const inside = context(cwd).root;
825
+ const { found, skipped } = runner.checkoutsIn(cwd, { git });
826
+ if (inside && !found.length)
818
827
  throw new PassalongError(
819
828
  "this checkout has no `origin` remote: the runner pushes each change to a branch there",
820
829
  );
821
- const runs = runner.runsDirOf(root);
822
- mkdirSync(runs, { recursive: true });
830
+ if (!found.length)
831
+ throw new PassalongError(
832
+ `there's no git checkout in ${cwd} or the folders under it` +
833
+ (skipped.length ? ` with an \`origin\` remote (${skipped.length} without one)` : "") +
834
+ ". Start the runner inside a repo, or in the folder that holds your repos.",
835
+ );
836
+ const checkouts = found.map(({ root, remote }) => {
837
+ const runs = runner.runsDirOf(root);
838
+ mkdirSync(runs, { recursive: true });
839
+ return { root, runs, repo: context(root).repo || remote, forge: runner.forgeOf(remote) };
840
+ });
841
+ const repos = checkouts.map((k) => k.repo);
842
+ const name = machine();
843
+ onStart({ repos, skipped });
823
844
 
824
845
  // What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
825
846
  // nobody has to look at this terminal to know. A failed report never stops a run.
@@ -827,11 +848,12 @@ export async function watch({
827
848
  const say = (stopped = false) =>
828
849
  Promise.resolve(
829
850
  report({
830
- host: hostname(),
831
- repo,
851
+ host: name,
852
+ repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
853
+ repos,
832
854
  running: [...onNow].map(([id, title]) => ({ id, title })),
833
855
  runs_today: runner.runsToday(countFile, runner.dayOf(now())),
834
- per_day: perDay,
856
+ per_day: 0,
835
857
  at_once: atOnce,
836
858
  stopped,
837
859
  }),
@@ -847,36 +869,56 @@ export async function watch({
847
869
  ? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
848
870
  : runner.claudeCommand({ cli });
849
871
 
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) {
872
+ /** Make the worktree a taken piece of work runs in, as the agent whose id took it. */
873
+ function worktreeFor(k, dir, id) {
855
874
  try {
856
- git(["-C", root, "fetch", "--quiet", "origin"]);
875
+ git(["-C", k.root, "fetch", "--quiet", "origin"]);
857
876
  } catch {}
858
877
  let base = "HEAD";
859
878
  try {
860
- base = git(["-C", root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
879
+ base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
861
880
  ? "origin/HEAD"
862
881
  : base;
863
882
  } catch {}
864
- const dir = join(runs, `run-${now()}-${n}`);
865
- git(["-C", root, "worktree", "add", "--detach", "--quiet", dir, base]);
883
+ git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
884
+ writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
885
+ }
886
+
887
+ /**
888
+ * One slot: a piece of work taken for one of the repos, its worktree made and its agent started.
889
+ * `null` when nothing waits in any of them. The run itself is handed back unawaited, so several
890
+ * go at once. Repos are asked in turn, starting after the last one that had work.
891
+ */
892
+ let turn = 0;
893
+ async function startOne(n) {
866
894
  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;
895
+ let k = null;
896
+ let dir = "";
897
+ let id = "";
898
+ for (let i = 0; i < checkouts.length && !t; i++) {
899
+ k = checkouts[(turn + i) % checkouts.length];
900
+ dir = join(k.runs, `run-${now()}-${n}`);
901
+ id = randomBytes(12).toString("hex");
902
+ try {
903
+ const got = await api.nextTask({
904
+ agent: id,
905
+ host: hostname(),
906
+ repo: k.repo,
907
+ worktree: dir,
908
+ alone: true,
909
+ });
910
+ t = got?.task || null;
911
+ } catch (err) {
912
+ onTrouble(err);
913
+ }
914
+ if (t) turn = (turn + i + 1) % checkouts.length;
875
915
  }
916
+ if (!t || !k) return null;
876
917
  // Its branch, or a fresh one beside it when that one is still checked out in the worktree of
877
918
  // an earlier run that didn't finish (kept for its author), as when a person ticks it again.
878
919
  let branch = runner.branchOf(t.id);
879
920
  try {
921
+ worktreeFor(k, dir, id);
880
922
  try {
881
923
  git(["-C", dir, "switch", "--quiet", "-C", branch]);
882
924
  } catch {
@@ -886,39 +928,92 @@ export async function watch({
886
928
  } catch (err) {
887
929
  // Taken and then stuck before any agent started: never left held with nobody on it.
888
930
  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));
931
+ await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
932
+ onTrouble(e, t),
933
+ );
890
934
  onPassed(t, { why, dir, log: "" });
891
935
  return { run: Promise.resolve() };
892
936
  }
893
937
  runner.countRun(countFile, runner.dayOf(now()));
894
938
  onNow.set(t.id, t.title || t.id);
895
- onTask(t, { dir, branch });
939
+ onTask(t, { dir, branch, repo: k.repo });
896
940
  say();
897
941
  return {
898
- run: finish(t, dir, branch).finally(() => {
899
- onNow.delete(t.id);
942
+ run: finish(t, k, dir, branch).finally(() => {
943
+ // A paused run is still on: the hub keeps showing it, with its last step saying why.
944
+ if (!paused.has(t.id)) onNow.delete(t.id);
900
945
  say();
901
946
  }),
902
947
  };
903
948
  }
904
949
 
905
- async function finish(t, dir, branch, { resumed = false } = {}) {
906
- const log = join(runs, `${t.id}.log`);
950
+ async function finish(t, k, dir, branch, { resumed = false } = {}) {
951
+ const log = join(k.runs, `${t.id}.log`);
952
+ // Steps go to the hub every few seconds rather than one request each: an agent can take a
953
+ // dozen in a second. Whatever is left goes when the run ends.
954
+ const pending = [];
955
+ const flush = async () => {
956
+ if (!pending.length) return;
957
+ await postSteps(t.id, pending.splice(0)).catch(() => {});
958
+ };
959
+ const step = (text) => {
960
+ pending.push({ at: new Date(now()).toISOString(), text });
961
+ onStep(t, text);
962
+ };
963
+ step(
964
+ resumed === "usage"
965
+ ? `Carrying on now that Claude's usage is back, on ${name}`
966
+ : resumed
967
+ ? `Carrying on after your answer, on ${name}`
968
+ : `Started on ${name} in ${k.repo}, on branch ${branch}`,
969
+ );
970
+ const timer = setInterval(flush, stepEvery * 1000);
907
971
  const ran = await runAgent({
908
972
  ...agentRun,
909
- prompt: resumed ? runner.resumePrompt(t, branch) : runner.runnerPrompt(t, branch),
973
+ prompt:
974
+ resumed === "usage"
975
+ ? runner.pausedPrompt(t, branch, { forge: k.forge })
976
+ : resumed
977
+ ? runner.resumePrompt(t, branch, { forge: k.forge })
978
+ : runner.runnerPrompt(t, branch, { forge: k.forge }),
910
979
  cwd: dir,
911
980
  log,
912
981
  minutes,
913
982
  env: { ...process.env, PASSALONG_TASK: t.id },
914
- });
983
+ onStep: step,
984
+ }).finally(() => clearInterval(timer));
985
+ if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
915
986
  let held = null;
916
987
  try {
917
988
  held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
918
989
  } catch {}
990
+ // Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
991
+ // it back again would be refused, and it is no longer this runner's to describe.
992
+ if (held?.state !== "done" && !held?.claim) {
993
+ step("It was no longer held here when the agent stopped, so the runner left it as it is");
994
+ await flush();
995
+ onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
996
+ return;
997
+ }
998
+ // The last step says how the run ended, while the work is still held here: once it is passed
999
+ // back, its steps are no longer this runner's to write.
1000
+ // Its usage ran out: not given up. It stays held here, and carries on once the usage is back.
1001
+ if (ran.usage && held.state !== "done" && !held.claim?.asking) {
1002
+ pauseUntil = Math.max(pauseUntil, ran.usage.until || now() + runner.USAGE_RETRY_MS);
1003
+ const when = ran.usage.until ? `at ${runner.clockOf(pauseUntil)}` : "when it's back";
1004
+ step(`Paused: Claude's usage ran out. It carries on here ${when}, with nothing lost.`);
1005
+ await flush();
1006
+ paused.set(t.id, { t, k, dir, branch });
1007
+ onPaused(t, { until: ran.usage.until ? pauseUntil : 0 });
1008
+ return;
1009
+ }
1010
+ if (held.state !== "done" && !held.claim?.asking)
1011
+ step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
1012
+ if (held.claim?.asking) step("Waiting for your answer, then it carries on");
1013
+ await flush();
919
1014
  if (held?.state === "done") {
920
1015
  try {
921
- git(["-C", root, "worktree", "remove", "--force", dir]);
1016
+ git(["-C", k.root, "worktree", "remove", "--force", dir]);
922
1017
  } catch {}
923
1018
  onFinished(t, { branch, log });
924
1019
  return;
@@ -926,7 +1021,7 @@ export async function watch({
926
1021
  // It stopped to ask, and the question is waiting on a person: not given up. The work stays held
927
1022
  // here with its worktree, and the run picks up again once they answer (resumeAnswered).
928
1023
  if (held?.claim?.asking) {
929
- waiting.set(t.id, { t, dir, branch });
1024
+ waiting.set(t.id, { t, k, dir, branch });
930
1025
  onWaiting(t, { question: held.claim.asking, dir });
931
1026
  return;
932
1027
  }
@@ -934,9 +1029,9 @@ export async function watch({
934
1029
  minutes,
935
1030
  ...ran,
936
1031
  note: held?.claim?.note || "",
937
- said: runner.lastLine(log),
938
- dir,
1032
+ said: ran.said || runner.lastLine(log),
939
1033
  branch,
1034
+ machine: name,
940
1035
  });
941
1036
  try {
942
1037
  await pass(t.id, why, { cwd: dir, runner: true });
@@ -947,18 +1042,21 @@ export async function watch({
947
1042
  }
948
1043
 
949
1044
  const running = new Set();
950
- /** Runs stopped on a question, by work id: their worktree and branch, kept to resume in. */
1045
+ /** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
951
1046
  const waiting = new Map();
1047
+ /** Runs stopped because the agent's usage ran out, the same way: they carry on at `pauseUntil`. */
1048
+ const paused = new Map();
1049
+ let pauseUntil = 0;
952
1050
  /** Start an agent again where it stopped: the same worktree, branch and claim. */
953
- function resume(t, dir, branch) {
1051
+ function resume(t, k, dir, branch, { afterPause = false } = {}) {
954
1052
  runner.countRun(countFile, runner.dayOf(now()));
955
1053
  onNow.set(t.id, t.title || t.id);
956
- onResumed(t, { dir, branch });
1054
+ onResumed(t, { dir, branch, afterPause });
957
1055
  say();
958
- const run = finish(t, dir, branch, { resumed: true })
1056
+ const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
959
1057
  .catch((err) => onTrouble(err, t))
960
1058
  .finally(() => {
961
- onNow.delete(t.id);
1059
+ if (!paused.has(t.id)) onNow.delete(t.id);
962
1060
  running.delete(run);
963
1061
  say();
964
1062
  });
@@ -982,9 +1080,9 @@ export async function watch({
982
1080
  if (row?.state === "done") {
983
1081
  waiting.delete(id);
984
1082
  try {
985
- git(["-C", root, "worktree", "remove", "--force", w.dir]);
1083
+ git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
986
1084
  } catch {}
987
- onFinished(w.t, { branch: w.branch, log: join(runs, `${id}.log`) });
1085
+ onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
988
1086
  continue;
989
1087
  }
990
1088
  if (!row?.claim) {
@@ -993,39 +1091,60 @@ export async function watch({
993
1091
  continue;
994
1092
  }
995
1093
  if (row.claim.asking) continue;
996
- if (running.size >= atOnce) return;
997
- if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
1094
+ if (running.size >= atOnce || paused.size) return;
998
1095
  waiting.delete(id);
999
- resume(w.t, w.dir, w.branch);
1096
+ resume(w.t, w.k, w.dir, w.branch);
1000
1097
  }
1001
1098
  }
1002
1099
 
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.
1100
+ // A runner started again finds what its earlier runs left held: their worktrees are still beside
1101
+ // their checkouts. One waiting on a question waits on; any other (paused when the usage ran out,
1102
+ // or stopped with the runner) carries on straight away.
1005
1103
  try {
1006
1104
  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;
1105
+ const leaf = basename(row.claim?.worktree || "");
1106
+ if (!leaf.startsWith("run-") || row.state === "done") continue;
1107
+ const k = checkouts.find((x) => existsSync(join(x.runs, leaf)));
1108
+ if (!k) continue;
1109
+ const dir = join(k.runs, leaf);
1010
1110
  let branch = runner.branchOf(row.id);
1011
1111
  try {
1012
1112
  branch = git(["-C", dir, "branch", "--show-current"]) || branch;
1013
1113
  } catch {}
1014
- waiting.set(row.id, { t: { id: row.id, title: row.title, markdown: "" }, dir, branch });
1114
+ (row.claim?.asking ? waiting : paused).set(row.id, {
1115
+ t: { id: row.id, title: row.title, markdown: "" },
1116
+ k,
1117
+ dir,
1118
+ branch,
1119
+ });
1015
1120
  }
1016
1121
  } catch {}
1017
1122
 
1123
+ /**
1124
+ * While the agent's usage is out: keep each paused run's hold alive (an empty report of steps
1125
+ * renews it, so the hub doesn't call it gone quiet), and once the time comes, carry them on. If
1126
+ * the usage is still out they pause again at once, which costs nothing.
1127
+ */
1128
+ async function carryOnPaused() {
1129
+ if (!paused.size) return;
1130
+ if (now() < pauseUntil) {
1131
+ for (const id of paused.keys()) await postSteps(id, []).catch(() => {});
1132
+ return;
1133
+ }
1134
+ pauseUntil = 0;
1135
+ for (const [id, p] of [...paused]) {
1136
+ if (running.size >= atOnce) break;
1137
+ paused.delete(id);
1138
+ resume(p.t, p.k, p.dir, p.branch, { afterPause: true });
1139
+ }
1140
+ }
1141
+
1018
1142
  let n = 0;
1019
- let cappedOn = "";
1020
1143
  for (;;) {
1144
+ await carryOnPaused();
1021
1145
  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
- }
1146
+ // Nothing new while the usage is out: it would only start and stop at once.
1147
+ while (running.size < atOnce && !paused.size && now() >= pauseUntil) {
1029
1148
  let slot;
1030
1149
  try {
1031
1150
  slot = await startOne(++n);
@@ -1034,7 +1153,7 @@ export async function watch({
1034
1153
  break;
1035
1154
  }
1036
1155
  if (!slot) {
1037
- if (!running.size) onIdle(repo);
1156
+ if (!running.size) onIdle(repos);
1038
1157
  break;
1039
1158
  }
1040
1159
  const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
package/src/runner.js CHANGED
@@ -12,11 +12,27 @@
12
12
  // Everything that touches git, the clock or a process is passed in, so the tests can run it.
13
13
 
14
14
  import { execFileSync, spawn } from "node:child_process";
15
- import { mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
16
- import { basename, dirname, join } from "node:path";
15
+ import {
16
+ appendFileSync,
17
+ mkdirSync,
18
+ readdirSync,
19
+ readFileSync,
20
+ statSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { hostname, platform } from "node:os";
24
+ import { basename, dirname, join, relative } from "node:path";
17
25
 
18
- /** How many agents at once, how many runs a day, and how long one run may take. */
19
- export const LIMITS = { atOnce: 3, perDay: 10, minutes: 60 };
26
+ /**
27
+ * How many agents at once, and how long one run may take. There is no count of runs a day: it was
28
+ * ten, and a loop that gave the same work back over and over spent all ten on nothing, so the
29
+ * runner sat idle with work waiting (2026-11-06). What limits a runner now is the agent's own usage
30
+ * (`usageOut`: it pauses until that comes back) and the work the Passalong plan lets you keep.
31
+ */
32
+ export const LIMITS = { atOnce: 3, minutes: 60 };
33
+
34
+ /** How long a runner waits to try again when its agent's usage ran out and it didn't say until when. */
35
+ export const USAGE_RETRY_MS = 15 * 60 * 1000;
20
36
 
21
37
  /**
22
38
  * What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
@@ -62,6 +78,9 @@ export const PERMISSIONS = {
62
78
  ],
63
79
  };
64
80
 
81
+ /** Where a checkout's origin is hosted, for how a run ends: a pull request or a merge request. */
82
+ export const forgeOf = (remote) => (/gitlab/i.test(String(remote || "")) ? "gitlab" : "github");
83
+
65
84
  /** The branch a piece of work's change goes on. */
66
85
  export const branchOf = (id) => `passalong/${id}`;
67
86
 
@@ -76,6 +95,11 @@ export function claudeCommand({ node = process.execPath, cli } = {}) {
76
95
  command: "claude",
77
96
  args: [
78
97
  "-p",
98
+ // One JSON event per line as it works, which the runner turns into the steps a person can
99
+ // watch from the hub (describe below). Plain -p prints only the final answer, at the end.
100
+ "--output-format",
101
+ "stream-json",
102
+ "--verbose",
79
103
  "--permission-mode",
80
104
  "dontAsk",
81
105
  "--settings",
@@ -88,7 +112,23 @@ export function claudeCommand({ node = process.execPath, cli } = {}) {
88
112
  }
89
113
 
90
114
  /** What the agent is told: the work itself, and how to finish it as a pull request. */
91
- export function runnerPrompt(t, branch = branchOf(t.id)) {
115
+ export function runnerPrompt(t, branch = branchOf(t.id), { forge = "github" } = {}) {
116
+ // GitLab has no `gh`: a push option opens the merge request, and git prints its link.
117
+ const ship =
118
+ forge === "gitlab"
119
+ ? [
120
+ `When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
121
+ `exactly \`git push -u origin ${branch} -o merge_request.create\`, which opens a merge`,
122
+ "request and prints its link. Then call hand_in with id " + t.id + ", `pr` set to that",
123
+ "link (or the commit hash if the push failed, and say why in the note), `note`: one plain",
124
+ ]
125
+ : [
126
+ `When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
127
+ `exactly \`git push -u origin ${branch}\` and open a pull request with`,
128
+ `\`gh pr create --fill --head ${branch}\`. Then call hand_in with id ${t.id}, \`pr\` set to the`,
129
+ "pull request's URL (or the commit hash if the push or the pull request failed, and say why in",
130
+ "the note), `note`: one plain",
131
+ ];
92
132
  return [
93
133
  `You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
94
134
  "",
@@ -98,11 +138,8 @@ export function runnerPrompt(t, branch = branchOf(t.id)) {
98
138
  `means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
99
139
  "status at each milestone; 30 minutes of silence stalls it.",
100
140
  "",
101
- `When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
102
- `exactly \`git push -u origin ${branch}\` and open a pull request with`,
103
- `\`gh pr create --fill --head ${branch}\`. Then call hand_in with id ${t.id}, \`pr\` set to the`,
104
- "pull request's URL (or the commit hash if the push or the pull request failed, and say why in",
105
- "the note), `note`: one plain sentence saying what you did, and `evidence`: what you ran and",
141
+ ...ship,
142
+ "sentence saying what you did, and `evidence`: what you ran and",
106
143
  "what came back, with the commands and the lines that decided it, the test summary, the commit.",
107
144
  "Keep that as you go. What you learned about the project goes into its documents",
108
145
  "(save_document). If a call says you no longer hold it, stop.",
@@ -120,6 +157,10 @@ export function runnerPrompt(t, branch = branchOf(t.id)) {
120
157
  "If something only a person can do stands between you and done (a permission, a secret), call",
121
158
  `progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you need,`,
122
159
  "and stop. The work goes back to the person who wrote it, with that note.",
160
+ "",
161
+ "Never call pass yourself. If you stop without hand_in, the runner gives the work back",
162
+ "unfinished and shows your last message as the reason, so end with what is done and what",
163
+ "isn't, in plain sentences.",
123
164
  ].join("\n");
124
165
  }
125
166
 
@@ -127,14 +168,32 @@ export function runnerPrompt(t, branch = branchOf(t.id)) {
127
168
  * What an agent is told when its run starts again after a person answered its question: read the
128
169
  * answer, then finish exactly as the first run was told to.
129
170
  */
130
- export function resumePrompt(t, branch = branchOf(t.id)) {
171
+ export function resumePrompt(t, branch = branchOf(t.id), opts = {}) {
131
172
  return [
132
173
  `You hold passalong work ${t.id} (${t.title || t.id}), and you stopped earlier to ask the person`,
133
174
  `a question. They have answered. Call the passalong MCP tool take with id ${t.id} first: their`,
134
175
  "answer comes back with it. Then carry on in this worktree, where your earlier changes are, and",
135
176
  "finish as before:",
136
177
  "",
137
- runnerPrompt({ ...t, markdown: "" }, branch)
178
+ runnerPrompt({ ...t, markdown: "" }, branch, opts)
179
+ .split("\n")
180
+ .slice(4)
181
+ .join("\n"),
182
+ ].join("\n");
183
+ }
184
+
185
+ /**
186
+ * What an agent is told when its run starts again after it stopped part-way, because Claude's usage
187
+ * ran out or the runner was stopped: the work is still held, its changes are in this worktree.
188
+ */
189
+ export function pausedPrompt(t, branch = branchOf(t.id), opts = {}) {
190
+ return [
191
+ `You hold passalong work ${t.id} (${t.title || t.id}). An earlier run on it stopped before it`,
192
+ "finished, because Claude's usage ran out or the runner was stopped. Call the passalong MCP tool",
193
+ `take with id ${t.id} first to read the work again. Then look at what is already done in this`,
194
+ "worktree (git status, git log), carry on from there, and finish as before:",
195
+ "",
196
+ runnerPrompt({ ...t, markdown: "" }, branch, opts)
138
197
  .split("\n")
139
198
  .slice(4)
140
199
  .join("\n"),
@@ -147,7 +206,7 @@ export const dayOf = (now) => {
147
206
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
148
207
  };
149
208
 
150
- /** How many runs have started today, kept in `file` beside the rest of the CLI's state. */
209
+ /** How many runs have started today, kept in `file` beside the rest of the CLI's state: shown, not a limit. */
151
210
  export function runsToday(file, day) {
152
211
  try {
153
212
  const s = JSON.parse(readFileSync(file, "utf8"));
@@ -167,25 +226,80 @@ export function countRun(file, day) {
167
226
  /** Where the runs' worktrees and logs live: beside the checkout, never inside it. */
168
227
  export const runsDirOf = (root) => join(dirname(root), `${basename(root)}.passalong-runs`);
169
228
 
170
- /** Why a run gave its work back, said to whoever wrote it. */
171
- export function passBack({ minutes, timedOut, exit, note, said, dir, branch, failed }) {
229
+ /**
230
+ * Why a run gave its work back, said to whoever wrote it and to the team's channel. It leads with
231
+ * what the agent itself said at the end, which is nearly always the reason ("Not done: I couldn't
232
+ * start the app to take the screenshots"). It said "stopped without finishing (exit 0)", which
233
+ * means nothing to a person, and "the last thing it printed", cut mid-word (2026-11-06).
234
+ */
235
+ export function passBack({ minutes, timedOut, exit, note, said, branch, failed, machine }) {
172
236
  const how = failed
173
- ? `couldn't start (${failed})`
237
+ ? `couldn't start it (${failed})`
174
238
  : timedOut
175
239
  ? `ran out of its ${minutes} minutes`
176
- : `stopped without finishing${exit === undefined || exit === null ? "" : ` (exit ${exit})`}`;
240
+ : exit === 0
241
+ ? "ended its run without handing it in"
242
+ : `stopped with an error${exit === undefined || exit === null || exit === "" ? "" : ` (${typeof exit === "number" ? `exit code ${exit}` : exit})`}`;
243
+ const words = sentences(note || said, 600);
177
244
  return [
178
- `The runner's agent ${how}, so this is back with you and no longer marked safe to run alone.`,
179
- note ? `Its last note: "${note}".` : "It left no note.",
180
- said ? `The last thing it printed: "${said}".` : "",
181
- failed
182
- ? ""
183
- : `What it changed is kept on branch ${branch}, in ${dir}, on the machine that ran it.`,
245
+ `The runner's agent ${how}.`,
246
+ words ? `It said: "${words}"` : "It didn't say why.",
247
+ "It's back in To do and no longer marked safe to run alone, so no runner takes it again until someone ticks it.",
248
+ failed ? "" : `What it changed is kept on branch ${branch}${machine ? ` on ${machine}` : ""}.`,
184
249
  ]
185
250
  .filter(Boolean)
186
251
  .join(" ");
187
252
  }
188
253
 
254
+ /** At most `max` characters, ended at a sentence or a word rather than mid-word. */
255
+ export function sentences(text, max) {
256
+ const one = String(text ?? "")
257
+ .replace(/\s+/g, " ")
258
+ .trim();
259
+ if (one.length <= max) return one;
260
+ const cut = one.slice(0, max);
261
+ const stop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("? "), cut.lastIndexOf("! "));
262
+ if (stop > max * 0.5) return cut.slice(0, stop + 1);
263
+ const space = cut.lastIndexOf(" ");
264
+ return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
265
+ }
266
+
267
+ /**
268
+ * Whether what an agent said means its usage ran out (Claude Code's plan limit, or the API refusing
269
+ * for rate or credit), and when it comes back: `{ until }` in ms, 0 when it didn't say. `null` when
270
+ * it means something else. Only Claude Code's own phrasings, in short messages: an agent that ran
271
+ * out says so in a line, and one that added "a daily limit" to someone's app must not pause.
272
+ */
273
+ export function usageOut(text, now = Date.now()) {
274
+ const s = String(text ?? "").trim();
275
+ if (!s || s.length > 400) return null;
276
+ if (
277
+ !/usage limit reached|hit your (usage |session |weekly )?limit|(session|weekly|5-hour|five-hour|opus|sonnet) limit reached|out of (extra )?usage|rate_limit_error|credit balance is too low/i.test(
278
+ s,
279
+ )
280
+ )
281
+ return null;
282
+ // "Claude AI usage limit reached|1736789000": when it resets, in seconds.
283
+ const epoch = s.match(/\|(\d{10})\b/);
284
+ if (epoch) return { until: Number(epoch[1]) * 1000 };
285
+ // "resets 3pm", "resets at 5:30 pm": the next time the clock says that, on this machine.
286
+ const at = s.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
287
+ if (at) {
288
+ let h = Number(at[1]) % 12;
289
+ if (!at[3]) h = Number(at[1]) % 24;
290
+ else if (at[3].toLowerCase() === "pm") h += 12;
291
+ const d = new Date(now);
292
+ d.setHours(h, Number(at[2] || 0), 0, 0);
293
+ if (d.getTime() <= now) d.setDate(d.getDate() + 1);
294
+ return { until: d.getTime() };
295
+ }
296
+ return { until: 0 };
297
+ }
298
+
299
+ /** "3:00 pm", for saying when a paused runner carries on. */
300
+ export const clockOf = (ms) =>
301
+ new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
302
+
189
303
  /** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
190
304
  export function lastLine(log) {
191
305
  try {
@@ -202,24 +316,66 @@ export function git(args) {
202
316
  }
203
317
 
204
318
  /**
205
- * Start an agent and wait for it, writing what it prints to `log`. Killed after `minutes`.
206
- * Resolves `{ exit, timedOut }`, never rejects: an agent that would not start is a run that
207
- * ended without the work done.
319
+ * Start an agent and wait for it. What it prints is read a line at a time: each becomes a line of
320
+ * `log` a person can read (Claude Code's JSON events made into sentences, anything else as it came),
321
+ * and each thing it does is handed to `onStep`. Killed after `minutes`. Resolves
322
+ * `{ exit, timedOut, said, usage }`, never rejects: an agent that would not start is a run that ended without
323
+ * the work done.
208
324
  */
209
- export function runAgent({ command, args, stdin, prompt, cwd, log, minutes, env }) {
210
- return new Promise((done) => {
211
- const out = openSync(log, "a");
325
+ export function runAgent({
326
+ command,
327
+ args,
328
+ stdin,
329
+ prompt,
330
+ cwd,
331
+ log,
332
+ minutes,
333
+ env,
334
+ onStep = () => {},
335
+ }) {
336
+ return new Promise((resolve) => {
212
337
  let timedOut = false;
213
338
  let child;
339
+ /** What the agent said at the end (Claude Code's result), for why it gave the work back. */
340
+ let said = "";
341
+ /** Set when it said its usage ran out (usageOut): the run is paused, not given up. */
342
+ let usage = null;
343
+ const done = (r) => resolve({ ...r, said, usage });
344
+ const write = (text) => {
345
+ try {
346
+ appendFileSync(log, `${clock()} ${text}\n`);
347
+ } catch {}
348
+ };
214
349
  try {
215
350
  child = spawn(command, stdin ? args : [...args, prompt], {
216
351
  cwd,
217
352
  env,
218
- stdio: [stdin ? "pipe" : "ignore", out, out],
353
+ stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
219
354
  });
220
355
  } catch (err) {
356
+ write(`couldn't start: ${err.message}`);
221
357
  return done({ exit: err.message, timedOut });
222
358
  }
359
+ const reader = () => {
360
+ let rest = "";
361
+ return (chunk) => {
362
+ rest += chunk;
363
+ const lines = rest.split("\n");
364
+ rest = lines.pop() ?? "";
365
+ for (const line of lines) {
366
+ if (!line.trim()) continue;
367
+ const d = describe(line, cwd);
368
+ if (d.said) said = d.said;
369
+ usage = d.usage || usage;
370
+ if (d.log) write(d.log);
371
+ for (const step of d.step ? d.step.split("\n") : []) onStep(step);
372
+ }
373
+ };
374
+ };
375
+ child.stdout?.setEncoding("utf8");
376
+ child.stderr?.setEncoding("utf8");
377
+ child.stdout?.on("data", reader());
378
+ child.stderr?.on("data", reader());
223
379
  const timer = setTimeout(
224
380
  () => {
225
381
  timedOut = true;
@@ -229,6 +385,7 @@ export function runAgent({ command, args, stdin, prompt, cwd, log, minutes, env
229
385
  );
230
386
  child.on("error", (err) => {
231
387
  clearTimeout(timer);
388
+ write(`couldn't start: ${err.message}`);
232
389
  done({ exit: err.message, timedOut });
233
390
  });
234
391
  child.on("close", (code) => {
@@ -241,3 +398,194 @@ export function runAgent({ command, args, stdin, prompt, cwd, log, minutes, env
241
398
  }
242
399
  });
243
400
  }
401
+
402
+ const clock = () => new Date().toTimeString().slice(0, 8);
403
+
404
+ /**
405
+ * Take out what looks like a secret before a line leaves the machine: a long run of letters and
406
+ * digits (a token, a key) and the value after token=, secret=, password= or key=. Steps are read by
407
+ * the team in the hub, and an agent's commands can carry what its environment holds.
408
+ */
409
+ export function hideSecrets(text) {
410
+ return String(text)
411
+ .replace(/\b((?:[a-z_]*_)?(?:token|secret|password|passwd|api_?key|key))=\S+/gi, "$1=[hidden]")
412
+ .replace(/\b(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,}\b/g, "[hidden]");
413
+ }
414
+
415
+ const one = (s, max = 140) => {
416
+ const line =
417
+ String(s ?? "")
418
+ .split("\n")
419
+ .map((l) => l.trim())
420
+ .find(Boolean) ?? "";
421
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
422
+ };
423
+
424
+ /** What one tool call does, in words: "Editing src/deals/form.tsx", "Running `npm test`". */
425
+ export function toolStep(name, input = {}, cwd = "") {
426
+ const path = (p) => {
427
+ if (!p) return "a file";
428
+ const r = cwd ? relative(cwd, String(p)) : String(p);
429
+ return r && !r.startsWith("..") ? r : String(p);
430
+ };
431
+ const said = {
432
+ Read: () => `Reading ${path(input.file_path)}`,
433
+ Edit: () => `Editing ${path(input.file_path)}`,
434
+ MultiEdit: () => `Editing ${path(input.file_path)}`,
435
+ Write: () => `Writing ${path(input.file_path)}`,
436
+ NotebookEdit: () => `Editing ${path(input.notebook_path)}`,
437
+ Bash: () => `Running \`${one(input.command, 120)}\``,
438
+ Grep: () => `Searching for "${one(input.pattern, 80)}"`,
439
+ Glob: () => `Looking for files: ${one(input.pattern, 80)}`,
440
+ WebFetch: () => `Reading ${one(input.url, 120)}`,
441
+ WebSearch: () => `Searching the web for "${one(input.query, 80)}"`,
442
+ TodoWrite: () => {
443
+ const now = (input.todos || []).find((t) => t.status === "in_progress");
444
+ return now ? `Next: ${one(now.activeForm || now.content)}` : "Planning the steps";
445
+ },
446
+ Task: () => `Starting a helper: ${one(input.description, 80)}`,
447
+ Agent: () => `Starting a helper: ${one(input.description, 80)}`,
448
+ mcp__passalong__progress: () => `Progress: ${one(input.note)}`,
449
+ mcp__passalong__hand_in: () => `Handing it in${input.pr ? `: ${one(input.pr)}` : ""}`,
450
+ mcp__passalong__ask: () => `Asking you: ${one(input.question)}`,
451
+ mcp__passalong__take: () => "Reading the work again",
452
+ mcp__passalong__save_document: () =>
453
+ `Writing down what it learned: ${one(input.title || input.path)}`,
454
+ }[name];
455
+ if (said) return said();
456
+ const short = String(name).replace(/^mcp__([^_]+)__/, "$1: ");
457
+ return `Using ${short}`;
458
+ }
459
+
460
+ /**
461
+ * One line the agent printed, as a line of the log and, when it did something, a step.
462
+ *
463
+ * Claude Code's stream-json events become sentences: what it says (its first line), each tool it
464
+ * calls, a tool that failed, and the result at the end. Anything that is not one of those events
465
+ * (another agent, a crash) is kept as it came, and is a step too: it may be the only sign of why a
466
+ * run stopped.
467
+ */
468
+ export function describe(line, cwd = "") {
469
+ let e;
470
+ try {
471
+ e = JSON.parse(line);
472
+ } catch {
473
+ const text = hideSecrets(one(line, 300));
474
+ return { log: text, step: text, usage: usageOut(line) };
475
+ }
476
+ if (!e || typeof e !== "object") return { log: hideSecrets(one(line, 300)), step: "" };
477
+ if (e.type === "system" && e.subtype === "init")
478
+ return { log: `started${e.model ? ` (${e.model})` : ""}`, step: "" };
479
+ if (e.type === "assistant") {
480
+ // Claude Code writes the refusal as a message of its own, marked with what went wrong.
481
+ if (["rate_limit", "billing_error"].includes(e.error)) {
482
+ const said = (e.message?.content ?? []).map((c) => c.text || "").join(" ");
483
+ const text = hideSecrets(one(said || "Claude's usage ran out", 200));
484
+ return { log: text, step: "", usage: usageOut(said) || { until: 0 } };
485
+ }
486
+ const logs = [];
487
+ const steps = [];
488
+ for (const c of e.message?.content ?? []) {
489
+ if (c.type === "text" && c.text?.trim()) {
490
+ logs.push(hideSecrets(c.text.trim()));
491
+ steps.push(hideSecrets(one(c.text)));
492
+ } else if (c.type === "tool_use") {
493
+ const s = hideSecrets(toolStep(c.name, c.input, cwd));
494
+ logs.push(s);
495
+ steps.push(s);
496
+ }
497
+ }
498
+ return { log: logs.join("\n"), step: steps.join("\n") };
499
+ }
500
+ if (e.type === "user") {
501
+ const failed = (e.message?.content ?? []).find((c) => c.type === "tool_result" && c.is_error);
502
+ if (!failed) return { log: "", step: "" };
503
+ const why = Array.isArray(failed.content)
504
+ ? failed.content.map((x) => x.text || "").join(" ")
505
+ : failed.content;
506
+ const text = hideSecrets(`That didn't work: ${one(why)}`);
507
+ return { log: text, step: text };
508
+ }
509
+ if (e.type === "result") {
510
+ const how = e.is_error ? "Stopped with an error" : "Finished";
511
+ const text = hideSecrets(`${how}${e.result ? `: ${one(e.result, 200)}` : ""}`);
512
+ // Its whole last message, too: when the work comes back unfinished, this is why.
513
+ return {
514
+ log: text,
515
+ step: text,
516
+ said: hideSecrets(String(e.result || "").trim()),
517
+ usage: usageOut(e.result),
518
+ };
519
+ }
520
+ return { log: "", step: "" };
521
+ }
522
+
523
+ /**
524
+ * The git checkouts a runner started in `cwd` serves: `cwd`'s own when it is inside one, else every
525
+ * checkout in the folders under it, three levels down at most. Worktrees, dependencies, hidden
526
+ * folders and the runner's own run folders are skipped. Those without an `origin` remote are listed
527
+ * apart, since a run ends by pushing its branch there.
528
+ */
529
+ export function checkoutsIn(cwd, { git: run = git, depth = 3 } = {}) {
530
+ const top = (dir) => {
531
+ try {
532
+ return run(["-C", dir, "rev-parse", "--show-toplevel"]);
533
+ } catch {
534
+ return "";
535
+ }
536
+ };
537
+ const roots = [];
538
+ const own = top(cwd);
539
+ if (own) roots.push(own);
540
+ else {
541
+ const walk = (dir, left) => {
542
+ let names = [];
543
+ try {
544
+ names = readdirSync(dir);
545
+ } catch {
546
+ return;
547
+ }
548
+ if (names.includes(".git")) {
549
+ // A .git file is a worktree of a checkout found elsewhere, not a repo of its own.
550
+ try {
551
+ if (statSync(join(dir, ".git")).isDirectory()) roots.push(top(dir) || dir);
552
+ } catch {}
553
+ return;
554
+ }
555
+ if (!left) return;
556
+ for (const n of names.sort()) {
557
+ if (n.startsWith(".") || n === "node_modules" || n.endsWith(".passalong-runs")) continue;
558
+ const p = join(dir, n);
559
+ try {
560
+ if (statSync(p).isDirectory()) walk(p, left - 1);
561
+ } catch {}
562
+ }
563
+ };
564
+ walk(cwd, depth);
565
+ }
566
+ const found = [];
567
+ const skipped = [];
568
+ for (const root of roots) {
569
+ let remote = "";
570
+ try {
571
+ remote = run(["-C", root, "remote", "get-url", "origin"]);
572
+ } catch {}
573
+ if (remote) found.push({ root, remote });
574
+ else skipped.push(root);
575
+ }
576
+ return { found, skipped };
577
+ }
578
+
579
+ /** This computer as a person knows it: "Lukman's MacBook Pro" on a Mac, its host name elsewhere. */
580
+ export function machineName() {
581
+ if (platform() === "darwin") {
582
+ try {
583
+ const name = execFileSync("scutil", ["--get", "ComputerName"], {
584
+ encoding: "utf8",
585
+ stdio: ["ignore", "pipe", "ignore"],
586
+ }).trim();
587
+ if (name) return name;
588
+ } catch {}
589
+ }
590
+ return hostname();
591
+ }