@cortexkit/opencode-openai-auth 1.3.0 → 1.4.0

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 (85) hide show
  1. package/dist/bin.js +8 -8
  2. package/dist/{codex-request-B9D0e97Q.d.ts → codex-request-hw0XcldE.d.ts} +27 -27
  3. package/dist/index-1eq2s1cp.js +2 -0
  4. package/dist/{index-m3ctrabv.js → index-1tdnrt68.js} +1 -1
  5. package/dist/{index-8x1aj7q3.js → index-5230x6s8.js} +2 -2
  6. package/dist/{index-gn2b097f.js → index-ag04nyv7.js} +7 -7
  7. package/dist/index-brbqqer5.js +90 -0
  8. package/dist/{index-xwvze96g.js → index-d98f1xdp.js} +1 -1
  9. package/dist/index-jcyrm58f.js +65 -0
  10. package/dist/{index-2pgkrebq.js → index-rsvnabbe.js} +2 -2
  11. package/dist/{index-ftkhhr6d.js → index-s88n2bjn.js} +1 -1
  12. package/dist/{index-bc89akak.js → index-t5m0dhmq.js} +1 -1
  13. package/dist/index-vy729a0b.js +69 -0
  14. package/dist/index-wgwze391.js +2 -0
  15. package/dist/{index-tddr876f.js → index-yhw3hnsh.js} +2 -2
  16. package/dist/index.d.ts +11 -3
  17. package/dist/index.js +1 -1
  18. package/dist/{model-BdITTmeb.d.ts → model-DphbMQce.d.ts} +1 -1
  19. package/dist/{port-file-DbHIVC9a.d.ts → port-file-Cx-wwe5Q.d.ts} +1 -1
  20. package/dist/{protocol-MysYPAho.d.ts → protocol-CDqdjh74.d.ts} +1 -1
  21. package/dist/rpc/port-file.d.ts +1 -1
  22. package/dist/rpc/port-file.js +1 -1
  23. package/dist/rpc/protocol.d.ts +1 -1
  24. package/dist/rpc/protocol.js +1 -1
  25. package/dist/rpc/rpc-client.d.ts +3 -3
  26. package/dist/rpc/rpc-client.js +1 -1
  27. package/dist/rpc/rpc-dir.js +1 -1
  28. package/dist/{sidebar-state-CVSok7zv.d.ts → sidebar-state-pTd9gg9W.d.ts} +55 -7
  29. package/dist/sidebar-state.d.ts +2 -2
  30. package/dist/sidebar-state.js +1 -1
  31. package/dist/{token-z6qq7a1d.js → token-0z5g59p8.js} +1 -1
  32. package/dist/token-util-jf9g6stk.js +1 -0
  33. package/dist/tui-preferences.js +1 -1
  34. package/dist/tui.d.ts +30 -3
  35. package/dist/v2/server.d.ts +2 -2
  36. package/dist/v2/server.js +2 -2
  37. package/package.json +4 -4
  38. package/src/tui-compiled/raw/shared/0332701351b3eeec-format.js +50 -5
  39. package/src/tui-compiled/raw/shared/0f77ebc3c914c9c6-custody.js +70 -1
  40. package/src/tui-compiled/raw/shared/169b2b4bf30909bc-consumer.js +88 -61
  41. package/src/tui-compiled/raw/shared/2577ec69f9973ae9-quota-normalize.js +45 -1
  42. package/src/tui-compiled/raw/shared/30f5bc480f31f51c-roster.js +100 -43
  43. package/src/tui-compiled/raw/shared/39c3cb05738fcf6d-projection.js +3 -1
  44. package/src/tui-compiled/raw/shared/42ae93ab554fcec2-open-url.js +7 -9
  45. package/src/tui-compiled/raw/shared/4825e03209d6ae3e-with-lock.js +2 -0
  46. package/src/tui-compiled/raw/shared/4f36d333d081d040-merge.js +17 -3
  47. package/src/tui-compiled/raw/shared/81dddd203f1b92a3-quota-writer.js +253 -0
  48. package/src/tui-compiled/raw/shared/905bffc0220f43d0-map.js +31 -6
  49. package/src/tui-compiled/raw/shared/abd062d633e29fd2-pool-routing.js +116 -12
  50. package/src/tui-compiled/raw/shared/bfbdd0f9aae56b47-pool-quota.js +15 -3
  51. package/src/tui-compiled/raw/shared/d0f0b6be200311af-insula.js +64 -2
  52. package/src/tui-compiled/raw/shared/d5a8f1061ee2f114-index.js +2 -2
  53. package/src/tui-compiled/raw/shared/dbc61cd405941828-accounts.js +5 -4
  54. package/src/tui-compiled/raw/shared/dc9290c2a7552ba6-watch.js +2 -1
  55. package/src/tui-compiled/raw/shared/e8d3c8da776ef9cd-index.js +1 -0
  56. package/src/tui-compiled/raw/sidebar-state.js +32 -3
  57. package/src/tui-compiled/raw/tui/v2-dialog.js +2 -1
  58. package/src/tui-compiled/raw/tui.tsx +170 -26
  59. package/src/tui-compiled/runtime/shared/0332701351b3eeec-format.js +50 -5
  60. package/src/tui-compiled/runtime/shared/0f77ebc3c914c9c6-custody.js +70 -1
  61. package/src/tui-compiled/runtime/shared/169b2b4bf30909bc-consumer.js +88 -61
  62. package/src/tui-compiled/runtime/shared/2577ec69f9973ae9-quota-normalize.js +45 -1
  63. package/src/tui-compiled/runtime/shared/30f5bc480f31f51c-roster.js +100 -43
  64. package/src/tui-compiled/runtime/shared/39c3cb05738fcf6d-projection.js +3 -1
  65. package/src/tui-compiled/runtime/shared/42ae93ab554fcec2-open-url.js +7 -9
  66. package/src/tui-compiled/runtime/shared/4825e03209d6ae3e-with-lock.js +2 -0
  67. package/src/tui-compiled/runtime/shared/4f36d333d081d040-merge.js +17 -3
  68. package/src/tui-compiled/runtime/shared/81dddd203f1b92a3-quota-writer.js +253 -0
  69. package/src/tui-compiled/runtime/shared/905bffc0220f43d0-map.js +31 -6
  70. package/src/tui-compiled/runtime/shared/abd062d633e29fd2-pool-routing.js +116 -12
  71. package/src/tui-compiled/runtime/shared/bfbdd0f9aae56b47-pool-quota.js +15 -3
  72. package/src/tui-compiled/runtime/shared/d0f0b6be200311af-insula.js +64 -2
  73. package/src/tui-compiled/runtime/shared/d5a8f1061ee2f114-index.js +2 -2
  74. package/src/tui-compiled/runtime/shared/dbc61cd405941828-accounts.js +5 -4
  75. package/src/tui-compiled/runtime/shared/dc9290c2a7552ba6-watch.js +2 -1
  76. package/src/tui-compiled/runtime/shared/e8d3c8da776ef9cd-index.js +1 -0
  77. package/src/tui-compiled/runtime/sidebar-state.js +32 -3
  78. package/src/tui-compiled/runtime/tui/v2-dialog.js +2 -1
  79. package/src/tui-compiled/runtime/tui.js +350 -219
  80. package/dist/index-1mbgsxsr.js +0 -2
  81. package/dist/index-4v2fs42x.js +0 -65
  82. package/dist/index-ex6nx3mq.js +0 -2
  83. package/dist/index-psjyexj3.js +0 -69
  84. package/dist/index-tn8x5tz8.js +0 -90
  85. package/dist/token-util-cf0nyh7n.js +0 -1
@@ -3,9 +3,10 @@ import { combineAbortSignals } from "./6371b24a0573d79e-any-signal.js";
3
3
  import { ClaustrumScopedCustody, decideScopedRetryAfter401, } from "./0f77ebc3c914c9c6-custody.js";
4
4
  import { readClaustrumEnrollmentToken } from "./b9588ad4725c1d10-enrollment.js";
5
5
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
6
- import { acquireInsulaQuotaSource } from "./d0f0b6be200311af-insula.js";
7
- import { acceptVaultRoute, declineVaultRoute, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
8
- import { SAFETY_POLL_MS, ScopedRosterWatch } from "./dc9290c2a7552ba6-watch.js";
6
+ import { acquireInsulaQuotaSource, } from "./d0f0b6be200311af-insula.js";
7
+ import { pendingVaultQuota, retainVaultQuotaWriter } from "./81dddd203f1b92a3-quota-writer.js";
8
+ import { acceptVaultRoute, applyVaultQuota, declineVaultRoute, forgetPendingVaultDiscovery, readVaultRoster, recordVaultAccountQuota, recordVaultQuota, refreshVaultRoster, vaultRoutingRows, } from "./30f5bc480f31f51c-roster.js";
9
+ import { SAFETY_POLL_MS } from "./dc9290c2a7552ba6-watch.js";
9
10
  /**
10
11
  * One plugin host's view of its vault accounts. It turns vault credentials
11
12
  * into rows for `/routing`, authorizes each physical send with the vault, and
@@ -19,8 +20,6 @@ export class ClaustrumConsumer {
19
20
  #shutdown = new AbortController();
20
21
  #logger;
21
22
  #custody;
22
- /** The custody's client, kept so `start()` can open its vault watch. */
23
- #client;
24
23
  #connecting;
25
24
  #refreshing;
26
25
  #roster;
@@ -34,11 +33,16 @@ export class ClaustrumConsumer {
34
33
  #insula;
35
34
  #vaultWatch;
36
35
  #releaseInsulaWatch;
36
+ #insulaSkips = new Map();
37
37
  /** When this instance last completed a discovery, by `now`. */
38
38
  #discoveredAt;
39
+ #releaseQuotaWriter;
39
40
  constructor(options) {
40
41
  this.#options = options;
41
42
  this.#logger = options.logger ?? createLogger('claustrum');
43
+ this.#releaseQuotaWriter = retainVaultQuotaWriter(options.rosterPath, (publication) => {
44
+ this.#roster = applyVaultQuota(this.#roster, publication).roster;
45
+ });
42
46
  }
43
47
  #assertOpen(signal) {
44
48
  this.#shutdown.signal.throwIfAborted();
@@ -90,7 +94,6 @@ export class ClaustrumConsumer {
90
94
  client.close();
91
95
  this.#assertOpen();
92
96
  }
93
- this.#client = client;
94
97
  this.#custody = new ClaustrumScopedCustody({
95
98
  client,
96
99
  family: this.#options.family,
@@ -143,9 +146,8 @@ export class ClaustrumConsumer {
143
146
  }
144
147
  /** Close the watches with the connection they belong to. */
145
148
  #stopWatch() {
146
- this.#vaultWatch?.stop();
149
+ this.#vaultWatch?.close();
147
150
  this.#vaultWatch = undefined;
148
- this.#client = undefined;
149
151
  this.#releaseInsulaWatch?.();
150
152
  this.#releaseInsulaWatch = undefined;
151
153
  }
@@ -179,11 +181,9 @@ export class ClaustrumConsumer {
179
181
  let discovered;
180
182
  const roster = await refreshVaultRoster({
181
183
  path: this.#options.rosterPath,
182
- custody: {
183
- discover: async (signal) => {
184
- discovered = await custody.discover(signal);
185
- return discovered;
186
- },
184
+ custody,
185
+ onInventory: (inventory) => {
186
+ discovered = inventory;
187
187
  },
188
188
  isActive: () => this.#active(),
189
189
  signal: this.#shutdown.signal,
@@ -196,7 +196,7 @@ export class ClaustrumConsumer {
196
196
  });
197
197
  this.#assertOpen();
198
198
  const changed = roster?.view !== this.#roster?.view;
199
- this.#roster = roster;
199
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, roster);
200
200
  custody.retain(roster?.rows ?? []);
201
201
  // Each discovery replaces the previous value, including with unknown. A
202
202
  // refresh that served a peer's roster discovered nothing and leaves it;
@@ -271,42 +271,30 @@ export class ClaustrumConsumer {
271
271
  * reached in vault mode: a host in local mode never connects.
272
272
  */
273
273
  #openWatch() {
274
- const client = this.#client;
275
- if (this.#shutdown.signal.aborted || !client || !this.#custody)
274
+ const custody = this.#custody;
275
+ if (this.#shutdown.signal.aborted || !custody)
276
276
  return;
277
- if (!this.#vaultWatch) {
278
- const watch = new ScopedRosterWatch({
279
- client,
280
- enrollmentToken: async () => (await readClaustrumEnrollmentToken(this.#options.tokenPath)).token,
281
- view: () => this.#roster?.view,
282
- // The existing discovery, so a watched change commits the roster and
283
- // re-applies it to kept receipts exactly as a poll does. A refresh
284
- // already in flight may have listed before the change was announced,
285
- // so wait for it and list again.
286
- discover: async () => {
287
- while (this.#refreshing)
288
- await this.#refreshing.catch(() => { });
289
- await this.refresh();
290
- },
291
- onRefused: (error) => {
292
- // As a poll that met the same refusal from list_scoped would.
293
- this.#custody?.listFailed(error);
277
+ // The same entry point a host without a consumer uses, so both apply
278
+ // notices, refusals and outages alike.
279
+ this.#vaultWatch ??= custody.watchRoster({
280
+ view: () => this.#roster?.view,
281
+ // The existing discovery, so a watched change commits the roster and
282
+ // re-applies it to kept receipts exactly as a poll does. A refresh
283
+ // already in flight may have listed before the change was announced,
284
+ // so wait for it and list again.
285
+ onView: async () => {
286
+ while (this.#refreshing)
287
+ await this.#refreshing.catch(() => { });
288
+ await this.refresh();
289
+ },
290
+ // Custody has dropped kept receipts, as a poll meeting the same
291
+ // refusal from list_scoped would.
292
+ onRefused: (error) => this.#options.onError?.(error),
293
+ onError: (error) => {
294
+ if (!this.#shutdown.signal.aborted)
294
295
  this.#options.onError?.(error);
295
- },
296
- onTransportLost: () => this.#custody?.transportLost(),
297
- onReachable: () => this.#custody?.reachable(),
298
- onError: (error) => {
299
- if (!this.#shutdown.signal.aborted)
300
- this.#options.onError?.(error);
301
- },
302
- logger: this.#logger,
303
- now: this.#options.now,
304
- setTimeoutImpl: this.#options.setTimeoutImpl,
305
- clearTimeoutImpl: this.#options.clearTimeoutImpl,
306
- });
307
- this.#vaultWatch = watch;
308
- watch.start();
309
- }
296
+ },
297
+ });
310
298
  if (this.#options.insula && this.#insula && !this.#releaseInsulaWatch)
311
299
  this.#releaseInsulaWatch = this.#insula.source.watch({
312
300
  provider: this.#options.insula.provider,
@@ -364,7 +352,7 @@ export class ClaustrumConsumer {
364
352
  return undefined;
365
353
  try {
366
354
  await readClaustrumEnrollmentToken(this.#options.tokenPath);
367
- const roster = await readVaultRoster(this.#options.rosterPath);
355
+ const roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
368
356
  const row = roster?.rows.find((entry) => entry.routeId === routeId);
369
357
  return this.#normalizeInsulaQuota(row?.accountIdentity);
370
358
  }
@@ -372,6 +360,44 @@ export class ClaustrumConsumer {
372
360
  return undefined;
373
361
  }
374
362
  }
363
+ /** Cache-only background lookup, preserving the reason a route has no reading. */
364
+ async lookupInsulaQuotaForRoute(routeId) {
365
+ const options = this.#options.insula;
366
+ if (!options || this.#shutdown.signal.aborted || !(await this.#active()))
367
+ return { miss: 'unreachable' };
368
+ try {
369
+ await readClaustrumEnrollmentToken(this.#options.tokenPath);
370
+ const roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
371
+ const account = roster?.rows.find((row) => row.routeId === routeId)?.accountIdentity;
372
+ if (!account || !this.#insula)
373
+ return { miss: 'unreachable' };
374
+ const result = this.#insula.source.lookup(options.provider, account, (this.#options.now ?? Date.now)());
375
+ const reading = result.reading &&
376
+ options.normalize(result.reading.entry, result.reading.context);
377
+ if (result.miss)
378
+ return { miss: result.miss, ...(reading !== undefined && { reading }) };
379
+ // Refusing an echo or unsupported window leaves the plugin's poll in charge.
380
+ return reading === undefined ? { miss: 'unreachable' } : { reading };
381
+ }
382
+ catch {
383
+ return { miss: 'unreachable' };
384
+ }
385
+ }
386
+ /** Skip the fallback poll while insula is cold or its upstream fetch failed. */
387
+ async pollInsulaQuotaForRoute(routeId, fallback) {
388
+ const result = await this.lookupInsulaQuotaForRoute(routeId);
389
+ if (result.miss === 'cold' || result.miss === 'upstream-failed') {
390
+ if (this.#insulaSkips.get(routeId) !== result.miss) {
391
+ this.#insulaSkips.set(routeId, result.miss);
392
+ this.#logger.warn('skipped insula provider poll', {
393
+ reason: result.miss,
394
+ });
395
+ }
396
+ return undefined;
397
+ }
398
+ this.#insulaSkips.delete(routeId);
399
+ return result.miss ? fallback() : result.reading;
400
+ }
375
401
  #normalizeInsulaQuota(accountIdentity) {
376
402
  if (!this.#options.insula || !accountIdentity)
377
403
  return undefined;
@@ -396,7 +422,7 @@ export class ClaustrumConsumer {
396
422
  this.#insula = undefined;
397
423
  throw new ClaustrumConsumerError('not-active', 'Claustrum scoped custody is not active');
398
424
  }
399
- const current = await readVaultRoster(this.#options.rosterPath);
425
+ const current = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
400
426
  this.#assertOpen(signal);
401
427
  const observed = roster.rows.find((row) => row.routeId === routeId);
402
428
  const configured = current?.rows.find((row) => row.routeId === routeId);
@@ -488,13 +514,13 @@ export class ClaustrumConsumer {
488
514
  async decline(routeId) {
489
515
  this.#assertOpen();
490
516
  await declineVaultRoute(this.#options.rosterPath, routeId);
491
- this.#roster = await readVaultRoster(this.#options.rosterPath);
517
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
492
518
  this.#custody?.retain(this.#roster?.rows ?? []);
493
519
  }
494
520
  async accept(routeId) {
495
521
  this.#assertOpen();
496
522
  await acceptVaultRoute(this.#options.rosterPath, routeId);
497
- this.#roster = await readVaultRoster(this.#options.rosterPath);
523
+ this.#roster = pendingVaultQuota(this.#options.rosterPath, await readVaultRoster(this.#options.rosterPath));
498
524
  }
499
525
  /**
500
526
  * Store a quota or profile observation for a vault route. The receipt the
@@ -504,7 +530,7 @@ export class ClaustrumConsumer {
504
530
  */
505
531
  async recordQuota(routeId, observation, attempt) {
506
532
  this.#assertOpen();
507
- const kept = await recordVaultQuota(this.#options.rosterPath, {
533
+ return recordVaultQuota(this.#options.rosterPath, {
508
534
  routeId,
509
535
  observation,
510
536
  credentialId: attempt.credentialId,
@@ -516,27 +542,21 @@ export class ClaustrumConsumer {
516
542
  expectedAccountIdentity: attempt.expectedAccountIdentity,
517
543
  }),
518
544
  });
519
- if (kept)
520
- this.#roster = await readVaultRoster(this.#options.rosterPath);
521
- return kept;
522
545
  }
523
546
  /**
524
547
  * Store a background observation that belongs to an account rather than to
525
548
  * a served credential (an insula reading from `cachedInsulaQuotaForRoute`).
526
549
  * It lands only while the route is still bound to that known account, so no
527
550
  * credential fetch is needed and a replaced account's reading is dropped.
528
- * The in-memory roster is reloaded from the file, without a vault call.
551
+ * Memory updates immediately; durable publication is detached and coalesced.
529
552
  */
530
553
  async recordAccountQuota(routeId, accountIdentity, observation) {
531
554
  this.#assertOpen();
532
- const kept = await recordVaultAccountQuota(this.#options.rosterPath, {
555
+ return recordVaultAccountQuota(this.#options.rosterPath, {
533
556
  routeId,
534
557
  accountIdentity,
535
558
  observation,
536
559
  });
537
- if (kept)
538
- this.#roster = await readVaultRoster(this.#options.rosterPath);
539
- return kept;
540
560
  }
541
561
  close() {
542
562
  if (this.#shutdown.signal.aborted)
@@ -551,5 +571,12 @@ export class ClaustrumConsumer {
551
571
  this.#insula = undefined;
552
572
  this.#roster = undefined;
553
573
  this.#grantReach = undefined;
574
+ void this.#releaseQuotaWriter();
575
+ forgetPendingVaultDiscovery(this.#options.rosterPath);
576
+ }
577
+ /** Close immediately; the last consumer also waits for the writer's bounded drain. */
578
+ async dispose() {
579
+ this.close();
580
+ await this.#releaseQuotaWriter();
554
581
  }
555
582
  }
@@ -56,8 +56,33 @@ export function normalizeQuotaHeaders(h) {
56
56
  const secondary = windowFromHeader(h, "x-codex-secondary-used-percent");
57
57
  if (secondary)
58
58
  snapshot.secondary = secondary;
59
+ const credits = creditsFromHeaders(h);
60
+ if (credits)
61
+ snapshot.credits = credits;
59
62
  return snapshot;
60
63
  }
64
+ function headerBool(h, name) {
65
+ const raw = h.get(name)?.trim();
66
+ if (raw === void 0)
67
+ return;
68
+ if (raw.toLowerCase() === "true" || raw === "1")
69
+ return !0;
70
+ if (raw.toLowerCase() === "false" || raw === "0")
71
+ return !1;
72
+ return;
73
+ }
74
+ function creditsFromHeaders(h) {
75
+ const hasCredits = headerBool(h, "x-codex-credits-has-credits"), unlimited = headerBool(h, "x-codex-credits-unlimited");
76
+ if (hasCredits === void 0 || unlimited === void 0)
77
+ return;
78
+ const balance = nonNegativeNumberish(h.get("x-codex-credits-balance")?.trim());
79
+ return {
80
+ hasCredits,
81
+ unlimited,
82
+ ...balance !== void 0 ? { balance } : {},
83
+ checkedAt: Date.now()
84
+ };
85
+ }
61
86
  export function isCompleteQuotaHeaderFrame(h) {
62
87
  const states = QUOTA_USED_PERCENT_HEADERS.map((name) => usedPercentHeaderState(h, name));
63
88
  return states.includes("valid") && !states.includes("invalid");
@@ -85,8 +110,26 @@ export function normalizeWsFrame(event) {
85
110
  const secondary = windowFromWs(event.rate_limits?.secondary);
86
111
  if (secondary)
87
112
  snapshot.secondary = secondary;
113
+ const credits = creditsFromWs(event.credits);
114
+ if (credits)
115
+ snapshot.credits = credits;
88
116
  return snapshot;
89
117
  }
118
+ function creditsFromWs(value) {
119
+ if (value === null || typeof value !== "object")
120
+ return;
121
+ const credits = value;
122
+ if (typeof credits.has_credits !== "boolean" || typeof credits.unlimited !== "boolean")
123
+ return;
124
+ const balance = nonNegativeNumberish(typeof credits.balance === "string" ? credits.balance.trim() : credits.balance);
125
+ return {
126
+ hasCredits: credits.has_credits,
127
+ unlimited: credits.unlimited,
128
+ ...balance !== void 0 ? { balance } : {},
129
+ ...typeof credits.overage_limit_reached === "boolean" ? { overageLimitReached: credits.overage_limit_reached } : {},
130
+ checkedAt: Date.now()
131
+ };
132
+ }
90
133
  function nonNegativeNumberish(value) {
91
134
  if (value == null || value === "")
92
135
  return;
@@ -147,7 +190,8 @@ export function normalizeWham(json) {
147
190
  hasCredits: credits.has_credits,
148
191
  unlimited: credits.unlimited,
149
192
  overageLimitReached: credits.overage_limit_reached,
150
- ...balance !== void 0 ? { balance } : {}
193
+ ...balance !== void 0 ? { balance } : {},
194
+ checkedAt: Date.now()
151
195
  };
152
196
  }
153
197
  return snapshot;
@@ -1,9 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
- import { acquireRefreshFileLock, withLock, writeJsonAtomic, } from "./ec971142548df90e-index.js";
2
+ import { resolve } from "node:path";
3
+ import { acquireRefreshFileLock, LockContentionError, withLock, writeJsonAtomic, } from "./ec971142548df90e-index.js";
3
4
  import { readPrivateText, windowsPrivateFiles } from "./375d9cca2def037d-private-files.js";
4
- import { decodeQuotaMap, mergeQuotaObservation, } from "./d5a8f1061ee2f114-index.js";
5
+ import { decodeQuotaMap, isQuotaObservation, mergeQuotaObservation, } from "./d5a8f1061ee2f114-index.js";
5
6
  import { ClaustrumConsumerError } from "./ca0fccaba1d87af4-errors.js";
6
7
  import { acceptAccount, declineAccount, isDeclined, } from "./7c140680fc81be92-interlock.js";
8
+ import { enqueueVaultQuota, pendingVaultQuota, } from "./81dddd203f1b92a3-quota-writer.js";
9
+ export { drainVaultQuota } from "./81dddd203f1b92a3-quota-writer.js";
7
10
  export const DEFAULT_ROUTE_PREFIX = 'vault:';
8
11
  function hash(value) {
9
12
  return createHash('sha256').update(value).digest('hex');
@@ -313,6 +316,7 @@ const WRITE_LOCK = {
313
316
  name: 'claustrum-roster-write',
314
317
  ttlMs: 10_000,
315
318
  timeoutMs: 5_000,
319
+ renew: true,
316
320
  };
317
321
  const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
318
322
  /**
@@ -321,7 +325,7 @@ const DISCOVERY_LEASE = { name: 'claustrum-roster', ttlMs: 30_000 };
321
325
  * before any rename performed inside `change`; if it throws, do not rename.
322
326
  */
323
327
  export async function mutateVaultRoster(path, change, options = {}) {
324
- return withLock(path, WRITE_LOCK, async (lock) => {
328
+ return withLock(path, { ...WRITE_LOCK, signal: options.signal }, async (lock) => {
325
329
  const current = await readVaultRoster(path);
326
330
  const { next, result } = await change(current, {
327
331
  assertOwned: () => lock.assertOwned(),
@@ -333,6 +337,7 @@ export async function mutateVaultRoster(path, change, options = {}) {
333
337
  await writeJsonAtomic(path, next, {
334
338
  beforeRename: async () => {
335
339
  await options.beforePublish?.();
340
+ options.signal?.throwIfAborted();
336
341
  await lock.assertOwned();
337
342
  },
338
343
  });
@@ -415,55 +420,73 @@ function receiptFence(row, receipt) {
415
420
  * write is fenced on the receipt the reading was taken with: it lands only
416
421
  * while the row still holds that receipt's credential (as representative or
417
422
  * alias) and account, so a slow read for a replaced account can never land on
418
- * its successor. Returns whether the observation was kept.
423
+ * its successor. Resolves false when the fence refuses the reading against the
424
+ * roster as it stands now (on disk, plus readings this process has not
425
+ * written yet), and true once the reading is queued for the next write. The
426
+ * write applies the fence again, so a rebind between the two still refuses.
419
427
  */
420
- export function recordVaultQuota(path, input) {
421
- return mutateVaultRoster(path, (current) => {
422
- const row = current?.rows.find((entry) => entry.routeId === input.routeId);
423
- const fence = !current || !row ? 'refuse' : receiptFence(row, input);
424
- if (!current || !row || fence === 'refuse')
425
- return { result: false };
426
- const quota = mergeQuotaObservation(row.quota, input.observation);
427
- // A bound account is `unclaimed`: the vault's list has not named it, and
428
- // the next projection keeps it as the account this credential last
429
- // logged into until the vault names one.
430
- const bound = fence === 'bind'
431
- ? { accountIdentity: input.accountIdentity, unclaimed: true }
432
- : {};
433
- return {
434
- next: {
435
- ...current,
436
- rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
437
- },
438
- result: true,
439
- };
428
+ export async function recordVaultQuota(path, input) {
429
+ const { routeId, observation, ...receipt } = input;
430
+ return enqueueFencedVaultQuota(path, {
431
+ routeId,
432
+ observation,
433
+ accountIdentity: receipt.accountIdentity,
434
+ receipt,
440
435
  });
441
436
  }
437
+ // The answer callers rely on (false for a replaced account) comes from the
438
+ // fence, so it runs here against the current roster before anything is
439
+ // queued. The read takes no lock: the roster file is replaced atomically.
440
+ async function enqueueFencedVaultQuota(path, publication) {
441
+ if (!isQuotaObservation(publication.observation))
442
+ return false;
443
+ const current = pendingVaultQuota(path, await readVaultRoster(path));
444
+ if (!applyVaultQuota(current, publication).kept)
445
+ return false;
446
+ return enqueueVaultQuota(path, publication);
447
+ }
442
448
  /**
443
449
  * Merge a quota observation that belongs to an account rather than to a
444
450
  * served credential, such as a background reading from insula. The write
445
451
  * lands only while the row is still bound to that exact known account, so a
446
452
  * reading taken before a replacement never reaches the successor. Unlike
447
453
  * `recordVaultQuota` it never binds an account: a row without a known account
448
- * is refused. Returns whether the observation was kept.
454
+ * is refused. Resolves false when the row is not bound to that account now,
455
+ * and true once the reading is queued, as `recordVaultQuota` does.
449
456
  */
450
457
  export function recordVaultAccountQuota(path, input) {
451
- return mutateVaultRoster(path, (current) => {
452
- const row = current?.rows.find((entry) => entry.routeId === input.routeId);
453
- if (!current ||
454
- !row ||
455
- row.accountIdentity === undefined ||
456
- row.accountIdentity !== input.accountIdentity)
457
- return { result: false };
458
- const quota = mergeQuotaObservation(row.quota, input.observation);
459
- return {
460
- next: {
461
- ...current,
462
- rows: current.rows.map((entry) => entry === row ? { ...entry, quota } : entry),
463
- },
464
- result: true,
465
- };
466
- });
458
+ return enqueueFencedVaultQuota(path, input);
459
+ }
460
+ /** Apply the same identity fence to memory overlays and durable quota batches. */
461
+ export function applyVaultQuota(current, input) {
462
+ const row = current?.rows.find((entry) => entry.routeId === input.routeId);
463
+ const fence = !row
464
+ ? 'refuse'
465
+ : input.receipt
466
+ ? receiptFence(row, input.receipt)
467
+ : row.accountIdentity !== undefined &&
468
+ row.accountIdentity === input.accountIdentity
469
+ ? 'hold'
470
+ : 'refuse';
471
+ if (!current || !row || fence === 'refuse')
472
+ return { roster: current, kept: false };
473
+ const quota = mergeQuotaObservation(row.quota, input.observation);
474
+ // Binding remembers which account this credential proved, without claiming
475
+ // that the vault's discovery has named that account yet.
476
+ const bound = fence === 'bind'
477
+ ? { accountIdentity: input.accountIdentity, unclaimed: true }
478
+ : {};
479
+ return {
480
+ roster: {
481
+ ...current,
482
+ rows: current.rows.map((entry) => entry === row ? { ...entry, ...bound, quota } : entry),
483
+ },
484
+ kept: true,
485
+ };
486
+ }
487
+ const pendingDiscoveries = new Map();
488
+ export function forgetPendingVaultDiscovery(path) {
489
+ pendingDiscoveries.delete(resolve(path));
467
490
  }
468
491
  /**
469
492
  * Discover and commit the roster. The lease covers the list call and the
@@ -471,12 +494,16 @@ export function recordVaultAccountQuota(path, input) {
471
494
  * from a newer one, so a run that lost its lease is refused at commit. A
472
495
  * failed list, or custody switching off mid-run, never writes anything; a
473
496
  * peer holding the lease means its last committed roster is served instead.
497
+ * A write-lock timeout keeps the discovered inventory for the next poll,
498
+ * unless a peer has superseded its base view in the meantime.
474
499
  */
475
500
  export async function refreshVaultRoster(options) {
476
501
  options.signal?.throwIfAborted();
477
502
  const active = async () => (await options.isActive?.()) ?? true;
478
- if (!(await active()))
503
+ if (!(await active())) {
504
+ forgetPendingVaultDiscovery(options.path);
479
505
  return undefined;
506
+ }
480
507
  const lease = await acquireRefreshFileLock({
481
508
  ...DISCOVERY_LEASE,
482
509
  path: options.path,
@@ -489,8 +516,25 @@ export async function refreshVaultRoster(options) {
489
516
  throw new ClaustrumConsumerError('roster-busy', 'Claustrum account discovery is already in progress');
490
517
  return persisted;
491
518
  }
519
+ const key = resolve(options.path);
520
+ let discovered;
492
521
  try {
493
- const inventory = await options.custody.discover(options.signal);
522
+ const previous = await readVaultRoster(options.path);
523
+ const pending = pendingDiscoveries.get(key);
524
+ // Opaque view cursors cannot be ordered. Reuse a discovery only while no
525
+ // peer has committed a different view since it was read.
526
+ discovered =
527
+ pending &&
528
+ (pending.previousView === previous?.view ||
529
+ pending.inventory.view === previous?.view)
530
+ ? pending
531
+ : {
532
+ inventory: await options.custody.discover(options.signal),
533
+ previousView: previous?.view,
534
+ };
535
+ pendingDiscoveries.delete(key);
536
+ const { inventory } = discovered;
537
+ options.onInventory?.(inventory);
494
538
  options.signal?.throwIfAborted();
495
539
  return await mutateVaultRoster(options.path, async (current) => {
496
540
  if (!(await active()))
@@ -503,8 +547,21 @@ export async function refreshVaultRoster(options) {
503
547
  const next = projectVaultRoster(current, inventory, projection);
504
548
  const changed = JSON.stringify(current) !== JSON.stringify(next);
505
549
  return { ...(changed && { next }), result: next };
550
+ }, {
551
+ beforePublish: async () => {
552
+ options.signal?.throwIfAborted();
553
+ await lease.assertOwned();
554
+ },
506
555
  });
507
556
  }
557
+ catch (error) {
558
+ if (error instanceof LockContentionError &&
559
+ discovered &&
560
+ !options.signal?.aborted &&
561
+ (await active()))
562
+ pendingDiscoveries.set(key, discovered);
563
+ throw error;
564
+ }
508
565
  finally {
509
566
  await lease.release();
510
567
  }
@@ -6,7 +6,7 @@
6
6
  // scope, orders the result the way the primitives expect, and carries each
7
7
  // entry's (scope, label) so a decision names the limit it judged rather than
8
8
  // the position it occupied.
9
- import { ALL_SCOPE, readQuotaCounters, } from "./905bffc0220f43d0-map.js";
9
+ import { ALL_SCOPE, isQuotaCredits, readQuotaCounters, } from "./905bffc0220f43d0-map.js";
10
10
  function project(entry) {
11
11
  if (entry.kind === 'retired') {
12
12
  return {
@@ -95,12 +95,14 @@ export function projectQuota(map, scope = ALL_SCOPE) {
95
95
  : undefined;
96
96
  const budget = map?.budget?.kind === 'reading' ? projectBudget(map.budget) : undefined;
97
97
  const counters = readQuotaCounters(map?.counters);
98
+ const credits = isQuotaCredits(map?.credits) ? map.credits : undefined;
98
99
  return {
99
100
  scope,
100
101
  limits,
101
102
  ...(checkedAt === undefined ? {} : { checkedAt }),
102
103
  ...(budget === undefined ? {} : { budget }),
103
104
  ...(counters === undefined ? {} : { counters }),
105
+ ...(credits === undefined ? {} : { credits }),
104
106
  };
105
107
  }
106
108
  /**
@@ -1,8 +1,5 @@
1
- import {
2
- execFileSync as defaultExecFileSync,
3
- spawn as defaultSpawn
4
- } from "node:child_process";
5
- export function openUrl(url, platform = process.platform, execFileSync = defaultExecFileSync, spawn = defaultSpawn) {
1
+ import { spawn as defaultSpawn } from "node:child_process";
2
+ export function openUrl(url, platform = process.platform, spawn = defaultSpawn) {
6
3
  try {
7
4
  if (platform === "win32") {
8
5
  const command = `Start-Process '${url.replaceAll("'", "''")}'`, encodedCommand = Buffer.from(command, "utf16le").toString("base64"), child = spawn("powershell.exe", [
@@ -17,11 +14,12 @@ export function openUrl(url, platform = process.platform, execFileSync = default
17
14
  child.unref();
18
15
  return child.pid !== void 0;
19
16
  }
20
- execFileSync(platform === "darwin" ? "open" : "xdg-open", [url], {
21
- stdio: "ignore",
22
- timeout: 3000
17
+ const child = spawn(platform === "darwin" ? "open" : "xdg-open", [url], {
18
+ stdio: "ignore"
23
19
  });
24
- return !0;
20
+ child.on("error", () => {});
21
+ child.unref();
22
+ return child.pid !== void 0;
25
23
  } catch {
26
24
  return !1;
27
25
  }
@@ -21,6 +21,7 @@ export class LockOwnershipError extends Error {
21
21
  export async function withLock(target, options, fn) {
22
22
  const started = performance.now();
23
23
  for (;;) {
24
+ options.signal?.throwIfAborted();
24
25
  const lock = await acquireRefreshFileLock({
25
26
  path: target,
26
27
  name: options.name,
@@ -33,6 +34,7 @@ export async function withLock(target, options, fn) {
33
34
  });
34
35
  if (lock) {
35
36
  try {
37
+ options.signal?.throwIfAborted();
36
38
  return await fn(lock);
37
39
  }
38
40
  finally {