diff --git a/docs/glossary.md b/docs/glossary.md index 8c9f54896c..c7fd1398c5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -38,6 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here - **Usage source** — Plugin registration that discovers accounts and fetches plan usage. Code: `UsageSourceRegistration` (`packages/plugin/src/server/usage.ts`). - **Report ID** — Stable daemon-composed identity for one source account: `:`. The source owns the account key; clients treat the whole ID as opaque. Code: `UsageReportEntry.id` (`packages/protocol/src/messages.ts`). - **Usage report** — A source's normalized usage for one account, including status and optional windows, balances, and details. Its entry carries the Report ID, account label, and fetch time. Each report renders as one card in the Usage modal, in host Settings, and in an agent's context window details (a hover card on desktop web, a tooltip on native wide screens, a sheet on compact ones), which show only the account that agent runs under, without pins. The hover card and the sheet have Refresh; the native tooltip takes no presses. Code: `UsageReport` (`packages/plugin/src/server/usage.ts`), `UsageCard` (`packages/app/src/usage/card.tsx`), `AgentUsage` (`packages/app/src/usage/agent-usage.tsx`). +- **Check usage** — Host-wide switch in Usage Settings, on by default. Turning it off stops login discovery and vendor requests on that host for every device. Usage shows an off state with **Turn on**; the sidebar summary and agent usage sections disappear. - **Usage (sidebar item)** — The sidebar footer item showing each pinned window's source icon and percent, hidden while no pinned window has data (the footer's Usage icon remains available); both entry points open the Usage modal over the current screen: a dialog on wide layouts, a bottom sheet on compact ones. Usage has no route of its own. Off until the user turns on **Summary in sidebar** (Usage Settings) or the Usage row in Settings > Sidebar; window rows pin only while it is on, and saved pins come back when it is turned on again. A pin is `{ sourceId, windowId }` and shows once per account of that source. Pins and the used/remaining choice are one per-device preference (`AppSettings.usage`) that every usage surface follows. It reads the active workspace's host, else the first connected host with usage sources. Code: `packages/app/src/usage/sidebar-item.tsx`, `usage/usage-modal.tsx`, `usage/pinned.ts`, `usage/preferences.ts`. - **Model** — A specific LLM offered by a provider. UI: "Model" / "Select model". Code: `AgentModelDefinition` (`packages/protocol/src/messages.ts:187`). - **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`). diff --git a/docs/plugins.md b/docs/plugins.md index fcc7ed7fdb..ffae136cc6 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -550,7 +550,7 @@ string is never inline SVG or a URL. Register a usage source from `index.server.ts` with `server.registerUsageSource()`. Import `UsageSourceRegistration` and normalization helpers from `@getpaseo/plugin/server/usage`. The plugin owns account discovery, harness labels, and credential-store reads; the daemon owns account grouping and the fetch cache. Agent popovers use only the agent's own login. The host-wide Usage modal falls back across an account's logins; any success shows usage only, and all failures show every login's labeled error and remedy. Follow the [usage source discovery contract](../public-docs/plugins/reference.md#usage-sources). The resolved launch environment crosses into the trusted, unsandboxed plugin subprocess for session discovery. Usage queries never run lifecycle hooks. Inputs are validated in the plugin process and remain daemon-side. `icon` uses the same SVG file rules as provider icons. -The daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. +When the host’s **Check usage** switch is off, the daemon rejects usage requests before discovery, without reading logins or calling vendors. Otherwise, the daemon calls discovery for `usage.list_reports`; the client gates this RPC on `server_info.features.usageSources`. The old `provider.usage.list` RPC maps discovered reports for older clients. ## Contribute sidebar items diff --git a/packages/app/e2e/browser/usage-off.spec.ts b/packages/app/e2e/browser/usage-off.spec.ts new file mode 100644 index 0000000000..a0114c8e33 --- /dev/null +++ b/packages/app/e2e/browser/usage-off.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; +import { installUsageReportsFixture } from "../support/helpers/usage-reports"; +import { + claudeAndCodexReports, + openUsageFromIcon, + openUsageOptions, + closeUsageOptions, + closeUsage, + expectNoUsageItem, + expectPinnedUsage, +} from "../support/helpers/usage-sidebar-item"; + +test("Check usage applies to the host and Turn on restores cards", async ({ + page, + e2eWorkerClient, +}) => { + test.setTimeout(180_000); + const previous = (await e2eWorkerClient.getDaemonConfig()).config.usage ?? { enabled: true }; + try { + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + await openUsageOptions(page); + await page.getByRole("switch", { name: "Check usage", exact: true }).click(); + await closeUsageOptions(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Refresh all", exact: true })).toHaveCount(0); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: false }); + await expectNoUsageItem(page); + const before = usage.listRequests().length; + await closeUsage(page); + await page.reload(); + await openUsageFromIcon(page); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + expect(usage.listRequests()).toHaveLength(before); + await page.getByRole("button", { name: "Turn on", exact: true }).click(); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + expect((await e2eWorkerClient.getDaemonConfig()).config.usage).toEqual({ enabled: true }); + // A separate client changes the policy, as another device would. + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: false } }); + await expect(page.getByRole("button", { name: "Turn on", exact: true })).toBeVisible(); + await e2eWorkerClient.patchDaemonConfig({ usage: { enabled: true } }); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + } finally { + await e2eWorkerClient.patchDaemonConfig({ usage: previous }); + } +}); + +test("older hosts keep usage without a Check usage switch", async ({ page }) => { + test.setTimeout(180_000); + await installUsageReportsFixture(page, { + usageSettingSupported: false, + lists: [() => claudeAndCodexReports()], + }); + await page.setViewportSize({ width: 1440, height: 1000 }); + await gotoAppShell(page); + await openUsageFromIcon(page); + await expect(page.getByText("Claude", { exact: true })).toBeVisible(); + await openUsageOptions(page); + await expect(page.getByRole("switch", { name: "Check usage", exact: true })).toHaveCount(0); +}); diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 27fac6691c..9151403904 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -441,3 +441,31 @@ test("released hosts supply source logos through the client conversion", async ( ).toHaveAttribute("d", codexPath); await qaScreenshot(page, "released-host-usage-modal"); }); + +test("hidden usage sends no requests until opened", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectNoUsageItem(page); + await expect(page.getByRole("button", { name: "Usage", exact: true })).toBeVisible({ + timeout: 90_000, + }); + await openSidebarNavSettings(page); + await expectFooterItemSetting(page, "usage", false); + await leaveSettings(page); + expect(usage.listRequests()).toHaveLength(0); + await openUsageFromIcon(page); + await usage.waitForListRequests(1); + expect(usage.listRequests()).toHaveLength(1); +}); + +test("visible usage summary requests reports on load", async ({ page }) => { + test.setTimeout(180_000); + const usage = await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await page.setViewportSize(WIDE); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h", "54% wk", "7% 5h", "12% wk"]); + expect(usage.listRequests()).toHaveLength(1); +}); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index f57beb5783..fe6af133f9 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -28,6 +28,10 @@ interface SeedProjectDescriptor { * prefer those wrappers over reaching for this client directly. */ export interface SeedDaemonClient { + patchDaemonConfig( + config: import("@getpaseo/protocol/messages").MutableDaemonConfigPatch, + ): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>; + getDaemonConfig(): Promise<{ config: import("@getpaseo/protocol/messages").MutableDaemonConfig }>; connect(): Promise; close(): Promise; addProject(cwd: string): Promise<{ diff --git a/packages/app/e2e/support/helpers/usage-reports.ts b/packages/app/e2e/support/helpers/usage-reports.ts index 86b74c6ea3..bb46e52f95 100644 --- a/packages/app/e2e/support/helpers/usage-reports.ts +++ b/packages/app/e2e/support/helpers/usage-reports.ts @@ -25,6 +25,7 @@ interface UsageReportsFixtureOptions { * app connects, so a reload can switch it. */ usageSupported?: boolean | (() => boolean); + usageSettingSupported?: boolean; /** Released hosts expose provider.usage.list with no source icons. */ providerUsageListOnly?: boolean; /** The host daemon's port; defaults to the E2E daemon. */ @@ -63,6 +64,7 @@ function withUsageSupportFeature( message: WebSocketMessage, enabled: boolean, providerUsageListOnly: boolean, + usageSettingSupported: boolean, ): string | null { const envelope = parseJson(message) as { type?: unknown; @@ -86,6 +88,7 @@ function withUsageSupportFeature( ...payload, features: { ...features, + usageSetting: usageSettingSupported ? true : undefined, usageSources: enabled && !providerUsageListOnly, providerUsageList: enabled, }, @@ -229,7 +232,12 @@ export async function installUsageReportsFixture( server.onMessage((message) => { const serverInfo = typeof message === "string" - ? withUsageSupportFeature(message, isUsageSupported(), providerUsageListOnly) + ? withUsageSupportFeature( + message, + isUsageSupported(), + providerUsageListOnly, + options.usageSettingSupported ?? true, + ) : null; ws.send(serverInfo ?? message); }); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index dbc501b5c7..983d142beb 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -64,12 +64,7 @@ import { useCloseAgentListGesture } from "@/mobile-panels/gestures"; import { MobilePanelOverlay } from "@/mobile-panels/presentation"; import { buildSettingsAddHostRoute, buildSettingsRoute } from "@/utils/host-routes"; import { openHostOverview } from "@/navigation/settings-navigation"; -import { - UsageSidebarItem, - UsageSidebarRoot, - useHasUsageSummary, - useOpenSidebarUsage, -} from "@/usage"; +import { UsageSidebarItem, UsageSidebarRoot, useUsageSummary, useOpenSidebarUsage } from "@/usage"; import { SidebarAgentListSkeleton } from "./sidebar-agent-list-skeleton"; import { SidebarCalloutSlot } from "./sidebar-callout-slot"; import { SidebarWorkspaceList } from "./sidebar-workspace-list"; @@ -480,10 +475,10 @@ function SidebarUsageIcon({ label }: { label: string }) { */ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void }) { const { items } = useSidebarNavItems("footer"); - const hasUsageSummary = useHasUsageSummary(); + const usageSummary = useUsageSummary(); const rowsRef = useRef(null); const visibleItems = items.filter( - (item) => item.visible && (item.kind === "plugin" || hasUsageSummary), + (item) => item.visible && (item.kind === "plugin" || usageSummary.length > 0), ); if (visibleItems.length === 0) return null; return ( @@ -499,7 +494,7 @@ function SidebarFooterRows({ onBeforeNavigate }: { onBeforeNavigate?: () => void onBeforeNavigate={onBeforeNavigate} /> ) : ( - + ), )} diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1cb6f0da06..99d5a11701 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2092,6 +2092,11 @@ export const ar: TranslationResources = { }, }, providerUsage: { + checkUsage: "التحقق من الاستخدام", + checkUsageHint: "ينطبق على {{host}} على جميع الأجهزة", + off: "التحقق من الاستخدام متوقف على {{host}}.", + turnOn: "تشغيل", + options: "الإعدادات", refreshFailed: "تعذّر تحديث الاستخدام", noHosts: "لا توجد مضيفات متصلة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 0511af34e2..636fe42d5a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -2117,6 +2117,11 @@ export const en = { }, }, providerUsage: { + checkUsage: "Check usage", + checkUsageHint: "Applies to {{host}} on every device", + off: "Usage is off on {{host}}.", + turnOn: "Turn on", + options: "Settings", refreshFailed: "Unable to refresh usage", noHosts: "No connected hosts", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index ffe9cedc1e..abd5320fe9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2140,6 +2140,11 @@ export const es: TranslationResources = { }, }, providerUsage: { + checkUsage: "Consultar uso", + checkUsageHint: "Se aplica a {{host}} en todos los dispositivos", + off: "El uso está desactivado en {{host}}.", + turnOn: "Activar", + options: "Configuración", refreshFailed: "No se pudo actualizar el uso", noHosts: "No hay hosts conectados", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 6c9df18211..848666b2bb 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2140,6 +2140,11 @@ export const fr: TranslationResources = { }, }, providerUsage: { + checkUsage: "Vérifier l’utilisation", + checkUsageHint: "S’applique à {{host}} sur tous les appareils", + off: "L’utilisation est désactivée sur {{host}}.", + turnOn: "Activer", + options: "Paramètres", refreshFailed: "Impossible d’actualiser l’utilisation", noHosts: "Aucun hôte connecté", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index dd214ebac9..eb6423913e 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2113,6 +2113,11 @@ export const ja: TranslationResources = { }, }, providerUsage: { + checkUsage: "使用状況を確認", + checkUsageHint: "すべてのデバイスで {{host}} に適用", + off: "{{host}} の使用状況確認はオフです。", + turnOn: "オンにする", + options: "設定", refreshFailed: "使用状況を更新できません", noHosts: "接続中のホストがありません", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index d30635b2a5..54605df4a6 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2102,6 +2102,11 @@ export const ko: TranslationResources = { }, }, providerUsage: { + checkUsage: "사용량 확인", + checkUsageHint: "모든 기기에서 {{host}}에 적용", + off: "{{host}}의 사용량 확인이 꺼져 있습니다.", + turnOn: "켜기", + options: "설정", refreshFailed: "사용량을 새로 고칠 수 없습니다", noHosts: "연결된 호스트가 없습니다", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 1d9ce0cb95..ecedcb5c9e 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2125,6 +2125,11 @@ export const ptBR: TranslationResources = { }, }, providerUsage: { + checkUsage: "Consultar uso", + checkUsageHint: "Aplica-se a {{host}} em todos os dispositivos", + off: "O uso está desativado em {{host}}.", + turnOn: "Ativar", + options: "Configurações", refreshFailed: "Não foi possível atualizar o uso", noHosts: "Nenhum host conectado", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 766f8bdae7..d9c7afdedd 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2125,6 +2125,11 @@ export const ru: TranslationResources = { }, }, providerUsage: { + checkUsage: "Проверять использование", + checkUsageHint: "Применяется к {{host}} на всех устройствах", + off: "Проверка использования отключена на {{host}}.", + turnOn: "Включить", + options: "Настройки", refreshFailed: "Не удалось обновить использование", noHosts: "Нет подключённых хостов", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 64867d7d21..e0b5bbcdb4 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2070,6 +2070,11 @@ export const zhCN: TranslationResources = { }, }, providerUsage: { + checkUsage: "检查用量", + checkUsageHint: "在所有设备上应用于 {{host}}", + off: "{{host}} 的用量检查已关闭。", + turnOn: "开启", + options: "设置", refreshFailed: "无法刷新用量", noHosts: "没有已连接的主机", diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index 20297c9df0..f0a8c73f5e 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -21,10 +21,12 @@ export interface UsageHostSelection { */ export function UsageControls({ view, + serverId, onRefresh, hostSelection, }: { view: UsageView; + serverId: string; onRefresh: () => void; hostSelection?: UsageHostSelection; }) { @@ -43,8 +45,8 @@ export function UsageControls({ ) : null} {view.kind === "unavailable" ? null : ( <> - - + {view.kind === "off" ? null : } + )} @@ -84,7 +86,12 @@ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { const controls = useMemo( () => hasControls ? ( - + ) : null, [hasControls, hostSelection, refresh, view], ); diff --git a/packages/app/src/usage/display.ts b/packages/app/src/usage/display.ts index 5b4e534693..c62ed78fd7 100644 --- a/packages/app/src/usage/display.ts +++ b/packages/app/src/usage/display.ts @@ -1,7 +1,5 @@ import { useCallback, useMemo } from "react"; import { useAppSettings } from "@/hooks/use-settings"; -import { useUsageHostId } from "./hosts"; -import { useUsageHostReports } from "./queries"; import { isUsagePinned, setUsageDisplayAs, @@ -25,12 +23,6 @@ interface UsagePreferencesState { display: UsageDisplay; } -/** The device's usage preferences, with pins resolved against a host's reports. */ -export function useUsagePreferences(serverId?: string): UsagePreferencesState { - const defaultServerId = useUsageHostId(); - return useUsageDisplay(useUsageHostReports(serverId ?? defaultServerId)); -} - /** The device's usage preferences, with pins resolved against the given reports. */ export function useUsageDisplay(reports: readonly UsageReportEntry[]): UsagePreferencesState { const { settings, updateSettings } = useAppSettings(); diff --git a/packages/app/src/usage/host-usage-section.tsx b/packages/app/src/usage/host-usage-section.tsx index e80e839680..4921c7f954 100644 --- a/packages/app/src/usage/host-usage-section.tsx +++ b/packages/app/src/usage/host-usage-section.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; import { useHostUsage } from "./queries"; import { UsageSection } from "./usage-section"; @@ -7,7 +7,7 @@ import { UsageSection } from "./usage-section"; export function HostUsageSection({ serverId }: { serverId: string }) { const { t } = useTranslation(); const { view, refresh } = useHostUsage(serverId); - const { display } = useUsagePreferences(serverId); + const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []); return ( { + expect( + resolveUsageView({ + hostLabel: "Laptop", + isConnected: true, + supportsUsage: true, + enabled: false, + query: { data: [], error: null, isFetching: false }, + }), + ).toMatchObject({ kind: "off" }); +}); diff --git a/packages/app/src/usage/model.ts b/packages/app/src/usage/model.ts index 48143ad23f..bbe183dcd0 100644 --- a/packages/app/src/usage/model.ts +++ b/packages/app/src/usage/model.ts @@ -106,6 +106,7 @@ export interface UsageQueryState { export function resolveUsageView( input: { + enabled?: boolean; hostLabel: string; isConnected: boolean; supportsUsage: boolean; @@ -125,6 +126,8 @@ export function resolveUsageView( message: t("providerUsage.hostUpgradeRequired", { host: hostLabel }), }; } + if (input.enabled === false) + return { kind: "off", message: t("providerUsage.off", { host: hostLabel }) }; if (query?.data) { return { kind: "ready", reports: query.data, isRefreshing: query.isFetching }; } diff --git a/packages/app/src/usage/options.tsx b/packages/app/src/usage/options.tsx index b735b612aa..44205fbddd 100644 --- a/packages/app/src/usage/options.tsx +++ b/packages/app/src/usage/options.tsx @@ -8,15 +8,20 @@ import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane- import { useIsCompactFormFactor } from "@/constants/layout"; import { SettingsRow, SettingsSwitch } from "@/components/settings"; import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control"; +import { Alert } from "@/components/ui/alert"; +import { useHosts } from "@/runtime/host-runtime"; import { settingsStyles } from "@/styles/settings"; -import { useUsagePreferences } from "./display"; +import { useUsageSetting } from "./setting"; +import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; import type { UsageDisplayAs } from "./preferences"; const ThemedSettings = withUnistyles(Settings); /** The shared usage preferences, opened from a cog as a popover or compact sheet. */ -export function UsageOptions() { +export function UsageOptions({ serverId }: { serverId: string | null }) { + const setting = useUsageSetting(serverId); + const hostLabel = useHosts().find((host) => host.serverId === serverId)?.label ?? serverId; const { t } = useTranslation(); const displayAsOptions: SegmentedControlOption[] = [ { value: "used", label: t("providerUsage.displayUsed"), testID: "usage-display-used" }, @@ -27,7 +32,7 @@ export function UsageOptions() { }, ]; const compact = useIsCompactFormFactor(); - const { display } = useUsagePreferences(); + const { display } = useUsageDisplay([]); const { inSidebar, setInSidebar } = useUsageInSidebar(); return ( @@ -49,13 +54,25 @@ export function UsageOptions() { testID="usage-options-surface" > - + {setting.error ? : null} + {setting.supported ? ( + + ) : null} + + + void } { + const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); const query = useFetchQuery({ queryKey: usageReportsQueryKey(serverId), queryFn: () => listReports(queryClient, serverId), - enabled: isConnected && isSupported, + enabled: isConnected && isSupported && setting.enabled, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); @@ -150,6 +152,7 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const view = resolveUsageView( { hostLabel, + enabled: setting.ready ? setting.enabled : undefined, isConnected, supportsUsage: isSupported, query: toQueryState(query), @@ -166,14 +169,16 @@ const NO_REPORTS: UsageReportEntry[] = []; * load, or without a host. */ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { + const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const query = useFetchQuery({ + enabled: setting.enabled, queryKey: usageReportsQueryKey(serverId ?? ""), queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, }); - return query.data ?? NO_REPORTS; + return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS; } /** @@ -209,7 +214,8 @@ export function useAgentUsage(serverId: string, agentId: string): AgentUsageView export function useHostReportsUsage(serverId: string): boolean { const isConnected = useHostRuntimeIsConnected(serverId); const isSupported = useSessionStore((state) => supportsUsage(state.sessions[serverId])); - return isConnected && isSupported; + const setting = useUsageSetting(serverId); + return isConnected && isSupported && setting.enabled; } /** Every host with whether it is connected and reports usage, in host order. */ diff --git a/packages/app/src/usage/setting.ts b/packages/app/src/usage/setting.ts new file mode 100644 index 0000000000..e6916cf8c8 --- /dev/null +++ b/packages/app/src/usage/setting.ts @@ -0,0 +1,26 @@ +import { useMutation } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { useDaemonConfig } from "@/hooks/use-daemon-config"; +import { useSessionStore } from "@/stores/session-store"; + +/** Host policy comes from the live daemon config replica. */ +export function useUsageSetting(serverId: string | null) { + const supported = useSessionStore((state) => + Boolean(serverId && state.sessions[serverId]?.serverInfo?.features?.usageSetting), + ); + const { config, patchConfig } = useDaemonConfig(supported ? serverId : null); + const mutation = useMutation({ + mutationFn: (enabled: boolean) => patchConfig({ usage: { enabled } }), + }); + const setEnabled = mutation.mutate; + const turnOn = useCallback(() => setEnabled(true), [setEnabled]); + return { + supported, + error: mutation.error, + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + enabled: !supported || (config !== null && config.usage?.enabled !== false), + ready: !supported || config !== null, + setEnabled, + turnOn, + }; +} diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 2ca99c4e29..437c0a929c 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -17,7 +17,8 @@ import { } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; +import { useUsageInSidebar } from "./in-sidebar"; import { useUsageHostId } from "./hosts"; import { useUsageHostReports } from "./queries"; import { UsageSourceIcon } from "./source-icon"; @@ -33,28 +34,24 @@ import { import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ -function useUsageSummary(): readonly PinnedUsageSource[] { +export function useUsageSummary(): readonly PinnedUsageSource[] { const { t } = useTranslation(); - const { preferences } = useUsagePreferences(); - const reports = useUsageHostReports(useUsageHostId()); + const { inSidebar } = useUsageInSidebar(); + const serverId = useUsageHostId(); + const reports = useUsageHostReports(inSidebar ? serverId : null); + const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } -/** Whether the sidebar Usage item has anything to show. */ -export function useHasUsageSummary(): boolean { - return useUsageSummary().length > 0; -} - /** * The sidebar footer's usage entry: each summary window's source icon and percent, and nothing * while no summary window has data, since the footer's Usage icon already opens Usage. * Pressing it opens the Usage modal. */ -export function UsageSidebarItem() { +export function UsageSidebarItem({ sources }: { sources: readonly PinnedUsageSource[] }) { const { t } = useTranslation(); const label = t(builtinSidebarNavLabelKey("usage")); const openUsage = useOpenSidebarUsage(); - const sources = useUsageSummary(); if (sources.length === 0) return null; return ; } diff --git a/packages/app/src/usage/types.ts b/packages/app/src/usage/types.ts index 58b8e31c98..9e36f58c84 100644 --- a/packages/app/src/usage/types.ts +++ b/packages/app/src/usage/types.ts @@ -10,6 +10,7 @@ export type UsageBalanceUnit = UsageBalance["unit"]; /** What a host's usage surface shows. `unavailable` is a host state, not a failed request. */ export type UsageView = + | { kind: "off"; message: string } | { kind: "unavailable"; message: string } | { kind: "loading" } | { kind: "error"; message: string } diff --git a/packages/app/src/usage/usage-modal.tsx b/packages/app/src/usage/usage-modal.tsx index bf92eaa60f..4e049c0b58 100644 --- a/packages/app/src/usage/usage-modal.tsx +++ b/packages/app/src/usage/usage-modal.tsx @@ -4,7 +4,7 @@ import { View } from "react-native"; import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useHostUsageWithControls } from "./controls"; -import { useUsagePreferences } from "./display"; +import { useUsageDisplay } from "./display"; import { useUsageHostSelection } from "./hosts"; import type { UsageHost } from "./model"; import { UsageBody, UsageMessage } from "./usage-section"; @@ -52,12 +52,12 @@ function HostUsageModal({ hosts: UsageHost[]; onSelectHost: (serverId: string) => void; }) { - const { display } = useUsagePreferences(); const hostSelection = useMemo( () => ({ hosts, serverId, onSelect: onSelectHost }), [hosts, onSelectHost, serverId], ); const { view, refresh, controls } = useHostUsageWithControls(hostSelection); + const { display } = useUsageDisplay(view.kind === "ready" ? view.reports : []); return ( diff --git a/packages/app/src/usage/usage-section.tsx b/packages/app/src/usage/usage-section.tsx index 26699fd447..9c1e9354c1 100644 --- a/packages/app/src/usage/usage-section.tsx +++ b/packages/app/src/usage/usage-section.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { settingsStyles } from "@/styles/settings"; import { SettingsSection } from "@/components/settings/headings/settings-section"; import type { UsageDisplay } from "./display"; +import { useUsageSetting } from "./setting"; import { UsageControls } from "./controls"; import { UsageList } from "./list"; import type { UsageView } from "./types"; @@ -27,8 +28,8 @@ export function UsageSection({ testID?: string; }) { const trailing = useMemo( - () => , - [onRefresh, view], + () => , + [onRefresh, view, serverId], ); return ( @@ -51,6 +52,15 @@ export function UsageBody({ onRefresh: () => void; }) { const { t } = useTranslation(); + const setting = useUsageSetting(serverId); + if (view.kind === "off") + return ( + + + {setting.error ? : null} + + + ); if (view.kind === "unavailable") { return ; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d20c91d773..18c3da9b67 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -229,6 +229,8 @@ export const MutableDaemonConfigSchema = z .optional(), app: z.object({ baseUrl: z.string() }).optional(), catalogRefreshTimeoutMs: z.number().int().positive().optional(), + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + usage: z.object({ enabled: z.boolean() }).optional(), browserTools: MutableBrowserToolsConfigSchema.default({ enabled: false }), providers: z.record(z.string(), MutableDaemonProviderConfigSchema).default({}), metadataGeneration: MutableMetadataGenerationConfigSchema.default({ providers: [] }), @@ -256,6 +258,7 @@ export const MutableDaemonConfigPatchSchema = z .object({ relay: MutableRelayConfigSchema.partial().optional(), mcp: z.object({ injectIntoAgents: z.boolean().optional() }).passthrough().optional(), + usage: z.object({ enabled: z.boolean() }).optional(), browserTools: MutableBrowserToolsConfigSchema.partial().optional(), providers: z .record(z.string(), MutableDaemonProviderConfigSchema.partial().passthrough()) @@ -3673,6 +3676,8 @@ export const ServerInfoStatusPayloadSchema = z providersSnapshot: z.boolean().optional(), connectedHosts: z.boolean().optional(), hostMultiplexing: z.boolean().optional(), + // COMPAT(usageSetting): added in v0.14, remove after 2027-05-01. + usageSetting: z.boolean().optional(), usageSources: z.boolean().optional(), // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. providersSnapshotCwd: z.boolean().optional(), diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 8fe0f05b14..fa896ef898 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -420,6 +420,7 @@ export interface PaseoDaemonConfig { trustedProxies?: true | string[]; mcpEnabled?: boolean; mcpInjectIntoAgents?: boolean; + usageEnabled?: boolean; browserToolsEnabled?: boolean; git?: { maxProcessesPerSecond: number; @@ -590,6 +591,7 @@ function createInitialMutableDaemonConfig(config: ResolvedDaemonSettings): Mutab ...(config.providerCatalogRefreshTimeoutMs !== undefined ? { catalogRefreshTimeoutMs: config.providerCatalogRefreshTimeoutMs } : {}), + usage: { enabled: config.usageEnabled !== false }, browserTools: { enabled: config.browserToolsEnabled ?? false }, providers, metadataGeneration: { diff --git a/packages/server/src/server/config.ts b/packages/server/src/server/config.ts index 3149fc6eb9..07c5c287b8 100644 --- a/packages/server/src/server/config.ts +++ b/packages/server/src/server/config.ts @@ -523,6 +523,10 @@ function resolveAppendSystemPrompt(persisted: ReturnType): boolean { + return persisted.daemon?.usage?.enabled !== false; +} + function resolveBrowserToolsEnabled(persisted: ReturnType): boolean { return persisted.daemon?.browserTools?.enabled ?? false; } @@ -548,6 +552,7 @@ function resolveStaticLoadConfigSettings( mcpEnabled: cli?.mcpEnabled ?? persisted.daemon?.mcp?.enabled ?? true, mcpInjectIntoAgents: cli?.mcpInjectIntoAgents ?? persisted.daemon?.mcp?.injectIntoAgents ?? false, + usageEnabled: resolveUsageEnabled(persisted), browserToolsEnabled: resolveBrowserToolsEnabled(persisted), autoArchiveAfterMerge: persisted.daemon?.autoArchiveAfterMerge ?? false, appendSystemPrompt: resolveAppendSystemPrompt(persisted), @@ -576,6 +581,7 @@ export type ResolvedDaemonSettings = Pick< | "trustedProxies" | "mcpEnabled" | "mcpInjectIntoAgents" + | "usageEnabled" | "browserToolsEnabled" | "git" | "autoArchiveAfterMerge" diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 89c3c7f977..d9b2e94181 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -10,6 +10,10 @@ import { readSpeechSettings } from "./speech/speech-settings.js"; import type { PersistedConfig } from "./persisted-config.js"; import type { MutableDaemonConfig } from "@getpaseo/protocol/messages"; +function usageEnabled(persisted: PersistedConfig): boolean { + return persisted.daemon?.usage?.enabled !== false; +} + function reloadableConfig( persisted: PersistedConfig, options: { relayEnabledFallback?: boolean } = {}, @@ -23,6 +27,7 @@ function reloadableConfig( enabled: relay.enabled ?? options.relayEnabledFallback ?? true, }, mcp: { enabled: true, injectIntoAgents: false }, + usage: { enabled: usageEnabled(persisted) }, browserTools: { enabled: daemon.browserTools?.enabled ?? false }, providers: (agents.providers ?? {}) as MutableDaemonConfig["providers"], metadataGeneration: { providers: agents.metadataGeneration?.providers ?? [] }, @@ -1572,3 +1577,31 @@ describe("DaemonConfigStore speech settings", () => { expect(JSON.stringify(other.get())).not.toContain("secret"); }); }); + +test("usage policy persists across restart and live reload", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "usage-policy-")); + try { + const initial = reloadableConfig({}); + const store = new DaemonConfigStore(paseoHome, initial, undefined, { + reloadSource: { + resolve: (persisted) => ({ + mutable: reloadableConfig(persisted), + overrideControlledPaths: [], + }), + }, + }); + await store.patch({ usage: { enabled: false } }); + expect(loadConfig(paseoHome, { env: {} }).usageEnabled).toBe(false); + const persisted = loadPersistedConfig(paseoHome); + const restarted = new DaemonConfigStore(paseoHome, reloadableConfig(persisted)); + expect(restarted.get().usage).toEqual({ enabled: false }); + writeFileSync( + path.join(paseoHome, "config.json"), + JSON.stringify({ daemon: { usage: { enabled: true } } }), + ); + await store.reload(); + expect(store.get().usage).toEqual({ enabled: true }); + } finally { + rmSync(paseoHome, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 1bd67822a3..e83874235f 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -22,6 +22,7 @@ type ProviderOverride = import("./agent/provider-launch-config.js").ProviderOver interface SupportedMutableConfigPatch { relay?: { enabled?: boolean; requireProtectedSecurity?: boolean }; mcp?: { injectIntoAgents?: boolean }; + usage?: { enabled: boolean }; browserTools?: { enabled?: boolean }; providers?: MutableDaemonConfig["providers"]; removeProviders?: string[]; @@ -182,6 +183,7 @@ const RELOADABLE_PATHS = [ "daemon.relay.requireProtectedSecurity", "daemon.mcp.enabled", "daemon.mcp.injectIntoAgents", + "daemon.usage.enabled", "daemon.browserTools.enabled", "daemon.hostnames", "daemon.cors.allowedOrigins", @@ -209,6 +211,7 @@ const PERSISTED_TO_MUTABLE_PATH = new Map([ ["daemon.relay.requireProtectedSecurity", "relay.requireProtectedSecurity"], ["daemon.mcp.enabled", "mcp.enabled"], ["daemon.mcp.injectIntoAgents", "mcp.injectIntoAgents"], + ["daemon.usage.enabled", "usage.enabled"], ["daemon.browserTools.enabled", "browserTools.enabled"], ["daemon.hostnames", "hostnames"], ["daemon.cors.allowedOrigins", "cors.allowedOrigins"], @@ -281,6 +284,7 @@ function pickRelayPatch( function pickSupportedPatchFields(patch: MutableDaemonConfigPatch): SupportedMutableConfigPatch { return { ...pickRelayPatch(patch.relay), + usage: patch.usage, ...(patch.mcp?.injectIntoAgents !== undefined ? { mcp: { injectIntoAgents: patch.mcp.injectIntoAgents } } : {}), @@ -767,6 +771,7 @@ function mergeMutableDaemonPatch( if (patch.mcp?.injectIntoAgents !== undefined) { next.mcp = { ...next.mcp, injectIntoAgents: patch.mcp.injectIntoAgents }; } + if (patch.usage) next.usage = patch.usage; if (patch.browserTools?.enabled !== undefined) { next.browserTools = { ...next.browserTools, enabled: patch.browserTools.enabled }; } diff --git a/packages/server/src/server/persisted-config.ts b/packages/server/src/server/persisted-config.ts index f7b3982513..3138ec9132 100644 --- a/packages/server/src/server/persisted-config.ts +++ b/packages/server/src/server/persisted-config.ts @@ -259,6 +259,7 @@ export const PersistedConfigSchema = z }) .passthrough() .optional(), + usage: z.object({ enabled: z.boolean().optional() }).optional(), browserTools: z .object({ enabled: z.boolean().optional(), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index cca0b4f3fa..09681de8ba 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1042,6 +1042,7 @@ export class Session { logger: this.sessionLogger, }); this.usageSession = new UsageSession({ + isEnabled: () => daemonConfigStore.get().usage?.enabled !== false, emit: (msg) => this.emit(msg), runtime: pluginRuntime, logger: this.sessionLogger, diff --git a/packages/server/src/server/session/usage/usage-session.test.ts b/packages/server/src/server/session/usage/usage-session.test.ts index 2aa65992b8..3a397094a3 100644 --- a/packages/server/src/server/session/usage/usage-session.test.ts +++ b/packages/server/src/server/session/usage/usage-session.test.ts @@ -15,6 +15,7 @@ test("lists reports from usage sources", async () => { report: { status: "available" as const, windows: [] }, }; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), runtime: { async listUsageReports(options) { @@ -40,6 +41,7 @@ test("lists reports from usage sources", async () => { test("surfaces a legacy usage-list failure as an rpc_error envelope", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), runtime: { async listUsageReports() { @@ -61,6 +63,7 @@ test("surfaces a legacy usage-list failure as an rpc_error envelope", async () = test("request failures terminate with an error response and no updates", async () => { const emitted: SessionOutboundMessage[] = []; const usage = new UsageSession({ + isEnabled: () => true, emit: (message) => emitted.push(message), logger: pino({ level: "silent" }), }); @@ -72,3 +75,36 @@ test("request failures terminate with an error response and no updates", async ( }, ]); }); + +test("off rejects both protocols before runtime discovery and turning on restores reports", async () => { + let enabled = false; + let calls = 0; + const emitted: SessionOutboundMessage[] = []; + const usage = new UsageSession({ + isEnabled: () => enabled, + emit: (message) => emitted.push(message), + logger: pino({ level: "silent" }), + runtime: { + async listUsageReports() { + calls++; + return []; + }, + async listLegacyUsage() { + calls++; + return { fetchedAt: "now", providers: [] }; + }, + }, + }); + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "off" }); + await usage.handleLegacyList({ type: "provider.usage.list.request", requestId: "old" }); + expect(calls).toBe(0); + expect(emitted[0]).toMatchObject({ payload: { error: "Usage is off on this host." } }); + expect(emitted[1]).toMatchObject({ + type: "rpc_error", + payload: { error: "Usage is off on this host." }, + }); + enabled = true; + await usage.handleListReports({ type: "usage.list_reports.request", requestId: "on" }); + expect(calls).toBe(1); + expect(emitted[2]).toMatchObject({ payload: { error: null } }); +}); diff --git a/packages/server/src/server/session/usage/usage-session.ts b/packages/server/src/server/session/usage/usage-session.ts index 500c03a088..cd1e2e8801 100644 --- a/packages/server/src/server/session/usage/usage-session.ts +++ b/packages/server/src/server/session/usage/usage-session.ts @@ -9,6 +9,7 @@ export interface UsageSessionOptions { listUsageReports(options: ListUsageReportsOptions): Promise; listLegacyUsage(): Promise<{ fetchedAt: string; providers: ProviderUsage[] }>; }; + isEnabled: () => boolean; logger: pino.Logger; } @@ -19,6 +20,7 @@ export class UsageSession { msg: Extract, ): Promise { try { + if (!this.options.isEnabled()) throw new Error("Usage is off on this host."); if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); await this.options.runtime.listUsageReports({ forceRefresh: msg.forceRefresh, @@ -49,6 +51,18 @@ export class UsageSession { async handleLegacyList( msg: Extract, ): Promise { + if (!this.options.isEnabled()) { + this.options.emit({ + type: "rpc_error", + payload: { + requestId: msg.requestId, + requestType: msg.type, + error: "Usage is off on this host.", + code: "provider_usage_list_failed", + }, + }); + return; + } try { if (!this.options.runtime) throw new Error("Plugin runtime is unavailable"); const usage = await this.options.runtime.listLegacyUsage(); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b7024a638b..96c81ab49a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1949,6 +1949,7 @@ export class VoiceAssistantWebSocketServer { hostMultiplexing: true, // COMPAT(relayProtectedSecurity): added in v0.13.0, remove gate after 2027-04-21. relayProtectedSecurity: true, + usageSetting: true, usageSources: true, ownedSubscriptions: true, agentRequestReceipts: true, diff --git a/packages/website/public/schemas/paseo.config.v1.json b/packages/website/public/schemas/paseo.config.v1.json index 1b74066bae..bae380d4c8 100644 --- a/packages/website/public/schemas/paseo.config.v1.json +++ b/packages/website/public/schemas/paseo.config.v1.json @@ -70,6 +70,14 @@ }, "additionalProperties": {} }, + "usage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, "browserTools": { "type": "object", "properties": {