@centerforagenticai/pi-multi-account 0.1.1

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.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
@@ -0,0 +1,215 @@
1
+ import { normalizeLabel } from "./account-labels.js";
2
+ import type { ManagedFamily } from "./config.js";
3
+ import { LOGICAL_PROVIDER_ID } from "./models-declaration.js";
4
+
5
+ export type LogicalRouteIndicatorPhase =
6
+ | "waiting"
7
+ | "trying"
8
+ | "completed"
9
+ | "failed"
10
+ | "cancelled";
11
+
12
+ export type LogicalRouteIndicatorRoute = Readonly<{
13
+ providerId: string;
14
+ family: ManagedFamily;
15
+ }>;
16
+
17
+ export type LogicalRouteUsage =
18
+ | Readonly<{
19
+ status: "fresh";
20
+ utilization?: number;
21
+ remainingRequests?: number;
22
+ remainingTokens?: number;
23
+ recoveryAtMs?: number;
24
+ }>
25
+ | Readonly<{ status: "stale" }>
26
+ | Readonly<{ status: "missing" }>;
27
+
28
+ export interface LogicalRouteIndicatorAttempt {
29
+ completed(): void;
30
+ failed(): void;
31
+ cancelled(): void;
32
+ }
33
+
34
+ export interface LogicalRouteIndicator {
35
+ modelSelected(providerId: string | undefined): void;
36
+ beginAttempt(route: LogicalRouteIndicatorRoute): LogicalRouteIndicatorAttempt;
37
+ usageChanged(providerId: string): void;
38
+ settle(): void;
39
+ shutdown(): void;
40
+ }
41
+
42
+ export interface LogicalRouteIndicatorDependencies {
43
+ publish(text: string | undefined): void;
44
+ usage(providerId: string, nowMs: number): LogicalRouteUsage;
45
+ configuredLabel(providerId: string): string | undefined;
46
+ now(): number;
47
+ }
48
+
49
+ type DisplayedAttempt = Readonly<{
50
+ phase: Exclude<LogicalRouteIndicatorPhase, "waiting">;
51
+ route: LogicalRouteIndicatorRoute;
52
+ }>;
53
+
54
+ const NOOP_ATTEMPT: LogicalRouteIndicatorAttempt = Object.freeze({
55
+ completed() {},
56
+ failed() {},
57
+ cancelled() {},
58
+ });
59
+
60
+ function validUtilization(value: number | undefined): value is number {
61
+ return (
62
+ typeof value === "number" &&
63
+ Number.isFinite(value) &&
64
+ value >= 0 &&
65
+ value <= 1
66
+ );
67
+ }
68
+
69
+ function validCount(value: number | undefined): value is number {
70
+ return Number.isSafeInteger(value) && (value as number) >= 0;
71
+ }
72
+
73
+ function compactCount(value: number): string {
74
+ if (value < 1_000) return String(value);
75
+ if (value < 1_000_000) {
76
+ const thousands = value / 1_000;
77
+ return `${thousands < 100 ? thousands.toFixed(1) : Math.round(thousands)}k`;
78
+ }
79
+ const millions = value / 1_000_000;
80
+ return `${millions < 100 ? millions.toFixed(1) : Math.round(millions)}M`;
81
+ }
82
+
83
+ function compactDuration(milliseconds: number): string {
84
+ const minute = 60_000;
85
+ const hour = 60 * minute;
86
+ const day = 24 * hour;
87
+ if (milliseconds < hour) return `${Math.max(1, Math.ceil(milliseconds / minute))}m`;
88
+ if (milliseconds < 48 * hour) return `${Math.ceil(milliseconds / hour)}h`;
89
+ return `${Math.ceil(milliseconds / day)}d`;
90
+ }
91
+
92
+ function usageText(usage: LogicalRouteUsage, nowMs: number): string {
93
+ if (usage.status === "missing") return "usage unknown";
94
+ if (usage.status === "stale") return "usage stale";
95
+
96
+ const parts: string[] = [];
97
+ if (validUtilization(usage.utilization)) {
98
+ parts.push(`${Math.round((1 - usage.utilization) * 100)}% left`);
99
+ } else {
100
+ if (validCount(usage.remainingRequests)) {
101
+ parts.push(`${compactCount(usage.remainingRequests)} req left`);
102
+ }
103
+ if (validCount(usage.remainingTokens)) {
104
+ parts.push(`${compactCount(usage.remainingTokens)} tok left`);
105
+ }
106
+ }
107
+ if (parts.length === 0) return "usage stale";
108
+ if (
109
+ typeof usage.recoveryAtMs === "number" &&
110
+ Number.isFinite(usage.recoveryAtMs) &&
111
+ usage.recoveryAtMs > nowMs
112
+ ) {
113
+ parts.push(`resets in ${compactDuration(usage.recoveryAtMs - nowMs)}`);
114
+ }
115
+ return parts.join(" · ");
116
+ }
117
+
118
+ /** Creates one host-neutral route indicator for one extension session. */
119
+ export function createLogicalRouteIndicator(
120
+ dependencies: LogicalRouteIndicatorDependencies,
121
+ ): LogicalRouteIndicator {
122
+ let generation = 0;
123
+ let logicalSelected = false;
124
+ let displayed: "waiting" | DisplayedAttempt | undefined;
125
+
126
+ const publish = (text: string | undefined): void => {
127
+ try {
128
+ dependencies.publish(text);
129
+ } catch {
130
+ // The widget is observational; its sink cannot affect provider lifecycle.
131
+ }
132
+ };
133
+
134
+ const render = (): void => {
135
+ if (!logicalSelected || displayed === undefined) {
136
+ publish(undefined);
137
+ return;
138
+ }
139
+ if (displayed === "waiting") {
140
+ publish("unified(waiting)");
141
+ return;
142
+ }
143
+ const nowMs = dependencies.now();
144
+ let usage: LogicalRouteUsage = { status: "missing" };
145
+ try {
146
+ usage = dependencies.usage(displayed.route.providerId, nowMs);
147
+ } catch {
148
+ // A display projection failure is unknown usage, never a routing failure.
149
+ }
150
+ let label: string | undefined;
151
+ try {
152
+ label = normalizeLabel(
153
+ dependencies.configuredLabel(displayed.route.providerId),
154
+ );
155
+ } catch {
156
+ // Labels are optional presentation data.
157
+ }
158
+ publish(
159
+ `unified(${displayed.route.providerId} · ${usageText(usage, nowMs)}` +
160
+ (label === undefined ? ")" : ` · ${label})`),
161
+ );
162
+ };
163
+
164
+ return {
165
+ modelSelected(providerId) {
166
+ generation += 1;
167
+ logicalSelected = providerId === LOGICAL_PROVIDER_ID;
168
+ displayed = logicalSelected ? "waiting" : undefined;
169
+ render();
170
+ },
171
+ beginAttempt(route) {
172
+ const attemptGeneration = generation;
173
+ if (!logicalSelected) return NOOP_ATTEMPT;
174
+ const attemptRoute: LogicalRouteIndicatorRoute = Object.freeze({
175
+ providerId: route.providerId,
176
+ family: route.family,
177
+ });
178
+ displayed = { phase: "trying", route: attemptRoute };
179
+ render();
180
+ const settleAttempt = (phase: DisplayedAttempt["phase"]): void => {
181
+ if (!logicalSelected || generation !== attemptGeneration) return;
182
+ displayed = { phase, route: attemptRoute };
183
+ render();
184
+ };
185
+ return {
186
+ completed: () => settleAttempt("completed"),
187
+ failed: () => settleAttempt("failed"),
188
+ cancelled: () => settleAttempt("cancelled"),
189
+ };
190
+ },
191
+ usageChanged(providerId) {
192
+ if (
193
+ !logicalSelected ||
194
+ typeof displayed !== "object" ||
195
+ displayed.route.providerId !== providerId
196
+ ) {
197
+ return;
198
+ }
199
+ render();
200
+ },
201
+ settle() {
202
+ generation += 1;
203
+ if (typeof displayed === "object" && displayed.phase === "trying") {
204
+ displayed = { phase: "failed", route: displayed.route };
205
+ render();
206
+ }
207
+ },
208
+ shutdown() {
209
+ generation += 1;
210
+ logicalSelected = false;
211
+ displayed = undefined;
212
+ publish(undefined);
213
+ },
214
+ };
215
+ }
@@ -0,0 +1,445 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ fstatSync,
5
+ ftruncateSync,
6
+ lstatSync,
7
+ fsyncSync,
8
+ linkSync,
9
+ mkdirSync,
10
+ openSync,
11
+ readFileSync,
12
+ renameSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { randomBytes, randomUUID } from "node:crypto";
17
+ import { hostname as readHostname } from "node:os";
18
+ import { dirname } from "node:path";
19
+
20
+ const DEFAULT_TTL_MS = 30_000;
21
+ const MAX_RECORD_BYTES = 2_048;
22
+ const TOKEN_PATTERN = /^[a-f0-9]{16,128}$/;
23
+
24
+ export interface MachineLeaseRecord {
25
+ readonly pid: number;
26
+ readonly hostname: string;
27
+ readonly token: string;
28
+ readonly acquiredAtMs: number;
29
+ readonly expiresAtMs: number;
30
+ }
31
+
32
+ export interface MachineLeaseOptions {
33
+ readonly lockPath: string;
34
+ readonly ttlMs?: number;
35
+ /**
36
+ * Wall clock used for expiry and for the malformed-record age gate. It must
37
+ * be the same clock as filesystem mtime: the age gate subtracts an mtime
38
+ * from this, so a clock running ahead of the filesystem ages every record
39
+ * and can reclaim a live one.
40
+ */
41
+ readonly now?: () => number;
42
+ readonly pid?: number;
43
+ readonly hostname?: string;
44
+ readonly tokenFactory?: () => string;
45
+ /** Test seam for pausing a stale reclaim before its atomic rename. */
46
+ readonly beforeReclaimRename?: () => void;
47
+ /** Test seam for pausing a stale reclaim before it restores the moved record. */
48
+ readonly beforeReclaimRestore?: () => void;
49
+ /** Test seam for inspecting the complete record after fsync and before publication. */
50
+ readonly beforePublish?: (temporaryPath: string) => void;
51
+ /** Test seam for racing the moved record between release's rename and its verification. */
52
+ readonly beforeReleaseVerify?: (tombstonePath: string) => void;
53
+ /**
54
+ * Reclaim a lock whose stored record cannot be read, so a contentless or
55
+ * corrupt lock recovers instead of refusing every caller forever. The
56
+ * reclaim is additionally gated on a regular file, a finite mtime, an age of
57
+ * at least this lease's ttl, and the same move-verify-unlink identity check
58
+ * as a stale reclaim, so it can never displace a live holder.
59
+ *
60
+ * That guarantee assumes `now` is the same wall clock as filesystem mtime.
61
+ * The age gate compares the two directly, so a caller injecting a clock
62
+ * ahead of the filesystem defeats it. For a valid record the gate is
63
+ * equivalent to the expiry check anyway, because mtime is the write time
64
+ * and `expiresAtMs` is that write time plus the ttl, so reclaiming a
65
+ * malformed record grants nothing an expired one would not.
66
+ */
67
+ readonly reclaimMalformed?: boolean;
68
+ }
69
+
70
+ export interface MachineLeaseHandle {
71
+ readonly record: MachineLeaseRecord;
72
+ /** The caller must renew before this interval elapses. */
73
+ readonly renewalIntervalMs: number;
74
+ renew(): boolean;
75
+ release(): boolean;
76
+ }
77
+
78
+ function assertFiniteTimestamp(value: number, name: string): void {
79
+ if (!Number.isFinite(value) || value < 0) {
80
+ throw new TypeError(`${name} must be a finite non-negative timestamp.`);
81
+ }
82
+ }
83
+
84
+ function validRecord(value: unknown): value is MachineLeaseRecord {
85
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
86
+ return false;
87
+ }
88
+ const record = value as Record<string, unknown>;
89
+ return (
90
+ Number.isSafeInteger(record.pid) &&
91
+ (record.pid as number) > 0 &&
92
+ typeof record.hostname === "string" &&
93
+ record.hostname.length > 0 &&
94
+ record.hostname.length <= 255 &&
95
+ typeof record.token === "string" &&
96
+ TOKEN_PATTERN.test(record.token) &&
97
+ typeof record.acquiredAtMs === "number" &&
98
+ Number.isFinite(record.acquiredAtMs) &&
99
+ typeof record.expiresAtMs === "number" &&
100
+ Number.isFinite(record.expiresAtMs) &&
101
+ record.acquiredAtMs >= 0 &&
102
+ record.expiresAtMs > record.acquiredAtMs
103
+ );
104
+ }
105
+
106
+ interface RecordSnapshot {
107
+ readonly record: MachineLeaseRecord | undefined;
108
+ readonly raw: string | undefined;
109
+ readonly device: number;
110
+ readonly inode: number;
111
+ readonly size: number;
112
+ readonly mtimeMs: number;
113
+ readonly regular: boolean;
114
+ }
115
+
116
+ function snapshotMetadata(identity: ReturnType<typeof fstatSync>): Pick<RecordSnapshot, "device" | "inode" | "size" | "mtimeMs" | "regular"> {
117
+ return {
118
+ device: Number(identity.dev),
119
+ inode: Number(identity.ino),
120
+ size: Number(identity.size),
121
+ mtimeMs: Number(identity.mtimeMs),
122
+ regular: identity.isFile(),
123
+ };
124
+ }
125
+
126
+ function readRecordSnapshot(lockPath: string): RecordSnapshot | undefined {
127
+ let descriptor: number | undefined;
128
+ let metadata: Pick<RecordSnapshot, "device" | "inode" | "size" | "mtimeMs" | "regular"> | undefined;
129
+ try {
130
+ metadata = snapshotMetadata(lstatSync(lockPath));
131
+ if (!metadata.regular) return { record: undefined, raw: undefined, ...metadata };
132
+ descriptor = openSync(lockPath, "r");
133
+ metadata = snapshotMetadata(fstatSync(descriptor));
134
+ const raw = readFileSync(descriptor, "utf8");
135
+ if (Buffer.byteLength(raw, "utf8") > MAX_RECORD_BYTES) {
136
+ return { record: undefined, raw: undefined, ...metadata };
137
+ }
138
+ let parsed: unknown;
139
+ try {
140
+ parsed = JSON.parse(raw);
141
+ } catch {
142
+ parsed = undefined;
143
+ }
144
+ return { record: validRecord(parsed) ? parsed : undefined, raw, ...metadata };
145
+ } catch {
146
+ // Retain stat metadata for a regular file whose contents are currently
147
+ // unreadable. This is the only evidence history may use for guarded reclaim.
148
+ return metadata === undefined ? undefined : { record: undefined, raw: undefined, ...metadata };
149
+ } finally {
150
+ if (descriptor !== undefined) closeSync(descriptor);
151
+ }
152
+ }
153
+
154
+ function sameSnapshot(left: RecordSnapshot, right: RecordSnapshot): boolean {
155
+ return (
156
+ sameIdentity(left, right) &&
157
+ left.size === right.size &&
158
+ left.mtimeMs === right.mtimeMs &&
159
+ left.regular === right.regular &&
160
+ left.raw === right.raw
161
+ );
162
+ }
163
+
164
+ function sameIdentity(left: RecordSnapshot, right: RecordSnapshot): boolean {
165
+ return left.device === right.device && left.inode === right.inode;
166
+ }
167
+
168
+ /**
169
+ * Puts a record this caller moved aside back at `lockPath`, without ever
170
+ * displacing whatever owns that path now. `rename(2)` cannot express that: it
171
+ * never fails on an existing destination, so it would silently destroy a
172
+ * replacement holder's live lock. Linking refuses with EEXIST instead, and the
173
+ * moved copy is this caller's own isolated file, so it is dropped rather than
174
+ * left as an orphan beside the live lock.
175
+ */
176
+ function restoreMovedRecord(movedPath: string, lockPath: string): void {
177
+ try {
178
+ linkSync(movedPath, lockPath);
179
+ unlinkSync(movedPath);
180
+ } catch (error) {
181
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") return;
182
+ try {
183
+ unlinkSync(movedPath);
184
+ } catch {
185
+ // Isolated cleanup of this caller's own copy is best-effort.
186
+ }
187
+ }
188
+ }
189
+
190
+ function writeRecord(
191
+ lockPath: string,
192
+ record: MachineLeaseRecord,
193
+ expected?: RecordSnapshot,
194
+ ): boolean {
195
+ const encoded = `${JSON.stringify(record)}\n`;
196
+ if (Buffer.byteLength(encoded, "utf8") > MAX_RECORD_BYTES) {
197
+ throw new RangeError("machine lease record exceeds its size bound.");
198
+ }
199
+ const descriptor = openSync(lockPath, "r+");
200
+ try {
201
+ if (expected !== undefined) {
202
+ const identity = fstatSync(descriptor);
203
+ if (identity.dev !== expected.device || identity.ino !== expected.inode) {
204
+ return false;
205
+ }
206
+ }
207
+ ftruncateSync(descriptor, 0);
208
+ writeFileSync(descriptor, encoded, { encoding: "utf8" });
209
+ fsyncSync(descriptor);
210
+ return true;
211
+ } finally {
212
+ closeSync(descriptor);
213
+ }
214
+ }
215
+
216
+ function validateOptions(
217
+ options: MachineLeaseOptions,
218
+ ): Required<
219
+ Pick<
220
+ MachineLeaseOptions,
221
+ "ttlMs" | "now" | "pid" | "hostname" | "tokenFactory"
222
+ >
223
+ > {
224
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
225
+ if (!Number.isFinite(ttlMs) || ttlMs < 2_000 || ttlMs > 86_400_000) {
226
+ throw new RangeError(
227
+ "machine lease ttlMs must be between 2000 and 86400000.",
228
+ );
229
+ }
230
+ const pid = options.pid ?? process.pid;
231
+ if (!Number.isSafeInteger(pid) || pid < 1) {
232
+ throw new RangeError("machine lease pid must be a positive safe integer.");
233
+ }
234
+ const hostname = options.hostname ?? readHostname();
235
+ if (hostname.length === 0 || hostname.length > 255) {
236
+ throw new RangeError(
237
+ "machine lease hostname must contain 1 through 255 characters.",
238
+ );
239
+ }
240
+ return {
241
+ ttlMs,
242
+ now: options.now ?? Date.now,
243
+ pid,
244
+ hostname,
245
+ tokenFactory:
246
+ options.tokenFactory ?? (() => randomBytes(16).toString("hex")),
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Acquires the one machine-global lease represented by `lockPath`.
252
+ *
253
+ * Acquisition uses exclusive creation rather than a read-then-write sequence.
254
+ * A stale, valid record may be removed and then retried. Any caller may opt into
255
+ * identity-checked malformed-record reclaim with `reclaimMalformed`; leases that
256
+ * do not opt in leave malformed records alone and stay refused. Losing the race
257
+ * returns undefined without diagnostics: most callers are not holders.
258
+ */
259
+ export function acquireMachineLease(
260
+ options: MachineLeaseOptions,
261
+ ): MachineLeaseHandle | undefined {
262
+ const normalized = validateOptions(options);
263
+ const directory = dirname(options.lockPath);
264
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
265
+ chmodSync(directory, 0o700);
266
+
267
+ for (let attempt = 0; attempt < 2; attempt += 1) {
268
+ const nowMs = normalized.now();
269
+ assertFiniteTimestamp(nowMs, "now");
270
+ const token = normalized.tokenFactory();
271
+ if (!TOKEN_PATTERN.test(token)) {
272
+ throw new TypeError(
273
+ "machine lease tokenFactory returned an invalid token.",
274
+ );
275
+ }
276
+ const record: MachineLeaseRecord = {
277
+ pid: normalized.pid,
278
+ hostname: normalized.hostname,
279
+ token,
280
+ acquiredAtMs: nowMs,
281
+ expiresAtMs: nowMs + normalized.ttlMs,
282
+ };
283
+ const temporaryPath = `${options.lockPath}.${process.pid}.${randomUUID()}.tmp`;
284
+ let descriptor: number | undefined;
285
+ try {
286
+ descriptor = openSync(temporaryPath, "wx", 0o600);
287
+ writeFileSync(descriptor, `${JSON.stringify(record)}\n`, { encoding: "utf8" });
288
+ fsyncSync(descriptor);
289
+ closeSync(descriptor);
290
+ descriptor = undefined;
291
+ options.beforePublish?.(temporaryPath);
292
+ linkSync(temporaryPath, options.lockPath);
293
+ return createHandle(
294
+ options.lockPath,
295
+ record,
296
+ normalized.ttlMs,
297
+ normalized.now,
298
+ options.beforeReleaseVerify,
299
+ );
300
+ } catch (error) {
301
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
302
+ const observed = readRecordSnapshot(options.lockPath);
303
+ if (!observed) return undefined;
304
+ if (observed.record !== undefined) {
305
+ if (observed.record.expiresAtMs > nowMs) return undefined;
306
+ } else {
307
+ const malformedAgeMs = nowMs - observed.mtimeMs;
308
+ if (
309
+ !options.reclaimMalformed ||
310
+ !observed.regular ||
311
+ !Number.isFinite(observed.mtimeMs) ||
312
+ malformedAgeMs < normalized.ttlMs
313
+ ) return undefined;
314
+ }
315
+ // Move the observed record out of the way atomically, then verify that
316
+ // the moved inode and all ownership fields are still the record we saw.
317
+ // A contender may reclaim and replace the path while this caller stalls;
318
+ // never unlink that fresh holder's record.
319
+ try {
320
+ options.beforeReclaimRename?.();
321
+ } catch {
322
+ return undefined;
323
+ }
324
+ const stalePath = `${options.lockPath}.${process.pid}.${randomUUID()}.stale`;
325
+ try {
326
+ renameSync(options.lockPath, stalePath);
327
+ } catch (renameError) {
328
+ if ((renameError as NodeJS.ErrnoException).code === "ENOENT") continue;
329
+ return undefined;
330
+ }
331
+ const moved = readRecordSnapshot(stalePath);
332
+ if (!moved || !sameSnapshot(observed, moved)) {
333
+ try {
334
+ options.beforeReclaimRestore?.();
335
+ } catch {
336
+ // A failing seam must not change the restore below.
337
+ }
338
+ restoreMovedRecord(stalePath, options.lockPath);
339
+ return undefined;
340
+ }
341
+ try {
342
+ unlinkSync(stalePath);
343
+ } catch {
344
+ restoreMovedRecord(stalePath, options.lockPath);
345
+ return undefined;
346
+ }
347
+ } finally {
348
+ if (descriptor !== undefined) {
349
+ try { closeSync(descriptor); } catch { /* cleanup continues */ }
350
+ }
351
+ try { unlinkSync(temporaryPath); } catch { /* already linked or absent */ }
352
+ }
353
+ }
354
+ return undefined;
355
+ }
356
+
357
+ function createHandle(
358
+ lockPath: string,
359
+ initial: MachineLeaseRecord,
360
+ ttlMs: number,
361
+ now: () => number,
362
+ beforeReleaseVerify?: (tombstonePath: string) => void,
363
+ ): MachineLeaseHandle {
364
+ let record = initial;
365
+ let released = false;
366
+ return {
367
+ get record() {
368
+ return record;
369
+ },
370
+ renewalIntervalMs: Math.max(1_000, Math.floor(ttlMs / 3)),
371
+ renew(): boolean {
372
+ if (released) return false;
373
+ const current = readRecordSnapshot(lockPath);
374
+ if (!current?.record || current.record.token !== record.token) return false;
375
+ const nowMs = now();
376
+ try {
377
+ assertFiniteTimestamp(nowMs, "now");
378
+ if (current.record.expiresAtMs <= nowMs) return false;
379
+ const renewed: MachineLeaseRecord = {
380
+ ...record,
381
+ acquiredAtMs: current.record.acquiredAtMs,
382
+ expiresAtMs: nowMs + ttlMs,
383
+ };
384
+ if (!writeRecord(lockPath, renewed, current)) return false;
385
+ const after = readRecordSnapshot(lockPath);
386
+ if (
387
+ !after?.record ||
388
+ after.record.token !== record.token ||
389
+ !sameIdentity(after, current)
390
+ ) {
391
+ return false;
392
+ }
393
+ record = after.record;
394
+ return true;
395
+ } catch {
396
+ return false;
397
+ }
398
+ },
399
+ release(): boolean {
400
+ if (released) return false;
401
+ const current = readRecordSnapshot(lockPath);
402
+ if (!current?.record || current.record.token !== record.token) {
403
+ released = true;
404
+ return false;
405
+ }
406
+ const tombstone = `${lockPath}.${process.pid}.${randomUUID()}.release`;
407
+ try {
408
+ renameSync(lockPath, tombstone);
409
+ } catch {
410
+ released = true;
411
+ return false;
412
+ }
413
+ try {
414
+ beforeReleaseVerify?.(tombstone);
415
+ } catch {
416
+ // A failing seam must not change the verification below.
417
+ }
418
+ const moved = readRecordSnapshot(tombstone);
419
+ if (
420
+ !moved?.record ||
421
+ moved.record.token !== record.token ||
422
+ !sameIdentity(moved, current)
423
+ ) {
424
+ // This caller moved a record it does not own, so put it back -- but
425
+ // only if nothing has taken the path since. A replacement's live lock
426
+ // is never overwritten, and the moved copy is never left orphaned.
427
+ restoreMovedRecord(tombstone, lockPath);
428
+ released = true;
429
+ return false;
430
+ }
431
+ try {
432
+ unlinkSync(tombstone);
433
+ released = true;
434
+ return true;
435
+ } catch {
436
+ // The tombstone survived, so this lease record is restored under the
437
+ // same rule: keep it only while the path is still free.
438
+ restoreMovedRecord(tombstone, lockPath);
439
+ return false;
440
+ }
441
+ },
442
+ };
443
+ }
444
+
445
+ export const MACHINE_LEASE_TTL_MS = DEFAULT_TTL_MS;