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