passalong 0.5.0 → 0.8.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 +2 -2
- package/bin/passalong +288 -20
- package/package.json +1 -1
- package/skill/SKILL.md +28 -1
- package/src/api.js +75 -1
- package/src/checks.js +127 -0
- package/src/guide.js +27 -5
- package/src/hooks.js +52 -5
- package/src/mcp.js +256 -135
- package/src/passalong.js +181 -20
- package/src/update.js +116 -0
package/src/passalong.js
CHANGED
|
@@ -287,6 +287,61 @@ export async function taskProgress(id, note, { cwd = process.cwd() } = {}) {
|
|
|
287
287
|
return api.taskProgress(id, note ? { agent: a, note } : { agent: a });
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* What a hand-in has to carry, checked before anything is published.
|
|
292
|
+
*
|
|
293
|
+
* The server refuses the call without it anyway; the reason to check first is that publishing the
|
|
294
|
+
* write-up and then being refused leaves a guide nobody asked for behind every refused hand-in.
|
|
295
|
+
* The rule and the wording are the server's — see evidenceProblem() in apps/api/src/claims.ts.
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* The claim generation this worktree was given when it took a guide, kept in `.passalong/held.json`
|
|
299
|
+
* beside `agent.json`. See apps/api/migrations/0029_claim_fence.sql.
|
|
300
|
+
*
|
|
301
|
+
* The agent never sees this number and is never asked for it — a model echoing an integer back
|
|
302
|
+
* three calls later is not a lock. `take` writes it, and every call that writes to the claim sends
|
|
303
|
+
* it, so a hand-in belonging to a claim that was released and re-taken is refused by the server
|
|
304
|
+
* rather than landing on somebody else's work.
|
|
305
|
+
*/
|
|
306
|
+
function fenceFile(cwd) {
|
|
307
|
+
return join(localDir(agent(cwd).worktree), "held.json");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function heldFences(cwd) {
|
|
311
|
+
try {
|
|
312
|
+
const held = JSON.parse(readFileSync(fenceFile(cwd), "utf8"));
|
|
313
|
+
return held && typeof held === "object" ? held : {};
|
|
314
|
+
} catch {
|
|
315
|
+
return {};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** The number this worktree holds for a guide, or undefined when it has none to send. */
|
|
320
|
+
function fenceFor(id, cwd) {
|
|
321
|
+
const n = heldFences(cwd)[id];
|
|
322
|
+
return Number.isInteger(n) ? n : undefined;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function rememberFence(id, fence, cwd) {
|
|
326
|
+
if (!Number.isInteger(fence)) return;
|
|
327
|
+
const held = heldFences(cwd);
|
|
328
|
+
held[id] = fence;
|
|
329
|
+
writeFileSync(fenceFile(cwd), `${JSON.stringify(held, null, 2)}\n`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** A check a runner executed: the command it ran, and what the process returned. See checks.js. */
|
|
333
|
+
const verified = (c) => Boolean(c?.cmd) && (typeof c?.exit === "number" || c?.exit === null);
|
|
334
|
+
|
|
335
|
+
function requireEvidence(evidence) {
|
|
336
|
+
const said = String(evidence || "").trim();
|
|
337
|
+
if (said.length >= 16) return;
|
|
338
|
+
throw new PassalongError(
|
|
339
|
+
"send `evidence`: what you ran and what came back — the command and the lines that decided " +
|
|
340
|
+
"it, a test summary, a link to the change, or a screenshot url. " +
|
|
341
|
+
`"${said || "nothing"}" is a claim, not evidence.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
290
345
|
/**
|
|
291
346
|
* The work is done: the task moves to review with a transfer guide about it attached.
|
|
292
347
|
*
|
|
@@ -296,9 +351,10 @@ export async function taskProgress(id, note, { cwd = process.cwd() } = {}) {
|
|
|
296
351
|
*/
|
|
297
352
|
export async function finishTask(
|
|
298
353
|
id,
|
|
299
|
-
{ report, markdown, pr = "", note = "", cwd = process.cwd() } = {},
|
|
354
|
+
{ report, markdown, evidence = "", pr = "", note = "", cwd = process.cwd() } = {},
|
|
300
355
|
) {
|
|
301
356
|
needsSync("finishing a task");
|
|
357
|
+
requireEvidence(evidence);
|
|
302
358
|
if (!report && markdown) {
|
|
303
359
|
const g = parse(markdown);
|
|
304
360
|
report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
|
|
@@ -308,7 +364,7 @@ export async function finishTask(
|
|
|
308
364
|
throw new PassalongError(
|
|
309
365
|
"finishing needs the write-up: `markdown` for a transfer guide about the work, or `report` with the id of one",
|
|
310
366
|
);
|
|
311
|
-
return api.finishTask(id, { agent: agent(cwd).agent, report, pr, note });
|
|
367
|
+
return api.finishTask(id, { agent: agent(cwd).agent, report, evidence, pr, note });
|
|
312
368
|
}
|
|
313
369
|
|
|
314
370
|
/**
|
|
@@ -323,6 +379,7 @@ export async function take(id, { cwd = process.cwd(), any = false } = {}) {
|
|
|
323
379
|
const who = agent(cwd);
|
|
324
380
|
const r = await api.take({ ...who, ...(id ? { id: await resolveId(id) } : {}), any });
|
|
325
381
|
if (!r.guide?.markdown) return r;
|
|
382
|
+
rememberFence(r.guide.id, r.guide.fence, cwd);
|
|
326
383
|
const path = join(localDir(who.worktree), `${r.guide.id}.md`);
|
|
327
384
|
writeFileSync(path, r.guide.markdown);
|
|
328
385
|
store.save(parse(r.guide.markdown));
|
|
@@ -332,38 +389,93 @@ export async function take(id, { cwd = process.cwd(), any = false } = {}) {
|
|
|
332
389
|
/** Word from the agent that holds it: the lease starts again, and `note` is what the hub shows. */
|
|
333
390
|
export async function progress(id, note, { cwd = process.cwd() } = {}) {
|
|
334
391
|
needsSync("reporting progress");
|
|
335
|
-
|
|
392
|
+
const fence = fenceFor(id, cwd);
|
|
393
|
+
return api.progress(id, {
|
|
394
|
+
agent: agent(cwd).agent,
|
|
395
|
+
...(note ? { note } : {}),
|
|
396
|
+
...(fence === undefined ? {} : { fence }),
|
|
397
|
+
});
|
|
336
398
|
}
|
|
337
399
|
|
|
338
400
|
/**
|
|
339
401
|
* Done here. A task hands in a write-up — `markdown`, published with `parent:` naming the task, or
|
|
340
402
|
* `report`, the id of one already published — for its author to review against Acceptance. Any
|
|
341
403
|
* other guide hands in whether its Verification held: `ok`, and a `note` when it did not.
|
|
404
|
+
*
|
|
405
|
+
* Every hand-in carries `evidence`: what was run and what came back. The write-up and the `ok` are
|
|
406
|
+
* both the agent's word for its own work; evidence is the part the reviewer can check.
|
|
342
407
|
*/
|
|
343
408
|
export async function handIn(
|
|
344
409
|
id,
|
|
345
|
-
{
|
|
410
|
+
{
|
|
411
|
+
ok,
|
|
412
|
+
note = "",
|
|
413
|
+
evidence = "",
|
|
414
|
+
checks = [],
|
|
415
|
+
markdown,
|
|
416
|
+
report,
|
|
417
|
+
pr = "",
|
|
418
|
+
cwd = process.cwd(),
|
|
419
|
+
} = {},
|
|
346
420
|
) {
|
|
347
421
|
needsSync("handing work in");
|
|
422
|
+
// Checks are evidence, sorted against the lines they answer: bringing them is bringing it. A
|
|
423
|
+
// check whose command was actually run is exempt from the length rule — its exit code is the
|
|
424
|
+
// evidence, and `test -f dist/app.js` exiting 0 says more than any sentence about it would.
|
|
425
|
+
if (checks.length) checks.forEach((c) => (verified(c) ? null : requireEvidence(c?.ran)));
|
|
426
|
+
else requireEvidence(evidence);
|
|
348
427
|
if (!report && markdown) {
|
|
349
428
|
const g = parse(markdown);
|
|
350
429
|
report = (await share(serialize({ meta: { ...g.meta, parent: id }, body: g.body }), { cwd }))
|
|
351
430
|
.guide.meta.id;
|
|
352
431
|
}
|
|
432
|
+
const fence = fenceFor(id, cwd);
|
|
353
433
|
return api.handIn(id, {
|
|
354
434
|
agent: agent(cwd).agent,
|
|
355
435
|
note,
|
|
436
|
+
evidence,
|
|
437
|
+
...(fence === undefined ? {} : { fence }),
|
|
438
|
+
...(checks.length
|
|
439
|
+
? {
|
|
440
|
+
checks: checks.map((c) => ({
|
|
441
|
+
check: c.check,
|
|
442
|
+
ran: c.ran,
|
|
443
|
+
// Only present on a check that was run. The server keeps them apart the same way.
|
|
444
|
+
...(verified(c) ? { cmd: c.cmd, exit: c.exit, ok: c.ok } : {}),
|
|
445
|
+
})),
|
|
446
|
+
}
|
|
447
|
+
: {}),
|
|
356
448
|
...(report ? { report, pr } : {}),
|
|
357
449
|
...(typeof ok === "boolean" ? { ok } : {}),
|
|
358
450
|
});
|
|
359
451
|
}
|
|
360
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Give a guide or task you wrote to someone else in its team: `@handle` for one person, `#group`
|
|
455
|
+
* for the people who do a thing, or "team" (or empty) for everyone. Whoever held it and is left out
|
|
456
|
+
* has it taken back and is told; a task then only goes to the new assignee's agents. The local copy
|
|
457
|
+
* is dropped so the next read shows the new `to:`.
|
|
458
|
+
*/
|
|
459
|
+
export async function assign(ref, to) {
|
|
460
|
+
needsSync("reassigning");
|
|
461
|
+
const id = await resolveId(ref);
|
|
462
|
+
const target = !to || to === "team" || to === "everyone" ? "" : String(to).trim();
|
|
463
|
+
const r = await api.assign(id, target);
|
|
464
|
+
store.remove(id);
|
|
465
|
+
return r;
|
|
466
|
+
}
|
|
467
|
+
|
|
361
468
|
/** Not this agent's to do: what it held is open again, and `why` goes to whoever is next. */
|
|
362
469
|
export async function pass(id, why, { cwd = process.cwd() } = {}) {
|
|
363
470
|
needsSync("passing work");
|
|
364
471
|
if (!String(why || "").trim())
|
|
365
472
|
throw new PassalongError("say why you are passing it, so whoever is next knows");
|
|
366
|
-
|
|
473
|
+
const fence = fenceFor(id, cwd);
|
|
474
|
+
return api.pass(id, {
|
|
475
|
+
agent: agent(cwd).agent,
|
|
476
|
+
why,
|
|
477
|
+
...(fence === undefined ? {} : { fence }),
|
|
478
|
+
});
|
|
367
479
|
}
|
|
368
480
|
|
|
369
481
|
/**
|
|
@@ -376,18 +488,31 @@ export async function now({ cwd = process.cwd() } = {}) {
|
|
|
376
488
|
// Read, never minted: the hooks run in every repo a session opens, and a worktree that has never
|
|
377
489
|
// taken anything has no agent — and should not be given a .passalong/ folder for asking.
|
|
378
490
|
const who = knownAgent(cwd);
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
491
|
+
const [w, t, i, h] = await Promise.allSettled([
|
|
492
|
+
api.working(),
|
|
493
|
+
api.tasks(),
|
|
494
|
+
api.inbox(),
|
|
495
|
+
api.handedIn(),
|
|
496
|
+
]);
|
|
497
|
+
if (w.status === "rejected") throw w.reason;
|
|
498
|
+
const held =
|
|
499
|
+
(who.agent && w.value.working.find((x) => x.agent === who.agent && x.by?.you)) || null;
|
|
500
|
+
const tasks = t.status === "fulfilled" ? t.value.tasks : [];
|
|
501
|
+
const waiting = {
|
|
502
|
+
ready: tasks.filter((x) => x.state === "ready" && x.target === who.repo).length,
|
|
503
|
+
inbox: i.status === "fulfilled" ? i.value.guides.length : 0,
|
|
504
|
+
};
|
|
505
|
+
// Everything waiting on the person, the way the hub's Needs you counts it: their tasks handed in
|
|
506
|
+
// or stuck, handoffs of theirs handed in, and guides handed to them.
|
|
507
|
+
const review = tasks.filter(
|
|
508
|
+
(x) =>
|
|
509
|
+
x.mine &&
|
|
510
|
+
(x.state === "review" ||
|
|
511
|
+
x.state === "stalled" ||
|
|
512
|
+
(x.state === "claimed" && /^BLOCKED:/i.test(x.claim?.note || ""))),
|
|
513
|
+
).length;
|
|
514
|
+
const handed = h.status === "fulfilled" ? h.value.handed_in.length : 0;
|
|
515
|
+
return { held, waiting, needs: review + handed + waiting.inbox, agent: who.agent };
|
|
391
516
|
}
|
|
392
517
|
|
|
393
518
|
/**
|
|
@@ -592,6 +717,39 @@ export async function followUps(meta = {}, deps = {}) {
|
|
|
592
717
|
].join("\n\n");
|
|
593
718
|
}
|
|
594
719
|
|
|
720
|
+
/**
|
|
721
|
+
* The guide a follow-up came out of, formatted to go in front of it — or "" when there is none.
|
|
722
|
+
*
|
|
723
|
+
* Follow-ups travelled one way: opening a guide handed over what was written under it, and opening
|
|
724
|
+
* one of those handed over nothing. An agent given a follow-up on its own reads a document that
|
|
725
|
+
* assumes a piece of work it has never seen, and either goes looking for it or treats the note as
|
|
726
|
+
* the whole job. Where the parent has got to is part of the context, not a separate question.
|
|
727
|
+
*
|
|
728
|
+
* The same shape the hosted server's parentOf() returns. Injectable for testing, and never worth
|
|
729
|
+
* failing the call over: the guide itself is what was asked for.
|
|
730
|
+
*/
|
|
731
|
+
export async function parentGuide(
|
|
732
|
+
meta = {},
|
|
733
|
+
{ parent = api.parent, loggedIn = api.loggedIn } = {},
|
|
734
|
+
) {
|
|
735
|
+
if (!meta.id || !meta.parent || !loggedIn()) return "";
|
|
736
|
+
try {
|
|
737
|
+
const { guide } = await parent(meta.id, { markdown: true });
|
|
738
|
+
if (!guide?.id) return "";
|
|
739
|
+
const who = guide.by?.name || (guide.by?.handle ? `@${guide.by.handle}` : "");
|
|
740
|
+
const state = guide.state === "held" && who ? `held by ${who}` : guide.state || "open";
|
|
741
|
+
return (
|
|
742
|
+
`THIS IS A FOLLOW-UP TO ${guide.id}: ${guide.title || "untitled"} — ${state}. It is more ` +
|
|
743
|
+
"context for that guide, not a piece of work on its own. Read the guide it follows first; " +
|
|
744
|
+
"it is below, and where the two disagree this follow-up is newer. If what it asks for " +
|
|
745
|
+
`depends on ${guide.id} being done and it is not, say so rather than starting.\n\n` +
|
|
746
|
+
`--- the guide it follows: ${guide.id} ---\n${String(guide.markdown ?? "").trimEnd()}\n\n`
|
|
747
|
+
);
|
|
748
|
+
} catch {
|
|
749
|
+
return "";
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
595
753
|
/** Local guides merged with synced ones (by id), newest first, optionally filtered. */
|
|
596
754
|
export async function list(query = "", { remote = true, scope = "" } = {}) {
|
|
597
755
|
const local = scope && scope !== "all" && scope !== "mine" ? [] : store.search(query);
|
|
@@ -772,9 +930,12 @@ export function workPrompt(t) {
|
|
|
772
930
|
"When every Acceptance check holds, commit what you changed as one commit whose message",
|
|
773
931
|
`starts with "${t.id}: " — several tasks share this worktree, and a reviewer reads each one's`,
|
|
774
932
|
"change on its own. Then call hand_in with id",
|
|
775
|
-
`${t.id}, \`pr\` set to that commit's hash,
|
|
776
|
-
"did, what you decided and why, and how you checked each Acceptance line
|
|
777
|
-
"
|
|
933
|
+
`${t.id}, \`pr\` set to that commit's hash, \`markdown\`: a transfer guide saying what you`,
|
|
934
|
+
"did, what you decided and why, and how you checked each Acceptance line, and `evidence`:",
|
|
935
|
+
"what you ran and what came back — the commands and the lines that decided it, the test",
|
|
936
|
+
"summary, the commit. Keep that as you go; at the end you would be writing it from memory,",
|
|
937
|
+
"and it is the one part of the hand-in a reviewer can check. If either call says you no",
|
|
938
|
+
"longer hold the task, stop.",
|
|
778
939
|
"",
|
|
779
940
|
"Nobody reads what you print: this session runs unattended. If something only a person can",
|
|
780
941
|
"do stands between you and an Acceptance line — a permission, a secret, a decision — call",
|
package/src/update.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Telling somebody their CLI is old, without ever making them wait to be told.
|
|
2
|
+
//
|
|
3
|
+
// The server can ship a new version of itself in an afternoon; an installed CLI changes only when
|
|
4
|
+
// a person types `npm i -g passalong`. Nothing told them to, so a machine could sit on a build from
|
|
5
|
+
// months ago and simply not have the tools everyone was talking about — and because the API keeps
|
|
6
|
+
// answering old clients on purpose (an absent `fence` is accepted, an absent `cmd` is accepted),
|
|
7
|
+
// nothing broke loudly enough to notice either.
|
|
8
|
+
//
|
|
9
|
+
// The registry is asked, not passalong.dev. What "the latest passalong" is, is a fact about npm,
|
|
10
|
+
// and a server that answered it would be a second copy of that fact to keep in step.
|
|
11
|
+
//
|
|
12
|
+
// **Nothing here waits on the network.** The first attempt at this awaited the fetch after the
|
|
13
|
+
// command finished, on a 1.5s timeout, and measuring it killed the design: the registry answered in
|
|
14
|
+
// six to ten seconds from an ordinary connection, so every check timed out, cached nothing, and the
|
|
15
|
+
// feature would have shipped dead while looking fine in review. So the line is read from a file and
|
|
16
|
+
// the file is refreshed by a process nobody waits for. What a person sees is a day old at worst,
|
|
17
|
+
// which is the right age for "there is a new version".
|
|
18
|
+
import { readConfig, writeConfig } from "./store.js";
|
|
19
|
+
|
|
20
|
+
/** How long a cached answer lasts before a refresh is worth starting. */
|
|
21
|
+
export const CHECK_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* How long after a failed check before trying again. A refresh that could not reach the registry
|
|
25
|
+
* must not blind the check for a whole day — that is one bad minute costing a day of notices — and
|
|
26
|
+
* must not retry on every command either, which is what the whole-day gap is there to prevent.
|
|
27
|
+
*/
|
|
28
|
+
export const RETRY_AFTER_MS = 60 * 60 * 1000;
|
|
29
|
+
|
|
30
|
+
/** Nobody is waiting, so this can be patient. It is generous because the registry is slow. */
|
|
31
|
+
const TIMEOUT_MS = 15_000;
|
|
32
|
+
|
|
33
|
+
const REGISTRY = "https://registry.npmjs.org/passalong/latest";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Is `latest` past `current`? Compares the numeric parts and nothing else.
|
|
37
|
+
*
|
|
38
|
+
* A prerelease (`1.2.0-rc.1`) is read as its release: it only matters that the numbers moved, and
|
|
39
|
+
* nobody is being told to install a tag that `latest` does not point at anyway.
|
|
40
|
+
*/
|
|
41
|
+
export function isNewer(latest, current) {
|
|
42
|
+
const parts = (v) =>
|
|
43
|
+
String(v ?? "")
|
|
44
|
+
.split("-")[0]
|
|
45
|
+
.split(".")
|
|
46
|
+
.map((n) => Number.parseInt(n, 10));
|
|
47
|
+
const a = parts(latest);
|
|
48
|
+
const b = parts(current);
|
|
49
|
+
if (a.length !== 3 || b.length !== 3 || [...a, ...b].some(Number.isNaN)) return false;
|
|
50
|
+
for (let i = 0; i < 3; i++) {
|
|
51
|
+
if (a[i] > b[i]) return true;
|
|
52
|
+
if (a[i] < b[i]) return false;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The one line somebody sees, or "" when there is nothing to say. */
|
|
58
|
+
export function noticeFor(latest, current) {
|
|
59
|
+
if (!isNewer(latest, current)) return "";
|
|
60
|
+
return `passalong ${latest} is out (you have ${current}) — npm i -g passalong`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* What to print and whether a refresh is due, from the file alone. No network, no waiting.
|
|
65
|
+
*
|
|
66
|
+
* `PASSALONG_NO_UPDATE_CHECK=1` turns the whole thing off, for a machine that should not be
|
|
67
|
+
* talking to the registry at all — an air-gapped box, or a CI runner where the line is noise.
|
|
68
|
+
*/
|
|
69
|
+
export function cachedNotice({
|
|
70
|
+
current,
|
|
71
|
+
now = Date.now(),
|
|
72
|
+
env = process.env,
|
|
73
|
+
config = readConfig,
|
|
74
|
+
}) {
|
|
75
|
+
if (env.PASSALONG_NO_UPDATE_CHECK === "1") return { notice: "", stale: false };
|
|
76
|
+
const held = config();
|
|
77
|
+
const checked = Number(held.update_checked) || 0;
|
|
78
|
+
const failed = !held.update_latest;
|
|
79
|
+
const wait = failed ? RETRY_AFTER_MS : CHECK_EVERY_MS;
|
|
80
|
+
return {
|
|
81
|
+
notice: noticeFor(held.update_latest || "", current),
|
|
82
|
+
stale: now - checked >= wait,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Ask the registry and write the answer down. Run by a process nobody is waiting for.
|
|
88
|
+
*
|
|
89
|
+
* A failure still records the attempt, so the retry gap applies to it rather than every command
|
|
90
|
+
* trying again — `cachedNotice` reads an empty `update_latest` as a failure and uses the shorter
|
|
91
|
+
* gap.
|
|
92
|
+
*/
|
|
93
|
+
export async function refresh({ now = Date.now(), fetchImpl = fetch, save = writeConfig } = {}) {
|
|
94
|
+
let latest = "";
|
|
95
|
+
try {
|
|
96
|
+
const res = await fetchImpl(REGISTRY, {
|
|
97
|
+
// Plain JSON. The abbreviated `application/vnd.npm.install-v1+json` type is for the whole
|
|
98
|
+
// packument at `/passalong`; on `/passalong/latest` the registry answers 406, which is how
|
|
99
|
+
// the first version of this managed to fail every single time.
|
|
100
|
+
headers: { accept: "application/json" },
|
|
101
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
102
|
+
});
|
|
103
|
+
if (res.ok) {
|
|
104
|
+
const body = await res.json();
|
|
105
|
+
if (typeof body?.version === "string") latest = body.version;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
// Offline, blocked, rate-limited, nonsense JSON: the attempt is recorded and nothing is said.
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
save({ update_checked: now, update_latest: latest });
|
|
112
|
+
} catch {
|
|
113
|
+
// A config that cannot be written means the next command tries again, which is harmless.
|
|
114
|
+
}
|
|
115
|
+
return latest;
|
|
116
|
+
}
|