@@ -145,6 +145,8 @@ import type {
145145import { getRpcDir } from './rpc/rpc-dir.ts'
146146import { type RpcServerHandle , startRpcServer } from './rpc/rpc-server.ts'
147147import {
148+ getInitialSidebarRoutingTestHooks ,
149+ getSidebarState ,
148150 getSidebarStateFile ,
149151 type SidebarState ,
150152 setSidebarState ,
@@ -173,6 +175,115 @@ const CONCURRENT_MAIN_REFRESH_POLL_BASE_MS = 200
173175const MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES = 240
174176const DEFAULT_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES =
175177 MIN_MAIN_REFRESH_BEFORE_EXPIRY_MINUTES
178+ const SIDEBAR_ROUTING_FRESH_MS = 10 * 60 * 1000
179+
180+ function hasEnabledOAuthAccount (
181+ storage : AccountStorage | null ,
182+ activeId : string ,
183+ ) {
184+ return ( storage ?. accounts ?? [ ] ) . some (
185+ ( account ) =>
186+ account . id === activeId &&
187+ account . enabled !== false &&
188+ isOAuthAccount ( account ) ,
189+ )
190+ }
191+
192+ function deriveSidebarRouting (
193+ storage : AccountStorage | null ,
194+ ) : Pick < SidebarState , 'activeId' | 'route' > {
195+ const firstFallback = ( storage ?. accounts ?? [ ] ) . find (
196+ ( account ) : account is OAuthAccount =>
197+ account . enabled !== false && isOAuthAccount ( account ) ,
198+ )
199+ if ( getRoutingMode ( storage ) === 'fallback-first' && firstFallback ) {
200+ return { activeId : firstFallback . id , route : 'fallback-first' }
201+ }
202+ return { activeId : 'main' , route : 'main' }
203+ }
204+
205+ function validateSidebarRouting (
206+ routing : Pick < SidebarState , 'activeId' | 'route' > | undefined ,
207+ storage : AccountStorage | null ,
208+ ) : Pick < SidebarState , 'activeId' | 'route' > {
209+ if (
210+ routing ?. activeId === 'main' ||
211+ ( routing ?. activeId && hasEnabledOAuthAccount ( storage , routing . activeId ) )
212+ ) {
213+ return routing
214+ }
215+ return deriveSidebarRouting ( storage )
216+ }
217+
218+ function resolveLoadedSidebarRouting (
219+ existing : SidebarState ,
220+ freshStorage : AccountStorage | null ,
221+ fallbackRouting ?: Pick < SidebarState , 'activeId' | 'route' > ,
222+ ) : Pick < SidebarState , 'activeId' | 'route' > {
223+ const existingAge = Date . now ( ) - existing . lastUpdated
224+ if (
225+ ! existing . activeId ||
226+ existingAge < 0 ||
227+ existingAge > SIDEBAR_ROUTING_FRESH_MS
228+ ) {
229+ return validateSidebarRouting ( fallbackRouting , freshStorage )
230+ }
231+
232+ return validateSidebarRouting ( existing , freshStorage )
233+ }
234+
235+ async function resolveFreshSidebarRouting (
236+ existing : SidebarState ,
237+ loadFreshStorage : ( ) => Promise < AccountStorage | null > ,
238+ fallbackRouting ?: Pick < SidebarState , 'activeId' | 'route' > ,
239+ ) : Promise < {
240+ activeId : string | undefined
241+ route : string
242+ freshStorage : AccountStorage | null
243+ } > {
244+ let freshStorage : AccountStorage | null
245+ try {
246+ freshStorage = await loadFreshStorage ( )
247+ } catch ( error ) {
248+ logger . warn ( 'sidebar' , 'account storage reload failed; routing preserved' , {
249+ message : error instanceof Error ? error . message : String ( error ) ,
250+ } )
251+ const preservedRouting = existing . activeId
252+ ? { activeId : existing . activeId , route : existing . route }
253+ : ( fallbackRouting ?? { activeId : 'main' , route : 'main' } )
254+ return { ...preservedRouting , freshStorage : null }
255+ }
256+
257+ return {
258+ ...resolveLoadedSidebarRouting ( existing , freshStorage , fallbackRouting ) ,
259+ freshStorage,
260+ }
261+ }
262+
263+ async function resolveInitialSidebarRouting (
264+ accountStoragePath : string ,
265+ ) : Promise < {
266+ activeId : string | undefined
267+ route : string
268+ freshStorage : AccountStorage | null
269+ } > {
270+ await getInitialSidebarRoutingTestHooks ( ) ?. beforeStorageLoad ?.( )
271+ let freshStorage : AccountStorage | null
272+ try {
273+ freshStorage = await loadAccounts ( accountStoragePath )
274+ } catch {
275+ return { activeId : 'main' , route : 'main' , freshStorage : null }
276+ } finally {
277+ await getInitialSidebarRoutingTestHooks ( ) ?. afterStorageLoad ?.( )
278+ }
279+
280+ await getInitialSidebarRoutingTestHooks ( ) ?. beforeSidebarRead ?.( )
281+ const existing = await getSidebarState ( )
282+ return {
283+ ...resolveLoadedSidebarRouting ( existing , freshStorage ) ,
284+ freshStorage,
285+ }
286+ }
176287
177288/**
178289 * Format the user-facing 429 message for a killswitch block. When the block
@@ -906,16 +1017,18 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
9061017 NonNullable < SidebarState [ 'fableRecoveries' ] > [ number ]
9071018 > ( )
9081019
909- function writeSidebarState (
1020+ interface SidebarStateInput {
1021+ activeId ?: string
1022+ route : string
1023+ mainAccessToken ?: string
1024+ mainRefreshToken ?: string
1025+ routingAuthoritative ?: boolean
1026+ }
1027+
1028+ function buildSidebarState (
9101029 storage : Awaited < ReturnType < typeof loadAccounts > > ,
911- options : {
912- activeId ?: string
913- route : string
914- mainAccessToken ?: string
915- mainRefreshToken ?: string
916- } ,
917- ) {
918- lastSidebarRouting = { activeId : options . activeId , route : options . route }
1030+ options : SidebarStateInput ,
1031+ ) : SidebarState {
9191032 quotaManager . updateStorage ( storage )
9201033 quotaManager . seedMainFromStorage ( storage , options . mainAccessToken )
9211034 quotaManager . seedFallbacksFromAccounts (
@@ -924,7 +1037,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
9241037 const mainEntry = quotaManager . getMain ( options . mainAccessToken )
9251038 const lastApiError = quotaManager . getLastApiError ( )
9261039 const mainRefreshError = storage ?. refresh ?. mainLastRefreshError
927- const state : SidebarState = {
1040+ return {
9281041 main : {
9291042 quota : mainEntry ?. quota ?? null ,
9301043 quotaBackedOff : quotaManager . isBackedOff ( ) ,
@@ -995,7 +1108,43 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
9951108 : undefined ,
9961109 lastUpdated : Date . now ( ) ,
9971110 }
998- return setSidebarState ( state , sidebarStateFile ) . catch ( ( error ) =>
1111+ }
1112+
1113+ function writeSidebarState (
1114+ storage : Awaited < ReturnType < typeof loadAccounts > > ,
1115+ options : SidebarStateInput ,
1116+ ) {
1117+ const routingAuthoritative = options . routingAuthoritative !== false
1118+ if ( routingAuthoritative ) {
1119+ lastSidebarRouting = { activeId : options . activeId , route : options . route }
1120+ }
1121+ const state = buildSidebarState ( storage , options )
1122+ return setSidebarState ( state , sidebarStateFile , {
1123+ routingAuthoritative,
1124+ resolvePreservedRouting : routingAuthoritative
1125+ ? undefined
1126+ : async ( current ) => {
1127+ const preservedRouting = await resolveFreshSidebarRouting (
1128+ current ,
1129+ ( ) => loadAccounts ( accountStoragePath ) ,
1130+ { activeId : state . activeId , route : state . route } ,
1131+ )
1132+ return {
1133+ activeId : preservedRouting . activeId ,
1134+ route : preservedRouting . route ,
1135+ state : buildSidebarState ( preservedRouting . freshStorage , {
1136+ ...options ,
1137+ activeId : preservedRouting . activeId ,
1138+ route : preservedRouting . route ,
1139+ } ) ,
1140+ }
1141+ } ,
1142+ onRoutingResolved : routingAuthoritative
1143+ ? undefined
1144+ : ( routing ) => {
1145+ lastSidebarRouting = routing
1146+ } ,
1147+ } ) . catch ( ( error ) =>
9991148 logger . warn ( 'sidebar' , 'state write failed' , {
10001149 error : error instanceof Error ? error . message : String ( error ) ,
10011150 } ) ,
@@ -1023,6 +1172,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
10231172 route : lastSidebarRouting . route ,
10241173 mainAccessToken : access ,
10251174 mainRefreshToken : refresh ,
1175+ routingAuthoritative : false ,
10261176 } )
10271177 }
10281178
@@ -1172,6 +1322,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
11721322 route : lastSidebarRouting . route ,
11731323 mainAccessToken : auth . access ,
11741324 mainRefreshToken : auth . refresh ,
1325+ routingAuthoritative : false ,
11751326 } )
11761327 } catch {
11771328 // auth not yet available — sidebar will refresh on next request
@@ -1202,6 +1353,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
12021353 route : lastSidebarRouting . route ,
12031354 mainAccessToken : auth . access ,
12041355 mainRefreshToken : auth . refresh ,
1356+ routingAuthoritative : false ,
12051357 } )
12061358
12071359 if ( ! desktopText || isTuiConnected ( notice . sessionId ) ) return
@@ -1231,6 +1383,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
12311383 route : lastSidebarRouting . route ,
12321384 mainAccessToken : auth . access ,
12331385 mainRefreshToken : auth . refresh ,
1386+ routingAuthoritative : false ,
12341387 } )
12351388 }
12361389
@@ -1584,6 +1737,7 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
15841737 route : lastSidebarRouting . route ,
15851738 mainAccessToken : auth . access ,
15861739 mainRefreshToken : auth . refresh ,
1740+ routingAuthoritative : false ,
15871741 } )
15881742 } catch {
15891743 // auth not yet available — sidebar will refresh on next request
@@ -1941,10 +2095,11 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
19412095 ? await latestGetAuth ( ) . catch ( ( ) => undefined )
19422096 : undefined
19432097 writeSidebarState ( cmdStorage , {
1944- activeId : 'main' ,
1945- route : 'main' ,
2098+ activeId : lastSidebarRouting . activeId ,
2099+ route : lastSidebarRouting . route ,
19462100 mainAccessToken : cmdAuth ?. access ,
19472101 mainRefreshToken : cmdAuth ?. refresh ,
2102+ routingAuthoritative : false ,
19482103 } )
19492104 }
19502105 if ( isTuiConnected ( input . sessionID ) ) {
@@ -2360,11 +2515,14 @@ export const AnthropicAuthPlugin: Plugin = async (ctx) => {
23602515 quotaManager . seedFallbacksFromAccounts (
23612516 ( initialStorage ?. accounts ?? [ ] ) . filter ( isOAuthAccount ) ,
23622517 )
2363- writeSidebarState ( initialStorage , {
2364- activeId : 'main' ,
2365- route : 'main' ,
2518+ const initialSidebarRouting =
2519+ await resolveInitialSidebarRouting ( accountStoragePath )
2520+ await writeSidebarState ( initialSidebarRouting . freshStorage , {
2521+ activeId : initialSidebarRouting . activeId ,
2522+ route : initialSidebarRouting . route ,
23662523 mainAccessToken : auth . access ,
23672524 mainRefreshToken : auth . refresh ,
2525+ routingAuthoritative : false ,
23682526 } )
23692527
23702528 function isReplayableRequest (
0 commit comments