@@ -19,6 +19,7 @@ import type { FreebuffSessionResponse } from '../types/freebuff-session'
1919import type {
2020 FreebuffCountryBlockReason ,
2121 FreebuffIpPrivacySignal ,
22+ FreebuffSessionServerResponse ,
2223} from '@codebuff/common/types/freebuff-session'
2324
2425const POLL_INTERVAL_QUEUED_MS = 5_000
@@ -52,7 +53,7 @@ async function callSession(
5253 method : 'POST' | 'GET' | 'DELETE' ,
5354 token : string ,
5455 opts : { instanceId ?: string ; model ?: string ; signal ?: AbortSignal } = { } ,
55- ) : Promise < FreebuffSessionResponse > {
56+ ) : Promise < FreebuffSessionServerResponse > {
5657 const headers : Record < string , string > = { Authorization : `Bearer ${ token } ` }
5758 if ( method === 'GET' && opts . instanceId ) {
5859 headers [ FREEBUFF_INSTANCE_HEADER ] = opts . instanceId
@@ -81,7 +82,7 @@ async function callSession(
8182 if ( resp . status === 403 ) {
8283 const body = ( await resp
8384 . json ( )
84- . catch ( ( ) => null ) ) as FreebuffSessionResponse | null
85+ . catch ( ( ) => null ) ) as FreebuffSessionServerResponse | null
8586 if (
8687 body &&
8788 ( body . status === 'country_blocked' || body . status === 'banned' )
@@ -96,7 +97,7 @@ async function callSession(
9697 if ( resp . status === 409 && method === 'POST' ) {
9798 const body = ( await resp
9899 . json ( )
99- . catch ( ( ) => null ) ) as FreebuffSessionResponse | null
100+ . catch ( ( ) => null ) ) as FreebuffSessionServerResponse | null
100101 if (
101102 body &&
102103 ( body . status === 'model_locked' || body . status === 'model_unavailable' )
@@ -112,7 +113,7 @@ async function callSession(
112113 if ( resp . status === 429 && method === 'POST' ) {
113114 const body = ( await resp
114115 . json ( )
115- . catch ( ( ) => null ) ) as FreebuffSessionResponse | null
116+ . catch ( ( ) => null ) ) as FreebuffSessionServerResponse | null
116117 if ( body && body . status === 'rate_limited' ) {
117118 return body
118119 }
@@ -123,7 +124,7 @@ async function callSession(
123124 `freebuff session ${ method } failed: ${ resp . status } ${ text . slice ( 0 , 200 ) } ` ,
124125 )
125126 }
126- return ( await resp . json ( ) ) as FreebuffSessionResponse
127+ return ( await resp . json ( ) ) as FreebuffSessionServerResponse
127128}
128129
129130/** Picks the poll delay after a successful tick. Returns null when the state
@@ -147,6 +148,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null {
147148 case 'none' :
148149 case 'disabled' :
149150 case 'superseded' :
151+ case 'takeover_prompt' :
150152 case 'country_blocked' :
151153 case 'banned' :
152154 case 'model_locked' :
@@ -301,6 +303,14 @@ export function joinFreebuffQueue(model: string): Promise<void> {
301303 return restartFreebuffSession ( 'rejoin' )
302304}
303305
306+ export function takeOverFreebuffSession ( ) : Promise < void > {
307+ if ( ! IS_FREEBUFF ) return Promise . resolve ( )
308+ const current = useFreebuffSessionStore . getState ( ) . session
309+ if ( current ?. status !== 'takeover_prompt' ) return Promise . resolve ( )
310+ useFreebuffModelStore . getState ( ) . setSelectedModel ( current . model )
311+ return restartFreebuffSession ( 'rejoin' )
312+ }
313+
304314/**
305315 * Best-effort DELETE of the caller's session row. Used by exit paths that
306316 * skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly
@@ -353,8 +363,9 @@ interface UseFreebuffSessionResult {
353363 * Manages the freebuff waiting-room session lifecycle:
354364 * - GET on mount to probe state (no auto-join; the user picks a model in
355365 * the landing screen, which calls joinFreebuffQueue)
356- * - if the probe sees an existing seat, POSTs once to take over (rotates
357- * the instance id so any other CLI on the same account is superseded)
366+ * - if the probe sees an existing seat, asks before POSTing to take over
367+ * (rotates the instance id so any other CLI on the same account is
368+ * superseded)
358369 * - polls GET while queued (fast) or active (slow) to keep state fresh
359370 * - re-POSTs on explicit refresh (chat gate rejected us, user switched
360371 * models, user rejoined after ending)
@@ -455,19 +466,20 @@ export function useFreebuffSession(): UseFreebuffSessionResult {
455466 }
456467
457468 // Startup takeover: the initial probe GET saw we already hold a seat
458- // (from a prior CLI instance). POST now to rotate our instance id so
459- // any other CLI on this account is superseded on its next poll.
469+ // (from a prior CLI instance). Stop here and ask before POSTing to
470+ // rotate our instance id; otherwise opening a second freebuff would
471+ // immediately supersede the first one.
460472 // `previousStatus === null` fences this to the very first tick only.
461473 // Pin the selected model to whatever the server thinks we're on so
462- // the POST preserves our queue position instead of switching queues.
474+ // an explicit takeover preserves our queue position instead of
475+ // switching queues.
463476 if (
464477 method === 'GET' &&
465478 previousStatus === null &&
466479 ( next . status === 'queued' || next . status === 'active' )
467480 ) {
468481 useFreebuffModelStore . getState ( ) . setSelectedModel ( next . model )
469- nextMethod = 'POST'
470- schedule ( 0 )
482+ apply ( { status : 'takeover_prompt' , model : next . model } )
471483 return
472484 }
473485
0 commit comments