passalong 1.4.0 → 1.5.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 +0 -4
- package/bin/passalong +24 -78
- package/package.json +1 -1
- package/src/api.js +0 -5
- package/src/checks.js +2 -2
- package/src/passalong.js +7 -437
- package/src/update.js +47 -2
- package/src/runner.js +0 -645
package/src/passalong.js
CHANGED
|
@@ -8,7 +8,6 @@ import { basename, join } from "node:path";
|
|
|
8
8
|
import * as api from "./api.js";
|
|
9
9
|
import { context } from "./capture.js";
|
|
10
10
|
import { refusal, runChecks } from "./checks.js";
|
|
11
|
-
import * as runner from "./runner.js";
|
|
12
11
|
import * as store from "./store.js";
|
|
13
12
|
import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
|
|
14
13
|
|
|
@@ -219,12 +218,11 @@ function needsSync(what) {
|
|
|
219
218
|
|
|
220
219
|
/**
|
|
221
220
|
* Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
|
|
222
|
-
* several. `null` when there is nothing to do.
|
|
223
|
-
* run unattended (the runner).
|
|
221
|
+
* several. `null` when there is nothing to do.
|
|
224
222
|
*/
|
|
225
|
-
export async function nextWork({ cwd = process.cwd(), any = false
|
|
223
|
+
export async function nextWork({ cwd = process.cwd(), any = false } = {}) {
|
|
226
224
|
needsSync("taking work");
|
|
227
|
-
const { task } = await api.nextTask({ ...agent(cwd), any
|
|
225
|
+
const { task } = await api.nextTask({ ...agent(cwd), any });
|
|
228
226
|
return task || null;
|
|
229
227
|
}
|
|
230
228
|
|
|
@@ -263,7 +261,7 @@ function rememberFence(id, fence, cwd) {
|
|
|
263
261
|
writeFileSync(fenceFile(cwd), `${JSON.stringify(held, null, 2)}\n`);
|
|
264
262
|
}
|
|
265
263
|
|
|
266
|
-
/** A check
|
|
264
|
+
/** A check that was actually run: the command, and what the process returned. See checks.js. */
|
|
267
265
|
const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
|
|
268
266
|
|
|
269
267
|
/**
|
|
@@ -327,7 +325,7 @@ export async function handIn(
|
|
|
327
325
|
) {
|
|
328
326
|
needsSync("handing work in");
|
|
329
327
|
// A check that names a command is run here, before anything is sent. Checks that already carry
|
|
330
|
-
// an exit
|
|
328
|
+
// an exit were already run by the MCP server, and are not run again, so nothing runs twice.
|
|
331
329
|
const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
|
|
332
330
|
if (pending.length) {
|
|
333
331
|
const done = runChecks(pending, { cwd });
|
|
@@ -379,11 +377,9 @@ export async function assign(ref, to) {
|
|
|
379
377
|
}
|
|
380
378
|
|
|
381
379
|
/**
|
|
382
|
-
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
|
|
383
|
-
* says the runner is giving it back, unfinished: the server then clears its Safe to run alone tick,
|
|
384
|
-
* so it waits for a person rather than going straight back to an unattended agent.
|
|
380
|
+
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
|
|
385
381
|
*/
|
|
386
|
-
export async function pass(id, why, { cwd = process.cwd()
|
|
382
|
+
export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
387
383
|
needsSync("passing work");
|
|
388
384
|
if (!String(why || "").trim())
|
|
389
385
|
throw new PassalongError("say why you are passing it, so whoever is next knows");
|
|
@@ -392,7 +388,6 @@ export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}
|
|
|
392
388
|
agent: agent(cwd).agent,
|
|
393
389
|
why,
|
|
394
390
|
...(fence === undefined ? {} : { fence }),
|
|
395
|
-
...(runner ? { runner: true } : {}),
|
|
396
391
|
});
|
|
397
392
|
}
|
|
398
393
|
|
|
@@ -420,7 +415,6 @@ export function needsWhy(t) {
|
|
|
420
415
|
if (!t.mine) return "";
|
|
421
416
|
if (t.state === "draft") return "in Draft: read it and mark it Ready";
|
|
422
417
|
if (t.state === "stalled") return "its agent went quiet";
|
|
423
|
-
if (t.state === "review") return "the runner finished it: ready for your review";
|
|
424
418
|
if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
|
|
425
419
|
if (/^BLOCKED:/i.test(t.claim?.note || ""))
|
|
426
420
|
return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
|
|
@@ -738,8 +732,6 @@ export function workPrompt(t) {
|
|
|
738
732
|
*
|
|
739
733
|
* An agent that exits without finishing stops the loop, rather than piling up half-done work. It
|
|
740
734
|
* stays held here, for a person to see on the board and resume (take it) or release.
|
|
741
|
-
*
|
|
742
|
-
* The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
|
|
743
735
|
*/
|
|
744
736
|
export async function work({
|
|
745
737
|
cwd = process.cwd(),
|
|
@@ -774,428 +766,6 @@ export async function work({
|
|
|
774
766
|
}
|
|
775
767
|
}
|
|
776
768
|
|
|
777
|
-
/**
|
|
778
|
-
* The runner (src/runner.js, docs/LOOP.md §1a): work a person ticked Safe to run alone, each piece in
|
|
779
|
-
* a git worktree of its own beside its checkout, on its own branch, by an agent that finishes with
|
|
780
|
-
* a pull request. Up to `atOnce` at a time, with no count a day; a run that ends without the work
|
|
781
|
-
* done passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
|
|
782
|
-
*
|
|
783
|
-
* When the agent's usage runs out (runner.usageOut) its work is not given back: it stays held here
|
|
784
|
-
* with its worktree, the runner takes nothing new, and once the usage is back (when the agent said,
|
|
785
|
-
* or every `USAGE_RETRY_MS`) it carries on where it stopped.
|
|
786
|
-
*
|
|
787
|
-
* Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
|
|
788
|
-
* one with an `origin` remote, so one terminal does what six used to. The folder is checked before
|
|
789
|
-
* anything is said (`onStart`), so a runner that cannot run says why instead of what it will do.
|
|
790
|
-
*
|
|
791
|
-
* Work is asked for before a worktree is made: the agent's id is minted first and written into the
|
|
792
|
-
* worktree once there is work for it, so a runner serving many repos does not make and remove a
|
|
793
|
-
* worktree in each of them every minute. What the agent does is read as it goes (runner.describe)
|
|
794
|
-
* and sent to the hub every few seconds as steps, and to `onStep` for the terminal.
|
|
795
|
-
*/
|
|
796
|
-
export async function watch({
|
|
797
|
-
cwd = process.cwd(),
|
|
798
|
-
agent: command,
|
|
799
|
-
every = 60,
|
|
800
|
-
atOnce = runner.LIMITS.atOnce,
|
|
801
|
-
minutes = runner.LIMITS.minutes,
|
|
802
|
-
cli,
|
|
803
|
-
git = runner.git,
|
|
804
|
-
runAgent = runner.runAgent,
|
|
805
|
-
now = () => Date.now(),
|
|
806
|
-
countFile = join(store.HOME, "runner.json"),
|
|
807
|
-
machine = runner.machineName,
|
|
808
|
-
onStart = () => {},
|
|
809
|
-
onTask = () => {},
|
|
810
|
-
onStep = () => {},
|
|
811
|
-
onIdle = () => {},
|
|
812
|
-
onPaused = () => {},
|
|
813
|
-
onFinished = () => {},
|
|
814
|
-
onPassed = () => {},
|
|
815
|
-
onWaiting = () => {},
|
|
816
|
-
onResumed = () => {},
|
|
817
|
-
onTrouble = () => {},
|
|
818
|
-
report = api.runnerStatus,
|
|
819
|
-
postSteps = api.workSteps,
|
|
820
|
-
stepEvery = 5,
|
|
821
|
-
signals = false,
|
|
822
|
-
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
823
|
-
} = {}) {
|
|
824
|
-
needsSync("running work");
|
|
825
|
-
const inside = context(cwd).root;
|
|
826
|
-
const { found, skipped } = runner.checkoutsIn(cwd, { git });
|
|
827
|
-
if (inside && !found.length)
|
|
828
|
-
throw new PassalongError(
|
|
829
|
-
"this checkout has no `origin` remote: the runner pushes each change to a branch there",
|
|
830
|
-
);
|
|
831
|
-
if (!found.length)
|
|
832
|
-
throw new PassalongError(
|
|
833
|
-
`there's no git checkout in ${cwd} or the folders under it` +
|
|
834
|
-
(skipped.length ? ` with an \`origin\` remote (${skipped.length} without one)` : "") +
|
|
835
|
-
". Start the runner inside a repo, or in the folder that holds your repos.",
|
|
836
|
-
);
|
|
837
|
-
const checkouts = found.map(({ root, remote }) => {
|
|
838
|
-
const runs = runner.runsDirOf(root);
|
|
839
|
-
mkdirSync(runs, { recursive: true });
|
|
840
|
-
return { root, runs, repo: context(root).repo || remote, forge: runner.forgeOf(remote) };
|
|
841
|
-
});
|
|
842
|
-
const repos = checkouts.map((k) => k.repo);
|
|
843
|
-
const name = machine();
|
|
844
|
-
onStart({ repos, skipped });
|
|
845
|
-
|
|
846
|
-
// What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
|
|
847
|
-
// nobody has to look at this terminal to know. A failed report never stops a run.
|
|
848
|
-
const onNow = new Map();
|
|
849
|
-
const say = (stopped = false) =>
|
|
850
|
-
Promise.resolve(
|
|
851
|
-
report({
|
|
852
|
-
host: name,
|
|
853
|
-
repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
|
|
854
|
-
repos,
|
|
855
|
-
running: [...onNow].map(([id, title]) => ({ id, title })),
|
|
856
|
-
runs_today: runner.runsToday(countFile, runner.dayOf(now())),
|
|
857
|
-
per_day: 0,
|
|
858
|
-
at_once: atOnce,
|
|
859
|
-
stopped,
|
|
860
|
-
}),
|
|
861
|
-
).catch(() => {});
|
|
862
|
-
// Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
|
|
863
|
-
// terminal; what they held stays held, and the sweep puts it back once it goes quiet.
|
|
864
|
-
if (signals)
|
|
865
|
-
process.once("SIGINT", async () => {
|
|
866
|
-
await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
|
|
867
|
-
process.exit(130);
|
|
868
|
-
});
|
|
869
|
-
/**
|
|
870
|
-
* The agent's command for one run: `--agent` as given, else Claude Code with what the server's
|
|
871
|
-
* brief allows it (runner-brief.ts on the server), or this package's own list without one.
|
|
872
|
-
*/
|
|
873
|
-
const agentRun = (brief) =>
|
|
874
|
-
command
|
|
875
|
-
? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
|
|
876
|
-
: runner.claudeCommand({
|
|
877
|
-
cli,
|
|
878
|
-
...(brief?.permissions?.allow ? { permissions: brief.permissions } : {}),
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
/**
|
|
882
|
-
* Make the worktree a taken piece of work runs in, as the agent whose id took it. Work sent back
|
|
883
|
-
* from review starts where its last run left off: on its pushed branch, not from scratch, which
|
|
884
|
-
* would also leave the agent unable to push over its own earlier commits.
|
|
885
|
-
*/
|
|
886
|
-
function worktreeFor(k, dir, id, workId) {
|
|
887
|
-
try {
|
|
888
|
-
git(["-C", k.root, "fetch", "--quiet", "origin"]);
|
|
889
|
-
} catch {}
|
|
890
|
-
let base = "HEAD";
|
|
891
|
-
try {
|
|
892
|
-
base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
|
|
893
|
-
? "origin/HEAD"
|
|
894
|
-
: base;
|
|
895
|
-
} catch {}
|
|
896
|
-
try {
|
|
897
|
-
const earlier = `origin/${runner.branchOf(workId)}`;
|
|
898
|
-
if (git(["-C", k.root, "rev-parse", "--verify", "--quiet", earlier])) base = earlier;
|
|
899
|
-
} catch {}
|
|
900
|
-
git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
|
|
901
|
-
writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
/**
|
|
905
|
-
* One slot: a piece of work taken for one of the repos, its worktree made and its agent started.
|
|
906
|
-
* `null` when nothing waits in any of them. The run itself is handed back unawaited, so several
|
|
907
|
-
* go at once. Repos are asked in turn, starting after the last one that had work.
|
|
908
|
-
*/
|
|
909
|
-
let turn = 0;
|
|
910
|
-
async function startOne(n) {
|
|
911
|
-
let t = null;
|
|
912
|
-
let k = null;
|
|
913
|
-
let dir = "";
|
|
914
|
-
let id = "";
|
|
915
|
-
for (let i = 0; i < checkouts.length && !t; i++) {
|
|
916
|
-
k = checkouts[(turn + i) % checkouts.length];
|
|
917
|
-
dir = join(k.runs, `run-${now()}-${n}`);
|
|
918
|
-
id = randomBytes(12).toString("hex");
|
|
919
|
-
try {
|
|
920
|
-
const got = await api.nextTask({
|
|
921
|
-
agent: id,
|
|
922
|
-
host: hostname(),
|
|
923
|
-
repo: k.repo,
|
|
924
|
-
worktree: dir,
|
|
925
|
-
alone: true,
|
|
926
|
-
// So the server's brief ends the run the way this checkout's host takes changes.
|
|
927
|
-
forge: k.forge,
|
|
928
|
-
});
|
|
929
|
-
// The brief rides with the work: what to tell its agent, from the server (1.4.0's).
|
|
930
|
-
t = got?.task ? { ...got.task, brief: got.runner || null } : null;
|
|
931
|
-
} catch (err) {
|
|
932
|
-
onTrouble(err);
|
|
933
|
-
}
|
|
934
|
-
if (t) turn = (turn + i + 1) % checkouts.length;
|
|
935
|
-
}
|
|
936
|
-
if (!t || !k) return null;
|
|
937
|
-
// Its branch, or a fresh one beside it when that one is still checked out in the worktree of
|
|
938
|
-
// an earlier run that didn't finish (kept for its author), as when a person ticks it again.
|
|
939
|
-
let branch = runner.branchOf(t.id);
|
|
940
|
-
try {
|
|
941
|
-
worktreeFor(k, dir, id, t.id);
|
|
942
|
-
try {
|
|
943
|
-
git(["-C", dir, "switch", "--quiet", "-C", branch]);
|
|
944
|
-
} catch {
|
|
945
|
-
branch = `${branch}-${now().toString(36)}`;
|
|
946
|
-
git(["-C", dir, "switch", "--quiet", "-c", branch]);
|
|
947
|
-
}
|
|
948
|
-
} catch (err) {
|
|
949
|
-
// Taken and then stuck before any agent started: never left held with nobody on it.
|
|
950
|
-
const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
|
|
951
|
-
await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
|
|
952
|
-
onTrouble(e, t),
|
|
953
|
-
);
|
|
954
|
-
onPassed(t, { why, dir, log: "" });
|
|
955
|
-
return { run: Promise.resolve() };
|
|
956
|
-
}
|
|
957
|
-
runner.countRun(countFile, runner.dayOf(now()));
|
|
958
|
-
onNow.set(t.id, t.title || t.id);
|
|
959
|
-
onTask(t, { dir, branch, repo: k.repo });
|
|
960
|
-
say();
|
|
961
|
-
return {
|
|
962
|
-
run: finish(t, k, dir, branch).finally(() => {
|
|
963
|
-
// A paused run is still on: the hub keeps showing it, with its last step saying why.
|
|
964
|
-
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
965
|
-
say();
|
|
966
|
-
}),
|
|
967
|
-
};
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
async function finish(t, k, dir, branch, { resumed = false } = {}) {
|
|
971
|
-
const log = join(k.runs, `${t.id}.log`);
|
|
972
|
-
// Steps go to the hub every few seconds rather than one request each: an agent can take a
|
|
973
|
-
// dozen in a second. Whatever is left goes when the run ends.
|
|
974
|
-
const pending = [];
|
|
975
|
-
const flush = async () => {
|
|
976
|
-
if (!pending.length) return;
|
|
977
|
-
await postSteps(t.id, pending.splice(0)).catch(() => {});
|
|
978
|
-
};
|
|
979
|
-
const step = (text) => {
|
|
980
|
-
pending.push({ at: new Date(now()).toISOString(), text });
|
|
981
|
-
onStep(t, text);
|
|
982
|
-
};
|
|
983
|
-
step(
|
|
984
|
-
resumed === "usage"
|
|
985
|
-
? `Carrying on now that Claude's usage is back, on ${name}`
|
|
986
|
-
: resumed
|
|
987
|
-
? `Carrying on after your answer, on ${name}`
|
|
988
|
-
: `Started on ${name} in ${k.repo}, on branch ${branch}`,
|
|
989
|
-
);
|
|
990
|
-
const timer = setInterval(flush, stepEvery * 1000);
|
|
991
|
-
// The server's words when it sent them, with this run's branch filled in; else this package's.
|
|
992
|
-
const told = (said) => (typeof said === "string" ? said.replaceAll("{branch}", branch) : "");
|
|
993
|
-
const prompt =
|
|
994
|
-
resumed === "usage"
|
|
995
|
-
? told(t.brief?.paused) || runner.pausedPrompt(t, branch, { forge: k.forge })
|
|
996
|
-
: resumed
|
|
997
|
-
? told(t.brief?.resume) || runner.resumePrompt(t, branch, { forge: k.forge })
|
|
998
|
-
: told(t.brief?.start) || runner.runnerPrompt(t, branch, { forge: k.forge });
|
|
999
|
-
const ran = await runAgent({
|
|
1000
|
-
...agentRun(t.brief),
|
|
1001
|
-
prompt,
|
|
1002
|
-
...(t.brief?.usage ? { usage: t.brief.usage } : {}),
|
|
1003
|
-
cwd: dir,
|
|
1004
|
-
log,
|
|
1005
|
-
minutes,
|
|
1006
|
-
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
1007
|
-
onStep: step,
|
|
1008
|
-
}).finally(() => clearInterval(timer));
|
|
1009
|
-
if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
|
|
1010
|
-
let held = null;
|
|
1011
|
-
try {
|
|
1012
|
-
held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
|
|
1013
|
-
} catch {}
|
|
1014
|
-
// Handed in: Done, or (from 1.4.0's server on) waiting for a person to approve it or send it
|
|
1015
|
-
// back. Either way this run is over and its branch is pushed.
|
|
1016
|
-
const handedIn = held?.state === "done" || held?.state === "review";
|
|
1017
|
-
// Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
|
|
1018
|
-
// it back again would be refused, and it is no longer this runner's to describe.
|
|
1019
|
-
if (!handedIn && !held?.claim) {
|
|
1020
|
-
step("It was no longer held here when the agent stopped, so the runner left it as it is");
|
|
1021
|
-
await flush();
|
|
1022
|
-
onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
|
|
1023
|
-
return;
|
|
1024
|
-
}
|
|
1025
|
-
// The last step says how the run ended, while the work is still held here: once it is passed
|
|
1026
|
-
// back, its steps are no longer this runner's to write.
|
|
1027
|
-
// Its usage ran out: not given up. It stays held here, and carries on once the usage is back.
|
|
1028
|
-
if (ran.usage && held.state !== "done" && !held.claim?.asking) {
|
|
1029
|
-
pauseUntil = Math.max(pauseUntil, ran.usage.until || now() + runner.USAGE_RETRY_MS);
|
|
1030
|
-
const when = ran.usage.until ? `at ${runner.clockOf(pauseUntil)}` : "when it's back";
|
|
1031
|
-
step(`Paused: Claude's usage ran out. It carries on here ${when}, with nothing lost.`);
|
|
1032
|
-
await flush();
|
|
1033
|
-
paused.set(t.id, { t, k, dir, branch });
|
|
1034
|
-
onPaused(t, { until: ran.usage.until ? pauseUntil : 0 });
|
|
1035
|
-
return;
|
|
1036
|
-
}
|
|
1037
|
-
if (!handedIn && !held.claim?.asking)
|
|
1038
|
-
step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
|
|
1039
|
-
if (held.state === "review")
|
|
1040
|
-
step(
|
|
1041
|
-
"Handed in. It's ready for your review: approve it, or send it back with what to change",
|
|
1042
|
-
);
|
|
1043
|
-
if (held.claim?.asking) step("Waiting for your answer, then it carries on");
|
|
1044
|
-
await flush();
|
|
1045
|
-
if (handedIn) {
|
|
1046
|
-
try {
|
|
1047
|
-
git(["-C", k.root, "worktree", "remove", "--force", dir]);
|
|
1048
|
-
} catch {}
|
|
1049
|
-
onFinished(t, { branch, log });
|
|
1050
|
-
return;
|
|
1051
|
-
}
|
|
1052
|
-
// It stopped to ask, and the question is waiting on a person: not given up. The work stays held
|
|
1053
|
-
// here with its worktree, and the run picks up again once they answer (resumeAnswered).
|
|
1054
|
-
if (held?.claim?.asking) {
|
|
1055
|
-
waiting.set(t.id, { t, k, dir, branch });
|
|
1056
|
-
onWaiting(t, { question: held.claim.asking, dir });
|
|
1057
|
-
return;
|
|
1058
|
-
}
|
|
1059
|
-
const why = runner.passBack({
|
|
1060
|
-
minutes,
|
|
1061
|
-
...ran,
|
|
1062
|
-
note: held?.claim?.note || "",
|
|
1063
|
-
said: ran.said || runner.lastLine(log),
|
|
1064
|
-
branch,
|
|
1065
|
-
machine: name,
|
|
1066
|
-
});
|
|
1067
|
-
try {
|
|
1068
|
-
await pass(t.id, why, { cwd: dir, runner: true });
|
|
1069
|
-
onPassed(t, { why, dir, log });
|
|
1070
|
-
} catch (err) {
|
|
1071
|
-
onTrouble(err, t);
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
1074
|
-
|
|
1075
|
-
const running = new Set();
|
|
1076
|
-
/** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
|
|
1077
|
-
const waiting = new Map();
|
|
1078
|
-
/** Runs stopped because the agent's usage ran out, the same way: they carry on at `pauseUntil`. */
|
|
1079
|
-
const paused = new Map();
|
|
1080
|
-
let pauseUntil = 0;
|
|
1081
|
-
/** Start an agent again where it stopped: the same worktree, branch and claim. */
|
|
1082
|
-
function resume(t, k, dir, branch, { afterPause = false } = {}) {
|
|
1083
|
-
runner.countRun(countFile, runner.dayOf(now()));
|
|
1084
|
-
onNow.set(t.id, t.title || t.id);
|
|
1085
|
-
onResumed(t, { dir, branch, afterPause });
|
|
1086
|
-
say();
|
|
1087
|
-
const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
|
|
1088
|
-
.catch((err) => onTrouble(err, t))
|
|
1089
|
-
.finally(() => {
|
|
1090
|
-
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
1091
|
-
running.delete(run);
|
|
1092
|
-
say();
|
|
1093
|
-
});
|
|
1094
|
-
running.add(run);
|
|
1095
|
-
}
|
|
1096
|
-
|
|
1097
|
-
/**
|
|
1098
|
-
* Questions answered since the last look start their run again, while there is a slot and the
|
|
1099
|
-
* day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
|
|
1100
|
-
*/
|
|
1101
|
-
async function resumeAnswered() {
|
|
1102
|
-
if (!waiting.size) return;
|
|
1103
|
-
let rows = [];
|
|
1104
|
-
try {
|
|
1105
|
-
rows = (await api.tasks()).tasks;
|
|
1106
|
-
} catch (err) {
|
|
1107
|
-
return onTrouble(err);
|
|
1108
|
-
}
|
|
1109
|
-
for (const [id, w] of [...waiting]) {
|
|
1110
|
-
const row = rows.find((x) => x.id === id);
|
|
1111
|
-
if (row?.state === "done") {
|
|
1112
|
-
waiting.delete(id);
|
|
1113
|
-
try {
|
|
1114
|
-
git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
|
|
1115
|
-
} catch {}
|
|
1116
|
-
onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
|
|
1117
|
-
continue;
|
|
1118
|
-
}
|
|
1119
|
-
if (!row?.claim) {
|
|
1120
|
-
waiting.delete(id);
|
|
1121
|
-
onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
|
|
1122
|
-
continue;
|
|
1123
|
-
}
|
|
1124
|
-
if (row.claim.asking) continue;
|
|
1125
|
-
if (running.size >= atOnce || paused.size) return;
|
|
1126
|
-
waiting.delete(id);
|
|
1127
|
-
resume(w.t, w.k, w.dir, w.branch);
|
|
1128
|
-
}
|
|
1129
|
-
}
|
|
1130
|
-
|
|
1131
|
-
// A runner started again finds what its earlier runs left held: their worktrees are still beside
|
|
1132
|
-
// their checkouts. One waiting on a question waits on; any other (paused when the usage ran out,
|
|
1133
|
-
// or stopped with the runner) carries on straight away.
|
|
1134
|
-
try {
|
|
1135
|
-
for (const row of (await api.tasks()).tasks) {
|
|
1136
|
-
const leaf = basename(row.claim?.worktree || "");
|
|
1137
|
-
if (!leaf.startsWith("run-") || row.state === "done") continue;
|
|
1138
|
-
const k = checkouts.find((x) => existsSync(join(x.runs, leaf)));
|
|
1139
|
-
if (!k) continue;
|
|
1140
|
-
const dir = join(k.runs, leaf);
|
|
1141
|
-
let branch = runner.branchOf(row.id);
|
|
1142
|
-
try {
|
|
1143
|
-
branch = git(["-C", dir, "branch", "--show-current"]) || branch;
|
|
1144
|
-
} catch {}
|
|
1145
|
-
(row.claim?.asking ? waiting : paused).set(row.id, {
|
|
1146
|
-
t: { id: row.id, title: row.title, markdown: "" },
|
|
1147
|
-
k,
|
|
1148
|
-
dir,
|
|
1149
|
-
branch,
|
|
1150
|
-
});
|
|
1151
|
-
}
|
|
1152
|
-
} catch {}
|
|
1153
|
-
|
|
1154
|
-
/**
|
|
1155
|
-
* While the agent's usage is out: keep each paused run's hold alive (an empty report of steps
|
|
1156
|
-
* renews it, so the hub doesn't call it gone quiet), and once the time comes, carry them on. If
|
|
1157
|
-
* the usage is still out they pause again at once, which costs nothing.
|
|
1158
|
-
*/
|
|
1159
|
-
async function carryOnPaused() {
|
|
1160
|
-
if (!paused.size) return;
|
|
1161
|
-
if (now() < pauseUntil) {
|
|
1162
|
-
for (const id of paused.keys()) await postSteps(id, []).catch(() => {});
|
|
1163
|
-
return;
|
|
1164
|
-
}
|
|
1165
|
-
pauseUntil = 0;
|
|
1166
|
-
for (const [id, p] of [...paused]) {
|
|
1167
|
-
if (running.size >= atOnce) break;
|
|
1168
|
-
paused.delete(id);
|
|
1169
|
-
resume(p.t, p.k, p.dir, p.branch, { afterPause: true });
|
|
1170
|
-
}
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
let n = 0;
|
|
1174
|
-
for (;;) {
|
|
1175
|
-
await carryOnPaused();
|
|
1176
|
-
await resumeAnswered();
|
|
1177
|
-
// Nothing new while the usage is out: it would only start and stop at once.
|
|
1178
|
-
while (running.size < atOnce && !paused.size && now() >= pauseUntil) {
|
|
1179
|
-
let slot;
|
|
1180
|
-
try {
|
|
1181
|
-
slot = await startOne(++n);
|
|
1182
|
-
} catch (err) {
|
|
1183
|
-
onTrouble(err);
|
|
1184
|
-
break;
|
|
1185
|
-
}
|
|
1186
|
-
if (!slot) {
|
|
1187
|
-
if (!running.size) onIdle(repos);
|
|
1188
|
-
break;
|
|
1189
|
-
}
|
|
1190
|
-
const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
|
|
1191
|
-
running.add(run);
|
|
1192
|
-
}
|
|
1193
|
-
// Wait for a run to end or for the next look, whichever is first.
|
|
1194
|
-
await say();
|
|
1195
|
-
await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
769
|
/**
|
|
1200
770
|
* Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
|
|
1201
771
|
* moment a planner has written several and you have read them all. Only your own: sending work on
|
package/src/update.js
CHANGED
|
@@ -20,8 +20,15 @@
|
|
|
20
20
|
// command started it, and config.json is read, patched and written whole: a refresh that read it
|
|
21
21
|
// just before `logout` or `use` wrote it, and wrote it back after, put the old accounts and the old
|
|
22
22
|
// default back. This file has one writer, so there is nothing to lose.
|
|
23
|
-
import {
|
|
24
|
-
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { delimiter, dirname, join } from "node:path";
|
|
25
32
|
import { HOME } from "./store.js";
|
|
26
33
|
|
|
27
34
|
const FILE = join(HOME, "update.json");
|
|
@@ -138,3 +145,41 @@ export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writ
|
|
|
138
145
|
}
|
|
139
146
|
return latest;
|
|
140
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Other copies of passalong on PATH, besides the one at `cli`: their path and version, and whether
|
|
151
|
+
* one of them comes first, so `passalong` typed in a shell runs it rather than this. npm and pnpm
|
|
152
|
+
* each keep their own global copy, and updating one left the old one running from the other
|
|
153
|
+
* (2026-11-06). Each version is read from the package.json beside the bin's real file.
|
|
154
|
+
*/
|
|
155
|
+
export function otherCopies(cli, { path = process.env.PATH || "" } = {}) {
|
|
156
|
+
const real = (p) => {
|
|
157
|
+
try {
|
|
158
|
+
return realpathSync(p);
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const versionAt = (file) => {
|
|
164
|
+
try {
|
|
165
|
+
return (
|
|
166
|
+
JSON.parse(readFileSync(join(dirname(file), "..", "package.json"), "utf8")).version || ""
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const mine = real(cli);
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
const found = [];
|
|
175
|
+
for (const dir of path.split(delimiter).filter(Boolean)) {
|
|
176
|
+
const bin = join(dir, process.platform === "win32" ? "passalong.cmd" : "passalong");
|
|
177
|
+
if (!existsSync(bin)) continue;
|
|
178
|
+
const file = real(bin);
|
|
179
|
+
if (!file || seen.has(file)) continue;
|
|
180
|
+
seen.add(file);
|
|
181
|
+
found.push({ path: bin, version: versionAt(file), mine: file === mine });
|
|
182
|
+
}
|
|
183
|
+
const others = found.filter((f) => !f.mine);
|
|
184
|
+
return { others, shadowed: found.length > 0 && !found[0].mine };
|
|
185
|
+
}
|