passalong 1.1.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/bin/passalong +37 -14
- package/package.json +1 -1
- package/src/api.js +3 -0
- package/src/passalong.js +193 -74
- package/src/runner.js +378 -30
package/README.md
CHANGED
|
@@ -51,8 +51,10 @@ 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
|
-
|
|
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, 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
|
|
56
58
|
passalong now what this worktree holds and what is waiting
|
|
57
59
|
passalong hub open the hub in the browser
|
|
58
60
|
passalong export [dir] every piece of work as plain markdown
|
package/bin/passalong
CHANGED
|
@@ -22,8 +22,8 @@
|
|
|
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,
|
|
26
|
-
// up to 3 at once
|
|
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, 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)
|
|
@@ -257,32 +257,55 @@ 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 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
|
-
|
|
281
|
-
console.error(
|
|
294
|
+
onPaused: (t, { until }) =>
|
|
295
|
+
console.error(
|
|
296
|
+
`‖ ${t.id} paused: Claude's usage ran out. ` +
|
|
297
|
+
(until
|
|
298
|
+
? `It carries on at ${new Date(until).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`
|
|
299
|
+
: "It tries again every 15 minutes") +
|
|
300
|
+
", and takes nothing new until then.",
|
|
301
|
+
),
|
|
282
302
|
onFinished: (t, { branch, log }) => console.error(`✓ ${t.id} finished on ${branch} (log: ${log})`),
|
|
283
303
|
onWaiting: (t, { question }) =>
|
|
284
304
|
console.error(`? ${t.id} is waiting on you: "${question.split("\n")[0]}" (answer it in the hub)`),
|
|
285
|
-
onResumed: (t
|
|
305
|
+
onResumed: (t, { afterPause }) =>
|
|
306
|
+
console.error(
|
|
307
|
+
`\n→ ${t.id} ${t.title} (${afterPause ? "carrying on where it stopped" : "answered: carrying on"})`,
|
|
308
|
+
),
|
|
286
309
|
onPassed: (t, { dir, log }) =>
|
|
287
310
|
console.error(`✗ ${t.id} went back to its author, unfinished. Its worktree: ${dir} (log: ${log})`),
|
|
288
311
|
onTrouble: (err, t) => console.error(`! ${t ? `${t.id}: ` : ""}${err?.message || err}`),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Work, projects, documents and files for a team and its agents: take work, hand it in with evidence, and keep what the team knows in its projects.",
|
|
5
5
|
"mcpName": "io.github.codellyson/passalong",
|
|
6
6
|
"keywords": [
|
package/src/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,51 +775,72 @@ 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
|
|
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.
|
|
778
|
+
* a git worktree of its own beside its checkout, on its own branch, by an agent that finishes with
|
|
779
|
+
* a pull request. Up to `atOnce` at a time, with no count a day; a run that ends without the work
|
|
780
|
+
* done passes it back to its author. Runs until stopped; `sleep` is how the tests stop it.
|
|
781
781
|
*
|
|
782
|
-
*
|
|
783
|
-
* the
|
|
784
|
-
*
|
|
782
|
+
* When the agent's usage runs out (runner.usageOut) its work is not given back: it stays held here
|
|
783
|
+
* with its worktree, the runner takes nothing new, and once the usage is back (when the agent said,
|
|
784
|
+
* or every `USAGE_RETRY_MS`) it carries on where it stopped.
|
|
785
|
+
*
|
|
786
|
+
* Started inside a checkout it serves that repo. Started in a folder of checkouts it serves every
|
|
787
|
+
* one with an `origin` remote, so one terminal does what six used to. The folder is checked before
|
|
788
|
+
* anything is said (`onStart`), so a runner that cannot run says why instead of what it will do.
|
|
789
|
+
*
|
|
790
|
+
* Work is asked for before a worktree is made: the agent's id is minted first and written into the
|
|
791
|
+
* worktree once there is work for it, so a runner serving many repos does not make and remove a
|
|
792
|
+
* worktree in each of them every minute. What the agent does is read as it goes (runner.describe)
|
|
793
|
+
* and sent to the hub every few seconds as steps, and to `onStep` for the terminal.
|
|
785
794
|
*/
|
|
786
795
|
export async function watch({
|
|
787
796
|
cwd = process.cwd(),
|
|
788
797
|
agent: command,
|
|
789
798
|
every = 60,
|
|
790
799
|
atOnce = runner.LIMITS.atOnce,
|
|
791
|
-
perDay = runner.LIMITS.perDay,
|
|
792
800
|
minutes = runner.LIMITS.minutes,
|
|
793
801
|
cli,
|
|
794
802
|
git = runner.git,
|
|
795
803
|
runAgent = runner.runAgent,
|
|
796
804
|
now = () => Date.now(),
|
|
797
805
|
countFile = join(store.HOME, "runner.json"),
|
|
806
|
+
machine = runner.machineName,
|
|
807
|
+
onStart = () => {},
|
|
798
808
|
onTask = () => {},
|
|
809
|
+
onStep = () => {},
|
|
799
810
|
onIdle = () => {},
|
|
800
|
-
|
|
811
|
+
onPaused = () => {},
|
|
801
812
|
onFinished = () => {},
|
|
802
813
|
onPassed = () => {},
|
|
803
814
|
onWaiting = () => {},
|
|
804
815
|
onResumed = () => {},
|
|
805
816
|
onTrouble = () => {},
|
|
806
817
|
report = api.runnerStatus,
|
|
818
|
+
postSteps = api.workSteps,
|
|
819
|
+
stepEvery = 5,
|
|
807
820
|
signals = false,
|
|
808
821
|
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
809
822
|
} = {}) {
|
|
810
823
|
needsSync("running work");
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
try {
|
|
815
|
-
remote = git(["-C", root, "remote", "get-url", "origin"]);
|
|
816
|
-
} catch {}
|
|
817
|
-
if (!remote)
|
|
824
|
+
const inside = context(cwd).root;
|
|
825
|
+
const { found, skipped } = runner.checkoutsIn(cwd, { git });
|
|
826
|
+
if (inside && !found.length)
|
|
818
827
|
throw new PassalongError(
|
|
819
828
|
"this checkout has no `origin` remote: the runner pushes each change to a branch there",
|
|
820
829
|
);
|
|
821
|
-
|
|
822
|
-
|
|
830
|
+
if (!found.length)
|
|
831
|
+
throw new PassalongError(
|
|
832
|
+
`there's no git checkout in ${cwd} or the folders under it` +
|
|
833
|
+
(skipped.length ? ` with an \`origin\` remote (${skipped.length} without one)` : "") +
|
|
834
|
+
". Start the runner inside a repo, or in the folder that holds your repos.",
|
|
835
|
+
);
|
|
836
|
+
const checkouts = found.map(({ root, remote }) => {
|
|
837
|
+
const runs = runner.runsDirOf(root);
|
|
838
|
+
mkdirSync(runs, { recursive: true });
|
|
839
|
+
return { root, runs, repo: context(root).repo || remote, forge: runner.forgeOf(remote) };
|
|
840
|
+
});
|
|
841
|
+
const repos = checkouts.map((k) => k.repo);
|
|
842
|
+
const name = machine();
|
|
843
|
+
onStart({ repos, skipped });
|
|
823
844
|
|
|
824
845
|
// What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
|
|
825
846
|
// nobody has to look at this terminal to know. A failed report never stops a run.
|
|
@@ -827,11 +848,12 @@ export async function watch({
|
|
|
827
848
|
const say = (stopped = false) =>
|
|
828
849
|
Promise.resolve(
|
|
829
850
|
report({
|
|
830
|
-
host:
|
|
831
|
-
repo
|
|
851
|
+
host: name,
|
|
852
|
+
repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
|
|
853
|
+
repos,
|
|
832
854
|
running: [...onNow].map(([id, title]) => ({ id, title })),
|
|
833
855
|
runs_today: runner.runsToday(countFile, runner.dayOf(now())),
|
|
834
|
-
per_day:
|
|
856
|
+
per_day: 0,
|
|
835
857
|
at_once: atOnce,
|
|
836
858
|
stopped,
|
|
837
859
|
}),
|
|
@@ -847,36 +869,56 @@ export async function watch({
|
|
|
847
869
|
? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
|
|
848
870
|
: runner.claudeCommand({ cli });
|
|
849
871
|
|
|
850
|
-
/**
|
|
851
|
-
|
|
852
|
-
* nothing waits. The run itself is handed back unawaited, so several go at once.
|
|
853
|
-
*/
|
|
854
|
-
async function startOne(n) {
|
|
872
|
+
/** Make the worktree a taken piece of work runs in, as the agent whose id took it. */
|
|
873
|
+
function worktreeFor(k, dir, id) {
|
|
855
874
|
try {
|
|
856
|
-
git(["-C", root, "fetch", "--quiet", "origin"]);
|
|
875
|
+
git(["-C", k.root, "fetch", "--quiet", "origin"]);
|
|
857
876
|
} catch {}
|
|
858
877
|
let base = "HEAD";
|
|
859
878
|
try {
|
|
860
|
-
base = git(["-C", root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
|
|
879
|
+
base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
|
|
861
880
|
? "origin/HEAD"
|
|
862
881
|
: base;
|
|
863
882
|
} catch {}
|
|
864
|
-
|
|
865
|
-
|
|
883
|
+
git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
|
|
884
|
+
writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* One slot: a piece of work taken for one of the repos, its worktree made and its agent started.
|
|
889
|
+
* `null` when nothing waits in any of them. The run itself is handed back unawaited, so several
|
|
890
|
+
* go at once. Repos are asked in turn, starting after the last one that had work.
|
|
891
|
+
*/
|
|
892
|
+
let turn = 0;
|
|
893
|
+
async function startOne(n) {
|
|
866
894
|
let t = null;
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
895
|
+
let k = null;
|
|
896
|
+
let dir = "";
|
|
897
|
+
let id = "";
|
|
898
|
+
for (let i = 0; i < checkouts.length && !t; i++) {
|
|
899
|
+
k = checkouts[(turn + i) % checkouts.length];
|
|
900
|
+
dir = join(k.runs, `run-${now()}-${n}`);
|
|
901
|
+
id = randomBytes(12).toString("hex");
|
|
902
|
+
try {
|
|
903
|
+
const got = await api.nextTask({
|
|
904
|
+
agent: id,
|
|
905
|
+
host: hostname(),
|
|
906
|
+
repo: k.repo,
|
|
907
|
+
worktree: dir,
|
|
908
|
+
alone: true,
|
|
909
|
+
});
|
|
910
|
+
t = got?.task || null;
|
|
911
|
+
} catch (err) {
|
|
912
|
+
onTrouble(err);
|
|
913
|
+
}
|
|
914
|
+
if (t) turn = (turn + i + 1) % checkouts.length;
|
|
875
915
|
}
|
|
916
|
+
if (!t || !k) return null;
|
|
876
917
|
// Its branch, or a fresh one beside it when that one is still checked out in the worktree of
|
|
877
918
|
// an earlier run that didn't finish (kept for its author), as when a person ticks it again.
|
|
878
919
|
let branch = runner.branchOf(t.id);
|
|
879
920
|
try {
|
|
921
|
+
worktreeFor(k, dir, id);
|
|
880
922
|
try {
|
|
881
923
|
git(["-C", dir, "switch", "--quiet", "-C", branch]);
|
|
882
924
|
} catch {
|
|
@@ -886,39 +928,92 @@ export async function watch({
|
|
|
886
928
|
} catch (err) {
|
|
887
929
|
// Taken and then stuck before any agent started: never left held with nobody on it.
|
|
888
930
|
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) =>
|
|
931
|
+
await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
|
|
932
|
+
onTrouble(e, t),
|
|
933
|
+
);
|
|
890
934
|
onPassed(t, { why, dir, log: "" });
|
|
891
935
|
return { run: Promise.resolve() };
|
|
892
936
|
}
|
|
893
937
|
runner.countRun(countFile, runner.dayOf(now()));
|
|
894
938
|
onNow.set(t.id, t.title || t.id);
|
|
895
|
-
onTask(t, { dir, branch });
|
|
939
|
+
onTask(t, { dir, branch, repo: k.repo });
|
|
896
940
|
say();
|
|
897
941
|
return {
|
|
898
|
-
run: finish(t, dir, branch).finally(() => {
|
|
899
|
-
|
|
942
|
+
run: finish(t, k, dir, branch).finally(() => {
|
|
943
|
+
// A paused run is still on: the hub keeps showing it, with its last step saying why.
|
|
944
|
+
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
900
945
|
say();
|
|
901
946
|
}),
|
|
902
947
|
};
|
|
903
948
|
}
|
|
904
949
|
|
|
905
|
-
async function finish(t, dir, branch, { resumed = false } = {}) {
|
|
906
|
-
const log = join(runs, `${t.id}.log`);
|
|
950
|
+
async function finish(t, k, dir, branch, { resumed = false } = {}) {
|
|
951
|
+
const log = join(k.runs, `${t.id}.log`);
|
|
952
|
+
// Steps go to the hub every few seconds rather than one request each: an agent can take a
|
|
953
|
+
// dozen in a second. Whatever is left goes when the run ends.
|
|
954
|
+
const pending = [];
|
|
955
|
+
const flush = async () => {
|
|
956
|
+
if (!pending.length) return;
|
|
957
|
+
await postSteps(t.id, pending.splice(0)).catch(() => {});
|
|
958
|
+
};
|
|
959
|
+
const step = (text) => {
|
|
960
|
+
pending.push({ at: new Date(now()).toISOString(), text });
|
|
961
|
+
onStep(t, text);
|
|
962
|
+
};
|
|
963
|
+
step(
|
|
964
|
+
resumed === "usage"
|
|
965
|
+
? `Carrying on now that Claude's usage is back, on ${name}`
|
|
966
|
+
: resumed
|
|
967
|
+
? `Carrying on after your answer, on ${name}`
|
|
968
|
+
: `Started on ${name} in ${k.repo}, on branch ${branch}`,
|
|
969
|
+
);
|
|
970
|
+
const timer = setInterval(flush, stepEvery * 1000);
|
|
907
971
|
const ran = await runAgent({
|
|
908
972
|
...agentRun,
|
|
909
|
-
prompt:
|
|
973
|
+
prompt:
|
|
974
|
+
resumed === "usage"
|
|
975
|
+
? runner.pausedPrompt(t, branch, { forge: k.forge })
|
|
976
|
+
: resumed
|
|
977
|
+
? runner.resumePrompt(t, branch, { forge: k.forge })
|
|
978
|
+
: runner.runnerPrompt(t, branch, { forge: k.forge }),
|
|
910
979
|
cwd: dir,
|
|
911
980
|
log,
|
|
912
981
|
minutes,
|
|
913
982
|
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
914
|
-
|
|
983
|
+
onStep: step,
|
|
984
|
+
}).finally(() => clearInterval(timer));
|
|
985
|
+
if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
|
|
915
986
|
let held = null;
|
|
916
987
|
try {
|
|
917
988
|
held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
|
|
918
989
|
} catch {}
|
|
990
|
+
// Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
|
|
991
|
+
// it back again would be refused, and it is no longer this runner's to describe.
|
|
992
|
+
if (held?.state !== "done" && !held?.claim) {
|
|
993
|
+
step("It was no longer held here when the agent stopped, so the runner left it as it is");
|
|
994
|
+
await flush();
|
|
995
|
+
onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
// The last step says how the run ended, while the work is still held here: once it is passed
|
|
999
|
+
// back, its steps are no longer this runner's to write.
|
|
1000
|
+
// Its usage ran out: not given up. It stays held here, and carries on once the usage is back.
|
|
1001
|
+
if (ran.usage && held.state !== "done" && !held.claim?.asking) {
|
|
1002
|
+
pauseUntil = Math.max(pauseUntil, ran.usage.until || now() + runner.USAGE_RETRY_MS);
|
|
1003
|
+
const when = ran.usage.until ? `at ${runner.clockOf(pauseUntil)}` : "when it's back";
|
|
1004
|
+
step(`Paused: Claude's usage ran out. It carries on here ${when}, with nothing lost.`);
|
|
1005
|
+
await flush();
|
|
1006
|
+
paused.set(t.id, { t, k, dir, branch });
|
|
1007
|
+
onPaused(t, { until: ran.usage.until ? pauseUntil : 0 });
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (held.state !== "done" && !held.claim?.asking)
|
|
1011
|
+
step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
|
|
1012
|
+
if (held.claim?.asking) step("Waiting for your answer, then it carries on");
|
|
1013
|
+
await flush();
|
|
919
1014
|
if (held?.state === "done") {
|
|
920
1015
|
try {
|
|
921
|
-
git(["-C", root, "worktree", "remove", "--force", dir]);
|
|
1016
|
+
git(["-C", k.root, "worktree", "remove", "--force", dir]);
|
|
922
1017
|
} catch {}
|
|
923
1018
|
onFinished(t, { branch, log });
|
|
924
1019
|
return;
|
|
@@ -926,7 +1021,7 @@ export async function watch({
|
|
|
926
1021
|
// It stopped to ask, and the question is waiting on a person: not given up. The work stays held
|
|
927
1022
|
// here with its worktree, and the run picks up again once they answer (resumeAnswered).
|
|
928
1023
|
if (held?.claim?.asking) {
|
|
929
|
-
waiting.set(t.id, { t, dir, branch });
|
|
1024
|
+
waiting.set(t.id, { t, k, dir, branch });
|
|
930
1025
|
onWaiting(t, { question: held.claim.asking, dir });
|
|
931
1026
|
return;
|
|
932
1027
|
}
|
|
@@ -934,9 +1029,9 @@ export async function watch({
|
|
|
934
1029
|
minutes,
|
|
935
1030
|
...ran,
|
|
936
1031
|
note: held?.claim?.note || "",
|
|
937
|
-
said: runner.lastLine(log),
|
|
938
|
-
dir,
|
|
1032
|
+
said: ran.said || runner.lastLine(log),
|
|
939
1033
|
branch,
|
|
1034
|
+
machine: name,
|
|
940
1035
|
});
|
|
941
1036
|
try {
|
|
942
1037
|
await pass(t.id, why, { cwd: dir, runner: true });
|
|
@@ -947,18 +1042,21 @@ export async function watch({
|
|
|
947
1042
|
}
|
|
948
1043
|
|
|
949
1044
|
const running = new Set();
|
|
950
|
-
/** Runs stopped on a question, by work id: their worktree and branch,
|
|
1045
|
+
/** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
|
|
951
1046
|
const waiting = new Map();
|
|
1047
|
+
/** Runs stopped because the agent's usage ran out, the same way: they carry on at `pauseUntil`. */
|
|
1048
|
+
const paused = new Map();
|
|
1049
|
+
let pauseUntil = 0;
|
|
952
1050
|
/** Start an agent again where it stopped: the same worktree, branch and claim. */
|
|
953
|
-
function resume(t, dir, branch) {
|
|
1051
|
+
function resume(t, k, dir, branch, { afterPause = false } = {}) {
|
|
954
1052
|
runner.countRun(countFile, runner.dayOf(now()));
|
|
955
1053
|
onNow.set(t.id, t.title || t.id);
|
|
956
|
-
onResumed(t, { dir, branch });
|
|
1054
|
+
onResumed(t, { dir, branch, afterPause });
|
|
957
1055
|
say();
|
|
958
|
-
const run = finish(t, dir, branch, { resumed: true })
|
|
1056
|
+
const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
|
|
959
1057
|
.catch((err) => onTrouble(err, t))
|
|
960
1058
|
.finally(() => {
|
|
961
|
-
onNow.delete(t.id);
|
|
1059
|
+
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
962
1060
|
running.delete(run);
|
|
963
1061
|
say();
|
|
964
1062
|
});
|
|
@@ -982,9 +1080,9 @@ export async function watch({
|
|
|
982
1080
|
if (row?.state === "done") {
|
|
983
1081
|
waiting.delete(id);
|
|
984
1082
|
try {
|
|
985
|
-
git(["-C", root, "worktree", "remove", "--force", w.dir]);
|
|
1083
|
+
git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
|
|
986
1084
|
} catch {}
|
|
987
|
-
onFinished(w.t, { branch: w.branch, log: join(runs, `${id}.log`) });
|
|
1085
|
+
onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
|
|
988
1086
|
continue;
|
|
989
1087
|
}
|
|
990
1088
|
if (!row?.claim) {
|
|
@@ -993,39 +1091,60 @@ export async function watch({
|
|
|
993
1091
|
continue;
|
|
994
1092
|
}
|
|
995
1093
|
if (row.claim.asking) continue;
|
|
996
|
-
if (running.size >= atOnce) return;
|
|
997
|
-
if (runner.runsToday(countFile, runner.dayOf(now())) >= perDay) return;
|
|
1094
|
+
if (running.size >= atOnce || paused.size) return;
|
|
998
1095
|
waiting.delete(id);
|
|
999
|
-
resume(w.t, w.dir, w.branch);
|
|
1096
|
+
resume(w.t, w.k, w.dir, w.branch);
|
|
1000
1097
|
}
|
|
1001
1098
|
}
|
|
1002
1099
|
|
|
1003
|
-
// A runner started again finds
|
|
1004
|
-
//
|
|
1100
|
+
// A runner started again finds what its earlier runs left held: their worktrees are still beside
|
|
1101
|
+
// their checkouts. One waiting on a question waits on; any other (paused when the usage ran out,
|
|
1102
|
+
// or stopped with the runner) carries on straight away.
|
|
1005
1103
|
try {
|
|
1006
1104
|
for (const row of (await api.tasks()).tasks) {
|
|
1007
|
-
const leaf = row.claim?.worktree || "";
|
|
1008
|
-
|
|
1009
|
-
|
|
1105
|
+
const leaf = basename(row.claim?.worktree || "");
|
|
1106
|
+
if (!leaf.startsWith("run-") || row.state === "done") continue;
|
|
1107
|
+
const k = checkouts.find((x) => existsSync(join(x.runs, leaf)));
|
|
1108
|
+
if (!k) continue;
|
|
1109
|
+
const dir = join(k.runs, leaf);
|
|
1010
1110
|
let branch = runner.branchOf(row.id);
|
|
1011
1111
|
try {
|
|
1012
1112
|
branch = git(["-C", dir, "branch", "--show-current"]) || branch;
|
|
1013
1113
|
} catch {}
|
|
1014
|
-
|
|
1114
|
+
(row.claim?.asking ? waiting : paused).set(row.id, {
|
|
1115
|
+
t: { id: row.id, title: row.title, markdown: "" },
|
|
1116
|
+
k,
|
|
1117
|
+
dir,
|
|
1118
|
+
branch,
|
|
1119
|
+
});
|
|
1015
1120
|
}
|
|
1016
1121
|
} catch {}
|
|
1017
1122
|
|
|
1123
|
+
/**
|
|
1124
|
+
* While the agent's usage is out: keep each paused run's hold alive (an empty report of steps
|
|
1125
|
+
* renews it, so the hub doesn't call it gone quiet), and once the time comes, carry them on. If
|
|
1126
|
+
* the usage is still out they pause again at once, which costs nothing.
|
|
1127
|
+
*/
|
|
1128
|
+
async function carryOnPaused() {
|
|
1129
|
+
if (!paused.size) return;
|
|
1130
|
+
if (now() < pauseUntil) {
|
|
1131
|
+
for (const id of paused.keys()) await postSteps(id, []).catch(() => {});
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
pauseUntil = 0;
|
|
1135
|
+
for (const [id, p] of [...paused]) {
|
|
1136
|
+
if (running.size >= atOnce) break;
|
|
1137
|
+
paused.delete(id);
|
|
1138
|
+
resume(p.t, p.k, p.dir, p.branch, { afterPause: true });
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1018
1142
|
let n = 0;
|
|
1019
|
-
let cappedOn = "";
|
|
1020
1143
|
for (;;) {
|
|
1144
|
+
await carryOnPaused();
|
|
1021
1145
|
await resumeAnswered();
|
|
1022
|
-
while
|
|
1023
|
-
|
|
1024
|
-
if (runner.runsToday(countFile, day) >= perDay) {
|
|
1025
|
-
if (cappedOn !== day) onCapped(perDay);
|
|
1026
|
-
cappedOn = day;
|
|
1027
|
-
break;
|
|
1028
|
-
}
|
|
1146
|
+
// Nothing new while the usage is out: it would only start and stop at once.
|
|
1147
|
+
while (running.size < atOnce && !paused.size && now() >= pauseUntil) {
|
|
1029
1148
|
let slot;
|
|
1030
1149
|
try {
|
|
1031
1150
|
slot = await startOne(++n);
|
|
@@ -1034,7 +1153,7 @@ export async function watch({
|
|
|
1034
1153
|
break;
|
|
1035
1154
|
}
|
|
1036
1155
|
if (!slot) {
|
|
1037
|
-
if (!running.size) onIdle(
|
|
1156
|
+
if (!running.size) onIdle(repos);
|
|
1038
1157
|
break;
|
|
1039
1158
|
}
|
|
1040
1159
|
const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
|
package/src/runner.js
CHANGED
|
@@ -12,11 +12,27 @@
|
|
|
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
|
-
/**
|
|
19
|
-
|
|
26
|
+
/**
|
|
27
|
+
* How many agents at once, and how long one run may take. There is no count of runs a day: it was
|
|
28
|
+
* ten, and a loop that gave the same work back over and over spent all ten on nothing, so the
|
|
29
|
+
* runner sat idle with work waiting (2026-11-06). What limits a runner now is the agent's own usage
|
|
30
|
+
* (`usageOut`: it pauses until that comes back) and the work the Passalong plan lets you keep.
|
|
31
|
+
*/
|
|
32
|
+
export const LIMITS = { atOnce: 3, minutes: 60 };
|
|
33
|
+
|
|
34
|
+
/** How long a runner waits to try again when its agent's usage ran out and it didn't say until when. */
|
|
35
|
+
export const USAGE_RETRY_MS = 15 * 60 * 1000;
|
|
20
36
|
|
|
21
37
|
/**
|
|
22
38
|
* What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
|
|
@@ -62,6 +78,9 @@ export const PERMISSIONS = {
|
|
|
62
78
|
],
|
|
63
79
|
};
|
|
64
80
|
|
|
81
|
+
/** Where a checkout's origin is hosted, for how a run ends: a pull request or a merge request. */
|
|
82
|
+
export const forgeOf = (remote) => (/gitlab/i.test(String(remote || "")) ? "gitlab" : "github");
|
|
83
|
+
|
|
65
84
|
/** The branch a piece of work's change goes on. */
|
|
66
85
|
export const branchOf = (id) => `passalong/${id}`;
|
|
67
86
|
|
|
@@ -76,6 +95,11 @@ export function claudeCommand({ node = process.execPath, cli } = {}) {
|
|
|
76
95
|
command: "claude",
|
|
77
96
|
args: [
|
|
78
97
|
"-p",
|
|
98
|
+
// One JSON event per line as it works, which the runner turns into the steps a person can
|
|
99
|
+
// watch from the hub (describe below). Plain -p prints only the final answer, at the end.
|
|
100
|
+
"--output-format",
|
|
101
|
+
"stream-json",
|
|
102
|
+
"--verbose",
|
|
79
103
|
"--permission-mode",
|
|
80
104
|
"dontAsk",
|
|
81
105
|
"--settings",
|
|
@@ -88,7 +112,23 @@ export function claudeCommand({ node = process.execPath, cli } = {}) {
|
|
|
88
112
|
}
|
|
89
113
|
|
|
90
114
|
/** 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)) {
|
|
115
|
+
export function runnerPrompt(t, branch = branchOf(t.id), { forge = "github" } = {}) {
|
|
116
|
+
// GitLab has no `gh`: a push option opens the merge request, and git prints its link.
|
|
117
|
+
const ship =
|
|
118
|
+
forge === "gitlab"
|
|
119
|
+
? [
|
|
120
|
+
`When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
|
|
121
|
+
`exactly \`git push -u origin ${branch} -o merge_request.create\`, which opens a merge`,
|
|
122
|
+
"request and prints its link. Then call hand_in with id " + t.id + ", `pr` set to that",
|
|
123
|
+
"link (or the commit hash if the push failed, and say why in the note), `note`: one plain",
|
|
124
|
+
]
|
|
125
|
+
: [
|
|
126
|
+
`When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
|
|
127
|
+
`exactly \`git push -u origin ${branch}\` and open a pull request with`,
|
|
128
|
+
`\`gh pr create --fill --head ${branch}\`. Then call hand_in with id ${t.id}, \`pr\` set to the`,
|
|
129
|
+
"pull request's URL (or the commit hash if the push or the pull request failed, and say why in",
|
|
130
|
+
"the note), `note`: one plain",
|
|
131
|
+
];
|
|
92
132
|
return [
|
|
93
133
|
`You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
|
|
94
134
|
"",
|
|
@@ -98,11 +138,8 @@ export function runnerPrompt(t, branch = branchOf(t.id)) {
|
|
|
98
138
|
`means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
|
|
99
139
|
"status at each milestone; 30 minutes of silence stalls it.",
|
|
100
140
|
"",
|
|
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",
|
|
141
|
+
...ship,
|
|
142
|
+
"sentence saying what you did, and `evidence`: what you ran and",
|
|
106
143
|
"what came back, with the commands and the lines that decided it, the test summary, the commit.",
|
|
107
144
|
"Keep that as you go. What you learned about the project goes into its documents",
|
|
108
145
|
"(save_document). If a call says you no longer hold it, stop.",
|
|
@@ -120,6 +157,10 @@ export function runnerPrompt(t, branch = branchOf(t.id)) {
|
|
|
120
157
|
"If something only a person can do stands between you and done (a permission, a secret), call",
|
|
121
158
|
`progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you need,`,
|
|
122
159
|
"and stop. The work goes back to the person who wrote it, with that note.",
|
|
160
|
+
"",
|
|
161
|
+
"Never call pass yourself. If you stop without hand_in, the runner gives the work back",
|
|
162
|
+
"unfinished and shows your last message as the reason, so end with what is done and what",
|
|
163
|
+
"isn't, in plain sentences.",
|
|
123
164
|
].join("\n");
|
|
124
165
|
}
|
|
125
166
|
|
|
@@ -127,14 +168,32 @@ export function runnerPrompt(t, branch = branchOf(t.id)) {
|
|
|
127
168
|
* What an agent is told when its run starts again after a person answered its question: read the
|
|
128
169
|
* answer, then finish exactly as the first run was told to.
|
|
129
170
|
*/
|
|
130
|
-
export function resumePrompt(t, branch = branchOf(t.id)) {
|
|
171
|
+
export function resumePrompt(t, branch = branchOf(t.id), opts = {}) {
|
|
131
172
|
return [
|
|
132
173
|
`You hold passalong work ${t.id} (${t.title || t.id}), and you stopped earlier to ask the person`,
|
|
133
174
|
`a question. They have answered. Call the passalong MCP tool take with id ${t.id} first: their`,
|
|
134
175
|
"answer comes back with it. Then carry on in this worktree, where your earlier changes are, and",
|
|
135
176
|
"finish as before:",
|
|
136
177
|
"",
|
|
137
|
-
runnerPrompt({ ...t, markdown: "" }, branch)
|
|
178
|
+
runnerPrompt({ ...t, markdown: "" }, branch, opts)
|
|
179
|
+
.split("\n")
|
|
180
|
+
.slice(4)
|
|
181
|
+
.join("\n"),
|
|
182
|
+
].join("\n");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* What an agent is told when its run starts again after it stopped part-way, because Claude's usage
|
|
187
|
+
* ran out or the runner was stopped: the work is still held, its changes are in this worktree.
|
|
188
|
+
*/
|
|
189
|
+
export function pausedPrompt(t, branch = branchOf(t.id), opts = {}) {
|
|
190
|
+
return [
|
|
191
|
+
`You hold passalong work ${t.id} (${t.title || t.id}). An earlier run on it stopped before it`,
|
|
192
|
+
"finished, because Claude's usage ran out or the runner was stopped. Call the passalong MCP tool",
|
|
193
|
+
`take with id ${t.id} first to read the work again. Then look at what is already done in this`,
|
|
194
|
+
"worktree (git status, git log), carry on from there, and finish as before:",
|
|
195
|
+
"",
|
|
196
|
+
runnerPrompt({ ...t, markdown: "" }, branch, opts)
|
|
138
197
|
.split("\n")
|
|
139
198
|
.slice(4)
|
|
140
199
|
.join("\n"),
|
|
@@ -147,7 +206,7 @@ export const dayOf = (now) => {
|
|
|
147
206
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
148
207
|
};
|
|
149
208
|
|
|
150
|
-
/** How many runs have started today, kept in `file` beside the rest of the CLI's state. */
|
|
209
|
+
/** How many runs have started today, kept in `file` beside the rest of the CLI's state: shown, not a limit. */
|
|
151
210
|
export function runsToday(file, day) {
|
|
152
211
|
try {
|
|
153
212
|
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
@@ -167,25 +226,80 @@ export function countRun(file, day) {
|
|
|
167
226
|
/** Where the runs' worktrees and logs live: beside the checkout, never inside it. */
|
|
168
227
|
export const runsDirOf = (root) => join(dirname(root), `${basename(root)}.passalong-runs`);
|
|
169
228
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
229
|
+
/**
|
|
230
|
+
* Why a run gave its work back, said to whoever wrote it and to the team's channel. It leads with
|
|
231
|
+
* what the agent itself said at the end, which is nearly always the reason ("Not done: I couldn't
|
|
232
|
+
* start the app to take the screenshots"). It said "stopped without finishing (exit 0)", which
|
|
233
|
+
* means nothing to a person, and "the last thing it printed", cut mid-word (2026-11-06).
|
|
234
|
+
*/
|
|
235
|
+
export function passBack({ minutes, timedOut, exit, note, said, branch, failed, machine }) {
|
|
172
236
|
const how = failed
|
|
173
|
-
? `couldn't start (${failed})`
|
|
237
|
+
? `couldn't start it (${failed})`
|
|
174
238
|
: timedOut
|
|
175
239
|
? `ran out of its ${minutes} minutes`
|
|
176
|
-
:
|
|
240
|
+
: exit === 0
|
|
241
|
+
? "ended its run without handing it in"
|
|
242
|
+
: `stopped with an error${exit === undefined || exit === null || exit === "" ? "" : ` (${typeof exit === "number" ? `exit code ${exit}` : exit})`}`;
|
|
243
|
+
const words = sentences(note || said, 600);
|
|
177
244
|
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.`,
|
|
245
|
+
`The runner's agent ${how}.`,
|
|
246
|
+
words ? `It said: "${words}"` : "It didn't say why.",
|
|
247
|
+
"It's back in To do and no longer marked safe to run alone, so no runner takes it again until someone ticks it.",
|
|
248
|
+
failed ? "" : `What it changed is kept on branch ${branch}${machine ? ` on ${machine}` : ""}.`,
|
|
184
249
|
]
|
|
185
250
|
.filter(Boolean)
|
|
186
251
|
.join(" ");
|
|
187
252
|
}
|
|
188
253
|
|
|
254
|
+
/** At most `max` characters, ended at a sentence or a word rather than mid-word. */
|
|
255
|
+
export function sentences(text, max) {
|
|
256
|
+
const one = String(text ?? "")
|
|
257
|
+
.replace(/\s+/g, " ")
|
|
258
|
+
.trim();
|
|
259
|
+
if (one.length <= max) return one;
|
|
260
|
+
const cut = one.slice(0, max);
|
|
261
|
+
const stop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("? "), cut.lastIndexOf("! "));
|
|
262
|
+
if (stop > max * 0.5) return cut.slice(0, stop + 1);
|
|
263
|
+
const space = cut.lastIndexOf(" ");
|
|
264
|
+
return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Whether what an agent said means its usage ran out (Claude Code's plan limit, or the API refusing
|
|
269
|
+
* for rate or credit), and when it comes back: `{ until }` in ms, 0 when it didn't say. `null` when
|
|
270
|
+
* it means something else. Only Claude Code's own phrasings, in short messages: an agent that ran
|
|
271
|
+
* out says so in a line, and one that added "a daily limit" to someone's app must not pause.
|
|
272
|
+
*/
|
|
273
|
+
export function usageOut(text, now = Date.now()) {
|
|
274
|
+
const s = String(text ?? "").trim();
|
|
275
|
+
if (!s || s.length > 400) return null;
|
|
276
|
+
if (
|
|
277
|
+
!/usage limit reached|hit your (usage |session |weekly )?limit|(session|weekly|5-hour|five-hour|opus|sonnet) limit reached|out of (extra )?usage|rate_limit_error|credit balance is too low/i.test(
|
|
278
|
+
s,
|
|
279
|
+
)
|
|
280
|
+
)
|
|
281
|
+
return null;
|
|
282
|
+
// "Claude AI usage limit reached|1736789000": when it resets, in seconds.
|
|
283
|
+
const epoch = s.match(/\|(\d{10})\b/);
|
|
284
|
+
if (epoch) return { until: Number(epoch[1]) * 1000 };
|
|
285
|
+
// "resets 3pm", "resets at 5:30 pm": the next time the clock says that, on this machine.
|
|
286
|
+
const at = s.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
|
|
287
|
+
if (at) {
|
|
288
|
+
let h = Number(at[1]) % 12;
|
|
289
|
+
if (!at[3]) h = Number(at[1]) % 24;
|
|
290
|
+
else if (at[3].toLowerCase() === "pm") h += 12;
|
|
291
|
+
const d = new Date(now);
|
|
292
|
+
d.setHours(h, Number(at[2] || 0), 0, 0);
|
|
293
|
+
if (d.getTime() <= now) d.setDate(d.getDate() + 1);
|
|
294
|
+
return { until: d.getTime() };
|
|
295
|
+
}
|
|
296
|
+
return { until: 0 };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** "3:00 pm", for saying when a paused runner carries on. */
|
|
300
|
+
export const clockOf = (ms) =>
|
|
301
|
+
new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
|
302
|
+
|
|
189
303
|
/** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
|
|
190
304
|
export function lastLine(log) {
|
|
191
305
|
try {
|
|
@@ -202,24 +316,66 @@ export function git(args) {
|
|
|
202
316
|
}
|
|
203
317
|
|
|
204
318
|
/**
|
|
205
|
-
* Start an agent and wait for it
|
|
206
|
-
*
|
|
207
|
-
*
|
|
319
|
+
* Start an agent and wait for it. What it prints is read a line at a time: each becomes a line of
|
|
320
|
+
* `log` a person can read (Claude Code's JSON events made into sentences, anything else as it came),
|
|
321
|
+
* and each thing it does is handed to `onStep`. Killed after `minutes`. Resolves
|
|
322
|
+
* `{ exit, timedOut, said, usage }`, never rejects: an agent that would not start is a run that ended without
|
|
323
|
+
* the work done.
|
|
208
324
|
*/
|
|
209
|
-
export function runAgent({
|
|
210
|
-
|
|
211
|
-
|
|
325
|
+
export function runAgent({
|
|
326
|
+
command,
|
|
327
|
+
args,
|
|
328
|
+
stdin,
|
|
329
|
+
prompt,
|
|
330
|
+
cwd,
|
|
331
|
+
log,
|
|
332
|
+
minutes,
|
|
333
|
+
env,
|
|
334
|
+
onStep = () => {},
|
|
335
|
+
}) {
|
|
336
|
+
return new Promise((resolve) => {
|
|
212
337
|
let timedOut = false;
|
|
213
338
|
let child;
|
|
339
|
+
/** What the agent said at the end (Claude Code's result), for why it gave the work back. */
|
|
340
|
+
let said = "";
|
|
341
|
+
/** Set when it said its usage ran out (usageOut): the run is paused, not given up. */
|
|
342
|
+
let usage = null;
|
|
343
|
+
const done = (r) => resolve({ ...r, said, usage });
|
|
344
|
+
const write = (text) => {
|
|
345
|
+
try {
|
|
346
|
+
appendFileSync(log, `${clock()} ${text}\n`);
|
|
347
|
+
} catch {}
|
|
348
|
+
};
|
|
214
349
|
try {
|
|
215
350
|
child = spawn(command, stdin ? args : [...args, prompt], {
|
|
216
351
|
cwd,
|
|
217
352
|
env,
|
|
218
|
-
stdio: [stdin ? "pipe" : "ignore",
|
|
353
|
+
stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
219
354
|
});
|
|
220
355
|
} catch (err) {
|
|
356
|
+
write(`couldn't start: ${err.message}`);
|
|
221
357
|
return done({ exit: err.message, timedOut });
|
|
222
358
|
}
|
|
359
|
+
const reader = () => {
|
|
360
|
+
let rest = "";
|
|
361
|
+
return (chunk) => {
|
|
362
|
+
rest += chunk;
|
|
363
|
+
const lines = rest.split("\n");
|
|
364
|
+
rest = lines.pop() ?? "";
|
|
365
|
+
for (const line of lines) {
|
|
366
|
+
if (!line.trim()) continue;
|
|
367
|
+
const d = describe(line, cwd);
|
|
368
|
+
if (d.said) said = d.said;
|
|
369
|
+
usage = d.usage || usage;
|
|
370
|
+
if (d.log) write(d.log);
|
|
371
|
+
for (const step of d.step ? d.step.split("\n") : []) onStep(step);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
};
|
|
375
|
+
child.stdout?.setEncoding("utf8");
|
|
376
|
+
child.stderr?.setEncoding("utf8");
|
|
377
|
+
child.stdout?.on("data", reader());
|
|
378
|
+
child.stderr?.on("data", reader());
|
|
223
379
|
const timer = setTimeout(
|
|
224
380
|
() => {
|
|
225
381
|
timedOut = true;
|
|
@@ -229,6 +385,7 @@ export function runAgent({ command, args, stdin, prompt, cwd, log, minutes, env
|
|
|
229
385
|
);
|
|
230
386
|
child.on("error", (err) => {
|
|
231
387
|
clearTimeout(timer);
|
|
388
|
+
write(`couldn't start: ${err.message}`);
|
|
232
389
|
done({ exit: err.message, timedOut });
|
|
233
390
|
});
|
|
234
391
|
child.on("close", (code) => {
|
|
@@ -241,3 +398,194 @@ export function runAgent({ command, args, stdin, prompt, cwd, log, minutes, env
|
|
|
241
398
|
}
|
|
242
399
|
});
|
|
243
400
|
}
|
|
401
|
+
|
|
402
|
+
const clock = () => new Date().toTimeString().slice(0, 8);
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Take out what looks like a secret before a line leaves the machine: a long run of letters and
|
|
406
|
+
* digits (a token, a key) and the value after token=, secret=, password= or key=. Steps are read by
|
|
407
|
+
* the team in the hub, and an agent's commands can carry what its environment holds.
|
|
408
|
+
*/
|
|
409
|
+
export function hideSecrets(text) {
|
|
410
|
+
return String(text)
|
|
411
|
+
.replace(/\b((?:[a-z_]*_)?(?:token|secret|password|passwd|api_?key|key))=\S+/gi, "$1=[hidden]")
|
|
412
|
+
.replace(/\b(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,}\b/g, "[hidden]");
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const one = (s, max = 140) => {
|
|
416
|
+
const line =
|
|
417
|
+
String(s ?? "")
|
|
418
|
+
.split("\n")
|
|
419
|
+
.map((l) => l.trim())
|
|
420
|
+
.find(Boolean) ?? "";
|
|
421
|
+
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
422
|
+
};
|
|
423
|
+
|
|
424
|
+
/** What one tool call does, in words: "Editing src/deals/form.tsx", "Running `npm test`". */
|
|
425
|
+
export function toolStep(name, input = {}, cwd = "") {
|
|
426
|
+
const path = (p) => {
|
|
427
|
+
if (!p) return "a file";
|
|
428
|
+
const r = cwd ? relative(cwd, String(p)) : String(p);
|
|
429
|
+
return r && !r.startsWith("..") ? r : String(p);
|
|
430
|
+
};
|
|
431
|
+
const said = {
|
|
432
|
+
Read: () => `Reading ${path(input.file_path)}`,
|
|
433
|
+
Edit: () => `Editing ${path(input.file_path)}`,
|
|
434
|
+
MultiEdit: () => `Editing ${path(input.file_path)}`,
|
|
435
|
+
Write: () => `Writing ${path(input.file_path)}`,
|
|
436
|
+
NotebookEdit: () => `Editing ${path(input.notebook_path)}`,
|
|
437
|
+
Bash: () => `Running \`${one(input.command, 120)}\``,
|
|
438
|
+
Grep: () => `Searching for "${one(input.pattern, 80)}"`,
|
|
439
|
+
Glob: () => `Looking for files: ${one(input.pattern, 80)}`,
|
|
440
|
+
WebFetch: () => `Reading ${one(input.url, 120)}`,
|
|
441
|
+
WebSearch: () => `Searching the web for "${one(input.query, 80)}"`,
|
|
442
|
+
TodoWrite: () => {
|
|
443
|
+
const now = (input.todos || []).find((t) => t.status === "in_progress");
|
|
444
|
+
return now ? `Next: ${one(now.activeForm || now.content)}` : "Planning the steps";
|
|
445
|
+
},
|
|
446
|
+
Task: () => `Starting a helper: ${one(input.description, 80)}`,
|
|
447
|
+
Agent: () => `Starting a helper: ${one(input.description, 80)}`,
|
|
448
|
+
mcp__passalong__progress: () => `Progress: ${one(input.note)}`,
|
|
449
|
+
mcp__passalong__hand_in: () => `Handing it in${input.pr ? `: ${one(input.pr)}` : ""}`,
|
|
450
|
+
mcp__passalong__ask: () => `Asking you: ${one(input.question)}`,
|
|
451
|
+
mcp__passalong__take: () => "Reading the work again",
|
|
452
|
+
mcp__passalong__save_document: () =>
|
|
453
|
+
`Writing down what it learned: ${one(input.title || input.path)}`,
|
|
454
|
+
}[name];
|
|
455
|
+
if (said) return said();
|
|
456
|
+
const short = String(name).replace(/^mcp__([^_]+)__/, "$1: ");
|
|
457
|
+
return `Using ${short}`;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* One line the agent printed, as a line of the log and, when it did something, a step.
|
|
462
|
+
*
|
|
463
|
+
* Claude Code's stream-json events become sentences: what it says (its first line), each tool it
|
|
464
|
+
* calls, a tool that failed, and the result at the end. Anything that is not one of those events
|
|
465
|
+
* (another agent, a crash) is kept as it came, and is a step too: it may be the only sign of why a
|
|
466
|
+
* run stopped.
|
|
467
|
+
*/
|
|
468
|
+
export function describe(line, cwd = "") {
|
|
469
|
+
let e;
|
|
470
|
+
try {
|
|
471
|
+
e = JSON.parse(line);
|
|
472
|
+
} catch {
|
|
473
|
+
const text = hideSecrets(one(line, 300));
|
|
474
|
+
return { log: text, step: text, usage: usageOut(line) };
|
|
475
|
+
}
|
|
476
|
+
if (!e || typeof e !== "object") return { log: hideSecrets(one(line, 300)), step: "" };
|
|
477
|
+
if (e.type === "system" && e.subtype === "init")
|
|
478
|
+
return { log: `started${e.model ? ` (${e.model})` : ""}`, step: "" };
|
|
479
|
+
if (e.type === "assistant") {
|
|
480
|
+
// Claude Code writes the refusal as a message of its own, marked with what went wrong.
|
|
481
|
+
if (["rate_limit", "billing_error"].includes(e.error)) {
|
|
482
|
+
const said = (e.message?.content ?? []).map((c) => c.text || "").join(" ");
|
|
483
|
+
const text = hideSecrets(one(said || "Claude's usage ran out", 200));
|
|
484
|
+
return { log: text, step: "", usage: usageOut(said) || { until: 0 } };
|
|
485
|
+
}
|
|
486
|
+
const logs = [];
|
|
487
|
+
const steps = [];
|
|
488
|
+
for (const c of e.message?.content ?? []) {
|
|
489
|
+
if (c.type === "text" && c.text?.trim()) {
|
|
490
|
+
logs.push(hideSecrets(c.text.trim()));
|
|
491
|
+
steps.push(hideSecrets(one(c.text)));
|
|
492
|
+
} else if (c.type === "tool_use") {
|
|
493
|
+
const s = hideSecrets(toolStep(c.name, c.input, cwd));
|
|
494
|
+
logs.push(s);
|
|
495
|
+
steps.push(s);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return { log: logs.join("\n"), step: steps.join("\n") };
|
|
499
|
+
}
|
|
500
|
+
if (e.type === "user") {
|
|
501
|
+
const failed = (e.message?.content ?? []).find((c) => c.type === "tool_result" && c.is_error);
|
|
502
|
+
if (!failed) return { log: "", step: "" };
|
|
503
|
+
const why = Array.isArray(failed.content)
|
|
504
|
+
? failed.content.map((x) => x.text || "").join(" ")
|
|
505
|
+
: failed.content;
|
|
506
|
+
const text = hideSecrets(`That didn't work: ${one(why)}`);
|
|
507
|
+
return { log: text, step: text };
|
|
508
|
+
}
|
|
509
|
+
if (e.type === "result") {
|
|
510
|
+
const how = e.is_error ? "Stopped with an error" : "Finished";
|
|
511
|
+
const text = hideSecrets(`${how}${e.result ? `: ${one(e.result, 200)}` : ""}`);
|
|
512
|
+
// Its whole last message, too: when the work comes back unfinished, this is why.
|
|
513
|
+
return {
|
|
514
|
+
log: text,
|
|
515
|
+
step: text,
|
|
516
|
+
said: hideSecrets(String(e.result || "").trim()),
|
|
517
|
+
usage: usageOut(e.result),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
return { log: "", step: "" };
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The git checkouts a runner started in `cwd` serves: `cwd`'s own when it is inside one, else every
|
|
525
|
+
* checkout in the folders under it, three levels down at most. Worktrees, dependencies, hidden
|
|
526
|
+
* folders and the runner's own run folders are skipped. Those without an `origin` remote are listed
|
|
527
|
+
* apart, since a run ends by pushing its branch there.
|
|
528
|
+
*/
|
|
529
|
+
export function checkoutsIn(cwd, { git: run = git, depth = 3 } = {}) {
|
|
530
|
+
const top = (dir) => {
|
|
531
|
+
try {
|
|
532
|
+
return run(["-C", dir, "rev-parse", "--show-toplevel"]);
|
|
533
|
+
} catch {
|
|
534
|
+
return "";
|
|
535
|
+
}
|
|
536
|
+
};
|
|
537
|
+
const roots = [];
|
|
538
|
+
const own = top(cwd);
|
|
539
|
+
if (own) roots.push(own);
|
|
540
|
+
else {
|
|
541
|
+
const walk = (dir, left) => {
|
|
542
|
+
let names = [];
|
|
543
|
+
try {
|
|
544
|
+
names = readdirSync(dir);
|
|
545
|
+
} catch {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
if (names.includes(".git")) {
|
|
549
|
+
// A .git file is a worktree of a checkout found elsewhere, not a repo of its own.
|
|
550
|
+
try {
|
|
551
|
+
if (statSync(join(dir, ".git")).isDirectory()) roots.push(top(dir) || dir);
|
|
552
|
+
} catch {}
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (!left) return;
|
|
556
|
+
for (const n of names.sort()) {
|
|
557
|
+
if (n.startsWith(".") || n === "node_modules" || n.endsWith(".passalong-runs")) continue;
|
|
558
|
+
const p = join(dir, n);
|
|
559
|
+
try {
|
|
560
|
+
if (statSync(p).isDirectory()) walk(p, left - 1);
|
|
561
|
+
} catch {}
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
walk(cwd, depth);
|
|
565
|
+
}
|
|
566
|
+
const found = [];
|
|
567
|
+
const skipped = [];
|
|
568
|
+
for (const root of roots) {
|
|
569
|
+
let remote = "";
|
|
570
|
+
try {
|
|
571
|
+
remote = run(["-C", root, "remote", "get-url", "origin"]);
|
|
572
|
+
} catch {}
|
|
573
|
+
if (remote) found.push({ root, remote });
|
|
574
|
+
else skipped.push(root);
|
|
575
|
+
}
|
|
576
|
+
return { found, skipped };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** This computer as a person knows it: "Lukman's MacBook Pro" on a Mac, its host name elsewhere. */
|
|
580
|
+
export function machineName() {
|
|
581
|
+
if (platform() === "darwin") {
|
|
582
|
+
try {
|
|
583
|
+
const name = execFileSync("scutil", ["--get", "ComputerName"], {
|
|
584
|
+
encoding: "utf8",
|
|
585
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
586
|
+
}).trim();
|
|
587
|
+
if (name) return name;
|
|
588
|
+
} catch {}
|
|
589
|
+
}
|
|
590
|
+
return hostname();
|
|
591
|
+
}
|