Skip to content

Commit bfe0fa2

Browse files
committed
Prompt before freebuff takeover
1 parent 5a8f86e commit bfe0fa2

4 files changed

Lines changed: 127 additions & 25 deletions

File tree

cli/src/app.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,7 @@ const AuthedSurface = ({
381381
// 'country_blocked' → terminal region-gate message
382382
// 'banned' → terminal account-banned message
383383
// 'rate_limited' → hit per-model session quota; terminal for this run
384+
// 'takeover_prompt' → another local CLI already holds this account
384385
//
385386
// 'ended' deliberately falls through to <Chat>: the agent may still be
386387
// finishing work under the server-side grace period, and the chat surface
@@ -392,7 +393,8 @@ const AuthedSurface = ({
392393
session.status === 'none' ||
393394
session.status === 'country_blocked' ||
394395
session.status === 'banned' ||
395-
session.status === 'rate_limited')
396+
session.status === 'rate_limited' ||
397+
session.status === 'takeover_prompt')
396398
) {
397399
return <WaitingRoomScreen session={session} error={sessionError} />
398400
}

cli/src/components/waiting-room-screen.tsx

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { TextAttributes } from '@opentui/core'
2-
import { useRenderer } from '@opentui/react'
3-
import React, { useMemo, useState } from 'react'
2+
import { useKeyboard, useRenderer } from '@opentui/react'
3+
import React, { useCallback, useMemo, useState } from 'react'
44

55
import { Button } from './button'
66
import { ChoiceAdBanner, CHOICE_AD_BANNER_HEIGHT } from './choice-ad-banner'
77
import { FreebuffModelSelector } from './freebuff-model-selector'
88
import { ShimmerText } from './shimmer-text'
9+
import { takeOverFreebuffSession } from '../hooks/use-freebuff-session'
910
import { useFreebuffCtrlCExit } from '../hooks/use-freebuff-ctrl-c-exit'
1011
import { useGravityAd } from '../hooks/use-gravity-ad'
1112
import { useLogo } from '../hooks/use-logo'
@@ -18,6 +19,7 @@ import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system'
1819

1920
import type { FreebuffSessionResponse } from '../types/freebuff-session'
2021
import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session'
22+
import type { KeyEvent } from '@opentui/core'
2123

2224
interface WaitingRoomScreenProps {
2325
session: FreebuffSessionResponse | null
@@ -88,6 +90,86 @@ const formatPrivacySignalList = (
8890
return `${labels.slice(0, -1).join(', ')}, or ${labels[labels.length - 1]}`
8991
}
9092

93+
const TakeoverPrompt: React.FC = () => {
94+
const theme = useTheme()
95+
const [pending, setPending] = useState(false)
96+
const [takeoverHover, setTakeoverHover] = useState(false)
97+
const [exitHover, setExitHover] = useState(false)
98+
99+
const handleTakeover = useCallback(() => {
100+
if (pending) return
101+
setPending(true)
102+
takeOverFreebuffSession().finally(() => setPending(false))
103+
}, [pending])
104+
105+
useKeyboard(
106+
useCallback(
107+
(key: KeyEvent) => {
108+
const name = key.name ?? ''
109+
const isConfirm = name === 'return' || name === 'enter'
110+
const isExit = name === 'escape' || name === 'esc'
111+
if (!isConfirm && !isExit) return
112+
key.preventDefault?.()
113+
if (isConfirm) {
114+
handleTakeover()
115+
} else {
116+
exitFreebuffCleanly()
117+
}
118+
},
119+
[handleTakeover],
120+
),
121+
)
122+
123+
return (
124+
<>
125+
<text
126+
style={{ fg: theme.foreground, marginBottom: 1 }}
127+
attributes={TextAttributes.BOLD}
128+
>
129+
Freebuff is already running
130+
</text>
131+
<text style={{ fg: theme.muted, wrapMode: 'word' }}>
132+
Only one freebuff instance can run at a time. Take over the other
133+
instance here, or exit and keep using the one already running.
134+
</text>
135+
<box style={{ flexDirection: 'row', gap: 2, marginTop: 1 }}>
136+
<Button
137+
onClick={handleTakeover}
138+
onMouseOver={() => setTakeoverHover(true)}
139+
onMouseOut={() => setTakeoverHover(false)}
140+
style={{ paddingLeft: 1, paddingRight: 1 }}
141+
>
142+
<text
143+
style={{
144+
fg: takeoverHover ? theme.background : theme.foreground,
145+
bg: takeoverHover ? theme.primary : undefined,
146+
}}
147+
attributes={TextAttributes.BOLD}
148+
>
149+
{pending ? 'Taking over...' : 'Take over'}
150+
</text>
151+
</Button>
152+
<Button
153+
onClick={exitFreebuffCleanly}
154+
onMouseOver={() => setExitHover(true)}
155+
onMouseOut={() => setExitHover(false)}
156+
style={{ paddingLeft: 1, paddingRight: 1 }}
157+
>
158+
<text
159+
style={{ fg: exitHover ? theme.foreground : theme.muted }}
160+
attributes={exitHover ? TextAttributes.BOLD : TextAttributes.NONE}
161+
>
162+
Exit
163+
</text>
164+
</Button>
165+
</box>
166+
<text style={{ fg: theme.muted, marginTop: 1 }}>
167+
Enter takes over · Esc exits
168+
</text>
169+
</>
170+
)
171+
}
172+
91173
export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
92174
session,
93175
error,
@@ -228,6 +310,8 @@ export const WaitingRoomScreen: React.FC<WaitingRoomScreenProps> = ({
228310
</>
229311
)}
230312

313+
{session?.status === 'takeover_prompt' && <TakeoverPrompt />}
314+
231315
{isQueued && session && (
232316
<>
233317
<text style={{ fg: theme.foreground, marginBottom: 1 }}>

cli/src/hooks/use-freebuff-session.ts

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { FreebuffSessionResponse } from '../types/freebuff-session'
1919
import type {
2020
FreebuffCountryBlockReason,
2121
FreebuffIpPrivacySignal,
22+
FreebuffSessionServerResponse,
2223
} from '@codebuff/common/types/freebuff-session'
2324

2425
const 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

cli/src/types/freebuff-session.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
/**
2-
* Re-export of the wire-level session shape. The CLI no longer layers any
3-
* client-only states on top — `ended` and `superseded` come straight from
4-
* the server now (see `common/src/types/freebuff-session.ts`).
5-
*/
6-
export type {
7-
FreebuffSessionServerResponse,
8-
FreebuffSessionServerResponse as FreebuffSessionResponse,
9-
} from '@codebuff/common/types/freebuff-session'
1+
export type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session'
102

113
import type { FreebuffSessionServerResponse } from '@codebuff/common/types/freebuff-session'
124

13-
export type FreebuffSessionStatus = FreebuffSessionServerResponse['status']
5+
/**
6+
* CLI session shape. Most states are wire-level `/api/v1/freebuff/session`
7+
* responses; `takeover_prompt` is local-only so startup can ask before POSTing
8+
* and rotating another running CLI's instance id.
9+
*/
10+
export type FreebuffSessionResponse =
11+
| FreebuffSessionServerResponse
12+
| {
13+
status: 'takeover_prompt'
14+
model: string
15+
}
16+
17+
export type FreebuffSessionStatus = FreebuffSessionResponse['status']

0 commit comments

Comments
 (0)