passalong 0.19.0 → 0.21.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "passalong",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Hand tasks, bugs and finished work to AI coding agents, and get back a write-up with evidence.",
5
5
  "mcpName": "io.github.codellyson/passalong",
6
6
  "keywords": [
package/src/api.js CHANGED
@@ -120,6 +120,19 @@ export function loggedIn() {
120
120
  return Boolean(token());
121
121
  }
122
122
 
123
+ /**
124
+ * Every login this machine holds, as `{ token, api }` for a call's `as`: each kept account and the
125
+ * one in use, once each. For work that belongs to all of them, like `tidy`, rather than to one.
126
+ */
127
+ export function logins() {
128
+ const all = Object.values(readAccounts())
129
+ .filter((a) => a.token)
130
+ .map((a) => ({ token: a.token, api: a.api || readConfig().api || DEFAULT_API }));
131
+ if (token()) all.push({ token: token(), api: baseUrl() });
132
+ const seen = new Set();
133
+ return all.filter((a) => !seen.has(`${a.token} ${a.api}`) && seen.add(`${a.token} ${a.api}`));
134
+ }
135
+
123
136
  /** The team this account works in. It belongs to the account, not the machine. */
124
137
  export function currentTeam() {
125
138
  const p = picked();
@@ -149,12 +162,12 @@ export { nameFor };
149
162
 
150
163
  async function call(
151
164
  path,
152
- { method = "GET", body, auth = true, raw = false, uploadName = "" } = {},
165
+ { method = "GET", body, auth = true, raw = false, uploadName = "", as = null } = {},
153
166
  ) {
154
167
  const headers = { ...CLIENT };
155
168
  if (auth) {
156
- if (needsChoice()) throw new ApiError(409, choiceMessage());
157
- const t = token();
169
+ if (!as && needsChoice()) throw new ApiError(409, choiceMessage());
170
+ const t = as ? as.token : token();
158
171
  if (!t) throw new ApiError(401, "not logged in — run `passalong login` to enable sync");
159
172
  headers.authorization = `Bearer ${t}`;
160
173
  }
@@ -166,9 +179,10 @@ async function call(
166
179
  : "application/json";
167
180
  }
168
181
  if (uploadName) headers["x-file-name"] = uploadName.replace(/[^\x20-\x7e]/g, "").slice(0, 120);
182
+ const base = as ? as.api.replace(/\/$/, "") : baseUrl();
169
183
  let res;
170
184
  try {
171
- res = await fetch(`${baseUrl()}${path}`, {
185
+ res = await fetch(`${base}${path}`, {
172
186
  method,
173
187
  headers,
174
188
  body:
@@ -179,7 +193,7 @@ async function call(
179
193
  : JSON.stringify(body),
180
194
  });
181
195
  } catch (err) {
182
- throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
196
+ throw new ApiError(0, `could not reach ${base} (${err.message})`);
183
197
  }
184
198
  if (!res.ok) {
185
199
  const text = await res.text();
@@ -379,10 +393,10 @@ export const inbox = () => call("/v1/inbox");
379
393
  export const createReport = (body) => call("/v1/reports", { method: "POST", body });
380
394
  /** One report and the issues filed under it, grouped by product area. */
381
395
  export const report = (id) => call(`/v1/reports/${encodeURIComponent(id)}`);
382
- export const get = (id, { peek = false } = {}) =>
383
- call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true });
396
+ export const get = (id, { peek = false, as = null } = {}) =>
397
+ call(`/v1/guides/${id}${q({ peek: peek ? "1" : "" })}`, { raw: true, as });
384
398
  /** One page of every guide this account can read, for `export`. Records no pull. */
385
- export const exportPage = (after = "") => call(`/v1/export${q({ after })}`);
399
+ export const exportPage = (after = "", as = null) => call(`/v1/export${q({ after })}`, { as });
386
400
  /**
387
401
  * A guide's follow-ups, one level down. `markdown` adds each one's content, oldest first and
388
402
  * capped server-side; neither form counts as opening them.
@@ -485,7 +499,7 @@ export async function uploadFile(bytes, type, name = "") {
485
499
  try {
486
500
  res = await fetch(`${baseUrl()}/v1/attachments`, { method: "POST", headers, body: bytes });
487
501
  } catch (err) {
488
- throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
502
+ throw new ApiError(0, `could not reach ${base} (${err.message})`);
489
503
  }
490
504
  if (!res.ok) {
491
505
  const body = await res.text();
@@ -509,7 +523,7 @@ export async function uploadShot(bytes, type, name = "") {
509
523
  try {
510
524
  res = await fetch(`${baseUrl()}/v1/shots`, { method: "POST", headers, body: bytes });
511
525
  } catch (err) {
512
- throw new ApiError(0, `could not reach ${baseUrl()} (${err.message})`);
526
+ throw new ApiError(0, `could not reach ${base} (${err.message})`);
513
527
  }
514
528
  if (!res.ok) {
515
529
  const body = await res.text();
package/src/hooks.js CHANGED
@@ -21,28 +21,47 @@ const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
21
21
  /**
22
22
  * What this worktree's agent holds and what is waiting, as a few lines for an agent to read.
23
23
  * Empty when there is nothing to say, so a session in an unrelated repo is not told anything.
24
+ *
25
+ * `held` is a list: an agent may hold several things, and several sessions in one worktree are one
26
+ * agent to the queue. So a session is told what the worktree holds, and to carry on only with what
27
+ * was its own before a restart — never to resume another session's work.
24
28
  */
25
- export function nowText({ held, waiting = {} }) {
26
- if (held) {
29
+ export function nowText({ held = [], waiting = {} }) {
30
+ if (held.length === 1) {
31
+ const [h] = held;
27
32
  const lines = [
28
33
  // `kind` is required on what /v1/working sends. The `|| "task"` that used to be here was
29
34
  // both dead and wrong — absent has meant transfer since kinds existed, never task.
30
- `Passalong: You hold ${held.id} (${held.kind}): ${held.title || held.id}.`,
35
+ `Passalong: This worktree holds ${h.id} (${h.kind}): ${h.title || h.id}.`,
31
36
  ];
32
- if (held.note) lines.push(`Last note: "${held.note}".`);
37
+ if (h.note) lines.push(`Last note: "${h.note}".`);
33
38
  // The one thing here that is new information rather than a reminder: the person has written, and
34
39
  // nothing else in the session will say so until the agent next checks in.
35
- if (held.replies)
40
+ if (h.replies)
36
41
  lines.push(
37
- `${plural(held.replies, "message", "messages")} from the person ${held.replies === 1 ? "is" : "are"} ` +
38
- `waiting for you: call take ${held.id} to read ${held.replies === 1 ? "it" : "them"} before anything else.`,
42
+ `${plural(h.replies, "message", "messages")} from the person ${h.replies === 1 ? "is" : "are"} ` +
43
+ `waiting: call take ${h.id} to read ${h.replies === 1 ? "it" : "them"} before anything else.`,
39
44
  );
40
- if (held.state === "stalled")
45
+ if (h.state === "stalled")
41
46
  lines.push("It is marked stalled: nobody has heard from its agent in 30 minutes.");
42
47
  lines.push(
43
- `To carry on, call take ${held.id} (it resumes what you hold), then progress at each ` +
44
- "milestone. When it is done, hand_in with evidence — what you ran and what came back — " +
45
- "so keep it as you go; if it is not yours or you are stuck, pass with the reason.",
48
+ `If it was yours before this session restarted, call take ${h.id} to carry on, then ` +
49
+ "progress at each milestone. When it is done, hand_in with evidence — what you ran and " +
50
+ "what came back — so keep it as you go; if it is not yours or you are stuck, pass with " +
51
+ "the reason. If another session here is on it, leave it to that session.",
52
+ );
53
+ return lines.join("\n");
54
+ }
55
+ if (held.length > 1) {
56
+ const lines = [`Passalong: This worktree holds ${held.length} things:`];
57
+ for (const h of held)
58
+ lines.push(
59
+ `- ${h.id} (${h.kind}): ${h.title || h.id}${h.state === "stalled" ? " — stalled" : ""}` +
60
+ `${h.replies ? ` — ${plural(h.replies, "message", "messages")} waiting` : ""}`,
61
+ );
62
+ lines.push(
63
+ "Several sessions may be working here. Carry on only with what was yours before this " +
64
+ "session restarted (take <id> resumes it) and leave the rest to the sessions on them.",
46
65
  );
47
66
  return lines.join("\n");
48
67
  }
@@ -59,19 +78,22 @@ export function nowText({ held, waiting = {} }) {
59
78
  * Whether a session may stop. `input` is what Claude Code sends the Stop hook on stdin. Null lets
60
79
  * it stop; an object is the hook's answer, blocking once with what to call. `stop_hook_active`
61
80
  * means this stop is already the session continuing because of this hook, and blocking again
62
- * would leave it no way to end.
81
+ * would leave it no way to end. The hook cannot tell which session took what, so it names
82
+ * everything still open here and lets the session settle its own.
63
83
  */
64
- export function stopVerdict({ held, input = {} }) {
84
+ export function stopVerdict({ held = [], input = {} }) {
65
85
  // An agent that asked a question was told to stop and wait, so it must be let: blocking it would
66
86
  // make the instruction and the hook contradict each other, and the agent would pick one.
67
- if (!held || held.asking || input.stop_hook_active) return null;
87
+ const open = held.filter((h) => !h.asking);
88
+ if (!open.length || input.stop_hook_active) return null;
89
+ const ids = open.map((h) => h.id).join(", ");
68
90
  return {
69
91
  decision: "block",
70
92
  reason:
71
- `You still hold ${held.id}: ${held.title || held.id}. Before ending, do one of: ` +
72
- `hand_in ${held.id} with your evidence if it is done; pass ${held.id} with the reason if ` +
73
- `it is not yours or you are stuck; or, if the user is stopping mid-way, progress ${held.id} ` +
74
- "with a note saying what is left — then end.",
93
+ `This worktree still holds ${ids}. For anything there you worked on in this session, do ` +
94
+ "one of before ending: hand_in it with your evidence if it is done; pass it with the reason " +
95
+ "if it is not yours or you are stuck; or, if the user is stopping mid-way, progress it with " +
96
+ "a note saying what is left. Leave what other sessions are on — then end.",
75
97
  };
76
98
  }
77
99
 
@@ -112,15 +134,19 @@ const clip = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
112
134
  * `needs` counts everything waiting on the person: work handed in for review, agents stuck on
113
135
  * them, and guides handed to them.
114
136
  */
115
- export function statusText({ held, needs = 0, ready = 0 }) {
137
+ export function statusText({ held = [], needs = 0, ready = 0 }) {
116
138
  const parts = [];
117
- if (held) {
118
- const said = held.note ? ` "${clip(held.note, 32)}"` : "";
119
- parts.push(`▸ ${held.id}${held.state === "stalled" ? " (stalled)" : ""}${said}`);
139
+ if (held.length === 1) {
140
+ const [h] = held;
141
+ const said = h.note ? ` "${clip(h.note, 32)}"` : "";
142
+ parts.push(`▸ ${h.id}${h.state === "stalled" ? " (stalled)" : ""}${said}`);
143
+ } else if (held.length > 1) {
144
+ const stalled = held.filter((h) => h.state === "stalled").length;
145
+ parts.push(`▸ ${held.length} held${stalled ? ` (${stalled} stalled)` : ""}`);
120
146
  } else parts.push("passalong");
121
147
  if (needs) parts.push(`${needs} need${needs === 1 ? "s" : ""} you`);
122
148
  if (ready) parts.push(`${ready} ready`);
123
- if (parts.length === 1 && !held) parts.push("clear");
149
+ if (parts.length === 1 && !held.length) parts.push("clear");
124
150
  return parts.join(" · ");
125
151
  }
126
152
 
package/src/mcp.js CHANGED
@@ -334,8 +334,8 @@ export function buildServer() {
334
334
  "session into a guide (guide_template kind transfer shows the shape) and call " +
335
335
  "publish_guide, with `to` as team, team/@handle for one teammate, or team/#group for the " +
336
336
  "people who do a thing. " +
337
- "BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call take with no id, or read the " +
338
- "inbox, and see what you hold. If this session's work answers something you hold, hand_in " +
337
+ "BEFORE PUBLISHING ANYTHING, CHECK WHAT IS ALREADY OPEN: call board (it lists what this " +
338
+ "worktree holds) or read the inbox. If this session's work answers something you hold, hand_in " +
339
339
  "that — never publish a second guide about it. A transfer guide is for work that has to " +
340
340
  "cross a boundary: another repo, another machine, a teammate without your branch. Work you " +
341
341
  "committed and pushed where the team can already see it has crossed no boundary, and a " +
@@ -944,12 +944,17 @@ export function buildServer() {
944
944
  "The state of this user's transfers as queues: waiting on you (handed to you, not " +
945
945
  "pulled), not working (someone gave it a failing verdict — the most urgent), in flight " +
946
946
  "(handed over, nobody has taken it — `stale` means it has sat over a week), and landed " +
947
- "(someone else has it). Use it to answer 'what is outstanding?'.",
947
+ "(someone else has it), and `held`: what this worktree's agents hold now. Use it to answer " +
948
+ "'what is outstanding?' and to see what you hold, without taking anything.",
948
949
  inputSchema: {},
949
950
  },
950
951
  async () => {
951
952
  try {
952
- return json(await passalong.board());
953
+ const [board, now] = await Promise.all([
954
+ passalong.board(),
955
+ passalong.now().catch(() => ({ held: [] })),
956
+ ]);
957
+ return json({ ...board, held: now.held });
953
958
  } catch (err) {
954
959
  return fail(err);
955
960
  }
@@ -1100,9 +1105,9 @@ export function buildServer() {
1100
1105
  annotations: ADDS,
1101
1106
  description:
1102
1107
  "Say you are doing it, and get it. With `id`, that guide — any kind: a task, a bug, a " +
1103
- "handoff. With no id, the next thing waiting for this worktree's agent. While you hold it " +
1104
- "no other agent can take it here, and its sender sees you are on it. One thing at a time: " +
1105
- "if you already hold something, finish or pass that first. If somebody else has it, the " +
1108
+ "handoff. With no id, the next thing waiting — always something new, never what you hold. " +
1109
+ "While you hold it no other agent can take it here, and its sender sees you are on it. You " +
1110
+ "may hold several at once; the person watches them on the board. If somebody else has it, the " +
1106
1111
  "answer says who — tell the user rather than doing the work twice. Every answer ends with " +
1107
1112
  "what to call next.",
1108
1113
  inputSchema: {
@@ -1525,8 +1530,8 @@ export function buildServer() {
1525
1530
  title: "Publish guide",
1526
1531
  annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
1527
1532
  description:
1528
- "Publish a guide from markdown (frontmatter + sections). Check what you hold first (take " +
1529
- "with no id): if this session answers something you hold, hand_in that instead, and if the " +
1533
+ "Publish a guide from markdown (frontmatter + sections). Check what you hold first " +
1534
+ "(board): if this session answers something you hold, hand_in that instead, and if the " +
1530
1535
  "work never left a branch the team can see, publish what is still open as a bug or a task " +
1531
1536
  "rather than a write-up of the fix. A transfer guide by default, or " +
1532
1537
  "a single bug with `kind: bug`; use file_bugs for more than one; or a task for work nobody " +
package/src/passalong.js CHANGED
@@ -264,7 +264,7 @@ export async function tasks() {
264
264
  }
265
265
 
266
266
  /**
267
- * Take the next task for this worktree's repo, or get back the one this worktree already holds.
267
+ * Take the next task for this worktree's repo — always a new one, since an agent may hold several.
268
268
  * `null` when there is nothing to do. Written to `.passalong/<id>.md` only when `save` asks.
269
269
  */
270
270
  export async function nextTask({ cwd = process.cwd(), any = false, save = false } = {}) {
@@ -542,7 +542,7 @@ export async function ask(id, question, { cwd = process.cwd() } = {}) {
542
542
 
543
543
  /**
544
544
  * What this worktree's agent holds right now, and what is waiting for it: what the session-start
545
- * and stop hooks read (src/hooks.js). `held` is null when it holds nothing. Counts are best effort
545
+ * and stop hooks read (src/hooks.js). `held` lists everything it holds, empty when nothing. Counts are best effort
546
546
  * — a hook must never fail a session over a count.
547
547
  */
548
548
  export async function now({ cwd = process.cwd() } = {}) {
@@ -557,8 +557,7 @@ export async function now({ cwd = process.cwd() } = {}) {
557
557
  api.handedIn(),
558
558
  ]);
559
559
  if (w.status === "rejected") throw w.reason;
560
- const held =
561
- (who.agent && w.value.working.find((x) => x.agent === who.agent && x.by?.you)) || null;
560
+ const held = who.agent ? w.value.working.filter((x) => x.agent === who.agent && x.by?.you) : [];
562
561
  const tasks = t.status === "fulfilled" ? t.value.tasks : [];
563
562
  const waiting = {
564
563
  ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
@@ -1099,9 +1098,8 @@ export function workPrompt(t) {
1099
1098
  * hand_in land on the claim this took. `agent` is a command the prompt is appended to;
1100
1099
  * `claude -p` by default, or whatever PASSALONG_AGENT says.
1101
1100
  *
1102
- * An agent that exits without finishing stops the loop. Asking for the next task would hand the
1103
- * same one back, since a worktree holds one task at a time, and the loop would run the agent on it
1104
- * forever. The task stays claimed here, for the next run to resume or a person to release.
1101
+ * An agent that exits without finishing stops the loop, rather than piling up half-done work. The
1102
+ * task stays claimed here, for a person to see on the board and resume (take it) or release.
1105
1103
  */
1106
1104
  export async function work({
1107
1105
  cwd = process.cwd(),
@@ -1122,8 +1120,9 @@ export async function work({
1122
1120
  stdio: "inherit",
1123
1121
  env: { ...process.env, PASSALONG_TASK: t.id },
1124
1122
  });
1123
+ // Finishing is final, so a finished task is Done; "review" is a server from before that.
1125
1124
  const state = (await tasks()).find((x) => x.id === t.id)?.state;
1126
- if (state !== "review")
1125
+ if (state !== "done" && state !== "review")
1127
1126
  return {
1128
1127
  finished,
1129
1128
  stopped: "unfinished",
@@ -1182,26 +1181,70 @@ export async function exportAll(dir) {
1182
1181
  });
1183
1182
  }
1184
1183
 
1184
+ // The host this product answered on before passalong.dev. It serves the same data, so a copy
1185
+ // whose url names it belongs to the default API.
1186
+ const OLD_HOST = "passalong.kreativekorna.com";
1187
+ const hostOf = (url) => {
1188
+ try {
1189
+ const { host, hostname } = new URL(url);
1190
+ if (["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return "";
1191
+ return host === OLD_HOST ? new URL(api.DEFAULT_API).host : host;
1192
+ } catch {
1193
+ return "";
1194
+ }
1195
+ };
1196
+ // Raised whenever tidy learns to remove something it used to keep, so machines that tidied run it once more.
1197
+ const TIDIED = 2;
1198
+
1185
1199
  /**
1186
1200
  * Once per machine, signed in: drop the local copies the server says it holds. They were written
1187
- * by every share, pull and take before `keep()`, and nothing ever removed one. What the server
1188
- * does not return — shared while signed out, or from another account — is left where it is.
1201
+ * by every share, pull and take before `keep()`, and nothing ever removed one. Every login on the
1202
+ * machine is asked, since a copy may be any of theirs. A copy whose `url:` is on a login's own host
1203
+ * and that the server answers 404 for is a guide since deleted, and goes too. Anything else — no
1204
+ * url, localhost, another host, or an answer that was neither 200 nor 404 — is left where it is,
1205
+ * and an export that failed leaves the flag unset so the next start tries again.
1189
1206
  */
1190
1207
  export async function tidy() {
1191
- if (!api.loggedIn() || store.readConfig().tidied) return 0;
1192
- const local = new Set(store.list().map((g) => g.meta.id));
1208
+ const logins = api.logins();
1209
+ if (!logins.length || store.readConfig().tidied === TIDIED) return 0;
1210
+ const local = new Map(store.list().map((g) => [g.meta.id, hostOf(g.meta.url)]));
1193
1211
  let removed = 0;
1194
- let after = "";
1195
- while (local.size) {
1196
- const page = await api.exportPage(after);
1197
- for (const g of page.guides)
1198
- if (local.delete(g.id)) {
1199
- store.remove(g.id);
1200
- removed++;
1201
- }
1202
- after = page.next;
1203
- if (!after) break;
1212
+ let sure = true;
1213
+ const drop = (id) => {
1214
+ if (!local.delete(id)) return;
1215
+ store.remove(id);
1216
+ removed++;
1217
+ };
1218
+ for (const as of logins) {
1219
+ try {
1220
+ let after = "";
1221
+ do {
1222
+ const page = await api.exportPage(after, as);
1223
+ for (const g of page.guides) drop(g.id);
1224
+ after = page.next;
1225
+ } while (after && local.size);
1226
+ } catch {
1227
+ sure = false;
1228
+ }
1229
+ }
1230
+ const said = new Map();
1231
+ for (const as of logins) {
1232
+ const host = hostOf(as.api);
1233
+ const ids = [...local].filter(([, h]) => h && h === host).map(([id]) => id);
1234
+ // Eight at a time: a few hundred copies is a few seconds, not a few hundred open requests.
1235
+ for (let i = 0; i < ids.length; i += 8)
1236
+ await Promise.all(
1237
+ ids.slice(i, i + 8).map(async (id) => {
1238
+ const status = await api.get(id, { peek: true, as }).then(
1239
+ () => 200,
1240
+ (err) => err.status ?? 0,
1241
+ );
1242
+ said.set(id, [...(said.get(id) ?? []), status]);
1243
+ }),
1244
+ );
1204
1245
  }
1205
- store.writeConfig({ tidied: true });
1246
+ for (const [id, statuses] of said)
1247
+ if (statuses.includes(200) || statuses.every((s) => s === 404)) drop(id);
1248
+ if (sure) store.writeConfig({ tidied: TIDIED });
1206
1249
  return removed;
1207
1250
  }
package/src/update.js CHANGED
@@ -15,7 +15,31 @@
15
15
  // feature would have shipped dead while looking fine in review. So the line is read from a file and
16
16
  // the file is refreshed by a process nobody waits for. What a person sees is a day old at worst,
17
17
  // which is the right age for "there is a new version".
18
- import { readConfig, writeConfig } from "./store.js";
18
+ //
19
+ // The answer is kept in its own file, never in config.json. The refresh runs beside whatever
20
+ // command started it, and config.json is read, patched and written whole: a refresh that read it
21
+ // just before `logout` or `use` wrote it, and wrote it back after, put the old accounts and the old
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";
25
+ import { HOME } from "./store.js";
26
+
27
+ const FILE = join(HOME, "update.json");
28
+
29
+ function readUpdate() {
30
+ try {
31
+ return JSON.parse(readFileSync(FILE, "utf8"));
32
+ } catch {
33
+ return {};
34
+ }
35
+ }
36
+
37
+ function writeUpdate(held) {
38
+ if (!existsSync(HOME)) mkdirSync(HOME, { recursive: true, mode: 0o700 });
39
+ const tmp = `${FILE}.${process.pid}.tmp`;
40
+ writeFileSync(tmp, `${JSON.stringify(held)}\n`, { mode: 0o600 });
41
+ renameSync(tmp, FILE);
42
+ }
19
43
 
20
44
  /** How long a cached answer lasts before a refresh is worth starting. */
21
45
  export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
@@ -70,7 +94,7 @@ export function cachedNotice({
70
94
  current,
71
95
  now = Date.now(),
72
96
  env = process.env,
73
- config = readConfig,
97
+ config = readUpdate,
74
98
  }) {
75
99
  if (env.PASSALONG_NO_UPDATE_CHECK === "1") return { notice: "", stale: false };
76
100
  const held = config();
@@ -90,7 +114,7 @@ export function cachedNotice({
90
114
  * trying again — `cachedNotice` reads an empty `update_latest` as a failure and uses the shorter
91
115
  * gap.
92
116
  */
93
- export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeConfig } = {}) {
117
+ export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeUpdate } = {}) {
94
118
  let latest = "";
95
119
  try {
96
120
  const res = await fetchImpl(REGISTRY, {