magus-cli 7.11.6 → 7.11.8

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.
@@ -37,10 +37,11 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
37
37
  *
38
38
  * This is the ONLY refresh rhythm magus has, and it applies to both surfaces:
39
39
  * `magus update` and the TUI read a catalog written here within the hour
40
- * before they go to the network. `update` pulls the marketplace clones and
41
- * drops its in-process memo before planning, but it does NOT clear this file,
42
- * so a release published inside the TTL is not seen by `update` either. Only
43
- * an explicit refresh (`r` in the TUI) clears it. Anything that reports "no
40
+ * before they go to the network. `update` pulls the marketplace clones (each
41
+ * at most once per TTL, machine-wide — see `cloneRefreshes`) and drops its
42
+ * in-process memo before planning, but it does NOT clear this file, so a
43
+ * release published inside the TTL is not seen by `update` either. Only an
44
+ * explicit refresh (`r` in the TUI) clears it, clone refresh times included. Anything that reports "no
44
45
  * update" — or decides a plugin is no longer published — off a cached catalog
45
46
  * is working from an answer up to an hour old, by design.
46
47
  */
@@ -93,14 +94,24 @@ interface StoreShape {
93
94
  catalogs: Record<string, StoredCatalog>;
94
95
  cooldowns: Record<string, StoredCooldown>;
95
96
  communityStyles: Record<string, StoredCommunityCheck>;
97
+ /**
98
+ * Per marketplace: wall-clock ms of the last SUCCESSFUL fast-forward of its
99
+ * on-disk clone. Machine-wide, so one project's `magus update` spares every
100
+ * other project's the same pull within CATALOG_TTL_MS.
101
+ */
102
+ cloneRefreshes: Record<string, number>;
96
103
  }
97
104
 
98
- const EMPTY: StoreShape = {
99
- version: 1,
100
- catalogs: {},
101
- cooldowns: {},
102
- communityStyles: {},
103
- };
105
+ /** A fresh empty store. A function, so no caller can share and mutate one object. */
106
+ function emptyStore(): StoreShape {
107
+ return {
108
+ version: 1,
109
+ catalogs: {},
110
+ cooldowns: {},
111
+ communityStyles: {},
112
+ cloneRefreshes: {},
113
+ };
114
+ }
104
115
 
105
116
  /**
106
117
  * Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
@@ -124,29 +135,24 @@ let memo: { path: string; data: StoreShape } | null = null;
124
135
 
125
136
  async function load(): Promise<StoreShape> {
126
137
  const file = storePath();
127
- if (!file)
128
- return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
138
+ if (!file) return emptyStore();
129
139
  if (memo?.path === file) return memo.data;
130
140
  try {
131
141
  const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
132
142
  // A cache from a future/unknown layout is discarded rather than migrated:
133
143
  // it is regenerable by definition, so guessing at it buys nothing.
134
- const data = parsed?.version === 1 ? parsed : { ...EMPTY };
144
+ const data = parsed?.version === 1 ? parsed : emptyStore();
135
145
  data.catalogs ??= {};
136
146
  data.cooldowns ??= {};
137
- // Absent in every file written before community styles existed, so this
147
+ // Absent in every file written before these sections existed, so the
138
148
  // default is the migration — an upgrade must not read as a corrupt cache.
139
149
  data.communityStyles ??= {};
150
+ data.cloneRefreshes ??= {};
140
151
  memo = { path: file, data };
141
152
  return data;
142
153
  } catch {
143
154
  // Absent or corrupt. A cache must never be a failure mode — start empty.
144
- const data: StoreShape = {
145
- ...EMPTY,
146
- catalogs: {},
147
- cooldowns: {},
148
- communityStyles: {},
149
- };
155
+ const data = emptyStore();
150
156
  memo = { path: file, data };
151
157
  return data;
152
158
  }
@@ -179,15 +185,11 @@ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
179
185
  catalogs: parsed.catalogs ?? {},
180
186
  cooldowns: parsed.cooldowns ?? {},
181
187
  communityStyles: parsed.communityStyles ?? {},
188
+ cloneRefreshes: parsed.cloneRefreshes ?? {},
182
189
  }
183
- : { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
190
+ : emptyStore();
184
191
  } catch {
185
- onDisk = {
186
- version: 1,
187
- catalogs: {},
188
- cooldowns: {},
189
- communityStyles: {},
190
- };
192
+ onDisk = emptyStore();
191
193
  }
192
194
  fn(onDisk);
193
195
  // Temp + rename, so a crash mid-write cannot leave a half-written file
@@ -312,6 +314,44 @@ export async function clearCommunityChecks(): Promise<void> {
312
314
  });
313
315
  }
314
316
 
317
+ // ─── Marketplace clone refreshes ─────────────────────────────────────────────
318
+
319
+ /**
320
+ * Milliseconds since this marketplace's clone was last fast-forwarded
321
+ * successfully, or null when that was never, longer ago than CATALOG_TTL_MS, or
322
+ * in the future (a clock that moved back must not freeze the clone).
323
+ *
324
+ * Same TTL as the catalogs, on purpose: the clone and the catalog answer the
325
+ * same question, so they go stale on one rhythm and `r` clears both.
326
+ */
327
+ export async function readCloneRefreshAge(
328
+ marketplace: string,
329
+ now: number = Date.now(),
330
+ ): Promise<number | null> {
331
+ const at = (await load()).cloneRefreshes[marketplace];
332
+ if (typeof at !== "number") return null;
333
+ const ageMs = now - at;
334
+ if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
335
+ return ageMs;
336
+ }
337
+
338
+ /** Record a successful fast-forward. Never called for a failed or skipped pull. */
339
+ export async function writeCloneRefreshed(
340
+ marketplace: string,
341
+ now: number = Date.now(),
342
+ ): Promise<void> {
343
+ await mutate((data) => {
344
+ data.cloneRefreshes[marketplace] = now;
345
+ });
346
+ }
347
+
348
+ /** Drop every clone refresh time. Backs the explicit refresh (`r`). */
349
+ export async function clearCloneRefreshes(): Promise<void> {
350
+ await mutate((data) => {
351
+ data.cloneRefreshes = {};
352
+ });
353
+ }
354
+
315
355
  /** Test seam: forget this process's memo so the next read hits disk. */
316
356
  export function resetCatalogCacheMemo(): void {
317
357
  memo = null;
@@ -27,10 +27,10 @@
27
27
  * working precisely when the HTTP catalog cannot.
28
28
  */
29
29
 
30
- import { spawn } from "node:child_process";
31
30
  import fs from "node:fs";
32
31
  import path from "node:path";
33
32
  import { claudeConfigDirOrNull } from "../utils/config-dir.js";
33
+ import { runBounded } from "../utils/spawn-bounded.js";
34
34
  import {
35
35
  type MarketplacePlugin,
36
36
  normalizeCatalogJson,
@@ -55,36 +55,27 @@ function cloneDir(name: string): string | null {
55
55
  return path.join(configDir, "plugins", "marketplaces", name);
56
56
  }
57
57
 
58
- function git(
58
+ async function git(
59
59
  cwd: string,
60
60
  args: string[],
61
61
  timeoutMs: number,
62
62
  ): Promise<{ ok: boolean; out: string }> {
63
- return new Promise((resolve) => {
64
- const child = spawn("git", args, {
65
- cwd,
66
- stdio: ["ignore", "pipe", "ignore"],
67
- timeout: timeoutMs,
68
- killSignal: "SIGKILL",
69
- env: {
70
- ...process.env,
71
- // Never let git stop for credentials: this runs inside a TUI with no
72
- // way to answer a prompt, and a private marketplace we cannot read
73
- // should degrade to "unknown", not hang the screen forever.
74
- GIT_TERMINAL_PROMPT: "0",
75
- GIT_ASKPASS: "echo",
76
- SSH_ASKPASS: "echo",
77
- },
78
- });
79
- let out = "";
80
- child.stdout.on("data", (d: Buffer | string) => {
81
- out += String(d);
82
- });
83
- child.on("error", () => resolve({ ok: false, out: "" }));
84
- child.on("close", (code: number | null) =>
85
- resolve({ ok: code === 0, out }),
86
- );
63
+ const r = await runBounded("git", args, {
64
+ cwd,
65
+ stdio: ["ignore", "pipe", "ignore"],
66
+ timeoutMs,
67
+ env: {
68
+ ...process.env,
69
+ // Never let git stop for credentials: this runs inside a TUI with no
70
+ // way to answer a prompt, and a private marketplace we cannot read
71
+ // should degrade to "unknown", not hang the screen forever.
72
+ GIT_TERMINAL_PROMPT: "0",
73
+ GIT_ASKPASS: "echo",
74
+ SSH_ASKPASS: "echo",
75
+ },
87
76
  });
77
+ if (r.spawnError) return { ok: false, out: "" };
78
+ return { ok: r.code === 0, out: r.stdout };
88
79
  }
89
80
 
90
81
  /**
@@ -10,6 +10,7 @@
10
10
  import { isValidGitHubRepo } from "../utils/string-utils.js";
11
11
  import {
12
12
  clearCachedCatalogs,
13
+ clearCloneRefreshes,
13
14
  readCachedCatalog,
14
15
  writeCachedCatalog,
15
16
  } from "./catalog-cache-store.js";
@@ -145,6 +146,9 @@ export function clearMarketplaceCache(): void {
145
146
  */
146
147
  export async function clearPersistedCatalogs(): Promise<void> {
147
148
  await clearCachedCatalogs();
149
+ // The clone refresh times too: an explicit refresh means "ask again now", so
150
+ // the next `magus update` pulls every needed clone instead of trusting the hour.
151
+ await clearCloneRefreshes();
148
152
  }
149
153
 
150
154
  /**
@@ -17,71 +17,114 @@
17
17
  * A failed `git pull --ff-only` leaves the clone exactly as it was: stale, but intact.
18
18
  */
19
19
 
20
- import { spawn } from "node:child_process";
21
20
  import fs from "node:fs";
22
21
  import path from "node:path";
22
+ import { runBounded } from "../utils/spawn-bounded.js";
23
+ import { parsePluginId } from "../utils/string-utils.js";
24
+ import {
25
+ readCloneRefreshAge,
26
+ writeCloneRefreshed,
27
+ } from "./catalog-cache-store.js";
23
28
  import {
24
29
  getConfiguredMarketplaces,
25
30
  getGlobalClaudeDir,
31
+ getGlobalEnabledPlugins,
26
32
  getMarketplaceAutoUpdate,
27
33
  } from "./claude-settings.js";
28
34
 
29
35
  /** Per-git-invocation timeout. A hung pull must never block launching Claude. */
30
36
  const GIT_TIMEOUT_MS = 30_000;
31
37
 
38
+ /** Hard cap per clone, whatever hangs: `git status` then `git pull`, each GIT_TIMEOUT_MS, plus slack. */
39
+ const CLONE_DEADLINE_MS = 2 * GIT_TIMEOUT_MS + 5_000;
40
+
32
41
  export interface RefreshResult {
33
42
  /** Clones fast-forwarded to a newer commit (or already current). */
34
43
  refreshed: string[];
35
44
  /** Pull errored (network, diverged, timeout) — clone left intact and stale. */
36
45
  failed: string[];
37
- /** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
46
+ /**
47
+ * Not pulled because this machine already refreshed the clone successfully
48
+ * within CATALOG_TTL_MS — by this project's run or any other.
49
+ */
50
+ fresh: string[];
51
+ /**
52
+ * Report-only: selected, and a writing run would pull it. Always empty when
53
+ * the refresh actually ran.
54
+ */
55
+ pending: string[];
56
+ /**
57
+ * Not pulled, and not a failure: no installed plugin comes from it, not a
58
+ * github source, autoUpdate off, no clone on disk, or dirty tree.
59
+ */
38
60
  skipped: string[];
39
61
  }
40
62
 
63
+ export interface RefreshOptions {
64
+ /**
65
+ * Plugin ids (`name@marketplace`) this run installs — the profile's
66
+ * PROJECT/LOCAL set. Unioned with every plugin installed at USER scope;
67
+ * a marketplace none of them comes from is not pulled.
68
+ */
69
+ pluginIds?: Iterable<string>;
70
+ /** Clock seam for the once-an-hour rule. */
71
+ now?: number;
72
+ }
73
+
74
+ /**
75
+ * The marketplaces some installed plugin comes from: the ones passed in
76
+ * (PROJECT/LOCAL, from the profile) plus every plugin turned on at USER scope.
77
+ * A USER entry set to `false` is not installed and does not count.
78
+ */
79
+ async function neededMarketplaces(
80
+ pluginIds: Iterable<string>,
81
+ ): Promise<Set<string>> {
82
+ const ids = [...pluginIds];
83
+ for (const [id, on] of Object.entries(await getGlobalEnabledPlugins())) {
84
+ if (on === true) ids.push(id);
85
+ }
86
+ const needed = new Set<string>();
87
+ for (const id of ids) {
88
+ const parsed = parsePluginId(id);
89
+ if (parsed) needed.add(parsed.marketplace);
90
+ }
91
+ return needed;
92
+ }
93
+
41
94
  function cloneDir(name: string): string {
42
95
  return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
43
96
  }
44
97
 
45
- /** Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`. */
46
- function runGit(
98
+ /**
99
+ * Run git in `cwd`, resolving stdout on exit 0. Killed after `timeoutMs`, or as
100
+ * soon as `signal` aborts (an already-aborted signal runs nothing). Throws on
101
+ * anything but exit 0.
102
+ */
103
+ async function runGit(
47
104
  cwd: string,
48
105
  args: string[],
49
106
  timeoutMs: number,
107
+ signal?: AbortSignal,
50
108
  ): Promise<string> {
51
- return new Promise((resolve, reject) => {
52
- const proc = spawn("git", args, {
53
- cwd,
54
- timeout: timeoutMs,
55
- killSignal: "SIGKILL",
56
- });
57
- let stdout = "";
58
- let stderr = "";
59
- proc.stdout?.on("data", (b) => {
60
- stdout += b.toString();
61
- });
62
- proc.stderr?.on("data", (b) => {
63
- stderr += b.toString();
64
- });
65
- proc.on("error", reject);
66
- proc.on("close", (code, signal) => {
67
- if (code === 0) {
68
- resolve(stdout);
69
- } else {
70
- reject(
71
- new Error(
72
- `git ${args.join(" ")} ${
73
- signal ? `killed (${signal})` : `exited ${code}`
74
- }: ${stderr.trim()}`,
75
- ),
76
- );
77
- }
78
- });
79
- });
109
+ const r = await runBounded("git", args, { cwd, timeoutMs, signal });
110
+ if (r.spawnError) throw r.spawnError;
111
+ if (r.code === 0 && !r.aborted) return r.stdout;
112
+ const how = r.aborted
113
+ ? "aborted"
114
+ : r.signal
115
+ ? `killed (${r.signal})`
116
+ : `exited ${r.code}`;
117
+ throw new Error(`git ${args.join(" ")} ${how}: ${r.stderr.trim()}`);
80
118
  }
81
119
 
82
120
  /** True if the clone has uncommitted changes we must not clobber. */
83
- async function isDirty(dir: string): Promise<boolean> {
84
- const out = await runGit(dir, ["status", "--porcelain"], GIT_TIMEOUT_MS);
121
+ async function isDirty(dir: string, signal?: AbortSignal): Promise<boolean> {
122
+ const out = await runGit(
123
+ dir,
124
+ ["status", "--porcelain"],
125
+ GIT_TIMEOUT_MS,
126
+ signal,
127
+ );
85
128
  return out.trim().length > 0;
86
129
  }
87
130
 
@@ -99,23 +142,50 @@ export type CloneRefresh = "absent" | "skipped" | "refreshed" | "failed";
99
142
  * deletes: the worst case is `failed` (stale clone left intact). Shared by the
100
143
  * batch refresh below and by claude-cli's "plugin not found" recovery, so both
101
144
  * paths get the same safe semantics instead of the old `marketplace update`.
145
+ *
146
+ * Bounded by `deadlineMs`: when it passes, the result is `failed` and the git
147
+ * command then in flight (`status` or `pull`) is SIGKILLed; no further git
148
+ * command starts. Only that direct child is killed: a `git fetch` or `ssh` it
149
+ * spawned may linger until it exits on its own, holding nothing we wait on.
150
+ * The clone directory is never deleted, but a pull killed while it updates the
151
+ * working tree can leave that update part-way and a `.git/index.lock` behind;
152
+ * later pulls then return `failed` until the lock is removed. The deadline sits
153
+ * well above a healthy pull, so that is the price of never hanging, not a
154
+ * normal outcome.
102
155
  */
103
156
  export async function fastForwardCloneIfPresent(
104
157
  name: string,
158
+ deadlineMs: number = CLONE_DEADLINE_MS,
105
159
  ): Promise<CloneRefresh> {
106
160
  const dir = cloneDir(name);
107
161
  // No clone on disk (claude never installed it) or not a git repo.
108
162
  if (!fs.existsSync(path.join(dir, ".git"))) return "absent";
163
+ const abort = new AbortController();
164
+ const work = (async (): Promise<CloneRefresh> => {
165
+ try {
166
+ // Never clobber a user's local edits to the clone.
167
+ if (await isDirty(dir, abort.signal)) return "skipped";
168
+ // --ff-only is the safety guarantee: it fetches and fast-forwards, and
169
+ // aborts without mutating the tree if it can't. It never deletes the dir,
170
+ // so a failure degrades to "stale clone", never "deleted marketplace".
171
+ await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS, abort.signal);
172
+ return "refreshed";
173
+ } catch {
174
+ return "failed";
175
+ }
176
+ })();
177
+ let timer: ReturnType<typeof setTimeout> | undefined;
178
+ const deadline = new Promise<CloneRefresh>((resolve) => {
179
+ timer = setTimeout(() => {
180
+ // Kill the in-flight git child rather than leave it running.
181
+ abort.abort();
182
+ resolve("failed");
183
+ }, deadlineMs);
184
+ });
109
185
  try {
110
- // Never clobber a user's local edits to the clone.
111
- if (await isDirty(dir)) return "skipped";
112
- // --ff-only is the safety guarantee: it fetches and fast-forwards, and
113
- // aborts without mutating the tree if it can't. It never deletes the dir,
114
- // so a failure degrades to "stale clone", never "deleted marketplace".
115
- await runGit(dir, ["pull", "--ff-only"], GIT_TIMEOUT_MS);
116
- return "refreshed";
117
- } catch {
118
- return "failed";
186
+ return await Promise.race([work, deadline]);
187
+ } finally {
188
+ clearTimeout(timer);
119
189
  }
120
190
  }
121
191
 
@@ -150,46 +220,62 @@ function tap(fn: (() => void) | undefined): void {
150
220
  }
151
221
  }
152
222
 
153
- /**
154
- * Non-destructively fast-forward the on-disk clone of every registered,
155
- * auto-update-enabled, github-sourced marketplace.
156
- *
157
- * @param skip marketplaces already refreshed this run (e.g. re-registered ones),
158
- * so they aren't pulled twice.
159
- * @param progress optional taps for a live progress display.
160
- * @returns which marketplaces were refreshed, which failed (clone left intact),
161
- * and which were skipped. Failures and skips are collected rather than thrown —
162
- * one unreachable marketplace must not block launching Claude. Pulls run
163
- * concurrently, so a slow one doesn't serialize the rest onto the launch path.
164
- */
165
223
  /**
166
224
  * Select and report, writing nothing.
167
225
  *
168
- * Everything above the pull loop is a read — the marketplace registry and each
169
- * entry's `autoUpdate` flag — so a read-only caller can have the full selection
170
- * verdict for free. `--dry-run` and `--check` promise to write nothing and
171
- * therefore cannot pull, but they must still be able to say "your magus clone
172
- * has autoUpdate off, so its catalog will never advance". Gating the whole call
173
- * off silently dropped that warning, which is the same "no signal reads as no
174
- * problem" failure the flag exists to prevent.
226
+ * Everything above the pull loop is a read — the marketplace registry, each
227
+ * entry's `autoUpdate` flag, which marketplaces installed plugins come from,
228
+ * and when each clone was last refreshed — so a read-only caller can have the
229
+ * full selection verdict for free. `--dry-run` and `--check` promise to write
230
+ * nothing and therefore cannot pull (nor record a refresh time), but their
231
+ * report must name the same set a writing run would pull: that set is
232
+ * `pending`.
175
233
  */
176
234
  export async function reportRegisteredMarketplaces(
177
235
  skip: Iterable<string> = [],
236
+ options: RefreshOptions = {},
178
237
  ): Promise<RefreshResult> {
179
- return refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
238
+ return selectAndRefresh(skip, {}, { ...options, reportOnly: true });
180
239
  }
181
240
 
241
+ /**
242
+ * Non-destructively fast-forward the on-disk clone of every registered,
243
+ * auto-update-enabled, github-sourced marketplace that some installed plugin
244
+ * comes from (USER scope, or `options.pluginIds`), unless this machine already
245
+ * refreshed it within CATALOG_TTL_MS. A successful pull records its time; a
246
+ * failed one does not, so the next run retries.
247
+ *
248
+ * @param skip marketplaces already refreshed this run (e.g. re-registered ones),
249
+ * so they aren't pulled twice.
250
+ * @param progress optional taps for a live progress display.
251
+ * @returns which marketplaces were refreshed, which failed (clone left intact),
252
+ * which were fresh, and which were skipped. Failures and skips are collected
253
+ * rather than thrown — one unreachable marketplace must not block launching
254
+ * Claude. Pulls run concurrently, so a slow one doesn't serialize the rest
255
+ * onto the launch path.
256
+ */
182
257
  export async function refreshRegisteredMarketplaces(
183
258
  skip: Iterable<string> = [],
184
259
  progress: RefreshProgress = {},
185
- options: { reportOnly?: boolean } = {},
260
+ options: RefreshOptions = {},
261
+ ): Promise<RefreshResult> {
262
+ return selectAndRefresh(skip, progress, { ...options, reportOnly: false });
263
+ }
264
+
265
+ async function selectAndRefresh(
266
+ skip: Iterable<string>,
267
+ progress: RefreshProgress,
268
+ options: RefreshOptions & { reportOnly: boolean },
186
269
  ): Promise<RefreshResult> {
187
270
  const refreshed: string[] = [];
188
271
  const failed: string[] = [];
272
+ const fresh: string[] = [];
189
273
  const skipped: string[] = [];
274
+ const now = options.now ?? Date.now();
190
275
 
191
276
  const configured = await getConfiguredMarketplaces();
192
277
  const skipSet = new Set(skip);
278
+ const needed = await neededMarketplaces(options.pluginIds ?? []);
193
279
 
194
280
  // Select eligible marketplaces first so the pulls can run concurrently.
195
281
  const eligible: string[] = [];
@@ -208,18 +294,25 @@ export async function refreshRegisteredMarketplaces(
208
294
  skipped.push(name);
209
295
  continue;
210
296
  }
297
+ // Nothing installed at any scope comes from it, so a pull buys nothing.
298
+ if (!needed.has(name)) {
299
+ skipped.push(name);
300
+ continue;
301
+ }
302
+ // Refreshed on this machine within the hour, by any project's run.
303
+ if ((await readCloneRefreshAge(name, now)) !== null) {
304
+ fresh.push(name);
305
+ continue;
306
+ }
211
307
  eligible.push(name);
212
308
  }
213
309
 
214
310
  // A read-only caller stops here, with the selection verdict intact and not
215
- // one clone touched. `eligible` becomes `skipped`: nothing was refreshed, so
216
- // claiming otherwise would be the false "all current" this module guards.
311
+ // one clone touched. `eligible` becomes `pending`, never `refreshed`: nothing
312
+ // was pulled, so claiming otherwise would be the false "all current" this
313
+ // module guards.
217
314
  if (options.reportOnly) {
218
- return {
219
- refreshed: [],
220
- failed: [],
221
- skipped: [...skipped, ...eligible],
222
- };
315
+ return { refreshed: [], failed: [], fresh, pending: eligible, skipped };
223
316
  }
224
317
 
225
318
  // Fast-forward every eligible clone in parallel so a slow one doesn't
@@ -241,5 +334,9 @@ export async function refreshRegisteredMarketplaces(
241
334
  else skipped.push(name); // absent | skipped
242
335
  });
243
336
 
244
- return { refreshed, failed, skipped };
337
+ // Only a successful pull earns the hour. A failed, absent or dirty clone
338
+ // records nothing, so the next run — from any project — tries again.
339
+ for (const name of refreshed) await writeCloneRefreshed(name, now);
340
+
341
+ return { refreshed, failed, fresh, pending: [], skipped };
245
342
  }