@cortexkit/opencode-antigravity-auth 3.0.2 → 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.
@@ -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
@@ -215,9 +139,8 @@ function toCommandAccountRow(entry: LiveAccountSnapshot): CommandAccountRow {
215
139
  : entry.cachedQuota
216
140
  // Normalize legacy pool keys (gemini-pro, gemini-flash, claude, gpt-oss)
217
141
  // to canonical keys before the SUPPORTED_QUOTA_KEYS loop. Without this
218
- // a legacy snapshot produces empty quota rows, and writeSidebar would
219
- // permanently re-emit legacy keys on every dialog open until the next
220
- // 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.
221
144
  const cached = normalizeLegacyCachedQuota(
222
145
  rawCached as Parameters<typeof normalizeLegacyCachedQuota>[0],
223
146
  )
@@ -303,604 +226,3 @@ export function projectCommandAccountRows(
303
226
  }),
304
227
  )
305
228
  }
306
-
307
- /**
308
- * Live AccountManager view the data service needs.
309
- *
310
- * - `getAccounts()` returns the in-memory snapshot — used for cache-only
311
- * reads during dialog open and for re-reading the post-mutation state.
312
- * `getAccountsForQuotaCheck()` returns the freshest `AccountMetadataV3`
313
- * (the canonical shape the quota manager expects).
314
- * - `updateQuotaCache(index, groups)` + `requestSaveToDisk()` fold the
315
- * refreshed quota back into the live view and persist it. The service
316
- * calls them together after the network fetch resolves.
317
- * - `setAccountEnabled`, `setAccountCurrent`, `removeAccountByIndex`
318
- * mirror the CLI menu's mutation primitives — the dialog actions
319
- * (Task 10) reuse them so the TUI never invents its own mutation
320
- * logic. Each method returns `true` when the live view changed.
321
- * - `getRefreshTokenAt(index)` lets the service identify the canonical
322
- * account identity before it reaches the locked storage mutator; a
323
- * concurrent OAuth could renumber the flat array between the dialog
324
- * opening and the apply, so keying by refresh token is mandatory.
325
- * - `flushSaveToDisk()` drains the AccountManager's debounced save so a
326
- * dialog-triggered mutation lands on disk before the dialog's response
327
- * returns. Without it, the dialog could toast "Account removed" while
328
- * the file still carries the old pool.
329
- */
330
- export interface CommandDataAccountManagerView {
331
- getAccounts(): LiveAccountSnapshot[]
332
- getAccountsForQuotaCheck(): CommandDataAccountMetadata[]
333
- updateQuotaCache(
334
- index: number,
335
- groups: Partial<
336
- Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
337
- >,
338
- expectedRefreshToken?: string,
339
- ): void
340
- requestSaveToDisk(): void
341
- flushSaveToDisk(): Promise<void>
342
- /**
343
- * Per-family live cursor. Used by the remove path so the persisted
344
- * `activeIndexByFamily` follows each model's currently-active account
345
- * independently — collapsing both families to a single index would
346
- * silently unelect one family on every restart.
347
- */
348
- getActiveIndexByFamily(): { claude: number; gemini: number }
349
- /** Enable/disable the account at `index`. Returns true when it changed. */
350
- setAccountEnabled(index: number, enabled: boolean): boolean
351
- /** Pin `index` as the active account for every family the dialog cares about. */
352
- setAccountCurrent(index: number): boolean
353
- /** Remove the account at `index` from the live view. Returns true when it changed. */
354
- removeAccountByIndex(index: number): boolean
355
- /** Canonical refresh token for the account at `index`, or undefined. */
356
- getRefreshTokenAt(index: number): string | undefined
357
- }
358
-
359
- /**
360
- * Storage adapter the data service uses for re-read-under-lock writes.
361
- * `mutate` runs the supplied callback against the latest snapshot under
362
- * the file lock — the callback receives the current storage and may
363
- * return a replacement; returning the input unchanged is a no-op.
364
- *
365
- * The promise resolves with the (possibly mutated) storage snapshot so
366
- * the data service can await it. A rejected promise signals a failed
367
- * write — `AccountStorageUnreadableError`, lock contention, or any
368
- * other I/O failure — which the data service surfaces to the dialog
369
- * as a friendly error toast.
370
- */
371
- export interface CommandDataStorage {
372
- mutate(
373
- mutator: (
374
- current: CommandDataAccountStorage,
375
- ) =>
376
- | CommandDataAccountStorage
377
- | undefined
378
- | Promise<CommandDataAccountStorage | undefined>,
379
- ): Promise<CommandDataAccountStorage | undefined> | undefined
380
- }
381
-
382
- /**
383
- * Options for `createCommandDataService`. Each field is required so
384
- * production wiring is explicit — a missing dependency is a startup
385
- * error, not a silent no-op at dialog-open time.
386
- */
387
- export interface CommandDataServiceOptions {
388
- accountManagerView: CommandDataAccountManagerView
389
- quotaManager: {
390
- refreshAccounts(
391
- accounts: CommandDataAccountMetadata[],
392
- options: {
393
- indexFor?: (account: CommandDataAccountMetadata) => number
394
- force?: boolean
395
- },
396
- ): Promise<CommandDataAccountQuotaResult[]>
397
- }
398
- /** Path to the owner-only sidebar state file. */
399
- sidebarStateFile: string
400
- /**
401
- * Optional storage adapter. When provided, the service persists the
402
- * refreshed quota to disk via the lock-held mutator (best-effort —
403
- * a lock contention never breaks the dialog response). When omitted,
404
- * the service still folds results into the live AccountManager view;
405
- * the auth-loader is responsible for the on-disk write.
406
- */
407
- storage?: CommandDataStorage
408
- /** Clock for `cachedQuotaUpdatedAt`. Tests inject a fixed clock. */
409
- now?: () => number
410
- }
411
-
412
- /**
413
- * Public surface of the command-data service. Each method is the
414
- * smallest possible projection so the dialog layer never has to know
415
- * how the underlying quota manager or storage adapter is wired.
416
- */
417
- export interface CommandDataService {
418
- /**
419
- * Cache-only snapshot. Performs zero quota manager calls — safe to
420
- * call as part of the dialog's open path.
421
- */
422
- listAccounts(): Promise<CommandAccountRow[]>
423
- /**
424
- * Force-refresh quota through the shared quota manager. Associate each
425
- * result with its live refresh-token identity rather than an index that
426
- * may have moved, persist it, and publish an owner-only sidebar snapshot.
427
- * Returns the freshly persisted rows so the
428
- * dialog can re-render in place.
429
- *
430
- * Bypass flag is intentional: explicit user-triggered refreshes
431
- * ("Refresh" button, direct slash-command with `refresh` argument)
432
- * must always fetch, even if the quota manager has backed off.
433
- */
434
- refreshQuota(): Promise<CommandAccountRow[]>
435
- /**
436
- * Non-forced quota check that RESPECTS the quota manager's per-account
437
- * backoff. Used by the dialog OPEN path — which is a VIEW, not a user
438
- * request to fetch. Accounts already fresh (or in backoff) are skipped;
439
- * the result is discarded (the sidebar poller carries the freshness
440
- * guarantee for idle accounts).
441
- */
442
- refreshQuotaRespectingBackoff(): Promise<void>
443
- /**
444
- * Pin `index` as the active account for every family the dialog
445
- * tracks (claude + gemini). Mutates the live AccountManager AND the
446
- * locked storage so the new active index survives a restart. Returns
447
- * the freshly projected rows so the dialog can re-render in place.
448
- * Returns `null` when the index is out of range or the account has
449
- * no refresh token — the dialog surfaces the null as a toast.
450
- */
451
- setCurrentAccount(index: number): Promise<CommandAccountRow[] | null>
452
- /**
453
- * Flip the `enabled` flag on the account at `index`. Mirrors the CLI
454
- * menu's "manage" toggle so the on-disk state matches what the CLI
455
- * would produce. Returns the freshly projected rows (or `null` when
456
- * the index is invalid or the account is ineligible).
457
- */
458
- toggleAccountEnabled(index: number): Promise<CommandAccountRow[] | null>
459
- /**
460
- * Remove the account at `index` from both the live view and the
461
- * locked storage. Removal renumbers the flat `accounts[]` array so
462
- * the returned rows use the freshest indices; callers MUST re-key
463
- * their transient dialog IDs (`acct-${index}`) by the row's `id`
464
- * field after this method returns.
465
- *
466
- * Returns `null` when the index is out of range — the dialog
467
- * surfaces the null as a toast.
468
- */
469
- removeAccount(index: number): Promise<CommandAccountRow[] | null>
470
- }
471
-
472
- /**
473
- * Build the data service.
474
- *
475
- * The factory form (instead of a module-level singleton) keeps the
476
- * service unit-testable: each test constructs its own dependencies
477
- * (storage stub, quota manager stub, fixed clock) without touching
478
- * the production quota path.
479
- */
480
- export function createCommandDataService(
481
- options: CommandDataServiceOptions,
482
- ): CommandDataService {
483
- const {
484
- accountManagerView,
485
- quotaManager,
486
- sidebarStateFile,
487
- storage,
488
- now = () => Date.now(),
489
- } = options
490
-
491
- const projectRows = (): CommandAccountRow[] =>
492
- accountManagerView.getAccounts().map(toCommandAccountRow)
493
-
494
- // Keep dialog/RPC rows redacted and rounded. The owner-only sidebar uses
495
- // private live metadata so known email and original quota precision survive.
496
- const toRedactionInput = (
497
- row: CommandAccountRow,
498
- ): SidebarAccountRedactionInput => {
499
- const live = accountManagerView
500
- .getAccounts()
501
- .find((entry) => entry.index === row.index)
502
- return {
503
- index: row.index,
504
- email: live?.email,
505
- enabled: row.enabled,
506
- current: row.current,
507
- healthScore: row.healthScore,
508
- coolingDownUntil: row.coolingDownUntil,
509
- tier: row.tier,
510
- cachedQuota: live?.cachedQuota,
511
- cachedQuotaUpdatedAt: live?.cachedQuotaUpdatedAt,
512
- cachedQuotaAccountId: live?.cachedQuotaAccountId,
513
- currentQuotaAccountId: live
514
- ? quotaAccountIdentity(live.refreshToken)
515
- : undefined,
516
- }
517
- }
518
-
519
- const writeSidebar = (rows: CommandAccountRow[]): void => {
520
- const accounts = rows.map(toRedactionInput)
521
- // Fire-and-forget -- the sidebar writer is fenced by its own queue,
522
- // so a transient lock contention cannot block the dialog response.
523
- void setSidebarMachineState(
524
- buildSidebarMachineStateFromAccounts(accounts, { checkedAt: now() }),
525
- { stateFile: sidebarStateFile },
526
- ).catch(() => {
527
- // Sidebar writes are best-effort; the next command or quota refresh
528
- // will publish the current snapshot.
529
- })
530
- }
531
-
532
- return {
533
- async listAccounts() {
534
- return projectRows()
535
- },
536
-
537
- async refreshQuota() {
538
- const accountsForQuota = accountManagerView.getAccountsForQuotaCheck()
539
- if (accountsForQuota.length === 0) {
540
- // Empty pool: still push a clean sidebar so the TUI drops any
541
- // stale snapshot left over from a previous session.
542
- writeSidebar([])
543
- return []
544
- }
545
-
546
- const results = await quotaManager.refreshAccounts(accountsForQuota, {
547
- indexFor: (account) => accountsForQuota.indexOf(account),
548
- force: true,
549
- })
550
-
551
- const refreshedAt = now()
552
- const updates: Array<{
553
- refreshToken: string
554
- groups?: Partial<
555
- Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
556
- >
557
- }> = []
558
- for (const result of results) {
559
- const refreshToken =
560
- result.updatedAccount?.refreshToken ??
561
- accountsForQuota[result.index]?.refreshToken
562
- if (!refreshToken) continue
563
- const groups =
564
- result.status === 'ok' && result.quota?.groups
565
- ? result.quota.groups
566
- : undefined
567
- updates.push({ refreshToken, groups })
568
- }
569
-
570
- // Resolve live indexes only after the network request. Numeric indexes
571
- // from the quota result refer to the original input array and may now
572
- // identify a different account after a concurrent add/remove.
573
- const liveIndexByRefreshToken = new Map<string, number>()
574
- for (const entry of accountManagerView.getAccounts()) {
575
- liveIndexByRefreshToken.set(entry.refreshToken, entry.index)
576
- }
577
- let liveQuotaChanged = false
578
- for (const update of updates) {
579
- const liveIndex = liveIndexByRefreshToken.get(update.refreshToken)
580
- if (liveIndex === undefined || !update.groups) continue
581
- accountManagerView.updateQuotaCache(
582
- liveIndex,
583
- update.groups,
584
- update.refreshToken,
585
- )
586
- liveQuotaChanged = true
587
- }
588
- if (liveQuotaChanged) accountManagerView.requestSaveToDisk()
589
-
590
- // Persist by canonical refresh token against the latest locked snapshot.
591
- // A removed account is skipped rather than falling back to its old index.
592
- if (storage) {
593
- const updateByRefreshToken = new Map(
594
- updates.map((update) => [update.refreshToken, update]),
595
- )
596
- const writeResult = storage.mutate((current) => ({
597
- ...current,
598
- accounts: current.accounts.map((entry) => {
599
- const update = updateByRefreshToken.get(entry.refreshToken)
600
- if (!update) return entry
601
- if (update.groups) {
602
- return {
603
- ...entry,
604
- cachedQuota: update.groups,
605
- // Stamp the persisted quota with an opaque identity derived
606
- // from the refresh token so a later projection can detect
607
- // a stale snapshot after an account-index shift.
608
- cachedQuotaAccountId: quotaAccountIdentity(entry.refreshToken),
609
- cachedQuotaUpdatedAt: refreshedAt,
610
- }
611
- }
612
- // Error result keeps the previous cached percentage and only
613
- // records that a refresh was attempted.
614
- return {
615
- ...entry,
616
- cachedQuotaUpdatedAt: refreshedAt,
617
- }
618
- }),
619
- }))
620
- await Promise.resolve(writeResult).catch(() => {
621
- // The live AccountManager already carries successful refreshes; its
622
- // next save can reconcile a transient storage-lock failure.
623
- })
624
- }
625
-
626
- // Re-read the live view post-mutate so the rows reflect the
627
- // freshly persisted percentages.
628
- const rows = projectRows()
629
- writeSidebar(rows)
630
- return rows
631
- },
632
-
633
- async refreshQuotaRespectingBackoff() {
634
- const accountsForQuota = accountManagerView.getAccountsForQuotaCheck()
635
- if (accountsForQuota.length === 0) return
636
-
637
- // Non-forced: the quota manager's per-account backoff and dedup
638
- // apply. Accounts that are fresh or in backoff are skipped. Unlike
639
- // the earlier fire-and-forget path, successful results ARE folded
640
- // into the live cache so the sidebar snapshot reflects fresh data
641
- // immediately — the dialog renders ONLY from the sidebar file, and
642
- // the quota manager's push path does not update AccountManager
643
- // entries for this caller.
644
- let results: Awaited<ReturnType<typeof quotaManager.refreshAccounts>>
645
- try {
646
- results = await quotaManager.refreshAccounts(accountsForQuota, {
647
- indexFor: (account) => accountsForQuota.indexOf(account),
648
- force: false,
649
- })
650
- } catch {
651
- // Non-critical: dialog open must never fail because a background
652
- // refresh check encountered backoff or a transient error.
653
- return
654
- }
655
-
656
- // Build updates akin to the forced refresh path — resolve live
657
- // indexes after the network call (a concurrent add/remove may have
658
- // shifted positions), fold successful results into the live cache,
659
- // then push a sidebar snapshot.
660
- const updates: Array<{
661
- refreshToken: string
662
- groups?: Partial<
663
- Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
664
- >
665
- }> = []
666
- for (const result of results) {
667
- const refreshToken =
668
- result.updatedAccount?.refreshToken ??
669
- accountsForQuota[result.index]?.refreshToken
670
- if (!refreshToken) continue
671
- const groups =
672
- result.status === 'ok' && result.quota?.groups
673
- ? result.quota.groups
674
- : undefined
675
- updates.push({ refreshToken, groups })
676
- }
677
-
678
- if (updates.length === 0) return
679
-
680
- const liveIndexByRefreshToken = new Map<string, number>()
681
- for (const entry of accountManagerView.getAccounts()) {
682
- liveIndexByRefreshToken.set(entry.refreshToken, entry.index)
683
- }
684
- let liveQuotaChanged = false
685
- for (const update of updates) {
686
- const liveIndex = liveIndexByRefreshToken.get(update.refreshToken)
687
- if (liveIndex === undefined || !update.groups) continue
688
- accountManagerView.updateQuotaCache(
689
- liveIndex,
690
- update.groups,
691
- update.refreshToken,
692
- )
693
- liveQuotaChanged = true
694
- }
695
- if (liveQuotaChanged) accountManagerView.requestSaveToDisk()
696
-
697
- // Push a fresh sidebar snapshot — the live view now carries the
698
- // just-refreshed quota plus any skipped (backoff/fresh) accounts
699
- // whose cached percentages are unchanged.
700
- const rows = projectRows()
701
- writeSidebar(rows)
702
- },
703
-
704
- async setCurrentAccount(index) {
705
- return mutateLiveAndStorage({ action: 'setCurrent', index })
706
- },
707
-
708
- async toggleAccountEnabled(index) {
709
- return mutateLiveAndStorage({ action: 'toggleEnabled', index })
710
- },
711
-
712
- async removeAccount(index) {
713
- return mutateLiveAndStorage({ action: 'remove', index })
714
- },
715
- }
716
-
717
- async function mutateLiveAndStorage(args: {
718
- action: 'setCurrent' | 'toggleEnabled' | 'remove'
719
- index: number
720
- }): Promise<CommandAccountRow[] | null> {
721
- const { index, action } = args
722
- const target = accountManagerView.getAccounts()[index]
723
- if (!target) return null
724
- const refreshToken =
725
- accountManagerView.getRefreshTokenAt(index) ?? target.refreshToken
726
- if (!refreshToken) return null
727
- if (
728
- action === 'toggleEnabled' &&
729
- target.enabled === false &&
730
- target.accountIneligible === true
731
- ) {
732
- throw new Error(
733
- 'This account is ineligible and cannot be enabled until eligibility is rechecked.',
734
- )
735
- }
736
-
737
- if (!storage) {
738
- throw new Error(
739
- 'CommandDataService is missing a locked-storage adapter; account mutations are disabled.',
740
- )
741
- }
742
-
743
- // Capture the live view's current-account identity per family BEFORE
744
- // the storage mutation. The remove action must persist the index that
745
- // the same account will occupy AFTER the removal — unconditionally
746
- // resetting to 0 made a non-current removal promote whichever account
747
- // shifted into slot 0 to "active" on the next restart. Each model's
748
- // cursor is tracked independently, so collapse-tokens does not work
749
- // here: a Claude-active account and a Gemini-active account can
750
- // point at different rows.
751
- const liveCurrentTokens: { claude?: string; gemini?: string } = {}
752
- if (action === 'remove') {
753
- const liveAccounts = accountManagerView.getAccounts()
754
- const liveIndexes = accountManagerView.getActiveIndexByFamily()
755
- liveCurrentTokens.claude =
756
- liveAccounts[liveIndexes.claude]?.refreshToken ?? undefined
757
- liveCurrentTokens.gemini =
758
- liveAccounts[liveIndexes.gemini]?.refreshToken ?? undefined
759
- }
760
-
761
- let foundInStorage = false
762
- let desiredEnabled: boolean | undefined
763
- let previousEnabled: boolean | undefined
764
- let nextActiveIndex = 0
765
- let nextActiveIndexByFamily: { claude?: number; gemini?: number } = {
766
- claude: 0,
767
- gemini: 0,
768
- }
769
- await storage.mutate((current) => {
770
- const tokenIdx = current.accounts.findIndex(
771
- (entry) => entry.refreshToken === refreshToken,
772
- )
773
- if (tokenIdx === -1) return current
774
- foundInStorage = true
775
-
776
- if (action === 'setCurrent') {
777
- return {
778
- ...current,
779
- activeIndex: tokenIdx,
780
- activeIndexByFamily: { claude: tokenIdx, gemini: tokenIdx },
781
- }
782
- }
783
-
784
- if (action === 'toggleEnabled') {
785
- const entry = current.accounts[tokenIdx]
786
- if (!entry) return current
787
- previousEnabled = entry.enabled !== false
788
- desiredEnabled = entry.enabled === false
789
- if (desiredEnabled && entry.accountIneligible === true) {
790
- throw new Error(
791
- 'This account is ineligible and cannot be enabled until eligibility is rechecked.',
792
- )
793
- }
794
- return {
795
- ...current,
796
- accounts: current.accounts.map((account) =>
797
- account.refreshToken === refreshToken
798
- ? { ...account, enabled: desiredEnabled }
799
- : account,
800
- ),
801
- }
802
- }
803
-
804
- // remove: build the post-removal account list, then resolve the
805
- // current-account's NEW index in that list. If the removed
806
- // account was the live current, the captured token falls out of
807
- // the list — the live AccountManager leaves the numeric current
808
- // cursor at the SAME index (now occupied by whichever account
809
- // shifted in), so the persisted index must follow that numeric
810
- // slot rather than resetting to 0.
811
- const nextAccounts = current.accounts.filter(
812
- (account) => account.refreshToken !== refreshToken,
813
- )
814
- const resolveNextIndex = (
815
- liveToken: string | undefined,
816
- legacyIndex: number,
817
- ): number => {
818
- if (!liveToken)
819
- return Math.max(0, Math.min(legacyIndex, nextAccounts.length - 1))
820
- const found = nextAccounts.findIndex(
821
- (account) => account.refreshToken === liveToken,
822
- )
823
- if (found !== -1) return found
824
- // The captured current token was the one removed. Mirror the
825
- // core's in-memory semantics: keep the cursor at the removed
826
- // slot when it still exists in the new array; when the removed
827
- // slot was the last position and no longer exists, persist 0
828
- // (the core's buildStorageSnapshot clamps negative sentinels
829
- // to 0, and auth-doctor treats a negative activeIndex as
830
- // corruption — cf. auth-doctor.ts:149-156).
831
- //
832
- // Use tokenIdx (the storage-snapshot position) rather than the
833
- // outer-scope `index` (live-read position): a concurrent mutation
834
- // between the live read and the lock can diverge the two, and the
835
- // storage-level cursor must match the storage-level array.
836
- return tokenIdx < nextAccounts.length ? tokenIdx : 0
837
- }
838
- const legacyClaude =
839
- current.activeIndexByFamily?.claude ?? current.activeIndex
840
- const legacyGemini =
841
- current.activeIndexByFamily?.gemini ?? current.activeIndex
842
- nextActiveIndex = resolveNextIndex(liveCurrentTokens.claude, legacyClaude)
843
- nextActiveIndexByFamily = {
844
- claude: resolveNextIndex(liveCurrentTokens.claude, legacyClaude),
845
- gemini: resolveNextIndex(liveCurrentTokens.gemini, legacyGemini),
846
- }
847
- return {
848
- ...current,
849
- accounts: nextAccounts,
850
- activeIndex: nextActiveIndex,
851
- activeIndexByFamily: nextActiveIndexByFamily,
852
- }
853
- })
854
-
855
- if (!foundInStorage) return null
856
-
857
- // Re-resolve the live index by canonical identity after the awaited disk
858
- // transaction. A concurrent OAuth add/remove may have shifted every index.
859
- const liveIndex = accountManagerView
860
- .getAccounts()
861
- .findIndex((account) => account.refreshToken === refreshToken)
862
- let applied = action === 'remove' && liveIndex === -1
863
- if (liveIndex !== -1) {
864
- if (action === 'setCurrent') {
865
- applied = accountManagerView.setAccountCurrent(liveIndex)
866
- } else if (action === 'toggleEnabled') {
867
- applied = accountManagerView.setAccountEnabled(
868
- liveIndex,
869
- desiredEnabled === true,
870
- )
871
- if (!applied) {
872
- applied =
873
- accountManagerView.getAccounts()[liveIndex]?.enabled ===
874
- desiredEnabled
875
- }
876
- } else {
877
- applied = accountManagerView.removeAccountByIndex(liveIndex)
878
- }
879
- }
880
-
881
- if (!applied && action !== 'remove') {
882
- if (action === 'toggleEnabled' && previousEnabled !== undefined) {
883
- await storage.mutate((current) => ({
884
- ...current,
885
- accounts: current.accounts.map((account) =>
886
- account.refreshToken === refreshToken
887
- ? { ...account, enabled: previousEnabled }
888
- : account,
889
- ),
890
- }))
891
- }
892
- throw new Error(
893
- 'The account changed while the operation was being applied; reopen the dialog and try again.',
894
- )
895
- }
896
-
897
- await accountManagerView.flushSaveToDisk().catch(() => {
898
- // The locked storage mutation already committed; a later periodic flush
899
- // can reconcile transient AccountManager lock contention.
900
- })
901
-
902
- const rows = projectRows()
903
- writeSidebar(rows)
904
- return rows
905
- }
906
- }