@@ -226,23 +226,47 @@ function gatewayModelLabel(id) {
226226/** Fetch the plan's model roster (entitled models + credits + ≈ turns-left). Caches it for the
227227 * footer/picker. Best-effort; returns null when signed out / offline / not gateway. */
228228async function fetchCloudRoster ( ) {
229- if ( providerMode ( ) !== 'gateway' || ! cloudSignedIn || ! ctx ) { return null ; }
230- const token = await ctx . secrets . get ( ACCOUNT_TOKEN_KEY ) ;
229+ // The presence of a stored token IS the signed-in truth; don't gate on the cloudSignedIn flag, which
230+ // can still be false mid-activation while a valid token already exists (another way the picker was
231+ // degrading to the 2-model fallback on a fresh open).
232+ if ( providerMode ( ) !== 'gateway' || ! ctx ) { return null ; }
233+ let token = await ctx . secrets . get ( ACCOUNT_TOKEN_KEY ) ;
231234 if ( ! token ) { return null ; }
232235 const api = cloudApiUrl ( ) ;
233236 if ( ! / ^ h t t p s : \/ \/ / i. test ( api ) && ! / ^ h t t p : \/ \/ ( l o c a l h o s t | 1 2 7 \. 0 \. 0 \. 1 ) ( [: / ] | $ ) / i. test ( api ) ) { return null ; }
237+ // Last-known-good roster: a transient failure keeps the FULL model list rather than collapsing to the
238+ // 2-model offline fallback. The per-model fields — INCLUDING "≈ turns left" — are whatever the last
239+ // good fetch returned, so they may be slightly stale. The account-level credit BALANCE is NOT carried
240+ // here, so pickCloudModel just omits the "$X credits left" header until the next successful fetch.
241+ const cached = ( ) => ( cloudRoster && cloudRoster . length ? { plan : cloudPlanName ( ) , models : cloudRoster } : null ) ;
242+ const get = ( bearer ) => fetch ( api + '/api/levelcode/v1/account/models' , { headers : { authorization : 'Bearer ' + bearer } } ) ;
234243 try {
235- const res = await fetch ( api + '/api/levelcode/v1/account/models' , { headers : { authorization : 'Bearer ' + token } } ) ;
236- if ( ! res . ok ) { dbg ( 'cloud.roster' , { ok : false , status : res . status } ) ; return null ; }
244+ let res = await get ( token ) ;
245+ // THE FIX: on a fresh open, last session's short-lived access token is usually EXPIRED, so this
246+ // first call 401s. Refresh once and retry. Without it the 401 silently degrades the picker to the
247+ // 2-model offline fallback and hides the plan's real roster (Opus, K3, …) — exactly the reported
248+ // bug. The profile fetch and the agent loop already refresh on 401; the roster fetch didn't.
249+ if ( res . status === 401 && await refreshCloudToken ( ) ) {
250+ // Guard the refreshed token: if it comes back falsy for any reason, retrying would send
251+ // `Authorization: Bearer null` — noise that masks the real 401. Skip the retry instead and let
252+ // the !res.ok path below fall back to the cached roster.
253+ const fresh = await ctx . secrets . get ( ACCOUNT_TOKEN_KEY ) ;
254+ if ( fresh ) { token = fresh ; res = await get ( token ) ; }
255+ }
256+ if ( ! res . ok ) { dbg ( 'cloud.roster' , { ok : false , status : res . status } ) ; return cached ( ) ; }
237257 const data = await res . json ( ) . catch ( ( ) => null ) ;
258+ // Only a payload with a real models array IS a roster. A 200 carrying an error object, a partial
259+ // response, or a schema drift is not — returning it would make pickCloudModel see "no models" and
260+ // collapse to the 2-model fallback despite a valid last-known-good list. Prefer the cache then.
238261 if ( data && Array . isArray ( data . models ) ) {
239262 cloudRoster = data . models ;
240263 // The roster carries each model's short label — refresh the footer chip so it shows
241264 // "Opus 4.8" instead of the raw id it fell back to before the roster finished loading.
242265 sendConfigToWebview ( ) ;
266+ return data ;
243267 }
244- return data ;
245- } catch ( e ) { dbg ( 'cloud.roster' , { error : String ( ( e && e . message ) || e ) } ) ; return null ; }
268+ return cached ( ) ;
269+ } catch ( e ) { dbg ( 'cloud.roster' , { error : String ( ( e && e . message ) || e ) } ) ; return cached ( ) ; }
246270}
247271
248272/**
0 commit comments