passalong 1.3.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/passalong +12 -0
- package/package.json +1 -1
- package/src/passalong.js +48 -17
- package/src/runner.js +73 -19
package/bin/passalong
CHANGED
|
@@ -61,6 +61,7 @@ import * as api from "../src/api.js";
|
|
|
61
61
|
import { workScaffold } from "../src/capture.js";
|
|
62
62
|
import { parse, serialize } from "../src/work.js";
|
|
63
63
|
import * as passalong from "../src/passalong.js";
|
|
64
|
+
import { otherCopies } from "../src/runner.js";
|
|
64
65
|
import * as store from "../src/store.js";
|
|
65
66
|
|
|
66
67
|
const PKG = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -277,6 +278,17 @@ async function runner(f) {
|
|
|
277
278
|
);
|
|
278
279
|
if (skipped.length)
|
|
279
280
|
console.error(`Skipped ${skipped.length} checkout${skipped.length === 1 ? "" : "s"} with no origin remote: ${some(skipped)}`);
|
|
281
|
+
// Another copy on PATH (npm's beside pnpm's): updating one leaves the other running.
|
|
282
|
+
const mine = JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version;
|
|
283
|
+
const { others, shadowed } = otherCopies(fileURLToPath(import.meta.url));
|
|
284
|
+
for (const o of others)
|
|
285
|
+
console.error(
|
|
286
|
+
`! Another passalong${o.version ? ` (${o.version})` : ""} is installed at ${o.path}` +
|
|
287
|
+
(shadowed && o === others[0]
|
|
288
|
+
? `, and it is the one \`passalong\` runs in a new terminal, not this ${mine}.`
|
|
289
|
+
: `, beside this ${mine}.`) +
|
|
290
|
+
" Keep one: `npm uninstall -g passalong` or `pnpm remove -g passalong`, whichever installed it.",
|
|
291
|
+
);
|
|
280
292
|
},
|
|
281
293
|
onTask: (t, { dir, branch, repo }) => {
|
|
282
294
|
idle = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "passalong",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Work, projects, documents and files for a team and its agents: take work, hand it in with evidence, and keep what the team knows in its projects.",
|
|
5
5
|
"mcpName": "io.github.codellyson/passalong",
|
|
6
6
|
"keywords": [
|
package/src/passalong.js
CHANGED
|
@@ -420,6 +420,7 @@ export function needsWhy(t) {
|
|
|
420
420
|
if (!t.mine) return "";
|
|
421
421
|
if (t.state === "draft") return "in Draft: read it and mark it Ready";
|
|
422
422
|
if (t.state === "stalled") return "its agent went quiet";
|
|
423
|
+
if (t.state === "review") return "the runner finished it: ready for your review";
|
|
423
424
|
if (t.claim?.asking) return `asking you: ${t.claim.asking}`;
|
|
424
425
|
if (/^BLOCKED:/i.test(t.claim?.note || ""))
|
|
425
426
|
return `stuck on you: ${t.claim.note.replace(/^BLOCKED:\s*/i, "")}`;
|
|
@@ -865,12 +866,24 @@ export async function watch({
|
|
|
865
866
|
await Promise.race([say(true), new Promise((r) => setTimeout(r, 2000))]);
|
|
866
867
|
process.exit(130);
|
|
867
868
|
});
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
869
|
+
/**
|
|
870
|
+
* The agent's command for one run: `--agent` as given, else Claude Code with what the server's
|
|
871
|
+
* brief allows it (runner-brief.ts on the server), or this package's own list without one.
|
|
872
|
+
*/
|
|
873
|
+
const agentRun = (brief) =>
|
|
874
|
+
command
|
|
875
|
+
? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
|
|
876
|
+
: runner.claudeCommand({
|
|
877
|
+
cli,
|
|
878
|
+
...(brief?.permissions?.allow ? { permissions: brief.permissions } : {}),
|
|
879
|
+
});
|
|
871
880
|
|
|
872
|
-
/**
|
|
873
|
-
|
|
881
|
+
/**
|
|
882
|
+
* Make the worktree a taken piece of work runs in, as the agent whose id took it. Work sent back
|
|
883
|
+
* from review starts where its last run left off: on its pushed branch, not from scratch, which
|
|
884
|
+
* would also leave the agent unable to push over its own earlier commits.
|
|
885
|
+
*/
|
|
886
|
+
function worktreeFor(k, dir, id, workId) {
|
|
874
887
|
try {
|
|
875
888
|
git(["-C", k.root, "fetch", "--quiet", "origin"]);
|
|
876
889
|
} catch {}
|
|
@@ -880,6 +893,10 @@ export async function watch({
|
|
|
880
893
|
? "origin/HEAD"
|
|
881
894
|
: base;
|
|
882
895
|
} catch {}
|
|
896
|
+
try {
|
|
897
|
+
const earlier = `origin/${runner.branchOf(workId)}`;
|
|
898
|
+
if (git(["-C", k.root, "rev-parse", "--verify", "--quiet", earlier])) base = earlier;
|
|
899
|
+
} catch {}
|
|
883
900
|
git(["-C", k.root, "worktree", "add", "--detach", "--quiet", dir, base]);
|
|
884
901
|
writeFileSync(join(localDir(dir), "agent.json"), `${JSON.stringify({ id }, null, 2)}\n`);
|
|
885
902
|
}
|
|
@@ -906,8 +923,11 @@ export async function watch({
|
|
|
906
923
|
repo: k.repo,
|
|
907
924
|
worktree: dir,
|
|
908
925
|
alone: true,
|
|
926
|
+
// So the server's brief ends the run the way this checkout's host takes changes.
|
|
927
|
+
forge: k.forge,
|
|
909
928
|
});
|
|
910
|
-
|
|
929
|
+
// The brief rides with the work: what to tell its agent, from the server (1.4.0's).
|
|
930
|
+
t = got?.task ? { ...got.task, brief: got.runner || null } : null;
|
|
911
931
|
} catch (err) {
|
|
912
932
|
onTrouble(err);
|
|
913
933
|
}
|
|
@@ -918,7 +938,7 @@ export async function watch({
|
|
|
918
938
|
// an earlier run that didn't finish (kept for its author), as when a person ticks it again.
|
|
919
939
|
let branch = runner.branchOf(t.id);
|
|
920
940
|
try {
|
|
921
|
-
worktreeFor(k, dir, id);
|
|
941
|
+
worktreeFor(k, dir, id, t.id);
|
|
922
942
|
try {
|
|
923
943
|
git(["-C", dir, "switch", "--quiet", "-C", branch]);
|
|
924
944
|
} catch {
|
|
@@ -968,14 +988,18 @@ export async function watch({
|
|
|
968
988
|
: `Started on ${name} in ${k.repo}, on branch ${branch}`,
|
|
969
989
|
);
|
|
970
990
|
const timer = setInterval(flush, stepEvery * 1000);
|
|
991
|
+
// The server's words when it sent them, with this run's branch filled in; else this package's.
|
|
992
|
+
const told = (said) => (typeof said === "string" ? said.replaceAll("{branch}", branch) : "");
|
|
993
|
+
const prompt =
|
|
994
|
+
resumed === "usage"
|
|
995
|
+
? told(t.brief?.paused) || runner.pausedPrompt(t, branch, { forge: k.forge })
|
|
996
|
+
: resumed
|
|
997
|
+
? told(t.brief?.resume) || runner.resumePrompt(t, branch, { forge: k.forge })
|
|
998
|
+
: told(t.brief?.start) || runner.runnerPrompt(t, branch, { forge: k.forge });
|
|
971
999
|
const ran = await runAgent({
|
|
972
|
-
...agentRun,
|
|
973
|
-
prompt
|
|
974
|
-
|
|
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 }),
|
|
1000
|
+
...agentRun(t.brief),
|
|
1001
|
+
prompt,
|
|
1002
|
+
...(t.brief?.usage ? { usage: t.brief.usage } : {}),
|
|
979
1003
|
cwd: dir,
|
|
980
1004
|
log,
|
|
981
1005
|
minutes,
|
|
@@ -987,9 +1011,12 @@ export async function watch({
|
|
|
987
1011
|
try {
|
|
988
1012
|
held = (await api.tasks()).tasks.find((x) => x.id === t.id) || null;
|
|
989
1013
|
} catch {}
|
|
1014
|
+
// Handed in: Done, or (from 1.4.0's server on) waiting for a person to approve it or send it
|
|
1015
|
+
// back. Either way this run is over and its branch is pushed.
|
|
1016
|
+
const handedIn = held?.state === "done" || held?.state === "review";
|
|
990
1017
|
// Let go of already: its agent passed it, somebody pressed Stop, or it was taken back. Giving
|
|
991
1018
|
// it back again would be refused, and it is no longer this runner's to describe.
|
|
992
|
-
if (
|
|
1019
|
+
if (!handedIn && !held?.claim) {
|
|
993
1020
|
step("It was no longer held here when the agent stopped, so the runner left it as it is");
|
|
994
1021
|
await flush();
|
|
995
1022
|
onTrouble(new Error("no longer held here when its agent stopped; left as it is"), t);
|
|
@@ -1007,11 +1034,15 @@ export async function watch({
|
|
|
1007
1034
|
onPaused(t, { until: ran.usage.until ? pauseUntil : 0 });
|
|
1008
1035
|
return;
|
|
1009
1036
|
}
|
|
1010
|
-
if (
|
|
1037
|
+
if (!handedIn && !held.claim?.asking)
|
|
1011
1038
|
step("Gave it back unfinished, with the reason on the work. Nothing it changed is lost.");
|
|
1039
|
+
if (held.state === "review")
|
|
1040
|
+
step(
|
|
1041
|
+
"Handed in. It's ready for your review: approve it, or send it back with what to change",
|
|
1042
|
+
);
|
|
1012
1043
|
if (held.claim?.asking) step("Waiting for your answer, then it carries on");
|
|
1013
1044
|
await flush();
|
|
1014
|
-
if (
|
|
1045
|
+
if (handedIn) {
|
|
1015
1046
|
try {
|
|
1016
1047
|
git(["-C", k.root, "worktree", "remove", "--force", dir]);
|
|
1017
1048
|
} catch {}
|
package/src/runner.js
CHANGED
|
@@ -4,24 +4,28 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Each piece gets its own git worktree, on a branch of its own, so up to three agents can work at
|
|
6
6
|
// once without touching each other's files, and each one's change arrives as a pull request a
|
|
7
|
-
// person reviews
|
|
8
|
-
// gave up, said it was blocked, or ran out of time) gives the work
|
|
9
|
-
// the agent's last note, and clears its Safe to run alone tick, so
|
|
10
|
-
// until a person has looked (2026-11-06).
|
|
7
|
+
// person reviews: what it hands in waits for them to approve it or send it back. A run that ends
|
|
8
|
+
// without the work done (the agent gave up, said it was blocked, or ran out of time) gives the work
|
|
9
|
+
// back to whoever wrote it, with the agent's last note, and clears its Safe to run alone tick, so
|
|
10
|
+
// it is not tried again unwatched until a person has looked (2026-11-06). What the agent is told
|
|
11
|
+
// and may run comes from the server with each piece of work (the brief); the copy here is for a
|
|
12
|
+
// server that sends none.
|
|
11
13
|
//
|
|
12
14
|
// Everything that touches git, the clock or a process is passed in, so the tests can run it.
|
|
13
15
|
|
|
14
16
|
import { execFileSync, spawn } from "node:child_process";
|
|
15
17
|
import {
|
|
16
18
|
appendFileSync,
|
|
19
|
+
existsSync,
|
|
17
20
|
mkdirSync,
|
|
18
21
|
readdirSync,
|
|
19
22
|
readFileSync,
|
|
23
|
+
realpathSync,
|
|
20
24
|
statSync,
|
|
21
25
|
writeFileSync,
|
|
22
26
|
} from "node:fs";
|
|
23
27
|
import { hostname, platform } from "node:os";
|
|
24
|
-
import { basename, dirname, join, relative } from "node:path";
|
|
28
|
+
import { basename, delimiter, dirname, join, relative } from "node:path";
|
|
25
29
|
|
|
26
30
|
/**
|
|
27
31
|
* How many agents at once, and how long one run may take. There is no count of runs a day: it was
|
|
@@ -90,7 +94,7 @@ export const branchOf = (id) => `passalong/${id}`;
|
|
|
90
94
|
* a fresh worktree is another. The server runs in the worktree, so it is that worktree's agent: the
|
|
91
95
|
* one that took the work. The prompt goes in on stdin.
|
|
92
96
|
*/
|
|
93
|
-
export function claudeCommand({ node = process.execPath, cli } = {}) {
|
|
97
|
+
export function claudeCommand({ node = process.execPath, cli, permissions = PERMISSIONS } = {}) {
|
|
94
98
|
return {
|
|
95
99
|
command: "claude",
|
|
96
100
|
args: [
|
|
@@ -103,7 +107,7 @@ export function claudeCommand({ node = process.execPath, cli } = {}) {
|
|
|
103
107
|
"--permission-mode",
|
|
104
108
|
"dontAsk",
|
|
105
109
|
"--settings",
|
|
106
|
-
JSON.stringify({ permissions
|
|
110
|
+
JSON.stringify({ permissions }),
|
|
107
111
|
"--mcp-config",
|
|
108
112
|
JSON.stringify({ mcpServers: { passalong: { command: node, args: [cli, "mcp"] } } }),
|
|
109
113
|
],
|
|
@@ -134,6 +138,10 @@ export function runnerPrompt(t, branch = branchOf(t.id), { forge = "github" } =
|
|
|
134
138
|
"",
|
|
135
139
|
String(t.markdown || "").trim(),
|
|
136
140
|
"",
|
|
141
|
+
`First call the passalong MCP tool take with id ${t.id}: it hands you any notes a person left.`,
|
|
142
|
+
"If this branch already has commits, an earlier run was sent back from review, and those notes",
|
|
143
|
+
"say what to change: start from what is there and change that, rather than starting over.",
|
|
144
|
+
"",
|
|
137
145
|
`Do it in this worktree, which is on its own branch, ${branch}. Read it, work out what done`,
|
|
138
146
|
`means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
|
|
139
147
|
"status at each milestone; 30 minutes of silence stalls it.",
|
|
@@ -270,15 +278,22 @@ export function sentences(text, max) {
|
|
|
270
278
|
* it means something else. Only Claude Code's own phrasings, in short messages: an agent that ran
|
|
271
279
|
* out says so in a line, and one that added "a daily limit" to someone's app must not pause.
|
|
272
280
|
*/
|
|
273
|
-
export
|
|
281
|
+
export const USAGE_OUT =
|
|
282
|
+
"usage limit reached|hit your (usage |session |weekly )?limit|(session|weekly|5-hour|five-hour|opus|sonnet) limit reached|out of (extra )?usage|rate_limit_error|credit balance is too low";
|
|
283
|
+
|
|
284
|
+
/** The server's pattern when it sent one (the brief), else this copy; a bad one falls back too. */
|
|
285
|
+
function usagePattern(source) {
|
|
286
|
+
try {
|
|
287
|
+
return new RegExp(source || USAGE_OUT, "i");
|
|
288
|
+
} catch {
|
|
289
|
+
return new RegExp(USAGE_OUT, "i");
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function usageOut(text, now = Date.now(), pattern = USAGE_OUT) {
|
|
274
294
|
const s = String(text ?? "").trim();
|
|
275
295
|
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;
|
|
296
|
+
if (!usagePattern(pattern).test(s)) return null;
|
|
282
297
|
// "Claude AI usage limit reached|1736789000": when it resets, in seconds.
|
|
283
298
|
const epoch = s.match(/\|(\d{10})\b/);
|
|
284
299
|
if (epoch) return { until: Number(epoch[1]) * 1000 };
|
|
@@ -332,6 +347,7 @@ export function runAgent({
|
|
|
332
347
|
minutes,
|
|
333
348
|
env,
|
|
334
349
|
onStep = () => {},
|
|
350
|
+
usage: usageSource = USAGE_OUT,
|
|
335
351
|
}) {
|
|
336
352
|
return new Promise((resolve) => {
|
|
337
353
|
let timedOut = false;
|
|
@@ -364,7 +380,7 @@ export function runAgent({
|
|
|
364
380
|
rest = lines.pop() ?? "";
|
|
365
381
|
for (const line of lines) {
|
|
366
382
|
if (!line.trim()) continue;
|
|
367
|
-
const d = describe(line, cwd);
|
|
383
|
+
const d = describe(line, cwd, usageSource);
|
|
368
384
|
if (d.said) said = d.said;
|
|
369
385
|
usage = d.usage || usage;
|
|
370
386
|
if (d.log) write(d.log);
|
|
@@ -465,13 +481,13 @@ export function toolStep(name, input = {}, cwd = "") {
|
|
|
465
481
|
* (another agent, a crash) is kept as it came, and is a step too: it may be the only sign of why a
|
|
466
482
|
* run stopped.
|
|
467
483
|
*/
|
|
468
|
-
export function describe(line, cwd = "") {
|
|
484
|
+
export function describe(line, cwd = "", usage = USAGE_OUT) {
|
|
469
485
|
let e;
|
|
470
486
|
try {
|
|
471
487
|
e = JSON.parse(line);
|
|
472
488
|
} catch {
|
|
473
489
|
const text = hideSecrets(one(line, 300));
|
|
474
|
-
return { log: text, step: text, usage: usageOut(line) };
|
|
490
|
+
return { log: text, step: text, usage: usageOut(line, Date.now(), usage) };
|
|
475
491
|
}
|
|
476
492
|
if (!e || typeof e !== "object") return { log: hideSecrets(one(line, 300)), step: "" };
|
|
477
493
|
if (e.type === "system" && e.subtype === "init")
|
|
@@ -481,7 +497,7 @@ export function describe(line, cwd = "") {
|
|
|
481
497
|
if (["rate_limit", "billing_error"].includes(e.error)) {
|
|
482
498
|
const said = (e.message?.content ?? []).map((c) => c.text || "").join(" ");
|
|
483
499
|
const text = hideSecrets(one(said || "Claude's usage ran out", 200));
|
|
484
|
-
return { log: text, step: "", usage: usageOut(said) || { until: 0 } };
|
|
500
|
+
return { log: text, step: "", usage: usageOut(said, Date.now(), usage) || { until: 0 } };
|
|
485
501
|
}
|
|
486
502
|
const logs = [];
|
|
487
503
|
const steps = [];
|
|
@@ -514,7 +530,7 @@ export function describe(line, cwd = "") {
|
|
|
514
530
|
log: text,
|
|
515
531
|
step: text,
|
|
516
532
|
said: hideSecrets(String(e.result || "").trim()),
|
|
517
|
-
usage: usageOut(e.result),
|
|
533
|
+
usage: usageOut(e.result, Date.now(), usage),
|
|
518
534
|
};
|
|
519
535
|
}
|
|
520
536
|
return { log: "", step: "" };
|
|
@@ -589,3 +605,41 @@ export function machineName() {
|
|
|
589
605
|
}
|
|
590
606
|
return hostname();
|
|
591
607
|
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Other copies of passalong on PATH, besides the one at `cli`: their path and version, and whether
|
|
611
|
+
* one of them comes first, so `passalong` typed in a shell runs it rather than this. npm and pnpm
|
|
612
|
+
* each keep their own global copy, and updating one left an old runner running from the other
|
|
613
|
+
* (2026-11-06). Each version is read from the package.json beside the bin's real file.
|
|
614
|
+
*/
|
|
615
|
+
export function otherCopies(cli, { path = process.env.PATH || "" } = {}) {
|
|
616
|
+
const real = (p) => {
|
|
617
|
+
try {
|
|
618
|
+
return realpathSync(p);
|
|
619
|
+
} catch {
|
|
620
|
+
return "";
|
|
621
|
+
}
|
|
622
|
+
};
|
|
623
|
+
const versionAt = (file) => {
|
|
624
|
+
try {
|
|
625
|
+
return (
|
|
626
|
+
JSON.parse(readFileSync(join(dirname(file), "..", "package.json"), "utf8")).version || ""
|
|
627
|
+
);
|
|
628
|
+
} catch {
|
|
629
|
+
return "";
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
const mine = real(cli);
|
|
633
|
+
const seen = new Set();
|
|
634
|
+
const found = [];
|
|
635
|
+
for (const dir of path.split(delimiter).filter(Boolean)) {
|
|
636
|
+
const bin = join(dir, process.platform === "win32" ? "passalong.cmd" : "passalong");
|
|
637
|
+
if (!existsSync(bin)) continue;
|
|
638
|
+
const file = real(bin);
|
|
639
|
+
if (!file || seen.has(file)) continue;
|
|
640
|
+
seen.add(file);
|
|
641
|
+
found.push({ path: bin, version: versionAt(file), mine: file === mine });
|
|
642
|
+
}
|
|
643
|
+
const others = found.filter((f) => !f.mine);
|
|
644
|
+
return { others, shadowed: found.length > 0 && !found[0].mine };
|
|
645
|
+
}
|