diff --git a/client/src/pages/UsagePage.jsx b/client/src/pages/UsagePage.jsx
index 0870235de2..167944b10c 100644
--- a/client/src/pages/UsagePage.jsx
+++ b/client/src/pages/UsagePage.jsx
@@ -1,11 +1,11 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router';
-import { RefreshCw, Clock, AlertTriangle, DatabaseZap } from 'lucide-react';
+import { RefreshCw, Clock, AlertTriangle, DatabaseZap, Network } from 'lucide-react';
import * as api from '../services/api';
import BrailleSpinner from '../components/BrailleSpinner';
import PageSkeleton from '../components/ui/PageSkeleton';
import Pill from '../components/ui/Pill';
-import { formatCompactCount, formatCompactCountOrDash as formatNumber, formatUsd, timeUntil } from '../utils/formatters';
+import { formatCompactCount, formatCompactCountOrDash as formatNumber, formatUsd, timeAgo, timeUntil } from '../utils/formatters';
import { useAsyncAction } from '../hooks/useAsyncAction';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import SubscriptionSavingsCard from '../components/usage/SubscriptionSavingsCard';
@@ -84,6 +84,21 @@ function StatTile({ label, value, detail }) {
);
}
+// A subscription is one account across every federated instance, but each
+// instance can only read its own CLI's panel. When peers have contributed a
+// reading, say so on the card and name them — the meters are the freshest
+// reading across the fleet and the activity counts are summed, which is a
+// different claim than "what this box saw".
+function FleetSourcesPill({ fleet }) {
+ if (!fleet || fleet.count < 2) return null;
+ const describe = (i) => `${i.self ? 'this machine' : (i.name || i.instanceId)}${i.self || !i.fetchedAt ? '' : ` (read ${timeAgo(i.fetchedAt)})`}`;
+ return (
+
+ {fleet.count} instances
+
+ );
+}
+
// One subscription-quota card per enabled provider family. Providers with no
// queryable usage surface (supported: false) render a muted note, never an
// error; a supported adapter that failed transiently shows a soft warning.
@@ -96,6 +111,7 @@ function ProviderQuotaCard({ quota, onRefresh, refreshing, disabled }) {
{quota.plan && quota.plan !== 'unknown' && (
{quota.plan}
)}
+
{/* Per-card refresh: every family's reading is its own multi-second
CLI/TUI scrape, so re-reading one provider must not respawn all
of them. */}
diff --git a/client/src/pages/UsagePage.test.jsx b/client/src/pages/UsagePage.test.jsx
index ce40a1aa6f..120fefba2b 100644
--- a/client/src/pages/UsagePage.test.jsx
+++ b/client/src/pages/UsagePage.test.jsx
@@ -197,6 +197,43 @@ describe('UsagePage per-provider refresh', () => {
});
});
+describe('UsagePage federated quota readings', () => {
+ const fleetCard = {
+ family: 'claude',
+ label: 'Claude Code',
+ supported: true,
+ limits: [{ key: 'week', label: 'Weekly', percentUsed: 65, percentRemaining: 35 }],
+ activity: [],
+ approximate: true,
+ fetchedAt: '2026-09-03T11:00:00.000Z',
+ note: 'Across 2 federated instances (this machine, Example Box) — meters show the freshest reading across them.',
+ fleet: {
+ count: 2,
+ instances: [
+ { instanceId: 'inst-self', name: null, self: true, fetchedAt: '2026-09-03T10:00:00.000Z' },
+ { instanceId: 'inst-peer', name: 'Example Box', self: false, fetchedAt: '2026-09-03T11:00:00.000Z' },
+ ],
+ },
+ };
+
+ it('says the card spans instances and names which ones', async () => {
+ api.getProviderUsage.mockResolvedValue({ providers: [fleetCard] });
+ render();
+
+ const pill = await screen.findByText('2 instances');
+ expect(pill.closest('[title]').getAttribute('title')).toContain('Example Box');
+ expect(screen.getByText(fleetCard.note)).toBeInTheDocument();
+ });
+
+ it('shows no fleet pill on a single-machine install', async () => {
+ api.getProviderUsage.mockResolvedValue({ providers: [{ ...fleetCard, fleet: undefined, note: 'This machine only — other federated instances have not reported a reading yet.' }] });
+ render();
+
+ await screen.findByText('Claude Code');
+ expect(screen.queryByText(/^\d+ instances$/)).not.toBeInTheDocument();
+ });
+});
+
describe('arrangeQuotaCells', () => {
const q = (family) => ({ family, label: family });
diff --git a/docs/decisions/2026-09-01-federated-usage-metrics.md b/docs/decisions/2026-09-01-federated-usage-metrics.md
index e354ff9239..b22e5b8b66 100644
--- a/docs/decisions/2026-09-01-federated-usage-metrics.md
+++ b/docs/decisions/2026-09-01-federated-usage-metrics.md
@@ -172,3 +172,54 @@ excluded on both privacy and payload grounds.
far smaller depth budget than native `JSON.stringify` — so a digest deep
enough to pass `atomicWrite` but blow that recursion would otherwise 500 the
snapshot endpoint for every peer, permanently and across restarts.
+
+## Amendment (2026-09-03): subscription-quota readings ride the same category
+
+A subscription is **one account across every federated instance**, but each
+install can only read the quota panel of its own local CLI. So every
+subscription card on the Usage page was a partial view of a shared allowance,
+captioned with the CLI's own wording — "Local sessions only — does not include
+other devices or claude.ai." That caption was accurate and useless: the
+federation already carries this user's other devices.
+
+**Each instance's last quota reading now rides the `usage` category alongside
+its usage digest**, and the cards are unified before they render
+([`server/lib/fleetQuotas.js`](../../server/lib/fleetQuotas.js)).
+
+- **Two merge rules, because the halves mean different things.** `limits` (the
+ meters) are account-wide — every machine reads the same server-side allowance,
+ just at a different moment — so the FRESHEST reading per limit key wins;
+ summing them would multiply one allowance by the number of machines that
+ looked at it. `activity` (requests/sessions) is per-machine, which is exactly
+ what the provider's caption is about, so those SUM. `metrics[]` is left local:
+ its values are prose (`"3 renders · 24h"`), not addends.
+- **A card this machine could not read is filled from a peer that could** — a
+ logged-out CLI or a scrape still in flight stops reporting a failure once
+ another instance has read the same account.
+- **Only families this install has enabled get a card.** A peer running a
+ provider we don't is that machine's business; a meter for a plan the viewer
+ can't spend would be noise.
+- **API-billed instances are excluded.** The existing per-row Subscriptions
+ toggle already marks fleet members that pay API rates rather than riding the
+ viewer's plans; those meter a different account, so folding their readings in
+ would be a wrong number rather than a fuller one.
+- **A single-machine install is unchanged, caption included.** With nothing to
+ combine, claiming otherwise would be worse than the wording this replaces —
+ so the local note says only that no other instance has reported yet.
+
+Mechanically, the readings live in an in-memory stale-while-revalidate cache
+(a reading costs a 10-20s CLI/TUI spawn), which cannot be federated: it dies
+with the process, and this category's checksum is invalidated by FILE
+fingerprints. So `services/providerQuotaShare.js` persists them to
+`data/provider-quotas.json` — added to `USAGE_CHECKSUM_PATHS`, and folded into
+the entry's `capturedAt` so a quota refresh with no new AI runs still advances
+the slot a peer pulls. The write is skipped when a card's *claim* is unchanged
+(comparing whole cards would rewrite the file on every page poll, since some
+adapters stamp the clock on read).
+
+Nothing here reads a provider: the AI Provider Usage Policy still holds, because
+this only records and forwards what a user-triggered reading already produced.
+Privacy is unchanged in kind — a quota card carries provider ids, percentages,
+reset times and the publishing instance's name; no prompts, no transcripts, no
+PII. The peer payload is rebuilt to the wire shape on arrival for the same
+recursion-depth reason the usage digest is.
diff --git a/server/lib/README.md b/server/lib/README.md
index 9d3b755bf7..0943383913 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -174,6 +174,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `credentialRegistry.js` | Pure catalog of PortOS credentials (`CREDENTIALS`, `CREDENTIAL_IDS`, `CREDENTIAL_TIERS`) — one entry per key/token an install can use (`id`, `label`, `unlocks`, `tier`, `getUrl`, `envVars`, `settingsPath`, `configurePath`, optional `feature`). Sits beside `instanceFeatureRegistry.js` so the two lists stay greppable together. Runtime resolution (settings / repo `.env` / inherited `process.env` / CLI / instance config) lives in `services/credentialInventory.js`. The Settings > Credentials page never receives a value or masked prefix. |
| `instanceFeatureRegistry.js` | The registry of optional per-install features (`INSTANCE_FEATURES`, `INSTANCE_FEATURE_IDS`, `APP_FEATURE_IDS`) — pure data, so `validation.js` derives its feature schemas from it and `navManifest.js` can be checked against it without a service→lib inversion. Runtime resolution (stored override → auto-detection → `defaultEnabled`) lives in `services/instanceFeatures.js`. A feature id tagged on a nav entry hides that page from ⌘K and the sidebar when the feature is off. |
| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)` → family id or null (local-runtime wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. |
+| `fleetQuotas.js` | Unifies subscription-quota readings across federated instances — one plan, several machines, each able to read only its own local CLI. `sanitizeQuotaCards` bounds a peer-supplied payload to the wire shape; `mergeFleetQuotaCards(localCards, peerEntries)` folds every peer's reading into this install's cards, taking the FRESHEST reading per limit key (the meters are account-wide, so summing them would multiply one allowance) and SUMMING activity counts (those are per-machine, which is why the provider captions them "does not include other devices"); `fleetNote` writes the caption naming what was combined. `metrics[]` is left local — its values are prose, not addends. Fed by `services/providerQuotaShare.js` (this machine's readings, persisted) and `services/peerUsage.js` (the `usage` sync category that carries them). |
| `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. |
| `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providers.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. |
| `providerTranscriptUsage.js` | Parsers for the session files the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions///`), `parseAgyTranscript`/`parseAgyHistory` (`~/.gemini/antigravity-cli/`), `claudeProjectSlug`, `totalTranscriptTokens`. Each de-duplicates a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, Codex's `total_token_usage` is cumulative and repeated, grok's `turn_completed.usage` has shipped in both per-prompt and cumulative shapes (detected and delta'd, never summed raw) while its `_meta.totalTokens` is context occupancy and never billed. Antigravity writes no token fields at all, so its parser returns chars for the caller to estimate from. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. |
diff --git a/server/lib/fleetQuotas.js b/server/lib/fleetQuotas.js
new file mode 100644
index 0000000000..4f17babb11
--- /dev/null
+++ b/server/lib/fleetQuotas.js
@@ -0,0 +1,232 @@
+/**
+ * Federated subscription-quota readings — the pure half of "one plan, several
+ * machines".
+ *
+ * A PortOS user commonly runs several federated installs against the SAME
+ * provider subscription, but each install can only read the quota panel of its
+ * own local CLI. That made every card a partial view: this machine's reading,
+ * captioned "local sessions only". The `usage` sync category now carries each
+ * instance's last quota reading alongside its usage digest, and these functions
+ * unify them into one card per family.
+ *
+ * Two different merge rules, because the two halves of a card mean different
+ * things:
+ *
+ * - **limits** (the meters) are ACCOUNT-wide — every machine reads the same
+ * server-side allowance, just at a different moment. So the freshest reading
+ * per limit key wins; summing them would multiply one allowance by the
+ * number of machines that looked at it.
+ * - **activity** (requests/sessions) is LOCAL to the machine that ran the
+ * work, which is exactly why the provider captions it "does not include
+ * other devices". Those sum.
+ *
+ * `metrics[]` is deliberately left alone: its values are prose
+ * (`"3 renders · 24h"`), not addends, so there is nothing to unify honestly.
+ */
+
+import { parseTsMs } from './lwwTimestamp.js';
+
+// Structural bounds on ONE peer-supplied quota payload. Same reasoning as the
+// usage digest's caps in services/peerUsage.js: the wire shape is fixed and
+// shallow, so it is rebuilt field-by-field rather than stored as it arrived.
+export const MAX_FLEET_QUOTA_CARDS = 16;
+const MAX_LIMITS_PER_CARD = 24;
+const MAX_ACTIVITY_PER_CARD = 12;
+const MAX_NOTES_PER_ACTIVITY = 8;
+// How many instance names a note spells out before collapsing the tail.
+const NOTE_NAME_LIMIT = 3;
+
+const isNonEmptyStr = (v) => typeof v === 'string' && v.length > 0;
+const str = (v, max) => (isNonEmptyStr(v) ? v.slice(0, max) : null);
+const int = (v) => (typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : null);
+const pct = (v) => {
+ const n = int(v);
+ return n === null ? null : Math.min(100, Math.max(0, n));
+};
+
+function sanitizeLimit(raw) {
+ const key = str(raw?.key, 120);
+ if (!key) return null;
+ const percentUsed = pct(raw?.percentUsed);
+ return {
+ key,
+ label: str(raw?.label, 200) || key,
+ percentUsed,
+ percentRemaining: percentUsed === null ? null : 100 - percentUsed,
+ resetsAt: str(raw?.resetsAt, 60),
+ timezone: str(raw?.timezone, 60),
+ };
+}
+
+function sanitizeActivity(raw) {
+ const period = str(raw?.period, 60);
+ if (!period) return null;
+ const notes = Array.isArray(raw?.notes)
+ ? raw.notes.slice(0, MAX_NOTES_PER_ACTIVITY).map((n) => str(n, 200)).filter(Boolean)
+ : [];
+ return { period, requests: int(raw?.requests) ?? 0, sessions: int(raw?.sessions) ?? 0, notes };
+}
+
+/**
+ * Rebuild a peer's quota payload to the known wire shape, dropping anything
+ * else. Cards without a family id, and limits without a key, are unmergeable
+ * and are dropped rather than stored.
+ */
+export function sanitizeQuotaCards(raw) {
+ if (!Array.isArray(raw)) return [];
+ const out = [];
+ for (const card of raw.slice(0, MAX_FLEET_QUOTA_CARDS)) {
+ const family = str(card?.family, 60);
+ if (!family) continue;
+ const limits = (Array.isArray(card?.limits) ? card.limits : [])
+ .slice(0, MAX_LIMITS_PER_CARD).map(sanitizeLimit).filter(Boolean);
+ const activity = (Array.isArray(card?.activity) ? card.activity : [])
+ .slice(0, MAX_ACTIVITY_PER_CARD).map(sanitizeActivity).filter(Boolean);
+ // A reading with nothing to contribute is not worth a wire slot.
+ if (!limits.length && !activity.length) continue;
+ out.push({
+ family,
+ label: str(card?.label, 120) || family,
+ plan: str(card?.plan, 60),
+ limits,
+ activity,
+ fetchedAt: str(card?.fetchedAt, 40),
+ });
+ }
+ return out;
+}
+
+/** The most recent `fetchedAt` in a set of cards, or null when none parses. */
+export const latestFetchedAt = (cards) => (Array.isArray(cards) ? cards : []).reduce((latest, card) => {
+ const ms = parseTsMs(card?.fetchedAt);
+ if (ms === null) return latest;
+ return latest === null || ms > parseTsMs(latest) ? card.fetchedAt : latest;
+}, null);
+
+/**
+ * Freshest reading per limit key across contributors, keeping the local card's
+ * ordering first and appending keys only a peer reported (a window this
+ * machine's CLI has not surfaced yet is still real).
+ */
+function unifyLimits(contributions) {
+ const best = new Map();
+ const order = [];
+ for (const c of contributions) {
+ for (const limit of c.limits || []) {
+ const incumbent = best.get(limit.key);
+ if (!incumbent) order.push(limit.key);
+ const incumbentMs = incumbent ? parseTsMs(incumbent.fetchedAt) : null;
+ const candidateMs = parseTsMs(c.fetchedAt);
+ // Unparseable-loses, tie → incumbent: same polarity as every other
+ // cross-instance merge, so the local reading (always first) holds a tie.
+ if (!incumbent || (candidateMs !== null && (incumbentMs === null || candidateMs > incumbentMs))) {
+ best.set(limit.key, { limit, fetchedAt: c.fetchedAt, instanceId: c.instanceId, name: c.name });
+ }
+ }
+ }
+ return order.map((key) => {
+ const { limit, instanceId, name } = best.get(key);
+ return { ...limit, readBy: instanceId, readByName: name };
+ });
+}
+
+/** Sum requests/sessions per period; a period only some instances report still counts. */
+function unifyActivity(contributions) {
+ const byPeriod = new Map();
+ for (const c of contributions) {
+ for (const entry of c.activity || []) {
+ const existing = byPeriod.get(entry.period);
+ if (!existing) {
+ byPeriod.set(entry.period, { ...entry, notes: [...(entry.notes || [])] });
+ continue;
+ }
+ existing.requests += entry.requests || 0;
+ existing.sessions += entry.sessions || 0;
+ }
+ }
+ return [...byPeriod.values()];
+}
+
+const nameList = (contributions) => {
+ const names = contributions.map((c) => (c.self ? 'this machine' : c.name || c.instanceId));
+ if (names.length <= NOTE_NAME_LIMIT) return names.join(', ');
+ const rest = names.length - NOTE_NAME_LIMIT;
+ return `${names.slice(0, NOTE_NAME_LIMIT).join(', ')} +${rest} more`;
+};
+
+/**
+ * The caption that replaces the provider's own "local sessions only" wording
+ * once a card actually spans machines. It names what was unified so the number
+ * on screen is falsifiable — a meter attributed to one instance and a summed
+ * activity count are different claims.
+ */
+export function fleetNote(contributions, { hasActivity }) {
+ const count = contributions.length;
+ const what = hasActivity
+ ? 'meters show the freshest reading, activity is summed'
+ : 'meters show the freshest reading across them';
+ return `Across ${count} federated instances (${nameList(contributions)}) — ${what}.`;
+}
+
+/**
+ * Unify one family's card with the readings peers published for it.
+ *
+ * `peerCards` are the same family's cards from other instances, each carrying
+ * its origin. Fewer than one contributing peer leaves the local card untouched,
+ * caption included — a single-machine install has nothing to combine, and
+ * claiming otherwise would be worse than the wording this replaces.
+ */
+export function mergeQuotaCard(local, peerCards = []) {
+ const localContribution = {
+ instanceId: local.instanceId || null,
+ name: local.name || null,
+ self: true,
+ fetchedAt: local.fetchedAt || null,
+ limits: local.limits || [],
+ activity: local.activity || [],
+ };
+ const contributions = [localContribution, ...peerCards.filter((c) => (c.limits?.length || c.activity?.length))];
+ if (contributions.length < 2) return local;
+
+ const limits = unifyLimits(contributions);
+ const activity = unifyActivity(contributions);
+ const fleet = {
+ instances: contributions.map(({ instanceId, name, self, fetchedAt }) => ({ instanceId, name, self, fetchedAt })),
+ // Which machines are represented, for a UI that wants to age the reading
+ // without re-deriving it from `instances`.
+ count: contributions.length,
+ };
+ return {
+ ...local,
+ limits,
+ activity,
+ fleet,
+ // A local card that could not be read (a logged-out CLI, a scrape still in
+ // flight) is no longer empty once a peer has read the SAME account — so it
+ // stops reporting a failure it no longer has.
+ pending: limits.length ? false : local.pending,
+ error: limits.length ? null : local.error,
+ note: fleetNote(contributions, { hasActivity: activity.length > 0 }),
+ };
+}
+
+/**
+ * Unify every local card with the fleet's readings.
+ *
+ * Only families this install actually has enabled get a card — a peer running a
+ * provider we don't is that machine's business, and inventing a card here would
+ * put a meter on screen for a plan the viewer can't spend.
+ */
+export function mergeFleetQuotaCards(localCards, peerEntries = []) {
+ const cards = Array.isArray(localCards) ? localCards : [];
+ if (!cards.length || !peerEntries.length) return cards;
+ const byFamily = new Map();
+ for (const entry of peerEntries) {
+ for (const card of entry.quotas || []) {
+ const list = byFamily.get(card.family) || [];
+ list.push({ ...card, instanceId: entry.instanceId, name: entry.name, self: false });
+ byFamily.set(card.family, list);
+ }
+ }
+ return cards.map((card) => mergeQuotaCard(card, byFamily.get(card.family) || []));
+}
diff --git a/server/lib/fleetQuotas.test.js b/server/lib/fleetQuotas.test.js
new file mode 100644
index 0000000000..b6089c7c8f
--- /dev/null
+++ b/server/lib/fleetQuotas.test.js
@@ -0,0 +1,135 @@
+import { describe, it, expect } from 'vitest';
+import { sanitizeQuotaCards, mergeFleetQuotaCards, mergeQuotaCard, latestFetchedAt } from './fleetQuotas.js';
+
+const limit = (key, percentUsed, extra = {}) => ({
+ key, label: key, percentUsed, percentRemaining: 100 - percentUsed, resetsAt: null, timezone: null, ...extra,
+});
+
+const localCard = (over = {}) => ({
+ family: 'claude',
+ label: 'Claude Code',
+ supported: true,
+ plan: 'subscription',
+ limits: [limit('session', 20)],
+ activity: [{ period: 'Last 24h', requests: 100, sessions: 4, notes: ['note'] }],
+ approximate: true,
+ fetchedAt: '2026-09-03T10:00:00.000Z',
+ note: 'This machine only — other federated instances have not reported a reading yet.',
+ ...over,
+});
+
+const peerEntry = (over = {}) => ({
+ instanceId: 'peer-1',
+ name: 'Example Box',
+ capturedAt: '2026-09-03T11:00:00.000Z',
+ quotas: [{
+ family: 'claude',
+ label: 'Claude Code',
+ plan: 'subscription',
+ limits: [limit('session', 65)],
+ activity: [{ period: 'Last 24h', requests: 20, sessions: 1, notes: [] }],
+ fetchedAt: '2026-09-03T11:00:00.000Z',
+ }],
+ ...over,
+});
+
+describe('sanitizeQuotaCards', () => {
+ it('rebuilds a peer payload to the wire shape and drops what cannot be merged', () => {
+ const [card] = sanitizeQuotaCards([
+ { family: 'claude', label: 'Claude', limits: [{ key: 'week', percentUsed: 140, label: 'Week' }, { percentUsed: 5 }], activity: [{ requests: 3 }], fetchedAt: '2026-09-03T10:00:00.000Z', raw: 'secret transcript' },
+ { label: 'no family', limits: [limit('week', 1)] },
+ { family: 'empty', limits: [], activity: [] },
+ ]);
+ expect(sanitizeQuotaCards([]).length).toBe(0);
+ // Only the first card survives: no family / nothing to contribute are dropped.
+ expect(card.family).toBe('claude');
+ expect(Object.hasOwn(card, 'raw')).toBe(false);
+ // A keyless limit has nothing to merge on; a percentage is clamped.
+ expect(card.limits).toEqual([{ key: 'week', label: 'Week', percentUsed: 100, percentRemaining: 0, resetsAt: null, timezone: null }]);
+ // An activity entry with no period can't be summed against anything.
+ expect(card.activity).toEqual([]);
+ });
+
+ it('ignores a non-array payload', () => {
+ expect(sanitizeQuotaCards({ family: 'claude' })).toEqual([]);
+ });
+});
+
+describe('latestFetchedAt', () => {
+ it('returns the newest parseable stamp, or null', () => {
+ expect(latestFetchedAt([{ fetchedAt: '2026-09-01T00:00:00.000Z' }, { fetchedAt: '2026-09-02T00:00:00.000Z' }, { fetchedAt: 'nope' }]))
+ .toBe('2026-09-02T00:00:00.000Z');
+ expect(latestFetchedAt([{ fetchedAt: 'nope' }])).toBeNull();
+ expect(latestFetchedAt(null)).toBeNull();
+ });
+});
+
+describe('mergeQuotaCard', () => {
+ it('leaves a single-instance card untouched, caption included', () => {
+ const card = localCard();
+ expect(mergeQuotaCard(card, [])).toBe(card);
+ // A peer with nothing to contribute is not a contributor.
+ expect(mergeQuotaCard(card, [{ instanceId: 'p', name: 'p', limits: [], activity: [] }])).toBe(card);
+ });
+
+ it('takes the freshest meter and sums the activity across instances', () => {
+ const merged = mergeFleetQuotaCards([localCard()], [peerEntry()])[0];
+ // Meters are account-wide: the newest reading wins rather than 20 + 65.
+ expect(merged.limits).toEqual([expect.objectContaining({ key: 'session', percentUsed: 65, readBy: 'peer-1', readByName: 'Example Box' })]);
+ // Activity is per-machine, so it adds up.
+ expect(merged.activity).toEqual([{ period: 'Last 24h', requests: 120, sessions: 5, notes: ['note'] }]);
+ expect(merged.note).toBe('Across 2 federated instances (this machine, Example Box) — meters show the freshest reading, activity is summed.');
+ expect(merged.fleet).toEqual({
+ count: 2,
+ instances: [
+ { instanceId: null, name: null, self: true, fetchedAt: '2026-09-03T10:00:00.000Z' },
+ { instanceId: 'peer-1', name: 'Example Box', self: false, fetchedAt: '2026-09-03T11:00:00.000Z' },
+ ],
+ });
+ });
+
+ it('keeps the local reading on a tie and appends a window only a peer reported', () => {
+ const peer = peerEntry({ quotas: [{ ...peerEntry().quotas[0], limits: [limit('session', 65), limit('week', 80)], fetchedAt: '2026-09-03T10:00:00.000Z' }] });
+ const merged = mergeFleetQuotaCards([localCard()], [peer])[0];
+ expect(merged.limits.map((l) => [l.key, l.percentUsed])).toEqual([['session', 20], ['week', 80]]);
+ });
+
+ it('fills a card this machine could not read from a peer that could', () => {
+ const merged = mergeFleetQuotaCards(
+ [localCard({ limits: [], activity: [], pending: true, error: null, note: 'Reading the Claude Code /usage panel…' })],
+ [peerEntry()],
+ )[0];
+ expect(merged.pending).toBe(false);
+ expect(merged.limits).toEqual([expect.objectContaining({ key: 'session', percentUsed: 65 })]);
+ });
+
+ it('leaves a still-unreadable card reporting its own failure', () => {
+ const merged = mergeFleetQuotaCards(
+ [localCard({ limits: [], activity: [], error: 'No quota data found.' })],
+ [peerEntry({ quotas: [{ family: 'claude', limits: [], activity: [{ period: 'Last 24h', requests: 5, sessions: 1, notes: [] }], fetchedAt: '2026-09-03T11:00:00.000Z' }] })],
+ )[0];
+ expect(merged.error).toBe('No quota data found.');
+ expect(merged.note).toBe('Across 2 federated instances (this machine, Example Box) — meters show the freshest reading, activity is summed.');
+ });
+
+ it('never invents a card for a family this install has not enabled', () => {
+ const cards = mergeFleetQuotaCards([localCard()], [peerEntry({ quotas: [{ ...peerEntry().quotas[0], family: 'grok' }] })]);
+ expect(cards.map((c) => c.family)).toEqual(['claude']);
+ expect(cards[0].fleet).toBeUndefined();
+ });
+
+ it('says only what it combined when no instance reported activity', () => {
+ const noActivity = (card) => ({ ...card, activity: [] });
+ const merged = mergeFleetQuotaCards(
+ [noActivity(localCard())],
+ [peerEntry({ quotas: [noActivity(peerEntry().quotas[0])] })],
+ )[0];
+ expect(merged.note).toBe('Across 2 federated instances (this machine, Example Box) — meters show the freshest reading across them.');
+ });
+
+ it('collapses the name list past three instances', () => {
+ const peers = ['a', 'b', 'c', 'd'].map((id) => peerEntry({ instanceId: id, name: id.toUpperCase() }));
+ expect(mergeFleetQuotaCards([localCard()], peers)[0].note)
+ .toBe('Across 5 federated instances (this machine, A, B +2 more) — meters show the freshest reading, activity is summed.');
+ });
+});
diff --git a/server/lib/index.js b/server/lib/index.js
index b9c6e7320b..1b00d4ae98 100644
--- a/server/lib/index.js
+++ b/server/lib/index.js
@@ -377,6 +377,7 @@ export * from './credentialRegistry.js';
export * from './usageRange.js';
export * from './subscriptionSavings.js';
export * from './providerFamilies.js';
+export * from './fleetQuotas.js';
export * from './harnessOutput.js';
export * from './providerGateways.js';
export * from './personaTraitBlend.js';
diff --git a/server/services/peerUsage.js b/server/services/peerUsage.js
index 6bf06da253..1cc86029fe 100644
--- a/server/services/peerUsage.js
+++ b/server/services/peerUsage.js
@@ -33,7 +33,9 @@ import { compareNewerWins, parseTsMs } from '../lib/lwwTimestamp.js';
import { mergeTombstones, normalizeTombstones, recordTombstone, isTombstoned } from '../lib/tombstones.js';
import { canonicalSnapshotChecksum } from '../lib/snapshotChecksum.js';
import { roundCents } from '../lib/subscriptionSavings.js';
+import { sanitizeQuotaCards } from '../lib/fleetQuotas.js';
import { buildUsageDigest, buildUsageReport, getUsage, USAGE_FILE } from './usage.js';
+import { readLocalQuotaCards, PROVIDER_QUOTAS_FILE } from './providerQuotaShare.js';
const PEER_USAGE_FILE = join(PATHS.data, 'peer-usage.json');
@@ -180,6 +182,9 @@ function sanitizeEntry(entry, expectedId) {
name: isNonEmptyStr(entry.name) ? entry.name.slice(0, 120) : expectedId,
capturedAt: entry.capturedAt,
usage: sanitizeDigest(entry.usage),
+ // Optional: a peer running an older build publishes no quota readings, and
+ // the fleet quota view simply has one fewer contributor.
+ quotas: sanitizeQuotaCards(entry.quotas),
};
}
@@ -195,12 +200,21 @@ function selfDigest(usageData) {
return digestMemo.digest;
}
-/** This instance's own live entry, rebuilt from `usage.json` on every read. */
+/**
+ * This instance's own live entry, rebuilt from `usage.json` (and the last quota
+ * readings) on every read.
+ *
+ * `capturedAt` is the LWW stamp AND the manifest fingerprint, so it has to move
+ * whenever anything in the entry does — a quota refresh that left it pinned to
+ * `usage.lastUpdated` would never be pulled by a peer.
+ */
async function buildSelfEntry() {
const { instanceId, name } = await readSelfIdentity();
if (!instanceId) return null;
const usage = selfDigest(getUsage());
- return { instanceId, name: name || instanceId, capturedAt: usage.lastUpdated, usage };
+ const { quotas, capturedAt: quotasAt } = await readLocalQuotaCards();
+ const capturedAt = compareNewerWins(quotasAt, usage.lastUpdated) ? quotasAt : usage.lastUpdated;
+ return { instanceId, name: name || instanceId, capturedAt, usage, quotas };
}
/**
@@ -443,6 +457,23 @@ export async function getFleetUsage({ from = null, to = null, providers = [], ap
return { instances: rows, totals: sumFleetTotals(included) };
}
+/**
+ * Every OTHER instance's last-known subscription-quota readings, for unifying
+ * them with this machine's cards (`lib/fleetQuotas.js`).
+ *
+ * `excludeInstanceIds` drops instances the viewer marked as paying API rates
+ * rather than riding their subscriptions — the same toggle the Across
+ * Instances card uses. Those machines meter a different account, so folding
+ * their readings into these meters would be a wrong number, not a fuller one.
+ */
+export async function getFleetQuotaEntries({ excludeInstanceIds = [] } = {}) {
+ const { peers } = await entriesWithSelf();
+ const excluded = new Set(Array.isArray(excludeInstanceIds) ? excludeInstanceIds : []);
+ return peers
+ .filter((e) => !excluded.has(e.instanceId) && e.quotas?.length)
+ .map(({ instanceId, name, capturedAt, quotas }) => ({ instanceId, name, capturedAt, quotas }));
+}
+
/**
* Retire an instance's usage digest — called when the user removes that peer.
*
@@ -468,6 +499,6 @@ export async function forgetInstanceUsage(instanceId) {
// Files whose fingerprint invalidates the category's checksum cache. The
// instances file is in the set because the manifest is keyed by this instance's
// ID — a re-identified machine must re-checksum even when no counter moved.
-export const USAGE_CHECKSUM_PATHS = [USAGE_FILE, PEER_USAGE_FILE, dataPath('instances.json')];
+export const USAGE_CHECKSUM_PATHS = [USAGE_FILE, PEER_USAGE_FILE, PROVIDER_QUOTAS_FILE, dataPath('instances.json')];
export { PEER_USAGE_FILE };
diff --git a/server/services/peerUsage.test.js b/server/services/peerUsage.test.js
index ccc4efd7be..0c478ed039 100644
--- a/server/services/peerUsage.test.js
+++ b/server/services/peerUsage.test.js
@@ -31,9 +31,11 @@ const {
getUsageManifest,
applyUsageRemote,
getFleetUsage,
+ getFleetQuotaEntries,
forgetInstanceUsage,
PEER_USAGE_FILE,
} = await import('./peerUsage.js');
+const { recordLocalQuotaCards, readLocalQuotaCards, PROVIDER_QUOTAS_FILE } = await import('./providerQuotaShare.js');
afterAll(cleanup);
@@ -92,9 +94,22 @@ const readStoreFile = async () => JSON.parse(await readFile(PEER_USAGE_FILE, 'ut
beforeEach(async () => {
localUsage = usageFixture();
- // Reset the store between cases — every test starts with no peer digests.
+ // Reset the stores between cases — every test starts with no peer digests
+ // and no quota readings of our own.
const { atomicWrite } = await import('../lib/fileUtils.js');
await atomicWrite(PEER_USAGE_FILE, { instances: {} });
+ await atomicWrite(PROVIDER_QUOTAS_FILE, { quotas: [] });
+});
+
+const quotaCard = (over = {}) => ({
+ family: 'claude',
+ label: 'Claude Code',
+ supported: true,
+ plan: 'subscription',
+ limits: [{ key: 'week', label: 'Current week', percentUsed: 40, percentRemaining: 60, resetsAt: null, timezone: null }],
+ activity: [{ period: 'Last 24h', requests: 10, sessions: 1, notes: [] }],
+ fetchedAt: '2026-08-31T00:00:00.000Z',
+ ...over,
});
describe('federated usage digest', () => {
@@ -431,3 +446,52 @@ describe('fleet report', () => {
expect(outside.totals.tokensOut).toBe(0);
});
});
+
+describe('federated subscription-quota readings', () => {
+ it('publishes this machine\'s readings and advances the LWW stamp past them', async () => {
+ await recordLocalQuotaCards([quotaCard()]);
+ const { data } = await getUsageSnapshot();
+ const self = data.instances['inst-self'];
+ expect(self.quotas).toEqual([expect.objectContaining({ family: 'claude', plan: 'subscription' })]);
+ // A quota refresh with no new AI runs must still move the slot, or no peer
+ // would ever pull it: `capturedAt` is both the LWW stamp and the manifest.
+ expect(self.capturedAt).toBe('2026-08-31T00:00:00.000Z');
+ });
+
+ it('re-records only what changed, so a repeated read is not a new slot to pull', async () => {
+ expect((await recordLocalQuotaCards([quotaCard()])).changed).toBe(true);
+ // Same reading, later clock: the claim is unchanged, so the file is not.
+ expect((await recordLocalQuotaCards([quotaCard({ fetchedAt: '2026-08-31T01:00:00.000Z' })])).changed).toBe(false);
+ expect((await recordLocalQuotaCards([quotaCard({ limits: [] })])).changed).toBe(true);
+ });
+
+ it('merges a narrowed read instead of retiring the families it skipped', async () => {
+ await recordLocalQuotaCards([quotaCard(), quotaCard({ family: 'codex', label: 'Codex' })]);
+ await recordLocalQuotaCards([quotaCard({ activity: [{ period: 'Last 24h', requests: 99, sessions: 3, notes: [] }] })]);
+ const { quotas } = await readLocalQuotaCards();
+ expect(quotas.map((q) => q.family).sort()).toEqual(['claude', 'codex']);
+ expect(quotas.find((q) => q.family === 'claude').activity[0].requests).toBe(99);
+ });
+
+ it('rebuilds a peer\'s readings to the wire shape and excludes API-billed instances', async () => {
+ await applyUsageRemote({
+ instances: {
+ 'inst-peer': peerEntry({ quotas: [quotaCard({ raw: 'transcript', limits: [{ key: 'week', percentUsed: 90 }] })] }),
+ 'inst-billed': peerEntry({ instanceId: 'inst-billed', name: 'Rented Box', quotas: [quotaCard()] }),
+ },
+ });
+ const all = await getFleetQuotaEntries();
+ expect(all.map((e) => e.instanceId).sort()).toEqual(['inst-billed', 'inst-peer']);
+ const peer = all.find((e) => e.instanceId === 'inst-peer');
+ expect(Object.hasOwn(peer.quotas[0], 'raw')).toBe(false);
+ expect(peer.quotas[0].limits[0].percentRemaining).toBe(10);
+
+ const kept = await getFleetQuotaEntries({ excludeInstanceIds: ['inst-billed'] });
+ expect(kept.map((e) => e.instanceId)).toEqual(['inst-peer']);
+ });
+
+ it('leaves out a peer that published no readings', async () => {
+ await applyUsageRemote({ instances: { 'inst-peer': peerEntry() } });
+ expect(await getFleetQuotaEntries()).toEqual([]);
+ });
+});
diff --git a/server/services/providerQuotaShare.js b/server/services/providerQuotaShare.js
new file mode 100644
index 0000000000..19dd4f3c1e
--- /dev/null
+++ b/server/services/providerQuotaShare.js
@@ -0,0 +1,75 @@
+/**
+ * This install's last-known subscription-quota readings, persisted so they can
+ * be federated.
+ *
+ * The quota cards themselves live in an in-memory stale-while-revalidate cache
+ * (see `providerUsage.js`) because reading one costs a 10-20s CLI/TUI spawn.
+ * That cache cannot be shared: it dies with the process, and the `usage` sync
+ * category's checksum is invalidated by FILE fingerprints, so a reading that
+ * exists only in memory would never move the checksum and would never reach a
+ * peer. Writing the cards to `data/provider-quotas.json` fixes both — and
+ * survives a restart, so a card is not blank until the next scrape.
+ *
+ * AI Provider Usage Policy: nothing here reads a provider. It only records what
+ * a user-triggered reading already produced, and serves it back.
+ */
+
+import { join } from 'path';
+import { atomicWrite, readJSONFile, PATHS } from '../lib/fileUtils.js';
+import { isPlainObject } from '../lib/objects.js';
+import { createMutex } from '../lib/asyncMutex.js';
+import { sanitizeQuotaCards, latestFetchedAt } from '../lib/fleetQuotas.js';
+
+export const PROVIDER_QUOTAS_FILE = join(PATHS.data, 'provider-quotas.json');
+
+const withLock = createMutex();
+
+/** The cards this instance last read, newest-known per family. Never throws. */
+export async function readLocalQuotaCards() {
+ const raw = await readJSONFile(PROVIDER_QUOTAS_FILE, null);
+ const quotas = sanitizeQuotaCards(isPlainObject(raw) ? raw.quotas : null);
+ return { quotas, capturedAt: latestFetchedAt(quotas) };
+}
+
+/**
+ * What a stored card claims, ignoring WHEN it was read.
+ *
+ * A card whose only difference is its `fetchedAt` is the same reading: some
+ * adapters stamp the current clock on every call (the image-gen card is derived
+ * on read, not scraped), so comparing whole cards would rewrite this file — and
+ * invalidate the `usage` sync checksum — on every page poll, handing peers a
+ * new slot to pull that says nothing new.
+ */
+const claimOf = ({ fetchedAt, ...rest }) => JSON.stringify(rest);
+
+/**
+ * Merge a batch of freshly-read cards into the store, keyed by family.
+ *
+ * MERGE, not replace: a read narrowed to one family (`?family=`) must not
+ * retire the other families' stored readings, or a per-card Refresh would drop
+ * this machine out of the fleet view for every provider the user didn't click.
+ *
+ * The write is skipped when no card's claim changed. Every write invalidates
+ * the `usage` sync category's checksum, and a page poll that re-serves
+ * identical cached cards is not new information for a peer to pull.
+ */
+export async function recordLocalQuotaCards(cards) {
+ const incoming = sanitizeQuotaCards(cards);
+ if (!incoming.length) return null;
+ return withLock(async () => {
+ const raw = await readJSONFile(PROVIDER_QUOTAS_FILE, null);
+ const stored = sanitizeQuotaCards(isPlainObject(raw) ? raw.quotas : null);
+ const byFamily = new Map(stored.map((card) => [card.family, card]));
+ let changed = false;
+ for (const card of incoming) {
+ const incumbent = byFamily.get(card.family);
+ if (incumbent && claimOf(incumbent) === claimOf(card)) continue;
+ byFamily.set(card.family, card);
+ changed = true;
+ }
+ const quotas = [...byFamily.values()];
+ if (!changed) return { quotas, changed: false };
+ await atomicWrite(PROVIDER_QUOTAS_FILE, { quotas });
+ return { quotas, changed: true };
+ });
+}
diff --git a/server/services/providerUsage.js b/server/services/providerUsage.js
index 0351f7e03d..311ab357a2 100644
--- a/server/services/providerUsage.js
+++ b/server/services/providerUsage.js
@@ -11,6 +11,10 @@ import { parseHumanReset } from '../lib/quotaReset.js';
import { readFileTail } from '../lib/fileUtils.js';
import { getSettings } from './settings.js';
import { getImageGenQuota, IMAGE_GEN_FAMILY } from './imageGenQuota.js';
+import { mergeFleetQuotaCards } from '../lib/fleetQuotas.js';
+import { getFleetQuotaEntries } from './peerUsage.js';
+import { recordLocalQuotaCards } from './providerQuotaShare.js';
+import { getApiBilledInstanceIds } from './usageFleetBilling.js';
import { enabledCloudImageModes } from './imageGen/modes.js';
/**
@@ -583,7 +587,10 @@ async function fetchClaudeQuota({ wait = WAIT.CACHED } = {}) {
limits: data.limits,
activity: data.activity,
approximate: data.approximate,
- note: data.approximate ? 'Local sessions only — does not include other devices or claude.ai.' : null,
+ // The CLI's own caption is about ONE machine's sessions. It stands only
+ // until a federated peer contributes its reading — `mergeFleetQuotaCards`
+ // replaces it with what was actually combined.
+ note: data.approximate ? 'This machine only — other federated instances have not reported a reading yet.' : null,
fetchedAt: data.fetchedAt
};
}
@@ -647,8 +654,36 @@ const fetchFamilyQuota = (family, { wait, providers }) =>
* for exactly the one the user clicked instead of respawning every provider's
* TUI. A family id that isn't enabled resolves to an empty list — the caller
* reads that as "this card is gone", not as an error.
+ *
+ * The reading this machine takes is only ever part of the answer: a
+ * subscription is one account across every federated instance. Each card is
+ * therefore unified with what peers published for the same family before it is
+ * returned — see `lib/fleetQuotas.js` for the two merge rules.
*/
export async function getProviderQuotas({ wait = WAIT.CACHED, family = null } = {}) {
+ const cards = await readProviderQuotas({ wait, family });
+ // Publish this machine's reading to the fleet and fold in every peer's. The
+ // two are independent — the peer read excludes our own slot — so they
+ // overlap. Recording is awaited rather than fired off so a caller that
+ // immediately re-reads (the page's per-card Refresh) sees its own reading.
+ const [, peerEntries] = await Promise.all([
+ recordLocalQuotaCards(cards).catch((err) => {
+ console.error(`❌ Could not record local quota readings: ${err?.message || err}`);
+ }),
+ getApiBilledInstanceIds()
+ .then((excludeInstanceIds) => getFleetQuotaEntries({ excludeInstanceIds }))
+ .catch((err) => {
+ // A federation read that fails must not take the cards down with it — a
+ // this-machine-only card is a smaller loss than an empty usage page.
+ console.error(`❌ Could not read federated quota readings: ${err?.message || err}`);
+ return [];
+ }),
+ ]);
+ return mergeFleetQuotaCards(cards, peerEntries);
+}
+
+/** The local readings, before any federated merge. */
+async function readProviderQuotas({ wait, family }) {
const result = await getAllProviders();
const providers = Array.isArray(result) ? result : (result?.providers || []);
const enabled = providers.filter((p) => p?.enabled && p.ollamaBacked !== true && p.mtplxBacked !== true && p.llamaBacked !== true && p.vllmBacked !== true && p.sglangBacked !== true);