@linxiraos/pi-utils 1.1.22 → 1.1.24

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/src/dirs.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
- * Centralized path helpers for zeta config directories.
2
+ * Centralized path helpers for zeta-c config directories.
3
3
  *
4
4
  * Uses PI_CONFIG_DIR (default ".zeta") for the config root and
5
5
  * ZETA_CODING_AGENT_DIR to override the agent directory.
6
6
  *
7
7
  * On Linux, if XDG_DATA_HOME / XDG_STATE_HOME / XDG_CACHE_HOME environment
8
8
  * variables are set, paths are redirected to XDG-compliant locations under
9
- * $XDG_*_HOME/zeta/. This requires running `zeta config migrate` first to
9
+ * $XDG_*_HOME/zeta/. This requires running `zeta-c config migrate` first to
10
10
  * move data to the new locations. No filesystem existence checks are performed
11
11
  * — if the env var is set, zeta trusts that the migration has been done.
12
12
  */
@@ -40,7 +40,7 @@ export const MAIN_CONFIG_FILENAMES = ["config.yml", "config.yaml"] as const;
40
40
  /** Version (e.g. "1.0.0") */
41
41
  export const VERSION: string = version;
42
42
 
43
- /** Default User-Agent header string (e.g. "zeta/1.1.5") */
43
+ /** Default User-Agent header string (e.g. "zeta/17.2.12") */
44
44
  export const USER_AGENT = `zeta/${VERSION}`;
45
45
 
46
46
  /** Minimum Bun version */
@@ -91,8 +91,8 @@ export function normalizeProfileName(profile: string | undefined): string | unde
91
91
  * validation/normalization to {@link normalizeProfileName} (which throws on a
92
92
  * syntactically invalid value).
93
93
  */
94
- export function resolveProfileEnv(profile: string | undefined): string | undefined {
95
- return normalizeProfileName(profile);
94
+ export function resolveProfileEnv(zeta: string | undefined): string | undefined {
95
+ return normalizeProfileName(zeta);
96
96
  }
97
97
 
98
98
  function getProfileFromEnv(): string | undefined {
@@ -330,6 +330,7 @@ class DirResolver {
330
330
  // With XDG on Linux, they point to $XDG_*_HOME/zeta/.
331
331
  readonly #rootDirs: Record<XdgCategory, string>;
332
332
  readonly #agentDirs: Record<XdgCategory, string>;
333
+ readonly #baseRootDirs: Record<XdgCategory, string>;
333
334
 
334
335
  readonly #rootCache = new Map<string, string>();
335
336
  readonly #agentCache = new Map<string, string>();
@@ -344,7 +345,7 @@ class DirResolver {
344
345
  const isDefault = this.agentDir === defaultAgent;
345
346
 
346
347
  // XDG is a Linux convention. On supported platforms, default profile state
347
- // resolves under $XDG_*_HOME/zeta once `zeta config init-xdg` has migrated
348
+ // resolves under $XDG_*_HOME/zeta once `zeta-c config init-xdg` has migrated
348
349
  // the user's data. Named profiles follow a stricter rule: the XDG choice
349
350
  // is keyed on the profile-specific XDG path, never the base app root.
350
351
  //
@@ -358,7 +359,8 @@ class DirResolver {
358
359
  let xdgData: string | undefined;
359
360
  let xdgState: string | undefined;
360
361
  let xdgCache: string | undefined;
361
- if ((process.platform === "linux" || process.platform === "darwin") && isDefault) {
362
+ const xdgPlatform = process.platform === "linux" || process.platform === "darwin";
363
+ if (xdgPlatform && isDefault) {
362
364
  const resolveIf = (envVar: string) => {
363
365
  const value = process.env[envVar];
364
366
  if (!value) return undefined;
@@ -366,14 +368,10 @@ class DirResolver {
366
368
  const appRoot = path.join(value, APP_NAME);
367
369
  if (profile) {
368
370
  const profilePath = path.join(appRoot, "profiles", profile);
369
- if (fs.existsSync(profilePath)) {
370
- return profilePath;
371
- }
371
+ if (fs.existsSync(profilePath)) return profilePath;
372
372
  return undefined;
373
373
  }
374
- if (fs.existsSync(appRoot)) {
375
- return appRoot;
376
- }
374
+ return fs.existsSync(appRoot) ? appRoot : undefined;
377
375
  } catch {}
378
376
  return undefined;
379
377
  };
@@ -382,6 +380,22 @@ class DirResolver {
382
380
  xdgCache = resolveIf("XDG_CACHE_HOME");
383
381
  }
384
382
 
383
+ // XDG choice for machine-global paths (daemon scopes shared by every
384
+ // process): keyed only on the base app root, independent of both the
385
+ // profile and any agent-dir override, so every zeta process on the machine
386
+ // agrees on one location. These hold process-scoped runtime state
387
+ // (sockets, tokens), so there is no migration to protect.
388
+ const resolveBase = (envVar: string) => {
389
+ if (!xdgPlatform) return undefined;
390
+ const value = process.env[envVar];
391
+ if (!value) return undefined;
392
+ try {
393
+ const appRoot = path.join(value, APP_NAME);
394
+ return fs.existsSync(appRoot) ? appRoot : undefined;
395
+ } catch {}
396
+ return undefined;
397
+ };
398
+
385
399
  this.#rootDirs = {
386
400
  data: xdgData ?? this.configRoot,
387
401
  state: xdgState ?? this.configRoot,
@@ -393,6 +407,17 @@ class DirResolver {
393
407
  state: xdgState ?? this.agentDir,
394
408
  cache: xdgCache ?? this.agentDir,
395
409
  };
410
+ const baseRoot = getBaseConfigRoot();
411
+ this.#baseRootDirs = {
412
+ data: resolveBase("XDG_DATA_HOME") ?? baseRoot,
413
+ state: resolveBase("XDG_STATE_HOME") ?? baseRoot,
414
+ cache: resolveBase("XDG_CACHE_HOME") ?? baseRoot,
415
+ };
416
+ }
417
+
418
+ /** Profile-independent config-root subdirectory, with optional XDG override. Shared across profiles. */
419
+ baseRootSubdir(subdir: string, xdg?: XdgCategory): string {
420
+ return path.join(xdg ? this.#baseRootDirs[xdg] : getBaseConfigRoot(), subdir);
396
421
  }
397
422
 
398
423
  /** Config-root subdirectory, with optional XDG override. */
@@ -429,12 +454,8 @@ class DirResolver {
429
454
  * without exporting it). Returns `undefined` in those cases so reset falls back
430
455
  * to the standard `~/.zeta/agent`.
431
456
  */
432
- function resolvePreProfileAgentDir(
433
- profile: string | undefined,
434
- agentDirEnv: string | undefined,
435
- profileAgentDirSource: string | undefined = profile,
436
- ): string | undefined {
437
- return isProfileDerivedAgentDir(profile ?? profileAgentDirSource, agentDirEnv) ? undefined : agentDirEnv;
457
+ function resolvePreProfileAgentDir(profile: string | undefined, agentDirEnv: string | undefined): string | undefined {
458
+ return isProfileDerivedAgentDir(profile, agentDirEnv) ? undefined : agentDirEnv;
438
459
  }
439
460
 
440
461
  let activeProfile = readProfileFromEnvSafe();
@@ -470,8 +491,9 @@ let dirs = new DirResolver({
470
491
  * with `ZETA_CODING_AGENT_DIR=/custom` then `setProfile("work")` then
471
492
  * `setProfile(undefined)` would silently lose `/custom` and fall back to
472
493
  * `~/.zeta/agent`. Captured at module load — ignoring a profile-derived value
473
- * inherited from a parent's `setProfile` (detected by directory shape in
474
- * {@link resolveActiveAgentDirOverride}) — and refreshed on `setAgentDir`.
494
+ * inherited from a parent's `setProfile` (see {@link resolvePreProfileAgentDir})
495
+ * — and refreshed on `setAgentDir`, since that call is the user explicitly
496
+ * redefining the baseline.
475
497
  */
476
498
  let preProfileAgentDirEnv: string | undefined = resolveActiveAgentDirOverride();
477
499
  // Anchor home for the resolver. Captured at module load to stay stable across
@@ -511,6 +533,7 @@ export function setAgentDir(dir: string): void {
511
533
  activeProfile = undefined;
512
534
  dirs = new DirResolver({ agentDirOverride: dir });
513
535
  process.env.ZETA_CODING_AGENT_DIR = dir;
536
+ preProfileAgentDirEnv = dir;
514
537
  for (const key of PROFILE_ENV_KEYS) {
515
538
  delete process.env[key];
516
539
  }
@@ -540,6 +563,7 @@ export function __resetDirsFromEnvForTests(): void {
540
563
  refreshDirsFromEnv();
541
564
  }
542
565
 
566
+ /** Activate a named profile. Passing undefined or "default" returns to the default profile. */
543
567
  export function setProfile(profile: string | undefined): void {
544
568
  const next = normalizeProfileName(profile);
545
569
  if (next && !activeProfile) {
@@ -603,7 +627,7 @@ export function getLogsDir(): string {
603
627
 
604
628
  /**
605
629
  * Local-timezone `YYYY-MM-DD` day key (zero-padded), formatted exactly like
606
- * the rotating log sink's file naming: log files are named `omp.<day>.<pid>.log`
630
+ * the rotating log sink's file naming: log files are named `zeta.<day>.<pid>.log`
607
631
  * with the LOCAL day, not the UTC day `toISOString()` yields. Anything that
608
632
  * computes "today's" log path or matches same-day log files by name must use
609
633
  * this key, or between local midnight and UTC midnight it points at files that
@@ -645,11 +669,89 @@ export function getPluginsPackageJson(home?: string): string {
645
669
  return path.join(getPluginsDir(home), "package.json");
646
670
  }
647
671
 
648
- /** Plugin lock file (~/.zeta/plugins/omp-plugins.lock.json). */
672
+ /** Plugin lock file (~/.zeta/plugins/omp-plugins.lock.json). The on-disk
673
+ * filename stays upstream-compatible so existing installs keep their runtime
674
+ * state (enabled features, versions) across upgrades — never rebrand it. */
649
675
  export function getPluginsLockfile(home?: string): string {
650
676
  return path.join(getPluginsDir(home), "omp-plugins.lock.json");
651
677
  }
652
678
 
679
+ /**
680
+ * Normalize a plugin id into a safe single path segment (spec §4.2): lowercased,
681
+ * every character outside `[a-z0-9._-]` (including `/` and `\`, so scoped npm
682
+ * names like `@scope/name` collapse to `-scope-name`) replaced with `-`.
683
+ * Guarantees the result cannot traverse: it never contains a separator, and
684
+ * degenerate segments (`.`, `..`, empty) throw instead of escaping.
685
+ */
686
+ export function normalizePluginId(pluginId: string): string {
687
+ const normalized = pluginId.toLowerCase().replace(/[^a-z0-9._-]/g, "-");
688
+ if (normalized.length === 0 || normalized === "." || normalized === "..") {
689
+ throw new TypeError(`Invalid plugin id: "${pluginId}"`);
690
+ }
691
+ return normalized;
692
+ }
693
+
694
+ function getPluginTierDir(tier: "data" | "cache" | "state", pluginId: string, home?: string): string {
695
+ return path.join(getPluginsDir(home), tier, normalizePluginId(pluginId));
696
+ }
697
+
698
+ /** Per-plugin persistent data dir (~/.zeta/plugins/data/<id>). User data: cleared on uninstall, kept across upgrades. */
699
+ export function getPluginDataDir(pluginId: string, home?: string): string {
700
+ return getPluginTierDir("data", pluginId, home);
701
+ }
702
+
703
+ /** Per-plugin rebuildable cache dir (~/.zeta/plugins/cache/<id>). May be wiped by GC at any time. */
704
+ export function getPluginCacheDir(pluginId: string, home?: string): string {
705
+ return getPluginTierDir("cache", pluginId, home);
706
+ }
707
+
708
+ /** Per-plugin runtime state dir (~/.zeta/plugins/state/<id>). Locks, cursors, migration markers. */
709
+ export function getPluginStateDir(pluginId: string, home?: string): string {
710
+ return getPluginTierDir("state", pluginId, home);
711
+ }
712
+
713
+ /**
714
+ * Adopt a legacy config file into its new home: one-shot best-effort copy
715
+ * (spec §4.5). Copies only when the target does not exist yet and the legacy
716
+ * file does; the legacy file is left in place for older versions sharing the
717
+ * profile. Returns whether a copy happened this call. Opportunistic: a copy
718
+ * race or unwritable target dir falls back to a fresh file at the new path.
719
+ */
720
+ export function adoptLegacyFile(legacyPath: string, targetPath: string): boolean {
721
+ if (targetPath === legacyPath) return false;
722
+ try {
723
+ if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) return false;
724
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
725
+ fs.copyFileSync(legacyPath, targetPath, fs.constants.COPYFILE_EXCL);
726
+ return true;
727
+ } catch {
728
+ // Opportunistic: a copy race or unwritable XDG dir falls back to a fresh
729
+ // file at the new path — the pre-adoption behavior.
730
+ return false;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * One-shot legacy migration with a marker file (spec §4.5, the
736
+ * pi-messenger `migrations/` precedent, generalized): the first call copies
737
+ * `legacyPath` → `targetPath` (when legacy data exists) and writes a
738
+ * `migratedAt` marker at `markerPath`; every later call is a no-op regardless
739
+ * of filesystem state. Returns whether a copy happened this call.
740
+ */
741
+ export function adoptLegacyFileOnce(legacyPath: string, targetPath: string, markerPath: string): boolean {
742
+ try {
743
+ if (fs.existsSync(markerPath)) return false;
744
+ const adopted = adoptLegacyFile(legacyPath, targetPath);
745
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
746
+ fs.writeFileSync(markerPath, `${JSON.stringify({ migratedAt: new Date().toISOString() })}\n`);
747
+ return adopted;
748
+ } catch {
749
+ // Opportunistic: an unwritable marker dir must not crash callers; the
750
+ // next run retries the migration (still idempotent via COPYFILE_EXCL).
751
+ return false;
752
+ }
753
+ }
754
+
653
755
  /** Get the remote mount directory (~/.zeta/remote). */
654
756
  export function getRemoteDir(): string {
655
757
  return dirs.rootSubdir("remote", "data");
@@ -660,8 +762,8 @@ export function getRemoteDir(): string {
660
762
  * empty/whitespace input or a path that is still relative after expansion.
661
763
  *
662
764
  * A worktree base is process-global and consumed by both creation
663
- * (PR checkout, task isolation) and cleanup (`omp worktree`). A relative value
664
- * would resolve against whatever cwd happened to launch `omp`, so checkout and
765
+ * (PR checkout, task isolation) and cleanup (`zeta worktree`). A relative value
766
+ * would resolve against whatever cwd happened to launch `zeta`, so checkout and
665
767
  * cleanup could disagree — we refuse it rather than silently bind it to cwd.
666
768
  */
667
769
  function resolveWorktreeBase(value: string | undefined): string | undefined {
@@ -677,7 +779,7 @@ let worktreesDirOverride: string | undefined;
677
779
 
678
780
  /**
679
781
  * Relocate the base directory for agent-managed worktrees (PR checkouts, task
680
- * isolation, and `omp worktree` cleanup all read the same base). Driven by the
782
+ * isolation, and `zeta worktree` cleanup all read the same base). Driven by the
681
783
  * `worktree.base` setting in coding-agent; pass `undefined`/empty to clear and
682
784
  * fall back to `OMP_WORKTREE_DIR` or the `~/.zeta/wt` default.
683
785
  *
@@ -898,15 +1000,21 @@ export function getTinyModelsCacheDir(agentDir?: string): string {
898
1000
  export function getDocumentConversionCacheDir(agentDir?: string): string {
899
1001
  return dirs.agentSubdir(agentDir, path.join("cache", "document-conversions"), "cache");
900
1002
  }
901
- /** Get the per-project composer speculative cache directory (~/.zeta/agent/cache/composer; XDG default: $XDG_CACHE_HOME/zeta/cache/composer). */
902
- export function getComposerCacheDir(agentDir?: string): string {
903
- return dirs.agentSubdir(agentDir, path.join("cache", "composer"), "cache");
904
- }
905
-
906
1003
  /** Get the composer speculative cache database (~/.zeta/agent/cache/composer.db; XDG default: $XDG_CACHE_HOME/zeta/cache/composer.db). */
907
1004
  export function getComposerCacheDbPath(agentDir?: string): string {
908
1005
  return dirs.agentSubdir(agentDir, path.join("cache", "composer.db"), "cache");
909
1006
  }
1007
+ /** Get the skill descriptions database (~/.zeta/agent/skill-descriptions.db; XDG default: $XDG_DATA_HOME/zeta/skill-descriptions.db). */
1008
+ export function getSkillDescriptionsDbPath(agentDir?: string): string {
1009
+ return dirs.agentSubdir(agentDir, "skill-descriptions.db", "data");
1010
+ }
1011
+ /** Get the text-predict engine state directory (~/.zeta/agent/predict/<method>; XDG default: $XDG_DATA_HOME/zeta/predict/<method>). Adopts legacy engine state on first XDG resolution. */
1012
+ export function getPredictStateDir(agentDir: string | undefined, method: string): string {
1013
+ const subdir = path.join("predict", method);
1014
+ const stateDir = dirs.agentSubdir(agentDir, subdir, "data");
1015
+ adoptLegacyDir(path.join(agentDir ?? dirs.agentDir, subdir), stateDir);
1016
+ return stateDir;
1017
+ }
910
1018
 
911
1019
  /** Get the sessions directory (~/.zeta/agent/sessions). */
912
1020
  export function getSessionsDir(agentDir?: string): string {
@@ -977,10 +1085,10 @@ export function getDebugLogPath(agentDir?: string): string {
977
1085
  * Best-effort one-time copy of a legacy config-root file to its redirected XDG
978
1086
  * location. Existing installs that enable XDG after the file was created keep
979
1087
  * their data (e.g. a placeholder key whose loss would break deobfuscation of
980
- * persisted transcripts). The legacy file is left in place for older omp
1088
+ * persisted transcripts). The legacy file is left in place for older zeta
981
1089
  * versions sharing the profile.
982
1090
  */
983
- function adoptLegacyFile(legacyPath: string, targetPath: string): void {
1091
+ function adoptLegacyFileViaStaging(legacyPath: string, targetPath: string): void {
984
1092
  if (targetPath === legacyPath) return;
985
1093
  try {
986
1094
  if (fs.existsSync(targetPath) || !fs.existsSync(legacyPath)) return;
@@ -992,10 +1100,33 @@ function adoptLegacyFile(legacyPath: string, targetPath: string): void {
992
1100
  }
993
1101
  }
994
1102
 
1103
+ /**
1104
+ * Best-effort one-time copy of a legacy directory to its redirected XDG
1105
+ * location, so learned state survives enabling XDG. The copy is staged next to
1106
+ * the target and renamed into place, so a reader never sees a partial tree and
1107
+ * a concurrent adopter cannot clobber a finished one. The legacy directory is
1108
+ * left in place for older zeta versions sharing the profile.
1109
+ */
1110
+ function adoptLegacyDir(legacyPath: string, targetPath: string): void {
1111
+ if (targetPath === legacyPath) return;
1112
+ const staging = `${targetPath}.adopt-${process.pid}`;
1113
+ try {
1114
+ if (fs.existsSync(targetPath) || !fs.statSync(legacyPath, { throwIfNoEntry: false })?.isDirectory()) return;
1115
+ fs.mkdirSync(path.dirname(targetPath), { recursive: true });
1116
+ fs.rmSync(staging, { recursive: true, force: true });
1117
+ fs.cpSync(legacyPath, staging, { recursive: true });
1118
+ fs.renameSync(staging, targetPath);
1119
+ } catch {
1120
+ // Opportunistic: a lost race or unwritable XDG dir falls back to fresh
1121
+ // state at the new path — the pre-adoption behavior.
1122
+ fs.rmSync(staging, { recursive: true, force: true });
1123
+ }
1124
+ }
1125
+
995
1126
  /** Get the secret placeholder key path (~/.zeta/agent/secret-placeholder.key; XDG default: $XDG_STATE_HOME/zeta/secret-placeholder.key). Adopts a legacy key on first XDG resolution. */
996
1127
  export function getSecretPlaceholderKeyPath(): string {
997
1128
  const keyPath = dirs.agentSubdir(undefined, "secret-placeholder.key", "state");
998
- adoptLegacyFile(path.join(dirs.agentDir, "secret-placeholder.key"), keyPath);
1129
+ adoptLegacyFileViaStaging(path.join(dirs.agentDir, "secret-placeholder.key"), keyPath);
999
1130
  return keyPath;
1000
1131
  }
1001
1132
 
@@ -1015,9 +1146,9 @@ export function getDaemonRuntimeDir(projectDir: string): string {
1015
1146
  return path.join(getDaemonRuntimeRoot(), key);
1016
1147
  }
1017
1148
 
1018
- /** Root directory containing every machine-global daemon service scope. */
1149
+ /** Root directory containing every machine-global daemon service scope (~/.zeta/run/daemons/global; XDG default: $XDG_STATE_HOME/zeta/run/daemons/global). Shared across profiles. */
1019
1150
  export function getGlobalDaemonRuntimeRoot(): string {
1020
- return path.join(getBaseConfigRoot(), "run", "daemons", "global");
1151
+ return dirs.baseRootSubdir(path.join("run", "daemons", "global"), "state");
1021
1152
  }
1022
1153
 
1023
1154
  /** Get a profile-independent runtime directory for a machine-global daemon service. */
@@ -1036,7 +1167,7 @@ export function getProviderInFlightRoot(): string {
1036
1167
  /** Get the marketplaces registry path (~/.zeta/marketplaces.json; XDG default: $XDG_DATA_HOME/zeta/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
1037
1168
  export function getMarketplacesRegistryPath(): string {
1038
1169
  const registryPath = dirs.rootSubdir("marketplaces.json", "data");
1039
- adoptLegacyFile(path.join(dirs.configRoot, "marketplaces.json"), registryPath);
1170
+ adoptLegacyFileViaStaging(path.join(dirs.configRoot, "marketplaces.json"), registryPath);
1040
1171
  return registryPath;
1041
1172
  }
1042
1173
 
@@ -1049,6 +1180,16 @@ export function getProjectModulesDir(cwd: string = getProjectDir()): string {
1049
1180
  return path.join(getProjectAgentDir(cwd), "modules");
1050
1181
  }
1051
1182
 
1183
+ /** Get the project-level tracking directory (<project>/.zeta/tracking). */
1184
+ export function getProjectTrackingDir(cwd: string = getProjectDir()): string {
1185
+ return path.join(getProjectAgentDir(cwd), "tracking");
1186
+ }
1187
+
1188
+ /** Get the global tracking index path (~/.zeta/agent/tracking-index.json). */
1189
+ export function getTrackingIndexPath(agentDir?: string): string {
1190
+ return path.join(agentDir ?? getAgentDir(), "tracking-index.json");
1191
+ }
1192
+
1052
1193
  /** Get the project-level prompts directory (.zeta/prompts). */
1053
1194
  export function getProjectPromptsDir(cwd: string = getProjectDir()): string {
1054
1195
  return path.join(getProjectAgentDir(cwd), "prompts");
@@ -1163,16 +1304,6 @@ export function getInstallId(): string {
1163
1304
  return next;
1164
1305
  }
1165
1306
 
1166
- /** Get the project-level tracking directory (<project>/.zeta/tracking). */
1167
- export function getProjectTrackingDir(cwd: string = getProjectDir()): string {
1168
- return path.join(getProjectAgentDir(cwd), "tracking");
1169
- }
1170
-
1171
- /** Get the global tracking index path (~/.zeta/agent/tracking-index.json). */
1172
- export function getTrackingIndexPath(agentDir?: string): string {
1173
- return path.join(agentDir ?? getAgentDir(), "tracking-index.json");
1174
- }
1175
-
1176
1307
  /** Test-only: clear cached install id. Never call from production code. */
1177
1308
  export function __resetInstallIdCacheForTests(): void {
1178
1309
  cachedInstallId = null;
package/src/file-lock.ts CHANGED
@@ -35,7 +35,14 @@ function getLockPath(filePath: string): string {
35
35
 
36
36
  function tryAcquireLock(lockPath: string): NativeFileLock | null {
37
37
  const lock = NativeFileLock.tryAcquire(lockPath);
38
- return lock.acquired ? lock : null;
38
+ if (lock.acquired) return lock;
39
+ lock.release();
40
+ return null;
41
+ }
42
+
43
+ /** Acquire an exclusive lease without waiting; `null` while another holder owns it. */
44
+ export function tryAcquireFileLock(filePath: string): FileLockHandle | null {
45
+ return tryAcquireLock(getLockPath(filePath));
39
46
  }
40
47
 
41
48
  /** Acquire an exclusive lease; callers must release it when their operation ends. */
package/src/format.ts CHANGED
@@ -127,3 +127,8 @@ export function pluralize(label: string, count: number): string {
127
127
  export function formatPercent(ratio: number): string {
128
128
  return `${(ratio * 100).toFixed(1)}%`;
129
129
  }
130
+
131
+ /** Round premium-request counters without losing legitimate fractional requests. */
132
+ export function normalizePremiumRequests(value: number): number {
133
+ return Math.round((value + Number.EPSILON) * 100) / 100;
134
+ }