diff --git a/.bb/skills/verify-bb/features/plugin-provider-usage.md b/.bb/skills/verify-bb/features/plugin-provider-usage.md
index bc500ecf92..054f9cf942 100644
--- a/.bb/skills/verify-bb/features/plugin-provider-usage.md
+++ b/.bb/skills/verify-bb/features/plugin-provider-usage.md
@@ -4,7 +4,7 @@ Status: **2026-09-05: 2 passed, 1 partial/blocked**. See [the audit](../MAINTENA
## Setup and entry points
-Enable Provider usage; configure at least one provider advertising usage maintenance. Open its usage card and Settings → Usage.
+Enable Provider usage and a provider implementing the usage RPC contract. Open its usage card and Settings → Installed plugins → Provider usage.
Use the main skill’s isolated targets and evidence rules. A plugin can be present
in this checkout but disabled in an installation. Enable it only in the test
@@ -17,6 +17,11 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs.
- `plugins/provider-usage/package.json`
- `plugins/provider-usage/server.ts`
- `plugins/provider-usage/app.tsx`
+- `plugins/provider-codex/src/usage-source.ts`
+- `plugins/provider-claude-code/src/usage-source.ts`
+- `plugins/provider-acp/src/usage-source.ts`
+- `plugins/account-pool/src/usage-source.ts`
+- `plugins/provider-usage/settings.tsx`
## Feature recipes
@@ -24,7 +29,7 @@ SKILL.md. Inspect nested `--help` before selecting flags and IDs.
| --- | --- | --- |
| All capable providers | Refresh with two supported providers and one unsupported provider. | Cards show only supported data using current provider names/icons and configured ordering. |
| Quota windows and errors | Inspect real returned windows/resets and a controlled refresh failure. | Values match the provider response; unknown/unavailable data is distinct from exhausted quota. |
-| CLI and SDK parity | Compare bb settings usage --json with bb.sdk.system.usageLimits() and the visible card. | All surfaces represent the same underlying provider maintenance data. |
+| CLI and SDK parity | Inspect `bb plugin rpc list --method provider-usage.v1.listResources --json`, list a provider plugin’s resources, and fetch one returned resource ID. Compare its selected host/provider with `bb settings usage --json`. | Discovery is independent of display plugins, inventory collects no quota, and fetch returns only the chosen resource. Pool resources remain separate from direct host maintenance. |
## Evidence and cleanup
@@ -35,6 +40,15 @@ failed attempts and missing prerequisites as unverified results. Restore plugin
configuration and remove only this run’s fixtures, registrations, and workers.
External account changes use authorized disposable targets.
-## Maintenance notes
-
-- Open Settings → Usage limits and the sidebar Provider usage disclosure. Compare core settings usage / sdk.system.usageLimits with plugin getUsage, which wraps per-machine providers and normalizes optional fields; it is not byte-for-byte the core response. Source: `plugins/provider-usage/app.tsx:112`.
+## Contract verification
+
+- Test a source with the display plugin disabled. Each provider implementation
+ publishes both source methods independently of the display.
+- Check empty pools, loading, first-load errors, stale measurements after failed
+ refresh, disconnected hosts, and removed resources on both displays.
+- Verify matching window and plan labels, account order, default pool selection,
+ and explicit machine selection. Do not deduplicate by email; known account
+ identities are deduplicated only within the selected location.
+- The settings page fetches resources for the selected location; the footer
+ fetches only its selected provider tab. Compare shared pool sources through
+ their RPC contract; `bb settings usage` remains the direct host-maintenance view.
diff --git a/apps/app/.ladle/config.mjs b/apps/app/.ladle/config.mjs
index 4ec7d5759e..e3f3909cda 100644
--- a/apps/app/.ladle/config.mjs
+++ b/apps/app/.ladle/config.mjs
@@ -43,6 +43,7 @@ export default {
"src/**/*.stories.tsx",
"../../plugins/workflows/**/*.stories.tsx",
"../../plugins/ask-user-question/*.stories.tsx",
+ "../../plugins/provider-usage/*.stories.tsx",
],
defaultStory: "",
viteConfig: "./.ladle/vite.config.ts",
diff --git a/apps/app/.ladle/ladle.css b/apps/app/.ladle/ladle.css
index 2668db92df..33603501e0 100644
--- a/apps/app/.ladle/ladle.css
+++ b/apps/app/.ladle/ladle.css
@@ -7,6 +7,7 @@
the plugin's CSS separately. */
@source "../../../plugins/automations";
@source "../../../plugins/provider-retry";
+@source "../../../plugins/provider-usage";
.ladle-main {
padding: 0;
diff --git a/apps/app/src/App.legacy-skill-route.test.tsx b/apps/app/src/App.legacy-skill-route.test.tsx
index 770abde919..31edd4f108 100644
--- a/apps/app/src/App.legacy-skill-route.test.tsx
+++ b/apps/app/src/App.legacy-skill-route.test.tsx
@@ -77,6 +77,7 @@ describe("legacy resource redirects", () => {
);
it.each([
+ ["/settings/usage", "/settings/plugins/provider-usage"],
["/settings/plugins", "/settings/plugins"],
["/extensions?view=installed#catalog", "/plugins?view=installed#catalog"],
["/extensions/plugins", "/plugins"],
diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index a06b8ec2b1..491300bb1f 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -60,6 +60,7 @@ import {
getAutomationDetailRoutePath,
getAutomationEditRoutePath,
getAutomationsRoutePath,
+ getPluginConfigurationRoutePath,
getSettingsRoutePath,
getSettingsProjectRoutePath,
} from "./lib/route-paths";
@@ -264,6 +265,17 @@ export function AppRoutes() {
+
+ }
+ />
} />
{
expect((searchField() as HTMLInputElement).value).toBe(">");
const titles = optionTitles();
expect(titles?.[0]).toContain("New thread");
- expect(titles).toHaveLength(19);
+ expect(titles).toHaveLength(18);
});
it("filters as the user types and keeps the selection on a live row", async () => {
diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx
deleted file mode 100644
index 0f27f10164..0000000000
--- a/apps/app/src/components/settings/UsageLimitsSettingsSection.stories.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-import { useState, type ReactNode } from "react";
-import type { Host, ProviderInfo } from "@bb/domain";
-import { makeHost, makeProviderInfo } from "@bb/test-helpers/domain-fixtures";
-import { StoryCard, StoryRow } from "../../../.ladle/story-card";
-import {
- UsageLimitsSettingsSectionContent,
- type UsageLimitsSettingsSectionContentProps,
-} from "./UsageLimitsSettingsSection";
-
-export default {
- title: "settings/Usage Limits",
-};
-
-type Usage = UsageLimitsSettingsSectionContentProps["usage"];
-
-const noop = () => {};
-
-function futureIso(minutesFromNow: number): string {
- return new Date(Date.now() + minutesFromNow * 60_000).toISOString();
-}
-
-const HEALTHY_USAGE: Usage = {
- codex: {
- status: "ok",
- accountEmail: "sawyer@example.com",
- planLabel: "Pro",
- windows: [
- {
- label: "Weekly usage limit",
- usedPercent: 8,
- resetsAt: futureIso(5 * 24 * 60),
- },
- ],
- },
- "claude-code": {
- status: "ok",
- accountEmail: "sawyer@example.com",
- planLabel: "Max (20x)",
- windows: [
- {
- label: "Current session",
- usedPercent: 53,
- resetsAt: futureIso(187),
- },
- {
- label: "All models",
- usedPercent: 25,
- resetsAt: futureIso(67),
- },
- {
- label: "Fable",
- usedPercent: 48,
- resetsAt: futureIso(67),
- },
- ],
- },
- "acp-cursor": {
- status: "ok",
- accountEmail: "sawyer@example.com",
- planLabel: "Pro",
- windows: [
- {
- label: "Plan usage",
- usedPercent: 72,
- resetsAt: futureIso(14 * 24 * 60),
- },
- {
- label: "On-demand spend",
- usedPercent: 25,
- resetsAt: futureIso(14 * 24 * 60),
- cost: { usedUsdCents: 1_250, limitUsdCents: 5_000 },
- },
- ],
- },
-};
-
-const AUTH_USAGE: Usage = {
- codex: { status: "unauthenticated" },
- "claude-code": { status: "expired" },
- "acp-cursor": { status: "not_installed" },
-};
-
-const EMPTY_AND_ERROR_USAGE: Usage = {
- codex: {
- status: "ok",
- accountEmail: null,
- planLabel: "Team",
- windows: [],
- },
- "claude-code": {
- status: "error",
- message: "Claude usage is temporarily unavailable.",
- planLabel: "Max (5x)",
- accountEmail: null,
- },
- "acp-cursor": { status: "not_installed" },
-};
-
-const HOSTS: Host[] = [
- makeHost({
- id: "host-macbook",
- name: "MacBook Pro",
- lastSeenAt: 1_700_000_000_000,
- createdAt: 1,
- updatedAt: 2,
- }),
- makeHost({
- id: "host-studio",
- name: "Mac Studio",
- lastSeenAt: 1_700_000_000_000,
- createdAt: 1,
- updatedAt: 2,
- }),
- makeHost({
- id: "host-build",
- name: "Build machine",
- status: "disconnected",
- lastSeenAt: 1_700_000_000_000,
- createdAt: 1,
- updatedAt: 2,
- }),
-];
-
-function provider(id: string, displayName: string): ProviderInfo {
- return makeProviderInfo({
- id,
- displayName,
- logoUrl: null,
- maintenance: { health: true, usage: true, installation: false },
- capabilities: {
- supportsThreadArchive: false,
- supportsThreadRename: false,
- supportsServiceTier: false,
- supportsNativeUserQuestion: false,
- supportsFork: false,
- supportsSessionRewind: false,
- modelCatalogScope: "workspace",
- permissionModes: ["full"],
- },
- });
-}
-
-const PROVIDERS = [
- provider("codex", "Codex"),
- provider("claude-code", "Claude Code"),
- provider("acp-cursor", "Cursor"),
-];
-
-function Stage({ children }: { children: ReactNode }) {
- return {children}
;
-}
-
-type UsagePreviewProps = Pick &
- Partial<
- Pick<
- UsageLimitsSettingsSectionContentProps,
- | "hosts"
- | "isError"
- | "isFetching"
- | "isLoading"
- | "onSelectHost"
- | "selectedHostId"
- >
- >;
-
-function UsagePreview({
- usage,
- hosts,
- isError = false,
- isFetching = false,
- isLoading = false,
- onSelectHost,
- selectedHostId,
-}: UsagePreviewProps) {
- return (
-
-
-
- );
-}
-
-function MultipleMachinesPreview() {
- const [selectedHostId, setSelectedHostId] = useState(HOSTS[0]?.id ?? null);
-
- return (
-
- );
-}
-
-export function Usage() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx
deleted file mode 100644
index 1d12fddf30..0000000000
--- a/apps/app/src/components/settings/UsageLimitsSettingsSection.test.tsx
+++ /dev/null
@@ -1,339 +0,0 @@
-// @vitest-environment jsdom
-
-import type { ComponentProps } from "react";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import type { ProviderInfo } from "@bb/domain";
-import { makeHost, makeProviderInfo } from "@bb/test-helpers/domain-fixtures";
-import { TooltipProvider } from "@bb/shared-ui/tooltip";
-import { afterEach, describe, expect, it, vi } from "vitest";
-import { UsageLimitsSettingsSectionContent } from "./UsageLimitsSettingsSection";
-
-const primaryHost = makeHost({
- id: "host-primary",
- name: "MacBook Pro",
- lastSeenAt: 1,
- createdAt: 1,
- updatedAt: 1,
-});
-
-const remoteHost = makeHost({
- ...primaryHost,
- id: "host-remote",
- name: "Build machine",
-});
-
-function provider(
- id: string,
- displayName: string,
- supportsUsage = true,
- strings?: ProviderInfo["strings"],
-): ProviderInfo {
- return makeProviderInfo({
- id,
- displayName,
- logoUrl: null,
- maintenance: { health: true, usage: supportsUsage, installation: false },
- capabilities: {
- supportsThreadArchive: false,
- supportsThreadRename: false,
- supportsServiceTier: false,
- supportsNativeUserQuestion: false,
- supportsFork: false,
- supportsSessionRewind: false,
- modelCatalogScope: "workspace",
- permissionModes: ["full"],
- },
- ...(strings === undefined ? {} : { strings }),
- });
-}
-
-const FIRST_PARTY_PROVIDERS: ProviderInfo[] = [
- provider("codex", "Codex", true, {
- signInHint: "Run `codex` to sign in and see your usage.",
- expiredHint: "Your Codex session expired. Run `codex`, then reload usage.",
- installUrl: "https://developers.openai.com/codex/cli",
- }),
- provider("claude-code", "Claude Code", true, {
- signInHint: "Run `claude` to sign in and see your usage.",
- expiredHint:
- "Your Claude session expired. Run `claude`, then reload usage.",
- installUrl: "https://claude.com/claude-code",
- }),
- provider("acp-cursor", "Cursor", true, {
- signInHint: "Run `cursor-agent login` to sign in and see your usage.",
- expiredHint:
- "Your Cursor session expired. Run `cursor-agent login`, then reload usage.",
- installUrl: "https://cursor.com/docs/cli/installation",
- }),
-];
-
-afterEach(cleanup);
-
-function renderContent(
- props: ComponentProps,
-) {
- return render(
-
-
- ,
- );
-}
-
-describe("UsageLimitsSettingsSectionContent", () => {
- it("renders Cursor plan and on-demand limits", () => {
- renderContent({
- usage: {
- "acp-cursor": {
- status: "ok",
- accountEmail: "cursor@example.com",
- planLabel: "Pro",
- windows: [
- { label: "Plan usage", usedPercent: 50, resetsAt: null },
- {
- label: "On-demand spend",
- usedPercent: 10,
- resetsAt: null,
- cost: { usedUsdCents: 500, limitUsdCents: 5_000 },
- },
- ],
- },
- },
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- });
-
- expect(screen.getByRole("heading", { name: "Cursor" })).toBeDefined();
- expect(screen.getByRole("region", { name: "Cursor" })).toBeDefined();
- expect(screen.getByText("cursor@example.com")).toBeDefined();
- expect(screen.getByText("Plan usage")).toBeDefined();
- expect(screen.getByText("50% used")).toBeDefined();
- expect(screen.getByText("On-demand spend")).toBeDefined();
- expect(screen.getByText("$5.00 / $50")).toBeDefined();
- });
-
- it("hides an uninstalled provider", () => {
- renderContent({
- usage: {
- codex: { status: "unauthenticated" },
- "acp-cursor": { status: "not_installed" },
- },
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- });
-
- expect(screen.queryByRole("heading", { name: "Cursor" })).toBeNull();
- expect(screen.queryByText("Not installed on this machine.")).toBeNull();
- expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined();
- });
-
- it("keeps states without usage bars with the provider heading", () => {
- renderContent({
- usage: { codex: { status: "unauthenticated" } },
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- });
-
- const heading = screen.getByRole("heading", { name: "Codex" });
- const status = screen.getByText(/Run `codex` to sign in/u);
- expect(heading.parentElement?.contains(status)).toBe(true);
- });
-
- it("renders usage reported by a plugin provider", () => {
- renderContent({
- usage: {
- "echo-agent": {
- status: "ok",
- accountEmail: null,
- planLabel: "Team",
- windows: [
- { label: "Monthly messages", usedPercent: 25, resetsAt: null },
- ],
- },
- },
- providers: [provider("echo-agent", "Echo Agent")],
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- });
-
- expect(screen.getByRole("heading", { name: "Echo Agent" })).toBeDefined();
- expect(screen.getByText("Monthly messages")).toBeDefined();
- expect(screen.getByText("25% used")).toBeDefined();
- });
-
- it("renders supported registry providers in registry order", () => {
- renderContent({
- usage: { codex: { status: "unauthenticated" } },
- providers: [
- provider("echo-agent", "Echo Agent"),
- provider("no-usage", "No Usage", false),
- provider("codex", "Codex from registry"),
- ],
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- });
-
- expect(
- screen
- .getAllByRole("heading", { level: 3 })
- .map((heading) => heading.textContent),
- ).toEqual(["Echo Agent", "Codex from registry"]);
- expect(screen.queryByRole("heading", { name: "No Usage" })).toBeNull();
- expect(screen.getByText("Usage not provided.")).toBeDefined();
- });
-
- it("loads supported providers and hides unsupported providers", () => {
- renderContent({
- usage: {},
- providers: [
- provider("codex", "Codex"),
- provider("echo-agent", "Echo Agent", false),
- ],
- isLoading: true,
- isError: false,
- isFetching: true,
- onRefresh: vi.fn(),
- });
-
- expect(screen.getByRole("heading", { name: "Codex" })).toBeDefined();
- expect(screen.queryByRole("heading", { name: "Echo Agent" })).toBeNull();
- expect(screen.getByText("Loading usage…")).toBeDefined();
- expect(screen.queryByText("Usage not provided.")).toBeNull();
- });
-
- it("renders completed providers while their peers are still loading", () => {
- renderContent({
- usage: { codex: { status: "unauthenticated" } },
- providers: FIRST_PARTY_PROVIDERS.filter(
- (entry) => entry.id === "codex" || entry.id === "claude-code",
- ),
- providerStates: {
- codex: { isError: false, isLoading: false },
- "claude-code": { isError: false, isLoading: true },
- },
- isLoading: true,
- isError: false,
- isFetching: true,
- onRefresh: vi.fn(),
- });
-
- expect(screen.getByText(/Run `codex` to sign in/u)).toBeDefined();
- const claudeHeading = screen.getByRole("heading", {
- name: "Claude Code",
- });
- const loading = screen.getByText("Loading usage…");
- expect(claudeHeading.parentElement?.contains(loading)).toBe(true);
- });
-
- it("shows an initial loading message before the provider list arrives", () => {
- renderContent({
- usage: {},
- providers: [],
- isLoading: true,
- isError: false,
- isProviderListLoading: true,
- isFetching: true,
- onRefresh: vi.fn(),
- });
-
- expect(screen.getByText("Loading providers and usage…")).toBeDefined();
- });
-
- it("keeps provider rows visible when the usage request fails", () => {
- renderContent({
- usage: {},
- providers: [provider("echo-agent", "Echo Agent")],
- isLoading: false,
- isError: true,
- isFetching: false,
- onRefresh: vi.fn(),
- });
-
- expect(screen.getByRole("heading", { name: "Echo Agent" })).toBeDefined();
- expect(screen.getByText(/Couldn't load usage right now/u)).toBeDefined();
- });
-
- it("selects which connected machine supplies usage", () => {
- const onSelectHost = vi.fn();
- renderContent({
- usage: {},
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- hosts: [primaryHost, remoteHost],
- selectedHostId: primaryHost.id,
- onSelectHost,
- });
-
- const sectionHeader = screen
- .getByRole("heading", { name: "Usage limits" })
- .closest("section")?.firstElementChild;
- expect(sectionHeader?.classList.contains("flex-col")).toBe(true);
-
- fireEvent.pointerDown(
- screen.getByRole("button", { name: "Usage limits machine" }),
- { button: 0 },
- );
- fireEvent.click(screen.getByRole("menuitem", { name: /Build machine/u }));
-
- expect(onSelectHost).toHaveBeenCalledWith(remoteHost.id);
- });
-
- it("does not show a machine selector when there is only one machine", () => {
- renderContent({
- usage: {},
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: vi.fn(),
- hosts: [primaryHost],
- selectedHostId: primaryHost.id,
- onSelectHost: vi.fn(),
- });
-
- const sectionHeader = screen
- .getByRole("heading", { name: "Usage limits" })
- .closest("section")?.firstElementChild;
- expect(sectionHeader?.classList.contains("flex-row")).toBe(true);
- expect(sectionHeader?.classList.contains("flex-col")).toBe(false);
- expect(
- screen.queryByRole("button", { name: "Usage limits machine" }),
- ).toBeNull();
- });
-});
-
-describe("UsageLimitsSettingsSectionContent marks", () => {
- it("draws each provider's declared logo beside its usage block", () => {
- renderContent({
- providers: [
- {
- ...provider("codex", "Codex"),
- logoUrl: "/api/v1/system/providers/codex/logo",
- },
- ],
- usage: {},
- isLoading: false,
- isError: false,
- isFetching: false,
- onRefresh: () => {},
- });
- expect(
- document.querySelector(
- '[data-provider-logo="/api/v1/system/providers/codex/logo"]',
- ),
- ).not.toBeNull();
- });
-});
diff --git a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx b/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx
deleted file mode 100644
index ca6b5df5dc..0000000000
--- a/apps/app/src/components/settings/UsageLimitsSettingsSection.tsx
+++ /dev/null
@@ -1,456 +0,0 @@
-import { useId, useMemo, useState } from "react";
-import type { Host, ProviderInfo } from "@bb/domain";
-import type {
- ProviderUsage,
- ProviderUsageResponse,
- ProviderUsageWindow,
-} from "@bb/host-daemon-contract";
-import { Button } from "@bb/shared-ui/button";
-import { Icon } from "@bb/shared-ui/icon";
-import {
- SettingsBadge,
- SettingsRowList,
- SettingsSection,
-} from "@/components/ui/settings-section";
-import { MachineStatusDot } from "@/components/machines/MachineStatusDot";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@bb/shared-ui/dropdown-menu";
-import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip";
-import {
- useSystemConfig,
- useSystemProviderUsageLimits,
- useSystemProviders,
- type ProviderUsageQueryState,
-} from "@/hooks/queries/system-queries";
-import {
- selectHosts,
- selectPrimaryHost,
- useHosts,
-} from "@/hooks/queries/host-queries";
-import { getProviderIconInfo } from "@/lib/provider-icon";
-import { ProviderIconMark } from "./ProviderIconMark";
-import { cn } from "@bb/shared-ui/lib/utils";
-import {
- formatUsageReset,
- formatUsdCents,
- usageBarColorClass,
-} from "@bb/shared-ui/lib/usage-format";
-
-interface ProviderConfig {
- name: string;
- providerId: string;
- signInHint: string;
- expiredHint: string;
- strings: ProviderInfo["strings"];
- provider: ProviderInfo | undefined;
-}
-
-function providerConfig(
- providerId: string,
- info: ProviderInfo | undefined,
-): ProviderConfig {
- const name = info?.displayName ?? providerId;
- return {
- providerId,
- name,
- strings: info?.strings,
- provider: info,
- signInHint:
- info?.strings?.signInHint ?? `Sign in to ${name}, then reload usage.`,
- expiredHint:
- info?.strings?.expiredHint ??
- `Your ${name} session expired. Sign in again, then reload usage.`,
- };
-}
-
-function usageWindowValue(window: ProviderUsageWindow): string {
- if (!window.cost) {
- return `${window.usedPercent}% used`;
- }
- return `${formatUsdCents(window.cost.usedUsdCents, true)} / ${formatUsdCents(window.cost.limitUsdCents, false)}`;
-}
-
-function UsageWindowRow({ window }: { window: ProviderUsageWindow }) {
- const reset = formatUsageReset(window.resetsAt);
- return (
-
-
- {window.label}
-
- {usageWindowValue(window)}
-
-
-
- {reset ?
{reset}
: null}
-
- );
-}
-
-interface ProviderUsageBlockProps {
- config: ProviderConfig;
- usage: ProviderUsage | undefined;
- isLoading: boolean;
- isError: boolean;
-}
-
-export interface UsageLimitsSettingsSectionContentProps {
- usage: ProviderUsageResponse;
- isLoading: boolean;
- isError: boolean;
- isProviderListLoading?: boolean;
- isProviderListError?: boolean;
- isFetching: boolean;
- onRefresh: () => void;
- providerStates?: Readonly>;
- providers?: readonly ProviderInfo[];
- hosts?: readonly Host[];
- selectedHostId?: string | null;
- onSelectHost?: (hostId: string) => void;
-}
-
-function UsageMachinePicker({
- hosts,
- selectedHostId,
- onSelectHost,
-}: {
- hosts: readonly Host[];
- selectedHostId: string | null;
- onSelectHost: (hostId: string) => void;
-}) {
- const selectedHost =
- hosts.find((host) => host.id === selectedHostId) ?? hosts[0];
-
- return (
-
-
-
-
-
- {hosts.map((host) => {
- const connected = host.status === "connected";
- return (
- onSelectHost(host.id)}
- className="flex items-center gap-2"
- >
-
- {host.name}
- {host.id === selectedHost?.id ? (
-
- ) : null}
-
- );
- })}
-
-
- );
-}
-
-function ProviderUsageBlock({
- config,
- usage,
- isLoading,
- isError,
-}: ProviderUsageBlockProps) {
- const planLabel = usage?.status === "ok" ? usage.planLabel : null;
- const accountEmail = usage?.status === "ok" ? usage.accountEmail : null;
- const iconInfo = getProviderIconInfo(
- "agent",
- config.providerId,
- config.provider ?? null,
- );
- const ProviderIcon = iconInfo?.icon;
- const headingId = useId();
- const showsUsageWindows =
- !isError && usage?.status === "ok" && usage.windows.length > 0;
-
- return (
-
-
-
- {ProviderIcon ? (
-
-
-
- ) : null}
-
-
- {config.name}
-
- {accountEmail ? (
-
- {accountEmail}
-
- ) : null}
- {!showsUsageWindows ? (
-
- ) : null}
-
-
- {planLabel ?
{planLabel} : null}
-
- {showsUsageWindows ? (
-
- ) : null}
-
- );
-}
-
-function ProviderUsageBody({
- config,
- usage,
- isLoading,
- isError,
-}: ProviderUsageBlockProps) {
- if (isError) {
- return (
-
- Couldn't load usage right now. Make sure the selected machine is
- connected, then reload usage.
-
- );
- }
- if (!usage) {
- return (
-
- {isLoading ? "Loading usage…" : "Usage not provided."}
-
- );
- }
- switch (usage.status) {
- case "ok":
- if (usage.windows.length === 0) {
- return (
-
- No usage limits reported for this plan.
-
- );
- }
- return (
-
- {usage.windows.map((window) => (
-
- ))}
-
- );
- case "not_installed":
- return (
-
- Not installed on this machine.
-
- );
- case "unauthenticated":
- return (
- {config.signInHint}
- );
- case "expired":
- return (
- {config.expiredHint}
- );
- case "error":
- return {usage.message}
;
- default:
- return null;
- }
-}
-
-export function UsageLimitsSettingsSectionContent({
- usage,
- isLoading,
- isError,
- isProviderListLoading = false,
- isProviderListError = false,
- isFetching,
- onRefresh,
- providerStates = {},
- providers = [],
- hosts = [],
- selectedHostId = null,
- onSelectHost,
-}: UsageLimitsSettingsSectionContentProps) {
- const showMachinePicker = hosts.length > 1 && onSelectHost !== undefined;
- const providerById = new Map(
- providers.map((provider) => [provider.id, provider] as const),
- );
- const reportedProviderIds = Object.keys(usage);
- const orderedProviderIds = [
- ...providers
- .filter((provider) => provider.maintenance.usage)
- .map((provider) => provider.id),
- ...reportedProviderIds.filter(
- (providerId) => !providerById.has(providerId),
- ),
- ];
- const providerConfigs = orderedProviderIds
- .filter((providerId) => usage[providerId]?.status !== "not_installed")
- .map((providerId) =>
- providerConfig(providerId, providerById.get(providerId)),
- );
- const emptyMessage =
- isLoading || isProviderListLoading
- ? "Loading providers and usage…"
- : isError || isProviderListError
- ? "Couldn't load providers or usage right now."
- : "No providers available.";
- return (
-
- {showMachinePicker ? (
-
- ) : null}
-
-
-
-
- Reload usage data
-
-
- }
- >
-
- {providerConfigs.length === 0 ? (
- {emptyMessage}
- ) : (
- providerConfigs.map((config) => (
-
- ))
- )}
-
-
- );
-}
-
-export function UsageLimitsSettingsSection() {
- const systemConfigQuery = useSystemConfig();
- const hostsQuery = useHosts();
- const hosts = useMemo(
- () => selectHosts(hostsQuery.data, "persistent"),
- [hostsQuery.data],
- );
- const [selectedHostId, setSelectedHostId] = useState(null);
- const primaryHost = selectPrimaryHost(
- hosts,
- systemConfigQuery.data?.primaryHostId ?? null,
- );
- const selectedHost =
- hosts.find((host) => host.id === selectedHostId) ?? primaryHost;
- const usageHostId =
- selectedHost?.id ?? systemConfigQuery.data?.primaryHostId ?? undefined;
- const providersQuery = useSystemProviders(
- usageHostId === undefined
- ? {
- capability: "usage",
- enabled: systemConfigQuery.data !== undefined,
- }
- : {
- capability: "usage",
- enabled: systemConfigQuery.data !== undefined,
- hostId: usageHostId,
- },
- );
- const providers = providersQuery.data ?? [];
- const usageQuery = useSystemProviderUsageLimits({
- ...(usageHostId === undefined ? {} : { hostId: usageHostId }),
- enabled: systemConfigQuery.data !== undefined && providersQuery.isSuccess,
- providerIds: providers.map((provider) => provider.id),
- });
-
- return (
- {
- void usageQuery.refetch();
- }}
- providerStates={usageQuery.providerStates}
- providers={providers}
- hosts={hosts}
- selectedHostId={selectedHost?.id ?? null}
- onSelectHost={setSelectedHostId}
- />
- );
-}
diff --git a/apps/app/src/components/settings/settings-sections.ts b/apps/app/src/components/settings/settings-sections.ts
index fa9cb2537b..2614be7ad7 100644
--- a/apps/app/src/components/settings/settings-sections.ts
+++ b/apps/app/src/components/settings/settings-sections.ts
@@ -7,7 +7,6 @@ export const SETTINGS_NAV_SECTIONS = [
{ icon: "Palette", id: "appearance", label: "Appearance" },
{ icon: "SlidersHorizontal", id: "keyboard", label: "Keyboard" },
{ icon: "Browser", id: "browser", label: "Browser" },
- { icon: "ChartColumn", id: "usage", label: "Usage limits" },
{ icon: "File", id: "files", label: "Files" },
{ icon: "FolderGit", id: "projects", label: "Projects" },
{ icon: "Laptop", id: "machines", label: "Machines" },
diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx
index cdd1ddf921..afbe52040f 100644
--- a/apps/app/src/views/SettingsView.stories.tsx
+++ b/apps/app/src/views/SettingsView.stories.tsx
@@ -5,17 +5,13 @@ import {
defaultExperiments,
type AppTheme,
type Experiments,
- type Host,
defaultAppSettings,
type AppSettings,
} from "@bb/domain";
-import { makeHost } from "@bb/test-helpers/domain-fixtures";
import type {
- ProviderUsage,
WorkspaceOpenTarget,
WorkspaceOpenTargetId,
} from "@bb/host-daemon-contract";
-import { UsageLimitsSettingsSectionContent } from "@/components/settings/UsageLimitsSettingsSection";
import { VoiceInputSettingsSectionContent } from "@/components/settings/VoiceInputSettingsSection";
import { ArchivedThreadsSettingsSection } from "@/components/settings/ArchivedThreadsSettingsSection";
import { CommunitySettingsSection } from "@/components/settings/CommunitySettingsSection";
@@ -109,86 +105,6 @@ const connectedTargets: WorkspaceOpenTarget[] = [
defaultAppTarget,
];
-function futureIso(minutesFromNow: number): string {
- return new Date(Date.now() + minutesFromNow * 60_000).toISOString();
-}
-
-const usageFixture: {
- codex: ProviderUsage;
- "claude-code": ProviderUsage;
- "acp-cursor": ProviderUsage;
-} = {
- codex: {
- status: "ok",
- accountEmail: "sawyer@example.com",
- planLabel: "Pro",
- windows: [
- {
- label: "Current session",
- resetsAt: futureIso(136),
- usedPercent: 35,
- },
- {
- label: "Weekly limit",
- resetsAt: futureIso(48),
- usedPercent: 74,
- },
- ],
- },
- "claude-code": {
- status: "ok",
- accountEmail: "sawyer@example.com",
- planLabel: "Max (20x)",
- windows: [
- {
- label: "Current session",
- resetsAt: futureIso(179),
- usedPercent: 3,
- },
- {
- label: "Weekly limit",
- resetsAt: futureIso(4 * 24 * 60),
- usedPercent: 26,
- },
- ],
- },
- "acp-cursor": {
- status: "ok",
- accountEmail: "sawyer@example.com",
- planLabel: "Pro",
- windows: [
- {
- label: "Plan usage",
- resetsAt: futureIso(14 * 24 * 60),
- usedPercent: 72,
- },
- {
- label: "On-demand spend",
- resetsAt: futureIso(14 * 24 * 60),
- usedPercent: 25,
- cost: { usedUsdCents: 1_250, limitUsdCents: 5_000 },
- },
- ],
- },
-};
-
-const usageHosts: Host[] = [
- makeHost({
- id: "host-macbook",
- name: "MacBook Pro",
- lastSeenAt: Date.now(),
- createdAt: 1,
- updatedAt: 1,
- }),
- makeHost({
- id: "host-studio",
- name: "Mac Studio",
- lastSeenAt: Date.now(),
- createdAt: 1,
- updatedAt: 1,
- }),
-];
-
function useSettingsStoryState() {
const [themePreference, setThemePreference] =
useState("system");
@@ -367,27 +283,6 @@ function ExperimentsStory() {
);
}
-function UsageLimitsStory() {
- const [isFetching, setIsFetching] = useState(false);
- const [selectedHostId, setSelectedHostId] = useState("host-macbook");
-
- return (
- {
- setIsFetching(true);
- window.setTimeout(() => setIsFetching(false), 500);
- }}
- hosts={usageHosts}
- selectedHostId={selectedHostId}
- onSelectHost={setSelectedHostId}
- />
- );
-}
-
function ProvidersSettingsStory() {
const [generalSettings, setGeneralSettings] =
useState(defaultAppSettings);
@@ -429,8 +324,6 @@ function SettingsStoryContent({ route }: { route: SettingsStoryRoute }) {
return ;
case "keyboard":
return ;
- case "usage":
- return ;
case "files":
return ;
case "projects":
diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx
index 55847424be..d9457e2e4d 100644
--- a/apps/app/src/views/SettingsView.tsx
+++ b/apps/app/src/views/SettingsView.tsx
@@ -50,7 +50,6 @@ import {
} from "@/hooks/useTheme";
import { useHostDaemon, useLocalHostDaemonAccess } from "@/hooks/useHostDaemon";
import { useAppThemePreview } from "@/hooks/useAppThemePreview";
-import { UsageLimitsSettingsSection } from "@/components/settings/UsageLimitsSettingsSection";
import { ProvidersSettingsSection } from "@/components/settings/ProvidersSettingsSection";
import { CodeRendererSettings } from "@/components/settings/CodeRendererSettings";
import { SidebarThreadListSetting } from "@/components/settings/SidebarThreadListSetting";
@@ -1162,8 +1161,6 @@ export function SettingsView() {
onThemePreferenceChange={setPreferredTheme}
/>
);
- } else if (activeSection === "usage") {
- content = ;
} else if (activeSection === "keyboard") {
content = ;
} else if (activeSection === "browser") {
diff --git a/apps/cli/src/commands/plugin.ts b/apps/cli/src/commands/plugin.ts
index 99d8236088..fc6c563aa5 100644
--- a/apps/cli/src/commands/plugin.ts
+++ b/apps/cli/src/commands/plugin.ts
@@ -6,7 +6,7 @@ import { createInterface } from "node:readline/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { Command } from "commander";
import { z } from "zod";
-import { derivePluginId } from "@bb/domain";
+import { derivePluginId, jsonValueSchema } from "@bb/domain";
import { pluginCliCall, RESERVED_BB_CLI_COMMANDS } from "@bb/domain/plugin-cli";
import type {
InstalledPlugin as PluginEntry,
@@ -786,6 +786,107 @@ export function registerPluginCommands(
.description("Manage BB plugins")
.enablePositionalOptions();
+ const rpc = plugin
+ .command("rpc")
+ .description("Inspect discoverable plugin RPC methods");
+ rpc
+ .command("list [plugin-id]")
+ .option("--method ", "Filter by exact method name")
+ .option("--json", "Output JSON")
+ .action(
+ action(
+ async (
+ pluginId: string | undefined,
+ opts: JsonOutputOptions & { method?: string },
+ ) => {
+ const methods = await createCliBbSdk(
+ getUrl(),
+ ).plugins.experimental_discoverRpc({ pluginId, method: opts.method });
+ if (opts.json) {
+ outputJson(opts, methods);
+ return;
+ }
+ if (methods.length === 0) console.log("No discoverable RPC methods.");
+ for (const method of methods)
+ console.log(
+ `${method.pluginId} ${method.method} ${method.methodDescription ?? method.registrationDescription ?? ""}`,
+ );
+ },
+ ),
+ );
+ rpc
+ .command("call ")
+ .description("Call a plugin RPC method with server-side schema validation")
+ .option(
+ "--input-file ",
+ "Read JSON input from a file; defaults to null",
+ )
+ .option("--json", "Output JSON")
+ .action(
+ action(
+ async (
+ pluginId: string,
+ method: string,
+ opts: JsonOutputOptions & { inputFile?: string },
+ ) => {
+ const input =
+ opts.inputFile === undefined
+ ? null
+ : jsonValueSchema.parse(
+ JSON.parse(await readFile(opts.inputFile, "utf8")),
+ );
+ const result = await createCliBbSdk(getUrl()).plugins.callRpc({
+ pluginId,
+ method,
+ input,
+ outputSchema: jsonValueSchema,
+ });
+ if (opts.json) {
+ outputJson(opts, result);
+ return;
+ }
+ console.log(JSON.stringify(result, null, 2));
+ },
+ ),
+ );
+
+ rpc
+ .command("inspect [method]")
+ .option("--json", "Output JSON")
+ .action(
+ action(
+ async (
+ pluginId: string,
+ method: string | undefined,
+ opts: JsonOutputOptions,
+ ) => {
+ const methods = await createCliBbSdk(
+ getUrl(),
+ ).plugins.experimental_discoverRpc({ pluginId, method });
+ if (opts.json) {
+ outputJson(opts, methods);
+ return;
+ }
+ if (methods.length === 0) console.log("No discoverable RPC methods.");
+ for (const method of methods) {
+ console.log(`${method.pluginId} · ${method.method}`);
+ if (method.registrationDescription !== null)
+ console.log(method.registrationDescription);
+ if (method.methodDescription !== null)
+ console.log(method.methodDescription);
+ console.log(
+ "Input schema:",
+ JSON.stringify(method.inputSchema, null, 2),
+ );
+ console.log(
+ "Output schema:",
+ JSON.stringify(method.outputSchema, null, 2),
+ );
+ }
+ },
+ ),
+ );
+
plugin
.command("search ")
.description(
diff --git a/apps/server/src/routes/plugins.ts b/apps/server/src/routes/plugins.ts
index fb6a84f10c..871254bf67 100644
--- a/apps/server/src/routes/plugins.ts
+++ b/apps/server/src/routes/plugins.ts
@@ -35,6 +35,7 @@ import {
} from "./plugin-image-response.js";
import {
pluginApplyUpdateRequestSchema,
+ pluginRpcDiscoveryQuerySchema,
pluginInstallRequestSchema,
pluginSettingsUpdateRequestSchema,
pluginTokenRequestSchema,
@@ -380,6 +381,13 @@ export function registerPluginRoutes(
});
});
+ app.get("/plugins/rpc", (context) => {
+ const query = pluginRpcDiscoveryQuerySchema.safeParse(context.req.query());
+ if (!query.success)
+ return context.json({ error: "Invalid RPC discovery query" }, 400);
+ return context.json(plugins.discoverRpc(query.data));
+ });
+
app.get("/plugins", (context) => context.json({ plugins: plugins.list() }));
app.get("/plugins/contributions", (context) =>
diff --git a/apps/server/src/services/plugins/builtin-registry.ts b/apps/server/src/services/plugins/builtin-registry.ts
index f6146c9332..2d21e2804e 100644
--- a/apps/server/src/services/plugins/builtin-registry.ts
+++ b/apps/server/src/services/plugins/builtin-registry.ts
@@ -106,7 +106,7 @@ export const BUILTIN_PLUGINS = [
{
name: "provider-usage",
pluginId: "provider-usage",
- defaultEnabled: false,
+ defaultEnabled: true,
},
{
name: "provider-acp",
diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts
index 1be0f2061c..c1e6f37468 100644
--- a/apps/server/src/services/plugins/plugin-api.ts
+++ b/apps/server/src/services/plugins/plugin-api.ts
@@ -82,6 +82,7 @@ import {
normalizeMentionProviderRegistration,
normalizeRealtimePayload,
normalizeRpcRegistration,
+ publishRpcMethod,
normalizeWebSocketRouteRegistration,
pluginCliCollisionWarning,
registerSettingDescriptors,
@@ -182,6 +183,7 @@ export interface PluginWebSocketRouteRecord {
}
export interface PluginRpcHandler {
+ publication: ReturnType;
inputSchema: StandardSchemaV1;
outputSchema: StandardSchemaV1;
handler: (input: unknown) => unknown;
@@ -778,12 +780,13 @@ export function createPluginApi(options: {
};
const rpc: PluginRpc = {
- register(contract, handlers) {
+ register(contract, handlers, options) {
assertLive();
for (const [name, record] of normalizeRpcRegistration(
contract,
handlers,
rpcHandlers,
+ options,
)) {
rpcHandlers.set(name, record);
}
diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts
index bf8c863dd7..1a31e3f945 100644
--- a/apps/server/src/services/plugins/plugin-service.ts
+++ b/apps/server/src/services/plugins/plugin-service.ts
@@ -1,3 +1,7 @@
+import type {
+ PluginRpcDiscoveryQuery,
+ PublishedPluginRpcMethod,
+} from "@bb/server-contract";
import { watch } from "node:fs";
import { readFile, rm } from "node:fs/promises";
import { join } from "node:path";
@@ -298,6 +302,7 @@ export interface PluginService {
id: string,
path: string,
): PluginWireLookup;
+ discoverRpc(query: PluginRpcDiscoveryQuery): PublishedPluginRpcMethod[];
getRpcHandler(id: string, method: string): PluginWireLookup;
invokeHttpRoute(
id: string,
@@ -1724,6 +1729,32 @@ export function createPluginService(deps: PluginServiceDeps): PluginService {
);
},
+ discoverRpc(query) {
+ return [...loaded.entries()]
+ .flatMap(([pluginId, plugin]) => {
+ if (query.pluginId !== undefined && query.pluginId !== pluginId)
+ return [];
+ return [...plugin.handle.rpcHandlers.values()].flatMap(
+ ({ publication }) => {
+ if (
+ publication === null ||
+ (query.method !== undefined &&
+ publication.method !== query.method)
+ )
+ return [];
+ return [
+ { pluginId, displayName: plugin.manifest.name, ...publication },
+ ];
+ },
+ );
+ })
+ .sort(
+ (a, b) =>
+ a.pluginId.localeCompare(b.pluginId) ||
+ a.method.localeCompare(b.method),
+ );
+ },
+
getRpcHandler(id, method) {
return wireLookup(id, (plugin) => plugin.handle.rpcHandlers.get(method));
},
diff --git a/apps/server/test/helpers/test-app.ts b/apps/server/test/helpers/test-app.ts
index 1964d12f3a..197eec91c9 100644
--- a/apps/server/test/helpers/test-app.ts
+++ b/apps/server/test/helpers/test-app.ts
@@ -80,12 +80,16 @@ export type TestAppHarnessConfigOverrides = Partial & {
}[];
};
-export const testLogger = {
- debug(): void {},
- error(): void {},
- info(): void {},
- warn(): void {},
-};
+function createTestLogger() {
+ return {
+ debug(): void {},
+ error(): void {},
+ info(): void {},
+ warn(): void {},
+ };
+}
+
+export const testLogger = createTestLogger();
interface TestDaemonKeyParts {
hostId: string;
@@ -136,6 +140,7 @@ export async function createTestAppHarness(
seedFirstPartyProviders = true,
...configOverrides
} = overrides;
+ const logger = createTestLogger();
const dataDir = await mkdtemp(join(tmpdir(), "bb-server-test-"));
const db = createTestDb();
const hub = new NotificationHubImpl();
@@ -177,7 +182,7 @@ export async function createTestAppHarness(
const machineAuth = await createMachineAuthService({
dataDir,
db,
- logger: testLogger,
+ logger,
});
await machineAuth.ensureReady();
const testMachineAuth = {
@@ -220,13 +225,13 @@ export async function createTestAppHarness(
config,
db,
hub,
- logger: testLogger,
+ logger,
openTimeoutMs: 50,
});
const bbAppManagedConfig = await createBbAppManagedConfigReloader({
config,
hub,
- logger: testLogger,
+ logger,
});
const telemetry = createNoopTelemetryService();
const skillTreeRegistry = new SkillTreeRegistry();
@@ -236,7 +241,7 @@ export async function createTestAppHarness(
db,
hub,
lifecycleDedupers,
- logger: testLogger,
+ logger,
machineAuth: testMachineAuth,
providerRegistry,
pluginHostArtifacts,
@@ -250,7 +255,7 @@ export async function createTestAppHarness(
appVersionService ??
createAppVersionService({
config,
- logger: testLogger,
+ logger,
});
const deps: ServerAppDeps = {
appVersion,
@@ -259,7 +264,7 @@ export async function createTestAppHarness(
db,
hub,
lifecycleDedupers,
- logger: testLogger,
+ logger,
machineAuth: testMachineAuth,
pendingInteractions,
providerRegistry,
diff --git a/apps/server/test/public/public-host-management.test.ts b/apps/server/test/public/public-host-management.test.ts
index 99b5c45ed9..23b8a43fdf 100644
--- a/apps/server/test/public/public-host-management.test.ts
+++ b/apps/server/test/public/public-host-management.test.ts
@@ -531,6 +531,7 @@ describe("public host management", () => {
});
const revokeHandler = vi.fn(async () => ({ ok: true }));
const revokeRecord = {
+ publication: null,
inputSchema: z.object({ machineId: z.string() }),
outputSchema: z.object({ ok: z.literal(true) }),
handler: revokeHandler,
diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts
index 1490bacfe4..6886d7ac5f 100644
--- a/apps/server/test/services/plugins/builtin-plugins.test.ts
+++ b/apps/server/test/services/plugins/builtin-plugins.test.ts
@@ -551,11 +551,11 @@ describe("builtin plugin reconciliation", () => {
]);
});
- it("ships Provider usage disabled on a fresh database", async () => {
+ it("ships Provider usage enabled on a fresh database", async () => {
const providerUsage = BUILTIN_PLUGINS.find(
(builtin) => builtin.name === "provider-usage",
);
- expect(providerUsage?.defaultEnabled).toBe(false);
+ expect(providerUsage?.defaultEnabled).toBe(true);
service = createService({
db,
@@ -570,8 +570,8 @@ describe("builtin plugin reconciliation", () => {
{
id: "provider-usage",
source: "builtin:provider-usage",
- enabled: false,
- status: "disabled",
+ enabled: true,
+ status: "running",
},
]);
});
diff --git a/apps/server/test/services/plugins/plugin-sdk.test.ts b/apps/server/test/services/plugins/plugin-sdk.test.ts
index 2e57fad5e3..4e8fa42b63 100644
--- a/apps/server/test/services/plugins/plugin-sdk.test.ts
+++ b/apps/server/test/services/plugins/plugin-sdk.test.ts
@@ -131,6 +131,45 @@ describe("plugin bb.sdk bind gate", () => {
) => ({ pong: true }),
);
const disposePluginHost = vi.fn(async () => undefined);
+ it("discovers only published methods from live implementations and removes them on disable", async () => {
+ for (const id of ["usage-a", "usage-b"]) {
+ const rootDir = await writePlugin(workDir, {
+ name: `bb-plugin-${id}`,
+ serverSource: `export default function plugin() {}`,
+ });
+ await service.installPath(rootDir);
+ requireApi(service, id).rpc.register(
+ defineRpcContract({
+ "usage.v1.get": {
+ input: z.null(),
+ output: z.object({ percent: z.number() }),
+ experimental_description: "Current usage",
+ },
+ }),
+ { "usage.v1.get": () => ({ percent: 42 }) },
+ {
+ experimental_discoverable: true,
+ experimental_description: "Usage source",
+ },
+ );
+ requireApi(service, id).rpc.register(
+ { internal: { input: z.null(), output: z.null() } },
+ { internal: () => null },
+ );
+ }
+ expect(
+ service
+ .discoverRpc({ method: "usage.v1.get" })
+ .map((item) => item.pluginId),
+ ).toEqual(["usage-a", "usage-b"]);
+ expect(service.discoverRpc({ method: "internal" })).toEqual([]);
+ expect(service.discoverRpc({ pluginId: "usage-a" })).toHaveLength(1);
+ await service.setEnabled("usage-a", false);
+ expect(service.discoverRpc({}).map((item) => item.pluginId)).toEqual([
+ "usage-b",
+ ]);
+ });
+
beforeEach(async () => {
db = createConnection(":memory:");
migrate(db);
diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts
index 399bef0924..3eeb4b84ba 100644
--- a/apps/server/test/services/plugins/plugin-service.test.ts
+++ b/apps/server/test/services/plugins/plugin-service.test.ts
@@ -154,8 +154,12 @@ describe("plugin service", () => {
}
afterEach(async () => {
- await service.stop();
- await rm(workDir, { recursive: true, force: true });
+ try {
+ await service.stop();
+ await rm(workDir, { recursive: true, force: true });
+ } finally {
+ vi.restoreAllMocks();
+ }
});
it("installs a path plugin, runs its factory, and reports running", async () => {
diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md
index 02cca0c5ee..a108134eaa 100644
--- a/docs/api_to_audit.md
+++ b/docs/api_to_audit.md
@@ -1,5 +1,13 @@
# APIs To Audit
+## Discoverable RPC
+
+`bb.rpc.register` accepts optional `experimental_discoverable` and `experimental_description` options. Method definitions accept `experimental_description`. Discoverable registration exports wire schemas through Standard JSON Schema; validation-only schemas remain usable without publication. Descriptions are published separately and absent descriptions become null. Discovery advertises methods without changing RPC authorization or dispatch.
+
+`bb.sdk.plugins.experimental_discoverRpc({ pluginId?, method? })` lists published methods from loaded plugins. Methods disappear on unload; callers handle the race between discovery and invocation. The SDK RPC caller accepts an optional abort signal. The fake host exposes `experimental_publishedRpcMethods` on its registration inspection surface.
+
+Before stabilization, audit schema export fidelity (especially refinements and transforms), descriptor size and reference limits, lifecycle races, and cross-plugin copied-schema compatibility. Verify `bb plugin rpc list|inspect` is sufficient to implement a consumer without a shared contract package. Method names carry optional versions; there is no negotiation.
+
## `bb.http.experimental_websocket`
**What it does.** Registers an exact-path WebSocket upgrade in the plugin's
diff --git a/docs/configuration.md b/docs/configuration.md
index dc9f372324..cb5ac49605 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -918,6 +918,12 @@ timelines and large expanded timeline details retain stable height-preserving
wrappers while mounting only rows near their active scrollport. Toggle it with
`bb settings experiment timelineWindowing `.
+The `multiMachinePicker` experiment is off by default. When enabled, projects
+with at least three machines use a searchable, target-first environment picker,
+and machine-only pickers become searchable when they have more than five
+machines. Toggle it with `bb settings experiment multiMachinePicker
+`.
+
## Thread Timeline Window
Timeline pages select conversation groups using user-message anchors. The
diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts
index ef34adf279..f046078b5e 100644
--- a/packages/domain/src/plugin-sdk-version.ts
+++ b/packages/domain/src/plugin-sdk-version.ts
@@ -1,3 +1,3 @@
-export const PLUGIN_SDK_VERSION = "0.4.89";
+export const PLUGIN_SDK_VERSION = "0.4.90";
export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]);
diff --git a/packages/plugin-api-map/src/plugin-icons.ts b/packages/plugin-api-map/src/plugin-icons.ts
index 112fa91ca4..e2a4ef9fbb 100644
--- a/packages/plugin-api-map/src/plugin-icons.ts
+++ b/packages/plugin-api-map/src/plugin-icons.ts
@@ -6,6 +6,7 @@ import {
BrowserIcon,
CheckListIcon,
Calendar03Icon,
+ ChartColumnIcon,
Clock01Icon,
Coffee01Icon,
ComputerIcon,
@@ -34,6 +35,7 @@ interface FirstPartyPlugin {
}
const FIRST_PARTY_PLUGINS: Record = {
+ "Account Pooler [Experimental]": { id: "account-pool", icon: Layers01Icon },
"Ask User Question": { id: "ask-user-question", icon: MessageQuestionIcon },
Automations: { id: "automations", icon: RepeatIcon },
"Custom instructions": { id: "custom-instructions", icon: Edit04Icon },
@@ -43,6 +45,7 @@ const FIRST_PARTY_PLUGINS: Record = {
"Keep Awake": { id: "keep-awake", icon: Coffee01Icon },
Memory: { id: "memory", icon: BrainIcon },
"Provider retry": { id: "provider-retry", icon: ArrowReloadHorizontalIcon },
+ "Provider usage": { id: "provider-usage", icon: ChartColumnIcon },
"Push notifications": { id: "push-notifications", icon: BellDotIcon },
"Remote access": { id: "connect", icon: SmartPhone01Icon },
Secrets: { id: "secrets", icon: LockIcon },
diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts
index cc5c20d354..2b31584aaf 100644
--- a/packages/plugin-api-map/src/surfaces.ts
+++ b/packages/plugin-api-map/src/surfaces.ts
@@ -659,11 +659,14 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [
"Connects the plugin's own UI, its server code, and outside services. With this, a plugin can:",
bullets: [
"Call its server from its UI over RPC, with arguments and results checked against a schema",
+ "Publish RPC methods with experimental_discoverable and registration/method experimental_description; other plugins discover implementations and copy their published JSON Schemas using bb plugin rpc inspect",
"Serve exact-path HTTP and WebSocket routes other systems can call, webhooks included",
"Push messages to every open bb window, so the UI does not have to poll",
],
apiSymbols: [
"PluginRpc",
+ "PluginRpcMethodContract",
+ "PluginsArea.experimental_discoverRpc",
"PluginHttp",
"PluginRealtime",
"ExperimentalPluginWebSocket",
diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json
index 4a490ec2aa..e8d03b1997 100644
--- a/packages/plugin-sdk/package.json
+++ b/packages/plugin-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@get-bb/plugin-sdk",
- "version": "0.4.89",
+ "version": "0.4.90",
"homepage": "https://github.com/get-bb/bb#readme",
"bugs": {
"url": "https://github.com/get-bb/bb/issues"
diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts
index 7188340c09..94bf6fb03f 100644
--- a/packages/plugin-sdk/src/backend-contract.ts
+++ b/packages/plugin-sdk/src/backend-contract.ts
@@ -824,6 +824,10 @@ export interface PluginRpc {
register(
contract: Contract,
handlers: PluginRpcHandlers,
+ options?: {
+ experimental_discoverable?: boolean;
+ experimental_description?: string;
+ },
): void;
}
diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts
index 34bbf47bb7..b372ae2dc3 100644
--- a/packages/plugin-sdk/src/internal/host-policy.ts
+++ b/packages/plugin-sdk/src/internal/host-policy.ts
@@ -1688,6 +1688,20 @@ export function isStandardSchema(value: unknown): value is StandardSchemaV1 {
);
}
+const rpcDescriptionSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(4096)
+ .optional()
+ .transform((value) => value ?? null);
+const rpcPublicationOptionsSchema = z
+ .object({
+ experimental_discoverable: z.boolean().default(false),
+ experimental_description: rpcDescriptionSchema,
+ })
+ .strict();
+
function readRpcMethodContract(
method: string,
value: unknown,
@@ -1709,7 +1723,80 @@ function readRpcMethodContract(
`rpc method "${method}" output must be a Standard Schema v1 validator`,
);
}
- return { input, output };
+ const description = rpcDescriptionSchema.parse(
+ Reflect.get(value, "experimental_description"),
+ );
+ return description === null
+ ? { input, output }
+ : { input, output, experimental_description: description };
+}
+
+export function readRpcPublicationOptions(value: unknown) {
+ return rpcPublicationOptionsSchema.parse(value ?? {});
+}
+
+function publishedRpcSchema(
+ schema: StandardSchemaV1,
+ direction: "input" | "output",
+) {
+ const converter = schema["~standard"].jsonSchema;
+ if (converter === undefined || typeof converter[direction] !== "function") {
+ throw new Error(
+ "discoverable RPC requires Standard JSON Schema export support",
+ );
+ }
+ const serialized = JSON.stringify(
+ converter[direction]({ target: "draft-2020-12" }),
+ );
+ if (
+ serialized === undefined ||
+ new TextEncoder().encode(serialized).byteLength > 128 * 1024
+ ) {
+ throw new Error("published RPC schema must be JSON and at most 128 KiB");
+ }
+ const result = z
+ .record(z.string(), jsonValueSchema)
+ .parse(JSON.parse(serialized));
+ const inspect = (value: JsonValue): void => {
+ if (value === null || typeof value !== "object") return;
+ if (Array.isArray(value)) {
+ for (const item of value) inspect(item);
+ return;
+ }
+ for (const [key, item] of Object.entries(value)) {
+ if (
+ (key === "$ref" || key === "$dynamicRef") &&
+ typeof item === "string" &&
+ !item.startsWith("#")
+ ) {
+ throw new Error("published RPC schemas must use local references");
+ }
+ inspect(item);
+ }
+ };
+ inspect(result);
+ return result;
+}
+
+export function publishRpcMethod(
+ method: string,
+ contract: PluginRpcMethodContract,
+ options: ReturnType,
+) {
+ if (!options.experimental_discoverable) return null;
+ try {
+ return {
+ method,
+ registrationDescription: options.experimental_description,
+ methodDescription: contract.experimental_description ?? null,
+ inputSchema: publishedRpcSchema(contract.input, "input"),
+ outputSchema: publishedRpcSchema(contract.output, "output"),
+ };
+ } catch (error) {
+ throw new Error(
+ `rpc method "${method}" cannot be published: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
}
/** Duck-typed zod detection: plugin sources may carry their own zod copy,
@@ -2792,6 +2879,7 @@ export function normalizeWebSocketRouteRegistration(
}
type RpcRegistrationRecord = {
+ publication: ReturnType;
inputSchema: StandardSchemaV1;
outputSchema: StandardSchemaV1;
handler: (input: unknown) => unknown;
@@ -2801,6 +2889,7 @@ export function normalizeRpcRegistration(
contract: unknown,
handlers: unknown,
registered: ReadonlyMap,
+ options: unknown,
): Array<[string, RpcRegistrationRecord]> {
if (
typeof contract !== "object" ||
@@ -2816,6 +2905,7 @@ export function normalizeRpcRegistration(
) {
throw new Error("rpc.register handlers must be an object");
}
+ const publicationOptions = readRpcPublicationOptions(options);
const pending: Array<[string, RpcRegistrationRecord]> = [];
const contractEntries = Object.entries(contract);
const contractNames = new Set(contractEntries.map(([name]) => name));
@@ -2843,6 +2933,7 @@ export function normalizeRpcRegistration(
pending.push([
name,
{
+ publication: publishRpcMethod(name, methodContract, publicationOptions),
inputSchema: methodContract.input,
outputSchema: methodContract.output,
handler,
diff --git a/packages/plugin-sdk/src/rpc-contract.ts b/packages/plugin-sdk/src/rpc-contract.ts
index 46d2345ebb..7e1d45a5c9 100644
--- a/packages/plugin-sdk/src/rpc-contract.ts
+++ b/packages/plugin-sdk/src/rpc-contract.ts
@@ -37,6 +37,10 @@ export interface StandardSchemaV1 {
) =>
| StandardSchemaV1Result
) : (
-
+
{usage.windows.map((window) => (
))}
@@ -235,20 +289,18 @@ function MachineSelector({
OPTION_BASE_CLASS_NAME,
OPTION_INTERACTIVE_CLASS_NAME,
LIST_HOVER_TRANSITION,
- "h-7 max-w-32 px-1 text-sidebar-foreground hover:bg-sidebar-accent",
+ "h-7 shrink overflow-hidden px-1 text-sidebar-foreground hover:bg-sidebar-accent",
)}
>
-
-
- {activeMachine?.displayName ?? "No machines"}
-
+
+ {activeMachine?.displayName ?? "Usage"}
{machines.map((machine) => {
const isActive = machine.id === activeMachine?.id;
@@ -259,27 +311,14 @@ function MachineSelector({
aria-label={machine.displayName}
aria-checked={isActive}
onSelect={() => onSelect(machine.id)}
- className={cn(
- "flex items-center justify-between gap-3",
- LIST_HOVER_TRANSITION,
- )}
+ className="flex items-center gap-2"
>
-
-
- {machine.displayName}
- {machine.status === "disconnected" ? (
-
- Offline
-
- ) : null}
+
+
+ {machine.displayName}
{
+ const timer = window.setInterval(
+ () => refreshCountdowns((tick) => tick + 1),
+ 60_000,
+ );
+ return () => window.clearInterval(timer);
+ }, []);
const machines = snapshot.data?.machines ?? [];
- const { threadId } = useBbContext();
- const sidebarThreads = experimental_useSidebarThreads();
- const threadMachineId = useMemo(
- () =>
- sidebarThreads.threads.find((thread) => thread.id === threadId)?.host
- ?.id ?? null,
- [sidebarThreads.threads, threadId],
- );
const [requestedMachineId, setRequestedMachineId] = useState(
lastMachineId,
);
const [requestedProviderIds, setRequestedProviderIds] = useState(
lastProviderIdByMachine,
);
- const activeMachine =
- machines.find((machine) => machine.id === requestedMachineId) ??
- machines.find((machine) => machine.id === threadMachineId) ??
- machines.find((machine) => machine.status === "connected") ??
- machines[0] ??
- null;
- const providers = activeMachine?.providers ?? [];
+ const activeMachine = selectUsageMachine(
+ machines,
+ requestedMachineId,
+ threadMachineId,
+ );
+ const providers = useMemo(() => {
+ const groups = new Map<
+ string,
+ UsageProvider & { accounts: UsageProvider[] }
+ >();
+ for (const account of activeMachine?.providers ?? []) {
+ if (account.usage?.status === "not_installed") continue;
+ const group = groups.get(account.providerId);
+ if (group) group.accounts.push(account);
+ else
+ groups.set(account.providerId, {
+ ...account,
+ id: account.providerId,
+ accounts: [account],
+ });
+ }
+ return [...groups.values()];
+ }, [activeMachine]);
const requestedProviderId =
activeMachine === null
? null
@@ -335,16 +392,48 @@ function ProviderUsageStatus({
providers.find((provider) => provider.id === requestedProviderId) ??
providers[0] ??
null;
+ const activeAccounts = activeProvider?.accounts ?? [];
+ const hasActiveUsage = hasReportedUsage(activeAccounts);
+ const feedback =
+ activeMachine === null
+ ? snapshot.error !== null
+ ? usageFeedbackMessages.loadFailed
+ : snapshot.isRefreshing
+ ? usageFeedbackMessages.loading
+ : usageFeedbackMessages.noSources
+ : activeMachine.status === "disconnected"
+ ? offlineUsageMessage(activeMachine, hasActiveUsage)
+ : snapshot.error !== null || activeMachine.error !== null
+ ? hasActiveUsage
+ ? usageFeedbackMessages.refreshFailed
+ : usageFeedbackMessages.loadFailed
+ : activeProvider === null
+ ? emptyUsageMessage(activeMachine)
+ : null;
const panelId = useId();
const activeMachineId = activeMachine?.id ?? null;
+ const activeProviderId = activeProvider?.id ?? null;
useEffect(() => {
- void refreshUsage({
- force: false,
- machineIds: activeMachineId === null ? null : [activeMachineId],
- maxAgeMs: CARD_MAX_AGE_MS,
- });
- }, [activeMachineId]);
+ if (!refreshEnabled) return;
+ if (activeMachineId === null || activeProviderId === null) return;
+ const refresh = () => {
+ if (document.visibilityState === "hidden") return;
+ void refreshUsage({
+ force: false,
+ machineIds: [activeMachineId],
+ providerId: activeProviderId,
+ maxAgeMs: CARD_MAX_AGE_MS,
+ });
+ };
+ refresh();
+ const timer = window.setInterval(refresh, CARD_MAX_AGE_MS);
+ window.addEventListener("focus", refresh);
+ return () => {
+ window.clearInterval(timer);
+ window.removeEventListener("focus", refresh);
+ };
+ }, [activeMachineId, activeProviderId, refreshEnabled]);
const selectMachine = useCallback((machineId: string) => {
lastMachineId = machineId;
@@ -391,28 +480,35 @@ function ProviderUsageStatus({
};
return (
-
+
- {providers.length === 0 ? (
-
- ) : (
+ {providers.length === 0 ? null : (
{providers.map((provider, index) => {
const isActive = provider.id === activeProvider?.id;
- const tone = providerUsageTone(provider);
+ const tones = provider.accounts.map(providerUsageTone);
+ const tone = tones.includes("critical")
+ ? "critical"
+ : tones.includes("warning")
+ ? "warning"
+ : null;
return (