passalong 1.2.0 → 1.4.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
@@ -52,8 +52,9 @@ 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
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 and 10 a day, each in its own worktree,
56
- finished as a pull request; each agent's steps show in the hub
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
57
58
  passalong now what this worktree holds and what is waiting
58
59
  passalong hub open the hub in the browser
59
60
  passalong export [dir] every piece of work as plain markdown
package/bin/passalong CHANGED
@@ -23,7 +23,7 @@
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
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 and 10 a day, each in its own worktree and branch, finished as a pull request
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)
@@ -61,6 +61,7 @@ import * as api from "../src/api.js";
61
61
  import { workScaffold } from "../src/capture.js";
62
62
  import { parse, serialize } from "../src/work.js";
63
63
  import * as passalong from "../src/passalong.js";
64
+ import { otherCopies } from "../src/runner.js";
64
65
  import * as store from "../src/store.js";
65
66
 
66
67
  const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
@@ -271,12 +272,23 @@ async function runner(f) {
271
272
  onStart: ({ repos, skipped }) => {
272
273
  console.error(
273
274
  `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 10 a day, and finishes each " +
275
+ "It takes work marked safe to run alone, up to 3 at once, and finishes each " +
275
276
  "as a pull request. You can watch each agent here and on the work's page in the hub. " +
276
277
  "Stop it with Ctrl-C: agents running then stop too.",
277
278
  );
278
279
  if (skipped.length)
279
280
  console.error(`Skipped ${skipped.length} checkout${skipped.length === 1 ? "" : "s"} with no origin remote: ${some(skipped)}`);
281
+ // Another copy on PATH (npm's beside pnpm's): updating one leaves the other running.
282
+ const mine = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
283
+ const { others, shadowed } = otherCopies(fileURLToPath(import.meta.url));
284
+ for (const o of others)
285
+ console.error(
286
+ `! Another passalong${o.version ? ` (${o.version})` : ""} is installed at ${o.path}` +
287
+ (shadowed && o === others[0]
288
+ ? `, and it is the one \`passalong\` runs in a new terminal, not this ${mine}.`
289
+ : `, beside this ${mine}.`) +
290
+ " Keep one: `npm uninstall -g passalong` or `pnpm remove -g passalong`, whichever installed it.",
291
+ );
280
292
  },
281
293
  onTask: (t, { dir, branch, repo }) => {
282
294
  idle = false;
@@ -291,12 +303,21 @@ async function runner(f) {
291
303
  );
292
304
  idle = true;
293
305
  },
294
- onCapped: (n) =>
295
- console.error(`${n} runs today: that's the day's limit. Picking up again tomorrow.`),
306
+ onPaused: (t, { until }) =>
307
+ console.error(
308
+ `‖ ${t.id} paused: Claude's usage ran out. ` +
309
+ (until
310
+ ? `It carries on at ${new Date(until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
311
+ : "It tries again every 15 minutes") +
312
+ ", and takes nothing new until then.",
313
+ ),
296
314
  onFinished: (t, { branch, log }) => console.error(`✓ ${t.id} finished on ${branch} (log: ${log})`),
297
315
  onWaiting: (t, { question }) =>
298
316
  console.error(`? ${t.id} is waiting on you: "${question.split("\n")[0]}" (answer it in the hub)`),
299
- onResumed: (t) => console.error(`\n→ ${t.id} ${t.title} (answered: carrying on)`),
317
+ onResumed: (t, { afterPause }) =>
318
+ console.error(
319
+ `\n→ ${t.id} ${t.title} (${afterPause ? "carrying on where it stopped" : "answered: carrying on"})`,
320
+ ),
300
321
  onPassed: (t, { dir, log }) =>
301
322
  console.error(`✗ ${t.id} went back to its author, unfinished. Its worktree: ${dir} (log: ${log})`),
302
323
  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.2.0",
3
+ "version": "1.4.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/passalong.js CHANGED
@@ -420,6 +420,7 @@ export function needsWhy(t) {
420
420
  if (!t.mine) return "";
421
421
  if (t.state === "draft") return "in Draft: read it and mark it Ready";
422
422
  if (t.state === "stalled") return "its agent went quiet";
423
+ if (t.state === "review") return "the runner finished it: ready for your review";
423
424
  if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
424
425
  if (/^BLOCKED:/i.test(t.claim?.note || ""))
425
426
  return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
@@ -776,8 +777,12 @@ export async function work({
776
777
  /**
777
778
  * The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
778
779
  * 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 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.
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.
781
786
  *
782
787
  * Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
783
788
  * one with an `origin` remote, so one terminal does what six used to. The folder is checked before
@@ -793,7 +798,6 @@ export async function watch({
793
798
  agent: command,
794
799
  every = 60,
795
800
  atOnce = runner.LIMITS.atOnce,
796
- perDay = runner.LIMITS.perDay,
797
801
  minutes = runner.LIMITS.minutes,
798
802
  cli,
799
803
  git = runner.git,
@@ -805,7 +809,7 @@ export async function watch({
805
809
  onTask = () => {},
806
810
  onStep = () => {},
807
811
  onIdle = () => {},
808
- onCapped = () => {},
812
+ onPaused = () => {},
809
813
  onFinished = () => {},
810
814
  onPassed = () => {},
811
815
  onWaiting = () => {},
@@ -850,7 +854,7 @@ export async function watch({
850
854
  repos,
851
855
  running: [...onNow].map(([id, title]) => ({ id, title })),
852
856
  runs_today: runner.runsToday(countFile, runner.dayOf(now())),
853
- per_day: perDay,
857
+ per_day: 0,
854
858
  at_once: atOnce,
855
859
  stopped,
856
860
  }),
@@ -862,12 +866,24 @@ export async function watch({
862
866
  await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
863
867
  process.exit(130);
864
868
  });
865
- const agentRun = command
866
- ? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
867
- : runner.claudeCommand({ cli });
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
+ });
868
880
 
869
- /** Make the worktree a taken piece of work runs in, as the agent whose id took it. */
870
- function worktreeFor(k, dir, id) {
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) {
871
887
  try {
872
888
  git(["-C", k.root, "fetch", "--quiet", "origin"]);
873
889
  } catch {}
@@ -877,6 +893,10 @@ export async function watch({
877
893
  ? "origin/HEAD"
878
894
  : base;
879
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 {}
880
900
  git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
881
901
  writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
882
902
  }
@@ -903,8 +923,11 @@ export async function watch({
903
923
  repo: k.repo,
904
924
  worktree: dir,
905
925
  alone: true,
926
+ // So the server's brief ends the run the way this checkout's host takes changes.
927
+ forge: k.forge,
906
928
  });
907
- t = got?.task || null;
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;
908
931
  } catch (err) {
909
932
  onTrouble(err);
910
933
  }
@@ -915,7 +938,7 @@ export async function watch({
915
938
  // an earlier run that didn't finish (kept for its author), as when a person ticks it again.
916
939
  let branch = runner.branchOf(t.id);
917
940
  try {
918
- worktreeFor(k, dir, id);
941
+ worktreeFor(k, dir, id, t.id);
919
942
  try {
920
943
  git(["-C", dir, "switch", "--quiet", "-C", branch]);
921
944
  } catch {
@@ -937,7 +960,8 @@ export async function watch({
937
960
  say();
938
961
  return {
939
962
  run: finish(t, k, dir, branch).finally(() => {
940
- onNow.delete(t.id);
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);
941
965
  say();
942
966
  }),
943
967
  };
@@ -957,16 +981,25 @@ export async function watch({
957
981
  onStep(t, text);
958
982
  };
959
983
  step(
960
- resumed
961
- ? `Carrying on after your answer, on ${name}`
962
- : `Started on ${name} in ${k.repo}, on branch ${branch}`,
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}`,
963
989
  );
964
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 });
965
999
  const ran = await runAgent({
966
- ...agentRun,
967
- prompt: resumed
968
- ? runner.resumePrompt(t, branch, { forge: k.forge })
969
- : runner.runnerPrompt(t, branch, { forge: k.forge }),
1000
+ ...agentRun(t.brief),
1001
+ prompt,
1002
+ ...(t.brief?.usage ? { usage: t.brief.usage } : {}),
970
1003
  cwd: dir,
971
1004
  log,
972
1005
  minutes,
@@ -978,9 +1011,12 @@ export async function watch({
978
1011
  try {
979
1012
  held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
980
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";
981
1017
  // Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
982
1018
  // it back again would be refused, and it is no longer this runner's to describe.
983
- if (held?.state !== "done" && !held?.claim) {
1019
+ if (!handedIn && !held?.claim) {
984
1020
  step("It was no longer held here when the agent stopped, so the runner left it as it is");
985
1021
  await flush();
986
1022
  onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
@@ -988,11 +1024,25 @@ export async function watch({
988
1024
  }
989
1025
  // The last step says how the run ended, while the work is still held here: once it is passed
990
1026
  // back, its steps are no longer this runner's to write.
991
- if (held.state !== "done" && !held.claim?.asking)
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)
992
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
+ );
993
1043
  if (held.claim?.asking) step("Waiting for your answer, then it carries on");
994
1044
  await flush();
995
- if (held?.state === "done") {
1045
+ if (handedIn) {
996
1046
  try {
997
1047
  git(["-C", k.root, "worktree", "remove", "--force", dir]);
998
1048
  } catch {}
@@ -1025,16 +1075,19 @@ export async function watch({
1025
1075
  const running = new Set();
1026
1076
  /** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
1027
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;
1028
1081
  /** Start an agent again where it stopped: the same worktree, branch and claim. */
1029
- function resume(t, k, dir, branch) {
1082
+ function resume(t, k, dir, branch, { afterPause = false } = {}) {
1030
1083
  runner.countRun(countFile, runner.dayOf(now()));
1031
1084
  onNow.set(t.id, t.title || t.id);
1032
- onResumed(t, { dir, branch });
1085
+ onResumed(t, { dir, branch, afterPause });
1033
1086
  say();
1034
- const run = finish(t, k, dir, branch, { resumed: true })
1087
+ const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
1035
1088
  .catch((err) => onTrouble(err, t))
1036
1089
  .finally(() => {
1037
- onNow.delete(t.id);
1090
+ if (!paused.has(t.id)) onNow.delete(t.id);
1038
1091
  running.delete(run);
1039
1092
  say();
1040
1093
  });
@@ -1069,15 +1122,15 @@ export async function watch({
1069
1122
  continue;
1070
1123
  }
1071
1124
  if (row.claim.asking) continue;
1072
- if (running.size >= atOnce) return;
1073
- if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
1125
+ if (running.size >= atOnce || paused.size) return;
1074
1126
  waiting.delete(id);
1075
1127
  resume(w.t, w.k, w.dir, w.branch);
1076
1128
  }
1077
1129
  }
1078
1130
 
1079
- // A runner started again finds the questions its earlier runs left waiting: their worktrees are
1080
- // still beside their checkouts, and the work is still held by them.
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.
1081
1134
  try {
1082
1135
  for (const row of (await api.tasks()).tasks) {
1083
1136
  const leaf = basename(row.claim?.worktree || "");
@@ -1089,21 +1142,40 @@ export async function watch({
1089
1142
  try {
1090
1143
  branch = git(["-C", dir, "branch", "--show-current"]) || branch;
1091
1144
  } catch {}
1092
- waiting.set(row.id, { t: { id: row.id, title: row.title, markdown: "" }, k, dir, branch });
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
+ });
1093
1151
  }
1094
1152
  } catch {}
1095
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
+
1096
1173
  let n = 0;
1097
- let cappedOn = "";
1098
1174
  for (;;) {
1175
+ await carryOnPaused();
1099
1176
  await resumeAnswered();
1100
- while (running.size < atOnce) {
1101
- const day = runner.dayOf(now());
1102
- if (runner.runsToday(countFile, day) >= perDay) {
1103
- if (cappedOn !== day) onCapped(perDay);
1104
- cappedOn = day;
1105
- break;
1106
- }
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) {
1107
1179
  let slot;
1108
1180
  try {
1109
1181
  slot = await startOne(++n);
package/src/runner.js CHANGED
@@ -4,27 +4,39 @@
4
4
  //
5
5
  // Each piece gets its own git worktree, on a branch of its own, so up to three agents can work at
6
6
  // once without touching each other's files, and each one's change arrives as a pull request a
7
- // person reviews. At most ten runs start in a day. A run that ends without the work done (the agent
8
- // gave up, said it was blocked, or ran out of time) gives the work back to whoever wrote it, with
9
- // the agent's last note, and clears its Safe to run alone tick, so it is not tried again unwatched
10
- // until a person has looked (2026-11-06).
7
+ // person reviews: what it hands in waits for them to approve it or send it back. A run that ends
8
+ // without the work done (the agent gave up, said it was blocked, or ran out of time) gives the work
9
+ // back to whoever wrote it, with the agent's last note, and clears its Safe to run alone tick, so
10
+ // it is not tried again unwatched until a person has looked (2026-11-06). What the agent is told
11
+ // and may run comes from the server with each piece of work (the brief); the copy here is for a
12
+ // server that sends none.
11
13
  //
12
14
  // Everything that touches git, the clock or a process is passed in, so the tests can run it.
13
15
 
14
16
  import { execFileSync, spawn } from "node:child_process";
15
17
  import {
16
18
  appendFileSync,
19
+ existsSync,
17
20
  mkdirSync,
18
21
  readdirSync,
19
22
  readFileSync,
23
+ realpathSync,
20
24
  statSync,
21
25
  writeFileSync,
22
26
  } from "node:fs";
23
27
  import { hostname, platform } from "node:os";
24
- import { basename, dirname, join, relative } from "node:path";
28
+ import { basename, delimiter, dirname, join, relative } from "node:path";
25
29
 
26
- /** How many agents at once, how many runs a day, and how long one run may take. */
27
- export const LIMITS = { atOnce: 3, perDay: 10, minutes: 60 };
30
+ /**
31
+ * How many agents at once, and how long one run may take. There is no count of runs a day: it was
32
+ * ten, and a loop that gave the same work back over and over spent all ten on nothing, so the
33
+ * runner sat idle with work waiting (2026-11-06). What limits a runner now is the agent's own usage
34
+ * (`usageOut`: it pauses until that comes back) and the work the Passalong plan lets you keep.
35
+ */
36
+ export const LIMITS = { atOnce: 3, minutes: 60 };
37
+
38
+ /** How long a runner waits to try again when its agent's usage ran out and it didn't say until when. */
39
+ export const USAGE_RETRY_MS = 15 * 60 * 1000;
28
40
 
29
41
  /**
30
42
  * What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
@@ -82,7 +94,7 @@ export const branchOf = (id) => `passalong/${id}`;
82
94
  * a fresh worktree is another. The server runs in the worktree, so it is that worktree's agent: the
83
95
  * one that took the work. The prompt goes in on stdin.
84
96
  */
85
- export function claudeCommand({ node = process.execPath, cli } = {}) {
97
+ export function claudeCommand({ node = process.execPath, cli, permissions = PERMISSIONS } = {}) {
86
98
  return {
87
99
  command: "claude",
88
100
  args: [
@@ -95,7 +107,7 @@ export function claudeCommand({ node = process.execPath, cli } = {}) {
95
107
  "--permission-mode",
96
108
  "dontAsk",
97
109
  "--settings",
98
- JSON.stringify({ permissions: PERMISSIONS }),
110
+ JSON.stringify({ permissions }),
99
111
  "--mcp-config",
100
112
  JSON.stringify({ mcpServers: { passalong: { command: node, args: [cli, "mcp"] } } }),
101
113
  ],
@@ -126,6 +138,10 @@ export function runnerPrompt(t, branch = branchOf(t.id), { forge = "github" } =
126
138
  "",
127
139
  String(t.markdown || "").trim(),
128
140
  "",
141
+ `First call the passalong MCP tool take with id ${t.id}: it hands you any notes a person left.`,
142
+ "If this branch already has commits, an earlier run was sent back from review, and those notes",
143
+ "say what to change: start from what is there and change that, rather than starting over.",
144
+ "",
129
145
  `Do it in this worktree, which is on its own branch, ${branch}. Read it, work out what done`,
130
146
  `means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
131
147
  "status at each milestone; 30 minutes of silence stalls it.",
@@ -174,13 +190,31 @@ export function resumePrompt(t, branch = branchOf(t.id), opts = {}) {
174
190
  ].join("\n");
175
191
  }
176
192
 
193
+ /**
194
+ * What an agent is told when its run starts again after it stopped part-way, because Claude's usage
195
+ * ran out or the runner was stopped: the work is still held, its changes are in this worktree.
196
+ */
197
+ export function pausedPrompt(t, branch = branchOf(t.id), opts = {}) {
198
+ return [
199
+ `You hold passalong work ${t.id} (${t.title || t.id}). An earlier run on it stopped before it`,
200
+ "finished, because Claude's usage ran out or the runner was stopped. Call the passalong MCP tool",
201
+ `take with id ${t.id} first to read the work again. Then look at what is already done in this`,
202
+ "worktree (git status, git log), carry on from there, and finish as before:",
203
+ "",
204
+ runnerPrompt({ ...t, markdown: "" }, branch, opts)
205
+ .split("\n")
206
+ .slice(4)
207
+ .join("\n"),
208
+ ].join("\n");
209
+ }
210
+
177
211
  /** Today, as the runner counts days: the machine's own date. */
178
212
  export const dayOf = (now) => {
179
213
  const d = new Date(now);
180
214
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
181
215
  };
182
216
 
183
- /** How many runs have started today, kept in `file` beside the rest of the CLI's state. */
217
+ /** How many runs have started today, kept in `file` beside the rest of the CLI's state: shown, not a limit. */
184
218
  export function runsToday(file, day) {
185
219
  try {
186
220
  const s = JSON.parse(readFileSync(file, "utf8"));
@@ -238,6 +272,49 @@ export function sentences(text, max) {
238
272
  return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
239
273
  }
240
274
 
275
+ /**
276
+ * Whether what an agent said means its usage ran out (Claude Code's plan limit, or the API refusing
277
+ * for rate or credit), and when it comes back: `{ until }` in ms, 0 when it didn't say. `null` when
278
+ * it means something else. Only Claude Code's own phrasings, in short messages: an agent that ran
279
+ * out says so in a line, and one that added "a daily limit" to someone's app must not pause.
280
+ */
281
+ export const USAGE_OUT =
282
+ "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";
283
+
284
+ /** The server's pattern when it sent one (the brief), else this copy; a bad one falls back too. */
285
+ function usagePattern(source) {
286
+ try {
287
+ return new RegExp(source || USAGE_OUT, "i");
288
+ } catch {
289
+ return new RegExp(USAGE_OUT, "i");
290
+ }
291
+ }
292
+
293
+ export function usageOut(text, now = Date.now(), pattern = USAGE_OUT) {
294
+ const s = String(text ?? "").trim();
295
+ if (!s || s.length > 400) return null;
296
+ if (!usagePattern(pattern).test(s)) return null;
297
+ // "Claude AI usage limit reached|1736789000": when it resets, in seconds.
298
+ const epoch = s.match(/\|(\d{10})\b/);
299
+ if (epoch) return { until: Number(epoch[1]) * 1000 };
300
+ // "resets 3pm", "resets at 5:30 pm": the next time the clock says that, on this machine.
301
+ const at = s.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
302
+ if (at) {
303
+ let h = Number(at[1]) % 12;
304
+ if (!at[3]) h = Number(at[1]) % 24;
305
+ else if (at[3].toLowerCase() === "pm") h += 12;
306
+ const d = new Date(now);
307
+ d.setHours(h, Number(at[2] || 0), 0, 0);
308
+ if (d.getTime() <= now) d.setDate(d.getDate() + 1);
309
+ return { until: d.getTime() };
310
+ }
311
+ return { until: 0 };
312
+ }
313
+
314
+ /** "3:00 pm", for saying when a paused runner carries on. */
315
+ export const clockOf = (ms) =>
316
+ new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
317
+
241
318
  /** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
242
319
  export function lastLine(log) {
243
320
  try {
@@ -257,7 +334,7 @@ export function git(args) {
257
334
  * Start an agent and wait for it. What it prints is read a line at a time: each becomes a line of
258
335
  * `log` a person can read (Claude Code's JSON events made into sentences, anything else as it came),
259
336
  * and each thing it does is handed to `onStep`. Killed after `minutes`. Resolves
260
- * `{ exit, timedOut }`, never rejects: an agent that would not start is a run that ended without
337
+ * `{ exit, timedOut, said, usage }`, never rejects: an agent that would not start is a run that ended without
261
338
  * the work done.
262
339
  */
263
340
  export function runAgent({
@@ -270,13 +347,16 @@ export function runAgent({
270
347
  minutes,
271
348
  env,
272
349
  onStep = () => {},
350
+ usage: usageSource = USAGE_OUT,
273
351
  }) {
274
352
  return new Promise((resolve) => {
275
353
  let timedOut = false;
276
354
  let child;
277
355
  /** What the agent said at the end (Claude Code's result), for why it gave the work back. */
278
356
  let said = "";
279
- const done = (r) => resolve({ ...r, said });
357
+ /** Set when it said its usage ran out (usageOut): the run is paused, not given up. */
358
+ let usage = null;
359
+ const done = (r) => resolve({ ...r, said, usage });
280
360
  const write = (text) => {
281
361
  try {
282
362
  appendFileSync(log, `${clock()} ${text}\n`);
@@ -300,8 +380,9 @@ export function runAgent({
300
380
  rest = lines.pop() ?? "";
301
381
  for (const line of lines) {
302
382
  if (!line.trim()) continue;
303
- const d = describe(line, cwd);
383
+ const d = describe(line, cwd, usageSource);
304
384
  if (d.said) said = d.said;
385
+ usage = d.usage || usage;
305
386
  if (d.log) write(d.log);
306
387
  for (const step of d.step ? d.step.split("\n") : []) onStep(step);
307
388
  }
@@ -400,18 +481,24 @@ export function toolStep(name, input = {}, cwd = "") {
400
481
  * (another agent, a crash) is kept as it came, and is a step too: it may be the only sign of why a
401
482
  * run stopped.
402
483
  */
403
- export function describe(line, cwd = "") {
484
+ export function describe(line, cwd = "", usage = USAGE_OUT) {
404
485
  let e;
405
486
  try {
406
487
  e = JSON.parse(line);
407
488
  } catch {
408
489
  const text = hideSecrets(one(line, 300));
409
- return { log: text, step: text };
490
+ return { log: text, step: text, usage: usageOut(line, Date.now(), usage) };
410
491
  }
411
492
  if (!e || typeof e !== "object") return { log: hideSecrets(one(line, 300)), step: "" };
412
493
  if (e.type === "system" && e.subtype === "init")
413
494
  return { log: `started${e.model ? ` (${e.model})` : ""}`, step: "" };
414
495
  if (e.type === "assistant") {
496
+ // Claude Code writes the refusal as a message of its own, marked with what went wrong.
497
+ if (["rate_limit", "billing_error"].includes(e.error)) {
498
+ const said = (e.message?.content ?? []).map((c) => c.text || "").join(" ");
499
+ const text = hideSecrets(one(said || "Claude's usage ran out", 200));
500
+ return { log: text, step: "", usage: usageOut(said, Date.now(), usage) || { until: 0 } };
501
+ }
415
502
  const logs = [];
416
503
  const steps = [];
417
504
  for (const c of e.message?.content ?? []) {
@@ -439,7 +526,12 @@ export function describe(line, cwd = "") {
439
526
  const how = e.is_error ? "Stopped with an error" : "Finished";
440
527
  const text = hideSecrets(`${how}${e.result ? `: ${one(e.result, 200)}` : ""}`);
441
528
  // Its whole last message, too: when the work comes back unfinished, this is why.
442
- return { log: text, step: text, said: hideSecrets(String(e.result || "").trim()) };
529
+ return {
530
+ log: text,
531
+ step: text,
532
+ said: hideSecrets(String(e.result || "").trim()),
533
+ usage: usageOut(e.result, Date.now(), usage),
534
+ };
443
535
  }
444
536
  return { log: "", step: "" };
445
537
  }
@@ -513,3 +605,41 @@ export function machineName() {
513
605
  }
514
606
  return hostname();
515
607
  }
608
+
609
+ /**
610
+ * Other copies of passalong on PATH, besides the one at `cli`: their path and version, and whether
611
+ * one of them comes first, so `passalong` typed in a shell runs it rather than this. npm and pnpm
612
+ * each keep their own global copy, and updating one left an old runner running from the other
613
+ * (2026-11-06). Each version is read from the package.json beside the bin's real file.
614
+ */
615
+ export function otherCopies(cli, { path = process.env.PATH || "" } = {}) {
616
+ const real = (p) => {
617
+ try {
618
+ return realpathSync(p);
619
+ } catch {
620
+ return "";
621
+ }
622
+ };
623
+ const versionAt = (file) => {
624
+ try {
625
+ return (
626
+ JSON.parse(readFileSync(join(dirname(file), "..", "package.json"), "utf8")).version || ""
627
+ );
628
+ } catch {
629
+ return "";
630
+ }
631
+ };
632
+ const mine = real(cli);
633
+ const seen = new Set();
634
+ const found = [];
635
+ for (const dir of path.split(delimiter).filter(Boolean)) {
636
+ const bin = join(dir, process.platform === "win32" ? "passalong.cmd" : "passalong");
637
+ if (!existsSync(bin)) continue;
638
+ const file = real(bin);
639
+ if (!file || seen.has(file)) continue;
640
+ seen.add(file);
641
+ found.push({ path: bin, version: versionAt(file), mine: file === mine });
642
+ }
643
+ const others = found.filter((f) => !f.mine);
644
+ return { others, shadowed: found.length > 0 && !found[0].mine };
645
+ }