diff --git a/cli/src/app.tsx b/cli/src/app.tsx index cac6e20ec..1d112af38 100644 --- a/cli/src/app.tsx +++ b/cli/src/app.tsx @@ -381,6 +381,7 @@ const AuthedSurface = ({ // 'country_blocked' → terminal region-gate message // 'banned' → terminal account-banned message // 'rate_limited' → hit per-model session quota; terminal for this run + // 'takeover_prompt' → another local CLI already holds this account // // 'ended' deliberately falls through to : the agent may still be // finishing work under the server-side grace period, and the chat surface @@ -392,7 +393,8 @@ const AuthedSurface = ({ session.status === 'none' || session.status === 'country_blocked' || session.status === 'banned' || - session.status === 'rate_limited') + session.status === 'rate_limited' || + session.status === 'takeover_prompt') ) { return } diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 839e780c6..9cdc385c9 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -1,11 +1,12 @@ import { TextAttributes } from '@opentui/core' -import { useRenderer } from '@opentui/react' -import React, { useMemo, useState } from 'react' +import { useKeyboard, useRenderer } from '@opentui/react' +import React, { useCallback, useMemo, useState } from 'react' import { Button } from './button' import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner' import { FreebuffModelSelector } from './freebuff-model-selector' import { ShimmerText } from './shimmer-text' +import { takeOverFreebuffSession } from '../hooks/use-freebuff-session' import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit' import { useGravityAd } from '../hooks/use-gravity-ad' import { useLogo } from '../hooks/use-logo' @@ -18,6 +19,7 @@ import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system' import type { FreebuffSessionResponse } from '../types/freebuff-session' import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session' +import type { KeyEvent } from '@opentui/core' interface WaitingRoomScreenProps { session: FreebuffSessionResponse | null @@ -88,6 +90,86 @@ const formatPrivacySignalList = ( return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}` } +const TakeoverPrompt: React.FC = () => { + const theme = useTheme() + const [pending, setPending] = useState(false) + const [takeoverHover, setTakeoverHover] = useState(false) + const [exitHover, setExitHover] = useState(false) + + const handleTakeover = useCallback(() => { + if (pending) return + setPending(true) + takeOverFreebuffSession().finally(() => setPending(false)) + }, [pending]) + + useKeyboard( + useCallback( + (key: KeyEvent) => { + const name = key.name ?? '' + const isConfirm = name === 'return' || name === 'enter' + const isExit = name === 'escape' || name === 'esc' + if (!isConfirm && !isExit) return + key.preventDefault?.() + if (isConfirm) { + handleTakeover() + } else { + exitFreebuffCleanly() + } + }, + [handleTakeover], + ), + ) + + return ( + <> + + Freebuff is already running + + + Only one freebuff instance can run at a time. Take over the other + instance here, or exit and keep using the one already running. + + + + + + + Enter takes over · Esc exits + + + ) +} + export const WaitingRoomScreen: React.FC = ({ session, error, @@ -228,6 +310,8 @@ export const WaitingRoomScreen: React.FC = ({ )} + {session?.status === 'takeover_prompt' && } + {isQueued && session && ( <> diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index e91503655..332ab6450 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -19,6 +19,7 @@ import type { FreebuffSessionResponse } from '../types/freebuff-session' import type { FreebuffCountryBlockReason, FreebuffIpPrivacySignal, + FreebuffSessionServerResponse, } from '@codebuff/common/types/freebuff-session' const POLL_INTERVAL_QUEUED_MS = 5_000 @@ -52,7 +53,7 @@ async function callSession( method: 'POST' | 'GET' | 'DELETE', token: string, opts: { instanceId?: string; model?: string; signal?: AbortSignal } = {}, -): Promise { +): Promise { const headers: Record = { Authorization: `Bearer ${token}` } if (method === 'GET' && opts.instanceId) { headers[FREEBUFF_INSTANCE_HEADER] = opts.instanceId @@ -81,7 +82,7 @@ async function callSession( if (resp.status === 403) { const body = (await resp .json() - .catch(() => null)) as FreebuffSessionResponse | null + .catch(() => null)) as FreebuffSessionServerResponse | null if ( body && (body.status === 'country_blocked' || body.status === 'banned') @@ -96,7 +97,7 @@ async function callSession( if (resp.status === 409 && method === 'POST') { const body = (await resp .json() - .catch(() => null)) as FreebuffSessionResponse | null + .catch(() => null)) as FreebuffSessionServerResponse | null if ( body && (body.status === 'model_locked' || body.status === 'model_unavailable') @@ -112,7 +113,7 @@ async function callSession( if (resp.status === 429 && method === 'POST') { const body = (await resp .json() - .catch(() => null)) as FreebuffSessionResponse | null + .catch(() => null)) as FreebuffSessionServerResponse | null if (body && body.status === 'rate_limited') { return body } @@ -123,7 +124,7 @@ async function callSession( `freebuff session ${method} failed: ${resp.status} ${text.slice(0, 200)}`, ) } - return (await resp.json()) as FreebuffSessionResponse + return (await resp.json()) as FreebuffSessionServerResponse } /** Picks the poll delay after a successful tick. Returns null when the state @@ -147,6 +148,7 @@ function nextDelayMs(next: FreebuffSessionResponse): number | null { case 'none': case 'disabled': case 'superseded': + case 'takeover_prompt': case 'country_blocked': case 'banned': case 'model_locked': @@ -301,6 +303,14 @@ export function joinFreebuffQueue(model: string): Promise { return restartFreebuffSession('rejoin') } +export function takeOverFreebuffSession(): Promise { + if (!IS_FREEBUFF) return Promise.resolve() + const current = useFreebuffSessionStore.getState().session + if (current?.status !== 'takeover_prompt') return Promise.resolve() + useFreebuffModelStore.getState().setSelectedModel(current.model) + return restartFreebuffSession('rejoin') +} + /** * Best-effort DELETE of the caller's session row. Used by exit paths that * skip React unmount (process.exit on Ctrl+C) so the seat frees up quickly @@ -353,8 +363,9 @@ interface UseFreebuffSessionResult { * Manages the freebuff waiting-room session lifecycle: * - GET on mount to probe state (no auto-join; the user picks a model in * the landing screen, which calls joinFreebuffQueue) - * - if the probe sees an existing seat, POSTs once to take over (rotates - * the instance id so any other CLI on the same account is superseded) + * - if the probe sees an existing seat, asks before POSTing to take over + * (rotates the instance id so any other CLI on the same account is + * superseded) * - polls GET while queued (fast) or active (slow) to keep state fresh * - re-POSTs on explicit refresh (chat gate rejected us, user switched * models, user rejoined after ending) @@ -455,19 +466,20 @@ export function useFreebuffSession(): UseFreebuffSessionResult { } // Startup takeover: the initial probe GET saw we already hold a seat - // (from a prior CLI instance). POST now to rotate our instance id so - // any other CLI on this account is superseded on its next poll. + // (from a prior CLI instance). Stop here and ask before POSTing to + // rotate our instance id; otherwise opening a second freebuff would + // immediately supersede the first one. // `previousStatus === null` fences this to the very first tick only. // Pin the selected model to whatever the server thinks we're on so - // the POST preserves our queue position instead of switching queues. + // an explicit takeover preserves our queue position instead of + // switching queues. if ( method === 'GET' && previousStatus === null && (next.status === 'queued' || next.status === 'active') ) { useFreebuffModelStore.getState().setSelectedModel(next.model) - nextMethod = 'POST' - schedule(0) + apply({ status: 'takeover_prompt', model: next.model }) return } diff --git a/cli/src/types/freebuff-session.ts b/cli/src/types/freebuff-session.ts index 80b8e3ebe..ef6ee83af 100644 --- a/cli/src/types/freebuff-session.ts +++ b/cli/src/types/freebuff-session.ts @@ -1,13 +1,17 @@ -/** - * Re-export of the wire-level session shape. The CLI no longer layers any - * client-only states on top — `ended` and `superseded` come straight from - * the server now (see `common/src/types/freebuff-session.ts`). - */ -export type { - FreebuffSessionServerResponse, - FreebuffSessionServerResponse as FreebuffSessionResponse, -} from '@codebuff/common/types/freebuff-session' +export type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session' import type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session' -export type FreebuffSessionStatus = FreebuffSessionServerResponse['status'] +/** + * CLI session shape. Most states are wire-level `/api/v1/freebuff/session` + * responses; `takeover_prompt` is local-only so startup can ask before POSTing + * and rotating another running CLI's instance id. + */ +export type FreebuffSessionResponse = + | FreebuffSessionServerResponse + | { + status: 'takeover_prompt' + model: string + } + +export type FreebuffSessionStatus = FreebuffSessionResponse['status']