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 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.0",
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
- const agentRun = command
869
- ? { command: command.trim().split(/\s+/)[0], args: command.trim().split(/\s+/).slice(1) }
870
- : runner.claudeCommand({ cli });
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
- /** Make the worktree a taken piece of work runs in, as the agent whose id took it. */
873
- function worktreeFor(k, dir, id) {
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
- t = got?.task || null;
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
- 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 }),
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 (held?.state !== "done" && !held?.claim) {
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 (held.state !== "done" && !held.claim?.asking)
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 (held?.state === "done") {
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. 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).
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: 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 function usageOut(text, now = Date.now()) {
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
+ }