@cortexkit/opencode-antigravity-auth 2.0.0 → 2.1.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 (77) hide show
  1. package/README.md +3 -8
  2. package/dist/cli.js +330 -83
  3. package/dist/cli.js.map +4 -4
  4. package/dist/index.js +17039 -15773
  5. package/dist/index.js.map +4 -4
  6. package/dist/src/plugin/account-command-oauth.d.ts +34 -0
  7. package/dist/src/plugin/account-command-oauth.d.ts.map +1 -0
  8. package/dist/src/plugin/account-command-oauth.js +156 -0
  9. package/dist/src/plugin/account-command-oauth.js.map +1 -0
  10. package/dist/src/plugin/auth-loader.d.ts +16 -1
  11. package/dist/src/plugin/auth-loader.d.ts.map +1 -1
  12. package/dist/src/plugin/auth-loader.js +113 -32
  13. package/dist/src/plugin/auth-loader.js.map +1 -1
  14. package/dist/src/plugin/background-quota-refresh.d.ts +146 -0
  15. package/dist/src/plugin/background-quota-refresh.d.ts.map +1 -0
  16. package/dist/src/plugin/background-quota-refresh.js +347 -0
  17. package/dist/src/plugin/background-quota-refresh.js.map +1 -0
  18. package/dist/src/plugin/command-data.d.ts +61 -8
  19. package/dist/src/plugin/command-data.d.ts.map +1 -1
  20. package/dist/src/plugin/command-data.js +231 -39
  21. package/dist/src/plugin/command-data.js.map +1 -1
  22. package/dist/src/plugin/commands.d.ts +12 -9
  23. package/dist/src/plugin/commands.d.ts.map +1 -1
  24. package/dist/src/plugin/commands.js +140 -33
  25. package/dist/src/plugin/commands.js.map +1 -1
  26. package/dist/src/plugin/config/schema.d.ts +2 -0
  27. package/dist/src/plugin/config/schema.d.ts.map +1 -1
  28. package/dist/src/plugin/config/schema.js +24 -0
  29. package/dist/src/plugin/config/schema.js.map +1 -1
  30. package/dist/src/plugin/debug.d.ts +7 -0
  31. package/dist/src/plugin/debug.d.ts.map +1 -1
  32. package/dist/src/plugin/debug.js +25 -0
  33. package/dist/src/plugin/debug.js.map +1 -1
  34. package/dist/src/plugin/fetch-interceptor.d.ts.map +1 -1
  35. package/dist/src/plugin/fetch-interceptor.js +13 -1
  36. package/dist/src/plugin/fetch-interceptor.js.map +1 -1
  37. package/dist/src/plugin/index.d.ts +7 -0
  38. package/dist/src/plugin/index.d.ts.map +1 -1
  39. package/dist/src/plugin/index.js +127 -24
  40. package/dist/src/plugin/index.js.map +1 -1
  41. package/dist/src/plugin/killswitch.d.ts.map +1 -1
  42. package/dist/src/plugin/killswitch.js +12 -9
  43. package/dist/src/plugin/killswitch.js.map +1 -1
  44. package/dist/src/plugin/oauth-methods.d.ts.map +1 -1
  45. package/dist/src/plugin/oauth-methods.js +2 -10
  46. package/dist/src/plugin/oauth-methods.js.map +1 -1
  47. package/dist/src/plugin/quota.d.ts +42 -1
  48. package/dist/src/plugin/quota.d.ts.map +1 -1
  49. package/dist/src/plugin/quota.js +185 -41
  50. package/dist/src/plugin/quota.js.map +1 -1
  51. package/dist/src/plugin/request.d.ts.map +1 -1
  52. package/dist/src/plugin/request.js +6 -2
  53. package/dist/src/plugin/request.js.map +1 -1
  54. package/dist/src/plugin/ui/auth-menu.d.ts.map +1 -1
  55. package/dist/src/plugin/ui/auth-menu.js +2 -4
  56. package/dist/src/plugin/ui/auth-menu.js.map +1 -1
  57. package/dist/src/plugin/ui/quota-status.d.ts.map +1 -1
  58. package/dist/src/plugin/ui/quota-status.js +3 -5
  59. package/dist/src/plugin/ui/quota-status.js.map +1 -1
  60. package/dist/src/sidebar-state.d.ts +126 -9
  61. package/dist/src/sidebar-state.d.ts.map +1 -1
  62. package/dist/src/sidebar-state.js +185 -25
  63. package/dist/src/sidebar-state.js.map +1 -1
  64. package/dist/src/tui/command-dialogs.js +101 -3
  65. package/dist/src/tui/command-dialogs.js.map +1 -1
  66. package/dist/src/tui.d.ts.map +1 -1
  67. package/dist/src/tui.js +55 -20
  68. package/dist/src/tui.js.map +1 -1
  69. package/package.json +6 -6
  70. package/src/plugin/command-data.ts +331 -51
  71. package/src/sidebar-state.ts +306 -32
  72. package/src/tui/command-dialogs.tsx +136 -3
  73. package/src/tui-compiled/plugin/command-data.ts +331 -51
  74. package/src/tui-compiled/sidebar-state.ts +306 -32
  75. package/src/tui-compiled/tui/command-dialogs.tsx +189 -69
  76. package/src/tui-compiled/tui.tsx +73 -30
  77. package/src/tui.tsx +82 -28
@@ -40,10 +40,14 @@
40
40
  * wrong account.
41
41
  */
42
42
 
43
+ import { createHash } from 'node:crypto'
44
+
43
45
  import {
44
46
  buildSidebarMachineStateFromAccounts,
47
+ normalizeLegacyCachedQuota,
45
48
  type SidebarAccountRedactionInput,
46
49
  setSidebarMachineState,
50
+ toCapturedTier,
47
51
  } from '../sidebar-state'
48
52
 
49
53
  /**
@@ -70,19 +74,21 @@ type CommandDataAccountMetadata = {
70
74
  Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
71
75
  >
72
76
  cachedQuotaUpdatedAt?: number
77
+ cachedQuotaAccountId?: string
73
78
  accountIneligible?: boolean
74
79
  }
75
80
 
76
- type CommandDataQuotaGroup =
77
- | 'claude'
78
- | 'gemini-pro'
79
- | 'gemini-flash'
80
- | 'gpt-oss'
81
+ type CommandDataQuotaGroup = 'gemini' | 'non-gemini'
81
82
 
82
83
  type CommandDataQuotaGroupSummary = {
83
84
  remainingFraction?: number
84
85
  resetTime?: string
85
86
  modelCount: number
87
+ windows?: Array<{
88
+ window: 'weekly' | '5h'
89
+ remainingFraction: number
90
+ resetTime: string
91
+ }>
86
92
  }
87
93
 
88
94
  type CommandDataAccountQuotaResult = {
@@ -105,7 +111,7 @@ type CommandDataAccountQuotaResult = {
105
111
  updatedAccount?: CommandDataAccountMetadata
106
112
  }
107
113
 
108
- type CommandDataAccountStorage = {
114
+ export interface CommandDataAccountStorage {
109
115
  version: 4
110
116
  activeIndex: number
111
117
  activeIndexByFamily?: { claude?: number; gemini?: number }
@@ -133,12 +139,27 @@ export interface CommandAccountRow {
133
139
  enabled: boolean
134
140
  /** `true` when this row matches the harness-active account. */
135
141
  current: boolean
142
+ /**
143
+ * Absolute timestamp (ms) until which this account is rate-limited.
144
+ * Carried on the row so the sidebar refresher can match cooldowns
145
+ * without relying on unstable numeric indices.
146
+ */
147
+ coolingDownUntil?: number
148
+ /** Health score in [0, 100], forwarded from the live tracker. */
149
+ healthScore?: number
136
150
  quota: Array<{
137
- key: 'claude' | 'gemini-pro' | 'gemini-flash' | 'gpt-oss'
151
+ key: 'gemini' | 'non-gemini'
138
152
  label: string
139
153
  remainingPercent: number | null
140
154
  resetAt?: number
155
+ windows?: Array<{
156
+ window: 'weekly' | '5h'
157
+ remainingPercent: number
158
+ resetAt?: number
159
+ }>
141
160
  }>
161
+ /** Captured plan tier. Absent when unknown; never defaulted. */
162
+ tier?: { id: string; capturedAt: number }
142
163
  }
143
164
 
144
165
  /**
@@ -149,17 +170,13 @@ const QUOTA_GROUP_LABELS: Record<
149
170
  CommandAccountRow['quota'][number]['key'],
150
171
  string
151
172
  > = {
152
- claude: 'Claude',
153
- 'gemini-pro': 'Gemini Pro',
154
- 'gemini-flash': 'Gemini Flash',
155
- 'gpt-oss': 'GPT-OSS',
173
+ gemini: 'Gemini',
174
+ 'non-gemini': 'Non-Gemini',
156
175
  }
157
176
 
158
177
  const SUPPORTED_QUOTA_KEYS = [
159
- 'claude',
160
- 'gemini-pro',
161
- 'gemini-flash',
162
- 'gpt-oss',
178
+ 'gemini',
179
+ 'non-gemini',
163
180
  ] as const satisfies readonly CommandAccountRow['quota'][number]['key'][]
164
181
 
165
182
  interface LiveAccountSnapshot {
@@ -172,11 +189,36 @@ interface LiveAccountSnapshot {
172
189
  Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
173
190
  >
174
191
  cachedQuotaUpdatedAt?: number
192
+ cachedQuotaAccountId?: string
175
193
  accountIneligible?: boolean
194
+ coolingDownUntil?: number
195
+ healthScore?: number
196
+ /** Captured plan tier from the most recent loadCodeAssist response. */
197
+ capturedTierId?: string
198
+ /** Captured paid-tier ID from the most recent loadCodeAssist response. */
199
+ capturedPaidTierId?: string
200
+ /** Epoch ms when capturedTierId was last recorded. */
201
+ capturedTierAt?: number
176
202
  }
177
203
 
178
204
  function toCommandAccountRow(entry: LiveAccountSnapshot): CommandAccountRow {
179
- const cached = entry.cachedQuota
205
+ // Stamp mismatch: the cached quota was captured for a different account
206
+ // (the refresh token changed, or an index shift placed another account's
207
+ // snapshot at this position). Drop the stale cache rather than rendering
208
+ // the wrong account's quota percentages.
209
+ const rawCached =
210
+ entry.cachedQuotaAccountId &&
211
+ entry.cachedQuotaAccountId !== quotaAccountIdentity(entry.refreshToken)
212
+ ? undefined
213
+ : entry.cachedQuota
214
+ // Normalize legacy pool keys (gemini-pro, gemini-flash, claude, gpt-oss)
215
+ // to canonical keys before the SUPPORTED_QUOTA_KEYS loop. Without this
216
+ // a legacy snapshot produces empty quota rows, and writeSidebar would
217
+ // permanently re-emit legacy keys on every dialog open until the next
218
+ // real quota refresh rewrites the on-disk snapshot.
219
+ const cached = normalizeLegacyCachedQuota(
220
+ rawCached as Parameters<typeof normalizeLegacyCachedQuota>[0],
221
+ )
180
222
  const quota: CommandAccountRow['quota'] = []
181
223
  for (const key of SUPPORTED_QUOTA_KEYS) {
182
224
  const cachedEntry = cached?.[key]
@@ -194,11 +236,27 @@ function toCommandAccountRow(entry: LiveAccountSnapshot): CommandAccountRow {
194
236
  const parsed = Date.parse(cachedEntry.resetTime)
195
237
  if (Number.isFinite(parsed)) resetAt = parsed
196
238
  }
239
+ // Carry per-window breakdown when the cache was populated from the
240
+ // windowed RUQS path — the sidebar writer projects through here.
241
+ const windows = cachedEntry.windows?.length
242
+ ? cachedEntry.windows.map((w) => ({
243
+ window: w.window,
244
+ remainingPercent: Math.round(w.remainingFraction * 100),
245
+ resetAt:
246
+ typeof w.resetTime === 'string' && w.resetTime.length > 0
247
+ ? (() => {
248
+ const parsed = Date.parse(w.resetTime)
249
+ return Number.isFinite(parsed) ? parsed : undefined
250
+ })()
251
+ : undefined,
252
+ }))
253
+ : undefined
197
254
  quota.push({
198
255
  key,
199
256
  label: QUOTA_GROUP_LABELS[key],
200
257
  remainingPercent,
201
258
  resetAt,
259
+ windows,
202
260
  })
203
261
  }
204
262
  const label = `Account ${entry.index + 1}`
@@ -208,10 +266,41 @@ function toCommandAccountRow(entry: LiveAccountSnapshot): CommandAccountRow {
208
266
  label,
209
267
  enabled: entry.enabled,
210
268
  current: entry.active,
269
+ coolingDownUntil: entry.coolingDownUntil,
270
+ healthScore: entry.healthScore,
211
271
  quota,
272
+ tier: toCapturedTier(entry),
212
273
  }
213
274
  }
214
275
 
276
+ /**
277
+ * Opaque identity derived from a refresh token. Antigravity refresh tokens
278
+ * are stable (they do not rotate), so this hash is a durable, prunable
279
+ * identity to detect a stale cached quota after an account-index shift.
280
+ */
281
+ function quotaAccountIdentity(refreshToken: string): string {
282
+ return createHash('sha256').update(refreshToken).digest('hex').slice(0, 16)
283
+ }
284
+
285
+ export function projectCommandAccountRows(
286
+ storage: CommandDataAccountStorage | null | undefined,
287
+ ): CommandAccountRow[] {
288
+ if (!storage) return []
289
+ const activeIndex = storage.activeIndexByFamily?.claude ?? storage.activeIndex
290
+ return storage.accounts.map((entry, index) =>
291
+ toCommandAccountRow({
292
+ index,
293
+ refreshToken: entry.refreshToken,
294
+ label: entry.label,
295
+ enabled: entry.enabled !== false,
296
+ active: index === activeIndex,
297
+ cachedQuota: entry.cachedQuota,
298
+ cachedQuotaUpdatedAt: entry.cachedQuotaUpdatedAt,
299
+ cachedQuotaAccountId: entry.cachedQuotaAccountId,
300
+ }),
301
+ )
302
+ }
303
+
215
304
  /**
216
305
  * Live AccountManager view the data service needs.
217
306
  *
@@ -234,8 +323,6 @@ function toCommandAccountRow(entry: LiveAccountSnapshot): CommandAccountRow {
234
323
  * dialog-triggered mutation lands on disk before the dialog's response
235
324
  * returns. Without it, the dialog could toast "Account removed" while
236
325
  * the file still carries the old pool.
237
- * - `activeIndex()` returns the position the harness considers active;
238
- * the service uses it to mark the matching row as `current: true`.
239
326
  */
240
327
  export interface CommandDataAccountManagerView {
241
328
  getAccounts(): LiveAccountSnapshot[]
@@ -245,10 +332,17 @@ export interface CommandDataAccountManagerView {
245
332
  groups: Partial<
246
333
  Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
247
334
  >,
335
+ expectedRefreshToken?: string,
248
336
  ): void
249
337
  requestSaveToDisk(): void
250
338
  flushSaveToDisk(): Promise<void>
251
- activeIndex(): number
339
+ /**
340
+ * Per-family live cursor. Used by the remove path so the persisted
341
+ * `activeIndexByFamily` follows each model's currently-active account
342
+ * independently — collapsing both families to a single index would
343
+ * silently unelect one family on every restart.
344
+ */
345
+ getActiveIndexByFamily(): { claude: number; gemini: number }
252
346
  /** Enable/disable the account at `index`. Returns true when it changed. */
253
347
  setAccountEnabled(index: number, enabled: boolean): boolean
254
348
  /** Pin `index` as the active account for every family the dialog cares about. */
@@ -328,8 +422,20 @@ export interface CommandDataService {
328
422
  * refresh token, bump `cachedQuotaUpdatedAt`, and push a label-only
329
423
  * sidebar snapshot. Returns the freshly persisted rows so the
330
424
  * dialog can re-render in place.
425
+ *
426
+ * Bypass flag is intentional: explicit user-triggered refreshes
427
+ * ("Refresh" button, direct slash-command with `refresh` argument)
428
+ * must always fetch, even if the quota manager has backed off.
331
429
  */
332
430
  refreshQuota(): Promise<CommandAccountRow[]>
431
+ /**
432
+ * Non-forced quota check that RESPECTS the quota manager's per-account
433
+ * backoff. Used by the dialog OPEN path — which is a VIEW, not a user
434
+ * request to fetch. Accounts already fresh (or in backoff) are skipped;
435
+ * the result is discarded (the sidebar poller carries the freshness
436
+ * guarantee for idle accounts).
437
+ */
438
+ refreshQuotaRespectingBackoff(): Promise<void>
333
439
  /**
334
440
  * Pin `index` as the active account for every family the dialog
335
441
  * tracks (claude + gemini). Mutates the live AccountManager AND the
@@ -381,39 +487,70 @@ export function createCommandDataService(
381
487
  const projectRows = (): CommandAccountRow[] =>
382
488
  accountManagerView.getAccounts().map(toCommandAccountRow)
383
489
 
384
- const writeSidebar = (rows: CommandAccountRow[]): void => {
385
- const accounts: SidebarAccountRedactionInput[] = rows.map((row) => {
386
- const claude = row.quota.find((q) => q.key === 'claude')
387
- const geminiPro = row.quota.find((q) => q.key === 'gemini-pro')
388
- const geminiFlash = row.quota.find((q) => q.key === 'gemini-flash')
389
- const gptOss = row.quota.find((q) => q.key === 'gpt-oss')
390
- const toFraction = (
391
- q: { remainingPercent: number | null } | undefined,
392
- ): { remainingFraction?: number; resetTime?: string } | undefined => {
393
- if (!q || q.remainingPercent == null) return undefined
394
- return { remainingFraction: q.remainingPercent / 100 }
395
- }
490
+ // Map a CommandAccountRow back to the SidebarAccountRedactionInput shape.
491
+ // Extracted to prevent the "sixth dropped field" pattern: every field in
492
+ // CommandAccountRow that must reach the sidebar lives here once, not as an
493
+ // inline literal that a future author has to remember to update in two places.
494
+ const toRedactionInput = (
495
+ row: CommandAccountRow,
496
+ ): SidebarAccountRedactionInput => {
497
+ const gemini = row.quota.find((q) => q.key === 'gemini')
498
+ const nonGemini = row.quota.find((q) => q.key === 'non-gemini')
499
+ // Map a CommandAccountRow quota entry to cachedQuota pool shape
500
+ // so projectQuotaPoolForSidebar (the canonical projection seam)
501
+ // carries the per-window breakdown into the sidebar state.
502
+ const toPool = (
503
+ q:
504
+ | {
505
+ remainingPercent: number | null
506
+ resetAt?: number
507
+ windows?: CommandAccountRow['quota'][number]['windows']
508
+ }
509
+ | undefined,
510
+ ) => {
511
+ if (!q || q.remainingPercent == null) return undefined
396
512
  return {
397
- index: row.index,
398
- label: row.label,
399
- enabled: row.enabled,
400
- current: row.current,
401
- cachedQuota: {
402
- claude: toFraction(claude),
403
- 'gemini-pro': toFraction(geminiPro),
404
- 'gemini-flash': toFraction(geminiFlash),
405
- 'gpt-oss': toFraction(gptOss),
406
- },
513
+ remainingFraction: q.remainingPercent / 100,
514
+ resetTime:
515
+ typeof q.resetAt === 'number' && Number.isFinite(q.resetAt)
516
+ ? new Date(q.resetAt).toISOString()
517
+ : undefined,
518
+ windows: q.windows?.map((w) => ({
519
+ window: w.window,
520
+ remainingFraction: w.remainingPercent / 100,
521
+ resetTime:
522
+ typeof w.resetAt === 'number' && Number.isFinite(w.resetAt)
523
+ ? new Date(w.resetAt).toISOString()
524
+ : '',
525
+ })),
407
526
  }
408
- })
409
- // Fire-and-forget — the sidebar writer is fenced by its own queue,
527
+ }
528
+ return {
529
+ index: row.index,
530
+ label: row.label,
531
+ enabled: row.enabled,
532
+ current: row.current,
533
+ healthScore: row.healthScore,
534
+ // Tier passes the redaction boundary unchanged (plan metadata, not PII).
535
+ // The stamp check was already done by toCommandAccountRow.
536
+ tier: row.tier,
537
+ cachedQuota: {
538
+ gemini: toPool(gemini),
539
+ 'non-gemini': toPool(nonGemini),
540
+ },
541
+ }
542
+ }
543
+
544
+ const writeSidebar = (rows: CommandAccountRow[]): void => {
545
+ const accounts = rows.map(toRedactionInput)
546
+ // Fire-and-forget -- the sidebar writer is fenced by its own queue,
410
547
  // so a transient lock contention cannot block the dialog response.
411
548
  void setSidebarMachineState(
412
549
  buildSidebarMachineStateFromAccounts(accounts, { checkedAt: now() }),
413
550
  { stateFile: sidebarStateFile },
414
551
  ).catch(() => {
415
- // Lock contention is the only realistic failure mode; the next
416
- // periodic writer will catch up.
552
+ // Sidebar writes are best-effort; the next command or quota refresh
553
+ // will publish the current snapshot.
417
554
  })
418
555
  }
419
556
 
@@ -466,7 +603,11 @@ export function createCommandDataService(
466
603
  for (const update of updates) {
467
604
  const liveIndex = liveIndexByRefreshToken.get(update.refreshToken)
468
605
  if (liveIndex === undefined || !update.groups) continue
469
- accountManagerView.updateQuotaCache(liveIndex, update.groups)
606
+ accountManagerView.updateQuotaCache(
607
+ liveIndex,
608
+ update.groups,
609
+ update.refreshToken,
610
+ )
470
611
  liveQuotaChanged = true
471
612
  }
472
613
  if (liveQuotaChanged) accountManagerView.requestSaveToDisk()
@@ -486,6 +627,10 @@ export function createCommandDataService(
486
627
  return {
487
628
  ...entry,
488
629
  cachedQuota: update.groups,
630
+ // Stamp the persisted quota with an opaque identity derived
631
+ // from the refresh token so a later projection can detect
632
+ // a stale snapshot after an account-index shift.
633
+ cachedQuotaAccountId: quotaAccountIdentity(entry.refreshToken),
489
634
  cachedQuotaUpdatedAt: refreshedAt,
490
635
  }
491
636
  }
@@ -510,6 +655,77 @@ export function createCommandDataService(
510
655
  return rows
511
656
  },
512
657
 
658
+ async refreshQuotaRespectingBackoff() {
659
+ const accountsForQuota = accountManagerView.getAccountsForQuotaCheck()
660
+ if (accountsForQuota.length === 0) return
661
+
662
+ // Non-forced: the quota manager's per-account backoff and dedup
663
+ // apply. Accounts that are fresh or in backoff are skipped. Unlike
664
+ // the earlier fire-and-forget path, successful results ARE folded
665
+ // into the live cache so the sidebar snapshot reflects fresh data
666
+ // immediately — the dialog renders ONLY from the sidebar file, and
667
+ // the quota manager's push path does not update AccountManager
668
+ // entries for this caller.
669
+ let results: Awaited<ReturnType<typeof quotaManager.refreshAccounts>>
670
+ try {
671
+ results = await quotaManager.refreshAccounts(accountsForQuota, {
672
+ indexFor: (account) => accountsForQuota.indexOf(account),
673
+ force: false,
674
+ })
675
+ } catch {
676
+ // Non-critical: dialog open must never fail because a background
677
+ // refresh check encountered backoff or a transient error.
678
+ return
679
+ }
680
+
681
+ // Build updates akin to the forced refresh path — resolve live
682
+ // indexes after the network call (a concurrent add/remove may have
683
+ // shifted positions), fold successful results into the live cache,
684
+ // then push a sidebar snapshot.
685
+ const updates: Array<{
686
+ refreshToken: string
687
+ groups?: Partial<
688
+ Record<CommandDataQuotaGroup, CommandDataQuotaGroupSummary>
689
+ >
690
+ }> = []
691
+ for (const result of results) {
692
+ const refreshToken =
693
+ result.updatedAccount?.refreshToken ??
694
+ accountsForQuota[result.index]?.refreshToken
695
+ if (!refreshToken) continue
696
+ const groups =
697
+ result.status === 'ok' && result.quota?.groups
698
+ ? result.quota.groups
699
+ : undefined
700
+ updates.push({ refreshToken, groups })
701
+ }
702
+
703
+ if (updates.length === 0) return
704
+
705
+ const liveIndexByRefreshToken = new Map<string, number>()
706
+ for (const entry of accountManagerView.getAccounts()) {
707
+ liveIndexByRefreshToken.set(entry.refreshToken, entry.index)
708
+ }
709
+ let liveQuotaChanged = false
710
+ for (const update of updates) {
711
+ const liveIndex = liveIndexByRefreshToken.get(update.refreshToken)
712
+ if (liveIndex === undefined || !update.groups) continue
713
+ accountManagerView.updateQuotaCache(
714
+ liveIndex,
715
+ update.groups,
716
+ update.refreshToken,
717
+ )
718
+ liveQuotaChanged = true
719
+ }
720
+ if (liveQuotaChanged) accountManagerView.requestSaveToDisk()
721
+
722
+ // Push a fresh sidebar snapshot — the live view now carries the
723
+ // just-refreshed quota plus any skipped (backoff/fresh) accounts
724
+ // whose cached percentages are unchanged.
725
+ const rows = projectRows()
726
+ writeSidebar(rows)
727
+ },
728
+
513
729
  async setCurrentAccount(index) {
514
730
  return mutateLiveAndStorage({ action: 'setCurrent', index })
515
731
  },
@@ -549,9 +765,32 @@ export function createCommandDataService(
549
765
  )
550
766
  }
551
767
 
768
+ // Capture the live view's current-account identity per family BEFORE
769
+ // the storage mutation. The remove action must persist the index that
770
+ // the same account will occupy AFTER the removal — unconditionally
771
+ // resetting to 0 made a non-current removal promote whichever account
772
+ // shifted into slot 0 to "active" on the next restart. Each model's
773
+ // cursor is tracked independently, so collapse-tokens does not work
774
+ // here: a Claude-active account and a Gemini-active account can
775
+ // point at different rows.
776
+ const liveCurrentTokens: { claude?: string; gemini?: string } = {}
777
+ if (action === 'remove') {
778
+ const liveAccounts = accountManagerView.getAccounts()
779
+ const liveIndexes = accountManagerView.getActiveIndexByFamily()
780
+ liveCurrentTokens.claude =
781
+ liveAccounts[liveIndexes.claude]?.refreshToken ?? undefined
782
+ liveCurrentTokens.gemini =
783
+ liveAccounts[liveIndexes.gemini]?.refreshToken ?? undefined
784
+ }
785
+
552
786
  let foundInStorage = false
553
787
  let desiredEnabled: boolean | undefined
554
788
  let previousEnabled: boolean | undefined
789
+ let nextActiveIndex = 0
790
+ let nextActiveIndexByFamily: { claude?: number; gemini?: number } = {
791
+ claude: 0,
792
+ gemini: 0,
793
+ }
555
794
  await storage.mutate((current) => {
556
795
  const tokenIdx = current.accounts.findIndex(
557
796
  (entry) => entry.refreshToken === refreshToken,
@@ -587,13 +826,54 @@ export function createCommandDataService(
587
826
  }
588
827
  }
589
828
 
829
+ // remove: build the post-removal account list, then resolve the
830
+ // current-account's NEW index in that list. If the removed
831
+ // account was the live current, the captured token falls out of
832
+ // the list — the live AccountManager leaves the numeric current
833
+ // cursor at the SAME index (now occupied by whichever account
834
+ // shifted in), so the persisted index must follow that numeric
835
+ // slot rather than resetting to 0.
836
+ const nextAccounts = current.accounts.filter(
837
+ (account) => account.refreshToken !== refreshToken,
838
+ )
839
+ const resolveNextIndex = (
840
+ liveToken: string | undefined,
841
+ legacyIndex: number,
842
+ ): number => {
843
+ if (!liveToken)
844
+ return Math.max(0, Math.min(legacyIndex, nextAccounts.length - 1))
845
+ const found = nextAccounts.findIndex(
846
+ (account) => account.refreshToken === liveToken,
847
+ )
848
+ if (found !== -1) return found
849
+ // The captured current token was the one removed. Mirror the
850
+ // core's in-memory semantics: keep the cursor at the removed
851
+ // slot when it still exists in the new array; when the removed
852
+ // slot was the last position and no longer exists, persist 0
853
+ // (the core's buildStorageSnapshot clamps negative sentinels
854
+ // to 0, and auth-doctor treats a negative activeIndex as
855
+ // corruption — cf. auth-doctor.ts:149-156).
856
+ //
857
+ // Use tokenIdx (the storage-snapshot position) rather than the
858
+ // outer-scope `index` (live-read position): a concurrent mutation
859
+ // between the live read and the lock can diverge the two, and the
860
+ // storage-level cursor must match the storage-level array.
861
+ return tokenIdx < nextAccounts.length ? tokenIdx : 0
862
+ }
863
+ const legacyClaude =
864
+ current.activeIndexByFamily?.claude ?? current.activeIndex
865
+ const legacyGemini =
866
+ current.activeIndexByFamily?.gemini ?? current.activeIndex
867
+ nextActiveIndex = resolveNextIndex(liveCurrentTokens.claude, legacyClaude)
868
+ nextActiveIndexByFamily = {
869
+ claude: resolveNextIndex(liveCurrentTokens.claude, legacyClaude),
870
+ gemini: resolveNextIndex(liveCurrentTokens.gemini, legacyGemini),
871
+ }
590
872
  return {
591
873
  ...current,
592
- accounts: current.accounts.filter(
593
- (account) => account.refreshToken !== refreshToken,
594
- ),
595
- activeIndex: 0,
596
- activeIndexByFamily: { claude: 0, gemini: 0 },
874
+ accounts: nextAccounts,
875
+ activeIndex: nextActiveIndex,
876
+ activeIndexByFamily: nextActiveIndexByFamily,
597
877
  }
598
878
  })
599
879