diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md
index fb1fc39b5b..a33a846e35 100644
--- a/apps/desktop/AGENTS.md
+++ b/apps/desktop/AGENTS.md
@@ -209,3 +209,17 @@ actually run rather than inventing a command; when in doubt, read the scripts.
locales?
If any answer is "not sure," that's the part to go verify.
+
+## Nous free tier: state is pulled, never latched in the renderer
+
+The free tier (a Nous identity with no account, `hermes_cli/anon_auth.py`) reaches the renderer
+through one JSON-RPC pair: `free_tier.status` (has_guest, enabled, available,
+notice_pending, model, label) read from local auth state with zero network, and
+`free_tier.ack_notice`, which persists the one-time notice flag on the identity itself. The
+first-launch ready screen and the own-key strip are the SAME state rendered for two situations,
+keyed on `notice_pending`; there is no localStorage latch, so the CLI and the desktop cannot
+disagree about whether the notice was shown. Sign-in goes through the existing
+`POST /api/providers/oauth/nous/start` + poll route, which over a free-tier identity registers the
+connector transfer and reports `reason`, `account_email` and `model` on completion; every entry
+point (Billing, status chip, ready screen) opens the one free-tier sign-in dialog. Never branch on
+provider display names: the picker row carries `free_tier_row`, status cards carry `free_tier`.
diff --git a/apps/desktop/src/AGENTS.md b/apps/desktop/src/AGENTS.md
index 523c13c0b7..9afe8dc8dd 100644
--- a/apps/desktop/src/AGENTS.md
+++ b/apps/desktop/src/AGENTS.md
@@ -81,3 +81,12 @@ in `src/plugins/hermes-bots/`: `canonical-chat-registry.test.ts` (tripwire: the
reads/writes a stored pointer), `canonical-chat-creation.test.ts`, `canonical-chat-adopt-on-conflict.test.ts`,
`bot-row-opens-canonical-chat.test.ts`, `hide-bot-chats.test.ts`; plus repo-root
`tests/tui_gateway/test_profiles_list_canonical_session.py`.
+
+## Free tier surfaces (`src/store/free-tier*.ts`, Billing, statusbar chip, onboarding ready screen)
+
+`$freeTierStatus` mirrors `free_tier.status` (pull; refreshed with the status snapshot and after a
+sign-in). `deriveBillingView` branches on `billing.free_tier` BEFORE `logged_in` (status
+`free_tier`: notice + one Sign in, Plan/Model/Connectors summary, no payment or usage rows). The
+sign-in dialog is a single claimed owner (first mount wins, like the real-profile consent prompt);
+its states map 1:1 to the poll route's `status` + `reason`. Copy is the ruled free-tier copy: never
+"guest", "anonymous", "claim" or "Nous Portal" in user-facing text.
diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx
index ee47f566bc..877150cedd 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx
@@ -8,6 +8,7 @@ import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/u
import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
+import { FreeTierNoticeStrip, useFreeTierNoticeOwner } from '@/components/free-tier/notice-strip'
import { usePaneVisible } from '@/components/pane-shell/pane-visibility'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
@@ -26,6 +27,7 @@ import {
type StatusGroup,
stopBackgroundProcess
} from '@/store/composer-status'
+import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier'
import { $previewStatusBySession, dismissPreviewArtifact } from '@/store/preview-status'
import { $sessionControlBySession, refreshSessionControl } from '@/store/session-control'
import { $threadScrolledUp } from '@/store/thread-scroll'
@@ -107,6 +109,12 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
const scrolledUp = useStore($threadScrolledUp)
const billing = useStore($billingBlock)
+ const freeTierStatus = useStore($freeTierStatus)
+ const freeTierRoute = useStore($freeTierRoute)
+ // One claimed owner across every mounted composer, so a split view shows the
+ // notice once — and a non-owning stack adds no empty row to its card.
+ const ownsFreeTierNotice = useFreeTierNoticeOwner()
+ const freeTierNotice = ownsFreeTierNotice && freeTierStripPending(freeTierStatus, freeTierRoute)
const isStructuredSupported = controlEntry?.capability === 'supported'
@@ -183,6 +191,13 @@ export function ComposerStatusStack({ onSubmit, queue, sessionId }: ComposerStat
sections.push({ key: 'billing', node: })
}
+ // Below the billing wall (a blocker outranks an offer), above everything the
+ // session itself is doing. The strip retires itself the moment any of its
+ // actions acks the notice.
+ if (freeTierNotice) {
+ sections.push({ key: 'free-tier', node: })
+ }
+
const hasControlContent = Boolean(
controlEntry &&
(controlEntry.error ||
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index 1a560faa5d..229d18ba24 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -19,6 +19,7 @@ import { BootFailureOverlay } from '@/components/boot-failure-overlay'
import { ConfirmHost } from '@/components/confirm-host'
import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { FindBar } from '@/components/find-bar'
+import { FreeTierSignInDialog } from '@/components/free-tier/sign-in-dialog'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
@@ -1194,6 +1195,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
requestGateway={requestGateway}
/>
)}
+ {/* One host for every free-tier sign-in entry point (Settings › Billing,
+ the statusbar chip, the first-launch intro). It owns the flow; the
+ entry points only record the intent. */}
+ {!isAuxiliaryWindow() && }
void
+ )}
)}
diff --git a/apps/desktop/src/app/settings/billing/types.ts b/apps/desktop/src/app/settings/billing/types.ts
index 78d888fe73..3d25389eab 100644
--- a/apps/desktop/src/app/settings/billing/types.ts
+++ b/apps/desktop/src/app/settings/billing/types.ts
@@ -7,8 +7,8 @@ import type {
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
- BillingStateResponse,
ChargeFailureReason,
+ BillingStateResponse as SharedBillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
@@ -16,6 +16,18 @@ import type {
UsageModelData
} from '@hermes/shared/billing'
+/**
+ * The gateway's `billing.state` payload as THIS app reads it: the shared shape
+ * plus the free-tier fields newer gateways add. When `free_tier` is true there
+ * is no account behind the call — `logged_in` is false and there is no balance,
+ * card or usage — so the free-tier branch must be read before the logged-out
+ * one. Both fields are absent on older gateways.
+ */
+export type BillingStateResponse = SharedBillingStateResponse & {
+ free_tier?: boolean
+ free_tier_model?: null | string
+}
+
export type {
BillingAutoReload,
BillingCardInfo,
@@ -25,8 +37,8 @@ export type {
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
- BillingStateResponse,
ChargeFailureReason,
+ SharedBillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
index a132e057db..6a9c2349cb 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts
@@ -171,6 +171,32 @@ describe('deriveBillingView', () => {
expect(view.usageRows).toEqual([])
})
+ it('derives the free-tier view before the logged-out one, with nothing to pay', () => {
+ // A free-tier install is logged_in:false, so this branch must win — otherwise
+ // the generic "connect your account" notice sends the user to the portal.
+ const view = deriveBillingView(
+ okBilling({ ...loggedOutBillingState, free_tier: true, free_tier_model: 'nous/welcome' }),
+ okSubscription(loggedOutSubscriptionState)
+ )
+
+ expect(view.status).toBe('free_tier')
+ expect(view.notice).toMatchObject({ title: "You're on the Nous free tier", tone: 'info' })
+ expect(view.notice?.action?.label).toBe('Sign in')
+ expect(view.summary).toEqual([
+ { label: 'Plan', value: 'Free tier' },
+ { label: 'Model', value: 'nous/welcome' },
+ { label: 'Connectors', tone: 'primary', value: 'Included' }
+ ])
+ expect(view.plan).toMatchObject({ tierName: 'Nous · free tier' })
+ expect(view.plan?.action).toBeUndefined()
+ expect(view.planFootnote).toContain('no balance and nothing to pay')
+ expect(view.paymentRow).toBeUndefined()
+ expect(view.topupRow).toBeUndefined()
+ expect(view.refillRow).toBeUndefined()
+ expect(view.tiers).toEqual([])
+ expect(view.usageRows).toEqual([])
+ })
+
it('derives a refusal notice when billing.state is unavailable', () => {
const view = deriveBillingView(endpointUnavailableBilling, okSubscription(todaySubscriptionState))
diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts
index 3ccc8693ac..d333dbf3dc 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts
@@ -1,6 +1,8 @@
import { useQuery } from '@tanstack/react-query'
import { fmtDate } from '@/lib/time'
+import { FREE_TIER_MODEL } from '@/store/free-tier'
+import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import type { BillingRefusal, BillingResult } from './api'
import { useBillingApi } from './api'
@@ -30,16 +32,18 @@ const BILLING_QUERY_OPTIONS = {
} as const
export interface BillingSummaryItemView {
- label: 'Auto-refill' | 'Balance' | 'Plan'
+ label: 'Auto-refill' | 'Balance' | 'Connectors' | 'Model' | 'Plan'
tone?: 'muted' | 'primary'
value: string
}
export interface BillingNoticeView {
- action?: {
- label: string
- url: string
- }
+ /** Either an external portal hop (`url`) or an in-app action (`onSelect`) —
+ * a discriminated pair, so a consumer never has to guard for "both" or
+ * "neither". */
+ action?:
+ | { label: string; onSelect: () => void; url?: undefined }
+ | { label: string; onSelect?: undefined; url: string }
message: string
title: string
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
@@ -96,7 +100,18 @@ export type BillingPlanCardView = {
pending?: PendingPlanTransition
price?: string
tierName: string
-} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } })
+} & (
+ | {
+ // `onSelect` overrides the card's default "open the plans grid" action —
+ // the free-tier card signs in instead. Absent = the plans grid.
+ action: { label: string; onSelect?: () => void }
+ link?: undefined
+ }
+ | { action?: undefined; link: { label: string; url: string } }
+ // The free-tier card is the "what you get" text alone: the page's one Sign in lives on the
+ // notice above it, so the card carries neither an action nor a link.
+ | { action?: undefined; link?: undefined }
+)
interface BillingPlanTierBase {
creditsDisplay?: string
@@ -137,9 +152,11 @@ export interface BillingView {
paymentRow?: BillingAccountRowView
/** Current-plan card (Plan section). Absent until billing.state resolves. */
plan?: BillingPlanCardView
+ /** Small print under the Plan section. Only the free-tier view sets it. */
+ planFootnote?: string
/** Automatic-refill section row. */
refillRow?: BillingAccountRowView
- status: 'loading' | 'logged_out' | 'normal' | 'refusal'
+ status: 'free_tier' | 'loading' | 'logged_out' | 'normal' | 'refusal'
summary: BillingSummaryItemView[]
/** Live tier catalog for the plans sub-view (empty when unavailable). */
tiers: BillingPlanTierView[]
@@ -196,6 +213,13 @@ export function deriveBillingView(
const billing = stateResult.data
const subscription = subscriptionResult?.ok ? subscriptionResult.data : null
+ // Read BEFORE the logged-out branch: a free-tier install has no account, so
+ // `logged_in` is false and the generic "connect your account" notice would
+ // otherwise win and tell the user to go to the portal.
+ if (billing.free_tier) {
+ return freeTierView(billing)
+ }
+
if (!billing.logged_in || subscription?.logged_in === false) {
return {
notice: {
@@ -297,6 +321,38 @@ function emptySummary(): BillingSummaryItemView[] {
]
}
+/**
+ * The no-account state: nothing is owed, nothing is owned, and every money
+ * control would be a lie. So the page collapses to one notice, a three-item
+ * summary, and a single plan card whose only action is signing in — no payment,
+ * credits, auto-refill or usage sections at all.
+ */
+function freeTierView(billing: BillingStateResponse): BillingView {
+ return {
+ notice: {
+ action: { label: 'Sign in', onSelect: openFreeTierSignIn },
+ message: 'Sign in to keep your connectors and unlock more.',
+ title: "You're on the Nous free tier",
+ tone: 'info'
+ },
+ plan: {
+ caption:
+ 'Runs on nous/welcome with connectors included. Signing in keeps your connectors and adds the tools that need an account and every other model.',
+ tierName: 'Nous · free tier'
+ },
+ planFootnote:
+ 'The free tier has no balance and nothing to pay. Payment and usage appear when you sign in with a Nous account.',
+ status: 'free_tier',
+ summary: [
+ { label: 'Plan', value: 'Free tier' },
+ { label: 'Model', value: billing.free_tier_model ?? FREE_TIER_MODEL },
+ { label: 'Connectors', tone: 'primary', value: 'Included' }
+ ],
+ tiers: [],
+ usageRows: []
+ }
+}
+
function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx
index 86562e26cb..f81d726ebb 100644
--- a/apps/desktop/src/app/settings/providers-settings.tsx
+++ b/apps/desktop/src/app/settings/providers-settings.tsx
@@ -157,13 +157,16 @@ function OAuthPicker({
const select = (p: OAuthProvider) => startManualProviderOAuth(p.id, profile)
- const featured = ordered.find(p => p.id === FEATURED_ID && !p.status?.logged_in) ?? null
+ // The free tier holds a token but no account: it is never "connected"; the featured Nous row
+ // names it (Nous · free tier) and offers the sign-in that keeps its connectors.
+ const isConnected = (p: OAuthProvider) => Boolean(p.status?.logged_in) && p.status?.free_tier !== true
+ const featured = ordered.find(p => p.id === FEATURED_ID && !isConnected(p)) ?? null
const rest = featured ? ordered.filter(p => p.id !== FEATURED_ID) : ordered
// Keep connected accounts grouped and always visible; only the unconnected
// providers hide behind the disclosure, so the page leads with what's set up.
// Both lists preserve `sortProviders` order (curated priority, then name).
- const connected = rest.filter(p => p.status?.logged_in)
- const others = rest.filter(p => !p.status?.logged_in)
+ const connected = rest.filter(isConnected)
+ const others = rest.filter(p => !isConnected(p))
const collapsible = others.length > 0
const showOthers = !collapsible || showAll
diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts
index 94859b09ad..820cc285c6 100644
--- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts
+++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.test.ts
@@ -54,7 +54,8 @@ describe('useStatusSnapshot', () => {
await flushAsync()
expect(getStatus).toHaveBeenCalledOnce()
- expect(requestGateway).toHaveBeenCalledTimes(2)
+ // One refresh round = setup.status + setup.runtime_check + free_tier.status.
+ expect(requestGateway).toHaveBeenCalledTimes(3)
})
it('keeps the last authoritative readiness through a transient RPC failure', async () => {
@@ -199,13 +200,14 @@ describe('useStatusSnapshot', () => {
renderHook(() => useStatusSnapshot('open', requestGateway))
await flushAsync()
- expect(requestGatewayMock).toHaveBeenCalledTimes(2)
+ // Three legs per round: setup.status, setup.runtime_check, free_tier.status.
+ expect(requestGatewayMock).toHaveBeenCalledTimes(3)
await act(async () => {
await vi.advanceTimersByTimeAsync(60_000)
})
- expect(requestGatewayMock).toHaveBeenCalledTimes(2)
+ expect(requestGatewayMock).toHaveBeenCalledTimes(3)
await act(async () => {
setup.resolve({ provider_configured: true })
@@ -216,11 +218,11 @@ describe('useStatusSnapshot', () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(59_999)
})
- expect(requestGatewayMock).toHaveBeenCalledTimes(2)
+ expect(requestGatewayMock).toHaveBeenCalledTimes(3)
await act(async () => {
await vi.advanceTimersByTimeAsync(1)
})
- expect(requestGatewayMock).toHaveBeenCalledTimes(4)
+ expect(requestGatewayMock).toHaveBeenCalledTimes(6)
})
})
diff --git a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
index 5fdc639177..c0917e9309 100644
--- a/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
+++ b/apps/desktop/src/app/shell/hooks/use-status-snapshot.ts
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { getStatus } from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
+import { refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier'
import type { StatusResponse } from '@/types/hermes'
// Statusbar health is ambient chrome, not live data — nothing the user acts on
@@ -59,7 +60,12 @@ export function useStatusSnapshot(
// race newer healthy results.
const [statusResult, inferenceResult] = await Promise.allSettled([
getStatus(),
- gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway) : Promise.resolve(null)
+ gatewayState === 'open' ? evaluateRuntimeReadiness(requestGateway) : Promise.resolve(null),
+ // The free-tier verdict is a local, zero-network read, so it rides
+ // this cadence rather than earning a poll of its own. It writes
+ // straight to its own store and swallows its failures — nothing here
+ // waits on it or reads the result.
+ gatewayState === 'open' ? refreshFreeTierStatus(requestGateway) : Promise.resolve(null)
])
if (cancelled) {
@@ -82,6 +88,7 @@ export function useStatusSnapshot(
// became unconfigured. Keep the last authoritative result instead
// of flashing "Inference not ready" during a gateway flap.
setInferenceStatus(inference)
+ setFreeTierRoute(inference.freeTier)
}
}
} finally {
diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
index 1714e62e57..ea96b309b0 100644
--- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
+++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx
@@ -10,6 +10,7 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel'
import { useContextBreakdown } from '@/app/shell/hooks/use-context-breakdown'
import { useSystemResourcesStatusbarItem } from '@/app/shell/system-resources-statusbar'
import { $paneVisible, togglePaneVisible } from '@/components/pane-shell/tree/store'
+import { Badge } from '@/components/ui/badge'
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { useI18n } from '@/i18n'
@@ -33,6 +34,8 @@ import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { resolveVersionStatus } from '@/lib/version-status'
import { copyFilePath, revealFile } from '@/store/file-actions'
+import { $freeTierStatus, FREE_TIER_MODEL } from '@/store/free-tier'
+import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import { revealFileInTree } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { $projectTree, projectNameForCwd } from '@/store/projects'
@@ -100,6 +103,7 @@ export function useStatusbarItems({
}: StatusbarItemsOptions) {
const { t } = useI18n()
const copy = t.shell.statusbar
+ const freeTierCopy = t.freeTier
const fileMenu = t.fileMenu
const primaryActiveSessionId = useStore($activeSessionId)
const activeGatewayProfile = useStore($activeGatewayProfile)
@@ -132,6 +136,9 @@ export function useStatusbarItems({
Object.values(bySession).reduce((sum, items) => sum + failedSubagentCount(items), 0)
)
+ // Backend truth for the free-tier chip. Refreshed on the ambient status
+ // cadence (use-status-snapshot), never polled from here.
+ const freeTier = useStore($freeTierStatus)
const updateStatus = useStore($updateStatus)
const updateApply = useStore($updateApply)
const backendUpdateStatus = useStore($backendUpdateStatus)
@@ -452,6 +459,23 @@ export function useStatusbarItems({
toggleLabel: copy.gateway,
variant: 'menu'
},
+ {
+ detail: (
+
+ {freeTierCopy.signIn}
+
+ ),
+ // Shown while a free-tier identity exists and the tier is on: it names the
+ // identity that carries the connectors (and inference when nothing else
+ // does), and it is the persistent way in to the sign-in.
+ hidden: !freeTier?.available,
+ icon: ,
+ id: 'free-tier',
+ label: freeTierCopy.statusLabel(freeTier?.model ?? FREE_TIER_MODEL),
+ onSelect: () => openFreeTierSignIn(),
+ toggleLabel: copy.toggleFreeTier,
+ variant: 'action'
+ },
{
hidden: !currentCwd,
icon: ,
@@ -535,9 +559,12 @@ export function useStatusbarItems({
commandCenterOpen,
copy,
currentCwd,
+ freeTierCopy,
fileMenu.copyPath,
fileMenu.revealFileManager,
fileMenu.revealInSidebar,
+ freeTier?.available,
+ freeTier?.model,
gatewayMenuContent,
gatewayClassName,
gatewayDetail,
diff --git a/apps/desktop/src/components/boot-failure-overlay.test.tsx b/apps/desktop/src/components/boot-failure-overlay.test.tsx
index fedfdd2b57..dd55e83adb 100644
--- a/apps/desktop/src/components/boot-failure-overlay.test.tsx
+++ b/apps/desktop/src/components/boot-failure-overlay.test.tsx
@@ -57,7 +57,8 @@ beforeEach(() => {
requested: false,
firstRunSkipped: false,
manual: false,
- localEndpoint: false
+ localEndpoint: false,
+ freeTierReady: false
})
failBoot()
})
diff --git a/apps/desktop/src/components/free-tier/notice-owner.test.tsx b/apps/desktop/src/components/free-tier/notice-owner.test.tsx
new file mode 100644
index 0000000000..738802e56b
--- /dev/null
+++ b/apps/desktop/src/components/free-tier/notice-owner.test.tsx
@@ -0,0 +1,21 @@
+import { act, renderHook } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
+ useGatewayRequest: () => ({ requestGateway: vi.fn() })
+}))
+
+describe('useFreeTierNoticeOwner', () => {
+ it('hands the notice to a composer that is still mounted when the owner unmounts', async () => {
+ const { useFreeTierNoticeOwner } = await import('./notice-strip')
+ const first = renderHook(() => useFreeTierNoticeOwner())
+ const second = renderHook(() => useFreeTierNoticeOwner())
+ expect(first.result.current).toBe(true)
+ expect(second.result.current).toBe(false)
+
+ act(() => first.unmount())
+
+ expect(second.result.current).toBe(true)
+ second.unmount()
+ })
+})
diff --git a/apps/desktop/src/components/free-tier/notice-strip.tsx b/apps/desktop/src/components/free-tier/notice-strip.tsx
new file mode 100644
index 0000000000..b0f1e68e3c
--- /dev/null
+++ b/apps/desktop/src/components/free-tier/notice-strip.tsx
@@ -0,0 +1,107 @@
+import { useStore } from '@nanostores/react'
+import { useEffect, useId } from 'react'
+
+import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
+import { StatusRow } from '@/components/chat/status-row'
+import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
+import { useI18n } from '@/i18n'
+import {
+ ackFreeTierNotice,
+ claimFreeTierNotice,
+ freeTierNoticeClaim,
+ releaseFreeTierNotice
+} from '@/store/free-tier'
+import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
+import { setModelPickerOpen } from '@/store/session'
+
+/**
+ * Which mounted composer gets to paint the strip. Several can be on screen at
+ * once (split zones, a popout mid-dock); the first to mount claims it and the
+ * rest report false, so one pending notice never paints N times. The CALLER
+ * asks, so a non-owning stack adds no empty row to its card.
+ */
+export function useFreeTierNoticeOwner(): boolean {
+ const id = useId()
+ const claim = useStore(freeTierNoticeClaim())
+
+ useEffect(() => {
+ claimFreeTierNotice(id)
+
+ return () => releaseFreeTierNotice(id)
+ }, [id])
+
+ // When the owner unmounts it releases the claim; a composer still mounted takes it over,
+ // so the notice does not vanish until some later mount.
+ useEffect(() => {
+ if (claim === null) {
+ claimFreeTierNotice(id)
+ }
+ }, [claim, id])
+
+ return claim === id
+}
+
+/**
+ * The quiet half of the free-tier introduction: shown when the user already has
+ * a provider of their own carrying inference, so the free models are an offer
+ * rather than the only road. It lives in the composer status stack — the same
+ * lane as the billing wall — and never blocks the composer.
+ *
+ * Backend-latched: `notice_pending` is the only source of truth, so any of the
+ * three actions retires it everywhere at once and no renderer flag can strand a
+ * strip the backend considers seen.
+ */
+export function FreeTierNoticeStrip() {
+ const { requestGateway } = useGatewayRequest()
+ const { t } = useI18n()
+ const copy = t.freeTier
+
+ const consume = (after?: () => void) => {
+ void ackFreeTierNotice(requestGateway)
+ after?.()
+ }
+
+ return (
+ }
+ trailing={
+ <>
+ consume(() => setModelPickerOpen(true))}
+ size="micro"
+ type="button"
+ variant="text"
+ >
+ {copy.openModelPicker}
+
+ consume(() => openFreeTierSignIn())}
+ size="micro"
+ type="button"
+ variant="text"
+ >
+ {copy.signIn}
+
+ consume()}
+ size="micro"
+ type="button"
+ variant="text"
+ >
+ {copy.dismiss}
+
+ >
+ }
+ trailingVisible
+ >
+
+ {copy.stripTitle}
+ {copy.stripBody}
+
+
+ )
+}
diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
new file mode 100644
index 0000000000..8918be7a85
--- /dev/null
+++ b/apps/desktop/src/components/free-tier/sign-in-dialog.test.tsx
@@ -0,0 +1,80 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type * as HermesApi from '@/hermes'
+import { $freeTierSignIn, openFreeTierSignIn } from '@/store/free-tier-sign-in'
+
+const pollOAuthSession = vi.fn()
+const requestGateway = vi.fn(async () => ({ available: true, has_guest: true }))
+
+// Only the two calls this flow makes are replaced; everything else keeps its
+// real implementation so the modules the dialog pulls in (the onboarding
+// DeviceCode cell, the model picker) still resolve their imports.
+vi.mock('@/hermes', async importOriginal => ({
+ ...(await importOriginal()),
+ pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId),
+ startOAuthLogin: async () => ({
+ expires_in: 900,
+ flow: 'device_code' as const,
+ poll_interval: 2,
+ session_id: 'session-1',
+ user_code: 'ABCD-EFGH',
+ verification_url: 'https://portal.example/claim?code=ABCD-EFGH'
+ })
+}))
+
+vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
+ useGatewayRequest: () => ({ requestGateway })
+}))
+
+beforeEach(() => {
+ vi.spyOn(window, 'open').mockReturnValue(null)
+})
+
+afterEach(() => {
+ cleanup()
+ $freeTierSignIn.set({ status: 'closed' })
+ vi.restoreAllMocks()
+ vi.clearAllMocks()
+ vi.useRealTimers()
+})
+
+describe('FreeTierSignInDialog', () => {
+ it('shows the transfer code, then the signed-in screen once the poll approves', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true })
+ pollOAuthSession.mockResolvedValue({
+ account_email: 'someone@example.com',
+ model: 'Hermes-4-405B',
+ reason: null,
+ session_id: 'session-1',
+ status: 'approved'
+ })
+
+ const { FreeTierSignInDialog } = await import('./sign-in-dialog')
+
+ await act(async () => {
+ render(
+
+
+
+ )
+ })
+
+ await act(async () => {
+ openFreeTierSignIn()
+ })
+
+ await waitFor(() => expect(screen.getByText('Do not share this code.')).toBeTruthy())
+ expect(screen.getByText('Enter this code in your browser to finish signing in.')).toBeTruthy()
+
+ // The 2s poll tick is what carries the approval through to the last screen.
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2100)
+ })
+
+ await waitFor(() => expect(screen.getByText('Signed in as someone@example.com')).toBeTruthy())
+ expect(screen.getByText('Your connectors are kept.')).toBeTruthy()
+ expect(screen.getByText('Hermes-4-405B')).toBeTruthy()
+ })
+})
diff --git a/apps/desktop/src/components/free-tier/sign-in-dialog.tsx b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
new file mode 100644
index 0000000000..2a765e49b4
--- /dev/null
+++ b/apps/desktop/src/components/free-tier/sign-in-dialog.tsx
@@ -0,0 +1,270 @@
+import { useStore } from '@nanostores/react'
+import { useQueryClient } from '@tanstack/react-query'
+import type * as React from 'react'
+import { type ReactNode, useEffect, useId } from 'react'
+
+import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
+import type { ModelSelection } from '@/app/shell/model-menu-panel'
+import { DeviceCode } from '@/components/onboarding/flow'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ preventCloseButtonAutoFocus
+} from '@/components/ui/dialog'
+import { getGlobalModelOptions } from '@/hermes'
+import { type Translations, useI18n } from '@/i18n'
+import { CheckCircle2, Loader2 } from '@/lib/icons'
+import { FREE_TIER_MODEL, NOUS_PROVIDER_ID, refreshFreeTierStatus } from '@/store/free-tier'
+import {
+ $freeTierSignIn,
+ beginFreeTierSignIn,
+ claimFreeTierSignIn,
+ closeFreeTierSignIn,
+ copyFreeTierCode,
+ copyFreeTierUrl,
+ freeTierSignInClaim,
+ type FreeTierSignInFailure,
+ releaseFreeTierSignIn
+} from '@/store/free-tier-sign-in'
+import { refreshOnboardingProviders } from '@/store/onboarding'
+import { $currentModel, setModelPickerOpen } from '@/store/session'
+
+interface FreeTierSignInDialogProps {
+ /** The app's model-assignment path. Used only to re-home a live session that
+ * is still pointed at the free-tier model once a real default arrives. */
+ onSelectModel?: (selection: ModelSelection) => Promise | void
+}
+
+/**
+ * The one sign-in surface for the free tier. Every entry point (Settings ›
+ * Billing, the statusbar chip, the first-launch intro) calls
+ * `openFreeTierSignIn()`; this host owns the gateway requester, drives the
+ * transfer, and paints one screen per state.
+ *
+ * Mounted once at the shell. A second mount claims nothing and renders null, so
+ * two hosts can never stack two dialogs over one flow.
+ */
+export function FreeTierSignInDialog({ onSelectModel }: FreeTierSignInDialogProps) {
+ const id = useId()
+ const claim = useStore(freeTierSignInClaim())
+ const state = useStore($freeTierSignIn)
+ const { requestGateway } = useGatewayRequest()
+ const queryClient = useQueryClient()
+ const { t } = useI18n()
+ const copy = t.freeTier
+ const owned = claim === id
+
+ useEffect(() => {
+ claimFreeTierSignIn(id)
+
+ return () => releaseFreeTierSignIn(id)
+ }, [id])
+
+ // An entry point can only record the intent — it has no requester of its own.
+ // The owner picks that up and starts the real flow.
+ useEffect(() => {
+ if (owned && state.status === 'requested') {
+ void beginFreeTierSignIn(requestGateway)
+ }
+ }, [owned, requestGateway, state.status])
+
+ if (!owned || state.status === 'closed' || state.status === 'requested') {
+ return null
+ }
+
+ // Everything that changed when the tokens landed: the account's billing, the
+ // model catalog (paid models are reachable now), the free-tier verdict the
+ // chrome paints, and the cached OAuth rows. Runs when the user leaves the
+ // completed screen, by either door.
+ const settle = (model: null | string) => {
+ void queryClient.invalidateQueries({ queryKey: ['billing'] })
+ void queryClient.invalidateQueries({ queryKey: ['model-options'] })
+ void getGlobalModelOptions({ refresh: true }).catch(() => undefined)
+ void refreshFreeTierStatus(requestGateway)
+ void refreshOnboardingProviders()
+
+ // Only re-home a session still sitting on the free-tier model: a user who
+ // already picked something of their own keeps it.
+ if (model && $currentModel.get() === FREE_TIER_MODEL) {
+ void onSelectModel?.({ model, provider: NOUS_PROVIDER_ID })
+ }
+ }
+
+ const finish = (model: null | string, after?: () => void) => {
+ settle(model)
+ closeFreeTierSignIn()
+ after?.()
+ }
+
+ const retry = () => void beginFreeTierSignIn(requestGateway)
+
+ return (
+
+ )
+}
+
+type FreeTierCopy = Translations['freeTier']
+
+function failureHeading(kind: FreeTierSignInFailure, copy: FreeTierCopy): string {
+ return kind === 'timed_out' ? copy.timedOutHeading : copy.didNotComplete
+}
+
+function failureBody(kind: FreeTierSignInFailure, message: null | string, copy: FreeTierCopy): string {
+ switch (kind) {
+ case 'rejected':
+ return copy.rejectedBody
+
+ case 'retired':
+ return copy.retiredBody
+
+ case 'superseded':
+ return copy.supersededBody
+
+ case 'timed_out':
+ return copy.timedOutBody
+
+ default:
+ // The backend's own wording when it sent one — it names the specific
+ // refusal (a busy account, a transport failure) better than we can.
+ return message ?? copy.errorBody
+ }
+}
+
+function Screen({
+ body,
+ children,
+ heading,
+ icon
+}: {
+ body?: string
+ children: ReactNode
+ heading: string
+ icon?: React.ComponentType<{ className?: string }>
+}) {
+ return (
+ <>
+
+ {heading}
+ {body ? {body} : null}
+
+ {children}
+ >
+ )
+}
+
+function Spinner({ children }: { children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ )
+}
+
+function Actions({ children }: { children: ReactNode }) {
+ return
{children}
+}
diff --git a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
index 66026b0a55..6dce4137db 100644
--- a/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
+++ b/apps/desktop/src/components/gateway-connecting-overlay.test.tsx
@@ -44,7 +44,8 @@ function resetStores() {
requested: false,
firstRunSkipped: false,
manual: false,
- localEndpoint: false
+ localEndpoint: false,
+ freeTierReady: false
})
}
diff --git a/apps/desktop/src/components/model-picker.tsx b/apps/desktop/src/components/model-picker.tsx
index 756931bdb1..700d7bc8cc 100644
--- a/apps/desktop/src/components/model-picker.tsx
+++ b/apps/desktop/src/components/model-picker.tsx
@@ -496,9 +496,12 @@ function ProviderHeading({ provider }: { provider: ModelOptionProvider }) {
const { t } = useI18n()
const copy = t.modelPicker
- // free_tier is only set for Nous. true → "Free tier", false → "Pro".
+ // Two different facts wear the same badge: `free_tier` is a signed-in Nous
+ // account on the free plan; `free_tier_row` is the no-account route's own
+ // row. Either way the user is on free inference, so say so. Never match the
+ // route by name — the label is copy.
const tierBadge =
- provider.free_tier === true ? (
+ provider.free_tier === true || provider.free_tier_row === true ? (
{copy.freeTier}
diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx
index dc4b16a54a..d3a8f0783d 100644
--- a/apps/desktop/src/components/onboarding/flow.tsx
+++ b/apps/desktop/src/components/onboarding/flow.tsx
@@ -146,7 +146,7 @@ function Step({ children, title }: { children: React.ReactNode; title: string })
// Device-code display: OTP-style — each character in its own readonly cell.
// The whole row is the copy button (no side button, no checkmark); on copy the
// cells flash emerald for feedback. Dashes render as quiet separators.
-function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
+export function DeviceCode({ code, copied, onCopy }: { code: string; copied: boolean; onCopy: () => void }) {
const { t } = useI18n()
return (
diff --git a/apps/desktop/src/components/onboarding/free-tier-intro.test.ts b/apps/desktop/src/components/onboarding/free-tier-intro.test.ts
new file mode 100644
index 0000000000..104bf68d81
--- /dev/null
+++ b/apps/desktop/src/components/onboarding/free-tier-intro.test.ts
@@ -0,0 +1,76 @@
+import { afterEach, describe, expect, it } from 'vitest'
+
+import { $freeTierRoute, $freeTierStatus, freeTierStripPending } from '@/store/free-tier'
+import { $desktopOnboarding, refreshOnboarding } from '@/store/onboarding'
+import type { FreeTierStatus } from '@/types/hermes'
+
+const READY: FreeTierStatus = {
+ available: true,
+ enabled: true,
+ has_guest: true,
+ label: 'Nous · free tier',
+ model: 'nous/welcome',
+ notice_pending: true
+}
+
+// A configured backend whose free-tier answer the test supplies. `setup.status`
+// and `setup.runtime_check` both report ready so refreshOnboarding takes the
+// "already configured" path — the one the intro hangs off.
+function gatewayReturning(freeTier: FreeTierStatus, route = true) {
+ return async (method: string): Promise => {
+ if (method === 'free_tier.status') {
+ return freeTier as T
+ }
+
+ if (method === 'setup.runtime_check') {
+ return { free_tier: route, ok: true } as T
+ }
+
+ return { provider_configured: true } as T
+ }
+}
+
+afterEach(() => {
+ $freeTierStatus.set(null)
+ $freeTierRoute.set(null)
+})
+
+describe('free-tier introduction branch table', () => {
+ it('opens the ready screen when the free tier is the route inference runs on', async () => {
+ await refreshOnboarding({ requestGateway: gatewayReturning(READY) })
+
+ expect($desktopOnboarding.get().freeTierReady).toBe(true)
+ // The overlay owns this case, so the composer strip must stay away.
+ expect(freeTierStripPending($freeTierStatus.get(), $freeTierRoute.get())).toBe(false)
+ })
+
+ it('offers the composer strip instead when the user owns the provider carrying inference', async () => {
+ await refreshOnboarding({ requestGateway: gatewayReturning(READY, false) })
+
+ expect($desktopOnboarding.get().freeTierReady).toBe(false)
+ expect(freeTierStripPending($freeTierStatus.get(), $freeTierRoute.get())).toBe(true)
+ })
+
+ it('shows nothing once the notice has been acknowledged', async () => {
+ await refreshOnboarding({ requestGateway: gatewayReturning({ ...READY, notice_pending: false }) })
+
+ expect($desktopOnboarding.get().freeTierReady).toBe(false)
+ expect(freeTierStripPending($freeTierStatus.get(), $freeTierRoute.get())).toBe(false)
+ })
+})
+
+describe('acknowledging the introduction', () => {
+ it('reports a failed ack so the ready screen stays up', async () => {
+ const { ackFreeTierIntro } = await import('@/store/onboarding')
+ const failing = async (method: string): Promise => {
+ if (method === 'free_tier.ack_notice') {
+ throw new Error('gateway away')
+ }
+
+ return READY as T
+ }
+
+ expect(await ackFreeTierIntro({ requestGateway: failing })).toBe(false)
+ expect(await ackFreeTierIntro({ requestGateway: gatewayReturning(READY) })).toBe(false) // status stub returns no {acked: true}
+ })
+})
diff --git a/apps/desktop/src/components/onboarding/index.test.tsx b/apps/desktop/src/components/onboarding/index.test.tsx
index ef099f93e0..5f8b27df0e 100644
--- a/apps/desktop/src/components/onboarding/index.test.tsx
+++ b/apps/desktop/src/components/onboarding/index.test.tsx
@@ -17,7 +17,8 @@ function setProviders(providers: OAuthProvider[]) {
requested: false,
firstRunSkipped: false,
manual: false,
- localEndpoint: false
+ localEndpoint: false,
+ freeTierReady: false
} satisfies DesktopOnboardingState)
}
@@ -41,7 +42,8 @@ afterEach(() => {
requested: false,
firstRunSkipped: false,
manual: false,
- localEndpoint: false
+ localEndpoint: false,
+ freeTierReady: false
})
})
diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx
index d18c819753..419408c2c5 100644
--- a/apps/desktop/src/components/onboarding/index.tsx
+++ b/apps/desktop/src/components/onboarding/index.tsx
@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react'
import { useEffect, useMemo, useRef, useState } from 'react'
+import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Input } from '@/components/ui/input'
@@ -11,9 +12,13 @@ import { Check, ChevronDown, ChevronLeft, KeyRound, Loader2 } from '@/lib/icons'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { cn } from '@/lib/utils'
import { $desktopBoot, type DesktopBootState } from '@/store/boot'
+import { FREE_TIER_MODEL } from '@/store/free-tier'
+import { openFreeTierSignIn } from '@/store/free-tier-sign-in'
import { $localModelsEnabled } from '@/store/local-models-flag'
import {
$desktopOnboarding,
+ ackFreeTierIntro,
+ clearFreeTierIntro,
clearPendingProviderOAuth,
closeManualOnboarding,
confirmOnboardingModel,
@@ -25,11 +30,13 @@ import {
refreshOnboarding,
saveOnboardingApiKey,
setOnboardingMode,
+ startManualOnboarding,
startProviderOAuth
} from '@/store/onboarding'
import type { ModelOptionProvider, OAuthProvider } from '@/types/hermes'
import { DocsLink, FlowPanel, Status } from './flow'
+import { DecodedLabel } from './glyph'
import {
FeaturedProviderRow,
FireworksProviderRow,
@@ -231,6 +238,38 @@ export function DesktopOnboardingOverlay({
window.setTimeout(() => confirmOnboardingModel(ctx), ONBOARDING_EXIT_MS)
}
+ // The free-tier intro's three doors share one exit: consume the notice, play
+ // the same dissolve, then run whatever the door opens onto. `after` runs at
+ // the END so a sign-in dialog or provider picker never appears behind a
+ // still-fading overlay.
+ const dismissFreeTierIntro = async (after?: () => void) => {
+ if (leaving) {
+ return
+ }
+
+ // The screen is keyed on the backend's notice flag: only a recorded ack takes it down.
+ // A failed ack leaves it in place for another try rather than hiding the only notice.
+ if (!(await ackFreeTierIntro(ctx))) {
+ return
+ }
+
+ const reduce = typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
+
+ if (reduce) {
+ clearFreeTierIntro()
+ after?.()
+
+ return
+ }
+
+ setLeaving(true)
+ window.setTimeout(() => {
+ setLeaving(false)
+ clearFreeTierIntro()
+ after?.()
+ }, ONBOARDING_EXIT_MS)
+ }
+
useEffect(() => {
if (enabled || onboarding.requested) {
void refreshOnboarding(ctx)
@@ -272,14 +311,14 @@ export function DesktopOnboardingOverlay({
// do we know whether to dismiss (true) or surface the picker (false).
// EXCEPTION: manual mode (user opened the selector from a working app to
// add/switch a provider) shows the overlay regardless of configured state.
- if (onboarding.configured === true && !onboarding.manual) {
+ if (onboarding.configured === true && !onboarding.manual && !onboarding.freeTierReady) {
return null
}
// The user chose "I'll choose a provider later" on first run. Stay out of the
// way on every subsequent launch — they re-enter via Settings → Providers
// (manual mode), which sets manual=true and bypasses this gate.
- if (onboarding.firstRunSkipped && !onboarding.manual) {
+ if (onboarding.firstRunSkipped && !onboarding.manual && !onboarding.freeTierReady) {
return null
}
@@ -300,11 +339,15 @@ export function DesktopOnboardingOverlay({
// In manual mode the app is already configured, so the flow is "ready"
// immediately — no runtime gate needed. Otherwise wait for the readiness
// check (configured === false) before showing the picker.
- const ready = onboarding.manual || (enabled && onboarding.configured === false)
- const showPicker = flow.status === 'idle' || flow.status === 'success'
+ // The free-tier intro owns the overlay while it is up: the app is already
+ // configured, so there is no picker to show and no runtime gate to wait on.
+ // A manual open (the user asked for the picker) outranks it.
+ const freeTierIntro = onboarding.freeTierReady && !onboarding.manual && flow.status === 'idle'
+ const ready = freeTierIntro || onboarding.manual || (enabled && onboarding.configured === false)
+ const showPicker = !freeTierIntro && (flow.status === 'idle' || flow.status === 'success')
// The final "you're in" screen drops the card chrome and floats centered on
// the surface — same bare, cinematic treatment as the connecting overlay.
- const bare = ready && !showPicker && flow.status === 'confirming_model'
+ const bare = ready && (freeTierIntro || (!showPicker && flow.status === 'confirming_model'))
return (
{reason ? : null}
{ready ? (
- showPicker ? (
+ freeTierIntro ? (
+
+ ) : showPicker ? (
) : (
@@ -362,6 +407,65 @@ export function DesktopOnboardingOverlay({
)
}
+/**
+ * The one-time free-tier welcome, shown when the free tier is what serves this
+ * user. Bare and centered like the model-confirm screen it stands in for: this
+ * IS their "you're in" moment, so it names the route, its model and its price,
+ * and offers the two ways out of it (a real account, or a provider of their
+ * own) without making either the default.
+ */
+function FreeTierReadyPanel({
+ leaving,
+ onDismiss
+}: {
+ leaving: boolean
+ onDismiss: (after?: () => void) => Promise
+}) {
+ const { t } = useI18n()
+ const copy = t.freeTier
+
+ return (
+
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 199ba6e163..0948fa3618 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -3274,6 +3274,48 @@ export const en: Translations = {
docs: provider => `${provider} docs`
},
+ freeTier: {
+ providerRowTitle: 'Nous · free tier',
+ providerRowPitch: 'Sign in to keep your connectors and unlock more.',
+ readyTitle: 'Hermes is ready.',
+ readyCaption: 'Free · connectors included',
+ begin: 'Begin',
+ signInInstead: 'Sign in with a Nous account instead',
+ otherProviders: 'Other providers',
+ stripTitle: 'Free Nous inference and connectors are now available.',
+ stripBody: 'Open the model picker to try them, or sign in with a Nous account.',
+ openModelPicker: 'Open model picker',
+ dismiss: 'Dismiss',
+ statusLabel: model => `Nous · free tier · ${model}`,
+ signIn: 'Sign in',
+ signInHeading: 'Sign in to keep your connectors and unlock more.',
+ settingUp: 'Setting up free inference…',
+ codeBody: 'Enter this code in your browser to finish signing in.',
+ copyLink: 'Copy link',
+ doNotShare: 'Do not share this code.',
+ waiting: 'Waiting for sign-in…',
+ finishingHeading: 'Finishing sign-in…',
+ finishingBody: 'Approved in the browser. Collecting your account tokens.',
+ signedInAs: email => `Signed in as ${email}`,
+ signedIn: 'Signed in.',
+ connectorsKept: 'Your connectors are kept.',
+ defaultModel: 'Default model',
+ change: 'Change',
+ done: 'Done',
+ notNow: 'Not now',
+ tryAgain: 'Try again',
+ startAgain: 'Start again',
+ didNotComplete: 'Sign-in did not complete',
+ rejectedBody: 'Sign-in was rejected in the browser. You are still on the free tier.',
+ supersededBody: 'A newer sign-in code replaced this one.',
+ timedOutHeading: 'Sign-in timed out',
+ timedOutBody: 'The code was not used in time. You are still on the free tier.',
+ retiredBody: 'This free-tier identity was already used or expired; a new one is set up on next use.',
+ errorBody: 'Sign-in did not complete; run it again.',
+ alreadySignedInHeading: 'Already signed in.',
+ alreadySignedInBody: 'This Hermes is already signed in to a Nous account.'
+ },
+
modelPicker: {
title: 'Switch model',
current: 'current:',
@@ -3398,6 +3440,7 @@ export const en: Translations = {
toggleTerminal: 'Terminal',
toggleTokensPerSecond: 'Tokens per second',
toggleVersion: 'Version & updates',
+ toggleFreeTier: 'Free tier',
toggleWorkspace: 'Workspace',
cacheHitRateTitle: 'Prompt cache hit rate this session — cached tokens cost less, so higher is cheaper',
tokensPerSecondTitle: 'Output tokens per second, averaged over the last 10 model calls',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 39964408f2..0ebca0ec6a 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -2800,6 +2800,53 @@ export interface Translations {
docs: (provider: string) => string
}
+ freeTier: {
+ /** Settings › Providers row title while the Nous identity is the free tier. */
+ providerRowTitle: string
+ /** The featured row's pitch while the identity is the free tier: what signing in adds. */
+ providerRowPitch: string
+ // First-launch introduction (ready screen + composer strip).
+ readyTitle: string
+ readyCaption: string
+ begin: string
+ signInInstead: string
+ otherProviders: string
+ stripTitle: string
+ stripBody: string
+ openModelPicker: string
+ dismiss: string
+ // Statusbar chip.
+ statusLabel: (model: string) => string
+ // Sign-in dialog.
+ signIn: string
+ signInHeading: string
+ settingUp: string
+ codeBody: string
+ copyLink: string
+ doNotShare: string
+ waiting: string
+ finishingHeading: string
+ finishingBody: string
+ signedInAs: (email: string) => string
+ signedIn: string
+ connectorsKept: string
+ defaultModel: string
+ change: string
+ done: string
+ notNow: string
+ tryAgain: string
+ startAgain: string
+ didNotComplete: string
+ rejectedBody: string
+ supersededBody: string
+ timedOutHeading: string
+ timedOutBody: string
+ retiredBody: string
+ errorBody: string
+ alreadySignedInHeading: string
+ alreadySignedInBody: string
+ }
+
modelPicker: {
title: string
current: string
@@ -2924,6 +2971,7 @@ export interface Translations {
toggleTerminal: string
toggleTokensPerSecond: string
toggleVersion: string
+ toggleFreeTier: string
toggleWorkspace: string
cacheHitRateTitle: string
tokensPerSecondTitle: string
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index f6bc67c188..ae9a80e520 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -3411,6 +3411,50 @@ export const zh: Translations = {
docs: provider => `${provider} 文档`
},
+ // Not yet translated — English fallbacks so the free-tier surfaces stay
+ // readable until a zh pass lands.
+ freeTier: {
+ providerRowTitle: 'Nous · free tier',
+ providerRowPitch: 'Sign in to keep your connectors and unlock more.',
+ readyTitle: 'Hermes is ready.',
+ readyCaption: 'Free · connectors included',
+ begin: 'Begin',
+ signInInstead: 'Sign in with a Nous account instead',
+ otherProviders: 'Other providers',
+ stripTitle: 'Free Nous inference and connectors are now available.',
+ stripBody: 'Open the model picker to try them, or sign in with a Nous account.',
+ openModelPicker: 'Open model picker',
+ dismiss: 'Dismiss',
+ statusLabel: model => `Nous · free tier · ${model}`,
+ signIn: 'Sign in',
+ signInHeading: 'Sign in to keep your connectors and unlock more.',
+ settingUp: 'Setting up free inference…',
+ codeBody: 'Enter this code in your browser to finish signing in.',
+ copyLink: 'Copy link',
+ doNotShare: 'Do not share this code.',
+ waiting: 'Waiting for sign-in…',
+ finishingHeading: 'Finishing sign-in…',
+ finishingBody: 'Approved in the browser. Collecting your account tokens.',
+ signedInAs: email => `Signed in as ${email}`,
+ signedIn: 'Signed in.',
+ connectorsKept: 'Your connectors are kept.',
+ defaultModel: 'Default model',
+ change: 'Change',
+ done: 'Done',
+ notNow: 'Not now',
+ tryAgain: 'Try again',
+ startAgain: 'Start again',
+ didNotComplete: 'Sign-in did not complete',
+ rejectedBody: 'Sign-in was rejected in the browser. You are still on the free tier.',
+ supersededBody: 'A newer sign-in code replaced this one.',
+ timedOutHeading: 'Sign-in timed out',
+ timedOutBody: 'The code was not used in time. You are still on the free tier.',
+ retiredBody: 'This free-tier identity was already used or expired; a new one is set up on next use.',
+ errorBody: 'Sign-in did not complete; run it again.',
+ alreadySignedInHeading: 'Already signed in.',
+ alreadySignedInBody: 'This Hermes is already signed in to a Nous account.'
+ },
+
modelPicker: {
title: '切换模型',
current: '当前:',
@@ -3535,6 +3579,7 @@ export const zh: Translations = {
toggleTerminal: '终端',
toggleTokensPerSecond: '每秒 token 数',
toggleVersion: '版本与更新',
+ toggleFreeTier: 'Free tier',
toggleWorkspace: '工作区',
cacheHitRateTitle: '本会话的提示缓存命中率 — 缓存 token 更便宜,越高越省',
tokensPerSecondTitle: '每秒输出 token 数,取最近 10 次模型调用的平均值',
diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts
index 252bbf1963..e07c150842 100644
--- a/apps/desktop/src/lib/runtime-readiness.ts
+++ b/apps/desktop/src/lib/runtime-readiness.ts
@@ -4,7 +4,12 @@ export interface SetupStatusSnapshot {
export interface RuntimeCheckSnapshot {
error?: string
+ /** True when the resolved route is the free tier rather than a credential of
+ * the user's own. Absent on older backends. */
+ free_tier?: boolean
+ model?: string
ok?: boolean
+ provider?: string
}
export interface RuntimeReadinessSignals {
@@ -22,6 +27,13 @@ export interface RuntimeReadinessOptions {
export interface RuntimeReadinessResult {
checksDisagree: boolean
+ /** Passed through from `setup.runtime_check`: the resolved route is the free
+ * tier. Undefined when the check did not answer (older backend, transport
+ * fallback) — never read it as "not free tier". */
+ freeTier?: boolean
+ /** Passed through from `setup.runtime_check`: the model the route resolved
+ * to. Undefined when the check did not answer. */
+ model?: string
ready: boolean
reason: null | string
source: 'fallback' | 'runtime_check' | 'setup_status'
@@ -100,12 +112,21 @@ export function interpretRuntimeReadiness(
const runtimeFailure = normalizeMessage(signals.runtime?.error) ?? normalizeMessage(signals.runtimeError)
const setupFailure = normalizeMessage(signals.setupError)
+ // Route facts the check reported, carried through untouched so consumers
+ // (free-tier chrome) don't have to re-issue setup.runtime_check. Left
+ // undefined when the check said nothing — "absent" and "false" differ.
+ const route = {
+ freeTier: typeof signals.runtime?.free_tier === 'boolean' ? signals.runtime.free_tier : undefined,
+ model: normalizeMessage(signals.runtime?.model) ?? undefined
+ }
+
const checksDisagree =
typeof setupConfigured === 'boolean' && typeof runtimeOk === 'boolean' && setupConfigured !== runtimeOk
if (typeof runtimeOk === 'boolean') {
if (runtimeOk) {
return {
+ ...route,
checksDisagree,
ready: true,
reason: null,
@@ -120,6 +141,7 @@ export function interpretRuntimeReadiness(
}
return {
+ ...route,
checksDisagree,
ready: false,
reason,
@@ -129,6 +151,7 @@ export function interpretRuntimeReadiness(
if (typeof setupConfigured === 'boolean') {
return {
+ ...route,
checksDisagree: false,
ready: setupConfigured,
reason: setupConfigured ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
@@ -137,6 +160,7 @@ export function interpretRuntimeReadiness(
}
return {
+ ...route,
checksDisagree: false,
ready: unknownReady,
reason: unknownReady ? null : (runtimeFailure ?? setupFailure ?? defaultReason),
diff --git a/apps/desktop/src/store/free-tier-sign-in.test.ts b/apps/desktop/src/store/free-tier-sign-in.test.ts
new file mode 100644
index 0000000000..3bf6b369b3
--- /dev/null
+++ b/apps/desktop/src/store/free-tier-sign-in.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type * as HermesApi from '@/hermes'
+import type { FreeTierRequester } from '@/store/free-tier'
+
+const startOAuthLogin = vi.fn()
+const pollOAuthSession = vi.fn()
+const cancelOAuthSession = vi.fn(async (_id: string) => ({ ok: true }))
+
+vi.mock('@/hermes', async importOriginal => ({
+ ...(await importOriginal()),
+ cancelOAuthSession: (id: string) => cancelOAuthSession(id),
+ listOAuthProviders: async () => ({ providers: [] }),
+ pollOAuthSession: (providerId: string, sessionId: string) => pollOAuthSession(providerId, sessionId),
+ startOAuthLogin: () => startOAuthLogin()
+}))
+
+const requestGateway = (async (_method: string, _params?: Record): Promise =>
+ ({ available: true, has_guest: true }) as T) satisfies FreeTierRequester
+const start = (id: string) => ({
+ expires_in: 900,
+ flow: 'device_code' as const,
+ poll_interval: 2,
+ session_id: id,
+ user_code: 'ABCD-EFGH',
+ verification_url: `https://portal.example/claim?code=${id}`
+})
+
+beforeEach(() => {
+ vi.useFakeTimers()
+ vi.spyOn(window, 'open').mockReturnValue(null)
+})
+
+afterEach(async () => {
+ const { closeFreeTierSignIn } = await import('./free-tier-sign-in')
+ closeFreeTierSignIn()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ vi.clearAllMocks()
+})
+
+describe('free-tier sign-in attempts', () => {
+ it('a poll from a closed attempt never overwrites the attempt now on screen', async () => {
+ const { $freeTierSignIn, beginFreeTierSignIn, closeFreeTierSignIn } = await import('./free-tier-sign-in')
+ let resolveA: (value: unknown) => void = () => undefined
+ startOAuthLogin.mockResolvedValueOnce(start('session-a')).mockResolvedValueOnce(start('session-b'))
+ pollOAuthSession.mockImplementation(
+ (_provider: string, id: string) =>
+ id === 'session-a' ? new Promise(resolve => (resolveA = resolve)) : Promise.resolve({ session_id: id, status: 'pending' })
+ )
+
+ await beginFreeTierSignIn(requestGateway)
+ expect($freeTierSignIn.get()).toMatchObject({ sessionId: 'session-a', status: 'code' })
+ await vi.advanceTimersByTimeAsync(2000) // A's poll is now in flight
+
+ closeFreeTierSignIn()
+ await beginFreeTierSignIn(requestGateway)
+ expect($freeTierSignIn.get()).toMatchObject({ sessionId: 'session-b', status: 'code' })
+
+ resolveA({ account_email: 'old@example.com', model: 'x', session_id: 'session-a', status: 'approved' })
+ await vi.advanceTimersByTimeAsync(10)
+
+ expect($freeTierSignIn.get()).toMatchObject({ sessionId: 'session-b', status: 'code' })
+ expect(cancelOAuthSession).toHaveBeenCalledWith('session-a')
+ })
+})
diff --git a/apps/desktop/src/store/free-tier-sign-in.ts b/apps/desktop/src/store/free-tier-sign-in.ts
new file mode 100644
index 0000000000..953466147e
--- /dev/null
+++ b/apps/desktop/src/store/free-tier-sign-in.ts
@@ -0,0 +1,313 @@
+import { atom } from 'nanostores'
+
+import { cancelOAuthSession, listOAuthProviders, pollOAuthSession, startOAuthLogin } from '@/hermes'
+
+import { type FreeTierRequester, NOUS_PROVIDER_ID, refreshFreeTierStatus } from './free-tier'
+
+const POLL_MS = 2000
+const COPY_FLASH_MS = 1500
+
+/** Why a sign-in ended without tokens. Each maps to one ruled screen; anything
+ * the backend does not name (transport failure, `account_busy`) lands on
+ * `error`, which carries the backend's own message when there is one. */
+export type FreeTierSignInFailure = 'error' | 'rejected' | 'retired' | 'superseded' | 'timed_out'
+
+export type FreeTierSignInState =
+ | { status: 'already_signed_in' }
+ | { status: 'closed' }
+ | { status: 'finishing' }
+ // A "please open the dialog" request from an entry point that has no gateway
+ // requester of its own. The mounted host picks it up and drives the flow.
+ | { status: 'requested' }
+ | { email: null | string; model: null | string; status: 'completed' }
+ | { kind: FreeTierSignInFailure; message: null | string; status: 'failed' }
+ // `minting` is true only when the backend still has to create the free-tier
+ // identity (the first `start` does it), which is the one case where the user
+ // waits on something worth naming.
+ | { minting: boolean; status: 'setting_up' }
+ | {
+ code: string
+ codeCopied: boolean
+ sessionId: string
+ url: string
+ urlCopied: boolean
+ status: 'code'
+ }
+
+export const $freeTierSignIn = atom({ status: 'closed' })
+
+// Several surfaces can mount the dialog host (the shell, a test harness). The
+// FIRST mount claims it; the rest render nothing, so one open never stacks two
+// identical dialogs. Mirrors the real-profile-consent claim.
+const $claim = atom(null)
+
+export function claimFreeTierSignIn(id: string) {
+ if ($claim.get() === null) {
+ $claim.set(id)
+ }
+}
+
+export function releaseFreeTierSignIn(id: string) {
+ if ($claim.get() === id) {
+ $claim.set(null)
+ }
+}
+
+export function freeTierSignInClaim() {
+ return $claim
+}
+
+let pollTimer: number | null = null
+let expiryTimer: number | null = null
+// Each begin/close bumps the generation. A continuation that resumes after an await (a poll that was
+// already in flight when the dialog closed, a start call for an abandoned attempt) compares its own
+// generation and drops out, so a stale attempt never publishes over the one now on screen.
+let attempt = 0
+
+function clearTimers() {
+ if (pollTimer !== null) {
+ window.clearInterval(pollTimer)
+ pollTimer = null
+ }
+
+ if (expiryTimer !== null) {
+ window.clearTimeout(expiryTimer)
+ expiryTimer = null
+ }
+}
+
+const set = (state: FreeTierSignInState) => $freeTierSignIn.set(state)
+
+const fail = (kind: FreeTierSignInFailure, message: null | string = null) => {
+ clearTimers()
+ set({ kind, message: message?.trim() || null, status: 'failed' })
+}
+
+/** Every entry point calls this — Settings › Billing, the statusbar chip, the
+ * first-launch intro. It only records the intent; the mounted host owns the
+ * gateway requester and drives the flow. Re-entrant by design: a second click
+ * while a sign-in is already on screen must not restart it. */
+export function openFreeTierSignIn() {
+ if ($freeTierSignIn.get().status === 'closed') {
+ set({ status: 'requested' })
+ }
+}
+
+/** Close and abandon. Cancels a live device-code session so the backend is not
+ * left polling a window nobody is watching. */
+export function closeFreeTierSignIn() {
+ const state = $freeTierSignIn.get()
+
+ attempt += 1
+ clearTimers()
+
+ if (state.status === 'code') {
+ cancelOAuthSession(state.sessionId).catch(() => undefined)
+ }
+
+ set({ status: 'closed' })
+}
+
+// The reasons the backend names on a non-approved terminal poll. Anything else
+// (including a bare `account_busy`) falls through to the generic error screen,
+// which shows the backend's own message.
+const FAILURE_BY_REASON: Record = {
+ account_not_anonymous: 'retired',
+ account_retired: 'retired',
+ superseded: 'superseded',
+ timeout: 'timed_out',
+ user_declined: 'rejected'
+}
+
+// Open a sign-in URL through the desktop bridge, falling back to window.open
+// when the bridge isn't there (dev preview, tests) so the flow never strands in
+// a waiting state. Same contract as the onboarding store's opener.
+async function openSignInUrl(url: string) {
+ if (window.hermesDesktop?.openExternal) {
+ try {
+ await window.hermesDesktop.openExternal(url)
+
+ return
+ } catch {
+ // Bridge present but failed (no OS handler, user denied). Fall through.
+ }
+ }
+
+ window.open(url, '_blank', 'noopener,noreferrer')
+}
+
+/**
+ * Drive one sign-in attempt end to end: resolve what identity this Hermes is
+ * on, start the transfer, open the consent page, then poll until it resolves.
+ * Safe to call again from a "Try again" button — it clears any previous timers
+ * first.
+ */
+export async function beginFreeTierSignIn(requestGateway: FreeTierRequester) {
+ clearTimers()
+ const mine = ++attempt
+ const stale = () => mine !== attempt
+
+ const status = await refreshFreeTierStatus(requestGateway)
+
+ if (stale()) {
+ return
+ }
+
+ const minting = !status?.has_guest
+
+ // No free-tier identity AND a real Nous account already connected: there is
+ // nothing to transfer. Say so instead of minting a guest the user does not
+ // need. A failed provider read is not proof either way — fall through and let
+ // the start call be the authority.
+ if (minting) {
+ try {
+ const { providers } = await listOAuthProviders()
+
+ if (stale()) {
+ return
+ }
+
+ const nous = providers.find(provider => provider.id === NOUS_PROVIDER_ID)
+
+ if (nous?.status.logged_in && nous.status.free_tier !== true) {
+ set({ status: 'already_signed_in' })
+
+ return
+ }
+ } catch {
+ // Provider list unavailable — continue with the sign-in.
+ }
+ }
+
+ set({ minting, status: 'setting_up' })
+
+ try {
+ const start = await startOAuthLogin(NOUS_PROVIDER_ID)
+
+ if (stale()) {
+ // The user closed the dialog while the start call was out: do not leave the backend
+ // polling a session nobody is watching.
+ cancelOAuthSession(start.session_id).catch(() => undefined)
+
+ return
+ }
+
+ if (start.flow !== 'device_code') {
+ fail('error', null)
+
+ return
+ }
+
+ await openSignInUrl(start.verification_url)
+
+ if (stale()) {
+ cancelOAuthSession(start.session_id).catch(() => undefined)
+
+ return
+ }
+
+ set({
+ code: start.user_code,
+ codeCopied: false,
+ sessionId: start.session_id,
+ status: 'code',
+ url: start.verification_url,
+ urlCopied: false
+ })
+
+ // Lapse locally when the code's own window closes, instead of polling a
+ // dead session forever. The backend usually flips it first and its message
+ // wins; this is the floor.
+ const ttlMs = Math.max(1, Number(start.expires_in) || 0) * 1000
+ expiryTimer = window.setTimeout(() => {
+ expiryTimer = null
+ fail('timed_out', null)
+ }, ttlMs)
+
+ pollTimer = window.setInterval(() => void pollOnce(start.session_id, requestGateway, mine), POLL_MS)
+ } catch (error) {
+ if (!stale()) {
+ fail('error', error instanceof Error ? error.message : String(error))
+ }
+ }
+}
+
+async function pollOnce(sessionId: string, requestGateway: FreeTierRequester, mine: number) {
+ const stale = () => mine !== attempt
+
+ try {
+ const result = await pollOAuthSession(NOUS_PROVIDER_ID, sessionId)
+
+ if (stale() || result.status === 'pending') {
+ return
+ }
+
+ clearTimers()
+
+ if (result.status !== 'approved') {
+ const kind = FAILURE_BY_REASON[result.reason ?? ''] ?? 'error'
+ fail(kind, result.error_message ?? null)
+
+ return
+ }
+
+ set({ status: 'finishing' })
+
+ // The tokens are on disk now, so the backend's view of this identity has
+ // changed: reload its env and re-read the free-tier verdict before the
+ // completed screen claims the user is signed in.
+ await requestGateway('reload.env').catch(() => undefined)
+ await refreshFreeTierStatus(requestGateway)
+
+ if (stale()) {
+ return
+ }
+
+ set({
+ email: result.account_email ?? null,
+ model: result.model ?? null,
+ status: 'completed'
+ })
+ } catch (error) {
+ if (!stale()) {
+ fail('error', error instanceof Error ? error.message : String(error))
+ }
+ }
+}
+
+async function copyAndFlash(text: string, field: 'codeCopied' | 'urlCopied') {
+ try {
+ await navigator.clipboard.writeText(text)
+ } catch {
+ return
+ }
+
+ const current = $freeTierSignIn.get()
+
+ if (current.status !== 'code') {
+ return
+ }
+
+ const { sessionId } = current
+ set({ ...current, [field]: true })
+
+ window.setTimeout(() => {
+ const later = $freeTierSignIn.get()
+
+ if (later.status === 'code' && later.sessionId === sessionId) {
+ set({ ...later, [field]: false })
+ }
+ }, COPY_FLASH_MS)
+}
+
+export function copyFreeTierCode() {
+ const state = $freeTierSignIn.get()
+
+ return state.status === 'code' ? copyAndFlash(state.code, 'codeCopied') : Promise.resolve()
+}
+
+export function copyFreeTierUrl() {
+ const state = $freeTierSignIn.get()
+
+ return state.status === 'code' ? copyAndFlash(state.url, 'urlCopied') : Promise.resolve()
+}
diff --git a/apps/desktop/src/store/free-tier.ts b/apps/desktop/src/store/free-tier.ts
new file mode 100644
index 0000000000..27d8f58dd9
--- /dev/null
+++ b/apps/desktop/src/store/free-tier.ts
@@ -0,0 +1,123 @@
+import { atom } from 'nanostores'
+
+import type { FreeTierStatus } from '@/types/hermes'
+
+/** The model the free-tier route runs on. Used to recognise a session that is
+ * still homed on the free tier after a sign-in. */
+export const FREE_TIER_MODEL = 'nous/welcome'
+
+/** The provider slug the free-tier route and a signed-in Nous account share. */
+export const NOUS_PROVIDER_ID = 'nous'
+
+export type FreeTierRequester = (method: string, params?: Record) => Promise
+
+/**
+ * The backend's free-tier verdict, cached for the chrome that paints it (the
+ * statusbar chip, the first-launch intro, the billing view). The backend is
+ * authoritative — this atom is only a cache of `free_tier.status`, which is a
+ * local, zero-network read — so nothing here ever decides on its own that the
+ * free tier is on or off. `null` means "not asked yet"; every consumer must
+ * render as if there were no free tier until an answer lands.
+ */
+export const $freeTierStatus = atom(null)
+
+function isFreeTierStatus(value: unknown): value is FreeTierStatus {
+ return typeof value === 'object' && value !== null && typeof (value as FreeTierStatus).has_guest === 'boolean'
+}
+
+/**
+ * Pull the current status. No polling loop of its own: callers ride an existing
+ * cadence (the ambient status snapshot) or a seam that just changed the answer
+ * (boot, a completed sign-in, an acknowledged notice).
+ *
+ * A failed read leaves the last known answer in place rather than blanking the
+ * chrome — an older backend without the method, or a gateway flap, is not
+ * evidence that the free tier went away.
+ */
+export async function refreshFreeTierStatus(requestGateway: FreeTierRequester): Promise {
+ try {
+ const status = await requestGateway('free_tier.status')
+
+ if (!isFreeTierStatus(status)) {
+ return $freeTierStatus.get()
+ }
+
+ $freeTierStatus.set(status)
+
+ return status
+ } catch {
+ return $freeTierStatus.get()
+ }
+}
+
+/** Persist the one-time notice acknowledgement, then re-read so every surface
+ * keyed on `notice_pending` drops away together. */
+export async function ackFreeTierNotice(requestGateway: FreeTierRequester): Promise {
+ try {
+ const result = await requestGateway<{ acked?: boolean }>('free_tier.ack_notice')
+
+ if (result?.acked !== true) {
+ return false
+ }
+ } catch {
+ // A failed ack means the notice is still owed; the caller keeps its surface up.
+ return false
+ }
+
+ await refreshFreeTierStatus(requestGateway)
+
+ return true
+}
+
+/**
+ * Whether the SELECTED route runs on the free tier: `setup.runtime_check.free_tier`, keyed on the
+ * endpoint the backend resolved, not on profile state. `null` until a readiness round answers. A
+ * free-tier identity beside the user's own key reads `false` here while `$freeTierStatus.available`
+ * stays true — that split is what picks the intro's shape.
+ */
+export const $freeTierRoute = atom(null)
+
+export function setFreeTierRoute(route: boolean | null | undefined) {
+ $freeTierRoute.set(typeof route === 'boolean' ? route : null)
+}
+
+/** True when the one-time introduction is still owed to this user. */
+export function freeTierNoticePending(status: FreeTierStatus | null): boolean {
+ return Boolean(status?.has_guest && status.notice_pending)
+}
+
+/** The introduction is owed AND the free tier is the route: the overlay's ready screen. */
+export function freeTierReadyPending(status: FreeTierStatus | null, route: boolean | null): boolean {
+ return freeTierNoticePending(status) && route === true
+}
+
+/**
+ * True when the introduction is owed AND a provider of the user's own carries
+ * inference — the case the composer strip covers. When the free tier itself
+ * carries inference the onboarding overlay's ready screen owns the moment
+ * instead, so the two can never both be on screen.
+ */
+export function freeTierStripPending(status: FreeTierStatus | null, route: boolean | null): boolean {
+ return freeTierNoticePending(status) && route === false
+}
+
+// Several composers can be mounted at once (split zones, a popout mid-dock).
+// The FIRST mounted strip claims the notice; the rest render nothing, so one
+// pending notice never paints N times. Mirrors the real-profile-consent claim.
+const $noticeClaim = atom(null)
+
+export function claimFreeTierNotice(id: string) {
+ if ($noticeClaim.get() === null) {
+ $noticeClaim.set(id)
+ }
+}
+
+export function releaseFreeTierNotice(id: string) {
+ if ($noticeClaim.get() === id) {
+ $noticeClaim.set(null)
+ }
+}
+
+export function freeTierNoticeClaim() {
+ return $noticeClaim
+}
diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts
index 0f2652e748..26fad91790 100644
--- a/apps/desktop/src/store/onboarding.test.ts
+++ b/apps/desktop/src/store/onboarding.test.ts
@@ -26,6 +26,7 @@ function baseState(overrides: Partial = {}): DesktopOnbo
firstRunSkipped: false,
manual: false,
localEndpoint: false,
+ freeTierReady: false,
...overrides
}
}
diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts
index 5813799336..d6fbce5a46 100644
--- a/apps/desktop/src/store/onboarding.ts
+++ b/apps/desktop/src/store/onboarding.ts
@@ -15,6 +15,7 @@ import { translateNow } from '@/i18n'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { setMainModelAssignment } from '@/store/cron-model-impact'
+import { ackFreeTierNotice, freeTierReadyPending, refreshFreeTierStatus, setFreeTierRoute } from '@/store/free-tier'
import { notify, notifyError } from '@/store/notifications'
import type { ModelOptionProvider, OAuthProvider, OAuthStartResponse } from '@/types/hermes'
@@ -75,6 +76,12 @@ export interface DesktopOnboardingState {
* custom endpoint"). Forces the API-key form with the local option
* preselected instead of the OAuth picker. */
localEndpoint: boolean
+ /** True when the backend still owes this user the one-time free-tier
+ * introduction AND the free tier is what carries inference. It makes the
+ * overlay show its "Hermes is ready" screen once even though the app is
+ * configured. The backend's `notice_pending` flag is the only source of
+ * truth — there is no renderer latch — so an ack clears it everywhere. */
+ freeTierReady: boolean
}
export interface OnboardingContext {
@@ -155,7 +162,8 @@ const INITIAL: DesktopOnboardingState = {
requested: false,
firstRunSkipped: readCachedSkipped(),
manual: false,
- localEndpoint: false
+ localEndpoint: false,
+ freeTierReady: false
}
export const $desktopOnboarding = atom(INITIAL)
@@ -434,6 +442,14 @@ function providerResolutionFailure(reason: null | string) {
: 'Connected, but Hermes still cannot resolve a usable provider.'
}
+/** Re-read the OAuth provider list into the onboarding cache. Exported so a
+ * flow that changes a provider's auth state outside onboarding (a free-tier
+ * sign-in) can keep the cached rows honest instead of leaving the picker
+ * describing the previous identity. */
+export async function refreshOnboardingProviders() {
+ await refreshProviders()
+}
+
async function refreshProviders() {
if (providersRefreshPromise) {
await providersRefreshPromise
@@ -519,6 +535,8 @@ export function startManualOnboarding(reason: null | string = DEFAULT_MANUAL_ONB
providers: null,
requested: true,
localEndpoint: false,
+ // The picker replaces the free-tier ready screen when the user asked for it.
+ freeTierReady: false,
// `null` opts out of the prompt banner entirely (e.g. when the user already
// picked a specific provider and we auto-start its sign-in).
reason: reason ? reason.trim() || DEFAULT_ONBOARDING_REASON : null,
@@ -581,7 +599,7 @@ export function closeManualOnboarding() {
providersRefreshPromise = null
pendingProviderOAuthId = null
- patch({ targetProfile: undefined, manual: false, requested: false, localEndpoint: false, flow: { status: 'idle' } })
+ patch({ targetProfile: undefined, manual: false, requested: false, localEndpoint: false, freeTierReady: false, flow: { status: 'idle' } })
}
export function completeDesktopOnboarding() {
@@ -599,7 +617,8 @@ export function completeDesktopOnboarding() {
requested: false,
firstRunSkipped: false,
manual: false,
- localEndpoint: false
+ localEndpoint: false,
+ freeTierReady: false
})
}
@@ -612,7 +631,14 @@ export function completeDesktopOnboarding() {
export function dismissFirstRunOnboarding() {
clearPoll()
writeCachedSkipped(true)
- patch({ firstRunSkipped: true, requested: false, manual: false, localEndpoint: false, flow: { status: 'idle' } })
+ patch({
+ firstRunSkipped: true,
+ requested: false,
+ manual: false,
+ localEndpoint: false,
+ freeTierReady: false,
+ flow: { status: 'idle' }
+ })
}
export function setOnboardingMode(mode: OnboardingMode) {
@@ -634,6 +660,7 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
if (runtime.ready) {
completeDesktopOnboarding()
+ await applyFreeTierIntro(ctx, runtime)
ctx.onCompleted?.()
return true
@@ -671,6 +698,40 @@ export async function refreshOnboarding(ctx: OnboardingContext) {
return false
}
+/**
+ * Ask the backend whether the one-time free-tier introduction is still owed,
+ * and if so which shape it takes. Pull-based on purpose: the flag lives on the
+ * identity, so a second window (or a reinstall against the same home) shows the
+ * intro exactly once between them.
+ *
+ * The runtime check's route flag picks the shape. When the free tier is the
+ * route inference runs on, the overlay stays up on a ready screen — this is
+ * their first launch and they have nothing else. When a provider of their own
+ * carries inference, the overlay is not warranted: the composer strip (keyed on
+ * the same notice flag) offers the free models without interrupting.
+ */
+async function applyFreeTierIntro(ctx: OnboardingContext, runtime: RuntimeReadinessResult) {
+ setFreeTierRoute(runtime.freeTier)
+ const status = await refreshFreeTierStatus(ctx.requestGateway)
+
+ if (freeTierReadyPending(status, runtime.freeTier ?? null)) {
+ patch({ freeTierReady: true })
+ }
+}
+
+/** "Begin" / "Sign in instead" / "Other providers" all consume the notice — the
+ * user has seen it. Returns whether the backend recorded it: on a failed write
+ * the ready screen stays up, because the flag it is keyed on is still pending. */
+export async function ackFreeTierIntro(ctx: OnboardingContext): Promise {
+ return ackFreeTierNotice(ctx.requestGateway)
+}
+
+/** Take the ready screen down. Separate from the ack because the overlay plays
+ * its exit BEFORE unmounting — clearing the flag up front would cut the fade. */
+export function clearFreeTierIntro() {
+ patch({ freeTierReady: false })
+}
+
// Open a sign-in URL via the desktop bridge, falling back to window.open
// when the bridge isn't present (e.g. the web dashboard / dev preview) so
// the flow never silently stalls in a waiting state. Mirrors the pattern in
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 4e5c140a57..bcabdaa5df 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -56,8 +56,15 @@ export interface ElevenLabsVoicesResponse {
}
export interface OAuthProviderStatus {
+ /** Nous only: the tier name the token resolves to, when the backend knows
+ * one. Null for a free-tier identity and for older backends. */
+ account_tier?: null | string
error?: string
expires_at?: null | string
+ /** Nous only: true when the stored token belongs to a free-tier identity
+ * rather than a signed-in account. `logged_in` stays true either way — a
+ * token exists — so this is the only way to tell the two apart. */
+ free_tier?: boolean
has_refresh_token?: boolean
last_refresh?: null | string
logged_in: boolean
@@ -107,12 +114,41 @@ export interface OAuthSubmitResponse {
}
export interface OAuthPollResponse {
+ /** Approved sign-ins only: the account the tokens now belong to. Null when
+ * the backend has no address for it. */
+ account_email?: null | string
error_message?: null | string
expires_at?: null | number
+ /** Approved sign-ins only: the default model the backend settled on. Null
+ * when the config already pointed at the user's own model and was left
+ * alone. */
+ model?: null | string
+ /** Why a non-approved terminal status ended that way: `user_declined`
+ * (status `denied`), or `superseded` / `account_retired` /
+ * `account_not_anonymous` / `account_busy` / `timeout` (status `error`).
+ * `error_message` carries the matching user-facing text. */
+ reason?: null | string
session_id: string
status: 'approved' | 'denied' | 'error' | 'expired' | 'pending'
}
+/** Result of the `free_tier.status` RPC. Pull-only: it reads local auth state
+ * and makes no network call, so it is safe to refresh on the ambient status
+ * cadence. */
+export interface FreeTierStatus {
+ /** An identity exists AND the free tier is on: connectors ride on it, and so
+ * does inference when nothing else carries it. Whether inference actually
+ * runs on it is the ROUTE's answer (`setup.runtime_check.free_tier`). */
+ available: boolean
+ enabled: boolean
+ has_guest: boolean
+ /** Display name for the route, e.g. "Nous · free tier". */
+ label: string
+ model: string
+ /** True until the one-time introduction has been acknowledged. */
+ notice_pending: boolean
+}
+
export interface MemoryProviderOAuthStatus {
auth: 'apikey' | 'oauth' | null
connected: boolean
@@ -461,8 +497,13 @@ export interface ModelOptionProvider {
/** Per-model pricing keyed by model id (present when the picker requested
* pricing and the provider supports live pricing). */
pricing?: Record
- /** Nous only: whether the current account is on the free tier. */
+ /** Nous only: whether the current account is on the free plan. Set by
+ * pricing for a signed-in account — NOT the same thing as `free_tier_row`,
+ * which marks the no-account route. */
free_tier?: boolean
+ /** True for the free-tier route's own provider row (no account behind it).
+ * Never match this row by `name` — the label is copy and can change. */
+ free_tier_row?: boolean
/** Nous only: paid models a free-tier user cannot select (shown disabled). */
unavailable_models?: string[]
/** Per-model option support, keyed by model id (present when the picker
diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py
index eefb2f853c..2d4f80047f 100644
--- a/hermes_cli/inventory.py
+++ b/hermes_cli/inventory.py
@@ -596,6 +596,10 @@ def _apply_pricing(rows: list[dict], *, force_fresh_nous_tier: bool = False, cac
models = row.get("models") or []
if not models:
continue
+ if row.get("free_tier_row"):
+ # The free tier's one model has no Portal pricing and no entitlement to read: pricing
+ # it would lock the only row a free-tier install can select.
+ continue
try:
pricing_kwargs = {"cached_only": True} if cached_only else {}
raw_pricing = get_pricing_for_provider(slug, **pricing_kwargs) or {}
diff --git a/hermes_cli/model_switch_providers.py b/hermes_cli/model_switch_providers.py
index 8a307a738d..f8d5ee641d 100644
--- a/hermes_cli/model_switch_providers.py
+++ b/hermes_cli/model_switch_providers.py
@@ -455,6 +455,10 @@ def _free_tier_nous_row(row: dict) -> dict | None:
out["name"] = anon_auth.FREE_TIER_LABEL
out["models"] = [anon_auth.GUEST_MODEL]
out["total_models"] = 1
+ # The explicit flag every consumer keys on (pricing, badges): never the display name. It also
+ # tells the picker this is the free tier's identity, distinct from ``free_tier`` (an account on
+ # the free plan) which pricing sets from the Portal's entitlement read.
+ out["free_tier_row"] = True
return out
diff --git a/hermes_cli/web_routers/oauth.py b/hermes_cli/web_routers/oauth.py
index fd50f4bcb2..1a13e84fd1 100644
--- a/hermes_cli/web_routers/oauth.py
+++ b/hermes_cli/web_routers/oauth.py
@@ -272,10 +272,10 @@ def _status_card(
# refresh. xai: source_label is a human-readable origin (auth-store path /
# credential source), not the internal auth_mode string ("oauth_pkce").
_PROVIDER_STATUS: Dict[str, tuple[str, Callable[[dict], dict]]] = {
- "nous": ("get_nous_auth_status_local", lambda r: _status_card(
+ "nous": ("get_nous_auth_status_local", lambda r: {**_status_card(
r, "nous_portal", r.get("portal_base_url") or "Nous Portal",
_truncate_token(r.get("access_token")), r.get("access_expires_at"), bool(r.get("has_refresh_token")),
- )),
+ ), "free_tier": bool(r.get("free_tier")), "account_tier": r.get("account_tier")}),
"openai-codex": ("get_codex_auth_status", lambda r: _status_card(
r, r.get("source") or "openai_codex", r.get("auth_mode") or "OpenAI Codex",
_truncate_token(r.get("api_key")), None, False, r.get("last_refresh"),
@@ -324,24 +324,50 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
async def _start_nous_device_code(profile: Optional[str]) -> Dict[str, Any]:
+ """Start a Nous sign-in. Over a free-tier identity (``nous.guest`` on) the same start also registers
+ the connector transfer with the account service, so the browser leg is that transfer's consent
+ page and its code, and the poller waits for the transfer before the token grant. Without one it is
+ the plain device-code flow."""
+ from hermes_cli import anon_auth
from hermes_cli.auth import PROVIDER_REGISTRY, _request_device_code
+ from hermes_cli.web_server_profiles import _profile_scope
pconfig = PROVIDER_REGISTRY["nous"]
portal_base_url = (
os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url
).rstrip("/")
- device_data = await _httpx_call(lambda client: _request_device_code(
- client=client, portal_base_url=portal_base_url, client_id=pconfig.client_id, scope=pconfig.scope,
- ))
+ with _profile_scope(_oauth_profile_name(profile)):
+ guest = anon_auth.current_nous_state() if anon_auth.guest_enabled() else None
+ anon_token = str(guest.get("anon_token") or "") if anon_auth.is_guest_state(guest) else ""
+
+ def _start(client):
+ device = _request_device_code(
+ client=client, portal_base_url=portal_base_url, client_id=pconfig.client_id, scope=pconfig.scope)
+ if not anon_token:
+ return device, None
+ intent = anon_auth.register_promotion_intent(
+ client, portal_base_url, anon_token, user_code=str(device["user_code"]),
+ device_code=str(device["device_code"]))
+ return device, intent
+
+ device_data, intent = await _httpx_call(_start)
+ user_code = str(device_data["user_code"])
+ verification_url = str(device_data["verification_uri_complete"])
+ expires_in, interval = int(device_data["expires_in"]), int(device_data["interval"])
+ fields = dict(
+ device_code=str(device_data["device_code"]), portal_base_url=portal_base_url,
+ client_id=pconfig.client_id, scope=pconfig.scope)
+ if intent is not None:
+ claim_url = str(intent.get("claim_url") or "")
+ if claim_url.startswith("/"):
+ claim_url = f"{portal_base_url}{claim_url}"
+ user_code = str(intent["claim_code"])
+ verification_url = claim_url or verification_url
+ expires_in = min(expires_in, int(intent.get("expires_in") or expires_in))
+ interval = int(intent.get("interval") or interval)
+ fields["claim_code"] = user_code
+ fields.update(interval=interval, expires_at=time.time() + expires_in)
return _device_session_started(
- "nous", profile, _nous_poller,
- dict(
- device_code=str(device_data["device_code"]), interval=int(device_data["interval"]),
- expires_at=time.time() + int(device_data["expires_in"]), portal_base_url=portal_base_url,
- client_id=pconfig.client_id, scope=pconfig.scope,
- ),
- str(device_data["user_code"]), str(device_data["verification_uri_complete"]),
- int(device_data["expires_in"]), int(device_data["interval"]),
- )
+ "nous", profile, _nous_poller, fields, user_code, verification_url, expires_in, interval)
async def _start_codex_device_code(profile: Optional[str]) -> Dict[str, Any]:
@@ -640,6 +666,9 @@ async def poll_oauth_session(provider_id: str, session_id: str, profile: Optiona
return {
"session_id": session_id, "status": sess["status"],
"error_message": sess.get("error_message"), "expires_at": sess.get("expires_at"),
+ # Nous over a free-tier identity: why a transfer ended, who signed in, and the default model
+ # the completion settled on (None when the config was on the user's own model).
+ "reason": sess.get("reason"), "account_email": sess.get("account_email"), "model": sess.get("model"),
}
diff --git a/hermes_cli/web_routers/status.py b/hermes_cli/web_routers/status.py
index 3d565dc5b4..7b5b9bd26d 100644
--- a/hermes_cli/web_routers/status.py
+++ b/hermes_cli/web_routers/status.py
@@ -655,6 +655,9 @@ def _get_portal_status_sync():
"logged_in": bool(auth.get("logged_in")), "portal_url": auth.get("portal_base_url"),
"inference_url": auth.get("inference_base_url"),
"provider": str((model_cfg or {}).get("provider") or ""),
+ # Free tier: a token exists, so logged_in stays true for callers that only ask "is there a
+ # credential"; surfaces that render an account must branch on free_tier first.
+ "free_tier": bool(auth.get("free_tier")), "account_tier": auth.get("account_tier"),
"subscription_url": "https://portal.nousresearch.com/manage-subscription",
"features": features}
diff --git a/hermes_cli/web_server_oauth.py b/hermes_cli/web_server_oauth.py
index 077f3c1da9..947f9c6c99 100644
--- a/hermes_cli/web_server_oauth.py
+++ b/hermes_cli/web_server_oauth.py
@@ -198,8 +198,15 @@ def _oauth_poller(label: str):
try:
fn(session_id, sess)
with _oauth_sessions_lock:
- sess["status"] = "approved"
- _log.info("oauth/device: %s login completed (session=%s)", label, session_id)
+ # A body that already settled the session (a sign-in the user declined in the
+ # browser is ``denied`` with a ``reason``) keeps its verdict.
+ settled = sess["status"] != "pending"
+ if not settled:
+ sess["status"] = "approved"
+ if settled:
+ _log.info("oauth/device: %s login ended %s (session=%s)", label, sess["status"], session_id)
+ else:
+ _log.info("oauth/device: %s login completed (session=%s)", label, session_id)
except Exception as e:
_log.warning("%s device-code poll failed (session=%s): %s", label, session_id, e)
with _oauth_sessions_lock:
@@ -209,19 +216,71 @@ def _oauth_poller(label: str):
return deco
+def _settle_promotion_failure(sess: Dict[str, Any], outcome: Dict[str, Any]) -> None:
+ """Record a transfer that did not complete on the session: ``denied`` when the user rejected it in
+ the browser, else ``error``; ``reason`` and the ruled copy ride along for the renderer."""
+ from hermes_cli import anon_auth
+ status = str(outcome.get("status") or "unknown")
+ reason = "timeout" if status == "timeout" else str(outcome.get("reason") or status)
+ message = (anon_auth.UPGRADE_TIMED_OUT if reason == "timeout"
+ else anon_auth.UPGRADE_REASON_COPY.get(reason, anon_auth.UPGRADE_NOT_COMPLETED))
+ with _oauth_sessions_lock:
+ sess["status"] = "denied" if reason == "user_declined" else "error"
+ sess["reason"] = reason
+ sess["error_message"] = message
+ if reason in anon_auth._RETIRED_REASONS:
+ anon_auth.clear_dead_guest("retired")
+
+
@_oauth_poller("nous")
def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None:
- """Background poller that drives a Nous device-code flow to completion."""
+ """Background poller that drives a Nous device-code flow to completion.
+
+ A session started over a free-tier identity carries ``claim_code``: the transfer of that identity's
+ connectors into the account is watched first (``wait_for_promotion``), and only a completed
+ transfer is followed by the token grant, so an install never loses its connectors to a sign-in the
+ user did not confirm. Every completion then runs ``settle_after_upgrade`` so a config still on the
+ free tier's route moves to the account's host and model; ``account_email`` and ``model`` land on
+ the session for the poll response.
+ """
from hermes_cli.web_server_profiles import _profile_scope
from hermes_cli.auth import _poll_for_token, persist_nous_credentials, refresh_nous_oauth_from_state
+ from hermes_cli import anon_auth
import httpx
portal_base_url, client_id = sess["portal_base_url"], sess["client_id"]
+ claim_code = str(sess.get("claim_code") or "")
+ outcome: Dict[str, Any] = {}
+
+ def _cancelled() -> bool:
+ # The user abandoned this sign-in (DELETE /sessions/{id}) while this thread was blocked
+ # on the portal: nothing it learns afterwards may reach the auth store.
+ with _oauth_sessions_lock:
+ if sess.get("cancelled"):
+ sess["status"] = "cancelled"
+ return True
+ return False
+
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
+ expires_in = max(60, int(sess["expires_at"] - time.time()))
+ if claim_code:
+ try:
+ outcome = anon_auth.wait_for_promotion(
+ client, portal_base_url, claim_code, expires_in=expires_in, interval=int(sess["interval"]))
+ except anon_auth.AnonCredentialDead:
+ outcome = {"status": "voided", "reason": "account_retired"}
+ if _cancelled():
+ return
+ if str(outcome.get("status")) != "completed":
+ with _profile_scope(_oauth_session_profile(session_id)):
+ _settle_promotion_failure(sess, outcome)
+ return
token_data = _poll_for_token(
client=client, portal_base_url=portal_base_url, client_id=client_id,
- device_code=sess["device_code"], expires_in=max(60, int(sess["expires_at"] - time.time())),
+ device_code=sess["device_code"], expires_in=expires_in,
poll_interval=sess["interval"],
)
+ if _cancelled():
+ return
# Same post-processing as _nous_device_code_login (validate/refresh JWT)
now = datetime.now(timezone.utc)
token_ttl = int(token_data.get("expires_in") or 0)
@@ -242,7 +301,19 @@ def _nous_poller(session_id: str, sess: Dict[str, Any]) -> None:
}
with _profile_scope(_oauth_session_profile(session_id)):
full_state = refresh_nous_oauth_from_state(auth_state, timeout_seconds=15.0, force_refresh=False)
- persist_nous_credentials(full_state)
+ if claim_code:
+ full_state["auth_method"] = anon_auth.UPGRADED_AUTH_METHOD
+ # The final cancellation check and the save share the session lock, so a cancel cannot
+ # land between them; the settle step (which may contact the portal) runs after the lock.
+ with _oauth_sessions_lock:
+ if sess.get("cancelled"):
+ sess["status"] = "cancelled"
+ return
+ persist_nous_credentials(full_state)
+ settled = anon_auth.settle_after_upgrade(full_state)
+ with _oauth_sessions_lock:
+ sess["account_email"] = str(outcome.get("account_email") or "") or None
+ sess["model"] = settled.get("model") or None
@_oauth_poller("minimax")
diff --git a/tests/hermes_cli/test_anon_desktop_signin.py b/tests/hermes_cli/test_anon_desktop_signin.py
new file mode 100644
index 0000000000..8364b130eb
--- /dev/null
+++ b/tests/hermes_cli/test_anon_desktop_signin.py
@@ -0,0 +1,108 @@
+"""Desktop (dashboard API) sign-in over a Nous free-tier identity.
+
+``POST /api/providers/oauth/nous/start`` registers the connector transfer with the account service
+and hands the renderer the transfer's code and consent URL; the poller waits for the transfer, then
+takes the token grant, persists the account, and settles the default model. Driven through the real
+FastAPI routes against a fake account service (the same fake ``hermes auth upgrade`` is tested with).
+"""
+
+from __future__ import annotations
+
+import time
+
+import httpx
+import pytest
+from fastapi.testclient import TestClient
+
+from hermes_cli import anon_auth
+from hermes_cli.auth import _load_auth_store
+from hermes_cli.web_server import _SESSION_TOKEN, app
+from tests.hermes_cli.test_anon_upgrade import (
+ EMAIL, FREE_PICK, INFERENCE, PORTAL, WELCOME, _model_config, _write_model_config, free_account, portal)
+
+client = TestClient(app)
+HEADERS = {"X-Hermes-Session-Token": _SESSION_TOKEN}
+
+__all__ = ["free_account", "portal"] # fixtures imported from the CLI test module
+
+
+def _wait_for_terminal(session_id: str, timeout: float = 10.0) -> dict:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ body = client.get(f"/api/providers/oauth/nous/poll/{session_id}", headers=HEADERS).json()
+ if body["status"] != "pending":
+ return body
+ time.sleep(0.05)
+ pytest.fail("poller never left pending")
+
+
+def test_start_registers_the_transfer_and_completion_settles_the_account(portal, free_account):
+ anon_auth.ensure_portal_identity(blocking=True)
+ _write_model_config({"provider": "nous", "default": anon_auth.GUEST_MODEL, "base_url": WELCOME})
+
+ resp = client.post("/api/providers/oauth/nous/start", headers=HEADERS)
+ assert resp.status_code == 200, resp.text
+ start = resp.json()
+ # The renderer shows the transfer's code and consent page, not the generic device page.
+ assert start["user_code"] == "clm_1"
+ assert start["verification_url"] == f"{PORTAL}/device"
+ assert portal.intent_bodies[0]["user_code"] == portal.user_code
+ assert portal.intent_bodies[0]["device_code"] == portal.device_code
+
+ body = _wait_for_terminal(start["session_id"])
+ assert body["status"] == "approved"
+ assert body["account_email"] == EMAIL
+ assert body["model"] == FREE_PICK
+ state = _load_auth_store()["providers"]["nous"]
+ assert "anon_token" not in state and not anon_auth.is_guest_state(state)
+ model_cfg = _model_config()
+ assert model_cfg["default"] == FREE_PICK
+ assert model_cfg["base_url"] == INFERENCE.rstrip("/")
+ assert not anon_auth.route_is_welcome_host(model_cfg["base_url"])
+
+
+def test_a_transfer_the_user_declined_is_reported_with_its_reason_and_keeps_the_free_tier(portal, free_account):
+ guest = anon_auth.ensure_portal_identity(blocking=True)
+ portal.status_sequence = [{"status": "voided", "reason": "user_declined"}]
+
+ start = client.post("/api/providers/oauth/nous/start", headers=HEADERS).json()
+ body = _wait_for_terminal(start["session_id"])
+ assert body["status"] == "denied"
+ assert body["reason"] == "user_declined"
+ assert body["error_message"] == anon_auth.UPGRADE_REASON_COPY["user_declined"]
+ assert portal.token_grants == 0
+ assert _load_auth_store()["providers"]["nous"]["anon_token"] == guest["anon_token"]
+
+
+def test_status_routes_report_the_free_tier(portal):
+ anon_auth.ensure_portal_identity(blocking=True)
+ portal_status = client.get("/api/portal", headers=HEADERS).json()
+ assert portal_status["free_tier"] is True
+ assert portal_status["account_tier"] == "anonymous"
+ providers = client.get("/api/providers/oauth", headers=HEADERS).json()["providers"]
+ nous = next(p for p in providers if p["id"] == "nous")
+ assert nous["status"]["free_tier"] is True
+
+
+def test_a_sign_in_cancelled_while_waiting_never_persists_the_account(portal, free_account, monkeypatch):
+ """The poller is blocked on the transfer when the user cancels; when the wait returns completed,
+ nothing may reach the auth store."""
+ import threading
+ from hermes_cli import web_server_oauth
+ guest = anon_auth.ensure_portal_identity(blocking=True)
+ release = threading.Event()
+
+ def _wait_until_released(client, portal_base_url, claim_code, *, expires_in, interval):
+ release.wait(10)
+ return {"status": "completed", "user_id": "nas_user:9", "account_email": EMAIL}
+ monkeypatch.setattr(anon_auth, "wait_for_promotion", _wait_until_released)
+
+ start = client.post("/api/providers/oauth/nous/start", headers=HEADERS).json()
+ assert client.delete(f"/api/providers/oauth/sessions/{start['session_id']}", headers=HEADERS).json()["ok"] is True
+ release.set()
+ for _ in range(100):
+ time.sleep(0.05)
+ assert portal.token_grants == 0
+ state = _load_auth_store()["providers"]["nous"]
+ assert state["anon_token"] == guest["anon_token"] and anon_auth.is_guest_state(state)
+ assert web_server_oauth._oauth_sessions.get(start["session_id"]) is None
diff --git a/tests/hermes_cli/test_anon_picker.py b/tests/hermes_cli/test_anon_picker.py
index c902be5510..ee7f0803f8 100644
--- a/tests/hermes_cli/test_anon_picker.py
+++ b/tests/hermes_cli/test_anon_picker.py
@@ -89,3 +89,18 @@ def test_guest_identity_with_guest_off_hides_the_nous_row(guest_home, monkeypatc
from hermes_cli.model_switch_providers import list_picker_providers
assert _nous_rows(list_picker_providers("nous", "", None, None, 50, "nous/welcome")) == []
assert _cli_nous_rows({"nous": {"guest": False}}) == []
+
+
+def test_desktop_picker_payload_never_locks_the_free_tier_row(guest_home, monkeypatch):
+ """``model.options`` (the desktop's path) prices rows from caches on a normal open; the free-tier
+ row has no Portal pricing and no entitlement to read, so it must be left alone rather than locked."""
+ from hermes_cli import inventory
+ monkeypatch.setattr(inventory, "_prewarm_pricing_async", lambda *a, **k: None)
+ payload = inventory.build_model_options_payload(inventory.load_picker_context())
+ rows = _nous_rows(payload["providers"])
+ assert len(rows) == 1
+ row = rows[0]
+ assert row["free_tier_row"] is True
+ assert row["models"] == ["nous/welcome"]
+ assert row.get("unavailable_models", []) == []
+ assert not row.get("free_tier_pending") and not row.get("pricing_pending")
diff --git a/tests/tui_gateway/test_free_tier_rpc.py b/tests/tui_gateway/test_free_tier_rpc.py
new file mode 100644
index 0000000000..116e900268
--- /dev/null
+++ b/tests/tui_gateway/test_free_tier_rpc.py
@@ -0,0 +1,85 @@
+"""``free_tier.status`` / ``free_tier.ack_notice`` (pull) and the free-tier branch of ``billing.state``,
+driven through the registered RPC handlers against a seeded free-tier identity."""
+
+from __future__ import annotations
+
+import base64
+import json
+import time
+
+import pytest
+
+import tui_gateway.server as srv
+from hermes_cli import anon_auth
+from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
+
+
+def _jwt(**claims) -> str:
+ def seg(obj):
+ return base64.urlsafe_b64encode(json.dumps(obj).encode()).rstrip(b"=").decode()
+ payload = {"sub": "nas_user:1", "client_id": "nas-anonymous", "account_tier": "anonymous",
+ "scope": "inference:invoke tool:invoke", "exp": int(time.time()) + 900, **claims}
+ return f"{seg({'alg': 'RS256'})}.{seg(payload)}.sig"
+
+
+def _call(method: str, params: dict | None = None) -> dict:
+ return srv._methods[method](1, params or {})["result"]
+
+
+@pytest.fixture
+def guest(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store"))
+ monkeypatch.delenv("HERMES_FORCE_GUEST", raising=False)
+ with _auth_store_lock():
+ store = _load_auth_store()
+ store.setdefault("providers", {})["nous"] = {
+ "auth_method": anon_auth.ANON_AUTH_METHOD, "account_tier": "anonymous", "anon_token": "anon_0001",
+ "client_id": "nas-anonymous", "access_token": _jwt(), "expires_at": "2999-01-01T00:00:00+00:00",
+ "inference_base_url": "https://welcome-api.nousresearch.com/v1"}
+ store["active_provider"] = "nous"
+ _save_auth_store(store)
+
+
+def _set_guest_off(monkeypatch):
+ from hermes_cli import config as cfg_mod
+ monkeypatch.setattr(anon_auth, "guest_enabled", lambda: False)
+ return cfg_mod
+
+
+def test_status_is_pull_from_local_state_and_ack_persists_on_the_identity(guest, monkeypatch):
+ status = _call("free_tier.status")
+ assert status == {"has_guest": True, "enabled": True, "available": True, "notice_pending": True,
+ "model": "nous/welcome", "label": anon_auth.FREE_TIER_LABEL}
+
+ assert _call("free_tier.ack_notice") == {"acked": True}
+ assert _call("free_tier.status")["notice_pending"] is False
+ assert _load_auth_store()["providers"]["nous"][anon_auth.GUEST_NOTICE_FLAG] is True
+
+ # nous.guest: false -> the identity exists but carries nothing; no notice either.
+ _set_guest_off(monkeypatch)
+ status = _call("free_tier.status")
+ assert status["has_guest"] is True and status["enabled"] is False
+ assert status["available"] is False and status["notice_pending"] is False
+
+
+def test_billing_state_answers_the_free_tier_locally(guest, monkeypatch):
+ import agent.billing_view as bv
+ monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: pytest.fail("free tier must not call the portal"))
+ res = _call("billing.state")
+ assert res["ok"] is True and res["logged_in"] is False
+ assert res["free_tier"] is True and res["free_tier_model"] == "nous/welcome"
+ assert res["usage"] == {"available": False}
+
+ _set_guest_off(monkeypatch)
+ monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: bv.BillingState(logged_in=False))
+ res = _call("billing.state")
+ assert res["free_tier"] is False and res["free_tier_model"] is None
+
+
+def test_status_without_an_identity_starts_the_background_setup_once(tmp_path, monkeypatch):
+ monkeypatch.setenv("HERMES_SHARED_AUTH_DIR", str(tmp_path / "shared-store"))
+ calls = []
+ monkeypatch.setattr(anon_auth, "ensure_portal_identity", lambda **kw: calls.append(kw) or None)
+ status = _call("free_tier.status")
+ assert status["has_guest"] is False and status["available"] is False
+ assert calls == [{"blocking": False}]
diff --git a/tui_gateway/billing_view.py b/tui_gateway/billing_view.py
index 5759f57ac9..48631bb7b9 100644
--- a/tui_gateway/billing_view.py
+++ b/tui_gateway/billing_view.py
@@ -71,9 +71,12 @@ def _serialize_auto_reload(ar, format_money) -> dict | None:
"reload_to_display": format_money(ar.reload_to_usd), "card": card_out}
-def _serialize_billing_state(state) -> dict:
- """Serialize a BillingState for the wire (Decimals → strings, money-safe)."""
+def _serialize_billing_state(state, *, free_tier: bool = False) -> dict:
+ """Serialize a BillingState for the wire (Decimals → strings, money-safe). ``free_tier`` marks the
+ Nous free tier: no account, no balance, nothing to pay; the renderer branches on it before
+ ``logged_in``."""
from agent.billing_view import format_money
+ from hermes_cli.anon_auth import GUEST_MODEL
card = mc = None
if state.card is not None:
@@ -88,7 +91,9 @@ def _serialize_billing_state(state) -> dict:
"spent_display": format_money(m.spent_this_month_usd),
"is_default_ceiling": m.is_default_ceiling}
return {
- "ok": True, "logged_in": state.logged_in, "org_name": state.org_name,
+ "ok": True, "logged_in": state.logged_in,
+ "free_tier": bool(free_tier), "free_tier_model": GUEST_MODEL if free_tier else None,
+ "org_name": state.org_name,
"org_slug": state.org_slug, "role": state.role, "is_admin": state.is_admin,
"can_change_plan": state.can_change_plan, "can_charge": state.can_charge,
"balance_usd": _wire_str(state.balance_usd),
diff --git a/tui_gateway/methods_config.py b/tui_gateway/methods_config.py
index b60562e1a8..9458478448 100644
--- a/tui_gateway/methods_config.py
+++ b/tui_gateway/methods_config.py
@@ -305,8 +305,13 @@ def _(rid, params: dict) -> dict:
if not (callable(api_key) or api_key_text in {"aws-sdk", "no-key-required"}
or has_usable_secret(api_key_text) or bool(runtime.get("command"))):
return fail(f"No usable credentials found for {provider}.", runtime.get("source"))
+ from hermes_cli.anon_auth import route_is_welcome_host
+ # free_tier is keyed on the SELECTED route (the welcome host serves only nous/welcome), not
+ # on profile state: a paid Nous key beside a free-tier identity must not read as free.
return {"ok": True, "provider": runtime.get("provider"), "model": runtime.get("model"),
- "source": runtime.get("source"), **scoped}
+ "source": runtime.get("source"),
+ "free_tier": provider == "nous" and route_is_welcome_host(runtime.get("base_url")),
+ **scoped}
return _readiness_check(rid, params, probe)
except Exception as e:
return _ok(rid, {"ok": False, "error": str(e)})
diff --git a/tui_gateway/methods_free_tier.py b/tui_gateway/methods_free_tier.py
new file mode 100644
index 0000000000..3cf66efb9c
--- /dev/null
+++ b/tui_gateway/methods_free_tier.py
@@ -0,0 +1,59 @@
+"""Nous free-tier JSON-RPC handlers: a renderer reads the profile's local auth state (pull); nothing
+is pushed. ``free_tier.status`` answers from the auth store with zero network; ``free_tier.ack_notice``
+persists the one-time notice flag on the free-tier identity itself, so it dies with that identity.
+Bodies are rebound onto server.py's globals (method_ctx.bind_module) and reference them bare.
+"""
+
+import logging
+
+from .method_ctx import HandlerRegistry, bind_module
+
+logger = logging.getLogger(__name__)
+_registry = HandlerRegistry()
+method = _registry.method
+_profile_scoped = _registry.profile_scoped
+
+
+@method("free_tier.status")
+@_profile_scoped
+def _(rid, params: dict) -> dict:
+ """``{has_guest, enabled, available, notice_pending, model, label}`` for the focused profile.
+ ``available`` = an identity exists AND ``nous.guest`` is on: the free tier (connectors, and the
+ model when nothing else carries inference) is there for this install. Whether inference actually
+ runs on it is a ROUTE question answered by ``setup.runtime_check.free_tier``, never by this flag.
+ ``notice_pending`` is true until ``free_tier.ack_notice`` ran for this identity."""
+ try:
+ from hermes_cli import anon_auth
+ has_guest = anon_auth.has_guest()
+ enabled = anon_auth.guest_enabled()
+ if enabled and not has_guest:
+ # The CLI sets the free tier up in the background beside an explicit provider at session
+ # setup (cli_agent_setup_mixin); a served backend has no such moment, so this read is the
+ # desktop's. One attempt per process, nothing waits on it: the answer below is the state
+ # as it stands, and a later read sees the identity once it lands.
+ try:
+ anon_auth.ensure_portal_identity(blocking=False)
+ except Exception as exc:
+ logger.debug("free tier background setup skipped: %s", exc)
+ return _ok(rid, {
+ "has_guest": has_guest, "enabled": enabled, "available": has_guest and enabled,
+ "notice_pending": bool(has_guest and enabled and anon_auth.guest_notice_pending()),
+ "model": anon_auth.GUEST_MODEL, "label": anon_auth.FREE_TIER_LABEL})
+ except Exception as e:
+ return _err(rid, 5090, str(e))
+
+
+@method("free_tier.ack_notice")
+@_profile_scoped
+def _(rid, params: dict) -> dict:
+ """Mark the availability notice shown on the free-tier identity. ``acked`` is false when there is
+ no free-tier identity to mark (nothing to show again either)."""
+ try:
+ from hermes_cli import anon_auth
+ return _ok(rid, {"acked": bool(anon_auth.mark_guest_notice_shown())})
+ except Exception as e:
+ return _err(rid, 5091, str(e))
+
+
+def register(server) -> None:
+ bind_module(globals(), server, skip=("_",))
diff --git a/tui_gateway/methods_session.py b/tui_gateway/methods_session.py
index c9d8d6eaa7..a6f38cd4ec 100644
--- a/tui_gateway/methods_session.py
+++ b/tui_gateway/methods_session.py
@@ -1541,8 +1541,21 @@ def _billing_view(name: str, module: str, builder: str, serializer: str, fallbac
return _ok(rid, dict(fallback))
-_billing_view("billing.state", "agent.billing_view", "build_billing_state", "_serialize_billing_state",
- {"ok": True, "logged_in": False, "error": "could not load billing state"})
+@method("billing.state")
+def _(rid, params: dict) -> dict:
+ """Read-only billing view (no scope required); fail-open. The Nous free tier has no account to
+ bill, so its state is answered locally (``free_tier`` set, ``logged_in`` false) without a portal
+ round-trip that could only fail."""
+ try:
+ from agent.billing_view import BillingState, build_billing_state
+ from hermes_cli.anon_auth import guest_carries_inference
+ if guest_carries_inference():
+ return _ok(rid, _serialize_billing_state(BillingState(logged_in=False), free_tier=True))
+ return _ok(rid, _serialize_billing_state(build_billing_state()))
+ except Exception:
+ return _ok(rid, {"ok": True, "logged_in": False, "free_tier": False, "error": "could not load billing state"})
+
+
_billing_view("usage.bars", "agent.billing_usage", "build_usage_model", "_serialize_usage_model", # two-bar $ view
{"ok": True, "available": False})
_billing_view("subscription.state", "agent.subscription_view", "build_subscription_state",
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index 0abc099681..064e2784c6 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -3218,7 +3218,7 @@ from . import ( # noqa: E402
methods_tools as _methods_tools, prompt_turn as _prompt_turn, billing_view as _billing_view,
methods_projects as _methods_projects, methods_session_foreign as _methods_session_foreign,
methods_session_control as _methods_session_control, methods_subagents as _methods_subagents,
- methods_vault as _methods_vault)
+ methods_vault as _methods_vault, methods_free_tier as _methods_free_tier)
for _m in (
_session_transports, _session_reaper, _session_lifecycle, _session_workdir, _compute_host_bridge, _model_switch,
@@ -3228,6 +3228,6 @@ for _m in (
_methods_browser_control, _methods_session, _methods_prompt, _methods_config,
_methods_config_set, _methods_complete, _methods_tools, _methods_profiles, _methods_images,
_methods_bot_relay, _prompt_turn, _billing_view, _methods_projects, _methods_session_foreign,
- _methods_session_control, _methods_subagents, _methods_vault):
+ _methods_session_control, _methods_subagents, _methods_vault, _methods_free_tier):
_m.register(sys.modules[__name__])
del _m
diff --git a/website/docs/user-guide/free-tier.md b/website/docs/user-guide/free-tier.md
index 17f0c71231..a39a5dc19c 100644
--- a/website/docs/user-guide/free-tier.md
+++ b/website/docs/user-guide/free-tier.md
@@ -91,6 +91,29 @@ does not carry your connectors over. Use `hermes auth upgrade` when you have con
to keep.
:::
+## On Hermes Desktop
+
+The desktop app runs on the same free tier as the CLI and shows it in four places:
+
+| Where | What you see |
+|---|---|
+| First launch | A ready screen: "Hermes is ready." with the default model `nous/welcome`, a Free tier badge, and **Begin**. "Sign in with a Nous account instead" and "Other providers" sit under it. The screen shows once. |
+| First launch with your own API key already present | A one-time strip above the composer: "Free Nous inference and connectors are now available." with **Open model picker**, **Sign in** and **Dismiss**. |
+| Status bar | A chip "Nous · free tier · nous/welcome" with a **Sign in** badge while the free tier carries inference. You can hide it from the bar's right-click menu. |
+| Settings › Billing | "You're on the Nous free tier" with one **Sign in** button; the summary reads Plan "Free tier", Model `nous/welcome`, Connectors "Included". There is no balance and nothing to pay, so no payment or usage sections appear. |
+
+Signing in from any of those places opens one dialog. It shows a code and a link; open the link
+(or the browser the app opened), confirm in the portal, and the dialog ends with "Signed in as
+you@example.com. Your connectors are kept." and the default model your account now uses. A
+sign-in you reject in the browser, a code that timed out, or a code replaced by a newer one each
+show their own message and leave you on the free tier. The model picker lists the free tier as one
+row, "Nous · free tier", with the single model `nous/welcome`; there is no sign-in action inside the
+picker.
+
+The desktop reads all of this from the same local state the CLI writes. The ready screen and the
+strip are keyed on the same one-time flag the CLI notice uses, so seeing one on the CLI means you
+will not see it again on the desktop for that free-tier identity, and the other way round.
+
## Turning the free tier off
```bash