Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cli/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Chat>: the agent may still be
// finishing work under the server-side grace period, and the chat surface
Expand All @@ -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 <WaitingRoomScreen session={session} error={sessionError} />
}
Expand Down
88 changes: 86 additions & 2 deletions cli/src/components/waiting-room-screen.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<>
<text
style={{ fg: theme.foreground, marginBottom: 1 }}
attributes={TextAttributes.BOLD}
>
Freebuff is already running
</text>
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
Only one freebuff instance can run at a time. Take over the other
instance here, or exit and keep using the one already running.
</text>
<box style={{ flexDirection: 'row', gap: 2, marginTop: 1 }}>
<Button
onClick={handleTakeover}
onMouseOver={() => setTakeoverHover(true)}
onMouseOut={() => setTakeoverHover(false)}
style={{ paddingLeft: 1, paddingRight: 1 }}
>
<text
style={{
fg: takeoverHover ? theme.background : theme.foreground,
bg: takeoverHover ? theme.primary : undefined,
}}
attributes={TextAttributes.BOLD}
>
{pending ? 'Taking over...' : 'Take over'}
</text>
</Button>
<Button
onClick={exitFreebuffCleanly}
onMouseOver={() => setExitHover(true)}
onMouseOut={() => setExitHover(false)}
style={{ paddingLeft: 1, paddingRight: 1 }}
>
<text
style={{ fg: exitHover ? theme.foreground : theme.muted }}
attributes={exitHover ? TextAttributes.BOLD : TextAttributes.NONE}
>
Exit
</text>
</Button>
</box>
<text style={{ fg: theme.muted, marginTop: 1 }}>
Enter takes over · Esc exits
</text>
</>
)
}

export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
session,
error,
Expand Down Expand Up @@ -228,6 +310,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
</>
)}

{session?.status === 'takeover_prompt' && <TakeoverPrompt />}

{isQueued && session && (
<>
<text style={{ fg: theme.foreground, marginBottom: 1 }}>
Expand Down
36 changes: 24 additions & 12 deletions cli/src/hooks/use-freebuff-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -52,7 +53,7 @@ async function callSession(
method: 'POST' | 'GET' | 'DELETE',
token: string,
opts: { instanceId?: string; model?: string; signal?: AbortSignal } = {},
): Promise<FreebuffSessionResponse> {
): Promise<FreebuffSessionServerResponse> {
const headers: Record<string, string> = { Authorization: `Bearer ${token}` }
if (method === 'GET' && opts.instanceId) {
headers[FREEBUFF_INSTANCE_HEADER] = opts.instanceId
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -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
}
Expand All @@ -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
Expand All @@ -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':
Expand Down Expand Up @@ -301,6 +303,14 @@ export function joinFreebuffQueue(model: string): Promise<void> {
return restartFreebuffSession('rejoin')
}

export function takeOverFreebuffSession(): Promise<void> {
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}

Expand Down
24 changes: 14 additions & 10 deletions cli/src/types/freebuff-session.ts
Original file line number Diff line number Diff line change
@@ -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']
Loading