passalong 1.4.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/src/runner.js DELETED
@@ -1,645 +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: 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.
13
- //
14
- // Everything that touches git, the clock or a process is passed in, so the tests can run it.
15
-
16
- import { execFileSync, spawn } from "node:child_process";
17
- import {
18
- appendFileSync,
19
- existsSync,
20
- mkdirSync,
21
- readdirSync,
22
- readFileSync,
23
- realpathSync,
24
- statSync,
25
- writeFileSync,
26
- } from "node:fs";
27
- import { hostname, platform } from "node:os";
28
- import { basename, delimiter, dirname, join, relative } from "node:path";
29
-
30
- /**
31
- * How many agents at once, and how long one run may take. There is no count of runs a day: it was
32
- * ten, and a loop that gave the same work back over and over spent all ten on nothing, so the
33
- * runner sat idle with work waiting (2026-11-06). What limits a runner now is the agent's own usage
34
- * (`usageOut`: it pauses until that comes back) and the work the Passalong plan lets you keep.
35
- */
36
- export const LIMITS = { atOnce: 3, minutes: 60 };
37
-
38
- /** How long a runner waits to try again when its agent's usage ran out and it didn't say until when. */
39
- export const USAGE_RETRY_MS = 15 * 60 * 1000;
40
-
41
- /**
42
- * What an unattended agent may do. `dontAsk` refuses anything not listed here, since there is
43
- * nobody to ask: read and edit files in its worktree, use Passalong, run the usual build and test
44
- * tools, commit, push its own branch and open a pull request. Never push anywhere else, force a
45
- * push, or throw work away.
46
- */
47
- export const PERMISSIONS = {
48
- allow: [
49
- "Read",
50
- "Edit",
51
- "Write",
52
- "Glob",
53
- "Grep",
54
- "mcp__passalong",
55
- "Bash(git status*)",
56
- "Bash(git diff*)",
57
- "Bash(git log*)",
58
- "Bash(git show*)",
59
- "Bash(git add *)",
60
- "Bash(git commit *)",
61
- "Bash(git push -u origin passalong/*)",
62
- "Bash(gh pr create *)",
63
- "Bash(gh pr view *)",
64
- "Bash(npm *)",
65
- "Bash(pnpm *)",
66
- "Bash(yarn *)",
67
- "Bash(npx *)",
68
- "Bash(node *)",
69
- "Bash(make *)",
70
- "Bash(python *)",
71
- "Bash(python3 *)",
72
- "Bash(pytest *)",
73
- "Bash(go *)",
74
- "Bash(cargo *)",
75
- ],
76
- deny: [
77
- "Bash(git push --force*)",
78
- "Bash(git push -f*)",
79
- "Bash(git push * --force*)",
80
- "Bash(git reset --hard*)",
81
- "Bash(rm -rf *)",
82
- ],
83
- };
84
-
85
- /** Where a checkout's origin is hosted, for how a run ends: a pull request or a merge request. */
86
- export const forgeOf = (remote) => (/gitlab/i.test(String(remote || "")) ? "gitlab" : "github");
87
-
88
- /** The branch a piece of work's change goes on. */
89
- export const branchOf = (id) => `passalong/${id}`;
90
-
91
- /**
92
- * Claude Code, started for one piece of work: headless, refusing anything not allowed above, and
93
- * with Passalong's MCP server named here, because `passalong setup` registers it for one folder and
94
- * a fresh worktree is another. The server runs in the worktree, so it is that worktree's agent: the
95
- * one that took the work. The prompt goes in on stdin.
96
- */
97
- export function claudeCommand({ node = process.execPath, cli, permissions = PERMISSIONS } = {}) {
98
- return {
99
- command: "claude",
100
- args: [
101
- "-p",
102
- // One JSON event per line as it works, which the runner turns into the steps a person can
103
- // watch from the hub (describe below). Plain -p prints only the final answer, at the end.
104
- "--output-format",
105
- "stream-json",
106
- "--verbose",
107
- "--permission-mode",
108
- "dontAsk",
109
- "--settings",
110
- JSON.stringify({ permissions }),
111
- "--mcp-config",
112
- JSON.stringify({ mcpServers: { passalong: { command: node, args: [cli, "mcp"] } } }),
113
- ],
114
- stdin: true,
115
- };
116
- }
117
-
118
- /** What the agent is told: the work itself, and how to finish it as a pull request. */
119
- export function runnerPrompt(t, branch = branchOf(t.id), { forge = "github" } = {}) {
120
- // GitLab has no `gh`: a push option opens the merge request, and git prints its link.
121
- const ship =
122
- forge === "gitlab"
123
- ? [
124
- `When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
125
- `exactly \`git push -u origin ${branch} -o merge_request.create\`, which opens a merge`,
126
- "request and prints its link. Then call hand_in with id " + t.id + ", `pr` set to that",
127
- "link (or the commit hash if the push failed, and say why in the note), `note`: one plain",
128
- ]
129
- : [
130
- `When it is done, commit what you changed with a message starting "${t.id}: ", then run`,
131
- `exactly \`git push -u origin ${branch}\` and open a pull request with`,
132
- `\`gh pr create --fill --head ${branch}\`. Then call hand_in with id ${t.id}, \`pr\` set to the`,
133
- "pull request's URL (or the commit hash if the push or the pull request failed, and say why in",
134
- "the note), `note`: one plain",
135
- ];
136
- return [
137
- `You hold passalong work ${t.id}, and no other agent can take it while you do. Here it is:`,
138
- "",
139
- String(t.markdown || "").trim(),
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
- "",
145
- `Do it in this worktree, which is on its own branch, ${branch}. Read it, work out what done`,
146
- `means, and get there. Call the passalong MCP tool progress with id ${t.id} and a one-line`,
147
- "status at each milestone; 30 minutes of silence stalls it.",
148
- "",
149
- ...ship,
150
- "sentence saying what you did, and `evidence`: what you ran and",
151
- "what came back, with the commands and the lines that decided it, the test summary, the commit.",
152
- "Keep that as you go. What you learned about the project goes into its documents",
153
- "(save_document). If a call says you no longer hold it, stop.",
154
- "",
155
- "Nobody reads what you print: this session runs unattended, and anything not allowed is",
156
- "refused rather than asked.",
157
- "",
158
- "When a decision is the person's to make (which of two layouts, which of three fixes), ask",
159
- `them: call ask with id ${t.id}, one clear question, and \`choices\`: two to four options,`,
160
- "each with a label and a line of detail. When an option is something to look at, take a",
161
- "screenshot of it if you can, upload it with add_file, and give its link as that option's",
162
- "`picture`. Then stop. They pick one with a tap in the hub, and you are started again here to",
163
- "read their pick (take) and carry on.",
164
- "",
165
- "If something only a person can do stands between you and done (a permission, a secret), call",
166
- `progress with id ${t.id} and a note starting "BLOCKED: " that says exactly what you need,`,
167
- "and stop. The work goes back to the person who wrote it, with that note.",
168
- "",
169
- "Never call pass yourself. If you stop without hand_in, the runner gives the work back",
170
- "unfinished and shows your last message as the reason, so end with what is done and what",
171
- "isn't, in plain sentences.",
172
- ].join("\n");
173
- }
174
-
175
- /**
176
- * What an agent is told when its run starts again after a person answered its question: read the
177
- * answer, then finish exactly as the first run was told to.
178
- */
179
- export function resumePrompt(t, branch = branchOf(t.id), opts = {}) {
180
- return [
181
- `You hold passalong work ${t.id} (${t.title || t.id}), and you stopped earlier to ask the person`,
182
- `a question. They have answered. Call the passalong MCP tool take with id ${t.id} first: their`,
183
- "answer comes back with it. Then carry on in this worktree, where your earlier changes are, and",
184
- "finish as before:",
185
- "",
186
- runnerPrompt({ ...t, markdown: "" }, branch, opts)
187
- .split("\n")
188
- .slice(4)
189
- .join("\n"),
190
- ].join("\n");
191
- }
192
-
193
- /**
194
- * What an agent is told when its run starts again after it stopped part-way, because Claude's usage
195
- * ran out or the runner was stopped: the work is still held, its changes are in this worktree.
196
- */
197
- export function pausedPrompt(t, branch = branchOf(t.id), opts = {}) {
198
- return [
199
- `You hold passalong work ${t.id} (${t.title || t.id}). An earlier run on it stopped before it`,
200
- "finished, because Claude's usage ran out or the runner was stopped. Call the passalong MCP tool",
201
- `take with id ${t.id} first to read the work again. Then look at what is already done in this`,
202
- "worktree (git status, git log), carry on from there, and finish as before:",
203
- "",
204
- runnerPrompt({ ...t, markdown: "" }, branch, opts)
205
- .split("\n")
206
- .slice(4)
207
- .join("\n"),
208
- ].join("\n");
209
- }
210
-
211
- /** Today, as the runner counts days: the machine's own date. */
212
- export const dayOf = (now) => {
213
- const d = new Date(now);
214
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
215
- };
216
-
217
- /** How many runs have started today, kept in `file` beside the rest of the CLI's state: shown, not a limit. */
218
- export function runsToday(file, day) {
219
- try {
220
- const s = JSON.parse(readFileSync(file, "utf8"));
221
- return s.day === day ? Number(s.runs) || 0 : 0;
222
- } catch {
223
- return 0;
224
- }
225
- }
226
-
227
- export function countRun(file, day) {
228
- const runs = runsToday(file, day) + 1;
229
- mkdirSync(dirname(file), { recursive: true });
230
- writeFileSync(file, `${JSON.stringify({ day, runs })}\n`);
231
- return runs;
232
- }
233
-
234
- /** Where the runs' worktrees and logs live: beside the checkout, never inside it. */
235
- export const runsDirOf = (root) => join(dirname(root), `${basename(root)}.passalong-runs`);
236
-
237
- /**
238
- * Why a run gave its work back, said to whoever wrote it and to the team's channel. It leads with
239
- * what the agent itself said at the end, which is nearly always the reason ("Not done: I couldn't
240
- * start the app to take the screenshots"). It said "stopped without finishing (exit 0)", which
241
- * means nothing to a person, and "the last thing it printed", cut mid-word (2026-11-06).
242
- */
243
- export function passBack({ minutes, timedOut, exit, note, said, branch, failed, machine }) {
244
- const how = failed
245
- ? `couldn't start it (${failed})`
246
- : timedOut
247
- ? `ran out of its ${minutes} minutes`
248
- : exit === 0
249
- ? "ended its run without handing it in"
250
- : `stopped with an error${exit === undefined || exit === null || exit === "" ? "" : ` (${typeof exit === "number" ? `exit code ${exit}` : exit})`}`;
251
- const words = sentences(note || said, 600);
252
- return [
253
- `The runner's agent ${how}.`,
254
- words ? `It said: "${words}"` : "It didn't say why.",
255
- "It's back in To do and no longer marked safe to run alone, so no runner takes it again until someone ticks it.",
256
- failed ? "" : `What it changed is kept on branch ${branch}${machine ? ` on ${machine}` : ""}.`,
257
- ]
258
- .filter(Boolean)
259
- .join(" ");
260
- }
261
-
262
- /** At most `max` characters, ended at a sentence or a word rather than mid-word. */
263
- export function sentences(text, max) {
264
- const one = String(text ?? "")
265
- .replace(/\s+/g, " ")
266
- .trim();
267
- if (one.length <= max) return one;
268
- const cut = one.slice(0, max);
269
- const stop = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf("? "), cut.lastIndexOf("! "));
270
- if (stop > max * 0.5) return cut.slice(0, stop + 1);
271
- const space = cut.lastIndexOf(" ");
272
- return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
273
- }
274
-
275
- /**
276
- * Whether what an agent said means its usage ran out (Claude Code's plan limit, or the API refusing
277
- * for rate or credit), and when it comes back: `{ until }` in ms, 0 when it didn't say. `null` when
278
- * it means something else. Only Claude Code's own phrasings, in short messages: an agent that ran
279
- * out says so in a line, and one that added "a daily limit" to someone's app must not pause.
280
- */
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) {
294
- const s = String(text ?? "").trim();
295
- if (!s || s.length > 400) return null;
296
- if (!usagePattern(pattern).test(s)) return null;
297
- // "Claude AI usage limit reached|1736789000": when it resets, in seconds.
298
- const epoch = s.match(/\|(\d{10})\b/);
299
- if (epoch) return { until: Number(epoch[1]) * 1000 };
300
- // "resets 3pm", "resets at 5:30 pm": the next time the clock says that, on this machine.
301
- const at = s.match(/resets?\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/i);
302
- if (at) {
303
- let h = Number(at[1]) % 12;
304
- if (!at[3]) h = Number(at[1]) % 24;
305
- else if (at[3].toLowerCase() === "pm") h += 12;
306
- const d = new Date(now);
307
- d.setHours(h, Number(at[2] || 0), 0, 0);
308
- if (d.getTime() <= now) d.setDate(d.getDate() + 1);
309
- return { until: d.getTime() };
310
- }
311
- return { until: 0 };
312
- }
313
-
314
- /** "3:00 pm", for saying when a paused runner carries on. */
315
- export const clockOf = (ms) =>
316
- new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
317
-
318
- /** The last line an agent printed, from its log: often the reason it stopped. "" when none. */
319
- export function lastLine(log) {
320
- try {
321
- const lines = readFileSync(log, "utf8").trim().split("\n");
322
- return (lines.at(-1) || "").trim().slice(0, 300);
323
- } catch {
324
- return "";
325
- }
326
- }
327
-
328
- /** Run a git command, quietly; its output, trimmed. Throws when git fails. */
329
- export function git(args) {
330
- return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
331
- }
332
-
333
- /**
334
- * Start an agent and wait for it. What it prints is read a line at a time: each becomes a line of
335
- * `log` a person can read (Claude Code's JSON events made into sentences, anything else as it came),
336
- * and each thing it does is handed to `onStep`. Killed after `minutes`. Resolves
337
- * `{ exit, timedOut, said, usage }`, never rejects: an agent that would not start is a run that ended without
338
- * the work done.
339
- */
340
- export function runAgent({
341
- command,
342
- args,
343
- stdin,
344
- prompt,
345
- cwd,
346
- log,
347
- minutes,
348
- env,
349
- onStep = () => {},
350
- usage: usageSource = USAGE_OUT,
351
- }) {
352
- return new Promise((resolve) => {
353
- let timedOut = false;
354
- let child;
355
- /** What the agent said at the end (Claude Code's result), for why it gave the work back. */
356
- let said = "";
357
- /** Set when it said its usage ran out (usageOut): the run is paused, not given up. */
358
- let usage = null;
359
- const done = (r) => resolve({ ...r, said, usage });
360
- const write = (text) => {
361
- try {
362
- appendFileSync(log, `${clock()} ${text}\n`);
363
- } catch {}
364
- };
365
- try {
366
- child = spawn(command, stdin ? args : [...args, prompt], {
367
- cwd,
368
- env,
369
- stdio: [stdin ? "pipe" : "ignore", "pipe", "pipe"],
370
- });
371
- } catch (err) {
372
- write(`couldn't start: ${err.message}`);
373
- return done({ exit: err.message, timedOut });
374
- }
375
- const reader = () => {
376
- let rest = "";
377
- return (chunk) => {
378
- rest += chunk;
379
- const lines = rest.split("\n");
380
- rest = lines.pop() ?? "";
381
- for (const line of lines) {
382
- if (!line.trim()) continue;
383
- const d = describe(line, cwd, usageSource);
384
- if (d.said) said = d.said;
385
- usage = d.usage || usage;
386
- if (d.log) write(d.log);
387
- for (const step of d.step ? d.step.split("\n") : []) onStep(step);
388
- }
389
- };
390
- };
391
- child.stdout?.setEncoding("utf8");
392
- child.stderr?.setEncoding("utf8");
393
- child.stdout?.on("data", reader());
394
- child.stderr?.on("data", reader());
395
- const timer = setTimeout(
396
- () => {
397
- timedOut = true;
398
- child.kill("SIGTERM");
399
- },
400
- minutes * 60 * 1000,
401
- );
402
- child.on("error", (err) => {
403
- clearTimeout(timer);
404
- write(`couldn't start: ${err.message}`);
405
- done({ exit: err.message, timedOut });
406
- });
407
- child.on("close", (code) => {
408
- clearTimeout(timer);
409
- done({ exit: code, timedOut });
410
- });
411
- if (stdin) {
412
- child.stdin.on("error", () => {});
413
- child.stdin.end(prompt);
414
- }
415
- });
416
- }
417
-
418
- const clock = () => new Date().toTimeString().slice(0, 8);
419
-
420
- /**
421
- * Take out what looks like a secret before a line leaves the machine: a long run of letters and
422
- * digits (a token, a key) and the value after token=, secret=, password= or key=. Steps are read by
423
- * the team in the hub, and an agent's commands can carry what its environment holds.
424
- */
425
- export function hideSecrets(text) {
426
- return String(text)
427
- .replace(/\b((?:[a-z_]*_)?(?:token|secret|password|passwd|api_?key|key))=\S+/gi, "$1=[hidden]")
428
- .replace(/\b(?=[A-Za-z0-9_-]*\d)(?=[A-Za-z0-9_-]*[A-Za-z])[A-Za-z0-9_-]{32,}\b/g, "[hidden]");
429
- }
430
-
431
- const one = (s, max = 140) => {
432
- const line =
433
- String(s ?? "")
434
- .split("\n")
435
- .map((l) => l.trim())
436
- .find(Boolean) ?? "";
437
- return line.length > max ? `${line.slice(0, max - 1)}…` : line;
438
- };
439
-
440
- /** What one tool call does, in words: "Editing src/deals/form.tsx", "Running `npm test`". */
441
- export function toolStep(name, input = {}, cwd = "") {
442
- const path = (p) => {
443
- if (!p) return "a file";
444
- const r = cwd ? relative(cwd, String(p)) : String(p);
445
- return r && !r.startsWith("..") ? r : String(p);
446
- };
447
- const said = {
448
- Read: () => `Reading ${path(input.file_path)}`,
449
- Edit: () => `Editing ${path(input.file_path)}`,
450
- MultiEdit: () => `Editing ${path(input.file_path)}`,
451
- Write: () => `Writing ${path(input.file_path)}`,
452
- NotebookEdit: () => `Editing ${path(input.notebook_path)}`,
453
- Bash: () => `Running \`${one(input.command, 120)}\``,
454
- Grep: () => `Searching for "${one(input.pattern, 80)}"`,
455
- Glob: () => `Looking for files: ${one(input.pattern, 80)}`,
456
- WebFetch: () => `Reading ${one(input.url, 120)}`,
457
- WebSearch: () => `Searching the web for "${one(input.query, 80)}"`,
458
- TodoWrite: () => {
459
- const now = (input.todos || []).find((t) => t.status === "in_progress");
460
- return now ? `Next: ${one(now.activeForm || now.content)}` : "Planning the steps";
461
- },
462
- Task: () => `Starting a helper: ${one(input.description, 80)}`,
463
- Agent: () => `Starting a helper: ${one(input.description, 80)}`,
464
- mcp__passalong__progress: () => `Progress: ${one(input.note)}`,
465
- mcp__passalong__hand_in: () => `Handing it in${input.pr ? `: ${one(input.pr)}` : ""}`,
466
- mcp__passalong__ask: () => `Asking you: ${one(input.question)}`,
467
- mcp__passalong__take: () => "Reading the work again",
468
- mcp__passalong__save_document: () =>
469
- `Writing down what it learned: ${one(input.title || input.path)}`,
470
- }[name];
471
- if (said) return said();
472
- const short = String(name).replace(/^mcp__([^_]+)__/, "$1: ");
473
- return `Using ${short}`;
474
- }
475
-
476
- /**
477
- * One line the agent printed, as a line of the log and, when it did something, a step.
478
- *
479
- * Claude Code's stream-json events become sentences: what it says (its first line), each tool it
480
- * calls, a tool that failed, and the result at the end. Anything that is not one of those events
481
- * (another agent, a crash) is kept as it came, and is a step too: it may be the only sign of why a
482
- * run stopped.
483
- */
484
- export function describe(line, cwd = "", usage = USAGE_OUT) {
485
- let e;
486
- try {
487
- e = JSON.parse(line);
488
- } catch {
489
- const text = hideSecrets(one(line, 300));
490
- return { log: text, step: text, usage: usageOut(line, Date.now(), usage) };
491
- }
492
- if (!e || typeof e !== "object") return { log: hideSecrets(one(line, 300)), step: "" };
493
- if (e.type === "system" && e.subtype === "init")
494
- return { log: `started${e.model ? ` (${e.model})` : ""}`, step: "" };
495
- if (e.type === "assistant") {
496
- // Claude Code writes the refusal as a message of its own, marked with what went wrong.
497
- if (["rate_limit", "billing_error"].includes(e.error)) {
498
- const said = (e.message?.content ?? []).map((c) => c.text || "").join(" ");
499
- const text = hideSecrets(one(said || "Claude's usage ran out", 200));
500
- return { log: text, step: "", usage: usageOut(said, Date.now(), usage) || { until: 0 } };
501
- }
502
- const logs = [];
503
- const steps = [];
504
- for (const c of e.message?.content ?? []) {
505
- if (c.type === "text" && c.text?.trim()) {
506
- logs.push(hideSecrets(c.text.trim()));
507
- steps.push(hideSecrets(one(c.text)));
508
- } else if (c.type === "tool_use") {
509
- const s = hideSecrets(toolStep(c.name, c.input, cwd));
510
- logs.push(s);
511
- steps.push(s);
512
- }
513
- }
514
- return { log: logs.join("\n"), step: steps.join("\n") };
515
- }
516
- if (e.type === "user") {
517
- const failed = (e.message?.content ?? []).find((c) => c.type === "tool_result" && c.is_error);
518
- if (!failed) return { log: "", step: "" };
519
- const why = Array.isArray(failed.content)
520
- ? failed.content.map((x) => x.text || "").join(" ")
521
- : failed.content;
522
- const text = hideSecrets(`That didn't work: ${one(why)}`);
523
- return { log: text, step: text };
524
- }
525
- if (e.type === "result") {
526
- const how = e.is_error ? "Stopped with an error" : "Finished";
527
- const text = hideSecrets(`${how}${e.result ? `: ${one(e.result, 200)}` : ""}`);
528
- // Its whole last message, too: when the work comes back unfinished, this is why.
529
- return {
530
- log: text,
531
- step: text,
532
- said: hideSecrets(String(e.result || "").trim()),
533
- usage: usageOut(e.result, Date.now(), usage),
534
- };
535
- }
536
- return { log: "", step: "" };
537
- }
538
-
539
- /**
540
- * The git checkouts a runner started in `cwd` serves: `cwd`'s own when it is inside one, else every
541
- * checkout in the folders under it, three levels down at most. Worktrees, dependencies, hidden
542
- * folders and the runner's own run folders are skipped. Those without an `origin` remote are listed
543
- * apart, since a run ends by pushing its branch there.
544
- */
545
- export function checkoutsIn(cwd, { git: run = git, depth = 3 } = {}) {
546
- const top = (dir) => {
547
- try {
548
- return run(["-C", dir, "rev-parse", "--show-toplevel"]);
549
- } catch {
550
- return "";
551
- }
552
- };
553
- const roots = [];
554
- const own = top(cwd);
555
- if (own) roots.push(own);
556
- else {
557
- const walk = (dir, left) => {
558
- let names = [];
559
- try {
560
- names = readdirSync(dir);
561
- } catch {
562
- return;
563
- }
564
- if (names.includes(".git")) {
565
- // A .git file is a worktree of a checkout found elsewhere, not a repo of its own.
566
- try {
567
- if (statSync(join(dir, ".git")).isDirectory()) roots.push(top(dir) || dir);
568
- } catch {}
569
- return;
570
- }
571
- if (!left) return;
572
- for (const n of names.sort()) {
573
- if (n.startsWith(".") || n === "node_modules" || n.endsWith(".passalong-runs")) continue;
574
- const p = join(dir, n);
575
- try {
576
- if (statSync(p).isDirectory()) walk(p, left - 1);
577
- } catch {}
578
- }
579
- };
580
- walk(cwd, depth);
581
- }
582
- const found = [];
583
- const skipped = [];
584
- for (const root of roots) {
585
- let remote = "";
586
- try {
587
- remote = run(["-C", root, "remote", "get-url", "origin"]);
588
- } catch {}
589
- if (remote) found.push({ root, remote });
590
- else skipped.push(root);
591
- }
592
- return { found, skipped };
593
- }
594
-
595
- /** This computer as a person knows it: "Lukman's MacBook Pro" on a Mac, its host name elsewhere. */
596
- export function machineName() {
597
- if (platform() === "darwin") {
598
- try {
599
- const name = execFileSync("scutil", ["--get", "ComputerName"], {
600
- encoding: "utf8",
601
- stdio: ["ignore", "pipe", "ignore"],
602
- }).trim();
603
- if (name) return name;
604
- } catch {}
605
- }
606
- return hostname();
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
- }