@oh-my-pi/pi-ai 18.0.5 → 18.0.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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.0.6] - 2026-08-26
6
+
7
+ ### Added
8
+
9
+ - Added the `backgroundIdleMs` option to customize how long background auth-broker activity remains active before automatically parking.
10
+
11
+ ### Fixed
12
+
13
+ - Fixed auth-broker background activity keeping processes alive unnecessarily, so unused broker-backed auth storage now parks automatically and no longer prevents CLI exit.
14
+
5
15
  ## [18.0.5] - 2026-08-25
6
16
 
7
17
  ### Breaking Changes
@@ -35,6 +35,14 @@ export interface RemoteAuthCredentialStoreOptions {
35
35
  accountPool?: AuthBrokerAccountPool;
36
36
  /** Flush cadence for batched observed-usage reports. Default 10s. */
37
37
  observedUsageFlushMs?: number;
38
+ /**
39
+ * Idle window after the last foreground store use before background
40
+ * snapshot sync (SSE stream / long-poll) disconnects and parks. A parked
41
+ * store holds no timers or sockets, so an unclosed store never keeps the
42
+ * process alive longer than one idle window. Sync resumes transparently on
43
+ * the next use. Default 20s.
44
+ */
45
+ backgroundIdleMs?: number;
38
46
  }
39
47
  export declare class RemoteAuthCredentialStore implements AuthCredentialStore {
40
48
  #private;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "18.0.5",
4
+ "version": "18.0.6",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Stencil Labs, Inc.",
@@ -37,10 +37,10 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/omptype": "18.0.5",
41
- "@oh-my-pi/pi-catalog": "18.0.5",
42
- "@oh-my-pi/pi-utils": "18.0.5",
43
- "@oh-my-pi/pi-wire": "18.0.5"
40
+ "@oh-my-pi/omptype": "18.0.6",
41
+ "@oh-my-pi/pi-catalog": "18.0.6",
42
+ "@oh-my-pi/pi-utils": "18.0.6",
43
+ "@oh-my-pi/pi-wire": "18.0.6"
44
44
  },
45
45
  "devDependencies": {
46
46
  "@types/bun": "^1.3.14"
@@ -8,7 +8,6 @@
8
8
  * runs isn't required.
9
9
  */
10
10
  import * as os from "node:os";
11
- import { scheduler } from "node:timers/promises";
12
11
  import { getInstallId, logger } from "@oh-my-pi/pi-utils";
13
12
  import {
14
13
  type AuthCredential,
@@ -64,6 +63,8 @@ const MAX_WAIT_MS = 5_000;
64
63
  const BACKGROUND_WAIT_MS = 30_000;
65
64
  const BACKGROUND_BACKOFF_INITIAL_MS = 500;
66
65
  const BACKGROUND_BACKOFF_MAX_MS = 30_000;
66
+ /** Idle window after the last foreground store use before background sync parks. */
67
+ const BACKGROUND_IDLE_MS = 20_000;
67
68
 
68
69
  function compareCredentialBlockSnapshots(a: CredentialBlockSnapshot, b: CredentialBlockSnapshot): number {
69
70
  const provider = a.providerKey.localeCompare(b.providerKey);
@@ -236,6 +237,14 @@ export interface RemoteAuthCredentialStoreOptions {
236
237
  accountPool?: AuthBrokerAccountPool;
237
238
  /** Flush cadence for batched observed-usage reports. Default 10s. */
238
239
  observedUsageFlushMs?: number;
240
+ /**
241
+ * Idle window after the last foreground store use before background
242
+ * snapshot sync (SSE stream / long-poll) disconnects and parks. A parked
243
+ * store holds no timers or sockets, so an unclosed store never keeps the
244
+ * process alive longer than one idle window. Sync resumes transparently on
245
+ * the next use. Default 20s.
246
+ */
247
+ backgroundIdleMs?: number;
239
248
  }
240
249
 
241
250
  export class RemoteAuthCredentialStore implements AuthCredentialStore {
@@ -248,6 +257,11 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
248
257
  #generation = 0;
249
258
  #usageOverlays: Map<string, UsageReport> = new Map();
250
259
  #backgroundAbort = new AbortController();
260
+ readonly #backgroundIdleMs: number;
261
+ /** Last foreground store use; background sync parks `#backgroundIdleMs` after this. */
262
+ #lastActivityMs = Date.now();
263
+ /** Present while the background loop is parked; resolved by `#noteActivity` or `close()`. */
264
+ #activityWakeup: PromiseWithResolvers<void> | null = null;
251
265
  #cache: Map<string, CacheEntry> = new Map();
252
266
  #usageCache?: UsageCacheEntry;
253
267
  #usageInflight?: Promise<UsageReport[] | null>;
@@ -281,6 +295,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
281
295
  this.#client = opts.client;
282
296
  this.#streamSnapshots = opts.streamSnapshots ?? true;
283
297
  this.#observedUsageFlushMs = opts.observedUsageFlushMs ?? 10_000;
298
+ this.#backgroundIdleMs = opts.backgroundIdleMs ?? BACKGROUND_IDLE_MS;
284
299
  this.#accountPool = opts.accountPool
285
300
  ? new Map([...opts.accountPool].map(([provider, identities]) => [provider, new Set(identities)]))
286
301
  : undefined;
@@ -294,6 +309,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
294
309
  }
295
310
 
296
311
  get snapshot(): SnapshotResponse {
312
+ this.#noteActivity();
297
313
  return this.#snapshot;
298
314
  }
299
315
 
@@ -346,49 +362,132 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
346
362
  }
347
363
  }
348
364
 
365
+ /**
366
+ * Background snapshot sync. Invariant: this loop never keeps the process
367
+ * alive on its own. While the store is in active foreground use it holds a
368
+ * live broker request (SSE stream or long-poll); once the store has been
369
+ * idle for `#backgroundIdleMs` an unref'd watchdog aborts that request and
370
+ * the loop parks on a bare promise — zero timers or sockets — until the
371
+ * next foreground call. Backoff sleeps use unref'd timers for the same
372
+ * reason. A leaked (never-closed) store therefore stops pinning the event
373
+ * loop at most one idle window after its last use.
374
+ */
349
375
  async #runBackground(): Promise<void> {
350
376
  let backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
351
377
  while (!this.#closed && !this.#backgroundAbort.signal.aborted) {
352
- if (this.#streamSnapshots && !this.#streamingUnsupported) {
378
+ if (this.#idleRemainingMs() <= 0) {
379
+ this.#activityWakeup ??= Promise.withResolvers<void>();
380
+ await this.#activityWakeup.promise;
381
+ continue;
382
+ }
383
+ const watchdog = this.#startIdleWatchdog();
384
+ try {
385
+ if (this.#streamSnapshots && !this.#streamingUnsupported) {
386
+ try {
387
+ await this.#consumeSnapshotStream(watchdog.signal);
388
+ backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
389
+ } catch (error) {
390
+ if (this.#closed || this.#backgroundAbort.signal.aborted) break;
391
+ if (watchdog.idled()) continue;
392
+ if (error instanceof AuthBrokerStreamUnsupportedError) {
393
+ this.#streamingUnsupported = true;
394
+ logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
395
+ continue;
396
+ }
397
+ logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
398
+ await this.#backoffWait(backoffMs);
399
+ backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
400
+ }
401
+ continue;
402
+ }
353
403
  try {
354
- await this.#consumeSnapshotStream();
404
+ const result = await this.#client.fetchSnapshot({
405
+ ifGenerationGt: this.#generation,
406
+ waitMs: BACKGROUND_WAIT_MS,
407
+ signal: watchdog.signal,
408
+ });
409
+ if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
355
410
  backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
356
- continue;
357
411
  } catch (error) {
358
412
  if (this.#closed || this.#backgroundAbort.signal.aborted) break;
359
- if (error instanceof AuthBrokerStreamUnsupportedError) {
360
- this.#streamingUnsupported = true;
361
- logger.debug("auth-broker snapshot stream unsupported; falling back to long-poll");
362
- continue;
363
- }
364
- logger.debug("auth-broker snapshot stream failed; backing off", { error: String(error) });
365
- await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
413
+ if (watchdog.idled()) continue;
414
+ logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
415
+ await this.#backoffWait(backoffMs);
366
416
  backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
367
- continue;
368
417
  }
418
+ } finally {
419
+ watchdog.stop();
369
420
  }
370
- try {
371
- const result = await this.#client.fetchSnapshot({
372
- ifGenerationGt: this.#generation,
373
- waitMs: BACKGROUND_WAIT_MS,
374
- signal: this.#backgroundAbort.signal,
375
- });
376
- if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
377
- backoffMs = BACKGROUND_BACKOFF_INITIAL_MS;
378
- } catch (error) {
379
- if (this.#closed || this.#backgroundAbort.signal.aborted) break;
380
- logger.debug("auth-broker background snapshot sync failed", { error: String(error) });
381
- await scheduler.wait(backoffMs, { signal: this.#backgroundAbort.signal }).catch(() => {});
382
- backoffMs = Math.min(BACKGROUND_BACKOFF_MAX_MS, backoffMs * 2);
421
+ }
422
+ }
423
+
424
+ /** Record a foreground store use; wakes the parked background sync. */
425
+ #noteActivity(): void {
426
+ this.#lastActivityMs = Date.now();
427
+ if (this.#activityWakeup) {
428
+ this.#activityWakeup.resolve();
429
+ this.#activityWakeup = null;
430
+ }
431
+ }
432
+
433
+ #idleRemainingMs(): number {
434
+ return this.#lastActivityMs + this.#backgroundIdleMs - Date.now();
435
+ }
436
+
437
+ /**
438
+ * Abort signal for one background iteration that trips once the store has
439
+ * been idle for `#backgroundIdleMs`. The timer is unref'd: it can only fire
440
+ * while something else keeps the event loop alive — typically our own
441
+ * in-flight broker request, which is exactly what it exists to end.
442
+ */
443
+ #startIdleWatchdog(): { signal: AbortSignal; idled: () => boolean; stop: () => void } {
444
+ const controller = new AbortController();
445
+ let idled = false;
446
+ let timer: Timer | undefined;
447
+ const arm = (): void => {
448
+ const remainingMs = this.#idleRemainingMs();
449
+ if (remainingMs > 0) {
450
+ timer = setTimeout(arm, remainingMs);
451
+ timer.unref?.();
452
+ return;
383
453
  }
454
+ idled = true;
455
+ controller.abort(new AIError.AbortError("auth-broker background sync idle"));
456
+ };
457
+ arm();
458
+ return {
459
+ signal: AbortSignal.any([this.#backgroundAbort.signal, controller.signal]),
460
+ idled: () => idled,
461
+ stop: () => clearTimeout(timer),
462
+ };
463
+ }
464
+
465
+ /**
466
+ * Backoff sleep on an unref'd timer so retry waits never pin the process;
467
+ * in an otherwise-exiting process the timer simply never fires and the
468
+ * suspended loop holds no handles. Wakes early on `close()`.
469
+ */
470
+ async #backoffWait(ms: number): Promise<void> {
471
+ const { promise, resolve } = Promise.withResolvers<void>();
472
+ const timer = setTimeout(resolve, ms);
473
+ timer.unref?.();
474
+ const onAbort = (): void => {
475
+ clearTimeout(timer);
476
+ resolve();
477
+ };
478
+ this.#backgroundAbort.signal.addEventListener("abort", onAbort, { once: true });
479
+ try {
480
+ await promise;
481
+ } finally {
482
+ this.#backgroundAbort.signal.removeEventListener("abort", onAbort);
384
483
  }
385
484
  }
386
485
 
387
- async #consumeSnapshotStream(): Promise<void> {
388
- const iterator = this.#client.openSnapshotStream({ signal: this.#backgroundAbort.signal });
486
+ async #consumeSnapshotStream(signal: AbortSignal): Promise<void> {
487
+ const iterator = this.#client.openSnapshotStream({ signal });
389
488
  try {
390
489
  for await (const event of iterator) {
391
- if (this.#closed || this.#backgroundAbort.signal.aborted) break;
490
+ if (this.#closed || signal.aborted) break;
392
491
  this.#streamingActive = true;
393
492
  this.#applyStreamEvent(event);
394
493
  }
@@ -469,12 +568,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
469
568
 
470
569
  /** Re-hydrate the in-memory snapshot from the broker. */
471
570
  async refreshSnapshot(): Promise<SnapshotResponse> {
571
+ this.#noteActivity();
472
572
  const result = await this.#client.fetchSnapshot();
473
573
  if (result.status === 200) this.#applySnapshot(result.snapshot, result.generation);
474
574
  return this.#snapshot;
475
575
  }
476
576
 
477
577
  listAuthCredentials(provider?: string): StoredAuthCredential[] {
578
+ this.#noteActivity();
478
579
  const out: StoredAuthCredential[] = [];
479
580
  for (const entry of this.#snapshot.credentials) {
480
581
  if (provider !== undefined && entry.provider !== provider) continue;
@@ -490,10 +591,12 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
490
591
 
491
592
  /** Broker-backed disabled tombstones; empty against brokers predating the endpoint. */
492
593
  listDisabledCredentials(provider?: string, signal?: AbortSignal): Promise<DisabledCredentialSummary[]> {
594
+ this.#noteActivity();
493
595
  return this.#client.listDisabledCredentials(provider, signal);
494
596
  }
495
597
 
496
598
  getCredentialBlock(credentialId: number, providerKey: string, blockScope: string): number | undefined {
599
+ this.#noteActivity();
497
600
  const nowMs = Date.now();
498
601
  this.cleanExpiredCredentialBlocks(nowMs);
499
602
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
@@ -511,6 +614,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
511
614
  }
512
615
 
513
616
  listCredentialBlocks(credentialIds: readonly number[]): StoredCredentialBlock[] {
617
+ this.#noteActivity();
514
618
  const nowMs = Date.now();
515
619
  this.cleanExpiredCredentialBlocks(nowMs);
516
620
  const ids = new Set(credentialIds);
@@ -533,6 +637,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
533
637
  }
534
638
 
535
639
  upsertCredentialBlock(block: StoredCredentialBlock): void {
640
+ this.#noteActivity();
536
641
  this.#upsertSnapshotBlock(block);
537
642
  this.#invalidateUsageCache();
538
643
  this.#credentialBlockReconcileAfter.set(
@@ -562,6 +667,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
562
667
  }
563
668
 
564
669
  deleteCredentialBlocks(credentialId: number): void {
670
+ this.#noteActivity();
565
671
  this.#deleteSnapshotBlocks(credentialId);
566
672
  for (const key of this.#credentialBlockReconcileAfter.keys()) {
567
673
  if (key.startsWith(`${credentialId}\0`)) this.#credentialBlockReconcileAfter.delete(key);
@@ -593,6 +699,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
593
699
  * authoritative row, so we just mirror it.
594
700
  */
595
701
  updateAuthCredential(id: number, credential: AuthCredential): void {
702
+ this.#noteActivity();
596
703
  for (const entry of this.#snapshot.credentials) {
597
704
  if (entry.id !== id) continue;
598
705
  entry.credential = credential as typeof entry.credential;
@@ -601,6 +708,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
601
708
  }
602
709
 
603
710
  deleteAuthCredential(id: number, disabledCause: string): void {
711
+ this.#noteActivity();
604
712
  this.#removeCredentialById(id);
605
713
  // Fire-and-forget: tell the broker to persist the disable.
606
714
  this.#client.disableCredential(id, disabledCause).catch(error => {
@@ -609,6 +717,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
609
717
  }
610
718
 
611
719
  async deleteAuthCredentialRemote(id: number, disabledCause: string): Promise<boolean> {
720
+ this.#noteActivity();
612
721
  const found = this.#snapshot.credentials.some(entry => entry.id === id);
613
722
  if (!found) return false;
614
723
  await this.#client.disableCredential(id, disabledCause);
@@ -618,6 +727,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
618
727
  }
619
728
 
620
729
  tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
730
+ this.#noteActivity();
621
731
  const found = this.#snapshot.credentials.find(entry => entry.id === id);
622
732
  if (!found) return false;
623
733
  this.deleteAuthCredential(id, disabledCause);
@@ -625,6 +735,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
625
735
  }
626
736
 
627
737
  async waitForFreshSnapshot(maxWaitMs: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
738
+ this.#noteActivity();
628
739
  const previousGeneration = this.#generation;
629
740
  const result = await this.#client.fetchSnapshot({
630
741
  ifGenerationGt: this.#generation,
@@ -636,6 +747,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
636
747
  }
637
748
 
638
749
  async prepareForRequest(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<boolean> {
750
+ this.#noteActivity();
639
751
  const entry = this.#snapshot.credentials.find(candidate => candidate.id === credentialId);
640
752
  if (entry?.credential.type !== "oauth" || entry.rotatesInMs === null) return false;
641
753
  const remainingMs = this.#snapshotReceivedAt + entry.rotatesInMs - Date.now();
@@ -644,6 +756,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
644
756
  }
645
757
 
646
758
  async markCredentialSuspect(credentialId: number, opts: { signal?: AbortSignal } = {}): Promise<void> {
759
+ this.#noteActivity();
647
760
  const { entry } = await this.#client.refreshCredential(credentialId, opts.signal);
648
761
  if (entry.credential.type !== "oauth") {
649
762
  throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
@@ -682,6 +795,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
682
795
  * any concurrent peer (refresh, generation bump) stays in sync.
683
796
  */
684
797
  async upsertAuthCredentialRemote(provider: string, credential: AuthCredential): Promise<StoredAuthCredential[]> {
798
+ this.#noteActivity();
685
799
  const { entries } = await this.#client.uploadCredential(provider, credential);
686
800
  this.#applyProviderEntries(provider, entries);
687
801
  this.#maybeRefreshSnapshot("upload");
@@ -866,6 +980,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
866
980
  }
867
981
 
868
982
  getCache(key: string): string | null {
983
+ this.#noteActivity();
869
984
  const entry = this.#cache.get(key);
870
985
  if (!entry) return null;
871
986
  if (entry.expiresAtSec * 1000 <= Date.now()) {
@@ -876,6 +991,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
876
991
  }
877
992
 
878
993
  setCache(key: string, value: string, expiresAtSec: number): void {
994
+ this.#noteActivity();
879
995
  this.#cache.set(key, { value, expiresAtSec });
880
996
  }
881
997
 
@@ -894,6 +1010,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
894
1010
  }
895
1011
 
896
1012
  async invalidateUsageCache(signal?: AbortSignal): Promise<void> {
1013
+ this.#noteActivity();
897
1014
  this.#invalidateUsageCache();
898
1015
  await this.#client.notifyUsageStale(signal).catch(err => {
899
1016
  logger.warn("auth-broker notification of stale usage failed", { error: String(err) });
@@ -918,6 +1035,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
918
1035
  _credential: OAuthCredential,
919
1036
  signal?: AbortSignal,
920
1037
  ): Promise<OAuthCredentials> {
1038
+ this.#noteActivity();
921
1039
  const { entry } = await this.#client.refreshCredential(credentialId, signal);
922
1040
  if (entry.credential.type !== "oauth") {
923
1041
  throw new AIError.AuthBrokerError(`Broker returned non-OAuth credential for id=${credentialId}`);
@@ -951,6 +1069,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
951
1069
  * residential laptop is, so all credentials surface every cycle.
952
1070
  */
953
1071
  async fetchUsageReports(signal?: AbortSignal): Promise<UsageReport[] | null> {
1072
+ this.#noteActivity();
954
1073
  const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
955
1074
  if (!reports) return null;
956
1075
  return this.#filterUsageReports(this.#applyUsageOverlays(reports));
@@ -970,6 +1089,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
970
1089
  credential: OAuthCredential,
971
1090
  signal?: AbortSignal,
972
1091
  ): Promise<UsageReport | null> {
1092
+ this.#noteActivity();
973
1093
  const reports = await this.#raceWithSignal(this.#loadUsageReports(), signal);
974
1094
  const visibleReports = reports ? this.#filterUsageReports(reports) : null;
975
1095
  const matched = visibleReports ? matchUsageReport(visibleReports, provider, credential) : null;
@@ -1014,6 +1134,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1014
1134
  }
1015
1135
 
1016
1136
  ingestUsageReport(provider: Provider, credential: OAuthCredential, report: UsageReport): boolean {
1137
+ this.#noteActivity();
1017
1138
  const key = usageOverlayKey(provider, credential);
1018
1139
  if (!key) return false;
1019
1140
  const activeOverlay = this.#getActiveUsageOverlay(provider, credential);
@@ -1202,6 +1323,8 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1202
1323
  if (this.#closed) return;
1203
1324
  this.#closed = true;
1204
1325
  this.#backgroundAbort.abort();
1326
+ this.#activityWakeup?.resolve();
1327
+ this.#activityWakeup = null;
1205
1328
  if (this.#observedUsageTimer !== undefined) {
1206
1329
  clearTimeout(this.#observedUsageTimer);
1207
1330
  this.#observedUsageTimer = undefined;