diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py
index f2186fb91a56..929bc34d3262 100644
--- a/agent/credits_tracker.py
+++ b/agent/credits_tracker.py
@@ -316,12 +316,21 @@ def evaluate_credits_notices(
active.discard(CREDITS_USAGE_KEY)
if target_band is not None:
# Belt-and-suspenders: a producer could set subscription_limit_micros
- # without subscription_limit_usd. Render "$? cap" rather than "$None cap".
+ # without subscription_limit_usd. Render "$?" rather than "$None".
_cap_usd = state.subscription_limit_usd or "?"
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
+ # Report absolute dollars used, not a bare "N% used": the percentage is
+ # only meaningful against a Nous subscription cap (no cap → never fires),
+ # so dollars are clearer and don't imply a universal %. Used = cap −
+ # remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
+ # change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
+ _lim = state.subscription_limit_micros or 0
+ _used_micros = max(0, min(_lim, _lim - state.subscription_micros))
+ _used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
+ _glyph = "⚠" if _level == "warn" else "•"
to_show.append(
AgentNotice(
- text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap",
+ text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
level=_level,
kind=CREDITS_NOTICE_KIND,
key=CREDITS_USAGE_KEY,
diff --git a/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts
new file mode 100644
index 000000000000..1766489818b8
--- /dev/null
+++ b/apps/desktop/src/app/contrib/dev/credits-notice-demo.ts
@@ -0,0 +1,128 @@
+// Dev-only: drive the credit-notice UX without a backend that can hit real
+// usage bands. Each trigger emits ONE synthetic `notification.show` /
+// `notification.clear` gateway event through the real fan-out
+// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
+// `use-message-stream/gateway-event.ts` — toast render, key-replacement
+// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
+//
+// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
+// of this ships in a production build.
+
+import type { GatewayEvent } from '@hermes/shared'
+
+import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
+import { registry } from '@/contrib/registry'
+import { CreditCard } from '@/lib/icons'
+import { emitLocalGatewayEvent } from '@/store/gateway'
+import { $activeSessionId } from '@/store/session'
+
+interface NoticeStep {
+ key: string
+ level: string
+ kind: 'sticky' | 'ttl'
+ text: string
+ ttl_ms?: number
+}
+
+// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
+// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
+// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
+// previous notice when the key changes, so the demo shows one toast at a time
+// (real usage CAN stack these, but that's noise when you're eyeballing a single
+// transition). The same-key usage steps still demonstrate in-place escalation.
+const STEPS: readonly NoticeStep[] = [
+ { key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
+ { key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
+ { key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
+ { key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
+ { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
+ { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
+]
+
+let cursor = 0
+let lastShownKey: null | string = null
+
+function clearNotice(key: string): void {
+ emitLocalGatewayEvent({
+ payload: { key },
+ session_id: $activeSessionId.get() ?? '',
+ type: 'notification.clear'
+ } as GatewayEvent)
+}
+
+function showNotice(step: NoticeStep): void {
+ emitLocalGatewayEvent({
+ payload: {
+ id: `${step.key}:${Date.now()}`,
+ key: step.key,
+ kind: step.kind,
+ level: step.level,
+ text: step.text,
+ ttl_ms: step.ttl_ms ?? null
+ },
+ session_id: $activeSessionId.get() ?? '',
+ type: 'notification.show'
+ } as GatewayEvent)
+}
+
+/** Fire the next notice in the scripted sequence, wrapping at the end. */
+export function stepCreditsNoticeDemo(): void {
+ const step = STEPS[cursor % STEPS.length]
+
+ // One toast at a time: retire the previous notice when we move to a new key.
+ // (Same-key steps skip this, so the usage line still escalates in place.)
+ if (lastShownKey && lastShownKey !== step.key) {
+ clearNotice(lastShownKey)
+ }
+
+ showNotice(step)
+ lastShownKey = step.key
+ cursor += 1
+}
+
+// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
+// with a system Cmd chord and works the same on Windows/Linux). Matched on
+// `code` so it's keyboard-layout independent, and captured before the composer
+// so a focused input can't swallow it.
+function isTriggerChord(e: KeyboardEvent): boolean {
+ return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
+}
+
+/**
+ * Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a ⌘K palette
+ * entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
+ */
+export function installCreditsNoticeDemo(): () => void {
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (!isTriggerChord(e)) {
+ return
+ }
+
+ e.preventDefault()
+ e.stopPropagation()
+ stepCreditsNoticeDemo()
+ }
+
+ window.addEventListener('keydown', onKeyDown, { capture: true })
+ ;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
+
+ const disposePalette = registry.register({
+ id: 'dev.creditsNotice',
+ area: PALETTE_AREA,
+ data: {
+ id: 'dev.creditsNotice',
+ icon: CreditCard,
+ keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
+ label: 'Dev: cycle credit notices',
+ run: stepCreditsNoticeDemo
+ } satisfies PaletteContribution
+ })
+
+ console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
+
+ return () => {
+ window.removeEventListener('keydown', onKeyDown, { capture: true })
+ disposePalette()
+ delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
+ }
+}
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index b8908150c0aa..978d328e228f 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -272,6 +272,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
}, [navigate])
+ // Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
+ // / window.__creditsDemo). Dynamic import inside the DEV guard so the module
+ // is dropped from production builds.
+ useEffect(() => {
+ if (!import.meta.env.DEV) {
+ return
+ }
+
+ let dispose: (() => void) | undefined
+
+ void import('./dev/credits-notice-demo').then(m => {
+ dispose = m.installCreditsNoticeDemo()
+ })
+
+ return () => dispose?.()
+ }, [])
+
// Post-turn rehydrate from stored history (same behavior as DesktopController,
// including finished-todos restoration).
const hydrateFromStoredSession = useCallback(
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
index a96e780e875a..30ceb1510562 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
@@ -15,6 +15,7 @@ import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
+import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest, normalizeChoices, setClarifyRequest, warnDroppedChoices } from '@/store/clarify'
@@ -865,6 +866,37 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
]
}))
}
+ } else if (event.type === 'notification.show') {
+ // Driver-agnostic agent notice (credits usage/grant/depleted/restored
+ // from `agent/credits_tracker.py`). The Ink TUI renders these in its
+ // status bar; the desktop renders them as toasts. The notice key doubles
+ // as the toast id, so the escalating 50→75→90 credits line replaces in
+ // place instead of stacking. Account-wide signal — shown regardless of
+ // which session is focused.
+ const notice = event.payload as AgentNoticePayload | undefined
+
+ showAgentNotice(notice)
+
+ // The urgent pair (access paused / restored) also breaks through as a
+ // native OS notification when Hermes is backgrounded; dispatch is gated
+ // by the user's notification prefs + backgrounded check.
+ const native = nativeNoticeInput(notice, translateNow('notifications.native.creditsTitle'))
+
+ if (native) {
+ dispatchNativeNotification(native)
+ }
+
+ // A credits crossing moves the account balance. Settings → Billing polls
+ // `billing.state` every 30s; nudge it so the page reflects the crossing
+ // immediately instead of up to 30s late.
+ if (notice?.key?.startsWith('credits.')) {
+ void queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
+ }
+ } else if (event.type === 'notification.clear') {
+ // Key-matched dismissal (e.g. credits restored clears the depleted
+ // notice). notify() keys the toast by the notice key, so this maps
+ // straight to dismissNotification(key).
+ clearAgentNotice((event.payload as AgentNoticePayload | undefined)?.key)
} else if (event.type === 'error') {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
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 7611a055508c..606495d27e75 100644
--- a/apps/desktop/src/app/settings/billing/use-billing-state.ts
+++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts
@@ -11,15 +11,22 @@ export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com'
-// Billing polls on its own while the page is mounted (react-query only ticks an
-// active observer), so the view stays live without a manual refresh control —
-// matching every other data view in the app. It pauses when the window is
-// backgrounded (refetchIntervalInBackground defaults to false).
+// The billing endpoint is the authoritative source of truth for balance / cap /
+// plan — the inference `x-nous-credits-*` headers are best-effort and can drift
+// out of sync (notably in team/org accounts where another member's spend moves
+// the shared balance without ever touching THIS client's headers). So the page
+// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a
+// fresh fetch every time it opens or regains focus, and it keeps polling every
+// 30s while mounted (react-query only ticks an active observer; it pauses when
+// the window is backgrounded — refetchIntervalInBackground defaults to false).
+// A `credits.*` notice crossing additionally invalidates ['billing','state'] to
+// pull the change in immediately rather than waiting for the next poll tick.
const BILLING_QUERY_OPTIONS = {
refetchInterval: 30_000,
+ refetchOnMount: 'always',
refetchOnWindowFocus: true,
retry: false,
- staleTime: 30_000
+ staleTime: 0
} as const
export interface BillingSummaryItemView {
diff --git a/apps/desktop/src/components/notifications.tsx b/apps/desktop/src/components/notifications.tsx
index ec6051843cd5..465e501f54ce 100644
--- a/apps/desktop/src/components/notifications.tsx
+++ b/apps/desktop/src/components/notifications.tsx
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
-import { type ReactNode, useEffect, useRef, useState } from 'react'
+import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
@@ -162,6 +162,30 @@ function BottomRightStack({
)
}
+// Emphasize only the leading money figure ("$16.00" — the amount used) with the
+// accent color (semibold), leaving the rest of the line in its default muted
+// tone. No accent, or no figure in the message → render the text untouched.
+function renderMessage(message: string, accent?: string): ReactNode {
+ const match = accent ? /\$\d+(?:\.\d{2})?/.exec(message) : null
+
+ if (!match) {
+ return message
+ }
+
+ const start = match.index
+ const end = start + match[0].length
+
+ return (
+ <>
+ {message.slice(0, start)}
+
+ {match[0]}
+
+ {message.slice(end)}
+ >
+ )
+}
+
function NotificationItem({ notification }: { notification: AppNotification }) {
const styles = tone[notification.kind]
const Icon = styles.icon
@@ -169,6 +193,12 @@ function NotificationItem({ notification }: { notification: AppNotification }) {
const { t } = useI18n()
const copy = t.notifications
+ // Nudge the icon down to sit on the first text line, in `ch` so it tracks the
+ // toast's font size instead of a fixed rem. `accentColor` (when set) tints the
+ // icon + message as a severity ramp, overriding the kind's default color.
+ const accent = notification.accentColor
+ const iconStyle: CSSProperties = { marginTop: '0.42ch', ...(accent ? { color: accent } : {}) }
+
return (
{notification.icon ? (
-
+
) : (
-
+
)}