@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,248 @@
1
+ /**
2
+ * Forces one credential refresh when a provider says 401 while the local
3
+ * credential still looks valid.
4
+ *
5
+ * Pi refreshes an OAuth credential only after LOCAL expiry. A server-side early
6
+ * revocation therefore leaves a token that looks fine locally and 401s on every
7
+ * request, forever, with nothing triggering a refresh. The warmer only warms
8
+ * near-expiry credentials, so it never touches this case either.
9
+ *
10
+ * Ported from the Sarrius reference (index.ts:2576-2646), with one structural
11
+ * improvement: persistence goes through `AuthStorage.modify()`, whose file
12
+ * backend does a locked read-modify-write, so the cross-process race Sarrius
13
+ * handles by hand is handled by the host. Sarrius's sharpest check is kept: a
14
+ * refresh that returns a DIFFERENT account identity is terminal, because the
15
+ * slot is now a different account and continuing would silently use credentials
16
+ * the operator did not intend.
17
+ *
18
+ * CREDENTIAL DISCIPLINE. Credential values transit the `modify` callback and
19
+ * the family refresher, and nowhere else: they never enter fields on this
20
+ * class, never reach diagnostics, and never appear in a return value. Callers
21
+ * receive only a bounded outcome string.
22
+ */
23
+
24
+ import type { DiagnosticLog } from "./diagnostics.js";
25
+ import type { AllowedFamily } from "./config.js";
26
+
27
+ /**
28
+ * The slice of a stored OAuth credential this module touches. Matches the
29
+ * host's stored shape; extra fields pass through `modify` untouched.
30
+ */
31
+ export interface RefreshableCredential {
32
+ readonly type?: string;
33
+ readonly access?: string;
34
+ readonly refresh?: string;
35
+ readonly expires?: number;
36
+ readonly accountId?: string;
37
+ readonly [key: string]: unknown;
38
+ }
39
+
40
+ /** Complete input required by both maintained family refresh callbacks. */
41
+ export interface OAuthRefreshCredential extends RefreshableCredential {
42
+ readonly type: "oauth";
43
+ readonly access: string;
44
+ readonly refresh: string;
45
+ readonly expires: number;
46
+ }
47
+
48
+ /** Refreshes one family's credential; the captured upstream oauth surface. */
49
+ export type FamilyRefresher = (
50
+ credentials: OAuthRefreshCredential,
51
+ ) => Promise<RefreshableCredential>;
52
+
53
+ /**
54
+ * `CredentialStore.modify` shape, injected so tests never construct a real
55
+ * store and live wiring can bind the host's already-open locked store.
56
+ */
57
+ export type AuthModify = (
58
+ provider: string,
59
+ fn: (
60
+ current: RefreshableCredential | undefined,
61
+ ) => Promise<RefreshableCredential | undefined>,
62
+ ) => Promise<RefreshableCredential | undefined>;
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === "object" && value !== null;
66
+ }
67
+
68
+ function isOAuthRefreshCredential(
69
+ value: RefreshableCredential | undefined,
70
+ ): value is OAuthRefreshCredential {
71
+ return (
72
+ value?.type === "oauth" &&
73
+ typeof value.access === "string" &&
74
+ value.access.length > 0 &&
75
+ typeof value.refresh === "string" &&
76
+ value.refresh.length > 0 &&
77
+ typeof value.expires === "number" &&
78
+ Number.isFinite(value.expires)
79
+ );
80
+ }
81
+
82
+ function familyRefresher(oauth: unknown): FamilyRefresher | undefined {
83
+ const refreshToken = isRecord(oauth) ? oauth.refreshToken : undefined;
84
+ if (typeof refreshToken !== "function") return undefined;
85
+ return async (credential) => {
86
+ const refreshed: unknown = await refreshToken.call(oauth, credential);
87
+ return isRecord(refreshed) ? refreshed : {};
88
+ };
89
+ }
90
+
91
+ export interface HostForcedCredentialRefresherOptions {
92
+ readonly modelRegistry: unknown;
93
+ readonly oauthByFamily: Partial<Record<AllowedFamily, unknown>>;
94
+ readonly diagnostics: Pick<DiagnosticLog, "record">;
95
+ }
96
+
97
+ /**
98
+ * Binds the maintained OAuth callbacks to Pi's already-open credential store.
99
+ *
100
+ * `ModelRegistry.runtime` and `ModelRuntime.credentials` are private in the
101
+ * locked Pi 0.84.4 declaration files, while that exact installed runtime owns
102
+ * both fields. `RuntimeCredentials` forwards `modify` to `AuthStorage.modify`,
103
+ * whose file backend holds a cross-process lock for the whole async
104
+ * read-modify-write. If that verified host shape changes, this returns
105
+ * undefined and 401 handling fails closed into the existing
106
+ * invalidate-and-route path.
107
+ */
108
+ export function createHostForcedCredentialRefresher(
109
+ options: HostForcedCredentialRefresherOptions,
110
+ ): ForcedCredentialRefresher | undefined {
111
+ const credentials = (
112
+ options.modelRegistry as
113
+ | {
114
+ runtime?: {
115
+ credentials?: { modify?: unknown };
116
+ };
117
+ }
118
+ | undefined
119
+ )?.runtime?.credentials;
120
+ if (!credentials || typeof credentials.modify !== "function") return undefined;
121
+
122
+ const refreshers: Partial<Record<AllowedFamily, FamilyRefresher>> = {};
123
+ for (const family of [
124
+ "anthropic",
125
+ "openai-codex",
126
+ "google-antigravity",
127
+ ] as const) {
128
+ const refresh = familyRefresher(options.oauthByFamily[family]);
129
+ if (refresh !== undefined) refreshers[family] = refresh;
130
+ }
131
+ return new ForcedCredentialRefresher({
132
+ modify: credentials.modify.bind(credentials) as AuthModify,
133
+ refreshers,
134
+ diagnostics: options.diagnostics,
135
+ });
136
+ }
137
+
138
+ export type ForcedRefreshOutcome =
139
+ /** A refreshed credential was persisted; the account deserves a fresh chance. */
140
+ | "refreshed"
141
+ /** The refresh identified a DIFFERENT account; invalidation must stand. */
142
+ | "identity-changed"
143
+ /** The refresh failed; nothing was persisted. */
144
+ | "failed"
145
+ /** No stored oauth credential with a refresh token; nothing to force. */
146
+ | "unsupported"
147
+ /** This provider already got its one forced attempt this session. */
148
+ | "already-attempted";
149
+
150
+ function recordOutcome(
151
+ diagnostics: Pick<DiagnosticLog, "record">,
152
+ providerId: string,
153
+ outcome: ForcedRefreshOutcome,
154
+ ): ForcedRefreshOutcome {
155
+ diagnostics.record(
156
+ outcome === "refreshed" ? "info" : "warning",
157
+ "credential.force-refresh",
158
+ outcome === "refreshed"
159
+ ? "Forced one credential refresh after an explicit provider 401."
160
+ : "A forced credential refresh did not recover the provider; normal invalidation remains active.",
161
+ { providerId, outcome },
162
+ );
163
+ return outcome;
164
+ }
165
+
166
+ export interface ForcedCredentialRefresherOptions {
167
+ readonly modify: AuthModify;
168
+ readonly refreshers: Partial<Record<AllowedFamily, FamilyRefresher>>;
169
+ readonly diagnostics: Pick<DiagnosticLog, "record">;
170
+ }
171
+
172
+ export class ForcedCredentialRefresher {
173
+ readonly #modify: AuthModify;
174
+ readonly #refreshers: Partial<Record<AllowedFamily, FamilyRefresher>>;
175
+ readonly #diagnostics: Pick<DiagnosticLog, "record">;
176
+ /**
177
+ * Exactly one forced attempt per provider per session, whatever the outcome.
178
+ * A 401 that survives a fresh token is not a staleness problem, and retrying
179
+ * the refresh on every exhausted turn would hammer the provider's token
180
+ * endpoint with requests that cannot succeed.
181
+ */
182
+ readonly #attempted = new Set<string>();
183
+
184
+ constructor(options: ForcedCredentialRefresherOptions) {
185
+ this.#modify = options.modify;
186
+ this.#refreshers = options.refreshers;
187
+ this.#diagnostics = options.diagnostics;
188
+ }
189
+
190
+ async attempt(
191
+ providerId: string,
192
+ family: AllowedFamily,
193
+ ): Promise<ForcedRefreshOutcome> {
194
+ if (this.#attempted.has(providerId)) return "already-attempted";
195
+ this.#attempted.add(providerId);
196
+
197
+ const refresher = this.#refreshers[family];
198
+ if (refresher === undefined) return "unsupported";
199
+
200
+ let outcome: ForcedRefreshOutcome = "failed";
201
+ try {
202
+ await this.#modify(providerId, async (current) => {
203
+ if (!isOAuthRefreshCredential(current)) {
204
+ outcome = "unsupported";
205
+ return undefined;
206
+ }
207
+ let refreshed: RefreshableCredential;
208
+ try {
209
+ refreshed = await refresher(current);
210
+ } catch {
211
+ // The refresh itself failed. Persist nothing; the 401 keeps its
212
+ // terminal classification. Never record the provider's error body:
213
+ // OAuth endpoints can echo credential material.
214
+ outcome = "failed";
215
+ return undefined;
216
+ }
217
+ if (
218
+ typeof current.accountId === "string" &&
219
+ typeof refreshed.accountId === "string" &&
220
+ current.accountId !== refreshed.accountId
221
+ ) {
222
+ // The slot now resolves to a different account. Persisting would
223
+ // silently authenticate as an account the operator did not choose --
224
+ // the exact concurrency failure this extension exists to avoid.
225
+ outcome = "identity-changed";
226
+ return undefined;
227
+ }
228
+ const merged: RefreshableCredential = {
229
+ ...current,
230
+ ...refreshed,
231
+ type: "oauth",
232
+ };
233
+ if (!isOAuthRefreshCredential(merged)) {
234
+ outcome = "failed";
235
+ return undefined;
236
+ }
237
+ outcome = "refreshed";
238
+ return merged;
239
+ });
240
+ } catch {
241
+ // Record only bounded metadata. The thrown value can originate in an OAuth
242
+ // implementation and is therefore not safe diagnostic material.
243
+ outcome = "failed";
244
+ }
245
+
246
+ return recordOutcome(this.#diagnostics, providerId, outcome);
247
+ }
248
+ }
@@ -0,0 +1,238 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ chmodSync,
4
+ closeSync,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from "node:fs";
14
+ import { dirname, join } from "node:path";
15
+ import { acquireMachineLease } from "./machine-lease.js";
16
+ import { getPeriodBounds } from "./period-boundaries.js";
17
+
18
+ const SCHEMA_VERSION = 1;
19
+ export const MAX_MARKER_BYTES = 1_024;
20
+ const LEASE_TTL_MS = 5_000;
21
+
22
+ export type DeclarationNoticeCondition = "stale" | "not-installed";
23
+
24
+ /**
25
+ * The only condition values that may ever reach the persisted marker.
26
+ *
27
+ * TypeScript's `DeclarationNoticeCondition` union is erased at runtime, so a
28
+ * JavaScript or casted caller could otherwise pass an arbitrary string that
29
+ * would be serialized into `declaration-notice.json`. This allow-list is the
30
+ * runtime boundary that keeps a caller from causing a path, identifier, or
31
+ * credential-like value to be retained (AC-6): anything outside it is rejected
32
+ * before the lease is acquired or any byte is written.
33
+ */
34
+ const ALLOWED_CONDITIONS: ReadonlySet<DeclarationNoticeCondition> = new Set([
35
+ "stale",
36
+ "not-installed",
37
+ ]);
38
+
39
+ function isAllowedCondition(
40
+ value: unknown,
41
+ ): value is DeclarationNoticeCondition {
42
+ return ALLOWED_CONDITIONS.has(value as DeclarationNoticeCondition);
43
+ }
44
+
45
+
46
+ interface DeclarationNoticeState {
47
+ readonly schemaVersion: 1;
48
+ readonly dayStartMs: number;
49
+ readonly condition: DeclarationNoticeCondition;
50
+ }
51
+
52
+ type MarkerReadResult =
53
+ | { readonly status: "valid"; readonly marker: DeclarationNoticeState }
54
+ | { readonly status: "missing" }
55
+ | { readonly status: "malformed" }
56
+ | { readonly status: "unavailable" };
57
+
58
+ function defaultPaths(): { readonly path: string; readonly lockPath: string } {
59
+ const agentDir =
60
+ process.env.PI_CODING_AGENT_DIR ??
61
+ join(process.env.HOME ?? ".", ".pi", "agent");
62
+ // Package storage identity stays decoupled from the logical provider ID.
63
+ const directory = join(agentDir, "pi-multi-account");
64
+ return {
65
+ path: join(directory, "declaration-notice.json"),
66
+ lockPath: join(directory, "declaration-notice.lock"),
67
+ };
68
+ }
69
+
70
+ function parseMarker(value: unknown): DeclarationNoticeState | undefined {
71
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
72
+ return undefined;
73
+ }
74
+ const candidate = value as Record<string, unknown>;
75
+ if (
76
+ candidate.schemaVersion !== SCHEMA_VERSION ||
77
+ !Number.isSafeInteger(candidate.dayStartMs) ||
78
+ (candidate.dayStartMs as number) < 0 ||
79
+ (candidate.condition !== "stale" && candidate.condition !== "not-installed") ||
80
+ Object.keys(candidate).some(
81
+ (key) =>
82
+ key !== "schemaVersion" && key !== "dayStartMs" && key !== "condition",
83
+ )
84
+ ) {
85
+ return undefined;
86
+ }
87
+ return {
88
+ schemaVersion: SCHEMA_VERSION,
89
+ dayStartMs: candidate.dayStartMs as number,
90
+ condition: candidate.condition,
91
+ };
92
+ }
93
+
94
+ /** Persistent once-per-UTC-day marker for the session-start declaration notice. */
95
+ export class DeclarationNoticeMarker {
96
+ readonly #path: string;
97
+ readonly #lockPath: string;
98
+ readonly #now: () => number;
99
+
100
+ constructor(options: {
101
+ readonly path?: string;
102
+ readonly lockPath?: string;
103
+ readonly now?: () => number;
104
+ } = {}) {
105
+ const defaults = defaultPaths();
106
+ this.#path = options.path ?? defaults.path;
107
+ this.#lockPath = options.lockPath ?? defaults.lockPath;
108
+ this.#now = options.now ?? Date.now;
109
+ }
110
+
111
+ /**
112
+ * Returns true when the caller should notify. Contention and lease/read failures
113
+ * suppress; a post-decision marker write failure still returns true.
114
+ */
115
+ shouldNotify(condition: DeclarationNoticeCondition): boolean {
116
+ // Reject any runtime value outside the allowed literals BEFORE acquiring
117
+ // the lease or writing, so a caller can never cause an arbitrary string to
118
+ // be persisted in the marker (AC-6).
119
+ if (!isAllowedCondition(condition)) return false;
120
+ let lease: ReturnType<typeof acquireMachineLease>;
121
+ try {
122
+ lease = acquireMachineLease({
123
+ lockPath: this.#lockPath,
124
+ ttlMs: LEASE_TTL_MS,
125
+ now: this.#now,
126
+ reclaimMalformed: true,
127
+ });
128
+ } catch {
129
+ return false;
130
+ }
131
+ if (lease === undefined) return false;
132
+ try {
133
+ const todayStartMs = getPeriodBounds(this.#now(), "day").startMs;
134
+ const existing = this.#readMarker();
135
+ if (existing.status === "unavailable") return false;
136
+ if (
137
+ existing.status === "valid" &&
138
+ existing.marker.dayStartMs === todayStartMs
139
+ ) {
140
+ return false;
141
+ }
142
+ this.#writeMarker({
143
+ schemaVersion: SCHEMA_VERSION,
144
+ dayStartMs: todayStartMs,
145
+ condition,
146
+ });
147
+ return true;
148
+ } catch {
149
+ return false;
150
+ } finally {
151
+ try {
152
+ lease.release();
153
+ } catch {
154
+ // A release failure cannot block session startup.
155
+ }
156
+ }
157
+ }
158
+
159
+ /** A matched session removes the prior notice marker so later drift can notify. */
160
+ clear(): void {
161
+ let lease: ReturnType<typeof acquireMachineLease>;
162
+ try {
163
+ lease = acquireMachineLease({
164
+ lockPath: this.#lockPath,
165
+ ttlMs: LEASE_TTL_MS,
166
+ now: this.#now,
167
+ reclaimMalformed: true,
168
+ });
169
+ } catch {
170
+ return;
171
+ }
172
+ if (lease === undefined) return;
173
+ try {
174
+ unlinkSync(this.#path);
175
+ } catch {
176
+ // Marker cleanup failures cannot block session startup.
177
+ } finally {
178
+ try {
179
+ lease.release();
180
+ } catch {
181
+ // A release failure cannot block session startup.
182
+ }
183
+ }
184
+ }
185
+
186
+ #readMarker(): MarkerReadResult {
187
+ try {
188
+ const stats = statSync(this.#path);
189
+ if (!stats.isFile() || stats.size > MAX_MARKER_BYTES) {
190
+ return { status: "malformed" };
191
+ }
192
+ const parsed = parseMarker(JSON.parse(readFileSync(this.#path, "utf8")));
193
+ return parsed === undefined
194
+ ? { status: "malformed" }
195
+ : { status: "valid", marker: parsed };
196
+ } catch (error) {
197
+ return (error as NodeJS.ErrnoException).code === "ENOENT"
198
+ ? { status: "missing" }
199
+ : error instanceof SyntaxError
200
+ ? { status: "malformed" }
201
+ : { status: "unavailable" };
202
+ }
203
+ }
204
+
205
+ #writeMarker(marker: DeclarationNoticeState): boolean {
206
+ const encoded = `${JSON.stringify(marker)}\n`;
207
+ if (Buffer.byteLength(encoded, "utf8") > MAX_MARKER_BYTES) return false;
208
+ const directory = dirname(this.#path);
209
+ const temporaryPath = `${this.#path}.${process.pid}.${randomUUID()}.tmp`;
210
+ let descriptor: number | undefined;
211
+ try {
212
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
213
+ chmodSync(directory, 0o700);
214
+ descriptor = openSync(temporaryPath, "wx", 0o600);
215
+ writeFileSync(descriptor, encoded, { encoding: "utf8" });
216
+ fsyncSync(descriptor);
217
+ closeSync(descriptor);
218
+ descriptor = undefined;
219
+ renameSync(temporaryPath, this.#path);
220
+ chmodSync(this.#path, 0o600);
221
+ return true;
222
+ } catch {
223
+ if (descriptor !== undefined) {
224
+ try {
225
+ closeSync(descriptor);
226
+ } catch {
227
+ // Continue with temporary-file cleanup.
228
+ }
229
+ }
230
+ try {
231
+ unlinkSync(temporaryPath);
232
+ } catch {
233
+ // The notice decision stays fail-soft when persistence fails.
234
+ }
235
+ return false;
236
+ }
237
+ }
238
+ }