passalong 1.2.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
@@ -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)
@@ -271,7 +271,7 @@ async function runner(f) {
271
271
  onStart: ({ repos, skipped }) => {
272
272
  console.error(
273
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 10 a day, and finishes each " +
274
+ "It takes work marked safe to run alone, up to 3 at once, and finishes each " +
275
275
  "as a pull request. You can watch each agent here and on the work's page in the hub. " +
276
276
  "Stop it with Ctrl-C: agents running then stop too.",
277
277
  );
@@ -291,12 +291,21 @@ async function runner(f) {
291
291
  );
292
292
  idle = true;
293
293
  },
294
- onCapped: (n) =>
295
- 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
+ ),
296
302
  onFinished: (t, { branch, log }) => console.error(`✓ ${t.id} finished on ${branch} (log: ${log})`),
297
303
  onWaiting: (t, { question }) =>
298
304
  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)`),
305
+ onResumed: (t, { afterPause }) =>
306
+ console.error(
307
+ `\n→ ${t.id} ${t.title} (${afterPause ? "carrying on where it stopped" : "answered: carrying on"})`,
308
+ ),
300
309
  onPassed: (t, { dir, log }) =>
301
310
  console.error(`✗ ${t.id} went back to its author, unfinished. Its worktree: ${dir} (log: ${log})`),
302
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.2.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/passalong.js CHANGED
@@ -776,8 +776,12 @@ export async function work({
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
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 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.
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
+ *
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.
781
785
  *
782
786
  * Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
783
787
  * one with an `origin` remote, so one terminal does what six used to. The folder is checked before
@@ -793,7 +797,6 @@ export async function watch({
793
797
  agent: command,
794
798
  every = 60,
795
799
  atOnce = runner.LIMITS.atOnce,
796
- perDay = runner.LIMITS.perDay,
797
800
  minutes = runner.LIMITS.minutes,
798
801
  cli,
799
802
  git = runner.git,
@@ -805,7 +808,7 @@ export async function watch({
805
808
  onTask = () => {},
806
809
  onStep = () => {},
807
810
  onIdle = () => {},
808
- onCapped = () => {},
811
+ onPaused = () => {},
809
812
  onFinished = () => {},
810
813
  onPassed = () => {},
811
814
  onWaiting = () => {},
@@ -850,7 +853,7 @@ export async function watch({
850
853
  repos,
851
854
  running: [...onNow].map(([id, title]) => ({ id, title })),
852
855
  runs_today: runner.runsToday(countFile, runner.dayOf(now())),
853
- per_day: perDay,
856
+ per_day: 0,
854
857
  at_once: atOnce,
855
858
  stopped,
856
859
  }),
@@ -937,7 +940,8 @@ export async function watch({
937
940
  say();
938
941
  return {
939
942
  run: finish(t, k, dir, branch).finally(() => {
940
- onNow.delete(t.id);
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);
941
945
  say();
942
946
  }),
943
947
  };
@@ -957,16 +961,21 @@ export async function watch({
957
961
  onStep(t, text);
958
962
  };
959
963
  step(
960
- resumed
961
- ? `Carrying on after your answer, on ${name}`
962
- : `Started on ${name} in ${k.repo}, on branch ${branch}`,
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}`,
963
969
  );
964
970
  const timer = setInterval(flush, stepEvery * 1000);
965
971
  const ran = await runAgent({
966
972
  ...agentRun,
967
- prompt: resumed
968
- ? runner.resumePrompt(t, branch, { forge: k.forge })
969
- : runner.runnerPrompt(t, branch, { forge: k.forge }),
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 }),
970
979
  cwd: dir,
971
980
  log,
972
981
  minutes,
@@ -988,6 +997,16 @@ export async function watch({
988
997
  }
989
998
  // The last step says how the run ended, while the work is still held here: once it is passed
990
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
+ }
991
1010
  if (held.state !== "done" && !held.claim?.asking)
992
1011
  step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
993
1012
  if (held.claim?.asking) step("Waiting for your answer, then it carries on");
@@ -1025,16 +1044,19 @@ export async function watch({
1025
1044
  const running = new Set();
1026
1045
  /** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
1027
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;
1028
1050
  /** Start an agent again where it stopped: the same worktree, branch and claim. */
1029
- function resume(t, k, dir, branch) {
1051
+ function resume(t, k, dir, branch, { afterPause = false } = {}) {
1030
1052
  runner.countRun(countFile, runner.dayOf(now()));
1031
1053
  onNow.set(t.id, t.title || t.id);
1032
- onResumed(t, { dir, branch });
1054
+ onResumed(t, { dir, branch, afterPause });
1033
1055
  say();
1034
- const run = finish(t, k, dir, branch, { resumed: true })
1056
+ const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
1035
1057
  .catch((err) => onTrouble(err, t))
1036
1058
  .finally(() => {
1037
- onNow.delete(t.id);
1059
+ if (!paused.has(t.id)) onNow.delete(t.id);
1038
1060
  running.delete(run);
1039
1061
  say();
1040
1062
  });
@@ -1069,15 +1091,15 @@ export async function watch({
1069
1091
  continue;
1070
1092
  }
1071
1093
  if (row.claim.asking) continue;
1072
- if (running.size >= atOnce) return;
1073
- if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
1094
+ if (running.size >= atOnce || paused.size) return;
1074
1095
  waiting.delete(id);
1075
1096
  resume(w.t, w.k, w.dir, w.branch);
1076
1097
  }
1077
1098
  }
1078
1099
 
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.
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.
1081
1103
  try {
1082
1104
  for (const row of (await api.tasks()).tasks) {
1083
1105
  const leaf = basename(row.claim?.worktree || "");
@@ -1089,21 +1111,40 @@ export async function watch({
1089
1111
  try {
1090
1112
  branch = git(["-C", dir, "branch", "--show-current"]) || branch;
1091
1113
  } catch {}
1092
- waiting.set(row.id, { t: { id: row.id, title: row.title, markdown: "" }, k, 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
+ });
1093
1120
  }
1094
1121
  } catch {}
1095
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
+
1096
1142
  let n = 0;
1097
- let cappedOn = "";
1098
1143
  for (;;) {
1144
+ await carryOnPaused();
1099
1145
  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
- }
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) {
1107
1148
  let slot;
1108
1149
  try {
1109
1150
  slot = await startOne(++n);
package/src/runner.js CHANGED
@@ -23,8 +23,16 @@ import {
23
23
  import { hostname, platform } from "node:os";
24
24
  import { basename, dirname, join, relative } from "node:path";
25
25
 
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 };
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;
28
36
 
29
37
  /**
30
38
  * What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
@@ -174,13 +182,31 @@ export function resumePrompt(t, branch = branchOf(t.id), opts = {}) {
174
182
  ].join("\n");
175
183
  }
176
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)
197
+ .split("\n")
198
+ .slice(4)
199
+ .join("\n"),
200
+ ].join("\n");
201
+ }
202
+
177
203
  /** Today, as the runner counts days: the machine's own date. */
178
204
  export const dayOf = (now) => {
179
205
  const d = new Date(now);
180
206
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
181
207
  };
182
208
 
183
- /** 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. */
184
210
  export function runsToday(file, day) {
185
211
  try {
186
212
  const s = JSON.parse(readFileSync(file, "utf8"));
@@ -238,6 +264,42 @@ export function sentences(text, max) {
238
264
  return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
239
265
  }
240
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
+
241
303
  /** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
242
304
  export function lastLine(log) {
243
305
  try {
@@ -257,7 +319,7 @@ export function git(args) {
257
319
  * Start an agent and wait for it. What it prints is read a line at a time: each becomes a line of
258
320
  * `log` a person can read (Claude Code's JSON events made into sentences, anything else as it came),
259
321
  * 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
322
+ * `{ exit, timedOut, said, usage }`, never rejects: an agent that would not start is a run that ended without
261
323
  * the work done.
262
324
  */
263
325
  export function runAgent({
@@ -276,7 +338,9 @@ export function runAgent({
276
338
  let child;
277
339
  /** What the agent said at the end (Claude Code's result), for why it gave the work back. */
278
340
  let said = "";
279
- const done = (r) => resolve({ ...r, 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 });
280
344
  const write = (text) => {
281
345
  try {
282
346
  appendFileSync(log, `${clock()} ${text}\n`);
@@ -302,6 +366,7 @@ export function runAgent({
302
366
  if (!line.trim()) continue;
303
367
  const d = describe(line, cwd);
304
368
  if (d.said) said = d.said;
369
+ usage = d.usage || usage;
305
370
  if (d.log) write(d.log);
306
371
  for (const step of d.step ? d.step.split("\n") : []) onStep(step);
307
372
  }
@@ -406,12 +471,18 @@ export function describe(line, cwd = "") {
406
471
  e = JSON.parse(line);
407
472
  } catch {
408
473
  const text = hideSecrets(one(line, 300));
409
- return { log: text, step: text };
474
+ return { log: text, step: text, usage: usageOut(line) };
410
475
  }
411
476
  if (!e || typeof e !== "object") return { log: hideSecrets(one(line, 300)), step: "" };
412
477
  if (e.type === "system" && e.subtype === "init")
413
478
  return { log: `started${e.model ? ` (${e.model})` : ""}`, step: "" };
414
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
+ }
415
486
  const logs = [];
416
487
  const steps = [];
417
488
  for (const c of e.message?.content ?? []) {
@@ -439,7 +510,12 @@ export function describe(line, cwd = "") {
439
510
  const how = e.is_error ? "Stopped with an error" : "Finished";
440
511
  const text = hideSecrets(`${how}${e.result ? `: ${one(e.result, 200)}` : ""}`);
441
512
  // 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()) };
513
+ return {
514
+ log: text,
515
+ step: text,
516
+ said: hideSecrets(String(e.result || "").trim()),
517
+ usage: usageOut(e.result),
518
+ };
443
519
  }
444
520
  return { log: "", step: "" };
445
521
  }