@cad0p/pi-usage 0.11.5 → 0.11.6-20261107.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.
@@ -0,0 +1,18 @@
1
+ <svg viewBox="0 0 10 10" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <rect x="0" y="0" width="2" height="2" fill="#FFD800"/>
3
+ <rect x="8" y="0" width="2" height="2" fill="#FFD800"/>
4
+ <rect x="0" y="2" width="2" height="2" fill="#FFAF00"/>
5
+ <rect x="2" y="2" width="2" height="2" fill="#FFAF00"/>
6
+ <rect x="6" y="2" width="2" height="2" fill="#FFAF00"/>
7
+ <rect x="8" y="2" width="2" height="2" fill="#FFAF00"/>
8
+ <rect x="0" y="4" width="2" height="2" fill="#FF8205"/>
9
+ <rect x="2" y="4" width="2" height="2" fill="#FF8205"/>
10
+ <rect x="4" y="4" width="2" height="2" fill="#FF8205"/>
11
+ <rect x="6" y="4" width="2" height="2" fill="#FF8205"/>
12
+ <rect x="8" y="4" width="2" height="2" fill="#FF8205"/>
13
+ <rect x="0" y="6" width="2" height="2" fill="#FA500F"/>
14
+ <rect x="4" y="6" width="2" height="2" fill="#FA500F"/>
15
+ <rect x="8" y="6" width="2" height="2" fill="#FA500F"/>
16
+ <rect x="0" y="8" width="2" height="2" fill="#E10500"/>
17
+ <rect x="8" y="8" width="2" height="2" fill="#E10500"/>
18
+ </svg>
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@cad0p/pi-usage",
3
- "version": "0.11.5",
3
+ "version": "0.11.6-20261107.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
7
  "type": "module",
8
- "description": "Read-only usage/quota for the AI subscriptions Pi already owns (Claude, Codex, multi-account OpenCode Go, Antigravity): /usage in Pi and the Omarchy Agents panel. Fork of @j3pf/piquota.",
8
+ "description": "Read-only usage/quota for the AI subscriptions Pi already owns (Claude, Codex, multi-account OpenCode Go, Antigravity, Mistral): /usage in Pi and the Omarchy Agents panel. Fork of @j3pf/piquota.",
9
9
  "engines": {
10
10
  "node": ">=22.19.0"
11
11
  },
@@ -34,6 +34,7 @@
34
34
  "usage",
35
35
  "omarchy",
36
36
  "opencode-go",
37
+ "mistral",
37
38
  "multi-account"
38
39
  ],
39
40
  "repository": {
@@ -28,6 +28,7 @@ export const FAMILY_BY_PROVIDER = {
28
28
  // kept for stores written by older Pi releases and third-party plugins.
29
29
  "google-antigravity": "antigravity",
30
30
  "opencode-go": "opencode-go",
31
+ mistral: "mistral",
31
32
  };
32
33
 
33
34
  /** Display label used by every surface (terminal, Pi TUI, Moshi cards). */
@@ -36,6 +37,7 @@ export const LABEL_BY_FAMILY = {
36
37
  claude: "Claude (Pi)",
37
38
  antigravity: "Antigravity (Pi)",
38
39
  "opencode-go": "OpenCode Go (Pi)",
40
+ mistral: "Mistral (Pi)",
39
41
  "github-actions": "GitHub Actions",
40
42
  };
41
43
 
package/src/cache.js CHANGED
@@ -62,6 +62,59 @@ export function resolveCachePath(options = {}) {
62
62
  return join(cacheHome, "pi-quota", "usage.json");
63
63
  }
64
64
 
65
+ /**
66
+ * Read the cache envelope, or null when there is not one this version understands.
67
+ *
68
+ * @param {string} path
69
+ * @returns {Record<string, any> | null}
70
+ */
71
+ function readPayload(path) {
72
+ let raw;
73
+ try {
74
+ raw = readFileSync(path, "utf-8");
75
+ } catch {
76
+ return null;
77
+ }
78
+ try {
79
+ const parsed = JSON.parse(raw);
80
+ return parsed && typeof parsed === "object" ? /** @type {Record<string, any>} */ (parsed) : null;
81
+ } catch {
82
+ return null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The fetch time of every family a cached report carries.
88
+ *
89
+ * The report is written family by family — the Pi extension repaints and saves
90
+ * as each one lands — so `savedAt` says when the *last* family was written and
91
+ * nothing about the others. Judging the whole file by it served Claude's
92
+ * five-minute-old numbers to a reader a minute later, only because Codex had
93
+ * just been saved. Each family therefore keeps its own clock. A cache written
94
+ * before that field existed carries only `savedAt`, and every family falls
95
+ * back to it.
96
+ *
97
+ * @param {Record<string, any>} parsed
98
+ * @param {string[]} families
99
+ * @returns {Record<string, number>}
100
+ */
101
+ function payloadClocks(parsed, families) {
102
+ const savedAt = typeof parsed.savedAt === "number" && Number.isFinite(parsed.savedAt) ? parsed.savedAt : null;
103
+ /** @type {Record<string, number>} */
104
+ const clocks = {};
105
+ const raw = parsed.fetchedAt;
106
+ if (raw && typeof raw === "object" && !Array.isArray(raw)) {
107
+ for (const [family, value] of Object.entries(raw)) {
108
+ if (typeof value === "number" && Number.isFinite(value)) clocks[family] = value;
109
+ }
110
+ }
111
+ for (const family of families) {
112
+ if (typeof clocks[family] === "number") continue;
113
+ if (savedAt !== null) clocks[family] = savedAt;
114
+ }
115
+ return clocks;
116
+ }
117
+
65
118
  /**
66
119
  * @param {{
67
120
  * path?: string,
@@ -71,27 +124,20 @@ export function resolveCachePath(options = {}) {
71
124
  * home?: string,
72
125
  * key?: string, Settings fingerprint; a cached report saved under another key is a miss.
73
126
  * }} [options]
74
- * @returns {{ report: import("./engine.js").PiQuotaReport, ageMs: number, path: string } | null}
127
+ * @returns {{
128
+ * report: import("./engine.js").PiQuotaReport,
129
+ * ageMs: number,
130
+ * fetchedAt: Record<string, number>,
131
+ * path: string,
132
+ * } | null}
75
133
  */
76
134
  export function readCache(options = {}) {
77
135
  const path = options.path ?? resolveCachePath(options);
78
136
  const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
79
137
  const now = options.now ?? Date.now();
80
138
 
81
- let raw;
82
- try {
83
- raw = readFileSync(path, "utf-8");
84
- } catch {
85
- return null;
86
- }
87
-
88
- let parsed;
89
- try {
90
- parsed = JSON.parse(raw);
91
- } catch {
92
- return null;
93
- }
94
- if (!parsed || typeof parsed !== "object") return null;
139
+ const parsed = readPayload(path);
140
+ if (!parsed) return null;
95
141
  const report = /** @type {{ report?: import("./engine.js").PiQuotaReport }} */ (parsed).report;
96
142
  if (!report || !Array.isArray(report.providers)) return null;
97
143
 
@@ -99,7 +145,13 @@ export function readCache(options = {}) {
99
145
  ? /** @type {number} */ (/** @type {{ savedAt?: number }} */ (parsed).savedAt)
100
146
  : null;
101
147
  if (savedAt === null) return null;
102
- const ageMs = now - savedAt;
148
+ const families = Array.from(new Set(report.providers.map((provider) => provider.family)));
149
+ const fetchedAt = payloadClocks(parsed, families);
150
+ // The oldest family decides the hit: a report is only as fresh as its stalest
151
+ // part, which is what the single-`savedAt` rule meant before per-family clocks.
152
+ const ageMs = families.length > 0
153
+ ? Math.max(...families.map((family) => now - (fetchedAt[family] ?? savedAt)))
154
+ : now - savedAt;
103
155
  if (ageMs > ttlMs) return null;
104
156
  // A report collected under other settings (e.g. another Claude source) answers a
105
157
  // different question, so it is a miss rather than a stale hit.
@@ -116,7 +168,33 @@ export function readCache(options = {}) {
116
168
  }
117
169
  }
118
170
 
119
- return { report, ageMs, path };
171
+ return { report, ageMs, fetchedAt, path };
172
+ }
173
+
174
+ /**
175
+ * @param {unknown} value
176
+ * @returns {value is number}
177
+ */
178
+ function finite(value) {
179
+ return typeof value === "number" && Number.isFinite(value);
180
+ }
181
+
182
+ /**
183
+ * The fetch time a family's own results carry: the oldest `updatedAt` among
184
+ * its accounts, or undefined when none parses.
185
+ *
186
+ * @param {import("./engine.js").PiQuotaReport} report
187
+ * @param {string} family
188
+ * @returns {number | undefined}
189
+ */
190
+ function oldestUpdatedAt(report, family) {
191
+ let oldest;
192
+ for (const provider of report.providers ?? []) {
193
+ if (provider?.family !== family) continue;
194
+ const at = Date.parse(provider.updatedAt ?? "");
195
+ if (Number.isFinite(at) && (oldest === undefined || at < oldest)) oldest = at;
196
+ }
197
+ return oldest;
120
198
  }
121
199
 
122
200
  /**
@@ -128,24 +206,34 @@ export function readCache(options = {}) {
128
206
  * home?: string,
129
207
  * merge?: boolean,
130
208
  * key?: string,
209
+ * fetchedAtMs?: Record<string, number>, Each family's own fetch time, when the caller knows it.
210
+ * freshFamilies?: string[], Families this write fetched just now; defaults to every family in `report`.
131
211
  * }} [options]
132
212
  * @returns {{ ok: boolean, path: string, error?: string }}
133
213
  */
134
214
  export function writeCache(report, options = {}) {
135
215
  const path = options.path ?? resolveCachePath(options);
136
216
  const now = options.now ?? Date.now();
217
+ const existing = readCache({ ...options, path, ttlMs: Number.POSITIVE_INFINITY });
137
218
  let finalReport = report;
138
- if (options.merge) {
139
- const existing = readCache({ ...options, path, ttlMs: Number.POSITIVE_INFINITY });
140
- if (existing?.report) {
141
- finalReport = mergeReportIntoCache(existing.report, report);
142
- }
219
+ if (options.merge && existing?.report) {
220
+ finalReport = mergeReportIntoCache(existing.report, report);
221
+ }
222
+ const fresh = new Set(options.freshFamilies ?? (report.providers ?? []).map((provider) => provider.family));
223
+ const provided = options.fetchedAtMs ?? {};
224
+ /** @type {Record<string, number>} */
225
+ const fetchedAt = {};
226
+ for (const family of new Set((finalReport.providers ?? []).map((provider) => provider.family))) {
227
+ // A family this write fetched is "now" unless the caller knows a better
228
+ // time. Any other family keeps the clock it had; with no clock anywhere it
229
+ // is as old as its own results say, never restamped to now, which would
230
+ // let an hour-old copy pass as fresh merely because a neighbour was saved.
231
+ const own = provided[family];
232
+ const at = finite(own) ? own : fresh.has(family) ? now : finite(existing?.fetchedAt?.[family]) ? existing.fetchedAt[family] : oldestUpdatedAt(finalReport, family);
233
+ fetchedAt[family] = finite(at) ? at : now;
143
234
  }
144
- const payload = JSON.stringify(
145
- options.key === undefined ? { savedAt: now, report: finalReport } : { savedAt: now, key: options.key, report: finalReport },
146
- null,
147
- 2,
148
- );
235
+ const envelope = { savedAt: now, ...(options.key === undefined ? {} : { key: options.key }), fetchedAt, report: finalReport };
236
+ const payload = JSON.stringify(envelope, null, 2);
149
237
 
150
238
  try {
151
239
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
@@ -211,10 +299,16 @@ export function describeCache(options = {}) {
211
299
  export async function withCache(options, loader) {
212
300
  const requested = options.families;
213
301
  if (!options.force) {
214
- const hit = readCache(options);
302
+ // Freshness is decided per family below, so the read must not apply one
303
+ // TTL to the whole file first.
304
+ const hit = readCache({ ...options, ttlMs: Number.POSITIVE_INFINITY });
215
305
  if (hit) {
216
- const allPresent = !requested || requested.every((fam) => fam in hit.report.byFamily);
217
- if (allPresent) {
306
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
307
+ const now = options.now ?? Date.now();
308
+ const present = new Set(hit.report.providers.map((provider) => provider.family));
309
+ const wanted = requested ?? Array.from(present);
310
+ const fresh = wanted.every((family) => present.has(family) && now - (hit.fetchedAt[family] ?? 0) <= ttlMs);
311
+ if (fresh) {
218
312
  const filteredReport = requested
219
313
  ? {
220
314
  ...hit.report,
@@ -224,7 +318,10 @@ export async function withCache(options, loader) {
224
318
  ),
225
319
  }
226
320
  : hit.report;
227
- return { report: filteredReport, cached: true, ageMs: hit.ageMs };
321
+ const ageMs = wanted.length > 0
322
+ ? Math.max(...wanted.map((family) => now - (hit.fetchedAt[family] ?? now)))
323
+ : hit.ageMs;
324
+ return { report: filteredReport, cached: true, ageMs };
228
325
  }
229
326
  }
230
327
  }
@@ -44,19 +44,37 @@ export function skippedFamilies(env, dir) {
44
44
  }
45
45
 
46
46
  /**
47
- * @param {{ load: () => Promise<import("../../engine.js").PiQuotaReport>, dir?: string, env?: Record<string, string | undefined>, quiet?: boolean }} options
47
+ * @param {{
48
+ * load: (options?: { onFamily?: (family: string, report: import("../../engine.js").PiQuotaReport) => void | Promise<void> }) => Promise<import("../../engine.js").PiQuotaReport>,
49
+ * dir?: string,
50
+ * env?: Record<string, string | undefined>,
51
+ * quiet?: boolean,
52
+ * }} options
48
53
  * @returns {Promise<number>}
49
54
  */
50
55
  export async function runOmarchyCommand(options) {
56
+ const skip = skippedFamilies(options.env ?? process.env, options.dir);
57
+ const onFamily = async (family, fragment) => {
58
+ if (skip.has(family)) return;
59
+ const providers = (fragment.providers ?? []).filter((provider) => provider?.family === family && !skip.has(provider.family));
60
+ if (providers.length === 0) return;
61
+ try {
62
+ const familyReport = { ...fragment, providers, byFamily: { [family]: providers } };
63
+ publishRecords(buildRecords(familyReport), { dir: options.dir, env: options.env, prune: false });
64
+ } catch {
65
+ // A per-family write must not prevent later providers from being collected.
66
+ // The final complete publish below reports any persistent write failure.
67
+ }
68
+ };
69
+
51
70
  let report;
52
71
  try {
53
- report = await options.load();
72
+ report = await options.load({ onFamily });
54
73
  } catch (error) {
55
74
  err(`omarchy: could not collect quota: ${redact(/** @type {{ message?: string }} */ (error)?.message ?? error)}`);
56
75
  return 1;
57
76
  }
58
77
 
59
- const skip = skippedFamilies(options.env ?? process.env, options.dir);
60
78
  const records = buildRecords(skip.size > 0 ? { ...report, providers: (report.providers ?? []).filter((p) => !skip.has(p?.family)) } : report);
61
79
  let result;
62
80
  try {
@@ -22,6 +22,8 @@ export const FAMILY_ALIASES = {
22
22
  opencode: "opencode-go",
23
23
  go: "opencode-go",
24
24
  zen: "opencode-go",
25
+ mistral: "mistral",
26
+ vibe: "mistral",
25
27
  "github-actions": "github-actions",
26
28
  gh: "github-actions",
27
29
  actions: "github-actions",
package/src/engine.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * degraded result with an actionable message.
7
7
  */
8
8
 
9
- import { checkFreshness, LABEL_BY_FAMILY, loadPiCredentials, resolveAuthPaths } from "./auth/pi-auth.js";
9
+ import { checkFreshness, LABEL_BY_FAMILY, loadPiCredentials, normalizeCredential, resolveAuthPaths } from "./auth/pi-auth.js";
10
10
  import { loadClaudeCodeCredential } from "./auth/claude-code-auth.js";
11
11
  import { redact } from "./http.js";
12
12
  import { collectLocalStats } from "./local/pi-sessions.js";
@@ -16,10 +16,11 @@ import { fetchQuota as fetchClaude } from "./providers/claude.js";
16
16
  import { fetchQuota as fetchCodex } from "./providers/codex.js";
17
17
  import { fetchQuota as fetchAntigravity } from "./providers/antigravity.js";
18
18
  import { fetchQuota as fetchOpenCodeGo } from "./providers/opencode-go.js";
19
+ import { fetchQuota as fetchMistral } from "./providers/mistral.js";
19
20
  import { fetchQuota as fetchGithubActions } from "./providers/github-actions.js";
20
21
 
21
22
  /** Canonical family order used by every surface. */
22
- export const FAMILIES = ["claude", "codex", "antigravity", "opencode-go", "github-actions"];
23
+ export const FAMILIES = ["claude", "codex", "antigravity", "opencode-go", "mistral", "github-actions"];
23
24
 
24
25
  /**
25
26
  * Return the ordered families enabled for a run.
@@ -196,6 +197,7 @@ const PROVIDERS = {
196
197
  codex: fetchCodex,
197
198
  antigravity: fetchAntigravity,
198
199
  "opencode-go": fetchOpenCodeGo,
200
+ mistral: fetchMistral,
199
201
  "github-actions": fetchGithubActions,
200
202
  };
201
203
 
@@ -239,6 +241,12 @@ const PROVIDERS = {
239
241
  * tests and library callers never read a real home directory; the CLI turns
240
242
  * it on with a time budget. Pass an object to inject session roots or a
241
243
  * cache path, or false to disable it explicitly.
244
+ * onFamily?: (family: string, report: PiQuotaReport) => void | Promise<void>,
245
+ * Called once a family has settled, before the other families necessarily
246
+ * finish. The report fragment contains only that family's results.
247
+ * authOverrides?: Record<string, { access: string, expiresAtMs?: number | null, accountId?: string | null }>,
248
+ * Optional in-memory OAuth access tokens supplied by the host runtime. Never
249
+ * persisted or included in reports.
242
250
  * }} [options]
243
251
  * @returns {Promise<PiQuotaReport>}
244
252
  */
@@ -255,6 +263,37 @@ export async function collectQuota(options = {}) {
255
263
  paths: options.paths,
256
264
  });
257
265
 
266
+ // Pi's extension host may have resolved/renewed an OAuth token through Pi's
267
+ // own locked auth path. Apply only to the matching provider in memory; the
268
+ // underlying file reader remains read-only and the token never reaches output.
269
+ for (const [provider, auth] of Object.entries(options.authOverrides ?? {})) {
270
+ if (typeof auth?.access !== "string" || auth.access === "") continue;
271
+ let matched = false;
272
+ for (const credential of loaded.credentials) {
273
+ if (credential.provider !== provider) continue;
274
+ credential.access = auth.access;
275
+ credential.expiresAtMs = auth.expiresAtMs ?? null;
276
+ if (typeof auth.accountId === "string" && auth.accountId !== "") credential.accountId = auth.accountId;
277
+ matched = true;
278
+ }
279
+ // Pi may own auth from a non-default agent directory that the standalone
280
+ // read-only path discovery does not know about. A host-resolved OAuth token
281
+ // is sufficient to create the in-memory provider entry; never persist it.
282
+ if (!matched) {
283
+ const credential = normalizeCredential(
284
+ provider,
285
+ {
286
+ type: "oauth",
287
+ access: auth.access,
288
+ expires: auth.expiresAtMs,
289
+ accountId: auth.accountId,
290
+ },
291
+ "Pi runtime auth",
292
+ );
293
+ if (credential) loaded.credentials.push(credential);
294
+ }
295
+ }
296
+
258
297
  // Claude is the one family with a second, non-Pi source, so it is resolved
259
298
  // before the per-family loop and then treated exactly like any other credential.
260
299
  const claude = resolveClaudeCredentials({
@@ -290,6 +329,43 @@ export async function collectQuota(options = {}) {
290
329
  /** @type {Record<string, string[]>} */
291
330
  const familyWarnings = {};
292
331
 
332
+ /**
333
+ * Publish a family fragment as soon as its provider work is complete. A
334
+ * callback failure is isolated from provider collection and becomes a warning
335
+ * on the final report.
336
+ *
337
+ * @param {string} family
338
+ * @param {import("./model.js").QuotaResult[]} results
339
+ */
340
+ const emitFamily = async (family, results) => {
341
+ for (const provider of results) {
342
+ provider.primaryWindowId = selectPrimaryWindow(provider.windows ?? [])?.id ?? null;
343
+ }
344
+ byFamily[family] = results;
345
+ familyWarnings[family] ??= [];
346
+ if (!options.onFamily) return;
347
+ const fragment = {
348
+ engine: "pi-quota",
349
+ schemaVersion: 1,
350
+ readOnly: true,
351
+ generatedAt: new Date(now).toISOString(),
352
+ sources: loaded.paths,
353
+ claudeSource: { mode: claude.mode, paths: claude.claudeCodePaths, unavailable: claude.unavailable },
354
+ warnings: [...warnings, ...familyWarnings[family]],
355
+ providers: results,
356
+ byFamily: { [family]: results },
357
+ localStats: {},
358
+ localStatsMeta: null,
359
+ };
360
+ try {
361
+ await options.onFamily(family, fragment);
362
+ } catch (error) {
363
+ familyWarnings[family].push(
364
+ `${family}: family callback failed: ${redact(/** @type {{ message?: string }} */ (error)?.message ?? String(error))}`,
365
+ );
366
+ }
367
+ };
368
+
293
369
  const hasOpenAi = loaded.rawProviders?.includes("openai") || Boolean(env.OPENAI_API_KEY);
294
370
 
295
371
  // The local scan runs beside the network calls: a first full scan of the
@@ -310,7 +386,7 @@ export async function collectQuota(options = {}) {
310
386
  // `notConfigured` is stated rather than inferred: the message now names a
311
387
  // second source, and every renderer keys off this flag to avoid painting a
312
388
  // failure icon for a provider the user simply does not use.
313
- byFamily[family] = [
389
+ await emitFamily(family, [
314
390
  degradedResult({
315
391
  family,
316
392
  label: LABEL_BY_FAMILY[family] ?? family,
@@ -319,7 +395,7 @@ export async function collectQuota(options = {}) {
319
395
  source: loaded.paths[0] ?? resolveAuthPaths({ env, home: options.home })[0] ?? "~/.pi/agent/auth.json",
320
396
  now,
321
397
  }),
322
- ];
398
+ ]);
323
399
  return;
324
400
  }
325
401
 
@@ -340,7 +416,7 @@ export async function collectQuota(options = {}) {
340
416
 
341
417
  const throttle = backoffState(family, { now, env, home: options.home });
342
418
  if (throttle.active && !options.force) {
343
- byFamily[family] = [
419
+ await emitFamily(family, [
344
420
  degradedResult({
345
421
  family,
346
422
  label: LABEL_BY_FAMILY[family] ?? family,
@@ -351,7 +427,7 @@ export async function collectQuota(options = {}) {
351
427
  source: credentials[0].source,
352
428
  now,
353
429
  }),
354
- ];
430
+ ]);
355
431
  return;
356
432
  }
357
433
 
@@ -404,7 +480,11 @@ export async function collectQuota(options = {}) {
404
480
  const results = [];
405
481
  const familyWarnList = [];
406
482
  for (const { result, warning, crashed } of outcomes) {
407
- if (warning) familyWarnList.push(warning);
483
+ // The stored-credential warning is a claim about this run, and a provider
484
+ // that renewed the token in memory for it knows better than the store:
485
+ // "the Pi token expired 271316m ago" next to two healthy Antigravity
486
+ // windows was reading Pi's stale copy, not the token that was used.
487
+ if (warning && result.tokenRenewedInMemory !== true) familyWarnList.push(warning);
408
488
  if (!crashed) {
409
489
  // Bookkeeping must degrade like the provider call it follows: a
410
490
  // failing backoff write is a warning, never a rejected family.
@@ -426,8 +506,8 @@ export async function collectQuota(options = {}) {
426
506
  }
427
507
  results.push(result);
428
508
  }
429
- byFamily[family] = results;
430
509
  familyWarnings[family] = familyWarnList;
510
+ await emitFamily(family, results);
431
511
  }),
432
512
  );
433
513
 
@@ -526,3 +606,16 @@ function refreshHint(credential) {
526
606
  export function firstForFamily(report, family) {
527
607
  return report.byFamily[family]?.[0] ?? null;
528
608
  }
609
+
610
+ /**
611
+ * Collect a single family using the same normalized engine and callback
612
+ * semantics as collectQuota. This is useful to independent refresh schedulers:
613
+ * an in-flight family does not serialize another family's request.
614
+ *
615
+ * @param {string} family
616
+ * @param {Parameters<typeof collectQuota>[0]} [options]
617
+ * @returns {Promise<PiQuotaReport>}
618
+ */
619
+ export function collectFamily(family, options = {}) {
620
+ return collectQuota({ ...options, families: [family] });
621
+ }
@@ -145,8 +145,22 @@ const RECENT_DAYS = 7;
145
145
  * @property {number} [now] Report time; decides which local day is "today".
146
146
  * @property {number} [budgetMs] Wall-clock budget for this scan.
147
147
  * @property {() => number} [clock] Time source; tests inject one to drive the budget.
148
+ * @property {() => boolean} [shouldStop] The host wants the scan to end now (session shutdown, /reload).
149
+ * @property {(lock: { path: string, token: string | null }) => void} [onLock] Receives the lock
150
+ * this scan holds, so a host that has to kill the worker can still release it.
148
151
  */
149
152
 
153
+ /**
154
+ * The host asked the scan to stop. Thrown out of the scan loop so the `finally`
155
+ * releases the history lock and nothing after the interruption is written.
156
+ */
157
+ export class LocalScanAbortedError extends Error {
158
+ constructor() {
159
+ super("local token stats scan stopped");
160
+ this.name = "LocalScanAbortedError";
161
+ }
162
+ }
163
+
150
164
  /**
151
165
  * One session file's cache entry.
152
166
  *
@@ -1135,6 +1149,10 @@ export function buildLocalStats(input = {}) {
1135
1149
  * }>}
1136
1150
  */export async function collectLocalStats(options = {}) {
1137
1151
  const clock = options.clock ?? Date.now;
1152
+ const hostStopped = options.shouldStop;
1153
+ const stopNow = () => {
1154
+ if (hostStopped?.() === true) throw new LocalScanAbortedError();
1155
+ };
1138
1156
  const startedAt = clock();
1139
1157
  const now = options.now ?? Date.now();
1140
1158
  const today = localDayKey(now);
@@ -1164,6 +1182,9 @@ export function buildLocalStats(input = {}) {
1164
1182
  // delta twice. A run that cannot take it still answers, from what is on disk.
1165
1183
  const lockPath = `${cachePath}.lock`;
1166
1184
  const lock = acquireHistoryLock({ lockPath, now: clock() });
1185
+ // The host gets the token, not just the path: whoever releases this lock must
1186
+ // prove it is this scan's, never another run's.
1187
+ if (lock.acquired) options.onLock?.({ path: lock.path, token: lock.token });
1167
1188
  if (!lock.acquired) {
1168
1189
  // Read-only path: `canWrite: false` keeps it from rebuilding or repairing
1169
1190
  // anything the run that does hold the lock is writing right now.
@@ -1277,6 +1298,7 @@ export function buildLocalStats(input = {}) {
1277
1298
  };
1278
1299
 
1279
1300
  for (const path of listen) {
1301
+ stopNow();
1280
1302
  if (!keepLock()) break;
1281
1303
  if (!partial && overBudget()) partial = true;
1282
1304
  if (partial) break;
@@ -1373,7 +1395,7 @@ export function buildLocalStats(input = {}) {
1373
1395
  startOffset,
1374
1396
  days,
1375
1397
  now,
1376
- shouldStop: overBudget,
1398
+ shouldStop: () => overBudget() || hostStopped?.() === true,
1377
1399
  onUsage: (usage) => {
1378
1400
  if (lastFrozenDay && usage.day <= lastFrozenDay) {
1379
1401
  // Frozen days are never reconstructed on a rescan. Incremental
@@ -1465,6 +1487,11 @@ export function buildLocalStats(input = {}) {
1465
1487
  };
1466
1488
  }
1467
1489
 
1490
+ // A stop that arrived while the last file was being read has to end the run
1491
+ // here: freezing days and writing the cache are the writes a shutdown must
1492
+ // not race. Everything before this point is undoable read-only work.
1493
+ stopNow();
1494
+
1468
1495
  const openRows = buildOpenRows(next);
1469
1496
  const pendingBeforeApply = pendingLate.size;
1470
1497
  const applied = applyDurable({
@@ -0,0 +1,27 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+
3
+ import { LocalScanAbortedError, collectLocalStats } from "./pi-sessions.js";
4
+
5
+ const options = workerData?.options ?? {};
6
+ // A stop request is a flag the scan polls, not a message it has to be idle to
7
+ // receive: a cold scan spends its time inside synchronous reads and hashing.
8
+ const stopFlag = workerData?.stopFlag instanceof Int32Array ? workerData.stopFlag : null;
9
+
10
+ try {
11
+ const result = await collectLocalStats({
12
+ ...options,
13
+ shouldStop: () => stopFlag !== null && Atomics.load(stopFlag, 0) === 1,
14
+ // The host keeps this handle: if it has to kill this thread, it can release
15
+ // the lock, and only with the token this scan created.
16
+ onLock: (lock) => parentPort?.postMessage({ type: "lock", path: lock.path, token: lock.token }),
17
+ });
18
+ parentPort?.postMessage({ type: "result", ok: true, result });
19
+ } catch (error) {
20
+ // Do not serialize arbitrary error objects from the scanner into the host.
21
+ parentPort?.postMessage({
22
+ type: "result",
23
+ ok: false,
24
+ aborted: error instanceof LocalScanAbortedError,
25
+ error: "the scan failed",
26
+ });
27
+ }