@cortexkit/opencode-antigravity-auth 3.0.1 → 3.0.3

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 (149) hide show
  1. package/dist/cli.js.map +2 -2
  2. package/dist/cli.meta.json +5 -5
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +2877 -2851
  5. package/dist/index.js.map +4 -4
  6. package/dist/index.meta.json +120 -33
  7. package/dist/server.js +475 -427
  8. package/dist/server.js.map +4 -4
  9. package/dist/server.meta.json +1 -7313
  10. package/dist/src/cli.d.ts +1 -1
  11. package/dist/src/constants.d.ts +1 -1
  12. package/dist/src/ga/rpc/protocol.d.ts +1 -1
  13. package/dist/src/ga/server/index.d.ts +6 -6
  14. package/dist/src/ga/server/index.d.ts.map +1 -1
  15. package/dist/src/ga/server/index.js +29 -1
  16. package/dist/src/ga/server/index.js.map +1 -1
  17. package/dist/src/ga/tui/host-ga.d.ts +2 -2
  18. package/dist/src/hooks/auto-update-checker/checker.d.ts +1 -1
  19. package/dist/src/hooks/auto-update-checker/index.d.ts +4 -4
  20. package/dist/src/plugin/account-access.d.ts +6 -6
  21. package/dist/src/plugin/account-command-oauth.d.ts +2 -2
  22. package/dist/src/plugin/accounts.d.ts +3 -3
  23. package/dist/src/plugin/auth-doctor.d.ts +2 -2
  24. package/dist/src/plugin/auth-drift.d.ts +2 -2
  25. package/dist/src/plugin/auth-loader.d.ts +8 -8
  26. package/dist/src/plugin/auth-loader.d.ts.map +1 -1
  27. package/dist/src/plugin/auth-loader.js +1 -0
  28. package/dist/src/plugin/auth-loader.js.map +1 -1
  29. package/dist/src/plugin/background-quota-refresh.d.ts +3 -3
  30. package/dist/src/plugin/background-vault-quota-refresh.d.ts +43 -0
  31. package/dist/src/plugin/background-vault-quota-refresh.d.ts.map +1 -0
  32. package/dist/src/plugin/background-vault-quota-refresh.js +155 -0
  33. package/dist/src/plugin/background-vault-quota-refresh.js.map +1 -0
  34. package/dist/src/plugin/cache/index.d.ts +1 -1
  35. package/dist/src/plugin/cache/signature-cache.d.ts +2 -2
  36. package/dist/src/plugin/cache.d.ts +6 -6
  37. package/dist/src/plugin/cli.d.ts +6 -6
  38. package/dist/src/plugin/command-apply.d.ts +1 -1
  39. package/dist/src/plugin/command-data.d.ts +2 -246
  40. package/dist/src/plugin/command-data.d.ts.map +1 -1
  41. package/dist/src/plugin/command-data.js +4 -418
  42. package/dist/src/plugin/command-data.js.map +1 -1
  43. package/dist/src/plugin/commands.d.ts +4 -4
  44. package/dist/src/plugin/config/index.d.ts +2 -2
  45. package/dist/src/plugin/config/loader.d.ts +2 -2
  46. package/dist/src/plugin/config/models.d.ts +2 -2
  47. package/dist/src/plugin/config/writer.d.ts +2 -2
  48. package/dist/src/plugin/core/streaming/index.d.ts +2 -2
  49. package/dist/src/plugin/core/streaming/transformer.d.ts +1 -1
  50. package/dist/src/plugin/debug.d.ts +1 -1
  51. package/dist/src/plugin/dependencies.d.ts +3 -3
  52. package/dist/src/plugin/event-handler.d.ts +3 -3
  53. package/dist/src/plugin/fetch-interceptor.d.ts +10 -10
  54. package/dist/src/plugin/fetch-routing.d.ts +3 -3
  55. package/dist/src/plugin/ga-location-services.d.ts +10 -10
  56. package/dist/src/plugin/gemini-dump.d.ts +0 -22
  57. package/dist/src/plugin/gemini-dump.d.ts.map +1 -1
  58. package/dist/src/plugin/gemini-dump.js +0 -39
  59. package/dist/src/plugin/gemini-dump.js.map +1 -1
  60. package/dist/src/plugin/google-search-tool.d.ts +1 -1
  61. package/dist/src/plugin/index.d.ts +8 -6
  62. package/dist/src/plugin/index.d.ts.map +1 -1
  63. package/dist/src/plugin/index.js +41 -18
  64. package/dist/src/plugin/index.js.map +1 -1
  65. package/dist/src/plugin/killswitch.d.ts +1 -1
  66. package/dist/src/plugin/lifecycle.d.ts +2 -2
  67. package/dist/src/plugin/logger.d.ts +2 -2
  68. package/dist/src/plugin/oauth-login.d.ts +1 -1
  69. package/dist/src/plugin/oauth-methods.d.ts +9 -9
  70. package/dist/src/plugin/operator-settings.d.ts +3 -3
  71. package/dist/src/plugin/persist-account-pool.d.ts +1 -1
  72. package/dist/src/plugin/quota.d.ts +5 -4
  73. package/dist/src/plugin/quota.d.ts.map +1 -1
  74. package/dist/src/plugin/quota.js +1 -0
  75. package/dist/src/plugin/quota.js.map +1 -1
  76. package/dist/src/plugin/recovery/index.d.ts +3 -3
  77. package/dist/src/plugin/recovery/storage.d.ts +1 -1
  78. package/dist/src/plugin/recovery.d.ts +3 -3
  79. package/dist/src/plugin/refresh-queue.d.ts +3 -3
  80. package/dist/src/plugin/request-helpers.d.ts +1 -1
  81. package/dist/src/plugin/request-helpers.d.ts.map +1 -1
  82. package/dist/src/plugin/request-helpers.js +10 -417
  83. package/dist/src/plugin/request-helpers.js.map +1 -1
  84. package/dist/src/plugin/request.d.ts +9 -9
  85. package/dist/src/plugin/request.js +4 -4
  86. package/dist/src/plugin/session-context.d.ts +1 -1
  87. package/dist/src/plugin/shared/request-services.d.ts +9 -9
  88. package/dist/src/plugin/shared/runtime.d.ts +11 -11
  89. package/dist/src/plugin/shared/source-sidebar.d.ts +1 -1
  90. package/dist/src/plugin/shared/vault-menu.d.ts +1 -1
  91. package/dist/src/plugin/shared/vault-request-credentials.d.ts +3 -1
  92. package/dist/src/plugin/shared/vault-request-credentials.d.ts.map +1 -1
  93. package/dist/src/plugin/shared/vault-request-credentials.js +1 -1
  94. package/dist/src/plugin/shared/vault-request-credentials.js.map +1 -1
  95. package/dist/src/plugin/stores/signature-store.d.ts +1 -1
  96. package/dist/src/plugin/token.d.ts +1 -1
  97. package/dist/src/plugin/ui/auth-menu.d.ts +4 -4
  98. package/dist/src/plugin/ui/model-status.d.ts +3 -3
  99. package/dist/src/plugin/ui/quota-status.d.ts +2 -2
  100. package/dist/src/plugin/vault-activation.d.ts +1 -1
  101. package/dist/src/plugin/vault-quota-refresh.d.ts +7 -0
  102. package/dist/src/plugin/vault-quota-refresh.d.ts.map +1 -0
  103. package/dist/src/plugin/vault-quota-refresh.js +12 -0
  104. package/dist/src/plugin/vault-quota-refresh.js.map +1 -0
  105. package/dist/src/plugin/vault-sidebar-projection.d.ts +27 -0
  106. package/dist/src/plugin/vault-sidebar-projection.d.ts.map +1 -0
  107. package/dist/src/plugin/vault-sidebar-projection.js +34 -0
  108. package/dist/src/plugin/vault-sidebar-projection.js.map +1 -0
  109. package/dist/src/rpc/notifications.d.ts +1 -1
  110. package/dist/src/rpc/rpc-server.d.ts +1 -1
  111. package/dist/src/server.d.ts +3 -3
  112. package/dist/src/sidebar-state.d.ts +20 -13
  113. package/dist/src/sidebar-state.d.ts.map +1 -1
  114. package/dist/src/sidebar-state.js +20 -10
  115. package/dist/src/sidebar-state.js.map +1 -1
  116. package/dist/src/tui/command-dialogs.d.ts +1 -1
  117. package/dist/src/tui/host-v1.d.ts +3 -3
  118. package/dist/src/tui.d.ts +3 -3
  119. package/dist/src/tui.js +1 -1
  120. package/dist/tui-build-map.json +6 -6
  121. package/dist/types/src/ga/server/index.d.ts.map +1 -1
  122. package/dist/types/src/plugin/auth-loader.d.ts.map +1 -1
  123. package/dist/types/src/plugin/background-vault-quota-refresh.d.ts +43 -0
  124. package/dist/types/src/plugin/background-vault-quota-refresh.d.ts.map +1 -0
  125. package/dist/types/src/plugin/command-data.d.ts +2 -246
  126. package/dist/types/src/plugin/command-data.d.ts.map +1 -1
  127. package/dist/types/src/plugin/gemini-dump.d.ts +0 -22
  128. package/dist/types/src/plugin/gemini-dump.d.ts.map +1 -1
  129. package/dist/types/src/plugin/index.d.ts +2 -0
  130. package/dist/types/src/plugin/index.d.ts.map +1 -1
  131. package/dist/types/src/plugin/quota.d.ts +1 -0
  132. package/dist/types/src/plugin/quota.d.ts.map +1 -1
  133. package/dist/types/src/plugin/request-helpers.d.ts.map +1 -1
  134. package/dist/types/src/plugin/shared/vault-request-credentials.d.ts +2 -0
  135. package/dist/types/src/plugin/shared/vault-request-credentials.d.ts.map +1 -1
  136. package/dist/types/src/plugin/vault-quota-refresh.d.ts +7 -0
  137. package/dist/types/src/plugin/vault-quota-refresh.d.ts.map +1 -0
  138. package/dist/types/src/plugin/vault-sidebar-projection.d.ts +27 -0
  139. package/dist/types/src/plugin/vault-sidebar-projection.d.ts.map +1 -0
  140. package/dist/types/src/sidebar-state.d.ts +20 -13
  141. package/dist/types/src/sidebar-state.d.ts.map +1 -1
  142. package/package.json +2 -2
  143. package/src/plugin/command-data.ts +6 -712
  144. package/src/sidebar-state.ts +34 -15
  145. package/src/tui-compiled/sidebar-state.js +12 -5
  146. package/src/tui-compiled/tui.js +1 -1
  147. package/src/tui-raw/sidebar-state.js +12 -5
  148. package/src/tui-raw/tui.tsx +1 -1
  149. package/src/tui.tsx +1 -1
@@ -1,66 +1,10 @@
1
- /**
2
- * Privacy-safe data service for the data-first slash-command dialogs.
3
- *
4
- * `/antigravity-quota` (this task) and the future `/antigravity-account`
5
- * dialogs (Tasks 10-11) read from a single shared service so they
6
- * never touch raw account storage, never see the email PII field, and
7
- * never run quota network I/O during the dialog's open path — the
8
- * Refresh action is the only path that performs a live fetch.
9
- *
10
- * Why this is a separate module:
11
- *
12
- * - `commands.ts` owns the slash-command orchestration; mixing the row
13
- * projection + quota refresh logic in there would balloon the surface
14
- * for what is fundamentally a small read/refresh service.
15
- * - The row type (`CommandAccountRow`) is the projection that crosses
16
- * the PII firewall into the dialog payload. Defining it here (next
17
- * to the code that constructs it) keeps the firewall reviewable.
18
- * - Tests can pin the read-vs-refresh boundary, the email redaction, and
19
- * the refresh-token-keyed persistence in one file rather than chasing
20
- * them across the dispatcher + RPC + apply layers.
21
- *
22
- * Cache-only opening contract (Task 9 operator requirement):
23
- *
24
- * `listAccounts()` is a pure read of the live AccountManager view
25
- * the auth-loader materialized at session start. It performs
26
- * ZERO quota manager calls — opening the dialog must be instant even
27
- * when the network is unreachable, and quota refresh must remain an
28
- * explicit user-driven action so the cached percentages never quietly
29
- * rewrite themselves behind the user's back.
30
- *
31
- * Refresh-token-keyed persistence (Task 9 plan trap):
32
- *
33
- * `refreshQuota()` runs the shared quota manager across every enabled
34
- * account and folds the results back into the live AccountManager +
35
- * storage. Concurrent OAuth can renumber the flat `accounts[]` array
36
- * between read and write, so we re-read under the lock and key the
37
- * update by `refreshToken` (the canonical identity) rather than the
38
- * array index. This way a successful OAuth add that lands between
39
- * the read and the write cannot cause the refresh to overwrite the
40
- * wrong account.
41
- */
1
+ /** Privacy-safe cached account rows for OpenCode's account OAuth replies. */
42
2
 
43
3
  import { createHash } from 'node:crypto'
44
4
 
45
- import {
46
- buildSidebarMachineStateFromAccounts,
47
- normalizeLegacyCachedQuota,
48
- type SidebarAccountRedactionInput,
49
- setSidebarMachineState,
50
- toCapturedTier,
51
- } from '../sidebar-state'
5
+ import { normalizeLegacyCachedQuota, toCapturedTier } from '../sidebar-state'
52
6
 
53
- /**
54
- * Local copies of the few core types the data service touches.
55
- *
56
- * The shipped TUI tree cannot depend on the `@cortexkit/antigravity-auth-core`
57
- * barrel (only subpath imports like `./file-lock` are allowed) — the
58
- * compiled `command-data.ts` is copied verbatim into `tui-compiled/`,
59
- * and the type-import would otherwise leak the full core surface into
60
- * the dialog render path. Declaring the structural shape locally keeps
61
- * the compiled tree free of the barrel while preserving duck-typed
62
- * compatibility with the production quota manager.
63
- */
7
+ /** Pool-file fields needed to project cached account rows without importing core's barrel into renderer source. */
64
8
  type CommandDataAccountMetadata = {
65
9
  email?: string
66
10
  refreshToken: string
@@ -91,26 +35,6 @@ type CommandDataQuotaGroupSummary = {
91
35
  }>
92
36
  }
93
37
 
94
- type CommandDataAccountQuotaResult = {
95
- index: number
96
- status: 'ok' | 'disabled' | 'error'
97
- error?: string
98
- disabled?: boolean
99
- quota?: {
100
- groups: Partial<Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>>
101
- perModel?: Array<{
102
- modelId: string
103
- displayName?: string
104
- group: CommandDataQuotaGroup | null
105
- remainingFraction: number
106
- resetTime?: string
107
- }>
108
- modelCount: number
109
- error?: string
110
- }
111
- updatedAccount?: CommandDataAccountMetadata
112
- }
113
-
114
38
  export interface CommandDataAccountStorage {
115
39
  version: 4
116
40
  activeIndex: number
@@ -181,6 +105,7 @@ const SUPPORTED_QUOTA_KEYS = [
181
105
  ] as const satisfies readonly CommandAccountRow['quota'][number]['key'][]
182
106
 
183
107
  interface LiveAccountSnapshot {
108
+ email?: string
184
109
  index: number
185
110
  refreshToken: string
186
111
  label?: string
@@ -214,9 +139,8 @@ function toCommandAccountRow(entry: LiveAccountSnapshot): CommandAccountRow {
214
139
  : entry.cachedQuota
215
140
  // Normalize legacy pool keys (gemini-pro, gemini-flash, claude, gpt-oss)
216
141
  // to canonical keys before the SUPPORTED_QUOTA_KEYS loop. Without this
217
- // a legacy snapshot produces empty quota rows, and writeSidebar would
218
- // permanently re-emit legacy keys on every dialog open until the next
219
- // real quota refresh rewrites the on-disk snapshot.
142
+ // a legacy snapshot produces empty quota rows until a real quota refresh
143
+ // rewrites the on-disk snapshot.
220
144
  const cached = normalizeLegacyCachedQuota(
221
145
  rawCached as Parameters<typeof normalizeLegacyCachedQuota>[0],
222
146
  )
@@ -302,633 +226,3 @@ export function projectCommandAccountRows(
302
226
  }),
303
227
  )
304
228
  }
305
-
306
- /**
307
- * Live AccountManager view the data service needs.
308
- *
309
- * - `getAccounts()` returns the in-memory snapshot — used for cache-only
310
- * reads during dialog open and for re-reading the post-mutation state.
311
- * `getAccountsForQuotaCheck()` returns the freshest `AccountMetadataV3`
312
- * (the canonical shape the quota manager expects).
313
- * - `updateQuotaCache(index, groups)` + `requestSaveToDisk()` fold the
314
- * refreshed quota back into the live view and persist it. The service
315
- * calls them together after the network fetch resolves.
316
- * - `setAccountEnabled`, `setAccountCurrent`, `removeAccountByIndex`
317
- * mirror the CLI menu's mutation primitives — the dialog actions
318
- * (Task 10) reuse them so the TUI never invents its own mutation
319
- * logic. Each method returns `true` when the live view changed.
320
- * - `getRefreshTokenAt(index)` lets the service identify the canonical
321
- * account identity before it reaches the locked storage mutator; a
322
- * concurrent OAuth could renumber the flat array between the dialog
323
- * opening and the apply, so keying by refresh token is mandatory.
324
- * - `flushSaveToDisk()` drains the AccountManager's debounced save so a
325
- * dialog-triggered mutation lands on disk before the dialog's response
326
- * returns. Without it, the dialog could toast "Account removed" while
327
- * the file still carries the old pool.
328
- */
329
- export interface CommandDataAccountManagerView {
330
- getAccounts(): LiveAccountSnapshot[]
331
- getAccountsForQuotaCheck(): CommandDataAccountMetadata[]
332
- updateQuotaCache(
333
- index: number,
334
- groups: Partial<
335
- Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
336
- >,
337
- expectedRefreshToken?: string,
338
- ): void
339
- requestSaveToDisk(): void
340
- flushSaveToDisk(): Promise<void>
341
- /**
342
- * Per-family live cursor. Used by the remove path so the persisted
343
- * `activeIndexByFamily` follows each model's currently-active account
344
- * independently — collapsing both families to a single index would
345
- * silently unelect one family on every restart.
346
- */
347
- getActiveIndexByFamily(): { claude: number; gemini: number }
348
- /** Enable/disable the account at `index`. Returns true when it changed. */
349
- setAccountEnabled(index: number, enabled: boolean): boolean
350
- /** Pin `index` as the active account for every family the dialog cares about. */
351
- setAccountCurrent(index: number): boolean
352
- /** Remove the account at `index` from the live view. Returns true when it changed. */
353
- removeAccountByIndex(index: number): boolean
354
- /** Canonical refresh token for the account at `index`, or undefined. */
355
- getRefreshTokenAt(index: number): string | undefined
356
- }
357
-
358
- /**
359
- * Storage adapter the data service uses for re-read-under-lock writes.
360
- * `mutate` runs the supplied callback against the latest snapshot under
361
- * the file lock — the callback receives the current storage and may
362
- * return a replacement; returning the input unchanged is a no-op.
363
- *
364
- * The promise resolves with the (possibly mutated) storage snapshot so
365
- * the data service can await it. A rejected promise signals a failed
366
- * write — `AccountStorageUnreadableError`, lock contention, or any
367
- * other I/O failure — which the data service surfaces to the dialog
368
- * as a friendly error toast.
369
- */
370
- export interface CommandDataStorage {
371
- mutate(
372
- mutator: (
373
- current: CommandDataAccountStorage,
374
- ) =>
375
- | CommandDataAccountStorage
376
- | undefined
377
- | Promise<CommandDataAccountStorage | undefined>,
378
- ): Promise<CommandDataAccountStorage | undefined> | undefined
379
- }
380
-
381
- /**
382
- * Options for `createCommandDataService`. Each field is required so
383
- * production wiring is explicit — a missing dependency is a startup
384
- * error, not a silent no-op at dialog-open time.
385
- */
386
- export interface CommandDataServiceOptions {
387
- accountManagerView: CommandDataAccountManagerView
388
- quotaManager: {
389
- refreshAccounts(
390
- accounts: CommandDataAccountMetadata[],
391
- options: {
392
- indexFor?: (account: CommandDataAccountMetadata) => number
393
- force?: boolean
394
- },
395
- ): Promise<CommandDataAccountQuotaResult[]>
396
- }
397
- /** Path to the label-only sidebar state file. */
398
- sidebarStateFile: string
399
- /**
400
- * Optional storage adapter. When provided, the service persists the
401
- * refreshed quota to disk via the lock-held mutator (best-effort —
402
- * a lock contention never breaks the dialog response). When omitted,
403
- * the service still folds results into the live AccountManager view;
404
- * the auth-loader is responsible for the on-disk write.
405
- */
406
- storage?: CommandDataStorage
407
- /** Clock for `cachedQuotaUpdatedAt`. Tests inject a fixed clock. */
408
- now?: () => number
409
- }
410
-
411
- /**
412
- * Public surface of the command-data service. Each method is the
413
- * smallest possible projection so the dialog layer never has to know
414
- * how the underlying quota manager or storage adapter is wired.
415
- */
416
- export interface CommandDataService {
417
- /**
418
- * Cache-only snapshot. Performs zero quota manager calls — safe to
419
- * call as part of the dialog's open path.
420
- */
421
- listAccounts(): Promise<CommandAccountRow[]>
422
- /**
423
- * Force-refresh quota through the shared quota manager, persist by
424
- * refresh token, bump `cachedQuotaUpdatedAt`, and push a label-only
425
- * sidebar snapshot. Returns the freshly persisted rows so the
426
- * dialog can re-render in place.
427
- *
428
- * Bypass flag is intentional: explicit user-triggered refreshes
429
- * ("Refresh" button, direct slash-command with `refresh` argument)
430
- * must always fetch, even if the quota manager has backed off.
431
- */
432
- refreshQuota(): Promise<CommandAccountRow[]>
433
- /**
434
- * Non-forced quota check that RESPECTS the quota manager's per-account
435
- * backoff. Used by the dialog OPEN path — which is a VIEW, not a user
436
- * request to fetch. Accounts already fresh (or in backoff) are skipped;
437
- * the result is discarded (the sidebar poller carries the freshness
438
- * guarantee for idle accounts).
439
- */
440
- refreshQuotaRespectingBackoff(): Promise<void>
441
- /**
442
- * Pin `index` as the active account for every family the dialog
443
- * tracks (claude + gemini). Mutates the live AccountManager AND the
444
- * locked storage so the new active index survives a restart. Returns
445
- * the freshly projected rows so the dialog can re-render in place.
446
- * Returns `null` when the index is out of range or the account has
447
- * no refresh token — the dialog surfaces the null as a toast.
448
- */
449
- setCurrentAccount(index: number): Promise<CommandAccountRow[] | null>
450
- /**
451
- * Flip the `enabled` flag on the account at `index`. Mirrors the CLI
452
- * menu's "manage" toggle so the on-disk state matches what the CLI
453
- * would produce. Returns the freshly projected rows (or `null` when
454
- * the index is invalid or the account is ineligible).
455
- */
456
- toggleAccountEnabled(index: number): Promise<CommandAccountRow[] | null>
457
- /**
458
- * Remove the account at `index` from both the live view and the
459
- * locked storage. Removal renumbers the flat `accounts[]` array so
460
- * the returned rows use the freshest indices; callers MUST re-key
461
- * their transient dialog IDs (`acct-${index}`) by the row's `id`
462
- * field after this method returns.
463
- *
464
- * Returns `null` when the index is out of range — the dialog
465
- * surfaces the null as a toast.
466
- */
467
- removeAccount(index: number): Promise<CommandAccountRow[] | null>
468
- }
469
-
470
- /**
471
- * Build the data service.
472
- *
473
- * The factory form (instead of a module-level singleton) keeps the
474
- * service unit-testable: each test constructs its own dependencies
475
- * (storage stub, quota manager stub, fixed clock) without touching
476
- * the production quota path.
477
- */
478
- export function createCommandDataService(
479
- options: CommandDataServiceOptions,
480
- ): CommandDataService {
481
- const {
482
- accountManagerView,
483
- quotaManager,
484
- sidebarStateFile,
485
- storage,
486
- now = () => Date.now(),
487
- } = options
488
-
489
- const projectRows = (): CommandAccountRow[] =>
490
- accountManagerView.getAccounts().map(toCommandAccountRow)
491
-
492
- // Map a CommandAccountRow back to the SidebarAccountRedactionInput shape.
493
- // Extracted to prevent the "sixth dropped field" pattern: every field in
494
- // CommandAccountRow that must reach the sidebar lives here once, not as an
495
- // inline literal that a future author has to remember to update in two places.
496
- const toRedactionInput = (
497
- row: CommandAccountRow,
498
- ): SidebarAccountRedactionInput => {
499
- const gemini = row.quota.find((q) => q.key === 'gemini')
500
- const nonGemini = row.quota.find((q) => q.key === 'non-gemini')
501
- // Map a CommandAccountRow quota entry to cachedQuota pool shape
502
- // so projectQuotaPoolForSidebar (the canonical projection seam)
503
- // carries the per-window breakdown into the sidebar state.
504
- const toPool = (
505
- q:
506
- | {
507
- remainingPercent: number | null
508
- resetAt?: number
509
- windows?: CommandAccountRow['quota'][number]['windows']
510
- }
511
- | undefined,
512
- ) => {
513
- if (!q || q.remainingPercent == null) return undefined
514
- return {
515
- remainingFraction: q.remainingPercent / 100,
516
- resetTime:
517
- typeof q.resetAt === 'number' && Number.isFinite(q.resetAt)
518
- ? new Date(q.resetAt).toISOString()
519
- : undefined,
520
- windows: q.windows?.map((w) => ({
521
- window: w.window,
522
- remainingFraction: w.remainingPercent / 100,
523
- resetTime:
524
- typeof w.resetAt === 'number' && Number.isFinite(w.resetAt)
525
- ? new Date(w.resetAt).toISOString()
526
- : '',
527
- })),
528
- }
529
- }
530
- return {
531
- index: row.index,
532
- label: row.label,
533
- enabled: row.enabled,
534
- current: row.current,
535
- healthScore: row.healthScore,
536
- // Tier passes the redaction boundary unchanged (plan metadata, not PII).
537
- // The stamp check was already done by toCommandAccountRow.
538
- tier: row.tier,
539
- cachedQuotaUpdatedAt: row.cachedQuotaUpdatedAt,
540
- cachedQuota: {
541
- gemini: toPool(gemini),
542
- 'non-gemini': toPool(nonGemini),
543
- },
544
- }
545
- }
546
-
547
- const writeSidebar = (rows: CommandAccountRow[]): void => {
548
- const accounts = rows.map(toRedactionInput)
549
- // Fire-and-forget -- the sidebar writer is fenced by its own queue,
550
- // so a transient lock contention cannot block the dialog response.
551
- void setSidebarMachineState(
552
- buildSidebarMachineStateFromAccounts(accounts, { checkedAt: now() }),
553
- { stateFile: sidebarStateFile },
554
- ).catch(() => {
555
- // Sidebar writes are best-effort; the next command or quota refresh
556
- // will publish the current snapshot.
557
- })
558
- }
559
-
560
- return {
561
- async listAccounts() {
562
- return projectRows()
563
- },
564
-
565
- async refreshQuota() {
566
- const accountsForQuota = accountManagerView.getAccountsForQuotaCheck()
567
- if (accountsForQuota.length === 0) {
568
- // Empty pool: still push a clean sidebar so the TUI drops any
569
- // stale snapshot left over from a previous session.
570
- writeSidebar([])
571
- return []
572
- }
573
-
574
- const results = await quotaManager.refreshAccounts(accountsForQuota, {
575
- indexFor: (account) => accountsForQuota.indexOf(account),
576
- force: true,
577
- })
578
-
579
- const refreshedAt = now()
580
- const updates: Array<{
581
- refreshToken: string
582
- groups?: Partial<
583
- Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
584
- >
585
- }> = []
586
- for (const result of results) {
587
- const refreshToken =
588
- result.updatedAccount?.refreshToken ??
589
- accountsForQuota[result.index]?.refreshToken
590
- if (!refreshToken) continue
591
- const groups =
592
- result.status === 'ok' && result.quota?.groups
593
- ? result.quota.groups
594
- : undefined
595
- updates.push({ refreshToken, groups })
596
- }
597
-
598
- // Resolve live indexes only after the network request. Numeric indexes
599
- // from the quota result refer to the original input array and may now
600
- // identify a different account after a concurrent add/remove.
601
- const liveIndexByRefreshToken = new Map<string, number>()
602
- for (const entry of accountManagerView.getAccounts()) {
603
- liveIndexByRefreshToken.set(entry.refreshToken, entry.index)
604
- }
605
- let liveQuotaChanged = false
606
- for (const update of updates) {
607
- const liveIndex = liveIndexByRefreshToken.get(update.refreshToken)
608
- if (liveIndex === undefined || !update.groups) continue
609
- accountManagerView.updateQuotaCache(
610
- liveIndex,
611
- update.groups,
612
- update.refreshToken,
613
- )
614
- liveQuotaChanged = true
615
- }
616
- if (liveQuotaChanged) accountManagerView.requestSaveToDisk()
617
-
618
- // Persist by canonical refresh token against the latest locked snapshot.
619
- // A removed account is skipped rather than falling back to its old index.
620
- if (storage) {
621
- const updateByRefreshToken = new Map(
622
- updates.map((update) => [update.refreshToken, update]),
623
- )
624
- const writeResult = storage.mutate((current) => ({
625
- ...current,
626
- accounts: current.accounts.map((entry) => {
627
- const update = updateByRefreshToken.get(entry.refreshToken)
628
- if (!update) return entry
629
- if (update.groups) {
630
- return {
631
- ...entry,
632
- cachedQuota: update.groups,
633
- // Stamp the persisted quota with an opaque identity derived
634
- // from the refresh token so a later projection can detect
635
- // a stale snapshot after an account-index shift.
636
- cachedQuotaAccountId: quotaAccountIdentity(entry.refreshToken),
637
- cachedQuotaUpdatedAt: refreshedAt,
638
- }
639
- }
640
- // Error result keeps the previous cached percentage and only
641
- // records that a refresh was attempted.
642
- return {
643
- ...entry,
644
- cachedQuotaUpdatedAt: refreshedAt,
645
- }
646
- }),
647
- }))
648
- await Promise.resolve(writeResult).catch(() => {
649
- // The live AccountManager already carries successful refreshes; its
650
- // next save can reconcile a transient storage-lock failure.
651
- })
652
- }
653
-
654
- // Re-read the live view post-mutate so the rows reflect the
655
- // freshly persisted percentages.
656
- const rows = projectRows()
657
- writeSidebar(rows)
658
- return rows
659
- },
660
-
661
- async refreshQuotaRespectingBackoff() {
662
- const accountsForQuota = accountManagerView.getAccountsForQuotaCheck()
663
- if (accountsForQuota.length === 0) return
664
-
665
- // Non-forced: the quota manager's per-account backoff and dedup
666
- // apply. Accounts that are fresh or in backoff are skipped. Unlike
667
- // the earlier fire-and-forget path, successful results ARE folded
668
- // into the live cache so the sidebar snapshot reflects fresh data
669
- // immediately — the dialog renders ONLY from the sidebar file, and
670
- // the quota manager's push path does not update AccountManager
671
- // entries for this caller.
672
- let results: Awaited<ReturnType<typeof quotaManager.refreshAccounts>>
673
- try {
674
- results = await quotaManager.refreshAccounts(accountsForQuota, {
675
- indexFor: (account) => accountsForQuota.indexOf(account),
676
- force: false,
677
- })
678
- } catch {
679
- // Non-critical: dialog open must never fail because a background
680
- // refresh check encountered backoff or a transient error.
681
- return
682
- }
683
-
684
- // Build updates akin to the forced refresh path — resolve live
685
- // indexes after the network call (a concurrent add/remove may have
686
- // shifted positions), fold successful results into the live cache,
687
- // then push a sidebar snapshot.
688
- const updates: Array<{
689
- refreshToken: string
690
- groups?: Partial<
691
- Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
692
- >
693
- }> = []
694
- for (const result of results) {
695
- const refreshToken =
696
- result.updatedAccount?.refreshToken ??
697
- accountsForQuota[result.index]?.refreshToken
698
- if (!refreshToken) continue
699
- const groups =
700
- result.status === 'ok' && result.quota?.groups
701
- ? result.quota.groups
702
- : undefined
703
- updates.push({ refreshToken, groups })
704
- }
705
-
706
- if (updates.length === 0) return
707
-
708
- const liveIndexByRefreshToken = new Map<string, number>()
709
- for (const entry of accountManagerView.getAccounts()) {
710
- liveIndexByRefreshToken.set(entry.refreshToken, entry.index)
711
- }
712
- let liveQuotaChanged = false
713
- for (const update of updates) {
714
- const liveIndex = liveIndexByRefreshToken.get(update.refreshToken)
715
- if (liveIndex === undefined || !update.groups) continue
716
- accountManagerView.updateQuotaCache(
717
- liveIndex,
718
- update.groups,
719
- update.refreshToken,
720
- )
721
- liveQuotaChanged = true
722
- }
723
- if (liveQuotaChanged) accountManagerView.requestSaveToDisk()
724
-
725
- // Push a fresh sidebar snapshot — the live view now carries the
726
- // just-refreshed quota plus any skipped (backoff/fresh) accounts
727
- // whose cached percentages are unchanged.
728
- const rows = projectRows()
729
- writeSidebar(rows)
730
- },
731
-
732
- async setCurrentAccount(index) {
733
- return mutateLiveAndStorage({ action: 'setCurrent', index })
734
- },
735
-
736
- async toggleAccountEnabled(index) {
737
- return mutateLiveAndStorage({ action: 'toggleEnabled', index })
738
- },
739
-
740
- async removeAccount(index) {
741
- return mutateLiveAndStorage({ action: 'remove', index })
742
- },
743
- }
744
-
745
- async function mutateLiveAndStorage(args: {
746
- action: 'setCurrent' | 'toggleEnabled' | 'remove'
747
- index: number
748
- }): Promise<CommandAccountRow[] | null> {
749
- const { index, action } = args
750
- const target = accountManagerView.getAccounts()[index]
751
- if (!target) return null
752
- const refreshToken =
753
- accountManagerView.getRefreshTokenAt(index) ?? target.refreshToken
754
- if (!refreshToken) return null
755
- if (
756
- action === 'toggleEnabled' &&
757
- target.enabled === false &&
758
- target.accountIneligible === true
759
- ) {
760
- throw new Error(
761
- 'This account is ineligible and cannot be enabled until eligibility is rechecked.',
762
- )
763
- }
764
-
765
- if (!storage) {
766
- throw new Error(
767
- 'CommandDataService is missing a locked-storage adapter; account mutations are disabled.',
768
- )
769
- }
770
-
771
- // Capture the live view's current-account identity per family BEFORE
772
- // the storage mutation. The remove action must persist the index that
773
- // the same account will occupy AFTER the removal — unconditionally
774
- // resetting to 0 made a non-current removal promote whichever account
775
- // shifted into slot 0 to "active" on the next restart. Each model's
776
- // cursor is tracked independently, so collapse-tokens does not work
777
- // here: a Claude-active account and a Gemini-active account can
778
- // point at different rows.
779
- const liveCurrentTokens: { claude?: string; gemini?: string } = {}
780
- if (action === 'remove') {
781
- const liveAccounts = accountManagerView.getAccounts()
782
- const liveIndexes = accountManagerView.getActiveIndexByFamily()
783
- liveCurrentTokens.claude =
784
- liveAccounts[liveIndexes.claude]?.refreshToken ?? undefined
785
- liveCurrentTokens.gemini =
786
- liveAccounts[liveIndexes.gemini]?.refreshToken ?? undefined
787
- }
788
-
789
- let foundInStorage = false
790
- let desiredEnabled: boolean | undefined
791
- let previousEnabled: boolean | undefined
792
- let nextActiveIndex = 0
793
- let nextActiveIndexByFamily: { claude?: number; gemini?: number } = {
794
- claude: 0,
795
- gemini: 0,
796
- }
797
- await storage.mutate((current) => {
798
- const tokenIdx = current.accounts.findIndex(
799
- (entry) => entry.refreshToken === refreshToken,
800
- )
801
- if (tokenIdx === -1) return current
802
- foundInStorage = true
803
-
804
- if (action === 'setCurrent') {
805
- return {
806
- ...current,
807
- activeIndex: tokenIdx,
808
- activeIndexByFamily: { claude: tokenIdx, gemini: tokenIdx },
809
- }
810
- }
811
-
812
- if (action === 'toggleEnabled') {
813
- const entry = current.accounts[tokenIdx]
814
- if (!entry) return current
815
- previousEnabled = entry.enabled !== false
816
- desiredEnabled = entry.enabled === false
817
- if (desiredEnabled && entry.accountIneligible === true) {
818
- throw new Error(
819
- 'This account is ineligible and cannot be enabled until eligibility is rechecked.',
820
- )
821
- }
822
- return {
823
- ...current,
824
- accounts: current.accounts.map((account) =>
825
- account.refreshToken === refreshToken
826
- ? { ...account, enabled: desiredEnabled }
827
- : account,
828
- ),
829
- }
830
- }
831
-
832
- // remove: build the post-removal account list, then resolve the
833
- // current-account's NEW index in that list. If the removed
834
- // account was the live current, the captured token falls out of
835
- // the list — the live AccountManager leaves the numeric current
836
- // cursor at the SAME index (now occupied by whichever account
837
- // shifted in), so the persisted index must follow that numeric
838
- // slot rather than resetting to 0.
839
- const nextAccounts = current.accounts.filter(
840
- (account) => account.refreshToken !== refreshToken,
841
- )
842
- const resolveNextIndex = (
843
- liveToken: string | undefined,
844
- legacyIndex: number,
845
- ): number => {
846
- if (!liveToken)
847
- return Math.max(0, Math.min(legacyIndex, nextAccounts.length - 1))
848
- const found = nextAccounts.findIndex(
849
- (account) => account.refreshToken === liveToken,
850
- )
851
- if (found !== -1) return found
852
- // The captured current token was the one removed. Mirror the
853
- // core's in-memory semantics: keep the cursor at the removed
854
- // slot when it still exists in the new array; when the removed
855
- // slot was the last position and no longer exists, persist 0
856
- // (the core's buildStorageSnapshot clamps negative sentinels
857
- // to 0, and auth-doctor treats a negative activeIndex as
858
- // corruption — cf. auth-doctor.ts:149-156).
859
- //
860
- // Use tokenIdx (the storage-snapshot position) rather than the
861
- // outer-scope `index` (live-read position): a concurrent mutation
862
- // between the live read and the lock can diverge the two, and the
863
- // storage-level cursor must match the storage-level array.
864
- return tokenIdx < nextAccounts.length ? tokenIdx : 0
865
- }
866
- const legacyClaude =
867
- current.activeIndexByFamily?.claude ?? current.activeIndex
868
- const legacyGemini =
869
- current.activeIndexByFamily?.gemini ?? current.activeIndex
870
- nextActiveIndex = resolveNextIndex(liveCurrentTokens.claude, legacyClaude)
871
- nextActiveIndexByFamily = {
872
- claude: resolveNextIndex(liveCurrentTokens.claude, legacyClaude),
873
- gemini: resolveNextIndex(liveCurrentTokens.gemini, legacyGemini),
874
- }
875
- return {
876
- ...current,
877
- accounts: nextAccounts,
878
- activeIndex: nextActiveIndex,
879
- activeIndexByFamily: nextActiveIndexByFamily,
880
- }
881
- })
882
-
883
- if (!foundInStorage) return null
884
-
885
- // Re-resolve the live index by canonical identity after the awaited disk
886
- // transaction. A concurrent OAuth add/remove may have shifted every index.
887
- const liveIndex = accountManagerView
888
- .getAccounts()
889
- .findIndex((account) => account.refreshToken === refreshToken)
890
- let applied = action === 'remove' && liveIndex === -1
891
- if (liveIndex !== -1) {
892
- if (action === 'setCurrent') {
893
- applied = accountManagerView.setAccountCurrent(liveIndex)
894
- } else if (action === 'toggleEnabled') {
895
- applied = accountManagerView.setAccountEnabled(
896
- liveIndex,
897
- desiredEnabled === true,
898
- )
899
- if (!applied) {
900
- applied =
901
- accountManagerView.getAccounts()[liveIndex]?.enabled ===
902
- desiredEnabled
903
- }
904
- } else {
905
- applied = accountManagerView.removeAccountByIndex(liveIndex)
906
- }
907
- }
908
-
909
- if (!applied && action !== 'remove') {
910
- if (action === 'toggleEnabled' && previousEnabled !== undefined) {
911
- await storage.mutate((current) => ({
912
- ...current,
913
- accounts: current.accounts.map((account) =>
914
- account.refreshToken === refreshToken
915
- ? { ...account, enabled: previousEnabled }
916
- : account,
917
- ),
918
- }))
919
- }
920
- throw new Error(
921
- 'The account changed while the operation was being applied; reopen the dialog and try again.',
922
- )
923
- }
924
-
925
- await accountManagerView.flushSaveToDisk().catch(() => {
926
- // The locked storage mutation already committed; a later periodic flush
927
- // can reconcile transient AccountManager lock contention.
928
- })
929
-
930
- const rows = projectRows()
931
- writeSidebar(rows)
932
- return rows
933
- }
934
- }