@cad0p/pi-usage 0.11.5 → 0.11.6

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": "@cad0p/pi-usage",
3
- "version": "0.11.5",
3
+ "version": "0.11.6",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
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 {
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";
@@ -239,6 +239,12 @@ const PROVIDERS = {
239
239
  * tests and library callers never read a real home directory; the CLI turns
240
240
  * it on with a time budget. Pass an object to inject session roots or a
241
241
  * cache path, or false to disable it explicitly.
242
+ * onFamily?: (family: string, report: PiQuotaReport) => void | Promise<void>,
243
+ * Called once a family has settled, before the other families necessarily
244
+ * finish. The report fragment contains only that family's results.
245
+ * authOverrides?: Record<string, { access: string, expiresAtMs?: number | null, accountId?: string | null }>,
246
+ * Optional in-memory OAuth access tokens supplied by the host runtime. Never
247
+ * persisted or included in reports.
242
248
  * }} [options]
243
249
  * @returns {Promise<PiQuotaReport>}
244
250
  */
@@ -255,6 +261,37 @@ export async function collectQuota(options = {}) {
255
261
  paths: options.paths,
256
262
  });
257
263
 
264
+ // Pi's extension host may have resolved/renewed an OAuth token through Pi's
265
+ // own locked auth path. Apply only to the matching provider in memory; the
266
+ // underlying file reader remains read-only and the token never reaches output.
267
+ for (const [provider, auth] of Object.entries(options.authOverrides ?? {})) {
268
+ if (typeof auth?.access !== "string" || auth.access === "") continue;
269
+ let matched = false;
270
+ for (const credential of loaded.credentials) {
271
+ if (credential.provider !== provider) continue;
272
+ credential.access = auth.access;
273
+ credential.expiresAtMs = auth.expiresAtMs ?? null;
274
+ if (typeof auth.accountId === "string" && auth.accountId !== "") credential.accountId = auth.accountId;
275
+ matched = true;
276
+ }
277
+ // Pi may own auth from a non-default agent directory that the standalone
278
+ // read-only path discovery does not know about. A host-resolved OAuth token
279
+ // is sufficient to create the in-memory provider entry; never persist it.
280
+ if (!matched) {
281
+ const credential = normalizeCredential(
282
+ provider,
283
+ {
284
+ type: "oauth",
285
+ access: auth.access,
286
+ expires: auth.expiresAtMs,
287
+ accountId: auth.accountId,
288
+ },
289
+ "Pi runtime auth",
290
+ );
291
+ if (credential) loaded.credentials.push(credential);
292
+ }
293
+ }
294
+
258
295
  // Claude is the one family with a second, non-Pi source, so it is resolved
259
296
  // before the per-family loop and then treated exactly like any other credential.
260
297
  const claude = resolveClaudeCredentials({
@@ -290,6 +327,43 @@ export async function collectQuota(options = {}) {
290
327
  /** @type {Record<string, string[]>} */
291
328
  const familyWarnings = {};
292
329
 
330
+ /**
331
+ * Publish a family fragment as soon as its provider work is complete. A
332
+ * callback failure is isolated from provider collection and becomes a warning
333
+ * on the final report.
334
+ *
335
+ * @param {string} family
336
+ * @param {import("./model.js").QuotaResult[]} results
337
+ */
338
+ const emitFamily = async (family, results) => {
339
+ for (const provider of results) {
340
+ provider.primaryWindowId = selectPrimaryWindow(provider.windows ?? [])?.id ?? null;
341
+ }
342
+ byFamily[family] = results;
343
+ familyWarnings[family] ??= [];
344
+ if (!options.onFamily) return;
345
+ const fragment = {
346
+ engine: "pi-quota",
347
+ schemaVersion: 1,
348
+ readOnly: true,
349
+ generatedAt: new Date(now).toISOString(),
350
+ sources: loaded.paths,
351
+ claudeSource: { mode: claude.mode, paths: claude.claudeCodePaths, unavailable: claude.unavailable },
352
+ warnings: [...warnings, ...familyWarnings[family]],
353
+ providers: results,
354
+ byFamily: { [family]: results },
355
+ localStats: {},
356
+ localStatsMeta: null,
357
+ };
358
+ try {
359
+ await options.onFamily(family, fragment);
360
+ } catch (error) {
361
+ familyWarnings[family].push(
362
+ `${family}: family callback failed: ${redact(/** @type {{ message?: string }} */ (error)?.message ?? String(error))}`,
363
+ );
364
+ }
365
+ };
366
+
293
367
  const hasOpenAi = loaded.rawProviders?.includes("openai") || Boolean(env.OPENAI_API_KEY);
294
368
 
295
369
  // The local scan runs beside the network calls: a first full scan of the
@@ -310,7 +384,7 @@ export async function collectQuota(options = {}) {
310
384
  // `notConfigured` is stated rather than inferred: the message now names a
311
385
  // second source, and every renderer keys off this flag to avoid painting a
312
386
  // failure icon for a provider the user simply does not use.
313
- byFamily[family] = [
387
+ await emitFamily(family, [
314
388
  degradedResult({
315
389
  family,
316
390
  label: LABEL_BY_FAMILY[family] ?? family,
@@ -319,7 +393,7 @@ export async function collectQuota(options = {}) {
319
393
  source: loaded.paths[0] ?? resolveAuthPaths({ env, home: options.home })[0] ?? "~/.pi/agent/auth.json",
320
394
  now,
321
395
  }),
322
- ];
396
+ ]);
323
397
  return;
324
398
  }
325
399
 
@@ -340,7 +414,7 @@ export async function collectQuota(options = {}) {
340
414
 
341
415
  const throttle = backoffState(family, { now, env, home: options.home });
342
416
  if (throttle.active && !options.force) {
343
- byFamily[family] = [
417
+ await emitFamily(family, [
344
418
  degradedResult({
345
419
  family,
346
420
  label: LABEL_BY_FAMILY[family] ?? family,
@@ -351,7 +425,7 @@ export async function collectQuota(options = {}) {
351
425
  source: credentials[0].source,
352
426
  now,
353
427
  }),
354
- ];
428
+ ]);
355
429
  return;
356
430
  }
357
431
 
@@ -404,7 +478,11 @@ export async function collectQuota(options = {}) {
404
478
  const results = [];
405
479
  const familyWarnList = [];
406
480
  for (const { result, warning, crashed } of outcomes) {
407
- if (warning) familyWarnList.push(warning);
481
+ // The stored-credential warning is a claim about this run, and a provider
482
+ // that renewed the token in memory for it knows better than the store:
483
+ // "the Pi token expired 271316m ago" next to two healthy Antigravity
484
+ // windows was reading Pi's stale copy, not the token that was used.
485
+ if (warning && result.tokenRenewedInMemory !== true) familyWarnList.push(warning);
408
486
  if (!crashed) {
409
487
  // Bookkeeping must degrade like the provider call it follows: a
410
488
  // failing backoff write is a warning, never a rejected family.
@@ -426,8 +504,8 @@ export async function collectQuota(options = {}) {
426
504
  }
427
505
  results.push(result);
428
506
  }
429
- byFamily[family] = results;
430
507
  familyWarnings[family] = familyWarnList;
508
+ await emitFamily(family, results);
431
509
  }),
432
510
  );
433
511
 
@@ -526,3 +604,16 @@ function refreshHint(credential) {
526
604
  export function firstForFamily(report, family) {
527
605
  return report.byFamily[family]?.[0] ?? null;
528
606
  }
607
+
608
+ /**
609
+ * Collect a single family using the same normalized engine and callback
610
+ * semantics as collectQuota. This is useful to independent refresh schedulers:
611
+ * an in-flight family does not serialize another family's request.
612
+ *
613
+ * @param {string} family
614
+ * @param {Parameters<typeof collectQuota>[0]} [options]
615
+ * @returns {Promise<PiQuotaReport>}
616
+ */
617
+ export function collectFamily(family, options = {}) {
618
+ return collectQuota({ ...options, families: [family] });
619
+ }
@@ -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
+ }