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 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
- if (f.watch) return runner(f);
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
- console.log(JSON.parse(readFileSync(join(PKG, "package.json"), "utf8")).version);
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.0",
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 head
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. `alone` asks only for work a person marked safe to
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, alone = 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, ...(alone ? { alone: true } : {}) });
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 a runner executed: the command it ran, and what the process returned. See checks.js. */
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 came from the MCP server's own runner and are left alone, so nothing runs twice.
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. `runner`
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(), runner = false } = {}) {
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 { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
- import { join } from "node:path";
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
- }