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 +3 -2
- package/bin/passalong +26 -5
- package/package.json +1 -1
- package/src/passalong.js +112 -40
- package/src/runner.js +146 -16
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
|
|
56
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
295
|
-
console.error(
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
/**
|
|
870
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
962
|
-
:
|
|
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
|
|
968
|
-
|
|
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 (
|
|
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
|
-
|
|
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 (
|
|
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
|
|
1080
|
-
//
|
|
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
|
-
|
|
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
|
|
1101
|
-
|
|
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
|
|
8
|
-
// gave up, said it was blocked, or ran out of time) gives the work
|
|
9
|
-
// the agent's last note, and clears its Safe to run alone tick, so
|
|
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
|
-
/**
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
+
}
|