passalong 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -4
- package/bin/passalong +24 -66
- package/package.json +1 -1
- package/src/api.js +0 -5
- package/src/checks.js +2 -2
- package/src/passalong.js +7 -406
- package/src/update.js +47 -2
- package/src/runner.js +0 -591
package/README.md
CHANGED
|
@@ -51,10 +51,6 @@ 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, 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
|
|
58
54
|
passalong now what this worktree holds and what is waiting
|
|
59
55
|
passalong hub open the hub in the browser
|
|
60
56
|
passalong export [dir] every piece of work as plain markdown
|
package/bin/passalong
CHANGED
|
@@ -22,8 +22,6 @@
|
|
|
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, 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
25
|
// passalong hub open the hub in the browser
|
|
28
26
|
// passalong export [dir] dump every piece of work as plain markdown (default ./passalong-export)
|
|
29
27
|
// passalong admin create <email> make an account that runs Passalong (owner only)
|
|
@@ -251,68 +249,6 @@ async function statusLine() {
|
|
|
251
249
|
}
|
|
252
250
|
}
|
|
253
251
|
|
|
254
|
-
/**
|
|
255
|
-
* `passalong work --watch`: the runner (src/runner.js). Each piece of work marked safe to run alone
|
|
256
|
-
* gets its own worktree, branch and Claude Code; what it does arrives as a pull request.
|
|
257
|
-
*/
|
|
258
|
-
async function runner(f) {
|
|
259
|
-
const every = Number(f.every) || 60;
|
|
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`;
|
|
264
|
-
await passalong
|
|
265
|
-
.watch({
|
|
266
|
-
agent: typeof f.agent === "string" ? f.agent : undefined,
|
|
267
|
-
every,
|
|
268
|
-
cli: fileURLToPath(import.meta.url),
|
|
269
|
-
signals: true,
|
|
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 }) => {
|
|
282
|
-
idle = false;
|
|
283
|
-
console.error(`\n→ ${t.id} ${t.title}\n ${repo}, on ${branch} in ${dir}`);
|
|
284
|
-
},
|
|
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
|
-
);
|
|
292
|
-
idle = true;
|
|
293
|
-
},
|
|
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
|
-
),
|
|
302
|
-
onFinished: (t, { branch, log }) => console.error(`✓ ${t.id} finished on ${branch} (log: ${log})`),
|
|
303
|
-
onWaiting: (t, { question }) =>
|
|
304
|
-
console.error(`? ${t.id} is waiting on you: "${question.split("\n")[0]}" (answer it in the hub)`),
|
|
305
|
-
onResumed: (t, { afterPause }) =>
|
|
306
|
-
console.error(
|
|
307
|
-
`\n→ ${t.id} ${t.title} (${afterPause ? "carrying on where it stopped" : "answered: carrying on"})`,
|
|
308
|
-
),
|
|
309
|
-
onPassed: (t, { dir, log }) =>
|
|
310
|
-
console.error(`✗ ${t.id} went back to its author, unfinished. Its worktree: ${dir} (log: ${log})`),
|
|
311
|
-
onTrouble: (err, t) => console.error(`! ${t ? `${t.id}: ` : ""}${err?.message || err}`),
|
|
312
|
-
})
|
|
313
|
-
.catch((e) => die(e.message));
|
|
314
|
-
}
|
|
315
|
-
|
|
316
252
|
const commands = {
|
|
317
253
|
/**
|
|
318
254
|
* Write work down: a sentence becomes its title, summary and text, or a file is sent as it is. At
|
|
@@ -507,7 +443,14 @@ const commands = {
|
|
|
507
443
|
* files without asking — which is yours to decide, not this command's.
|
|
508
444
|
*/
|
|
509
445
|
async work(f) {
|
|
510
|
-
|
|
446
|
+
// The runner `--watch` started was removed (2026-11-07). Saying so beats running the queue
|
|
447
|
+
// instead, which is a different thing from what whoever typed `--watch` asked for.
|
|
448
|
+
if (f.watch) {
|
|
449
|
+
console.error(
|
|
450
|
+
"The runner has been removed from Passalong. Open a Claude Code session in your repo and ask it to take work from Passalong instead.",
|
|
451
|
+
);
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
511
454
|
const run = await passalong
|
|
512
455
|
.work({
|
|
513
456
|
agent: typeof f.agent === "string" ? f.agent : undefined,
|
|
@@ -927,7 +870,22 @@ if (typeof argv.as === "string") {
|
|
|
927
870
|
// both are caught here, with `version`, before dispatch. Only the number goes to stdout, so
|
|
928
871
|
// `passalong -v` can be read by a script comparing it against the registry.
|
|
929
872
|
if (cmd === "-v" || cmd === "version" || (cmd === "help" && argv.version === true)) {
|
|
930
|
-
|
|
873
|
+
const mine = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
|
|
874
|
+
console.log(mine);
|
|
875
|
+
// Another copy on PATH (npm's beside pnpm's) is the usual reason the version is not the one
|
|
876
|
+
// expected: updating one leaves the other answering. Said on stderr, so stdout stays the number.
|
|
877
|
+
try {
|
|
878
|
+
const { otherCopies } = await import("../src/update.js");
|
|
879
|
+
const { others, shadowed } = otherCopies(fileURLToPath(import.meta.url));
|
|
880
|
+
for (const o of others)
|
|
881
|
+
console.error(
|
|
882
|
+
`! Another passalong${o.version ? ` (${o.version})` : ""} is installed at ${o.path}` +
|
|
883
|
+
(shadowed && o === others[0]
|
|
884
|
+
? `, and it is the one \`passalong\` runs in a new terminal, not this ${mine}.`
|
|
885
|
+
: `, beside this ${mine}.`) +
|
|
886
|
+
" Keep one: `npm uninstall -g passalong` or `pnpm remove -g passalong`, whichever installed it.",
|
|
887
|
+
);
|
|
888
|
+
} catch {}
|
|
931
889
|
} else if (cmd === "--help" || cmd === "-h" || cmd === "help") commands.help();
|
|
932
890
|
// A command this build does not have used to print the help page and exit 0, which reads as "that
|
|
933
891
|
// command exists and did nothing" — and the usual cause is an older passalong, where the command is
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.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
|
@@ -357,11 +357,6 @@ export const setStatus = (id, status) =>
|
|
|
357
357
|
export const remove = (id) => call(`/v1/work/${id}`, { method: "DELETE" });
|
|
358
358
|
export const tasks = () => call("/v1/tasks");
|
|
359
359
|
export const nextTask = (body) => call("/v1/tasks/next", { method: "POST", body });
|
|
360
|
-
/** The runner saying how it is, for the hub: what it is on, today's count, or that it stopped. */
|
|
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 } });
|
|
365
360
|
export const reopen = (id, why) =>
|
|
366
361
|
call(`/v1/work/${encodeURIComponent(id)}/reopen`, { method: "POST", body: { why } });
|
|
367
362
|
/** Take work back from whoever holds it. */
|
package/src/checks.js
CHANGED
|
@@ -50,8 +50,8 @@ export const CHECK_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
50
50
|
export const OUTPUT_MAX = 4000;
|
|
51
51
|
|
|
52
52
|
/**
|
|
53
|
-
* The tail of what a command printed, because that is where a runner puts what went wrong. A
|
|
54
|
-
* would hold the banner and the first hundred passing lines.
|
|
53
|
+
* The tail of what a command printed, because that is where a test runner puts what went wrong. A
|
|
54
|
+
* head would hold the banner and the first hundred passing lines.
|
|
55
55
|
*/
|
|
56
56
|
function tail(out, max = OUTPUT_MAX) {
|
|
57
57
|
const s = String(out ?? "").replace(/\s+$/, "");
|
package/src/passalong.js
CHANGED
|
@@ -8,7 +8,6 @@ import { basename, join } from "node:path";
|
|
|
8
8
|
import * as api from "./api.js";
|
|
9
9
|
import { context } from "./capture.js";
|
|
10
10
|
import { refusal, runChecks } from "./checks.js";
|
|
11
|
-
import * as runner from "./runner.js";
|
|
12
11
|
import * as store from "./store.js";
|
|
13
12
|
import { ID_RE, parse, STATUSES, serialize, stamp, unheldFields, validate } from "./work.js";
|
|
14
13
|
|
|
@@ -219,12 +218,11 @@ function needsSync(what) {
|
|
|
219
218
|
|
|
220
219
|
/**
|
|
221
220
|
* Take the next piece of work for this worktree's repo — always a new one, since an agent may hold
|
|
222
|
-
* several. `null` when there is nothing to do.
|
|
223
|
-
* run unattended (the runner).
|
|
221
|
+
* several. `null` when there is nothing to do.
|
|
224
222
|
*/
|
|
225
|
-
export async function nextWork({ cwd = process.cwd(), any = false
|
|
223
|
+
export async function nextWork({ cwd = process.cwd(), any = false } = {}) {
|
|
226
224
|
needsSync("taking work");
|
|
227
|
-
const { task } = await api.nextTask({ ...agent(cwd), any
|
|
225
|
+
const { task } = await api.nextTask({ ...agent(cwd), any });
|
|
228
226
|
return task || null;
|
|
229
227
|
}
|
|
230
228
|
|
|
@@ -263,7 +261,7 @@ function rememberFence(id, fence, cwd) {
|
|
|
263
261
|
writeFileSync(fenceFile(cwd), `${JSON.stringify(held, null, 2)}\n`);
|
|
264
262
|
}
|
|
265
263
|
|
|
266
|
-
/** A check
|
|
264
|
+
/** A check that was actually run: the command, and what the process returned. See checks.js. */
|
|
267
265
|
const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
|
|
268
266
|
|
|
269
267
|
/**
|
|
@@ -327,7 +325,7 @@ export async function handIn(
|
|
|
327
325
|
) {
|
|
328
326
|
needsSync("handing work in");
|
|
329
327
|
// A check that names a command is run here, before anything is sent. Checks that already carry
|
|
330
|
-
// an exit
|
|
328
|
+
// an exit were already run by the MCP server, and are not run again, so nothing runs twice.
|
|
331
329
|
const pending = checks.filter((c) => c?.cmd && typeof c?.exit !== "number" && c?.exit !== null);
|
|
332
330
|
if (pending.length) {
|
|
333
331
|
const done = runChecks(pending, { cwd });
|
|
@@ -379,11 +377,9 @@ export async function assign(ref, to) {
|
|
|
379
377
|
}
|
|
380
378
|
|
|
381
379
|
/**
|
|
382
|
-
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
|
|
383
|
-
* says the runner is giving it back, unfinished: the server then clears its Safe to run alone tick,
|
|
384
|
-
* so it waits for a person rather than going straight back to an unattended agent.
|
|
380
|
+
* Not this agent's to do: what it held is open again, and `why` goes to whoever is next.
|
|
385
381
|
*/
|
|
386
|
-
export async function pass(id, why, { cwd = process.cwd()
|
|
382
|
+
export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
387
383
|
needsSync("passing work");
|
|
388
384
|
if (!String(why || "").trim())
|
|
389
385
|
throw new PassalongError("say why you are passing it, so whoever is next knows");
|
|
@@ -392,7 +388,6 @@ export async function pass(id, why, { cwd = process.cwd(), runner = false } = {}
|
|
|
392
388
|
agent: agent(cwd).agent,
|
|
393
389
|
why,
|
|
394
390
|
...(fence === undefined ? {} : { fence }),
|
|
395
|
-
...(runner ? { runner: true } : {}),
|
|
396
391
|
});
|
|
397
392
|
}
|
|
398
393
|
|
|
@@ -737,8 +732,6 @@ export function workPrompt(t) {
|
|
|
737
732
|
*
|
|
738
733
|
* An agent that exits without finishing stops the loop, rather than piling up half-done work. It
|
|
739
734
|
* stays held here, for a person to see on the board and resume (take it) or release.
|
|
740
|
-
*
|
|
741
|
-
* The runner, which takes work marked safe to run alone with nobody watching, is watch() below.
|
|
742
735
|
*/
|
|
743
736
|
export async function work({
|
|
744
737
|
cwd = process.cwd(),
|
|
@@ -773,398 +766,6 @@ export async function work({
|
|
|
773
766
|
}
|
|
774
767
|
}
|
|
775
768
|
|
|
776
|
-
/**
|
|
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 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
|
-
*
|
|
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.
|
|
794
|
-
*/
|
|
795
|
-
export async function watch({
|
|
796
|
-
cwd = process.cwd(),
|
|
797
|
-
agent: command,
|
|
798
|
-
every = 60,
|
|
799
|
-
atOnce = runner.LIMITS.atOnce,
|
|
800
|
-
minutes = runner.LIMITS.minutes,
|
|
801
|
-
cli,
|
|
802
|
-
git = runner.git,
|
|
803
|
-
runAgent = runner.runAgent,
|
|
804
|
-
now = () => Date.now(),
|
|
805
|
-
countFile = join(store.HOME, "runner.json"),
|
|
806
|
-
machine = runner.machineName,
|
|
807
|
-
onStart = () => {},
|
|
808
|
-
onTask = () => {},
|
|
809
|
-
onStep = () => {},
|
|
810
|
-
onIdle = () => {},
|
|
811
|
-
onPaused = () => {},
|
|
812
|
-
onFinished = () => {},
|
|
813
|
-
onPassed = () => {},
|
|
814
|
-
onWaiting = () => {},
|
|
815
|
-
onResumed = () => {},
|
|
816
|
-
onTrouble = () => {},
|
|
817
|
-
report = api.runnerStatus,
|
|
818
|
-
postSteps = api.workSteps,
|
|
819
|
-
stepEvery = 5,
|
|
820
|
-
signals = false,
|
|
821
|
-
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
822
|
-
} = {}) {
|
|
823
|
-
needsSync("running work");
|
|
824
|
-
const inside = context(cwd).root;
|
|
825
|
-
const { found, skipped } = runner.checkoutsIn(cwd, { git });
|
|
826
|
-
if (inside && !found.length)
|
|
827
|
-
throw new PassalongError(
|
|
828
|
-
"this checkout has no `origin` remote: the runner pushes each change to a branch there",
|
|
829
|
-
);
|
|
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 });
|
|
844
|
-
|
|
845
|
-
// What it is on, said to the hub once a minute and whenever it changes (PUT /v1/runner), so
|
|
846
|
-
// nobody has to look at this terminal to know. A failed report never stops a run.
|
|
847
|
-
const onNow = new Map();
|
|
848
|
-
const say = (stopped = false) =>
|
|
849
|
-
Promise.resolve(
|
|
850
|
-
report({
|
|
851
|
-
host: name,
|
|
852
|
-
repo: checkouts.length === 1 ? repos[0] : `${basename(cwd)}/`,
|
|
853
|
-
repos,
|
|
854
|
-
running: [...onNow].map(([id, title]) => ({ id, title })),
|
|
855
|
-
runs_today: runner.runsToday(countFile, runner.dayOf(now())),
|
|
856
|
-
per_day: 0,
|
|
857
|
-
at_once: atOnce,
|
|
858
|
-
stopped,
|
|
859
|
-
}),
|
|
860
|
-
).catch(() => {});
|
|
861
|
-
// Ctrl-C: the hub hears it stopped. Agents running then stop with it, since they share the
|
|
862
|
-
// terminal; what they held stays held, and the sweep puts it back once it goes quiet.
|
|
863
|
-
if (signals)
|
|
864
|
-
process.once("SIGINT", async () => {
|
|
865
|
-
await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
|
|
866
|
-
process.exit(130);
|
|
867
|
-
});
|
|
868
|
-
const agentRun = command
|
|
869
|
-
? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
|
|
870
|
-
: runner.claudeCommand({ cli });
|
|
871
|
-
|
|
872
|
-
/** Make the worktree a taken piece of work runs in, as the agent whose id took it. */
|
|
873
|
-
function worktreeFor(k, dir, id) {
|
|
874
|
-
try {
|
|
875
|
-
git(["-C", k.root, "fetch", "--quiet", "origin"]);
|
|
876
|
-
} catch {}
|
|
877
|
-
let base = "HEAD";
|
|
878
|
-
try {
|
|
879
|
-
base = git(["-C", k.root, "rev-parse", "--verify", "--quiet", "origin/HEAD"])
|
|
880
|
-
? "origin/HEAD"
|
|
881
|
-
: base;
|
|
882
|
-
} catch {}
|
|
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) {
|
|
894
|
-
let t = null;
|
|
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;
|
|
915
|
-
}
|
|
916
|
-
if (!t || !k) return null;
|
|
917
|
-
// Its branch, or a fresh one beside it when that one is still checked out in the worktree of
|
|
918
|
-
// an earlier run that didn't finish (kept for its author), as when a person ticks it again.
|
|
919
|
-
let branch = runner.branchOf(t.id);
|
|
920
|
-
try {
|
|
921
|
-
worktreeFor(k, dir, id);
|
|
922
|
-
try {
|
|
923
|
-
git(["-C", dir, "switch", "--quiet", "-C", branch]);
|
|
924
|
-
} catch {
|
|
925
|
-
branch = `${branch}-${now().toString(36)}`;
|
|
926
|
-
git(["-C", dir, "switch", "--quiet", "-c", branch]);
|
|
927
|
-
}
|
|
928
|
-
} catch (err) {
|
|
929
|
-
// Taken and then stuck before any agent started: never left held with nobody on it.
|
|
930
|
-
const why = runner.passBack({ minutes, failed: err.message.split("\n")[0], dir, branch });
|
|
931
|
-
await pass(t.id, why, { cwd: existsSync(dir) ? dir : k.root, runner: true }).catch((e) =>
|
|
932
|
-
onTrouble(e, t),
|
|
933
|
-
);
|
|
934
|
-
onPassed(t, { why, dir, log: "" });
|
|
935
|
-
return { run: Promise.resolve() };
|
|
936
|
-
}
|
|
937
|
-
runner.countRun(countFile, runner.dayOf(now()));
|
|
938
|
-
onNow.set(t.id, t.title || t.id);
|
|
939
|
-
onTask(t, { dir, branch, repo: k.repo });
|
|
940
|
-
say();
|
|
941
|
-
return {
|
|
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);
|
|
945
|
-
say();
|
|
946
|
-
}),
|
|
947
|
-
};
|
|
948
|
-
}
|
|
949
|
-
|
|
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);
|
|
971
|
-
const ran = await runAgent({
|
|
972
|
-
...agentRun,
|
|
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 }),
|
|
979
|
-
cwd: dir,
|
|
980
|
-
log,
|
|
981
|
-
minutes,
|
|
982
|
-
env: { ...process.env, PASSALONG_TASK: t.id },
|
|
983
|
-
onStep: step,
|
|
984
|
-
}).finally(() => clearInterval(timer));
|
|
985
|
-
if (ran.timedOut) step(`Stopped: it ran out of its ${minutes} minutes`);
|
|
986
|
-
let held = null;
|
|
987
|
-
try {
|
|
988
|
-
held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
|
|
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();
|
|
1014
|
-
if (held?.state === "done") {
|
|
1015
|
-
try {
|
|
1016
|
-
git(["-C", k.root, "worktree", "remove", "--force", dir]);
|
|
1017
|
-
} catch {}
|
|
1018
|
-
onFinished(t, { branch, log });
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
// It stopped to ask, and the question is waiting on a person: not given up. The work stays held
|
|
1022
|
-
// here with its worktree, and the run picks up again once they answer (resumeAnswered).
|
|
1023
|
-
if (held?.claim?.asking) {
|
|
1024
|
-
waiting.set(t.id, { t, k, dir, branch });
|
|
1025
|
-
onWaiting(t, { question: held.claim.asking, dir });
|
|
1026
|
-
return;
|
|
1027
|
-
}
|
|
1028
|
-
const why = runner.passBack({
|
|
1029
|
-
minutes,
|
|
1030
|
-
...ran,
|
|
1031
|
-
note: held?.claim?.note || "",
|
|
1032
|
-
said: ran.said || runner.lastLine(log),
|
|
1033
|
-
branch,
|
|
1034
|
-
machine: name,
|
|
1035
|
-
});
|
|
1036
|
-
try {
|
|
1037
|
-
await pass(t.id, why, { cwd: dir, runner: true });
|
|
1038
|
-
onPassed(t, { why, dir, log });
|
|
1039
|
-
} catch (err) {
|
|
1040
|
-
onTrouble(err, t);
|
|
1041
|
-
}
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
const running = new Set();
|
|
1045
|
-
/** Runs stopped on a question, by work id: their checkout, worktree and branch, to resume in. */
|
|
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;
|
|
1050
|
-
/** Start an agent again where it stopped: the same worktree, branch and claim. */
|
|
1051
|
-
function resume(t, k, dir, branch, { afterPause = false } = {}) {
|
|
1052
|
-
runner.countRun(countFile, runner.dayOf(now()));
|
|
1053
|
-
onNow.set(t.id, t.title || t.id);
|
|
1054
|
-
onResumed(t, { dir, branch, afterPause });
|
|
1055
|
-
say();
|
|
1056
|
-
const run = finish(t, k, dir, branch, { resumed: afterPause ? "usage" : true })
|
|
1057
|
-
.catch((err) => onTrouble(err, t))
|
|
1058
|
-
.finally(() => {
|
|
1059
|
-
if (!paused.has(t.id)) onNow.delete(t.id);
|
|
1060
|
-
running.delete(run);
|
|
1061
|
-
say();
|
|
1062
|
-
});
|
|
1063
|
-
running.add(run);
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/**
|
|
1067
|
-
* Questions answered since the last look start their run again, while there is a slot and the
|
|
1068
|
-
* day's allowance lasts. Work that was finished, taken back or given away meanwhile is let go.
|
|
1069
|
-
*/
|
|
1070
|
-
async function resumeAnswered() {
|
|
1071
|
-
if (!waiting.size) return;
|
|
1072
|
-
let rows = [];
|
|
1073
|
-
try {
|
|
1074
|
-
rows = (await api.tasks()).tasks;
|
|
1075
|
-
} catch (err) {
|
|
1076
|
-
return onTrouble(err);
|
|
1077
|
-
}
|
|
1078
|
-
for (const [id, w] of [...waiting]) {
|
|
1079
|
-
const row = rows.find((x) => x.id === id);
|
|
1080
|
-
if (row?.state === "done") {
|
|
1081
|
-
waiting.delete(id);
|
|
1082
|
-
try {
|
|
1083
|
-
git(["-C", w.k.root, "worktree", "remove", "--force", w.dir]);
|
|
1084
|
-
} catch {}
|
|
1085
|
-
onFinished(w.t, { branch: w.branch, log: join(w.k.runs, `${id}.log`) });
|
|
1086
|
-
continue;
|
|
1087
|
-
}
|
|
1088
|
-
if (!row?.claim) {
|
|
1089
|
-
waiting.delete(id);
|
|
1090
|
-
onTrouble(new Error(`no longer held here; its worktree is kept in ${w.dir}`), w.t);
|
|
1091
|
-
continue;
|
|
1092
|
-
}
|
|
1093
|
-
if (row.claim.asking) continue;
|
|
1094
|
-
if (running.size >= atOnce || paused.size) return;
|
|
1095
|
-
waiting.delete(id);
|
|
1096
|
-
resume(w.t, w.k, w.dir, w.branch);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
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.
|
|
1103
|
-
try {
|
|
1104
|
-
for (const row of (await api.tasks()).tasks) {
|
|
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);
|
|
1110
|
-
let branch = runner.branchOf(row.id);
|
|
1111
|
-
try {
|
|
1112
|
-
branch = git(["-C", dir, "branch", "--show-current"]) || branch;
|
|
1113
|
-
} catch {}
|
|
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
|
-
});
|
|
1120
|
-
}
|
|
1121
|
-
} catch {}
|
|
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
|
-
|
|
1142
|
-
let n = 0;
|
|
1143
|
-
for (;;) {
|
|
1144
|
-
await carryOnPaused();
|
|
1145
|
-
await resumeAnswered();
|
|
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) {
|
|
1148
|
-
let slot;
|
|
1149
|
-
try {
|
|
1150
|
-
slot = await startOne(++n);
|
|
1151
|
-
} catch (err) {
|
|
1152
|
-
onTrouble(err);
|
|
1153
|
-
break;
|
|
1154
|
-
}
|
|
1155
|
-
if (!slot) {
|
|
1156
|
-
if (!running.size) onIdle(repos);
|
|
1157
|
-
break;
|
|
1158
|
-
}
|
|
1159
|
-
const run = slot.run.catch((err) => onTrouble(err)).finally(() => running.delete(run));
|
|
1160
|
-
running.add(run);
|
|
1161
|
-
}
|
|
1162
|
-
// Wait for a run to end or for the next look, whichever is first.
|
|
1163
|
-
await say();
|
|
1164
|
-
await Promise.race([sleep(Math.max(10, every) * 1000), ...running]);
|
|
1165
|
-
}
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
769
|
/**
|
|
1169
770
|
* Make every piece of work you wrote that is still in Draft ready, and return their ids. For the
|
|
1170
771
|
* moment a planner has written several and you have read them all. Only your own: sending work on
|
package/src/update.js
CHANGED
|
@@ -20,8 +20,15 @@
|
|
|
20
20
|
// command started it, and config.json is read, patched and written whole: a refresh that read it
|
|
21
21
|
// just before `logout` or `use` wrote it, and wrote it back after, put the old accounts and the old
|
|
22
22
|
// default back. This file has one writer, so there is nothing to lose.
|
|
23
|
-
import {
|
|
24
|
-
|
|
23
|
+
import {
|
|
24
|
+
existsSync,
|
|
25
|
+
mkdirSync,
|
|
26
|
+
readFileSync,
|
|
27
|
+
realpathSync,
|
|
28
|
+
renameSync,
|
|
29
|
+
writeFileSync,
|
|
30
|
+
} from "node:fs";
|
|
31
|
+
import { delimiter, dirname, join } from "node:path";
|
|
25
32
|
import { HOME } from "./store.js";
|
|
26
33
|
|
|
27
34
|
const FILE = join(HOME, "update.json");
|
|
@@ -138,3 +145,41 @@ export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writ
|
|
|
138
145
|
}
|
|
139
146
|
return latest;
|
|
140
147
|
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Other copies of passalong on PATH, besides the one at `cli`: their path and version, and whether
|
|
151
|
+
* one of them comes first, so `passalong` typed in a shell runs it rather than this. npm and pnpm
|
|
152
|
+
* each keep their own global copy, and updating one left the old one running from the other
|
|
153
|
+
* (2026-11-06). Each version is read from the package.json beside the bin's real file.
|
|
154
|
+
*/
|
|
155
|
+
export function otherCopies(cli, { path = process.env.PATH || "" } = {}) {
|
|
156
|
+
const real = (p) => {
|
|
157
|
+
try {
|
|
158
|
+
return realpathSync(p);
|
|
159
|
+
} catch {
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const versionAt = (file) => {
|
|
164
|
+
try {
|
|
165
|
+
return (
|
|
166
|
+
JSON.parse(readFileSync(join(dirname(file), "..", "package.json"), "utf8")).version || ""
|
|
167
|
+
);
|
|
168
|
+
} catch {
|
|
169
|
+
return "";
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
const mine = real(cli);
|
|
173
|
+
const seen = new Set();
|
|
174
|
+
const found = [];
|
|
175
|
+
for (const dir of path.split(delimiter).filter(Boolean)) {
|
|
176
|
+
const bin = join(dir, process.platform === "win32" ? "passalong.cmd" : "passalong");
|
|
177
|
+
if (!existsSync(bin)) continue;
|
|
178
|
+
const file = real(bin);
|
|
179
|
+
if (!file || seen.has(file)) continue;
|
|
180
|
+
seen.add(file);
|
|
181
|
+
found.push({ path: bin, version: versionAt(file), mine: file === mine });
|
|
182
|
+
}
|
|
183
|
+
const others = found.filter((f) => !f.mine);
|
|
184
|
+
return { others, shadowed: found.length > 0 && !found[0].mine };
|
|
185
|
+
}
|
package/src/runner.js
DELETED
|
@@ -1,591 +0,0 @@
|
|
|
1
|
-
// The runner: `passalong work --watch` (docs/LOOP.md §1a). Left running with a repo checked out,
|
|
2
|
-
// it takes work a person ticked Safe to run alone and starts Claude Code on each piece, with nobody
|
|
3
|
-
// watching.
|
|
4
|
-
//
|
|
5
|
-
// Each piece gets its own git worktree, on a branch of its own, so up to three agents can work at
|
|
6
|
-
// once without touching each other's files, and each one's change arrives as a pull request a
|
|
7
|
-
// person reviews. At most ten runs start in a day. A run that ends without the work done (the agent
|
|
8
|
-
// gave up, said it was blocked, or ran out of time) gives the work back to whoever wrote it, with
|
|
9
|
-
// the agent's last note, and clears its Safe to run alone tick, so it is not tried again unwatched
|
|
10
|
-
// until a person has looked (2026-11-06).
|
|
11
|
-
//
|
|
12
|
-
// Everything that touches git, the clock or a process is passed in, so the tests can run it.
|
|
13
|
-
|
|
14
|
-
import { execFileSync, spawn } from "node:child_process";
|
|
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";
|
|
25
|
-
|
|
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;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
|
|
39
|
-
* nobody to ask: read and edit files in its worktree, use Passalong, run the usual build and test
|
|
40
|
-
* tools, commit, push its own branch and open a pull request. Never push anywhere else, force a
|
|
41
|
-
* push, or throw work away.
|
|
42
|
-
*/
|
|
43
|
-
export const PERMISSIONS = {
|
|
44
|
-
allow: [
|
|
45
|
-
"Read",
|
|
46
|
-
"Edit",
|
|
47
|
-
"Write",
|
|
48
|
-
"Glob",
|
|
49
|
-
"Grep",
|
|
50
|
-
"mcp__passalong",
|
|
51
|
-
"Bash(git status*)",
|
|
52
|
-
"Bash(git diff*)",
|
|
53
|
-
"Bash(git log*)",
|
|
54
|
-
"Bash(git show*)",
|
|
55
|
-
"Bash(git add *)",
|
|
56
|
-
"Bash(git commit *)",
|
|
57
|
-
"Bash(git push -u origin passalong/*)",
|
|
58
|
-
"Bash(gh pr create *)",
|
|
59
|
-
"Bash(gh pr view *)",
|
|
60
|
-
"Bash(npm *)",
|
|
61
|
-
"Bash(pnpm *)",
|
|
62
|
-
"Bash(yarn *)",
|
|
63
|
-
"Bash(npx *)",
|
|
64
|
-
"Bash(node *)",
|
|
65
|
-
"Bash(make *)",
|
|
66
|
-
"Bash(python *)",
|
|
67
|
-
"Bash(python3 *)",
|
|
68
|
-
"Bash(pytest *)",
|
|
69
|
-
"Bash(go *)",
|
|
70
|
-
"Bash(cargo *)",
|
|
71
|
-
],
|
|
72
|
-
deny: [
|
|
73
|
-
"Bash(git push --force*)",
|
|
74
|
-
"Bash(git push -f*)",
|
|
75
|
-
"Bash(git push * --force*)",
|
|
76
|
-
"Bash(git reset --hard*)",
|
|
77
|
-
"Bash(rm -rf *)",
|
|
78
|
-
],
|
|
79
|
-
};
|
|
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
|
-
|
|
84
|
-
/** The branch a piece of work's change goes on. */
|
|
85
|
-
export const branchOf = (id) => `passalong/${id}`;
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Claude Code, started for one piece of work: headless, refusing anything not allowed above, and
|
|
89
|
-
* with Passalong's MCP server named here, because `passalong setup` registers it for one folder and
|
|
90
|
-
* a fresh worktree is another. The server runs in the worktree, so it is that worktree's agent: the
|
|
91
|
-
* one that took the work. The prompt goes in on stdin.
|
|
92
|
-
*/
|
|
93
|
-
export function claudeCommand({ node = process.execPath, cli } = {}) {
|
|
94
|
-
return {
|
|
95
|
-
command: "claude",
|
|
96
|
-
args: [
|
|
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",
|
|
103
|
-
"--permission-mode",
|
|
104
|
-
"dontAsk",
|
|
105
|
-
"--settings",
|
|
106
|
-
JSON.stringify({ permissions: PERMISSIONS }),
|
|
107
|
-
"--mcp-config",
|
|
108
|
-
JSON.stringify({ mcpServers: { passalong: { command: node, args: [cli, "mcp"] } } }),
|
|
109
|
-
],
|
|
110
|
-
stdin: true,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** What the agent is told: the work itself, and how to finish it as a pull request. */
|
|
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
|
-
];
|
|
132
|
-
return [
|
|
133
|
-
`You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
|
|
134
|
-
"",
|
|
135
|
-
String(t.markdown || "").trim(),
|
|
136
|
-
"",
|
|
137
|
-
`Do it in this worktree, which is on its own branch, ${branch}. Read it, work out what done`,
|
|
138
|
-
`means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
|
|
139
|
-
"status at each milestone; 30 minutes of silence stalls it.",
|
|
140
|
-
"",
|
|
141
|
-
...ship,
|
|
142
|
-
"sentence saying what you did, and `evidence`: what you ran and",
|
|
143
|
-
"what came back, with the commands and the lines that decided it, the test summary, the commit.",
|
|
144
|
-
"Keep that as you go. What you learned about the project goes into its documents",
|
|
145
|
-
"(save_document). If a call says you no longer hold it, stop.",
|
|
146
|
-
"",
|
|
147
|
-
"Nobody reads what you print: this session runs unattended, and anything not allowed is",
|
|
148
|
-
"refused rather than asked.",
|
|
149
|
-
"",
|
|
150
|
-
"When a decision is the person's to make (which of two layouts, which of three fixes), ask",
|
|
151
|
-
`them: call ask with id ${t.id}, one clear question, and \`choices\`: two to four options,`,
|
|
152
|
-
"each with a label and a line of detail. When an option is something to look at, take a",
|
|
153
|
-
"screenshot of it if you can, upload it with add_file, and give its link as that option's",
|
|
154
|
-
"`picture`. Then stop. They pick one with a tap in the hub, and you are started again here to",
|
|
155
|
-
"read their pick (take) and carry on.",
|
|
156
|
-
"",
|
|
157
|
-
"If something only a person can do stands between you and done (a permission, a secret), call",
|
|
158
|
-
`progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you need,`,
|
|
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.",
|
|
164
|
-
].join("\n");
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* What an agent is told when its run starts again after a person answered its question: read the
|
|
169
|
-
* answer, then finish exactly as the first run was told to.
|
|
170
|
-
*/
|
|
171
|
-
export function resumePrompt(t, branch = branchOf(t.id), opts = {}) {
|
|
172
|
-
return [
|
|
173
|
-
`You hold passalong work ${t.id} (${t.title || t.id}), and you stopped earlier to ask the person`,
|
|
174
|
-
`a question. They have answered. Call the passalong MCP tool take with id ${t.id} first: their`,
|
|
175
|
-
"answer comes back with it. Then carry on in this worktree, where your earlier changes are, and",
|
|
176
|
-
"finish as before:",
|
|
177
|
-
"",
|
|
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)
|
|
197
|
-
.split("\n")
|
|
198
|
-
.slice(4)
|
|
199
|
-
.join("\n"),
|
|
200
|
-
].join("\n");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Today, as the runner counts days: the machine's own date. */
|
|
204
|
-
export const dayOf = (now) => {
|
|
205
|
-
const d = new Date(now);
|
|
206
|
-
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
207
|
-
};
|
|
208
|
-
|
|
209
|
-
/** How many runs have started today, kept in `file` beside the rest of the CLI's state: shown, not a limit. */
|
|
210
|
-
export function runsToday(file, day) {
|
|
211
|
-
try {
|
|
212
|
-
const s = JSON.parse(readFileSync(file, "utf8"));
|
|
213
|
-
return s.day === day ? Number(s.runs) || 0 : 0;
|
|
214
|
-
} catch {
|
|
215
|
-
return 0;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function countRun(file, day) {
|
|
220
|
-
const runs = runsToday(file, day) + 1;
|
|
221
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
222
|
-
writeFileSync(file, `${JSON.stringify({ day, runs })}\n`);
|
|
223
|
-
return runs;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Where the runs' worktrees and logs live: beside the checkout, never inside it. */
|
|
227
|
-
export const runsDirOf = (root) => join(dirname(root), `${basename(root)}.passalong-runs`);
|
|
228
|
-
|
|
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 }) {
|
|
236
|
-
const how = failed
|
|
237
|
-
? `couldn't start it (${failed})`
|
|
238
|
-
: timedOut
|
|
239
|
-
? `ran out of its ${minutes} minutes`
|
|
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);
|
|
244
|
-
return [
|
|
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}` : ""}.`,
|
|
249
|
-
]
|
|
250
|
-
.filter(Boolean)
|
|
251
|
-
.join(" ");
|
|
252
|
-
}
|
|
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
|
-
|
|
303
|
-
/** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
|
|
304
|
-
export function lastLine(log) {
|
|
305
|
-
try {
|
|
306
|
-
const lines = readFileSync(log, "utf8").trim().split("\n");
|
|
307
|
-
return (lines.at(-1) || "").trim().slice(0, 300);
|
|
308
|
-
} catch {
|
|
309
|
-
return "";
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/** Run a git command, quietly; its output, trimmed. Throws when git fails. */
|
|
314
|
-
export function git(args) {
|
|
315
|
-
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
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.
|
|
324
|
-
*/
|
|
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) => {
|
|
337
|
-
let timedOut = false;
|
|
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
|
-
};
|
|
349
|
-
try {
|
|
350
|
-
child = spawn(command, stdin ? args : [...args, prompt], {
|
|
351
|
-
cwd,
|
|
352
|
-
env,
|
|
353
|
-
stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
|
|
354
|
-
});
|
|
355
|
-
} catch (err) {
|
|
356
|
-
write(`couldn't start: ${err.message}`);
|
|
357
|
-
return done({ exit: err.message, timedOut });
|
|
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());
|
|
379
|
-
const timer = setTimeout(
|
|
380
|
-
() => {
|
|
381
|
-
timedOut = true;
|
|
382
|
-
child.kill("SIGTERM");
|
|
383
|
-
},
|
|
384
|
-
minutes * 60 * 1000,
|
|
385
|
-
);
|
|
386
|
-
child.on("error", (err) => {
|
|
387
|
-
clearTimeout(timer);
|
|
388
|
-
write(`couldn't start: ${err.message}`);
|
|
389
|
-
done({ exit: err.message, timedOut });
|
|
390
|
-
});
|
|
391
|
-
child.on("close", (code) => {
|
|
392
|
-
clearTimeout(timer);
|
|
393
|
-
done({ exit: code, timedOut });
|
|
394
|
-
});
|
|
395
|
-
if (stdin) {
|
|
396
|
-
child.stdin.on("error", () => {});
|
|
397
|
-
child.stdin.end(prompt);
|
|
398
|
-
}
|
|
399
|
-
});
|
|
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
|
-
}
|